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.
Files changed (50) hide show
  1. {readerboard-0.1.4/readerboard.egg-info → readerboard-0.2.0}/PKG-INFO +44 -5
  2. {readerboard-0.1.4 → readerboard-0.2.0}/README.md +43 -4
  3. {readerboard-0.1.4 → readerboard-0.2.0}/pyproject.toml +14 -3
  4. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/__init__.py +1 -1
  5. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/app.py +10 -2
  6. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/deps.py +31 -2
  7. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/routes_simple.py +5 -3
  8. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/constants.py +1 -1
  9. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/tokens.py +1 -1
  10. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/registry.py +3 -3
  11. {readerboard-0.1.4 → readerboard-0.2.0/readerboard.egg-info}/PKG-INFO +44 -5
  12. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_api.py +98 -1
  13. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_markup.py +2 -2
  14. {readerboard-0.1.4 → readerboard-0.2.0}/LICENSE +0 -0
  15. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/__main__.py +0 -0
  16. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/__init__.py +0 -0
  17. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/models.py +0 -0
  18. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/api/routes_v2.py +0 -0
  19. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/config.py +0 -0
  20. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/logging_setup.py +0 -0
  21. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/__init__.py +0 -0
  22. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/frames.py +0 -0
  23. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/protocol/markup.py +0 -0
  24. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/py.typed +0 -0
  25. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/__init__.py +0 -0
  26. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/alerts.py +0 -0
  27. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/clock.py +0 -0
  28. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/services/commands.py +0 -0
  29. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/__init__.py +0 -0
  30. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/controller.py +0 -0
  31. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/layout.py +0 -0
  32. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/sign/state.py +0 -0
  33. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/__init__.py +0 -0
  34. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/base.py +0 -0
  35. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/fake.py +0 -0
  36. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard/transport/serial_link.py +0 -0
  37. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/SOURCES.txt +0 -0
  38. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/dependency_links.txt +0 -0
  39. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/entry_points.txt +0 -0
  40. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/requires.txt +0 -0
  41. {readerboard-0.1.4 → readerboard-0.2.0}/readerboard.egg-info/top_level.txt +0 -0
  42. {readerboard-0.1.4 → readerboard-0.2.0}/setup.cfg +0 -0
  43. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_alerts.py +0 -0
  44. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_clock.py +0 -0
  45. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_constant_values.py +0 -0
  46. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_controller.py +0 -0
  47. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_frames.py +0 -0
  48. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_registry.py +0 -0
  49. {readerboard-0.1.4 → readerboard-0.2.0}/tests/test_state.py +0 -0
  50. {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.1.4
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` does not.
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 single exception is a
216
- missing or wrong API key, which is a 401: a caller the service will not talk to is not the
217
- same as a request that failed.
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` does not.
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 single exception is a
174
- missing or wrong API key, which is a 401: a caller the service will not talk to is not the
175
- same as a request that failed.
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.1.4"
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
- testpaths = ["tests"]
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"
@@ -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.1.4"
10
+ __version__ = "0.2.0"
@@ -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` does not.
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, Header, HTTPException, Request, status
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, Header(alias=API_KEY_HEADER)] = 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
- There is one exception to always-200, and it is deliberate: a request without a
8
- valid API key gets a 401 like any other, because a caller the service will not
9
- talk to is not the same as a request that failed.
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-8061F). See docs/protocol-notes.md
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 the list of dicts the enumeration endpoints return."""
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 Phase 0 spike shows the sign answers read commands through the
153
- adapter, this can become a read-back comparison that only writes on a
154
- real mismatch. The frame builders for those reads already exist; what is
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.1.4
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` does not.
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 single exception is a
216
- missing or wrong API key, which is a 401: a caller the service will not talk to is not the
217
- same as a request that failed.
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
- # The single exception to their always-200 rule.
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
- deliberate: reject it where we can, pass it through where we must.
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