plexus-python 0.11.2__tar.gz → 0.11.4__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 (63) hide show
  1. {plexus_python-0.11.2 → plexus_python-0.11.4}/API.md +7 -3
  2. {plexus_python-0.11.2 → plexus_python-0.11.4}/CHANGELOG.md +66 -0
  3. {plexus_python-0.11.2 → plexus_python-0.11.4}/CONTRIBUTING.md +2 -2
  4. {plexus_python-0.11.2 → plexus_python-0.11.4}/PKG-INFO +3 -3
  5. {plexus_python-0.11.2 → plexus_python-0.11.4}/README.md +2 -2
  6. {plexus_python-0.11.2 → plexus_python-0.11.4}/SECURITY.md +3 -3
  7. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/__init__.py +1 -1
  8. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/cli.py +109 -4
  9. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/ws.py +3 -4
  10. {plexus_python-0.11.2 → plexus_python-0.11.4}/pyproject.toml +1 -1
  11. {plexus_python-0.11.2 → plexus_python-0.11.4}/skills/plexus-firmware/SKILL.md +1 -1
  12. plexus_python-0.11.4/tests/test_cli_diagnostics.py +94 -0
  13. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_ws.py +1 -1
  14. {plexus_python-0.11.2 → plexus_python-0.11.4}/uv.lock +1 -1
  15. plexus_python-0.11.2/TODO.md +0 -1
  16. plexus_python-0.11.2/scripts/plexus.service +0 -27
  17. plexus_python-0.11.2/scripts/scan_buses.py +0 -17
  18. {plexus_python-0.11.2 → plexus_python-0.11.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {plexus_python-0.11.2 → plexus_python-0.11.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {plexus_python-0.11.2 → plexus_python-0.11.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {plexus_python-0.11.2 → plexus_python-0.11.4}/.github/workflows/ci.yml +0 -0
  22. {plexus_python-0.11.2 → plexus_python-0.11.4}/.github/workflows/publish.yml +0 -0
  23. {plexus_python-0.11.2 → plexus_python-0.11.4}/.gitignore +0 -0
  24. {plexus_python-0.11.2 → plexus_python-0.11.4}/AGENTS.md +0 -0
  25. {plexus_python-0.11.2 → plexus_python-0.11.4}/CODE_OF_CONDUCT.md +0 -0
  26. {plexus_python-0.11.2 → plexus_python-0.11.4}/LICENSE +0 -0
  27. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/.python-version +0 -0
  28. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/README.md +0 -0
  29. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/basic.py +0 -0
  30. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/can.py +0 -0
  31. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/i2c_bme280.py +0 -0
  32. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/mac_metrics.py +0 -0
  33. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/mavlink.py +0 -0
  34. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/mqtt.py +0 -0
  35. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/pyproject.toml +0 -0
  36. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/thermal_camera.py +0 -0
  37. {plexus_python-0.11.2 → plexus_python-0.11.4}/examples/uv.lock +0 -0
  38. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/_log.py +0 -0
  39. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/batching.py +0 -0
  40. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/buffer.py +0 -0
  41. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/cameras/__init__.py +0 -0
  42. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/cameras/thermal.py +0 -0
  43. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/client.py +0 -0
  44. {plexus_python-0.11.2 → plexus_python-0.11.4}/plexus/config.py +0 -0
  45. {plexus_python-0.11.2 → plexus_python-0.11.4}/scripts/release.sh +0 -0
  46. {plexus_python-0.11.2 → plexus_python-0.11.4}/scripts/setup.sh +0 -0
  47. {plexus_python-0.11.2 → plexus_python-0.11.4}/scripts/verify_skills.py +0 -0
  48. {plexus_python-0.11.2 → plexus_python-0.11.4}/skills/README.md +0 -0
  49. {plexus_python-0.11.2 → plexus_python-0.11.4}/skills/plexus/SKILL.md +0 -0
  50. {plexus_python-0.11.2 → plexus_python-0.11.4}/skills/plexus-dashboard/SKILL.md +0 -0
  51. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/conftest.py +0 -0
  52. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_basic.py +0 -0
  53. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_batching.py +0 -0
  54. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_batching_wire.py +0 -0
  55. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_buffer.py +0 -0
  56. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_config.py +0 -0
  57. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_isolation.py +0 -0
  58. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_rate_limit.py +0 -0
  59. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_retry.py +0 -0
  60. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_runs.py +0 -0
  61. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_skills.py +0 -0
  62. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_thermal.py +0 -0
  63. {plexus_python-0.11.2 → plexus_python-0.11.4}/tests/test_video.py +0 -0
@@ -35,6 +35,7 @@ curl -X POST https://gateway.plexus.company/ingest \
35
35
  -H "Content-Type: application/json" \
36
36
  -d '{
37
37
  "points": [{
38
+ "class": "metric",
38
39
  "metric": "temperature",
39
40
  "value": 72.5,
40
41
  "timestamp": 1699900000,
@@ -83,6 +84,7 @@ x-api-key: plx_xxxxx
83
84
  {
84
85
  "points": [
85
86
  {
87
+ "class": "metric",
86
88
  "metric": "temperature",
87
89
  "value": 72.5,
88
90
  "timestamp": 1699900000.123,
@@ -216,9 +218,9 @@ Devices authenticate using an API key. The gateway echoes the declared `source_i
216
218
  {
217
219
  "type": "telemetry",
218
220
  "points": [
219
- { "metric": "accel_x", "value": 0.12, "timestamp": 1699900000123 },
220
- { "metric": "accel_y", "value": 0.05, "timestamp": 1699900000123 },
221
- { "metric": "accel_z", "value": 9.81, "timestamp": 1699900000123 }
221
+ { "class": "metric", "metric": "accel_x", "value": 0.12, "timestamp": 1699900000123 },
222
+ { "class": "metric", "metric": "accel_y", "value": 0.05, "timestamp": 1699900000123 },
223
+ { "class": "metric", "metric": "accel_z", "value": 9.81, "timestamp": 1699900000123 }
222
224
  ]
223
225
  }
224
226
  ```
@@ -251,6 +253,7 @@ requests.post(
251
253
  headers={"x-api-key": "plx_xxxxx"},
252
254
  json={
253
255
  "points": [{
256
+ "class": "metric",
254
257
  "metric": "temperature",
255
258
  "value": 72.5,
256
259
  "timestamp": time.time(),
@@ -297,6 +300,7 @@ import (
297
300
  func main() {
298
301
  points := map[string]interface{}{
299
302
  "points": []map[string]interface{}{{
303
+ "class": "metric",
300
304
  "metric": "temperature",
301
305
  "value": 72.5,
302
306
  "timestamp": float64(time.Now().Unix()),
@@ -2,6 +2,72 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.4] - 2026-09-08 - Docs that actually work
6
+
7
+ No code changes. Every edit here is documentation or dead weight, but the
8
+ first item was breaking people who copied from the docs.
9
+
10
+ ### Fixed
11
+
12
+ - **Every raw-HTTP example in `API.md` would have returned 400.** The
13
+ gateway requires `class` on each point (`points[i].class is required`)
14
+ and not one of the five curl/Python/Go/WebSocket examples included it.
15
+ The SDK sets `class` for you, which is exactly why this survived so
16
+ long: the library path worked, and only people copying the HTTP
17
+ examples hit it.
18
+
19
+ - **The documented `source_id` pattern rejected valid ids.** README and
20
+ the firmware skill both said `^[a-z0-9][a-z0-9_-]{1,62}$`, while
21
+ `client.py` enforces `^[a-z0-9][a-z0-9._-]*$` at 256 characters. Dotted
22
+ ids like `sat.01` are legal and read as forbidden.
23
+
24
+ - **`SECURITY.md` declared the shipping version unsupported** — the table
25
+ stopped at `0.5.x` — and pointed at an address that isn't monitored.
26
+ It now says `0.11.x` and `info@plexus.company`.
27
+
28
+ ### Removed
29
+
30
+ - **All references to the C SDK.** `plexus-oss/plexus-c` is deleted, so
31
+ the README, `ws.py` and `test_ws.py` were describing the wire protocol
32
+ by pointing at a repository that 404s. Now described as what it is: the
33
+ gateway's device wire protocol. `CHANGELOG` history is left alone.
34
+
35
+ - **`scripts/plexus.service`** — a systemd unit running `plexus start`, a
36
+ subcommand that does not exist. Nothing referenced it, and the `/setup`
37
+ installer tells you to write your own unit.
38
+
39
+ - **`scripts/scan_buses.py`** (17-line scratch script, undeclared
40
+ `smbus2` dependency, no callers) and a 7-byte `TODO.md`.
41
+
42
+
43
+ ## [0.11.3] - 2026-09-02 - Let the CLI diagnose the CLI
44
+
45
+ All three of these came out of one evening of actually using it.
46
+
47
+ ### Added
48
+
49
+ - **`plexus --version`.** It previously exited 2 with "the following
50
+ arguments are required: command" — the least useful possible reply to the
51
+ first thing anyone types when a CLI misbehaves.
52
+
53
+ ### Fixed
54
+
55
+ - **An unknown subcommand now names the running version and the upgrade.**
56
+ `invalid choice: 'skills'` sends people hunting for a typo when the real
57
+ cause is an old install — a pipx shim from four months earlier shadowing a
58
+ fresh `pip install`, in the case that prompted this. The command genuinely
59
+ does not exist *there*, and the fix is an upgrade, so the error says which
60
+ version is running and gives both the pip and pipx forms.
61
+
62
+ - **`whoami` now asks the server whether the key is actually good.** It used
63
+ to print the local key and exit 0 without checking anything, so a revoked
64
+ or expired credential produced a confident-looking summary and the 401s
65
+ that followed looked unrelated. It now reports the org and scopes on
66
+ success, distinguishes rejected (401) from access-disabled (403), and
67
+ treats unreachable as *unknown* rather than invalid — conflating those is
68
+ how a flaky connection becomes an afternoon of regenerating good
69
+ credentials. `--no-verify` keeps the old local-only behaviour.
70
+
5
71
  ## [0.11.2] - 2026-09-02 - Stamp run boundaries on the server's clock
6
72
 
7
73
  ### Fixed
@@ -1,4 +1,4 @@
1
- # Contributing to Plexus Agent
1
+ # Contributing to plexus-python
2
2
 
3
3
  Thanks for your interest in contributing! Here's how to get started.
4
4
 
@@ -6,7 +6,7 @@ Thanks for your interest in contributing! Here's how to get started.
6
6
 
7
7
  ```bash
8
8
  git clone https://github.com/plexus-oss/plexus-python.git
9
- cd agent
9
+ cd plexus-python
10
10
  python -m venv .venv
11
11
  source .venv/bin/activate
12
12
  pip install -e ".[dev]"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plexus-python
3
- Version: 0.11.2
3
+ Version: 0.11.4
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.company
6
6
  Project-URL: Documentation, https://docs.plexus.company
@@ -66,7 +66,7 @@ curl -sL https://app.plexus.company/setup | bash -s -- \
66
66
  --key plx_xxx --name drone-01
67
67
  ```
68
68
 
69
- The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
69
+ The name must match `^[a-z0-9][a-z0-9._-]*$` (max 256 chars). `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
70
70
 
71
71
  **Names are not auto-deduplicated.** The gateway echoes back whatever `source_id` you declare, unchanged — pick a unique name per device (that's what `--name` and `source_id=...` are for). Two devices that declare the same name write into the same source.
72
72
 
@@ -252,7 +252,7 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no cor
252
252
 
253
253
  ## Transport
254
254
 
255
- By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
255
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — the gateway's device wire protocol. This gives you:
256
256
 
257
257
  - lower-latency streaming of telemetry,
258
258
  - live command delivery from the UI / API to the device.
@@ -29,7 +29,7 @@ curl -sL https://app.plexus.company/setup | bash -s -- \
29
29
  --key plx_xxx --name drone-01
30
30
  ```
31
31
 
32
- The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
32
+ The name must match `^[a-z0-9][a-z0-9._-]*$` (max 256 chars). `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
33
33
 
34
34
  **Names are not auto-deduplicated.** The gateway echoes back whatever `source_id` you declare, unchanged — pick a unique name per device (that's what `--name` and `source_id=...` are for). Two devices that declare the same name write into the same source.
35
35
 
@@ -215,7 +215,7 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no cor
215
215
 
216
216
  ## Transport
217
217
 
218
- By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
218
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — the gateway's device wire protocol. This gives you:
219
219
 
220
220
  - lower-latency streaming of telemetry,
221
221
  - live command delivery from the UI / API to the device.
@@ -6,7 +6,7 @@ If you discover a security vulnerability in plexus-python, please report it resp
6
6
 
7
7
  **Do NOT open a public GitHub issue for security vulnerabilities.**
8
8
 
9
- Instead, email **support@plexus.company** with:
9
+ Instead, email **info@plexus.company** with:
10
10
 
11
11
  - Description of the vulnerability
12
12
  - Steps to reproduce
@@ -23,8 +23,8 @@ Instead, email **support@plexus.company** with:
23
23
 
24
24
  | Version | Supported |
25
25
  | ------- | --------- |
26
- | 0.5.x | Yes |
27
- | < 0.5 | No |
26
+ | 0.11.x | Yes |
27
+ | < 0.11 | No |
28
28
 
29
29
  ## Disclosure Policy
30
30
 
@@ -23,7 +23,7 @@ from plexus.client import (
23
23
  )
24
24
  from plexus.config import RetryConfig
25
25
 
26
- __version__ = "0.11.2"
26
+ __version__ = "0.11.4"
27
27
  __all__ = [
28
28
  "AuthenticationError",
29
29
  "BatchSender",
@@ -31,6 +31,7 @@ import threading
31
31
  import urllib.parse
32
32
  import webbrowser
33
33
  from pathlib import Path
34
+ from typing import Any
34
35
 
35
36
  from . import config
36
37
 
@@ -428,8 +429,15 @@ def cmd_logout(_args: argparse.Namespace) -> int:
428
429
  return 0
429
430
 
430
431
 
431
- def cmd_whoami(_args: argparse.Namespace) -> int:
432
- """Print the prefix of the locally stored key + the configured endpoint."""
432
+ def cmd_whoami(args: argparse.Namespace) -> int:
433
+ """Report the local key, and whether the server still accepts it.
434
+
435
+ Printing the key alone was worse than printing nothing: a revoked or
436
+ expired credential produced confident-looking output and a zero exit, and
437
+ the 401s that followed from the SDK looked unrelated. `whoami` is what
438
+ someone runs precisely when they suspect their auth — it has to answer
439
+ that question rather than confirm a file exists.
440
+ """
433
441
  key = config.get_api_key()
434
442
  endpoint = config.get_endpoint()
435
443
  if not key:
@@ -438,7 +446,60 @@ def cmd_whoami(_args: argparse.Namespace) -> int:
438
446
  masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
439
447
  print(f"key: {masked}")
440
448
  print(f"endpoint: {endpoint}")
441
- return 0
449
+
450
+ if args.no_verify:
451
+ return 0
452
+
453
+ status, body = _verify_key(endpoint, key)
454
+ if status == 200:
455
+ org = body.get("org_id") or "unknown"
456
+ scopes = ", ".join(body.get("scopes") or []) or "default"
457
+ print(f"org: {org}")
458
+ print(f"scopes: {scopes}")
459
+ print("status: valid")
460
+ return 0
461
+ if status == 401:
462
+ print("status: REJECTED — this key is invalid, revoked or expired.")
463
+ print(" Run `plexus init --force` to authorize this machine again.")
464
+ return 1
465
+ if status == 403:
466
+ print("status: DISABLED — the key is real but access is switched off.")
467
+ print(" Usually billing; check with your org admin.")
468
+ return 1
469
+ if status is None:
470
+ # Could not ask. Say so rather than implying either answer.
471
+ print(f"status: unknown — could not reach {endpoint} ({body})")
472
+ return 0
473
+ print(f"status: unexpected response ({status})")
474
+ return 1
475
+
476
+
477
+ def _verify_key(endpoint: str, key: str) -> tuple[int | None, Any]:
478
+ """Ask the server whether a key is good. Returns (status, parsed_or_reason).
479
+
480
+ A network failure returns (None, reason): unreachable is not the same as
481
+ rejected, and reporting one as the other is how a flaky connection gets
482
+ mistaken for a credentials problem.
483
+ """
484
+ import json as _json
485
+ import urllib.error
486
+ import urllib.request
487
+
488
+ req = urllib.request.Request(
489
+ f"{endpoint.rstrip('/')}/api/auth/verify-key",
490
+ headers={"x-api-key": key},
491
+ method="GET",
492
+ )
493
+ try:
494
+ with urllib.request.urlopen(req, timeout=10) as resp:
495
+ return resp.status, _json.loads(resp.read().decode() or "{}")
496
+ except urllib.error.HTTPError as e:
497
+ try:
498
+ return e.code, _json.loads(e.read().decode() or "{}")
499
+ except Exception:
500
+ return e.code, {}
501
+ except Exception as e: # DNS, TLS, timeout, offline
502
+ return None, str(e)
442
503
 
443
504
 
444
505
  def _bundled_skills_dir() -> Path | None:
@@ -507,11 +568,50 @@ def _default_skills_target(args: argparse.Namespace) -> Path:
507
568
  return Path.home() / ".claude" / "skills"
508
569
 
509
570
 
571
+ class _VersionAwareParser(argparse.ArgumentParser):
572
+ """Turns an unknown subcommand into a version diagnosis.
573
+
574
+ argparse says `invalid choice: 'skills'` and stops. When the real cause is
575
+ an old install — a pipx shim from months ago shadowing a fresh pip
576
+ install, say — that message sends people looking for a typo instead of at
577
+ their version. The command they were told to run genuinely does not exist
578
+ *here*, and the fix is an upgrade, so say which version is running and how
579
+ to move it.
580
+ """
581
+
582
+ def error(self, message: str) -> None: # type: ignore[override]
583
+ if "invalid choice" in message:
584
+ from plexus import __version__
585
+
586
+ self.print_usage(sys.stderr)
587
+ print(f"\n{self.prog}: error: {message}", file=sys.stderr)
588
+ print(
589
+ f"\nYou are running plexus-python {__version__}. If you were "
590
+ "following a doc that\nnames this command, your install is "
591
+ "probably older than the doc:\n"
592
+ "\n pip install --upgrade plexus-python"
593
+ "\n pipx upgrade plexus-python # if you installed with pipx"
594
+ "\n\nThen check with: plexus --version",
595
+ file=sys.stderr,
596
+ )
597
+ self.exit(2)
598
+ super().error(message)
599
+
600
+
510
601
  def build_parser() -> argparse.ArgumentParser:
511
- parser = argparse.ArgumentParser(
602
+ from plexus import __version__
603
+
604
+ parser = _VersionAwareParser(
512
605
  prog="plexus",
513
606
  description="Plexus CLI — auth, send, query telemetry from your terminal.",
514
607
  )
608
+ # The first thing anyone types when a CLI misbehaves, and previously an
609
+ # error: `plexus --version` demanded a subcommand and told you nothing.
610
+ parser.add_argument(
611
+ "--version",
612
+ action="version",
613
+ version=f"plexus-python {__version__}",
614
+ )
515
615
  sub = parser.add_subparsers(dest="command", required=True)
516
616
 
517
617
  init = sub.add_parser(
@@ -537,6 +637,11 @@ def build_parser() -> argparse.ArgumentParser:
537
637
  logout.set_defaults(func=cmd_logout)
538
638
 
539
639
  whoami = sub.add_parser("whoami", help="Show the local credential summary.")
640
+ whoami.add_argument(
641
+ "--no-verify",
642
+ action="store_true",
643
+ help="Skip the server check and only print what is stored locally.",
644
+ )
540
645
  whoami.set_defaults(func=cmd_whoami)
541
646
 
542
647
  skills = sub.add_parser(
@@ -1,7 +1,7 @@
1
1
  """
2
2
  WebSocket transport for the Plexus Python SDK.
3
3
 
4
- Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
4
+ Implements the gateway's device wire protocol. Targets its
5
5
  `/ws/device` endpoint and exchanges the same JSON frames:
6
6
 
7
7
  client → {"type": "device_auth", "api_key": ..., "source_id": ...,
@@ -392,7 +392,7 @@ class WebSocketTransport:
392
392
  command = msg.get("command") or ""
393
393
  params = msg.get("params") or {}
394
394
 
395
- # Ack immediately (matches C SDK: plexus_ws.c:275-280)
395
+ # Ack immediately, per the gateway wire contract
396
396
  self._send_frame({
397
397
  "type": "command_result",
398
398
  "id": cmd_id,
@@ -547,8 +547,7 @@ def _safe_json(raw: Any) -> dict[str, Any]:
547
547
 
548
548
 
549
549
  def _backoff_delay(attempt: int) -> float:
550
- """Exponential backoff with ±25% jitter, capped at BACKOFF_MAX_S.
551
- Matches plexus_ws.c:44-52."""
550
+ """Exponential backoff with ±25% jitter, capped at BACKOFF_MAX_S."""
552
551
  base = min(BACKOFF_BASE_S * (2 ** attempt), BACKOFF_MAX_S)
553
552
  jitter = base * 0.25 * (2 * random.random() - 1)
554
553
  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.11.2"
7
+ version = "0.11.4"
8
8
  description = "Thin Python SDK for Plexus — send telemetry in one line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -61,7 +61,7 @@ Four things the gateway will reject you for. Get these right or nothing lands:
61
61
  1. **The array is `points`.** `"metrics": [...]` returns `400 {"error":"'points' array is required"}`.
62
62
  2. **Every point needs `class`**, either `"metric"` or `"event"`. There is no default.
63
63
  3. **`timestamp` is a number, never a string.** An ISO-8601 string returns `points[i].timestamp must be a number`. Epoch **milliseconds**; a positive value below `1e12` is read as **seconds** and scaled for you, so either unit works as long as it's numeric.
64
- 4. **`source_id` must match `^[a-z0-9][a-z0-9_-]{1,62}$`** and is not deduplicated — two devices declaring the same id merge into one source. This is what SD-card clones do when they all boot as `raspberrypi`.
64
+ 4. **`source_id` must match `^[a-z0-9][a-z0-9._-]*$` (max 256 chars)** and is not deduplicated — two devices declaring the same id merge into one source. This is what SD-card clones do when they all boot as `raspberrypi`.
65
65
 
66
66
  `timestamp` is optional. Omit it and the gateway stamps the point with its receive time — which is the right move on a device whose clock has never been NTP-synced. Per-point `tags` (a flat string→string map) are supported and optional.
67
67
 
@@ -0,0 +1,94 @@
1
+ """The CLI has to help you debug the CLI.
2
+
3
+ Every case here came out of one evening of real use: a four-month-old pipx
4
+ shim shadowing a fresh install, `plexus --version` refusing to answer, and
5
+ `whoami` cheerfully printing a credential it had never checked.
6
+ """
7
+
8
+ from unittest import mock
9
+
10
+ import pytest
11
+
12
+ from plexus import __version__
13
+ from plexus.cli import build_parser, cmd_whoami
14
+
15
+
16
+ def _args(**kw):
17
+ ns = mock.Mock()
18
+ ns.no_verify = kw.get("no_verify", False)
19
+ return ns
20
+
21
+
22
+ def test_version_flag_answers_instead_of_demanding_a_subcommand(capsys):
23
+ # It used to exit 2 with "the following arguments are required: command",
24
+ # which is the least useful possible reply to "what version am I on".
25
+ with pytest.raises(SystemExit) as exc:
26
+ build_parser().parse_args(["--version"])
27
+ assert exc.value.code == 0
28
+ assert __version__ in capsys.readouterr().out
29
+
30
+
31
+ def test_unknown_subcommand_names_the_version_and_the_upgrade(capsys):
32
+ """`invalid choice: 'skills'` sends people hunting for a typo when the real
33
+ cause is an old install. Say which version is running."""
34
+ with pytest.raises(SystemExit) as exc:
35
+ build_parser().parse_args(["skills-that-do-not-exist-yet"])
36
+ assert exc.value.code == 2
37
+ err = capsys.readouterr().err
38
+ assert __version__ in err
39
+ assert "pip install --upgrade" in err
40
+ assert "pipx upgrade" in err # the shadowing case that caused this
41
+
42
+
43
+ def test_whoami_reports_a_valid_key_with_its_org(monkeypatch, capsys):
44
+ monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "a" * 32)
45
+ with mock.patch(
46
+ "plexus.cli._verify_key",
47
+ return_value=(200, {"org_id": "org_abc", "scopes": ["read", "write"]}),
48
+ ):
49
+ assert cmd_whoami(_args()) == 0
50
+ out = capsys.readouterr().out
51
+ assert "org_abc" in out and "valid" in out
52
+
53
+
54
+ def test_whoami_fails_loudly_on_a_rejected_key(monkeypatch, capsys):
55
+ """The whole point. A revoked key used to print a confident summary and
56
+ exit 0, so the 401s that followed looked unrelated."""
57
+ monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "b" * 32)
58
+ with mock.patch("plexus.cli._verify_key", return_value=(401, {})):
59
+ assert cmd_whoami(_args()) == 1
60
+ out = capsys.readouterr().out
61
+ assert "REJECTED" in out
62
+ assert "plexus init --force" in out # tell them the fix, not just the fault
63
+
64
+
65
+ def test_whoami_distinguishes_disabled_from_invalid(monkeypatch, capsys):
66
+ monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "c" * 32)
67
+ with mock.patch("plexus.cli._verify_key", return_value=(403, {})):
68
+ assert cmd_whoami(_args()) == 1
69
+ assert "DISABLED" in capsys.readouterr().out
70
+
71
+
72
+ def test_unreachable_is_not_reported_as_invalid(monkeypatch, capsys):
73
+ """Offline is not rejected. Conflating them turns a flaky connection into
74
+ an afternoon spent regenerating perfectly good credentials."""
75
+ monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "d" * 32)
76
+ with mock.patch("plexus.cli._verify_key", return_value=(None, "connection refused")):
77
+ assert cmd_whoami(_args()) == 0 # unknown is not failure
78
+ out = capsys.readouterr().out
79
+ assert "unknown" in out
80
+ assert "REJECTED" not in out
81
+
82
+
83
+ def test_no_verify_skips_the_network(monkeypatch, capsys):
84
+ monkeypatch.setenv("PLEXUS_API_KEY", "plx_" + "e" * 32)
85
+ with mock.patch("plexus.cli._verify_key") as v:
86
+ assert cmd_whoami(_args(no_verify=True)) == 0
87
+ v.assert_not_called()
88
+
89
+
90
+ def test_whoami_without_a_key_says_what_to_run(monkeypatch, capsys):
91
+ monkeypatch.delenv("PLEXUS_API_KEY", raising=False)
92
+ with mock.patch("plexus.config.load_config", return_value={}):
93
+ assert cmd_whoami(_args()) == 1
94
+ assert "plexus init" in capsys.readouterr().out
@@ -2,7 +2,7 @@
2
2
 
3
3
  Spins up a tiny `websockets`-based server on localhost that impersonates the
4
4
  gateway's /ws/device endpoint and asserts the frames the SDK exchanges match
5
- the C SDK / gateway contract:
5
+ the gateway contract:
6
6
 
7
7
  device_auth → authenticated → telemetry → heartbeat → typed_command roundtrip
8
8
  """
@@ -425,7 +425,7 @@ wheels = [
425
425
 
426
426
  [[package]]
427
427
  name = "plexus-python"
428
- version = "0.11.1"
428
+ version = "0.11.4"
429
429
  source = { editable = "." }
430
430
  dependencies = [
431
431
  { name = "websocket-client" },
@@ -1 +0,0 @@
1
- # TODO
@@ -1,27 +0,0 @@
1
- [Unit]
2
- Description=Plexus Agent
3
- Documentation=https://docs.plexus.company
4
- After=network-online.target
5
- Wants=network-online.target
6
-
7
- [Service]
8
- Type=simple
9
- User=pi
10
- ExecStart=/usr/local/bin/plexus start
11
- Restart=always
12
- RestartSec=10
13
- Environment=PLEXUS_CONFIG_DIR=/home/pi/.plexus
14
-
15
- # Logging
16
- StandardOutput=journal
17
- StandardError=journal
18
- SyslogIdentifier=plexus
19
-
20
- # Security hardening
21
- NoNewPrivileges=true
22
- ProtectSystem=strict
23
- ProtectHome=read-only
24
- ReadWritePaths=/home/pi/.plexus
25
-
26
- [Install]
27
- WantedBy=multi-user.target
@@ -1,17 +0,0 @@
1
- from smbus2 import SMBus
2
-
3
- for bus in [0, 1, 10, 20, 21, 22]:
4
- try:
5
- s = SMBus(bus)
6
- addrs = []
7
- for a in range(0x03, 0x78):
8
- try:
9
- s.read_byte(a)
10
- addrs.append(f'0x{a:02X}')
11
- except OSError:
12
- pass
13
- s.close()
14
- if addrs:
15
- print(f'Bus {bus}: {addrs}')
16
- except Exception as e:
17
- print(f'Bus {bus}: {e}')
File without changes
File without changes