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