pymammotion 0.0.40__py3-none-any.whl → 0.0.42__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 +14 -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} +294 -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.42.dist-info}/METADATA +10 -4
- {pymammotion-0.0.40.dist-info → pymammotion-0.0.42.dist-info}/RECORD +36 -34
- pymammotion/luba/_init_.py +0 -0
- pymammotion/luba/base.py +0 -52
- {pymammotion-0.0.40.dist-info → pymammotion-0.0.42.dist-info}/LICENSE +0 -0
- {pymammotion-0.0.40.dist-info → pymammotion-0.0.42.dist-info}/WHEEL +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 not None and 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,7 @@ 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
|
+
self.schedule_ble_sync()
|
|
366
466
|
|
|
367
467
|
async def _send_command_locked(self, key: str, command: bytes) -> bytes:
|
|
368
468
|
"""Send command to device and read response."""
|
|
@@ -383,15 +483,11 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
383
483
|
raise
|
|
384
484
|
except BLEAK_RETRY_EXCEPTIONS as ex:
|
|
385
485
|
# 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
|
-
)
|
|
486
|
+
_LOGGER.debug("%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex)
|
|
389
487
|
await self._execute_forced_disconnect()
|
|
390
488
|
raise
|
|
391
489
|
|
|
392
|
-
async def _notification_handler(
|
|
393
|
-
self, _sender: BleakGATTCharacteristic, data: bytearray
|
|
394
|
-
) -> None:
|
|
490
|
+
async def _notification_handler(self, _sender: BleakGATTCharacteristic, data: bytearray) -> None:
|
|
395
491
|
"""Handle notification responses."""
|
|
396
492
|
_LOGGER.debug("%s: Received notification: %s", self.name, data)
|
|
397
493
|
result = self._message.parseNotification(data)
|
|
@@ -403,15 +499,18 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
403
499
|
return
|
|
404
500
|
new_msg = LubaMsg().parse(data)
|
|
405
501
|
if betterproto.serialized_on_wire(new_msg.net):
|
|
406
|
-
if new_msg.net.todev_ble_sync != 0 or has_field(
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
502
|
+
if new_msg.net.todev_ble_sync != 0 or has_field(new_msg.net.toapp_wifi_iot_status):
|
|
503
|
+
self._pong_count += 1
|
|
504
|
+
|
|
505
|
+
if self._pong_count < 3:
|
|
506
|
+
return
|
|
411
507
|
|
|
508
|
+
# may or may not be correct, some work could be done here to correctly match responses
|
|
412
509
|
if self._notify_future and not self._notify_future.done():
|
|
510
|
+
self._pong_count = 0
|
|
413
511
|
self._notify_future.set_result(data)
|
|
414
|
-
|
|
512
|
+
|
|
513
|
+
await self._state_manager.notification(new_msg)
|
|
415
514
|
|
|
416
515
|
async def _start_notify(self) -> None:
|
|
417
516
|
"""Start notification."""
|
|
@@ -428,10 +527,14 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
428
527
|
_LOGGER.debug("%s: Sending command: %s", self.name, key)
|
|
429
528
|
await self._message.post_custom_data_bytes(command)
|
|
430
529
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
530
|
+
retry_handle = self.loop.call_at(
|
|
531
|
+
self.loop.time() + 2,
|
|
532
|
+
lambda: asyncio.ensure_future(
|
|
533
|
+
_handle_retry(self._notify_future, self._message.post_custom_data_bytes, command)
|
|
534
|
+
),
|
|
434
535
|
)
|
|
536
|
+
timeout = 5
|
|
537
|
+
timeout_handle = self.loop.call_at(self.loop.time() + timeout, _handle_timeout, self._notify_future)
|
|
435
538
|
timeout_expired = False
|
|
436
539
|
try:
|
|
437
540
|
notify_msg = await self._notify_future
|
|
@@ -441,6 +544,7 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
441
544
|
finally:
|
|
442
545
|
if not timeout_expired:
|
|
443
546
|
timeout_handle.cancel()
|
|
547
|
+
retry_handle.cancel()
|
|
444
548
|
self._notify_future = None
|
|
445
549
|
|
|
446
550
|
_LOGGER.debug("%s: Notification received: %s", self.name, notify_msg.hex())
|
|
@@ -454,25 +558,23 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
454
558
|
"""Resolve characteristics."""
|
|
455
559
|
self._read_char = services.get_characteristic(READ_CHAR_UUID)
|
|
456
560
|
if not self._read_char:
|
|
457
|
-
|
|
561
|
+
self._read_char = READ_CHAR_UUID
|
|
562
|
+
_LOGGER.error(CharacteristicMissingError(READ_CHAR_UUID))
|
|
458
563
|
self._write_char = services.get_characteristic(WRITE_CHAR_UUID)
|
|
459
564
|
if not self._write_char:
|
|
460
|
-
|
|
565
|
+
self._write_char = WRITE_CHAR_UUID
|
|
566
|
+
_LOGGER.error(CharacteristicMissingError(WRITE_CHAR_UUID))
|
|
461
567
|
|
|
462
568
|
def _reset_disconnect_timer(self):
|
|
463
569
|
"""Reset disconnect timer."""
|
|
464
570
|
self._cancel_disconnect_timer()
|
|
465
571
|
self._expected_disconnect = False
|
|
466
|
-
self._disconnect_timer = self.loop.call_later(
|
|
467
|
-
DISCONNECT_DELAY, self._disconnect_from_timer
|
|
468
|
-
)
|
|
572
|
+
self._disconnect_timer = self.loop.call_later(DISCONNECT_DELAY, self._disconnect_from_timer)
|
|
469
573
|
|
|
470
574
|
def _disconnected(self, client: BleakClientWithServiceCache) -> None:
|
|
471
575
|
"""Disconnected callback."""
|
|
472
576
|
if self._expected_disconnect:
|
|
473
|
-
_LOGGER.debug(
|
|
474
|
-
"%s: Disconnected from device; RSSI: %s", self.name, self.rssi
|
|
475
|
-
)
|
|
577
|
+
_LOGGER.debug("%s: Disconnected from device; RSSI: %s", self.name, self.rssi)
|
|
476
578
|
return
|
|
477
579
|
_LOGGER.warning(
|
|
478
580
|
"%s: Device unexpectedly disconnected; RSSI: %s",
|
|
@@ -492,9 +594,7 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
492
594
|
self._reset_disconnect_timer()
|
|
493
595
|
return
|
|
494
596
|
self._cancel_disconnect_timer()
|
|
495
|
-
self._timed_disconnect_task = asyncio.create_task(
|
|
496
|
-
self._execute_timed_disconnect()
|
|
497
|
-
)
|
|
597
|
+
self._timed_disconnect_task = asyncio.create_task(self._execute_timed_disconnect())
|
|
498
598
|
|
|
499
599
|
def _cancel_disconnect_timer(self):
|
|
500
600
|
"""Cancel disconnect timer."""
|
|
@@ -542,10 +642,11 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
542
642
|
_LOGGER.debug("%s: Disconnecting", self.name)
|
|
543
643
|
try:
|
|
544
644
|
"""We reset what command the robot last heard before disconnecting."""
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
645
|
+
if client.is_connected:
|
|
646
|
+
command_bytes = self._commands.send_todev_ble_sync(2)
|
|
647
|
+
await self._message.post_custom_data_bytes(command_bytes)
|
|
648
|
+
await client.stop_notify(self._read_char)
|
|
649
|
+
await client.disconnect()
|
|
549
650
|
except BLEAK_RETRY_EXCEPTIONS as ex:
|
|
550
651
|
_LOGGER.warning(
|
|
551
652
|
"%s: Error disconnecting: %s; RSSI: %s",
|
|
@@ -560,3 +661,86 @@ class MammotionBaseBLEDevice(MammotionBaseDevice):
|
|
|
560
661
|
async def _disconnect(self) -> bool:
|
|
561
662
|
if self._client is not None:
|
|
562
663
|
return await self._client.disconnect()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
class MammotionBaseCloudDevice(MammotionBaseDevice):
|
|
667
|
+
"""Base class for Mammotion Cloud devices."""
|
|
668
|
+
|
|
669
|
+
def __init__(
|
|
670
|
+
self,
|
|
671
|
+
mqtt_client: MammotionMQTT,
|
|
672
|
+
iot_id: str,
|
|
673
|
+
device_name: str,
|
|
674
|
+
nick_name: str,
|
|
675
|
+
**kwargs: Any,
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Initialize MammotionBaseCloudDevice."""
|
|
678
|
+
super().__init__()
|
|
679
|
+
self._mqtt_client = mqtt_client
|
|
680
|
+
self.iot_id = iot_id
|
|
681
|
+
self.nick_name = nick_name
|
|
682
|
+
self._command_futures = {}
|
|
683
|
+
self._commands: MammotionCommand = MammotionCommand(device_name)
|
|
684
|
+
self.loop = asyncio.get_event_loop()
|
|
685
|
+
|
|
686
|
+
def _on_mqtt_message(self, topic: str, payload: str) -> None:
|
|
687
|
+
"""Handle incoming MQTT messages."""
|
|
688
|
+
_LOGGER.debug("MQTT message received on topic %s: %s", topic, payload)
|
|
689
|
+
payload = json.loads(payload)
|
|
690
|
+
message_id = self._extract_message_id(payload)
|
|
691
|
+
if message_id and message_id in self._command_futures:
|
|
692
|
+
self._parse_mqtt_response(topic=topic, payload=payload)
|
|
693
|
+
future = self._command_futures.pop(message_id)
|
|
694
|
+
if not future.done():
|
|
695
|
+
future.set_result(payload)
|
|
696
|
+
|
|
697
|
+
async def _send_command(self, key: str, retry: int | None = None) -> bytes | None:
|
|
698
|
+
"""Send command to device via MQTT and read response."""
|
|
699
|
+
future = self.loop.create_future()
|
|
700
|
+
command_bytes = getattr(self._commands, key)()
|
|
701
|
+
message_id = self._mqtt_client.get_cloud_client().send_cloud_command(self.iot_id, command_bytes)
|
|
702
|
+
if message_id != "":
|
|
703
|
+
self._command_futures[message_id] = future
|
|
704
|
+
try:
|
|
705
|
+
return await asyncio.wait_for(future, timeout=TIMEOUT_CLOUD_RESPONSE)
|
|
706
|
+
except asyncio.TimeoutError:
|
|
707
|
+
_LOGGER.error("Command '%s' timed out", key)
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
async def _send_command_with_args(self, key: str, **kwargs: any) -> bytes | None:
|
|
711
|
+
"""Send command with arguments to device via MQTT and read response."""
|
|
712
|
+
future = self.loop.create_future()
|
|
713
|
+
command_bytes = getattr(self._commands, key)(**kwargs)
|
|
714
|
+
message_id = self._mqtt_client.get_cloud_client().send_cloud_command(self.iot_id, command_bytes)
|
|
715
|
+
if message_id != "":
|
|
716
|
+
self._command_futures[message_id] = future
|
|
717
|
+
try:
|
|
718
|
+
return await asyncio.wait_for(future, timeout=TIMEOUT_CLOUD_RESPONSE)
|
|
719
|
+
except asyncio.TimeoutError:
|
|
720
|
+
_LOGGER.error("Command '%s' timed out", key)
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
def _extract_message_id(self, payload: dict) -> str:
|
|
724
|
+
"""Extract the message ID from the payload."""
|
|
725
|
+
return payload.get("id", "")
|
|
726
|
+
|
|
727
|
+
def _extract_encoded_message(self, payload: dict) -> str:
|
|
728
|
+
"""Extract the encoded message from the payload."""
|
|
729
|
+
try:
|
|
730
|
+
content = payload.get("data", {}).get("data", {}).get("params", {}).get("content", "")
|
|
731
|
+
return str(content)
|
|
732
|
+
except AttributeError:
|
|
733
|
+
_LOGGER.error("Error extracting encoded message. Payload: %s", payload)
|
|
734
|
+
return ""
|
|
735
|
+
|
|
736
|
+
def _parse_mqtt_response(self, topic: str, payload: dict) -> None:
|
|
737
|
+
"""Parse the MQTT response."""
|
|
738
|
+
if topic.endswith("/app/down/thing/events"):
|
|
739
|
+
event = ThingEventMessage(**payload)
|
|
740
|
+
params = event.params
|
|
741
|
+
if params.identifier == "device_protobuf_msg_event":
|
|
742
|
+
self._update_raw_data(cast(bytes, params.value.content))
|
|
743
|
+
|
|
744
|
+
async def _disconnect(self):
|
|
745
|
+
"""Disconnect the MQTT client."""
|
|
746
|
+
self._mqtt_client.disconnect()
|