readerboard 0.4.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 (65) hide show
  1. {readerboard-0.4.0/readerboard.egg-info → readerboard-0.6.0}/PKG-INFO +140 -16
  2. {readerboard-0.4.0 → readerboard-0.6.0}/README.md +139 -15
  3. {readerboard-0.4.0 → readerboard-0.6.0}/pyproject.toml +1 -1
  4. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/api/app.py +34 -21
  6. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/api/deps.py +4 -4
  7. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/api/errors.py +15 -1
  8. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/api/models.py +161 -7
  9. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/api/routes.py +199 -31
  10. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/config.py +46 -10
  11. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/protocol/constants.py +43 -9
  12. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/protocol/frames.py +77 -8
  13. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/protocol/markup.py +143 -3
  14. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/protocol/replies.py +5 -4
  15. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/protocol/tokens.py +6 -5
  16. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/services/alerts.py +56 -48
  17. readerboard-0.6.0/readerboard/services/registry.py +952 -0
  18. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/sign/controller.py +45 -20
  19. readerboard-0.6.0/readerboard/sign/layout.py +157 -0
  20. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/sign/state.py +72 -10
  21. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/transport/base.py +1 -1
  22. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/transport/serial_link.py +21 -4
  23. {readerboard-0.4.0 → readerboard-0.6.0/readerboard.egg-info}/PKG-INFO +140 -16
  24. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard.egg-info/SOURCES.txt +3 -1
  25. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_api.py +317 -48
  26. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_component_names.py +7 -0
  27. readerboard-0.6.0/tests/test_config.py +47 -0
  28. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_constant_values.py +42 -11
  29. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_controller.py +110 -13
  30. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_frames.py +63 -0
  31. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_markup.py +119 -1
  32. readerboard-0.6.0/tests/test_registry.py +863 -0
  33. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_state.py +97 -20
  34. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_transport.py +70 -1
  35. readerboard-0.6.0/tests/test_variables.py +613 -0
  36. readerboard-0.4.0/readerboard/services/registry.py +0 -432
  37. readerboard-0.4.0/readerboard/sign/layout.py +0 -116
  38. readerboard-0.4.0/tests/test_registry.py +0 -479
  39. {readerboard-0.4.0 → readerboard-0.6.0}/LICENSE +0 -0
  40. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/__main__.py +0 -0
  41. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/api/__init__.py +0 -0
  42. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/logging_setup.py +0 -0
  43. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/names.py +0 -0
  44. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/protocol/__init__.py +0 -0
  45. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/py.typed +0 -0
  46. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/services/__init__.py +0 -0
  47. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/services/clock.py +0 -0
  48. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/services/commands.py +0 -0
  49. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/sign/__init__.py +0 -0
  50. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/transport/__init__.py +0 -0
  51. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard/transport/fake.py +0 -0
  52. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard.egg-info/dependency_links.txt +0 -0
  53. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard.egg-info/entry_points.txt +0 -0
  54. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard.egg-info/requires.txt +0 -0
  55. {readerboard-0.4.0 → readerboard-0.6.0}/readerboard.egg-info/top_level.txt +0 -0
  56. {readerboard-0.4.0 → readerboard-0.6.0}/setup.cfg +0 -0
  57. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_alerts.py +0 -0
  58. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_clock.py +0 -0
  59. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_launch_configurations.py +0 -0
  60. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_logging_setup.py +0 -0
  61. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_open_docs.py +0 -0
  62. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_replies.py +0 -0
  63. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_run_against_a_sign.py +0 -0
  64. {readerboard-0.4.0 → readerboard-0.6.0}/tests/test_run_with_simulator.py +0 -0
  65. {readerboard-0.4.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.4.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,8 +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. An **alert** takes the whole display over
59
- until it is released, 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.
60
64
 
61
65
  ## What it does
62
66
 
@@ -64,6 +68,10 @@ until it is released, after which the rotation resumes.
64
68
  automation owns `doorbell`, without either knowing about the other.
65
69
  - **The sign does the rotating.** Each message lives in its own sign file and the sign
66
70
  cycles them on its own, so rotation costs no serial traffic at all.
71
+ - **Live values without a blink.** A message such as `Outside <var:temp><degree>F` calls
72
+ the variable `temp`, and a new value is one small write that the sign shows the next
73
+ time it draws the message, with no blank and no restart. One variable can appear in
74
+ any number of messages, and a value that stops arriving can be made to go stale.
67
75
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
68
76
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
69
77
  back. That last trigger is the one that matters: a sign returning from a power cut
@@ -76,8 +84,10 @@ until it is released, after which the rotation resumes.
76
84
  flicker.
77
85
  - **It survives restarts and outages.** The registered messages are persisted and
78
86
  pushed to the sign again whenever the link returns, so a restart or a power cut leaves
79
- the rotation intact. A write that arrives while the sign is unreachable is refused with
80
- 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.
81
91
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
82
92
  render is a 400, each with the reason in the body. Nothing here reports a failure
83
93
  under a 200.
@@ -257,7 +267,7 @@ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, th
257
267
  Register a message:
258
268
 
259
269
  ```
260
- curl -X PUT http://localhost:5001/messages/temperature \
270
+ curl -X PUT http://localhost:5001/slots/temperature \
261
271
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
262
272
  -d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
263
273
  ```
@@ -265,11 +275,70 @@ curl -X PUT http://localhost:5001/messages/temperature \
265
275
  Register a second one and the sign rotates between them:
266
276
 
267
277
  ```
268
- curl -X PUT http://localhost:5001/messages/doorbell \
278
+ curl -X PUT http://localhost:5001/slots/doorbell \
269
279
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
270
280
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
271
281
  ```
272
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
+
327
+ Show a live value. Create the variable first, then a message that calls it:
328
+
329
+ ```
330
+ curl -X PUT http://localhost:5001/variables/temp \
331
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
332
+ -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
333
+
334
+ curl -X PUT http://localhost:5001/slots/weather \
335
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
336
+ -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
337
+ ```
338
+
339
+ From then on only the variable needs writing, and the message picks each new value up
340
+ on its next pass across the sign.
341
+
273
342
  Take the sign over for thirty seconds:
274
343
 
275
344
  ```
@@ -296,8 +365,8 @@ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back o
296
365
  lives on the sign and survives a restart, so it is also the first thing to check if
297
366
  `SOUND` ever seems to do nothing.
298
367
 
299
- The full API is at `/docs`. Every markup token, display mode and control
300
- command is listed by the `/enumerations` reads there, which answer at
368
+ The full API is at `/docs`. Every markup token, value token, display mode and
369
+ control command is listed by the `/enumerations` reads there, which answer at
301
370
  request time rather than being frozen into the description.
302
371
 
303
372
  ### Writing messages
@@ -311,6 +380,59 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
311
380
  unknown token: a write is told what the sign would have made of it rather than being
312
381
  shown something it did not ask for.
313
382
 
383
+ ### Live values: variables
384
+
385
+ A variable lives in a small file of its own on the sign, and a message or an alert
386
+ calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
387
+ which the sign takes without blanking, so a message showing a temperature or a count can
388
+ change every minute and never restart. In a scrolling mode the new value appears on the
389
+ message's next pass. An alert carrying a live wind speed works the same way, and keeps
390
+ the sign while the number changes.
391
+
392
+ A few things are worth knowing, all of them measured on the sign:
393
+
394
+ - **Create the variable before a message calls it.** A message or an alert naming a
395
+ variable that does not exist is refused with a 400, and a variable that a message or
396
+ the alert still calls cannot be deleted: that is a 409 naming what calls it.
397
+ - **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
398
+ of the message red as well, and so does a character set or a speed. If the text after
399
+ the call matters, set it again in the message after the call.
400
+ - **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
401
+ `9` from inside a variable, and a variable cannot call another. `GET
402
+ /enumerations/value-tokens` lists what is allowed.
403
+ - **Keep a changing number from pushing the text around it.** With the sign's usual
404
+ proportional spacing, `11` and `88` are different widths and the text after them moves.
405
+ Put `<fixed_width>` in the message before the call and send values of the same length.
406
+ Fixed width also left-justifies the line.
407
+ - **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
408
+ such as `--`, and once the time passes without a new value the sign shows the stale
409
+ value instead. The variable itself stays, since messages call it.
410
+ - **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
411
+ rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
412
+ so the service refuses one rather than send it.
413
+
414
+ A Home Assistant `rest_command` that keeps a sensor on the sign:
415
+
416
+ ```yaml
417
+ rest_command:
418
+ sign_temperature:
419
+ url: http://readerboard.local:5001/variables/temp
420
+ method: put
421
+ headers:
422
+ X-API-Key: !secret readerboard_key
423
+ content_type: application/json
424
+ payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
425
+ ```
426
+
427
+ Call it from an automation triggered by the sensor, with the reading as `value`:
428
+
429
+ ```yaml
430
+ actions:
431
+ - action: rest_command.sign_temperature
432
+ data:
433
+ value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
434
+ ```
435
+
314
436
  ### Recovering a sign that has stopped responding
315
437
 
316
438
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -339,10 +461,10 @@ from the service's own record, so the display still comes back to what it was.
339
461
  curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
340
462
  ```
341
463
 
342
- Reach for it only when a soft reset was not enough. The sign is blank for about ten
343
- seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
344
- that without resetting anything. The client fronts the reboot with a warning-coloured
345
- 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.
346
468
 
347
469
  ## Configuration
348
470
 
@@ -361,9 +483,11 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
361
483
  and the port, and pyserial's answer to one that has a slash names neither the setting
362
484
  nor the value.
363
485
 
364
- Two settings reallocate the sign's memory when changed, and **that erases every message
365
- on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
366
- the log, but they are not settings to fiddle with.
486
+ Four settings reallocate the sign's memory when changed, and **that erases every message
487
+ on it**: `slot_count`, `slot_capacity`, `variable_count` and `variable_capacity`. The
488
+ service will do it, and say so loudly in the log, but they are not settings to fiddle
489
+ with. With `variable_count` at 0, `variable_capacity` allocates nothing, so changing it
490
+ alone reallocates nothing either.
367
491
 
368
492
  ## Security
369
493
 
@@ -11,8 +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. An **alert** takes the whole display over
15
- until it is released, 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.
16
20
 
17
21
  ## What it does
18
22
 
@@ -20,6 +24,10 @@ until it is released, after which the rotation resumes.
20
24
  automation owns `doorbell`, without either knowing about the other.
21
25
  - **The sign does the rotating.** Each message lives in its own sign file and the sign
22
26
  cycles them on its own, so rotation costs no serial traffic at all.
27
+ - **Live values without a blink.** A message such as `Outside <var:temp><degree>F` calls
28
+ the variable `temp`, and a new value is one small write that the sign shows the next
29
+ time it draws the message, with no blank and no restart. One variable can appear in
30
+ any number of messages, and a value that stops arriving can be made to go stale.
23
31
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
24
32
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
25
33
  back. That last trigger is the one that matters: a sign returning from a power cut
@@ -32,8 +40,10 @@ until it is released, after which the rotation resumes.
32
40
  flicker.
33
41
  - **It survives restarts and outages.** The registered messages are persisted and
34
42
  pushed to the sign again whenever the link returns, so a restart or a power cut leaves
35
- the rotation intact. A write that arrives while the sign is unreachable is refused with
36
- 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.
37
47
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
38
48
  render is a 400, each with the reason in the body. Nothing here reports a failure
39
49
  under a 200.
@@ -213,7 +223,7 @@ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, th
213
223
  Register a message:
214
224
 
215
225
  ```
216
- curl -X PUT http://localhost:5001/messages/temperature \
226
+ curl -X PUT http://localhost:5001/slots/temperature \
217
227
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
218
228
  -d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
219
229
  ```
@@ -221,11 +231,70 @@ curl -X PUT http://localhost:5001/messages/temperature \
221
231
  Register a second one and the sign rotates between them:
222
232
 
223
233
  ```
224
- curl -X PUT http://localhost:5001/messages/doorbell \
234
+ curl -X PUT http://localhost:5001/slots/doorbell \
225
235
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
226
236
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
227
237
  ```
228
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
+
283
+ Show a live value. Create the variable first, then a message that calls it:
284
+
285
+ ```
286
+ curl -X PUT http://localhost:5001/variables/temp \
287
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
288
+ -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
289
+
290
+ curl -X PUT http://localhost:5001/slots/weather \
291
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
292
+ -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
293
+ ```
294
+
295
+ From then on only the variable needs writing, and the message picks each new value up
296
+ on its next pass across the sign.
297
+
229
298
  Take the sign over for thirty seconds:
230
299
 
231
300
  ```
@@ -252,8 +321,8 @@ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back o
252
321
  lives on the sign and survives a restart, so it is also the first thing to check if
253
322
  `SOUND` ever seems to do nothing.
254
323
 
255
- The full API is at `/docs`. Every markup token, display mode and control
256
- command is listed by the `/enumerations` reads there, which answer at
324
+ The full API is at `/docs`. Every markup token, value token, display mode and
325
+ control command is listed by the `/enumerations` reads there, which answer at
257
326
  request time rather than being frozen into the description.
258
327
 
259
328
  ### Writing messages
@@ -267,6 +336,59 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
267
336
  unknown token: a write is told what the sign would have made of it rather than being
268
337
  shown something it did not ask for.
269
338
 
339
+ ### Live values: variables
340
+
341
+ A variable lives in a small file of its own on the sign, and a message or an alert
342
+ calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
343
+ which the sign takes without blanking, so a message showing a temperature or a count can
344
+ change every minute and never restart. In a scrolling mode the new value appears on the
345
+ message's next pass. An alert carrying a live wind speed works the same way, and keeps
346
+ the sign while the number changes.
347
+
348
+ A few things are worth knowing, all of them measured on the sign:
349
+
350
+ - **Create the variable before a message calls it.** A message or an alert naming a
351
+ variable that does not exist is refused with a 400, and a variable that a message or
352
+ the alert still calls cannot be deleted: that is a 409 naming what calls it.
353
+ - **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
354
+ of the message red as well, and so does a character set or a speed. If the text after
355
+ the call matters, set it again in the message after the call.
356
+ - **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
357
+ `9` from inside a variable, and a variable cannot call another. `GET
358
+ /enumerations/value-tokens` lists what is allowed.
359
+ - **Keep a changing number from pushing the text around it.** With the sign's usual
360
+ proportional spacing, `11` and `88` are different widths and the text after them moves.
361
+ Put `<fixed_width>` in the message before the call and send values of the same length.
362
+ Fixed width also left-justifies the line.
363
+ - **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
364
+ such as `--`, and once the time passes without a new value the sign shows the stale
365
+ value instead. The variable itself stays, since messages call it.
366
+ - **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
367
+ rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
368
+ so the service refuses one rather than send it.
369
+
370
+ A Home Assistant `rest_command` that keeps a sensor on the sign:
371
+
372
+ ```yaml
373
+ rest_command:
374
+ sign_temperature:
375
+ url: http://readerboard.local:5001/variables/temp
376
+ method: put
377
+ headers:
378
+ X-API-Key: !secret readerboard_key
379
+ content_type: application/json
380
+ payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
381
+ ```
382
+
383
+ Call it from an automation triggered by the sensor, with the reading as `value`:
384
+
385
+ ```yaml
386
+ actions:
387
+ - action: rest_command.sign_temperature
388
+ data:
389
+ value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
390
+ ```
391
+
270
392
  ### Recovering a sign that has stopped responding
271
393
 
272
394
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -295,10 +417,10 @@ from the service's own record, so the display still comes back to what it was.
295
417
  curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
296
418
  ```
297
419
 
298
- Reach for it only when a soft reset was not enough. The sign is blank for about ten
299
- seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
300
- that without resetting anything. The client fronts the reboot with a warning-coloured
301
- 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.
302
424
 
303
425
  ## Configuration
304
426
 
@@ -317,9 +439,11 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
317
439
  and the port, and pyserial's answer to one that has a slash names neither the setting
318
440
  nor the value.
319
441
 
320
- Two settings reallocate the sign's memory when changed, and **that erases every message
321
- on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
322
- the log, but they are not settings to fiddle with.
442
+ Four settings reallocate the sign's memory when changed, and **that erases every message
443
+ on it**: `slot_count`, `slot_capacity`, `variable_count` and `variable_capacity`. The
444
+ service will do it, and say so loudly in the log, but they are not settings to fiddle
445
+ with. With `variable_count` at 0, `variable_capacity` allocates nothing, so changing it
446
+ alone reallocates nothing either.
323
447
 
324
448
  ## Security
325
449
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readerboard"
7
- version = "0.4.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.4.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,14 @@ 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.
49
+
50
+ A **variable** is a value a slot's message or an alert calls with `<var:name>`.
51
+ Changing it rewrites only the variable, so the sign shows the new value without
52
+ blanking or restarting what calls it.
47
53
 
48
54
  Every write needs an `X-API-Key` header, and so does `GET /sign/information`,
49
55
  which reads the sign rather than the service: it puts a question on the wire and
@@ -53,13 +59,14 @@ holds the sign until the answer comes back. The service's own reads and
53
59
 
54
60
  A failure is reported by the status code, with the reason in a `detail` field:
55
61
  400 for a command the sign does not have, a parameter it will not accept, a
56
- message too long for its slot or markup the sign cannot render, 401 for a
57
- missing or wrong `X-API-Key`, 404 for a slot nothing has registered, 409 when
58
- every message slot is already in use, 503 when the sign is unreachable, answers
59
- with something the service cannot read, or no API key is configured at all, 500
60
- for something the service has no code for, and 422 for a body that is not the
61
- shape the endpoint declares, which includes a display mode the sign does not
62
- have.
62
+ message or value too long for its file, markup the sign cannot render or a call
63
+ to a variable that does not exist, 401 for a missing or wrong `X-API-Key`, 404
64
+ for a slot or variable that does not exist, 409 when every slot or every
65
+ variable is already in use or a variable something still calls is deleted, 503
66
+ when the sign is unreachable, stops partway through an answer or answers with
67
+ something the service cannot read, or no API key is configured at all, 500 for
68
+ something the service has no code for, and 422 for a body that is not the shape
69
+ the endpoint declares, which includes a display mode the sign does not have.
63
70
  """
64
71
 
65
72
 
@@ -77,10 +84,10 @@ def build_transport(settings: Settings) -> Transport:
77
84
  async def _refresh_loop(app: FastAPI, interval: float) -> None:
78
85
  """Push everything to the sign again, periodically.
79
86
 
80
- 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
81
88
  that repairs a sign power cycled behind a still-connected adapter.
82
89
  """
83
- registry: MessageRegistry = app.state.registry
90
+ registry: SlotRegistry = app.state.registry
84
91
  alerts: AlertService = app.state.alerts
85
92
 
86
93
  while True:
@@ -99,8 +106,8 @@ async def _refresh_loop(app: FastAPI, interval: float) -> None:
99
106
 
100
107
 
101
108
  async def _sweep_loop(app: FastAPI, interval: float) -> None:
102
- """Expire slots and alerts whose deadlines have passed."""
103
- registry: MessageRegistry = app.state.registry
109
+ """Expire slots and alerts, and turn variables stale, once their deadlines pass."""
110
+ registry: SlotRegistry = app.state.registry
104
111
  alerts: AlertService = app.state.alerts
105
112
 
106
113
  while True:
@@ -137,14 +144,17 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
137
144
  )
138
145
  store = StateStore(settings.state_path)
139
146
  state = store.load()
140
- layout = Layout(settings.slot_count, settings.slot_capacity)
141
- alerts = AlertService(controller, store, state)
142
- registry = MessageRegistry(
143
- controller, layout, store, state, alert_active=lambda: alerts.active is not None
147
+ layout = Layout(
148
+ settings.slot_count,
149
+ settings.slot_capacity,
150
+ settings.variable_count,
151
+ settings.variable_capacity,
144
152
  )
145
- # An alert holding the sign makes the registry hold back run sequence
146
- # writes; releasing it is what lets them through.
147
- alerts.set_release_hook(registry.flush_deferred)
153
+ alerts = AlertService(controller, store, state)
154
+ registry = SlotRegistry(controller, layout, store, state)
155
+ # An alert calling a variable is rendered by the registry, under its
156
+ # lock, so the variable cannot be deleted while the alert calls it.
157
+ alerts.set_rendering(registry.rendering)
148
158
  clock = ClockService(
149
159
  controller,
150
160
  interval_seconds=settings.clock_sync_interval_seconds,
@@ -240,6 +250,7 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
240
250
  clock = get_clock(request)
241
251
 
242
252
  used, total = registry.occupancy
253
+ variables_used, variables_total = registry.variable_occupancy
243
254
  return HealthResponse(
244
255
  status="ok" if controller.is_connected else "degraded",
245
256
  version=__version__,
@@ -253,6 +264,8 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
253
264
  ),
254
265
  slots_used=used,
255
266
  slots_total=total,
267
+ variables_used=variables_used,
268
+ variables_total=variables_total,
256
269
  sign_in_sync=registry.in_sync,
257
270
  alert_active=alerts.active is not None,
258
271
  clock_last_synced_at=clock.last_sync_at,
@@ -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)
@@ -18,7 +18,14 @@ from readerboard.protocol.markup import MarkupError
18
18
  from readerboard.protocol.replies import ReplyError
19
19
  from readerboard.services import commands
20
20
  from readerboard.services.alerts import AlertTooLong
21
- from readerboard.services.registry import MessageTooLong, UnknownSlot
21
+ from readerboard.services.registry import (
22
+ MessageTooLong,
23
+ UnknownSlot,
24
+ UnknownVariable,
25
+ VariableInUse,
26
+ VariablesDisabled,
27
+ VariableTooLong,
28
+ )
22
29
  from readerboard.sign.layout import LayoutFull
23
30
  from readerboard.transport.base import TransportError
24
31
 
@@ -26,11 +33,18 @@ STATUS_FOR_ERROR: tuple[tuple[type[Exception], int], ...] = (
26
33
  (MarkupError, status.HTTP_400_BAD_REQUEST),
27
34
  (ProtocolError, status.HTTP_400_BAD_REQUEST),
28
35
  (MessageTooLong, status.HTTP_400_BAD_REQUEST),
36
+ (VariableTooLong, status.HTTP_400_BAD_REQUEST),
37
+ (VariablesDisabled, status.HTTP_400_BAD_REQUEST),
29
38
  (AlertTooLong, status.HTTP_400_BAD_REQUEST),
30
39
  (commands.UnknownCommand, status.HTTP_400_BAD_REQUEST),
31
40
  (commands.BadParameter, status.HTTP_400_BAD_REQUEST),
32
41
  (UnknownSlot, status.HTTP_404_NOT_FOUND),
42
+ (UnknownVariable, status.HTTP_404_NOT_FOUND),
33
43
  (LayoutFull, status.HTTP_409_CONFLICT),
44
+ # Deleting a variable a message still calls would leave that message
45
+ # calling a file the next variable could be given. Not the caller's
46
+ # request being malformed, which is what makes it a conflict.
47
+ (VariableInUse, status.HTTP_409_CONFLICT),
34
48
  (TransportError, status.HTTP_503_SERVICE_UNAVAILABLE),
35
49
  # A sign that answers with something unreadable is as unusable as one
36
50
  # that does not answer, and neither is the caller's doing. The route