RDM card reader: print card events, select the right port, serve taps to a web app

Card reading (rdm6300 + pyserial) printing card inserted/removed with the
full card data, plus a --serve mode that publishes taps over HTTP so a web
page can authenticate a tap.

The bridge reports card ids only - mapping a card to a user stays on the
server. The tap is latched for --tap-ttl seconds, including after the card is
lifted, and is single use, so a page reload cannot replay one tap into two
logins.

Port selection identifies the reader by serial number or by-id path instead of
/dev/ttyUSBn, and refuses to guess when a selector matches several adapters.

deploy/ has the systemd unit, a udev rule for a stable /dev/rdm-reader, and
install.sh. Covered by unit tests plus PTY end-to-end tests.
This commit is contained in:
2026-10-01 15:31:04 +03:00
commit 576f22268f
26 changed files with 3771 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
"""Card data model, protocol constants and output formatting.
Nothing in this module touches hardware or the serial port, which keeps the
data model easy to unit test.
"""
from __future__ import annotations
import json
import time
from collections import namedtuple
from dataclasses import dataclass
from typing import Any, Dict
try: # pragma: no cover - depends on the environment
from rdm6300 import CardData
except ImportError: # pragma: no cover - fallback so the model stays importable
CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"])
#: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi
#: default, the others cover USB / GPIO UART dongles.
CANDIDATE_PORTS = (
"/dev/ttyS0",
"/dev/ttyAMA0",
"/dev/ttyUSB0",
"/dev/ttyACM0",
)
#: A special card id used by the WMT workstation app to open its config screen.
#: Reported as a note only - this package never acts on it.
CONFIG_CARD_VALUE = 12886709
#: Seconds without a read before a card is considered removed. Set to ``None``
#: to disable removal detection entirely.
DEFAULT_HEARTBEAT_INTERVAL = 0.5
BAR = "-" * 58
EVENT_INSERTED = "card_inserted"
EVENT_REMOVED = "card_removed"
EVENT_INVALID = "invalid_card"
def now_stamp() -> str:
"""Local timestamp in the same format the workstation app logs."""
return time.strftime("%Y-%m-%d %H:%M:%S")
@dataclass(frozen=True)
class CardEvent:
"""A single card read event, decoupled from the reader library."""
event: str
timestamp: str
value: int
checksum: int
card_type: int
is_valid: bool
port: str
# -- constructors -----------------------------------------------------
@classmethod
def from_card(cls, event: str, card: Any, port: str) -> "CardEvent":
"""Build an event from an ``rdm6300.CardData`` (or anything alike)."""
return cls(
event=event,
timestamp=now_stamp(),
value=int(card.value),
checksum=int(card.checksum),
card_type=int(card.type),
is_valid=bool(card.is_valid),
port=str(port),
)
# -- derived values ---------------------------------------------------
@property
def hex_value(self) -> str:
"""Card id as an 8 digit hex string, e.g. ``0x00C4A0B5``."""
return "0x{:08X}".format(self.value)
@property
def is_config_card(self) -> bool:
return self.value == CONFIG_CARD_VALUE
# -- serialisation ----------------------------------------------------
def to_dict(self) -> Dict[str, Any]:
"""Machine-readable form, used by ``--json``."""
return {
"event": self.event,
"timestamp": self.timestamp,
"id": self.value,
"hex": self.hex_value,
"type": self.card_type,
"checksum": self.checksum,
"is_valid": self.is_valid,
"port": self.port,
}
def to_json(self) -> str:
return json.dumps(self.to_dict())
def format_event(event: CardEvent) -> str:
"""Render an event as the human-readable block printed on stdout."""
if event.event == EVENT_INSERTED:
lines = [
"",
BAR,
" CARD INSERTED - {}".format(event.timestamp),
" id : {} ({})".format(event.value, event.hex_value),
" type : 0x{:02X}".format(event.card_type),
" checksum : {}".format(event.checksum),
" valid : {}".format(event.is_valid),
" port : {}".format(event.port),
]
if event.is_config_card:
lines.append(
" note : this is the config card (id {})".format(
CONFIG_CARD_VALUE
)
)
lines.append(BAR)
return "\n".join(lines)
if event.event == EVENT_REMOVED:
return " CARD REMOVED - {} id: {} ({})".format(
event.timestamp, event.value, event.hex_value
)
return " INVALID CARD - {} raw: {} (checksum mismatch)".format(
event.timestamp, event.hex_value
)