python-yarbo 0.1.0__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.
- python_yarbo-0.1.0.dist-info/METADATA +306 -0
- python_yarbo-0.1.0.dist-info/RECORD +18 -0
- python_yarbo-0.1.0.dist-info/WHEEL +4 -0
- python_yarbo-0.1.0.dist-info/licenses/LICENSE +21 -0
- yarbo/__init__.py +122 -0
- yarbo/_codec.py +63 -0
- yarbo/auth.py +244 -0
- yarbo/client.py +695 -0
- yarbo/cloud.py +288 -0
- yarbo/cloud_mqtt.py +109 -0
- yarbo/const.py +197 -0
- yarbo/discovery.py +217 -0
- yarbo/error_reporting.py +83 -0
- yarbo/exceptions.py +112 -0
- yarbo/keys/README.md +41 -0
- yarbo/local.py +1636 -0
- yarbo/models.py +787 -0
- yarbo/mqtt.py +487 -0
yarbo/local.py
ADDED
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
"""
|
|
2
|
+
yarbo.local — YarboLocalClient: anonymous MQTT-only local control.
|
|
3
|
+
|
|
4
|
+
Controls the Yarbo robot directly over the local EMQX broker without
|
|
5
|
+
requiring a cloud account. All operations are local and work offline.
|
|
6
|
+
|
|
7
|
+
Prerequisites:
|
|
8
|
+
- The host machine must be on the same WiFi as the robot.
|
|
9
|
+
- The robot's EMQX broker IP must be known (default: 192.168.1.24).
|
|
10
|
+
- ``paho-mqtt`` must be installed: ``pip install 'python-yarbo'``.
|
|
11
|
+
|
|
12
|
+
Protocol notes (from live captures):
|
|
13
|
+
- All MQTT payloads are zlib-compressed JSON (see ``_codec``).
|
|
14
|
+
- ``get_controller`` MUST be sent before action commands (e.g. light_ctrl).
|
|
15
|
+
- Topics: ``snowbot/{SN}/app/{cmd}`` (publish) and
|
|
16
|
+
``snowbot/{SN}/device/{feedback}`` (subscribe).
|
|
17
|
+
- Commands are generally fire-and-forget; responses on ``data_feedback``.
|
|
18
|
+
|
|
19
|
+
Transport limitations (NOT YET IMPLEMENTED):
|
|
20
|
+
- Local REST API (``192.168.8.8:8088``) — direct HTTP REST on the robot network.
|
|
21
|
+
Endpoints are unknown; requires further sniffing or SSH exploration.
|
|
22
|
+
- Local TCP JSON (``192.168.8.1:22220``) — a JSON-over-TCP protocol discovered
|
|
23
|
+
in libapp.so (uses ``com`` field with ``@n`` namespace notation).
|
|
24
|
+
- This module is MQTT-only. Both unimplemented transports are TODO items.
|
|
25
|
+
|
|
26
|
+
References:
|
|
27
|
+
yarbo-reversing/scripts/local_ctrl.py — working reference implementation
|
|
28
|
+
yarbo-reversing/docs/COMMAND_CATALOGUE.md — full command catalogue
|
|
29
|
+
yarbo-reversing/docs/LIGHT_CTRL_PROTOCOL.md — light control protocol
|
|
30
|
+
yarbo-reversing/docs/MQTT_PROTOCOL.md — protocol reference
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import contextlib
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
import logging
|
|
39
|
+
import time
|
|
40
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
41
|
+
|
|
42
|
+
from .const import (
|
|
43
|
+
DEFAULT_CMD_TIMEOUT,
|
|
44
|
+
LOCAL_BROKER_DEFAULT,
|
|
45
|
+
LOCAL_PORT,
|
|
46
|
+
TOPIC_LEAF_DATA_FEEDBACK,
|
|
47
|
+
TOPIC_LEAF_DEVICE_MSG,
|
|
48
|
+
TOPIC_LEAF_PLAN_FEEDBACK,
|
|
49
|
+
)
|
|
50
|
+
from .exceptions import YarboNotControllerError, YarboTimeoutError
|
|
51
|
+
from .models import YarboCommandResult, YarboLightState, YarboPlan, YarboSchedule, YarboTelemetry
|
|
52
|
+
from .mqtt import MqttTransport
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from collections.abc import AsyncIterator
|
|
56
|
+
from types import TracebackType
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class YarboLocalClient:
|
|
62
|
+
"""
|
|
63
|
+
Local MQTT client for anonymous control of a Yarbo robot.
|
|
64
|
+
|
|
65
|
+
Communicates directly with the robot's on-board EMQX broker.
|
|
66
|
+
No cloud account, no internet connection required.
|
|
67
|
+
|
|
68
|
+
Example (async context manager)::
|
|
69
|
+
|
|
70
|
+
async with YarboLocalClient(broker="192.168.1.24", sn="24400102L8HO5227") as client:
|
|
71
|
+
await client.lights_on()
|
|
72
|
+
await client.buzzer(state=1)
|
|
73
|
+
async for telemetry in client.watch_telemetry():
|
|
74
|
+
print(f"Battery: {telemetry.battery}%")
|
|
75
|
+
|
|
76
|
+
Example (manual lifecycle)::
|
|
77
|
+
|
|
78
|
+
client = YarboLocalClient(broker="192.168.1.24", sn="24400102L8HO5227")
|
|
79
|
+
await client.connect()
|
|
80
|
+
await client.lights_on()
|
|
81
|
+
await client.disconnect()
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
broker: MQTT broker IP address.
|
|
85
|
+
sn: Robot serial number.
|
|
86
|
+
port: Broker port (default 1883).
|
|
87
|
+
auto_controller: If ``True`` (default), automatically send
|
|
88
|
+
``get_controller`` before the first action command.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
broker: str = LOCAL_BROKER_DEFAULT,
|
|
94
|
+
sn: str = "",
|
|
95
|
+
port: int = LOCAL_PORT,
|
|
96
|
+
auto_controller: bool = True,
|
|
97
|
+
) -> None:
|
|
98
|
+
self._broker = broker
|
|
99
|
+
self._sn = sn
|
|
100
|
+
self._port = port
|
|
101
|
+
self._auto_controller = auto_controller
|
|
102
|
+
self._transport = MqttTransport(broker=broker, sn=sn, port=port)
|
|
103
|
+
self._controller_acquired = False
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Context manager
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> YarboLocalClient:
|
|
110
|
+
await self.connect()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(
|
|
114
|
+
self,
|
|
115
|
+
exc_type: type[BaseException] | None,
|
|
116
|
+
exc_val: BaseException | None,
|
|
117
|
+
exc_tb: TracebackType | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
await self.disconnect()
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Connection
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _on_reconnect(self) -> None:
|
|
126
|
+
"""Reset controller state when the transport reconnects after a drop."""
|
|
127
|
+
self._controller_acquired = False
|
|
128
|
+
logger.info(
|
|
129
|
+
"Reconnected — controller role reset, will re-acquire on next command (sn=%s)",
|
|
130
|
+
self._sn,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def connect(self) -> None:
|
|
134
|
+
"""Connect to the local MQTT broker."""
|
|
135
|
+
self._transport.add_reconnect_callback(self._on_reconnect)
|
|
136
|
+
await self._transport.connect()
|
|
137
|
+
logger.info(
|
|
138
|
+
"YarboLocalClient connected to %s:%d (sn=%s)",
|
|
139
|
+
self._broker,
|
|
140
|
+
self._port,
|
|
141
|
+
self._sn,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def disconnect(self) -> None:
|
|
145
|
+
"""Disconnect from the local MQTT broker."""
|
|
146
|
+
await self._transport.disconnect()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def is_connected(self) -> bool:
|
|
150
|
+
"""True if the MQTT connection is active."""
|
|
151
|
+
return self._transport.is_connected
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def serial_number(self) -> str:
|
|
155
|
+
"""Robot serial number (read-only)."""
|
|
156
|
+
return self._sn
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def controller_acquired(self) -> bool:
|
|
160
|
+
"""True if the controller handshake has been successfully completed."""
|
|
161
|
+
return self._controller_acquired
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def last_heartbeat(self) -> datetime | None:
|
|
165
|
+
"""UTC datetime of the last received ``heart_beat`` message, or ``None``."""
|
|
166
|
+
ts = self._transport.last_heartbeat
|
|
167
|
+
if ts is None:
|
|
168
|
+
return None
|
|
169
|
+
return datetime.fromtimestamp(ts, tz=UTC)
|
|
170
|
+
|
|
171
|
+
def is_healthy(self, max_age_seconds: float = 60.0) -> bool:
|
|
172
|
+
"""Return ``True`` if a heartbeat was received within *max_age_seconds*.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
max_age_seconds: Maximum acceptable age of the last heartbeat in
|
|
176
|
+
seconds (default 60.0).
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
``True`` when the transport is connected, a heartbeat has been
|
|
180
|
+
received, and the most recent one arrived within *max_age_seconds*.
|
|
181
|
+
"""
|
|
182
|
+
if not self.is_connected:
|
|
183
|
+
return False
|
|
184
|
+
ts = self._transport.last_heartbeat
|
|
185
|
+
if ts is None:
|
|
186
|
+
return False
|
|
187
|
+
return (time.time() - ts) <= max_age_seconds
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Controller handshake
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
async def get_controller(self) -> YarboCommandResult:
|
|
194
|
+
"""
|
|
195
|
+
Acquire controller role for this session.
|
|
196
|
+
|
|
197
|
+
MUST be called before any action command (lights, buzzer, motion, etc.).
|
|
198
|
+
Called automatically when ``auto_controller=True`` (the default).
|
|
199
|
+
|
|
200
|
+
Validates the ``data_feedback`` response. If the robot rejects the
|
|
201
|
+
handshake (non-zero ``state``), raises :exc:`~yarbo.exceptions.YarboNotControllerError`.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
YarboNotControllerError: If the robot explicitly rejects the handshake.
|
|
208
|
+
YarboTimeoutError: If no acknowledgement is received within the
|
|
209
|
+
command timeout (controller flag stays ``False``).
|
|
210
|
+
"""
|
|
211
|
+
# Pre-register the reply queue BEFORE publishing to eliminate the
|
|
212
|
+
# publish/subscribe race (response could arrive before we start waiting).
|
|
213
|
+
wait_queue = self._transport.create_wait_queue()
|
|
214
|
+
try:
|
|
215
|
+
await self._transport.publish("get_controller", {})
|
|
216
|
+
except Exception:
|
|
217
|
+
# publish() failed — wait_for_message's finally block never runs, so
|
|
218
|
+
# we must release the pre-registered queue here to prevent a leak.
|
|
219
|
+
self._transport.release_queue(wait_queue)
|
|
220
|
+
raise
|
|
221
|
+
msg = await self._transport.wait_for_message(
|
|
222
|
+
timeout=DEFAULT_CMD_TIMEOUT,
|
|
223
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
224
|
+
command_name="get_controller",
|
|
225
|
+
_queue=wait_queue,
|
|
226
|
+
)
|
|
227
|
+
if msg:
|
|
228
|
+
result = YarboCommandResult.from_dict(msg)
|
|
229
|
+
if not result.success:
|
|
230
|
+
raise YarboNotControllerError(
|
|
231
|
+
f"get_controller handshake rejected by robot "
|
|
232
|
+
f"(topic={result.topic!r}, state={result.state})",
|
|
233
|
+
code=str(result.state),
|
|
234
|
+
)
|
|
235
|
+
self._controller_acquired = True
|
|
236
|
+
return result
|
|
237
|
+
# Timeout — firmware that doesn't send data_feedback for get_controller.
|
|
238
|
+
# Do NOT mark as acquired; raise so the caller can decide whether to retry.
|
|
239
|
+
raise YarboTimeoutError("Timed out waiting for get_controller acknowledgement from robot.")
|
|
240
|
+
|
|
241
|
+
async def _ensure_controller(self) -> None:
|
|
242
|
+
"""Send ``get_controller`` if not already acquired and auto mode is on."""
|
|
243
|
+
if self._auto_controller and not self._controller_acquired:
|
|
244
|
+
await self.get_controller()
|
|
245
|
+
await asyncio.sleep(0.5)
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Light control
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
async def set_lights(self, state: YarboLightState) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Set all 7 LED channels at once.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
state: :class:`~yarbo.models.YarboLightState` with per-channel values (0-255).
|
|
257
|
+
|
|
258
|
+
Example::
|
|
259
|
+
|
|
260
|
+
await client.set_lights(YarboLightState(led_head=255, led_left_w=128))
|
|
261
|
+
"""
|
|
262
|
+
await self._ensure_controller()
|
|
263
|
+
await self._transport.publish("light_ctrl", state.to_dict())
|
|
264
|
+
|
|
265
|
+
async def lights_on(self) -> None:
|
|
266
|
+
"""Turn all lights on at full brightness (255)."""
|
|
267
|
+
await self.set_lights(YarboLightState.all_on())
|
|
268
|
+
|
|
269
|
+
async def lights_off(self) -> None:
|
|
270
|
+
"""Turn all lights off."""
|
|
271
|
+
await self.set_lights(YarboLightState.all_off())
|
|
272
|
+
|
|
273
|
+
async def lights_body(self) -> None:
|
|
274
|
+
"""Turn on body accent lights only (red channels, others off)."""
|
|
275
|
+
await self.set_lights(YarboLightState(body_left_r=255, body_right_r=255))
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# Buzzer
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
async def buzzer(self, state: int = 1) -> None:
|
|
282
|
+
"""
|
|
283
|
+
Trigger the robot's buzzer.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
state: 1 to play, 0 to stop. Defaults to 1 (play).
|
|
287
|
+
|
|
288
|
+
Example::
|
|
289
|
+
|
|
290
|
+
await client.buzzer(state=1) # beep
|
|
291
|
+
await asyncio.sleep(0.5)
|
|
292
|
+
await client.buzzer(state=0) # stop
|
|
293
|
+
"""
|
|
294
|
+
await self._ensure_controller()
|
|
295
|
+
ts = int(time.time() * 1000)
|
|
296
|
+
await self._transport.publish("cmd_buzzer", {"state": state, "timeStamp": ts})
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
# Chute (snow blower)
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async def set_chute(self, vel: int) -> None:
|
|
303
|
+
"""
|
|
304
|
+
Set the snow chute direction/velocity (snow blower models only).
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
vel: Chute velocity / direction integer. Positive = right, negative = left.
|
|
308
|
+
|
|
309
|
+
Reference:
|
|
310
|
+
yarbo-reversing/docs/LIGHT_CTRL_PROTOCOL.md#cmd_chute
|
|
311
|
+
"""
|
|
312
|
+
await self._ensure_controller()
|
|
313
|
+
await self._transport.publish("cmd_chute", {"vel": vel})
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# Telemetry
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
async def get_status(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> YarboTelemetry | None:
|
|
320
|
+
"""
|
|
321
|
+
Fetch a single telemetry snapshot from ``DeviceMSG`` (full telemetry).
|
|
322
|
+
|
|
323
|
+
Waits for the next ``DeviceMSG`` message, which contains the complete
|
|
324
|
+
nested telemetry payload (battery, state, RTK, odometry, etc.).
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
:class:`~yarbo.models.YarboTelemetry` or ``None`` on timeout.
|
|
328
|
+
"""
|
|
329
|
+
envelope = await self._transport.wait_for_message(
|
|
330
|
+
timeout=timeout,
|
|
331
|
+
feedback_leaf=TOPIC_LEAF_DEVICE_MSG,
|
|
332
|
+
_return_envelope=True,
|
|
333
|
+
)
|
|
334
|
+
if envelope:
|
|
335
|
+
topic = envelope.get("topic", "")
|
|
336
|
+
payload = envelope.get("payload", {})
|
|
337
|
+
return YarboTelemetry.from_dict(payload, topic=topic)
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
async def watch_telemetry(self) -> AsyncIterator[YarboTelemetry]:
|
|
341
|
+
"""
|
|
342
|
+
Async generator yielding live telemetry from ``DeviceMSG`` messages.
|
|
343
|
+
|
|
344
|
+
Filters the envelope stream to ``DeviceMSG`` messages only and yields
|
|
345
|
+
a :class:`~yarbo.models.YarboTelemetry` for each one (~1-2 Hz).
|
|
346
|
+
|
|
347
|
+
To access raw envelopes from all topics, use
|
|
348
|
+
:meth:`~yarbo.mqtt.MqttTransport.telemetry_stream` on the transport
|
|
349
|
+
directly.
|
|
350
|
+
|
|
351
|
+
Example::
|
|
352
|
+
|
|
353
|
+
async for telemetry in client.watch_telemetry():
|
|
354
|
+
print(f"Battery: {telemetry.battery}% State: {telemetry.state}")
|
|
355
|
+
if telemetry.battery and telemetry.battery < 10:
|
|
356
|
+
break
|
|
357
|
+
"""
|
|
358
|
+
# Cache plan_feedback data to merge into each DeviceMSG telemetry object
|
|
359
|
+
_plan_payload: dict[str, Any] = {}
|
|
360
|
+
async for envelope in self._transport.telemetry_stream():
|
|
361
|
+
if envelope.kind == TOPIC_LEAF_PLAN_FEEDBACK:
|
|
362
|
+
_plan_payload = envelope.payload
|
|
363
|
+
elif envelope.is_telemetry:
|
|
364
|
+
t = envelope.to_telemetry()
|
|
365
|
+
if _plan_payload:
|
|
366
|
+
t.plan_id = _plan_payload.get("planId")
|
|
367
|
+
t.plan_state = _plan_payload.get("state")
|
|
368
|
+
t.area_covered = _plan_payload.get("areaCovered")
|
|
369
|
+
t.duration = _plan_payload.get("duration")
|
|
370
|
+
yield t
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------------------------
|
|
373
|
+
# Internal helper: publish + wait for data_feedback
|
|
374
|
+
# ------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
async def _publish_and_wait(
|
|
377
|
+
self,
|
|
378
|
+
cmd: str,
|
|
379
|
+
payload: dict[str, Any],
|
|
380
|
+
timeout: float = DEFAULT_CMD_TIMEOUT,
|
|
381
|
+
) -> YarboCommandResult:
|
|
382
|
+
"""Publish *cmd* and wait for the matching ``data_feedback`` response.
|
|
383
|
+
|
|
384
|
+
Uses the pre-register pattern (create queue → publish → wait) to avoid
|
|
385
|
+
the publish/subscribe race for fast-responding firmware.
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
YarboTimeoutError: If no response arrives within *timeout* seconds.
|
|
389
|
+
"""
|
|
390
|
+
wait_queue = self._transport.create_wait_queue()
|
|
391
|
+
try:
|
|
392
|
+
await self._transport.publish(cmd, payload)
|
|
393
|
+
except Exception:
|
|
394
|
+
self._transport.release_queue(wait_queue)
|
|
395
|
+
raise
|
|
396
|
+
msg = await self._transport.wait_for_message(
|
|
397
|
+
timeout=timeout,
|
|
398
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
399
|
+
command_name=cmd,
|
|
400
|
+
_queue=wait_queue,
|
|
401
|
+
)
|
|
402
|
+
if msg is None:
|
|
403
|
+
raise YarboTimeoutError(f"Timed out waiting for {cmd!r} response from robot.")
|
|
404
|
+
return YarboCommandResult.from_dict(msg)
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# Plan management
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async def start_plan(self, plan_id: str) -> YarboCommandResult:
|
|
411
|
+
"""Start the plan identified by *plan_id*.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
plan_id: UUID of the plan to execute.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
421
|
+
"""
|
|
422
|
+
await self._ensure_controller()
|
|
423
|
+
return await self._publish_and_wait("start_plan", {"planId": plan_id})
|
|
424
|
+
|
|
425
|
+
async def stop_plan(self) -> YarboCommandResult:
|
|
426
|
+
"""Stop the currently running plan.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
433
|
+
"""
|
|
434
|
+
await self._ensure_controller()
|
|
435
|
+
return await self._publish_and_wait("stop_plan", {})
|
|
436
|
+
|
|
437
|
+
async def pause_plan(self) -> YarboCommandResult:
|
|
438
|
+
"""Pause the currently running plan.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
445
|
+
"""
|
|
446
|
+
await self._ensure_controller()
|
|
447
|
+
return await self._publish_and_wait("pause_plan", {})
|
|
448
|
+
|
|
449
|
+
async def resume_plan(self) -> YarboCommandResult:
|
|
450
|
+
"""Resume a paused plan.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
457
|
+
"""
|
|
458
|
+
await self._ensure_controller()
|
|
459
|
+
return await self._publish_and_wait("resume_plan", {})
|
|
460
|
+
|
|
461
|
+
async def return_to_dock(self) -> YarboCommandResult:
|
|
462
|
+
"""Send the robot back to its charging dock (``cmd_recharge``).
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
466
|
+
|
|
467
|
+
Raises:
|
|
468
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
469
|
+
"""
|
|
470
|
+
await self._ensure_controller()
|
|
471
|
+
return await self._publish_and_wait("cmd_recharge", {})
|
|
472
|
+
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
# Schedule management
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
async def list_schedules(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> list[YarboSchedule]:
|
|
478
|
+
"""Fetch the list of saved schedules from the robot.
|
|
479
|
+
|
|
480
|
+
Sends ``read_all_schedule`` and waits for the ``data_feedback`` response.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
timeout: Maximum wait time in seconds (default 5.0).
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
List of :class:`~yarbo.models.YarboSchedule` objects.
|
|
487
|
+
Returns an empty list on timeout.
|
|
488
|
+
"""
|
|
489
|
+
wait_queue = self._transport.create_wait_queue()
|
|
490
|
+
try:
|
|
491
|
+
await self._transport.publish("read_all_schedule", {})
|
|
492
|
+
except Exception:
|
|
493
|
+
self._transport.release_queue(wait_queue)
|
|
494
|
+
raise
|
|
495
|
+
msg = await self._transport.wait_for_message(
|
|
496
|
+
timeout=timeout,
|
|
497
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
498
|
+
command_name="read_all_schedule",
|
|
499
|
+
_queue=wait_queue,
|
|
500
|
+
)
|
|
501
|
+
if msg is None:
|
|
502
|
+
return []
|
|
503
|
+
data: dict[str, Any] = msg.get("data", {}) or {}
|
|
504
|
+
schedules_raw: list[Any] = data.get("scheduleList", data.get("schedules", []))
|
|
505
|
+
return [YarboSchedule.from_dict(s) for s in schedules_raw]
|
|
506
|
+
|
|
507
|
+
async def set_schedule(self, schedule: YarboSchedule) -> YarboCommandResult:
|
|
508
|
+
"""Save or update a schedule on the robot.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
schedule: :class:`~yarbo.models.YarboSchedule` to save.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
518
|
+
"""
|
|
519
|
+
await self._ensure_controller()
|
|
520
|
+
return await self._publish_and_wait("save_schedule", schedule.to_dict())
|
|
521
|
+
|
|
522
|
+
async def delete_schedule(self, schedule_id: str) -> YarboCommandResult:
|
|
523
|
+
"""Delete a schedule by its ID.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
schedule_id: UUID of the schedule to delete.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
530
|
+
|
|
531
|
+
Raises:
|
|
532
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
533
|
+
"""
|
|
534
|
+
await self._ensure_controller()
|
|
535
|
+
return await self._publish_and_wait("del_schedule", {"scheduleId": schedule_id})
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------------
|
|
538
|
+
# Plan CRUD
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
async def list_plans(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> list[YarboPlan]:
|
|
542
|
+
"""Fetch the list of saved plans from the robot.
|
|
543
|
+
|
|
544
|
+
Sends ``read_all_plan`` and waits for the ``data_feedback`` response.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
timeout: Maximum wait time in seconds (default 5.0).
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
List of :class:`~yarbo.models.YarboPlan` objects.
|
|
551
|
+
Returns an empty list on timeout.
|
|
552
|
+
"""
|
|
553
|
+
wait_queue = self._transport.create_wait_queue()
|
|
554
|
+
try:
|
|
555
|
+
await self._transport.publish("read_all_plan", {})
|
|
556
|
+
except Exception:
|
|
557
|
+
self._transport.release_queue(wait_queue)
|
|
558
|
+
raise
|
|
559
|
+
msg = await self._transport.wait_for_message(
|
|
560
|
+
timeout=timeout,
|
|
561
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
562
|
+
command_name="read_all_plan",
|
|
563
|
+
_queue=wait_queue,
|
|
564
|
+
)
|
|
565
|
+
if msg is None:
|
|
566
|
+
return []
|
|
567
|
+
data: dict[str, Any] = msg.get("data", {}) or {}
|
|
568
|
+
plans_raw: list[Any] = data.get("planList", data.get("plans", []))
|
|
569
|
+
return [YarboPlan.from_dict(p) for p in plans_raw]
|
|
570
|
+
|
|
571
|
+
async def delete_plan(self, plan_id: str) -> YarboCommandResult:
|
|
572
|
+
"""Delete a plan by its ID.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
plan_id: UUID of the plan to delete.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
579
|
+
|
|
580
|
+
Raises:
|
|
581
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
582
|
+
"""
|
|
583
|
+
await self._ensure_controller()
|
|
584
|
+
return await self._publish_and_wait("del_plan", {"planId": plan_id})
|
|
585
|
+
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
# Manual drive
|
|
588
|
+
# ------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
async def start_manual_drive(self) -> None:
|
|
591
|
+
"""Enter manual drive mode (``set_working_state`` state=manual).
|
|
592
|
+
|
|
593
|
+
Fires and forgets — no response is expected for this command.
|
|
594
|
+
Use :meth:`set_velocity`, :meth:`set_roller`, and :meth:`set_chute`
|
|
595
|
+
to control the robot while in manual mode, then call
|
|
596
|
+
:meth:`stop_manual_drive` when done.
|
|
597
|
+
"""
|
|
598
|
+
await self._ensure_controller()
|
|
599
|
+
await self._transport.publish("set_working_state", {"state": "manual"})
|
|
600
|
+
|
|
601
|
+
async def set_velocity(self, linear: float, angular: float = 0.0) -> None:
|
|
602
|
+
"""Send a velocity command to the robot.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
linear: Linear velocity in m/s (forward positive).
|
|
606
|
+
angular: Angular velocity in rad/s (counter-clockwise positive).
|
|
607
|
+
Defaults to 0.0 (straight).
|
|
608
|
+
"""
|
|
609
|
+
await self._ensure_controller()
|
|
610
|
+
await self._transport.publish("cmd_vel", {"vel": linear, "rev": angular})
|
|
611
|
+
|
|
612
|
+
async def set_roller(self, speed: int) -> None:
|
|
613
|
+
"""Set the roller speed (leaf-blower/snow-blower models only).
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
speed: Roller speed in RPM (0-2000).
|
|
617
|
+
"""
|
|
618
|
+
await self._ensure_controller()
|
|
619
|
+
await self._transport.publish("cmd_roller", {"vel": speed})
|
|
620
|
+
|
|
621
|
+
async def stop_manual_drive(
|
|
622
|
+
self, hard: bool = False, emergency: bool = False
|
|
623
|
+
) -> YarboCommandResult:
|
|
624
|
+
"""Exit manual drive mode and stop the robot.
|
|
625
|
+
|
|
626
|
+
Three stop modes are supported (in increasing priority):
|
|
627
|
+
|
|
628
|
+
* ``stop_manual_drive()`` → ``dstop`` (graceful stop)
|
|
629
|
+
* ``stop_manual_drive(hard=True)`` → ``dstopp`` (hard immediate stop)
|
|
630
|
+
* ``stop_manual_drive(emergency=True)``→ ``emergency_stop_active``
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
hard: Send an immediate hard stop (``dstopp``) instead of the
|
|
634
|
+
default graceful stop (``dstop``).
|
|
635
|
+
emergency: Send an emergency stop (``emergency_stop_active``),
|
|
636
|
+
overrides *hard*.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
:class:`~yarbo.models.YarboCommandResult` from the robot.
|
|
640
|
+
|
|
641
|
+
Raises:
|
|
642
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
643
|
+
"""
|
|
644
|
+
await self._ensure_controller()
|
|
645
|
+
cmd = "emergency_stop_active" if emergency else ("dstopp" if hard else "dstop")
|
|
646
|
+
return await self._publish_and_wait(cmd, {})
|
|
647
|
+
|
|
648
|
+
# ------------------------------------------------------------------
|
|
649
|
+
# Global params
|
|
650
|
+
# ------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
async def get_global_params(self, timeout: float = DEFAULT_CMD_TIMEOUT) -> dict[str, Any]:
|
|
653
|
+
"""Fetch all global robot parameters (``read_global_params``).
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
timeout: Maximum wait time in seconds (default 5.0).
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Dict of global parameters as returned by the robot.
|
|
660
|
+
Returns an empty dict on timeout.
|
|
661
|
+
"""
|
|
662
|
+
wait_queue = self._transport.create_wait_queue()
|
|
663
|
+
try:
|
|
664
|
+
await self._transport.publish("read_global_params", {})
|
|
665
|
+
except Exception:
|
|
666
|
+
self._transport.release_queue(wait_queue)
|
|
667
|
+
raise
|
|
668
|
+
msg = await self._transport.wait_for_message(
|
|
669
|
+
timeout=timeout,
|
|
670
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
671
|
+
command_name="read_global_params",
|
|
672
|
+
_queue=wait_queue,
|
|
673
|
+
)
|
|
674
|
+
if msg is None:
|
|
675
|
+
return {}
|
|
676
|
+
return dict(msg.get("data", {}) or {})
|
|
677
|
+
|
|
678
|
+
async def set_global_params(self, params: dict[str, Any]) -> YarboCommandResult:
|
|
679
|
+
"""Save global robot parameters (``cmd_save_para``).
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
params: Dict of parameter key/value pairs to save.
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
686
|
+
|
|
687
|
+
Raises:
|
|
688
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
689
|
+
"""
|
|
690
|
+
await self._ensure_controller()
|
|
691
|
+
return await self._publish_and_wait("cmd_save_para", params)
|
|
692
|
+
|
|
693
|
+
# ------------------------------------------------------------------
|
|
694
|
+
# Map retrieval
|
|
695
|
+
# ------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async def get_map(self, timeout: float = 10.0) -> dict[str, Any]:
|
|
698
|
+
"""Retrieve the robot's current map data (``get_map``).
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
timeout: Maximum wait time in seconds (default 10.0).
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Map data dict as returned by the robot.
|
|
705
|
+
Returns an empty dict on timeout.
|
|
706
|
+
"""
|
|
707
|
+
wait_queue = self._transport.create_wait_queue()
|
|
708
|
+
try:
|
|
709
|
+
await self._transport.publish("get_map", {})
|
|
710
|
+
except Exception:
|
|
711
|
+
self._transport.release_queue(wait_queue)
|
|
712
|
+
raise
|
|
713
|
+
msg = await self._transport.wait_for_message(
|
|
714
|
+
timeout=timeout,
|
|
715
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
716
|
+
command_name="get_map",
|
|
717
|
+
_queue=wait_queue,
|
|
718
|
+
)
|
|
719
|
+
if msg is None:
|
|
720
|
+
return {}
|
|
721
|
+
return dict(msg.get("data", {}) or {})
|
|
722
|
+
|
|
723
|
+
# ------------------------------------------------------------------
|
|
724
|
+
# Plan creation
|
|
725
|
+
# ------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
async def create_plan(
|
|
728
|
+
self,
|
|
729
|
+
name: str,
|
|
730
|
+
area_ids: list[int],
|
|
731
|
+
enable_self_order: bool = False,
|
|
732
|
+
) -> YarboCommandResult:
|
|
733
|
+
"""Create a new work plan on the robot (``save_plan``).
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
name: Display name for the plan.
|
|
737
|
+
area_ids: List of area IDs to include.
|
|
738
|
+
enable_self_order: Whether the robot should self-order the areas.
|
|
739
|
+
Defaults to ``False``.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
:class:`~yarbo.models.YarboCommandResult` on success.
|
|
743
|
+
|
|
744
|
+
Raises:
|
|
745
|
+
YarboTimeoutError: If no acknowledgement is received.
|
|
746
|
+
"""
|
|
747
|
+
await self._ensure_controller()
|
|
748
|
+
payload: dict[str, Any] = {
|
|
749
|
+
"name": name,
|
|
750
|
+
"areaIds": area_ids,
|
|
751
|
+
"enable_self_order": enable_self_order,
|
|
752
|
+
}
|
|
753
|
+
return await self._publish_and_wait("save_plan", payload)
|
|
754
|
+
|
|
755
|
+
# ------------------------------------------------------------------
|
|
756
|
+
# Robot control
|
|
757
|
+
# ------------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
async def shutdown(self) -> None:
|
|
760
|
+
"""Power off the robot."""
|
|
761
|
+
await self._ensure_controller()
|
|
762
|
+
await self._transport.publish("shutdown", {})
|
|
763
|
+
|
|
764
|
+
async def restart_container(self) -> None:
|
|
765
|
+
"""Restart the EMQX container on the robot."""
|
|
766
|
+
await self._ensure_controller()
|
|
767
|
+
await self._transport.publish("restart_container", {})
|
|
768
|
+
|
|
769
|
+
async def emergency_stop(self) -> None:
|
|
770
|
+
"""Trigger an emergency stop."""
|
|
771
|
+
await self._ensure_controller()
|
|
772
|
+
await self._transport.publish("emergency_stop_active", {})
|
|
773
|
+
|
|
774
|
+
async def emergency_unlock(self) -> None:
|
|
775
|
+
"""Clear the emergency stop state."""
|
|
776
|
+
await self._ensure_controller()
|
|
777
|
+
await self._transport.publish("emergency_unlock", {})
|
|
778
|
+
|
|
779
|
+
async def dstop(self) -> None:
|
|
780
|
+
"""Soft-stop the robot (decelerate to halt)."""
|
|
781
|
+
await self._ensure_controller()
|
|
782
|
+
await self._transport.publish("dstop", {})
|
|
783
|
+
|
|
784
|
+
async def resume(self) -> None:
|
|
785
|
+
"""Resume operation after a pause or soft-stop."""
|
|
786
|
+
await self._ensure_controller()
|
|
787
|
+
await self._transport.publish("resume", {})
|
|
788
|
+
|
|
789
|
+
async def cmd_recharge(self) -> None:
|
|
790
|
+
"""Send the robot back to its charging dock."""
|
|
791
|
+
await self._ensure_controller()
|
|
792
|
+
await self._transport.publish("cmd_recharge", {})
|
|
793
|
+
|
|
794
|
+
# ------------------------------------------------------------------
|
|
795
|
+
# Lights & sound
|
|
796
|
+
# ------------------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
async def set_head_light(self, enabled: bool) -> None:
|
|
799
|
+
"""
|
|
800
|
+
Enable or disable the head light.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
enabled: True to turn on, False to turn off.
|
|
804
|
+
"""
|
|
805
|
+
await self._ensure_controller()
|
|
806
|
+
await self._transport.publish("head_light", {"state": 1 if enabled else 0})
|
|
807
|
+
|
|
808
|
+
async def set_roof_lights(self, enabled: bool) -> None:
|
|
809
|
+
"""
|
|
810
|
+
Enable or disable the roof lights.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
enabled: True to turn on, False to turn off.
|
|
814
|
+
"""
|
|
815
|
+
await self._ensure_controller()
|
|
816
|
+
await self._transport.publish("roof_lights_enable", {"enable": 1 if enabled else 0})
|
|
817
|
+
|
|
818
|
+
async def set_laser(self, enabled: bool) -> None:
|
|
819
|
+
"""
|
|
820
|
+
Enable or disable the laser.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
enabled: True to enable, False to disable.
|
|
824
|
+
"""
|
|
825
|
+
await self._ensure_controller()
|
|
826
|
+
await self._transport.publish("laser_toggle", {"enabled": enabled})
|
|
827
|
+
|
|
828
|
+
async def set_sound(self, volume: int, song_id: int = 0) -> None:
|
|
829
|
+
"""
|
|
830
|
+
Set the speaker volume.
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
volume: Volume level (0-100).
|
|
834
|
+
song_id: Song identifier (reserved, default 0).
|
|
835
|
+
"""
|
|
836
|
+
await self._ensure_controller()
|
|
837
|
+
await self._transport.publish("set_sound_param", {"vol": volume, "songId": song_id})
|
|
838
|
+
|
|
839
|
+
async def play_song(self, song_id: int) -> None:
|
|
840
|
+
"""
|
|
841
|
+
Play a sound/song by ID.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
song_id: Identifier of the song to play.
|
|
845
|
+
"""
|
|
846
|
+
await self._ensure_controller()
|
|
847
|
+
await self._transport.publish("song_cmd", {"songId": song_id})
|
|
848
|
+
|
|
849
|
+
# ------------------------------------------------------------------
|
|
850
|
+
# Camera & detection
|
|
851
|
+
# ------------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
async def set_camera(self, enabled: bool) -> None:
|
|
854
|
+
"""
|
|
855
|
+
Enable or disable the camera.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
enabled: True to enable, False to disable.
|
|
859
|
+
"""
|
|
860
|
+
await self._ensure_controller()
|
|
861
|
+
await self._transport.publish("camera_toggle", {"enabled": enabled})
|
|
862
|
+
|
|
863
|
+
async def set_person_detect(self, enabled: bool) -> None:
|
|
864
|
+
"""
|
|
865
|
+
Enable or disable person detection.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
enabled: True to enable, False to disable.
|
|
869
|
+
"""
|
|
870
|
+
await self._ensure_controller()
|
|
871
|
+
await self._transport.publish("set_person_detect", {"enable": 1 if enabled else 0})
|
|
872
|
+
|
|
873
|
+
async def set_usb(self, enabled: bool) -> None:
|
|
874
|
+
"""
|
|
875
|
+
Enable or disable the USB port.
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
enabled: True to enable, False to disable.
|
|
879
|
+
"""
|
|
880
|
+
await self._ensure_controller()
|
|
881
|
+
await self._transport.publish("usb_toggle", {"enabled": enabled})
|
|
882
|
+
|
|
883
|
+
# ------------------------------------------------------------------
|
|
884
|
+
# Plans & scheduling
|
|
885
|
+
# ------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
async def start_plan_direct(self, plan_id: int, percent: int = 100) -> None:
|
|
888
|
+
"""
|
|
889
|
+
Start a work plan by numeric ID (direct command, no response).
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
plan_id: Numeric ID of the plan to execute.
|
|
893
|
+
percent: Coverage percentage (default 100).
|
|
894
|
+
"""
|
|
895
|
+
await self._ensure_controller()
|
|
896
|
+
await self._transport.publish("start_plan", {"planId": plan_id, "percent": percent})
|
|
897
|
+
|
|
898
|
+
async def read_plan(self, plan_id: int, timeout: float = 5.0) -> dict[str, Any]:
|
|
899
|
+
"""
|
|
900
|
+
Request detail for a specific plan and await the data_feedback response.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
plan_id: Numeric plan ID.
|
|
904
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
905
|
+
|
|
906
|
+
Returns:
|
|
907
|
+
Response payload dict, or empty dict on timeout.
|
|
908
|
+
"""
|
|
909
|
+
return await self._request_data_feedback("read_plan", {"id": plan_id}, timeout)
|
|
910
|
+
|
|
911
|
+
async def read_all_plans(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
912
|
+
"""
|
|
913
|
+
Request all plan summaries and await the data_feedback response.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
917
|
+
|
|
918
|
+
Returns:
|
|
919
|
+
Response payload dict, or empty dict on timeout.
|
|
920
|
+
"""
|
|
921
|
+
return await self._request_data_feedback("read_all_plan", {}, timeout)
|
|
922
|
+
|
|
923
|
+
async def delete_plan_direct(self, plan_id: int, confirm: bool = False) -> None:
|
|
924
|
+
"""
|
|
925
|
+
Delete a plan by numeric ID (direct command, no response).
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
plan_id: Numeric plan ID to delete.
|
|
929
|
+
confirm: Must be ``True`` to proceed — this is a destructive operation.
|
|
930
|
+
|
|
931
|
+
Raises:
|
|
932
|
+
ValueError: If *confirm* is not ``True``.
|
|
933
|
+
"""
|
|
934
|
+
if not confirm:
|
|
935
|
+
raise ValueError("delete_plan_direct is destructive — pass confirm=True to proceed.")
|
|
936
|
+
await self._ensure_controller()
|
|
937
|
+
await self._transport.publish("del_plan", {"planId": plan_id})
|
|
938
|
+
|
|
939
|
+
async def delete_all_plans(self, confirm: bool = False) -> None:
|
|
940
|
+
"""Delete all stored plans from the robot.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
confirm: Must be ``True`` to proceed — this is a destructive operation.
|
|
944
|
+
|
|
945
|
+
Raises:
|
|
946
|
+
ValueError: If *confirm* is not ``True``.
|
|
947
|
+
"""
|
|
948
|
+
if not confirm:
|
|
949
|
+
raise ValueError("delete_all_plans is destructive — pass confirm=True to proceed.")
|
|
950
|
+
await self._ensure_controller()
|
|
951
|
+
await self._transport.publish("del_all_plan", {})
|
|
952
|
+
|
|
953
|
+
async def pause_planning(self) -> None:
|
|
954
|
+
"""Pause the currently running plan (direct command, no response)."""
|
|
955
|
+
await self._ensure_controller()
|
|
956
|
+
await self._transport.publish("planning_paused", {})
|
|
957
|
+
|
|
958
|
+
async def in_plan_action(self, action: str) -> None:
|
|
959
|
+
"""
|
|
960
|
+
Send an in-plan action command.
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
action: Action string (e.g. ``"pause"``, ``"resume"``, ``"stop"``).
|
|
964
|
+
"""
|
|
965
|
+
await self._ensure_controller()
|
|
966
|
+
await self._transport.publish("in_plan_action", {"action": action})
|
|
967
|
+
|
|
968
|
+
async def read_schedules(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
969
|
+
"""
|
|
970
|
+
Request all schedules and await the data_feedback response.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
974
|
+
|
|
975
|
+
Returns:
|
|
976
|
+
Response payload dict, or empty dict on timeout.
|
|
977
|
+
"""
|
|
978
|
+
return await self._request_data_feedback("read_schedules", {}, timeout)
|
|
979
|
+
|
|
980
|
+
# ------------------------------------------------------------------
|
|
981
|
+
# Navigation & maps
|
|
982
|
+
# ------------------------------------------------------------------
|
|
983
|
+
|
|
984
|
+
async def start_waypoint(self, index: int) -> None:
|
|
985
|
+
"""
|
|
986
|
+
Start navigation to a waypoint by index.
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
index: Zero-based waypoint index.
|
|
990
|
+
"""
|
|
991
|
+
await self._ensure_controller()
|
|
992
|
+
await self._transport.publish("start_way_point", {"index": index})
|
|
993
|
+
|
|
994
|
+
async def read_recharge_point(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
995
|
+
"""
|
|
996
|
+
Request the saved recharge/dock point and await the data_feedback response.
|
|
997
|
+
|
|
998
|
+
Args:
|
|
999
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
Response payload dict, or empty dict on timeout.
|
|
1003
|
+
"""
|
|
1004
|
+
return await self._request_data_feedback("read_recharge_point", {}, timeout)
|
|
1005
|
+
|
|
1006
|
+
async def save_charging_point(self) -> None:
|
|
1007
|
+
"""Save the robot's current position as the charging/dock point."""
|
|
1008
|
+
await self._ensure_controller()
|
|
1009
|
+
await self._transport.publish("save_charging_point", {})
|
|
1010
|
+
|
|
1011
|
+
async def read_clean_area(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1012
|
+
"""
|
|
1013
|
+
Request the clean area definition and await the data_feedback response.
|
|
1014
|
+
|
|
1015
|
+
Args:
|
|
1016
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1017
|
+
|
|
1018
|
+
Returns:
|
|
1019
|
+
Response payload dict, or empty dict on timeout.
|
|
1020
|
+
"""
|
|
1021
|
+
return await self._request_data_feedback("read_clean_area", {}, timeout)
|
|
1022
|
+
|
|
1023
|
+
async def get_all_map_backup(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1024
|
+
"""
|
|
1025
|
+
Request all map backups and await the data_feedback response.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1029
|
+
|
|
1030
|
+
Returns:
|
|
1031
|
+
Response payload dict, or empty dict on timeout.
|
|
1032
|
+
"""
|
|
1033
|
+
return await self._request_data_feedback("get_all_map_backup", {}, timeout)
|
|
1034
|
+
|
|
1035
|
+
async def save_map_backup(self) -> None:
|
|
1036
|
+
"""Save a backup of the current map."""
|
|
1037
|
+
await self._ensure_controller()
|
|
1038
|
+
await self._transport.publish("save_map_backup", {})
|
|
1039
|
+
|
|
1040
|
+
# ------------------------------------------------------------------
|
|
1041
|
+
# WiFi & connectivity
|
|
1042
|
+
# ------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
async def get_wifi_list(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1045
|
+
"""
|
|
1046
|
+
Request the list of available WiFi networks and await the data_feedback response.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1050
|
+
|
|
1051
|
+
Returns:
|
|
1052
|
+
Response payload dict, or empty dict on timeout.
|
|
1053
|
+
"""
|
|
1054
|
+
return await self._request_data_feedback("get_wifi_list", {}, timeout)
|
|
1055
|
+
|
|
1056
|
+
async def get_connected_wifi(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1057
|
+
"""
|
|
1058
|
+
Request the currently connected WiFi network name and await the data_feedback response.
|
|
1059
|
+
|
|
1060
|
+
Args:
|
|
1061
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1062
|
+
|
|
1063
|
+
Returns:
|
|
1064
|
+
Response payload dict, or empty dict on timeout.
|
|
1065
|
+
"""
|
|
1066
|
+
return await self._request_data_feedback("get_connect_wifi_name", {}, timeout)
|
|
1067
|
+
|
|
1068
|
+
async def start_hotspot(self) -> None:
|
|
1069
|
+
"""Start the robot's WiFi hotspot."""
|
|
1070
|
+
await self._ensure_controller()
|
|
1071
|
+
await self._transport.publish("start_hotspot", {})
|
|
1072
|
+
|
|
1073
|
+
async def get_hub_info(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1074
|
+
"""
|
|
1075
|
+
Request hub information and await the data_feedback response.
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1079
|
+
|
|
1080
|
+
Returns:
|
|
1081
|
+
Response payload dict, or empty dict on timeout.
|
|
1082
|
+
"""
|
|
1083
|
+
return await self._request_data_feedback("hub_info", {}, timeout)
|
|
1084
|
+
|
|
1085
|
+
# ------------------------------------------------------------------
|
|
1086
|
+
# Diagnostics (read-only telemetry requests)
|
|
1087
|
+
# ------------------------------------------------------------------
|
|
1088
|
+
|
|
1089
|
+
async def read_no_charge_period(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1090
|
+
"""
|
|
1091
|
+
Request no-charge period configuration and await the data_feedback response.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1095
|
+
|
|
1096
|
+
Returns:
|
|
1097
|
+
Response payload dict, or empty dict on timeout.
|
|
1098
|
+
"""
|
|
1099
|
+
return await self._request_data_feedback("read_no_charge_period", {}, timeout)
|
|
1100
|
+
|
|
1101
|
+
async def get_battery_cell_temps(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1102
|
+
"""
|
|
1103
|
+
Request battery cell temperature data and await the data_feedback response.
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1107
|
+
|
|
1108
|
+
Returns:
|
|
1109
|
+
Response payload dict, or empty dict on timeout.
|
|
1110
|
+
"""
|
|
1111
|
+
return await self._request_data_feedback("battery_cell_temp_msg", {}, timeout)
|
|
1112
|
+
|
|
1113
|
+
async def get_motor_temps(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1114
|
+
"""
|
|
1115
|
+
Request motor temperature data and await the data_feedback response.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
Response payload dict, or empty dict on timeout.
|
|
1122
|
+
"""
|
|
1123
|
+
return await self._request_data_feedback("motor_temp_samp", {}, timeout)
|
|
1124
|
+
|
|
1125
|
+
async def get_body_current(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1126
|
+
"""
|
|
1127
|
+
Request body current telemetry and await the data_feedback response.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1131
|
+
|
|
1132
|
+
Returns:
|
|
1133
|
+
Response payload dict, or empty dict on timeout.
|
|
1134
|
+
"""
|
|
1135
|
+
return await self._request_data_feedback("body_current_msg", {}, timeout)
|
|
1136
|
+
|
|
1137
|
+
async def get_head_current(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1138
|
+
"""
|
|
1139
|
+
Request head current telemetry and await the data_feedback response.
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
Response payload dict, or empty dict on timeout.
|
|
1146
|
+
"""
|
|
1147
|
+
return await self._request_data_feedback("head_current_msg", {}, timeout)
|
|
1148
|
+
|
|
1149
|
+
async def get_speed(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1150
|
+
"""
|
|
1151
|
+
Request current speed telemetry and await the data_feedback response.
|
|
1152
|
+
|
|
1153
|
+
Args:
|
|
1154
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1155
|
+
|
|
1156
|
+
Returns:
|
|
1157
|
+
Response payload dict, or empty dict on timeout.
|
|
1158
|
+
"""
|
|
1159
|
+
return await self._request_data_feedback("speed_msg", {}, timeout)
|
|
1160
|
+
|
|
1161
|
+
async def get_odometer(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1162
|
+
"""
|
|
1163
|
+
Request odometer data and await the data_feedback response.
|
|
1164
|
+
|
|
1165
|
+
Args:
|
|
1166
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1167
|
+
|
|
1168
|
+
Returns:
|
|
1169
|
+
Response payload dict, or empty dict on timeout.
|
|
1170
|
+
"""
|
|
1171
|
+
return await self._request_data_feedback("odometer_msg", {}, timeout)
|
|
1172
|
+
|
|
1173
|
+
async def get_product_code(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1174
|
+
"""
|
|
1175
|
+
Request the product code and await the data_feedback response.
|
|
1176
|
+
|
|
1177
|
+
Args:
|
|
1178
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1179
|
+
|
|
1180
|
+
Returns:
|
|
1181
|
+
Response payload dict, or empty dict on timeout.
|
|
1182
|
+
"""
|
|
1183
|
+
return await self._request_data_feedback("product_code_msg", {}, timeout)
|
|
1184
|
+
|
|
1185
|
+
# ------------------------------------------------------------------
|
|
1186
|
+
# Data feedback helper
|
|
1187
|
+
# ------------------------------------------------------------------
|
|
1188
|
+
|
|
1189
|
+
async def _request_data_feedback(
|
|
1190
|
+
self, cmd: str, payload: dict[str, Any], timeout: float = 5.0
|
|
1191
|
+
) -> dict[str, Any]:
|
|
1192
|
+
"""
|
|
1193
|
+
Send a command and wait for the matching ``data_feedback`` response.
|
|
1194
|
+
|
|
1195
|
+
Pre-registers a receive queue before publishing to eliminate any
|
|
1196
|
+
publish/subscribe race condition.
|
|
1197
|
+
|
|
1198
|
+
Args:
|
|
1199
|
+
cmd: Topic leaf name of the command to send.
|
|
1200
|
+
payload: Payload dict to publish.
|
|
1201
|
+
timeout: Seconds to wait for the response.
|
|
1202
|
+
|
|
1203
|
+
Returns:
|
|
1204
|
+
Decoded response payload dict, or empty dict on timeout.
|
|
1205
|
+
"""
|
|
1206
|
+
await self._ensure_controller()
|
|
1207
|
+
wait_queue = self._transport.create_wait_queue()
|
|
1208
|
+
try:
|
|
1209
|
+
await self._transport.publish(cmd, payload)
|
|
1210
|
+
msg = await self._transport.wait_for_message(
|
|
1211
|
+
timeout=timeout,
|
|
1212
|
+
feedback_leaf=TOPIC_LEAF_DATA_FEEDBACK,
|
|
1213
|
+
command_name=cmd,
|
|
1214
|
+
_queue=wait_queue,
|
|
1215
|
+
)
|
|
1216
|
+
return msg if isinstance(msg, dict) else {}
|
|
1217
|
+
except Exception:
|
|
1218
|
+
with contextlib.suppress(ValueError):
|
|
1219
|
+
self._transport._message_queues.remove(wait_queue)
|
|
1220
|
+
raise
|
|
1221
|
+
|
|
1222
|
+
# ------------------------------------------------------------------
|
|
1223
|
+
# Raw publish (escape hatch)
|
|
1224
|
+
# ------------------------------------------------------------------
|
|
1225
|
+
|
|
1226
|
+
async def publish_raw(self, cmd: str, payload: dict[str, Any]) -> None:
|
|
1227
|
+
"""
|
|
1228
|
+
Publish an arbitrary command to the robot.
|
|
1229
|
+
|
|
1230
|
+
Useful for commands not yet wrapped in a dedicated method.
|
|
1231
|
+
|
|
1232
|
+
Args:
|
|
1233
|
+
cmd: Topic leaf (e.g. ``"start_plan"``).
|
|
1234
|
+
payload: Dict payload (will be zlib-encoded).
|
|
1235
|
+
"""
|
|
1236
|
+
await self._ensure_controller()
|
|
1237
|
+
await self._transport.publish(cmd, payload)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
async def publish_command(self, cmd: str, payload: dict[str, Any]) -> None:
|
|
1241
|
+
"""Alias for :meth:`publish_raw` — publish an arbitrary command to the robot.
|
|
1242
|
+
|
|
1243
|
+
Args:
|
|
1244
|
+
cmd: Topic leaf (e.g. ``"set_blade_height"``).
|
|
1245
|
+
payload: Dict payload (will be zlib-encoded).
|
|
1246
|
+
"""
|
|
1247
|
+
await self.publish_raw(cmd, payload)
|
|
1248
|
+
|
|
1249
|
+
# ------------------------------------------------------------------
|
|
1250
|
+
# Blade / mowing configuration
|
|
1251
|
+
# ------------------------------------------------------------------
|
|
1252
|
+
|
|
1253
|
+
async def set_blade_height(self, height: int) -> None:
|
|
1254
|
+
"""Set the blade cutting height.
|
|
1255
|
+
|
|
1256
|
+
Args:
|
|
1257
|
+
height: Blade height value (robot-defined units).
|
|
1258
|
+
"""
|
|
1259
|
+
await self._ensure_controller()
|
|
1260
|
+
await self._transport.publish("set_blade_height", {"height": height})
|
|
1261
|
+
|
|
1262
|
+
async def set_blade_speed(self, speed: int) -> None:
|
|
1263
|
+
"""Set the blade rotation speed.
|
|
1264
|
+
|
|
1265
|
+
Args:
|
|
1266
|
+
speed: Blade speed value (robot-defined units).
|
|
1267
|
+
"""
|
|
1268
|
+
await self._ensure_controller()
|
|
1269
|
+
await self._transport.publish("set_blade_speed", {"speed": speed})
|
|
1270
|
+
|
|
1271
|
+
async def set_charge_limit(self, min_pct: int, max_pct: int) -> None:
|
|
1272
|
+
"""Set battery charge limits.
|
|
1273
|
+
|
|
1274
|
+
Args:
|
|
1275
|
+
min_pct: Minimum charge percentage before robot returns to dock.
|
|
1276
|
+
max_pct: Maximum charge percentage (charge stops here).
|
|
1277
|
+
"""
|
|
1278
|
+
await self._ensure_controller()
|
|
1279
|
+
await self._transport.publish("set_charge_limit", {"min": min_pct, "max": max_pct})
|
|
1280
|
+
|
|
1281
|
+
async def set_turn_type(self, turn_type: int) -> None:
|
|
1282
|
+
"""Set the turning behaviour type.
|
|
1283
|
+
|
|
1284
|
+
Args:
|
|
1285
|
+
turn_type: Integer representing the turn mode (robot-defined).
|
|
1286
|
+
"""
|
|
1287
|
+
await self._ensure_controller()
|
|
1288
|
+
await self._transport.publish("set_turn_type", {"turnType": turn_type})
|
|
1289
|
+
|
|
1290
|
+
# ------------------------------------------------------------------
|
|
1291
|
+
# Snow blower accessories
|
|
1292
|
+
# ------------------------------------------------------------------
|
|
1293
|
+
|
|
1294
|
+
async def push_snow_dir(self, direction: int) -> None:
|
|
1295
|
+
"""Set the snow push direction.
|
|
1296
|
+
|
|
1297
|
+
Args:
|
|
1298
|
+
direction: Direction integer (robot-defined).
|
|
1299
|
+
"""
|
|
1300
|
+
await self._ensure_controller()
|
|
1301
|
+
await self._transport.publish("push_snow_dir", {"direction": direction})
|
|
1302
|
+
|
|
1303
|
+
async def set_chute_steering_work(self, angle: int) -> None:
|
|
1304
|
+
"""Set the chute steering angle during work.
|
|
1305
|
+
|
|
1306
|
+
Args:
|
|
1307
|
+
angle: Steering angle in degrees.
|
|
1308
|
+
"""
|
|
1309
|
+
await self._ensure_controller()
|
|
1310
|
+
await self._transport.publish("cmd_chute_streeing_work", {"angle": angle})
|
|
1311
|
+
|
|
1312
|
+
async def set_roller_speed(self, speed: int) -> None:
|
|
1313
|
+
"""Set the roller/blower speed.
|
|
1314
|
+
|
|
1315
|
+
Args:
|
|
1316
|
+
speed: Speed value (robot-defined units).
|
|
1317
|
+
"""
|
|
1318
|
+
await self._ensure_controller()
|
|
1319
|
+
await self._transport.publish("cmd_roller", {"speed": speed})
|
|
1320
|
+
|
|
1321
|
+
# ------------------------------------------------------------------
|
|
1322
|
+
# Motor & mechanical
|
|
1323
|
+
# ------------------------------------------------------------------
|
|
1324
|
+
|
|
1325
|
+
async def set_motor_protect(self, state: int) -> None:
|
|
1326
|
+
"""Enable or disable motor protection mode.
|
|
1327
|
+
|
|
1328
|
+
Args:
|
|
1329
|
+
state: 1 to enable, 0 to disable.
|
|
1330
|
+
"""
|
|
1331
|
+
await self._ensure_controller()
|
|
1332
|
+
await self._transport.publish("cmd_motor_protect", {"state": state})
|
|
1333
|
+
|
|
1334
|
+
async def set_trimmer(self, state: int) -> None:
|
|
1335
|
+
"""Enable or disable the trimmer.
|
|
1336
|
+
|
|
1337
|
+
Args:
|
|
1338
|
+
state: 1 to enable, 0 to disable.
|
|
1339
|
+
"""
|
|
1340
|
+
await self._ensure_controller()
|
|
1341
|
+
await self._transport.publish("cmd_trimmer", {"state": state})
|
|
1342
|
+
|
|
1343
|
+
# ------------------------------------------------------------------
|
|
1344
|
+
# Blowing / edge / smart features
|
|
1345
|
+
# ------------------------------------------------------------------
|
|
1346
|
+
|
|
1347
|
+
async def set_edge_blowing(self, state: int) -> None:
|
|
1348
|
+
"""Enable or disable edge blowing.
|
|
1349
|
+
|
|
1350
|
+
Args:
|
|
1351
|
+
state: 1 to enable, 0 to disable.
|
|
1352
|
+
"""
|
|
1353
|
+
await self._ensure_controller()
|
|
1354
|
+
await self._transport.publish("edge_blowing", {"state": state})
|
|
1355
|
+
|
|
1356
|
+
async def set_smart_blowing(self, state: int) -> None:
|
|
1357
|
+
"""Enable or disable smart blowing.
|
|
1358
|
+
|
|
1359
|
+
Args:
|
|
1360
|
+
state: 1 to enable, 0 to disable.
|
|
1361
|
+
"""
|
|
1362
|
+
await self._ensure_controller()
|
|
1363
|
+
await self._transport.publish("smart_blowing", {"state": state})
|
|
1364
|
+
|
|
1365
|
+
async def set_heating_film(self, state: int) -> None:
|
|
1366
|
+
"""Enable or disable heating film (anti-ice).
|
|
1367
|
+
|
|
1368
|
+
Args:
|
|
1369
|
+
state: 1 to enable, 0 to disable.
|
|
1370
|
+
"""
|
|
1371
|
+
await self._ensure_controller()
|
|
1372
|
+
await self._transport.publish("heating_film_ctrl", {"state": state})
|
|
1373
|
+
|
|
1374
|
+
async def set_module_lock(self, state: int) -> None:
|
|
1375
|
+
"""Lock or unlock an accessory module.
|
|
1376
|
+
|
|
1377
|
+
Args:
|
|
1378
|
+
state: 1 to lock, 0 to unlock.
|
|
1379
|
+
"""
|
|
1380
|
+
await self._ensure_controller()
|
|
1381
|
+
await self._transport.publish("module_lock_ctl", {"state": state})
|
|
1382
|
+
|
|
1383
|
+
# ------------------------------------------------------------------
|
|
1384
|
+
# Autonomous modes
|
|
1385
|
+
# ------------------------------------------------------------------
|
|
1386
|
+
|
|
1387
|
+
async def set_follow_mode(self, state: int) -> None:
|
|
1388
|
+
"""Enable or disable follow mode.
|
|
1389
|
+
|
|
1390
|
+
Args:
|
|
1391
|
+
state: 1 to enable, 0 to disable.
|
|
1392
|
+
"""
|
|
1393
|
+
await self._ensure_controller()
|
|
1394
|
+
await self._transport.publish("set_follow_state", {"state": state})
|
|
1395
|
+
|
|
1396
|
+
async def set_draw_mode(self, state: int) -> None:
|
|
1397
|
+
"""Enable or disable draw/mapping mode.
|
|
1398
|
+
|
|
1399
|
+
Args:
|
|
1400
|
+
state: 1 to enable, 0 to disable.
|
|
1401
|
+
"""
|
|
1402
|
+
await self._ensure_controller()
|
|
1403
|
+
await self._transport.publish("start_draw_cmd", {"state": state})
|
|
1404
|
+
|
|
1405
|
+
# ------------------------------------------------------------------
|
|
1406
|
+
# OTA / firmware updates
|
|
1407
|
+
# ------------------------------------------------------------------
|
|
1408
|
+
|
|
1409
|
+
async def set_auto_update(self, state: int) -> None:
|
|
1410
|
+
"""Enable or disable automatic firmware (Greengrass) updates.
|
|
1411
|
+
|
|
1412
|
+
Args:
|
|
1413
|
+
state: 1 to enable, 0 to disable.
|
|
1414
|
+
"""
|
|
1415
|
+
await self._ensure_controller()
|
|
1416
|
+
await self._transport.publish("set_greengrass_auto_update_switch", {"state": state})
|
|
1417
|
+
|
|
1418
|
+
async def set_camera_ota(self, state: int) -> None:
|
|
1419
|
+
"""Enable or disable IP camera OTA updates.
|
|
1420
|
+
|
|
1421
|
+
Args:
|
|
1422
|
+
state: 1 to enable, 0 to disable.
|
|
1423
|
+
"""
|
|
1424
|
+
await self._ensure_controller()
|
|
1425
|
+
await self._transport.publish("set_ipcamera_ota_switch", {"state": state})
|
|
1426
|
+
|
|
1427
|
+
# ------------------------------------------------------------------
|
|
1428
|
+
# Vision / recording
|
|
1429
|
+
# ------------------------------------------------------------------
|
|
1430
|
+
|
|
1431
|
+
async def set_smart_vision(self, state: int) -> None:
|
|
1432
|
+
"""Enable or disable smart vision processing.
|
|
1433
|
+
|
|
1434
|
+
Args:
|
|
1435
|
+
state: 1 to enable, 0 to disable.
|
|
1436
|
+
"""
|
|
1437
|
+
await self._ensure_controller()
|
|
1438
|
+
await self._transport.publish("smart_vision_control", {"state": state})
|
|
1439
|
+
|
|
1440
|
+
async def set_video_record(self, state: int) -> None:
|
|
1441
|
+
"""Enable or disable video recording.
|
|
1442
|
+
|
|
1443
|
+
Args:
|
|
1444
|
+
state: 1 to enable, 0 to disable.
|
|
1445
|
+
"""
|
|
1446
|
+
await self._ensure_controller()
|
|
1447
|
+
await self._transport.publish("enable_video_record", {"state": state})
|
|
1448
|
+
|
|
1449
|
+
# ------------------------------------------------------------------
|
|
1450
|
+
# Safety / fencing
|
|
1451
|
+
# ------------------------------------------------------------------
|
|
1452
|
+
|
|
1453
|
+
async def set_child_lock(self, state: int) -> None:
|
|
1454
|
+
"""Enable or disable the child lock.
|
|
1455
|
+
|
|
1456
|
+
Args:
|
|
1457
|
+
state: 1 to enable, 0 to disable.
|
|
1458
|
+
"""
|
|
1459
|
+
await self._ensure_controller()
|
|
1460
|
+
await self._transport.publish("child_lock", {"state": state})
|
|
1461
|
+
|
|
1462
|
+
async def set_geo_fence(self, state: int) -> None:
|
|
1463
|
+
"""Enable or disable geo-fencing.
|
|
1464
|
+
|
|
1465
|
+
Args:
|
|
1466
|
+
state: 1 to enable, 0 to disable.
|
|
1467
|
+
"""
|
|
1468
|
+
await self._ensure_controller()
|
|
1469
|
+
await self._transport.publish("enable_geo_fence", {"state": state})
|
|
1470
|
+
|
|
1471
|
+
async def set_elec_fence(self, state: int) -> None:
|
|
1472
|
+
"""Enable or disable the electric fence.
|
|
1473
|
+
|
|
1474
|
+
Args:
|
|
1475
|
+
state: 1 to enable, 0 to disable.
|
|
1476
|
+
"""
|
|
1477
|
+
await self._ensure_controller()
|
|
1478
|
+
await self._transport.publish("enable_elec_fence", {"state": state})
|
|
1479
|
+
|
|
1480
|
+
async def set_ngz_edge(self, state: int) -> None:
|
|
1481
|
+
"""Enable or disable NGZ (no-go-zone) edge enforcement.
|
|
1482
|
+
|
|
1483
|
+
Args:
|
|
1484
|
+
state: 1 to enable, 0 to disable.
|
|
1485
|
+
"""
|
|
1486
|
+
await self._ensure_controller()
|
|
1487
|
+
await self._transport.publish("ngz_edge", {"state": state})
|
|
1488
|
+
|
|
1489
|
+
# ------------------------------------------------------------------
|
|
1490
|
+
# Manual drive extras
|
|
1491
|
+
# ------------------------------------------------------------------
|
|
1492
|
+
|
|
1493
|
+
async def set_velocity_manual(self, linear: float, angular: float) -> None:
|
|
1494
|
+
"""Send a velocity command in manual drive mode.
|
|
1495
|
+
|
|
1496
|
+
Args:
|
|
1497
|
+
linear: Linear velocity (forward positive).
|
|
1498
|
+
angular: Angular velocity (counter-clockwise positive).
|
|
1499
|
+
"""
|
|
1500
|
+
await self._ensure_controller()
|
|
1501
|
+
await self._transport.publish("cmd_vel", {"vel": linear, "rev": angular})
|
|
1502
|
+
|
|
1503
|
+
async def set_sound_param(self, volume: int, enabled: int) -> None:
|
|
1504
|
+
"""Set sound volume and enable/disable audio output.
|
|
1505
|
+
|
|
1506
|
+
Args:
|
|
1507
|
+
volume: Volume level (0-100).
|
|
1508
|
+
enabled: 1 to enable audio, 0 to disable.
|
|
1509
|
+
"""
|
|
1510
|
+
await self._ensure_controller()
|
|
1511
|
+
await self._transport.publish("set_sound_param", {"volume": volume, "enable": enabled})
|
|
1512
|
+
|
|
1513
|
+
# ------------------------------------------------------------------
|
|
1514
|
+
# Map management (destructive)
|
|
1515
|
+
# ------------------------------------------------------------------
|
|
1516
|
+
|
|
1517
|
+
async def erase_map(self, confirm: bool = False) -> None:
|
|
1518
|
+
"""Erase the robot's stored map.
|
|
1519
|
+
|
|
1520
|
+
.. warning::
|
|
1521
|
+
This is a **destructive** operation. The map cannot be recovered
|
|
1522
|
+
after erasure. You must pass ``confirm=True`` to proceed.
|
|
1523
|
+
|
|
1524
|
+
Args:
|
|
1525
|
+
confirm: Must be ``True`` to proceed.
|
|
1526
|
+
|
|
1527
|
+
Raises:
|
|
1528
|
+
ValueError: If *confirm* is not ``True``.
|
|
1529
|
+
"""
|
|
1530
|
+
if not confirm:
|
|
1531
|
+
raise ValueError("erase_map is destructive — pass confirm=True to proceed.")
|
|
1532
|
+
await self._ensure_controller()
|
|
1533
|
+
await self._transport.publish("erase_map", {})
|
|
1534
|
+
|
|
1535
|
+
async def map_recovery(self, map_id: str, confirm: bool = False) -> None:
|
|
1536
|
+
"""Restore a map from a backup by ID.
|
|
1537
|
+
|
|
1538
|
+
.. warning::
|
|
1539
|
+
This is a **destructive** operation — it overwrites the current map.
|
|
1540
|
+
You must pass ``confirm=True`` to proceed.
|
|
1541
|
+
|
|
1542
|
+
Args:
|
|
1543
|
+
map_id: ID of the map backup to restore.
|
|
1544
|
+
confirm: Must be ``True`` to proceed.
|
|
1545
|
+
|
|
1546
|
+
Raises:
|
|
1547
|
+
ValueError: If *confirm* is not ``True``.
|
|
1548
|
+
"""
|
|
1549
|
+
if not confirm:
|
|
1550
|
+
raise ValueError("map_recovery is destructive — pass confirm=True to proceed.")
|
|
1551
|
+
await self._ensure_controller()
|
|
1552
|
+
await self._transport.publish("map_recovery", {"mapId": map_id})
|
|
1553
|
+
|
|
1554
|
+
async def save_current_map(self) -> None:
|
|
1555
|
+
"""Save the robot's current map state."""
|
|
1556
|
+
await self._ensure_controller()
|
|
1557
|
+
await self._transport.publish("save_current_map", {})
|
|
1558
|
+
|
|
1559
|
+
async def save_map_backup_list(self, timeout: float = 5.0) -> dict[str, Any]:
|
|
1560
|
+
"""Save map backup and retrieve all map backup names and IDs.
|
|
1561
|
+
|
|
1562
|
+
Args:
|
|
1563
|
+
timeout: Seconds to wait for the response (default 5.0).
|
|
1564
|
+
|
|
1565
|
+
Returns:
|
|
1566
|
+
Response payload dict, or empty dict on timeout.
|
|
1567
|
+
"""
|
|
1568
|
+
return await self._request_data_feedback(
|
|
1569
|
+
"save_map_backup_and_get_all_map_backup_nameandid", {}, timeout
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
# ------------------------------------------------------------------
|
|
1573
|
+
# Sync wrapper
|
|
1574
|
+
# ------------------------------------------------------------------
|
|
1575
|
+
|
|
1576
|
+
@classmethod
|
|
1577
|
+
def connect_sync(
|
|
1578
|
+
cls,
|
|
1579
|
+
broker: str = LOCAL_BROKER_DEFAULT,
|
|
1580
|
+
sn: str = "",
|
|
1581
|
+
port: int = LOCAL_PORT,
|
|
1582
|
+
) -> _SyncYarboLocalClient:
|
|
1583
|
+
"""
|
|
1584
|
+
Create a synchronous wrapper around ``YarboLocalClient``.
|
|
1585
|
+
|
|
1586
|
+
Useful for scripts and REPL sessions that don't use asyncio.
|
|
1587
|
+
|
|
1588
|
+
Example::
|
|
1589
|
+
|
|
1590
|
+
client = YarboLocalClient.connect_sync(broker="192.168.1.24", sn="24400102...")
|
|
1591
|
+
client.lights_on()
|
|
1592
|
+
client.buzzer()
|
|
1593
|
+
client.disconnect()
|
|
1594
|
+
"""
|
|
1595
|
+
return _SyncYarboLocalClient(broker=broker, sn=sn, port=port)
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
class _SyncYarboLocalClient:
|
|
1599
|
+
"""Synchronous wrapper around :class:`YarboLocalClient`."""
|
|
1600
|
+
|
|
1601
|
+
def __init__(self, broker: str, sn: str, port: int) -> None:
|
|
1602
|
+
self._loop = asyncio.new_event_loop()
|
|
1603
|
+
self._client = YarboLocalClient(broker=broker, sn=sn, port=port)
|
|
1604
|
+
self._loop.run_until_complete(self._client.connect())
|
|
1605
|
+
|
|
1606
|
+
def _run(self, coro: Any) -> Any:
|
|
1607
|
+
return self._loop.run_until_complete(coro)
|
|
1608
|
+
|
|
1609
|
+
def lights_on(self) -> None:
|
|
1610
|
+
"""Turn all lights on at full brightness."""
|
|
1611
|
+
self._run(self._client.lights_on())
|
|
1612
|
+
|
|
1613
|
+
def lights_off(self) -> None:
|
|
1614
|
+
"""Turn all lights off."""
|
|
1615
|
+
self._run(self._client.lights_off())
|
|
1616
|
+
|
|
1617
|
+
def buzzer(self, state: int = 1) -> None:
|
|
1618
|
+
"""Trigger buzzer (state=1 play, state=0 stop)."""
|
|
1619
|
+
self._run(self._client.buzzer(state=state))
|
|
1620
|
+
|
|
1621
|
+
def set_chute(self, vel: int) -> None:
|
|
1622
|
+
"""Set chute direction/velocity."""
|
|
1623
|
+
self._run(self._client.set_chute(vel=vel))
|
|
1624
|
+
|
|
1625
|
+
def get_status(self) -> YarboTelemetry | None:
|
|
1626
|
+
"""Fetch a telemetry snapshot."""
|
|
1627
|
+
return cast("YarboTelemetry | None", self._run(self._client.get_status()))
|
|
1628
|
+
|
|
1629
|
+
def publish_raw(self, cmd: str, payload: dict[str, Any]) -> None:
|
|
1630
|
+
"""Publish an arbitrary command."""
|
|
1631
|
+
self._run(self._client.publish_raw(cmd, payload))
|
|
1632
|
+
|
|
1633
|
+
def disconnect(self) -> None:
|
|
1634
|
+
"""Disconnect from the broker."""
|
|
1635
|
+
self._run(self._client.disconnect())
|
|
1636
|
+
self._loop.close()
|