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.
- {readerboard-0.6.0/readerboard.egg-info → readerboard-0.7.0}/PKG-INFO +82 -11
- {readerboard-0.6.0 → readerboard-0.7.0}/README.md +80 -9
- {readerboard-0.6.0 → readerboard-0.7.0}/pyproject.toml +11 -2
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/__init__.py +1 -1
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/app.py +79 -15
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/errors.py +24 -1
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/models.py +47 -2
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/routes.py +65 -6
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/config.py +58 -21
- readerboard-0.7.0/readerboard/icons.py +520 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/constants.py +76 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/frames.py +224 -6
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/markup.py +116 -10
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/replies.py +82 -4
- readerboard-0.7.0/readerboard/services/alerts.py +647 -0
- readerboard-0.7.0/readerboard/services/registry.py +1834 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/controller.py +31 -2
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/layout.py +73 -14
- readerboard-0.7.0/readerboard/sign/pool.py +165 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/state.py +83 -9
- {readerboard-0.6.0 → readerboard-0.7.0/readerboard.egg-info}/PKG-INFO +82 -11
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/SOURCES.txt +5 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/requires.txt +1 -1
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_alerts.py +52 -1
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_api.py +263 -10
- readerboard-0.7.0/tests/test_config.py +107 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_constant_values.py +85 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_frames.py +163 -3
- readerboard-0.7.0/tests/test_icons.py +141 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_markup.py +86 -0
- readerboard-0.7.0/tests/test_pictures.py +1671 -0
- readerboard-0.7.0/tests/test_pool.py +192 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_registry.py +141 -4
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_run_against_a_sign.py +102 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_run_with_simulator.py +47 -3
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_state.py +13 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_variables.py +9 -4
- readerboard-0.6.0/readerboard/services/alerts.py +0 -247
- readerboard-0.6.0/readerboard/services/registry.py +0 -952
- readerboard-0.6.0/tests/test_config.py +0 -47
- {readerboard-0.6.0 → readerboard-0.7.0}/LICENSE +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/__main__.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/api/deps.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/logging_setup.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/names.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/protocol/tokens.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/py.typed +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/services/clock.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/services/commands.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/base.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/fake.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard/transport/serial_link.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/setup.cfg +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_clock.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_component_names.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_controller.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_launch_configurations.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_logging_setup.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_open_docs.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_replies.py +0 -0
- {readerboard-0.6.0 → readerboard-0.7.0}/tests/test_tool_icons.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
487
|
-
on it**: `slot_count`, `slot_capacity`, `variable_count
|
|
488
|
-
service will do it, and say so loudly in the log, but they are not
|
|
489
|
-
with. With `variable_count` at 0, `variable_capacity` allocates
|
|
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
|
|
572
|
-
"readerboard
|
|
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
|
|
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
|
-
|
|
443
|
-
on it**: `slot_count`, `slot_capacity`, `variable_count
|
|
444
|
-
service will do it, and say so loudly in the log, but they are not
|
|
445
|
-
with. With `variable_count` at 0, `variable_capacity` allocates
|
|
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
|
|
528
|
-
"readerboard
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
when
|
|
67
|
-
something
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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>,
|
|
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.
|
|
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
|
+
)
|