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.
Files changed (57) hide show
  1. {readerboard-0.2.0/readerboard.egg-info → readerboard-0.3.0}/PKG-INFO +35 -58
  2. {readerboard-0.2.0 → readerboard-0.3.0}/README.md +32 -56
  3. {readerboard-0.2.0 → readerboard-0.3.0}/pyproject.toml +23 -20
  4. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/__main__.py +3 -3
  6. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/app.py +23 -31
  7. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/deps.py +5 -14
  8. readerboard-0.3.0/readerboard/api/errors.py +34 -0
  9. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/models.py +4 -64
  10. readerboard-0.2.0/readerboard/api/routes_v2.py → readerboard-0.3.0/readerboard/api/routes.py +11 -4
  11. {readerboard-0.2.0 → 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.2.0 → readerboard-0.3.0}/readerboard/protocol/markup.py +8 -4
  15. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/protocol/tokens.py +0 -5
  16. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/alerts.py +1 -2
  17. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/commands.py +1 -1
  18. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/registry.py +1 -9
  19. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/controller.py +3 -3
  20. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/layout.py +9 -5
  21. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/state.py +107 -5
  22. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/base.py +1 -1
  23. {readerboard-0.2.0 → readerboard-0.3.0/readerboard.egg-info}/PKG-INFO +35 -58
  24. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/SOURCES.txt +6 -2
  25. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_api.py +120 -203
  26. readerboard-0.3.0/tests/test_component_names.py +198 -0
  27. {readerboard-0.2.0 → 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.3.0/tests/test_state.py +336 -0
  30. readerboard-0.3.0/tests/test_tool_icons.py +73 -0
  31. readerboard-0.2.0/readerboard/api/routes_simple.py +0 -145
  32. readerboard-0.2.0/readerboard/protocol/constants.py +0 -465
  33. readerboard-0.2.0/tests/test_state.py +0 -171
  34. {readerboard-0.2.0 → readerboard-0.3.0}/LICENSE +0 -0
  35. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/api/__init__.py +0 -0
  36. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/logging_setup.py +0 -0
  37. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/protocol/__init__.py +0 -0
  38. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/protocol/frames.py +0 -0
  39. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/py.typed +0 -0
  40. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/__init__.py +0 -0
  41. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/services/clock.py +0 -0
  42. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/sign/__init__.py +0 -0
  43. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/__init__.py +0 -0
  44. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/fake.py +0 -0
  45. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard/transport/serial_link.py +0 -0
  46. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/dependency_links.txt +0 -0
  47. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/entry_points.txt +0 -0
  48. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/requires.txt +0 -0
  49. {readerboard-0.2.0 → readerboard-0.3.0}/readerboard.egg-info/top_level.txt +0 -0
  50. {readerboard-0.2.0 → readerboard-0.3.0}/setup.cfg +0 -0
  51. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_alerts.py +0 -0
  52. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_clock.py +0 -0
  53. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_controller.py +0 -0
  54. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_frames.py +0 -0
  55. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_markup.py +0 -0
  56. {readerboard-0.2.0 → readerboard-0.3.0}/tests/test_registry.py +0 -0
  57. {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.2.0
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
 
@@ -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/v2/messages/temperature \
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/v2/messages/doorbell \
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/v2/alerts \
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, 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.
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 /v2/enumerations/markup-tokens` lists
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 on `/v2`,
215
- and replaced with `?` on the simpler endpoints described below.
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 above. Its own tests
325
- are collected by the `pytest` run here and need no Qt installed; the application does,
326
- and it is pinned separately so that nothing the service installs ever pulls Qt in.
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. Both
329
- editors have it as a launch configuration, "API and sign simulator" in
330
- `.vscode/launch.json` and "API and simulator" in `.idea/runConfigurations/`, along with
331
- configurations for each half on its own.
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
- `readerboard/protocol/constants.py` came, with thanks, from
351
- [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), with some
352
- 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.
353
329
 
354
- The protocol itself is documented in the Alpha Sign Communications Protocol, form
355
- 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
 
@@ -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/v2/messages/temperature \
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/v2/messages/doorbell \
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/v2/alerts \
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, 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.
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 /v2/enumerations/markup-tokens` lists
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 on `/v2`,
173
- and replaced with `?` on the simpler endpoints described below.
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 above. Its own tests
283
- are collected by the `pytest` run here and need no Qt installed; the application does,
284
- and it is pinned separately so that nothing the service installs ever pulls Qt in.
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. Both
287
- editors have it as a launch configuration, "API and sign simulator" in
288
- `.vscode/launch.json` and "API and simulator" in `.idea/runConfigurations/`, along with
289
- configurations for each half on its own.
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
- `readerboard/protocol/constants.py` came, with thanks, from
309
- [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), with some
310
- 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.
311
286
 
312
- The protocol itself is documented in the Alpha Sign Communications Protocol, form
313
- 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.2.0"
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,10 +100,12 @@ packages = [
97
100
  readerboard = ["py.typed"]
98
101
 
99
102
  [tool.pytest.ini_options]
100
- # The simulator under tools/ is collected too. Its tests import only the pure
101
- # half of it, never PySide6, so they run in CI where Qt is not installed, and
102
- # the round trip they check is against the frame builders in this package.
103
- testpaths = ["tests", "tools/signsim/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"]
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 simulator's
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 simulator is not under the project root, so import sorting would put it
150
- # in the third party block and then complain that it is in the wrong one.
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 vendored constants module is a flat table of byte literals; annotating
170
- # 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.
171
174
  module = "readerboard.protocol.constants"
172
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.2.0"
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
@@ -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
- The `/Write` and `/Enumerations` paths are a smaller surface for clients that
60
- would rather not read status codes: every response there is a 200 with the
61
- outcome in the body, unless the request never reaches the route at all. A missing
62
- or wrong `X-API-Key` is a 401, a service with no API key configured is a 503, and
63
- a body that is not the shape the endpoint declares is a 422.
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="readerboard",
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(routes_v2.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
- app.add_exception_handler(MarkupError, handler(status.HTTP_400_BAD_REQUEST))
260
- app.add_exception_handler(ProtocolError, handler(status.HTTP_400_BAD_REQUEST))
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 the simple endpoints document the
34
- # difference, so the checking stays in `require_api_key` below and this declares
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
- This is the one place the simple endpoints are allowed to break their
95
- "always 200" rule. A caller without the key is not a caller whose request
96
- failed; it is a caller the service will not talk to.
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
+ )