ledit 1.45.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.
ledit-1.45.0/PKG-INFO ADDED
@@ -0,0 +1,253 @@
1
+ Metadata-Version: 2.4
2
+ Name: ledit
3
+ Version: 1.45.0
4
+ Summary: LEDit device client: stream frames to an RGB LED matrix over WebSocket
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/martynvdijke/LEDit
7
+ Project-URL: Repository, https://github.com/martynvdijke/LEDit
8
+ Project-URL: Issues, https://github.com/martynvdijke/LEDit/issues
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: Pillow>=9.0
12
+ Requires-Dist: websocket-client>=1.5
13
+ Requires-Dist: opentelemetry-api>=1.20
14
+ Requires-Dist: opentelemetry-sdk>=1.20
15
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20
16
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20
17
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.45b0
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=8; extra == "test"
20
+ Requires-Dist: pytest-cov>=5; extra == "test"
21
+ Requires-Dist: pytest-asyncio; extra == "test"
22
+ Requires-Dist: websockets>=12; extra == "test"
23
+ Provides-Extra: discovery
24
+ Requires-Dist: zeroconf>=0.132; extra == "discovery"
25
+
26
+ # LEDit Device Client
27
+
28
+ A small Python package for Raspberry Pi Zero (or any Pi) devices driving an RGB
29
+ LED matrix (HUB75 panels). It connects **out** to your LEDit server over
30
+ WebSocket, pulls frames, and renders them onto the panel.
31
+
32
+ Because the device pulls from the server, there is no inbound port, no static
33
+ IP, and no credentials beyond a per-device token. Updating the server requires
34
+ no changes to the device — new features appear automatically on the next frame.
35
+
36
+ ## How it works
37
+
38
+ 1. The server renders each source (F1, weather, calendar, news, stocks, …) to a
39
+ PNG at the device's configured width × height.
40
+ 2. Frames stream to the device at `ws://<server>/ws/device/<token>`.
41
+ 3. The client decodes each frame and pushes it to the matrix.
42
+ 4. The cycle interval (how long each source shows) is configured **per device**
43
+ on the server (`refresh_interval`, default 60 seconds).
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.8+
48
+ - [rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) (C++
49
+ library + Python bindings, installed separately)
50
+ - `Pillow`, `websocket-client`, and the OpenTelemetry packages (installed
51
+ automatically by pip)
52
+
53
+ ### Install on a Pi Zero
54
+
55
+ ```bash
56
+ # System packages
57
+ sudo apt update && sudo apt install -y python3-pip python3-pil git
58
+
59
+ # rpi-rgb-led-matrix (build Python bindings)
60
+ git clone https://github.com/hzeller/rpi-rgb-led-matrix.git
61
+ cd rpi-rgb-led-matrix
62
+ make build-python PYTHON=$(which python3)
63
+ sudo make install-python PYTHON=$(which python3)
64
+
65
+ # This package (published to PyPI on every LEDit release)
66
+ pip3 install ledit
67
+ ```
68
+
69
+ Or from a checkout of this repo:
70
+
71
+ ```bash
72
+ pip3 install ./device
73
+ ```
74
+
75
+ For development (editable install):
76
+
77
+ ```bash
78
+ pip3 install -e .
79
+ ```
80
+
81
+ ## Configuration
82
+
83
+ All configuration is via environment variables:
84
+
85
+ | Variable | Default | Purpose |
86
+ | ----------------------- | --------------------- | -------------------------------- |
87
+ | `LEDIT_SERVER` | `ws://localhost:8080` | WebSocket URL of the server |
88
+ | `LEDIT_TOKEN` | *(required)* | Device token (admin → Devices); may be omitted after auto-provisioning (persisted to `~/.config/ledit/token`) |
89
+ | `LEDIT_UPDATE_INTERVAL` | `3600` | Firmware OTA poll interval (seconds); `0` disables |
90
+ | `LEDIT_UPDATE_CHANNEL` | *(empty)* | OTA channel (empty = server default channel) |
91
+ | `LEDIT_COLS` | `64` | Panel width |
92
+ | `LEDIT_ROWS` | `64` | Panel height |
93
+ | `LEDIT_CHAIN` | `1` | Chained panels |
94
+ | `LEDIT_PARALLEL` | `1` | Parallel chains |
95
+ | `LEDIT_HARDWARE_MAPPING`| `regular` | rpi-rgb-led-matrix mapping |
96
+ | `LEDIT_BRIGHTNESS` | `80` | Startup brightness, 0–100 (live hint overrides) |
97
+ | `LEDIT_GPIO_SLOWDOWN` | `1` | Set >1 on Pi 4 / fast boards |
98
+ | `LEDIT_PREVIEW_DIR` | *(unset)* | Save frames as PNGs (no hardware)|
99
+ | `LEDIT_SPECTRUM` | `0` | Opt in to the audio spectrum tap (`1`/`true`) |
100
+ | `LEDIT_BUTTON_SHORT_MS` | `500` | Nominal short-press window (ms) |
101
+ | `LEDIT_BUTTON_LONG_MS` | `800` | Hold threshold; press ≥ this emits `hold` (ms) |
102
+ | `LEDIT_BUTTON_HOLD_REPEAT_MS` | `0` | Repeat `hold` every N ms while held (`0` = once) |
103
+
104
+ ## Protocol v2 (brightness, spectrum, buttons)
105
+
106
+ The client connects with `?protocol=2`. Servers that understand it reply with a
107
+ `{"type":"welcome","protocol":2,"capabilities":["brightness","spectrum","hold"]}`
108
+ message; if no welcome arrives the client stays in v1 mode with no brightness
109
+ hints and no spectrum. All v2 fields are optional and additive — old servers
110
+ and old `wscat` clients keep working unchanged.
111
+
112
+ - **Brightness**: frames may carry a `brightness` integer (0–100). When present
113
+ and in range the client applies it to the running `rpi-rgb-led-matrix`
114
+ instance live, without recreating the matrix. Until the first hint the
115
+ `LEDIT_BRIGHTNESS` startup value is used. Absent or out-of-range values leave
116
+ brightness unchanged.
117
+ - **Spectrum (opt-in, default off)**: with `LEDIT_SPECTRUM=1`, when the server
118
+ advertises `spectrum` and the current frame source is the audio visualizer
119
+ (`audio:visualizer`, or the built-in display name `Audio Visualizer`), the
120
+ client captures microphone audio best-effort and sends
121
+ `{"type":"spectrum","bins":[...]}` (16 bins, 0–255) at ~20 Hz. No microphone
122
+ or optional audio library simply means no spectrum is sent — never a crash.
123
+ Requires `numpy`; `sounddevice` is used opportunistically when installed.
124
+ - **Buttons**: a short press (released before `LEDIT_BUTTON_LONG_MS`) sends the
125
+ existing `{"action":"next"}` / `{"action":"pause"}` on release. A press held
126
+ to or beyond `LEDIT_BUTTON_LONG_MS` sends `{"action":"hold"}`, optionally
127
+ repeating every `LEDIT_BUTTON_HOLD_REPEAT_MS` while held. Debounce is
128
+ preserved.
129
+ - **v1 compatibility**: a v1 server (no welcome) or a v1 device (no `protocol`
130
+ param) degrades to v1 behaviour. Frames never change key names or the PNG
131
+ format.
132
+
133
+
134
+ ## OpenTelemetry
135
+
136
+ The device exports **traces, metrics, and logs** to an OTLP-compatible backend
137
+ (the same way the LEDit server does). Everything is off by default — if
138
+ `OTEL_EXPORTER_OTLP_ENDPOINT` is not set the client runs exactly as before,
139
+ with no telemetry overhead.
140
+
141
+ | Variable | Default | Purpose |
142
+ | ------------------------------ | ---------------- | ---------------------------------------------- |
143
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | *(unset)* | OTLP collector endpoint; unset disables telemetry |
144
+ | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` | `grpc` or `http/protobuf` |
145
+ | `OTEL_SERVICE_NAME` | `ledit-device` | Service name attached to exported telemetry |
146
+ | `OTEL_RESOURCE_ATTRIBUTES` | *(unset)* | Extra resource attributes (e.g. `rack=42,zone=west`) |
147
+ | `OTEL_TRACES_SAMPLER` | *(default)* | `always_on`, `always_off`, `traceidratio`, `parentbased_*` |
148
+
149
+ Spans cover the WebSocket lifecycle (message received, image/text render,
150
+ connection errors) and metrics include `device.frames_rendered_total`,
151
+ `device.connection_errors_total`, and `device.reconnects_total`. Device logs
152
+ are forwarded to the OTLP backend with trace-context correlation.
153
+
154
+ Example with a local collector:
155
+
156
+ ```bash
157
+ LEDIT_TOKEN=<token> OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 ledit-device
158
+ ```
159
+
160
+ ## Getting the token
161
+
162
+ 1. Open the LEDit admin UI → **Devices**.
163
+ 2. Create a device (name + matrix size + refresh interval).
164
+ 3. Copy the generated **token** (and full connection URL) from the table.
165
+
166
+ ## Discovery and auto-provisioning
167
+
168
+ Unprovisioned devices can advertise themselves via mDNS and be enrolled from the server without manually copying a token.
169
+
170
+ - **Advertisement**: DNS-SD service `_ledit._tcp.local` with TXT records `id` (stable fingerprint), `model`, `version`, `proto`, `nonce`. The token is never advertised.
171
+ - **Fingerprint**: `fingerprint()` reads `/etc/machine-id` when available, otherwise a random ID persisted at `~/.config/ledit/device_id`. Stable across reboots.
172
+ - **Nonce**: `new_nonce()` generates a fresh value per boot and is included in the TXT records.
173
+ - **Optional dependency**: `zeroconf` is required only for discovery. Install with `pip install 'ledit[discovery]'`. If missing, advertising/provisioning is skipped with a warning and manual `LEDIT_TOKEN` mode is unaffected.
174
+ - **API**: `discovery.start_advertising()` / `discovery.stop_advertising()` and `discovery.provision(server_url, fingerprint, nonce, interval, timeout)` which polls `GET /api/device/provision?fingerprint=…&nonce=…`.
175
+
176
+ **Enabling flow**:
177
+
178
+ 1. Start the device without `LEDIT_TOKEN` (with the discovery extra installed). It begins advertising.
179
+ 2. In the server admin UI go to **Admin → Discovery** — the device appears as pending.
180
+ 3. Enroll it. The server binds the fingerprint+nonce to a token.
181
+ 4. The device polls `GET /api/device/provision` until the token is returned (once), persists it to `~/.config/ledit/token` (configurable via `LEDIT_CONFIG_DIR`), and then connects to `/ws/device/<token>`. Subsequent boots use the persisted token and `LEDIT_TOKEN` may be omitted.
182
+
183
+ ## Firmware OTA
184
+
185
+ `firmware.check_and_update(server_url, token, current_version, channel)` polls the server manifest, downloads the artifact, verifies `sha256`, and stages the update atomically.
186
+
187
+ - Polls `GET /api/device/firmware?version=<current>&channel=<channel>` (channel from `LEDIT_UPDATE_CHANNEL`).
188
+ - Downloads from the manifest `url` (or `/api/device/firmware/<version>/artifact`), verifies `sha256` (and `size` when provided).
189
+ - Stages to `~/.config/ledit/staging/` (or `LEDIT_STAGING_DIR`) as `firmware-<version>.bin` with an `activate` marker; the running process is never overwritten. A failed or interrupted update leaves the previous version bootable.
190
+ - Non-fatal on network/parse errors — logs a warning and returns.
191
+ - Polling interval is `LEDIT_UPDATE_INTERVAL` (default 3600 s); set `0` to disable.
192
+
193
+ ## Inbound webhook signing
194
+
195
+ When a signing secret is configured in **Admin → Webhook settings**, inbound webhook requests must be signed. This is separate from LEDit's *outbound* webhooks (which sign the body only).
196
+
197
+ - Headers:
198
+ - `X-LEDit-Timestamp: <unix seconds>`
199
+ - `X-LEDit-Signature: sha256=<hex>` where hex is `HMAC-SHA256(secret, "<timestamp>.<raw-body>")` — the timestamp string, a literal `.`, and the raw request body.
200
+ - Verification: missing, stale (>300 s, configurable via `signing_window_seconds`), or mismatched signatures get a generic `401`.
201
+ - When no signing secret is set, the legacy `X-API-Key` / `?token=` auth is unchanged. If both a signing secret and an API key/token are configured, both are required.
202
+
203
+ ## Run
204
+
205
+ Installed as a package, run the console script:
206
+
207
+ ```bash
208
+ LEDIT_SERVER=ws://ledit.local:8080 LEDIT_TOKEN=<token> ledit-device
209
+ ```
210
+
211
+ Or without installing (from the `device/` directory):
212
+
213
+ ```bash
214
+ LEDIT_SERVER=ws://ledit.local:8080 LEDIT_TOKEN=<token> python3 -m ledit_device
215
+ ```
216
+
217
+ The client reconnects automatically on network drops.
218
+
219
+ ### Test without hardware
220
+
221
+ ```bash
222
+ LEDIT_SERVER=ws://localhost:8080 LEDIT_TOKEN=<token> \
223
+ LEDIT_PREVIEW_DIR=/tmp/ledit_frames python3 -m ledit_device
224
+ ```
225
+
226
+ This writes each received frame as a PNG into `LEDIT_PREVIEW_DIR`.
227
+
228
+ ## Package layout
229
+
230
+ ```
231
+ device/
232
+ pyproject.toml # package metadata + console script
233
+ ledit_device/
234
+ __init__.py # version + public exports
235
+ __main__.py # entry point (python -m ledit_device)
236
+ config.py # env-var config + logging
237
+ display.py # MatrixDisplay / FileDisplay abstractions
238
+ client.py # WebSocket frame handling + rendering
239
+ telemetry.py # OpenTelemetry init/shutdown (traces, metrics, logs)
240
+ tests/
241
+ test_client.py # unit tests (no hardware required)
242
+ test_telemetry.py # telemetry unit tests
243
+ ```
244
+
245
+ ## Running tests
246
+
247
+ The unit tests use a `FileDisplay` (writes PNGs to a temp dir), so they run
248
+ without a panel or the `rgbmatrix` bindings installed:
249
+
250
+ ```bash
251
+ cd device
252
+ python3 -m unittest discover -s tests -v
253
+ ```
ledit-1.45.0/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # LEDit Device Client
2
+
3
+ A small Python package for Raspberry Pi Zero (or any Pi) devices driving an RGB
4
+ LED matrix (HUB75 panels). It connects **out** to your LEDit server over
5
+ WebSocket, pulls frames, and renders them onto the panel.
6
+
7
+ Because the device pulls from the server, there is no inbound port, no static
8
+ IP, and no credentials beyond a per-device token. Updating the server requires
9
+ no changes to the device — new features appear automatically on the next frame.
10
+
11
+ ## How it works
12
+
13
+ 1. The server renders each source (F1, weather, calendar, news, stocks, …) to a
14
+ PNG at the device's configured width × height.
15
+ 2. Frames stream to the device at `ws://<server>/ws/device/<token>`.
16
+ 3. The client decodes each frame and pushes it to the matrix.
17
+ 4. The cycle interval (how long each source shows) is configured **per device**
18
+ on the server (`refresh_interval`, default 60 seconds).
19
+
20
+ ## Requirements
21
+
22
+ - Python 3.8+
23
+ - [rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) (C++
24
+ library + Python bindings, installed separately)
25
+ - `Pillow`, `websocket-client`, and the OpenTelemetry packages (installed
26
+ automatically by pip)
27
+
28
+ ### Install on a Pi Zero
29
+
30
+ ```bash
31
+ # System packages
32
+ sudo apt update && sudo apt install -y python3-pip python3-pil git
33
+
34
+ # rpi-rgb-led-matrix (build Python bindings)
35
+ git clone https://github.com/hzeller/rpi-rgb-led-matrix.git
36
+ cd rpi-rgb-led-matrix
37
+ make build-python PYTHON=$(which python3)
38
+ sudo make install-python PYTHON=$(which python3)
39
+
40
+ # This package (published to PyPI on every LEDit release)
41
+ pip3 install ledit
42
+ ```
43
+
44
+ Or from a checkout of this repo:
45
+
46
+ ```bash
47
+ pip3 install ./device
48
+ ```
49
+
50
+ For development (editable install):
51
+
52
+ ```bash
53
+ pip3 install -e .
54
+ ```
55
+
56
+ ## Configuration
57
+
58
+ All configuration is via environment variables:
59
+
60
+ | Variable | Default | Purpose |
61
+ | ----------------------- | --------------------- | -------------------------------- |
62
+ | `LEDIT_SERVER` | `ws://localhost:8080` | WebSocket URL of the server |
63
+ | `LEDIT_TOKEN` | *(required)* | Device token (admin → Devices); may be omitted after auto-provisioning (persisted to `~/.config/ledit/token`) |
64
+ | `LEDIT_UPDATE_INTERVAL` | `3600` | Firmware OTA poll interval (seconds); `0` disables |
65
+ | `LEDIT_UPDATE_CHANNEL` | *(empty)* | OTA channel (empty = server default channel) |
66
+ | `LEDIT_COLS` | `64` | Panel width |
67
+ | `LEDIT_ROWS` | `64` | Panel height |
68
+ | `LEDIT_CHAIN` | `1` | Chained panels |
69
+ | `LEDIT_PARALLEL` | `1` | Parallel chains |
70
+ | `LEDIT_HARDWARE_MAPPING`| `regular` | rpi-rgb-led-matrix mapping |
71
+ | `LEDIT_BRIGHTNESS` | `80` | Startup brightness, 0–100 (live hint overrides) |
72
+ | `LEDIT_GPIO_SLOWDOWN` | `1` | Set >1 on Pi 4 / fast boards |
73
+ | `LEDIT_PREVIEW_DIR` | *(unset)* | Save frames as PNGs (no hardware)|
74
+ | `LEDIT_SPECTRUM` | `0` | Opt in to the audio spectrum tap (`1`/`true`) |
75
+ | `LEDIT_BUTTON_SHORT_MS` | `500` | Nominal short-press window (ms) |
76
+ | `LEDIT_BUTTON_LONG_MS` | `800` | Hold threshold; press ≥ this emits `hold` (ms) |
77
+ | `LEDIT_BUTTON_HOLD_REPEAT_MS` | `0` | Repeat `hold` every N ms while held (`0` = once) |
78
+
79
+ ## Protocol v2 (brightness, spectrum, buttons)
80
+
81
+ The client connects with `?protocol=2`. Servers that understand it reply with a
82
+ `{"type":"welcome","protocol":2,"capabilities":["brightness","spectrum","hold"]}`
83
+ message; if no welcome arrives the client stays in v1 mode with no brightness
84
+ hints and no spectrum. All v2 fields are optional and additive — old servers
85
+ and old `wscat` clients keep working unchanged.
86
+
87
+ - **Brightness**: frames may carry a `brightness` integer (0–100). When present
88
+ and in range the client applies it to the running `rpi-rgb-led-matrix`
89
+ instance live, without recreating the matrix. Until the first hint the
90
+ `LEDIT_BRIGHTNESS` startup value is used. Absent or out-of-range values leave
91
+ brightness unchanged.
92
+ - **Spectrum (opt-in, default off)**: with `LEDIT_SPECTRUM=1`, when the server
93
+ advertises `spectrum` and the current frame source is the audio visualizer
94
+ (`audio:visualizer`, or the built-in display name `Audio Visualizer`), the
95
+ client captures microphone audio best-effort and sends
96
+ `{"type":"spectrum","bins":[...]}` (16 bins, 0–255) at ~20 Hz. No microphone
97
+ or optional audio library simply means no spectrum is sent — never a crash.
98
+ Requires `numpy`; `sounddevice` is used opportunistically when installed.
99
+ - **Buttons**: a short press (released before `LEDIT_BUTTON_LONG_MS`) sends the
100
+ existing `{"action":"next"}` / `{"action":"pause"}` on release. A press held
101
+ to or beyond `LEDIT_BUTTON_LONG_MS` sends `{"action":"hold"}`, optionally
102
+ repeating every `LEDIT_BUTTON_HOLD_REPEAT_MS` while held. Debounce is
103
+ preserved.
104
+ - **v1 compatibility**: a v1 server (no welcome) or a v1 device (no `protocol`
105
+ param) degrades to v1 behaviour. Frames never change key names or the PNG
106
+ format.
107
+
108
+
109
+ ## OpenTelemetry
110
+
111
+ The device exports **traces, metrics, and logs** to an OTLP-compatible backend
112
+ (the same way the LEDit server does). Everything is off by default — if
113
+ `OTEL_EXPORTER_OTLP_ENDPOINT` is not set the client runs exactly as before,
114
+ with no telemetry overhead.
115
+
116
+ | Variable | Default | Purpose |
117
+ | ------------------------------ | ---------------- | ---------------------------------------------- |
118
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | *(unset)* | OTLP collector endpoint; unset disables telemetry |
119
+ | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` | `grpc` or `http/protobuf` |
120
+ | `OTEL_SERVICE_NAME` | `ledit-device` | Service name attached to exported telemetry |
121
+ | `OTEL_RESOURCE_ATTRIBUTES` | *(unset)* | Extra resource attributes (e.g. `rack=42,zone=west`) |
122
+ | `OTEL_TRACES_SAMPLER` | *(default)* | `always_on`, `always_off`, `traceidratio`, `parentbased_*` |
123
+
124
+ Spans cover the WebSocket lifecycle (message received, image/text render,
125
+ connection errors) and metrics include `device.frames_rendered_total`,
126
+ `device.connection_errors_total`, and `device.reconnects_total`. Device logs
127
+ are forwarded to the OTLP backend with trace-context correlation.
128
+
129
+ Example with a local collector:
130
+
131
+ ```bash
132
+ LEDIT_TOKEN=<token> OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 ledit-device
133
+ ```
134
+
135
+ ## Getting the token
136
+
137
+ 1. Open the LEDit admin UI → **Devices**.
138
+ 2. Create a device (name + matrix size + refresh interval).
139
+ 3. Copy the generated **token** (and full connection URL) from the table.
140
+
141
+ ## Discovery and auto-provisioning
142
+
143
+ Unprovisioned devices can advertise themselves via mDNS and be enrolled from the server without manually copying a token.
144
+
145
+ - **Advertisement**: DNS-SD service `_ledit._tcp.local` with TXT records `id` (stable fingerprint), `model`, `version`, `proto`, `nonce`. The token is never advertised.
146
+ - **Fingerprint**: `fingerprint()` reads `/etc/machine-id` when available, otherwise a random ID persisted at `~/.config/ledit/device_id`. Stable across reboots.
147
+ - **Nonce**: `new_nonce()` generates a fresh value per boot and is included in the TXT records.
148
+ - **Optional dependency**: `zeroconf` is required only for discovery. Install with `pip install 'ledit[discovery]'`. If missing, advertising/provisioning is skipped with a warning and manual `LEDIT_TOKEN` mode is unaffected.
149
+ - **API**: `discovery.start_advertising()` / `discovery.stop_advertising()` and `discovery.provision(server_url, fingerprint, nonce, interval, timeout)` which polls `GET /api/device/provision?fingerprint=…&nonce=…`.
150
+
151
+ **Enabling flow**:
152
+
153
+ 1. Start the device without `LEDIT_TOKEN` (with the discovery extra installed). It begins advertising.
154
+ 2. In the server admin UI go to **Admin → Discovery** — the device appears as pending.
155
+ 3. Enroll it. The server binds the fingerprint+nonce to a token.
156
+ 4. The device polls `GET /api/device/provision` until the token is returned (once), persists it to `~/.config/ledit/token` (configurable via `LEDIT_CONFIG_DIR`), and then connects to `/ws/device/<token>`. Subsequent boots use the persisted token and `LEDIT_TOKEN` may be omitted.
157
+
158
+ ## Firmware OTA
159
+
160
+ `firmware.check_and_update(server_url, token, current_version, channel)` polls the server manifest, downloads the artifact, verifies `sha256`, and stages the update atomically.
161
+
162
+ - Polls `GET /api/device/firmware?version=<current>&channel=<channel>` (channel from `LEDIT_UPDATE_CHANNEL`).
163
+ - Downloads from the manifest `url` (or `/api/device/firmware/<version>/artifact`), verifies `sha256` (and `size` when provided).
164
+ - Stages to `~/.config/ledit/staging/` (or `LEDIT_STAGING_DIR`) as `firmware-<version>.bin` with an `activate` marker; the running process is never overwritten. A failed or interrupted update leaves the previous version bootable.
165
+ - Non-fatal on network/parse errors — logs a warning and returns.
166
+ - Polling interval is `LEDIT_UPDATE_INTERVAL` (default 3600 s); set `0` to disable.
167
+
168
+ ## Inbound webhook signing
169
+
170
+ When a signing secret is configured in **Admin → Webhook settings**, inbound webhook requests must be signed. This is separate from LEDit's *outbound* webhooks (which sign the body only).
171
+
172
+ - Headers:
173
+ - `X-LEDit-Timestamp: <unix seconds>`
174
+ - `X-LEDit-Signature: sha256=<hex>` where hex is `HMAC-SHA256(secret, "<timestamp>.<raw-body>")` — the timestamp string, a literal `.`, and the raw request body.
175
+ - Verification: missing, stale (>300 s, configurable via `signing_window_seconds`), or mismatched signatures get a generic `401`.
176
+ - When no signing secret is set, the legacy `X-API-Key` / `?token=` auth is unchanged. If both a signing secret and an API key/token are configured, both are required.
177
+
178
+ ## Run
179
+
180
+ Installed as a package, run the console script:
181
+
182
+ ```bash
183
+ LEDIT_SERVER=ws://ledit.local:8080 LEDIT_TOKEN=<token> ledit-device
184
+ ```
185
+
186
+ Or without installing (from the `device/` directory):
187
+
188
+ ```bash
189
+ LEDIT_SERVER=ws://ledit.local:8080 LEDIT_TOKEN=<token> python3 -m ledit_device
190
+ ```
191
+
192
+ The client reconnects automatically on network drops.
193
+
194
+ ### Test without hardware
195
+
196
+ ```bash
197
+ LEDIT_SERVER=ws://localhost:8080 LEDIT_TOKEN=<token> \
198
+ LEDIT_PREVIEW_DIR=/tmp/ledit_frames python3 -m ledit_device
199
+ ```
200
+
201
+ This writes each received frame as a PNG into `LEDIT_PREVIEW_DIR`.
202
+
203
+ ## Package layout
204
+
205
+ ```
206
+ device/
207
+ pyproject.toml # package metadata + console script
208
+ ledit_device/
209
+ __init__.py # version + public exports
210
+ __main__.py # entry point (python -m ledit_device)
211
+ config.py # env-var config + logging
212
+ display.py # MatrixDisplay / FileDisplay abstractions
213
+ client.py # WebSocket frame handling + rendering
214
+ telemetry.py # OpenTelemetry init/shutdown (traces, metrics, logs)
215
+ tests/
216
+ test_client.py # unit tests (no hardware required)
217
+ test_telemetry.py # telemetry unit tests
218
+ ```
219
+
220
+ ## Running tests
221
+
222
+ The unit tests use a `FileDisplay` (writes PNGs to a temp dir), so they run
223
+ without a panel or the `rgbmatrix` bindings installed:
224
+
225
+ ```bash
226
+ cd device
227
+ python3 -m unittest discover -s tests -v
228
+ ```