PySwitchbot 2.4.1__tar.gz → 2.6.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 (84) hide show
  1. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/PKG-INFO +1 -1
  2. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/pyproject.toml +1 -1
  3. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/__init__.py +10 -1
  4. pyswitchbot-2.6.0/switchbot/__version__.py +1 -0
  5. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parser.py +30 -2
  6. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/climate_panel.py +18 -0
  7. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/fan.py +49 -1
  8. pyswitchbot-2.6.0/switchbot/adv_parsers/remote.py +42 -0
  9. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/__init__.py +4 -0
  10. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/fan.py +17 -0
  11. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/device.py +2 -0
  12. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/fan.py +119 -0
  13. pyswitchbot-2.6.0/switchbot/devices/universal_remote.py +23 -0
  14. pyswitchbot-2.4.1/switchbot/__version__.py +0 -1
  15. pyswitchbot-2.4.1/switchbot/adv_parsers/remote.py +0 -23
  16. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/LICENSE +0 -0
  17. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/README.md +0 -0
  18. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/__init__.py +0 -0
  19. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/_sensor_th.py +0 -0
  20. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/air_purifier.py +0 -0
  21. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/art_frame.py +0 -0
  22. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  23. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/bot.py +0 -0
  24. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/bulb.py +0 -0
  25. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  26. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/contact.py +0 -0
  27. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/curtain.py +0 -0
  28. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/hub2.py +0 -0
  29. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/hub3.py +0 -0
  30. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  31. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/humidifier.py +0 -0
  32. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/keypad.py +0 -0
  33. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/keypad_vision.py +0 -0
  34. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/leak.py +0 -0
  35. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/light_strip.py +0 -0
  36. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/lock.py +0 -0
  37. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/meter.py +0 -0
  38. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/motion.py +0 -0
  39. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/plug.py +0 -0
  40. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/presence_sensor.py +0 -0
  41. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  42. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/roller_shade.py +0 -0
  43. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
  44. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/vacuum.py +0 -0
  45. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/adv_parsers/weather_station.py +0 -0
  46. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/api_config.py +0 -0
  47. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/air_purifier.py +0 -0
  48. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/climate.py +0 -0
  49. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/evaporative_humidifier.py +0 -0
  50. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/hub2.py +0 -0
  51. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/hub3.py +0 -0
  52. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/light.py +0 -0
  53. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/lock.py +0 -0
  54. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/const/presence_sensor.py +0 -0
  55. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/__init__.py +0 -0
  56. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/air_purifier.py +0 -0
  57. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/art_frame.py +0 -0
  58. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/base_cover.py +0 -0
  59. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/base_light.py +0 -0
  60. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/blind_tilt.py +0 -0
  61. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/bot.py +0 -0
  62. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/bulb.py +0 -0
  63. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/ceiling_light.py +0 -0
  64. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/contact.py +0 -0
  65. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/curtain.py +0 -0
  66. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  67. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/humidifier.py +0 -0
  68. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/keypad.py +0 -0
  69. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/keypad_vision.py +0 -0
  70. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/light_strip.py +0 -0
  71. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/lock.py +0 -0
  72. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/meter.py +0 -0
  73. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/meter_pro.py +0 -0
  74. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/motion.py +0 -0
  75. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/plug.py +0 -0
  76. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/relay_switch.py +0 -0
  77. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/roller_shade.py +0 -0
  78. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/smart_thermostat_radiator.py +0 -0
  79. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/devices/vacuum.py +0 -0
  80. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/discovery.py +0 -0
  81. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/enum.py +0 -0
  82. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/helpers.py +0 -0
  83. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/models.py +0 -0
  84. {pyswitchbot-2.4.1 → pyswitchbot-2.6.0}/switchbot/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 2.4.1
3
+ Version: 2.6.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.4.1"
3
+ version = "2.6.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 (
@@ -70,6 +75,7 @@ from .devices.relay_switch import (
70
75
  )
71
76
  from .devices.roller_shade import SwitchbotRollerShade
72
77
  from .devices.smart_thermostat_radiator import SwitchbotSmartThermostatRadiator
78
+ from .devices.universal_remote import SwitchbotUniversalRemote
73
79
  from .devices.vacuum import SwitchbotVacuum
74
80
  from .discovery import GetSwitchbotDevices
75
81
  from .models import SwitchBotAdvertisement
@@ -79,6 +85,7 @@ __all__ = [
79
85
  "AirQualityLevel",
80
86
  "BulbColorMode",
81
87
  "CeilingLightColorMode",
88
+ "CirculatorFanProMode",
82
89
  "ClimateAction",
83
90
  "ClimateMode",
84
91
  "ColorMode",
@@ -105,6 +112,7 @@ __all__ = [
105
112
  "SwitchbotBulb",
106
113
  "SwitchbotCandleWarmerLamp",
107
114
  "SwitchbotCeilingLight",
115
+ "SwitchbotCirculatorFanPro",
108
116
  "SwitchbotCurtain",
109
117
  "SwitchbotDevice",
110
118
  "SwitchbotEncryptedDevice",
@@ -131,6 +139,7 @@ __all__ = [
131
139
  "SwitchbotStandingFan",
132
140
  "SwitchbotStripLight3",
133
141
  "SwitchbotSupportedType",
142
+ "SwitchbotUniversalRemote",
134
143
  "SwitchbotVacuum",
135
144
  "VerticalOscillationAngle",
136
145
  "close_stale_connections",
@@ -0,0 +1 @@
1
+ __version__ = "2.6.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
@@ -51,7 +55,7 @@ from .adv_parsers.relay_switch import (
51
55
  process_relay_switch_2pm,
52
56
  process_relay_switch_common_data,
53
57
  )
54
- from .adv_parsers.remote import process_woremote
58
+ from .adv_parsers.remote import process_woremote, process_wouniversal_remote
55
59
  from .adv_parsers.roller_shade import process_worollershade
56
60
  from .adv_parsers.smart_thermostat_radiator import process_smart_thermostat_radiator
57
61
  from .adv_parsers.vacuum import process_vacuum, process_vacuum_k
@@ -402,6 +406,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
402
406
  "func": process_woremote,
403
407
  "manufacturer_id": 89,
404
408
  },
409
+ "\x07": {
410
+ "modelName": SwitchbotModel.UNIVERSAL_REMOTE,
411
+ "modelFriendlyName": "Universal Remote",
412
+ "func": process_wouniversal_remote,
413
+ "manufacturer_id": 2409,
414
+ },
415
+ "'": {
416
+ "modelName": SwitchbotModel.UNIVERSAL_REMOTE,
417
+ "modelFriendlyName": "Universal Remote",
418
+ "func": process_wouniversal_remote,
419
+ "manufacturer_id": 2409,
420
+ },
405
421
  ",": {
406
422
  "modelName": SwitchbotModel.ROLLER_SHADE,
407
423
  "modelFriendlyName": "Roller Shade",
@@ -702,6 +718,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
702
718
  "func": process_rgbicww_ceiling_light,
703
719
  "manufacturer_id": 2409,
704
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
+ },
705
733
  b"\x00\x10\xd0\xb7": {
706
734
  "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
707
735
  "modelFriendlyName": "Permanent Outdoor Light",
@@ -28,6 +28,18 @@ def process_climate_panel(
28
28
  pir_state = bool(mfr_data[15] & 0x80)
29
29
  is_light = ((mfr_data[15] >> 1) & 0x03) == 0x02
30
30
 
31
+ # Keystate bytes report physical ON/OFF button presses. For each byte,
32
+ # Bit[7:5] is the press mode (0: power-on init, 1: single, 2: double,
33
+ # 3: long) and Bit[4:0] is a 1-30 cyclic counter that resets to 1 when the
34
+ # mode changes. A value of 0x00 is the power-on state and must not trigger.
35
+ # The raw byte is also exposed so consumers can watch it for any change.
36
+ on_keystate = mfr_data[13]
37
+ off_keystate = mfr_data[14]
38
+ on_keystate_mode = (on_keystate >> 5) & 0x07
39
+ on_keystate_counter = on_keystate & 0x1F
40
+ off_keystate_mode = (off_keystate >> 5) & 0x07
41
+ off_keystate_counter = off_keystate & 0x1F
42
+
31
43
  result = {
32
44
  "sequence_number": seq_number,
33
45
  "isOn": isOn,
@@ -38,6 +50,12 @@ def process_climate_panel(
38
50
  "humidity_alarm": humidity_alarm,
39
51
  "motion_detected": pir_state,
40
52
  "is_light": is_light,
53
+ "on_keystate": on_keystate,
54
+ "off_keystate": off_keystate,
55
+ "on_keystate_mode": on_keystate_mode,
56
+ "on_keystate_counter": on_keystate_counter,
57
+ "off_keystate_mode": off_keystate_mode,
58
+ "off_keystate_counter": off_keystate_counter,
41
59
  }
42
60
 
43
61
  _LOGGER.debug(
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ """Remote adv parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+
10
+ def process_woremote(
11
+ data: bytes | None, mfr_data: bytes | None
12
+ ) -> dict[str, int | None]:
13
+ """Process WoRemote adv data."""
14
+ if data is None or len(data) < 3:
15
+ return {
16
+ "battery": None,
17
+ }
18
+
19
+ _LOGGER.debug("data: %s", data.hex())
20
+
21
+ return {
22
+ "battery": data[2] & 0b01111111,
23
+ }
24
+
25
+
26
+ def process_wouniversal_remote(
27
+ data: bytes | None, mfr_data: bytes | None
28
+ ) -> dict[str, bool | int | None]:
29
+ """Process Universal Remote adv data."""
30
+ if mfr_data is None or len(mfr_data) < 8:
31
+ return {
32
+ "battery": None,
33
+ "charging": None,
34
+ }
35
+
36
+ _LOGGER.debug("mfr_data: %s", mfr_data.hex())
37
+
38
+ # mfr_data[7]: bit 7 is charging; bits 6-0 are battery percentage.
39
+ return {
40
+ "battery": mfr_data[7] & 0b01111111,
41
+ "charging": bool((mfr_data[7] >> 7) & 1),
42
+ }
@@ -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,
@@ -82,10 +83,12 @@ class SwitchbotModel(StrEnum):
82
83
  RELAY_SWITCH_1PM = "Relay Switch 1PM"
83
84
  RELAY_SWITCH_1 = "Relay Switch 1"
84
85
  REMOTE = "WoRemote"
86
+ UNIVERSAL_REMOTE = "WoUniversalRemote"
85
87
  EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
86
88
  ROLLER_SHADE = "Roller Shade"
87
89
  HUBMINI_MATTER = "HubMini Matter"
88
90
  CIRCULATOR_FAN = "Circulator Fan"
91
+ CIRCULATOR_FAN_PRO = "Circulator Fan Pro"
89
92
  STANDING_FAN = "Standing Fan"
90
93
  K20_VACUUM = "K20 Vacuum"
91
94
  S10_VACUUM = "S10 Vacuum"
@@ -134,6 +137,7 @@ __all__ = [
134
137
  "AirQualityLevel",
135
138
  "BulbColorMode",
136
139
  "CeilingLightColorMode",
140
+ "CirculatorFanProMode",
137
141
  "ClimateAction",
138
142
  "ClimateMode",
139
143
  "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
 
@@ -74,6 +74,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
74
74
  "WoBlindTilt": SwitchbotModel.BLIND_TILT,
75
75
  "WoIOSensor": SwitchbotModel.IO_METER, # Outdoor Meter
76
76
  "WoButton": SwitchbotModel.REMOTE, # Remote button
77
+ "WoUniversalRemote": SwitchbotModel.UNIVERSAL_REMOTE, # Universal Remote
77
78
  "WoLinkMini": SwitchbotModel.HUBMINI_MATTER, # Hub Mini
78
79
  "WoFan2": SwitchbotModel.CIRCULATOR_FAN,
79
80
  "WoHub2": SwitchbotModel.HUB2,
@@ -104,6 +105,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
104
105
  "W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP,
105
106
  "W1163000": SwitchbotModel.RGBICWW_LIGHT_BARS,
106
107
  "W1162000": SwitchbotModel.RGBICWW_CEILING_LIGHT,
108
+ "W1160000": SwitchbotModel.CIRCULATOR_FAN_PRO,
107
109
  "W1104000": SwitchbotModel.PLUG_MINI_EU,
108
110
  "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
109
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")
@@ -0,0 +1,23 @@
1
+ """Library to handle connection with Switchbot Universal Remote."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from .device import SwitchbotDevice
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class SwitchbotUniversalRemote(SwitchbotDevice):
14
+ """Representation of a Switchbot Universal Remote."""
15
+
16
+ async def get_basic_info(self) -> dict[str, Any] | None:
17
+ """Get device basic settings."""
18
+ if not (_data := await self._get_basic_info()):
19
+ return None
20
+ return {
21
+ "battery": _data[1],
22
+ "charging": bool(_data[12]),
23
+ }
@@ -1 +0,0 @@
1
- __version__ = "2.4.1"
@@ -1,23 +0,0 @@
1
- """Remote adv parser."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
-
7
- _LOGGER = logging.getLogger(__name__)
8
-
9
-
10
- def process_woremote(
11
- data: bytes | None, mfr_data: bytes | None
12
- ) -> dict[str, int | None]:
13
- """Process WoRemote adv data."""
14
- if data is None or len(data) < 3:
15
- return {
16
- "battery": None,
17
- }
18
-
19
- _LOGGER.debug("data: %s", data.hex())
20
-
21
- return {
22
- "battery": data[2] & 0b01111111,
23
- }
File without changes
File without changes