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,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")