pymammotion 0.2.28__py3-none-any.whl → 0.2.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.
Files changed (55) hide show
  1. pymammotion/__init__.py +12 -9
  2. pymammotion/aliyun/cloud_gateway.py +57 -39
  3. pymammotion/aliyun/cloud_service.py +3 -3
  4. pymammotion/aliyun/dataclass/dev_by_account_response.py +1 -2
  5. pymammotion/aliyun/dataclass/session_by_authcode_response.py +1 -0
  6. pymammotion/bluetooth/ble.py +6 -6
  7. pymammotion/bluetooth/ble_message.py +30 -16
  8. pymammotion/bluetooth/data/convert.py +1 -1
  9. pymammotion/bluetooth/data/framectrldata.py +1 -1
  10. pymammotion/bluetooth/data/notifydata.py +6 -6
  11. pymammotion/const.py +1 -0
  12. pymammotion/data/model/__init__.py +2 -0
  13. pymammotion/data/model/account.py +1 -1
  14. pymammotion/data/model/device.py +31 -24
  15. pymammotion/data/model/device_config.py +71 -0
  16. pymammotion/data/model/enums.py +4 -4
  17. pymammotion/data/model/excute_boarder_params.py +5 -5
  18. pymammotion/data/model/execute_boarder.py +4 -4
  19. pymammotion/data/model/generate_route_information.py +18 -124
  20. pymammotion/data/model/hash_list.py +4 -7
  21. pymammotion/data/model/location.py +3 -3
  22. pymammotion/data/model/mowing_modes.py +1 -1
  23. pymammotion/data/model/plan.py +4 -4
  24. pymammotion/data/model/region_data.py +4 -4
  25. pymammotion/data/model/report_info.py +1 -1
  26. pymammotion/data/mqtt/event.py +8 -3
  27. pymammotion/data/state_manager.py +13 -12
  28. pymammotion/event/event.py +14 -14
  29. pymammotion/http/http.py +33 -45
  30. pymammotion/http/model/http.py +75 -0
  31. pymammotion/mammotion/commands/messages/driver.py +20 -23
  32. pymammotion/mammotion/commands/messages/navigation.py +47 -48
  33. pymammotion/mammotion/commands/messages/network.py +17 -35
  34. pymammotion/mammotion/commands/messages/system.py +6 -7
  35. pymammotion/mammotion/control/joystick.py +11 -10
  36. pymammotion/mammotion/devices/__init__.py +2 -2
  37. pymammotion/mammotion/devices/base.py +248 -0
  38. pymammotion/mammotion/devices/mammotion.py +52 -1042
  39. pymammotion/mammotion/devices/mammotion_bluetooth.py +447 -0
  40. pymammotion/mammotion/devices/mammotion_cloud.py +244 -0
  41. pymammotion/mqtt/mammotion_future.py +3 -2
  42. pymammotion/mqtt/mammotion_mqtt.py +23 -23
  43. pymammotion/proto/__init__.py +6 -0
  44. pymammotion/utility/constant/__init__.py +3 -1
  45. pymammotion/utility/conversions.py +1 -1
  46. pymammotion/utility/datatype_converter.py +9 -9
  47. pymammotion/utility/device_type.py +47 -18
  48. pymammotion/utility/map.py +2 -2
  49. pymammotion/utility/movement.py +2 -1
  50. pymammotion/utility/periodic.py +5 -5
  51. pymammotion/utility/rocker_util.py +1 -1
  52. {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/METADATA +3 -1
  53. {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/RECORD +55 -51
  54. {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/LICENSE +0 -0
  55. {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/WHEEL +0 -0
@@ -2,130 +2,30 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import queue
6
- import threading
7
5
  import asyncio
8
- import base64
9
- import codecs
10
- import json
11
6
  import logging
12
- from abc import abstractmethod
13
- from collections import deque
14
7
  from enum import Enum
15
8
  from functools import cache
16
- from typing import Any, Callable, Optional, cast, Awaitable
17
- from uuid import UUID
9
+ from typing import Any
18
10
 
19
- import betterproto
20
11
  from aiohttp import ClientSession
21
12
  from bleak.backends.device import BLEDevice
22
- from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection
23
- from bleak.exc import BleakDBusError
24
- from bleak_retry_connector import (
25
- BLEAK_RETRY_EXCEPTIONS,
26
- BleakClientWithServiceCache,
27
- BleakNotFoundError,
28
- establish_connection,
29
- )
30
13
 
31
- from pymammotion.aliyun.cloud_gateway import CloudIOTGateway, DeviceOfflineException, SetupException
14
+ from pymammotion.aliyun.cloud_gateway import CloudIOTGateway
32
15
  from pymammotion.aliyun.dataclass.dev_by_account_response import Device
33
- from pymammotion.bluetooth import BleMessage
34
16
  from pymammotion.const import MAMMOTION_DOMAIN
35
- from pymammotion.data.model import RegionData
36
17
  from pymammotion.data.model.account import Credentials
37
18
  from pymammotion.data.model.device import MowingDevice
38
- from pymammotion.data.mqtt.event import ThingEventMessage
39
- from pymammotion.data.state_manager import StateManager
40
19
  from pymammotion.http.http import connect_http
41
- from pymammotion.mammotion.commands.mammotion_command import MammotionCommand
20
+ from pymammotion.mammotion.devices.mammotion_bluetooth import MammotionBaseBLEDevice
21
+ from pymammotion.mammotion.devices.mammotion_cloud import MammotionBaseCloudDevice, MammotionCloud
42
22
  from pymammotion.mqtt import MammotionMQTT
43
- from pymammotion.mqtt.mammotion_future import MammotionFuture
44
- from pymammotion.proto.luba_msg import LubaMsg
45
- from pymammotion.proto.mctrl_nav import NavGetCommDataAck, NavGetHashListAck
46
- from pymammotion.utility.movement import get_percent, transform_both_speeds
47
-
48
-
49
- class CharacteristicMissingError(Exception):
50
- """Raised when a characteristic is missing."""
51
-
52
-
53
- def _sb_uuid(comms_type: str = "service") -> UUID | str:
54
- """Return Mammotion UUID.
55
-
56
- Args:
57
- comms_type (str): The type of communication (tx, rx, or service).
58
-
59
- Returns:
60
- UUID | str: The UUID for the specified communication type or an error message.
61
-
62
- """
63
- _uuid = {"tx": "ff01", "rx": "ff02", "service": "2A05"}
64
-
65
- if comms_type in _uuid:
66
- return UUID(f"0000{_uuid[comms_type]}-0000-1000-8000-00805f9b34fb")
67
-
68
- return "Incorrect type, choose between: tx, rx or service"
69
-
70
-
71
- READ_CHAR_UUID = _sb_uuid(comms_type="rx")
72
- WRITE_CHAR_UUID = _sb_uuid(comms_type="tx")
73
-
74
- DBUS_ERROR_BACKOFF_TIME = 0.25
75
-
76
- DISCONNECT_DELAY = 10
77
23
 
78
24
  TIMEOUT_CLOUD_RESPONSE = 10
79
25
 
80
26
  _LOGGER = logging.getLogger(__name__)
81
27
 
82
28
 
83
- def slashescape(err):
84
- """Escape a slash character."""
85
- # print err, dir(err), err.start, err.end, err.object[:err.start]
86
- thebyte = err.object[err.start : err.end]
87
- repl = "\\x" + hex(ord(thebyte))[2:]
88
- return (repl, err.end)
89
-
90
-
91
- codecs.register_error("slashescape", slashescape)
92
-
93
-
94
- def find_next_integer(lst: list[int], current_hash: float) -> int | None:
95
- try:
96
- # Find the index of the current integer
97
- current_index = lst.index(current_hash)
98
-
99
- # Check if there is a next integer in the list
100
- if current_index + 1 < len(lst):
101
- return lst[current_index + 1]
102
- else:
103
- return None # Or raise an exception or handle it in some other way
104
- except ValueError:
105
- # Handle the case where current_int is not in the list
106
- return None # Or raise an exception or handle it in some other way
107
-
108
-
109
- def _handle_timeout(fut: asyncio.Future[None]) -> None:
110
- """Handle a timeout."""
111
- if not fut.done():
112
- fut.set_exception(asyncio.TimeoutError)
113
-
114
-
115
- async def _handle_retry(fut: asyncio.Future[None], func, command: bytes) -> None:
116
- """Handle a retry."""
117
- if not fut.done():
118
- await func(command)
119
-
120
-
121
- async def _handle_retry_cloud(self, fut: asyncio.Future[None], func, iot_id: str, command: bytes) -> None:
122
- """Handle a retry."""
123
-
124
- if not fut.done():
125
- self._operation_lock.release()
126
- await self.loop.run_in_executor(None, func, iot_id, command)
127
-
128
-
129
29
  class ConnectionPreference(Enum):
130
30
  """Enum for connection preference."""
131
31
 
@@ -133,13 +33,19 @@ class ConnectionPreference(Enum):
133
33
  WIFI = 1
134
34
  BLUETOOTH = 2
135
35
 
36
+
136
37
  class MammotionMixedDeviceManager:
137
38
  _ble_device: MammotionBaseBLEDevice | None = None
138
39
  _cloud_device: MammotionBaseCloudDevice | None = None
139
40
  _mowing_state: MowingDevice = MowingDevice()
140
41
 
141
- def __init__(self, name: str, cloud_device: Device | None = None,
142
- ble_device: BLEDevice | None = None, mqtt: MammotionMQTT | None = None) -> None:
42
+ def __init__(
43
+ self,
44
+ name: str,
45
+ cloud_device: Device | None = None,
46
+ ble_device: BLEDevice | None = None,
47
+ mqtt: MammotionCloud | None = None,
48
+ ) -> None:
143
49
  self.name = name
144
50
  self.add_ble(ble_device)
145
51
  self.add_cloud(cloud_device, mqtt)
@@ -157,17 +63,16 @@ class MammotionMixedDeviceManager:
157
63
  if ble_device is not None:
158
64
  self._ble_device = MammotionBaseBLEDevice(self._mowing_state, ble_device)
159
65
 
160
- def add_cloud(self, cloud_device: Device | None = None, mqtt: MammotionMQTT | None = None) -> None:
66
+ def add_cloud(self, cloud_device: Device | None = None, mqtt: MammotionCloud | None = None) -> None:
161
67
  if cloud_device is not None:
162
68
  self._cloud_device = MammotionBaseCloudDevice(
163
- mqtt_client=mqtt,
164
- cloud_device=cloud_device,
165
- mowing_state=self._mowing_state)
69
+ mqtt, cloud_device=cloud_device, mowing_state=self._mowing_state
70
+ )
166
71
 
167
- def replace_cloud(self, cloud_device:MammotionBaseCloudDevice) -> None:
72
+ def replace_cloud(self, cloud_device: MammotionBaseCloudDevice) -> None:
168
73
  self._cloud_device = cloud_device
169
74
 
170
- def replace_ble(self, ble_device:MammotionBaseBLEDevice) -> None:
75
+ def replace_ble(self, ble_device: MammotionBaseBLEDevice) -> None:
171
76
  self._ble_device = ble_device
172
77
 
173
78
  def has_cloud(self) -> bool:
@@ -178,7 +83,6 @@ class MammotionMixedDeviceManager:
178
83
 
179
84
 
180
85
  class MammotionDevices:
181
-
182
86
  devices: dict[str, MammotionMixedDeviceManager] = {}
183
87
 
184
88
  def add_device(self, mammotion_device: MammotionMixedDeviceManager) -> None:
@@ -194,33 +98,33 @@ class MammotionDevices:
194
98
  def get_device(self, mammotion_device_name: str) -> MammotionMixedDeviceManager:
195
99
  return self.devices.get(mammotion_device_name)
196
100
 
197
- async def create_devices(ble_device: BLEDevice,
101
+
102
+ async def create_devices(
103
+ ble_device: BLEDevice,
198
104
  cloud_credentials: Credentials | None = None,
199
- preference: ConnectionPreference = ConnectionPreference.BLUETOOTH):
105
+ preference: ConnectionPreference = ConnectionPreference.BLUETOOTH,
106
+ ):
200
107
  mammotion = Mammotion(ble_device, preference)
201
108
 
202
109
  if cloud_credentials and preference == ConnectionPreference.EITHER or preference == ConnectionPreference.WIFI:
203
- cloud_client = await Mammotion.login(cloud_credentials.account_id or cloud_credentials.email,
204
- cloud_credentials.password)
110
+ cloud_client = await Mammotion.login(
111
+ cloud_credentials.account_id or cloud_credentials.email, cloud_credentials.password
112
+ )
205
113
  await mammotion.initiate_cloud_connection(cloud_client)
206
114
 
207
115
  return mammotion
208
116
 
209
117
 
210
118
  @cache
211
- class Mammotion(object):
212
- """Represents a Mammotion device."""
119
+ class Mammotion:
120
+ """Represents a Mammotion account and its devices."""
213
121
 
214
122
  devices = MammotionDevices()
215
123
  cloud_client: CloudIOTGateway | None = None
216
- mqtt: MammotionMQTT | None = None
217
-
218
-
124
+ mqtt: MammotionCloud | None = None
219
125
 
220
126
  def __init__(
221
- self,
222
- ble_device: BLEDevice,
223
- preference: ConnectionPreference = ConnectionPreference.BLUETOOTH
127
+ self, ble_device: BLEDevice, preference: ConnectionPreference = ConnectionPreference.BLUETOOTH
224
128
  ) -> None:
225
129
  """Initialize MammotionDevice."""
226
130
  if ble_device:
@@ -235,22 +139,28 @@ class Mammotion(object):
235
139
  return
236
140
 
237
141
  self.cloud_client = cloud_client
238
- self.mqtt = MammotionMQTT(region_id=cloud_client._region_response.data.regionId,
239
- product_key=cloud_client._aep_response.data.productKey,
240
- device_name=cloud_client._aep_response.data.deviceName,
241
- device_secret=cloud_client._aep_response.data.deviceSecret,
242
- iot_token=cloud_client._session_by_authcode_response.data.iotToken,
243
- client_id=cloud_client._client_id)
142
+ self.mqtt = MammotionCloud(
143
+ MammotionMQTT(
144
+ region_id=cloud_client.region_response.data.regionId,
145
+ product_key=cloud_client.aep_response.data.productKey,
146
+ device_name=cloud_client.aep_response.data.deviceName,
147
+ device_secret=cloud_client.aep_response.data.deviceSecret,
148
+ iot_token=cloud_client.session_by_authcode_response.data.iotToken,
149
+ client_id=cloud_client.client_id,
150
+ cloud_client=cloud_client,
151
+ )
152
+ )
244
153
 
245
- self.mqtt._cloud_client = cloud_client
246
154
  loop = asyncio.get_running_loop()
247
155
  await loop.run_in_executor(None, self.mqtt.connect_async)
248
156
 
249
- for device in cloud_client.listing_dev_by_account_response.data.data:
250
- if device.deviceName.startswith(("Luba-", "Yuka-")):
251
- self.devices.add_device(MammotionMixedDeviceManager(name=device.deviceName, cloud_device=device, mqtt=self.mqtt))
157
+ for device in cloud_client.devices_by_account_response.data.data:
158
+ if device.deviceName.startswith(("Luba-", "Yuka-")) and self.devices.get_device(device.deviceName) is None:
159
+ self.devices.add_device(
160
+ MammotionMixedDeviceManager(name=device.deviceName, cloud_device=device, mqtt=self.mqtt)
161
+ )
252
162
 
253
- def set_disconnect_strategy(self, disconnect: bool):
163
+ def set_disconnect_strategy(self, disconnect: bool) -> None:
254
164
  for device_name, device in self.devices.devices:
255
165
  if device.ble() is not None:
256
166
  ble_device: MammotionBaseBLEDevice = device.ble()
@@ -267,7 +177,9 @@ class Mammotion(object):
267
177
  _LOGGER.debug("AuthCode: " + mammotion_http.login_info.authorization_code)
268
178
  loop = asyncio.get_running_loop()
269
179
  cloud_client.set_http(mammotion_http)
270
- await loop.run_in_executor(None, cloud_client.get_region, country_code, mammotion_http.login_info.authorization_code)
180
+ await loop.run_in_executor(
181
+ None, cloud_client.get_region, country_code, mammotion_http.login_info.authorization_code
182
+ )
271
183
  await cloud_client.connect()
272
184
  await cloud_client.login_by_oauth(country_code, mammotion_http.login_info.authorization_code)
273
185
  await loop.run_in_executor(None, cloud_client.aep_handle)
@@ -276,7 +188,6 @@ class Mammotion(object):
276
188
  await loop.run_in_executor(None, cloud_client.list_binding_by_account)
277
189
  return cloud_client
278
190
 
279
-
280
191
  def get_device_by_name(self, name: str) -> MammotionMixedDeviceManager:
281
192
  return self.devices.get_device(name)
282
193
 
@@ -290,7 +201,7 @@ class Mammotion(object):
290
201
  return await device.cloud().command(key)
291
202
  # TODO work with both with EITHER
292
203
 
293
- async def send_command_with_args(self,name: str, key: str, **kwargs: any):
204
+ async def send_command_with_args(self, name: str, key: str, **kwargs: Any):
294
205
  """Send a command with args to the device."""
295
206
  device = self.get_device_by_name(name)
296
207
  if device:
@@ -300,7 +211,7 @@ class Mammotion(object):
300
211
  return await device.cloud().command(key, **kwargs)
301
212
  # TODO work with both with EITHER
302
213
 
303
- async def start_sync(self, name:str, retry: int):
214
+ async def start_sync(self, name: str, retry: int):
304
215
  device = self.get_device_by_name(name)
305
216
  if device:
306
217
  if self._preference is ConnectionPreference.BLUETOOTH:
@@ -309,7 +220,7 @@ class Mammotion(object):
309
220
  return await device.cloud().start_sync(retry)
310
221
  # TODO work with both with EITHER
311
222
 
312
- async def start_map_sync(self, name:str):
223
+ async def start_map_sync(self, name: str):
313
224
  device = self.get_device_by_name(name)
314
225
  if device:
315
226
  if self._preference is ConnectionPreference.BLUETOOTH:
@@ -322,904 +233,3 @@ class Mammotion(object):
322
233
  device = self.get_device_by_name(name)
323
234
  if device:
324
235
  return device.mower_state()
325
-
326
- def has_field(message: betterproto.Message) -> bool:
327
- """Check if the message has any fields serialized on wire."""
328
- return betterproto.serialized_on_wire(message)
329
-
330
-
331
- class MammotionBaseDevice:
332
- """Base class for Mammotion devices."""
333
-
334
- _mower: MowingDevice
335
- _state_manager: StateManager
336
- _cloud_device: Device | None = None
337
-
338
- def __init__(self, device: MowingDevice, cloud_device: Device | None = None) -> None:
339
- """Initialize MammotionBaseDevice."""
340
- self.loop = asyncio.get_event_loop()
341
- self._raw_data = LubaMsg().to_dict(casing=betterproto.Casing.SNAKE)
342
- self._mower = device
343
- self._state_manager = StateManager(self._mower)
344
- self._state_manager.gethash_ack_callback = self.datahash_response
345
- self._state_manager.get_commondata_ack_callback = self.commdata_response
346
- self._notify_future: asyncio.Future[bytes] | None = None
347
- self._cloud_device = cloud_device
348
-
349
- def set_notification_callback(self, func: Callable[[],Awaitable[None]]):
350
- self._state_manager.on_notification_callback = func
351
-
352
- async def datahash_response(self, hash_ack: NavGetHashListAck):
353
- """Handle datahash responses."""
354
- await self.queue_command("synchronize_hash_data", hash_num=hash_ack.data_couple[0])
355
-
356
- async def commdata_response(self, common_data: NavGetCommDataAck):
357
- """Handle common data responses."""
358
- total_frame = common_data.total_frame
359
- current_frame = common_data.current_frame
360
-
361
- missing_frames = self._mower.map.missing_frame(common_data)
362
- if len(missing_frames) == 0:
363
- # get next in hash ack list
364
-
365
- data_hash = find_next_integer(self._mower.nav.toapp_gethash_ack.data_couple, common_data.hash)
366
- if data_hash is None:
367
- return
368
-
369
- await self.queue_command("synchronize_hash_data", hash_num=data_hash)
370
- else:
371
- if current_frame != missing_frames[0]-1:
372
- current_frame = missing_frames[0]-1
373
-
374
- region_data = RegionData()
375
- region_data.hash = common_data.hash
376
- region_data.action = common_data.action
377
- region_data.type = common_data.type
378
- region_data.total_frame = total_frame
379
- region_data.current_frame = current_frame
380
- await self.queue_command("get_regional_data", regional_data=region_data)
381
-
382
- def _update_raw_data(self, data: bytes) -> None:
383
- """Update raw and model data from notifications."""
384
- tmp_msg = LubaMsg().parse(data)
385
- res = betterproto.which_one_of(tmp_msg, "LubaSubMsg")
386
- match res[0]:
387
- case "nav":
388
- self._update_nav_data(tmp_msg)
389
- case "sys":
390
- self._update_sys_data(tmp_msg)
391
- case "driver":
392
- self._update_driver_data(tmp_msg)
393
- case "net":
394
- self._update_net_data(tmp_msg)
395
- case "mul":
396
- self._update_mul_data(tmp_msg)
397
- case "ota":
398
- self._update_ota_data(tmp_msg)
399
-
400
- self._mower.update_raw(self._raw_data)
401
-
402
- def _update_nav_data(self, tmp_msg):
403
- """Update navigation data."""
404
- nav_sub_msg = betterproto.which_one_of(tmp_msg.nav, "SubNavMsg")
405
- if nav_sub_msg[1] is None:
406
- _LOGGER.debug("Sub message was NoneType %s", nav_sub_msg[0])
407
- return
408
- nav = self._raw_data.get("nav", {})
409
- if isinstance(nav_sub_msg[1], int):
410
- nav[nav_sub_msg[0]] = nav_sub_msg[1]
411
- else:
412
- nav[nav_sub_msg[0]] = nav_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
413
- self._raw_data["nav"] = nav
414
-
415
- def _update_sys_data(self, tmp_msg):
416
- """Update system data."""
417
- sys_sub_msg = betterproto.which_one_of(tmp_msg.sys, "SubSysMsg")
418
- if sys_sub_msg[1] is None:
419
- _LOGGER.debug("Sub message was NoneType %s", sys_sub_msg[0])
420
- return
421
- sys = self._raw_data.get("sys", {})
422
- sys[sys_sub_msg[0]] = sys_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
423
- self._raw_data["sys"] = sys
424
-
425
- def _update_driver_data(self, tmp_msg):
426
- """Update driver data."""
427
- drv_sub_msg = betterproto.which_one_of(tmp_msg.driver, "SubDrvMsg")
428
- if drv_sub_msg[1] is None:
429
- _LOGGER.debug("Sub message was NoneType %s", drv_sub_msg[0])
430
- return
431
- drv = self._raw_data.get("driver", {})
432
- drv[drv_sub_msg[0]] = drv_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
433
- self._raw_data["driver"] = drv
434
-
435
- def _update_net_data(self, tmp_msg):
436
- """Update network data."""
437
- net_sub_msg = betterproto.which_one_of(tmp_msg.net, "NetSubType")
438
- if net_sub_msg[1] is None:
439
- _LOGGER.debug("Sub message was NoneType %s", net_sub_msg[0])
440
- return
441
- net = self._raw_data.get("net", {})
442
- if isinstance(net_sub_msg[1], int):
443
- net[net_sub_msg[0]] = net_sub_msg[1]
444
- else:
445
- net[net_sub_msg[0]] = net_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
446
- self._raw_data["net"] = net
447
-
448
- def _update_mul_data(self, tmp_msg):
449
- """Update mul data."""
450
- mul_sub_msg = betterproto.which_one_of(tmp_msg.mul, "SubMul")
451
- if mul_sub_msg[1] is None:
452
- _LOGGER.debug("Sub message was NoneType %s", mul_sub_msg[0])
453
- return
454
- mul = self._raw_data.get("mul", {})
455
- mul[mul_sub_msg[0]] = mul_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
456
- self._raw_data["mul"] = mul
457
-
458
- def _update_ota_data(self, tmp_msg):
459
- """Update OTA data."""
460
- ota_sub_msg = betterproto.which_one_of(tmp_msg.ota, "SubOtaMsg")
461
- if ota_sub_msg[1] is None:
462
- _LOGGER.debug("Sub message was NoneType %s", ota_sub_msg[0])
463
- return
464
- ota = self._raw_data.get("ota", {})
465
- ota[ota_sub_msg[0]] = ota_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
466
- self._raw_data["ota"] = ota
467
-
468
- @property
469
- def raw_data(self) -> dict[str, Any]:
470
- """Get the raw data of the device."""
471
- return self._raw_data
472
-
473
- @property
474
- def mower(self) -> MowingDevice:
475
- """Get the LubaMsg of the device."""
476
- return self._mower
477
-
478
- @abstractmethod
479
- async def queue_command(self, key: str, **kwargs: any) -> bytes:
480
- """Queue commands to mower."""
481
-
482
- @abstractmethod
483
- async def _send_command(self, key: str, retry: int | None = None) -> bytes | None:
484
- """Send command to device and read response."""
485
-
486
- @abstractmethod
487
- async def _send_command_with_args(self, key: str, **kwargs: any) -> bytes | None:
488
- """Send command to device and read response."""
489
-
490
- @abstractmethod
491
- async def _ble_sync(self):
492
- """Send ble sync command every 3 seconds or sooner."""
493
-
494
- async def start_sync(self, retry: int):
495
- """Start synchronization with the device."""
496
- await self.queue_command("get_device_base_info")
497
- await self.queue_command("get_device_product_model")
498
- await self.queue_command("get_report_cfg")
499
- """RTK and dock location."""
500
- await self.queue_command("allpowerfull_rw", id=5, rw=1, context=1)
501
-
502
- async def start_map_sync(self):
503
- """Start sync of map data."""
504
- await self.queue_command("read_plan", sub_cmd=2, plan_index=0)
505
-
506
- await self.queue_command("get_all_boundary_hash_list", sub_cmd=0)
507
-
508
- await self.queue_command("get_hash_response", total_frame=1, current_frame=1)
509
-
510
-
511
- # work out why this crashes sometimes for better proto
512
- if self._cloud_device:
513
- await self.queue_command(
514
- "get_area_name_list", device_id=self._cloud_device.deviceName
515
- )
516
- if has_field(self._mower.net.toapp_wifi_iot_status):
517
- await self.queue_command(
518
- "get_area_name_list", device_id=self._mower.net.toapp_wifi_iot_status.devicename
519
- )
520
-
521
-
522
- # sub_cmd 3 is job hashes??
523
- # sub_cmd 4 is dump location (yuka)
524
- # jobs list
525
- # hash_list_result = await self._send_command_with_args("get_all_boundary_hash_list", sub_cmd=3)
526
- async def async_get_errors(self):
527
- """Error codes."""
528
- await self.queue_command("allpowerfull_rw", id=5, rw=1, context=2)
529
- await self.queue_command("allpowerfull_rw", id=5, rw=1, context=3)
530
-
531
-
532
- async def move_forward(self, linear: float):
533
- """Move forward. values 0.0 1.0."""
534
- linear_percent = get_percent(abs(linear * 100))
535
- (linear_speed, angular_speed) = transform_both_speeds(90.0, 0.0, linear_percent, 0.0)
536
- await self.queue_command("send_movement", linear_speed=linear_speed, angular_speed=angular_speed)
537
-
538
- async def move_back(self, linear: float):
539
- """Move back. values 0.0 1.0."""
540
- linear_percent = get_percent(abs(linear * 100))
541
- (linear_speed, angular_speed) = transform_both_speeds(270.0, 0.0, linear_percent, 0.0)
542
- await self.queue_command("send_movement", linear_speed=linear_speed, angular_speed=angular_speed)
543
-
544
- async def move_left(self, angulur: float):
545
- """Move forward. values 0.0 1.0."""
546
- angular_percent = get_percent(abs(angulur * 100))
547
- (linear_speed, angular_speed) = transform_both_speeds(0.0, 0.0, 0.0, angular_percent)
548
- await self.queue_command("send_movement", linear_speed=linear_speed, angular_speed=angular_speed)
549
-
550
- async def move_right(self, angulur: float):
551
- """Move back. values 0.0 1.0."""
552
- angular_percent = get_percent(abs(angulur * 100))
553
- (linear_speed, angular_speed) = transform_both_speeds(0.0, 180.0, 0.0, angular_percent)
554
- await self.queue_command("send_movement", linear_speed=linear_speed, angular_speed=angular_speed)
555
-
556
-
557
- async def command(self, key: str, **kwargs):
558
- """Send a command to the device."""
559
- return await self.queue_command(key, **kwargs)
560
-
561
-
562
- class MammotionBaseBLEDevice(MammotionBaseDevice):
563
- """Base class for Mammotion BLE devices."""
564
-
565
- def __init__(self, mowing_state: MowingDevice, device: BLEDevice, interface: int = 0, **kwargs: Any) -> None:
566
- """Initialize MammotionBaseBLEDevice."""
567
- super().__init__(mowing_state)
568
- self._disconnect_strategy = True
569
- self._ble_sync_task = None
570
- self._prev_notification = None
571
- self._interface = f"hci{interface}"
572
- self._device = device
573
- self._mower = mowing_state
574
- self._client: BleakClientWithServiceCache | None = None
575
- self._read_char: BleakGATTCharacteristic | None = None
576
- self._write_char: BleakGATTCharacteristic | None = None
577
- self._disconnect_timer: asyncio.TimerHandle | None = None
578
- self._message: BleMessage | None = None
579
- self._commands: MammotionCommand = MammotionCommand(device.name)
580
- self._expected_disconnect = False
581
- self._connect_lock = asyncio.Lock()
582
- self._operation_lock = asyncio.Lock()
583
- self._key: str | None = None
584
-
585
- def update_device(self, device: BLEDevice) -> None:
586
- """Update the BLE device."""
587
- self._device = device
588
-
589
- async def _ble_sync(self):
590
- command_bytes = self._commands.send_todev_ble_sync(2)
591
- await self._message.post_custom_data_bytes(command_bytes)
592
-
593
- async def run_periodic_sync_task(self) -> None:
594
- """Send ble sync to robot."""
595
- try:
596
- await self._ble_sync()
597
- finally:
598
- self.schedule_ble_sync()
599
-
600
- def schedule_ble_sync(self):
601
- """Periodically sync to keep connection alive."""
602
- if self._client is not None and self._client.is_connected:
603
- self._ble_sync_task = self.loop.call_later(
604
- 130, lambda: asyncio.ensure_future(self.run_periodic_sync_task())
605
- )
606
-
607
- async def queue_command(self, key: str, **kwargs: any) -> bytes | None:
608
- return await self._send_command_with_args(key, **kwargs)
609
-
610
- async def _send_command_with_args(self, key: str, **kwargs) -> bytes | None:
611
- """Send command to device and read response."""
612
- if self._operation_lock.locked():
613
- _LOGGER.debug(
614
- "%s: Operation already in progress, waiting for it to complete; RSSI: %s",
615
- self.name,
616
- self.rssi,
617
- )
618
- async with self._operation_lock:
619
- try:
620
- command_bytes = getattr(self._commands, key)(**kwargs)
621
- return await self._send_command_locked(key, command_bytes)
622
- except BleakNotFoundError:
623
- _LOGGER.exception(
624
- "%s: device not found, no longer in range, or poor RSSI: %s",
625
- self.name,
626
- self.rssi,
627
- )
628
- raise
629
- except CharacteristicMissingError as ex:
630
- _LOGGER.debug(
631
- "%s: characteristic missing: %s; RSSI: %s",
632
- self.name,
633
- ex,
634
- self.rssi,
635
- exc_info=True,
636
- )
637
- except BLEAK_RETRY_EXCEPTIONS:
638
- _LOGGER.debug("%s: communication failed with:", self.name, exc_info=True)
639
-
640
- async def _send_command(self, key: str, retry: int | None = None) -> bytes | None:
641
- """Send command to device and read response."""
642
- if self._operation_lock.locked():
643
- _LOGGER.debug(
644
- "%s: Operation already in progress, waiting for it to complete; RSSI: %s",
645
- self.name,
646
- self.rssi,
647
- )
648
- async with self._operation_lock:
649
- try:
650
- command_bytes = getattr(self._commands, key)()
651
- return await self._send_command_locked(key, command_bytes)
652
- except BleakNotFoundError:
653
- _LOGGER.exception(
654
- "%s: device not found, no longer in range, or poor RSSI: %s",
655
- self.name,
656
- self.rssi,
657
- )
658
- raise
659
- except CharacteristicMissingError as ex:
660
- _LOGGER.debug(
661
- "%s: characteristic missing: %s; RSSI: %s",
662
- self.name,
663
- ex,
664
- self.rssi,
665
- exc_info=True,
666
- )
667
- except BLEAK_RETRY_EXCEPTIONS:
668
- _LOGGER.debug("%s: communication failed with:", self.name, exc_info=True)
669
-
670
- @property
671
- def name(self) -> str:
672
- """Return device name."""
673
- return f"{self._device.name} ({self._device.address})"
674
-
675
- @property
676
- def rssi(self) -> int:
677
- """Return RSSI of device."""
678
- try:
679
- return self._mower.sys.toapp_report_data.connect.ble_rssi
680
- finally:
681
- return 0
682
-
683
- async def _ensure_connected(self):
684
- """Ensure connection to device is established."""
685
- if self._connect_lock.locked():
686
- _LOGGER.debug(
687
- "%s: Connection already in progress, waiting for it to complete; RSSI: %s",
688
- self.name,
689
- self.rssi,
690
- )
691
- if self._client and self._client.is_connected:
692
- _LOGGER.debug(
693
- "%s: Already connected before obtaining lock, resetting timer; RSSI: %s",
694
- self.name,
695
- self.rssi,
696
- )
697
- self._reset_disconnect_timer()
698
- return
699
- async with self._connect_lock:
700
- # Check again while holding the lock
701
- if self._client and self._client.is_connected:
702
- _LOGGER.debug(
703
- "%s: Already connected after obtaining lock, resetting timer; RSSI: %s",
704
- self.name,
705
- self.rssi,
706
- )
707
- self._reset_disconnect_timer()
708
- return
709
- _LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi)
710
- client: BleakClientWithServiceCache = await establish_connection(
711
- BleakClientWithServiceCache,
712
- self._device,
713
- self.name,
714
- self._disconnected,
715
- max_attempts=10,
716
- ble_device_callback=lambda: self._device,
717
- )
718
- _LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi)
719
- self._client = client
720
- self._message = BleMessage(client)
721
-
722
- try:
723
- self._resolve_characteristics(client.services)
724
- except CharacteristicMissingError as ex:
725
- _LOGGER.debug(
726
- "%s: characteristic missing, clearing cache: %s; RSSI: %s",
727
- self.name,
728
- ex,
729
- self.rssi,
730
- exc_info=True,
731
- )
732
- await client.clear_cache()
733
- self._cancel_disconnect_timer()
734
- await self._execute_disconnect_with_lock()
735
- raise
736
-
737
- _LOGGER.debug(
738
- "%s: Starting notify and disconnect timer; RSSI: %s",
739
- self.name,
740
- self.rssi,
741
- )
742
- self._reset_disconnect_timer()
743
- await self._start_notify()
744
- command_bytes = self._commands.send_todev_ble_sync(2)
745
- await self._message.post_custom_data_bytes(command_bytes)
746
- self.schedule_ble_sync()
747
-
748
- async def _send_command_locked(self, key: str, command: bytes) -> bytes:
749
- """Send command to device and read response."""
750
- await self._ensure_connected()
751
- try:
752
- return await self._execute_command_locked(key, command)
753
- except BleakDBusError as ex:
754
- # Disconnect so we can reset state and try again
755
- await asyncio.sleep(DBUS_ERROR_BACKOFF_TIME)
756
- _LOGGER.debug(
757
- "%s: RSSI: %s; Backing off %ss; Disconnecting due to error: %s",
758
- self.name,
759
- self.rssi,
760
- DBUS_ERROR_BACKOFF_TIME,
761
- ex,
762
- )
763
- await self._execute_forced_disconnect()
764
- raise
765
- except BLEAK_RETRY_EXCEPTIONS as ex:
766
- # Disconnect so we can reset state and try again
767
- _LOGGER.debug("%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex)
768
- await self._execute_forced_disconnect()
769
- raise
770
-
771
- async def _notification_handler(self, _sender: BleakGATTCharacteristic, data: bytearray) -> None:
772
- """Handle notification responses."""
773
- result = self._message.parseNotification(data)
774
- if result == 0:
775
- data = await self._message.parseBlufiNotifyData(True)
776
- self._update_raw_data(data)
777
- self._message.clearNotification()
778
- _LOGGER.debug("%s: Received notification: %s", self.name, data)
779
- else:
780
- return
781
- new_msg = LubaMsg().parse(data)
782
- if betterproto.serialized_on_wire(new_msg.net):
783
- if new_msg.net.todev_ble_sync != 0 or has_field(new_msg.net.toapp_wifi_iot_status):
784
- if has_field(new_msg.net.toapp_wifi_iot_status) and self._commands.get_device_product_key() == "":
785
- self._commands.set_device_product_key(new_msg.net.toapp_wifi_iot_status.productkey)
786
-
787
- return
788
-
789
- # may or may not be correct, some work could be done here to correctly match responses
790
- if self._notify_future and not self._notify_future.done():
791
- self._notify_future.set_result(data)
792
-
793
- self._reset_disconnect_timer()
794
- await self._state_manager.notification(new_msg)
795
-
796
- async def _start_notify(self) -> None:
797
- """Start notification."""
798
- _LOGGER.debug("%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi)
799
- await self._client.start_notify(self._read_char, self._notification_handler)
800
-
801
- async def _execute_command_locked(self, key: str, command: bytes) -> bytes:
802
- """Execute command and read response."""
803
- assert self._client is not None
804
- assert self._read_char is not None
805
- assert self._write_char is not None
806
- self._notify_future = self.loop.create_future()
807
- self._key = key
808
- _LOGGER.debug("%s: Sending command: %s", self.name, key)
809
- await self._message.post_custom_data_bytes(command)
810
-
811
- timeout = 2
812
- timeout_handle = self.loop.call_at(self.loop.time() + timeout, _handle_timeout, self._notify_future)
813
- timeout_expired = False
814
- try:
815
- notify_msg = await self._notify_future
816
- except asyncio.TimeoutError:
817
- timeout_expired = True
818
- notify_msg = b''
819
- finally:
820
- if not timeout_expired:
821
- timeout_handle.cancel()
822
- self._notify_future = None
823
-
824
- _LOGGER.debug("%s: Notification received: %s", self.name, notify_msg.hex())
825
- return notify_msg
826
-
827
- async def _execute_command_locked_old(self, key: str, command: bytes) -> bytes:
828
- """Execute command and read response."""
829
- assert self._client is not None
830
- assert self._read_char is not None
831
- assert self._write_char is not None
832
- self._notify_future = self.loop.create_future()
833
- self._key = key
834
- _LOGGER.debug("%s: Sending command: %s", self.name, key)
835
- await self._message.post_custom_data_bytes(command)
836
-
837
- retry_handle = self.loop.call_at(
838
- self.loop.time() + 2,
839
- lambda: asyncio.ensure_future(
840
- _handle_retry(self._notify_future, self._message.post_custom_data_bytes, command)
841
- ),
842
- )
843
- timeout = 5
844
- timeout_handle = self.loop.call_at(self.loop.time() + timeout, _handle_timeout, self._notify_future)
845
- timeout_expired = False
846
- try:
847
- notify_msg = await self._notify_future
848
- except asyncio.TimeoutError:
849
- timeout_expired = True
850
- raise
851
- finally:
852
- if not timeout_expired:
853
- timeout_handle.cancel()
854
- retry_handle.cancel()
855
- self._notify_future = None
856
-
857
- _LOGGER.debug("%s: Notification received: %s", self.name, notify_msg.hex())
858
- return notify_msg
859
-
860
- def get_address(self) -> str:
861
- """Return address of device."""
862
- return self._device.address
863
-
864
- def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> None:
865
- """Resolve characteristics."""
866
- self._read_char = services.get_characteristic(READ_CHAR_UUID)
867
- if not self._read_char:
868
- self._read_char = READ_CHAR_UUID
869
- _LOGGER.error(CharacteristicMissingError(READ_CHAR_UUID))
870
- self._write_char = services.get_characteristic(WRITE_CHAR_UUID)
871
- if not self._write_char:
872
- self._write_char = WRITE_CHAR_UUID
873
- _LOGGER.error(CharacteristicMissingError(WRITE_CHAR_UUID))
874
-
875
- def _reset_disconnect_timer(self):
876
- """Reset disconnect timer."""
877
- self._cancel_disconnect_timer()
878
- self._expected_disconnect = False
879
- self._disconnect_timer = self.loop.call_later(DISCONNECT_DELAY, self._disconnect_from_timer)
880
-
881
- def _disconnected(self, client: BleakClientWithServiceCache) -> None:
882
- """Disconnected callback."""
883
- if self._expected_disconnect:
884
- _LOGGER.debug("%s: Disconnected from device; RSSI: %s", self.name, self.rssi)
885
- return
886
- _LOGGER.warning(
887
- "%s: Device unexpectedly disconnected; RSSI: %s",
888
- self.name,
889
- self.rssi,
890
- )
891
- self._cancel_disconnect_timer()
892
-
893
- def _disconnect_from_timer(self):
894
- """Disconnect from device."""
895
- if self._operation_lock.locked() and self._client.is_connected:
896
- _LOGGER.debug(
897
- "%s: Operation in progress, resetting disconnect timer; RSSI: %s",
898
- self.name,
899
- self.rssi,
900
- )
901
- self._reset_disconnect_timer()
902
- return
903
- self._cancel_disconnect_timer()
904
- self._timed_disconnect_task = asyncio.create_task(self._execute_timed_disconnect())
905
-
906
- def _cancel_disconnect_timer(self):
907
- """Cancel disconnect timer."""
908
- if self._disconnect_timer:
909
- self._disconnect_timer.cancel()
910
- self._disconnect_timer = None
911
-
912
- async def _execute_forced_disconnect(self) -> None:
913
- """Execute forced disconnection."""
914
- self._cancel_disconnect_timer()
915
- _LOGGER.debug(
916
- "%s: Executing forced disconnect",
917
- self.name,
918
- )
919
- await self._execute_disconnect()
920
-
921
- async def _execute_timed_disconnect(self) -> None:
922
- """Execute timed disconnection."""
923
- if not self._disconnect_strategy:
924
- return
925
- _LOGGER.debug(
926
- "%s: Executing timed disconnect after timeout of %s",
927
- self.name,
928
- DISCONNECT_DELAY,
929
- )
930
- await self._execute_disconnect()
931
-
932
- async def _execute_disconnect(self) -> None:
933
- """Execute disconnection."""
934
- _LOGGER.debug("%s: Executing disconnect", self.name)
935
- async with self._connect_lock:
936
- await self._execute_disconnect_with_lock()
937
-
938
- async def _execute_disconnect_with_lock(self) -> None:
939
- """Execute disconnection while holding the lock."""
940
- assert self._connect_lock.locked(), "Lock not held"
941
- _LOGGER.debug("%s: Executing disconnect with lock", self.name)
942
- if self._disconnect_timer: # If the timer was reset, don't disconnect
943
- _LOGGER.debug("%s: Skipping disconnect as timer reset", self.name)
944
- return
945
- client = self._client
946
- self._expected_disconnect = True
947
-
948
- if not client:
949
- _LOGGER.debug("%s: Already disconnected", self.name)
950
- return
951
- _LOGGER.debug("%s: Disconnecting", self.name)
952
- try:
953
- """We reset what command the robot last heard before disconnecting."""
954
- if client is not None and client.is_connected:
955
- command_bytes = self._commands.send_todev_ble_sync(2)
956
- await self._message.post_custom_data_bytes(command_bytes)
957
- await client.stop_notify(self._read_char)
958
- await client.disconnect()
959
- except BLEAK_RETRY_EXCEPTIONS as ex:
960
- _LOGGER.warning(
961
- "%s: Error disconnecting: %s; RSSI: %s",
962
- self.name,
963
- ex,
964
- self.rssi,
965
- )
966
- else:
967
- _LOGGER.debug("%s: Disconnect completed successfully", self.name)
968
- self._client = None
969
-
970
- async def _disconnect(self) -> bool:
971
- if self._client is not None:
972
- return await self._client.disconnect()
973
-
974
- def set_disconnect_strategy(self, disconnect):
975
- self._disconnect_strategy = disconnect
976
-
977
-
978
- class MammotionBaseCloudDevice(MammotionBaseDevice):
979
- """Base class for Mammotion Cloud devices."""
980
-
981
- def __init__(
982
- self,
983
- mqtt_client: MammotionMQTT,
984
- cloud_device: Device,
985
- mowing_state: MowingDevice
986
- ) -> None:
987
- """Initialize MammotionBaseCloudDevice."""
988
- super().__init__(mowing_state, cloud_device)
989
- self._ble_sync_task = None
990
- self.is_ready = False
991
- self.command_queue = asyncio.Queue()
992
- self._mqtt_client = mqtt_client
993
- self.iot_id = cloud_device.iotId
994
- self.device = cloud_device
995
- self._mower = mowing_state
996
- self._command_futures = {}
997
- self._commands: MammotionCommand = MammotionCommand(cloud_device.deviceName)
998
- self.currentID = ""
999
- self.on_ready_callback: Optional[Callable[[], Awaitable[None]]] = None
1000
- self._waiting_queue = deque()
1001
- self._operation_lock = asyncio.Lock()
1002
-
1003
- self._mqtt_client.on_connected = self.on_connected
1004
- self._mqtt_client.on_disconnected = self.on_disconnected
1005
- self._mqtt_client.on_message = self._on_mqtt_message
1006
- self._mqtt_client.on_ready = self.on_ready
1007
- if self._mqtt_client.is_connected:
1008
- self._ble_sync()
1009
- self.run_periodic_sync_task()
1010
-
1011
- # temporary for testing only
1012
- # self._start_sync_task = self.loop.call_later(30, lambda: asyncio.ensure_future(self.start_sync(0)))
1013
-
1014
- async def on_ready(self):
1015
- """Callback for when MQTT is subscribed to events."""
1016
- loop = asyncio.get_event_loop()
1017
-
1018
-
1019
- await self._ble_sync()
1020
- await self.run_periodic_sync_task()
1021
- loop.create_task(self._process_queue())
1022
- if self.on_ready_callback:
1023
- await self.on_ready_callback()
1024
-
1025
- async def on_connected(self):
1026
- """Callback for when MQTT connects."""
1027
-
1028
-
1029
- async def on_disconnected(self):
1030
- """Callback for when MQTT disconnects."""
1031
-
1032
- async def _ble_sync(self):
1033
- command_bytes = self._commands.send_todev_ble_sync(3)
1034
- loop = asyncio.get_running_loop()
1035
- await loop.run_in_executor(None, self._mqtt_client.get_cloud_client().send_cloud_command, self.iot_id, command_bytes)
1036
-
1037
-
1038
- async def run_periodic_sync_task(self) -> None:
1039
- """Send ble sync to robot."""
1040
- try:
1041
- if not self._operation_lock.locked():
1042
- await self._ble_sync()
1043
- finally:
1044
- self.schedule_ble_sync()
1045
-
1046
- def schedule_ble_sync(self):
1047
- """Periodically sync to keep connection alive."""
1048
- if self._mqtt_client is not None and self._mqtt_client.is_connected:
1049
- self._ble_sync_task = self.loop.call_later(
1050
- 160, lambda: asyncio.ensure_future(self.run_periodic_sync_task())
1051
- )
1052
-
1053
- async def queue_command(self, key: str, **kwargs: any) -> bytes:
1054
- # Create a future to hold the result
1055
- _LOGGER.debug("Queueing command: %s", key)
1056
- future = asyncio.Future()
1057
- # Put the command in the queue as a tuple (key, command, future)
1058
- command_bytes = getattr(self._commands, key)(**kwargs)
1059
- await self.command_queue.put((key, command_bytes, future))
1060
- # Wait for the future to be resolved
1061
- return await future
1062
-
1063
- async def _process_queue(self):
1064
- while True:
1065
- # Get the next item from the queue
1066
- key, command, future = await self.command_queue.get()
1067
- try:
1068
- # Process the command using _execute_command_locked
1069
- result = await self._execute_command_locked(key, command)
1070
- # Set the result on the future
1071
- future.set_result(result)
1072
- except Exception as ex:
1073
- # Set the exception on the future if something goes wrong
1074
- future.set_exception(ex)
1075
- finally:
1076
- # Mark the task as done
1077
- self.command_queue.task_done()
1078
-
1079
- async def _on_mqtt_message(self, topic: str, payload: str, iot_id: str) -> None:
1080
- """Handle incoming MQTT messages."""
1081
- _LOGGER.debug("MQTT message received on topic %s: %s, iot_id: %s", topic, payload, iot_id)
1082
-
1083
- json_str = json.dumps(payload)
1084
- payload = json.loads(json_str)
1085
-
1086
- await self._handle_mqtt_message(topic, payload)
1087
-
1088
- async def _send_command(self, key: str, retry: int | None = None) -> bytes | None:
1089
- """Send command to device via MQTT and read response."""
1090
- if self._operation_lock.locked():
1091
- _LOGGER.debug(
1092
- "%s: Operation already in progress, waiting for it to complete;",
1093
- self.device.nickName
1094
- )
1095
- with self._operation_lock:
1096
- try:
1097
- command_bytes = getattr(self._commands, key)()
1098
- return await self._send_command_locked(key, command_bytes)
1099
- except Exception as ex:
1100
- _LOGGER.exception("%s: error in sending command - %s", self.device.nickName, ex)
1101
- raise
1102
-
1103
- async def _send_command_locked(self, key: str, command: bytes) -> bytes:
1104
- """Send command to device and read response."""
1105
- if not self._mqtt_client.is_connected:
1106
- loop = asyncio.get_running_loop()
1107
- await loop.run_in_executor(None, self._mqtt_client.connect_async)
1108
-
1109
- if not self._mqtt_client.is_connected:
1110
- raise Exception("MQTT not connected, couldn't recover")
1111
- try:
1112
- return await self._execute_command_locked(key, command)
1113
- except DeviceOfflineException as ex:
1114
- _LOGGER.debug(
1115
- "%s: device offline in _send_command_locked: %s",
1116
- self.device.nickName,
1117
- ex,
1118
- )
1119
- except SetupException as ex:
1120
- session = self._mqtt_client.get_cloud_client().get_session_by_authcode_response()
1121
- _LOGGER.debug(
1122
- "%s: session identityId mssing in _send_command_locked: %s",
1123
- self.device.nickName,
1124
- session,
1125
- )
1126
- if session.data.identityId is None:
1127
- await self._mqtt_client.get_cloud_client().session_by_auth_code()
1128
-
1129
- except Exception as ex:
1130
- _LOGGER.debug(
1131
- "%s: error in _send_command_locked: %s",
1132
- self.device.nickName,
1133
- ex,
1134
- )
1135
- raise
1136
-
1137
- async def _execute_command_locked(self, key: str, command: bytes) -> bytes:
1138
- """Execute command and read response."""
1139
- assert self._mqtt_client is not None
1140
- self._key = key
1141
- _LOGGER.debug("%s: Sending command: %s", self.device.nickName, key)
1142
-
1143
- await self.loop.run_in_executor(None, self._mqtt_client.get_cloud_client().send_cloud_command, self.iot_id, command)
1144
- future = MammotionFuture()
1145
- self._waiting_queue.append(future)
1146
- timeout = 5
1147
- try:
1148
- notify_msg = await future.async_get(timeout)
1149
- except asyncio.TimeoutError:
1150
- notify_msg = b''
1151
-
1152
- _LOGGER.debug("%s: Message received", self.device.nickName)
1153
-
1154
- return notify_msg
1155
-
1156
- async def _send_command_with_args(self, key: str, **kwargs: any) -> bytes | None:
1157
- """Send command with arguments to device via MQTT and read response."""
1158
- if self._operation_lock.locked():
1159
- _LOGGER.debug(
1160
- "%s: Operation already in progress, waiting for it to complete;",
1161
- self.device.nickName
1162
- )
1163
- with self._operation_lock:
1164
- try:
1165
- command_bytes = getattr(self._commands, key)(**kwargs)
1166
- return await self._send_command_locked(key, command_bytes)
1167
- except Exception as ex:
1168
- _LOGGER.exception("%s: error in sending command - %s", self.device.nickName, ex)
1169
- raise
1170
-
1171
- def _extract_message_id(self, payload: dict) -> str:
1172
- """Extract the message ID from the payload."""
1173
- return payload.get("id", "")
1174
-
1175
- def _extract_encoded_message(self, payload: dict) -> str:
1176
- """Extract the encoded message from the payload."""
1177
- try:
1178
- content = payload.get("data", {}).get("data", {}).get("params", {}).get("content", "")
1179
- return str(content)
1180
- except AttributeError:
1181
- _LOGGER.error("Error extracting encoded message. Payload: %s", payload)
1182
- return ""
1183
-
1184
- async def _parse_mqtt_response(self, topic: str, payload: dict) -> None:
1185
- """Parse the MQTT response."""
1186
- if topic.endswith("/app/down/thing/events"):
1187
- _LOGGER.debug("Thing event received")
1188
- event = ThingEventMessage.from_dicts(payload)
1189
- params = event.params
1190
- if params.identifier is None:
1191
- return
1192
- if params.identifier == "device_protobuf_msg_event" and event.method == "thing.events":
1193
- _LOGGER.debug("Protobuf event")
1194
- binary_data = base64.b64decode(params.value.get("content", ""))
1195
- self._update_raw_data(cast(bytes, binary_data))
1196
- new_msg = LubaMsg().parse(cast(bytes, binary_data))
1197
-
1198
- if self._commands.get_device_product_key() == "" and self._commands.get_device_name() == event.params.deviceName:
1199
- self._commands.set_device_product_key(event.params.productKey)
1200
-
1201
- if betterproto.serialized_on_wire(new_msg.net):
1202
- if new_msg.net.todev_ble_sync != 0 or has_field(new_msg.net.toapp_wifi_iot_status):
1203
- return
1204
-
1205
-
1206
- if len(self._waiting_queue) > 0:
1207
- fut: MammotionFuture = self._waiting_queue.popleft()
1208
- while fut.fut.cancelled() and len(self._waiting_queue) > 0:
1209
- fut: MammotionFuture = self._waiting_queue.popleft()
1210
- if not fut.fut.cancelled():
1211
- fut.resolve(cast(bytes, binary_data))
1212
- await self._state_manager.notification(new_msg)
1213
- if event.method == "thing.properties":
1214
- _LOGGER.debug(event)
1215
-
1216
- async def _handle_mqtt_message(self, topic: str, payload: dict) -> None:
1217
- """Async handler for incoming MQTT messages."""
1218
- await self._parse_mqtt_response(topic=topic, payload=payload)
1219
-
1220
- def _disconnect(self):
1221
- """Disconnect the MQTT client."""
1222
- self._mqtt_client.disconnect()
1223
-
1224
-
1225
-