readerboard 0.1.4__tar.gz → 0.3.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.
- {readerboard-0.1.4/readerboard.egg-info → readerboard-0.3.0}/PKG-INFO +66 -50
- {readerboard-0.1.4 → readerboard-0.3.0}/README.md +63 -48
- {readerboard-0.1.4 → readerboard-0.3.0}/pyproject.toml +28 -14
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/__init__.py +1 -1
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/__main__.py +3 -3
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/app.py +31 -31
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/deps.py +32 -12
- readerboard-0.3.0/readerboard/api/errors.py +34 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/models.py +4 -64
- readerboard-0.1.4/readerboard/api/routes_v2.py → readerboard-0.3.0/readerboard/api/routes.py +11 -4
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/config.py +4 -34
- readerboard-0.3.0/readerboard/names.py +24 -0
- readerboard-0.3.0/readerboard/protocol/constants.py +654 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/markup.py +8 -4
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/tokens.py +0 -5
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/alerts.py +1 -2
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/commands.py +1 -1
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/registry.py +4 -12
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/controller.py +3 -3
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/layout.py +9 -5
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/state.py +107 -5
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/base.py +1 -1
- {readerboard-0.1.4 → readerboard-0.3.0/readerboard.egg-info}/PKG-INFO +66 -50
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/SOURCES.txt +6 -2
- readerboard-0.3.0/tests/test_api.py +448 -0
- readerboard-0.3.0/tests/test_component_names.py +198 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_constant_values.py +110 -4
- readerboard-0.3.0/tests/test_launch_configurations.py +67 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_markup.py +2 -2
- readerboard-0.3.0/tests/test_state.py +336 -0
- readerboard-0.3.0/tests/test_tool_icons.py +73 -0
- readerboard-0.1.4/readerboard/api/routes_simple.py +0 -143
- readerboard-0.1.4/readerboard/protocol/constants.py +0 -465
- readerboard-0.1.4/tests/test_api.py +0 -434
- readerboard-0.1.4/tests/test_state.py +0 -171
- {readerboard-0.1.4 → readerboard-0.3.0}/LICENSE +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/logging_setup.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/frames.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/py.typed +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/clock.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/fake.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/serial_link.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/requires.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/setup.cfg +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_alerts.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_clock.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_controller.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_frames.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_registry.py +0 -0
- {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_transport.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readerboard
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts,
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync
|
|
5
5
|
Author: mjaksn
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/mjaksn/readerboard
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
24
|
Classifier: Topic :: Home Automation
|
|
24
25
|
Classifier: Topic :: System :: Hardware
|
|
25
26
|
Classifier: Typing :: Typed
|
|
@@ -72,8 +73,9 @@ until it is released, after which the rotation resumes.
|
|
|
72
73
|
- **It survives restarts and outages.** The registered messages are persisted, and a
|
|
73
74
|
write that arrives while the sign is unreachable is accepted and delivered when the
|
|
74
75
|
link returns.
|
|
75
|
-
- **Errors are errors.** A dead serial link is a 503
|
|
76
|
-
|
|
76
|
+
- **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
|
|
77
|
+
render is a 400, each with the reason in the body. Nothing here reports a failure
|
|
78
|
+
under a 200.
|
|
77
79
|
|
|
78
80
|
## Requirements
|
|
79
81
|
|
|
@@ -106,6 +108,20 @@ docker run --rm -p 5001:5001 \
|
|
|
106
108
|
|
|
107
109
|
Then open <http://127.0.0.1:5001/docs>.
|
|
108
110
|
|
|
111
|
+
`loop://` swallows everything written to it, so the service runs but there is
|
|
112
|
+
nothing to see. To watch what it would have sent, run it against the sign
|
|
113
|
+
simulator in `tools/signsim/` instead:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
pip install --require-hashes -r tools/signsim/requirements.lock
|
|
117
|
+
python scripts/run_with_simulator.py
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
That starts the simulator and the service together, already pointed at each
|
|
121
|
+
other, and stops both on Ctrl+C. The simulator decodes each transmission, says
|
|
122
|
+
what every byte of it means, and shows what the sign would be holding as a
|
|
123
|
+
result. `tools/signsim/README.md` has the details.
|
|
124
|
+
|
|
109
125
|
## Installing it properly
|
|
110
126
|
|
|
111
127
|
Two ways, which do the same job. Pick whichever suits the machine.
|
|
@@ -161,12 +177,13 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
|
|
|
161
177
|
|
|
162
178
|
## Using it
|
|
163
179
|
|
|
164
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
180
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
|
|
181
|
+
Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
|
|
165
182
|
|
|
166
183
|
Register a message:
|
|
167
184
|
|
|
168
185
|
```
|
|
169
|
-
curl -X PUT http://localhost:5001/
|
|
186
|
+
curl -X PUT http://localhost:5001/messages/temperature \
|
|
170
187
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
171
188
|
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
|
|
172
189
|
```
|
|
@@ -174,7 +191,7 @@ curl -X PUT http://localhost:5001/v2/messages/temperature \
|
|
|
174
191
|
Register a second one and the sign rotates between them:
|
|
175
192
|
|
|
176
193
|
```
|
|
177
|
-
curl -X PUT http://localhost:5001/
|
|
194
|
+
curl -X PUT http://localhost:5001/messages/doorbell \
|
|
178
195
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
179
196
|
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
|
|
180
197
|
```
|
|
@@ -182,44 +199,25 @@ curl -X PUT http://localhost:5001/v2/messages/doorbell \
|
|
|
182
199
|
Take the sign over for thirty seconds:
|
|
183
200
|
|
|
184
201
|
```
|
|
185
|
-
curl -X POST http://localhost:5001/
|
|
202
|
+
curl -X POST http://localhost:5001/alerts \
|
|
186
203
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
187
204
|
-d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
|
|
188
205
|
```
|
|
189
206
|
|
|
190
|
-
The full API
|
|
207
|
+
The full API is at `/docs`. Every markup token, display mode, text position and
|
|
208
|
+
control command is listed by the `/enumerations` reads there, which answer at
|
|
209
|
+
request time rather than being frozen into the description.
|
|
191
210
|
|
|
192
211
|
### Writing messages
|
|
193
212
|
|
|
194
213
|
A message is plain text plus tokens written as `<name>`: `<green>18.4<degree>` is a
|
|
195
|
-
colour change, a number, and a degree symbol. `GET /
|
|
214
|
+
colour change, a number, and a degree symbol. `GET /enumerations/markup-tokens` lists
|
|
196
215
|
them all.
|
|
197
216
|
|
|
198
217
|
Text is encoded against the sign's own character table rather than as UTF-8, so `café`
|
|
199
|
-
displays correctly. A character the sign cannot render is rejected with a 400
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
## A simpler set of endpoints
|
|
203
|
-
|
|
204
|
-
Alongside `/v2` there is a smaller surface: `POST /Write/Message`,
|
|
205
|
-
`POST /Write/ControlCommand`, and the `/Enumerations` reads.
|
|
206
|
-
|
|
207
|
-
These follow one convention that `/v2` does not. **Every response is HTTP 200**, with the
|
|
208
|
-
outcome in the body:
|
|
209
|
-
|
|
210
|
-
```json
|
|
211
|
-
{"result": "OK", "result_message": "Message displayed on sign"}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
That suits a client which finds branching on status codes awkward, such as a Home
|
|
215
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The single exception is a
|
|
216
|
-
missing or wrong API key, which is a 401: a caller the service will not talk to is not the
|
|
217
|
-
same as a request that failed.
|
|
218
|
-
|
|
219
|
-
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
220
|
-
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
221
|
-
the sign. Written to an ordinary slot it looks identical while it is the only message
|
|
222
|
-
registered, and it shares the sign the moment anything else registers.
|
|
218
|
+
displays correctly. A character the sign cannot render is rejected with a 400, as is an
|
|
219
|
+
unknown token: a write is told what the sign would have made of it rather than being
|
|
220
|
+
shown something it did not ask for.
|
|
223
221
|
|
|
224
222
|
## Configuration
|
|
225
223
|
|
|
@@ -242,6 +240,19 @@ the log, but they are not settings to fiddle with.
|
|
|
242
240
|
## Security
|
|
243
241
|
|
|
244
242
|
An API key is required on every write, compared in constant time, and never logged.
|
|
243
|
+
Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
|
|
244
|
+
that could write to it.
|
|
245
|
+
|
|
246
|
+
The key is declared to the API description as a security scheme, so the Swagger UI at
|
|
247
|
+
`/docs` has an **Authorize** button: enter the key once and every write on the page
|
|
248
|
+
carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
|
|
249
|
+
or a Home Assistant `rest_command` changes.
|
|
250
|
+
|
|
251
|
+
That page is configured to remember the key, so it survives a reload or a browser
|
|
252
|
+
restart rather than needing to be pasted in again. Convenient on your own machine, and
|
|
253
|
+
worth knowing before you use **Authorize** on a shared or kiosk browser, where the next
|
|
254
|
+
person to open `/docs` inherits it. Use the browser's Logout in the Authorize dialog, or
|
|
255
|
+
just do not authorize there.
|
|
245
256
|
|
|
246
257
|
**Message content reaches the sign as protocol bytes**, so it is worth knowing what a
|
|
247
258
|
client holding the key can do. The markup renderer emits bytes only for tokens it
|
|
@@ -291,26 +302,31 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
291
302
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
292
303
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
293
304
|
|
|
294
|
-
|
|
305
|
+
`tools/signsim/` is the sign simulator, a PySide6 stand-in for the sign, described
|
|
306
|
+
above. `tools/apiclient/` is the client, a PySide6 application for calling the API by
|
|
307
|
+
hand: every endpoint, responses shown as text rather than JSON, and a vocabulary it
|
|
308
|
+
loads from the service rather than one compiled into it. The tests of both are
|
|
309
|
+
collected by the `pytest` run here and need no Qt installed; the applications do, and
|
|
310
|
+
each is pinned separately so that nothing the service installs ever pulls Qt in.
|
|
295
311
|
|
|
296
|
-
|
|
312
|
+
`scripts/run_with_simulator.py` starts the service and the simulator together, and
|
|
313
|
+
with `--with-client` the client as well, so the whole loop comes up from one command.
|
|
314
|
+
Both editors carry it as a launch configuration under the same name, "readerboard and
|
|
315
|
+
the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`, beside
|
|
316
|
+
configurations for running the pieces separately. Both carry the three way one as
|
|
317
|
+
"readerboard, the sign simulator and the client" as well.
|
|
297
318
|
|
|
298
|
-
|
|
299
|
-
`readerboard/protocol/constants.py` is vendored from
|
|
300
|
-
[jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), and that
|
|
301
|
-
repository carries no license file. No license is not the same as a permissive one: it
|
|
302
|
-
means no copying permission has been granted at all. That module is therefore the one
|
|
303
|
-
part of this project whose provenance is not cleanly MIT.
|
|
319
|
+
## Licence
|
|
304
320
|
|
|
305
|
-
|
|
306
|
-
expression, and `docs/protocol-notes.md` now cites the protocol document directly, so the
|
|
307
|
-
table can be regenerated from the primary source if that ever needs settling properly.
|
|
321
|
+
MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
|
|
308
322
|
|
|
309
323
|
## Credits
|
|
310
324
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
325
|
+
The protocol is documented in the Alpha Sign Communications Protocol, form 9708-8061,
|
|
326
|
+
published by Adaptive Micro Systems. Every byte value in
|
|
327
|
+
`readerboard/protocol/constants.py` is transcribed from that document, and
|
|
328
|
+
`tests/test_constant_values.py` pins each one against it with a citation per assertion.
|
|
314
329
|
|
|
315
|
-
|
|
316
|
-
|
|
330
|
+
An earlier version of this project took that table from
|
|
331
|
+
[jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), which is
|
|
332
|
+
recorded here with thanks even though no code from it remains.
|
|
@@ -30,8 +30,9 @@ until it is released, after which the rotation resumes.
|
|
|
30
30
|
- **It survives restarts and outages.** The registered messages are persisted, and a
|
|
31
31
|
write that arrives while the sign is unreachable is accepted and delivered when the
|
|
32
32
|
link returns.
|
|
33
|
-
- **Errors are errors.** A dead serial link is a 503
|
|
34
|
-
|
|
33
|
+
- **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
|
|
34
|
+
render is a 400, each with the reason in the body. Nothing here reports a failure
|
|
35
|
+
under a 200.
|
|
35
36
|
|
|
36
37
|
## Requirements
|
|
37
38
|
|
|
@@ -64,6 +65,20 @@ docker run --rm -p 5001:5001 \
|
|
|
64
65
|
|
|
65
66
|
Then open <http://127.0.0.1:5001/docs>.
|
|
66
67
|
|
|
68
|
+
`loop://` swallows everything written to it, so the service runs but there is
|
|
69
|
+
nothing to see. To watch what it would have sent, run it against the sign
|
|
70
|
+
simulator in `tools/signsim/` instead:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
pip install --require-hashes -r tools/signsim/requirements.lock
|
|
74
|
+
python scripts/run_with_simulator.py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That starts the simulator and the service together, already pointed at each
|
|
78
|
+
other, and stops both on Ctrl+C. The simulator decodes each transmission, says
|
|
79
|
+
what every byte of it means, and shows what the sign would be holding as a
|
|
80
|
+
result. `tools/signsim/README.md` has the details.
|
|
81
|
+
|
|
67
82
|
## Installing it properly
|
|
68
83
|
|
|
69
84
|
Two ways, which do the same job. Pick whichever suits the machine.
|
|
@@ -119,12 +134,13 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
|
|
|
119
134
|
|
|
120
135
|
## Using it
|
|
121
136
|
|
|
122
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
137
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
|
|
138
|
+
Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
|
|
123
139
|
|
|
124
140
|
Register a message:
|
|
125
141
|
|
|
126
142
|
```
|
|
127
|
-
curl -X PUT http://localhost:5001/
|
|
143
|
+
curl -X PUT http://localhost:5001/messages/temperature \
|
|
128
144
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
129
145
|
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
|
|
130
146
|
```
|
|
@@ -132,7 +148,7 @@ curl -X PUT http://localhost:5001/v2/messages/temperature \
|
|
|
132
148
|
Register a second one and the sign rotates between them:
|
|
133
149
|
|
|
134
150
|
```
|
|
135
|
-
curl -X PUT http://localhost:5001/
|
|
151
|
+
curl -X PUT http://localhost:5001/messages/doorbell \
|
|
136
152
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
137
153
|
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
|
|
138
154
|
```
|
|
@@ -140,44 +156,25 @@ curl -X PUT http://localhost:5001/v2/messages/doorbell \
|
|
|
140
156
|
Take the sign over for thirty seconds:
|
|
141
157
|
|
|
142
158
|
```
|
|
143
|
-
curl -X POST http://localhost:5001/
|
|
159
|
+
curl -X POST http://localhost:5001/alerts \
|
|
144
160
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
145
161
|
-d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
|
|
146
162
|
```
|
|
147
163
|
|
|
148
|
-
The full API
|
|
164
|
+
The full API is at `/docs`. Every markup token, display mode, text position and
|
|
165
|
+
control command is listed by the `/enumerations` reads there, which answer at
|
|
166
|
+
request time rather than being frozen into the description.
|
|
149
167
|
|
|
150
168
|
### Writing messages
|
|
151
169
|
|
|
152
170
|
A message is plain text plus tokens written as `<name>`: `<green>18.4<degree>` is a
|
|
153
|
-
colour change, a number, and a degree symbol. `GET /
|
|
171
|
+
colour change, a number, and a degree symbol. `GET /enumerations/markup-tokens` lists
|
|
154
172
|
them all.
|
|
155
173
|
|
|
156
174
|
Text is encoded against the sign's own character table rather than as UTF-8, so `café`
|
|
157
|
-
displays correctly. A character the sign cannot render is rejected with a 400
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
## A simpler set of endpoints
|
|
161
|
-
|
|
162
|
-
Alongside `/v2` there is a smaller surface: `POST /Write/Message`,
|
|
163
|
-
`POST /Write/ControlCommand`, and the `/Enumerations` reads.
|
|
164
|
-
|
|
165
|
-
These follow one convention that `/v2` does not. **Every response is HTTP 200**, with the
|
|
166
|
-
outcome in the body:
|
|
167
|
-
|
|
168
|
-
```json
|
|
169
|
-
{"result": "OK", "result_message": "Message displayed on sign"}
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
That suits a client which finds branching on status codes awkward, such as a Home
|
|
173
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The single exception is a
|
|
174
|
-
missing or wrong API key, which is a 401: a caller the service will not talk to is not the
|
|
175
|
-
same as a request that failed.
|
|
176
|
-
|
|
177
|
-
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
178
|
-
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
179
|
-
the sign. Written to an ordinary slot it looks identical while it is the only message
|
|
180
|
-
registered, and it shares the sign the moment anything else registers.
|
|
175
|
+
displays correctly. A character the sign cannot render is rejected with a 400, as is an
|
|
176
|
+
unknown token: a write is told what the sign would have made of it rather than being
|
|
177
|
+
shown something it did not ask for.
|
|
181
178
|
|
|
182
179
|
## Configuration
|
|
183
180
|
|
|
@@ -200,6 +197,19 @@ the log, but they are not settings to fiddle with.
|
|
|
200
197
|
## Security
|
|
201
198
|
|
|
202
199
|
An API key is required on every write, compared in constant time, and never logged.
|
|
200
|
+
Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
|
|
201
|
+
that could write to it.
|
|
202
|
+
|
|
203
|
+
The key is declared to the API description as a security scheme, so the Swagger UI at
|
|
204
|
+
`/docs` has an **Authorize** button: enter the key once and every write on the page
|
|
205
|
+
carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
|
|
206
|
+
or a Home Assistant `rest_command` changes.
|
|
207
|
+
|
|
208
|
+
That page is configured to remember the key, so it survives a reload or a browser
|
|
209
|
+
restart rather than needing to be pasted in again. Convenient on your own machine, and
|
|
210
|
+
worth knowing before you use **Authorize** on a shared or kiosk browser, where the next
|
|
211
|
+
person to open `/docs` inherits it. Use the browser's Logout in the Authorize dialog, or
|
|
212
|
+
just do not authorize there.
|
|
203
213
|
|
|
204
214
|
**Message content reaches the sign as protocol bytes**, so it is worth knowing what a
|
|
205
215
|
client holding the key can do. The markup renderer emits bytes only for tokens it
|
|
@@ -249,26 +259,31 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
249
259
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
250
260
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
251
261
|
|
|
252
|
-
|
|
262
|
+
`tools/signsim/` is the sign simulator, a PySide6 stand-in for the sign, described
|
|
263
|
+
above. `tools/apiclient/` is the client, a PySide6 application for calling the API by
|
|
264
|
+
hand: every endpoint, responses shown as text rather than JSON, and a vocabulary it
|
|
265
|
+
loads from the service rather than one compiled into it. The tests of both are
|
|
266
|
+
collected by the `pytest` run here and need no Qt installed; the applications do, and
|
|
267
|
+
each is pinned separately so that nothing the service installs ever pulls Qt in.
|
|
253
268
|
|
|
254
|
-
|
|
269
|
+
`scripts/run_with_simulator.py` starts the service and the simulator together, and
|
|
270
|
+
with `--with-client` the client as well, so the whole loop comes up from one command.
|
|
271
|
+
Both editors carry it as a launch configuration under the same name, "readerboard and
|
|
272
|
+
the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`, beside
|
|
273
|
+
configurations for running the pieces separately. Both carry the three way one as
|
|
274
|
+
"readerboard, the sign simulator and the client" as well.
|
|
255
275
|
|
|
256
|
-
|
|
257
|
-
`readerboard/protocol/constants.py` is vendored from
|
|
258
|
-
[jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), and that
|
|
259
|
-
repository carries no license file. No license is not the same as a permissive one: it
|
|
260
|
-
means no copying permission has been granted at all. That module is therefore the one
|
|
261
|
-
part of this project whose provenance is not cleanly MIT.
|
|
276
|
+
## Licence
|
|
262
277
|
|
|
263
|
-
|
|
264
|
-
expression, and `docs/protocol-notes.md` now cites the protocol document directly, so the
|
|
265
|
-
table can be regenerated from the primary source if that ever needs settling properly.
|
|
278
|
+
MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
|
|
266
279
|
|
|
267
280
|
## Credits
|
|
268
281
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
282
|
+
The protocol is documented in the Alpha Sign Communications Protocol, form 9708-8061,
|
|
283
|
+
published by Adaptive Micro Systems. Every byte value in
|
|
284
|
+
`readerboard/protocol/constants.py` is transcribed from that document, and
|
|
285
|
+
`tests/test_constant_values.py` pins each one against it with a citation per assertion.
|
|
272
286
|
|
|
273
|
-
|
|
274
|
-
|
|
287
|
+
An earlier version of this project took that table from
|
|
288
|
+
[jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), which is
|
|
289
|
+
recorded here with thanks even though no code from it remains.
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "readerboard"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts,
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
license = "MIT"
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Programming Language :: Python :: 3.11",
|
|
25
25
|
"Programming Language :: Python :: 3.12",
|
|
26
26
|
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
27
28
|
"Topic :: Home Automation",
|
|
28
29
|
"Topic :: System :: Hardware",
|
|
29
30
|
"Typing :: Typed",
|
|
@@ -36,7 +37,8 @@ classifiers = [
|
|
|
36
37
|
#
|
|
37
38
|
# The ceilings sit at the next major for the settled packages, and at the next
|
|
38
39
|
# minor for the two still on 0.x, where a minor bump is where a breaking change
|
|
39
|
-
# is allowed to live.
|
|
40
|
+
# is allowed to live. tzdata is the exception and takes a floor only: it is a
|
|
41
|
+
# calendar-versioned data package with no API to break.
|
|
40
42
|
dependencies = [
|
|
41
43
|
"fastapi>=0.141.1,<0.142",
|
|
42
44
|
# Plain uvicorn, not the "standard" extra. That extra exists to make a busy
|
|
@@ -69,8 +71,9 @@ dev = [
|
|
|
69
71
|
]
|
|
70
72
|
|
|
71
73
|
[project.scripts]
|
|
72
|
-
# The whole command line
|
|
73
|
-
#
|
|
74
|
+
# The whole command line, and what the systemd unit and the container image run.
|
|
75
|
+
# `python -m readerboard` runs the same thing out of a checkout, which is what
|
|
76
|
+
# the editor launch configurations use.
|
|
74
77
|
readerboard = "readerboard.__main__:main"
|
|
75
78
|
|
|
76
79
|
[project.urls]
|
|
@@ -80,8 +83,8 @@ Issues = "https://github.com/mjaksn/readerboard/issues"
|
|
|
80
83
|
Changelog = "https://github.com/mjaksn/readerboard/blob/main/CHANGELOG.md"
|
|
81
84
|
|
|
82
85
|
[tool.setuptools]
|
|
83
|
-
# Named explicitly rather than auto-discovered:
|
|
84
|
-
# beside the package
|
|
86
|
+
# Named explicitly rather than auto-discovered: `tests`, `tools` and `scripts`
|
|
87
|
+
# all sit beside the package in a flat layout, so what ships is decided here.
|
|
85
88
|
packages = [
|
|
86
89
|
"readerboard",
|
|
87
90
|
"readerboard.api",
|
|
@@ -97,7 +100,12 @@ packages = [
|
|
|
97
100
|
readerboard = ["py.typed"]
|
|
98
101
|
|
|
99
102
|
[tool.pytest.ini_options]
|
|
100
|
-
|
|
103
|
+
# The two tools under tools/ are collected too. Their tests import only the
|
|
104
|
+
# pure half of each, never PySide6, so they run in CI where Qt is not installed.
|
|
105
|
+
# The simulator's round trip is checked against the frame builders in this
|
|
106
|
+
# package; the client's catalogue is checked against docs/openapi.json, so a
|
|
107
|
+
# route added here fails that tool's tests in the same commit.
|
|
108
|
+
testpaths = ["tests", "tools/signsim/tests", "tools/apiclient/tests"]
|
|
101
109
|
addopts = "-q --strict-markers --strict-config"
|
|
102
110
|
asyncio_mode = "auto"
|
|
103
111
|
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -132,12 +140,18 @@ ignore = [
|
|
|
132
140
|
]
|
|
133
141
|
|
|
134
142
|
[tool.ruff.lint.per-file-ignores]
|
|
135
|
-
# The vendored constants table is a wall of assignments with trailing comments,
|
|
136
|
-
# and rewriting it into docstring-bearing prose would only obscure its origin.
|
|
137
|
-
"readerboard/protocol/constants.py" = ["E501", "RUF001", "RUF003"]
|
|
138
143
|
# A test's name is its documentation, and a docstring repeating it would be
|
|
139
|
-
# worse than none. Tests that need explaining have one anyway.
|
|
144
|
+
# worse than none. Tests that need explaining have one anyway. The two tools'
|
|
145
|
+
# tests are named separately because this glob is anchored at the project root
|
|
146
|
+
# and does not reach into tools/.
|
|
140
147
|
"tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
148
|
+
"tools/signsim/tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
149
|
+
"tools/apiclient/tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
150
|
+
|
|
151
|
+
[tool.ruff.lint.isort]
|
|
152
|
+
# The two tools are not under the project root, so import sorting would put
|
|
153
|
+
# them in the third party block and then complain they are in the wrong one.
|
|
154
|
+
known-first-party = ["readerboard", "signsim", "apiclient"]
|
|
141
155
|
|
|
142
156
|
[tool.ruff.lint.pydocstyle]
|
|
143
157
|
convention = "pep257"
|
|
@@ -155,7 +169,7 @@ module = ["serial", "serial.*"]
|
|
|
155
169
|
ignore_missing_imports = true
|
|
156
170
|
|
|
157
171
|
[[tool.mypy.overrides]]
|
|
158
|
-
# The
|
|
159
|
-
#
|
|
172
|
+
# The constants module is a flat table of byte literals; annotating every one of
|
|
173
|
+
# them would add nothing a reader does not already see.
|
|
160
174
|
module = "readerboard.protocol.constants"
|
|
161
175
|
disallow_untyped_defs = false
|
|
@@ -11,14 +11,14 @@ import argparse
|
|
|
11
11
|
|
|
12
12
|
import uvicorn
|
|
13
13
|
|
|
14
|
-
from readerboard import __version__, logging_setup
|
|
14
|
+
from readerboard import __version__, logging_setup, names
|
|
15
15
|
from readerboard.config import Settings
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def main() -> int:
|
|
19
19
|
"""Start the HTTP server."""
|
|
20
20
|
parser = argparse.ArgumentParser(
|
|
21
|
-
prog=
|
|
21
|
+
prog=names.IDENTIFIER,
|
|
22
22
|
description=(
|
|
23
23
|
"Serve the readerboard API, which drives a BetaBrite Classic sign. "
|
|
24
24
|
"Settings come from the config file "
|
|
@@ -26,7 +26,7 @@ def main() -> int:
|
|
|
26
26
|
"and from environment variables prefixed READERBOARD_."
|
|
27
27
|
),
|
|
28
28
|
)
|
|
29
|
-
parser.add_argument("--version", action="version", version="
|
|
29
|
+
parser.add_argument("--version", action="version", version="%s %s" % (names.IDENTIFIER, __version__))
|
|
30
30
|
parser.add_argument("--host", help="override the configured listen address")
|
|
31
31
|
parser.add_argument("--port", type=int, help="override the configured port")
|
|
32
32
|
parser.add_argument(
|
|
@@ -18,26 +18,19 @@ import contextlib
|
|
|
18
18
|
import logging
|
|
19
19
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
20
20
|
|
|
21
|
-
from fastapi import FastAPI, Request
|
|
21
|
+
from fastapi import FastAPI, Request
|
|
22
22
|
from fastapi.responses import JSONResponse
|
|
23
23
|
|
|
24
|
-
from readerboard import __version__, logging_setup
|
|
25
|
-
from readerboard.api import
|
|
24
|
+
from readerboard import __version__, logging_setup, names
|
|
25
|
+
from readerboard.api import errors, routes
|
|
26
26
|
from readerboard.api.deps import get_alerts, get_clock, get_controller, get_registry
|
|
27
27
|
from readerboard.api.models import HealthResponse, LinkHealth
|
|
28
28
|
from readerboard.config import Settings
|
|
29
|
-
from readerboard.
|
|
30
|
-
from readerboard.protocol.markup import MarkupError
|
|
31
|
-
from readerboard.services import commands
|
|
32
|
-
from readerboard.services.alerts import AlertService, AlertTooLong
|
|
29
|
+
from readerboard.services.alerts import AlertService
|
|
33
30
|
from readerboard.services.clock import ClockService
|
|
34
|
-
from readerboard.services.registry import
|
|
35
|
-
MessageRegistry,
|
|
36
|
-
MessageTooLong,
|
|
37
|
-
UnknownSlot,
|
|
38
|
-
)
|
|
31
|
+
from readerboard.services.registry import MessageRegistry
|
|
39
32
|
from readerboard.sign.controller import SignController
|
|
40
|
-
from readerboard.sign.layout import Layout
|
|
33
|
+
from readerboard.sign.layout import Layout
|
|
41
34
|
from readerboard.sign.state import StateStore
|
|
42
35
|
from readerboard.transport.base import Transport, TransportError
|
|
43
36
|
from readerboard.transport.serial_link import SerialTransport
|
|
@@ -52,11 +45,18 @@ Several sources can share the sign at once. Each registers a named **slot**, and
|
|
|
52
45
|
the sign rotates through the registered slots by itself. An **alert** takes the
|
|
53
46
|
whole display over until it is released, then the rotation resumes.
|
|
54
47
|
|
|
55
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. On this
|
|
49
|
+
page, put the key in once with the **Authorize** button and every write below
|
|
50
|
+
carries it.
|
|
51
|
+
|
|
52
|
+
A failure is reported by the status code, with the reason in a `detail` field:
|
|
53
|
+
400 for a command the sign does not have, a parameter it will not accept, a
|
|
54
|
+
message too long for its slot or markup the sign cannot render, 401 for a
|
|
55
|
+
missing or wrong `X-API-Key`, 404 for a slot nothing has registered, 409 when
|
|
56
|
+
every message slot is already in use, 503 when the sign is unreachable or no
|
|
57
|
+
API key is configured at all, 500 for something the service has no code for,
|
|
58
|
+
and 422 for a body that is not the shape the endpoint declares, which includes
|
|
59
|
+
a display mode or a text position the sign does not have.
|
|
60
60
|
"""
|
|
61
61
|
|
|
62
62
|
|
|
@@ -192,16 +192,19 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
192
192
|
logger.info("readerboard stopped")
|
|
193
193
|
|
|
194
194
|
app = FastAPI(
|
|
195
|
-
title=
|
|
195
|
+
title=names.DISPLAY_NAME,
|
|
196
196
|
description=DESCRIPTION,
|
|
197
197
|
version=__version__,
|
|
198
198
|
lifespan=lifespan,
|
|
199
|
+
# Keep the key entered in the Swagger UI's Authorize dialog across a page
|
|
200
|
+
# reload. Without it every reload is another trip to the config file for
|
|
201
|
+
# somebody trying things out, which is most of what /docs is for.
|
|
202
|
+
swagger_ui_parameters={"persistAuthorization": True},
|
|
199
203
|
)
|
|
200
204
|
app.state.settings = settings
|
|
201
205
|
|
|
202
206
|
_install_error_handlers(app)
|
|
203
|
-
app.include_router(
|
|
204
|
-
app.include_router(routes_simple.router)
|
|
207
|
+
app.include_router(routes.router)
|
|
205
208
|
|
|
206
209
|
@app.get("/health", tags=["Health"], summary="Is the service talking to the sign")
|
|
207
210
|
async def health(request: Request) -> HealthResponse:
|
|
@@ -238,7 +241,11 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
238
241
|
|
|
239
242
|
|
|
240
243
|
def _install_error_handlers(app: FastAPI) -> None:
|
|
241
|
-
"""Turn the service's own exceptions into the status codes they mean.
|
|
244
|
+
"""Turn the service's own exceptions into the status codes they mean.
|
|
245
|
+
|
|
246
|
+
Registered by walking the table in readerboard.api.errors, which is the one
|
|
247
|
+
place that decides what any of them means.
|
|
248
|
+
"""
|
|
242
249
|
|
|
243
250
|
def handler(code: int) -> Callable[[Request, Exception], Awaitable[JSONResponse]]:
|
|
244
251
|
async def handle(_: Request, exc: Exception) -> JSONResponse:
|
|
@@ -248,15 +255,8 @@ def _install_error_handlers(app: FastAPI) -> None:
|
|
|
248
255
|
|
|
249
256
|
return handle
|
|
250
257
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
app.add_exception_handler(MessageTooLong, handler(status.HTTP_400_BAD_REQUEST))
|
|
254
|
-
app.add_exception_handler(AlertTooLong, handler(status.HTTP_400_BAD_REQUEST))
|
|
255
|
-
app.add_exception_handler(commands.UnknownCommand, handler(status.HTTP_400_BAD_REQUEST))
|
|
256
|
-
app.add_exception_handler(commands.BadParameter, handler(status.HTTP_400_BAD_REQUEST))
|
|
257
|
-
app.add_exception_handler(UnknownSlot, handler(status.HTTP_404_NOT_FOUND))
|
|
258
|
-
app.add_exception_handler(LayoutFull, handler(status.HTTP_409_CONFLICT))
|
|
259
|
-
app.add_exception_handler(TransportError, handler(status.HTTP_503_SERVICE_UNAVAILABLE))
|
|
258
|
+
for kind, code in errors.STATUS_FOR_ERROR:
|
|
259
|
+
app.add_exception_handler(kind, handler(code))
|
|
260
260
|
|
|
261
261
|
|
|
262
262
|
app = create_app()
|