mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-23 02:22:20 -05:00
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.
119 lines
3.6 KiB
Python
119 lines
3.6 KiB
Python
"""Router setup for the v1 version of the API."""
|
|
|
|
# ruff: noqa: D103
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from spoolman import env
|
|
from spoolman.database.database import backup_global_db
|
|
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, tag, vendor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(
|
|
title="Spoolman REST API v1",
|
|
version="1.0.0",
|
|
description="""
|
|
REST API for Spoolman.
|
|
|
|
The API is served on the path `/api/v1/`.
|
|
|
|
Some endpoints also serve a websocket on the same path. The websocket is used to listen for changes to the data
|
|
that the endpoint serves. The websocket messages are JSON objects. Additionally, there is a root-level websocket
|
|
endpoint that listens for changes to any data in the database.
|
|
""",
|
|
)
|
|
|
|
|
|
@app.exception_handler(ItemNotFoundError)
|
|
async def itemnotfounderror_exception_handler(_request: Request, exc: ItemNotFoundError) -> Response:
|
|
logger.debug(exc)
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"message": exc.args[0]},
|
|
)
|
|
|
|
|
|
# Add a general info endpoint
|
|
@app.get("/info")
|
|
async def info() -> models.Info:
|
|
"""Return general info about the API."""
|
|
return models.Info(
|
|
version=env.get_version(),
|
|
debug_mode=env.is_debug_mode(),
|
|
automatic_backups=env.is_automatic_backup_enabled(),
|
|
data_dir=str(env.get_data_dir().resolve()),
|
|
logs_dir=str(env.get_logs_dir().resolve()),
|
|
backups_dir=str(env.get_backups_dir().resolve()),
|
|
db_type=str(env.get_database_type() or "sqlite"),
|
|
external_db_name=get_external_db_name(),
|
|
git_commit=env.get_commit_hash(),
|
|
build_date=env.get_build_date(),
|
|
)
|
|
|
|
|
|
# Add health check endpoint
|
|
@app.get("/health")
|
|
async def health() -> models.HealthCheck:
|
|
"""Return a health check."""
|
|
return models.HealthCheck(status="healthy")
|
|
|
|
|
|
# Add endpoint for triggering a db backup
|
|
@app.post(
|
|
"/backup",
|
|
description="Trigger a database backup. Only applicable for SQLite databases.",
|
|
response_model=models.BackupResponse,
|
|
responses={500: {"model": models.Message}},
|
|
)
|
|
async def backup(): # noqa: ANN201
|
|
"""Trigger a database backup."""
|
|
result = await backup_global_db()
|
|
if result.path is None:
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"message": "Backup failed. See server logs for more information."},
|
|
)
|
|
return models.BackupResponse(path=str(result.path), created=result.created)
|
|
|
|
|
|
@app.websocket(
|
|
"/",
|
|
name="Listen to any changes",
|
|
)
|
|
async def notify(
|
|
websocket: WebSocket,
|
|
) -> None:
|
|
await websocket.accept()
|
|
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:
|
|
websocket_manager.disconnect((), websocket)
|
|
|
|
|
|
# Add routers
|
|
app.include_router(filament.router)
|
|
app.include_router(spool.router)
|
|
app.include_router(vendor.router)
|
|
app.include_router(setting.router)
|
|
app.include_router(field.router)
|
|
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)
|