PySwitchbot 0.59.0__tar.gz → 0.60.1__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 (75) hide show
  1. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/PKG-INFO +1 -1
  2. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/PySwitchbot.egg-info/PKG-INFO +1 -1
  3. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/PySwitchbot.egg-info/SOURCES.txt +4 -0
  4. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/setup.py +1 -1
  5. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/__init__.py +4 -0
  6. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parser.py +7 -0
  7. pyswitchbot-0.60.1/switchbot/adv_parsers/fan.py +33 -0
  8. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/const/__init__.py +2 -0
  9. pyswitchbot-0.60.1/switchbot/const/fan.py +14 -0
  10. pyswitchbot-0.60.1/switchbot/devices/fan.py +116 -0
  11. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_adv_parser.py +94 -0
  12. pyswitchbot-0.60.1/tests/test_fan.py +172 -0
  13. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/LICENSE +0 -0
  14. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/MANIFEST.in +0 -0
  15. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  16. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/PySwitchbot.egg-info/requires.txt +0 -0
  17. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/PySwitchbot.egg-info/top_level.txt +0 -0
  18. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/README.md +0 -0
  19. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/pyproject.toml +0 -0
  20. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/setup.cfg +0 -0
  21. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/__init__.py +0 -0
  22. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/blind_tilt.py +0 -0
  23. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/bot.py +0 -0
  24. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/bulb.py +0 -0
  25. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/ceiling_light.py +0 -0
  26. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/contact.py +0 -0
  27. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/curtain.py +0 -0
  28. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/hub2.py +0 -0
  29. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  30. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/humidifier.py +0 -0
  31. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/keypad.py +0 -0
  32. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/leak.py +0 -0
  33. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/light_strip.py +0 -0
  34. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/lock.py +0 -0
  35. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/meter.py +0 -0
  36. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/motion.py +0 -0
  37. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/plug.py +0 -0
  38. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/relay_switch.py +0 -0
  39. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/remote.py +0 -0
  40. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/adv_parsers/roller_shade.py +0 -0
  41. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/api_config.py +0 -0
  42. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/const/evaporative_humidifier.py +0 -0
  43. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/const/hub2.py +0 -0
  44. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/const/lock.py +0 -0
  45. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/__init__.py +0 -0
  46. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/base_cover.py +0 -0
  47. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/base_light.py +0 -0
  48. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/blind_tilt.py +0 -0
  49. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/bot.py +0 -0
  50. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/bulb.py +0 -0
  51. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/ceiling_light.py +0 -0
  52. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/contact.py +0 -0
  53. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/curtain.py +0 -0
  54. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/device.py +0 -0
  55. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/evaporative_humidifier.py +0 -0
  56. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/humidifier.py +0 -0
  57. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/keypad.py +0 -0
  58. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/light_strip.py +0 -0
  59. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/lock.py +0 -0
  60. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/meter.py +0 -0
  61. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/motion.py +0 -0
  62. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/plug.py +0 -0
  63. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/relay_switch.py +0 -0
  64. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/devices/roller_shade.py +0 -0
  65. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/discovery.py +0 -0
  66. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/enum.py +0 -0
  67. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/helpers.py +0 -0
  68. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/switchbot/models.py +0 -0
  69. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_base_cover.py +0 -0
  70. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_blind_tilt.py +0 -0
  71. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_curtain.py +0 -0
  72. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_evaporative_humidifier.py +0 -0
  73. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_hub2.py +0 -0
  74. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_relay_switch.py +0 -0
  75. {pyswitchbot-0.59.0 → pyswitchbot-0.60.1}/tests/test_roller_shade.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 0.59.0
3
+ Version: 0.60.1
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.59.0
3
+ Version: 0.60.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -22,6 +22,7 @@ switchbot/adv_parsers/bulb.py
22
22
  switchbot/adv_parsers/ceiling_light.py
23
23
  switchbot/adv_parsers/contact.py
24
24
  switchbot/adv_parsers/curtain.py
25
+ switchbot/adv_parsers/fan.py
25
26
  switchbot/adv_parsers/hub2.py
26
27
  switchbot/adv_parsers/hubmini_matter.py
27
28
  switchbot/adv_parsers/humidifier.py
@@ -37,6 +38,7 @@ switchbot/adv_parsers/remote.py
37
38
  switchbot/adv_parsers/roller_shade.py
38
39
  switchbot/const/__init__.py
39
40
  switchbot/const/evaporative_humidifier.py
41
+ switchbot/const/fan.py
40
42
  switchbot/const/hub2.py
41
43
  switchbot/const/lock.py
42
44
  switchbot/devices/__init__.py
@@ -50,6 +52,7 @@ switchbot/devices/contact.py
50
52
  switchbot/devices/curtain.py
51
53
  switchbot/devices/device.py
52
54
  switchbot/devices/evaporative_humidifier.py
55
+ switchbot/devices/fan.py
53
56
  switchbot/devices/humidifier.py
54
57
  switchbot/devices/keypad.py
55
58
  switchbot/devices/light_strip.py
@@ -64,6 +67,7 @@ tests/test_base_cover.py
64
67
  tests/test_blind_tilt.py
65
68
  tests/test_curtain.py
66
69
  tests/test_evaporative_humidifier.py
70
+ tests/test_fan.py
67
71
  tests/test_hub2.py
68
72
  tests/test_relay_switch.py
69
73
  tests/test_roller_shade.py
@@ -20,7 +20,7 @@ setup(
20
20
  "cryptography>=39.0.0",
21
21
  "pyOpenSSL>=23.0.0",
22
22
  ],
23
- version="0.59.0",
23
+ version="0.60.1",
24
24
  description="A library to communicate with Switchbot",
25
25
  long_description=long_description,
26
26
  long_description_content_type="text/markdown",
@@ -10,6 +10,7 @@ from bleak_retry_connector import (
10
10
 
11
11
  from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
12
12
  from .const import (
13
+ FanMode,
13
14
  LockStatus,
14
15
  SwitchbotAccountConnectionError,
15
16
  SwitchbotApiError,
@@ -24,6 +25,7 @@ from .devices.ceiling_light import SwitchbotCeilingLight
24
25
  from .devices.curtain import SwitchbotCurtain
25
26
  from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
26
27
  from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
28
+ from .devices.fan import SwitchbotFan
27
29
  from .devices.humidifier import SwitchbotHumidifier
28
30
  from .devices.light_strip import SwitchbotLightStrip
29
31
  from .devices.lock import SwitchbotLock
@@ -35,6 +37,7 @@ from .models import SwitchBotAdvertisement
35
37
 
36
38
  __all__ = [
37
39
  "ColorMode",
40
+ "FanMode",
38
41
  "GetSwitchbotDevices",
39
42
  "LockStatus",
40
43
  "SwitchBotAdvertisement",
@@ -51,6 +54,7 @@ __all__ = [
51
54
  "SwitchbotDevice",
52
55
  "SwitchbotEncryptedDevice",
53
56
  "SwitchbotEvaporativeHumidifier",
57
+ "SwitchbotFan",
54
58
  "SwitchbotHumidifier",
55
59
  "SwitchbotLightStrip",
56
60
  "SwitchbotLock",
@@ -16,6 +16,7 @@ from .adv_parsers.bulb import process_color_bulb
16
16
  from .adv_parsers.ceiling_light import process_woceiling
17
17
  from .adv_parsers.contact import process_wocontact
18
18
  from .adv_parsers.curtain import process_wocurtain
19
+ from .adv_parsers.fan import process_fan
19
20
  from .adv_parsers.hub2 import process_wohub2
20
21
  from .adv_parsers.hubmini_matter import process_hubmini_matter
21
22
  from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
@@ -230,6 +231,12 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
230
231
  "func": process_hubmini_matter,
231
232
  "manufacturer_id": 2409,
232
233
  },
234
+ "~": {
235
+ "modelName": SwitchbotModel.CIRCULATOR_FAN,
236
+ "modelFriendlyName": "Circulator Fan",
237
+ "func": process_fan,
238
+ "manufacturer_id": 2409,
239
+ },
233
240
  }
234
241
 
235
242
  _SWITCHBOT_MODEL_TO_CHAR = {
@@ -0,0 +1,33 @@
1
+ """Fan adv parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..const.fan import FanMode
6
+
7
+
8
+ def process_fan(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
9
+ """Process fan services data."""
10
+ if mfr_data is None:
11
+ return {}
12
+
13
+ device_data = mfr_data[6:]
14
+
15
+ _seq_num = device_data[0]
16
+ _isOn = bool(device_data[1] & 0b10000000)
17
+ _mode = (device_data[1] & 0b01110000) >> 4
18
+ _mode = FanMode(_mode).name.lower() if 1 <= _mode <= 4 else None
19
+ _nightLight = (device_data[1] & 0b00001100) >> 2
20
+ _oscillate_left_and_right = bool(device_data[1] & 0b00000010)
21
+ _oscillate_up_and_down = bool(device_data[1] & 0b00000001)
22
+ _battery = device_data[2] & 0b01111111
23
+ _speed = device_data[3] & 0b01111111
24
+
25
+ return {
26
+ "sequence_number": _seq_num,
27
+ "isOn": _isOn,
28
+ "mode": _mode,
29
+ "nightLight": _nightLight,
30
+ "oscillating": _oscillate_left_and_right | _oscillate_up_and_down,
31
+ "battery": _battery,
32
+ "speed": _speed,
33
+ }
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from ..enum import StrEnum
6
+ from .fan import FanMode as FanMode
6
7
 
7
8
  # Preserve old LockStatus export for backwards compatibility
8
9
  from .lock import LockStatus as LockStatus
@@ -65,3 +66,4 @@ class SwitchbotModel(StrEnum):
65
66
  EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
66
67
  ROLLER_SHADE = "Roller Shade"
67
68
  HUBMINI_MATTER = "HubMini Matter"
69
+ CIRCULATOR_FAN = "Circulator Fan"
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class FanMode(Enum):
7
+ NORMAL = 1
8
+ NATURAL = 2
9
+ SLEEP = 3
10
+ BABY = 4
11
+
12
+ @classmethod
13
+ def get_modes(cls) -> list[str]:
14
+ return [mode.name.lower() for mode in cls]
@@ -0,0 +1,116 @@
1
+ """Library to handle connection with Switchbot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from ..const.fan import FanMode
9
+ from .device import (
10
+ DEVICE_GET_BASIC_SETTINGS_KEY,
11
+ SwitchbotSequenceDevice,
12
+ update_after_operation,
13
+ )
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ COMMAND_HEAD = "570f41"
19
+ COMMAND_TURN_ON = f"{COMMAND_HEAD}0101"
20
+ COMMAND_TURN_OFF = f"{COMMAND_HEAD}0102"
21
+ COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff"
22
+ COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff"
23
+ COMMAND_SET_MODE = {
24
+ FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff",
25
+ FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff",
26
+ FanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103",
27
+ FanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104",
28
+ }
29
+ COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302" # +speed
30
+ COMMAND_GET_BASIC_INFO = "570f428102"
31
+
32
+
33
+ class SwitchbotFan(SwitchbotSequenceDevice):
34
+ """Representation of a Switchbot Circulator Fan."""
35
+
36
+ def __init__(self, device, password=None, interface=0, **kwargs):
37
+ super().__init__(device, password, interface, **kwargs)
38
+
39
+ async def get_basic_info(self) -> dict[str, Any] | None:
40
+ """Get device basic settings."""
41
+ if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)):
42
+ return None
43
+ if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
44
+ return None
45
+
46
+ _LOGGER.debug("data: %s", _data)
47
+ battery = _data[2] & 0b01111111
48
+ isOn = bool(_data[3] & 0b10000000)
49
+ oscillating = bool(_data[3] & 0b01100000)
50
+ _mode = _data[8] & 0b00000111
51
+ mode = FanMode(_mode).name.lower() if 1 <= _mode <= 4 else None
52
+ speed = _data[9]
53
+ firmware = _data1[2] / 10.0
54
+
55
+ return {
56
+ "battery": battery,
57
+ "isOn": isOn,
58
+ "oscillating": oscillating,
59
+ "mode": mode,
60
+ "speed": speed,
61
+ "firmware": firmware,
62
+ }
63
+
64
+ async def _get_basic_info(self, cmd: str) -> bytes | None:
65
+ """Return basic info of device."""
66
+ _data = await self._send_command(key=cmd, retry=self._retry_count)
67
+
68
+ if _data in (b"\x07", b"\x00"):
69
+ _LOGGER.error("Unsuccessful, please try again")
70
+ return None
71
+
72
+ return _data
73
+
74
+ @update_after_operation
75
+ async def set_preset_mode(self, preset_mode: str) -> bool:
76
+ """Send command to set fan preset_mode."""
77
+ return await self._send_command(COMMAND_SET_MODE[preset_mode])
78
+
79
+ @update_after_operation
80
+ async def set_percentage(self, percentage: int) -> bool:
81
+ """Send command to set fan percentage."""
82
+ return await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
83
+
84
+ @update_after_operation
85
+ async def set_oscillation(self, oscillating: bool) -> bool:
86
+ """Send command to set fan oscillation"""
87
+ if oscillating:
88
+ return await self._send_command(COMMAND_START_OSCILLATION)
89
+ else:
90
+ return await self._send_command(COMMAND_STOP_OSCILLATION)
91
+
92
+ @update_after_operation
93
+ async def turn_on(self) -> bool:
94
+ """Turn on the fan."""
95
+ return await self._send_command(COMMAND_TURN_ON)
96
+
97
+ @update_after_operation
98
+ async def turn_off(self) -> bool:
99
+ """Turn off the fan."""
100
+ return await self._send_command(COMMAND_TURN_OFF)
101
+
102
+ def get_current_percentage(self) -> Any:
103
+ """Return cached percentage."""
104
+ return self._get_adv_value("speed")
105
+
106
+ def is_on(self) -> bool | None:
107
+ """Return fan state from cache."""
108
+ return self._get_adv_value("isOn")
109
+
110
+ def get_oscillating_state(self) -> Any:
111
+ """Return cached oscillating."""
112
+ return self._get_adv_value("oscillating")
113
+
114
+ def get_current_mode(self) -> Any:
115
+ """Return cached mode."""
116
+ return self._get_adv_value("mode")
@@ -2103,3 +2103,97 @@ def test_roller_shade_passive() -> None:
2103
2103
  rssi=-97,
2104
2104
  active=False,
2105
2105
  )
2106
+
2107
+
2108
+ def test_circulator_fan_active() -> None:
2109
+ """Test parsing circulator fan with active data."""
2110
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2111
+ adv_data = generate_advertisement_data(
2112
+ manufacturer_data={2409: b"\xb0\xe9\xfeXY\xa8~LR9"},
2113
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"},
2114
+ rssi=-97,
2115
+ )
2116
+ result = parse_advertisement_data(
2117
+ ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN
2118
+ )
2119
+ assert result == SwitchBotAdvertisement(
2120
+ address="aa:bb:cc:dd:ee:ff",
2121
+ data={
2122
+ "rawAdvData": b"~\x00R",
2123
+ "data": {
2124
+ "sequence_number": 126,
2125
+ "isOn": False,
2126
+ "mode": "baby",
2127
+ "nightLight": 3,
2128
+ "oscillating": False,
2129
+ "battery": 82,
2130
+ "speed": 57,
2131
+ },
2132
+ "isEncrypted": False,
2133
+ "model": "~",
2134
+ "modelFriendlyName": "Circulator Fan",
2135
+ "modelName": SwitchbotModel.CIRCULATOR_FAN,
2136
+ },
2137
+ device=ble_device,
2138
+ rssi=-97,
2139
+ active=True,
2140
+ )
2141
+
2142
+
2143
+ def test_circulator_fan_passive() -> None:
2144
+ """Test parsing circulator fan with passive data."""
2145
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2146
+ adv_data = generate_advertisement_data(
2147
+ manufacturer_data={2409: b"\xb0\xe9\xfeXY\xa8~LR9"},
2148
+ rssi=-97,
2149
+ )
2150
+ result = parse_advertisement_data(
2151
+ ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN
2152
+ )
2153
+ assert result == SwitchBotAdvertisement(
2154
+ address="aa:bb:cc:dd:ee:ff",
2155
+ data={
2156
+ "rawAdvData": None,
2157
+ "data": {
2158
+ "sequence_number": 126,
2159
+ "isOn": False,
2160
+ "mode": "baby",
2161
+ "nightLight": 3,
2162
+ "oscillating": False,
2163
+ "battery": 82,
2164
+ "speed": 57,
2165
+ },
2166
+ "isEncrypted": False,
2167
+ "model": "~",
2168
+ "modelFriendlyName": "Circulator Fan",
2169
+ "modelName": SwitchbotModel.CIRCULATOR_FAN,
2170
+ },
2171
+ device=ble_device,
2172
+ rssi=-97,
2173
+ active=False,
2174
+ )
2175
+
2176
+
2177
+ def test_circulator_fan_with_empty_data() -> None:
2178
+ """Test parsing circulator fan with empty data."""
2179
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2180
+ adv_data = generate_advertisement_data(
2181
+ manufacturer_data={2409: None},
2182
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"},
2183
+ rssi=-97,
2184
+ )
2185
+ result = parse_advertisement_data(
2186
+ ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN
2187
+ )
2188
+ assert result == SwitchBotAdvertisement(
2189
+ address="aa:bb:cc:dd:ee:ff",
2190
+ data={
2191
+ "rawAdvData": b"~\x00R",
2192
+ "data": {},
2193
+ "isEncrypted": False,
2194
+ "model": "~",
2195
+ },
2196
+ device=ble_device,
2197
+ rssi=-97,
2198
+ active=True,
2199
+ )
@@ -0,0 +1,172 @@
1
+ from unittest.mock import AsyncMock
2
+
3
+ import pytest
4
+ from bleak.backends.device import BLEDevice
5
+
6
+ from switchbot import SwitchBotAdvertisement, SwitchbotModel
7
+ from switchbot.const.fan import FanMode
8
+ from switchbot.devices import fan
9
+
10
+ from .test_adv_parser import generate_ble_device
11
+
12
+
13
+ def create_device_for_command_testing(init_data: dict | None = None):
14
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
15
+ fan_device = fan.SwitchbotFan(ble_device)
16
+ fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
17
+ fan_device._send_command = AsyncMock()
18
+ fan_device.update = AsyncMock()
19
+ return fan_device
20
+
21
+
22
+ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
23
+ """Set advertisement data with defaults."""
24
+ if init_data is None:
25
+ init_data = {}
26
+
27
+ return SwitchBotAdvertisement(
28
+ address="aa:bb:cc:dd:ee:ff",
29
+ data={
30
+ "rawAdvData": b"~\x00R",
31
+ "data": {
32
+ "isOn": True,
33
+ "mode": "NORMAL",
34
+ "nightLight": 3,
35
+ "oscillating": False,
36
+ "battery": 60,
37
+ "speed": 50,
38
+ }
39
+ | init_data,
40
+ "isEncrypted": False,
41
+ "model": ",",
42
+ "modelFriendlyName": "Circulator Fan",
43
+ "modelName": SwitchbotModel.CIRCULATOR_FAN,
44
+ },
45
+ device=ble_device,
46
+ rssi=-80,
47
+ active=True,
48
+ )
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ @pytest.mark.parametrize(
53
+ "response, expected",
54
+ [
55
+ (b"\x00", None),
56
+ (b"\x07", None),
57
+ (b"\x01\x02\x03", b"\x01\x02\x03"),
58
+ ],
59
+ )
60
+ async def test__get_basic_info(response, expected):
61
+ fan_device = create_device_for_command_testing()
62
+ fan_device._send_command = AsyncMock(return_value=response)
63
+ result = await fan_device._get_basic_info(cmd="TEST_CMD")
64
+ assert result == expected
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ @pytest.mark.parametrize(
69
+ "basic_info,firmware_info", [(True, False), (False, True), (False, False)]
70
+ )
71
+ async def test_get_basic_info_returns_none(basic_info, firmware_info):
72
+ fan_device = create_device_for_command_testing()
73
+
74
+ async def mock_get_basic_info(arg):
75
+ if arg == fan.COMMAND_GET_BASIC_INFO:
76
+ return basic_info
77
+ elif arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
78
+ return firmware_info
79
+
80
+ fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
81
+
82
+ assert await fan_device.get_basic_info() is None
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ @pytest.mark.parametrize(
87
+ "basic_info,firmware_info,result",
88
+ [
89
+ (
90
+ bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"),
91
+ bytearray(b"\x01W\x0b\x17\x01"),
92
+ [87, True, False, "normal", 61, 1.1],
93
+ ),
94
+ (
95
+ bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"),
96
+ bytearray(b"\x01U\x0b\x17\x01"),
97
+ [85, True, True, "baby", 43, 1.1],
98
+ ),
99
+ ],
100
+ )
101
+ async def test_get_basic_info(basic_info, firmware_info, result):
102
+ fan_device = create_device_for_command_testing()
103
+
104
+ async def mock_get_basic_info(arg):
105
+ if arg == fan.COMMAND_GET_BASIC_INFO:
106
+ return basic_info
107
+ elif arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
108
+ return firmware_info
109
+
110
+ fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
111
+
112
+ info = await fan_device.get_basic_info()
113
+ assert info["battery"] == result[0]
114
+ assert info["isOn"] == result[1]
115
+ assert info["oscillating"] == result[2]
116
+ assert info["mode"] == result[3]
117
+ assert info["speed"] == result[4]
118
+ assert info["firmware"] == result[5]
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_set_preset_mode():
123
+ fan_device = create_device_for_command_testing({"mode": "baby"})
124
+ await fan_device.set_preset_mode("baby")
125
+ assert fan_device.get_current_mode() == "baby"
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_set_set_percentage_with_speed_is_0():
130
+ fan_device = create_device_for_command_testing({"speed": 0, "isOn": False})
131
+ await fan_device.turn_off()
132
+ assert fan_device.get_current_percentage() == 0
133
+ assert fan_device.is_on() is False
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_set_set_percentage():
138
+ fan_device = create_device_for_command_testing({"speed": 80})
139
+ await fan_device.set_percentage(80)
140
+ assert fan_device.get_current_percentage() == 80
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_set_not_oscillation():
145
+ fan_device = create_device_for_command_testing({"oscillating": False})
146
+ await fan_device.set_oscillation(False)
147
+ assert fan_device.get_oscillating_state() is False
148
+
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_set_oscillation():
152
+ fan_device = create_device_for_command_testing({"oscillating": True})
153
+ await fan_device.set_oscillation(True)
154
+ assert fan_device.get_oscillating_state() is True
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_turn_on():
159
+ fan_device = create_device_for_command_testing({"isOn": True})
160
+ await fan_device.turn_on()
161
+ assert fan_device.is_on() is True
162
+
163
+
164
+ @pytest.mark.asyncio
165
+ async def test_turn_off():
166
+ fan_device = create_device_for_command_testing({"isOn": False})
167
+ await fan_device.turn_off()
168
+ assert fan_device.is_on() is False
169
+
170
+
171
+ def test_get_modes():
172
+ assert FanMode.get_modes() == ["normal", "natural", "sleep", "baby"]
File without changes
File without changes
File without changes
File without changes