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.
Files changed (63) hide show
  1. {readerboard-0.5.0/readerboard.egg-info → readerboard-0.6.0}/PKG-INFO +62 -14
  2. {readerboard-0.5.0 → readerboard-0.6.0}/README.md +61 -13
  3. {readerboard-0.5.0 → readerboard-0.6.0}/pyproject.toml +1 -1
  4. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/app.py +10 -13
  6. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/deps.py +4 -4
  7. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/models.py +50 -3
  8. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/routes.py +81 -25
  9. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/config.py +5 -4
  10. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/constants.py +8 -4
  11. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/frames.py +4 -5
  12. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/replies.py +5 -4
  13. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/tokens.py +6 -5
  14. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/alerts.py +4 -21
  15. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/registry.py +253 -88
  16. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/controller.py +11 -14
  17. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/layout.py +1 -1
  18. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/state.py +14 -1
  19. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/serial_link.py +21 -4
  20. {readerboard-0.5.0 → readerboard-0.6.0/readerboard.egg-info}/PKG-INFO +62 -14
  21. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_api.py +153 -52
  22. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_component_names.py +7 -0
  23. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_constant_values.py +9 -11
  24. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_controller.py +14 -15
  25. readerboard-0.6.0/tests/test_registry.py +863 -0
  26. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_transport.py +70 -1
  27. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_variables.py +10 -13
  28. readerboard-0.5.0/tests/test_registry.py +0 -479
  29. {readerboard-0.5.0 → readerboard-0.6.0}/LICENSE +0 -0
  30. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/__main__.py +0 -0
  31. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/__init__.py +0 -0
  32. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/api/errors.py +0 -0
  33. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/logging_setup.py +0 -0
  34. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/names.py +0 -0
  35. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/__init__.py +0 -0
  36. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/protocol/markup.py +0 -0
  37. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/py.typed +0 -0
  38. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/__init__.py +0 -0
  39. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/clock.py +0 -0
  40. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/services/commands.py +0 -0
  41. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/sign/__init__.py +0 -0
  42. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/__init__.py +0 -0
  43. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/base.py +0 -0
  44. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard/transport/fake.py +0 -0
  45. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/SOURCES.txt +0 -0
  46. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/dependency_links.txt +0 -0
  47. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/entry_points.txt +0 -0
  48. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/requires.txt +0 -0
  49. {readerboard-0.5.0 → readerboard-0.6.0}/readerboard.egg-info/top_level.txt +0 -0
  50. {readerboard-0.5.0 → readerboard-0.6.0}/setup.cfg +0 -0
  51. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_alerts.py +0 -0
  52. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_clock.py +0 -0
  53. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_config.py +0 -0
  54. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_frames.py +0 -0
  55. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_launch_configurations.py +0 -0
  56. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_logging_setup.py +0 -0
  57. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_markup.py +0 -0
  58. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_open_docs.py +0 -0
  59. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_replies.py +0 -0
  60. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_run_against_a_sign.py +0 -0
  61. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_run_with_simulator.py +0 -0
  62. {readerboard-0.5.0 → readerboard-0.6.0}/tests/test_state.py +0 -0
  63. {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.5.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 registered slots by itself. A **variable** is a value that messages
59
- call by name, such as a temperature, and changing it does not blank the sign or restart
60
- the message showing it. An **alert** takes the whole display over until it is released,
61
- after which the rotation resumes.
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 unreachable is refused with
86
- a 503 rather than silently held, so the caller learns it did not land.
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/messages/temperature \
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/messages/doorbell \
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/messages/weather \
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 about ten
417
- seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
418
- that without resetting anything. The client fronts the reboot with a warning-coloured
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 registered slots by itself. A **variable** is a value that messages
15
- call by name, such as a temperature, and changing it does not blank the sign or restart
16
- the message showing it. An **alert** takes the whole display over until it is released,
17
- after which the rotation resumes.
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 unreachable is refused with
42
- a 503 rather than silently held, so the caller learns it did not land.
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/messages/temperature \
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/messages/doorbell \
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/messages/weather \
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 about ten
373
- seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
374
- that without resetting anything. The client fronts the reboot with a warning-coloured
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.5.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"
@@ -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.5.0"
10
+ __version__ = "0.6.0"
@@ -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 MessageRegistry
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 registered slots by itself. An **alert** takes the
46
- whole display over until it is released, then the rotation resumes.
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 ``MessageRegistry.refresh`` for why blind re-pushing is the only thing
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: MessageRegistry = app.state.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: MessageRegistry = app.state.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 = MessageRegistry(
153
- controller, layout, store, state, alert_active=lambda: alerts.active is not None
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 MessageRegistry
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) -> MessageRegistry:
58
+ def get_registry(request: Request) -> SlotRegistry:
59
59
  """Return the registered messages."""
60
- registry: MessageRegistry = request.app.state.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[MessageRegistry, Depends(get_registry)]
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 MessageRequest(BaseModel):
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
- "cycles to a file with no text in it. Use DELETE to give the slot back"
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="drop the message this many seconds from now; omit to keep it until replaced",
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 MessageRegistry
47
+ from readerboard.services.registry import SlotRegistry
47
48
  from readerboard.sign.state import VariableState
48
49
 
49
50
  router = APIRouter()
50
51
 
51
- messages = APIRouter(prefix="/messages", tags=["Messages"])
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
- # Messages
60
+ # Slots
60
61
  # ===========================================================================
61
62
 
62
63
 
63
- @messages.get("", summary="List the messages sharing the sign")
64
- async def list_messages(registry: RegistryDep) -> list[SlotResponse]:
65
- """Return every registered slot, in the order the sign plays them."""
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
- @messages.get("/{key}", summary="Read one message")
70
- async def get_message(key: SlotKey, registry: RegistryDep) -> SlotResponse:
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
- @messages.put("/{key}", summary="Register or replace a message", dependencies=[RequireApiKey])
76
- async def put_message(
77
- key: SlotKey, body: MessageRequest, registry: RegistryDep
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 every registered slot on its own, so registering a
82
- second message does not displace the first.
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
- @messages.delete(
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="Take a message off the sign",
141
+ summary="Give up one slot",
98
142
  status_code=status.HTTP_204_NO_CONTENT,
99
143
  dependencies=[RequireApiKey],
100
144
  )
101
- async def delete_message(key: SlotKey, registry: RegistryDep) -> Response:
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
- @messages.delete(
151
+ @slots.delete(
108
152
  "",
109
- summary="Take every message off the sign",
153
+ summary="Give up every slot",
110
154
  status_code=status.HTTP_204_NO_CONTENT,
111
155
  dependencies=[RequireApiKey],
112
156
  )
113
- async def clear_messages(registry: RegistryDep) -> Response:
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: MessageRegistry, variable: VariableState) -> VariableResponse:
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 /messages` takes every message off
337
- without resetting anything, and this puts them all straight back. Expect a
338
- blank display for roughly ten seconds before the rotation returns.
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(messages)
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)