wyzeapy 0.5.28__py3-none-any.whl → 0.5.29__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 +267 -45
- wyzeapy/const.py +9 -4
- wyzeapy/crypto.py +31 -2
- wyzeapy/exceptions.py +11 -8
- wyzeapy/payload_factory.py +177 -172
- wyzeapy/services/__init__.py +3 -0
- wyzeapy/services/base_service.py +333 -212
- wyzeapy/services/bulb_service.py +67 -63
- wyzeapy/services/camera_service.py +136 -50
- wyzeapy/services/hms_service.py +8 -17
- 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/types.py +20 -12
- wyzeapy/utils.py +98 -17
- wyzeapy/wyze_auth_lib.py +195 -37
- wyzeapy-0.5.29.dist-info/METADATA +13 -0
- wyzeapy-0.5.29.dist-info/RECORD +22 -0
- {wyzeapy-0.5.28.dist-info → wyzeapy-0.5.29.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
|
@@ -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)
|
wyzeapy/types.py
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
# of the attached license. You should have received a copy of
|
|
4
4
|
# the license with this file. If not, please write to:
|
|
5
5
|
# katie@mulliken.net to receive a copy
|
|
6
|
+
"""
|
|
7
|
+
Type definitions and data models for Wyzeapy library, including devices, events,
|
|
8
|
+
and API response code enums.
|
|
9
|
+
"""
|
|
10
|
+
|
|
6
11
|
from enum import Enum
|
|
7
12
|
from typing import Union, List, Dict, Any
|
|
8
13
|
|
|
@@ -79,15 +84,17 @@ class Sensor(Device):
|
|
|
79
84
|
@property
|
|
80
85
|
def activity_detected(self) -> int:
|
|
81
86
|
if self.type is DeviceTypes.CONTACT_SENSOR:
|
|
82
|
-
return int(self.device_params[
|
|
87
|
+
return int(self.device_params["open_close_state"])
|
|
83
88
|
elif self.type is DeviceTypes.MOTION_SENSOR:
|
|
84
|
-
return int(self.device_params[
|
|
89
|
+
return int(self.device_params["motion_state"])
|
|
85
90
|
else:
|
|
86
|
-
raise AssertionError(
|
|
91
|
+
raise AssertionError(
|
|
92
|
+
"Device must be of type CONTACT_SENSOR or MOTION_SENSOR"
|
|
93
|
+
)
|
|
87
94
|
|
|
88
95
|
@property
|
|
89
96
|
def is_low_battery(self) -> int:
|
|
90
|
-
return int(self.device_params[
|
|
97
|
+
return int(self.device_params["is_low_battery"])
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
class PropertyIDs(Enum):
|
|
@@ -104,11 +111,11 @@ class PropertyIDs(Enum):
|
|
|
104
111
|
CONTACT_STATE = "P1301"
|
|
105
112
|
MOTION_STATE = "P1302"
|
|
106
113
|
CAMERA_SIREN = "P1049"
|
|
107
|
-
ACCESSORY = "P1056"
|
|
114
|
+
ACCESSORY = "P1056" # Is state for camera accessories, like garage doors, light sockets, and floodlights.
|
|
108
115
|
SUN_MATCH = "P1528"
|
|
109
116
|
MOTION_DETECTION = "P1047" # Current Motion Detection State of the Camera
|
|
110
117
|
MOTION_DETECTION_TOGGLE = "P1001" # This toggles Camera Motion Detection On/Off
|
|
111
|
-
WCO_MOTION_DETECTION = "P1029"
|
|
118
|
+
WCO_MOTION_DETECTION = "P1029" # Wyze cam outdoor requires both P1047 and P1029 to be set. P1029 is set via set_property_list
|
|
112
119
|
|
|
113
120
|
|
|
114
121
|
class WallSwitchProps(Enum):
|
|
@@ -153,7 +160,7 @@ class ResponseCodes(Enum):
|
|
|
153
160
|
SUCCESS = "1"
|
|
154
161
|
PARAMETER_ERROR = "1001"
|
|
155
162
|
ACCESS_TOKEN_ERROR = "2001"
|
|
156
|
-
DEVICE_OFFLINE =
|
|
163
|
+
DEVICE_OFFLINE = "3019"
|
|
157
164
|
|
|
158
165
|
|
|
159
166
|
class ResponseCodesLock(Enum):
|
|
@@ -205,9 +212,9 @@ class Event:
|
|
|
205
212
|
|
|
206
213
|
|
|
207
214
|
class HMSStatus(Enum):
|
|
208
|
-
DISARMED =
|
|
209
|
-
HOME =
|
|
210
|
-
AWAY =
|
|
215
|
+
DISARMED = "disarmed"
|
|
216
|
+
HOME = "home"
|
|
217
|
+
AWAY = "away"
|
|
211
218
|
|
|
212
219
|
|
|
213
220
|
class DeviceMgmtToggleType:
|
|
@@ -217,6 +224,7 @@ class DeviceMgmtToggleType:
|
|
|
217
224
|
|
|
218
225
|
|
|
219
226
|
class DeviceMgmtToggleProps(Enum):
|
|
220
|
-
EVENT_RECORDING_TOGGLE = DeviceMgmtToggleType(
|
|
227
|
+
EVENT_RECORDING_TOGGLE = DeviceMgmtToggleType(
|
|
228
|
+
"cam_event_recording", "ge.motion_detect_recording"
|
|
229
|
+
)
|
|
221
230
|
NOTIFICATION_TOGGLE = DeviceMgmtToggleType("cam_device_notify", "ge.push_switch")
|
|
222
|
-
|
wyzeapy/utils.py
CHANGED
|
@@ -13,6 +13,10 @@ from Crypto.Cipher import AES
|
|
|
13
13
|
from .exceptions import ParameterError, AccessTokenError, UnknownApiError
|
|
14
14
|
from .types import ResponseCodes, PropertyIDs, Device, Event
|
|
15
15
|
|
|
16
|
+
"""
|
|
17
|
+
Utility functions for encryption, decryption, error handling, and common Wyze API tasks.
|
|
18
|
+
"""
|
|
19
|
+
|
|
16
20
|
PADDING = bytes.fromhex("05")
|
|
17
21
|
|
|
18
22
|
|
|
@@ -44,7 +48,7 @@ def wyze_encrypt(key, text):
|
|
|
44
48
|
cipher = AES.new(key, AES.MODE_CBC, iv)
|
|
45
49
|
enc = cipher.encrypt(raw)
|
|
46
50
|
b64_enc = base64.b64encode(enc).decode("ascii")
|
|
47
|
-
b64_enc = b64_enc.replace("/", r
|
|
51
|
+
b64_enc = b64_enc.replace("/", r"\/")
|
|
48
52
|
return b64_enc
|
|
49
53
|
|
|
50
54
|
|
|
@@ -57,7 +61,7 @@ def wyze_decrypt(key, enc):
|
|
|
57
61
|
"""
|
|
58
62
|
enc = base64.b64decode(enc)
|
|
59
63
|
|
|
60
|
-
key = key.encode(
|
|
64
|
+
key = key.encode("ascii")
|
|
61
65
|
iv = key
|
|
62
66
|
cipher = AES.new(key, AES.MODE_CBC, iv)
|
|
63
67
|
decrypt = cipher.decrypt(enc)
|
|
@@ -68,44 +72,79 @@ def wyze_decrypt(key, enc):
|
|
|
68
72
|
|
|
69
73
|
|
|
70
74
|
def wyze_decrypt_cbc(key: str, enc_hex_str: str) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Decrypt a hex-encoded string using Wyze's CBC decryption with MD5 based key.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
key: The secret key string.
|
|
80
|
+
enc_hex_str: The encrypted data as a hex string.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The decrypted plaintext string.
|
|
84
|
+
"""
|
|
71
85
|
key_hash = hashlib.md5(key.encode("utf-8")).digest()
|
|
72
|
-
|
|
86
|
+
|
|
73
87
|
iv = b"0123456789ABCDEF"
|
|
74
88
|
cipher = AES.new(key_hash, AES.MODE_CBC, iv)
|
|
75
|
-
|
|
89
|
+
|
|
76
90
|
encrypted_bytes = binascii.unhexlify(enc_hex_str)
|
|
77
91
|
decrypted_bytes = cipher.decrypt(encrypted_bytes)
|
|
78
|
-
|
|
92
|
+
|
|
79
93
|
# PKCS5Padding
|
|
80
94
|
padding_length = decrypted_bytes[-1]
|
|
81
95
|
return decrypted_bytes[:-padding_length].decode()
|
|
82
96
|
|
|
83
97
|
|
|
84
98
|
def create_password(password: str) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Derive the Wyze API password hash using a triple MD5 hashing scheme.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
password: The plain user password string.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The hashed password as a hex string.
|
|
107
|
+
"""
|
|
85
108
|
hex1 = hashlib.md5(password.encode()).hexdigest()
|
|
86
109
|
hex2 = hashlib.md5(hex1.encode()).hexdigest()
|
|
87
110
|
return hashlib.md5(hex2.encode()).hexdigest()
|
|
88
111
|
|
|
89
112
|
|
|
90
113
|
def check_for_errors_standard(service, response_json: Dict[str, Any]) -> None:
|
|
91
|
-
|
|
114
|
+
"""
|
|
115
|
+
Check for standard Wyze API error codes and raise exceptions as needed.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
service: The service instance triggering the call.
|
|
119
|
+
response_json: The JSON response from the API.
|
|
120
|
+
"""
|
|
121
|
+
response_code = response_json["code"]
|
|
92
122
|
if response_code != ResponseCodes.SUCCESS.value:
|
|
93
123
|
if response_code == ResponseCodes.PARAMETER_ERROR.value:
|
|
94
|
-
raise ParameterError(response_code, response_json[
|
|
124
|
+
raise ParameterError(response_code, response_json["msg"])
|
|
95
125
|
elif response_code == ResponseCodes.ACCESS_TOKEN_ERROR.value:
|
|
96
126
|
service._auth_lib.token.expired = True
|
|
97
|
-
raise AccessTokenError(
|
|
127
|
+
raise AccessTokenError(
|
|
128
|
+
response_code, "Access Token expired, attempting to refresh"
|
|
129
|
+
)
|
|
98
130
|
elif response_code == ResponseCodes.DEVICE_OFFLINE.value:
|
|
99
131
|
return
|
|
100
132
|
else:
|
|
101
|
-
raise UnknownApiError(response_code, response_json[
|
|
133
|
+
raise UnknownApiError(response_code, response_json["msg"])
|
|
102
134
|
|
|
103
135
|
|
|
104
136
|
def check_for_errors_lock(service, response_json: Dict[str, Any]) -> None:
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
"""
|
|
138
|
+
Check for lock-specific API errors and raise exceptions as needed.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
service: The lock service instance.
|
|
142
|
+
response_json: The JSON response from the lock API.
|
|
143
|
+
"""
|
|
144
|
+
if response_json["ErrNo"] != 0:
|
|
145
|
+
if response_json.get("code") == ResponseCodes.PARAMETER_ERROR.value:
|
|
107
146
|
raise ParameterError(response_json)
|
|
108
|
-
elif response_json.get(
|
|
147
|
+
elif response_json.get("code") == ResponseCodes.ACCESS_TOKEN_ERROR.value:
|
|
109
148
|
service._auth_lib.token.expired = True
|
|
110
149
|
raise AccessTokenError("Access Token expired, attempting to refresh")
|
|
111
150
|
else:
|
|
@@ -113,8 +152,15 @@ def check_for_errors_lock(service, response_json: Dict[str, Any]) -> None:
|
|
|
113
152
|
|
|
114
153
|
|
|
115
154
|
def check_for_errors_devicemgmt(service, response_json: Dict[Any, Any]) -> None:
|
|
116
|
-
|
|
117
|
-
|
|
155
|
+
"""
|
|
156
|
+
Check for device management API errors and raise exceptions as needed.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
service: The device management service instance.
|
|
160
|
+
response_json: The JSON response from the device management API.
|
|
161
|
+
"""
|
|
162
|
+
if response_json["status"] != 200:
|
|
163
|
+
if "InvalidTokenError>" in response_json["response"]["errors"][0]["message"]:
|
|
118
164
|
service._auth_lib.token.expired = True
|
|
119
165
|
raise AccessTokenError("Access Token expired, attempting to refresh")
|
|
120
166
|
else:
|
|
@@ -122,20 +168,45 @@ def check_for_errors_devicemgmt(service, response_json: Dict[Any, Any]) -> None:
|
|
|
122
168
|
|
|
123
169
|
|
|
124
170
|
def check_for_errors_iot(service, response_json: Dict[Any, Any]) -> None:
|
|
125
|
-
|
|
126
|
-
|
|
171
|
+
"""
|
|
172
|
+
Check for IoT API errors and raise exceptions as needed.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
service: The IoT service instance.
|
|
176
|
+
response_json: The JSON response from the IoT API.
|
|
177
|
+
"""
|
|
178
|
+
if response_json["code"] != 1:
|
|
179
|
+
if str(response_json["code"]) == ResponseCodes.ACCESS_TOKEN_ERROR.value:
|
|
127
180
|
service._auth_lib.token.expired = True
|
|
128
181
|
raise AccessTokenError("Access Token expired, attempting to refresh")
|
|
129
182
|
else:
|
|
130
183
|
raise UnknownApiError(response_json)
|
|
131
184
|
|
|
185
|
+
|
|
132
186
|
def check_for_errors_hms(service, response_json: Dict[Any, Any]) -> None:
|
|
133
|
-
|
|
187
|
+
"""
|
|
188
|
+
Check for home monitoring system (HMS) API errors and raise exceptions as needed.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
service: The HMS service instance.
|
|
192
|
+
response_json: The JSON response from the HMS API.
|
|
193
|
+
"""
|
|
194
|
+
if response_json["message"] is None:
|
|
134
195
|
service._auth_lib.token.expired = True
|
|
135
196
|
raise AccessTokenError("Access Token expired, attempting to refresh")
|
|
136
197
|
|
|
137
198
|
|
|
138
199
|
def return_event_for_device(device: Device, events: List[Event]) -> Optional[Event]:
|
|
200
|
+
"""
|
|
201
|
+
Retrieve the most recent event matching a given device from a list of events.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
device: The device to match against event.device_mac.
|
|
205
|
+
events: List of events to search.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
The first matching Event or None if no match is found.
|
|
209
|
+
"""
|
|
139
210
|
for event in events:
|
|
140
211
|
if event.device_mac == device.mac:
|
|
141
212
|
return event
|
|
@@ -144,4 +215,14 @@ def return_event_for_device(device: Device, events: List[Event]) -> Optional[Eve
|
|
|
144
215
|
|
|
145
216
|
|
|
146
217
|
def create_pid_pair(pid_enum: PropertyIDs, value: str) -> Dict[str, str]:
|
|
218
|
+
"""
|
|
219
|
+
Create a property ID/value pair dictionary for API payloads.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
pid_enum: PropertyIDs enum member for the property.
|
|
223
|
+
value: The value to set for the property.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
A dict with 'pid' and 'pvalue' keys for the Wyze API.
|
|
227
|
+
"""
|
|
147
228
|
return {"pid": pid_enum.value, "pvalue": value}
|