readerboard 0.3.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 (76) hide show
  1. {readerboard-0.3.0/readerboard.egg-info → readerboard-0.5.0}/PKG-INFO +242 -20
  2. readerboard-0.5.0/README.md +510 -0
  3. {readerboard-0.3.0 → readerboard-0.5.0}/pyproject.toml +16 -9
  4. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/__init__.py +1 -1
  5. readerboard-0.5.0/readerboard/__main__.py +171 -0
  6. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/api/app.py +49 -12
  7. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/api/errors.py +21 -1
  8. readerboard-0.5.0/readerboard/api/models.py +347 -0
  9. readerboard-0.5.0/readerboard/api/routes.py +401 -0
  10. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/config.py +60 -6
  11. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/logging_setup.py +6 -4
  12. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/protocol/constants.py +169 -21
  13. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/protocol/frames.py +148 -11
  14. readerboard-0.5.0/readerboard/protocol/markup.py +354 -0
  15. readerboard-0.5.0/readerboard/protocol/replies.py +190 -0
  16. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/protocol/tokens.py +94 -25
  17. readerboard-0.5.0/readerboard/services/alerts.py +264 -0
  18. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/services/clock.py +66 -8
  19. readerboard-0.5.0/readerboard/services/commands.py +174 -0
  20. readerboard-0.5.0/readerboard/services/registry.py +787 -0
  21. readerboard-0.5.0/readerboard/sign/controller.py +519 -0
  22. readerboard-0.5.0/readerboard/sign/layout.py +157 -0
  23. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/sign/state.py +71 -11
  24. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/transport/base.py +12 -0
  25. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/transport/fake.py +25 -1
  26. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/transport/serial_link.py +81 -10
  27. {readerboard-0.3.0 → readerboard-0.5.0/readerboard.egg-info}/PKG-INFO +242 -20
  28. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard.egg-info/SOURCES.txt +9 -1
  29. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard.egg-info/requires.txt +1 -0
  30. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_alerts.py +73 -3
  31. readerboard-0.5.0/tests/test_api.py +907 -0
  32. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_clock.py +109 -6
  33. readerboard-0.5.0/tests/test_config.py +47 -0
  34. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_constant_values.py +141 -12
  35. readerboard-0.5.0/tests/test_controller.py +721 -0
  36. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_frames.py +113 -7
  37. readerboard-0.5.0/tests/test_launch_configurations.py +217 -0
  38. readerboard-0.5.0/tests/test_logging_setup.py +96 -0
  39. readerboard-0.5.0/tests/test_markup.py +364 -0
  40. readerboard-0.5.0/tests/test_open_docs.py +177 -0
  41. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_registry.py +106 -34
  42. readerboard-0.5.0/tests/test_replies.py +157 -0
  43. readerboard-0.5.0/tests/test_run_against_a_sign.py +276 -0
  44. readerboard-0.5.0/tests/test_run_with_simulator.py +89 -0
  45. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_state.py +97 -21
  46. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_transport.py +106 -1
  47. readerboard-0.5.0/tests/test_variables.py +616 -0
  48. readerboard-0.3.0/README.md +0 -289
  49. readerboard-0.3.0/readerboard/__main__.py +0 -57
  50. readerboard-0.3.0/readerboard/api/models.py +0 -206
  51. readerboard-0.3.0/readerboard/api/routes.py +0 -214
  52. readerboard-0.3.0/readerboard/protocol/markup.py +0 -177
  53. readerboard-0.3.0/readerboard/services/alerts.py +0 -182
  54. readerboard-0.3.0/readerboard/services/commands.py +0 -83
  55. readerboard-0.3.0/readerboard/services/registry.py +0 -394
  56. readerboard-0.3.0/readerboard/sign/controller.py +0 -278
  57. readerboard-0.3.0/readerboard/sign/layout.py +0 -116
  58. readerboard-0.3.0/tests/test_api.py +0 -448
  59. readerboard-0.3.0/tests/test_controller.py +0 -258
  60. readerboard-0.3.0/tests/test_launch_configurations.py +0 -67
  61. readerboard-0.3.0/tests/test_markup.py +0 -101
  62. {readerboard-0.3.0 → readerboard-0.5.0}/LICENSE +0 -0
  63. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/api/__init__.py +0 -0
  64. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/api/deps.py +0 -0
  65. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/names.py +0 -0
  66. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/protocol/__init__.py +0 -0
  67. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/py.typed +0 -0
  68. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/services/__init__.py +0 -0
  69. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/sign/__init__.py +0 -0
  70. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard/transport/__init__.py +0 -0
  71. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard.egg-info/dependency_links.txt +0 -0
  72. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard.egg-info/entry_points.txt +0 -0
  73. {readerboard-0.3.0 → readerboard-0.5.0}/readerboard.egg-info/top_level.txt +0 -0
  74. {readerboard-0.3.0 → readerboard-0.5.0}/setup.cfg +0 -0
  75. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_component_names.py +0 -0
  76. {readerboard-0.3.0 → readerboard-0.5.0}/tests/test_tool_icons.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.3.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
@@ -37,6 +37,7 @@ Provides-Extra: dev
37
37
  Requires-Dist: pytest<10,>=9.1.1; extra == "dev"
38
38
  Requires-Dist: pytest-asyncio<2,>=1.4.0; extra == "dev"
39
39
  Requires-Dist: httpx2<3,>=2.10.0; extra == "dev"
40
+ Requires-Dist: anyio<4.15,>=4.14.2; extra == "dev"
40
41
  Requires-Dist: ruff<0.17,>=0.16.2; extra == "dev"
41
42
  Requires-Dist: mypy<3,>=2.3.0; extra == "dev"
42
43
  Dynamic: license-file
@@ -54,8 +55,10 @@ An HTTP service that drives a BetaBrite Classic sign, either through a serial ca
54
55
  through an Ethernet to RS-232 adapter.
55
56
 
56
57
  Several sources can share the sign at once. Each registers a named **slot**, and the sign
57
- rotates through the registered slots by itself. An **alert** takes the whole display over
58
- 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.
59
62
 
60
63
  ## What it does
61
64
 
@@ -63,16 +66,24 @@ until it is released, after which the rotation resumes.
63
66
  automation owns `doorbell`, without either knowing about the other.
64
67
  - **The sign does the rotating.** Each message lives in its own sign file and the sign
65
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.
66
73
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
67
74
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
68
75
  back. That last trigger is the one that matters: a sign returning from a power cut
69
- does so at no particular minute.
76
+ does so at no particular minute. The sign is set one minute fast on purpose: the
77
+ protocol has no seconds field, so a sign told the current minute reads behind for the
78
+ rest of it and never ahead, and a minute of lead puts the error on the side that reads
79
+ as a clock being a touch fast rather than most of a minute slow.
70
80
  - **It does not redraw the sign for nothing.** A write of bytes the sign already holds is
71
81
  suppressed, so a source re-sending an unchanged temperature does not make the display
72
82
  flicker.
73
- - **It survives restarts and outages.** The registered messages are persisted, and a
74
- write that arrives while the sign is unreachable is accepted and delivered when the
75
- link returns.
83
+ - **It survives restarts and outages.** The registered messages are persisted and
84
+ pushed to the sign again whenever the link returns, so a restart or a power cut leaves
85
+ the rotation intact. A write that arrives while the sign is unreachable is refused with
86
+ a 503 rather than silently held, so the caller learns it did not land.
76
87
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
77
88
  render is a 400, each with the reason in the body. Nothing here reports a failure
78
89
  under a 200.
@@ -122,6 +133,73 @@ other, and stops both on Ctrl+C. The simulator decodes each transmission, says
122
133
  what every byte of it means, and shows what the sign would be holding as a
123
134
  result. `tools/signsim/README.md` has the details.
124
135
 
136
+ ## Running it against a real sign
137
+
138
+ From a checkout, with the sign on a cable or on an Ethernet to RS-232 adapter:
139
+
140
+ ```
141
+ pip install -e ".[dev]"
142
+ pip install --require-hashes -r tools/apiclient/requirements.lock
143
+ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
144
+ ```
145
+
146
+ That starts the service and the client together, with no simulator. The service
147
+ comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
148
+ pointed at that address, and the API key to paste into the client is printed in
149
+ the same window. `--no-client` leaves the client out. Ctrl+C stops everything,
150
+ and closing the client leaves the service running.
151
+
152
+ Both editors carry it as a launch configuration named "readerboard against the
153
+ real sign and the client". **The sign's address is an argument in those, not a
154
+ setting in a file**, so changing which sign is driven means editing the
155
+ Parameters field in PyCharm's run configuration dialog, or `args` in
156
+ `.vscode/launch.json`. They also pass `--api-port 5002`, so a second checkout of
157
+ this repository on the same machine can run beside them; the launcher checks
158
+ that port before it starts anything rather than letting the service bind, fail
159
+ and stop after the client has been pointed at whatever else answered.
160
+
161
+ ### Writing the address
162
+
163
+ It is a pyserial URL, and **there is no slash between the host and the port**.
164
+ `socket://192.168.2.51/:4001` looks close enough to right and is not: pyserial
165
+ answers it with a bare `TypeError` from deep inside a connection attempt, naming
166
+ neither the setting nor the value. The launcher checks the address before it
167
+ opens anything and says which part is wrong. The four forms are:
168
+
169
+ ```
170
+ socket://192.168.2.51:4001 an Ethernet to RS-232 adapter passing raw TCP
171
+ rfc2217://192.168.2.51:23 an adapter speaking the telnet serial protocol
172
+ COM3 a cable on Windows
173
+ /dev/ttyUSB0 a cable on Linux
174
+ ```
175
+
176
+ Most adapters pass raw TCP, so try `socket://` first. If the link opens but the
177
+ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
178
+ probably negotiating telnet rather than passing bytes through, and `rfc2217://`
179
+ is the form that speaks that.
180
+
181
+ ### The API key, and config.local.toml
182
+
183
+ The key is not an argument. A launch configuration is a tracked file and a
184
+ command line is a shell history, and anyone holding the key can write to the
185
+ sign. It lives in `config.local.toml` at the root of the checkout, which
186
+ `.gitignore` covers and which the launcher writes with a generated key the first
187
+ time it runs. Given no `--serial-url`, the address is read from there too.
188
+
189
+ ### The first run erases the sign
190
+
191
+ Writing a memory configuration erases every message on the sign, and the service
192
+ writes one whenever it has no record of the configuration already applied. The
193
+ first run against a sign this machine has never driven therefore erases it,
194
+ which is also the only way to allocate the files it then writes into. Every run
195
+ after that reads the record and leaves the sign alone.
196
+
197
+ That record is `.local-sign-state.json`, and it belongs to this launcher alone.
198
+ `scripts/run_with_simulator.py` deletes its own `.local-state.json` on every
199
+ launch, because the simulator starts empty every time and the service has to
200
+ reconfigure it. If the two shared one file, a simulator session would throw the
201
+ sign's record away and the next run against the sign would erase it.
202
+
125
203
  ## Installing it properly
126
204
 
127
205
  Two ways, which do the same job. Pick whichever suits the machine.
@@ -177,8 +255,10 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
177
255
 
178
256
  ## Using it
179
257
 
180
- Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
181
- Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
258
+ Every write needs an `X-API-Key` header, and so does `GET /sign/information`,
259
+ which asks the sign a question rather than reading the service's own record. The
260
+ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, the
261
+ **Authorize** button puts it in once for the whole page.
182
262
 
183
263
  Register a message:
184
264
 
@@ -196,6 +276,21 @@ curl -X PUT http://localhost:5001/messages/doorbell \
196
276
  -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
197
277
  ```
198
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
+
199
294
  Take the sign over for thirty seconds:
200
295
 
201
296
  ```
@@ -204,7 +299,25 @@ curl -X POST http://localhost:5001/alerts \
204
299
  -d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
205
300
  ```
206
301
 
207
- The full API is at `/docs`. Every markup token, display mode, text position and
302
+ Make a noise, which is worth pairing with an alert if the sign is somewhere nobody
303
+ is watching it:
304
+
305
+ ```
306
+ curl -X POST http://localhost:5001/sign/command \
307
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
308
+ -d '{"command": "SOUND", "parameter": "BEEPS"}'
309
+ ```
310
+
311
+ `BEEPS` is three short beeps and `TONE` is one continuous tone of about two seconds.
312
+ Those are the only two sounds there are: the sign has a fixed-pitch buzzer, so there
313
+ is no pitch or volume to choose.
314
+
315
+ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back on with
316
+ `ON`. That is a real mute: `SOUND` is still accepted and makes no noise. The setting
317
+ lives on the sign and survives a restart, so it is also the first thing to check if
318
+ `SOUND` ever seems to do nothing.
319
+
320
+ The full API is at `/docs`. Every markup token, value token, display mode and
208
321
  control command is listed by the `/enumerations` reads there, which answer at
209
322
  request time rather than being frozen into the description.
210
323
 
@@ -219,6 +332,92 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
219
332
  unknown token: a write is told what the sign would have made of it rather than being
220
333
  shown something it did not ask for.
221
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
+
388
+ ### Recovering a sign that has stopped responding
389
+
390
+ A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
391
+ showing, it stops responding to writes, and there is no power switch within reach.
392
+ There are two recoveries, and they are not interchangeable. Try the gentle one first.
393
+
394
+ **A soft reset restarts the sign and erases nothing.** The sign runs the same power-up
395
+ diagnostics it runs when you plug it in, then carries on showing what it was showing.
396
+ Its memory, its file table and its messages all survive; this was verified on the sign
397
+ by reading them back either side of a reset.
398
+
399
+ ```
400
+ curl -X POST http://localhost:5001/sign/command \
401
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
402
+ -d '{"command": "SOFT_RESET"}'
403
+ ```
404
+
405
+ The call waits out the diagnostics before answering, so a 204 means the sign is
406
+ listening again rather than that the bytes went out.
407
+
408
+ **`POST /sign/reboot` is the escalation, and it is destructive.** It clears the sign
409
+ outright, waits for it to restart, then re-pushes every message and the run sequence
410
+ from the service's own record, so the display still comes back to what it was.
411
+
412
+ ```
413
+ curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
414
+ ```
415
+
416
+ Reach for it only when a soft reset was not enough. The sign is blank for about ten
417
+ seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
418
+ that without resetting anything. The client fronts the reboot with a warning-coloured
419
+ confirmation for the same reason.
420
+
222
421
  ## Configuration
223
422
 
224
423
  Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
@@ -230,22 +429,30 @@ and usually absent, which is not an error. `READERBOARD_CONFIG_FILE` moves the f
230
429
  you want it somewhere other than the default.
231
430
 
232
431
  The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
233
- for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
234
- `loop://` to run the service with no sign attached.
235
-
236
- Two settings reallocate the sign's memory when changed, and **that erases every message
237
- on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
238
- the log, but they are not settings to fiddle with.
432
+ for an Ethernet to RS-232 adapter, `rfc2217://192.168.2.51:23` for one speaking the
433
+ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in, or
434
+ `loop://` to run the service with no sign attached. There is no slash between the host
435
+ and the port, and pyserial's answer to one that has a slash names neither the setting
436
+ nor the value.
437
+
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.
239
443
 
240
444
  ## Security
241
445
 
242
446
  An API key is required on every write, compared in constant time, and never logged.
243
- Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
244
- that could write to it.
447
+ `GET /sign/information` needs one too: it is a read of the sign itself rather than of the
448
+ service, so it sends a question over the wire, holds the sign until the answer arrives,
449
+ and reports the hardware's firmware and how full its memory is. The service's own reads
450
+ and `GET /health` need none, so a monitor can watch the slots without holding a key that
451
+ could write to them.
245
452
 
246
453
  The key is declared to the API description as a security scheme, so the Swagger UI at
247
- `/docs` has an **Authorize** button: enter the key once and every write on the page
248
- carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
454
+ `/docs` has an **Authorize** button: enter the key once and everything on the page that
455
+ needs it carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
249
456
  or a Home Assistant `rest_command` changes.
250
457
 
251
458
  That page is configured to remember the key, so it survives a reload or a browser
@@ -316,6 +523,21 @@ the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`,
316
523
  configurations for running the pieces separately. Both carry the three way one as
317
524
  "readerboard, the sign simulator and the client" as well.
318
525
 
526
+ `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
527
+ service and the client, no simulator, and the sign's address passed as an argument so
528
+ that it can be edited in a run configuration dialog. Both editors carry it as
529
+ "readerboard against the real sign and the client". The section above has the rest,
530
+ including the one thing about it that is dangerous. The two launchers share their
531
+ process supervision through `scripts/_supervise.py` and differ in what each child is
532
+ given, which is the part that matters: the simulator launcher discards its state file
533
+ on every run and this one never discards anything.
534
+
535
+ Every one of those that starts the service sets `READERBOARD_OPEN_DOCS`, so `/docs`
536
+ opens in a browser once the port answers. The service does the waiting and the
537
+ opening, which is why it lands on the port actually bound rather than one repeated in
538
+ a launch file. The setting is off unless asked for, so an installed service opens
539
+ nothing.
540
+
319
541
  ## Licence
320
542
 
321
543
  MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).