readerboard 0.1.1__tar.gz

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.
Files changed (50) hide show
  1. readerboard-0.1.1/LICENSE +21 -0
  2. readerboard-0.1.1/PKG-INFO +248 -0
  3. readerboard-0.1.1/README.md +206 -0
  4. readerboard-0.1.1/pyproject.toml +152 -0
  5. readerboard-0.1.1/readerboard/__init__.py +10 -0
  6. readerboard-0.1.1/readerboard/__main__.py +57 -0
  7. readerboard-0.1.1/readerboard/api/__init__.py +1 -0
  8. readerboard-0.1.1/readerboard/api/app.py +262 -0
  9. readerboard-0.1.1/readerboard/api/deps.py +93 -0
  10. readerboard-0.1.1/readerboard/api/models.py +266 -0
  11. readerboard-0.1.1/readerboard/api/routes_simple.py +143 -0
  12. readerboard-0.1.1/readerboard/api/routes_v2.py +207 -0
  13. readerboard-0.1.1/readerboard/config.py +222 -0
  14. readerboard-0.1.1/readerboard/logging_setup.py +53 -0
  15. readerboard-0.1.1/readerboard/protocol/__init__.py +1 -0
  16. readerboard-0.1.1/readerboard/protocol/constants.py +465 -0
  17. readerboard-0.1.1/readerboard/protocol/frames.py +250 -0
  18. readerboard-0.1.1/readerboard/protocol/markup.py +173 -0
  19. readerboard-0.1.1/readerboard/protocol/tokens.py +166 -0
  20. readerboard-0.1.1/readerboard/py.typed +0 -0
  21. readerboard-0.1.1/readerboard/services/__init__.py +1 -0
  22. readerboard-0.1.1/readerboard/services/alerts.py +183 -0
  23. readerboard-0.1.1/readerboard/services/clock.py +111 -0
  24. readerboard-0.1.1/readerboard/services/commands.py +83 -0
  25. readerboard-0.1.1/readerboard/services/registry.py +402 -0
  26. readerboard-0.1.1/readerboard/sign/__init__.py +1 -0
  27. readerboard-0.1.1/readerboard/sign/controller.py +278 -0
  28. readerboard-0.1.1/readerboard/sign/layout.py +112 -0
  29. readerboard-0.1.1/readerboard/sign/state.py +171 -0
  30. readerboard-0.1.1/readerboard/transport/__init__.py +1 -0
  31. readerboard-0.1.1/readerboard/transport/base.py +53 -0
  32. readerboard-0.1.1/readerboard/transport/fake.py +84 -0
  33. readerboard-0.1.1/readerboard/transport/serial_link.py +159 -0
  34. readerboard-0.1.1/readerboard.egg-info/PKG-INFO +248 -0
  35. readerboard-0.1.1/readerboard.egg-info/SOURCES.txt +48 -0
  36. readerboard-0.1.1/readerboard.egg-info/dependency_links.txt +1 -0
  37. readerboard-0.1.1/readerboard.egg-info/entry_points.txt +2 -0
  38. readerboard-0.1.1/readerboard.egg-info/requires.txt +15 -0
  39. readerboard-0.1.1/readerboard.egg-info/top_level.txt +1 -0
  40. readerboard-0.1.1/setup.cfg +4 -0
  41. readerboard-0.1.1/tests/test_alerts.py +162 -0
  42. readerboard-0.1.1/tests/test_api.py +427 -0
  43. readerboard-0.1.1/tests/test_clock.py +160 -0
  44. readerboard-0.1.1/tests/test_constant_values.py +682 -0
  45. readerboard-0.1.1/tests/test_controller.py +258 -0
  46. readerboard-0.1.1/tests/test_frames.py +181 -0
  47. readerboard-0.1.1/tests/test_markup.py +101 -0
  48. readerboard-0.1.1/tests/test_registry.py +407 -0
  49. readerboard-0.1.1/tests/test_state.py +171 -0
  50. readerboard-0.1.1/tests/test_transport.py +161 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Matthew Jackson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: readerboard
3
+ Version: 0.1.1
4
+ Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync
5
+ Author: mjaksn
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mjaksn/readerboard
8
+ Project-URL: Source, https://github.com/mjaksn/readerboard
9
+ Project-URL: Issues, https://github.com/mjaksn/readerboard/issues
10
+ Project-URL: Changelog, https://github.com/mjaksn/readerboard/blob/main/CHANGELOG.md
11
+ Keywords: betabrite,alpha,led sign,readerboard,home assistant,rs-232,signage
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: End Users/Desktop
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Home Automation
24
+ Classifier: Topic :: System :: Hardware
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: fastapi<0.142,>=0.141.1
30
+ Requires-Dist: uvicorn[standard]<0.53,>=0.52.1
31
+ Requires-Dist: pydantic<3,>=2.13.4
32
+ Requires-Dist: pydantic-settings<3,>=2.15.0
33
+ Requires-Dist: pyserial<4,>=3.5
34
+ Requires-Dist: tzdata>=2026.3; sys_platform == "win32"
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest<10,>=9.1.1; extra == "dev"
37
+ Requires-Dist: pytest-asyncio<2,>=1.4.0; extra == "dev"
38
+ Requires-Dist: httpx2<3,>=2.10.0; extra == "dev"
39
+ Requires-Dist: ruff<0.17,>=0.16.2; extra == "dev"
40
+ Requires-Dist: mypy<3,>=2.3.0; extra == "dev"
41
+ Dynamic: license-file
42
+
43
+ # readerboard
44
+
45
+ [![CI](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml)
46
+ [![PyPI](https://img.shields.io/pypi/v/readerboard)](https://pypi.org/project/readerboard/)
47
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mjaksn/readerboard/blob/main/LICENSE)
48
+
49
+ An HTTP service that drives a BetaBrite Classic sign, either through a serial cable or
50
+ through an Ethernet to RS-232 adapter.
51
+
52
+ Several sources can share the sign at once. Each registers a named **slot**, and the sign
53
+ rotates through the registered slots by itself. An **alert** takes the whole display over
54
+ until it is released, after which the rotation resumes.
55
+
56
+ ## What it does
57
+
58
+ - **Many messages, one sign.** Home Assistant can own `temperature` while a doorbell
59
+ automation owns `doorbell`, without either knowing about the other.
60
+ - **The sign does the rotating.** Each message lives in its own sign file and the sign
61
+ cycles them on its own, so rotation costs no serial traffic at all.
62
+ - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
63
+ - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
64
+ back. That last trigger is the one that matters: a sign returning from a power cut
65
+ does so at no particular minute.
66
+ - **It does not redraw the sign for nothing.** A write of bytes the sign already holds is
67
+ suppressed, so a source re-sending an unchanged temperature does not make the display
68
+ flicker.
69
+ - **It survives restarts and outages.** The registered messages are persisted, and a
70
+ write that arrives while the sign is unreachable is accepted and delivered when the
71
+ link returns.
72
+ - **Errors are errors.** A dead serial link is a 503, not an HTTP 200 with the word
73
+ ERROR in the body.
74
+
75
+ ## Requirements
76
+
77
+ - Python 3.11 or newer.
78
+ - A BetaBrite Classic, reachable either at a serial device such as `/dev/ttyUSB0` or over
79
+ the network through an Ethernet to RS-232 adapter at `socket://host:port`.
80
+ - For the installer, a machine running systemd. The service itself runs anywhere Python
81
+ does; only `scripts/install.sh` is Linux specific.
82
+
83
+ ## Try it without a sign
84
+
85
+ `loop://` is pyserial's loopback, so the service will start and serve its API with
86
+ nothing attached.
87
+
88
+ ```
89
+ pip install -e ".[dev]"
90
+ READERBOARD_SERIAL_URL=loop:// READERBOARD_API_KEY=dev-key \
91
+ READERBOARD_STATE_PATH=./state.json \
92
+ python -m readerboard
93
+ ```
94
+
95
+ Then open <http://127.0.0.1:5001/docs>.
96
+
97
+ ## Installing it properly
98
+
99
+ ```
100
+ sudo scripts/install.sh --serial-url socket://192.168.2.51:4001
101
+ ```
102
+
103
+ This creates a `readerboard` system user, builds a virtual environment in
104
+ `/opt/readerboard`, writes `/etc/readerboard/config.toml` with a freshly generated API key,
105
+ and enables the `readerboard` service. It prints the key once, and it is safe to run
106
+ again after pulling a new version: your config file and key are left alone.
107
+
108
+ `sudo scripts/uninstall.sh` removes the service and the program but keeps your config and
109
+ your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
110
+ remove those too.
111
+
112
+ ## Using it
113
+
114
+ Every write needs an `X-API-Key` header. `GET /health` does not.
115
+
116
+ Register a message:
117
+
118
+ ```
119
+ curl -X PUT http://localhost:5001/v2/messages/temperature \
120
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
121
+ -d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
122
+ ```
123
+
124
+ Register a second one and the sign rotates between them:
125
+
126
+ ```
127
+ curl -X PUT http://localhost:5001/v2/messages/doorbell \
128
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
129
+ -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
130
+ ```
131
+
132
+ Take the sign over for thirty seconds:
133
+
134
+ ```
135
+ curl -X POST http://localhost:5001/v2/alerts \
136
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
137
+ -d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
138
+ ```
139
+
140
+ The full API, including every markup token and display mode, is at `/docs`.
141
+
142
+ ### Writing messages
143
+
144
+ A message is plain text plus tokens written as `<name>`: `<green>18.4<degree>` is a
145
+ colour change, a number, and a degree symbol. `GET /v2/enumerations/markup-tokens` lists
146
+ them all.
147
+
148
+ Text is encoded against the sign's own character table rather than as UTF-8, so `café`
149
+ displays correctly. A character the sign cannot render is rejected with a 400 on `/v2`,
150
+ and replaced with `?` on the simpler endpoints described below.
151
+
152
+ ## A simpler set of endpoints
153
+
154
+ Alongside `/v2` there is a smaller surface: `POST /Write/Message`,
155
+ `POST /Write/ControlCommand`, and the `/Enumerations` reads.
156
+
157
+ These follow one convention that `/v2` does not. **Every response is HTTP 200**, with the
158
+ outcome in the body:
159
+
160
+ ```json
161
+ {"result": "OK", "result_message": "Message displayed on sign"}
162
+ ```
163
+
164
+ That suits a client which finds branching on status codes awkward, such as a Home
165
+ Assistant `rest_command` or a shell one-liner in a cron job. The single exception is a
166
+ missing or wrong API key, which is a 401: a caller the service will not talk to is not the
167
+ same as a request that failed.
168
+
169
+ `POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
170
+ not touch the sign's **priority** file, which by protocol suppresses every other message on
171
+ the sign. Written to an ordinary slot it looks identical while it is the only message
172
+ registered, and it shares the sign the moment anything else registers.
173
+
174
+ ## Configuration
175
+
176
+ Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
177
+ prefixed `READERBOARD_`. `packaging/config.example.toml` documents every one of them.
178
+
179
+ The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
180
+ for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
181
+ `loop://` to run the service with no sign attached.
182
+
183
+ Two settings reallocate the sign's memory when changed, and **that erases every message
184
+ on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
185
+ the log, but they are not settings to fiddle with.
186
+
187
+ ## Security
188
+
189
+ An API key is required on every write, compared in constant time, and never logged.
190
+
191
+ **Message content reaches the sign as protocol bytes**, so it is worth knowing what a
192
+ client holding the key can do. The markup renderer emits bytes only for tokens it
193
+ recognises and for characters in the sign's own table, so arbitrary control sequences
194
+ cannot be injected through a message. What the holder of a key can do is display
195
+ anything they like on your wall and set the sign's clock. There is nothing beyond the
196
+ sign to reach: the service opens one serial link and touches nothing else.
197
+
198
+ Sensible precautions remain sensible:
199
+
200
+ - Do not expose the service to the internet.
201
+ - Keep `/etc/readerboard/config.toml` mode 0640. Anyone who can read it can write to the
202
+ sign.
203
+ - Give the key only to clients you trust, and prefer a firewall allow-list on top.
204
+ - The service runs as a dedicated system user under a hardened systemd unit, which is
205
+ worth keeping rather than running it as root for convenience.
206
+
207
+ ## Development
208
+
209
+ ```
210
+ pip install -e ".[dev]"
211
+ pytest
212
+ ruff check .
213
+ mypy readerboard
214
+ ```
215
+
216
+ No sign is needed. The tests run against a capturing fake transport and against
217
+ pyserial's `loop://` URL, so the real serial code path is exercised without hardware.
218
+
219
+ `docs/protocol-notes.md` records what the Alpha protocol actually says about the memory
220
+ configuration, the run sequence and the priority file, with the quotations that back each
221
+ claim. Read it before changing anything in `readerboard/protocol/`.
222
+
223
+ `scripts/protocol_spike.py` settles the few questions the document cannot answer about
224
+ this particular sign. It is destructive and refuses to run without `--confirm-erase`.
225
+
226
+ ## Licence
227
+
228
+ MIT. See [LICENSE.md](LICENSE.md).
229
+
230
+ One caveat, recorded because it is easy to miss.
231
+ `readerboard/protocol/constants.py` is vendored from
232
+ [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), and that
233
+ repository carries no license file. No license is not the same as a permissive one: it
234
+ means no copying permission has been granted at all. That module is therefore the one
235
+ part of this project whose provenance is not cleanly MIT.
236
+
237
+ In practice it is a table of byte values dictated by the protocol rather than authored
238
+ expression, and `docs/protocol-notes.md` now cites the protocol document directly, so the
239
+ table can be regenerated from the primary source if that ever needs settling properly.
240
+
241
+ ## Credits
242
+
243
+ `readerboard/protocol/constants.py` came, with thanks, from
244
+ [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), with some
245
+ corrections noted in the file.
246
+
247
+ The protocol itself is documented in the Alpha Sign Communications Protocol, form
248
+ 9708-8061, published by Adaptive Micro Systems.
@@ -0,0 +1,206 @@
1
+ # readerboard
2
+
3
+ [![CI](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/readerboard)](https://pypi.org/project/readerboard/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mjaksn/readerboard/blob/main/LICENSE)
6
+
7
+ An HTTP service that drives a BetaBrite Classic sign, either through a serial cable or
8
+ through an Ethernet to RS-232 adapter.
9
+
10
+ Several sources can share the sign at once. Each registers a named **slot**, and the sign
11
+ rotates through the registered slots by itself. An **alert** takes the whole display over
12
+ until it is released, after which the rotation resumes.
13
+
14
+ ## What it does
15
+
16
+ - **Many messages, one sign.** Home Assistant can own `temperature` while a doorbell
17
+ automation owns `doorbell`, without either knowing about the other.
18
+ - **The sign does the rotating.** Each message lives in its own sign file and the sign
19
+ cycles them on its own, so rotation costs no serial traffic at all.
20
+ - **Alerts.** Take the display over, optionally with a deadline, then hand it back.
21
+ - **It keeps the sign's clock right**, at startup, hourly, and whenever the link comes
22
+ back. That last trigger is the one that matters: a sign returning from a power cut
23
+ does so at no particular minute.
24
+ - **It does not redraw the sign for nothing.** A write of bytes the sign already holds is
25
+ suppressed, so a source re-sending an unchanged temperature does not make the display
26
+ flicker.
27
+ - **It survives restarts and outages.** The registered messages are persisted, and a
28
+ write that arrives while the sign is unreachable is accepted and delivered when the
29
+ link returns.
30
+ - **Errors are errors.** A dead serial link is a 503, not an HTTP 200 with the word
31
+ ERROR in the body.
32
+
33
+ ## Requirements
34
+
35
+ - Python 3.11 or newer.
36
+ - A BetaBrite Classic, reachable either at a serial device such as `/dev/ttyUSB0` or over
37
+ the network through an Ethernet to RS-232 adapter at `socket://host:port`.
38
+ - For the installer, a machine running systemd. The service itself runs anywhere Python
39
+ does; only `scripts/install.sh` is Linux specific.
40
+
41
+ ## Try it without a sign
42
+
43
+ `loop://` is pyserial's loopback, so the service will start and serve its API with
44
+ nothing attached.
45
+
46
+ ```
47
+ pip install -e ".[dev]"
48
+ READERBOARD_SERIAL_URL=loop:// READERBOARD_API_KEY=dev-key \
49
+ READERBOARD_STATE_PATH=./state.json \
50
+ python -m readerboard
51
+ ```
52
+
53
+ Then open <http://127.0.0.1:5001/docs>.
54
+
55
+ ## Installing it properly
56
+
57
+ ```
58
+ sudo scripts/install.sh --serial-url socket://192.168.2.51:4001
59
+ ```
60
+
61
+ This creates a `readerboard` system user, builds a virtual environment in
62
+ `/opt/readerboard`, writes `/etc/readerboard/config.toml` with a freshly generated API key,
63
+ and enables the `readerboard` service. It prints the key once, and it is safe to run
64
+ again after pulling a new version: your config file and key are left alone.
65
+
66
+ `sudo scripts/uninstall.sh` removes the service and the program but keeps your config and
67
+ your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
68
+ remove those too.
69
+
70
+ ## Using it
71
+
72
+ Every write needs an `X-API-Key` header. `GET /health` does not.
73
+
74
+ Register a message:
75
+
76
+ ```
77
+ curl -X PUT http://localhost:5001/v2/messages/temperature \
78
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
79
+ -d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
80
+ ```
81
+
82
+ Register a second one and the sign rotates between them:
83
+
84
+ ```
85
+ curl -X PUT http://localhost:5001/v2/messages/doorbell \
86
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
87
+ -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
88
+ ```
89
+
90
+ Take the sign over for thirty seconds:
91
+
92
+ ```
93
+ curl -X POST http://localhost:5001/v2/alerts \
94
+ -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
95
+ -d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
96
+ ```
97
+
98
+ The full API, including every markup token and display mode, is at `/docs`.
99
+
100
+ ### Writing messages
101
+
102
+ A message is plain text plus tokens written as `<name>`: `<green>18.4<degree>` is a
103
+ colour change, a number, and a degree symbol. `GET /v2/enumerations/markup-tokens` lists
104
+ them all.
105
+
106
+ Text is encoded against the sign's own character table rather than as UTF-8, so `café`
107
+ displays correctly. A character the sign cannot render is rejected with a 400 on `/v2`,
108
+ and replaced with `?` on the simpler endpoints described below.
109
+
110
+ ## A simpler set of endpoints
111
+
112
+ Alongside `/v2` there is a smaller surface: `POST /Write/Message`,
113
+ `POST /Write/ControlCommand`, and the `/Enumerations` reads.
114
+
115
+ These follow one convention that `/v2` does not. **Every response is HTTP 200**, with the
116
+ outcome in the body:
117
+
118
+ ```json
119
+ {"result": "OK", "result_message": "Message displayed on sign"}
120
+ ```
121
+
122
+ That suits a client which finds branching on status codes awkward, such as a Home
123
+ Assistant `rest_command` or a shell one-liner in a cron job. The single exception is a
124
+ missing or wrong API key, which is a 401: a caller the service will not talk to is not the
125
+ same as a request that failed.
126
+
127
+ `POST /Write/Message` writes to one reserved slot, named `default`. It deliberately does
128
+ not touch the sign's **priority** file, which by protocol suppresses every other message on
129
+ the sign. Written to an ordinary slot it looks identical while it is the only message
130
+ registered, and it shares the sign the moment anything else registers.
131
+
132
+ ## Configuration
133
+
134
+ Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
135
+ prefixed `READERBOARD_`. `packaging/config.example.toml` documents every one of them.
136
+
137
+ The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
138
+ for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
139
+ `loop://` to run the service with no sign attached.
140
+
141
+ Two settings reallocate the sign's memory when changed, and **that erases every message
142
+ on it**: `slot_count` and `slot_capacity`. The service will do it, and say so loudly in
143
+ the log, but they are not settings to fiddle with.
144
+
145
+ ## Security
146
+
147
+ An API key is required on every write, compared in constant time, and never logged.
148
+
149
+ **Message content reaches the sign as protocol bytes**, so it is worth knowing what a
150
+ client holding the key can do. The markup renderer emits bytes only for tokens it
151
+ recognises and for characters in the sign's own table, so arbitrary control sequences
152
+ cannot be injected through a message. What the holder of a key can do is display
153
+ anything they like on your wall and set the sign's clock. There is nothing beyond the
154
+ sign to reach: the service opens one serial link and touches nothing else.
155
+
156
+ Sensible precautions remain sensible:
157
+
158
+ - Do not expose the service to the internet.
159
+ - Keep `/etc/readerboard/config.toml` mode 0640. Anyone who can read it can write to the
160
+ sign.
161
+ - Give the key only to clients you trust, and prefer a firewall allow-list on top.
162
+ - The service runs as a dedicated system user under a hardened systemd unit, which is
163
+ worth keeping rather than running it as root for convenience.
164
+
165
+ ## Development
166
+
167
+ ```
168
+ pip install -e ".[dev]"
169
+ pytest
170
+ ruff check .
171
+ mypy readerboard
172
+ ```
173
+
174
+ No sign is needed. The tests run against a capturing fake transport and against
175
+ pyserial's `loop://` URL, so the real serial code path is exercised without hardware.
176
+
177
+ `docs/protocol-notes.md` records what the Alpha protocol actually says about the memory
178
+ configuration, the run sequence and the priority file, with the quotations that back each
179
+ claim. Read it before changing anything in `readerboard/protocol/`.
180
+
181
+ `scripts/protocol_spike.py` settles the few questions the document cannot answer about
182
+ this particular sign. It is destructive and refuses to run without `--confirm-erase`.
183
+
184
+ ## Licence
185
+
186
+ MIT. See [LICENSE.md](LICENSE.md).
187
+
188
+ One caveat, recorded because it is easy to miss.
189
+ `readerboard/protocol/constants.py` is vendored from
190
+ [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), and that
191
+ repository carries no license file. No license is not the same as a permissive one: it
192
+ means no copying permission has been granted at all. That module is therefore the one
193
+ part of this project whose provenance is not cleanly MIT.
194
+
195
+ In practice it is a table of byte values dictated by the protocol rather than authored
196
+ expression, and `docs/protocol-notes.md` now cites the protocol document directly, so the
197
+ table can be regenerated from the primary source if that ever needs settling properly.
198
+
199
+ ## Credits
200
+
201
+ `readerboard/protocol/constants.py` came, with thanks, from
202
+ [jonathankoren/readerboard](https://github.com/jonathankoren/readerboard), with some
203
+ corrections noted in the file.
204
+
205
+ The protocol itself is documented in the Alpha Sign Communications Protocol, form
206
+ 9708-8061, published by Adaptive Micro Systems.
@@ -0,0 +1,152 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"] # PEP 639 licence expressions
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "readerboard"
7
+ version = "0.1.1"
8
+ description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "mjaksn" }]
14
+ keywords = ["betabrite", "alpha", "led sign", "readerboard", "home assistant", "rs-232", "signage"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Web Environment",
18
+ "Framework :: FastAPI",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: End Users/Desktop",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Home Automation",
28
+ "Topic :: System :: Hardware",
29
+ "Typing :: Typed",
30
+ ]
31
+
32
+ # Floors and ceilings rather than exact pins. This is something people install
33
+ # beside other things, and `==` would make it a package that has to be
34
+ # uninstalled before anything else can move. requirements.lock carries the exact
35
+ # versions, and that is what the installer feeds the machine driving the sign.
36
+ #
37
+ # The ceilings sit at the next major for the settled packages, and at the next
38
+ # minor for the two still on 0.x, where a minor bump is where a breaking change
39
+ # is allowed to live.
40
+ dependencies = [
41
+ "fastapi>=0.141.1,<0.142",
42
+ "uvicorn[standard]>=0.52.1,<0.53",
43
+ "pydantic>=2.13.4,<3",
44
+ "pydantic-settings>=2.15.0,<3",
45
+ "pyserial>=3.5,<4",
46
+ # Linux ships a system time zone database and zoneinfo reads it directly.
47
+ # Windows ships none, so the `timezone` setting would fail there without it.
48
+ "tzdata>=2026.3; sys_platform == 'win32'",
49
+ ]
50
+
51
+ [project.optional-dependencies]
52
+ dev = [
53
+ "pytest>=9.1.1,<10",
54
+ "pytest-asyncio>=1.4.0,<2",
55
+ # starlette's TestClient moved to httpx2; plain httpx now raises a
56
+ # deprecation warning, which this suite is configured to treat as an error.
57
+ "httpx2>=2.10.0,<3",
58
+ "ruff>=0.16.2,<0.17",
59
+ "mypy>=2.3.0,<3",
60
+ ]
61
+
62
+ [project.scripts]
63
+ # The whole command line. `python -m readerboard` runs the same thing out of a
64
+ # checkout, which is what the systemd unit and the editor launch config use.
65
+ readerboard = "readerboard.__main__:main"
66
+
67
+ [project.urls]
68
+ Homepage = "https://github.com/mjaksn/readerboard"
69
+ Source = "https://github.com/mjaksn/readerboard"
70
+ Issues = "https://github.com/mjaksn/readerboard/issues"
71
+ Changelog = "https://github.com/mjaksn/readerboard/blob/main/CHANGELOG.md"
72
+
73
+ [tool.setuptools]
74
+ # Named explicitly rather than auto-discovered: the flat layout puts `tests`
75
+ # beside the package and it has an __init__.py, so discovery would ship it.
76
+ packages = [
77
+ "readerboard",
78
+ "readerboard.api",
79
+ "readerboard.protocol",
80
+ "readerboard.services",
81
+ "readerboard.sign",
82
+ "readerboard.transport",
83
+ ]
84
+
85
+ [tool.setuptools.package-data]
86
+ # Without this the marker is left out of the wheel and the annotations are
87
+ # invisible to anyone type checking against the installed package.
88
+ readerboard = ["py.typed"]
89
+
90
+ [tool.pytest.ini_options]
91
+ testpaths = ["tests"]
92
+ addopts = "-q --strict-markers --strict-config"
93
+ asyncio_mode = "auto"
94
+ asyncio_default_fixture_loop_scope = "function"
95
+ filterwarnings = ["error"]
96
+
97
+ [tool.ruff]
98
+ line-length = 110
99
+ target-version = "py311"
100
+
101
+ [tool.ruff.lint]
102
+ # Stricter than the other projects here, and deliberately so. This one is a
103
+ # service rather than a command: it runs unattended for months, and the prose it
104
+ # emits is part of what it delivers, so the docstring rules are carrying weight
105
+ # rather than decorating.
106
+ select = [
107
+ "E", # pycodestyle
108
+ "W", # pycodestyle warnings
109
+ "F", # pyflakes
110
+ "I", # import sorting
111
+ "B", # bugbear
112
+ "UP", # pyupgrade
113
+ "SIM", # simplify
114
+ "RUF", # ruff's own rules
115
+ "D", # docstrings
116
+ ]
117
+ ignore = [
118
+ "D203", # incompatible with D211, which is the one we want
119
+ "D213", # incompatible with D212, which is the one we want
120
+ # This project formats with %, matching the lazy % that logging takes, so
121
+ # that one idiom covers both a log line and the exception text next to it.
122
+ "UP031",
123
+ ]
124
+
125
+ [tool.ruff.lint.per-file-ignores]
126
+ # The vendored constants table is a wall of assignments with trailing comments,
127
+ # and rewriting it into docstring-bearing prose would only obscure its origin.
128
+ "readerboard/protocol/constants.py" = ["E501", "RUF001", "RUF003"]
129
+ # A test's name is its documentation, and a docstring repeating it would be
130
+ # worse than none. Tests that need explaining have one anyway.
131
+ "tests/*" = ["D100", "D101", "D102", "D103", "D105", "D107"]
132
+
133
+ [tool.ruff.lint.pydocstyle]
134
+ convention = "pep257"
135
+
136
+ [tool.mypy]
137
+ python_version = "3.11"
138
+ files = ["readerboard"]
139
+ strict = true
140
+ warn_unreachable = true
141
+ enable_error_code = ["redundant-expr", "truthy-bool", "ignore-without-code"]
142
+
143
+ [[tool.mypy.overrides]]
144
+ # pyserial ships no type information.
145
+ module = ["serial", "serial.*"]
146
+ ignore_missing_imports = true
147
+
148
+ [[tool.mypy.overrides]]
149
+ # The vendored constants module is a flat table of byte literals; annotating
150
+ # every one of them would add nothing a reader does not already see.
151
+ module = "readerboard.protocol.constants"
152
+ disallow_untyped_defs = false
@@ -0,0 +1,10 @@
1
+ """An HTTP service that drives a BetaBrite Classic sign over the Alpha protocol.
2
+
3
+ The version here and the one in pyproject.toml are checked against each other,
4
+ and against the release tag, before anything is published. See
5
+ .github/workflows/release.yml.
6
+ """
7
+
8
+ __all__ = ["__version__"]
9
+
10
+ __version__ = "0.1.1"