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:
@@ -0,0 +1,51 @@
|
||||
"""Reading RDM6300 (EM4100) RFID cards as simple insert / remove events.
|
||||
|
||||
Two ways to use this package:
|
||||
|
||||
* as a plain reader that prints what happens (``rdm-card-reader``)
|
||||
* as a small HTTP bridge that publishes card events for a web app
|
||||
(``rdm-card-reader --serve``)
|
||||
|
||||
The bridge reports card ids only. Deciding which user a card belongs to is the
|
||||
server's job - see ``bridge`` for the reasoning.
|
||||
"""
|
||||
|
||||
from .card import (
|
||||
CANDIDATE_PORTS,
|
||||
CONFIG_CARD_VALUE,
|
||||
CardData,
|
||||
CardEvent,
|
||||
format_event,
|
||||
)
|
||||
from .bridge import EventHub, create_server
|
||||
from .ports import (
|
||||
AmbiguousPort,
|
||||
PortError,
|
||||
PortInfo,
|
||||
PortNotFound,
|
||||
discover,
|
||||
resolve,
|
||||
)
|
||||
from .reader import available, list_ports, open_reader
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
__all__ = [
|
||||
"CANDIDATE_PORTS",
|
||||
"CONFIG_CARD_VALUE",
|
||||
"CardData",
|
||||
"CardEvent",
|
||||
"AmbiguousPort",
|
||||
"EventHub",
|
||||
"PortError",
|
||||
"PortInfo",
|
||||
"PortNotFound",
|
||||
"available",
|
||||
"create_server",
|
||||
"discover",
|
||||
"format_event",
|
||||
"list_ports",
|
||||
"open_reader",
|
||||
"resolve",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Allow ``python -m rdm_card_reader``."""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,503 @@
|
||||
"""HTTP bridge: expose card taps to a web page, without ever becoming the
|
||||
authority on who the card belongs to.
|
||||
|
||||
The reader is a *sensor*. This module publishes what the sensor sees and stops
|
||||
there - resolving a card id to a user is the server's job, so nothing here
|
||||
writes to a database, mints session tokens or decides whether a login succeeds.
|
||||
|
||||
Served by default:
|
||||
|
||||
============================ ============================================
|
||||
``GET /api/health`` reader state, port, uptime, last tap age
|
||||
``GET /api/card`` the card on the reader right now (latched)
|
||||
``POST /api/card/claim`` claim that tap, single use, for one login
|
||||
``GET /api/events`` Server-Sent Events stream of card events
|
||||
``GET /api/last`` most recent card event (or 204)
|
||||
``GET /api/taps`` recent tap history (ring buffer)
|
||||
``POST /api/mode`` switch reader mode: login / monitor
|
||||
============================ ============================================
|
||||
|
||||
The usual integration, for a web page that may load at any moment::
|
||||
|
||||
const r = await fetch("http://127.0.0.1:8765/api/card");
|
||||
const state = await r.json();
|
||||
if (state.claimable) {
|
||||
const c = await fetch("http://127.0.0.1:8765/api/card/claim", {method: "POST"});
|
||||
if (c.ok) {
|
||||
const { card } = await c.json();
|
||||
// hand card.id to your server; it decides who that is
|
||||
}
|
||||
}
|
||||
|
||||
``claimable`` stays true for ``--tap-ttl`` seconds after the card is read -
|
||||
including after the card is lifted - so a quick tap is not lost.
|
||||
|
||||
Security posture (important, and deliberate):
|
||||
|
||||
* Binds to ``127.0.0.1`` unless the operator explicitly opts in to more, because
|
||||
an open card-event endpoint on a LAN lets anyone read who is tapping.
|
||||
* Cross-origin requests are refused unless the origin is on an allow-list.
|
||||
A page on another site must not be able to observe your taps.
|
||||
* Optionally requires a pre-shared token (``--token`` / ``RDM_BRIDGE_TOKEN``)
|
||||
for non-local callers.
|
||||
|
||||
An EM4100 card id is a *username*, not a password: it is broadcast in the clear
|
||||
and can be cloned. Never let holding a card id alone create a session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Callable, Deque, Dict, List, Optional
|
||||
|
||||
from .card import EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
||||
|
||||
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
||||
#: attempt). ``monitor`` publishes inserts and removals both.
|
||||
MODE_LOGIN = "login"
|
||||
MODE_MONITOR = "monitor"
|
||||
VALID_MODES = (MODE_LOGIN, MODE_MONITOR)
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8765
|
||||
DEFAULT_HISTORY = 50
|
||||
|
||||
#: How long a tap stays claimable. This deliberately outlives the card
|
||||
#: leaving the reader: a quick tap that is lifted straight away must still be
|
||||
#: claimable, otherwise a page that polls a moment later sees nothing and the
|
||||
#: login silently fails. Set to 0 to keep a tap claimable until it is used or
|
||||
#: replaced.
|
||||
DEFAULT_TAP_TTL = 10.0
|
||||
|
||||
|
||||
class EventHub:
|
||||
"""Tracks reader state and fans card events out to waiting subscribers.
|
||||
|
||||
This is the only place the raw card id is held. Subscribers are HTTP
|
||||
connections owned by the same process, never third-party code.
|
||||
|
||||
The card currently read is *latched*: it stays readable through
|
||||
:meth:`current_card` after the card has left the reader, for ``tap_ttl``
|
||||
seconds from the tap. That is what makes the data "always available" to a
|
||||
web page that may load or poll at any moment.
|
||||
|
||||
Claiming it through :meth:`consume_card` marks it used, so a page reload or
|
||||
a second tab cannot replay the same physical tap into a second login.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history: int = DEFAULT_HISTORY,
|
||||
mode: str = MODE_LOGIN,
|
||||
tap_ttl: float = DEFAULT_TAP_TTL,
|
||||
):
|
||||
self._lock = threading.Lock()
|
||||
self._subscribers: List["deque"] = []
|
||||
self._history: Deque[CardEvent] = deque(maxlen=history)
|
||||
self._last: Optional[CardEvent] = None
|
||||
self._last_at: Optional[float] = None
|
||||
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
|
||||
self._started_at = time.time()
|
||||
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, "invalid_card": 0}
|
||||
self._port: Optional[str] = None
|
||||
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
|
||||
|
||||
# The latched tap. It deliberately outlives the card leaving the
|
||||
# reader, so a page that loads after the tap can still claim it.
|
||||
self._tap: Optional[CardEvent] = None
|
||||
self._tap_at: Optional[float] = None
|
||||
self._on_reader = False
|
||||
self._consumed_at: Optional[float] = None
|
||||
|
||||
# -- state ------------------------------------------------------------
|
||||
|
||||
def set_port(self, port: str) -> None:
|
||||
with self._lock:
|
||||
self._port = port
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
with self._lock:
|
||||
return self._mode
|
||||
|
||||
def set_mode(self, mode: str) -> bool:
|
||||
"""Switch mode. Returns False for an unknown mode."""
|
||||
if mode not in VALID_MODES:
|
||||
return False
|
||||
with self._lock:
|
||||
self._mode = mode
|
||||
return True
|
||||
|
||||
def publish(self, event: CardEvent) -> None:
|
||||
"""Record an event and wake every subscriber. Safe from any thread."""
|
||||
with self._lock:
|
||||
self._counts[event.event] = self._counts.get(event.event, 0) + 1
|
||||
|
||||
# Presence tracking always happens, in every mode: the latched tap
|
||||
# is state, not a published event.
|
||||
if event.event == EVENT_INSERTED:
|
||||
self._last = event
|
||||
self._last_at = time.time()
|
||||
# A new tap replaces any previous unclaimed one.
|
||||
self._tap = event
|
||||
self._tap_at = self._last_at
|
||||
self._on_reader = True
|
||||
self._consumed_at = None
|
||||
elif event.event == EVENT_REMOVED:
|
||||
# Lifting the card does NOT discard the tap: it must stay
|
||||
# claimable for tap_ttl, otherwise a quick tap is lost before
|
||||
# the web page gets a chance to ask for it.
|
||||
self._on_reader = False
|
||||
|
||||
# In login mode a tap is an authentication attempt; card removal is
|
||||
# not published, so a web app cannot mistake it for a second tap.
|
||||
if self._mode == MODE_LOGIN and event.event == EVENT_REMOVED:
|
||||
return
|
||||
|
||||
self._history.append(event)
|
||||
queues = list(self._subscribers)
|
||||
payload = event.to_dict()
|
||||
|
||||
for queue in queues:
|
||||
try:
|
||||
queue.append(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- latched card -----------------------------------------------------
|
||||
|
||||
def current_card(self) -> Dict[str, Any]:
|
||||
"""The latched tap, so a page can ask at any moment.
|
||||
|
||||
``present`` is whether the card is physically on the reader right now;
|
||||
``claimable`` is whether this tap can still be spent on a login.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._tap is None:
|
||||
return {
|
||||
"present": False,
|
||||
"claimable": False,
|
||||
"consumed": False,
|
||||
"expired": False,
|
||||
"age_seconds": None,
|
||||
"card": None,
|
||||
}
|
||||
age = time.time() - (self._tap_at or 0.0)
|
||||
expired = bool(self._tap_ttl) and age > self._tap_ttl
|
||||
consumed = self._consumed_at is not None
|
||||
return {
|
||||
"present": self._on_reader,
|
||||
"claimable": (not consumed) and (not expired),
|
||||
"consumed": consumed,
|
||||
"expired": expired,
|
||||
"age_seconds": round(age, 1),
|
||||
"card": self._tap.to_dict(),
|
||||
}
|
||||
|
||||
def consume_card(self):
|
||||
"""Atomically claim the latched tap for one authentication.
|
||||
|
||||
Returns ``(payload, error, status)``. The tap is marked used only when
|
||||
this call succeeds, so two concurrent requests cannot both win.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._tap is None:
|
||||
return None, "no card read yet", 409
|
||||
|
||||
age = time.time() - (self._tap_at or 0.0)
|
||||
if self._tap_ttl and age > self._tap_ttl:
|
||||
return None, "card tap expired", 410
|
||||
if self._consumed_at is not None:
|
||||
return None, "card already used", 409
|
||||
|
||||
self._consumed_at = time.time()
|
||||
return (
|
||||
{
|
||||
"card": self._tap.to_dict(),
|
||||
"age_seconds": round(age, 1),
|
||||
"consumed": True,
|
||||
},
|
||||
None,
|
||||
200,
|
||||
)
|
||||
|
||||
def subscribe(self) -> "deque":
|
||||
"""Register a queue that receives every subsequent event."""
|
||||
queue: Deque[Dict[str, Any]] = deque(maxlen=100)
|
||||
with self._lock:
|
||||
self._subscribers.append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: "deque") -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subscribers.remove(queue)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# -- snapshots --------------------------------------------------------
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
last_age = (
|
||||
round(time.time() - self._last_at, 1) if self._last_at else None
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"port": self._port,
|
||||
"mode": self._mode,
|
||||
"uptime_seconds": round(time.time() - self._started_at, 1),
|
||||
"subscribers": len(self._subscribers),
|
||||
"counts": dict(self._counts),
|
||||
"last_tap": self._last.to_dict() if self._last else None,
|
||||
"last_tap_age_seconds": last_age,
|
||||
"card_present": self._on_reader,
|
||||
"card_consumed": self._consumed_at is not None,
|
||||
"tap_ttl_seconds": self._tap_ttl or None,
|
||||
}
|
||||
|
||||
def last(self) -> Optional[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
return self._last.to_dict() if self._last else None
|
||||
|
||||
def taps(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
items = list(self._history)[-max(1, limit):]
|
||||
return [event.to_dict() for event in items]
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""One HTTP connection. ``server.hub`` holds the shared state."""
|
||||
|
||||
server_version = "rdm-card-reader"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def _cors_ok(self) -> bool:
|
||||
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
||||
origin = self.headers.get("Origin")
|
||||
if not origin:
|
||||
return True # same-origin fetch or a non-browser client
|
||||
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
||||
if not allowed:
|
||||
return False # browsers only; opt out entirely
|
||||
return origin in allowed
|
||||
|
||||
def _token_ok(self) -> bool:
|
||||
"""Check the pre-shared token for non-loopback callers."""
|
||||
required: Optional[str] = getattr(self.server, "token", None)
|
||||
if not required:
|
||||
return True
|
||||
client = self.client_address[0]
|
||||
if client in ("127.0.0.1", "::1", "localhost"):
|
||||
return True
|
||||
supplied = self.headers.get("X-Auth-Token") or ""
|
||||
if not supplied:
|
||||
query = self.path.split("?", 1)[1] if "?" in self.path else ""
|
||||
for pair in query.split("&"):
|
||||
if pair.startswith("token="):
|
||||
supplied = pair.split("=", 1)[1]
|
||||
return secrets.compare_digest(supplied, required)
|
||||
|
||||
def _send_json(self, payload: Any, status: int = 200) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_no_content(self) -> None:
|
||||
self.send_response(204)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", "0")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
logger: Optional[Callable[[str], None]] = getattr(self.server, "logger", None)
|
||||
if logger:
|
||||
logger("{} - {}".format(self.client_address[0], fmt % args))
|
||||
|
||||
@property
|
||||
def _hub(self) -> EventHub:
|
||||
return self.server.hub # type: ignore[attr-defined]
|
||||
|
||||
# -- routing ----------------------------------------------------------
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
|
||||
if path == "/api/health":
|
||||
self._send_json(self._hub.health())
|
||||
return
|
||||
|
||||
if not self._cors_ok():
|
||||
self._send_json({"error": "origin not allowed"}, status=403)
|
||||
return
|
||||
if not self._token_ok():
|
||||
self._send_json({"error": "missing or invalid token"}, status=401)
|
||||
return
|
||||
|
||||
if path == "/api/last":
|
||||
payload = self._hub.last()
|
||||
if payload is None:
|
||||
self._send_no_content()
|
||||
else:
|
||||
self._send_json(payload)
|
||||
return
|
||||
|
||||
if path == "/api/card":
|
||||
self._send_json(self._hub.current_card())
|
||||
return
|
||||
|
||||
if path == "/api/taps":
|
||||
limit = 20
|
||||
if "?" in self.path:
|
||||
for pair in self.path.split("?", 1)[1].split("&"):
|
||||
if pair.startswith("limit="):
|
||||
try:
|
||||
limit = int(pair.split("=", 1)[1])
|
||||
except ValueError:
|
||||
pass
|
||||
self._send_json({"taps": self._hub.taps(limit)})
|
||||
return
|
||||
|
||||
if path == "/api/events":
|
||||
self._stream_events()
|
||||
return
|
||||
|
||||
self._send_json({"error": "not found", "path": path}, status=404)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
|
||||
if path == "/api/card/claim":
|
||||
if not self._cors_ok():
|
||||
self._send_json({"error": "origin not allowed"}, status=403)
|
||||
return
|
||||
if not self._token_ok():
|
||||
self._send_json({"error": "missing or invalid token"}, status=401)
|
||||
return
|
||||
payload, error, status = self._hub.consume_card()
|
||||
if error:
|
||||
self._send_json(
|
||||
{"error": error, "state": self._hub.current_card()},
|
||||
status=status,
|
||||
)
|
||||
return
|
||||
self._send_json(payload, status=status)
|
||||
return
|
||||
|
||||
if path != "/api/mode":
|
||||
self._send_json({"error": "not found", "path": path}, status=404)
|
||||
return
|
||||
if not self._cors_ok():
|
||||
self._send_json({"error": "origin not allowed"}, status=403)
|
||||
return
|
||||
if not self._token_ok():
|
||||
self._send_json({"error": "missing or invalid token"}, status=401)
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||||
except ValueError:
|
||||
self._send_json({"error": "invalid JSON body"}, status=400)
|
||||
return
|
||||
|
||||
mode = str(payload.get("mode", ""))
|
||||
if not self._hub.set_mode(mode):
|
||||
self._send_json(
|
||||
{"error": "invalid mode", "valid": list(VALID_MODES)}, status=400
|
||||
)
|
||||
return
|
||||
self._send_json({"mode": mode})
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
origin = self.headers.get("Origin")
|
||||
self.send_response(204)
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
||||
self.send_header("Vary", "Origin")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
# -- Server-Sent Events ----------------------------------------------
|
||||
|
||||
def _stream_events(self) -> None:
|
||||
"""Hold the connection open and push each event as it happens."""
|
||||
queue = self._hub.subscribe()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin and origin in getattr(self.server, "allowed_origins", []):
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
try:
|
||||
self.wfile.write(b": connected\n\n")
|
||||
self.wfile.flush()
|
||||
while True:
|
||||
try:
|
||||
payload = queue.popleft()
|
||||
except IndexError:
|
||||
payload = None
|
||||
|
||||
if payload is not None:
|
||||
message = "data: {}\n\n".format(json.dumps(payload))
|
||||
self.wfile.write(message.encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
else:
|
||||
# Comment frame keeps proxies and the browser from timing out.
|
||||
self.wfile.write(b": keepalive\n\n")
|
||||
self.wfile.flush()
|
||||
time.sleep(0.2)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
self._hub.unsubscribe(queue)
|
||||
|
||||
|
||||
def create_server(
|
||||
hub: EventHub,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
allowed_origins: Optional[List[str]] = None,
|
||||
token: Optional[str] = None,
|
||||
logger: Optional[Callable[[str], None]] = None,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build (but do not start) the HTTP server around *hub*."""
|
||||
server = ThreadingHTTPServer((host, port), _Handler)
|
||||
server.daemon_threads = True
|
||||
server.hub = hub # type: ignore[attr-defined]
|
||||
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
||||
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
||||
server.logger = logger # type: ignore[attr-defined]
|
||||
return server
|
||||
|
||||
|
||||
def is_loopback(host: str) -> bool:
|
||||
"""True for hosts that are not reachable off-box."""
|
||||
return host in ("127.0.0.1", "::1", "localhost")
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Command line interface for the RDM card reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from . import __version__
|
||||
from .bridge import (
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_TAP_TTL,
|
||||
MODE_LOGIN,
|
||||
VALID_MODES,
|
||||
EventHub,
|
||||
create_server,
|
||||
)
|
||||
from .card import (
|
||||
BAR,
|
||||
CANDIDATE_PORTS,
|
||||
DEFAULT_HEARTBEAT_INTERVAL,
|
||||
CardEvent,
|
||||
format_event,
|
||||
)
|
||||
from .demo import run_demo
|
||||
from .ports import AmbiguousPort, PortNotFound, discover, resolve
|
||||
from .reader import available, explain_error, list_ports, open_reader
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_NO_PORT = 1
|
||||
EXIT_NO_LIBRARY = 2
|
||||
EXIT_PORT_AMBIGUOUS = 3
|
||||
|
||||
|
||||
def _make_printer(as_json: bool):
|
||||
"""Return a callback that writes one event to stdout."""
|
||||
|
||||
def print_event(event: CardEvent) -> None:
|
||||
if as_json:
|
||||
print(event.to_json(), flush=True)
|
||||
else:
|
||||
print(format_event(event), flush=True)
|
||||
|
||||
return print_event
|
||||
|
||||
|
||||
def _show_ports(verbose: bool = False) -> int:
|
||||
info = list_ports()
|
||||
print("Candidate serial ports (tried in this order):")
|
||||
for port, exists in info["candidates"]:
|
||||
print(" {} {}".format("OK " if exists else "---", port))
|
||||
|
||||
ports = discover()
|
||||
if ports:
|
||||
print("\nIdentified serial ports (best candidate first):")
|
||||
for entry in sorted(ports, key=lambda p: (-p.score(), p.device)):
|
||||
print(" [score {:>3}] {}".format(entry.score(), entry.describe()))
|
||||
if verbose:
|
||||
stable = entry.stable_id or "(none)"
|
||||
print(" by-id : {}".format(stable))
|
||||
print(" by-path : {}".format(entry.by_path or "(none)"))
|
||||
print(" location : {}".format(entry.location or "(unknown)"))
|
||||
print(" usb id : {}".format(entry.usb_id or "(not USB)"))
|
||||
print(" driver : {}".format(entry.product or "(unknown)"))
|
||||
else:
|
||||
print("\n(no serial ports identified)")
|
||||
|
||||
print("\nTip: pass --port with a serial number or by-id path so the same")
|
||||
print(" physical adapter is always used, e.g.")
|
||||
print(" --port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _resolve_port(args) -> str:
|
||||
"""Work out which serial port to open, or exit with a clear reason."""
|
||||
selectors = [args.port] if args.port else None
|
||||
try:
|
||||
device, reason = resolve(
|
||||
selectors=selectors,
|
||||
fallback_ports=[args.port] if args.port else list(CANDIDATE_PORTS),
|
||||
autodetect=args.autodetect,
|
||||
)
|
||||
except AmbiguousPort as exc:
|
||||
print("x {}".format(exc), file=sys.stderr)
|
||||
print(" Refusing to guess which reader is the right one.", file=sys.stderr)
|
||||
sys.exit(EXIT_PORT_AMBIGUOUS)
|
||||
except PortNotFound as exc:
|
||||
print("x {}".format(exc), file=sys.stderr)
|
||||
sys.exit(EXIT_NO_PORT)
|
||||
|
||||
print("-> Using {} ({})".format(device, reason), flush=True)
|
||||
return device
|
||||
|
||||
|
||||
def _run_bridge(args) -> int:
|
||||
"""Serve what the reader sees over HTTP for a web app to consume."""
|
||||
if not available():
|
||||
print("ERROR: the 'rdm6300' library is not installed.", file=sys.stderr)
|
||||
print(" Install it with: pip install rdm6300", file=sys.stderr)
|
||||
print(" Or run with --demo to see the output format.", file=sys.stderr)
|
||||
return EXIT_NO_LIBRARY
|
||||
|
||||
if not args.allow_remote and args.host not in ("127.0.0.1", "::1", "localhost"):
|
||||
print("ERROR: refusing to bind {} without --allow-remote.".format(args.host),
|
||||
file=sys.stderr)
|
||||
print(" A card-event endpoint on the LAN exposes who taps where.",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
device = _resolve_port(args)
|
||||
|
||||
hub = EventHub(mode=args.mode, tap_ttl=args.tap_ttl)
|
||||
hub.set_port(device)
|
||||
|
||||
def emit(event: CardEvent) -> None:
|
||||
hub.publish(event)
|
||||
if args.verbose:
|
||||
print(format_event(event), flush=True)
|
||||
|
||||
def report_error(port: str, message: str) -> None:
|
||||
print(" x {}: {}".format(port, explain_error(port, message)), flush=True)
|
||||
|
||||
reader = open_reader(emit, ports=[device],
|
||||
heartbeat_interval=args.heartbeat,
|
||||
on_error=report_error)
|
||||
if reader is None:
|
||||
print("x Could not open {}. Is another process using it?".format(device),
|
||||
file=sys.stderr)
|
||||
return EXIT_NO_PORT
|
||||
|
||||
server = create_server(
|
||||
hub,
|
||||
host=args.host,
|
||||
port=args.bridge_port,
|
||||
allowed_origins=args.allow_origin,
|
||||
token=args.token,
|
||||
logger=print if args.verbose else None,
|
||||
)
|
||||
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True,
|
||||
name="bridge-http")
|
||||
server_thread.start()
|
||||
|
||||
print(" OK reader on {} ; bridge on http://{}:{} (mode: {})".format(
|
||||
device, args.host, args.bridge_port, args.mode))
|
||||
print(" GET /api/card <- the card on the reader right now")
|
||||
print(" POST /api/card/claim (single use, for one login)")
|
||||
print(" GET /api/events (SSE stream)")
|
||||
print(" GET /api/last /api/health")
|
||||
if args.token:
|
||||
print(" token required for non-local callers")
|
||||
if not args.allow_origin:
|
||||
print(" note: cross-origin browser requests are refused")
|
||||
print(" pass --allow-origin https://your.app to permit one")
|
||||
print(" Ctrl+C to stop.", flush=True)
|
||||
|
||||
# The reader loop blocks this thread; the HTTP server runs alongside it.
|
||||
try:
|
||||
reader.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped by user.")
|
||||
finally:
|
||||
try:
|
||||
reader.close()
|
||||
except Exception:
|
||||
pass
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="rdm-card-reader",
|
||||
description="Read RFID cards (RDM6300 / EM4100) and print the card "
|
||||
"inserted and card removed output.",
|
||||
)
|
||||
parser.add_argument("--version", action="version",
|
||||
version="%(prog)s {}".format(__version__))
|
||||
parser.add_argument("--port", help="serial port: device path, by-id symlink, "
|
||||
"USB serial number or vid:pid")
|
||||
parser.add_argument("--autodetect", action="store_true",
|
||||
help="pick the only USB serial adapter; refuse if unsure")
|
||||
parser.add_argument("--heartbeat", type=float,
|
||||
default=DEFAULT_HEARTBEAT_INTERVAL,
|
||||
help="seconds without a read before the card is reported "
|
||||
"as removed (default: %(default)s)")
|
||||
parser.add_argument("--json", action="store_true",
|
||||
help="print one JSON object per event")
|
||||
parser.add_argument("--list-ports", action="store_true",
|
||||
help="list candidate and identified ports, then exit")
|
||||
parser.add_argument("--verbose-ports", action="store_true",
|
||||
help="with --list-ports, show by-id / by-path detail")
|
||||
parser.add_argument("--demo", action="store_true",
|
||||
help="simulate card reads (needs no hardware or library)")
|
||||
parser.add_argument("--demo-count", type=int, default=3,
|
||||
help="number of simulated reads in --demo (default: %(default)s)")
|
||||
|
||||
bridge = parser.add_argument_group("HTTP bridge (--serve)")
|
||||
bridge.add_argument("--serve", action="store_true",
|
||||
help="serve card events over HTTP for a web app")
|
||||
bridge.add_argument("--host", default=DEFAULT_HOST,
|
||||
help="bind address (default: %(default)s, loopback only)")
|
||||
bridge.add_argument("--bridge-port", type=int, default=DEFAULT_PORT,
|
||||
help="HTTP port (default: %(default)s)")
|
||||
bridge.add_argument("--mode", default=MODE_LOGIN, choices=list(VALID_MODES),
|
||||
help="login: publish taps only; monitor: taps and removals "
|
||||
"(default: %(default)s)")
|
||||
bridge.add_argument("--tap-ttl", type=float, default=DEFAULT_TAP_TTL,
|
||||
help="seconds an unclaimed tap stays claimable; 0 keeps it "
|
||||
"claimable while the card is present (default: %(default)s)")
|
||||
bridge.add_argument("--allow-origin", action="append", metavar="ORIGIN",
|
||||
help="permit this browser origin to call the API; repeatable")
|
||||
bridge.add_argument("--token",
|
||||
help="pre-shared token required from non-local callers "
|
||||
"(or set RDM_BRIDGE_TOKEN)")
|
||||
bridge.add_argument("--allow-remote", action="store_true",
|
||||
help="permit binding a non-loopback address")
|
||||
bridge.add_argument("--verbose", action="store_true",
|
||||
help="also print each event and each HTTP request")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
if args.list_ports:
|
||||
return _show_ports(verbose=args.verbose_ports)
|
||||
|
||||
if args.demo:
|
||||
return run_demo(count=args.demo_count, heartbeat=args.heartbeat,
|
||||
as_json=args.json)
|
||||
|
||||
if args.serve:
|
||||
return _run_bridge(args)
|
||||
|
||||
if not available():
|
||||
print("ERROR: the 'rdm6300' library is not installed.", file=sys.stderr)
|
||||
print(" Install it with: pip install rdm6300", file=sys.stderr)
|
||||
print(" Or run with --demo to see the output format.", file=sys.stderr)
|
||||
return EXIT_NO_LIBRARY
|
||||
|
||||
print_event = _make_printer(args.json)
|
||||
ports = [args.port] if args.port else list(CANDIDATE_PORTS)
|
||||
|
||||
def report_error(port: str, message: str) -> None:
|
||||
print(" x {}: {}".format(port, explain_error(port, message)), flush=True)
|
||||
|
||||
for port in ports:
|
||||
print("-> Opening {} ...".format(port), flush=True)
|
||||
|
||||
reader = open_reader(print_event, ports=ports,
|
||||
heartbeat_interval=args.heartbeat,
|
||||
on_error=report_error)
|
||||
|
||||
if reader is None:
|
||||
print("\nx Could not open any serial port. Check:", file=sys.stderr)
|
||||
print(" 1. the reader is connected (rdm-card-reader --list-ports)",
|
||||
file=sys.stderr)
|
||||
print(" 2. UART is enabled: sudo raspi-config -> Interface Options -> Serial",
|
||||
file=sys.stderr)
|
||||
print(" 3. the user is in dialout: sudo usermod -aG dialout $USER",
|
||||
file=sys.stderr)
|
||||
return EXIT_NO_PORT
|
||||
|
||||
print(" OK reader ready on {} - waiting for cards (Ctrl+C to stop)".format(
|
||||
reader.port), flush=True)
|
||||
|
||||
try:
|
||||
reader.start() # blocks; events arrive through the callback
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped by user.")
|
||||
finally:
|
||||
try:
|
||||
reader.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Simulated card reads, so the output can be checked without hardware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from .card import (
|
||||
DEFAULT_HEARTBEAT_INTERVAL,
|
||||
CardData,
|
||||
CardEvent,
|
||||
format_event,
|
||||
)
|
||||
|
||||
# Card ids with the checksum the real protocol would produce
|
||||
# (XOR of the type byte and the four id bytes), so the output looks genuine.
|
||||
DEMO_CARDS = (
|
||||
CardData(value=0x00C4A0B5, checksum=0xDE, type=0x0F, is_valid=True),
|
||||
CardData(value=0x1A2B3C4D, checksum=0x4F, type=0x0F, is_valid=True),
|
||||
CardData(value=0xDEADBEEF, checksum=0x00, type=0x0F, is_valid=False),
|
||||
)
|
||||
|
||||
|
||||
def _emit(event: CardEvent, as_json: bool) -> None:
|
||||
print(event.to_json() if as_json else format_event(event), flush=True)
|
||||
|
||||
|
||||
def run_demo(
|
||||
count: int = 3,
|
||||
heartbeat: float = DEFAULT_HEARTBEAT_INTERVAL,
|
||||
as_json: bool = False,
|
||||
seed: int = 1234,
|
||||
) -> int:
|
||||
"""Print *count* simulated card read cycles. Returns a process exit code."""
|
||||
rng = random.Random(seed)
|
||||
print("Demo mode - no hardware is used, values are simulated.\n", flush=True)
|
||||
|
||||
for _ in range(max(1, count)):
|
||||
card = rng.choice(DEMO_CARDS)
|
||||
event_name = "card_inserted" if card.is_valid else "invalid_card"
|
||||
_emit(CardEvent.from_card(event_name, card, "demo"), as_json)
|
||||
|
||||
time.sleep(max(0.1, heartbeat))
|
||||
if card.is_valid:
|
||||
_emit(CardEvent.from_card("card_removed", card, "demo"), as_json)
|
||||
time.sleep(0.3)
|
||||
|
||||
print("\nDemo finished.", flush=True)
|
||||
return 0
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Identify serial ports reliably, so the reader does not grab the wrong ttyUSB.
|
||||
|
||||
A bare ``/dev/ttyUSB0`` is not a stable identity: unplug two adapters and the
|
||||
numbers swap. This module answers "is this really the RFID reader?" using the
|
||||
things that *are* stable:
|
||||
|
||||
* the USB serial number (``/dev/serial/by-id``)
|
||||
* the physical USB port (``/dev/serial/by-path``)
|
||||
* the USB vendor:product id and the adapter description
|
||||
|
||||
Use :func:`resolve` to turn a user-supplied selector into exactly one port,
|
||||
refusing to guess when the answer is ambiguous.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
#: USB-serial bridge chips commonly found on RDM6300 / Arduino adapters.
|
||||
#: Mapping of ``vid:pid`` -> friendly name. Used for scoring, not required.
|
||||
KNOWN_USB_SERIAL_CHIPS: Dict[Tuple[int, int], str] = {
|
||||
(0x1A86, 0x7523): "CH340",
|
||||
(0x1A86, 0x5523): "CH341",
|
||||
(0x1A86, 0x55D4): "CH9102",
|
||||
(0x0403, 0x6001): "FT232R",
|
||||
(0x0403, 0x6015): "FT231X",
|
||||
(0x10C4, 0xEA60): "CP2102",
|
||||
(0x10C4, 0xEA70): "CP2105",
|
||||
(0x067B, 0x2303): "PL2303",
|
||||
(0x2341, 0x0043): "Arduino Uno",
|
||||
(0x1A86, 0x55D3): "CH343",
|
||||
}
|
||||
|
||||
#: Description fragments that suggest a generic UART bridge.
|
||||
_SERIAL_HINTS = ("serial", "uart", "usb-serial", "usbserial", "ttyusb", "ttyacm")
|
||||
|
||||
|
||||
class PortError(RuntimeError):
|
||||
"""Base class for port selection problems."""
|
||||
|
||||
|
||||
class PortNotFound(PortError):
|
||||
"""No port matched the requested selector."""
|
||||
|
||||
|
||||
class AmbiguousPort(PortError):
|
||||
"""More than one port matched; picking one silently would be unsafe."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortInfo:
|
||||
"""Everything stable we can learn about one serial port."""
|
||||
|
||||
device: str
|
||||
description: str = ""
|
||||
hwid: str = ""
|
||||
vid: Optional[int] = None
|
||||
pid: Optional[int] = None
|
||||
serial_number: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
product: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
aliases: List[str] = field(default_factory=list)
|
||||
|
||||
# -- derived ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_usb(self) -> bool:
|
||||
"""True when this port is a USB device (as opposed to an on-board UART)."""
|
||||
return self.vid is not None or "usb" in self.device.lower()
|
||||
|
||||
@property
|
||||
def usb_id(self) -> Optional[str]:
|
||||
"""``"1a86:7523"`` when both ids are known."""
|
||||
if self.vid is None or self.pid is None:
|
||||
return None
|
||||
return "{:04x}:{:04x}".format(self.vid, self.pid)
|
||||
|
||||
@property
|
||||
def chip(self) -> Optional[str]:
|
||||
"""Friendly name of the USB-serial bridge, when recognised."""
|
||||
if self.vid is None or self.pid is None:
|
||||
return None
|
||||
return KNOWN_USB_SERIAL_CHIPS.get((self.vid, self.pid))
|
||||
|
||||
@property
|
||||
def stable_id(self) -> Optional[str]:
|
||||
"""A ``/dev/serial/by-id`` path, when the kernel provides one.
|
||||
|
||||
This is the value to persist in a config file - it survives replugging
|
||||
and reordering.
|
||||
"""
|
||||
for alias in self.aliases:
|
||||
if "/by-id/" in alias:
|
||||
return alias
|
||||
return None
|
||||
|
||||
@property
|
||||
def by_path(self) -> Optional[str]:
|
||||
"""A ``/dev/serial/by-path`` path - stable per physical USB socket."""
|
||||
for alias in self.aliases:
|
||||
if "/by-path/" in alias:
|
||||
return alias
|
||||
return None
|
||||
|
||||
def score(self) -> int:
|
||||
"""Likely-to-be-an-RFID-adapter score. Higher is better.
|
||||
|
||||
Deliberately conservative: a plain on-board ``/dev/ttyS0`` scores low
|
||||
so that a real USB adapter wins the autodetect race.
|
||||
"""
|
||||
value = 0
|
||||
if self.chip:
|
||||
value += 60
|
||||
elif self.is_usb:
|
||||
value += 40
|
||||
if self.serial_number:
|
||||
value += 20 # uniquely identifiable
|
||||
if self.stable_id:
|
||||
value += 10
|
||||
if any(hint in self.description.lower() for hint in _SERIAL_HINTS):
|
||||
value += 10
|
||||
if self.device.startswith("/dev/serial"):
|
||||
value += 15 # kernel-managed alias
|
||||
if self.device.startswith("/dev/ttyS"):
|
||||
value += 5 # on-board UART, still valid but less specific
|
||||
return value
|
||||
|
||||
def _haystacks(self) -> List[str]:
|
||||
parts = [
|
||||
self.device,
|
||||
self.description,
|
||||
self.hwid,
|
||||
self.manufacturer or "",
|
||||
self.product or "",
|
||||
self.serial_number or "",
|
||||
self.usb_id or "",
|
||||
]
|
||||
parts.extend(self.aliases)
|
||||
return [p.lower() for p in parts if p]
|
||||
|
||||
def matches(self, selector: str) -> bool:
|
||||
"""True when *selector* identifies this port.
|
||||
|
||||
Accepts a device path, a ``by-id``/``by-path`` symlink, a serial
|
||||
number, ``vid:pid``, a manufacturer/product fragment or a chip name.
|
||||
"""
|
||||
needle = selector.strip().lower()
|
||||
if not needle:
|
||||
return False
|
||||
if self.device.lower() == needle:
|
||||
return True
|
||||
if self.serial_number and self.serial_number.lower() == needle:
|
||||
return True
|
||||
if self.usb_id and self.usb_id == needle:
|
||||
return True
|
||||
if self.chip and self.chip.lower() == needle:
|
||||
return True
|
||||
return any(needle in hay for hay in self._haystacks())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"device": self.device,
|
||||
"description": self.description,
|
||||
"usb_id": self.usb_id,
|
||||
"chip": self.chip,
|
||||
"serial_number": self.serial_number,
|
||||
"manufacturer": self.manufacturer,
|
||||
"product": self.product,
|
||||
"location": self.location,
|
||||
"stable_id": self.stable_id,
|
||||
"by_path": self.by_path,
|
||||
"is_usb": self.is_usb,
|
||||
"score": self.score(),
|
||||
}
|
||||
|
||||
def describe(self) -> str:
|
||||
"""One-line summary for ``--list-ports``."""
|
||||
bits = [self.device]
|
||||
if self.chip:
|
||||
bits.append(self.chip)
|
||||
elif self.description:
|
||||
bits.append(self.description)
|
||||
if self.usb_id:
|
||||
bits.append(self.usb_id)
|
||||
if self.serial_number:
|
||||
bits.append("S/N {}".format(self.serial_number))
|
||||
return " :: ".join(bits)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _aliases_for(device: str) -> List[str]:
|
||||
"""Collect ``/dev/serial/by-id`` and ``by-path`` symlinks pointing at *device*."""
|
||||
found: List[str] = []
|
||||
for folder in ("by-id", "by-path"):
|
||||
pattern = "/dev/serial/{}/**".format(folder)
|
||||
for link in sorted(glob.glob(pattern, recursive=True)):
|
||||
try:
|
||||
if os.path.realpath(link) == os.path.realpath(device):
|
||||
found.append(link)
|
||||
except OSError:
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
def _info_from_sysfs(device: str) -> PortInfo:
|
||||
"""Fallback identification straight from sysfs (no pyserial needed)."""
|
||||
info = PortInfo(device=device)
|
||||
try:
|
||||
info.aliases = _aliases_for(device)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
name = os.path.basename(device)
|
||||
base = "/sys/class/tty/{}/device".format(name)
|
||||
if not os.path.exists(base):
|
||||
return info
|
||||
|
||||
def _read(relpath: str) -> Optional[str]:
|
||||
try:
|
||||
with open(os.path.join(base, relpath), "r") as handle:
|
||||
return handle.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
vid = _read("../idVendor")
|
||||
pid = _read("../idProduct")
|
||||
if vid:
|
||||
try:
|
||||
info.vid = int(vid, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
if pid:
|
||||
try:
|
||||
info.pid = int(pid, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
info.serial_number = _read("../serial")
|
||||
info.manufacturer = _read("../manufacturer")
|
||||
info.product = _read("../product")
|
||||
interface = _read("interface")
|
||||
if interface:
|
||||
info.description = interface
|
||||
return info
|
||||
|
||||
|
||||
def discover() -> List[PortInfo]:
|
||||
"""List every serial port with its stable identity.
|
||||
|
||||
Prefers pyserial's enumeration (richer metadata) and falls back to scanning
|
||||
``/dev`` plus sysfs, so it still works when pyserial is absent.
|
||||
"""
|
||||
ports: List[PortInfo] = []
|
||||
|
||||
try:
|
||||
from serial.tools import list_ports as serial_list_ports
|
||||
|
||||
for entry in serial_list_ports.comports():
|
||||
info = PortInfo(
|
||||
device=entry.device,
|
||||
description=entry.description or "",
|
||||
hwid=entry.hwid or "",
|
||||
vid=getattr(entry, "vid", None),
|
||||
pid=getattr(entry, "pid", None),
|
||||
serial_number=getattr(entry, "serial_number", None),
|
||||
manufacturer=getattr(entry, "manufacturer", None),
|
||||
product=getattr(entry, "product", None),
|
||||
location=getattr(entry, "location", None),
|
||||
)
|
||||
info.aliases = _aliases_for(entry.device)
|
||||
ports.append(info)
|
||||
|
||||
if ports:
|
||||
return ports
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: scan /dev for tty devices, enriching from sysfs.
|
||||
candidates = sorted(glob.glob("/dev/ttyUSB*")) + sorted(glob.glob("/dev/ttyACM*"))
|
||||
candidates += sorted(glob.glob("/dev/ttyS*")) + sorted(glob.glob("/dev/ttyAMA*"))
|
||||
candidates += sorted(glob.glob("/dev/serial/by-id/*"))
|
||||
|
||||
seen = set()
|
||||
for device in candidates:
|
||||
real = os.path.realpath(device)
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
ports.append(_info_from_sysfs(device))
|
||||
|
||||
return ports
|
||||
|
||||
|
||||
def best_usb_candidates(ports: Sequence[PortInfo]) -> List[PortInfo]:
|
||||
"""USB-serial ports sorted most-likely-first, duplicates by identity removed."""
|
||||
usb = [p for p in ports if p.is_usb]
|
||||
usb.sort(key=lambda p: (-p.score(), p.device))
|
||||
|
||||
unique: List[PortInfo] = []
|
||||
identities = set()
|
||||
for port in usb:
|
||||
identity = port.stable_id or port.usb_id or port.device
|
||||
if port.serial_number:
|
||||
identity = "{}|{}".format(port.usb_id, port.serial_number)
|
||||
if identity in identities:
|
||||
continue
|
||||
identities.add(identity)
|
||||
unique.append(port)
|
||||
return unique
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve(
|
||||
selectors: Optional[Sequence[str]] = None,
|
||||
fallback_ports: Optional[Sequence[str]] = None,
|
||||
autodetect: bool = False,
|
||||
) -> Tuple[str, str]:
|
||||
"""Decide which port to open.
|
||||
|
||||
Returns ``(device, reason)`` where *reason* explains the choice so the
|
||||
caller can log it - being explicit about *why* a port was picked is the
|
||||
whole point of this module.
|
||||
|
||||
Raises :class:`PortNotFound` or :class:`AmbiguousPort` rather than
|
||||
guessing.
|
||||
"""
|
||||
ports = discover()
|
||||
|
||||
# 1. Explicit selectors always win.
|
||||
if selectors:
|
||||
matched: List[PortInfo] = []
|
||||
for selector in selectors:
|
||||
for port in ports:
|
||||
if port.matches(selector) and port not in matched:
|
||||
matched.append(port)
|
||||
|
||||
if not matched:
|
||||
# The selector may be a raw path pyserial cannot enumerate.
|
||||
for selector in selectors:
|
||||
if os.path.exists(selector):
|
||||
return selector, "explicit path {}".format(selector)
|
||||
available = ", ".join(p.device for p in ports) or "none detected"
|
||||
raise PortNotFound(
|
||||
"no port matched {}. Detected ports: {}".format(
|
||||
", ".join(repr(s) for s in selectors), available
|
||||
)
|
||||
)
|
||||
|
||||
if len(matched) > 1:
|
||||
raise AmbiguousPort(
|
||||
"{} ports matched: {}. Use --port with a serial number or "
|
||||
"by-id path, or --list-ports to see them.".format(
|
||||
len(matched), ", ".join(p.describe() for p in matched)
|
||||
)
|
||||
)
|
||||
|
||||
port = matched[0]
|
||||
return port.device, "matched {} ({})".format(
|
||||
port.stable_id or port.usb_id or "selector", port.describe()
|
||||
)
|
||||
|
||||
# 2. Autodetect: only trust a single unambiguous USB adapter.
|
||||
if autodetect:
|
||||
candidates = best_usb_candidates(ports)
|
||||
if len(candidates) == 1:
|
||||
port = candidates[0]
|
||||
return port.device, "autodetected {} (score {})".format(
|
||||
port.describe(), port.score()
|
||||
)
|
||||
if len(candidates) > 1:
|
||||
raise AmbiguousPort(
|
||||
"{} USB serial ports found; refusing to guess. Use --port or "
|
||||
"set a serial_number in the config. Candidates: {}".format(
|
||||
len(candidates), ", ".join(p.describe() for p in candidates)
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Fall back to the fixed order, which is what the workstation app used.
|
||||
for device in list(fallback_ports or []):
|
||||
if os.path.exists(device):
|
||||
return device, "first available of the default order"
|
||||
|
||||
raise PortNotFound(
|
||||
"no serial port found. Detected: {}".format(
|
||||
", ".join(p.describe() for p in ports) or "none"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Serial-port handling: open the RDM6300 and turn reads into events.
|
||||
|
||||
The reader library is a hard dependency at runtime but is imported lazily so
|
||||
that ``--demo`` and the unit tests keep working without it installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from .card import CANDIDATE_PORTS, DEFAULT_HEARTBEAT_INTERVAL, CardEvent
|
||||
|
||||
try: # pragma: no cover - depends on the environment
|
||||
import rdm6300
|
||||
except ImportError: # pragma: no cover
|
||||
rdm6300 = None # type: ignore[assignment]
|
||||
|
||||
#: Called once per card event. Keep it fast - it runs on the reader thread.
|
||||
EventCallback = Callable[[CardEvent], None]
|
||||
|
||||
|
||||
class ReaderUnavailable(RuntimeError):
|
||||
"""Raised when the ``rdm6300`` library is not installed."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""True when the ``rdm6300`` library can be imported."""
|
||||
return rdm6300 is not None
|
||||
|
||||
|
||||
def _make_reader_class(callback: EventCallback):
|
||||
"""Build a ``BaseReader`` subclass that reports events via *callback*."""
|
||||
if rdm6300 is None:
|
||||
raise ReaderUnavailable(
|
||||
"the 'rdm6300' library is not installed (pip install rdm6300)"
|
||||
)
|
||||
|
||||
class CardReader(rdm6300.BaseReader): # type: ignore[misc, valid-type]
|
||||
"""Fires *callback* for each insert, removal and checksum failure."""
|
||||
|
||||
def card_inserted(self, card) -> None:
|
||||
callback(CardEvent.from_card("card_inserted", card, self.port))
|
||||
|
||||
def card_removed(self, card) -> None:
|
||||
if card is None:
|
||||
return
|
||||
callback(CardEvent.from_card("card_removed", card, self.port))
|
||||
|
||||
def invalid_card(self, card) -> None:
|
||||
callback(CardEvent.from_card("invalid_card", card, self.port))
|
||||
|
||||
return CardReader
|
||||
|
||||
|
||||
def open_reader(
|
||||
callback: EventCallback,
|
||||
ports: Optional[Sequence[str]] = None,
|
||||
heartbeat_interval: Optional[float] = DEFAULT_HEARTBEAT_INTERVAL,
|
||||
on_error: Optional[Callable[[str, str], None]] = None,
|
||||
):
|
||||
"""Open the first usable port and return the reader (not yet started).
|
||||
|
||||
Returns ``None`` when no port could be opened. *on_error* receives
|
||||
``(port, message)`` for each failed attempt so the caller can print or log
|
||||
the reason.
|
||||
"""
|
||||
candidates = list(ports) if ports else list(CANDIDATE_PORTS)
|
||||
ReaderClass = _make_reader_class(callback)
|
||||
|
||||
for port in candidates:
|
||||
try:
|
||||
reader = ReaderClass(port, heartbeat_interval=heartbeat_interval)
|
||||
except Exception as exc: # port missing, permission denied, busy, ...
|
||||
if on_error is not None:
|
||||
on_error(port, str(exc))
|
||||
continue
|
||||
return reader
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def explain_error(port: str, message: str) -> str:
|
||||
"""Turn a pyserial error into an actionable hint."""
|
||||
lowered = message.lower()
|
||||
if "permission denied" in lowered:
|
||||
return (
|
||||
"permission denied - add the user to the 'dialout' group: "
|
||||
"sudo usermod -aG dialout $USER (then log in again)"
|
||||
)
|
||||
if "no such file" in lowered or "could not open port" in lowered:
|
||||
return "device not found"
|
||||
return message
|
||||
|
||||
|
||||
def list_ports() -> Dict[str, List]:
|
||||
"""Report candidate ports and whatever pyserial can enumerate."""
|
||||
candidates = [(port, os.path.exists(port)) for port in CANDIDATE_PORTS]
|
||||
|
||||
detected: List[str] = []
|
||||
try:
|
||||
from serial.tools import list_ports as serial_list_ports
|
||||
|
||||
detected = [
|
||||
"{} ({})".format(info.device, info.description)
|
||||
for info in serial_list_ports.comports()
|
||||
]
|
||||
except Exception:
|
||||
detected = []
|
||||
|
||||
return {"candidates": candidates, "detected": detected}
|
||||
Reference in New Issue
Block a user