PySwitchbot 0.51.0__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.
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/PKG-INFO +1 -1
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/PKG-INFO +1 -1
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/SOURCES.txt +2 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/setup.py +1 -1
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/__init__.py +2 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parser.py +16 -1
- pyswitchbot-0.52.0/switchbot/adv_parsers/relay_switch.py +31 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/const.py +2 -0
- pyswitchbot-0.52.0/switchbot/devices/relay_switch.py +155 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/tests/test_adv_parser.py +0 -37
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/LICENSE +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/MANIFEST.in +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/dependency_links.txt +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/requires.txt +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/PySwitchbot.egg-info/top_level.txt +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/README.md +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/setup.cfg +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/__init__.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/blind_tilt.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/bot.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/bulb.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/ceiling_light.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/contact.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/curtain.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/hub2.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/humidifier.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/light_strip.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/lock.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/meter.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/motion.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/adv_parsers/plug.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/api_config.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/__init__.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/base_cover.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/base_light.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/blind_tilt.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/bot.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/bulb.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/ceiling_light.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/contact.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/curtain.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/device.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/humidifier.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/light_strip.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/lock.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/meter.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/motion.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/devices/plug.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/discovery.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/enum.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/switchbot/models.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/tests/test_base_cover.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/tests/test_blind_tilt.py +0 -0
- {pyswitchbot-0.51.0 → pyswitchbot-0.52.0}/tests/test_curtain.py +0 -0
|
@@ -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
|
|
@@ -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 = {
|
|
@@ -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
|
+
}
|
|
@@ -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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|