PySwitchbot 0.61.0__tar.gz → 0.62.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 (82) hide show
  1. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/PKG-INFO +1 -1
  2. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
  3. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/PySwitchbot.egg-info/SOURCES.txt +4 -0
  4. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/setup.py +1 -1
  5. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/__init__.py +4 -0
  6. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parser.py +25 -0
  7. pyswitchbot-0.62.0/switchbot/adv_parsers/air_purifier.py +52 -0
  8. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/const/__init__.py +3 -0
  9. pyswitchbot-0.62.0/switchbot/const/air_purifier.py +23 -0
  10. pyswitchbot-0.62.0/switchbot/devices/air_purifier.py +142 -0
  11. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_adv_parser.py +185 -0
  12. pyswitchbot-0.62.0/tests/test_air_purifier.py +231 -0
  13. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/LICENSE +0 -0
  14. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/MANIFEST.in +0 -0
  15. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  16. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/PySwitchbot.egg-info/requires.txt +0 -0
  17. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/PySwitchbot.egg-info/top_level.txt +0 -0
  18. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/README.md +0 -0
  19. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/pyproject.toml +0 -0
  20. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/setup.cfg +0 -0
  21. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/__init__.py +0 -0
  22. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  23. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/bot.py +0 -0
  24. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/bulb.py +0 -0
  25. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  26. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/contact.py +0 -0
  27. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/curtain.py +0 -0
  28. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/fan.py +0 -0
  29. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/hub2.py +0 -0
  30. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/hubmini_matter.py +0 -0
  31. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/humidifier.py +0 -0
  32. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/keypad.py +0 -0
  33. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/leak.py +0 -0
  34. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/light_strip.py +0 -0
  35. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/lock.py +0 -0
  36. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/meter.py +0 -0
  37. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/motion.py +0 -0
  38. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/plug.py +0 -0
  39. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/relay_switch.py +0 -0
  40. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/remote.py +0 -0
  41. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/roller_shade.py +0 -0
  42. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/adv_parsers/vacuum.py +0 -0
  43. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/api_config.py +0 -0
  44. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/const/evaporative_humidifier.py +0 -0
  45. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/const/fan.py +0 -0
  46. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/const/hub2.py +0 -0
  47. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/const/lock.py +0 -0
  48. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/__init__.py +0 -0
  49. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/base_cover.py +0 -0
  50. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/base_light.py +0 -0
  51. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/blind_tilt.py +0 -0
  52. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/bot.py +0 -0
  53. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/bulb.py +0 -0
  54. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/ceiling_light.py +0 -0
  55. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/contact.py +0 -0
  56. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/curtain.py +0 -0
  57. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/device.py +0 -0
  58. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/evaporative_humidifier.py +0 -0
  59. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/fan.py +0 -0
  60. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/humidifier.py +0 -0
  61. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/keypad.py +0 -0
  62. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/light_strip.py +0 -0
  63. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/lock.py +0 -0
  64. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/meter.py +0 -0
  65. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/motion.py +0 -0
  66. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/plug.py +0 -0
  67. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/relay_switch.py +0 -0
  68. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/roller_shade.py +0 -0
  69. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/devices/vacuum.py +0 -0
  70. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/discovery.py +0 -0
  71. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/enum.py +0 -0
  72. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/helpers.py +0 -0
  73. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/switchbot/models.py +0 -0
  74. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_base_cover.py +0 -0
  75. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_blind_tilt.py +0 -0
  76. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_curtain.py +0 -0
  77. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_evaporative_humidifier.py +0 -0
  78. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_fan.py +0 -0
  79. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_hub2.py +0 -0
  80. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_relay_switch.py +0 -0
  81. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_roller_shade.py +0 -0
  82. {pyswitchbot-0.61.0 → pyswitchbot-0.62.0}/tests/test_vacuum.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PySwitchbot
3
- Version: 0.61.0
3
+ Version: 0.62.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.61.0
3
+ Version: 0.62.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
@@ -16,6 +16,7 @@ switchbot/enum.py
16
16
  switchbot/helpers.py
17
17
  switchbot/models.py
18
18
  switchbot/adv_parsers/__init__.py
19
+ switchbot/adv_parsers/air_purifier.py
19
20
  switchbot/adv_parsers/blind_tilt.py
20
21
  switchbot/adv_parsers/bot.py
21
22
  switchbot/adv_parsers/bulb.py
@@ -38,11 +39,13 @@ switchbot/adv_parsers/remote.py
38
39
  switchbot/adv_parsers/roller_shade.py
39
40
  switchbot/adv_parsers/vacuum.py
40
41
  switchbot/const/__init__.py
42
+ switchbot/const/air_purifier.py
41
43
  switchbot/const/evaporative_humidifier.py
42
44
  switchbot/const/fan.py
43
45
  switchbot/const/hub2.py
44
46
  switchbot/const/lock.py
45
47
  switchbot/devices/__init__.py
48
+ switchbot/devices/air_purifier.py
46
49
  switchbot/devices/base_cover.py
47
50
  switchbot/devices/base_light.py
48
51
  switchbot/devices/blind_tilt.py
@@ -65,6 +68,7 @@ switchbot/devices/relay_switch.py
65
68
  switchbot/devices/roller_shade.py
66
69
  switchbot/devices/vacuum.py
67
70
  tests/test_adv_parser.py
71
+ tests/test_air_purifier.py
68
72
  tests/test_base_cover.py
69
73
  tests/test_blind_tilt.py
70
74
  tests/test_curtain.py
@@ -20,7 +20,7 @@ setup(
20
20
  "cryptography>=39.0.0",
21
21
  "pyOpenSSL>=23.0.0",
22
22
  ],
23
- version="0.61.0",
23
+ version="0.62.0",
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
+ AirPurifierMode,
13
14
  FanMode,
14
15
  LockStatus,
15
16
  SwitchbotAccountConnectionError,
@@ -17,6 +18,7 @@ from .const import (
17
18
  SwitchbotAuthenticationError,
18
19
  SwitchbotModel,
19
20
  )
21
+ from .devices.air_purifier import SwitchbotAirPurifier
20
22
  from .devices.base_light import SwitchbotBaseLight
21
23
  from .devices.blind_tilt import SwitchbotBlindTilt
22
24
  from .devices.bot import Switchbot
@@ -37,6 +39,7 @@ from .discovery import GetSwitchbotDevices
37
39
  from .models import SwitchBotAdvertisement
38
40
 
39
41
  __all__ = [
42
+ "AirPurifierMode",
40
43
  "ColorMode",
41
44
  "FanMode",
42
45
  "GetSwitchbotDevices",
@@ -45,6 +48,7 @@ __all__ = [
45
48
  "Switchbot",
46
49
  "Switchbot",
47
50
  "SwitchbotAccountConnectionError",
51
+ "SwitchbotAirPurifier",
48
52
  "SwitchbotApiError",
49
53
  "SwitchbotAuthenticationError",
50
54
  "SwitchbotBaseLight",
@@ -10,6 +10,7 @@ from typing import Any, TypedDict
10
10
  from bleak.backends.device import BLEDevice
11
11
  from bleak.backends.scanner import AdvertisementData
12
12
 
13
+ from .adv_parsers.air_purifier import process_air_purifier
13
14
  from .adv_parsers.blind_tilt import process_woblindtilt
14
15
  from .adv_parsers.bot import process_wohand
15
16
  from .adv_parsers.bulb import process_color_bulb
@@ -268,6 +269,30 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
268
269
  "func": process_vacuum_k,
269
270
  "manufacturer_id": 2409,
270
271
  },
272
+ "*": {
273
+ "modelName": SwitchbotModel.AIR_PURIFIER,
274
+ "modelFriendlyName": "Air Purifier",
275
+ "func": process_air_purifier,
276
+ "manufacturer_id": 2409,
277
+ },
278
+ "+": {
279
+ "modelName": SwitchbotModel.AIR_PURIFIER,
280
+ "modelFriendlyName": "Air Purifier",
281
+ "func": process_air_purifier,
282
+ "manufacturer_id": 2409,
283
+ },
284
+ "7": {
285
+ "modelName": SwitchbotModel.AIR_PURIFIER,
286
+ "modelFriendlyName": "Air Purifier",
287
+ "func": process_air_purifier,
288
+ "manufacturer_id": 2409,
289
+ },
290
+ "8": {
291
+ "modelName": SwitchbotModel.AIR_PURIFIER,
292
+ "modelFriendlyName": "Air Purifier",
293
+ "func": process_air_purifier,
294
+ "manufacturer_id": 2409,
295
+ },
271
296
  }
272
297
 
273
298
  _SWITCHBOT_MODEL_TO_CHAR = {
@@ -0,0 +1,52 @@
1
+ """Air Purifier adv parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ from ..const.air_purifier import AirPurifierMode, AirQualityLevel
8
+
9
+
10
+ def process_air_purifier(
11
+ data: bytes | None, mfr_data: bytes | None
12
+ ) -> dict[str, bool | int]:
13
+ """Process air purifier services data."""
14
+ if mfr_data is None:
15
+ return {}
16
+ device_data = mfr_data[6:]
17
+
18
+ _seq_num = device_data[0]
19
+ _isOn = bool(device_data[1] & 0b10000000)
20
+ _mode = device_data[1] & 0b00000111
21
+ _is_aqi_valid = bool(device_data[2] & 0b00000100)
22
+ _child_lock = bool(device_data[2] & 0b00000010)
23
+ _speed = device_data[3] & 0b01111111
24
+ _aqi_level = (device_data[4] & 0b00000110) >> 1
25
+ _aqi_level = AirQualityLevel(_aqi_level).name.lower()
26
+ _work_time = struct.unpack(">H", device_data[5:7])[0]
27
+ _err_code = device_data[7]
28
+
29
+ return {
30
+ "isOn": _isOn,
31
+ "mode": get_air_purifier_mode(_mode, _speed),
32
+ "isAqiValid": _is_aqi_valid,
33
+ "child_lock": _child_lock,
34
+ "speed": _speed,
35
+ "aqi_level": _aqi_level,
36
+ "filter element working time": _work_time,
37
+ "err_code": _err_code,
38
+ "sequence_number": _seq_num,
39
+ }
40
+
41
+
42
+ def get_air_purifier_mode(mode: int, speed: int) -> str | None:
43
+ if mode == 1:
44
+ if 0 <= speed <= 33:
45
+ return "level_1"
46
+ if 34 <= speed <= 66:
47
+ return "level_2"
48
+ return "level_3"
49
+ if 1 < mode <= 4:
50
+ mode += 2
51
+ return AirPurifierMode(mode).name.lower()
52
+ return None
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from ..enum import StrEnum
6
+ from .air_purifier import AirPurifierMode
6
7
  from .fan import FanMode
7
8
 
8
9
  # Preserve old LockStatus export for backwards compatibility
@@ -72,12 +73,14 @@ class SwitchbotModel(StrEnum):
72
73
  K10_VACUUM = "K10+ Vacuum"
73
74
  K10_PRO_VACUUM = "K10+ Pro Vacuum"
74
75
  K10_PRO_COMBO_VACUUM = "K10+ Pro Combo Vacuum"
76
+ AIR_PURIFIER = "Air Purifier"
75
77
 
76
78
 
77
79
  __all__ = [
78
80
  "DEFAULT_RETRY_COUNT",
79
81
  "DEFAULT_RETRY_TIMEOUT",
80
82
  "DEFAULT_SCAN_TIMEOUT",
83
+ "AirPurifierMode",
81
84
  "FanMode",
82
85
  "LockStatus",
83
86
  "SwitchbotAccountConnectionError",
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class AirPurifierMode(Enum):
7
+ LEVEL_1 = 1
8
+ LEVEL_2 = 2
9
+ LEVEL_3 = 3
10
+ AUTO = 4
11
+ PET = 5
12
+ SLEEP = 6
13
+
14
+ @classmethod
15
+ def get_modes(cls) -> list[str]:
16
+ return [mode.name.lower() for mode in cls]
17
+
18
+
19
+ class AirQualityLevel(Enum):
20
+ EXCELLENT = 0
21
+ GOOD = 1
22
+ MODERATE = 2
23
+ UNHEALTHY = 3
@@ -0,0 +1,142 @@
1
+ """Library to handle connection with Switchbot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import struct
7
+ from typing import Any
8
+
9
+ from bleak.backends.device import BLEDevice
10
+
11
+ from ..adv_parsers.air_purifier import get_air_purifier_mode
12
+ from ..const import SwitchbotModel
13
+ from ..const.air_purifier import AirPurifierMode, AirQualityLevel
14
+ from .device import (
15
+ SwitchbotEncryptedDevice,
16
+ SwitchbotSequenceDevice,
17
+ update_after_operation,
18
+ )
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ COMMAND_HEAD = "570f4c"
24
+ COMMAND_TURN_OFF = f"{COMMAND_HEAD}010000"
25
+ COMMAND_TURN_ON = f"{COMMAND_HEAD}010100"
26
+ COMMAND_SET_MODE = {
27
+ AirPurifierMode.LEVEL_1.name.lower(): f"{COMMAND_HEAD}01010100",
28
+ AirPurifierMode.LEVEL_2.name.lower(): f"{COMMAND_HEAD}01010132",
29
+ AirPurifierMode.LEVEL_3.name.lower(): f"{COMMAND_HEAD}01010164",
30
+ AirPurifierMode.AUTO.name.lower(): f"{COMMAND_HEAD}01010200",
31
+ AirPurifierMode.PET.name.lower(): f"{COMMAND_HEAD}01010300",
32
+ AirPurifierMode.SLEEP.name.lower(): f"{COMMAND_HEAD}01010400",
33
+ }
34
+ DEVICE_GET_BASIC_SETTINGS_KEY = "570f4d81"
35
+
36
+
37
+ class SwitchbotAirPurifier(SwitchbotSequenceDevice, SwitchbotEncryptedDevice):
38
+ """Representation of a Switchbot Air Purifier."""
39
+
40
+ def __init__(
41
+ self,
42
+ device: BLEDevice,
43
+ key_id: str,
44
+ encryption_key: str,
45
+ interface: int = 0,
46
+ model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER,
47
+ **kwargs: Any,
48
+ ) -> None:
49
+ super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
50
+
51
+ @classmethod
52
+ async def verify_encryption_key(
53
+ cls,
54
+ device: BLEDevice,
55
+ key_id: str,
56
+ encryption_key: str,
57
+ model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER,
58
+ **kwargs: Any,
59
+ ) -> bool:
60
+ return await super().verify_encryption_key(
61
+ device, key_id, encryption_key, model, **kwargs
62
+ )
63
+
64
+ async def get_basic_info(self) -> dict[str, Any] | None:
65
+ """Get device basic settings."""
66
+ if not (_data := await self._get_basic_info()):
67
+ return None
68
+
69
+ _LOGGER.debug("data: %s", _data)
70
+ isOn = bool(_data[2] & 0b10000000)
71
+ version_info = (_data[2] & 0b00110000) >> 4
72
+ _mode = _data[2] & 0b00000111
73
+ isAqiValid = bool(_data[3] & 0b00000100)
74
+ child_lock = bool(_data[3] & 0b00000010)
75
+ _aqi_level = (_data[4] & 0b00000110) >> 1
76
+ aqi_level = AirQualityLevel(_aqi_level).name.lower()
77
+ speed = _data[6] & 0b01111111
78
+ pm25 = struct.unpack("<H", _data[12:14])[0] & 0xFFF
79
+ firmware = _data[15] / 10.0
80
+ mode = get_air_purifier_mode(_mode, speed)
81
+
82
+ return {
83
+ "isOn": isOn,
84
+ "version_info": version_info,
85
+ "mode": mode,
86
+ "isAqiValid": isAqiValid,
87
+ "child_lock": child_lock,
88
+ "aqi_level": aqi_level,
89
+ "speed": speed,
90
+ "pm25": pm25,
91
+ "firmware": firmware,
92
+ }
93
+
94
+ async def _get_basic_info(self) -> bytes | None:
95
+ """Return basic info of device."""
96
+ _data = await self._send_command(
97
+ key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count
98
+ )
99
+
100
+ if _data in (b"\x07", b"\x00"):
101
+ _LOGGER.error("Unsuccessful, please try again")
102
+ return None
103
+
104
+ return _data
105
+
106
+ @update_after_operation
107
+ async def set_preset_mode(self, preset_mode: str) -> bool:
108
+ """Send command to set air purifier preset_mode."""
109
+ result = await self._send_command(COMMAND_SET_MODE[preset_mode])
110
+ return self._check_command_result(result, 0, {1})
111
+
112
+ @update_after_operation
113
+ async def turn_on(self) -> bool:
114
+ """Turn on the air purifier."""
115
+ result = await self._send_command(COMMAND_TURN_ON)
116
+ return self._check_command_result(result, 0, {1})
117
+
118
+ @update_after_operation
119
+ async def turn_off(self) -> bool:
120
+ """Turn off the air purifier."""
121
+ result = await self._send_command(COMMAND_TURN_OFF)
122
+ return self._check_command_result(result, 0, {1})
123
+
124
+ def get_current_percentage(self) -> Any:
125
+ """Return cached percentage."""
126
+ return self._get_adv_value("speed")
127
+
128
+ def is_on(self) -> bool | None:
129
+ """Return air purifier state from cache."""
130
+ return self._get_adv_value("isOn")
131
+
132
+ def get_current_aqi_level(self) -> Any:
133
+ """Return cached aqi level."""
134
+ return self._get_adv_value("aqi_level")
135
+
136
+ def get_current_pm25(self) -> Any:
137
+ """Return cached pm25."""
138
+ return self._get_adv_value("pm25")
139
+
140
+ def get_current_mode(self) -> Any:
141
+ """Return cached mode."""
142
+ return self._get_adv_value("mode")
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
+ import pytest
5
6
  from bleak.backends.device import BLEDevice
6
7
  from bleak.backends.scanner import AdvertisementData
7
8
 
@@ -2643,3 +2644,187 @@ def test_s10_with_empty_data() -> None:
2643
2644
  rssi=-97,
2644
2645
  active=True,
2645
2646
  )
2647
+
2648
+
2649
+ @pytest.mark.parametrize(
2650
+ ("manufacturer_data", "service_data", "data", "model"),
2651
+ [
2652
+ (
2653
+ b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00",
2654
+ b"7\x00\x00\x95-\x00",
2655
+ {
2656
+ "isOn": True,
2657
+ "mode": "level_3",
2658
+ "isAqiValid": False,
2659
+ "child_lock": False,
2660
+ "speed": 100,
2661
+ "aqi_level": "excellent",
2662
+ "filter element working time": 405,
2663
+ "err_code": 0,
2664
+ "sequence_number": 161,
2665
+ },
2666
+ "7",
2667
+ ),
2668
+ (
2669
+ b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00',
2670
+ b"*\x00\x00\x15\x04\x00",
2671
+ {
2672
+ "isOn": False,
2673
+ "mode": "auto",
2674
+ "isAqiValid": False,
2675
+ "child_lock": False,
2676
+ "speed": 0,
2677
+ "aqi_level": "excellent",
2678
+ "filter element working time": 15,
2679
+ "err_code": 0,
2680
+ "sequence_number": 9,
2681
+ },
2682
+ "*",
2683
+ ),
2684
+ (
2685
+ b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00",
2686
+ b"+\x00\x00\x15\x04\x00",
2687
+ {
2688
+ "isOn": True,
2689
+ "mode": "pet",
2690
+ "isAqiValid": False,
2691
+ "child_lock": False,
2692
+ "speed": 100,
2693
+ "aqi_level": "excellent",
2694
+ "filter element working time": 60000,
2695
+ "err_code": 0,
2696
+ "sequence_number": 11,
2697
+ },
2698
+ "+",
2699
+ ),
2700
+ (
2701
+ b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00",
2702
+ b"8\x00\x00\x95-\x00",
2703
+ {
2704
+ "isOn": True,
2705
+ "mode": "level_2",
2706
+ "isAqiValid": True,
2707
+ "child_lock": False,
2708
+ "speed": 50,
2709
+ "aqi_level": "excellent",
2710
+ "filter element working time": 404,
2711
+ "err_code": 0,
2712
+ "sequence_number": 155,
2713
+ },
2714
+ "8",
2715
+ ),
2716
+ (
2717
+ b"\xcc\x8d\xa2\xa7\xc1\xae\x9e\xa1\x8c\x800\x01\x95\x00\x00",
2718
+ b"8\x00\x00\x95-\x00",
2719
+ {
2720
+ "isOn": True,
2721
+ "mode": "level_1",
2722
+ "isAqiValid": True,
2723
+ "child_lock": False,
2724
+ "speed": 0,
2725
+ "aqi_level": "excellent",
2726
+ "filter element working time": 405,
2727
+ "err_code": 0,
2728
+ "sequence_number": 158,
2729
+ },
2730
+ "8",
2731
+ ),
2732
+ (
2733
+ b"\xcc\x8d\xa2\xa7\xc1\xae\x9e\x05\x8c\x800\x01\x95\x00\x00",
2734
+ b"8\x00\x00\x95-\x00",
2735
+ {
2736
+ "isOn": False,
2737
+ "mode": None,
2738
+ "isAqiValid": True,
2739
+ "child_lock": False,
2740
+ "speed": 0,
2741
+ "aqi_level": "excellent",
2742
+ "filter element working time": 405,
2743
+ "err_code": 0,
2744
+ "sequence_number": 158,
2745
+ },
2746
+ "8",
2747
+ ),
2748
+ ],
2749
+ )
2750
+ def test_air_purifier_active(manufacturer_data, service_data, data, model) -> None:
2751
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2752
+ adv_data = generate_advertisement_data(
2753
+ manufacturer_data={2409: manufacturer_data},
2754
+ service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": service_data},
2755
+ rssi=-97,
2756
+ )
2757
+ result = parse_advertisement_data(ble_device, adv_data)
2758
+ assert result == SwitchBotAdvertisement(
2759
+ address="aa:bb:cc:dd:ee:ff",
2760
+ data={
2761
+ "rawAdvData": service_data,
2762
+ "data": data,
2763
+ "isEncrypted": False,
2764
+ "model": model,
2765
+ "modelFriendlyName": "Air Purifier",
2766
+ "modelName": SwitchbotModel.AIR_PURIFIER,
2767
+ },
2768
+ device=ble_device,
2769
+ rssi=-97,
2770
+ active=True,
2771
+ )
2772
+
2773
+
2774
+ def test_air_purifier_passive() -> None:
2775
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2776
+ adv_data = generate_advertisement_data(
2777
+ manufacturer_data={
2778
+ 2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00"
2779
+ },
2780
+ rssi=-97,
2781
+ )
2782
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.AIR_PURIFIER)
2783
+ assert result == SwitchBotAdvertisement(
2784
+ address="aa:bb:cc:dd:ee:ff",
2785
+ data={
2786
+ "rawAdvData": None,
2787
+ "data": {
2788
+ "isOn": True,
2789
+ "mode": "level_3",
2790
+ "isAqiValid": False,
2791
+ "child_lock": False,
2792
+ "speed": 100,
2793
+ "aqi_level": "excellent",
2794
+ "filter element working time": 405,
2795
+ "err_code": 0,
2796
+ "sequence_number": 161,
2797
+ },
2798
+ "isEncrypted": False,
2799
+ "model": "8",
2800
+ "modelFriendlyName": "Air Purifier",
2801
+ "modelName": SwitchbotModel.AIR_PURIFIER,
2802
+ },
2803
+ device=ble_device,
2804
+ rssi=-97,
2805
+ active=False,
2806
+ )
2807
+
2808
+
2809
+ def test_air_purifier_with_empty_data() -> None:
2810
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
2811
+ adv_data = generate_advertisement_data(
2812
+ manufacturer_data={2409: None},
2813
+ service_data={
2814
+ "0000fd3d-0000-1000-8000-00805f9b34fb": b"+\x00\x00\x15\x04\x00",
2815
+ },
2816
+ rssi=-97,
2817
+ )
2818
+ result = parse_advertisement_data(ble_device, adv_data)
2819
+ assert result == SwitchBotAdvertisement(
2820
+ address="aa:bb:cc:dd:ee:ff",
2821
+ data={
2822
+ "rawAdvData": b"+\x00\x00\x15\x04\x00",
2823
+ "data": {},
2824
+ "isEncrypted": False,
2825
+ "model": "+",
2826
+ },
2827
+ device=ble_device,
2828
+ rssi=-97,
2829
+ active=True,
2830
+ )
@@ -0,0 +1,231 @@
1
+ from unittest.mock import AsyncMock, MagicMock, patch
2
+
3
+ import pytest
4
+ from bleak.backends.device import BLEDevice
5
+
6
+ from switchbot import SwitchBotAdvertisement, SwitchbotEncryptedDevice, SwitchbotModel
7
+ from switchbot.const.air_purifier import AirPurifierMode
8
+ from switchbot.devices import air_purifier
9
+
10
+ from .test_adv_parser import generate_ble_device
11
+
12
+ common_params = [
13
+ (b"7\x00\x00\x95-\x00", "7"),
14
+ (b"*\x00\x00\x15\x04\x00", "*"),
15
+ (b"+\x00\x00\x15\x04\x00", "+"),
16
+ (b"8\x00\x00\x95-\x00", "8"),
17
+ ]
18
+
19
+
20
+ def create_device_for_command_testing(
21
+ rawAdvData: bytes, model: str, init_data: dict | None = None
22
+ ):
23
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
24
+ device = air_purifier.SwitchbotAirPurifier(
25
+ ble_device, "ff", "ffffffffffffffffffffffffffffffff"
26
+ )
27
+ device.update_from_advertisement(
28
+ make_advertisement_data(ble_device, rawAdvData, model, init_data)
29
+ )
30
+ device._send_command = AsyncMock()
31
+ device._check_command_result = MagicMock()
32
+ device.update = AsyncMock()
33
+ return device
34
+
35
+
36
+ def make_advertisement_data(
37
+ ble_device: BLEDevice, rawAdvData: bytes, model: str, init_data: dict | None = None
38
+ ):
39
+ """Set advertisement data with defaults."""
40
+ if init_data is None:
41
+ init_data = {}
42
+
43
+ return SwitchBotAdvertisement(
44
+ address="aa:bb:cc:dd:ee:ff",
45
+ data={
46
+ "rawAdvData": rawAdvData,
47
+ "data": {
48
+ "isOn": True,
49
+ "mode": "level_3",
50
+ "isAqiValid": False,
51
+ "child_lock": False,
52
+ "speed": 100,
53
+ "aqi_level": "excellent",
54
+ "filter element working time": 405,
55
+ "err_code": 0,
56
+ "sequence_number": 161,
57
+ }
58
+ | init_data,
59
+ "isEncrypted": False,
60
+ "model": model,
61
+ "modelFriendlyName": "Air Purifier",
62
+ "modelName": SwitchbotModel.AIR_PURIFIER,
63
+ },
64
+ device=ble_device,
65
+ rssi=-80,
66
+ active=True,
67
+ )
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ @pytest.mark.parametrize(
72
+ ("rawAdvData", "model"),
73
+ common_params,
74
+ )
75
+ @pytest.mark.parametrize(
76
+ "pm25",
77
+ [150],
78
+ )
79
+ async def test_status_from_proceess_adv(rawAdvData, model, pm25):
80
+ device = create_device_for_command_testing(rawAdvData, model, {"pm25": pm25})
81
+ assert device.get_current_percentage() == 100
82
+ assert device.is_on() is True
83
+ assert device.get_current_aqi_level() == "excellent"
84
+ assert device.get_current_mode() == "level_3"
85
+ assert device.get_current_pm25() == 150
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ @pytest.mark.parametrize(
90
+ ("rawAdvData", "model"),
91
+ common_params,
92
+ )
93
+ async def test_get_basic_info_returns_none_when_no_data(rawAdvData, model):
94
+ device = create_device_for_command_testing(rawAdvData, model)
95
+ device._get_basic_info = AsyncMock(return_value=None)
96
+
97
+ assert await device.get_basic_info() is None
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ @pytest.mark.parametrize(
102
+ ("rawAdvData", "model"),
103
+ common_params,
104
+ )
105
+ @pytest.mark.parametrize(
106
+ "mode", ["level_1", "level_2", "level_3", "auto", "pet", "sleep"]
107
+ )
108
+ async def test_set_preset_mode(rawAdvData, model, mode):
109
+ device = create_device_for_command_testing(rawAdvData, model, {"mode": mode})
110
+ await device.set_preset_mode(mode)
111
+ assert device.get_current_mode() == mode
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ @pytest.mark.parametrize(
116
+ ("rawAdvData", "model"),
117
+ common_params,
118
+ )
119
+ async def test_turn_on(rawAdvData, model):
120
+ device = create_device_for_command_testing(rawAdvData, model, {"isOn": True})
121
+ await device.turn_on()
122
+ assert device.is_on() is True
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ @pytest.mark.parametrize(
127
+ ("rawAdvData", "model"),
128
+ common_params,
129
+ )
130
+ async def test_turn_off(rawAdvData, model):
131
+ device = create_device_for_command_testing(rawAdvData, model, {"isOn": False})
132
+ await device.turn_off()
133
+ assert device.is_on() is False
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ @pytest.mark.parametrize(
138
+ ("rawAdvData", "model"),
139
+ common_params,
140
+ )
141
+ @pytest.mark.parametrize(
142
+ ("response", "expected"),
143
+ [
144
+ (b"\x00", None),
145
+ (b"\x07", None),
146
+ (b"\x01\x02\x03", b"\x01\x02\x03"),
147
+ ],
148
+ )
149
+ async def test__get_basic_info(rawAdvData, model, response, expected):
150
+ device = create_device_for_command_testing(rawAdvData, model)
151
+ device._send_command = AsyncMock(return_value=response)
152
+ result = await device._get_basic_info()
153
+ assert result == expected
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ @pytest.mark.parametrize(
158
+ ("rawAdvData", "model"),
159
+ common_params,
160
+ )
161
+ @pytest.mark.parametrize(
162
+ ("basic_info", "result"),
163
+ [
164
+ (
165
+ bytearray(
166
+ b"\x01\xa7\xe9\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\xf0\x00\x00\x17"
167
+ ),
168
+ [True, 2, "level_2", True, False, "excellent", 50, 240, 2.3],
169
+ ),
170
+ (
171
+ bytearray(
172
+ b"\x01\xa8\xec\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\xf0\x00\x00\x17"
173
+ ),
174
+ [True, 2, "sleep", True, False, "excellent", 50, 240, 2.3],
175
+ ),
176
+ ],
177
+ )
178
+ async def test_get_basic_info(rawAdvData, model, basic_info, result):
179
+ device = create_device_for_command_testing(rawAdvData, model)
180
+
181
+ async def mock_get_basic_info():
182
+ return basic_info
183
+
184
+ device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
185
+
186
+ info = await device.get_basic_info()
187
+ assert info["isOn"] == result[0]
188
+ assert info["version_info"] == result[1]
189
+ assert info["mode"] == result[2]
190
+ assert info["isAqiValid"] == result[3]
191
+ assert info["child_lock"] == result[4]
192
+ assert info["aqi_level"] == result[5]
193
+ assert info["speed"] == result[6]
194
+ assert info["pm25"] == result[7]
195
+ assert info["firmware"] == result[8]
196
+
197
+
198
+ @pytest.mark.asyncio
199
+ @patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
200
+ async def test_verify_encryption_key(mock_parent_verify):
201
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
202
+ key_id = "ff"
203
+ encryption_key = "ffffffffffffffffffffffffffffffff"
204
+
205
+ mock_parent_verify.return_value = True
206
+
207
+ result = await air_purifier.SwitchbotAirPurifier.verify_encryption_key(
208
+ device=ble_device,
209
+ key_id=key_id,
210
+ encryption_key=encryption_key,
211
+ )
212
+
213
+ mock_parent_verify.assert_awaited_once_with(
214
+ ble_device,
215
+ key_id,
216
+ encryption_key,
217
+ SwitchbotModel.AIR_PURIFIER,
218
+ )
219
+
220
+ assert result is True
221
+
222
+
223
+ def test_get_modes():
224
+ assert AirPurifierMode.get_modes() == [
225
+ "level_1",
226
+ "level_2",
227
+ "level_3",
228
+ "auto",
229
+ "pet",
230
+ "sleep",
231
+ ]
File without changes
File without changes
File without changes
File without changes