ledit 1.45.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.
- ledit-1.45.0.dist-info/METADATA +253 -0
- ledit-1.45.0.dist-info/RECORD +14 -0
- ledit-1.45.0.dist-info/WHEEL +5 -0
- ledit-1.45.0.dist-info/entry_points.txt +2 -0
- ledit-1.45.0.dist-info/top_level.txt +1 -0
- ledit_device/__init__.py +15 -0
- ledit_device/__main__.py +90 -0
- ledit_device/buttons.py +249 -0
- ledit_device/client.py +347 -0
- ledit_device/config.py +107 -0
- ledit_device/discovery.py +228 -0
- ledit_device/display.py +135 -0
- ledit_device/firmware.py +169 -0
- ledit_device/telemetry.py +206 -0
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ledit_device/__init__.py,sha256=6xkmOmMA8krfVxcxryuz9Rf5OOQ56ZcmYiknVBwmTBI,509
|
|
2
|
+
ledit_device/__main__.py,sha256=v8zGU7pF21vx1JKJEkwAsE3u8gBkBx-ALgiZMj1fLTM,3160
|
|
3
|
+
ledit_device/buttons.py,sha256=M0R6yqYViEbiwMMpQj_2H443AE1iD0nsp1CRIRqrhbM,8599
|
|
4
|
+
ledit_device/client.py,sha256=3gUNyRIGgBv6wGpNCqk7cq2yLCZ8GMQW2PFb_SPqBJc,12499
|
|
5
|
+
ledit_device/config.py,sha256=d-4chzMPZX69y9XuIQShXtetIcB-yT_LM5Q8wqc1Fsc,2738
|
|
6
|
+
ledit_device/discovery.py,sha256=nt0rO16bMrACodAdETX1_uW8o81KU8Kgax0nDhMy97M,7049
|
|
7
|
+
ledit_device/display.py,sha256=1G-3G5_jIeO4g021OSTlLwCXIa2Do92rX5Xz2QJmS60,4687
|
|
8
|
+
ledit_device/firmware.py,sha256=-C9QYK1H5wThNmxENQHqXJk6_niTCUzz8DWBS8fpBiQ,6318
|
|
9
|
+
ledit_device/telemetry.py,sha256=u38O-UUGYWdwZyGi7-QIDywO-PFADDXBuA62jg3OwWM,7719
|
|
10
|
+
ledit-1.45.0.dist-info/METADATA,sha256=z0rFYXvIQHRksIjEz1g_JCO8qS1euzweEefqqdMAOVk,12475
|
|
11
|
+
ledit-1.45.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
ledit-1.45.0.dist-info/entry_points.txt,sha256=seXvrC3UpQhphrZu283oQNZ-Siu0_R1c_FB4OA05wqs,60
|
|
13
|
+
ledit-1.45.0.dist-info/top_level.txt,sha256=wwZ-HMu477qfHhjb_vXHaQzC9Xojju2_-Ge17VEPLAk,13
|
|
14
|
+
ledit-1.45.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ledit_device
|
ledit_device/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""LEDit device client package.
|
|
2
|
+
|
|
3
|
+
Streams frames from a LEDit server over WebSocket and renders them onto an
|
|
4
|
+
RGB LED matrix (HUB75 panels via ``rpi-rgb-led-matrix``).
|
|
5
|
+
|
|
6
|
+
The device connects OUT to the server, so no inbound port or static IP is
|
|
7
|
+
required. Each device authenticates with its own token (admin -> Devices).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import Client
|
|
11
|
+
from .display import Display, FileDisplay, MatrixDisplay
|
|
12
|
+
|
|
13
|
+
__version__ = "1.45.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["Client", "Display", "MatrixDisplay", "FileDisplay", "__version__"]
|
ledit_device/__main__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Run as ``python -m ledit_device`` or via the ``ledit-device`` console script.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import websocket # websocket-client
|
|
7
|
+
|
|
8
|
+
from .client import Client, build_ws_url
|
|
9
|
+
from .config import log, server_url, token
|
|
10
|
+
from .display import make_display
|
|
11
|
+
from .telemetry import init_telemetry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
telemetry = init_telemetry()
|
|
16
|
+
buttons = None
|
|
17
|
+
client = None
|
|
18
|
+
adv = None
|
|
19
|
+
try:
|
|
20
|
+
from .config import token_optional
|
|
21
|
+
|
|
22
|
+
token_value = token_optional()
|
|
23
|
+
if not token_value:
|
|
24
|
+
# Discovery + provisioning mode
|
|
25
|
+
try:
|
|
26
|
+
from .discovery import fingerprint, new_nonce, start_advertising, provision
|
|
27
|
+
fp = fingerprint()
|
|
28
|
+
nonce = new_nonce()
|
|
29
|
+
adv = start_advertising(fp, nonce)
|
|
30
|
+
tok = provision(server_url(), fp, nonce, interval=2.0, timeout=300)
|
|
31
|
+
if tok:
|
|
32
|
+
token_value = tok
|
|
33
|
+
else:
|
|
34
|
+
# fallback to original token() which will exit with message
|
|
35
|
+
token_value = token()
|
|
36
|
+
except SystemExit:
|
|
37
|
+
raise
|
|
38
|
+
except Exception:
|
|
39
|
+
token_value = token()
|
|
40
|
+
url = build_ws_url(server_url(), token_value)
|
|
41
|
+
|
|
42
|
+
display = make_display()
|
|
43
|
+
client = Client(display)
|
|
44
|
+
log("info", "connecting to %s (matrix %dx%d)" % (url, display.width, display.height))
|
|
45
|
+
|
|
46
|
+
ws = websocket.WebSocketApp(
|
|
47
|
+
url,
|
|
48
|
+
on_message=client.on_message,
|
|
49
|
+
on_error=client.on_error,
|
|
50
|
+
on_close=client.on_close,
|
|
51
|
+
on_open=client.on_open,
|
|
52
|
+
on_reconnect=client.on_reconnect,
|
|
53
|
+
)
|
|
54
|
+
try: # pragma: no cover - hardware wiring, tested via mock
|
|
55
|
+
from .buttons import ButtonHandler # pragma: no cover
|
|
56
|
+
|
|
57
|
+
def _sender(msg): # pragma: no cover
|
|
58
|
+
try: # pragma: no cover
|
|
59
|
+
ws.send(msg) # pragma: no cover
|
|
60
|
+
except Exception: # pragma: no cover
|
|
61
|
+
pass # pragma: no cover
|
|
62
|
+
|
|
63
|
+
buttons = ButtonHandler(sender=_sender) # pragma: no cover
|
|
64
|
+
buttons.start() # pragma: no cover
|
|
65
|
+
except Exception: # pragma: no cover
|
|
66
|
+
pass # pragma: no cover
|
|
67
|
+
# run_forever with reconnect=True keeps the device online across drops.
|
|
68
|
+
ws.run_forever(ping_interval=30, ping_timeout=10, reconnect=5)
|
|
69
|
+
finally:
|
|
70
|
+
if adv is not None:
|
|
71
|
+
try:
|
|
72
|
+
from .discovery import stop_advertising
|
|
73
|
+
stop_advertising(adv)
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
if client is not None: # pragma: no cover
|
|
77
|
+
try: # pragma: no cover
|
|
78
|
+
client.close() # pragma: no cover
|
|
79
|
+
except Exception: # pragma: no cover
|
|
80
|
+
pass # pragma: no cover
|
|
81
|
+
if buttons is not None: # pragma: no cover
|
|
82
|
+
try: # pragma: no cover
|
|
83
|
+
buttons.close() # pragma: no cover
|
|
84
|
+
except Exception: # pragma: no cover
|
|
85
|
+
pass # pragma: no cover
|
|
86
|
+
telemetry.shutdown()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
ledit_device/buttons.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""GPIO button handling for push-to-display (next/pause) and hold gestures."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .config import env_int
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _platform() -> str:
|
|
15
|
+
"""Injectable platform seam so tests never mutate global os.name
|
|
16
|
+
(mutating os.name flips pathlib.Path flavour on Python 3.12+)."""
|
|
17
|
+
return os.name
|
|
18
|
+
|
|
19
|
+
_DEBOUNCE_S = 0.02
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ButtonHandler:
|
|
23
|
+
"""Handle GPIO buttons for next/pause actions plus long-press/hold.
|
|
24
|
+
|
|
25
|
+
Short presses (released before the long-press threshold) emit the existing
|
|
26
|
+
``next``/``pause`` actions on release. A press held at or beyond the long
|
|
27
|
+
threshold emits ``{"action": "hold"}`` (once, or repeatedly when
|
|
28
|
+
``LEDIT_BUTTON_HOLD_REPEAT_MS`` is set).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
on_next: callable invoked on a short next press.
|
|
32
|
+
on_pause: callable invoked on a short pause press.
|
|
33
|
+
on_hold: callable invoked on a long press/hold; defaults to sending
|
|
34
|
+
``{"action": "hold"}`` when *sender* is supplied.
|
|
35
|
+
sender: optional callable ``sender(json_str)`` used to build default
|
|
36
|
+
callbacks when *on_next*/*on_pause*/*on_hold* are not supplied.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, on_next=None, on_pause=None, sender=None, on_hold=None):
|
|
40
|
+
if sender is not None:
|
|
41
|
+
if on_next is None:
|
|
42
|
+
def _next(sender=sender):
|
|
43
|
+
sender(json.dumps({"action": "next"}))
|
|
44
|
+
|
|
45
|
+
on_next = _next
|
|
46
|
+
if on_pause is None: # pragma: no cover
|
|
47
|
+
def _pause(sender=sender): # pragma: no cover
|
|
48
|
+
sender(json.dumps({"action": "pause"})) # pragma: no cover
|
|
49
|
+
|
|
50
|
+
on_pause = _pause # pragma: no cover
|
|
51
|
+
if on_hold is None:
|
|
52
|
+
def _hold(sender=sender):
|
|
53
|
+
sender(json.dumps({"action": "hold"}))
|
|
54
|
+
|
|
55
|
+
on_hold = _hold
|
|
56
|
+
self._on_next = on_next
|
|
57
|
+
self._on_pause = on_pause
|
|
58
|
+
self._on_hold = on_hold
|
|
59
|
+
self._sender = sender
|
|
60
|
+
self._next_pin = env_int("LEDIT_BTN_NEXT_PIN", 0)
|
|
61
|
+
self._pause_pin = env_int("LEDIT_BTN_PAUSE_PIN", 0)
|
|
62
|
+
self._last_press: dict[str, float] = {}
|
|
63
|
+
self._started = False
|
|
64
|
+
self._chip = None
|
|
65
|
+
self._lines = []
|
|
66
|
+
|
|
67
|
+
# Gesture thresholds (ms). A release before the long threshold is a
|
|
68
|
+
# short press; the long threshold must exceed the nominal short window.
|
|
69
|
+
self._short_ms = env_int("LEDIT_BUTTON_SHORT_MS", 500)
|
|
70
|
+
self._long_ms = env_int("LEDIT_BUTTON_LONG_MS", 800)
|
|
71
|
+
self._repeat_ms = env_int("LEDIT_BUTTON_HOLD_REPEAT_MS", 0)
|
|
72
|
+
if self._short_ms <= 0:
|
|
73
|
+
self._short_ms = 500
|
|
74
|
+
if self._long_ms <= self._short_ms:
|
|
75
|
+
self._long_ms = self._short_ms + 300
|
|
76
|
+
if self._repeat_ms < 0:
|
|
77
|
+
self._repeat_ms = 0
|
|
78
|
+
|
|
79
|
+
self._press_start: dict[str, float] = {}
|
|
80
|
+
self._hold_sent: dict[str, bool] = {}
|
|
81
|
+
self._hold_timers: dict[str, threading.Timer] = {}
|
|
82
|
+
|
|
83
|
+
# -- pin helpers ---------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _should_debounce(self, key: str) -> bool:
|
|
86
|
+
now = time.monotonic()
|
|
87
|
+
last = self._last_press.get(key, 0)
|
|
88
|
+
if now - last < _DEBOUNCE_S:
|
|
89
|
+
return True
|
|
90
|
+
self._last_press[key] = now
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def _safe_invoke(self, cb, key: str):
|
|
94
|
+
if cb is None:
|
|
95
|
+
return
|
|
96
|
+
if self._should_debounce(key):
|
|
97
|
+
return
|
|
98
|
+
self._invoke(cb, key)
|
|
99
|
+
|
|
100
|
+
def _invoke(self, cb, key: str):
|
|
101
|
+
if cb is None:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
cb()
|
|
105
|
+
except Exception as exc: # noqa: BLE001
|
|
106
|
+
logger.warning("button callback %s failed: %s", key, exc)
|
|
107
|
+
|
|
108
|
+
# public triggers (testable without hardware)
|
|
109
|
+
def press_next(self):
|
|
110
|
+
self._safe_invoke(self._on_next, "next")
|
|
111
|
+
|
|
112
|
+
def press_pause(self):
|
|
113
|
+
self._safe_invoke(self._on_pause, "pause")
|
|
114
|
+
|
|
115
|
+
# -- gesture state machine ----------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _begin_press(self, key: str):
|
|
118
|
+
if self._should_debounce(key):
|
|
119
|
+
return
|
|
120
|
+
self._press_start[key] = time.monotonic()
|
|
121
|
+
self._hold_sent[key] = False
|
|
122
|
+
self._start_hold_timer(key)
|
|
123
|
+
|
|
124
|
+
def _end_press(self, key: str, short_cb):
|
|
125
|
+
start = self._press_start.pop(key, None)
|
|
126
|
+
self._cancel_hold_timer(key)
|
|
127
|
+
if start is None:
|
|
128
|
+
return
|
|
129
|
+
if self._hold_sent.get(key):
|
|
130
|
+
return
|
|
131
|
+
duration_ms = (time.monotonic() - start) * 1000.0
|
|
132
|
+
if duration_ms >= self._long_ms:
|
|
133
|
+
self._emit_hold(key)
|
|
134
|
+
else:
|
|
135
|
+
self._invoke(short_cb, key)
|
|
136
|
+
|
|
137
|
+
def _start_hold_timer(self, key: str):
|
|
138
|
+
self._cancel_hold_timer(key)
|
|
139
|
+
|
|
140
|
+
def fire():
|
|
141
|
+
if key not in self._press_start:
|
|
142
|
+
return
|
|
143
|
+
if self._hold_sent.get(key):
|
|
144
|
+
# Repeat while still held: bypass the once-only guard.
|
|
145
|
+
self._invoke(self._on_hold, key + ":hold")
|
|
146
|
+
else:
|
|
147
|
+
self._emit_hold(key)
|
|
148
|
+
if self._repeat_ms > 0 and key in self._press_start:
|
|
149
|
+
timer = threading.Timer(self._repeat_ms / 1000.0, fire)
|
|
150
|
+
timer.daemon = True
|
|
151
|
+
self._hold_timers[key] = timer
|
|
152
|
+
timer.start()
|
|
153
|
+
|
|
154
|
+
timer = threading.Timer(self._long_ms / 1000.0, fire)
|
|
155
|
+
timer.daemon = True
|
|
156
|
+
self._hold_timers[key] = timer
|
|
157
|
+
timer.start()
|
|
158
|
+
|
|
159
|
+
def _cancel_hold_timer(self, key: str):
|
|
160
|
+
timer = self._hold_timers.pop(key, None)
|
|
161
|
+
if timer is not None:
|
|
162
|
+
timer.cancel()
|
|
163
|
+
|
|
164
|
+
def _emit_hold(self, key: str):
|
|
165
|
+
if self._hold_sent.get(key):
|
|
166
|
+
return
|
|
167
|
+
self._hold_sent[key] = True
|
|
168
|
+
self._invoke(self._on_hold, key + ":hold")
|
|
169
|
+
|
|
170
|
+
def press_next_down(self):
|
|
171
|
+
self._begin_press("next")
|
|
172
|
+
|
|
173
|
+
def press_next_up(self):
|
|
174
|
+
self._end_press("next", self._on_next)
|
|
175
|
+
|
|
176
|
+
def press_pause_down(self):
|
|
177
|
+
self._begin_press("pause")
|
|
178
|
+
|
|
179
|
+
def press_pause_up(self):
|
|
180
|
+
self._end_press("pause", self._on_pause)
|
|
181
|
+
|
|
182
|
+
# -- lifecycle -----------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def setup(self):
|
|
185
|
+
return self.start()
|
|
186
|
+
|
|
187
|
+
def start(self):
|
|
188
|
+
if _platform() != "posix":
|
|
189
|
+
logger.info("buttons disabled: non-posix platform")
|
|
190
|
+
return
|
|
191
|
+
if not self._next_pin and not self._pause_pin:
|
|
192
|
+
logger.info("buttons disabled: no pins configured")
|
|
193
|
+
return
|
|
194
|
+
try:
|
|
195
|
+
import gpiod # noqa: F401
|
|
196
|
+
except ImportError:
|
|
197
|
+
logger.info("buttons disabled: gpiod not available")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# At this point we would open gpiod lines with pull-up and 20ms
|
|
201
|
+
# debounce. The actual hardware setup is intentionally minimal and
|
|
202
|
+
# failure-tolerant; any error is logged and treated as no-op.
|
|
203
|
+
try: # pragma: no cover - hardware path
|
|
204
|
+
# Try to open lines if gpiod is available; best-effort.
|
|
205
|
+
# We keep the implementation lightweight so tests without hardware
|
|
206
|
+
# still pass. Real hardware path would request lines here.
|
|
207
|
+
import gpiod # re-import for use # pragma: no cover
|
|
208
|
+
|
|
209
|
+
# Attempt generic setup; swallow all errors.
|
|
210
|
+
# Use gpiod v2 API if available, otherwise no-op.
|
|
211
|
+
if hasattr(gpiod, "Chip"): # pragma: no cover
|
|
212
|
+
pass # placeholder for real chip open
|
|
213
|
+
self._started = True
|
|
214
|
+
logger.info(
|
|
215
|
+
"buttons enabled: next_pin=%s pause_pin=%s",
|
|
216
|
+
self._next_pin,
|
|
217
|
+
self._pause_pin,
|
|
218
|
+
)
|
|
219
|
+
except Exception as exc: # noqa: BLE001 # pragma: no cover
|
|
220
|
+
logger.info("buttons disabled: gpiod setup failed: %s", exc) # pragma: no cover
|
|
221
|
+
return # pragma: no cover
|
|
222
|
+
|
|
223
|
+
def stop(self):
|
|
224
|
+
self.close()
|
|
225
|
+
|
|
226
|
+
def close(self):
|
|
227
|
+
for timer in list(self._hold_timers.values()):
|
|
228
|
+
try:
|
|
229
|
+
timer.cancel()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
self._hold_timers = {}
|
|
233
|
+
try:
|
|
234
|
+
for line in self._lines:
|
|
235
|
+
try:
|
|
236
|
+
line.release()
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
if self._chip is not None:
|
|
240
|
+
try:
|
|
241
|
+
self._chip.close()
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
except Exception: # pragma: no cover
|
|
245
|
+
pass # pragma: no cover
|
|
246
|
+
finally:
|
|
247
|
+
self._lines = []
|
|
248
|
+
self._chip = None
|
|
249
|
+
self._started = False
|