pymammotion 0.5.34__py3-none-any.whl → 0.5.41__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.

Potentially problematic release.


This version of pymammotion might be problematic. Click here for more details.

Files changed (47) hide show
  1. pymammotion/__init__.py +3 -3
  2. pymammotion/aliyun/cloud_gateway.py +106 -18
  3. pymammotion/aliyun/model/dev_by_account_response.py +198 -20
  4. pymammotion/data/model/device.py +1 -0
  5. pymammotion/data/model/device_config.py +1 -1
  6. pymammotion/data/model/enums.py +3 -1
  7. pymammotion/data/model/generate_route_information.py +2 -2
  8. pymammotion/data/model/hash_list.py +113 -33
  9. pymammotion/data/model/region_data.py +4 -4
  10. pymammotion/data/{state_manager.py → mower_state_manager.py} +17 -7
  11. pymammotion/homeassistant/__init__.py +3 -0
  12. pymammotion/homeassistant/mower_api.py +446 -0
  13. pymammotion/homeassistant/rtk_api.py +54 -0
  14. pymammotion/http/http.py +115 -4
  15. pymammotion/http/model/http.py +77 -2
  16. pymammotion/http/model/response_factory.py +10 -4
  17. pymammotion/mammotion/commands/mammotion_command.py +6 -0
  18. pymammotion/mammotion/commands/messages/navigation.py +10 -6
  19. pymammotion/mammotion/devices/__init__.py +27 -3
  20. pymammotion/mammotion/devices/base.py +16 -138
  21. pymammotion/mammotion/devices/mammotion.py +361 -204
  22. pymammotion/mammotion/devices/mammotion_bluetooth.py +7 -5
  23. pymammotion/mammotion/devices/mammotion_cloud.py +22 -74
  24. pymammotion/mammotion/devices/mammotion_mower_ble.py +49 -0
  25. pymammotion/mammotion/devices/mammotion_mower_cloud.py +39 -0
  26. pymammotion/mammotion/devices/managers/managers.py +81 -0
  27. pymammotion/mammotion/devices/mower_device.py +121 -0
  28. pymammotion/mammotion/devices/mower_manager.py +107 -0
  29. pymammotion/mammotion/devices/rtk_ble.py +89 -0
  30. pymammotion/mammotion/devices/rtk_cloud.py +113 -0
  31. pymammotion/mammotion/devices/rtk_device.py +50 -0
  32. pymammotion/mammotion/devices/rtk_manager.py +122 -0
  33. pymammotion/mqtt/__init__.py +2 -1
  34. pymammotion/mqtt/aliyun_mqtt.py +232 -0
  35. pymammotion/mqtt/mammotion_mqtt.py +132 -194
  36. pymammotion/mqtt/mqtt_models.py +66 -0
  37. pymammotion/proto/__init__.py +1 -1
  38. pymammotion/proto/mctrl_nav.proto +1 -1
  39. pymammotion/proto/mctrl_nav_pb2.py +1 -1
  40. pymammotion/proto/mctrl_nav_pb2.pyi +4 -4
  41. pymammotion/proto/mctrl_sys.proto +1 -1
  42. pymammotion/utility/device_type.py +88 -3
  43. pymammotion/utility/mur_mur_hash.py +132 -87
  44. {pymammotion-0.5.34.dist-info → pymammotion-0.5.41.dist-info}/METADATA +25 -31
  45. {pymammotion-0.5.34.dist-info → pymammotion-0.5.41.dist-info}/RECORD +54 -40
  46. {pymammotion-0.5.34.dist-info → pymammotion-0.5.41.dist-info}/WHEEL +1 -1
  47. {pymammotion-0.5.34.dist-info → pymammotion-0.5.41.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,446 @@
1
+ """Thin api layer between home assistant and pymammotion."""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timedelta
5
+ from logging import getLogger
6
+ from typing import Any
7
+
8
+ from pymammotion.aliyun.cloud_gateway import (
9
+ EXPIRED_CREDENTIAL_EXCEPTIONS,
10
+ DeviceOfflineException,
11
+ FailedRequestException,
12
+ GatewayTimeoutException,
13
+ NoConnectionException,
14
+ )
15
+ from pymammotion.data.model import GenerateRouteInformation
16
+ from pymammotion.data.model.device import MowingDevice
17
+ from pymammotion.data.model.device_config import OperationSettings, create_path_order
18
+ from pymammotion.mammotion.devices import MammotionMowerDeviceManager
19
+ from pymammotion.mammotion.devices.mammotion import Mammotion
20
+ from pymammotion.proto import RptAct, RptInfoType
21
+ from pymammotion.utility.device_type import DeviceType
22
+
23
+ logger = getLogger(__name__)
24
+
25
+
26
+ class HomeAssistantMowerApi:
27
+ """API for interacting with Mammotion Mowers for Home Assistant."""
28
+
29
+ def __init__(self) -> None:
30
+ self.update_failures = 0
31
+ self._mammotion = Mammotion()
32
+ self._map_lock = asyncio.Lock()
33
+ self._last_call_times: dict[str, datetime] = {}
34
+ self._call_intervals = {
35
+ "check_maps": timedelta(minutes=1),
36
+ "read_settings": timedelta(minutes=5),
37
+ "get_errors": timedelta(minutes=1),
38
+ "get_report_cfg": timedelta(seconds=5),
39
+ "get_maintenance": timedelta(minutes=30),
40
+ "device_version_upgrade": timedelta(hours=5),
41
+ }
42
+
43
+ @property
44
+ def mammotion(self) -> Mammotion:
45
+ return self._mammotion
46
+
47
+ def _should_call_api(self, api_name: str, device: MowingDevice | None = None) -> bool:
48
+ """Check if API should be called based on time or criteria."""
49
+ # Time-based check
50
+ if api_name not in self._last_call_times:
51
+ return True
52
+
53
+ last_call = self._last_call_times[api_name]
54
+ interval = self._call_intervals.get(api_name, timedelta(seconds=10))
55
+
56
+ # Criteria-based checks
57
+ if api_name == "check_maps" and device:
58
+ # Call immediately if map data is incomplete
59
+ if len(device.map.area) == 0 or device.map.missing_hashlist():
60
+ return True
61
+
62
+ return datetime.now() - last_call >= interval
63
+
64
+ def _mark_api_called(self, api_name: str) -> None:
65
+ """Mark an API as called with the current timestamp."""
66
+ self._last_call_times[api_name] = datetime.now()
67
+
68
+ async def update(self, device_name: str) -> MowingDevice:
69
+ device = self._mammotion.get_device_by_name(device_name)
70
+
71
+ if device.has_queued_commands():
72
+ return device.state
73
+
74
+ if self._map_lock.locked():
75
+ # if maps is not complete kick off the map sync process again
76
+ if len(device.state.map.missing_hashlist()) > 0:
77
+ await self._mammotion.start_map_sync(device_name)
78
+ return device.state
79
+ # if maps complete
80
+ else:
81
+ self._map_lock.release()
82
+
83
+ # Check maps periodically
84
+ if self._should_call_api("check_maps"):
85
+ await self._map_lock.acquire()
86
+ await self.mammotion.start_map_sync(device_name)
87
+ self._mark_api_called("check_maps")
88
+
89
+ # Read settings less frequently
90
+ if self._should_call_api("read_settings"):
91
+ # await device.async_read_settings()
92
+ self._mark_api_called("read_settings")
93
+
94
+ # Check for errors periodically
95
+ if self._should_call_api("get_errors"):
96
+ await self.async_send_command(device_name, "get_error_code")
97
+ await self.async_send_command(device_name, "get_error_timestamp")
98
+ self._mark_api_called("get_errors")
99
+
100
+ if self._should_call_api("get_report_cfg"):
101
+ await self.async_send_command(device_name, "get_report_cfg")
102
+ self._mark_api_called("get_report_cfg")
103
+
104
+ if self._should_call_api("get_maintenance"):
105
+ await self.async_send_command(device_name, "get_maintenance")
106
+ self._mark_api_called("get_maintenance")
107
+
108
+ if self._should_call_api("device_version_upgrade"):
109
+ self._mark_api_called("device_version_upgrade")
110
+
111
+ return device.state
112
+
113
+ async def async_send_command(self, device_name: str, command: str, **kwargs: Any) -> bool | None:
114
+ """Send command."""
115
+ device = self._mammotion.get_device_by_name(device_name)
116
+
117
+ try:
118
+ # TODO check preference
119
+ if device.cloud:
120
+ return await self.async_send_cloud_command(device, command, **kwargs)
121
+ elif device.ble:
122
+ return await self.async_send_bluetooth_command(device, command, **kwargs)
123
+ except (DeviceOfflineException, NoConnectionException) as ex:
124
+ """Device is offline try bluetooth if we have it."""
125
+ logger.error(f"Device offline: {ex.iot_id}")
126
+ if ble := device.ble:
127
+ # if we don't do this, it will stay connected and no longer update over Wi-Fi
128
+ ble.set_disconnect_strategy(disconnect=True)
129
+ await ble.queue_command(command, **kwargs)
130
+
131
+ return True
132
+ raise DeviceOfflineException(ex.args[0], device.iot_id)
133
+ return False
134
+
135
+ async def async_send_cloud_command(
136
+ self, device: MammotionMowerDeviceManager, key: str, **kwargs: Any
137
+ ) -> bool | None:
138
+ """Send command."""
139
+ if cloud := device.cloud:
140
+ if not device.state.online:
141
+ return False
142
+
143
+ try:
144
+ await cloud.command(key, **kwargs)
145
+ self.update_failures = 0
146
+ return True
147
+ except FailedRequestException:
148
+ self.update_failures += 1
149
+ if self.update_failures < 5:
150
+ await cloud.command(key, **kwargs)
151
+ return True
152
+ return False
153
+ except EXPIRED_CREDENTIAL_EXCEPTIONS:
154
+ self.update_failures += 1
155
+ await self._mammotion.refresh_login(device.mammotion_http.account)
156
+ # TODO tell home assistant the credentials have changed
157
+ if self.update_failures < 5:
158
+ await cloud.command(key, **kwargs)
159
+ return True
160
+ return False
161
+ except GatewayTimeoutException as ex:
162
+ logger.error(f"Gateway timeout exception: {ex.iot_id}")
163
+ self.update_failures = 0
164
+ return False
165
+ except (DeviceOfflineException, NoConnectionException) as ex:
166
+ """Device is offline try bluetooth if we have it."""
167
+ logger.error(f"Device offline: {ex.iot_id}")
168
+ return False
169
+
170
+ @staticmethod
171
+ async def async_send_bluetooth_command(device: MammotionMowerDeviceManager, key: str, **kwargs: Any) -> bool | None:
172
+ """Send command."""
173
+ if ble := device.ble:
174
+ await ble.command(key, **kwargs)
175
+
176
+ return True
177
+ raise DeviceOfflineException("bluetooth command failed", device.iot_id)
178
+
179
+ async def set_scheduled_updates(self, device_name: str, enabled: bool) -> None:
180
+ device = self.mammotion.get_device_by_name(device_name)
181
+ device.state.enabled = enabled
182
+ if device.state.enabled:
183
+ self.update_failures = 0
184
+ if not device.state.online:
185
+ device.state.online = True
186
+ if device.cloud and device.cloud.stopped:
187
+ await device.cloud.start()
188
+ else:
189
+ if device.cloud:
190
+ await device.cloud.stop()
191
+ if device.cloud.mqtt.is_connected():
192
+ device.cloud.mqtt.disconnect()
193
+ if device.ble:
194
+ await device.ble.stop()
195
+
196
+ def is_online(self, device_name: str) -> bool:
197
+ if device := self.mammotion.get_device_by_name(device_name):
198
+ ble = device.ble
199
+ return device.state.online or ble is not None and ble.client.is_connected
200
+ return False
201
+
202
+ async def update_firmware(self, device_name: str, version: str) -> None:
203
+ """Update firmware."""
204
+ device = self.mammotion.get_device_by_name(device_name)
205
+ await device.mammotion_http.start_ota_upgrade(device.iot_id, version)
206
+
207
+ async def async_start_stop_blades(self, device_name: str, start_stop: bool, blade_height: int = 60) -> None:
208
+ """Start stop blades."""
209
+ if DeviceType.is_luba1(device_name):
210
+ if start_stop:
211
+ await self.async_send_command(device_name, "set_blade_control", on_off=1)
212
+ else:
213
+ await self.async_send_command(device_name, "set_blade_control", on_off=0)
214
+ elif start_stop:
215
+ if DeviceType.is_yuka(device_name) or DeviceType.is_yuka_mini(device_name):
216
+ blade_height = 0
217
+
218
+ await self.async_send_command(
219
+ "operate_on_device",
220
+ main_ctrl=1,
221
+ cut_knife_ctrl=1,
222
+ cut_knife_height=blade_height,
223
+ max_run_speed=1.2,
224
+ )
225
+ else:
226
+ await self.async_send_command(
227
+ "operate_on_device",
228
+ main_ctrl=0,
229
+ cut_knife_ctrl=0,
230
+ cut_knife_height=blade_height,
231
+ max_run_speed=1.2,
232
+ )
233
+
234
+ async def async_set_rain_detection(self, device_name: str, on_off: bool) -> None:
235
+ """Set rain detection."""
236
+ await self.async_send_command(device_name, "read_write_device", rw_id=3, context=int(on_off), rw=1)
237
+
238
+ async def async_read_rain_detection(self, device_name: str) -> None:
239
+ """Set rain detection."""
240
+ await self.async_send_command(device_name, "read_write_device", rw_id=3, context=1, rw=0)
241
+
242
+ async def async_set_sidelight(self, device_name: str, on_off: int) -> None:
243
+ """Set Sidelight."""
244
+ await self.async_send_command(device_name, "read_and_set_sidelight", is_sidelight=bool(on_off), operate=0)
245
+ await self.async_read_sidelight()
246
+
247
+ async def async_read_sidelight(self, device_name: str) -> None:
248
+ """Set Sidelight."""
249
+ await self.async_send_command(device_name, "read_and_set_sidelight", is_sidelight=False, operate=1)
250
+
251
+ async def async_set_manual_light(self, device_name: str, manual_ctrl: bool) -> None:
252
+ """Set manual night light."""
253
+ await self.async_send_command(device_name, "set_car_manual_light", manual_ctrl=manual_ctrl)
254
+ await self.async_send_command(device_name, "get_car_light", ids=1126)
255
+
256
+ async def async_set_night_light(self, device_name: str, night_light: bool) -> None:
257
+ """Set night light."""
258
+ await self.async_send_command(device_name, "set_car_light", on_off=night_light)
259
+ await self.async_send_command(device_name, "get_car_light", ids=1123)
260
+
261
+ async def async_set_traversal_mode(self, device_name: str, context: int) -> None:
262
+ """Set traversal mode."""
263
+ await self.async_send_command(device_name, "traverse_mode", context=context)
264
+
265
+ async def async_set_turning_mode(self, device_name: str, context: int) -> None:
266
+ """Set turning mode."""
267
+ await self.async_send_command(device_name, "turning_mode", context=context)
268
+
269
+ async def async_blade_height(self, device_name: str, height: int) -> int:
270
+ """Set blade height."""
271
+ await self.async_send_command(device_name, "set_blade_height", height=height)
272
+ return height
273
+
274
+ async def async_set_cutter_speed(self, device_name: str, mode: int) -> None:
275
+ """Set cutter speed."""
276
+ await self.async_send_command(device_name, "set_cutter_mode", cutter_mode=mode)
277
+
278
+ async def async_set_speed(self, device_name: str, speed: float) -> None:
279
+ """Set working speed."""
280
+ await self.async_send_command(device_name, "set_speed", speed=speed)
281
+
282
+ async def async_leave_dock(self, device_name: str) -> None:
283
+ """Leave dock."""
284
+ await self.send_command_and_update(device_name, "leave_dock")
285
+
286
+ async def async_cancel_task(self, device_name: str) -> None:
287
+ """Cancel task."""
288
+ await self.send_command_and_update(device_name, "cancel_job")
289
+
290
+ async def async_move_forward(self, device_name: str, speed: float) -> None:
291
+ """Move forward."""
292
+ device = self.mammotion.get_device_by_name(device_name)
293
+ await self.async_send_bluetooth_command(device, "move_forward", linear=speed)
294
+
295
+ async def async_move_left(self, device_name: str, speed: float) -> None:
296
+ """Move left."""
297
+ device = self.mammotion.get_device_by_name(device_name)
298
+ await self.async_send_bluetooth_command(device, "move_left", angular=speed)
299
+
300
+ async def async_move_right(self, device_name: str, speed: float) -> None:
301
+ """Move right."""
302
+ device = self.mammotion.get_device_by_name(device_name)
303
+ await self.async_send_bluetooth_command(device, "move_right", angular=speed)
304
+
305
+ async def async_move_back(self, device_name: str, speed: float) -> None:
306
+ """Move back."""
307
+ device = self.mammotion.get_device_by_name(device_name)
308
+ await self.async_send_bluetooth_command(device, "move_back", linear=speed)
309
+
310
+ async def async_rtk_dock_location(self, device_name: str) -> None:
311
+ """RTK and dock location."""
312
+ await self.async_send_command(device_name, "read_write_device", rw_id=5, rw=1, context=1)
313
+
314
+ async def async_get_area_list(self, device_name: str, iot_id: str) -> None:
315
+ """Mowing area List."""
316
+ await self.async_send_command(device_name, "get_area_name_list", device_id=iot_id)
317
+
318
+ async def async_relocate_charging_station(self, device_name: str) -> None:
319
+ """Reset charging station."""
320
+ await self.async_send_command(device_name, "delete_charge_point")
321
+ # fetch charging location?
322
+ """
323
+ nav {
324
+ todev_get_commondata {
325
+ pver: 1
326
+ subCmd: 2
327
+ action: 6
328
+ type: 5
329
+ totalFrame: 1
330
+ currentFrame: 1
331
+ }
332
+ }
333
+ """
334
+
335
+ async def send_command_and_update(self, device_name: str, command_str: str, **kwargs: Any) -> None:
336
+ """Send command and update."""
337
+ await self.async_send_command(device_name, command_str, **kwargs)
338
+ await self.async_request_iot_sync(device_name)
339
+
340
+ async def async_request_iot_sync(self, device_name: str, stop: bool = False) -> None:
341
+ """Sync specific info from device."""
342
+ await self.async_send_command(
343
+ device_name,
344
+ "request_iot_sys",
345
+ rpt_act=RptAct.RPT_STOP if stop else RptAct.RPT_START,
346
+ rpt_info_type=[
347
+ RptInfoType.RIT_DEV_STA,
348
+ RptInfoType.RIT_DEV_LOCAL,
349
+ RptInfoType.RIT_WORK,
350
+ RptInfoType.RIT_MAINTAIN,
351
+ RptInfoType.RIT_BASESTATION_INFO,
352
+ RptInfoType.RIT_VIO,
353
+ ],
354
+ timeout=10000,
355
+ period=3000,
356
+ no_change_period=4000,
357
+ count=0,
358
+ )
359
+
360
+ def generate_route_information(
361
+ self, device_name: str, operation_settings: OperationSettings
362
+ ) -> GenerateRouteInformation:
363
+ """Generate route information."""
364
+ device = self.mammotion.get_device_by_name(device_name)
365
+ if device.state.report_data.dev:
366
+ dev = device.state.report_data.dev
367
+ if dev.collector_status.collector_installation_status == 0:
368
+ operation_settings.is_dump = False
369
+
370
+ if DeviceType.is_yuka(device_name):
371
+ operation_settings.blade_height = -10
372
+
373
+ route_information = GenerateRouteInformation(
374
+ one_hashs=list(operation_settings.areas),
375
+ rain_tactics=operation_settings.rain_tactics,
376
+ speed=operation_settings.speed,
377
+ ultra_wave=operation_settings.ultra_wave, # touch no touch etc
378
+ toward=operation_settings.toward, # is just angle (route angle)
379
+ toward_included_angle=operation_settings.toward_included_angle # demond_angle
380
+ if operation_settings.channel_mode == 1
381
+ else 0, # crossing angle relative to grid
382
+ toward_mode=operation_settings.toward_mode,
383
+ blade_height=operation_settings.blade_height,
384
+ channel_mode=operation_settings.channel_mode, # single, double, segment or none (route mode)
385
+ channel_width=operation_settings.channel_width, # path space
386
+ job_mode=operation_settings.job_mode, # taskMode grid or border first
387
+ edge_mode=operation_settings.mowing_laps, # perimeter/mowing laps
388
+ path_order=create_path_order(operation_settings, device_name),
389
+ obstacle_laps=operation_settings.obstacle_laps,
390
+ )
391
+
392
+ if DeviceType.is_luba1(device_name):
393
+ route_information.toward_mode = 0
394
+ route_information.toward_included_angle = 0
395
+ return route_information
396
+
397
+ async def async_plan_route(self, device_name: str, operation_settings: OperationSettings) -> bool | None:
398
+ """Plan mow."""
399
+ route_information = self.generate_route_information(device_name, operation_settings)
400
+
401
+ # not sure if this is artificial limit
402
+ # if (
403
+ # DeviceType.is_mini_or_x_series(device_name)
404
+ # and route_information.toward_mode == 0
405
+ # ):
406
+ # route_information.toward = 0
407
+
408
+ return await self.async_send_command(
409
+ device_name, "generate_route_information", generate_route_information=route_information
410
+ )
411
+
412
+ async def async_modify_plan_route(self, device_name: str, operation_settings: OperationSettings) -> bool | None:
413
+ """Modify plan mow."""
414
+ device = self.mammotion.get_device_by_name(device_name)
415
+
416
+ if work := device.state.work:
417
+ operation_settings.areas = work.zone_hashs
418
+ operation_settings.toward = work.toward
419
+ operation_settings.toward_mode = work.toward_mode
420
+ operation_settings.toward_included_angle = work.toward_included_angle
421
+ operation_settings.mowing_laps = work.edge_mode
422
+ operation_settings.job_mode = work.job_mode
423
+ operation_settings.job_id = work.job_id
424
+ operation_settings.job_version = work.job_ver
425
+
426
+ route_information = self.generate_route_information(device_name, operation_settings)
427
+ if route_information.toward_mode == 0:
428
+ route_information.toward = 0
429
+
430
+ return await self.async_send_command(
431
+ device_name, "modify_route_information", generate_route_information=route_information
432
+ )
433
+
434
+ async def start_task(self, device_name: str, plan_id: str) -> None:
435
+ """Start task."""
436
+ await self.async_send_command(device_name, "single_schedule", plan_id=plan_id)
437
+
438
+ async def clear_update_failures(self, device_name: str) -> None:
439
+ """Clear update failures."""
440
+ self.update_failures = 0
441
+ device = self.mammotion.get_device_by_name(device_name)
442
+ if not device.state.online:
443
+ device.state.online = True
444
+ if cloud := device.cloud:
445
+ if cloud.stopped:
446
+ await cloud.start()
@@ -0,0 +1,54 @@
1
+ import json
2
+
3
+ from pymammotion.aliyun.cloud_gateway import DeviceOfflineException, GatewayTimeoutException, SetupException
4
+ from pymammotion.data.model.device import RTKDevice
5
+ from pymammotion.http.model.http import CheckDeviceVersion
6
+ from pymammotion.mammotion.devices.mammotion import Mammotion
7
+
8
+
9
+ class HomeAssistantRTKApi:
10
+ def __init__(self) -> None:
11
+ self._mammotion = Mammotion()
12
+
13
+ @property
14
+ def mammotion(self) -> Mammotion:
15
+ return self._mammotion
16
+
17
+ async def update(self, device_name: str) -> RTKDevice:
18
+ """Update RTK data."""
19
+ device = self.mammotion.get_rtk_device_by_name(device_name)
20
+ try:
21
+ response = await device.cloud_client.get_device_properties(device.iot_id)
22
+ if response.code == 200:
23
+ data = response.data
24
+ if ota_progress := data.otaProgress:
25
+ device.update_check = CheckDeviceVersion.from_dict(ota_progress.value)
26
+ if network_info := data.networkInfo:
27
+ network = json.loads(network_info.value)
28
+ device.state.wifi_rssi = network["wifi_rssi"]
29
+ device.state.wifi_sta_mac = network["wifi_sta_mac"]
30
+ device.state.bt_mac = network["bt_mac"]
31
+ if coordinate := data.coordinate:
32
+ coord_val = json.loads(coordinate.value)
33
+ if device.state.lat == 0:
34
+ device.state.lat = coord_val["lat"]
35
+ if device.state.lon == 0:
36
+ device.state.lon = coord_val["lon"]
37
+ if device_version := data.deviceVersion:
38
+ device.state.device_version = device_version.value
39
+ device.state.online = True
40
+
41
+ ota_info = await device.cloud_client.mammotion_http.get_device_ota_firmware([device.state.iot_id])
42
+ if check_versions := ota_info.data:
43
+ for check_version in check_versions:
44
+ if check_version.device_id == device.state.iot_id:
45
+ device.state.update_check = check_version
46
+ return device.state
47
+ except SetupException:
48
+ """Cloud IOT Gateway is not setup."""
49
+ return device.state
50
+ except DeviceOfflineException:
51
+ device.state.online = False
52
+ except GatewayTimeoutException:
53
+ """Gateway is timing out again."""
54
+ return device.state
pymammotion/http/http.py CHANGED
@@ -1,35 +1,58 @@
1
1
  import csv
2
+ import random
2
3
  import time
3
4
  from typing import cast
4
5
 
5
6
  from aiohttp import ClientSession
7
+ import jwt
6
8
 
7
9
  from pymammotion.const import MAMMOTION_API_DOMAIN, MAMMOTION_CLIENT_ID, MAMMOTION_CLIENT_SECRET, MAMMOTION_DOMAIN
8
10
  from pymammotion.http.encryption import EncryptionUtils
9
11
  from pymammotion.http.model.camera_stream import StreamSubscriptionResponse, VideoResourceResponse
10
- from pymammotion.http.model.http import CheckDeviceVersion, ErrorInfo, LoginResponseData, Response
12
+ from pymammotion.http.model.http import (
13
+ CheckDeviceVersion,
14
+ DeviceInfo,
15
+ DeviceRecords,
16
+ ErrorInfo,
17
+ JWTTokenInfo,
18
+ LoginResponseData,
19
+ MQTTConnection,
20
+ Response,
21
+ )
11
22
  from pymammotion.http.model.response_factory import response_factory
12
23
  from pymammotion.http.model.rtk import RTK
13
24
 
14
25
 
15
26
  class MammotionHTTP:
16
27
  def __init__(self, account: str | None = None, password: str | None = None) -> None:
17
- self.expires_in = 0
28
+ self.device_info: list[DeviceInfo] = []
29
+ self.mqtt_credentials: MQTTConnection | None = None
30
+ self.device_records: DeviceRecords = DeviceRecords(records=[], current=0, total=0, size=0, pages=0)
31
+ self.expires_in = 0.0
18
32
  self.code = 0
19
33
  self.msg = None
20
34
  self.account = account
21
35
  self._password = password
22
36
  self.response: Response | None = None
23
37
  self.login_info: LoginResponseData | None = None
38
+ self.jwt_info: JWTTokenInfo = JWTTokenInfo("", "")
24
39
  self._headers = {"User-Agent": "okhttp/4.9.3", "App-Version": "Home Assistant,1.14.2.29"}
25
40
  self.encryption_utils = EncryptionUtils()
26
41
 
42
+ # Add this method to generate a 10-digit random number
43
+ def get_10_random() -> str:
44
+ """Generate a 10-digit random number as a string."""
45
+ return "".join([str(random.randint(0, 9)) for _ in range(7)])
46
+
47
+ # Replace the line in the __init__ method with:
48
+ self.client_id = f"{int(time.time() * 1000)}_{get_10_random()}_1"
49
+
27
50
  @staticmethod
28
51
  def generate_headers(token: str) -> dict:
29
52
  return {"Authorization": f"Bearer {token}"}
30
53
 
31
54
  async def handle_expiry(self, resp: Response) -> Response:
32
- if resp.code == 401:
55
+ if resp.code == 401 and self.account and self._password:
33
56
  return await self.login(self.account, self._password)
34
57
  return resp
35
58
 
@@ -89,7 +112,9 @@ class MammotionHTTP:
89
112
  ) as resp:
90
113
  data = await resp.json()
91
114
 
115
+ self.login_info.access_token = data["data"].get("accessToken", self.login_info.access_token)
92
116
  self.login_info.authorization_code = data["data"].get("code", self.login_info.authorization_code)
117
+ await self.get_mqtt_credentials()
93
118
  return Response.from_dict(data)
94
119
 
95
120
  async def pair_devices_mqtt(self, mower_name: str, rtk_name: str) -> Response:
@@ -163,6 +188,7 @@ class MammotionHTTP:
163
188
  ) -> Response[StreamSubscriptionResponse]:
164
189
  # Prepare the payload with cameraStates based on is_yuka flag
165
190
  """Fetches stream subscription data for a given IoT device."""
191
+
166
192
  payload = {"deviceId": iot_id, "mode": 0, "cameraStates": []}
167
193
 
168
194
  # Add appropriate cameraStates based on the is_yuka flag
@@ -264,6 +290,88 @@ class MammotionHTTP:
264
290
 
265
291
  return response_factory(Response[list[RTK]], data)
266
292
 
293
+ async def get_user_device_list(self) -> Response[list[DeviceInfo]]:
294
+ """Fetches device list for a user, older devices / aliyun."""
295
+ async with ClientSession(MAMMOTION_API_DOMAIN) as session:
296
+ async with session.get(
297
+ "/device-server/v1/device/list",
298
+ headers={
299
+ **self._headers,
300
+ "Authorization": f"Bearer {self.login_info.access_token}",
301
+ "Content-Type": "application/json",
302
+ "User-Agent": "okhttp/4.9.3",
303
+ },
304
+ ) as resp:
305
+ resp_dict = await resp.json()
306
+ response = response_factory(Response[list[DeviceInfo]], resp_dict)
307
+ self.device_info = response.data if response.data else self.device_info
308
+ return response
309
+
310
+ async def get_user_device_page(self) -> Response[DeviceRecords]:
311
+ """Fetches device list for a user, is either new API or for newer devices."""
312
+ async with ClientSession(self.jwt_info.iot) as session:
313
+ async with session.post(
314
+ "/v1/user/device/page",
315
+ json={
316
+ "iotId": "",
317
+ "pageNumber": 1,
318
+ "pageSize": 100,
319
+ },
320
+ headers={
321
+ **self._headers,
322
+ "Authorization": f"Bearer {self.login_info.access_token}",
323
+ "Content-Type": "application/json",
324
+ "User-Agent": "okhttp/4.9.3",
325
+ "Client-Id": self.client_id,
326
+ "Client-Type": "1",
327
+ },
328
+ ) as resp:
329
+ if resp.status != 200:
330
+ return Response.from_dict({"code": resp.status, "msg": "get device list failed"})
331
+ resp_dict = await resp.json()
332
+ response = response_factory(Response[DeviceRecords], resp_dict)
333
+ self.device_records = response.data if response.data else self.device_records
334
+ return response
335
+
336
+ async def get_mqtt_credentials(self) -> Response[MQTTConnection]:
337
+ """Get mammotion mqtt credentials"""
338
+ async with ClientSession(self.jwt_info.iot) as session:
339
+ async with session.post(
340
+ "/v1/mqtt/auth/jwt",
341
+ headers={
342
+ **self._headers,
343
+ "Authorization": f"Bearer {self.login_info.access_token}",
344
+ "Content-Type": "application/json",
345
+ "User-Agent": "okhttp/4.9.3",
346
+ },
347
+ ) as resp:
348
+ if resp.status != 200:
349
+ return Response.from_dict({"code": resp.status, "msg": "get mqtt failed"})
350
+ resp_dict = await resp.json()
351
+ response = response_factory(Response[MQTTConnection], resp_dict)
352
+ self.mqtt_credentials = response.data
353
+ return response
354
+
355
+ async def mqtt_invoke(self, content: str, device_name: str, iot_id: str) -> Response[dict]:
356
+ """Send mqtt commands to devices."""
357
+ async with ClientSession(self.jwt_info.iot) as session:
358
+ async with session.post(
359
+ "/v1/mqtt/rpc/thing/service/invoke",
360
+ json={"args": {"content": content, "deviceName": device_name, "iotId": iot_id, "productKey": ""}},
361
+ headers={
362
+ **self._headers,
363
+ "Authorization": f"Bearer {self.login_info.access_token}",
364
+ "Content-Type": "application/json",
365
+ "User-Agent": "okhttp/4.9.3",
366
+ "Client-Id": self.client_id,
367
+ "Client-Type": "1",
368
+ },
369
+ ) as resp:
370
+ if resp.status != 200:
371
+ return Response.from_dict({"code": resp.status, "msg": "get mqtt failed"})
372
+ resp_dict = await resp.json()
373
+ return response_factory(Response[dict], resp_dict)
374
+
267
375
  async def refresh_login(self) -> Response[LoginResponseData]:
268
376
  if self.expires_in > time.time():
269
377
  res = await self.refresh_token()
@@ -297,7 +405,7 @@ class MammotionHTTP:
297
405
  return Response.from_dict({"code": resp.status, "msg": "Login failed"})
298
406
  data = await resp.json()
299
407
  login_response = response_factory(Response[LoginResponseData], data)
300
- if login_response.data is None:
408
+ if login_response is None or login_response.data is None:
301
409
  print(login_response)
302
410
  return Response.from_dict({"code": resp.status, "msg": "Login failed"})
303
411
  self.login_info = login_response.data
@@ -308,6 +416,9 @@ class MammotionHTTP:
308
416
  self.response = login_response
309
417
  self.msg = login_response.msg
310
418
  self.code = login_response.code
419
+ decoded_token = jwt.decode(self.response.data.access_token, options={"verify_signature": False})
420
+ if isinstance(decoded_token, dict):
421
+ self.jwt_info = JWTTokenInfo(iot=decoded_token.get("iot", ""), robot=decoded_token.get("robot", ""))
311
422
  # TODO catch errors from mismatch user / password elsewhere
312
423
  # Assuming the data format matches the expected structure
313
424
  return login_response