mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 10:02:17 -05:00
register() kept datetime.utcnow() while should_broadcast() and readers() moved to datetime.now(tz=utc), so the naive last_seen it stored was compared against an aware cutoff during pruning: GET /tag/reader raised "can't compare offset-naive and offset-aware datetimes" as soon as any reader had scanned. The existing unit tests could not catch it because they inject `now` into every call. Added one that exercises each method's own clock instead. Also adds design/nfc-phase-1-frontend-handoff.md: the API shapes the client will consume, the behaviours it has to get right, two stale paths in the phase 1 doc's frontend plan, and the open UI decisions.
152 lines
6.8 KiB
Python
152 lines
6.8 KiB
Python
"""In-memory state for the tag scan relay: scan debouncing and the reader registry.
|
|
|
|
Relay state is neither database state nor HTTP state, so it lives on its own. Isolating it
|
|
means the debounce window and the registry's eviction are testable without a socket or a
|
|
session.
|
|
|
|
Everything here is per-process and deliberately not persisted. `entrypoint.sh` runs a single
|
|
uvicorn process with no `--workers`, so a module-level registry and debounce cache are correct
|
|
as deployed; a user who adds `--workers 4` gets a partitioned registry and a per-worker
|
|
debounce window. That is a documented limit, not a reason to add Redis or a table for state
|
|
whose whole lifetime is "a few seconds" or "until the next restart".
|
|
"""
|
|
|
|
import re
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
# Readers poll continuously -- nfc2klipper re-reads a tag for as long as it sits on the
|
|
# reader -- so the same UID fires over and over. Suppressing repeats server-side, before the
|
|
# broadcast, means every subscriber benefits and no device has to be well-behaved for the UI
|
|
# to be.
|
|
DEBOUNCE_WINDOW = timedelta(seconds=3)
|
|
|
|
# How long a reader stays listed after its last scan. Long, because the list exists to fill a
|
|
# "choose a reader" dropdown: a reader tapped this morning should still be pickable this
|
|
# afternoon, and a stale entry is a much smaller annoyance than a missing one. The registry is
|
|
# in-memory, so a restart clears it regardless and this is really "seen since startup".
|
|
READER_TTL = timedelta(hours=24)
|
|
|
|
# Hard caps. Spoolman has no authentication, so anything on the LAN can post scans; these keep
|
|
# a broken or hostile agent from growing either structure without bound. Both evict oldest
|
|
# first, so a working setup never notices them.
|
|
MAX_READERS = 256
|
|
MAX_DEBOUNCE_ENTRIES = 1024
|
|
|
|
READER_ID_MAX_LENGTH = 64
|
|
|
|
# What a reader_id may contain. It is operator-chosen, travels in a websocket path, and is
|
|
# echoed back in events, so keep it to characters that are unambiguous in all three places.
|
|
READER_ID_PATTERN = r"^[A-Za-z0-9._:-]{1,64}$"
|
|
|
|
_NON_ID_CHARS = re.compile(r"[^A-Za-z0-9]+")
|
|
|
|
|
|
def derive_reader_id(client_host: str | None) -> str:
|
|
"""Derive a stable reader id from a client's network address.
|
|
|
|
Some setups are a dozen lines of ESPHome YAML and will never be rewritten to send a
|
|
reader_id. Deriving one from the address is ugly and breaks on DHCP churn, but it is
|
|
stable enough that tap-to-pair works against a completely unmodified agent, which is the
|
|
point.
|
|
"""
|
|
sanitized = _NON_ID_CHARS.sub("-", client_host or "").strip("-")
|
|
if not sanitized:
|
|
return "unknown"
|
|
return f"ip-{sanitized}"[:READER_ID_MAX_LENGTH]
|
|
|
|
|
|
@dataclass
|
|
class Reader:
|
|
"""A reader that has reported a scan."""
|
|
|
|
reader_id: str
|
|
name: str | None
|
|
last_seen: datetime
|
|
|
|
|
|
class ScanRelay:
|
|
"""Debounce cache and reader registry for the tag scan relay."""
|
|
|
|
def __init__(
|
|
self,
|
|
debounce_window: timedelta = DEBOUNCE_WINDOW,
|
|
reader_ttl: timedelta = READER_TTL,
|
|
) -> None:
|
|
"""Initialize an empty relay."""
|
|
self.debounce_window = debounce_window
|
|
self.reader_ttl = reader_ttl
|
|
# (uid, reader_id) -> when it was last broadcast.
|
|
self._last_broadcast: dict[tuple[str, str], datetime] = {}
|
|
self._readers: dict[str, Reader] = {}
|
|
# Scans arrive on the event loop, but the loop is not the only thing that can touch
|
|
# this: pruning walks the dicts while a scan may be inserting into them. A plain lock
|
|
# is enough and is never held across an await.
|
|
self._lock = threading.Lock()
|
|
|
|
def register(self, reader_id: str, name: str | None, now: datetime | None = None) -> None:
|
|
"""Record that a reader reported a scan just now."""
|
|
# Aware, like the other two clocks in this class. They all end up compared against each
|
|
# other -- register writes last_seen, readers() prunes on it -- and mixing one naive
|
|
# clock in raises "can't compare offset-naive and offset-aware datetimes".
|
|
now = now or datetime.now(tz=timezone.utc)
|
|
with self._lock:
|
|
# Popping first re-inserts at the end, so the dict stays ordered least- to
|
|
# most-recently-seen and eviction can drop from the front.
|
|
existing = self._readers.pop(reader_id, None)
|
|
# A reader that stops sending a name keeps the one it last gave, so a scan from a
|
|
# trimmed-down agent doesn't blank the label in someone's dropdown.
|
|
self._readers[reader_id] = Reader(
|
|
reader_id=reader_id,
|
|
name=name if name is not None else (existing.name if existing else None),
|
|
last_seen=now,
|
|
)
|
|
self._prune_readers(now)
|
|
while len(self._readers) > MAX_READERS:
|
|
self._readers.pop(next(iter(self._readers)))
|
|
|
|
def should_broadcast(self, uid: str, reader_id: str, now: datetime | None = None) -> bool:
|
|
"""Whether this scan is new enough to broadcast, recording it if so.
|
|
|
|
Only the fan-out is debounced. The caller still answers the device on every POST: a
|
|
de-duplicated scan must not look to the device like a failed lookup.
|
|
"""
|
|
now = now or datetime.now(tz=timezone.utc)
|
|
key = (uid, reader_id)
|
|
with self._lock:
|
|
self._prune_debounce(now)
|
|
last = self._last_broadcast.get(key)
|
|
if last is not None and now - last < self.debounce_window:
|
|
return False
|
|
self._last_broadcast[key] = now
|
|
while len(self._last_broadcast) > MAX_DEBOUNCE_ENTRIES:
|
|
self._last_broadcast.pop(next(iter(self._last_broadcast)))
|
|
return True
|
|
|
|
def readers(self, now: datetime | None = None) -> list[Reader]:
|
|
"""List the readers seen within the TTL, most recently seen first.
|
|
|
|
Eviction happens here rather than on a timer: there is no background task to start, no
|
|
lifecycle hook to register, and nothing to shut down cleanly.
|
|
"""
|
|
now = now or datetime.now(tz=timezone.utc)
|
|
with self._lock:
|
|
self._prune_readers(now)
|
|
return sorted(self._readers.values(), key=lambda reader: reader.last_seen, reverse=True)
|
|
|
|
def _prune_readers(self, now: datetime) -> None:
|
|
"""Drop readers past their TTL. Caller holds the lock."""
|
|
cutoff = now - self.reader_ttl
|
|
for reader_id in [rid for rid, reader in self._readers.items() if reader.last_seen < cutoff]:
|
|
del self._readers[reader_id]
|
|
|
|
def _prune_debounce(self, now: datetime) -> None:
|
|
"""Drop debounce entries older than the window. Caller holds the lock."""
|
|
cutoff = now - self.debounce_window
|
|
for key in [k for k, seen in self._last_broadcast.items() if seen < cutoff]:
|
|
del self._last_broadcast[key]
|
|
|
|
|
|
scan_relay = ScanRelay()
|