feat: dual external spool support, AMS slot model filtering & pre-population

Backend:
- Add dual external spool support for H2D (vt_tray as list: Ext-L/Ext-R)
- Add cloud filament ID map endpoint (/cloud/filament-id-map)
- Fix RFID spool data erased by periodic AMS updates (skip tag matcher
  for RFID-tagged trays)
- Fix AMS slot config overwrites RFID spool state
- Fix K-profile selection corrupts existing profiles on X1C/P1S
- Resolve K-profiles filament name via cloud filament ID map
- Update print scheduler and usage tracker for dual external spools

Frontend:
- Add printer model filtering to ConfigureAmsSlotModal (cloud/local/builtin
  presets filtered by @BBL model suffix and compatible_printers)
- Add pre-population for configured slots (preset, color, K-profile)
- Add K-Profiles view with accurate filament name resolution
- Internationalize all ConfigureAmsSlotModal strings (en/de/fr/it/ja — 21 keys)
- Add 5 new ConfigureAmsSlotModal tests (model filtering, pre-selection,
  color pre-population, i18n)
- Update PrintersPage for dual external spool rendering

Docs:
- Update CHANGELOG, README, website features, and wiki AMS docs
This commit is contained in:
maziggy
2026-02-15 08:04:01 +01:00
parent 12e92a301d
commit a37dfaf7fb
40 changed files with 10007 additions and 9175 deletions
+61
View File
@@ -889,6 +889,67 @@ async def get_builtin_filaments(
return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
# Cache for filament_id → name mapping (resolved from cloud preset details)
_filament_id_name_cache: dict[str, str] = {}
_filament_id_name_cache_time: float = 0
@router.get("/filament-id-map")
async def get_filament_id_map(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""
Get filament_id → name mapping for user cloud presets.
K-profiles store a filament_id (e.g., "P4d64437") which is different from
the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
fetches details for all custom presets and returns the mapping.
Cached for 5 minutes.
"""
import time
global _filament_id_name_cache, _filament_id_name_cache_time
if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
return _filament_id_name_cache
token, _ = await get_stored_token(db)
if not token:
return _filament_id_name_cache or {}
cloud = get_cloud_service()
cloud.set_token(token)
if not cloud.is_authenticated:
return _filament_id_name_cache or {}
try:
data = await cloud.get_slicer_settings()
custom_presets = data.get("filament", {}).get("private", [])
result: dict[str, str] = {}
for preset in custom_presets:
setting_id = preset.get("setting_id", "")
if not setting_id:
continue
try:
detail = await cloud.get_setting_detail(setting_id)
fid = detail.get("filament_id", "")
name = detail.get("name", "")
if fid and name:
# Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
clean_name = name.split(" @")[0].strip() if " @" in name else name
result[fid] = clean_name
except Exception:
pass
_filament_id_name_cache = result
_filament_id_name_cache_time = time.time()
return result
except Exception:
return _filament_id_name_cache or {}
@router.get("/fields/{preset_type}")
async def get_preset_fields(
preset_type: Literal["filament", "print", "process", "printer"],
+38 -14
View File
@@ -645,18 +645,32 @@ async def assign_spool(
fingerprint_type = None
state = printer_manager.get_status(data.printer_id)
if state and state.raw_data:
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
)
tray = _find_tray_in_ams_data(
ams_list,
data.ams_id,
data.tray_id,
)
if tray:
fingerprint_color = tray.get("tray_color", "")
fingerprint_type = tray.get("tray_type", "")
if data.ams_id == 255:
# External slot: look up tray from vt_tray by global ID
vt_tray = state.raw_data.get("vt_tray") or []
ext_id = data.tray_id + 254 # 0→254, 1→255
for vt in vt_tray:
if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
fingerprint_color = vt.get("tray_color", "")
fingerprint_type = vt.get("tray_type", "")
break
else:
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", [])
if isinstance(ams_data, dict)
else ams_data
if isinstance(ams_data, list)
else []
)
tray = _find_tray_in_ams_data(
ams_list,
data.ams_id,
data.tray_id,
)
if tray:
fingerprint_color = tray.get("tray_color", "")
fingerprint_type = tray.get("tray_type", "")
# 3. Upsert assignment (replace if same printer+ams+tray)
existing = await db.execute(
@@ -715,16 +729,27 @@ async def assign_spool(
setting_id=setting_id,
)
# b. Look up K-profile for this spool + printer + nozzle
# b. Look up K-profile for this spool + printer + nozzle + extruder
nozzle_diameter = "0.4"
if state and state.nozzles:
nd = state.nozzles[0].nozzle_diameter
if nd:
nozzle_diameter = nd
# Determine slot's extruder from ams_extruder_map
slot_extruder = None
if state and state.ams_extruder_map:
if data.ams_id == 255:
# External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
slot_extruder = 1 - data.tray_id # 0→1, 1→0
else:
slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
matching_kp = None
for kp in spool.k_profiles:
if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
continue
matching_kp = kp
break
@@ -735,7 +760,6 @@ async def assign_spool(
cali_idx=matching_kp.cali_idx,
filament_id=tray_info_idx,
nozzle_diameter=nozzle_diameter,
setting_id=matching_kp.setting_id,
)
configured = True
+3
View File
@@ -278,6 +278,9 @@ async def delete_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile delete command")
# Wait for printer to process the delete before frontend refetches
await asyncio.sleep(0.5)
return {"success": True, "message": "K-profile deleted successfully"}
+214 -54
View File
@@ -237,7 +237,7 @@ async def get_printer_status(
# Parse AMS data from raw_data
ams_units = []
vt_tray = None
vt_tray = []
ams_exists = False
raw_data = state.raw_data or {}
@@ -319,38 +319,41 @@ async def get_printer_status(
)
)
# Virtual tray (external spool holder) - comes from vt_tray in raw_data
# Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
if "vt_tray" in raw_data:
vt_data = raw_data["vt_tray"]
# Filter out empty/invalid tag values for vt_tray
vt_tag_uid = vt_data.get("tag_uid", "")
if vt_tag_uid in ("", "0000000000000000"):
vt_tag_uid = None
vt_tray_uuid = vt_data.get("tray_uuid", "")
if vt_tray_uuid in ("", "00000000000000000000000000000000"):
vt_tray_uuid = None
for vt_data in raw_data["vt_tray"]:
# Filter out empty/invalid tag values for vt_tray
vt_tag_uid = vt_data.get("tag_uid", "")
if vt_tag_uid in ("", "0000000000000000"):
vt_tag_uid = None
vt_tray_uuid = vt_data.get("tray_uuid", "")
if vt_tray_uuid in ("", "00000000000000000000000000000000"):
vt_tray_uuid = None
# Get K value: first try tray's k field, then lookup from K-profiles
vt_k_value = vt_data.get("k")
vt_cali_idx = vt_data.get("cali_idx")
if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
vt_k_value = kprofile_map[vt_cali_idx]
# Get K value: first try tray's k field, then lookup from K-profiles
vt_k_value = vt_data.get("k")
vt_cali_idx = vt_data.get("cali_idx")
if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
vt_k_value = kprofile_map[vt_cali_idx]
vt_tray = AMSTray(
id=254, # Virtual tray ID
tray_color=vt_data.get("tray_color"),
tray_type=vt_data.get("tray_type"),
tray_sub_brands=vt_data.get("tray_sub_brands"),
tray_id_name=vt_data.get("tray_id_name"),
tray_info_idx=vt_data.get("tray_info_idx"),
remain=vt_data.get("remain", 0),
k=vt_k_value,
cali_idx=vt_cali_idx,
tag_uid=vt_tag_uid,
tray_uuid=vt_tray_uuid,
nozzle_temp_min=vt_data.get("nozzle_temp_min"),
nozzle_temp_max=vt_data.get("nozzle_temp_max"),
)
tray_id = int(vt_data.get("id", 254))
vt_tray.append(
AMSTray(
id=tray_id,
tray_color=vt_data.get("tray_color"),
tray_type=vt_data.get("tray_type"),
tray_sub_brands=vt_data.get("tray_sub_brands"),
tray_id_name=vt_data.get("tray_id_name"),
tray_info_idx=vt_data.get("tray_info_idx"),
remain=vt_data.get("remain", 0),
k=vt_k_value,
cali_idx=vt_cali_idx,
tag_uid=vt_tag_uid,
tray_uuid=vt_tray_uuid,
nozzle_temp_min=vt_data.get("nozzle_temp_min"),
nozzle_temp_max=vt_data.get("nozzle_temp_max"),
)
)
# Convert nozzle info to response format
nozzles = [
@@ -1637,40 +1640,72 @@ async def configure_ams_slot(
if not client:
raise HTTPException(status_code=400, detail="Printer not connected")
# Send the filament setting command (type, color, temp)
success = client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=nozzle_temp_min,
nozzle_temp_max=nozzle_temp_max,
setting_id=setting_id,
)
# Detect RFID spool before sending commands
is_rfid_spool = False
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
from backend.app.api.routes.inventory import _find_tray_in_ams_data
from backend.app.services.spool_tag_matcher import is_valid_tag
if not success:
raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
)
current_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
if current_tray:
is_rfid_spool = is_valid_tag(
current_tray.get("tag_uid", ""),
current_tray.get("tray_uuid", ""),
)
# Send the calibration/K-profile commands
# Use the K profile's filament_id if provided, otherwise use tray_info_idx
# Send filament setting + K-profile commands
filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
if is_rfid_spool:
# RFID spool: skip ams_set_filament_setting to preserve RFID state (eye icon).
# The firmware already has filament config from the RFID tag.
logger.info("[configure_ams_slot] RFID spool detected — skipping ams_set_filament_setting")
else:
# Non-RFID spool: send filament setting (type, color, temp)
# When a K-profile is selected, use the K-profile's filament_id as
# tray_info_idx so BambuStudio queries the right PA history table.
# But always use the PRESET's setting_id (not the K-profile's) —
# BambuStudio uses setting_id to identify the filament preset and
# overriding it with the K-profile's setting_id confuses the slicer.
effective_tray_info_idx = filament_id_for_kprofile if cali_idx >= 0 else tray_info_idx
success = client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=effective_tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=nozzle_temp_min,
nozzle_temp_max=nozzle_temp_max,
setting_id=setting_id,
)
if not success:
raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
# Method 1: Select existing calibration profile by cali_idx
# IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
# Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
# Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
# and including it causes the firmware to mislink the profile on X1C/P1S.
client.extrusion_cali_sel(
ams_id=ams_id,
tray_id=tray_id,
cali_idx=cali_idx,
filament_id=filament_id_for_kprofile,
nozzle_diameter=nozzle_diameter,
setting_id=kprofile_setting_id if kprofile_setting_id else None,
)
# Method 2: Also directly set the K value if provided (for better compatibility)
if k_value > 0:
# Method 2: Only send extrusion_cali_set when NO existing profile was selected
# (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
# correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
# the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
# corrupting it — e.g., overwriting a High Flow extruder 1 profile with
# hardcoded extruder_id=0 and nozzle_id=HS00.
if k_value > 0 and cali_idx < 0:
# Calculate global tray ID for extrusion_cali_set
if ams_id <= 3:
global_tray_id = ams_id * 4 + tray_id
@@ -1682,11 +1717,12 @@ async def configure_ams_slot(
client.extrusion_cali_set(
tray_id=global_tray_id,
k_value=k_value,
n_coef=0.0,
nozzle_diameter=nozzle_diameter,
bed_temp=60,
nozzle_temp=nozzle_temp_max,
max_volumetric_speed=20.0,
filament_id=filament_id_for_kprofile,
setting_id=kprofile_setting_id or "",
name=tray_sub_brands or "",
cali_idx=cali_idx,
)
# Request fresh status push from printer so frontend gets updated data via WebSocket
@@ -2109,9 +2145,133 @@ async def refresh_ams_slot(
if not success:
raise HTTPException(400, message)
# Apply PA profile after delay (RFID re-read takes a few seconds)
asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))
return {"success": True, "message": message}
async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
"""Apply PA profile after RFID re-read completes.
Waits for the printer to finish processing the RFID data, then selects
the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting
because that would overwrite the RFID-provided filament data.
"""
await asyncio.sleep(5)
try:
from backend.app.api.routes.inventory import _find_tray_in_ams_data
from backend.app.core.database import async_session
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment as SA
from backend.app.services.spool_tag_matcher import is_bambu_tag
client = printer_manager.get_client(printer_id)
if not client:
return
state = printer_manager.get_status(printer_id)
if not state or not state.raw_data:
return
# Find current tray data (should have RFID data by now)
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
)
tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
if not tray or not tray.get("tray_type"):
logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
return
tag_uid = tray.get("tag_uid", "")
tray_uuid = tray.get("tray_uuid", "")
tray_info_idx = tray.get("tray_info_idx", "")
if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
return
async with async_session() as db:
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
result = await db.execute(
sa_select(SA)
.options(selectinload(SA.spool).selectinload(Spool.k_profiles))
.where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
)
assignment = result.scalar_one_or_none()
if not assignment or not assignment.spool or not assignment.spool.k_profiles:
return
spool = assignment.spool
nozzle_diameter = "0.4"
if state.nozzles:
nd = state.nozzles[0].nozzle_diameter
if nd:
nozzle_diameter = nd
# Determine slot's extruder from ams_extruder_map
slot_extruder = None
if state.ams_extruder_map:
if ams_id == 255:
# External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
slot_extruder = 1 - slot_id # 0→1, 1→0
else:
slot_extruder = state.ams_extruder_map.get(str(ams_id))
matching_kp = None
for kp in spool.k_profiles:
if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
continue
matching_kp = kp
break
if not matching_kp or matching_kp.cali_idx is None:
return
# The filament_id in extrusion_cali_sel must match the filament preset
# under which the K-profile was calibrated. Use spool.slicer_filament
# (the preset assigned in inventory), falling back to tray's RFID value.
kp_filament_id = spool.slicer_filament or tray_info_idx
logger.info(
"PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
ams_id,
slot_id,
matching_kp.cali_idx,
kp_filament_id,
)
# 1. Select K-profile
# NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
# "this is a manual config" which destroys the RFID-detected spool state
# (changes eye icon to pen icon in slicer).
client.extrusion_cali_sel(
ams_id=ams_id,
tray_id=slot_id,
cali_idx=matching_kp.cali_idx,
filament_id=kp_filament_id,
nozzle_diameter=nozzle_diameter,
)
# NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
# selected the correct profile by cali_idx. Sending extrusion_cali_set with
# the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
# nozzle_id, name), corrupting it.
logger.info(
"Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d",
matching_kp.cali_idx,
matching_kp.k_value or 0,
printer_id,
ams_id,
slot_id,
)
except Exception as e:
logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
@router.get("/{printer_id}/runtime-debug")
async def get_runtime_debug(
printer_id: int,
+1 -1
View File
@@ -473,7 +473,7 @@ async def _collect_support_info() -> dict:
for unit in ams_units:
trays = unit.get("tray", [])
ams_tray_count += len([t for t in trays if t.get("tray_type")])
has_vt_tray = state.raw_data.get("vt_tray") is not None
has_vt_tray = bool(state.raw_data.get("vt_tray"))
info["printers"].append(
{
+4 -1
View File
@@ -335,12 +335,14 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
bed_target = round(temps.get("bed_target", 0))
nozzle_target = round(temps.get("nozzle_target", 0))
# Include tray_now and vt_tray hash so external spool changes trigger broadcasts
vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
status_key = (
f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
f"{state.chamber_light}:{state.active_extruder}"
f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}"
)
# MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -707,6 +709,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
spool,
printer_manager,
db,
tray_info_idx=tray_info_idx,
)
await db.commit()
await ws_manager.broadcast(
+1 -1
View File
@@ -199,7 +199,7 @@ class PrinterStatus(BaseModel):
hms_errors: list[HMSErrorResponse] = []
ams: list[AMSUnit] = []
ams_exists: bool = False
vt_tray: AMSTray | None = None # Virtual tray / external spool
vt_tray: list[AMSTray] = [] # Virtual tray / external spool(s)
sdcard: bool = False # SD card inserted
store_to_sdcard: bool = False # Store sent files on SD card
timelapse: bool = False # Timelapse recording active
+151 -57
View File
@@ -128,7 +128,7 @@ class PrinterState:
chamber_light: bool = False
# Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow
active_extruder: int = 0
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
# Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
tray_now: int = 255
# Pending load target - used to track what tray we're loading for H2D disambiguation
pending_tray_target: int | None = None
@@ -451,14 +451,33 @@ class BambuMQTTClient:
except Exception as e:
logger.error("[%s] Error handling AMS data from print: %s", self.serial_number, e)
# Handle vir_slot (H2-series external spool data) — list of external trays
# Process vir_slot FIRST so it takes priority over vt_tray
if "vir_slot" in print_data:
vir_slot = print_data["vir_slot"]
if isinstance(vir_slot, list) and vir_slot:
# Fix: single-nozzle printers (X1C, P1S, A1) report their single
# external slot with id=255 in vir_slot, but tray_now=254 when active.
# Remap id=255→254 for single-slot printers so active detection works.
# Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).
if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255":
vir_slot[0]["id"] = "254"
self.state.raw_data["vt_tray"] = vir_slot
# Handle vt_tray (virtual tray / external spool) data
if "vt_tray" in print_data:
# Only use vt_tray if vir_slot is NOT in this message AND we don't already
# have vir_slot data (H2-series sends vt_tray as a single active spool dict
# which would overwrite the correct multi-slot vir_slot data)
if "vt_tray" in print_data and "vir_slot" not in print_data:
vt_tray = print_data["vt_tray"]
self.state.raw_data["vt_tray"] = vt_tray
# Log vt_tray to investigate per-extruder data for H2D
if not hasattr(self, "_vt_tray_logged") or not self._vt_tray_logged:
logger.info("[%s] vt_tray data: %s", self.serial_number, vt_tray)
self._vt_tray_logged = True
existing = self.state.raw_data.get("vt_tray")
# Don't let a single-spool vt_tray dict overwrite multi-slot vir_slot data
if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1:
pass # Keep the vir_slot data
else:
if isinstance(vt_tray, dict):
vt_tray = [vt_tray]
self.state.raw_data["vt_tray"] = vt_tray
# Parse ams_status directly from print data (NOT from print.ams)
# ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
@@ -486,7 +505,10 @@ class BambuMQTTClient:
# Check for K-profile response (extrusion_cali)
if "command" in print_data:
logger.debug("[%s] Received command response: %s", self.serial_number, print_data.get("command"))
cmd = print_data.get("command")
logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
logger.info("[%s] %s response: %s", self.serial_number, cmd, print_data)
if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
self._handle_kprofile_response(print_data)
@@ -941,13 +963,20 @@ class BambuMQTTClient:
if tray_id is not None and tray_id in existing_trays:
# Merge: start with existing, update with new non-empty values
merged_tray = existing_trays[tray_id].copy()
# Detect slot-clearing updates (spool removal):
# When tray_type is explicitly empty, clear everything
# including RFID data (tag_uid/tray_uuid).
slot_clearing = new_tray.get("tray_type") == ""
for key, value in new_tray.items():
# Fields that should always be updated (even with empty/zero values):
# - remain, k, id, cali_idx: status indicators where 0 is valid
# - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
# tray_color, tray_id_name: slot content indicators that must
# be cleared when a spool is removed (fixes #147 - old AMS
# empty slot)
# - tray_type, tray_sub_brands, tray_info_idx, tray_color,
# tray_id_name: slot content indicators that must be cleared
# when a spool is removed (fixes #147 - old AMS empty slot)
# NOTE: tag_uid and tray_uuid are NOT in always_update_fields.
# They are only cleared during spool removal (slot_clearing=True).
# Periodic AMS updates often include empty RFID fields which
# would overwrite valid data from the initial pushall.
always_update_fields = (
"remain",
"k",
@@ -955,17 +984,20 @@ class BambuMQTTClient:
"cali_idx",
"tray_type",
"tray_sub_brands",
"tag_uid",
"tray_uuid",
"tray_info_idx",
"tray_color",
"tray_id_name",
)
if key in always_update_fields or value not in (
None,
"",
"0000000000000000",
"00000000000000000000000000000000",
if (
key in always_update_fields
or slot_clearing
or value
not in (
None,
"",
"0000000000000000",
"00000000000000000000000000000000",
)
):
merged_tray[key] = value
merged_trays.append(merged_tray)
@@ -2424,7 +2456,7 @@ class BambuMQTTClient:
def _handle_kprofile_response(self, data: dict):
"""Handle K-profile response from printer."""
response_nozzle = data.get("nozzle_diameter")
_response_seq_id = data.get("sequence_id", "?")
response_seq_id = data.get("sequence_id", "?")
filaments = data.get("filaments", [])
expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
has_pending_request = self._pending_kprofile_response is not None
@@ -2432,7 +2464,8 @@ class BambuMQTTClient:
# Log all incoming responses when we have a pending request (for debugging)
if has_pending_request:
logger.info(
f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}"
f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
)
# If we have a pending request, only accept responses with matching nozzle_diameter
@@ -3305,6 +3338,7 @@ class BambuMQTTClient:
command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}}
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id)
return True, f"Refreshing AMS {ams_id} tray {tray_id}"
def ams_set_filament_setting(
@@ -3341,18 +3375,33 @@ class BambuMQTTClient:
logger.warning("[%s] Cannot set AMS filament setting: not connected", self.serial_number)
return False
# Calculate slot_id based on AMS type
if ams_id <= 3:
# Calculate mqtt IDs based on AMS type
if ams_id == 255:
vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
if len(vt_tray) > 1:
# Dual external slots (H2D): each ext slot is its own virtual AMS unit
# (254=ext-L / slot 0, 255=ext-R / slot 1)
mqtt_ams_id = 254 + tray_id
else:
# Single external slot (X1C, P1S, A1): always ams_id=255
mqtt_ams_id = 255
mqtt_tray_id = 0
slot_id = 0
elif ams_id <= 3:
mqtt_ams_id = ams_id
mqtt_tray_id = tray_id
slot_id = tray_id
else:
# AMS-HT or external: slot_id = 0
# AMS-HT: single tray per unit
mqtt_ams_id = ams_id
mqtt_tray_id = tray_id
slot_id = 0
command = {
"print": {
"command": "ams_filament_setting",
"ams_id": ams_id,
"tray_id": tray_id,
"ams_id": mqtt_ams_id,
"tray_id": mqtt_tray_id,
"slot_id": slot_id,
"tray_info_idx": tray_info_idx,
"tray_type": tray_type,
@@ -3390,17 +3439,32 @@ class BambuMQTTClient:
logger.warning("[%s] Cannot reset AMS slot: not connected", self.serial_number)
return False
# Calculate slot_id based on AMS type
if ams_id <= 3:
# Calculate mqtt IDs based on AMS type
if ams_id == 255:
vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
if len(vt_tray) > 1:
# Dual external slots (H2D): each ext slot is its own virtual AMS unit
mqtt_ams_id = 254 + tray_id
else:
# Single external slot (X1C, P1S, A1): always ams_id=255
mqtt_ams_id = 255
mqtt_tray_id = 0
slot_id = 0
elif ams_id <= 3:
mqtt_ams_id = ams_id
mqtt_tray_id = tray_id
slot_id = tray_id
else:
# AMS-HT: single tray per unit
mqtt_ams_id = ams_id
mqtt_tray_id = tray_id
slot_id = 0
command = {
"print": {
"command": "ams_filament_setting",
"ams_id": ams_id,
"tray_id": tray_id,
"ams_id": mqtt_ams_id,
"tray_id": mqtt_tray_id,
"slot_id": slot_id,
"tray_info_idx": "",
"tray_type": "",
@@ -3425,20 +3489,21 @@ class BambuMQTTClient:
cali_idx: int,
filament_id: str,
nozzle_diameter: str = "0.4",
setting_id: str | None = None,
) -> bool:
"""Set calibration profile (K value) for an AMS slot.
This command selects a K profile from the printer's calibration list.
Use cali_idx=-1 to use the default K value (0.020).
Note: Do NOT send setting_id in this command — BambuStudio never includes
it, and adding it causes the firmware to mislink the profile on X1C/P1S.
Args:
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
tray_id: Tray ID within the AMS (0-3)
cali_idx: Calibration profile index (-1 for default)
filament_id: Filament preset ID (same as tray_info_idx)
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
Returns:
True if command was sent, False otherwise
@@ -3447,13 +3512,34 @@ class BambuMQTTClient:
logger.warning("[%s] Cannot set calibration: not connected", self.serial_number)
return False
# Calculate slot_id based on AMS type
# tray_id in the command should be the local tray index (0-3)
if ams_id <= 3:
# Calculate mqtt IDs based on AMS type.
# IMPORTANT: extrusion_cali_sel uses GLOBAL tray_id (unlike ams_filament_setting
# which uses LOCAL). BambuStudio confirms: tray_id = ams_id * 4 + slot.
if ams_id == 255:
# External spool: extrusion_cali_sel uses GLOBAL tray_id (unlike
# ams_filament_setting which uses LOCAL tray_id=0).
vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
if len(vt_tray) > 1:
# Dual external slots (H2D): each ext slot is its own virtual AMS unit
# Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255
mqtt_ams_id = 254 + tray_id
mqtt_tray_id = 254 + tray_id
else:
# Single external slot (X1C, P1S, A1): global tray_id=254
mqtt_ams_id = 254
mqtt_tray_id = 254
slot_id = 0
elif ams_id <= 3:
mqtt_ams_id = ams_id
mqtt_tray_id = ams_id * 4 + tray_id
slot_id = tray_id
elif ams_id >= 128 and ams_id <= 135:
mqtt_ams_id = ams_id
mqtt_tray_id = tray_id
slot_id = 0
else:
mqtt_ams_id = ams_id
mqtt_tray_id = tray_id
slot_id = 0
command = {
@@ -3462,20 +3548,16 @@ class BambuMQTTClient:
"cali_idx": cali_idx,
"filament_id": filament_id,
"nozzle_diameter": nozzle_diameter,
"ams_id": ams_id,
"tray_id": tray_id, # Local tray index (0-3), not global
"ams_id": mqtt_ams_id,
"tray_id": mqtt_tray_id,
"slot_id": slot_id,
"sequence_id": "0",
}
}
# Include setting_id if provided (helps slicer show correct K profile)
if setting_id:
command["print"]["setting_id"] = setting_id
command_json = json.dumps(command)
logger.info(
f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}"
f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}"
)
logger.debug("[%s] extrusion_cali_sel command: %s", self.serial_number, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
@@ -3485,25 +3567,26 @@ class BambuMQTTClient:
self,
tray_id: int,
k_value: float,
n_coef: float = 0.0,
nozzle_diameter: str = "0.4",
bed_temp: int = 60,
nozzle_temp: int = 220,
max_volumetric_speed: float = 20.0,
filament_id: str = "",
setting_id: str = "",
name: str = "",
cali_idx: int = -1,
) -> bool:
"""Directly set K value (pressure advance) for a tray.
This command sets the K value directly without selecting from stored profiles.
Use this when you want to apply a specific K value to a tray.
Uses the filaments array format required by current firmware.
Args:
tray_id: Global tray ID (ams_id * 4 + slot)
k_value: Pressure advance K value (e.g., 0.020)
n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration)
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
bed_temp: Bed temperature for calibration reference
nozzle_temp: Nozzle temperature for calibration reference
max_volumetric_speed: Max volumetric speed for calibration reference
filament_id: Filament preset ID (e.g., "GFA02")
setting_id: Setting ID (e.g., "GFSA02_07")
name: Profile display name
cali_idx: Calibration index (-1 for new)
Returns:
True if command was sent, False otherwise
@@ -3512,17 +3595,28 @@ class BambuMQTTClient:
logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
return False
nozzle_id = f"HS00-{nozzle_diameter}"
filament_entry = {
"ams_id": 0,
"cali_idx": cali_idx,
"extruder_id": 0,
"filament_id": filament_id,
"k_value": f"{k_value:.6f}",
"n_coef": "1.400000",
"name": name,
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id,
"tray_id": tray_id,
}
command = {
"print": {
"command": "extrusion_cali_set",
"tray_id": tray_id,
"k_value": k_value,
"n_coef": n_coef,
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"bed_temp": bed_temp,
"nozzle_temp": nozzle_temp,
"max_volumetric_speed": max_volumetric_speed,
"sequence_id": "0",
"sequence_id": str(self._sequence_id),
}
}
+21 -21
View File
@@ -333,10 +333,9 @@ class PrintScheduler:
if tray_type:
loaded_types.add(tray_type.upper())
# Check external spool (virtual tray, stored in raw_data["vt_tray"])
vt_tray = status.raw_data.get("vt_tray")
if vt_tray:
vt_type = vt_tray.get("tray_type")
# Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
for vt in status.raw_data.get("vt_tray") or []:
vt_type = vt.get("tray_type")
if vt_type:
loaded_types.add(vt_type.upper())
@@ -538,23 +537,24 @@ class PrintScheduler:
}
)
# Check external spool (vt_tray)
vt_tray = status.raw_data.get("vt_tray")
if vt_tray and vt_tray.get("tray_type"):
color = self._normalize_color(vt_tray.get("tray_color", ""))
filaments.append(
{
"type": vt_tray["tray_type"],
"color": color,
"tray_info_idx": vt_tray.get("tray_info_idx", ""),
"ams_id": -1,
"tray_id": 0,
"is_ht": False,
"is_external": True,
"global_tray_id": 254,
"extruder_id": 0 if ams_extruder_map else None,
}
)
# Check external spool(s) (vt_tray is a list)
for idx, vt in enumerate(status.raw_data.get("vt_tray") or []):
if vt.get("tray_type"):
color = self._normalize_color(vt.get("tray_color", ""))
tray_id = int(vt.get("id", 254))
filaments.append(
{
"type": vt["tray_type"],
"color": color,
"tray_info_idx": vt.get("tray_info_idx", ""),
"ams_id": -1,
"tray_id": idx,
"is_ht": False,
"is_external": True,
"global_tray_id": tray_id,
"extruder_id": (tray_id - 254) if ams_extruder_map else None,
}
)
return filaments
+32 -29
View File
@@ -502,7 +502,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
"""
# Parse AMS data from raw_data
ams_units = []
vt_tray = None
vt_tray = []
raw_data = state.raw_data or {}
# Build K-profile lookup map: cali_idx -> k_value
@@ -578,37 +578,40 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
}
)
# Parse virtual tray (external spool)
# Parse virtual tray (external spool) — now a list
if "vt_tray" in raw_data:
vt_data = raw_data["vt_tray"]
vt_tag_uid = vt_data.get("tag_uid")
if vt_tag_uid in ("", "0000000000000000"):
vt_tag_uid = None
vt_tray_uuid = vt_data.get("tray_uuid")
if vt_tray_uuid in ("", "00000000000000000000000000000000"):
vt_tray_uuid = None
for vt_data in raw_data["vt_tray"]:
vt_tag_uid = vt_data.get("tag_uid")
if vt_tag_uid in ("", "0000000000000000"):
vt_tag_uid = None
vt_tray_uuid = vt_data.get("tray_uuid")
if vt_tray_uuid in ("", "00000000000000000000000000000000"):
vt_tray_uuid = None
# Get K value for vt_tray
vt_k_value = vt_data.get("k")
vt_cali_idx = vt_data.get("cali_idx")
if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
vt_k_value = kprofile_map[vt_cali_idx]
# Get K value for vt_tray
vt_k_value = vt_data.get("k")
vt_cali_idx = vt_data.get("cali_idx")
if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
vt_k_value = kprofile_map[vt_cali_idx]
vt_tray = {
"id": 254,
"tray_color": vt_data.get("tray_color"),
"tray_type": vt_data.get("tray_type"),
"tray_sub_brands": vt_data.get("tray_sub_brands"),
"tray_id_name": vt_data.get("tray_id_name"),
"tray_info_idx": vt_data.get("tray_info_idx"),
"remain": vt_data.get("remain", 0),
"k": vt_k_value,
"cali_idx": vt_cali_idx,
"tag_uid": vt_tag_uid,
"tray_uuid": vt_tray_uuid,
"nozzle_temp_min": vt_data.get("nozzle_temp_min"),
"nozzle_temp_max": vt_data.get("nozzle_temp_max"),
}
tray_id = int(vt_data.get("id", 254))
vt_tray.append(
{
"id": tray_id,
"tray_color": vt_data.get("tray_color"),
"tray_type": vt_data.get("tray_type"),
"tray_sub_brands": vt_data.get("tray_sub_brands"),
"tray_id_name": vt_data.get("tray_id_name"),
"tray_info_idx": vt_data.get("tray_info_idx"),
"remain": vt_data.get("remain", 0),
"k": vt_k_value,
"cali_idx": vt_cali_idx,
"tag_uid": vt_tag_uid,
"tray_uuid": vt_tray_uuid,
"nozzle_temp_min": vt_data.get("nozzle_temp_min"),
"nozzle_temp_max": vt_data.get("nozzle_temp_max"),
}
)
# Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
ams_extruder_map = raw_data.get("ams_extruder_map", {})
+34 -34
View File
@@ -196,16 +196,19 @@ async def auto_assign_spool(
spool: Spool,
printer_manager,
db: AsyncSession,
tray_info_idx: str = "",
) -> SpoolAssignment:
"""Create a SpoolAssignment and auto-configure the AMS slot via MQTT.
Reuses the same MQTT configuration logic as the manual assign endpoint.
For BL spools (RFID-detected), only K-profile commands are sent.
ams_set_filament_setting is NOT sent because the firmware already has
filament configuration from the RFID tag, and sending it would destroy
the RFID-detected state (eye → pen icon in BambuStudio).
"""
from backend.app.api.routes.inventory import MATERIAL_TEMPS
# Get current tray state for fingerprint
fingerprint_color = None
fingerprint_type = None
tray = None
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
from backend.app.api.routes.inventory import _find_tray_in_ams_data
@@ -246,34 +249,14 @@ async def auto_assign_spool(
db.add(assignment)
await db.flush()
# Auto-configure AMS slot via MQTT
# Apply K-profile via MQTT (if available)
# NOTE: Do NOT send ams_set_filament_setting here. This function is only
# called for BL spools (RFID-detected). The firmware already has the filament
# configuration from the RFID tag. Sending ams_set_filament_setting would
# destroy the RFID-detected state (eye → pen icon in BambuStudio/OrcaSlicer).
try:
client = printer_manager.get_client(printer_id)
if client:
tray_type = spool.material
tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
tray_color = spool.rgba or "FFFFFFFF"
tray_info_idx = spool.slicer_filament or ""
setting_id = ""
temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
if spool.nozzle_temp_min is not None:
temp_min = spool.nozzle_temp_min
if spool.nozzle_temp_max is not None:
temp_max = spool.nozzle_temp_max
client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=temp_min,
nozzle_temp_max=temp_max,
setting_id=setting_id,
)
# Apply K-profile if available
nozzle_diameter = "0.4"
if state and state.nozzles:
@@ -288,23 +271,40 @@ async def auto_assign_spool(
break
if matching_kp and matching_kp.cali_idx is not None:
# The filament_id in extrusion_cali_sel must match the filament preset
# under which the K-profile was calibrated. Use spool.slicer_filament
# (the preset assigned in inventory), falling back to tray's RFID value.
cali_filament_id = spool.slicer_filament or tray_info_idx or ""
client.extrusion_cali_sel(
ams_id=ams_id,
tray_id=tray_id,
cali_idx=matching_kp.cali_idx,
filament_id=tray_info_idx,
filament_id=cali_filament_id,
nozzle_diameter=nozzle_diameter,
setting_id=matching_kp.setting_id,
)
# NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
# selected the correct profile by cali_idx. Sending extrusion_cali_set
# with the same cali_idx would MODIFY the existing profile's metadata
# (extruder_id, nozzle_id, name), corrupting it.
logger.info(
"Applied K-profile cali_idx=%d for spool %d on printer %d AMS%d-T%d",
matching_kp.cali_idx,
spool.id,
printer_id,
ams_id,
tray_id,
)
logger.info(
"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d (RFID match)",
ams_id,
tray_id,
"Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)",
spool.id,
printer_id,
ams_id,
tray_id,
)
except Exception as e:
logger.warning("MQTT auto-configure failed for spool %d (RFID match): %s", spool.id, e)
logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
return assignment
+9 -8
View File
@@ -66,14 +66,15 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
"tray_type": tray.get("tray_type", ""),
}
# External spool (global_tray_id = 254)
vt_tray = raw_data.get("vt_tray")
if vt_tray and vt_tray.get("tray_type"):
lookup[254] = {
"tray_uuid": vt_tray.get("tray_uuid", ""),
"tag_uid": vt_tray.get("tag_uid", ""),
"tray_type": vt_tray.get("tray_type", ""),
}
# External spool(s) (vt_tray is a list, global_tray_id from each entry's "id")
for vt in raw_data.get("vt_tray") or []:
if vt.get("tray_type"):
tray_id = int(vt.get("id", 254))
lookup[tray_id] = {
"tray_uuid": vt.get("tray_uuid", ""),
"tag_uid": vt.get("tag_uid", ""),
"tray_type": vt.get("tray_type", ""),
}
return lookup
+11 -2
View File
@@ -263,8 +263,13 @@ async def _track_from_3mf(
tray_now_override: int | None = None
if not slot_to_tray and len(nonzero_slots) == 1:
state = printer_manager.get_status(printer_id)
if state and state.tray_now < 255:
if state and 0 <= state.tray_now <= 254:
tray_now_override = state.tray_now
elif state and state.tray_now == 255:
# 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
vt_tray = state.raw_data.get("vt_tray") or []
if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
tray_now_override = state.tray_now
# Scale factor for partial prints (failed/aborted)
if status == "completed":
@@ -322,7 +327,11 @@ async def _track_from_3mf(
if isinstance(mapped, int) and mapped >= 0:
global_tray_id = mapped
if global_tray_id >= 128:
if global_tray_id >= 254:
# External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
ams_id = 255
tray_id = global_tray_id - 254
elif global_tray_id >= 128:
ams_id = global_tray_id
tray_id = 0
else:
@@ -734,23 +734,26 @@ class TestPrinterStateToDict:
assert result["ams"][0]["tray"][0]["tag_uid"] is None
def test_vt_tray_parsing(self, mock_state):
"""Verify virtual tray is parsed correctly."""
"""Verify virtual tray is parsed correctly as a list."""
mock_state.raw_data = {
"vt_tray": {
"tray_color": "00FF00",
"tray_type": "PETG",
"tray_sub_brands": "Generic",
"remain": 60,
"tag_uid": "VT123",
}
"vt_tray": [
{
"tray_color": "00FF00",
"tray_type": "PETG",
"tray_sub_brands": "Generic",
"remain": 60,
"tag_uid": "VT123",
}
]
}
result = printer_state_to_dict(mock_state)
assert result["vt_tray"] is not None
assert result["vt_tray"]["id"] == 254
assert result["vt_tray"]["tray_color"] == "00FF00"
assert result["vt_tray"]["tray_type"] == "PETG"
assert isinstance(result["vt_tray"], list)
assert len(result["vt_tray"]) == 1
assert result["vt_tray"][0]["id"] == 254
assert result["vt_tray"][0]["tray_color"] == "00FF00"
assert result["vt_tray"][0]["tray_type"] == "PETG"
def test_hms_errors_conversion(self, mock_state):
"""Verify HMS errors are converted correctly."""
@@ -99,14 +99,14 @@ class TestBuildAmsTrayLookup:
def test_external_spool(self):
raw = {
"ams": [],
"vt_tray": {"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"},
"vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}],
}
lookup = build_ams_tray_lookup(raw)
assert 254 in lookup
assert lookup[254]["tray_type"] == "TPU"
def test_empty_external_spool_skipped(self):
raw = {"ams": [], "vt_tray": {"tray_type": ""}}
raw = {"ams": [], "vt_tray": [{"tray_type": ""}]}
lookup = build_ams_tray_lookup(raw)
assert 254 not in lookup
@@ -140,7 +140,7 @@ class TestBuildLoadedFilaments:
"""Should include external spool."""
class MockStatus:
raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1
@@ -466,7 +466,7 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
"""Should extract tray_info_idx from external spool."""
class MockStatus:
raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}]}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1
@@ -624,7 +624,7 @@ class TestNozzleAwareMapping:
class MockStatus:
raw_data = {
"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"},
"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}],
"ams_extruder_map": {"0": 0},
}
@@ -637,7 +637,7 @@ class TestNozzleAwareMapping:
"""External spool extruder_id should be None without ams_extruder_map."""
class MockStatus:
raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1