Add keyboard-wedge typing and LED event support

- keyboard.py: emulate a commercial USB RFID reader by typing card ids
  into the focused window via a /dev/uinput virtual keyboard
- led.py / bridge.py: drive the status LED from card events
- cli.py, card.py: wire up the new options and event flow
- deploy: harden install.sh and the systemd unit
- README: document keyboard mode, LED support, and deployment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-10-05 21:14:58 +03:00
parent 48e800adc1
commit c0a354b98e
9 changed files with 973 additions and 67 deletions
+45
View File
@@ -75,6 +75,12 @@ DEFAULT_HISTORY = 50
#: replaced.
DEFAULT_TAP_TTL = 10.0
#: How long a *claimed* tap stays readable afterwards. The page that claimed it
#: needs a moment to confirm which card logged in, but an id that stays
#: readable forever is indistinguishable from a tap still waiting to be spent -
#: so it is cleared once this window passes. Set to 0 to keep it indefinitely.
DEFAULT_CLAIMED_TTL = 30.0
class EventHub:
"""Tracks reader state and fans card events out to waiting subscribers.
@@ -96,6 +102,7 @@ class EventHub:
history: int = DEFAULT_HISTORY,
mode: str = MODE_LOGIN,
tap_ttl: float = DEFAULT_TAP_TTL,
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
):
self._lock = threading.Lock()
self._subscribers: List["deque"] = []
@@ -107,6 +114,7 @@ class EventHub:
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)
self._claimed_ttl = float(claimed_ttl if claimed_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.
@@ -177,8 +185,16 @@ class EventHub:
``present`` is whether the card is physically on the reader right now;
``claimable`` is whether this tap can still be spent on a login.
A claimed tap is reported for ``claimed_ttl`` seconds after it was
spent, so the page that claimed it can confirm which card logged in. An
expired unclaimed tap is cleared too: keeping it would leave an id that
can never be claimed again, which is indistinguishable from a tap that
is still waiting to be used.
"""
with self._lock:
self._drop_dead_tap()
if self._tap is None:
return {
"present": False,
@@ -200,6 +216,32 @@ class EventHub:
"card": self._tap.to_dict(),
}
def _drop_dead_tap(self) -> None:
"""Clear the latched tap once it can no longer be acted on.
Called with the lock held. Two independent deadlines: an unclaimed tap
is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of
zero TTL means "keep until replaced", which the claimed case uses to
keep the id around indefinitely.
Crucially this drops only the *tap*, never the presence flag: whether a
card is on the reader is a physical fact learnt from insert/remove
events, and a tap ageing out says nothing about it. Clearing presence
here reported "no card" while a card was sitting on the antenna.
"""
if self._tap is None:
return
now = time.time()
if self._consumed_at is not None:
if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl:
self._tap = None
self._tap_at = None
self._consumed_at = None
return
if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl:
self._tap = None
self._tap_at = None
def consume_card(self):
"""Atomically claim the latched tap for one authentication.
@@ -207,6 +249,7 @@ class EventHub:
this call succeeds, so two concurrent requests cannot both win.
"""
with self._lock:
self._drop_dead_tap()
if self._tap is None:
return None, "no card read yet", 409
@@ -245,6 +288,7 @@ class EventHub:
def health(self) -> Dict[str, Any]:
with self._lock:
self._drop_dead_tap()
last_age = (
round(time.time() - self._last_at, 1) if self._last_at else None
)
@@ -260,6 +304,7 @@ class EventHub:
"card_present": self._on_reader,
"card_consumed": self._consumed_at is not None,
"tap_ttl_seconds": self._tap_ttl or None,
"claimed_ttl_seconds": self._claimed_ttl or None,
}
def last(self) -> Optional[Dict[str, Any]]: