Add LAUNCH.md: startup sequence and why it fails without a reader

development is x86_64 with no RFID reader attached, so the reader path exits 1
by design. That looks like breakage when it is not, so document it.

- The expected output on this host, including why /dev/ttyS0 gives
  'Input/output error' (node exists, hardware does not answer) rather than
  'device not found' - the app distinguishes the two.
- The 5-stage startup as a diagram, with the code and exit code for each stage,
  and why stages 1-2 fail fast on purpose.
- How to exercise the reader path with no hardware: the PTY tests, plus a
  standalone snippet that writes a real RDM6300 frame. Both the snippet and the
  documented outputs were run verbatim, not written from memory.
- The pipe trap: head closes the pipe early and the shell then reports
  128+SIGPIPE, so redirect to a file when checking the real exit code.
This commit is contained in:
2026-10-01 15:55:16 +03:00
parent 23cd8f2df5
commit 674d91a5ca
2 changed files with 286 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
# LAUNCH.md — starting the app, and why it fails on this host
## TL;DR
**This host (`development`, x86_64) has no card reader attached. Failure is
expected and correct.** To see the app work without hardware, use `--demo`:
```bash
.venv/bin/python -m rdm_card_reader --demo
```
To see the reader path run end-to-end without hardware, the test suite drives a
pseudo-terminal pretending to be an RDM6300:
```bash
.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
```
---
## Quick start
All commands run from the project root. The virtualenv is already built.
```bash
.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
.venv/bin/python -m rdm_card_reader --list-ports # what serial ports exist
.venv/bin/python -m rdm_card_reader # real reader (fails here)
.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
```
After `pip install .` the same thing is available as `rdm-card-reader …`.
---
## What happens on this host, and why
### `--list-ports` works fine
```console
$ .venv/bin/python -m rdm_card_reader --list-ports --verbose-ports
Candidate serial ports (tried in this order):
OK /dev/ttyS0
--- /dev/ttyAMA0
--- /dev/ttyUSB0
--- /dev/ttyACM0
Identified serial ports (best candidate first):
[score 5] /dev/ttyS0 :: n/a
by-id : (none)
by-path : (none)
location : (unknown)
usb id : (not USB)
driver : (unknown)
```
Two things to read from this:
* `OK /dev/ttyS0` — the device node **exists**, but existence is not proof of a
working reader. This container has a UART node with no hardware behind it.
* `score 5` and `(not USB)` — nothing here looks like a USB-serial adapter.
A real reader scores 60–100 and shows a chip name (`CH340`, `FT232R`, …).
### Starting the reader fails
```console
$ .venv/bin/python -m rdm_card_reader
-> Opening /dev/ttyS0 ...
-> Opening /dev/ttyAMA0 ...
-> Opening /dev/ttyUSB0 ...
-> Opening /dev/ttyACM0 ...
x /dev/ttyS0: Could not configure port: (5, 'Input/output error')
x /dev/ttyAMA0: device not found
x /dev/ttyUSB0: device not found
x /dev/ttyACM0: device not found
x Could not open any serial port. Check:
1. the reader is connected (rdm-card-reader --list-ports)
2. UART is enabled: sudo raspi-config -> Interface Options -> Serial
3. the user is in dialout: sudo usermod -aG dialout $USER
```
This is the **correct** outcome. Note the distinct error for `/dev/ttyS0`:
`Input/output error` (errno 5) means the node exists but the underlying hardware
does not answer — different from `device not found` for the ports that do not
exist at all. The app reports which is which rather than a generic failure.
### `--autodetect` also fails, as designed
With no USB adapter present there is nothing to autodetect, so it falls through
to the default port order and fails the same way. Crucially, it **refuses to
guess** when several adapters *are* present (`exit 3`) — picking the wrong
reader means authenticating the wrong person, so ambiguity is an error rather
than a coin flip.
### `--demo` works, because it touches no hardware
```console
$ .venv/bin/python -m rdm_card_reader --demo --demo-count 1
Demo mode - no hardware is used, values are simulated.
----------------------------------------------------------
CARD INSERTED - 2026-10-01 15:53:10
id : 439041101 (0x1A2B3C4D)
type : 0x0F
checksum : 79
valid : True
port : demo
----------------------------------------------------------
CARD REMOVED - 2026-10-01 15:53:11 id: 439041101 (0x1A2B3C4D)
Demo finished.
```
`--demo` needs neither the hardware nor the `rdm6300` library, so it is the
fastest way to confirm the build is sane.
---
## How the app starts
```mermaid
flowchart TD
A["rdm-card-reader<br/>(console script → cli:main)"] --> B{which mode?}
B -->|--list-ports| P["print ports, exit 0"]
B -->|--demo| D["simulated cards, no hardware"]
B -->|--serve or plain| C["1. ensure libraries"]
C --> C1{"rdm6300 importable?"}
C1 -->|yes| E
C1 -->|no| C2["install from bundled wheels<br/>(--no-index, SHA256 verified)"]
C2 --> C3["reload_library()<br/>re-import rdm6300"]
C3 -->|still missing| X2["exit 2"]
C3 -->|ok| E["2. resolve the serial port"]
E --> E1{"--port given?"}
E1 -->|yes| E2["match it; refuse if ambiguous<br/>→ exit 3"]
E1 -->|no| E3["--autodetect: only a single USB adapter<br/>else fall back to default order"]
E2 --> F
E3 --> F["3. open the port"]
F -->|all ports fail| X1["exit 1"]
F --> G["4. reader.start() — blocking read loop"]
G --> H{"mode"}
H -->|plain| I["print each card event"]
H -->|--serve| J["latch the tap in the EventHub"]
J --> K["HTTP server on 127.0.0.1:8765"]
K --> L["web page polls /api/card<br/>and claims it once"]
```
The numbered stages, and where each lives:
| # | Stage | Code | On failure |
| --- | --- | --- | --- |
| 1 | Ensure `rdm6300` + `pyserial` | `bootstrap.ensure_dependencies()` | `exit 2` |
| 2 | Resolve which serial port | `ports.resolve()` | `exit 1` (none), `exit 3` (ambiguous) |
| 3 | Open the port | `reader.open_reader()` | `exit 1` |
| 4 | Read frames forever | `rdm6300.BaseReader.start()` | — |
| 5 | Publish events | `card.format_event()` or `bridge.EventHub` | — |
Stages 1 and 2 are deliberately **fail-fast and loud**. A reader that starts up
having silently picked the wrong port, or without its library, is worse than one
that refuses to start — the first failure mode is invisible until someone
authenticates as the wrong person.
### Stage 1 in detail: dependency install
The `rdm6300` and `pyserial` wheels are **shipped inside the package**
(`rdm_card_reader/wheels/`), so this works on a device with no internet:
```console
Reader library not found - checking dependencies...
Missing reader dependencies: rdm6300
Using the wheels bundled with this package:
.../site-packages/rdm_card_reader/wheels
Installing from .../rdm_card_reader/wheels
Dependencies ready: rdm6300
```
Nothing is installed when the libraries are already importable, so a normal
start does no network work and no `pip` call at all. The install uses
`--no-index` and verifies `SHA256SUMS` first; a wheel that fails its checksum is
**refused rather than installed**.
`reload_library()` matters here: the module-level `import rdm6300` already ran
and failed at process start, so without re-importing, a successful install would
still look like a missing library.
### Stage 4 in detail: reading is not polling
The reader loop **blocks** on serial reads and the library calls back when it
assembles a valid frame. Nothing here polls. The *web page* polls (or subscribes
to SSE) — a different thing, and worth keeping straight.
A card is reported on insert and again on removal; the library de-duplicates
repeated reads while the card sits on the antenna, so you do not get a stream of
repeats. Removal is detected by a `--heartbeat` (default `0.5s`) of silence.
---
## Exit codes
| Code | Meaning | Seen on this host? |
| --- | --- | --- |
| `0` | Ran normally, or stopped with Ctrl+C | with `--demo`, `--list-ports` |
| `1` | No serial port could be opened | **yes** — this is the expected result here |
| `2` | `rdm6300` missing and could not be installed | only if offline *and* wheels tampered |
| `3` | Port selector matched several adapters (refused to guess) | needs 2+ adapters |
> When testing with a pipe, remember `head` closes the pipe early and the shell
> then reports `128+SIGPIPE`. Redirect to a file instead if you want the real code:
> `… > out.txt 2>&1; echo $?`
---
## When a reader *is* attached
On the Raspberry Pi the same command should end with a ready line rather than an
error:
```console
-> Using /dev/rdm-reader (matched /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0)
OK reader on /dev/rdm-reader ; bridge on http://127.0.0.1:8765 (mode: login)
```
Then compare this host against the Pi:
| | this host (`development`) | Raspberry Pi with reader |
| --- | --- | --- |
| Architecture | x86_64 | armv7l / aarch64 |
| Reader | none | RDM6300 on USB-serial or UART |
| `/dev/ttyUSB*` | absent | present |
| `--list-ports` score | 5, `(not USB)` | 60–100, e.g. `CH340 1a86:7523` |
| `/dev/rdm-reader` | absent | udev symlink |
| Serial access group | n/a | user must be in `dialout` |
| Expected `rdm-card-reader` | `exit 1` | stays running |
For a real device, `deploy/install.sh` sets all of this up:
```bash
sudo ./deploy/install.sh --origin https://your.web.app
journalctl -u rdm-card-reader -f
curl http://127.0.0.1:8765/api/card # tap a card first
```
---
## Testing the reader path without a reader
Because the app talks plain serial, a pseudo-terminal is enough to exercise the
whole chain. `tests/test_e2e_pty.py` writes real RDM6300 frames into a PTY and
asserts on the printed events; `tests/test_e2e_bridge.py` does the same and then
claims the tap over HTTP exactly as a web page would.
To hand-feed a single frame yourself:
```python
import os, pty, struct, subprocess, sys, time
def frame(value, card_type=0x0F):
id_bytes = struct.pack(">I", value)
checksum = card_type
for b in id_bytes:
checksum ^= b
return (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper().encode()
master, slave = pty.openpty()
port = os.ttyname(slave)
proc = subprocess.Popen([sys.executable, "-u", "-m", "rdm_card_reader", "--port", port])
time.sleep(2) # let the child open the port FIRST
os.write(master, b"\x02" + frame(0x00C4A0B5) + b"\x03")
time.sleep(1)
proc.terminate()
```
Two traps worth knowing, both of which cost me a debugging cycle:
* **Send the frame after the child has opened the port.** A PTY buffers, so
frames written too early are discarded and it looks like a reader bug.
* **Read the child's stdout from a thread.** With text-mode buffered pipes,
`select()` will not report the data even though it is there.
Frame layout, since it is easy to get wrong: the 12 hex nibbles are
**type, id, checksum**, and the checksum is the XOR of the type byte **and** all
four id bytes — not the id bytes alone.
+4
View File
@@ -5,6 +5,10 @@ print what happens: **card inserted** with the card data, then **card removed**.
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port. Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
> — it explains the startup sequence, why it exits `1` with no hardware, and how
> to exercise the reader path anyway.
--- ---
## What it does ## What it does