PySwitchbot 2.5.0__tar.gz → 2.7.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 (83) hide show
  1. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/PKG-INFO +1 -1
  2. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/pyproject.toml +1 -1
  3. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/__init__.py +8 -1
  4. pyswitchbot-2.7.0/switchbot/__version__.py +1 -0
  5. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parser.py +29 -1
  6. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/fan.py +49 -1
  7. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/__init__.py +4 -0
  8. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/fan.py +17 -0
  9. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/device.py +1 -0
  10. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/fan.py +119 -0
  11. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/lock.py +11 -0
  12. pyswitchbot-2.5.0/switchbot/__version__.py +0 -1
  13. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/LICENSE +0 -0
  14. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/README.md +0 -0
  15. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/__init__.py +0 -0
  16. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/_sensor_th.py +0 -0
  17. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/air_purifier.py +0 -0
  18. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/art_frame.py +0 -0
  19. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  20. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/bot.py +0 -0
  21. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/bulb.py +0 -0
  22. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  23. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/climate_panel.py +0 -0
  24. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/contact.py +0 -0
  25. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/curtain.py +0 -0
  26. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/hub2.py +0 -0
  27. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/hub3.py +0 -0
  28. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  29. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/humidifier.py +0 -0
  30. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/keypad.py +0 -0
  31. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/keypad_vision.py +0 -0
  32. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/leak.py +0 -0
  33. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/light_strip.py +0 -0
  34. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/lock.py +0 -0
  35. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/meter.py +0 -0
  36. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/motion.py +0 -0
  37. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/plug.py +0 -0
  38. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
  39. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  40. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/remote.py +0 -0
  41. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/roller_shade.py +0 -0
  42. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
  43. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/vacuum.py +0 -0
  44. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/adv_parsers/weather_station.py +0 -0
  45. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/api_config.py +0 -0
  46. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/air_purifier.py +0 -0
  47. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/climate.py +0 -0
  48. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/evaporative_humidifier.py +0 -0
  49. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/hub2.py +0 -0
  50. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/hub3.py +0 -0
  51. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/light.py +0 -0
  52. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/lock.py +0 -0
  53. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/const/presence_sensor.py +0 -0
  54. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/__init__.py +0 -0
  55. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/air_purifier.py +0 -0
  56. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/art_frame.py +0 -0
  57. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/base_cover.py +0 -0
  58. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/base_light.py +0 -0
  59. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/blind_tilt.py +0 -0
  60. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/bot.py +0 -0
  61. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/bulb.py +0 -0
  62. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/ceiling_light.py +0 -0
  63. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/contact.py +0 -0
  64. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/curtain.py +0 -0
  65. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  66. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/humidifier.py +0 -0
  67. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/keypad.py +0 -0
  68. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/keypad_vision.py +0 -0
  69. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/light_strip.py +0 -0
  70. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/meter.py +0 -0
  71. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/meter_pro.py +0 -0
  72. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/motion.py +0 -0
  73. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/plug.py +0 -0
  74. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/relay_switch.py +0 -0
  75. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/roller_shade.py +0 -0
  76. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
  77. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/universal_remote.py +0 -0
  78. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/devices/vacuum.py +0 -0
  79. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/discovery.py +0 -0
  80. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/enum.py +0 -0
  81. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/helpers.py +0 -0
  82. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/models.py +0 -0
  83. {pyswitchbot-2.5.0 → pyswitchbot-2.7.0}/switchbot/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: A library to communicate with Switchbot
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "PySwitchbot"
3
- version = "2.5.0"
3
+ version = "2.7.0"
4
4
  description = "A library to communicate with Switchbot"
5
5
  authors = ["Daniel Hjelseth Hoyer"]
6
6
  license = "MIT"
@@ -14,6 +14,7 @@ from .const import (
14
14
  AirQualityLevel,
15
15
  BulbColorMode,
16
16
  CeilingLightColorMode,
17
+ CirculatorFanProMode,
17
18
  ClimateAction,
18
19
  ClimateMode,
19
20
  ColorMode,
@@ -48,7 +49,11 @@ from .devices.device import (
48
49
  fetch_cloud_devices,
49
50
  )
50
51
  from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
51
- from .devices.fan import SwitchbotFan, SwitchbotStandingFan
52
+ from .devices.fan import (
53
+ SwitchbotCirculatorFanPro,
54
+ SwitchbotFan,
55
+ SwitchbotStandingFan,
56
+ )
52
57
  from .devices.humidifier import SwitchbotHumidifier
53
58
  from .devices.keypad_vision import SwitchbotKeypadVision
54
59
  from .devices.light_strip import (
@@ -80,6 +85,7 @@ __all__ = [
80
85
  "AirQualityLevel",
81
86
  "BulbColorMode",
82
87
  "CeilingLightColorMode",
88
+ "CirculatorFanProMode",
83
89
  "ClimateAction",
84
90
  "ClimateMode",
85
91
  "ColorMode",
@@ -106,6 +112,7 @@ __all__ = [
106
112
  "SwitchbotBulb",
107
113
  "SwitchbotCandleWarmerLamp",
108
114
  "SwitchbotCeilingLight",
115
+ "SwitchbotCirculatorFanPro",
109
116
  "SwitchbotCurtain",
110
117
  "SwitchbotDevice",
111
118
  "SwitchbotEncryptedDevice",
@@ -0,0 +1 @@
1
+ __version__ = "2.7.0"
@@ -20,7 +20,11 @@ from .adv_parsers.ceiling_light import process_woceiling
20
20
  from .adv_parsers.climate_panel import process_climate_panel
21
21
  from .adv_parsers.contact import process_wocontact
22
22
  from .adv_parsers.curtain import process_wocurtain
23
- from .adv_parsers.fan import process_fan, process_standing_fan
23
+ from .adv_parsers.fan import (
24
+ process_circulator_fan_pro,
25
+ process_fan,
26
+ process_standing_fan,
27
+ )
24
28
  from .adv_parsers.hub2 import process_wohub2
25
29
  from .adv_parsers.hub3 import process_hub3
26
30
  from .adv_parsers.hubmini_matter import process_hubmini_matter
@@ -714,6 +718,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
714
718
  "func": process_rgbicww_ceiling_light,
715
719
  "manufacturer_id": 2409,
716
720
  },
721
+ b"\x00\x11\xb3@": {
722
+ "modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
723
+ "modelFriendlyName": "Circulator Fan Pro",
724
+ "func": process_circulator_fan_pro,
725
+ "manufacturer_id": 2409,
726
+ },
727
+ b"\x01\x11\xb3@": {
728
+ "modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
729
+ "modelFriendlyName": "Circulator Fan Pro",
730
+ "func": process_circulator_fan_pro,
731
+ "manufacturer_id": 2409,
732
+ },
717
733
  b"\x00\x10\xd0\xb7": {
718
734
  "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
719
735
  "modelFriendlyName": "Permanent Outdoor Light",
@@ -882,6 +898,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
882
898
  "func": process_wolock_pro,
883
899
  "manufacturer_id": 2409,
884
900
  },
901
+ b"\x00\x11\x9f\xb8": {
902
+ "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
903
+ "modelFriendlyName": "Lock Ultra Max",
904
+ "func": process_lock2,
905
+ "manufacturer_id": 2409,
906
+ },
907
+ b"\x01\x11\x9f\xb8": {
908
+ "modelName": SwitchbotModel.LOCK_ULTRA_MAX,
909
+ "modelFriendlyName": "Lock Ultra Max",
910
+ "func": process_lock2,
911
+ "manufacturer_id": 2409,
912
+ },
885
913
  b"\x00\x11\x07\x60": {
886
914
  "modelName": SwitchbotModel.STANDING_FAN,
887
915
  "modelFriendlyName": "Standing Fan",
@@ -2,12 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from ..const.fan import FanMode, StandingFanMode
5
+ from ..const.fan import CirculatorFanProMode, FanMode, StandingFanMode
6
6
 
7
7
  _FAN_MODE_MAP: dict[int, str] = {m.value: m.name.lower() for m in FanMode}
8
8
  _STANDING_FAN_MODE_MAP: dict[int, str] = {
9
9
  m.value: m.name.lower() for m in StandingFanMode
10
10
  }
11
+ _CIRCULATOR_FAN_PRO_MODE_MAP: dict[int, str] = {
12
+ m.value: m.name.lower() for m in CirculatorFanProMode
13
+ }
11
14
 
12
15
 
13
16
  def _parse_fan(
@@ -60,3 +63,48 @@ def process_standing_fan(
60
63
  ) -> dict[str, bool | int | str | None]:
61
64
  """Process Standing Fan services data (modes 1-5; adds CUSTOM_NATURAL)."""
62
65
  return _parse_fan(mfr_data, _STANDING_FAN_MODE_MAP, with_charging=True)
66
+
67
+
68
+ def process_circulator_fan_pro(
69
+ data: bytes | None, mfr_data: bytes | None
70
+ ) -> dict[str, bool | int | str | None]:
71
+ """
72
+ Process Circulator Fan Pro (W1160) advertisement.
73
+
74
+ The Pro shares the W1071 Modern Ceiling Fan broadcast layout, which differs
75
+ from the legacy Circulator Fan: battery and the fan-state byte are swapped.
76
+ The fan-state byte carries a two-level night light (bit2 = on/off, bit3 =
77
+ level: 0 high / 1 low). Byte offsets are relative to the manufacturer data,
78
+ after the leading 6-byte MAC.
79
+ """
80
+ if mfr_data is None or len(mfr_data) < 10:
81
+ return {}
82
+
83
+ device_data = mfr_data[6:]
84
+
85
+ _seq_num = device_data[0]
86
+ _charging = bool(device_data[1] & 0b10000000)
87
+ _battery = device_data[1] & 0b01111111
88
+ _state = device_data[2]
89
+ _isOn = bool(_state & 0b10000000)
90
+ _mode = (_state & 0b01110000) >> 4
91
+ _night_light_on = bool(_state & 0b00000100)
92
+ # bit3: 0 = level 1 (high / bright), 1 = level 2 (low / dim)
93
+ _night_light_level = 2 if _state & 0b00001000 else 1
94
+ _oscillate_left_and_right = bool(_state & 0b00000010)
95
+ _oscillate_up_and_down = bool(_state & 0b00000001)
96
+ _speed = device_data[3] & 0b01111111
97
+
98
+ return {
99
+ "sequence_number": _seq_num,
100
+ "isOn": _isOn,
101
+ "mode": _CIRCULATOR_FAN_PRO_MODE_MAP.get(_mode),
102
+ "night_light_is_on": _night_light_on,
103
+ "night_light_level": _night_light_level if _night_light_on else 0,
104
+ "oscillating": _oscillate_left_and_right or _oscillate_up_and_down,
105
+ "oscillating_horizontal": _oscillate_left_and_right,
106
+ "oscillating_vertical": _oscillate_up_and_down,
107
+ "battery": _battery,
108
+ "charging": _charging,
109
+ "speed": _speed,
110
+ }
@@ -11,6 +11,7 @@ from .evaporative_humidifier import (
11
11
  HumidifierWaterLevel,
12
12
  )
13
13
  from .fan import (
14
+ CirculatorFanProMode,
14
15
  FanMode,
15
16
  HorizontalOscillationAngle,
16
17
  NightLightState,
@@ -87,6 +88,7 @@ class SwitchbotModel(StrEnum):
87
88
  ROLLER_SHADE = "Roller Shade"
88
89
  HUBMINI_MATTER = "HubMini Matter"
89
90
  CIRCULATOR_FAN = "Circulator Fan"
91
+ CIRCULATOR_FAN_PRO = "Circulator Fan Pro"
90
92
  STANDING_FAN = "Standing Fan"
91
93
  K20_VACUUM = "K20 Vacuum"
92
94
  S10_VACUUM = "S10 Vacuum"
@@ -124,6 +126,7 @@ class SwitchbotModel(StrEnum):
124
126
  LOCK_VISION_PRO = "Lock Vision Pro"
125
127
  LOCK_VISION = "Lock Vision"
126
128
  LOCK_PRO_WIFI = "Lock Pro Wifi"
129
+ LOCK_ULTRA_MAX = "Lock Ultra Max"
127
130
  WEATHER_STATION = "Weather Station"
128
131
 
129
132
 
@@ -135,6 +138,7 @@ __all__ = [
135
138
  "AirQualityLevel",
136
139
  "BulbColorMode",
137
140
  "CeilingLightColorMode",
141
+ "CirculatorFanProMode",
138
142
  "ClimateAction",
139
143
  "ClimateMode",
140
144
  "ColorMode",
@@ -26,6 +26,23 @@ class StandingFanMode(Enum):
26
26
  return [mode.name.lower() for mode in cls]
27
27
 
28
28
 
29
+ class CirculatorFanProMode(Enum):
30
+ """
31
+ Circulator Fan Pro (W1160) running modes.
32
+
33
+ Mode 0x04 is hurricane, not the baby mode of the legacy fan.
34
+ """
35
+
36
+ NORMAL = 1
37
+ NATURAL = 2
38
+ SLEEP = 3
39
+ HURRICANE = 4
40
+
41
+ @classmethod
42
+ def get_modes(cls) -> list[str]:
43
+ return [mode.name.lower() for mode in cls]
44
+
45
+
29
46
  class NightLightState(Enum):
30
47
  """Standing Fan night-light command values."""
31
48
 
@@ -105,6 +105,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
105
105
  "W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP,
106
106
  "W1163000": SwitchbotModel.RGBICWW_LIGHT_BARS,
107
107
  "W1162000": SwitchbotModel.RGBICWW_CEILING_LIGHT,
108
+ "W1160000": SwitchbotModel.CIRCULATOR_FAN_PRO,
108
109
  "W1104000": SwitchbotModel.PLUG_MINI_EU,
109
110
  "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
110
111
  "W1111000": SwitchbotModel.CLIMATE_PANEL,
@@ -6,7 +6,9 @@ import logging
6
6
  from enum import Enum
7
7
  from typing import Any, ClassVar
8
8
 
9
+ from ..const import SwitchbotModel
9
10
  from ..const.fan import (
11
+ CirculatorFanProMode,
10
12
  FanMode,
11
13
  HorizontalOscillationAngle,
12
14
  NightLightState,
@@ -15,6 +17,7 @@ from ..const.fan import (
15
17
  )
16
18
  from .device import (
17
19
  DEVICE_GET_BASIC_SETTINGS_KEY,
20
+ SwitchbotEncryptedDevice,
18
21
  SwitchbotSequenceDevice,
19
22
  update_after_operation,
20
23
  )
@@ -186,6 +189,11 @@ class SwitchbotFan(SwitchbotSequenceDevice):
186
189
  """Return cached mode."""
187
190
  return self._get_adv_value("mode")
188
191
 
192
+ @property
193
+ def fan_modes(self) -> list[str]:
194
+ """Return the supported preset (wind) modes for this device."""
195
+ return self._mode_enum.get_modes()
196
+
189
197
 
190
198
  class SwitchbotStandingFan(SwitchbotFan):
191
199
  """Representation of a Switchbot Standing Fan (FAN2)."""
@@ -309,3 +317,114 @@ class SwitchbotStandingFan(SwitchbotFan):
309
317
  def get_auto_recenter(self) -> bool | None:
310
318
  """Return cached auto-recenter (return-to-center) state."""
311
319
  return self._get_adv_value("auto_recenter")
320
+
321
+
322
+ class SwitchbotCirculatorFanPro(SwitchbotEncryptedDevice, SwitchbotFan):
323
+ """
324
+ Representation of a Switchbot Circulator Fan Pro (W1160).
325
+
326
+ The Pro uses extended commands (``57 0F <subcmd> …``) with a control-source
327
+ byte (0x29 = Home Assistant), wrapped in the encrypted command shell, so it
328
+ extends SwitchbotEncryptedDevice. Fan power uses subcommand 0x41 (open/close
329
+ sub-op 0x11); the night light uses subcommand 0x96 and supports on/off plus
330
+ a choice between two brightness levels via ``turn_on_light(low=...)``
331
+ (level 1 / bright or level 2 / dim).
332
+ """
333
+
334
+ _model = SwitchbotModel.CIRCULATOR_FAN_PRO
335
+
336
+ # Fan power: ext 0x0F, subcmd 0x41, 0x11 = power, 0x29 = control source
337
+ # (Home Assistant), byte5 0x01 = on / 0x00 = off / 0x02 = toggle.
338
+ _turn_on_command = "570f41112901"
339
+ _turn_off_command = "570f41112900"
340
+ # Preset mode: the 0x11 power command also carries the running mode in byte6
341
+ # (turning the fan on). The Pro's mode 0x04 is hurricane, not the legacy baby.
342
+ _mode_enum: ClassVar[type[Enum]] = CirculatorFanProMode
343
+ _command_set_mode: ClassVar[dict[str, str]] = {
344
+ mode.name.lower(): f"570f41112901{mode.value:02X}"
345
+ for mode in CirculatorFanProMode
346
+ }
347
+ # Night light: ext 0x0F, subcmd 0x96, byte3 0x0A, byte4 0x02, then a state
348
+ # byte: bit0 = on/off (0 off / 1 on), bit1 = level (0 high / 1 low).
349
+ # off = 0x00, on high = 0x01, on low = 0x03.
350
+ _night_light_command = "570f960a02{}"
351
+ # Oscillation: ext 0x0F, subcmd 0x02, 0x29 = control source (Home Assistant),
352
+ # then per-axis action bytes [horizontal, vertical] where 0x01 = start,
353
+ # 0x02 = stop, 0xFF = keep current. The Pro is dual-axis, so the all-axes
354
+ # start/stop variants toggle both axes at once.
355
+ _command_start_oscillation: ClassVar[str] = "570f4102290101"
356
+ _command_stop_oscillation: ClassVar[str] = "570f4102290202"
357
+ _command_start_horizontal_oscillation: ClassVar[str] = "570f41022901ff"
358
+ _command_stop_horizontal_oscillation: ClassVar[str] = "570f41022902ff"
359
+ _command_start_vertical_oscillation: ClassVar[str] = "570f410229ff01"
360
+ _command_stop_vertical_oscillation: ClassVar[str] = "570f410229ff02"
361
+
362
+ async def get_basic_info(self) -> dict[str, Any] | None:
363
+ """
364
+ Get device basic info.
365
+
366
+ The Pro carries all runtime state (fan + night light) in its
367
+ advertisement, so only the firmware is read here.
368
+ """
369
+ if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
370
+ return None
371
+ if len(_data1) <= 2:
372
+ return None
373
+ return {"firmware": _data1[2] / 10.0}
374
+
375
+ @update_after_operation
376
+ async def set_percentage(self, percentage: int) -> bool:
377
+ """
378
+ Set the fan speed (1-100).
379
+
380
+ Speed lives in byte7 of the 0x11 power command and only applies in
381
+ direct mode, so this sends "on + direct mode + speed".
382
+ """
383
+ percentage = max(1, min(100, percentage))
384
+ result = await self._send_command(f"570f4111290101{percentage:02X}")
385
+ return self._check_command_result(result, 0, {1})
386
+
387
+ @update_after_operation
388
+ async def set_horizontal_oscillation(self, oscillating: bool) -> bool:
389
+ """Send command to set fan horizontal (left-right) oscillation only."""
390
+ cmd = (
391
+ self._command_start_horizontal_oscillation
392
+ if oscillating
393
+ else self._command_stop_horizontal_oscillation
394
+ )
395
+ result = await self._send_command(cmd)
396
+ return self._check_command_result(result, 0, {1})
397
+
398
+ @update_after_operation
399
+ async def set_vertical_oscillation(self, oscillating: bool) -> bool:
400
+ """Send command to set fan vertical (up-down) oscillation only."""
401
+ cmd = (
402
+ self._command_start_vertical_oscillation
403
+ if oscillating
404
+ else self._command_stop_vertical_oscillation
405
+ )
406
+ result = await self._send_command(cmd)
407
+ return self._check_command_result(result, 0, {1})
408
+
409
+ @update_after_operation
410
+ async def turn_on_light(self, low: bool = False) -> bool:
411
+ """Turn the night light on (low selects level 2 / dim, else level 1 / bright)."""
412
+ state = 0x03 if low else 0x01
413
+ result = await self._send_command(
414
+ self._night_light_command.format(f"{state:02X}")
415
+ )
416
+ return self._check_command_result(result, 0, {1})
417
+
418
+ @update_after_operation
419
+ async def turn_off_light(self) -> bool:
420
+ """Turn the night light off."""
421
+ result = await self._send_command(self._night_light_command.format("00"))
422
+ return self._check_command_result(result, 0, {1})
423
+
424
+ def is_night_light_on(self) -> bool | None:
425
+ """Return the cached night-light power state."""
426
+ return self._get_adv_value("night_light_is_on")
427
+
428
+ def get_night_light_level(self) -> int | None:
429
+ """Return the cached night-light level (1 high, 2 low, 0 off)."""
430
+ return self._get_adv_value("night_light_level")
@@ -22,6 +22,7 @@ COMMAND_LOCK_INFO = {
22
22
  SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4f8101",
23
23
  SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8104",
24
24
  SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8107",
25
+ SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4f8107",
25
26
  SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4f8102",
26
27
  SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4f8102",
27
28
  SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4f810a",
@@ -31,6 +32,7 @@ COMMAND_UNLOCK = {
31
32
  SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011080",
32
33
  SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080",
33
34
  SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080",
35
+ SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000080",
34
36
  SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000080",
35
37
  SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000080",
36
38
  SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000080",
@@ -40,6 +42,7 @@ COMMAND_UNLOCK_WITHOUT_UNLATCH = {
40
42
  SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e010110a0",
41
43
  SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
42
44
  SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0",
45
+ SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e01010000a0",
43
46
  SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e01010000a0",
44
47
  SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e01010000a0",
45
48
  SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e01010000a0",
@@ -49,6 +52,7 @@ COMMAND_LOCK = {
49
52
  SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011000",
50
53
  SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000",
51
54
  SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000",
55
+ SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0f4e0101000000",
52
56
  SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0f4e0101000000",
53
57
  SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0f4e0101000000",
54
58
  SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0f4e0101000000",
@@ -62,6 +66,7 @@ COMMAND_ENABLE_NOTIFICATIONS = {
62
66
  SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0e01001e00008101",
63
67
  SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0e01001e00008104",
64
68
  SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0e01001e00008107",
69
+ SwitchbotModel.LOCK_ULTRA_MAX: f"{COMMAND_HEADER}0e01001e00008107",
65
70
  SwitchbotModel.LOCK_VISION_PRO: f"{COMMAND_HEADER}0e01001e00008102",
66
71
  SwitchbotModel.LOCK_VISION: f"{COMMAND_HEADER}0e01001e00008102",
67
72
  SwitchbotModel.LOCK_PRO_WIFI: f"{COMMAND_HEADER}0e01001e0000810a",
@@ -102,6 +107,7 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
102
107
  SwitchbotModel.LOCK_PRO,
103
108
  SwitchbotModel.LOCK_LITE,
104
109
  SwitchbotModel.LOCK_ULTRA,
110
+ SwitchbotModel.LOCK_ULTRA_MAX,
105
111
  SwitchbotModel.LOCK_VISION_PRO,
106
112
  SwitchbotModel.LOCK_VISION,
107
113
  SwitchbotModel.LOCK_PRO_WIFI,
@@ -143,6 +149,11 @@ class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
143
149
 
144
150
  def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]:
145
151
  """Parse basic data from lock."""
152
+ if self._model is SwitchbotModel.LOCK_ULTRA_MAX:
153
+ # Lock Ultra Max's basic-info response format is not documented.
154
+ # Its advertisement already carries the reliable battery and state
155
+ # data, so never overwrite that data with an unverified response.
156
+ return {}
146
157
  return {
147
158
  "battery": basic_data[1],
148
159
  "firmware": basic_data[2] / 10.0,
@@ -1 +0,0 @@
1
- __version__ = "2.5.0"
File without changes
File without changes