Files
rdm-card-reader/LAUNCH.md
T
ske087 674d91a5ca 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.
2026-10-01 15:55:16 +03:00

283 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.