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,183 @@
|
|
|
1
|
+
"""Taking the whole sign over, and giving it back.
|
|
2
|
+
|
|
3
|
+
An alert is written to the sign's priority file, which by protocol suppresses
|
|
4
|
+
every other file until it is released. Releasing means writing an empty priority
|
|
5
|
+
file, at which point the sign resumes its run sequence by itself. Nothing has to
|
|
6
|
+
rebuild the rotation afterwards.
|
|
7
|
+
|
|
8
|
+
The release deadline is persisted. A service that restarted during an alert and
|
|
9
|
+
forgot about it would leave the sign stuck showing that alert forever, with the
|
|
10
|
+
rotation invisible behind it and no record of why.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
|
|
20
|
+
from readerboard.protocol import constants as c
|
|
21
|
+
from readerboard.protocol.markup import render
|
|
22
|
+
from readerboard.protocol.tokens import MODE_BY_NAME, POSITION_BY_NAME
|
|
23
|
+
from readerboard.sign.controller import SignController
|
|
24
|
+
from readerboard.sign.state import AlertState, ServiceState, StateStore
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AlertTooLong(ValueError):
|
|
30
|
+
"""The alert does not fit the sign's fixed size priority file."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _utcnow() -> datetime:
|
|
34
|
+
return datetime.now(UTC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AlertService:
|
|
38
|
+
"""Owns the priority file and the deadline for giving it back."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
controller: SignController,
|
|
43
|
+
store: StateStore,
|
|
44
|
+
state: ServiceState,
|
|
45
|
+
*,
|
|
46
|
+
now: Callable[[], datetime] = _utcnow,
|
|
47
|
+
on_release: Callable[[], Awaitable[object]] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Wire the alert service to the sign and to its restored state.
|
|
50
|
+
|
|
51
|
+
``on_release`` runs after the sign has been handed back. The registry
|
|
52
|
+
uses it to apply a run sequence it held back while the alert was up; see
|
|
53
|
+
``MessageRegistry._apply_run_sequence`` for why it holds one back.
|
|
54
|
+
"""
|
|
55
|
+
self._controller = controller
|
|
56
|
+
self._store = store
|
|
57
|
+
self._state = state
|
|
58
|
+
self._now = now
|
|
59
|
+
self._on_release = on_release
|
|
60
|
+
self._lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
def set_release_hook(self, hook: Callable[[], Awaitable[object]]) -> None:
|
|
63
|
+
"""Attach something to run after the sign is handed back."""
|
|
64
|
+
self._on_release = hook
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def active(self) -> AlertState | None:
|
|
68
|
+
"""The alert currently holding the sign, if any."""
|
|
69
|
+
return self._state.alert
|
|
70
|
+
|
|
71
|
+
async def restore(self) -> None:
|
|
72
|
+
"""Re-establish or clean up an alert that was running before a restart."""
|
|
73
|
+
alert = self._state.alert
|
|
74
|
+
if alert is None:
|
|
75
|
+
# The sign may still be holding an alert from before an unclean
|
|
76
|
+
# stop, and there is no way to ask it. Releasing costs one small
|
|
77
|
+
# write and guarantees the rotation is visible.
|
|
78
|
+
await self._controller.clear_priority()
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if alert.expires_at is not None and alert.expires_at <= self._now():
|
|
82
|
+
logger.info("an alert was active before the restart but has since expired")
|
|
83
|
+
await self.release()
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
logger.info("restoring the alert that was active before the restart")
|
|
87
|
+
await self._write(alert)
|
|
88
|
+
|
|
89
|
+
async def reassert(self) -> bool:
|
|
90
|
+
"""Write the active alert to the sign again, if there is one.
|
|
91
|
+
|
|
92
|
+
The registry's periodic refresh exists because the sign can be power
|
|
93
|
+
cycled behind a still-connected adapter, leaving it blank with nothing
|
|
94
|
+
to notice. That refresh puts the slots back, but an alert lives in the
|
|
95
|
+
priority file, which the registry does not touch. Without this, a sign
|
|
96
|
+
power cycled mid-alert would sit blank until the alert's deadline
|
|
97
|
+
passed, and an alert with no deadline would sit blank indefinitely.
|
|
98
|
+
|
|
99
|
+
Returns whether there was an alert to re-assert.
|
|
100
|
+
"""
|
|
101
|
+
async with self._lock:
|
|
102
|
+
alert = self._state.alert
|
|
103
|
+
if alert is None:
|
|
104
|
+
return False
|
|
105
|
+
# Forced, because what is in doubt here is precisely whether the
|
|
106
|
+
# sign still holds what the controller believes it does. Left to
|
|
107
|
+
# suppression this would write nothing at all.
|
|
108
|
+
await self._write(alert, force=True)
|
|
109
|
+
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
async def raise_alert(
|
|
113
|
+
self,
|
|
114
|
+
message: str,
|
|
115
|
+
*,
|
|
116
|
+
mode: str,
|
|
117
|
+
position: str,
|
|
118
|
+
ttl_seconds: float | None = None,
|
|
119
|
+
strict: bool = True,
|
|
120
|
+
) -> AlertState:
|
|
121
|
+
"""Take the sign over with an alert."""
|
|
122
|
+
body = render(message, strict=strict)
|
|
123
|
+
if len(body) > c.PRIORITY_FILE_CAPACITY:
|
|
124
|
+
raise AlertTooLong(
|
|
125
|
+
"the alert renders to %d bytes but the sign's priority file holds %d "
|
|
126
|
+
"and cannot be resized" % (len(body), c.PRIORITY_FILE_CAPACITY)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
now = self._now()
|
|
130
|
+
alert = AlertState(
|
|
131
|
+
message=message,
|
|
132
|
+
mode=mode,
|
|
133
|
+
position=position,
|
|
134
|
+
started_at=now,
|
|
135
|
+
expires_at=now + timedelta(seconds=ttl_seconds) if ttl_seconds else None,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async with self._lock:
|
|
139
|
+
await self._write(alert)
|
|
140
|
+
self._state.alert = alert
|
|
141
|
+
self._store.save(self._state)
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
"alert raised%s",
|
|
145
|
+
" until %s" % alert.expires_at.isoformat() if alert.expires_at else " with no deadline",
|
|
146
|
+
)
|
|
147
|
+
return alert
|
|
148
|
+
|
|
149
|
+
async def release(self) -> bool:
|
|
150
|
+
"""Give the sign back. Returns whether an alert was actually holding it."""
|
|
151
|
+
async with self._lock:
|
|
152
|
+
was_active = self._state.alert is not None
|
|
153
|
+
await self._controller.clear_priority()
|
|
154
|
+
self._state.alert = None
|
|
155
|
+
self._store.save(self._state)
|
|
156
|
+
|
|
157
|
+
if was_active:
|
|
158
|
+
logger.info("alert released, rotation resumes")
|
|
159
|
+
|
|
160
|
+
if self._on_release is not None:
|
|
161
|
+
await self._on_release()
|
|
162
|
+
|
|
163
|
+
return was_active
|
|
164
|
+
|
|
165
|
+
async def sweep(self) -> bool:
|
|
166
|
+
"""Release the alert if its deadline has passed. Returns whether it did."""
|
|
167
|
+
alert = self._state.alert
|
|
168
|
+
if alert is None or alert.expires_at is None:
|
|
169
|
+
return False
|
|
170
|
+
if alert.expires_at > self._now():
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
logger.info("alert reached its deadline")
|
|
174
|
+
await self.release()
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
async def _write(self, alert: AlertState, *, force: bool = False) -> None:
|
|
178
|
+
await self._controller.write_priority(
|
|
179
|
+
render(alert.message, strict=False),
|
|
180
|
+
mode=MODE_BY_NAME[alert.mode].value,
|
|
181
|
+
position=POSITION_BY_NAME[alert.position].value,
|
|
182
|
+
force=force,
|
|
183
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Keeping the sign's clock right.
|
|
2
|
+
|
|
3
|
+
The sign has its own battery-backed clock and it drifts. It also loses the time
|
|
4
|
+
when it loses power for long enough, and a sign showing ``<time>`` with a wrong
|
|
5
|
+
clock is worse than one showing nothing, because it looks fine.
|
|
6
|
+
|
|
7
|
+
So the clock is set on startup, once an hour, and on every reconnect. The
|
|
8
|
+
reconnect trigger is the one that earns its keep: a scheduled sync alone leaves
|
|
9
|
+
a sign that came back at ten past the hour wrong until the next sync, and the
|
|
10
|
+
link returning is the closest signal available to "the sign may just have been
|
|
11
|
+
power cycled".
|
|
12
|
+
|
|
13
|
+
``now`` is injected rather than mocked, so the tests can be about what gets sent
|
|
14
|
+
at a given time rather than about patching the clock out from under the code.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from zoneinfo import ZoneInfo
|
|
25
|
+
|
|
26
|
+
from readerboard.protocol import frames
|
|
27
|
+
from readerboard.sign.controller import SignController
|
|
28
|
+
from readerboard.transport.base import TransportError
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _local_now() -> datetime:
|
|
34
|
+
return datetime.now().astimezone()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sign_day_of_week(moment: datetime) -> int:
|
|
38
|
+
"""Convert a datetime to the sign's day numbering, 1 for Sunday to 7 for Saturday."""
|
|
39
|
+
# isoweekday is 1 for Monday through 7 for Sunday, so this rotates Sunday
|
|
40
|
+
# to the front.
|
|
41
|
+
return (moment.isoweekday() % 7) + 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClockService:
|
|
45
|
+
"""Sets the sign's clock on a schedule and whenever the link comes back."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
controller: SignController,
|
|
50
|
+
*,
|
|
51
|
+
interval_seconds: float = 3600.0,
|
|
52
|
+
timezone: str | None = None,
|
|
53
|
+
now: Callable[[], datetime] = _local_now,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Configure the sync. Nothing is sent until :meth:`sync` or :meth:`start`."""
|
|
56
|
+
self._controller = controller
|
|
57
|
+
self._interval = interval_seconds
|
|
58
|
+
self._zone = ZoneInfo(timezone) if timezone else None
|
|
59
|
+
self._now = now
|
|
60
|
+
self._task: asyncio.Task[None] | None = None
|
|
61
|
+
self._stopping = asyncio.Event()
|
|
62
|
+
|
|
63
|
+
self.last_sync_at: datetime | None = None
|
|
64
|
+
|
|
65
|
+
def current_time(self) -> datetime:
|
|
66
|
+
"""Return the time the sign should be told, in the configured zone."""
|
|
67
|
+
moment = self._now()
|
|
68
|
+
return moment.astimezone(self._zone) if self._zone else moment
|
|
69
|
+
|
|
70
|
+
async def sync(self) -> datetime:
|
|
71
|
+
"""Set the sign's clock and day of week. Returns the time it was told."""
|
|
72
|
+
moment = self.current_time()
|
|
73
|
+
await self._controller.send_special(frames.set_time(moment.hour, moment.minute))
|
|
74
|
+
await self._controller.send_special(frames.set_day_of_week(sign_day_of_week(moment)))
|
|
75
|
+
self.last_sync_at = moment
|
|
76
|
+
logger.info("sign clock set to %s", moment.strftime("%Y-%m-%d %H:%M %Z").strip())
|
|
77
|
+
return moment
|
|
78
|
+
|
|
79
|
+
async def start(self) -> None:
|
|
80
|
+
"""Begin syncing on a schedule."""
|
|
81
|
+
self._task = asyncio.create_task(self._loop())
|
|
82
|
+
|
|
83
|
+
async def stop(self) -> None:
|
|
84
|
+
"""Stop syncing."""
|
|
85
|
+
self._stopping.set()
|
|
86
|
+
task = self._task
|
|
87
|
+
self._task = None
|
|
88
|
+
if task is not None:
|
|
89
|
+
task.cancel()
|
|
90
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
91
|
+
await task
|
|
92
|
+
|
|
93
|
+
async def sync_quietly(self) -> None:
|
|
94
|
+
"""Sync, logging rather than raising if the sign is unreachable.
|
|
95
|
+
|
|
96
|
+
This is what the reconnect hook and the periodic loop use. A clock sync
|
|
97
|
+
that fails is worth a log line, not a crashed background task.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
await self.sync()
|
|
101
|
+
except (TransportError, OSError) as err:
|
|
102
|
+
logger.warning("could not set the sign's clock: %s", err)
|
|
103
|
+
|
|
104
|
+
async def _loop(self) -> None:
|
|
105
|
+
while not self._stopping.is_set():
|
|
106
|
+
try:
|
|
107
|
+
await asyncio.wait_for(self._stopping.wait(), timeout=self._interval)
|
|
108
|
+
except TimeoutError:
|
|
109
|
+
await self.sync_quietly()
|
|
110
|
+
else:
|
|
111
|
+
return
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Turning a named control command and its parameter into a payload.
|
|
2
|
+
|
|
3
|
+
These are the commands that act on the sign itself rather than on a message:
|
|
4
|
+
setting its clock, its day of week, and how it renders the time.
|
|
5
|
+
|
|
6
|
+
The set is deliberately closed. Anything reaching the sign from here is one of
|
|
7
|
+
these three, none of which touches the memory configuration or the run time
|
|
8
|
+
table, so a caller cannot use this route to disturb the file layout the service
|
|
9
|
+
believes it has.
|
|
10
|
+
|
|
11
|
+
Note the day of week numbering, which is easy to get wrong: the protocol defines
|
|
12
|
+
1 for Sunday through 7 for Saturday, not 0 through 6.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from readerboard.protocol import frames
|
|
18
|
+
from readerboard.protocol.tokens import COMMAND_BY_NAME
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnknownCommand(ValueError):
|
|
22
|
+
"""No control command by that name exists."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BadParameter(ValueError):
|
|
26
|
+
"""The parameter is not valid for the command it was given to."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build(name: str, parameter: str) -> bytes:
|
|
30
|
+
"""Build the payload for a named control command.
|
|
31
|
+
|
|
32
|
+
``name`` is matched case insensitively.
|
|
33
|
+
"""
|
|
34
|
+
command = name.strip().upper()
|
|
35
|
+
if command not in COMMAND_BY_NAME:
|
|
36
|
+
raise UnknownCommand(
|
|
37
|
+
"unrecognized control command %r; the available commands are %s"
|
|
38
|
+
% (name, ", ".join(sorted(COMMAND_BY_NAME)))
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if command == "SET_TIME":
|
|
42
|
+
return _set_time(parameter)
|
|
43
|
+
if command == "SET_DAY_OF_WEEK":
|
|
44
|
+
return _set_day_of_week(parameter)
|
|
45
|
+
if command == "SET_TIME_FORMAT":
|
|
46
|
+
return _set_time_format(parameter)
|
|
47
|
+
|
|
48
|
+
raise UnknownCommand("control command %r has no handler" % command) # pragma: no cover
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _set_time(parameter: str) -> bytes:
|
|
52
|
+
value = parameter.strip()
|
|
53
|
+
if len(value) != 4 or not value.isdigit():
|
|
54
|
+
raise BadParameter(
|
|
55
|
+
"SET_TIME takes the time as four digits, HHMM on a 24 hour clock, got %r"
|
|
56
|
+
% parameter
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
return frames.set_time(int(value[:2]), int(value[2:]))
|
|
60
|
+
except frames.ProtocolError as err:
|
|
61
|
+
raise BadParameter(str(err)) from err
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _set_day_of_week(parameter: str) -> bytes:
|
|
65
|
+
value = parameter.strip()
|
|
66
|
+
if len(value) != 1 or not value.isdigit():
|
|
67
|
+
raise BadParameter(
|
|
68
|
+
"SET_DAY_OF_WEEK takes a single digit, 1 for Sunday through 7 for Saturday, "
|
|
69
|
+
"got %r" % parameter
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
return frames.set_day_of_week(int(value))
|
|
73
|
+
except frames.ProtocolError as err:
|
|
74
|
+
raise BadParameter(str(err)) from err
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _set_time_format(parameter: str) -> bytes:
|
|
78
|
+
value = parameter.strip().upper()
|
|
79
|
+
if value not in {"S", "M"}:
|
|
80
|
+
raise BadParameter(
|
|
81
|
+
"SET_TIME_FORMAT takes 'S' for standard or 'M' for military, got %r" % parameter
|
|
82
|
+
)
|
|
83
|
+
return frames.set_time_format(military=value == "M")
|