dosync 0.4.1__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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — MAVLink Adapter (command channel)
|
|
3
|
+
==========================================
|
|
4
|
+
Drives a MAVLink vehicle (drone/rover/boat running ArduPilot or PX4) by
|
|
5
|
+
translating a DoSync action into the corresponding MAVLink command and waiting
|
|
6
|
+
for the vehicle's COMMAND_ACK. This is the "dumb body, external mind" principle
|
|
7
|
+
at its purest: the aircraft already knows how to fly — its firmware holds the
|
|
8
|
+
failsafe and the flight controller. DoSync does not fly it; DoSync *coordinates*
|
|
9
|
+
it, expressing intent ("go to this point", "come home") and letting the vehicle
|
|
10
|
+
execute with its own safety systems intact.
|
|
11
|
+
|
|
12
|
+
SCOPE — this module is the COMMAND CHANNEL only (Step 1):
|
|
13
|
+
A command is a point-in-time event: DoSync sends "go_to", the vehicle replies
|
|
14
|
+
COMMAND_ACK: ACCEPTED almost immediately. That ACK means "I received and
|
|
15
|
+
accepted the order" — NOT "I arrived". execute() returns that ACK as an
|
|
16
|
+
ActionResult, which the execution_model records as a dispatch acceptance
|
|
17
|
+
(operation -> in_progress), never as completion. "Dispatch OK != navigating."
|
|
18
|
+
|
|
19
|
+
The TELEMETRY CHANNEL — the continuous stream that reports arming, takeoff,
|
|
20
|
+
arrival, and (critically) a human taking manual control — is a SEPARATE
|
|
21
|
+
component with its own lifecycle (a background listener), built in Step 2. It
|
|
22
|
+
is what makes "silence != success" real: only positive telemetry advances an
|
|
23
|
+
operation toward completed. This file deliberately does not implement it.
|
|
24
|
+
|
|
25
|
+
SAFETY POSTURE (established by the expert panel, incl. a drone manufacturer and a
|
|
26
|
+
pilot):
|
|
27
|
+
- The failsafe lives in the VEHICLE FIRMWARE, never in DoSync. Network/HTTP can
|
|
28
|
+
fail exactly when needed; the drone must protect itself without us.
|
|
29
|
+
- Operator override is by HARDWARE/RC and always wins. The pilot moves the
|
|
30
|
+
stick; they do not wait for DoSync to "cede". DoSync only learns (via Step 2
|
|
31
|
+
telemetry) that control was taken, and reports it.
|
|
32
|
+
- DoSync coordinates; it is NOT the failsafe.
|
|
33
|
+
|
|
34
|
+
DEPENDENCY — pymavlink is OPTIONAL and imported lazily. A hub that controls no
|
|
35
|
+
MAVLink vehicle never installs it. Absent the library, the adapter degrades to
|
|
36
|
+
SIMULATED mode: it logs the command it WOULD send and returns success, so the
|
|
37
|
+
manifest, registration, and the rest of the protocol work unchanged. Same posture
|
|
38
|
+
as the BLE adapter with bleak. The open protocol never forces a drone library on
|
|
39
|
+
a deployment that only has light bulbs.
|
|
40
|
+
|
|
41
|
+
Manifest adapter_config schema (per vehicle):
|
|
42
|
+
{
|
|
43
|
+
"connection": "udp:127.0.0.1:14550", # MAVLink endpoint (SITL or radio)
|
|
44
|
+
"default_alt": 10.0 # default takeoff altitude (m), optional
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
import logging
|
|
50
|
+
import threading
|
|
51
|
+
import queue
|
|
52
|
+
import time
|
|
53
|
+
import asyncio
|
|
54
|
+
from typing import Optional
|
|
55
|
+
|
|
56
|
+
from ..models import ActionResult, DeviceAction, Urgency
|
|
57
|
+
from . import DoSyncAdapter
|
|
58
|
+
|
|
59
|
+
log = logging.getLogger("dosync.adapters.mavlink")
|
|
60
|
+
|
|
61
|
+
# pymavlink is imported lazily so the module imports (and the adapter registers /
|
|
62
|
+
# unit-tests) on a host without the drone stack. Absent it, we run simulated.
|
|
63
|
+
try:
|
|
64
|
+
from pymavlink import mavutil
|
|
65
|
+
_MAVLINK_AVAILABLE = True
|
|
66
|
+
except Exception: # pragma: no cover - depends on host
|
|
67
|
+
mavutil = None # type: ignore
|
|
68
|
+
_MAVLINK_AVAILABLE = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Action vocabulary ────────────────────────────────────────────────────────
|
|
72
|
+
# The five high-level actions DoSync expresses to an aerial vehicle. Each maps to
|
|
73
|
+
# a MAVLink command or mode change. These are INTENT-level verbs — the vehicle
|
|
74
|
+
# decides how to carry them out, the same way "ensure_safety" lets a bulb decide
|
|
75
|
+
# it should turn on. The aerial profile of the execution_model.
|
|
76
|
+
SUPPORTED_ACTIONS = ("take_off", "go_to", "land", "return_home", "loiter")
|
|
77
|
+
|
|
78
|
+
# How long to wait for the vehicle's COMMAND_ACK before treating the dispatch as
|
|
79
|
+
# failed. A command is point-in-time, so this is short — we are not waiting for
|
|
80
|
+
# the action to *finish*, only for the vehicle to *accept* the order.
|
|
81
|
+
_ACK_TIMEOUT_S = 5.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MAVLinkAdapter(DoSyncAdapter):
|
|
85
|
+
"""Command-channel adapter for a MAVLink vehicle.
|
|
86
|
+
|
|
87
|
+
One instance handles every device whose manifest declares adapter="mavlink".
|
|
88
|
+
The connection string and defaults come from the manifest's adapter_config,
|
|
89
|
+
read from the hub registry (same pattern as WiZAdapter / BLEAdapter).
|
|
90
|
+
|
|
91
|
+
The MAVLink connection is opened lazily on first use, so a hub that registers a
|
|
92
|
+
drone but never commands it pays no connection cost.
|
|
93
|
+
|
|
94
|
+
SINGLE CONNECTION, SINGLE READER — DESIGN:
|
|
95
|
+
This adapter uses ONE bidirectional connection per vehicle, exactly like a
|
|
96
|
+
real GCS over a serial radio. The telemetry listener OWNS that connection: it
|
|
97
|
+
opens it, waits for the heartbeat (which fixes target_system/target_component
|
|
98
|
+
to the real vehicle), and is the only reader (recv_match on its thread). The
|
|
99
|
+
command channel does NOT open its own socket — it fetches the listener's live
|
|
100
|
+
connection (get_connection) and WRITES on it (command_long_send, a thread-safe
|
|
101
|
+
sendto). COMMAND_ACKs arrive on the listener's read loop and are routed to the
|
|
102
|
+
waiting command via an ACK registry (record_ack / wait_for_ack).
|
|
103
|
+
|
|
104
|
+
Why one connection: a real drone over a serial link (SiK 915MHz, a USB cable)
|
|
105
|
+
is a single bidirectional channel — a serial port cannot be opened twice. A
|
|
106
|
+
separate, outbound-only command channel is blind: it cannot receive the
|
|
107
|
+
heartbeat (so target_system stays 0 and the vehicle ignores its commands),
|
|
108
|
+
cannot read its ACKs, and on UDP collides with the listener's bind. Sharing
|
|
109
|
+
the one connection the listener already owns removes that entire family of
|
|
110
|
+
failures and makes the same code work over SITL/UDP and real serial hardware
|
|
111
|
+
with no divergence. ("Implementado ≠ validado" — this is validated against
|
|
112
|
+
the real flight path, not a simulator-only shortcut.)
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, hub=None, ack_timeout: float = _ACK_TIMEOUT_S,
|
|
116
|
+
connect_timeout: float = 12.0):
|
|
117
|
+
"""
|
|
118
|
+
Args:
|
|
119
|
+
hub: reference to the DoSyncHub to read adapter_config from the
|
|
120
|
+
manifest. Optional — if absent, config must come in action.params.
|
|
121
|
+
ack_timeout: seconds to wait for COMMAND_ACK before failing the dispatch.
|
|
122
|
+
connect_timeout: seconds to wait for the listener to connect (receive the
|
|
123
|
+
first heartbeat, so target_system is valid) before commanding.
|
|
124
|
+
"""
|
|
125
|
+
self._hub = hub
|
|
126
|
+
self._ack_timeout = ack_timeout
|
|
127
|
+
self._connect_timeout = connect_timeout
|
|
128
|
+
# Cache of connection-string -> live mavutil connection. Keyed by endpoint
|
|
129
|
+
# so multiple vehicles on different endpoints each keep their own link.
|
|
130
|
+
self._connections: dict = {}
|
|
131
|
+
# ── Telemetry channel (Step 2b) ──────────────────────────────────────
|
|
132
|
+
# One listener thread per vehicle (producer) feeds a shared queue; a single
|
|
133
|
+
# consumer asyncio task (on the event-loop thread) drains it and calls
|
|
134
|
+
# hub.apply_telemetry. The queue is the thread-safe boundary; only the
|
|
135
|
+
# consumer touches the DB.
|
|
136
|
+
self._telemetry_queue: "queue.Queue" = queue.Queue()
|
|
137
|
+
self._listeners: dict = {} # device_id -> MAVLinkTelemetryListener
|
|
138
|
+
self._consumer_task: Optional[asyncio.Task] = None
|
|
139
|
+
self._consumer_running = False
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def adapter_name(self) -> str:
|
|
143
|
+
return "mavlink"
|
|
144
|
+
|
|
145
|
+
# ── Config resolution (same pattern as BLE/WiZ) ──────────────────────────
|
|
146
|
+
def _get_config(self, action: DeviceAction) -> dict:
|
|
147
|
+
"""Resolve adapter_config: action.params override, then manifest."""
|
|
148
|
+
cfg = action.params.get("adapter_config")
|
|
149
|
+
if cfg:
|
|
150
|
+
return cfg
|
|
151
|
+
if self._hub:
|
|
152
|
+
device = self._hub.registry.get(action.device_id)
|
|
153
|
+
if device and device.adapter_config:
|
|
154
|
+
return device.adapter_config
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
def _get_connection(self, conn_str: str):
|
|
158
|
+
"""LEGACY — no longer used by execute(). In the single-connection design the
|
|
159
|
+
command writes on the listener's connection (listener.get_connection()), it
|
|
160
|
+
does not open its own. Kept only so disconnect()'s cache sweep stays valid;
|
|
161
|
+
the cache is empty in the current flow. Opening a separate command socket is
|
|
162
|
+
exactly what produced the blind-command failures (bind conflict, missed ACK,
|
|
163
|
+
target_system=0)."""
|
|
164
|
+
conn = self._connections.get(conn_str)
|
|
165
|
+
if conn is None:
|
|
166
|
+
log.info("MAVLink: opening connection %s", conn_str)
|
|
167
|
+
conn = mavutil.mavlink_connection(conn_str)
|
|
168
|
+
conn.wait_heartbeat(timeout=10)
|
|
169
|
+
log.info("MAVLink: heartbeat from system %s component %s",
|
|
170
|
+
conn.target_system, conn.target_component)
|
|
171
|
+
self._connections[conn_str] = conn
|
|
172
|
+
return conn
|
|
173
|
+
|
|
174
|
+
# ── The command channel ───────────────────────────────────────────────────
|
|
175
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
176
|
+
"""Translate a DoSync action into a MAVLink command and return the ACK.
|
|
177
|
+
|
|
178
|
+
Returns success=True when the vehicle ACCEPTS the command (a dispatch
|
|
179
|
+
acceptance — the operation is now underway, NOT finished). Returns
|
|
180
|
+
success=False when the vehicle rejects it or no ACK arrives. The continuous
|
|
181
|
+
confirmation that the vehicle actually flew the command is the telemetry
|
|
182
|
+
channel's job (Step 2), not this method's.
|
|
183
|
+
"""
|
|
184
|
+
if action.action not in SUPPORTED_ACTIONS:
|
|
185
|
+
return ActionResult(
|
|
186
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
187
|
+
error=f"MAVLink adapter has no mapping for action '{action.action}'. "
|
|
188
|
+
f"Supported: {', '.join(SUPPORTED_ACTIONS)}.",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
cfg = self._get_config(action)
|
|
192
|
+
|
|
193
|
+
# SINGLE bidirectional connection (panel: single-connection, single-reader).
|
|
194
|
+
# The listener opens and owns ONE connection that both receives (heartbeat,
|
|
195
|
+
# telemetry, ACKs) and is written to (commands). It must be a binding/
|
|
196
|
+
# receiving form (udp:/udpin:/tcp:/serial:) — NOT udpout, which cannot
|
|
197
|
+
# receive the heartbeat that fixes target_system. A legacy 'udpout:' or a
|
|
198
|
+
# separate 'telemetry_connection' is normalized back to the receiving form.
|
|
199
|
+
conn_str = self._single_connection(cfg)
|
|
200
|
+
if not conn_str:
|
|
201
|
+
return ActionResult(
|
|
202
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
203
|
+
error="MAVLink manifest missing a usable 'connection' "
|
|
204
|
+
"(e.g. 'udp:127.0.0.1:14550').",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
default_alt = float(cfg.get("default_alt", 10.0))
|
|
208
|
+
params = action.params or {}
|
|
209
|
+
|
|
210
|
+
# Validate action params BEFORE touching any connection — there is no point
|
|
211
|
+
# connecting to the vehicle only to reject the command for a missing param.
|
|
212
|
+
if action.action == "go_to" and (params.get("lat") is None
|
|
213
|
+
or params.get("lon") is None):
|
|
214
|
+
return ActionResult(
|
|
215
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
216
|
+
error="go_to requires 'lat' and 'lon' params.",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# ── Simulated mode — pymavlink not installed on this host ─────────────
|
|
220
|
+
if not _MAVLINK_AVAILABLE:
|
|
221
|
+
log.info("[SIMULATED] MAVLink %s: %s %s (would send to %s)",
|
|
222
|
+
action.device_id, action.action, params, conn_str)
|
|
223
|
+
return ActionResult(
|
|
224
|
+
device_id=action.device_id, action=action.action, success=True,
|
|
225
|
+
response={"status": "simulated", "connection": conn_str,
|
|
226
|
+
"command": action.action, "params": params},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# ── Real command dispatch (single shared connection) ──────────────────
|
|
230
|
+
# SINGLE-CONNECTION design: the command channel does NOT open its own socket.
|
|
231
|
+
# The telemetry listener owns the one bidirectional connection — it received
|
|
232
|
+
# the heartbeat (so target_system/target_component are valid) and it is the
|
|
233
|
+
# only reader. The command writes on that same connection. This is exactly a
|
|
234
|
+
# real serial radio: one link, one reader, the command writes on it. It also
|
|
235
|
+
# eliminates the whole family of "blind command channel" failures — the bind
|
|
236
|
+
# conflict, the missed ACK, and target_system=0 — because the command no
|
|
237
|
+
# longer has a separate connection that can be blind.
|
|
238
|
+
#
|
|
239
|
+
# Start the listener (it opens the link, waits for the heartbeat, becomes the
|
|
240
|
+
# single reader). The telemetry endpoint binds and listens; the command will
|
|
241
|
+
# write on the very connection the listener opened.
|
|
242
|
+
if action.device_id not in self._listeners:
|
|
243
|
+
self.start_telemetry(action.device_id, conn_str)
|
|
244
|
+
|
|
245
|
+
# Wait for the listener to be connected — i.e. its factory completed its
|
|
246
|
+
# wait_heartbeat, so the connection's target_system is the real vehicle, not
|
|
247
|
+
# 0, and it is reading. Bounded wait; if it never connects we cannot command.
|
|
248
|
+
listener = self._listeners.get(action.device_id)
|
|
249
|
+
if listener is None:
|
|
250
|
+
return ActionResult(
|
|
251
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
252
|
+
error="MAVLink: telemetry listener could not be started; "
|
|
253
|
+
"no connection to command the vehicle.",
|
|
254
|
+
)
|
|
255
|
+
if not listener.wait_connected(timeout=self._connect_timeout):
|
|
256
|
+
return ActionResult(
|
|
257
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
258
|
+
error=f"MAVLink: vehicle {action.device_id} did not connect within "
|
|
259
|
+
f"{self._connect_timeout}s (no heartbeat) — cannot command.",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Fetch the listener's live connection per-dispatch (never cache — a reconnect
|
|
263
|
+
# replaces the object). The command writes on it; the listener reads it.
|
|
264
|
+
conn = listener.get_connection()
|
|
265
|
+
if conn is None:
|
|
266
|
+
return ActionResult(
|
|
267
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
268
|
+
error=f"MAVLink: vehicle {action.device_id} connection not available "
|
|
269
|
+
f"(listener connected then dropped) — cannot command.",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
return await self._dispatch(conn, action, params, default_alt)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
log.warning("MAVLink %s failed: %s", action.action, e)
|
|
276
|
+
return ActionResult(
|
|
277
|
+
device_id=action.device_id, action=action.action, success=False,
|
|
278
|
+
error=f"MAVLink dispatch failed: {e}",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def _dispatch(self, conn, action: DeviceAction, params: dict,
|
|
282
|
+
default_alt: float) -> ActionResult:
|
|
283
|
+
"""Send the MAVLink command for this action and wait for its ACK.
|
|
284
|
+
|
|
285
|
+
Each branch ends by sending a command that produces a COMMAND_ACK (or, for
|
|
286
|
+
mode changes, sets the mode and confirms). The dispatch is considered
|
|
287
|
+
accepted when the vehicle acknowledges; the actual flying is observed via
|
|
288
|
+
telemetry (Step 2).
|
|
289
|
+
"""
|
|
290
|
+
act = action.action
|
|
291
|
+
ok = False
|
|
292
|
+
detail = {}
|
|
293
|
+
|
|
294
|
+
if act == "take_off":
|
|
295
|
+
alt = float(params.get("altitude", default_alt))
|
|
296
|
+
# Sequence: GUIDED -> arm -> NAV_TAKEOFF. The panel's "preparing" phase
|
|
297
|
+
# (arming/taking_off) will be surfaced by telemetry in Step 2; here we
|
|
298
|
+
# only dispatch the order and confirm acceptance.
|
|
299
|
+
self._set_mode(conn, "GUIDED", device_id=action.device_id)
|
|
300
|
+
self._arm(conn, device_id=action.device_id)
|
|
301
|
+
send_time = time.time()
|
|
302
|
+
conn.mav.command_long_send(
|
|
303
|
+
conn.target_system, conn.target_component,
|
|
304
|
+
mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
|
|
305
|
+
0, 0, 0, 0, 0, 0, 0, alt,
|
|
306
|
+
)
|
|
307
|
+
ok = self._wait_ack(conn, mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
|
|
308
|
+
device_id=action.device_id, since=send_time)
|
|
309
|
+
detail = {"altitude": alt}
|
|
310
|
+
# Tell this vehicle's telemetry listener to confirm the climb: emit
|
|
311
|
+
# FINISHED once the vehicle reaches the commanded altitude. Without this
|
|
312
|
+
# the take_off would dispatch, be ACK'd, and then stall forever (silence
|
|
313
|
+
# is not success) because nothing ever confirms the climb completed.
|
|
314
|
+
# Guarded so it's a no-op when telemetry isn't running.
|
|
315
|
+
if ok:
|
|
316
|
+
listener = self._listeners.get(action.device_id)
|
|
317
|
+
if listener is not None:
|
|
318
|
+
listener.set_arrival_target("altitude", alt)
|
|
319
|
+
|
|
320
|
+
elif act == "go_to":
|
|
321
|
+
lat = params.get("lat")
|
|
322
|
+
lon = params.get("lon")
|
|
323
|
+
alt = float(params.get("alt", default_alt))
|
|
324
|
+
if lat is None or lon is None:
|
|
325
|
+
return ActionResult(
|
|
326
|
+
device_id=action.device_id, action=act, success=False,
|
|
327
|
+
error="go_to requires 'lat' and 'lon' params.",
|
|
328
|
+
)
|
|
329
|
+
# Reposition the vehicle to a coordinate (GUIDED mode target).
|
|
330
|
+
conn.mav.set_position_target_global_int_send(
|
|
331
|
+
0, conn.target_system, conn.target_component,
|
|
332
|
+
mavutil.mavlink.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
|
|
333
|
+
0b0000111111111000, # position only
|
|
334
|
+
int(lat * 1e7), int(lon * 1e7), alt,
|
|
335
|
+
0, 0, 0, 0, 0, 0, 0, 0,
|
|
336
|
+
)
|
|
337
|
+
# Position targets do not emit a COMMAND_ACK; acceptance is implicit on
|
|
338
|
+
# send in GUIDED mode. We confirm the vehicle is in GUIDED.
|
|
339
|
+
ok = True
|
|
340
|
+
detail = {"lat": lat, "lon": lon, "alt": alt}
|
|
341
|
+
# Tell this vehicle's telemetry listener where the vehicle is headed, so
|
|
342
|
+
# it can emit FINISHED on arrival (set_position_target gives no
|
|
343
|
+
# MISSION_ITEM_REACHED). This is the single point where the command
|
|
344
|
+
# channel touches the telemetry channel — guarded so it is a no-op when
|
|
345
|
+
# telemetry isn't running (simulated mode, or telemetry not started).
|
|
346
|
+
listener = self._listeners.get(action.device_id)
|
|
347
|
+
if listener is not None:
|
|
348
|
+
listener.set_arrival_target("position", (lat, lon))
|
|
349
|
+
|
|
350
|
+
elif act == "land":
|
|
351
|
+
ok = self._set_mode(conn, "LAND", device_id=action.device_id)
|
|
352
|
+
detail = {"mode": "LAND"}
|
|
353
|
+
|
|
354
|
+
elif act == "return_home":
|
|
355
|
+
ok = self._set_mode(conn, "RTL", device_id=action.device_id)
|
|
356
|
+
detail = {"mode": "RTL"}
|
|
357
|
+
# Register a disarm arrival target so the supervisor waits for the RTL to
|
|
358
|
+
# actually complete: the listener emits FINISHED when the vehicle disarms
|
|
359
|
+
# (landed at home, motors off) — the real "RTL done" signal. Without this
|
|
360
|
+
# the step would be evaluated on the ACK alone and finish/abort instantly,
|
|
361
|
+
# never waiting for the descent. Guarded so it's a no-op without telemetry.
|
|
362
|
+
if ok:
|
|
363
|
+
listener = self._listeners.get(action.device_id)
|
|
364
|
+
if listener is not None:
|
|
365
|
+
listener.set_arrival_target("disarm", None)
|
|
366
|
+
|
|
367
|
+
elif act == "loiter":
|
|
368
|
+
ok = self._set_mode(conn, "LOITER", device_id=action.device_id)
|
|
369
|
+
detail = {"mode": "LOITER"}
|
|
370
|
+
|
|
371
|
+
if ok:
|
|
372
|
+
log.info("MAVLink %s: %s accepted %s", action.device_id, act, detail)
|
|
373
|
+
return ActionResult(
|
|
374
|
+
device_id=action.device_id, action=act, success=True,
|
|
375
|
+
response={"dispatch": "accepted", "command": act, **detail},
|
|
376
|
+
)
|
|
377
|
+
return ActionResult(
|
|
378
|
+
device_id=action.device_id, action=act, success=False,
|
|
379
|
+
error=f"Vehicle did not accept '{act}' (no ACK / rejected within "
|
|
380
|
+
f"{self._ack_timeout}s).",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# ── MAVLink primitives ────────────────────────────────────────────────────
|
|
384
|
+
def _set_mode(self, conn, mode_name: str, device_id: str = None) -> bool:
|
|
385
|
+
"""Set a flight mode by name and confirm via COMMAND_ACK."""
|
|
386
|
+
mode_map = conn.mode_mapping()
|
|
387
|
+
if mode_map is None or mode_name not in mode_map:
|
|
388
|
+
log.warning("MAVLink: mode '%s' unknown to this vehicle", mode_name)
|
|
389
|
+
return False
|
|
390
|
+
mode_id = mode_map[mode_name]
|
|
391
|
+
send_time = time.time()
|
|
392
|
+
conn.mav.command_long_send(
|
|
393
|
+
conn.target_system, conn.target_component,
|
|
394
|
+
mavutil.mavlink.MAV_CMD_DO_SET_MODE, 0,
|
|
395
|
+
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, mode_id,
|
|
396
|
+
0, 0, 0, 0, 0,
|
|
397
|
+
)
|
|
398
|
+
return self._wait_ack(conn, mavutil.mavlink.MAV_CMD_DO_SET_MODE,
|
|
399
|
+
device_id=device_id, since=send_time)
|
|
400
|
+
|
|
401
|
+
def _arm(self, conn, device_id: str = None) -> bool:
|
|
402
|
+
"""Arm the vehicle's motors and confirm via COMMAND_ACK."""
|
|
403
|
+
send_time = time.time()
|
|
404
|
+
conn.mav.command_long_send(
|
|
405
|
+
conn.target_system, conn.target_component,
|
|
406
|
+
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, 0,
|
|
407
|
+
1, 0, 0, 0, 0, 0, 0,
|
|
408
|
+
)
|
|
409
|
+
return self._wait_ack(conn, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
|
410
|
+
device_id=device_id, since=send_time)
|
|
411
|
+
|
|
412
|
+
def _wait_ack(self, conn, command_id, device_id: str = None,
|
|
413
|
+
since: float = None) -> bool:
|
|
414
|
+
"""Wait for a COMMAND_ACK for the given command. Returns True only on an
|
|
415
|
+
explicit ACCEPTED result — silence or rejection is False. This is the
|
|
416
|
+
command-channel embodiment of 'no positive signal, no success'.
|
|
417
|
+
|
|
418
|
+
SINGLE-READER design: the ACK is read by the telemetry listener (the only
|
|
419
|
+
reader of the socket) and recorded in its ACK registry; this method consumes
|
|
420
|
+
it from there via wait_for_ack, rather than reading the socket itself. The
|
|
421
|
+
old socket read does not work once the command channel is outbound-only
|
|
422
|
+
(udpout) — the ACK arrives on the listener's socket, not the command's.
|
|
423
|
+
|
|
424
|
+
`since` is the command's send time; only an ACK that arrived at or after it
|
|
425
|
+
counts, so a stale ACK from an earlier identical command is never accepted.
|
|
426
|
+
If no listener is running for this device, there is no reader for the ACK —
|
|
427
|
+
we log and return False (never assume success)."""
|
|
428
|
+
listener = self._listeners.get(device_id) if device_id else None
|
|
429
|
+
if listener is None:
|
|
430
|
+
# No single reader for this device's ACKs. With an outbound-only command
|
|
431
|
+
# channel there is nothing to read the ACK from, so we cannot confirm.
|
|
432
|
+
# Silence is not success.
|
|
433
|
+
log.warning("MAVLink: no telemetry listener for %s — cannot confirm "
|
|
434
|
+
"COMMAND_ACK for command %s (treating as not accepted)",
|
|
435
|
+
device_id, command_id)
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
send_time = since if since is not None else time.time()
|
|
439
|
+
result = listener.wait_for_ack(command_id, send_time, self._ack_timeout)
|
|
440
|
+
if result is None:
|
|
441
|
+
log.warning("MAVLink: no COMMAND_ACK for command %s within %ss",
|
|
442
|
+
command_id, self._ack_timeout)
|
|
443
|
+
return False
|
|
444
|
+
accepted = (result == mavutil.mavlink.MAV_RESULT_ACCEPTED)
|
|
445
|
+
if not accepted:
|
|
446
|
+
log.warning("MAVLink: command %s result=%s (not ACCEPTED)",
|
|
447
|
+
command_id, result)
|
|
448
|
+
return accepted
|
|
449
|
+
|
|
450
|
+
# ── Telemetry channel (Step 2b) ──────────────────────────────────────────
|
|
451
|
+
def start_telemetry(self, device_id: str, conn_str: str = None) -> bool:
|
|
452
|
+
"""Begin listening to a vehicle's telemetry.
|
|
453
|
+
|
|
454
|
+
Spawns a listener thread for the vehicle and ensures the consumer task is
|
|
455
|
+
running. The listener reconnects on its own via the connection factory, so
|
|
456
|
+
this can be called even before the vehicle is reachable.
|
|
457
|
+
|
|
458
|
+
Returns False (and does nothing) in simulated mode — without pymavlink there
|
|
459
|
+
is no socket to listen to. A hub with no drone library still runs; it just
|
|
460
|
+
has no live telemetry, exactly like the command channel.
|
|
461
|
+
"""
|
|
462
|
+
if not _MAVLINK_AVAILABLE:
|
|
463
|
+
log.info("[SIMULATED] telemetry not started for %s (pymavlink absent)",
|
|
464
|
+
device_id)
|
|
465
|
+
return False
|
|
466
|
+
if device_id in self._listeners:
|
|
467
|
+
return True # already listening
|
|
468
|
+
|
|
469
|
+
conn_str = conn_str or self._connection_string_for(device_id)
|
|
470
|
+
if not conn_str:
|
|
471
|
+
log.warning("Cannot start telemetry for %s: no connection string", device_id)
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
# The factory lets the listener (re)open the link on its own thread, and
|
|
475
|
+
# lets tests inject a fake. We do NOT share the command-channel connection:
|
|
476
|
+
# telemetry reads continuously and must not contend with command ACKs.
|
|
477
|
+
def factory():
|
|
478
|
+
conn = mavutil.mavlink_connection(conn_str)
|
|
479
|
+
conn.wait_heartbeat(timeout=10)
|
|
480
|
+
return conn
|
|
481
|
+
|
|
482
|
+
listener = MAVLinkTelemetryListener(
|
|
483
|
+
device_id=device_id,
|
|
484
|
+
connection_factory=factory,
|
|
485
|
+
out_queue=self._telemetry_queue,
|
|
486
|
+
)
|
|
487
|
+
self._listeners[device_id] = listener
|
|
488
|
+
listener.start()
|
|
489
|
+
self._ensure_consumer()
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
def _connection_string_for(self, device_id: str) -> Optional[str]:
|
|
493
|
+
"""Resolve a device's MAVLink endpoint from its manifest adapter_config."""
|
|
494
|
+
if self._hub:
|
|
495
|
+
device = self._hub.registry.get(device_id)
|
|
496
|
+
if device and device.adapter_config:
|
|
497
|
+
return device.adapter_config.get("connection")
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _single_connection(cfg: dict) -> Optional[str]:
|
|
502
|
+
"""Derive the ONE bidirectional connection string the listener opens and the
|
|
503
|
+
command writes on (panel: single-connection, single-reader).
|
|
504
|
+
|
|
505
|
+
A real drone over a serial radio is a single bidirectional link — the GCS
|
|
506
|
+
opens it once, receives heartbeats (learning target_system), sends commands,
|
|
507
|
+
receives ACKs and telemetry, all on the one connection. We mirror that: the
|
|
508
|
+
listener owns one connection and is the only reader; the command writes on it.
|
|
509
|
+
|
|
510
|
+
The connection MUST be able to RECEIVE — it is what learns target_system from
|
|
511
|
+
the heartbeat. So it must be a binding/receiving form (udp:/udpin:/tcp:/
|
|
512
|
+
serial:). A legacy `udpout:` (outbound-only — cannot receive the heartbeat,
|
|
513
|
+
which is exactly what produced target_system=0) is normalized back to the
|
|
514
|
+
receiving `udp:` form. A separately declared `telemetry_connection` is
|
|
515
|
+
accepted as the single connection if present (it is the receiving endpoint).
|
|
516
|
+
|
|
517
|
+
Returns the connection string, or None if none is usable.
|
|
518
|
+
"""
|
|
519
|
+
# Prefer an explicit receiving telemetry endpoint if declared; otherwise the
|
|
520
|
+
# main connection. Either way we want the receiving form.
|
|
521
|
+
base = cfg.get("connection") or cfg.get("telemetry_connection")
|
|
522
|
+
if not base:
|
|
523
|
+
return None
|
|
524
|
+
# Normalize an outbound-only command string back to a receiving one — the
|
|
525
|
+
# single connection has to receive the heartbeat.
|
|
526
|
+
if base.startswith("udpout:"):
|
|
527
|
+
base = "udp:" + base[len("udpout:"):]
|
|
528
|
+
return base
|
|
529
|
+
|
|
530
|
+
def _ensure_consumer(self) -> None:
|
|
531
|
+
"""Start the single consumer task if it isn't already running. The consumer
|
|
532
|
+
runs on the event-loop thread — the only place the DB is touched."""
|
|
533
|
+
if self._consumer_running:
|
|
534
|
+
return
|
|
535
|
+
# get_running_loop() states the requirement directly: this consumer only
|
|
536
|
+
# makes sense on a running loop. It also replaces the deprecated
|
|
537
|
+
# get_event_loop() — and since it returns ONLY a running loop, the old
|
|
538
|
+
# `not loop.is_running()` check is now redundant.
|
|
539
|
+
try:
|
|
540
|
+
loop = asyncio.get_running_loop()
|
|
541
|
+
except RuntimeError:
|
|
542
|
+
loop = None
|
|
543
|
+
if loop is None:
|
|
544
|
+
# No running loop (e.g. some test contexts). The consumer can be driven
|
|
545
|
+
# manually via drain_telemetry_once() instead.
|
|
546
|
+
return
|
|
547
|
+
self._consumer_running = True
|
|
548
|
+
self._consumer_task = loop.create_task(self._consume_telemetry())
|
|
549
|
+
|
|
550
|
+
async def _consume_telemetry(self) -> None:
|
|
551
|
+
"""Drain the telemetry queue, applying each fact to the hub. Runs until
|
|
552
|
+
stop_telemetry() lowers the flag. Sleeps briefly when the queue is empty so
|
|
553
|
+
it never busy-spins."""
|
|
554
|
+
log.info("MAVLink telemetry consumer started")
|
|
555
|
+
while self._consumer_running:
|
|
556
|
+
applied = self.drain_telemetry_once()
|
|
557
|
+
if applied == 0:
|
|
558
|
+
await asyncio.sleep(0.1)
|
|
559
|
+
log.info("MAVLink telemetry consumer stopped")
|
|
560
|
+
|
|
561
|
+
def drain_telemetry_once(self) -> int:
|
|
562
|
+
"""Apply all currently-queued telemetry facts to the hub. Returns how many
|
|
563
|
+
were applied. Separated from the async loop so tests can drive it directly
|
|
564
|
+
without an event loop. This is the ONLY place the DB is touched for
|
|
565
|
+
telemetry — always on the calling (event-loop) thread."""
|
|
566
|
+
applied = 0
|
|
567
|
+
while True:
|
|
568
|
+
try:
|
|
569
|
+
device_id, event, phase = self._telemetry_queue.get_nowait()
|
|
570
|
+
except queue.Empty:
|
|
571
|
+
break
|
|
572
|
+
if self._hub is not None:
|
|
573
|
+
try:
|
|
574
|
+
self._hub.apply_telemetry(device_id, event, phase=phase)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
log.warning("apply_telemetry failed for %s (%s): %s",
|
|
577
|
+
device_id, event, e)
|
|
578
|
+
applied += 1
|
|
579
|
+
return applied
|
|
580
|
+
|
|
581
|
+
def stop_telemetry(self) -> None:
|
|
582
|
+
"""Stop all listeners and the consumer. Joins every listener thread."""
|
|
583
|
+
self._consumer_running = False
|
|
584
|
+
if self._consumer_task is not None:
|
|
585
|
+
self._consumer_task.cancel()
|
|
586
|
+
self._consumer_task = None
|
|
587
|
+
for device_id, listener in list(self._listeners.items()):
|
|
588
|
+
listener.stop()
|
|
589
|
+
self._listeners.clear()
|
|
590
|
+
|
|
591
|
+
async def disconnect(self) -> None:
|
|
592
|
+
"""Close all cached MAVLink connections and tear down the telemetry channel."""
|
|
593
|
+
self.stop_telemetry()
|
|
594
|
+
for conn_str, conn in list(self._connections.items()):
|
|
595
|
+
try:
|
|
596
|
+
conn.close()
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
self._connections.clear()
|
|
600
|
+
|
|
601
|
+
async def get_state(self, device_id: str) -> Optional[dict]:
|
|
602
|
+
"""State query is part of the telemetry channel (Step 2). Deferred."""
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def mavlink_manifest(
|
|
607
|
+
device_id: str,
|
|
608
|
+
device_name: str,
|
|
609
|
+
connection: str,
|
|
610
|
+
default_alt: float = 10.0,
|
|
611
|
+
**kwargs,
|
|
612
|
+
):
|
|
613
|
+
"""Helper to build a CapabilityManifest for a MAVLink vehicle.
|
|
614
|
+
|
|
615
|
+
The vehicle declares the five aerial actions as long-running, telemetry-capable
|
|
616
|
+
actuators — so the execution_model tracks each as an operation and (in Step 2)
|
|
617
|
+
the telemetry channel advances it. This is the aerial domain profile of the
|
|
618
|
+
universal execution_model.
|
|
619
|
+
"""
|
|
620
|
+
from ..models import (
|
|
621
|
+
CapabilityManifest, ActuatorSpec, DeviceCategory, CertTier,
|
|
622
|
+
)
|
|
623
|
+
actuators = [
|
|
624
|
+
ActuatorSpec(a, a, execution_model="long_running", emits_telemetry=True)
|
|
625
|
+
for a in SUPPORTED_ACTIONS
|
|
626
|
+
]
|
|
627
|
+
return CapabilityManifest(
|
|
628
|
+
device_id=device_id,
|
|
629
|
+
device_name=device_name,
|
|
630
|
+
manufacturer=kwargs.get("manufacturer", "MAVLink"),
|
|
631
|
+
model=kwargs.get("model", "generic"),
|
|
632
|
+
firmware=kwargs.get("firmware", "ArduPilot"),
|
|
633
|
+
category=DeviceCategory.ACTUATOR,
|
|
634
|
+
tags=kwargs.get("tags", ["aerial", "vehicle", "mavlink"]),
|
|
635
|
+
actuators=actuators,
|
|
636
|
+
sensors=[],
|
|
637
|
+
emergency_capable=kwargs.get("emergency_capable", False),
|
|
638
|
+
cert_tier=CertTier.BASIC,
|
|
639
|
+
adapter_config={"connection": connection, "default_alt": default_alt},
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ── Telemetry mapping (pure, Step 2a) ─────────────────────────────────────────
|
|
644
|
+
# The MAVLink-native -> abstract TelemetryEvent translation. This is a PURE
|
|
645
|
+
# function of (message, remembered previous mode): no socket, no I/O. The
|
|
646
|
+
# background listener loop (Step 2b) owns the socket and calls this for each
|
|
647
|
+
# message; keeping the mapping pure means it is exhaustively testable by injecting
|
|
648
|
+
# fake messages — including messages that produce NO event.
|
|
649
|
+
#
|
|
650
|
+
# Why a class and not a function: the most safety-critical event,
|
|
651
|
+
# MANUAL_CONTROL_TAKEN, is a TRANSITION, not a level. The vehicle broadcasts a
|
|
652
|
+
# HEARTBEAT ~1/s carrying its current flight mode. We must emit "a human took
|
|
653
|
+
# control" exactly once, on the GUIDED -> manual edge — not on every heartbeat
|
|
654
|
+
# that happens to be in a manual mode. That requires remembering the previous
|
|
655
|
+
# mode. The memory lives here, isolated from the socket.
|
|
656
|
+
|
|
657
|
+
# ArduCopter flight modes that mean "DoSync is driving" vs "a human/other is".
|
|
658
|
+
# GUIDED is the mode DoSync commands the vehicle in. AUTO (running a mission) is
|
|
659
|
+
# also autonomous. Anything else, entered while an operation is active, means
|
|
660
|
+
# control left DoSync's hands — most often a pilot moving the sticks.
|
|
661
|
+
_AUTONOMOUS_MODES = frozenset({"GUIDED", "AUTO", "RTL", "LAND"})
|
|
662
|
+
# RTL and LAND are autonomous modes WE command (return_home, land). Without them
|
|
663
|
+
# here, the GUIDED->RTL transition our own return_home triggers would be read as the
|
|
664
|
+
# autonomous->manual edge and emit a false MANUAL_CONTROL_TAKEN. (A pilot selecting
|
|
665
|
+
# RTL on the radio is a real handover, but distinguishing who selected the mode is a
|
|
666
|
+
# later refinement — for now our commanded RTL/LAND are autonomous.)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class MAVLinkTelemetryMapper:
|
|
670
|
+
"""Translates MAVLink messages into abstract TelemetryEvents, remembering the
|
|
671
|
+
previous flight mode so manual-takeover is detected as an edge, not a level.
|
|
672
|
+
|
|
673
|
+
Stateful only in the minimal way the transition detection requires. One mapper
|
|
674
|
+
per vehicle/connection. Pure with respect to I/O — it never touches a socket;
|
|
675
|
+
the listener loop (Step 2b) feeds it messages and acts on the events it returns.
|
|
676
|
+
|
|
677
|
+
Each call to map_message returns either a (TelemetryEvent, phase) tuple or
|
|
678
|
+
None when the message implies no operation-relevant fact. `phase` is an
|
|
679
|
+
optional domain sub-phase string (e.g. "arming") carried with PREPARING; it is
|
|
680
|
+
None for events that don't refine a phase.
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
def __init__(self):
|
|
684
|
+
# Last flight mode we saw in a HEARTBEAT. None until the first heartbeat.
|
|
685
|
+
self._last_mode: Optional[str] = None
|
|
686
|
+
# Whether we've already emitted STARTED for the current flight, so we don't
|
|
687
|
+
# re-emit it on every position update. Reset when disarmed.
|
|
688
|
+
self._takeoff_confirmed = False
|
|
689
|
+
|
|
690
|
+
def reset(self) -> None:
|
|
691
|
+
"""Clear remembered state — used on reconnect, so the mapper re-learns the
|
|
692
|
+
vehicle's mode from the next heartbeat rather than assuming the past."""
|
|
693
|
+
self._last_mode = None
|
|
694
|
+
self._takeoff_confirmed = False
|
|
695
|
+
|
|
696
|
+
def map_message(self, msg) -> Optional[tuple]:
|
|
697
|
+
"""Map one MAVLink message to (TelemetryEvent, phase) or None.
|
|
698
|
+
|
|
699
|
+
`msg` is anything with a `.get_type()` and the relevant fields — a real
|
|
700
|
+
pymavlink message in production, or a simple stand-in in tests. The mapping
|
|
701
|
+
reads only attributes, never a socket, so it is fully testable offline.
|
|
702
|
+
"""
|
|
703
|
+
# Local import so this module still imports without the reconciler in any
|
|
704
|
+
# odd packaging; in practice it's always present.
|
|
705
|
+
from ..reconciler import TelemetryEvent
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
mtype = msg.get_type()
|
|
709
|
+
except Exception:
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
if mtype == "HEARTBEAT":
|
|
713
|
+
return self._map_heartbeat(msg, TelemetryEvent)
|
|
714
|
+
if mtype == "STATUSTEXT":
|
|
715
|
+
return self._map_statustext(msg, TelemetryEvent)
|
|
716
|
+
if mtype == "MISSION_ITEM_REACHED":
|
|
717
|
+
# The vehicle reached a commanded waypoint — the positive completion
|
|
718
|
+
# signal for a go_to. (Takeoff/land have their own confirmations.)
|
|
719
|
+
return (TelemetryEvent.FINISHED, None)
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
def _map_heartbeat(self, msg, TelemetryEvent) -> Optional[tuple]:
|
|
723
|
+
"""HEARTBEAT carries the current flight mode and the armed flag. This is
|
|
724
|
+
where manual-takeover is detected, on the autonomous->manual edge."""
|
|
725
|
+
mode = self._mode_name(msg)
|
|
726
|
+
if mode is None:
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
prev = self._last_mode
|
|
730
|
+
self._last_mode = mode
|
|
731
|
+
|
|
732
|
+
# The critical safety edge: we WERE driving (autonomous) and now we are
|
|
733
|
+
# not. A human (or a failsafe) took control. Emit exactly once, on the edge.
|
|
734
|
+
if (prev in _AUTONOMOUS_MODES
|
|
735
|
+
and mode not in _AUTONOMOUS_MODES):
|
|
736
|
+
return (TelemetryEvent.MANUAL_CONTROL_TAKEN, None)
|
|
737
|
+
|
|
738
|
+
# No operation-relevant fact from this heartbeat (same mode, or a
|
|
739
|
+
# manual->manual change, or the first heartbeat). Steady-state heartbeats
|
|
740
|
+
# must NOT spam the reconciler.
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
def _map_statustext(self, msg, TelemetryEvent) -> Optional[tuple]:
|
|
744
|
+
"""STATUSTEXT carries human-readable vehicle notices. We map only the few
|
|
745
|
+
that correspond to operation facts; everything else is None."""
|
|
746
|
+
text = (getattr(msg, "text", "") or "").strip()
|
|
747
|
+
low = text.lower()
|
|
748
|
+
if not low:
|
|
749
|
+
return None
|
|
750
|
+
# Arming is the start of the PREPARING phase, sub-phase "arming".
|
|
751
|
+
if "arming motors" in low or low == "arming":
|
|
752
|
+
return (TelemetryEvent.PREPARING, "arming")
|
|
753
|
+
# A failure notice. ArduPilot emits varied failure text; we match the
|
|
754
|
+
# common, unambiguous markers and stay conservative otherwise.
|
|
755
|
+
if low.startswith("prearm") or "failsafe" in low or "crash" in low:
|
|
756
|
+
return (TelemetryEvent.FAILED, None)
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
@staticmethod
|
|
760
|
+
def _mode_name(msg) -> Optional[str]:
|
|
761
|
+
"""Extract the flight-mode name from a HEARTBEAT. Real pymavlink exposes
|
|
762
|
+
a mapping; in tests a stand-in can carry a `.mode_name` directly."""
|
|
763
|
+
# Test/stand-in fast path.
|
|
764
|
+
direct = getattr(msg, "mode_name", None)
|
|
765
|
+
if direct:
|
|
766
|
+
return direct
|
|
767
|
+
# Real pymavlink path: decode custom_mode via the message's own helper if
|
|
768
|
+
# present. We avoid importing mavutil here (keeps the mapper pure); the
|
|
769
|
+
# listener (Step 2b) can attach a resolved mode_name onto the message
|
|
770
|
+
# before calling, which is the direct path above. Returning None when we
|
|
771
|
+
# can't resolve is safe: it yields no event.
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ── Telemetry listener (background, Step 2b) ──────────────────────────────────
|
|
776
|
+
# The listener is the producer half of a producer-consumer pair. It owns a thread
|
|
777
|
+
# that blocks on the MAVLink socket — necessary because pymavlink's recv_match is
|
|
778
|
+
# blocking and would freeze the asyncio event loop if called on it directly. The
|
|
779
|
+
# thread translates each message with the (pure) mapper and ENQUEUES the resulting
|
|
780
|
+
# (device_id, event, phase) fact. It NEVER touches the DB or the audit log: those
|
|
781
|
+
# live on the event-loop thread (SQLite is not safely shared across threads), so
|
|
782
|
+
# the consumer — an asyncio task on the main thread — is the only thing that calls
|
|
783
|
+
# hub.apply_telemetry. This split is the whole reason the design is two objects.
|
|
784
|
+
#
|
|
785
|
+
# Disconnection safety (the panel's hard rule): when the socket goes quiet, the
|
|
786
|
+
# thread does NOT invent state. Silence is not a fact and is never enqueued as one.
|
|
787
|
+
# The thread logs the gap, tries to reconnect on a fixed interval, and on reconnect
|
|
788
|
+
# calls mapper.reset() so it re-learns the vehicle's mode from scratch rather than
|
|
789
|
+
# assuming the past. Active operations simply stay where they are; their
|
|
790
|
+
# time_in_state grows and the Policy Engine is what watches them.
|
|
791
|
+
|
|
792
|
+
# How long recv_match blocks before returning control to the loop so it can check
|
|
793
|
+
# the stop flag and notice silence. Short, so manual-takeover latency stays ~1-2s
|
|
794
|
+
# and shutdown is responsive.
|
|
795
|
+
_RECV_TIMEOUT_S = 0.5
|
|
796
|
+
# After this many seconds with no message at all, treat the link as down and begin
|
|
797
|
+
# reconnect attempts. A healthy vehicle heartbeats ~1/s, so this is generous.
|
|
798
|
+
_SILENCE_BEFORE_RECONNECT_S = 5.0
|
|
799
|
+
# Reconnect backoff. On a long-range radio link a drop can last a while, and
|
|
800
|
+
# hammering the factory every few seconds is poor radio citizenship (and wasteful).
|
|
801
|
+
# So reconnect attempts back off exponentially: base, base*2, base*4 ... capped at
|
|
802
|
+
# max. The counter resets to base the moment a connection succeeds, so a brief
|
|
803
|
+
# blip recovers fast and only a sustained outage stretches the interval out.
|
|
804
|
+
_RECONNECT_BASE_S = 3.0 # first retry waits this long
|
|
805
|
+
_RECONNECT_MAX_S = 60.0 # never wait longer than this between attempts
|
|
806
|
+
# Waypoint-arrival threshold. A go_to issued via set_position_target does NOT
|
|
807
|
+
# produce a MISSION_ITEM_REACHED (that is mission-only), so without this the
|
|
808
|
+
# vehicle would reach its destination and DoSync would never mark the operation
|
|
809
|
+
# finished. The listener compares live position against the go_to target and emits
|
|
810
|
+
# FINISHED once the vehicle is within this horizontal radius. 3m is a touch beyond
|
|
811
|
+
# ArduPilot's typical 2m waypoint-acceptance radius, accounting for GPS jitter — a
|
|
812
|
+
# vehicle never hovers perfectly still over a point. "Entered the radius = arrived";
|
|
813
|
+
# we deliberately do not require holding for N seconds (a simpler, sufficient rule).
|
|
814
|
+
_WAYPOINT_ARRIVAL_RADIUS_M = 3.0
|
|
815
|
+
# A take_off is considered FINISHED once the vehicle reaches this fraction of the
|
|
816
|
+
# commanded altitude. ArduPilot itself treats a climb as complete around 95% — a
|
|
817
|
+
# vehicle oscillates and rarely settles exactly on the target, so requiring the exact
|
|
818
|
+
# altitude would never confirm and the supervisor would stall. Vertical analogue of
|
|
819
|
+
# the waypoint arrival radius.
|
|
820
|
+
_TAKEOFF_ARRIVAL_FRACTION = 0.95
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
824
|
+
"""Great-circle distance between two lat/lon points, in meters. Delegates to the
|
|
825
|
+
shared geo module (dosync/geo.py) so the formula lives in exactly one place.
|
|
826
|
+
Kept as a thin module-level wrapper for the listener's waypoint-arrival check."""
|
|
827
|
+
from ..geo import haversine_m
|
|
828
|
+
return haversine_m(lat1, lon1, lat2, lon2)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class MAVLinkTelemetryListener:
|
|
832
|
+
"""A background thread that reads one vehicle's MAVLink telemetry, translates
|
|
833
|
+
it with a MAVLinkTelemetryMapper, and enqueues abstract facts for the adapter's
|
|
834
|
+
consumer to apply. One listener per vehicle.
|
|
835
|
+
|
|
836
|
+
Lifecycle: start() spawns the thread; stop() lowers the running flag and joins.
|
|
837
|
+
The thread never blocks longer than _RECV_TIMEOUT_S, so stop() returns promptly.
|
|
838
|
+
|
|
839
|
+
The listener is given a `connection_factory` (a zero-arg callable returning an
|
|
840
|
+
object with recv_match) rather than a live connection, so it can reconnect by
|
|
841
|
+
calling the factory again — and so tests can inject a fake connection.
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
def __init__(self, device_id: str, connection_factory, out_queue: "queue.Queue",
|
|
845
|
+
mapper: "MAVLinkTelemetryMapper" = None):
|
|
846
|
+
self.device_id = device_id
|
|
847
|
+
self._connection_factory = connection_factory
|
|
848
|
+
self._queue = out_queue
|
|
849
|
+
self._mapper = mapper or MAVLinkTelemetryMapper()
|
|
850
|
+
self._running = False
|
|
851
|
+
self._thread: Optional[threading.Thread] = None
|
|
852
|
+
self._conn = None
|
|
853
|
+
# Reconnect backoff: how many consecutive connect attempts have failed.
|
|
854
|
+
# 0 = healthy. Each failure grows the wait (see _current_backoff); a
|
|
855
|
+
# successful connect resets it to 0.
|
|
856
|
+
self._reconnect_failures = 0
|
|
857
|
+
# Active ARRIVAL TARGET for this vehicle, or None. A single target at a time:
|
|
858
|
+
# the supervisor runs steps sequentially (it waits for one step's FINISHED
|
|
859
|
+
# before dispatching the next), so a take_off (altitude target) and a go_to
|
|
860
|
+
# (position target) are never active simultaneously. This unifies what were
|
|
861
|
+
# two parallel mechanisms into one "arrival target" with a kind:
|
|
862
|
+
# ("position", (lat, lon)) — reached when within _WAYPOINT_ARRIVAL_RADIUS_M
|
|
863
|
+
# ("altitude", target_m) — reached at _TAKEOFF_ARRIVAL_FRACTION of target
|
|
864
|
+
# Lives HERE (per-vehicle, already drone-specific), not in the pure mapper or
|
|
865
|
+
# the generic hub — neither should know about coordinates or altitude.
|
|
866
|
+
self._arrival_target: Optional[tuple] = None # (kind, value) | None
|
|
867
|
+
self._target_lock = threading.Lock()
|
|
868
|
+
|
|
869
|
+
# ── COMMAND_ACK registry (single-reader design) ──────────────────────
|
|
870
|
+
# The listener is the ONLY reader of the socket, so COMMAND_ACKs arrive
|
|
871
|
+
# here, not on the (outbound-only) command channel. We record the latest
|
|
872
|
+
# ACK per command id and notify waiters. _wait_ack (called from the command
|
|
873
|
+
# path) consumes from here instead of reading the socket itself — which is
|
|
874
|
+
# exactly how a real GCS handles a single bidirectional link, and what makes
|
|
875
|
+
# this adapter work over a serial radio (one link, one reader), not just
|
|
876
|
+
# SITL/UDP. Maps command_id -> (result, arrival_time). A waiter only accepts
|
|
877
|
+
# an ACK whose arrival_time is at or after its own send time, so a stale ACK
|
|
878
|
+
# from an earlier identical command is never mistaken for a fresh one, and an
|
|
879
|
+
# ACK that arrives just before the waiter starts waiting is not lost.
|
|
880
|
+
self._ack_registry: dict = {} # command_id -> (result, arrival_time)
|
|
881
|
+
self._ack_condition = threading.Condition()
|
|
882
|
+
# Set once the listener has an open connection. The command path waits on
|
|
883
|
+
# this before dispatching, so the listener (the single reader) is already
|
|
884
|
+
# listening when the first command's ACK comes back — otherwise the ACKs of
|
|
885
|
+
# the opening commands (set_mode, arm) could be read-and-missed before the
|
|
886
|
+
# reader is up.
|
|
887
|
+
self._connected_event = threading.Event()
|
|
888
|
+
|
|
889
|
+
def wait_connected(self, timeout: float) -> bool:
|
|
890
|
+
"""Block up to `timeout` for the listener to establish its connection.
|
|
891
|
+
Returns True if connected within the timeout, False otherwise."""
|
|
892
|
+
return self._connected_event.wait(timeout=timeout)
|
|
893
|
+
|
|
894
|
+
def get_connection(self):
|
|
895
|
+
"""Return the live MAVLink connection this listener owns, or None if it has
|
|
896
|
+
not connected yet. The command channel writes commands on THIS connection —
|
|
897
|
+
a single bidirectional link, exactly like a real serial radio. The listener
|
|
898
|
+
is the only reader (recv_match on its thread); the command only writes
|
|
899
|
+
(command_long_send), which is a thread-safe sendto. The connection already
|
|
900
|
+
learned target_system/target_component from the heartbeat its factory waited
|
|
901
|
+
for, so commands written on it are addressed to the real vehicle (not
|
|
902
|
+
system 0). Callers must fetch this per-dispatch (never cache) — on a
|
|
903
|
+
reconnect the underlying connection object changes."""
|
|
904
|
+
return self._conn
|
|
905
|
+
|
|
906
|
+
def record_ack(self, command_id: int, result: int, at: float = None) -> None:
|
|
907
|
+
"""Record a COMMAND_ACK and wake any waiter. Called by the listener thread
|
|
908
|
+
when it reads a COMMAND_ACK off the socket."""
|
|
909
|
+
ts = at if at is not None else time.time()
|
|
910
|
+
with self._ack_condition:
|
|
911
|
+
self._ack_registry[command_id] = (result, ts)
|
|
912
|
+
self._ack_condition.notify_all()
|
|
913
|
+
|
|
914
|
+
def wait_for_ack(self, command_id: int, since: float, timeout: float) -> Optional[int]:
|
|
915
|
+
"""Block up to `timeout` seconds for a COMMAND_ACK for `command_id` that
|
|
916
|
+
arrived at or after `since`. Returns the MAVLink result code, or None on
|
|
917
|
+
timeout. Thread-safe: the listener thread records ACKs while this runs on
|
|
918
|
+
the command path. The `since` filter is what prevents both the lost-ACK race
|
|
919
|
+
(ACK arrived just before we started waiting — still counted, its arrival_time
|
|
920
|
+
>= since) and the stale-ACK bug (an ACK from a prior identical command —
|
|
921
|
+
arrival_time < since, ignored)."""
|
|
922
|
+
deadline = time.time() + timeout
|
|
923
|
+
with self._ack_condition:
|
|
924
|
+
while True:
|
|
925
|
+
entry = self._ack_registry.get(command_id)
|
|
926
|
+
if entry is not None and entry[1] >= since:
|
|
927
|
+
return entry[0]
|
|
928
|
+
remaining = deadline - time.time()
|
|
929
|
+
if remaining <= 0:
|
|
930
|
+
return None
|
|
931
|
+
self._ack_condition.wait(timeout=remaining)
|
|
932
|
+
|
|
933
|
+
def set_arrival_target(self, kind: str, value) -> None:
|
|
934
|
+
"""Record the active arrival target. kind is "position" (value=(lat,lon)) or
|
|
935
|
+
"altitude" (value=target_m). The listener emits FINISHED once the vehicle
|
|
936
|
+
reaches it. Called by the adapter from the command channel — the one point
|
|
937
|
+
where command and telemetry meet. Replaces any prior target (the supervisor's
|
|
938
|
+
sequencing guarantees there is at most one in flight)."""
|
|
939
|
+
with self._target_lock:
|
|
940
|
+
self._arrival_target = (kind, value)
|
|
941
|
+
|
|
942
|
+
def clear_arrival_target(self) -> None:
|
|
943
|
+
with self._target_lock:
|
|
944
|
+
self._arrival_target = None
|
|
945
|
+
|
|
946
|
+
def _get_arrival_target(self) -> Optional[tuple]:
|
|
947
|
+
with self._target_lock:
|
|
948
|
+
return self._arrival_target
|
|
949
|
+
|
|
950
|
+
# Backward-compatible aliases — set_destination/clear_destination were the
|
|
951
|
+
# position-only API before take_off needed altitude confirmation. Kept so any
|
|
952
|
+
# existing caller/test using them still works; they delegate to the unified target.
|
|
953
|
+
def set_destination(self, lat: float, lon: float) -> None:
|
|
954
|
+
self.set_arrival_target("position", (lat, lon))
|
|
955
|
+
|
|
956
|
+
def clear_destination(self) -> None:
|
|
957
|
+
self.clear_arrival_target()
|
|
958
|
+
|
|
959
|
+
def _get_destination(self) -> Optional[tuple]:
|
|
960
|
+
"""Backward-compatible accessor: returns the active position target (lat,lon)
|
|
961
|
+
or None. Returns None if the active target is an altitude (take_off) rather
|
|
962
|
+
than a position — preserving the original position-only semantics."""
|
|
963
|
+
target = self._get_arrival_target()
|
|
964
|
+
if target is not None and target[0] == "position":
|
|
965
|
+
return target[1]
|
|
966
|
+
return None
|
|
967
|
+
|
|
968
|
+
def _current_backoff(self) -> float:
|
|
969
|
+
"""Seconds to wait before the next reconnect attempt, growing
|
|
970
|
+
exponentially with consecutive failures and capped at the max."""
|
|
971
|
+
interval = _RECONNECT_BASE_S * (2 ** max(0, self._reconnect_failures - 1))
|
|
972
|
+
return min(interval, _RECONNECT_MAX_S)
|
|
973
|
+
|
|
974
|
+
def start(self) -> None:
|
|
975
|
+
if self._running:
|
|
976
|
+
return
|
|
977
|
+
self._running = True
|
|
978
|
+
self._thread = threading.Thread(
|
|
979
|
+
target=self._run, name=f"mavlink-listener-{self.device_id}", daemon=True)
|
|
980
|
+
self._thread.start()
|
|
981
|
+
log.info("MAVLink listener started for %s", self.device_id)
|
|
982
|
+
|
|
983
|
+
def stop(self, join_timeout: float = 2.0) -> None:
|
|
984
|
+
"""Signal the thread to stop and wait for it to exit. Idempotent."""
|
|
985
|
+
self._running = False
|
|
986
|
+
if self._thread is not None:
|
|
987
|
+
self._thread.join(timeout=join_timeout)
|
|
988
|
+
self._thread = None
|
|
989
|
+
if self._conn is not None:
|
|
990
|
+
try:
|
|
991
|
+
self._conn.close()
|
|
992
|
+
except Exception:
|
|
993
|
+
pass
|
|
994
|
+
self._conn = None
|
|
995
|
+
log.info("MAVLink listener stopped for %s", self.device_id)
|
|
996
|
+
|
|
997
|
+
def _run(self) -> None:
|
|
998
|
+
"""The thread body. Reads messages, maps them, enqueues facts. Handles
|
|
999
|
+
silence and reconnection without ever inventing operation state."""
|
|
1000
|
+
last_message_at = time.time()
|
|
1001
|
+
while self._running:
|
|
1002
|
+
# Ensure we have a connection; (re)connect if needed.
|
|
1003
|
+
if self._conn is None:
|
|
1004
|
+
if not self._reconnect():
|
|
1005
|
+
# Could not connect — wait (with exponential backoff) and retry,
|
|
1006
|
+
# but keep checking _running so stop() stays responsive.
|
|
1007
|
+
self._sleep_interruptible(self._current_backoff())
|
|
1008
|
+
continue
|
|
1009
|
+
last_message_at = time.time()
|
|
1010
|
+
|
|
1011
|
+
# Read one message (blocking up to _RECV_TIMEOUT_S).
|
|
1012
|
+
try:
|
|
1013
|
+
msg = self._conn.recv_match(blocking=True, timeout=_RECV_TIMEOUT_S)
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
log.warning("MAVLink listener %s: recv error: %s", self.device_id, e)
|
|
1016
|
+
self._drop_connection()
|
|
1017
|
+
continue
|
|
1018
|
+
|
|
1019
|
+
now = time.time()
|
|
1020
|
+
if msg is None:
|
|
1021
|
+
# No message this cycle. Not a fact — never enqueue anything. Just
|
|
1022
|
+
# check whether the link has gone quiet long enough to be 'down'.
|
|
1023
|
+
if now - last_message_at > _SILENCE_BEFORE_RECONNECT_S:
|
|
1024
|
+
log.warning("MAVLink listener %s: link silent %.1fs — reconnecting "
|
|
1025
|
+
"(operations untouched; silence is not success)",
|
|
1026
|
+
self.device_id, now - last_message_at)
|
|
1027
|
+
self._drop_connection()
|
|
1028
|
+
continue
|
|
1029
|
+
|
|
1030
|
+
# A real message arrived. Translate and, if it carries a fact, enqueue.
|
|
1031
|
+
last_message_at = now
|
|
1032
|
+
# COMMAND_ACK capture (single-reader design). The listener is the only
|
|
1033
|
+
# reader of the socket, so a command's ACK arrives HERE, not on the
|
|
1034
|
+
# outbound command channel. Record it so the waiting command path
|
|
1035
|
+
# (wait_for_ack) can consume it. This is what lets the command confirm
|
|
1036
|
+
# its ACK over a one-reader link — the serial-real design, not just SITL.
|
|
1037
|
+
try:
|
|
1038
|
+
if msg.get_type() == "COMMAND_ACK":
|
|
1039
|
+
self.record_ack(msg.command, msg.result, now)
|
|
1040
|
+
except Exception:
|
|
1041
|
+
pass
|
|
1042
|
+
# Resolve the human-readable flight-mode name on heartbeats before the
|
|
1043
|
+
# mapper sees them. Real pymavlink HEARTBEATs carry the mode as a numeric
|
|
1044
|
+
# custom_mode, but the (pure, socket-free) mapper keys off a `mode_name`
|
|
1045
|
+
# string. Resolving it here — where we have the live connection — keeps
|
|
1046
|
+
# the mapper pure while making manual-takeover detection actually work
|
|
1047
|
+
# against a real vehicle (not just against tests that supply mode_name).
|
|
1048
|
+
self._attach_mode_name(msg)
|
|
1049
|
+
try:
|
|
1050
|
+
mapped = self._mapper.map_message(msg)
|
|
1051
|
+
except Exception as e:
|
|
1052
|
+
log.debug("MAVLink listener %s: map error on %s: %s",
|
|
1053
|
+
self.device_id, getattr(msg, "get_type", lambda: "?")(), e)
|
|
1054
|
+
mapped = None
|
|
1055
|
+
if mapped is not None:
|
|
1056
|
+
event, phase = mapped
|
|
1057
|
+
# Enqueue the abstract fact. The consumer (event-loop thread) applies
|
|
1058
|
+
# it. We never touch the DB here.
|
|
1059
|
+
self._queue.put((self.device_id, event, phase))
|
|
1060
|
+
|
|
1061
|
+
# Waypoint-arrival detection. A go_to (set_position_target) gets no
|
|
1062
|
+
# MISSION_ITEM_REACHED, so we close the loop by position: if a
|
|
1063
|
+
# destination is set and the vehicle is within the arrival radius, emit
|
|
1064
|
+
# FINISHED once and clear the destination. This is a positive
|
|
1065
|
+
# confirmation (the vehicle actually reached the point) — silence still
|
|
1066
|
+
# never completes anything.
|
|
1067
|
+
self._check_arrival(msg)
|
|
1068
|
+
|
|
1069
|
+
def _attach_mode_name(self, msg) -> None:
|
|
1070
|
+
"""For a HEARTBEAT, decode the numeric custom_mode into a mode-name string
|
|
1071
|
+
and attach it as `msg.mode_name`, which is what the mapper reads. No-op for
|
|
1072
|
+
other message types or if decoding is unavailable (the mapper then simply
|
|
1073
|
+
emits no event for that heartbeat — safe)."""
|
|
1074
|
+
try:
|
|
1075
|
+
if msg.get_type() != "HEARTBEAT":
|
|
1076
|
+
return
|
|
1077
|
+
except Exception:
|
|
1078
|
+
return
|
|
1079
|
+
if getattr(msg, "mode_name", None):
|
|
1080
|
+
return # already resolved (e.g. a test stand-in)
|
|
1081
|
+
try:
|
|
1082
|
+
from pymavlink import mavutil
|
|
1083
|
+
msg.mode_name = mavutil.mode_string_v10(msg)
|
|
1084
|
+
except Exception:
|
|
1085
|
+
# Cannot resolve (no pymavlink, or unexpected message) — leave it unset.
|
|
1086
|
+
pass
|
|
1087
|
+
|
|
1088
|
+
def _check_arrival(self, msg) -> None:
|
|
1089
|
+
"""If an arrival target is active and this message shows the vehicle has
|
|
1090
|
+
reached it, enqueue FINISHED once and clear the target. Handles three target
|
|
1091
|
+
kinds:
|
|
1092
|
+
- position (go_to) — GLOBAL_POSITION_INT within the arrival radius
|
|
1093
|
+
- altitude (take_off) — GLOBAL_POSITION_INT at the arrival fraction
|
|
1094
|
+
- disarm (return_home) — HEARTBEAT showing the vehicle disarmed (it landed
|
|
1095
|
+
at home after RTL and shut its motors down)
|
|
1096
|
+
The supervisor's sequencing guarantees only one target is ever active, so
|
|
1097
|
+
there is no risk of a double FINISHED, and a disarm is only read as success
|
|
1098
|
+
when a return_home is actually in flight."""
|
|
1099
|
+
target = self._get_arrival_target()
|
|
1100
|
+
if target is None:
|
|
1101
|
+
return
|
|
1102
|
+
kind, value = target
|
|
1103
|
+
from ..reconciler import TelemetryEvent
|
|
1104
|
+
|
|
1105
|
+
try:
|
|
1106
|
+
mtype = msg.get_type()
|
|
1107
|
+
except Exception:
|
|
1108
|
+
return
|
|
1109
|
+
|
|
1110
|
+
# ── disarm target (return_home): confirmed by the disarm in a HEARTBEAT ──
|
|
1111
|
+
if kind == "disarm":
|
|
1112
|
+
if mtype != "HEARTBEAT":
|
|
1113
|
+
return
|
|
1114
|
+
if self._is_disarmed(msg):
|
|
1115
|
+
log.info("MAVLink listener %s: disarmed after RTL — return_home "
|
|
1116
|
+
"FINISHED", self.device_id)
|
|
1117
|
+
self._queue.put((self.device_id, TelemetryEvent.FINISHED, None))
|
|
1118
|
+
self.clear_arrival_target()
|
|
1119
|
+
return
|
|
1120
|
+
|
|
1121
|
+
# ── position / altitude targets: confirmed by GLOBAL_POSITION_INT ───────
|
|
1122
|
+
if mtype != "GLOBAL_POSITION_INT":
|
|
1123
|
+
return
|
|
1124
|
+
|
|
1125
|
+
if kind == "position":
|
|
1126
|
+
try:
|
|
1127
|
+
cur_lat = msg.lat / 1e7
|
|
1128
|
+
cur_lon = msg.lon / 1e7
|
|
1129
|
+
except Exception:
|
|
1130
|
+
return
|
|
1131
|
+
distance = _haversine_m(cur_lat, cur_lon, value[0], value[1])
|
|
1132
|
+
if distance <= _WAYPOINT_ARRIVAL_RADIUS_M:
|
|
1133
|
+
log.info("MAVLink listener %s: reached go_to target (%.1fm) — FINISHED",
|
|
1134
|
+
self.device_id, distance)
|
|
1135
|
+
self._queue.put((self.device_id, TelemetryEvent.FINISHED, None))
|
|
1136
|
+
self.clear_arrival_target()
|
|
1137
|
+
|
|
1138
|
+
elif kind == "altitude":
|
|
1139
|
+
try:
|
|
1140
|
+
cur_alt = msg.relative_alt / 1000.0 # mm -> m
|
|
1141
|
+
except Exception:
|
|
1142
|
+
return
|
|
1143
|
+
target_alt = float(value)
|
|
1144
|
+
if target_alt > 0 and cur_alt >= target_alt * _TAKEOFF_ARRIVAL_FRACTION:
|
|
1145
|
+
log.info("MAVLink listener %s: reached take_off altitude "
|
|
1146
|
+
"(%.1fm of %.1fm) — FINISHED",
|
|
1147
|
+
self.device_id, cur_alt, target_alt)
|
|
1148
|
+
self._queue.put((self.device_id, TelemetryEvent.FINISHED, None))
|
|
1149
|
+
self.clear_arrival_target()
|
|
1150
|
+
|
|
1151
|
+
@staticmethod
|
|
1152
|
+
def _is_disarmed(msg) -> bool:
|
|
1153
|
+
"""True if a HEARTBEAT shows the vehicle disarmed. The armed state is the
|
|
1154
|
+
MAV_MODE_FLAG_SAFETY_ARMED bit (0x80) of base_mode: set = armed, clear =
|
|
1155
|
+
disarmed. A vehicle that completed RTL and landed clears this bit."""
|
|
1156
|
+
try:
|
|
1157
|
+
base_mode = msg.base_mode
|
|
1158
|
+
except Exception:
|
|
1159
|
+
return False
|
|
1160
|
+
ARMED_BIT = 0x80 # mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
|
|
1161
|
+
return (base_mode & ARMED_BIT) == 0
|
|
1162
|
+
|
|
1163
|
+
# Backward-compatible alias for the former method name.
|
|
1164
|
+
def _check_waypoint_arrival(self, msg) -> None:
|
|
1165
|
+
self._check_arrival(msg)
|
|
1166
|
+
|
|
1167
|
+
def _reconnect(self) -> bool:
|
|
1168
|
+
"""Open a fresh connection via the factory and reset the mapper so it does
|
|
1169
|
+
not assume the pre-disconnection mode. Returns True on success.
|
|
1170
|
+
|
|
1171
|
+
On success the backoff counter resets to 0 (a recovered link retries fast
|
|
1172
|
+
next time). On failure it grows, stretching the interval for a sustained
|
|
1173
|
+
outage so we are not hammering a dead radio link."""
|
|
1174
|
+
try:
|
|
1175
|
+
self._conn = self._connection_factory()
|
|
1176
|
+
if self._conn is None:
|
|
1177
|
+
self._reconnect_failures += 1
|
|
1178
|
+
return False
|
|
1179
|
+
# Re-learn reality from the next heartbeat — never assume the past.
|
|
1180
|
+
self._mapper.reset()
|
|
1181
|
+
self._reconnect_failures = 0 # healthy again — reset backoff
|
|
1182
|
+
self._connected_event.set()
|
|
1183
|
+
log.info("MAVLink listener %s: connected", self.device_id)
|
|
1184
|
+
return True
|
|
1185
|
+
except Exception as e:
|
|
1186
|
+
self._reconnect_failures += 1
|
|
1187
|
+
log.warning("MAVLink listener %s: connect failed (attempt %d): %s",
|
|
1188
|
+
self.device_id, self._reconnect_failures, e)
|
|
1189
|
+
self._conn = None
|
|
1190
|
+
return False
|
|
1191
|
+
|
|
1192
|
+
def _drop_connection(self) -> None:
|
|
1193
|
+
if self._conn is not None:
|
|
1194
|
+
try:
|
|
1195
|
+
self._conn.close()
|
|
1196
|
+
except Exception:
|
|
1197
|
+
pass
|
|
1198
|
+
self._conn = None
|
|
1199
|
+
self._connected_event.clear()
|
|
1200
|
+
|
|
1201
|
+
def _sleep_interruptible(self, seconds: float) -> None:
|
|
1202
|
+
"""Sleep in small slices so a stop() is noticed quickly."""
|
|
1203
|
+
deadline = time.time() + seconds
|
|
1204
|
+
while self._running and time.time() < deadline:
|
|
1205
|
+
time.sleep(min(0.2, deadline - time.time()))
|