readerboard 0.4.0__tar.gz → 0.5.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 (64) hide show
  1. {readerboard-0.4.0/readerboard.egg-info → readerboard-0.5.0}/PKG-INFO +84 -8
  2. {readerboard-0.4.0 → readerboard-0.5.0}/README.md +83 -7
  3. {readerboard-0.4.0 → readerboard-0.5.0}/pyproject.toml +1 -1
  4. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/app.py +25 -9
  6. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/errors.py +15 -1
  7. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/models.py +111 -4
  8. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/routes.py +120 -8
  9. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/config.py +41 -6
  10. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/constants.py +35 -5
  11. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/frames.py +73 -3
  12. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/markup.py +143 -3
  13. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/alerts.py +62 -37
  14. readerboard-0.5.0/readerboard/services/registry.py +787 -0
  15. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/sign/controller.py +41 -13
  16. readerboard-0.5.0/readerboard/sign/layout.py +157 -0
  17. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/sign/state.py +58 -9
  18. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/base.py +1 -1
  19. {readerboard-0.4.0 → readerboard-0.5.0/readerboard.egg-info}/PKG-INFO +84 -8
  20. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/SOURCES.txt +3 -1
  21. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_api.py +170 -2
  22. readerboard-0.5.0/tests/test_config.py +47 -0
  23. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_constant_values.py +33 -0
  24. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_controller.py +101 -3
  25. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_frames.py +63 -0
  26. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_markup.py +119 -1
  27. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_registry.py +5 -5
  28. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_state.py +97 -20
  29. readerboard-0.5.0/tests/test_variables.py +616 -0
  30. readerboard-0.4.0/readerboard/services/registry.py +0 -432
  31. readerboard-0.4.0/readerboard/sign/layout.py +0 -116
  32. {readerboard-0.4.0 → readerboard-0.5.0}/LICENSE +0 -0
  33. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/__main__.py +0 -0
  34. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/__init__.py +0 -0
  35. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/deps.py +0 -0
  36. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/logging_setup.py +0 -0
  37. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/names.py +0 -0
  38. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/__init__.py +0 -0
  39. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/replies.py +0 -0
  40. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/tokens.py +0 -0
  41. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/py.typed +0 -0
  42. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/__init__.py +0 -0
  43. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/clock.py +0 -0
  44. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/commands.py +0 -0
  45. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/sign/__init__.py +0 -0
  46. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/__init__.py +0 -0
  47. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/fake.py +0 -0
  48. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/serial_link.py +0 -0
  49. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/dependency_links.txt +0 -0
  50. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/entry_points.txt +0 -0
  51. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/requires.txt +0 -0
  52. {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/top_level.txt +0 -0
  53. {readerboard-0.4.0 → readerboard-0.5.0}/setup.cfg +0 -0
  54. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_alerts.py +0 -0
  55. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_clock.py +0 -0
  56. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_component_names.py +0 -0
  57. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_launch_configurations.py +0 -0
  58. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_logging_setup.py +0 -0
  59. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_open_docs.py +0 -0
  60. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_replies.py +0 -0
  61. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_run_against_a_sign.py +0 -0
  62. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_run_with_simulator.py +0 -0
  63. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_tool_icons.py +0 -0
  64. {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync
5
5
  Author: mjaksn
6
6
  License-Expression: MIT
@@ -55,8 +55,10 @@ An HTTP service that drives a BetaBrite Classic sign, either through a serial ca
55
55
  through an Ethernet to RS-232 adapter.
56
56
 
57
57
  Several sources can share the sign at once. Each registers a named **slot**, and the sign
58
- rotates through the registered slots by itself. An **alert** takes the whole display over
59
- until it is released, after which the rotation resumes.
58
+ rotates through the registered slots by itself. A **variable** is a value that messages
59
+ call by name, such as a temperature, and changing it does not blank the sign or restart
60
+ the message showing it. An **alert** takes the whole display over until it is released,
61
+ after which the rotation resumes.
60
62
 
61
63
  ## What it does
62
64
 
@@ -64,6 +66,10 @@ until it is released, after which the rotation resumes.
64
66
  automation owns `doorbell`, without either knowing about the other.
65
67
  - **The sign does the rotating.** Each message lives in its own sign file and the sign
66
68
  cycles them on its own, so rotation costs no serial traffic at all.
69
+ - **Live values without a blink.** A message such as `Outside <var:temp><degree>F` calls
70
+ the variable `temp`, and a new value is one small write that the sign shows the next
71
+ time it draws the message, with no blank and no restart. One variable can appear in
72
+ any number of messages, and a value that stops arriving can be made to go stale.
67
73
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
68
74
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
69
75
  back. That last trigger is the one that matters: a sign returning from a power cut
@@ -270,6 +276,21 @@ curl -X PUT http://localhost:5001/messages/doorbell \
270
276
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
271
277
  ```
272
278
 
279
+ Show a live value. Create the variable first, then a message that calls it:
280
+
281
+ ```
282
+ curl -X PUT http://localhost:5001/variables/temp \
283
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
284
+ -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
285
+
286
+ curl -X PUT http://localhost:5001/messages/weather \
287
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
288
+ -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
289
+ ```
290
+
291
+ From then on only the variable needs writing, and the message picks each new value up
292
+ on its next pass across the sign.
293
+
273
294
  Take the sign over for thirty seconds:
274
295
 
275
296
  ```
@@ -296,8 +317,8 @@ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back o
296
317
  lives on the sign and survives a restart, so it is also the first thing to check if
297
318
  `SOUND` ever seems to do nothing.
298
319
 
299
- The full API is at `/docs`. Every markup token, display mode and control
300
- command is listed by the `/enumerations` reads there, which answer at
320
+ The full API is at `/docs`. Every markup token, value token, display mode and
321
+ control command is listed by the `/enumerations` reads there, which answer at
301
322
  request time rather than being frozen into the description.
302
323
 
303
324
  ### Writing messages
@@ -311,6 +332,59 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
311
332
  unknown token: a write is told what the sign would have made of it rather than being
312
333
  shown something it did not ask for.
313
334
 
335
+ ### Live values: variables
336
+
337
+ A variable lives in a small file of its own on the sign, and a message or an alert
338
+ calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
339
+ which the sign takes without blanking, so a message showing a temperature or a count can
340
+ change every minute and never restart. In a scrolling mode the new value appears on the
341
+ message's next pass. An alert carrying a live wind speed works the same way, and keeps
342
+ the sign while the number changes.
343
+
344
+ A few things are worth knowing, all of them measured on the sign:
345
+
346
+ - **Create the variable before a message calls it.** A message or an alert naming a
347
+ variable that does not exist is refused with a 400, and a variable that a message or
348
+ the alert still calls cannot be deleted: that is a 409 naming what calls it.
349
+ - **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
350
+ of the message red as well, and so does a character set or a speed. If the text after
351
+ the call matters, set it again in the message after the call.
352
+ - **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
353
+ `9` from inside a variable, and a variable cannot call another. `GET
354
+ /enumerations/value-tokens` lists what is allowed.
355
+ - **Keep a changing number from pushing the text around it.** With the sign's usual
356
+ proportional spacing, `11` and `88` are different widths and the text after them moves.
357
+ Put `<fixed_width>` in the message before the call and send values of the same length.
358
+ Fixed width also left-justifies the line.
359
+ - **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
360
+ such as `--`, and once the time passes without a new value the sign shows the stale
361
+ value instead. The variable itself stays, since messages call it.
362
+ - **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
363
+ rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
364
+ so the service refuses one rather than send it.
365
+
366
+ A Home Assistant `rest_command` that keeps a sensor on the sign:
367
+
368
+ ```yaml
369
+ rest_command:
370
+ sign_temperature:
371
+ url: http://readerboard.local:5001/variables/temp
372
+ method: put
373
+ headers:
374
+ X-API-Key: !secret readerboard_key
375
+ content_type: application/json
376
+ payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
377
+ ```
378
+
379
+ Call it from an automation triggered by the sensor, with the reading as `value`:
380
+
381
+ ```yaml
382
+ actions:
383
+ - action: rest_command.sign_temperature
384
+ data:
385
+ value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
386
+ ```
387
+
314
388
  ### Recovering a sign that has stopped responding
315
389
 
316
390
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -361,9 +435,11 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
361
435
  and the port, and pyserial's answer to one that has a slash names neither the setting
362
436
  nor the value.
363
437
 
364
- Two settings reallocate the sign's memory when changed, and **that erases every message
365
- on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
366
- the log, but they are not settings to fiddle with.
438
+ Four settings reallocate the sign's memory when changed, and **that erases every message
439
+ on it**: `slot_count`, `slot_capacity`, `variable_count` and `variable_capacity`. The
440
+ service will do it, and say so loudly in the log, but they are not settings to fiddle
441
+ with. With `variable_count` at 0, `variable_capacity` allocates nothing, so changing it
442
+ alone reallocates nothing either.
367
443
 
368
444
  ## Security
369
445
 
@@ -11,8 +11,10 @@ An HTTP service that drives a BetaBrite Classic sign, either through a serial ca
11
11
  through an Ethernet to RS-232 adapter.
12
12
 
13
13
  Several sources can share the sign at once. Each registers a named **slot**, and the sign
14
- rotates through the registered slots by itself. An **alert** takes the whole display over
15
- until it is released, after which the rotation resumes.
14
+ rotates through the registered slots by itself. A **variable** is a value that messages
15
+ call by name, such as a temperature, and changing it does not blank the sign or restart
16
+ the message showing it. An **alert** takes the whole display over until it is released,
17
+ after which the rotation resumes.
16
18
 
17
19
  ## What it does
18
20
 
@@ -20,6 +22,10 @@ until it is released, after which the rotation resumes.
20
22
  automation owns `doorbell`, without either knowing about the other.
21
23
  - **The sign does the rotating.** Each message lives in its own sign file and the sign
22
24
  cycles them on its own, so rotation costs no serial traffic at all.
25
+ - **Live values without a blink.** A message such as `Outside <var:temp><degree>F` calls
26
+ the variable `temp`, and a new value is one small write that the sign shows the next
27
+ time it draws the message, with no blank and no restart. One variable can appear in
28
+ any number of messages, and a value that stops arriving can be made to go stale.
23
29
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
24
30
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
25
31
  back. That last trigger is the one that matters: a sign returning from a power cut
@@ -226,6 +232,21 @@ curl -X PUT http://localhost:5001/messages/doorbell \
226
232
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
227
233
  ```
228
234
 
235
+ Show a live value. Create the variable first, then a message that calls it:
236
+
237
+ ```
238
+ curl -X PUT http://localhost:5001/variables/temp \
239
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
240
+ -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'
241
+
242
+ curl -X PUT http://localhost:5001/messages/weather \
243
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
244
+ -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
245
+ ```
246
+
247
+ From then on only the variable needs writing, and the message picks each new value up
248
+ on its next pass across the sign.
249
+
229
250
  Take the sign over for thirty seconds:
230
251
 
231
252
  ```
@@ -252,8 +273,8 @@ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back o
252
273
  lives on the sign and survives a restart, so it is also the first thing to check if
253
274
  `SOUND` ever seems to do nothing.
254
275
 
255
- The full API is at `/docs`. Every markup token, display mode and control
256
- command is listed by the `/enumerations` reads there, which answer at
276
+ The full API is at `/docs`. Every markup token, value token, display mode and
277
+ control command is listed by the `/enumerations` reads there, which answer at
257
278
  request time rather than being frozen into the description.
258
279
 
259
280
  ### Writing messages
@@ -267,6 +288,59 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
267
288
  unknown token: a write is told what the sign would have made of it rather than being
268
289
  shown something it did not ask for.
269
290
 
291
+ ### Live values: variables
292
+
293
+ A variable lives in a small file of its own on the sign, and a message or an alert
294
+ calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
295
+ which the sign takes without blanking, so a message showing a temperature or a count can
296
+ change every minute and never restart. In a scrolling mode the new value appears on the
297
+ message's next pass. An alert carrying a live wind speed works the same way, and keeps
298
+ the sign while the number changes.
299
+
300
+ A few things are worth knowing, all of them measured on the sign:
301
+
302
+ - **Create the variable before a message calls it.** A message or an alert naming a
303
+ variable that does not exist is refused with a 400, and a variable that a message or
304
+ the alert still calls cannot be deleted: that is a 409 naming what calls it.
305
+ - **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
306
+ of the message red as well, and so does a character set or a speed. If the text after
307
+ the call matters, set it again in the message after the call.
308
+ - **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
309
+ `9` from inside a variable, and a variable cannot call another. `GET
310
+ /enumerations/value-tokens` lists what is allowed.
311
+ - **Keep a changing number from pushing the text around it.** With the sign's usual
312
+ proportional spacing, `11` and `88` are different widths and the text after them moves.
313
+ Put `<fixed_width>` in the message before the call and send values of the same length.
314
+ Fixed width also left-justifies the line.
315
+ - **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
316
+ such as `--`, and once the time passes without a new value the sign shows the stale
317
+ value instead. The variable itself stays, since messages call it.
318
+ - **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
319
+ rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
320
+ so the service refuses one rather than send it.
321
+
322
+ A Home Assistant `rest_command` that keeps a sensor on the sign:
323
+
324
+ ```yaml
325
+ rest_command:
326
+ sign_temperature:
327
+ url: http://readerboard.local:5001/variables/temp
328
+ method: put
329
+ headers:
330
+ X-API-Key: !secret readerboard_key
331
+ content_type: application/json
332
+ payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
333
+ ```
334
+
335
+ Call it from an automation triggered by the sensor, with the reading as `value`:
336
+
337
+ ```yaml
338
+ actions:
339
+ - action: rest_command.sign_temperature
340
+ data:
341
+ value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
342
+ ```
343
+
270
344
  ### Recovering a sign that has stopped responding
271
345
 
272
346
  A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
@@ -317,9 +391,11 @@ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in
317
391
  and the port, and pyserial's answer to one that has a slash names neither the setting
318
392
  nor the value.
319
393
 
320
- Two settings reallocate the sign's memory when changed, and **that erases every message
321
- on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
322
- the log, but they are not settings to fiddle with.
394
+ Four settings reallocate the sign's memory when changed, and **that erases every message
395
+ on it**: `slot_count`, `slot_capacity`, `variable_count` and `variable_capacity`. The
396
+ service will do it, and say so loudly in the log, but they are not settings to fiddle
397
+ with. With `variable_count` at 0, `variable_capacity` allocates nothing, so changing it
398
+ alone reallocates nothing either.
323
399
 
324
400
  ## Security
325
401
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readerboard"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,4 +7,4 @@ and against the release tag, before anything is published. See
7
7
 
8
8
  __all__ = ["__version__"]
9
9
 
10
- __version__ = "0.4.0"
10
+ __version__ = "0.5.0"
@@ -45,6 +45,10 @@ Several sources can share the sign at once. Each registers a named **slot**, and
45
45
  the sign rotates through the registered slots by itself. An **alert** takes the
46
46
  whole display over until it is released, then the rotation resumes.
47
47
 
48
+ A **variable** is a value a slot's message or an alert calls with `<var:name>`.
49
+ Changing it rewrites only the variable, so the sign shows the new value without
50
+ blanking or restarting what calls it.
51
+
48
52
  Every write needs an `X-API-Key` header, and so does `GET /sign/information`,
49
53
  which reads the sign rather than the service: it puts a question on the wire and
50
54
  holds the sign until the answer comes back. The service's own reads and
@@ -53,13 +57,14 @@ holds the sign until the answer comes back. The service's own reads and
53
57
 
54
58
  A failure is reported by the status code, with the reason in a `detail` field:
55
59
  400 for a command the sign does not have, a parameter it will not accept, a
56
- message too long for its slot or markup the sign cannot render, 401 for a
57
- missing or wrong `X-API-Key`, 404 for a slot nothing has registered, 409 when
58
- every message slot is already in use, 503 when the sign is unreachable, answers
59
- with something the service cannot read, or no API key is configured at all, 500
60
- for something the service has no code for, and 422 for a body that is not the
61
- shape the endpoint declares, which includes a display mode the sign does not
62
- have.
60
+ message or value too long for its file, markup the sign cannot render or a call
61
+ to a variable that does not exist, 401 for a missing or wrong `X-API-Key`, 404
62
+ for a slot or variable that does not exist, 409 when every slot or every
63
+ variable is already in use or a variable something still calls is deleted, 503
64
+ when the sign is unreachable, stops partway through an answer or answers with
65
+ something the service cannot read, or no API key is configured at all, 500 for
66
+ something the service has no code for, and 422 for a body that is not the shape
67
+ the endpoint declares, which includes a display mode the sign does not have.
63
68
  """
64
69
 
65
70
 
@@ -99,7 +104,7 @@ async def _refresh_loop(app: FastAPI, interval: float) -> None:
99
104
 
100
105
 
101
106
  async def _sweep_loop(app: FastAPI, interval: float) -> None:
102
- """Expire slots and alerts whose deadlines have passed."""
107
+ """Expire slots and alerts, and turn variables stale, once their deadlines pass."""
103
108
  registry: MessageRegistry = app.state.registry
104
109
  alerts: AlertService = app.state.alerts
105
110
 
@@ -137,7 +142,12 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
137
142
  )
138
143
  store = StateStore(settings.state_path)
139
144
  state = store.load()
140
- layout = Layout(settings.slot_count, settings.slot_capacity)
145
+ layout = Layout(
146
+ settings.slot_count,
147
+ settings.slot_capacity,
148
+ settings.variable_count,
149
+ settings.variable_capacity,
150
+ )
141
151
  alerts = AlertService(controller, store, state)
142
152
  registry = MessageRegistry(
143
153
  controller, layout, store, state, alert_active=lambda: alerts.active is not None
@@ -145,6 +155,9 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
145
155
  # An alert holding the sign makes the registry hold back run sequence
146
156
  # writes; releasing it is what lets them through.
147
157
  alerts.set_release_hook(registry.flush_deferred)
158
+ # And an alert calling a variable is rendered by the registry, under its
159
+ # lock, so the variable cannot be deleted while the alert calls it.
160
+ alerts.set_rendering(registry.rendering)
148
161
  clock = ClockService(
149
162
  controller,
150
163
  interval_seconds=settings.clock_sync_interval_seconds,
@@ -240,6 +253,7 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
240
253
  clock = get_clock(request)
241
254
 
242
255
  used, total = registry.occupancy
256
+ variables_used, variables_total = registry.variable_occupancy
243
257
  return HealthResponse(
244
258
  status="ok" if controller.is_connected else "degraded",
245
259
  version=__version__,
@@ -253,6 +267,8 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
253
267
  ),
254
268
  slots_used=used,
255
269
  slots_total=total,
270
+ variables_used=variables_used,
271
+ variables_total=variables_total,
256
272
  sign_in_sync=registry.in_sync,
257
273
  alert_active=alerts.active is not None,
258
274
  clock_last_synced_at=clock.last_sync_at,
@@ -18,7 +18,14 @@ from readerboard.protocol.markup import MarkupError
18
18
  from readerboard.protocol.replies import ReplyError
19
19
  from readerboard.services import commands
20
20
  from readerboard.services.alerts import AlertTooLong
21
- from readerboard.services.registry import MessageTooLong, UnknownSlot
21
+ from readerboard.services.registry import (
22
+ MessageTooLong,
23
+ UnknownSlot,
24
+ UnknownVariable,
25
+ VariableInUse,
26
+ VariablesDisabled,
27
+ VariableTooLong,
28
+ )
22
29
  from readerboard.sign.layout import LayoutFull
23
30
  from readerboard.transport.base import TransportError
24
31
 
@@ -26,11 +33,18 @@ STATUS_FOR_ERROR: tuple[tuple[type[Exception], int], ...] = (
26
33
  (MarkupError, status.HTTP_400_BAD_REQUEST),
27
34
  (ProtocolError, status.HTTP_400_BAD_REQUEST),
28
35
  (MessageTooLong, status.HTTP_400_BAD_REQUEST),
36
+ (VariableTooLong, status.HTTP_400_BAD_REQUEST),
37
+ (VariablesDisabled, status.HTTP_400_BAD_REQUEST),
29
38
  (AlertTooLong, status.HTTP_400_BAD_REQUEST),
30
39
  (commands.UnknownCommand, status.HTTP_400_BAD_REQUEST),
31
40
  (commands.BadParameter, status.HTTP_400_BAD_REQUEST),
32
41
  (UnknownSlot, status.HTTP_404_NOT_FOUND),
42
+ (UnknownVariable, status.HTTP_404_NOT_FOUND),
33
43
  (LayoutFull, status.HTTP_409_CONFLICT),
44
+ # Deleting a variable a message still calls would leave that message
45
+ # calling a file the next variable could be given. Not the caller's
46
+ # request being malformed, which is what makes it a conflict.
47
+ (VariableInUse, status.HTTP_409_CONFLICT),
34
48
  (TransportError, status.HTTP_503_SERVICE_UNAVAILABLE),
35
49
  # A sign that answers with something unreadable is as unusable as one
36
50
  # that does not answer, and neither is the caller's doing. The route
@@ -14,9 +14,10 @@ from typing import Annotated
14
14
 
15
15
  from pydantic import BaseModel, ConfigDict, Field, field_validator
16
16
 
17
+ from readerboard.protocol.markup import VARIABLE_NAME_PATTERN
17
18
  from readerboard.protocol.replies import GeneralInformation
18
19
  from readerboard.protocol.tokens import COMMAND_BY_NAME, MODE_BY_NAME
19
- from readerboard.sign.state import AlertState, SlotState
20
+ from readerboard.sign.state import AlertState, SlotState, VariableState
20
21
 
21
22
  SlotKey = Annotated[
22
23
  str,
@@ -28,6 +29,19 @@ SlotKey = Annotated[
28
29
  ),
29
30
  ]
30
31
 
32
+ # The same pattern the markup checks a <var:name> against, so that no variable
33
+ # can be created under a name no message could call.
34
+ VariableName = Annotated[
35
+ str,
36
+ Field(
37
+ pattern=VARIABLE_NAME_PATTERN,
38
+ description=(
39
+ "the variable's name, one to 32 lowercase letters, digits and underscores. "
40
+ "A message calls it as <var:name>"
41
+ ),
42
+ ),
43
+ ]
44
+
31
45
 
32
46
  def _normalise_mode(value: str) -> str:
33
47
  upper = value.strip().upper()
@@ -47,8 +61,9 @@ class MessageRequest(BaseModel):
47
61
  min_length=1,
48
62
  max_length=4096,
49
63
  description=(
50
- "the message, including markup tokens such as <red> and <degree>. It cannot "
51
- "be empty: an empty message holds a slot open around nothing, and the sign "
64
+ "the message, including markup tokens such as <red> and <degree>, and "
65
+ "<var:name> to call a variable, which has to exist first. It cannot be "
66
+ "empty: an empty message holds a slot open around nothing, and the sign "
52
67
  "cycles to a file with no text in it. Use DELETE to give the slot back"
53
68
  ),
54
69
  )
@@ -98,6 +113,95 @@ class SlotResponse(BaseModel):
98
113
  )
99
114
 
100
115
 
116
+ class VariableRequest(BaseModel):
117
+ """A value for a variable, which the messages calling it show."""
118
+
119
+ model_config = ConfigDict(extra="forbid")
120
+
121
+ value: str = Field(
122
+ max_length=1024,
123
+ description=(
124
+ "the value, with the same markup a message takes except <week_day> and "
125
+ "<var:name>, which the sign draws as a literal character from inside a "
126
+ "variable. Formatting set here carries on into the message after the call: "
127
+ "a value of <red>DOWN turns the rest of the message red too. May be empty, "
128
+ "which shows nothing where the variable is called"
129
+ ),
130
+ )
131
+ ttl_seconds: float | None = Field(
132
+ default=None,
133
+ gt=0,
134
+ description=(
135
+ "show stale_value in its place this many seconds from now unless a fresh "
136
+ "value arrives first. The variable itself stays, since messages call it. "
137
+ "Omit to keep the value until replaced"
138
+ ),
139
+ )
140
+ stale_value: str = Field(
141
+ default="",
142
+ max_length=1024,
143
+ description=(
144
+ "what to show once ttl_seconds has passed, such as --. Empty shows nothing. "
145
+ "Checked against the variable's size now, not when it is needed"
146
+ ),
147
+ )
148
+ source: str | None = Field(
149
+ default=None,
150
+ max_length=128,
151
+ description="who wrote this, recorded so the variable list is readable",
152
+ )
153
+
154
+
155
+ class VariableResponse(BaseModel):
156
+ """A variable."""
157
+
158
+ name: str
159
+ label: str = Field(description="the sign file this variable occupies, a through z")
160
+ value: str
161
+ stale_value: str
162
+ stale: bool = Field(
163
+ description="true once ttl_seconds has passed, when the sign shows stale_value instead"
164
+ )
165
+ source: str | None
166
+ expires_at: datetime | None = Field(
167
+ description=(
168
+ "when the value goes stale, or null if it never will or already has, which "
169
+ "stale tells apart"
170
+ )
171
+ )
172
+ updated_at: datetime
173
+ called_by: list[str] = Field(
174
+ description=(
175
+ "the keys of the slots whose messages call this variable. It cannot be deleted "
176
+ "while any do, nor while called_by_alert is true"
177
+ )
178
+ )
179
+ called_by_alert: bool = Field(
180
+ description=(
181
+ "true while the alert holding the sign calls this variable. It cannot be "
182
+ "deleted until the alert is released or replaced with one that does not"
183
+ )
184
+ )
185
+
186
+ @classmethod
187
+ def of(
188
+ cls, variable: VariableState, called_by: list[str], *, called_by_alert: bool
189
+ ) -> VariableResponse:
190
+ """Render a stored variable as the API's view of it."""
191
+ return cls(
192
+ name=variable.name,
193
+ label=variable.label,
194
+ value=variable.value,
195
+ stale_value=variable.stale_value,
196
+ stale=variable.stale,
197
+ source=variable.source,
198
+ expires_at=variable.expires_at,
199
+ updated_at=variable.updated_at,
200
+ called_by=called_by,
201
+ called_by_alert=called_by_alert,
202
+ )
203
+
204
+
101
205
  class SignInformationResponse(BaseModel):
102
206
  """What the sign says about itself."""
103
207
 
@@ -141,7 +245,8 @@ class AlertRequest(BaseModel):
141
245
  max_length=4096,
142
246
  description=(
143
247
  "the alert text. The sign's priority file holds 125 bytes once markup has "
144
- "been rendered, and cannot be resized. It cannot be empty: a write with no "
248
+ "been rendered, and cannot be resized. It can call variables with "
249
+ "<var:name>, as a message can. It cannot be empty: a write with no "
145
250
  "text still carries the formatting bytes around it, which the sign reads as "
146
251
  "a blank priority message and displays, so the sign would sit blank with "
147
252
  "the rotation suppressed behind it and an alert reported as active"
@@ -222,6 +327,8 @@ class HealthResponse(BaseModel):
222
327
  link: LinkHealth
223
328
  slots_used: int
224
329
  slots_total: int
330
+ variables_used: int
331
+ variables_total: int = Field(description="0 when variables are switched off")
225
332
  sign_in_sync: bool = Field(
226
333
  description=(
227
334
  "false when the sign is behind the service's record, which is a removal or "