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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|