mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user