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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""A smaller surface for clients that would rather not read status codes.
|
|
2
|
+
|
|
3
|
+
Fixed paths, one message, and **every response is HTTP 200** with the outcome in
|
|
4
|
+
the body. That suits a Home Assistant ``rest_command`` or a shell one-liner in a
|
|
5
|
+
cron job, neither of which branches gracefully on a status code.
|
|
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.
|
|
10
|
+
|
|
11
|
+
``POST /Write/Message`` writes to a reserved slot rather than to the sign's
|
|
12
|
+
priority file. That distinction matters more than it looks. By protocol a
|
|
13
|
+
priority message suppresses every other file on the sign, so writing one here
|
|
14
|
+
would quietly turn a service that shares the sign between several sources into a
|
|
15
|
+
service that can only ever show one thing. Written to an ordinary slot it looks
|
|
16
|
+
identical while it is the only message registered, and it shares the sign the
|
|
17
|
+
moment anything else registers.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter
|
|
25
|
+
|
|
26
|
+
from readerboard.api.deps import ControllerDep, RegistryDep, RequireApiKey, SettingsDep
|
|
27
|
+
from readerboard.api.models import (
|
|
28
|
+
SimpleCommandRequest,
|
|
29
|
+
SimpleControlCommand,
|
|
30
|
+
SimpleDisplayMode,
|
|
31
|
+
SimpleMessageRequest,
|
|
32
|
+
SimpleResult,
|
|
33
|
+
SimpleToken,
|
|
34
|
+
)
|
|
35
|
+
from readerboard.protocol.tokens import CONTROL_COMMANDS, DISPLAY_MODES, MARKUP_TOKENS
|
|
36
|
+
from readerboard.services import commands
|
|
37
|
+
from readerboard.services.registry import DEFAULT_SLOT_KEY
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
router = APIRouter()
|
|
42
|
+
|
|
43
|
+
write = APIRouter(prefix="/Write", tags=["Write (simple)"])
|
|
44
|
+
enumerations = APIRouter(prefix="/Enumerations", tags=["Enumerations (simple)"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@write.post("/Message", summary="Write a message to the sign", dependencies=[RequireApiKey])
|
|
48
|
+
async def write_message(
|
|
49
|
+
body: SimpleMessageRequest, registry: RegistryDep, settings: SettingsDep
|
|
50
|
+
) -> SimpleResult:
|
|
51
|
+
"""Display a message on the sign.
|
|
52
|
+
|
|
53
|
+
The message goes into a reserved slot rather than the sign's priority file,
|
|
54
|
+
so other sources can share the sign with it.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
await registry.upsert(
|
|
58
|
+
DEFAULT_SLOT_KEY,
|
|
59
|
+
body.message,
|
|
60
|
+
mode=body.display_mode.strip().upper(),
|
|
61
|
+
position="MIDDLE",
|
|
62
|
+
source="simple",
|
|
63
|
+
# Unset by default, which is exactly how this endpoint has always
|
|
64
|
+
# behaved: the message stays until something replaces it. Set it and
|
|
65
|
+
# an automation that stops calling leaves an empty sign rather than
|
|
66
|
+
# a stale temperature that still looks current.
|
|
67
|
+
ttl_seconds=settings.default_slot_ttl_seconds,
|
|
68
|
+
# Unknown tokens are passed through as literal text, exactly as the
|
|
69
|
+
# old parser did, so a payload that worked before cannot start
|
|
70
|
+
# failing now.
|
|
71
|
+
strict=False,
|
|
72
|
+
)
|
|
73
|
+
except KeyError:
|
|
74
|
+
return SimpleResult.error(
|
|
75
|
+
"The display mode '%s' is not valid" % body.display_mode
|
|
76
|
+
)
|
|
77
|
+
except Exception as err:
|
|
78
|
+
logger.warning("simple write failed: %s", err)
|
|
79
|
+
return SimpleResult.error(str(err))
|
|
80
|
+
|
|
81
|
+
return SimpleResult.ok("Message displayed on sign")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@write.post(
|
|
85
|
+
"/ControlCommand",
|
|
86
|
+
summary="Send a control command to the sign",
|
|
87
|
+
dependencies=[RequireApiKey],
|
|
88
|
+
)
|
|
89
|
+
async def write_control_command(
|
|
90
|
+
body: SimpleCommandRequest, controller: ControllerDep
|
|
91
|
+
) -> SimpleResult:
|
|
92
|
+
"""Send one of the sign's control commands."""
|
|
93
|
+
try:
|
|
94
|
+
payload = commands.build(body.command, body.parameter)
|
|
95
|
+
except commands.UnknownCommand:
|
|
96
|
+
# The old wording, kept because something may be matching on it.
|
|
97
|
+
return SimpleResult.error("Unrecognized control command '%s'" % body.command)
|
|
98
|
+
except commands.BadParameter as err:
|
|
99
|
+
return SimpleResult.error(str(err))
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
await controller.send_special(payload)
|
|
103
|
+
except Exception as err:
|
|
104
|
+
logger.warning("simple control command failed: %s", err)
|
|
105
|
+
return SimpleResult.error(str(err))
|
|
106
|
+
|
|
107
|
+
return SimpleResult.ok("Control command sent to sign")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@enumerations.get("/DisplayModes", summary="Available display modes")
|
|
111
|
+
async def display_modes() -> list[SimpleDisplayMode]:
|
|
112
|
+
"""List the display modes, in the old shape."""
|
|
113
|
+
return [
|
|
114
|
+
SimpleDisplayMode(display_mode=token.text, description=token.description)
|
|
115
|
+
for token in DISPLAY_MODES
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@enumerations.get("/ControlCommands", summary="Available control commands")
|
|
120
|
+
async def control_commands() -> list[SimpleControlCommand]:
|
|
121
|
+
"""List the control commands, in the old shape."""
|
|
122
|
+
return [
|
|
123
|
+
SimpleControlCommand(control_command=token.text, description=token.description)
|
|
124
|
+
for token in CONTROL_COMMANDS
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@enumerations.get("/MarkupTokens", summary="Available markup tokens")
|
|
129
|
+
async def markup_tokens() -> list[SimpleToken]:
|
|
130
|
+
"""List the markup tokens, in the old shape.
|
|
131
|
+
|
|
132
|
+
``token_text`` is the token to write in a message, such as ``<red>``, and
|
|
133
|
+
``description`` says what it does. Easy to describe the wrong way round,
|
|
134
|
+
which is why they are spelled out here.
|
|
135
|
+
"""
|
|
136
|
+
return [
|
|
137
|
+
SimpleToken(token_text=token.text, description=token.description)
|
|
138
|
+
for token in MARKUP_TOKENS
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
router.include_router(write)
|
|
143
|
+
router.include_router(enumerations)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The current API.
|
|
2
|
+
|
|
3
|
+
Status codes mean what they say here: 400 for a message the sign cannot render,
|
|
4
|
+
401 for a missing key, 404 for a slot that does not exist, 409 when the pool is
|
|
5
|
+
full, and 503 when the sign is unreachable. The routes in ``routes_simple``
|
|
6
|
+
deliberately do none of that, and answer 200 to everything instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Response, status
|
|
12
|
+
|
|
13
|
+
from readerboard.api.deps import AlertsDep, ClockDep, ControllerDep, RegistryDep, RequireApiKey
|
|
14
|
+
from readerboard.api.models import (
|
|
15
|
+
AlertRequest,
|
|
16
|
+
AlertResponse,
|
|
17
|
+
ClockResponse,
|
|
18
|
+
ControlCommandRequest,
|
|
19
|
+
MessageRequest,
|
|
20
|
+
SlotKey,
|
|
21
|
+
SlotResponse,
|
|
22
|
+
TokenInfo,
|
|
23
|
+
)
|
|
24
|
+
from readerboard.protocol.tokens import (
|
|
25
|
+
CONTROL_COMMANDS,
|
|
26
|
+
DISPLAY_MODES,
|
|
27
|
+
MARKUP_TOKENS,
|
|
28
|
+
TEXT_POSITIONS,
|
|
29
|
+
Token,
|
|
30
|
+
)
|
|
31
|
+
from readerboard.services import commands
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/v2")
|
|
34
|
+
|
|
35
|
+
messages = APIRouter(prefix="/messages", tags=["Messages"])
|
|
36
|
+
alerts_routes = APIRouter(prefix="/alerts", tags=["Alerts"])
|
|
37
|
+
sign_routes = APIRouter(prefix="/sign", tags=["Sign"])
|
|
38
|
+
enumerations = APIRouter(prefix="/enumerations", tags=["Enumerations"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ===========================================================================
|
|
42
|
+
# Messages
|
|
43
|
+
# ===========================================================================
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@messages.get("", summary="List the messages sharing the sign")
|
|
47
|
+
async def list_messages(registry: RegistryDep) -> list[SlotResponse]:
|
|
48
|
+
"""Return every registered slot, in the order the sign plays them."""
|
|
49
|
+
return [SlotResponse.of(slot) for slot in registry.list_slots()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@messages.get("/{key}", summary="Read one message")
|
|
53
|
+
async def get_message(key: SlotKey, registry: RegistryDep) -> SlotResponse:
|
|
54
|
+
"""Return one slot by name."""
|
|
55
|
+
return SlotResponse.of(registry.get(key))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@messages.put("/{key}", summary="Register or replace a message", dependencies=[RequireApiKey])
|
|
59
|
+
async def put_message(
|
|
60
|
+
key: SlotKey, body: MessageRequest, registry: RegistryDep
|
|
61
|
+
) -> SlotResponse:
|
|
62
|
+
"""Put a message in a slot, replacing whatever was there.
|
|
63
|
+
|
|
64
|
+
The sign rotates through every registered slot on its own, so registering a
|
|
65
|
+
second message does not displace the first.
|
|
66
|
+
"""
|
|
67
|
+
slot = await registry.upsert(
|
|
68
|
+
key,
|
|
69
|
+
body.message,
|
|
70
|
+
mode=body.display_mode,
|
|
71
|
+
position=body.position,
|
|
72
|
+
order=body.order,
|
|
73
|
+
ttl_seconds=body.ttl_seconds,
|
|
74
|
+
source=body.source,
|
|
75
|
+
)
|
|
76
|
+
return SlotResponse.of(slot)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@messages.delete(
|
|
80
|
+
"/{key}",
|
|
81
|
+
summary="Take a message off the sign",
|
|
82
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
83
|
+
dependencies=[RequireApiKey],
|
|
84
|
+
)
|
|
85
|
+
async def delete_message(key: SlotKey, registry: RegistryDep) -> Response:
|
|
86
|
+
"""Remove one slot and free the sign file it held."""
|
|
87
|
+
await registry.remove(key)
|
|
88
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@messages.delete(
|
|
92
|
+
"",
|
|
93
|
+
summary="Take every message off the sign",
|
|
94
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
95
|
+
dependencies=[RequireApiKey],
|
|
96
|
+
)
|
|
97
|
+
async def clear_messages(registry: RegistryDep) -> Response:
|
|
98
|
+
"""Remove every slot, leaving the sign showing nothing."""
|
|
99
|
+
await registry.clear()
|
|
100
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ===========================================================================
|
|
104
|
+
# Alerts
|
|
105
|
+
# ===========================================================================
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@alerts_routes.get("", summary="Read the alert holding the sign")
|
|
109
|
+
async def get_alert(alerts: AlertsDep) -> AlertResponse | None:
|
|
110
|
+
"""Return the active alert, or null if the sign is rotating normally."""
|
|
111
|
+
alert = alerts.active
|
|
112
|
+
return AlertResponse.of(alert) if alert else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@alerts_routes.post("", summary="Take the sign over with an alert", dependencies=[RequireApiKey])
|
|
116
|
+
async def post_alert(body: AlertRequest, alerts: AlertsDep) -> AlertResponse:
|
|
117
|
+
"""Take the whole sign over until the alert is released.
|
|
118
|
+
|
|
119
|
+
This uses the sign's priority file, which suppresses every other message. If
|
|
120
|
+
a ttl is given, the sign is released automatically and the rotation resumes
|
|
121
|
+
by itself.
|
|
122
|
+
"""
|
|
123
|
+
alert = await alerts.raise_alert(
|
|
124
|
+
body.message,
|
|
125
|
+
mode=body.display_mode,
|
|
126
|
+
position=body.position,
|
|
127
|
+
ttl_seconds=body.ttl_seconds,
|
|
128
|
+
)
|
|
129
|
+
return AlertResponse.of(alert)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@alerts_routes.delete(
|
|
133
|
+
"",
|
|
134
|
+
summary="Give the sign back",
|
|
135
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
136
|
+
dependencies=[RequireApiKey],
|
|
137
|
+
)
|
|
138
|
+
async def delete_alert(alerts: AlertsDep) -> Response:
|
|
139
|
+
"""Release an alert so the rotation resumes."""
|
|
140
|
+
await alerts.release()
|
|
141
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ===========================================================================
|
|
145
|
+
# The sign itself
|
|
146
|
+
# ===========================================================================
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@sign_routes.post("/sync-clock", summary="Set the sign's clock now", dependencies=[RequireApiKey])
|
|
150
|
+
async def sync_clock(clock: ClockDep) -> ClockResponse:
|
|
151
|
+
"""Set the sign's clock and day of week immediately.
|
|
152
|
+
|
|
153
|
+
The service already does this at startup, hourly, and whenever the link to
|
|
154
|
+
the sign comes back. This is for when you would rather not wait.
|
|
155
|
+
"""
|
|
156
|
+
return ClockResponse(synced_at=await clock.sync())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@sign_routes.post(
|
|
160
|
+
"/command",
|
|
161
|
+
summary="Send a control command to the sign",
|
|
162
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
163
|
+
dependencies=[RequireApiKey],
|
|
164
|
+
)
|
|
165
|
+
async def send_command(body: ControlCommandRequest, controller: ControllerDep) -> Response:
|
|
166
|
+
"""Send one of the sign's own control commands."""
|
|
167
|
+
await controller.send_special(commands.build(body.command, body.parameter))
|
|
168
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ===========================================================================
|
|
172
|
+
# Enumerations
|
|
173
|
+
# ===========================================================================
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _as_info(tokens: tuple[Token, ...]) -> list[TokenInfo]:
|
|
177
|
+
return [TokenInfo(name=token.text, description=token.description) for token in tokens]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@enumerations.get("/markup-tokens", summary="Markup tokens a message may contain")
|
|
181
|
+
async def markup_tokens() -> list[TokenInfo]:
|
|
182
|
+
"""List every token that can be written inline in a message."""
|
|
183
|
+
return _as_info(MARKUP_TOKENS)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@enumerations.get("/display-modes", summary="Ways the sign can present a message")
|
|
187
|
+
async def display_modes() -> list[TokenInfo]:
|
|
188
|
+
"""List every display mode."""
|
|
189
|
+
return _as_info(DISPLAY_MODES)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@enumerations.get("/text-positions", summary="Where text sits vertically")
|
|
193
|
+
async def text_positions() -> list[TokenInfo]:
|
|
194
|
+
"""List every vertical text position."""
|
|
195
|
+
return _as_info(TEXT_POSITIONS)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@enumerations.get("/control-commands", summary="Commands aimed at the sign itself")
|
|
199
|
+
async def control_commands() -> list[TokenInfo]:
|
|
200
|
+
"""List every control command."""
|
|
201
|
+
return _as_info(CONTROL_COMMANDS)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
router.include_router(messages)
|
|
205
|
+
router.include_router(alerts_routes)
|
|
206
|
+
router.include_router(sign_routes)
|
|
207
|
+
router.include_router(enumerations)
|
readerboard/config.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Everything the service reads from its environment, in one place.
|
|
2
|
+
|
|
3
|
+
Settings come from three places, later ones winning: the defaults below, a TOML
|
|
4
|
+
file (``/etc/readerboard/config.toml`` unless ``READERBOARD_CONFIG_FILE`` says
|
|
5
|
+
otherwise), and environment variables prefixed ``READERBOARD_``.
|
|
6
|
+
|
|
7
|
+
One change here is worth calling out because it will bite on upgrade. The old
|
|
8
|
+
``config.json`` held ``com_port``, a bare device name that the server prefixed
|
|
9
|
+
with ``/dev/`` before opening. This service takes a full pyserial URL in
|
|
10
|
+
``serial_url`` instead, so a sign on an Ethernet adapter is
|
|
11
|
+
``socket://192.168.2.51:4001`` and a sign on a cable is ``/dev/ttyUSB0``. There
|
|
12
|
+
is no prefixing, and no way to express a network sign in the old key.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
22
|
+
from pydantic_settings import (
|
|
23
|
+
BaseSettings,
|
|
24
|
+
PydanticBaseSettingsSource,
|
|
25
|
+
SettingsConfigDict,
|
|
26
|
+
TomlConfigSettingsSource,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
DEFAULT_CONFIG_FILE = Path("/etc/readerboard/config.toml")
|
|
30
|
+
|
|
31
|
+
# A BetaBrite Classic holds roughly 30000 bytes of messages and graphics all
|
|
32
|
+
# told. Allocating the whole of it leaves the sign no room for anything else, so
|
|
33
|
+
# the service refuses a pool that claims more than this much of it. The protocol
|
|
34
|
+
# charges eleven bytes of directory overhead per configured file on top of each
|
|
35
|
+
# file's own size, and that is counted too.
|
|
36
|
+
SIGN_MEMORY_BUDGET = 26000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _config_file() -> Path:
|
|
40
|
+
override = os.environ.get("READERBOARD_CONFIG_FILE")
|
|
41
|
+
return Path(override) if override else DEFAULT_CONFIG_FILE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Settings(BaseSettings):
|
|
45
|
+
"""The service's configuration."""
|
|
46
|
+
|
|
47
|
+
model_config = SettingsConfigDict(
|
|
48
|
+
env_prefix="READERBOARD_",
|
|
49
|
+
toml_file=_config_file(),
|
|
50
|
+
extra="forbid",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# == the link to the sign ==============================================
|
|
54
|
+
|
|
55
|
+
serial_url: str = Field(
|
|
56
|
+
default="loop://",
|
|
57
|
+
description=(
|
|
58
|
+
"pyserial URL for the sign: socket://host:port for an Ethernet to RS-232 "
|
|
59
|
+
"adapter, /dev/ttyUSB0 for a direct cable, or loop:// to run without one"
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
baud_rate: int = Field(default=9600, ge=110, le=921600)
|
|
63
|
+
serial_timeout: float = Field(default=10.0, gt=0)
|
|
64
|
+
inter_packet_delay: float = Field(
|
|
65
|
+
default=0.5,
|
|
66
|
+
ge=0,
|
|
67
|
+
le=10,
|
|
68
|
+
description=(
|
|
69
|
+
"seconds to wait after each transmission before sending another. The old "
|
|
70
|
+
"server always slept 2 seconds; run scripts/protocol_spike.py to find what "
|
|
71
|
+
"this sign actually needs"
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
backoff_initial: float = Field(default=1.0, gt=0)
|
|
75
|
+
backoff_max: float = Field(default=60.0, gt=0)
|
|
76
|
+
|
|
77
|
+
# == the pool of sign files the rotation uses ==========================
|
|
78
|
+
|
|
79
|
+
slot_count: int = Field(
|
|
80
|
+
default=8,
|
|
81
|
+
ge=1,
|
|
82
|
+
le=26,
|
|
83
|
+
description="how many messages can share the sign at once, one sign file each",
|
|
84
|
+
)
|
|
85
|
+
slot_capacity: int = Field(
|
|
86
|
+
default=256,
|
|
87
|
+
ge=16,
|
|
88
|
+
le=4096,
|
|
89
|
+
description="bytes allocated to each message, after markup has been rendered",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# == behaviour =========================================================
|
|
93
|
+
|
|
94
|
+
default_display_mode: str = "HOLD"
|
|
95
|
+
default_text_position: str = "MIDDLE"
|
|
96
|
+
registry_sweep_seconds: float = Field(default=15.0, gt=0)
|
|
97
|
+
refresh_interval_seconds: float = Field(
|
|
98
|
+
default=900.0,
|
|
99
|
+
gt=0,
|
|
100
|
+
description=(
|
|
101
|
+
"how often to push every message to the sign again whether or not it looks "
|
|
102
|
+
"necessary. This is what repairs a sign that was power cycled behind a "
|
|
103
|
+
"still-connected Ethernet adapter, which nothing else can detect"
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
default_slot_ttl_seconds: float | None = Field(
|
|
107
|
+
default=None,
|
|
108
|
+
gt=0,
|
|
109
|
+
description=(
|
|
110
|
+
"expire the slot POST /Write/Message writes to, after this long. Unset, "
|
|
111
|
+
"which is the default, reproduces the old behaviour exactly: the message "
|
|
112
|
+
"stays until something replaces it. Setting it means a Home Assistant "
|
|
113
|
+
"automation that dies leaves a visibly empty sign rather than a quietly "
|
|
114
|
+
"wrong temperature"
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
clock_sync_enabled: bool = True
|
|
118
|
+
clock_sync_interval_seconds: float = Field(default=3600.0, gt=0)
|
|
119
|
+
timezone: str | None = Field(
|
|
120
|
+
default=None,
|
|
121
|
+
description=(
|
|
122
|
+
"IANA name such as America/New_York, used when setting the sign's clock. "
|
|
123
|
+
"Unset means the machine's own local time"
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# == the HTTP surface ==================================================
|
|
128
|
+
|
|
129
|
+
host: str = "0.0.0.0"
|
|
130
|
+
port: int = Field(default=5001, ge=1, le=65535)
|
|
131
|
+
api_key: SecretStr = Field(
|
|
132
|
+
default=SecretStr(""),
|
|
133
|
+
description="required on every write. Generated by scripts/install.sh if absent",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# == where state and logs go ===========================================
|
|
137
|
+
|
|
138
|
+
state_path: Path = Path("/var/lib/readerboard/state.json")
|
|
139
|
+
log_level: str = "INFO"
|
|
140
|
+
log_file: Path | None = None
|
|
141
|
+
|
|
142
|
+
@field_validator("log_level")
|
|
143
|
+
@classmethod
|
|
144
|
+
def _check_log_level(cls, value: str) -> str:
|
|
145
|
+
allowed = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}
|
|
146
|
+
upper = value.upper()
|
|
147
|
+
if upper not in allowed:
|
|
148
|
+
raise ValueError("log_level must be one of %s" % ", ".join(sorted(allowed)))
|
|
149
|
+
return upper
|
|
150
|
+
|
|
151
|
+
@field_validator("default_display_mode")
|
|
152
|
+
@classmethod
|
|
153
|
+
def _check_mode(cls, value: str) -> str:
|
|
154
|
+
from readerboard.protocol.tokens import MODE_BY_NAME
|
|
155
|
+
|
|
156
|
+
upper = value.upper()
|
|
157
|
+
if upper not in MODE_BY_NAME:
|
|
158
|
+
raise ValueError("unknown display mode %r" % value)
|
|
159
|
+
return upper
|
|
160
|
+
|
|
161
|
+
@field_validator("default_text_position")
|
|
162
|
+
@classmethod
|
|
163
|
+
def _check_position(cls, value: str) -> str:
|
|
164
|
+
from readerboard.protocol.tokens import POSITION_BY_NAME
|
|
165
|
+
|
|
166
|
+
upper = value.upper()
|
|
167
|
+
if upper not in POSITION_BY_NAME:
|
|
168
|
+
raise ValueError("unknown text position %r" % value)
|
|
169
|
+
return upper
|
|
170
|
+
|
|
171
|
+
@field_validator("timezone")
|
|
172
|
+
@classmethod
|
|
173
|
+
def _check_timezone(cls, value: str | None) -> str | None:
|
|
174
|
+
if value is None:
|
|
175
|
+
return None
|
|
176
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
ZoneInfo(value)
|
|
180
|
+
except (ZoneInfoNotFoundError, ValueError) as err:
|
|
181
|
+
raise ValueError("unknown timezone %r: %s" % (value, err)) from err
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
@model_validator(mode="after")
|
|
185
|
+
def _check_pool_fits(self) -> Settings:
|
|
186
|
+
from readerboard.protocol.constants import FILE_OVERHEAD_BYTES
|
|
187
|
+
|
|
188
|
+
claimed = self.slot_count * (self.slot_capacity + FILE_OVERHEAD_BYTES)
|
|
189
|
+
if claimed > SIGN_MEMORY_BUDGET:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
"slot_count %d at slot_capacity %d claims %d bytes of the sign's "
|
|
192
|
+
"memory pool, more than the %d this service is willing to take. "
|
|
193
|
+
"Lower one of them."
|
|
194
|
+
% (self.slot_count, self.slot_capacity, claimed, SIGN_MEMORY_BUDGET)
|
|
195
|
+
)
|
|
196
|
+
if self.backoff_max < self.backoff_initial:
|
|
197
|
+
raise ValueError("backoff_max must not be smaller than backoff_initial")
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def settings_customise_sources(
|
|
202
|
+
cls,
|
|
203
|
+
settings_cls: type[BaseSettings],
|
|
204
|
+
init_settings: PydanticBaseSettingsSource,
|
|
205
|
+
env_settings: PydanticBaseSettingsSource,
|
|
206
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
207
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
208
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
209
|
+
"""Put the TOML file below the environment but above the defaults."""
|
|
210
|
+
return (
|
|
211
|
+
init_settings,
|
|
212
|
+
env_settings,
|
|
213
|
+
dotenv_settings,
|
|
214
|
+
TomlConfigSettingsSource(settings_cls),
|
|
215
|
+
file_secret_settings,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def redacted(self) -> dict[str, Any]:
|
|
219
|
+
"""Return the settings as a dict with the API key removed, safe to log."""
|
|
220
|
+
data = self.model_dump(mode="json")
|
|
221
|
+
data["api_key"] = "set" if self.api_key.get_secret_value() else "unset"
|
|
222
|
+
return data
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Logging configuration.
|
|
2
|
+
|
|
3
|
+
Output goes to stderr, which is what journald reads when the service runs under
|
|
4
|
+
systemd, with an optional rotating file as well for anyone not running it that
|
|
5
|
+
way.
|
|
6
|
+
|
|
7
|
+
Log messages are documentation. They are the only account of what the service
|
|
8
|
+
did that anyone will read at three in the morning, so they say what happened and
|
|
9
|
+
what it means rather than dumping state. The one thing they never contain is the
|
|
10
|
+
API key.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import logging.handlers
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
|
|
21
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
22
|
+
|
|
23
|
+
MAX_FILE_BYTES = 1_000_000
|
|
24
|
+
BACKUP_COUNT = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configure(level: str = "INFO", log_file: Path | None = None) -> None:
|
|
28
|
+
"""Set up logging for the service. Safe to call more than once."""
|
|
29
|
+
root = logging.getLogger()
|
|
30
|
+
root.setLevel(level)
|
|
31
|
+
|
|
32
|
+
for handler in list(root.handlers):
|
|
33
|
+
root.removeHandler(handler)
|
|
34
|
+
handler.close()
|
|
35
|
+
|
|
36
|
+
formatter = logging.Formatter(FORMAT, datefmt=DATE_FORMAT)
|
|
37
|
+
|
|
38
|
+
stream = logging.StreamHandler(sys.stderr)
|
|
39
|
+
stream.setFormatter(formatter)
|
|
40
|
+
root.addHandler(stream)
|
|
41
|
+
|
|
42
|
+
if log_file is not None:
|
|
43
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
rotating = logging.handlers.RotatingFileHandler(
|
|
45
|
+
log_file, maxBytes=MAX_FILE_BYTES, backupCount=BACKUP_COUNT, encoding="utf-8"
|
|
46
|
+
)
|
|
47
|
+
rotating.setFormatter(formatter)
|
|
48
|
+
root.addHandler(rotating)
|
|
49
|
+
|
|
50
|
+
# uvicorn installs its own handlers; letting them propagate to ours would
|
|
51
|
+
# print every access line twice.
|
|
52
|
+
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
53
|
+
logging.getLogger(name).propagate = False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Alpha sign protocol: constants, markup, and frame builders."""
|