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.
13 KiB
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:
.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:
.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.
.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:
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:
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
$ .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 5and(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
$ .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
$ .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
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:
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
headcloses the pipe early and the shell then reports128+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:
-> 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:
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:
# 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:
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.