ske087 2ded37e190 Fix install.sh failing on offline Raspberry Pi at the venv step
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.
2026-10-01 16:19:33 +03:00

rdm-card-reader

Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and 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 — it explains the startup sequence, why it exits 1 with no hardware, and how to exercise the reader path anyway.


What it does

$ rdm-card-reader

-> Opening /dev/ttyAMA0 ...
   OK reader ready on /dev/ttyAMA0 - waiting for cards (Ctrl+C to stop)

----------------------------------------------------------
 CARD INSERTED - 2026-10-01 14:56:54
    id        : 12886197   (0x00C4A0B5)
    type      : 0x0F
    checksum  : 222
    valid     : True
    port      : /dev/ttyAMA0
----------------------------------------------------------
 CARD REMOVED  - 2026-10-01 14:56:54  id: 12886197 (0x00C4A0B5)

id is the number printed on the card. A card is reported once on insert (the reader library de-duplicates repeated reads while the card sits on the antenna) and once on removal.

Startup sequence

  1. Check dependencies. If rdm6300 / pyserial cannot be imported, install them automatically and carry on. A fresh device needs no manual pip work.
  2. Find the reader. Resolve the serial port and open it, identifying the adapter by serial number or by-id rather than trusting ttyUSB0.
  3. Start reading. Read frames in a loop; each valid one becomes a card event.
  4. Publish it. In --serve mode the event is latched so a web page can claim it whenever it gets around to asking.

Steps 1 and 2 both fail loudly with a reason rather than starting up half-working, because a reader that silently picks the wrong port or runs without its library is worse than one that refuses to start.


Install

On a machine with internet:

pip install .

rdm6300 and pyserial are pulled in automatically. On Raspberry Pi OS with an externally-managed Python, add --break-system-packages or use a venv:

python3 -m venv .venv && . .venv/bin/activate && pip install .

On a device with no internet

pip install . will not work offline. It builds the project, which makes pip download setuptools into an isolated build environment, and that fails with:

ERROR: Could not find a version that satisfies the requirement setuptools>=68
       (from versions: none)

The runtime dependencies are bundled in src/rdm_card_reader/wheels/, so install those directly and put src on the path — no build step, no network:

python3 -m venv .venv
.venv/bin/pip install --no-index \
    --find-links src/rdm_card_reader/wheels rdm6300 pyserial
echo "$PWD/src" > "$( .venv/bin/python -c \
    'import sysconfig; print(sysconfig.get_paths()["purelib"])' )/rdm_card_reader_src.pth"
.venv/bin/python -m rdm_card_reader --demo     # verify, no hardware needed

deploy/install.sh does exactly this, plus the service setup. Running the app directly also works: it installs anything missing from the same bundled wheels on first start.


Usage

rdm-card-reader                     # auto-detect the serial port
rdm-card-reader --port /dev/ttyUSB0 # use a specific port
rdm-card-reader --json              # one JSON object per event
rdm-card-reader --heartbeat 1.5     # slower card-removed detection
rdm-card-reader --list-ports        # show which ports exist, then exit
rdm-card-reader --serve             # serve card data to a web app (see below)
rdm-card-reader --demo              # simulate reads, no hardware needed

Also works as a module: python -m rdm_card_reader.

Option Default Meaning
--port auto-detect Serial port: device path, by-id symlink, serial number or vid:pid
--autodetect off Pick the only USB adapter; refuse if there is more than one
--heartbeat 0.5 Seconds without a read before the card counts as removed
--json off Machine-readable output instead of the text block
--list-ports – List candidate and identified ports, then exit
--verbose-ports off With --list-ports, show by-id / by-path detail
--demo – Simulated reads; needs no hardware and no rdm6300
--demo-count 3 Number of simulated reads in --demo
--no-install-deps off Never install missing libraries automatically
--wheel-dir bundled Install from local wheels in this DIR instead of the bundled ones
--version – Print the version

With --serve: --host (127.0.0.1), --bridge-port (8765), --mode (login), --tap-ttl (10), --allow-origin (repeatable), --token, --allow-remote, --verbose.

When --port is omitted the ports are tried in this order, the same order the workstation app uses:

/dev/ttyS0  ->  /dev/ttyAMA0  ->  /dev/ttyUSB0  ->  /dev/ttyACM0

Output modes

Text mode is for humans and logs. --json prints one object per event, which is what you want when piping into another program:

{"event": "card_inserted", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"}
{"event": "card_removed", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"}

Automatic dependency install

The wheels for rdm6300 and pyserial ship inside this package (rdm_card_reader/wheels/), so a device with no internet can still set itself up. On first start the reader installs whatever is missing from those wheels:

Reader library not found - checking dependencies...
Missing reader dependencies: rdm6300
Using the wheels bundled with this package:
   /opt/rdm-card-reader/.venv/lib/python3.13/site-packages/rdm_card_reader/wheels
Installing from .../rdm_card_reader/wheels
Dependencies ready: rdm6300

No network access is needed. They ship with the package rather than being fetched at build time, because the build machine and the target device are not necessarily the same.

Both wheels are pure Python (py3-none-any / py2.py3-none-any), which is what makes this possible: the same files install on armv7l, aarch64 and x86_64. This differs from the workstation app's Files/repository/ cache, which held architecture-specific binaries (aarch64 manylinux wheels) and so could not be committed — that cache only ever existed on the Pi devices themselves.

  • Nothing is installed when the libraries are already present, so a normal start does no network work at all.
  • The bundled install runs with --no-index, so it works with no route to PyPI at all — no proxy or DNS needed.
  • Wheels are verified against a bundled SHA256SUMS before installation. A wheel that fails its checksum is refused, not installed — then it falls back to PyPI, and if that is unreachable it reports exit 2 rather than proceeding with something unverified.
  • Outside a virtualenv pip is given --break-system-packages, which Debian and Raspberry Pi OS require before they will touch the system Python.
  • A failed install does not crash: it reports exit 2 and tells you the exact command to run by hand.

To install from your own wheel directory instead (for example a newer version than the bundled one), pass --wheel-dir. An explicit directory that is incomplete or fails verification is reported and then PyPI is used, rather than failing outright. To manage dependencies yourself, pass --no-install-deps.

Exit codes

Code Meaning
0 Ran normally, or stopped with Ctrl+C
1 No serial port could be opened
2 The rdm6300 library is missing and could not be installed
3 The port selector matched more than one adapter (refused to guess)

Serving card data to a web app (--serve)

This is the mode for authenticating a web page: the reader runs as a service on the device, and the page asks it for the card that was just tapped.

rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app

The bridge reports card ids only. Deciding which user a card belongs to is your server's job, not this program's.

The flow

sequenceDiagram
    participant C as Card
    participant B as rdm-card-reader (Pi)
    participant K as Web page
    participant W as Your server

    C->>B: tap
    B->>B: latch the tap (tap-ttl seconds)
    K->>B: GET /api/card
    B-->>K: {claimable: true, card: {id: ...}}
    K->>B: POST /api/card/claim
    B-->>K: {card: {id: ...}}   (once)
    K->>W: POST /login {card_id}
    W->>W: card_id -> user
    W-->>K: session

The point of the latch is that the tap stays available for --tap-ttl seconds (default 10), including after the card is lifted. A quick tap therefore cannot be lost because the page happened to poll a moment later.

The point of claim is single use. Once a tap is spent, a page reload or a second tab gets 409 Conflict instead of a second login, so one physical tap can never authenticate two sessions.

Endpoints

Method Path Purpose
GET /api/card The latched tap: present, claimable, consumed, age_seconds, card
POST /api/card/claim Spend the tap on one login. 200, or 409 if used/none, 410 if expired
GET /api/events Server-Sent Events stream, pushes each event as it happens
GET /api/last Most recent card event, or 204
GET /api/taps Recent tap history (?limit=N)
GET /api/health Reader state, port, uptime, tap counts — for monitoring
POST /api/mode {"mode": "login"} or {"mode": "monitor"}

login mode (default) publishes taps only — a tap is an authentication attempt. monitor mode also publishes removals, which is what you want for watching a reader rather than logging people in.

Browser example

const state = await (await fetch("http://127.0.0.1:8765/api/card")).json();
if (state.claimable) {
  const res = await fetch("http://127.0.0.1:8765/api/card/claim", { method: "POST" });
  if (res.ok) {
    const { card } = await res.json();
    await fetch("/login/card", {           // your server decides who card.id is
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ card_id: card.id, hex: card.hex }),
    });
  }
}

Or push instead of poll, so a tap logs in immediately:

const events = new EventSource("http://127.0.0.1:8765/api/events");
events.onmessage = (e) => { const tap = JSON.parse(e.data); /* tap.id */ };

Security, and its limits

Read :ref:the warning below <card-ids-are-not-secrets>: an EM4100 id is a username, not a password. These controls bound who can read the bridge; they do not make the card itself convincing as a sole credential.

Control Behaviour
Bind address 127.0.0.1 by default. Anything else needs --allow-remote, so an open card-event endpoint cannot appear on the LAN by accident.
Origins Cross-origin browser requests are refused unless --allow-origin lists that exact origin. Stops another site from reading your taps.
Token --token (or RDM_BRIDGE_TOKEN) is required from non-loopback callers.
Single use A tap can only ever produce one successful claim.
Expiry A tap older than --tap-ttl is refused with 410.

Add a second factor — a PIN after the tap — wherever a cloned card would actually cost you something.

Card ids are not secrets. EM4100 cards broadcast a fixed 40-bit id with no challenge-response, and clones are cheap. If holding an id is enough to log in, the security is "whoever knows the number". Treat card auth as identification, and require something else for anything sensitive.


Running at boot (Raspberry Pi)

sudo ./deploy/install.sh --origin https://your.web.app

That installs a service account, a virtualenv, a systemd unit and a udev rule, then enables the service. Afterwards:

systemctl status rdm-card-reader      # state
journalctl -u rdm-card-reader -f      # live log
curl http://127.0.0.1:8765/api/card   # tap a card, then run this

Identifying the right ttyUSB

/dev/ttyUSB0 is not a stable identity — unplug something and the number changes. Point this at the wrong adapter and you authenticate the wrong reader, so the tooling is deliberately strict about it.

rdm-card-reader --list-ports --verbose-ports
Identified serial ports (best candidate first):
   [score 100] /dev/ttyUSB0  ::  CH340  ::  1a86:7523  ::  S/N AB0XYZ12
       by-id     : /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
       by-path   : /dev/serial/by-path/pci-0000:01:00.0-usb-0:1.2:1.0-port0

Then pin it, most specific first:

--port /dev/rdm-reader                                              # udev alias
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0            # by device
--port AB0XYZ12                                                     # by serial number
--port 1a86:7523                                                    # by chip

--autodetect picks the only USB adapter, and refuses when there is more than one — silently choosing between two readers is exactly the failure this avoids. Verify the wiring in deploy/99-rdm-reader.rules.

Exit codes: 0 ok, 1 no port, 2 library missing, 3 port ambiguous.


Wiring

The RDM6300 talks plain serial at 9600 baud, 8N1. Connect it to the Pi's UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.

On a Raspberry Pi you usually need to free up the primary UART first:

sudo raspi-config          # Interface Options -> Serial Port
#   "login shell over serial?"  -> No
#   "serial port hardware?"     -> Yes
sudo usermod -aG dialout $USER   # then log out and back in

The GPIO UART appears as /dev/serial0 (a symlink to /dev/ttyAMA0 or /dev/ttyS0).


The protocol

Worth knowing if you ever decode the bytes yourself. The reader emits a frame between STX (0x02) and ETX (0x03) as 12 ASCII hex nibbles:

0x02  0F 00C4A0B5 DE  0x03
      |  |        |
      |  |        +-- checksum
      |  +----------- card id (4 bytes, big-endian)
      +-------------- type byte

Two details that are easy to get wrong:

  1. Field order is type, id, checksum — the id is not first.
  2. The checksum is the XOR of the type byte and all four id bytes. XOR-ing only the id bytes produces a mismatch on every card.
checksum = type ^ b0 ^ b1 ^ b2 ^ b3
         = 0x0F ^ 0x00 ^ 0xC4 ^ 0xA0 ^ 0xB5
         = 0xDE   (= 222)

A frame whose checksum does not match is reported as INVALID CARD and is never treated as a card insert.

There is also a subtle failure mode: a frame with the wrong number of nibbles is not rejected outright. Two stray nibbles stay in the buffer and corrupt the next read instead of the current one, so a single glitch can look like a problem with the following card.


Troubleshooting

Symptom Fix
device not found on every port Check the wiring; run rdm-card-reader --list-ports
permission denied sudo usermod -aG dialout $USER, then log out and back in
Reader opens but no cards Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil
INVALID CARD repeatedly Noisy supply or long/unshielded wiring; shorten it
Every card reported twice Lower --heartbeat if the card is lifted quickly

Development

pip install -e ".[dev]"
pytest

The suite needs no RFID hardware: the end-to-end tests open a pseudo-terminal, write real RDM6300 frames into it and assert on the CLI output. --demo also works with no hardware at all.

Layout

src/rdm_card_reader/
    card.py     data model, constants, output formatting (no hardware)
    reader.py   serial port handling, event callbacks
    cli.py      argument parsing and program flow
    demo.py     simulated reads
tests/
    test_protocol.py     RDM6300 wire format, byte for byte
    test_card.py         event model and formatting
    test_reader.py       port selection, using a fake reader library
    test_cli.py          exit codes and output modes
    test_e2e_pty.py      full stack over a pseudo-terminal

Using it as a library

from rdm_card_reader import open_reader

def on_event(event):
    if event.event == "card_inserted":
        print("card", event.value, event.hex_value)

reader = open_reader(on_event)
if reader is None:
    raise SystemExit("no reader found")

reader.start()   # blocks; call reader.close() to stop

License

MIT

S
Description
No description provided
Readme MIT 4.5 MiB
Languages
Python 84.5%
Shell 15.5%