Initializare dupa curatare folder

This commit is contained in:
2026-10-05 11:46:46 +03:00
commit 48e800adc1
39 changed files with 6303 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
"""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.
On first start the reader installs its own libraries if they are missing, so a
fresh device needs no manual setup - see ``bootstrap``.
"""
from .bootstrap import ensure_dependencies, missing_packages, bundled_wheel_dir
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 .led import DEFAULT_LED_PIN, StatusLED, create as create_led
from .reader import available, list_ports, open_reader, reload_library
__version__ = "0.4.0"
__all__ = [
"CANDIDATE_PORTS",
"CONFIG_CARD_VALUE",
"CardData",
"CardEvent",
"AmbiguousPort",
"DEFAULT_LED_PIN",
"EventHub",
"PortError",
"PortInfo",
"PortNotFound",
"StatusLED",
"available",
"bundled_wheel_dir",
"create_led",
"create_server",
"discover",
"ensure_dependencies",
"format_event",
"list_ports",
"missing_packages",
"open_reader",
"reload_library",
"resolve",
"__version__",
]
+8
View File
@@ -0,0 +1,8 @@
"""Allow ``python -m rdm_card_reader``."""
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+312
View File
@@ -0,0 +1,312 @@
"""Make sure the reader library is present before we try to use it.
On a fresh device the ``rdm6300`` package may not be installed. Rather than
dying with "please install this yourself", the reader installs it on first run
and then carries on - the same behaviour the workstation app had, but without
shelling out to a bare ``pip`` with no error handling.
The wheels for both libraries are **shipped inside this package** (in
``rdm_card_reader/wheels``), so a device with no internet can still set itself
up. They are pure-Python (``py3-none-any`` / ``py2.py3-none-any``), so the same
files work on any architecture - unlike the workstation app's cache, which held
architecture-specific binaries and therefore could not be committed.
Two things this deliberately does *not* do:
* It never installs anything when the library is already importable, so a
normal start does no network work at all.
* It never lets a failed install crash the process. A missing library is a
condition to report clearly, not a traceback.
"""
from __future__ import annotations
import hashlib
import importlib
import os
import subprocess
import sys
from typing import Callable, List, Optional, Sequence
#: Import name -> pip requirement. The import name is what we check; the
#: requirement is what pip is asked for.
REQUIRED_PACKAGES = (
("rdm6300", "rdm6300>=0.1.1"),
("serial", "pyserial>=3.5"),
)
#: Extra packages needed only when driving a status LED.
#:
#: ``colorzero`` is listed explicitly because ``gpiozero`` requires it. Both are
#: installed with ``--no-deps``: gpiozero's declared dependency chain pulls in
#: ``setuptools`` (via colorzero), which is only a packaging requirement and is
#: not imported at runtime. Vendoring it would add 818 KB for nothing.
LED_PACKAGES = (
("gpiozero", "gpiozero>=2.0"),
("colorzero", "colorzero>=2.0"),
)
#: The LED extras are optional: refusing to start because a LED cannot be
#: driven would be absurd.
OPTIONAL_IMPORTS = frozenset({"gpiozero", "colorzero"})
#: Shown when an install fails, so the operator knows what to do by hand.
MANUAL_HINT = (
"Install manually with: {python} -m pip install {requirements}"
)
def bundled_wheel_dir() -> Optional[str]:
"""Directory of the wheels shipped inside this package.
Returns ``None`` when it is absent, which is the case for a source checkout
that has not vendored them.
"""
folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wheels")
if not os.path.isdir(folder):
return None
if not any(name.endswith(".whl") for name in os.listdir(folder)):
return None
return folder
def local_wheel_missing(requirements: Sequence[str], wheel_dir: str) -> List[str]:
"""Requirements that have no matching wheel in *wheel_dir*.
``--no-index`` makes pip fail outright if a wheel is absent, so check first
and fall back to PyPI rather than dying on a half-provisioned directory.
"""
try:
present = os.listdir(wheel_dir)
except OSError:
return list(requirements)
missing = []
for requirement in requirements:
# "rdm6300>=0.1.1" -> "rdm6300"
name = requirement.split(">=")[0].split("==")[0].split("[")[0].strip()
# Both sides are dash/underscore-normalised, so the prefix separator
# must be "_" - comparing against "name-" never matches.
normalised = name.replace("-", "_").lower()
if not any(
entry.replace("-", "_").lower().startswith(normalised + "_")
for entry in present
):
missing.append(requirement)
return missing
def verify_wheels(wheel_dir: str, sums_file: Optional[str] = None) -> List[str]:
"""Check wheels against ``SHA256SUMS``. Returns a list of problems.
A tampered wheel is an arbitrary-code-execution risk on every device that
installs it, so a mismatch is reported rather than installed.
"""
problems: List[str] = []
sums_path = sums_file or os.path.join(wheel_dir, "SHA256SUMS")
if not os.path.exists(sums_path):
return problems # nothing to check against
try:
with open(sums_path, "r") as handle:
lines = [line.strip() for line in handle if line.strip()]
except OSError as exc:
return ["cannot read {}: {}".format(sums_path, exc)]
for line in lines:
parts = line.split(None, 1)
if len(parts) != 2:
continue
expected, filename = parts[0], parts[1].lstrip("*").strip()
path = os.path.join(wheel_dir, filename)
if not os.path.exists(path):
problems.append("missing {}".format(filename))
continue
digest = hashlib.sha256()
try:
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
except OSError as exc:
problems.append("cannot read {}: {}".format(filename, exc))
continue
if digest.hexdigest() != expected:
problems.append(
"checksum mismatch for {} (expected {}, got {})".format(
filename, expected[:12], digest.hexdigest()[:12]
)
)
return problems
def missing_packages(
packages: Sequence[tuple] = REQUIRED_PACKAGES,
) -> List[tuple]:
"""Return the ``(import_name, requirement)`` pairs that cannot be imported."""
missing = []
for import_name, requirement in packages:
try:
importlib.import_module(import_name)
except ImportError:
missing.append((import_name, requirement))
return missing
def in_virtualenv() -> bool:
"""True when running inside a venv, where PEP 668 does not apply."""
if os.environ.get("VIRTUAL_ENV"):
return True
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
def _pip_command(
requirements: Sequence[str],
wheel_dir: Optional[str],
no_deps: bool = False,
) -> List[str]:
"""Build the pip invocation, handling venv vs system Python."""
command = [sys.executable, "-m", "pip", "install"]
if no_deps:
command.append("--no-deps")
if wheel_dir:
# Offline install from a local wheel directory: no index, no network.
command += ["--no-index", "--find-links", wheel_dir]
else:
command += ["--disable-pip-version-check"]
# Outside a virtualenv, pip on Debian/Raspberry Pi OS refuses to touch the
# system environment unless told explicitly (PEP 668).
if not in_virtualenv():
command.append("--break-system-packages")
command += list(requirements)
return command
def install(
requirements: Sequence[str],
wheel_dir: Optional[str] = None,
logger: Optional[Callable[[str], None]] = None,
timeout: int = 300,
no_deps: bool = False,
) -> bool:
"""Run pip. Returns True on success; never raises for a failed install."""
say = logger or (lambda message: None)
command = _pip_command(requirements, wheel_dir, no_deps=no_deps)
say(" $ {}".format(" ".join(command)))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
say(" x install timed out after {}s".format(timeout))
return False
except OSError as exc:
say(" x could not run pip: {}".format(exc))
return False
if result.returncode != 0:
# pip writes the useful part to stderr; show the tail of it.
tail = (result.stderr or result.stdout or "").strip().splitlines()[-6:]
for line in tail:
say(" | {}".format(line))
return False
return True
def _is_optional(packages: Sequence[tuple]) -> bool:
"""True when every package in the group is an optional extra."""
names = {name for name, _ in packages}
return bool(names) and names <= OPTIONAL_IMPORTS
def ensure_dependencies(
packages: Sequence[tuple] = REQUIRED_PACKAGES,
wheel_dir: Optional[str] = None,
auto_install: bool = True,
logger: Optional[Callable[[str], None]] = None,
use_bundled: bool = True,
no_deps: bool = False,
) -> bool:
"""Check for the given libraries and install them if they are missing.
Returns True when everything is importable at the end - either because it
already was, or because the install worked. Returns False when something is
still missing, so the caller can decide what to do. For an optional group
(the LED extras) False just means the feature is unavailable.
"""
say = logger or (lambda message: None)
missing = missing_packages(packages)
if not missing:
return True
requirements = [requirement for _, requirement in missing]
names = ", ".join(name for name, _ in missing)
say("Missing reader dependencies: {}".format(names))
if not auto_install:
say("Automatic install is disabled (--no-install-deps).")
say(MANUAL_HINT.format(
python=sys.executable, requirements=" ".join(requirements)))
return False
# Prefer the wheels shipped with the package: a device with no internet
# must still be able to set itself up.
explicit = wheel_dir is not None
if wheel_dir is None and use_bundled:
wheel_dir = bundled_wheel_dir()
if wheel_dir:
say("Using the wheels bundled with this package:")
say(" {}".format(wheel_dir))
if wheel_dir:
problems = verify_wheels(wheel_dir)
for problem in problems:
say(" x {}".format(problem))
if problems:
# A bad or incomplete bundle is not fatal: fall back to PyPI rather
# than installing something we could not verify.
if explicit:
say(" x the --wheel-dir you gave is unusable")
say("Bundle unusable - falling back to PyPI.")
wheel_dir = None
else:
uncovered = local_wheel_missing(requirements, wheel_dir)
if uncovered:
# Do not attempt a --no-index install that is guaranteed to
# fail; PyPI covers whatever the bundle does not contain.
say(" local wheels do not cover: {}".format(
", ".join(uncovered)))
if explicit:
say(" x the --wheel-dir you gave is incomplete")
say("Falling back to PyPI for those.")
wheel_dir = None
if wheel_dir:
say("Installing from {}".format(wheel_dir))
else:
say("Installing from PyPI ...")
install(requirements, wheel_dir=wheel_dir, logger=say,
no_deps=no_deps and wheel_dir is not None)
# Re-check rather than trusting pip's exit code alone.
still_missing = missing_packages(packages)
if still_missing:
say("Still missing: {}".format(
", ".join(name for name, _ in still_missing)))
say(MANUAL_HINT.format(
python=sys.executable, requirements=" ".join(requirements)))
return False
say("Dependencies ready: {}".format(
", ".join(name for name, _ in missing)))
return True
+503
View File
@@ -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")
+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
)
+382
View File
@@ -0,0 +1,382 @@
"""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 .bootstrap import LED_PACKAGES, ensure_dependencies
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,
EVENT_INVALID,
CardEvent,
format_event,
)
from .demo import run_demo
from .led import DEFAULT_LED_PIN, MODES as LED_MODES, create as create_led
from .ports import AmbiguousPort, PortNotFound, discover, resolve
from .reader import available, explain_error, list_ports, open_reader, reload_library
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 _ensure_library(args) -> bool:
"""Make sure the reader library is importable, installing it if needed.
A fresh device should not need manual pip work, so this installs on first
run. It is skipped entirely when the library is already present.
"""
if available():
return True
print("Reader library not found - checking dependencies...", flush=True)
installed = ensure_dependencies(
wheel_dir=args.wheel_dir,
auto_install=not args.no_install_deps,
logger=print,
)
if installed:
# The import happened at process start, so refresh it now.
reload_library()
if available():
return True
# Flush stdout first: stderr is unbuffered while stdout is block-buffered
# when piped, so without this the explanation can appear after the error.
sys.stdout.flush()
print("\nERROR: the 'rdm6300' library is not available.", file=sys.stderr)
print(" Retry with network access, or run with --demo to see the"
" output format.", file=sys.stderr)
return False
def _build_led(args):
"""Set up the status LED, or a no-op when GPIO is unusable.
Never fatal: a reader that refuses to start because a LED cannot be driven
would be worse than one with no light.
"""
if args.no_led:
return create_led(enabled=False)
# Only pull in gpiozero when a LED was actually asked for, so a headless
# install does not grow the dependency set.
ensure_dependencies(
packages=LED_PACKAGES,
wheel_dir=args.wheel_dir,
auto_install=not args.no_install_deps,
logger=print,
no_deps=True,
)
return create_led(
pin=args.led_pin,
enabled=True,
mode=args.led_mode,
error_blink=not args.led_no_error_blink,
logger=lambda message: print(" {}".format(message)),
)
def _run_bridge(args) -> int:
"""Serve what the reader sees over HTTP for a web app to consume."""
if not _ensure_library(args):
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)
led = _build_led(args)
def emit(event: CardEvent) -> None:
# Drive the LED first: it is queued, so it never delays the reader.
if event.event == EVENT_INVALID:
led.error()
elif event.event == "card_removed":
led.remove()
else:
led.insert()
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
led.close()
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)")
parser.add_argument("--no-install-deps", action="store_true",
help="do not install missing reader libraries automatically")
parser.add_argument("--wheel-dir", metavar="DIR",
help="install missing libraries from local wheels in DIR "
"instead of PyPI (offline install)")
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")
led = parser.add_argument_group("status LED")
led.add_argument("--no-led", action="store_true",
help="do not drive a status LED")
led.add_argument("--led-pin", type=int, default=DEFAULT_LED_PIN,
help="GPIO pin of the status LED (default: %(default)s, "
"matching the workstation app)")
led.add_argument("--led-mode", choices=list(LED_MODES), default=LED_MODES[0],
help="'error' (default): lit whenever the reader is running, "
"blinking means a bad read; 'presence': lit only while "
"a card is on the reader")
led.add_argument("--led-no-error-blink", action="store_true",
help="do not blink the LED on a bad read")
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 _ensure_library(args):
return EXIT_NO_LIBRARY
led = _build_led(args)
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)
def on_event(event: CardEvent) -> None:
# LED first: it is queued, so it never delays the reader loop.
if event.event == EVENT_INVALID:
led.error()
elif event.event == "card_removed":
led.remove()
else:
led.insert()
print_event(event)
for port in ports:
print("-> Opening {} ...".format(port), flush=True)
reader = open_reader(on_event, ports=ports,
heartbeat_interval=args.heartbeat,
on_error=report_error)
if reader is None:
led.close()
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
led.close()
return EXIT_OK
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+49
View File
@@ -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
+314
View File
@@ -0,0 +1,314 @@
"""Drive a status LED from card events.
The workstation app lit an LED (GPIO 23) with the card: on when a card was
inserted, off when it was removed. This reproduces that, with three deliberate
differences:
* It never blocks the reader. WMT toggled the LED and slept on the reader
thread, which stalls card detection; here every change is queued to a worker.
* It cannot crash the process. On a machine without GPIO, turning the LED on
raises ``BadPinFactory``. WMT worked around this with a ``DummyLED``; this
keeps the same idea but also handles a pin that disappears at runtime.
* It is explicit. Whether an LED is actually attached is reported, so "no light
came on" is distinguishable from "the card was not read".
The default pin (23) matches the workstation app. **Use a separate LED from the
workstation's if both run on one device**, since two processes driving one pin
will fight.
"""
from __future__ import annotations
import queue
import threading
from dataclasses import dataclass
from typing import Any, Callable, Optional
#: GPIO pin of the status LED. Chosen to match the workstation app.
DEFAULT_LED_PIN = 23
#: How long to hold an error indication, in seconds.
ERROR_BLINK_SECONDS = 1.0
OFF = "off"
ON = "on"
BLINK = "blink"
#: Lit the whole time the app runs, and blinks on a bad read. This is a health
#: indicator: light on means the reader is alive and ready for a card.
ERROR = "error"
#: Lit only while a card is on the reader, and off otherwise. This is a card
#: indicator, for when the light should mean "a card is here".
PRESENCE = "presence"
MODES = (ERROR, PRESENCE)
#: Kept for the occasional caller that only cares about the concrete commands.
_VALID_MODES = (OFF, ON, BLINK)
@dataclass
class LEDStatus:
"""What the LED subsystem is actually doing."""
enabled: bool
pin: Optional[int]
backend: str # "gpiozero" | "null"
mode: str = ERROR
state: bool = False
reason: str = ""
def to_dict(self) -> dict:
return {
"enabled": self.enabled,
"pin": self.pin,
"backend": self.backend,
"mode": self.mode,
"state": "on" if self.state else "off",
"reason": self.reason,
}
class NullLED:
"""A LED that does nothing, used when there is no usable GPIO.
Kept deliberately quiet: the reason it is inert is reported once at
startup, not on every card.
"""
backend = "null"
def __init__(self, pin: Optional[int], reason: str = ""):
self.pin = pin
self.reason = reason
def on(self) -> None:
pass
def off(self) -> None:
pass
def close(self) -> None:
pass
class GPIOLED:
"""A real LED on a GPIO pin, via gpiozero."""
backend = "gpiozero"
def __init__(self, pin: int, active_high: bool = True):
from gpiozero import LED as _LED
self.pin = pin
self._led = _LED(pin, active_high=active_high)
self._is_on = False
def on(self) -> None:
self._led.on()
self._is_on = True
def off(self) -> None:
self._led.off()
self._is_on = False
def close(self) -> None:
try:
self._led.close()
except Exception:
pass
class _Blinker(threading.Thread):
"""Repeatedly toggles a LED, for an error indication."""
def __init__(self, led: Any, period: float = 0.15):
super().__init__(daemon=True, name="led-blink")
self._led = led
self._period = period
self._stop = threading.Event()
def run(self) -> None:
state = False
while not self._stop.is_set():
try:
self._led.on() if state else self._led.off()
except Exception:
return
state = not state
self._stop.wait(self._period)
try:
self._led.off()
except Exception:
pass
def stop(self) -> None:
self._stop.set()
class StatusLED:
"""Non-blocking LED controller driven by card events.
Commands are queued and applied by a worker thread, so calling :meth:`insert`
from the reader callback never blocks card detection.
"""
def __init__(
self,
pin: int = DEFAULT_LED_PIN,
enabled: bool = True,
mode: str = ERROR,
active_high: bool = True,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
):
self._log = logger or (lambda message: None)
self._mode = mode if mode in MODES else ERROR
self._error_blink = error_blink
self._enabled = enabled
self._blinker: Optional[_Blinker] = None
self._state = False
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
self._thread: Optional[threading.Thread] = None
if not enabled:
self.led: Any = NullLED(None, "LED disabled (--no-led)")
self.status = LEDStatus(False, None, "null", self._mode, False,
"disabled")
return
# Check the library separately from building the pin.
#
# gpiozero raises BadPinFactory when no pin provider works, and that is
# a subclass of ImportError, so catching ImportError around the pin
# construction would report "gpiozero is not installed" even when it is
# installed and simply has no usable pin library. On a Pi missing lgpio
# or RPi.GPIO that message sends you to the wrong fix.
try:
import gpiozero # noqa: F401
except ImportError as exc:
reason = "gpiozero not installed: {}".format(exc)
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
self._log("LED unavailable: {}".format(reason))
else:
try:
self.led = GPIOLED(pin, active_high=active_high)
self.status = LEDStatus(True, pin, "gpiozero", self._mode,
False, "ready")
self._log("LED on GPIO {} ({} mode, gpiozero)".format(
pin, self._mode))
except Exception as exc:
# No usable pin factory (not a Pi), pin already in use, bad pin.
reason = ("no usable GPIO on pin {}: {}".format(pin, exc))
self.led = NullLED(pin, reason)
self.status = LEDStatus(False, pin, "null", self._mode, False,
reason)
self._log("LED unavailable: {}".format(reason))
if self.status.enabled and self._mode == ERROR:
# A health indicator shows 'alive' immediately. In presence mode the
# light means 'a card is here', so it must stay dark until one is.
self.led.on()
self._set_state(True)
self.status.state = True
self._thread = threading.Thread(target=self._worker, daemon=True,
name="led-worker")
self._thread.start()
# -- event hooks ------------------------------------------------------
def insert(self) -> None:
"""A valid card was read: off in 'error' mode, on in 'presence' mode."""
self._send(OFF if self._mode == ERROR else ON)
def remove(self) -> None:
"""The card left the reader: dark in 'presence' mode; 'error' mode stays on."""
self._send(ON if self._mode == ERROR else OFF)
def error(self) -> None:
"""A bad read: blink, if blinking is enabled."""
self._send(BLINK if self._error_blink else ON)
# -- control ----------------------------------------------------------
def _send(self, command: str) -> None:
if not self.status.enabled:
return
try:
self._queue.put_nowait(command)
except Exception:
pass
def _worker(self) -> None:
while True:
command = self._queue.get()
if command is None:
return
try:
self._apply(command)
except Exception as exc:
# A failed toggle must never take the reader down.
self._log("LED update failed: {}".format(exc))
finally:
self._queue.task_done()
def _apply(self, command: str) -> None:
# Blinking is used only for a bad read, and only by 'error' mode.
if command == BLINK and self._mode == ERROR:
self._stop_blink()
self._blinker = _Blinker(self.led)
self._blinker.start()
return
self._stop_blink() # a steady command cancels any blink
# Two readings of the light, hence two modes:
# error - a health indicator: lit the whole time the reader runs,
# so "light on" means "ready for a card".
# presence - a card indicator: lit only while a card is on the reader.
if command == OFF:
self.led.off()
self._set_state(False)
elif command == ON:
self.led.on()
self._set_state(True)
def _stop_blink(self) -> None:
if self._blinker is not None:
self._blinker.stop()
self._blinker = None
def _set_state(self, value: bool) -> None:
self._state = value
self.status.state = value
def close(self) -> None:
self._stop_blink()
if self._thread is not None:
self._queue.put(None)
try:
self.led.off()
except Exception:
pass
self._set_state(False)
try:
self.led.close()
except Exception:
pass
def create(
enabled: bool = True,
pin: int = DEFAULT_LED_PIN,
mode: str = ERROR,
error_blink: bool = True,
logger: Optional[Callable[[str], None]] = None,
) -> StatusLED:
"""Build the LED controller, degrading to a no-op when GPIO is unusable."""
return StatusLED(pin=pin, enabled=enabled, mode=mode,
error_blink=error_blink, logger=logger)
+397
View File
@@ -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"
)
)
View File
+129
View File
@@ -0,0 +1,129 @@
"""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 importlib
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 reload_library() -> bool:
"""Re-attempt the import, for when the library was installed at runtime.
The module-level import above runs once at process start, so installing
afterwards is invisible until this is called.
"""
global rdm6300
if rdm6300 is not None:
return True
try:
rdm6300 = importlib.import_module("rdm6300")
except ImportError:
rdm6300 = None
return False
return True
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}
+4
View File
@@ -0,0 +1,4 @@
0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8 colorzero-2.0-py2.py3-none-any.whl
d491734803a9bc6036602e6b8bbd73fb25c33da88de0fcd11a40e890b8bc2d3b gpiozero-2.0.1.post3-py3-none-any.whl
c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 pyserial-3.5-py2.py3-none-any.whl
afb067da8b8d23a477138ca67d4ff1d198f12d157b64668c9dd3cbd7df54e74e rdm6300-0.1.1-py3-none-any.whl