Skip to content
24 changes: 20 additions & 4 deletions spoolman/api/v1/externaldb.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""External database API."""

import json
import logging
from typing import Annotated

from fastapi import APIRouter, Query, Response
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse

from spoolman.env import is_tigertag_enabled
from spoolman.externaldb import (
ExternalFilament,
ExternalMaterial,
Expand All @@ -29,9 +31,23 @@
response_model_exclude_none=True,
response_model=list[ExternalFilament],
)
async def filaments() -> FileResponse:
"""Get all external filaments."""
return FileResponse(path=get_filaments_file(), media_type="application/json")
async def filaments() -> FileResponse | JSONResponse:
"""Get all external filaments.

When the optional TigerTag add-on is enabled, its synced catalog is merged in
alongside the default SpoolmanDB catalog. Import stays lazy so a deployment that
never enables the add-on never loads its module.
"""
if not is_tigertag_enabled():
return FileResponse(path=get_filaments_file(), media_type="application/json")

from spoolman.tigertagdb import get_tigertag_filaments_file # noqa: PLC0415

combined = json.loads(get_filaments_file().read_bytes())
tigertag_path = get_tigertag_filaments_file()
if tigertag_path.exists():
combined.extend(json.loads(tigertag_path.read_bytes()))
return JSONResponse(content=combined)


@router.get(
Expand Down
11 changes: 8 additions & 3 deletions spoolman/api/v1/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ async def scan(
db_spool = target if isinstance(target, models.Spool) else None
db_filament = target if isinstance(target, models.Filament) else None

decoded = _decode_payload(body.format, body.payload_b64, uid)
decoded = await _decode_payload(body.format, body.payload_b64, uid)

created = False
# A tag can identify a filament as well as a spool; either counts as already linked. Creating
Expand Down Expand Up @@ -232,12 +232,17 @@ async def scan(
return JSONResponse(content=content)


def _decode_payload(tag_format: str | None, payload_b64: str | None, uid: str) -> tag_decode.DecodedTag | None:
async def _decode_payload(tag_format: str | None, payload_b64: str | None, uid: str) -> tag_decode.DecodedTag | None:
"""Decode a scan's payload, if both a format and a payload were given.

Soft-fails on everything: a missing field, invalid base64, an unknown format or an
unparseable payload for a known one all return None. Decoding is enrichment of a scan,
never a reason to fail one -- see the class docstring on TagDecodedInfo.

Async (rather than the plain `tag_decode.decode()` most callers want) so a TigerTag
scan can await a live per-tag catalog lookup when the external-DB add-on is enabled --
see `tag_decode.decode_async`. Every other format, and TigerTag with the add-on off,
take the same path `decode()` would; the `await` costs nothing extra there.
"""
if tag_format is None or payload_b64 is None:
return None
Expand All @@ -253,7 +258,7 @@ def _decode_payload(tag_format: str | None, payload_b64: str | None, uid: str) -
uid_bytes: bytes | None = bytes.fromhex(uid)
except ValueError:
uid_bytes = None
return tag_decode.decode(tag_format, raw, uid_bytes=uid_bytes)
return await tag_decode.decode_async(tag_format, raw, uid_bytes=uid_bytes, uid=uid)


def _decoded_response(decoded: tag_decode.DecodedTag | None) -> TagDecodedInfo | None:
Expand Down
43 changes: 43 additions & 0 deletions spoolman/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,3 +573,46 @@ def is_legacy_client_enabled() -> bool:
raise ValueError(
f"Failed to parse SPOOLMAN_LEGACY_CLIENT variable: Unknown value '{legacy_client}'.",
)


def is_tigertag_enabled() -> bool:
"""Get whether the TigerTag external database add-on is enabled.

Off by default: this talks to a third-party service (api.tigertag.io) on a
schedule, which nothing in core should do without an explicit opt-in.

Returns:
bool: Whether the TigerTag database add-on is enabled.

"""
enabled = os.getenv("SPOOLMAN_TIGERTAG_ENABLED", "FALSE").upper()
if enabled in {"FALSE", "0"}:
return False
if enabled in {"TRUE", "1"}:
return True
raise ValueError(
f"Failed to parse SPOOLMAN_TIGERTAG_ENABLED variable: Unknown value '{enabled}'.",
)


def get_tigertag_api_url() -> str:
"""Get the TigerTag API base URL from environment variables.

Returns:
str: The TigerTag API base URL.

"""
return os.getenv("SPOOLMAN_TIGERTAG_API_URL", "https://api.tigertag.io/api:tigertag/")


def get_tigertag_sync_interval() -> int:
"""Get the TigerTag catalog sync interval, in seconds, from environment variables.

A value of 0 disables periodic re-sync; the catalog is still fetched once at
startup.

Returns:
int: The sync interval in seconds.

"""
return int(os.getenv("SPOOLMAN_TIGERTAG_SYNC_INTERVAL", "3600"))
7 changes: 7 additions & 0 deletions spoolman/externaldb.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ class ExternalFilament(BaseModel):
)
pattern: Pattern | None = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
source: str | None = Field(
default=None,
description="Which catalog this entry came from, if not the default SpoolmanDB. "
"Unset for SpoolmanDB entries; set by optional add-ons that merge in their own catalog, "
"e.g. 'tigertag'.",
examples=["tigertag"],
)
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")


Expand Down
7 changes: 7 additions & 0 deletions spoolman/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ async def startup() -> None:
externaldb.schedule_tasks(schedule)
update_check.schedule_tasks(schedule)

# Optional TigerTag external database add-on: only imported when enabled, so a
# deployment that never sets SPOOLMAN_TIGERTAG_ENABLED never loads it.
if env.is_tigertag_enabled():
from spoolman import tigertagdb # noqa: PLC0415

tigertagdb.schedule_tasks(schedule)

logger.info("Startup complete.")

if env.is_docker() and not env.is_data_dir_mounted():
Expand Down
163 changes: 163 additions & 0 deletions spoolman/tags/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@
Decoding is best-effort and pure: no database, no FastAPI. An unrecognized format or a payload
that fails to parse returns None rather than raising -- a scan's core contract (resolve a UID)
must never fail because enrichment of it didn't work.

One exception to "pure": TigerTag's on-tag `id_material`/`id_brand` are catalog IDs, not
names, and resolving them needs the optional external-DB add-on (spoolman/tigertagdb.py, off
by default -- see env.is_tigertag_enabled). `decode()` stays fully pure and reads only the
add-on's background-synced cache when it's enabled; `decode_async()` additionally awaits a
live per-tag API call first, for an exact catalog match, before falling back to that same
cache. Either way the add-on module itself is only ever imported when enabled, so a
deployment that never opts in never loads it.
"""

import io
import logging
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from spoolman.tags.tigertag import TigerTagData

from spoolman.tags import normalize_format

Expand Down Expand Up @@ -284,11 +296,50 @@ def decode(tag_format: str | None, payload: bytes, uid_bytes: bytes | None = Non
try:
if fmt == "openprinttag":
return _decode_openprinttag(payload, uid_bytes)
if fmt == "tigertag":
return _decode_tigertag(payload)
except Exception: # noqa: BLE001 -- best effort by contract, see the module docstring
logger.warning("Could not decode %s payload", fmt, exc_info=True)
return None


async def decode_async(
tag_format: str | None,
payload: bytes,
uid_bytes: bytes | None = None,
uid: str | None = None,
) -> DecodedTag | None:
"""Decode a tag's raw payload, resolving TigerTag names via a live API call if enabled.

Identical to `decode()` for every format, and for TigerTag with the add-on off -- the
only difference is TigerTag with it on, where this awaits
`tigertagdb.lookup_product_by_tag(uid, id_product)` for an exact catalog match before
falling back to the same background-synced cache `decode()` uses on its own. Prefer this
from request-handling code, which is already async and can afford the await; `decode()`
stays synchronous for pure/offline callers like the test suite.

Args:
tag_format: See `decode()`.
payload: See `decode()`.
uid_bytes: See `decode()`.
uid: The tag's UID as the same normalized hex string `/tag/scan` resolves it to (see
spoolman.tags.normalize_uid). TigerTag's live lookup needs it as a string, not
bytes -- it's the key the tag was registered against on TigerTag's own API.

Returns:
DecodedTag | None: See `decode()`.

"""
fmt = normalize_format(tag_format)
try:
if fmt == "tigertag":
return await _decode_tigertag_async(payload, uid)
except Exception: # noqa: BLE001 -- best effort by contract, see the module docstring
logger.warning("Could not decode %s payload", fmt, exc_info=True)
return None
return decode(tag_format, payload, uid_bytes)


def _decode_openprinttag(payload: bytes, uid_bytes: bytes | None) -> DecodedTag | None:
# Imported here, not at module level: cbor2 is only needed when a payload actually arrives,
# so a missing or broken cbor2 must not stop the server from starting.
Expand Down Expand Up @@ -322,3 +373,115 @@ def _decode_openprinttag(payload: bytes, uid_bytes: bytes | None) -> DecodedTag
bed_temp=bed_temp,
instance_uuid=data.effective_instance_uuid,
)


def _decode_tigertag_raw(payload: bytes) -> "TigerTagData | None":
# Imported here for the same reason as openprinttag above: the dependency should only be
# paid for when a tigertag payload actually arrives.
from spoolman.tags import tigertag as tigertag_codec # noqa: PLC0415

try:
data = tigertag_codec.decode_ntag213(payload)
except ValueError:
logger.debug("Could not decode payload as TigerTag", exc_info=True)
return None

if not tigertag_codec.is_tigertag(data.id_tigertag):
# Wrong magic number -- either not a TigerTag at all, or a blank/Init tag with
# nothing written to it yet. Either way there's nothing usable to surface.
return None

return data


def _tigertag_decoded(
raw: "TigerTagData",
*,
material: str | None = None,
name: str | None = None,
vendor_name: str | None = None,
density: float | None = None,
) -> DecodedTag:
# color_hex/diameter/weight always come straight off the physical tag, never from the
# external-DB add-on -- the tag is ground truth for this specific roll's physical
# properties; the add-on only ever fills in what the tag can't carry: names for
# id_material/id_brand's catalog IDs, and a material-specific density.
# id_product is a catalog SKU, not a per-roll identifier, so it's never used as
# instance_uuid -- same instance-uniqueness caution as OpenPrintTag's UID-derived id.
# TigerTag (NTAG213 "Maker"/"+") only ever tags filament spools, so material_class is
# always "FFF" -- unlike OpenPrintTag, there is no resin variant of this format.
return DecodedTag(
material_class="FFF",
material=material,
name=name,
vendor_name=vendor_name,
color_hex=raw.color_hex,
diameter=raw.diameter_mm or None,
density=density,
# weight_grams is None both when the tag has no weight and when it reports one in a
# unit other than grams (e.g. kilograms) -- either way, not a usable weight in grams.
weight=float(raw.weight_grams) if raw.weight_grams else None,
)


def _tigertag_decoded_from_cache(raw: "TigerTagData") -> DecodedTag:
"""Resolve names for a raw TigerTag decode from the add-on's background-synced cache.

Only called once the caller has already confirmed the add-on is enabled -- this is where
spoolman/tigertagdb.py actually gets imported, kept out of every other code path so a
deployment that never opts in never loads it.
"""
from spoolman import tigertagdb # noqa: PLC0415 -- see docstring

return _tigertag_decoded(
raw,
material=tigertagdb.lookup_material_name(raw.id_material),
name=tigertagdb.lookup_material_name(raw.id_material),
vendor_name=tigertagdb.lookup_brand_name(raw.id_brand),
density=tigertagdb.lookup_material_density(raw.id_material),
)


def _decode_tigertag(payload: bytes) -> DecodedTag | None:
from spoolman import env # noqa: PLC0415

raw = _decode_tigertag_raw(payload)
if raw is None:
return None
if not env.is_tigertag_enabled():
return _tigertag_decoded(raw)
return _tigertag_decoded_from_cache(raw)


async def _decode_tigertag_async(payload: bytes, uid: str | None) -> DecodedTag | None:
from spoolman import env # noqa: PLC0415

raw = _decode_tigertag_raw(payload)
if raw is None:
return None
if not env.is_tigertag_enabled():
return _tigertag_decoded(raw)

from spoolman import tigertagdb # noqa: PLC0415 -- see _tigertag_decoded_from_cache

if raw.id_product and uid is not None:
try:
ext = await tigertagdb.lookup_product_by_tag(uid, raw.id_product)
except Exception: # noqa: BLE001 -- enrichment must never fail the scan it's decoding
logger.debug("TigerTag live product lookup failed for uid=%s", uid, exc_info=True)
ext = None
if ext is not None:
return _tigertag_decoded(
raw,
material=ext.material if ext.material != "Unknown" else None,
name=ext.name,
vendor_name=ext.manufacturer if ext.manufacturer != "Unknown" else None,
# ext.density is a fixed 1.24 placeholder for every TigerTag product (see
# tigertagdb._to_external_filament) -- the materials cache's own per-material
# figure is the real one, so prefer it here too.
density=tigertagdb.lookup_material_density(raw.id_material),
)

# No product id on the tag, or the live lookup was unavailable/failed/found nothing --
# fall back to the coarser but still real background-synced cache.
return _tigertag_decoded_from_cache(raw)
Loading
Loading