Files
Spoolman/scripts/fake_reader.py
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

232 lines
9.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Pretend to be an NFC/RFID reader, so the tag features can be driven without hardware.
Spoolman never talks to reader hardware. A reader — an ESP32 at the printer, a phone, a
Node-RED flow — reports a tag it has read by making one HTTP POST, and the server fans
that out to whichever browsers are listening. That contract being ordinary HTTP is what
makes this script possible: it is a complete, conforming reader in about a hundred lines,
and everything a real one can do it can do too.
Stdlib only; talks to a running instance over the public API:
python scripts/fake_reader.py # interactive, keeps tapping
python scripts/fake_reader.py 04:a2:b3:c4 # one tap, then exit
python scripts/fake_reader.py --new # invent a blank tag and tap it
Also available as `uv run poe fake-reader <args>`.
Point a browser at the same instance, turn on "Open the spool a scanned tag belongs to"
in Settings, and taps here will move it. Pairing works too: click "Pair by tapping" and
then tap anything — this reader's `--reader-id` is what the browser latches onto.
Unlike the seed scripts this one writes nothing and deletes nothing. A scan is an event,
not a record: it is broadcast to listeners and remembered only as "this reader was seen
recently". Safe to point anywhere you would be willing to click around in the UI.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import random
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
DEFAULT_URL = "http://localhost:7912"
# The server constrains reader ids to this because they travel in a websocket path
# (`/api/v1/tag/scan/{reader_id}`), so a bad one is a 422 rather than a mystery.
READER_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,64}$")
class ApiError(RuntimeError):
"""A non-2xx response from the Spoolman API."""
def __init__(self, method: str, path: str, status: int, body: str) -> None:
super().__init__(f"{method} {path} -> {status}: {body}")
self.status = status
self.body = body
class Api:
"""Minimal Spoolman API v1 client."""
def __init__(self, base_url: str, timeout: float = 30.0) -> None:
self.base = base_url.rstrip("/") + "/api/v1"
self.timeout = timeout
def _request(self, method: str, path: str, body: Any = None, params: dict | None = None) -> Any:
url = self.base + path
if params:
url += "?" + urllib.parse.urlencode(params)
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method) # noqa: S310
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
raw = resp.read()
except urllib.error.HTTPError as e:
raise ApiError(method, path, e.code, e.read().decode(errors="replace")[:500]) from None
except urllib.error.URLError as e:
raise RuntimeError(f"Could not reach {url}: {e.reason}") from None
if not raw:
return None
return json.loads(raw)
def get(self, path: str, params: dict | None = None) -> Any:
return self._request("GET", path, params=params)
def post(self, path: str, body: Any) -> Any:
return self._request("POST", path, body=body)
def new_uid() -> str:
"""Invent a UID shaped like a blank NTAG sticker's: 7 bytes, NXP's 0x04 first."""
return "04" + "".join(f"{random.randrange(256):02X}" for _ in range(6))
def filament_label(filament: dict) -> str:
"""Name a filament the way the UI would: vendor and name, falling back to the material."""
vendor = (filament.get("vendor") or {}).get("name")
parts = [p for p in (vendor, filament.get("name")) if p]
return " ".join(parts) or filament.get("material") or "unnamed filament"
def describe_filament(filament: dict) -> str:
return f"filament #{filament['id']} — {filament_label(filament)}"
def describe(spool: dict) -> str:
"""Name a spool the way the UI would, so a tap is recognisable at a glance."""
label = filament_label(spool.get("filament") or {})
remaining = spool.get("remaining_weight")
weight = f", {remaining:.0f} g left" if isinstance(remaining, (int, float)) else ""
return f"spool #{spool['id']} — {label}{weight}"
def tap(api: Api, uid: str, reader_id: str, name: str | None, tag_format: str | None) -> None:
"""Report one tag read, and say what the server made of it."""
body: dict[str, Any] = {"uid": uid, "reader_id": reader_id}
if name:
body["name"] = name
if tag_format:
body["format"] = tag_format
try:
scan = api.post("/tag/scan", body)
except ApiError as e:
# A UID that isn't hexadecimal is a 400 and an empty one a 422; both are the
# user's typing rather than anything wrong with the reader, so keep going.
# Only the 400 carries a `message` worth showing — 422 is FastAPI's own
# validation blob, which says the same thing at ten times the length.
message = ""
with contextlib.suppress(ValueError, AttributeError):
message = json.loads(e.body).get("message") or ""
if not message:
message = "the UID is empty" if e.status == 422 else e.body
print(f" rejected ({e.status}): {message}")
return
# `matched_spool_id` and `matched_filament_id` are always present in the HTTP response,
# null when that kind did not match — that is the published device contract, and it is
# what a real reader keys off.
if scan.get("matched_spool_id") is not None:
print(f" {uid} — {describe(scan['spool'])}")
elif scan.get("matched_filament_id") is not None:
print(f" {uid} — {describe_filament(scan['filament'])}")
else:
print(f" {uid} — unknown tag, nothing has it")
def show_tagged(api: Api) -> None:
"""List the tags already linked, so there is something known to tap."""
spools = api.get("/spool", {"allow_archived": "true"})
filaments = api.get("/filament", {})
rows = [(t["uid"], describe(s)) for s in spools for t in s.get("tags", [])]
rows += [(t["uid"], describe_filament(f)) for f in filaments for t in f.get("tags", [])]
if not rows:
print(" nothing is tagged yet — link one in an inspector, or press 'n' for a blank tag")
return
for uid, label in rows:
print(f" {uid} {label}")
def interactive(api: Api, reader_id: str, name: str | None, tag_format: str | None) -> int:
print(f"Reader '{reader_id}' ready. Enter a UID to tap it.")
print(" <enter> re-taps the last one (the server de-duplicates repeats, as it does for a real")
print(" reader that keeps re-reading a tag left sitting on it)")
print(" n invent a blank tag and tap it")
print(" l list the tags already linked")
print(" q quit")
last: str | None = None
while True:
try:
line = input("tap> ").strip()
except (EOFError, KeyboardInterrupt):
print()
return 0
if line in {"q", "quit", "exit"}:
return 0
if line in {"l", "list"}:
show_tagged(api)
continue
if line in {"n", "new"}:
last = new_uid()
elif line:
last = line
elif last is None:
print(" nothing tapped yet — enter a UID, or 'n' for a blank tag")
continue
tap(api, last, reader_id, name, tag_format)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("uid", nargs="*", help="UID(s) to tap, in any spelling. Omit for an interactive reader.")
parser.add_argument("--url", default=DEFAULT_URL, help=f"Spoolman base URL (default: {DEFAULT_URL})")
parser.add_argument("--reader-id", default="desk", help="What to call this reader (default: desk)")
parser.add_argument("--name", help="Friendly name, shown in the UI in place of the reader id")
parser.add_argument("--format", dest="tag_format", help="Tag type, e.g. ntag. Informational.")
parser.add_argument("--new", action="store_true", help="Invent a blank tag's UID and tap it")
args = parser.parse_args()
if not READER_ID_RE.match(args.reader_id):
print(
f"--reader-id {args.reader_id!r} is not usable: it travels in a websocket path, so it may only "
"contain letters, digits, dot, underscore, colon or dash (max 64).",
file=sys.stderr,
)
return 2
api = Api(args.url)
try:
info = api.get("/info")
except (ApiError, RuntimeError) as e:
print(f"{e}\n\nIs Spoolman running? Point elsewhere with --url.", file=sys.stderr)
return 1
print(f"Spoolman {info.get('version', '?')} at {args.url}")
uids = list(args.uid)
if args.new:
uids.append(new_uid())
if not uids:
return interactive(api, args.reader_id, args.name, args.tag_format)
for uid in uids:
tap(api, uid, args.reader_id, args.name, args.tag_format)
return 0
if __name__ == "__main__":
sys.exit(main())