plexus-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
plexus/streaming.py ADDED
@@ -0,0 +1,743 @@
1
+ """
2
+ Stream management for Plexus devices.
3
+
4
+ Handles real-time sensor and camera streaming over WebSocket,
5
+ with optional HTTP persistence for recording.
6
+
7
+ Store-and-forward: when a buffer is provided, telemetry that fails to send
8
+ over WebSocket (e.g. during disconnection) is written to the buffer instead
9
+ of being dropped. The connector drains the buffer on reconnect via HTTP.
10
+ """
11
+
12
+ import asyncio
13
+ import base64
14
+ import json
15
+ import logging
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from io import BytesIO
20
+ from typing import Optional, Callable, List, Dict, Any, TYPE_CHECKING
21
+
22
+ from websockets.exceptions import ConnectionClosed
23
+
24
+ if TYPE_CHECKING:
25
+ from plexus.sensors.base import SensorHub
26
+ from plexus.cameras.base import CameraHub
27
+ from plexus.adapters.can_detect import DetectedCAN
28
+ from plexus.adapters.mavlink_detect import DetectedMAVLink
29
+ from plexus.buffer import BufferBackend
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _upload_frame_async(endpoint: str, api_key: str, frame, source_id: str, run_id: str = None):
35
+ """Upload a single frame to /api/frames in a background thread."""
36
+ def _do_upload():
37
+ try:
38
+ import requests
39
+ files = {"frame": ("frame.jpg", BytesIO(frame.data), "image/jpeg")}
40
+ data = {
41
+ "source_id": source_id,
42
+ "camera_id": frame.camera_id,
43
+ "timestamp": str(int(frame.timestamp * 1000)),
44
+ }
45
+ if run_id:
46
+ data["run_id"] = run_id
47
+ if frame.tags:
48
+ data["tags"] = json.dumps(frame.tags)
49
+
50
+ requests.post(
51
+ f"{endpoint}/api/frames",
52
+ files=files,
53
+ data=data,
54
+ headers={"x-api-key": api_key},
55
+ timeout=10,
56
+ )
57
+ except Exception as e:
58
+ logger.debug(f"Frame upload error: {e}")
59
+
60
+ t = threading.Thread(target=_do_upload, daemon=True)
61
+ t.start()
62
+
63
+
64
+ class StreamManager:
65
+ """Manages sensor and camera streams.
66
+
67
+ All telemetry is relayed over WebSocket. Persistence is a gateway/
68
+ consumer concern — the agent has no per-stream "record" state.
69
+
70
+ Args:
71
+ sensor_hub: SensorHub instance for reading sensors.
72
+ camera_hub: CameraHub instance for capturing frames.
73
+ on_status: Callback for status messages.
74
+ buffer: Optional buffer backend for store-and-forward. When provided,
75
+ telemetry that fails to send over WebSocket is buffered locally
76
+ instead of being lost.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ sensor_hub: Optional["SensorHub"] = None,
82
+ camera_hub: Optional["CameraHub"] = None,
83
+ can_adapters: Optional[List["DetectedCAN"]] = None,
84
+ mavlink_connections: Optional[List["DetectedMAVLink"]] = None,
85
+ on_status: Optional[Callable[[str], None]] = None,
86
+ error_report_fn: Optional[Callable] = None,
87
+ buffer: Optional["BufferBackend"] = None,
88
+ store_frames: bool = False,
89
+ endpoint: str = "",
90
+ api_key: str = "",
91
+ source_id: str = "",
92
+ run_id_fn: Optional[Callable[[], Optional[str]]] = None,
93
+ store_frames_fn: Optional[Callable[[], bool]] = None,
94
+ ):
95
+ self.sensor_hub = sensor_hub
96
+ self.camera_hub = camera_hub
97
+ self.can_adapters = can_adapters or []
98
+ self.mavlink_connections = mavlink_connections or []
99
+ self.on_status = on_status or (lambda x: None)
100
+ self.error_report_fn = error_report_fn
101
+ self.buffer = buffer
102
+ self.store_frames = store_frames
103
+ self._endpoint = endpoint
104
+ self._api_key = api_key
105
+ self._source_id = source_id
106
+ self._run_id_fn = run_id_fn
107
+ self._store_frames_fn = store_frames_fn
108
+
109
+ self._active_streams: Dict[str, asyncio.Task] = {}
110
+ self._active_camera_streams: Dict[str, asyncio.Task] = {}
111
+ self._active_can_streams: Dict[str, asyncio.Task] = {}
112
+ self._can_instances: Dict[str, Any] = {} # channel -> CANAdapter
113
+ self._active_mavlink_streams: Dict[str, asyncio.Task] = {}
114
+ self._mavlink_instances: Dict[str, Any] = {} # conn_string -> MAVLinkAdapter
115
+
116
+ # =========================================================================
117
+ # WebSocket Send with Buffer Fallback
118
+ # =========================================================================
119
+
120
+ async def _send_or_buffer(self, ws, points: List[Dict[str, Any]]) -> bool:
121
+ """Send telemetry over WebSocket, falling back to buffer on failure.
122
+
123
+ Wraps points in the pipeline envelope with version, trace_id, and
124
+ source identifiers for gateway routing.
125
+
126
+ Returns True if sent over WebSocket, False if buffered.
127
+ """
128
+ try:
129
+ envelope = {
130
+ "type": "telemetry",
131
+ "v": 1,
132
+ "trace_id": uuid.uuid4().hex,
133
+ "source_id": self._source_id,
134
+ "points": points,
135
+ "ingested_at": int(time.time() * 1000),
136
+ }
137
+ await ws.send(json.dumps(envelope))
138
+ return True
139
+ except (ConnectionClosed, ConnectionError, OSError):
140
+ if self.buffer and points:
141
+ self.buffer.add(points)
142
+ logger.debug("WebSocket down, buffered %d points", len(points))
143
+ return False
144
+
145
+ # =========================================================================
146
+ # Sensor Streaming
147
+ # =========================================================================
148
+
149
+ async def start_stream(self, data: dict, ws):
150
+ """Start streaming sensor data.
151
+
152
+ Each sensor is sampled at its own declared `sample_rate` (Hz).
153
+ The loop ticks at the fastest sensor's rate and reads each
154
+ sensor only when its interval is up. Sensors with different
155
+ rates coexist correctly — a 100Hz IMU and a 1Hz BME280 in the
156
+ same hub each run at their natural cadence.
157
+
158
+ Args (from dashboard):
159
+ metrics: list - Which metrics to stream (empty = all)
160
+ interval_ms: int - Optional global rate cap in ms. When set,
161
+ no sensor samples faster than 1000/interval_ms Hz. When
162
+ omitted, sensors use their declared rates.
163
+ """
164
+ stream_id = data.get("id", f"stream_{int(time.time())}")
165
+ metrics = data.get("metrics", [])
166
+ interval_ms = data.get("interval_ms") # optional global cap
167
+
168
+ if not self.sensor_hub:
169
+ self.on_status("No sensors configured")
170
+ return
171
+
172
+ sensors = list(self.sensor_hub.sensors)
173
+ if not sensors:
174
+ self.on_status("No sensors available")
175
+ return
176
+
177
+ # Apply optional global rate cap from the dashboard.
178
+ # Each sensor samples at min(sensor.sample_rate, cap_hz).
179
+ cap_hz = None
180
+ if interval_ms and interval_ms > 0:
181
+ cap_hz = 1000.0 / interval_ms
182
+
183
+ def effective_rate(s) -> float:
184
+ rate = getattr(s, "sample_rate", 10.0) or 10.0
185
+ if cap_hz is not None:
186
+ rate = min(rate, cap_hz)
187
+ return max(rate, 0.01) # floor at 0.01Hz (one read per 100s)
188
+
189
+ # Loop tick = fastest sensor's interval
190
+ max_rate = max(effective_rate(s) for s in sensors)
191
+ tick = 1.0 / max_rate
192
+
193
+ metric_count = len(metrics) if metrics else "all"
194
+ cap_note = f" cap={cap_hz:.1f}Hz" if cap_hz is not None else ""
195
+ self.on_status(f"Streaming {metric_count} metrics (tick {tick*1000:.0f}ms{cap_note})")
196
+
197
+ async def stream_loop():
198
+ # Parse metric filters (strip source_id prefix if present)
199
+ filters = set()
200
+ for m in metrics:
201
+ filters.add(m.split(":", 1)[-1] if ":" in m else m)
202
+
203
+ # Per-sensor last-read timestamps
204
+ last_read = {id(s): 0.0 for s in sensors}
205
+
206
+ try:
207
+ while stream_id in self._active_streams:
208
+ now = time.time()
209
+ readings = []
210
+
211
+ for sensor in sensors:
212
+ if getattr(sensor, "_disabled", False):
213
+ continue
214
+ interval = 1.0 / effective_rate(sensor)
215
+ if now - last_read[id(sensor)] >= interval:
216
+ try:
217
+ readings.extend(sensor.read())
218
+ except Exception as e:
219
+ logger.debug(f"Sensor read failed: {sensor.name}: {e}")
220
+ continue
221
+ last_read[id(sensor)] = now
222
+
223
+ if filters:
224
+ readings = [r for r in readings if r.metric in filters]
225
+
226
+ if readings:
227
+ points = [
228
+ {
229
+ "class": "metric",
230
+ "metric": r.metric,
231
+ "value": r.value,
232
+ "timestamp": int(time.time() * 1000),
233
+ }
234
+ for r in readings
235
+ ]
236
+ await self._send_or_buffer(ws, points)
237
+
238
+ await asyncio.sleep(tick)
239
+ except asyncio.CancelledError:
240
+ pass
241
+ except Exception as e:
242
+ self.on_status(f"Stream error: {e}")
243
+ if self.error_report_fn:
244
+ await self.error_report_fn(
245
+ "stream.sensor", str(e), "error"
246
+ )
247
+
248
+ self._active_streams[stream_id] = asyncio.create_task(stream_loop())
249
+
250
+ async def stop_stream(self, data: dict):
251
+ """Stop sensor streaming."""
252
+ stream_id = data.get("id")
253
+
254
+ if stream_id == "*":
255
+ for task in self._active_streams.values():
256
+ task.cancel()
257
+ self._active_streams.clear()
258
+ self.on_status("Stopped all streams")
259
+ elif stream_id in self._active_streams:
260
+ self._active_streams[stream_id].cancel()
261
+ del self._active_streams[stream_id]
262
+ self.on_status("Stopped stream")
263
+
264
+ async def configure_sensor(self, data: dict):
265
+ """Configure a sensor's runtime parameters.
266
+
267
+ Supports changing sample_rate and prefix. Sensors may also
268
+ implement a configure() method for driver-specific settings.
269
+ """
270
+ if not self.sensor_hub:
271
+ return
272
+
273
+ sensor = self.sensor_hub.get_sensor(data.get("sensor"))
274
+ if not sensor:
275
+ return
276
+
277
+ config = data.get("config", {})
278
+
279
+ # Apply generic settings that all sensors support
280
+ if "sample_rate" in config:
281
+ sensor.sample_rate = float(config["sample_rate"])
282
+ if "prefix" in config:
283
+ sensor.prefix = config["prefix"]
284
+
285
+ # Delegate driver-specific config if the sensor supports it
286
+ if hasattr(sensor, "configure"):
287
+ try:
288
+ sensor.configure(**config)
289
+ except Exception as e:
290
+ self.on_status(f"Config failed: {e}")
291
+ return
292
+
293
+ self.on_status(f"Configured {data.get('sensor')}")
294
+
295
+ # =========================================================================
296
+ # Camera Streaming
297
+ # =========================================================================
298
+
299
+ async def start_camera(self, data: dict, ws):
300
+ """Start camera streaming."""
301
+ camera_id = data.get("camera_id")
302
+ frame_rate = data.get("frame_rate", 10)
303
+
304
+ # Allow dashboard to enable frame persistence per-stream
305
+ if data.get("store_frames"):
306
+ self.store_frames = True
307
+
308
+ if not self.camera_hub:
309
+ self.on_status("No cameras configured")
310
+ return
311
+
312
+ camera = self.camera_hub.get_camera(camera_id)
313
+ if not camera:
314
+ self.on_status(f"Camera not found: {camera_id}")
315
+ return
316
+
317
+ if data.get("resolution"):
318
+ camera.resolution = tuple(data["resolution"])
319
+ if data.get("quality"):
320
+ camera.quality = data["quality"]
321
+ camera.frame_rate = frame_rate
322
+
323
+ # Stop existing stream for this camera before starting a new one
324
+ if camera_id in self._active_camera_streams:
325
+ self._active_camera_streams[camera_id].cancel()
326
+ try:
327
+ await self._active_camera_streams[camera_id]
328
+ except (asyncio.CancelledError, Exception):
329
+ pass
330
+ del self._active_camera_streams[camera_id]
331
+
332
+ self.on_status(f"Camera {camera_id} @ {frame_rate}fps")
333
+
334
+ async def camera_loop():
335
+ interval = 1.0 / frame_rate
336
+ try:
337
+ camera.setup()
338
+ while camera_id in self._active_camera_streams:
339
+ frame = camera.capture()
340
+ if frame:
341
+ await ws.send(json.dumps({
342
+ "type": "video_frame",
343
+ "v": 1,
344
+ "trace_id": uuid.uuid4().hex,
345
+ "source_id": self._source_id,
346
+ "camera_id": camera_id,
347
+ "frame": base64.b64encode(frame.data).decode('ascii'),
348
+ "width": frame.width,
349
+ "height": frame.height,
350
+ "timestamp": int(frame.timestamp * 1000),
351
+ }))
352
+
353
+ # Persist frame to storage if recording
354
+ should_store = (
355
+ self._store_frames_fn() if self._store_frames_fn
356
+ else self.store_frames
357
+ )
358
+ if should_store and frame.data:
359
+ try:
360
+ run_id = (
361
+ self._run_id_fn() if self._run_id_fn
362
+ else None
363
+ )
364
+ _upload_frame_async(
365
+ endpoint=self._endpoint,
366
+ api_key=self._api_key,
367
+ frame=frame,
368
+ source_id=self._source_id,
369
+ run_id=run_id,
370
+ )
371
+ except Exception as e:
372
+ logger.debug(f"Frame upload failed: {e}")
373
+
374
+ await asyncio.sleep(interval)
375
+ except asyncio.CancelledError:
376
+ pass
377
+ finally:
378
+ camera.cleanup()
379
+
380
+ self._active_camera_streams[camera_id] = asyncio.create_task(camera_loop())
381
+
382
+ async def stop_camera(self, data: dict):
383
+ """Stop camera streaming."""
384
+ camera_id = data.get("camera_id")
385
+
386
+ if camera_id == "*":
387
+ for task in self._active_camera_streams.values():
388
+ task.cancel()
389
+ self._active_camera_streams.clear()
390
+ elif camera_id in self._active_camera_streams:
391
+ self._active_camera_streams[camera_id].cancel()
392
+ del self._active_camera_streams[camera_id]
393
+
394
+ self.on_status("Stopped camera")
395
+
396
+ async def configure_camera(self, data: dict):
397
+ """Configure a camera."""
398
+ if not self.camera_hub:
399
+ return
400
+
401
+ camera = self.camera_hub.get_camera(data.get("camera_id"))
402
+ if camera:
403
+ config = data.get("config", {})
404
+ if "resolution" in config:
405
+ camera.resolution = tuple(config["resolution"])
406
+ if "quality" in config:
407
+ camera.quality = config["quality"]
408
+ if "frame_rate" in config:
409
+ camera.frame_rate = config["frame_rate"]
410
+
411
+ # =========================================================================
412
+ # CAN Streaming
413
+ # =========================================================================
414
+
415
+ async def start_can_stream(self, data: dict, ws):
416
+ """Start streaming CAN bus data.
417
+
418
+ Args (from dashboard):
419
+ channel: CAN channel to stream (e.g. "can0"). Required.
420
+ dbc_path: Optional path to DBC file for signal decoding.
421
+ interval_ms: Poll interval in ms (default 10).
422
+ store: Whether to persist to ClickHouse.
423
+ """
424
+ channel = data.get("channel")
425
+ if not channel:
426
+ self.on_status("No CAN channel specified")
427
+ return
428
+
429
+ # Find the matching detected adapter
430
+ detected = None
431
+ for c in self.can_adapters:
432
+ if c.channel == channel:
433
+ detected = c
434
+ break
435
+
436
+ if not detected:
437
+ self.on_status(f"CAN channel not found: {channel}")
438
+ return
439
+
440
+ if not detected.is_up:
441
+ self.on_status(f"CAN interface {channel} is down — configure with: sudo ip link set {channel} up type can bitrate 500000")
442
+ return
443
+
444
+ # Stop existing stream for this channel
445
+ if channel in self._active_can_streams:
446
+ self._active_can_streams[channel].cancel()
447
+ try:
448
+ await self._active_can_streams[channel]
449
+ except (asyncio.CancelledError, Exception):
450
+ pass
451
+ del self._active_can_streams[channel]
452
+ self._cleanup_can_instance(channel)
453
+
454
+ dbc_path = data.get("dbc_path")
455
+ interval_ms = data.get("interval_ms", 10)
456
+
457
+ try:
458
+ from plexus.adapters.can import CANAdapter
459
+
460
+ adapter = CANAdapter(
461
+ interface=detected.interface,
462
+ channel=detected.channel,
463
+ bitrate=detected.bitrate or 500000,
464
+ dbc_path=dbc_path,
465
+ )
466
+ adapter.connect()
467
+ self._can_instances[channel] = adapter
468
+ except ImportError:
469
+ self.on_status("python-can not installed. Install with: pip install plexus-python[can]")
470
+ return
471
+ except Exception as e:
472
+ self.on_status(f"CAN connect failed: {e}")
473
+ return
474
+
475
+ self.on_status(f"CAN {channel} streaming @ {interval_ms}ms")
476
+
477
+ async def can_loop():
478
+ loop = asyncio.get_event_loop()
479
+ try:
480
+ while channel in self._active_can_streams:
481
+ # poll() is blocking (0.1s timeout), run in thread pool
482
+ metrics = await loop.run_in_executor(None, adapter.poll)
483
+
484
+ if metrics:
485
+ points = [
486
+ {
487
+ "class": m.data_class,
488
+ "metric": m.name,
489
+ "value": m.value,
490
+ "timestamp": int((m.timestamp or time.time()) * 1000),
491
+ "tags": m.tags or {},
492
+ }
493
+ for m in metrics
494
+ ]
495
+ await self._send_or_buffer(ws, points)
496
+
497
+ await asyncio.sleep(interval_ms / 1000)
498
+ except asyncio.CancelledError:
499
+ pass
500
+ except Exception as e:
501
+ logger.debug(f"CAN stream error on {channel}: {e}")
502
+ self.on_status(f"CAN stream error: {e}")
503
+ if self.error_report_fn:
504
+ await self.error_report_fn(
505
+ f"stream.can.{channel}", str(e), "error"
506
+ )
507
+ finally:
508
+ self._cleanup_can_instance(channel)
509
+
510
+ self._active_can_streams[channel] = asyncio.create_task(can_loop())
511
+
512
+ async def stop_can_stream(self, data: dict):
513
+ """Stop CAN streaming."""
514
+ channel = data.get("channel")
515
+
516
+ if channel == "*":
517
+ for task in self._active_can_streams.values():
518
+ task.cancel()
519
+ self._active_can_streams.clear()
520
+ for ch in list(self._can_instances):
521
+ self._cleanup_can_instance(ch)
522
+ self.on_status("Stopped all CAN streams")
523
+ elif channel in self._active_can_streams:
524
+ self._active_can_streams[channel].cancel()
525
+ del self._active_can_streams[channel]
526
+ self._cleanup_can_instance(channel)
527
+ self.on_status(f"Stopped CAN stream: {channel}")
528
+
529
+ def _cleanup_can_instance(self, channel: str):
530
+ """Disconnect and remove a CAN adapter instance."""
531
+ adapter = self._can_instances.pop(channel, None)
532
+ if adapter:
533
+ try:
534
+ adapter.disconnect()
535
+ except Exception as e:
536
+ logger.debug(f"CAN disconnect error on {channel}: {e}")
537
+
538
+ # =========================================================================
539
+ # MAVLink Streaming
540
+ # =========================================================================
541
+
542
+ async def start_mavlink_stream(self, data: dict, ws):
543
+ """Start streaming MAVLink telemetry.
544
+
545
+ Args (from dashboard):
546
+ connection_string: MAVLink connection (e.g. "udpin:0.0.0.0:14550"). Required.
547
+ interval_ms: Poll interval in ms (default 10).
548
+ include_messages: Optional list of message types to include.
549
+ store: Whether to persist to ClickHouse.
550
+ """
551
+ conn_str = data.get("connection_string")
552
+ if not conn_str:
553
+ self.on_status("No MAVLink connection string specified")
554
+ return
555
+
556
+ # Stop existing stream for this connection
557
+ if conn_str in self._active_mavlink_streams:
558
+ self._active_mavlink_streams[conn_str].cancel()
559
+ try:
560
+ await self._active_mavlink_streams[conn_str]
561
+ except (asyncio.CancelledError, Exception):
562
+ pass
563
+ del self._active_mavlink_streams[conn_str]
564
+ self._cleanup_mavlink_instance(conn_str)
565
+
566
+ interval_ms = data.get("interval_ms", 10)
567
+ include_messages = data.get("include_messages")
568
+
569
+ try:
570
+ from plexus.adapters.mavlink import MAVLinkAdapter
571
+
572
+ adapter = MAVLinkAdapter(
573
+ connection_string=conn_str,
574
+ include_messages=include_messages,
575
+ )
576
+ adapter.connect()
577
+ self._mavlink_instances[conn_str] = adapter
578
+ except ImportError:
579
+ self.on_status("pymavlink not installed. Install with: pip install plexus-python[mavlink]")
580
+ return
581
+ except Exception as e:
582
+ self.on_status(f"MAVLink connect failed: {e}")
583
+ return
584
+
585
+ self.on_status(f"MAVLink {conn_str} streaming @ {interval_ms}ms")
586
+
587
+ async def mavlink_loop():
588
+ loop = asyncio.get_event_loop()
589
+ try:
590
+ while conn_str in self._active_mavlink_streams:
591
+ metrics = await loop.run_in_executor(None, adapter.poll)
592
+
593
+ if metrics:
594
+ points = [
595
+ {
596
+ "class": m.data_class,
597
+ "metric": m.name,
598
+ "value": m.value,
599
+ "timestamp": int((m.timestamp or time.time()) * 1000),
600
+ "tags": m.tags or {},
601
+ }
602
+ for m in metrics
603
+ ]
604
+ await self._send_or_buffer(ws, points)
605
+
606
+ await asyncio.sleep(interval_ms / 1000)
607
+ except asyncio.CancelledError:
608
+ pass
609
+ except Exception as e:
610
+ logger.debug(f"MAVLink stream error on {conn_str}: {e}")
611
+ self.on_status(f"MAVLink stream error: {e}")
612
+ if self.error_report_fn:
613
+ await self.error_report_fn(
614
+ "stream.mavlink", str(e), "error"
615
+ )
616
+ finally:
617
+ self._cleanup_mavlink_instance(conn_str)
618
+
619
+ self._active_mavlink_streams[conn_str] = asyncio.create_task(mavlink_loop())
620
+
621
+ async def stop_mavlink_stream(self, data: dict):
622
+ """Stop MAVLink streaming."""
623
+ conn_str = data.get("connection_string")
624
+
625
+ if conn_str == "*":
626
+ for task in self._active_mavlink_streams.values():
627
+ task.cancel()
628
+ self._active_mavlink_streams.clear()
629
+ for cs in list(self._mavlink_instances):
630
+ self._cleanup_mavlink_instance(cs)
631
+ self.on_status("Stopped all MAVLink streams")
632
+ elif conn_str in self._active_mavlink_streams:
633
+ self._active_mavlink_streams[conn_str].cancel()
634
+ del self._active_mavlink_streams[conn_str]
635
+ self._cleanup_mavlink_instance(conn_str)
636
+ self.on_status(f"Stopped MAVLink stream: {conn_str}")
637
+
638
+ def _cleanup_mavlink_instance(self, conn_str: str):
639
+ """Disconnect and remove a MAVLink adapter instance."""
640
+ adapter = self._mavlink_instances.pop(conn_str, None)
641
+ if adapter:
642
+ try:
643
+ adapter.disconnect()
644
+ except Exception as e:
645
+ logger.debug(f"MAVLink disconnect error on {conn_str}: {e}")
646
+
647
+ async def send_mavlink_command(self, data: dict, ws=None):
648
+ """Send a command to a MAVLink vehicle.
649
+
650
+ Args (from dashboard via gateway):
651
+ connection_string: Which MAVLink connection to target.
652
+ If omitted, uses the first active connection.
653
+ command: "arm", "disarm", or "set_mode"
654
+ mode: Flight mode name (for set_mode)
655
+ force: Force arm/disarm (optional, default false)
656
+ """
657
+ command = data.get("command")
658
+ if not command:
659
+ self.on_status("MAVLink command missing 'command' field")
660
+ return
661
+
662
+ # Find the target adapter
663
+ conn_str = data.get("connection_string")
664
+ adapter = None
665
+ if conn_str:
666
+ adapter = self._mavlink_instances.get(conn_str)
667
+ elif self._mavlink_instances:
668
+ adapter = next(iter(self._mavlink_instances.values()))
669
+
670
+ if not adapter:
671
+ self.on_status("No MAVLink connection available for command")
672
+ return
673
+
674
+ force = data.get("force", False)
675
+ try:
676
+ if command == "arm":
677
+ adapter.arm(force=force)
678
+ self.on_status("MAVLink: Armed")
679
+ elif command == "disarm":
680
+ adapter.disarm(force=force)
681
+ self.on_status("MAVLink: Disarmed")
682
+ elif command == "set_mode":
683
+ mode = data.get("mode", "")
684
+ adapter.set_mode(mode)
685
+ self.on_status(f"MAVLink: Mode set to {mode}")
686
+ else:
687
+ self.on_status(f"Unknown MAVLink command: {command}")
688
+ return
689
+
690
+ # Send ACK back to browser
691
+ if ws:
692
+ import json
693
+ ack = {
694
+ "type": "command_ack",
695
+ "command": command,
696
+ "status": "ok",
697
+ }
698
+ try:
699
+ await ws.send(json.dumps(ack))
700
+ except Exception:
701
+ pass
702
+
703
+ except Exception as e:
704
+ self.on_status(f"MAVLink command failed: {e}")
705
+ if ws:
706
+ import json
707
+ try:
708
+ await ws.send(json.dumps({
709
+ "type": "command_ack",
710
+ "command": command,
711
+ "status": "error",
712
+ "message": str(e),
713
+ }))
714
+ except Exception:
715
+ pass
716
+
717
+ # =========================================================================
718
+ # Cleanup
719
+ # =========================================================================
720
+
721
+ def cancel_all(self):
722
+ """Cancel all active streams."""
723
+ for task in self._active_streams.values():
724
+ task.cancel()
725
+ self._active_streams.clear()
726
+
727
+ for task in self._active_camera_streams.values():
728
+ task.cancel()
729
+ self._active_camera_streams.clear()
730
+
731
+ for task in self._active_can_streams.values():
732
+ task.cancel()
733
+ self._active_can_streams.clear()
734
+
735
+ for ch in list(self._can_instances):
736
+ self._cleanup_can_instance(ch)
737
+
738
+ for task in self._active_mavlink_streams.values():
739
+ task.cancel()
740
+ self._active_mavlink_streams.clear()
741
+
742
+ for cs in list(self._mavlink_instances):
743
+ self._cleanup_mavlink_instance(cs)