picotty 1.0.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 (42) hide show
  1. picotty-1.0.0/PKG-INFO +164 -0
  2. picotty-1.0.0/README.md +125 -0
  3. picotty-1.0.0/pyproject.toml +73 -0
  4. picotty-1.0.0/src/picotty/__init__.py +28 -0
  5. picotty-1.0.0/src/picotty/client/__init__.py +175 -0
  6. picotty-1.0.0/src/picotty/hub/__init__.py +35 -0
  7. picotty-1.0.0/src/picotty/hub/alerts.py +111 -0
  8. picotty-1.0.0/src/picotty/hub/api/__init__.py +1 -0
  9. picotty-1.0.0/src/picotty/hub/api/models.py +159 -0
  10. picotty-1.0.0/src/picotty/hub/api/rest.py +805 -0
  11. picotty-1.0.0/src/picotty/hub/api/ws.py +65 -0
  12. picotty-1.0.0/src/picotty/hub/classifier.py +65 -0
  13. picotty-1.0.0/src/picotty/hub/config.py +122 -0
  14. picotty-1.0.0/src/picotty/hub/core.py +339 -0
  15. picotty-1.0.0/src/picotty/hub/db.py +672 -0
  16. picotty-1.0.0/src/picotty/hub/eventbus.py +65 -0
  17. picotty-1.0.0/src/picotty/hub/expect.py +276 -0
  18. picotty-1.0.0/src/picotty/hub/main.py +151 -0
  19. picotty-1.0.0/src/picotty/hub/ota.py +308 -0
  20. picotty-1.0.0/src/picotty/hub/protocol.py +90 -0
  21. picotty-1.0.0/src/picotty/hub/registry.py +89 -0
  22. picotty-1.0.0/src/picotty/hub/runbook.py +186 -0
  23. picotty-1.0.0/src/picotty/hub/serialbridge.py +164 -0
  24. picotty-1.0.0/src/picotty/hub/tasks.py +115 -0
  25. picotty-1.0.0/src/picotty/hub/tcp_server.py +198 -0
  26. picotty-1.0.0/src/picotty/hub/telegram_setup.py +195 -0
  27. picotty-1.0.0/src/picotty/hub/utils.py +86 -0
  28. picotty-1.0.0/src/picotty/protocol.py +31 -0
  29. picotty-1.0.0/src/picotty/sim.py +271 -0
  30. picotty-1.0.0/src/picotty/static/UI-CHANGES.md +240 -0
  31. picotty-1.0.0/src/picotty/static/app.css +77 -0
  32. picotty-1.0.0/src/picotty/static/app.js +2488 -0
  33. picotty-1.0.0/src/picotty/static/help.html +623 -0
  34. picotty-1.0.0/src/picotty/static/index.html +27 -0
  35. picotty-1.0.0/src/picotty/static/styles.css +234 -0
  36. picotty-1.0.0/src/picotty/static/vendor/README.md +43 -0
  37. picotty-1.0.0/src/picotty/static/vendor/asciinema-player.css +2352 -0
  38. picotty-1.0.0/src/picotty/static/vendor/asciinema-player.min.js +1 -0
  39. picotty-1.0.0/src/picotty/static/vendor/fetch-vendor.sh +51 -0
  40. picotty-1.0.0/src/picotty/static/vendor/xterm-addon-fit.js +2 -0
  41. picotty-1.0.0/src/picotty/static/vendor/xterm.css +209 -0
  42. picotty-1.0.0/src/picotty/static/vendor/xterm.js +2 -0
picotty-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: picotty
3
+ Version: 1.0.0
4
+ Summary: Networked serial console + USB HID keyboard injection for a fleet of headless machines — hub server, client SDK, and wire protocol.
5
+ Keywords: serial,console,kvm,homelab,raspberry-pi,pico,circuitpython,hid,keyboard,out-of-band,fastapi
6
+ Author: morpheuslord
7
+ Author-email: morpheuslord <gopalkrishna.bs1971@gmail.com>
8
+ License-Expression: GPL-3.0-or-later
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: System :: Hardware
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Dist: httpx>=0.27
21
+ Requires-Dist: websockets>=12
22
+ Requires-Dist: fastapi>=0.110 ; extra == 'hub'
23
+ Requires-Dist: uvicorn[standard]>=0.27 ; extra == 'hub'
24
+ Requires-Dist: aiosqlite>=0.19 ; extra == 'hub'
25
+ Requires-Dist: pydantic>=2.0 ; extra == 'hub'
26
+ Requires-Dist: pyyaml>=6.0 ; extra == 'hub'
27
+ Requires-Dist: python-telegram-bot[rate-limiter]>=21,<22 ; extra == 'telegram'
28
+ Requires-Dist: pyotp>=2.9 ; extra == 'telegram'
29
+ Maintainer: morpheuslord
30
+ Maintainer-email: morpheuslord <gopalkrishna.bs1971@gmail.com>
31
+ Requires-Python: >=3.11
32
+ Project-URL: Homepage, https://github.com/morpheuslord/PICOTTY
33
+ Project-URL: Repository, https://github.com/morpheuslord/PICOTTY
34
+ Project-URL: Documentation, https://github.com/morpheuslord/PICOTTY/tree/main/docs
35
+ Project-URL: Issues, https://github.com/morpheuslord/PICOTTY/issues
36
+ Provides-Extra: hub
37
+ Provides-Extra: telegram
38
+ Description-Content-Type: text/markdown
39
+
40
+ # Hub — the `picotty` package
41
+
42
+ The management side, packaged as the **`picotty`** Python distribution (uv). One
43
+ process on one asyncio event loop runs two faces at once — a raw TCP server for
44
+ the node swarm and a FastAPI app for the browser — over a shared in-memory
45
+ registry and one SQLite database.
46
+
47
+ One distribution, three import surfaces:
48
+
49
+ | Import | What | Needs |
50
+ |---|---|---|
51
+ | `picotty.hub` | the server (registry + SQLite + :9000 TCP + FastAPI dashboard) | `[hub]` extra |
52
+ | `picotty.client` | the SDK: `HubClient` (REST) + `HubEvents` (WebSocket) | base install |
53
+ | `picotty.protocol` | wire framing, validation, `PROTOCOL_VERSION` | base install |
54
+
55
+ The lean base (httpx + websockets) is all a client needs; the server stack
56
+ (FastAPI, uvicorn, aiosqlite, pydantic, pyyaml) comes from the `[hub]` extra, so a
57
+ Pi Zero 2 W running only the Telegram sidecar stays small.
58
+
59
+ ## Layout
60
+
61
+ ```
62
+ hub/
63
+ pyproject.toml # the picotty distribution (uv_build backend)
64
+ uv.lock # pinned, reproducible installs
65
+ .python-version # 3.11 floor
66
+ src/picotty/
67
+ __init__.py # version, single-sourced from distribution metadata
68
+ protocol.py # public wire-protocol surface (re-exports hub/protocol.py)
69
+ client/ # the SDK: HubClient (REST) + HubEvents (WS async iterator)
70
+ sim.py # the node simulator (picotty-sim console script)
71
+ static/ # the dashboard, shipped inside the wheel (served at /)
72
+ hub/ # the server
73
+ main.py # wires TCP + FastAPI + background tasks on one loop
74
+ config.py # process config (env) + operator-tunable defaults
75
+ protocol.py # length-prefixed JSON framing (authoritative)
76
+ registry.py # in-memory NodeState registry
77
+ db.py # aiosqlite: schema, queries, batched output, retention
78
+ eventbus.py # WebSocket fan-out with subscription filtering
79
+ core.py # the shared Hub: dispatch, ping/pong, audit, view-merge
80
+ tcp_server.py # swarm face on :9000
81
+ telegram_setup.py # writes the sidecar .env for the dashboard's Telegram page
82
+ tasks.py # sweep, output flush, retention, stats, loop-lag
83
+ api/{rest,ws,models}.py
84
+ tests/ # test_db.py, test_integration.py, driver.py
85
+ scripts/ # install / run / systemd unit
86
+ ```
87
+
88
+ ## Run it (from source, with uv)
89
+
90
+ ```bash
91
+ bash scripts/install.sh # uv sync --extra hub (+ fetches terminal libs)
92
+ bash scripts/run.sh # foreground (dev), loads private/hub-token.txt
93
+ # or install as a service that starts on boot:
94
+ bash scripts/install-service.sh # renders + enables the systemd unit
95
+ journalctl -u swarm-hub -f # follow logs
96
+ ```
97
+
98
+ Manual equivalent:
99
+
100
+ ```bash
101
+ cd hub
102
+ uv sync --extra hub
103
+ uv run --extra hub picotty-hub
104
+ ```
105
+
106
+ Or install it as a tool (no repo checkout): `uv tool install picotty`, which puts
107
+ `picotty-hub` and `picotty-sim` on PATH. See **[../docs/packaging.md](../docs/packaging.md)**.
108
+
109
+ On first start the hub mints a **shared node token** and prints it once — put it
110
+ in each node's `settings.toml` as `NODE_TOKEN`. It listens on `:9000` (swarm) and
111
+ `:8080` (browser). Open http://localhost:8080 for the dashboard.
112
+
113
+ Configuration via environment (all optional): `HUB_TCP_PORT`, `HUB_HTTP_PORT`,
114
+ `HUB_DB_PATH`, `HUB_STATIC_DIR`, `HUB_TCP_HOST`, `HUB_HTTP_HOST`, `TELEGRAM_ENV_PATH`.
115
+ Runtime state (the SQLite DB) defaults to `~/.local/share/picotty/hub.db` (honors
116
+ `XDG_DATA_HOME`); the systemd unit uses `/var/lib/picotty`. Static assets ship
117
+ inside the wheel. Operator-tunable settings (heartbeat, stale timeout, retention,
118
+ confirm-dangerous, alerts) live in the DB and change via `PATCH /api/settings`.
119
+
120
+ Run under **one** uvicorn worker only. The single event loop is the design: a
121
+ second worker would get its own registry and node sockets and the two would
122
+ disagree about who is online.
123
+
124
+ ## Test without hardware
125
+
126
+ Start the hub, grab the printed token, then run one or more fake nodes with the
127
+ packaged simulator:
128
+
129
+ ```bash
130
+ uv run picotty-sim --id node-01 --token <TOKEN>
131
+ uv run picotty-sim --id node-02 --token <TOKEN>
132
+ ```
133
+
134
+ Each simulator connects, heartbeats, answers commands, and streams fake serial
135
+ output — enough to exercise the full dashboard. The suites:
136
+
137
+ ```bash
138
+ uv run python tests/test_db.py # offline db checks
139
+ uv run python tests/test_integration.py # end-to-end over real TCP + REST
140
+ ```
141
+
142
+ ## Using the client SDK
143
+
144
+ ```python
145
+ from picotty.client import HubClient
146
+
147
+ async with HubClient("http://hub:8080") as hub:
148
+ print(await hub.health())
149
+ async with hub.events_stream() as stream: # the /ws feed
150
+ await stream.subscribe("node-01")
151
+ async for ev in stream:
152
+ ...
153
+ ```
154
+
155
+ This is what the Telegram sidecar imports; it needs only the base install.
156
+
157
+ ## Notes
158
+
159
+ - **Registry is disposable.** On restart it starts empty and refills as nodes
160
+ reconnect; the SQLite record survives.
161
+ - **Output is batched** to SQLite (default every 500 ms) to protect SD-card write
162
+ throughput; the live WebSocket stream is immediate and independent.
163
+ - **Auth is optional** and off by default — the design assumes an isolated
164
+ management VLAN reached through a VPN/tunnel, not port exposure.
@@ -0,0 +1,125 @@
1
+ # Hub — the `picotty` package
2
+
3
+ The management side, packaged as the **`picotty`** Python distribution (uv). One
4
+ process on one asyncio event loop runs two faces at once — a raw TCP server for
5
+ the node swarm and a FastAPI app for the browser — over a shared in-memory
6
+ registry and one SQLite database.
7
+
8
+ One distribution, three import surfaces:
9
+
10
+ | Import | What | Needs |
11
+ |---|---|---|
12
+ | `picotty.hub` | the server (registry + SQLite + :9000 TCP + FastAPI dashboard) | `[hub]` extra |
13
+ | `picotty.client` | the SDK: `HubClient` (REST) + `HubEvents` (WebSocket) | base install |
14
+ | `picotty.protocol` | wire framing, validation, `PROTOCOL_VERSION` | base install |
15
+
16
+ The lean base (httpx + websockets) is all a client needs; the server stack
17
+ (FastAPI, uvicorn, aiosqlite, pydantic, pyyaml) comes from the `[hub]` extra, so a
18
+ Pi Zero 2 W running only the Telegram sidecar stays small.
19
+
20
+ ## Layout
21
+
22
+ ```
23
+ hub/
24
+ pyproject.toml # the picotty distribution (uv_build backend)
25
+ uv.lock # pinned, reproducible installs
26
+ .python-version # 3.11 floor
27
+ src/picotty/
28
+ __init__.py # version, single-sourced from distribution metadata
29
+ protocol.py # public wire-protocol surface (re-exports hub/protocol.py)
30
+ client/ # the SDK: HubClient (REST) + HubEvents (WS async iterator)
31
+ sim.py # the node simulator (picotty-sim console script)
32
+ static/ # the dashboard, shipped inside the wheel (served at /)
33
+ hub/ # the server
34
+ main.py # wires TCP + FastAPI + background tasks on one loop
35
+ config.py # process config (env) + operator-tunable defaults
36
+ protocol.py # length-prefixed JSON framing (authoritative)
37
+ registry.py # in-memory NodeState registry
38
+ db.py # aiosqlite: schema, queries, batched output, retention
39
+ eventbus.py # WebSocket fan-out with subscription filtering
40
+ core.py # the shared Hub: dispatch, ping/pong, audit, view-merge
41
+ tcp_server.py # swarm face on :9000
42
+ telegram_setup.py # writes the sidecar .env for the dashboard's Telegram page
43
+ tasks.py # sweep, output flush, retention, stats, loop-lag
44
+ api/{rest,ws,models}.py
45
+ tests/ # test_db.py, test_integration.py, driver.py
46
+ scripts/ # install / run / systemd unit
47
+ ```
48
+
49
+ ## Run it (from source, with uv)
50
+
51
+ ```bash
52
+ bash scripts/install.sh # uv sync --extra hub (+ fetches terminal libs)
53
+ bash scripts/run.sh # foreground (dev), loads private/hub-token.txt
54
+ # or install as a service that starts on boot:
55
+ bash scripts/install-service.sh # renders + enables the systemd unit
56
+ journalctl -u swarm-hub -f # follow logs
57
+ ```
58
+
59
+ Manual equivalent:
60
+
61
+ ```bash
62
+ cd hub
63
+ uv sync --extra hub
64
+ uv run --extra hub picotty-hub
65
+ ```
66
+
67
+ Or install it as a tool (no repo checkout): `uv tool install picotty`, which puts
68
+ `picotty-hub` and `picotty-sim` on PATH. See **[../docs/packaging.md](../docs/packaging.md)**.
69
+
70
+ On first start the hub mints a **shared node token** and prints it once — put it
71
+ in each node's `settings.toml` as `NODE_TOKEN`. It listens on `:9000` (swarm) and
72
+ `:8080` (browser). Open http://localhost:8080 for the dashboard.
73
+
74
+ Configuration via environment (all optional): `HUB_TCP_PORT`, `HUB_HTTP_PORT`,
75
+ `HUB_DB_PATH`, `HUB_STATIC_DIR`, `HUB_TCP_HOST`, `HUB_HTTP_HOST`, `TELEGRAM_ENV_PATH`.
76
+ Runtime state (the SQLite DB) defaults to `~/.local/share/picotty/hub.db` (honors
77
+ `XDG_DATA_HOME`); the systemd unit uses `/var/lib/picotty`. Static assets ship
78
+ inside the wheel. Operator-tunable settings (heartbeat, stale timeout, retention,
79
+ confirm-dangerous, alerts) live in the DB and change via `PATCH /api/settings`.
80
+
81
+ Run under **one** uvicorn worker only. The single event loop is the design: a
82
+ second worker would get its own registry and node sockets and the two would
83
+ disagree about who is online.
84
+
85
+ ## Test without hardware
86
+
87
+ Start the hub, grab the printed token, then run one or more fake nodes with the
88
+ packaged simulator:
89
+
90
+ ```bash
91
+ uv run picotty-sim --id node-01 --token <TOKEN>
92
+ uv run picotty-sim --id node-02 --token <TOKEN>
93
+ ```
94
+
95
+ Each simulator connects, heartbeats, answers commands, and streams fake serial
96
+ output — enough to exercise the full dashboard. The suites:
97
+
98
+ ```bash
99
+ uv run python tests/test_db.py # offline db checks
100
+ uv run python tests/test_integration.py # end-to-end over real TCP + REST
101
+ ```
102
+
103
+ ## Using the client SDK
104
+
105
+ ```python
106
+ from picotty.client import HubClient
107
+
108
+ async with HubClient("http://hub:8080") as hub:
109
+ print(await hub.health())
110
+ async with hub.events_stream() as stream: # the /ws feed
111
+ await stream.subscribe("node-01")
112
+ async for ev in stream:
113
+ ...
114
+ ```
115
+
116
+ This is what the Telegram sidecar imports; it needs only the base install.
117
+
118
+ ## Notes
119
+
120
+ - **Registry is disposable.** On restart it starts empty and refills as nodes
121
+ reconnect; the SQLite record survives.
122
+ - **Output is batched** to SQLite (default every 500 ms) to protect SD-card write
123
+ throughput; the live WebSocket stream is immediate and independent.
124
+ - **Auth is optional** and off by default — the design assumes an isolated
125
+ management VLAN reached through a VPN/tunnel, not port exposure.
@@ -0,0 +1,73 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11,<0.12"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "picotty"
7
+ version = "1.0.0"
8
+ description = "Networked serial console + USB HID keyboard injection for a fleet of headless machines — hub server, client SDK, and wire protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "GPL-3.0-or-later"
12
+ authors = [{ name = "morpheuslord", email = "gopalkrishna.bs1971@gmail.com" }]
13
+ maintainers = [{ name = "morpheuslord", email = "gopalkrishna.bs1971@gmail.com" }]
14
+ keywords = ["serial", "console", "kvm", "homelab", "raspberry-pi", "pico",
15
+ "circuitpython", "hid", "keyboard", "out-of-band", "fastapi"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Environment :: Web Environment",
20
+ "Framework :: FastAPI",
21
+ "Intended Audience :: System Administrators",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: System :: Hardware",
27
+ "Topic :: System :: Systems Administration",
28
+ ]
29
+
30
+ # Base install is the lean CLIENT: enough to talk to a running hub (SDK, scripts,
31
+ # the Telegram sidecar, CI). The server pulls its heavier stack via the [hub]
32
+ # extra so a client — or a Pi Zero 2 W running only the sidecar — stays small.
33
+ dependencies = [
34
+ "httpx>=0.27",
35
+ "websockets>=12",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ hub = [
40
+ "fastapi>=0.110",
41
+ "uvicorn[standard]>=0.27",
42
+ "aiosqlite>=0.19",
43
+ "pydantic>=2.0",
44
+ "pyyaml>=6.0",
45
+ ]
46
+ telegram = [
47
+ "python-telegram-bot[rate-limiter]>=21,<22",
48
+ "pyotp>=2.9",
49
+ ]
50
+
51
+ [project.scripts]
52
+ picotty-hub = "picotty.hub.main:main"
53
+ picotty-sim = "picotty.sim:main"
54
+
55
+ [project.urls]
56
+ Homepage = "https://github.com/morpheuslord/PICOTTY"
57
+ Repository = "https://github.com/morpheuslord/PICOTTY"
58
+ Documentation = "https://github.com/morpheuslord/PICOTTY/tree/main/docs"
59
+ Issues = "https://github.com/morpheuslord/PICOTTY/issues"
60
+
61
+ # Dev tooling lives in a uv dependency group, NOT an extra, so it never leaks
62
+ # into user installs. `uv sync` installs it; `uv sync --no-dev` omits it.
63
+ [dependency-groups]
64
+ dev = [
65
+ "pytest>=8",
66
+ "httpx>=0.27",
67
+ ]
68
+
69
+ [tool.uv.build-backend]
70
+ # src/ layout: the package is src/picotty. (module-name/module-root shown
71
+ # explicitly even though these are the backend defaults, so the intent is clear.)
72
+ module-name = "picotty"
73
+ module-root = "src"
@@ -0,0 +1,28 @@
1
+ """PICOTTY — networked serial console + HID keyboard injection for a fleet of
2
+ headless machines.
3
+
4
+ One distribution, three import surfaces (see Dev-Docs/improvement-docs/picotty-packaging.md):
5
+
6
+ - ``picotty.hub`` — the server (registry + SQLite + :9000 TCP + FastAPI).
7
+ Needs the ``[hub]`` extra.
8
+ - ``picotty.client`` — the SDK: :class:`~picotty.client.HubClient` (REST) and
9
+ :class:`~picotty.client.HubEvents` (WebSocket feed).
10
+ Lean; base install (httpx + websockets) only.
11
+ - ``picotty.protocol`` — the wire protocol: frame pack/unpack, validation,
12
+ ``PROTOCOL_VERSION`` and command constants. The single
13
+ authoritative definition shared by hub, client, sim.
14
+
15
+ The version is single-sourced from the installed distribution metadata (the
16
+ ``version`` field in pyproject.toml).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ try:
22
+ from importlib.metadata import PackageNotFoundError, version
23
+
24
+ __version__ = version("picotty")
25
+ except Exception: # running from a source tree with no metadata yet
26
+ __version__ = "0.0.0+source"
27
+
28
+ __all__ = ["__version__"]
@@ -0,0 +1,175 @@
1
+ """``picotty.client`` — the hub SDK.
2
+
3
+ Everything a program needs to talk to a running hub, over the same REST API and
4
+ ``/ws`` event stream the dashboard uses:
5
+
6
+ - :class:`HubClient` — async REST wrapper (health, stats, nodes, commands, send).
7
+ - :class:`HubEvents` — async iterator over the WebSocket event feed, with
8
+ per-node subscribe/unsubscribe.
9
+
10
+ Lean by design: only ``httpx`` and ``websockets``, so the base ``picotty`` install
11
+ (no ``[hub]`` extra, no FastAPI) is all a client — the Telegram sidecar, a cron
12
+ health check, CI — needs.
13
+
14
+ from picotty.client import HubClient
15
+
16
+ async with HubClient("http://hub:8080") as hub:
17
+ print(await hub.health())
18
+ async with hub.events_stream() as stream:
19
+ await stream.subscribe("node-01")
20
+ async for ev in stream:
21
+ ...
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import json
28
+ from typing import AsyncIterator, Optional
29
+
30
+ import httpx
31
+ import websockets
32
+
33
+
34
+ class HubError(Exception):
35
+ """A hub REST call returned a non-JSON body or a server (5xx) error."""
36
+
37
+
38
+ def _derive_ws(base_url: str) -> str:
39
+ base = base_url.rstrip("/")
40
+ if base.startswith("https://"):
41
+ return "wss://" + base[len("https://"):] + "/ws"
42
+ if base.startswith("http://"):
43
+ return "ws://" + base[len("http://"):] + "/ws"
44
+ return "ws://" + base + "/ws"
45
+
46
+
47
+ class HubClient:
48
+ """Async REST client for a running hub. The hub mounts REST under ``/api`` and
49
+ the WebSocket at ``/ws``; pass the server root as ``base_url``."""
50
+
51
+ def __init__(self, base_url: str, *, timeout: float = 10.0,
52
+ ws_url: Optional[str] = None):
53
+ self._base = base_url.rstrip("/")
54
+ self._ws_url = ws_url or _derive_ws(self._base)
55
+ self._http = httpx.AsyncClient(base_url=self._base + "/api", timeout=timeout)
56
+
57
+ async def __aenter__(self) -> "HubClient":
58
+ return self
59
+
60
+ async def __aexit__(self, *exc) -> None:
61
+ await self.aclose()
62
+
63
+ async def aclose(self) -> None:
64
+ await self._http.aclose()
65
+
66
+ # -- generic REST ---------------------------------------------------------
67
+
68
+ async def get(self, path: str, **params) -> dict:
69
+ params = {k: v for k, v in params.items() if v is not None}
70
+ r = await self._http.get(path, params=params)
71
+ r.raise_for_status()
72
+ return r.json()
73
+
74
+ async def post(self, path: str, body: Optional[dict] = None) -> dict:
75
+ r = await self._http.post(path, json=body or {})
76
+ if r.status_code >= 500:
77
+ r.raise_for_status()
78
+ try:
79
+ return r.json()
80
+ except ValueError:
81
+ raise HubError("non-JSON reply from hub (%s)" % r.status_code)
82
+
83
+ # -- typed convenience ----------------------------------------------------
84
+
85
+ async def health(self) -> dict:
86
+ return await self.get("/health")
87
+
88
+ async def stats(self) -> dict:
89
+ return await self.get("/stats")
90
+
91
+ async def nodes(self, **filters) -> list[dict]:
92
+ return (await self.get("/nodes", **filters)).get("nodes", [])
93
+
94
+ async def node(self, node_id: str) -> Optional[dict]:
95
+ data = await self.get("/nodes/%s" % node_id)
96
+ return data.get("node") if data.get("ok") else None
97
+
98
+ async def events(self, since: Optional[int] = None, limit: int = 100,
99
+ type_: Optional[str] = None) -> list[dict]:
100
+ data = await self.get("/events", since=since, limit=limit, type=type_)
101
+ return data.get("events", [])
102
+
103
+ async def cmd(self, node_id: str, command: dict) -> dict:
104
+ return await self.post("/nodes/%s/cmd" % node_id, command)
105
+
106
+ async def send_serial(self, node_id: str, *, data: Optional[str] = None,
107
+ raw: Optional[str] = None) -> dict:
108
+ """Write into a node's serial getty (the hub `send` command). Exactly one
109
+ of ``data`` (UTF-8 text) or ``raw`` (hex bytes), mirroring firmware."""
110
+ body: dict = {"type": "send"}
111
+ if data is not None:
112
+ body["data"] = data
113
+ if raw is not None:
114
+ body["raw"] = raw
115
+ return await self.cmd(node_id, body)
116
+
117
+ async def reboot(self, node_id: str) -> dict:
118
+ return await self.post("/nodes/%s/reboot" % node_id)
119
+
120
+ async def sysrq(self, node_id: str, command: str) -> dict:
121
+ return await self.post("/nodes/%s/sysrq" % node_id, {"command": command})
122
+
123
+ # -- live event stream ----------------------------------------------------
124
+
125
+ def events_stream(self, **connect_kwargs) -> "HubEventsCM":
126
+ """Return an async context manager yielding a :class:`HubEvents`."""
127
+ return HubEventsCM(self._ws_url, connect_kwargs)
128
+
129
+ # Short alias used in the docstring / common case.
130
+ def events_ws(self, **connect_kwargs) -> "HubEventsCM":
131
+ return self.events_stream(**connect_kwargs)
132
+
133
+
134
+ class HubEventsCM:
135
+ def __init__(self, ws_url: str, connect_kwargs: dict):
136
+ self._ws_url = ws_url
137
+ self._connect_kwargs = connect_kwargs
138
+ self._sock = None
139
+
140
+ async def __aenter__(self) -> "HubEvents":
141
+ kwargs = {"ping_interval": 20, "ping_timeout": 20, "max_size": 2 ** 20}
142
+ kwargs.update(self._connect_kwargs)
143
+ self._sock = await websockets.connect(self._ws_url, **kwargs)
144
+ return HubEvents(self._sock)
145
+
146
+ async def __aexit__(self, *exc) -> None:
147
+ if self._sock is not None:
148
+ await self._sock.close()
149
+
150
+
151
+ class HubEvents:
152
+ """An async iterator over the hub's WebSocket event feed.
153
+
154
+ Low-volume events (node up/down, heartbeat, node_state) arrive to everyone;
155
+ high-volume per-node ``output``/``result`` events arrive only after
156
+ :meth:`subscribe`."""
157
+
158
+ def __init__(self, sock):
159
+ self._sock = sock
160
+
161
+ async def subscribe(self, node_id: str) -> None:
162
+ await self._sock.send(json.dumps({"type": "subscribe", "node_id": node_id}))
163
+
164
+ async def unsubscribe(self, node_id: str) -> None:
165
+ await self._sock.send(json.dumps({"type": "unsubscribe", "node_id": node_id}))
166
+
167
+ async def __aiter__(self) -> AsyncIterator[dict]:
168
+ async for raw in self._sock:
169
+ try:
170
+ yield json.loads(raw)
171
+ except (ValueError, TypeError):
172
+ continue
173
+
174
+
175
+ __all__ = ["HubClient", "HubEvents", "HubEventsCM", "HubError"]
@@ -0,0 +1,35 @@
1
+ """``picotty.hub`` — the server.
2
+
3
+ A single asyncio process that runs two faces on one event loop:
4
+
5
+ - a raw TCP server on :9000 facing the node swarm (the wire protocol), and
6
+ - a FastAPI app on :8080 facing the browser (REST + WebSocket + static dashboard).
7
+
8
+ They share one in-memory registry and one SQLite database. Import this only with
9
+ the ``[hub]`` extra installed (FastAPI, uvicorn, aiosqlite, pydantic, pyyaml); a
10
+ plain client needs :mod:`picotty.client` instead.
11
+
12
+ Public surface:
13
+
14
+ - :class:`Hub` — the shared owner of the registry, SQLite, protocol and events.
15
+ - :func:`serve` — bring the hub up (the ``picotty-hub`` console entry point).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .. import __version__ # single-sourced from the distribution metadata
21
+ from .core import Hub
22
+
23
+ __all__ = ["Hub", "serve", "build_app", "__version__"]
24
+
25
+
26
+ def build_app():
27
+ """Return the FastAPI application (lazy: imports the server stack on demand)."""
28
+ from .main import build_app as _build_app
29
+ return _build_app()
30
+
31
+
32
+ def serve() -> None:
33
+ """Run the hub (uvicorn). Backs the ``picotty-hub`` console script."""
34
+ from .main import main as _main
35
+ _main()