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/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
|