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.
@@ -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"
@@ -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()
@@ -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