readerboard 0.2.0__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.2.0/readerboard.egg-info → readerboard-0.3.0}/PKG-INFO +35 -58
- {readerboard-0.2.0 → readerboard-0.3.0}/README.md +32 -56
- {readerboard-0.2.0 → readerboard-0.3.0}/pyproject.toml +23 -20
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/__init__.py +1 -1
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/__main__.py +3 -3
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/app.py +23 -31
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/deps.py +5 -14
- readerboard-0.3.0/readerboard/api/errors.py +34 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/models.py +4 -64
- readerboard-0.2.0/readerboard/api/routes_v2.py → readerboard-0.3.0/readerboard/api/routes.py +11 -4
- {readerboard-0.2.0 → 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.2.0 → readerboard-0.3.0}/readerboard/protocol/markup.py +8 -4
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/protocol/tokens.py +0 -5
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/alerts.py +1 -2
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/commands.py +1 -1
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/registry.py +1 -9
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/controller.py +3 -3
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/layout.py +9 -5
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/state.py +107 -5
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/base.py +1 -1
- {readerboard-0.2.0 → readerboard-0.3.0/readerboard.egg-info}/PKG-INFO +35 -58
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/SOURCES.txt +6 -2
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_api.py +120 -203
- readerboard-0.3.0/tests/test_component_names.py +198 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_constant_values.py +110 -4
- readerboard-0.3.0/tests/test_launch_configurations.py +67 -0
- readerboard-0.3.0/tests/test_state.py +336 -0
- readerboard-0.3.0/tests/test_tool_icons.py +73 -0
- readerboard-0.2.0/readerboard/api/routes_simple.py +0 -145
- readerboard-0.2.0/readerboard/protocol/constants.py +0 -465
- readerboard-0.2.0/tests/test_state.py +0 -171
- {readerboard-0.2.0 → readerboard-0.3.0}/LICENSE +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/logging_setup.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/protocol/frames.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/py.typed +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/clock.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/fake.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/serial_link.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/requires.txt +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/setup.cfg +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_alerts.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_clock.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_controller.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_frames.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_markup.py +0 -0
- {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_registry.py +0 -0
- {readerboard-0.2.0 → 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
|
|
|
@@ -181,7 +183,7 @@ Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole pa
|
|
|
181
183
|
Register a message:
|
|
182
184
|
|
|
183
185
|
```
|
|
184
|
-
curl -X PUT http://localhost:5001/
|
|
186
|
+
curl -X PUT http://localhost:5001/messages/temperature \
|
|
185
187
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
186
188
|
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
|
|
187
189
|
```
|
|
@@ -189,7 +191,7 @@ curl -X PUT http://localhost:5001/v2/messages/temperature \
|
|
|
189
191
|
Register a second one and the sign rotates between them:
|
|
190
192
|
|
|
191
193
|
```
|
|
192
|
-
curl -X PUT http://localhost:5001/
|
|
194
|
+
curl -X PUT http://localhost:5001/messages/doorbell \
|
|
193
195
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
194
196
|
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
|
|
195
197
|
```
|
|
@@ -197,46 +199,25 @@ curl -X PUT http://localhost:5001/v2/messages/doorbell \
|
|
|
197
199
|
Take the sign over for thirty seconds:
|
|
198
200
|
|
|
199
201
|
```
|
|
200
|
-
curl -X POST http://localhost:5001/
|
|
202
|
+
curl -X POST http://localhost:5001/alerts \
|
|
201
203
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
202
204
|
-d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
|
|
203
205
|
```
|
|
204
206
|
|
|
205
|
-
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.
|
|
206
210
|
|
|
207
211
|
### Writing messages
|
|
208
212
|
|
|
209
213
|
A message is plain text plus tokens written as `<name>`: `<green>18.4<degree>` is a
|
|
210
|
-
colour change, a number, and a degree symbol. `GET /
|
|
214
|
+
colour change, a number, and a degree symbol. `GET /enumerations/markup-tokens` lists
|
|
211
215
|
them all.
|
|
212
216
|
|
|
213
217
|
Text is encoded against the sign's own character table rather than as UTF-8, so `café`
|
|
214
|
-
displays correctly. A character the sign cannot render is rejected with a 400
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
## A simpler set of endpoints
|
|
218
|
-
|
|
219
|
-
Alongside `/v2` there is a smaller surface: `POST /Write/Message`,
|
|
220
|
-
`POST /Write/ControlCommand`, and the `/Enumerations` reads.
|
|
221
|
-
|
|
222
|
-
These follow one convention that `/v2` does not. **Every response is HTTP 200**, with the
|
|
223
|
-
outcome in the body:
|
|
224
|
-
|
|
225
|
-
```json
|
|
226
|
-
{"result": "OK", "result_message": "Message displayed on sign"}
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
That suits a client which finds branching on status codes awkward, such as a Home
|
|
230
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The exceptions are the
|
|
231
|
-
requests that never reach the endpoint at all: a missing or wrong API key is a 401, a
|
|
232
|
-
service with no API key configured is a 503, and a body that is not the shape the endpoint
|
|
233
|
-
declares gets FastAPI's own 422. A caller the service will not talk to, and a body it
|
|
234
|
-
cannot read, are not the same as a request that failed.
|
|
235
|
-
|
|
236
|
-
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
237
|
-
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
238
|
-
the sign. Written to an ordinary slot it looks identical while it is the only message
|
|
239
|
-
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.
|
|
240
221
|
|
|
241
222
|
## Configuration
|
|
242
223
|
|
|
@@ -321,35 +302,31 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
321
302
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
322
303
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
323
304
|
|
|
324
|
-
`tools/signsim/` is a PySide6 stand-in for the sign, described
|
|
325
|
-
|
|
326
|
-
|
|
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.
|
|
327
311
|
|
|
328
|
-
`scripts/run_with_simulator.py` starts the service and the simulator together
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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.
|
|
332
318
|
|
|
333
319
|
## Licence
|
|
334
320
|
|
|
335
321
|
MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
|
|
336
322
|
|
|
337
|
-
One caveat, recorded because it is easy to miss.
|
|
338
|
-
`readerboard/protocol/constants.py` is vendored from
|
|
339
|
-
[jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), and that
|
|
340
|
-
repository carries no license file. No license is not the same as a permissive one: it
|
|
341
|
-
means no copying permission has been granted at all. That module is therefore the one
|
|
342
|
-
part of this project whose provenance is not cleanly MIT.
|
|
343
|
-
|
|
344
|
-
In practice it is a table of byte values dictated by the protocol rather than authored
|
|
345
|
-
expression, and `docs/protocol-notes.md` now cites the protocol document directly, so the
|
|
346
|
-
table can be regenerated from the primary source if that ever needs settling properly.
|
|
347
|
-
|
|
348
323
|
## Credits
|
|
349
324
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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.
|
|
353
329
|
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
|
@@ -139,7 +140,7 @@ Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole pa
|
|
|
139
140
|
Register a message:
|
|
140
141
|
|
|
141
142
|
```
|
|
142
|
-
curl -X PUT http://localhost:5001/
|
|
143
|
+
curl -X PUT http://localhost:5001/messages/temperature \
|
|
143
144
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
144
145
|
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
|
|
145
146
|
```
|
|
@@ -147,7 +148,7 @@ curl -X PUT http://localhost:5001/v2/messages/temperature \
|
|
|
147
148
|
Register a second one and the sign rotates between them:
|
|
148
149
|
|
|
149
150
|
```
|
|
150
|
-
curl -X PUT http://localhost:5001/
|
|
151
|
+
curl -X PUT http://localhost:5001/messages/doorbell \
|
|
151
152
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
152
153
|
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
|
|
153
154
|
```
|
|
@@ -155,46 +156,25 @@ curl -X PUT http://localhost:5001/v2/messages/doorbell \
|
|
|
155
156
|
Take the sign over for thirty seconds:
|
|
156
157
|
|
|
157
158
|
```
|
|
158
|
-
curl -X POST http://localhost:5001/
|
|
159
|
+
curl -X POST http://localhost:5001/alerts \
|
|
159
160
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
160
161
|
-d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
|
|
161
162
|
```
|
|
162
163
|
|
|
163
|
-
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.
|
|
164
167
|
|
|
165
168
|
### Writing messages
|
|
166
169
|
|
|
167
170
|
A message is plain text plus tokens written as `<name>`: `<green>18.4<degree>` is a
|
|
168
|
-
colour change, a number, and a degree symbol. `GET /
|
|
171
|
+
colour change, a number, and a degree symbol. `GET /enumerations/markup-tokens` lists
|
|
169
172
|
them all.
|
|
170
173
|
|
|
171
174
|
Text is encoded against the sign's own character table rather than as UTF-8, so `café`
|
|
172
|
-
displays correctly. A character the sign cannot render is rejected with a 400
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
## A simpler set of endpoints
|
|
176
|
-
|
|
177
|
-
Alongside `/v2` there is a smaller surface: `POST /Write/Message`,
|
|
178
|
-
`POST /Write/ControlCommand`, and the `/Enumerations` reads.
|
|
179
|
-
|
|
180
|
-
These follow one convention that `/v2` does not. **Every response is HTTP 200**, with the
|
|
181
|
-
outcome in the body:
|
|
182
|
-
|
|
183
|
-
```json
|
|
184
|
-
{"result": "OK", "result_message": "Message displayed on sign"}
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
That suits a client which finds branching on status codes awkward, such as a Home
|
|
188
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The exceptions are the
|
|
189
|
-
requests that never reach the endpoint at all: a missing or wrong API key is a 401, a
|
|
190
|
-
service with no API key configured is a 503, and a body that is not the shape the endpoint
|
|
191
|
-
declares gets FastAPI's own 422. A caller the service will not talk to, and a body it
|
|
192
|
-
cannot read, are not the same as a request that failed.
|
|
193
|
-
|
|
194
|
-
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
195
|
-
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
196
|
-
the sign. Written to an ordinary slot it looks identical while it is the only message
|
|
197
|
-
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.
|
|
198
178
|
|
|
199
179
|
## Configuration
|
|
200
180
|
|
|
@@ -279,35 +259,31 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
279
259
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
280
260
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
281
261
|
|
|
282
|
-
`tools/signsim/` is a PySide6 stand-in for the sign, described
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
285
268
|
|
|
286
|
-
`scripts/run_with_simulator.py` starts the service and the simulator together
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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.
|
|
290
275
|
|
|
291
276
|
## Licence
|
|
292
277
|
|
|
293
278
|
MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
|
|
294
279
|
|
|
295
|
-
One caveat, recorded because it is easy to miss.
|
|
296
|
-
`readerboard/protocol/constants.py` is vendored from
|
|
297
|
-
[jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), and that
|
|
298
|
-
repository carries no license file. No license is not the same as a permissive one: it
|
|
299
|
-
means no copying permission has been granted at all. That module is therefore the one
|
|
300
|
-
part of this project whose provenance is not cleanly MIT.
|
|
301
|
-
|
|
302
|
-
In practice it is a table of byte values dictated by the protocol rather than authored
|
|
303
|
-
expression, and `docs/protocol-notes.md` now cites the protocol document directly, so the
|
|
304
|
-
table can be regenerated from the primary source if that ever needs settling properly.
|
|
305
|
-
|
|
306
280
|
## Credits
|
|
307
281
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
311
286
|
|
|
312
|
-
|
|
313
|
-
|
|
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,10 +100,12 @@ packages = [
|
|
|
97
100
|
readerboard = ["py.typed"]
|
|
98
101
|
|
|
99
102
|
[tool.pytest.ini_options]
|
|
100
|
-
# The
|
|
101
|
-
# half of
|
|
102
|
-
#
|
|
103
|
-
|
|
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"]
|
|
104
109
|
addopts = "-q --strict-markers --strict-config"
|
|
105
110
|
asyncio_mode = "auto"
|
|
106
111
|
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -135,20 +140,18 @@ ignore = [
|
|
|
135
140
|
]
|
|
136
141
|
|
|
137
142
|
[tool.ruff.lint.per-file-ignores]
|
|
138
|
-
# The vendored constants table is a wall of assignments with trailing comments,
|
|
139
|
-
# and rewriting it into docstring-bearing prose would only obscure its origin.
|
|
140
|
-
"readerboard/protocol/constants.py" = ["E501", "RUF001", "RUF003"]
|
|
141
143
|
# A test's name is its documentation, and a docstring repeating it would be
|
|
142
|
-
# worse than none. Tests that need explaining have one anyway. The
|
|
144
|
+
# worse than none. Tests that need explaining have one anyway. The two tools'
|
|
143
145
|
# tests are named separately because this glob is anchored at the project root
|
|
144
146
|
# and does not reach into tools/.
|
|
145
147
|
"tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
146
148
|
"tools/signsim/tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
149
|
+
"tools/apiclient/tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
147
150
|
|
|
148
151
|
[tool.ruff.lint.isort]
|
|
149
|
-
# The
|
|
150
|
-
# in the third party block and then complain
|
|
151
|
-
known-first-party = ["readerboard", "signsim"]
|
|
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"]
|
|
152
155
|
|
|
153
156
|
[tool.ruff.lint.pydocstyle]
|
|
154
157
|
convention = "pep257"
|
|
@@ -166,7 +169,7 @@ module = ["serial", "serial.*"]
|
|
|
166
169
|
ignore_missing_imports = true
|
|
167
170
|
|
|
168
171
|
[[tool.mypy.overrides]]
|
|
169
|
-
# The
|
|
170
|
-
#
|
|
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.
|
|
171
174
|
module = "readerboard.protocol.constants"
|
|
172
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
|
|
@@ -56,11 +49,14 @@ Every write needs an `X-API-Key` header. Reads and `GET /health` do not. On this
|
|
|
56
49
|
page, put the key in once with the **Authorize** button and every write below
|
|
57
50
|
carries it.
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
or wrong `X-API-Key
|
|
63
|
-
|
|
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.
|
|
64
60
|
"""
|
|
65
61
|
|
|
66
62
|
|
|
@@ -196,7 +192,7 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
196
192
|
logger.info("readerboard stopped")
|
|
197
193
|
|
|
198
194
|
app = FastAPI(
|
|
199
|
-
title=
|
|
195
|
+
title=names.DISPLAY_NAME,
|
|
200
196
|
description=DESCRIPTION,
|
|
201
197
|
version=__version__,
|
|
202
198
|
lifespan=lifespan,
|
|
@@ -208,8 +204,7 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
208
204
|
app.state.settings = settings
|
|
209
205
|
|
|
210
206
|
_install_error_handlers(app)
|
|
211
|
-
app.include_router(
|
|
212
|
-
app.include_router(routes_simple.router)
|
|
207
|
+
app.include_router(routes.router)
|
|
213
208
|
|
|
214
209
|
@app.get("/health", tags=["Health"], summary="Is the service talking to the sign")
|
|
215
210
|
async def health(request: Request) -> HealthResponse:
|
|
@@ -246,7 +241,11 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
246
241
|
|
|
247
242
|
|
|
248
243
|
def _install_error_handlers(app: FastAPI) -> None:
|
|
249
|
-
"""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
|
+
"""
|
|
250
249
|
|
|
251
250
|
def handler(code: int) -> Callable[[Request, Exception], Awaitable[JSONResponse]]:
|
|
252
251
|
async def handle(_: Request, exc: Exception) -> JSONResponse:
|
|
@@ -256,15 +255,8 @@ def _install_error_handlers(app: FastAPI) -> None:
|
|
|
256
255
|
|
|
257
256
|
return handle
|
|
258
257
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
app.add_exception_handler(MessageTooLong, handler(status.HTTP_400_BAD_REQUEST))
|
|
262
|
-
app.add_exception_handler(AlertTooLong, handler(status.HTTP_400_BAD_REQUEST))
|
|
263
|
-
app.add_exception_handler(commands.UnknownCommand, handler(status.HTTP_400_BAD_REQUEST))
|
|
264
|
-
app.add_exception_handler(commands.BadParameter, handler(status.HTTP_400_BAD_REQUEST))
|
|
265
|
-
app.add_exception_handler(UnknownSlot, handler(status.HTTP_404_NOT_FOUND))
|
|
266
|
-
app.add_exception_handler(LayoutFull, handler(status.HTTP_409_CONFLICT))
|
|
267
|
-
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))
|
|
268
260
|
|
|
269
261
|
|
|
270
262
|
app = create_app()
|
|
@@ -14,7 +14,6 @@ from typing import Annotated
|
|
|
14
14
|
from fastapi import Depends, HTTPException, Request, Security, status
|
|
15
15
|
from fastapi.security import APIKeyHeader
|
|
16
16
|
|
|
17
|
-
from readerboard.config import Settings
|
|
18
17
|
from readerboard.services.alerts import AlertService
|
|
19
18
|
from readerboard.services.clock import ClockService
|
|
20
19
|
from readerboard.services.registry import MessageRegistry
|
|
@@ -30,9 +29,8 @@ API_KEY_HEADER = "X-API-Key"
|
|
|
30
29
|
# `auto_error=False` is load bearing. Left at its default the scheme rejects a
|
|
31
30
|
# missing key itself, with its own wording and with no way to tell "you sent no
|
|
32
31
|
# key" apart from "this service has no key configured at all". Those are
|
|
33
|
-
# different answers, 401 and 503, and
|
|
34
|
-
#
|
|
35
|
-
# the scheme and nothing else.
|
|
32
|
+
# different answers, 401 and 503, and both are documented, so the checking stays
|
|
33
|
+
# in `require_api_key` below and this declares the scheme and nothing else.
|
|
36
34
|
# The scheme name becomes a key under `components.securitySchemes`, and the
|
|
37
35
|
# OpenAPI specification requires those to match `^[a-zA-Z0-9\.\-_]+$` (section
|
|
38
36
|
# 4.8.7.1). So it cannot be the prettier "API key", however much better that
|
|
@@ -51,12 +49,6 @@ api_key_scheme = APIKeyHeader(
|
|
|
51
49
|
)
|
|
52
50
|
|
|
53
51
|
|
|
54
|
-
def get_settings(request: Request) -> Settings:
|
|
55
|
-
"""Return the service's configuration."""
|
|
56
|
-
settings: Settings = request.app.state.settings
|
|
57
|
-
return settings
|
|
58
|
-
|
|
59
|
-
|
|
60
52
|
def get_controller(request: Request) -> SignController:
|
|
61
53
|
"""Return the single writer that owns the sign."""
|
|
62
54
|
controller: SignController = request.app.state.controller
|
|
@@ -91,9 +83,9 @@ def require_api_key(
|
|
|
91
83
|
narrowed down by timing. The key itself is never logged or echoed, here or
|
|
92
84
|
anywhere else.
|
|
93
85
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
A 401 rather than anything a route decides. A caller without the key is not
|
|
87
|
+
a caller whose request failed; it is a caller the service will not talk to,
|
|
88
|
+
and it never reaches a route to be answered by one.
|
|
97
89
|
"""
|
|
98
90
|
expected: str = request.app.state.settings.api_key.get_secret_value()
|
|
99
91
|
|
|
@@ -114,7 +106,6 @@ def require_api_key(
|
|
|
114
106
|
)
|
|
115
107
|
|
|
116
108
|
|
|
117
|
-
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|
118
109
|
ControllerDep = Annotated[SignController, Depends(get_controller)]
|
|
119
110
|
RegistryDep = Annotated[MessageRegistry, Depends(get_registry)]
|
|
120
111
|
AlertsDep = Annotated[AlertService, Depends(get_alerts)]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""What each of the service's own exceptions means as a status code.
|
|
2
|
+
|
|
3
|
+
One table, and one reader of it. Routes let these propagate; an exception
|
|
4
|
+
handler registered from this table turns each into a status code with FastAPI's
|
|
5
|
+
usual ``detail`` body. An exception this table does not name reaches nobody's
|
|
6
|
+
handler and is a 500, which is the honest answer for a failure the service never
|
|
7
|
+
planned for: it cannot say whose fault it was.
|
|
8
|
+
|
|
9
|
+
Add an exception here and the handler for it is registered with it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from fastapi import status
|
|
15
|
+
|
|
16
|
+
from readerboard.protocol.frames import ProtocolError
|
|
17
|
+
from readerboard.protocol.markup import MarkupError
|
|
18
|
+
from readerboard.services import commands
|
|
19
|
+
from readerboard.services.alerts import AlertTooLong
|
|
20
|
+
from readerboard.services.registry import MessageTooLong, UnknownSlot
|
|
21
|
+
from readerboard.sign.layout import LayoutFull
|
|
22
|
+
from readerboard.transport.base import TransportError
|
|
23
|
+
|
|
24
|
+
STATUS_FOR_ERROR: tuple[tuple[type[Exception], int], ...] = (
|
|
25
|
+
(MarkupError, status.HTTP_400_BAD_REQUEST),
|
|
26
|
+
(ProtocolError, status.HTTP_400_BAD_REQUEST),
|
|
27
|
+
(MessageTooLong, status.HTTP_400_BAD_REQUEST),
|
|
28
|
+
(AlertTooLong, status.HTTP_400_BAD_REQUEST),
|
|
29
|
+
(commands.UnknownCommand, status.HTTP_400_BAD_REQUEST),
|
|
30
|
+
(commands.BadParameter, status.HTTP_400_BAD_REQUEST),
|
|
31
|
+
(UnknownSlot, status.HTTP_404_NOT_FOUND),
|
|
32
|
+
(LayoutFull, status.HTTP_409_CONFLICT),
|
|
33
|
+
(TransportError, status.HTTP_503_SERVICE_UNAVAILABLE),
|
|
34
|
+
)
|