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.
- pymammotion/__init__.py +12 -9
- pymammotion/aliyun/cloud_gateway.py +57 -39
- pymammotion/aliyun/cloud_service.py +3 -3
- pymammotion/aliyun/dataclass/dev_by_account_response.py +1 -2
- pymammotion/aliyun/dataclass/session_by_authcode_response.py +1 -0
- pymammotion/bluetooth/ble.py +6 -6
- pymammotion/bluetooth/ble_message.py +30 -16
- pymammotion/bluetooth/data/convert.py +1 -1
- pymammotion/bluetooth/data/framectrldata.py +1 -1
- pymammotion/bluetooth/data/notifydata.py +6 -6
- pymammotion/const.py +1 -0
- pymammotion/data/model/__init__.py +2 -0
- pymammotion/data/model/account.py +1 -1
- pymammotion/data/model/device.py +31 -24
- pymammotion/data/model/device_config.py +71 -0
- pymammotion/data/model/enums.py +4 -4
- pymammotion/data/model/excute_boarder_params.py +5 -5
- pymammotion/data/model/execute_boarder.py +4 -4
- pymammotion/data/model/generate_route_information.py +18 -124
- pymammotion/data/model/hash_list.py +4 -7
- pymammotion/data/model/location.py +3 -3
- pymammotion/data/model/mowing_modes.py +1 -1
- pymammotion/data/model/plan.py +4 -4
- pymammotion/data/model/region_data.py +4 -4
- pymammotion/data/model/report_info.py +1 -1
- pymammotion/data/mqtt/event.py +8 -3
- pymammotion/data/state_manager.py +13 -12
- pymammotion/event/event.py +14 -14
- pymammotion/http/http.py +33 -45
- pymammotion/http/model/http.py +75 -0
- pymammotion/mammotion/commands/messages/driver.py +20 -23
- pymammotion/mammotion/commands/messages/navigation.py +47 -48
- pymammotion/mammotion/commands/messages/network.py +17 -35
- pymammotion/mammotion/commands/messages/system.py +6 -7
- pymammotion/mammotion/control/joystick.py +11 -10
- pymammotion/mammotion/devices/__init__.py +2 -2
- pymammotion/mammotion/devices/base.py +248 -0
- pymammotion/mammotion/devices/mammotion.py +52 -1042
- pymammotion/mammotion/devices/mammotion_bluetooth.py +447 -0
- pymammotion/mammotion/devices/mammotion_cloud.py +244 -0
- pymammotion/mqtt/mammotion_future.py +3 -2
- pymammotion/mqtt/mammotion_mqtt.py +23 -23
- pymammotion/proto/__init__.py +6 -0
- pymammotion/utility/constant/__init__.py +3 -1
- pymammotion/utility/conversions.py +1 -1
- pymammotion/utility/datatype_converter.py +9 -9
- pymammotion/utility/device_type.py +47 -18
- pymammotion/utility/map.py +2 -2
- pymammotion/utility/movement.py +2 -1
- pymammotion/utility/periodic.py +5 -5
- pymammotion/utility/rocker_util.py +1 -1
- {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/METADATA +3 -1
- {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/RECORD +55 -51
- {pymammotion-0.2.28.dist-info → pymammotion-0.2.30.dist-info}/LICENSE +0 -0
- {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
|
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
|
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.
|
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__(
|
142
|
-
|
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:
|
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
|
-
|
164
|
-
|
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
|
-
|
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(
|
204
|
-
|
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
|
212
|
-
"""Represents a Mammotion
|
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:
|
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 =
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
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.
|
250
|
-
if device.deviceName.startswith(("Luba-", "Yuka-")):
|
251
|
-
self.devices.add_device(
|
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(
|
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:
|
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
|
-
|