readerboard 0.3.0__tar.gz → 0.4.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 (66) hide show
  1. {readerboard-0.3.0/readerboard.egg-info → readerboard-0.4.0}/PKG-INFO +161 -15
  2. {readerboard-0.3.0 → readerboard-0.4.0}/README.md +159 -14
  3. {readerboard-0.3.0 → readerboard-0.4.0}/pyproject.toml +16 -9
  4. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/__init__.py +1 -1
  5. readerboard-0.4.0/readerboard/__main__.py +171 -0
  6. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/app.py +29 -8
  7. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/errors.py +6 -0
  8. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/models.py +57 -23
  9. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/routes.py +86 -11
  10. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/config.py +19 -0
  11. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/logging_setup.py +6 -4
  12. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/constants.py +134 -16
  13. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/frames.py +75 -8
  14. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/markup.py +37 -0
  15. readerboard-0.4.0/readerboard/protocol/replies.py +190 -0
  16. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/tokens.py +94 -25
  17. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/alerts.py +64 -7
  18. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/clock.py +66 -8
  19. readerboard-0.4.0/readerboard/services/commands.py +174 -0
  20. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/registry.py +68 -30
  21. readerboard-0.4.0/readerboard/sign/controller.py +491 -0
  22. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/sign/state.py +13 -2
  23. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/base.py +12 -0
  24. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/fake.py +25 -1
  25. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/serial_link.py +81 -10
  26. {readerboard-0.3.0 → readerboard-0.4.0/readerboard.egg-info}/PKG-INFO +161 -15
  27. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/SOURCES.txt +6 -0
  28. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/requires.txt +1 -0
  29. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_alerts.py +73 -3
  30. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_api.py +299 -8
  31. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_clock.py +109 -6
  32. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_constant_values.py +108 -12
  33. readerboard-0.4.0/tests/test_controller.py +623 -0
  34. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_frames.py +50 -7
  35. readerboard-0.4.0/tests/test_launch_configurations.py +217 -0
  36. readerboard-0.4.0/tests/test_logging_setup.py +96 -0
  37. readerboard-0.4.0/tests/test_markup.py +246 -0
  38. readerboard-0.4.0/tests/test_open_docs.py +177 -0
  39. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_registry.py +104 -32
  40. readerboard-0.4.0/tests/test_replies.py +157 -0
  41. readerboard-0.4.0/tests/test_run_against_a_sign.py +276 -0
  42. readerboard-0.4.0/tests/test_run_with_simulator.py +89 -0
  43. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_state.py +0 -1
  44. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_transport.py +106 -1
  45. readerboard-0.3.0/readerboard/__main__.py +0 -57
  46. readerboard-0.3.0/readerboard/services/commands.py +0 -83
  47. readerboard-0.3.0/readerboard/sign/controller.py +0 -278
  48. readerboard-0.3.0/tests/test_controller.py +0 -258
  49. readerboard-0.3.0/tests/test_launch_configurations.py +0 -67
  50. readerboard-0.3.0/tests/test_markup.py +0 -101
  51. {readerboard-0.3.0 → readerboard-0.4.0}/LICENSE +0 -0
  52. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/__init__.py +0 -0
  53. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/deps.py +0 -0
  54. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/names.py +0 -0
  55. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/__init__.py +0 -0
  56. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/py.typed +0 -0
  57. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/__init__.py +0 -0
  58. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/sign/__init__.py +0 -0
  59. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/sign/layout.py +0 -0
  60. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/__init__.py +0 -0
  61. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/dependency_links.txt +0 -0
  62. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/entry_points.txt +0 -0
  63. {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/top_level.txt +0 -0
  64. {readerboard-0.3.0 → readerboard-0.4.0}/setup.cfg +0 -0
  65. {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_component_names.py +0 -0
  66. {readerboard-0.3.0 → readerboard-0.4.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.4.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
@@ -66,13 +67,17 @@ until it is released, after which the rotation resumes.
66
67
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
67
68
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
68
69
  back. That last trigger is the one that matters: a sign returning from a power cut
69
- does so at no particular minute.
70
+ does so at no particular minute. The sign is set one minute fast on purpose: the
71
+ protocol has no seconds field, so a sign told the current minute reads behind for the
72
+ rest of it and never ahead, and a minute of lead puts the error on the side that reads
73
+ as a clock being a touch fast rather than most of a minute slow.
70
74
  - **It does not redraw the sign for nothing.** A write of bytes the sign already holds is
71
75
  suppressed, so a source re-sending an unchanged temperature does not make the display
72
76
  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.
77
+ - **It survives restarts and outages.** The registered messages are persisted and
78
+ pushed to the sign again whenever the link returns, so a restart or a power cut leaves
79
+ the rotation intact. A write that arrives while the sign is unreachable is refused with
80
+ a 503 rather than silently held, so the caller learns it did not land.
76
81
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
77
82
  render is a 400, each with the reason in the body. Nothing here reports a failure
78
83
  under a 200.
@@ -122,6 +127,73 @@ other, and stops both on Ctrl+C. The simulator decodes each transmission, says
122
127
  what every byte of it means, and shows what the sign would be holding as a
123
128
  result. `tools/signsim/README.md` has the details.
124
129
 
130
+ ## Running it against a real sign
131
+
132
+ From a checkout, with the sign on a cable or on an Ethernet to RS-232 adapter:
133
+
134
+ ```
135
+ pip install -e ".[dev]"
136
+ pip install --require-hashes -r tools/apiclient/requirements.lock
137
+ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
138
+ ```
139
+
140
+ That starts the service and the client together, with no simulator. The service
141
+ comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
142
+ pointed at that address, and the API key to paste into the client is printed in
143
+ the same window. `--no-client` leaves the client out. Ctrl+C stops everything,
144
+ and closing the client leaves the service running.
145
+
146
+ Both editors carry it as a launch configuration named "readerboard against the
147
+ real sign and the client". **The sign's address is an argument in those, not a
148
+ setting in a file**, so changing which sign is driven means editing the
149
+ Parameters field in PyCharm's run configuration dialog, or `args` in
150
+ `.vscode/launch.json`. They also pass `--api-port 5002`, so a second checkout of
151
+ this repository on the same machine can run beside them; the launcher checks
152
+ that port before it starts anything rather than letting the service bind, fail
153
+ and stop after the client has been pointed at whatever else answered.
154
+
155
+ ### Writing the address
156
+
157
+ It is a pyserial URL, and **there is no slash between the host and the port**.
158
+ `socket://192.168.2.51/:4001` looks close enough to right and is not: pyserial
159
+ answers it with a bare `TypeError` from deep inside a connection attempt, naming
160
+ neither the setting nor the value. The launcher checks the address before it
161
+ opens anything and says which part is wrong. The four forms are:
162
+
163
+ ```
164
+ socket://192.168.2.51:4001 an Ethernet to RS-232 adapter passing raw TCP
165
+ rfc2217://192.168.2.51:23 an adapter speaking the telnet serial protocol
166
+ COM3 a cable on Windows
167
+ /dev/ttyUSB0 a cable on Linux
168
+ ```
169
+
170
+ Most adapters pass raw TCP, so try `socket://` first. If the link opens but the
171
+ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
172
+ probably negotiating telnet rather than passing bytes through, and `rfc2217://`
173
+ is the form that speaks that.
174
+
175
+ ### The API key, and config.local.toml
176
+
177
+ The key is not an argument. A launch configuration is a tracked file and a
178
+ command line is a shell history, and anyone holding the key can write to the
179
+ sign. It lives in `config.local.toml` at the root of the checkout, which
180
+ `.gitignore` covers and which the launcher writes with a generated key the first
181
+ time it runs. Given no `--serial-url`, the address is read from there too.
182
+
183
+ ### The first run erases the sign
184
+
185
+ Writing a memory configuration erases every message on the sign, and the service
186
+ writes one whenever it has no record of the configuration already applied. The
187
+ first run against a sign this machine has never driven therefore erases it,
188
+ which is also the only way to allocate the files it then writes into. Every run
189
+ after that reads the record and leaves the sign alone.
190
+
191
+ That record is `.local-sign-state.json`, and it belongs to this launcher alone.
192
+ `scripts/run_with_simulator.py` deletes its own `.local-state.json` on every
193
+ launch, because the simulator starts empty every time and the service has to
194
+ reconfigure it. If the two shared one file, a simulator session would throw the
195
+ sign's record away and the next run against the sign would erase it.
196
+
125
197
  ## Installing it properly
126
198
 
127
199
  Two ways, which do the same job. Pick whichever suits the machine.
@@ -177,8 +249,10 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
177
249
 
178
250
  ## Using it
179
251
 
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.
252
+ Every write needs an `X-API-Key` header, and so does `GET /sign/information`,
253
+ which asks the sign a question rather than reading the service's own record. The
254
+ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, the
255
+ **Authorize** button puts it in once for the whole page.
182
256
 
183
257
  Register a message:
184
258
 
@@ -204,8 +278,26 @@ curl -X POST http://localhost:5001/alerts \
204
278
  -d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
205
279
  ```
206
280
 
207
- The full API is at `/docs`. Every markup token, display mode, text position and
208
- control command is listed by the `/enumerations` reads there, which answer at
281
+ Make a noise, which is worth pairing with an alert if the sign is somewhere nobody
282
+ is watching it:
283
+
284
+ ```
285
+ curl -X POST http://localhost:5001/sign/command \
286
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
287
+ -d '{"command": "SOUND", "parameter": "BEEPS"}'
288
+ ```
289
+
290
+ `BEEPS` is three short beeps and `TONE` is one continuous tone of about two seconds.
291
+ Those are the only two sounds there are: the sign has a fixed-pitch buzzer, so there
292
+ is no pitch or volume to choose.
293
+
294
+ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back on with
295
+ `ON`. That is a real mute: `SOUND` is still accepted and makes no noise. The setting
296
+ lives on the sign and survives a restart, so it is also the first thing to check if
297
+ `SOUND` ever seems to do nothing.
298
+
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
209
301
  request time rather than being frozen into the description.
210
302
 
211
303
  ### Writing messages
@@ -219,6 +311,39 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
219
311
  unknown token: a write is told what the sign would have made of it rather than being
220
312
  shown something it did not ask for.
221
313
 
314
+ ### Recovering a sign that has stopped responding
315
+
316
+ A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
317
+ showing, it stops responding to writes, and there is no power switch within reach.
318
+ There are two recoveries, and they are not interchangeable. Try the gentle one first.
319
+
320
+ **A soft reset restarts the sign and erases nothing.** The sign runs the same power-up
321
+ diagnostics it runs when you plug it in, then carries on showing what it was showing.
322
+ Its memory, its file table and its messages all survive; this was verified on the sign
323
+ by reading them back either side of a reset.
324
+
325
+ ```
326
+ curl -X POST http://localhost:5001/sign/command \
327
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
328
+ -d '{"command": "SOFT_RESET"}'
329
+ ```
330
+
331
+ The call waits out the diagnostics before answering, so a 204 means the sign is
332
+ listening again rather than that the bytes went out.
333
+
334
+ **`POST /sign/reboot` is the escalation, and it is destructive.** It clears the sign
335
+ outright, waits for it to restart, then re-pushes every message and the run sequence
336
+ from the service's own record, so the display still comes back to what it was.
337
+
338
+ ```
339
+ curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
340
+ ```
341
+
342
+ Reach for it only when a soft reset was not enough. The sign is blank for about ten
343
+ seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
344
+ that without resetting anything. The client fronts the reboot with a warning-coloured
345
+ confirmation for the same reason.
346
+
222
347
  ## Configuration
223
348
 
224
349
  Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
@@ -230,8 +355,11 @@ and usually absent, which is not an error. `READERBOARD_CONFIG_FILE` moves the f
230
355
  you want it somewhere other than the default.
231
356
 
232
357
  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.
358
+ for an Ethernet to RS-232 adapter, `rfc2217://192.168.2.51:23` for one speaking the
359
+ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in, or
360
+ `loop://` to run the service with no sign attached. There is no slash between the host
361
+ and the port, and pyserial's answer to one that has a slash names neither the setting
362
+ nor the value.
235
363
 
236
364
  Two settings reallocate the sign's memory when changed, and **that erases every message
237
365
  on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
@@ -240,12 +368,15 @@ the log, but they are not settings to fiddle with.
240
368
  ## Security
241
369
 
242
370
  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.
371
+ `GET /sign/information` needs one too: it is a read of the sign itself rather than of the
372
+ service, so it sends a question over the wire, holds the sign until the answer arrives,
373
+ and reports the hardware's firmware and how full its memory is. The service's own reads
374
+ and `GET /health` need none, so a monitor can watch the slots without holding a key that
375
+ could write to them.
245
376
 
246
377
  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
378
+ `/docs` has an **Authorize** button: enter the key once and everything on the page that
379
+ needs it carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
249
380
  or a Home Assistant `rest_command` changes.
250
381
 
251
382
  That page is configured to remember the key, so it survives a reload or a browser
@@ -316,6 +447,21 @@ the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`,
316
447
  configurations for running the pieces separately. Both carry the three way one as
317
448
  "readerboard, the sign simulator and the client" as well.
318
449
 
450
+ `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
451
+ service and the client, no simulator, and the sign's address passed as an argument so
452
+ that it can be edited in a run configuration dialog. Both editors carry it as
453
+ "readerboard against the real sign and the client". The section above has the rest,
454
+ including the one thing about it that is dangerous. The two launchers share their
455
+ process supervision through `scripts/_supervise.py` and differ in what each child is
456
+ given, which is the part that matters: the simulator launcher discards its state file
457
+ on every run and this one never discards anything.
458
+
459
+ Every one of those that starts the service sets `READERBOARD_OPEN_DOCS`, so `/docs`
460
+ opens in a browser once the port answers. The service does the waiting and the
461
+ opening, which is why it lands on the port actually bound rather than one repeated in
462
+ a launch file. The setting is off unless asked for, so an installed service opens
463
+ nothing.
464
+
319
465
  ## Licence
320
466
 
321
467
  MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
@@ -23,13 +23,17 @@ until it is released, after which the rotation resumes.
23
23
  - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
24
24
  - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
25
25
  back. That last trigger is the one that matters: a sign returning from a power cut
26
- does so at no particular minute.
26
+ does so at no particular minute. The sign is set one minute fast on purpose: the
27
+ protocol has no seconds field, so a sign told the current minute reads behind for the
28
+ rest of it and never ahead, and a minute of lead puts the error on the side that reads
29
+ as a clock being a touch fast rather than most of a minute slow.
27
30
  - **It does not redraw the sign for nothing.** A write of bytes the sign already holds is
28
31
  suppressed, so a source re-sending an unchanged temperature does not make the display
29
32
  flicker.
30
- - **It survives restarts and outages.** The registered messages are persisted, and a
31
- write that arrives while the sign is unreachable is accepted and delivered when the
32
- link returns.
33
+ - **It survives restarts and outages.** The registered messages are persisted and
34
+ pushed to the sign again whenever the link returns, so a restart or a power cut leaves
35
+ the rotation intact. A write that arrives while the sign is unreachable is refused with
36
+ a 503 rather than silently held, so the caller learns it did not land.
33
37
  - **Errors are errors.** A dead serial link is a 503 and a message the sign cannot
34
38
  render is a 400, each with the reason in the body. Nothing here reports a failure
35
39
  under a 200.
@@ -79,6 +83,73 @@ other, and stops both on Ctrl+C. The simulator decodes each transmission, says
79
83
  what every byte of it means, and shows what the sign would be holding as a
80
84
  result. `tools/signsim/README.md` has the details.
81
85
 
86
+ ## Running it against a real sign
87
+
88
+ From a checkout, with the sign on a cable or on an Ethernet to RS-232 adapter:
89
+
90
+ ```
91
+ pip install -e ".[dev]"
92
+ pip install --require-hashes -r tools/apiclient/requirements.lock
93
+ python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
94
+ ```
95
+
96
+ That starts the service and the client together, with no simulator. The service
97
+ comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
98
+ pointed at that address, and the API key to paste into the client is printed in
99
+ the same window. `--no-client` leaves the client out. Ctrl+C stops everything,
100
+ and closing the client leaves the service running.
101
+
102
+ Both editors carry it as a launch configuration named "readerboard against the
103
+ real sign and the client". **The sign's address is an argument in those, not a
104
+ setting in a file**, so changing which sign is driven means editing the
105
+ Parameters field in PyCharm's run configuration dialog, or `args` in
106
+ `.vscode/launch.json`. They also pass `--api-port 5002`, so a second checkout of
107
+ this repository on the same machine can run beside them; the launcher checks
108
+ that port before it starts anything rather than letting the service bind, fail
109
+ and stop after the client has been pointed at whatever else answered.
110
+
111
+ ### Writing the address
112
+
113
+ It is a pyserial URL, and **there is no slash between the host and the port**.
114
+ `socket://192.168.2.51/:4001` looks close enough to right and is not: pyserial
115
+ answers it with a bare `TypeError` from deep inside a connection attempt, naming
116
+ neither the setting nor the value. The launcher checks the address before it
117
+ opens anything and says which part is wrong. The four forms are:
118
+
119
+ ```
120
+ socket://192.168.2.51:4001 an Ethernet to RS-232 adapter passing raw TCP
121
+ rfc2217://192.168.2.51:23 an adapter speaking the telnet serial protocol
122
+ COM3 a cable on Windows
123
+ /dev/ttyUSB0 a cable on Linux
124
+ ```
125
+
126
+ Most adapters pass raw TCP, so try `socket://` first. If the link opens but the
127
+ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
128
+ probably negotiating telnet rather than passing bytes through, and `rfc2217://`
129
+ is the form that speaks that.
130
+
131
+ ### The API key, and config.local.toml
132
+
133
+ The key is not an argument. A launch configuration is a tracked file and a
134
+ command line is a shell history, and anyone holding the key can write to the
135
+ sign. It lives in `config.local.toml` at the root of the checkout, which
136
+ `.gitignore` covers and which the launcher writes with a generated key the first
137
+ time it runs. Given no `--serial-url`, the address is read from there too.
138
+
139
+ ### The first run erases the sign
140
+
141
+ Writing a memory configuration erases every message on the sign, and the service
142
+ writes one whenever it has no record of the configuration already applied. The
143
+ first run against a sign this machine has never driven therefore erases it,
144
+ which is also the only way to allocate the files it then writes into. Every run
145
+ after that reads the record and leaves the sign alone.
146
+
147
+ That record is `.local-sign-state.json`, and it belongs to this launcher alone.
148
+ `scripts/run_with_simulator.py` deletes its own `.local-state.json` on every
149
+ launch, because the simulator starts empty every time and the service has to
150
+ reconfigure it. If the two shared one file, a simulator session would throw the
151
+ sign's record away and the next run against the sign would erase it.
152
+
82
153
  ## Installing it properly
83
154
 
84
155
  Two ways, which do the same job. Pick whichever suits the machine.
@@ -134,8 +205,10 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
134
205
 
135
206
  ## Using it
136
207
 
137
- Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
138
- Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
208
+ Every write needs an `X-API-Key` header, and so does `GET /sign/information`,
209
+ which asks the sign a question rather than reading the service's own record. The
210
+ service's other reads and `GET /health` do not. In the Swagger UI at `/docs`, the
211
+ **Authorize** button puts it in once for the whole page.
139
212
 
140
213
  Register a message:
141
214
 
@@ -161,8 +234,26 @@ curl -X POST http://localhost:5001/alerts \
161
234
  -d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
162
235
  ```
163
236
 
164
- The full API is at `/docs`. Every markup token, display mode, text position and
165
- control command is listed by the `/enumerations` reads there, which answer at
237
+ Make a noise, which is worth pairing with an alert if the sign is somewhere nobody
238
+ is watching it:
239
+
240
+ ```
241
+ curl -X POST http://localhost:5001/sign/command \
242
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
243
+ -d '{"command": "SOUND", "parameter": "BEEPS"}'
244
+ ```
245
+
246
+ `BEEPS` is three short beeps and `TONE` is one continuous tone of about two seconds.
247
+ Those are the only two sounds there are: the sign has a fixed-pitch buzzer, so there
248
+ is no pitch or volume to choose.
249
+
250
+ Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back on with
251
+ `ON`. That is a real mute: `SOUND` is still accepted and makes no noise. The setting
252
+ lives on the sign and survives a restart, so it is also the first thing to check if
253
+ `SOUND` ever seems to do nothing.
254
+
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
166
257
  request time rather than being frozen into the description.
167
258
 
168
259
  ### Writing messages
@@ -176,6 +267,39 @@ displays correctly. A character the sign cannot render is rejected with a 400, a
176
267
  unknown token: a write is told what the sign would have made of it rather than being
177
268
  shown something it did not ask for.
178
269
 
270
+ ### Recovering a sign that has stopped responding
271
+
272
+ A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
273
+ showing, it stops responding to writes, and there is no power switch within reach.
274
+ There are two recoveries, and they are not interchangeable. Try the gentle one first.
275
+
276
+ **A soft reset restarts the sign and erases nothing.** The sign runs the same power-up
277
+ diagnostics it runs when you plug it in, then carries on showing what it was showing.
278
+ Its memory, its file table and its messages all survive; this was verified on the sign
279
+ by reading them back either side of a reset.
280
+
281
+ ```
282
+ curl -X POST http://localhost:5001/sign/command \
283
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
284
+ -d '{"command": "SOFT_RESET"}'
285
+ ```
286
+
287
+ The call waits out the diagnostics before answering, so a 204 means the sign is
288
+ listening again rather than that the bytes went out.
289
+
290
+ **`POST /sign/reboot` is the escalation, and it is destructive.** It clears the sign
291
+ outright, waits for it to restart, then re-pushes every message and the run sequence
292
+ from the service's own record, so the display still comes back to what it was.
293
+
294
+ ```
295
+ curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
296
+ ```
297
+
298
+ Reach for it only when a soft reset was not enough. The sign is blank for about ten
299
+ seconds while it resets. Neither is a way to clear messages: `DELETE /messages` does
300
+ that without resetting anything. The client fronts the reboot with a warning-coloured
301
+ confirmation for the same reason.
302
+
179
303
  ## Configuration
180
304
 
181
305
  Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
@@ -187,8 +311,11 @@ and usually absent, which is not an error. `READERBOARD_CONFIG_FILE` moves the f
187
311
  you want it somewhere other than the default.
188
312
 
189
313
  The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
190
- for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
191
- `loop://` to run the service with no sign attached.
314
+ for an Ethernet to RS-232 adapter, `rfc2217://192.168.2.51:23` for one speaking the
315
+ telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in, or
316
+ `loop://` to run the service with no sign attached. There is no slash between the host
317
+ and the port, and pyserial's answer to one that has a slash names neither the setting
318
+ nor the value.
192
319
 
193
320
  Two settings reallocate the sign's memory when changed, and **that erases every message
194
321
  on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
@@ -197,12 +324,15 @@ the log, but they are not settings to fiddle with.
197
324
  ## Security
198
325
 
199
326
  An API key is required on every write, compared in constant time, and never logged.
200
- Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
201
- that could write to it.
327
+ `GET /sign/information` needs one too: it is a read of the sign itself rather than of the
328
+ service, so it sends a question over the wire, holds the sign until the answer arrives,
329
+ and reports the hardware's firmware and how full its memory is. The service's own reads
330
+ and `GET /health` need none, so a monitor can watch the slots without holding a key that
331
+ could write to them.
202
332
 
203
333
  The key is declared to the API description as a security scheme, so the Swagger UI at
204
- `/docs` has an **Authorize** button: enter the key once and every write on the page
205
- carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
334
+ `/docs` has an **Authorize** button: enter the key once and everything on the page that
335
+ needs it carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
206
336
  or a Home Assistant `rest_command` changes.
207
337
 
208
338
  That page is configured to remember the key, so it survives a reload or a browser
@@ -273,6 +403,21 @@ the sign simulator", in `.vscode/launch.json` and in `.idea/runConfigurations/`,
273
403
  configurations for running the pieces separately. Both carry the three way one as
274
404
  "readerboard, the sign simulator and the client" as well.
275
405
 
406
+ `scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
407
+ service and the client, no simulator, and the sign's address passed as an argument so
408
+ that it can be edited in a run configuration dialog. Both editors carry it as
409
+ "readerboard against the real sign and the client". The section above has the rest,
410
+ including the one thing about it that is dangerous. The two launchers share their
411
+ process supervision through `scripts/_supervise.py` and differ in what each child is
412
+ given, which is the part that matters: the simulator launcher discards its state file
413
+ on every run and this one never discards anything.
414
+
415
+ Every one of those that starts the service sets `READERBOARD_OPEN_DOCS`, so `/docs`
416
+ opens in a browser once the port answers. The service does the waiting and the
417
+ opening, which is why it lands on the port actually bound rather than one repeated in
418
+ a launch file. The setting is off unless asked for, so an installed service opens
419
+ nothing.
420
+
276
421
  ## Licence
277
422
 
278
423
  MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readerboard"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -48,8 +48,10 @@ dependencies = [
48
48
  # measurable. What they cost is real, though. uvloop, httptools and PyYAML
49
49
  # publish no 32-bit arm wheel, and building all three from source under
50
50
  # emulation was eight minutes of the container image's build. The extra also
51
- # brought in websockets, for a surface this API does not have, and watchfiles,
52
- # for a --reload this never runs with.
51
+ # brought in websockets, for a surface this API does not have, and
52
+ # watchfiles, which only makes --reload quicker. That flag is for working on
53
+ # the service rather than for running one, and uvicorn polls the tree
54
+ # without it.
53
55
  "uvicorn>=0.52.1,<0.53",
54
56
  "pydantic>=2.13.4,<3",
55
57
  "pydantic-settings>=2.15.0,<3",
@@ -66,6 +68,17 @@ dev = [
66
68
  # starlette's TestClient moved to httpx2; plain httpx now raises a
67
69
  # deprecation warning, which this suite is configured to treat as an error.
68
70
  "httpx2>=2.10.0,<3",
71
+ # The same thing again, one layer down. anyio 4.15.0 deprecated the
72
+ # anyio.abc.BlockingPortal alias, starlette's TestClient still imports it at
73
+ # module scope, and filterwarnings = ["error"] turns that into a collection
74
+ # error before a single test runs. It is not our deprecation to fix, and the
75
+ # ceiling comes off when starlette stops using the old name.
76
+ #
77
+ # This is a ceiling on a package nothing here imports, which is worth saying
78
+ # plainly: anyio arrives through fastapi and starlette, and pinning it in a
79
+ # dev extra is how a transitive dependency gets held still without a lock
80
+ # file. Issue #41 is about doing this properly.
81
+ "anyio>=4.14.2,<4.15",
69
82
  "ruff>=0.16.2,<0.17",
70
83
  "mypy>=2.3.0,<3",
71
84
  ]
@@ -167,9 +180,3 @@ enable_error_code = ["redundant-expr", "truthy-bool", "ignore-without-code"]
167
180
  # pyserial ships no type information.
168
181
  module = ["serial", "serial.*"]
169
182
  ignore_missing_imports = true
170
-
171
- [[tool.mypy.overrides]]
172
- # The constants module is a flat table of byte literals; annotating every one of
173
- # them would add nothing a reader does not already see.
174
- module = "readerboard.protocol.constants"
175
- disallow_untyped_defs = false
@@ -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.3.0"
10
+ __version__ = "0.4.0"