Merge remote-tracking branch 'upstream/master' into feat/light_module_feats

This commit is contained in:
Steven B 2024-12-23 09:46:11 +00:00
commit a7192b8a92
No known key found for this signature in database
GPG Key ID: 6D5B46B3679F2A43
216 changed files with 11945 additions and 2156 deletions

View File

@ -1,8 +1,89 @@
# Changelog # Changelog
## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) ## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21)
This patch release fixes some issues with newly supported smartcam devices. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.1...0.9.0)
**Release highlights:**
- Improvements to Tapo camera support:
- C100, C225, C325WB, C520WS and TC70 now supported.
- Support for motion, person, tamper, and baby cry detection.
- Initial support for Tapo robovacs.
- API extended with `FeatureAttributes` for consumers to test for [supported features](https://python-kasa.readthedocs.io/en/stable/topics.html#modules-and-features).
- Experimental support for Kasa cameras[^1]
[^1]: Currently limited to devices not yet provisioned via the Tapo app - Many thanks to @puxtril!
**Breaking changes:**
- Use DeviceInfo consistently across devices [\#1338](https://github.com/python-kasa/python-kasa/pull/1338) (@sdb9696)
**Implemented enhancements:**
- Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696)
- Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696)
- Add bare-bones matter modules to smart and smartcam devices [\#1371](https://github.com/python-kasa/python-kasa/pull/1371) (@sdb9696)
- Add bare bones homekit modules smart and smartcam devices [\#1370](https://github.com/python-kasa/python-kasa/pull/1370) (@sdb9696)
- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696)
- cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti)
- Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti)
- Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696)
- Add LinkieTransportV2 and basic IOT.IPCAMERA support [\#1270](https://github.com/python-kasa/python-kasa/pull/1270) (@Puxtril)
- Add ssltransport for robovacs [\#943](https://github.com/python-kasa/python-kasa/pull/943) (@rytilahti)
**Fixed bugs:**
- Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149)
- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696)
- Fix lens mask required component and state [\#1386](https://github.com/python-kasa/python-kasa/pull/1386) (@sdb9696)
- Add LensMask module to smartcam [\#1385](https://github.com/python-kasa/python-kasa/pull/1385) (@sdb9696)
- Do not error when accessing smart device\_type before update [\#1319](https://github.com/python-kasa/python-kasa/pull/1319) (@sdb9696)
- Fallback to other module data on get\_energy\_usage errors [\#1245](https://github.com/python-kasa/python-kasa/pull/1245) (@rytilahti)
**Added support for devices:**
- Add P210M\(US\) 1.0 1.0.3 fixture [\#1399](https://github.com/python-kasa/python-kasa/pull/1399) (@sdb9696)
- Add C225\(US\) 2.0 1.0.11 fixture [\#1398](https://github.com/python-kasa/python-kasa/pull/1398) (@sdb9696)
- Add P306\(US\) 1.0 1.1.2 fixture [\#1396](https://github.com/python-kasa/python-kasa/pull/1396) (@nakanaela)
- Add TC70 3.0 1.3.11 fixture [\#1390](https://github.com/python-kasa/python-kasa/pull/1390) (@sdb9696)
- Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696)
- Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696)
- Add KS200 \(US\) IOT Fixture and P115 \(US\) Smart Fixture [\#1355](https://github.com/python-kasa/python-kasa/pull/1355) (@ZeliardM)
- Add C520WS camera fixture [\#1352](https://github.com/python-kasa/python-kasa/pull/1352) (@Happy-Cadaver)
**Documentation updates:**
- Update docs for Tapo Lab Third-Party compatibility [\#1380](https://github.com/python-kasa/python-kasa/pull/1380) (@sdb9696)
- Add homebridge-kasa-python link to README [\#1367](https://github.com/python-kasa/python-kasa/pull/1367) (@rytilahti)
- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696)
- Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti)
**Project maintenance:**
- Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696)
- Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696)
- Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696)
- Update C520WS fixture with new methods [\#1384](https://github.com/python-kasa/python-kasa/pull/1384) (@sdb9696)
- Miscellaneous minor fixes to dump\_devinfo [\#1382](https://github.com/python-kasa/python-kasa/pull/1382) (@sdb9696)
- Add timeout parameter to dump\_devinfo [\#1381](https://github.com/python-kasa/python-kasa/pull/1381) (@sdb9696)
- Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696)
- Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696)
- Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696)
- Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@sdb9696)
- Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696)
- Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696)
- Pass raw components to SmartChildDevice init [\#1363](https://github.com/python-kasa/python-kasa/pull/1363) (@sdb9696)
- Fix line endings in device\_fixtures.py [\#1361](https://github.com/python-kasa/python-kasa/pull/1361) (@sdb9696)
- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696)
- Tweak RELEASING.md instructions for patch releases [\#1347](https://github.com/python-kasa/python-kasa/pull/1347) (@sdb9696)
- Scrub more vacuum keys [\#1328](https://github.com/python-kasa/python-kasa/pull/1328) (@rytilahti)
- Remove unnecessary check for python \<3.10 [\#1326](https://github.com/python-kasa/python-kasa/pull/1326) (@rytilahti)
- Add vacuum component queries to dump\_devinfo [\#1320](https://github.com/python-kasa/python-kasa/pull/1320) (@rytilahti)
- Handle missing mgt\_encryption\_schm in discovery [\#1318](https://github.com/python-kasa/python-kasa/pull/1318) (@sdb9696)
- Follow main package structure for tests [\#1317](https://github.com/python-kasa/python-kasa/pull/1317) (@rytilahti)
## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06)
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1)
@ -46,28 +127,28 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
- Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696) - Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696)
- Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696) - Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696)
- Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696) - Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696)
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696) - Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696)
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
- Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril) - Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril)
- Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696) - Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696)
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
- Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696) - Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696)
**Fixed bugs:** **Fixed bugs:**
- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) - TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309)
- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
- How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306) - How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306)
- kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267) - kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267)
- device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262) - device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262)
- Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243) - Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243)
- Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201) - Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201)
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) - Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
- Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti) - Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti)
- Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696) - Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696)
- Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696) - Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696)
- Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti) - Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti)
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti)
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
**Added support for devices:** **Added support for devices:**
@ -81,13 +162,11 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
**Documentation updates:** **Documentation updates:**
- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696) - Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696)
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
- Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696) - Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696)
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
**Project maintenance:** **Project maintenance:**
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
- Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696) - Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696)
- Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696) - Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696)
- Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696) - Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696)
@ -117,15 +196,17 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im
- Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti) - Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti)
- Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696) - Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696)
- Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696) - Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696)
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) - Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti)
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) - Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696)
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) - Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher)
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
- Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti) - Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti)
- Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti) - Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti)
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
**Closed issues:** **Closed issues:**

View File

@ -178,6 +178,10 @@ The following devices have been tested and confirmed as working. If your device
> [!NOTE] > [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. > The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed.
> [!NOTE]
> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app.
> Alternatively, you can factory reset and then prevent the device from accessing the internet.
<!--Do not edit text inside the SUPPORTED section below --> <!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
### Supported Kasa devices ### Supported Kasa devices
@ -193,11 +197,11 @@ The following devices have been tested and confirmed as working. If your device
### Supported Tapo[^1] devices ### Supported Tapo[^1] devices
- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15
- **Power Strips**: P300, P304M, TP25 - **Power Strips**: P210M, P300, P304M, P306, TP25
- **Wall Switches**: S500D, S505, S505D - **Wall Switches**: S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630 - **Bulbs**: L510B, L510E, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Cameras**: C210, C520WS, TC65 - **Cameras**: C100, C210, C225, C325WB, C520WS, TC65, TC70
- **Hubs**: H100, H200 - **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
@ -223,6 +227,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf
* [Home Assistant](https://www.home-assistant.io/integrations/tplink/) * [Home Assistant](https://www.home-assistant.io/integrations/tplink/)
* [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) * [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa)
* [Homebridge Kasa Python Plug-In](https://github.com/ZeliardM/homebridge-kasa-python)
### Other related projects ### Other related projects

View File

@ -5,6 +5,9 @@ The following devices have been tested and confirmed as working. If your device
> [!NOTE] > [!NOTE]
> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. > The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed.
> [!NOTE]
> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app.
> Alternatively, you can factory reset and then prevent the device from accessing the internet.
<!--Do not edit text inside the SUPPORTED section below --> <!--Do not edit text inside the SUPPORTED section below -->
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
@ -199,17 +202,22 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.0
- **P135** - **P135**
- Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.2.0
- **TP15** - **TP15**
- Hardware: 1.0 (US) / Firmware: 1.0.3 - Hardware: 1.0 (US) / Firmware: 1.0.3
### Power Strips ### Power Strips
- **P210M**
- Hardware: 1.0 (US) / Firmware: 1.0.3
- **P300** - **P300**
- Hardware: 1.0 (EU) / Firmware: 1.0.13 - Hardware: 1.0 (EU) / Firmware: 1.0.13
- Hardware: 1.0 (EU) / Firmware: 1.0.15 - Hardware: 1.0 (EU) / Firmware: 1.0.15
- Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.0.7
- **P304M** - **P304M**
- Hardware: 1.0 (UK) / Firmware: 1.0.3 - Hardware: 1.0 (UK) / Firmware: 1.0.3
- **P306**
- Hardware: 1.0 (US) / Firmware: 1.1.2
- **TP25** - **TP25**
- Hardware: 1.0 (US) / Firmware: 1.0.2 - Hardware: 1.0 (US) / Firmware: 1.0.2
@ -255,13 +263,21 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
### Cameras ### Cameras
- **C100**
- Hardware: 4.0 / Firmware: 1.3.14
- **C210** - **C210**
- Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.2
- Hardware: 2.0 (EU) / Firmware: 1.4.3 - Hardware: 2.0 (EU) / Firmware: 1.4.3
- **C225**
- Hardware: 2.0 (US) / Firmware: 1.0.11
- **C325WB**
- Hardware: 1.0 (EU) / Firmware: 1.1.17
- **C520WS** - **C520WS**
- Hardware: 1.0 (US) / Firmware: 1.2.8 - Hardware: 1.0 (US) / Firmware: 1.2.8
- **TC65** - **TC65**
- Hardware: 1.0 / Firmware: 1.3.9 - Hardware: 1.0 / Firmware: 1.3.9
- **TC70**
- Hardware: 3.0 / Firmware: 1.3.11
### Hubs ### Hubs

View File

@ -10,8 +10,6 @@ and finally execute a query to query all of them at once.
from __future__ import annotations from __future__ import annotations
import base64
import collections.abc
import dataclasses import dataclasses
import json import json
import logging import logging
@ -19,6 +17,7 @@ import re
import sys import sys
import traceback import traceback
from collections import defaultdict, namedtuple from collections import defaultdict, namedtuple
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from pprint import pprint from pprint import pprint
from typing import Any from typing import Any
@ -39,30 +38,80 @@ from kasa import (
) )
from kasa.device_factory import get_protocol from kasa.device_factory import get_protocol
from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily
from kasa.discover import DiscoveryResult from kasa.discover import (
NEW_DISCOVERY_REDACTORS,
DiscoveredRaw,
DiscoveryResult,
)
from kasa.exceptions import SmartErrorCode from kasa.exceptions import SmartErrorCode
from kasa.protocols import IotProtocol from kasa.protocols import IotProtocol
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.protocol import redact_data
from kasa.protocols.smartcamprotocol import ( from kasa.protocols.smartcamprotocol import (
SmartCamProtocol, SmartCamProtocol,
_ChildCameraProtocolWrapper, _ChildCameraProtocolWrapper,
) )
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from kasa.smart import SmartChildDevice, SmartDevice from kasa.smart import SmartChildDevice, SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamDevice
Call = namedtuple("Call", "module method") Call = namedtuple("Call", "module method")
FixtureResult = namedtuple("FixtureResult", "filename, folder, data") FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix")
SMART_FOLDER = "tests/fixtures/smart/" SMART_FOLDER = "tests/fixtures/smart/"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/"
IOT_FOLDER = "tests/fixtures/iot/" IOT_FOLDER = "tests/fixtures/iot/"
SMART_PROTOCOL_SUFFIX = "SMART"
SMARTCAM_SUFFIX = "SMARTCAM"
SMART_CHILD_SUFFIX = "SMART.CHILD"
IOT_SUFFIX = "IOT"
NO_GIT_FIXTURE_FOLDER = "kasa-fixtures"
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]):
"""Wrap the redactors for dump_devinfo.
Will replace all partial REDACT_ values with zeros.
If the data item is already scrubbed by dump_devinfo will leave as-is.
"""
def _wrap(key: str) -> Any:
def _wrapped(redactor: Callable[[Any], Any] | None) -> Any | None:
if redactor is None:
return lambda x: "**SCRUBBED**"
def _redact_to_zeros(x: Any) -> Any:
if isinstance(x, str) and "REDACT" in x:
return re.sub(r"\w", "0", x)
if isinstance(x, dict):
for k, v in x.items():
x[k] = _redact_to_zeros(v)
return x
def _scrub(x: Any) -> Any:
if key in {"ip", "local_ip"}:
return "127.0.0.123"
# Already scrubbed by dump_devinfo
if isinstance(x, str) and "SCRUBBED" in x:
return x
default = redactor(x)
return _redact_to_zeros(default)
return _scrub
return _wrapped(redactors[key])
return {key: _wrap(key) for key in redactors}
@dataclasses.dataclass @dataclasses.dataclass
class SmartCall: class SmartCall:
"""Class for smart and smartcam calls.""" """Class for smart and smartcam calls."""
@ -74,115 +123,6 @@ class SmartCall:
supports_multiple: bool = True supports_multiple: bool = True
def scrub(res):
"""Remove identifiers from the given dict."""
keys_to_scrub = [
"deviceId",
"fwId",
"hwId",
"oemId",
"mac",
"mic_mac",
"latitude_i",
"longitude_i",
"latitude",
"longitude",
"la", # lat on ks240
"lo", # lon on ks240
"owner",
"device_id",
"ip",
"ssid",
"hw_id",
"fw_id",
"oem_id",
"nickname",
"alias",
"bssid",
"channel",
"original_device_id", # for child devices on strips
"parent_device_id", # for hub children
"setup_code", # matter
"setup_payload", # matter
"mfi_setup_code", # mfi_ for homekit
"mfi_setup_id",
"mfi_token_token",
"mfi_token_uuid",
"dev_id",
"device_name",
"device_alias",
"connect_ssid",
"encrypt_info",
"local_ip",
"username",
# vacuum
"board_sn",
"custom_sn",
"location",
]
for k, v in res.items():
if isinstance(v, collections.abc.Mapping):
if k == "encrypt_info":
if "data" in v:
v["data"] = ""
if "key" in v:
v["key"] = ""
else:
res[k] = scrub(res.get(k))
elif (
isinstance(v, list)
and len(v) > 0
and isinstance(v[0], collections.abc.Mapping)
):
res[k] = [scrub(vi) for vi in v]
else:
if k in keys_to_scrub:
if k in ["mac", "mic_mac"]:
# Some macs have : or - as a separator and others do not
if len(v) == 12:
v = f"{v[:6]}000000"
else:
delim = ":" if ":" in v else "-"
rest = delim.join(
format(s, "02x") for s in bytes.fromhex("000000")
)
v = f"{v[:8]}{delim}{rest}"
elif k in ["latitude", "latitude_i", "longitude", "longitude_i"]:
v = 0
elif k in ["ip", "local_ip"]:
v = "127.0.0.123"
elif k in ["ssid"]:
# Need a valid base64 value here
v = base64.b64encode(b"#MASKED_SSID#").decode()
elif k in ["nickname"]:
v = base64.b64encode(b"#MASKED_NAME#").decode()
elif k in [
"alias",
"device_alias",
"device_name",
"username",
"location",
]:
v = "#MASKED_NAME#"
elif isinstance(res[k], int):
v = 0
elif k in ["map_data"]: #
v = "#SCRUBBED_MAPDATA#"
elif k in ["device_id", "dev_id"] and "SCRUBBED" in v:
pass # already scrubbed
elif k == ["device_id", "dev_id"] and len(v) > 40:
# retain the last two chars when scrubbing child ids
end = v[-2:]
v = re.sub(r"\w", "0", v)
v = v[:40] + end
else:
v = re.sub(r"\w", "0", v)
res[k] = v
return res
def default_to_regular(d): def default_to_regular(d):
"""Convert nested defaultdicts to regular ones. """Convert nested defaultdicts to regular ones.
@ -207,9 +147,19 @@ async def handle_device(
] ]
for fixture_result in fixture_results: for fixture_result in fixture_results:
save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename save_folder = Path(basedir) / fixture_result.folder
if save_folder.exists():
save_filename = save_folder / f"{fixture_result.filename}.json"
else:
# If being run without git clone
save_folder = Path(basedir) / NO_GIT_FIXTURE_FOLDER
save_folder.mkdir(exist_ok=True)
save_filename = (
save_folder
/ f"{fixture_result.filename}-{fixture_result.protocol_suffix}.json"
)
pprint(scrub(fixture_result.data)) pprint(fixture_result.data)
if autosave: if autosave:
save = "y" save = "y"
else: else:
@ -300,6 +250,12 @@ async def handle_device(
type=bool, type=bool,
help="Set flag if the device encryption uses https.", help="Set flag if the device encryption uses https.",
) )
@click.option(
"--timeout",
required=False,
default=15,
help="Timeout for queries.",
)
@click.option("--port", help="Port override", type=int) @click.option("--port", help="Port override", type=int)
async def cli( async def cli(
host, host,
@ -317,6 +273,7 @@ async def cli(
device_family, device_family,
login_version, login_version,
port, port,
timeout,
): ):
"""Generate devinfo files for devices. """Generate devinfo files for devices.
@ -325,6 +282,11 @@ async def cli(
if debug: if debug:
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
raw_discovery = {}
def capture_raw(discovered: DiscoveredRaw):
raw_discovery[discovered["meta"]["ip"]] = discovered["discovery_response"]
credentials = Credentials(username=username, password=password) credentials = Credentials(username=username, password=password)
if host is not None: if host is not None:
if discovery_info: if discovery_info:
@ -342,6 +304,7 @@ async def cli(
connection_type=connection_type, connection_type=connection_type,
port_override=port, port_override=port,
credentials=credentials, credentials=credentials,
timeout=timeout,
) )
device = await Device.connect(config=dc) device = await Device.connect(config=dc)
await handle_device( await handle_device(
@ -363,6 +326,7 @@ async def cli(
port_override=port, port_override=port,
credentials=credentials, credentials=credentials,
connection_type=ctype, connection_type=ctype,
timeout=timeout,
) )
if protocol := get_protocol(config): if protocol := get_protocol(config):
await handle_device(basedir, autosave, protocol, batch_size=batch_size) await handle_device(basedir, autosave, protocol, batch_size=batch_size)
@ -377,12 +341,17 @@ async def cli(
credentials=credentials, credentials=credentials,
port=port, port=port,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
timeout=timeout,
on_discovered_raw=capture_raw,
) )
discovery_info = raw_discovery[device.host]
if decrypted_data := device._discovery_info.get("decrypted_data"):
discovery_info["result"]["decrypted_data"] = decrypted_data
await handle_device( await handle_device(
basedir, basedir,
autosave, autosave,
device.protocol, device.protocol,
discovery_info=device._discovery_info, discovery_info=discovery_info,
batch_size=batch_size, batch_size=batch_size,
) )
else: else:
@ -391,21 +360,29 @@ async def cli(
f" {target}. Use --target to override." f" {target}. Use --target to override."
) )
devices = await Discover.discover( devices = await Discover.discover(
target=target, credentials=credentials, discovery_timeout=discovery_timeout target=target,
credentials=credentials,
discovery_timeout=discovery_timeout,
timeout=timeout,
on_discovered_raw=capture_raw,
) )
click.echo(f"Detected {len(devices)} devices") click.echo(f"Detected {len(devices)} devices")
for dev in devices.values(): for dev in devices.values():
discovery_info = raw_discovery[dev.host]
if decrypted_data := dev._discovery_info.get("decrypted_data"):
discovery_info["result"]["decrypted_data"] = decrypted_data
await handle_device( await handle_device(
basedir, basedir,
autosave, autosave,
dev.protocol, dev.protocol,
discovery_info=dev._discovery_info, discovery_info=discovery_info,
batch_size=batch_size, batch_size=batch_size,
) )
async def get_legacy_fixture( async def get_legacy_fixture(
protocol: IotProtocol, *, discovery_info: dict[str, Any] | None protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None
) -> FixtureResult: ) -> FixtureResult:
"""Get fixture for legacy IOT style protocol.""" """Get fixture for legacy IOT style protocol."""
items = [ items = [
@ -475,11 +452,21 @@ async def get_legacy_fixture(
_echo_error(f"Unable to query all successes at once: {ex}") _echo_error(f"Unable to query all successes at once: {ex}")
finally: finally:
await protocol.close() await protocol.close()
final = redact_data(final, _wrap_redactors(IOT_REDACTORS))
# Scrub the child device ids
if children := final.get("system", {}).get("get_sysinfo", {}).get("children"):
for index, child in enumerate(children):
if "id" not in child:
_LOGGER.error("Could not find a device for the child device: %s", child)
else:
child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
if discovery_info and not discovery_info.get("system"): if discovery_info and not discovery_info.get("system"):
# Need to recreate a DiscoverResult here because we don't want the aliases final["discovery_result"] = redact_data(
# in the fixture, we want the actual field names as returned by the device. discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
dr = DiscoveryResult.from_dict(discovery_info) )
final["discovery_result"] = dr.to_dict()
click.echo(f"Got {len(successes)} successes") click.echo(f"Got {len(successes)} successes")
click.echo(click.style("## device info file ##", bold=True)) click.echo(click.style("## device info file ##", bold=True))
@ -489,9 +476,14 @@ async def get_legacy_fixture(
hw_version = sysinfo["hw_ver"] hw_version = sysinfo["hw_ver"]
sw_version = sysinfo["sw_ver"] sw_version = sysinfo["sw_ver"]
sw_version = sw_version.split(" ", maxsplit=1)[0] sw_version = sw_version.split(" ", maxsplit=1)[0]
save_filename = f"{model}_{hw_version}_{sw_version}.json" save_filename = f"{model}_{hw_version}_{sw_version}"
copy_folder = IOT_FOLDER copy_folder = IOT_FOLDER
return FixtureResult(filename=save_filename, folder=copy_folder, data=final) return FixtureResult(
filename=save_filename,
folder=copy_folder,
data=final,
protocol_suffix=IOT_SUFFIX,
)
def _echo_error(msg: str): def _echo_error(msg: str):
@ -860,14 +852,20 @@ def get_smart_child_fixture(response):
model = model_info.long_name model = model_info.long_name
if model_info.region is not None: if model_info.region is not None:
model = f"{model}({model_info.region})" model = f"{model}({model_info.region})"
save_filename = f"{model}_{hw_version}_{fw_version}.json" save_filename = f"{model}_{hw_version}_{fw_version}"
return FixtureResult( return FixtureResult(
filename=save_filename, folder=SMART_CHILD_FOLDER, data=response filename=save_filename,
folder=SMART_CHILD_FOLDER,
data=response,
protocol_suffix=SMART_CHILD_SUFFIX,
) )
async def get_smart_fixtures( async def get_smart_fixtures(
protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int protocol: SmartProtocol,
*,
discovery_info: dict[str, dict[str, Any]] | None,
batch_size: int,
) -> list[FixtureResult]: ) -> list[FixtureResult]:
"""Get fixture for new TAPO style protocol.""" """Get fixture for new TAPO style protocol."""
if isinstance(protocol, SmartCamProtocol): if isinstance(protocol, SmartCamProtocol):
@ -963,6 +961,7 @@ async def get_smart_fixtures(
and (child_model := response["get_device_info"].get("model")) and (child_model := response["get_device_info"].get("model"))
and child_model != parent_model and child_model != parent_model
): ):
response = redact_data(response, _wrap_redactors(SMART_REDACTORS))
fixture_results.append(get_smart_child_fixture(response)) fixture_results.append(get_smart_child_fixture(response))
else: else:
cd = final.setdefault("child_devices", {}) cd = final.setdefault("child_devices", {})
@ -978,43 +977,56 @@ async def get_smart_fixtures(
child["device_id"] = scrubbed_device_ids[device_id] child["device_id"] = scrubbed_device_ids[device_id]
# Scrub the device ids in the parent for the smart camera protocol # Scrub the device ids in the parent for the smart camera protocol
if gc := final.get("getChildDeviceList"): if gc := final.get("getChildDeviceComponentList"):
for child in gc["child_device_list"]: for child in gc["child_component_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_device_ids[device_id]
for child in final["getChildDeviceList"]["child_device_list"]:
if device_id := child.get("device_id"): if device_id := child.get("device_id"):
child["device_id"] = scrubbed_device_ids[device_id] child["device_id"] = scrubbed_device_ids[device_id]
continue continue
if device_id := child.get("dev_id"): elif dev_id := child.get("dev_id"):
child["dev_id"] = scrubbed_device_ids[device_id] child["dev_id"] = scrubbed_device_ids[dev_id]
continue continue
_LOGGER.error("Could not find a device for the child device: %s", child) _LOGGER.error("Could not find a device for the child device: %s", child)
# Need to recreate a DiscoverResult here because we don't want the aliases final = redact_data(final, _wrap_redactors(SMART_REDACTORS))
# in the fixture, we want the actual field names as returned by the device. discovery_result = None
if discovery_info: if discovery_info:
dr = DiscoveryResult.from_dict(discovery_info) # type: ignore final["discovery_result"] = redact_data(
final["discovery_result"] = dr.to_dict() discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS)
)
discovery_result = discovery_info["result"]
click.echo(f"Got {len(successes)} successes") click.echo(f"Got {len(successes)} successes")
click.echo(click.style("## device info file ##", bold=True)) click.echo(click.style("## device info file ##", bold=True))
if "get_device_info" in final: if "get_device_info" in final:
# smart protocol # smart protocol
model_info = SmartDevice._get_device_info(final, discovery_info) model_info = SmartDevice._get_device_info(final, discovery_result)
copy_folder = SMART_FOLDER copy_folder = SMART_FOLDER
protocol_suffix = SMART_PROTOCOL_SUFFIX
else: else:
# smart camera protocol # smart camera protocol
model_info = SmartCamDevice._get_device_info(final, discovery_info) model_info = SmartCamDevice._get_device_info(final, discovery_result)
copy_folder = SMARTCAM_FOLDER copy_folder = SMARTCAM_FOLDER
protocol_suffix = SMARTCAM_SUFFIX
hw_version = model_info.hardware_version hw_version = model_info.hardware_version
sw_version = model_info.firmware_version sw_version = model_info.firmware_version
model = model_info.long_name model = model_info.long_name
if model_info.region is not None: if model_info.region is not None:
model = f"{model}({model_info.region})" model = f"{model}({model_info.region})"
save_filename = f"{model}_{hw_version}_{sw_version}.json" save_filename = f"{model}_{hw_version}_{sw_version}"
fixture_results.insert( fixture_results.insert(
0, FixtureResult(filename=save_filename, folder=copy_folder, data=final) 0,
FixtureResult(
filename=save_filename,
folder=copy_folder,
data=final,
protocol_suffix=protocol_suffix,
),
) )
return fixture_results return fixture_results

View File

@ -205,7 +205,7 @@ def _get_supported_devices(
fixture_data = json.load(f) fixture_data = json.load(f)
model_info = device_cls._get_device_info( model_info = device_cls._get_device_info(
fixture_data, fixture_data.get("discovery_result") fixture_data, fixture_data.get("discovery_result", {}).get("result")
) )
supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type] supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type]

View File

@ -60,4 +60,7 @@ SMARTCAM_REQUESTS: list[dict] = [
{"get": {"motor": {"name": ["capability"]}}}, {"get": {"motor": {"name": ["capability"]}}},
{"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}}, {"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}},
{"get": {"audio_config": {"name": ["speaker", "microphone"]}}}, {"get": {"audio_config": {"name": ["speaker", "microphone"]}}},
{"getMatterSetupInfo": {"matter": {}}},
{"getConnectStatus": {"onboarding": {"get_connect_status": {}}}},
{"scanApList": {"onboarding": {"scan": {}}}},
] ]

128
devtools/update_fixtures.py Normal file
View File

@ -0,0 +1,128 @@
"""Module to mass update fixture files."""
import json
import logging
from collections.abc import Callable
from pathlib import Path
import asyncclick as click
from devtools.dump_devinfo import _wrap_redactors
from kasa.discover import NEW_DISCOVERY_REDACTORS, redact_data
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
FIXTURE_FOLDER = "tests/fixtures/"
_LOGGER = logging.getLogger(__name__)
def update_fixtures(update_func: Callable[[dict], bool], *, dry_run: bool) -> None:
"""Run the update function against the fixtures."""
for file in Path(FIXTURE_FOLDER).glob("**/*.json"):
with file.open("r") as f:
fixture_data = json.load(f)
if file.parent.name == "serialization":
continue
changed = update_func(fixture_data)
if changed:
click.echo(f"Will update {file.name}\n")
if changed and not dry_run:
with file.open("w") as f:
json.dump(fixture_data, f, sort_keys=True, indent=4)
f.write("\n")
def _discovery_result_update(info) -> bool:
"""Update discovery_result to be the raw result and error_code."""
if (disco_result := info.get("discovery_result")) and "result" not in disco_result:
info["discovery_result"] = {
"result": disco_result,
"error_code": 0,
}
return True
return False
def _child_device_id_update(info) -> bool:
"""Update child device ids to be the scrubbed ids from dump_devinfo."""
changed = False
if get_child_device_list := info.get("get_child_device_list"):
child_device_list = get_child_device_list["child_device_list"]
child_component_list = info["get_child_device_component_list"][
"child_component_list"
]
for index, child_device in enumerate(child_device_list):
child_component = child_component_list[index]
if "SCRUBBED" not in child_device["device_id"]:
dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
click.echo(
f"child_device_id{index}: {child_device['device_id']} -> {dev_id}"
)
child_device["device_id"] = dev_id
child_component["device_id"] = dev_id
changed = True
if children := info.get("system", {}).get("get_sysinfo", {}).get("children"):
for index, child_device in enumerate(children):
if "SCRUBBED" not in child_device["id"]:
dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
click.echo(f"child_device_id{index}: {child_device['id']} -> {dev_id}")
child_device["id"] = dev_id
changed = True
return changed
def _diff_data(fullkey, data1, data2, diffs):
if isinstance(data1, dict):
for k, v in data1.items():
_diff_data(fullkey + "/" + k, v, data2[k], diffs)
elif isinstance(data1, list):
for index, item in enumerate(data1):
_diff_data(fullkey + "/" + str(index), item, data2[index], diffs)
elif data1 != data2:
diffs[fullkey] = (data1, data2)
def _redactor_result_update(info) -> bool:
"""Update fixtures with the output using the common redactors."""
changed = False
redactors = IOT_REDACTORS if "system" in info else SMART_REDACTORS
for key, val in info.items():
if not isinstance(val, dict):
continue
if key == "discovery_result":
info[key] = redact_data(val, _wrap_redactors(NEW_DISCOVERY_REDACTORS))
else:
info[key] = redact_data(val, _wrap_redactors(redactors))
diffs: dict[str, tuple[str, str]] = {}
_diff_data(key, val, info[key], diffs)
if diffs:
for k, v in diffs.items():
click.echo(f"{k}: {v[0]} -> {v[1]}")
changed = True
return changed
@click.option(
"--dry-run/--no-dry-run",
default=False,
is_flag=True,
type=bool,
help="Perform a dry run without saving.",
)
@click.command()
async def cli(dry_run: bool) -> None:
"""Cli method fo rupdating fixtures."""
update_fixtures(_discovery_result_update, dry_run=dry_run)
update_fixtures(_child_device_id_update, dry_run=dry_run)
update_fixtures(_redactor_result_update, dry_run=dry_run)
if __name__ == "__main__":
cli()

View File

@ -0,0 +1,13 @@
Some modules have attributes that may not be supported by the device.
These attributes will be annotated with a `FeatureAttribute` return type.
For example:
```py
@property
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb."""
```
You can test whether a `FeatureAttribute` is supported by the device with {meth}`kasa.Module.has_feature`
or {meth}`kasa.Module.get_feature` which will return `None` if not supported.
Calling these methods on attributes not annotated with a `FeatureAttribute` return type will return an error.

View File

@ -13,11 +13,13 @@
## Device ## Device
% N.B. Credentials clashes with autodoc
```{eval-rst} ```{eval-rst}
.. autoclass:: Device .. autoclass:: Device
:members: :members:
:undoc-members: :undoc-members:
:exclude-members: Credentials
``` ```
@ -28,7 +30,6 @@
.. autoclass:: Credentials .. autoclass:: Credentials
:members: :members:
:undoc-members: :undoc-members:
:noindex:
``` ```
@ -61,15 +62,11 @@
```{eval-rst} ```{eval-rst}
.. autoclass:: Module .. autoclass:: Module
:noindex:
:members: :members:
:inherited-members:
:undoc-members:
``` ```
```{eval-rst} ```{eval-rst}
.. autoclass:: Feature .. autoclass:: Feature
:noindex:
:members: :members:
:inherited-members: :inherited-members:
:undoc-members: :undoc-members:
@ -77,7 +74,6 @@
```{eval-rst} ```{eval-rst}
.. automodule:: kasa.interfaces .. automodule:: kasa.interfaces
:noindex:
:members: :members:
:inherited-members: :inherited-members:
:undoc-members: :undoc-members:
@ -85,64 +81,29 @@
## Protocols and transports ## Protocols and transports
```{eval-rst} ```{eval-rst}
.. autoclass:: kasa.protocols.BaseProtocol .. automodule:: kasa.protocols
:members: :members:
:inherited-members: :imported-members:
:undoc-members: :undoc-members:
:exclude-members: SmartErrorCode
:no-index:
``` ```
```{eval-rst} ```{eval-rst}
.. autoclass:: kasa.protocols.IotProtocol .. automodule:: kasa.transports
:members: :members:
:inherited-members: :imported-members:
:undoc-members: :undoc-members:
:no-index:
``` ```
```{eval-rst}
.. autoclass:: kasa.protocols.SmartProtocol
:members:
:inherited-members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.transports.BaseTransport
:members:
:inherited-members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.transports.XorTransport
:members:
:inherited-members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.transports.KlapTransport
:members:
:inherited-members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.transports.KlapTransportV2
:members:
:inherited-members:
:undoc-members:
```
```{eval-rst}
.. autoclass:: kasa.transports.AesTransport
:members:
:inherited-members:
:undoc-members:
```
## Errors and exceptions ## Errors and exceptions
```{eval-rst} ```{eval-rst}
.. autoclass:: kasa.exceptions.KasaException .. autoclass:: kasa.exceptions.KasaException
:members: :members:
@ -171,3 +132,4 @@
.. autoclass:: kasa.exceptions.TimeoutError .. autoclass:: kasa.exceptions.TimeoutError
:members: :members:
:undoc-members: :undoc-members:
```

View File

@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property.
## Modules and Features ## Modules and Features
The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules. The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules.
While the individual device-type specific classes provide an easy access for the most import features, While the device class provides easy access for most device related attributes,
you can also access individual modules through {attr}`kasa.Device.modules`. for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`.
You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`. The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection.
```{note} Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module.
If you only need some module-specific information, They allow for instrospection and can be accessed through {attr}`kasa.Device.features`.
you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`. Attributes can be accessed via a `Feature` or a module attribute depending on the use case.
``` Modules tend to provide richer functionality but using the features does not require an understanding of the module api.
:::{include} featureattributes.md
:::
(topics-protocols-and-transports)= (topics-protocols-and-transports)=
## Protocols and Transports ## Protocols and Transports
@ -137,96 +140,3 @@ The base exception for all library errors is {class}`KasaException <kasa.excepti
- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError <kasa.exceptions.UnsupportedDeviceError>`. - If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError <kasa.exceptions.UnsupportedDeviceError>`.
- If the device fails to respond within a timeout the library raises a {class}`TimeoutError <kasa.exceptions.TimeoutError>`. - If the device fails to respond within a timeout the library raises a {class}`TimeoutError <kasa.exceptions.TimeoutError>`.
- All other failures will raise the base {class}`KasaException <kasa.exceptions.KasaException>` class. - All other failures will raise the base {class}`KasaException <kasa.exceptions.KasaException>` class.
<!-- Commenting out this section keeps git seeing the change as a rename.
API documentation for modules and features
******************************************
.. autoclass:: kasa.Module
:noindex:
:members:
:inherited-members:
:undoc-members:
.. automodule:: kasa.interfaces
:noindex:
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.Feature
:noindex:
:members:
:inherited-members:
:undoc-members:
API documentation for protocols and transports
**********************************************
.. autoclass:: kasa.protocols.BaseProtocol
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.protocols.IotProtocol
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.protocols.SmartProtocol
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.BaseTransport
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.XorTransport
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.KlapTransport
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.KlapTransportV2
:members:
:inherited-members:
:undoc-members:
.. autoclass:: kasa.transports.AesTransport
:members:
:inherited-members:
:undoc-members:
API documentation for errors and exceptions
*******************************************
.. autoclass:: kasa.exceptions.KasaException
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.DeviceError
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.AuthenticationError
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.UnsupportedDeviceError
:members:
:undoc-members:
.. autoclass:: kasa.exceptions.TimeoutError
:members:
:undoc-members:
-->

View File

@ -40,7 +40,7 @@ Different groups of functionality are supported by modules which you can access
key from :class:`~kasa.Module`. key from :class:`~kasa.Module`.
Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device. Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device.
You can check the availability using ``is_``-prefixed properties like `is_color`. You can check the availability using ``has_feature()`` method.
>>> from kasa import Module >>> from kasa import Module
>>> Module.Light in dev.modules >>> Module.Light in dev.modules

View File

@ -38,7 +38,7 @@ from kasa.feature import Feature
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
from kasa.interfaces.thermostat import Thermostat, ThermostatState from kasa.interfaces.thermostat import Thermostat, ThermostatState
from kasa.module import Module from kasa.module import Module
from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol from kasa.protocols import BaseProtocol, IotProtocol, SmartCamProtocol, SmartProtocol
from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
from kasa.smartcam.modules.camera import StreamResolution from kasa.smartcam.modules.camera import StreamResolution
from kasa.transports import BaseTransport from kasa.transports import BaseTransport
@ -52,6 +52,7 @@ __all__ = [
"BaseTransport", "BaseTransport",
"IotProtocol", "IotProtocol",
"SmartProtocol", "SmartProtocol",
"SmartCamProtocol",
"LightState", "LightState",
"TurnOnBehaviors", "TurnOnBehaviors",
"TurnOnBehavior", "TurnOnBehavior",

View File

@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import re import re
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from contextlib import contextmanager from contextlib import contextmanager
from functools import singledispatch, update_wrapper, wraps from functools import singledispatch, update_wrapper, wraps
from gettext import gettext
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
import asyncclick as click import asyncclick as click
@ -238,4 +240,19 @@ def CatchAllExceptions(cls):
except Exception as exc: except Exception as exc:
_handle_exception(self._debug, exc) _handle_exception(self._debug, exc)
def __call__(self, *args, **kwargs):
"""Run the coroutine in the event loop and print any exceptions.
python click catches KeyboardInterrupt in main, raises Abort()
and does sys.exit. asyncclick doesn't properly handle a coroutine
receiving CancelledError on a KeyboardInterrupt, so we catch the
KeyboardInterrupt here once asyncio.run has re-raised it. This
avoids large stacktraces when a user presses Ctrl-C.
"""
try:
asyncio.run(self.main(*args, **kwargs))
except KeyboardInterrupt:
click.echo(gettext("\nAborted!"), file=sys.stderr)
sys.exit(1)
return _CommandCls return _CommandCls

View File

@ -41,8 +41,14 @@ async def state(ctx, dev: Device):
echo(f"Device state: {dev.is_on}") echo(f"Device state: {dev.is_on}")
echo(f"Time: {dev.time} (tz: {dev.timezone})") echo(f"Time: {dev.time} (tz: {dev.timezone})")
echo(f"Hardware: {dev.hw_info['hw_ver']}") echo(
echo(f"Software: {dev.hw_info['sw_ver']}") f"Hardware: {dev.device_info.hardware_version}"
f"{' (' + dev.region + ')' if dev.region else ''}"
)
echo(
f"Firmware: {dev.device_info.firmware_version}"
f" {dev.device_info.firmware_build}"
)
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
if verbose: if verbose:
echo(f"Location: {dev.location}") echo(f"Location: {dev.location}")

View File

@ -123,14 +123,19 @@ async def list(ctx):
async def print_discovered(dev: Device): async def print_discovered(dev: Device):
cparams = dev.config.connection_type cparams = dev.config.connection_type
infostr = ( infostr = (
f"{dev.host:<15} {cparams.device_family.value:<20} " f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} "
f"{cparams.encryption_type.value:<7}" f"{cparams.encryption_type.value:<7} {cparams.https:<5} "
f"{cparams.login_version or '-':<3}"
) )
async with sem: async with sem:
try: try:
await dev.update() await dev.update()
except AuthenticationError: except AuthenticationError:
echo(f"{infostr} - Authentication failed") echo(f"{infostr} - Authentication failed")
except TimeoutError:
echo(f"{infostr} - Timed out")
except Exception as ex:
echo(f"{infostr} - Error: {ex}")
else: else:
echo(f"{infostr} {dev.alias}") echo(f"{infostr} {dev.alias}")
@ -138,7 +143,10 @@ async def list(ctx):
if host := unsupported_exception.host: if host := unsupported_exception.host:
echo(f"{host:<15} UNSUPPORTED DEVICE") echo(f"{host:<15} UNSUPPORTED DEVICE")
echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") echo(
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
)
return await _discover( return await _discover(
ctx, ctx,
print_discovered=print_discovered, print_discovered=print_discovered,

View File

@ -25,7 +25,9 @@ def light(dev) -> None:
@pass_dev_or_child @pass_dev_or_child
async def brightness(dev: Device, brightness: int, transition: int): async def brightness(dev: Device, brightness: int, transition: int):
"""Get or set brightness.""" """Get or set brightness."""
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
"brightness"
):
error("This device does not support brightness.") error("This device does not support brightness.")
return return
@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int):
@pass_dev_or_child @pass_dev_or_child
async def temperature(dev: Device, temperature: int, transition: int): async def temperature(dev: Device, temperature: int, transition: int):
"""Get or set color temperature.""" """Get or set color temperature."""
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: if not (light := dev.modules.get(Module.Light)) or not (
color_temp_feat := light.get_feature("color_temp")
):
error("Device does not support color temperature") error("Device does not support color temperature")
return return
if temperature is None: if temperature is None:
echo(f"Color temperature: {light.color_temp}") echo(f"Color temperature: {light.color_temp}")
valid_temperature_range = light.valid_temperature_range valid_temperature_range = color_temp_feat.range
if valid_temperature_range != (0, 0): if valid_temperature_range != (0, 0):
echo("(min: {}, max: {})".format(*valid_temperature_range)) echo("(min: {}, max: {})".format(*valid_temperature_range))
else: else:
@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
"Temperature range unknown, please open a github issue" "Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'" f" or a pull request for model '{dev.model}'"
) )
return light.valid_temperature_range return color_temp_feat.range
else: else:
echo(f"Setting color temperature to {temperature}") echo(f"Setting color temperature to {temperature}")
return await light.set_color_temp(temperature, transition=transition) return await light.set_color_temp(temperature, transition=transition)
@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect):
@pass_dev_or_child @pass_dev_or_child
async def hsv(dev: Device, ctx, h, s, v, transition): async def hsv(dev: Device, ctx, h, s, v, transition):
"""Get or set color in HSV.""" """Get or set color in HSV."""
if not (light := dev.modules.get(Module.Light)) or not light.is_color: if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"):
error("Device does not support colors") error("Device does not support colors")
return return

View File

@ -29,7 +29,7 @@ All devices provide several informational properties:
>>> dev.alias >>> dev.alias
Bedroom Lamp Plug Bedroom Lamp Plug
>>> dev.model >>> dev.model
HS110(EU) HS110
>>> dev.rssi >>> dev.rssi
-71 -71
>>> dev.mac >>> dev.mac
@ -151,7 +151,7 @@ _LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class _DeviceInfo: class DeviceInfo:
"""Device Model Information.""" """Device Model Information."""
short_name: str short_name: str
@ -208,7 +208,7 @@ class Device(ABC):
self.protocol: BaseProtocol = protocol or IotProtocol( self.protocol: BaseProtocol = protocol or IotProtocol(
transport=XorTransport(config=config or DeviceConfig(host=host)), transport=XorTransport(config=config or DeviceConfig(host=host)),
) )
self._last_update: Any = None self._last_update: dict[str, Any] = {}
_LOGGER.debug("Initializing %s of type %s", host, type(self)) _LOGGER.debug("Initializing %s of type %s", host, type(self))
self._device_type = DeviceType.Unknown self._device_type = DeviceType.Unknown
# TODO: typing Any is just as using dict | None would require separate # TODO: typing Any is just as using dict | None would require separate
@ -334,9 +334,21 @@ class Device(ABC):
"""Returns the device model.""" """Returns the device model."""
@property @property
def region(self) -> str | None:
"""Returns the device region."""
return self.device_info.region
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return self._get_device_info(self._last_update, self._discovery_info)
@staticmethod
@abstractmethod @abstractmethod
def _model_region(self) -> str: def _get_device_info(
"""Return device full model name and region.""" info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> DeviceInfo:
"""Get device info."""
@property @property
@abstractmethod @abstractmethod

View File

@ -8,7 +8,7 @@ from typing import Any
from .device import Device from .device import Device
from .device_type import DeviceType from .device_type import DeviceType
from .deviceconfig import DeviceConfig from .deviceconfig import DeviceConfig, DeviceFamily
from .exceptions import KasaException, UnsupportedDeviceError from .exceptions import KasaException, UnsupportedDeviceError
from .iot import ( from .iot import (
IotBulb, IotBulb,
@ -179,20 +179,29 @@ def get_device_class_from_family(
def get_protocol( def get_protocol(
config: DeviceConfig, config: DeviceConfig,
) -> BaseProtocol | None: ) -> BaseProtocol | None:
"""Return the protocol from the connection name.""" """Return the protocol from the connection name.
protocol_name = config.connection_type.device_family.value.split(".")[0]
For cameras and vacuums the device family is a simple mapping to
the protocol/transport. For other device types the transport varies
based on the discovery information.
"""
ctype = config.connection_type ctype = config.connection_type
protocol_name = ctype.device_family.value.split(".")[0]
if ctype.device_family is DeviceFamily.SmartIpCamera:
return SmartCamProtocol(transport=SslAesTransport(config=config))
if ctype.device_family is DeviceFamily.IotIpCamera:
return IotProtocol(transport=LinkieTransportV2(config=config))
if ctype.device_family is DeviceFamily.SmartTapoRobovac:
return SmartProtocol(transport=SslTransport(config=config))
protocol_transport_key = ( protocol_transport_key = (
protocol_name protocol_name
+ "." + "."
+ ctype.encryption_type.value + ctype.encryption_type.value
+ (".HTTPS" if ctype.https else "") + (".HTTPS" if ctype.https else "")
+ (
f".{ctype.login_version}"
if ctype.login_version and ctype.login_version > 1
else ""
)
) )
_LOGGER.debug("Finding transport for %s", protocol_transport_key) _LOGGER.debug("Finding transport for %s", protocol_transport_key)
@ -201,12 +210,11 @@ def get_protocol(
] = { ] = {
"IOT.XOR": (IotProtocol, XorTransport), "IOT.XOR": (IotProtocol, XorTransport),
"IOT.KLAP": (IotProtocol, KlapTransport), "IOT.KLAP": (IotProtocol, KlapTransport),
"IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2),
"SMART.AES": (SmartProtocol, AesTransport), "SMART.AES": (SmartProtocol, AesTransport),
"SMART.AES.2": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2),
"SMART.KLAP.2": (SmartProtocol, KlapTransportV2), # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use
"SMART.AES.HTTPS.2": (SmartCamProtocol, SslAesTransport), # https to distuingish from SmartProtocol devices
"SMART.AES.HTTPS": (SmartProtocol, SslTransport), "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),
} }
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
return None return None

View File

@ -22,7 +22,7 @@ Discovery returns a dict of {ip: discovered devices}:
>>> >>>
>>> found_devices = await Discover.discover() >>> found_devices = await Discover.discover()
>>> [dev.model for dev in found_devices.values()] >>> [dev.model for dev in found_devices.values()]
['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)'] ['KP303', 'HS110', 'L530E', 'KL430', 'HS220']
You can pass username and password for devices requiring authentication You can pass username and password for devices requiring authentication
@ -65,17 +65,17 @@ It is also possible to pass a coroutine to be executed for each found device:
>>> print(f"Discovered {dev.alias} (model: {dev.model})") >>> print(f"Discovered {dev.alias} (model: {dev.model})")
>>> >>>
>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) >>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds)
Discovered Bedroom Power Strip (model: KP303(UK)) Discovered Bedroom Power Strip (model: KP303)
Discovered Bedroom Lamp Plug (model: HS110(EU)) Discovered Bedroom Lamp Plug (model: HS110)
Discovered Living Room Bulb (model: L530) Discovered Living Room Bulb (model: L530)
Discovered Bedroom Lightstrip (model: KL430(US)) Discovered Bedroom Lightstrip (model: KL430)
Discovered Living Room Dimmer Switch (model: HS220(US)) Discovered Living Room Dimmer Switch (model: HS220)
Discovering a single device returns a kasa.Device object. Discovering a single device returns a kasa.Device object.
>>> device = await Discover.discover_single("127.0.0.1", credentials=creds) >>> device = await Discover.discover_single("127.0.0.1", credentials=creds)
>>> device.model >>> device.model
'KP303(UK)' 'KP303'
""" """
@ -168,6 +168,12 @@ OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine]
OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None]
DeviceDict = dict[str, Device] DeviceDict = dict[str, Device]
DECRYPTED_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"connect_ssid": lambda x: "#MASKED_SSID#" if x else "",
"device_id": lambda x: "REDACTED_" + x[9::],
"owner": lambda x: "REDACTED_" + x[9::],
}
NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"device_id": lambda x: "REDACTED_" + x[9::], "device_id": lambda x: "REDACTED_" + x[9::],
"device_name": lambda x: "#MASKED_NAME#" if x else "", "device_name": lambda x: "#MASKED_NAME#" if x else "",
@ -177,6 +183,8 @@ NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"group_id": lambda x: "REDACTED_" + x[9::], "group_id": lambda x: "REDACTED_" + x[9::],
"group_name": lambda x: "I01BU0tFRF9TU0lEIw==", "group_name": lambda x: "I01BU0tFRF9TU0lEIw==",
"encrypt_info": lambda x: {**x, "key": "", "data": ""}, "encrypt_info": lambda x: {**x, "key": "", "data": ""},
"ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
"decrypted_data": lambda x: redact_data(x, DECRYPTED_REDACTORS),
} }
@ -490,7 +498,7 @@ class Discover:
try: try:
_LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout)
await protocol.wait_for_discovery_to_complete() await protocol.wait_for_discovery_to_complete()
except KasaException as ex: except (KasaException, asyncio.CancelledError) as ex:
for device in protocol.discovered_devices.values(): for device in protocol.discovered_devices.values():
await device.protocol.close() await device.protocol.close()
raise ex raise ex
@ -742,6 +750,7 @@ class Discover:
@staticmethod @staticmethod
def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None:
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
if TYPE_CHECKING: if TYPE_CHECKING:
assert discovery_result.encrypt_info assert discovery_result.encrypt_info
assert _AesDiscoveryQuery.keypair assert _AesDiscoveryQuery.keypair
@ -757,7 +766,19 @@ class Discover:
session = AesEncyptionSession(key, iv) session = AesEncyptionSession(key, iv)
decrypted_data = session.decrypt(encrypted_data) decrypted_data = session.decrypt(encrypted_data)
discovery_result.decrypted_data = json_loads(decrypted_data) result = json_loads(decrypted_data)
if debug_enabled:
data = (
redact_data(result, DECRYPTED_REDACTORS)
if Discover._redact_data
else result
)
_LOGGER.debug(
"Decrypted encrypt_info for %s: %s",
discovery_result.ip,
pf(data),
)
discovery_result.decrypted_data = result
@staticmethod @staticmethod
def _get_discovery_json(data: bytes, ip: str) -> dict: def _get_discovery_json(data: bytes, ip: str) -> dict:
@ -826,12 +847,12 @@ class Discover:
): ):
encrypt_type = encrypt_info.sym_schm encrypt_type = encrypt_info.sym_schm
if ( if not (login_version := encrypt_schm.lv) and (
not (login_version := encrypt_schm.lv) et := discovery_result.encrypt_type
and (et := discovery_result.encrypt_type)
and et == ["3"]
): ):
login_version = 2 # Known encrypt types are ["1","2"] and ["3"]
# Reuse the login_version attribute to pass the max to transport
login_version = max([int(i) for i in et])
if not encrypt_type: if not encrypt_type:
raise UnsupportedDeviceError( raise UnsupportedDeviceError(

View File

@ -113,10 +113,23 @@ class HttpClient:
ssl=ssl, ssl=ssl,
) )
async with resp: async with resp:
if resp.status == 200: response_data = await resp.read()
response_data = await resp.read()
if return_json: if resp.status == 200:
if return_json:
response_data = json_loads(response_data.decode())
else:
_LOGGER.debug(
"Device %s received status code %s with response %s",
self._config.host,
resp.status,
str(response_data),
)
if response_data and return_json:
try:
response_data = json_loads(response_data.decode()) response_data = json_loads(response_data.decode())
except Exception:
_LOGGER.debug("Device %s response could not be parsed as json")
except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex:
if not self._wait_between_requests: if not self._wait_between_requests:

View File

@ -23,7 +23,7 @@ Get the light module to interact:
>>> light = dev.modules[Module.Light] >>> light = dev.modules[Module.Light]
You can use the ``is_``-prefixed properties to check for supported features: You can use the ``has_feature()`` method to check for supported features:
>>> light.has_feature("brightness") >>> light.has_feature("brightness")
True True

View File

@ -22,7 +22,7 @@ from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from warnings import warn from warnings import warn
from ..device import Device, WifiNetwork, _DeviceInfo from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..exceptions import KasaException from ..exceptions import KasaException
@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any:
@functools.wraps(f) @functools.wraps(f)
async def wrapped(*args: Any, **kwargs: Any) -> Any: async def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0] self = args[0]
if self._last_update is None and ( if not self._last_update and (
self._sys_info is None or f.__name__ not in self._sys_info self._sys_info is None or f.__name__ not in self._sys_info
): ):
raise KasaException("You need to await update() to access the data") raise KasaException("You need to await update() to access the data")
@ -54,7 +54,7 @@ def requires_update(f: Callable) -> Any:
@functools.wraps(f) @functools.wraps(f)
def wrapped(*args: Any, **kwargs: Any) -> Any: def wrapped(*args: Any, **kwargs: Any) -> Any:
self = args[0] self = args[0]
if self._last_update is None and ( if not self._last_update and (
self._sys_info is None or f.__name__ not in self._sys_info self._sys_info is None or f.__name__ not in self._sys_info
): ):
raise KasaException("You need to await update() to access the data") raise KasaException("You need to await update() to access the data")
@ -112,7 +112,7 @@ class IotDevice(Device):
>>> dev.alias >>> dev.alias
Bedroom Lamp Plug Bedroom Lamp Plug
>>> dev.model >>> dev.model
HS110(EU) HS110
>>> dev.rssi >>> dev.rssi
-71 -71
>>> dev.mac >>> dev.mac
@ -310,7 +310,7 @@ class IotDevice(Device):
# If this is the initial update, check only for the sysinfo # If this is the initial update, check only for the sysinfo
# This is necessary as some devices crash on unexpected modules # This is necessary as some devices crash on unexpected modules
# See #105, #120, #161 # See #105, #120, #161
if self._last_update is None: if not self._last_update:
_LOGGER.debug("Performing the initial update to obtain sysinfo") _LOGGER.debug("Performing the initial update to obtain sysinfo")
response = await self.protocol.query(req) response = await self.protocol.query(req)
self._last_update = response self._last_update = response
@ -452,7 +452,9 @@ class IotDevice(Device):
# This allows setting of some info properties directly # This allows setting of some info properties directly
# from partial discovery info that will then be found # from partial discovery info that will then be found
# by the requires_update decorator # by the requires_update decorator
self._set_sys_info(info) discovery_model = info["device_model"]
no_region_model, _, _ = discovery_model.partition("(")
self._set_sys_info({**info, "model": no_region_model})
def _set_sys_info(self, sys_info: dict[str, Any]) -> None: def _set_sys_info(self, sys_info: dict[str, Any]) -> None:
"""Set sys_info.""" """Set sys_info."""
@ -471,18 +473,13 @@ class IotDevice(Device):
""" """
return self._sys_info # type: ignore return self._sys_info # type: ignore
@property # type: ignore
@requires_update
def model(self) -> str:
"""Return device model."""
sys_info = self._sys_info
return str(sys_info["model"])
@property @property
@requires_update @requires_update
def _model_region(self) -> str: def model(self) -> str:
"""Return device full model name and region.""" """Returns the device model."""
return self.model if self._last_update:
return self.device_info.short_name
return self._sys_info["model"]
@property # type: ignore @property # type: ignore
def alias(self) -> str | None: def alias(self) -> str | None:
@ -748,7 +745,7 @@ class IotDevice(Device):
@staticmethod @staticmethod
def _get_device_info( def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo: ) -> DeviceInfo:
"""Get model information for a device.""" """Get model information for a device."""
sys_info = _extract_sys_info(info) sys_info = _extract_sys_info(info)
@ -766,7 +763,7 @@ class IotDevice(Device):
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
return _DeviceInfo( return DeviceInfo(
short_name=long_name, short_name=long_name,
long_name=long_name, long_name=long_name,
brand="kasa", brand="kasa",

View File

@ -207,17 +207,18 @@ class Light(IotModule, LightInterface):
return self._light_state return self._light_state
async def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
if self._device.is_on is False: device = self._device
if device.is_on is False:
state = LightState(light_on=False) state = LightState(light_on=False)
else: else:
state = LightState(light_on=True) state = LightState(light_on=True)
if self._device._is_dimmable: if device._is_dimmable:
state.brightness = self.brightness state.brightness = self.brightness
if self._device._is_color: if device._is_color:
hsv = self.hsv hsv = self.hsv
state.hue = hsv.hue state.hue = hsv.hue
state.saturation = hsv.saturation state.saturation = hsv.saturation
if self._device._is_variable_color_temp: if device._is_variable_color_temp:
state.color_temp = self.color_temp state.color_temp = self.color_temp
self._light_state = state self._light_state = state

View File

@ -85,17 +85,19 @@ class LightPreset(IotModule, LightPresetInterface):
def preset(self) -> str: def preset(self) -> str:
"""Return current preset name.""" """Return current preset name."""
light = self._device.modules[Module.Light] light = self._device.modules[Module.Light]
is_color = light.has_feature("hsv")
is_variable_color_temp = light.has_feature("color_temp")
brightness = light.brightness brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None color_temp = light.color_temp if is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
for preset_name, preset in self._presets.items(): for preset_name, preset in self._presets.items():
if ( if (
preset.brightness == brightness preset.brightness == brightness
and ( and (preset.color_temp == color_temp or not is_variable_color_temp)
preset.color_temp == color_temp or not light.is_variable_color_temp and (preset.hue == h or not is_color)
) and (preset.saturation == s or not is_color)
and (preset.hue == h or not light.is_color)
and (preset.saturation == s or not light.is_color)
): ):
return preset_name return preset_name
return self.PRESET_NOT_SET return self.PRESET_NOT_SET
@ -107,7 +109,7 @@ class LightPreset(IotModule, LightPresetInterface):
"""Set a light preset for the device.""" """Set a light preset for the device."""
light = self._device.modules[Module.Light] light = self._device.modules[Module.Light]
if preset_name == self.PRESET_NOT_SET: if preset_name == self.PRESET_NOT_SET:
if light.is_color: if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100) preset = LightState(hue=0, saturation=0, brightness=100)
else: else:
preset = LightState(brightness=100) preset = LightState(brightness=100)

View File

@ -21,6 +21,9 @@ check for the existence of the module:
>>> print(light.brightness) >>> print(light.brightness)
100 100
.. include:: ../featureattributes.md
:parser: myst_parser.sphinx_
To see whether a device supports specific functionality, you can check whether the To see whether a device supports specific functionality, you can check whether the
module has that feature: module has that feature:
@ -151,8 +154,12 @@ class Module(ABC):
) )
TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs")
HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit")
Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter")
# SMARTCAM only modules # SMARTCAM only modules
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
def __init__(self, device: Device, module: str) -> None: def __init__(self, device: Device, module: str) -> None:
self._device = device self._device = device

View File

@ -2,6 +2,7 @@
from .iotprotocol import IotProtocol from .iotprotocol import IotProtocol
from .protocol import BaseProtocol from .protocol import BaseProtocol
from .smartcamprotocol import SmartCamProtocol
from .smartprotocol import SmartErrorCode, SmartProtocol from .smartprotocol import SmartErrorCode, SmartProtocol
__all__ = [ __all__ = [
@ -9,4 +10,5 @@ __all__ = [
"IotProtocol", "IotProtocol",
"SmartErrorCode", "SmartErrorCode",
"SmartProtocol", "SmartProtocol",
"SmartCamProtocol",
] ]

View File

@ -25,19 +25,35 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _mask_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]:
result = {
**child,
"id": f"SCRUBBED_CHILD_DEVICE_ID_{index+1}",
}
# Will leave empty aliases as blank
if child.get("alias"):
result["alias"] = f"#MASKED_NAME# {index + 1}"
return result
return [mask_child(child, index) for index, child in enumerate(children)]
REDACTORS: dict[str, Callable[[Any], Any] | None] = { REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0, "latitude": lambda x: 0,
"longitude": lambda x: 0, "longitude": lambda x: 0,
"latitude_i": lambda x: 0, "latitude_i": lambda x: 0,
"longitude_i": lambda x: 0, "longitude_i": lambda x: 0,
"deviceId": lambda x: "REDACTED_" + x[9::], "deviceId": lambda x: "REDACTED_" + x[9::],
"id": lambda x: "REDACTED_" + x[9::], "children": _mask_children,
"alias": lambda x: "#MASKED_NAME#" if x else "", "alias": lambda x: "#MASKED_NAME#" if x else "",
"mac": mask_mac, "mac": mask_mac,
"mic_mac": mask_mac, "mic_mac": mask_mac,
"ssid": lambda x: "#MASKED_SSID#" if x else "", "ssid": lambda x: "#MASKED_SSID#" if x else "",
"oemId": lambda x: "REDACTED_" + x[9::], "oemId": lambda x: "REDACTED_" + x[9::],
"username": lambda _: "user@example.com", # cnCloud "username": lambda _: "user@example.com", # cnCloud
"hwId": lambda x: "REDACTED_" + x[9::],
} }

View File

@ -66,6 +66,8 @@ def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) ->
def mask_mac(mac: str) -> str: def mask_mac(mac: str) -> str:
"""Return mac address with last two octects blanked.""" """Return mac address with last two octects blanked."""
if len(mac) == 12:
return f"{mac[:6]}000000"
delim = ":" if ":" in mac else "-" delim = ":" if ":" in mac else "-"
rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000"))
return f"{mac[:8]}{delim}{rest}" return f"{mac[:8]}{delim}{rest}"

View File

@ -19,7 +19,7 @@ from ..transports.sslaestransport import (
SMART_RETRYABLE_ERRORS, SMART_RETRYABLE_ERRORS,
SmartErrorCode, SmartErrorCode,
) )
from . import SmartProtocol from .smartprotocol import SmartProtocol
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import logging import logging
import re
import time import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
@ -45,15 +46,36 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children
"nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "",
"mac": mask_mac, "mac": mask_mac,
"ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", "ssid": lambda x: "I01BU0tFRF9TU0lEIw==" if x else "",
"bssid": lambda _: "000000000000", "bssid": lambda _: "000000000000",
"channel": lambda _: 0,
"oem_id": lambda x: "REDACTED_" + x[9::], "oem_id": lambda x: "REDACTED_" + x[9::],
"setup_code": None, # matter "hw_id": lambda x: "REDACTED_" + x[9::],
"setup_payload": None, # matter "fw_id": lambda x: "REDACTED_" + x[9::],
"mfi_setup_code": None, # mfi_ for homekit "setup_code": lambda x: re.sub(r"\w", "0", x), # matter
"mfi_setup_id": None, "setup_payload": lambda x: re.sub(r"\w", "0", x), # matter
"mfi_token_token": None, "mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit
"mfi_token_uuid": None, "mfi_setup_id": lambda x: re.sub(r"\w", "0", x),
"mfi_token_token": lambda x: re.sub(r"\w", "0", x),
"mfi_token_uuid": lambda x: re.sub(r"\w", "0", x),
"ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
# smartcam
"dev_id": lambda x: "REDACTED_" + x[9::],
"device_name": lambda x: "#MASKED_NAME#" if x else "",
"device_alias": lambda x: "#MASKED_NAME#" if x else "",
"local_ip": lambda x: x, # don't redact but keep listed here for dump_devinfo
# robovac
"board_sn": lambda _: "000000000000",
"custom_sn": lambda _: "000000000000",
"location": lambda x: "#MASKED_NAME#" if x else "",
"map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "",
}
# Queries that are known not to work properly when sent as a
# multiRequest. They will not return the `method` key.
FORCE_SINGLE_REQUEST = {
"getConnectStatus",
"scanApList",
} }
@ -76,6 +98,7 @@ class SmartProtocol(BaseProtocol):
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
) )
self._redact_data = True self._redact_data = True
self._method_missing_logged = False
def get_smart_request(self, method: str, params: dict | None = None) -> str: def get_smart_request(self, method: str, params: dict | None = None) -> str:
"""Get a request message as a string.""" """Get a request message as a string."""
@ -162,17 +185,18 @@ class SmartProtocol(BaseProtocol):
multi_result: dict[str, Any] = {} multi_result: dict[str, Any] = {}
smart_method = "multipleRequest" smart_method = "multipleRequest"
multi_requests = [ end = len(requests)
{"method": method, "params": params} if params else {"method": method}
for method, params in requests.items()
]
end = len(multi_requests)
# The SmartCamProtocol sends requests with a length 1 as a # The SmartCamProtocol sends requests with a length 1 as a
# multipleRequest. The SmartProtocol doesn't so will never # multipleRequest. The SmartProtocol doesn't so will never
# raise_on_error # raise_on_error
raise_on_error = end == 1 raise_on_error = end == 1
multi_requests = [
{"method": method, "params": params} if params else {"method": method}
for method, params in requests.items()
if method not in FORCE_SINGLE_REQUEST
]
# Break the requests down as there can be a size limit # Break the requests down as there can be a size limit
step = self._multi_request_batch_size step = self._multi_request_batch_size
if step == 1: if step == 1:
@ -233,7 +257,20 @@ class SmartProtocol(BaseProtocol):
responses = response_step["result"]["responses"] responses = response_step["result"]["responses"]
for response in responses: for response in responses:
method = response["method"] # some smartcam devices calls do not populate the method key
# these should be defined in DO_NOT_SEND_AS_MULTI_REQUEST.
if not (method := response.get("method")):
if not self._method_missing_logged:
# Avoid spamming the logs
self._method_missing_logged = True
_LOGGER.error(
"No method key in response for %s, skipping: %s",
self._host,
response_step,
)
# These will end up being queried individually
continue
self._handle_response_error_code( self._handle_response_error_code(
response, method, raise_on_error=raise_on_error response, method, raise_on_error=raise_on_error
) )
@ -242,13 +279,17 @@ class SmartProtocol(BaseProtocol):
result, method, retry_count=retry_count result, method, retry_count=retry_count
) )
multi_result[method] = result multi_result[method] = result
# Multi requests don't continue after errors so requery any missing
# Multi requests don't continue after errors so requery any missing.
# Will also query individually any DO_NOT_SEND_AS_MULTI_REQUEST.
for method, params in requests.items(): for method, params in requests.items():
if method not in multi_result: if method not in multi_result:
resp = await self._transport.send( resp = await self._transport.send(
self.get_smart_request(method, params) self.get_smart_request(method, params)
) )
self._handle_response_error_code(resp, method, raise_on_error=False) self._handle_response_error_code(
resp, method, raise_on_error=raise_on_error
)
multi_result[method] = resp.get("result") multi_result[method] = resp.get("result")
return multi_result return multi_result

View File

@ -16,6 +16,7 @@ from .energy import Energy
from .fan import Fan from .fan import Fan
from .firmware import Firmware from .firmware import Firmware
from .frostprotection import FrostProtection from .frostprotection import FrostProtection
from .homekit import HomeKit
from .humiditysensor import HumiditySensor from .humiditysensor import HumiditySensor
from .led import Led from .led import Led
from .light import Light from .light import Light
@ -23,6 +24,7 @@ from .lighteffect import LightEffect
from .lightpreset import LightPreset from .lightpreset import LightPreset
from .lightstripeffect import LightStripEffect from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .matter import Matter
from .motionsensor import MotionSensor from .motionsensor import MotionSensor
from .overheatprotection import OverheatProtection from .overheatprotection import OverheatProtection
from .reportmode import ReportMode from .reportmode import ReportMode
@ -66,4 +68,6 @@ __all__ = [
"Thermostat", "Thermostat",
"SmartLightEffect", "SmartLightEffect",
"OverheatProtection", "OverheatProtection",
"HomeKit",
"Matter",
] ]

View File

@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import NoReturn from typing import Any, NoReturn
from ...emeterstatus import EmeterStatus from ...emeterstatus import EmeterStatus
from ...exceptions import KasaException from ...exceptions import DeviceError, KasaException
from ...interfaces.energy import Energy as EnergyInterface from ...interfaces.energy import Energy as EnergyInterface
from ..smartmodule import SmartModule, raise_if_update_error from ..smartmodule import SmartModule, raise_if_update_error
@ -15,12 +15,39 @@ class Energy(SmartModule, EnergyInterface):
REQUIRED_COMPONENT = "energy_monitoring" REQUIRED_COMPONENT = "energy_monitoring"
_energy: dict[str, Any]
_current_consumption: float | None
async def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
if "voltage_mv" in self.data.get("get_emeter_data", {}): try:
data = self.data
except DeviceError as de:
self._energy = {}
self._current_consumption = None
raise de
# If version is 1 then data is get_energy_usage
self._energy = data.get("get_energy_usage", data)
if "voltage_mv" in data.get("get_emeter_data", {}):
self._supported = ( self._supported = (
self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT
) )
if (power := self._energy.get("current_power")) is not None or (
power := data.get("get_emeter_data", {}).get("power_mw")
) is not None:
self._current_consumption = power / 1_000
# Fallback if get_energy_usage does not provide current_power,
# which can happen on some newer devices (e.g. P304M).
# This may not be valid scenario as it pre-dates trying get_emeter_data
elif (
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
self._current_consumption = power
else:
self._current_consumption = None
def query(self) -> dict: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
req = { req = {
@ -33,28 +60,21 @@ class Energy(SmartModule, EnergyInterface):
return req return req
@property @property
@raise_if_update_error def optional_response_keys(self) -> list[str]:
def current_consumption(self) -> float | None: """Return optional response keys for the module."""
"""Current power in watts.""" if self.supported_version > 1:
if (power := self.energy.get("current_power")) is not None or ( return ["get_energy_usage"]
power := self.data.get("get_emeter_data", {}).get("power_mw") return []
) is not None:
return power / 1_000 @property
# Fallback if get_energy_usage does not provide current_power, def current_consumption(self) -> float | None:
# which can happen on some newer devices (e.g. P304M). """Current power in watts."""
elif ( return self._current_consumption
power := self.data.get("get_current_power", {}).get("current_power")
) is not None:
return power
return None
@property @property
@raise_if_update_error
def energy(self) -> dict: def energy(self) -> dict:
"""Return get_energy_usage results.""" """Return get_energy_usage results."""
if en := self.data.get("get_energy_usage"): return self._energy
return en
return self.data
def _get_status_from_energy(self, energy: dict) -> EmeterStatus: def _get_status_from_energy(self, energy: dict) -> EmeterStatus:
return EmeterStatus( return EmeterStatus(
@ -83,16 +103,18 @@ class Energy(SmartModule, EnergyInterface):
return self._get_status_from_energy(res["get_energy_usage"]) return self._get_status_from_energy(res["get_energy_usage"])
@property @property
@raise_if_update_error
def consumption_this_month(self) -> float | None: def consumption_this_month(self) -> float | None:
"""Get the emeter value for this month in kWh.""" """Get the emeter value for this month in kWh."""
return self.energy.get("month_energy", 0) / 1_000 if (month := self.energy.get("month_energy")) is not None:
return month / 1_000
return None
@property @property
@raise_if_update_error
def consumption_today(self) -> float | None: def consumption_today(self) -> float | None:
"""Get the emeter value for today in kWh.""" """Get the emeter value for today in kWh."""
return self.energy.get("today_energy", 0) / 1_000 if (today := self.energy.get("today_energy")) is not None:
return today / 1_000
return None
@property @property
@raise_if_update_error @raise_if_update_error

View File

@ -0,0 +1,32 @@
"""Implementation of homekit module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class HomeKit(SmartModule):
"""Implementation of homekit module."""
QUERY_GETTER_NAME: str = "get_homekit_info"
REQUIRED_COMPONENT = "homekit"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="homekit_setup_code",
name="Homekit setup code",
container=self,
attribute_getter=lambda x: x.info["mfi_setup_code"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
@property
def info(self) -> dict[str, str]:
"""Homekit mfi setup info."""
return self.data

View File

@ -136,16 +136,17 @@ class Light(SmartModule, LightInterface):
return self._light_state return self._light_state
async def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
if self._device.is_on is False: device = self._device
if device.is_on is False:
state = LightState(light_on=False) state = LightState(light_on=False)
else: else:
state = LightState(light_on=True) state = LightState(light_on=True)
if Module.Brightness in self._device.modules: if Module.Brightness in device.modules:
state.brightness = self.brightness state.brightness = self.brightness
if Module.Color in self._device.modules: if Module.Color in device.modules:
hsv = self.hsv hsv = self.hsv
state.hue = hsv.hue state.hue = hsv.hue
state.saturation = hsv.saturation state.saturation = hsv.saturation
if Module.ColorTemperature in self._device.modules: if Module.ColorTemperature in device.modules:
state.color_temp = self.color_temp state.color_temp = self.color_temp
self._light_state = state self._light_state = state

View File

@ -0,0 +1,43 @@
"""Implementation of matter module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class Matter(SmartModule):
"""Implementation of matter module."""
QUERY_GETTER_NAME: str = "get_matter_setup_info"
REQUIRED_COMPONENT = "matter"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="matter_setup_code",
name="Matter setup code",
container=self,
attribute_getter=lambda x: x.info["setup_code"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
self._add_feature(
Feature(
self._device,
id="matter_setup_payload",
name="Matter setup payload",
container=self,
attribute_getter=lambda x: x.info["setup_payload"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
@property
def info(self) -> dict[str, str]:
"""Matter setup info."""
return self.data

View File

@ -6,10 +6,11 @@ import logging
import time import time
from typing import Any from typing import Any
from ..device import DeviceInfo
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from .smartdevice import SmartDevice from .smartdevice import ComponentsRaw, SmartDevice
from .smartmodule import SmartModule from .smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -37,7 +38,7 @@ class SmartChildDevice(SmartDevice):
self, self,
parent: SmartDevice, parent: SmartDevice,
info: dict, info: dict,
component_info: dict, component_info_raw: ComponentsRaw,
*, *,
config: DeviceConfig | None = None, config: DeviceConfig | None = None,
protocol: SmartProtocol | None = None, protocol: SmartProtocol | None = None,
@ -47,7 +48,24 @@ class SmartChildDevice(SmartDevice):
super().__init__(parent.host, config=parent.config, protocol=_protocol) super().__init__(parent.host, config=parent.config, protocol=_protocol)
self._parent = parent self._parent = parent
self._update_internal_state(info) self._update_internal_state(info)
self._components = component_info self._components_raw = component_info_raw
self._components = self._parse_components(self._components_raw)
@property
def device_info(self) -> DeviceInfo:
"""Return device info.
Child device does not have it info and components in _last_update so
this overrides the base implementation to call _get_device_info with
info and components combined as they would be in _last_update.
"""
return self._get_device_info(
{
"get_device_info": self._info,
"component_nego": self._components_raw,
},
None,
)
async def update(self, update_children: bool = True) -> None: async def update(self, update_children: bool = True) -> None:
"""Update child module info. """Update child module info.
@ -84,7 +102,7 @@ class SmartChildDevice(SmartDevice):
cls, cls,
parent: SmartDevice, parent: SmartDevice,
child_info: dict, child_info: dict,
child_components: dict, child_components_raw: ComponentsRaw,
protocol: SmartProtocol | None = None, protocol: SmartProtocol | None = None,
*, *,
last_update: dict | None = None, last_update: dict | None = None,
@ -97,7 +115,7 @@ class SmartChildDevice(SmartDevice):
derived from the parent. derived from the parent.
""" """
child: SmartChildDevice = cls( child: SmartChildDevice = cls(
parent, child_info, child_components, protocol=protocol parent, child_info, child_components_raw, protocol=protocol
) )
if last_update: if last_update:
child._last_update = last_update child._last_update = last_update

View File

@ -7,9 +7,9 @@ import logging
import time import time
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import UTC, datetime, timedelta, tzinfo from datetime import UTC, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, TypeAlias, cast
from ..device import Device, WifiNetwork, _DeviceInfo from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
@ -40,6 +40,8 @@ _LOGGER = logging.getLogger(__name__)
# same issue, homekit perhaps? # same issue, homekit perhaps?
NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud]
ComponentsRaw: TypeAlias = dict[str, list[dict[str, int | str]]]
# Device must go last as the other interfaces also inherit Device # Device must go last as the other interfaces also inherit Device
# and python needs a consistent method resolution order. # and python needs a consistent method resolution order.
@ -61,13 +63,12 @@ class SmartDevice(Device):
) )
super().__init__(host=host, config=config, protocol=_protocol) super().__init__(host=host, config=config, protocol=_protocol)
self.protocol: SmartProtocol self.protocol: SmartProtocol
self._components_raw: dict[str, Any] | None = None self._components_raw: ComponentsRaw | None = None
self._components: dict[str, int] = {} self._components: dict[str, int] = {}
self._state_information: dict[str, Any] = {} self._state_information: dict[str, Any] = {}
self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._modules: dict[str | ModuleName[Module], SmartModule] = {}
self._parent: SmartDevice | None = None self._parent: SmartDevice | None = None
self._children: Mapping[str, SmartDevice] = {} self._children: Mapping[str, SmartDevice] = {}
self._last_update = {}
self._last_update_time: float | None = None self._last_update_time: float | None = None
self._on_since: datetime | None = None self._on_since: datetime | None = None
self._info: dict[str, Any] = {} self._info: dict[str, Any] = {}
@ -82,10 +83,8 @@ class SmartDevice(Device):
self.internal_state.update(resp) self.internal_state.update(resp)
children = self.internal_state["get_child_device_list"]["child_device_list"] children = self.internal_state["get_child_device_list"]["child_device_list"]
children_components = { children_components_raw = {
child["device_id"]: { child["device_id"]: child
comp["id"]: int(comp["ver_code"]) for comp in child["component_list"]
}
for child in self.internal_state["get_child_device_component_list"][ for child in self.internal_state["get_child_device_component_list"][
"child_component_list" "child_component_list"
] ]
@ -96,7 +95,7 @@ class SmartDevice(Device):
child_info["device_id"]: await SmartChildDevice.create( child_info["device_id"]: await SmartChildDevice.create(
parent=self, parent=self,
child_info=child_info, child_info=child_info,
child_components=children_components[child_info["device_id"]], child_components_raw=children_components_raw[child_info["device_id"]],
) )
for child_info in children for child_info in children
} }
@ -131,6 +130,13 @@ class SmartDevice(Device):
f"{request} not found in {responses} for device {self.host}" f"{request} not found in {responses} for device {self.host}"
) )
@staticmethod
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
return {
str(comp["id"]): int(comp["ver_code"])
for comp in components_raw["component_list"]
}
async def _negotiate(self) -> None: async def _negotiate(self) -> None:
"""Perform initialization. """Perform initialization.
@ -151,12 +157,9 @@ class SmartDevice(Device):
self._info = self._try_get_response(resp, "get_device_info") self._info = self._try_get_response(resp, "get_device_info")
# Create our internal presentation of available components # Create our internal presentation of available components
self._components_raw = cast(dict, resp["component_nego"]) self._components_raw = cast(ComponentsRaw, resp["component_nego"])
self._components = { self._components = self._parse_components(self._components_raw)
comp["id"]: int(comp["ver_code"])
for comp in self._components_raw["component_list"]
}
if "child_device" in self._components and not self.children: if "child_device" in self._components and not self.children:
await self._initialize_children() await self._initialize_children()
@ -493,18 +496,13 @@ class SmartDevice(Device):
@property @property
def model(self) -> str: def model(self) -> str:
"""Returns the device model.""" """Returns the device model."""
return str(self._info.get("model")) # If update hasn't been called self._device_info can't be used
if self._last_update:
return self.device_info.short_name
@property disco_model = str(self._info.get("device_model"))
def _model_region(self) -> str: long_name, _, _ = disco_model.partition("(")
"""Return device full model name and region.""" return long_name
if (disco := self._discovery_info) and (
disco_model := disco.get("device_model")
):
return disco_model
# Some devices have the region in the specs element.
region = f"({specs})" if (specs := self._info.get("specs")) else ""
return f"{self.model}{region}"
@property @property
def alias(self) -> str | None: def alias(self) -> str | None:
@ -804,7 +802,7 @@ class SmartDevice(Device):
@staticmethod @staticmethod
def _get_device_info( def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo: ) -> DeviceInfo:
"""Get model information for a device.""" """Get model information for a device."""
di = info["get_device_info"] di = info["get_device_info"]
components = [comp["id"] for comp in info["component_nego"]["component_list"]] components = [comp["id"] for comp in info["component_nego"]["component_list"]]
@ -833,7 +831,7 @@ class SmartDevice(Device):
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
brand = devicetype[:4].lower() brand = devicetype[:4].lower()
return _DeviceInfo( return DeviceInfo(
short_name=short_name, short_name=short_name,
long_name=long_name, long_name=long_name,
brand=brand, brand=brand,

View File

@ -57,7 +57,7 @@ class SmartModule(Module):
#: Module is initialized, if any of the given keys exists in the sysinfo #: Module is initialized, if any of the given keys exists in the sysinfo
SYSINFO_LOOKUP_KEYS: list[str] = [] SYSINFO_LOOKUP_KEYS: list[str] = []
#: Query to execute during the main update cycle #: Query to execute during the main update cycle
QUERY_GETTER_NAME: str QUERY_GETTER_NAME: str = ""
REGISTERED_MODULES: dict[str, type[SmartModule]] = {} REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
@ -72,6 +72,7 @@ class SmartModule(Module):
self._last_update_time: float | None = None self._last_update_time: float | None = None
self._last_update_error: KasaException | None = None self._last_update_error: KasaException | None = None
self._error_count = 0 self._error_count = 0
self._logged_remove_keys: list[str] = []
def __init_subclass__(cls, **kwargs) -> None: def __init_subclass__(cls, **kwargs) -> None:
# We only want to register submodules in a modules package so that # We only want to register submodules in a modules package so that
@ -138,7 +139,9 @@ class SmartModule(Module):
Default implementation uses the raw query getter w/o parameters. Default implementation uses the raw query getter w/o parameters.
""" """
return {self.QUERY_GETTER_NAME: None} if self.QUERY_GETTER_NAME:
return {self.QUERY_GETTER_NAME: None}
return {}
async def call(self, method: str, params: dict | None = None) -> dict: async def call(self, method: str, params: dict | None = None) -> dict:
"""Call a method. """Call a method.
@ -147,6 +150,15 @@ class SmartModule(Module):
""" """
return await self._device._query_helper(method, params) return await self._device._query_helper(method, params)
@property
def optional_response_keys(self) -> list[str]:
"""Return optional response keys for the module.
Defaults to no keys. Overriding this and providing keys will remove
instead of raise on error.
"""
return []
@property @property
def data(self) -> dict[str, Any]: def data(self) -> dict[str, Any]:
"""Return response data for the module. """Return response data for the module.
@ -179,12 +191,31 @@ class SmartModule(Module):
filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys}
remove_keys: list[str] = []
for data_item in filtered_data: for data_item in filtered_data:
if isinstance(filtered_data[data_item], SmartErrorCode): if isinstance(filtered_data[data_item], SmartErrorCode):
raise DeviceError( if data_item in self.optional_response_keys:
f"{data_item} for {self.name}", error_code=filtered_data[data_item] remove_keys.append(data_item)
else:
raise DeviceError(
f"{data_item} for {self.name}",
error_code=filtered_data[data_item],
)
for key in remove_keys:
if key not in self._logged_remove_keys:
self._logged_remove_keys.append(key)
_LOGGER.debug(
"Removed key %s from response for device %s as it returned "
"error: %s. This message will only be logged once per key.",
key,
self._device.host,
filtered_data[key],
) )
if len(filtered_data) == 1:
filtered_data.pop(key)
if len(filtered_data) == 1 and not remove_keys:
return next(iter(filtered_data.values())) return next(iter(filtered_data.values()))
return filtered_data return filtered_data

View File

@ -1,19 +1,33 @@
"""Modules for SMARTCAM devices.""" """Modules for SMARTCAM devices."""
from .alarm import Alarm from .alarm import Alarm
from .babycrydetection import BabyCryDetection
from .camera import Camera from .camera import Camera
from .childdevice import ChildDevice from .childdevice import ChildDevice
from .device import DeviceModule from .device import DeviceModule
from .homekit import HomeKit
from .led import Led from .led import Led
from .lensmask import LensMask
from .matter import Matter
from .motiondetection import MotionDetection
from .pantilt import PanTilt from .pantilt import PanTilt
from .persondetection import PersonDetection
from .tamperdetection import TamperDetection
from .time import Time from .time import Time
__all__ = [ __all__ = [
"Alarm", "Alarm",
"BabyCryDetection",
"Camera", "Camera",
"ChildDevice", "ChildDevice",
"DeviceModule", "DeviceModule",
"Led", "Led",
"PanTilt", "PanTilt",
"PersonDetection",
"Time", "Time",
"HomeKit",
"Matter",
"MotionDetection",
"LensMask",
"TamperDetection",
] ]

View File

@ -0,0 +1,47 @@
"""Implementation of baby cry detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class BabyCryDetection(SmartCamModule):
"""Implementation of baby cry detection module."""
REQUIRED_COMPONENT = "babyCryDetection"
QUERY_GETTER_NAME = "getBCDConfig"
QUERY_MODULE_NAME = "sound_detection"
QUERY_SECTION_NAMES = "bcd"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="baby_cry_detection",
name="Baby cry detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the baby cry detection enabled state."""
return self.data["bcd"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the baby cry detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setBCDConfig", self.QUERY_MODULE_NAME, "bcd", params
)

View File

@ -1,16 +1,18 @@
"""Implementation of device module.""" """Implementation of camera module."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging import logging
from enum import StrEnum from enum import StrEnum
from typing import Annotated
from urllib.parse import quote_plus from urllib.parse import quote_plus
from ...credentials import Credentials from ...credentials import Credentials
from ...device_type import DeviceType from ...device_type import DeviceType
from ...feature import Feature from ...feature import Feature
from ...json import loads as json_loads from ...json import loads as json_loads
from ...module import FeatureAttribute, Module
from ..smartcammodule import SmartCamModule from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,28 +31,38 @@ class StreamResolution(StrEnum):
class Camera(SmartCamModule): class Camera(SmartCamModule):
"""Implementation of device module.""" """Implementation of device module."""
QUERY_GETTER_NAME = "getLensMaskConfig"
QUERY_MODULE_NAME = "lens_mask"
QUERY_SECTION_NAMES = "lens_mask_info"
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""
self._add_feature( if Module.LensMask in self._device.modules:
Feature( self._add_feature(
self._device, Feature(
id="state", self._device,
name="State", id="state",
attribute_getter="is_on", name="State",
attribute_setter="set_state", container=self,
type=Feature.Type.Switch, attribute_getter="is_on",
category=Feature.Category.Primary, attribute_setter="set_state",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
) )
)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return the device id.""" """Return the device on state."""
return self.data["lens_mask_info"]["enabled"] == "off" if lens_mask := self._device.modules.get(Module.LensMask):
return not lens_mask.enabled
return True
async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]:
"""Set the device on state.
If the device does not support setting state will do nothing.
"""
if lens_mask := self._device.modules.get(Module.LensMask):
# Turning off enables the privacy mask which is why value is reversed.
return await lens_mask.set_enabled(not on)
return {}
def _get_credentials(self) -> Credentials | None: def _get_credentials(self) -> Credentials | None:
"""Get credentials from .""" """Get credentials from ."""
@ -109,14 +121,6 @@ class Camera(SmartCamModule):
"""Return the onvif url.""" """Return the onvif url."""
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service"
async def set_state(self, on: bool) -> dict:
"""Set the device state."""
# Turning off enables the privacy mask which is why value is reversed.
params = {"enabled": "off" if on else "on"}
return await self._device._query_setter_helper(
"setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params
)
async def _check_supported(self) -> bool: async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device.""" """Additional check to see if the module is supported by the device."""
return self._device.device_type is DeviceType.Camera return self._device.device_type is DeviceType.Camera

View File

@ -14,6 +14,13 @@ class DeviceModule(SmartCamModule):
QUERY_MODULE_NAME = "device_info" QUERY_MODULE_NAME = "device_info"
QUERY_SECTION_NAMES = ["basic_info", "info"] QUERY_SECTION_NAMES = ["basic_info", "info"]
def query(self) -> dict:
"""Query to execute during the update cycle."""
q = super().query()
q["getConnectionType"] = {"network": {"get_connection_type": []}}
return q
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""
self._add_feature( self._add_feature(
@ -26,6 +33,32 @@ class DeviceModule(SmartCamModule):
type=Feature.Type.Sensor, type=Feature.Type.Sensor,
) )
) )
if self.rssi is not None:
self._add_feature(
Feature(
self._device,
container=self,
id="rssi",
name="RSSI",
attribute_getter="rssi",
icon="mdi:signal",
unit_getter=lambda: "dBm",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
)
self._add_feature(
Feature(
self._device,
container=self,
id="signal_level",
name="Signal Level",
attribute_getter="signal_level",
icon="mdi:signal",
category=Feature.Category.Info,
type=Feature.Type.Sensor,
)
)
async def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
"""Overriden to prevent module disabling. """Overriden to prevent module disabling.
@ -37,4 +70,14 @@ class DeviceModule(SmartCamModule):
@property @property
def device_id(self) -> str: def device_id(self) -> str:
"""Return the device id.""" """Return the device id."""
return self.data["basic_info"]["dev_id"] return self.data[self.QUERY_GETTER_NAME]["basic_info"]["dev_id"]
@property
def rssi(self) -> int | None:
"""Return the device id."""
return self.data["getConnectionType"].get("rssiValue")
@property
def signal_level(self) -> int | None:
"""Return the device id."""
return self.data["getConnectionType"].get("rssi")

View File

@ -0,0 +1,16 @@
"""Implementation of homekit module."""
from __future__ import annotations
from ..smartcammodule import SmartCamModule
class HomeKit(SmartCamModule):
"""Implementation of homekit module."""
REQUIRED_COMPONENT = "homekit"
@property
def info(self) -> dict[str, str]:
"""Not supported, return empty dict."""
return {}

View File

@ -0,0 +1,31 @@
"""Implementation of lens mask privacy module."""
from __future__ import annotations
import logging
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class LensMask(SmartCamModule):
"""Implementation of lens mask module."""
REQUIRED_COMPONENT = "lensMask"
QUERY_GETTER_NAME = "getLensMaskConfig"
QUERY_MODULE_NAME = "lens_mask"
QUERY_SECTION_NAMES = "lens_mask_info"
@property
def enabled(self) -> bool:
"""Return the lens mask state."""
return self.data["lens_mask_info"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the lens mask state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params
)

View File

@ -0,0 +1,44 @@
"""Implementation of matter module."""
from __future__ import annotations
from ...feature import Feature
from ..smartcammodule import SmartCamModule
class Matter(SmartCamModule):
"""Implementation of matter module."""
QUERY_GETTER_NAME = "getMatterSetupInfo"
QUERY_MODULE_NAME = "matter"
REQUIRED_COMPONENT = "matter"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="matter_setup_code",
name="Matter setup code",
container=self,
attribute_getter=lambda x: x.info["setup_code"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
self._add_feature(
Feature(
self._device,
id="matter_setup_payload",
name="Matter setup payload",
container=self,
attribute_getter=lambda x: x.info["setup_payload"],
type=Feature.Type.Sensor,
category=Feature.Category.Debug,
)
)
@property
def info(self) -> dict[str, str]:
"""Matter setup info."""
return self.data

View File

@ -0,0 +1,47 @@
"""Implementation of motion detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class MotionDetection(SmartCamModule):
"""Implementation of motion detection module."""
REQUIRED_COMPONENT = "detection"
QUERY_GETTER_NAME = "getDetectionConfig"
QUERY_MODULE_NAME = "motion_detection"
QUERY_SECTION_NAMES = "motion_det"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="motion_detection",
name="Motion detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the motion detection enabled state."""
return self.data["motion_det"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the motion detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setDetectionConfig", self.QUERY_MODULE_NAME, "motion_det", params
)

View File

@ -0,0 +1,47 @@
"""Implementation of person detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class PersonDetection(SmartCamModule):
"""Implementation of person detection module."""
REQUIRED_COMPONENT = "personDetection"
QUERY_GETTER_NAME = "getPersonDetectionConfig"
QUERY_MODULE_NAME = "people_detection"
QUERY_SECTION_NAMES = "detection"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="person_detection",
name="Person detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the person detection enabled state."""
return self.data["detection"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the person detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setPersonDetectionConfig", self.QUERY_MODULE_NAME, "detection", params
)

View File

@ -0,0 +1,47 @@
"""Implementation of tamper detection module."""
from __future__ import annotations
import logging
from ...feature import Feature
from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__)
class TamperDetection(SmartCamModule):
"""Implementation of tamper detection module."""
REQUIRED_COMPONENT = "tamperDetection"
QUERY_GETTER_NAME = "getTamperDetectionConfig"
QUERY_MODULE_NAME = "tamper_detection"
QUERY_SECTION_NAMES = "tamper_det"
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
id="tamper_detection",
name="Tamper detection",
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@property
def enabled(self) -> bool:
"""Return the tamper detection enabled state."""
return self.data["tamper_det"]["enabled"] == "on"
async def set_enabled(self, enable: bool) -> dict:
"""Set the tamper detection enabled state."""
params = {"enabled": "on" if enable else "off"}
return await self._device._query_setter_helper(
"setTamperDetectionConfig", self.QUERY_MODULE_NAME, "tamper_det", params
)

View File

@ -3,13 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, cast
from ..device import _DeviceInfo from ..device import DeviceInfo
from ..device_type import DeviceType from ..device_type import DeviceType
from ..module import Module from ..module import Module
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
from ..smart import SmartChildDevice, SmartDevice from ..smart import SmartChildDevice, SmartDevice
from ..smart.smartdevice import ComponentsRaw
from .modules import ChildDevice, DeviceModule from .modules import ChildDevice, DeviceModule
from .smartcammodule import SmartCamModule from .smartcammodule import SmartCamModule
@ -36,7 +37,7 @@ class SmartCamDevice(SmartDevice):
@staticmethod @staticmethod
def _get_device_info( def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo: ) -> DeviceInfo:
"""Get model information for a device.""" """Get model information for a device."""
basic_info = info["getDeviceInfo"]["device_info"]["basic_info"] basic_info = info["getDeviceInfo"]["device_info"]["basic_info"]
short_name = basic_info["device_model"] short_name = basic_info["device_model"]
@ -44,7 +45,7 @@ class SmartCamDevice(SmartDevice):
device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info) device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info)
fw_version_full = basic_info["sw_version"] fw_version_full = basic_info["sw_version"]
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
return _DeviceInfo( return DeviceInfo(
short_name=basic_info["device_model"], short_name=basic_info["device_model"],
long_name=long_name, long_name=long_name,
brand="tapo", brand="tapo",
@ -78,7 +79,7 @@ class SmartCamDevice(SmartDevice):
self._children[child_id]._update_internal_state(info) self._children[child_id]._update_internal_state(info)
async def _initialize_smart_child( async def _initialize_smart_child(
self, info: dict, child_components: dict self, info: dict, child_components_raw: ComponentsRaw
) -> SmartDevice: ) -> SmartDevice:
"""Initialize a smart child device attached to a smartcam device.""" """Initialize a smart child device attached to a smartcam device."""
child_id = info["device_id"] child_id = info["device_id"]
@ -93,7 +94,7 @@ class SmartCamDevice(SmartDevice):
return await SmartChildDevice.create( return await SmartChildDevice.create(
parent=self, parent=self,
child_info=info, child_info=info,
child_components=child_components, child_components_raw=child_components_raw,
protocol=child_protocol, protocol=child_protocol,
last_update=initial_response, last_update=initial_response,
) )
@ -108,17 +109,8 @@ class SmartCamDevice(SmartDevice):
self.internal_state.update(resp) self.internal_state.update(resp)
smart_children_components = { smart_children_components = {
child["device_id"]: { child["device_id"]: child
comp["id"]: int(comp["ver_code"]) for comp in component_list
}
for child in resp["getChildDeviceComponentList"]["child_component_list"] for child in resp["getChildDeviceComponentList"]["child_component_list"]
if (component_list := child.get("component_list"))
# Child camera devices will have a different component schema so only
# extract smart values.
and (first_comp := next(iter(component_list), None))
and isinstance(first_comp, dict)
and "id" in first_comp
and "ver_code" in first_comp
} }
children = {} children = {}
for info in resp["getChildDeviceList"]["child_device_list"]: for info in resp["getChildDeviceList"]["child_device_list"]:
@ -142,6 +134,11 @@ class SmartCamDevice(SmartDevice):
if ( if (
mod.REQUIRED_COMPONENT mod.REQUIRED_COMPONENT
and mod.REQUIRED_COMPONENT not in self._components and mod.REQUIRED_COMPONENT not in self._components
# Always add Camera module to cameras
and (
mod._module_name() != Module.Camera
or self._device_type is not DeviceType.Camera
)
): ):
continue continue
module = mod(self, mod._module_name()) module = mod(self, mod._module_name())
@ -172,6 +169,13 @@ class SmartCamDevice(SmartDevice):
return res return res
@staticmethod
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
return {
str(comp["name"]): int(comp["version"])
for comp in components_raw["app_component_list"]
}
async def _negotiate(self) -> None: async def _negotiate(self) -> None:
"""Perform initialization. """Perform initialization.
@ -181,17 +185,16 @@ class SmartCamDevice(SmartDevice):
initial_query = { initial_query = {
"getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}},
"getAppComponentList": {"app_component": {"name": "app_component_list"}}, "getAppComponentList": {"app_component": {"name": "app_component_list"}},
"getConnectionType": {"network": {"get_connection_type": {}}},
} }
resp = await self.protocol.query(initial_query) resp = await self.protocol.query(initial_query)
self._last_update.update(resp) self._last_update.update(resp)
self._update_internal_info(resp) self._update_internal_info(resp)
self._components = { self._components_raw = cast(
comp["name"]: int(comp["version"]) ComponentsRaw, resp["getAppComponentList"]["app_component"]
for comp in resp["getAppComponentList"]["app_component"][ )
"app_component_list" self._components = self._parse_components(self._components_raw)
]
}
if "childControl" in self._components and not self.children: if "childControl" in self._components and not self.children:
await self._initialize_children() await self._initialize_children()
@ -251,11 +254,16 @@ class SmartCamDevice(SmartDevice):
def hw_info(self) -> dict: def hw_info(self) -> dict:
"""Return hardware info for the device.""" """Return hardware info for the device."""
return { return {
"sw_ver": self._info.get("hw_ver"), "sw_ver": self._info.get("fw_ver"),
"hw_ver": self._info.get("fw_ver"), "hw_ver": self._info.get("hw_ver"),
"mac": self._info.get("mac"), "mac": self._info.get("mac"),
"type": self._info.get("type"), "type": self._info.get("type"),
"hwId": self._info.get("hwId"), "hwId": self._info.get("hwId"),
"dev_name": self.alias, "dev_name": self.alias,
"oemId": self._info.get("oem_id"), "oemId": self._info.get("oem_id"),
} }
@property
def rssi(self) -> int | None:
"""Return the device id."""
return self.modules[SmartCamModule.SmartCamDeviceModule].rssi

View File

@ -20,9 +20,23 @@ class SmartCamModule(SmartModule):
"""Base class for SMARTCAM modules.""" """Base class for SMARTCAM modules."""
SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm") SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm")
SmartCamMotionDetection: Final[ModuleName[modules.MotionDetection]] = ModuleName(
"MotionDetection"
)
SmartCamPersonDetection: Final[ModuleName[modules.PersonDetection]] = ModuleName(
"PersonDetection"
)
SmartCamTamperDetection: Final[ModuleName[modules.TamperDetection]] = ModuleName(
"TamperDetection"
)
SmartCamBabyCryDetection: Final[ModuleName[modules.BabyCryDetection]] = ModuleName(
"BabyCryDetection"
)
SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName(
"devicemodule"
)
#: Query to execute during the main update cycle
QUERY_GETTER_NAME: str
#: Module name to be queried #: Module name to be queried
QUERY_MODULE_NAME: str QUERY_MODULE_NAME: str
#: Section name or names to be queried #: Section name or names to be queried
@ -37,6 +51,8 @@ class SmartCamModule(SmartModule):
Default implementation uses the raw query getter w/o parameters. Default implementation uses the raw query getter w/o parameters.
""" """
if not self.QUERY_GETTER_NAME:
return {}
section_names = ( section_names = (
{"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {} {"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {}
) )
@ -86,7 +102,8 @@ class SmartCamModule(SmartModule):
f" for '{self._module}'" f" for '{self._module}'"
) )
return query_resp.get(self.QUERY_MODULE_NAME) # Some calls return the data under the module, others not
return query_resp.get(self.QUERY_MODULE_NAME, query_resp)
else: else:
found = {key: val for key, val in dev._last_update.items() if key in q} found = {key: val for key, val in dev._last_update.items() if key in q}
for key in q: for key in q:

View File

@ -4,6 +4,7 @@ from .aestransport import AesEncyptionSession, AesTransport
from .basetransport import BaseTransport from .basetransport import BaseTransport
from .klaptransport import KlapTransport, KlapTransportV2 from .klaptransport import KlapTransport, KlapTransportV2
from .linkietransport import LinkieTransportV2 from .linkietransport import LinkieTransportV2
from .sslaestransport import SslAesTransport
from .ssltransport import SslTransport from .ssltransport import SslTransport
from .xortransport import XorEncryption, XorTransport from .xortransport import XorEncryption, XorTransport
@ -11,6 +12,7 @@ __all__ = [
"AesTransport", "AesTransport",
"AesEncyptionSession", "AesEncyptionSession",
"SslTransport", "SslTransport",
"SslAesTransport",
"BaseTransport", "BaseTransport",
"KlapTransport", "KlapTransport",
"KlapTransportV2", "KlapTransportV2",

View File

@ -8,6 +8,7 @@ import hashlib
import logging import logging
import secrets import secrets
import ssl import ssl
from contextlib import suppress
from enum import Enum, auto from enum import Enum, auto
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@ -160,6 +161,19 @@ class SslAesTransport(BaseTransport):
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
return error_code return error_code
def _get_response_inner_error(self, resp_dict: Any) -> SmartErrorCode | None:
error_code_raw = resp_dict.get("data", {}).get("code")
if error_code_raw is None:
return None
try:
error_code = SmartErrorCode.from_int(error_code_raw)
except ValueError:
_LOGGER.warning(
"Device %s received unknown error code: %s", self._host, error_code_raw
)
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
return error_code
def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None:
error_code = self._get_response_error(resp_dict) error_code = self._get_response_error(resp_dict)
if error_code is SmartErrorCode.SUCCESS: if error_code is SmartErrorCode.SUCCESS:
@ -216,6 +230,31 @@ class SslAesTransport(BaseTransport):
ssl=await self._get_ssl_context(), ssl=await self._get_ssl_context(),
) )
if TYPE_CHECKING:
assert self._encryption_session is not None
# Devices can respond with 500 if another session is created from
# the same host. Decryption may not succeed after that
if status_code == 500:
msg = (
f"Device {self._host} replied with status 500 after handshake, "
f"response: "
)
decrypted = None
if isinstance(resp_dict, dict) and (
response := resp_dict.get("result", {}).get("response")
):
with suppress(Exception):
decrypted = self._encryption_session.decrypt(response.encode())
if decrypted:
msg += decrypted
else:
msg += str(resp_dict)
_LOGGER.debug(msg)
raise _RetryableError(msg)
if status_code != 200: if status_code != 200:
raise KasaException( raise KasaException(
f"{self._host} responded with an unexpected " f"{self._host} responded with an unexpected "
@ -228,7 +267,6 @@ class SslAesTransport(BaseTransport):
if TYPE_CHECKING: if TYPE_CHECKING:
resp_dict = cast(dict[str, Any], resp_dict) resp_dict = cast(dict[str, Any], resp_dict)
assert self._encryption_session is not None
if "result" in resp_dict and "response" in resp_dict["result"]: if "result" in resp_dict and "response" in resp_dict["result"]:
raw_response: str = resp_dict["result"]["response"] raw_response: str = resp_dict["result"]["response"]
@ -383,13 +421,29 @@ class SslAesTransport(BaseTransport):
error_code = default_error_code error_code = default_error_code
resp_dict = default_resp_dict resp_dict = default_resp_dict
# If the default login worked it's ok not to provide credentials but if
# it didn't raise auth error here.
if not self._username: if not self._username:
raise AuthenticationError( raise AuthenticationError(
f"Credentials must be supplied to connect to {self._host}" f"Credentials must be supplied to connect to {self._host}"
) )
# Device responds with INVALID_NONCE and a "nonce" to indicate ready
# for secure login. Otherwise error.
if error_code is not SmartErrorCode.INVALID_NONCE or ( if error_code is not SmartErrorCode.INVALID_NONCE or (
resp_dict and "nonce" not in resp_dict["result"].get("data", {}) resp_dict and "nonce" not in resp_dict.get("result", {}).get("data", {})
): ):
if (
resp_dict
and self._get_response_inner_error(resp_dict)
is SmartErrorCode.DEVICE_BLOCKED
):
sec_left = resp_dict.get("data", {}).get("sec_left")
msg = "Device blocked" + (
f" for {sec_left} seconds" if sec_left else ""
)
raise DeviceError(msg, error_code=SmartErrorCode.DEVICE_BLOCKED)
raise AuthenticationError(f"Error trying handshake1: {resp_dict}") raise AuthenticationError(f"Error trying handshake1: {resp_dict}")
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@ -1,6 +1,6 @@
[project] [project]
name = "python-kasa" name = "python-kasa"
version = "0.8.1" version = "0.9.0"
description = "Python API for TP-Link Kasa and Tapo devices" description = "Python API for TP-Link Kasa and Tapo devices"
license = {text = "GPL-3.0-or-later"} license = {text = "GPL-3.0-or-later"}
authors = [ { name = "python-kasa developers" }] authors = [ { name = "python-kasa developers" }]

View File

@ -79,8 +79,6 @@ PLUGS_IOT = {
"KP125", "KP125",
"KP401", "KP401",
} }
# P135 supports dimming, but its not currently support
# by the library
PLUGS_SMART = { PLUGS_SMART = {
"P100", "P100",
"P110", "P110",
@ -112,7 +110,7 @@ SWITCHES_SMART = {
} }
SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART}
STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"}
STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306"}
STRIPS = {*STRIPS_IOT, *STRIPS_SMART} STRIPS = {*STRIPS_IOT, *STRIPS_SMART}
DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"}
@ -435,7 +433,7 @@ async def get_device_for_fixture(
discovery_data = None discovery_data = None
if "discovery_result" in fixture_data.data: if "discovery_result" in fixture_data.data:
discovery_data = fixture_data.data["discovery_result"] discovery_data = fixture_data.data["discovery_result"]["result"]
elif "system" in fixture_data.data: elif "system" in fixture_data.data:
discovery_data = { discovery_data = {
"system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]}
@ -473,8 +471,12 @@ def get_nearest_fixture_to_ip(dev):
assert protocol_fixtures, "Unknown device type" assert protocol_fixtures, "Unknown device type"
# This will get the best fixture with a match on model region # This will get the best fixture with a match on model region
if model_region_fixtures := filter_fixtures( if (di := dev.device_info) and (
"", model_filter={dev._model_region}, fixture_list=protocol_fixtures model_region_fixtures := filter_fixtures(
"",
model_filter={di.long_name + (f"({di.region})" if di.region else "")},
fixture_list=protocol_fixtures,
)
): ):
return next(iter(model_region_fixtures)) return next(iter(model_region_fixtures))

View File

@ -139,7 +139,8 @@ smart_discovery = parametrize_discovery("smart discovery", protocol_filter={"SMA
) )
async def discovery_mock(request, mocker): async def discovery_mock(request, mocker):
"""Mock discovery and patch protocol queries to use Fake protocols.""" """Mock discovery and patch protocol queries to use Fake protocols."""
fixture_info: FixtureInfo = request.param fi: FixtureInfo = request.param
fixture_info = FixtureInfo(fi.name, fi.protocol, copy.deepcopy(fi.data))
return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker)
@ -159,6 +160,17 @@ def create_discovery_mock(ip: str, fixture_data: dict):
login_version: int | None = None login_version: int | None = None
port_override: int | None = None port_override: int | None = None
@property
def model(self) -> str:
dd = self.discovery_data
model_region = (
dd["result"]["device_model"]
if self.discovery_port == 20002
else dd["system"]["get_sysinfo"]["model"]
)
model, _, _ = model_region.partition("(")
return model
@property @property
def _datagram(self) -> bytes: def _datagram(self) -> bytes:
if self.default_port == 9999: if self.default_port == 9999:
@ -170,14 +182,17 @@ def create_discovery_mock(ip: str, fixture_data: dict):
) )
if "discovery_result" in fixture_data: if "discovery_result" in fixture_data:
discovery_data = {"result": fixture_data["discovery_result"].copy()} discovery_data = fixture_data["discovery_result"].copy()
discovery_result = fixture_data["discovery_result"] discovery_result = fixture_data["discovery_result"]["result"]
device_type = discovery_result["device_type"] device_type = discovery_result["device_type"]
encrypt_type = discovery_result["mgt_encrypt_schm"].get( encrypt_type = discovery_result["mgt_encrypt_schm"].get(
"encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm") "encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm")
) )
login_version = discovery_result["mgt_encrypt_schm"].get("lv") if not (login_version := discovery_result["mgt_encrypt_schm"].get("lv")) and (
et := discovery_result.get("encrypt_type")
):
login_version = max([int(i) for i in et])
https = discovery_result["mgt_encrypt_schm"]["is_support_https"] https = discovery_result["mgt_encrypt_schm"]["is_support_https"]
dm = _DiscoveryMock( dm = _DiscoveryMock(
ip, ip,
@ -305,7 +320,7 @@ def discovery_data(request, mocker):
mocker.patch("kasa.IotProtocol.query", return_value=fixture_data) mocker.patch("kasa.IotProtocol.query", return_value=fixture_data)
mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data)
if "discovery_result" in fixture_data: if "discovery_result" in fixture_data:
return {"result": fixture_data["discovery_result"]} return fixture_data["discovery_result"].copy()
else: else:
return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}}

View File

@ -114,6 +114,15 @@ class FakeSmartTransport(BaseTransport):
"type": 0, "type": 0,
}, },
), ),
"get_homekit_info": (
"homekit",
{
"mfi_setup_code": "000-00-000",
"mfi_setup_id": "0000",
"mfi_token_token": "000000000000000000000000000000000",
"mfi_token_uuid": "00000000-0000-0000-0000-000000000000",
},
),
"get_auto_update_info": ( "get_auto_update_info": (
"firmware", "firmware",
{"enable": True, "random_range": 120, "time": 180}, {"enable": True, "random_range": 120, "time": 180},
@ -151,6 +160,13 @@ class FakeSmartTransport(BaseTransport):
"energy_monitoring", "energy_monitoring",
{"igain": 10861, "vgain": 118657}, {"igain": 10861, "vgain": 118657},
), ),
"get_matter_setup_info": (
"matter",
{
"setup_code": "00000000000",
"setup_payload": "00:0000000-0000.00.000",
},
),
} }
async def send(self, request: str): async def send(self, request: str):

View File

@ -34,6 +34,7 @@ class FakeSmartCamTransport(BaseTransport):
list_return_size=10, list_return_size=10,
is_child=False, is_child=False,
verbatim=False, verbatim=False,
components_not_included=False,
): ):
super().__init__( super().__init__(
config=DeviceConfig( config=DeviceConfig(
@ -44,6 +45,7 @@ class FakeSmartCamTransport(BaseTransport):
), ),
), ),
) )
self.fixture_name = fixture_name self.fixture_name = fixture_name
# When True verbatim will bypass any extra processing of missing # When True verbatim will bypass any extra processing of missing
# methods and is used to test the fixture creation itself. # methods and is used to test the fixture creation itself.
@ -58,6 +60,17 @@ class FakeSmartCamTransport(BaseTransport):
# self.child_protocols = self._get_child_protocols() # self.child_protocols = self._get_child_protocols()
self.list_return_size = list_return_size self.list_return_size = list_return_size
# Setting this flag allows tests to create dummy transports without
# full fixture info for testing specific cases like list handling etc
self.components_not_included = (components_not_included,)
if not components_not_included:
self.components = {
comp["name"]: comp["version"]
for comp in self.info["getAppComponentList"]["app_component"][
"app_component_list"
]
}
@property @property
def default_port(self): def default_port(self):
"""Default port for the transport.""" """Default port for the transport."""
@ -112,6 +125,15 @@ class FakeSmartCamTransport(BaseTransport):
info = info[key] info = info[key]
info[set_keys[-1]] = value info[set_keys[-1]] = value
FIXTURE_MISSING_MAP = {
"getMatterSetupInfo": (
"matter",
{
"setup_code": "00000000000",
"setup_payload": "00:0000000-0000.00.000",
},
)
}
# Setters for when there's not a simple mapping of setters to getters # Setters for when there's not a simple mapping of setters to getters
SETTERS = { SETTERS = {
("system", "sys", "dev_alias"): [ ("system", "sys", "dev_alias"): [
@ -199,26 +221,38 @@ class FakeSmartCamTransport(BaseTransport):
return {**result, "error_code": 0} return {**result, "error_code": 0}
else: else:
return {"error_code": -1} return {"error_code": -1}
elif method[:3] == "get":
params = request_dict.get("params")
if method in info:
result = copy.deepcopy(info[method])
if "start_index" in result and "sum" in result:
list_key = next(
iter([key for key in result if isinstance(result[key], list)])
)
start_index = (
start_index
if (params and (start_index := params.get("start_index")))
else 0
)
result[list_key] = result[list_key][ if method in info:
start_index : start_index + self.list_return_size params = request_dict.get("params")
] result = copy.deepcopy(info[method])
return {"result": result, "error_code": 0} if "start_index" in result and "sum" in result:
else: list_key = next(
return {"error_code": -1} iter([key for key in result if isinstance(result[key], list)])
)
start_index = (
start_index
if (params and (start_index := params.get("start_index")))
else 0
)
result[list_key] = result[list_key][
start_index : start_index + self.list_return_size
]
return {"result": result, "error_code": 0}
if self.verbatim:
return {"error_code": -1}
if (
# FIXTURE_MISSING is for service calls not in place when
# SMART fixtures started to be generated
missing_result := self.FIXTURE_MISSING_MAP.get(method)
) and missing_result[0] in self.components:
# Copy to info so it will work with update methods
info[method] = copy.deepcopy(missing_result[1])
result = copy.deepcopy(info[method])
return {"result": result, "error_code": 0}
return {"error_code": -1} return {"error_code": -1}
async def close(self) -> None: async def close(self) -> None:

View File

@ -145,12 +145,21 @@ def filter_fixtures(
def _component_match( def _component_match(
fixture_data: FixtureInfo, component_filter: str | ComponentFilter fixture_data: FixtureInfo, component_filter: str | ComponentFilter
): ):
if (component_nego := fixture_data.data.get("component_nego")) is None: components = {}
if component_nego := fixture_data.data.get("component_nego"):
components = {
component["id"]: component["ver_code"]
for component in component_nego["component_list"]
}
if get_app_component_list := fixture_data.data.get("getAppComponentList"):
components = {
component["name"]: component["version"]
for component in get_app_component_list["app_component"][
"app_component_list"
]
}
if not components:
return False return False
components = {
component["id"]: component["ver_code"]
for component in component_nego["component_list"]
}
if isinstance(component_filter, str): if isinstance(component_filter, str):
return component_filter in components return component_filter in components
else: else:

View File

@ -2,7 +2,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "167 lamp", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Mini", "dev_name": "Smart Wi-Fi Plug Mini",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -1,12 +1,12 @@
{ {
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"alias": "TP-LINK_Smart Plug_004F", "alias": "#MASKED_NAME#",
"child_num": 2, "child_num": 2,
"children": [ "children": [
{ {
"alias": "Zombie", "alias": "#MASKED_NAME# 1",
"id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F200", "id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -14,8 +14,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Magic", "alias": "#MASKED_NAME# 2",
"id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F201", "id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },

View File

@ -11,7 +11,7 @@
"stopConnect": 0, "stopConnect": 0,
"tcspInfo": "", "tcspInfo": "",
"tcspStatus": 1, "tcspStatus": 1,
"username": "#MASKED_NAME#" "username": "user@example.com"
}, },
"get_intl_fw_list": { "get_intl_fw_list": {
"err_code": 0, "err_code": 0,

View File

@ -78,7 +78,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Test ES20M", "alias": "#MASKED_NAME#",
"brightness": 35, "brightness": 35,
"dev_name": "Wi-Fi Smart Dimmer with sensor", "dev_name": "Wi-Fi Smart Dimmer with sensor",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",

View File

@ -1,18 +1,21 @@
{ {
"discovery_result": { "discovery_result": {
"device_id": "00000000000000000000000000000000", "error_code": 0,
"device_model": "HS100(UK)", "result": {
"device_type": "IOT.SMARTPLUGSWITCH", "device_id": "00000000000000000000000000000000",
"factory_default": true, "device_model": "HS100(UK)",
"hw_ver": "4.1", "device_type": "IOT.SMARTPLUGSWITCH",
"ip": "127.0.0.123", "factory_default": true,
"mac": "CC-32-E5-00-00-00", "hw_ver": "4.1",
"mgt_encrypt_schm": { "ip": "127.0.0.123",
"encrypt_type": "KLAP", "mac": "CC-32-E5-00-00-00",
"http_port": 80, "mgt_encrypt_schm": {
"is_support_https": false "encrypt_type": "KLAP",
}, "http_port": 80,
"owner": "00000000000000000000000000000000" "is_support_https": false
},
"owner": "00000000000000000000000000000000"
}
}, },
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {

View File

@ -18,7 +18,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "schedule", "active_mode": "schedule",
"alias": "Unused 3", "alias": "#MASKED_NAME#",
"dev_name": "Wi-Fi Smart Plug", "dev_name": "Wi-Fi Smart Plug",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -20,7 +20,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "3D Printer", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug", "dev_name": "Smart Wi-Fi Plug",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -20,7 +20,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "schedule", "active_mode": "schedule",
"alias": "Night lite", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Lite", "dev_name": "Smart Wi-Fi Plug Lite",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -18,7 +18,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Corner", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Lite", "dev_name": "Smart Wi-Fi Plug Lite",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -2,7 +2,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Plug", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Lite", "dev_name": "Smart Wi-Fi Plug Lite",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -20,7 +20,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Unused 1", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Mini", "dev_name": "Smart Wi-Fi Plug Mini",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -17,12 +17,12 @@
}, },
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"alias": "TP-LINK_Smart Plug_D310", "alias": "#MASKED_NAME#",
"child_num": 2, "child_num": 2,
"children": [ "children": [
{ {
"alias": "Garage Charger 1", "alias": "#MASKED_NAME# 1",
"id": "00", "id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -30,8 +30,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Garage Charger 2", "alias": "#MASKED_NAME# 2",
"id": "01", "id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -46,7 +46,7 @@
"hw_ver": "1.0", "hw_ver": "1.0",
"latitude_i": 0, "latitude_i": 0,
"led_off": 0, "led_off": 0,
"longitude_i": -0, "longitude_i": 0,
"mac": "00:00:00:00:00:00", "mac": "00:00:00:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH", "mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS107(US)", "model": "HS107(US)",

View File

@ -11,7 +11,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "schedule", "active_mode": "schedule",
"alias": "Bedroom Lamp Plug", "alias": "#MASKED_NAME#",
"dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -11,7 +11,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "schedule", "active_mode": "schedule",
"alias": "Home Google WiFi HS110", "alias": "#MASKED_NAME#",
"dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -20,7 +20,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Master Bedroom Fan", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Light Switch", "dev_name": "Smart Wi-Fi Light Switch",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -2,7 +2,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "House Fan", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Light Switch", "dev_name": "Smart Wi-Fi Light Switch",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -21,7 +21,7 @@
"get_sysinfo": { "get_sysinfo": {
"abnormal_detect": 1, "abnormal_detect": 1,
"active_mode": "none", "active_mode": "none",
"alias": "Garage Light", "alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi 3-Way Light Switch", "dev_name": "Smart Wi-Fi 3-Way Light Switch",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",
"err_code": 0, "err_code": 0,

View File

@ -28,7 +28,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Living Room Dimmer Switch", "alias": "#MASKED_NAME#",
"brightness": 25, "brightness": 25,
"dev_name": "Smart Wi-Fi Dimmer", "dev_name": "Smart Wi-Fi Dimmer",
"deviceId": "000000000000000000000000000000000000000", "deviceId": "000000000000000000000000000000000000000",
@ -38,9 +38,9 @@
"hwId": "00000000000000000000000000000000", "hwId": "00000000000000000000000000000000",
"hw_ver": "1.0", "hw_ver": "1.0",
"icon_hash": "", "icon_hash": "",
"latitude_i": 11.6210, "latitude_i": 0,
"led_off": 0, "led_off": 0,
"longitude_i": 42.2074, "longitude_i": 0,
"mac": "00:00:00:00:00:00", "mac": "00:00:00:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH", "mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS220(US)", "model": "HS220(US)",

View File

@ -17,7 +17,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Living Room Dimmer Switch", "alias": "#MASKED_NAME#",
"brightness": 100, "brightness": 100,
"dev_name": "Wi-Fi Smart Dimmer", "dev_name": "Wi-Fi Smart Dimmer",
"deviceId": "0000000000000000000000000000000000000000", "deviceId": "0000000000000000000000000000000000000000",

View File

@ -22,12 +22,12 @@
}, },
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"alias": "TP-LINK_Power Strip_DAE1", "alias": "#MASKED_NAME#",
"child_num": 6, "child_num": 6,
"children": [ "children": [
{ {
"alias": "Office Monitor 1", "alias": "#MASKED_NAME# 1",
"id": "00", "id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -35,8 +35,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Office Monitor 2", "alias": "#MASKED_NAME# 2",
"id": "01", "id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -44,8 +44,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Office Monitor 3", "alias": "#MASKED_NAME# 3",
"id": "02", "id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -53,8 +53,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Office Laptop Dock", "alias": "#MASKED_NAME# 4",
"id": "03", "id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -62,8 +62,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Office Desk Light", "alias": "#MASKED_NAME# 5",
"id": "04", "id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -71,8 +71,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Laptop", "alias": "#MASKED_NAME# 6",
"id": "05", "id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -87,7 +87,7 @@
"hw_ver": "1.0", "hw_ver": "1.0",
"latitude_i": 0, "latitude_i": 0,
"led_off": 0, "led_off": 0,
"longitude_i": -0, "longitude_i": 0,
"mac": "00:00:00:00:00:00", "mac": "00:00:00:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH", "mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS300(US)", "model": "HS300(US)",

View File

@ -10,12 +10,12 @@
}, },
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"alias": "TP-LINK_Power Strip_2CA9", "alias": "#MASKED_NAME#",
"child_num": 6, "child_num": 6,
"children": [ "children": [
{ {
"alias": "Home CameraPC", "alias": "#MASKED_NAME# 1",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED00", "id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -23,8 +23,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "Home Firewalla", "alias": "#MASKED_NAME# 2",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED01", "id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -32,8 +32,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "Home Cox modem", "alias": "#MASKED_NAME# 3",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED02", "id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -41,8 +41,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "Home rpi3-2", "alias": "#MASKED_NAME# 4",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED03", "id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -50,8 +50,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "Home Camera Switch", "alias": "#MASKED_NAME# 5",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED05", "id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -59,8 +59,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "Home Network Switch", "alias": "#MASKED_NAME# 6",
"id": "800623145DFF1AA096363EFD161C2E661A9D8DED04", "id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },

View File

@ -15,8 +15,8 @@
"child_num": 6, "child_num": 6,
"children": [ "children": [
{ {
"alias": "#MASKED_NAME#", "alias": "#MASKED_NAME# 1",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D00", "id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -24,8 +24,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "#MASKED_NAME#", "alias": "#MASKED_NAME# 2",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D01", "id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -33,8 +33,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "#MASKED_NAME#", "alias": "#MASKED_NAME# 3",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D02", "id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -42,8 +42,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "#MASKED_NAME#", "alias": "#MASKED_NAME# 4",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D03", "id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -51,8 +51,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "#MASKED_NAME#", "alias": "#MASKED_NAME# 5",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D04", "id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -60,8 +60,8 @@
"state": 1 "state": 1
}, },
{ {
"alias": "#MASKED_NAME#", "alias": "#MASKED_NAME# 6",
"id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D05", "id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },

View File

@ -11,12 +11,12 @@
}, },
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"alias": "TP-LINK_Power Strip_5C33", "alias": "#MASKED_NAME#",
"child_num": 6, "child_num": 6,
"children": [ "children": [
{ {
"alias": "Plug 1", "alias": "#MASKED_NAME# 1",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", "id": "SCRUBBED_CHILD_DEVICE_ID_1",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -24,8 +24,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Plug 2", "alias": "#MASKED_NAME# 2",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", "id": "SCRUBBED_CHILD_DEVICE_ID_2",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -33,8 +33,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Plug 3", "alias": "#MASKED_NAME# 3",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", "id": "SCRUBBED_CHILD_DEVICE_ID_3",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -42,8 +42,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Plug 4", "alias": "#MASKED_NAME# 4",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", "id": "SCRUBBED_CHILD_DEVICE_ID_4",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -51,8 +51,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Plug 5", "alias": "#MASKED_NAME# 5",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", "id": "SCRUBBED_CHILD_DEVICE_ID_5",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },
@ -60,8 +60,8 @@
"state": 0 "state": 0
}, },
{ {
"alias": "Plug 6", "alias": "#MASKED_NAME# 6",
"id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", "id": "SCRUBBED_CHILD_DEVICE_ID_6",
"next_action": { "next_action": {
"type": -1 "type": -1
}, },

View File

@ -21,7 +21,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Bulb3", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -19,7 +19,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Home Family Room Table", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -34,11 +34,11 @@
}, },
"description": "Smart Wi-Fi LED Bulb with Tunable White Light", "description": "Smart Wi-Fi LED Bulb with Tunable White Light",
"dev_state": "normal", "dev_state": "normal",
"deviceId": "801200814AD69370AC59DE5501319C051AF409C3", "deviceId": "0000000000000000000000000000000000000000",
"disco_ver": "1.0", "disco_ver": "1.0",
"err_code": 0, "err_code": 0,
"heapsize": 290784, "heapsize": 290784,
"hwId": "111E35908497A05512E259BB76801E10", "hwId": "00000000000000000000000000000000",
"hw_ver": "1.0", "hw_ver": "1.0",
"is_color": 0, "is_color": 0,
"is_dimmable": 1, "is_dimmable": 1,
@ -52,10 +52,10 @@
"on_off": 1, "on_off": 1,
"saturation": 0 "saturation": 0
}, },
"mic_mac": "D80D17150474", "mic_mac": "D80D17000000",
"mic_type": "IOT.SMARTBULB", "mic_type": "IOT.SMARTBULB",
"model": "KL120(US)", "model": "KL120(US)",
"oemId": "1210657CD7FBDC72895644388EEFAE8B", "oemId": "00000000000000000000000000000000",
"preferred_state": [ "preferred_state": [
{ {
"brightness": 100, "brightness": 100,

View File

@ -20,7 +20,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "kasa-bc01", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -22,7 +22,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Test bulb 6", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -11,7 +11,7 @@
"stopConnect": 0, "stopConnect": 0,
"tcspInfo": "", "tcspInfo": "",
"tcspStatus": 1, "tcspStatus": 1,
"username": "#MASKED_NAME#" "username": "user@example.com"
}, },
"get_intl_fw_list": { "get_intl_fw_list": {
"err_code": 0, "err_code": 0,

View File

@ -21,7 +21,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Bulb2", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -11,7 +11,7 @@
"stopConnect": 0, "stopConnect": 0,
"tcspInfo": "", "tcspInfo": "",
"tcspStatus": 1, "tcspStatus": 1,
"username": "#MASKED_NAME#" "username": "user@example.com"
}, },
"get_intl_fw_list": { "get_intl_fw_list": {
"err_code": 0, "err_code": 0,

View File

@ -20,7 +20,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "KL135 Bulb", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": { "get_sysinfo": {
"LEF": 0, "LEF": 0,
"active_mode": "none", "active_mode": "none",
"alias": "Kl400", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": { "get_sysinfo": {
"LEF": 0, "LEF": 0,
"active_mode": "none", "active_mode": "none",
"alias": "Kl400", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": { "get_sysinfo": {
"LEF": 1, "LEF": 1,
"active_mode": "none", "active_mode": "none",
"alias": "Kl420 test", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": { "get_sysinfo": {
"LEF": 1, "LEF": 1,
"active_mode": "none", "active_mode": "none",
"alias": "Bedroom light strip", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -23,7 +23,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "schedule", "active_mode": "schedule",
"alias": "Bedroom Lightstrip", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -11,7 +11,7 @@
"stopConnect": 0, "stopConnect": 0,
"tcspInfo": "", "tcspInfo": "",
"tcspStatus": 1, "tcspStatus": 1,
"username": "#MASKED_NAME#" "username": "user@example.com"
}, },
"get_intl_fw_list": { "get_intl_fw_list": {
"err_code": 0, "err_code": 0,

View File

@ -10,7 +10,7 @@
"get_sysinfo": { "get_sysinfo": {
"LEF": 1, "LEF": 1,
"active_mode": "none", "active_mode": "none",
"alias": "89 strip", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -10,7 +10,7 @@
"get_sysinfo": { "get_sysinfo": {
"LEF": 1, "LEF": 1,
"active_mode": "none", "active_mode": "none",
"alias": "kl430 updated", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -22,7 +22,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "Kl50", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"

View File

@ -32,7 +32,7 @@
"system": { "system": {
"get_sysinfo": { "get_sysinfo": {
"active_mode": "none", "active_mode": "none",
"alias": "TP-LINK_Smart Bulb_9179", "alias": "#MASKED_NAME#",
"ctrl_protocols": { "ctrl_protocols": {
"name": "Linkie", "name": "Linkie",
"version": "1.0" "version": "1.0"
@ -60,7 +60,7 @@
"on_off": 0 "on_off": 0
}, },
"longitude_i": 0, "longitude_i": 0,
"mic_mac": "74DA88C89179", "mic_mac": "74DA88000000",
"mic_type": "IOT.SMARTBULB", "mic_type": "IOT.SMARTBULB",
"model": "KL60(UN)", "model": "KL60(UN)",
"oemId": "00000000000000000000000000000000", "oemId": "00000000000000000000000000000000",

Some files were not shown because too many files have changed in this diff Show More