|
@@ -4,19 +4,22 @@ import asyncio
|
|
|
import contextlib
|
|
import contextlib
|
|
|
import json
|
|
import json
|
|
|
import logging
|
|
import logging
|
|
|
|
|
+import re
|
|
|
import time
|
|
import time
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from datetime import datetime, timedelta, timezone
|
|
|
from urllib.parse import urlparse
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
import httpx
|
|
import httpx
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
-from sqlalchemy import select
|
|
|
|
|
|
|
+from sqlalchemy import func, select
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
+from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.database import get_db
|
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.core.permissions import Permission
|
|
|
from backend.app.core.websocket import ws_manager
|
|
from backend.app.core.websocket import ws_manager
|
|
|
|
|
+from backend.app.models.spool import Spool
|
|
|
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
|
|
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
|
|
|
from backend.app.models.user import User
|
|
from backend.app.models.user import User
|
|
|
from backend.app.schemas.spoolbuddy import (
|
|
from backend.app.schemas.spoolbuddy import (
|
|
@@ -36,12 +39,24 @@ from backend.app.schemas.spoolbuddy import (
|
|
|
TagRemovedRequest,
|
|
TagRemovedRequest,
|
|
|
TagScannedRequest,
|
|
TagScannedRequest,
|
|
|
UpdateSpoolWeightRequest,
|
|
UpdateSpoolWeightRequest,
|
|
|
- UpdateStatusRequest,
|
|
|
|
|
WriteTagRequest,
|
|
WriteTagRequest,
|
|
|
WriteTagResultRequest,
|
|
WriteTagResultRequest,
|
|
|
)
|
|
)
|
|
|
-from backend.app.services.spool_tag_matcher import get_spool_by_tag
|
|
|
|
|
|
|
+from backend.app.services.bambu_tag_blocks import spool_prefill_from_tag
|
|
|
|
|
+from backend.app.services.spool_tag_matcher import (
|
|
|
|
|
+ begins_with_cascade_level_1,
|
|
|
|
|
+ cascade_level_1_uid,
|
|
|
|
|
+ cascade_level_1_uid_start,
|
|
|
|
|
+ get_spool_by_tag,
|
|
|
|
|
+ warn_cascade_level_1_ambiguous,
|
|
|
|
|
+)
|
|
|
from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
|
|
from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
|
|
|
|
|
+from backend.app.utils.tag_normalization import (
|
|
|
|
|
+ is_bambu_tray_uuid,
|
|
|
|
|
+ normalize_hex,
|
|
|
|
|
+ normalize_tag_uid,
|
|
|
|
|
+ normalize_tray_uuid,
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
@@ -68,6 +83,19 @@ async def _translate_spoolbuddy_errors():
|
|
|
raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
|
|
raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _spoolman_mode(db: AsyncSession) -> bool:
|
|
|
|
|
+ """Spoolman mode as every SpoolBuddy screen reads it: switched on, with a URL.
|
|
|
|
|
+
|
|
|
|
|
+ Distinct from having a usable client: a URL the SSRF guard refuses leaves the
|
|
|
|
|
+ mode on but no client, and the spools are still Spoolman's.
|
|
|
|
|
+ """
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+
|
|
|
|
|
+ enabled = await get_setting(db, "spoolman_enabled")
|
|
|
|
|
+ url = await get_setting(db, "spoolman_url")
|
|
|
|
|
+ return bool(enabled) and enabled.lower() == "true" and bool((url or "").strip())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def _get_spoolman_client_or_none(db: AsyncSession):
|
|
async def _get_spoolman_client_or_none(db: AsyncSession):
|
|
|
"""Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
|
|
"""Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
|
|
|
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
|
|
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
|
|
@@ -177,7 +205,7 @@ def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
|
|
|
@router.post("/devices/register", response_model=DeviceResponse)
|
|
@router.post("/devices/register", response_model=DeviceResponse)
|
|
|
async def register_device(
|
|
async def register_device(
|
|
|
req: DeviceRegisterRequest,
|
|
req: DeviceRegisterRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Register or re-register a SpoolBuddy device."""
|
|
"""Register or re-register a SpoolBuddy device."""
|
|
@@ -248,7 +276,7 @@ async def register_device(
|
|
|
|
|
|
|
|
@router.get("/devices", response_model=list[DeviceResponse])
|
|
@router.get("/devices", response_model=list[DeviceResponse])
|
|
|
async def list_devices(
|
|
async def list_devices(
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
|
):
|
|
):
|
|
|
"""List all registered SpoolBuddy devices."""
|
|
"""List all registered SpoolBuddy devices."""
|
|
@@ -260,7 +288,7 @@ async def list_devices(
|
|
|
@router.delete("/devices/{device_id}")
|
|
@router.delete("/devices/{device_id}")
|
|
|
async def unregister_device(
|
|
async def unregister_device(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
|
|
|
):
|
|
):
|
|
|
"""Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
|
|
"""Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
|
|
@@ -281,7 +309,7 @@ async def unregister_device(
|
|
|
async def device_heartbeat(
|
|
async def device_heartbeat(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: HeartbeatRequest,
|
|
req: HeartbeatRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Daemon heartbeat — updates status and returns pending commands."""
|
|
"""Daemon heartbeat — updates status and returns pending commands."""
|
|
@@ -374,10 +402,183 @@ async def device_heartbeat(
|
|
|
# --- NFC endpoints ---
|
|
# --- NFC endpoints ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _backfill_local_tray_uuid(db: AsyncSession, spool: Spool, tag_uid: str, tray_uuid: str) -> None:
|
|
|
|
|
+ """Give a spool matched by its exact tag UID the tray UUID read from that tag.
|
|
|
|
|
+
|
|
|
|
|
+ Spools added on the kiosk before #984 carry only the UID of the tag that was
|
|
|
|
|
+ scanned then, so the spool's other tag and the AMS never found them. The scan
|
|
|
|
|
+ read block 9 of this very tag, so the UUID belongs to this spool. Only an
|
|
|
|
|
+ exact UID match counts: a fuzzy (suffix or first-byte) match may be another
|
|
|
|
|
+ spool's tag and must never write anything. A UUID any other spool carries,
|
|
|
|
|
+ archived ones included, is left alone.
|
|
|
|
|
+ """
|
|
|
|
|
+ if spool.tray_uuid or normalize_tag_uid(spool.tag_uid) != normalize_tag_uid(tag_uid):
|
|
|
|
|
+ return
|
|
|
|
|
+ try:
|
|
|
|
|
+ holder = await db.execute(
|
|
|
|
|
+ select(Spool.id).where(func.upper(Spool.tray_uuid) == tray_uuid, Spool.id != spool.id).limit(1)
|
|
|
|
|
+ )
|
|
|
|
|
+ if holder.scalar_one_or_none() is not None:
|
|
|
|
|
+ return
|
|
|
|
|
+ spool.tray_uuid = tray_uuid
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ logger.info("SpoolBuddy: saved tray_uuid %s on spool %d, matched by tag %s", tray_uuid, spool.id, tag_uid)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ await db.rollback()
|
|
|
|
|
+ logger.exception("SpoolBuddy: could not save tray_uuid %s on spool %d", tray_uuid, spool.id)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _backfill_spoolman_tray_uuid(client, sm_spool: dict, tag_uid: str, tray_uuid: str) -> None:
|
|
|
|
|
+ """Store the tray UUID as the tag of a Spoolman spool matched by its exact tag UID.
|
|
|
|
|
+
|
|
|
|
|
+ Spoolman has one extra.tag per spool, and the AMS sync keys Bambu spools by
|
|
|
|
|
+ the tray UUID there, so the UUID replaces the tag UID. The caller already
|
|
|
|
|
+ looked the UUID up among the active spools and found none, the same check
|
|
|
|
|
+ the link route makes. See _backfill_local_tray_uuid for why only an exact
|
|
|
|
|
+ UID match counts.
|
|
|
|
|
+ """
|
|
|
|
|
+ extra = sm_spool.get("extra")
|
|
|
|
|
+ raw_tag = extra.get("tag") if isinstance(extra, dict) else None
|
|
|
|
|
+ stored = raw_tag.strip('"').upper() if isinstance(raw_tag, str) else ""
|
|
|
|
|
+ spool_id = sm_spool.get("id")
|
|
|
|
|
+ if not isinstance(spool_id, int) or not stored or stored != tag_uid.strip('"').upper():
|
|
|
|
|
+ return
|
|
|
|
|
+ try:
|
|
|
|
|
+ await client.merge_spool_extra(spool_id, {"tag": json.dumps(tray_uuid)})
|
|
|
|
|
+ logger.info("SpoolBuddy: stored tray_uuid %s as the tag of Spoolman spool %d", tray_uuid, spool_id)
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.exception("SpoolBuddy: could not store tray_uuid %s on Spoolman spool %d", tray_uuid, spool_id)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _spoolman_native_tag_uids(sm_spool: dict) -> set[str]:
|
|
|
|
|
+ """The UIDs a Spoolman spool holds as native tags (Spoolman 0.27+)."""
|
|
|
|
|
+ uids = {normalize_hex(t.get("uid")) for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
|
|
|
|
|
+ uids.discard("")
|
|
|
|
|
+ return uids
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _spoolman_extra_tag_uid(sm_spool: dict) -> str:
|
|
|
|
|
+ """The tag UID in a Spoolman spool's extra.tag, which holds it JSON-encoded; empty when none."""
|
|
|
|
|
+ extra = sm_spool.get("extra")
|
|
|
|
|
+ raw_tag = extra.get("tag") if isinstance(extra, dict) else None
|
|
|
|
|
+ return normalize_hex(raw_tag) if isinstance(raw_tag, str) else ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _spoolman_tag_links(sm_spool: dict) -> set[str]:
|
|
|
|
|
+ """Every UID a Spoolman spool is linked by: its native tags and extra.tag."""
|
|
|
|
|
+ return _spoolman_native_tag_uids(sm_spool) | ({_spoolman_extra_tag_uid(sm_spool)} - {""})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _spoolman_spool_by_cascade_level_1(spools: list[dict], tag_uid: str, sak: int | None = None) -> dict | None:
|
|
|
|
|
+ """Match a tag UID and a Spoolman link stored from the tag's cascade level 1, either way round.
|
|
|
|
|
+
|
|
|
|
|
+ The Spoolman counterpart of the built-in inventory's fallback in
|
|
|
|
|
+ get_spool_by_tag (#3258), with the same rules. A full 7- or 10-byte read
|
|
|
|
|
+ finds the active spool linked as 88 + the UID's first three bytes, as a
|
|
|
|
|
+ native tag or in extra.tag. A level-1 read from a daemon not updated yet
|
|
|
|
|
+ finds the active spool linked by the 7-byte UID it begins. Three bytes can
|
|
|
|
|
+ begin more than one tag, so only a single fitting spool is matched.
|
|
|
|
|
+ """
|
|
|
|
|
+ level1_uid = cascade_level_1_uid(tag_uid)
|
|
|
|
|
+ uid_start = cascade_level_1_uid_start(tag_uid, sak)
|
|
|
|
|
+ if not level1_uid and not uid_start:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ matches: list[dict] = []
|
|
|
|
|
+ for sm_spool in spools:
|
|
|
|
|
+ if not isinstance(sm_spool, dict) or sm_spool.get("archived"):
|
|
|
|
|
+ continue
|
|
|
|
|
+ links = _spoolman_tag_links(sm_spool)
|
|
|
|
|
+ if level1_uid in links or (
|
|
|
|
|
+ uid_start is not None and any(begins_with_cascade_level_1(uid, uid_start) for uid in links)
|
|
|
|
|
+ ):
|
|
|
|
|
+ matches.append(sm_spool)
|
|
|
|
|
+
|
|
|
|
|
+ if len(matches) == 1:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Matched Spoolman spool %s by cascade level 1 of tag %s: links %s",
|
|
|
|
|
+ matches[0].get("id"),
|
|
|
|
|
+ normalize_hex(tag_uid),
|
|
|
|
|
+ sorted(_spoolman_tag_links(matches[0])),
|
|
|
|
|
+ )
|
|
|
|
|
+ return matches[0]
|
|
|
|
|
+ if len(matches) > 1:
|
|
|
|
|
+ warn_cascade_level_1_ambiguous(normalize_hex(tag_uid), [sm_spool.get("id") for sm_spool in matches])
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _upgrade_spoolman_cascade_level_1_link(client, sm_spool: dict, full_uid: str, level1_uid: str) -> None:
|
|
|
|
|
+ """Move a Spoolman spool's tag link stored from cascade level 1 over to the full UID (#3258).
|
|
|
|
|
+
|
|
|
|
|
+ The scan has linked the full UID as a native tag already (add_native_tags).
|
|
|
|
|
+ An extra.tag holding the 88 form is rewritten to the full UID, and the 88
|
|
|
|
|
+ native tag is unlinked once the spool is linked by the full UID, natively
|
|
|
|
|
+ or in extra.tag, so the spool is never left without a link.
|
|
|
|
|
+
|
|
|
|
|
+ Best-effort: a write Spoolman refuses or fails is logged and the scan keeps
|
|
|
|
|
+ its match. Whatever is left of the 88 form stays on the spool, and the next
|
|
|
|
|
+ full scan of the tag comes here again and finishes the move.
|
|
|
|
|
+ """
|
|
|
|
|
+ spool_id = sm_spool.get("id")
|
|
|
|
|
+ if not isinstance(spool_id, int):
|
|
|
|
|
+ return
|
|
|
|
|
+ changed = False
|
|
|
|
|
+
|
|
|
|
|
+ if _spoolman_extra_tag_uid(sm_spool) == level1_uid:
|
|
|
|
|
+ try:
|
|
|
|
|
+ await client.merge_spool_extra(spool_id, {"tag": json.dumps(full_uid)})
|
|
|
|
|
+ changed = True
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "SpoolBuddy: extra.tag %s of Spoolman spool %d rewritten to the full UID %s",
|
|
|
|
|
+ level1_uid,
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ full_uid,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "SpoolBuddy: could not rewrite extra.tag %s of Spoolman spool %d to %s, trying again on the next scan: %s",
|
|
|
|
|
+ level1_uid,
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ full_uid,
|
|
|
|
|
+ exc,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if level1_uid in _spoolman_native_tag_uids(sm_spool):
|
|
|
|
|
+ try:
|
|
|
|
|
+ current = await client.get_spool(spool_id)
|
|
|
|
|
+ if full_uid in _spoolman_tag_links(current):
|
|
|
|
|
+ await client.unlink_native_tag(spool_id, level1_uid)
|
|
|
|
|
+ changed = True
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "SpoolBuddy: native tag %s unlinked from Spoolman spool %d, now linked by the full UID %s",
|
|
|
|
|
+ level1_uid,
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ full_uid,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "SpoolBuddy: Spoolman spool %d keeps native tag %s until the full UID %s is linked",
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ level1_uid,
|
|
|
|
|
+ full_uid,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "SpoolBuddy: could not unlink native tag %s from Spoolman spool %d, trying again on the next scan: %s",
|
|
|
|
|
+ level1_uid,
|
|
|
|
|
+ spool_id,
|
|
|
|
|
+ exc,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if changed:
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@router.post("/nfc/tag-scanned")
|
|
@router.post("/nfc/tag-scanned")
|
|
|
async def nfc_tag_scanned(
|
|
async def nfc_tag_scanned(
|
|
|
req: TagScannedRequest,
|
|
req: TagScannedRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""RPi reports NFC tag detected — lookup spool and broadcast.
|
|
"""RPi reports NFC tag detected — lookup spool and broadcast.
|
|
@@ -393,29 +594,82 @@ async def nfc_tag_scanned(
|
|
|
"""
|
|
"""
|
|
|
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
|
|
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
|
|
|
|
|
|
|
|
- # _get_spoolman_client_or_none returns a usable client when spoolman_enabled
|
|
|
|
|
- # is true (and the URL passes the SSRF guard), None otherwise — so its
|
|
|
|
|
- # return value doubles as the mode discriminator.
|
|
|
|
|
|
|
+ # Daemons before #984 read the filament type from blocks 4-5 and sent it as
|
|
|
|
|
+ # tray_uuid. Drop anything that is not a real tray UUID, so an old daemon
|
|
|
|
|
+ # falls back to tag_uid matching and its value is never offered for saving.
|
|
|
|
|
+ tray_uuid: str | None = normalize_tray_uuid(req.tray_uuid) or None
|
|
|
|
|
+ if tray_uuid and not is_bambu_tray_uuid(tray_uuid):
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "SpoolBuddy %s sent tray_uuid %s for tag %s, which is not a Bambu tray UUID; ignoring it. "
|
|
|
|
|
+ "Update the SpoolBuddy daemon.",
|
|
|
|
|
+ req.device_id,
|
|
|
|
|
+ tray_uuid,
|
|
|
|
|
+ req.tag_uid,
|
|
|
|
|
+ )
|
|
|
|
|
+ tray_uuid = None
|
|
|
|
|
+
|
|
|
|
|
+ # A usable client when Spoolman is on and its URL passes the SSRF guard.
|
|
|
|
|
+ # With the mode on but no usable client the spools are still Spoolman's,
|
|
|
|
|
+ # and the built-in table must not answer.
|
|
|
client = await _get_spoolman_client_or_none(db)
|
|
client = await _get_spoolman_client_or_none(db)
|
|
|
|
|
+ if client is None and await _spoolman_mode(db):
|
|
|
|
|
+ await ws_manager.broadcast(
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "spoolman_unavailable",
|
|
|
|
|
+ "device_id": req.device_id,
|
|
|
|
|
+ "context": "nfc_tag_scanned",
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ return {"status": "ok", "matched": False, "spool_id": None}
|
|
|
|
|
|
|
|
if client is not None:
|
|
if client is not None:
|
|
|
# Spoolman mode — exclusive lookup, no local-DB fallback.
|
|
# Spoolman mode — exclusive lookup, no local-DB fallback.
|
|
|
try:
|
|
try:
|
|
|
- cached_spools = await client.get_spools()
|
|
|
|
|
sm_spool: dict | None = None
|
|
sm_spool: dict | None = None
|
|
|
- if req.tray_uuid:
|
|
|
|
|
- sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
|
|
|
|
|
- if sm_spool is None and req.tag_uid:
|
|
|
|
|
- sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
|
|
|
|
|
|
|
+ native = await client.has_tag_api()
|
|
|
|
|
+ cached_spools: list[dict] | None = None
|
|
|
|
|
+ # The tray UUID is settled before the tag UID, as it always was: a spool
|
|
|
|
|
+ # that carries the UUID only in extra.tag still wins over one holding the
|
|
|
|
|
+ # chip UID natively. Spoolman 0.27+ answers each identifier with one exact
|
|
|
|
|
+ # query; the whole inventory is loaded only when that misses.
|
|
|
|
|
+ for uid in (tray_uuid, req.tag_uid):
|
|
|
|
|
+ if not uid or sm_spool is not None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if native:
|
|
|
|
|
+ try:
|
|
|
|
|
+ sm_spool = await client.find_spool_by_native_tag(uid.upper())
|
|
|
|
|
+ except SpoolmanClientError as exc:
|
|
|
|
|
+ logger.warning("Native tag lookup refused for %s, using extra.tag: %s", uid, exc)
|
|
|
|
|
+ if sm_spool is None:
|
|
|
|
|
+ if cached_spools is None:
|
|
|
|
|
+ cached_spools = await client.get_spools()
|
|
|
|
|
+ sm_spool = await client.find_spool_by_tag(uid, cached_spools=cached_spools)
|
|
|
|
|
+
|
|
|
|
|
+ # Last, a link stored from the tag's cascade level 1 (#3258), as
|
|
|
|
|
+ # get_spool_by_tag falls back to in the built-in inventory.
|
|
|
|
|
+ level1_read = cascade_level_1_uid_start(req.tag_uid, req.sak) is not None
|
|
|
|
|
+ level1_read_match = False
|
|
|
|
|
+ if sm_spool is None and (level1_read or cascade_level_1_uid(req.tag_uid)):
|
|
|
|
|
+ if cached_spools is None:
|
|
|
|
|
+ cached_spools = await client.get_spools()
|
|
|
|
|
+ sm_spool = _spoolman_spool_by_cascade_level_1(cached_spools, req.tag_uid, req.sak)
|
|
|
|
|
+ level1_read_match = level1_read and sm_spool is not None
|
|
|
|
|
|
|
|
if sm_spool is not None:
|
|
if sm_spool is not None:
|
|
|
|
|
+ # What this scan read becomes a native tag of the spool: a spool found
|
|
|
|
|
+ # through extra.tag moves over on its first scan, and a Bambu spool
|
|
|
|
|
+ # collects the chip UID of each side as it is read. A level-1 read
|
|
|
|
|
+ # that matched through the fallback is not one: 88 + three bytes can
|
|
|
|
|
+ # begin more than one tag, and the spool is linked by the full UID.
|
|
|
|
|
+ scanned_uids = [tray_uuid] if level1_read_match else [tray_uuid, req.tag_uid]
|
|
|
|
|
+ await client.add_native_tags(sm_spool, scanned_uids, "bambu" if tray_uuid else None, retry_refused=True)
|
|
|
mapped = _map_spoolman_spool(sm_spool)
|
|
mapped = _map_spoolman_spool(sm_spool)
|
|
|
await ws_manager.broadcast(
|
|
await ws_manager.broadcast(
|
|
|
{
|
|
{
|
|
|
"type": "spoolbuddy_tag_matched",
|
|
"type": "spoolbuddy_tag_matched",
|
|
|
"device_id": req.device_id,
|
|
"device_id": req.device_id,
|
|
|
"tag_uid": req.tag_uid,
|
|
"tag_uid": req.tag_uid,
|
|
|
- "tray_uuid": req.tray_uuid,
|
|
|
|
|
|
|
+ "tray_uuid": tray_uuid,
|
|
|
"spool": {
|
|
"spool": {
|
|
|
"id": mapped["id"],
|
|
"id": mapped["id"],
|
|
|
"material": mapped["material"],
|
|
"material": mapped["material"],
|
|
@@ -439,6 +693,15 @@ async def nfc_tag_scanned(
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
|
|
logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
|
|
|
|
|
+ # A full read of a spool still linked by the 88 form, matched by the
|
|
|
|
|
+ # fallback or exactly after an earlier move stopped half-way.
|
|
|
|
|
+ level1_uid = cascade_level_1_uid(req.tag_uid)
|
|
|
|
|
+ if level1_uid and level1_uid in _spoolman_tag_links(sm_spool):
|
|
|
|
|
+ await _upgrade_spoolman_cascade_level_1_link(
|
|
|
|
|
+ client, sm_spool, normalize_hex(req.tag_uid), level1_uid
|
|
|
|
|
+ )
|
|
|
|
|
+ if tray_uuid:
|
|
|
|
|
+ await _backfill_spoolman_tray_uuid(client, sm_spool, req.tag_uid, tray_uuid)
|
|
|
return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
|
|
return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
|
|
|
except ValueError as exc:
|
|
except ValueError as exc:
|
|
|
logger.error(
|
|
logger.error(
|
|
@@ -481,14 +744,14 @@ async def nfc_tag_scanned(
|
|
|
return {"status": "ok", "matched": False, "spool_id": None}
|
|
return {"status": "ok", "matched": False, "spool_id": None}
|
|
|
else:
|
|
else:
|
|
|
# Local mode — exclusive lookup, no Spoolman fallback.
|
|
# Local mode — exclusive lookup, no Spoolman fallback.
|
|
|
- spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
|
|
|
|
|
|
|
+ spool = await get_spool_by_tag(db, req.tag_uid, tray_uuid or "", req.sak)
|
|
|
if spool:
|
|
if spool:
|
|
|
await ws_manager.broadcast(
|
|
await ws_manager.broadcast(
|
|
|
{
|
|
{
|
|
|
"type": "spoolbuddy_tag_matched",
|
|
"type": "spoolbuddy_tag_matched",
|
|
|
"device_id": req.device_id,
|
|
"device_id": req.device_id,
|
|
|
"tag_uid": req.tag_uid,
|
|
"tag_uid": req.tag_uid,
|
|
|
- "tray_uuid": req.tray_uuid,
|
|
|
|
|
|
|
+ "tray_uuid": tray_uuid,
|
|
|
"spool": {
|
|
"spool": {
|
|
|
"id": spool.id,
|
|
"id": spool.id,
|
|
|
"material": spool.material,
|
|
"material": spool.material,
|
|
@@ -508,24 +771,36 @@ async def nfc_tag_scanned(
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
|
|
logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
|
|
|
|
|
+ if tray_uuid:
|
|
|
|
|
+ await _backfill_local_tray_uuid(db, spool, req.tag_uid, tray_uuid)
|
|
|
return {"status": "ok", "matched": True, "spool_id": spool.id}
|
|
return {"status": "ok", "matched": True, "spool_id": spool.id}
|
|
|
|
|
|
|
|
|
|
+ # What the tag itself says about the roll, so the kiosk's Quick Add can
|
|
|
|
|
+ # create the spool AMS sync would have created, not a blank PLA (#2685)
|
|
|
|
|
+ tag_data = None
|
|
|
|
|
+ if req.raw_blocks:
|
|
|
|
|
+ try:
|
|
|
|
|
+ tag_data = await spool_prefill_from_tag(db, req.raw_blocks)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.warning("Could not decode Bambu tag blocks for %s", req.tag_uid, exc_info=True)
|
|
|
|
|
+
|
|
|
await ws_manager.broadcast(
|
|
await ws_manager.broadcast(
|
|
|
{
|
|
{
|
|
|
"type": "spoolbuddy_unknown_tag",
|
|
"type": "spoolbuddy_unknown_tag",
|
|
|
"device_id": req.device_id,
|
|
"device_id": req.device_id,
|
|
|
"tag_uid": req.tag_uid,
|
|
"tag_uid": req.tag_uid,
|
|
|
- "tray_uuid": req.tray_uuid,
|
|
|
|
|
|
|
+ "tray_uuid": tray_uuid,
|
|
|
"sak": req.sak,
|
|
"sak": req.sak,
|
|
|
"tag_type": req.tag_type,
|
|
"tag_type": req.tag_type,
|
|
|
|
|
+ "tag_data": tag_data,
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
|
|
"SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
|
|
|
req.tag_uid,
|
|
req.tag_uid,
|
|
|
len(req.tag_uid or ""),
|
|
len(req.tag_uid or ""),
|
|
|
- req.tray_uuid,
|
|
|
|
|
- len(req.tray_uuid or ""),
|
|
|
|
|
|
|
+ tray_uuid,
|
|
|
|
|
+ len(tray_uuid or ""),
|
|
|
req.tag_type,
|
|
req.tag_type,
|
|
|
req.sak,
|
|
req.sak,
|
|
|
)
|
|
)
|
|
@@ -551,10 +826,15 @@ async def nfc_tag_removed(
|
|
|
@router.post("/nfc/write-tag")
|
|
@router.post("/nfc/write-tag")
|
|
|
async def nfc_write_tag(
|
|
async def nfc_write_tag(
|
|
|
req: WriteTagRequest,
|
|
req: WriteTagRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
- """Queue an NFC tag write command for a SpoolBuddy device."""
|
|
|
|
|
|
|
+ """Queue an NFC tag write command for a SpoolBuddy device.
|
|
|
|
|
+
|
|
|
|
|
+ The spool comes from the inventory in use: Spoolman when it is enabled,
|
|
|
|
|
+ otherwise Bambuddy's own. Built-in spools are kept after switching to
|
|
|
|
|
+ Spoolman, so one with the same id must not stand in for the Spoolman spool.
|
|
|
|
|
+ """
|
|
|
from backend.app.models.spool import Spool
|
|
from backend.app.models.spool import Spool
|
|
|
from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
|
|
from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
|
|
|
|
|
|
|
@@ -564,22 +844,20 @@ async def nfc_write_tag(
|
|
|
if not device:
|
|
if not device:
|
|
|
raise HTTPException(status_code=404, detail="Device not registered")
|
|
raise HTTPException(status_code=404, detail="Device not registered")
|
|
|
|
|
|
|
|
- # Try local DB first
|
|
|
|
|
- result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
|
|
|
|
|
- spool = result.scalar_one_or_none()
|
|
|
|
|
|
|
+ sm_client = await _get_spoolman_client_or_none(db)
|
|
|
|
|
+ if sm_client is None and await _spoolman_mode(db):
|
|
|
|
|
+ raise HTTPException(status_code=503, detail="Spoolman is unavailable")
|
|
|
|
|
|
|
|
nfc_warnings: list[str] = []
|
|
nfc_warnings: list[str] = []
|
|
|
- if spool:
|
|
|
|
|
|
|
+ if sm_client is None:
|
|
|
|
|
+ spool = (await db.execute(select(Spool).where(Spool.id == req.spool_id))).scalar_one_or_none()
|
|
|
|
|
+ if spool is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Spool not found")
|
|
|
ndef_data = encode_opentag3d(spool)
|
|
ndef_data = encode_opentag3d(spool)
|
|
|
data_origin = "local"
|
|
data_origin = "local"
|
|
|
else:
|
|
else:
|
|
|
- # Local DB miss — fall back to Spoolman when enabled
|
|
|
|
|
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
|
|
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
|
|
|
|
|
|
|
|
- sm_client = await _get_spoolman_client_or_none(db)
|
|
|
|
|
- if sm_client is None:
|
|
|
|
|
- raise HTTPException(status_code=404, detail="Spool not found")
|
|
|
|
|
-
|
|
|
|
|
async with _translate_spoolbuddy_errors():
|
|
async with _translate_spoolbuddy_errors():
|
|
|
sm_spool = await sm_client.get_spool(req.spool_id)
|
|
sm_spool = await sm_client.get_spool(req.spool_id)
|
|
|
|
|
|
|
@@ -595,19 +873,36 @@ async def nfc_write_tag(
|
|
|
detail="Spoolman spool has no material set — cannot encode NFC tag",
|
|
detail="Spoolman spool has no material set — cannot encode NFC tag",
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ # A Bambu spool created from the AMS is linked by its tray UUID in
|
|
|
|
|
+ # extra.tag; a sticker would replace it there, and the next AMS sync,
|
|
|
|
|
+ # no longer finding the spool, would create a duplicate (SM-C5). Not
|
|
|
|
|
+ # when Spoolman also keeps that UUID as a native tag (0.27+): the sync
|
|
|
|
|
+ # finds the spool by it first, and the sticker leaves it alone.
|
|
|
|
|
+ from backend.app.api.routes._spoolman_helpers import _extract_extra_str
|
|
|
|
|
+
|
|
|
|
|
+ extra_tag = _extract_extra_str(sm_spool.get("extra") or {}, "tag").strip().upper()
|
|
|
|
|
+ native_uids = {str(t.get("uid", "")).upper() for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
|
|
|
|
|
+ if re.fullmatch(r"[0-9A-F]{32}", extra_tag) and extra_tag not in native_uids:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=409,
|
|
|
|
|
+ detail="This spool is linked to its Bambu RFID tag through the AMS; "
|
|
|
|
|
+ "a sticker would replace that link in Spoolman",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
ndef_data = encode_opentag3d_from_mapped(mapped)
|
|
ndef_data = encode_opentag3d_from_mapped(mapped)
|
|
|
data_origin = "spoolman"
|
|
data_origin = "spoolman"
|
|
|
|
|
|
|
|
# Warn when fields that drive NFC content are absent in Spoolman.
|
|
# Warn when fields that drive NFC content are absent in Spoolman.
|
|
|
- # color_name specifically must check the raw filament field, not the
|
|
|
|
|
- # mapped value — _map_spoolman_spool falls back to the filament's
|
|
|
|
|
- # subtype when color_name is unset (so LinkSpoolModal stops showing
|
|
|
|
|
- # "Unknown color"), but the NFC tag should still warn when Spoolman
|
|
|
|
|
- # has no genuine color_name on file. Without this, the fallback
|
|
|
|
|
- # silently masks a real missing-data condition.
|
|
|
|
|
- raw_filament: dict = sm_spool.get("filament") or {}
|
|
|
|
|
- if not raw_filament.get("color_name"):
|
|
|
|
|
- nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
|
|
|
|
|
|
|
+ # The colour name is read the way the tag gets it: from the spool's
|
|
|
|
|
+ # extra (where Bambuddy keeps it -- Spoolman's filament has no such
|
|
|
|
|
+ # field, so checking that warned on every spool, SM-C6), else the
|
|
|
|
|
+ # subtype stands in for it.
|
|
|
|
|
+ if mapped.get("color_name") is None:
|
|
|
|
|
+ nfc_warnings.append("color_name not set in Spoolman — tag encodes an empty color name")
|
|
|
|
|
+ elif mapped.get("color_name_is_synthesized"):
|
|
|
|
|
+ nfc_warnings.append(
|
|
|
|
|
+ f"color_name not set in Spoolman — tag encodes the subtype ({mapped['color_name']}) as its color name"
|
|
|
|
|
+ )
|
|
|
if not mapped.get("nozzle_temp_min"):
|
|
if not mapped.get("nozzle_temp_min"):
|
|
|
nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
|
|
nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
|
|
|
if not mapped.get("subtype"):
|
|
if not mapped.get("subtype"):
|
|
@@ -652,7 +947,7 @@ async def nfc_write_tag(
|
|
|
@router.post("/nfc/write-result")
|
|
@router.post("/nfc/write-result")
|
|
|
async def nfc_write_result(
|
|
async def nfc_write_result(
|
|
|
req: WriteTagResultRequest,
|
|
req: WriteTagResultRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Handle NFC tag write result from SpoolBuddy daemon."""
|
|
"""Handle NFC tag write result from SpoolBuddy daemon."""
|
|
@@ -688,6 +983,7 @@ async def nfc_write_result(
|
|
|
"spool_id": req.spool_id,
|
|
"spool_id": req.spool_id,
|
|
|
"tag_uid": req.tag_uid,
|
|
"tag_uid": req.tag_uid,
|
|
|
"message": "Spoolman not configured",
|
|
"message": "Spoolman not configured",
|
|
|
|
|
+ "reason": "spoolman_not_configured",
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
@@ -702,28 +998,13 @@ async def nfc_write_result(
|
|
|
# one Spoolman spool, otherwise find_spool_by_tag returns
|
|
# one Spoolman spool, otherwise find_spool_by_tag returns
|
|
|
# whichever spool comes first in the cached list (usually the
|
|
# whichever spool comes first in the cached list (usually the
|
|
|
# older one) and the dashboard shows the wrong spool when the
|
|
# older one) and the dashboard shows the wrong spool when the
|
|
|
- # tag is scanned. Before binding the new owner, clear the tag
|
|
|
|
|
- # from any other spool that currently has it. Best-effort:
|
|
|
|
|
- # cleanup failure does not block the write itself, but the
|
|
|
|
|
- # warning surfaces in logs so a stale duplicate can be tracked
|
|
|
|
|
- # down manually.
|
|
|
|
|
|
|
+ # tag is scanned. Before binding the new owner, clear extra.tag
|
|
|
|
|
+ # on every other spool that holds it -- JSON-encoded, as Spoolman
|
|
|
|
|
+ # refuses a bare "" (#3312). Best-effort: cleanup failure does
|
|
|
|
|
+ # not block the write itself; each failed spool is logged.
|
|
|
try:
|
|
try:
|
|
|
- cached_spools = await sm_client.get_spools()
|
|
|
|
|
- duplicate = await sm_client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
|
|
|
|
|
- if duplicate is not None and duplicate.get("id") != req.spool_id:
|
|
|
|
|
- await sm_client.merge_spool_extra(int(duplicate["id"]), {"tag": ""})
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "Spoolman: cleared tag %s from previous holder spool %d before binding to spool %d",
|
|
|
|
|
- req.tag_uid,
|
|
|
|
|
- duplicate["id"],
|
|
|
|
|
- req.spool_id,
|
|
|
|
|
- )
|
|
|
|
|
- except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as cleanup_exc:
|
|
|
|
|
- logger.warning(
|
|
|
|
|
- "Spoolman: failed to clear duplicate tag %s before binding to spool %d (proceeding anyway): %s",
|
|
|
|
|
- req.tag_uid,
|
|
|
|
|
- req.spool_id,
|
|
|
|
|
- cleanup_exc,
|
|
|
|
|
|
|
+ await _clear_stale_tag_links(
|
|
|
|
|
+ sm_client, tag=req.tag_uid, keep_spool_id=req.spool_id, log_context="SpoolBuddy tag write"
|
|
|
)
|
|
)
|
|
|
except Exception:
|
|
except Exception:
|
|
|
logger.exception(
|
|
logger.exception(
|
|
@@ -732,7 +1013,39 @@ async def nfc_write_result(
|
|
|
req.spool_id,
|
|
req.spool_id,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
|
|
|
|
|
|
|
+ # Spoolman 0.27+: the same tag as a native one. It was just written onto
|
|
|
|
|
+ # this spool, so a previous holder gives it up here too. Linked before
|
|
|
|
|
+ # extra.tag, as the step Spoolman can refuse; a failed extra.tag write
|
|
|
|
|
+ # takes back what was added.
|
|
|
|
|
+ uid = req.tag_uid.upper()
|
|
|
|
|
+ added_native = False
|
|
|
|
|
+ if await sm_client.has_tag_api():
|
|
|
|
|
+ before = await sm_client.get_spool(req.spool_id)
|
|
|
|
|
+ had = any(t.get("uid") == uid for t in before.get("tags") or [])
|
|
|
|
|
+ # A spool that already holds the tag has nothing to link or move.
|
|
|
|
|
+ if not had:
|
|
|
|
|
+ holder = await sm_client.link_native_tag(req.spool_id, uid)
|
|
|
|
|
+ if holder is not None and holder > 0:
|
|
|
|
|
+ previous = holder
|
|
|
|
|
+ await sm_client.unlink_native_tag(previous, uid)
|
|
|
|
|
+ holder = await sm_client.link_native_tag(req.spool_id, uid)
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Spoolman: native tag %s moved from spool %d to %d", uid, previous, req.spool_id
|
|
|
|
|
+ )
|
|
|
|
|
+ if holder is not None:
|
|
|
|
|
+ logger.warning("Native tag %s belongs to a filament or location, not linked", uid)
|
|
|
|
|
+ added_native = holder is None
|
|
|
|
|
+ try:
|
|
|
|
|
+ await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ if added_native:
|
|
|
|
|
+ try:
|
|
|
|
|
+ await sm_client.unlink_native_tag(req.spool_id, uid)
|
|
|
|
|
+ except (SpoolmanClientError, SpoolmanUnavailableError) as undo_exc:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "Could not take back native tag %s from spool %d: %s", uid, req.spool_id, undo_exc
|
|
|
|
|
+ )
|
|
|
|
|
+ raise
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"Spoolman tag written and linked: spool %d -> tag %s",
|
|
"Spoolman tag written and linked: spool %d -> tag %s",
|
|
|
req.spool_id,
|
|
req.spool_id,
|
|
@@ -756,6 +1069,10 @@ async def nfc_write_result(
|
|
|
# fall through to broadcast + raise 502 below
|
|
# fall through to broadcast + raise 502 below
|
|
|
|
|
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
+ # The tag moved between Spoolman spools, or the stale holders were
|
|
|
|
|
+ # cleared before a link that then failed: either way pages open
|
|
|
|
|
+ # elsewhere hold a tag -> spool map that changed (#3402).
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
if _tag_link_ok:
|
|
if _tag_link_ok:
|
|
|
await ws_manager.broadcast(
|
|
await ws_manager.broadcast(
|
|
|
{
|
|
{
|
|
@@ -775,6 +1092,7 @@ async def nfc_write_result(
|
|
|
# Generic message — full exception (may contain internal URLs/hostnames)
|
|
# Generic message — full exception (may contain internal URLs/hostnames)
|
|
|
# is logged server-side only to prevent information leakage via WebSocket.
|
|
# is logged server-side only to prevent information leakage via WebSocket.
|
|
|
"message": "Spoolman link failed",
|
|
"message": "Spoolman link failed",
|
|
|
|
|
+ "reason": "spoolman_link_failed",
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
raise HTTPException(
|
|
raise HTTPException(
|
|
@@ -799,6 +1117,7 @@ async def nfc_write_result(
|
|
|
"device_id": req.device_id,
|
|
"device_id": req.device_id,
|
|
|
"spool_id": req.spool_id,
|
|
"spool_id": req.spool_id,
|
|
|
"message": "Spool not found",
|
|
"message": "Spool not found",
|
|
|
|
|
+ "reason": "spool_not_found",
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
return {"status": "ok", "linked": False, "message": "Spool not found"}
|
|
return {"status": "ok", "linked": False, "message": "Spool not found"}
|
|
@@ -836,7 +1155,7 @@ async def nfc_write_result(
|
|
|
@router.post("/devices/{device_id}/cancel-write")
|
|
@router.post("/devices/{device_id}/cancel-write")
|
|
|
async def cancel_write(
|
|
async def cancel_write(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Cancel a pending write-tag command."""
|
|
"""Cancel a pending write-tag command."""
|
|
@@ -878,7 +1197,7 @@ async def scale_reading(
|
|
|
@router.post("/scale/update-spool-weight")
|
|
@router.post("/scale/update-spool-weight")
|
|
|
async def update_spool_weight(
|
|
async def update_spool_weight(
|
|
|
req: UpdateSpoolWeightRequest,
|
|
req: UpdateSpoolWeightRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Update spool's used weight from scale reading.
|
|
"""Update spool's used weight from scale reading.
|
|
@@ -895,6 +1214,8 @@ async def update_spool_weight(
|
|
|
from backend.app.models.spool import Spool
|
|
from backend.app.models.spool import Spool
|
|
|
|
|
|
|
|
sm_client = await _get_spoolman_client_or_none(db)
|
|
sm_client = await _get_spoolman_client_or_none(db)
|
|
|
|
|
+ if sm_client is None and await _spoolman_mode(db):
|
|
|
|
|
+ raise HTTPException(status_code=503, detail="Spoolman is unavailable")
|
|
|
|
|
|
|
|
if sm_client is None:
|
|
if sm_client is None:
|
|
|
# Local mode — exclusive update, no Spoolman fallback.
|
|
# Local mode — exclusive update, no Spoolman fallback.
|
|
@@ -907,7 +1228,11 @@ async def update_spool_weight(
|
|
|
spool.weight_used = max(0, spool.label_weight - net_filament)
|
|
spool.weight_used = max(0, spool.label_weight - net_filament)
|
|
|
spool.last_scale_weight = req.weight_grams
|
|
spool.last_scale_weight = req.weight_grams
|
|
|
spool.last_weighed_at = datetime.now(timezone.utc)
|
|
spool.last_weighed_at = datetime.now(timezone.utc)
|
|
|
|
|
+ # A measured figure, which the AMS remain% sync must not overwrite
|
|
|
|
|
+ # (as a hand-set one, see update_spool).
|
|
|
|
|
+ spool.weight_locked = True
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
|
|
"SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
|
|
|
spool.id,
|
|
spool.id,
|
|
@@ -936,6 +1261,7 @@ async def update_spool_weight(
|
|
|
|
|
|
|
|
async with _translate_spoolbuddy_errors():
|
|
async with _translate_spoolbuddy_errors():
|
|
|
await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
|
|
await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
|
|
|
weight_used = max(0.0, label_weight - remaining_weight)
|
|
weight_used = max(0.0, label_weight - remaining_weight)
|
|
|
logger.info(
|
|
logger.info(
|
|
@@ -957,7 +1283,7 @@ async def update_spool_weight(
|
|
|
@router.post("/devices/{device_id}/calibration/tare")
|
|
@router.post("/devices/{device_id}/calibration/tare")
|
|
|
async def tare_scale(
|
|
async def tare_scale(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Set pending tare command for the device to pick up."""
|
|
"""Set pending tare command for the device to pick up."""
|
|
@@ -975,7 +1301,7 @@ async def tare_scale(
|
|
|
async def set_tare_offset(
|
|
async def set_tare_offset(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: SetTareRequest,
|
|
req: SetTareRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Store tare offset reported by the daemon after executing a tare."""
|
|
"""Store tare offset reported by the daemon after executing a tare."""
|
|
@@ -999,7 +1325,7 @@ async def set_tare_offset(
|
|
|
async def set_calibration_factor(
|
|
async def set_calibration_factor(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: SetCalibrationFactorRequest,
|
|
req: SetCalibrationFactorRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Calculate and store calibration factor from a known weight."""
|
|
"""Calculate and store calibration factor from a known weight."""
|
|
@@ -1036,7 +1362,7 @@ async def set_calibration_factor(
|
|
|
@router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
|
|
@router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
|
|
|
async def get_calibration(
|
|
async def get_calibration(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
|
):
|
|
):
|
|
|
"""Get current calibration values for a device."""
|
|
"""Get current calibration values for a device."""
|
|
@@ -1057,7 +1383,7 @@ async def get_calibration(
|
|
|
@router.get("/devices/{device_id}/display")
|
|
@router.get("/devices/{device_id}/display")
|
|
|
async def get_display_settings(
|
|
async def get_display_settings(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Read current display brightness and screen blank timeout for a device.
|
|
"""Read current display brightness and screen blank timeout for a device.
|
|
@@ -1080,7 +1406,7 @@ async def get_display_settings(
|
|
|
async def update_display_settings(
|
|
async def update_display_settings(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: DisplaySettingsRequest,
|
|
req: DisplaySettingsRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Update display brightness and screen blank timeout for a device."""
|
|
"""Update display brightness and screen blank timeout for a device."""
|
|
@@ -1106,7 +1432,7 @@ async def update_display_settings(
|
|
|
async def queue_system_config_update(
|
|
async def queue_system_config_update(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: SystemConfigRequest,
|
|
req: SystemConfigRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Queue update of SpoolBuddy .env config on the device."""
|
|
"""Queue update of SpoolBuddy .env config on the device."""
|
|
@@ -1143,7 +1469,7 @@ VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browse
|
|
|
async def queue_system_command(
|
|
async def queue_system_command(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: SystemCommandRequest,
|
|
req: SystemCommandRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
# Aligns with the rest of the kiosk-scoped device routes (calibration,
|
|
# Aligns with the rest of the kiosk-scoped device routes (calibration,
|
|
|
# display, cancel-write, command-result — all INVENTORY_UPDATE). The
|
|
# display, cancel-write, command-result — all INVENTORY_UPDATE). The
|
|
|
# previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
|
|
# previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
|
|
@@ -1179,7 +1505,7 @@ async def queue_system_command(
|
|
|
async def system_command_result(
|
|
async def system_command_result(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: SystemCommandResultRequest,
|
|
req: SystemCommandResultRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Receive completion status for queued system command from daemon."""
|
|
"""Receive completion status for queued system command from daemon."""
|
|
@@ -1220,8 +1546,8 @@ async def system_command_result(
|
|
|
async def queue_diagnostic(
|
|
async def queue_diagnostic(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
diagnostic: str,
|
|
diagnostic: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
|
|
+ _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Queue a hardware diagnostic to run on the SpoolBuddy device.
|
|
"""Queue a hardware diagnostic to run on the SpoolBuddy device.
|
|
|
|
|
|
|
@@ -1252,7 +1578,7 @@ async def queue_diagnostic(
|
|
|
async def get_diagnostic_result(
|
|
async def get_diagnostic_result(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
diagnostic: str,
|
|
diagnostic: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
|
):
|
|
):
|
|
|
"""Get the latest diagnostic result for a device.
|
|
"""Get the latest diagnostic result for a device.
|
|
@@ -1282,7 +1608,7 @@ async def get_diagnostic_result(
|
|
|
async def report_diagnostic_result(
|
|
async def report_diagnostic_result(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: DiagnosticResultRequest,
|
|
req: DiagnosticResultRequest,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
):
|
|
):
|
|
|
"""Report diagnostic result from SpoolBuddy device."""
|
|
"""Report diagnostic result from SpoolBuddy device."""
|
|
@@ -1314,7 +1640,7 @@ async def report_diagnostic_result(
|
|
|
@router.get("/devices/{device_id}/update-check")
|
|
@router.get("/devices/{device_id}/update-check")
|
|
|
async def check_daemon_update(
|
|
async def check_daemon_update(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
|
|
):
|
|
):
|
|
|
"""Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
|
|
"""Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
|
|
@@ -1339,7 +1665,7 @@ async def check_daemon_update(
|
|
|
async def trigger_daemon_update(
|
|
async def trigger_daemon_update(
|
|
|
device_id: str,
|
|
device_id: str,
|
|
|
req: dict | None = None,
|
|
req: dict | None = None,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
+ db: AsyncSession = Depends(get_db, scope="function"),
|
|
|
# Aligns with the rest of the kiosk-scoped device routes (calibration,
|
|
# Aligns with the rest of the kiosk-scoped device routes (calibration,
|
|
|
# display, cancel-write, system/command — all INVENTORY_UPDATE).
|
|
# display, cancel-write, system/command — all INVENTORY_UPDATE).
|
|
|
# SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update
|
|
# SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update
|
|
@@ -1412,41 +1738,6 @@ async def get_ssh_public_key(
|
|
|
raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
|
|
raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
-@router.post("/devices/{device_id}/update-status")
|
|
|
|
|
-async def report_update_status(
|
|
|
|
|
- device_id: str,
|
|
|
|
|
- req: UpdateStatusRequest,
|
|
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
|
|
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
|
|
|
-):
|
|
|
|
|
- """Daemon reports update progress back to the backend."""
|
|
|
|
|
- result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
|
|
|
|
|
- device = result.scalar_one_or_none()
|
|
|
|
|
- if not device:
|
|
|
|
|
- raise HTTPException(status_code=404, detail="Device not registered")
|
|
|
|
|
-
|
|
|
|
|
- device.update_status = req.status
|
|
|
|
|
- device.update_message = req.message
|
|
|
|
|
- # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
|
|
|
|
|
- # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
|
|
|
|
|
- # because it owns the full update lifecycle end-to-end.
|
|
|
|
|
- if req.status == "complete":
|
|
|
|
|
- device.pending_command = None
|
|
|
|
|
- await db.commit()
|
|
|
|
|
-
|
|
|
|
|
- logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
|
|
|
|
|
- await ws_manager.broadcast(
|
|
|
|
|
- {
|
|
|
|
|
- "type": "spoolbuddy_update",
|
|
|
|
|
- "device_id": device_id,
|
|
|
|
|
- "update_status": req.status,
|
|
|
|
|
- "update_message": req.message,
|
|
|
|
|
- }
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- return {"status": "ok"}
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
# --- Background watchdog ---
|
|
# --- Background watchdog ---
|
|
|
|
|
|
|
|
|
|
|