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,112 @@
1
+ """Deciding which sign file each message lives in.
2
+
3
+ The sign stores up to 26 TEXT files, labelled ``A`` through ``Z``, plus the
4
+ priority file ``0``. This service allocates a fixed pool of those TEXT files
5
+ once and then hands them out to registered messages, one file each.
6
+
7
+ The pool is fixed rather than grown on demand for one reason: allocating files
8
+ erases the sign. Growing the pool when a fourth source turned up would blank the
9
+ other three, so the size is a configuration decision made up front and the
10
+ service simply refuses a message once the pool is full.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from readerboard.protocol import constants as c
16
+ from readerboard.protocol.frames import FileAllocation
17
+ from readerboard.sign.state import AppliedLayout
18
+
19
+
20
+ class LayoutFull(RuntimeError):
21
+ """Every file in the pool is already spoken for."""
22
+
23
+
24
+ class Layout:
25
+ """The pool of sign files, and who currently holds each one."""
26
+
27
+ def __init__(self, slot_count: int, slot_capacity: int) -> None:
28
+ """Describe a pool of ``slot_count`` files of ``slot_capacity`` bytes each."""
29
+ if not 1 <= slot_count <= len(c.TEXT_FILE_LABELS):
30
+ raise ValueError(
31
+ "slot_count must be between 1 and %d, got %d"
32
+ % (len(c.TEXT_FILE_LABELS), slot_count)
33
+ )
34
+ self.slot_count = slot_count
35
+ self.slot_capacity = slot_capacity
36
+ self.labels: tuple[bytes, ...] = c.TEXT_FILE_LABELS[:slot_count]
37
+ self._assigned: dict[str, bytes] = {}
38
+
39
+ # == the memory configuration ==========================================
40
+
41
+ def allocations(self) -> list[FileAllocation]:
42
+ """Return the memory configuration entries for the whole pool."""
43
+ return [FileAllocation(label, self.slot_capacity) for label in self.labels]
44
+
45
+ def as_applied(self) -> AppliedLayout:
46
+ """Return this layout in the form the state file records."""
47
+ return AppliedLayout(
48
+ slot_count=self.slot_count,
49
+ slot_capacity=self.slot_capacity,
50
+ labels=[label.decode("ascii") for label in self.labels],
51
+ )
52
+
53
+ def needs_reconfiguration(self, applied: AppliedLayout | None) -> bool:
54
+ """Whether the sign has to be reallocated, which will erase it."""
55
+ if applied is None:
56
+ return True
57
+ return not applied.matches(self.slot_count, self.slot_capacity)
58
+
59
+ # == handing files out ==================================================
60
+
61
+ def assign(self, key: str) -> bytes:
62
+ """Return the file this key uses, claiming a free one if it has none.
63
+
64
+ Raises :class:`LayoutFull` when the pool is exhausted, which the API
65
+ turns into a 409 rather than quietly dropping somebody's message.
66
+ """
67
+ existing = self._assigned.get(key)
68
+ if existing is not None:
69
+ return existing
70
+
71
+ taken = set(self._assigned.values())
72
+ for label in self.labels:
73
+ if label not in taken:
74
+ self._assigned[key] = label
75
+ return label
76
+
77
+ raise LayoutFull(
78
+ "all %d message slots are in use. Remove one, or raise slot_count and "
79
+ "restart, which reallocates the sign and clears it." % self.slot_count
80
+ )
81
+
82
+ def release(self, key: str) -> bytes | None:
83
+ """Give a key's file back to the pool, returning the file it held."""
84
+ return self._assigned.pop(key, None)
85
+
86
+ def label_for(self, key: str) -> bytes | None:
87
+ """Return the file this key holds, or None if it holds none."""
88
+ return self._assigned.get(key)
89
+
90
+ def restore(self, key: str, label: bytes) -> None:
91
+ """Re-establish an assignment read back from the state file.
92
+
93
+ A pool that shrank between runs can leave a key pointing at a file that
94
+ no longer exists. That key is dropped rather than silently moved, since
95
+ moving it would mean writing to a file the sign has not allocated.
96
+ """
97
+ if label not in self.labels:
98
+ raise ValueError(
99
+ "file %r is outside the pool A to %s"
100
+ % (label.decode("ascii"), self.labels[-1].decode("ascii"))
101
+ )
102
+ self._assigned[key] = label
103
+
104
+ @property
105
+ def assignments(self) -> dict[str, bytes]:
106
+ """A copy of the current key to file mapping."""
107
+ return dict(self._assigned)
108
+
109
+ @property
110
+ def free_count(self) -> int:
111
+ """How many files in the pool are unclaimed."""
112
+ return self.slot_count - len(self._assigned)
@@ -0,0 +1,171 @@
1
+ """What the service remembers across a restart.
2
+
3
+ Three things have to survive: which messages are registered and where on the
4
+ sign each one lives, whether an alert is currently holding the display, and
5
+ which memory configuration was last applied.
6
+
7
+ The last of those matters more than it looks. Writing a memory configuration
8
+ erases every file on the sign, so the service must be able to tell "the pool I
9
+ want is the pool that is already there" from "the pool changed", and only
10
+ reconfigure in the second case. Without this record, every restart would wipe
11
+ the sign.
12
+
13
+ Writes are atomic. A half-written state file after a power cut would strand the
14
+ sign, and a Pi losing power is exactly the event this service is expected to
15
+ recover from.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import os
23
+ import tempfile
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ from pydantic import BaseModel, Field
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ STATE_VERSION = 1
32
+
33
+
34
+ class SlotState(BaseModel):
35
+ """One registered message."""
36
+
37
+ key: str
38
+ label: str
39
+ message: str
40
+ mode: str
41
+ position: str
42
+ order: int = 0
43
+ source: str | None = None
44
+ expires_at: datetime | None = None
45
+ updated_at: datetime
46
+
47
+
48
+ class AlertState(BaseModel):
49
+ """An alert holding the priority file."""
50
+
51
+ message: str
52
+ mode: str
53
+ position: str
54
+ started_at: datetime
55
+ expires_at: datetime | None = None
56
+
57
+
58
+ class AppliedLayout(BaseModel):
59
+ """The memory configuration currently believed to be on the sign."""
60
+
61
+ slot_count: int
62
+ slot_capacity: int
63
+ labels: list[str]
64
+
65
+ def matches(self, slot_count: int, slot_capacity: int) -> bool:
66
+ """Whether this layout is already what the given settings ask for."""
67
+ return self.slot_count == slot_count and self.slot_capacity == slot_capacity
68
+
69
+
70
+ class ServiceState(BaseModel):
71
+ """Everything persisted, in one document."""
72
+
73
+ version: int = STATE_VERSION
74
+ slots: dict[str, SlotState] = Field(default_factory=dict)
75
+ alert: AlertState | None = None
76
+ layout: AppliedLayout | None = None
77
+
78
+
79
+ class StateStore:
80
+ """Loads and saves :class:`ServiceState` as JSON, atomically."""
81
+
82
+ def __init__(self, path: Path) -> None:
83
+ """Point the store at a file. The file need not exist yet."""
84
+ self.path = path
85
+ self._last_written: str | None = None
86
+ self.writes = 0
87
+ self.skipped = 0
88
+
89
+ def load(self) -> ServiceState:
90
+ """Read the state, returning a fresh one if there is nothing usable to read.
91
+
92
+ A corrupt or unreadable state file is a bad reason to refuse to start.
93
+ The sign can be repopulated by its sources; refusing to boot cannot be
94
+ fixed without someone logging in. So it is logged loudly and set aside.
95
+ """
96
+ if not self.path.exists():
97
+ logger.info("no state file at %s; starting empty", self.path)
98
+ return ServiceState()
99
+
100
+ try:
101
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
102
+ except (OSError, json.JSONDecodeError) as err:
103
+ logger.error("could not read state from %s (%s); starting empty", self.path, err)
104
+ return ServiceState()
105
+
106
+ version = raw.get("version")
107
+ if version != STATE_VERSION:
108
+ logger.error(
109
+ "state at %s is version %r, not %d; starting empty",
110
+ self.path,
111
+ version,
112
+ STATE_VERSION,
113
+ )
114
+ return ServiceState()
115
+
116
+ try:
117
+ state = ServiceState.model_validate(raw)
118
+ except ValueError as err:
119
+ logger.error("state at %s did not validate (%s); starting empty", self.path, err)
120
+ return ServiceState()
121
+
122
+ logger.info(
123
+ "restored %d slot(s)%s from %s",
124
+ len(state.slots),
125
+ " and an active alert" if state.alert else "",
126
+ self.path,
127
+ )
128
+ return state
129
+
130
+ def save(self, state: ServiceState) -> None:
131
+ """Write the state out, replacing the old file in one step.
132
+
133
+ A save whose content is identical to the last one written is skipped.
134
+ This runs on a Raspberry Pi with an SD card underneath it, and the
135
+ service saves on every operation whether or not the operation changed
136
+ anything: sweeps that expire nothing, restores, releases with no alert
137
+ to release.
138
+
139
+ An upsert that genuinely re-sends the same temperature is not one of
140
+ those, because ``updated_at`` moves. That is deliberate. Knowing when a
141
+ source last wrote is how a dead automation becomes visible, and it is
142
+ worth one small write per message to keep.
143
+ """
144
+ payload = state.model_dump_json(indent=2)
145
+ if payload == self._last_written and self.path.exists():
146
+ self.skipped += 1
147
+ return
148
+
149
+ self.path.parent.mkdir(parents=True, exist_ok=True)
150
+
151
+ handle = tempfile.NamedTemporaryFile( # noqa: SIM115 - closed, then renamed
152
+ mode="w",
153
+ encoding="utf-8",
154
+ dir=self.path.parent,
155
+ prefix=self.path.name + ".",
156
+ suffix=".tmp",
157
+ delete=False,
158
+ )
159
+ temporary = Path(handle.name)
160
+ try:
161
+ with handle:
162
+ handle.write(payload)
163
+ handle.flush()
164
+ os.fsync(handle.fileno())
165
+ os.replace(temporary, self.path)
166
+ except OSError:
167
+ temporary.unlink(missing_ok=True)
168
+ raise
169
+
170
+ self._last_written = payload
171
+ self.writes += 1
@@ -0,0 +1 @@
1
+ """Ways of getting bytes to the sign."""
@@ -0,0 +1,53 @@
1
+ """What the rest of the service is allowed to assume about the link to the sign."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+
8
+ class TransportError(RuntimeError):
9
+ """The bytes did not reach the sign.
10
+
11
+ This is the one failure the ``/v2`` API turns into a 503. Reporting it any
12
+ other way, in particular as a 200 with an error string in the body, makes a
13
+ dead serial link indistinguishable from success to anything that reads the
14
+ status code.
15
+ """
16
+
17
+
18
+ @runtime_checkable
19
+ class Transport(Protocol):
20
+ """A one-way byte pipe to the sign.
21
+
22
+ Implementations are synchronous and are expected to block. The controller
23
+ runs them on a worker thread, so nothing here needs to know about asyncio.
24
+ """
25
+
26
+ @property
27
+ def is_open(self) -> bool:
28
+ """Whether the link is currently up."""
29
+ ...
30
+
31
+ @property
32
+ def description(self) -> str:
33
+ """A short human readable name for the link, safe to put in a log line."""
34
+ ...
35
+
36
+ def ensure_open(self) -> None:
37
+ """Open the link if it is down.
38
+
39
+ Raises :class:`TransportError` if it cannot be opened, including when a
40
+ backoff window means it is not yet worth trying.
41
+ """
42
+ ...
43
+
44
+ def write(self, data: bytes) -> None:
45
+ """Send one complete transmission.
46
+
47
+ Raises :class:`TransportError` if it could not be sent.
48
+ """
49
+ ...
50
+
51
+ def close(self) -> None:
52
+ """Close the link. Closing an already closed link does nothing."""
53
+ ...
@@ -0,0 +1,84 @@
1
+ """A transport that keeps the packets instead of sending them.
2
+
3
+ This is what the tests assert against. It is not a mock of the serial link so
4
+ much as a sign that writes everything down: tests read ``packets`` to see
5
+ exactly what would have gone down the wire, which is the same thing the golden
6
+ byte tests in ``tests/test_frames.py`` check the builders produce.
7
+
8
+ It can also be told to fail, which is how the reconnect and error paths get
9
+ exercised without unplugging anything.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from readerboard.transport.base import TransportError
15
+
16
+
17
+ class FakeTransport:
18
+ """An in-memory transport that records every packet written to it."""
19
+
20
+ def __init__(self, *, fail_with: str | None = None, open_fails_with: str | None = None) -> None:
21
+ """Create a transport. Pass a reason to make writes or opens fail."""
22
+ self.packets: list[bytes] = []
23
+ self.fail_with = fail_with
24
+ self.open_fails_with = open_fails_with
25
+ self.open_count = 0
26
+ self.close_count = 0
27
+ self.write_count = 0
28
+ self._is_open = False
29
+
30
+ @property
31
+ def is_open(self) -> bool:
32
+ """Whether the link is currently up."""
33
+ return self._is_open
34
+
35
+ @property
36
+ def description(self) -> str:
37
+ """A short name for the link, safe to log."""
38
+ return "fake://"
39
+
40
+ @property
41
+ def last_error(self) -> str | None:
42
+ """Why the link last failed, or None."""
43
+ return self.open_fails_with or self.fail_with
44
+
45
+ def seconds_until_retry(self) -> float:
46
+ """How long until a reconnect would be attempted. The fake never waits."""
47
+ return 0.0
48
+
49
+ def ensure_open(self) -> None:
50
+ """Open the link if it is down."""
51
+ if self._is_open:
52
+ return
53
+ if self.open_fails_with is not None:
54
+ raise TransportError(self.open_fails_with)
55
+ self._is_open = True
56
+ self.open_count += 1
57
+
58
+ def write(self, data: bytes) -> None:
59
+ """Record one transmission, or fail if the fake was told to."""
60
+ self.ensure_open()
61
+ if self.fail_with is not None:
62
+ self._is_open = False
63
+ raise TransportError(self.fail_with)
64
+ self.write_count += 1
65
+ self.packets.append(data)
66
+
67
+ def close(self) -> None:
68
+ """Close the link."""
69
+ if self._is_open:
70
+ self.close_count += 1
71
+ self._is_open = False
72
+
73
+ # == helpers for tests ==================================================
74
+
75
+ @property
76
+ def last_packet(self) -> bytes:
77
+ """The most recent packet written, for the common single-write assertion."""
78
+ if not self.packets:
79
+ raise AssertionError("nothing has been written to this transport")
80
+ return self.packets[-1]
81
+
82
+ def clear(self) -> None:
83
+ """Forget every recorded packet, leaving the counters alone."""
84
+ self.packets.clear()
@@ -0,0 +1,159 @@
1
+ """The real link to the sign, over a serial port or over the network.
2
+
3
+ ``serial.serial_for_url`` accepts both ``socket://192.168.2.51:4001`` for an
4
+ Ethernet to RS-232 adapter and ``/dev/ttyUSB0`` for a cable plugged straight
5
+ in, so one configuration value covers both ways of reaching the sign. It also
6
+ accepts ``loop://``, which is what the tests use to exercise this module rather
7
+ than a stand-in for it.
8
+
9
+ The link is opened once and held, not opened and closed around every write. The
10
+ old implementation did the latter, and paid for it with a two second sleep on
11
+ every request and a race whenever two callers arrived together.
12
+
13
+ Reconnection is deliberately passive. When the link is down, ``ensure_open``
14
+ refuses to try again until a backoff window has passed, and ``write`` fails
15
+ immediately rather than blocking a request behind a device that is not there.
16
+ Something has to drive the retries; that is the controller's reconnect loop.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import threading
23
+ import time
24
+ from collections.abc import Callable
25
+
26
+ import serial
27
+
28
+ from readerboard.transport.base import TransportError
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class SerialTransport:
34
+ """A held-open pyserial link with capped exponential backoff on failure."""
35
+
36
+ def __init__(
37
+ self,
38
+ url: str,
39
+ *,
40
+ baud_rate: int = 9600,
41
+ timeout: float = 10.0,
42
+ backoff_initial: float = 1.0,
43
+ backoff_max: float = 60.0,
44
+ monotonic: Callable[[], float] = time.monotonic,
45
+ ) -> None:
46
+ """Configure the link. Nothing is opened until :meth:`ensure_open`."""
47
+ self._url = url
48
+ self._baud_rate = baud_rate
49
+ self._timeout = timeout
50
+ self._backoff_initial = backoff_initial
51
+ self._backoff_max = backoff_max
52
+ self._monotonic = monotonic
53
+
54
+ self._port: serial.Serial | None = None
55
+ self._lock = threading.Lock()
56
+ self._failures = 0
57
+ self._retry_after = 0.0
58
+ self._last_error: str | None = None
59
+
60
+ @property
61
+ def is_open(self) -> bool:
62
+ """Whether the link is currently up."""
63
+ port = self._port
64
+ return port is not None and bool(port.is_open)
65
+
66
+ @property
67
+ def description(self) -> str:
68
+ """A short name for the link, safe to log."""
69
+ return self._url
70
+
71
+ @property
72
+ def last_error(self) -> str | None:
73
+ """Why the link last failed, or None if it has not failed since it opened."""
74
+ return self._last_error
75
+
76
+ def seconds_until_retry(self) -> float:
77
+ """How long until :meth:`ensure_open` will try again. Zero means now."""
78
+ if self.is_open:
79
+ return 0.0
80
+ return max(0.0, self._retry_after - self._monotonic())
81
+
82
+ def ensure_open(self) -> None:
83
+ """Open the link if it is down, honouring the backoff window."""
84
+ with self._lock:
85
+ self._ensure_open_locked()
86
+
87
+ def write(self, data: bytes) -> None:
88
+ """Send one complete transmission, opening the link first if needed."""
89
+ with self._lock:
90
+ self._ensure_open_locked()
91
+ port = self._port
92
+ assert port is not None # _ensure_open_locked guarantees this
93
+ try:
94
+ port.write(data)
95
+ port.flush()
96
+ except (serial.SerialException, OSError) as err:
97
+ self._record_failure(err)
98
+ self._close_locked()
99
+ raise TransportError("write to %s failed: %s" % (self._url, err)) from err
100
+
101
+ def close(self) -> None:
102
+ """Close the link. Closing an already closed link does nothing."""
103
+ with self._lock:
104
+ self._close_locked()
105
+
106
+ # == internals ==========================================================
107
+
108
+ def _ensure_open_locked(self) -> None:
109
+ if self.is_open:
110
+ return
111
+
112
+ waiting = max(0.0, self._retry_after - self._monotonic())
113
+ if waiting > 0:
114
+ raise TransportError(
115
+ "link to %s is down (%s); next attempt in %.1fs"
116
+ % (self._url, self._last_error or "reason unknown", waiting)
117
+ )
118
+
119
+ try:
120
+ self._port = serial.serial_for_url(
121
+ self._url, baudrate=self._baud_rate, timeout=self._timeout
122
+ )
123
+ except (serial.SerialException, OSError, ValueError) as err:
124
+ self._record_failure(err)
125
+ self._port = None
126
+ raise TransportError("could not open %s: %s" % (self._url, err)) from err
127
+
128
+ if self._failures:
129
+ logger.info("link to %s is back after %d failed attempts", self._url, self._failures)
130
+ else:
131
+ logger.info("link to %s is open", self._url)
132
+ self._failures = 0
133
+ self._retry_after = 0.0
134
+ self._last_error = None
135
+
136
+ def _record_failure(self, err: Exception) -> None:
137
+ self._failures += 1
138
+ self._last_error = str(err)
139
+ delay = min(self._backoff_max, self._backoff_initial * (2 ** (self._failures - 1)))
140
+ self._retry_after = self._monotonic() + delay
141
+ logger.warning(
142
+ "link to %s failed (attempt %d): %s; backing off %.1fs",
143
+ self._url,
144
+ self._failures,
145
+ err,
146
+ delay,
147
+ )
148
+
149
+ def _close_locked(self) -> None:
150
+ port = self._port
151
+ self._port = None
152
+ if port is None:
153
+ return
154
+ try:
155
+ port.close()
156
+ except Exception:
157
+ # A port that will not close cleanly is still a port we are done
158
+ # with, and there is nothing useful for a caller to do about it.
159
+ logger.debug("ignoring error while closing %s", self._url, exc_info=True)