plexus-python 0.1.0__py3-none-any.whl → 0.3.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 +5 -22
- plexus/client.py +74 -8
- plexus/config.py +11 -29
- plexus/ws.py +344 -0
- plexus_python-0.3.0.dist-info/METADATA +176 -0
- plexus_python-0.3.0.dist-info/RECORD +9 -0
- plexus/__main__.py +0 -4
- plexus/adapters/__init__.py +0 -122
- plexus/adapters/base.py +0 -409
- plexus/adapters/ble.py +0 -257
- plexus/adapters/can.py +0 -439
- plexus/adapters/can_detect.py +0 -174
- plexus/adapters/mavlink.py +0 -642
- plexus/adapters/mavlink_detect.py +0 -192
- plexus/adapters/modbus.py +0 -622
- plexus/adapters/mqtt.py +0 -350
- plexus/adapters/opcua.py +0 -607
- plexus/adapters/registry.py +0 -206
- plexus/adapters/serial_adapter.py +0 -547
- plexus/cameras/__init__.py +0 -57
- plexus/cameras/auto.py +0 -239
- plexus/cameras/base.py +0 -189
- plexus/cameras/picamera.py +0 -171
- plexus/cameras/usb.py +0 -143
- plexus/cli.py +0 -783
- plexus/connector.py +0 -666
- plexus/deps.py +0 -246
- plexus/detect.py +0 -1238
- plexus/importers/__init__.py +0 -25
- plexus/importers/rosbag.py +0 -778
- plexus/sensors/__init__.py +0 -118
- plexus/sensors/ads1115.py +0 -164
- plexus/sensors/adxl345.py +0 -179
- plexus/sensors/auto.py +0 -290
- plexus/sensors/base.py +0 -412
- plexus/sensors/bh1750.py +0 -102
- plexus/sensors/bme280.py +0 -241
- plexus/sensors/gps.py +0 -317
- plexus/sensors/ina219.py +0 -149
- plexus/sensors/magnetometer.py +0 -239
- plexus/sensors/mpu6050.py +0 -162
- plexus/sensors/sht3x.py +0 -139
- plexus/sensors/spi_scan.py +0 -164
- plexus/sensors/system.py +0 -261
- plexus/sensors/vl53l0x.py +0 -109
- plexus/streaming.py +0 -743
- plexus/tui.py +0 -642
- plexus_python-0.1.0.dist-info/METADATA +0 -470
- plexus_python-0.1.0.dist-info/RECORD +0 -50
- plexus_python-0.1.0.dist-info/entry_points.txt +0 -2
- {plexus_python-0.1.0.dist-info → plexus_python-0.3.0.dist-info}/WHEEL +0 -0
- {plexus_python-0.1.0.dist-info → plexus_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
|
@@ -1,31 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Plexus
|
|
2
|
+
Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
3
3
|
|
|
4
|
-
Basic Usage:
|
|
5
4
|
from plexus import Plexus
|
|
6
5
|
|
|
7
|
-
px = Plexus()
|
|
6
|
+
px = Plexus(api_key="plx_xxx", source_id="device-001")
|
|
8
7
|
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
8
|
"""
|
|
26
9
|
|
|
27
10
|
from plexus.client import Plexus
|
|
28
|
-
from plexus.
|
|
11
|
+
from plexus.ws import WebSocketTransport
|
|
29
12
|
|
|
30
|
-
__version__ = "0.
|
|
31
|
-
__all__ = ["Plexus", "
|
|
13
|
+
__version__ = "0.3.0"
|
|
14
|
+
__all__ = ["Plexus", "WebSocketTransport"]
|
plexus/client.py
CHANGED
|
@@ -48,8 +48,8 @@ from plexus.config import (
|
|
|
48
48
|
get_api_key,
|
|
49
49
|
get_endpoint,
|
|
50
50
|
get_gateway_url,
|
|
51
|
+
get_gateway_ws_url,
|
|
51
52
|
get_source_id,
|
|
52
|
-
require_login,
|
|
53
53
|
)
|
|
54
54
|
logger = logging.getLogger(__name__)
|
|
55
55
|
|
|
@@ -96,12 +96,15 @@ class Plexus:
|
|
|
96
96
|
max_buffer_size: int = 10000,
|
|
97
97
|
persistent_buffer: bool = False,
|
|
98
98
|
buffer_path: Optional[str] = None,
|
|
99
|
+
transport: str = "ws",
|
|
100
|
+
ws_url: Optional[str] = None,
|
|
99
101
|
):
|
|
100
102
|
self.api_key = api_key or get_api_key()
|
|
101
|
-
|
|
102
|
-
# Require login if no API key provided
|
|
103
103
|
if not self.api_key:
|
|
104
|
-
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"No API key. Pass api_key=... or set PLEXUS_API_KEY. "
|
|
106
|
+
"Get a key at app.plexus.company/devices."
|
|
107
|
+
)
|
|
105
108
|
|
|
106
109
|
self.endpoint = (endpoint or get_endpoint()).rstrip("/")
|
|
107
110
|
self.gateway_url = get_gateway_url()
|
|
@@ -114,6 +117,12 @@ class Plexus:
|
|
|
114
117
|
self._session: Optional[requests.Session] = None
|
|
115
118
|
self._store_frames: bool = False
|
|
116
119
|
|
|
120
|
+
if transport not in ("ws", "http"):
|
|
121
|
+
raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
|
|
122
|
+
self.transport = transport
|
|
123
|
+
self._ws_url = (ws_url or get_gateway_ws_url())
|
|
124
|
+
self._ws = None # lazily constructed in _ensure_ws()
|
|
125
|
+
|
|
117
126
|
# Pluggable buffer backend for failed sends
|
|
118
127
|
if persistent_buffer:
|
|
119
128
|
self._buffer: BufferBackend = SqliteBuffer(
|
|
@@ -254,12 +263,54 @@ class Plexus:
|
|
|
254
263
|
data_points = [self._make_point(m, v, ts, tags) for m, v in points]
|
|
255
264
|
return self._send_points(data_points)
|
|
256
265
|
|
|
266
|
+
def _ensure_ws(self):
|
|
267
|
+
"""Lazily construct and start the WebSocket transport."""
|
|
268
|
+
if self._ws is not None:
|
|
269
|
+
return self._ws
|
|
270
|
+
from plexus.ws import WebSocketTransport
|
|
271
|
+
from plexus import __version__
|
|
272
|
+
self._ws = WebSocketTransport(
|
|
273
|
+
api_key=self.api_key,
|
|
274
|
+
source_id=self.source_id,
|
|
275
|
+
ws_url=self._ws_url,
|
|
276
|
+
agent_version=__version__,
|
|
277
|
+
)
|
|
278
|
+
self._ws.start()
|
|
279
|
+
return self._ws
|
|
280
|
+
|
|
281
|
+
def on_command(
|
|
282
|
+
self,
|
|
283
|
+
name: str,
|
|
284
|
+
handler,
|
|
285
|
+
*,
|
|
286
|
+
description: Optional[str] = None,
|
|
287
|
+
params: Optional[List[Dict[str, Any]]] = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Register a command handler (WebSocket transport only).
|
|
290
|
+
|
|
291
|
+
The handler is called as `handler(command_name, params_dict)` and may
|
|
292
|
+
return a dict (→ `result`) or raise (→ `error`). An `ack` is sent
|
|
293
|
+
automatically before the handler runs.
|
|
294
|
+
|
|
295
|
+
Must be called before the first send() so the command is advertised
|
|
296
|
+
in the auth frame.
|
|
297
|
+
"""
|
|
298
|
+
if self.transport != "ws":
|
|
299
|
+
raise PlexusError("on_command requires transport='ws'")
|
|
300
|
+
ws = self._ensure_ws()
|
|
301
|
+
ws.register_command(name, handler, description=description, params=params)
|
|
302
|
+
|
|
257
303
|
def _send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
258
|
-
"""Send data points to the
|
|
304
|
+
"""Send data points to the gateway with retry and buffering.
|
|
259
305
|
|
|
260
|
-
|
|
261
|
-
-
|
|
262
|
-
|
|
306
|
+
Path:
|
|
307
|
+
- transport='ws': try the WebSocket first; if not yet authenticated or
|
|
308
|
+
the socket fails, fall through to the HTTP path so points still land.
|
|
309
|
+
- transport='http': always POST /ingest with retries.
|
|
310
|
+
|
|
311
|
+
Retry behavior (HTTP path):
|
|
312
|
+
- Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
|
|
313
|
+
- No retry on: HTTP 401/403 (auth), HTTP 400/422 (bad request)
|
|
263
314
|
- After max retries: buffers points locally for next send attempt
|
|
264
315
|
"""
|
|
265
316
|
if not self.api_key:
|
|
@@ -270,6 +321,18 @@ class Plexus:
|
|
|
270
321
|
# Include any previously buffered points
|
|
271
322
|
all_points = self._get_buffered_points() + points
|
|
272
323
|
|
|
324
|
+
# Preferred path: WebSocket.
|
|
325
|
+
if self.transport == "ws":
|
|
326
|
+
ws = self._ensure_ws()
|
|
327
|
+
# Brief wait on first call so startup races don't dump every point
|
|
328
|
+
# into the HTTP fallback path.
|
|
329
|
+
if not ws.is_authenticated:
|
|
330
|
+
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
331
|
+
if ws.send_points(all_points):
|
|
332
|
+
self._clear_buffer()
|
|
333
|
+
return True
|
|
334
|
+
# Socket unavailable → fall through to HTTP.
|
|
335
|
+
|
|
273
336
|
url = f"{self.gateway_url}/ingest"
|
|
274
337
|
last_error: Optional[Exception] = None
|
|
275
338
|
|
|
@@ -451,6 +514,9 @@ class Plexus:
|
|
|
451
514
|
|
|
452
515
|
def close(self):
|
|
453
516
|
"""Close the client and release resources."""
|
|
517
|
+
if self._ws is not None:
|
|
518
|
+
self._ws.stop()
|
|
519
|
+
self._ws = None
|
|
454
520
|
if self._session:
|
|
455
521
|
self._session.close()
|
|
456
522
|
self._session = None
|
plexus/config.py
CHANGED
|
@@ -50,15 +50,12 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
|
50
50
|
|
|
51
51
|
PLEXUS_ENDPOINT = "https://app.plexus.company"
|
|
52
52
|
PLEXUS_GATEWAY_URL = "https://plexus-gateway.fly.dev"
|
|
53
|
+
PLEXUS_GATEWAY_WS_URL = "wss://plexus-gateway.fly.dev"
|
|
53
54
|
|
|
54
55
|
DEFAULT_CONFIG = {
|
|
55
56
|
"api_key": None,
|
|
56
57
|
"source_id": None,
|
|
57
|
-
"org_id": None,
|
|
58
|
-
"source_name": None,
|
|
59
|
-
"endpoint": None,
|
|
60
58
|
"persistent_buffer": True,
|
|
61
|
-
"sensors": None,
|
|
62
59
|
}
|
|
63
60
|
|
|
64
61
|
def get_config_path() -> Path:
|
|
@@ -117,7 +114,7 @@ def get_endpoint() -> str:
|
|
|
117
114
|
|
|
118
115
|
|
|
119
116
|
def get_gateway_url() -> str:
|
|
120
|
-
"""Get the ingest gateway base URL (POST /ingest
|
|
117
|
+
"""Get the ingest gateway base URL (POST /ingest)."""
|
|
121
118
|
env_gateway = os.environ.get("PLEXUS_GATEWAY_URL")
|
|
122
119
|
if env_gateway:
|
|
123
120
|
return env_gateway.rstrip("/")
|
|
@@ -125,6 +122,15 @@ def get_gateway_url() -> str:
|
|
|
125
122
|
return (config.get("gateway_url") or PLEXUS_GATEWAY_URL).rstrip("/")
|
|
126
123
|
|
|
127
124
|
|
|
125
|
+
def get_gateway_ws_url() -> str:
|
|
126
|
+
"""Get the gateway WebSocket base URL (/ws/device)."""
|
|
127
|
+
env_ws = os.environ.get("PLEXUS_GATEWAY_WS_URL")
|
|
128
|
+
if env_ws:
|
|
129
|
+
return env_ws.rstrip("/")
|
|
130
|
+
config = load_config()
|
|
131
|
+
return (config.get("gateway_ws_url") or PLEXUS_GATEWAY_WS_URL).rstrip("/")
|
|
132
|
+
|
|
133
|
+
|
|
128
134
|
def get_source_id() -> Optional[str]:
|
|
129
135
|
"""Get the source ID, generating one if not set."""
|
|
130
136
|
config = load_config()
|
|
@@ -139,30 +145,6 @@ def get_source_id() -> Optional[str]:
|
|
|
139
145
|
return source_id
|
|
140
146
|
|
|
141
147
|
|
|
142
|
-
def get_org_id() -> Optional[str]:
|
|
143
|
-
"""Get the organization ID from config or environment variable."""
|
|
144
|
-
# Environment variable takes precedence
|
|
145
|
-
env_org = os.environ.get("PLEXUS_ORG_ID")
|
|
146
|
-
if env_org:
|
|
147
|
-
return env_org
|
|
148
|
-
|
|
149
|
-
config = load_config()
|
|
150
|
-
return config.get("org_id")
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def is_logged_in() -> bool:
|
|
154
|
-
"""Check if device is authenticated (has API key)."""
|
|
155
|
-
return get_api_key() is not None
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def require_login() -> None:
|
|
159
|
-
"""Raise an error if not logged in."""
|
|
160
|
-
if not is_logged_in():
|
|
161
|
-
raise RuntimeError(
|
|
162
|
-
"Not logged in. Run 'plexus start' to connect your account."
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
|
|
166
148
|
def get_persistent_buffer() -> bool:
|
|
167
149
|
"""Get persistent buffer setting. Default True (store-and-forward enabled)."""
|
|
168
150
|
config = load_config()
|
plexus/ws.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket transport for the Plexus Python SDK.
|
|
3
|
+
|
|
4
|
+
Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
|
|
5
|
+
`/ws/device` endpoint and exchanges the same JSON frames:
|
|
6
|
+
|
|
7
|
+
client → {"type": "device_auth", "api_key": ..., "source_id": ...,
|
|
8
|
+
"platform": "python-sdk", "agent_version": ..., "commands": [...]}
|
|
9
|
+
server → {"type": "authenticated", "source_id": ...}
|
|
10
|
+
client → {"type": "telemetry", "points": [...]}
|
|
11
|
+
client → {"type": "heartbeat", "source_id": ..., "agent_version": ...} # every 30s
|
|
12
|
+
server → {"type": "typed_command", "id": ..., "command": ..., "params": {...}}
|
|
13
|
+
client → {"type": "command_result", "id": ..., "command": ..., "event": "ack"}
|
|
14
|
+
client → {"type": "command_result", "id": ..., "command": ...,
|
|
15
|
+
"event": "result" | "error", "result": {...} | "error": "..."}
|
|
16
|
+
|
|
17
|
+
Runs the read loop on a background daemon thread so callers can stay sync.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import random
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import websocket # websocket-client
|
|
32
|
+
except ImportError as e: # pragma: no cover - import-time failure is obvious
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"WebSocket transport requires 'websocket-client'. "
|
|
35
|
+
"Install with: pip install websocket-client"
|
|
36
|
+
) from e
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
AUTH_TIMEOUT_S = 10.0
|
|
41
|
+
HEARTBEAT_INTERVAL_S = 30.0
|
|
42
|
+
BACKOFF_BASE_S = 1.0
|
|
43
|
+
BACKOFF_MAX_S = 60.0
|
|
44
|
+
|
|
45
|
+
CommandHandler = Callable[[str, Dict[str, Any]], Optional[Dict[str, Any]]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _RegisteredCommand:
|
|
50
|
+
name: str
|
|
51
|
+
handler: CommandHandler
|
|
52
|
+
description: Optional[str] = None
|
|
53
|
+
params: List[Dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def to_manifest(self) -> Dict[str, Any]:
|
|
56
|
+
m: Dict[str, Any] = {"name": self.name}
|
|
57
|
+
if self.description:
|
|
58
|
+
m["description"] = self.description
|
|
59
|
+
if self.params:
|
|
60
|
+
m["params"] = self.params
|
|
61
|
+
return m
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WebSocketTransport:
|
|
65
|
+
"""Background WebSocket connection to the Plexus gateway.
|
|
66
|
+
|
|
67
|
+
Lifecycle:
|
|
68
|
+
t = WebSocketTransport(api_key, source_id, ws_url)
|
|
69
|
+
t.start()
|
|
70
|
+
t.wait_authenticated(timeout=5)
|
|
71
|
+
t.send_points([...])
|
|
72
|
+
t.stop()
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str,
|
|
78
|
+
source_id: str,
|
|
79
|
+
ws_url: str,
|
|
80
|
+
*,
|
|
81
|
+
agent_version: str = "0.0.0",
|
|
82
|
+
platform: str = "python-sdk",
|
|
83
|
+
auto_reconnect: bool = True,
|
|
84
|
+
):
|
|
85
|
+
if not api_key:
|
|
86
|
+
raise ValueError("api_key required")
|
|
87
|
+
if not source_id:
|
|
88
|
+
raise ValueError("source_id required")
|
|
89
|
+
|
|
90
|
+
self.api_key = api_key
|
|
91
|
+
self.source_id = source_id
|
|
92
|
+
self.ws_url = _ensure_device_path(ws_url)
|
|
93
|
+
self.agent_version = agent_version
|
|
94
|
+
self.platform = platform
|
|
95
|
+
self.auto_reconnect = auto_reconnect
|
|
96
|
+
|
|
97
|
+
self._commands: Dict[str, _RegisteredCommand] = {}
|
|
98
|
+
self._ws: Optional[websocket.WebSocket] = None
|
|
99
|
+
self._ws_lock = threading.Lock()
|
|
100
|
+
self._authenticated = threading.Event()
|
|
101
|
+
self._stop = threading.Event()
|
|
102
|
+
self._thread: Optional[threading.Thread] = None
|
|
103
|
+
self._backoff_attempt = 0
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ public
|
|
106
|
+
|
|
107
|
+
def register_command(
|
|
108
|
+
self,
|
|
109
|
+
name: str,
|
|
110
|
+
handler: CommandHandler,
|
|
111
|
+
*,
|
|
112
|
+
description: Optional[str] = None,
|
|
113
|
+
params: Optional[List[Dict[str, Any]]] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Register a command handler. Must be called before start() to be
|
|
116
|
+
advertised in the auth frame."""
|
|
117
|
+
self._commands[name] = _RegisteredCommand(
|
|
118
|
+
name=name, handler=handler, description=description, params=params or []
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def start(self) -> None:
|
|
122
|
+
if self._thread and self._thread.is_alive():
|
|
123
|
+
return
|
|
124
|
+
self._stop.clear()
|
|
125
|
+
self._thread = threading.Thread(
|
|
126
|
+
target=self._run, name="plexus-ws", daemon=True
|
|
127
|
+
)
|
|
128
|
+
self._thread.start()
|
|
129
|
+
|
|
130
|
+
def stop(self, timeout: float = 2.0) -> None:
|
|
131
|
+
self._stop.set()
|
|
132
|
+
with self._ws_lock:
|
|
133
|
+
ws = self._ws
|
|
134
|
+
if ws is not None:
|
|
135
|
+
try:
|
|
136
|
+
ws.close()
|
|
137
|
+
except Exception: # pragma: no cover
|
|
138
|
+
pass
|
|
139
|
+
if self._thread:
|
|
140
|
+
self._thread.join(timeout=timeout)
|
|
141
|
+
|
|
142
|
+
def wait_authenticated(self, timeout: float = AUTH_TIMEOUT_S) -> bool:
|
|
143
|
+
return self._authenticated.wait(timeout=timeout)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def is_authenticated(self) -> bool:
|
|
147
|
+
return self._authenticated.is_set()
|
|
148
|
+
|
|
149
|
+
def send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
150
|
+
"""Send a telemetry frame. Returns False if the socket is not
|
|
151
|
+
authenticated — caller is expected to fall back to HTTP."""
|
|
152
|
+
if not points:
|
|
153
|
+
return True
|
|
154
|
+
if not self._authenticated.is_set():
|
|
155
|
+
return False
|
|
156
|
+
frame = {"type": "telemetry", "points": points}
|
|
157
|
+
return self._send_frame(frame)
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ thread
|
|
160
|
+
|
|
161
|
+
def _run(self) -> None:
|
|
162
|
+
while not self._stop.is_set():
|
|
163
|
+
try:
|
|
164
|
+
self._connect_and_serve()
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning("plexus ws loop error: %s", e)
|
|
167
|
+
finally:
|
|
168
|
+
self._authenticated.clear()
|
|
169
|
+
with self._ws_lock:
|
|
170
|
+
self._ws = None
|
|
171
|
+
|
|
172
|
+
if not self.auto_reconnect or self._stop.is_set():
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
delay = _backoff_delay(self._backoff_attempt)
|
|
176
|
+
self._backoff_attempt = min(self._backoff_attempt + 1, 10)
|
|
177
|
+
logger.info("plexus ws reconnect in %.1fs", delay)
|
|
178
|
+
if self._stop.wait(timeout=delay):
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
def _connect_and_serve(self) -> None:
|
|
182
|
+
ws = websocket.create_connection(self.ws_url, timeout=AUTH_TIMEOUT_S)
|
|
183
|
+
with self._ws_lock:
|
|
184
|
+
self._ws = ws
|
|
185
|
+
|
|
186
|
+
# 1. Send device_auth
|
|
187
|
+
auth = {
|
|
188
|
+
"type": "device_auth",
|
|
189
|
+
"api_key": self.api_key,
|
|
190
|
+
"source_id": self.source_id,
|
|
191
|
+
"platform": self.platform,
|
|
192
|
+
"agent_version": self.agent_version,
|
|
193
|
+
}
|
|
194
|
+
if self._commands:
|
|
195
|
+
auth["commands"] = [c.to_manifest() for c in self._commands.values()]
|
|
196
|
+
ws.send(json.dumps(auth))
|
|
197
|
+
|
|
198
|
+
# 2. Wait for authenticated
|
|
199
|
+
ws.settimeout(AUTH_TIMEOUT_S)
|
|
200
|
+
try:
|
|
201
|
+
raw = ws.recv()
|
|
202
|
+
except websocket.WebSocketTimeoutException as e:
|
|
203
|
+
raise TimeoutError("auth timeout") from e
|
|
204
|
+
|
|
205
|
+
msg = _safe_json(raw)
|
|
206
|
+
if msg.get("type") != "authenticated":
|
|
207
|
+
raise RuntimeError(f"auth failed: {msg}")
|
|
208
|
+
|
|
209
|
+
self._authenticated.set()
|
|
210
|
+
self._backoff_attempt = 0
|
|
211
|
+
logger.info("plexus ws authenticated as %s", self.source_id)
|
|
212
|
+
|
|
213
|
+
# 3. Read loop with heartbeat pump
|
|
214
|
+
ws.settimeout(1.0)
|
|
215
|
+
last_heartbeat = time.monotonic()
|
|
216
|
+
while not self._stop.is_set():
|
|
217
|
+
now = time.monotonic()
|
|
218
|
+
if now - last_heartbeat >= HEARTBEAT_INTERVAL_S:
|
|
219
|
+
self._send_frame({
|
|
220
|
+
"type": "heartbeat",
|
|
221
|
+
"source_id": self.source_id,
|
|
222
|
+
"agent_version": self.agent_version,
|
|
223
|
+
})
|
|
224
|
+
last_heartbeat = now
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
raw = ws.recv()
|
|
228
|
+
except websocket.WebSocketTimeoutException:
|
|
229
|
+
continue
|
|
230
|
+
except (websocket.WebSocketConnectionClosedException, OSError):
|
|
231
|
+
logger.info("plexus ws closed")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if not raw:
|
|
235
|
+
continue
|
|
236
|
+
self._dispatch(_safe_json(raw))
|
|
237
|
+
|
|
238
|
+
def _dispatch(self, msg: Dict[str, Any]) -> None:
|
|
239
|
+
mtype = msg.get("type")
|
|
240
|
+
if mtype == "typed_command":
|
|
241
|
+
self._handle_command(msg)
|
|
242
|
+
elif mtype == "error":
|
|
243
|
+
logger.warning("plexus ws server error: %s", msg.get("detail") or msg)
|
|
244
|
+
# ignore unknown types — forward-compat
|
|
245
|
+
|
|
246
|
+
def _handle_command(self, msg: Dict[str, Any]) -> None:
|
|
247
|
+
cmd_id = msg.get("id") or ""
|
|
248
|
+
command = msg.get("command") or ""
|
|
249
|
+
params = msg.get("params") or {}
|
|
250
|
+
|
|
251
|
+
# Ack immediately (matches C SDK: plexus_ws.c:275-280)
|
|
252
|
+
self._send_frame({
|
|
253
|
+
"type": "command_result",
|
|
254
|
+
"id": cmd_id,
|
|
255
|
+
"command": command,
|
|
256
|
+
"event": "ack",
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
reg = self._commands.get(command)
|
|
260
|
+
if reg is None:
|
|
261
|
+
self._send_frame({
|
|
262
|
+
"type": "command_result",
|
|
263
|
+
"id": cmd_id,
|
|
264
|
+
"command": command,
|
|
265
|
+
"event": "error",
|
|
266
|
+
"error": f"unknown command: {command}",
|
|
267
|
+
})
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Run the handler off the read-loop thread so a slow handler doesn't
|
|
271
|
+
# block heartbeats or other inbound frames.
|
|
272
|
+
threading.Thread(
|
|
273
|
+
target=self._run_handler,
|
|
274
|
+
args=(reg, cmd_id, command, params),
|
|
275
|
+
daemon=True,
|
|
276
|
+
).start()
|
|
277
|
+
|
|
278
|
+
def _run_handler(
|
|
279
|
+
self,
|
|
280
|
+
reg: _RegisteredCommand,
|
|
281
|
+
cmd_id: str,
|
|
282
|
+
command: str,
|
|
283
|
+
params: Dict[str, Any],
|
|
284
|
+
) -> None:
|
|
285
|
+
try:
|
|
286
|
+
result = reg.handler(command, params)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
self._send_frame({
|
|
289
|
+
"type": "command_result",
|
|
290
|
+
"id": cmd_id,
|
|
291
|
+
"command": command,
|
|
292
|
+
"event": "error",
|
|
293
|
+
"error": str(e),
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
self._send_frame({
|
|
297
|
+
"type": "command_result",
|
|
298
|
+
"id": cmd_id,
|
|
299
|
+
"command": command,
|
|
300
|
+
"event": "result",
|
|
301
|
+
"result": result if result is not None else {},
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
def _send_frame(self, frame: Dict[str, Any]) -> bool:
|
|
305
|
+
with self._ws_lock:
|
|
306
|
+
ws = self._ws
|
|
307
|
+
if ws is None:
|
|
308
|
+
return False
|
|
309
|
+
try:
|
|
310
|
+
ws.send(json.dumps(frame))
|
|
311
|
+
return True
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.debug("plexus ws send failed: %s", e)
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# --------------------------------------------------------------------- helpers
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _ensure_device_path(url: str) -> str:
|
|
321
|
+
url = url.rstrip("/")
|
|
322
|
+
if url.endswith("/ws/device"):
|
|
323
|
+
return url
|
|
324
|
+
return url + "/ws/device"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _safe_json(raw: Any) -> Dict[str, Any]:
|
|
328
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
329
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
330
|
+
if not isinstance(raw, str):
|
|
331
|
+
return {}
|
|
332
|
+
try:
|
|
333
|
+
obj = json.loads(raw)
|
|
334
|
+
except json.JSONDecodeError:
|
|
335
|
+
return {}
|
|
336
|
+
return obj if isinstance(obj, dict) else {}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _backoff_delay(attempt: int) -> float:
|
|
340
|
+
"""Exponential backoff with ±25% jitter, capped at BACKOFF_MAX_S.
|
|
341
|
+
Matches plexus_ws.c:44-52."""
|
|
342
|
+
base = min(BACKOFF_BASE_S * (2 ** attempt), BACKOFF_MAX_S)
|
|
343
|
+
jitter = base * 0.25 * (2 * random.random() - 1)
|
|
344
|
+
return max(0.1, base + jitter)
|