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/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plexus Agent - Send sensor data to Plexus in one line of code.
|
|
3
|
+
|
|
4
|
+
Basic Usage:
|
|
5
|
+
from plexus import Plexus
|
|
6
|
+
|
|
7
|
+
px = Plexus()
|
|
8
|
+
px.send("temperature", 72.5)
|
|
9
|
+
|
|
10
|
+
With Sensors (pip install plexus-python[sensors]):
|
|
11
|
+
from plexus import Plexus
|
|
12
|
+
from plexus.sensors import SensorHub, MPU6050, BME280
|
|
13
|
+
|
|
14
|
+
hub = SensorHub()
|
|
15
|
+
hub.add(MPU6050(sample_rate=100))
|
|
16
|
+
hub.add(BME280(sample_rate=1))
|
|
17
|
+
hub.run(Plexus())
|
|
18
|
+
|
|
19
|
+
Auto-Detection:
|
|
20
|
+
from plexus import Plexus
|
|
21
|
+
from plexus.sensors import auto_sensors
|
|
22
|
+
|
|
23
|
+
hub = auto_sensors() # Finds all connected sensors
|
|
24
|
+
hub.run(Plexus())
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from plexus.client import Plexus
|
|
28
|
+
from plexus.config import load_config, save_config
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
__all__ = ["Plexus", "load_config", "save_config"]
|
plexus/__main__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocol Adapters - Extensible protocol support for Plexus
|
|
3
|
+
|
|
4
|
+
This module provides a plugin system for protocol adapters, enabling
|
|
5
|
+
Plexus to ingest data from any protocol without modifying core code.
|
|
6
|
+
|
|
7
|
+
Built-in adapters:
|
|
8
|
+
- MQTTAdapter: Bridge MQTT brokers to Plexus
|
|
9
|
+
- CANAdapter: CAN bus with DBC signal decoding
|
|
10
|
+
- MAVLinkAdapter: MAVLink for drones and autonomous vehicles
|
|
11
|
+
- OPCUAAdapter: OPC-UA client for industrial automation servers
|
|
12
|
+
- SerialAdapter: Serial port (USB/UART/RS-232/RS-485) reader
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
from plexus.adapters import MQTTAdapter, CANAdapter, MAVLinkAdapter, AdapterRegistry
|
|
16
|
+
|
|
17
|
+
# Use built-in adapter
|
|
18
|
+
adapter = MQTTAdapter(broker="localhost", topic="sensors/#")
|
|
19
|
+
adapter.connect()
|
|
20
|
+
adapter.run(on_data=my_callback)
|
|
21
|
+
|
|
22
|
+
# CAN bus adapter
|
|
23
|
+
adapter = CANAdapter(interface="socketcan", channel="can0", dbc_path="vehicle.dbc")
|
|
24
|
+
with adapter:
|
|
25
|
+
for metric in adapter.poll():
|
|
26
|
+
print(f"{metric.name}: {metric.value}")
|
|
27
|
+
|
|
28
|
+
# MAVLink adapter
|
|
29
|
+
adapter = MAVLinkAdapter(connection_string="udpin:0.0.0.0:14550")
|
|
30
|
+
with adapter:
|
|
31
|
+
for metric in adapter.poll():
|
|
32
|
+
print(f"{metric.name}: {metric.value}")
|
|
33
|
+
|
|
34
|
+
# Create custom adapter
|
|
35
|
+
from plexus.adapters import ProtocolAdapter, Metric
|
|
36
|
+
|
|
37
|
+
class MyProtocolAdapter(ProtocolAdapter):
|
|
38
|
+
def connect(self) -> bool:
|
|
39
|
+
# Connect to your protocol
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def poll(self) -> "List[Metric]":
|
|
43
|
+
# Read data and return metrics
|
|
44
|
+
return [Metric("sensor.temp", 72.5)]
|
|
45
|
+
|
|
46
|
+
# Register custom adapter
|
|
47
|
+
AdapterRegistry.register("my-protocol", MyProtocolAdapter)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from plexus.adapters.base import (
|
|
51
|
+
ProtocolAdapter,
|
|
52
|
+
Metric,
|
|
53
|
+
AdapterConfig,
|
|
54
|
+
AdapterState,
|
|
55
|
+
AdapterError,
|
|
56
|
+
)
|
|
57
|
+
from plexus.adapters.registry import AdapterRegistry
|
|
58
|
+
from plexus.adapters.mqtt import MQTTAdapter
|
|
59
|
+
|
|
60
|
+
# Import CANAdapter (requires optional [can] extra)
|
|
61
|
+
try:
|
|
62
|
+
from plexus.adapters.can import CANAdapter
|
|
63
|
+
_HAS_CAN = True
|
|
64
|
+
except ImportError:
|
|
65
|
+
CANAdapter = None # type: ignore
|
|
66
|
+
_HAS_CAN = False
|
|
67
|
+
|
|
68
|
+
# Import ModbusAdapter (requires optional [modbus] extra)
|
|
69
|
+
try:
|
|
70
|
+
from plexus.adapters.modbus import ModbusAdapter
|
|
71
|
+
_HAS_MODBUS = True
|
|
72
|
+
except ImportError:
|
|
73
|
+
ModbusAdapter = None # type: ignore
|
|
74
|
+
_HAS_MODBUS = False
|
|
75
|
+
|
|
76
|
+
# Import MAVLinkAdapter (requires optional [mavlink] extra)
|
|
77
|
+
try:
|
|
78
|
+
from plexus.adapters.mavlink import MAVLinkAdapter
|
|
79
|
+
_HAS_MAVLINK = True
|
|
80
|
+
except ImportError:
|
|
81
|
+
MAVLinkAdapter = None # type: ignore
|
|
82
|
+
_HAS_MAVLINK = False
|
|
83
|
+
|
|
84
|
+
# Import OPCUAAdapter (requires optional [opcua] extra)
|
|
85
|
+
try:
|
|
86
|
+
from plexus.adapters.opcua import OPCUAAdapter
|
|
87
|
+
_HAS_OPCUA = True
|
|
88
|
+
except ImportError:
|
|
89
|
+
OPCUAAdapter = None # type: ignore
|
|
90
|
+
_HAS_OPCUA = False
|
|
91
|
+
|
|
92
|
+
# Import SerialAdapter (requires optional [serial] extra)
|
|
93
|
+
try:
|
|
94
|
+
from plexus.adapters.serial_adapter import SerialAdapter
|
|
95
|
+
_HAS_SERIAL = True
|
|
96
|
+
except ImportError:
|
|
97
|
+
SerialAdapter = None # type: ignore
|
|
98
|
+
_HAS_SERIAL = False
|
|
99
|
+
|
|
100
|
+
# Import BLERelayAdapter (requires optional [ble] extra)
|
|
101
|
+
try:
|
|
102
|
+
from plexus.adapters.ble import BLERelayAdapter
|
|
103
|
+
_HAS_BLE = True
|
|
104
|
+
except ImportError:
|
|
105
|
+
BLERelayAdapter = None # type: ignore
|
|
106
|
+
_HAS_BLE = False
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"ProtocolAdapter",
|
|
110
|
+
"Metric",
|
|
111
|
+
"AdapterConfig",
|
|
112
|
+
"AdapterState",
|
|
113
|
+
"AdapterError",
|
|
114
|
+
"AdapterRegistry",
|
|
115
|
+
"MQTTAdapter",
|
|
116
|
+
"CANAdapter",
|
|
117
|
+
"ModbusAdapter",
|
|
118
|
+
"MAVLinkAdapter",
|
|
119
|
+
"OPCUAAdapter",
|
|
120
|
+
"SerialAdapter",
|
|
121
|
+
"BLERelayAdapter",
|
|
122
|
+
]
|
plexus/adapters/base.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Protocol Adapter - Abstract interface for all protocol adapters
|
|
3
|
+
|
|
4
|
+
This module defines the interface that all protocol adapters must implement.
|
|
5
|
+
By following this interface, new protocols can be added without modifying
|
|
6
|
+
the core Plexus codebase.
|
|
7
|
+
|
|
8
|
+
Example custom adapter:
|
|
9
|
+
|
|
10
|
+
from plexus.adapters import ProtocolAdapter, Metric, AdapterConfig
|
|
11
|
+
|
|
12
|
+
class SerialAdapter(ProtocolAdapter):
|
|
13
|
+
'''Adapter for serial port communication'''
|
|
14
|
+
|
|
15
|
+
def __init__(self, port: str, baudrate: int = 9600, **kwargs):
|
|
16
|
+
config = AdapterConfig(
|
|
17
|
+
name="serial",
|
|
18
|
+
params={"port": port, "baudrate": baudrate, **kwargs}
|
|
19
|
+
)
|
|
20
|
+
super().__init__(config)
|
|
21
|
+
self.port = port
|
|
22
|
+
self.baudrate = baudrate
|
|
23
|
+
self._serial = None
|
|
24
|
+
|
|
25
|
+
def connect(self) -> bool:
|
|
26
|
+
import serial
|
|
27
|
+
self._serial = serial.Serial(self.port, self.baudrate)
|
|
28
|
+
return self._serial.is_open
|
|
29
|
+
|
|
30
|
+
def disconnect(self) -> None:
|
|
31
|
+
if self._serial:
|
|
32
|
+
self._serial.close()
|
|
33
|
+
|
|
34
|
+
def poll(self) -> "List[Metric]":
|
|
35
|
+
line = self._serial.readline().decode().strip()
|
|
36
|
+
# Parse your protocol format
|
|
37
|
+
metric, value = line.split(":")
|
|
38
|
+
return [Metric(metric, float(value))]
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
import random
|
|
43
|
+
from abc import ABC, abstractmethod
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from enum import Enum
|
|
46
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
47
|
+
import time
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AdapterState(Enum):
|
|
53
|
+
"""Adapter connection state"""
|
|
54
|
+
DISCONNECTED = "disconnected"
|
|
55
|
+
CONNECTING = "connecting"
|
|
56
|
+
CONNECTED = "connected"
|
|
57
|
+
ERROR = "error"
|
|
58
|
+
RECONNECTING = "reconnecting"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AdapterError(Exception):
|
|
62
|
+
"""Base exception for adapter errors"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConnectionError(AdapterError):
|
|
67
|
+
"""Raised when connection fails"""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProtocolError(AdapterError):
|
|
72
|
+
"""Raised when protocol-specific error occurs"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class AdapterConfig:
|
|
78
|
+
"""Configuration for a protocol adapter"""
|
|
79
|
+
name: str
|
|
80
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
# Connection settings
|
|
83
|
+
auto_reconnect: bool = True
|
|
84
|
+
reconnect_interval: float = 5.0
|
|
85
|
+
max_reconnect_attempts: int = 10
|
|
86
|
+
|
|
87
|
+
# Data settings
|
|
88
|
+
batch_size: int = 100
|
|
89
|
+
flush_interval: float = 1.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Metric:
|
|
94
|
+
"""
|
|
95
|
+
A single metric data point.
|
|
96
|
+
|
|
97
|
+
This is the universal format that all adapters produce.
|
|
98
|
+
The Plexus client will convert these to the ingest API format.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
name: Metric name (e.g., "temperature", "motor.rpm", "robot.state")
|
|
102
|
+
value: The value - can be number, string, bool, dict, or list
|
|
103
|
+
timestamp: Unix timestamp (seconds). If None, current time is used.
|
|
104
|
+
tags: Optional key-value metadata
|
|
105
|
+
source_id: Optional source identifier
|
|
106
|
+
data_class: Pipeline data class - "metric" (default), "event", or "blob"
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
Metric("temperature", 72.5)
|
|
110
|
+
Metric("robot.state", "MOVING")
|
|
111
|
+
Metric("position", {"x": 1.5, "y": 2.3, "z": 0.0})
|
|
112
|
+
Metric("joint_angles", [0.5, 1.2, -0.3, 0.0])
|
|
113
|
+
"""
|
|
114
|
+
name: str
|
|
115
|
+
value: Union[int, float, str, bool, Dict[str, Any], List[Any]]
|
|
116
|
+
timestamp: Optional[float] = None
|
|
117
|
+
tags: Optional[Dict[str, str]] = None
|
|
118
|
+
source_id: Optional[str] = None
|
|
119
|
+
data_class: str = "metric"
|
|
120
|
+
|
|
121
|
+
def __post_init__(self):
|
|
122
|
+
if self.timestamp is None:
|
|
123
|
+
self.timestamp = time.time()
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
126
|
+
"""Convert to dictionary for API submission"""
|
|
127
|
+
result = {
|
|
128
|
+
"class": self.data_class,
|
|
129
|
+
"metric": self.name,
|
|
130
|
+
"value": self.value,
|
|
131
|
+
"timestamp": self.timestamp,
|
|
132
|
+
}
|
|
133
|
+
if self.tags:
|
|
134
|
+
result["tags"] = self.tags
|
|
135
|
+
if self.source_id:
|
|
136
|
+
result["source_id"] = self.source_id
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Type alias for data callback
|
|
141
|
+
DataCallback = Callable[[List[Metric]], None]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ProtocolAdapter(ABC):
|
|
145
|
+
"""
|
|
146
|
+
Abstract base class for protocol adapters.
|
|
147
|
+
|
|
148
|
+
All protocol adapters must inherit from this class and implement
|
|
149
|
+
the required abstract methods.
|
|
150
|
+
|
|
151
|
+
Lifecycle:
|
|
152
|
+
1. __init__() - Configure the adapter
|
|
153
|
+
2. connect() - Establish connection
|
|
154
|
+
3. run() or poll() - Receive data
|
|
155
|
+
4. disconnect() - Clean up
|
|
156
|
+
|
|
157
|
+
Two modes of operation:
|
|
158
|
+
- Push mode: Override on_data() or pass callback to run()
|
|
159
|
+
- Pull mode: Call poll() periodically to get data
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, config: AdapterConfig):
|
|
163
|
+
self.config = config
|
|
164
|
+
self._state = AdapterState.DISCONNECTED
|
|
165
|
+
self._error: Optional[str] = None
|
|
166
|
+
self._metrics_received = 0
|
|
167
|
+
self._last_data_time: Optional[float] = None
|
|
168
|
+
self._on_data_callback: Optional[DataCallback] = None
|
|
169
|
+
self._on_state_change: Optional[Callable[[AdapterState], None]] = None
|
|
170
|
+
self._on_error_report: Optional[Any] = None # async fn(source, error, severity)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def state(self) -> AdapterState:
|
|
174
|
+
"""Current adapter state"""
|
|
175
|
+
return self._state
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def is_connected(self) -> bool:
|
|
179
|
+
"""Whether adapter is connected"""
|
|
180
|
+
return self._state == AdapterState.CONNECTED
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def error(self) -> Optional[str]:
|
|
184
|
+
"""Last error message, if any"""
|
|
185
|
+
return self._error
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def stats(self) -> Dict[str, Any]:
|
|
189
|
+
"""Adapter statistics"""
|
|
190
|
+
return {
|
|
191
|
+
"state": self._state.value,
|
|
192
|
+
"metrics_received": self._metrics_received,
|
|
193
|
+
"last_data_time": self._last_data_time,
|
|
194
|
+
"error": self._error,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def _set_state(self, state: AdapterState, error: Optional[str] = None):
|
|
198
|
+
"""Update adapter state and notify listeners"""
|
|
199
|
+
old_state = self._state
|
|
200
|
+
self._state = state
|
|
201
|
+
self._error = error
|
|
202
|
+
if self._on_state_change:
|
|
203
|
+
self._on_state_change(state)
|
|
204
|
+
# Report transitions to ERROR or RECONNECTING to dashboard
|
|
205
|
+
if state in (AdapterState.ERROR, AdapterState.RECONNECTING) and old_state != state:
|
|
206
|
+
if self._on_error_report:
|
|
207
|
+
import asyncio
|
|
208
|
+
severity = "error" if state == AdapterState.ERROR else "warning"
|
|
209
|
+
msg = error or f"Adapter {self.config.name} entered {state.value}"
|
|
210
|
+
try:
|
|
211
|
+
coro = self._on_error_report(
|
|
212
|
+
f"adapter.{self.config.name}", msg, severity
|
|
213
|
+
)
|
|
214
|
+
# Fire-and-forget if there's a running loop
|
|
215
|
+
loop = asyncio.get_event_loop()
|
|
216
|
+
if loop.is_running():
|
|
217
|
+
asyncio.ensure_future(coro)
|
|
218
|
+
else:
|
|
219
|
+
loop.run_until_complete(coro)
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
def _emit_data(self, metrics: List[Metric]):
|
|
224
|
+
"""Emit data to callback"""
|
|
225
|
+
if metrics:
|
|
226
|
+
self._metrics_received += len(metrics)
|
|
227
|
+
self._last_data_time = time.time()
|
|
228
|
+
if self._on_data_callback:
|
|
229
|
+
self._on_data_callback(metrics)
|
|
230
|
+
|
|
231
|
+
# =========================================================================
|
|
232
|
+
# Auto-Reconnection
|
|
233
|
+
# =========================================================================
|
|
234
|
+
|
|
235
|
+
def _reconnect_loop(self, max_attempts: Optional[int] = None):
|
|
236
|
+
"""Exponential backoff reconnection.
|
|
237
|
+
|
|
238
|
+
Uses config values if max_attempts is not specified.
|
|
239
|
+
"""
|
|
240
|
+
if max_attempts is None:
|
|
241
|
+
max_attempts = self.config.max_reconnect_attempts
|
|
242
|
+
|
|
243
|
+
attempt = 0
|
|
244
|
+
delay = self.config.reconnect_interval
|
|
245
|
+
|
|
246
|
+
while max_attempts is None or attempt < max_attempts:
|
|
247
|
+
attempt += 1
|
|
248
|
+
jitter = random.uniform(0.75, 1.25)
|
|
249
|
+
time.sleep(delay * jitter)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
self.disconnect()
|
|
253
|
+
result = self.connect()
|
|
254
|
+
if result:
|
|
255
|
+
logger.info(
|
|
256
|
+
"%s: reconnected after %d attempt(s)",
|
|
257
|
+
self.config.name, attempt,
|
|
258
|
+
)
|
|
259
|
+
return True
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"%s: reconnect attempt %d failed: %s",
|
|
263
|
+
self.config.name, attempt, e,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
delay = min(delay * 2, 60.0)
|
|
267
|
+
|
|
268
|
+
self._set_state(AdapterState.ERROR, "Max reconnect attempts reached")
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
# =========================================================================
|
|
272
|
+
# Abstract methods - MUST be implemented by subclasses
|
|
273
|
+
# =========================================================================
|
|
274
|
+
|
|
275
|
+
@abstractmethod
|
|
276
|
+
def connect(self) -> bool:
|
|
277
|
+
"""
|
|
278
|
+
Establish connection to the data source.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if connection successful, False otherwise.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
ConnectionError: If connection fails with an error.
|
|
285
|
+
"""
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
@abstractmethod
|
|
289
|
+
def disconnect(self) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Close connection and clean up resources.
|
|
292
|
+
|
|
293
|
+
This should be idempotent - calling it multiple times should be safe.
|
|
294
|
+
"""
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
@abstractmethod
|
|
298
|
+
def poll(self) -> List[Metric]:
|
|
299
|
+
"""
|
|
300
|
+
Poll for new data (pull mode).
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
List of Metric objects. Empty list if no new data.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
ProtocolError: If reading data fails.
|
|
307
|
+
|
|
308
|
+
Note:
|
|
309
|
+
For push-based protocols (like MQTT), this may return an empty
|
|
310
|
+
list and data will arrive via the callback instead.
|
|
311
|
+
"""
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
# =========================================================================
|
|
315
|
+
# Optional methods - MAY be overridden by subclasses
|
|
316
|
+
# =========================================================================
|
|
317
|
+
|
|
318
|
+
def validate_config(self) -> bool:
|
|
319
|
+
"""
|
|
320
|
+
Validate adapter configuration.
|
|
321
|
+
|
|
322
|
+
Override this to add protocol-specific validation.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
True if config is valid.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
ValueError: If config is invalid.
|
|
329
|
+
"""
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
def on_data(self, metrics: List[Metric]) -> None:
|
|
333
|
+
"""
|
|
334
|
+
Handle incoming data (push mode).
|
|
335
|
+
|
|
336
|
+
Override this for custom data handling, or pass a callback to run().
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
metrics: List of received metrics.
|
|
340
|
+
"""
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
def run(
|
|
344
|
+
self,
|
|
345
|
+
on_data: Optional[DataCallback] = None,
|
|
346
|
+
on_state_change: Optional[Callable[[AdapterState], None]] = None,
|
|
347
|
+
blocking: bool = True,
|
|
348
|
+
) -> None:
|
|
349
|
+
"""
|
|
350
|
+
Run the adapter (main loop for push-based protocols).
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
on_data: Callback for received data. If None, on_data() method is used.
|
|
354
|
+
on_state_change: Callback for state changes.
|
|
355
|
+
blocking: If True, blocks until stopped. If False, returns immediately.
|
|
356
|
+
|
|
357
|
+
For pull-based protocols, this starts a polling loop.
|
|
358
|
+
For push-based protocols, this starts listening for events.
|
|
359
|
+
"""
|
|
360
|
+
self._on_data_callback = on_data
|
|
361
|
+
self._on_state_change = on_state_change
|
|
362
|
+
|
|
363
|
+
if not self.is_connected:
|
|
364
|
+
if not self.connect():
|
|
365
|
+
raise ConnectionError(f"Failed to connect: {self._error}")
|
|
366
|
+
|
|
367
|
+
if blocking:
|
|
368
|
+
self._run_loop()
|
|
369
|
+
|
|
370
|
+
def _run_loop(self) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Default run loop implementation (polling mode).
|
|
373
|
+
|
|
374
|
+
Override this for push-based protocols like MQTT.
|
|
375
|
+
Handles disconnect detection and auto-reconnection.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
while True:
|
|
379
|
+
try:
|
|
380
|
+
while self.is_connected:
|
|
381
|
+
metrics = self.poll()
|
|
382
|
+
if metrics:
|
|
383
|
+
self._emit_data(metrics)
|
|
384
|
+
self.on_data(metrics)
|
|
385
|
+
time.sleep(0.01) # 100 Hz max
|
|
386
|
+
except (ConnectionError, OSError) as e:
|
|
387
|
+
logger.warning("%s: connection lost: %s", self.config.name, e)
|
|
388
|
+
self._set_state(AdapterState.RECONNECTING, str(e))
|
|
389
|
+
if self.config.auto_reconnect:
|
|
390
|
+
if self._reconnect_loop():
|
|
391
|
+
continue
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
# Normal exit (is_connected went False)
|
|
395
|
+
break
|
|
396
|
+
except KeyboardInterrupt:
|
|
397
|
+
pass
|
|
398
|
+
finally:
|
|
399
|
+
self.disconnect()
|
|
400
|
+
|
|
401
|
+
def __enter__(self):
|
|
402
|
+
"""Context manager entry"""
|
|
403
|
+
self.connect()
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
407
|
+
"""Context manager exit"""
|
|
408
|
+
self.disconnect()
|
|
409
|
+
return False
|