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.
Files changed (58) hide show
  1. {readerboard-0.1.4/readerboard.egg-info → readerboard-0.3.0}/PKG-INFO +66 -50
  2. {readerboard-0.1.4 → readerboard-0.3.0}/README.md +63 -48
  3. {readerboard-0.1.4 → readerboard-0.3.0}/pyproject.toml +28 -14
  4. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/__main__.py +3 -3
  6. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/app.py +31 -31
  7. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/deps.py +32 -12
  8. readerboard-0.3.0/readerboard/api/errors.py +34 -0
  9. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/models.py +4 -64
  10. readerboard-0.1.4/readerboard/api/routes_v2.py → readerboard-0.3.0/readerboard/api/routes.py +11 -4
  11. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/config.py +4 -34
  12. readerboard-0.3.0/readerboard/names.py +24 -0
  13. readerboard-0.3.0/readerboard/protocol/constants.py +654 -0
  14. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/markup.py +8 -4
  15. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/tokens.py +0 -5
  16. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/alerts.py +1 -2
  17. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/commands.py +1 -1
  18. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/registry.py +4 -12
  19. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/controller.py +3 -3
  20. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/layout.py +9 -5
  21. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/state.py +107 -5
  22. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/base.py +1 -1
  23. {readerboard-0.1.4 → readerboard-0.3.0/readerboard.egg-info}/PKG-INFO +66 -50
  24. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/SOURCES.txt +6 -2
  25. readerboard-0.3.0/tests/test_api.py +448 -0
  26. readerboard-0.3.0/tests/test_component_names.py +198 -0
  27. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_constant_values.py +110 -4
  28. readerboard-0.3.0/tests/test_launch_configurations.py +67 -0
  29. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_markup.py +2 -2
  30. readerboard-0.3.0/tests/test_state.py +336 -0
  31. readerboard-0.3.0/tests/test_tool_icons.py +73 -0
  32. readerboard-0.1.4/readerboard/api/routes_simple.py +0 -143
  33. readerboard-0.1.4/readerboard/protocol/constants.py +0 -465
  34. readerboard-0.1.4/tests/test_api.py +0 -434
  35. readerboard-0.1.4/tests/test_state.py +0 -171
  36. {readerboard-0.1.4 → readerboard-0.3.0}/LICENSE +0 -0
  37. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/api/__init__.py +0 -0
  38. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/logging_setup.py +0 -0
  39. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/__init__.py +0 -0
  40. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/protocol/frames.py +0 -0
  41. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/py.typed +0 -0
  42. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/__init__.py +0 -0
  43. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/services/clock.py +0 -0
  44. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/sign/__init__.py +0 -0
  45. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/__init__.py +0 -0
  46. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/fake.py +0 -0
  47. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard/transport/serial_link.py +0 -0
  48. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/dependency_links.txt +0 -0
  49. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/entry_points.txt +0 -0
  50. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/requires.txt +0 -0
  51. {readerboard-0.1.4 → readerboard-0.3.0}/readerboard.egg-info/top_level.txt +0 -0
  52. {readerboard-0.1.4 → readerboard-0.3.0}/setup.cfg +0 -0
  53. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_alerts.py +0 -0
  54. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_clock.py +0 -0
  55. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_controller.py +0 -0
  56. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_frames.py +0 -0
  57. {readerboard-0.1.4 → readerboard-0.3.0}/tests/test_registry.py +0 -0
  58. {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.1.4
4
- Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync
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, not an HTTP 200 with the word
76
- ERROR in the body.
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` does not.
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/v2/messages/temperature \
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/v2/messages/doorbell \
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/v2/alerts \
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, including every markup token and display mode, is at `/docs`.
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 /v2/enumerations/markup-tokens` lists
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 on `/v2`,
200
- and replaced with `?` on the simpler endpoints described below.
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
- ## Licence
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
- MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
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
- One caveat, recorded because it is easy to miss.
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
- In practice it is a table of byte values dictated by the protocol rather than authored
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
- `readerboard/protocol/constants.py` came, with thanks, from
312
- [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), with some
313
- corrections noted in the file.
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
- The protocol itself is documented in the Alpha Sign Communications Protocol, form
316
- 9708-8061, published by Adaptive Micro Systems.
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, not an HTTP 200 with the word
34
- ERROR in the body.
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` does not.
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/v2/messages/temperature \
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/v2/messages/doorbell \
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/v2/alerts \
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, including every markup token and display mode, is at `/docs`.
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 /v2/enumerations/markup-tokens` lists
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 on `/v2`,
158
- and replaced with `?` on the simpler endpoints described below.
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
- ## Licence
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
- MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
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
- One caveat, recorded because it is easy to miss.
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
- In practice it is a table of byte values dictated by the protocol rather than authored
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
- `readerboard/protocol/constants.py` came, with thanks, from
270
- [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), with some
271
- corrections noted in the file.
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
- The protocol itself is documented in the Alpha Sign Communications Protocol, form
274
- 9708-8061, published by Adaptive Micro Systems.
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.1.4"
8
- description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync"
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. `python -m readerboard` runs the same thing out of a
73
- # checkout, which is what the systemd unit and the editor launch config use.
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: the flat layout puts `tests`
84
- # beside the package and it has an __init__.py, so discovery would ship it.
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
- testpaths = ["tests"]
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 vendored constants module is a flat table of byte literals; annotating
159
- # every one of them would add nothing a reader does not already see.
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
@@ -7,4 +7,4 @@ and against the release tag, before anything is published. See
7
7
 
8
8
  __all__ = ["__version__"]
9
9
 
10
- __version__ = "0.1.4"
10
+ __version__ = "0.3.0"
@@ -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="readerboard",
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="readerboard %s" % __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, status
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 routes_simple, routes_v2
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.protocol.frames import ProtocolError
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, LayoutFull
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` does not.
56
-
57
- The `/Write` and `/Enumerations` paths are a smaller surface for clients that
58
- would rather not read status codes: every response there is a 200 with the
59
- outcome in the body.
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="readerboard",
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(routes_v2.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
- app.add_exception_handler(MarkupError, handler(status.HTTP_400_BAD_REQUEST))
252
- app.add_exception_handler(ProtocolError, handler(status.HTTP_400_BAD_REQUEST))
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()