plexus-python 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {plexus_python-0.2.0 → plexus_python-0.3.0}/CHANGELOG.md +13 -0
  2. {plexus_python-0.2.0 → plexus_python-0.3.0}/PKG-INFO +38 -2
  3. {plexus_python-0.2.0 → plexus_python-0.3.0}/README.md +35 -1
  4. {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/__init__.py +3 -2
  5. {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/client.py +70 -4
  6. plexus_python-0.3.0/plexus/ws.py +344 -0
  7. {plexus_python-0.2.0 → plexus_python-0.3.0}/pyproject.toml +3 -2
  8. plexus_python-0.3.0/tests/test_ws.py +255 -0
  9. {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/workflows/ci.yml +0 -0
  13. {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/workflows/publish.yml +0 -0
  14. {plexus_python-0.2.0 → plexus_python-0.3.0}/.gitignore +0 -0
  15. {plexus_python-0.2.0 → plexus_python-0.3.0}/AGENTS.md +0 -0
  16. {plexus_python-0.2.0 → plexus_python-0.3.0}/API.md +0 -0
  17. {plexus_python-0.2.0 → plexus_python-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  18. {plexus_python-0.2.0 → plexus_python-0.3.0}/CONTRIBUTING.md +0 -0
  19. {plexus_python-0.2.0 → plexus_python-0.3.0}/LICENSE +0 -0
  20. {plexus_python-0.2.0 → plexus_python-0.3.0}/SECURITY.md +0 -0
  21. {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/README.md +0 -0
  22. {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/basic.py +0 -0
  23. {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/can.py +0 -0
  24. {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/i2c_bme280.py +0 -0
  25. {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/mavlink.py +0 -0
  26. {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/mqtt.py +0 -0
  27. {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/buffer.py +0 -0
  28. {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/config.py +0 -0
  29. {plexus_python-0.2.0 → plexus_python-0.3.0}/scripts/plexus.service +0 -0
  30. {plexus_python-0.2.0 → plexus_python-0.3.0}/scripts/scan_buses.py +0 -0
  31. {plexus_python-0.2.0 → plexus_python-0.3.0}/scripts/setup.sh +0 -0
  32. {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_basic.py +0 -0
  33. {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_buffer.py +0 -0
  34. {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_config.py +0 -0
  35. {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_retry.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - WebSocket transport
4
+
5
+ Adds a wire-compatible WebSocket transport matching the `plexus-c` SDK. WS is now the default; failed sends transparently fall back to `POST /ingest`.
6
+
7
+ ### Added
8
+
9
+ - `plexus.WebSocketTransport` — connects to `/ws/device` on the gateway. Exchanges the same `device_auth` / `authenticated` / `telemetry` / `heartbeat` / `typed_command` / `command_result` frames as `plexus-c`.
10
+ - `Plexus(transport="ws" | "http")` — defaults to `"ws"`.
11
+ - `Plexus.on_command(name, handler, description=..., params=...)` — register command handlers; automatic `ack`, handler return becomes `result`, exceptions become `error`.
12
+ - `Plexus.close()` — stops the WebSocket thread.
13
+ - Runtime dep: `websocket-client>=1.7`.
14
+ - Tests: `tests/test_ws.py` (auth handshake, telemetry, command roundtrip, error paths).
15
+
3
16
  ## [0.2.0] - Thin SDK rewrite
4
17
 
5
18
  Breaking. `plexus-python` is now just the thin client — no agent, adapters, sensors, CLI, or TUI. The package is 886 lines with one runtime dependency (`requests`). Protocol integrations (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, I2C sensors) now live as standalone recipes in `examples/`, using the upstream library directly (`pymavlink`, `python-can`, `paho-mqtt`, etc.) plus `px.send()`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.dev
6
6
  Project-URL: Documentation, https://docs.plexus.dev
@@ -24,10 +24,12 @@ Classifier: Topic :: Scientific/Engineering
24
24
  Classifier: Topic :: System :: Hardware
25
25
  Requires-Python: >=3.8
26
26
  Requires-Dist: requests>=2.28.0
27
+ Requires-Dist: websocket-client>=1.7
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest; extra == 'dev'
29
30
  Requires-Dist: pytest-cov; extra == 'dev'
30
31
  Requires-Dist: ruff; extra == 'dev'
32
+ Requires-Dist: websockets>=12; extra == 'dev'
31
33
  Description-Content-Type: text/markdown
32
34
 
33
35
  # plexus-python
@@ -119,13 +121,47 @@ px.buffer_size()
119
121
  px.flush_buffer()
120
122
  ```
121
123
 
124
+ ## Transport
125
+
126
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
127
+
128
+ - lower-latency streaming of telemetry,
129
+ - live command delivery from the UI / API to the device.
130
+
131
+ If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
132
+
133
+ ```python
134
+ # default — ws with http fallback
135
+ px = Plexus()
136
+
137
+ # force http (legacy)
138
+ px = Plexus(transport="http")
139
+ ```
140
+
141
+ ### Handling commands
142
+
143
+ Register a handler before the first `send()` so the command is advertised in the auth frame:
144
+
145
+ ```python
146
+ def reboot(name, params):
147
+ delay = params.get("delay_s", 0)
148
+ # ... reboot logic ...
149
+ return {"ok": True, "delay": delay}
150
+
151
+ px = Plexus()
152
+ px.on_command("reboot", reboot, description="reboot the device")
153
+ px.send("temperature", 72.5) # opens the socket, waits for auth
154
+ ```
155
+
156
+ The SDK sends an `ack` frame before invoking the handler, then a `result` frame with whatever the handler returns (or an `error` frame if it raises).
157
+
122
158
  ## Environment Variables
123
159
 
124
160
  | Variable | Description | Default |
125
161
  | ----------------------- | ---------------------------- | -------------------------------- |
126
162
  | `PLEXUS_API_KEY` | API key (required) | none |
127
163
  | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
128
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL (unused in SDK, for compatibility) | `wss://plexus-gateway.fly.dev` |
164
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
129
165
 
130
166
  ## Architecture
131
167
 
@@ -87,13 +87,47 @@ px.buffer_size()
87
87
  px.flush_buffer()
88
88
  ```
89
89
 
90
+ ## Transport
91
+
92
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
93
+
94
+ - lower-latency streaming of telemetry,
95
+ - live command delivery from the UI / API to the device.
96
+
97
+ If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
98
+
99
+ ```python
100
+ # default — ws with http fallback
101
+ px = Plexus()
102
+
103
+ # force http (legacy)
104
+ px = Plexus(transport="http")
105
+ ```
106
+
107
+ ### Handling commands
108
+
109
+ Register a handler before the first `send()` so the command is advertised in the auth frame:
110
+
111
+ ```python
112
+ def reboot(name, params):
113
+ delay = params.get("delay_s", 0)
114
+ # ... reboot logic ...
115
+ return {"ok": True, "delay": delay}
116
+
117
+ px = Plexus()
118
+ px.on_command("reboot", reboot, description="reboot the device")
119
+ px.send("temperature", 72.5) # opens the socket, waits for auth
120
+ ```
121
+
122
+ The SDK sends an `ack` frame before invoking the handler, then a `result` frame with whatever the handler returns (or an `error` frame if it raises).
123
+
90
124
  ## Environment Variables
91
125
 
92
126
  | Variable | Description | Default |
93
127
  | ----------------------- | ---------------------------- | -------------------------------- |
94
128
  | `PLEXUS_API_KEY` | API key (required) | none |
95
129
  | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
96
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL (unused in SDK, for compatibility) | `wss://plexus-gateway.fly.dev` |
130
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
97
131
 
98
132
  ## Architecture
99
133
 
@@ -8,6 +8,7 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
8
8
  """
9
9
 
10
10
  from plexus.client import Plexus
11
+ from plexus.ws import WebSocketTransport
11
12
 
12
- __version__ = "0.2.0"
13
- __all__ = ["Plexus"]
13
+ __version__ = "0.3.0"
14
+ __all__ = ["Plexus", "WebSocketTransport"]
@@ -48,6 +48,7 @@ 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
53
  )
53
54
  logger = logging.getLogger(__name__)
@@ -95,6 +96,8 @@ class Plexus:
95
96
  max_buffer_size: int = 10000,
96
97
  persistent_buffer: bool = False,
97
98
  buffer_path: Optional[str] = None,
99
+ transport: str = "ws",
100
+ ws_url: Optional[str] = None,
98
101
  ):
99
102
  self.api_key = api_key or get_api_key()
100
103
  if not self.api_key:
@@ -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 API with retry and buffering.
304
+ """Send data points to the gateway with retry and buffering.
305
+
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.
259
310
 
260
- Retry behavior:
261
- - Retries on: Timeout, ConnectionError, HTTP 429 (rate limit), HTTP 5xx
262
- - No retry on: HTTP 401/403 (auth errors), HTTP 400/422 (bad request)
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
@@ -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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plexus-python"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Thin Python SDK for Plexus — send telemetry in one line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -29,10 +29,11 @@ classifiers = [
29
29
  ]
30
30
  dependencies = [
31
31
  "requests>=2.28.0",
32
+ "websocket-client>=1.7",
32
33
  ]
33
34
 
34
35
  [project.optional-dependencies]
35
- dev = ["pytest", "pytest-cov", "ruff"]
36
+ dev = ["pytest", "pytest-cov", "ruff", "websockets>=12"]
36
37
 
37
38
  [project.urls]
38
39
  Homepage = "https://plexus.dev"
@@ -0,0 +1,255 @@
1
+ """Wire-compatibility tests for plexus.ws.WebSocketTransport.
2
+
3
+ Spins up a tiny `websockets`-based server on localhost that impersonates the
4
+ gateway's /ws/device endpoint and asserts the frames the SDK exchanges match
5
+ the C SDK / gateway contract:
6
+
7
+ device_auth → authenticated → telemetry → heartbeat → typed_command roundtrip
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import threading
15
+ import time
16
+ from typing import Any, Dict, List
17
+
18
+ import pytest
19
+
20
+ websockets = pytest.importorskip("websockets")
21
+ from websockets.server import serve # noqa: E402
22
+
23
+ from plexus.ws import WebSocketTransport # noqa: E402
24
+
25
+
26
+ class _StubGateway:
27
+ """Minimal gateway stub. Records every frame the client sends."""
28
+
29
+ def __init__(self):
30
+ self.received: List[Dict[str, Any]] = []
31
+ self.auth_frame: Dict[str, Any] = {}
32
+ self._loop: asyncio.AbstractEventLoop | None = None
33
+ self._server = None
34
+ self._thread: threading.Thread | None = None
35
+ self.port = 0
36
+ self._ws = None
37
+ self._ready = threading.Event()
38
+
39
+ def start(self) -> None:
40
+ self._thread = threading.Thread(target=self._run, daemon=True)
41
+ self._thread.start()
42
+ assert self._ready.wait(timeout=3), "stub server did not start"
43
+
44
+ def stop(self) -> None:
45
+ if self._loop and self._server:
46
+ self._loop.call_soon_threadsafe(self._server.close)
47
+ if self._thread:
48
+ self._thread.join(timeout=2)
49
+
50
+ async def _handler(self, ws, path="/ws/device"):
51
+ self._ws = ws
52
+ # First frame must be device_auth.
53
+ raw = await ws.recv()
54
+ msg = json.loads(raw)
55
+ self.auth_frame = msg
56
+ await ws.send(json.dumps({
57
+ "type": "authenticated",
58
+ "source_id": msg.get("source_id"),
59
+ }))
60
+ try:
61
+ async for raw in ws:
62
+ self.received.append(json.loads(raw))
63
+ except websockets.ConnectionClosed:
64
+ return
65
+
66
+ async def send_command(self, cmd_id: str, name: str, params: Dict[str, Any]):
67
+ assert self._ws is not None
68
+ await self._ws.send(json.dumps({
69
+ "type": "typed_command",
70
+ "id": cmd_id,
71
+ "command": name,
72
+ "params": params,
73
+ }))
74
+
75
+ def send_command_sync(self, cmd_id: str, name: str, params: Dict[str, Any]):
76
+ assert self._loop is not None
77
+ fut = asyncio.run_coroutine_threadsafe(
78
+ self.send_command(cmd_id, name, params), self._loop
79
+ )
80
+ fut.result(timeout=2)
81
+
82
+ def _run(self) -> None:
83
+ async def main():
84
+ self._server = await serve(self._handler, "127.0.0.1", 0)
85
+ self.port = self._server.sockets[0].getsockname()[1]
86
+ self._ready.set()
87
+ await self._server.wait_closed()
88
+
89
+ self._loop = asyncio.new_event_loop()
90
+ asyncio.set_event_loop(self._loop)
91
+ try:
92
+ self._loop.run_until_complete(main())
93
+ finally:
94
+ self._loop.close()
95
+
96
+
97
+ @pytest.fixture
98
+ def gateway():
99
+ g = _StubGateway()
100
+ g.start()
101
+ yield g
102
+ g.stop()
103
+
104
+
105
+ def _url(port: int) -> str:
106
+ return f"ws://127.0.0.1:{port}"
107
+
108
+
109
+ def _wait_until(pred, timeout=3.0):
110
+ deadline = time.monotonic() + timeout
111
+ while time.monotonic() < deadline:
112
+ if pred():
113
+ return True
114
+ time.sleep(0.02)
115
+ return False
116
+
117
+
118
+ def test_auth_handshake_and_telemetry(gateway):
119
+ t = WebSocketTransport(
120
+ api_key="plx_test_abc",
121
+ source_id="drone-001",
122
+ ws_url=_url(gateway.port),
123
+ agent_version="9.9.9",
124
+ )
125
+ t.start()
126
+ try:
127
+ assert t.wait_authenticated(timeout=3)
128
+
129
+ # Auth frame shape
130
+ assert gateway.auth_frame["type"] == "device_auth"
131
+ assert gateway.auth_frame["api_key"] == "plx_test_abc"
132
+ assert gateway.auth_frame["source_id"] == "drone-001"
133
+ assert gateway.auth_frame["platform"] == "python-sdk"
134
+ assert gateway.auth_frame["agent_version"] == "9.9.9"
135
+ # commands is omitted when none registered
136
+ assert "commands" not in gateway.auth_frame
137
+
138
+ # Telemetry frame shape
139
+ assert t.send_points([
140
+ {"metric": "battery_voltage", "value": 12.4, "timestamp": 1700000000000}
141
+ ])
142
+ assert _wait_until(
143
+ lambda: any(m.get("type") == "telemetry" for m in gateway.received)
144
+ )
145
+ tele = next(m for m in gateway.received if m["type"] == "telemetry")
146
+ assert tele["points"][0]["metric"] == "battery_voltage"
147
+ assert tele["points"][0]["value"] == 12.4
148
+ finally:
149
+ t.stop()
150
+
151
+
152
+ def test_command_roundtrip(gateway):
153
+ got: Dict[str, Any] = {}
154
+
155
+ def reboot(name: str, params: Dict[str, Any]) -> Dict[str, Any]:
156
+ got["name"] = name
157
+ got["params"] = params
158
+ return {"ok": True, "delay": params.get("delay_s")}
159
+
160
+ t = WebSocketTransport(
161
+ api_key="plx_test_abc",
162
+ source_id="drone-001",
163
+ ws_url=_url(gateway.port),
164
+ )
165
+ t.register_command("reboot", reboot, description="reboot device")
166
+ t.start()
167
+ try:
168
+ assert t.wait_authenticated(timeout=3)
169
+ # Advertised in auth frame
170
+ assert gateway.auth_frame["commands"] == [
171
+ {"name": "reboot", "description": "reboot device"}
172
+ ]
173
+
174
+ gateway.send_command_sync("cmd-42", "reboot", {"delay_s": 10})
175
+
176
+ # Expect ack then result
177
+ assert _wait_until(
178
+ lambda: sum(
179
+ 1 for m in gateway.received
180
+ if m.get("type") == "command_result" and m.get("id") == "cmd-42"
181
+ ) >= 2
182
+ )
183
+ results = [
184
+ m for m in gateway.received
185
+ if m.get("type") == "command_result" and m.get("id") == "cmd-42"
186
+ ]
187
+ assert results[0]["event"] == "ack"
188
+ assert results[0]["command"] == "reboot"
189
+ assert results[1]["event"] == "result"
190
+ assert results[1]["result"] == {"ok": True, "delay": 10}
191
+
192
+ assert got == {"name": "reboot", "params": {"delay_s": 10}}
193
+ finally:
194
+ t.stop()
195
+
196
+
197
+ def test_unknown_command_returns_error(gateway):
198
+ t = WebSocketTransport(
199
+ api_key="plx_test_abc",
200
+ source_id="drone-001",
201
+ ws_url=_url(gateway.port),
202
+ )
203
+ t.start()
204
+ try:
205
+ assert t.wait_authenticated(timeout=3)
206
+ gateway.send_command_sync("cmd-1", "nope", {})
207
+ assert _wait_until(lambda: any(
208
+ m.get("type") == "command_result" and m.get("event") == "error"
209
+ for m in gateway.received
210
+ ))
211
+ err = next(
212
+ m for m in gateway.received
213
+ if m.get("type") == "command_result" and m.get("event") == "error"
214
+ )
215
+ assert "unknown command" in err["error"]
216
+ finally:
217
+ t.stop()
218
+
219
+
220
+ def test_handler_exception_returns_error(gateway):
221
+ def bad(name, params):
222
+ raise RuntimeError("boom")
223
+
224
+ t = WebSocketTransport(
225
+ api_key="plx_test_abc",
226
+ source_id="drone-001",
227
+ ws_url=_url(gateway.port),
228
+ )
229
+ t.register_command("bad", bad)
230
+ t.start()
231
+ try:
232
+ assert t.wait_authenticated(timeout=3)
233
+ gateway.send_command_sync("cmd-9", "bad", {})
234
+ assert _wait_until(lambda: any(
235
+ m.get("type") == "command_result"
236
+ and m.get("event") == "error"
237
+ and m.get("id") == "cmd-9"
238
+ for m in gateway.received
239
+ ))
240
+ err = next(
241
+ m for m in gateway.received
242
+ if m.get("type") == "command_result"
243
+ and m.get("event") == "error"
244
+ and m.get("id") == "cmd-9"
245
+ )
246
+ assert err["error"] == "boom"
247
+ finally:
248
+ t.stop()
249
+
250
+
251
+ def test_ensure_device_path():
252
+ from plexus.ws import _ensure_device_path
253
+ assert _ensure_device_path("wss://foo") == "wss://foo/ws/device"
254
+ assert _ensure_device_path("wss://foo/") == "wss://foo/ws/device"
255
+ assert _ensure_device_path("wss://foo/ws/device") == "wss://foo/ws/device"
File without changes
File without changes
File without changes
File without changes
File without changes