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"],