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,250 @@
|
|
|
1
|
+
"""Build the byte strings the sign expects.
|
|
2
|
+
|
|
3
|
+
Everything in this module is pure: bytes in, bytes out, no input or output. That
|
|
4
|
+
is what makes the golden byte tests in ``tests/test_frames.py`` worth having,
|
|
5
|
+
because they pin the wire format against the protocol document without needing a
|
|
6
|
+
sign to be plugged in.
|
|
7
|
+
|
|
8
|
+
A complete transmission looks like this:
|
|
9
|
+
|
|
10
|
+
WAKEUP SOH sign type address STX payload EOT
|
|
11
|
+
|
|
12
|
+
The payload is one command. ``A`` writes a TEXT file and ``E`` writes a special
|
|
13
|
+
function; the special function's own label follows immediately after the ``E``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from readerboard.protocol import constants as c
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProtocolError(ValueError):
|
|
24
|
+
"""A frame could not be built as asked."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class FileAllocation:
|
|
29
|
+
"""One entry in a memory configuration.
|
|
30
|
+
|
|
31
|
+
``capacity`` is the file's size in bytes. ``locked`` decides whether the
|
|
32
|
+
sign's own infrared keyboard may edit the file; leaving it unlocked is the
|
|
33
|
+
friendlier default for a sign hanging on a wall.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
label: bytes
|
|
37
|
+
capacity: int
|
|
38
|
+
file_type: bytes = c.FILE_TYPE_TEXT
|
|
39
|
+
locked: bool = False
|
|
40
|
+
schedule: bytes = c.TEXT_SCHEDULE_ALWAYS
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
"""Reject an allocation the sign could not accept."""
|
|
44
|
+
if len(self.label) != 1:
|
|
45
|
+
raise ProtocolError("a file label is exactly one byte, got %r" % self.label)
|
|
46
|
+
if self.label == c.FILE_PRIORITY:
|
|
47
|
+
raise ProtocolError(
|
|
48
|
+
"the priority file '0' is allocated by the sign and must not appear "
|
|
49
|
+
"in a memory configuration"
|
|
50
|
+
)
|
|
51
|
+
if not 0 < self.capacity <= 0xFFFF:
|
|
52
|
+
raise ProtocolError("file capacity must be between 1 and 65535, got %d" % self.capacity)
|
|
53
|
+
if len(self.schedule) != 4:
|
|
54
|
+
raise ProtocolError("a file schedule is exactly four bytes, got %r" % self.schedule)
|
|
55
|
+
|
|
56
|
+
def encode(self) -> bytes:
|
|
57
|
+
"""Render this entry as its eleven protocol characters."""
|
|
58
|
+
lock = c.FILE_LOCKED if self.locked else c.FILE_UNLOCKED
|
|
59
|
+
return b"%s%s%s%04X%s" % (
|
|
60
|
+
self.label,
|
|
61
|
+
self.file_type,
|
|
62
|
+
lock,
|
|
63
|
+
self.capacity,
|
|
64
|
+
self.schedule,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def packet(
|
|
69
|
+
payload: bytes,
|
|
70
|
+
*,
|
|
71
|
+
sign_type: bytes = c.SIGN_TYPE_BETABRITE,
|
|
72
|
+
address: bytes = c.SIGN_ADDRESS_BROADCAST,
|
|
73
|
+
) -> bytes:
|
|
74
|
+
"""Wrap a payload in the framing bytes and return the whole transmission."""
|
|
75
|
+
return c.WAKEUP + c.SOH + sign_type + address + c.STX + payload + c.EOT
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_text_file(
|
|
79
|
+
label: bytes,
|
|
80
|
+
body: bytes,
|
|
81
|
+
*,
|
|
82
|
+
mode: bytes = c.MODE_HOLD,
|
|
83
|
+
position: bytes = c.TEXT_POS_MIDDLE,
|
|
84
|
+
) -> bytes:
|
|
85
|
+
"""Build the payload that writes ``body`` into the TEXT file ``label``.
|
|
86
|
+
|
|
87
|
+
An empty ``body`` blanks the file. Writing an empty body to the priority
|
|
88
|
+
file is how an alert is released, so it is explicitly allowed.
|
|
89
|
+
"""
|
|
90
|
+
if len(label) != 1:
|
|
91
|
+
raise ProtocolError("a file label is exactly one byte, got %r" % label)
|
|
92
|
+
if label == c.FILE_PRIORITY and len(body) > c.PRIORITY_FILE_CAPACITY:
|
|
93
|
+
raise ProtocolError(
|
|
94
|
+
"the priority file holds %d bytes and the sign will not let that change; "
|
|
95
|
+
"this message needs %d" % (c.PRIORITY_FILE_CAPACITY, len(body))
|
|
96
|
+
)
|
|
97
|
+
return c.COMMAND_WRITE_TEXT + label + c.SOM + position + mode + body
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_priority_file() -> bytes:
|
|
101
|
+
"""Build the payload that releases a priority takeover.
|
|
102
|
+
|
|
103
|
+
Writing nothing to file ``0`` is what tells the sign to go back to playing
|
|
104
|
+
its run sequence. There is no separate release command.
|
|
105
|
+
"""
|
|
106
|
+
return write_text_file(c.FILE_PRIORITY, b"")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_special(label: bytes, parameter: bytes = b"") -> bytes:
|
|
110
|
+
"""Build the payload for a special function."""
|
|
111
|
+
if len(label) != 1:
|
|
112
|
+
raise ProtocolError("a special function label is exactly one byte, got %r" % label)
|
|
113
|
+
return c.COMMAND_WRITE_SPECIAL + label + parameter
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def clear_memory() -> bytes:
|
|
117
|
+
"""Build the payload that clears the sign's memory outright.
|
|
118
|
+
|
|
119
|
+
The protocol spells this "E$" with nothing after it. It is separate from
|
|
120
|
+
:func:`set_memory_config` on purpose, so that an accidentally empty list of
|
|
121
|
+
allocations cannot wipe the sign by mistake.
|
|
122
|
+
"""
|
|
123
|
+
return write_special(c.SF_SET_MEMORY_CONFIG)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def memory_claimed(allocations: list[FileAllocation]) -> int:
|
|
127
|
+
"""Bytes of the sign's memory pool a configuration would take.
|
|
128
|
+
|
|
129
|
+
The protocol charges each configured file eleven bytes of directory
|
|
130
|
+
overhead on top of its own size, and the total has to fit the pool.
|
|
131
|
+
"""
|
|
132
|
+
return sum(entry.capacity + c.FILE_OVERHEAD_BYTES for entry in allocations)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def set_memory_config(allocations: list[FileAllocation]) -> bytes:
|
|
136
|
+
"""Build the payload that allocates the sign's files.
|
|
137
|
+
|
|
138
|
+
This erases every file already on the sign, so the whole pool has to be
|
|
139
|
+
described in one call. Note also that, apart from the priority file and the
|
|
140
|
+
default file ``A``, no file can be written at all until a memory
|
|
141
|
+
configuration has been written. See docs/protocol-notes.md.
|
|
142
|
+
"""
|
|
143
|
+
if not allocations:
|
|
144
|
+
raise ProtocolError(
|
|
145
|
+
"a memory configuration needs at least one file; use clear_memory() to "
|
|
146
|
+
"deliberately wipe the sign"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
labels = [entry.label for entry in allocations]
|
|
150
|
+
if len(set(labels)) != len(labels):
|
|
151
|
+
raise ProtocolError("a memory configuration cannot name the same file twice")
|
|
152
|
+
|
|
153
|
+
body = b"".join(entry.encode() for entry in allocations)
|
|
154
|
+
return write_special(c.SF_SET_MEMORY_CONFIG, body)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def set_run_sequence(
|
|
158
|
+
labels: list[bytes],
|
|
159
|
+
*,
|
|
160
|
+
mode: bytes = c.RUN_SEQ_IGNORE_TIME,
|
|
161
|
+
locked: bool = False,
|
|
162
|
+
) -> bytes:
|
|
163
|
+
"""Build the payload that decides which TEXT files play, and in what order.
|
|
164
|
+
|
|
165
|
+
An empty ``labels`` is meaningful: it stops the sign playing anything from
|
|
166
|
+
the pool, which is what an emptied registry should look like.
|
|
167
|
+
|
|
168
|
+
The default mode ignores each file's own start and stop time. The service
|
|
169
|
+
allocates every file as always eligible, so the two modes behave the same
|
|
170
|
+
for it, but being explicit means a file that later gains a schedule does not
|
|
171
|
+
silently change how the rotation behaves.
|
|
172
|
+
"""
|
|
173
|
+
for label in labels:
|
|
174
|
+
if len(label) != 1:
|
|
175
|
+
raise ProtocolError("a file label is exactly one byte, got %r" % label)
|
|
176
|
+
if len(set(labels)) != len(labels):
|
|
177
|
+
raise ProtocolError("a run sequence cannot name the same file twice")
|
|
178
|
+
|
|
179
|
+
parameter = mode + (c.FILE_LOCKED if locked else c.FILE_UNLOCKED) + b"".join(labels)
|
|
180
|
+
return write_special(c.SF_SET_RUN_SEQUENCE, parameter)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def set_time(hour: int, minute: int) -> bytes:
|
|
184
|
+
"""Build the payload that sets the sign's clock, as HHMM on a 24 hour clock."""
|
|
185
|
+
if not 0 <= hour <= 23:
|
|
186
|
+
raise ProtocolError("hour must be between 0 and 23, got %d" % hour)
|
|
187
|
+
if not 0 <= minute <= 59:
|
|
188
|
+
raise ProtocolError("minute must be between 0 and 59, got %d" % minute)
|
|
189
|
+
return write_special(c.CMD_SET_TIME, b"%02d%02d" % (hour, minute))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def set_day_of_week(day: int) -> bytes:
|
|
193
|
+
"""Build the payload that sets the sign's day of week, 1 for Sunday to 7 for Saturday."""
|
|
194
|
+
if not 1 <= day <= 7:
|
|
195
|
+
raise ProtocolError("day of week must be between 1 and 7, got %d" % day)
|
|
196
|
+
return write_special(c.CMD_SET_DAY_OF_WEEK, b"%d" % day)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_time_format(military: bool) -> bytes:
|
|
200
|
+
"""Build the payload that chooses a 24 hour or 12 hour clock on the sign."""
|
|
201
|
+
return write_special(c.CMD_SET_TIME_FORMAT, b"M" if military else b"S")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ===========================================================================
|
|
205
|
+
# Reading state back from the sign.
|
|
206
|
+
#
|
|
207
|
+
# These exist so that divergence between what the service believes and what the
|
|
208
|
+
# sign actually holds can be detected rather than assumed. Whether this sign
|
|
209
|
+
# answers them through the Ethernet to RS-232 adapter is the one genuinely
|
|
210
|
+
# unproven thing left; two-way traffic over that path has never been tried.
|
|
211
|
+
# scripts/protocol_spike.py settles it. Until then the service reconciles by
|
|
212
|
+
# re-pushing on a timer, which needs no reply.
|
|
213
|
+
# ===========================================================================
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_special(label: bytes) -> bytes:
|
|
217
|
+
"""Build the payload that asks the sign for a special function's value."""
|
|
218
|
+
if len(label) != 1:
|
|
219
|
+
raise ProtocolError("a special function label is exactly one byte, got %r" % label)
|
|
220
|
+
return c.COMMAND_READ_SPECIAL + label
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def read_memory_config() -> bytes:
|
|
224
|
+
"""Ask the sign for its memory configuration table.
|
|
225
|
+
|
|
226
|
+
Note when comparing the reply against a plan: the sign hands whatever is
|
|
227
|
+
left of the memory pool to the first file in the configuration once it
|
|
228
|
+
starts running, so the first file's size will not match what was sent.
|
|
229
|
+
Compare the plan semantically, not byte for byte.
|
|
230
|
+
"""
|
|
231
|
+
return read_special(c.SF_SET_MEMORY_CONFIG)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def read_memory_pool_size() -> bytes:
|
|
235
|
+
"""Ask the sign for the total and unused size of its memory pool."""
|
|
236
|
+
return read_special(c.SF_MEMORY_POOL_SIZE)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def read_run_sequence() -> bytes:
|
|
240
|
+
"""Ask the sign which files it is currently playing, and in what order."""
|
|
241
|
+
return read_special(c.SF_SET_RUN_SEQUENCE)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def read_run_time_table() -> bytes:
|
|
245
|
+
"""Ask the sign for its run time table.
|
|
246
|
+
|
|
247
|
+
The reply also carries whether a priority message is running, which is the
|
|
248
|
+
only way to ask the sign whether an alert is still up.
|
|
249
|
+
"""
|
|
250
|
+
return read_special(c.SF_RUN_TIME_TABLE)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Turn a markup string into the bytes a TEXT file holds.
|
|
2
|
+
|
|
3
|
+
The message language is deliberately small: printable text, plus tokens written
|
|
4
|
+
as ``<name>``. ``<red>Hello <degree>`` is a colour change, the word Hello, and a
|
|
5
|
+
degree symbol.
|
|
6
|
+
|
|
7
|
+
Two rules here are load bearing, and both have an obvious wrong answer.
|
|
8
|
+
|
|
9
|
+
First, the cursor advances on every branch. A tokenizer that only moves forward
|
|
10
|
+
inside the branch that found a closing ``>`` will spin forever on a message
|
|
11
|
+
containing a bare ``<``, wedging whichever thread is rendering it. The loop body
|
|
12
|
+
below always moves ``index``, whatever it decided about the character it just
|
|
13
|
+
looked at.
|
|
14
|
+
|
|
15
|
+
Second, text is encoded against the sign's own character table, not as UTF-8.
|
|
16
|
+
The sign does not understand UTF-8, so encoding it that way puts two bytes of
|
|
17
|
+
noise on the display in place of each accented letter. Characters the sign can
|
|
18
|
+
render are mapped to it, and characters it cannot are either rejected or
|
|
19
|
+
replaced depending on how strict the caller asked us to be.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from readerboard.protocol import constants as c
|
|
25
|
+
from readerboard.protocol.tokens import MARKUP_BY_TEXT
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MarkupError(ValueError):
|
|
29
|
+
"""A message could not be rendered exactly as written."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Characters outside plain ASCII that the sign can render, mapped to the single
|
|
33
|
+
# byte that renders them. Taken from the extended character table in the
|
|
34
|
+
# protocol document.
|
|
35
|
+
EXTENDED_CHARACTERS: dict[str, bytes] = {
|
|
36
|
+
"°": c.DEGREES,
|
|
37
|
+
"¢": c.CENTS,
|
|
38
|
+
"£": c.POUNDS,
|
|
39
|
+
"¥": c.YEN,
|
|
40
|
+
"¿": c.INVERT_QUESTION,
|
|
41
|
+
"¡": c.INVERT_EXCLAIM,
|
|
42
|
+
"Ä": c.A_UMLAUT,
|
|
43
|
+
"Å": c.A_CIRCLE,
|
|
44
|
+
"Æ": c.AE_LIGATURE,
|
|
45
|
+
"Ç": c.C_TAIL,
|
|
46
|
+
"É": c.E_ACCENT,
|
|
47
|
+
"Ñ": c.N_TILDE,
|
|
48
|
+
"Õ": c.O_TILDE,
|
|
49
|
+
"Ö": c.O_UMLAUT,
|
|
50
|
+
"Ü": c.U_UMLAUT,
|
|
51
|
+
"ß": c.BETA,
|
|
52
|
+
"à": c.a_GRAVE,
|
|
53
|
+
"á": c.a_ACCENT,
|
|
54
|
+
"â": c.a_CIRCUMFLEX,
|
|
55
|
+
"ä": c.a_UMLAUT,
|
|
56
|
+
"å": c.a_CIRCLE,
|
|
57
|
+
"æ": c.ae_LIGATURE,
|
|
58
|
+
"ç": c.c_TAIL,
|
|
59
|
+
"è": c.e_GRAVE,
|
|
60
|
+
"é": c.e_ACCENT,
|
|
61
|
+
"ê": c.e_CIRCUMFLEX,
|
|
62
|
+
"ë": c.e_UMLAUT,
|
|
63
|
+
"ì": c.i_GRAVE,
|
|
64
|
+
"í": c.i_ACCENT,
|
|
65
|
+
"î": c.i_CIRCUMFLEX,
|
|
66
|
+
"ï": c.i_UMLAUT,
|
|
67
|
+
"ñ": c.n_TILDE,
|
|
68
|
+
"ò": c.o_GRAVE,
|
|
69
|
+
"ó": c.o_ACCENT,
|
|
70
|
+
"ô": c.o_CIRCUMFLEX,
|
|
71
|
+
"õ": c.o_TILDE,
|
|
72
|
+
"ö": c.o_UMLAUT,
|
|
73
|
+
"ù": c.u_GRAVE,
|
|
74
|
+
"ú": c.u_ACCENT,
|
|
75
|
+
"û": c.u_CIRCUMFLEX,
|
|
76
|
+
"ü": c.u_UMLAUT,
|
|
77
|
+
"ÿ": c.y_UMLAUT,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# What an unrenderable character becomes when the caller is not being strict.
|
|
81
|
+
REPLACEMENT = b"?"
|
|
82
|
+
|
|
83
|
+
# The tag name may only contain these, which keeps a stray "<" in prose such as
|
|
84
|
+
# "a < b" from being mistaken for the start of a tag that runs to the next ">".
|
|
85
|
+
_TAG_CHARACTERS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render(message: str, *, strict: bool = True) -> bytes:
|
|
89
|
+
"""Render ``message`` to sign bytes.
|
|
90
|
+
|
|
91
|
+
When ``strict`` is true an unknown tag, an unterminated tag or a character
|
|
92
|
+
the sign cannot display raises :class:`MarkupError`. When it is false each
|
|
93
|
+
of those is passed through as literal text instead, which is what the
|
|
94
|
+
simple endpoints need so that payloads that worked against the old
|
|
95
|
+
server keep working against this one.
|
|
96
|
+
"""
|
|
97
|
+
out = bytearray()
|
|
98
|
+
index = 0
|
|
99
|
+
length = len(message)
|
|
100
|
+
|
|
101
|
+
while index < length:
|
|
102
|
+
char = message[index]
|
|
103
|
+
|
|
104
|
+
if char == "<":
|
|
105
|
+
tag, after = _read_tag(message, index)
|
|
106
|
+
if tag is None:
|
|
107
|
+
# Not a tag at all, just a less-than sign in the text.
|
|
108
|
+
if strict:
|
|
109
|
+
raise MarkupError(
|
|
110
|
+
"unterminated tag at position %d; write a complete tag such as "
|
|
111
|
+
"<red>, or remove the '<'" % index
|
|
112
|
+
)
|
|
113
|
+
out += _encode_character("<", strict=False)
|
|
114
|
+
index += 1
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
token = MARKUP_BY_TEXT.get(tag)
|
|
118
|
+
if token is None:
|
|
119
|
+
if strict:
|
|
120
|
+
raise MarkupError("unknown markup token %r" % tag)
|
|
121
|
+
out += _encode_text(tag, strict=False)
|
|
122
|
+
else:
|
|
123
|
+
out += token.value
|
|
124
|
+
index = after
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
out += _encode_character(char, strict=strict)
|
|
128
|
+
index += 1
|
|
129
|
+
|
|
130
|
+
return bytes(out)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_tag(message: str, start: int) -> tuple[str | None, int]:
|
|
134
|
+
"""Read a ``<name>`` beginning at ``start``.
|
|
135
|
+
|
|
136
|
+
Returns the tag including its brackets and the index just past it, or
|
|
137
|
+
``(None, start)`` when what follows is not a well formed tag.
|
|
138
|
+
"""
|
|
139
|
+
index = start + 1
|
|
140
|
+
length = len(message)
|
|
141
|
+
while index < length and message[index] in _TAG_CHARACTERS:
|
|
142
|
+
index += 1
|
|
143
|
+
|
|
144
|
+
if index < length and message[index] == ">" and index > start + 1:
|
|
145
|
+
return message[start : index + 1], index + 1
|
|
146
|
+
|
|
147
|
+
return None, start
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _encode_text(text: str, *, strict: bool) -> bytes:
|
|
151
|
+
out = bytearray()
|
|
152
|
+
for char in text:
|
|
153
|
+
out += _encode_character(char, strict=strict)
|
|
154
|
+
return bytes(out)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _encode_character(char: str, *, strict: bool) -> bytes:
|
|
158
|
+
code = ord(char)
|
|
159
|
+
if 0x20 <= code <= 0x7E:
|
|
160
|
+
return char.encode("ascii")
|
|
161
|
+
|
|
162
|
+
mapped = EXTENDED_CHARACTERS.get(char)
|
|
163
|
+
if mapped is not None:
|
|
164
|
+
return mapped
|
|
165
|
+
|
|
166
|
+
if char == "\n":
|
|
167
|
+
return c.CR
|
|
168
|
+
|
|
169
|
+
if strict:
|
|
170
|
+
raise MarkupError(
|
|
171
|
+
"the sign cannot display %r (U+%04X); use a markup token or plain ASCII" % (char, code)
|
|
172
|
+
)
|
|
173
|
+
return REPLACEMENT
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The vocabulary a caller can use: markup tokens, display modes, control commands.
|
|
2
|
+
|
|
3
|
+
Every name here is part of the service's public surface, so renaming one is a
|
|
4
|
+
breaking change for whatever is already sending it. The table covers what the
|
|
5
|
+
sign can actually do rather than a convenient subset, because a mode missing
|
|
6
|
+
from here is a mode nobody can reach.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from readerboard.protocol import constants as c
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Token:
|
|
18
|
+
"""One named thing a caller can ask for, and the bytes it turns into."""
|
|
19
|
+
|
|
20
|
+
text: str
|
|
21
|
+
value: bytes
|
|
22
|
+
description: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ===========================================================================
|
|
26
|
+
# Markup tokens, written inline in a message as <name>.
|
|
27
|
+
# ===========================================================================
|
|
28
|
+
|
|
29
|
+
MARKUP_TOKENS: tuple[Token, ...] = (
|
|
30
|
+
Token("<red>", c.TEXT_COLOR_RED, "Set text colour to red"),
|
|
31
|
+
Token("<green>", c.TEXT_COLOR_GREEN, "Set text colour to green"),
|
|
32
|
+
Token("<amber>", c.TEXT_COLOR_AMBER, "Set text colour to amber"),
|
|
33
|
+
Token("<dimred>", c.TEXT_COLOR_DIMRED, "Set text colour to dim red"),
|
|
34
|
+
Token("<dimgreen>", c.TEXT_COLOR_DIMGREEN, "Set text colour to dim green"),
|
|
35
|
+
Token("<brown>", c.TEXT_COLOR_BROWN, "Set text colour to brown"),
|
|
36
|
+
Token("<orange>", c.TEXT_COLOR_ORANGE, "Set text colour to orange"),
|
|
37
|
+
Token("<yellow>", c.TEXT_COLOR_YELLOW, "Set text colour to yellow"),
|
|
38
|
+
Token("<rainbow1>", c.TEXT_COLOR_RAINBOW1, "Colour the whole message as a rainbow"),
|
|
39
|
+
Token("<rainbow2>", c.TEXT_COLOR_RAINBOW2, "Colour each character as a rainbow"),
|
|
40
|
+
Token("<color_mix>", c.TEXT_COLOR_MIX, "Give each character a different colour"),
|
|
41
|
+
Token("<color_auto>", c.TEXT_COLOR_AUTO, "Cycle through the colour modes"),
|
|
42
|
+
Token("<flash_on>", c.CHAR_FLASH_ON, "Characters after this token flash"),
|
|
43
|
+
Token("<flash_off>", c.CHAR_FLASH_OFF, "Characters after this token stop flashing"),
|
|
44
|
+
Token("<wide_on>", c.WIDE_CHARS_ON, "Characters after this token are wide"),
|
|
45
|
+
Token("<wide_off>", c.WIDE_CHARS_OFF, "Characters after this token are normal width"),
|
|
46
|
+
Token(
|
|
47
|
+
"<dbl_height_on>",
|
|
48
|
+
c.DBL_HEIGHT_CHARS_ON,
|
|
49
|
+
"Characters after this token are double height",
|
|
50
|
+
),
|
|
51
|
+
Token("<dbl_height_off>", c.DBL_HEIGHT_CHARS_OFF, "Return to single height characters"),
|
|
52
|
+
Token("<fixed_width>", c.FIXED_WIDTH_ON, "Left justify and make text fixed width; put this first"),
|
|
53
|
+
Token("<proportional>", c.FIXED_WIDTH_OFF, "Return to proportionally spaced text"),
|
|
54
|
+
Token("<degree>", c.XC_DEGREES, "Degree symbol"),
|
|
55
|
+
Token("<block>", c.BLOCK_CHAR, "A solid square block character"),
|
|
56
|
+
Token("<half_space>", c.TILDE, "A half width space"),
|
|
57
|
+
Token("<time>", c.CURTIME_INSERT, "Insert the sign's current time"),
|
|
58
|
+
Token("<week_day>", c.CURDATE_WEEKDAYY, "Insert the current day of the week"),
|
|
59
|
+
Token("<date>", c.CURDATE_MMDDYY_SLASH, "Insert the current date as MM/DD/YY"),
|
|
60
|
+
Token("<date_dmy>", c.CURDATE_DDMMYY_SLASH, "Insert the current date as DD/MM/YY"),
|
|
61
|
+
Token("<date_long>", c.CURDATE_MMMDDYYYY, "Insert the current date as MMM.DD, YYYY"),
|
|
62
|
+
Token("<newline>", c.CR, "Start a new line"),
|
|
63
|
+
Token("<new_page>", c.NEW_PAGE, "Start the next display page"),
|
|
64
|
+
Token("<no_hold_speed>", c.NO_HOLD_SPEED, "Do not pause after the mode finishes"),
|
|
65
|
+
Token("<speed1>", c.SPEED_1, "Set the scroll speed to 1, the slowest"),
|
|
66
|
+
Token("<speed2>", c.SPEED_2, "Set the scroll speed to 2"),
|
|
67
|
+
Token("<speed3>", c.SPEED_3, "Set the scroll speed to 3"),
|
|
68
|
+
Token("<speed4>", c.SPEED_4, "Set the scroll speed to 4"),
|
|
69
|
+
Token("<speed5>", c.SPEED_5, "Set the scroll speed to 5, the fastest"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ===========================================================================
|
|
74
|
+
# Display modes, chosen per message rather than written inline.
|
|
75
|
+
# ===========================================================================
|
|
76
|
+
|
|
77
|
+
DISPLAY_MODES: tuple[Token, ...] = (
|
|
78
|
+
Token(
|
|
79
|
+
"HOLD",
|
|
80
|
+
c.MODE_HOLD,
|
|
81
|
+
"Hold the message still, unless it is wider than the sign, in which case "
|
|
82
|
+
"the sign scrolls it repeatedly",
|
|
83
|
+
),
|
|
84
|
+
Token("FLASH", c.MODE_FLASH, "Hold the message still and flash it repeatedly"),
|
|
85
|
+
Token("ROTATE", c.MODE_ROTATE, "Scroll the message across the sign repeatedly"),
|
|
86
|
+
Token("ROLLUP", c.MODE_ROLLUP, "Push the old message up with the new one"),
|
|
87
|
+
Token("ROLLDOWN", c.MODE_ROLLDOWN, "Push the old message down with the new one"),
|
|
88
|
+
Token("ROLLLEFT", c.MODE_ROLLLEFT, "Push the old message left with the new one"),
|
|
89
|
+
Token("ROLLRIGHT", c.MODE_ROLLRIGHT, "Push the old message right with the new one"),
|
|
90
|
+
Token("WIPEUP", c.MODE_WIPEUP, "Wipe the new message up over the old one"),
|
|
91
|
+
Token("WIPEDOWN", c.MODE_WIPEDOWN, "Wipe the new message down over the old one"),
|
|
92
|
+
Token("WIPELEFT", c.MODE_WIPELEFT, "Wipe the new message left over the old one"),
|
|
93
|
+
Token("WIPERIGHT", c.MODE_WIPERIGHT, "Wipe the new message right over the old one"),
|
|
94
|
+
Token("ROLLIN", c.MODE_ROLLIN, "Push the new message inward"),
|
|
95
|
+
Token("ROLLOUT", c.MODE_ROLLOUT, "Push the new message outward"),
|
|
96
|
+
Token("WIPEIN", c.MODE_WIPEIN, "Wipe the new message inward over the old one"),
|
|
97
|
+
Token("WIPEOUT", c.MODE_WIPEOUT, "Wipe the new message outward over the old one"),
|
|
98
|
+
Token("COMPRESSED_ROTATE", c.MODE_CMPRSROT, "Scroll the message at half width"),
|
|
99
|
+
Token("AUTO", c.MODE_AUTO, "Let the sign pick a mode at random"),
|
|
100
|
+
Token("TWINKLE", c.MODE_TWINKLE, "Twinkle the message"),
|
|
101
|
+
Token("SPARKLE", c.MODE_SPARKLE, "Sparkle the new message over the old one"),
|
|
102
|
+
Token("SNOW", c.MODE_SNOW, "Snow the new message onto the sign"),
|
|
103
|
+
Token("INTERLOCK", c.MODE_INTERLOCK, "Interlock the new message over the old one"),
|
|
104
|
+
Token("SWITCH", c.MODE_SWITCH, "Switch the old message off and the new one on, character by character"),
|
|
105
|
+
Token("SLIDE", c.MODE_SLIDE, "Slide characters in one at a time, right to left"),
|
|
106
|
+
Token("SPRAY", c.MODE_SPRAY, "Spray the message on right to left"),
|
|
107
|
+
Token("STARBURST", c.MODE_STARBURST, "Explode the new message onto the sign"),
|
|
108
|
+
Token("WELCOME", c.MODE_WELCOME, "Display a script \"Welcome\""),
|
|
109
|
+
Token("SLOTMACHINE", c.MODE_SLOTMACHINE, "Display slot machine reels"),
|
|
110
|
+
Token("NEWSFLASH", c.MODE_NEWSFLASH, "Display a \"Newsflash\" animation"),
|
|
111
|
+
Token("TRUMPET", c.MODE_TRUMPET, "Display a trumpet animation"),
|
|
112
|
+
Token("THANKYOU", c.MODE_THANKYOU, "Display a script \"Thank You\""),
|
|
113
|
+
Token("NOSMOKING", c.MODE_NOSMOKING, "Display a \"No Smoking\" animation"),
|
|
114
|
+
Token("DRINKDRIVE", c.MODE_DRINKDRIVE, "Display a \"Don't Drink and Drive\" animation"),
|
|
115
|
+
Token("FISH", c.MODE_FISH, "Display swimming fish"),
|
|
116
|
+
Token("FIREWORKS", c.MODE_FIREWORKS, "Display a fireworks animation"),
|
|
117
|
+
Token("BALLOONS", c.MODE_BALLOONS, "Display a balloon animation"),
|
|
118
|
+
Token("CHERRYBOMB", c.MODE_CHERRYBOMB, "Display a cherry bomb animation"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ===========================================================================
|
|
123
|
+
# Control commands, sent on their own rather than as part of a message.
|
|
124
|
+
# ===========================================================================
|
|
125
|
+
|
|
126
|
+
CONTROL_COMMANDS: tuple[Token, ...] = (
|
|
127
|
+
Token(
|
|
128
|
+
"SET_TIME",
|
|
129
|
+
c.CMD_SET_TIME,
|
|
130
|
+
"Set the sign's internal clock. The parameter is the time as HHMM on a 24 hour clock",
|
|
131
|
+
),
|
|
132
|
+
Token(
|
|
133
|
+
"SET_TIME_FORMAT",
|
|
134
|
+
c.CMD_SET_TIME_FORMAT,
|
|
135
|
+
"Set how the sign renders the time. The parameter is 'S' for standard or 'M' for military",
|
|
136
|
+
),
|
|
137
|
+
Token(
|
|
138
|
+
"SET_DAY_OF_WEEK",
|
|
139
|
+
c.CMD_SET_DAY_OF_WEEK,
|
|
140
|
+
"Set the sign's day of the week. The parameter is a single digit, "
|
|
141
|
+
"1 for Sunday through 7 for Saturday",
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ===========================================================================
|
|
147
|
+
# Vertical position of text within a TEXT file.
|
|
148
|
+
# ===========================================================================
|
|
149
|
+
|
|
150
|
+
TEXT_POSITIONS: tuple[Token, ...] = (
|
|
151
|
+
Token("MIDDLE", c.TEXT_POS_MIDDLE, "Centre the text vertically"),
|
|
152
|
+
Token("TOP", c.TEXT_POS_TOP, "Begin the text at the top of the sign"),
|
|
153
|
+
Token("BOTTOM", c.TEXT_POS_BOTTOM, "Place the text immediately below the top"),
|
|
154
|
+
Token("FILL", c.TEXT_POS_FILL, "Centre vertically and use every available line"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
MARKUP_BY_TEXT: dict[str, Token] = {token.text: token for token in MARKUP_TOKENS}
|
|
159
|
+
MODE_BY_NAME: dict[str, Token] = {token.text: token for token in DISPLAY_MODES}
|
|
160
|
+
COMMAND_BY_NAME: dict[str, Token] = {token.text: token for token in CONTROL_COMMANDS}
|
|
161
|
+
POSITION_BY_NAME: dict[str, Token] = {token.text: token for token in TEXT_POSITIONS}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def describe(tokens: tuple[Token, ...], key: str) -> list[dict[str, str]]:
|
|
165
|
+
"""Render a token table as the list of dicts the enumeration endpoints return."""
|
|
166
|
+
return [{key: token.text, "description": token.description} for token in tokens]
|
readerboard/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""What the sign is asked to show: the registry, alerts, and the clock."""
|