readerboard 0.6.0__tar.gz → 0.7.1__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.1}/PKG-INFO +99 -12
  2. {readerboard-0.6.0 → readerboard-0.7.1}/README.md +96 -9
  3. {readerboard-0.6.0 → readerboard-0.7.1}/pyproject.toml +17 -3
  4. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/__init__.py +1 -1
  5. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/api/app.py +79 -15
  6. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/api/errors.py +24 -1
  7. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/api/models.py +47 -2
  8. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/api/routes.py +65 -6
  9. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/config.py +58 -21
  10. readerboard-0.7.1/readerboard/icons.py +520 -0
  11. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/protocol/constants.py +76 -0
  12. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/protocol/frames.py +224 -6
  13. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/protocol/markup.py +116 -10
  14. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/protocol/replies.py +82 -4
  15. readerboard-0.7.1/readerboard/services/alerts.py +647 -0
  16. readerboard-0.7.1/readerboard/services/registry.py +1834 -0
  17. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/sign/controller.py +31 -2
  18. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/sign/layout.py +73 -14
  19. readerboard-0.7.1/readerboard/sign/pool.py +165 -0
  20. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/sign/state.py +83 -9
  21. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/transport/serial_link.py +63 -0
  22. {readerboard-0.6.0 → readerboard-0.7.1/readerboard.egg-info}/PKG-INFO +99 -12
  23. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard.egg-info/SOURCES.txt +5 -0
  24. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard.egg-info/requires.txt +2 -2
  25. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_alerts.py +52 -1
  26. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_api.py +263 -10
  27. readerboard-0.7.1/tests/test_config.py +107 -0
  28. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_constant_values.py +85 -0
  29. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_frames.py +163 -3
  30. readerboard-0.7.1/tests/test_icons.py +141 -0
  31. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_markup.py +86 -0
  32. readerboard-0.7.1/tests/test_pictures.py +1671 -0
  33. readerboard-0.7.1/tests/test_pool.py +192 -0
  34. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_registry.py +141 -4
  35. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_run_against_a_sign.py +102 -0
  36. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_run_with_simulator.py +47 -3
  37. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_state.py +13 -0
  38. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_transport.py +101 -1
  39. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_variables.py +9 -4
  40. readerboard-0.6.0/readerboard/services/alerts.py +0 -247
  41. readerboard-0.6.0/readerboard/services/registry.py +0 -952
  42. readerboard-0.6.0/tests/test_config.py +0 -47
  43. {readerboard-0.6.0 → readerboard-0.7.1}/LICENSE +0 -0
  44. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/__main__.py +0 -0
  45. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/api/__init__.py +0 -0
  46. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/api/deps.py +0 -0
  47. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/logging_setup.py +0 -0
  48. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/names.py +0 -0
  49. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/protocol/__init__.py +0 -0
  50. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/protocol/tokens.py +0 -0
  51. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/py.typed +0 -0
  52. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/services/__init__.py +0 -0
  53. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/services/clock.py +0 -0
  54. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/services/commands.py +0 -0
  55. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/sign/__init__.py +0 -0
  56. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/transport/__init__.py +0 -0
  57. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/transport/base.py +0 -0
  58. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard/transport/fake.py +0 -0
  59. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard.egg-info/dependency_links.txt +0 -0
  60. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard.egg-info/entry_points.txt +0 -0
  61. {readerboard-0.6.0 → readerboard-0.7.1}/readerboard.egg-info/top_level.txt +0 -0
  62. {readerboard-0.6.0 → readerboard-0.7.1}/setup.cfg +0 -0
  63. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_clock.py +0 -0
  64. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_component_names.py +0 -0
  65. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_controller.py +0 -0
  66. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_launch_configurations.py +0 -0
  67. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_logging_setup.py +0 -0
  68. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_open_docs.py +0 -0
  69. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_replies.py +0 -0
  70. {readerboard-0.6.0 → readerboard-0.7.1}/tests/test_tool_icons.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.1
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,13 +28,13 @@ 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
35
35
  Requires-Dist: tzdata>=2026.3; sys_platform == "win32"
36
36
  Provides-Extra: dev
37
- Requires-Dist: pytest<10,>=9.1.1; extra == "dev"
37
+ Requires-Dist: pytest<9.2,>=9.1.1; extra == "dev"
38
38
  Requires-Dist: pytest-asyncio<2,>=1.4.0; extra == "dev"
39
39
  Requires-Dist: httpx2<3,>=2.10.0; extra == "dev"
40
40
  Requires-Dist: anyio<4.15,>=4.14.2; extra == "dev"
@@ -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
@@ -182,6 +184,22 @@ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
182
184
  probably negotiating telnet rather than passing bytes through, and `rfc2217://`
183
185
  is the form that speaks that.
184
186
 
187
+ An adapter that drops off the network says nothing as it goes. The service asks
188
+ the operating system to give a network link up about a minute after the adapter
189
+ stops answering: on Linux, a minute after the first write it did not
190
+ acknowledge, or, if nothing was written, about a minute after it last answered,
191
+ found by keepalive probes. A platform that does not take one of these options
192
+ keeps its own default for it, which can be much longer. The service itself only
193
+ finds out at its first read or write after the link has gone, and `GET /health`
194
+ shows `"connected": false` from then on. A sign with a variable updated every
195
+ minute is written to that often. One left alone is written to at the refresh,
196
+ every `refresh_interval_seconds`, which is 15 minutes by default, and a refresh
197
+ that lands before the link has gone is buffered like any other write, so on a
198
+ quiet sign `link.connected` can stay true for a refresh interval and a minute
199
+ after the adapter has gone. It is still worth a monitor watching, and needs no
200
+ key, but a ping of the adapter answers within a minute however quiet the sign
201
+ is, and is the better alarm.
202
+
185
203
  ### The API key, and config.local.toml
186
204
 
187
205
  The key is not an argument. A launch configuration is a tracked file and a
@@ -223,6 +241,19 @@ again after pulling a new version: your config file and key are left alone.
223
241
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
224
242
  remove those too.
225
243
 
244
+ The unit restarts the service whenever it stops, and gives up after ten failed starts in
245
+ five minutes. Almost nothing here fails permanently, which is what makes the exceptions
246
+ worth stopping for: a memory pool too big for the sign fails identically every time, and
247
+ each attempt puts a read on the wire that stalls a scrolling message. `systemctl status
248
+ readerboard` says which failure it was. Once the configuration is fixed, clearing the
249
+ give-up and starting it again are two commands, because `reset-failed` clears the counter
250
+ and leaves the unit stopped:
251
+
252
+ ```
253
+ sudo systemctl reset-failed readerboard
254
+ sudo systemctl start readerboard
255
+ ```
256
+
226
257
  ### With Docker
227
258
 
228
259
  The image is published to both registries on every release, for `linux/amd64`,
@@ -433,6 +464,40 @@ actions:
433
464
  value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
434
465
  ```
435
466
 
467
+ ### Icons
468
+
469
+ A message can draw one of 148 built-in bitmaps where a tag sits. `<icon:sun> FINE`
470
+ puts a sun in front of the word, and `<icon:lock:red>` retints an icon that is drawn
471
+ in a single ink; the colour words are the colour tokens' own, down to `dimred` and
472
+ `dimgreen`, so a message that can say `<red>` needs no second spelling. An icon
473
+ drawn in its own colours, such as the sun, takes no tint and asking for one is
474
+ refused rather than ignored. `GET /enumerations/icons` lists every icon with its
475
+ group, its width and whether it takes a tint.
476
+
477
+ Icons are off until `picture_count` is set, because each one on the sign needs a
478
+ picture file of its own and allocating those reallocates the sign's memory, which
479
+ erases every message on it. Set it once, alongside the other pool settings, and
480
+ 16 is a comfortable number.
481
+
482
+ Four things are worth knowing, and the first is the one that decides how to use them:
483
+
484
+ - **An icon is not a live value.** Every write to a picture file blanks the display
485
+ and restarts a scrolling message. A weather slot stepping from sun to cloud to rain
486
+ pays that each time it lands on an icon the sign is not already holding. Something
487
+ that changes every minute belongs in a variable, which costs no blank at all.
488
+ - **The pool is smaller than the library, and that is the design.** A picture file is
489
+ claimed by whichever icon a message calls, and kept after its last caller goes, so a
490
+ source alternating between two icons costs nothing after the first write. When every
491
+ file is holding an icon something still calls and a new one is asked for, the write
492
+ is refused with a 409. `GET /health` reports pictures used against pictures total,
493
+ and a full pool is the resting state rather than a warning.
494
+ - **A tint makes a second picture.** `<icon:check:green>` and `<icon:check:red>` are
495
+ two bitmaps and take two files.
496
+ - **An icon can be parted from its word.** A line too wide for the display breaks onto
497
+ a second page in HOLD, and the last word can arrive there without the icon labelling
498
+ it. The service cannot warn about this: it would have to know the width of the sign's
499
+ proportional font.
500
+
436
501
  ### Recovering a sign that has stopped responding
437
502
 
438
503
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -483,11 +548,32 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
483
548
  and the port, and pyserial's answer to one that has a slash names neither the setting
484
549
  nor the value.
485
550
 
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.
551
+ Five settings reallocate the sign's memory when changed, and **that erases every message
552
+ on it**: `slot_count`, `slot_capacity`, `variable_count`, `variable_capacity` and
553
+ `picture_count`. The service will do it, and say so loudly in the log, but they are not
554
+ settings to fiddle with. With `variable_count` at 0, `variable_capacity` allocates
555
+ nothing, so changing it alone reallocates nothing either. `picture_count` starts at 0,
556
+ which switches icons off, so turning them on is one deliberate erase.
557
+
558
+ All five come out of one memory pool, which a BetaBrite Classic reported as 5482 bytes,
559
+ and each file costs thirteen bytes beyond its own size. The defaults take 2518 of that,
560
+ and each icon takes 69 on top, so the defaults with sixteen icons take 3622.
561
+
562
+ That 5482 is a ceiling, and it is checked in two places that do different jobs. A
563
+ configuration bigger than it is refused when the settings are read, on any machine,
564
+ whether or not a sign is attached; that is the ceiling, and no sign can raise it. Then,
565
+ on a start that is about to reallocate the sign's memory and only then, the service asks
566
+ the sign for its own figure, and a sign reporting less than 5482 is believed. So the
567
+ second check can lower the limit and never raise it. A sign with a bigger pool than this
568
+ hardware's would need `ASSUMED_SIGN_MEMORY_POOL` in `readerboard/sign/pool.py` raised
569
+ before it could use the extra.
570
+
571
+ A sign that answers and does not have the room stops the service starting, with a message
572
+ naming what was configured, what it needs and what there is; that is the one failure that
573
+ does stop it, because the alternative is erasing every message on the sign to write a pool
574
+ that could never work. A sign that says nothing does not stop anything. `POST /sign/reboot`
575
+ asks the same question before it clears the sign, and answers 409 rather than erasing it,
576
+ except when the sign is too wedged to answer, which is the case that endpoint exists for.
491
577
 
492
578
  ## Security
493
579
 
@@ -568,8 +654,9 @@ each is pinned separately so that nothing the service installs ever pulls Qt in.
568
654
  with `--with-client` the client as well, so the whole loop comes up from one command.
569
655
  Both editors carry it as a launch configuration under the same name, "readerboard and
570
656
  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.
657
+ configurations for running the pieces separately, the client among them as
658
+ "readerboard client". Both carry the three way one as "readerboard, the sign simulator
659
+ and the client" as well.
573
660
 
574
661
  `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
575
662
  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
@@ -138,6 +140,22 @@ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
138
140
  probably negotiating telnet rather than passing bytes through, and `rfc2217://`
139
141
  is the form that speaks that.
140
142
 
143
+ An adapter that drops off the network says nothing as it goes. The service asks
144
+ the operating system to give a network link up about a minute after the adapter
145
+ stops answering: on Linux, a minute after the first write it did not
146
+ acknowledge, or, if nothing was written, about a minute after it last answered,
147
+ found by keepalive probes. A platform that does not take one of these options
148
+ keeps its own default for it, which can be much longer. The service itself only
149
+ finds out at its first read or write after the link has gone, and `GET /health`
150
+ shows `"connected": false` from then on. A sign with a variable updated every
151
+ minute is written to that often. One left alone is written to at the refresh,
152
+ every `refresh_interval_seconds`, which is 15 minutes by default, and a refresh
153
+ that lands before the link has gone is buffered like any other write, so on a
154
+ quiet sign `link.connected` can stay true for a refresh interval and a minute
155
+ after the adapter has gone. It is still worth a monitor watching, and needs no
156
+ key, but a ping of the adapter answers within a minute however quiet the sign
157
+ is, and is the better alarm.
158
+
141
159
  ### The API key, and config.local.toml
142
160
 
143
161
  The key is not an argument. A launch configuration is a tracked file and a
@@ -179,6 +197,19 @@ again after pulling a new version: your config file and key are left alone.
179
197
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
180
198
  remove those too.
181
199
 
200
+ The unit restarts the service whenever it stops, and gives up after ten failed starts in
201
+ five minutes. Almost nothing here fails permanently, which is what makes the exceptions
202
+ worth stopping for: a memory pool too big for the sign fails identically every time, and
203
+ each attempt puts a read on the wire that stalls a scrolling message. `systemctl status
204
+ readerboard` says which failure it was. Once the configuration is fixed, clearing the
205
+ give-up and starting it again are two commands, because `reset-failed` clears the counter
206
+ and leaves the unit stopped:
207
+
208
+ ```
209
+ sudo systemctl reset-failed readerboard
210
+ sudo systemctl start readerboard
211
+ ```
212
+
182
213
  ### With Docker
183
214
 
184
215
  The image is published to both registries on every release, for `linux/amd64`,
@@ -389,6 +420,40 @@ actions:
389
420
  value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
390
421
  ```
391
422
 
423
+ ### Icons
424
+
425
+ A message can draw one of 148 built-in bitmaps where a tag sits. `<icon:sun> FINE`
426
+ puts a sun in front of the word, and `<icon:lock:red>` retints an icon that is drawn
427
+ in a single ink; the colour words are the colour tokens' own, down to `dimred` and
428
+ `dimgreen`, so a message that can say `<red>` needs no second spelling. An icon
429
+ drawn in its own colours, such as the sun, takes no tint and asking for one is
430
+ refused rather than ignored. `GET /enumerations/icons` lists every icon with its
431
+ group, its width and whether it takes a tint.
432
+
433
+ Icons are off until `picture_count` is set, because each one on the sign needs a
434
+ picture file of its own and allocating those reallocates the sign's memory, which
435
+ erases every message on it. Set it once, alongside the other pool settings, and
436
+ 16 is a comfortable number.
437
+
438
+ Four things are worth knowing, and the first is the one that decides how to use them:
439
+
440
+ - **An icon is not a live value.** Every write to a picture file blanks the display
441
+ and restarts a scrolling message. A weather slot stepping from sun to cloud to rain
442
+ pays that each time it lands on an icon the sign is not already holding. Something
443
+ that changes every minute belongs in a variable, which costs no blank at all.
444
+ - **The pool is smaller than the library, and that is the design.** A picture file is
445
+ claimed by whichever icon a message calls, and kept after its last caller goes, so a
446
+ source alternating between two icons costs nothing after the first write. When every
447
+ file is holding an icon something still calls and a new one is asked for, the write
448
+ is refused with a 409. `GET /health` reports pictures used against pictures total,
449
+ and a full pool is the resting state rather than a warning.
450
+ - **A tint makes a second picture.** `<icon:check:green>` and `<icon:check:red>` are
451
+ two bitmaps and take two files.
452
+ - **An icon can be parted from its word.** A line too wide for the display breaks onto
453
+ a second page in HOLD, and the last word can arrive there without the icon labelling
454
+ it. The service cannot warn about this: it would have to know the width of the sign's
455
+ proportional font.
456
+
392
457
  ### Recovering a sign that has stopped responding
393
458
 
394
459
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -439,11 +504,32 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
439
504
  and the port, and pyserial's answer to one that has a slash names neither the setting
440
505
  nor the value.
441
506
 
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.
507
+ Five settings reallocate the sign's memory when changed, and **that erases every message
508
+ on it**: `slot_count`, `slot_capacity`, `variable_count`, `variable_capacity` and
509
+ `picture_count`. The service will do it, and say so loudly in the log, but they are not
510
+ settings to fiddle with. With `variable_count` at 0, `variable_capacity` allocates
511
+ nothing, so changing it alone reallocates nothing either. `picture_count` starts at 0,
512
+ which switches icons off, so turning them on is one deliberate erase.
513
+
514
+ All five come out of one memory pool, which a BetaBrite Classic reported as 5482 bytes,
515
+ and each file costs thirteen bytes beyond its own size. The defaults take 2518 of that,
516
+ and each icon takes 69 on top, so the defaults with sixteen icons take 3622.
517
+
518
+ That 5482 is a ceiling, and it is checked in two places that do different jobs. A
519
+ configuration bigger than it is refused when the settings are read, on any machine,
520
+ whether or not a sign is attached; that is the ceiling, and no sign can raise it. Then,
521
+ on a start that is about to reallocate the sign's memory and only then, the service asks
522
+ the sign for its own figure, and a sign reporting less than 5482 is believed. So the
523
+ second check can lower the limit and never raise it. A sign with a bigger pool than this
524
+ hardware's would need `ASSUMED_SIGN_MEMORY_POOL` in `readerboard/sign/pool.py` raised
525
+ before it could use the extra.
526
+
527
+ A sign that answers and does not have the room stops the service starting, with a message
528
+ naming what was configured, what it needs and what there is; that is the one failure that
529
+ does stop it, because the alternative is erasing every message on the sign to write a pool
530
+ that could never work. A sign that says nothing does not stop anything. `POST /sign/reboot`
531
+ asks the same question before it clears the sign, and answers 409 rather than erasing it,
532
+ except when the sign is too wedged to answer, which is the case that endpoint exists for.
447
533
 
448
534
  ## Security
449
535
 
@@ -524,8 +610,9 @@ each is pinned separately so that nothing the service installs ever pulls Qt in.
524
610
  with `--with-client` the client as well, so the whole loop comes up from one command.
525
611
  Both editors carry it as a launch configuration under the same name, "readerboard and
526
612
  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.
613
+ configurations for running the pieces separately, the client among them as
614
+ "readerboard client". Both carry the three way one as "readerboard, the sign simulator
615
+ and the client" as well.
529
616
 
530
617
  `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
531
618
  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.1"
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",
@@ -63,7 +63,12 @@ dependencies = [
63
63
 
64
64
  [project.optional-dependencies]
65
65
  dev = [
66
- "pytest>=9.1.1,<10",
66
+ # pytest 9.2 renamed CallSpec2 and deprecated the old name, which the anyio
67
+ # held below 4.15 further down still imports in its pytest plugin. That is
68
+ # the same collection error again, raised as the plugin loads, so no test
69
+ # runs at all. anyio 4.16 copes with the rename, so this ceiling comes off
70
+ # when the anyio one does.
71
+ "pytest>=9.1.1,<9.2",
67
72
  "pytest-asyncio>=1.4.0,<2",
68
73
  # starlette's TestClient moved to httpx2; plain httpx now raises a
69
74
  # deprecation warning, which this suite is configured to treat as an error.
@@ -175,6 +180,15 @@ files = ["readerboard"]
175
180
  strict = true
176
181
  warn_unreachable = true
177
182
  enable_error_code = ["redundant-expr", "truthy-bool", "ignore-without-code"]
183
+ # `files` above still names only the service, so checking a tool is a separate
184
+ # invocation, `mypy tools/signsim/signsim` or `mypy tools/apiclient/apiclient`.
185
+ # Naming both tool roots here means one of those resolves the tool's own
186
+ # package (`import signsim`, `import apiclient`) on its own, no MYPYPATH
187
+ # needed by hand or in CI. The directory-level invocations above already found
188
+ # their root by crawling up from the package's own `__init__.py`; what this
189
+ # actually buys is the narrower case that crawl finds nothing for, such as
190
+ # `mypy` on a single file under a tool's `tests/`, which has none.
191
+ mypy_path = ["tools/signsim", "tools/apiclient"]
178
192
 
179
193
  [[tool.mypy.overrides]]
180
194
  # 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.1"
@@ -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