576f22268f
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.
136 lines
4.1 KiB
Python
136 lines
4.1 KiB
Python
"""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
|
|
)
|