From 674d91a5ca10f28c3288e19d69da764e0027e04b Mon Sep 17 00:00:00 2001 From: scheianu Date: Thu, 1 Oct 2026 15:55:16 +0300 Subject: [PATCH] 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. --- LAUNCH.md | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 + 2 files changed, 286 insertions(+) create mode 100644 LAUNCH.md diff --git a/LAUNCH.md b/LAUNCH.md new file mode 100644 index 0000000..1dd5dcd --- /dev/null +++ b/LAUNCH.md @@ -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
(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
(--no-index, SHA256 verified)"] + C2 --> C3["reload_library()
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
→ exit 3"] + E1 -->|no| E3["--autodetect: only a single USB adapter
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
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. diff --git a/README.md b/README.md index 4be0f58..bed777b 100644 --- a/README.md +++ b/README.md @@ -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. +> 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