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/adapters/ble.py ADDED
@@ -0,0 +1,257 @@
1
+ """
2
+ BLE Relay Adapter - Bluetooth Low Energy gateway support
3
+
4
+ This adapter scans for BLE peripherals and reads GATT characteristics,
5
+ acting as a relay/gateway for BLE-only devices that can't reach the
6
+ internet directly. Ideal for RPi or Linux gateways.
7
+
8
+ Requirements:
9
+ pip install plexus-python[ble]
10
+ # or
11
+ pip install bleak
12
+
13
+ Usage:
14
+ from plexus.adapters import BLERelayAdapter
15
+
16
+ adapter = BLERelayAdapter(
17
+ service_uuids=["181A"], # Environmental Sensing
18
+ scan_duration=5.0,
19
+ )
20
+ adapter.connect()
21
+ for metric in adapter.poll():
22
+ print(f"{metric.name}: {metric.value}")
23
+
24
+ Emitted metrics:
25
+ - {source_prefix}.{device_name}.{char_uuid} - Raw characteristic values
26
+ - {source_prefix}.{device_name}.rssi - Signal strength
27
+ """
28
+
29
+ from typing import Any, Dict, List, Optional
30
+ import asyncio
31
+ import logging
32
+ import struct
33
+ import time
34
+
35
+ from plexus.adapters.base import (
36
+ ProtocolAdapter,
37
+ AdapterConfig,
38
+ AdapterState,
39
+ Metric,
40
+ ConnectionError,
41
+ ProtocolError,
42
+ )
43
+ from plexus.adapters.registry import register_adapter
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ try:
48
+ import bleak
49
+ from bleak import BleakScanner, BleakClient
50
+ except ImportError:
51
+ bleak = None # type: ignore[assignment]
52
+ BleakScanner = None # type: ignore[assignment,misc]
53
+ BleakClient = None # type: ignore[assignment,misc]
54
+
55
+ # Well-known GATT characteristic UUIDs → human-readable names
56
+ _KNOWN_CHARACTERISTICS: Dict[str, str] = {
57
+ "00002a6e-0000-1000-8000-00805f9b34fb": "temperature",
58
+ "00002a6f-0000-1000-8000-00805f9b34fb": "humidity",
59
+ "00002a6d-0000-1000-8000-00805f9b34fb": "pressure",
60
+ "00002a19-0000-1000-8000-00805f9b34fb": "battery_level",
61
+ "00002a1c-0000-1000-8000-00805f9b34fb": "temperature_measurement",
62
+ }
63
+
64
+
65
+ def _slugify(name: str) -> str:
66
+ """Convert a BLE device name to a metric-safe slug."""
67
+ return name.lower().replace(" ", "_").replace("-", "_")[:32]
68
+
69
+
70
+ def _try_decode_value(data: bytes, char_uuid: str) -> Any:
71
+ """Attempt to decode a characteristic value to a numeric type."""
72
+ uuid_lower = char_uuid.lower()
73
+
74
+ # Battery level is a single uint8 percentage
75
+ if uuid_lower == "00002a19-0000-1000-8000-00805f9b34fb":
76
+ return data[0] if len(data) >= 1 else None
77
+
78
+ # Temperature (sint16, 0.01 degC resolution per BLE SIG)
79
+ if uuid_lower == "00002a6e-0000-1000-8000-00805f9b34fb":
80
+ if len(data) >= 2:
81
+ raw = struct.unpack("<h", data[:2])[0]
82
+ return raw / 100.0
83
+ return None
84
+
85
+ # Humidity (uint16, 0.01% resolution)
86
+ if uuid_lower == "00002a6f-0000-1000-8000-00805f9b34fb":
87
+ if len(data) >= 2:
88
+ raw = struct.unpack("<H", data[:2])[0]
89
+ return raw / 100.0
90
+ return None
91
+
92
+ # Pressure (uint32, 0.1 Pa resolution)
93
+ if uuid_lower == "00002a6d-0000-1000-8000-00805f9b34fb":
94
+ if len(data) >= 4:
95
+ raw = struct.unpack("<I", data[:4])[0]
96
+ return raw / 10.0
97
+ return None
98
+
99
+ # Generic: try to interpret as a number
100
+ if len(data) == 1:
101
+ return data[0]
102
+ if len(data) == 2:
103
+ return struct.unpack("<h", data[:2])[0]
104
+ if len(data) == 4:
105
+ return struct.unpack("<f", data[:4])[0]
106
+
107
+ # Fall back to hex string
108
+ return data.hex()
109
+
110
+
111
+ @register_adapter(
112
+ "ble",
113
+ description="BLE relay adapter for gateway devices",
114
+ author="Plexus",
115
+ version="1.0.0",
116
+ requires=["bleak"],
117
+ )
118
+ class BLERelayAdapter(ProtocolAdapter):
119
+ """
120
+ BLE Relay protocol adapter.
121
+
122
+ Scans for BLE peripherals, connects, and reads GATT characteristics.
123
+ Designed for gateway scenarios where a Linux host (e.g., RPi) relays
124
+ BLE sensor data to Plexus cloud.
125
+
126
+ Args:
127
+ service_uuids: List of GATT service UUIDs to filter for (short or full form).
128
+ scan_duration: How long to scan for devices per poll cycle (seconds).
129
+ name_filter: Only connect to devices whose name contains this string.
130
+ source_prefix: Prefix for emitted metric names.
131
+ read_timeout: Timeout for reading each characteristic (seconds).
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ service_uuids: Optional[List[str]] = None,
137
+ scan_duration: float = 5.0,
138
+ name_filter: Optional[str] = None,
139
+ source_prefix: str = "ble",
140
+ read_timeout: float = 10.0,
141
+ **kwargs: Any,
142
+ ):
143
+ if bleak is None:
144
+ raise ImportError(
145
+ "BLE adapter requires 'bleak'. Install with: pip install plexus-python[ble]"
146
+ )
147
+
148
+ config = AdapterConfig(
149
+ name="ble",
150
+ params={
151
+ "service_uuids": service_uuids,
152
+ "scan_duration": scan_duration,
153
+ "name_filter": name_filter,
154
+ "source_prefix": source_prefix,
155
+ **kwargs,
156
+ },
157
+ )
158
+ super().__init__(config)
159
+ self.service_uuids = service_uuids or []
160
+ self.scan_duration = scan_duration
161
+ self.name_filter = name_filter
162
+ self.source_prefix = source_prefix
163
+ self.read_timeout = read_timeout
164
+ self._scanner: Any = None
165
+
166
+ def connect(self) -> bool:
167
+ """Initialize the BLE scanner."""
168
+ try:
169
+ self._scanner = BleakScanner(
170
+ service_uuids=self.service_uuids if self.service_uuids else None,
171
+ )
172
+ self._set_state(AdapterState.CONNECTED)
173
+ logger.info("BLE adapter ready (filter: %s)", self.name_filter or "none")
174
+ return True
175
+ except Exception as e:
176
+ self._set_state(AdapterState.ERROR, str(e))
177
+ raise ConnectionError(f"Failed to initialize BLE scanner: {e}") from e
178
+
179
+ def disconnect(self) -> None:
180
+ """Stop the BLE scanner."""
181
+ self._scanner = None
182
+ self._set_state(AdapterState.DISCONNECTED)
183
+
184
+ def poll(self) -> List[Metric]:
185
+ """Scan for BLE devices and read their characteristics."""
186
+ if not self._scanner:
187
+ raise ProtocolError("BLE adapter not connected")
188
+
189
+ try:
190
+ return asyncio.run(self._async_poll())
191
+ except RuntimeError:
192
+ # If there's already an event loop running, create a new one
193
+ loop = asyncio.new_event_loop()
194
+ try:
195
+ return loop.run_until_complete(self._async_poll())
196
+ finally:
197
+ loop.close()
198
+
199
+ async def _async_poll(self) -> List[Metric]:
200
+ """Async implementation of the poll cycle."""
201
+ metrics: List[Metric] = []
202
+ now = time.time()
203
+
204
+ # Scan for devices
205
+ devices = await BleakScanner.discover(
206
+ timeout=self.scan_duration,
207
+ service_uuids=self.service_uuids if self.service_uuids else None,
208
+ )
209
+
210
+ for device in devices:
211
+ # Apply name filter
212
+ if self.name_filter and device.name:
213
+ if self.name_filter.lower() not in device.name.lower():
214
+ continue
215
+ elif self.name_filter and not device.name:
216
+ continue
217
+
218
+ device_name = _slugify(device.name or device.address.replace(":", ""))
219
+
220
+ # Emit RSSI as a metric
221
+ if device.rssi is not None:
222
+ metrics.append(Metric(
223
+ name=f"{self.source_prefix}.{device_name}.rssi",
224
+ value=device.rssi,
225
+ timestamp=now,
226
+ ))
227
+
228
+ # Connect and read characteristics
229
+ try:
230
+ async with BleakClient(device.address, timeout=self.read_timeout) as client:
231
+ for service in client.services:
232
+ for char in service.characteristics:
233
+ if "read" not in char.properties:
234
+ continue
235
+ try:
236
+ data = await client.read_gatt_char(char.uuid)
237
+ value = _try_decode_value(data, char.uuid)
238
+ if value is not None:
239
+ char_name = _KNOWN_CHARACTERISTICS.get(
240
+ char.uuid.lower(),
241
+ char.uuid.split("-")[0],
242
+ )
243
+ metrics.append(Metric(
244
+ name=f"{self.source_prefix}.{device_name}.{char_name}",
245
+ value=value,
246
+ timestamp=now,
247
+ ))
248
+ except Exception as e:
249
+ logger.debug(
250
+ "Failed to read %s from %s: %s",
251
+ char.uuid, device_name, e,
252
+ )
253
+ except Exception as e:
254
+ logger.warning("Failed to connect to %s: %s", device_name, e)
255
+
256
+ logger.debug("BLE poll: %d metrics from %d devices", len(metrics), len(devices))
257
+ return metrics
plexus/adapters/can.py ADDED
@@ -0,0 +1,439 @@
1
+ """
2
+ CAN Bus Adapter - CAN protocol support with DBC decoding
3
+
4
+ This adapter reads CAN bus data and emits both raw frames and decoded
5
+ signals when a DBC file is provided.
6
+
7
+ Requirements:
8
+ pip install plexus-python[can]
9
+ # or
10
+ pip install python-can cantools
11
+
12
+ Usage:
13
+ from plexus.adapters import CANAdapter
14
+
15
+ # Basic usage with virtual CAN
16
+ adapter = CANAdapter(interface="socketcan", channel="vcan0")
17
+ adapter.connect()
18
+ for metric in adapter.poll():
19
+ print(f"{metric.name}: {metric.value}")
20
+
21
+ # With DBC file for signal decoding
22
+ adapter = CANAdapter(
23
+ interface="socketcan",
24
+ channel="can0",
25
+ dbc_path="/path/to/vehicle.dbc"
26
+ )
27
+
28
+ Supported interfaces:
29
+ - socketcan (Linux)
30
+ - pcan (Peak CAN)
31
+ - vector (Vector CANalyzer)
32
+ - kvaser (Kvaser)
33
+ - slcan (Serial CAN)
34
+ - virtual (Testing)
35
+
36
+ Emitted metrics:
37
+ - can.raw.0x{id} - Raw frame data as hex string
38
+ - {signal_name} - Decoded signals from DBC (e.g., "engine_rpm", "coolant_temp")
39
+ """
40
+
41
+ from typing import Any, Dict, List, Optional
42
+ import time
43
+ import logging
44
+
45
+ from plexus.adapters.base import (
46
+ ProtocolAdapter,
47
+ AdapterConfig,
48
+ AdapterState,
49
+ Metric,
50
+ ConnectionError,
51
+ ProtocolError,
52
+ )
53
+ from plexus.adapters.registry import register_adapter
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Optional dependencies — imported at module level so they can be
58
+ # mocked in tests with @patch("plexus.adapters.can.can") etc.
59
+ try:
60
+ import can
61
+ except ImportError:
62
+ can = None # type: ignore[assignment]
63
+
64
+ try:
65
+ import cantools
66
+ except ImportError:
67
+ cantools = None # type: ignore[assignment]
68
+
69
+
70
+ @register_adapter(
71
+ "can",
72
+ description="CAN bus adapter with DBC signal decoding",
73
+ author="Plexus",
74
+ version="1.0.0",
75
+ requires=["python-can", "cantools"],
76
+ )
77
+ class CANAdapter(ProtocolAdapter):
78
+ """
79
+ CAN Bus protocol adapter.
80
+
81
+ Reads CAN frames from a CAN interface and optionally decodes them
82
+ using a DBC file.
83
+
84
+ Args:
85
+ interface: CAN interface type (socketcan, pcan, vector, etc.)
86
+ channel: CAN channel (can0, vcan0, PCAN_USBBUS1, etc.)
87
+ bitrate: CAN bitrate in bps (default: 500000)
88
+ dbc_path: Path to DBC file for signal decoding (optional)
89
+ emit_raw: Whether to emit raw frame metrics (default: True)
90
+ emit_decoded: Whether to emit decoded signals (default: True)
91
+ raw_prefix: Prefix for raw frame metrics (default: "can.raw")
92
+ filters: List of CAN ID filters as dicts with 'can_id' and 'can_mask'
93
+ receive_own_messages: Whether to receive own transmitted messages
94
+ source_id: Source ID for metrics (optional)
95
+
96
+ Example:
97
+ adapter = CANAdapter(
98
+ interface="socketcan",
99
+ channel="can0",
100
+ dbc_path="vehicle.dbc",
101
+ bitrate=500000
102
+ )
103
+
104
+ with adapter:
105
+ while True:
106
+ for metric in adapter.poll():
107
+ print(f"{metric.name} = {metric.value}")
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ interface: str = "socketcan",
113
+ channel: str = "can0",
114
+ bitrate: int = 500000,
115
+ dbc_path: Optional[str] = None,
116
+ emit_raw: bool = True,
117
+ emit_decoded: bool = True,
118
+ raw_prefix: str = "can.raw",
119
+ filters: Optional[List[Dict[str, int]]] = None,
120
+ receive_own_messages: bool = False,
121
+ source_id: Optional[str] = None,
122
+ **kwargs,
123
+ ):
124
+ config = AdapterConfig(
125
+ name="can",
126
+ params={
127
+ "interface": interface,
128
+ "channel": channel,
129
+ "bitrate": bitrate,
130
+ "dbc_path": dbc_path,
131
+ **kwargs,
132
+ },
133
+ )
134
+ super().__init__(config)
135
+
136
+ self.interface = interface
137
+ self.channel = channel
138
+ self.bitrate = bitrate
139
+ self.dbc_path = dbc_path
140
+ self.emit_raw = emit_raw
141
+ self.emit_decoded = emit_decoded
142
+ self.raw_prefix = raw_prefix
143
+ self.filters = filters
144
+ self.receive_own_messages = receive_own_messages
145
+ self._source_id = source_id
146
+
147
+ self._bus: Optional[Any] = None # can.Bus instance
148
+ self._db: Optional[Any] = None # cantools.Database instance
149
+ self._message_cache: Dict[int, Any] = {} # Cache DBC message lookups
150
+
151
+ def validate_config(self) -> bool:
152
+ """Validate adapter configuration."""
153
+ if not self.channel:
154
+ raise ValueError("CAN channel is required")
155
+
156
+ valid_interfaces = [
157
+ "socketcan", "pcan", "vector", "kvaser", "slcan",
158
+ "virtual", "ixxat", "neovi", "nican", "iscan"
159
+ ]
160
+ if self.interface not in valid_interfaces:
161
+ logger.warning(
162
+ f"Unknown interface '{self.interface}'. "
163
+ f"Valid interfaces: {', '.join(valid_interfaces)}"
164
+ )
165
+
166
+ return True
167
+
168
+ def connect(self) -> bool:
169
+ """Connect to CAN bus interface."""
170
+ if can is None:
171
+ self._set_state(AdapterState.ERROR, "python-can not installed")
172
+ raise ConnectionError(
173
+ "python-can is required. Install with: pip install plexus-python[can]"
174
+ )
175
+
176
+ try:
177
+ self._set_state(AdapterState.CONNECTING)
178
+ logger.info(
179
+ f"Connecting to CAN bus: {self.interface}:{self.channel} "
180
+ f"at {self.bitrate} bps"
181
+ )
182
+
183
+ # Configure bus
184
+ bus_kwargs = {
185
+ "interface": self.interface,
186
+ "channel": self.channel,
187
+ "bitrate": self.bitrate,
188
+ "receive_own_messages": self.receive_own_messages,
189
+ }
190
+
191
+ # Add filters if specified
192
+ if self.filters:
193
+ bus_kwargs["can_filters"] = self.filters
194
+
195
+ self._bus = can.Bus(**bus_kwargs)
196
+
197
+ # Load DBC file if provided
198
+ if self.dbc_path:
199
+ self._load_dbc(self.dbc_path)
200
+
201
+ self._set_state(AdapterState.CONNECTED)
202
+ logger.info(f"Connected to CAN bus: {self.channel}")
203
+ return True
204
+
205
+ except Exception as e:
206
+ self._set_state(AdapterState.ERROR, str(e))
207
+ logger.error(f"Failed to connect to CAN bus: {e}")
208
+ raise ConnectionError(f"CAN connection failed: {e}")
209
+
210
+ def _load_dbc(self, dbc_path: str) -> None:
211
+ """Load a DBC file for signal decoding."""
212
+ if cantools is None:
213
+ logger.warning(
214
+ "cantools not installed. DBC decoding disabled. "
215
+ "Install with: pip install cantools"
216
+ )
217
+ self._db = None
218
+ return
219
+
220
+ try:
221
+ logger.info(f"Loading DBC file: {dbc_path}")
222
+ self._db = cantools.database.load_file(dbc_path)
223
+ logger.info(
224
+ f"Loaded DBC with {len(self._db.messages)} messages"
225
+ )
226
+
227
+ # Pre-cache message lookups by arbitration ID
228
+ for msg in self._db.messages:
229
+ self._message_cache[msg.frame_id] = msg
230
+
231
+ except FileNotFoundError:
232
+ logger.error(f"DBC file not found: {dbc_path}")
233
+ self._db = None
234
+ except Exception as e:
235
+ logger.error(f"Failed to load DBC file: {e}")
236
+ self._db = None
237
+
238
+ def disconnect(self) -> None:
239
+ """Disconnect from CAN bus."""
240
+ if self._bus:
241
+ try:
242
+ self._bus.shutdown()
243
+ logger.info("Disconnected from CAN bus")
244
+ except Exception as e:
245
+ logger.warning(f"Error shutting down CAN bus: {e}")
246
+ finally:
247
+ self._bus = None
248
+
249
+ self._set_state(AdapterState.DISCONNECTED)
250
+
251
+ def poll(self) -> List[Metric]:
252
+ """
253
+ Poll for CAN frames and return metrics.
254
+
255
+ Returns:
256
+ List of Metric objects for raw frames and/or decoded signals.
257
+
258
+ Raises:
259
+ OSError: On bus disconnect (triggers auto-reconnect in run loop).
260
+ ProtocolError: If reading data fails.
261
+ """
262
+ if not self._bus:
263
+ return []
264
+
265
+ metrics: List[Metric] = []
266
+
267
+ try:
268
+ # Non-blocking receive with short timeout
269
+ message = self._bus.recv(timeout=0.1)
270
+
271
+ if message is None:
272
+ return []
273
+
274
+ timestamp = message.timestamp if message.timestamp else time.time()
275
+
276
+ # Emit raw frame metric
277
+ if self.emit_raw:
278
+ raw_metric = self._create_raw_metric(message, timestamp)
279
+ metrics.append(raw_metric)
280
+
281
+ # Decode and emit signal metrics
282
+ if self.emit_decoded and self._db:
283
+ decoded_metrics = self._decode_message(message, timestamp)
284
+ metrics.extend(decoded_metrics)
285
+
286
+ except OSError:
287
+ raise # Let run loop handle disconnect/reconnect
288
+ except Exception as e:
289
+ logger.error(f"Error reading CAN frame: {e}")
290
+ raise ProtocolError(f"CAN read error: {e}")
291
+
292
+ return metrics
293
+
294
+ def _create_raw_metric(self, message: Any, timestamp: float) -> Metric:
295
+ """Create a raw frame metric."""
296
+ # Format arbitration ID as hex
297
+ arb_id = f"0x{message.arbitration_id:03X}"
298
+ metric_name = f"{self.raw_prefix}.{arb_id}"
299
+
300
+ # Format data as hex string
301
+ data_hex = message.data.hex().upper()
302
+
303
+ # Include metadata as tags
304
+ tags = {
305
+ "arbitration_id": str(message.arbitration_id),
306
+ "dlc": str(message.dlc),
307
+ "is_extended": str(message.is_extended_id).lower(),
308
+ }
309
+
310
+ if message.is_error_frame:
311
+ tags["error_frame"] = "true"
312
+ if message.is_remote_frame:
313
+ tags["remote_frame"] = "true"
314
+
315
+ return Metric(
316
+ name=metric_name,
317
+ value=data_hex,
318
+ timestamp=timestamp,
319
+ tags=tags,
320
+ source_id=self._source_id,
321
+ )
322
+
323
+ def _decode_message(self, message: Any, timestamp: float) -> List[Metric]:
324
+ """Decode CAN message using DBC and return signal metrics."""
325
+ metrics: List[Metric] = []
326
+
327
+ # Look up message in DBC
328
+ dbc_message = self._message_cache.get(message.arbitration_id)
329
+ if not dbc_message:
330
+ return []
331
+
332
+ try:
333
+ # Decode all signals in the message
334
+ decoded = dbc_message.decode(message.data)
335
+
336
+ for signal_name, value in decoded.items():
337
+ # Get signal info for units
338
+ signal = dbc_message.get_signal_by_name(signal_name)
339
+ tags = {
340
+ "can_id": f"0x{message.arbitration_id:03X}",
341
+ "dbc_message": dbc_message.name,
342
+ }
343
+
344
+ # Add unit if available
345
+ if signal and signal.unit:
346
+ tags["unit"] = signal.unit
347
+
348
+ metrics.append(
349
+ Metric(
350
+ name=signal_name,
351
+ value=value,
352
+ timestamp=timestamp,
353
+ tags=tags,
354
+ source_id=self._source_id,
355
+ )
356
+ )
357
+
358
+ except Exception as e:
359
+ logger.debug(
360
+ f"Could not decode message 0x{message.arbitration_id:03X}: {e}"
361
+ )
362
+
363
+ return metrics
364
+
365
+ def send(
366
+ self,
367
+ arbitration_id: int,
368
+ data: bytes,
369
+ is_extended_id: bool = False,
370
+ ) -> bool:
371
+ """
372
+ Send a CAN frame.
373
+
374
+ Args:
375
+ arbitration_id: CAN arbitration ID
376
+ data: Frame data (1-8 bytes)
377
+ is_extended_id: Whether to use extended (29-bit) ID
378
+
379
+ Returns:
380
+ True if sent successfully
381
+ """
382
+ if not self._bus:
383
+ raise ProtocolError("Not connected to CAN bus")
384
+
385
+ try:
386
+ message = can.Message(
387
+ arbitration_id=arbitration_id,
388
+ data=data,
389
+ is_extended_id=is_extended_id,
390
+ )
391
+ self._bus.send(message)
392
+ logger.debug(f"Sent CAN frame: 0x{arbitration_id:03X} {data.hex()}")
393
+ return True
394
+
395
+ except Exception as e:
396
+ logger.error(f"Failed to send CAN frame: {e}")
397
+ raise ProtocolError(f"CAN send error: {e}")
398
+
399
+ def send_signal(
400
+ self,
401
+ message_name: str,
402
+ signals: Dict[str, float],
403
+ ) -> bool:
404
+ """
405
+ Send a CAN message with encoded signals (requires DBC).
406
+
407
+ Args:
408
+ message_name: DBC message name
409
+ signals: Dict of signal names to values
410
+
411
+ Returns:
412
+ True if sent successfully
413
+ """
414
+ if not self._db:
415
+ raise ProtocolError("DBC file required for signal encoding")
416
+
417
+ try:
418
+ # Find message in DBC
419
+ dbc_message = self._db.get_message_by_name(message_name)
420
+ data = dbc_message.encode(signals)
421
+
422
+ return self.send(dbc_message.frame_id, data)
423
+
424
+ except Exception as e:
425
+ logger.error(f"Failed to send CAN signal: {e}")
426
+ raise ProtocolError(f"Signal encoding error: {e}")
427
+
428
+ @property
429
+ def stats(self) -> Dict[str, Any]:
430
+ """Get adapter statistics including CAN-specific info."""
431
+ base_stats = super().stats
432
+ base_stats.update({
433
+ "interface": self.interface,
434
+ "channel": self.channel,
435
+ "bitrate": self.bitrate,
436
+ "dbc_loaded": self._db is not None,
437
+ "dbc_messages": len(self._message_cache) if self._db else 0,
438
+ })
439
+ return base_stats