674d91a5ca
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.
283 lines
10 KiB
Markdown
283 lines
10 KiB
Markdown
# 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.
|