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
|
@@ -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
|
+
}
|