Files
Spoolman/scripts/fake_reader.py
Donkie 5144b64a8a scripts: add a fake tag reader
The reader-to-Spoolman contract is one ordinary HTTP POST, which means a
complete conforming reader fits in a hundred lines of stdlib and needs no NFC
hardware at all. That is worth having as a tool: pairing, auto-navigate and
filling the link dialog are otherwise only reachable by someone who owns an
ESP32 and has already flashed it.

Interactive by default, because the thing you want while clicking around the
UI is to tap the same tag again, or a new one, without retyping a command.
Blank input re-taps the last UID -- which is also how to watch the server's
de-duplication behave, since it answers every repeat while broadcasting only
the first, exactly as it does for a real reader re-reading a tag that is
sitting on it.

Writes nothing and deletes nothing, unlike its neighbours in this directory:
a scan is an event, not a record.
2026-08-15 09:05:17 +02:00

219 lines
8.7 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 describe(spool: dict) -> str:
"""Name a spool the way the UI would, so a tap is recognisable at a glance."""
filament = spool.get("filament") or {}
vendor = (filament.get("vendor") or {}).get("name")
parts = [p for p in (vendor, filament.get("name")) if p]
label = " ".join(parts) or filament.get("material") or "unnamed filament"
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` is always present in the HTTP response, null when unknown —
# that is the published device contract, and it is what a real reader keys off.
if scan.get("matched_spool_id") is None:
print(f" {uid} — unknown tag, no spool has it")
else:
print(f" {uid} — {describe(scan['spool'])}")
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"})
rows = [(t["uid"], s) for s in spools for t in s.get("tags", [])]
if not rows:
print(" nothing is tagged yet — link one in a spool's inspector, or press 'n' for a blank tag")
return
for uid, spool in rows:
print(f" {uid} {describe(spool)}")
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())