readerboard 0.5.0__tar.gz → 0.6.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.5.0/readerboard.egg-info → readerboard-0.6.0}/PKG-INFO +62 -14
- {readerboard-0.5.0 → readerboard-0.6.0}/README.md +61 -13
- {readerboard-0.5.0 → readerboard-0.6.0}/pyproject.toml +1 -1
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/__init__.py +1 -1
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/app.py +10 -13
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/deps.py +4 -4
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/models.py +50 -3
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/routes.py +81 -25
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/config.py +5 -4
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/constants.py +8 -4
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/frames.py +4 -5
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/replies.py +5 -4
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/tokens.py +6 -5
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/alerts.py +4 -21
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/registry.py +253 -88
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/controller.py +11 -14
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/layout.py +1 -1
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/state.py +14 -1
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/serial_link.py +21 -4
- {readerboard-0.5.0 → readerboard-0.6.0/readerboard.egg-info}/PKG-INFO +62 -14
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_api.py +153 -52
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_component_names.py +7 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_constant_values.py +9 -11
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_controller.py +14 -15
- readerboard-0.6.0/tests/test_registry.py +863 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_transport.py +70 -1
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_variables.py +10 -13
- readerboard-0.5.0/tests/test_registry.py +0 -479
- {readerboard-0.5.0 → readerboard-0.6.0}/LICENSE +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/__main__.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/errors.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/logging_setup.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/names.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/markup.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/py.typed +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/clock.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/commands.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/base.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/fake.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/SOURCES.txt +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/requires.txt +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/setup.cfg +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_alerts.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_clock.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_config.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_frames.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_launch_configurations.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_logging_setup.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_markup.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_open_docs.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_replies.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_run_against_a_sign.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_run_with_simulator.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_state.py +0 -0
- {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_tool_icons.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readerboard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
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
|
|
@@ -55,10 +55,12 @@ An HTTP service that drives a BetaBrite Classic sign, either through a serial ca
|
|
|
55
55
|
through an Ethernet to RS-232 adapter.
|
|
56
56
|
|
|
57
57
|
Several sources can share the sign at once. Each registers a named **slot**, and the sign
|
|
58
|
-
rotates through the
|
|
59
|
-
|
|
60
|
-
the
|
|
61
|
-
|
|
58
|
+
rotates through the slots that are showing by itself. A slot can be hidden without being
|
|
59
|
+
given up, so a message can be taken off the display and put back without being sent again,
|
|
60
|
+
unless it was the last one showing. A **variable** is a value that messages call by name,
|
|
61
|
+
such as a temperature, and changing it does not blank the sign or restart the message
|
|
62
|
+
showing it. An **alert** takes the whole display over until it is released, after which
|
|
63
|
+
the rotation resumes.
|
|
62
64
|
|
|
63
65
|
## What it does
|
|
64
66
|
|
|
@@ -82,8 +84,10 @@ after which the rotation resumes.
|
|
|
82
84
|
flicker.
|
|
83
85
|
- **It survives restarts and outages.** The registered messages are persisted and
|
|
84
86
|
pushed to the sign again whenever the link returns, so a restart or a power cut leaves
|
|
85
|
-
the rotation intact. A write that arrives while the sign is
|
|
86
|
-
a 503 rather than silently held, so the caller learns it did
|
|
87
|
+
the rotation intact. A message or a variable write that arrives while the sign is
|
|
88
|
+
unreachable is refused with a 503 rather than silently held, so the caller learns it did
|
|
89
|
+
not land. Deleting or hiding a message, or deleting a variable, is accepted and carried
|
|
90
|
+
out when the link returns.
|
|
87
91
|
- **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
|
|
88
92
|
render is a 400, each with the reason in the body. Nothing here reports a failure
|
|
89
93
|
under a 200.
|
|
@@ -263,7 +267,7 @@ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, th
|
|
|
263
267
|
Register a message:
|
|
264
268
|
|
|
265
269
|
```
|
|
266
|
-
curl -X PUT http://localhost:5001/
|
|
270
|
+
curl -X PUT http://localhost:5001/slots/temperature \
|
|
267
271
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
268
272
|
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
|
|
269
273
|
```
|
|
@@ -271,11 +275,55 @@ curl -X PUT http://localhost:5001/messages/temperature \
|
|
|
271
275
|
Register a second one and the sign rotates between them:
|
|
272
276
|
|
|
273
277
|
```
|
|
274
|
-
curl -X PUT http://localhost:5001/
|
|
278
|
+
curl -X PUT http://localhost:5001/slots/doorbell \
|
|
275
279
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
276
280
|
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
|
|
277
281
|
```
|
|
278
282
|
|
|
283
|
+
Take a message off the display without giving up its slot, and put it back later:
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
curl -X PUT http://localhost:5001/slots/doorbell/active \
|
|
287
|
+
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
288
|
+
-d '{"active": false}'
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A hidden message keeps its slot, its place in the order and its text, so showing it again
|
|
292
|
+
takes `{"active": true}` and no copy of what it said. Hiding or showing one is a single run
|
|
293
|
+
sequence write. That does disturb the display briefly, but far less than rewriting a
|
|
294
|
+
message does: enough less that it is easy to miss unless you are watching for it on a
|
|
295
|
+
static screen. The exception is the last message showing: its file is emptied as it goes,
|
|
296
|
+
because a sign whose run sequence names nothing freezes on what it was drawing, so
|
|
297
|
+
putting that one back costs the text as well as the sequence.
|
|
298
|
+
|
|
299
|
+
`active` is a field on the message endpoint too, where leaving it out is the point: a
|
|
300
|
+
source re-sending the same content every few minutes says nothing about it and so cannot
|
|
301
|
+
switch back on something that was deliberately hidden. Sending it moves the message.
|
|
302
|
+
|
|
303
|
+
A `ttl_seconds` can hide a message instead of deleting it, which suits anything that comes
|
|
304
|
+
back later, such as a bin day or a school notice:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
curl -X PUT http://localhost:5001/slots/bins \
|
|
308
|
+
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
309
|
+
-d '{"message": "<green>BINS OUT TONIGHT", "ttl_seconds": 43200,
|
|
310
|
+
"delete_on_expiry": false}'
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Put those together and a recurring notification is one call per event, sent in the same
|
|
314
|
+
shape every time. This shows the alarm's state for a minute whenever it changes, then takes
|
|
315
|
+
it off the rotation until the next one:
|
|
316
|
+
|
|
317
|
+
```
|
|
318
|
+
curl -X PUT http://localhost:5001/slots/alarm \
|
|
319
|
+
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
320
|
+
-d '{"message": "<red>ALARM NOW <var:arm_state>", "ttl_seconds": 60,
|
|
321
|
+
"delete_on_expiry": false, "active": true}'
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The deadline clears itself as it hides the message, so each event gets a fresh minute
|
|
325
|
+
rather than the message vanishing again on a deadline the last one left behind.
|
|
326
|
+
|
|
279
327
|
Show a live value. Create the variable first, then a message that calls it:
|
|
280
328
|
|
|
281
329
|
```
|
|
@@ -283,7 +331,7 @@ curl -X PUT http://localhost:5001/variables/temp \
|
|
|
283
331
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
284
332
|
-d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
|
|
285
333
|
|
|
286
|
-
curl -X PUT http://localhost:5001/
|
|
334
|
+
curl -X PUT http://localhost:5001/slots/weather \
|
|
287
335
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
288
336
|
-d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
|
|
289
337
|
```
|
|
@@ -413,10 +461,10 @@ from the service's own record, so the display still comes back to what it was.
|
|
|
413
461
|
curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
|
|
414
462
|
```
|
|
415
463
|
|
|
416
|
-
Reach for it only when a soft reset was not enough. The sign is blank for
|
|
417
|
-
|
|
418
|
-
that without resetting anything. The client
|
|
419
|
-
confirmation for the same reason.
|
|
464
|
+
Reach for it only when a soft reset was not enough. The sign is blank for twelve seconds
|
|
465
|
+
or more while it resets, longer with a lot of messages to put back. Neither is a way to
|
|
466
|
+
clear messages: `DELETE /slots` does that without resetting anything. The client
|
|
467
|
+
fronts the reboot with a warning-coloured confirmation for the same reason.
|
|
420
468
|
|
|
421
469
|
## Configuration
|
|
422
470
|
|
|
@@ -11,10 +11,12 @@ An HTTP service that drives a BetaBrite Classic sign, either through a serial ca
|
|
|
11
11
|
through an Ethernet to RS-232 adapter.
|
|
12
12
|
|
|
13
13
|
Several sources can share the sign at once. Each registers a named **slot**, and the sign
|
|
14
|
-
rotates through the
|
|
15
|
-
|
|
16
|
-
the
|
|
17
|
-
|
|
14
|
+
rotates through the slots that are showing by itself. A slot can be hidden without being
|
|
15
|
+
given up, so a message can be taken off the display and put back without being sent again,
|
|
16
|
+
unless it was the last one showing. A **variable** is a value that messages call by name,
|
|
17
|
+
such as a temperature, and changing it does not blank the sign or restart the message
|
|
18
|
+
showing it. An **alert** takes the whole display over until it is released, after which
|
|
19
|
+
the rotation resumes.
|
|
18
20
|
|
|
19
21
|
## What it does
|
|
20
22
|
|
|
@@ -38,8 +40,10 @@ after which the rotation resumes.
|
|
|
38
40
|
flicker.
|
|
39
41
|
- **It survives restarts and outages.** The registered messages are persisted and
|
|
40
42
|
pushed to the sign again whenever the link returns, so a restart or a power cut leaves
|
|
41
|
-
the rotation intact. A write that arrives while the sign is
|
|
42
|
-
a 503 rather than silently held, so the caller learns it did
|
|
43
|
+
the rotation intact. A message or a variable write that arrives while the sign is
|
|
44
|
+
unreachable is refused with a 503 rather than silently held, so the caller learns it did
|
|
45
|
+
not land. Deleting or hiding a message, or deleting a variable, is accepted and carried
|
|
46
|
+
out when the link returns.
|
|
43
47
|
- **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
|
|
44
48
|
render is a 400, each with the reason in the body. Nothing here reports a failure
|
|
45
49
|
under a 200.
|
|
@@ -219,7 +223,7 @@ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, th
|
|
|
219
223
|
Register a message:
|
|
220
224
|
|
|
221
225
|
```
|
|
222
|
-
curl -X PUT http://localhost:5001/
|
|
226
|
+
curl -X PUT http://localhost:5001/slots/temperature \
|
|
223
227
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
224
228
|
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
|
|
225
229
|
```
|
|
@@ -227,11 +231,55 @@ curl -X PUT http://localhost:5001/messages/temperature \
|
|
|
227
231
|
Register a second one and the sign rotates between them:
|
|
228
232
|
|
|
229
233
|
```
|
|
230
|
-
curl -X PUT http://localhost:5001/
|
|
234
|
+
curl -X PUT http://localhost:5001/slots/doorbell \
|
|
231
235
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
232
236
|
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
|
|
233
237
|
```
|
|
234
238
|
|
|
239
|
+
Take a message off the display without giving up its slot, and put it back later:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
curl -X PUT http://localhost:5001/slots/doorbell/active \
|
|
243
|
+
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
244
|
+
-d '{"active": false}'
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A hidden message keeps its slot, its place in the order and its text, so showing it again
|
|
248
|
+
takes `{"active": true}` and no copy of what it said. Hiding or showing one is a single run
|
|
249
|
+
sequence write. That does disturb the display briefly, but far less than rewriting a
|
|
250
|
+
message does: enough less that it is easy to miss unless you are watching for it on a
|
|
251
|
+
static screen. The exception is the last message showing: its file is emptied as it goes,
|
|
252
|
+
because a sign whose run sequence names nothing freezes on what it was drawing, so
|
|
253
|
+
putting that one back costs the text as well as the sequence.
|
|
254
|
+
|
|
255
|
+
`active` is a field on the message endpoint too, where leaving it out is the point: a
|
|
256
|
+
source re-sending the same content every few minutes says nothing about it and so cannot
|
|
257
|
+
switch back on something that was deliberately hidden. Sending it moves the message.
|
|
258
|
+
|
|
259
|
+
A `ttl_seconds` can hide a message instead of deleting it, which suits anything that comes
|
|
260
|
+
back later, such as a bin day or a school notice:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
curl -X PUT http://localhost:5001/slots/bins \
|
|
264
|
+
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
265
|
+
-d '{"message": "<green>BINS OUT TONIGHT", "ttl_seconds": 43200,
|
|
266
|
+
"delete_on_expiry": false}'
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Put those together and a recurring notification is one call per event, sent in the same
|
|
270
|
+
shape every time. This shows the alarm's state for a minute whenever it changes, then takes
|
|
271
|
+
it off the rotation until the next one:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
curl -X PUT http://localhost:5001/slots/alarm \
|
|
275
|
+
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
276
|
+
-d '{"message": "<red>ALARM NOW <var:arm_state>", "ttl_seconds": 60,
|
|
277
|
+
"delete_on_expiry": false, "active": true}'
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The deadline clears itself as it hides the message, so each event gets a fresh minute
|
|
281
|
+
rather than the message vanishing again on a deadline the last one left behind.
|
|
282
|
+
|
|
235
283
|
Show a live value. Create the variable first, then a message that calls it:
|
|
236
284
|
|
|
237
285
|
```
|
|
@@ -239,7 +287,7 @@ curl -X PUT http://localhost:5001/variables/temp \
|
|
|
239
287
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
240
288
|
-d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
|
|
241
289
|
|
|
242
|
-
curl -X PUT http://localhost:5001/
|
|
290
|
+
curl -X PUT http://localhost:5001/slots/weather \
|
|
243
291
|
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
|
|
244
292
|
-d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
|
|
245
293
|
```
|
|
@@ -369,10 +417,10 @@ from the service's own record, so the display still comes back to what it was.
|
|
|
369
417
|
curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
|
|
370
418
|
```
|
|
371
419
|
|
|
372
|
-
Reach for it only when a soft reset was not enough. The sign is blank for
|
|
373
|
-
|
|
374
|
-
that without resetting anything. The client
|
|
375
|
-
confirmation for the same reason.
|
|
420
|
+
Reach for it only when a soft reset was not enough. The sign is blank for twelve seconds
|
|
421
|
+
or more while it resets, longer with a lot of messages to put back. Neither is a way to
|
|
422
|
+
clear messages: `DELETE /slots` does that without resetting anything. The client
|
|
423
|
+
fronts the reboot with a warning-coloured confirmation for the same reason.
|
|
376
424
|
|
|
377
425
|
## Configuration
|
|
378
426
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "readerboard"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
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"
|
|
@@ -28,7 +28,7 @@ from readerboard.api.models import HealthResponse, LinkHealth
|
|
|
28
28
|
from readerboard.config import Settings
|
|
29
29
|
from readerboard.services.alerts import AlertService
|
|
30
30
|
from readerboard.services.clock import ClockService
|
|
31
|
-
from readerboard.services.registry import
|
|
31
|
+
from readerboard.services.registry import SlotRegistry
|
|
32
32
|
from readerboard.sign.controller import SignController
|
|
33
33
|
from readerboard.sign.layout import Layout
|
|
34
34
|
from readerboard.sign.state import StateStore
|
|
@@ -42,8 +42,10 @@ Drives a BetaBrite Classic sign over the Alpha protocol, either through a serial
|
|
|
42
42
|
cable or through an Ethernet to RS-232 adapter.
|
|
43
43
|
|
|
44
44
|
Several sources can share the sign at once. Each registers a named **slot**, and
|
|
45
|
-
the sign rotates through the
|
|
46
|
-
|
|
45
|
+
the sign rotates through the slots that are showing by itself. A slot can be
|
|
46
|
+
hidden without being given up, so a message can be taken off the display and put
|
|
47
|
+
back without being sent again. An **alert** takes the whole display over until it
|
|
48
|
+
is released, then the rotation resumes.
|
|
47
49
|
|
|
48
50
|
A **variable** is a value a slot's message or an alert calls with `<var:name>`.
|
|
49
51
|
Changing it rewrites only the variable, so the sign shows the new value without
|
|
@@ -82,10 +84,10 @@ def build_transport(settings: Settings) -> Transport:
|
|
|
82
84
|
async def _refresh_loop(app: FastAPI, interval: float) -> None:
|
|
83
85
|
"""Push everything to the sign again, periodically.
|
|
84
86
|
|
|
85
|
-
See ``
|
|
87
|
+
See ``SlotRegistry.refresh`` for why blind re-pushing is the only thing
|
|
86
88
|
that repairs a sign power cycled behind a still-connected adapter.
|
|
87
89
|
"""
|
|
88
|
-
registry:
|
|
90
|
+
registry: SlotRegistry = app.state.registry
|
|
89
91
|
alerts: AlertService = app.state.alerts
|
|
90
92
|
|
|
91
93
|
while True:
|
|
@@ -105,7 +107,7 @@ async def _refresh_loop(app: FastAPI, interval: float) -> None:
|
|
|
105
107
|
|
|
106
108
|
async def _sweep_loop(app: FastAPI, interval: float) -> None:
|
|
107
109
|
"""Expire slots and alerts, and turn variables stale, once their deadlines pass."""
|
|
108
|
-
registry:
|
|
110
|
+
registry: SlotRegistry = app.state.registry
|
|
109
111
|
alerts: AlertService = app.state.alerts
|
|
110
112
|
|
|
111
113
|
while True:
|
|
@@ -149,13 +151,8 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
149
151
|
settings.variable_capacity,
|
|
150
152
|
)
|
|
151
153
|
alerts = AlertService(controller, store, state)
|
|
152
|
-
registry =
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
# An alert holding the sign makes the registry hold back run sequence
|
|
156
|
-
# writes; releasing it is what lets them through.
|
|
157
|
-
alerts.set_release_hook(registry.flush_deferred)
|
|
158
|
-
# And an alert calling a variable is rendered by the registry, under its
|
|
154
|
+
registry = SlotRegistry(controller, layout, store, state)
|
|
155
|
+
# An alert calling a variable is rendered by the registry, under its
|
|
159
156
|
# lock, so the variable cannot be deleted while the alert calls it.
|
|
160
157
|
alerts.set_rendering(registry.rendering)
|
|
161
158
|
clock = ClockService(
|
|
@@ -16,7 +16,7 @@ from fastapi.security import APIKeyHeader
|
|
|
16
16
|
|
|
17
17
|
from readerboard.services.alerts import AlertService
|
|
18
18
|
from readerboard.services.clock import ClockService
|
|
19
|
-
from readerboard.services.registry import
|
|
19
|
+
from readerboard.services.registry import SlotRegistry
|
|
20
20
|
from readerboard.sign.controller import SignController
|
|
21
21
|
|
|
22
22
|
API_KEY_HEADER = "X-API-Key"
|
|
@@ -55,9 +55,9 @@ def get_controller(request: Request) -> SignController:
|
|
|
55
55
|
return controller
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
def get_registry(request: Request) ->
|
|
58
|
+
def get_registry(request: Request) -> SlotRegistry:
|
|
59
59
|
"""Return the registered messages."""
|
|
60
|
-
registry:
|
|
60
|
+
registry: SlotRegistry = request.app.state.registry
|
|
61
61
|
return registry
|
|
62
62
|
|
|
63
63
|
|
|
@@ -107,7 +107,7 @@ def require_api_key(
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
ControllerDep = Annotated[SignController, Depends(get_controller)]
|
|
110
|
-
RegistryDep = Annotated[
|
|
110
|
+
RegistryDep = Annotated[SlotRegistry, Depends(get_registry)]
|
|
111
111
|
AlertsDep = Annotated[AlertService, Depends(get_alerts)]
|
|
112
112
|
ClockDep = Annotated[ClockService, Depends(get_clock)]
|
|
113
113
|
RequireApiKey = Depends(require_api_key)
|
|
@@ -52,7 +52,7 @@ def _normalise_mode(value: str) -> str:
|
|
|
52
52
|
return upper
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
class
|
|
55
|
+
class SlotRequest(BaseModel):
|
|
56
56
|
"""A message registered into a slot."""
|
|
57
57
|
|
|
58
58
|
model_config = ConfigDict(extra="forbid")
|
|
@@ -64,7 +64,9 @@ class MessageRequest(BaseModel):
|
|
|
64
64
|
"the message, including markup tokens such as <red> and <degree>, and "
|
|
65
65
|
"<var:name> to call a variable, which has to exist first. It cannot be "
|
|
66
66
|
"empty: an empty message holds a slot open around nothing, and the sign "
|
|
67
|
-
"
|
|
67
|
+
"gives a file with no text in it no turn of its own but does hold the "
|
|
68
|
+
"message before it several seconds longer, so the slot would be spent and "
|
|
69
|
+
"the rotation would drag. Use DELETE to give the slot back"
|
|
68
70
|
),
|
|
69
71
|
)
|
|
70
72
|
display_mode: str = Field(default="HOLD", description="how the sign presents the message")
|
|
@@ -75,7 +77,30 @@ class MessageRequest(BaseModel):
|
|
|
75
77
|
ttl_seconds: float | None = Field(
|
|
76
78
|
default=None,
|
|
77
79
|
gt=0,
|
|
78
|
-
description=
|
|
80
|
+
description=(
|
|
81
|
+
"act on the message this many seconds from now, deleting or hiding it "
|
|
82
|
+
"according to delete_on_expiry. Omit it for no deadline, which leaves the "
|
|
83
|
+
"message in place until something replaces or removes it"
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
delete_on_expiry: bool = Field(
|
|
87
|
+
default=True,
|
|
88
|
+
description=(
|
|
89
|
+
"what happens when ttl_seconds passes. True gives the slot back. False keeps "
|
|
90
|
+
"the message registered and takes it off the display, clearing the deadline "
|
|
91
|
+
"with it, so it can be shown again without being sent afresh. Nothing "
|
|
92
|
+
"without a ttl_seconds"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
active: bool | None = Field(
|
|
96
|
+
default=None,
|
|
97
|
+
description=(
|
|
98
|
+
"whether the sign should be playing it. Omit it and the message keeps "
|
|
99
|
+
"whatever it already was, which is what a source re-sending the same "
|
|
100
|
+
"content on a timer wants: repeating itself cannot switch back on "
|
|
101
|
+
"something that was deliberately hidden. true shows it, false hides it, "
|
|
102
|
+
"and a new message nobody says anything about is shown"
|
|
103
|
+
),
|
|
79
104
|
)
|
|
80
105
|
source: str | None = Field(
|
|
81
106
|
default=None,
|
|
@@ -86,6 +111,20 @@ class MessageRequest(BaseModel):
|
|
|
86
111
|
_check_mode = field_validator("display_mode")(_normalise_mode)
|
|
87
112
|
|
|
88
113
|
|
|
114
|
+
class SlotActiveRequest(BaseModel):
|
|
115
|
+
"""Whether the sign should be playing a slot."""
|
|
116
|
+
|
|
117
|
+
model_config = ConfigDict(extra="forbid")
|
|
118
|
+
|
|
119
|
+
active: bool = Field(
|
|
120
|
+
description=(
|
|
121
|
+
"true to put the message into the rotation, false to take it off the display "
|
|
122
|
+
"while it stays registered, keeping its slot, its file, its text and its place "
|
|
123
|
+
"in the order"
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
89
128
|
class SlotResponse(BaseModel):
|
|
90
129
|
"""A registered slot."""
|
|
91
130
|
|
|
@@ -94,6 +133,12 @@ class SlotResponse(BaseModel):
|
|
|
94
133
|
message: str
|
|
95
134
|
display_mode: str
|
|
96
135
|
order: int
|
|
136
|
+
active: bool = Field(
|
|
137
|
+
description="whether the sign is playing it; a false one stays registered and hidden"
|
|
138
|
+
)
|
|
139
|
+
delete_on_expiry: bool = Field(
|
|
140
|
+
description="whether ttl_seconds gives the slot back, or only hides the message"
|
|
141
|
+
)
|
|
97
142
|
source: str | None
|
|
98
143
|
expires_at: datetime | None
|
|
99
144
|
updated_at: datetime
|
|
@@ -107,6 +152,8 @@ class SlotResponse(BaseModel):
|
|
|
107
152
|
message=slot.message,
|
|
108
153
|
display_mode=slot.mode,
|
|
109
154
|
order=slot.order,
|
|
155
|
+
active=slot.active,
|
|
156
|
+
delete_on_expiry=slot.delete_on_expiry,
|
|
110
157
|
source=slot.source,
|
|
111
158
|
expires_at=slot.expires_at,
|
|
112
159
|
updated_at=slot.updated_at,
|
|
@@ -24,9 +24,10 @@ from readerboard.api.models import (
|
|
|
24
24
|
AlertResponse,
|
|
25
25
|
ClockResponse,
|
|
26
26
|
ControlCommandRequest,
|
|
27
|
-
MessageRequest,
|
|
28
27
|
SignInformationResponse,
|
|
28
|
+
SlotActiveRequest,
|
|
29
29
|
SlotKey,
|
|
30
|
+
SlotRequest,
|
|
30
31
|
SlotResponse,
|
|
31
32
|
TokenInfo,
|
|
32
33
|
VariableName,
|
|
@@ -43,12 +44,12 @@ from readerboard.protocol.tokens import (
|
|
|
43
44
|
Token,
|
|
44
45
|
)
|
|
45
46
|
from readerboard.services import commands
|
|
46
|
-
from readerboard.services.registry import
|
|
47
|
+
from readerboard.services.registry import SlotRegistry
|
|
47
48
|
from readerboard.sign.state import VariableState
|
|
48
49
|
|
|
49
50
|
router = APIRouter()
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
slots = APIRouter(prefix="/slots", tags=["Slots"])
|
|
52
53
|
variables = APIRouter(prefix="/variables", tags=["Variables"])
|
|
53
54
|
alerts_routes = APIRouter(prefix="/alerts", tags=["Alerts"])
|
|
54
55
|
sign_routes = APIRouter(prefix="/sign", tags=["Sign"])
|
|
@@ -56,30 +57,40 @@ enumerations = APIRouter(prefix="/enumerations", tags=["Enumerations"])
|
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
# ===========================================================================
|
|
59
|
-
#
|
|
60
|
+
# Slots
|
|
60
61
|
# ===========================================================================
|
|
61
62
|
|
|
62
63
|
|
|
63
|
-
@
|
|
64
|
-
async def
|
|
65
|
-
"""Return every registered slot, in
|
|
64
|
+
@slots.get("", summary="List the slots sharing the sign")
|
|
65
|
+
async def list_slots(registry: RegistryDep) -> list[SlotResponse]:
|
|
66
|
+
"""Return every registered slot, in rotation order, hidden ones included.
|
|
67
|
+
|
|
68
|
+
A hidden slot is listed like any other, with `active` false. It is still
|
|
69
|
+
registered and still holding its file; it is simply not one the sign plays.
|
|
70
|
+
"""
|
|
66
71
|
return [SlotResponse.of(slot) for slot in registry.list_slots()]
|
|
67
72
|
|
|
68
73
|
|
|
69
|
-
@
|
|
70
|
-
async def
|
|
74
|
+
@slots.get("/{key}", summary="Read one slot")
|
|
75
|
+
async def get_slot(key: SlotKey, registry: RegistryDep) -> SlotResponse:
|
|
71
76
|
"""Return one slot by name."""
|
|
72
77
|
return SlotResponse.of(registry.get(key))
|
|
73
78
|
|
|
74
79
|
|
|
75
|
-
@
|
|
76
|
-
async def
|
|
77
|
-
key: SlotKey, body:
|
|
80
|
+
@slots.put("/{key}", summary="Register or replace a slot", dependencies=[RequireApiKey])
|
|
81
|
+
async def put_slot(
|
|
82
|
+
key: SlotKey, body: SlotRequest, registry: RegistryDep
|
|
78
83
|
) -> SlotResponse:
|
|
79
84
|
"""Put a message in a slot, replacing whatever was there.
|
|
80
85
|
|
|
81
|
-
The sign rotates through
|
|
82
|
-
second
|
|
86
|
+
The sign rotates through the slots that are showing on its own, so
|
|
87
|
+
registering a second slot does not displace the first.
|
|
88
|
+
|
|
89
|
+
Leave `active` out and a hidden slot stays hidden, which is what a source
|
|
90
|
+
re-sending the same content every few minutes wants: repeating itself cannot
|
|
91
|
+
switch back on something that was deliberately hidden. Send it and the slot
|
|
92
|
+
moves, so one call can write the text, set a deadline and put it up.
|
|
93
|
+
`PUT /slots/{key}/active` does the same without resending the message.
|
|
83
94
|
"""
|
|
84
95
|
slot = await registry.upsert(
|
|
85
96
|
key,
|
|
@@ -87,30 +98,63 @@ async def put_message(
|
|
|
87
98
|
mode=body.display_mode,
|
|
88
99
|
order=body.order,
|
|
89
100
|
ttl_seconds=body.ttl_seconds,
|
|
101
|
+
delete_on_expiry=body.delete_on_expiry,
|
|
102
|
+
active=body.active,
|
|
90
103
|
source=body.source,
|
|
91
104
|
)
|
|
92
105
|
return SlotResponse.of(slot)
|
|
93
106
|
|
|
94
107
|
|
|
95
|
-
@
|
|
108
|
+
@slots.put(
|
|
109
|
+
"/{key}/active",
|
|
110
|
+
summary="Show or hide a slot without giving it up",
|
|
111
|
+
dependencies=[RequireApiKey],
|
|
112
|
+
)
|
|
113
|
+
async def set_slot_active(
|
|
114
|
+
key: SlotKey, body: SlotActiveRequest, registry: RegistryDep
|
|
115
|
+
) -> SlotResponse:
|
|
116
|
+
"""Take a slot off the display, or put it back, keeping it registered either way.
|
|
117
|
+
|
|
118
|
+
A hidden slot stays registered. It keeps its file, its text and its place in
|
|
119
|
+
the order, and is simply left out of the rotation the sign cycles, so showing
|
|
120
|
+
it again needs no copy of what it said. `PUT /slots/{key}`
|
|
121
|
+
can move it too, by sending `active`; this endpoint is for when the caller
|
|
122
|
+
does not have the message text to resend, and a caller who omits `active`
|
|
123
|
+
there cannot move it by accident.
|
|
124
|
+
|
|
125
|
+
Hiding or showing one is a single run sequence write. That does disturb the
|
|
126
|
+
display, but far less than rewriting a message does: briefly enough to be
|
|
127
|
+
missed unless you are watching a static screen for it. The hidden slot's own
|
|
128
|
+
file keeps its text, so showing it again sends nothing but the sequence.
|
|
129
|
+
The exception is the last slot showing: that one's file is emptied as it
|
|
130
|
+
goes, because a sign whose sequence names nothing freezes on what it was
|
|
131
|
+
drawing.
|
|
132
|
+
|
|
133
|
+
404 when no slot by that name is registered.
|
|
134
|
+
"""
|
|
135
|
+
slot = await registry.set_active(key, body.active)
|
|
136
|
+
return SlotResponse.of(slot)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@slots.delete(
|
|
96
140
|
"/{key}",
|
|
97
|
-
summary="
|
|
141
|
+
summary="Give up one slot",
|
|
98
142
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
99
143
|
dependencies=[RequireApiKey],
|
|
100
144
|
)
|
|
101
|
-
async def
|
|
145
|
+
async def delete_slot(key: SlotKey, registry: RegistryDep) -> Response:
|
|
102
146
|
"""Remove one slot and free the sign file it held."""
|
|
103
147
|
await registry.remove(key)
|
|
104
148
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
105
149
|
|
|
106
150
|
|
|
107
|
-
@
|
|
151
|
+
@slots.delete(
|
|
108
152
|
"",
|
|
109
|
-
summary="
|
|
153
|
+
summary="Give up every slot",
|
|
110
154
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
111
155
|
dependencies=[RequireApiKey],
|
|
112
156
|
)
|
|
113
|
-
async def
|
|
157
|
+
async def clear_slots(registry: RegistryDep) -> Response:
|
|
114
158
|
"""Remove every slot, leaving the sign showing nothing."""
|
|
115
159
|
await registry.clear()
|
|
116
160
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -183,7 +227,7 @@ async def delete_variable(name: VariableName, registry: RegistryDep) -> Response
|
|
|
183
227
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
184
228
|
|
|
185
229
|
|
|
186
|
-
def _variable_response(registry:
|
|
230
|
+
def _variable_response(registry: SlotRegistry, variable: VariableState) -> VariableResponse:
|
|
187
231
|
"""Describe a variable along with everything that calls it."""
|
|
188
232
|
return VariableResponse.of(
|
|
189
233
|
variable,
|
|
@@ -273,6 +317,17 @@ async def sign_information(controller: ControllerDep) -> SignInformationResponse
|
|
|
273
317
|
useful as a source of troubleshooting information", which is a fair summary:
|
|
274
318
|
nothing here changes anything.
|
|
275
319
|
|
|
320
|
+
Changing nothing is not the same as costing nothing, and what a read costs
|
|
321
|
+
the display was measured on 2026-09-12. Six reads were put to the sign, the
|
|
322
|
+
four special functions along with a TEXT file read and a STRING file read,
|
|
323
|
+
and every one of them left a message that was holding still completely
|
|
324
|
+
undisturbed and cost a message that was scrolling about half a second of
|
|
325
|
+
stall and blank. The `F"` this endpoint sends was not one of the six: it is
|
|
326
|
+
the same kind of command as the four that were measured and nothing suggests
|
|
327
|
+
it differs, but that is an expectation rather than a measurement. On it, this
|
|
328
|
+
is free to call as often as you like on a sign whose slots all hold, and a
|
|
329
|
+
brief interruption to whichever slot happens to be scrolling when it lands.
|
|
330
|
+
|
|
276
331
|
This is the only read in the service, so it is also the only place a silent
|
|
277
332
|
sign is distinguishable from an unplugged one. A sign that does not answer
|
|
278
333
|
within a few seconds is a 503, the same as a sign that cannot be written to,
|
|
@@ -333,9 +388,10 @@ async def reboot_sign(registry: RegistryDep, alerts: AlertsDep) -> Response:
|
|
|
333
388
|
Use it for a sign that has stopped responding to writes or is showing
|
|
334
389
|
garbage, the wedged-decoder state a unit mounted out of reach can fall into
|
|
335
390
|
from a stray bit and that cannot be fixed by power cycling it by hand. Do
|
|
336
|
-
not use it to clear the sign: `DELETE /
|
|
337
|
-
|
|
338
|
-
blank display for
|
|
391
|
+
not use it to clear the sign: `DELETE /slots` gives up every slot without
|
|
392
|
+
resetting anything, and this puts them all straight back. Expect a
|
|
393
|
+
blank display for twelve seconds or more before the rotation returns,
|
|
394
|
+
longer with a lot of messages to put back.
|
|
339
395
|
|
|
340
396
|
503 if the sign cannot be reached, since a sign that is not answering cannot
|
|
341
397
|
be rebooted.
|
|
@@ -394,7 +450,7 @@ async def control_commands() -> list[TokenInfo]:
|
|
|
394
450
|
return _as_info(CONTROL_COMMANDS)
|
|
395
451
|
|
|
396
452
|
|
|
397
|
-
router.include_router(
|
|
453
|
+
router.include_router(slots)
|
|
398
454
|
router.include_router(variables)
|
|
399
455
|
router.include_router(alerts_routes)
|
|
400
456
|
router.include_router(sign_routes)
|