GeezerVison is showing github.com — Add transport registry and support for external plugins by rytilahti · Pull Request #1810 · python-kasa/python-kasa
Conversation
rytilahti
commented
Oct 24, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
Choosing the protocol, transport and device class for a device was spread over:
- two string-keyed tables,
- special cases for some families,
- a login-version check done after the lookup,
- a separate filtered cross product in
try_connect_all.
This PR replaces all of that with one registry, and lets other packages register transports through an entry point.
Registry (kasa/registry.py)
- A
Registry class holds the entries and the lookups that use them: register_transport, lookup, get_protocol, get_device_class and load_plugins. The shared instance is kasa.registry.REGISTRY.
- Each entry has a family (
IOT/SMART or a full family such as SMART.IPCAMERA), an encryption type and https. Encryption and https can be left unset to match any value.
- The most specific matching entry wins: full family first, then encryption type, then https. Registering the same parameters again replaces the earlier entry.
- A new
Registry() starts with the built-in transports.
- The device class now follows from the protocol, so there is no second table.
get_device_class_from_family is removed, together with the strict and require_exact options.
- Both discovery paths now map the response to
DeviceConnectionParameters, then look up the protocol and device class. The legacy mapping is now its own function, _get_connection_parameters_legacy.
try_connect_all tries a fixed, ordered list of the same 8 combinations as before. It creates them one at a time, and tries IOT XOR without a login version, which XOR does not use.
Plugins
- Packages register in the
kasa.plugins entry point group with a callable that takes the registry, setup(registry), and calls registry.register_transport(...). It is called when kasa is imported.
- A plugin can add a new encryption type, which becomes a
DeviceEncryptionType member so that stored configs deserialise. It can also replace or override an existing transport.
- If a plugin raises, it is skipped and anything it registered before failing is removed.
- A warning is logged for each existing transport that a plugin actually takes devices over from.
KASA_DISABLE_PLUGINS turns plugin loading off.
try_connect_all uses a plugin's transport when it replaces one of the tried combinations, but never tries transports for new encryption types.
Behaviour changes
- https SMART devices: a SMART family other than the hub with AES over https now gets
SmartCamDevice instead of the generic SmartDevice fallback. This matches the protocol that was already chosen for it. Real devices are not affected.
- IOT XOR login version: a device found by
try_connect_all with IOT XOR now stores login_version=None instead of 2.
- Keyword-only arguments:
DeviceConnectionParameters arguments must now be passed by keyword.
- Deprecated:
kasa.device_factory.get_protocol still works but warns, use kasa.registry.REGISTRY.get_protocol instead.
Tests
tests/test_registry.py uses its own Registry per test and covers:
- which entry is chosen when several match,
- the device class derived from the protocol,
- adding an encryption type,
- failing plugins and interrupts,
- override warnings,
KASA_DISABLE_PLUGINS.
test_try_connect_all_attempts checks that attempts stop at the first success, all resolve to a protocol and device class, and are not repeated; the fixture-based test_discover_try_connect_all checks that the combinations reach every known device.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
The device family and encryption type are easy to confuse with the login version and https flag when passed positionally,
naming them at every call site makes the intent explicit.
This breaks callers that pass them positionally.
Both discovery paths now map the response to connection parameters in a dedicated function.
No functional change.
The cross product of encryption types, families, https and login versions had to be filtered through strict lookups to arrive at the eight combinations that were actually tried,
an explicit list is easier to follow and keeps the order stable.
- Drop the strict and require_exact options, which only existed for this.
- Create the attempts one at a time.
- Try IOT XOR without a login version, which it does not use.
The choice was spread over two string-keyed tables, special cases for some families and a version check after the lookup.
A registry where the most specific registration wins makes it declarative and resolves the protocol and device class together.
- Register all built-in transports in the registry.
- Derive the device class from the protocol instead of a second table.
- Replace get_device_class_from_family with get_device_class.
- Let a registration replace an earlier one for the same parameters.
- Give combinations that never occur on real devices a consistent device class.
Other packages can now add a transport for a new encryption type, or replace the transport of an existing one, without changes to python-kasa.
They register it through the kasa.plugins entry point when kasa is imported.
A plugin that fails to load is skipped.
A warning is logged when a plugin takes devices over from an existing transport.
KASA_DISABLE_PLUGINS turns plugins off.
get_protocol returns None when no entry matches, which mypy flagged on every direct _transport access.
A plugin that raised after registering some transports kept them, so it was only partially skipped and could still replace built-in transports.
Restore the entries from before the plugin when it fails.
The override warning compared each new entry with every older entry it overlapped, so a single override also warned about entries that were already shadowed for those devices.
A plugin registering SMART.IPCAMERA AES logged three warnings where one transport was replaced.
Compare the lookup result for every family, encryption type and https combination before and after each plugin instead, and warn once per replaced entry.
rytilahti
changed the title
Choose transports from a registry and support transport plugins
Add transport registry and support for external plugins
Oct 24, 2026
codecov
Bot
commented
Oct 24, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
|
❌ Patch coverage is 94.81481% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.48%. Comparing base (a4fe376) to head (a479b51).
⚠️ Report is 5 commits behind head on master.
Additional details and impacted files
@@ Coverage Diff @@
## master #1810 +/- ##
==========================================
+ Coverage 93.43% 93.48% +0.05%
==========================================
Files 159 161 +2
Lines 10116 10164 +48
Branches 1044 1041 -3
==========================================
+ Hits 9452 9502 +50
- Misses 471 473 +2
+ Partials 193 189 -4
☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.
🚀 New features to boost your workflow:
- ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
The registry was a module-level list with free functions, and plugins called kasa.registry.register_transport directly.
A Registry object holds the entries together with the lookups that use them, and is passed to the plugin entry point as setup(registry).
Tests now use their own Registry instance instead of saving and restoring the module state.
- Move the built-in registrations, get_protocol and get_device_class from device_factory into Registry.
- Use the shared kasa.registry.REGISTRY instance in discovery, device_factory and dump_devinfo.
- Keep device_factory.get_protocol as a deprecated wrapper.
- Drop the warning on replacing a registration, plugin overrides are already reported by load_plugins.
- Compute the override warnings from a single snapshot of the lookup results.
The test listed the same eight connection parameters as discover.py, so it only checked that the two copies matched.
Check instead that attempts stop at the first success, report each result once, all resolve to a protocol and device class, and are not repeated.
The fixture-based test_discover_try_connect_all still checks that the combinations reach every known device.
This was referenced Oct 29, 2026
|
Nice work, this reads a lot better than the old tables. I ran it against master, tried a few plugins with real entry points, and packaged TPAP (#1592) as a plugin on top of it to see how a real transport fits. Notes below, most important first.
What I ran (a479b51 merged with master bd4826f, merges clean)
- Full suite passes on 3.11 to 3.15 (24458 passed on each).
- All 252 family × encryption × https ×
login_version combinations get the same protocol and transport as on master, and the 107 fixtures with a discovery result come out identical.
- A few device classes change, but only for combinations no device reaches (cameras and doorbells without https, Tapo hubs with KLAP over https), and each now matches its protocol. Maybe worth a line in the description.
try_connect_all makes the same 8 attempts as master, now in a fixed order. Master's order depended on PYTHONHASHSEED, so that's an improvement.
Needs fixing
1. import kasa can blow up
Plugins load during import kasa (kasa/__init__.py:87), but two parts of load_plugins are outside the try. Either of these makes import kasa itself raise, which takes out the CLI and the HA integration:
- A malformed
entry_points.txt in any installed package, plugin or not, since entry_points(group="kasa.plugins") (registry.py:204) parses all of them. A line without = gives TypeError: Pair.__new__() missing 1 required positional argument: 'value', and non-UTF-8 bytes give a UnicodeDecodeError. Neither names the package. Unlikely, but master imports fine with the same package installed.
- A plugin that passes a member name instead of its value, like
register_transport("SMART", "Klap", ...) rather than "KLAP". That overwrites _member_map_["Klap"], so the real KLAP member drops out of iteration, and the override check (registry.py:217-221) raises KeyError from import kasa. Overriding with "KLAP" itself works fine: I swapped SMART KLAP and the camera transport for subclasses, and both were picked up, with the override warning.
Putting the entry point lookup, _choices() and the override check in the same try as the plugin call would fix the first. For the second, requiring encryption values to be uppercase would do it. The built-in names are CamelCase, and everything devices report is uppercase (KLAP, AES, and TPAP with #1592), so a value in any other case could never match discovery anyway.
2. Enum members added by plugins
register_transport adds DeviceEncryptionType members by hand (registry.py:146-152), and they don't behave like real members. With a plugin adding NEWENC:
|
3.11 |
3.12 |
3.13 |
3.14 |
3.15 |
DeviceEncryptionType.NEWENC |
works |
AttributeError |
AttributeError |
AttributeError |
AttributeError |
"NEWENC" in DeviceEncryptionType |
TypeError |
True |
False |
False |
False |
DeviceEncryptionType("NEWENC"), ["NEWENC"], pickle |
work |
work |
work |
work |
work |
("KLAP" in DeviceEncryptionType is True on 3.12+, so the False on 3.13+ only hits added members.)
- Rollback leaves the member behind. A plugin that registers
FAILENC and then raises has its entries dropped, but FAILENC stays in the enum. It shows up in kasa --encrypt-type, and a stored config with it loads, then fails at connect with UnsupportedDeviceError. And since the enum is global, even a separate Registry() adds to it. The autouse _restore_encryption_types fixture in tests/test_registry.py hides both.
- No validation. A typo, a lowercase family or an old
"SMART.TAPOPLUG.HTTPS" key is accepted and never matches. The uppercase check from point 1 covers encryption values. Checking the family against DeviceFamily (or IOT/SMART) would cover the rest.
3. try_connect_all and discovery
- Shared parameter objects. The device
try_connect_all returns has the module-level _TRY_CONNECT_PARAMETERS entry as its config.connection_type. So changing it on the device (I set login_version to 99) changes what the next try_connect_all tries. Master built new ones on each call, and dataclasses.replace(conn_params) would fix it.
- A plugin transport that raises takes things down.
REGISTRY.get_protocol(config) runs before the try (discover.py:688). With a plugin transport that raises RuntimeError for SMART AES, try_connect_all raised it after the IOT KLAP, KLAP v2 and XOR attempts and never tried the other five. In UDP discovery the same exception ends up in the event loop's exception handler and the device just disappears: discover() doesn't list it, and discover_single says "Timed out getting discovery response".
- A protocol with no device class crashes the CLI. Master skipped rows without a class. Now a plugin protocol that isn't a
SmartProtocol subclass gives ConnectAttempt(device=None), and kasa discover config fails with 'NoneType' object has no attribute '__name__' (cli/discover.py:266).
4. Breaking changes, labelled enhancement
The changelog now groups by label (#1809), so with only enhancement these won't show up as breaking:
@dataclass(kw_only=True) on DeviceConnectionParameters makes every positional call a TypeError, including through the deprecated kasa.ConnectionType alias.
- The
get_protocol shim dropped strict, so get_protocol(config, strict=True) is a TypeError instead of a deprecation warning. No test calls the shim.
get_device_class_from_family is gone without a shim, and so are the 14 names kasa.device_factory used to re-export (SmartDevice, SmartProtocol, the transports and so on).
HA's tplink integration uses none of these, so it's only other users. The breaking change label would cover it. Shims for strict and the removed names (a module __getattr__, like kasa/__init__.py has) would keep them working for a release, if you want that.
TPAP (#1592) as a plugin
I packaged #1592's transport as a kasa.plugins plugin on top of this to see how a real transport fits. The transport only needed a few small changes to live outside kasa (absolute imports, its own TpapParameters, and a raw-value check for -2203, which has no SmartErrorCode). setup() is five register_transport lines: SMART, cameras and doorbells (their any-encryption entries would win otherwise), hubs over https, and IOT over http.
The routing works well. All 28 family × https combinations with TPAP get the same protocol and transport as #1592 on master, and nothing built in gets replaced. Against a fake TPAP device answering from the KP125M fixture, these all work: discovery then update(), reconnecting from a stored config with the credentials hash (as HA does), kasa --host … state, and --encrypt-type TPAP.
What a plugin can't do, and #1592 does in kasa itself:
- Pick TPAP from the discovery reply. Like the Tapo app, TPAP Implementation #1592 uses TPAP when the UDP reply has a
tpap object, whatever encrypt_type says. Cameras advertise AES next to it (the C101 fixture in the repo, a C510W fw 1.3.4 reply), so with the plugin they stay on SslAesTransport.
- Use the values in that object. TPAP Implementation #1592 keeps tls, port, pake, dac, user_hash_type and the MAC in the connection parameters, so the login uses them and they're stored with the config. A plugin can't add that field, so it has to ask the device again with the in-band discover, which cameras refuse.
- Add
try_connect_all attempts. TPAP Implementation #1592 tries TPAP last and stops after a refused login, since every TPAP login with a wrong password counts toward the device's lockout. With the fixed _TRY_CONNECT_PARAMETERS, a TPAP-only device isn't found when discovery fails.
- Smaller: the -2203 error code, masking the session token in
HttpClient's debug log, CLI options.
TPAP stays in #1592 either way. But I think the registry should carry everything a transport brings, not just the route. Right now a plugin can only answer "which transport for these parameters", and everything that builds the parameters is hard-coded. If a plugin can add a transport, it should also be able to add:
- its
try_connect_all attempts, including where they go in the order and a way to stop after a refused login, which TPAP needs;
- the CLI bits that depend on the transport. The
--type shortcuts hardcode AES for camera and KLAP for smart, and transport options like TPAP Implementation #1592's --tpap have nowhere to go.
Otherwise a plugin transport is only reached when discovery already names it, or through a hand-written config.
And just asking: would you want discovery in there too? Other new transports may need something from the discovery reply as well, like TPAP's tpap object. A way for a plugin to add to or change how the reply is read into parameters would help them.
Smaller points
Plugin loading
- Import order. Plugins load while
kasa/__init__.py is still running. So if a plugin's own package gets imported before kasa (its tests, or from some_plugin import X), EntryPoint.load() gets a half-initialised module, logs "Unable to load plugin" and skips the plugin for the process. Putting import kasa first thing in the plugin package avoids it, which is worth documenting.
- Import cost. Every
import kasa now reads every installed distribution's entry_points.txt: +3 ms with a dev venv's 71 distributions, and +49 ms with about 1,000 (median of 11 to 15 fresh interpreters).
- Every import logs. An intended override is a WARNING, and a failing plugin's traceback is printed, on every
import kasa. With no logging set up, both land on stderr through the last-resort handler, so every CLI run shows them. INFO for overrides might be enough.
- Any-encryption entries swallow new types.
SMART.IPCAMERA, SMART.TAPODOORBELL and IOT.IPCAMERA match any encryption type and are more specific than a plugin's ("SMART", "NEWENC"). So a doorbell reporting NEWENC still gets SslAesTransport, with no warning. A new type has to register those families itself (TPAP does), which is also worth documenting.
- Smaller:
KASA_DISABLE_PLUGINS=0 (or false) also disables plugins.
load_plugins() isn't idempotent.
- The load order follows the installed distributions, and the log names the entry point but not the distribution.
Small things
REGISTRY is imported into kasa but isn't in __all__.
- Docs. Nothing in
docs/ covers kasa.plugins, the setup(registry) contract, precedence, when plugins load or KASA_DISABLE_PLUGINS, and kasa.registry isn't in the API reference. From the TPAP plugin, two more things are worth a line:
- HA only turns on debug logging for the
kasa logger, so a plugin logging under its own package name is missing from HA debug logs;
- which private names a transport can rely on (
_RetryableError, _ConnectionError, _DeviceConfigBaseMixin, Credentials._from_plaintext_hash).
- The plugin tests patch
entry_points with SimpleNamespace objects, so nothing loads a real entry point or runs the import-time call.
Not from this PR: IOT devices from 20002 discovery get their family's class
Not new here, but the new comment in get_device_class ("IOT device classes are decided by the sysinfo, discovery only has the family") reads as if it's handled, and it isn't for every caller.
- A fresh dump of an HS300(US) hw 2.0 fw 1.1.2 (KLAP,
lv 2) comes out of Discover._get_device_instance as IotPlug over KlapTransportV2. Device.connect then picks IotStrip from sysinfo (Classify IOT devices via sysinfo regardless of transport (fixes #1740) #1769), so anything that connects with the discovered config is fine, like HA's integration discovery.
- Anything that calls
update() on the discovered object fails with KeyError: 'relay_state'. That covers kasa --host <ip> state and discover, and, from reading HA's config flow, _async_try_discover_and_update, which its DHCP, manual add, reauth and reconfigure steps use. A KeyError isn't a KasaException, so those steps don't catch it. Same symptom as KLAP v2 Authentication Failure on TP-Link HS300 (US) — Hardware Version 2.0 #1604.
- With the fixture added, four existing tests fail here exactly as on master:
test_device_factory.py::test_connect, test_discovery.py::test_discover_try_connect_all, and test_cli.py::test_discover and test_discover_host. IOT light strips announced as IOT.SMARTBULB come out as IotBulb instead of IotLightStrip the same way.
I'd like to fix this in a follow-up once this lands: classify from sysinfo when the login works, otherwise return an IOT device of unknown type whose update() raises AuthenticationError, so HA keeps working. Nothing needed here, though maybe reword the comment for now.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
ZeliardM
Awaiting requested review from ZeliardM
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|