PySwitchbot 0.75.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.
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PKG-INFO +1 -1
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/SOURCES.txt +6 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/setup.py +1 -1
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/__init__.py +4 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parser.py +25 -0
- pyswitchbot-1.0.0/switchbot/adv_parsers/keypad_vision.py +79 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/__init__.py +2 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/base_cover.py +0 -13
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/base_light.py +0 -13
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/device.py +125 -12
- pyswitchbot-1.0.0/switchbot/devices/keypad_vision.py +167 -0
- pyswitchbot-1.0.0/switchbot/devices/meter_pro.py +172 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/discovery.py +12 -1
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_adv_parser.py +94 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_device.py +39 -2
- pyswitchbot-1.0.0/tests/test_discovery_callback.py +142 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_encrypted_device.py +174 -12
- pyswitchbot-1.0.0/tests/test_keypad_vision.py +259 -0
- pyswitchbot-1.0.0/tests/test_meter_pro.py +249 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/LICENSE +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/MANIFEST.in +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/requires.txt +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/top_level.txt +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/README.md +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/pyproject.toml +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/setup.cfg +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/__init__.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/air_purifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/art_frame.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/bot.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/bulb.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/climate_panel.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/contact.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/curtain.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/fan.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hub2.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hub3.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/humidifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/keypad.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/leak.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/light_strip.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/lock.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/meter.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/motion.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/plug.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/relay_switch.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/remote.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/roller_shade.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/vacuum.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/api_config.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/air_purifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/climate.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/evaporative_humidifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/fan.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/hub2.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/hub3.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/light.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/lock.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/presence_sensor.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/__init__.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/air_purifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/art_frame.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/blind_tilt.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/bot.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/bulb.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/ceiling_light.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/contact.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/curtain.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/evaporative_humidifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/fan.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/humidifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/keypad.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/light_strip.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/lock.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/meter.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/motion.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/plug.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/relay_switch.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/roller_shade.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/vacuum.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/enum.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/helpers.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/models.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/utils.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_air_purifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_art_frame.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_base_cover.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_blind_tilt.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_bulb.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_ceiling_light.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_colormode_imports.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_curtain.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_evaporative_humidifier.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_fan.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_helpers.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_hub2.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_hub3.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_lock.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_relay_switch.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_roller_shade.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_smart_thermostat_radiator.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_strip_light.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_utils.py +0 -0
- {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_vacuum.py +0 -0
|
@@ -32,6 +32,7 @@ switchbot/adv_parsers/hub3.py
|
|
|
32
32
|
switchbot/adv_parsers/hubmini_matter.py
|
|
33
33
|
switchbot/adv_parsers/humidifier.py
|
|
34
34
|
switchbot/adv_parsers/keypad.py
|
|
35
|
+
switchbot/adv_parsers/keypad_vision.py
|
|
35
36
|
switchbot/adv_parsers/leak.py
|
|
36
37
|
switchbot/adv_parsers/light_strip.py
|
|
37
38
|
switchbot/adv_parsers/lock.py
|
|
@@ -70,9 +71,11 @@ switchbot/devices/evaporative_humidifier.py
|
|
|
70
71
|
switchbot/devices/fan.py
|
|
71
72
|
switchbot/devices/humidifier.py
|
|
72
73
|
switchbot/devices/keypad.py
|
|
74
|
+
switchbot/devices/keypad_vision.py
|
|
73
75
|
switchbot/devices/light_strip.py
|
|
74
76
|
switchbot/devices/lock.py
|
|
75
77
|
switchbot/devices/meter.py
|
|
78
|
+
switchbot/devices/meter_pro.py
|
|
76
79
|
switchbot/devices/motion.py
|
|
77
80
|
switchbot/devices/plug.py
|
|
78
81
|
switchbot/devices/relay_switch.py
|
|
@@ -89,13 +92,16 @@ tests/test_ceiling_light.py
|
|
|
89
92
|
tests/test_colormode_imports.py
|
|
90
93
|
tests/test_curtain.py
|
|
91
94
|
tests/test_device.py
|
|
95
|
+
tests/test_discovery_callback.py
|
|
92
96
|
tests/test_encrypted_device.py
|
|
93
97
|
tests/test_evaporative_humidifier.py
|
|
94
98
|
tests/test_fan.py
|
|
95
99
|
tests/test_helpers.py
|
|
96
100
|
tests/test_hub2.py
|
|
97
101
|
tests/test_hub3.py
|
|
102
|
+
tests/test_keypad_vision.py
|
|
98
103
|
tests/test_lock.py
|
|
104
|
+
tests/test_meter_pro.py
|
|
99
105
|
tests/test_relay_switch.py
|
|
100
106
|
tests/test_roller_shade.py
|
|
101
107
|
tests/test_smart_thermostat_radiator.py
|
|
@@ -45,12 +45,14 @@ from .devices.device import (
|
|
|
45
45
|
from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
|
|
46
46
|
from .devices.fan import SwitchbotFan
|
|
47
47
|
from .devices.humidifier import SwitchbotHumidifier
|
|
48
|
+
from .devices.keypad_vision import SwitchbotKeypadVision
|
|
48
49
|
from .devices.light_strip import (
|
|
49
50
|
SwitchbotLightStrip,
|
|
50
51
|
SwitchbotRgbicLight,
|
|
51
52
|
SwitchbotStripLight3,
|
|
52
53
|
)
|
|
53
54
|
from .devices.lock import SwitchbotLock
|
|
55
|
+
from .devices.meter_pro import SwitchbotMeterProCO2
|
|
54
56
|
from .devices.plug import SwitchbotPlugMini
|
|
55
57
|
from .devices.relay_switch import (
|
|
56
58
|
SwitchbotGarageDoorOpener,
|
|
@@ -97,8 +99,10 @@ __all__ = [
|
|
|
97
99
|
"SwitchbotFan",
|
|
98
100
|
"SwitchbotGarageDoorOpener",
|
|
99
101
|
"SwitchbotHumidifier",
|
|
102
|
+
"SwitchbotKeypadVision",
|
|
100
103
|
"SwitchbotLightStrip",
|
|
101
104
|
"SwitchbotLock",
|
|
105
|
+
"SwitchbotMeterProCO2",
|
|
102
106
|
"SwitchbotModel",
|
|
103
107
|
"SwitchbotModel",
|
|
104
108
|
"SwitchbotOperationError",
|
|
@@ -26,6 +26,7 @@ from .adv_parsers.hub3 import process_hub3
|
|
|
26
26
|
from .adv_parsers.hubmini_matter import process_hubmini_matter
|
|
27
27
|
from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
|
|
28
28
|
from .adv_parsers.keypad import process_wokeypad
|
|
29
|
+
from .adv_parsers.keypad_vision import process_keypad_vision, process_keypad_vision_pro
|
|
29
30
|
from .adv_parsers.leak import process_leak
|
|
30
31
|
from .adv_parsers.light_strip import process_light, process_rgbic_light, process_wostrip
|
|
31
32
|
from .adv_parsers.lock import (
|
|
@@ -730,6 +731,30 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
|
|
|
730
731
|
"func": process_art_frame,
|
|
731
732
|
"manufacturer_id": 2409,
|
|
732
733
|
},
|
|
734
|
+
b"\x00\x11\x03x": {
|
|
735
|
+
"modelName": SwitchbotModel.KEYPAD_VISION,
|
|
736
|
+
"modelFriendlyName": "Keypad Vision",
|
|
737
|
+
"func": process_keypad_vision,
|
|
738
|
+
"manufacturer_id": 2409,
|
|
739
|
+
},
|
|
740
|
+
b"\x01\x11\x03x": {
|
|
741
|
+
"modelName": SwitchbotModel.KEYPAD_VISION,
|
|
742
|
+
"modelFriendlyName": "Keypad Vision",
|
|
743
|
+
"func": process_keypad_vision,
|
|
744
|
+
"manufacturer_id": 2409,
|
|
745
|
+
},
|
|
746
|
+
b"\x00\x11Q\x98": {
|
|
747
|
+
"modelName": SwitchbotModel.KEYPAD_VISION_PRO,
|
|
748
|
+
"modelFriendlyName": "Keypad Vision Pro",
|
|
749
|
+
"func": process_keypad_vision_pro,
|
|
750
|
+
"manufacturer_id": 2409,
|
|
751
|
+
},
|
|
752
|
+
b"\x01\x11Q\x98": {
|
|
753
|
+
"modelName": SwitchbotModel.KEYPAD_VISION_PRO,
|
|
754
|
+
"modelFriendlyName": "Keypad Vision Pro",
|
|
755
|
+
"func": process_keypad_vision_pro,
|
|
756
|
+
"manufacturer_id": 2409,
|
|
757
|
+
},
|
|
733
758
|
}
|
|
734
759
|
|
|
735
760
|
_SWITCHBOT_MODEL_TO_CHAR: defaultdict[SwitchbotModel, list[str | bytes]] = defaultdict(
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Keypad Vision (Pro) device data parsers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
_LOGGER = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def process_common_mfr_data(mfr_data: bytes | None) -> dict[str, bool | int]:
|
|
9
|
+
"""Process common Keypad Vision (Pro) manufacturer data."""
|
|
10
|
+
if mfr_data is None:
|
|
11
|
+
return {}
|
|
12
|
+
|
|
13
|
+
sequence_number = mfr_data[6]
|
|
14
|
+
battery_charging = bool(mfr_data[7] & 0b10000000)
|
|
15
|
+
battery = mfr_data[7] & 0b01111111
|
|
16
|
+
lockout_alarm = bool(mfr_data[8] & 0b00000001)
|
|
17
|
+
tamper_alarm = bool(mfr_data[8] & 0b00000010)
|
|
18
|
+
duress_alarm = bool(mfr_data[8] & 0b00000100)
|
|
19
|
+
low_temperature = bool(mfr_data[8] & 0b10000000)
|
|
20
|
+
high_temperature = bool(mfr_data[8] & 0b01000000)
|
|
21
|
+
doorbell = bool(mfr_data[12] & 0b00001000)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"sequence_number": sequence_number,
|
|
25
|
+
"battery_charging": battery_charging,
|
|
26
|
+
"battery": battery,
|
|
27
|
+
"lockout_alarm": lockout_alarm,
|
|
28
|
+
"tamper_alarm": tamper_alarm,
|
|
29
|
+
"duress_alarm": duress_alarm,
|
|
30
|
+
"low_temperature": low_temperature,
|
|
31
|
+
"high_temperature": high_temperature,
|
|
32
|
+
"doorbell": doorbell,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def process_keypad_vision(
|
|
37
|
+
data: bytes | None, mfr_data: bytes | None
|
|
38
|
+
) -> dict[str, bool | int | str]:
|
|
39
|
+
"""Process Keypad Vision data."""
|
|
40
|
+
result = process_common_mfr_data(mfr_data)
|
|
41
|
+
|
|
42
|
+
if not result:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
pir_triggered_level = mfr_data[13] & 0x03
|
|
46
|
+
|
|
47
|
+
result.update(
|
|
48
|
+
{
|
|
49
|
+
"pir_triggered_level": pir_triggered_level,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_LOGGER.debug("Keypad Vision mfr data: %s, result: %s", mfr_data.hex(), result)
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def process_keypad_vision_pro(
|
|
59
|
+
data: bytes | None, mfr_data: bytes | None
|
|
60
|
+
) -> dict[str, bool | int | str]:
|
|
61
|
+
"""Process Keypad Vision Pro data."""
|
|
62
|
+
result = process_common_mfr_data(mfr_data)
|
|
63
|
+
|
|
64
|
+
if not result:
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
radar_triggered_level = mfr_data[13] & 0x03
|
|
68
|
+
radar_triggered_distance = (mfr_data[13] >> 2) & 0x03
|
|
69
|
+
|
|
70
|
+
result.update(
|
|
71
|
+
{
|
|
72
|
+
"radar_triggered_level": radar_triggered_level,
|
|
73
|
+
"radar_triggered_distance": radar_triggered_distance,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_LOGGER.debug("Keypad Vision Pro mfr data: %s, result: %s", mfr_data.hex(), result)
|
|
78
|
+
|
|
79
|
+
return result
|
|
@@ -43,19 +43,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
|
|
|
43
43
|
self._is_opening: bool = False
|
|
44
44
|
self._is_closing: bool = False
|
|
45
45
|
|
|
46
|
-
async def _send_multiple_commands(self, keys: list[str]) -> bool:
|
|
47
|
-
"""
|
|
48
|
-
Send multiple commands to device.
|
|
49
|
-
|
|
50
|
-
Since we current have no way to tell which command the device
|
|
51
|
-
needs we send both.
|
|
52
|
-
"""
|
|
53
|
-
final_result = False
|
|
54
|
-
for key in keys:
|
|
55
|
-
result = await self._send_command(key)
|
|
56
|
-
final_result |= self._check_command_result(result, 0, {1})
|
|
57
|
-
return final_result
|
|
58
|
-
|
|
59
46
|
@update_after_operation
|
|
60
47
|
async def stop(self) -> bool:
|
|
61
48
|
"""Send stop command to device."""
|
|
@@ -119,19 +119,6 @@ class SwitchbotBaseLight(SwitchbotDevice):
|
|
|
119
119
|
self._override_state({"effect": effect})
|
|
120
120
|
return result
|
|
121
121
|
|
|
122
|
-
async def _send_multiple_commands(self, keys: list[str]) -> bool:
|
|
123
|
-
"""
|
|
124
|
-
Send multiple commands to device.
|
|
125
|
-
|
|
126
|
-
Since we current have no way to tell which command the device
|
|
127
|
-
needs we send both.
|
|
128
|
-
"""
|
|
129
|
-
final_result = False
|
|
130
|
-
for key in keys:
|
|
131
|
-
result = await self._send_command(key)
|
|
132
|
-
final_result |= self._check_command_result(result, 0, {1})
|
|
133
|
-
return final_result
|
|
134
|
-
|
|
135
122
|
async def _get_multi_commands_results(
|
|
136
123
|
self, commands: list[str]
|
|
137
124
|
) -> tuple[bytes, bytes] | None:
|
|
@@ -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"}
|
|
@@ -931,6 +947,32 @@ class SwitchbotDevice(SwitchbotBaseDevice):
|
|
|
931
947
|
super().update_from_advertisement(advertisement)
|
|
932
948
|
self._set_advertisement_data(advertisement)
|
|
933
949
|
|
|
950
|
+
async def _send_multiple_commands(self, keys: list[str]) -> bool:
|
|
951
|
+
"""
|
|
952
|
+
Send multiple commands to device.
|
|
953
|
+
|
|
954
|
+
Returns True if any command succeeds. Used when we don't know
|
|
955
|
+
which command the device needs, so we send multiple and consider
|
|
956
|
+
it successful if any one works.
|
|
957
|
+
"""
|
|
958
|
+
final_result = False
|
|
959
|
+
for key in keys:
|
|
960
|
+
result = await self._send_command(key)
|
|
961
|
+
final_result |= self._check_command_result(result, 0, {1})
|
|
962
|
+
return final_result
|
|
963
|
+
|
|
964
|
+
async def _send_command_sequence(self, keys: list[str]) -> bool:
|
|
965
|
+
"""
|
|
966
|
+
Send a sequence of commands to device where all must succeed.
|
|
967
|
+
|
|
968
|
+
Returns True only if all commands succeed.
|
|
969
|
+
"""
|
|
970
|
+
for key in keys:
|
|
971
|
+
result = await self._send_command(key)
|
|
972
|
+
if not self._check_command_result(result, 0, {1}):
|
|
973
|
+
return False
|
|
974
|
+
return True
|
|
975
|
+
|
|
934
976
|
|
|
935
977
|
class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
936
978
|
"""A Switchbot device that uses encryption."""
|
|
@@ -956,7 +998,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
956
998
|
self._key_id = key_id
|
|
957
999
|
self._encryption_key = bytearray.fromhex(encryption_key)
|
|
958
1000
|
self._iv: bytes | None = None
|
|
959
|
-
self._cipher:
|
|
1001
|
+
self._cipher: Cipher | None = None
|
|
1002
|
+
self._encryption_mode: AESMode | None = None
|
|
960
1003
|
super().__init__(device, None, interface, **kwargs)
|
|
961
1004
|
self._model = model
|
|
962
1005
|
|
|
@@ -1055,9 +1098,8 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1055
1098
|
_LOGGER.error("Failed to initialize encryption")
|
|
1056
1099
|
return None
|
|
1057
1100
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
)
|
|
1101
|
+
ciphertext_hex, header_hex = self._encrypt(key[2:])
|
|
1102
|
+
encrypted = key[:2] + self._key_id + header_hex + ciphertext_hex
|
|
1061
1103
|
command = bytearray.fromhex(self._commandkey(encrypted))
|
|
1062
1104
|
_LOGGER.debug("%s: Scheduling command %s", self.name, command.hex())
|
|
1063
1105
|
max_attempts = retry + 1
|
|
@@ -1067,7 +1109,10 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1067
1109
|
)
|
|
1068
1110
|
if result is None:
|
|
1069
1111
|
return None
|
|
1070
|
-
|
|
1112
|
+
decrypted = self._decrypt(result[4:])
|
|
1113
|
+
if self._encryption_mode == AESMode.GCM:
|
|
1114
|
+
self._increment_gcm_iv()
|
|
1115
|
+
return result[:1] + decrypted
|
|
1071
1116
|
|
|
1072
1117
|
async def _ensure_encryption_initialized(self) -> bool:
|
|
1073
1118
|
"""Ensure encryption is initialized, must be called with operation lock held."""
|
|
@@ -1091,34 +1136,71 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1091
1136
|
return False
|
|
1092
1137
|
|
|
1093
1138
|
if ok := self._check_command_result(result, 0, {1}):
|
|
1094
|
-
self.
|
|
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
|
|
1095
1158
|
self._cipher = None # Reset cipher when IV changes
|
|
1096
1159
|
_LOGGER.debug("%s: Encryption initialized successfully", self.name)
|
|
1097
1160
|
|
|
1098
1161
|
return ok
|
|
1099
1162
|
|
|
1100
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
|
+
"""
|
|
1101
1170
|
async with self._connect_lock:
|
|
1102
1171
|
self._iv = None
|
|
1103
1172
|
self._cipher = None
|
|
1173
|
+
self._encryption_mode = None
|
|
1104
1174
|
await self._execute_disconnect_with_lock()
|
|
1105
1175
|
|
|
1106
1176
|
def _get_cipher(self) -> Cipher:
|
|
1107
1177
|
if self._cipher is None:
|
|
1108
1178
|
if self._iv is None:
|
|
1109
1179
|
raise RuntimeError("Cannot create cipher: IV is None")
|
|
1110
|
-
self.
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
+
)
|
|
1113
1188
|
return self._cipher
|
|
1114
1189
|
|
|
1115
|
-
def _encrypt(self, data: str) -> str:
|
|
1190
|
+
def _encrypt(self, data: str) -> tuple[str, str]:
|
|
1116
1191
|
if len(data) == 0:
|
|
1117
|
-
return ""
|
|
1192
|
+
return "", ""
|
|
1118
1193
|
if self._iv is None:
|
|
1119
1194
|
raise RuntimeError("Cannot encrypt: IV is None")
|
|
1120
1195
|
encryptor = self._get_cipher().encryptor()
|
|
1121
|
-
|
|
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
|
|
1122
1204
|
|
|
1123
1205
|
def _decrypt(self, data: bytearray) -> bytes:
|
|
1124
1206
|
if len(data) == 0:
|
|
@@ -1131,9 +1213,40 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1131
1213
|
)
|
|
1132
1214
|
return b""
|
|
1133
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)
|
|
1134
1225
|
decryptor = self._get_cipher().decryptor()
|
|
1135
1226
|
return decryptor.update(data) + decryptor.finalize()
|
|
1136
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
|
+
|
|
1137
1250
|
|
|
1138
1251
|
class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
|
|
1139
1252
|
"""
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Keypad Vision (Pro) device handling."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bleak.backends.device import BLEDevice
|
|
8
|
+
|
|
9
|
+
from ..const import SwitchbotModel
|
|
10
|
+
from .device import SwitchbotEncryptedDevice, SwitchbotSequenceDevice
|
|
11
|
+
|
|
12
|
+
PASSWORD_RE = re.compile(r"^\d{6,12}$")
|
|
13
|
+
COMMAND_GET_PASSWORD_COUNT = "570F530100"
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SwitchbotKeypadVision(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
|
|
19
|
+
"""Representation of a Switchbot Keypad Vision (Pro) device."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
device: BLEDevice,
|
|
24
|
+
key_id: str,
|
|
25
|
+
encryption_key: str,
|
|
26
|
+
model: SwitchbotModel,
|
|
27
|
+
**kwargs: Any,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Initialize Keypad Vision (Pro) device."""
|
|
30
|
+
super().__init__(device, key_id, encryption_key, model, **kwargs)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
async def verify_encryption_key(
|
|
34
|
+
cls,
|
|
35
|
+
device: BLEDevice,
|
|
36
|
+
key_id: str,
|
|
37
|
+
encryption_key: str,
|
|
38
|
+
model: SwitchbotModel,
|
|
39
|
+
**kwargs: Any,
|
|
40
|
+
) -> bool:
|
|
41
|
+
return await super().verify_encryption_key(
|
|
42
|
+
device, key_id, encryption_key, model, **kwargs
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def get_basic_info(self) -> dict[str, Any] | None:
|
|
46
|
+
"""Get device basic settings."""
|
|
47
|
+
if not (_data := await self._get_basic_info()):
|
|
48
|
+
return None
|
|
49
|
+
_LOGGER.debug("Raw model %s basic info data: %s", self._model, _data.hex())
|
|
50
|
+
|
|
51
|
+
battery = _data[1] & 0x7F
|
|
52
|
+
firmware = _data[2] / 10.0
|
|
53
|
+
hardware = _data[3]
|
|
54
|
+
support_fingerprint = _data[4]
|
|
55
|
+
lock_button_enabled = bool(_data[5] != 1)
|
|
56
|
+
tamper_alarm_enabled = bool(_data[9])
|
|
57
|
+
backlight_enabled = bool(_data[10] != 1)
|
|
58
|
+
backlight_level = _data[11]
|
|
59
|
+
prompt_tone_enabled = bool(_data[12] != 1)
|
|
60
|
+
|
|
61
|
+
if self._model == SwitchbotModel.KEYPAD_VISION:
|
|
62
|
+
battery_charging = bool((_data[14] & 0x06) >> 1)
|
|
63
|
+
else:
|
|
64
|
+
battery_charging = bool((_data[14] & 0x0E) >> 1)
|
|
65
|
+
|
|
66
|
+
result = {
|
|
67
|
+
"battery": battery,
|
|
68
|
+
"firmware": firmware,
|
|
69
|
+
"hardware": hardware,
|
|
70
|
+
"support_fingerprint": support_fingerprint,
|
|
71
|
+
"lock_button_enabled": lock_button_enabled,
|
|
72
|
+
"tamper_alarm_enabled": tamper_alarm_enabled,
|
|
73
|
+
"backlight_enabled": backlight_enabled,
|
|
74
|
+
"backlight_level": backlight_level,
|
|
75
|
+
"prompt_tone_enabled": prompt_tone_enabled,
|
|
76
|
+
"battery_charging": battery_charging,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_LOGGER.debug("%s basic info: %s", self._model, result)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def _check_password_rules(self, password: str) -> None:
|
|
83
|
+
"""Check if the password compliant with the rules."""
|
|
84
|
+
if not PASSWORD_RE.fullmatch(password):
|
|
85
|
+
raise ValueError("Password must be 6-12 digits.")
|
|
86
|
+
|
|
87
|
+
def _build_password_payload(self, password: str) -> bytes:
|
|
88
|
+
"""Build password payload."""
|
|
89
|
+
pwd_bytes = bytes(int(ch) for ch in password)
|
|
90
|
+
pwd_length = len(pwd_bytes)
|
|
91
|
+
|
|
92
|
+
payload = bytearray()
|
|
93
|
+
payload.append(0xFF)
|
|
94
|
+
payload.append(0x00)
|
|
95
|
+
payload.append(pwd_length)
|
|
96
|
+
payload.extend(pwd_bytes)
|
|
97
|
+
|
|
98
|
+
return bytes(payload)
|
|
99
|
+
|
|
100
|
+
def _build_add_password_cmd(self, password: str) -> list[str]:
|
|
101
|
+
"""Build command to add a password."""
|
|
102
|
+
cmd_header = bytes.fromhex("570F520202")
|
|
103
|
+
|
|
104
|
+
payload = self._build_password_payload(password)
|
|
105
|
+
|
|
106
|
+
max_payload = 11
|
|
107
|
+
|
|
108
|
+
chunks = [
|
|
109
|
+
payload[i : i + max_payload] for i in range(0, len(payload), max_payload)
|
|
110
|
+
]
|
|
111
|
+
total = len(chunks)
|
|
112
|
+
cmds: list[str] = []
|
|
113
|
+
|
|
114
|
+
for idx, chunk in enumerate(chunks):
|
|
115
|
+
packet_info = ((total & 0x0F) << 4) | (idx & 0x0F)
|
|
116
|
+
|
|
117
|
+
cmd = bytearray()
|
|
118
|
+
cmd.extend(cmd_header)
|
|
119
|
+
cmd.append(packet_info)
|
|
120
|
+
cmd.extend(chunk)
|
|
121
|
+
|
|
122
|
+
cmds.append(cmd.hex().upper())
|
|
123
|
+
|
|
124
|
+
_LOGGER.debug(
|
|
125
|
+
"device: %s add password commands: %s", self._device.address, cmds
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return cmds
|
|
129
|
+
|
|
130
|
+
async def add_password(self, password: str) -> bool:
|
|
131
|
+
"""Add a password to the Keypad Vision (Pro)."""
|
|
132
|
+
self._check_password_rules(password)
|
|
133
|
+
cmds = self._build_add_password_cmd(password)
|
|
134
|
+
return await self._send_command_sequence(cmds)
|
|
135
|
+
|
|
136
|
+
async def get_password_count(self) -> dict[str, int] | None:
|
|
137
|
+
"""Get the number of passwords stored in the Keypad Vision (Pro)."""
|
|
138
|
+
if not (_data := await self._send_command(COMMAND_GET_PASSWORD_COUNT)):
|
|
139
|
+
return None
|
|
140
|
+
_LOGGER.debug("Raw model %s password count data: %s", self._model, _data.hex())
|
|
141
|
+
|
|
142
|
+
pin = _data[1]
|
|
143
|
+
nfc = _data[2]
|
|
144
|
+
fingerprint = _data[3]
|
|
145
|
+
duress_pin = _data[4]
|
|
146
|
+
duress_fingerprint = _data[5]
|
|
147
|
+
|
|
148
|
+
result = {
|
|
149
|
+
"pin": pin,
|
|
150
|
+
"nfc": nfc,
|
|
151
|
+
"fingerprint": fingerprint,
|
|
152
|
+
"duress_pin": duress_pin,
|
|
153
|
+
"duress_fingerprint": duress_fingerprint,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if self._model == SwitchbotModel.KEYPAD_VISION_PRO:
|
|
157
|
+
face = _data[6]
|
|
158
|
+
palm_vein = _data[7]
|
|
159
|
+
result.update(
|
|
160
|
+
{
|
|
161
|
+
"face": face,
|
|
162
|
+
"palm_vein": palm_vein,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
_LOGGER.debug("%s password count: %s", self._model, result)
|
|
167
|
+
return result
|