Files
Donkie 6520ce6d1b Let NFC tags identify filaments as well as spools
A tag can now be linked to a filament type, not just to one spool of it. The
tag table already carried filament_id and target_type for exactly this, so
there is no migration.

API, all additive:

  POST   /filament/{id}/tag        link; 409 if anything else holds the UID
  DELETE /filament/{id}/tag/{uid}  unlink
  GET    /filament?tag=<uid>       exact lookup on the normalized UID
  Filament.tags                    also inside every spool's embedded filament
  TagScan.matched_filament_id      always present, like matched_spool_id
  TagConflictMessage.filament_id   names a filament holder on a 409

One UID still identifies one thing across both kinds: a spool's tag cannot be
linked to a filament or the reverse, and neither ?tag= lookup answers with the
other kind. The scan debounce key now includes the kind, since spool 7 and
filament 7 are different answers.

SpoolTag and SpoolTagParameters are renamed Tag and TagLinkParameters while
they are unreleased, so the schema does not describe filament tags as spool
tags forever.

client_v2: the filament inspector gets the Tags section, the link dialog names
and moves a tag held by either kind, and scanning a filament's tag opens that
filament when auto-navigate is on.

Tag having two delete-orphan parents was checked on SQLAlchemy 2.0.51: new tags
flush, unlinking deletes the row, and deleting either parent cascades.

Proven on sqlite, postgres, mariadb and cockroachdb: 558 integration tests
each. Unit tests, svelte-check and the Playwright tag specs pass.
2026-09-13 10:10:13 +02:00

184 lines
8.5 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. Only a scan that resolves the same way is a repeat; see should_broadcast.
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
# The last UID this reader reported, so the Add-tag dialog can offer what is
# already sitting on the reader instead of asking for another tap. A scale
# holds a spool on its pad to weigh it; making the user lift it and put it
# back just to fill in a field is the kind of thing that reads as broken.
#
# One UID, not a history: this is "what is on the reader", not a log. It
# lives and dies with the registry entry, which is in memory only.
last_uid: str | None = None
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, match) -> when it was last broadcast. The match is part of the key
# because it is part of the answer: a repeat is only a repeat while the scan still
# resolves to the same thing.
self._last_broadcast: dict[tuple[str, str, tuple[str, int] | None], 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,
uid: str | None = 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,
# Same rule as the name: a caller that does not pass one keeps what the
# reader last reported, so registering for another reason cannot blank it.
last_uid=uid if uid is not None else (existing.last_uid if existing else None),
)
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,
match: tuple[str, int] | None,
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.
`match` is what the scan resolved to, as `(target_type, id)` such as `("spool", 7)`, or
None for an unknown tag. The kind is part of it because spool 7 and filament 7 are
different answers. It is required rather than optional so that a future call site cannot
quietly reintroduce the bug this argument exists to fix (#1115): a tag scanned before it
was linked, then linked and scanned again, used to be suppressed as a duplicate, leaving
every subscriber that trusts the broadcast still showing "unknown tag".
"""
now = now or datetime.now(tz=timezone.utc)
key = (uid, reader_id, match)
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()