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.
- {readerboard-0.4.0/readerboard.egg-info → readerboard-0.5.0}/PKG-INFO +84 -8
- {readerboard-0.4.0 → readerboard-0.5.0}/README.md +83 -7
- {readerboard-0.4.0 → readerboard-0.5.0}/pyproject.toml +1 -1
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/__init__.py +1 -1
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/app.py +25 -9
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/errors.py +15 -1
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/models.py +111 -4
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/routes.py +120 -8
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/config.py +41 -6
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/constants.py +35 -5
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/frames.py +73 -3
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/markup.py +143 -3
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/alerts.py +62 -37
- readerboard-0.5.0/readerboard/services/registry.py +787 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/sign/controller.py +41 -13
- readerboard-0.5.0/readerboard/sign/layout.py +157 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/sign/state.py +58 -9
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/base.py +1 -1
- {readerboard-0.4.0 → readerboard-0.5.0/readerboard.egg-info}/PKG-INFO +84 -8
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/SOURCES.txt +3 -1
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_api.py +170 -2
- readerboard-0.5.0/tests/test_config.py +47 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_constant_values.py +33 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_controller.py +101 -3
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_frames.py +63 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_markup.py +119 -1
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_registry.py +5 -5
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_state.py +97 -20
- readerboard-0.5.0/tests/test_variables.py +616 -0
- readerboard-0.4.0/readerboard/services/registry.py +0 -432
- readerboard-0.4.0/readerboard/sign/layout.py +0 -116
- {readerboard-0.4.0 → readerboard-0.5.0}/LICENSE +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/__main__.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/api/deps.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/logging_setup.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/names.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/replies.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/protocol/tokens.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/py.typed +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/clock.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/services/commands.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/fake.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard/transport/serial_link.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/requires.txt +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/setup.cfg +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_alerts.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_clock.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_component_names.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_launch_configurations.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_logging_setup.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_open_docs.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_replies.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_run_against_a_sign.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_run_with_simulator.py +0 -0
- {readerboard-0.4.0 → readerboard-0.5.0}/tests/test_tool_icons.py +0 -0
- {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.
|
|
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.
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
365
|
-
on it**: `slot_count` and `
|
|
366
|
-
the log, but they are not settings to fiddle
|
|
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.
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
on it**: `slot_count` and `
|
|
322
|
-
the log, but they are not settings to fiddle
|
|
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.
|
|
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"
|
|
@@ -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
|
|
57
|
-
missing or wrong `X-API-Key`, 404
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
51
|
-
"
|
|
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
|
|
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 "
|