readerboard 0.1.1__py3-none-any.whl
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/__init__.py +10 -0
- readerboard/__main__.py +57 -0
- readerboard/api/__init__.py +1 -0
- readerboard/api/app.py +262 -0
- readerboard/api/deps.py +93 -0
- readerboard/api/models.py +266 -0
- readerboard/api/routes_simple.py +143 -0
- readerboard/api/routes_v2.py +207 -0
- readerboard/config.py +222 -0
- readerboard/logging_setup.py +53 -0
- readerboard/protocol/__init__.py +1 -0
- readerboard/protocol/constants.py +465 -0
- readerboard/protocol/frames.py +250 -0
- readerboard/protocol/markup.py +173 -0
- readerboard/protocol/tokens.py +166 -0
- readerboard/py.typed +0 -0
- readerboard/services/__init__.py +1 -0
- readerboard/services/alerts.py +183 -0
- readerboard/services/clock.py +111 -0
- readerboard/services/commands.py +83 -0
- readerboard/services/registry.py +402 -0
- readerboard/sign/__init__.py +1 -0
- readerboard/sign/controller.py +278 -0
- readerboard/sign/layout.py +112 -0
- readerboard/sign/state.py +171 -0
- readerboard/transport/__init__.py +1 -0
- readerboard/transport/base.py +53 -0
- readerboard/transport/fake.py +84 -0
- readerboard/transport/serial_link.py +159 -0
- readerboard-0.1.1.dist-info/METADATA +248 -0
- readerboard-0.1.1.dist-info/RECORD +35 -0
- readerboard-0.1.1.dist-info/WHEEL +5 -0
- readerboard-0.1.1.dist-info/entry_points.txt +2 -0
- readerboard-0.1.1.dist-info/licenses/LICENSE +21 -0
- readerboard-0.1.1.dist-info/top_level.txt +1 -0
readerboard/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""An HTTP service that drives a BetaBrite Classic sign over the Alpha protocol.
|
|
2
|
+
|
|
3
|
+
The version here and the one in pyproject.toml are checked against each other,
|
|
4
|
+
and against the release tag, before anything is published. See
|
|
5
|
+
.github/workflows/release.yml.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.1"
|
readerboard/__main__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Run the service.
|
|
2
|
+
|
|
3
|
+
This is what the systemd unit invokes and what ``readerboard`` on the command
|
|
4
|
+
line runs. It exists so the unit does not have to know a uvicorn invocation, and
|
|
5
|
+
so the host and port come from the same configuration as everything else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
from readerboard import __version__, logging_setup
|
|
15
|
+
from readerboard.config import Settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> int:
|
|
19
|
+
"""Start the HTTP server."""
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="readerboard",
|
|
22
|
+
description=(
|
|
23
|
+
"Serve the readerboard API, which drives a BetaBrite Classic sign. "
|
|
24
|
+
"Settings come from the config file "
|
|
25
|
+
"(/etc/readerboard/config.toml unless READERBOARD_CONFIG_FILE says otherwise) "
|
|
26
|
+
"and from environment variables prefixed READERBOARD_."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--version", action="version", version="readerboard %s" % __version__)
|
|
30
|
+
parser.add_argument("--host", help="override the configured listen address")
|
|
31
|
+
parser.add_argument("--port", type=int, help="override the configured port")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--print-config",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="show the settings in force, with the API key redacted, and exit",
|
|
36
|
+
)
|
|
37
|
+
args = parser.parse_args()
|
|
38
|
+
|
|
39
|
+
settings = Settings()
|
|
40
|
+
logging_setup.configure(settings.log_level, settings.log_file)
|
|
41
|
+
|
|
42
|
+
if args.print_config:
|
|
43
|
+
for key, value in sorted(settings.redacted().items()):
|
|
44
|
+
print("%-32s %s" % (key, value))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
uvicorn.run(
|
|
48
|
+
"readerboard.api.app:app",
|
|
49
|
+
host=args.host or settings.host,
|
|
50
|
+
port=args.port or settings.port,
|
|
51
|
+
log_config=None,
|
|
52
|
+
)
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The HTTP surface."""
|
readerboard/api/app.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Building the application and wiring its parts together.
|
|
2
|
+
|
|
3
|
+
Startup order matters. The transport is opened first, then the memory
|
|
4
|
+
configuration is settled, then the slots and any alert are put back, and only
|
|
5
|
+
then does anything start on a timer. Getting that wrong would mean writing
|
|
6
|
+
messages to files the sign has not allocated.
|
|
7
|
+
|
|
8
|
+
Nothing here fails to start because the sign is unreachable. A service that
|
|
9
|
+
refused to boot with the sign unplugged would need someone to notice and restart
|
|
10
|
+
it once the sign came back, which is precisely the situation it exists to
|
|
11
|
+
survive.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, Request, status
|
|
22
|
+
from fastapi.responses import JSONResponse
|
|
23
|
+
|
|
24
|
+
from readerboard import __version__, logging_setup
|
|
25
|
+
from readerboard.api import routes_simple, routes_v2
|
|
26
|
+
from readerboard.api.deps import get_alerts, get_clock, get_controller, get_registry
|
|
27
|
+
from readerboard.api.models import HealthResponse, LinkHealth
|
|
28
|
+
from readerboard.config import Settings
|
|
29
|
+
from readerboard.protocol.frames import ProtocolError
|
|
30
|
+
from readerboard.protocol.markup import MarkupError
|
|
31
|
+
from readerboard.services import commands
|
|
32
|
+
from readerboard.services.alerts import AlertService, AlertTooLong
|
|
33
|
+
from readerboard.services.clock import ClockService
|
|
34
|
+
from readerboard.services.registry import (
|
|
35
|
+
MessageRegistry,
|
|
36
|
+
MessageTooLong,
|
|
37
|
+
UnknownSlot,
|
|
38
|
+
)
|
|
39
|
+
from readerboard.sign.controller import SignController
|
|
40
|
+
from readerboard.sign.layout import Layout, LayoutFull
|
|
41
|
+
from readerboard.sign.state import StateStore
|
|
42
|
+
from readerboard.transport.base import Transport, TransportError
|
|
43
|
+
from readerboard.transport.serial_link import SerialTransport
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
DESCRIPTION = """
|
|
48
|
+
Drives a BetaBrite Classic sign over the Alpha protocol, either through a serial
|
|
49
|
+
cable or through an Ethernet to RS-232 adapter.
|
|
50
|
+
|
|
51
|
+
Several sources can share the sign at once. Each registers a named **slot**, and
|
|
52
|
+
the sign rotates through the registered slots by itself. An **alert** takes the
|
|
53
|
+
whole display over until it is released, then the rotation resumes.
|
|
54
|
+
|
|
55
|
+
Every write needs an `X-API-Key` header. `GET /health` does not.
|
|
56
|
+
|
|
57
|
+
The `/Write` and `/Enumerations` paths are a smaller surface for clients that
|
|
58
|
+
would rather not read status codes: every response there is a 200 with the
|
|
59
|
+
outcome in the body.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_transport(settings: Settings) -> Transport:
|
|
64
|
+
"""Create the link to the sign described by the settings."""
|
|
65
|
+
return SerialTransport(
|
|
66
|
+
settings.serial_url,
|
|
67
|
+
baud_rate=settings.baud_rate,
|
|
68
|
+
timeout=settings.serial_timeout,
|
|
69
|
+
backoff_initial=settings.backoff_initial,
|
|
70
|
+
backoff_max=settings.backoff_max,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _refresh_loop(app: FastAPI, interval: float) -> None:
|
|
75
|
+
"""Push everything to the sign again, periodically.
|
|
76
|
+
|
|
77
|
+
See ``MessageRegistry.refresh`` for why blind re-pushing is the only thing
|
|
78
|
+
that repairs a sign power cycled behind a still-connected adapter.
|
|
79
|
+
"""
|
|
80
|
+
registry: MessageRegistry = app.state.registry
|
|
81
|
+
alerts: AlertService = app.state.alerts
|
|
82
|
+
|
|
83
|
+
while True:
|
|
84
|
+
await asyncio.sleep(interval)
|
|
85
|
+
try:
|
|
86
|
+
await registry.refresh()
|
|
87
|
+
# The refresh puts the slots back, but an alert lives in the
|
|
88
|
+
# priority file, which the registry does not touch. Without this a
|
|
89
|
+
# sign power cycled mid-alert would stay blank until the alert's
|
|
90
|
+
# deadline, and an alert with no deadline would stay blank for good.
|
|
91
|
+
await alerts.reassert()
|
|
92
|
+
except TransportError as err:
|
|
93
|
+
logger.debug("periodic refresh skipped, sign unreachable: %s", err)
|
|
94
|
+
except Exception:
|
|
95
|
+
logger.exception("the periodic refresh failed")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _sweep_loop(app: FastAPI, interval: float) -> None:
|
|
99
|
+
"""Expire slots and alerts whose deadlines have passed."""
|
|
100
|
+
registry: MessageRegistry = app.state.registry
|
|
101
|
+
alerts: AlertService = app.state.alerts
|
|
102
|
+
|
|
103
|
+
while True:
|
|
104
|
+
await asyncio.sleep(interval)
|
|
105
|
+
try:
|
|
106
|
+
await alerts.sweep()
|
|
107
|
+
await registry.sweep()
|
|
108
|
+
except TransportError as err:
|
|
109
|
+
logger.warning("could not apply expiries: %s", err)
|
|
110
|
+
except Exception:
|
|
111
|
+
# A sweep that raises must not take the loop down with it, or
|
|
112
|
+
# nothing would ever expire again.
|
|
113
|
+
logger.exception("the expiry sweep failed")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_app(settings: Settings | None = None, transport: Transport | None = None) -> FastAPI:
|
|
117
|
+
"""Build the application.
|
|
118
|
+
|
|
119
|
+
``transport`` is for tests, which supply a capturing one. In the service it
|
|
120
|
+
is left unset and built from the settings.
|
|
121
|
+
"""
|
|
122
|
+
settings = settings or Settings()
|
|
123
|
+
|
|
124
|
+
@contextlib.asynccontextmanager
|
|
125
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
126
|
+
logging_setup.configure(settings.log_level, settings.log_file)
|
|
127
|
+
logger.info("readerboard %s starting; sign at %s", __version__, settings.serial_url)
|
|
128
|
+
|
|
129
|
+
link = transport if transport is not None else build_transport(settings)
|
|
130
|
+
controller = SignController(link, inter_packet_delay=settings.inter_packet_delay)
|
|
131
|
+
store = StateStore(settings.state_path)
|
|
132
|
+
state = store.load()
|
|
133
|
+
layout = Layout(settings.slot_count, settings.slot_capacity)
|
|
134
|
+
alerts = AlertService(controller, store, state)
|
|
135
|
+
registry = MessageRegistry(
|
|
136
|
+
controller, layout, store, state, alert_active=lambda: alerts.active is not None
|
|
137
|
+
)
|
|
138
|
+
# An alert holding the sign makes the registry hold back run sequence
|
|
139
|
+
# writes; releasing it is what lets them through.
|
|
140
|
+
alerts.set_release_hook(registry.flush_deferred)
|
|
141
|
+
clock = ClockService(
|
|
142
|
+
controller,
|
|
143
|
+
interval_seconds=settings.clock_sync_interval_seconds,
|
|
144
|
+
timezone=settings.timezone,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
app.state.settings = settings
|
|
148
|
+
app.state.controller = controller
|
|
149
|
+
app.state.registry = registry
|
|
150
|
+
app.state.alerts = alerts
|
|
151
|
+
app.state.clock = clock
|
|
152
|
+
|
|
153
|
+
await controller.start()
|
|
154
|
+
|
|
155
|
+
# The sign may be unreachable, and that is not a reason to refuse to
|
|
156
|
+
# start. What is put back below happens again on the next reconnect.
|
|
157
|
+
try:
|
|
158
|
+
await registry.restore()
|
|
159
|
+
await alerts.restore()
|
|
160
|
+
if settings.clock_sync_enabled:
|
|
161
|
+
await clock.sync_quietly()
|
|
162
|
+
except TransportError as err:
|
|
163
|
+
logger.warning("could not restore the sign's contents yet: %s", err)
|
|
164
|
+
|
|
165
|
+
# Only now, so that opening the link above does not fire hooks that
|
|
166
|
+
# duplicate the work just done. Registering them earlier meant every
|
|
167
|
+
# startup set the clock twice and sent a run sequence for an empty
|
|
168
|
+
# registry before the pool had even been allocated.
|
|
169
|
+
if settings.clock_sync_enabled:
|
|
170
|
+
controller.on_reconnect(clock.sync_quietly)
|
|
171
|
+
# A link that just came back may be in front of a sign that was power
|
|
172
|
+
# cycled, so nothing the controller believes about its contents holds.
|
|
173
|
+
controller.on_reconnect(registry.refresh)
|
|
174
|
+
|
|
175
|
+
if settings.clock_sync_enabled:
|
|
176
|
+
await clock.start()
|
|
177
|
+
sweeper = asyncio.create_task(_sweep_loop(app, settings.registry_sweep_seconds))
|
|
178
|
+
refresher = asyncio.create_task(
|
|
179
|
+
_refresh_loop(app, settings.refresh_interval_seconds)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
yield
|
|
184
|
+
finally:
|
|
185
|
+
for task in (sweeper, refresher):
|
|
186
|
+
task.cancel()
|
|
187
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
188
|
+
await task
|
|
189
|
+
if settings.clock_sync_enabled:
|
|
190
|
+
await clock.stop()
|
|
191
|
+
await controller.stop()
|
|
192
|
+
logger.info("readerboard stopped")
|
|
193
|
+
|
|
194
|
+
app = FastAPI(
|
|
195
|
+
title="readerboard",
|
|
196
|
+
description=DESCRIPTION,
|
|
197
|
+
version=__version__,
|
|
198
|
+
lifespan=lifespan,
|
|
199
|
+
)
|
|
200
|
+
app.state.settings = settings
|
|
201
|
+
|
|
202
|
+
_install_error_handlers(app)
|
|
203
|
+
app.include_router(routes_v2.router)
|
|
204
|
+
app.include_router(routes_simple.router)
|
|
205
|
+
|
|
206
|
+
@app.get("/health", tags=["Health"], summary="Is the service talking to the sign")
|
|
207
|
+
async def health(request: Request) -> HealthResponse:
|
|
208
|
+
"""Report the state of the link, the slots, and the clock.
|
|
209
|
+
|
|
210
|
+
Deliberately unauthenticated, so that a monitor can watch the sign
|
|
211
|
+
without holding a key that could write to it.
|
|
212
|
+
"""
|
|
213
|
+
controller = get_controller(request)
|
|
214
|
+
registry = get_registry(request)
|
|
215
|
+
alerts = get_alerts(request)
|
|
216
|
+
clock = get_clock(request)
|
|
217
|
+
|
|
218
|
+
used, total = registry.occupancy
|
|
219
|
+
return HealthResponse(
|
|
220
|
+
status="ok" if controller.is_connected else "degraded",
|
|
221
|
+
version=__version__,
|
|
222
|
+
link=LinkHealth(
|
|
223
|
+
url=controller.link_description,
|
|
224
|
+
connected=controller.is_connected,
|
|
225
|
+
last_write_at=controller.last_write_at,
|
|
226
|
+
last_error=controller.last_error,
|
|
227
|
+
writes=controller.writes,
|
|
228
|
+
suppressed_writes=controller.suppressed,
|
|
229
|
+
),
|
|
230
|
+
slots_used=used,
|
|
231
|
+
slots_total=total,
|
|
232
|
+
sign_in_sync=registry.in_sync,
|
|
233
|
+
alert_active=alerts.active is not None,
|
|
234
|
+
clock_last_synced_at=clock.last_sync_at,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return app
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _install_error_handlers(app: FastAPI) -> None:
|
|
241
|
+
"""Turn the service's own exceptions into the status codes they mean."""
|
|
242
|
+
|
|
243
|
+
def handler(code: int) -> Callable[[Request, Exception], Awaitable[JSONResponse]]:
|
|
244
|
+
async def handle(_: Request, exc: Exception) -> JSONResponse:
|
|
245
|
+
if code >= 500:
|
|
246
|
+
logger.warning("request failed: %s", exc)
|
|
247
|
+
return JSONResponse(status_code=code, content={"detail": str(exc)})
|
|
248
|
+
|
|
249
|
+
return handle
|
|
250
|
+
|
|
251
|
+
app.add_exception_handler(MarkupError, handler(status.HTTP_400_BAD_REQUEST))
|
|
252
|
+
app.add_exception_handler(ProtocolError, handler(status.HTTP_400_BAD_REQUEST))
|
|
253
|
+
app.add_exception_handler(MessageTooLong, handler(status.HTTP_400_BAD_REQUEST))
|
|
254
|
+
app.add_exception_handler(AlertTooLong, handler(status.HTTP_400_BAD_REQUEST))
|
|
255
|
+
app.add_exception_handler(commands.UnknownCommand, handler(status.HTTP_400_BAD_REQUEST))
|
|
256
|
+
app.add_exception_handler(commands.BadParameter, handler(status.HTTP_400_BAD_REQUEST))
|
|
257
|
+
app.add_exception_handler(UnknownSlot, handler(status.HTTP_404_NOT_FOUND))
|
|
258
|
+
app.add_exception_handler(LayoutFull, handler(status.HTTP_409_CONFLICT))
|
|
259
|
+
app.add_exception_handler(TransportError, handler(status.HTTP_503_SERVICE_UNAVAILABLE))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
app = create_app()
|
readerboard/api/deps.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Getting at the running service's parts from inside a request, and the API key.
|
|
2
|
+
|
|
3
|
+
Everything the service owns is built once during startup and hung on
|
|
4
|
+
``app.state``. These accessors are what routes depend on, so a route never
|
|
5
|
+
reaches into application state directly and tests can build the pieces
|
|
6
|
+
themselves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hmac
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, Header, HTTPException, Request, status
|
|
15
|
+
|
|
16
|
+
from readerboard.config import Settings
|
|
17
|
+
from readerboard.services.alerts import AlertService
|
|
18
|
+
from readerboard.services.clock import ClockService
|
|
19
|
+
from readerboard.services.registry import MessageRegistry
|
|
20
|
+
from readerboard.sign.controller import SignController
|
|
21
|
+
|
|
22
|
+
API_KEY_HEADER = "X-API-Key"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_settings(request: Request) -> Settings:
|
|
26
|
+
"""Return the service's configuration."""
|
|
27
|
+
settings: Settings = request.app.state.settings
|
|
28
|
+
return settings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_controller(request: Request) -> SignController:
|
|
32
|
+
"""Return the single writer that owns the sign."""
|
|
33
|
+
controller: SignController = request.app.state.controller
|
|
34
|
+
return controller
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_registry(request: Request) -> MessageRegistry:
|
|
38
|
+
"""Return the registered messages."""
|
|
39
|
+
registry: MessageRegistry = request.app.state.registry
|
|
40
|
+
return registry
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_alerts(request: Request) -> AlertService:
|
|
44
|
+
"""Return the alert service."""
|
|
45
|
+
alerts: AlertService = request.app.state.alerts
|
|
46
|
+
return alerts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_clock(request: Request) -> ClockService:
|
|
50
|
+
"""Return the clock sync."""
|
|
51
|
+
clock: ClockService = request.app.state.clock
|
|
52
|
+
return clock
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_api_key(
|
|
56
|
+
request: Request,
|
|
57
|
+
x_api_key: Annotated[str | None, Header(alias=API_KEY_HEADER)] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Reject a write that does not carry the configured API key.
|
|
60
|
+
|
|
61
|
+
Compared with :func:`hmac.compare_digest` so that a wrong key cannot be
|
|
62
|
+
narrowed down by timing. The key itself is never logged or echoed, here or
|
|
63
|
+
anywhere else.
|
|
64
|
+
|
|
65
|
+
This is the one place the simple endpoints are allowed to break their
|
|
66
|
+
"always 200" rule. A caller without the key is not a caller whose request
|
|
67
|
+
failed; it is a caller the service will not talk to.
|
|
68
|
+
"""
|
|
69
|
+
expected: str = request.app.state.settings.api_key.get_secret_value()
|
|
70
|
+
|
|
71
|
+
if not expected:
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
74
|
+
detail=(
|
|
75
|
+
"no API key is configured, so every write is refused. Set api_key in "
|
|
76
|
+
"the config file or READERBOARD_API_KEY in the environment."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if x_api_key is None or not hmac.compare_digest(x_api_key, expected):
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
83
|
+
detail="a valid %s header is required" % API_KEY_HEADER,
|
|
84
|
+
headers={"WWW-Authenticate": API_KEY_HEADER},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|
89
|
+
ControllerDep = Annotated[SignController, Depends(get_controller)]
|
|
90
|
+
RegistryDep = Annotated[MessageRegistry, Depends(get_registry)]
|
|
91
|
+
AlertsDep = Annotated[AlertService, Depends(get_alerts)]
|
|
92
|
+
ClockDep = Annotated[ClockService, Depends(get_clock)]
|
|
93
|
+
RequireApiKey = Depends(require_api_key)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Request and response shapes.
|
|
2
|
+
|
|
3
|
+
The ``Simple*`` models are the shapes the ``/Write`` and ``/Enumerations``
|
|
4
|
+
endpoints use, where every response is a 200 carrying the outcome in the body.
|
|
5
|
+
The rest belong to ``/v2``, which reports through status codes instead.
|
|
6
|
+
|
|
7
|
+
Every ``description`` here ends up in the OpenAPI page, so it is documentation
|
|
8
|
+
in the same sense the README is, and it rots the same way.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
|
+
|
|
18
|
+
from readerboard.protocol.tokens import COMMAND_BY_NAME, MODE_BY_NAME, POSITION_BY_NAME
|
|
19
|
+
from readerboard.sign.state import AlertState, SlotState
|
|
20
|
+
|
|
21
|
+
SlotKey = Annotated[
|
|
22
|
+
str,
|
|
23
|
+
Field(
|
|
24
|
+
min_length=1,
|
|
25
|
+
max_length=64,
|
|
26
|
+
pattern=r"^[A-Za-z0-9._-]+$",
|
|
27
|
+
description="the name of the slot, chosen by whoever owns it",
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalise_mode(value: str) -> str:
|
|
33
|
+
upper = value.strip().upper()
|
|
34
|
+
if upper not in MODE_BY_NAME:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"unknown display mode %r; see GET /v2/enumerations/display-modes" % value
|
|
37
|
+
)
|
|
38
|
+
return upper
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalise_position(value: str) -> str:
|
|
42
|
+
upper = value.strip().upper()
|
|
43
|
+
if upper not in POSITION_BY_NAME:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"unknown text position %r; see GET /v2/enumerations/text-positions" % value
|
|
46
|
+
)
|
|
47
|
+
return upper
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MessageRequest(BaseModel):
|
|
51
|
+
"""A message registered into a slot."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="forbid")
|
|
54
|
+
|
|
55
|
+
message: str = Field(
|
|
56
|
+
max_length=4096,
|
|
57
|
+
description="the message, including markup tokens such as <red> and <degree>",
|
|
58
|
+
)
|
|
59
|
+
display_mode: str = Field(default="HOLD", description="how the sign presents the message")
|
|
60
|
+
position: str = Field(default="MIDDLE", description="where the text sits vertically")
|
|
61
|
+
order: int = Field(
|
|
62
|
+
default=0,
|
|
63
|
+
description="lower numbers play earlier in the rotation; ties break on the slot name",
|
|
64
|
+
)
|
|
65
|
+
ttl_seconds: float | None = Field(
|
|
66
|
+
default=None,
|
|
67
|
+
gt=0,
|
|
68
|
+
description="drop the message this many seconds from now; omit to keep it until replaced",
|
|
69
|
+
)
|
|
70
|
+
source: str | None = Field(
|
|
71
|
+
default=None,
|
|
72
|
+
max_length=128,
|
|
73
|
+
description="who registered this, recorded so the slot list is readable",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_check_mode = field_validator("display_mode")(_normalise_mode)
|
|
77
|
+
_check_position = field_validator("position")(_normalise_position)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SlotResponse(BaseModel):
|
|
81
|
+
"""A registered slot."""
|
|
82
|
+
|
|
83
|
+
key: str
|
|
84
|
+
label: str = Field(description="the sign file this slot occupies, A through Z")
|
|
85
|
+
message: str
|
|
86
|
+
display_mode: str
|
|
87
|
+
position: str
|
|
88
|
+
order: int
|
|
89
|
+
source: str | None
|
|
90
|
+
expires_at: datetime | None
|
|
91
|
+
updated_at: datetime
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def of(cls, slot: SlotState) -> SlotResponse:
|
|
95
|
+
"""Render a stored slot as the API's view of it."""
|
|
96
|
+
return cls(
|
|
97
|
+
key=slot.key,
|
|
98
|
+
label=slot.label,
|
|
99
|
+
message=slot.message,
|
|
100
|
+
display_mode=slot.mode,
|
|
101
|
+
position=slot.position,
|
|
102
|
+
order=slot.order,
|
|
103
|
+
source=slot.source,
|
|
104
|
+
expires_at=slot.expires_at,
|
|
105
|
+
updated_at=slot.updated_at,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AlertRequest(BaseModel):
|
|
110
|
+
"""A message that takes the whole sign over until it is released."""
|
|
111
|
+
|
|
112
|
+
model_config = ConfigDict(extra="forbid")
|
|
113
|
+
|
|
114
|
+
message: str = Field(
|
|
115
|
+
max_length=4096,
|
|
116
|
+
description=(
|
|
117
|
+
"the alert text. The sign's priority file holds 125 bytes once markup has "
|
|
118
|
+
"been rendered, and cannot be resized"
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
display_mode: str = Field(default="HOLD", description="how the sign presents the alert")
|
|
122
|
+
position: str = Field(default="MIDDLE", description="where the text sits vertically")
|
|
123
|
+
ttl_seconds: float | None = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
gt=0,
|
|
126
|
+
description=(
|
|
127
|
+
"release the sign this many seconds from now. Omit and the alert holds the "
|
|
128
|
+
"sign until something releases it explicitly"
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
_check_mode = field_validator("display_mode")(_normalise_mode)
|
|
133
|
+
_check_position = field_validator("position")(_normalise_position)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AlertResponse(BaseModel):
|
|
137
|
+
"""The alert currently holding the sign."""
|
|
138
|
+
|
|
139
|
+
message: str
|
|
140
|
+
display_mode: str
|
|
141
|
+
position: str
|
|
142
|
+
started_at: datetime
|
|
143
|
+
expires_at: datetime | None
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def of(cls, alert: AlertState) -> AlertResponse:
|
|
147
|
+
"""Render a stored alert as the API's view of it."""
|
|
148
|
+
return cls(
|
|
149
|
+
message=alert.message,
|
|
150
|
+
display_mode=alert.mode,
|
|
151
|
+
position=alert.position,
|
|
152
|
+
started_at=alert.started_at,
|
|
153
|
+
expires_at=alert.expires_at,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ControlCommandRequest(BaseModel):
|
|
158
|
+
"""A command aimed at the sign itself rather than at a message."""
|
|
159
|
+
|
|
160
|
+
model_config = ConfigDict(extra="forbid")
|
|
161
|
+
|
|
162
|
+
command: str = Field(description="one of %s" % ", ".join(sorted(COMMAND_BY_NAME)))
|
|
163
|
+
parameter: str = Field(default="", description="the command's parameter, if it takes one")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ClockResponse(BaseModel):
|
|
167
|
+
"""The result of setting the sign's clock."""
|
|
168
|
+
|
|
169
|
+
synced_at: datetime = Field(description="the time the sign was told, in its configured zone")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class LinkHealth(BaseModel):
|
|
173
|
+
"""The state of the link to the sign."""
|
|
174
|
+
|
|
175
|
+
url: str = Field(description="the configured pyserial URL")
|
|
176
|
+
connected: bool
|
|
177
|
+
last_write_at: datetime | None
|
|
178
|
+
last_error: str | None
|
|
179
|
+
writes: int
|
|
180
|
+
suppressed_writes: int = Field(
|
|
181
|
+
description="writes skipped because the sign already held those exact bytes"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class HealthResponse(BaseModel):
|
|
186
|
+
"""What the service knows about itself. Requires no API key."""
|
|
187
|
+
|
|
188
|
+
status: str = Field(description="'ok' when the sign is reachable, otherwise 'degraded'")
|
|
189
|
+
version: str
|
|
190
|
+
link: LinkHealth
|
|
191
|
+
slots_used: int
|
|
192
|
+
slots_total: int
|
|
193
|
+
sign_in_sync: bool = Field(
|
|
194
|
+
description=(
|
|
195
|
+
"false when a registered message has been accepted but not yet written to "
|
|
196
|
+
"the sign, which is what a write during an outage looks like"
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
alert_active: bool
|
|
200
|
+
clock_last_synced_at: datetime | None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TokenInfo(BaseModel):
|
|
204
|
+
"""One entry in an enumeration."""
|
|
205
|
+
|
|
206
|
+
name: str
|
|
207
|
+
description: str
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ===========================================================================
|
|
211
|
+
# The simple API's shapes. Every response is a 200 with the outcome in the body.
|
|
212
|
+
# ===========================================================================
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class SimpleMessageRequest(BaseModel):
|
|
216
|
+
"""The body POST /Write/Message has always accepted."""
|
|
217
|
+
|
|
218
|
+
display_mode: str = Field(description="the display mode to use when showing the message")
|
|
219
|
+
message: str = Field(
|
|
220
|
+
description="the message, including markup tokens, to display on the sign"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SimpleCommandRequest(BaseModel):
|
|
225
|
+
"""The body POST /Write/ControlCommand has always accepted."""
|
|
226
|
+
|
|
227
|
+
command: str = Field(description="the control command to send to the sign")
|
|
228
|
+
parameter: str = Field(default="", description="a parameter for the command")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SimpleResult(BaseModel):
|
|
232
|
+
"""The body the old endpoints have always returned, whatever happened."""
|
|
233
|
+
|
|
234
|
+
result: str = Field(description="OK or ERROR")
|
|
235
|
+
result_message: str = Field(description="text description of the command result")
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def ok(cls, message: str) -> SimpleResult:
|
|
239
|
+
"""Build a success, in the old shape."""
|
|
240
|
+
return cls(result="OK", result_message=message)
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def error(cls, message: str) -> SimpleResult:
|
|
244
|
+
"""Build a failure, in the old shape and still with a 200 status."""
|
|
245
|
+
return cls(result="ERROR", result_message=message)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SimpleDisplayMode(BaseModel):
|
|
249
|
+
"""One entry of GET /Enumerations/DisplayModes."""
|
|
250
|
+
|
|
251
|
+
display_mode: str
|
|
252
|
+
description: str
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class SimpleControlCommand(BaseModel):
|
|
256
|
+
"""One entry of GET /Enumerations/ControlCommands."""
|
|
257
|
+
|
|
258
|
+
control_command: str
|
|
259
|
+
description: str
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class SimpleToken(BaseModel):
|
|
263
|
+
"""One entry of GET /Enumerations/MarkupTokens."""
|
|
264
|
+
|
|
265
|
+
token_text: str
|
|
266
|
+
description: str
|