readerboard
An HTTP service that drives a BetaBrite Classic sign, either through a serial cable or through an Ethernet to RS-232 adapter.
Several sources can share the sign at once. Each registers a named slot, and the sign rotates through the registered slots by itself. An alert takes the whole display over until it is released, after which the rotation resumes.
What it does
- Many messages, one sign. Home Assistant can own
temperaturewhile a doorbell automation ownsdoorbell, without either knowing about the other. - The sign does the rotating. Each message lives in its own sign file and the sign cycles them on its own, so rotation costs no serial traffic at all.
- Alerts. Take the display over, optionally with a deadline, then hand it back.
- It keeps the sign's clock right, at startup, hourly, and whenever the link comes back. That last trigger is the one that matters: a sign returning from a power cut does so at no particular minute.
- It does not redraw the sign for nothing. A write of bytes the sign already holds is suppressed, so a source re-sending an unchanged temperature does not make the display flicker.
- It survives restarts and outages. The registered messages are persisted, and a write that arrives while the sign is unreachable is accepted and delivered when the link returns.
- Errors are errors. A dead serial link is a 503 and a message the sign cannot render is a 400, each with the reason in the body. Nothing here reports a failure under a 200.
Requirements
- Python 3.11 or newer.
- A BetaBrite Classic, reachable either at a serial device such as
/dev/ttyUSB0or over the network through an Ethernet to RS-232 adapter atsocket://host:port. - Either a machine running systemd, for
scripts/install.sh, or a container runtime, for the published image. The service itself runs anywhere Python does; only the installer is Linux specific.
Try it without a sign
loop:// is pyserial's loopback, so the service will start and serve its API with
nothing attached. From a checkout:
pip install -e ".[dev]"
READERBOARD_SERIAL_URL=loop:// READERBOARD_API_KEY=dev-key \
READERBOARD_STATE_PATH=./state.json \
python -m readerboard
Or without one:
docker run --rm -p 5001:5001 \
-e READERBOARD_SERIAL_URL=loop:// -e READERBOARD_API_KEY=dev-key \
ghcr.io/mjaksn/readerboard:latest
Then open http://127.0.0.1:5001/docs.
loop:// swallows everything written to it, so the service runs but there is
nothing to see. To watch what it would have sent, run it against the sign
simulator in tools/signsim/ instead:
pip install --require-hashes -r tools/signsim/requirements.lock
python scripts/run_with_simulator.py
That starts the simulator and the service together, already pointed at each
other, and stops both on Ctrl+C. The simulator decodes each transmission, says
what every byte of it means, and shows what the sign would be holding as a
result. tools/signsim/README.md has the details.
Installing it properly
Two ways, which do the same job. Pick whichever suits the machine.
With systemd
sudo scripts/install.sh --serial-url socket://192.168.2.51:4001
This creates a readerboard system user, builds a virtual environment in
/opt/readerboard, writes /etc/readerboard/config.toml with a freshly generated API key,
and enables the readerboard service. It prints the key once, and it is safe to run
again after pulling a new version: your config file and key are left alone.
sudo scripts/uninstall.sh removes the service and the program but keeps your config and
your registered messages, so reinstalling puts the sign back as it was. Add --purge to
remove those too.
With Docker
The image is published to both registries on every release, for linux/amd64,
linux/arm64 and linux/arm/v7, so a Pi pulls the same tag an x86 server does.
docker run -d --name readerboard --restart unless-stopped -p 5001:5001 \
-e READERBOARD_SERIAL_URL=socket://192.168.2.51:4001 \
-e READERBOARD_API_KEY=YOUR-KEY \
-v readerboard-state:/var/lib/readerboard \
ghcr.io/mjaksn/readerboard:latest
packaging/docker-compose.yml is the same thing as a Compose file, with the settings
worth knowing about written out beside it.
The volume is what matters here. The registered messages are persisted to
/var/lib/readerboard, and without it the sign comes back empty after a restart rather
than putting back what was on it.
Every setting is available as an environment variable, so no config file is needed. Mount
one at /etc/readerboard/config.toml if you would rather have it, in the format
packaging/config.example.toml documents; the environment still wins over the file.
For a sign on a cable rather than on the network, the container needs the device passed
in and needs to be in the group that owns it. The group has to be given as a number,
because the container has no /etc/group entry for the host's dialout:
stat -c '%G %g' /dev/ttyUSB0 # 20 on Debian and Raspberry Pi OS, 18 on Fedora
docker run ... --device /dev/ttyUSB0 --group-add 20 \
-e READERBOARD_SERIAL_URL=/dev/ttyUSB0 ...
Using it
Every write needs an X-API-Key header. Reads and GET /health do not. In the
Swagger UI at /docs, the Authorize button puts it in once for the whole page.
Register a message:
curl -X PUT http://localhost:5001/messages/temperature \
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
-d '{"message": "<green>18.4<degree> <red><time>", "display_mode": "HOLD"}'
Register a second one and the sign rotates between them:
curl -X PUT http://localhost:5001/messages/doorbell \
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
-d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
Take the sign over for thirty seconds:
curl -X POST http://localhost:5001/alerts \
-H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
-d '{"message": "<red><flash_on>SMOKE ALARM", "ttl_seconds": 30}'
The full API is at /docs. Every markup token, display mode, text position and
control command is listed by the /enumerations reads there, which answer at
request time rather than being frozen into the description.
Writing messages
A message is plain text plus tokens written as <name>: <green>18.4<degree> is a
colour change, a number, and a degree symbol. GET /enumerations/markup-tokens lists
them all.
Text is encoded against the sign's own character table rather than as UTF-8, so café
displays correctly. A character the sign cannot render is rejected with a 400, as is an
unknown token: a write is told what the sign would have made of it rather than being
shown something it did not ask for.
Configuration
Settings come from /etc/readerboard/config.toml, overridden by environment variables
prefixed READERBOARD_. packaging/config.example.toml documents every one of them.
Every setting has both forms, and the container path relies on it: slot_count in the
file is READERBOARD_SLOT_COUNT in the environment. Under Docker the file is optional
and usually absent, which is not an error. READERBOARD_CONFIG_FILE moves the file if
you want it somewhere other than the default.
The sign's address is a full pyserial URL in serial_url: socket://192.168.2.51:4001
for an Ethernet to RS-232 adapter, /dev/ttyUSB0 for a cable plugged straight in, or
loop:// to run the service with no sign attached.
Two settings reallocate the sign's memory when changed, and that erases every message
on it: slot_count and slot_capacity. The service will do it, and say so loudly in
the log, but they are not settings to fiddle with.
Security
An API key is required on every write, compared in constant time, and never logged.
Reads and GET /health need none, so a monitor can watch the sign without holding a key
that could write to it.
The key is declared to the API description as a security scheme, so the Swagger UI at
/docs has an Authorize button: enter the key once and every write on the page
carries it. It is the same X-API-Key header a client sends, so nothing about a script
or a Home Assistant rest_command changes.
That page is configured to remember the key, so it survives a reload or a browser
restart rather than needing to be pasted in again. Convenient on your own machine, and
worth knowing before you use Authorize on a shared or kiosk browser, where the next
person to open /docs inherits it. Use the browser's Logout in the Authorize dialog, or
just do not authorize there.
Message content reaches the sign as protocol bytes, so it is worth knowing what a client holding the key can do. The markup renderer emits bytes only for tokens it recognises and for characters in the sign's own table, so arbitrary control sequences cannot be injected through a message. What the holder of a key can do is display anything they like on your wall and set the sign's clock. There is nothing beyond the sign to reach: the service opens one serial link and touches nothing else.
Sensible precautions remain sensible:
- Do not expose the service to the internet.
- Keep
/etc/readerboard/config.tomlmode 0640. Anyone who can read it can write to the sign. - Give the key only to clients you trust, and prefer a firewall allow-list on top.
- The service runs as a dedicated system user under a hardened systemd unit, which is worth keeping rather than running it as root for convenience.
Under Docker the same points apply, with different mechanisms:
- The image runs as an unprivileged user, UID and GID 10001, not as root. A bind-mounted state directory has to be owned by that number on the host.
- An API key passed as an environment variable is visible to anyone who can run
docker inspecton the container, and to anything that reads the Compose file's environment. Mounting a config file mode 0640 keeps it out of both. - Bind the published port to the loopback address,
-p 127.0.0.1:5001:5001, unless clients on other machines need to reach it. - Passing a serial device in with
--devicegives the container that device and nothing else. It does not need--privileged, and giving it that would hand it every device on the host.
Development
pip install -e ".[dev]"
pytest
ruff check .
mypy readerboard
No sign is needed. The tests run against a capturing fake transport and against
pyserial's loop:// URL, so the real serial code path is exercised without hardware.
docs/protocol-notes.md records what the Alpha protocol actually says about the memory
configuration, the run sequence and the priority file, with the quotations that back each
claim. Read it before changing anything in readerboard/protocol/.
scripts/protocol_spike.py settles the few questions the document cannot answer about
this particular sign. It is destructive and refuses to run without --confirm-erase.
tools/signsim/ is the sign simulator, a PySide6 stand-in for the sign, described
above. tools/apiclient/ is the client, a PySide6 application for calling the API by
hand: every endpoint, responses shown as text rather than JSON, and a vocabulary it
loads from the service rather than one compiled into it. The tests of both are
collected by the pytest run here and need no Qt installed; the applications do, and
each is pinned separately so that nothing the service installs ever pulls Qt in.
scripts/run_with_simulator.py starts the service and the simulator together, and
with --with-client the client as well, so the whole loop comes up from one command.
Both editors carry it as a launch configuration under the same name, "readerboard and
the sign simulator", in .vscode/launch.json and in .idea/runConfigurations/, beside
configurations for running the pieces separately. Both carry the three way one as
"readerboard, the sign simulator and the client" as well.
Licence
MIT. See LICENSE.
Credits
The protocol is documented in the Alpha Sign Communications Protocol, form 9708-8061,
published by Adaptive Micro Systems. Every byte value in
readerboard/protocol/constants.py is transcribed from that document, and
tests/test_constant_values.py pins each one against it with a citation per assertion.
An earlier version of this project took that table from jonathankoren/readerboard, which is recorded here with thanks even though no code from it remains.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file readerboard-0.3.0.tar.gz.
File metadata
- Download URL: readerboard-0.3.0.tar.gz
- Upload date:
- Size: 97.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c191bf6195bc88189ff3de671b88c90f82f2de11f6181a900ad4f3f2171651ab
|
|
| MD5 |
f860bf502e308735b6674c78834a04b8
|
|
| BLAKE2b-256 |
49756d901746cc1434c5ec0cafa6e74a60c2d63d824ad85e5f6ad387e2a6a1f5
|
Provenance
The following attestation bundles were made for readerboard-0.3.0.tar.gz:
Publisher:
release.yml on mjaksn/readerboard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
readerboard-0.3.0.tar.gz -
Subject digest:
c191bf6195bc88189ff3de671b88c90f82f2de11f6181a900ad4f3f2171651ab - Sigstore transparency entry: 2684514674
- Sigstore integration time:
-
Permalink:
mjaksn/readerboard@f4962130e6e77e97fda5e2bf8df3fff721b2126a -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/mjaksn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f4962130e6e77e97fda5e2bf8df3fff721b2126a -
Trigger Event:
push
-
Statement type:
File details
Details for the file readerboard-0.3.0-py3-none-any.whl.
File metadata
- Download URL: readerboard-0.3.0-py3-none-any.whl
- Upload date:
- Size: 67.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05cd34a8dd44b5374e549e71e1587668502e35bf9fd052c3a8fd85de84167885
|
|
| MD5 |
6bb107909d66dfd48afb9a8e30f3ec85
|
|
| BLAKE2b-256 |
05ae8ae63bb08762e3f9e527ad86f21f455700e7dfcd7bdb277fdb272c3cba8a
|
Provenance
The following attestation bundles were made for readerboard-0.3.0-py3-none-any.whl:
Publisher:
release.yml on mjaksn/readerboard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
readerboard-0.3.0-py3-none-any.whl -
Subject digest:
05cd34a8dd44b5374e549e71e1587668502e35bf9fd052c3a8fd85de84167885 - Sigstore transparency entry: 2684514690
- Sigstore integration time:
-
Permalink:
mjaksn/readerboard@f4962130e6e77e97fda5e2bf8df3fff721b2126a -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/mjaksn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f4962130e6e77e97fda5e2bf8df3fff721b2126a -
Trigger Event:
push
-
Statement type: