wyzeapy 0.5.28__py3-none-any.whl → 0.5.30__py3-none-any.whl
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.
- wyzeapy/__init__.py +277 -45
- wyzeapy/const.py +9 -4
- wyzeapy/crypto.py +31 -2
- wyzeapy/exceptions.py +11 -8
- wyzeapy/payload_factory.py +205 -170
- wyzeapy/services/__init__.py +3 -0
- wyzeapy/services/base_service.py +406 -212
- wyzeapy/services/bulb_service.py +67 -63
- wyzeapy/services/camera_service.py +136 -50
- wyzeapy/services/hms_service.py +8 -17
- wyzeapy/services/irrigation_service.py +189 -0
- wyzeapy/services/lock_service.py +5 -3
- wyzeapy/services/sensor_service.py +32 -11
- wyzeapy/services/switch_service.py +6 -2
- wyzeapy/services/thermostat_service.py +29 -15
- wyzeapy/services/update_manager.py +38 -11
- wyzeapy/services/wall_switch_service.py +18 -8
- wyzeapy/tests/test_irrigation_service.py +536 -0
- wyzeapy/types.py +29 -12
- wyzeapy/utils.py +98 -17
- wyzeapy/wyze_auth_lib.py +195 -37
- wyzeapy-0.5.30.dist-info/METADATA +13 -0
- wyzeapy-0.5.30.dist-info/RECORD +24 -0
- {wyzeapy-0.5.28.dist-info → wyzeapy-0.5.30.dist-info}/WHEEL +1 -1
- wyzeapy/tests/test_bulb_service.py +0 -135
- wyzeapy/tests/test_camera_service.py +0 -180
- wyzeapy/tests/test_hms_service.py +0 -90
- wyzeapy/tests/test_lock_service.py +0 -114
- wyzeapy/tests/test_sensor_service.py +0 -159
- wyzeapy/tests/test_switch_service.py +0 -138
- wyzeapy/tests/test_thermostat_service.py +0 -136
- wyzeapy/tests/test_wall_switch_service.py +0 -161
- wyzeapy-0.5.28.dist-info/LICENSES/GPL-3.0-only.txt +0 -232
- wyzeapy-0.5.28.dist-info/METADATA +0 -16
- wyzeapy-0.5.28.dist-info/RECORD +0 -31
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from .base_service import BaseService
|
|
6
|
+
from ..types import Device, IrrigationProps, DeviceTypes
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CropType(Enum):
|
|
12
|
+
COOL_SEASON_GRASS = "cool_season_grass"
|
|
13
|
+
WARM_SEASON_GRASS = "warm_season_grass"
|
|
14
|
+
SHRUBS = "shrubs"
|
|
15
|
+
TREES = "trees"
|
|
16
|
+
ANNUALS = "annuals"
|
|
17
|
+
PERENNIALS = "perennials"
|
|
18
|
+
XERISCAPE = "xeriscape"
|
|
19
|
+
GARDEN = "garden"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExposureType(Enum):
|
|
23
|
+
LOTS_OF_SUN = "lots_of_sun"
|
|
24
|
+
SOME_SHADE = "some_shade"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NozzleType(Enum):
|
|
28
|
+
FIXED_SPRAY_HEAD = "fixed_spray_head"
|
|
29
|
+
ROTOR_HEAD = "rotor_head"
|
|
30
|
+
ROTARY_NOZZLE = "rotary_nozzle"
|
|
31
|
+
MISTER = "mister"
|
|
32
|
+
BUBBLER = "bubbler"
|
|
33
|
+
EMITTER = "emitter"
|
|
34
|
+
DRIP_LINE = "drip_line"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SlopeType(Enum):
|
|
38
|
+
FLAT = "flat"
|
|
39
|
+
SLIGHT = "slight"
|
|
40
|
+
MODERATE = "moderate"
|
|
41
|
+
STEEP = "steep"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SoilType(Enum):
|
|
45
|
+
CLAY_LOAM = 'clay_loam'
|
|
46
|
+
CLAY = 'clay'
|
|
47
|
+
SILTY_CLAY = 'silty_clay'
|
|
48
|
+
LOAM = 'loam'
|
|
49
|
+
SANDY_LOAM = 'sandy_loam'
|
|
50
|
+
LOAMY_SAND = 'loamy_sand'
|
|
51
|
+
SAND = 'sand'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Zone:
|
|
55
|
+
"""Represents a single irrigation zone."""
|
|
56
|
+
def __init__(self, dictionary: Dict[Any, Any]):
|
|
57
|
+
self.zone_number: int = dictionary.get('zone_number', 1)
|
|
58
|
+
self.name: str = dictionary.get('name', 'Zone 1')
|
|
59
|
+
self.enabled: bool = dictionary.get('enabled', True)
|
|
60
|
+
self.zone_id: str = dictionary.get('zone_id', 'zone_id')
|
|
61
|
+
self.smart_duration: int = dictionary.get('smart_duration', 600)
|
|
62
|
+
|
|
63
|
+
# this quickrun duration is used only for running a zone manually
|
|
64
|
+
# the wyze api has no such value, but takes a duration as part of the api call
|
|
65
|
+
# the default value grabs the wyze smart_duration but all further updates
|
|
66
|
+
# are managed through the home assistant state
|
|
67
|
+
self.quickrun_duration: int = dictionary.get('smart_duration', 600)
|
|
68
|
+
|
|
69
|
+
class Irrigation(Device):
|
|
70
|
+
def __init__(self, dictionary: Dict[Any, Any]):
|
|
71
|
+
super().__init__(dictionary)
|
|
72
|
+
|
|
73
|
+
# the below comes from the get_iot_prop call
|
|
74
|
+
self.RSSI: int = 0
|
|
75
|
+
self.IP: str = "192.168.1.100"
|
|
76
|
+
self.sn: str = "SN123456789"
|
|
77
|
+
self.available: bool = False
|
|
78
|
+
self.ssid: str = "ssid"
|
|
79
|
+
# the below comes from the device_info call
|
|
80
|
+
self.zones: List[Zone] = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class IrrigationService(BaseService):
|
|
84
|
+
async def update(self, irrigation: Irrigation) -> Irrigation:
|
|
85
|
+
"""Update the irrigation device with latest data from Wyze API."""
|
|
86
|
+
# Get IoT properties
|
|
87
|
+
properties = (await self.get_iot_prop(irrigation))['data']['props']
|
|
88
|
+
|
|
89
|
+
# Update device properties
|
|
90
|
+
irrigation.RSSI = properties.get('RSSI', -65)
|
|
91
|
+
irrigation.IP = properties.get('IP', '192.168.1.100')
|
|
92
|
+
irrigation.sn = properties.get('sn', 'SN123456789')
|
|
93
|
+
irrigation.ssid = properties.get('ssid', 'ssid')
|
|
94
|
+
irrigation.available = (properties.get(IrrigationProps.IOT_STATE.value) == "connected")
|
|
95
|
+
|
|
96
|
+
# Get zones
|
|
97
|
+
zones = (await self.get_zone_by_device(irrigation))['data']['zones']
|
|
98
|
+
|
|
99
|
+
# Update zones
|
|
100
|
+
irrigation.zones = []
|
|
101
|
+
for zone in zones:
|
|
102
|
+
irrigation.zones.append(Zone(zone))
|
|
103
|
+
|
|
104
|
+
return irrigation
|
|
105
|
+
|
|
106
|
+
async def update_device_props(self, irrigation: Irrigation) -> Irrigation:
|
|
107
|
+
"""Update the irrigation device with latest data from Wyze API."""
|
|
108
|
+
# Get IoT properties
|
|
109
|
+
properties = (await self.get_iot_prop(irrigation))['data']['props']
|
|
110
|
+
|
|
111
|
+
# Update device properties
|
|
112
|
+
irrigation.RSSI = properties.get('RSSI')
|
|
113
|
+
irrigation.IP = properties.get('IP')
|
|
114
|
+
irrigation.sn = properties.get('sn')
|
|
115
|
+
irrigation.ssid = properties.get('ssid')
|
|
116
|
+
irrigation.available = (properties.get(IrrigationProps.IOT_STATE.value) == 'connected')
|
|
117
|
+
|
|
118
|
+
return irrigation
|
|
119
|
+
|
|
120
|
+
async def get_irrigations(self) -> List[Irrigation]:
|
|
121
|
+
if self._devices is None:
|
|
122
|
+
self._devices = await self.get_object_list()
|
|
123
|
+
|
|
124
|
+
irrigations = [device for device in self._devices if device.type == DeviceTypes.IRRIGATION and "BS_WK1" in device.product_model]
|
|
125
|
+
|
|
126
|
+
return [Irrigation(irrigation.raw_dict) for irrigation in irrigations]
|
|
127
|
+
|
|
128
|
+
async def start_zone(self, irrigation: Device, zone_number: int, quickrun_duration: int) -> Dict[Any, Any]:
|
|
129
|
+
"""Start a zone with the specified duration.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
irrigation: The irrigation device
|
|
133
|
+
zone_number: The zone number to start
|
|
134
|
+
quickrun_duration: Duration in seconds to run the zone
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict containing the API response
|
|
138
|
+
"""
|
|
139
|
+
url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/quickrun"
|
|
140
|
+
return await self._start_zone(url, irrigation, zone_number, quickrun_duration)
|
|
141
|
+
|
|
142
|
+
async def stop_running_schedule(self, device: Device) -> Dict[Any, Any]:
|
|
143
|
+
"""Stop any currently running irrigation schedule.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
device: The irrigation device
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict containing the API response
|
|
150
|
+
"""
|
|
151
|
+
url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/runningschedule"
|
|
152
|
+
action = "STOP"
|
|
153
|
+
return await self._stop_running_schedule(url, device, action)
|
|
154
|
+
|
|
155
|
+
async def set_zone_quickrun_duration(self, irrigation: Irrigation, zone_number: int, duration: int) -> Irrigation:
|
|
156
|
+
"""Set the quickrun duration for a specific zone.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
irrigation: The irrigation device
|
|
160
|
+
zone_number: The zone number to configure
|
|
161
|
+
duration: Duration in seconds for quickrun
|
|
162
|
+
"""
|
|
163
|
+
for zone in irrigation.zones:
|
|
164
|
+
if zone.zone_number == zone_number:
|
|
165
|
+
zone.quickrun_duration = duration
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
return irrigation
|
|
169
|
+
|
|
170
|
+
# Private implementation methods
|
|
171
|
+
async def get_iot_prop(self, device: Device) -> Dict[Any, Any]:
|
|
172
|
+
"""Get IoT properties for a device."""
|
|
173
|
+
url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/get_iot_prop"
|
|
174
|
+
keys = 'zone_state,iot_state,iot_state_update_time,app_version,RSSI,' \
|
|
175
|
+
'wifi_mac,sn,device_model,ssid,IP'
|
|
176
|
+
return await self._get_iot_prop(url, device, keys)
|
|
177
|
+
|
|
178
|
+
async def get_device_info(self, device: Device) -> Dict[Any, Any]:
|
|
179
|
+
"""Get device info from Wyze API."""
|
|
180
|
+
url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/device_info"
|
|
181
|
+
keys = 'wiring,sensor,enable_schedules,notification_enable,notification_watering_begins,' \
|
|
182
|
+
'notification_watering_ends,notification_watering_is_skipped,skip_low_temp,skip_wind,' \
|
|
183
|
+
'skip_rain,skip_saturation'
|
|
184
|
+
return await self._irrigation_device_info(url, device, keys)
|
|
185
|
+
|
|
186
|
+
async def get_zone_by_device(self, device: Device) -> List[Dict[Any, Any]]:
|
|
187
|
+
"""Get zones for a device."""
|
|
188
|
+
url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/zone"
|
|
189
|
+
return await self._get_zone_by_device(url, device)
|
wyzeapy/services/lock_service.py
CHANGED
|
@@ -27,7 +27,9 @@ class LockService(BaseService):
|
|
|
27
27
|
ble_token_info = await self._get_lock_ble_token(lock)
|
|
28
28
|
lock.raw_dict["token"] = ble_token_info["token"]
|
|
29
29
|
lock.ble_id = ble_token_info["token"]["id"]
|
|
30
|
-
lock.ble_token = wyze_decrypt_cbc(
|
|
30
|
+
lock.ble_token = wyze_decrypt_cbc(
|
|
31
|
+
FORD_APP_SECRET[:16], ble_token_info["token"]["token"]
|
|
32
|
+
)
|
|
31
33
|
|
|
32
34
|
lock.available = lock.raw_dict.get("onoff_line") == 1
|
|
33
35
|
lock.door_open = lock.raw_dict.get("door_open_status") == 1
|
|
@@ -37,13 +39,13 @@ class LockService(BaseService):
|
|
|
37
39
|
locker_status = lock.raw_dict.get("locker_status")
|
|
38
40
|
# Check if the door is locked
|
|
39
41
|
lock.unlocked = locker_status.get("hardlock") == 2
|
|
40
|
-
|
|
42
|
+
|
|
41
43
|
# Reset unlocking and locking if needed
|
|
42
44
|
if lock.unlocked and lock.unlocking:
|
|
43
45
|
lock.unlocking = False
|
|
44
46
|
if not lock.unlocked and lock.locking:
|
|
45
47
|
lock.locking = False
|
|
46
|
-
|
|
48
|
+
|
|
47
49
|
return lock
|
|
48
50
|
|
|
49
51
|
async def get_locks(self):
|
|
@@ -31,9 +31,9 @@ class SensorService(BaseService):
|
|
|
31
31
|
sensor.device_params = await self.get_updated_params(sensor.mac)
|
|
32
32
|
properties = await self._get_device_info(sensor)
|
|
33
33
|
|
|
34
|
-
for property in properties[
|
|
35
|
-
pid = property[
|
|
36
|
-
value = property[
|
|
34
|
+
for property in properties["data"]["property_list"]:
|
|
35
|
+
pid = property["pid"]
|
|
36
|
+
value = property["value"]
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
39
|
if PropertyIDs(pid) == PropertyIDs.CONTACT_STATE:
|
|
@@ -45,26 +45,44 @@ class SensorService(BaseService):
|
|
|
45
45
|
|
|
46
46
|
return sensor
|
|
47
47
|
|
|
48
|
-
async def register_for_updates(
|
|
48
|
+
async def register_for_updates(
|
|
49
|
+
self, sensor: Sensor, callback: Callable[[Sensor], None]
|
|
50
|
+
):
|
|
49
51
|
_LOGGER.debug(f"Registering sensor: {sensor.nickname} for updates")
|
|
50
52
|
loop = asyncio.get_event_loop()
|
|
51
53
|
if not self._updater_thread:
|
|
52
|
-
self._updater_thread = Thread(
|
|
54
|
+
self._updater_thread = Thread(
|
|
55
|
+
target=self.update_worker,
|
|
56
|
+
args=[
|
|
57
|
+
loop,
|
|
58
|
+
],
|
|
59
|
+
daemon=True,
|
|
60
|
+
)
|
|
53
61
|
self._updater_thread.start()
|
|
54
62
|
|
|
55
63
|
self._subscribers.append((sensor, callback))
|
|
56
64
|
|
|
57
65
|
async def deregister_for_updates(self, sensor: Sensor):
|
|
58
|
-
self._subscribers = [
|
|
66
|
+
self._subscribers = [
|
|
67
|
+
(sense, callback)
|
|
68
|
+
for sense, callback in self._subscribers
|
|
69
|
+
if sense.mac != sensor.mac
|
|
70
|
+
]
|
|
59
71
|
|
|
60
72
|
def update_worker(self, loop):
|
|
61
73
|
while True:
|
|
62
74
|
for sensor, callback in self._subscribers:
|
|
63
75
|
_LOGGER.debug(f"Providing update for {sensor.nickname}")
|
|
64
76
|
try:
|
|
65
|
-
callback(
|
|
77
|
+
callback(
|
|
78
|
+
asyncio.run_coroutine_threadsafe(
|
|
79
|
+
self.update(sensor), loop
|
|
80
|
+
).result()
|
|
81
|
+
)
|
|
66
82
|
except UnknownApiError as e:
|
|
67
|
-
_LOGGER.warning(
|
|
83
|
+
_LOGGER.warning(
|
|
84
|
+
f"The update method detected an UnknownApiError: {e}"
|
|
85
|
+
)
|
|
68
86
|
except ClientOSError as e:
|
|
69
87
|
_LOGGER.error(f"A network error was detected: {e}")
|
|
70
88
|
except ContentTypeError as e:
|
|
@@ -74,7 +92,10 @@ class SensorService(BaseService):
|
|
|
74
92
|
if self._devices is None:
|
|
75
93
|
self._devices = await self.get_object_list()
|
|
76
94
|
|
|
77
|
-
sensors = [
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
sensors = [
|
|
96
|
+
Sensor(device.raw_dict)
|
|
97
|
+
for device in self._devices
|
|
98
|
+
if device.type is DeviceTypes.MOTION_SENSOR
|
|
99
|
+
or device.type is DeviceTypes.CONTACT_SENSOR
|
|
100
|
+
]
|
|
80
101
|
return [Sensor(sensor.raw_dict) for sensor in sensors]
|
|
@@ -36,8 +36,12 @@ class SwitchService(BaseService):
|
|
|
36
36
|
if self._devices is None:
|
|
37
37
|
self._devices = await self.get_object_list()
|
|
38
38
|
|
|
39
|
-
devices = [
|
|
40
|
-
|
|
39
|
+
devices = [
|
|
40
|
+
device
|
|
41
|
+
for device in self._devices
|
|
42
|
+
if device.type is DeviceTypes.PLUG
|
|
43
|
+
or device.type is DeviceTypes.OUTDOOR_PLUG
|
|
44
|
+
]
|
|
41
45
|
return [Switch(switch.raw_dict) for switch in devices]
|
|
42
46
|
|
|
43
47
|
async def turn_on(self, switch: Switch):
|
|
@@ -37,9 +37,9 @@ class Preset(Enum):
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class HVACState(Enum):
|
|
40
|
-
COOLING =
|
|
41
|
-
HEATING =
|
|
42
|
-
IDLE =
|
|
40
|
+
COOLING = "cooling"
|
|
41
|
+
HEATING = "heating"
|
|
42
|
+
IDLE = "idle"
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
class Thermostat(Device):
|
|
@@ -60,7 +60,7 @@ class Thermostat(Device):
|
|
|
60
60
|
|
|
61
61
|
class ThermostatService(BaseService):
|
|
62
62
|
async def update(self, thermostat: Thermostat) -> Thermostat:
|
|
63
|
-
properties = (await self._thermostat_get_iot_prop(thermostat))[
|
|
63
|
+
properties = (await self._thermostat_get_iot_prop(thermostat))["data"]["props"]
|
|
64
64
|
|
|
65
65
|
device_props = []
|
|
66
66
|
for property in properties:
|
|
@@ -87,7 +87,7 @@ class ThermostatService(BaseService):
|
|
|
87
87
|
elif prop == ThermostatProps.TEMPERATURE:
|
|
88
88
|
thermostat.temperature = float(value)
|
|
89
89
|
elif prop == ThermostatProps.IOT_STATE:
|
|
90
|
-
thermostat.available = value ==
|
|
90
|
+
thermostat.available = value == "connected"
|
|
91
91
|
elif prop == ThermostatProps.HUMIDITY:
|
|
92
92
|
thermostat.humidity = int(value)
|
|
93
93
|
elif prop == ThermostatProps.WORKING_STATE:
|
|
@@ -99,7 +99,9 @@ class ThermostatService(BaseService):
|
|
|
99
99
|
if self._devices is None:
|
|
100
100
|
self._devices = await self.get_object_list()
|
|
101
101
|
|
|
102
|
-
thermostats = [
|
|
102
|
+
thermostats = [
|
|
103
|
+
device for device in self._devices if device.type is DeviceTypes.THERMOSTAT
|
|
104
|
+
]
|
|
103
105
|
|
|
104
106
|
return [Thermostat(thermostat.raw_dict) for thermostat in thermostats]
|
|
105
107
|
|
|
@@ -110,22 +112,34 @@ class ThermostatService(BaseService):
|
|
|
110
112
|
await self._thermostat_set_iot_prop(thermostat, ThermostatProps.HEAT_SP, temp)
|
|
111
113
|
|
|
112
114
|
async def set_hvac_mode(self, thermostat: Device, hvac_mode: HVACMode):
|
|
113
|
-
await self._thermostat_set_iot_prop(
|
|
115
|
+
await self._thermostat_set_iot_prop(
|
|
116
|
+
thermostat, ThermostatProps.MODE_SYS, hvac_mode.value
|
|
117
|
+
)
|
|
114
118
|
|
|
115
119
|
async def set_fan_mode(self, thermostat: Device, fan_mode: FanMode):
|
|
116
|
-
await self._thermostat_set_iot_prop(
|
|
120
|
+
await self._thermostat_set_iot_prop(
|
|
121
|
+
thermostat, ThermostatProps.FAN_MODE, fan_mode.value
|
|
122
|
+
)
|
|
117
123
|
|
|
118
124
|
async def set_preset(self, thermostat: Thermostat, preset: Preset):
|
|
119
|
-
await self._thermostat_set_iot_prop(
|
|
125
|
+
await self._thermostat_set_iot_prop(
|
|
126
|
+
thermostat, ThermostatProps.CURRENT_SCENARIO, preset.value
|
|
127
|
+
)
|
|
120
128
|
|
|
121
129
|
async def _thermostat_get_iot_prop(self, device: Device) -> Dict[Any, Any]:
|
|
122
130
|
url = "https://wyze-earth-service.wyzecam.com/plugin/earth/get_iot_prop"
|
|
123
|
-
keys =
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
keys = (
|
|
132
|
+
"trigger_off_val,emheat,temperature,humidity,time2temp_val,protect_time,mode_sys,heat_sp,cool_sp,"
|
|
133
|
+
"current_scenario,config_scenario,temp_unit,fan_mode,iot_state,w_city_id,w_lat,w_lon,working_state,"
|
|
134
|
+
"dev_hold,dev_holdtime,asw_hold,app_version,setup_state,wiring_logic_id,save_comfort_balance,"
|
|
135
|
+
"kid_lock,calibrate_humidity,calibrate_temperature,fancirc_time,query_schedule"
|
|
136
|
+
)
|
|
127
137
|
return await self._get_iot_prop(url, device, keys)
|
|
128
138
|
|
|
129
|
-
async def _thermostat_set_iot_prop(
|
|
130
|
-
|
|
139
|
+
async def _thermostat_set_iot_prop(
|
|
140
|
+
self, device: Device, prop: ThermostatProps, value: Any
|
|
141
|
+
) -> None:
|
|
142
|
+
url = (
|
|
143
|
+
"https://wyze-earth-service.wyzecam.com/plugin/earth/set_iot_prop_by_topic"
|
|
144
|
+
)
|
|
131
145
|
return await self._set_iot_prop(url, device, prop.value, value)
|
|
@@ -7,17 +7,34 @@ from ..types import Device
|
|
|
7
7
|
import logging
|
|
8
8
|
import threading
|
|
9
9
|
|
|
10
|
+
"""
|
|
11
|
+
Asynchronous device update scheduling and management.
|
|
12
|
+
|
|
13
|
+
This module provides classes to schedule and execute periodic updates of
|
|
14
|
+
Wyze devices, ensuring rate limits and fair distribution of update calls.
|
|
15
|
+
"""
|
|
16
|
+
|
|
10
17
|
_LOGGER = logging.getLogger(__name__)
|
|
11
18
|
|
|
12
19
|
INTERVAL = 300
|
|
13
20
|
MAX_SLOTS = 225
|
|
14
21
|
|
|
22
|
+
|
|
15
23
|
@dataclass(order=True)
|
|
16
24
|
class DeviceUpdater(object):
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
"""Represents a scheduled update task for a single device.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
service: The service instance responsible for updating the device.
|
|
29
|
+
device: The Device object to be updated.
|
|
30
|
+
update_in: Countdown ticks until the next update is due.
|
|
31
|
+
updates_per_interval: Number of updates allowed per INTERVAL.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
device: Device = field(compare=False)
|
|
35
|
+
service: Any = field(compare=False)
|
|
36
|
+
update_in: int # Countdown ticks until this device should be updated
|
|
37
|
+
updates_per_interval: int = field(compare=False)
|
|
21
38
|
|
|
22
39
|
def __init__(self, service, device: Device, update_interval: int):
|
|
23
40
|
"""
|
|
@@ -42,7 +59,7 @@ class DeviceUpdater(object):
|
|
|
42
59
|
self.device = await self.service.update(self.device)
|
|
43
60
|
# Callback to provide the updated info to the subscriber
|
|
44
61
|
self.device.callback_function(self.device)
|
|
45
|
-
except:
|
|
62
|
+
except Exception:
|
|
46
63
|
_LOGGER.exception("Unknown error happened during updating device info")
|
|
47
64
|
finally:
|
|
48
65
|
# Release the mutex after the async call
|
|
@@ -63,11 +80,17 @@ class DeviceUpdater(object):
|
|
|
63
80
|
if self.updates_per_interval > 1:
|
|
64
81
|
self.updates_per_interval -= 1
|
|
65
82
|
|
|
83
|
+
|
|
66
84
|
class UpdateManager:
|
|
67
|
-
|
|
85
|
+
"""Manager for scheduling and executing periodic device updates.
|
|
86
|
+
|
|
87
|
+
Maintains a priority queue of DeviceUpdater instances and enforces rate
|
|
88
|
+
limits and fair distribution of update calls across devices.
|
|
89
|
+
"""
|
|
90
|
+
|
|
68
91
|
updaters = []
|
|
69
92
|
removed_updaters = []
|
|
70
|
-
mutex = threading.Lock()
|
|
93
|
+
mutex = threading.Lock()
|
|
71
94
|
|
|
72
95
|
def check_if_removed(self, updater: DeviceUpdater):
|
|
73
96
|
for item in self.removed_updaters:
|
|
@@ -78,7 +101,7 @@ class UpdateManager:
|
|
|
78
101
|
# This function should be called once every second
|
|
79
102
|
async def update_next(self):
|
|
80
103
|
# If there are no updaters in the queue we don't need to do anything
|
|
81
|
-
if
|
|
104
|
+
if len(self.updaters) == 0:
|
|
82
105
|
_LOGGER.debug("No devices to update in queue")
|
|
83
106
|
return
|
|
84
107
|
while True:
|
|
@@ -92,12 +115,13 @@ class UpdateManager:
|
|
|
92
115
|
# We then reduce the counter for all the other updaters
|
|
93
116
|
self.tick_tock()
|
|
94
117
|
# Then we update the target device
|
|
95
|
-
await updater.update(
|
|
118
|
+
await updater.update(
|
|
119
|
+
self.mutex
|
|
120
|
+
) # It will only update if it is time for it to update. Otherwise it just reduces its update_in counter.
|
|
96
121
|
# Then we put it back at the end of the queue. Or the front again if it wasn't ready to update
|
|
97
122
|
heappush(self.updaters, updater)
|
|
98
123
|
await sleep(1)
|
|
99
124
|
|
|
100
|
-
|
|
101
125
|
def filled_slots(self):
|
|
102
126
|
# This just returns the number of available slots
|
|
103
127
|
current_slots = 0
|
|
@@ -123,7 +147,10 @@ class UpdateManager:
|
|
|
123
147
|
|
|
124
148
|
# When we add a new updater it has to fit within the max slots or we will not add it
|
|
125
149
|
while (self.filled_slots() + updater.updates_per_interval) > MAX_SLOTS:
|
|
126
|
-
_LOGGER.debug(
|
|
150
|
+
_LOGGER.debug(
|
|
151
|
+
"Reducing updates per interval to fit new device as slots are full: %s",
|
|
152
|
+
self.filled_slots(),
|
|
153
|
+
)
|
|
127
154
|
# If we are overflowing the available slots we will reduce the frequency of updates evenly for all devices until we can fit in one more.
|
|
128
155
|
self.decrease_updates_per_interval()
|
|
129
156
|
updater.delay()
|
|
@@ -41,7 +41,7 @@ class WallSwitch(Device):
|
|
|
41
41
|
|
|
42
42
|
class WallSwitchService(BaseService):
|
|
43
43
|
async def update(self, switch: WallSwitch) -> WallSwitch:
|
|
44
|
-
properties = (await self._wall_switch_get_iot_prop(switch))[
|
|
44
|
+
properties = (await self._wall_switch_get_iot_prop(switch))["data"]["props"]
|
|
45
45
|
|
|
46
46
|
device_props = []
|
|
47
47
|
for prop_key, prop_value in properties.items():
|
|
@@ -67,9 +67,11 @@ class WallSwitchService(BaseService):
|
|
|
67
67
|
if self._devices is None:
|
|
68
68
|
self._devices = await self.get_object_list()
|
|
69
69
|
|
|
70
|
-
switches = [
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
switches = [
|
|
71
|
+
device
|
|
72
|
+
for device in self._devices
|
|
73
|
+
if device.type is DeviceTypes.COMMON and device.product_model == "LD_SS1"
|
|
74
|
+
]
|
|
73
75
|
|
|
74
76
|
return [WallSwitch(switch.raw_dict) for switch in switches]
|
|
75
77
|
|
|
@@ -89,7 +91,9 @@ class WallSwitchService(BaseService):
|
|
|
89
91
|
await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_POWER, True)
|
|
90
92
|
|
|
91
93
|
async def power_off(self, switch: WallSwitch):
|
|
92
|
-
await self._wall_switch_set_iot_prop(
|
|
94
|
+
await self._wall_switch_set_iot_prop(
|
|
95
|
+
switch, WallSwitchProps.SWITCH_POWER, False
|
|
96
|
+
)
|
|
93
97
|
|
|
94
98
|
async def iot_on(self, switch: WallSwitch):
|
|
95
99
|
await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_IOT, True)
|
|
@@ -97,14 +101,20 @@ class WallSwitchService(BaseService):
|
|
|
97
101
|
async def iot_off(self, switch: WallSwitch):
|
|
98
102
|
await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_IOT, False)
|
|
99
103
|
|
|
100
|
-
async def set_single_press_type(
|
|
101
|
-
|
|
104
|
+
async def set_single_press_type(
|
|
105
|
+
self, switch: WallSwitch, single_press_type: SinglePressType
|
|
106
|
+
):
|
|
107
|
+
await self._wall_switch_set_iot_prop(
|
|
108
|
+
switch, WallSwitchProps.SINGLE_PRESS_TYPE, single_press_type.value
|
|
109
|
+
)
|
|
102
110
|
|
|
103
111
|
async def _wall_switch_get_iot_prop(self, device: Device) -> Dict[Any, Any]:
|
|
104
112
|
url = "https://wyze-sirius-service.wyzecam.com//plugin/sirius/get_iot_prop"
|
|
105
113
|
keys = "iot_state,switch-power,switch-iot,single_press_type"
|
|
106
114
|
return await self._get_iot_prop(url, device, keys)
|
|
107
115
|
|
|
108
|
-
async def _wall_switch_set_iot_prop(
|
|
116
|
+
async def _wall_switch_set_iot_prop(
|
|
117
|
+
self, device: Device, prop: WallSwitchProps, value: Any
|
|
118
|
+
) -> None:
|
|
109
119
|
url = "https://wyze-sirius-service.wyzecam.com//plugin/sirius/set_iot_prop_by_topic"
|
|
110
120
|
return await self._set_iot_prop(url, device, prop.value, value)
|