Initializare dupa curatare folder
This commit is contained in:
@@ -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__",
|
||||
]
|
||||
@@ -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,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
|
||||
@@ -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,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())
|
||||
@@ -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,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)
|
||||
@@ -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,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}
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user