PySwitchbot 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {pyswitchbot-2.2.0/PySwitchbot.egg-info → pyswitchbot-2.3.0}/PKG-INFO +46 -22
  2. pyswitchbot-2.2.0/PKG-INFO → pyswitchbot-2.3.0/README.md +27 -35
  3. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/pyproject.toml +73 -0
  4. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/__init__.py +2 -0
  5. pyswitchbot-2.3.0/switchbot/__version__.py +1 -0
  6. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parser.py +13 -0
  7. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/air_purifier.py +1 -1
  8. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/art_frame.py +1 -1
  9. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/blind_tilt.py +2 -2
  10. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/bot.py +1 -1
  11. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/bulb.py +1 -1
  12. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/ceiling_light.py +1 -1
  13. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/climate_panel.py +1 -1
  14. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/contact.py +8 -3
  15. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/curtain.py +3 -3
  16. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/fan.py +11 -4
  17. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/hub2.py +1 -1
  18. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/hub3.py +1 -1
  19. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/hubmini_matter.py +1 -1
  20. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/humidifier.py +2 -2
  21. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/keypad.py +1 -1
  22. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/keypad_vision.py +3 -3
  23. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/leak.py +1 -1
  24. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/light_strip.py +16 -3
  25. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/lock.py +4 -4
  26. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/meter.py +10 -4
  27. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/motion.py +2 -1
  28. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/plug.py +1 -1
  29. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/presence_sensor.py +1 -1
  30. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/remote.py +1 -1
  31. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/roller_shade.py +2 -2
  32. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +1 -1
  33. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/vacuum.py +2 -2
  34. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/__init__.py +1 -0
  35. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/light.py +11 -0
  36. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/device.py +3 -2
  37. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/fan.py +82 -1
  38. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/light_strip.py +138 -2
  39. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/relay_switch.py +24 -0
  40. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/smart_thermostat_radiator.py +12 -0
  41. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/discovery.py +2 -4
  42. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/utils.py +1 -1
  43. pyswitchbot-2.2.0/MANIFEST.in +0 -1
  44. pyswitchbot-2.2.0/PySwitchbot.egg-info/SOURCES.txt +0 -112
  45. pyswitchbot-2.2.0/PySwitchbot.egg-info/dependency_links.txt +0 -1
  46. pyswitchbot-2.2.0/PySwitchbot.egg-info/requires.txt +0 -5
  47. pyswitchbot-2.2.0/PySwitchbot.egg-info/top_level.txt +0 -1
  48. pyswitchbot-2.2.0/README.md +0 -110
  49. pyswitchbot-2.2.0/setup.cfg +0 -4
  50. pyswitchbot-2.2.0/setup.py +0 -40
  51. pyswitchbot-2.2.0/tests/test_adv_parser.py +0 -4754
  52. pyswitchbot-2.2.0/tests/test_air_purifier.py +0 -466
  53. pyswitchbot-2.2.0/tests/test_art_frame.py +0 -200
  54. pyswitchbot-2.2.0/tests/test_base_cover.py +0 -151
  55. pyswitchbot-2.2.0/tests/test_blind_tilt.py +0 -240
  56. pyswitchbot-2.2.0/tests/test_bulb.py +0 -241
  57. pyswitchbot-2.2.0/tests/test_ceiling_light.py +0 -194
  58. pyswitchbot-2.2.0/tests/test_colormode_imports.py +0 -88
  59. pyswitchbot-2.2.0/tests/test_curtain.py +0 -425
  60. pyswitchbot-2.2.0/tests/test_device.py +0 -416
  61. pyswitchbot-2.2.0/tests/test_discovery_callback.py +0 -142
  62. pyswitchbot-2.2.0/tests/test_encrypted_device.py +0 -593
  63. pyswitchbot-2.2.0/tests/test_evaporative_humidifier.py +0 -341
  64. pyswitchbot-2.2.0/tests/test_fan.py +0 -621
  65. pyswitchbot-2.2.0/tests/test_helpers.py +0 -72
  66. pyswitchbot-2.2.0/tests/test_hub2.py +0 -18
  67. pyswitchbot-2.2.0/tests/test_hub3.py +0 -13
  68. pyswitchbot-2.2.0/tests/test_keypad_vision.py +0 -259
  69. pyswitchbot-2.2.0/tests/test_lock.py +0 -864
  70. pyswitchbot-2.2.0/tests/test_meter_pro.py +0 -249
  71. pyswitchbot-2.2.0/tests/test_relay_switch.py +0 -482
  72. pyswitchbot-2.2.0/tests/test_roller_shade.py +0 -383
  73. pyswitchbot-2.2.0/tests/test_smart_thermostat_radiator.py +0 -221
  74. pyswitchbot-2.2.0/tests/test_strip_light.py +0 -475
  75. pyswitchbot-2.2.0/tests/test_utils.py +0 -41
  76. pyswitchbot-2.2.0/tests/test_vacuum.py +0 -145
  77. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/LICENSE +0 -0
  78. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/__init__.py +0 -0
  79. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/_sensor_th.py +0 -0
  80. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  81. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/adv_parsers/weather_station.py +0 -0
  82. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/api_config.py +0 -0
  83. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/air_purifier.py +0 -0
  84. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/climate.py +0 -0
  85. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/evaporative_humidifier.py +0 -0
  86. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/fan.py +0 -0
  87. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/hub2.py +0 -0
  88. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/hub3.py +0 -0
  89. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/lock.py +0 -0
  90. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/const/presence_sensor.py +0 -0
  91. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/__init__.py +0 -0
  92. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/air_purifier.py +0 -0
  93. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/art_frame.py +0 -0
  94. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/base_cover.py +0 -0
  95. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/base_light.py +0 -0
  96. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/blind_tilt.py +0 -0
  97. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/bot.py +0 -0
  98. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/bulb.py +0 -0
  99. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/ceiling_light.py +0 -0
  100. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/contact.py +0 -0
  101. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/curtain.py +0 -0
  102. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  103. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/humidifier.py +0 -0
  104. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/keypad.py +0 -0
  105. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/keypad_vision.py +0 -0
  106. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/lock.py +0 -0
  107. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/meter.py +0 -0
  108. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/meter_pro.py +0 -0
  109. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/motion.py +0 -0
  110. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/plug.py +0 -0
  111. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/roller_shade.py +0 -0
  112. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/devices/vacuum.py +0 -0
  113. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/enum.py +0 -0
  114. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/helpers.py +0 -0
  115. {pyswitchbot-2.2.0 → pyswitchbot-2.3.0}/switchbot/models.py +0 -0
@@ -1,35 +1,33 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A library to communicate with Switchbot
5
- Home-page: https://github.com/sblibs/pySwitchbot/
6
- Author: Daniel Hjelseth Hoyer
7
5
  License: MIT
6
+ License-File: LICENSE
7
+ Author: Daniel Hjelseth Hoyer
8
+ Requires-Python: >=3.11,<4.0
8
9
  Classifier: Development Status :: 3 - Alpha
9
10
  Classifier: Environment :: Other Environment
10
11
  Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
11
13
  Classifier: Operating System :: OS Independent
12
14
  Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
13
20
  Classifier: Topic :: Home Automation
14
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
- Requires-Python: >=3.11
22
+ Requires-Dist: aiohttp (>=3.9.5)
23
+ Requires-Dist: bleak (>=0.19.0)
24
+ Requires-Dist: bleak-retry-connector (>=3.4.0)
25
+ Requires-Dist: cryptography (>=39.0.0)
26
+ Requires-Dist: pyOpenSSL (>=23.0.0)
27
+ Project-URL: Bug Tracker, https://github.com/sblibs/pySwitchbot/issues
28
+ Project-URL: Changelog, https://github.com/sblibs/pySwitchbot/blob/main/CHANGELOG.md
29
+ Project-URL: Repository, https://github.com/sblibs/pySwitchbot/
16
30
  Description-Content-Type: text/markdown
17
- License-File: LICENSE
18
- Requires-Dist: aiohttp>=3.9.5
19
- Requires-Dist: bleak>=0.19.0
20
- Requires-Dist: bleak-retry-connector>=3.4.0
21
- Requires-Dist: cryptography>=39.0.0
22
- Requires-Dist: pyOpenSSL>=23.0.0
23
- Dynamic: author
24
- Dynamic: classifier
25
- Dynamic: description
26
- Dynamic: description-content-type
27
- Dynamic: home-page
28
- Dynamic: license
29
- Dynamic: license-file
30
- Dynamic: requires-dist
31
- Dynamic: requires-python
32
- Dynamic: summary
33
31
 
34
32
  # pySwitchbot [![codecov](https://codecov.io/gh/sblibs/pySwitchbot/graph/badge.svg?token=TI027U5ISQ)](https://codecov.io/gh/sblibs/pySwitchbot)
35
33
 
@@ -58,6 +56,31 @@ Encryption key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
58
56
  Where `MAC` is MAC address of the lock and `USERNAME` is your SwitchBot account username, after that script will ask for your password.
59
57
  If authentication succeeds then script should output your key id and encryption key.
60
58
 
59
+ ### Troubleshooting key retrieval
60
+
61
+ Key retrieval talks to SwitchBot's account API with your username and
62
+ password. The most common failures are account-side, not bugs in this library:
63
+
64
+ - **`Authentication failed: ...`** — the username/password were rejected. This
65
+ happens when:
66
+ - Two-factor authentication (2FA) is enabled on the account. The API login
67
+ used here does not support a verification code, so 2FA accounts cannot
68
+ retrieve keys this way — temporarily disable 2FA, fetch the key, then
69
+ re-enable it.
70
+ - The account was created via "Sign in with Apple"/Google and has no
71
+ password set. Set a password in the SwitchBot app first, or use an
72
+ email/password account.
73
+ - The username is an email but the account is registered to a phone number
74
+ (or vice versa). Use the exact identifier you log in with.
75
+ - **`Failed to retrieve encryption key from SwitchBot Account: ...`** —
76
+ authentication succeeded but the key could not be read. Usually the account
77
+ is not the device **owner**: keys are only returned to the owning account,
78
+ not to shared/family members. Retrieve the key from the owner account, or
79
+ transfer ownership in the app.
80
+
81
+ The key only needs to be fetched once; store the `key_id` and encryption key
82
+ and reuse them — there is no need to call the script on every connection.
83
+
61
84
  ## Examples:
62
85
 
63
86
  #### WoLock (Lock-Pro)
@@ -79,7 +102,7 @@ LOCK_MODEL=SwitchbotModel.LOCK_PRO # Your lock model (here we use the Lock-Pro)
79
102
  async def main():
80
103
  wolock = await GetSwitchbotDevices().get_locks()
81
104
  await lock.SwitchbotLock(
82
- wolock[BLE_MAC].device, KEY_ID, ENCRYPTION_KEY, model=LOCK_MODEL
105
+ wolock[BLE_MAC].device, KEY_ID, ENC_KEY, model=LOCK_MODEL
83
106
  ).unlock()
84
107
 
85
108
 
@@ -103,7 +126,7 @@ LOCK_MODEL=SwitchbotModel.LOCK_PRO # Your lock model (here we use the Lock-Pro)
103
126
  async def main():
104
127
  wolock = await GetSwitchbotDevices().get_locks()
105
128
  await lock.SwitchbotLock(
106
- wolock[BLE_MAC].device, KEY_ID, ENCRYPTION_KEY, model=LOCK_MODEL
129
+ wolock[BLE_MAC].device, KEY_ID, ENC_KEY, model=LOCK_MODEL
107
130
  ).lock()
108
131
 
109
132
 
@@ -141,3 +164,4 @@ async def main():
141
164
  if __name__ == "__main__":
142
165
  asyncio.run(main())
143
166
  ```
167
+
@@ -1,36 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: PySwitchbot
3
- Version: 2.2.0
4
- Summary: A library to communicate with Switchbot
5
- Home-page: https://github.com/sblibs/pySwitchbot/
6
- Author: Daniel Hjelseth Hoyer
7
- License: MIT
8
- Classifier: Development Status :: 3 - Alpha
9
- Classifier: Environment :: Other Environment
10
- Classifier: Intended Audience :: Developers
11
- Classifier: Operating System :: OS Independent
12
- Classifier: Programming Language :: Python
13
- Classifier: Topic :: Home Automation
14
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
- Requires-Python: >=3.11
16
- Description-Content-Type: text/markdown
17
- License-File: LICENSE
18
- Requires-Dist: aiohttp>=3.9.5
19
- Requires-Dist: bleak>=0.19.0
20
- Requires-Dist: bleak-retry-connector>=3.4.0
21
- Requires-Dist: cryptography>=39.0.0
22
- Requires-Dist: pyOpenSSL>=23.0.0
23
- Dynamic: author
24
- Dynamic: classifier
25
- Dynamic: description
26
- Dynamic: description-content-type
27
- Dynamic: home-page
28
- Dynamic: license
29
- Dynamic: license-file
30
- Dynamic: requires-dist
31
- Dynamic: requires-python
32
- Dynamic: summary
33
-
34
1
  # pySwitchbot [![codecov](https://codecov.io/gh/sblibs/pySwitchbot/graph/badge.svg?token=TI027U5ISQ)](https://codecov.io/gh/sblibs/pySwitchbot)
35
2
 
36
3
  Library to control Switchbot IoT devices https://www.switch-bot.com/
@@ -58,6 +25,31 @@ Encryption key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
58
25
  Where `MAC` is MAC address of the lock and `USERNAME` is your SwitchBot account username, after that script will ask for your password.
59
26
  If authentication succeeds then script should output your key id and encryption key.
60
27
 
28
+ ### Troubleshooting key retrieval
29
+
30
+ Key retrieval talks to SwitchBot's account API with your username and
31
+ password. The most common failures are account-side, not bugs in this library:
32
+
33
+ - **`Authentication failed: ...`** — the username/password were rejected. This
34
+ happens when:
35
+ - Two-factor authentication (2FA) is enabled on the account. The API login
36
+ used here does not support a verification code, so 2FA accounts cannot
37
+ retrieve keys this way — temporarily disable 2FA, fetch the key, then
38
+ re-enable it.
39
+ - The account was created via "Sign in with Apple"/Google and has no
40
+ password set. Set a password in the SwitchBot app first, or use an
41
+ email/password account.
42
+ - The username is an email but the account is registered to a phone number
43
+ (or vice versa). Use the exact identifier you log in with.
44
+ - **`Failed to retrieve encryption key from SwitchBot Account: ...`** —
45
+ authentication succeeded but the key could not be read. Usually the account
46
+ is not the device **owner**: keys are only returned to the owning account,
47
+ not to shared/family members. Retrieve the key from the owner account, or
48
+ transfer ownership in the app.
49
+
50
+ The key only needs to be fetched once; store the `key_id` and encryption key
51
+ and reuse them — there is no need to call the script on every connection.
52
+
61
53
  ## Examples:
62
54
 
63
55
  #### WoLock (Lock-Pro)
@@ -79,7 +71,7 @@ LOCK_MODEL=SwitchbotModel.LOCK_PRO # Your lock model (here we use the Lock-Pro)
79
71
  async def main():
80
72
  wolock = await GetSwitchbotDevices().get_locks()
81
73
  await lock.SwitchbotLock(
82
- wolock[BLE_MAC].device, KEY_ID, ENCRYPTION_KEY, model=LOCK_MODEL
74
+ wolock[BLE_MAC].device, KEY_ID, ENC_KEY, model=LOCK_MODEL
83
75
  ).unlock()
84
76
 
85
77
 
@@ -103,7 +95,7 @@ LOCK_MODEL=SwitchbotModel.LOCK_PRO # Your lock model (here we use the Lock-Pro)
103
95
  async def main():
104
96
  wolock = await GetSwitchbotDevices().get_locks()
105
97
  await lock.SwitchbotLock(
106
- wolock[BLE_MAC].device, KEY_ID, ENCRYPTION_KEY, model=LOCK_MODEL
98
+ wolock[BLE_MAC].device, KEY_ID, ENC_KEY, model=LOCK_MODEL
107
99
  ).lock()
108
100
 
109
101
 
@@ -1,8 +1,64 @@
1
+ [tool.poetry]
2
+ name = "PySwitchbot"
3
+ version = "2.3.0"
4
+ description = "A library to communicate with Switchbot"
5
+ authors = ["Daniel Hjelseth Hoyer"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ repository = "https://github.com/sblibs/pySwitchbot/"
9
+ packages = [
10
+ { include = "switchbot" },
11
+ ]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Other Environment",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python",
18
+ "Topic :: Home Automation",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+
22
+ [tool.poetry.urls]
23
+ "Bug Tracker" = "https://github.com/sblibs/pySwitchbot/issues"
24
+ "Changelog" = "https://github.com/sblibs/pySwitchbot/blob/main/CHANGELOG.md"
25
+
26
+ [tool.poetry.dependencies]
27
+ python = ">=3.11,<4.0"
28
+ aiohttp = ">=3.9.5"
29
+ bleak = ">=0.19.0"
30
+ bleak-retry-connector = ">=3.4.0"
31
+ cryptography = ">=39.0.0"
32
+ pyOpenSSL = ">=23.0.0"
33
+
34
+ [tool.poetry.group.dev.dependencies]
35
+ pytest = ">=7,<10"
36
+ pytest-cov = ">=3,<8"
37
+ pytest-asyncio = ">=0.19,<1.4"
38
+
39
+ [tool.semantic_release]
40
+ branch = "main"
41
+ # Existing release tags have no "v" prefix (e.g. 2.2.0), unlike PSR's default
42
+ # "v{version}"; without this PSR finds no prior release and bumps from 0.0.0.
43
+ tag_format = "{version}"
44
+ version_toml = ["pyproject.toml:tool.poetry.version"]
45
+ version_variables = ["switchbot/__version__.py:__version__"]
46
+ build_command = "pip install poetry && poetry build"
47
+
48
+ [tool.pytest.ini_options]
49
+ addopts = "--cov=switchbot --cov-report=term-missing"
50
+
51
+ [build-system]
52
+ requires = ["poetry-core>=1.0.0"]
53
+ build-backend = "poetry.core.masonry.api"
54
+
1
55
  [tool.ruff]
2
56
  target-version = "py311"
3
57
  line-length = 88
4
58
 
5
59
  [tool.ruff.lint]
60
+ preview = true
61
+ explicit-preview-rules = true # opt in ONLY explicitly-listed preview rules
6
62
  ignore = [
7
63
  "S101", # use of assert
8
64
  "D203", # 1 blank line required before class docstring
@@ -34,15 +90,30 @@ ignore = [
34
90
  ]
35
91
  select = [
36
92
  "ASYNC", # async rules
93
+ "A", # flake8-builtins
37
94
  "B", # flake8-bugbear
95
+ "BLE", # flake8-blind-except
38
96
  "D", # flake8-docstrings
39
97
  "C4", # flake8-comprehensions
98
+ "C90", # mccabe complexity
99
+ "DTZ", # flake8-datetimez
100
+ "ERA", # eradicate
101
+ "EXE", # flake8-executable
40
102
  "S", # flake8-bandit
41
103
  "F", # pyflake
104
+ "FA", # flake8-future-annotations
105
+ "FIX", # flake8-fixme
106
+ "FURB", # refurb
107
+ "FURB118", # reimplemented-operator (preview) - unneeded lambdas
42
108
  "E", # pycodestyle
43
109
  "W", # pycodestyle
44
110
  "UP", # pyupgrade
45
111
  "I", # isort
112
+ "ICN", # flake8-import-conventions
113
+ "INP", # flake8-no-pep420
114
+ "ISC", # flake8-implicit-str-concat
115
+ "LOG", # flake8-logging
116
+ "Q", # flake8-quotes
46
117
  "RUF", # ruff specific
47
118
  "FLY", # flynt
48
119
  "G", # flake8-logging-format ,
@@ -60,8 +131,10 @@ select = [
60
131
  "SLOT", # flake8-slots
61
132
  "T100", # Trace found: {name} used
62
133
  "T20", # flake8-print
134
+ "TD", # flake8-todos
63
135
  "TID", # Tidy imports
64
136
  "TRY", # tryceratops
137
+ "YTT", # flake8-2020
65
138
  ]
66
139
 
67
140
  [tool.ruff.lint.per-file-ignores]
@@ -57,6 +57,7 @@ from .devices.light_strip import (
57
57
  SwitchbotPermanentOutdoorLight,
58
58
  SwitchbotRgbicLight,
59
59
  SwitchbotRgbicNeonLight,
60
+ SwitchbotRgbicwwCeilingLight,
60
61
  SwitchbotStripLight3,
61
62
  )
62
63
  from .devices.lock import SwitchbotLock
@@ -126,6 +127,7 @@ __all__ = [
126
127
  "SwitchbotRelaySwitch2PM",
127
128
  "SwitchbotRgbicLight",
128
129
  "SwitchbotRgbicNeonLight",
130
+ "SwitchbotRgbicwwCeilingLight",
129
131
  "SwitchbotRollerShade",
130
132
  "SwitchbotSmartThermostatRadiator",
131
133
  "SwitchbotStandingFan",
@@ -0,0 +1 @@
1
+ __version__ = "2.3.0"
@@ -32,6 +32,7 @@ from .adv_parsers.light_strip import (
32
32
  process_candle_warmer_lamp,
33
33
  process_light,
34
34
  process_rgbic_light,
35
+ process_rgbicww_ceiling_light,
35
36
  process_wostrip,
36
37
  )
37
38
  from .adv_parsers.lock import (
@@ -677,6 +678,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
677
678
  "func": process_rgbic_light,
678
679
  "manufacturer_id": 2409,
679
680
  },
681
+ b"\x00\x11\xbb\x10": {
682
+ "modelName": SwitchbotModel.RGBICWW_CEILING_LIGHT,
683
+ "modelFriendlyName": "RGBICWW Ceiling Light",
684
+ "func": process_rgbicww_ceiling_light,
685
+ "manufacturer_id": 2409,
686
+ },
687
+ b"\x01\x11\xbb\x10": {
688
+ "modelName": SwitchbotModel.RGBICWW_CEILING_LIGHT,
689
+ "modelFriendlyName": "RGBICWW Ceiling Light",
690
+ "func": process_rgbicww_ceiling_light,
691
+ "manufacturer_id": 2409,
692
+ },
680
693
  b"\x00\x10\xd0\xb7": {
681
694
  "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
682
695
  "modelFriendlyName": "Permanent Outdoor Light",
@@ -11,7 +11,7 @@ def process_air_purifier(
11
11
  data: bytes | None, mfr_data: bytes | None
12
12
  ) -> dict[str, bool | int]:
13
13
  """Process air purifier services data."""
14
- if mfr_data is None:
14
+ if mfr_data is None or len(mfr_data) < 14:
15
15
  return {}
16
16
  device_data = mfr_data[6:]
17
17
 
@@ -9,7 +9,7 @@ def process_art_frame(
9
9
  data: bytes | None, mfr_data: bytes | None
10
10
  ) -> dict[str, bool | int | str]:
11
11
  """Process Art Frame data."""
12
- if mfr_data is None:
12
+ if mfr_data is None or len(mfr_data) < 10:
13
13
  return {}
14
14
 
15
15
  _seq_num = mfr_data[6]
@@ -7,7 +7,7 @@ def process_woblindtilt(
7
7
  data: bytes | None, mfr_data: bytes | None, reverse: bool = False
8
8
  ) -> dict[str, bool | int]:
9
9
  """Process woBlindTilt services data."""
10
- if mfr_data is None:
10
+ if mfr_data is None or len(mfr_data) < 10:
11
11
  return {}
12
12
 
13
13
  device_data = mfr_data[6:]
@@ -19,7 +19,7 @@ def process_woblindtilt(
19
19
 
20
20
  return {
21
21
  "calibration": _calibrated,
22
- "battery": data[2] & 0b01111111 if data else None,
22
+ "battery": data[2] & 0b01111111 if data and len(data) >= 3 else None,
23
23
  "inMotion": _in_motion,
24
24
  "tilt": (100 - _tilt) if reverse else _tilt,
25
25
  "lightLevel": _light_level,
@@ -8,7 +8,7 @@ def process_wohand(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool
8
8
  if data is None and mfr_data is None:
9
9
  return {}
10
10
 
11
- if data is None:
11
+ if data is None or len(data) < 3:
12
12
  return {
13
13
  "switchMode": None,
14
14
  "isOn": None,
@@ -7,7 +7,7 @@ def process_color_bulb(
7
7
  data: bytes | None, mfr_data: bytes | None
8
8
  ) -> dict[str, bool | int]:
9
9
  """Process WoBulb services data."""
10
- if mfr_data is None:
10
+ if mfr_data is None or len(mfr_data) < 11:
11
11
  return {}
12
12
  return {
13
13
  "sequence_number": mfr_data[6],
@@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__)
15
15
 
16
16
  def process_woceiling(data: bytes, mfr_data: bytes | None) -> dict[str, bool | int]:
17
17
  """Process WoCeiling services data."""
18
- if mfr_data is None:
18
+ if mfr_data is None or len(mfr_data) < 11:
19
19
  return {}
20
20
  return {
21
21
  "sequence_number": mfr_data[6],
@@ -9,7 +9,7 @@ def process_climate_panel(
9
9
  data: bytes | None, mfr_data: bytes | None
10
10
  ) -> dict[str, bool | int | str]:
11
11
  """Process Climate Panel data."""
12
- if mfr_data is None:
12
+ if mfr_data is None or len(mfr_data) < 16:
13
13
  return {}
14
14
 
15
15
  seq_number = mfr_data[6]
@@ -10,10 +10,15 @@ def process_wocontact(
10
10
  if data is None and mfr_data is None:
11
11
  return {}
12
12
 
13
- battery = data[2] & 0b01111111 if data else None
14
- tested = bool(data[1] & 0b10000000) if data else None
13
+ has_full_mfr = mfr_data is not None and len(mfr_data) >= 13
14
+ has_full_data = data is not None and len(data) >= 9
15
+ if not has_full_mfr and not has_full_data:
16
+ return {}
17
+
18
+ battery = data[2] & 0b01111111 if data and len(data) >= 3 else None
19
+ tested = bool(data[1] & 0b10000000) if data and len(data) >= 2 else None
15
20
 
16
- if mfr_data and len(mfr_data) >= 13:
21
+ if has_full_mfr:
17
22
  motion_detected = bool(mfr_data[7] & 0b10000000)
18
23
  contact_open = bool(mfr_data[7] & 0b00010000)
19
24
  contact_timeout = bool(mfr_data[7] & 0b00100000)
@@ -12,8 +12,8 @@ def process_wocurtain(
12
12
  battery_data = mfr_data[12]
13
13
  elif mfr_data and len(mfr_data) >= 11:
14
14
  device_data = mfr_data[8:11]
15
- battery_data = data[2] if data else None
16
- elif data:
15
+ battery_data = data[2] if data and len(data) >= 3 else None
16
+ elif data and len(data) >= 6:
17
17
  device_data = data[3:6]
18
18
  battery_data = data[2]
19
19
  else:
@@ -25,7 +25,7 @@ def process_wocurtain(
25
25
  _device_chain = device_data[1] & 0b00000111
26
26
 
27
27
  return {
28
- "calibration": bool(data[1] & 0b01000000) if data else None,
28
+ "calibration": bool(data[1] & 0b01000000) if data and len(data) >= 2 else None,
29
29
  "battery": battery_data & 0b01111111 if battery_data is not None else None,
30
30
  "inMotion": _in_motion,
31
31
  "position": (100 - _position) if reverse else _position,
@@ -11,10 +11,13 @@ _STANDING_FAN_MODE_MAP: dict[int, str] = {
11
11
 
12
12
 
13
13
  def _parse_fan(
14
- mfr_data: bytes | None, mode_map: dict[int, str]
14
+ mfr_data: bytes | None,
15
+ mode_map: dict[int, str],
16
+ *,
17
+ with_charging: bool = False,
15
18
  ) -> dict[str, bool | int | str | None]:
16
19
  """Shared fan advertisement parse, parameterized on the mode map."""
17
- if mfr_data is None:
20
+ if mfr_data is None or len(mfr_data) < 10:
18
21
  return {}
19
22
 
20
23
  device_data = mfr_data[6:]
@@ -28,7 +31,7 @@ def _parse_fan(
28
31
  _battery = device_data[2] & 0b01111111
29
32
  _speed = device_data[3] & 0b01111111
30
33
 
31
- return {
34
+ result: dict[str, bool | int | str | None] = {
32
35
  "sequence_number": _seq_num,
33
36
  "isOn": _isOn,
34
37
  "mode": mode_map.get(_mode),
@@ -39,6 +42,10 @@ def _parse_fan(
39
42
  "battery": _battery,
40
43
  "speed": _speed,
41
44
  }
45
+ if with_charging:
46
+ # Bit 7 of the battery byte is the charging flag (Standing Fan only).
47
+ result["charging"] = bool(device_data[2] & 0b10000000)
48
+ return result
42
49
 
43
50
 
44
51
  def process_fan(
@@ -52,4 +59,4 @@ def process_standing_fan(
52
59
  data: bytes | None, mfr_data: bytes | None
53
60
  ) -> dict[str, bool | int | str | None]:
54
61
  """Process Standing Fan services data (modes 1-5; adds CUSTOM_NATURAL)."""
55
- return _parse_fan(mfr_data, _STANDING_FAN_MODE_MAP)
62
+ return _parse_fan(mfr_data, _STANDING_FAN_MODE_MAP, with_charging=True)
@@ -12,7 +12,7 @@ def process_wohub2(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]
12
12
  """Process woHub2 sensor manufacturer data."""
13
13
  temp_data = None
14
14
 
15
- if mfr_data:
15
+ if mfr_data and len(mfr_data) >= 16:
16
16
  status = mfr_data[12]
17
17
  temp_data = mfr_data[13:16]
18
18
 
@@ -10,7 +10,7 @@ from ..helpers import celsius_to_fahrenheit
10
10
 
11
11
  def process_hub3(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
12
12
  """Process hub3 sensor manufacturer data."""
13
- if mfr_data is None:
13
+ if mfr_data is None or len(mfr_data) < 17:
14
14
  return {}
15
15
  device_data = mfr_data[6:]
16
16
 
@@ -13,7 +13,7 @@ def process_hubmini_matter(
13
13
  """Process Hubmini matter sensor manufacturer data."""
14
14
  temp_data = None
15
15
 
16
- if mfr_data:
16
+ if mfr_data and len(mfr_data) >= 16:
17
17
  temp_data = mfr_data[13:16]
18
18
 
19
19
  if not temp_data:
@@ -46,7 +46,7 @@ def process_wohumidifier(
46
46
  data: bytes | None, mfr_data: bytes | None
47
47
  ) -> dict[str, bool | int]:
48
48
  """Process WoHumi services data."""
49
- if data is None:
49
+ if data is None or len(data) < 5:
50
50
  return {
51
51
  "isOn": None,
52
52
  "level": None,
@@ -64,7 +64,7 @@ def process_evaporative_humidifier(
64
64
  data: bytes | None, mfr_data: bytes | None
65
65
  ) -> dict[str, bool | int]:
66
66
  """Process WoHumi services data."""
67
- if mfr_data is None:
67
+ if mfr_data is None or len(mfr_data) < 17:
68
68
  return {}
69
69
 
70
70
  seq_number = mfr_data[6]
@@ -12,7 +12,7 @@ def process_wokeypad(
12
12
  mfr_data: bytes | None,
13
13
  ) -> dict[str, bool | int | None]:
14
14
  """Process woKeypad services data."""
15
- if data is None or mfr_data is None:
15
+ if data is None or mfr_data is None or len(data) < 3 or len(mfr_data) < 7:
16
16
  return {"battery": None, "attempt_state": None}
17
17
 
18
18
  _LOGGER.debug("mfr_data: %s", mfr_data.hex())
@@ -7,7 +7,7 @@ _LOGGER = logging.getLogger(__name__)
7
7
 
8
8
  def process_common_mfr_data(mfr_data: bytes | None) -> dict[str, bool | int]:
9
9
  """Process common Keypad Vision (Pro) manufacturer data."""
10
- if mfr_data is None:
10
+ if mfr_data is None or len(mfr_data) < 13:
11
11
  return {}
12
12
 
13
13
  sequence_number = mfr_data[6]
@@ -41,7 +41,7 @@ def process_keypad_vision(
41
41
  """Process Keypad Vision data."""
42
42
  result = process_common_mfr_data(mfr_data)
43
43
 
44
- if not result:
44
+ if not result or len(mfr_data) < 14:
45
45
  return {}
46
46
 
47
47
  pir_triggered_level = mfr_data[13] & 0x03
@@ -63,7 +63,7 @@ def process_keypad_vision_pro(
63
63
  """Process Keypad Vision Pro data."""
64
64
  result = process_common_mfr_data(mfr_data)
65
65
 
66
- if not result:
66
+ if not result or len(mfr_data) < 14:
67
67
  return {}
68
68
 
69
69
  radar_triggered_level = mfr_data[13] & 0x03
@@ -3,7 +3,7 @@
3
3
 
4
4
  def process_leak(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
5
5
  """Process SwitchBot Water Leak Detector advertisement data."""
6
- if data is None or len(data) < 3 or mfr_data is None or len(mfr_data) < 2:
6
+ if mfr_data is None or len(mfr_data) < 9:
7
7
  return {}
8
8
 
9
9
  water_leak_detected = None
@@ -9,7 +9,7 @@ def process_wostrip(
9
9
  data: bytes | None, mfr_data: bytes | None
10
10
  ) -> dict[str, bool | int]:
11
11
  """Process WoStrip services data."""
12
- if mfr_data is None:
12
+ if mfr_data is None or len(mfr_data) < 9:
13
13
  return {}
14
14
  return {
15
15
  "sequence_number": mfr_data[6],
@@ -25,7 +25,7 @@ def process_candle_warmer_lamp(
25
25
  data: bytes | None, mfr_data: bytes | None
26
26
  ) -> dict[str, bool | int]:
27
27
  """Process Candle Warmer Lamp services data."""
28
- if mfr_data is None:
28
+ if mfr_data is None or len(mfr_data) < 9:
29
29
  return {}
30
30
  return {
31
31
  "sequence_number": mfr_data[6],
@@ -41,7 +41,7 @@ def process_light(
41
41
  ) -> dict[str, bool | int]:
42
42
  """Support for strip light 3 and floor lamp."""
43
43
  common_data = process_wostrip(data, mfr_data)
44
- if not common_data:
44
+ if not common_data or len(mfr_data) < cw_offset + 2:
45
45
  return {}
46
46
 
47
47
  light_data = {"cw": _UNPACK_UINT16_BE(mfr_data, cw_offset)[0]}
@@ -54,3 +54,16 @@ def process_rgbic_light(
54
54
  ) -> dict[str, bool | int]:
55
55
  """Support for RGBIC lights."""
56
56
  return process_light(data, mfr_data, cw_offset=10)
57
+
58
+
59
+ def process_rgbicww_ceiling_light(
60
+ data: bytes | None, mfr_data: bytes | None
61
+ ) -> dict[str, bool | int]:
62
+ """Support for RGBICWW Ceiling Light (white + color sub-lights)."""
63
+ common_data = process_light(data, mfr_data, cw_offset=10)
64
+ if not common_data or len(mfr_data) < 14:
65
+ return {}
66
+ return common_data | {
67
+ "main_isOn": bool(mfr_data[13] & 0b10000000),
68
+ "main_brightness": mfr_data[13] & 0b01111111,
69
+ }