2ded37e190
install.sh ran 'pip install .', which builds the project: pip downloads
setuptools into an isolated build environment. With no internet it fails at
the 'Building the virtual environment' step with
Could not find a version that satisfies the requirement setuptools>=68
(from versions: none)
even though the runtime libraries are already in the wheel directory. The
build backend is only needed to package, never to run.
- Install the runtime deps straight from the bundled wheels with --no-index,
and put src/ on the path via a .pth file, so no build step happens at all.
- Generate the console script with the venv's interpreter as an absolute
shebang. '#\!/usr/bin/env python3' resolves to the *system* python when the
venv is not activated, so the package was not importable from the unit.
- apt-get update/install and 'pip install --upgrade pip' are now best-effort:
all three need the network and none should abort the install.
- Detect a failed 'python3 -m venv' and name python3-venv as the usual cause.
- Sanity-check the install with --version before enabling the service.
- The unit falls back to 'python -m rdm_card_reader' if the console script is
ever absent (no shell in ExecStart, so the installer picks the path).
Reproduced the failure offline, then verified the fix offline as well: a venv
created on a copy of the tree self-provisions from the bundled wheels and runs
from cwd=/ with no PYTHONPATH and no venv activation.
Added tests/test_deploy.py (20 tests) covering the installer and the deploy
artifacts, including a regression test for 'pip install .' that was confirmed
to fail when the bug is reintroduced.
368 lines
13 KiB
Markdown
368 lines
13 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 …`.
|
||
`pip install .` needs the network — see **Troubleshooting** below for the
|
||
offline route.
|
||
|
||
---
|
||
|
||
## Troubleshooting install
|
||
|
||
### "Could not find a version that satisfies the requirement setuptools>=68"
|
||
|
||
Seen when running `deploy/install.sh` on a Pi with no internet, at the
|
||
"Building the virtual environment" step:
|
||
|
||
```
|
||
ERROR: Could not find a version that satisfies the requirement setuptools>=68
|
||
(from versions: none)
|
||
ERROR: No matching distribution found for setuptools>=68
|
||
```
|
||
|
||
**Cause:** `pip install .` builds the project. pip creates an isolated build
|
||
environment and downloads the *build* backend (`setuptools`) into it — even
|
||
though the *runtime* libraries are already sitting in the wheel directory. With
|
||
no network that download cannot happen.
|
||
|
||
**It is not** a problem with the bundled wheels, and not a Python version
|
||
mismatch. Two separate confusions produce a similar-looking error:
|
||
|
||
| Situation | Error |
|
||
| --- | --- |
|
||
| Build isolation, no network | `Could not find a version ... setuptools>=68` |
|
||
| `--no-build-isolation`, and setuptools absent | `Cannot import 'setuptools.build_meta'` |
|
||
|
||
Note that Python 3.12+ venvs no longer include `setuptools`, so the second case
|
||
is increasingly common.
|
||
|
||
**Fix:** the current `install.sh` avoids the build entirely — it installs the
|
||
bundled runtime wheels and puts `src/` on the path with a `.pth` file. If you
|
||
are on an older copy, update it (`git pull`) or run these by hand on the Pi:
|
||
|
||
```bash
|
||
python3 -m venv .venv
|
||
.venv/bin/pip install --no-index \
|
||
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
|
||
SITE=$(.venv/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
|
||
echo "$PWD/src" > "$SITE/rdm_card_reader_src.pth"
|
||
.venv/bin/python -m rdm_card_reader --demo # verify, no hardware needed
|
||
```
|
||
|
||
No build step, no network, and the same result.
|
||
|
||
### "Could not create the virtualenv"
|
||
|
||
On Debian and Raspberry Pi OS, `python3 -m venv` needs the `python3-venv`
|
||
package:
|
||
|
||
```bash
|
||
sudo apt-get install python3-venv
|
||
```
|
||
|
||
### apt-get failing on the first step
|
||
|
||
`apt-get update` and `apt-get install` need the network. The installer now
|
||
treats both as best-effort and continues, because a device typically already
|
||
has `python3`. If they fail and something really is missing, install it while
|
||
online and re-run.
|
||
|
||
---
|
||
|
||
## 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
|
||
```
|
||
|
||
The installer needs no internet: the runtime libraries ship inside the
|
||
source tree (`src/rdm_card_reader/wheels/`).
|
||
|
||
### Copying the project to the Pi
|
||
|
||
`install.sh` resolves its source directory from its own location, so copy the
|
||
whole tree (including `src/` — that is where the wheels live) and run it from
|
||
there:
|
||
|
||
```bash
|
||
# from this machine
|
||
scp -r rdm-card-reader pi@192.168.0.123:~/Desktop/
|
||
|
||
# on the Pi
|
||
cd ~/Desktop/rdm-card-reader
|
||
sudo ./deploy/install.sh --origin https://your.web.app
|
||
```
|
||
|
||
If you transferred only a subset of the tree, `--dir` can point the install
|
||
somewhere else, but `src/` must be present or the wheels will be missing.
|
||
|
||
---
|
||
|
||
## 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.
|