pymammotion 0.0.40__py3-none-any.whl → 0.0.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 (38) hide show
  1. pymammotion/__init__.py +4 -2
  2. pymammotion/aliyun/__init__.py +1 -0
  3. pymammotion/aliyun/cloud_gateway.py +74 -95
  4. pymammotion/aliyun/tmp_constant.py +2 -6
  5. pymammotion/bluetooth/ble.py +4 -12
  6. pymammotion/bluetooth/ble_message.py +12 -36
  7. pymammotion/bluetooth/data/convert.py +1 -3
  8. pymammotion/bluetooth/data/notifydata.py +0 -1
  9. pymammotion/data/model/device.py +62 -3
  10. pymammotion/data/model/hash_list.py +34 -14
  11. pymammotion/data/model/location.py +40 -0
  12. pymammotion/data/model/rapid_state.py +1 -5
  13. pymammotion/data/state_manager.py +84 -0
  14. pymammotion/event/event.py +18 -3
  15. pymammotion/http/http.py +2 -6
  16. pymammotion/mammotion/commands/mammotion_command.py +1 -3
  17. pymammotion/mammotion/commands/messages/driver.py +7 -21
  18. pymammotion/mammotion/commands/messages/media.py +4 -9
  19. pymammotion/mammotion/commands/messages/navigation.py +42 -107
  20. pymammotion/mammotion/commands/messages/network.py +10 -30
  21. pymammotion/mammotion/commands/messages/system.py +11 -26
  22. pymammotion/mammotion/commands/messages/video.py +1 -3
  23. pymammotion/mammotion/control/joystick.py +9 -33
  24. pymammotion/mammotion/devices/__init__.py +5 -1
  25. pymammotion/mammotion/devices/{luba.py → mammotion.py} +299 -110
  26. pymammotion/mqtt/__init__.py +5 -0
  27. pymammotion/mqtt/{mqtt.py → mammotion_mqtt.py} +46 -50
  28. pymammotion/utility/constant/device_constant.py +14 -0
  29. pymammotion/utility/datatype_converter.py +52 -9
  30. pymammotion/utility/device_type.py +129 -20
  31. pymammotion/utility/periodic.py +65 -0
  32. pymammotion/utility/rocker_util.py +63 -4
  33. {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/METADATA +10 -4
  34. {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/RECORD +36 -34
  35. {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/WHEEL +1 -1
  36. pymammotion/luba/_init_.py +0 -0
  37. pymammotion/luba/base.py +0 -52
  38. {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/LICENSE +0 -0
@@ -1,11 +1,14 @@
1
+ """Device control of mammotion robots over bluetooth or MQTT."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import asyncio
4
6
  import codecs
7
+ import json
5
8
  import logging
6
9
  from abc import abstractmethod
7
10
  from enum import Enum
8
- from typing import Any
11
+ from typing import Any, cast
9
12
  from uuid import UUID
10
13
 
11
14
  import betterproto
@@ -21,9 +24,14 @@ from bleak_retry_connector import (
21
24
  )
22
25
 
23
26
  from pymammotion.bluetooth import BleMessage
27
+ from pymammotion.data.model import RegionData
24
28
  from pymammotion.data.model.device import MowingDevice
29
+ from pymammotion.data.mqtt.event import ThingEventMessage
30
+ from pymammotion.data.state_manager import StateManager
25
31
  from pymammotion.mammotion.commands.mammotion_command import MammotionCommand
32
+ from pymammotion.mqtt import MammotionMQTT
26
33
  from pymammotion.proto.luba_msg import LubaMsg
34
+ from pymammotion.proto.mctrl_nav import NavGetCommDataAck, NavGetHashListAck
27
35
 
28
36
 
29
37
  class CharacteristicMissingError(Exception):
@@ -31,8 +39,15 @@ class CharacteristicMissingError(Exception):
31
39
 
32
40
 
33
41
  def _sb_uuid(comms_type: str = "service") -> UUID | str:
34
- """Return Mammotion UUID."""
42
+ """Return Mammotion UUID.
43
+
44
+ Args:
45
+ comms_type (str): The type of communication (tx, rx, or service).
46
+
47
+ Returns:
48
+ UUID | str: The UUID for the specified communication type or an error message.
35
49
 
50
+ """
36
51
  _uuid = {"tx": "ff01", "rx": "ff02", "service": "2A05"}
37
52
 
38
53
  if comms_type in _uuid:
@@ -48,14 +63,13 @@ DBUS_ERROR_BACKOFF_TIME = 0.25
48
63
 
49
64
  DISCONNECT_DELAY = 10
50
65
 
66
+ TIMEOUT_CLOUD_RESPONSE = 10
67
+
51
68
  _LOGGER = logging.getLogger(__name__)
52
69
 
53
70
 
54
71
  def slashescape(err):
55
- """Codecs error handler. err is UnicodeDecode instance. return
56
- a tuple with a replacement for the unencodable part of the input
57
- and a position where encoding should continue
58
- """
72
+ """Escape a slash character."""
59
73
  # print err, dir(err), err.start, err.end, err.object[:err.start]
60
74
  thebyte = err.object[err.start : err.end]
61
75
  repl = "\\x" + hex(ord(thebyte))[2:]
@@ -71,13 +85,23 @@ def _handle_timeout(fut: asyncio.Future[None]) -> None:
71
85
  fut.set_exception(asyncio.TimeoutError)
72
86
 
73
87
 
88
+ async def _handle_retry(fut: asyncio.Future[None], func, command: bytes) -> None:
89
+ """Handle a retry."""
90
+ if not fut.done():
91
+ await func(command)
92
+
93
+
74
94
  class ConnectionPreference(Enum):
95
+ """Enum for connection preference."""
96
+
75
97
  EITHER = 0
76
98
  WIFI = 1
77
99
  BLUETOOTH = 2
78
100
 
79
101
 
80
102
  class MammotionDevice:
103
+ """Represents a Mammotion device."""
104
+
81
105
  _ble_device: MammotionBaseBLEDevice | None = None
82
106
 
83
107
  def __init__(
@@ -85,96 +109,141 @@ class MammotionDevice:
85
109
  ble_device: BLEDevice,
86
110
  preference: ConnectionPreference = ConnectionPreference.EITHER,
87
111
  ) -> None:
112
+ """Initialize MammotionDevice."""
88
113
  if ble_device:
89
114
  self._ble_device = MammotionBaseBLEDevice(ble_device)
90
115
  self._preference = preference
91
116
 
92
117
  async def send_command(self, key: str):
118
+ """Send a command to the device."""
93
119
  return await self._ble_device.command(key)
94
120
 
95
121
 
96
122
  def has_field(message: betterproto.Message) -> bool:
123
+ """Check if the message has any fields serialized on wire."""
97
124
  return betterproto.serialized_on_wire(message)
98
125
 
99
126
 
100
127
  class MammotionBaseDevice:
128
+ """Base class for Mammotion devices."""
129
+
130
+ _luba_msg: MowingDevice
131
+ _state_manager: StateManager
132
+
101
133
  def __init__(self) -> None:
134
+ """Initialize MammotionBaseDevice."""
102
135
  self.loop = asyncio.get_event_loop()
103
136
  self._raw_data = LubaMsg().to_dict(casing=betterproto.Casing.SNAKE)
104
- self._luba_msg = LubaMsg()
137
+ self._luba_msg = MowingDevice()
138
+ self._state_manager = StateManager(self._luba_msg)
139
+
140
+ self._state_manager.gethash_ack_callback.add_subscribers(self.datahash_response)
141
+ self._state_manager.get_commondata_ack_callback.add_subscribers(self.commdata_response)
105
142
  self._notify_future: asyncio.Future[bytes] | None = None
106
143
 
144
+ async def datahash_response(self, hash_ack: NavGetHashListAck):
145
+ """Handle datahash responses."""
146
+ for data_hash in hash_ack.data_couple:
147
+ result_hash = 0
148
+ while data_hash != result_hash:
149
+ data = await self._send_command_with_args("synchronize_hash_data", hash_num=data_hash)
150
+ msg = LubaMsg().parse(data)
151
+ if betterproto.serialized_on_wire(msg.nav.toapp_get_commondata_ack):
152
+ result_hash = msg.nav.toapp_get_commondata_ack.hash
153
+ else:
154
+ await asyncio.sleep(0.5)
155
+
156
+ async def commdata_response(self, common_data: NavGetCommDataAck):
157
+ """Handle common data responses."""
158
+ # TODO check if the hash exists and whether or not to call get regional
159
+ total_frame = common_data.total_frame
160
+ current_frame = 1
161
+ while current_frame <= total_frame:
162
+ region_data = RegionData()
163
+ region_data.hash = common_data.data_hash
164
+ region_data.action = common_data.action
165
+ region_data.type = common_data.type
166
+ region_data.total_frame = total_frame
167
+ region_data.current_frame = current_frame
168
+ await self._send_command_with_args("get_regional_data", regional_data=region_data)
169
+ current_frame += 1
170
+
107
171
  def _update_raw_data(self, data: bytes) -> None:
108
172
  """Update raw and model data from notifications."""
109
- # proto_luba = luba_msg_pb2.LubaMsg()
110
- # proto_luba.ParseFromString(data)
111
173
  tmp_msg = LubaMsg().parse(data)
112
174
  res = betterproto.which_one_of(tmp_msg, "LubaSubMsg")
113
175
  match res[0]:
114
176
  case "nav":
115
- nav_sub_msg = betterproto.which_one_of(tmp_msg.nav, "SubNavMsg")
116
- nav = self._raw_data.get("nav")
117
- if nav is None:
118
- self._raw_data["nav"] = {}
119
- if isinstance(nav_sub_msg[1], int):
120
- self._raw_data["net"][nav_sub_msg[0]] = nav_sub_msg[1]
121
- else:
122
- self._raw_data["nav"][nav_sub_msg[0]] = nav_sub_msg[1].to_dict(
123
- casing=betterproto.Casing.SNAKE
124
- )
177
+ self._update_nav_data(tmp_msg)
125
178
  case "sys":
126
- sys_sub_msg = betterproto.which_one_of(tmp_msg.sys, "SubSysMsg")
127
- sys = self._raw_data.get("sys")
128
- if sys is None:
129
- self._raw_data["sys"] = {}
130
- self._raw_data["sys"][sys_sub_msg[0]] = sys_sub_msg[1].to_dict(
131
- casing=betterproto.Casing.SNAKE
132
- )
179
+ self._update_sys_data(tmp_msg)
133
180
  case "driver":
134
- drv_sub_msg = betterproto.which_one_of(tmp_msg.driver, "SubDrvMsg")
135
- drv = self._raw_data.get("driver")
136
- if drv is None:
137
- self._raw_data["driver"] = {}
138
- self._raw_data["driver"][drv_sub_msg[0]] = drv_sub_msg[1].to_dict(
139
- casing=betterproto.Casing.SNAKE
140
- )
181
+ self._update_driver_data(tmp_msg)
141
182
  case "net":
142
- net_sub_msg = betterproto.which_one_of(tmp_msg.net, "NetSubType")
143
- net = self._raw_data.get("net")
144
- if net is None:
145
- self._raw_data["net"] = {}
146
- if isinstance(net_sub_msg[1], int):
147
- self._raw_data["net"][net_sub_msg[0]] = net_sub_msg[1]
148
- else:
149
- self._raw_data["net"][net_sub_msg[0]] = net_sub_msg[1].to_dict(
150
- casing=betterproto.Casing.SNAKE
151
- )
152
-
183
+ self._update_net_data(tmp_msg)
153
184
  case "mul":
154
- mul_sub_msg = betterproto.which_one_of(tmp_msg.mul, "SubMul")
155
- mul = self._raw_data.get("mul")
156
- if mul is None:
157
- self._raw_data["mul"] = {}
158
- self._raw_data["mul"][mul_sub_msg[0]] = mul_sub_msg[1].to_dict(
159
- casing=betterproto.Casing.SNAKE
160
- )
185
+ self._update_mul_data(tmp_msg)
161
186
  case "ota":
162
- ota_sub_msg = betterproto.which_one_of(tmp_msg.ota, "SubOtaMsg")
163
- ota = self._raw_data.get("ota")
164
- if ota is None:
165
- self._raw_data["ota"] = {}
166
- self._raw_data["ota"][ota_sub_msg[0]] = ota_sub_msg[1].to_dict(
167
- casing=betterproto.Casing.SNAKE
168
- )
187
+ self._update_ota_data(tmp_msg)
188
+
189
+ self._luba_msg.update_raw(self._raw_data)
169
190
 
170
- self._luba_msg = MowingDevice.from_raw(self._raw_data)
191
+ def _update_nav_data(self, tmp_msg):
192
+ """Update navigation data."""
193
+ nav_sub_msg = betterproto.which_one_of(tmp_msg.nav, "SubNavMsg")
194
+ nav = self._raw_data.get("nav", {})
195
+ if isinstance(nav_sub_msg[1], int):
196
+ nav[nav_sub_msg[0]] = nav_sub_msg[1]
197
+ else:
198
+ nav[nav_sub_msg[0]] = nav_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
199
+ self._raw_data["nav"] = nav
200
+
201
+ def _update_sys_data(self, tmp_msg):
202
+ """Update system data."""
203
+ sys_sub_msg = betterproto.which_one_of(tmp_msg.sys, "SubSysMsg")
204
+ sys = self._raw_data.get("sys", {})
205
+ sys[sys_sub_msg[0]] = sys_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
206
+ self._raw_data["sys"] = sys
207
+
208
+ def _update_driver_data(self, tmp_msg):
209
+ """Update driver data."""
210
+ drv_sub_msg = betterproto.which_one_of(tmp_msg.driver, "SubDrvMsg")
211
+ drv = self._raw_data.get("driver", {})
212
+ drv[drv_sub_msg[0]] = drv_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
213
+ self._raw_data["driver"] = drv
214
+
215
+ def _update_net_data(self, tmp_msg):
216
+ """Update network data."""
217
+ net_sub_msg = betterproto.which_one_of(tmp_msg.net, "NetSubType")
218
+ net = self._raw_data.get("net", {})
219
+ if isinstance(net_sub_msg[1], int):
220
+ net[net_sub_msg[0]] = net_sub_msg[1]
221
+ else:
222
+ net[net_sub_msg[0]] = net_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
223
+ self._raw_data["net"] = net
224
+
225
+ def _update_mul_data(self, tmp_msg):
226
+ """Update mul data."""
227
+ mul_sub_msg = betterproto.which_one_of(tmp_msg.mul, "SubMul")
228
+ mul = self._raw_data.get("mul", {})
229
+ mul[mul_sub_msg[0]] = mul_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
230
+ self._raw_data["mul"] = mul
231
+
232
+ def _update_ota_data(self, tmp_msg):
233
+ """Update OTA data."""
234
+ ota_sub_msg = betterproto.which_one_of(tmp_msg.ota, "SubOtaMsg")
235
+ ota = self._raw_data.get("ota", {})
236
+ ota[ota_sub_msg[0]] = ota_sub_msg[1].to_dict(casing=betterproto.Casing.SNAKE)
237
+ self._raw_data["ota"] = ota
171
238
 
172
239
  @property
173
240
  def raw_data(self) -> dict[str, Any]:
241
+ """Get the raw data of the device."""
174
242
  return self._raw_data
175
243
 
176
244
  @property
177
245
  def luba_msg(self) -> LubaMsg:
246
+ """Get the LubaMsg of the device."""
178
247
  return self._luba_msg
179
248
 
180
249
  @abstractmethod
@@ -185,25 +254,47 @@ class MammotionBaseDevice:
185
254
  async def _send_command_with_args(self, key: str, **kwargs: any) -> bytes | None:
186
255
  """Send command to device and read response."""
187
256
 
257
+ @abstractmethod
258
+ async def _ble_sync(self):
259
+ """Send ble sync command every 3 seconds or sooner."""
260
+
188
261
  async def start_sync(self, retry: int):
262
+ """Start synchronization with the device."""
189
263
  await self._send_command("get_device_base_info", retry)
190
264
  await self._send_command("get_report_cfg", retry)
265
+ """RTK and dock location."""
266
+ await self._send_command_with_args("allpowerfull_rw", id=5, rw=1, context=1)
267
+ """Error codes."""
268
+ await self._send_command_with_args("allpowerfull_rw", id=5, rw=1, context=2)
269
+ await self._send_command_with_args("allpowerfull_rw", id=5, rw=1, context=3)
270
+
271
+ async def start_map_sync(self):
272
+ """Start sync of map data."""
191
273
  await self._send_command_with_args("read_plan", sub_cmd=2, plan_index=0)
192
274
 
193
- RW = await self._send_command_with_args(
194
- "allpowerfull_rw", id=5, context=1, rw=1
195
- )
196
- # RW_proto = luba_msg_pb2.LubaMsg()
197
- # RW_proto.ParseFromString(RW)
198
- # print(json_format.MessageToDict(RW_proto))
275
+ await self._send_command_with_args("get_all_boundary_hash_list", sub_cmd=0)
276
+
277
+ await self._send_command_with_args("get_hash_response", total_frame=1, current_frame=1)
278
+
279
+ # sub_cmd 3 is job hashes??
280
+ # sub_cmd 4 is dump location (yuka)
281
+ # jobs list
282
+ # hash_list_result = await self._send_command_with_args("get_all_boundary_hash_list", sub_cmd=3)
199
283
 
200
284
  async def command(self, key: str, **kwargs):
285
+ """Send a command to the device."""
201
286
  return await self._send_command_with_args(key, **kwargs)
202
287
 
203
288
 
204
289
  class MammotionBaseBLEDevice(MammotionBaseDevice):
290
+ """Base class for Mammotion BLE devices."""
291
+
205
292
  def __init__(self, device: BLEDevice, interface: int = 0, **kwargs: Any) -> None:
293
+ """Initialize MammotionBaseBLEDevice."""
206
294
  super().__init__()
295
+ self._pong_count = None
296
+ self._ble_sync_task = None
297
+ self._prev_notification = None
207
298
  self._interface = f"hci{interface}"
208
299
  self._device = device
209
300
  self._client: BleakClientWithServiceCache | None = None
@@ -218,8 +309,27 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
218
309
  self._key: str | None = None
219
310
 
220
311
  def update_device(self, device: BLEDevice) -> None:
312
+ """Update the BLE device."""
221
313
  self._device = device
222
314
 
315
+ async def _ble_sync(self):
316
+ command_bytes = self._commands.send_todev_ble_sync(2)
317
+ await self._message.post_custom_data_bytes(command_bytes)
318
+
319
+ async def run_periodic_sync_task(self) -> None:
320
+ """Send ble sync to robot."""
321
+ try:
322
+ await self._ble_sync()
323
+ finally:
324
+ self.schedule_ble_sync()
325
+
326
+ def schedule_ble_sync(self):
327
+ """Periodically sync to keep connection alive."""
328
+ if self._client.is_connected:
329
+ self._ble_sync_task = self.loop.call_later(
330
+ 130, lambda: asyncio.ensure_future(self.run_periodic_sync_task())
331
+ )
332
+
223
333
  async def _send_command_with_args(self, key: str, **kwargs) -> bytes | None:
224
334
  """Send command to device and read response."""
225
335
  if self._operation_lock.locked():
@@ -233,11 +343,10 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
233
343
  command_bytes = getattr(self._commands, key)(**kwargs)
234
344
  return await self._send_command_locked(key, command_bytes)
235
345
  except BleakNotFoundError:
236
- _LOGGER.error(
346
+ _LOGGER.exception(
237
347
  "%s: device not found, no longer in range, or poor RSSI: %s",
238
348
  self.name,
239
349
  self.rssi,
240
- exc_info=True,
241
350
  )
242
351
  raise
243
352
  except CharacteristicMissingError as ex:
@@ -249,10 +358,7 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
249
358
  exc_info=True,
250
359
  )
251
360
  except BLEAK_RETRY_EXCEPTIONS:
252
- _LOGGER.debug(
253
- "%s: communication failed with:", self.name, exc_info=True
254
- )
255
- # raise RuntimeError("Unreachable")
361
+ _LOGGER.debug("%s: communication failed with:", self.name, exc_info=True)
256
362
 
257
363
  async def _send_command(self, key: str, retry: int | None = None) -> bytes | None:
258
364
  """Send command to device and read response."""
@@ -267,11 +373,10 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
267
373
  command_bytes = getattr(self._commands, key)()
268
374
  return await self._send_command_locked(key, command_bytes)
269
375
  except BleakNotFoundError:
270
- _LOGGER.error(
376
+ _LOGGER.exception(
271
377
  "%s: device not found, no longer in range, or poor RSSI: %s",
272
378
  self.name,
273
379
  self.rssi,
274
- exc_info=True,
275
380
  )
276
381
  raise
277
382
  except CharacteristicMissingError as ex:
@@ -283,10 +388,7 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
283
388
  exc_info=True,
284
389
  )
285
390
  except BLEAK_RETRY_EXCEPTIONS:
286
- _LOGGER.debug(
287
- "%s: communication failed with:", self.name, exc_info=True
288
- )
289
- # raise RuntimeError("Unreachable")
391
+ _LOGGER.debug("%s: communication failed with:", self.name, exc_info=True)
290
392
 
291
393
  @property
292
394
  def name(self) -> str:
@@ -360,9 +462,8 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
360
462
  )
361
463
  self._reset_disconnect_timer()
362
464
  await self._start_notify()
363
-
364
- command_bytes = self._commands.send_todev_ble_sync(2)
365
- await self._message.post_custom_data_bytes(command_bytes)
465
+ # don't await this
466
+ self.schedule_ble_sync()
366
467
 
367
468
  async def _send_command_locked(self, key: str, command: bytes) -> bytes:
368
469
  """Send command to device and read response."""
@@ -383,15 +484,11 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
383
484
  raise
384
485
  except BLEAK_RETRY_EXCEPTIONS as ex:
385
486
  # Disconnect so we can reset state and try again
386
- _LOGGER.debug(
387
- "%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex
388
- )
487
+ _LOGGER.debug("%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex)
389
488
  await self._execute_forced_disconnect()
390
489
  raise
391
490
 
392
- async def _notification_handler(
393
- self, _sender: BleakGATTCharacteristic, data: bytearray
394
- ) -> None:
491
+ async def _notification_handler(self, _sender: BleakGATTCharacteristic, data: bytearray) -> None:
395
492
  """Handle notification responses."""
396
493
  _LOGGER.debug("%s: Received notification: %s", self.name, data)
397
494
  result = self._message.parseNotification(data)
@@ -403,15 +500,18 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
403
500
  return
404
501
  new_msg = LubaMsg().parse(data)
405
502
  if betterproto.serialized_on_wire(new_msg.net):
406
- if new_msg.net.todev_ble_sync != 0 or has_field(
407
- new_msg.net.toapp_wifi_iot_status
408
- ):
409
- # TODO occasionally respond with ble sync
410
- return
503
+ if new_msg.net.todev_ble_sync != 0 or has_field(new_msg.net.toapp_wifi_iot_status):
504
+ self._pong_count += 1
505
+
506
+ if self._pong_count < 3:
507
+ return
411
508
 
509
+ # may or may not be correct, some work could be done here to correctly match responses
412
510
  if self._notify_future and not self._notify_future.done():
511
+ self._pong_count = 0
413
512
  self._notify_future.set_result(data)
414
- return
513
+
514
+ await self._state_manager.notification(new_msg)
415
515
 
416
516
  async def _start_notify(self) -> None:
417
517
  """Start notification."""
@@ -428,10 +528,14 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
428
528
  _LOGGER.debug("%s: Sending command: %s", self.name, key)
429
529
  await self._message.post_custom_data_bytes(command)
430
530
 
431
- timeout = 5
432
- timeout_handle = self.loop.call_at(
433
- self.loop.time() + timeout, _handle_timeout, self._notify_future
531
+ retry_handle = self.loop.call_at(
532
+ self.loop.time() + 2,
533
+ lambda: asyncio.ensure_future(
534
+ _handle_retry(self._notify_future, self._message.post_custom_data_bytes, command)
535
+ ),
434
536
  )
537
+ timeout = 5
538
+ timeout_handle = self.loop.call_at(self.loop.time() + timeout, _handle_timeout, self._notify_future)
435
539
  timeout_expired = False
436
540
  try:
437
541
  notify_msg = await self._notify_future
@@ -441,6 +545,7 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
441
545
  finally:
442
546
  if not timeout_expired:
443
547
  timeout_handle.cancel()
548
+ retry_handle.cancel()
444
549
  self._notify_future = None
445
550
 
446
551
  _LOGGER.debug("%s: Notification received: %s", self.name, notify_msg.hex())
@@ -454,25 +559,27 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
454
559
  """Resolve characteristics."""
455
560
  self._read_char = services.get_characteristic(READ_CHAR_UUID)
456
561
  if not self._read_char:
457
- raise CharacteristicMissingError(READ_CHAR_UUID)
562
+ self._read_char = READ_CHAR_UUID
563
+ _LOGGER.error(CharacteristicMissingError(READ_CHAR_UUID))
564
+ """Sometimes the robot doesn't report this correctly."""
565
+ # raise CharacteristicMissingError(READ_CHAR_UUID)
458
566
  self._write_char = services.get_characteristic(WRITE_CHAR_UUID)
459
567
  if not self._write_char:
460
- raise CharacteristicMissingError(WRITE_CHAR_UUID)
568
+ self._write_char = WRITE_CHAR_UUID
569
+ _LOGGER.error(CharacteristicMissingError(WRITE_CHAR_UUID))
570
+ """Sometimes the robot doesn't report this correctly."""
571
+ # raise CharacteristicMissingError(WRITE_CHAR_UUID)
461
572
 
462
573
  def _reset_disconnect_timer(self):
463
574
  """Reset disconnect timer."""
464
575
  self._cancel_disconnect_timer()
465
576
  self._expected_disconnect = False
466
- self._disconnect_timer = self.loop.call_later(
467
- DISCONNECT_DELAY, self._disconnect_from_timer
468
- )
577
+ self._disconnect_timer = self.loop.call_later(DISCONNECT_DELAY, self._disconnect_from_timer)
469
578
 
470
579
  def _disconnected(self, client: BleakClientWithServiceCache) -> None:
471
580
  """Disconnected callback."""
472
581
  if self._expected_disconnect:
473
- _LOGGER.debug(
474
- "%s: Disconnected from device; RSSI: %s", self.name, self.rssi
475
- )
582
+ _LOGGER.debug("%s: Disconnected from device; RSSI: %s", self.name, self.rssi)
476
583
  return
477
584
  _LOGGER.warning(
478
585
  "%s: Device unexpectedly disconnected; RSSI: %s",
@@ -492,9 +599,7 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
492
599
  self._reset_disconnect_timer()
493
600
  return
494
601
  self._cancel_disconnect_timer()
495
- self._timed_disconnect_task = asyncio.create_task(
496
- self._execute_timed_disconnect()
497
- )
602
+ self._timed_disconnect_task = asyncio.create_task(self._execute_timed_disconnect())
498
603
 
499
604
  def _cancel_disconnect_timer(self):
500
605
  """Cancel disconnect timer."""
@@ -542,10 +647,11 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
542
647
  _LOGGER.debug("%s: Disconnecting", self.name)
543
648
  try:
544
649
  """We reset what command the robot last heard before disconnecting."""
545
- command_bytes = self._commands.send_todev_ble_sync(2)
546
- await self._message.post_custom_data_bytes(command_bytes)
547
- await client.stop_notify(self._read_char)
548
- await client.disconnect()
650
+ if client.is_connected:
651
+ command_bytes = self._commands.send_todev_ble_sync(2)
652
+ await self._message.post_custom_data_bytes(command_bytes)
653
+ await client.stop_notify(self._read_char)
654
+ await client.disconnect()
549
655
  except BLEAK_RETRY_EXCEPTIONS as ex:
550
656
  _LOGGER.warning(
551
657
  "%s: Error disconnecting: %s; RSSI: %s",
@@ -560,3 +666,86 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
560
666
  async def _disconnect(self) -> bool:
561
667
  if self._client is not None:
562
668
  return await self._client.disconnect()
669
+
670
+
671
+ class MammotionBaseCloudDevice(MammotionBaseDevice):
672
+ """Base class for Mammotion Cloud devices."""
673
+
674
+ def __init__(
675
+ self,
676
+ mqtt_client: MammotionMQTT,
677
+ iot_id: str,
678
+ device_name: str,
679
+ nick_name: str,
680
+ **kwargs: Any,
681
+ ) -> None:
682
+ """Initialize MammotionBaseCloudDevice."""
683
+ super().__init__()
684
+ self._mqtt_client = mqtt_client
685
+ self.iot_id = iot_id
686
+ self.nick_name = nick_name
687
+ self._command_futures = {}
688
+ self._commands: MammotionCommand = MammotionCommand(device_name)
689
+ self.loop = asyncio.get_event_loop()
690
+
691
+ def _on_mqtt_message(self, topic: str, payload: str) -> None:
692
+ """Handle incoming MQTT messages."""
693
+ _LOGGER.debug("MQTT message received on topic %s: %s", topic, payload)
694
+ payload = json.loads(payload)
695
+ message_id = self._extract_message_id(payload)
696
+ if message_id and message_id in self._command_futures:
697
+ self._parse_mqtt_response(topic=topic, payload=payload)
698
+ future = self._command_futures.pop(message_id)
699
+ if not future.done():
700
+ future.set_result(payload)
701
+
702
+ async def _send_command(self, key: str, retry: int | None = None) -> bytes | None:
703
+ """Send command to device via MQTT and read response."""
704
+ future = self.loop.create_future()
705
+ command_bytes = getattr(self._commands, key)()
706
+ message_id = self._mqtt_client.get_cloud_client().send_cloud_command(self.iot_id, command_bytes)
707
+ if message_id != "":
708
+ self._command_futures[message_id] = future
709
+ try:
710
+ return await asyncio.wait_for(future, timeout=TIMEOUT_CLOUD_RESPONSE)
711
+ except asyncio.TimeoutError:
712
+ _LOGGER.error("Command '%s' timed out", key)
713
+ return None
714
+
715
+ async def _send_command_with_args(self, key: str, **kwargs: any) -> bytes | None:
716
+ """Send command with arguments to device via MQTT and read response."""
717
+ future = self.loop.create_future()
718
+ command_bytes = getattr(self._commands, key)(**kwargs)
719
+ message_id = self._mqtt_client.get_cloud_client().send_cloud_command(self.iot_id, command_bytes)
720
+ if message_id != "":
721
+ self._command_futures[message_id] = future
722
+ try:
723
+ return await asyncio.wait_for(future, timeout=TIMEOUT_CLOUD_RESPONSE)
724
+ except asyncio.TimeoutError:
725
+ _LOGGER.error("Command '%s' timed out", key)
726
+ return None
727
+
728
+ def _extract_message_id(self, payload: dict) -> str:
729
+ """Extract the message ID from the payload."""
730
+ return payload.get("id", "")
731
+
732
+ def _extract_encoded_message(self, payload: dict) -> str:
733
+ """Extract the encoded message from the payload."""
734
+ try:
735
+ content = payload.get("data", {}).get("data", {}).get("params", {}).get("content", "")
736
+ return str(content)
737
+ except AttributeError:
738
+ _LOGGER.error("Error extracting encoded message. Payload: %s", payload)
739
+ return ""
740
+
741
+ def _parse_mqtt_response(self, topic: str, payload: dict) -> None:
742
+ """Parse the MQTT response."""
743
+ if topic.endswith("/app/down/thing/events"):
744
+ event = ThingEventMessage(**payload)
745
+ params = event.params
746
+ if params.identifier == "device_protobuf_msg_event":
747
+ self._update_raw_data(cast(bytes, params.value.content))
748
+
749
+ async def _disconnect(self):
750
+ """Disconnect the MQTT client."""
751
+ self._mqtt_client.disconnect()
@@ -0,0 +1,5 @@
1
+ """Package for MammotionMQTT."""
2
+
3
+ from .mammotion_mqtt import MammotionMQTT
4
+
5
+ __all__ = ["MammotionMQTT"]