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.
Files changed (52) hide show
  1. plexus/__init__.py +5 -22
  2. plexus/client.py +74 -8
  3. plexus/config.py +11 -29
  4. plexus/ws.py +344 -0
  5. plexus_python-0.3.0.dist-info/METADATA +176 -0
  6. plexus_python-0.3.0.dist-info/RECORD +9 -0
  7. plexus/__main__.py +0 -4
  8. plexus/adapters/__init__.py +0 -122
  9. plexus/adapters/base.py +0 -409
  10. plexus/adapters/ble.py +0 -257
  11. plexus/adapters/can.py +0 -439
  12. plexus/adapters/can_detect.py +0 -174
  13. plexus/adapters/mavlink.py +0 -642
  14. plexus/adapters/mavlink_detect.py +0 -192
  15. plexus/adapters/modbus.py +0 -622
  16. plexus/adapters/mqtt.py +0 -350
  17. plexus/adapters/opcua.py +0 -607
  18. plexus/adapters/registry.py +0 -206
  19. plexus/adapters/serial_adapter.py +0 -547
  20. plexus/cameras/__init__.py +0 -57
  21. plexus/cameras/auto.py +0 -239
  22. plexus/cameras/base.py +0 -189
  23. plexus/cameras/picamera.py +0 -171
  24. plexus/cameras/usb.py +0 -143
  25. plexus/cli.py +0 -783
  26. plexus/connector.py +0 -666
  27. plexus/deps.py +0 -246
  28. plexus/detect.py +0 -1238
  29. plexus/importers/__init__.py +0 -25
  30. plexus/importers/rosbag.py +0 -778
  31. plexus/sensors/__init__.py +0 -118
  32. plexus/sensors/ads1115.py +0 -164
  33. plexus/sensors/adxl345.py +0 -179
  34. plexus/sensors/auto.py +0 -290
  35. plexus/sensors/base.py +0 -412
  36. plexus/sensors/bh1750.py +0 -102
  37. plexus/sensors/bme280.py +0 -241
  38. plexus/sensors/gps.py +0 -317
  39. plexus/sensors/ina219.py +0 -149
  40. plexus/sensors/magnetometer.py +0 -239
  41. plexus/sensors/mpu6050.py +0 -162
  42. plexus/sensors/sht3x.py +0 -139
  43. plexus/sensors/spi_scan.py +0 -164
  44. plexus/sensors/system.py +0 -261
  45. plexus/sensors/vl53l0x.py +0 -109
  46. plexus/streaming.py +0 -743
  47. plexus/tui.py +0 -642
  48. plexus_python-0.1.0.dist-info/METADATA +0 -470
  49. plexus_python-0.1.0.dist-info/RECORD +0 -50
  50. plexus_python-0.1.0.dist-info/entry_points.txt +0 -2
  51. {plexus_python-0.1.0.dist-info → plexus_python-0.3.0.dist-info}/WHEEL +0 -0
  52. {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 Agent - Send sensor data to Plexus in one line of code.
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.config import load_config, save_config
11
+ from plexus.ws import WebSocketTransport
29
12
 
30
- __version__ = "0.1.0"
31
- __all__ = ["Plexus", "load_config", "save_config"]
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
- require_login()
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 API with retry and buffering.
304
+ """Send data points to the gateway with retry and buffering.
259
305
 
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)
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, GET /ws/device)."""
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)