mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 18:12:18 -05:00
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.
219 lines
8.7 KiB
Python
Executable File
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())
|