mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.
This commit is contained in:
parent
50a4c4c3eb
commit
7cb905ad0c
29 changed files with 937 additions and 233 deletions
File diff suppressed because one or more lines are too long
|
|
@ -2284,3 +2284,79 @@ async def clear_shopping_list(
|
|||
deleted = len(result.fetchall())
|
||||
await db.commit()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
class CreateSpoolFromSlotRequest(BaseModel):
|
||||
printer_id: int
|
||||
ams_id: int
|
||||
tray_id: int
|
||||
|
||||
|
||||
@router.post("/spools/from-slot", response_model=SpoolResponse)
|
||||
async def create_spool_from_slot(
|
||||
req: CreateSpoolFromSlotRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Explicit user action: create an inventory spool from an AMS slot's current tray data.
|
||||
|
||||
Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
|
||||
the user looked at the slot and chose to register it. Also assigns the new spool
|
||||
to the slot in the same call.
|
||||
"""
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
|
||||
|
||||
state = printer_manager.get_status(req.printer_id)
|
||||
if not state or not state.raw_data:
|
||||
raise HTTPException(status_code=404, detail="Printer not connected or no state available")
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
ams_units: list[dict] = []
|
||||
if isinstance(ams_data, list):
|
||||
ams_units = ams_data
|
||||
elif isinstance(ams_data, dict):
|
||||
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
||||
ams_units = ams_data["ams"]
|
||||
elif "tray" in ams_data:
|
||||
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
||||
|
||||
tray: dict | None = None
|
||||
for unit in ams_units:
|
||||
if not isinstance(unit, dict):
|
||||
continue
|
||||
if int(unit.get("id", -1)) != req.ams_id:
|
||||
continue
|
||||
for t in unit.get("tray", []):
|
||||
if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
|
||||
tray = t
|
||||
break
|
||||
if tray:
|
||||
break
|
||||
|
||||
if not tray or not tray.get("tray_type"):
|
||||
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
|
||||
|
||||
spool = await create_spool_from_tray(db, tray)
|
||||
await auto_assign_spool(
|
||||
req.printer_id,
|
||||
req.ams_id,
|
||||
req.tray_id,
|
||||
spool,
|
||||
printer_manager,
|
||||
db,
|
||||
tray_info_idx=tray.get("tray_info_idx", ""),
|
||||
)
|
||||
await db.commit()
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "spool_auto_assigned",
|
||||
"printer_id": req.printer_id,
|
||||
"ams_id": req.ams_id,
|
||||
"tray_id": req.tray_id,
|
||||
"spool_id": spool.id,
|
||||
}
|
||||
)
|
||||
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
|
||||
return result.scalar_one()
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"spoolman_enabled",
|
||||
"spoolman_disable_weight_sync",
|
||||
"spoolman_report_partial_usage",
|
||||
"auto_add_unknown_rfid",
|
||||
"disable_filament_warnings",
|
||||
"prefer_lowest_filament",
|
||||
"check_updates",
|
||||
|
|
@ -407,6 +408,7 @@ async def get_spoolman_settings(
|
|||
spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
|
||||
spoolman_disable_weight_sync = await get_setting(db, "spoolman_disable_weight_sync") or "false"
|
||||
spoolman_report_partial_usage = await get_setting(db, "spoolman_report_partial_usage") or "true"
|
||||
auto_add_unknown_rfid = await get_setting(db, "auto_add_unknown_rfid") or "true"
|
||||
|
||||
return {
|
||||
"spoolman_enabled": spoolman_enabled,
|
||||
|
|
@ -414,6 +416,7 @@ async def get_spoolman_settings(
|
|||
"spoolman_sync_mode": spoolman_sync_mode,
|
||||
"spoolman_disable_weight_sync": spoolman_disable_weight_sync,
|
||||
"spoolman_report_partial_usage": spoolman_report_partial_usage,
|
||||
"auto_add_unknown_rfid": auto_add_unknown_rfid,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -453,6 +456,8 @@ async def update_spoolman_settings(
|
|||
await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
|
||||
if "spoolman_report_partial_usage" in settings:
|
||||
await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
|
||||
if "auto_add_unknown_rfid" in settings:
|
||||
await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
|
||||
|
||||
spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
|
||||
|
||||
|
|
|
|||
|
|
@ -222,6 +222,11 @@ async def sync_printer_ams(
|
|||
skipped: list[SkippedSpool] = []
|
||||
errors = []
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
_auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
|
||||
auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
|
||||
|
||||
# Handle different AMS data structures
|
||||
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
|
||||
# H2D/newer printers: dict with different structure
|
||||
|
|
@ -326,6 +331,7 @@ async def sync_printer_ams(
|
|||
cached_spools=cached_spools,
|
||||
inventory_remaining=inv_remaining,
|
||||
spoolman_spool_id_hint=hint,
|
||||
auto_add_unknown_rfid=auto_add_unknown_rfid,
|
||||
)
|
||||
if sync_result:
|
||||
synced += 1
|
||||
|
|
@ -338,6 +344,15 @@ async def sync_printer_ams(
|
|||
logger.info(
|
||||
"Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
|
||||
)
|
||||
elif spool_tag and not auto_add_unknown_rfid:
|
||||
skipped.append(
|
||||
SkippedSpool(
|
||||
location=f"AMS {ams_id} T{tray.tray_id}",
|
||||
reason="Auto-add disabled; add to inventory manually",
|
||||
filament_type=tray.tray_type or None,
|
||||
color=tray.tray_color[:6] if tray.tray_color else None,
|
||||
)
|
||||
)
|
||||
elif spool_tag:
|
||||
errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
|
||||
elif not hint:
|
||||
|
|
@ -422,6 +437,11 @@ async def sync_all_printers(
|
|||
all_skipped: list[SkippedSpool] = []
|
||||
all_errors = []
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
_auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
|
||||
auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
|
||||
|
||||
# OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
|
||||
# This eliminates redundant API calls across all printers
|
||||
logger.debug("Fetching spools cache for sync-all operation...")
|
||||
|
|
@ -528,6 +548,7 @@ async def sync_all_printers(
|
|||
cached_spools=cached_spools,
|
||||
inventory_remaining=inv_remaining,
|
||||
spoolman_spool_id_hint=hint,
|
||||
auto_add_unknown_rfid=auto_add_unknown_rfid,
|
||||
)
|
||||
if sync_result:
|
||||
total_synced += 1
|
||||
|
|
@ -537,6 +558,15 @@ async def sync_all_printers(
|
|||
if not spool_exists:
|
||||
cached_spools.append(sync_result)
|
||||
logger.debug("Added newly created spool %s to cache", sync_result["id"])
|
||||
elif spool_tag and not auto_add_unknown_rfid:
|
||||
all_skipped.append(
|
||||
SkippedSpool(
|
||||
location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
|
||||
reason="Auto-add disabled; add to inventory manually",
|
||||
filament_type=tray.tray_type or None,
|
||||
color=tray.tray_color[:6] if tray.tray_color else None,
|
||||
)
|
||||
)
|
||||
elif spool_tag:
|
||||
all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
|
||||
elif not hint:
|
||||
|
|
@ -1108,3 +1138,112 @@ async def unlink_spool(
|
|||
|
||||
logger.info("Unlinked Spoolman spool %s", spool_id)
|
||||
return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
|
||||
|
||||
|
||||
class CreateSpoolFromSlotRequest(BaseModel):
|
||||
printer_id: int
|
||||
ams_id: int
|
||||
tray_id: int
|
||||
|
||||
|
||||
@router.post("/spools/from-slot")
|
||||
async def create_spool_from_slot(
|
||||
req: CreateSpoolFromSlotRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
|
||||
|
||||
Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
|
||||
the user looked at the slot and chose to register it. Calls sync_ams_tray with the
|
||||
auto-add override on so the spool is created even when the global setting is off.
|
||||
"""
|
||||
sm = await get_spoolman_settings(db)
|
||||
if not sm["enabled"]:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
if sm["url"]:
|
||||
client = await init_spoolman_client(sm["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 db.execute(select(Printer).where(Printer.id == req.printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
|
||||
state = printer_manager.get_status(req.printer_id)
|
||||
if not state or not state.raw_data:
|
||||
raise HTTPException(status_code=404, detail="Printer not connected or no state available")
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
ams_units: list[dict] = []
|
||||
if isinstance(ams_data, list):
|
||||
ams_units = ams_data
|
||||
elif isinstance(ams_data, dict):
|
||||
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
||||
ams_units = ams_data["ams"]
|
||||
elif "tray" in ams_data:
|
||||
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
||||
|
||||
tray = None
|
||||
for unit in ams_units:
|
||||
if not isinstance(unit, dict):
|
||||
continue
|
||||
if int(unit.get("id", -1)) != req.ams_id:
|
||||
continue
|
||||
for t in unit.get("tray", []):
|
||||
if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
|
||||
tray = client.parse_ams_tray(req.ams_id, t)
|
||||
break
|
||||
if tray:
|
||||
break
|
||||
|
||||
if not tray:
|
||||
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
|
||||
|
||||
sync_result = await client.sync_ams_tray(
|
||||
tray,
|
||||
printer.name,
|
||||
disable_weight_sync=True,
|
||||
auto_add_unknown_rfid=True,
|
||||
)
|
||||
if not sync_result:
|
||||
raise HTTPException(status_code=500, detail="Spoolman did not create a spool from the slot")
|
||||
|
||||
# Persist the slot assignment so the new spool shows on the slot tile.
|
||||
# If this fails, surface a 500 — silently returning success while the
|
||||
# binding rolled back leaves the user thinking the spool was added,
|
||||
# then watching the modal re-fire on the next MQTT push.
|
||||
if sync_result.get("id"):
|
||||
try:
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO spoolman_slot_assignments"
|
||||
" (printer_id, ams_id, tray_id, spoolman_spool_id)"
|
||||
" VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
|
||||
" ON CONFLICT(printer_id, ams_id, tray_id)"
|
||||
" DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
|
||||
),
|
||||
{
|
||||
"printer_id": req.printer_id,
|
||||
"ams_id": req.ams_id,
|
||||
"tray_id": req.tray_id,
|
||||
"spool_id": sync_result["id"],
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.exception("Failed to persist Spoolman slot assignment")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return {"success": True, "spool_id": sync_result.get("id")}
|
||||
|
|
|
|||
|
|
@ -529,6 +529,76 @@ def _get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
|
|||
return lock
|
||||
|
||||
|
||||
# Per-printer dedup for unknown_tag WS broadcasts. Keyed by
|
||||
# (ams_id, tray_id) -> (tag_uid, tray_uuid); we only re-broadcast when the
|
||||
# tag tuple changes for the slot. Cleared when the slot is reported empty
|
||||
# so remove + reinsert reliably re-prompts the UI.
|
||||
_unknown_tag_last_broadcast: dict[int, dict[tuple[int, int], tuple[str, str]]] = {}
|
||||
|
||||
|
||||
async def _broadcast_unknown_tag(
|
||||
*,
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
tag_uid: str,
|
||||
tray_uuid: str,
|
||||
tray_type: str | None = None,
|
||||
tray_color: str | None = None,
|
||||
tray_sub_brands: str | None = None,
|
||||
tray_count: int | None = None,
|
||||
) -> None:
|
||||
"""Broadcast unknown_tag, deduped so repeated MQTT pushes for the same slot+tag don't spam the UI."""
|
||||
_logger = logging.getLogger(__name__)
|
||||
slot_key = (ams_id, tray_id)
|
||||
tag_key = (tag_uid or "", tray_uuid or "")
|
||||
per_printer = _unknown_tag_last_broadcast.setdefault(printer_id, {})
|
||||
if per_printer.get(slot_key) == tag_key:
|
||||
_logger.debug(
|
||||
"unknown_tag deduped for printer=%d AMS=%d slot=%d tag=%s",
|
||||
printer_id,
|
||||
ams_id,
|
||||
tray_id,
|
||||
tag_key[0][:8] or tag_key[1][:8] or "(none)",
|
||||
)
|
||||
return
|
||||
_logger.info(
|
||||
"unknown_tag broadcast: printer=%d AMS=%d slot=%d type=%r color=%r tag=%s",
|
||||
printer_id,
|
||||
ams_id,
|
||||
tray_id,
|
||||
tray_type,
|
||||
tray_color,
|
||||
tag_key[0][:8] or tag_key[1][:8] or "(none)",
|
||||
)
|
||||
# Broadcast first; only commit the dedup if the WS write succeeds.
|
||||
# If broadcast raises, the next MQTT push retries instead of being
|
||||
# permanently silenced by a poisoned dedup entry.
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "unknown_tag",
|
||||
"printer_id": printer_id,
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
"tag_uid": tag_uid,
|
||||
"tray_uuid": tray_uuid,
|
||||
"tray_type": tray_type,
|
||||
"tray_color": tray_color,
|
||||
"tray_sub_brands": tray_sub_brands,
|
||||
"tray_count": tray_count,
|
||||
}
|
||||
)
|
||||
per_printer[slot_key] = tag_key
|
||||
|
||||
|
||||
def _clear_unknown_tag_dedup(printer_id: int, ams_id: int, tray_id: int) -> None:
|
||||
"""Drop the cached last-broadcast tag for a slot (called when slot reports empty or gets matched)."""
|
||||
per_printer = _unknown_tag_last_broadcast.get(printer_id)
|
||||
if per_printer is None:
|
||||
return
|
||||
per_printer.pop((ams_id, tray_id), None)
|
||||
|
||||
|
||||
# TTL for expected-print entries: evict registrations older than this to prevent
|
||||
# unbounded growth when a print is registered but never starts (e.g. printer
|
||||
# disconnect, app restart, print started from the printer panel).
|
||||
|
|
@ -1532,6 +1602,8 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
)
|
||||
|
||||
_spoolman_on = await get_setting(db, "spoolman_enabled")
|
||||
_auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
|
||||
_auto_add_unknown = _auto_add_raw is None or _auto_add_raw.lower() == "true"
|
||||
if not _spoolman_on or _spoolman_on.lower() != "true":
|
||||
for ams_unit in ams_data:
|
||||
if not isinstance(ams_unit, dict):
|
||||
|
|
@ -1545,6 +1617,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
tray_uuid = tray.get("tray_uuid", "")
|
||||
tray_info_idx = tray.get("tray_info_idx", "")
|
||||
if not tray.get("tray_type"):
|
||||
# Slot reported empty — drop any cached unknown-tag
|
||||
# broadcast so reinserting the same spool re-prompts.
|
||||
_clear_unknown_tag_dedup(printer_id, ams_id, tray_id)
|
||||
continue # Empty slot
|
||||
# Check if assignment already exists for this slot
|
||||
existing = await db.execute(
|
||||
|
|
@ -1684,8 +1759,27 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
spool = await find_matching_untagged_spool(db, tray)
|
||||
if spool:
|
||||
await link_tag_to_inventory_spool(db, spool, tray)
|
||||
else:
|
||||
elif _auto_add_unknown:
|
||||
spool = await create_spool_from_tray(db, tray)
|
||||
else:
|
||||
# Auto-add disabled: surface the slot so the
|
||||
# user can add it manually via the UI.
|
||||
await _broadcast_unknown_tag(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tag_uid=tag_uid,
|
||||
tray_uuid=tray_uuid,
|
||||
tray_type=tray.get("tray_type"),
|
||||
tray_color=tray.get("tray_color"),
|
||||
tray_sub_brands=tray.get("tray_sub_brands"),
|
||||
tray_count=len(ams_unit.get("tray", [])),
|
||||
)
|
||||
continue
|
||||
# Slot matched (existing tag, untagged inventory
|
||||
# match, or freshly auto-created spool) — drop any
|
||||
# stale dedup so a future tag swap re-prompts.
|
||||
_clear_unknown_tag_dedup(printer_id, ams_id, tray_id)
|
||||
await auto_assign_spool(
|
||||
printer_id,
|
||||
ams_id,
|
||||
|
|
@ -1714,27 +1808,29 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
)
|
||||
elif is_valid_tag(tag_uid, tray_uuid):
|
||||
# Non-BL spool with some tag — let user choose
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "unknown_tag",
|
||||
"printer_id": printer_id,
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
"tag_uid": tag_uid,
|
||||
"tray_uuid": tray_uuid,
|
||||
}
|
||||
await _broadcast_unknown_tag(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tag_uid=tag_uid,
|
||||
tray_uuid=tray_uuid,
|
||||
tray_type=tray.get("tray_type"),
|
||||
tray_color=tray.get("tray_color"),
|
||||
tray_sub_brands=tray.get("tray_sub_brands"),
|
||||
tray_count=len(ams_unit.get("tray", [])),
|
||||
)
|
||||
else:
|
||||
# No tag at all — let user choose from inventory
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "unknown_tag",
|
||||
"printer_id": printer_id,
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
"tag_uid": "",
|
||||
"tray_uuid": "",
|
||||
}
|
||||
await _broadcast_unknown_tag(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tag_uid="",
|
||||
tray_uuid="",
|
||||
tray_type=tray.get("tray_type"),
|
||||
tray_color=tray.get("tray_color"),
|
||||
tray_sub_brands=tray.get("tray_sub_brands"),
|
||||
tray_count=len(ams_unit.get("tray", [])),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
|
||||
|
|
@ -1754,6 +1850,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
if sync_mode and sync_mode != "auto":
|
||||
return # Only sync on auto mode
|
||||
|
||||
_auto_add_raw_sm = await get_setting(db, "auto_add_unknown_rfid")
|
||||
auto_add_unknown_rfid = _auto_add_raw_sm is None or _auto_add_raw_sm.lower() == "true"
|
||||
|
||||
# `spoolman_disable_weight_sync` is deprecated (#1119) — weight is now
|
||||
# always owned by per-print tracking, never by AMS auto-sync. The
|
||||
# setting is still read by the settings UI for backwards compat but
|
||||
|
|
@ -1846,7 +1945,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
tray = client.parse_ams_tray(ams_id, tray_data)
|
||||
if not tray:
|
||||
# Empty tray slot — record for local assignment cleanup
|
||||
# and drop any cached unknown-tag broadcast so a
|
||||
# reinserted spool re-prompts.
|
||||
empty_slots.append((ams_id, tray_id_raw))
|
||||
_clear_unknown_tag_dedup(printer_id, ams_id, tray_id_raw)
|
||||
continue
|
||||
|
||||
spool_tag = (
|
||||
|
|
@ -1870,7 +1972,24 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
cached_spools=cached_spools,
|
||||
inventory_remaining=inv_remaining,
|
||||
spoolman_spool_id_hint=hint,
|
||||
auto_add_unknown_rfid=auto_add_unknown_rfid,
|
||||
)
|
||||
if result is None and spool_tag and not auto_add_unknown_rfid:
|
||||
# Spoolman skipped auto-create per user setting — surface
|
||||
# the slot so the UI can offer "+ Add to inventory".
|
||||
await _broadcast_unknown_tag(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray.tray_id,
|
||||
tag_uid=tray.tag_uid or "",
|
||||
tray_uuid=tray.tray_uuid or "",
|
||||
tray_type=tray.tray_type,
|
||||
tray_color=tray.tray_color,
|
||||
tray_sub_brands=tray.tray_sub_brands,
|
||||
tray_count=len(trays),
|
||||
)
|
||||
elif result:
|
||||
_clear_unknown_tag_dedup(printer_id, ams_id, tray.tray_id)
|
||||
if result:
|
||||
synced += 1
|
||||
if result.get("id"):
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ class AppSettings(BaseModel):
|
|||
default=True,
|
||||
description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
|
||||
)
|
||||
auto_add_unknown_rfid: bool = Field(
|
||||
default=True,
|
||||
description="Automatically add spools with unknown RFID tags to inventory. Disable if you pre-create inventory entries manually to avoid duplicates.",
|
||||
)
|
||||
disable_filament_warnings: bool = Field(
|
||||
default=False,
|
||||
description="Disable insufficient filament warnings when printing or queueing prints",
|
||||
|
|
@ -403,6 +407,7 @@ class AppSettingsUpdate(BaseModel):
|
|||
spoolman_sync_mode: str | None = None
|
||||
spoolman_disable_weight_sync: bool | None = None
|
||||
spoolman_report_partial_usage: bool | None = None
|
||||
auto_add_unknown_rfid: bool | None = None
|
||||
disable_filament_warnings: bool | None = None
|
||||
prefer_lowest_filament: bool | None = None
|
||||
check_updates: bool | None = None
|
||||
|
|
|
|||
|
|
@ -1080,6 +1080,7 @@ class SpoolmanClient:
|
|||
cached_spools: list[dict] | None = None,
|
||||
inventory_remaining: float | None = None,
|
||||
spoolman_spool_id_hint: int | None = None,
|
||||
auto_add_unknown_rfid: bool = True,
|
||||
) -> dict | None:
|
||||
"""Sync one AMS tray to Spoolman; creates the spool on first sight, updates weight otherwise."""
|
||||
logger.debug(
|
||||
|
|
@ -1126,7 +1127,18 @@ class SpoolmanClient:
|
|||
remaining_weight=None if disable_weight_sync else remaining,
|
||||
)
|
||||
|
||||
# Spool not found by tag - auto-create it
|
||||
# Spool not found by tag - auto-create it, unless the user has
|
||||
# opted out of auto-adding unknown RFIDs (settings.auto_add_unknown_rfid).
|
||||
# Caller broadcasts unknown_tag on the resulting None so the UI can
|
||||
# surface a "+ Add to inventory" affordance on the slot.
|
||||
if not auto_add_unknown_rfid:
|
||||
logger.info(
|
||||
"Auto-add disabled; skipping Spoolman spool create for %s (tag: %s...)",
|
||||
tray.tray_sub_brands,
|
||||
spool_tag[:16],
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
|
||||
if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
|
||||
filament = await self._find_or_create_filament(tray)
|
||||
|
|
|
|||
|
|
@ -1114,6 +1114,9 @@ export interface AppSettings {
|
|||
// Filament tracking
|
||||
disable_filament_warnings: boolean; // Disable filament warnings (print insufficiency and assignment mismatch)
|
||||
prefer_lowest_filament: boolean; // When multiple spools match, prefer lowest remaining filament
|
||||
spoolman_enabled: boolean; // True when the user has switched filament tracking to Spoolman; backend includes this in the /settings/ response even though earlier consumers read it from the dedicated /settings/spoolman endpoint as a string
|
||||
auto_add_unknown_rfid: boolean; // When false, the backend skips auto-creating inventory spools for unknown RFID tags and instead broadcasts an unknown_tag event for the confirmation modal
|
||||
spoolman_url: string;
|
||||
// Default printer
|
||||
default_printer_id: number | null;
|
||||
// Dark mode theme settings
|
||||
|
|
@ -5051,10 +5054,20 @@ export const api = {
|
|||
request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/unlink`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
createSpoolmanSpoolFromSlot: (data: { printer_id: number; ams_id: number; tray_id: number }) =>
|
||||
request<{ success: boolean; spool_id: number | null }>(`/spoolman/spools/from-slot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
createSpoolFromSlot: (data: { printer_id: number; ams_id: number; tray_id: number }) =>
|
||||
request<InventorySpool>(`/inventory/spools/from-slot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getSpoolmanSettings: () =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman'),
|
||||
updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; }) =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman', {
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; auto_add_unknown_rfid: string; }>('/settings/spoolman'),
|
||||
updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; auto_add_unknown_rfid?: string; }) =>
|
||||
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; auto_add_unknown_rfid: string; }>('/settings/spoolman', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { KProfile } from '../api/client';
|
|||
import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
|
||||
import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
|
||||
import { Button } from './Button';
|
||||
import { getAmsLabel } from '../utils/amsHelpers';
|
||||
|
||||
interface SlotInfo {
|
||||
amsId: number;
|
||||
|
|
@ -21,35 +22,6 @@ interface SlotInfo {
|
|||
savedPresetId?: string;
|
||||
}
|
||||
|
||||
// Get proper AMS label (handles HT AMS with ID 128+)
|
||||
function getAmsLabel(amsId: number, trayCount: number): string {
|
||||
// External spool
|
||||
if (amsId === 255) return 'External';
|
||||
|
||||
let normalizedId: number;
|
||||
let isHt = false;
|
||||
|
||||
if (amsId >= 128 && amsId <= 135) {
|
||||
// HT AMS range: 128-135 → A-H
|
||||
normalizedId = amsId - 128;
|
||||
isHt = true;
|
||||
} else if (amsId >= 0 && amsId <= 3) {
|
||||
// Regular AMS range: 0-3 → A-D
|
||||
normalizedId = amsId;
|
||||
// Check tray count as secondary indicator
|
||||
isHt = trayCount === 1;
|
||||
} else {
|
||||
// Unknown range - fallback to A
|
||||
normalizedId = 0;
|
||||
}
|
||||
|
||||
// Cap to valid letter range (A-H)
|
||||
normalizedId = Math.max(0, Math.min(normalizedId, 7));
|
||||
const letter = String.fromCharCode(65 + normalizedId);
|
||||
|
||||
return isHt ? `HT-${letter}` : `AMS-${letter}`;
|
||||
}
|
||||
|
||||
// Convert setting_id to tray_info_idx (filament_id format)
|
||||
// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
|
||||
function convertToTrayInfoIdx(settingId: string): string {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { getIconByName } from './IconPicker';
|
|||
import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
|
||||
import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
|
||||
import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
|
||||
import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt';
|
||||
import { UnknownSpoolModal } from './UnknownSpoolModal';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Card, CardHeader, CardContent } from './Card';
|
||||
|
|
@ -116,6 +118,10 @@ export function Layout() {
|
|||
// Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
|
||||
useSponsorPrompt(settings?.currency ?? 'EUR');
|
||||
|
||||
// Unknown-spool prompt — surfaces a confirmation modal when the AMS reports a
|
||||
// tag with no inventory match (only when `auto_add_unknown_rfid` is off).
|
||||
const unknownSpool = useUnknownTagPrompt();
|
||||
|
||||
// Fetch default sidebar order via a public endpoint (no settings:read needed)
|
||||
const { data: defaultSidebarData } = useQuery({
|
||||
queryKey: ['default-sidebar-order'],
|
||||
|
|
@ -895,6 +901,13 @@ export function Layout() {
|
|||
<Outlet />
|
||||
</main>
|
||||
|
||||
<UnknownSpoolModal
|
||||
prompt={unknownSpool.prompt}
|
||||
isPending={unknownSpool.isPending}
|
||||
onConfirm={unknownSpool.confirm}
|
||||
onCancel={unknownSpool.cancel}
|
||||
/>
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
{showShortcuts && (
|
||||
<KeyboardShortcutsModal
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export function SpoolmanSettings() {
|
|||
const [localSyncMode, setLocalSyncMode] = useState('auto');
|
||||
const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);
|
||||
const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);
|
||||
const [localAutoAddUnknownRfid, setLocalAutoAddUnknownRfid] = useState(true);
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showAllSkipped, setShowAllSkipped] = useState(false);
|
||||
|
|
@ -51,6 +52,7 @@ export function SpoolmanSettings() {
|
|||
setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
|
||||
setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true');
|
||||
setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false');
|
||||
setLocalAutoAddUnknownRfid(settings.auto_add_unknown_rfid !== 'false');
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [settings]);
|
||||
|
|
@ -65,7 +67,8 @@ export function SpoolmanSettings() {
|
|||
(settings.spoolman_url || '') !== localUrl ||
|
||||
(settings.spoolman_sync_mode || 'auto') !== localSyncMode ||
|
||||
(settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync ||
|
||||
(settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage;
|
||||
(settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage ||
|
||||
(settings.auto_add_unknown_rfid !== 'false') !== localAutoAddUnknownRfid;
|
||||
|
||||
if (hasChanges) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
|
|
@ -74,7 +77,7 @@ export function SpoolmanSettings() {
|
|||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, isInitialized]);
|
||||
}, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, localAutoAddUnknownRfid, isInitialized]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
|
|
@ -85,6 +88,7 @@ export function SpoolmanSettings() {
|
|||
spoolman_sync_mode: localSyncMode,
|
||||
spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false',
|
||||
spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false',
|
||||
auto_add_unknown_rfid: localAutoAddUnknownRfid ? 'true' : 'false',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
|
||||
|
|
@ -285,6 +289,25 @@ export function SpoolmanSettings() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-add unknown RFID toggle — applies to both internal and Spoolman modes */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="pr-4">
|
||||
<p className="text-white">{t('settings.autoAddUnknownRfid')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('settings.autoAddUnknownRfidDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localAutoAddUnknownRfid}
|
||||
onChange={(e) => setLocalAutoAddUnknownRfid(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Built-in Inventory details */}
|
||||
{!localEnabled && (
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
51
frontend/src/components/UnknownSpoolModal.tsx
Normal file
51
frontend/src/components/UnknownSpoolModal.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { getSwatchStyle } from '../utils/colors';
|
||||
import type { UnknownSpoolPrompt } from '../hooks/useUnknownTagPrompt';
|
||||
|
||||
interface UnknownSpoolModalProps {
|
||||
prompt: UnknownSpoolPrompt | null;
|
||||
isPending: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnknownSpoolModal({ prompt, isPending, onConfirm, onCancel }: UnknownSpoolModalProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!prompt) return null;
|
||||
|
||||
const location = `${prompt.printer_name} • ${prompt.ams_label} • ${prompt.slot_label}`;
|
||||
const swatchStyle = prompt.color_hex ? getSwatchStyle(prompt.color_hex) : undefined;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t('inventory.unknownSpoolTitle')}
|
||||
message={t('inventory.unknownSpoolMessage', { location })}
|
||||
confirmText={t('inventory.addToInventory')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="default"
|
||||
isLoading={isPending}
|
||||
loadingText={t('inventory.addToInventoryPending')}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary">
|
||||
{swatchStyle && (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full border border-black/20 flex-shrink-0"
|
||||
style={swatchStyle}
|
||||
aria-label={prompt.color_hex ?? undefined}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
{prompt.brand ? `${prompt.brand} ${prompt.material ?? ''}`.trim() : prompt.material ?? '—'}
|
||||
</p>
|
||||
{prompt.color_hex && (
|
||||
<p className="text-xs text-bambu-gray font-mono uppercase">#{prompt.color_hex.replace(/^#/, '').slice(0, 6)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
149
frontend/src/hooks/useUnknownTagPrompt.ts
Normal file
149
frontend/src/hooks/useUnknownTagPrompt.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Printer } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getAmsLabel } from '../utils/amsHelpers';
|
||||
|
||||
export interface UnknownTagDetail {
|
||||
printer_id: number;
|
||||
ams_id: number;
|
||||
tray_id: number;
|
||||
tag_uid?: string;
|
||||
tray_uuid?: string;
|
||||
// Backend-provided so the modal doesn't need to look up stale cached
|
||||
// `printerStatus` data — the React Query cache often lags the WS event by
|
||||
// several seconds while the new MQTT push is being applied.
|
||||
tray_type?: string | null;
|
||||
tray_color?: string | null;
|
||||
tray_sub_brands?: string | null;
|
||||
tray_count?: number | null;
|
||||
}
|
||||
|
||||
export interface UnknownSpoolPrompt {
|
||||
printer_id: number;
|
||||
ams_id: number;
|
||||
tray_id: number;
|
||||
printer_name: string;
|
||||
ams_label: string;
|
||||
slot_label: string;
|
||||
material: string | null;
|
||||
color_hex: string | null;
|
||||
brand: string | null;
|
||||
}
|
||||
|
||||
function slotKey(printer_id: number, ams_id: number, tray_id: number): string {
|
||||
return `${printer_id}|${ams_id}|${tray_id}`;
|
||||
}
|
||||
|
||||
export function useUnknownTagPrompt() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { user, authEnabled } = useAuth();
|
||||
const [queue, setQueue] = useState<UnknownSpoolPrompt[]>([]);
|
||||
|
||||
const isAuthed = !authEnabled || !!user;
|
||||
|
||||
const buildPrompt = useCallback(
|
||||
(detail: UnknownTagDetail): UnknownSpoolPrompt | null => {
|
||||
// The backend only broadcasts unknown_tag for slots with real tray data,
|
||||
// and includes the relevant fields in the payload — no need to fall
|
||||
// back to the (often stale) cached printerStatus query for these.
|
||||
if (!detail.tray_type) return null;
|
||||
const printers = queryClient.getQueryData<Printer[]>(['printers']);
|
||||
const printer = printers?.find(p => p.id === detail.printer_id);
|
||||
const trayCount = detail.tray_count ?? 4;
|
||||
return {
|
||||
printer_id: detail.printer_id,
|
||||
ams_id: detail.ams_id,
|
||||
tray_id: detail.tray_id,
|
||||
printer_name: printer?.name ?? `Printer ${detail.printer_id}`,
|
||||
ams_label: getAmsLabel(detail.ams_id, trayCount),
|
||||
slot_label: `${t('inventory.unknownSpoolSlot', 'Slot')} ${detail.tray_id + 1}`,
|
||||
material: detail.tray_type ?? null,
|
||||
color_hex: detail.tray_color ?? null,
|
||||
brand: detail.tray_sub_brands ?? null,
|
||||
};
|
||||
},
|
||||
[queryClient, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthed) return;
|
||||
const handler = (e: Event) => {
|
||||
const ce = e as CustomEvent<UnknownTagDetail>;
|
||||
const detail = ce.detail;
|
||||
if (!detail) return;
|
||||
const prompt = buildPrompt(detail);
|
||||
if (!prompt) return;
|
||||
setQueue(prev => {
|
||||
// Don't double-queue the same slot — the backend dedupes per
|
||||
// (slot, tag) so repeat events here mean the user is still
|
||||
// looking at the same modal.
|
||||
const key = slotKey(prompt.printer_id, prompt.ams_id, prompt.tray_id);
|
||||
if (prev.some(p => slotKey(p.printer_id, p.ams_id, p.tray_id) === key)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, prompt];
|
||||
});
|
||||
};
|
||||
window.addEventListener('unknown-tag', handler);
|
||||
return () => window.removeEventListener('unknown-tag', handler);
|
||||
}, [isAuthed, buildPrompt]);
|
||||
|
||||
const current = queue[0] ?? null;
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (prompt: UnknownSpoolPrompt) => {
|
||||
const settings = queryClient.getQueryData<{ spoolman_enabled?: boolean }>(['settings']);
|
||||
if (settings?.spoolman_enabled) {
|
||||
await api.createSpoolmanSpoolFromSlot({
|
||||
printer_id: prompt.printer_id,
|
||||
ams_id: prompt.ams_id,
|
||||
tray_id: prompt.tray_id,
|
||||
});
|
||||
} else {
|
||||
await api.createSpoolFromSlot({
|
||||
printer_id: prompt.printer_id,
|
||||
ams_id: prompt.ams_id,
|
||||
tray_id: prompt.tray_id,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(t('inventory.addToInventorySuccess'), 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
|
||||
setQueue(prev => prev.slice(1));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('inventory.addToInventoryFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
if (!current || addMutation.isPending) return;
|
||||
addMutation.mutate(current);
|
||||
}, [current, addMutation]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!current) return;
|
||||
setQueue(prev => prev.slice(1));
|
||||
}, [current]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
prompt: current,
|
||||
isPending: addMutation.isPending,
|
||||
confirm,
|
||||
cancel,
|
||||
}),
|
||||
[current, addMutation.isPending, confirm, cancel],
|
||||
);
|
||||
}
|
||||
|
|
@ -310,18 +310,37 @@ export function useWebSocket() {
|
|||
debouncedInvalidate('inventory-spools');
|
||||
break;
|
||||
|
||||
case 'unknown_tag':
|
||||
// Unknown RFID tag detected - dispatch event for UI
|
||||
case 'unknown_tag': {
|
||||
// Unknown RFID tag detected — dispatch event for UI. The backend
|
||||
// ships the slot's current tray data alongside the event so
|
||||
// consumers don't have to look it up from the (frequently stale)
|
||||
// cached printerStatus query.
|
||||
const m = message as unknown as {
|
||||
printer_id?: number;
|
||||
ams_id?: number;
|
||||
tray_id?: number;
|
||||
tag_uid?: string;
|
||||
tray_uuid?: string;
|
||||
tray_type?: string | null;
|
||||
tray_color?: string | null;
|
||||
tray_sub_brands?: string | null;
|
||||
tray_count?: number | null;
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent('unknown-tag', {
|
||||
detail: {
|
||||
printer_id: (message as unknown as { printer_id?: number }).printer_id,
|
||||
ams_id: (message as unknown as { ams_id?: number }).ams_id,
|
||||
tray_id: (message as unknown as { tray_id?: number }).tray_id,
|
||||
tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,
|
||||
tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
|
||||
printer_id: m.printer_id,
|
||||
ams_id: m.ams_id,
|
||||
tray_id: m.tray_id,
|
||||
tag_uid: m.tag_uid,
|
||||
tray_uuid: m.tray_uuid,
|
||||
tray_type: m.tray_type,
|
||||
tray_color: m.tray_color,
|
||||
tray_sub_brands: m.tray_sub_brands,
|
||||
tray_count: m.tray_count,
|
||||
}
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'background_dispatch':
|
||||
window.dispatchEvent(
|
||||
|
|
|
|||
|
|
@ -1761,6 +1761,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'Filament-Verfolgung',
|
||||
filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',
|
||||
autoAddUnknownRfid: 'Unbekannte RFID-Spulen automatisch hinzufügen',
|
||||
autoAddUnknownRfidDesc: 'Erstellt automatisch einen Inventareintrag, wenn eine Spule mit unbekanntem RFID-Tag erkannt wird. Deaktivieren, wenn Sie neue Spulen vorab manuell anlegen, um Duplikate zu vermeiden.',
|
||||
filamentChecks: 'Filament-Prüfungen',
|
||||
disableFilamentWarnings: 'Filament-Warnungen deaktivieren',
|
||||
disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
|
||||
|
|
@ -3841,6 +3843,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Spulen-Inventar',
|
||||
subtitle: 'Verwalten Sie Ihre Spulen',
|
||||
addToInventory: 'Zum Inventar hinzufügen',
|
||||
addToInventoryPending: 'Wird hinzugefügt...',
|
||||
addToInventorySuccess: 'Spule zum Inventar hinzugefügt',
|
||||
addToInventoryFailed: 'Spule konnte nicht zum Inventar hinzugefügt werden',
|
||||
unknownSpoolTitle: 'Neues Filament erkannt',
|
||||
unknownSpoolMessage: 'An {{location}} wurde eine Spule mit unbekanntem RFID-Tag erkannt. Jetzt zum Inventar hinzufügen?',
|
||||
unknownSpoolSlot: 'Slot',
|
||||
spoolmanMixedContentTitle: 'Spoolman lässt sich nicht über HTTPS laden — Browser blockiert gemischte Inhalte',
|
||||
spoolmanMixedContentBody: 'Bambuddy wird über HTTPS ausgeliefert (über deinen Reverse-Proxy), aber deine Spoolman-URL ist nach wie vor HTTP. Browser blockieren gemischte Inhalte aus Sicherheitsgründen, daher kann die eingebettete Spoolman-Oberfläche nicht geladen werden. Spoolman muss ebenfalls über HTTPS erreichbar sein.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Stelle Spoolman hinter denselben Reverse-Proxy wie Bambuddy (Traefik / Nginx / Caddy) mit HTTPS und aktualisiere die Spoolman-URL in den Einstellungen auf die neue HTTPS-Adresse.',
|
||||
|
|
|
|||
|
|
@ -1775,6 +1775,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'Filament Tracking',
|
||||
filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',
|
||||
autoAddUnknownRfid: 'Auto-add unknown RFID spools',
|
||||
autoAddUnknownRfidDesc: 'Automatically create an inventory entry when a spool with an unknown RFID tag is detected. Turn off if you pre-register new spools manually to avoid duplicates.',
|
||||
filamentChecks: 'Filament checks',
|
||||
disableFilamentWarnings: 'Disable filament warnings',
|
||||
disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
|
||||
|
|
@ -3856,6 +3858,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Spool Inventory',
|
||||
subtitle: 'Manage your spools',
|
||||
addToInventory: 'Add to Inventory',
|
||||
addToInventoryPending: 'Adding...',
|
||||
addToInventorySuccess: 'Spool added to inventory',
|
||||
addToInventoryFailed: 'Failed to add spool to inventory',
|
||||
unknownSpoolTitle: 'New filament detected',
|
||||
unknownSpoolMessage: 'A spool with an unknown RFID tag was detected at {{location}}. Add it to your inventory now?',
|
||||
unknownSpoolSlot: 'Slot',
|
||||
spoolmanMixedContentTitle: 'Spoolman can\'t load over HTTPS — mixed-content blocked by your browser',
|
||||
spoolmanMixedContentBody: 'Bambuddy is served over HTTPS (via your reverse proxy), but your Spoolman URL is still plain HTTP. Browsers block mixed content for security, so the embedded Spoolman UI can\'t render. Spoolman needs to be reachable over HTTPS for this to work.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.',
|
||||
|
|
|
|||
|
|
@ -1764,6 +1764,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'Seguimiento del filamento',
|
||||
filamentTrackingDesc: 'Elija cómo realizar el seguimiento de sus bobinas de filamento. Puede usar el inventario integrado o conectar un servidor Spoolman externo.',
|
||||
autoAddUnknownRfid: 'Añadir automáticamente bobinas RFID desconocidas',
|
||||
autoAddUnknownRfidDesc: 'Crea automáticamente una entrada de inventario cuando se detecta una bobina con una etiqueta RFID desconocida. Desactive si registra manualmente las nuevas bobinas con antelación para evitar duplicados.',
|
||||
filamentChecks: 'Comprobaciones de filamento',
|
||||
disableFilamentWarnings: 'Desactivar las advertencias de filamento',
|
||||
disableFilamentWarningsDesc: 'No mostrar advertencias sobre filamento insuficiente al imprimir o encolar',
|
||||
|
|
@ -3844,6 +3846,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Inventario de bobinas',
|
||||
subtitle: 'Gestione sus bobinas',
|
||||
addToInventory: 'Añadir al inventario',
|
||||
addToInventoryPending: 'Añadiendo...',
|
||||
addToInventorySuccess: 'Bobina añadida al inventario',
|
||||
addToInventoryFailed: 'No se pudo añadir la bobina al inventario',
|
||||
unknownSpoolTitle: 'Nuevo filamento detectado',
|
||||
unknownSpoolMessage: 'Se ha detectado una bobina con una etiqueta RFID desconocida en {{location}}. ¿Añadirla a su inventario ahora?',
|
||||
unknownSpoolSlot: 'Ranura',
|
||||
spoolmanMixedContentTitle: 'Spoolman no se puede cargar por HTTPS — contenido mixto bloqueado por su navegador',
|
||||
spoolmanMixedContentBody: 'Bambuddy se sirve por HTTPS (mediante su proxy inverso), pero su URL de Spoolman sigue siendo HTTP sin cifrar. Los navegadores bloquean el contenido mixto por seguridad, por lo que la interfaz integrada de Spoolman no se puede mostrar. Spoolman debe ser accesible por HTTPS para que esto funcione.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Ponga Spoolman tras el mismo proxy inverso que Bambuddy (Traefik / Nginx / Caddy) con HTTPS y luego actualice la URL de Spoolman en Ajustes a la nueva dirección HTTPS.',
|
||||
|
|
|
|||
|
|
@ -1717,6 +1717,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'Suivi de Filament',
|
||||
filamentTrackingDesc: 'Choisissez comment suivre vos bobines. Utilisez l\'inventaire intégré ou connectez un serveur Spoolman.',
|
||||
autoAddUnknownRfid: 'Ajouter automatiquement les bobines RFID inconnues',
|
||||
autoAddUnknownRfidDesc: 'Crée automatiquement une entrée d\'inventaire lorsqu\'une bobine avec un tag RFID inconnu est détectée. Désactivez si vous enregistrez manuellement les nouvelles bobines à l\'avance pour éviter les doublons.',
|
||||
filamentChecks: 'Vérifications du filament',
|
||||
disableFilamentWarnings: 'Désactiver les avertissements de filament',
|
||||
disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
|
||||
|
|
@ -3830,6 +3832,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Inventaire de Bobines',
|
||||
subtitle: 'Gérez vos bobines',
|
||||
addToInventory: 'Ajouter à l\'inventaire',
|
||||
addToInventoryPending: 'Ajout...',
|
||||
addToInventorySuccess: 'Bobine ajoutée à l\'inventaire',
|
||||
addToInventoryFailed: 'Échec de l\'ajout de la bobine à l\'inventaire',
|
||||
unknownSpoolTitle: 'Nouveau filament détecté',
|
||||
unknownSpoolMessage: 'Une bobine avec un tag RFID inconnu a été détectée à {{location}}. L\'ajouter à votre inventaire maintenant ?',
|
||||
unknownSpoolSlot: 'Emplacement',
|
||||
spoolmanMixedContentTitle: 'Spoolman ne peut pas se charger en HTTPS — contenu mixte bloqué par votre navigateur',
|
||||
spoolmanMixedContentBody: 'Bambuddy est servi en HTTPS (via votre reverse proxy), mais votre URL Spoolman est encore en HTTP. Les navigateurs bloquent le contenu mixte pour des raisons de sécurité, donc l\'interface Spoolman intégrée ne peut pas s\'afficher. Spoolman doit être accessible en HTTPS.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Placez Spoolman derrière le même reverse proxy que Bambuddy (Traefik / Nginx / Caddy) en HTTPS, puis mettez à jour l\'URL Spoolman dans les Paramètres avec la nouvelle adresse HTTPS.',
|
||||
|
|
|
|||
|
|
@ -1717,6 +1717,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'Tracciamento filamento',
|
||||
filamentTrackingDesc: 'Scegli come tracciare le bobine di filamento. Puoi usare l\'inventario integrato o collegare un server Spoolman esterno.',
|
||||
autoAddUnknownRfid: 'Aggiungi automaticamente bobine RFID sconosciute',
|
||||
autoAddUnknownRfidDesc: 'Crea automaticamente una voce di inventario quando viene rilevata una bobina con tag RFID sconosciuto. Disabilita se registri manualmente le nuove bobine in anticipo per evitare duplicati.',
|
||||
filamentChecks: 'Controlli filamento',
|
||||
disableFilamentWarnings: 'Disabilita avvisi filamento',
|
||||
disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
|
||||
|
|
@ -3829,6 +3831,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Inventario Bobine',
|
||||
subtitle: 'Gestisci le tue bobine',
|
||||
addToInventory: 'Aggiungi all\'inventario',
|
||||
addToInventoryPending: 'Aggiunta in corso...',
|
||||
addToInventorySuccess: 'Bobina aggiunta all\'inventario',
|
||||
addToInventoryFailed: 'Aggiunta della bobina all\'inventario non riuscita',
|
||||
unknownSpoolTitle: 'Nuovo filamento rilevato',
|
||||
unknownSpoolMessage: 'È stata rilevata una bobina con tag RFID sconosciuto in {{location}}. Aggiungerla all\'inventario ora?',
|
||||
unknownSpoolSlot: 'Slot',
|
||||
spoolmanMixedContentTitle: 'Spoolman non può essere caricato tramite HTTPS — contenuto misto bloccato dal browser',
|
||||
spoolmanMixedContentBody: 'Bambuddy viene servito tramite HTTPS (dietro il tuo reverse proxy), ma l\'URL di Spoolman è ancora HTTP. I browser bloccano il contenuto misto per motivi di sicurezza, quindi l\'interfaccia Spoolman incorporata non può essere visualizzata. Anche Spoolman deve essere raggiungibile via HTTPS.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Metti Spoolman dietro lo stesso reverse proxy di Bambuddy (Traefik / Nginx / Caddy) in HTTPS, poi aggiorna l\'URL di Spoolman nelle Impostazioni con il nuovo indirizzo HTTPS.',
|
||||
|
|
|
|||
|
|
@ -1760,6 +1760,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'フィラメント追跡',
|
||||
filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',
|
||||
autoAddUnknownRfid: '不明なRFIDスプールを自動追加',
|
||||
autoAddUnknownRfidDesc: '不明なRFIDタグのスプールが検出されたとき、自動的に在庫エントリを作成します。新しいスプールを事前に手動で登録している場合は、重複を避けるためにオフにしてください。',
|
||||
filamentChecks: 'フィラメントチェック',
|
||||
disableFilamentWarnings: 'フィラメント警告を無効化',
|
||||
disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
|
||||
|
|
@ -3841,6 +3843,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'スプール在庫管理',
|
||||
subtitle: 'スプールを管理',
|
||||
addToInventory: '在庫に追加',
|
||||
addToInventoryPending: '追加中...',
|
||||
addToInventorySuccess: 'スプールを在庫に追加しました',
|
||||
addToInventoryFailed: 'スプールの在庫追加に失敗しました',
|
||||
unknownSpoolTitle: '新しいフィラメントを検出',
|
||||
unknownSpoolMessage: '{{location}} で不明なRFIDタグのスプールが検出されました。今すぐ在庫に追加しますか?',
|
||||
unknownSpoolSlot: 'スロット',
|
||||
spoolmanMixedContentTitle: 'Spoolman を HTTPS で読み込めません — ブラウザが混在コンテンツをブロックしています',
|
||||
spoolmanMixedContentBody: 'Bambuddy はリバースプロキシ経由で HTTPS 配信されていますが、Spoolman の URL は HTTP のままです。ブラウザはセキュリティ上の理由で混在コンテンツをブロックするため、埋め込みの Spoolman UI を表示できません。Spoolman も HTTPS でアクセスできる必要があります。',
|
||||
spoolmanMixedContentFixReverseProxy: 'Spoolman を Bambuddy と同じリバースプロキシ(Traefik / Nginx / Caddy)の後ろに HTTPS で配置し、設定で Spoolman URL を新しい HTTPS アドレスに更新してください。',
|
||||
|
|
|
|||
|
|
@ -1668,6 +1668,8 @@ export default {
|
|||
},
|
||||
filamentTracking: '필라멘트 추적',
|
||||
filamentTrackingDesc: '필라멘트 스풀을 추적하는 방법을 선택하세요. 내장 인벤토리를 사용하거나 외부 Spoolman 서버에 연결할 수 있습니다.',
|
||||
autoAddUnknownRfid: '알 수 없는 RFID 스풀 자동 추가',
|
||||
autoAddUnknownRfidDesc: '알 수 없는 RFID 태그가 있는 스풀이 감지되면 자동으로 인벤토리 항목을 생성합니다. 새 스풀을 미리 수동으로 등록한다면 중복을 피하기 위해 끄세요.',
|
||||
filamentChecks: '필라멘트 확인',
|
||||
disableFilamentWarnings: '필라멘트 경고 비활성화',
|
||||
disableFilamentWarningsDesc: '인쇄 또는 대기열 추가 시 필라멘트 부족 경고 표시 안 함',
|
||||
|
|
@ -3631,6 +3633,13 @@ export default {
|
|||
|
||||
inventory: {
|
||||
title: '스풀 재고',
|
||||
addToInventory: '인벤토리에 추가',
|
||||
addToInventoryPending: '추가 중...',
|
||||
addToInventorySuccess: '스풀이 인벤토리에 추가됨',
|
||||
addToInventoryFailed: '스풀을 인벤토리에 추가하지 못함',
|
||||
unknownSpoolTitle: '새 필라멘트 감지됨',
|
||||
unknownSpoolMessage: '{{location}}에서 알 수 없는 RFID 태그가 있는 스풀이 감지되었습니다. 지금 인벤토리에 추가하시겠습니까?',
|
||||
unknownSpoolSlot: '슬롯',
|
||||
spoolmanMixedContentTitle: 'HTTPS에서 Spoolman을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',
|
||||
spoolmanMixedContentBody: 'Bambuddy가 HTTPS로 서비스되고 있지만 Spoolman URL은 여전히 HTTP입니다. 브라우저는 보안상 혼합 콘텐츠를 차단하므로 내장된 Spoolman UI가 렌더링되지 않습니다. 이 기능이 작동하려면 Spoolman이 HTTPS로 접근 가능해야 합니다.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Spoolman을 Bambuddy와 같은 리버스 프록시(Traefik / Nginx / Caddy) 뒤에 HTTPS로 배치한 다음 설정에서 Spoolman URL을 새 HTTPS 주소로 업데이트하세요.',
|
||||
|
|
|
|||
|
|
@ -1717,6 +1717,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: 'Rastreamento de Filamento',
|
||||
filamentTrackingDesc: 'Escolha como rastrear seus rolos de filamento. Você pode usar o inventário interno ou conectar a um servidor Spoolman externo.',
|
||||
autoAddUnknownRfid: 'Adicionar automaticamente carretéis RFID desconhecidos',
|
||||
autoAddUnknownRfidDesc: 'Cria automaticamente uma entrada de inventário quando um carretel com tag RFID desconhecido é detectado. Desligue se você pré-registra novos carretéis manualmente para evitar duplicatas.',
|
||||
filamentChecks: 'Verificações de filamento',
|
||||
disableFilamentWarnings: 'Desativar avisos de filamento',
|
||||
disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',
|
||||
|
|
@ -3829,6 +3831,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Inventário de Carretéis',
|
||||
subtitle: 'Gerencie seus carretéis',
|
||||
addToInventory: 'Adicionar ao Inventário',
|
||||
addToInventoryPending: 'Adicionando...',
|
||||
addToInventorySuccess: 'Carretel adicionado ao inventário',
|
||||
addToInventoryFailed: 'Falha ao adicionar carretel ao inventário',
|
||||
unknownSpoolTitle: 'Novo filamento detectado',
|
||||
unknownSpoolMessage: 'Um carretel com tag RFID desconhecido foi detectado em {{location}}. Adicioná-lo ao seu inventário agora?',
|
||||
unknownSpoolSlot: 'Slot',
|
||||
spoolmanMixedContentTitle: 'Spoolman não pode carregar em HTTPS — conteúdo misto bloqueado pelo navegador',
|
||||
spoolmanMixedContentBody: 'O Bambuddy é servido via HTTPS (pelo seu reverse proxy), mas a URL do Spoolman ainda é HTTP. Os navegadores bloqueiam conteúdo misto por segurança, então a interface embutida do Spoolman não consegue carregar. O Spoolman também precisa estar acessível via HTTPS.',
|
||||
spoolmanMixedContentFixReverseProxy: 'Coloque o Spoolman atrás do mesmo reverse proxy do Bambuddy (Traefik / Nginx / Caddy) com HTTPS e atualize a URL do Spoolman em Configurações com o novo endereço HTTPS.',
|
||||
|
|
|
|||
|
|
@ -1764,6 +1764,8 @@ export default {
|
|||
// Filament Takip Modu
|
||||
filamentTracking: 'Filament Takibi',
|
||||
filamentTrackingDesc: 'Filament makaralarınızı nasıl takip edeceğinizi seçin. Yerleşik envanteri kullanabilir veya harici bir Spoolman sunucusuna bağlanabilirsiniz.',
|
||||
autoAddUnknownRfid: 'Bilinmeyen RFID makaralarını otomatik ekle',
|
||||
autoAddUnknownRfidDesc: 'Bilinmeyen RFID etiketli bir makara algılandığında otomatik olarak envantere bir kayıt oluşturur. Çoğaltmaları önlemek için yeni makaraları manuel olarak önceden kaydediyorsanız kapatın.',
|
||||
filamentChecks: 'Filament kontrolleri',
|
||||
disableFilamentWarnings: 'Filament uyarılarını devre dışı bırak',
|
||||
disableFilamentWarningsDesc: 'Yazdırırken veya kuyruğa eklerken yetersiz filamentle ilgili uyarıları gösterme',
|
||||
|
|
@ -3830,6 +3832,13 @@ export default {
|
|||
inventory: {
|
||||
title: 'Makara Envanteri',
|
||||
subtitle: 'Makaralarınızı yönetin',
|
||||
addToInventory: 'Envantere Ekle',
|
||||
addToInventoryPending: 'Ekleniyor...',
|
||||
addToInventorySuccess: 'Makara envantere eklendi',
|
||||
addToInventoryFailed: 'Makara envantere eklenemedi',
|
||||
unknownSpoolTitle: 'Yeni filament algılandı',
|
||||
unknownSpoolMessage: '{{location}} konumunda bilinmeyen RFID etiketli bir makara algılandı. Şimdi envantere eklensin mi?',
|
||||
unknownSpoolSlot: 'Yuva',
|
||||
spoolmanMixedContentTitle: 'Spoolman HTTPS üzerinden yüklenemiyor — tarayıcınız tarafından karışık içerik engellendi',
|
||||
spoolmanMixedContentBody: 'Bambuddy HTTPS üzerinden sunuluyor (ters proxy\'niz aracılığıyla), ancak Spoolman URL\'niz hâlâ düz HTTP. Tarayıcılar güvenlik için karışık içeriği engeller, bu nedenle gömülü Spoolman arayüzü oluşturulamaz. Bunun çalışması için Spoolman\'in HTTPS üzerinden erişilebilir olması gerekiyor.',
|
||||
spoolmanMixedContentFixReverseProxy: "Spoolman'i Bambuddy ile aynı ters proxy'nin (Traefik / Nginx / Caddy) arkasına HTTPS ile koyun, ardından Ayarlardaki Spoolman URL'sini yeni HTTPS adresine güncelleyin.",
|
||||
|
|
|
|||
|
|
@ -1762,6 +1762,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: '耗材追踪',
|
||||
filamentTrackingDesc: '选择如何追踪您的耗材。您可以使用内置库存或连接外部 Spoolman 服务器。',
|
||||
autoAddUnknownRfid: '自动添加未知 RFID 料盘',
|
||||
autoAddUnknownRfidDesc: '检测到带有未知 RFID 标签的料盘时,自动创建库存条目。如果您手动预先注册新料盘以避免重复,请关闭此项。',
|
||||
filamentChecks: '耗材检查',
|
||||
disableFilamentWarnings: '禁用耗材警告',
|
||||
disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',
|
||||
|
|
@ -3829,6 +3831,13 @@ export default {
|
|||
inventory: {
|
||||
title: '耗材库存',
|
||||
subtitle: '管理您的料盘',
|
||||
addToInventory: '添加到库存',
|
||||
addToInventoryPending: '正在添加...',
|
||||
addToInventorySuccess: '料盘已添加到库存',
|
||||
addToInventoryFailed: '添加料盘到库存失败',
|
||||
unknownSpoolTitle: '检测到新耗材',
|
||||
unknownSpoolMessage: '在 {{location}} 检测到带有未知 RFID 标签的料盘。是否立即添加到库存?',
|
||||
unknownSpoolSlot: '槽位',
|
||||
spoolmanMixedContentTitle: 'Spoolman 无法通过 HTTPS 加载 — 浏览器已阻止混合内容',
|
||||
spoolmanMixedContentBody: 'Bambuddy 通过您的反向代理以 HTTPS 提供服务,但您的 Spoolman 地址仍为 HTTP。出于安全考虑,浏览器会阻止混合内容,因此嵌入式 Spoolman 界面无法加载。Spoolman 也必须通过 HTTPS 访问。',
|
||||
spoolmanMixedContentFixReverseProxy: '请将 Spoolman 置于与 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之后并启用 HTTPS,然后在设置中将 Spoolman URL 更新为新的 HTTPS 地址。',
|
||||
|
|
|
|||
|
|
@ -1762,6 +1762,8 @@ export default {
|
|||
// Filament Tracking Mode
|
||||
filamentTracking: '耗材追蹤',
|
||||
filamentTrackingDesc: '選擇如何追蹤您的耗材。您可以使用內建庫存或連線外部 Spoolman 伺服器。',
|
||||
autoAddUnknownRfid: '自動新增未知 RFID 料盤',
|
||||
autoAddUnknownRfidDesc: '偵測到具有未知 RFID 標籤的料盤時,自動建立庫存項目。如果您手動預先註冊新料盤以避免重複,請關閉此項。',
|
||||
filamentChecks: '耗材檢查',
|
||||
disableFilamentWarnings: '停用耗材警告',
|
||||
disableFilamentWarningsDesc: '在列印或加入佇列時不顯示耗材不足警告',
|
||||
|
|
@ -3829,6 +3831,13 @@ export default {
|
|||
inventory: {
|
||||
title: '耗材庫存',
|
||||
subtitle: '管理您的料盤',
|
||||
addToInventory: '新增至庫存',
|
||||
addToInventoryPending: '正在新增...',
|
||||
addToInventorySuccess: '料盤已新增至庫存',
|
||||
addToInventoryFailed: '新增料盤至庫存失敗',
|
||||
unknownSpoolTitle: '偵測到新耗材',
|
||||
unknownSpoolMessage: '在 {{location}} 偵測到具有未知 RFID 標籤的料盤。是否立即新增至庫存?',
|
||||
unknownSpoolSlot: '插槽',
|
||||
spoolmanMixedContentTitle: 'Spoolman 無法透過 HTTPS 載入 — 瀏覽器已封鎖混合內容',
|
||||
spoolmanMixedContentBody: 'Bambuddy 透過您的反向代理以 HTTPS 提供服務,但您的 Spoolman 位址仍為 HTTP。基於安全考量,瀏覽器會封鎖混合內容,因此內嵌的 Spoolman 介面無法載入。Spoolman 也必須可透過 HTTPS 存取。',
|
||||
spoolmanMixedContentFixReverseProxy: '請將 Spoolman 置於與 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之後並啟用 HTTPS,然後在設定中將 Spoolman URL 更新為新的 HTTPS 位址。',
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
|
|||
import { FileUploadModal } from '../components/FileUploadModal';
|
||||
import { PrintModal } from '../components/PrintModal';
|
||||
import { PrinterInfoModal } from '../components/PrinterInfoModal';
|
||||
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool } from '../utils/amsHelpers';
|
||||
import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool } from '../utils/amsHelpers';
|
||||
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
|
||||
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
|
||||
import { Collapsible } from '../components/Collapsible';
|
||||
|
|
@ -845,16 +845,6 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
|
|||
}
|
||||
|
||||
|
||||
function getAmsLabel(amsId: number | string, trayCount: number): string {
|
||||
// Ensure amsId is a number (backend might send string)
|
||||
const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
|
||||
const safeId = isNaN(id) ? 0 : id;
|
||||
const isHt = trayCount === 1;
|
||||
// AMS-HT uses IDs starting at 128, regular AMS uses 0-3
|
||||
const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
|
||||
const letter = String.fromCharCode(65 + normalizedId); // 0=A, 1=B, 2=C, 3=D
|
||||
return isHt ? `HT-${letter}` : `AMS-${letter}`;
|
||||
}
|
||||
|
||||
/** Classify an empty AMS slot for UI rendering (#1322 follow-up).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ export function normalizeColorForCompare(color: string | undefined): string {
|
|||
return color.replace('#', '').toLowerCase().substring(0, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
|
||||
* regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
|
||||
* IDs starting at 128). `trayCount` is required because the type can't be
|
||||
* inferred from the id alone — regular AMS IDs 0-3 can collide with the
|
||||
* normalized HT range otherwise.
|
||||
*/
|
||||
export function getAmsLabel(amsId: number | string, trayCount: number): string {
|
||||
const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
|
||||
const safeId = isNaN(id) ? 0 : id;
|
||||
if (safeId === 255) return 'External';
|
||||
const isHt = trayCount === 1;
|
||||
const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
|
||||
const letter = String.fromCharCode(65 + normalizedId);
|
||||
return isHt ? `HT-${letter}` : `AMS-${letter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filament type equivalence groups.
|
||||
* Types within the same group are interchangeable on the printer side
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Dk8D7AQr.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CHyRb--b.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DSFMlFH_.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue