nimbus-notify 1.0.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.
- nimbus_notify-1.0.0.dist-info/METADATA +303 -0
- nimbus_notify-1.0.0.dist-info/RECORD +22 -0
- nimbus_notify-1.0.0.dist-info/WHEEL +4 -0
- nimbus_notify-1.0.0.dist-info/entry_points.txt +3 -0
- nimbus_notify-1.0.0.dist-info/licenses/LICENSE +21 -0
- notify/__init__.py +0 -0
- notify/broker/__init__.py +0 -0
- notify/broker/frame.py +108 -0
- notify/broker/segments.py +99 -0
- notify/broker/server.py +371 -0
- notify/broker/session.py +67 -0
- notify/cli/__init__.py +0 -0
- notify/cli/led_report.py +96 -0
- notify/harness/__init__.py +0 -0
- notify/harness/base.py +19 -0
- notify/harness/claude.py +47 -0
- notify/harness/codex.py +43 -0
- notify/harness/vibe.py +165 -0
- notify/state.py +52 -0
- notify/transport/__init__.py +21 -0
- notify/transport/ble_tx.py +361 -0
- notify/transport/serial_tx.py +99 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nimbus-notify
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Host broker that watches your AI coding sessions (Claude Code, Codex, Mistral Vibe) and pushes live status to a physical LED ring / e-ink device over a documented wire protocol
|
|
5
|
+
Project-URL: Homepage, https://github.com/ristllin/nimbus-notify
|
|
6
|
+
Project-URL: Repository, https://github.com/ristllin/nimbus-notify
|
|
7
|
+
Project-URL: Changelog, https://github.com/ristllin/nimbus-notify/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Roy Darnell
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ble,claude-code,codex,hooks,led,mistral-vibe,notifier,serial
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: aiohttp>=3.9
|
|
22
|
+
Requires-Dist: bleak>=0.22
|
|
23
|
+
Requires-Dist: pyserial>=3.5
|
|
24
|
+
Requires-Dist: watchdog>=4.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# nimbus-notify
|
|
28
|
+
|
|
29
|
+
A Python host broker that watches your AI coding-agent sessions — **Claude
|
|
30
|
+
Code**, **Codex**, and **Mistral Vibe** — via lightweight hooks, and pushes
|
|
31
|
+
their live status (running / waiting for you / needs approval / done /
|
|
32
|
+
errored) to a physical display device over serial (USB-CDC) or Bluetooth LE,
|
|
33
|
+
using a small documented binary protocol ([nsn](docs/protocol.md)).
|
|
34
|
+
|
|
35
|
+
If you've got several agent sessions going in parallel across different
|
|
36
|
+
terminals and projects, nimbus-notify gives you one glanceable place — an LED
|
|
37
|
+
ring, an e-ink panel, whatever device you point it at — to see which ones
|
|
38
|
+
are still working and which ones are waiting on you.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
┌────────────┐ hooks ┌───────────────┐ nsn wire protocol ┌──────────┐
|
|
42
|
+
│ Claude Code│ ─────────▶ │ │ (serial or BLE) │ status │
|
|
43
|
+
│ Codex │ ─────────▶ │ nsnotify- │ ─────────────────────▶│ device │
|
|
44
|
+
│ Mistral │ ─────────▶ │ broker │ │(your own)│
|
|
45
|
+
│ Vibe │ │ │ │ │
|
|
46
|
+
└────────────┘ └───────────────┘ └──────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Status
|
|
50
|
+
|
|
51
|
+
Beta. The core broker, all three harness adapters, and both transports are
|
|
52
|
+
implemented and tested (`python3 -m pytest`). This is a fresh split out of a
|
|
53
|
+
private monorepo into its own package — see [CHANGELOG.md](CHANGELOG.md).
|
|
54
|
+
|
|
55
|
+
## Compatible devices
|
|
56
|
+
|
|
57
|
+
nimbus-notify speaks a documented, transport-agnostic wire protocol — see
|
|
58
|
+
[docs/protocol.md](docs/protocol.md). Any device that implements the
|
|
59
|
+
protocol's serial or BLE side can be driven by this broker; nothing here is
|
|
60
|
+
tied to a specific piece of hardware. If you build (or already have) a
|
|
61
|
+
microcontroller project with an LED strip, an e-ink panel, or any other
|
|
62
|
+
status display, point it at this broker.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
Not yet on PyPI — install from a clone:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/ristllin/nimbus-notify.git
|
|
70
|
+
cd nimbus-notify
|
|
71
|
+
pip install -e .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This installs two commands on your `PATH`:
|
|
75
|
+
|
|
76
|
+
- `nimbus-notify-broker` — the daemon that maintains session state and talks to
|
|
77
|
+
your device.
|
|
78
|
+
- `led-report` — the small CLI that harness hooks call to report events
|
|
79
|
+
into the broker (fire-and-forget; never blocks your agent).
|
|
80
|
+
|
|
81
|
+
PyPI publishing (`pip install nimbus-notify`) is a planned next step — for now,
|
|
82
|
+
the git-clone path above is the supported install method.
|
|
83
|
+
|
|
84
|
+
## Quickstart
|
|
85
|
+
|
|
86
|
+
1. Install the package (above).
|
|
87
|
+
2. Wire up the harness(es) you use — see [Harnesses](#harnesses) below.
|
|
88
|
+
3. Start the broker:
|
|
89
|
+
```bash
|
|
90
|
+
nimbus-notify-broker
|
|
91
|
+
```
|
|
92
|
+
4. Start (or resume) an agent session in a wired-up harness. Its status
|
|
93
|
+
should now be reported to the broker, and forwarded to your device.
|
|
94
|
+
|
|
95
|
+
If you use Claude Code, the fastest path is the bundled slash commands —
|
|
96
|
+
see [Claude Code plugin](#claude-code-plugin) below.
|
|
97
|
+
|
|
98
|
+
## Harnesses
|
|
99
|
+
|
|
100
|
+
nimbus-notify supports three AI coding harnesses today. Each harness reports
|
|
101
|
+
events (session start, a tool running, waiting on your approval, done,
|
|
102
|
+
errored, session end) by calling `led-report <harness> <verb>` from a hook.
|
|
103
|
+
|
|
104
|
+
### Claude Code
|
|
105
|
+
|
|
106
|
+
Merge [`hooks/claude/settings.json`](hooks/claude/settings.json)'s `hooks`
|
|
107
|
+
block into your `~/.claude/settings.json`, preserving any hooks you already
|
|
108
|
+
have (append to each event's array rather than replacing it). It wires up
|
|
109
|
+
`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `Notification`, `Stop`,
|
|
110
|
+
`StopFailure`, and `SessionEnd`.
|
|
111
|
+
|
|
112
|
+
### Codex
|
|
113
|
+
|
|
114
|
+
Merge [`hooks/codex/hooks.json`](hooks/codex/hooks.json) into
|
|
115
|
+
`~/.codex/hooks.json`, then enable hooks and the notify bridge in
|
|
116
|
+
`~/.codex/config.toml`:
|
|
117
|
+
|
|
118
|
+
```toml
|
|
119
|
+
[features]
|
|
120
|
+
hooks = true
|
|
121
|
+
|
|
122
|
+
notify = ["led-report", "codex-notify"]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Mistral Vibe
|
|
126
|
+
|
|
127
|
+
Vibe has no native session start/stop hook, so the broker also runs a
|
|
128
|
+
background watcher over `~/.vibe/logs/session/` to detect new sessions and
|
|
129
|
+
infer human-in-the-loop waits (if a tool call starts but doesn't finish
|
|
130
|
+
within a timeout, that's treated as "awaiting approval").
|
|
131
|
+
|
|
132
|
+
Enable experimental hooks in `~/.vibe/config.toml`:
|
|
133
|
+
|
|
134
|
+
```toml
|
|
135
|
+
enable_experimental_hooks = true
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Then merge [`hooks/vibe/hooks.toml`](hooks/vibe/hooks.toml) into
|
|
139
|
+
`~/.vibe/hooks.toml` (requires Vibe v2.15.0+ for `before_tool` /
|
|
140
|
+
`after_tool` / `post_agent_turn`).
|
|
141
|
+
|
|
142
|
+
### Claude Code plugin
|
|
143
|
+
|
|
144
|
+
This repo is also a Claude Code plugin (`.claude-plugin/plugin.json`). If
|
|
145
|
+
you install it as a plugin, two slash commands become available:
|
|
146
|
+
|
|
147
|
+
- `/nsnotify-setup` — installs the Python package, merges the Claude Code
|
|
148
|
+
hooks automatically, checks whether the broker is running, and prints
|
|
149
|
+
the manual steps for Codex/Vibe.
|
|
150
|
+
- `/nsnotify-status` — shows current session states without needing to look
|
|
151
|
+
at the device.
|
|
152
|
+
|
|
153
|
+
## Transports
|
|
154
|
+
|
|
155
|
+
Pick a transport with `--transport`:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
nimbus-notify-broker --transport serial # default
|
|
159
|
+
nimbus-notify-broker --transport ble
|
|
160
|
+
nimbus-notify-broker --transport auto # serial if a device is plugged in at
|
|
161
|
+
# startup, else BLE
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Serial** auto-detects a likely USB-CDC port (Espressif native-USB VID
|
|
165
|
+
`0x303A`, or common USB-UART bridge chips), or pin one explicitly:
|
|
166
|
+
`nimbus-notify-broker --transport serial --port /dev/cu.usbmodem101`.
|
|
167
|
+
- **BLE** requires your device to be powered on, flashed with firmware that
|
|
168
|
+
advertises the nsn BLE service, and in range. Optionally pin a specific
|
|
169
|
+
device: `nimbus-notify-broker --transport ble --ble-address <address>`
|
|
170
|
+
(a CoreBluetooth UUID on macOS, a MAC address on Linux). Without
|
|
171
|
+
`--ble-address`, the broker scans for the nsn service UUID.
|
|
172
|
+
|
|
173
|
+
Transport selection happens once at startup — there's no live failover
|
|
174
|
+
between serial and BLE mid-session in this version.
|
|
175
|
+
|
|
176
|
+
### Bonding the BLE link (one time)
|
|
177
|
+
|
|
178
|
+
Recent Nimbus firmware **secures the BLE link** (bonded + encrypted, LE Secure
|
|
179
|
+
Connections), so a device won't accept frames from an un-bonded computer — this
|
|
180
|
+
stops anyone in radio range from painting your ring. Bonding is **automatic**
|
|
181
|
+
(macOS "Just Works", no code to type) — but there are two things to know:
|
|
182
|
+
|
|
183
|
+
- **Nimbus does *not* appear in System Settings → Bluetooth.** That list only
|
|
184
|
+
shows recognized device types (keyboards, mice, audio). A custom BLE
|
|
185
|
+
peripheral is invisible there by design — don't look for it. Bonding happens
|
|
186
|
+
on-demand when the broker first touches the device, not by picking it in a
|
|
187
|
+
list.
|
|
188
|
+
- **Do the first bond with the broker in the foreground.** macOS only completes
|
|
189
|
+
a bond for a process running in your normal login session — a fully detached
|
|
190
|
+
process (e.g. `nohup … & disown`) is too detached and the bond silently fails.
|
|
191
|
+
So the *first* time, just run `nimbus-notify-broker --transport ble` in a normal
|
|
192
|
+
terminal and leave it up for a few seconds. Once bonded, the bond persists on
|
|
193
|
+
both the device (flash) and your Mac, and every session after that is
|
|
194
|
+
transparent — you can then run it backgrounded or as a service (below).
|
|
195
|
+
|
|
196
|
+
To un-bond: on the device, *Connectivity → Forget paired devices* (or the
|
|
197
|
+
`FORGETBONDS` console command); the Mac side clears on its own next connect.
|
|
198
|
+
|
|
199
|
+
> The firmware also carries a dormant MITM/passkey mode (it shows a 6-digit code
|
|
200
|
+
> on its e-ink screen). It's off by default because macOS won't surface the
|
|
201
|
+
> passkey-entry dialog for a broker-triggered pairing of a custom peripheral, so
|
|
202
|
+
> that pairing can't complete without a companion app.
|
|
203
|
+
|
|
204
|
+
## Running persistently
|
|
205
|
+
|
|
206
|
+
For day-to-day use you'll want the broker running in the background
|
|
207
|
+
whenever you're coding, not started by hand each time.
|
|
208
|
+
|
|
209
|
+
### macOS (launchd)
|
|
210
|
+
|
|
211
|
+
Create `~/Library/LaunchAgents/com.nimbus-notify.broker.plist`:
|
|
212
|
+
|
|
213
|
+
```xml
|
|
214
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
215
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
216
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
217
|
+
<plist version="1.0">
|
|
218
|
+
<dict>
|
|
219
|
+
<key>Label</key><string>com.nimbus-notify.broker</string>
|
|
220
|
+
<key>ProgramArguments</key>
|
|
221
|
+
<array>
|
|
222
|
+
<string>/usr/bin/env</string>
|
|
223
|
+
<string>nimbus-notify-broker</string>
|
|
224
|
+
<string>--transport</string>
|
|
225
|
+
<string>auto</string>
|
|
226
|
+
</array>
|
|
227
|
+
<key>RunAtLoad</key><true/>
|
|
228
|
+
<key>KeepAlive</key><true/>
|
|
229
|
+
<key>StandardOutPath</key><string>/tmp/nimbus-notify-broker.log</string>
|
|
230
|
+
<key>StandardErrorPath</key><string>/tmp/nimbus-notify-broker.log</string>
|
|
231
|
+
</dict>
|
|
232
|
+
</plist>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Then:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
launchctl load ~/Library/LaunchAgents/com.nimbus-notify.broker.plist
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Linux (systemd, user service)
|
|
242
|
+
|
|
243
|
+
Create `~/.config/systemd/user/nimbus-notify-broker.service`:
|
|
244
|
+
|
|
245
|
+
```ini
|
|
246
|
+
[Unit]
|
|
247
|
+
Description=nimbus-notify broker
|
|
248
|
+
|
|
249
|
+
[Service]
|
|
250
|
+
ExecStart=%h/.local/bin/nimbus-notify-broker --transport auto
|
|
251
|
+
Restart=on-failure
|
|
252
|
+
|
|
253
|
+
[Install]
|
|
254
|
+
WantedBy=default.target
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Then:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
systemctl --user daemon-reload
|
|
261
|
+
systemctl --user enable --now nimbus-notify-broker
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Adjust `ExecStart` to wherever `pip install -e .` put the entry point
|
|
265
|
+
(check with `which nimbus-notify-broker`).
|
|
266
|
+
|
|
267
|
+
## The nsn wire protocol
|
|
268
|
+
|
|
269
|
+
A short summary — full details in [docs/protocol.md](docs/protocol.md).
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
[SOF 0xAA] [LEN] [payload: LEN bytes] [CRC8-MAXIM]
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The payload starts with magic byte `0x4E`, then a sequence number, then up
|
|
276
|
+
to 16 fixed-size segment records (state, hue, animation, LED span) plus a
|
|
277
|
+
global brightness — enough for a device to render a full status frame from
|
|
278
|
+
a single self-contained packet, no persistent client-side state required.
|
|
279
|
+
The reference encoder/decoder is [`notify/broker/frame.py`](notify/broker/frame.py).
|
|
280
|
+
|
|
281
|
+
## Repository layout
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
notify/
|
|
285
|
+
broker/ frame.py (wire codec), segments.py, server.py, session.py
|
|
286
|
+
cli/ led_report.py — the hook-facing CLI entry point
|
|
287
|
+
harness/ base.py + claude.py, codex.py, vibe.py adapters
|
|
288
|
+
transport/ serial_tx.py, ble_tx.py
|
|
289
|
+
state.py shared State/Anim enums + default styling
|
|
290
|
+
tests/ pytest suite for the above
|
|
291
|
+
hooks/ drop-in hook configs per harness
|
|
292
|
+
commands/ Claude Code plugin slash commands
|
|
293
|
+
docs/ protocol.md
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Contributing
|
|
297
|
+
|
|
298
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md), including the versioning
|
|
299
|
+
convention used for releases.
|
|
300
|
+
|
|
301
|
+
## License
|
|
302
|
+
|
|
303
|
+
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Roy Darnell.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
notify/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
notify/state.py,sha256=4c_C2nqOlEo4b7dO3KeJxjLIYquX6eWV12RR_Z9oe9A,1894
|
|
3
|
+
notify/broker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
notify/broker/frame.py,sha256=Ki7_eo0RXEVKpSJ1kmlumu6ydswvnTMVNsb9nFkbyE4,2936
|
|
5
|
+
notify/broker/segments.py,sha256=JYG3qZyz0YyX7Ch1lOYxO4mQaF1sM29sO-6Badnxj78,3632
|
|
6
|
+
notify/broker/server.py,sha256=LgoNAyX5X9OQXJTsUi0xdoQGxBbdhsW885b0SjdIkpI,15605
|
|
7
|
+
notify/broker/session.py,sha256=DWMdAL9miB8AjxaSadnbblIw-38Qh3adSCPkzabs-t0,2530
|
|
8
|
+
notify/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
notify/cli/led_report.py,sha256=iIhC-qN5Rl1y47zH7oZtJynLP-z1J3xDrIb_pOF1diU,3237
|
|
10
|
+
notify/harness/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
notify/harness/base.py,sha256=HdSoevm20mKG3vpBTEfjUcORDd9lA-OCya_0eT9NSZA,578
|
|
12
|
+
notify/harness/claude.py,sha256=HVLwZmNXxZltpURvfoJJDOZ0KM8dA-7LqOrQKmfwgF0,1417
|
|
13
|
+
notify/harness/codex.py,sha256=3TvcLUpDqJ_0oSupQsix2t1rUTqMBAWTOol7xAlOlA0,1261
|
|
14
|
+
notify/harness/vibe.py,sha256=mYGfJwrf1Z33THaQDvLVz1_9mjD26R_0e2EHFVL8stk,6223
|
|
15
|
+
notify/transport/__init__.py,sha256=zy2Lp7gd4K2OCBmo6hv2B0tPgm84EyTXLxlaqWRn0pk,614
|
|
16
|
+
notify/transport/ble_tx.py,sha256=TQqBOasZCivVaCEhZopbQOXD40THe6i-1WM9lmwx_4U,16966
|
|
17
|
+
notify/transport/serial_tx.py,sha256=NnZ0zRZ812vRiHud-a3COBti7HPPF3_mbdTkJnLRvTY,3570
|
|
18
|
+
nimbus_notify-1.0.0.dist-info/METADATA,sha256=c5-hezJrJ4V1SN_B5p4Oc-F03UK3SEM7DVOA3kqppRg,11556
|
|
19
|
+
nimbus_notify-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
20
|
+
nimbus_notify-1.0.0.dist-info/entry_points.txt,sha256=7D2GeGf3dzMiSm0WjyUHH8MoJKhbFJA7eQvRzzQwSoY,107
|
|
21
|
+
nimbus_notify-1.0.0.dist-info/licenses/LICENSE,sha256=5gMUOlQupieheYGvmjavBGr7adTBxAJYaQ8Gj2Po5c8,1068
|
|
22
|
+
nimbus_notify-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roy Darnell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
notify/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
notify/broker/frame.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phase 2 — Wire frame encoder / decoder.
|
|
3
|
+
|
|
4
|
+
Wire format (bytes on USB-CDC or BLE):
|
|
5
|
+
[SOF 0xAA] [LEN] [payload: LEN bytes] [CRC8]
|
|
6
|
+
|
|
7
|
+
Payload:
|
|
8
|
+
byte 0: MAGIC 0x4E
|
|
9
|
+
byte 1: sequence number (wraps at 255)
|
|
10
|
+
byte 2: segment count N
|
|
11
|
+
byte 3: global brightness (0–255)
|
|
12
|
+
bytes 4 .. 4+N*4-1: N segment records, 4 bytes each:
|
|
13
|
+
byte 0: state (notify.state.State)
|
|
14
|
+
byte 1: hue (0–254 HSV hue; 255 = white)
|
|
15
|
+
byte 2: anim (notify.state.Anim)
|
|
16
|
+
byte 3: span (LED count; 0 = auto-even)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from notify.state import Anim, State, STATE_STYLE
|
|
23
|
+
|
|
24
|
+
FRAME_SOF = 0xAA
|
|
25
|
+
FRAME_MAGIC = 0x4E
|
|
26
|
+
MAX_SEGS = 16
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class FrameSegment:
|
|
31
|
+
state: State
|
|
32
|
+
hue: int # 0–255
|
|
33
|
+
anim: Anim
|
|
34
|
+
span: int # 0 = auto
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_state(cls, state: State, span: int = 0) -> "FrameSegment":
|
|
38
|
+
hue, anim = STATE_STYLE[state]
|
|
39
|
+
return cls(state=state, hue=hue, anim=anim, span=span)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _crc8(data: bytes) -> int:
|
|
43
|
+
"""CRC-8/MAXIM (polynomial 0x31, init 0x00, refin/refout True)."""
|
|
44
|
+
crc = 0
|
|
45
|
+
for byte in data:
|
|
46
|
+
crc ^= byte
|
|
47
|
+
for _ in range(8):
|
|
48
|
+
crc = (crc >> 1) ^ 0x8C if (crc & 0x01) else (crc >> 1)
|
|
49
|
+
return crc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def encode_frame(segments: list[FrameSegment], brightness: int, seq: int) -> bytes:
|
|
53
|
+
"""Return a complete framed packet ready to write to the transport."""
|
|
54
|
+
n = min(len(segments), MAX_SEGS)
|
|
55
|
+
payload = bytes([
|
|
56
|
+
FRAME_MAGIC,
|
|
57
|
+
seq & 0xFF,
|
|
58
|
+
n,
|
|
59
|
+
brightness & 0xFF,
|
|
60
|
+
])
|
|
61
|
+
for seg in segments[:n]:
|
|
62
|
+
payload += bytes([
|
|
63
|
+
int(seg.state) & 0xFF,
|
|
64
|
+
seg.hue & 0xFF,
|
|
65
|
+
int(seg.anim) & 0xFF,
|
|
66
|
+
seg.span & 0xFF,
|
|
67
|
+
])
|
|
68
|
+
crc = _crc8(payload)
|
|
69
|
+
return bytes([FRAME_SOF, len(payload)]) + payload + bytes([crc])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class DecodedFrame:
|
|
74
|
+
seq: int
|
|
75
|
+
brightness: int
|
|
76
|
+
segments: list[FrameSegment]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decode_frame(packet: bytes) -> DecodedFrame | None:
|
|
80
|
+
"""Parse a raw packet (including SOF / LEN / CRC wrapper). Returns None on error."""
|
|
81
|
+
if len(packet) < 6:
|
|
82
|
+
return None
|
|
83
|
+
if packet[0] != FRAME_SOF:
|
|
84
|
+
return None
|
|
85
|
+
length = packet[1]
|
|
86
|
+
if len(packet) < 2 + length + 1:
|
|
87
|
+
return None
|
|
88
|
+
payload = packet[2 : 2 + length]
|
|
89
|
+
crc = packet[2 + length]
|
|
90
|
+
if _crc8(payload) != crc:
|
|
91
|
+
return None
|
|
92
|
+
if payload[0] != FRAME_MAGIC:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
n = payload[2]
|
|
96
|
+
brightness = payload[3]
|
|
97
|
+
segments: list[FrameSegment] = []
|
|
98
|
+
for i in range(n):
|
|
99
|
+
off = 4 + i * 4
|
|
100
|
+
if off + 4 > len(payload):
|
|
101
|
+
break
|
|
102
|
+
segments.append(FrameSegment(
|
|
103
|
+
state=State(payload[off]),
|
|
104
|
+
hue=payload[off + 1],
|
|
105
|
+
anim=Anim(payload[off + 2]),
|
|
106
|
+
span=payload[off + 3],
|
|
107
|
+
))
|
|
108
|
+
return DecodedFrame(seq=payload[1], brightness=brightness, segments=segments)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phase 2 — Segment allocator.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Each live session occupies exactly one segment index (0..MAX_SEGS-1).
|
|
6
|
+
- Segments are assigned in insertion order (lowest free index wins).
|
|
7
|
+
- A segment showing AWAITING_APPROVAL or WAITING_INPUT is never evicted to
|
|
8
|
+
make room for a new session while high-priority slots are full — the new
|
|
9
|
+
session is assigned the next available index instead.
|
|
10
|
+
- On free(), the index is returned to the pool immediately.
|
|
11
|
+
- active_segments() returns SessionRecords ordered by segment index, which
|
|
12
|
+
determines ring position.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from notify.broker.session import SessionRecord
|
|
17
|
+
from notify.state import State, STATE_PRIORITY
|
|
18
|
+
|
|
19
|
+
MAX_SEGS = 16
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SegmentAllocator:
|
|
23
|
+
def __init__(self, max_segs: int = MAX_SEGS) -> None:
|
|
24
|
+
self._max = max_segs
|
|
25
|
+
self._sessions: dict[str, SessionRecord] = {} # session_id → record
|
|
26
|
+
self._index: dict[str, int] = {} # session_id → segment index
|
|
27
|
+
self._used: set[int] = set()
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
# Mutation
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def register(self, record: SessionRecord) -> int:
|
|
34
|
+
"""Assign a segment to a new session. Returns the segment index, or -1 if full."""
|
|
35
|
+
if record.session_id in self._index:
|
|
36
|
+
return self._index[record.session_id]
|
|
37
|
+
|
|
38
|
+
idx = self._next_free()
|
|
39
|
+
if idx < 0:
|
|
40
|
+
return -1
|
|
41
|
+
|
|
42
|
+
self._sessions[record.session_id] = record
|
|
43
|
+
self._index[record.session_id] = idx
|
|
44
|
+
self._used.add(idx)
|
|
45
|
+
record.segment = idx
|
|
46
|
+
return idx
|
|
47
|
+
|
|
48
|
+
def update(self, record: SessionRecord) -> None:
|
|
49
|
+
"""Update state for an already-registered session."""
|
|
50
|
+
sid = record.session_id
|
|
51
|
+
if sid not in self._sessions:
|
|
52
|
+
self.register(record)
|
|
53
|
+
return
|
|
54
|
+
self._sessions[sid].state = record.state
|
|
55
|
+
self._sessions[sid].last_event = record.last_event
|
|
56
|
+
|
|
57
|
+
def free(self, session_id: str) -> None:
|
|
58
|
+
"""Release the segment held by session_id."""
|
|
59
|
+
idx = self._index.pop(session_id, None)
|
|
60
|
+
if idx is not None:
|
|
61
|
+
self._used.discard(idx)
|
|
62
|
+
self._sessions.pop(session_id, None)
|
|
63
|
+
|
|
64
|
+
def evict_stale(self) -> list[str]:
|
|
65
|
+
"""Remove sessions that have exceeded their TTL. Returns evicted ids."""
|
|
66
|
+
stale = [sid for sid, rec in self._sessions.items() if rec.is_stale()]
|
|
67
|
+
for sid in stale:
|
|
68
|
+
self.free(sid)
|
|
69
|
+
return stale
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Query
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def active_segments(self) -> list[SessionRecord]:
|
|
76
|
+
"""Sessions ordered by segment index (ring position)."""
|
|
77
|
+
return sorted(self._sessions.values(), key=lambda r: self._index[r.session_id])
|
|
78
|
+
|
|
79
|
+
def highest_priority_state(self) -> State:
|
|
80
|
+
"""State with the highest priority among all active sessions."""
|
|
81
|
+
if not self._sessions:
|
|
82
|
+
return State.Idle
|
|
83
|
+
return max(
|
|
84
|
+
(r.state for r in self._sessions.values()),
|
|
85
|
+
key=lambda s: STATE_PRIORITY[s],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def __len__(self) -> int:
|
|
89
|
+
return len(self._sessions)
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Internal
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _next_free(self) -> int:
|
|
96
|
+
for i in range(self._max):
|
|
97
|
+
if i not in self._used:
|
|
98
|
+
return i
|
|
99
|
+
return -1
|