NFC phase 1 backend: tag identity, lookup, and the scan relay

Implements the backend half of design/nfc-phase-1-tag-identity.md. Format
agnostic: it keys only on a tag's hardware UID, so it works with blank NTAG
stickers, Prusa NFC-V tags, and the Bambu/Creality/Qidi MIFARE tags whose
contents Spoolman can never decrypt.

Answers the question the whole external ecosystem has been working around:
which spool is this physical tag? Every project so far invented its own way to
smuggle a Spoolman id into the tag contents, which fails the moment the tag is
a vendor tag you cannot write to.

Data model: new spool_tag table, additive only, with a unique index on uid.
Many tags per spool, since copying a vendor tag onto a blank sticker leaves one
spool with two UIDs. UIDs are normalized to uppercase hex with separators
stripped, in the database layer rather than a Pydantic validator, so the unique
constraint holds for callers that never arrive over HTTP.

API, all additive:
  GET    /spool?tag=<uid>          exact match on the normalized UID
  POST   /spool/{id}/tag           201, 409 with the conflicting spool id
  DELETE /spool/{id}/tag/{uid}     204
  POST   /tag/scan                 broadcast and return the match
  GET    /tag/reader               readers seen recently
  WS     /tag/scan[/{reader_id}]   the relay

Link and unlink emit the existing spool event, so clients need no new wiring.
Spool responses gain a tags array.

The relay decouples where the reader is from where the user is looking: an
agent POSTs a scan, a paired browser reacts. Scans are ephemeral, never
persisted, and go through a SECOND WebsocketManager instance rather than the
shared one. The shared tree propagates to subscribers at every level, and the
root /api/v1/ pool means "any change", so putting scans there would inject a
novel resource into every existing consumer's stream. An integration test pins
that they never reach the entity websockets.

Repeated identical (uid, reader_id) scans are debounced before the broadcast
only, since readers re-read a tag that is sitting still. The response always
carries the match, so a de-duplicated scan never looks to a device like a
failed lookup.

Migration creates the table and its indexes in one revision, following the
extra-fields tables rather than the doc's suggested split. The DDL/DML split
cockroachdb needs does not apply when there is no data migration, and the 4-DB
suite confirms it.

Verified: 506 integration tests on sqlite, postgres, mariadb and cockroachdb;
263 unit tests; ruff clean.

Frontend, docs, and the #783 action links are not included.
This commit is contained in:
Donkie committed 2026-08-13 18:56:35 +02:00
1 parent f261dee61b
commit de533bc74d
18 files changed
+1870 -3

No files matched your search

@@ -0,0 +1,50 @@
"""spool tags.
Revision ID: fe4970567bb3
Revises: 9c1d5f2a7b31
Create Date: 2026-08-13 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "fe4970567bb3"
down_revision = "9c1d5f2a7b31"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create the spool_tag table linking physical NFC/RFID tags to spools."""
# Purely additive: one new table, no existing column touched, so lossless by
# construction. CREATE TABLE and its indexes ride the same revision, which is the
# pattern the extra-fields tables already established (b8881bdb716c) and which the
# 4-DB suite covers; the DDL/DML split cockroachdb needs (see 304a32906234) does not
# apply here because there is no data migration.
op.create_table(
"spool_tag",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("uid", sa.String(length=64), nullable=False),
sa.Column("format", sa.String(length=32), nullable=True),
sa.Column("added", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_spool_tag_id"), "spool_tag", ["id"], unique=False)
op.create_index(op.f("ix_spool_tag_spool_id"), "spool_tag", ["spool_id"], unique=False)
# The whole point of the table: one tag, one spool, enforced by the database rather
# than by whichever client happened to write last.
op.create_index(op.f("ix_spool_tag_uid"), "spool_tag", ["uid"], unique=True)
def downgrade() -> None:
"""Perform the downgrade."""
op.drop_index(op.f("ix_spool_tag_uid"), table_name="spool_tag")
op.drop_index(op.f("ix_spool_tag_spool_id"), table_name="spool_tag")
op.drop_index(op.f("ix_spool_tag_id"), table_name="spool_tag")
op.drop_table("spool_tag")
+123
View File
@@ -87,6 +87,17 @@ class Message(BaseModel):
message: str = Field()
class TagConflictMessage(Message):
"""A tag UID is already linked to a different spool.
Subclasses Message so the `message` key is where it is in every other error body, and
adds the conflicting spool's ID so a client can offer to move the tag there instead of
making the user go and find it.
"""
spool_id: int = Field(description="The spool the tag is already linked to.", examples=[42])
class SettingResponse(BaseModel):
value: str = Field(description="Setting value.")
is_set: bool = Field(description="Whether the setting has been set. If false, 'value' contains the default value.")
@@ -282,6 +293,32 @@ class Filament(BaseModel):
)
class SpoolTag(BaseModel):
"""A physical NFC/RFID tag linked to a spool."""
uid: str = Field(
description=(
"The tag's hardware UID, normalized to uppercase hexadecimal with separators "
"stripped. Unique across all spools: one tag identifies exactly one spool."
),
examples=["04A2B3C4D5E6F7"],
)
format: str | None = Field(
None,
description=(
"What kind of tag this is, e.g. openprinttag, ntag, bambu, tigertag. "
"Informational, free-form, and not validated against a fixed list."
),
examples=["ntag"],
)
added: SpoolmanDateTime = Field(description="When the tag was linked to the spool. UTC Timezone.")
@staticmethod
def from_db(item: models.SpoolTag) -> "SpoolTag":
"""Create a new Pydantic spool tag object from a database spool tag object."""
return SpoolTag(uid=item.uid, format=item.format, added=item.added)
class Spool(BaseModel):
id: int = Field(description="Unique internal ID of this spool of filament.")
registered: SpoolmanDateTime = Field(description="When the spool was registered in the database. UTC Timezone.")
@@ -362,6 +399,13 @@ class Spool(BaseModel):
extra: dict[str, str] = Field(
description=_extra_fields_description("spool"),
)
tags: list[SpoolTag] = Field(
default_factory=list,
description=(
"NFC/RFID tags linked to this spool. A spool can carry more than one tag, e.g. when a "
"vendor tag has been copied onto a blank sticker. Empty if none are linked."
),
)
@staticmethod
def from_db(item: models.Spool) -> "Spool":
@@ -410,6 +454,7 @@ class Spool(BaseModel):
comment=item.comment,
archived=item.archived if item.archived is not None else False,
extra={field.key: field.value for field in item.extra},
tags=[SpoolTag.from_db(tag) for tag in item.tags],
)
@@ -563,6 +608,69 @@ class SearchResults(BaseModel):
)
class TagScan(BaseModel):
"""One tag read reported by a reader-side agent.
Scans are ephemeral: they are broadcast to the scan websockets and never stored. The
match is resolved server-side and included, so an agent that ignores websockets
entirely can use the scan endpoint as a one-shot lookup.
"""
uid: str = Field(
description="The scanned tag's UID, normalized to uppercase hexadecimal with separators stripped.",
examples=["04A2B3C4D5E6F7"],
)
reader_id: str = Field(
description=(
"Which reader reported the scan. Either the id the agent sent, or one derived from its "
"network address when it sent none."
),
examples=["printer-voron"],
)
name: str | None = Field(
None,
description="Human-readable name for the reader, if it sent one.",
examples=["Voron spool holder"],
)
format: str | None = Field(
None,
description="What kind of tag this is, if the agent could tell.",
examples=["ntag"],
)
payload_b64: str | None = Field(
None,
description=(
"The tag's raw contents, base64-encoded, if the agent read them. Carried through "
"untouched: Spoolman does not decode tag contents."
),
)
matched_spool_id: int | None = Field(
None,
description="The spool this tag is linked to, or null if the tag is not known to Spoolman.",
examples=[42],
)
spool: Spool | None = Field(
None,
description="The matched spool, so a client needs no follow-up request. Null if the tag is unknown.",
)
class TagReader(BaseModel):
"""A reader that has reported a scan recently.
The registry is in-memory and is not persisted: a reader reappears the moment it
scans again, and the list is empty after a restart until one does.
"""
reader_id: str = Field(description="The reader's id.", examples=["printer-voron"])
name: str | None = Field(
None,
description="Human-readable name for the reader, if it has sent one.",
examples=["Voron spool holder"],
)
last_seen: SpoolmanDateTime = Field(description="When this reader last reported a scan. UTC Timezone.")
class Info(BaseModel):
version: str = Field(examples=["0.7.0"])
debug_mode: bool = Field(examples=[False])
@@ -607,6 +715,9 @@ class EventType(str, Enum):
ADDED = "added"
UPDATED = "updated"
DELETED = "deleted"
# Only ever emitted on the dedicated scan websockets (see TagScanEvent), never on the
# entity ones, so no existing consumer can receive it.
SCANNED = "scanned"
class Event(BaseModel):
@@ -644,3 +755,15 @@ class SettingEvent(Event):
payload: SettingKV = Field(description="Updated setting.")
resource: Literal["setting"] = Field(description="Resource type.")
class TagScanEvent(Event):
"""A tag was scanned by a reader.
Travels ONLY on the dedicated scan websockets, which are a separate subscription tree
from the entity ones. It therefore never reaches the root /api/v1/ websocket, whose
subscribers asked to hear about changes to data -- and a scan changes nothing.
"""
payload: TagScan = Field(description="The scan.")
resource: Literal["tag_scan"] = Field(description="Resource type.")
+2 -1
View File
@@ -16,7 +16,7 @@ from spoolman.exceptions import ItemNotFoundError
from spoolman.externaldb import get_external_db_name
from spoolman.ws import websocket_manager
from . import export, externaldb, field, filament, models, other, search, setting, spool, vendor
from . import export, externaldb, field, filament, models, other, search, setting, spool, tag, vendor
logger = logging.getLogger(__name__)
@@ -115,3 +115,4 @@ app.include_router(other.router)
app.include_router(externaldb.router)
app.include_router(export.router)
app.include_router(search.router)
app.include_router(tag.router)
+111 -2
View File
@@ -5,7 +5,7 @@ import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, Path, Query, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
@@ -18,14 +18,20 @@ from spoolman.api.v1.models import (
Spool,
SpoolEvent,
SpoolGroup,
SpoolTag,
TagConflictMessage,
Vendor,
extra_fields_request_description,
)
# Aliased: `tag` is taken by the find endpoint's query parameter, whose name is API surface.
from spoolman.database import spool
from spoolman.database import tag as tag_db
from spoolman.database.database import get_db_session
from spoolman.database.utils import parse_sort
from spoolman.exceptions import ItemCreateError, SpoolMeasureError
from spoolman.exceptions import ItemCreateError, SpoolMeasureError, TagConflictError
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.tags import FORMAT_MAX_LENGTH, KNOWN_FORMATS, UID_MAX_LENGTH
from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__)
@@ -291,6 +297,20 @@ async def find(
),
),
] = None,
tag: Annotated[
str | None,
Query(
title="Tag UID",
description=(
"Match the spool that an NFC/RFID tag with this UID is linked to. Exact match on the "
"normalized UID: separators are ignored and case does not matter, so 04:a2:b3:c4, "
"04-A2-B3-C4 and 04a2b3c4 all find the same spool. A tag is linked to at most one "
"spool, so this returns either one spool or none. Returns 400 if the UID is not "
"hexadecimal."
),
examples=["04A2B3C4D5E6F7", "04:a2:b3:c4:d5:e6:f7"],
),
] = None,
allow_archived: Annotated[
bool,
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
@@ -343,6 +363,7 @@ async def find(
vendor_id=filament_vendor_ids,
location=location,
lot_nr=lot_nr,
tag=tag,
allow_archived=allow_archived,
extra_field_filters=spool_extra or None,
filament_extra_field_filters=filament_extra or None,
@@ -769,6 +790,94 @@ async def delete(
return Message(message="Success!")
class SpoolTagParameters(BaseModel):
uid: str = Field(
min_length=1,
max_length=UID_MAX_LENGTH * 2, # room for separators; the normalized UID is what must fit
description=(
"The tag's hardware UID, in whatever shape the reader reports it. Separators (:, -, _, "
"spaces) are stripped and the result is uppercased before storing, so every spelling of "
"one physical tag resolves to the same tag."
),
examples=["04:a2:b3:c4:d5:e6:f7", "04A2B3C4D5E6F7"],
)
format: str | None = Field(
None,
max_length=FORMAT_MAX_LENGTH,
description=(
"What kind of tag this is. Informational; not validated against a fixed list, because new "
f"tag types appear faster than releases do. Commonly one of: {', '.join(KNOWN_FORMATS)}."
),
examples=["ntag"],
)
@router.post(
"/{spool_id}/tag",
name="Link a tag to a spool",
description=(
"Link a physical NFC/RFID tag to this spool, so that the tag's UID identifies it. "
"A tag belongs to exactly one spool; linking a UID that another spool already holds "
"returns 409 with that spool's id, so a client can offer to move it instead. "
"Re-linking a tag to the spool that already holds it succeeds and changes nothing, "
"except that a format sent now refines one recorded earlier."
),
status_code=201,
response_model_exclude_none=True,
response_model=SpoolTag,
responses={
400: {"model": Message},
404: {"model": Message},
409: {"model": TagConflictMessage},
},
)
async def link_tag( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
spool_id: int,
body: SpoolTagParameters,
):
try:
db_item = await tag_db.link(db=db, spool_id=spool_id, uid=body.uid, tag_format=body.format)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
except TagConflictError as e:
return JSONResponse(
status_code=409,
content=TagConflictMessage(message=str(e), spool_id=e.spool_id).dict(),
)
return SpoolTag.from_db(db_item)
@router.delete(
"/{spool_id}/tag/{uid}",
name="Unlink a tag from a spool",
description=(
"Unlink a physical NFC/RFID tag from this spool. The UID is matched the same way it is "
"stored: separators are ignored and case does not matter. Deleting a spool unlinks its "
"tags on its own, so this is only for taking one tag off a spool that keeps existing."
),
status_code=204,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def unlink_tag(
db: Annotated[AsyncSession, Depends(get_db_session)],
spool_id: int,
uid: Annotated[
str,
Path(
title="Tag UID",
description="The tag's UID, in any shape. Normalized before matching.",
examples=["04A2B3C4D5E6F7"],
),
],
) -> Response:
try:
await tag_db.unlink(db=db, spool_id=spool_id, uid=uid)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
return Response(status_code=204)
@router.put(
"/{spool_id}/use",
name="Use spool filament",
+226
View File
@@ -0,0 +1,226 @@
"""Tag scan relay endpoints.
Spoolman never talks to reader hardware; the reader talks to Spoolman. The reader is almost
never on the Spoolman host -- Spoolman runs in Docker on a NAS while the tag gets tapped at the
printer -- so the contract between the two is one plain HTTP POST per tap, with no auth, no
handshake, no inbound port on the device and no library. Anything that can POST JSON qualifies,
which is why Node-RED, Home Assistant, a shell script and a microcontroller are all first-class
here without Spoolman shipping code for any of them.
What the relay adds on top of a lookup is decoupling *where the reader is* from *where the user
is looking*: the scan is broadcast to browsers that subscribed to that reader, so tapping a tag
at the printer can drive a page open on a desk across the house.
"""
import asyncio
import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import EventType, Message, Spool, TagReader, TagScan, TagScanEvent
from spoolman.database import tag as tag_db
from spoolman.database.database import get_db_session
from spoolman.scanrelay import READER_ID_PATTERN, derive_reader_id, scan_relay
from spoolman.tags import FORMAT_MAX_LENGTH, KNOWN_FORMATS, UID_MAX_LENGTH, normalize_uid
from spoolman.ws import scan_websocket_manager
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/tag",
tags=["tag"],
)
# ruff: noqa: D103
# Tag contents are carried through untouched and never decoded, so the only thing worth
# constraining is size: an unbounded payload is an unbounded websocket broadcast to every
# subscriber, from an unauthenticated endpoint.
PAYLOAD_MAX_LENGTH = 8192
class TagScanParameters(BaseModel):
uid: str = Field(
min_length=1,
max_length=UID_MAX_LENGTH * 2, # room for separators; the normalized UID is what must fit
description=(
"The scanned tag's UID, in whatever shape the reader reports it. Any separator style, "
"any case: the server normalizes it, which is why an ESPHome device sending 04-a2-b3 "
"and a phone sending 04:A2:B3 identify the same tag."
),
examples=["04-A2-B3-C4-D5-E6-F7"],
)
reader_id: str | None = Field(
None,
pattern=READER_ID_PATTERN,
description=(
"A stable, operator-chosen id for this reader, e.g. the device hostname. Browsers "
"subscribe by it, so it is what binds a screen to a reader. If omitted, one is derived "
"from the client's network address, which works but breaks on DHCP churn."
),
examples=["printer-voron"],
)
name: str | None = Field(
None,
max_length=64,
description="Human-readable name for the reader, shown when choosing one.",
examples=["Voron spool holder"],
)
format: str | None = Field(
None,
max_length=FORMAT_MAX_LENGTH,
description=(
"What kind of tag this is, if the agent can tell. Informational; not validated against "
f"a fixed list. Commonly one of: {', '.join(KNOWN_FORMATS)}."
),
examples=["ntag"],
)
payload_b64: str | None = Field(
None,
max_length=PAYLOAD_MAX_LENGTH,
description=(
"The tag's raw contents, base64-encoded, if the agent read them. Accepted and carried "
"into the broadcast untouched; Spoolman does not decode tag contents."
),
)
@router.post(
"/scan",
name="Report a tag scan",
description=(
"Report that a reader has scanned a tag. Spoolman resolves the tag to a spool and returns "
"the match, then broadcasts the scan on the tag scan websockets so a paired browser can "
"react to it.\n\n"
"The response is the whole integration for a device that only wants a lookup: "
"`matched_spool_id` is always present, null when the tag is not linked to anything. A "
"device may ignore the response entirely.\n\n"
"Scans are ephemeral -- broadcast only, never stored. Repeated identical scans from the "
"same reader within a few seconds are broadcast once, because readers re-detect a tag that "
"is sitting still; the response is unaffected, so a de-duplicated scan never looks to the "
"device like a failed lookup."
),
responses={
200: {"model": TagScan},
400: {"model": Message},
299: {"model": TagScanEvent, "description": "Websocket message"},
},
)
async def scan(
*,
request: Request,
db: Annotated[AsyncSession, Depends(get_db_session)],
body: TagScanParameters,
) -> JSONResponse:
reader_id = body.reader_id or derive_reader_id(request.client.host if request.client else None)
try:
# Normalized here so the response can echo the canonical form back -- an agent should be
# able to see the shape its tag is stored under without reimplementing the rules.
# database/tag.py normalizes again on the way to the query; doing so is idempotent, and
# that layer is what makes the guarantee hold for callers that never come through here.
uid = normalize_uid(body.uid)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
db_spool = await tag_db.find_spool_by_uid(db, uid)
scan_relay.register(reader_id, body.name)
result = TagScan(
uid=uid,
reader_id=reader_id,
name=body.name,
format=body.format,
payload_b64=body.payload_b64,
matched_spool_id=db_spool.id if db_spool is not None else None,
spool=Spool.from_db(db_spool) if db_spool is not None else None,
)
if scan_relay.should_broadcast(uid, reader_id):
await _broadcast(result)
content = jsonable_encoder(result, exclude_none=True)
# exclude_none strips a null match, but "no match" is the answer to the device's question
# and it has to be able to see it. Everything else may be omitted.
content.setdefault("matched_spool_id", None)
return JSONResponse(content=content)
async def _broadcast(result: TagScan) -> None:
"""Broadcast a scan to the reader's pool, and to anyone following every reader."""
try:
await scan_websocket_manager.send(
(result.reader_id,),
TagScanEvent(
type=EventType.SCANNED,
resource="tag_scan",
date=datetime.utcnow(),
payload=result,
),
)
except Exception:
# Same reasoning as spool_changed: a websocket failure must never fail the request
# that triggered it.
logger.exception("Failed to send websocket message")
@router.get(
"/reader",
name="List recently seen tag readers",
description=(
"List the readers that have reported a scan recently, so a client can offer a "
"'choose a reader' picker instead of asking someone to type an id.\n\n"
"The registry is in-memory and never persisted: it is empty after a restart, and a "
"reader reappears the moment it scans again."
),
response_model_exclude_none=True,
)
async def readers() -> list[TagReader]:
return [
TagReader(reader_id=reader.reader_id, name=reader.name, last_seen=reader.last_seen)
for reader in scan_relay.readers()
]
@router.websocket(
"/scan",
name="Listen to tag scans from any reader",
)
async def notify_any(
websocket: WebSocket,
) -> None:
await websocket.accept()
scan_websocket_manager.connect((), websocket)
try:
while True:
await asyncio.sleep(0.5)
if await websocket.receive_text():
await websocket.send_json({"status": "healthy"})
except WebSocketDisconnect:
scan_websocket_manager.disconnect((), websocket)
@router.websocket(
"/scan/{reader_id}",
name="Listen to tag scans from one reader",
)
async def notify(
websocket: WebSocket,
reader_id: str,
) -> None:
await websocket.accept()
scan_websocket_manager.connect((reader_id,), websocket)
try:
while True:
await asyncio.sleep(0.5)
if await websocket.receive_text():
await websocket.send_json({"status": "healthy"})
except WebSocketDisconnect:
scan_websocket_manager.disconnect((reader_id,), websocket)
+32
View File
@@ -94,6 +94,14 @@ class Spool(Base):
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
)
# selectin for the same reason as `extra` above: a spool listing already joins
# filament and vendor, and a joined collection here would multiply against the
# extra-field rows.
tags: Mapped[list["SpoolTag"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
)
class Setting(Base):
@@ -129,3 +137,27 @@ class SpoolField(Base):
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class SpoolTag(Base):
"""A physical NFC/RFID tag linked to a spool.
Many tags per spool is a real case rather than a hypothetical: copying a Prusa NFC-V
tag's payload onto an NTAG215 so a PN532 can read it leaves one spool carrying two
physical tags with two different UIDs (#776).
The unique index on `uid` is the point of the table -- one tag, one spool, enforced.
It only means that because every write goes through `spoolman.tags.normalize_uid`;
see the module docstring there.
"""
__tablename__ = "spool_tag"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), index=True)
spool: Mapped["Spool"] = relationship(back_populates="tags")
uid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
# Free-ish string (openprinttag, ntag, bambu, ...) rather than an enum: informational
# in phase 1, and new tag types appear faster than migrations should.
format: Mapped[str | None] = mapped_column(String(32))
added: Mapped[datetime] = mapped_column()
+17
View File
@@ -34,6 +34,7 @@ from spoolman.database.utils import (
from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError
from spoolman.extra_field_registry import EntityType, ExtraField, ExtraFieldType, get_extra_fields
from spoolman.math import weight_from_length
from spoolman.tags import normalize_uid
from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__)
@@ -103,6 +104,11 @@ async def create(
comment=comment,
archived=archived,
extra=[models.SpoolField(key=k, value=v) for k, v in (extra or {}).items() if v is not None],
# Explicitly empty rather than left unset: a selectin collection that was never
# populated is *unloaded* on the persistent object after the commit below, so
# Spool.from_db would emit a lazy SELECT from async code and raise MissingGreenlet.
# A new spool has no tags, and saying so costs nothing.
tags=[],
)
db.add(spool)
await db.commit()
@@ -133,6 +139,7 @@ async def find( # noqa: C901
vendor_id: int | Sequence[int] | None = None,
location: str | None = None,
lot_nr: str | None = None,
tag: str | None = None,
allow_archived: bool = False,
extra_field_filters: dict[str, str] | None = None,
filament_extra_field_filters: dict[str, str] | None = None,
@@ -158,6 +165,7 @@ async def find( # noqa: C901
vendor_id=vendor_id,
location=location,
lot_nr=lot_nr,
tag=tag,
allow_archived=allow_archived,
).options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor))
@@ -267,10 +275,19 @@ def _apply_spool_filters(
vendor_id: int | Sequence[int] | None = None,
location: str | None = None,
lot_nr: str | None = None,
tag: str | None = None,
allow_archived: bool = False,
) -> sqlalchemy.Select:
"""Apply the standard spool joins and where-clauses shared by find and find_groups."""
stmt = stmt.join(models.Spool.filament, isouter=True).join(models.Filament.vendor, isouter=True)
if tag is not None:
# An inner join rather than a subquery, so the list query and the count query stay
# in sync automatically: they share this builder. `uid` is unique, so at most one
# spool_tag row can match and the join cannot multiply result rows -- which is why
# the contains_eager chain for filament/vendor is unaffected.
stmt = stmt.join(models.SpoolTag, models.SpoolTag.spool_id == models.Spool.id).where(
models.SpoolTag.uid == normalize_uid(tag),
)
stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id)
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
+152
View File
@@ -0,0 +1,152 @@
"""Helper functions for interacting with spool tag database objects.
Kept out of `spool.py`, which is already the largest module in the tree, but following
its conventions exactly: `AsyncSession` first, exceptions from `spoolman.exceptions`,
and the websocket event emitted after the commit.
This module is the ONE place a UID gets normalized, on the way in and on every lookup.
Doing it in a Pydantic validator would read better but would only cover callers that
arrive over HTTP -- and the unique constraint is worthless if any other path (an
importer, a migration backfill, a future tag codec) can write a differently-shaped UID.
"""
import logging
from datetime import datetime
import sqlalchemy
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import EventType
from spoolman.database import models, spool
from spoolman.exceptions import ItemNotFoundError, TagConflictError
from spoolman.tags import normalize_format, normalize_uid
logger = logging.getLogger(__name__)
async def _get_tag_by_uid(db: AsyncSession, uid: str) -> models.SpoolTag | None:
"""Get the tag with this exact normalized UID, if any. Hits the unique index."""
stmt = sqlalchemy.select(models.SpoolTag).where(models.SpoolTag.uid == uid)
return (await db.execute(stmt)).scalar_one_or_none()
async def link(
*,
db: AsyncSession,
spool_id: int,
uid: str,
tag_format: str | None = None,
) -> models.SpoolTag:
"""Link a physical tag to a spool.
Re-linking a UID to the spool that already holds it is idempotent; if the request
carries a format and the stored row has a different one, the stored one is refined,
since a later scan generally knows more about the tag than the first one did.
Args:
db: Database session.
spool_id: The spool to link the tag to.
uid: The tag UID in any shape; normalized here.
tag_format: Optional tag format name, e.g. "ntag".
Returns:
models.SpoolTag: The linked tag.
Raises:
ItemNotFoundError: If no spool with that ID exists.
TagConflictError: If the UID is already linked to a different spool.
ValueError: If the UID or format is not valid.
"""
uid = normalize_uid(uid)
tag_format = normalize_format(tag_format)
db_spool = await spool.get_by_id(db, spool_id)
existing = await _get_tag_by_uid(db, uid)
if existing is not None:
if existing.spool_id != spool_id:
raise TagConflictError(
f"Tag {uid} is already linked to spool {existing.spool_id}.",
existing.spool_id,
)
if tag_format is not None and existing.format != tag_format:
existing.format = tag_format
await db.commit()
await spool.spool_changed(db_spool, EventType.UPDATED)
return existing
tag = models.SpoolTag(
uid=uid,
format=tag_format,
added=datetime.utcnow().replace(microsecond=0),
)
db_spool.tags.append(tag)
try:
await db.commit()
except IntegrityError:
# Two clients linked the same UID at the same time and the unique index caught
# the loser. The database, not the read above, is what makes "one tag, one spool"
# true; report the winner the same way a sequential conflict is reported.
await db.rollback()
winner = await _get_tag_by_uid(db, uid)
if winner is None:
raise
raise TagConflictError(
f"Tag {uid} is already linked to spool {winner.spool_id}.",
winner.spool_id,
) from None
await spool.spool_changed(db_spool, EventType.UPDATED)
return tag
async def unlink(*, db: AsyncSession, spool_id: int, uid: str) -> None:
"""Unlink a tag from a spool.
Args:
db: Database session.
spool_id: The spool the tag is linked to.
uid: The tag UID in any shape; normalized here.
Raises:
ItemNotFoundError: If the spool does not exist, or does not hold that tag.
ValueError: If the UID is not valid.
"""
uid = normalize_uid(uid)
db_spool = await spool.get_by_id(db, spool_id)
for tag in db_spool.tags:
if tag.uid == uid:
# delete-orphan on the relationship turns this into the DELETE.
db_spool.tags.remove(tag)
break
else:
raise ItemNotFoundError(f"Spool {spool_id} has no tag with UID {uid}.")
# Commit before notifying so the change is durable and visible to subsequent
# requests; post-commit notification must be the last, infallible step.
await db.commit()
await spool.spool_changed(db_spool, EventType.UPDATED)
async def find_spool_by_uid(db: AsyncSession, uid: str) -> models.Spool | None:
"""Find the spool a tag UID is linked to, or None if the tag is unknown.
Args:
db: Database session.
uid: The tag UID in any shape; normalized here.
Returns:
models.Spool | None: The spool holding that tag.
Raises:
ValueError: If the UID is not valid.
"""
tag = await _get_tag_by_uid(db, normalize_uid(uid))
if tag is None:
return None
return await spool.get_by_id(db, tag.spool_id)
+14
View File
@@ -15,3 +15,17 @@ class ItemCreateError(Exception):
class SpoolMeasureError(Exception):
pass
class TagConflictError(Exception):
"""A tag UID is already linked to a different spool.
Carries the conflicting spool's ID rather than only a message, so the API can hand it
to the client and the client can offer "move the tag to this spool instead" without a
second lookup.
"""
def __init__(self, message: str, spool_id: int) -> None:
"""Initialize with the message and the ID of the spool already holding the tag."""
super().__init__(message)
self.spool_id = spool_id
+8
View File
@@ -51,6 +51,14 @@ async def flatten_sqlalchemy_object(obj: models.Base, parent_key: str = "", sep:
fields[f"{parent_key}extra.{v.key}"] = v.value
continue
if attr == "tags":
# A collection, like `extra`, so it needs collapsing into a cell rather
# than being left as a list of ORM objects: CSV would write their repr and
# JSON would stringify them. A spool's tags have no keys to spread across
# columns, so they become one comma-separated list of UIDs.
fields[f"{parent_key}tags"] = ",".join(v.uid for v in value)
continue
# Handle nested SQLAlchemy objects
if isinstance(value, models.Base):
nested_fields = await flatten_sqlalchemy_object(value, f"{parent_key}{attr}{sep}", sep=sep)
+148
View File
@@ -0,0 +1,148 @@
"""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
# 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.
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
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) -> when it was last broadcast.
self._last_broadcast: dict[tuple[str, str], 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, now: datetime | None = None) -> None:
"""Record that a reader reported a scan just now."""
now = now or datetime.utcnow()
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,
)
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, 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.
"""
now = now or datetime.utcnow()
key = (uid, reader_id)
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.utcnow()
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()
+96
View File
@@ -0,0 +1,96 @@
"""NFC/RFID tag identity: UID normalization and the known tag formats.
Spoolman keys a tag on its hardware UID and nothing else, which is the one thing every
tag has -- a blank NTAG215 sticker, a Prusa NFC-V tag and a Bambu MIFARE tag whose
contents we can never decrypt all report one. Readers, however, report that UID in
whatever shape their library happens to use: Web NFC's ``serialNumber`` is
colon-separated lowercase, ESPHome emits ``04-a2-b3``, nfcpy hands you raw bytes that
most scripts ``.hex()`` into unseparated lowercase.
``normalize_uid`` collapses all of those to one canonical spelling so that the unique
index on ``spool_tag.uid`` actually means "one tag, one spool". Without it the same
physical tag could be linked to three spools under three spellings and no constraint
would notice.
Pure module: no database, no FastAPI. Same shape as ``colors.py`` and ``math.py``.
"""
import re
# Mirrors the SpoolTag.uid column width, so an over-long UID is a 400 and not a 500.
UID_MAX_LENGTH = 64
# Mirrors the SpoolTag.format column width.
FORMAT_MAX_LENGTH = 32
# Tag formats Spoolman knows the name of. Deliberately NOT an enum and NOT enforced:
# `format` is informational in phase 1, new tag types appear faster than migrations
# should, and rejecting an unknown one would make Spoolman the thing standing between a
# user and their hardware. These exist to document the vocabulary and to keep clients
# spelling the common ones the same way.
KNOWN_FORMATS = (
"openprinttag",
"opentag3d",
"ntag",
"bambu",
"tigertag",
"qidi",
"creality",
"prusa",
)
# Separators readers put between UID bytes, plus the whitespace that survives a
# copy-paste out of a log line.
_SEPARATORS = re.compile(r"[\s:_.-]+")
_HEX_ONLY = re.compile(r"^[0-9A-F]+$")
def normalize_uid(uid: str) -> str:
"""Normalize a tag UID to its canonical form: uppercase hex, no separators.
Accepts any separator style and any case, plus an optional ``0x`` prefix, so a
reader-side agent never has to care which shape its NFC library produces.
Args:
uid: The UID as reported by a reader, e.g. ``04:a2:b3:c4``, ``04-A2-B3-C4`` or
``04a2b3c4``.
Returns:
str: The canonical UID, e.g. ``04A2B3C4``.
Raises:
ValueError: If the UID is empty, is not hexadecimal, or is longer than the
column can hold. A tag UID is a byte string by definition, so anything else
is a caller bug worth surfacing rather than a row worth storing -- a
junk-drawer uid column would make the uniqueness guarantee meaningless.
"""
normalized = _SEPARATORS.sub("", uid).strip().upper().removeprefix("0X")
if not normalized:
raise ValueError("A tag UID must not be empty.")
if not _HEX_ONLY.match(normalized):
raise ValueError(f'"{uid}" is not a valid tag UID. A UID must be hexadecimal.')
if len(normalized) > UID_MAX_LENGTH:
raise ValueError(f"A tag UID can be at most {UID_MAX_LENGTH} hex characters.")
return normalized
def normalize_format(tag_format: str | None) -> str | None:
"""Normalize a tag format name, or return None if none was given.
Lowercased and trimmed so that ``NTAG`` and ``ntag`` are the same format. Unknown
values pass through unchanged -- see KNOWN_FORMATS.
Raises:
ValueError: If the name is longer than the column can hold.
"""
if tag_format is None:
return None
normalized = tag_format.strip().lower()
if not normalized:
return None
if len(normalized) > FORMAT_MAX_LENGTH:
raise ValueError(f"A tag format can be at most {FORMAT_MAX_LENGTH} characters.")
return normalized
+127
View File
@@ -0,0 +1,127 @@
"""Tests for the scan relay's debounce cache and reader registry.
Both are pure in-memory state, so they are tested here without a socket or a session. Time is
injected rather than slept through, which is what keeps this file fast enough to be worth
running on every change.
"""
import re
from datetime import datetime, timedelta, timezone
import pytest
from spoolman.scanrelay import MAX_READERS, READER_ID_PATTERN, ScanRelay, derive_reader_id
# The relay compares datetimes it is given against each other only, so the tzinfo here is
# just to keep the value unambiguous; the server passes naive UTC, as the rest of the code does.
T0 = datetime(2026, 8, 13, 12, 0, 0, tzinfo=timezone.utc)
@pytest.fixture
def relay() -> ScanRelay:
return ScanRelay(debounce_window=timedelta(seconds=3), reader_ttl=timedelta(hours=1))
def test_first_scan_broadcasts(relay: ScanRelay):
assert relay.should_broadcast("04A2", "desk", now=T0) is True
def test_repeat_within_the_window_is_suppressed(relay: ScanRelay):
"""A reader re-reads a tag that is sitting still; subscribers should see one event."""
relay.should_broadcast("04A2", "desk", now=T0)
assert relay.should_broadcast("04A2", "desk", now=T0 + timedelta(seconds=1)) is False
assert relay.should_broadcast("04A2", "desk", now=T0 + timedelta(seconds=2.9)) is False
def test_repeat_after_the_window_broadcasts_again(relay: ScanRelay):
"""Tapping the same tag again later is a new scan, not a duplicate."""
relay.should_broadcast("04A2", "desk", now=T0)
assert relay.should_broadcast("04A2", "desk", now=T0 + timedelta(seconds=4)) is True
def test_debounce_is_per_uid_and_per_reader(relay: ScanRelay):
"""Two readers tapping at once, or one reader seeing two tags, are distinct scans."""
relay.should_broadcast("04A2", "desk", now=T0)
assert relay.should_broadcast("04A2", "printer", now=T0) is True
assert relay.should_broadcast("B1C2", "desk", now=T0) is True
def test_debounce_entries_are_pruned(relay: ScanRelay):
"""The cache does not grow without bound as tags come and go."""
for i in range(50):
relay.should_broadcast(f"UID{i:04X}", "desk", now=T0)
assert len(relay._last_broadcast) == 50 # noqa: SLF001
relay.should_broadcast("LATER", "desk", now=T0 + timedelta(seconds=30))
assert len(relay._last_broadcast) == 1 # noqa: SLF001
def test_readers_are_listed_most_recently_seen_first(relay: ScanRelay):
relay.register("desk", "Desk reader", now=T0)
relay.register("printer", "Voron spool holder", now=T0 + timedelta(minutes=1))
readers = relay.readers(now=T0 + timedelta(minutes=2))
assert [r.reader_id for r in readers] == ["printer", "desk"]
assert readers[0].name == "Voron spool holder"
assert readers[1].last_seen == T0
def test_registry_is_empty_before_anything_scans(relay: ScanRelay):
"""The registry is not persisted, so this is also the state after a restart."""
assert relay.readers(now=T0) == []
def test_reader_keeps_its_name_when_a_later_scan_omits_one(relay: ScanRelay):
"""A trimmed-down agent must not blank the label in someone's picker."""
relay.register("desk", "Desk reader", now=T0)
relay.register("desk", None, now=T0 + timedelta(minutes=1))
readers = relay.readers(now=T0 + timedelta(minutes=2))
assert readers[0].name == "Desk reader"
assert readers[0].last_seen == T0 + timedelta(minutes=1)
def test_a_new_name_replaces_the_old_one(relay: ScanRelay):
relay.register("desk", "Desk reader", now=T0)
relay.register("desk", "Bench reader", now=T0 + timedelta(minutes=1))
assert relay.readers(now=T0 + timedelta(minutes=2))[0].name == "Bench reader"
def test_readers_past_their_ttl_are_evicted_on_read(relay: ScanRelay):
"""Eviction happens as the response is built: no background task, nothing to shut down."""
relay.register("desk", None, now=T0)
assert len(relay.readers(now=T0 + timedelta(minutes=59))) == 1
assert relay.readers(now=T0 + timedelta(hours=2)) == []
def test_registry_is_capped(relay: ScanRelay):
"""An unauthenticated endpoint cannot be used to grow the registry without bound."""
for i in range(MAX_READERS + 20):
relay.register(f"reader-{i}", None, now=T0 + timedelta(seconds=i))
readers = relay.readers(now=T0 + timedelta(minutes=30))
assert len(readers) == MAX_READERS
# The least recently seen are the ones dropped.
assert readers[0].reader_id == f"reader-{MAX_READERS + 19}"
assert {r.reader_id for r in readers} == {f"reader-{i}" for i in range(20, MAX_READERS + 20)}
@pytest.mark.parametrize(
("host", "expected"),
[
("192.168.1.50", "ip-192-168-1-50"),
("10.0.0.1", "ip-10-0-0-1"),
("fe80::1", "ip-fe80-1"),
("testclient", "ip-testclient"),
(None, "unknown"),
("", "unknown"),
("...", "unknown"),
],
)
def test_reader_id_is_derived_from_the_client_address(host: str | None, expected: str):
"""An unmodified agent that sends no reader_id still gets a stable id to pair against."""
assert derive_reader_id(host) == expected
def test_derived_reader_id_fits_the_pattern():
"""Whatever comes out must be usable as a websocket path segment."""
assert re.match(READER_ID_PATTERN, derive_reader_id("a" * 200))
+91
View File
@@ -0,0 +1,91 @@
"""Tests for tag UID normalization.
The unique index on `spool_tag.uid` only means "one tag, one spool" because every write goes
through `normalize_uid`. These pin the shapes real readers produce, so a change that stopped
collapsing one of them would show up here rather than as two spools quietly claiming the same
physical tag.
"""
import pytest
from spoolman.tags import UID_MAX_LENGTH, normalize_format, normalize_uid
@pytest.mark.parametrize(
"reported",
[
"04A2B3C4D5E6F7", # already canonical
"04a2b3c4d5e6f7", # nfcpy: bytes.hex()
"04:a2:b3:c4:d5:e6:f7", # Web NFC serialNumber
"04:A2:B3:C4:D5:E6:F7",
"04-a2-b3-c4-d5-e6-f7", # ESPHome on_tag
"04-A2-B3-C4-D5-E6-F7",
"04 a2 b3 c4 d5 e6 f7", # pasted out of a log line
"04_A2_B3_C4_D5_E6_F7",
"04.a2.b3.c4.d5.e6.f7",
"0x04a2b3c4d5e6f7",
" 04a2b3c4d5e6f7 ",
],
)
def test_every_reader_shape_collapses_to_one_uid(reported: str):
"""Every way a reader can spell one tag resolves to the same stored UID."""
assert normalize_uid(reported) == "04A2B3C4D5E6F7"
def test_normalization_is_idempotent():
"""Normalizing an already-normalized UID is a no-op, so layers may normalize twice."""
once = normalize_uid("04:a2:b3:c4")
assert normalize_uid(once) == once
@pytest.mark.parametrize(
"bad",
[
"",
" ",
":::",
"----",
"not-a-uid",
"04A2B3G4", # G is not hex
"04A2B3C4!",
"0x",
],
)
def test_rejects_uids_that_are_not_hex(bad: str):
"""A UID is a byte string by definition; anything else is a caller bug, not a row."""
with pytest.raises(ValueError, match="UID"):
normalize_uid(bad)
def test_rejects_uid_longer_than_the_column():
"""An over-long UID is a 400, not a database error."""
normalize_uid("A" * UID_MAX_LENGTH) # the limit itself is fine
with pytest.raises(ValueError, match="at most"):
normalize_uid("A" * (UID_MAX_LENGTH + 1))
def test_separators_do_not_count_towards_the_length_limit():
"""The limit applies to the stored UID, not to what the reader happened to send."""
assert len(normalize_uid(":".join("AB" * (UID_MAX_LENGTH // 2)))) == UID_MAX_LENGTH
@pytest.mark.parametrize(
("reported", "expected"),
[
("ntag", "ntag"),
("NTAG", "ntag"),
(" OpenPrintTag ", "openprinttag"),
("something-nobody-has-shipped-yet", "something-nobody-has-shipped-yet"),
(None, None),
("", None),
(" ", None),
],
)
def test_format_is_normalized_but_not_restricted(reported: str | None, expected: str | None):
"""Formats are lowercased for consistency, and unknown ones pass through unchanged."""
assert normalize_format(reported) == expected
def test_rejects_format_longer_than_the_column():
with pytest.raises(ValueError, match="at most"):
normalize_format("f" * 33)
+298
View File
@@ -0,0 +1,298 @@
"""Integration tests: linking physical NFC/RFID tags to spools, and looking a spool up by one.
The point of the `spool_tag` table is that one tag identifies exactly one spool, enforced by the
database rather than by whichever client wrote last. These tests pin that from both ends: the
409 that stops a second spool claiming a tag, and the normalization that stops the same physical
tag being linked twice under two spellings.
"""
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import httpx
import pytest
from ..conftest import URL, assert_httpx_code, assert_httpx_success
def _uid() -> str:
"""Build a UID no other test in the session will produce. Hex, as a real UID is."""
return uuid.uuid4().hex[:14].upper()
@contextmanager
def _spool(filament_id: int, **kwargs: object) -> Iterator[dict[str, Any]]:
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": filament_id, **kwargs})
assert_httpx_success(result)
spool = result.json()
try:
yield spool
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
def _get_spool(spool_id: int) -> dict[str, Any]:
result = httpx.get(f"{URL}/api/v1/spool/{spool_id}")
assert_httpx_success(result)
return result.json()
def test_link_tag(random_filament: dict[str, Any]):
"""Linking returns the stored tag and adds it to the spool."""
uid = _uid()
with _spool(random_filament["id"]) as spool:
result = httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid, "format": "ntag"})
assert_httpx_code(result, 201)
tag = result.json()
assert tag["uid"] == uid
assert tag["format"] == "ntag"
assert "added" in tag
assert _get_spool(spool["id"])["tags"] == [tag]
def test_spool_with_no_tags_has_an_empty_list(random_filament: dict[str, Any]):
"""`tags` is always present, so a client never has to distinguish absent from empty."""
with _spool(random_filament["id"]) as spool:
assert spool["tags"] == []
assert _get_spool(spool["id"])["tags"] == []
def test_link_tag_without_a_format(random_filament: dict[str, Any]):
"""Format is optional; a reader that only knows the UID can still link."""
with _spool(random_filament["id"]) as spool:
result = httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": _uid()})
assert_httpx_code(result, 201)
# exclude_none omits it rather than sending an explicit null.
assert "format" not in result.json()
@pytest.mark.parametrize(
"reported",
["{uid}", "{lower}", "{colons}", "{dashes}", "{spaces}"],
)
def test_uid_is_normalized_on_write(random_filament: dict[str, Any], reported: str):
"""Every shape a reader can report writes the same canonical UID."""
uid = _uid()
spelling = reported.format(
uid=uid,
lower=uid.lower(),
colons=":".join(uid[i : i + 2] for i in range(0, len(uid), 2)),
dashes="-".join(uid[i : i + 2] for i in range(0, len(uid), 2)).lower(),
spaces=" ".join(uid[i : i + 2] for i in range(0, len(uid), 2)),
)
with _spool(random_filament["id"]) as spool:
result = httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": spelling})
assert_httpx_code(result, 201)
assert result.json()["uid"] == uid
def test_link_is_idempotent(random_filament: dict[str, Any]):
"""Re-linking a tag to the spool that already holds it succeeds and adds nothing."""
uid = _uid()
with _spool(random_filament["id"]) as spool:
for _ in range(3):
assert_httpx_code(httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}), 201)
tags = _get_spool(spool["id"])["tags"]
assert len(tags) == 1
assert tags[0]["uid"] == uid
def test_relinking_with_a_format_refines_the_stored_one(random_filament: dict[str, Any]):
"""A later scan generally knows more about the tag than the first one did."""
uid = _uid()
with _spool(random_filament["id"]) as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
result = httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid, "format": "openprinttag"})
assert_httpx_code(result, 201)
tags = _get_spool(spool["id"])["tags"]
assert len(tags) == 1
assert tags[0]["format"] == "openprinttag"
def test_duplicate_uid_on_another_spool_is_a_conflict(random_filament: dict[str, Any]):
"""One tag, one spool. The conflicting spool's id comes back so a client can offer to move it."""
uid = _uid()
with _spool(random_filament["id"]) as first, _spool(random_filament["id"]) as second:
httpx.post(f"{URL}/api/v1/spool/{first['id']}/tag", json={"uid": uid}).raise_for_status()
result = httpx.post(f"{URL}/api/v1/spool/{second['id']}/tag", json={"uid": uid})
assert_httpx_code(result, 409)
assert result.json()["spool_id"] == first["id"]
assert "message" in result.json()
# The losing spool got nothing.
assert _get_spool(second["id"])["tags"] == []
def test_conflict_is_detected_across_uid_spellings(random_filament: dict[str, Any]):
"""A different spelling of a linked tag is the same tag, so it conflicts.
Normalization is what makes the unique index mean anything: without it, one physical tag
could occupy three rows on three spools and no constraint would notice.
"""
uid = _uid()
with _spool(random_filament["id"]) as first, _spool(random_filament["id"]) as second:
httpx.post(f"{URL}/api/v1/spool/{first['id']}/tag", json={"uid": uid}).raise_for_status()
colons = ":".join(uid[i : i + 2] for i in range(0, len(uid), 2)).lower()
result = httpx.post(f"{URL}/api/v1/spool/{second['id']}/tag", json={"uid": colons})
assert_httpx_code(result, 409)
assert result.json()["spool_id"] == first["id"]
def test_many_tags_on_one_spool(random_filament: dict[str, Any]):
"""A spool can carry a vendor tag and a copy of it on a blank sticker; a real case (#776)."""
uids = sorted(_uid() for _ in range(3))
with _spool(random_filament["id"]) as spool:
for uid in uids:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
assert sorted(tag["uid"] for tag in _get_spool(spool["id"])["tags"]) == uids
def test_unlink_tag(random_filament: dict[str, Any]):
uid = _uid()
with _spool(random_filament["id"]) as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
assert_httpx_code(httpx.delete(f"{URL}/api/v1/spool/{spool['id']}/tag/{uid}"), 204)
assert _get_spool(spool["id"])["tags"] == []
def test_unlink_accepts_any_uid_spelling(random_filament: dict[str, Any]):
uid = _uid()
with _spool(random_filament["id"]) as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
assert_httpx_code(httpx.delete(f"{URL}/api/v1/spool/{spool['id']}/tag/{uid.lower()}"), 204)
assert _get_spool(spool["id"])["tags"] == []
def test_unlinking_frees_the_uid(random_filament: dict[str, Any]):
"""After unlinking, another spool can take the tag -- the unique row really is gone."""
uid = _uid()
with _spool(random_filament["id"]) as first, _spool(random_filament["id"]) as second:
httpx.post(f"{URL}/api/v1/spool/{first['id']}/tag", json={"uid": uid}).raise_for_status()
httpx.delete(f"{URL}/api/v1/spool/{first['id']}/tag/{uid}").raise_for_status()
assert_httpx_code(httpx.post(f"{URL}/api/v1/spool/{second['id']}/tag", json={"uid": uid}), 201)
def test_unlink_unknown_tag_is_404(random_filament: dict[str, Any]):
with _spool(random_filament["id"]) as spool:
assert_httpx_code(httpx.delete(f"{URL}/api/v1/spool/{spool['id']}/tag/{_uid()}"), 404)
def test_unlink_tag_belonging_to_another_spool_is_404(random_filament: dict[str, Any]):
"""A tag is only unlinkable from the spool that holds it."""
uid = _uid()
with _spool(random_filament["id"]) as first, _spool(random_filament["id"]) as second:
httpx.post(f"{URL}/api/v1/spool/{first['id']}/tag", json={"uid": uid}).raise_for_status()
assert_httpx_code(httpx.delete(f"{URL}/api/v1/spool/{second['id']}/tag/{uid}"), 404)
assert len(_get_spool(first["id"])["tags"]) == 1
def test_link_to_unknown_spool_is_404():
assert_httpx_code(httpx.post(f"{URL}/api/v1/spool/-1/tag", json={"uid": _uid()}), 404)
@pytest.mark.parametrize("bad", ["", "not-a-uid", "04A2B3G4", "!!!"])
def test_link_with_an_invalid_uid_is_rejected(random_filament: dict[str, Any], bad: str):
"""A UID is hexadecimal by definition; junk is a client bug, not a row worth storing."""
with _spool(random_filament["id"]) as spool:
result = httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": bad})
# An empty UID fails Pydantic's min_length (422); the rest fail normalization (400).
assert result.status_code in (400, 422), result.text
def test_deleting_a_spool_removes_its_tags(random_filament: dict[str, Any]):
"""Cascade: the tag row must go with the spool, or its UID would be permanently unusable."""
uid = _uid()
with _spool(random_filament["id"]) as doomed:
httpx.post(f"{URL}/api/v1/spool/{doomed['id']}/tag", json={"uid": uid}).raise_for_status()
httpx.delete(f"{URL}/api/v1/spool/{doomed['id']}").raise_for_status()
with _spool(random_filament["id"]) as fresh:
assert_httpx_code(httpx.post(f"{URL}/api/v1/spool/{fresh['id']}/tag", json={"uid": uid}), 201)
def test_find_spool_by_tag(random_filament: dict[str, Any]):
"""Answer the lookup the whole ecosystem has been working around: which spool is this tag."""
uid = _uid()
with _spool(random_filament["id"]) as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
result = httpx.get(f"{URL}/api/v1/spool", params={"tag": uid})
assert_httpx_success(result)
found = result.json()
assert len(found) == 1
assert found[0]["id"] == spool["id"]
assert result.headers["x-total-count"] == "1"
@pytest.mark.parametrize("style", ["lower", "colons", "dashes"])
def test_find_by_tag_normalizes_the_query(random_filament: dict[str, Any], style: str):
"""A reader querying with its own spelling finds the spool it linked."""
uid = _uid()
spellings = {
"lower": uid.lower(),
"colons": ":".join(uid[i : i + 2] for i in range(0, len(uid), 2)),
"dashes": "-".join(uid[i : i + 2] for i in range(0, len(uid), 2)).lower(),
}
with _spool(random_filament["id"]) as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
result = httpx.get(f"{URL}/api/v1/spool", params={"tag": spellings[style]})
assert_httpx_success(result)
assert [s["id"] for s in result.json()] == [spool["id"]]
def test_find_by_unknown_tag_is_empty():
"""An unknown tag is not an error; it is a tag nobody has linked yet."""
result = httpx.get(f"{URL}/api/v1/spool", params={"tag": _uid()})
assert_httpx_success(result)
assert result.json() == []
assert result.headers["x-total-count"] == "0"
@pytest.mark.parametrize("bad", ["not-a-uid", "04A2B3G4"])
def test_find_by_invalid_tag_is_400(bad: str):
assert_httpx_code(httpx.get(f"{URL}/api/v1/spool", params={"tag": bad}), 400)
def test_find_by_tag_respects_allow_archived(random_filament: dict[str, Any]):
"""The tag filter behaves like every other filter: archived spools are hidden by default."""
uid = _uid()
with _spool(random_filament["id"], archived=True) as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
assert httpx.get(f"{URL}/api/v1/spool", params={"tag": uid}).json() == []
result = httpx.get(f"{URL}/api/v1/spool", params={"tag": uid, "allow_archived": True})
assert_httpx_success(result)
assert [s["id"] for s in result.json()] == [spool["id"]]
def test_find_by_tag_combines_with_other_filters(random_filament: dict[str, Any]):
"""The tag join goes through the shared filter builder, so it composes and the count agrees."""
uid = _uid()
with _spool(random_filament["id"], location="Shelf T") as spool:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
matching = httpx.get(f"{URL}/api/v1/spool", params={"tag": uid, "location": "Shelf T", "limit": 10})
assert_httpx_success(matching)
assert [s["id"] for s in matching.json()] == [spool["id"]]
assert matching.headers["x-total-count"] == "1"
# A limit makes the count a separate query; it must agree with the rows.
missing = httpx.get(f"{URL}/api/v1/spool", params={"tag": uid, "location": "Nowhere", "limit": 10})
assert_httpx_success(missing)
assert missing.json() == []
assert missing.headers["x-total-count"] == "0"
+1
View File
@@ -0,0 +1 @@
"""Integration tests for the tag scan relay."""
@@ -0,0 +1,221 @@
"""Integration tests for the tag scan websockets.
Two things are being pinned here. The first is the routing the pairing UX depends on: a scan
must reach the browser that follows *that* reader, and only it. The second is the API v1
compatibility guard -- scans live in their own subscription tree, so the existing `/api/v1/`
root stream, whose subscribers asked to hear about changes to data, must never see one.
"""
import asyncio
import contextlib
import json
import uuid
from typing import Any
import httpx
import pytest
from websockets.asyncio.client import ClientConnection, connect
from ..conftest import URL
WS_URL = URL.replace("http://", "ws://", 1)
# How long to wait for an event we expect NOT to arrive. Long enough that a leak into the
# wrong pool would reliably land inside it, short enough to keep the suite quick across all
# four database backends.
QUIET_PERIOD_S = 2.0
# Long enough to absorb a slow container, short enough to fail rather than hang.
RECV_TIMEOUT_S = 10.0
def _uid() -> str:
return uuid.uuid4().hex[:14].upper()
def _reader_id() -> str:
return f"reader-{uuid.uuid4().hex[:8]}"
def _scan(uid: str, reader_id: str, **kwargs: object) -> httpx.Response:
return httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": uid, "reader_id": reader_id, **kwargs})
async def _expect_nothing(ws: ClientConnection, why: str) -> None:
with contextlib.suppress(TimeoutError):
event = json.loads(await asyncio.wait_for(ws.recv(), timeout=QUIET_PERIOD_S))
pytest.fail(f"{why}: {event}")
@pytest.mark.asyncio
async def test_scan_reaches_the_readers_own_pool():
"""The subscription a browser holds after tap-to-pair."""
uid, reader_id = _uid(), _reader_id()
async with connect(f"{WS_URL}/api/v1/tag/scan/{reader_id}") as ws:
# The subscription is registered synchronously right after the handshake,
# but give the server a beat before triggering the event.
await asyncio.sleep(0.2)
_scan(uid, reader_id, name="Voron spool holder").raise_for_status()
event = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
assert event["type"] == "scanned"
assert event["resource"] == "tag_scan"
assert event["payload"]["uid"] == uid
assert event["payload"]["reader_id"] == reader_id
assert event["payload"]["name"] == "Voron spool holder"
@pytest.mark.asyncio
async def test_scan_does_not_reach_another_readers_pool():
"""A wall tablet by printer A must not jump when someone taps a tag at printer B."""
uid, mine, theirs = _uid(), _reader_id(), _reader_id()
async with connect(f"{WS_URL}/api/v1/tag/scan/{mine}") as ws:
await asyncio.sleep(0.2)
_scan(uid, theirs).raise_for_status()
await _expect_nothing(ws, "a scan leaked into another reader's pool")
@pytest.mark.asyncio
async def test_root_scan_pool_follows_every_reader():
"""Pairing a scanner works by listening to everything until the first tap arrives."""
reader_a, reader_b = _reader_id(), _reader_id()
uid_a, uid_b = _uid(), _uid()
async with connect(f"{WS_URL}/api/v1/tag/scan") as ws:
await asyncio.sleep(0.2)
_scan(uid_a, reader_a).raise_for_status()
first = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
_scan(uid_b, reader_b).raise_for_status()
second = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
assert (first["payload"]["uid"], first["payload"]["reader_id"]) == (uid_a, reader_a)
assert (second["payload"]["uid"], second["payload"]["reader_id"]) == (uid_b, reader_b)
@pytest.mark.asyncio
async def test_scan_carries_the_match(random_filament: dict[str, Any]):
"""A subscriber navigates from the event alone, without a follow-up request."""
uid, reader_id = _uid(), _reader_id()
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"]})
result.raise_for_status()
spool = result.json()
try:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
async with connect(f"{WS_URL}/api/v1/tag/scan/{reader_id}") as ws:
await asyncio.sleep(0.2)
_scan(uid, reader_id).raise_for_status()
event = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
assert event["payload"]["matched_spool_id"] == spool["id"]
assert event["payload"]["spool"]["id"] == spool["id"]
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
@pytest.mark.asyncio
async def test_scans_never_reach_the_entity_websockets():
"""THE API v1 compatibility guard.
`/api/v1/` subscribes with pool () -- "listen to any changes" -- and SubscriptionTree
broadcasts to every level along a path. Had scans been added as a resource on the shared
manager, they would have appeared in every existing root consumer's stream. They are on a
separate manager precisely so they cannot.
"""
uid, reader_id = _uid(), _reader_id()
async with connect(f"{WS_URL}/api/v1/") as root, connect(f"{WS_URL}/api/v1/spool") as spools:
await asyncio.sleep(0.2)
_scan(uid, reader_id).raise_for_status()
await _expect_nothing(root, "a scan reached the root entity websocket")
await _expect_nothing(spools, "a scan reached the spool websocket")
@pytest.mark.asyncio
async def test_repeated_scans_are_broadcast_once():
"""Readers re-detect a tag that is sitting still; subscribers should see one event."""
uid, reader_id = _uid(), _reader_id()
async with connect(f"{WS_URL}/api/v1/tag/scan/{reader_id}") as ws:
await asyncio.sleep(0.2)
for _ in range(4):
_scan(uid, reader_id).raise_for_status()
event = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
assert event["payload"]["uid"] == uid
await _expect_nothing(ws, "a debounced repeat scan was broadcast")
@pytest.mark.asyncio
async def test_debounce_suppresses_the_broadcast_not_the_response(random_filament: dict[str, Any]):
"""A de-duplicated scan must not look to the device like a failed lookup."""
uid, reader_id = _uid(), _reader_id()
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"]})
result.raise_for_status()
spool = result.json()
try:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
for _ in range(4):
scan = _scan(uid, reader_id)
scan.raise_for_status()
assert scan.json()["matched_spool_id"] == spool["id"]
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
@pytest.mark.asyncio
async def test_distinct_tags_from_one_reader_are_not_debounced():
"""Debouncing is per (uid, reader): moving from one spool to the next is two scans."""
reader_id = _reader_id()
uid_a, uid_b = _uid(), _uid()
async with connect(f"{WS_URL}/api/v1/tag/scan/{reader_id}") as ws:
await asyncio.sleep(0.2)
_scan(uid_a, reader_id).raise_for_status()
_scan(uid_b, reader_id).raise_for_status()
seen = {
json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))["payload"]["uid"],
json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))["payload"]["uid"],
}
assert seen == {uid_a, uid_b}
@pytest.mark.asyncio
async def test_linking_a_tag_emits_a_spool_event(random_filament: dict[str, Any]):
"""Link and unlink ride the existing spool event.
They mutate a spool as far as any client is concerned, so an open inspector re-renders
with the new tag list without a single line of client-side wiring.
"""
uid = _uid()
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"]})
result.raise_for_status()
spool = result.json()
try:
async with connect(f"{WS_URL}/api/v1/spool/{spool['id']}") as ws:
await asyncio.sleep(0.2)
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
linked = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}/tag/{uid}").raise_for_status()
unlinked = json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT_S))
assert linked["type"] == "updated"
assert linked["resource"] == "spool"
assert [tag["uid"] for tag in linked["payload"]["tags"]] == [uid]
assert unlinked["type"] == "updated"
assert unlinked["payload"]["tags"] == []
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
+153
View File
@@ -0,0 +1,153 @@
"""Integration tests for POST /tag/scan and GET /tag/reader.
The scan endpoint is the whole device-side contract: one HTTP POST per tap, and a response a
device can light an LED from. These tests drive it with plain POSTs, which is also how a user
can demo the entire browser-side experience with no NFC hardware in the room.
"""
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import httpx
import pytest
from ..conftest import URL, assert_httpx_code, assert_httpx_success
def _uid() -> str:
return uuid.uuid4().hex[:14].upper()
def _reader_id() -> str:
return f"reader-{uuid.uuid4().hex[:8]}"
@contextmanager
def _spool_with_tag(filament_id: int, uid: str) -> Iterator[dict[str, Any]]:
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": filament_id})
assert_httpx_success(result)
spool = result.json()
try:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
yield spool
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
def test_scan_of_a_known_tag_returns_the_spool(random_filament: dict[str, Any]):
"""The lookup a device makes on every tap, answered in one round trip."""
uid = _uid()
with _spool_with_tag(random_filament["id"], uid) as spool:
result = httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": uid, "reader_id": _reader_id()})
assert_httpx_success(result)
body = result.json()
assert body["matched_spool_id"] == spool["id"]
assert body["spool"]["id"] == spool["id"]
# The embedded spool is the standard object, so a device needs no follow-up request.
assert body["spool"]["filament"]["id"] == random_filament["id"]
def test_scan_of_an_unknown_tag_reports_no_match():
"""`matched_spool_id` is present and null -- "not known yet" is an answer, not a missing key."""
result = httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": _uid(), "reader_id": _reader_id()})
assert_httpx_success(result)
body = result.json()
assert "matched_spool_id" in body
assert body["matched_spool_id"] is None
assert body.get("spool") is None
def test_scan_normalizes_and_echoes_the_uid(random_filament: dict[str, Any]):
"""A device sending its own spelling still matches, and is told the canonical form."""
uid = _uid()
dashed = "-".join(uid[i : i + 2] for i in range(0, len(uid), 2)).lower()
with _spool_with_tag(random_filament["id"], uid) as spool:
result = httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": dashed, "reader_id": _reader_id()})
assert_httpx_success(result)
body = result.json()
assert body["uid"] == uid
assert body["matched_spool_id"] == spool["id"]
def test_scan_finds_an_archived_spool(random_filament: dict[str, Any]):
"""Tapping an archived spool's tag should say which spool it is, not pretend it is unknown."""
uid = _uid()
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "archived": True})
assert_httpx_success(result)
spool = result.json()
try:
httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}).raise_for_status()
scan = httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": uid, "reader_id": _reader_id()})
assert_httpx_success(scan)
assert scan.json()["matched_spool_id"] == spool["id"]
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
@pytest.mark.parametrize("bad", ["not-a-uid", "04A2B3G4", "!!!"])
def test_scan_with_an_invalid_uid_is_400(bad: str):
assert_httpx_code(httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": bad, "reader_id": _reader_id()}), 400)
def test_scan_with_a_malformed_reader_id_is_rejected():
"""reader_id travels in a websocket path, so it is constrained rather than taken as-is."""
result = httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": _uid(), "reader_id": "has spaces/and/slashes"})
assert_httpx_code(result, 422)
def test_scanning_registers_the_reader():
"""The registry backs a 'choose a reader' picker for people who prefer it to tap-to-pair."""
reader_id = _reader_id()
httpx.post(
f"{URL}/api/v1/tag/scan",
json={"uid": _uid(), "reader_id": reader_id, "name": "Voron spool holder"},
).raise_for_status()
result = httpx.get(f"{URL}/api/v1/tag/reader")
assert_httpx_success(result)
readers = {reader["reader_id"]: reader for reader in result.json()}
assert reader_id in readers
assert readers[reader_id]["name"] == "Voron spool holder"
assert "last_seen" in readers[reader_id]
def test_reader_id_is_derived_when_the_agent_sends_none():
"""A dozen lines of ESPHome YAML that will never be rewritten still gets a stable id."""
result = httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": _uid()})
assert_httpx_success(result)
reader_id = result.json()["reader_id"]
assert reader_id.startswith("ip-")
listed = httpx.get(f"{URL}/api/v1/tag/reader")
assert_httpx_success(listed)
assert reader_id in {reader["reader_id"] for reader in listed.json()}
def test_scan_stores_nothing(random_filament: dict[str, Any]):
"""Scans are ephemeral. A scan must not link a tag, create a spool, or change one."""
uid = _uid()
before = httpx.get(f"{URL}/api/v1/spool", params={"limit": 1})
assert_httpx_success(before)
httpx.post(f"{URL}/api/v1/tag/scan", json={"uid": uid, "reader_id": _reader_id()}).raise_for_status()
# The scanned UID is still unlinked afterwards, so a spool can claim it.
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"]})
assert_httpx_success(result)
spool = result.json()
try:
assert_httpx_code(httpx.post(f"{URL}/api/v1/spool/{spool['id']}/tag", json={"uid": uid}), 201)
finally:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}")
after = httpx.get(f"{URL}/api/v1/spool", params={"limit": 1})
assert_httpx_success(after)
assert after.headers["x-total-count"] == before.headers["x-total-count"]