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(
{