readerboard 0.1.2__tar.gz → 0.1.4__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.2/readerboard.egg-info → readerboard-0.1.4}/PKG-INFO +73 -6
  2. {readerboard-0.1.2 → readerboard-0.1.4}/README.md +71 -4
  3. {readerboard-0.1.2 → readerboard-0.1.4}/pyproject.toml +11 -2
  4. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/__init__.py +1 -1
  5. {readerboard-0.1.2 → readerboard-0.1.4/readerboard.egg-info}/PKG-INFO +73 -6
  6. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard.egg-info/requires.txt +1 -1
  7. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_api.py +10 -3
  8. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_markup.py +2 -2
  9. {readerboard-0.1.2 → readerboard-0.1.4}/LICENSE +0 -0
  10. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/__main__.py +0 -0
  11. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/api/__init__.py +0 -0
  12. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/api/app.py +0 -0
  13. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/api/deps.py +0 -0
  14. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/api/models.py +0 -0
  15. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/api/routes_simple.py +0 -0
  16. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/api/routes_v2.py +0 -0
  17. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/config.py +0 -0
  18. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/logging_setup.py +0 -0
  19. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/protocol/__init__.py +0 -0
  20. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/protocol/constants.py +0 -0
  21. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/protocol/frames.py +0 -0
  22. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/protocol/markup.py +0 -0
  23. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/protocol/tokens.py +0 -0
  24. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/py.typed +0 -0
  25. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/services/__init__.py +0 -0
  26. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/services/alerts.py +0 -0
  27. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/services/clock.py +0 -0
  28. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/services/commands.py +0 -0
  29. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/services/registry.py +0 -0
  30. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/sign/__init__.py +0 -0
  31. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/sign/controller.py +0 -0
  32. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/sign/layout.py +0 -0
  33. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/sign/state.py +0 -0
  34. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/transport/__init__.py +0 -0
  35. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/transport/base.py +0 -0
  36. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/transport/fake.py +0 -0
  37. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard/transport/serial_link.py +0 -0
  38. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard.egg-info/SOURCES.txt +0 -0
  39. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard.egg-info/dependency_links.txt +0 -0
  40. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard.egg-info/entry_points.txt +0 -0
  41. {readerboard-0.1.2 → readerboard-0.1.4}/readerboard.egg-info/top_level.txt +0 -0
  42. {readerboard-0.1.2 → readerboard-0.1.4}/setup.cfg +0 -0
  43. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_alerts.py +0 -0
  44. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_clock.py +0 -0
  45. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_constant_values.py +0 -0
  46. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_controller.py +0 -0
  47. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_frames.py +0 -0
  48. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_registry.py +0 -0
  49. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_state.py +0 -0
  50. {readerboard-0.1.2 → readerboard-0.1.4}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync
5
5
  Author: mjaksn
6
6
  License-Expression: MIT
@@ -27,7 +27,7 @@ Requires-Python: >=3.11
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE
29
29
  Requires-Dist: fastapi<0.142,>=0.141.1
30
- Requires-Dist: uvicorn[standard]<0.53,>=0.52.1
30
+ Requires-Dist: uvicorn<0.53,>=0.52.1
31
31
  Requires-Dist: pydantic<3,>=2.13.4
32
32
  Requires-Dist: pydantic-settings<3,>=2.15.0
33
33
  Requires-Dist: pyserial<4,>=3.5
@@ -45,6 +45,8 @@ Dynamic: license-file
45
45
  [![CI](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml)
46
46
  [![Release](https://github.com/mjaksn/readerboard/actions/workflows/release.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/release.yml)
47
47
  [![PyPI](https://img.shields.io/pypi/v/readerboard)](https://pypi.org/project/readerboard/)
48
+ [![GHCR](https://img.shields.io/badge/ghcr.io-readerboard-blue)](https://github.com/mjaksn/readerboard/pkgs/container/readerboard)
49
+ [![Docker Hub](https://img.shields.io/docker/v/mjaksn/readerboard?label=docker%20hub&sort=semver)](https://hub.docker.com/r/mjaksn/readerboard)
48
50
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/mjaksn/readerboard/blob/main/LICENSE)
49
51
 
50
52
  An HTTP service that drives a BetaBrite Classic sign, either through a serial cable or
@@ -78,13 +80,14 @@ until it is released, after which the rotation resumes.
78
80
  - Python 3.11 or newer.
79
81
  - A BetaBrite Classic, reachable either at a serial device such as `/dev/ttyUSB0` or over
80
82
  the network through an Ethernet to RS-232 adapter at `socket://host:port`.
81
- - For the installer, a machine running systemd. The service itself runs anywhere Python
82
- does; only `scripts/install.sh` is Linux specific.
83
+ - Either a machine running systemd, for `scripts/install.sh`, or a container runtime, for
84
+ the published image. The service itself runs anywhere Python does; only the installer
85
+ is Linux specific.
83
86
 
84
87
  ## Try it without a sign
85
88
 
86
89
  `loop://` is pyserial's loopback, so the service will start and serve its API with
87
- nothing attached.
90
+ nothing attached. From a checkout:
88
91
 
89
92
  ```
90
93
  pip install -e ".[dev]"
@@ -93,10 +96,22 @@ READERBOARD_SERIAL_URL=loop:// READERBOARD_API_KEY=dev-key \
93
96
  python -m readerboard
94
97
  ```
95
98
 
99
+ Or without one:
100
+
101
+ ```
102
+ docker run --rm -p 5001:5001 \
103
+ -e READERBOARD_SERIAL_URL=loop:// -e READERBOARD_API_KEY=dev-key \
104
+ ghcr.io/mjaksn/readerboard:latest
105
+ ```
106
+
96
107
  Then open <http://127.0.0.1:5001/docs>.
97
108
 
98
109
  ## Installing it properly
99
110
 
111
+ Two ways, which do the same job. Pick whichever suits the machine.
112
+
113
+ ### With systemd
114
+
100
115
  ```
101
116
  sudo scripts/install.sh --serial-url socket://192.168.2.51:4001
102
117
  ```
@@ -110,6 +125,40 @@ again after pulling a new version: your config file and key are left alone.
110
125
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
111
126
  remove those too.
112
127
 
128
+ ### With Docker
129
+
130
+ The image is published to both registries on every release, for `linux/amd64`,
131
+ `linux/arm64` and `linux/arm/v7`, so a Pi pulls the same tag an x86 server does.
132
+
133
+ ```
134
+ docker run -d --name readerboard --restart unless-stopped -p 5001:5001 \
135
+ -e READERBOARD_SERIAL_URL=socket://192.168.2.51:4001 \
136
+ -e READERBOARD_API_KEY=YOUR-KEY \
137
+ -v readerboard-state:/var/lib/readerboard \
138
+ ghcr.io/mjaksn/readerboard:latest
139
+ ```
140
+
141
+ `packaging/docker-compose.yml` is the same thing as a Compose file, with the settings
142
+ worth knowing about written out beside it.
143
+
144
+ The volume is what matters here. The registered messages are persisted to
145
+ `/var/lib/readerboard`, and without it the sign comes back empty after a restart rather
146
+ than putting back what was on it.
147
+
148
+ Every setting is available as an environment variable, so no config file is needed. Mount
149
+ one at `/etc/readerboard/config.toml` if you would rather have it, in the format
150
+ `packaging/config.example.toml` documents; the environment still wins over the file.
151
+
152
+ For a sign on a cable rather than on the network, the container needs the device passed
153
+ in and needs to be in the group that owns it. The group has to be given as a number,
154
+ because the container has no `/etc/group` entry for the host's `dialout`:
155
+
156
+ ```
157
+ stat -c '%G %g' /dev/ttyUSB0 # 20 on Debian and Raspberry Pi OS, 18 on Fedora
158
+ docker run ... --device /dev/ttyUSB0 --group-add 20 \
159
+ -e READERBOARD_SERIAL_URL=/dev/ttyUSB0 ...
160
+ ```
161
+
113
162
  ## Using it
114
163
 
115
164
  Every write needs an `X-API-Key` header. `GET /health` does not.
@@ -177,6 +226,11 @@ registered, and it shares the sign the moment anything else registers.
177
226
  Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
178
227
  prefixed `READERBOARD_`. `packaging/config.example.toml` documents every one of them.
179
228
 
229
+ Every setting has both forms, and the container path relies on it: `slot_count` in the
230
+ file is `READERBOARD_SLOT_COUNT` in the environment. Under Docker the file is optional
231
+ and usually absent, which is not an error. `READERBOARD_CONFIG_FILE` moves the file if
232
+ you want it somewhere other than the default.
233
+
180
234
  The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
181
235
  for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
182
236
  `loop://` to run the service with no sign attached.
@@ -205,6 +259,19 @@ Sensible precautions remain sensible:
205
259
  - The service runs as a dedicated system user under a hardened systemd unit, which is
206
260
  worth keeping rather than running it as root for convenience.
207
261
 
262
+ Under Docker the same points apply, with different mechanisms:
263
+
264
+ - The image runs as an unprivileged user, UID and GID 10001, not as root. A bind-mounted
265
+ state directory has to be owned by that number on the host.
266
+ - An API key passed as an environment variable is visible to anyone who can run
267
+ `docker inspect` on the container, and to anything that reads the Compose file's
268
+ environment. Mounting a config file mode 0640 keeps it out of both.
269
+ - Bind the published port to the loopback address, `-p 127.0.0.1:5001:5001`, unless
270
+ clients on other machines need to reach it.
271
+ - Passing a serial device in with `--device` gives the container that device and nothing
272
+ else. It does not need `--privileged`, and giving it that would hand it every device on
273
+ the host.
274
+
208
275
  ## Development
209
276
 
210
277
  ```
@@ -226,7 +293,7 @@ this particular sign. It is destructive and refuses to run without `--confirm-er
226
293
 
227
294
  ## Licence
228
295
 
229
- MIT. See [LICENSE.md](LICENSE.md).
296
+ MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
230
297
 
231
298
  One caveat, recorded because it is easy to miss.
232
299
  `readerboard/protocol/constants.py` is vendored from
@@ -3,6 +3,8 @@
3
3
  [![CI](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml)
4
4
  [![Release](https://github.com/mjaksn/readerboard/actions/workflows/release.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/release.yml)
5
5
  [![PyPI](https://img.shields.io/pypi/v/readerboard)](https://pypi.org/project/readerboard/)
6
+ [![GHCR](https://img.shields.io/badge/ghcr.io-readerboard-blue)](https://github.com/mjaksn/readerboard/pkgs/container/readerboard)
7
+ [![Docker Hub](https://img.shields.io/docker/v/mjaksn/readerboard?label=docker%20hub&sort=semver)](https://hub.docker.com/r/mjaksn/readerboard)
6
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/mjaksn/readerboard/blob/main/LICENSE)
7
9
 
8
10
  An HTTP service that drives a BetaBrite Classic sign, either through a serial cable or
@@ -36,13 +38,14 @@ until it is released, after which the rotation resumes.
36
38
  - Python 3.11 or newer.
37
39
  - A BetaBrite Classic, reachable either at a serial device such as `/dev/ttyUSB0` or over
38
40
  the network through an Ethernet to RS-232 adapter at `socket://host:port`.
39
- - For the installer, a machine running systemd. The service itself runs anywhere Python
40
- does; only `scripts/install.sh` is Linux specific.
41
+ - Either a machine running systemd, for `scripts/install.sh`, or a container runtime, for
42
+ the published image. The service itself runs anywhere Python does; only the installer
43
+ is Linux specific.
41
44
 
42
45
  ## Try it without a sign
43
46
 
44
47
  `loop://` is pyserial's loopback, so the service will start and serve its API with
45
- nothing attached.
48
+ nothing attached. From a checkout:
46
49
 
47
50
  ```
48
51
  pip install -e ".[dev]"
@@ -51,10 +54,22 @@ READERBOARD_SERIAL_URL=loop:// READERBOARD_API_KEY=dev-key \
51
54
  python -m readerboard
52
55
  ```
53
56
 
57
+ Or without one:
58
+
59
+ ```
60
+ docker run --rm -p 5001:5001 \
61
+ -e READERBOARD_SERIAL_URL=loop:// -e READERBOARD_API_KEY=dev-key \
62
+ ghcr.io/mjaksn/readerboard:latest
63
+ ```
64
+
54
65
  Then open <http://127.0.0.1:5001/docs>.
55
66
 
56
67
  ## Installing it properly
57
68
 
69
+ Two ways, which do the same job. Pick whichever suits the machine.
70
+
71
+ ### With systemd
72
+
58
73
  ```
59
74
  sudo scripts/install.sh --serial-url socket://192.168.2.51:4001
60
75
  ```
@@ -68,6 +83,40 @@ again after pulling a new version: your config file and key are left alone.
68
83
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
69
84
  remove those too.
70
85
 
86
+ ### With Docker
87
+
88
+ The image is published to both registries on every release, for `linux/amd64`,
89
+ `linux/arm64` and `linux/arm/v7`, so a Pi pulls the same tag an x86 server does.
90
+
91
+ ```
92
+ docker run -d --name readerboard --restart unless-stopped -p 5001:5001 \
93
+ -e READERBOARD_SERIAL_URL=socket://192.168.2.51:4001 \
94
+ -e READERBOARD_API_KEY=YOUR-KEY \
95
+ -v readerboard-state:/var/lib/readerboard \
96
+ ghcr.io/mjaksn/readerboard:latest
97
+ ```
98
+
99
+ `packaging/docker-compose.yml` is the same thing as a Compose file, with the settings
100
+ worth knowing about written out beside it.
101
+
102
+ The volume is what matters here. The registered messages are persisted to
103
+ `/var/lib/readerboard`, and without it the sign comes back empty after a restart rather
104
+ than putting back what was on it.
105
+
106
+ Every setting is available as an environment variable, so no config file is needed. Mount
107
+ one at `/etc/readerboard/config.toml` if you would rather have it, in the format
108
+ `packaging/config.example.toml` documents; the environment still wins over the file.
109
+
110
+ For a sign on a cable rather than on the network, the container needs the device passed
111
+ in and needs to be in the group that owns it. The group has to be given as a number,
112
+ because the container has no `/etc/group` entry for the host's `dialout`:
113
+
114
+ ```
115
+ stat -c '%G %g' /dev/ttyUSB0 # 20 on Debian and Raspberry Pi OS, 18 on Fedora
116
+ docker run ... --device /dev/ttyUSB0 --group-add 20 \
117
+ -e READERBOARD_SERIAL_URL=/dev/ttyUSB0 ...
118
+ ```
119
+
71
120
  ## Using it
72
121
 
73
122
  Every write needs an `X-API-Key` header. `GET /health` does not.
@@ -135,6 +184,11 @@ registered, and it shares the sign the moment anything else registers.
135
184
  Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
136
185
  prefixed `READERBOARD_`. `packaging/config.example.toml` documents every one of them.
137
186
 
187
+ Every setting has both forms, and the container path relies on it: `slot_count` in the
188
+ file is `READERBOARD_SLOT_COUNT` in the environment. Under Docker the file is optional
189
+ and usually absent, which is not an error. `READERBOARD_CONFIG_FILE` moves the file if
190
+ you want it somewhere other than the default.
191
+
138
192
  The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
139
193
  for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
140
194
  `loop://` to run the service with no sign attached.
@@ -163,6 +217,19 @@ Sensible precautions remain sensible:
163
217
  - The service runs as a dedicated system user under a hardened systemd unit, which is
164
218
  worth keeping rather than running it as root for convenience.
165
219
 
220
+ Under Docker the same points apply, with different mechanisms:
221
+
222
+ - The image runs as an unprivileged user, UID and GID 10001, not as root. A bind-mounted
223
+ state directory has to be owned by that number on the host.
224
+ - An API key passed as an environment variable is visible to anyone who can run
225
+ `docker inspect` on the container, and to anything that reads the Compose file's
226
+ environment. Mounting a config file mode 0640 keeps it out of both.
227
+ - Bind the published port to the loopback address, `-p 127.0.0.1:5001:5001`, unless
228
+ clients on other machines need to reach it.
229
+ - Passing a serial device in with `--device` gives the container that device and nothing
230
+ else. It does not need `--privileged`, and giving it that would hand it every device on
231
+ the host.
232
+
166
233
  ## Development
167
234
 
168
235
  ```
@@ -184,7 +251,7 @@ this particular sign. It is destructive and refuses to run without `--confirm-er
184
251
 
185
252
  ## Licence
186
253
 
187
- MIT. See [LICENSE.md](LICENSE.md).
254
+ MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
188
255
 
189
256
  One caveat, recorded because it is easy to miss.
190
257
  `readerboard/protocol/constants.py` is vendored from
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readerboard"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -39,7 +39,16 @@ classifiers = [
39
39
  # is allowed to live.
40
40
  dependencies = [
41
41
  "fastapi>=0.141.1,<0.142",
42
- "uvicorn[standard]>=0.52.1,<0.53",
42
+ # Plain uvicorn, not the "standard" extra. That extra exists to make a busy
43
+ # server faster, and nothing here is busy: this one answers a handful of
44
+ # requests and then waits on a 9600 baud serial line behind a deliberate
45
+ # inter-packet delay, so a faster event loop and HTTP parser buy nothing
46
+ # measurable. What they cost is real, though. uvloop, httptools and PyYAML
47
+ # publish no 32-bit arm wheel, and building all three from source under
48
+ # emulation was eight minutes of the container image's build. The extra also
49
+ # brought in websockets, for a surface this API does not have, and watchfiles,
50
+ # for a --reload this never runs with.
51
+ "uvicorn>=0.52.1,<0.53",
43
52
  "pydantic>=2.13.4,<3",
44
53
  "pydantic-settings>=2.15.0,<3",
45
54
  "pyserial>=3.5,<4",
@@ -7,4 +7,4 @@ and against the release tag, before anything is published. See
7
7
 
8
8
  __all__ = ["__version__"]
9
9
 
10
- __version__ = "0.1.2"
10
+ __version__ = "0.1.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, scheduling and clock sync
5
5
  Author: mjaksn
6
6
  License-Expression: MIT
@@ -27,7 +27,7 @@ Requires-Python: >=3.11
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE
29
29
  Requires-Dist: fastapi<0.142,>=0.141.1
30
- Requires-Dist: uvicorn[standard]<0.53,>=0.52.1
30
+ Requires-Dist: uvicorn<0.53,>=0.52.1
31
31
  Requires-Dist: pydantic<3,>=2.13.4
32
32
  Requires-Dist: pydantic-settings<3,>=2.15.0
33
33
  Requires-Dist: pyserial<4,>=3.5
@@ -45,6 +45,8 @@ Dynamic: license-file
45
45
  [![CI](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml)
46
46
  [![Release](https://github.com/mjaksn/readerboard/actions/workflows/release.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/release.yml)
47
47
  [![PyPI](https://img.shields.io/pypi/v/readerboard)](https://pypi.org/project/readerboard/)
48
+ [![GHCR](https://img.shields.io/badge/ghcr.io-readerboard-blue)](https://github.com/mjaksn/readerboard/pkgs/container/readerboard)
49
+ [![Docker Hub](https://img.shields.io/docker/v/mjaksn/readerboard?label=docker%20hub&sort=semver)](https://hub.docker.com/r/mjaksn/readerboard)
48
50
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/mjaksn/readerboard/blob/main/LICENSE)
49
51
 
50
52
  An HTTP service that drives a BetaBrite Classic sign, either through a serial cable or
@@ -78,13 +80,14 @@ until it is released, after which the rotation resumes.
78
80
  - Python 3.11 or newer.
79
81
  - A BetaBrite Classic, reachable either at a serial device such as `/dev/ttyUSB0` or over
80
82
  the network through an Ethernet to RS-232 adapter at `socket://host:port`.
81
- - For the installer, a machine running systemd. The service itself runs anywhere Python
82
- does; only `scripts/install.sh` is Linux specific.
83
+ - Either a machine running systemd, for `scripts/install.sh`, or a container runtime, for
84
+ the published image. The service itself runs anywhere Python does; only the installer
85
+ is Linux specific.
83
86
 
84
87
  ## Try it without a sign
85
88
 
86
89
  `loop://` is pyserial's loopback, so the service will start and serve its API with
87
- nothing attached.
90
+ nothing attached. From a checkout:
88
91
 
89
92
  ```
90
93
  pip install -e ".[dev]"
@@ -93,10 +96,22 @@ READERBOARD_SERIAL_URL=loop:// READERBOARD_API_KEY=dev-key \
93
96
  python -m readerboard
94
97
  ```
95
98
 
99
+ Or without one:
100
+
101
+ ```
102
+ docker run --rm -p 5001:5001 \
103
+ -e READERBOARD_SERIAL_URL=loop:// -e READERBOARD_API_KEY=dev-key \
104
+ ghcr.io/mjaksn/readerboard:latest
105
+ ```
106
+
96
107
  Then open <http://127.0.0.1:5001/docs>.
97
108
 
98
109
  ## Installing it properly
99
110
 
111
+ Two ways, which do the same job. Pick whichever suits the machine.
112
+
113
+ ### With systemd
114
+
100
115
  ```
101
116
  sudo scripts/install.sh --serial-url socket://192.168.2.51:4001
102
117
  ```
@@ -110,6 +125,40 @@ again after pulling a new version: your config file and key are left alone.
110
125
  your registered messages, so reinstalling puts the sign back as it was. Add `--purge` to
111
126
  remove those too.
112
127
 
128
+ ### With Docker
129
+
130
+ The image is published to both registries on every release, for `linux/amd64`,
131
+ `linux/arm64` and `linux/arm/v7`, so a Pi pulls the same tag an x86 server does.
132
+
133
+ ```
134
+ docker run -d --name readerboard --restart unless-stopped -p 5001:5001 \
135
+ -e READERBOARD_SERIAL_URL=socket://192.168.2.51:4001 \
136
+ -e READERBOARD_API_KEY=YOUR-KEY \
137
+ -v readerboard-state:/var/lib/readerboard \
138
+ ghcr.io/mjaksn/readerboard:latest
139
+ ```
140
+
141
+ `packaging/docker-compose.yml` is the same thing as a Compose file, with the settings
142
+ worth knowing about written out beside it.
143
+
144
+ The volume is what matters here. The registered messages are persisted to
145
+ `/var/lib/readerboard`, and without it the sign comes back empty after a restart rather
146
+ than putting back what was on it.
147
+
148
+ Every setting is available as an environment variable, so no config file is needed. Mount
149
+ one at `/etc/readerboard/config.toml` if you would rather have it, in the format
150
+ `packaging/config.example.toml` documents; the environment still wins over the file.
151
+
152
+ For a sign on a cable rather than on the network, the container needs the device passed
153
+ in and needs to be in the group that owns it. The group has to be given as a number,
154
+ because the container has no `/etc/group` entry for the host's `dialout`:
155
+
156
+ ```
157
+ stat -c '%G %g' /dev/ttyUSB0 # 20 on Debian and Raspberry Pi OS, 18 on Fedora
158
+ docker run ... --device /dev/ttyUSB0 --group-add 20 \
159
+ -e READERBOARD_SERIAL_URL=/dev/ttyUSB0 ...
160
+ ```
161
+
113
162
  ## Using it
114
163
 
115
164
  Every write needs an `X-API-Key` header. `GET /health` does not.
@@ -177,6 +226,11 @@ registered, and it shares the sign the moment anything else registers.
177
226
  Settings come from `/etc/readerboard/config.toml`, overridden by environment variables
178
227
  prefixed `READERBOARD_`. `packaging/config.example.toml` documents every one of them.
179
228
 
229
+ Every setting has both forms, and the container path relies on it: `slot_count` in the
230
+ file is `READERBOARD_SLOT_COUNT` in the environment. Under Docker the file is optional
231
+ and usually absent, which is not an error. `READERBOARD_CONFIG_FILE` moves the file if
232
+ you want it somewhere other than the default.
233
+
180
234
  The sign's address is a full pyserial URL in `serial_url`: `socket://192.168.2.51:4001`
181
235
  for an Ethernet to RS-232 adapter, `/dev/ttyUSB0` for a cable plugged straight in, or
182
236
  `loop://` to run the service with no sign attached.
@@ -205,6 +259,19 @@ Sensible precautions remain sensible:
205
259
  - The service runs as a dedicated system user under a hardened systemd unit, which is
206
260
  worth keeping rather than running it as root for convenience.
207
261
 
262
+ Under Docker the same points apply, with different mechanisms:
263
+
264
+ - The image runs as an unprivileged user, UID and GID 10001, not as root. A bind-mounted
265
+ state directory has to be owned by that number on the host.
266
+ - An API key passed as an environment variable is visible to anyone who can run
267
+ `docker inspect` on the container, and to anything that reads the Compose file's
268
+ environment. Mounting a config file mode 0640 keeps it out of both.
269
+ - Bind the published port to the loopback address, `-p 127.0.0.1:5001:5001`, unless
270
+ clients on other machines need to reach it.
271
+ - Passing a serial device in with `--device` gives the container that device and nothing
272
+ else. It does not need `--privileged`, and giving it that would hand it every device on
273
+ the host.
274
+
208
275
  ## Development
209
276
 
210
277
  ```
@@ -226,7 +293,7 @@ this particular sign. It is destructive and refuses to run without `--confirm-er
226
293
 
227
294
  ## Licence
228
295
 
229
- MIT. See [LICENSE.md](LICENSE.md).
296
+ MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/LICENSE).
230
297
 
231
298
  One caveat, recorded because it is easy to miss.
232
299
  `readerboard/protocol/constants.py` is vendored from
@@ -1,5 +1,5 @@
1
1
  fastapi<0.142,>=0.141.1
2
- uvicorn[standard]<0.53,>=0.52.1
2
+ uvicorn<0.53,>=0.52.1
3
3
  pydantic<3,>=2.13.4
4
4
  pydantic-settings<3,>=2.15.0
5
5
  pyserial<4,>=3.5
@@ -260,10 +260,17 @@ class TestUnreachableSign:
260
260
 
261
261
 
262
262
  class TestTheSimpleEndpoints:
263
- """The exact payloads the example integrations in this repository send."""
263
+ """The payload shapes these endpoints accept, written out in full.
264
+
265
+ They exist for callers that post a fixed body to a fixed path, so the
266
+ bodies below are spelled out rather than built. A change that breaks one
267
+ of them breaks somebody's configuration file, which is exactly what these
268
+ tests are here to notice.
269
+ """
264
270
 
265
271
  def test_the_home_assistant_rest_command_payload(self, client):
266
- # Home_Assistant_Sign_REST_Commands.yaml, with the template filled in.
272
+ # A temperature and the time, the common shape: a value from somewhere
273
+ # else, then <time> for the sign to fill in on its own.
267
274
  response = client.post(
268
275
  "/Write/Message",
269
276
  json={
@@ -280,7 +287,7 @@ class TestTheSimpleEndpoints:
280
287
  }
281
288
 
282
289
  def test_the_cron_line_payload(self, client):
283
- # BetaBrite_Sign_Cron_Set_Time.txt sends SET_TIME with HHMM.
290
+ # SET_TIME takes the time as HHMM on a 24 hour clock.
284
291
  response = client.post(
285
292
  "/Write/ControlCommand",
286
293
  json={"command": "SET_TIME", "parameter": "2359"},
@@ -25,8 +25,8 @@ def test_every_token_in_the_table_renders():
25
25
 
26
26
 
27
27
  def test_the_home_assistant_payload_renders():
28
- # The exact shape Home_Assistant_Sign_REST_Commands.yaml sends, with the
29
- # template already filled in.
28
+ # A temperature and the time, the common shape: a value from somewhere
29
+ # else, then <time> for the sign to fill in on its own.
30
30
  rendered = render("<green>18.4<degree> <red><time>")
31
31
  assert rendered == (
32
32
  c.TEXT_COLOR_GREEN + b"18.4" + c.XC_DEGREES + b" " + c.TEXT_COLOR_RED + c.CURTIME_INSERT
File without changes
File without changes