PySwitchbot 0.50.1__tar.gz → 0.52.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 (54) hide show
  1. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/PKG-INFO +1 -1
  2. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
  3. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/SOURCES.txt +2 -0
  4. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/setup.py +1 -1
  5. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/__init__.py +2 -0
  6. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parser.py +16 -1
  7. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/meter.py +9 -36
  8. pyswitchbot-0.52.0/switchbot/adv_parsers/relay_switch.py +31 -0
  9. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/const.py +2 -0
  10. pyswitchbot-0.52.0/switchbot/devices/relay_switch.py +155 -0
  11. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/tests/test_adv_parser.py +2 -37
  12. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/LICENSE +0 -0
  13. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/MANIFEST.in +0 -0
  14. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  15. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/requires.txt +0 -0
  16. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/top_level.txt +0 -0
  17. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/README.md +0 -0
  18. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/setup.cfg +0 -0
  19. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/__init__.py +0 -0
  20. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
  21. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/bot.py +0 -0
  22. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/bulb.py +0 -0
  23. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
  24. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/contact.py +0 -0
  25. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/curtain.py +0 -0
  26. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/hub2.py +0 -0
  27. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/humidifier.py +0 -0
  28. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/light_strip.py +0 -0
  29. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/lock.py +0 -0
  30. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/motion.py +0 -0
  31. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/adv_parsers/plug.py +0 -0
  32. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/api_config.py +0 -0
  33. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/__init__.py +0 -0
  34. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/base_cover.py +0 -0
  35. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/base_light.py +0 -0
  36. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/blind_tilt.py +0 -0
  37. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/bot.py +0 -0
  38. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/bulb.py +0 -0
  39. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/ceiling_light.py +0 -0
  40. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/contact.py +0 -0
  41. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/curtain.py +0 -0
  42. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/device.py +0 -0
  43. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/humidifier.py +0 -0
  44. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/light_strip.py +0 -0
  45. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/lock.py +0 -0
  46. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/meter.py +0 -0
  47. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/motion.py +0 -0
  48. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/devices/plug.py +0 -0
  49. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/discovery.py +0 -0
  50. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/enum.py +0 -0
  51. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/switchbot/models.py +0 -0
  52. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/tests/test_base_cover.py +0 -0
  53. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/tests/test_blind_tilt.py +0 -0
  54. {pyswitchbot-0.50.1 → pyswitchbot-0.52.0}/tests/test_curtain.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PySwitchbot
3
- Version: 0.50.1
3
+ Version: 0.52.0
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/Danielhiversen/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PySwitchbot
3
- Version: 0.50.1
3
+ Version: 0.52.0
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/Danielhiversen/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -28,6 +28,7 @@ switchbot/adv_parsers/lock.py
28
28
  switchbot/adv_parsers/meter.py
29
29
  switchbot/adv_parsers/motion.py
30
30
  switchbot/adv_parsers/plug.py
31
+ switchbot/adv_parsers/relay_switch.py
31
32
  switchbot/devices/__init__.py
32
33
  switchbot/devices/base_cover.py
33
34
  switchbot/devices/base_light.py
@@ -44,6 +45,7 @@ switchbot/devices/lock.py
44
45
  switchbot/devices/meter.py
45
46
  switchbot/devices/motion.py
46
47
  switchbot/devices/plug.py
48
+ switchbot/devices/relay_switch.py
47
49
  tests/test_adv_parser.py
48
50
  tests/test_base_cover.py
49
51
  tests/test_blind_tilt.py
@@ -10,7 +10,7 @@ setup(
10
10
  "cryptography>=39.0.0",
11
11
  "pyOpenSSL>=23.0.0",
12
12
  ],
13
- version="0.50.1",
13
+ version="0.52.0",
14
14
  description="A library to communicate with Switchbot",
15
15
  author="Daniel Hjelseth Hoyer",
16
16
  url="https://github.com/Danielhiversen/pySwitchbot/",
@@ -26,6 +26,7 @@ from .devices.humidifier import SwitchbotHumidifier
26
26
  from .devices.light_strip import SwitchbotLightStrip
27
27
  from .devices.lock import SwitchbotLock
28
28
  from .devices.plug import SwitchbotPlugMini
29
+ from .devices.relay_switch import SwitchbotRelaySwitch
29
30
  from .discovery import GetSwitchbotDevices
30
31
  from .models import SwitchBotAdvertisement
31
32
 
@@ -54,4 +55,5 @@ __all__ = [
54
55
  "SwitchbotModel",
55
56
  "SwitchbotLock",
56
57
  "SwitchbotBlindTilt",
58
+ "SwitchbotRelaySwitch",
57
59
  ]
@@ -23,6 +23,10 @@ from .adv_parsers.lock import process_wolock, process_wolock_pro
23
23
  from .adv_parsers.meter import process_wosensorth, process_wosensorth_c
24
24
  from .adv_parsers.motion import process_wopresence
25
25
  from .adv_parsers.plug import process_woplugmini
26
+ from .adv_parsers.relay_switch import (
27
+ process_worelay_switch_1plus,
28
+ process_worelay_switch_1pm,
29
+ )
26
30
  from .const import SwitchbotModel
27
31
  from .models import SwitchBotAdvertisement
28
32
 
@@ -69,7 +73,6 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
69
73
  "modelFriendlyName": "Light Strip",
70
74
  "func": process_wostrip,
71
75
  "manufacturer_id": 2409,
72
- "manufacturer_data_length": 16,
73
76
  },
74
77
  "{": {
75
78
  "modelName": SwitchbotModel.CURTAIN,
@@ -174,6 +177,18 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
174
177
  "func": process_woblindtilt,
175
178
  "manufacturer_id": 2409,
176
179
  },
180
+ "<": {
181
+ "modelName": SwitchbotModel.RelaySwitch1PM,
182
+ "modelFriendlyName": "Relay Switch 1PM",
183
+ "func": process_worelay_switch_1pm,
184
+ "manufacturer_id": 2409,
185
+ },
186
+ ";": {
187
+ "modelName": SwitchbotModel.RelaySwitch1Plus,
188
+ "modelFriendlyName": "Relay Switch 1",
189
+ "func": process_worelay_switch_1plus,
190
+ "manufacturer_id": 2409,
191
+ },
177
192
  }
178
193
 
179
194
  _SWITCHBOT_MODEL_TO_CHAR = {
@@ -1,13 +1,16 @@
1
1
  """Meter parser."""
2
2
  from __future__ import annotations
3
3
 
4
+ import struct
4
5
  from typing import Any
5
6
 
7
+ CO2_UNPACK = struct.Struct(">H").unpack_from
8
+
6
9
 
7
10
  def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
8
11
  """Process woSensorTH/Temp sensor services data."""
9
- temp_data = None
10
- battery = None
12
+ temp_data: bytes | None = None
13
+ battery: bytes | None = None
11
14
 
12
15
  if mfr_data:
13
16
  temp_data = mfr_data[8:11]
@@ -45,38 +48,8 @@ def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str,
45
48
 
46
49
  def process_wosensorth_c(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]:
47
50
  """Process woSensorTH/Temp sensor services data with CO2."""
48
- temp_data = None
49
- battery = None
50
-
51
- if mfr_data:
52
- temp_data = mfr_data[8:11]
53
-
54
- if data:
55
- if not temp_data:
56
- temp_data = data[3:6]
57
- battery = data[2] & 0b01111111
58
-
59
- if not temp_data:
60
- return {}
61
-
62
- _temp_sign = 1 if temp_data[1] & 0b10000000 else -1
63
- _temp_c = _temp_sign * (
64
- (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
65
- )
66
- _temp_f = (_temp_c * 9 / 5) + 32
67
- _temp_f = (_temp_f * 10) / 10
68
- humidity = temp_data[2] & 0b01111111
69
-
70
- if _temp_c == 0 and humidity == 0 and battery == 0:
71
- return {}
72
-
73
- _wosensorth_data = {
74
- # Data should be flat, but we keep the original structure for now
75
- "temp": {"c": _temp_c, "f": _temp_f},
76
- "temperature": _temp_c,
77
- "fahrenheit": bool(temp_data[2] & 0b10000000),
78
- "humidity": humidity,
79
- "battery": battery,
80
- }
81
-
51
+ _wosensorth_data = process_wosensorth(data, mfr_data)
52
+ if _wosensorth_data and mfr_data and len(mfr_data) >= 15:
53
+ co2_data = mfr_data[13:15]
54
+ _wosensorth_data["co2"] = CO2_UNPACK(co2_data)[0]
82
55
  return _wosensorth_data
@@ -0,0 +1,31 @@
1
+ """Relay Switch adv parser."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def process_worelay_switch_1pm(
6
+ data: bytes | None, mfr_data: bytes | None
7
+ ) -> dict[str, bool | int]:
8
+ """Process WoStrip services data."""
9
+ if mfr_data is None:
10
+ return {}
11
+ return {
12
+ "switchMode": True, # for compatibility, useless
13
+ "sequence_number": mfr_data[6],
14
+ "isOn": bool(mfr_data[7] & 0b10000000),
15
+ "power": ((mfr_data[10] << 8) + mfr_data[11]) / 10,
16
+ "voltage": 0,
17
+ "current": 0,
18
+ }
19
+
20
+
21
+ def process_worelay_switch_1plus(
22
+ data: bytes | None, mfr_data: bytes | None
23
+ ) -> dict[str, bool | int]:
24
+ """Process WoStrip services data."""
25
+ if mfr_data is None:
26
+ return {}
27
+ return {
28
+ "switchMode": True, # for compatibility, useless
29
+ "sequence_number": mfr_data[6],
30
+ "isOn": bool(mfr_data[7] & 0b10000000),
31
+ }
@@ -52,6 +52,8 @@ class SwitchbotModel(StrEnum):
52
52
  LOCK_PRO = "WoLockPro"
53
53
  BLIND_TILT = "WoBlindTilt"
54
54
  HUB2 = "WoHub2"
55
+ RelaySwitch1PM = "Relay Switch 1PM"
56
+ RelaySwitch1Plus = "Relay Switch 1"
55
57
 
56
58
 
57
59
  class LockStatus(Enum):
@@ -0,0 +1,155 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ from bleak.backends.device import BLEDevice
5
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
6
+
7
+ from ..const import SwitchbotModel
8
+ from .device import SwitchbotSequenceDevice
9
+
10
+ COMMAND_HEADER = "57"
11
+ COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
12
+ COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f70010000"
13
+ COMMAND_TURN_ON = f"{COMMAND_HEADER}0f70010100"
14
+ COMMAND_TOGGLE = f"{COMMAND_HEADER}0f70010200"
15
+ COMMAND_GET_VOLTAGE_AND_CURRENT = f"{COMMAND_HEADER}0f7106000000"
16
+ PASSIVE_POLL_INTERVAL = 1 * 60
17
+
18
+
19
+ class SwitchbotRelaySwitch(SwitchbotSequenceDevice):
20
+ """Representation of a Switchbot relay switch 1pm."""
21
+
22
+ def __init__(
23
+ self,
24
+ device: BLEDevice,
25
+ key_id: str,
26
+ encryption_key: str,
27
+ interface: int = 0,
28
+ model: SwitchbotModel = SwitchbotModel.RelaySwitch1PM,
29
+ **kwargs: Any,
30
+ ) -> None:
31
+ if len(key_id) == 0:
32
+ raise ValueError("key_id is missing")
33
+ elif len(key_id) != 2:
34
+ raise ValueError("key_id is invalid")
35
+ if len(encryption_key) == 0:
36
+ raise ValueError("encryption_key is missing")
37
+ elif len(encryption_key) != 32:
38
+ raise ValueError("encryption_key is invalid")
39
+ self._iv = None
40
+ self._cipher = None
41
+ self._key_id = key_id
42
+ self._encryption_key = bytearray.fromhex(encryption_key)
43
+ self._model: SwitchbotModel = model
44
+ super().__init__(device, None, interface, **kwargs)
45
+
46
+ async def update(self, interface: int | None = None) -> None:
47
+ """Update state of device."""
48
+ if info := await self.get_voltage_and_current():
49
+ self._last_full_update = time.monotonic()
50
+ self._update_parsed_data(info)
51
+ self._fire_callbacks()
52
+
53
+ async def get_voltage_and_current(self) -> dict[str, Any] | None:
54
+ """Get voltage and current because advtisement don't have these"""
55
+ result = await self._send_command(COMMAND_GET_VOLTAGE_AND_CURRENT)
56
+ ok = self._check_command_result(result, 0, {1})
57
+ if ok:
58
+ return {
59
+ "voltage": (result[9] << 8) + result[10],
60
+ "current": (result[11] << 8) + result[12],
61
+ }
62
+ return None
63
+
64
+ def poll_needed(self, seconds_since_last_poll: float | None) -> bool:
65
+ """Return if device needs polling."""
66
+ if (
67
+ seconds_since_last_poll is not None
68
+ and seconds_since_last_poll < PASSIVE_POLL_INTERVAL
69
+ ):
70
+ return False
71
+ time_since_last_full_update = time.monotonic() - self._last_full_update
72
+ if time_since_last_full_update < PASSIVE_POLL_INTERVAL:
73
+ return False
74
+ return True
75
+
76
+ async def turn_on(self) -> bool:
77
+ """Turn device on."""
78
+ result = await self._send_command(COMMAND_TURN_ON)
79
+ ok = self._check_command_result(result, 0, {1})
80
+ if ok:
81
+ self._override_state({"isOn": True})
82
+ self._fire_callbacks()
83
+ return ok
84
+
85
+ async def turn_off(self) -> bool:
86
+ """Turn device off."""
87
+ result = await self._send_command(COMMAND_TURN_OFF)
88
+ ok = self._check_command_result(result, 0, {1})
89
+ if ok:
90
+ self._override_state({"isOn": False})
91
+ self._fire_callbacks()
92
+ return ok
93
+
94
+ async def async_toggle(self, **kwargs) -> bool:
95
+ """Toggle device."""
96
+ result = await self._send_command(COMMAND_TOGGLE)
97
+ status = self._check_command_result(result, 0, {1})
98
+ return status
99
+
100
+ def is_on(self) -> bool | None:
101
+ """Return switch state from cache."""
102
+ return self._get_adv_value("isOn")
103
+
104
+ async def _send_command(
105
+ self, key: str, retry: int | None = None, encrypt: bool = True
106
+ ) -> bytes | None:
107
+ if not encrypt:
108
+ return await super()._send_command(key[:2] + "000000" + key[2:], retry)
109
+
110
+ result = await self._ensure_encryption_initialized()
111
+ if not result:
112
+ return None
113
+
114
+ encrypted = (
115
+ key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:])
116
+ )
117
+ result = await super()._send_command(encrypted, retry)
118
+ return result[:1] + self._decrypt(result[4:])
119
+
120
+ async def _ensure_encryption_initialized(self) -> bool:
121
+ if self._iv is not None:
122
+ return True
123
+
124
+ result = await self._send_command(
125
+ COMMAND_GET_CK_IV + self._key_id, encrypt=False
126
+ )
127
+ ok = self._check_command_result(result, 0, {1})
128
+ if ok:
129
+ self._iv = result[4:]
130
+
131
+ return ok
132
+
133
+ async def _execute_disconnect(self) -> None:
134
+ await super()._execute_disconnect()
135
+ self._iv = None
136
+ self._cipher = None
137
+
138
+ def _get_cipher(self) -> Cipher:
139
+ if self._cipher is None:
140
+ self._cipher = Cipher(
141
+ algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
142
+ )
143
+ return self._cipher
144
+
145
+ def _encrypt(self, data: str) -> str:
146
+ if len(data) == 0:
147
+ return ""
148
+ encryptor = self._get_cipher().encryptor()
149
+ return (encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()).hex()
150
+
151
+ def _decrypt(self, data: bytearray) -> bytes:
152
+ if len(data) == 0:
153
+ return b""
154
+ decryptor = self._get_cipher().decryptor()
155
+ return decryptor.update(data) + decryptor.finalize()
@@ -807,43 +807,6 @@ def test_bulb_active():
807
807
  )
808
808
 
809
809
 
810
- def test_lightstrip_passive():
811
- """Test parsing lightstrip as passive."""
812
- ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
813
- adv_data = generate_advertisement_data(
814
- manufacturer_data={
815
- 2409: b"`U\xf9(\xe5\x96\x00d\x02\xb0\x00\x00\x00\x00\x00\x00"
816
- },
817
- service_data={},
818
- tx_power=-127,
819
- rssi=-50,
820
- )
821
- result = parse_advertisement_data(ble_device, adv_data)
822
- assert result == SwitchBotAdvertisement(
823
- address="aa:bb:cc:dd:ee:ff",
824
- data={
825
- "data": {
826
- "brightness": 100,
827
- "color_mode": 2,
828
- "delay": False,
829
- "isOn": False,
830
- "loop_index": 0,
831
- "preset": False,
832
- "sequence_number": 0,
833
- "speed": 48,
834
- },
835
- "isEncrypted": False,
836
- "model": "r",
837
- "modelFriendlyName": "Light Strip",
838
- "modelName": SwitchbotModel.LIGHT_STRIP,
839
- "rawAdvData": None,
840
- },
841
- device=ble_device,
842
- rssi=-50,
843
- active=False,
844
- )
845
-
846
-
847
810
  def test_wosensor_passive_and_active():
848
811
  """Test parsing wosensor as passive with active data as well."""
849
812
  ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
@@ -1640,6 +1603,7 @@ def test_meter_pro_c_active() -> None:
1640
1603
  "humidity": 36,
1641
1604
  "temp": {"c": 27.7, "f": 81.86},
1642
1605
  "temperature": 27.7,
1606
+ "co2": 725,
1643
1607
  },
1644
1608
  "isEncrypted": False,
1645
1609
  "model": "5",
@@ -1671,6 +1635,7 @@ def test_meter_pro_c_passive() -> None:
1671
1635
  "humidity": 36,
1672
1636
  "temp": {"c": 27.7, "f": 81.86},
1673
1637
  "temperature": 27.7,
1638
+ "co2": 725,
1674
1639
  },
1675
1640
  "isEncrypted": False,
1676
1641
  "model": "5",
File without changes
File without changes
File without changes
File without changes