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.
- {readerboard-0.3.0/readerboard.egg-info → readerboard-0.4.0}/PKG-INFO +161 -15
- {readerboard-0.3.0 → readerboard-0.4.0}/README.md +159 -14
- {readerboard-0.3.0 → readerboard-0.4.0}/pyproject.toml +16 -9
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/__init__.py +1 -1
- readerboard-0.4.0/readerboard/__main__.py +171 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/app.py +29 -8
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/errors.py +6 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/models.py +57 -23
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/routes.py +86 -11
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/config.py +19 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/logging_setup.py +6 -4
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/constants.py +134 -16
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/frames.py +75 -8
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/markup.py +37 -0
- readerboard-0.4.0/readerboard/protocol/replies.py +190 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/tokens.py +94 -25
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/alerts.py +64 -7
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/clock.py +66 -8
- readerboard-0.4.0/readerboard/services/commands.py +174 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/registry.py +68 -30
- readerboard-0.4.0/readerboard/sign/controller.py +491 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/sign/state.py +13 -2
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/base.py +12 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/fake.py +25 -1
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/serial_link.py +81 -10
- {readerboard-0.3.0 → readerboard-0.4.0/readerboard.egg-info}/PKG-INFO +161 -15
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/SOURCES.txt +6 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/requires.txt +1 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_alerts.py +73 -3
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_api.py +299 -8
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_clock.py +109 -6
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_constant_values.py +108 -12
- readerboard-0.4.0/tests/test_controller.py +623 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_frames.py +50 -7
- readerboard-0.4.0/tests/test_launch_configurations.py +217 -0
- readerboard-0.4.0/tests/test_logging_setup.py +96 -0
- readerboard-0.4.0/tests/test_markup.py +246 -0
- readerboard-0.4.0/tests/test_open_docs.py +177 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_registry.py +104 -32
- readerboard-0.4.0/tests/test_replies.py +157 -0
- readerboard-0.4.0/tests/test_run_against_a_sign.py +276 -0
- readerboard-0.4.0/tests/test_run_with_simulator.py +89 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_state.py +0 -1
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_transport.py +106 -1
- readerboard-0.3.0/readerboard/__main__.py +0 -57
- readerboard-0.3.0/readerboard/services/commands.py +0 -83
- readerboard-0.3.0/readerboard/sign/controller.py +0 -278
- readerboard-0.3.0/tests/test_controller.py +0 -258
- readerboard-0.3.0/tests/test_launch_configurations.py +0 -67
- readerboard-0.3.0/tests/test_markup.py +0 -101
- {readerboard-0.3.0 → readerboard-0.4.0}/LICENSE +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/api/deps.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/names.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/py.typed +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/sign/layout.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/setup.cfg +0 -0
- {readerboard-0.3.0 → readerboard-0.4.0}/tests/test_component_names.py +0 -0
- {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
|
+
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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,
|
|
234
|
-
`
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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,
|
|
191
|
-
`
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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.
|
|
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
|
|
52
|
-
#
|
|
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
|