wyzeapy 0.5.29__py3-none-any.whl → 0.5.31__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 CHANGED
@@ -16,6 +16,7 @@ from .services.lock_service import LockService
16
16
  from .services.sensor_service import SensorService
17
17
  from .services.switch_service import SwitchService, SwitchUsageService
18
18
  from .services.thermostat_service import ThermostatService
19
+ from .services.irrigation_service import IrrigationService
19
20
  from .services.wall_switch_service import WallSwitchService
20
21
  from .wyze_auth_lib import WyzeAuthLib, Token
21
22
 
@@ -50,6 +51,7 @@ class Wyzeapy:
50
51
  self._hms_service = None
51
52
  self._lock_service = None
52
53
  self._sensor_service = None
54
+ self._irrigation_service = None
53
55
  self._wall_switch_service = None
54
56
  self._switch_usage_service = None
55
57
  self._email = None
@@ -442,6 +444,14 @@ class Wyzeapy:
442
444
  self._sensor_service = SensorService(self._auth_lib)
443
445
  return self._sensor_service
444
446
 
447
+ @property
448
+ async def irrigation_service(self) -> IrrigationService:
449
+ """Returns an instance of the irrigation service"""
450
+
451
+ if self._irrigation_service is None:
452
+ self._irrigation_service = IrrigationService(self._auth_lib)
453
+ return self._irrigation_service
454
+
445
455
  @property
446
456
  async def wall_switch_service(self) -> WallSwitchService:
447
457
  """Provides access to the Wyze Wall Switch service.
@@ -52,6 +52,40 @@ def olive_create_get_payload(device_mac: str, keys: str) -> Dict[str, Any]:
52
52
  return {"keys": keys, "did": device_mac, "nonce": nonce}
53
53
 
54
54
 
55
+ def olive_create_get_payload_irrigation(device_mac: str) -> Dict[str, Any]:
56
+ nonce = int(time.time() * 1000)
57
+
58
+ return {"device_id": device_mac, "nonce": str(nonce)}
59
+
60
+
61
+ def olive_create_post_payload_irrigation_stop(
62
+ device_mac: str, action: str
63
+ ) -> Dict[str, Any]:
64
+ nonce = int(time.time() * 1000)
65
+
66
+ return {"device_id": device_mac, "nonce": str(nonce), "action": action}
67
+
68
+
69
+ def olive_create_post_payload_irrigation_quickrun(
70
+ device_mac: str, zone_number: int, duration: int
71
+ ) -> Dict[str, Any]:
72
+ nonce = int(time.time() * 1000)
73
+
74
+ return {
75
+ "device_id": device_mac,
76
+ "nonce": str(nonce),
77
+ "zone_runs": [{"zone_number": zone_number, "duration": duration}],
78
+ }
79
+
80
+
81
+ def olive_create_get_payload_irrigation_schedule_runs(
82
+ device_mac: str,
83
+ ) -> Dict[str, Any]:
84
+ nonce = int(time.time() * 1000)
85
+
86
+ return {"device_id": device_mac, "nonce": str(nonce)}
87
+
88
+
55
89
  def olive_create_post_payload(
56
90
  device_mac: str, device_model: str, prop_key: str, value: Any
57
91
  ) -> Dict[str, Any]:
@@ -36,6 +36,10 @@ from ..payload_factory import (
36
36
  olive_create_user_info_payload,
37
37
  devicemgmt_create_capabilities_payload,
38
38
  devicemgmt_get_iot_props_list,
39
+ olive_create_get_payload_irrigation,
40
+ olive_create_post_payload_irrigation_stop,
41
+ olive_create_post_payload_irrigation_quickrun,
42
+ olive_create_get_payload_irrigation_schedule_runs,
39
43
  )
40
44
  from ..types import PropertyIDs, Device, DeviceMgmtToggleType
41
45
  from ..utils import (
@@ -896,3 +900,110 @@ class BaseService:
896
900
  check_for_errors_standard(self, response_json)
897
901
 
898
902
  return response_json["data"]["usage_record_list"]
903
+
904
+ async def _get_zone_by_device(self, url: str, device: Device) -> Dict[Any, Any]:
905
+ await self._auth_lib.refresh_if_should()
906
+
907
+ payload = olive_create_get_payload_irrigation(device.mac)
908
+ signature = olive_create_signature(payload, self._auth_lib.token.access_token)
909
+ headers = {
910
+ "Accept-Encoding": "gzip",
911
+ "User-Agent": "myapp",
912
+ "appid": OLIVE_APP_ID,
913
+ "appinfo": APP_INFO,
914
+ "phoneid": PHONE_ID,
915
+ "access_token": self._auth_lib.token.access_token,
916
+ "signature2": signature,
917
+ }
918
+
919
+ response_json = await self._auth_lib.get(url, headers=headers, params=payload)
920
+
921
+ check_for_errors_iot(self, response_json)
922
+
923
+ return response_json
924
+
925
+ async def _stop_running_schedule(
926
+ self, url: str, device: Device, action: str
927
+ ) -> Dict[Any, Any]:
928
+ await self._auth_lib.refresh_if_should()
929
+
930
+ payload = olive_create_post_payload_irrigation_stop(device.mac, action)
931
+ signature = olive_create_signature(
932
+ json.dumps(payload, separators=(",", ":")),
933
+ self._auth_lib.token.access_token,
934
+ )
935
+ headers = {
936
+ "Accept-Encoding": "gzip",
937
+ "Content-Type": "application/json",
938
+ "User-Agent": "myapp",
939
+ "appid": OLIVE_APP_ID,
940
+ "appinfo": APP_INFO,
941
+ "phoneid": PHONE_ID,
942
+ "access_token": self._auth_lib.token.access_token,
943
+ "signature2": signature,
944
+ }
945
+
946
+ payload_str = json.dumps(payload, separators=(",", ":"))
947
+ response_json = await self._auth_lib.post(
948
+ url, headers=headers, data=payload_str
949
+ )
950
+
951
+ check_for_errors_iot(self, response_json)
952
+
953
+ return response_json
954
+
955
+ async def _start_zone(
956
+ self, url: str, device: Device, zone_number: int, duration: int
957
+ ) -> Dict[Any, Any]:
958
+ await self._auth_lib.refresh_if_should()
959
+
960
+ payload = olive_create_post_payload_irrigation_quickrun(
961
+ device.mac, zone_number, duration
962
+ )
963
+ signature = olive_create_signature(
964
+ json.dumps(payload, separators=(",", ":")),
965
+ self._auth_lib.token.access_token,
966
+ )
967
+ headers = {
968
+ "Accept-Encoding": "gzip",
969
+ "Content-Type": "application/json",
970
+ "User-Agent": "myapp",
971
+ "appid": OLIVE_APP_ID,
972
+ "appinfo": APP_INFO,
973
+ "phoneid": PHONE_ID,
974
+ "access_token": self._auth_lib.token.access_token,
975
+ "signature2": signature,
976
+ }
977
+
978
+ payload_str = json.dumps(payload, separators=(",", ":"))
979
+ response_json = await self._auth_lib.post(
980
+ url, headers=headers, data=payload_str
981
+ )
982
+
983
+ check_for_errors_iot(self, response_json)
984
+
985
+ return response_json
986
+
987
+ async def _get_schedule_runs(
988
+ self, url: str, device: Device, limit: int = 2
989
+ ) -> Dict[Any, Any]:
990
+ await self._auth_lib.refresh_if_should()
991
+
992
+ payload = olive_create_get_payload_irrigation_schedule_runs(device.mac)
993
+ payload["limit"] = limit
994
+ signature = olive_create_signature(payload, self._auth_lib.token.access_token)
995
+ headers = {
996
+ "Accept-Encoding": "gzip",
997
+ "User-Agent": "myapp",
998
+ "appid": OLIVE_APP_ID,
999
+ "appinfo": APP_INFO,
1000
+ "phoneid": PHONE_ID,
1001
+ "access_token": self._auth_lib.token.access_token,
1002
+ "signature2": signature,
1003
+ }
1004
+
1005
+ response_json = await self._auth_lib.get(url, headers=headers, params=payload)
1006
+
1007
+ check_for_errors_iot(self, response_json)
1008
+
1009
+ return response_json
@@ -15,7 +15,20 @@ _LOGGER = logging.getLogger(__name__)
15
15
 
16
16
 
17
17
  class Bulb(Device):
18
- """Bulb class for interacting with Wyze bulbs."""
18
+ """Bulb class for interacting with Wyze bulbs.
19
+
20
+ Note: When created via get_bulbs(), bulb properties (brightness, color,
21
+ color_temp, on) are initialized with default values. Call
22
+ BulbService.update(bulb) to fetch the actual current values from the
23
+ Wyze API.
24
+
25
+ Example:
26
+ bulb_service = await client.bulb_service
27
+ bulbs = await bulb_service.get_bulbs()
28
+ for bulb in bulbs:
29
+ bulb = await bulb_service.update(bulb) # Fetches actual values
30
+ print(f"Brightness: {bulb.brightness}, Color: {bulb.color}")
31
+ """
19
32
 
20
33
  _brightness: int = 0
21
34
  _color_temp: int = 1800
@@ -90,10 +103,14 @@ class BulbService(BaseService):
90
103
  """Bulb service for interacting with Wyze bulbs."""
91
104
 
92
105
  async def update(self, bulb: Bulb) -> Bulb:
93
- """Update the bulb object with the latest device parameters.
106
+ """Fetch and update the bulb's current state from the Wyze API.
107
+
108
+ This method retrieves the actual values for brightness, color,
109
+ color_temp, on/off state, and other properties from the Wyze API.
110
+ Must be called after get_bulbs() to get accurate property values.
94
111
 
95
112
  :param bulb: Bulb object to update
96
- :return: Updated bulb object
113
+ :return: Updated bulb object with current property values
97
114
  """
98
115
  # Get updated device_params
99
116
  async with BaseService._update_lock:
@@ -131,7 +148,11 @@ class BulbService(BaseService):
131
148
  async def get_bulbs(self) -> List[Bulb]:
132
149
  """Get a list of all bulbs.
133
150
 
134
- :return: List of Bulb objects
151
+ Note: Returned bulbs have default property values (brightness=0,
152
+ color="000000", etc.). Call update(bulb) on each bulb to fetch
153
+ the actual current values from the Wyze API.
154
+
155
+ :return: List of Bulb objects with default property values
135
156
  """
136
157
  if self._devices is None:
137
158
  self._devices = await self.get_object_list()
@@ -0,0 +1,246 @@
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
+
57
+ def __init__(self, dictionary: Dict[Any, Any]):
58
+ self.zone_number: int = dictionary.get("zone_number", 1)
59
+ self.name: str = dictionary.get("name", "Zone 1")
60
+ self.enabled: bool = dictionary.get("enabled", True)
61
+ self.zone_id: str = dictionary.get("zone_id", "zone_id")
62
+ self.smart_duration: int = dictionary.get("smart_duration", 600)
63
+
64
+ # this quickrun duration is used only for running a zone manually
65
+ # the wyze api has no such value, but takes a duration as part of the api call
66
+ # the default value grabs the wyze smart_duration but all further updates
67
+ # are managed through the home assistant state
68
+ self.quickrun_duration: int = dictionary.get("smart_duration", 600)
69
+
70
+
71
+ class Irrigation(Device):
72
+ def __init__(self, dictionary: Dict[Any, Any]):
73
+ super().__init__(dictionary)
74
+
75
+ # the below comes from the get_iot_prop call
76
+ self.RSSI: int = 0
77
+ self.IP: str = "192.168.1.100"
78
+ self.sn: str = "SN123456789"
79
+ self.available: bool = False
80
+ self.ssid: str = "ssid"
81
+ # the below comes from the device_info call
82
+ self.zones: List[Zone] = []
83
+
84
+
85
+ class IrrigationService(BaseService):
86
+ async def update(self, irrigation: Irrigation) -> Irrigation:
87
+ """Update the irrigation device with latest data from Wyze API."""
88
+ # Get IoT properties
89
+ properties = (await self.get_iot_prop(irrigation))["data"]["props"]
90
+
91
+ # Update device properties
92
+ irrigation.RSSI = properties.get("RSSI", -65)
93
+ irrigation.IP = properties.get("IP", "192.168.1.100")
94
+ irrigation.sn = properties.get("sn", "SN123456789")
95
+ irrigation.ssid = properties.get("ssid", "ssid")
96
+ irrigation.available = (
97
+ properties.get(IrrigationProps.IOT_STATE.value) == "connected"
98
+ )
99
+
100
+ # Get zones
101
+ zones = (await self.get_zone_by_device(irrigation))["data"]["zones"]
102
+
103
+ # Update zones
104
+ irrigation.zones = []
105
+ for zone in zones:
106
+ irrigation.zones.append(Zone(zone))
107
+
108
+ return irrigation
109
+
110
+ async def update_device_props(self, irrigation: Irrigation) -> Irrigation:
111
+ """Update the irrigation device with latest data from Wyze API."""
112
+ # Get IoT properties
113
+ properties = (await self.get_iot_prop(irrigation))["data"]["props"]
114
+
115
+ # Update device properties
116
+ irrigation.RSSI = properties.get("RSSI")
117
+ irrigation.IP = properties.get("IP")
118
+ irrigation.sn = properties.get("sn")
119
+ irrigation.ssid = properties.get("ssid")
120
+ irrigation.available = (
121
+ properties.get(IrrigationProps.IOT_STATE.value) == "connected"
122
+ )
123
+
124
+ return irrigation
125
+
126
+ async def get_irrigations(self) -> List[Irrigation]:
127
+ if self._devices is None:
128
+ self._devices = await self.get_object_list()
129
+
130
+ irrigations = [
131
+ device
132
+ for device in self._devices
133
+ if device.type == DeviceTypes.IRRIGATION
134
+ and "BS_WK1" in device.product_model
135
+ ]
136
+
137
+ return [Irrigation(irrigation.raw_dict) for irrigation in irrigations]
138
+
139
+ async def start_zone(
140
+ self, irrigation: Device, zone_number: int, quickrun_duration: int
141
+ ) -> Dict[Any, Any]:
142
+ """Start a zone with the specified duration.
143
+
144
+ Args:
145
+ irrigation: The irrigation device
146
+ zone_number: The zone number to start
147
+ quickrun_duration: Duration in seconds to run the zone
148
+
149
+ Returns:
150
+ Dict containing the API response
151
+ """
152
+ url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/quickrun"
153
+ return await self._start_zone(url, irrigation, zone_number, quickrun_duration)
154
+
155
+ async def stop_running_schedule(self, device: Device) -> Dict[Any, Any]:
156
+ """Stop any currently running irrigation schedule.
157
+
158
+ Args:
159
+ device: The irrigation device
160
+
161
+ Returns:
162
+ Dict containing the API response
163
+ """
164
+ url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/runningschedule"
165
+ action = "STOP"
166
+ return await self._stop_running_schedule(url, device, action)
167
+
168
+ async def set_zone_quickrun_duration(
169
+ self, irrigation: Irrigation, zone_number: int, duration: int
170
+ ) -> Irrigation:
171
+ """Set the quickrun duration for a specific zone.
172
+
173
+ Args:
174
+ irrigation: The irrigation device
175
+ zone_number: The zone number to configure
176
+ duration: Duration in seconds for quickrun
177
+ """
178
+ for zone in irrigation.zones:
179
+ if zone.zone_number == zone_number:
180
+ zone.quickrun_duration = duration
181
+ break
182
+
183
+ return irrigation
184
+
185
+ # Private implementation methods
186
+ async def get_iot_prop(self, device: Device) -> Dict[Any, Any]:
187
+ """Get IoT properties for a device."""
188
+ url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/get_iot_prop"
189
+ keys = (
190
+ "zone_state,iot_state,iot_state_update_time,app_version,RSSI,"
191
+ "wifi_mac,sn,device_model,ssid,IP"
192
+ )
193
+ return await self._get_iot_prop(url, device, keys)
194
+
195
+ async def get_device_info(self, device: Device) -> Dict[Any, Any]:
196
+ """Get device info from Wyze API."""
197
+ url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/device_info"
198
+ keys = (
199
+ "wiring,sensor,enable_schedules,notification_enable,notification_watering_begins,"
200
+ "notification_watering_ends,notification_watering_is_skipped,skip_low_temp,skip_wind,"
201
+ "skip_rain,skip_saturation"
202
+ )
203
+ return await self._irrigation_device_info(url, device, keys)
204
+
205
+ async def get_zone_by_device(self, device: Device) -> List[Dict[Any, Any]]:
206
+ """Get zones for a device."""
207
+ url = "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/zone"
208
+ return await self._get_zone_by_device(url, device)
209
+
210
+ async def get_schedule_runs(self, device: Device) -> Dict[Any, Any]:
211
+ """Get schedule runs for an irrigation device.
212
+
213
+ Args:
214
+ device: The irrigation device
215
+
216
+ Returns:
217
+ Dict containing running status and zone information if running
218
+ """
219
+ url = (
220
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/schedule_runs"
221
+ )
222
+ response = await self._get_schedule_runs(url, device, limit=2)
223
+
224
+ # Process the response and return simplified payload
225
+ result = {"running": False}
226
+
227
+ if "data" in response and "schedules" in response["data"]:
228
+ schedules = response["data"]["schedules"]
229
+ for schedule in schedules:
230
+ schedule_state = schedule.get("schedule_state")
231
+
232
+ if schedule_state == "running":
233
+ result["running"] = True
234
+ # Get zone information from zone_runs
235
+ zone_runs = schedule.get("zone_runs")
236
+ # Use the first zone run for zone info
237
+ zone_run = zone_runs[0]
238
+ result["zone_number"] = zone_run.get("zone_number")
239
+ result["zone_name"] = zone_run.get("zone_name")
240
+ break # Found a running schedule, no need to check others
241
+ else:
242
+ _LOGGER.warning(
243
+ "No schedule data found in response for device %s", device.mac
244
+ )
245
+
246
+ return result
@@ -0,0 +1,732 @@
1
+ import unittest
2
+ from unittest.mock import AsyncMock, MagicMock
3
+ from wyzeapy.services.irrigation_service import IrrigationService, Irrigation, Zone
4
+ from wyzeapy.types import DeviceTypes, Device
5
+ from wyzeapy.wyze_auth_lib import WyzeAuthLib
6
+
7
+ # todo: add tests for irrigation service
8
+
9
+
10
+ class TestIrrigationService(unittest.IsolatedAsyncioTestCase):
11
+ async def asyncSetUp(self):
12
+ self.mock_auth_lib = MagicMock(spec=WyzeAuthLib)
13
+ self.irrigation_service = IrrigationService(auth_lib=self.mock_auth_lib)
14
+ self.irrigation_service.get_iot_prop = AsyncMock()
15
+ self.irrigation_service.get_zone_by_device = AsyncMock()
16
+ self.irrigation_service.get_object_list = AsyncMock()
17
+ self.irrigation_service._get_iot_prop = AsyncMock()
18
+ self.irrigation_service._get_zone_by_device = AsyncMock()
19
+ self.irrigation_service._irrigation_device_info = AsyncMock()
20
+
21
+ # Create test irrigation
22
+ self.test_irrigation = Irrigation(
23
+ {
24
+ "device_type": DeviceTypes.IRRIGATION.value,
25
+ "product_model": "BS_WK1",
26
+ "mac": "IRRIG123",
27
+ "nickname": "Test Irrigation",
28
+ "device_params": {"ip": "192.168.1.100"},
29
+ "raw_dict": {},
30
+ }
31
+ )
32
+
33
+ async def test_update_irrigation(self):
34
+ # Mock IoT properties response
35
+ self.irrigation_service.get_iot_prop.return_value = {
36
+ "data": {
37
+ "props": {
38
+ "RSSI": "-65",
39
+ "IP": "192.168.1.100",
40
+ "sn": "SN123456789",
41
+ "ssid": "TestSSID",
42
+ "iot_state": "connected",
43
+ }
44
+ }
45
+ }
46
+
47
+ # Mock zones response
48
+ self.irrigation_service.get_zone_by_device.return_value = {
49
+ "data": {
50
+ "zones": [
51
+ {
52
+ "zone_number": 1,
53
+ "name": "Zone 1",
54
+ "enabled": True,
55
+ "zone_id": "zone1",
56
+ "smart_duration": 600,
57
+ },
58
+ {
59
+ "zone_number": 2,
60
+ "name": "Zone 2",
61
+ "enabled": True,
62
+ "zone_id": "zone2",
63
+ "smart_duration": 900,
64
+ },
65
+ ]
66
+ }
67
+ }
68
+
69
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
70
+
71
+ # Test IoT properties
72
+ self.assertEqual(updated_irrigation.RSSI, "-65")
73
+ self.assertEqual(updated_irrigation.IP, "192.168.1.100")
74
+ self.assertEqual(updated_irrigation.sn, "SN123456789")
75
+ self.assertEqual(updated_irrigation.ssid, "TestSSID")
76
+ self.assertTrue(updated_irrigation.available)
77
+
78
+ # Test zones
79
+ self.assertEqual(len(updated_irrigation.zones), 2)
80
+ self.assertEqual(updated_irrigation.zones[0].zone_number, 1)
81
+ self.assertEqual(updated_irrigation.zones[0].name, "Zone 1")
82
+ self.assertTrue(updated_irrigation.zones[0].enabled)
83
+ self.assertEqual(updated_irrigation.zones[0].quickrun_duration, 600)
84
+ self.assertEqual(updated_irrigation.zones[1].zone_number, 2)
85
+ self.assertEqual(updated_irrigation.zones[1].name, "Zone 2")
86
+ self.assertTrue(updated_irrigation.zones[1].enabled)
87
+ self.assertEqual(updated_irrigation.zones[1].quickrun_duration, 900)
88
+
89
+ async def test_get_irrigations(self):
90
+ # Create a mock irrigation device with all required attributes
91
+ mock_irrigation = MagicMock()
92
+ mock_irrigation.type = DeviceTypes.IRRIGATION
93
+ mock_irrigation.product_model = "BS_WK1"
94
+ mock_irrigation.raw_dict = {
95
+ "device_type": DeviceTypes.IRRIGATION.value,
96
+ "product_model": "BS_WK1",
97
+ "mac": "IRRIG123",
98
+ "nickname": "Test Irrigation",
99
+ "device_params": {"ip": "192.168.1.100"},
100
+ "raw_dict": {},
101
+ }
102
+
103
+ # Mock the get_object_list to return our mock irrigation device
104
+ self.irrigation_service.get_object_list.return_value = [mock_irrigation]
105
+
106
+ # Get the irrigations
107
+ irrigations = await self.irrigation_service.get_irrigations()
108
+
109
+ # Verify the results
110
+ self.assertEqual(len(irrigations), 1)
111
+ self.assertIsInstance(irrigations[0], Irrigation)
112
+ self.assertEqual(irrigations[0].product_model, "BS_WK1")
113
+ self.assertEqual(irrigations[0].mac, "IRRIG123")
114
+ self.irrigation_service.get_object_list.assert_awaited_once()
115
+
116
+ async def test_set_zone_quickrun_duration(self):
117
+ # Setup test irrigation with zones
118
+ self.test_irrigation.zones = [
119
+ Zone(
120
+ {
121
+ "zone_number": 1,
122
+ "name": "Zone 1",
123
+ "enabled": True,
124
+ "zone_id": "zone1",
125
+ "smart_duration": 400,
126
+ }
127
+ ),
128
+ Zone(
129
+ {
130
+ "zone_number": 2,
131
+ "name": "Zone 2",
132
+ "enabled": True,
133
+ "zone_id": "zone2",
134
+ "smart_duration": 900,
135
+ }
136
+ ),
137
+ ]
138
+
139
+ # Test setting quickrun duration
140
+ await self.irrigation_service.set_zone_quickrun_duration(
141
+ self.test_irrigation, 1, 300
142
+ )
143
+ self.assertEqual(self.test_irrigation.zones[0].quickrun_duration, 300)
144
+
145
+ # Test setting quickrun duration for non-existent zone
146
+ await self.irrigation_service.set_zone_quickrun_duration(
147
+ self.test_irrigation, 999, 300
148
+ )
149
+ # Verify that no zones were modified
150
+ self.assertEqual(len(self.test_irrigation.zones), 2)
151
+ self.assertEqual(
152
+ self.test_irrigation.zones[0].quickrun_duration, 300
153
+ ) # First zone changed to 300
154
+ self.assertEqual(
155
+ self.test_irrigation.zones[1].quickrun_duration, 900
156
+ ) # Second zone should be unchanged at 900
157
+
158
+ async def test_update_with_invalid_property(self):
159
+ self.irrigation_service.get_iot_prop.return_value = {
160
+ "data": {"props": {"invalid_property": "some_value", "RSSI": "-65"}}
161
+ }
162
+
163
+ self.irrigation_service.get_zone_by_device.return_value = {
164
+ "data": {"zones": []}
165
+ }
166
+
167
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
168
+ self.assertEqual(updated_irrigation.RSSI, "-65")
169
+ # Other properties should maintain their default values
170
+ self.assertEqual(updated_irrigation.IP, "192.168.1.100")
171
+ self.assertEqual(updated_irrigation.sn, "SN123456789")
172
+ self.assertEqual(updated_irrigation.ssid, "ssid")
173
+
174
+ async def test_start_zone(self):
175
+ # Mock the _start_zone method
176
+ self.irrigation_service._start_zone = AsyncMock()
177
+ expected_response = {"data": {"result": "success"}}
178
+ self.irrigation_service._start_zone.return_value = expected_response
179
+
180
+ # Test starting a zone
181
+ result = await self.irrigation_service.start_zone(
182
+ self.test_irrigation, zone_number=1, quickrun_duration=300
183
+ )
184
+
185
+ # Verify the call was made with correct parameters
186
+ self.irrigation_service._start_zone.assert_awaited_once_with(
187
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/quickrun",
188
+ self.test_irrigation,
189
+ 1,
190
+ 300,
191
+ )
192
+ self.assertEqual(result, expected_response)
193
+
194
+ async def test_stop_running_schedule(self):
195
+ # Mock the _stop_running_schedule method
196
+ self.irrigation_service._stop_running_schedule = AsyncMock()
197
+ expected_response = {"data": {"result": "stopped"}}
198
+ self.irrigation_service._stop_running_schedule.return_value = expected_response
199
+
200
+ # Test stopping running schedule
201
+ result = await self.irrigation_service.stop_running_schedule(
202
+ self.test_irrigation
203
+ )
204
+
205
+ # Verify the call was made with correct parameters
206
+ self.irrigation_service._stop_running_schedule.assert_awaited_once_with(
207
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/runningschedule",
208
+ self.test_irrigation,
209
+ "STOP",
210
+ )
211
+ self.assertEqual(result, expected_response)
212
+
213
+ async def test_update_device_props(self):
214
+ # Mock IoT properties response
215
+ self.irrigation_service.get_iot_prop.return_value = {
216
+ "data": {
217
+ "props": {
218
+ "RSSI": "-70",
219
+ "IP": "192.168.1.101",
220
+ "sn": "SN987654321",
221
+ "ssid": "NewSSID",
222
+ "iot_state": "connected",
223
+ }
224
+ }
225
+ }
226
+
227
+ updated_irrigation = await self.irrigation_service.update_device_props(
228
+ self.test_irrigation
229
+ )
230
+
231
+ # Test that properties were updated correctly
232
+ self.assertEqual(updated_irrigation.RSSI, "-70")
233
+ self.assertEqual(updated_irrigation.IP, "192.168.1.101")
234
+ self.assertEqual(updated_irrigation.sn, "SN987654321")
235
+ self.assertEqual(updated_irrigation.ssid, "NewSSID")
236
+ self.assertTrue(updated_irrigation.available)
237
+
238
+ async def test_update_device_props_disconnected(self):
239
+ # Mock IoT properties response with disconnected state
240
+ self.irrigation_service.get_iot_prop.return_value = {
241
+ "data": {
242
+ "props": {
243
+ "RSSI": "-80",
244
+ "IP": "192.168.1.102",
245
+ "sn": "SN555666777",
246
+ "ssid": "TestSSID2",
247
+ "iot_state": "disconnected",
248
+ }
249
+ }
250
+ }
251
+
252
+ updated_irrigation = await self.irrigation_service.update_device_props(
253
+ self.test_irrigation
254
+ )
255
+
256
+ # Test that device is marked as unavailable
257
+ self.assertFalse(updated_irrigation.available)
258
+ self.assertEqual(updated_irrigation.RSSI, "-80")
259
+ self.assertEqual(updated_irrigation.IP, "192.168.1.102")
260
+
261
+ async def test_get_iot_prop(self):
262
+ # Mock the get_iot_prop method directly to test the public interface
263
+ expected_response = {"data": {"props": {"RSSI": "-65"}}}
264
+ self.irrigation_service.get_iot_prop.return_value = expected_response
265
+
266
+ # Test get_iot_prop
267
+ result = await self.irrigation_service.get_iot_prop(self.test_irrigation)
268
+
269
+ # Verify the call was made and returned expected result
270
+ self.irrigation_service.get_iot_prop.assert_awaited_once_with(
271
+ self.test_irrigation
272
+ )
273
+ self.assertEqual(result, expected_response)
274
+
275
+ async def test_get_device_info(self):
276
+ # Mock the _irrigation_device_info method
277
+ self.irrigation_service._irrigation_device_info = AsyncMock()
278
+ expected_response = {"data": {"props": {"enable_schedules": True}}}
279
+ self.irrigation_service._irrigation_device_info.return_value = expected_response
280
+
281
+ # Test get_device_info
282
+ result = await self.irrigation_service.get_device_info(self.test_irrigation)
283
+
284
+ # Verify the call was made with correct parameters
285
+ expected_keys = "wiring,sensor,enable_schedules,notification_enable,notification_watering_begins,notification_watering_ends,notification_watering_is_skipped,skip_low_temp,skip_wind,skip_rain,skip_saturation"
286
+ self.irrigation_service._irrigation_device_info.assert_awaited_once_with(
287
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/device_info",
288
+ self.test_irrigation,
289
+ expected_keys,
290
+ )
291
+ self.assertEqual(result, expected_response)
292
+
293
+ async def test_get_zone_by_device_method(self):
294
+ # Mock the get_zone_by_device method directly to test the public interface
295
+ expected_response = {"data": {"zones": [{"zone_number": 1, "name": "Zone 1"}]}}
296
+ self.irrigation_service.get_zone_by_device.return_value = expected_response
297
+
298
+ # Test get_zone_by_device
299
+ result = await self.irrigation_service.get_zone_by_device(self.test_irrigation)
300
+
301
+ # Verify the call was made and returned expected result
302
+ self.irrigation_service.get_zone_by_device.assert_awaited_once_with(
303
+ self.test_irrigation
304
+ )
305
+ self.assertEqual(result, expected_response)
306
+
307
+
308
+ class TestZone(unittest.TestCase):
309
+ def test_zone_initialization_with_defaults(self):
310
+ # Test zone initialization with minimal data
311
+ zone_data = {"zone_number": 3, "name": "Test Zone"}
312
+ zone = Zone(zone_data)
313
+
314
+ self.assertEqual(zone.zone_number, 3)
315
+ self.assertEqual(zone.name, "Test Zone")
316
+ self.assertTrue(zone.enabled) # Default value
317
+ self.assertEqual(zone.zone_id, "zone_id") # Default value
318
+ self.assertEqual(zone.smart_duration, 600) # Default value
319
+ self.assertEqual(
320
+ zone.quickrun_duration, 600
321
+ ) # Default value from smart_duration
322
+
323
+ def test_zone_initialization_with_all_data(self):
324
+ # Test zone initialization with all data
325
+ zone_data = {
326
+ "zone_number": 2,
327
+ "name": "Garden Zone",
328
+ "enabled": False,
329
+ "zone_id": "zone_garden",
330
+ "smart_duration": 1200,
331
+ }
332
+ zone = Zone(zone_data)
333
+
334
+ self.assertEqual(zone.zone_number, 2)
335
+ self.assertEqual(zone.name, "Garden Zone")
336
+ self.assertFalse(zone.enabled)
337
+ self.assertEqual(zone.zone_id, "zone_garden")
338
+ self.assertEqual(zone.smart_duration, 1200)
339
+ self.assertEqual(zone.quickrun_duration, 1200) # Should use smart_duration
340
+
341
+ def test_zone_initialization_empty_dict(self):
342
+ # Test zone initialization with empty dict
343
+ zone = Zone({})
344
+
345
+ self.assertEqual(zone.zone_number, 1) # Default value
346
+ self.assertEqual(zone.name, "Zone 1") # Default value
347
+ self.assertTrue(zone.enabled) # Default value
348
+ self.assertEqual(zone.zone_id, "zone_id") # Default value
349
+ self.assertEqual(zone.smart_duration, 600) # Default value
350
+ self.assertEqual(zone.quickrun_duration, 600) # Default value
351
+
352
+
353
+ class TestIrrigation(unittest.TestCase):
354
+ def test_irrigation_initialization(self):
355
+ # Test irrigation device initialization
356
+ irrigation_data = {
357
+ "device_type": DeviceTypes.IRRIGATION.value,
358
+ "product_model": "BS_WK1",
359
+ "mac": "IRRIG456",
360
+ "nickname": "Backyard Sprinkler",
361
+ "device_params": {"ip": "192.168.1.200"},
362
+ }
363
+ irrigation = Irrigation(irrigation_data)
364
+
365
+ self.assertEqual(irrigation.product_model, "BS_WK1")
366
+ self.assertEqual(irrigation.mac, "IRRIG456")
367
+ self.assertEqual(irrigation.nickname, "Backyard Sprinkler")
368
+
369
+ # Test default values
370
+ self.assertEqual(irrigation.RSSI, 0)
371
+ self.assertEqual(irrigation.IP, "192.168.1.100")
372
+ self.assertEqual(irrigation.sn, "SN123456789")
373
+ self.assertFalse(irrigation.available)
374
+ self.assertEqual(irrigation.ssid, "ssid")
375
+ self.assertEqual(len(irrigation.zones), 0)
376
+
377
+ def test_irrigation_inheritance(self):
378
+ # Test that Irrigation inherits from Device
379
+ irrigation_data = {
380
+ "product_type": DeviceTypes.IRRIGATION.value,
381
+ "product_model": "BS_WK1",
382
+ "mac": "IRRIG789",
383
+ "nickname": "Front Yard Sprinkler",
384
+ }
385
+ irrigation = Irrigation(irrigation_data)
386
+
387
+ # Test inherited Device properties
388
+ self.assertIsInstance(irrigation, Device)
389
+ self.assertEqual(irrigation.type, DeviceTypes.IRRIGATION)
390
+
391
+
392
+ class TestIrrigationServiceEdgeCases(unittest.IsolatedAsyncioTestCase):
393
+ async def asyncSetUp(self):
394
+ self.mock_auth_lib = MagicMock(spec=WyzeAuthLib)
395
+ self.irrigation_service = IrrigationService(auth_lib=self.mock_auth_lib)
396
+ self.irrigation_service.get_iot_prop = AsyncMock()
397
+ self.irrigation_service.get_zone_by_device = AsyncMock()
398
+ self.irrigation_service.get_object_list = AsyncMock()
399
+
400
+ # Create test irrigation
401
+ self.test_irrigation = Irrigation(
402
+ {
403
+ "device_type": DeviceTypes.IRRIGATION.value,
404
+ "product_model": "BS_WK1",
405
+ "mac": "IRRIG123",
406
+ "nickname": "Test Irrigation",
407
+ "device_params": {"ip": "192.168.1.100"},
408
+ "raw_dict": {},
409
+ }
410
+ )
411
+
412
+ async def test_update_with_empty_zones(self):
413
+ # Mock IoT properties response
414
+ self.irrigation_service.get_iot_prop.return_value = {
415
+ "data": {
416
+ "props": {
417
+ "RSSI": "-65",
418
+ "IP": "192.168.1.100",
419
+ "sn": "SN123456789",
420
+ "ssid": "TestSSID",
421
+ "iot_state": "connected",
422
+ }
423
+ }
424
+ }
425
+
426
+ # Mock empty zones response
427
+ self.irrigation_service.get_zone_by_device.return_value = {
428
+ "data": {"zones": []}
429
+ }
430
+
431
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
432
+
433
+ # Verify empty zones list
434
+ self.assertEqual(len(updated_irrigation.zones), 0)
435
+ self.assertTrue(updated_irrigation.available)
436
+
437
+ async def test_update_with_missing_iot_props(self):
438
+ # Mock IoT properties response with missing props
439
+ self.irrigation_service.get_iot_prop.return_value = {"data": {"props": {}}}
440
+
441
+ self.irrigation_service.get_zone_by_device.return_value = {
442
+ "data": {"zones": []}
443
+ }
444
+
445
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
446
+
447
+ # Verify default values are used
448
+ self.assertEqual(updated_irrigation.RSSI, -65)
449
+ self.assertEqual(updated_irrigation.IP, "192.168.1.100")
450
+ self.assertEqual(updated_irrigation.sn, "SN123456789")
451
+ self.assertEqual(updated_irrigation.ssid, "ssid")
452
+ self.assertFalse(
453
+ updated_irrigation.available
454
+ ) # iot_state missing, so not connected
455
+
456
+ async def test_get_irrigations_empty_device_list(self):
457
+ # Mock empty device list
458
+ self.irrigation_service.get_object_list.return_value = []
459
+
460
+ irrigations = await self.irrigation_service.get_irrigations()
461
+
462
+ # Verify empty list returned
463
+ self.assertEqual(len(irrigations), 0)
464
+ self.irrigation_service.get_object_list.assert_awaited_once()
465
+
466
+ async def test_get_irrigations_no_irrigation_devices(self):
467
+ # Mock device list with non-irrigation devices
468
+ mock_camera = MagicMock()
469
+ mock_camera.type = DeviceTypes.CAMERA
470
+ mock_camera.product_model = "CAM_V1"
471
+
472
+ mock_bulb = MagicMock()
473
+ mock_bulb.type = DeviceTypes.LIGHT
474
+ mock_bulb.product_model = "LIGHT_V1"
475
+
476
+ self.irrigation_service.get_object_list.return_value = [mock_camera, mock_bulb]
477
+
478
+ irrigations = await self.irrigation_service.get_irrigations()
479
+
480
+ # Verify no irrigation devices returned
481
+ self.assertEqual(len(irrigations), 0)
482
+
483
+ async def test_get_irrigations_wrong_product_model(self):
484
+ # Mock device list with irrigation type but wrong product model
485
+ mock_irrigation = MagicMock()
486
+ mock_irrigation.type = DeviceTypes.IRRIGATION
487
+ mock_irrigation.product_model = "WRONG_MODEL"
488
+ mock_irrigation.raw_dict = {
489
+ "device_type": DeviceTypes.IRRIGATION.value,
490
+ "product_model": "WRONG_MODEL",
491
+ "mac": "IRRIG123",
492
+ "nickname": "Test Irrigation",
493
+ }
494
+
495
+ self.irrigation_service.get_object_list.return_value = [mock_irrigation]
496
+
497
+ irrigations = await self.irrigation_service.get_irrigations()
498
+
499
+ # Verify no irrigation devices returned due to wrong product model
500
+ self.assertEqual(len(irrigations), 0)
501
+
502
+ async def test_set_zone_quickrun_duration_zone_not_found(self):
503
+ # Setup test irrigation with zones
504
+ self.test_irrigation.zones = [
505
+ Zone(
506
+ {
507
+ "zone_number": 1,
508
+ "name": "Zone 1",
509
+ "enabled": True,
510
+ "zone_id": "zone1",
511
+ "smart_duration": 600,
512
+ }
513
+ )
514
+ ]
515
+
516
+ # Try to set duration for non-existent zone
517
+ result = await self.irrigation_service.set_zone_quickrun_duration(
518
+ self.test_irrigation,
519
+ 99, # Non-existent zone
520
+ 300,
521
+ )
522
+
523
+ # Verify existing zone unchanged
524
+ self.assertEqual(self.test_irrigation.zones[0].quickrun_duration, 600)
525
+ self.assertEqual(result, self.test_irrigation)
526
+
527
+ async def test_set_zone_quickrun_duration_no_zones(self):
528
+ # Test with irrigation that has no zones
529
+ self.test_irrigation.zones = []
530
+
531
+ result = await self.irrigation_service.set_zone_quickrun_duration(
532
+ self.test_irrigation, 1, 300
533
+ )
534
+
535
+ # Verify no error and empty zones list
536
+ self.assertEqual(len(self.test_irrigation.zones), 0)
537
+ self.assertEqual(result, self.test_irrigation)
538
+
539
+ async def test_get_schedule_runs_running_schedule(self):
540
+ # Mock the _get_schedule_runs method
541
+ self.irrigation_service._get_schedule_runs = AsyncMock()
542
+ mock_response = {
543
+ "data": {
544
+ "schedules": [
545
+ {
546
+ "schedule_state": "running",
547
+ "schedule_name": "Morning Watering",
548
+ "zone_runs": [
549
+ {
550
+ "zone_number": 3,
551
+ "zone_name": "Backyard S",
552
+ "start_ts": 1746376809,
553
+ "end_ts": 1746376869,
554
+ }
555
+ ],
556
+ }
557
+ ]
558
+ }
559
+ }
560
+ self.irrigation_service._get_schedule_runs.return_value = mock_response
561
+
562
+ # Test get_schedule_runs with running schedule
563
+ result = await self.irrigation_service.get_schedule_runs(self.test_irrigation)
564
+
565
+ # Verify the call was made with correct parameters
566
+ self.irrigation_service._get_schedule_runs.assert_awaited_once_with(
567
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/schedule_runs",
568
+ self.test_irrigation,
569
+ limit=2,
570
+ )
571
+
572
+ # Verify the result
573
+ expected_result = {"running": True, "zone_number": 3, "zone_name": "Backyard S"}
574
+ self.assertEqual(result, expected_result)
575
+
576
+ async def test_get_schedule_runs_past_schedule(self):
577
+ # Mock the _get_schedule_runs method
578
+ self.irrigation_service._get_schedule_runs = AsyncMock()
579
+ mock_response = {
580
+ "data": {
581
+ "schedules": [
582
+ {
583
+ "schedule_state": "past",
584
+ "schedule_name": "Evening Watering",
585
+ "zone_runs": [
586
+ {
587
+ "zone_number": 1,
588
+ "zone_name": "Front Yard",
589
+ "start_ts": 1746376809,
590
+ "end_ts": 1746376869,
591
+ }
592
+ ],
593
+ }
594
+ ]
595
+ }
596
+ }
597
+ self.irrigation_service._get_schedule_runs.return_value = mock_response
598
+
599
+ # Test get_schedule_runs with past schedule
600
+ result = await self.irrigation_service.get_schedule_runs(self.test_irrigation)
601
+
602
+ # Verify the result
603
+ expected_result = {"running": False}
604
+ self.assertEqual(result, expected_result)
605
+
606
+ async def test_get_schedule_runs_no_schedules(self):
607
+ # Mock the _get_schedule_runs method
608
+ self.irrigation_service._get_schedule_runs = AsyncMock()
609
+ mock_response = {"data": {"schedules": []}}
610
+ self.irrigation_service._get_schedule_runs.return_value = mock_response
611
+
612
+ # Test get_schedule_runs with no schedules
613
+ result = await self.irrigation_service.get_schedule_runs(self.test_irrigation)
614
+
615
+ # Verify the result
616
+ expected_result = {"running": False}
617
+ self.assertEqual(result, expected_result)
618
+
619
+ async def test_get_schedule_runs_no_data(self):
620
+ # Mock the _get_schedule_runs method
621
+ self.irrigation_service._get_schedule_runs = AsyncMock()
622
+ mock_response = {} # No data field
623
+ self.irrigation_service._get_schedule_runs.return_value = mock_response
624
+
625
+ # Test get_schedule_runs with no data
626
+ with self.assertLogs(
627
+ "wyzeapy.services.irrigation_service", level="WARNING"
628
+ ) as log:
629
+ result = await self.irrigation_service.get_schedule_runs(
630
+ self.test_irrigation
631
+ )
632
+
633
+ # Verify the result
634
+ expected_result = {"running": False}
635
+ self.assertEqual(result, expected_result)
636
+
637
+ # Verify warning was logged
638
+ self.assertIn(
639
+ "No schedule data found in response for device IRRIG123", log.output[0]
640
+ )
641
+
642
+ async def test_get_schedule_runs_multiple_schedules_running_first(self):
643
+ # Mock the _get_schedule_runs method
644
+ self.irrigation_service._get_schedule_runs = AsyncMock()
645
+ mock_response = {
646
+ "data": {
647
+ "schedules": [
648
+ {
649
+ "schedule_state": "running",
650
+ "schedule_name": "Morning Watering",
651
+ "zone_runs": [
652
+ {
653
+ "zone_number": 2,
654
+ "zone_name": "Side Yard",
655
+ "start_ts": 1746376809,
656
+ "end_ts": 1746376869,
657
+ }
658
+ ],
659
+ },
660
+ {
661
+ "schedule_state": "past",
662
+ "schedule_name": "Evening Watering",
663
+ "zone_runs": [
664
+ {
665
+ "zone_number": 1,
666
+ "zone_name": "Front Yard",
667
+ "start_ts": 1746376809,
668
+ "end_ts": 1746376869,
669
+ }
670
+ ],
671
+ },
672
+ ]
673
+ }
674
+ }
675
+ self.irrigation_service._get_schedule_runs.return_value = mock_response
676
+
677
+ # Test get_schedule_runs with multiple schedules, first one running
678
+ result = await self.irrigation_service.get_schedule_runs(self.test_irrigation)
679
+
680
+ # Verify the result uses the first running schedule
681
+ expected_result = {"running": True, "zone_number": 2, "zone_name": "Side Yard"}
682
+ self.assertEqual(result, expected_result)
683
+
684
+ async def test_get_schedule_runs_multiple_schedules_running_second(self):
685
+ # Mock the _get_schedule_runs method
686
+ self.irrigation_service._get_schedule_runs = AsyncMock()
687
+ mock_response = {
688
+ "data": {
689
+ "schedules": [
690
+ {
691
+ "schedule_state": "past",
692
+ "schedule_name": "Evening Watering",
693
+ "zone_runs": [
694
+ {
695
+ "zone_number": 1,
696
+ "zone_name": "Front Yard",
697
+ "start_ts": 1746376809,
698
+ "end_ts": 1746376869,
699
+ }
700
+ ],
701
+ },
702
+ {
703
+ "schedule_state": "running",
704
+ "schedule_name": "Morning Watering",
705
+ "zone_runs": [
706
+ {
707
+ "zone_number": 4,
708
+ "zone_name": "Garden Area",
709
+ "start_ts": 1746376809,
710
+ "end_ts": 1746376869,
711
+ }
712
+ ],
713
+ },
714
+ ]
715
+ }
716
+ }
717
+ self.irrigation_service._get_schedule_runs.return_value = mock_response
718
+
719
+ # Test get_schedule_runs with multiple schedules, second one running
720
+ result = await self.irrigation_service.get_schedule_runs(self.test_irrigation)
721
+
722
+ # Verify the result uses the first running schedule found
723
+ expected_result = {
724
+ "running": True,
725
+ "zone_number": 4,
726
+ "zone_name": "Garden Area",
727
+ }
728
+ self.assertEqual(result, expected_result)
729
+
730
+
731
+ if __name__ == "__main__":
732
+ unittest.main()
wyzeapy/types.py CHANGED
@@ -48,6 +48,7 @@ class DeviceTypes(Enum):
48
48
  SENSE_V2_GATEWAY = "S1Gateway"
49
49
  KEYPAD = "Keypad"
50
50
  LIGHTSTRIP = "LightStrip"
51
+ IRRIGATION = "Common"
51
52
 
52
53
 
53
54
  class Device:
@@ -156,6 +157,14 @@ class ThermostatProps(Enum):
156
157
  ASW_HOLD = "asw_hold"
157
158
 
158
159
 
160
+ class IrrigationProps(Enum):
161
+ IOT_STATE = "iot_state" # Connection state: connected, disconnected
162
+ RSSI = "RSSI"
163
+ IP = "IP"
164
+ SN = "sn"
165
+ SSID = "ssid"
166
+
167
+
159
168
  class ResponseCodes(Enum):
160
169
  SUCCESS = "1"
161
170
  PARAMETER_ERROR = "1001"
wyzeapy/utils.py CHANGED
@@ -81,8 +81,13 @@ def wyze_decrypt_cbc(key: str, enc_hex_str: str) -> str:
81
81
 
82
82
  Returns:
83
83
  The decrypted plaintext string.
84
+
85
+ Note:
86
+ MD5 is used here because it is required by Wyze's proprietary API protocol.
87
+ This is not a security vulnerability - it's mandatory for API compatibility.
84
88
  """
85
- key_hash = hashlib.md5(key.encode("utf-8")).digest()
89
+ # MD5 is required by Wyze's API protocol - not a security choice but API compatibility
90
+ key_hash = hashlib.md5(key.encode("utf-8")).digest() # nosec B324
86
91
 
87
92
  iv = b"0123456789ABCDEF"
88
93
  cipher = AES.new(key_hash, AES.MODE_CBC, iv)
@@ -104,10 +109,15 @@ def create_password(password: str) -> str:
104
109
 
105
110
  Returns:
106
111
  The hashed password as a hex string.
112
+
113
+ Note:
114
+ Triple MD5 is mandated by Wyze's authentication API. This cannot be changed
115
+ without breaking compatibility with Wyze's servers.
107
116
  """
108
- hex1 = hashlib.md5(password.encode()).hexdigest()
109
- hex2 = hashlib.md5(hex1.encode()).hexdigest()
110
- return hashlib.md5(hex2.encode()).hexdigest()
117
+ # MD5 is required by Wyze's authentication protocol - not a security choice
118
+ hex1 = hashlib.md5(password.encode()).hexdigest() # nosec B324
119
+ hex2 = hashlib.md5(hex1.encode()).hexdigest() # nosec B324
120
+ return hashlib.md5(hex2.encode()).hexdigest() # nosec B324
111
121
 
112
122
 
113
123
  def check_for_errors_standard(service, response_json: Dict[str, Any]) -> None:
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wyzeapy
3
- Version: 0.5.29
3
+ Version: 0.5.31
4
4
  Summary: A library for interacting with Wyze devices
5
5
  Author-email: Katie Mulliken <katie@mulliken.net>
6
6
  License: GPL-3.0-only
7
7
  Requires-Python: >=3.11.0
8
- Requires-Dist: aiodns<4.0.0,>=3.2.0
8
+ Requires-Dist: aiodns<5.0.0,>=3.2.0
9
9
  Requires-Dist: aiohttp<4.0.0,>=3.11.12
10
10
  Requires-Dist: pycryptodome<4.0.0,>=3.21.0
11
11
  Provides-Extra: dev
12
- Requires-Dist: pdoc<16.0.0,>=15.0.3; extra == 'dev'
13
- Requires-Dist: pytest<9.0.0,>=7.0.0; extra == 'dev'
12
+ Requires-Dist: pdoc<17.0.0,>=15.0.3; extra == 'dev'
13
+ Requires-Dist: pytest<10.0.0,>=7.0.0; extra == 'dev'
@@ -1,22 +1,24 @@
1
- wyzeapy/__init__.py,sha256=PeZ7J6Q9P8CsNrtpoUU7j8_OHMzupjn9pAKV7ba4ECQ,16850
1
+ wyzeapy/__init__.py,sha256=4peDFw5zdEc9T6iR2NxZ1mwhcG3ShW91gceBJqcWZy8,17244
2
2
  wyzeapy/const.py,sha256=3PV2Uq7wDD1X0ZmJBg8GWXYGHrpJvszyr9FuvwjHyus,1249
3
3
  wyzeapy/crypto.py,sha256=ApzPL0hrrd0D4k2jB5psNatJvUSzx1Kxui6_l4NJGO8,2057
4
4
  wyzeapy/exceptions.py,sha256=uKVWooofK22DZ3o9kwxnlJXnhk0VMJXnp-26pNavAis,1136
5
- wyzeapy/payload_factory.py,sha256=HbkVMUsJgFiRnoOh2ppmTAQobRMgFX8DcEuGg9AMQ2k,19735
6
- wyzeapy/types.py,sha256=5VP2ltvYsGZQCovYWmEefTNopz3lHxsbeFiOUtRDYfM,6591
7
- wyzeapy/utils.py,sha256=EXnsZFBxgI3LsIh9Ttg4gq3Aq1VMLOxBUPCspWJX9IQ,7407
5
+ wyzeapy/payload_factory.py,sha256=1QUEgqSSDwEjKv1ew-jQYoXcFk0uf5B4gODw83ep2zg,20638
6
+ wyzeapy/types.py,sha256=LPLc86FkEy8rUoPMwG8xB73NCTVRkJ1O5w7uSfDydLQ,6785
7
+ wyzeapy/utils.py,sha256=_aoP9H7RXuND3nCMP3gJRLBxX6Dsxag81RLm83G5Pu4,7969
8
8
  wyzeapy/wyze_auth_lib.py,sha256=CiWdl_UAzYxKLS8yCfjXxEI87gStJede2eS4g7KlnjE,18273
9
9
  wyzeapy/services/__init__.py,sha256=hbdyglbWQjM4XlNqPIACOEbspdsEEm4k5VXZ1hI0gc8,77
10
- wyzeapy/services/base_service.py,sha256=wMxWIlQyW_Kmsu_U7TvIZR0ax-k22zwZywCKOG8xcPM,31671
11
- wyzeapy/services/bulb_service.py,sha256=DNuT9PBmFhXpaD9rcjoYZMt-TLWWEAC3o0Yyvw_itHA,7825
10
+ wyzeapy/services/base_service.py,sha256=3FsbZHm6ndc6pSGCwrwh6SRgGb7HDK_L5lzuSjaLgO0,35547
11
+ wyzeapy/services/bulb_service.py,sha256=4Z_rX40OGqzRuKz_fgyWGb4di7jtyJSB_ZvFFCDlcCo,8821
12
12
  wyzeapy/services/camera_service.py,sha256=vaJIChKDMg3zWcoC_JqITDBjw4sgFdyUEkl3_w2ML8I,11170
13
13
  wyzeapy/services/hms_service.py,sha256=lQojRASz9AlwqkRfj7W7gOKXpLHrHHVwBGMw5WJ23Nc,2450
14
+ wyzeapy/services/irrigation_service.py,sha256=6HzwFRTjZcM4-Y9exSn5EmS2DZbl1gF_uaiMpK2Kd6c,8670
14
15
  wyzeapy/services/lock_service.py,sha256=NBjlr7pL5zJqdJaH33v1i6BbLHb7TXCkoCql4hCr8J8,2234
15
16
  wyzeapy/services/sensor_service.py,sha256=WSNz0OOLoZKru4d1ZwZ80-pdJ321HssUnwEgVfwX2zM,3578
16
17
  wyzeapy/services/switch_service.py,sha256=2O3J8-hP3vOgGVi0cKiKG_3j71zI6rHiqQd3u7CEKcE,2244
17
18
  wyzeapy/services/thermostat_service.py,sha256=_d-UbD65JArhwsslawvwpTmfVC4tMksY-L1Uu7HW0m4,5360
18
19
  wyzeapy/services/update_manager.py,sha256=5pZJmnyN4rlYJwMtEY13NlPBssnRLhtlLLmXr9t990Q,6770
19
20
  wyzeapy/services/wall_switch_service.py,sha256=cBKmnB2InHKIuoPwQ47t1rDtDplyOyGQYvnfX4fXFcc,4339
20
- wyzeapy-0.5.29.dist-info/METADATA,sha256=xYN-zwQtZz2SYcJ0f9HetzIj7VShIyrDZzHtPQiCCyQ,445
21
- wyzeapy-0.5.29.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
- wyzeapy-0.5.29.dist-info/RECORD,,
21
+ wyzeapy/tests/test_irrigation_service.py,sha256=ui3ZlqLUkoLb-uebK8Q1A83d9lAV9WIuHGbIrqPLyqg,28926
22
+ wyzeapy-0.5.31.dist-info/METADATA,sha256=ps5g5kCz_rY2XLNeVS31nSuX4ZXp1xVQmNJTrCLsTOw,446
23
+ wyzeapy-0.5.31.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
24
+ wyzeapy-0.5.31.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any