readerboard 0.1.4__tar.gz → 0.2.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.1.4/readerboard.egg-info → readerboard-0.2.0}/PKG-INFO +44 -5
- {readerboard-0.1.4 → readerboard-0.2.0}/README.md +43 -4
- {readerboard-0.1.4 → readerboard-0.2.0}/pyproject.toml +14 -3
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/__init__.py +1 -1
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/app.py +10 -2
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/deps.py +31 -2
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/routes_simple.py +5 -3
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/constants.py +1 -1
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/tokens.py +1 -1
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/registry.py +3 -3
- {readerboard-0.1.4 → readerboard-0.2.0/readerboard.egg-info}/PKG-INFO +44 -5
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_api.py +98 -1
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_markup.py +2 -2
- {readerboard-0.1.4 → readerboard-0.2.0}/LICENSE +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/__main__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/models.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/routes_v2.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/config.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/logging_setup.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/frames.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/markup.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/py.typed +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/alerts.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/clock.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/commands.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/controller.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/layout.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/state.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/__init__.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/base.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/fake.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/serial_link.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/SOURCES.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/dependency_links.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/entry_points.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/requires.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/top_level.txt +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/setup.cfg +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_alerts.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_clock.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_constant_values.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_controller.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_frames.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_registry.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_state.py +0 -0
- {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readerboard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync
|
|
5
5
|
Author: mjaksn
|
|
6
6
|
License-Expression: MIT
|
|
@@ -106,6 +106,20 @@ docker run --rm -p 5001:5001 \
|
|
|
106
106
|
|
|
107
107
|
Then open <http://127.0.0.1:5001/docs>.
|
|
108
108
|
|
|
109
|
+
`loop://` swallows everything written to it, so the service runs but there is
|
|
110
|
+
nothing to see. To watch what it would have sent, run it against the sign
|
|
111
|
+
simulator in `tools/signsim/` instead:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
pip install --require-hashes -r tools/signsim/requirements.lock
|
|
115
|
+
python scripts/run_with_simulator.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
That starts the simulator and the service together, already pointed at each
|
|
119
|
+
other, and stops both on Ctrl+C. The simulator decodes each transmission, says
|
|
120
|
+
what every byte of it means, and shows what the sign would be holding as a
|
|
121
|
+
result. `tools/signsim/README.md` has the details.
|
|
122
|
+
|
|
109
123
|
## Installing it properly
|
|
110
124
|
|
|
111
125
|
Two ways, which do the same job. Pick whichever suits the machine.
|
|
@@ -161,7 +175,8 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
|
|
|
161
175
|
|
|
162
176
|
## Using it
|
|
163
177
|
|
|
164
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
178
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
|
|
179
|
+
Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
|
|
165
180
|
|
|
166
181
|
Register a message:
|
|
167
182
|
|
|
@@ -212,9 +227,11 @@ outcome in the body:
|
|
|
212
227
|
```
|
|
213
228
|
|
|
214
229
|
That suits a client which finds branching on status codes awkward, such as a Home
|
|
215
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The
|
|
216
|
-
|
|
217
|
-
|
|
230
|
+
Assistant `rest_command` or a shell one-liner in a cron job. The exceptions are the
|
|
231
|
+
requests that never reach the endpoint at all: a missing or wrong API key is a 401, a
|
|
232
|
+
service with no API key configured is a 503, and a body that is not the shape the endpoint
|
|
233
|
+
declares gets FastAPI's own 422. A caller the service will not talk to, and a body it
|
|
234
|
+
cannot read, are not the same as a request that failed.
|
|
218
235
|
|
|
219
236
|
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
220
237
|
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
@@ -242,6 +259,19 @@ the log, but they are not settings to fiddle with.
|
|
|
242
259
|
## Security
|
|
243
260
|
|
|
244
261
|
An API key is required on every write, compared in constant time, and never logged.
|
|
262
|
+
Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
|
|
263
|
+
that could write to it.
|
|
264
|
+
|
|
265
|
+
The key is declared to the API description as a security scheme, so the Swagger UI at
|
|
266
|
+
`/docs` has an **Authorize** button: enter the key once and every write on the page
|
|
267
|
+
carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
|
|
268
|
+
or a Home Assistant `rest_command` changes.
|
|
269
|
+
|
|
270
|
+
That page is configured to remember the key, so it survives a reload or a browser
|
|
271
|
+
restart rather than needing to be pasted in again. Convenient on your own machine, and
|
|
272
|
+
worth knowing before you use **Authorize** on a shared or kiosk browser, where the next
|
|
273
|
+
person to open `/docs` inherits it. Use the browser's Logout in the Authorize dialog, or
|
|
274
|
+
just do not authorize there.
|
|
245
275
|
|
|
246
276
|
**Message content reaches the sign as protocol bytes**, so it is worth knowing what a
|
|
247
277
|
client holding the key can do. The markup renderer emits bytes only for tokens it
|
|
@@ -291,6 +321,15 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
291
321
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
292
322
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
293
323
|
|
|
324
|
+
`tools/signsim/` is a PySide6 stand-in for the sign, described above. Its own tests
|
|
325
|
+
are collected by the `pytest` run here and need no Qt installed; the application does,
|
|
326
|
+
and it is pinned separately so that nothing the service installs ever pulls Qt in.
|
|
327
|
+
|
|
328
|
+
`scripts/run_with_simulator.py` starts the service and the simulator together. Both
|
|
329
|
+
editors have it as a launch configuration, "API and sign simulator" in
|
|
330
|
+
`.vscode/launch.json` and "API and simulator" in `.idea/runConfigurations/`, along with
|
|
331
|
+
configurations for each half on its own.
|
|
332
|
+
|
|
294
333
|
## Licence
|
|
295
334
|
|
|
296
335
|
MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
|
|
@@ -64,6 +64,20 @@ docker run --rm -p 5001:5001 \
|
|
|
64
64
|
|
|
65
65
|
Then open <http://127.0.0.1:5001/docs>.
|
|
66
66
|
|
|
67
|
+
`loop://` swallows everything written to it, so the service runs but there is
|
|
68
|
+
nothing to see. To watch what it would have sent, run it against the sign
|
|
69
|
+
simulator in `tools/signsim/` instead:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
pip install --require-hashes -r tools/signsim/requirements.lock
|
|
73
|
+
python scripts/run_with_simulator.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That starts the simulator and the service together, already pointed at each
|
|
77
|
+
other, and stops both on Ctrl+C. The simulator decodes each transmission, says
|
|
78
|
+
what every byte of it means, and shows what the sign would be holding as a
|
|
79
|
+
result. `tools/signsim/README.md` has the details.
|
|
80
|
+
|
|
67
81
|
## Installing it properly
|
|
68
82
|
|
|
69
83
|
Two ways, which do the same job. Pick whichever suits the machine.
|
|
@@ -119,7 +133,8 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
|
|
|
119
133
|
|
|
120
134
|
## Using it
|
|
121
135
|
|
|
122
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
136
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
|
|
137
|
+
Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
|
|
123
138
|
|
|
124
139
|
Register a message:
|
|
125
140
|
|
|
@@ -170,9 +185,11 @@ outcome in the body:
|
|
|
170
185
|
```
|
|
171
186
|
|
|
172
187
|
That suits a client which finds branching on status codes awkward, such as a Home
|
|
173
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
Assistant `rest_command` or a shell one-liner in a cron job. The exceptions are the
|
|
189
|
+
requests that never reach the endpoint at all: a missing or wrong API key is a 401, a
|
|
190
|
+
service with no API key configured is a 503, and a body that is not the shape the endpoint
|
|
191
|
+
declares gets FastAPI's own 422. A caller the service will not talk to, and a body it
|
|
192
|
+
cannot read, are not the same as a request that failed.
|
|
176
193
|
|
|
177
194
|
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
178
195
|
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
@@ -200,6 +217,19 @@ the log, but they are not settings to fiddle with.
|
|
|
200
217
|
## Security
|
|
201
218
|
|
|
202
219
|
An API key is required on every write, compared in constant time, and never logged.
|
|
220
|
+
Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
|
|
221
|
+
that could write to it.
|
|
222
|
+
|
|
223
|
+
The key is declared to the API description as a security scheme, so the Swagger UI at
|
|
224
|
+
`/docs` has an **Authorize** button: enter the key once and every write on the page
|
|
225
|
+
carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
|
|
226
|
+
or a Home Assistant `rest_command` changes.
|
|
227
|
+
|
|
228
|
+
That page is configured to remember the key, so it survives a reload or a browser
|
|
229
|
+
restart rather than needing to be pasted in again. Convenient on your own machine, and
|
|
230
|
+
worth knowing before you use **Authorize** on a shared or kiosk browser, where the next
|
|
231
|
+
person to open `/docs` inherits it. Use the browser's Logout in the Authorize dialog, or
|
|
232
|
+
just do not authorize there.
|
|
203
233
|
|
|
204
234
|
**Message content reaches the sign as protocol bytes**, so it is worth knowing what a
|
|
205
235
|
client holding the key can do. The markup renderer emits bytes only for tokens it
|
|
@@ -249,6 +279,15 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
249
279
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
250
280
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
251
281
|
|
|
282
|
+
`tools/signsim/` is a PySide6 stand-in for the sign, described above. Its own tests
|
|
283
|
+
are collected by the `pytest` run here and need no Qt installed; the application does,
|
|
284
|
+
and it is pinned separately so that nothing the service installs ever pulls Qt in.
|
|
285
|
+
|
|
286
|
+
`scripts/run_with_simulator.py` starts the service and the simulator together. Both
|
|
287
|
+
editors have it as a launch configuration, "API and sign simulator" in
|
|
288
|
+
`.vscode/launch.json` and "API and simulator" in `.idea/runConfigurations/`, along with
|
|
289
|
+
configurations for each half on its own.
|
|
290
|
+
|
|
252
291
|
## Licence
|
|
253
292
|
|
|
254
293
|
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.2.0"
|
|
8
8
|
description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -97,7 +97,10 @@ packages = [
|
|
|
97
97
|
readerboard = ["py.typed"]
|
|
98
98
|
|
|
99
99
|
[tool.pytest.ini_options]
|
|
100
|
-
|
|
100
|
+
# The simulator under tools/ is collected too. Its tests import only the pure
|
|
101
|
+
# half of it, never PySide6, so they run in CI where Qt is not installed, and
|
|
102
|
+
# the round trip they check is against the frame builders in this package.
|
|
103
|
+
testpaths = ["tests", "tools/signsim/tests"]
|
|
101
104
|
addopts = "-q --strict-markers --strict-config"
|
|
102
105
|
asyncio_mode = "auto"
|
|
103
106
|
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -136,8 +139,16 @@ ignore = [
|
|
|
136
139
|
# and rewriting it into docstring-bearing prose would only obscure its origin.
|
|
137
140
|
"readerboard/protocol/constants.py" = ["E501", "RUF001", "RUF003"]
|
|
138
141
|
# A test's name is its documentation, and a docstring repeating it would be
|
|
139
|
-
# worse than none. Tests that need explaining have one anyway.
|
|
142
|
+
# worse than none. Tests that need explaining have one anyway. The simulator's
|
|
143
|
+
# tests are named separately because this glob is anchored at the project root
|
|
144
|
+
# and does not reach into tools/.
|
|
140
145
|
"tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
146
|
+
"tools/signsim/tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
|
|
147
|
+
|
|
148
|
+
[tool.ruff.lint.isort]
|
|
149
|
+
# The simulator is not under the project root, so import sorting would put it
|
|
150
|
+
# in the third party block and then complain that it is in the wrong one.
|
|
151
|
+
known-first-party = ["readerboard", "signsim"]
|
|
141
152
|
|
|
142
153
|
[tool.ruff.lint.pydocstyle]
|
|
143
154
|
convention = "pep257"
|
|
@@ -52,11 +52,15 @@ Several sources can share the sign at once. Each registers a named **slot**, and
|
|
|
52
52
|
the sign rotates through the registered slots by itself. An **alert** takes the
|
|
53
53
|
whole display over until it is released, then the rotation resumes.
|
|
54
54
|
|
|
55
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
55
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. On this
|
|
56
|
+
page, put the key in once with the **Authorize** button and every write below
|
|
57
|
+
carries it.
|
|
56
58
|
|
|
57
59
|
The `/Write` and `/Enumerations` paths are a smaller surface for clients that
|
|
58
60
|
would rather not read status codes: every response there is a 200 with the
|
|
59
|
-
outcome in the body.
|
|
61
|
+
outcome in the body, unless the request never reaches the route at all. A missing
|
|
62
|
+
or wrong `X-API-Key` is a 401, a service with no API key configured is a 503, and
|
|
63
|
+
a body that is not the shape the endpoint declares is a 422.
|
|
60
64
|
"""
|
|
61
65
|
|
|
62
66
|
|
|
@@ -196,6 +200,10 @@ def create_app(settings: Settings | None = None, transport: Transport | None = N
|
|
|
196
200
|
description=DESCRIPTION,
|
|
197
201
|
version=__version__,
|
|
198
202
|
lifespan=lifespan,
|
|
203
|
+
# Keep the key entered in the Swagger UI's Authorize dialog across a page
|
|
204
|
+
# reload. Without it every reload is another trip to the config file for
|
|
205
|
+
# somebody trying things out, which is most of what /docs is for.
|
|
206
|
+
swagger_ui_parameters={"persistAuthorization": True},
|
|
199
207
|
)
|
|
200
208
|
app.state.settings = settings
|
|
201
209
|
|
|
@@ -11,7 +11,8 @@ from __future__ import annotations
|
|
|
11
11
|
import hmac
|
|
12
12
|
from typing import Annotated
|
|
13
13
|
|
|
14
|
-
from fastapi import Depends,
|
|
14
|
+
from fastapi import Depends, HTTPException, Request, Security, status
|
|
15
|
+
from fastapi.security import APIKeyHeader
|
|
15
16
|
|
|
16
17
|
from readerboard.config import Settings
|
|
17
18
|
from readerboard.services.alerts import AlertService
|
|
@@ -21,6 +22,34 @@ from readerboard.sign.controller import SignController
|
|
|
21
22
|
|
|
22
23
|
API_KEY_HEADER = "X-API-Key"
|
|
23
24
|
|
|
25
|
+
# Declaring the key as a security scheme rather than as a plain header parameter
|
|
26
|
+
# is what puts the Authorize button in the Swagger UI, and what makes a generated
|
|
27
|
+
# client treat it as a credential rather than as one more header to fill in per
|
|
28
|
+
# call. The header and the value are exactly what they always were.
|
|
29
|
+
#
|
|
30
|
+
# `auto_error=False` is load bearing. Left at its default the scheme rejects a
|
|
31
|
+
# missing key itself, with its own wording and with no way to tell "you sent no
|
|
32
|
+
# key" apart from "this service has no key configured at all". Those are
|
|
33
|
+
# different answers, 401 and 503, and the simple endpoints document the
|
|
34
|
+
# difference, so the checking stays in `require_api_key` below and this declares
|
|
35
|
+
# the scheme and nothing else.
|
|
36
|
+
# The scheme name becomes a key under `components.securitySchemes`, and the
|
|
37
|
+
# OpenAPI specification requires those to match `^[a-zA-Z0-9\.\-_]+$` (section
|
|
38
|
+
# 4.8.7.1). So it cannot be the prettier "API key", however much better that
|
|
39
|
+
# reads in the Authorize dialog: a space there makes the whole document invalid
|
|
40
|
+
# and trips validators and client generators. The human wording lives in the
|
|
41
|
+
# description below, which is what the dialog shows underneath the name.
|
|
42
|
+
api_key_scheme = APIKeyHeader(
|
|
43
|
+
name=API_KEY_HEADER,
|
|
44
|
+
auto_error=False,
|
|
45
|
+
scheme_name="ApiKeyAuth",
|
|
46
|
+
description=(
|
|
47
|
+
"The shared key every write carries. Set it with `api_key` in the config "
|
|
48
|
+
"file or `READERBOARD_API_KEY` in the environment; `scripts/install.sh` "
|
|
49
|
+
"generates one."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
24
53
|
|
|
25
54
|
def get_settings(request: Request) -> Settings:
|
|
26
55
|
"""Return the service's configuration."""
|
|
@@ -54,7 +83,7 @@ def get_clock(request: Request) -> ClockService:
|
|
|
54
83
|
|
|
55
84
|
def require_api_key(
|
|
56
85
|
request: Request,
|
|
57
|
-
x_api_key: Annotated[str | None,
|
|
86
|
+
x_api_key: Annotated[str | None, Security(api_key_scheme)] = None,
|
|
58
87
|
) -> None:
|
|
59
88
|
"""Reject a write that does not carry the configured API key.
|
|
60
89
|
|
|
@@ -4,9 +4,11 @@ Fixed paths, one message, and **every response is HTTP 200** with the outcome in
|
|
|
4
4
|
the body. That suits a Home Assistant ``rest_command`` or a shell one-liner in a
|
|
5
5
|
cron job, neither of which branches gracefully on a status code.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
The exceptions to always-200 are all requests that never reach the code below,
|
|
8
|
+
and they are deliberate. A missing or wrong API key is a 401, and a service with
|
|
9
|
+
no API key configured at all is a 503, because a caller the service will not talk
|
|
10
|
+
to is not the same as a request that failed. A body that is not the shape the
|
|
11
|
+
endpoint declares gets FastAPI's own 422 before any of this runs.
|
|
10
12
|
|
|
11
13
|
``POST /Write/Message`` writes to a reserved slot rather than to the sign's
|
|
12
14
|
priority file. That distinction matters more than it looks. By protocol a
|
|
@@ -398,7 +398,7 @@ o_TILDE = b"\xc1" # lowecase 'o' with tilde
|
|
|
398
398
|
|
|
399
399
|
# ===========================================================================
|
|
400
400
|
# Constants added for this service, sourced from the Alpha Sign Communications
|
|
401
|
-
# Protocol (Adaptive Micro Systems, form 9708-
|
|
401
|
+
# Protocol (Adaptive Micro Systems, form 9708-8061E). See docs/protocol-notes.md
|
|
402
402
|
# for where each of these came from and what is still unconfirmed on hardware.
|
|
403
403
|
# ===========================================================================
|
|
404
404
|
|
|
@@ -162,5 +162,5 @@ POSITION_BY_NAME: dict[str, Token] = {token.text: token for token in TEXT_POSITI
|
|
|
162
162
|
|
|
163
163
|
|
|
164
164
|
def describe(tokens: tuple[Token, ...], key: str) -> list[dict[str, str]]:
|
|
165
|
-
"""Render a token table as
|
|
165
|
+
"""Render a token table as a list of dicts keyed by ``key`` and "description"."""
|
|
166
166
|
return [{key: token.text, "description": token.description} for token in tokens]
|
|
@@ -149,9 +149,9 @@ class MessageRegistry:
|
|
|
149
149
|
Refreshing drops what the controller believes about the sign's contents
|
|
150
150
|
and writes it all again. It runs on a timer, and on every reconnect.
|
|
151
151
|
|
|
152
|
-
If the
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
If the spike shows the sign answers read commands through the adapter,
|
|
153
|
+
this can become a read-back comparison that only writes on a real
|
|
154
|
+
mismatch. The frame builders for those reads already exist; what is
|
|
155
155
|
unproven is whether two-way traffic works over that path at all.
|
|
156
156
|
"""
|
|
157
157
|
async with self._lock:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readerboard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync
|
|
5
5
|
Author: mjaksn
|
|
6
6
|
License-Expression: MIT
|
|
@@ -106,6 +106,20 @@ docker run --rm -p 5001:5001 \
|
|
|
106
106
|
|
|
107
107
|
Then open <http://127.0.0.1:5001/docs>.
|
|
108
108
|
|
|
109
|
+
`loop://` swallows everything written to it, so the service runs but there is
|
|
110
|
+
nothing to see. To watch what it would have sent, run it against the sign
|
|
111
|
+
simulator in `tools/signsim/` instead:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
pip install --require-hashes -r tools/signsim/requirements.lock
|
|
115
|
+
python scripts/run_with_simulator.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
That starts the simulator and the service together, already pointed at each
|
|
119
|
+
other, and stops both on Ctrl+C. The simulator decodes each transmission, says
|
|
120
|
+
what every byte of it means, and shows what the sign would be holding as a
|
|
121
|
+
result. `tools/signsim/README.md` has the details.
|
|
122
|
+
|
|
109
123
|
## Installing it properly
|
|
110
124
|
|
|
111
125
|
Two ways, which do the same job. Pick whichever suits the machine.
|
|
@@ -161,7 +175,8 @@ docker run ... --device /dev/ttyUSB0 --group-add 20 \
|
|
|
161
175
|
|
|
162
176
|
## Using it
|
|
163
177
|
|
|
164
|
-
Every write needs an `X-API-Key` header. `GET /health`
|
|
178
|
+
Every write needs an `X-API-Key` header. Reads and `GET /health` do not. In the
|
|
179
|
+
Swagger UI at `/docs`, the **Authorize** button puts it in once for the whole page.
|
|
165
180
|
|
|
166
181
|
Register a message:
|
|
167
182
|
|
|
@@ -212,9 +227,11 @@ outcome in the body:
|
|
|
212
227
|
```
|
|
213
228
|
|
|
214
229
|
That suits a client which finds branching on status codes awkward, such as a Home
|
|
215
|
-
Assistant `rest_command` or a shell one-liner in a cron job. The
|
|
216
|
-
|
|
217
|
-
|
|
230
|
+
Assistant `rest_command` or a shell one-liner in a cron job. The exceptions are the
|
|
231
|
+
requests that never reach the endpoint at all: a missing or wrong API key is a 401, a
|
|
232
|
+
service with no API key configured is a 503, and a body that is not the shape the endpoint
|
|
233
|
+
declares gets FastAPI's own 422. A caller the service will not talk to, and a body it
|
|
234
|
+
cannot read, are not the same as a request that failed.
|
|
218
235
|
|
|
219
236
|
`POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
|
|
220
237
|
not touch the sign's **priority** file, which by protocol suppresses every other message on
|
|
@@ -242,6 +259,19 @@ the log, but they are not settings to fiddle with.
|
|
|
242
259
|
## Security
|
|
243
260
|
|
|
244
261
|
An API key is required on every write, compared in constant time, and never logged.
|
|
262
|
+
Reads and `GET /health` need none, so a monitor can watch the sign without holding a key
|
|
263
|
+
that could write to it.
|
|
264
|
+
|
|
265
|
+
The key is declared to the API description as a security scheme, so the Swagger UI at
|
|
266
|
+
`/docs` has an **Authorize** button: enter the key once and every write on the page
|
|
267
|
+
carries it. It is the same `X-API-Key` header a client sends, so nothing about a script
|
|
268
|
+
or a Home Assistant `rest_command` changes.
|
|
269
|
+
|
|
270
|
+
That page is configured to remember the key, so it survives a reload or a browser
|
|
271
|
+
restart rather than needing to be pasted in again. Convenient on your own machine, and
|
|
272
|
+
worth knowing before you use **Authorize** on a shared or kiosk browser, where the next
|
|
273
|
+
person to open `/docs` inherits it. Use the browser's Logout in the Authorize dialog, or
|
|
274
|
+
just do not authorize there.
|
|
245
275
|
|
|
246
276
|
**Message content reaches the sign as protocol bytes**, so it is worth knowing what a
|
|
247
277
|
client holding the key can do. The markup renderer emits bytes only for tokens it
|
|
@@ -291,6 +321,15 @@ claim. Read it before changing anything in `readerboard/protocol/`.
|
|
|
291
321
|
`scripts/protocol_spike.py` settles the few questions the document cannot answer about
|
|
292
322
|
this particular sign. It is destructive and refuses to run without `--confirm-erase`.
|
|
293
323
|
|
|
324
|
+
`tools/signsim/` is a PySide6 stand-in for the sign, described above. Its own tests
|
|
325
|
+
are collected by the `pytest` run here and need no Qt installed; the application does,
|
|
326
|
+
and it is pinned separately so that nothing the service installs ever pulls Qt in.
|
|
327
|
+
|
|
328
|
+
`scripts/run_with_simulator.py` starts the service and the simulator together. Both
|
|
329
|
+
editors have it as a launch configuration, "API and sign simulator" in
|
|
330
|
+
`.vscode/launch.json` and "API and simulator" in `.idea/runConfigurations/`, along with
|
|
331
|
+
configurations for each half on its own.
|
|
332
|
+
|
|
294
333
|
## Licence
|
|
295
334
|
|
|
296
335
|
MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for the HTTP surface, including the exact payloads already in use."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from collections.abc import Iterator
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
@@ -57,6 +58,19 @@ class TestHealth:
|
|
|
57
58
|
assert KEY not in client.get("/health").text
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
class _AnyDescription:
|
|
62
|
+
"""Matches any description that tells a reader where the key comes from."""
|
|
63
|
+
|
|
64
|
+
def __eq__(self, other: object) -> bool:
|
|
65
|
+
return isinstance(other, str) and "READERBOARD_API_KEY" in other
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
return "<a description mentioning READERBOARD_API_KEY>"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ANY_DESCRIPTION = _AnyDescription()
|
|
72
|
+
|
|
73
|
+
|
|
60
74
|
class TestAuth:
|
|
61
75
|
def test_a_write_without_a_key_is_refused(self, client):
|
|
62
76
|
response = client.put("/v2/messages/temperature", json={"message": "HI"})
|
|
@@ -80,12 +94,95 @@ class TestAuth:
|
|
|
80
94
|
assert client.get("/v2/messages").status_code == 200
|
|
81
95
|
|
|
82
96
|
def test_the_simple_endpoints_also_need_it(self, client):
|
|
83
|
-
#
|
|
97
|
+
# One of the three exceptions to their always-200 rule; the others are
|
|
98
|
+
# a service with no API key configured (503) and a malformed body (422).
|
|
84
99
|
response = client.post(
|
|
85
100
|
"/Write/Message", json={"display_mode": "HOLD", "message": "HI"}
|
|
86
101
|
)
|
|
87
102
|
assert response.status_code == 401
|
|
88
103
|
|
|
104
|
+
def test_the_refusal_says_which_header_is_wanted(self, client):
|
|
105
|
+
# The scheme is declared with auto_error=False precisely so this wording
|
|
106
|
+
# and the 503 below stay ours rather than becoming "Not authenticated".
|
|
107
|
+
response = client.put("/v2/messages/temperature", json={"message": "HI"})
|
|
108
|
+
assert response.json()["detail"] == "a valid X-API-Key header is required"
|
|
109
|
+
assert response.headers["WWW-Authenticate"] == "X-API-Key"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestTheKeyIsDeclaredAsASecurityScheme:
|
|
113
|
+
"""What puts the Authorize button in the Swagger UI.
|
|
114
|
+
|
|
115
|
+
The key was a plain header parameter once, which worked but told neither
|
|
116
|
+
the documentation page nor a generated client that it was a credential.
|
|
117
|
+
These pin the shape rather than the rendering, since the rendering is
|
|
118
|
+
Swagger's business.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def test_the_scheme_is_declared(self, settings, sign):
|
|
122
|
+
schema = create_app(settings, transport=sign).openapi()
|
|
123
|
+
assert schema["components"]["securitySchemes"] == {
|
|
124
|
+
"ApiKeyAuth": {
|
|
125
|
+
"type": "apiKey",
|
|
126
|
+
"in": "header",
|
|
127
|
+
"name": "X-API-Key",
|
|
128
|
+
"description": ANY_DESCRIPTION,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def test_every_write_requires_it(self, settings, sign):
|
|
133
|
+
schema = create_app(settings, transport=sign).openapi()
|
|
134
|
+
for path, method in [
|
|
135
|
+
("/v2/messages/{key}", "put"),
|
|
136
|
+
("/v2/messages/{key}", "delete"),
|
|
137
|
+
("/v2/messages", "delete"),
|
|
138
|
+
("/v2/alerts", "post"),
|
|
139
|
+
("/v2/alerts", "delete"),
|
|
140
|
+
("/v2/sign/sync-clock", "post"),
|
|
141
|
+
("/v2/sign/command", "post"),
|
|
142
|
+
("/Write/Message", "post"),
|
|
143
|
+
("/Write/ControlCommand", "post"),
|
|
144
|
+
]:
|
|
145
|
+
assert schema["paths"][path][method]["security"] == [{"ApiKeyAuth": []}], (
|
|
146
|
+
"%s %s should be marked as needing the key" % (method.upper(), path)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def test_the_open_endpoints_are_not_marked_as_needing_it(self, settings, sign):
|
|
150
|
+
# Health is deliberately unauthenticated so a monitor can watch the sign
|
|
151
|
+
# without holding a key that could write to it, and the reads are open
|
|
152
|
+
# too. The document should say so rather than leave it to be guessed.
|
|
153
|
+
schema = create_app(settings, transport=sign).openapi()
|
|
154
|
+
for path, method in [
|
|
155
|
+
("/health", "get"),
|
|
156
|
+
("/v2/messages", "get"),
|
|
157
|
+
("/v2/alerts", "get"),
|
|
158
|
+
("/v2/enumerations/display-modes", "get"),
|
|
159
|
+
]:
|
|
160
|
+
assert "security" not in schema["paths"][path][method]
|
|
161
|
+
|
|
162
|
+
def test_every_component_key_is_one_the_specification_allows(self, settings, sign):
|
|
163
|
+
# OpenAPI 3.1 section 4.8.7.1: "All the fixed fields declared above are
|
|
164
|
+
# objects that MUST use keys that match the regular expression:
|
|
165
|
+
# ^[a-zA-Z0-9\.\-_]+$". The scheme name became one of those keys, and a
|
|
166
|
+
# readable "API key" with a space in it made the whole document invalid
|
|
167
|
+
# for anything stricter than the Swagger UI. Nothing here catches that by
|
|
168
|
+
# itself: the CI check only diffs the generated document against the
|
|
169
|
+
# committed one, so both sides would be equally wrong.
|
|
170
|
+
schema = create_app(settings, transport=sign).openapi()
|
|
171
|
+
allowed = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
172
|
+
for section, entries in schema.get("components", {}).items():
|
|
173
|
+
for key in entries:
|
|
174
|
+
assert allowed.match(key), "components.%s has the key %r" % (section, key)
|
|
175
|
+
|
|
176
|
+
def test_the_header_is_no_longer_a_parameter_on_every_operation(self, settings, sign):
|
|
177
|
+
# The old shape put an optional X-API-Key parameter on each protected
|
|
178
|
+
# operation, which is what a reader had to fill in one endpoint at a
|
|
179
|
+
# time. One scheme replaces all of them.
|
|
180
|
+
schema = create_app(settings, transport=sign).openapi()
|
|
181
|
+
for path, operations in schema["paths"].items():
|
|
182
|
+
for method, operation in operations.items():
|
|
183
|
+
names = [one["name"] for one in operation.get("parameters", [])]
|
|
184
|
+
assert "X-API-Key" not in names, "%s %s" % (method.upper(), path)
|
|
185
|
+
|
|
89
186
|
|
|
90
187
|
class TestMessages:
|
|
91
188
|
def test_registering_and_reading_back(self, client):
|
|
@@ -39,8 +39,8 @@ class TestUnterminatedTag:
|
|
|
39
39
|
The obvious implementation searches for the closing bracket and advances the
|
|
40
40
|
cursor only when it finds one, so an unterminated tag leaves the cursor
|
|
41
41
|
where it was and the loop spins forever, taking the thread with it. Both
|
|
42
|
-
behaviours below are
|
|
43
|
-
|
|
42
|
+
behaviours below are deliberate: reject it where we can, pass it through
|
|
43
|
+
where we must.
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
46
|
def test_strict_rejects(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|