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