Metadata-Version: 2.4
Name: readerboard
Version: 0.7.0
Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync
Author: mjaksn
License-Expression: MIT
Project-URL: Homepage, https://github.com/mjaksn/readerboard
Project-URL: Source, https://github.com/mjaksn/readerboard
Project-URL: Issues, https://github.com/mjaksn/readerboard/issues
Project-URL: Changelog, https://github.com/mjaksn/readerboard/blob/main/CHANGELOG.md
Keywords: betabrite,alpha,led sign,readerboard,home assistant,rs-232,signage
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Classifier: Topic :: System :: Hardware
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi<0.142,>=0.141.1
Requires-Dist: uvicorn<0.54,>=0.52.1
Requires-Dist: pydantic<3,>=2.13.4
Requires-Dist: pydantic-settings<3,>=2.15.0
Requires-Dist: pyserial<4,>=3.5
Requires-Dist: tzdata>=2026.3; sys_platform == "win32"
Provides-Extra: dev
Requires-Dist: pytest<10,>=9.1.1; extra == "dev"
Requires-Dist: pytest-asyncio<2,>=1.4.0; extra == "dev"
Requires-Dist: httpx2<3,>=2.10.0; extra == "dev"
Requires-Dist: anyio<4.15,>=4.14.2; extra == "dev"
Requires-Dist: ruff<0.17,>=0.16.2; extra == "dev"
Requires-Dist: mypy<3,>=2.3.0; extra == "dev"
Dynamic: license-file

# readerboard

[![CI](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/ci.yml)
[![Release](https://github.com/mjaksn/readerboard/actions/workflows/release.yml/badge.svg)](https://github.com/mjaksn/readerboard/actions/workflows/release.yml)
[![PyPI](https://img.shields.io/pypi/v/readerboard)](https://pypi.org/project/readerboard/)
[![GHCR](https://img.shields.io/badge/ghcr.io-readerboard-blue)](https://github.com/mjaksn/readerboard/pkgs/container/readerboard)
[![Docker Hub](https://img.shields.io/docker/v/mjaksn/readerboard?label=docker%20hub&sort=semver)](https://hub.docker.com/r/mjaksn/readerboard)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/mjaksn/readerboard/blob/main/LICENSE)

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 slots that are showing by itself. A slot can be hidden without being
given up, so a message can be taken off the display and put back without being sent again,
unless it was the last one showing. A **variable** is a value that messages call by name,
such as a temperature, and changing it does not blank the sign or restart the message
showing it. 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 `temperature` while a doorbell
  automation owns `doorbell`, 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.
- **Live values without a blink.** A message such as `Outside <var:temp><degree>F` calls
  the variable `temp`, and a new value is one small write that the sign shows the next
  time it draws the message, with no blank and no restart. One variable can appear in
  any number of messages, and a value that stops arriving can be made to go stale.
- **Alerts.** Take the display over, optionally with a deadline, then hand it back.
  A caller that must not overwrite somebody else's alert can ask to be refused
  instead.
- **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. The sign is set one minute fast on purpose: the
  protocol has no seconds field, so a sign told the current minute reads behind for the
  rest of it and never ahead, and a minute of lead puts the error on the side that reads
  as a clock being a touch fast rather than most of a minute slow.
- **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
  pushed to the sign again whenever the link returns, so a restart or a power cut leaves
  the rotation intact. A message or a variable write that arrives while the sign is
  unreachable is refused with a 503 rather than silently held, so the caller learns it did
  not land. Deleting or hiding a message, or deleting a variable, is accepted and carried
  out 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/ttyUSB0` or over
  the network through an Ethernet to RS-232 adapter at `socket://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.

## Running it against a real sign

From a checkout, with the sign on a cable or on an Ethernet to RS-232 adapter:

```
pip install -e ".[dev]"
pip install --require-hashes -r tools/apiclient/requirements.lock
python scripts/run_against_a_sign.py --serial-url socket://192.168.2.51:4001
```

That starts the service and the client together, with no simulator. The service
comes up on <http://127.0.0.1:5001> with `/docs` beside it, the client comes up
pointed at that address with the API key already in its box, and the key is
printed in the same window for anything else that needs it. `--no-client` leaves the client out. Ctrl+C stops everything,
and closing the client leaves the service running.

Both editors carry it as a launch configuration named "readerboard against the
real sign and the client". **The sign's address is an argument in those, not a
setting in a file**, so changing which sign is driven means editing the
Parameters field in PyCharm's run configuration dialog, or `args` in
`.vscode/launch.json`. They also pass `--api-port 5002`, so a second checkout of
this repository on the same machine can run beside them; the launcher checks
that port before it starts anything rather than letting the service bind, fail
and stop after the client has been pointed at whatever else answered.

### Writing the address

It is a pyserial URL, and **there is no slash between the host and the port**.
`socket://192.168.2.51/:4001` looks close enough to right and is not: pyserial
answers it with a bare `TypeError` from deep inside a connection attempt, naming
neither the setting nor the value. The launcher checks the address before it
opens anything and says which part is wrong. The four forms are:

```
socket://192.168.2.51:4001   an Ethernet to RS-232 adapter passing raw TCP
rfc2217://192.168.2.51:23    an adapter speaking the telnet serial protocol
COM3                         a cable on Windows
/dev/ttyUSB0                 a cable on Linux
```

Most adapters pass raw TCP, so try `socket://` first. If the link opens but the
sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
probably negotiating telnet rather than passing bytes through, and `rfc2217://`
is the form that speaks that.

### The API key, and config.local.toml

The key is not an argument. A launch configuration is a tracked file and a
command line is a shell history, and anyone holding the key can write to the
sign. It lives in `config.local.toml` at the root of the checkout, which
`.gitignore` covers and which the launcher writes with a generated key the first
time it runs. Given no `--serial-url`, the address is read from there too.

### The first run erases the sign

Writing a memory configuration erases every message on the sign, and the service
writes one whenever it has no record of the configuration already applied. The
first run against a sign this machine has never driven therefore erases it,
which is also the only way to allocate the files it then writes into. Every run
after that reads the record and leaves the sign alone.

That record is `.local-sign-state.json`, and it belongs to this launcher alone.
`scripts/run_with_simulator.py` deletes its own `.local-state.json` on every
launch, because the simulator starts empty every time and the service has to
reconfigure it. If the two shared one file, a simulator session would throw the
sign's record away and the next run against the sign would erase it.

## 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.

The unit restarts the service whenever it stops, and gives up after ten failed starts in
five minutes. Almost nothing here fails permanently, which is what makes the exceptions
worth stopping for: a memory pool too big for the sign fails identically every time, and
each attempt puts a read on the wire that stalls a scrolling message. `systemctl status
readerboard` says which failure it was. Once the configuration is fixed, clearing the
give-up and starting it again are two commands, because `reset-failed` clears the counter
and leaves the unit stopped:

```
sudo systemctl reset-failed readerboard
sudo systemctl start readerboard
```

### 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, and so does `GET /sign/information`,
which asks the sign a question rather than reading the service's own record. The
service's other 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/slots/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/slots/doorbell \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"message": "<amber>Someone at the door", "ttl_seconds": 300}'
```

Take a message off the display without giving up its slot, and put it back later:

```
curl -X PUT http://localhost:5001/slots/doorbell/active \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"active": false}'
```

A hidden message keeps its slot, its place in the order and its text, so showing it again
takes `{"active": true}` and no copy of what it said. Hiding or showing one is a single run
sequence write. That does disturb the display briefly, but far less than rewriting a
message does: enough less that it is easy to miss unless you are watching for it on a
static screen. The exception is the last message showing: its file is emptied as it goes,
because a sign whose run sequence names nothing freezes on what it was drawing, so
putting that one back costs the text as well as the sequence.

`active` is a field on the message endpoint too, where leaving it out is the point: a
source re-sending the same content every few minutes says nothing about it and so cannot
switch back on something that was deliberately hidden. Sending it moves the message.

A `ttl_seconds` can hide a message instead of deleting it, which suits anything that comes
back later, such as a bin day or a school notice:

```
curl -X PUT http://localhost:5001/slots/bins \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"message": "<green>BINS OUT TONIGHT", "ttl_seconds": 43200,
          "delete_on_expiry": false}'
```

Put those together and a recurring notification is one call per event, sent in the same
shape every time. This shows the alarm's state for a minute whenever it changes, then takes
it off the rotation until the next one:

```
curl -X PUT http://localhost:5001/slots/alarm \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"message": "<red>ALARM NOW <var:arm_state>", "ttl_seconds": 60,
          "delete_on_expiry": false, "active": true}'
```

The deadline clears itself as it hides the message, so each event gets a fresh minute
rather than the message vanishing again on a deadline the last one left behind.

Show a live value. Create the variable first, then a message that calls it:

```
curl -X PUT http://localhost:5001/variables/temp \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"value": "72", "ttl_seconds": 1800, "stale_value": "--"}'

curl -X PUT http://localhost:5001/slots/weather \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"message": "Outside <var:temp><degree>F", "display_mode": "ROTATE"}'
```

From then on only the variable needs writing, and the message picks each new value up
on its next pass across the sign.

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}'
```

Make a noise, which is worth pairing with an alert if the sign is somewhere nobody
is watching it:

```
curl -X POST http://localhost:5001/sign/command \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"command": "SOUND", "parameter": "BEEPS"}'
```

`BEEPS` is three short beeps and `TONE` is one continuous tone of about two seconds.
Those are the only two sounds there are: the sign has a fixed-pitch buzzer, so there
is no pitch or volume to choose.

Silence it with `{"command": "SPEAKER", "parameter": "OFF"}`, and turn it back on with
`ON`. That is a real mute: `SOUND` is still accepted and makes no noise. The setting
lives on the sign and survives a restart, so it is also the first thing to check if
`SOUND` ever seems to do nothing.

The full API is at `/docs`. Every markup token, value token, display mode 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.

### Live values: variables

A variable lives in a small file of its own on the sign, and a message or an alert
calls it with `<var:name>`. Writing a new value rewrites that file and nothing else,
which the sign takes without blanking, so a message showing a temperature or a count can
change every minute and never restart. In a scrolling mode the new value appears on the
message's next pass. An alert carrying a live wind speed works the same way, and keeps
the sign while the number changes.

A few things are worth knowing, all of them measured on the sign:

- **Create the variable before a message calls it.** A message or an alert naming a
  variable that does not exist is refused with a 400, and a variable that a message or
  the alert still calls cannot be deleted: that is a 409 naming what calls it.
- **Formatting in a value carries on after it.** A value of `<red>DOWN` turns the rest
  of the message red as well, and so does a character set or a speed. If the text after
  the call matters, set it again in the message after the call.
- **A value takes the message markup, less two tokens.** `<week_day>` draws as a literal
  `9` from inside a variable, and a variable cannot call another. `GET
  /enumerations/value-tokens` lists what is allowed.
- **Keep a changing number from pushing the text around it.** With the sign's usual
  proportional spacing, `11` and `88` are different widths and the text after them moves.
  Put `<fixed_width>` in the message before the call and send values of the same length.
  Fixed width also left-justifies the line.
- **A value that stops arriving can go stale.** Give a `ttl_seconds` and a `stale_value`
  such as `--`, and once the time passes without a new value the sign shows the stale
  value instead. The variable itself stays, since messages call it.
- **A value that does not fit is refused.** Each holds `variable_capacity` bytes after
  rendering, 32 by default. The sign does not cut an overlong value short, it empties it,
  so the service refuses one rather than send it.

A Home Assistant `rest_command` that keeps a sensor on the sign:

```yaml
rest_command:
  sign_temperature:
    url: http://readerboard.local:5001/variables/temp
    method: put
    headers:
      X-API-Key: !secret readerboard_key
    content_type: application/json
    payload: '{"value": "{{ value }}", "ttl_seconds": 1800, "stale_value": "--"}'
```

Call it from an automation triggered by the sensor, with the reading as `value`:

```yaml
actions:
  - action: rest_command.sign_temperature
    data:
      value: "{{ states('sensor.outside_temperature') | round(0) | int }}"
```

### Icons

A message can draw one of 148 built-in bitmaps where a tag sits. `<icon:sun> FINE`
puts a sun in front of the word, and `<icon:lock:red>` retints an icon that is drawn
in a single ink; the colour words are the colour tokens' own, down to `dimred` and
`dimgreen`, so a message that can say `<red>` needs no second spelling. An icon
drawn in its own colours, such as the sun, takes no tint and asking for one is
refused rather than ignored. `GET /enumerations/icons` lists every icon with its
group, its width and whether it takes a tint.

Icons are off until `picture_count` is set, because each one on the sign needs a
picture file of its own and allocating those reallocates the sign's memory, which
erases every message on it. Set it once, alongside the other pool settings, and
16 is a comfortable number.

Four things are worth knowing, and the first is the one that decides how to use them:

- **An icon is not a live value.** Every write to a picture file blanks the display
  and restarts a scrolling message. A weather slot stepping from sun to cloud to rain
  pays that each time it lands on an icon the sign is not already holding. Something
  that changes every minute belongs in a variable, which costs no blank at all.
- **The pool is smaller than the library, and that is the design.** A picture file is
  claimed by whichever icon a message calls, and kept after its last caller goes, so a
  source alternating between two icons costs nothing after the first write. When every
  file is holding an icon something still calls and a new one is asked for, the write
  is refused with a 409. `GET /health` reports pictures used against pictures total,
  and a full pool is the resting state rather than a warning.
- **A tint makes a second picture.** `<icon:check:green>` and `<icon:check:red>` are
  two bitmaps and take two files.
- **An icon can be parted from its word.** A line too wide for the display breaks onto
  a second page in HOLD, and the last word can arrive there without the icon labelling
  it. The service cannot warn about this: it would have to know the width of the sign's
  proportional font.

### Recovering a sign that has stopped responding

A sign mounted out of reach can wedge: a stray bit corrupts what its decoder is
showing, it stops responding to writes, and there is no power switch within reach.
There are two recoveries, and they are not interchangeable. Try the gentle one first.

**A soft reset restarts the sign and erases nothing.** The sign runs the same power-up
diagnostics it runs when you plug it in, then carries on showing what it was showing.
Its memory, its file table and its messages all survive; this was verified on the sign
by reading them back either side of a reset.

```
curl -X POST http://localhost:5001/sign/command \
     -H 'X-API-Key: YOUR-KEY' -H 'Content-Type: application/json' \
     -d '{"command": "SOFT_RESET"}'
```

The call waits out the diagnostics before answering, so a 204 means the sign is
listening again rather than that the bytes went out.

**`POST /sign/reboot` is the escalation, and it is destructive.** It clears the sign
outright, waits for it to restart, then re-pushes every message and the run sequence
from the service's own record, so the display still comes back to what it was.

```
curl -X POST http://localhost:5001/sign/reboot -H 'X-API-Key: YOUR-KEY'
```

Reach for it only when a soft reset was not enough. The sign is blank for twelve seconds
or more while it resets, longer with a lot of messages to put back. Neither is a way to
clear messages: `DELETE /slots` does that without resetting anything. The client
fronts the reboot with a warning-coloured confirmation for the same reason.

## 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, `rfc2217://192.168.2.51:23` for one speaking the
telnet serial protocol, `/dev/ttyUSB0` or `COM3` for a cable plugged straight in, or
`loop://` to run the service with no sign attached. There is no slash between the host
and the port, and pyserial's answer to one that has a slash names neither the setting
nor the value.

Five settings reallocate the sign's memory when changed, and **that erases every message
on it**: `slot_count`, `slot_capacity`, `variable_count`, `variable_capacity` and
`picture_count`. The service will do it, and say so loudly in the log, but they are not
settings to fiddle with. With `variable_count` at 0, `variable_capacity` allocates
nothing, so changing it alone reallocates nothing either. `picture_count` starts at 0,
which switches icons off, so turning them on is one deliberate erase.

All five come out of one memory pool, which a BetaBrite Classic reported as 5482 bytes,
and each file costs thirteen bytes beyond its own size. The defaults take 2518 of that,
and each icon takes 69 on top, so the defaults with sixteen icons take 3622.

That 5482 is a ceiling, and it is checked in two places that do different jobs. A
configuration bigger than it is refused when the settings are read, on any machine,
whether or not a sign is attached; that is the ceiling, and no sign can raise it. Then,
on a start that is about to reallocate the sign's memory and only then, the service asks
the sign for its own figure, and a sign reporting less than 5482 is believed. So the
second check can lower the limit and never raise it. A sign with a bigger pool than this
hardware's would need `ASSUMED_SIGN_MEMORY_POOL` in `readerboard/sign/pool.py` raised
before it could use the extra.

A sign that answers and does not have the room stops the service starting, with a message
naming what was configured, what it needs and what there is; that is the one failure that
does stop it, because the alternative is erasing every message on the sign to write a pool
that could never work. A sign that says nothing does not stop anything. `POST /sign/reboot`
asks the same question before it clears the sign, and answers 409 rather than erasing it,
except when the sign is too wedged to answer, which is the case that endpoint exists for.

## Security

An API key is required on every write, compared in constant time, and never logged.
`GET /sign/information` needs one too: it is a read of the sign itself rather than of the
service, so it sends a question over the wire, holds the sign until the answer arrives,
and reports the hardware's firmware and how full its memory is. The service's own reads
and `GET /health` need none, so a monitor can watch the slots without holding a key that
could write to them.

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 everything on the page that
needs it 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.toml` mode 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 inspect` on 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 `--device` gives 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, the client among them as
"readerboard client". Both carry the three way one as "readerboard, the sign simulator
and the client" as well.

`scripts/run_against_a_sign.py` is the other one, for when the sign is real: the
service and the client, no simulator, and the sign's address passed as an argument so
that it can be edited in a run configuration dialog. Both editors carry it as
"readerboard against the real sign and the client". The section above has the rest,
including the one thing about it that is dangerous. The two launchers share their
process supervision through `scripts/_supervise.py` and differ in what each child is
given, which is the part that matters: the simulator launcher discards its state file
on every run and this one never discards anything.

Every one of those that starts the service sets `READERBOARD_OPEN_DOCS`, so `/docs`
opens in a browser once the port answers. The service does the waiting and the
opening, which is why it lands on the port actually bound rather than one repeated in
a launch file. The setting is off unless asked for, so an installed service opens
nothing.

## Licence

MIT. See [LICENSE](https://github.com/mjaksn/readerboard/blob/main/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](https://github.com/jonathankoren/readerboard), which is
recorded here with thanks even though no code from it remains.
