Files
rdm-card-reader/README.md
T
ske087 882b89d6da Serve a read-only status page on the bridge port
GET / now returns a dashboard for the reader: mode, port, uptime,
card presence, the current card, event counters and a live event log
seeded from /api/taps and updated over the /api/events SSE stream.

The page is read-only on purpose - it has no claim button, because
POST /api/card/claim spends the tap and merely viewing the status
would otherwise consume a real login.

Serving the page requires allowing same-origin browser requests: a
browser sends Origin even for a request to the same origin, so
_cors_ok() used to reject the page's own API calls with 403 unless
--allow-origin was passed. Loopback and same-host origins are now
accepted, and the Access-Control-Allow-Origin echo is applied
consistently in one helper instead of four copies.

--no-page reverts POST-free deployments to API-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 21:34:17 +03:00

37 KiB
Raw Blame History

rdm-card-reader

Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port. Two ways to use it:

  • print events — card inserted with the card data, then card removed
  • serve an API (--serve) — expose the card on the reader over HTTP so a web app can authenticate a card tap

Built for a Raspberry Pi, 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.


Contents


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/serial0 # the Pi's on-board UART
rdm-card-reader --port /dev/ttyUSB0 # a USB adapter
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 2.0 Seconds without a read before the card counts as removed. Also the presence/LED window
--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:

Option Default Meaning
--host 127.0.0.1 Bind address; anything else needs --allow-remote
--bridge-port 8765 HTTP port
--mode login login publishes taps only; monitor also publishes removals
--tap-ttl 10 Seconds an unclaimed tap stays claimable; 0 keeps it until replaced. Keep it ≥ --heartbeat
--claimed-ttl 30 Seconds a claimed card stays readable; 0 keeps it indefinitely
--allow-origin – Permit this browser origin (repeatable)
--token – Pre-shared token for non-local callers (or RDM_BRIDGE_TOKEN)
--allow-remote off Permit binding a non-loopback address
--verbose off Also print each event and each HTTP request

When --port is omitted the ports are tried in this order:

/dev/ttyS0  ->  /dev/ttyAMA0  ->  /dev/serial0  ->  /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/serial0 --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: {present: true, claimable: true, card: {id: ...}}
    K->>B: POST /api/card/claim
    B-->>K: {card: {id: ...}, consumed: true}   (once, then 409)
    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.

present and card answer different questions, which is the part that surprises people:

Field Meaning
present the card is physically on the reader right now — goes false the moment it is lifted
card the latched tap — survives the card being lifted, so you still know which card it was

So a card that was tapped and lifted a second ago reports present: false with card still populated. That is correct, not a bug: if the id vanished with the card, any poll arriving after the lift could not authenticate anything.

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.

Two independent windows govern how long the card stays readable:

Window Flag Default Governs
Unclaimed --tap-ttl 10 s how long a tap can still be spent on a login
Claimed --claimed-ttl 30 s how long a claimed card stays readable, so the page that claimed it can confirm which card logged in

Put together, /api/card moves through these states:

Moment present card claimable consumed
Card held on the reader true id true false
Lifted, within --tap-ttl false id true false
Claimed, within --claimed-ttl false id false true
After the claimed window false null false false
Unclaimed and past --tap-ttl false null false false

The last two rows are the point: an id that can no longer be acted on is cleared, so it cannot be mistaken for a tap still waiting to be used. /api/last and /api/taps keep the history either way. Setting either value to 0 disables that expiry.

Set --tap-ttl at least as long as --heartbeat, or longer. --tap-ttl applies to the tap, --heartbeat to presence, and they are independent. If the tap expires before the heartbeat declares the card removed, a card still sitting on the reader has its id cleared and reads as "card": null until the next re-read — which on a reader that reports once per placement may be a long time. Matching them (--heartbeat 10 --tap-ttl 10) avoids the gap for a card held in place.

Status page

Serving also puts a status page at GET / on the same port, so you can see what the reader is doing from a browser:

http://127.0.0.1:8765/

It shows the reader's mode, port, uptime, whether a card is present and claimable, the current card, the event counters, and a live event log. The log is seeded with recent history from /api/taps and then updated live over the /api/events SSE stream, so it keeps filling in without a refresh.

The page is read-only: it deliberately has no "claim" button, because POST /api/card/claim spends the tap, and a page that claimed would silently consume a real login just for being open. Claim from your own app instead.

Disable it with --no-page if you want the port to serve the API only.

Endpoints

Method Path Purpose
GET / Status page (unless --no-page)
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, both TTLs — 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.

/api/health is the liveness check: status: "ok" with a rising uptime_seconds means the service is serving. It is answered before the origin and token checks, so it stays readable by any client that can reach the port — which is the intent on loopback, but means binding with --allow-remote exposes the port, mode, uptime and tap counts to the network.

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 }),
    });
  }
}

Already claimed, and you just want to know which card it was? The id stays readable for --claimed-ttl (default 30 s) after the claim, with consumed: true:

const { card, consumed } = await (await fetch("http://127.0.0.1:8765/api/card")).json();
if (consumed && card) console.log("that tap was card", card.id);

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 An unclaimed tap older than --tap-ttl is refused with 410.
Bounded retention A claimed tap stays readable for --claimed-ttl (30 s), then the id is cleared — an id that can no longer be acted on is never left in the response.

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)

The defaults already match the common wiring — /dev/serial0 for the reader on the Pi's UART and GPIO 23 for the LED — so the LED pin and device usually need no arguments at all:

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

To be explicit (and safe if the defaults ever change):

sudo ./deploy/install.sh --origin https://your.web.app \
    --device /dev/serial0 \
    --led-pin 23 \
    --led-mode presence
Argument Why
--device /dev/serial0 The reader is wired to the UART pins (8 / 10). This is the default. For a USB-to-serial adapter pass --device /dev/rdm-reader instead — that alias is created by udev for USB adapters only, so it never exists on a UART setup
--led-pin 23 BCM GPIO 23, which is physical pin 16. The default
--led-mode presence Lit while a card is on the reader, dark otherwise. The default

--led-pin is the BCM GPIO number, not the header pin. If your LED wire is on physical pin 16, pass --led-pin 23 (the default). Passing --led-pin 16 would drive GPIO 16, which is physical pin 36 — a different pin entirely. The LED is optional: with no LED, pass --led-mode presence regardless and ignore the light, or add --no-led to the unit if you want the app to stop trying.

Standalone install

deploy/install.sh is the only installer — there is no deploy.sh.

That installs a service account, a virtualenv, a systemd unit and a udev rule, then enables the service. The device defaults to /dev/rdm-reader; if the reader is on the Pi's UART instead, add --device /dev/serial0 — see UART or USB? above. The installer refuses to enable the service unless the device exists and opens, so a misconfigured port fails during install rather than as a crash-looping unit.

Useful installer options:

Option Default Meaning
--origin URL – Web app origin allowed to call the API (repeatable)
--device PATH /dev/serial0 Serial device the service opens (UART; use /dev/rdm-reader for USB)
--led-pin N 23 BCM GPIO number of the status LED (physical pin 16)
--led-mode M presence presence: lit while a card is on the reader; error: lit whenever running
--heartbeat S 2.0 Seconds before a silent reader counts as card removed — also the presence/LED window
--port PORT 8765 HTTP port for the bridge
--dir PATH /opt/rdm-card-reader Install location
--user NAME rdmcard Service account
--no-service off Install and configure, but do not enable the service

The unit runs the app as:

/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
    --serve --port <device> --bridge-port 8765 --mode login \
    --led-pin <pin> --led-mode <mode> --heartbeat <seconds>

WorkingDirectory=/opt/rdm-card-reader, so this is equivalent to running .venv/bin/python -m rdm_card_reader --port <device> there with --serve added. --serve is what makes the API available; without it the app only prints events, which a service sends to the journal.

Afterwards:

systemctl status rdm-card-reader      # state
systemctl is-enabled rdm-card-reader  # should be 'enabled' (starts at boot)
journalctl -u rdm-card-reader -f      # live log
ss -ltnp | grep 8765                  # confirms something is listening
curl -sS http://127.0.0.1:8765/api/card   # tap a card, then run this

A healthy startup logs one line:

   OK reader on /dev/serial0 ; bridge on http://127.0.0.1:8765 (mode: login)

Checking the reader from the command line

# Everything at once, in a browser: the status page
xdg-open http://127.0.0.1:8765/     # or open it on another machine

# Is the reader alive? (status, port, uptime)
curl -sS http://127.0.0.1:8765/api/health | python3 -m json.tool

# Is a card on the reader right now?
curl -sS http://127.0.0.1:8765/api/card | python3 -m json.tool

Use curl -sS rather than plain -s: the extra S prints errors, so a Connection refused is visible instead of an empty response that json.tool then reports as Expecting value: line 1 column 1 (char 0). That message means nothing answered, which almost always means the service is not running — check ss -ltnp | grep 8765 first.

The service gave up after a few failures

StartLimitBurst=5 over StartLimitIntervalSec=60 means systemd stops trying after five failed starts — about 15 seconds. That is deliberate (an absent reader should not restart forever), but it means fixing the wiring is not enough on its own; the failed state has to be cleared:

sudo systemctl reset-failed rdm-card-reader
sudo systemctl restart rdm-card-reader

If a unit edit has no effect, you forgot sudo systemctl daemon-reload — systemd caches unit files. Confirm what the service actually runs with:

systemctl cat rdm-card-reader | grep -E '^(ExecStart|DeviceAllow|After)'

That output, not the .service file on disk, is the truth the service runs.

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 (USB adapters)
--port /dev/serial0                                                 # on-board UART
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0            # by device
--port AB0XYZ12                                                     # by serial number
--port 1a86:7523                                                    # by chip

Note the two are wired differently and the udev alias only exists for one of them — see UART or USB? below.

--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.


Status LED

An LED driven by card events, on GPIO 23 (physical pin 16) by default — the same pin the workstation app used. Optional: everything works without it.

By default the light tracks the card: lit while a card is on the reader, dark otherwise.

rdm-card-reader                    # lit only while a card is on the reader
rdm-card-reader --led-mode error   # lit whenever running, blinks on a bad read
rdm-card-reader --led-pin 17       # a different pin
rdm-card-reader --no-led           # no LED at all
Flag Default Meaning
--no-led off Do not drive a LED
--led-pin 23 BCM GPIO number — not the physical header pin
--led-mode presence See the two readings below
--led-no-error-blink off Do not blink on a bad read

--led-pin takes the BCM GPIO number. The two are easy to confuse, and it is the second most common reason a LED "does not work" — the default 23 is physical pin 16, not pin 23. The full mapping is under Wiring below.

Two modes, because the light can mean two different things:

Mode Behaviour Use when
presence (default) Lit only while a card is on the reader, dark otherwise The light should mean "a card is here" — the common choice, and unambiguous
error Lit whenever the reader is running. Dark while a card is being read, blinks on a bad read The light should mean "the reader is alive and ready"

In presence mode the light is dark at startup and lights on the first valid card read, then goes dark when the card is lifted. A dark LED on a freshly started service is therefore correct, not a fault. Check that a card read lights it before concluding the LED is broken.

Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.

It cannot break the reader

Three deliberate choices, because a LED is a convenience and the reader is the point:

  • Never blocks. The workstation app toggled the LED directly on the reader thread, which stalls card detection. Here every change is queued to a worker thread, so a toggle returns immediately even with slow hardware.
  • Never fatal. On a machine with no GPIO, gpiozero raises BadPinFactory. The app falls back to a no-op LED and says why, rather than refusing to run.
  • Never crashes on a dead pin. A failed toggle is logged and ignored.

Startup reports which of the three states it is in (the mode shown is presence by default):

LED on GPIO 23 (presence mode, gpiozero)           # working
LED unavailable: gpiozero not installed            # library missing
LED unavailable: no usable GPIO on pin 23: ...     # no pin factory / pin busy

That distinction matters: BadPinFactory subclasses ImportError, so a naive check reports "not installed" when the library is present but has no usable pin provider — which would send you to the wrong fix.

The reader works but the light stays dark

Because a LED failure is never fatal, a misconfigured LED looks exactly like a working reader with no light. Check the reason the app printed:

journalctl -u rdm-card-reader -n 40 --no-pager | grep -i led
Message Cause Fix
LED unavailable: no usable GPIO on pin 23: ... gpiozero cannot reach a pin backend, or systemd/group permission is missing See the three checks below
LED unavailable: gpiozero not installed: ... LED extras missing from the venv Install from the bundled wheels (below)
LED on GPIO 23 (presence mode, gpiozero) LED is initialised — suspect wiring, or simply no card read yet See the wiring note below

Check 1 — the venv must see the system pin backend. gpiozero is pure Python, but the library it uses to talk to the pins (lgpio on Bookworm, RPi.GPIO on older releases) is an apt package in the system Python. A plain venv cannot see it, so gpiozero raises BadPinFactory and the app quietly falls back to a no-op LED. This is why install.sh creates the venv with --system-site-packages; if you built the venv by hand, that is the most likely cause:

grep system-site-packages /opt/rdm-card-reader/.venv/pyvenv.cfg   # want: true
/opt/rdm-card-reader/.venv/bin/python -c 'import lgpio; print("lgpio OK")'

If the first line is missing or the import fails, install the backend and rebuild the venv:

sudo apt-get install -y python3-lgpio        # Bookworm; python3-rpi.gpio on older
sudo ./deploy/install.sh --device /dev/serial0   # recreates the venv with the flag

Check 2 — systemd device policy. The unit sets DevicePolicy=closed, so only devices named in DeviceAllow= are reachable. It must list the GPIO chip as well as the serial port:

DeviceAllow=/dev/serial0 rw
DeviceAllow=/dev/gpiochip0 rw
DeviceAllow=/dev/gpiochip4 rw

gpiochip0 is the header on Pi 1–4, gpiochip4 on Pi 5. A missing entry is denied by systemd before any group permission is even consulted. Confirm what the service actually runs with systemctl cat rdm-card-reader | grep DeviceAllow.

Check 3 — the gpio group. On modern Raspberry Pi OS (Bookworm onward) gpiozero drives GPIO through lgpio, which opens /dev/gpiochip0 — owned root:gpio, mode 0660. The service account must be in gpio:

sudo usermod -aG gpio rdmcard

Older releases (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group.

After any of these changes: sudo systemctl daemon-reload (systemd caches unit files) and sudo systemctl restart rdm-card-reader.

If the LED extras are missing, install them offline from the bundled wheels — a Pi with no internet cannot reach PyPI:

/opt/rdm-card-reader/.venv/bin/pip install --no-index \
    --find-links /opt/rdm-card-reader/src/rdm_card_reader/wheels \
    --no-deps gpiozero colorzero

If the journal says the LED is on GPIO 23, the software side is fine. Tap a card: in the default presence mode the light comes on while the card is held and goes dark when it is lifted. If it stays dark with a card on the reader, the problem is electrical — see the pin numbering note under Wiring. GPIO 23 is physical pin 16, and the anode needs a series resistor to the pin with the cathode to GND.

If the workstation app also runs on this device, use a different pin — two processes driving GPIO 23 will fight, and the LED will flicker.

gpiozero and colorzero are installed automatically on first use, from the bundled wheels, so this works offline. They add ~180 KB. setuptools, which gpiozero pulls in as a packaging dependency, is deliberately not vendored: it is never imported at runtime and would add 818 KB for nothing.


Wiring

The RDM6300 talks plain serial at 9600 baud, 8N1.

A typical connection to the Pi's 40-pin header, using the on-board UART:

RDM6300 Pi pin Function
VCC (5 V) 4 power
GND 6 ground (must be common)
TX 10 (GPIO 15, RXD) card data → Pi
RX 8 (GPIO 14, TXD) not used — the reader only transmits

The reader is transmit-only: it emits frames continuously and never reads, so the Pi's TX line (pin 8) has nothing to send. Only pin 10 carries data.

Level warning. The RDM6300's TX output is 5 V TTL, while GPIO 15 is a 3.3 V input. Feeding 5 V straight in often appears to work but stresses the pin. Use a divider (e.g. 1 kΩ in series, 2 kΩ to GND) or a level shifter on the reader-TX → pin-10 line. This is not a cause of "no cards read" if you already see data, but it is worth fixing before relying on the device.

For the status LED: anode → GPIO pin through a ~330 Ω resistor, cathode → GND. See the pin numbering table below, and note the LED is optional — the reader works without it.

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). If the serial console still owns the port the service will fail to open it — check with:

sudo systemctl is-active serial-getty@ttyAMA0.service   # must not be active
grep -o 'console=[^ ]*' /boot/firmware/cmdline.txt      # must not say serial0

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

UART or USB? The service must be told which

The two are not interchangeable, and getting it wrong is the most common reason the service fails with exit 1:

Wiring Device to pass udev alias
Pi UART pins 8 / 10 (or 10/8) /dev/serial0 never created
USB-to-serial adapter /dev/rdm-reader created by the udev rule

deploy/99-rdm-reader.rules matches a USB bridge chip, so it cannot fire for the on-board UART — there is no USB device to match. A service pointing at /dev/rdm-reader with the reader on the UART therefore finds nothing and exits 1 on every start.

Tell the installer which one you have:

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

It verifies the device both exists and opens at 9600 baud before enabling the service, so this class of failure is caught at install time rather than as a crash-looping unit:

   /dev/serial0 opened at 9600 baud

If the LED is not on GPIO 23, say so at the same time (--led-pin takes the BCM GPIO number, not the physical header pin):

sudo ./deploy/install.sh --device /dev/serial0 --led-pin 18   # LED on GPIO 18

LED pin numbering

--led-pin BCM GPIO Physical header pin
23 (the default) GPIO 23 16
18 GPIO 18 12
17 GPIO 17 11

--led-pin is the BCM number, which is what the app uses and what GPIO libraries use. Putting the LED on physical pin 23 would drive GPIO 11, not GPIO 23 — the wire must go to physical pin 16 for the default to be right.


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
Card reported as removed while still on the reader Raise --heartbeat — the reader reports a card once per placement, so a short window expires while the card sits still
LED blinks on and off instead of staying lit Same cause: the LED follows present. Raise --heartbeat
Service exit 1, no port matched '/dev/rdm-reader' Reader is on the UART — re-install with --device /dev/serial0
Service exit 1, Could not open /dev/serial0 UART held by the serial console, or the user is not in dialout
Service stuck in failed, restarts refused sudo systemctl reset-failed rdm-card-reader
curl returns nothing, Expecting value: line 1 column 1 Nothing is listening — the service is not running; check ss -ltnp | grep 8765
Reader works but the LED is dark See The reader works but the light stays dark

Check it works

With no RFID hardware attached, --demo exercises the formatting path and --list-ports shows what serial ports exist:

rdm-card-reader --demo              # simulated reads, no hardware needed
rdm-card-reader --list-ports        # candidate and identified ports

To drive the real reader path with no hardware, feed RDM6300 frames into a pseudo-terminal — see the snippet in LAUNCH.md.

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
    ports.py    stable serial port identification
    bridge.py   the HTTP bridge behind --serve
    webui.py    the status page served at GET / by the bridge
    led.py      status LED
    bootstrap.py  offline dependency install
    wheels/     the bundled wheels (offline install source)

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