PySwitchbot 0.76.0__tar.gz → 1.0.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 (112) hide show
  1. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/PKG-INFO +1 -1
  2. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
  3. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/SOURCES.txt +3 -0
  4. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/setup.py +1 -1
  5. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/__init__.py +2 -0
  6. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/device.py +99 -12
  7. pyswitchbot-1.0.0/switchbot/devices/meter_pro.py +172 -0
  8. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/discovery.py +12 -1
  9. pyswitchbot-1.0.0/tests/test_discovery_callback.py +142 -0
  10. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_encrypted_device.py +174 -12
  11. pyswitchbot-1.0.0/tests/test_meter_pro.py +249 -0
  12. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/LICENSE +0 -0
  13. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/MANIFEST.in +0 -0
  14. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  15. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/requires.txt +0 -0
  16. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/top_level.txt +0 -0
  17. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/README.md +0 -0
  18. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/pyproject.toml +0 -0
  19. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/setup.cfg +0 -0
  20. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parser.py +0 -0
  21. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/__init__.py +0 -0
  22. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/air_purifier.py +0 -0
  23. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/art_frame.py +0 -0
  24. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  25. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/bot.py +0 -0
  26. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/bulb.py +0 -0
  27. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  28. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/climate_panel.py +0 -0
  29. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/contact.py +0 -0
  30. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/curtain.py +0 -0
  31. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/fan.py +0 -0
  32. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hub2.py +0 -0
  33. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hub3.py +0 -0
  34. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  35. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/humidifier.py +0 -0
  36. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/keypad.py +0 -0
  37. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/keypad_vision.py +0 -0
  38. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/leak.py +0 -0
  39. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/light_strip.py +0 -0
  40. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/lock.py +0 -0
  41. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/meter.py +0 -0
  42. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/motion.py +0 -0
  43. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/plug.py +0 -0
  44. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
  45. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  46. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/remote.py +0 -0
  47. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/roller_shade.py +0 -0
  48. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
  49. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/vacuum.py +0 -0
  50. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/api_config.py +0 -0
  51. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/__init__.py +0 -0
  52. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/air_purifier.py +0 -0
  53. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/climate.py +0 -0
  54. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/evaporative_humidifier.py +0 -0
  55. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/fan.py +0 -0
  56. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/hub2.py +0 -0
  57. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/hub3.py +0 -0
  58. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/light.py +0 -0
  59. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/lock.py +0 -0
  60. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/const/presence_sensor.py +0 -0
  61. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/__init__.py +0 -0
  62. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/air_purifier.py +0 -0
  63. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/art_frame.py +0 -0
  64. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/base_cover.py +0 -0
  65. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/base_light.py +0 -0
  66. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/blind_tilt.py +0 -0
  67. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/bot.py +0 -0
  68. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/bulb.py +0 -0
  69. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/ceiling_light.py +0 -0
  70. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/contact.py +0 -0
  71. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/curtain.py +0 -0
  72. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  73. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/fan.py +0 -0
  74. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/humidifier.py +0 -0
  75. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/keypad.py +0 -0
  76. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/keypad_vision.py +0 -0
  77. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/light_strip.py +0 -0
  78. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/lock.py +0 -0
  79. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/meter.py +0 -0
  80. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/motion.py +0 -0
  81. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/plug.py +0 -0
  82. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/relay_switch.py +0 -0
  83. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/roller_shade.py +0 -0
  84. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
  85. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/devices/vacuum.py +0 -0
  86. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/enum.py +0 -0
  87. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/helpers.py +0 -0
  88. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/models.py +0 -0
  89. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/switchbot/utils.py +0 -0
  90. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_adv_parser.py +0 -0
  91. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_air_purifier.py +0 -0
  92. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_art_frame.py +0 -0
  93. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_base_cover.py +0 -0
  94. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_blind_tilt.py +0 -0
  95. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_bulb.py +0 -0
  96. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_ceiling_light.py +0 -0
  97. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_colormode_imports.py +0 -0
  98. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_curtain.py +0 -0
  99. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_device.py +0 -0
  100. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_evaporative_humidifier.py +0 -0
  101. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_fan.py +0 -0
  102. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_helpers.py +0 -0
  103. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_hub2.py +0 -0
  104. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_hub3.py +0 -0
  105. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_keypad_vision.py +0 -0
  106. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_lock.py +0 -0
  107. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_relay_switch.py +0 -0
  108. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_roller_shade.py +0 -0
  109. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_smart_thermostat_radiator.py +0 -0
  110. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_strip_light.py +0 -0
  111. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_utils.py +0 -0
  112. {pyswitchbot-0.76.0 → pyswitchbot-1.0.0}/tests/test_vacuum.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 0.76.0
3
+ Version: 1.0.0
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 0.76.0
3
+ Version: 1.0.0
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -75,6 +75,7 @@ switchbot/devices/keypad_vision.py
75
75
  switchbot/devices/light_strip.py
76
76
  switchbot/devices/lock.py
77
77
  switchbot/devices/meter.py
78
+ switchbot/devices/meter_pro.py
78
79
  switchbot/devices/motion.py
79
80
  switchbot/devices/plug.py
80
81
  switchbot/devices/relay_switch.py
@@ -91,6 +92,7 @@ tests/test_ceiling_light.py
91
92
  tests/test_colormode_imports.py
92
93
  tests/test_curtain.py
93
94
  tests/test_device.py
95
+ tests/test_discovery_callback.py
94
96
  tests/test_encrypted_device.py
95
97
  tests/test_evaporative_humidifier.py
96
98
  tests/test_fan.py
@@ -99,6 +101,7 @@ tests/test_hub2.py
99
101
  tests/test_hub3.py
100
102
  tests/test_keypad_vision.py
101
103
  tests/test_lock.py
104
+ tests/test_meter_pro.py
102
105
  tests/test_relay_switch.py
103
106
  tests/test_roller_shade.py
104
107
  tests/test_smart_thermostat_radiator.py
@@ -20,7 +20,7 @@ setup(
20
20
  "cryptography>=39.0.0",
21
21
  "pyOpenSSL>=23.0.0",
22
22
  ],
23
- version="0.76.0",
23
+ version="1.0.0",
24
24
  description="A library to communicate with Switchbot",
25
25
  long_description=long_description,
26
26
  long_description_content_type="text/markdown",
@@ -52,6 +52,7 @@ from .devices.light_strip import (
52
52
  SwitchbotStripLight3,
53
53
  )
54
54
  from .devices.lock import SwitchbotLock
55
+ from .devices.meter_pro import SwitchbotMeterProCO2
55
56
  from .devices.plug import SwitchbotPlugMini
56
57
  from .devices.relay_switch import (
57
58
  SwitchbotGarageDoorOpener,
@@ -101,6 +102,7 @@ __all__ = [
101
102
  "SwitchbotKeypadVision",
102
103
  "SwitchbotLightStrip",
103
104
  "SwitchbotLock",
105
+ "SwitchbotMeterProCO2",
104
106
  "SwitchbotModel",
105
107
  "SwitchbotModel",
106
108
  "SwitchbotOperationError",
@@ -8,6 +8,7 @@ import logging
8
8
  import time
9
9
  from collections.abc import Callable
10
10
  from dataclasses import replace
11
+ from enum import IntEnum
11
12
  from typing import Any, TypeVar, cast
12
13
  from uuid import UUID
13
14
 
@@ -142,6 +143,21 @@ class SwitchbotOperationError(Exception):
142
143
  """Raised when an operation fails."""
143
144
 
144
145
 
146
+ class AESMode(IntEnum):
147
+ """Supported AES modes for encrypted devices."""
148
+
149
+ CTR = 0
150
+ GCM = 1
151
+
152
+
153
+ def _normalize_encryption_mode(mode: int) -> AESMode:
154
+ """Normalize encryption mode to AESMode (only 0/1 allowed)."""
155
+ try:
156
+ return AESMode(mode)
157
+ except (TypeError, ValueError) as exc:
158
+ raise ValueError(f"Unsupported encryption mode: {mode}") from exc
159
+
160
+
145
161
  def _sb_uuid(comms_type: str = "service") -> UUID | str:
146
162
  """Return Switchbot UUID."""
147
163
  _uuid = {"tx": "002", "rx": "003", "service": "d00"}
@@ -982,7 +998,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
982
998
  self._key_id = key_id
983
999
  self._encryption_key = bytearray.fromhex(encryption_key)
984
1000
  self._iv: bytes | None = None
985
- self._cipher: bytes | None = None
1001
+ self._cipher: Cipher | None = None
1002
+ self._encryption_mode: AESMode | None = None
986
1003
  super().__init__(device, None, interface, **kwargs)
987
1004
  self._model = model
988
1005
 
@@ -1081,9 +1098,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
1081
1098
  _LOGGER.error("Failed to initialize encryption")
1082
1099
  return None
1083
1100
 
1084
- encrypted = (
1085
- key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:])
1086
- )
1101
+ ciphertext_hex, header_hex = self._encrypt(key[2:])
1102
+ encrypted = key[:2] + self._key_id + header_hex + ciphertext_hex
1087
1103
  command = bytearray.fromhex(self._commandkey(encrypted))
1088
1104
  _LOGGER.debug("%s: Scheduling command %s", self.name, command.hex())
1089
1105
  max_attempts = retry + 1
@@ -1093,7 +1109,10 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
1093
1109
  )
1094
1110
  if result is None:
1095
1111
  return None
1096
- return result[:1] + self._decrypt(result[4:])
1112
+ decrypted = self._decrypt(result[4:])
1113
+ if self._encryption_mode == AESMode.GCM:
1114
+ self._increment_gcm_iv()
1115
+ return result[:1] + decrypted
1097
1116
 
1098
1117
  async def _ensure_encryption_initialized(self) -> bool:
1099
1118
  """Ensure encryption is initialized, must be called with operation lock held."""
@@ -1117,34 +1136,71 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
1117
1136
  return False
1118
1137
 
1119
1138
  if ok := self._check_command_result(result, 0, {1}):
1120
- self._iv = result[4:]
1139
+ _LOGGER.debug("%s: Encryption init response: %s", self.name, result.hex())
1140
+ mode_byte = result[2] if len(result) > 2 else None
1141
+ self._resolve_encryption_mode(mode_byte)
1142
+ if self._encryption_mode == AESMode.GCM:
1143
+ iv = result[4:-4]
1144
+ expected_iv_len = 12
1145
+ else:
1146
+ iv = result[4:]
1147
+ expected_iv_len = 16
1148
+ if len(iv) != expected_iv_len:
1149
+ _LOGGER.error(
1150
+ "%s: Invalid IV length %d for mode %s (expected %d)",
1151
+ self.name,
1152
+ len(iv),
1153
+ self._encryption_mode.name,
1154
+ expected_iv_len,
1155
+ )
1156
+ return False
1157
+ self._iv = iv
1121
1158
  self._cipher = None # Reset cipher when IV changes
1122
1159
  _LOGGER.debug("%s: Encryption initialized successfully", self.name)
1123
1160
 
1124
1161
  return ok
1125
1162
 
1126
1163
  async def _execute_disconnect(self) -> None:
1164
+ """
1165
+ Reset encryption state and disconnect.
1166
+
1167
+ Clears IV, cipher, and encryption mode so they can be
1168
+ re-detected on the next connection (e.g., after firmware update).
1169
+ """
1127
1170
  async with self._connect_lock:
1128
1171
  self._iv = None
1129
1172
  self._cipher = None
1173
+ self._encryption_mode = None
1130
1174
  await self._execute_disconnect_with_lock()
1131
1175
 
1132
1176
  def _get_cipher(self) -> Cipher:
1133
1177
  if self._cipher is None:
1134
1178
  if self._iv is None:
1135
1179
  raise RuntimeError("Cannot create cipher: IV is None")
1136
- self._cipher = Cipher(
1137
- algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
1138
- )
1180
+ if self._encryption_mode == AESMode.GCM:
1181
+ self._cipher = Cipher(
1182
+ algorithms.AES128(self._encryption_key), modes.GCM(self._iv)
1183
+ )
1184
+ else:
1185
+ self._cipher = Cipher(
1186
+ algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
1187
+ )
1139
1188
  return self._cipher
1140
1189
 
1141
- def _encrypt(self, data: str) -> str:
1190
+ def _encrypt(self, data: str) -> tuple[str, str]:
1142
1191
  if len(data) == 0:
1143
- return ""
1192
+ return "", ""
1144
1193
  if self._iv is None:
1145
1194
  raise RuntimeError("Cannot encrypt: IV is None")
1146
1195
  encryptor = self._get_cipher().encryptor()
1147
- return (encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()).hex()
1196
+ ciphertext = encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()
1197
+ if self._encryption_mode == AESMode.GCM:
1198
+ header_hex = encryptor.tag[:2].hex()
1199
+ # GCM cipher is single-use; clear it so _get_cipher() creates a fresh one
1200
+ self._cipher = None
1201
+ else:
1202
+ header_hex = self._iv[0:2].hex()
1203
+ return ciphertext.hex(), header_hex
1148
1204
 
1149
1205
  def _decrypt(self, data: bytearray) -> bytes:
1150
1206
  if len(data) == 0:
@@ -1157,9 +1213,40 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
1157
1213
  )
1158
1214
  return b""
1159
1215
  raise RuntimeError("Cannot decrypt: IV is None")
1216
+ if self._encryption_mode == AESMode.GCM:
1217
+ # Firmware only returns a 2-byte partial tag which can't be used for
1218
+ # verification. Use a dummy 16-byte tag and skip finalize() since
1219
+ # authentication is handled by the firmware.
1220
+ decryptor = Cipher(
1221
+ algorithms.AES128(self._encryption_key),
1222
+ modes.GCM(self._iv, b"\x00" * 16),
1223
+ ).decryptor()
1224
+ return decryptor.update(data)
1160
1225
  decryptor = self._get_cipher().decryptor()
1161
1226
  return decryptor.update(data) + decryptor.finalize()
1162
1227
 
1228
+ def _increment_gcm_iv(self) -> None:
1229
+ """Increment GCM IV by 1 (big-endian). Called after each encrypted command."""
1230
+ if self._iv is None:
1231
+ raise RuntimeError("Cannot increment GCM IV: IV is None")
1232
+ if len(self._iv) != 12:
1233
+ raise RuntimeError("Cannot increment GCM IV: IV length is not 12 bytes")
1234
+ iv_int = int.from_bytes(self._iv, "big") + 1
1235
+ self._iv = iv_int.to_bytes(12, "big")
1236
+ self._cipher = None
1237
+
1238
+ def _resolve_encryption_mode(self, mode_byte: int | None) -> None:
1239
+ """Resolve encryption mode from device response when available."""
1240
+ if mode_byte is None:
1241
+ raise ValueError("Encryption mode byte is missing")
1242
+ detected_mode = _normalize_encryption_mode(mode_byte)
1243
+ if self._encryption_mode is not None and self._encryption_mode != detected_mode:
1244
+ raise ValueError(
1245
+ f"Conflicting encryption modes detected: {self._encryption_mode.name} vs {detected_mode.name}"
1246
+ )
1247
+ self._encryption_mode = detected_mode
1248
+ _LOGGER.debug("%s: Detected encryption mode: %s", self.name, detected_mode.name)
1249
+
1163
1250
 
1164
1251
  class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
1165
1252
  """
@@ -0,0 +1,172 @@
1
+ from typing import Any
2
+
3
+ from ..helpers import parse_uint24_be
4
+ from .device import SwitchbotDevice, SwitchbotOperationError
5
+
6
+ COMMAND_SET_TIME_OFFSET = "570f680506"
7
+ COMMAND_GET_TIME_OFFSET = "570f690506"
8
+ MAX_TIME_OFFSET = (1 << 24) - 1
9
+
10
+ COMMAND_GET_DEVICE_DATETIME = "570f6901"
11
+ COMMAND_SET_DEVICE_DATETIME = "57000503"
12
+ COMMAND_SET_DISPLAY_FORMAT = "570f680505"
13
+
14
+
15
+ class SwitchbotMeterProCO2(SwitchbotDevice):
16
+ """API to control Switchbot Meter Pro CO2."""
17
+
18
+ async def get_time_offset(self) -> int:
19
+ """
20
+ Get the current display time offset from the device.
21
+
22
+ Returns:
23
+ int: The time offset in seconds. Max 24 bits.
24
+
25
+ """
26
+ # Response Format: 5 bytes, where
27
+ # - byte 0: "01" (success)
28
+ # - byte 1: "00" (plus offset) or "80" (minus offset)
29
+ # - bytes 2-4: int24, number of seconds to offset.
30
+ # Example response: 01-80-00-10-00 -> subtract 4096 seconds.
31
+ result = await self._send_command(COMMAND_GET_TIME_OFFSET)
32
+ result = self._validate_result("get_time_offset", result, min_length=5)
33
+
34
+ is_negative = bool(result[1] & 0b10000000)
35
+ offset = parse_uint24_be(result, 2)
36
+ return -offset if is_negative else offset
37
+
38
+ async def set_time_offset(self, offset_seconds: int) -> None:
39
+ """
40
+ Set the display time offset on the device. This is what happens when
41
+ you adjust display time in the Switchbot app. The displayed time is
42
+ calculated as the internal device time (usually comes from the factory
43
+ settings or set by the Switchbot app upon syncing) + offset. The offset
44
+ is provided in seconds and can be positive or negative.
45
+
46
+ Args:
47
+ offset_seconds (int): 2^24 maximum, can be negative.
48
+
49
+ """
50
+ abs_offset = abs(offset_seconds)
51
+ if abs_offset > MAX_TIME_OFFSET:
52
+ raise SwitchbotOperationError(
53
+ f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{MAX_TIME_OFFSET} max."
54
+ )
55
+
56
+ sign_byte = "80" if offset_seconds < 0 else "00"
57
+
58
+ # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds.
59
+ payload = f"{COMMAND_SET_TIME_OFFSET}{sign_byte}{abs_offset:06x}"
60
+ result = await self._send_command(payload)
61
+ self._validate_result("set_time_offset", result)
62
+
63
+ async def get_datetime(self) -> dict[str, Any]:
64
+ """
65
+ Get the current device time and settings as it is displayed. Contains
66
+ a time offset, if any was applied (see set_time_offset).
67
+ Doesn't include the current time zone.
68
+
69
+ Returns:
70
+ dict: Dictionary containing:
71
+ - 12h_mode (bool): True if 12h mode, False if 24h mode.
72
+ - year (int)
73
+ - month (int)
74
+ - day (int)
75
+ - hour (int)
76
+ - minute (int)
77
+ - second (int)
78
+
79
+ """
80
+ # Response Format: 13 bytes, where
81
+ # - byte 0: "01" (success)
82
+ # - bytes 1-4: temperature, ignored here.
83
+ # - byte 5: time display format:
84
+ # - "80" - 12h (am/pm)
85
+ # - "00" - 24h
86
+ # - bytes 6-12: yyyy-MM-dd-hh-mm-ss
87
+ # Example: 01-e4-02-94-23-00-07-e9-0c-1e-08-37-01 contains
88
+ # "year 2025, 30 December, 08:55:01, displayed in 24h format".
89
+ result = await self._send_command(COMMAND_GET_DEVICE_DATETIME)
90
+ result = self._validate_result("get_datetime", result, min_length=13)
91
+ return {
92
+ # Whether the time is displayed in 12h(am/pm) or 24h mode.
93
+ "12h_mode": bool(result[5] & 0b10000000),
94
+ "year": (result[6] << 8) + result[7],
95
+ "month": result[8],
96
+ "day": result[9],
97
+ "hour": result[10],
98
+ "minute": result[11],
99
+ "second": result[12],
100
+ }
101
+
102
+ async def set_datetime(
103
+ self, timestamp: int, utc_offset_hours: int = 0, utc_offset_minutes: int = 0
104
+ ) -> None:
105
+ """
106
+ Set the device internal time and timezone. Similar to how the
107
+ Switchbot app does it upon syncing with the device.
108
+ Pay attention to calculating UTC offset hours and minutes, see
109
+ examples below.
110
+
111
+ Args:
112
+ timestamp (int): Unix timestamp in seconds.
113
+ utc_offset_hours (int): UTC offset in hours, floor()'ed,
114
+ within [-12; 14] range.
115
+ Examples: -5 for UTC-05:00, -6 for UTC-05:30,
116
+ 5 for UTC+05:00, 5 for UTC+5:30.
117
+ utc_offset_minutes (int): UTC offset minutes component, always
118
+ positive, complements utc_offset_hours.
119
+ Examples: 45 for UTC+05:45, 15 for UTC-5:45.
120
+
121
+ """
122
+ if not (-12 <= utc_offset_hours <= 14):
123
+ raise SwitchbotOperationError(
124
+ f"{self.name}: utc_offset_hours must be between -12 and +14 inclusive, got {utc_offset_hours}"
125
+ )
126
+ if not (0 <= utc_offset_minutes < 60):
127
+ raise SwitchbotOperationError(
128
+ f"{self.name}: utc_offset_minutes must be between 0 and 59 inclusive, got {utc_offset_minutes}"
129
+ )
130
+
131
+ # The device doesn't automatically add offset minutes, it expects them
132
+ # to come as a part of the timestamp.
133
+ adjusted_timestamp = timestamp + utc_offset_minutes * 60
134
+
135
+ # The timezone is encoded as 1 byte, where 00 stands for UTC-12.
136
+ # TZ with minute offset gets floor()ed: 4:30 yields 4, -4:30 yields -5.
137
+ utc_byte = utc_offset_hours + 12
138
+
139
+ payload = (
140
+ f"{COMMAND_SET_DEVICE_DATETIME}{utc_byte:02x}"
141
+ f"{adjusted_timestamp:016x}{utc_offset_minutes:02x}"
142
+ )
143
+
144
+ result = await self._send_command(payload)
145
+ self._validate_result("set_datetime", result)
146
+
147
+ async def set_time_display_format(self, is_12h_mode: bool = False) -> None:
148
+ """
149
+ Set the time display format on the device: 12h(AM/PM) or 24h.
150
+
151
+ Args:
152
+ is_12h_mode (bool): True for 12h (AM/PM) mode, False for 24h mode.
153
+
154
+ """
155
+ mode_byte = "80" if is_12h_mode else "00"
156
+ payload = f"{COMMAND_SET_DISPLAY_FORMAT}{mode_byte}"
157
+ result = await self._send_command(payload)
158
+ self._validate_result("set_time_display_format", result)
159
+
160
+ def _validate_result(
161
+ self, op_name: str, result: bytes | None, min_length: int | None = None
162
+ ) -> bytes:
163
+ if not self._check_command_result(result, 0, {1}):
164
+ raise SwitchbotOperationError(
165
+ f"{self.name}: Unexpected response code for {op_name} (result={result.hex() if result else 'None'} rssi={self.rssi})"
166
+ )
167
+ assert result is not None
168
+ if min_length is not None and len(result) < min_length:
169
+ raise SwitchbotOperationError(
170
+ f"{self.name}: Unexpected response len for {op_name}, wanted at least {min_length} (result={result.hex() if result else 'None'} rssi={self.rssi})"
171
+ )
172
+ return result
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from collections.abc import Callable
7
8
 
8
9
  import bleak
9
10
  from bleak.backends.device import BLEDevice
@@ -20,10 +21,15 @@ CONNECT_LOCK = asyncio.Lock()
20
21
  class GetSwitchbotDevices:
21
22
  """Scan for all Switchbot devices and return by type."""
22
23
 
23
- def __init__(self, interface: int = 0) -> None:
24
+ def __init__(
25
+ self,
26
+ interface: int = 0,
27
+ callback: Callable[[SwitchBotAdvertisement], None] | None = None,
28
+ ) -> None:
24
29
  """Get switchbot devices class constructor."""
25
30
  self._interface = f"hci{interface}"
26
31
  self._adv_data: dict[str, SwitchBotAdvertisement] = {}
32
+ self._callback = callback
27
33
 
28
34
  def detection_callback(
29
35
  self,
@@ -34,6 +40,11 @@ class GetSwitchbotDevices:
34
40
  discovery = parse_advertisement_data(device, advertisement_data)
35
41
  if discovery:
36
42
  self._adv_data[discovery.address] = discovery
43
+ if self._callback is not None:
44
+ try:
45
+ self._callback(discovery)
46
+ except Exception:
47
+ _LOGGER.exception("Error in discovery callback")
37
48
 
38
49
  async def discover(
39
50
  self, retry: int = DEFAULT_RETRY_COUNT, scan_timeout: int = DEFAULT_SCAN_TIMEOUT
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ import switchbot.discovery as discovery_module
9
+ from switchbot.discovery import GetSwitchbotDevices
10
+ from switchbot.models import SwitchBotAdvertisement
11
+
12
+
13
+ @dataclass
14
+ class _FakeBleakScanner:
15
+ detection_callback: object
16
+ adapter: str
17
+
18
+ async def start(self) -> None:
19
+ # detection_callback signature: (device, advertisement_data)
20
+ self.detection_callback(object(), object())
21
+ self.detection_callback(object(), object())
22
+
23
+ async def stop(self) -> None:
24
+ return
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_discover_fires_callback_for_each_packet(
29
+ monkeypatch: pytest.MonkeyPatch,
30
+ ) -> None:
31
+ calls: list[SwitchBotAdvertisement] = []
32
+
33
+ # Patch parse_advertisement_data to return a different parsed object per invocation.
34
+ parsed: list[SwitchBotAdvertisement] = [
35
+ SwitchBotAdvertisement(
36
+ address="aa:bb:cc:dd:ee:ff",
37
+ data={"model": "c", "modelName": "Curtain", "data": {"position": 10}},
38
+ device=object(),
39
+ rssi=-80,
40
+ active=True,
41
+ ),
42
+ SwitchBotAdvertisement(
43
+ address="aa:bb:cc:dd:ee:ff",
44
+ data={"model": "c", "modelName": "Curtain", "data": {"position": 20}},
45
+ device=object(),
46
+ rssi=-70,
47
+ active=True,
48
+ ),
49
+ ]
50
+
51
+ def _fake_parse(_device: object, _advertisement_data: object):
52
+ return parsed.pop(0)
53
+
54
+ async def _fake_sleep(_seconds: float) -> None:
55
+ return
56
+
57
+ def _fake_bleak_scanner(*, detection_callback, adapter: str):
58
+ return _FakeBleakScanner(detection_callback=detection_callback, adapter=adapter)
59
+
60
+ monkeypatch.setattr(discovery_module, "parse_advertisement_data", _fake_parse)
61
+ monkeypatch.setattr(discovery_module.asyncio, "sleep", _fake_sleep)
62
+ monkeypatch.setattr(discovery_module.bleak, "BleakScanner", _fake_bleak_scanner)
63
+
64
+ scanner = GetSwitchbotDevices(callback=calls.append)
65
+ result = await scanner.discover(scan_timeout=60)
66
+
67
+ assert len(calls) == 2
68
+ assert calls[0].data["data"]["position"] == 10
69
+ assert calls[1].data["data"]["position"] == 20
70
+
71
+ # discover() retains backwards compatibility by still returning accumulated data.
72
+ assert result["aa:bb:cc:dd:ee:ff"].data["data"]["position"] == 20
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_callback_exception_does_not_break_discovery(
77
+ monkeypatch: pytest.MonkeyPatch,
78
+ ) -> None:
79
+ adv = SwitchBotAdvertisement(
80
+ address="11:22:33:44:55:66",
81
+ data={"model": "H", "modelName": "Bot", "data": {}},
82
+ device=object(),
83
+ rssi=-50,
84
+ active=True,
85
+ )
86
+
87
+ def _fake_parse(_device: object, _advertisement_data: object):
88
+ return adv
89
+
90
+ async def _fake_sleep(_seconds: float) -> None:
91
+ return
92
+
93
+ def _fake_bleak_scanner(*, detection_callback, adapter: str):
94
+ class _S:
95
+ async def start(self) -> None:
96
+ detection_callback(object(), object())
97
+
98
+ async def stop(self) -> None:
99
+ return
100
+
101
+ return _S()
102
+
103
+ def _boom(_adv: SwitchBotAdvertisement) -> None:
104
+ raise RuntimeError("boom")
105
+
106
+ monkeypatch.setattr(discovery_module, "parse_advertisement_data", _fake_parse)
107
+ monkeypatch.setattr(discovery_module.asyncio, "sleep", _fake_sleep)
108
+ monkeypatch.setattr(discovery_module.bleak, "BleakScanner", _fake_bleak_scanner)
109
+
110
+ scanner = GetSwitchbotDevices(callback=_boom)
111
+ result = await scanner.discover(scan_timeout=1)
112
+
113
+ assert result["11:22:33:44:55:66"] == adv
114
+
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_callback_exception_is_logged_and_suppressed(
118
+ caplog: pytest.LogCaptureFixture,
119
+ ) -> None:
120
+ """
121
+ Test that exceptions raised in the user callback are caught,
122
+ logged, and do not crash the discovery process.
123
+ """
124
+ mock_callback = MagicMock(side_effect=RuntimeError("Boom!"))
125
+
126
+ scanner = GetSwitchbotDevices(callback=mock_callback)
127
+
128
+ adv = SwitchBotAdvertisement(
129
+ address="aa:bb:cc:dd:ee:ff",
130
+ data={"model": "H", "modelName": "Bot", "data": {}},
131
+ device=MagicMock(),
132
+ rssi=-80,
133
+ active=True,
134
+ )
135
+
136
+ with patch("switchbot.discovery.parse_advertisement_data", return_value=adv):
137
+ scanner.detection_callback(MagicMock(), MagicMock())
138
+
139
+ mock_callback.assert_called_once()
140
+
141
+ assert "Error in discovery callback" in caplog.text
142
+ assert "Boom!" in caplog.text # Exception message should also be in the log