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
@@ -0,0 +1,642 @@
1
+ """
2
+ MAVLink Adapter - MAVLink protocol support for drones and autonomous vehicles
3
+
4
+ This adapter connects to MAVLink-speaking vehicles (ArduPilot, PX4, etc.)
5
+ and emits decoded telemetry as Plexus metrics.
6
+
7
+ Requirements:
8
+ pip install plexus-python[mavlink]
9
+ # or
10
+ pip install pymavlink
11
+
12
+ Usage:
13
+ from plexus.adapters import MAVLinkAdapter
14
+
15
+ # Connect to SITL or companion computer (UDP)
16
+ adapter = MAVLinkAdapter(connection_string="udpin:0.0.0.0:14550")
17
+ adapter.connect()
18
+ for metric in adapter.poll():
19
+ print(f"{metric.name}: {metric.value}")
20
+
21
+ # Connect to flight controller over serial
22
+ adapter = MAVLinkAdapter(
23
+ connection_string="/dev/ttyACM0",
24
+ baud=57600,
25
+ )
26
+
27
+ Supported connections:
28
+ - udpin:host:port (listen for UDP, e.g. from SITL or GCS)
29
+ - udpout:host:port (send UDP to a target)
30
+ - tcp:host:port (TCP client)
31
+ - tcpin:host:port (TCP server)
32
+ - /dev/ttyXXX (serial port)
33
+
34
+ Emitted metrics:
35
+ - attitude.roll, attitude.pitch, attitude.yaw (degrees)
36
+ - gps.lat, gps.lon, gps.alt (degrees, meters)
37
+ - battery.voltage, battery.current, battery.remaining
38
+ - hud.airspeed, hud.groundspeed, hud.heading, hud.throttle, hud.climb
39
+ - mavlink.raw.{MSG_TYPE} - Raw message dict (when emit_raw=True)
40
+ """
41
+
42
+ from typing import Any, Dict, List, Optional, Set
43
+ import re
44
+ import time
45
+ import logging
46
+
47
+ from plexus.adapters.base import (
48
+ ProtocolAdapter,
49
+ AdapterConfig,
50
+ AdapterState,
51
+ Metric,
52
+ ConnectionError,
53
+ ProtocolError,
54
+ )
55
+ from plexus.adapters.registry import register_adapter
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ # Optional dependency — imported at module level so it can be
60
+ # mocked in tests with @patch("plexus.adapters.mavlink.mavutil")
61
+ try:
62
+ from pymavlink import mavutil
63
+ except ImportError:
64
+ mavutil = None # type: ignore[assignment]
65
+
66
+ # Valid connection string patterns
67
+ _CONNECTION_PATTERNS = [
68
+ re.compile(r"^udpin:"),
69
+ re.compile(r"^udpout:"),
70
+ re.compile(r"^udp:"),
71
+ re.compile(r"^tcp:"),
72
+ re.compile(r"^tcpin:"),
73
+ re.compile(r"^/dev/"),
74
+ re.compile(r"^COM\d+$", re.IGNORECASE),
75
+ ]
76
+
77
+
78
+ @register_adapter(
79
+ "mavlink",
80
+ description="MAVLink adapter for drones and autonomous vehicles",
81
+ author="Plexus",
82
+ version="1.0.0",
83
+ requires=["pymavlink"],
84
+ )
85
+ class MAVLinkAdapter(ProtocolAdapter):
86
+ """
87
+ MAVLink protocol adapter.
88
+
89
+ Connects to MAVLink-speaking vehicles and emits telemetry as metrics.
90
+
91
+ Args:
92
+ connection_string: MAVLink connection string (default: "udpin:0.0.0.0:14550")
93
+ baud: Serial baud rate (default: 57600). PX4 typically uses 115200.
94
+ source_system: MAVLink system ID for this adapter (default: 255)
95
+ source_component: MAVLink component ID (default: 0)
96
+ dialect: MAVLink dialect (default: "ardupilotmega")
97
+ dialect_path: Custom dialect XML path (optional)
98
+ include_messages: Only process these message types (optional)
99
+ exclude_messages: Skip these message types (optional)
100
+ emit_raw: Whether to emit raw message dicts (default: False)
101
+ emit_decoded: Whether to emit decoded metrics (default: True)
102
+ raw_prefix: Prefix for raw metrics (default: "mavlink.raw")
103
+ request_streams: Whether to request data streams on connect (default: True)
104
+ stream_rate_hz: Requested data stream rate (default: 4)
105
+ source_id: Source ID for metrics (optional)
106
+
107
+ Example:
108
+ adapter = MAVLinkAdapter(
109
+ connection_string="udpin:0.0.0.0:14550",
110
+ include_messages=["ATTITUDE", "GPS_RAW_INT"],
111
+ )
112
+
113
+ with adapter:
114
+ while True:
115
+ for metric in adapter.poll():
116
+ print(f"{metric.name} = {metric.value}")
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ connection_string: str = "udpin:0.0.0.0:14550",
122
+ baud: int = 57600,
123
+ source_system: int = 255,
124
+ source_component: int = 0,
125
+ dialect: str = "ardupilotmega",
126
+ dialect_path: Optional[str] = None,
127
+ include_messages: Optional[List[str]] = None,
128
+ exclude_messages: Optional[List[str]] = None,
129
+ emit_raw: bool = False,
130
+ emit_decoded: bool = True,
131
+ raw_prefix: str = "mavlink.raw",
132
+ request_streams: bool = True,
133
+ stream_rate_hz: int = 4,
134
+ source_id: Optional[str] = None,
135
+ **kwargs,
136
+ ):
137
+ config = AdapterConfig(
138
+ name="mavlink",
139
+ params={
140
+ "connection_string": connection_string,
141
+ "baud": baud,
142
+ "source_system": source_system,
143
+ "source_component": source_component,
144
+ "dialect": dialect,
145
+ **kwargs,
146
+ },
147
+ )
148
+ super().__init__(config)
149
+
150
+ self.connection_string = connection_string
151
+ self.baud = baud
152
+ self.source_system = source_system
153
+ self.source_component = source_component
154
+ self.dialect = dialect
155
+ self.dialect_path = dialect_path
156
+ self.include_messages: Optional[Set[str]] = (
157
+ set(include_messages) if include_messages else None
158
+ )
159
+ self.exclude_messages: Set[str] = set(exclude_messages) if exclude_messages else set()
160
+ self.emit_raw = emit_raw
161
+ self.emit_decoded = emit_decoded
162
+ self.raw_prefix = raw_prefix
163
+ self.request_streams = request_streams
164
+ self.stream_rate_hz = stream_rate_hz
165
+ self._source_id = source_id
166
+
167
+ self._conn: Optional[Any] = None # mavutil.mavlink_connection instance
168
+ self._vehicle_sysid: Optional[int] = None
169
+ self._vehicle_compid: Optional[int] = None
170
+
171
+ def validate_config(self) -> bool:
172
+ """Validate adapter configuration."""
173
+ if not self.connection_string:
174
+ raise ValueError("MAVLink connection_string is required")
175
+
176
+ if not any(p.match(self.connection_string) for p in _CONNECTION_PATTERNS):
177
+ raise ValueError(
178
+ f"Invalid connection string '{self.connection_string}'. "
179
+ f"Expected format: udpin:host:port, tcp:host:port, /dev/ttyXXX, or COMx"
180
+ )
181
+
182
+ return True
183
+
184
+ def connect(self) -> bool:
185
+ """Connect to MAVLink vehicle."""
186
+ if mavutil is None:
187
+ self._set_state(AdapterState.ERROR, "pymavlink not installed")
188
+ raise ConnectionError(
189
+ "pymavlink is required. Install with: pip install plexus-python[mavlink]"
190
+ )
191
+
192
+ try:
193
+ self._set_state(AdapterState.CONNECTING)
194
+ logger.info(f"Connecting to MAVLink: {self.connection_string}")
195
+
196
+ # Set dialect if custom path provided
197
+ if self.dialect_path:
198
+ import os
199
+ os.environ["MAVLINK20"] = "1"
200
+ mavutil.set_dialect(self.dialect)
201
+
202
+ # Create connection
203
+ self._conn = mavutil.mavlink_connection(
204
+ self.connection_string,
205
+ baud=self.baud,
206
+ source_system=self.source_system,
207
+ source_component=self.source_component,
208
+ dialect=self.dialect,
209
+ )
210
+
211
+ # Wait for heartbeat (confirms vehicle is alive)
212
+ logger.info("Waiting for heartbeat...")
213
+ msg = self._conn.wait_heartbeat(timeout=10)
214
+ if msg is None:
215
+ self._set_state(AdapterState.ERROR, "No heartbeat received")
216
+ raise ConnectionError(
217
+ "No heartbeat received within 10s. Is the vehicle connected?"
218
+ )
219
+
220
+ self._vehicle_sysid = msg.get_srcSystem()
221
+ self._vehicle_compid = msg.get_srcComponent()
222
+ logger.info(
223
+ f"Heartbeat from system {self._vehicle_sysid}, "
224
+ f"component {self._vehicle_compid}"
225
+ )
226
+
227
+ # Request data streams
228
+ if self.request_streams:
229
+ self._request_data_streams()
230
+
231
+ self._set_state(AdapterState.CONNECTED)
232
+ logger.info(f"Connected to MAVLink vehicle (sysid={self._vehicle_sysid})")
233
+ return True
234
+
235
+ except ConnectionError:
236
+ raise
237
+ except Exception as e:
238
+ self._set_state(AdapterState.ERROR, str(e))
239
+ logger.error(f"Failed to connect to MAVLink: {e}")
240
+ raise ConnectionError(f"MAVLink connection failed: {e}")
241
+
242
+ def _request_data_streams(self) -> None:
243
+ """Request data streams from the vehicle."""
244
+ if not self._conn:
245
+ return
246
+
247
+ try:
248
+ self._conn.mav.request_data_stream_send(
249
+ self._conn.target_system,
250
+ self._conn.target_component,
251
+ mavutil.mavlink.MAV_DATA_STREAM_ALL,
252
+ self.stream_rate_hz,
253
+ 1, # start sending
254
+ )
255
+ logger.debug(f"Requested all data streams at {self.stream_rate_hz} Hz")
256
+ except Exception as e:
257
+ logger.warning(f"Failed to request data streams: {e}")
258
+
259
+ def disconnect(self) -> None:
260
+ """Disconnect from MAVLink vehicle."""
261
+ if self._conn:
262
+ try:
263
+ self._conn.close()
264
+ logger.info("Disconnected from MAVLink")
265
+ except Exception as e:
266
+ logger.warning(f"Error closing MAVLink connection: {e}")
267
+ finally:
268
+ self._conn = None
269
+
270
+ self._vehicle_sysid = None
271
+ self._vehicle_compid = None
272
+ self._set_state(AdapterState.DISCONNECTED)
273
+
274
+ def poll(self) -> List[Metric]:
275
+ """
276
+ Poll for MAVLink messages and return metrics.
277
+
278
+ Returns:
279
+ List of Metric objects for raw messages and/or decoded telemetry.
280
+ """
281
+ if not self._conn:
282
+ return []
283
+
284
+ metrics: List[Metric] = []
285
+
286
+ try:
287
+ msg = self._conn.recv_match(blocking=False)
288
+
289
+ if msg is None:
290
+ return []
291
+
292
+ msg_type = msg.get_type()
293
+
294
+ # Skip bad data
295
+ if msg_type == "BAD_DATA":
296
+ return []
297
+
298
+ # Apply filters
299
+ if self.include_messages and msg_type not in self.include_messages:
300
+ return []
301
+ if msg_type in self.exclude_messages:
302
+ return []
303
+
304
+ timestamp = time.time()
305
+ tags = self._base_tags(msg)
306
+
307
+ # Emit raw message metric
308
+ if self.emit_raw:
309
+ raw_metric = self._create_raw_metric(msg, msg_type, timestamp, tags)
310
+ metrics.append(raw_metric)
311
+
312
+ # Emit decoded metrics
313
+ if self.emit_decoded:
314
+ decoded_metrics = self._decode_message(msg, msg_type, timestamp, tags)
315
+ metrics.extend(decoded_metrics)
316
+
317
+ except Exception as e:
318
+ logger.error(f"Error reading MAVLink message: {e}")
319
+ raise ProtocolError(f"MAVLink read error: {e}")
320
+
321
+ return metrics
322
+
323
+ def _base_tags(self, msg: Any) -> Dict[str, str]:
324
+ """Create base tags for a message."""
325
+ tags: Dict[str, str] = {
326
+ "message_type": msg.get_type(),
327
+ }
328
+ try:
329
+ tags["message_id"] = str(msg.get_msgId())
330
+ tags["system_id"] = str(msg.get_srcSystem())
331
+ tags["component_id"] = str(msg.get_srcComponent())
332
+ except Exception:
333
+ pass
334
+ return tags
335
+
336
+ def _create_raw_metric(
337
+ self, msg: Any, msg_type: str, timestamp: float, tags: Dict[str, str]
338
+ ) -> Metric:
339
+ """Create a raw message metric containing the full message dict."""
340
+ metric_name = f"{self.raw_prefix}.{msg_type}"
341
+
342
+ # Convert message to dict
343
+ msg_dict = msg.to_dict()
344
+ msg_dict.pop("mavpackettype", None)
345
+
346
+ return Metric(
347
+ name=metric_name,
348
+ value=msg_dict,
349
+ timestamp=timestamp,
350
+ tags=tags,
351
+ source_id=self._source_id,
352
+ )
353
+
354
+ def _decode_message(
355
+ self, msg: Any, msg_type: str, timestamp: float, tags: Dict[str, str]
356
+ ) -> List[Metric]:
357
+ """Decode a MAVLink message into human-readable metrics."""
358
+ decoder = _MESSAGE_DECODERS.get(msg_type)
359
+ if not decoder:
360
+ return []
361
+
362
+ try:
363
+ return decoder(self, msg, timestamp, tags)
364
+ except Exception as e:
365
+ logger.debug(f"Could not decode {msg_type}: {e}")
366
+ return []
367
+
368
+ def _metric(
369
+ self,
370
+ name: str,
371
+ value: Any,
372
+ timestamp: float,
373
+ tags: Dict[str, str],
374
+ unit: Optional[str] = None,
375
+ ) -> Metric:
376
+ """Helper to create a metric with optional unit tag."""
377
+ metric_tags = dict(tags)
378
+ if unit:
379
+ metric_tags["unit"] = unit
380
+ return Metric(
381
+ name=name,
382
+ value=value,
383
+ timestamp=timestamp,
384
+ tags=metric_tags,
385
+ source_id=self._source_id,
386
+ )
387
+
388
+ # =========================================================================
389
+ # Command sending
390
+ # =========================================================================
391
+
392
+ def send_command(
393
+ self,
394
+ command: int,
395
+ param1: float = 0,
396
+ param2: float = 0,
397
+ param3: float = 0,
398
+ param4: float = 0,
399
+ param5: float = 0,
400
+ param6: float = 0,
401
+ param7: float = 0,
402
+ ) -> bool:
403
+ """
404
+ Send a MAVLink command (COMMAND_LONG).
405
+
406
+ Args:
407
+ command: MAVLink command ID (MAV_CMD_*)
408
+ param1-param7: Command parameters
409
+
410
+ Returns:
411
+ True if sent successfully
412
+ """
413
+ if not self._conn:
414
+ raise ProtocolError("Not connected to MAVLink vehicle")
415
+
416
+ try:
417
+ self._conn.mav.command_long_send(
418
+ self._conn.target_system,
419
+ self._conn.target_component,
420
+ command,
421
+ 0, # confirmation
422
+ param1, param2, param3, param4, param5, param6, param7,
423
+ )
424
+ logger.debug(f"Sent command {command}")
425
+ return True
426
+ except Exception as e:
427
+ logger.error(f"Failed to send command: {e}")
428
+ raise ProtocolError(f"MAVLink command error: {e}")
429
+
430
+ def arm(self, force: bool = False) -> bool:
431
+ """Arm the vehicle."""
432
+ if mavutil is None:
433
+ raise ProtocolError("pymavlink not installed")
434
+ return self.send_command(
435
+ mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
436
+ param1=1,
437
+ param2=21196 if force else 0,
438
+ )
439
+
440
+ def disarm(self, force: bool = False) -> bool:
441
+ """Disarm the vehicle."""
442
+ if mavutil is None:
443
+ raise ProtocolError("pymavlink not installed")
444
+ return self.send_command(
445
+ mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
446
+ param1=0,
447
+ param2=21196 if force else 0,
448
+ )
449
+
450
+ def set_mode(self, mode: str) -> bool:
451
+ """
452
+ Set the vehicle flight mode by name.
453
+
454
+ Args:
455
+ mode: Mode name (e.g., "STABILIZE", "GUIDED", "AUTO", "LOITER")
456
+
457
+ Returns:
458
+ True if sent successfully
459
+ """
460
+ if not self._conn:
461
+ raise ProtocolError("Not connected to MAVLink vehicle")
462
+
463
+ try:
464
+ mode_id = self._conn.mode_mapping().get(mode.upper())
465
+ if mode_id is None:
466
+ available = list(self._conn.mode_mapping().keys())
467
+ raise ValueError(
468
+ f"Unknown mode '{mode}'. Available: {', '.join(available)}"
469
+ )
470
+
471
+ self._conn.set_mode(mode_id)
472
+ logger.debug(f"Set mode to {mode} (id={mode_id})")
473
+ return True
474
+ except ValueError:
475
+ raise
476
+ except Exception as e:
477
+ logger.error(f"Failed to set mode: {e}")
478
+ raise ProtocolError(f"MAVLink set_mode error: {e}")
479
+
480
+ @property
481
+ def stats(self) -> Dict[str, Any]:
482
+ """Get adapter statistics including MAVLink-specific info."""
483
+ base_stats = super().stats
484
+ base_stats.update({
485
+ "connection_string": self.connection_string,
486
+ "baud": self.baud,
487
+ "vehicle_sysid": self._vehicle_sysid,
488
+ "vehicle_compid": self._vehicle_compid,
489
+ "dialect": self.dialect,
490
+ })
491
+ return base_stats
492
+
493
+
494
+ # =============================================================================
495
+ # Message Decoders
496
+ # =============================================================================
497
+ # Each decoder takes (adapter, msg, timestamp, tags) and returns List[Metric].
498
+
499
+ def _decode_heartbeat(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
500
+ return [
501
+ adapter._metric("heartbeat.type", msg.type, ts, tags),
502
+ adapter._metric("heartbeat.autopilot", msg.autopilot, ts, tags),
503
+ adapter._metric("heartbeat.base_mode", msg.base_mode, ts, tags),
504
+ adapter._metric("heartbeat.custom_mode", msg.custom_mode, ts, tags),
505
+ adapter._metric("heartbeat.system_status", msg.system_status, ts, tags),
506
+ ]
507
+
508
+
509
+ def _decode_attitude(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
510
+ import math
511
+ return [
512
+ adapter._metric("attitude.roll", round(math.degrees(msg.roll), 2), ts, tags, "deg"),
513
+ adapter._metric("attitude.pitch", round(math.degrees(msg.pitch), 2), ts, tags, "deg"),
514
+ adapter._metric("attitude.yaw", round(math.degrees(msg.yaw), 2), ts, tags, "deg"),
515
+ adapter._metric("attitude.rollspeed", round(math.degrees(msg.rollspeed), 2), ts, tags, "deg/s"),
516
+ adapter._metric("attitude.pitchspeed", round(math.degrees(msg.pitchspeed), 2), ts, tags, "deg/s"),
517
+ adapter._metric("attitude.yawspeed", round(math.degrees(msg.yawspeed), 2), ts, tags, "deg/s"),
518
+ ]
519
+
520
+
521
+ def _decode_gps_raw_int(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
522
+ return [
523
+ adapter._metric("gps.lat", msg.lat / 1e7, ts, tags, "deg"),
524
+ adapter._metric("gps.lon", msg.lon / 1e7, ts, tags, "deg"),
525
+ adapter._metric("gps.alt", msg.alt / 1000.0, ts, tags, "m"),
526
+ adapter._metric("gps.fix_type", msg.fix_type, ts, tags),
527
+ adapter._metric("gps.satellites", msg.satellites_visible, ts, tags),
528
+ adapter._metric("gps.eph", msg.eph / 100.0, ts, tags, "m"),
529
+ adapter._metric("gps.epv", msg.epv / 100.0, ts, tags, "m"),
530
+ adapter._metric("gps.vel", msg.vel / 100.0, ts, tags, "m/s"),
531
+ ]
532
+
533
+
534
+ def _decode_sys_status(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
535
+ return [
536
+ adapter._metric("sys.voltage", msg.voltage_battery / 1000.0, ts, tags, "V"),
537
+ adapter._metric("sys.current", msg.current_battery / 100.0, ts, tags, "A"),
538
+ adapter._metric("sys.battery_remaining", msg.battery_remaining, ts, tags, "%"),
539
+ adapter._metric("sys.drop_rate", msg.drop_rate_comm / 100.0, ts, tags, "%"),
540
+ adapter._metric("sys.errors_comm", msg.errors_comm, ts, tags),
541
+ ]
542
+
543
+
544
+ def _decode_vfr_hud(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
545
+ return [
546
+ adapter._metric("hud.airspeed", round(msg.airspeed, 2), ts, tags, "m/s"),
547
+ adapter._metric("hud.groundspeed", round(msg.groundspeed, 2), ts, tags, "m/s"),
548
+ adapter._metric("hud.heading", msg.heading, ts, tags, "deg"),
549
+ adapter._metric("hud.throttle", msg.throttle, ts, tags, "%"),
550
+ adapter._metric("hud.alt", round(msg.alt, 2), ts, tags, "m"),
551
+ adapter._metric("hud.climb", round(msg.climb, 2), ts, tags, "m/s"),
552
+ ]
553
+
554
+
555
+ def _decode_rc_channels(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
556
+ metrics = [
557
+ adapter._metric("rc.rssi", msg.rssi, ts, tags),
558
+ adapter._metric("rc.chancount", msg.chancount, ts, tags),
559
+ ]
560
+ # Emit first 8 channels (most common)
561
+ for i in range(1, min(9, msg.chancount + 1)):
562
+ val = getattr(msg, f"chan{i}_raw", 65535)
563
+ if val != 65535: # 65535 = unused
564
+ metrics.append(
565
+ adapter._metric(f"rc.ch{i}", val, ts, tags, "us")
566
+ )
567
+ return metrics
568
+
569
+
570
+ def _decode_battery_status(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
571
+ metrics = [
572
+ adapter._metric("battery.current", msg.current_battery / 100.0, ts, tags, "A"),
573
+ adapter._metric("battery.consumed", msg.current_consumed, ts, tags, "mAh"),
574
+ adapter._metric("battery.remaining", msg.battery_remaining, ts, tags, "%"),
575
+ adapter._metric("battery.temperature", msg.temperature / 100.0, ts, tags, "degC"),
576
+ ]
577
+ # First cell voltage (if present)
578
+ if msg.voltages[0] != 65535:
579
+ total_mv = sum(v for v in msg.voltages if v != 65535)
580
+ metrics.append(
581
+ adapter._metric("battery.voltage", total_mv / 1000.0, ts, tags, "V")
582
+ )
583
+ return metrics
584
+
585
+
586
+ def _decode_global_position_int(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
587
+ return [
588
+ adapter._metric("position.lat", msg.lat / 1e7, ts, tags, "deg"),
589
+ adapter._metric("position.lon", msg.lon / 1e7, ts, tags, "deg"),
590
+ adapter._metric("position.alt", msg.alt / 1000.0, ts, tags, "m"),
591
+ adapter._metric("position.relative_alt", msg.relative_alt / 1000.0, ts, tags, "m"),
592
+ adapter._metric("position.vx", msg.vx / 100.0, ts, tags, "m/s"),
593
+ adapter._metric("position.vy", msg.vy / 100.0, ts, tags, "m/s"),
594
+ adapter._metric("position.vz", msg.vz / 100.0, ts, tags, "m/s"),
595
+ adapter._metric("position.heading", msg.hdg / 100.0, ts, tags, "deg"),
596
+ ]
597
+
598
+
599
+ def _decode_local_position_ned(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
600
+ return [
601
+ adapter._metric("local.x", round(msg.x, 3), ts, tags, "m"),
602
+ adapter._metric("local.y", round(msg.y, 3), ts, tags, "m"),
603
+ adapter._metric("local.z", round(msg.z, 3), ts, tags, "m"),
604
+ adapter._metric("local.vx", round(msg.vx, 3), ts, tags, "m/s"),
605
+ adapter._metric("local.vy", round(msg.vy, 3), ts, tags, "m/s"),
606
+ adapter._metric("local.vz", round(msg.vz, 3), ts, tags, "m/s"),
607
+ ]
608
+
609
+
610
+ def _decode_servo_output_raw(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
611
+ metrics = []
612
+ for i in range(1, 9):
613
+ val = getattr(msg, f"servo{i}_raw", 0)
614
+ if val > 0:
615
+ metrics.append(
616
+ adapter._metric(f"servo.ch{i}", val, ts, tags, "us")
617
+ )
618
+ return metrics
619
+
620
+
621
+ def _decode_statustext(adapter: MAVLinkAdapter, msg, ts, tags) -> List[Metric]:
622
+ text = msg.text.rstrip("\x00") if hasattr(msg.text, "rstrip") else str(msg.text)
623
+ return [
624
+ adapter._metric("status.text", text, ts, tags),
625
+ adapter._metric("status.severity", msg.severity, ts, tags),
626
+ ]
627
+
628
+
629
+ # Decoder lookup table
630
+ _MESSAGE_DECODERS = {
631
+ "HEARTBEAT": _decode_heartbeat,
632
+ "ATTITUDE": _decode_attitude,
633
+ "GPS_RAW_INT": _decode_gps_raw_int,
634
+ "SYS_STATUS": _decode_sys_status,
635
+ "VFR_HUD": _decode_vfr_hud,
636
+ "RC_CHANNELS": _decode_rc_channels,
637
+ "BATTERY_STATUS": _decode_battery_status,
638
+ "GLOBAL_POSITION_INT": _decode_global_position_int,
639
+ "LOCAL_POSITION_NED": _decode_local_position_ned,
640
+ "SERVO_OUTPUT_RAW": _decode_servo_output_raw,
641
+ "STATUSTEXT": _decode_statustext,
642
+ }