- Refactored printer card's AMS section for better experience

This commit is contained in:
maziggy
2026-01-01 12:08:55 +01:00
parent 569dbc3d21
commit ccdb084f23
13 changed files with 2737 additions and 678 deletions
+104 -29
View File
@@ -5,33 +5,36 @@ Handles authentication and profile management with Bambu Cloud.
"""
import json
import logging
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import get_db
from backend.app.models.settings import Settings
from backend.app.services.bambu_cloud import (
get_cloud_service,
BambuCloudError,
BambuCloudAuthError,
)
from backend.app.schemas.cloud import (
CloudLoginRequest,
CloudVerifyRequest,
CloudLoginResponse,
CloudAuthStatus,
CloudTokenRequest,
SlicerSettingsResponse,
SlicerSetting,
CloudDevice,
CloudLoginRequest,
CloudLoginResponse,
CloudTokenRequest,
CloudVerifyRequest,
SlicerSetting,
SlicerSettingCreate,
SlicerSettingUpdate,
SlicerSettingDeleteResponse,
SlicerSettingsResponse,
SlicerSettingUpdate,
)
from backend.app.services.bambu_cloud import (
BambuCloudAuthError,
BambuCloudError,
get_cloud_service,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/cloud", tags=["cloud"])
@@ -43,9 +46,7 @@ CLOUD_EMAIL_KEY = "bambu_cloud_email"
async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
"""Get stored cloud token and email from database."""
result = await db.execute(
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
)
result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
settings = {s.key: s.value for s in result.scalars().all()}
return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
@@ -64,9 +65,7 @@ async def store_token(db: AsyncSession, token: str, email: str) -> None:
async def clear_token(db: AsyncSession) -> None:
"""Clear stored cloud token and email."""
result = await db.execute(
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
)
result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
for setting in result.scalars().all():
await db.delete(setting)
await db.commit()
@@ -213,14 +212,16 @@ async def get_slicer_settings(
parsed = []
for s in all_settings:
parsed.append(SlicerSetting(
setting_id=s.get("setting_id", s.get("id", "")),
name=s.get("name", "Unknown"),
type=our_type,
version=s.get("version"),
user_id=s.get("user_id"),
updated_time=s.get("updated_time"),
))
parsed.append(
SlicerSetting(
setting_id=s.get("setting_id", s.get("id", "")),
name=s.get("name", "Unknown"),
type=our_type,
version=s.get("version"),
user_id=s.get("user_id"),
updated_time=s.get("updated_time"),
)
)
setattr(result, our_type, parsed)
return result
@@ -258,6 +259,80 @@ async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)
raise HTTPException(status_code=500, detail=str(e))
# Cache for filament preset info (setting_id -> {name, k})
_filament_cache: dict[str, dict] = {}
_filament_cache_time: float = 0
FILAMENT_CACHE_TTL = 300 # 5 minutes
@router.post("/filament-info")
async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
"""
Get filament preset info (name and K value) for multiple setting IDs.
Used to enrich AMS tray tooltips with cloud preset data.
"""
import time
logger.info(f"get_filament_info called with {len(setting_ids)} IDs: {setting_ids}")
global _filament_cache, _filament_cache_time
# Clear stale cache
if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
_filament_cache = {}
_filament_cache_time = time.time()
token, _ = await get_stored_token(db)
if not token:
logger.info("get_filament_info: Not authenticated, returning empty")
# Return empty results if not authenticated (graceful degradation)
return {}
cloud = get_cloud_service()
cloud.set_token(token)
if not cloud.is_authenticated:
return {}
result = {}
for setting_id in setting_ids:
if not setting_id:
continue
# Check cache first
if setting_id in _filament_cache:
result[setting_id] = _filament_cache[setting_id]
continue
try:
data = await cloud.get_setting_detail(setting_id)
setting = data.get("setting", {})
# Extract name (e.g., "Bambu PLA Basic Jade White")
name = data.get("name", "")
# Extract K value (pressure_advance)
k_value = setting.get("pressure_advance")
if k_value is not None:
try:
k_value = float(k_value)
except (ValueError, TypeError):
k_value = None
info = {"name": name, "k": k_value}
_filament_cache[setting_id] = info
result[setting_id] = info
except Exception as e:
logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
# Cache the failure to avoid repeated requests
_filament_cache[setting_id] = {"name": "", "k": None}
result[setting_id] = {"name": "", "k": None}
return result
@router.get("/devices", response_model=list[CloudDevice])
async def get_devices(db: AsyncSession = Depends(get_db)):
"""
@@ -425,7 +500,7 @@ def _load_fields(preset_type: str) -> dict:
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
with open(file_path, "r") as f:
with open(file_path) as f:
data = json.load(f)
_fields_cache[preset_type] = data
+31 -3
View File
@@ -169,6 +169,16 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
ams_exists = False
raw_data = state.raw_data or {}
# Build K-profile lookup map: cali_idx -> k_value
# This allows looking up the calibrated K value for each AMS slot
kprofile_map: dict[int, float] = {}
for kp in state.kprofiles or []:
if kp.slot_id is not None and kp.k_value:
try:
kprofile_map[kp.slot_id] = float(kp.k_value)
except (ValueError, TypeError):
pass
if "ams" in raw_data and isinstance(raw_data["ams"], list):
ams_exists = True
for ams_data in raw_data["ams"]:
@@ -184,6 +194,13 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
tray_uuid = tray_data.get("tray_uuid", "")
if tray_uuid in ("", "00000000000000000000000000000000"):
tray_uuid = None
# Get K value: first try tray's k field, then lookup from K-profiles
k_value = tray_data.get("k")
cali_idx = tray_data.get("cali_idx")
if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
k_value = kprofile_map[cali_idx]
trays.append(
AMSTray(
id=tray_data.get("id", 0),
@@ -193,7 +210,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
tray_id_name=tray_data.get("tray_id_name"),
tray_info_idx=tray_data.get("tray_info_idx"),
remain=tray_data.get("remain", 0),
k=tray_data.get("k"),
k=k_value,
cali_idx=cali_idx,
tag_uid=tag_uid,
tray_uuid=tray_uuid,
nozzle_temp_min=tray_data.get("nozzle_temp_min"),
@@ -239,13 +257,23 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
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]
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_data.get("k"),
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"),
@@ -977,7 +1005,7 @@ async def debug_simulate_print_complete(
"timelapse_was_active": False,
}
logger.info(f"[DEBUG] Simulating print complete for printer {printer_id}, archive {archive.id}")
logger.info(f"Simulating print complete for printer {printer_id}, archive {archive.id}")
# Call the actual on_print_complete handler
await on_print_complete(printer_id, data)
+4 -1
View File
@@ -1,4 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, Field
@@ -54,7 +55,8 @@ class AMSTray(BaseModel):
tray_id_name: str | None = None # Bambu filament ID like "A00-Y2" (can decode to color)
tray_info_idx: str | None = None # Filament preset ID like "GFA00"
remain: int = 0
k: float | None = None # Pressure advance value
k: float | None = None # Pressure advance value (from tray or K-profile lookup)
cali_idx: int | None = None # Calibration index for K-profile lookup
tag_uid: str | None = None # RFID tag UID (any tag)
tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex)
nozzle_temp_min: int | None = None # Min nozzle temperature
@@ -76,6 +78,7 @@ class NozzleInfoResponse(BaseModel):
class PrintOptionsResponse(BaseModel):
"""AI detection and print options from xcam data."""
# Core AI detectors
spaghetti_detector: bool = False
print_halt: bool = False
+39 -31
View File
@@ -733,9 +733,9 @@ class BambuMQTTClient:
parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255
# H2D dual-nozzle printers report only slot number (0-3), not global tray ID
# Use pending_tray_target from our load command tracking for disambiguation
# Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
if parsed_tray_now >= 0 and parsed_tray_now <= 3:
# Check if we have a pending target that matches this slot
# First, check if we have a pending target that matches this slot
pending_target = self.state.pending_tray_target
if pending_target is not None:
pending_slot = pending_target % 4
@@ -758,28 +758,45 @@ class BambuMQTTClient:
# Clear pending target since it's stale
self.state.pending_tray_target = None
else:
# No pending target - check if we already have a resolved global ID
# that matches this slot (from a previous successful disambiguation)
current_tray = self.state.tray_now
if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
# Current tray_now is already a valid global ID that matches this slot
# Keep it (don't overwrite with raw slot number)
logger.debug(
f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
f"(matches incoming slot {parsed_tray_now})"
# No pending target - use active_extruder + ams_extruder_map to disambiguate
# Find which AMS is connected to the active extruder
active_ext = self.state.active_extruder # 0=right, 1=left
ams_map = self.state.ams_extruder_map # {ams_id: extruder_id}
# Find the AMS connected to the active extruder
active_ams_id = None
for ams_id_str, ext_id in ams_map.items():
if ext_id == active_ext:
try:
active_ams_id = int(ams_id_str)
except ValueError:
pass
break
if active_ams_id is not None:
# Calculate global tray ID using the active AMS
global_tray_id = active_ams_id * 4 + parsed_tray_now
logger.info(
f"[{self.serial_number}] H2D tray_now disambiguation: "
f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}"
)
self.state.tray_now = global_tray_id
else:
# No pending target and no valid existing global ID
# For H2D with multiple AMS units, we can't reliably determine which AMS
# the slot belongs to without a pending_tray_target from our load command.
# Use slot number as-is - this may be incorrect for multi-AMS setups,
# but it's better than guessing wrong based on unreliable heuristics.
# The user can load filament via our API to get correct tracking.
logger.warning(
f"[{self.serial_number}] H2D tray_now: no pending target, "
f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)"
)
self.state.tray_now = parsed_tray_now
# No AMS found for active extruder - check if we already have a resolved global ID
current_tray = self.state.tray_now
if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
# Current tray_now is already a valid global ID that matches this slot
logger.debug(
f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
f"(matches incoming slot {parsed_tray_now})"
)
else:
# Fallback: use slot as-is
logger.warning(
f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}, "
f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)"
)
self.state.tray_now = parsed_tray_now
else:
# tray_now > 3 means it's already a global ID, or 255 means unloaded
# Note: Do NOT clear pending_tray_target on tray_now=255 here.
@@ -806,15 +823,6 @@ class BambuMQTTClient:
# Extract ams_extruder_map from each AMS unit's info field
# According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
# Log AMS unit fields once to discover available fields
if not hasattr(self, "_ams_fields_logged") and ams_list:
first_unit = ams_list[0]
logger.info(f"[{self.serial_number}] AMS unit fields: {sorted(first_unit.keys())}")
for ams_unit in ams_list:
ams_id = ams_unit.get("id")
unit_info = {k: v for k, v in ams_unit.items() if k != "tray"}
logger.info(f"[{self.serial_number}] AMS {ams_id} data: {unit_info}")
self._ams_fields_logged = True
ams_extruder_map = {}
for ams_unit in ams_list:
+39 -1
View File
@@ -338,6 +338,15 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
vt_tray = None
raw_data = state.raw_data or {}
# Build K-profile lookup map: cali_idx -> k_value
kprofile_map: dict[int, float] = {}
for kp in state.kprofiles or []:
if kp.slot_id is not None and kp.k_value:
try:
kprofile_map[kp.slot_id] = float(kp.k_value)
except (ValueError, TypeError):
pass
if "ams" in raw_data and isinstance(raw_data["ams"], list):
for ams_data in raw_data["ams"]:
trays = []
@@ -348,16 +357,28 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
tray_uuid = tray.get("tray_uuid")
if tray_uuid in ("", "00000000000000000000000000000000"):
tray_uuid = None
# Get K value: first try tray's k field, then lookup from K-profiles
k_value = tray.get("k")
cali_idx = tray.get("cali_idx")
if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
k_value = kprofile_map[cali_idx]
trays.append(
{
"id": tray.get("id", 0),
"tray_color": tray.get("tray_color"),
"tray_type": tray.get("tray_type"),
"tray_sub_brands": tray.get("tray_sub_brands"),
"tray_id_name": tray.get("tray_id_name"),
"tray_info_idx": tray.get("tray_info_idx"),
"remain": tray.get("remain", 0),
"k": tray.get("k"),
"k": k_value,
"cali_idx": cali_idx,
"tag_uid": tag_uid,
"tray_uuid": tray_uuid,
"nozzle_temp_min": tray.get("nozzle_temp_min"),
"nozzle_temp_max": tray.get("nozzle_temp_max"),
}
)
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
@@ -396,13 +417,30 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
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]
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"),
}
# Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)