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.
@@ -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()))