[Fix]: Non-Bambu Lab Spools can now be fully linked/unlinked to Spoolman (#653)

* Expanded link logic to accept a generic spool tag (spool_tag, tray_uuid, or tag_uid) and validate 16/32-char hex values, including rejection of all-zero tags. Added a new unlink endpoint that clears Spoolman extra.tag for a given spool ID.

* Updated the link validation expectation to match the new 16-or-32-hex rule. Added an integration test for the new unlink endpoint to verify extra.tag is cleared and success is returned.

* Updated the link API call to send spool_tag instead of tray_uuid. Added a new unlinkSpool API helper that calls the new backend unlink route.

* Switched modal data source from inventory spools to Spoolman unlinked spools so the list matches the backend response you shared. Updated the link action to call Spoolman link directly and use trayUuid or tagUid as the slot tag.

* Refactored Spoolman card behavior so actions render correctly across linked/unlinked cases, and added support for an Unlink from Spoolman button under Open in Spoolman. Final UI rule now hides Unlink for Bambu Lab filament and shows it only for non-Bambu linked spools.

* Added fallback slot-tag generation so untagged slots can still be linked/unlinked reliably and linked spool lookups work consistently across AMS, AMS-HT, and external trays. Wired a new unlink mutation and passed per-slot unlink callbacks into the hover card, with query invalidation for linked/unlinked spool caches after unlink.

* Added/updated the noTrayUuid message to reflect that either tray UUID or tag UID is required when no slot tag is available. This keeps error text aligned with the new tag fallback and modal behavior.

* Added noTrayUuid message in the other languages. The translation was done using Google Translate.

* Updated LinkSpoolModal tests to use Spoolman API instead of inventory API, with new UnlinkedSpool data structure and Select Spool UI text.

* Adds hashSerialToHex32, uses new function in getFallbackSpoolTag and updates all uses. This ensures we're generating a fallback spool tag using the printers serial number rather than the database ID for consistency.

* Removes 'import json' from unlink_spool and moves to the top of the file.

* Removes hasUnlinkedSpools and related references since it's no longer used

* Removes noTrayUuid from translations and references in components.

* Adds confirmation dialog before fully unlinking spool from Spoolman

* Adds unlinkConfrimTitle and unlinkConfirmMessage. All languages other than English were done using Google Translate

* Fixes unlink toast to use dedicated unlinkSuccess and unlinkFailed keys.

* Changes tag priority on spoolTag, fixes type error by adding non-null assertion to spoolTag

* Switches around tag_uid and tray_uuid on const trayTag

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
This commit is contained in:
Dakota G
2026-03-10 14:06:09 +01:00
committed by GitHub
co-authored by MartinNYHC
parent 788b396406
commit 971984f91d
14 changed files with 316 additions and 161 deletions
+54 -12
View File
@@ -1,6 +1,7 @@
"""Spoolman integration API routes."""
import logging
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@@ -663,9 +664,11 @@ async def get_linked_spools(
class LinkSpoolRequest(BaseModel):
"""Request to link a Spoolman spool to an AMS tray."""
"""Request to link a Spoolman spool to an AMS tag (tray_uuid or tag_uid)."""
tray_uuid: str
spool_tag: str | None = None
tray_uuid: str | None = None
tag_uid: str | None = None
@router.post("/spools/{spool_id}/link")
@@ -675,7 +678,7 @@ async def link_spool(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
"""Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
sm = await get_spoolman_settings(db)
enabled, url = sm["enabled"], sm["url"]
if not enabled:
@@ -691,14 +694,19 @@ async def link_spool(
if not await client.health_check():
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
# Validate tray_uuid format (32 hex characters)
tray_uuid = request.tray_uuid.strip()
if len(tray_uuid) != 32:
raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
# Resolve and validate spool tag (supports tray_uuid=32 hex and tag_uid=16 hex)
spool_tag = (request.spool_tag or request.tray_uuid or request.tag_uid or "").strip()
if not spool_tag:
raise HTTPException(status_code=400, detail="Missing spool tag (tray_uuid or tag_uid)")
if len(spool_tag) not in (16, 32):
raise HTTPException(status_code=400, detail="Invalid spool tag format (must be 16 or 32 hex characters)")
try:
int(tray_uuid, 16)
int(spool_tag, 16)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
raise HTTPException(status_code=400, detail="Invalid spool tag format (must be hex)")
if set(spool_tag) == {"0"}:
raise HTTPException(status_code=400, detail="Invalid spool tag format (all-zero tag is not linkable)")
# Update spool with tag
# Note: Spoolman extra field values must be valid JSON, so we encode the string
@@ -706,11 +714,45 @@ async def link_spool(
result = await client.update_spool(
spool_id=spool_id,
extra={"tag": json.dumps(tray_uuid)},
extra={"tag": json.dumps(spool_tag)},
)
if result:
logger.info("Linked Spoolman spool %s to tray_uuid %s", spool_id, tray_uuid)
return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
else:
raise HTTPException(status_code=500, detail="Failed to update spool")
@router.post("/spools/{spool_id}/unlink")
async def unlink_spool(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
sm = await get_spoolman_settings(db)
enabled, url = sm["enabled"], sm["url"]
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
client = await get_spoolman_client()
if not client:
if url:
client = await init_spoolman_client(url)
else:
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
if not await client.health_check():
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
result = await client.update_spool(
spool_id=spool_id,
extra={"tag": json.dumps("")},
)
if result:
logger.info("Unlinked Spoolman spool %s", spool_id)
return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
else:
raise HTTPException(status_code=500, detail="Failed to update spool")