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.
- pymammotion/__init__.py +4 -2
- pymammotion/aliyun/__init__.py +1 -0
- pymammotion/aliyun/cloud_gateway.py +74 -95
- pymammotion/aliyun/tmp_constant.py +2 -6
- pymammotion/bluetooth/ble.py +4 -12
- pymammotion/bluetooth/ble_message.py +12 -36
- pymammotion/bluetooth/data/convert.py +1 -3
- pymammotion/bluetooth/data/notifydata.py +0 -1
- pymammotion/data/model/device.py +62 -3
- pymammotion/data/model/hash_list.py +34 -14
- pymammotion/data/model/location.py +40 -0
- pymammotion/data/model/rapid_state.py +1 -5
- pymammotion/data/state_manager.py +84 -0
- pymammotion/event/event.py +18 -3
- pymammotion/http/http.py +2 -6
- pymammotion/mammotion/commands/mammotion_command.py +1 -3
- pymammotion/mammotion/commands/messages/driver.py +7 -21
- pymammotion/mammotion/commands/messages/media.py +4 -9
- pymammotion/mammotion/commands/messages/navigation.py +42 -107
- pymammotion/mammotion/commands/messages/network.py +10 -30
- pymammotion/mammotion/commands/messages/system.py +11 -26
- pymammotion/mammotion/commands/messages/video.py +1 -3
- pymammotion/mammotion/control/joystick.py +9 -33
- pymammotion/mammotion/devices/__init__.py +5 -1
- pymammotion/mammotion/devices/{luba.py → mammotion.py} +299 -110
- pymammotion/mqtt/__init__.py +5 -0
- pymammotion/mqtt/{mqtt.py → mammotion_mqtt.py} +46 -50
- pymammotion/utility/constant/device_constant.py +14 -0
- pymammotion/utility/datatype_converter.py +52 -9
- pymammotion/utility/device_type.py +129 -20
- pymammotion/utility/periodic.py +65 -0
- pymammotion/utility/rocker_util.py +63 -4
- {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/METADATA +10 -4
- {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/RECORD +36 -34
- {pymammotion-0.0.40.dist-info → pymammotion-0.0.41.dist-info}/WHEEL +1 -1
- pymammotion/luba/_init_.py +0 -0
- pymammotion/luba/base.py +0 -52
- {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
|
-
"""
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
#
|
|
198
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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()
|