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.
- {plexus_python-0.2.0 → plexus_python-0.3.0}/CHANGELOG.md +13 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/PKG-INFO +38 -2
- {plexus_python-0.2.0 → plexus_python-0.3.0}/README.md +35 -1
- {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/__init__.py +3 -2
- {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/client.py +70 -4
- plexus_python-0.3.0/plexus/ws.py +344 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/pyproject.toml +3 -2
- plexus_python-0.3.0/tests/test_ws.py +255 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/.gitignore +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/AGENTS.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/API.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/CONTRIBUTING.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/LICENSE +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/SECURITY.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/README.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/basic.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/can.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/mavlink.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/examples/mqtt.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/buffer.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/plexus/config.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/scripts/plexus.service +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/scripts/scan_buses.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/scripts/setup.sh +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_basic.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_buffer.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.3.0}/tests/test_config.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
262
|
-
- No retry on: HTTP 401/403 (auth
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|