mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 09:39:46 -05:00
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:
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")
|
||||
@@ -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.")
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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']}")
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user