PySwitchbot 2.2.0__tar.gz → 2.4.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.4.0}/PKG-INFO +46 -22
  2. pyswitchbot-2.2.0/PKG-INFO → pyswitchbot-2.4.0/README.md +27 -35
  3. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/pyproject.toml +73 -0
  4. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/__init__.py +4 -5
  5. pyswitchbot-2.4.0/switchbot/__version__.py +1 -0
  6. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parser.py +25 -0
  7. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/air_purifier.py +1 -1
  8. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/art_frame.py +1 -1
  9. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/blind_tilt.py +2 -2
  10. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/bot.py +1 -1
  11. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/bulb.py +1 -1
  12. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/ceiling_light.py +1 -1
  13. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/climate_panel.py +1 -1
  14. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/contact.py +8 -3
  15. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/curtain.py +3 -3
  16. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/fan.py +11 -4
  17. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/hub2.py +1 -1
  18. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/hub3.py +1 -1
  19. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/hubmini_matter.py +1 -1
  20. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/humidifier.py +2 -2
  21. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/keypad.py +1 -1
  22. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/keypad_vision.py +3 -3
  23. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/leak.py +1 -1
  24. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/light_strip.py +16 -3
  25. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/lock.py +4 -4
  26. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/meter.py +10 -4
  27. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/motion.py +2 -1
  28. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/plug.py +1 -1
  29. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/presence_sensor.py +1 -1
  30. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/remote.py +1 -1
  31. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/roller_shade.py +2 -2
  32. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +1 -1
  33. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/vacuum.py +2 -2
  34. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/__init__.py +2 -0
  35. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/light.py +11 -0
  36. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/ceiling_light.py +1 -1
  37. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/device.py +4 -2
  38. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/fan.py +87 -2
  39. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/light_strip.py +138 -2
  40. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/meter_pro.py +6 -2
  41. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/relay_switch.py +24 -0
  42. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/smart_thermostat_radiator.py +12 -0
  43. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/discovery.py +2 -4
  44. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/utils.py +1 -1
  45. pyswitchbot-2.2.0/MANIFEST.in +0 -1
  46. pyswitchbot-2.2.0/PySwitchbot.egg-info/SOURCES.txt +0 -112
  47. pyswitchbot-2.2.0/PySwitchbot.egg-info/dependency_links.txt +0 -1
  48. pyswitchbot-2.2.0/PySwitchbot.egg-info/requires.txt +0 -5
  49. pyswitchbot-2.2.0/PySwitchbot.egg-info/top_level.txt +0 -1
  50. pyswitchbot-2.2.0/README.md +0 -110
  51. pyswitchbot-2.2.0/setup.cfg +0 -4
  52. pyswitchbot-2.2.0/setup.py +0 -40
  53. pyswitchbot-2.2.0/tests/test_adv_parser.py +0 -4754
  54. pyswitchbot-2.2.0/tests/test_air_purifier.py +0 -466
  55. pyswitchbot-2.2.0/tests/test_art_frame.py +0 -200
  56. pyswitchbot-2.2.0/tests/test_base_cover.py +0 -151
  57. pyswitchbot-2.2.0/tests/test_blind_tilt.py +0 -240
  58. pyswitchbot-2.2.0/tests/test_bulb.py +0 -241
  59. pyswitchbot-2.2.0/tests/test_ceiling_light.py +0 -194
  60. pyswitchbot-2.2.0/tests/test_colormode_imports.py +0 -88
  61. pyswitchbot-2.2.0/tests/test_curtain.py +0 -425
  62. pyswitchbot-2.2.0/tests/test_device.py +0 -416
  63. pyswitchbot-2.2.0/tests/test_discovery_callback.py +0 -142
  64. pyswitchbot-2.2.0/tests/test_encrypted_device.py +0 -593
  65. pyswitchbot-2.2.0/tests/test_evaporative_humidifier.py +0 -341
  66. pyswitchbot-2.2.0/tests/test_fan.py +0 -621
  67. pyswitchbot-2.2.0/tests/test_helpers.py +0 -72
  68. pyswitchbot-2.2.0/tests/test_hub2.py +0 -18
  69. pyswitchbot-2.2.0/tests/test_hub3.py +0 -13
  70. pyswitchbot-2.2.0/tests/test_keypad_vision.py +0 -259
  71. pyswitchbot-2.2.0/tests/test_lock.py +0 -864
  72. pyswitchbot-2.2.0/tests/test_meter_pro.py +0 -249
  73. pyswitchbot-2.2.0/tests/test_relay_switch.py +0 -482
  74. pyswitchbot-2.2.0/tests/test_roller_shade.py +0 -383
  75. pyswitchbot-2.2.0/tests/test_smart_thermostat_radiator.py +0 -221
  76. pyswitchbot-2.2.0/tests/test_strip_light.py +0 -475
  77. pyswitchbot-2.2.0/tests/test_utils.py +0 -41
  78. pyswitchbot-2.2.0/tests/test_vacuum.py +0 -145
  79. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/LICENSE +0 -0
  80. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/__init__.py +0 -0
  81. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/_sensor_th.py +0 -0
  82. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  83. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/adv_parsers/weather_station.py +0 -0
  84. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/api_config.py +0 -0
  85. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/air_purifier.py +0 -0
  86. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/climate.py +0 -0
  87. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/evaporative_humidifier.py +0 -0
  88. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/fan.py +0 -0
  89. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/hub2.py +0 -0
  90. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/hub3.py +0 -0
  91. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/lock.py +0 -0
  92. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/const/presence_sensor.py +0 -0
  93. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/__init__.py +0 -0
  94. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/air_purifier.py +0 -0
  95. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/art_frame.py +0 -0
  96. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/base_cover.py +0 -0
  97. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/base_light.py +0 -0
  98. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/blind_tilt.py +0 -0
  99. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/bot.py +0 -0
  100. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/bulb.py +0 -0
  101. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/contact.py +0 -0
  102. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/curtain.py +0 -0
  103. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  104. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/humidifier.py +0 -0
  105. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/keypad.py +0 -0
  106. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/keypad_vision.py +0 -0
  107. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/lock.py +0 -0
  108. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/meter.py +0 -0
  109. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/motion.py +0 -0
  110. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/plug.py +0 -0
  111. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/roller_shade.py +0 -0
  112. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/devices/vacuum.py +0 -0
  113. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/enum.py +0 -0
  114. {pyswitchbot-2.2.0 → pyswitchbot-2.4.0}/switchbot/helpers.py +0 -0
  115. {pyswitchbot-2.2.0 → pyswitchbot-2.4.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.4.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.4.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.5"
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,10 +57,11 @@ 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
63
- from .devices.meter_pro import SwitchbotMeterProCO2
64
+ from .devices.meter_pro import SwitchbotMeterPro, SwitchbotMeterProCO2
64
65
  from .devices.plug import SwitchbotPlugMini
65
66
  from .devices.relay_switch import (
66
67
  SwitchbotGarageDoorOpener,
@@ -94,7 +95,6 @@ __all__ = [
94
95
  "StripLightColorMode",
95
96
  "SwitchBotAdvertisement",
96
97
  "Switchbot",
97
- "Switchbot",
98
98
  "SwitchbotAccountConnectionError",
99
99
  "SwitchbotAirPurifier",
100
100
  "SwitchbotApiError",
@@ -115,23 +115,22 @@ __all__ = [
115
115
  "SwitchbotKeypadVision",
116
116
  "SwitchbotLightStrip",
117
117
  "SwitchbotLock",
118
+ "SwitchbotMeterPro",
118
119
  "SwitchbotMeterProCO2",
119
120
  "SwitchbotModel",
120
- "SwitchbotModel",
121
121
  "SwitchbotOperationError",
122
122
  "SwitchbotPermanentOutdoorLight",
123
123
  "SwitchbotPlugMini",
124
- "SwitchbotPlugMini",
125
124
  "SwitchbotRelaySwitch",
126
125
  "SwitchbotRelaySwitch2PM",
127
126
  "SwitchbotRgbicLight",
128
127
  "SwitchbotRgbicNeonLight",
128
+ "SwitchbotRgbicwwCeilingLight",
129
129
  "SwitchbotRollerShade",
130
130
  "SwitchbotSmartThermostatRadiator",
131
131
  "SwitchbotStandingFan",
132
132
  "SwitchbotStripLight3",
133
133
  "SwitchbotSupportedType",
134
- "SwitchbotSupportedType",
135
134
  "SwitchbotVacuum",
136
135
  "VerticalOscillationAngle",
137
136
  "close_stale_connections",
@@ -0,0 +1 @@
1
+ __version__ = "2.4.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,30 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
677
678
  "func": process_rgbic_light,
678
679
  "manufacturer_id": 2409,
679
680
  },
681
+ b"\x00\x11\xbe\xf8": {
682
+ "modelName": SwitchbotModel.RGBICWW_LIGHT_BARS,
683
+ "modelFriendlyName": "RGBICWW Light Bars",
684
+ "func": process_rgbic_light,
685
+ "manufacturer_id": 2409,
686
+ },
687
+ b"\x01\x11\xbe\xf8": {
688
+ "modelName": SwitchbotModel.RGBICWW_LIGHT_BARS,
689
+ "modelFriendlyName": "RGBICWW Light Bars",
690
+ "func": process_rgbic_light,
691
+ "manufacturer_id": 2409,
692
+ },
693
+ b"\x00\x11\xbb\x10": {
694
+ "modelName": SwitchbotModel.RGBICWW_CEILING_LIGHT,
695
+ "modelFriendlyName": "RGBICWW Ceiling Light",
696
+ "func": process_rgbicww_ceiling_light,
697
+ "manufacturer_id": 2409,
698
+ },
699
+ b"\x01\x11\xbb\x10": {
700
+ "modelName": SwitchbotModel.RGBICWW_CEILING_LIGHT,
701
+ "modelFriendlyName": "RGBICWW Ceiling Light",
702
+ "func": process_rgbicww_ceiling_light,
703
+ "manufacturer_id": 2409,
704
+ },
680
705
  b"\x00\x10\xd0\xb7": {
681
706
  "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
682
707
  "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