readerboard 0.6.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 (70) hide show
  1. {readerboard-0.6.0/readerboard.egg-info → readerboard-0.7.0}/PKG-INFO +82 -11
  2. {readerboard-0.6.0 → readerboard-0.7.0}/README.md +80 -9
  3. {readerboard-0.6.0 → readerboard-0.7.0}/pyproject.toml +11 -2
  4. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/app.py +79 -15
  6. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/errors.py +24 -1
  7. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/models.py +47 -2
  8. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/routes.py +65 -6
  9. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/config.py +58 -21
  10. readerboard-0.7.0/readerboard/icons.py +520 -0
  11. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/constants.py +76 -0
  12. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/frames.py +224 -6
  13. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/markup.py +116 -10
  14. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/replies.py +82 -4
  15. readerboard-0.7.0/readerboard/services/alerts.py +647 -0
  16. readerboard-0.7.0/readerboard/services/registry.py +1834 -0
  17. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/controller.py +31 -2
  18. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/layout.py +73 -14
  19. readerboard-0.7.0/readerboard/sign/pool.py +165 -0
  20. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/state.py +83 -9
  21. {readerboard-0.6.0 → readerboard-0.7.0/readerboard.egg-info}/PKG-INFO +82 -11
  22. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/SOURCES.txt +5 -0
  23. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/requires.txt +1 -1
  24. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_alerts.py +52 -1
  25. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_api.py +263 -10
  26. readerboard-0.7.0/tests/test_config.py +107 -0
  27. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_constant_values.py +85 -0
  28. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_frames.py +163 -3
  29. readerboard-0.7.0/tests/test_icons.py +141 -0
  30. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_markup.py +86 -0
  31. readerboard-0.7.0/tests/test_pictures.py +1671 -0
  32. readerboard-0.7.0/tests/test_pool.py +192 -0
  33. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_registry.py +141 -4
  34. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_run_against_a_sign.py +102 -0
  35. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_run_with_simulator.py +47 -3
  36. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_state.py +13 -0
  37. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_variables.py +9 -4
  38. readerboard-0.6.0/readerboard/services/alerts.py +0 -247
  39. readerboard-0.6.0/readerboard/services/registry.py +0 -952
  40. readerboard-0.6.0/tests/test_config.py +0 -47
  41. {readerboard-0.6.0 → readerboard-0.7.0}/LICENSE +0 -0
  42. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/__main__.py +0 -0
  43. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/__init__.py +0 -0
  44. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/deps.py +0 -0
  45. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/logging_setup.py +0 -0
  46. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/names.py +0 -0
  47. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/__init__.py +0 -0
  48. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/tokens.py +0 -0
  49. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/py.typed +0 -0
  50. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/services/__init__.py +0 -0
  51. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/services/clock.py +0 -0
  52. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/services/commands.py +0 -0
  53. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/__init__.py +0 -0
  54. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/__init__.py +0 -0
  55. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/base.py +0 -0
  56. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/fake.py +0 -0
  57. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/serial_link.py +0 -0
  58. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/dependency_links.txt +0 -0
  59. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/entry_points.txt +0 -0
  60. {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/top_level.txt +0 -0
  61. {readerboard-0.6.0 → readerboard-0.7.0}/setup.cfg +0 -0
  62. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_clock.py +0 -0
  63. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_component_names.py +0 -0
  64. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_controller.py +0 -0
  65. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_launch_configurations.py +0 -0
  66. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_logging_setup.py +0 -0
  67. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_open_docs.py +0 -0
  68. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_replies.py +0 -0
  69. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_tool_icons.py +0 -0
  70. {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.6.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
@@ -73,6 +73,8 @@ the rotation resumes.
73
73
  time it draws the message, with no blank and no restart. One variable can appear in
74
74
  any number of messages, and a value that stops arriving can be made to go stale.
75
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.
76
78
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
77
79
  back. That last trigger is the one that matters: a sign returning from a power cut
78
80
  does so at no particular minute. The sign is set one minute fast on purpose: the
@@ -149,8 +151,8 @@ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
149
151
 
150
152
  That starts the service and the client together, with no simulator. The service
151
153
  comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
152
- pointed at that address, and the API key to paste into the client is printed in
153
- 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,
154
156
  and closing the client leaves the service running.
155
157
 
156
158
  Both editors carry it as a launch configuration named "readerboard against the
@@ -223,6 +225,19 @@ again after pulling a new version: your config file and key are left alone.
223
225
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
224
226
  remove those too.
225
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
+
226
241
  ### With Docker
227
242
 
228
243
  The image is published to both registries on every release, for `linux/amd64`,
@@ -433,6 +448,40 @@ actions:
433
448
  value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
434
449
  ```
435
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
+
436
485
  ### Recovering a sign that has stopped responding
437
486
 
438
487
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -483,11 +532,32 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
483
532
  and the port, and pyserial's answer to one that has a slash names neither the setting
484
533
  nor the value.
485
534
 
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.
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.
491
561
 
492
562
  ## Security
493
563
 
@@ -568,8 +638,9 @@ each is pinned separately so that nothing the service installs ever pulls Qt in.
568
638
  with `--with-client` the client as well, so the whole loop comes up from one command.
569
639
  Both editors carry it as a launch configuration under the same name, "readerboard and
570
640
  the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`, beside
571
- configurations for running the pieces separately. Both carry the three way one as
572
- "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.
573
644
 
574
645
  `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
575
646
  service and the client, no simulator, and the sign's address passed as an argument so
@@ -29,6 +29,8 @@ the rotation resumes.
29
29
  time it draws the message, with no blank and no restart. One variable can appear in
30
30
  any number of messages, and a value that stops arriving can be made to go stale.
31
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.
32
34
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
33
35
  back. That last trigger is the one that matters: a sign returning from a power cut
34
36
  does so at no particular minute. The sign is set one minute fast on purpose: the
@@ -105,8 +107,8 @@ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
105
107
 
106
108
  That starts the service and the client together, with no simulator. The service
107
109
  comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
108
- pointed at that address, and the API key to paste into the client is printed in
109
- 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,
110
112
  and closing the client leaves the service running.
111
113
 
112
114
  Both editors carry it as a launch configuration named "readerboard against the
@@ -179,6 +181,19 @@ again after pulling a new version: your config file and key are left alone.
179
181
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
180
182
  remove those too.
181
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
+
182
197
  ### With Docker
183
198
 
184
199
  The image is published to both registries on every release, for `linux/amd64`,
@@ -389,6 +404,40 @@ actions:
389
404
  value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
390
405
  ```
391
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
+
392
441
  ### Recovering a sign that has stopped responding
393
442
 
394
443
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -439,11 +488,32 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
439
488
  and the port, and pyserial's answer to one that has a slash names neither the setting
440
489
  nor the value.
441
490
 
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.
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.
447
517
 
448
518
  ## Security
449
519
 
@@ -524,8 +594,9 @@ each is pinned separately so that nothing the service installs ever pulls Qt in.
524
594
  with `--with-client` the client as well, so the whole loop comes up from one command.
525
595
  Both editors carry it as a launch configuration under the same name, "readerboard and
526
596
  the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`, beside
527
- configurations for running the pieces separately. Both carry the three way one as
528
- "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.
529
600
 
530
601
  `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
531
602
  service and the client, no simulator, and the sign's address passed as an argument so
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readerboard"
7
- version = "0.6.0"
7
+ version = "0.7.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"
@@ -52,7 +52,7 @@ dependencies = [
52
52
  # watchfiles, which only makes --reload quicker. That flag is for working on
53
53
  # the service rather than for running one, and uvicorn polls the tree
54
54
  # without it.
55
- "uvicorn>=0.52.1,<0.53",
55
+ "uvicorn>=0.52.1,<0.54",
56
56
  "pydantic>=2.13.4,<3",
57
57
  "pydantic-settings>=2.15.0,<3",
58
58
  "pyserial>=3.5,<4",
@@ -175,6 +175,15 @@ files = ["readerboard"]
175
175
  strict = true
176
176
  warn_unreachable = true
177
177
  enable_error_code = ["redundant-expr", "truthy-bool", "ignore-without-code"]
178
+ # `files` above still names only the service, so checking a tool is a separate
179
+ # invocation, `mypy tools/signsim/signsim` or `mypy tools/apiclient/apiclient`.
180
+ # Naming both tool roots here means one of those resolves the tool's own
181
+ # package (`import signsim`, `import apiclient`) on its own, no MYPYPATH
182
+ # needed by hand or in CI. The directory-level invocations above already found
183
+ # their root by crawling up from the package's own `__init__.py`; what this
184
+ # actually buys is the narrower case that crawl finds nothing for, such as
185
+ # `mypy` on a single file under a tool's `tests/`, which has none.
186
+ mypy_path = ["tools/signsim", "tools/apiclient"]
178
187
 
179
188
  [[tool.mypy.overrides]]
180
189
  # pyserial ships no type information.
@@ -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.6.0"
10
+ __version__ = "0.7.0"
@@ -9,12 +9,18 @@ Nothing here fails to start because the sign is unreachable. A service that
9
9
  refused to boot with the sign unplugged would need someone to notice and restart
10
10
  it once the sign came back, which is precisely the situation it exists to
11
11
  survive.
12
+
13
+ There is one thing that does stop it, and it is not that. A sign that is
14
+ answering and has less memory than the configured pool needs is a configuration
15
+ somebody has to change, and going ahead would erase every message on the sign to
16
+ write a pool that cannot work. See readerboard.sign.pool.
12
17
  """
13
18
 
14
19
  from __future__ import annotations
15
20
 
16
21
  import asyncio
17
22
  import contextlib
23
+ import functools
18
24
  import logging
19
25
  from collections.abc import AsyncIterator, Awaitable, Callable
20
26
 
@@ -29,6 +35,7 @@ from readerboard.config import Settings
29
35
  from readerboard.services.alerts import AlertService
30
36
  from readerboard.services.clock import ClockService
31
37
  from readerboard.services.registry import SlotRegistry
38
+ from readerboard.sign import pool
32
39
  from readerboard.sign.controller import SignController
33
40
  from readerboard.sign.layout import Layout
34
41
  from readerboard.sign.state import StateStore
@@ -51,6 +58,13 @@ A **variable** is a value a slot's message or an alert calls with `<var:name>`.
51
58
  Changing it rewrites only the variable, so the sign shows the new value without
52
59
  blanking or restarting what calls it.
53
60
 
61
+ An **icon** is one of the built-in bitmaps, drawn where `<icon:name>` sits in a
62
+ message or an alert. `GET /enumerations/icons` lists them. There is a fixed pool
63
+ of picture files on the sign and it is far smaller than the library, so a file is
64
+ claimed by whichever icon a message calls and kept until another icon needs it.
65
+ Icons are off until `picture_count` is raised, because raising it reallocates the
66
+ sign's memory and clears it.
67
+
54
68
  Every write needs an `X-API-Key` header, and so does `GET /sign/information`,
55
69
  which reads the sign rather than the service: it puts a question on the wire and
56
70
  holds the sign until the answer comes back. The service's own reads and
@@ -59,14 +73,17 @@ holds the sign until the answer comes back. The service's own reads and
59
73
 
60
74
  A failure is reported by the status code, with the reason in a `detail` field:
61
75
  400 for a command the sign does not have, a parameter it will not accept, a
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.
76
+ message or value too long for its file, markup the sign cannot render, a call to
77
+ a variable that does not exist, an icon nobody has, a colour asked for on an icon
78
+ drawn in fixed colours, or an icon called while icons are switched off, 401 for a
79
+ missing or wrong `X-API-Key`, 404 for a slot or variable that does not exist, 409
80
+ when every slot, every variable or every picture file is already in use, a
81
+ variable something still calls is deleted, or an alert is raised with
82
+ `fail_if_active` while one is already up, 503 when the sign is unreachable,
83
+ stops partway through an answer or answers with something the service cannot
84
+ read, or no API key is configured at all, 500 for something the service has no
85
+ code for, and 422 for a body that is not the shape the endpoint declares, which
86
+ includes a display mode the sign does not have.
70
87
  """
71
88
 
72
89
 
@@ -81,6 +98,26 @@ def build_transport(settings: Settings) -> Transport:
81
98
  )
82
99
 
83
100
 
101
+ async def _refresh_and_reassert(registry: SlotRegistry, alerts: AlertService) -> None:
102
+ """Push everything to the sign again, and make sure any alert is back on it.
103
+
104
+ Both of the places that re-push go through here, the timer below and the
105
+ reconnect hook, because both are answering the same question: the sign may
106
+ have been power cycled and nothing the controller believes about it holds.
107
+
108
+ The refresh puts the slots back, and it puts the alert back too when it had
109
+ to hand the sign over to write a picture. It says which, and that is why
110
+ this asks rather than always re-asserting: re-asserting an alert that is
111
+ already back on the priority file restarts it on the display for nothing,
112
+ which is a visible flicker every refresh interval for as long as the alert
113
+ is up. In the case where the refresh did not touch the priority file, which
114
+ is a service with no icons, the re-assert is the only thing that would put
115
+ the alert on a sign that came back blank.
116
+ """
117
+ if not await registry.refresh():
118
+ await alerts.reassert()
119
+
120
+
84
121
  async def _refresh_loop(app: FastAPI, interval: float) -> None:
85
122
  """Push everything to the sign again, periodically.
86
123
 
@@ -93,12 +130,7 @@ async def _refresh_loop(app: FastAPI, interval: float) -> None:
93
130
  while True:
94
131
  await asyncio.sleep(interval)
95
132
  try:
96
- await registry.refresh()
97
- # The refresh puts the slots back, but an alert lives in the
98
- # priority file, which the registry does not touch. Without this a
99
- # sign power cycled mid-alert would stay blank until the alert's
100
- # deadline, and an alert with no deadline would stay blank for good.
101
- await alerts.reassert()
133
+ await _refresh_and_reassert(registry, alerts)
102
134
  except TransportError as err:
103
135
  logger.debug("periodic refresh skipped, sign unreachable: %s", err)
104
136
  except Exception:
@@ -149,12 +181,19 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
149
181
  settings.slot_capacity,
150
182
  settings.variable_count,
151
183
  settings.variable_capacity,
184
+ settings.picture_count,
152
185
  )
153
186
  alerts = AlertService(controller, store, state)
154
187
  registry = SlotRegistry(controller, layout, store, state)
155
188
  # An alert calling a variable is rendered by the registry, under its
156
189
  # lock, so the variable cannot be deleted while the alert calls it.
157
190
  alerts.set_rendering(registry.rendering)
191
+ # And the other way: the registry hands the sign back before it writes a
192
+ # picture, because the sign will not take one while a priority message
193
+ # is running. Both directions are wired here rather than either service
194
+ # reaching for the other, and the lock order is what the two of them
195
+ # have to agree on: the registry's first, then this one's.
196
+ registry.set_priority_hold(alerts.lifted)
158
197
  clock = ClockService(
159
198
  controller,
160
199
  interval_seconds=settings.clock_sync_interval_seconds,
@@ -169,6 +208,23 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
169
208
 
170
209
  await controller.start()
171
210
 
211
+ # Only when a reconfiguration is due, which is the one moment the pool
212
+ # can be got wrong and the one moment the sign is about to be erased. An
213
+ # ordinary restart asks the sign nothing, costs the display nothing, and
214
+ # behaves exactly as it did before this check existed. Nothing here can
215
+ # bring on a reallocation either: the only thing it can do is stop one.
216
+ if layout.needs_reconfiguration(state.layout):
217
+ budget = await pool.measure(controller, fallback=pool.ASSUMED_SIGN_MEMORY_POOL)
218
+ try:
219
+ pool.check_fits(layout, budget)
220
+ except pool.PoolTooLarge as err:
221
+ # Logged as well as raised, because the traceback uvicorn prints
222
+ # around a failed startup buries the one sentence that says what
223
+ # to do about it.
224
+ logger.error("refusing to start: %s", err)
225
+ await controller.stop()
226
+ raise
227
+
172
228
  # The sign may be unreachable, and that is not a reason to refuse to
173
229
  # start. What is put back below happens again on the next reconnect.
174
230
  try:
@@ -201,7 +257,12 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
201
257
  controller.on_reconnect(clock.sync_quietly)
202
258
  # A link that just came back may be in front of a sign that was power
203
259
  # cycled, so nothing the controller believes about its contents holds.
204
- controller.on_reconnect(registry.refresh)
260
+ # The alert goes back on with everything else: it was only the slots
261
+ # before, so a reconnect to a sign that had lost an alert left the
262
+ # priority file empty until the next tick of the timer above.
263
+ controller.on_reconnect(
264
+ functools.partial(_refresh_and_reassert, registry, alerts)
265
+ )
205
266
 
206
267
  if settings.clock_sync_enabled:
207
268
  await clock.start()
@@ -251,6 +312,7 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
251
312
 
252
313
  used, total = registry.occupancy
253
314
  variables_used, variables_total = registry.variable_occupancy
315
+ pictures_used, pictures_total = registry.picture_occupancy
254
316
  return HealthResponse(
255
317
  status="ok" if controller.is_connected else "degraded",
256
318
  version=__version__,
@@ -266,6 +328,8 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
266
328
  slots_total=total,
267
329
  variables_used=variables_used,
268
330
  variables_total=variables_total,
331
+ pictures_used=pictures_used,
332
+ pictures_total=pictures_total,
269
333
  sign_in_sync=registry.in_sync,
270
334
  alert_active=alerts.active is not None,
271
335
  clock_last_synced_at=clock.last_sync_at,
@@ -13,13 +13,16 @@ from __future__ import annotations
13
13
 
14
14
  from fastapi import status
15
15
 
16
+ from readerboard.icons import IconError
16
17
  from readerboard.protocol.frames import ProtocolError
17
18
  from readerboard.protocol.markup import MarkupError
18
19
  from readerboard.protocol.replies import ReplyError
19
20
  from readerboard.services import commands
20
- from readerboard.services.alerts import AlertTooLong
21
+ from readerboard.services.alerts import AlertAlreadyActive, AlertTooLong
21
22
  from readerboard.services.registry import (
23
+ IconsDisabled,
22
24
  MessageTooLong,
25
+ PicturePoolFull,
23
26
  UnknownSlot,
24
27
  UnknownVariable,
25
28
  VariableInUse,
@@ -27,6 +30,7 @@ from readerboard.services.registry import (
27
30
  VariableTooLong,
28
31
  )
29
32
  from readerboard.sign.layout import LayoutFull
33
+ from readerboard.sign.pool import PoolTooLarge
30
34
  from readerboard.transport.base import TransportError
31
35
 
32
36
  STATUS_FOR_ERROR: tuple[tuple[type[Exception], int], ...] = (
@@ -35,16 +39,35 @@ STATUS_FOR_ERROR: tuple[tuple[type[Exception], int], ...] = (
35
39
  (MessageTooLong, status.HTTP_400_BAD_REQUEST),
36
40
  (VariableTooLong, status.HTTP_400_BAD_REQUEST),
37
41
  (VariablesDisabled, status.HTTP_400_BAD_REQUEST),
42
+ (IconsDisabled, status.HTTP_400_BAD_REQUEST),
43
+ # An icon nobody has, or a tint on one drawn in fixed colours. The library
44
+ # raises these rather than the renderer, because the renderer is not told
45
+ # which icons exist; mapping the one base they share is what keeps a
46
+ # misspelled icon name a 400 rather than a 500.
47
+ (IconError, status.HTTP_400_BAD_REQUEST),
38
48
  (AlertTooLong, status.HTTP_400_BAD_REQUEST),
39
49
  (commands.UnknownCommand, status.HTTP_400_BAD_REQUEST),
40
50
  (commands.BadParameter, status.HTTP_400_BAD_REQUEST),
41
51
  (UnknownSlot, status.HTTP_404_NOT_FOUND),
42
52
  (UnknownVariable, status.HTTP_404_NOT_FOUND),
43
53
  (LayoutFull, status.HTTP_409_CONFLICT),
54
+ # Only POST /sign/reboot can raise this: the sign has less memory than the
55
+ # configuration needs, found by asking it just before the reboot would have
56
+ # erased it. The state of the hardware rather than anything wrong with the
57
+ # request, which is what makes it a conflict, and nothing was written.
58
+ (PoolTooLarge, status.HTTP_409_CONFLICT),
44
59
  # Deleting a variable a message still calls would leave that message
45
60
  # calling a file the next variable could be given. Not the caller's
46
61
  # request being malformed, which is what makes it a conflict.
47
62
  (VariableInUse, status.HTTP_409_CONFLICT),
63
+ # Every picture file is holding an icon something still calls. Like a full
64
+ # slot pool and unlike a bad icon name, this is the state of the sign rather
65
+ # than anything wrong with the request.
66
+ (PicturePoolFull, status.HTTP_409_CONFLICT),
67
+ # An alert is already up and the caller asked to be refused rather than
68
+ # replace it. The same request would have been accepted a moment earlier, so
69
+ # it is the state of the sign that decides this and not the body.
70
+ (AlertAlreadyActive, status.HTTP_409_CONFLICT),
48
71
  (TransportError, status.HTTP_503_SERVICE_UNAVAILABLE),
49
72
  # A sign that answers with something unreadable is as unusable as one
50
73
  # that does not answer, and neither is the caller's doing. The route
@@ -61,7 +61,8 @@ class SlotRequest(BaseModel):
61
61
  min_length=1,
62
62
  max_length=4096,
63
63
  description=(
64
- "the message, including markup tokens such as <red> and <degree>, and "
64
+ "the message, including markup tokens such as <red> and <degree>, "
65
+ "<icon:name> to draw one of the built-in icons, and "
65
66
  "<var:name> to call a variable, which has to exist first. It cannot be "
66
67
  "empty: an empty message holds a slot open around nothing, and the sign "
67
68
  "gives a file with no text in it no turn of its own but does hold the "
@@ -293,7 +294,8 @@ class AlertRequest(BaseModel):
293
294
  description=(
294
295
  "the alert text. The sign's priority file holds 125 bytes once markup has "
295
296
  "been rendered, and cannot be resized. It can call variables with "
296
- "<var:name>, as a message can. It cannot be empty: a write with no "
297
+ "<var:name> and draw icons with <icon:name>, as a message can. An icon "
298
+ "costs the alert two of those 125 bytes. It cannot be empty: a write with no "
297
299
  "text still carries the formatting bytes around it, which the sign reads as "
298
300
  "a blank priority message and displays, so the sign would sit blank with "
299
301
  "the rotation suppressed behind it and an alert reported as active"
@@ -308,6 +310,16 @@ class AlertRequest(BaseModel):
308
310
  "sign until something releases it explicitly"
309
311
  ),
310
312
  )
313
+ fail_if_active: bool = Field(
314
+ default=False,
315
+ description=(
316
+ "refuse with a 409 if an alert is already holding the sign, rather than "
317
+ "replacing it. For a caller that is one of several raising alerts and has "
318
+ "no business overwriting somebody else's alert. An alert whose deadline has "
319
+ "already passed does not count as holding the sign. Release the alert that "
320
+ "is up, or send the same call without this, to get past a refusal"
321
+ ),
322
+ )
311
323
 
312
324
  _check_mode = field_validator("display_mode")(_normalise_mode)
313
325
 
@@ -376,6 +388,14 @@ class HealthResponse(BaseModel):
376
388
  slots_total: int
377
389
  variables_used: int
378
390
  variables_total: int = Field(description="0 when variables are switched off")
391
+ pictures_used: int = Field(
392
+ description=(
393
+ "picture files holding an icon, which includes ones nothing calls any more: "
394
+ "a file is kept after its last caller goes, and given up only when another "
395
+ "icon needs it"
396
+ )
397
+ )
398
+ pictures_total: int = Field(description="0 when icons are switched off")
379
399
  sign_in_sync: bool = Field(
380
400
  description=(
381
401
  "false when the sign is behind the service's record, which is a removal or "
@@ -392,3 +412,28 @@ class TokenInfo(BaseModel):
392
412
 
393
413
  name: str
394
414
  description: str
415
+
416
+
417
+ class IconInfo(BaseModel):
418
+ """One icon from the built-in library.
419
+
420
+ ``name`` is the whole tag rather than the bare icon name, which is what
421
+ every other enumeration answers too: the caller writes what it is given,
422
+ and a client that had to wrap the answer in ``<icon:`` first would be a
423
+ client that knows a piece of syntax nobody told it.
424
+
425
+ The three fields after the description are the same facts in a form
426
+ something can sort or filter on without reading prose.
427
+ """
428
+
429
+ name: str
430
+ description: str
431
+ group: str = Field(description="what it is listed with, such as 'weather' or 'arrows'")
432
+ width: int = Field(description="how many dots wide it is drawn; every icon is 7 high")
433
+ tintable: bool = Field(
434
+ description=(
435
+ "whether a colour may be asked for, as <icon:check:red>. False for an icon "
436
+ "whose own colours are the point of it, and asking for one anyway is refused "
437
+ "rather than ignored"
438
+ )
439
+ )