PySwitchbot 0.76.0__tar.gz → 1.1.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.
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/PKG-INFO +1 -1
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/PySwitchbot.egg-info/SOURCES.txt +3 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/pyproject.toml +0 -1
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/setup.py +1 -1
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/__init__.py +2 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parser.py +36 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/__init__.py +3 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/device.py +102 -12
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/lock.py +20 -2
- pyswitchbot-1.1.0/switchbot/devices/meter_pro.py +172 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/discovery.py +12 -1
- pyswitchbot-1.1.0/switchbot/enum.py +7 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_adv_parser.py +160 -0
- pyswitchbot-1.1.0/tests/test_discovery_callback.py +142 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_encrypted_device.py +174 -12
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_lock.py +126 -2
- pyswitchbot-1.1.0/tests/test_meter_pro.py +249 -0
- pyswitchbot-0.76.0/switchbot/enum.py +0 -20
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/LICENSE +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/MANIFEST.in +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/PySwitchbot.egg-info/requires.txt +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/PySwitchbot.egg-info/top_level.txt +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/README.md +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/setup.cfg +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/__init__.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/air_purifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/art_frame.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/bot.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/bulb.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/climate_panel.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/contact.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/curtain.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/fan.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/hub2.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/hub3.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/humidifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/keypad.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/keypad_vision.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/leak.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/light_strip.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/lock.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/meter.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/motion.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/plug.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/relay_switch.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/remote.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/roller_shade.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/adv_parsers/vacuum.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/api_config.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/air_purifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/climate.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/evaporative_humidifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/fan.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/hub2.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/hub3.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/light.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/lock.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/const/presence_sensor.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/__init__.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/air_purifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/art_frame.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/base_cover.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/base_light.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/blind_tilt.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/bot.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/bulb.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/ceiling_light.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/contact.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/curtain.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/evaporative_humidifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/fan.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/humidifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/keypad.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/keypad_vision.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/light_strip.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/meter.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/motion.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/plug.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/relay_switch.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/roller_shade.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/devices/vacuum.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/helpers.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/models.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/switchbot/utils.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_air_purifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_art_frame.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_base_cover.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_blind_tilt.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_bulb.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_ceiling_light.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_colormode_imports.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_curtain.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_device.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_evaporative_humidifier.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_fan.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_helpers.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_hub2.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_hub3.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_keypad_vision.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_relay_switch.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_roller_shade.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_smart_thermostat_radiator.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_strip_light.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_utils.py +0 -0
- {pyswitchbot-0.76.0 → pyswitchbot-1.1.0}/tests/test_vacuum.py +0 -0
|
@@ -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
|
|
@@ -25,7 +25,6 @@ ignore = [
|
|
|
25
25
|
"S110", # `try`-`except`-`pass` detected, consider logging the exception
|
|
26
26
|
"D106", # Missing docstring in public nested class
|
|
27
27
|
"UP007", # typer needs Optional syntax
|
|
28
|
-
"UP038", # Use `X | Y` in `isinstance` is slower
|
|
29
28
|
"S603", # check for execution of untrusted input
|
|
30
29
|
"S105", # possible hard coded creds
|
|
31
30
|
"TID252", # not for this lib
|
|
@@ -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",
|
|
@@ -755,6 +755,42 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
|
|
|
755
755
|
"func": process_keypad_vision_pro,
|
|
756
756
|
"manufacturer_id": 2409,
|
|
757
757
|
},
|
|
758
|
+
b"\x00\x11\x69\x09": {
|
|
759
|
+
"modelName": SwitchbotModel.LOCK_VISION_PRO,
|
|
760
|
+
"modelFriendlyName": "Lock Vision Pro",
|
|
761
|
+
"func": process_lock2,
|
|
762
|
+
"manufacturer_id": 2409,
|
|
763
|
+
},
|
|
764
|
+
b"\x01\x11\x69\x09": {
|
|
765
|
+
"modelName": SwitchbotModel.LOCK_VISION_PRO,
|
|
766
|
+
"modelFriendlyName": "Lock Vision Pro",
|
|
767
|
+
"func": process_lock2,
|
|
768
|
+
"manufacturer_id": 2409,
|
|
769
|
+
},
|
|
770
|
+
b"\x00\x11\x69\x08": {
|
|
771
|
+
"modelName": SwitchbotModel.LOCK_VISION,
|
|
772
|
+
"modelFriendlyName": "Lock Vision",
|
|
773
|
+
"func": process_locklite,
|
|
774
|
+
"manufacturer_id": 2409,
|
|
775
|
+
},
|
|
776
|
+
b"\x01\x11\x69\x08": {
|
|
777
|
+
"modelName": SwitchbotModel.LOCK_VISION,
|
|
778
|
+
"modelFriendlyName": "Lock Vision",
|
|
779
|
+
"func": process_locklite,
|
|
780
|
+
"manufacturer_id": 2409,
|
|
781
|
+
},
|
|
782
|
+
b"\x00\x10\xff\x90": {
|
|
783
|
+
"modelName": SwitchbotModel.LOCK_PRO_WIFI,
|
|
784
|
+
"modelFriendlyName": "Lock Pro Wifi",
|
|
785
|
+
"func": process_wolock_pro,
|
|
786
|
+
"manufacturer_id": 2409,
|
|
787
|
+
},
|
|
788
|
+
b"\x01\x10\xff\x90": {
|
|
789
|
+
"modelName": SwitchbotModel.LOCK_PRO_WIFI,
|
|
790
|
+
"modelFriendlyName": "Lock Pro Wifi",
|
|
791
|
+
"func": process_wolock_pro,
|
|
792
|
+
"manufacturer_id": 2409,
|
|
793
|
+
},
|
|
758
794
|
}
|
|
759
795
|
|
|
760
796
|
_SWITCHBOT_MODEL_TO_CHAR: defaultdict[SwitchbotModel, list[str | bytes]] = defaultdict(
|
|
@@ -105,6 +105,9 @@ class SwitchbotModel(StrEnum):
|
|
|
105
105
|
ART_FRAME = "Art Frame"
|
|
106
106
|
KEYPAD_VISION = "Keypad Vision"
|
|
107
107
|
KEYPAD_VISION_PRO = "Keypad Vision Pro"
|
|
108
|
+
LOCK_VISION_PRO = "Lock Vision Pro"
|
|
109
|
+
LOCK_VISION = "Lock Vision"
|
|
110
|
+
LOCK_PRO_WIFI = "Lock Pro Wifi"
|
|
108
111
|
|
|
109
112
|
|
|
110
113
|
__all__ = [
|
|
@@ -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
|
|
|
@@ -105,6 +106,9 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
|
|
|
105
106
|
"W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
|
|
106
107
|
"W1111000": SwitchbotModel.CLIMATE_PANEL,
|
|
107
108
|
"W1130000": SwitchbotModel.ART_FRAME,
|
|
109
|
+
"W1141001": SwitchbotModel.LOCK_VISION_PRO,
|
|
110
|
+
"W1141000": SwitchbotModel.LOCK_VISION,
|
|
111
|
+
"W1114000": SwitchbotModel.LOCK_PRO_WIFI,
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
REQ_HEADER = "570f"
|
|
@@ -142,6 +146,21 @@ class SwitchbotOperationError(Exception):
|
|
|
142
146
|
"""Raised when an operation fails."""
|
|
143
147
|
|
|
144
148
|
|
|
149
|
+
class AESMode(IntEnum):
|
|
150
|
+
"""Supported AES modes for encrypted devices."""
|
|
151
|
+
|
|
152
|
+
CTR = 0
|
|
153
|
+
GCM = 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_encryption_mode(mode: int) -> AESMode:
|
|
157
|
+
"""Normalize encryption mode to AESMode (only 0/1 allowed)."""
|
|
158
|
+
try:
|
|
159
|
+
return AESMode(mode)
|
|
160
|
+
except (TypeError, ValueError) as exc:
|
|
161
|
+
raise ValueError(f"Unsupported encryption mode: {mode}") from exc
|
|
162
|
+
|
|
163
|
+
|
|
145
164
|
def _sb_uuid(comms_type: str = "service") -> UUID | str:
|
|
146
165
|
"""Return Switchbot UUID."""
|
|
147
166
|
_uuid = {"tx": "002", "rx": "003", "service": "d00"}
|
|
@@ -982,7 +1001,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
982
1001
|
self._key_id = key_id
|
|
983
1002
|
self._encryption_key = bytearray.fromhex(encryption_key)
|
|
984
1003
|
self._iv: bytes | None = None
|
|
985
|
-
self._cipher:
|
|
1004
|
+
self._cipher: Cipher | None = None
|
|
1005
|
+
self._encryption_mode: AESMode | None = None
|
|
986
1006
|
super().__init__(device, None, interface, **kwargs)
|
|
987
1007
|
self._model = model
|
|
988
1008
|
|
|
@@ -1081,9 +1101,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1081
1101
|
_LOGGER.error("Failed to initialize encryption")
|
|
1082
1102
|
return None
|
|
1083
1103
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
)
|
|
1104
|
+
ciphertext_hex, header_hex = self._encrypt(key[2:])
|
|
1105
|
+
encrypted = key[:2] + self._key_id + header_hex + ciphertext_hex
|
|
1087
1106
|
command = bytearray.fromhex(self._commandkey(encrypted))
|
|
1088
1107
|
_LOGGER.debug("%s: Scheduling command %s", self.name, command.hex())
|
|
1089
1108
|
max_attempts = retry + 1
|
|
@@ -1093,7 +1112,10 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1093
1112
|
)
|
|
1094
1113
|
if result is None:
|
|
1095
1114
|
return None
|
|
1096
|
-
|
|
1115
|
+
decrypted = self._decrypt(result[4:])
|
|
1116
|
+
if self._encryption_mode == AESMode.GCM:
|
|
1117
|
+
self._increment_gcm_iv()
|
|
1118
|
+
return result[:1] + decrypted
|
|
1097
1119
|
|
|
1098
1120
|
async def _ensure_encryption_initialized(self) -> bool:
|
|
1099
1121
|
"""Ensure encryption is initialized, must be called with operation lock held."""
|
|
@@ -1117,34 +1139,71 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1117
1139
|
return False
|
|
1118
1140
|
|
|
1119
1141
|
if ok := self._check_command_result(result, 0, {1}):
|
|
1120
|
-
self.
|
|
1142
|
+
_LOGGER.debug("%s: Encryption init response: %s", self.name, result.hex())
|
|
1143
|
+
mode_byte = result[2] if len(result) > 2 else None
|
|
1144
|
+
self._resolve_encryption_mode(mode_byte)
|
|
1145
|
+
if self._encryption_mode == AESMode.GCM:
|
|
1146
|
+
iv = result[4:-4]
|
|
1147
|
+
expected_iv_len = 12
|
|
1148
|
+
else:
|
|
1149
|
+
iv = result[4:]
|
|
1150
|
+
expected_iv_len = 16
|
|
1151
|
+
if len(iv) != expected_iv_len:
|
|
1152
|
+
_LOGGER.error(
|
|
1153
|
+
"%s: Invalid IV length %d for mode %s (expected %d)",
|
|
1154
|
+
self.name,
|
|
1155
|
+
len(iv),
|
|
1156
|
+
self._encryption_mode.name,
|
|
1157
|
+
expected_iv_len,
|
|
1158
|
+
)
|
|
1159
|
+
return False
|
|
1160
|
+
self._iv = iv
|
|
1121
1161
|
self._cipher = None # Reset cipher when IV changes
|
|
1122
1162
|
_LOGGER.debug("%s: Encryption initialized successfully", self.name)
|
|
1123
1163
|
|
|
1124
1164
|
return ok
|
|
1125
1165
|
|
|
1126
1166
|
async def _execute_disconnect(self) -> None:
|
|
1167
|
+
"""
|
|
1168
|
+
Reset encryption state and disconnect.
|
|
1169
|
+
|
|
1170
|
+
Clears IV, cipher, and encryption mode so they can be
|
|
1171
|
+
re-detected on the next connection (e.g., after firmware update).
|
|
1172
|
+
"""
|
|
1127
1173
|
async with self._connect_lock:
|
|
1128
1174
|
self._iv = None
|
|
1129
1175
|
self._cipher = None
|
|
1176
|
+
self._encryption_mode = None
|
|
1130
1177
|
await self._execute_disconnect_with_lock()
|
|
1131
1178
|
|
|
1132
1179
|
def _get_cipher(self) -> Cipher:
|
|
1133
1180
|
if self._cipher is None:
|
|
1134
1181
|
if self._iv is None:
|
|
1135
1182
|
raise RuntimeError("Cannot create cipher: IV is None")
|
|
1136
|
-
self.
|
|
1137
|
-
|
|
1138
|
-
|
|
1183
|
+
if self._encryption_mode == AESMode.GCM:
|
|
1184
|
+
self._cipher = Cipher(
|
|
1185
|
+
algorithms.AES128(self._encryption_key), modes.GCM(self._iv)
|
|
1186
|
+
)
|
|
1187
|
+
else:
|
|
1188
|
+
self._cipher = Cipher(
|
|
1189
|
+
algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
|
|
1190
|
+
)
|
|
1139
1191
|
return self._cipher
|
|
1140
1192
|
|
|
1141
|
-
def _encrypt(self, data: str) -> str:
|
|
1193
|
+
def _encrypt(self, data: str) -> tuple[str, str]:
|
|
1142
1194
|
if len(data) == 0:
|
|
1143
|
-
return ""
|
|
1195
|
+
return "", ""
|
|
1144
1196
|
if self._iv is None:
|
|
1145
1197
|
raise RuntimeError("Cannot encrypt: IV is None")
|
|
1146
1198
|
encryptor = self._get_cipher().encryptor()
|
|
1147
|
-
|
|
1199
|
+
ciphertext = encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()
|
|
1200
|
+
if self._encryption_mode == AESMode.GCM:
|
|
1201
|
+
header_hex = encryptor.tag[:2].hex()
|
|
1202
|
+
# GCM cipher is single-use; clear it so _get_cipher() creates a fresh one
|
|
1203
|
+
self._cipher = None
|
|
1204
|
+
else:
|
|
1205
|
+
header_hex = self._iv[0:2].hex()
|
|
1206
|
+
return ciphertext.hex(), header_hex
|
|
1148
1207
|
|
|
1149
1208
|
def _decrypt(self, data: bytearray) -> bytes:
|
|
1150
1209
|
if len(data) == 0:
|
|
@@ -1157,9 +1216,40 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1157
1216
|
)
|
|
1158
1217
|
return b""
|
|
1159
1218
|
raise RuntimeError("Cannot decrypt: IV is None")
|
|
1219
|
+
if self._encryption_mode == AESMode.GCM:
|
|
1220
|
+
# Firmware only returns a 2-byte partial tag which can't be used for
|
|
1221
|
+
# verification. Use a dummy 16-byte tag and skip finalize() since
|
|
1222
|
+
# authentication is handled by the firmware.
|
|
1223
|
+
decryptor = Cipher(
|
|
1224
|
+
algorithms.AES128(self._encryption_key),
|
|
1225
|
+
modes.GCM(self._iv, b"\x00" * 16),
|
|
1226
|
+
).decryptor()
|
|
1227
|
+
return decryptor.update(data)
|
|
1160
1228
|
decryptor = self._get_cipher().decryptor()
|
|
1161
1229
|
return decryptor.update(data) + decryptor.finalize()
|
|
1162
1230
|
|
|
1231
|
+
def _increment_gcm_iv(self) -> None:
|
|
1232
|
+
"""Increment GCM IV by 1 (big-endian). Called after each encrypted command."""
|
|
1233
|
+
if self._iv is None:
|
|
1234
|
+
raise RuntimeError("Cannot increment GCM IV: IV is None")
|
|
1235
|
+
if len(self._iv) != 12:
|
|
1236
|
+
raise RuntimeError("Cannot increment GCM IV: IV length is not 12 bytes")
|
|
1237
|
+
iv_int = int.from_bytes(self._iv, "big") + 1
|
|
1238
|
+
self._iv = iv_int.to_bytes(12, "big")
|
|
1239
|
+
self._cipher = None
|
|
1240
|
+
|
|
1241
|
+
def _resolve_encryption_mode(self, mode_byte: int | None) -> None:
|
|
1242
|
+
"""Resolve encryption mode from device response when available."""
|
|
1243
|
+
if mode_byte is None:
|
|
1244
|
+
raise ValueError("Encryption mode byte is missing")
|
|
1245
|
+
detected_mode = _normalize_encryption_mode(mode_byte)
|
|
1246
|
+
if self._encryption_mode is not None and self._encryption_mode != detected_mode:
|
|
1247
|
+
raise ValueError(
|
|
1248
|
+
f"Conflicting encryption modes detected: {self._encryption_mode.name} vs {detected_mode.name}"
|
|
1249
|
+
)
|
|
1250
|
+
self._encryption_mode = detected_mode
|
|
1251
|
+
_LOGGER.debug("%s: Detected encryption mode: %s", self.name, detected_mode.name)
|
|
1252
|
+
|
|
1163
1253
|
|
|
1164
1254
|
class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
|
|
1165
1255
|
"""
|
|
@@ -18,30 +18,45 @@ COMMAND_LOCK_INFO = {
|
|
|
18
18
|
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4f8101",
|
|
19
19
|
SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8104",
|
|
20
20
|
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8107",
|
|
21
|
+
SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4f8102",
|
|
22
|
+
SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4f8102",
|
|
23
|
+
SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4f810a",
|
|
21
24
|
}
|
|
22
25
|
COMMAND_UNLOCK = {
|
|
23
26
|
SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011080",
|
|
24
27
|
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011080",
|
|
25
28
|
SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080",
|
|
26
29
|
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
|
|
30
|
+
SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000080",
|
|
31
|
+
SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000080",
|
|
32
|
+
SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000080",
|
|
27
33
|
}
|
|
28
34
|
COMMAND_UNLOCK_WITHOUT_UNLATCH = {
|
|
29
35
|
SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e010110a0",
|
|
30
36
|
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e010110a0",
|
|
31
37
|
SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
|
|
32
38
|
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
|
|
39
|
+
SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
|
|
40
|
+
SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e01010000a0",
|
|
41
|
+
SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e01010000a0",
|
|
33
42
|
}
|
|
34
43
|
COMMAND_LOCK = {
|
|
35
44
|
SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011000",
|
|
36
45
|
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011000",
|
|
37
46
|
SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000",
|
|
38
47
|
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
|
|
48
|
+
SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000000",
|
|
49
|
+
SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000000",
|
|
50
|
+
SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000000",
|
|
39
51
|
}
|
|
40
52
|
COMMAND_ENABLE_NOTIFICATIONS = {
|
|
41
53
|
SwitchbotModel.LOCK: f"{COMMAND_HEADER}0e01001e00008101",
|
|
42
54
|
SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
|
|
43
55
|
SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0e01001e00008104",
|
|
44
56
|
SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0e01001e00008107",
|
|
57
|
+
SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0e01001e00008102",
|
|
58
|
+
SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0e01001e00008102",
|
|
59
|
+
SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0e01001e0000810a",
|
|
45
60
|
}
|
|
46
61
|
COMMAND_DISABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e00"
|
|
47
62
|
|
|
@@ -74,6 +89,9 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
|
|
|
74
89
|
SwitchbotModel.LOCK_PRO,
|
|
75
90
|
SwitchbotModel.LOCK_LITE,
|
|
76
91
|
SwitchbotModel.LOCK_ULTRA,
|
|
92
|
+
SwitchbotModel.LOCK_VISION_PRO,
|
|
93
|
+
SwitchbotModel.LOCK_VISION,
|
|
94
|
+
SwitchbotModel.LOCK_PRO_WIFI,
|
|
77
95
|
):
|
|
78
96
|
raise ValueError("initializing SwitchbotLock with a non-lock model")
|
|
79
97
|
self._notifications_enabled: bool = False
|
|
@@ -236,7 +254,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
|
|
|
236
254
|
|
|
237
255
|
@staticmethod
|
|
238
256
|
def _parse_lock_data(data: bytes, model: SwitchbotModel) -> dict[str, Any]:
|
|
239
|
-
if model
|
|
257
|
+
if model in {SwitchbotModel.LOCK, SwitchbotModel.LOCK_VISION_PRO}:
|
|
240
258
|
return {
|
|
241
259
|
"calibration": bool(data[0] & 0b10000000),
|
|
242
260
|
"status": LockStatus((data[0] & 0b01110000) >> 4),
|
|
@@ -244,7 +262,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
|
|
|
244
262
|
"unclosed_alarm": bool(data[1] & 0b00100000),
|
|
245
263
|
"unlocked_alarm": bool(data[1] & 0b00010000),
|
|
246
264
|
}
|
|
247
|
-
if model
|
|
265
|
+
if model in {SwitchbotModel.LOCK_LITE, SwitchbotModel.LOCK_VISION}:
|
|
248
266
|
return {
|
|
249
267
|
"calibration": bool(data[0] & 0b10000000),
|
|
250
268
|
"status": LockStatus((data[0] & 0b01110000) >> 4),
|
|
@@ -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__(
|
|
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
|