readerboard 0.4.0__tar.gz → 0.7.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 (77) hide show
  1. {readerboard-0.4.0/readerboard.egg-info → readerboard-0.7.0}/PKG-INFO +216 -21
  2. {readerboard-0.4.0 → readerboard-0.7.0}/README.md +214 -19
  3. {readerboard-0.4.0 → readerboard-0.7.0}/pyproject.toml +11 -2
  4. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/api/app.py +105 -28
  6. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/api/deps.py +4 -4
  7. readerboard-0.7.0/readerboard/api/errors.py +77 -0
  8. readerboard-0.7.0/readerboard/api/models.py +439 -0
  9. readerboard-0.7.0/readerboard/api/routes.py +516 -0
  10. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/config.py +97 -24
  11. readerboard-0.7.0/readerboard/icons.py +520 -0
  12. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/protocol/constants.py +119 -9
  13. readerboard-0.7.0/readerboard/protocol/frames.py +604 -0
  14. readerboard-0.7.0/readerboard/protocol/markup.py +460 -0
  15. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/protocol/replies.py +87 -8
  16. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/protocol/tokens.py +6 -5
  17. readerboard-0.7.0/readerboard/services/alerts.py +647 -0
  18. readerboard-0.7.0/readerboard/services/registry.py +1834 -0
  19. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/sign/controller.py +75 -21
  20. readerboard-0.7.0/readerboard/sign/layout.py +216 -0
  21. readerboard-0.7.0/readerboard/sign/pool.py +165 -0
  22. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/sign/state.py +145 -9
  23. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/transport/base.py +1 -1
  24. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/transport/serial_link.py +21 -4
  25. {readerboard-0.4.0 → readerboard-0.7.0/readerboard.egg-info}/PKG-INFO +216 -21
  26. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard.egg-info/SOURCES.txt +8 -1
  27. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard.egg-info/requires.txt +1 -1
  28. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_alerts.py +52 -1
  29. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_api.py +572 -50
  30. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_component_names.py +7 -0
  31. readerboard-0.7.0/tests/test_config.py +107 -0
  32. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_constant_values.py +127 -11
  33. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_controller.py +110 -13
  34. readerboard-0.7.0/tests/test_frames.py +447 -0
  35. readerboard-0.7.0/tests/test_icons.py +141 -0
  36. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_markup.py +205 -1
  37. readerboard-0.7.0/tests/test_pictures.py +1671 -0
  38. readerboard-0.7.0/tests/test_pool.py +192 -0
  39. readerboard-0.7.0/tests/test_registry.py +1000 -0
  40. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_run_against_a_sign.py +102 -0
  41. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_run_with_simulator.py +47 -3
  42. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_state.py +110 -20
  43. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_transport.py +70 -1
  44. readerboard-0.7.0/tests/test_variables.py +618 -0
  45. readerboard-0.4.0/readerboard/api/errors.py +0 -40
  46. readerboard-0.4.0/readerboard/api/models.py +0 -240
  47. readerboard-0.4.0/readerboard/api/routes.py +0 -289
  48. readerboard-0.4.0/readerboard/protocol/frames.py +0 -317
  49. readerboard-0.4.0/readerboard/protocol/markup.py +0 -214
  50. readerboard-0.4.0/readerboard/services/alerts.py +0 -239
  51. readerboard-0.4.0/readerboard/services/registry.py +0 -432
  52. readerboard-0.4.0/readerboard/sign/layout.py +0 -116
  53. readerboard-0.4.0/tests/test_frames.py +0 -224
  54. readerboard-0.4.0/tests/test_registry.py +0 -479
  55. {readerboard-0.4.0 → readerboard-0.7.0}/LICENSE +0 -0
  56. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/__main__.py +0 -0
  57. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/api/__init__.py +0 -0
  58. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/logging_setup.py +0 -0
  59. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/names.py +0 -0
  60. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/protocol/__init__.py +0 -0
  61. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/py.typed +0 -0
  62. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/services/__init__.py +0 -0
  63. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/services/clock.py +0 -0
  64. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/services/commands.py +0 -0
  65. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/sign/__init__.py +0 -0
  66. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/transport/__init__.py +0 -0
  67. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard/transport/fake.py +0 -0
  68. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard.egg-info/dependency_links.txt +0 -0
  69. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard.egg-info/entry_points.txt +0 -0
  70. {readerboard-0.4.0 → readerboard-0.7.0}/readerboard.egg-info/top_level.txt +0 -0
  71. {readerboard-0.4.0 → readerboard-0.7.0}/setup.cfg +0 -0
  72. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_clock.py +0 -0
  73. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_launch_configurations.py +0 -0
  74. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_logging_setup.py +0 -0
  75. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_open_docs.py +0 -0
  76. {readerboard-0.4.0 → readerboard-0.7.0}/tests/test_replies.py +0 -0
  77. {readerboard-0.4.0 → readerboard-0.7.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.7.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
@@ -28,7 +28,7 @@ Requires-Python: >=3.11
28
28
  Description-Content-Type: text/markdown
29
29
  License-File: LICENSE
30
30
  Requires-Dist: fastapi<0.142,>=0.141.1
31
- Requires-Dist: uvicorn<0.53,>=0.52.1
31
+ Requires-Dist: uvicorn<0.54,>=0.52.1
32
32
  Requires-Dist: pydantic<3,>=2.13.4
33
33
  Requires-Dist: pydantic-settings<3,>=2.15.0
34
34
  Requires-Dist: pyserial<4,>=3.5
@@ -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,7 +68,13 @@ 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.
76
+ A caller that must not overwrite somebody else's alert can ask to be refused
77
+ instead.
68
78
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
69
79
  back. That last trigger is the one that matters: a sign returning from a power cut
70
80
  does so at no particular minute. The sign is set one minute fast on purpose: the
@@ -76,8 +86,10 @@ until it is released, after which the rotation resumes.
76
86
  flicker.
77
87
  - **It survives restarts and outages.** The registered messages are persisted and
78
88
  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.
89
+ the rotation intact. A message or a variable write that arrives while the sign is
90
+ unreachable is refused with a 503 rather than silently held, so the caller learns it did
91
+ not land. Deleting or hiding a message, or deleting a variable, is accepted and carried
92
+ out when the link returns.
81
93
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
82
94
  render is a 400, each with the reason in the body. Nothing here reports a failure
83
95
  under a 200.
@@ -139,8 +151,8 @@ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
139
151
 
140
152
  That starts the service and the client together, with no simulator. The service
141
153
  comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
142
- pointed at that address, and the API key to paste into the client is printed in
143
- the same window. `--no-client` leaves the client out. Ctrl+C stops everything,
154
+ pointed at that address with the API key already in its box, and the key is
155
+ printed in the same window for anything else that needs it. `--no-client` leaves the client out. Ctrl+C stops everything,
144
156
  and closing the client leaves the service running.
145
157
 
146
158
  Both editors carry it as a launch configuration named "readerboard against the
@@ -213,6 +225,19 @@ again after pulling a new version: your config file and key are left alone.
213
225
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
214
226
  remove those too.
215
227
 
228
+ The unit restarts the service whenever it stops, and gives up after ten failed starts in
229
+ five minutes. Almost nothing here fails permanently, which is what makes the exceptions
230
+ worth stopping for: a memory pool too big for the sign fails identically every time, and
231
+ each attempt puts a read on the wire that stalls a scrolling message. `systemctl status
232
+ readerboard` says which failure it was. Once the configuration is fixed, clearing the
233
+ give-up and starting it again are two commands, because `reset-failed` clears the counter
234
+ and leaves the unit stopped:
235
+
236
+ ```
237
+ sudo systemctl reset-failed readerboard
238
+ sudo systemctl start readerboard
239
+ ```
240
+
216
241
  ### With Docker
217
242
 
218
243
  The image is published to both registries on every release, for `linux/amd64`,
@@ -257,7 +282,7 @@ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, th
257
282
  Register a message:
258
283
 
259
284
  ```
260
- curl -X PUT http://localhost:5001/messages/temperature \
285
+ curl -X PUT http://localhost:5001/slots/temperature \
261
286
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
262
287
  -d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
263
288
  ```
@@ -265,11 +290,70 @@ curl -X PUT http://localhost:5001/messages/temperature \
265
290
  Register a second one and the sign rotates between them:
266
291
 
267
292
  ```
268
- curl -X PUT http://localhost:5001/messages/doorbell \
293
+ curl -X PUT http://localhost:5001/slots/doorbell \
269
294
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
270
295
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
271
296
  ```
272
297
 
298
+ Take a message off the display without giving up its slot, and put it back later:
299
+
300
+ ```
301
+ curl -X PUT http://localhost:5001/slots/doorbell/active \
302
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
303
+ -d '{"active": false}'
304
+ ```
305
+
306
+ A hidden message keeps its slot, its place in the order and its text, so showing it again
307
+ takes `{"active": true}` and no copy of what it said. Hiding or showing one is a single run
308
+ sequence write. That does disturb the display briefly, but far less than rewriting a
309
+ message does: enough less that it is easy to miss unless you are watching for it on a
310
+ static screen. The exception is the last message showing: its file is emptied as it goes,
311
+ because a sign whose run sequence names nothing freezes on what it was drawing, so
312
+ putting that one back costs the text as well as the sequence.
313
+
314
+ `active` is a field on the message endpoint too, where leaving it out is the point: a
315
+ source re-sending the same content every few minutes says nothing about it and so cannot
316
+ switch back on something that was deliberately hidden. Sending it moves the message.
317
+
318
+ A `ttl_seconds` can hide a message instead of deleting it, which suits anything that comes
319
+ back later, such as a bin day or a school notice:
320
+
321
+ ```
322
+ curl -X PUT http://localhost:5001/slots/bins \
323
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
324
+ -d '{"message": "<green>BINS OUT TONIGHT", "ttl_seconds": 43200,
325
+ "delete_on_expiry": false}'
326
+ ```
327
+
328
+ Put those together and a recurring notification is one call per event, sent in the same
329
+ shape every time. This shows the alarm's state for a minute whenever it changes, then takes
330
+ it off the rotation until the next one:
331
+
332
+ ```
333
+ curl -X PUT http://localhost:5001/slots/alarm \
334
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
335
+ -d '{"message": "<red>ALARM NOW <var:arm_state>", "ttl_seconds": 60,
336
+ "delete_on_expiry": false, "active": true}'
337
+ ```
338
+
339
+ The deadline clears itself as it hides the message, so each event gets a fresh minute
340
+ rather than the message vanishing again on a deadline the last one left behind.
341
+
342
+ Show a live value. Create the variable first, then a message that calls it:
343
+
344
+ ```
345
+ curl -X PUT http://localhost:5001/variables/temp \
346
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
347
+ -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
348
+
349
+ curl -X PUT http://localhost:5001/slots/weather \
350
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
351
+ -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
352
+ ```
353
+
354
+ From then on only the variable needs writing, and the message picks each new value up
355
+ on its next pass across the sign.
356
+
273
357
  Take the sign over for thirty seconds:
274
358
 
275
359
  ```
@@ -296,8 +380,8 @@ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back o
296
380
  lives on the sign and survives a restart, so it is also the first thing to check if
297
381
  `SOUND` ever seems to do nothing.
298
382
 
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
383
+ The full API is at `/docs`. Every markup token, value token, display mode and
384
+ control command is listed by the `/enumerations` reads there, which answer at
301
385
  request time rather than being frozen into the description.
302
386
 
303
387
  ### Writing messages
@@ -311,6 +395,93 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
311
395
  unknown token: a write is told what the sign would have made of it rather than being
312
396
  shown something it did not ask for.
313
397
 
398
+ ### Live values: variables
399
+
400
+ A variable lives in a small file of its own on the sign, and a message or an alert
401
+ calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
402
+ which the sign takes without blanking, so a message showing a temperature or a count can
403
+ change every minute and never restart. In a scrolling mode the new value appears on the
404
+ message's next pass. An alert carrying a live wind speed works the same way, and keeps
405
+ the sign while the number changes.
406
+
407
+ A few things are worth knowing, all of them measured on the sign:
408
+
409
+ - **Create the variable before a message calls it.** A message or an alert naming a
410
+ variable that does not exist is refused with a 400, and a variable that a message or
411
+ the alert still calls cannot be deleted: that is a 409 naming what calls it.
412
+ - **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
413
+ of the message red as well, and so does a character set or a speed. If the text after
414
+ the call matters, set it again in the message after the call.
415
+ - **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
416
+ `9` from inside a variable, and a variable cannot call another. `GET
417
+ /enumerations/value-tokens` lists what is allowed.
418
+ - **Keep a changing number from pushing the text around it.** With the sign's usual
419
+ proportional spacing, `11` and `88` are different widths and the text after them moves.
420
+ Put `<fixed_width>` in the message before the call and send values of the same length.
421
+ Fixed width also left-justifies the line.
422
+ - **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
423
+ such as `--`, and once the time passes without a new value the sign shows the stale
424
+ value instead. The variable itself stays, since messages call it.
425
+ - **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
426
+ rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
427
+ so the service refuses one rather than send it.
428
+
429
+ A Home Assistant `rest_command` that keeps a sensor on the sign:
430
+
431
+ ```yaml
432
+ rest_command:
433
+ sign_temperature:
434
+ url: http://readerboard.local:5001/variables/temp
435
+ method: put
436
+ headers:
437
+ X-API-Key: !secret readerboard_key
438
+ content_type: application/json
439
+ payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
440
+ ```
441
+
442
+ Call it from an automation triggered by the sensor, with the reading as `value`:
443
+
444
+ ```yaml
445
+ actions:
446
+ - action: rest_command.sign_temperature
447
+ data:
448
+ value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
449
+ ```
450
+
451
+ ### Icons
452
+
453
+ A message can draw one of 148 built-in bitmaps where a tag sits. `<icon:sun> FINE`
454
+ puts a sun in front of the word, and `<icon:lock:red>` retints an icon that is drawn
455
+ in a single ink; the colour words are the colour tokens' own, down to `dimred` and
456
+ `dimgreen`, so a message that can say `<red>` needs no second spelling. An icon
457
+ drawn in its own colours, such as the sun, takes no tint and asking for one is
458
+ refused rather than ignored. `GET /enumerations/icons` lists every icon with its
459
+ group, its width and whether it takes a tint.
460
+
461
+ Icons are off until `picture_count` is set, because each one on the sign needs a
462
+ picture file of its own and allocating those reallocates the sign's memory, which
463
+ erases every message on it. Set it once, alongside the other pool settings, and
464
+ 16 is a comfortable number.
465
+
466
+ Four things are worth knowing, and the first is the one that decides how to use them:
467
+
468
+ - **An icon is not a live value.** Every write to a picture file blanks the display
469
+ and restarts a scrolling message. A weather slot stepping from sun to cloud to rain
470
+ pays that each time it lands on an icon the sign is not already holding. Something
471
+ that changes every minute belongs in a variable, which costs no blank at all.
472
+ - **The pool is smaller than the library, and that is the design.** A picture file is
473
+ claimed by whichever icon a message calls, and kept after its last caller goes, so a
474
+ source alternating between two icons costs nothing after the first write. When every
475
+ file is holding an icon something still calls and a new one is asked for, the write
476
+ is refused with a 409. `GET /health` reports pictures used against pictures total,
477
+ and a full pool is the resting state rather than a warning.
478
+ - **A tint makes a second picture.** `<icon:check:green>` and `<icon:check:red>` are
479
+ two bitmaps and take two files.
480
+ - **An icon can be parted from its word.** A line too wide for the display breaks onto
481
+ a second page in HOLD, and the last word can arrive there without the icon labelling
482
+ it. The service cannot warn about this: it would have to know the width of the sign's
483
+ proportional font.
484
+
314
485
  ### Recovering a sign that has stopped responding
315
486
 
316
487
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -339,10 +510,10 @@ from the service's own record, so the display still comes back to what it was.
339
510
  curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
340
511
  ```
341
512
 
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.
513
+ Reach for it only when a soft reset was not enough. The sign is blank for twelve seconds
514
+ or more while it resets, longer with a lot of messages to put back. Neither is a way to
515
+ clear messages: `DELETE /slots` does that without resetting anything. The client
516
+ fronts the reboot with a warning-coloured confirmation for the same reason.
346
517
 
347
518
  ## Configuration
348
519
 
@@ -361,9 +532,32 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
361
532
  and the port, and pyserial's answer to one that has a slash names neither the setting
362
533
  nor the value.
363
534
 
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.
535
+ Five settings reallocate the sign's memory when changed, and **that erases every message
536
+ on it**: `slot_count`, `slot_capacity`, `variable_count`, `variable_capacity` and
537
+ `picture_count`. The service will do it, and say so loudly in the log, but they are not
538
+ settings to fiddle with. With `variable_count` at 0, `variable_capacity` allocates
539
+ nothing, so changing it alone reallocates nothing either. `picture_count` starts at 0,
540
+ which switches icons off, so turning them on is one deliberate erase.
541
+
542
+ All five come out of one memory pool, which a BetaBrite Classic reported as 5482 bytes,
543
+ and each file costs thirteen bytes beyond its own size. The defaults take 2518 of that,
544
+ and each icon takes 69 on top, so the defaults with sixteen icons take 3622.
545
+
546
+ That 5482 is a ceiling, and it is checked in two places that do different jobs. A
547
+ configuration bigger than it is refused when the settings are read, on any machine,
548
+ whether or not a sign is attached; that is the ceiling, and no sign can raise it. Then,
549
+ on a start that is about to reallocate the sign's memory and only then, the service asks
550
+ the sign for its own figure, and a sign reporting less than 5482 is believed. So the
551
+ second check can lower the limit and never raise it. A sign with a bigger pool than this
552
+ hardware's would need `ASSUMED_SIGN_MEMORY_POOL` in `readerboard/sign/pool.py` raised
553
+ before it could use the extra.
554
+
555
+ A sign that answers and does not have the room stops the service starting, with a message
556
+ naming what was configured, what it needs and what there is; that is the one failure that
557
+ does stop it, because the alternative is erasing every message on the sign to write a pool
558
+ that could never work. A sign that says nothing does not stop anything. `POST /sign/reboot`
559
+ asks the same question before it clears the sign, and answers 409 rather than erasing it,
560
+ except when the sign is too wedged to answer, which is the case that endpoint exists for.
367
561
 
368
562
  ## Security
369
563
 
@@ -444,8 +638,9 @@ each is pinned separately so that nothing the service installs ever pulls Qt in.
444
638
  with `--with-client` the client as well, so the whole loop comes up from one command.
445
639
  Both editors carry it as a launch configuration under the same name, "readerboard and
446
640
  the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`, beside
447
- configurations for running the pieces separately. Both carry the three way one as
448
- "readerboard, the sign simulator and the client" as well.
641
+ configurations for running the pieces separately, the client among them as
642
+ "readerboard client". Both carry the three way one as "readerboard, the sign simulator
643
+ and the client" as well.
449
644
 
450
645
  `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
451
646
  service and the client, no simulator, and the sign's address passed as an argument so
@@ -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,7 +24,13 @@ 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.
32
+ A caller that must not overwrite somebody else's alert can ask to be refused
33
+ instead.
24
34
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
25
35
  back. That last trigger is the one that matters: a sign returning from a power cut
26
36
  does so at no particular minute. The sign is set one minute fast on purpose: the
@@ -32,8 +42,10 @@ until it is released, after which the rotation resumes.
32
42
  flicker.
33
43
  - **It survives restarts and outages.** The registered messages are persisted and
34
44
  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.
45
+ the rotation intact. A message or a variable write that arrives while the sign is
46
+ unreachable is refused with a 503 rather than silently held, so the caller learns it did
47
+ not land. Deleting or hiding a message, or deleting a variable, is accepted and carried
48
+ out when the link returns.
37
49
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
38
50
  render is a 400, each with the reason in the body. Nothing here reports a failure
39
51
  under a 200.
@@ -95,8 +107,8 @@ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
95
107
 
96
108
  That starts the service and the client together, with no simulator. The service
97
109
  comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
98
- pointed at that address, and the API key to paste into the client is printed in
99
- the same window. `--no-client` leaves the client out. Ctrl+C stops everything,
110
+ pointed at that address with the API key already in its box, and the key is
111
+ printed in the same window for anything else that needs it. `--no-client` leaves the client out. Ctrl+C stops everything,
100
112
  and closing the client leaves the service running.
101
113
 
102
114
  Both editors carry it as a launch configuration named "readerboard against the
@@ -169,6 +181,19 @@ again after pulling a new version: your config file and key are left alone.
169
181
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
170
182
  remove those too.
171
183
 
184
+ The unit restarts the service whenever it stops, and gives up after ten failed starts in
185
+ five minutes. Almost nothing here fails permanently, which is what makes the exceptions
186
+ worth stopping for: a memory pool too big for the sign fails identically every time, and
187
+ each attempt puts a read on the wire that stalls a scrolling message. `systemctl status
188
+ readerboard` says which failure it was. Once the configuration is fixed, clearing the
189
+ give-up and starting it again are two commands, because `reset-failed` clears the counter
190
+ and leaves the unit stopped:
191
+
192
+ ```
193
+ sudo systemctl reset-failed readerboard
194
+ sudo systemctl start readerboard
195
+ ```
196
+
172
197
  ### With Docker
173
198
 
174
199
  The image is published to both registries on every release, for `linux/amd64`,
@@ -213,7 +238,7 @@ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, th
213
238
  Register a message:
214
239
 
215
240
  ```
216
- curl -X PUT http://localhost:5001/messages/temperature \
241
+ curl -X PUT http://localhost:5001/slots/temperature \
217
242
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
218
243
  -d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
219
244
  ```
@@ -221,11 +246,70 @@ curl -X PUT http://localhost:5001/messages/temperature \
221
246
  Register a second one and the sign rotates between them:
222
247
 
223
248
  ```
224
- curl -X PUT http://localhost:5001/messages/doorbell \
249
+ curl -X PUT http://localhost:5001/slots/doorbell \
225
250
  -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
226
251
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
227
252
  ```
228
253
 
254
+ Take a message off the display without giving up its slot, and put it back later:
255
+
256
+ ```
257
+ curl -X PUT http://localhost:5001/slots/doorbell/active \
258
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
259
+ -d '{"active": false}'
260
+ ```
261
+
262
+ A hidden message keeps its slot, its place in the order and its text, so showing it again
263
+ takes `{"active": true}` and no copy of what it said. Hiding or showing one is a single run
264
+ sequence write. That does disturb the display briefly, but far less than rewriting a
265
+ message does: enough less that it is easy to miss unless you are watching for it on a
266
+ static screen. The exception is the last message showing: its file is emptied as it goes,
267
+ because a sign whose run sequence names nothing freezes on what it was drawing, so
268
+ putting that one back costs the text as well as the sequence.
269
+
270
+ `active` is a field on the message endpoint too, where leaving it out is the point: a
271
+ source re-sending the same content every few minutes says nothing about it and so cannot
272
+ switch back on something that was deliberately hidden. Sending it moves the message.
273
+
274
+ A `ttl_seconds` can hide a message instead of deleting it, which suits anything that comes
275
+ back later, such as a bin day or a school notice:
276
+
277
+ ```
278
+ curl -X PUT http://localhost:5001/slots/bins \
279
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
280
+ -d '{"message": "<green>BINS OUT TONIGHT", "ttl_seconds": 43200,
281
+ "delete_on_expiry": false}'
282
+ ```
283
+
284
+ Put those together and a recurring notification is one call per event, sent in the same
285
+ shape every time. This shows the alarm's state for a minute whenever it changes, then takes
286
+ it off the rotation until the next one:
287
+
288
+ ```
289
+ curl -X PUT http://localhost:5001/slots/alarm \
290
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
291
+ -d '{"message": "<red>ALARM NOW <var:arm_state>", "ttl_seconds": 60,
292
+ "delete_on_expiry": false, "active": true}'
293
+ ```
294
+
295
+ The deadline clears itself as it hides the message, so each event gets a fresh minute
296
+ rather than the message vanishing again on a deadline the last one left behind.
297
+
298
+ Show a live value. Create the variable first, then a message that calls it:
299
+
300
+ ```
301
+ curl -X PUT http://localhost:5001/variables/temp \
302
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
303
+ -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
304
+
305
+ curl -X PUT http://localhost:5001/slots/weather \
306
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
307
+ -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
308
+ ```
309
+
310
+ From then on only the variable needs writing, and the message picks each new value up
311
+ on its next pass across the sign.
312
+
229
313
  Take the sign over for thirty seconds:
230
314
 
231
315
  ```
@@ -252,8 +336,8 @@ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back o
252
336
  lives on the sign and survives a restart, so it is also the first thing to check if
253
337
  `SOUND` ever seems to do nothing.
254
338
 
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
339
+ The full API is at `/docs`. Every markup token, value token, display mode and
340
+ control command is listed by the `/enumerations` reads there, which answer at
257
341
  request time rather than being frozen into the description.
258
342
 
259
343
  ### Writing messages
@@ -267,6 +351,93 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
267
351
  unknown token: a write is told what the sign would have made of it rather than being
268
352
  shown something it did not ask for.
269
353
 
354
+ ### Live values: variables
355
+
356
+ A variable lives in a small file of its own on the sign, and a message or an alert
357
+ calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
358
+ which the sign takes without blanking, so a message showing a temperature or a count can
359
+ change every minute and never restart. In a scrolling mode the new value appears on the
360
+ message's next pass. An alert carrying a live wind speed works the same way, and keeps
361
+ the sign while the number changes.
362
+
363
+ A few things are worth knowing, all of them measured on the sign:
364
+
365
+ - **Create the variable before a message calls it.** A message or an alert naming a
366
+ variable that does not exist is refused with a 400, and a variable that a message or
367
+ the alert still calls cannot be deleted: that is a 409 naming what calls it.
368
+ - **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
369
+ of the message red as well, and so does a character set or a speed. If the text after
370
+ the call matters, set it again in the message after the call.
371
+ - **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
372
+ `9` from inside a variable, and a variable cannot call another. `GET
373
+ /enumerations/value-tokens` lists what is allowed.
374
+ - **Keep a changing number from pushing the text around it.** With the sign's usual
375
+ proportional spacing, `11` and `88` are different widths and the text after them moves.
376
+ Put `<fixed_width>` in the message before the call and send values of the same length.
377
+ Fixed width also left-justifies the line.
378
+ - **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
379
+ such as `--`, and once the time passes without a new value the sign shows the stale
380
+ value instead. The variable itself stays, since messages call it.
381
+ - **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
382
+ rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
383
+ so the service refuses one rather than send it.
384
+
385
+ A Home Assistant `rest_command` that keeps a sensor on the sign:
386
+
387
+ ```yaml
388
+ rest_command:
389
+ sign_temperature:
390
+ url: http://readerboard.local:5001/variables/temp
391
+ method: put
392
+ headers:
393
+ X-API-Key: !secret readerboard_key
394
+ content_type: application/json
395
+ payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
396
+ ```
397
+
398
+ Call it from an automation triggered by the sensor, with the reading as `value`:
399
+
400
+ ```yaml
401
+ actions:
402
+ - action: rest_command.sign_temperature
403
+ data:
404
+ value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
405
+ ```
406
+
407
+ ### Icons
408
+
409
+ A message can draw one of 148 built-in bitmaps where a tag sits. `<icon:sun> FINE`
410
+ puts a sun in front of the word, and `<icon:lock:red>` retints an icon that is drawn
411
+ in a single ink; the colour words are the colour tokens' own, down to `dimred` and
412
+ `dimgreen`, so a message that can say `<red>` needs no second spelling. An icon
413
+ drawn in its own colours, such as the sun, takes no tint and asking for one is
414
+ refused rather than ignored. `GET /enumerations/icons` lists every icon with its
415
+ group, its width and whether it takes a tint.
416
+
417
+ Icons are off until `picture_count` is set, because each one on the sign needs a
418
+ picture file of its own and allocating those reallocates the sign's memory, which
419
+ erases every message on it. Set it once, alongside the other pool settings, and
420
+ 16 is a comfortable number.
421
+
422
+ Four things are worth knowing, and the first is the one that decides how to use them:
423
+
424
+ - **An icon is not a live value.** Every write to a picture file blanks the display
425
+ and restarts a scrolling message. A weather slot stepping from sun to cloud to rain
426
+ pays that each time it lands on an icon the sign is not already holding. Something
427
+ that changes every minute belongs in a variable, which costs no blank at all.
428
+ - **The pool is smaller than the library, and that is the design.** A picture file is
429
+ claimed by whichever icon a message calls, and kept after its last caller goes, so a
430
+ source alternating between two icons costs nothing after the first write. When every
431
+ file is holding an icon something still calls and a new one is asked for, the write
432
+ is refused with a 409. `GET /health` reports pictures used against pictures total,
433
+ and a full pool is the resting state rather than a warning.
434
+ - **A tint makes a second picture.** `<icon:check:green>` and `<icon:check:red>` are
435
+ two bitmaps and take two files.
436
+ - **An icon can be parted from its word.** A line too wide for the display breaks onto
437
+ a second page in HOLD, and the last word can arrive there without the icon labelling
438
+ it. The service cannot warn about this: it would have to know the width of the sign's
439
+ proportional font.
440
+
270
441
  ### Recovering a sign that has stopped responding
271
442
 
272
443
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -295,10 +466,10 @@ from the service's own record, so the display still comes back to what it was.
295
466
  curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
296
467
  ```
297
468
 
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.
469
+ Reach for it only when a soft reset was not enough. The sign is blank for twelve seconds
470
+ or more while it resets, longer with a lot of messages to put back. Neither is a way to
471
+ clear messages: `DELETE /slots` does that without resetting anything. The client
472
+ fronts the reboot with a warning-coloured confirmation for the same reason.
302
473
 
303
474
  ## Configuration
304
475
 
@@ -317,9 +488,32 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
317
488
  and the port, and pyserial's answer to one that has a slash names neither the setting
318
489
  nor the value.
319
490
 
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.
491
+ Five settings reallocate the sign's memory when changed, and **that erases every message
492
+ on it**: `slot_count`, `slot_capacity`, `variable_count`, `variable_capacity` and
493
+ `picture_count`. The service will do it, and say so loudly in the log, but they are not
494
+ settings to fiddle with. With `variable_count` at 0, `variable_capacity` allocates
495
+ nothing, so changing it alone reallocates nothing either. `picture_count` starts at 0,
496
+ which switches icons off, so turning them on is one deliberate erase.
497
+
498
+ All five come out of one memory pool, which a BetaBrite Classic reported as 5482 bytes,
499
+ and each file costs thirteen bytes beyond its own size. The defaults take 2518 of that,
500
+ and each icon takes 69 on top, so the defaults with sixteen icons take 3622.
501
+
502
+ That 5482 is a ceiling, and it is checked in two places that do different jobs. A
503
+ configuration bigger than it is refused when the settings are read, on any machine,
504
+ whether or not a sign is attached; that is the ceiling, and no sign can raise it. Then,
505
+ on a start that is about to reallocate the sign's memory and only then, the service asks
506
+ the sign for its own figure, and a sign reporting less than 5482 is believed. So the
507
+ second check can lower the limit and never raise it. A sign with a bigger pool than this
508
+ hardware's would need `ASSUMED_SIGN_MEMORY_POOL` in `readerboard/sign/pool.py` raised
509
+ before it could use the extra.
510
+
511
+ A sign that answers and does not have the room stops the service starting, with a message
512
+ naming what was configured, what it needs and what there is; that is the one failure that
513
+ does stop it, because the alternative is erasing every message on the sign to write a pool
514
+ that could never work. A sign that says nothing does not stop anything. `POST /sign/reboot`
515
+ asks the same question before it clears the sign, and answers 409 rather than erasing it,
516
+ except when the sign is too wedged to answer, which is the case that endpoint exists for.
323
517
 
324
518
  ## Security
325
519
 
@@ -400,8 +594,9 @@ each is pinned separately so that nothing the service installs ever pulls Qt in.
400
594
  with `--with-client` the client as well, so the whole loop comes up from one command.
401
595
  Both editors carry it as a launch configuration under the same name, "readerboard and
402
596
  the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`, beside
403
- configurations for running the pieces separately. Both carry the three way one as
404
- "readerboard, the sign simulator and the client" as well.
597
+ configurations for running the pieces separately, the client among them as
598
+ "readerboard client". Both carry the three way one as "readerboard, the sign simulator
599
+ and the client" as well.
405
600
 
406
601
  `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
407
602
  service and the client, no simulator, and the sign's address passed as an argument so