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,547 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serial Port Adapter - Read serial data from devices into Plexus
|
|
3
|
+
|
|
4
|
+
This adapter reads data from serial ports (USB, UART, RS-232/485) and
|
|
5
|
+
parses incoming lines into Plexus metrics. Supports multiple parsing
|
|
6
|
+
formats for common embedded device output patterns.
|
|
7
|
+
|
|
8
|
+
Requirements:
|
|
9
|
+
pip install plexus-python[serial]
|
|
10
|
+
# or
|
|
11
|
+
pip install pyserial
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from plexus.adapters import SerialAdapter
|
|
15
|
+
|
|
16
|
+
# Basic usage - parse "metric:value" lines
|
|
17
|
+
adapter = SerialAdapter(port="/dev/ttyUSB0", baudrate=115200)
|
|
18
|
+
adapter.connect()
|
|
19
|
+
for metric in adapter.poll():
|
|
20
|
+
print(f"{metric.name}: {metric.value}")
|
|
21
|
+
|
|
22
|
+
# JSON mode - parse {"temp": 22.5, "humidity": 45} lines
|
|
23
|
+
adapter = SerialAdapter(
|
|
24
|
+
port="/dev/ttyACM0",
|
|
25
|
+
baudrate=9600,
|
|
26
|
+
parser="json",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# CSV mode - first line is headers, subsequent lines are values
|
|
30
|
+
# e.g., "temp,humidity,pressure\\n22.5,45,1013.2"
|
|
31
|
+
adapter = SerialAdapter(
|
|
32
|
+
port="COM3",
|
|
33
|
+
baudrate=115200,
|
|
34
|
+
parser="csv",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Run with callback
|
|
38
|
+
def handle_data(metrics):
|
|
39
|
+
for m in metrics:
|
|
40
|
+
print(f"{m.name}: {m.value}")
|
|
41
|
+
|
|
42
|
+
adapter.run(on_data=handle_data)
|
|
43
|
+
|
|
44
|
+
Emitted metrics:
|
|
45
|
+
- serial.{metric_name} - Parsed metric with configurable prefix
|
|
46
|
+
|
|
47
|
+
Parser formats:
|
|
48
|
+
- "line": Expects "metric_name:value" per line (default)
|
|
49
|
+
- "json": Expects a JSON object per line; each key becomes a metric
|
|
50
|
+
- "csv": First line is comma-separated headers, subsequent lines are values
|
|
51
|
+
|
|
52
|
+
Requires: pip install pyserial
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
import logging
|
|
57
|
+
import time
|
|
58
|
+
from typing import Any, Dict, List, Optional
|
|
59
|
+
|
|
60
|
+
from plexus.adapters.base import (
|
|
61
|
+
ProtocolAdapter,
|
|
62
|
+
Metric,
|
|
63
|
+
AdapterConfig,
|
|
64
|
+
AdapterState,
|
|
65
|
+
ConnectionError,
|
|
66
|
+
ProtocolError,
|
|
67
|
+
)
|
|
68
|
+
from plexus.adapters.registry import AdapterRegistry
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
# Optional dependency — imported at module level so it can be mocked in tests
|
|
73
|
+
try:
|
|
74
|
+
import serial as pyserial
|
|
75
|
+
except ImportError:
|
|
76
|
+
pyserial = None # type: ignore[assignment]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SerialAdapter(ProtocolAdapter):
|
|
80
|
+
"""
|
|
81
|
+
Serial port protocol adapter.
|
|
82
|
+
|
|
83
|
+
Reads lines from a serial port and parses them into Plexus metrics.
|
|
84
|
+
Three parser modes are supported:
|
|
85
|
+
|
|
86
|
+
**line** (default): Each line contains "metric_name:value". The metric
|
|
87
|
+
name and numeric value are split on the colon. Non-numeric values are
|
|
88
|
+
forwarded as strings.
|
|
89
|
+
|
|
90
|
+
Example input::
|
|
91
|
+
|
|
92
|
+
temperature:22.5
|
|
93
|
+
humidity:45
|
|
94
|
+
status:OK
|
|
95
|
+
|
|
96
|
+
**json**: Each line is a JSON object. Every key-value pair in the object
|
|
97
|
+
becomes a separate metric.
|
|
98
|
+
|
|
99
|
+
Example input::
|
|
100
|
+
|
|
101
|
+
{"temperature": 22.5, "humidity": 45}
|
|
102
|
+
|
|
103
|
+
**csv**: The first line received is treated as a comma-separated header
|
|
104
|
+
row. All subsequent lines are parsed as values matching those headers.
|
|
105
|
+
|
|
106
|
+
Example input::
|
|
107
|
+
|
|
108
|
+
temperature,humidity,pressure
|
|
109
|
+
22.5,45,1013.2
|
|
110
|
+
22.6,44,1013.1
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
port: Serial port path (e.g., "/dev/ttyUSB0", "COM3")
|
|
114
|
+
baudrate: Baud rate (default: 9600)
|
|
115
|
+
parser: Parsing mode - "line", "json", or "csv" (default: "line")
|
|
116
|
+
line_ending: Line ending character(s) (default: "\\n")
|
|
117
|
+
prefix: Prefix prepended to all metric names (default: "serial.")
|
|
118
|
+
timeout: Serial read timeout in seconds (default: 1.0)
|
|
119
|
+
source_id: Optional source identifier attached to all emitted metrics
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
adapter = SerialAdapter(
|
|
123
|
+
port="/dev/ttyUSB0",
|
|
124
|
+
baudrate=115200,
|
|
125
|
+
parser="json",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
with adapter:
|
|
129
|
+
while True:
|
|
130
|
+
for metric in adapter.poll():
|
|
131
|
+
print(f"{metric.name} = {metric.value}")
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
port: str = "/dev/ttyUSB0",
|
|
137
|
+
baudrate: int = 9600,
|
|
138
|
+
parser: str = "line",
|
|
139
|
+
line_ending: str = "\n",
|
|
140
|
+
prefix: str = "serial.",
|
|
141
|
+
timeout: float = 1.0,
|
|
142
|
+
source_id: Optional[str] = None,
|
|
143
|
+
**kwargs,
|
|
144
|
+
):
|
|
145
|
+
config = AdapterConfig(
|
|
146
|
+
name="serial",
|
|
147
|
+
params={
|
|
148
|
+
"port": port,
|
|
149
|
+
"baudrate": baudrate,
|
|
150
|
+
"parser": parser,
|
|
151
|
+
"line_ending": line_ending,
|
|
152
|
+
"prefix": prefix,
|
|
153
|
+
**kwargs,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
super().__init__(config)
|
|
157
|
+
|
|
158
|
+
self.port = port
|
|
159
|
+
self.baudrate = baudrate
|
|
160
|
+
self.parser = parser
|
|
161
|
+
self.line_ending = line_ending
|
|
162
|
+
self.prefix = prefix
|
|
163
|
+
self.timeout = timeout
|
|
164
|
+
self._source_id = source_id
|
|
165
|
+
|
|
166
|
+
self._serial: Optional[Any] = None # serial.Serial instance
|
|
167
|
+
self._csv_headers: Optional[List[str]] = None
|
|
168
|
+
self._read_buffer: str = ""
|
|
169
|
+
|
|
170
|
+
def validate_config(self) -> bool:
|
|
171
|
+
"""Validate adapter configuration."""
|
|
172
|
+
if not self.port:
|
|
173
|
+
raise ValueError("Serial port is required")
|
|
174
|
+
|
|
175
|
+
valid_parsers = ["line", "json", "csv"]
|
|
176
|
+
if self.parser not in valid_parsers:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Invalid parser '{self.parser}'. "
|
|
179
|
+
f"Valid parsers: {', '.join(valid_parsers)}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if self.baudrate <= 0:
|
|
183
|
+
raise ValueError("Baud rate must be positive")
|
|
184
|
+
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
def connect(self) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
Open the serial port.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if the port was opened successfully, False otherwise.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ConnectionError: If pyserial is not installed or the port
|
|
196
|
+
cannot be opened.
|
|
197
|
+
"""
|
|
198
|
+
if pyserial is None:
|
|
199
|
+
self._set_state(AdapterState.ERROR, "pyserial not installed")
|
|
200
|
+
raise ConnectionError(
|
|
201
|
+
"pyserial is required. Install with: pip install plexus-python[serial] "
|
|
202
|
+
"or pip install pyserial"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self._set_state(AdapterState.CONNECTING)
|
|
207
|
+
logger.info(
|
|
208
|
+
f"Opening serial port: {self.port} at {self.baudrate} baud"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
self._serial = pyserial.Serial(
|
|
212
|
+
port=self.port,
|
|
213
|
+
baudrate=self.baudrate,
|
|
214
|
+
timeout=self.timeout,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Reset CSV headers for a fresh connection
|
|
218
|
+
self._csv_headers = None
|
|
219
|
+
self._read_buffer = ""
|
|
220
|
+
|
|
221
|
+
self._set_state(AdapterState.CONNECTED)
|
|
222
|
+
logger.info(f"Serial port opened: {self.port}")
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
self._set_state(AdapterState.ERROR, str(e))
|
|
227
|
+
logger.error(f"Failed to open serial port: {e}")
|
|
228
|
+
raise ConnectionError(f"Serial connection failed: {e}")
|
|
229
|
+
|
|
230
|
+
def disconnect(self) -> None:
|
|
231
|
+
"""Close the serial port."""
|
|
232
|
+
if self._serial:
|
|
233
|
+
try:
|
|
234
|
+
self._serial.close()
|
|
235
|
+
logger.info(f"Serial port closed: {self.port}")
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.warning(f"Error closing serial port: {e}")
|
|
238
|
+
finally:
|
|
239
|
+
self._serial = None
|
|
240
|
+
|
|
241
|
+
self._csv_headers = None
|
|
242
|
+
self._read_buffer = ""
|
|
243
|
+
self._set_state(AdapterState.DISCONNECTED)
|
|
244
|
+
|
|
245
|
+
def poll(self) -> List[Metric]:
|
|
246
|
+
"""
|
|
247
|
+
Read available lines from the serial port and parse them into metrics.
|
|
248
|
+
|
|
249
|
+
Reads all available data from the serial buffer, splits into complete
|
|
250
|
+
lines, and parses each line according to the configured parser mode.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
List of Metric objects. Empty list if no complete lines available.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
OSError: On port disconnect (triggers auto-reconnect in run loop).
|
|
257
|
+
ProtocolError: If reading data fails.
|
|
258
|
+
"""
|
|
259
|
+
if not self._serial or not self._serial.is_open:
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
metrics: List[Metric] = []
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
lines = self._read_lines()
|
|
266
|
+
for line in lines:
|
|
267
|
+
parsed = self._parse_line(line)
|
|
268
|
+
metrics.extend(parsed)
|
|
269
|
+
except OSError:
|
|
270
|
+
raise # Let run loop handle disconnect/reconnect
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.error(f"Error reading from serial port: {e}")
|
|
273
|
+
raise ProtocolError(f"Serial read error: {e}")
|
|
274
|
+
|
|
275
|
+
return metrics
|
|
276
|
+
|
|
277
|
+
def _read_lines(self) -> List[str]:
|
|
278
|
+
"""
|
|
279
|
+
Read complete lines from the serial port.
|
|
280
|
+
|
|
281
|
+
Reads all bytes currently in the serial buffer, appends them to an
|
|
282
|
+
internal buffer, and splits on the configured line ending. Incomplete
|
|
283
|
+
lines (no trailing line ending) are kept in the buffer for the next
|
|
284
|
+
call.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
List of complete lines (without line endings).
|
|
288
|
+
"""
|
|
289
|
+
if not self._serial:
|
|
290
|
+
return []
|
|
291
|
+
|
|
292
|
+
# Read all available bytes
|
|
293
|
+
waiting = self._serial.in_waiting
|
|
294
|
+
if waiting > 0:
|
|
295
|
+
raw = self._serial.read(waiting)
|
|
296
|
+
else:
|
|
297
|
+
# Do a blocking read up to timeout for one byte, then grab rest
|
|
298
|
+
raw = self._serial.read(1)
|
|
299
|
+
if raw:
|
|
300
|
+
extra = self._serial.in_waiting
|
|
301
|
+
if extra > 0:
|
|
302
|
+
raw += self._serial.read(extra)
|
|
303
|
+
|
|
304
|
+
if not raw:
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
self._read_buffer += raw.decode("utf-8", errors="replace")
|
|
309
|
+
except Exception:
|
|
310
|
+
return []
|
|
311
|
+
|
|
312
|
+
# Split on line ending
|
|
313
|
+
parts = self._read_buffer.split(self.line_ending)
|
|
314
|
+
|
|
315
|
+
# Last element is either empty (line ended with separator) or
|
|
316
|
+
# an incomplete line — keep it in the buffer
|
|
317
|
+
self._read_buffer = parts[-1]
|
|
318
|
+
complete_lines = [line.strip() for line in parts[:-1] if line.strip()]
|
|
319
|
+
|
|
320
|
+
return complete_lines
|
|
321
|
+
|
|
322
|
+
def _parse_line(self, line: str) -> List[Metric]:
|
|
323
|
+
"""
|
|
324
|
+
Parse a single line using the configured parser.
|
|
325
|
+
|
|
326
|
+
Dispatches to the appropriate parser method based on self.parser.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
line: A complete line of text from the serial port.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of Metric objects parsed from the line.
|
|
333
|
+
"""
|
|
334
|
+
if self.parser == "json":
|
|
335
|
+
return self._parse_json(line)
|
|
336
|
+
elif self.parser == "csv":
|
|
337
|
+
return self._parse_csv(line)
|
|
338
|
+
else:
|
|
339
|
+
return self._parse_key_value(line)
|
|
340
|
+
|
|
341
|
+
def _parse_key_value(self, line: str) -> List[Metric]:
|
|
342
|
+
"""
|
|
343
|
+
Parse a "metric_name:value" line.
|
|
344
|
+
|
|
345
|
+
If the line contains a colon, splits on the first colon to get the
|
|
346
|
+
metric name and value. Attempts to convert the value to a number;
|
|
347
|
+
falls back to string.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
line: Line in "name:value" format.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Single-element list with the parsed Metric, or empty list on
|
|
354
|
+
parse failure.
|
|
355
|
+
"""
|
|
356
|
+
if ":" not in line:
|
|
357
|
+
logger.debug(f"Skipping line without colon separator: {line!r}")
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
name, _, raw_value = line.partition(":")
|
|
361
|
+
name = name.strip()
|
|
362
|
+
raw_value = raw_value.strip()
|
|
363
|
+
|
|
364
|
+
if not name or not raw_value:
|
|
365
|
+
return []
|
|
366
|
+
|
|
367
|
+
value = self._coerce_value(raw_value)
|
|
368
|
+
metric_name = f"{self.prefix}{name}"
|
|
369
|
+
|
|
370
|
+
return [
|
|
371
|
+
Metric(
|
|
372
|
+
name=metric_name,
|
|
373
|
+
value=value,
|
|
374
|
+
timestamp=time.time(),
|
|
375
|
+
source_id=self._source_id,
|
|
376
|
+
)
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
def _parse_json(self, line: str) -> List[Metric]:
|
|
380
|
+
"""
|
|
381
|
+
Parse a JSON object line. Each key becomes a separate metric.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
line: Line containing a JSON object string.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
List of Metric objects, one per key-value pair.
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
data = json.loads(line)
|
|
391
|
+
except json.JSONDecodeError:
|
|
392
|
+
logger.debug(f"Failed to parse JSON line: {line!r}")
|
|
393
|
+
return []
|
|
394
|
+
|
|
395
|
+
if not isinstance(data, dict):
|
|
396
|
+
logger.debug(f"JSON line is not an object: {line!r}")
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
metrics: List[Metric] = []
|
|
400
|
+
now = time.time()
|
|
401
|
+
|
|
402
|
+
for key, value in data.items():
|
|
403
|
+
if not self._is_valid_value(value):
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
metric_name = f"{self.prefix}{key}"
|
|
407
|
+
metrics.append(
|
|
408
|
+
Metric(
|
|
409
|
+
name=metric_name,
|
|
410
|
+
value=value,
|
|
411
|
+
timestamp=now,
|
|
412
|
+
source_id=self._source_id,
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return metrics
|
|
417
|
+
|
|
418
|
+
def _parse_csv(self, line: str) -> List[Metric]:
|
|
419
|
+
"""
|
|
420
|
+
Parse a CSV line using previously received headers.
|
|
421
|
+
|
|
422
|
+
The first line received in CSV mode is treated as the header row.
|
|
423
|
+
Subsequent lines are parsed as comma-separated values matching
|
|
424
|
+
those headers positionally.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
line: Comma-separated line of text.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
List of Metric objects, one per column. Empty list if this is
|
|
431
|
+
the header line or parsing fails.
|
|
432
|
+
"""
|
|
433
|
+
parts = [p.strip() for p in line.split(",")]
|
|
434
|
+
|
|
435
|
+
if self._csv_headers is None:
|
|
436
|
+
# First line is the header
|
|
437
|
+
self._csv_headers = parts
|
|
438
|
+
logger.debug(f"CSV headers set: {self._csv_headers}")
|
|
439
|
+
return []
|
|
440
|
+
|
|
441
|
+
if len(parts) != len(self._csv_headers):
|
|
442
|
+
logger.debug(
|
|
443
|
+
f"CSV column count mismatch: expected {len(self._csv_headers)}, "
|
|
444
|
+
f"got {len(parts)}"
|
|
445
|
+
)
|
|
446
|
+
return []
|
|
447
|
+
|
|
448
|
+
metrics: List[Metric] = []
|
|
449
|
+
now = time.time()
|
|
450
|
+
|
|
451
|
+
for header, raw_value in zip(self._csv_headers, parts):
|
|
452
|
+
if not header or not raw_value:
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
value = self._coerce_value(raw_value)
|
|
456
|
+
metric_name = f"{self.prefix}{header}"
|
|
457
|
+
|
|
458
|
+
metrics.append(
|
|
459
|
+
Metric(
|
|
460
|
+
name=metric_name,
|
|
461
|
+
value=value,
|
|
462
|
+
timestamp=now,
|
|
463
|
+
source_id=self._source_id,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return metrics
|
|
468
|
+
|
|
469
|
+
def _coerce_value(self, raw: str) -> Any:
|
|
470
|
+
"""
|
|
471
|
+
Coerce a raw string value to the most appropriate Python type.
|
|
472
|
+
|
|
473
|
+
Tries int, then float, then returns the original string.
|
|
474
|
+
"""
|
|
475
|
+
# Try int
|
|
476
|
+
try:
|
|
477
|
+
return int(raw)
|
|
478
|
+
except ValueError:
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
# Try float
|
|
482
|
+
try:
|
|
483
|
+
return float(raw)
|
|
484
|
+
except ValueError:
|
|
485
|
+
pass
|
|
486
|
+
|
|
487
|
+
# Boolean-ish strings
|
|
488
|
+
lower = raw.lower()
|
|
489
|
+
if lower in ("true", "yes", "on"):
|
|
490
|
+
return True
|
|
491
|
+
if lower in ("false", "no", "off"):
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
return raw
|
|
495
|
+
|
|
496
|
+
def _is_valid_value(self, value: Any) -> bool:
|
|
497
|
+
"""Check if a value is a valid Metric value type."""
|
|
498
|
+
return isinstance(value, (int, float, str, bool, dict, list))
|
|
499
|
+
|
|
500
|
+
def write(self, data: str) -> bool:
|
|
501
|
+
"""
|
|
502
|
+
Write a string to the serial port.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
data: String data to send. Line ending is NOT automatically appended.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
True if written successfully.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
ProtocolError: If the port is not open or write fails.
|
|
512
|
+
"""
|
|
513
|
+
if not self._serial or not self._serial.is_open:
|
|
514
|
+
raise ProtocolError("Serial port is not open")
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
self._serial.write(data.encode("utf-8"))
|
|
518
|
+
self._serial.flush()
|
|
519
|
+
logger.debug(f"Wrote to serial: {data!r}")
|
|
520
|
+
return True
|
|
521
|
+
except Exception as e:
|
|
522
|
+
logger.error(f"Failed to write to serial port: {e}")
|
|
523
|
+
raise ProtocolError(f"Serial write error: {e}")
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def stats(self) -> Dict[str, Any]:
|
|
527
|
+
"""Get adapter statistics including serial-specific info."""
|
|
528
|
+
base_stats = super().stats
|
|
529
|
+
base_stats.update({
|
|
530
|
+
"port": self.port,
|
|
531
|
+
"baudrate": self.baudrate,
|
|
532
|
+
"parser": self.parser,
|
|
533
|
+
"csv_headers": self._csv_headers,
|
|
534
|
+
"is_open": self._serial.is_open if self._serial else False,
|
|
535
|
+
})
|
|
536
|
+
return base_stats
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# Register the adapter
|
|
540
|
+
AdapterRegistry.register(
|
|
541
|
+
"serial",
|
|
542
|
+
SerialAdapter,
|
|
543
|
+
description="Serial port adapter for USB/UART/RS-232/RS-485 devices",
|
|
544
|
+
author="Plexus",
|
|
545
|
+
version="1.0.0",
|
|
546
|
+
requires=["pyserial"],
|
|
547
|
+
)
|