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.
- picotty-1.0.0/PKG-INFO +164 -0
- picotty-1.0.0/README.md +125 -0
- picotty-1.0.0/pyproject.toml +73 -0
- picotty-1.0.0/src/picotty/__init__.py +28 -0
- picotty-1.0.0/src/picotty/client/__init__.py +175 -0
- picotty-1.0.0/src/picotty/hub/__init__.py +35 -0
- picotty-1.0.0/src/picotty/hub/alerts.py +111 -0
- picotty-1.0.0/src/picotty/hub/api/__init__.py +1 -0
- picotty-1.0.0/src/picotty/hub/api/models.py +159 -0
- picotty-1.0.0/src/picotty/hub/api/rest.py +805 -0
- picotty-1.0.0/src/picotty/hub/api/ws.py +65 -0
- picotty-1.0.0/src/picotty/hub/classifier.py +65 -0
- picotty-1.0.0/src/picotty/hub/config.py +122 -0
- picotty-1.0.0/src/picotty/hub/core.py +339 -0
- picotty-1.0.0/src/picotty/hub/db.py +672 -0
- picotty-1.0.0/src/picotty/hub/eventbus.py +65 -0
- picotty-1.0.0/src/picotty/hub/expect.py +276 -0
- picotty-1.0.0/src/picotty/hub/main.py +151 -0
- picotty-1.0.0/src/picotty/hub/ota.py +308 -0
- picotty-1.0.0/src/picotty/hub/protocol.py +90 -0
- picotty-1.0.0/src/picotty/hub/registry.py +89 -0
- picotty-1.0.0/src/picotty/hub/runbook.py +186 -0
- picotty-1.0.0/src/picotty/hub/serialbridge.py +164 -0
- picotty-1.0.0/src/picotty/hub/tasks.py +115 -0
- picotty-1.0.0/src/picotty/hub/tcp_server.py +198 -0
- picotty-1.0.0/src/picotty/hub/telegram_setup.py +195 -0
- picotty-1.0.0/src/picotty/hub/utils.py +86 -0
- picotty-1.0.0/src/picotty/protocol.py +31 -0
- picotty-1.0.0/src/picotty/sim.py +271 -0
- picotty-1.0.0/src/picotty/static/UI-CHANGES.md +240 -0
- picotty-1.0.0/src/picotty/static/app.css +77 -0
- picotty-1.0.0/src/picotty/static/app.js +2488 -0
- picotty-1.0.0/src/picotty/static/help.html +623 -0
- picotty-1.0.0/src/picotty/static/index.html +27 -0
- picotty-1.0.0/src/picotty/static/styles.css +234 -0
- picotty-1.0.0/src/picotty/static/vendor/README.md +43 -0
- picotty-1.0.0/src/picotty/static/vendor/asciinema-player.css +2352 -0
- picotty-1.0.0/src/picotty/static/vendor/asciinema-player.min.js +1 -0
- picotty-1.0.0/src/picotty/static/vendor/fetch-vendor.sh +51 -0
- picotty-1.0.0/src/picotty/static/vendor/xterm-addon-fit.js +2 -0
- picotty-1.0.0/src/picotty/static/vendor/xterm.css +209 -0
- 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.
|
picotty-1.0.0/README.md
ADDED
|
@@ -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()
|