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.
Files changed (112) hide show
  1. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PKG-INFO +1 -1
  2. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
  3. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/SOURCES.txt +6 -0
  4. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/setup.py +1 -1
  5. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/__init__.py +4 -0
  6. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parser.py +25 -0
  7. pyswitchbot-1.0.0/switchbot/adv_parsers/keypad_vision.py +79 -0
  8. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/__init__.py +2 -0
  9. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/base_cover.py +0 -13
  10. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/base_light.py +0 -13
  11. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/device.py +125 -12
  12. pyswitchbot-1.0.0/switchbot/devices/keypad_vision.py +167 -0
  13. pyswitchbot-1.0.0/switchbot/devices/meter_pro.py +172 -0
  14. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/discovery.py +12 -1
  15. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_adv_parser.py +94 -0
  16. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_device.py +39 -2
  17. pyswitchbot-1.0.0/tests/test_discovery_callback.py +142 -0
  18. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_encrypted_device.py +174 -12
  19. pyswitchbot-1.0.0/tests/test_keypad_vision.py +259 -0
  20. pyswitchbot-1.0.0/tests/test_meter_pro.py +249 -0
  21. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/LICENSE +0 -0
  22. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/MANIFEST.in +0 -0
  23. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  24. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/requires.txt +0 -0
  25. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/PySwitchbot.egg-info/top_level.txt +0 -0
  26. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/README.md +0 -0
  27. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/pyproject.toml +0 -0
  28. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/setup.cfg +0 -0
  29. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/__init__.py +0 -0
  30. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/air_purifier.py +0 -0
  31. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/art_frame.py +0 -0
  32. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  33. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/bot.py +0 -0
  34. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/bulb.py +0 -0
  35. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  36. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/climate_panel.py +0 -0
  37. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/contact.py +0 -0
  38. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/curtain.py +0 -0
  39. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/fan.py +0 -0
  40. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hub2.py +0 -0
  41. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hub3.py +0 -0
  42. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  43. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/humidifier.py +0 -0
  44. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/keypad.py +0 -0
  45. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/leak.py +0 -0
  46. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/light_strip.py +0 -0
  47. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/lock.py +0 -0
  48. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/meter.py +0 -0
  49. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/motion.py +0 -0
  50. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/plug.py +0 -0
  51. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
  52. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  53. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/remote.py +0 -0
  54. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/roller_shade.py +0 -0
  55. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
  56. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/adv_parsers/vacuum.py +0 -0
  57. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/api_config.py +0 -0
  58. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/air_purifier.py +0 -0
  59. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/climate.py +0 -0
  60. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/evaporative_humidifier.py +0 -0
  61. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/fan.py +0 -0
  62. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/hub2.py +0 -0
  63. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/hub3.py +0 -0
  64. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/light.py +0 -0
  65. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/lock.py +0 -0
  66. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/const/presence_sensor.py +0 -0
  67. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/__init__.py +0 -0
  68. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/air_purifier.py +0 -0
  69. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/art_frame.py +0 -0
  70. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/blind_tilt.py +0 -0
  71. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/bot.py +0 -0
  72. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/bulb.py +0 -0
  73. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/ceiling_light.py +0 -0
  74. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/contact.py +0 -0
  75. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/curtain.py +0 -0
  76. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  77. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/fan.py +0 -0
  78. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/humidifier.py +0 -0
  79. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/keypad.py +0 -0
  80. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/light_strip.py +0 -0
  81. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/lock.py +0 -0
  82. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/meter.py +0 -0
  83. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/motion.py +0 -0
  84. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/plug.py +0 -0
  85. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/relay_switch.py +0 -0
  86. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/roller_shade.py +0 -0
  87. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
  88. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/devices/vacuum.py +0 -0
  89. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/enum.py +0 -0
  90. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/helpers.py +0 -0
  91. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/models.py +0 -0
  92. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/switchbot/utils.py +0 -0
  93. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_air_purifier.py +0 -0
  94. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_art_frame.py +0 -0
  95. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_base_cover.py +0 -0
  96. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_blind_tilt.py +0 -0
  97. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_bulb.py +0 -0
  98. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_ceiling_light.py +0 -0
  99. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_colormode_imports.py +0 -0
  100. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_curtain.py +0 -0
  101. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_evaporative_humidifier.py +0 -0
  102. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_fan.py +0 -0
  103. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_helpers.py +0 -0
  104. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_hub2.py +0 -0
  105. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_hub3.py +0 -0
  106. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_lock.py +0 -0
  107. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_relay_switch.py +0 -0
  108. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_roller_shade.py +0 -0
  109. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_smart_thermostat_radiator.py +0 -0
  110. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_strip_light.py +0 -0
  111. {pyswitchbot-0.75.0 → pyswitchbot-1.0.0}/tests/test_utils.py +0 -0
  112. {pyswitchbot-0.75.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.75.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.75.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
@@ -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
@@ -20,7 +20,7 @@ setup(
20
20
  "cryptography>=39.0.0",
21
21
  "pyOpenSSL>=23.0.0",
22
22
  ],
23
- version="0.75.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",
@@ -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
@@ -103,6 +103,8 @@ class SwitchbotModel(StrEnum):
103
103
  S20_VACUUM = "S20 Vacuum"
104
104
  PRESENCE_SENSOR = "Presence Sensor"
105
105
  ART_FRAME = "Art Frame"
106
+ KEYPAD_VISION = "Keypad Vision"
107
+ KEYPAD_VISION_PRO = "Keypad Vision Pro"
106
108
 
107
109
 
108
110
  __all__ = [
@@ -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: bytes | None = None
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
- encrypted = (
1059
- key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:])
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
- 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
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._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
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._cipher = Cipher(
1111
- algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
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
- 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
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