wyzeapy 0.5.29__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 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
@@ -441,6 +443,14 @@ class Wyzeapy:
441
443
  if self._sensor_service is None:
442
444
  self._sensor_service = SensorService(self._auth_lib)
443
445
  return self._sensor_service
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
444
454
 
445
455
  @property
446
456
  async def wall_switch_service(self) -> WallSwitchService:
@@ -51,6 +51,36 @@ def olive_create_get_payload(device_mac: str, keys: str) -> Dict[str, Any]:
51
51
 
52
52
  return {"keys": keys, "did": device_mac, "nonce": nonce}
53
53
 
54
+ def olive_create_get_payload_irrigation(device_mac: str) -> Dict[str, Any]:
55
+ nonce = int(time.time() * 1000)
56
+
57
+ return {
58
+ 'device_id': device_mac,
59
+ 'nonce': str(nonce)
60
+ }
61
+
62
+ def olive_create_post_payload_irrigation_stop(device_mac: str, action: str) -> Dict[str, Any]:
63
+ nonce = int(time.time() * 1000)
64
+
65
+ return {
66
+ 'device_id': device_mac,
67
+ 'nonce': str(nonce),
68
+ "action": action
69
+ }
70
+
71
+ def olive_create_post_payload_irrigation_quickrun(device_mac: str, zone_number: int, duration: int) -> Dict[str, Any]:
72
+ nonce = int(time.time() * 1000)
73
+
74
+ return {
75
+ 'device_id': device_mac,
76
+ 'nonce': str(nonce),
77
+ "zone_runs": [
78
+ {
79
+ "zone_number": zone_number,
80
+ "duration": duration
81
+ }
82
+ ]
83
+ }
54
84
 
55
85
  def olive_create_post_payload(
56
86
  device_mac: str, device_model: str, prop_key: str, value: Any
@@ -36,6 +36,9 @@ 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,
39
42
  )
40
43
  from ..types import PropertyIDs, Device, DeviceMgmtToggleType
41
44
  from ..utils import (
@@ -896,3 +899,73 @@ class BaseService:
896
899
  check_for_errors_standard(self, response_json)
897
900
 
898
901
  return response_json["data"]["usage_record_list"]
902
+
903
+ async def _get_zone_by_device(self, url: str, device: Device) -> Dict[Any, Any]:
904
+ await self._auth_lib.refresh_if_should()
905
+
906
+ payload = olive_create_get_payload_irrigation(device.mac)
907
+ signature = olive_create_signature(payload, self._auth_lib.token.access_token)
908
+ headers = {
909
+ 'Accept-Encoding': 'gzip',
910
+ 'User-Agent': 'myapp',
911
+ 'appid': OLIVE_APP_ID,
912
+ 'appinfo': APP_INFO,
913
+ 'phoneid': PHONE_ID,
914
+ 'access_token': self._auth_lib.token.access_token,
915
+ 'signature2': signature
916
+ }
917
+
918
+ response_json = await self._auth_lib.get(url, headers=headers, params=payload)
919
+
920
+ check_for_errors_iot(self, response_json)
921
+
922
+ return response_json
923
+
924
+
925
+ async def _stop_running_schedule(self, url: str, device: Device, action: str) -> Dict[Any, Any]:
926
+ await self._auth_lib.refresh_if_should()
927
+
928
+ payload = olive_create_post_payload_irrigation_stop(device.mac, action)
929
+ signature = olive_create_signature(json.dumps(payload, separators=(',', ':')),
930
+ self._auth_lib.token.access_token)
931
+ headers = {
932
+ 'Accept-Encoding': 'gzip',
933
+ 'Content-Type': 'application/json',
934
+ 'User-Agent': 'myapp',
935
+ 'appid': OLIVE_APP_ID,
936
+ 'appinfo': APP_INFO,
937
+ 'phoneid': PHONE_ID,
938
+ 'access_token': self._auth_lib.token.access_token,
939
+ 'signature2': signature
940
+ }
941
+
942
+ payload_str = json.dumps(payload, separators=(',', ':'))
943
+ response_json = await self._auth_lib.post(url, headers=headers, data=payload_str)
944
+
945
+ check_for_errors_iot(self, response_json)
946
+
947
+ return response_json
948
+
949
+ async def _start_zone(self, url: str, device: Device, zone_number: int, duration: int) -> Dict[Any, Any]:
950
+ await self._auth_lib.refresh_if_should()
951
+
952
+ payload = olive_create_post_payload_irrigation_quickrun(device.mac, zone_number, duration)
953
+ signature = olive_create_signature(json.dumps(payload, separators=(',', ':')),
954
+ self._auth_lib.token.access_token)
955
+ headers = {
956
+ 'Accept-Encoding': 'gzip',
957
+ 'Content-Type': 'application/json',
958
+ 'User-Agent': 'myapp',
959
+ 'appid': OLIVE_APP_ID,
960
+ 'appinfo': APP_INFO,
961
+ 'phoneid': PHONE_ID,
962
+ 'access_token': self._auth_lib.token.access_token,
963
+ 'signature2': signature
964
+ }
965
+
966
+ payload_str = json.dumps(payload, separators=(',', ':'))
967
+ response_json = await self._auth_lib.post(url, headers=headers, data=payload_str)
968
+
969
+ check_for_errors_iot(self, response_json)
970
+
971
+ return response_json
@@ -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)
@@ -0,0 +1,536 @@
1
+ import unittest
2
+ from unittest.mock import AsyncMock, MagicMock
3
+ from wyzeapy.services.irrigation_service import (
4
+ IrrigationService, Irrigation, Zone
5
+ )
6
+ from wyzeapy.types import DeviceTypes, Device
7
+ from wyzeapy.wyze_auth_lib import WyzeAuthLib
8
+
9
+ # todo: add tests for irrigation service
10
+
11
+ class TestIrrigationService(unittest.IsolatedAsyncioTestCase):
12
+ async def asyncSetUp(self):
13
+ self.mock_auth_lib = MagicMock(spec=WyzeAuthLib)
14
+ self.irrigation_service = IrrigationService(auth_lib=self.mock_auth_lib)
15
+ self.irrigation_service.get_iot_prop = AsyncMock()
16
+ self.irrigation_service.get_zone_by_device = AsyncMock()
17
+ self.irrigation_service.get_object_list = AsyncMock()
18
+ self.irrigation_service._get_iot_prop = AsyncMock()
19
+ self.irrigation_service._get_zone_by_device = AsyncMock()
20
+ self.irrigation_service._irrigation_device_info = AsyncMock()
21
+
22
+ # Create test irrigation
23
+ self.test_irrigation = Irrigation({
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
+ async def test_update_irrigation(self):
33
+ # Mock IoT properties response
34
+ self.irrigation_service.get_iot_prop.return_value = {
35
+ 'data': {
36
+ 'props': {
37
+ 'RSSI': '-65',
38
+ 'IP': '192.168.1.100',
39
+ 'sn': 'SN123456789',
40
+ 'ssid': 'TestSSID',
41
+ 'iot_state': 'connected'
42
+ }
43
+ }
44
+ }
45
+
46
+ # Mock zones response
47
+ self.irrigation_service.get_zone_by_device.return_value = {
48
+ 'data': {
49
+ 'zones': [
50
+ {
51
+ 'zone_number': 1,
52
+ 'name': 'Zone 1',
53
+ 'enabled': True,
54
+ 'zone_id': 'zone1',
55
+ 'smart_duration': 600
56
+ },
57
+ {
58
+ 'zone_number': 2,
59
+ 'name': 'Zone 2',
60
+ 'enabled': True,
61
+ 'zone_id': 'zone2',
62
+ 'smart_duration': 900
63
+ }
64
+ ]
65
+ }
66
+ }
67
+
68
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
69
+
70
+ # Test IoT properties
71
+ self.assertEqual(updated_irrigation.RSSI, '-65')
72
+ self.assertEqual(updated_irrigation.IP, '192.168.1.100')
73
+ self.assertEqual(updated_irrigation.sn, 'SN123456789')
74
+ self.assertEqual(updated_irrigation.ssid, 'TestSSID')
75
+ self.assertTrue(updated_irrigation.available)
76
+
77
+ # Test zones
78
+ self.assertEqual(len(updated_irrigation.zones), 2)
79
+ self.assertEqual(updated_irrigation.zones[0].zone_number, 1)
80
+ self.assertEqual(updated_irrigation.zones[0].name, 'Zone 1')
81
+ self.assertTrue(updated_irrigation.zones[0].enabled)
82
+ self.assertEqual(updated_irrigation.zones[0].quickrun_duration, 600)
83
+ self.assertEqual(updated_irrigation.zones[1].zone_number, 2)
84
+ self.assertEqual(updated_irrigation.zones[1].name, 'Zone 2')
85
+ self.assertTrue(updated_irrigation.zones[1].enabled)
86
+ self.assertEqual(updated_irrigation.zones[1].quickrun_duration, 900)
87
+
88
+ async def test_get_irrigations(self):
89
+ # Create a mock irrigation device with all required attributes
90
+ mock_irrigation = MagicMock()
91
+ mock_irrigation.type = DeviceTypes.IRRIGATION
92
+ mock_irrigation.product_model = "BS_WK1"
93
+ mock_irrigation.raw_dict = {
94
+ "device_type": DeviceTypes.IRRIGATION.value,
95
+ "product_model": "BS_WK1",
96
+ "mac": "IRRIG123",
97
+ "nickname": "Test Irrigation",
98
+ "device_params": {"ip": "192.168.1.100"},
99
+ "raw_dict": {}
100
+ }
101
+
102
+ # Mock the get_object_list to return our mock irrigation device
103
+ self.irrigation_service.get_object_list.return_value = [mock_irrigation]
104
+
105
+ # Get the irrigations
106
+ irrigations = await self.irrigation_service.get_irrigations()
107
+
108
+ # Verify the results
109
+ self.assertEqual(len(irrigations), 1)
110
+ self.assertIsInstance(irrigations[0], Irrigation)
111
+ self.assertEqual(irrigations[0].product_model, "BS_WK1")
112
+ self.assertEqual(irrigations[0].mac, "IRRIG123")
113
+ self.irrigation_service.get_object_list.assert_awaited_once()
114
+
115
+ async def test_set_zone_quickrun_duration(self):
116
+ # Setup test irrigation with zones
117
+ self.test_irrigation.zones = [
118
+ Zone({
119
+ 'zone_number': 1,
120
+ 'name': 'Zone 1',
121
+ 'enabled': True,
122
+ 'zone_id': 'zone1',
123
+ 'smart_duration': 400
124
+ }),
125
+ Zone({
126
+ 'zone_number': 2,
127
+ 'name': 'Zone 2',
128
+ 'enabled': True,
129
+ 'zone_id': 'zone2',
130
+ 'smart_duration': 900
131
+ })
132
+ ]
133
+
134
+ # Test setting quickrun duration
135
+ await self.irrigation_service.set_zone_quickrun_duration(
136
+ self.test_irrigation,
137
+ 1,
138
+ 300
139
+ )
140
+ self.assertEqual(self.test_irrigation.zones[0].quickrun_duration, 300)
141
+
142
+ # Test setting quickrun duration for non-existent zone
143
+ await self.irrigation_service.set_zone_quickrun_duration(
144
+ self.test_irrigation,
145
+ 999,
146
+ 300
147
+ )
148
+ # Verify that no zones were modified
149
+ self.assertEqual(len(self.test_irrigation.zones), 2)
150
+ self.assertEqual(self.test_irrigation.zones[0].quickrun_duration, 300) # First zone changed to 300
151
+ self.assertEqual(self.test_irrigation.zones[1].quickrun_duration, 900) # Second zone should be unchanged at 900
152
+
153
+ async def test_update_with_invalid_property(self):
154
+ self.irrigation_service.get_iot_prop.return_value = {
155
+ 'data': {
156
+ 'props': {
157
+ 'invalid_property': 'some_value',
158
+ 'RSSI': '-65'
159
+ }
160
+ }
161
+ }
162
+
163
+ self.irrigation_service.get_zone_by_device.return_value = {
164
+ 'data': {
165
+ 'zones': []
166
+ }
167
+ }
168
+
169
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
170
+ self.assertEqual(updated_irrigation.RSSI, '-65')
171
+ # Other properties should maintain their default values
172
+ self.assertEqual(updated_irrigation.IP, "192.168.1.100")
173
+ self.assertEqual(updated_irrigation.sn, "SN123456789")
174
+ self.assertEqual(updated_irrigation.ssid, "ssid")
175
+
176
+ async def test_start_zone(self):
177
+ # Mock the _start_zone method
178
+ self.irrigation_service._start_zone = AsyncMock()
179
+ expected_response = {'data': {'result': 'success'}}
180
+ self.irrigation_service._start_zone.return_value = expected_response
181
+
182
+ # Test starting a zone
183
+ result = await self.irrigation_service.start_zone(
184
+ self.test_irrigation,
185
+ zone_number=1,
186
+ quickrun_duration=300
187
+ )
188
+
189
+ # Verify the call was made with correct parameters
190
+ self.irrigation_service._start_zone.assert_awaited_once_with(
191
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/quickrun",
192
+ self.test_irrigation,
193
+ 1,
194
+ 300
195
+ )
196
+ self.assertEqual(result, expected_response)
197
+
198
+ async def test_stop_running_schedule(self):
199
+ # Mock the _stop_running_schedule method
200
+ self.irrigation_service._stop_running_schedule = AsyncMock()
201
+ expected_response = {'data': {'result': 'stopped'}}
202
+ self.irrigation_service._stop_running_schedule.return_value = expected_response
203
+
204
+ # Test stopping running schedule
205
+ result = await self.irrigation_service.stop_running_schedule(self.test_irrigation)
206
+
207
+ # Verify the call was made with correct parameters
208
+ self.irrigation_service._stop_running_schedule.assert_awaited_once_with(
209
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/runningschedule",
210
+ self.test_irrigation,
211
+ "STOP"
212
+ )
213
+ self.assertEqual(result, expected_response)
214
+
215
+ async def test_update_device_props(self):
216
+ # Mock IoT properties response
217
+ self.irrigation_service.get_iot_prop.return_value = {
218
+ 'data': {
219
+ 'props': {
220
+ 'RSSI': '-70',
221
+ 'IP': '192.168.1.101',
222
+ 'sn': 'SN987654321',
223
+ 'ssid': 'NewSSID',
224
+ 'iot_state': 'connected'
225
+ }
226
+ }
227
+ }
228
+
229
+ updated_irrigation = await self.irrigation_service.update_device_props(self.test_irrigation)
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(self.test_irrigation)
253
+
254
+ # Test that device is marked as unavailable
255
+ self.assertFalse(updated_irrigation.available)
256
+ self.assertEqual(updated_irrigation.RSSI, '-80')
257
+ self.assertEqual(updated_irrigation.IP, '192.168.1.102')
258
+
259
+ async def test_get_iot_prop(self):
260
+ # Mock the get_iot_prop method directly to test the public interface
261
+ expected_response = {'data': {'props': {'RSSI': '-65'}}}
262
+ self.irrigation_service.get_iot_prop.return_value = expected_response
263
+
264
+ # Test get_iot_prop
265
+ result = await self.irrigation_service.get_iot_prop(self.test_irrigation)
266
+
267
+ # Verify the call was made and returned expected result
268
+ self.irrigation_service.get_iot_prop.assert_awaited_once_with(self.test_irrigation)
269
+ self.assertEqual(result, expected_response)
270
+
271
+ async def test_get_device_info(self):
272
+ # Mock the _irrigation_device_info method
273
+ self.irrigation_service._irrigation_device_info = AsyncMock()
274
+ expected_response = {'data': {'props': {'enable_schedules': True}}}
275
+ self.irrigation_service._irrigation_device_info.return_value = expected_response
276
+
277
+ # Test get_device_info
278
+ result = await self.irrigation_service.get_device_info(self.test_irrigation)
279
+
280
+ # Verify the call was made with correct parameters
281
+ 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'
282
+ self.irrigation_service._irrigation_device_info.assert_awaited_once_with(
283
+ "https://wyze-lockwood-service.wyzecam.com/plugin/irrigation/device_info",
284
+ self.test_irrigation,
285
+ expected_keys
286
+ )
287
+ self.assertEqual(result, expected_response)
288
+
289
+ async def test_get_zone_by_device_method(self):
290
+ # Mock the get_zone_by_device method directly to test the public interface
291
+ expected_response = {'data': {'zones': [{'zone_number': 1, 'name': 'Zone 1'}]}}
292
+ self.irrigation_service.get_zone_by_device.return_value = expected_response
293
+
294
+ # Test get_zone_by_device
295
+ result = await self.irrigation_service.get_zone_by_device(self.test_irrigation)
296
+
297
+ # Verify the call was made and returned expected result
298
+ self.irrigation_service.get_zone_by_device.assert_awaited_once_with(self.test_irrigation)
299
+ self.assertEqual(result, expected_response)
300
+
301
+
302
+ class TestZone(unittest.TestCase):
303
+ def test_zone_initialization_with_defaults(self):
304
+ # Test zone initialization with minimal data
305
+ zone_data = {'zone_number': 3, 'name': 'Test Zone'}
306
+ zone = Zone(zone_data)
307
+
308
+ self.assertEqual(zone.zone_number, 3)
309
+ self.assertEqual(zone.name, 'Test Zone')
310
+ self.assertTrue(zone.enabled) # Default value
311
+ self.assertEqual(zone.zone_id, 'zone_id') # Default value
312
+ self.assertEqual(zone.smart_duration, 600) # Default value
313
+ self.assertEqual(zone.quickrun_duration, 600) # Default value from smart_duration
314
+
315
+ def test_zone_initialization_with_all_data(self):
316
+ # Test zone initialization with all data
317
+ zone_data = {
318
+ 'zone_number': 2,
319
+ 'name': 'Garden Zone',
320
+ 'enabled': False,
321
+ 'zone_id': 'zone_garden',
322
+ 'smart_duration': 1200
323
+ }
324
+ zone = Zone(zone_data)
325
+
326
+ self.assertEqual(zone.zone_number, 2)
327
+ self.assertEqual(zone.name, 'Garden Zone')
328
+ self.assertFalse(zone.enabled)
329
+ self.assertEqual(zone.zone_id, 'zone_garden')
330
+ self.assertEqual(zone.smart_duration, 1200)
331
+ self.assertEqual(zone.quickrun_duration, 1200) # Should use smart_duration
332
+
333
+ def test_zone_initialization_empty_dict(self):
334
+ # Test zone initialization with empty dict
335
+ zone = Zone({})
336
+
337
+ self.assertEqual(zone.zone_number, 1) # Default value
338
+ self.assertEqual(zone.name, 'Zone 1') # Default value
339
+ self.assertTrue(zone.enabled) # Default value
340
+ self.assertEqual(zone.zone_id, 'zone_id') # Default value
341
+ self.assertEqual(zone.smart_duration, 600) # Default value
342
+ self.assertEqual(zone.quickrun_duration, 600) # Default value
343
+
344
+
345
+ class TestIrrigation(unittest.TestCase):
346
+ def test_irrigation_initialization(self):
347
+ # Test irrigation device initialization
348
+ irrigation_data = {
349
+ "device_type": DeviceTypes.IRRIGATION.value,
350
+ "product_model": "BS_WK1",
351
+ "mac": "IRRIG456",
352
+ "nickname": "Backyard Sprinkler",
353
+ "device_params": {"ip": "192.168.1.200"}
354
+ }
355
+ irrigation = Irrigation(irrigation_data)
356
+
357
+ self.assertEqual(irrigation.product_model, "BS_WK1")
358
+ self.assertEqual(irrigation.mac, "IRRIG456")
359
+ self.assertEqual(irrigation.nickname, "Backyard Sprinkler")
360
+
361
+ # Test default values
362
+ self.assertEqual(irrigation.RSSI, 0)
363
+ self.assertEqual(irrigation.IP, "192.168.1.100")
364
+ self.assertEqual(irrigation.sn, "SN123456789")
365
+ self.assertFalse(irrigation.available)
366
+ self.assertEqual(irrigation.ssid, "ssid")
367
+ self.assertEqual(len(irrigation.zones), 0)
368
+
369
+ def test_irrigation_inheritance(self):
370
+ # Test that Irrigation inherits from Device
371
+ irrigation_data = {
372
+ "product_type": DeviceTypes.IRRIGATION.value,
373
+ "product_model": "BS_WK1",
374
+ "mac": "IRRIG789",
375
+ "nickname": "Front Yard Sprinkler"
376
+ }
377
+ irrigation = Irrigation(irrigation_data)
378
+
379
+ # Test inherited Device properties
380
+ self.assertIsInstance(irrigation, Device)
381
+ self.assertEqual(irrigation.type, DeviceTypes.IRRIGATION)
382
+
383
+
384
+ class TestIrrigationServiceEdgeCases(unittest.IsolatedAsyncioTestCase):
385
+ async def asyncSetUp(self):
386
+ self.mock_auth_lib = MagicMock(spec=WyzeAuthLib)
387
+ self.irrigation_service = IrrigationService(auth_lib=self.mock_auth_lib)
388
+ self.irrigation_service.get_iot_prop = AsyncMock()
389
+ self.irrigation_service.get_zone_by_device = AsyncMock()
390
+ self.irrigation_service.get_object_list = AsyncMock()
391
+
392
+ # Create test irrigation
393
+ self.test_irrigation = Irrigation({
394
+ "device_type": DeviceTypes.IRRIGATION.value,
395
+ "product_model": "BS_WK1",
396
+ "mac": "IRRIG123",
397
+ "nickname": "Test Irrigation",
398
+ "device_params": {"ip": "192.168.1.100"},
399
+ "raw_dict": {}
400
+ })
401
+
402
+ async def test_update_with_empty_zones(self):
403
+ # Mock IoT properties response
404
+ self.irrigation_service.get_iot_prop.return_value = {
405
+ 'data': {
406
+ 'props': {
407
+ 'RSSI': '-65',
408
+ 'IP': '192.168.1.100',
409
+ 'sn': 'SN123456789',
410
+ 'ssid': 'TestSSID',
411
+ 'iot_state': 'connected'
412
+ }
413
+ }
414
+ }
415
+
416
+ # Mock empty zones response
417
+ self.irrigation_service.get_zone_by_device.return_value = {
418
+ 'data': {
419
+ 'zones': []
420
+ }
421
+ }
422
+
423
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
424
+
425
+ # Verify empty zones list
426
+ self.assertEqual(len(updated_irrigation.zones), 0)
427
+ self.assertTrue(updated_irrigation.available)
428
+
429
+ async def test_update_with_missing_iot_props(self):
430
+ # Mock IoT properties response with missing props
431
+ self.irrigation_service.get_iot_prop.return_value = {
432
+ 'data': {
433
+ 'props': {}
434
+ }
435
+ }
436
+
437
+ self.irrigation_service.get_zone_by_device.return_value = {
438
+ 'data': {
439
+ 'zones': []
440
+ }
441
+ }
442
+
443
+ updated_irrigation = await self.irrigation_service.update(self.test_irrigation)
444
+
445
+ # Verify default values are used
446
+ self.assertEqual(updated_irrigation.RSSI, -65)
447
+ self.assertEqual(updated_irrigation.IP, '192.168.1.100')
448
+ self.assertEqual(updated_irrigation.sn, 'SN123456789')
449
+ self.assertEqual(updated_irrigation.ssid, 'ssid')
450
+ self.assertFalse(updated_irrigation.available) # iot_state missing, so not connected
451
+
452
+ async def test_get_irrigations_empty_device_list(self):
453
+ # Mock empty device list
454
+ self.irrigation_service.get_object_list.return_value = []
455
+
456
+ irrigations = await self.irrigation_service.get_irrigations()
457
+
458
+ # Verify empty list returned
459
+ self.assertEqual(len(irrigations), 0)
460
+ self.irrigation_service.get_object_list.assert_awaited_once()
461
+
462
+ async def test_get_irrigations_no_irrigation_devices(self):
463
+ # Mock device list with non-irrigation devices
464
+ mock_camera = MagicMock()
465
+ mock_camera.type = DeviceTypes.CAMERA
466
+ mock_camera.product_model = "CAM_V1"
467
+
468
+ mock_bulb = MagicMock()
469
+ mock_bulb.type = DeviceTypes.LIGHT
470
+ mock_bulb.product_model = "LIGHT_V1"
471
+
472
+ self.irrigation_service.get_object_list.return_value = [mock_camera, mock_bulb]
473
+
474
+ irrigations = await self.irrigation_service.get_irrigations()
475
+
476
+ # Verify no irrigation devices returned
477
+ self.assertEqual(len(irrigations), 0)
478
+
479
+ async def test_get_irrigations_wrong_product_model(self):
480
+ # Mock device list with irrigation type but wrong product model
481
+ mock_irrigation = MagicMock()
482
+ mock_irrigation.type = DeviceTypes.IRRIGATION
483
+ mock_irrigation.product_model = "WRONG_MODEL"
484
+ mock_irrigation.raw_dict = {
485
+ "device_type": DeviceTypes.IRRIGATION.value,
486
+ "product_model": "WRONG_MODEL",
487
+ "mac": "IRRIG123",
488
+ "nickname": "Test Irrigation"
489
+ }
490
+
491
+ self.irrigation_service.get_object_list.return_value = [mock_irrigation]
492
+
493
+ irrigations = await self.irrigation_service.get_irrigations()
494
+
495
+ # Verify no irrigation devices returned due to wrong product model
496
+ self.assertEqual(len(irrigations), 0)
497
+
498
+ async def test_set_zone_quickrun_duration_zone_not_found(self):
499
+ # Setup test irrigation with zones
500
+ self.test_irrigation.zones = [
501
+ Zone({
502
+ 'zone_number': 1,
503
+ 'name': 'Zone 1',
504
+ 'enabled': True,
505
+ 'zone_id': 'zone1',
506
+ 'smart_duration': 600
507
+ })
508
+ ]
509
+
510
+ # Try to set duration for non-existent zone
511
+ result = await self.irrigation_service.set_zone_quickrun_duration(
512
+ self.test_irrigation,
513
+ 99, # Non-existent zone
514
+ 300
515
+ )
516
+
517
+ # Verify existing zone unchanged
518
+ self.assertEqual(self.test_irrigation.zones[0].quickrun_duration, 600)
519
+ self.assertEqual(result, self.test_irrigation)
520
+
521
+ async def test_set_zone_quickrun_duration_no_zones(self):
522
+ # Test with irrigation that has no zones
523
+ self.test_irrigation.zones = []
524
+
525
+ result = await self.irrigation_service.set_zone_quickrun_duration(
526
+ self.test_irrigation,
527
+ 1,
528
+ 300
529
+ )
530
+
531
+ # Verify no error and empty zones list
532
+ self.assertEqual(len(self.test_irrigation.zones), 0)
533
+ self.assertEqual(result, self.test_irrigation)
534
+
535
+ if __name__ == '__main__':
536
+ 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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wyzeapy
3
- Version: 0.5.29
3
+ Version: 0.5.30
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
@@ -1,22 +1,24 @@
1
- wyzeapy/__init__.py,sha256=PeZ7J6Q9P8CsNrtpoUU7j8_OHMzupjn9pAKV7ba4ECQ,16850
1
+ wyzeapy/__init__.py,sha256=QckgsDmJxrQFPdVT4rheUqi3cCBSN-jkEWD-6hHmrqk,17248
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
5
+ wyzeapy/payload_factory.py,sha256=D37H0rTFakqnhmYrYjBAhFvRSquujINA7b8UyhRUjl0,20547
6
+ wyzeapy/types.py,sha256=LPLc86FkEy8rUoPMwG8xB73NCTVRkJ1O5w7uSfDydLQ,6785
7
7
  wyzeapy/utils.py,sha256=EXnsZFBxgI3LsIh9Ttg4gq3Aq1VMLOxBUPCspWJX9IQ,7407
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
10
+ wyzeapy/services/base_service.py,sha256=HUIU2uugHhY0KiGvc5ALYQn4NnsFMNZzECLJgqPHR9Q,34573
11
11
  wyzeapy/services/bulb_service.py,sha256=DNuT9PBmFhXpaD9rcjoYZMt-TLWWEAC3o0Yyvw_itHA,7825
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=lzQoUT2COlIMF1oTTDsvb-7H7zkra96i_Cxr4zkfYzs,7110
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=6rknfL0FzkptHs4CcYBamBTMD4pHcG0RyYNe3OgYBI8,21270
22
+ wyzeapy-0.5.30.dist-info/METADATA,sha256=7OVSrLycTNIfh3zcmTI-38EZ0xt4NZExNKamzuTT61I,445
23
+ wyzeapy-0.5.30.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
+ wyzeapy-0.5.30.dist-info/RECORD,,