mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Sync and housekeeping
This commit is contained in:
@@ -38,6 +38,7 @@ from backend.app.schemas.spool import (
|
||||
normalize_extra_colors,
|
||||
)
|
||||
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
|
||||
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
||||
from backend.app.services.spool_csv import (
|
||||
MAX_CSV_IMPORT_BYTES,
|
||||
ImportPreview,
|
||||
@@ -66,27 +67,6 @@ _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
|
||||
# FilamentColors.xyz API
|
||||
FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
|
||||
|
||||
# Generic Bambu filament IDs by material — fallback when no specific
|
||||
# preset is resolvable. Keep aligned with the inline table in
|
||||
# apply_spool_to_slot_via_mqtt below; both paths must produce the same
|
||||
# value for a given material.
|
||||
_GENERIC_FILAMENT_IDS: dict[str, str] = {
|
||||
"PLA": "GFL99",
|
||||
"PETG": "GFG99",
|
||||
"ABS": "GFB99",
|
||||
"ASA": "GFB98",
|
||||
"PC": "GFC99",
|
||||
"PA": "GFN99",
|
||||
"NYLON": "GFN99",
|
||||
"TPU": "GFU99",
|
||||
"PVA": "GFS99",
|
||||
"HIPS": "GFS98",
|
||||
"PLA-CF": "GFL98",
|
||||
"PETG-CF": "GFG98",
|
||||
"PA-CF": "GFN98",
|
||||
"PETG HF": "GFG96",
|
||||
}
|
||||
|
||||
|
||||
async def apply_spool_to_slot_via_mqtt(
|
||||
*,
|
||||
@@ -129,125 +109,24 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
)
|
||||
tray_color = spool.rgba or "FFFFFFFF"
|
||||
|
||||
_generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
|
||||
_generic_id_values = _GENERIC_ID_VALUES
|
||||
_known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
|
||||
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
sf = spool.slicer_filament or ""
|
||||
|
||||
if sf:
|
||||
base_sf = sf.split("_")[0] if "_" in sf else sf
|
||||
# Cloud-side preset IDs in three known shapes:
|
||||
# GFS… — Bambu official cloud preset
|
||||
# PFUS… — cloud user-created preset
|
||||
# PFCN… — cloud shared / partner preset (e.g. Polymaker's
|
||||
# "(Custom)" Bambu Lab H2D variant, #1648)
|
||||
# All three need a cloud-detail lookup to extract the underlying
|
||||
# filament_id; without it the raw cloud id ends up in tray_info_idx
|
||||
# and the printer's calibration table can't resolve it.
|
||||
if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
|
||||
setting_id = base_sf
|
||||
try:
|
||||
from backend.app.api.routes.cloud import build_authenticated_cloud
|
||||
|
||||
cloud = await build_authenticated_cloud(db, current_user)
|
||||
if cloud is not None and cloud.is_authenticated:
|
||||
try:
|
||||
detail = await cloud.get_setting_detail(base_sf)
|
||||
if detail.get("filament_id"):
|
||||
tray_info_idx = detail["filament_id"]
|
||||
cloud_name = detail.get("name", "")
|
||||
if cloud_name:
|
||||
tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
|
||||
elif detail.get("base_id"):
|
||||
bid = detail["base_id"].split("_")[0]
|
||||
if bid.startswith("GFS") and len(bid) >= 5:
|
||||
tray_info_idx = f"GF{bid[3:]}"
|
||||
else:
|
||||
tray_info_idx = bid
|
||||
finally:
|
||||
await cloud.close()
|
||||
elif cloud is not None:
|
||||
await cloud.close()
|
||||
except Exception as e:
|
||||
logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
|
||||
|
||||
if not tray_info_idx:
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
elif base_sf.startswith("GF"):
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
else:
|
||||
try:
|
||||
local_id = int(sf)
|
||||
from backend.app.models.local_preset import LocalPreset as LP
|
||||
|
||||
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
|
||||
lp = lp_result.scalar_one_or_none()
|
||||
if lp:
|
||||
# Local preset's setting JSON carries the printer-recognized
|
||||
# filament_id (e.g. "P4d64437") — use that directly so the
|
||||
# slicer can resolve the specific preset. Falls through to
|
||||
# generic material id only when the JSON doesn't carry one.
|
||||
lp_filament_id = ""
|
||||
if lp.setting:
|
||||
try:
|
||||
setting_data = json.loads(lp.setting)
|
||||
raw_fid = setting_data.get("filament_id")
|
||||
if isinstance(raw_fid, str) and raw_fid:
|
||||
lp_filament_id = raw_fid
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
if lp_filament_id:
|
||||
tray_info_idx = lp_filament_id
|
||||
setting_id = filament_id_to_setting_id(lp_filament_id)
|
||||
else:
|
||||
mat = (spool.material or lp.filament_type or "").upper().strip()
|
||||
tray_info_idx = (
|
||||
_GENERIC_FILAMENT_IDS.get(mat)
|
||||
or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
if lp.name:
|
||||
tray_sub_brands = lp.name.split("@")[0].strip()
|
||||
except (ValueError, TypeError):
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
|
||||
if tray_info_idx and spool.slicer_filament_name:
|
||||
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
||||
|
||||
expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
|
||||
if expected_name and expected_name != spool.slicer_filament_name:
|
||||
for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
|
||||
if fname == spool.slicer_filament_name:
|
||||
tray_info_idx = fid
|
||||
setting_id = filament_id_to_setting_id(fid)
|
||||
break
|
||||
|
||||
# Defend against tray_info_idx values the slicer cannot resolve. Three
|
||||
# shapes leak through and must be discarded so the generic-material
|
||||
# fallback below can rescue the slot:
|
||||
# 1. Literal material names ("PLA", "PETG-CF") that pass through
|
||||
# normalize_slicer_filament unchanged when the spool's slicer_filament
|
||||
# is free-text rather than a real preset ID.
|
||||
# 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
|
||||
# by the slicer as tray_info_idx (the printer's calibration table
|
||||
# indexes by filament_id, and a PFUS isn't one). This normally gets
|
||||
# realigned to a P-prefix local id via printer_kp lookup, but the
|
||||
# replay path in main.py.on_ams_change passes current_user=None,
|
||||
# which skips cloud auth and leaves the raw PFUS in tray_info_idx —
|
||||
# overwriting the correctly-configured slot from the original assign.
|
||||
# 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
|
||||
# "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
|
||||
# Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
|
||||
# "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
|
||||
_known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
|
||||
if tray_info_idx and (
|
||||
tray_info_idx.upper() in _known_materials
|
||||
or tray_info_idx.startswith("PFUS")
|
||||
or tray_info_idx.startswith("PFCN")
|
||||
):
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
# slicer_filament → (tray_info_idx, setting_id) resolution is shared with
|
||||
# the Spoolman-mode route via this helper (#1713). The helper handles
|
||||
# GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
|
||||
# the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
|
||||
# sanitization. When it returns an empty tray_info_idx the local
|
||||
# current-tray-state + generic-material fallback below rescues the slot.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
slicer_filament=spool.slicer_filament,
|
||||
slicer_filament_name=spool.slicer_filament_name,
|
||||
material=spool.material,
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
|
||||
if not tray_info_idx:
|
||||
if (
|
||||
@@ -263,8 +142,8 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
elif tray_type:
|
||||
material = tray_type.upper().strip()
|
||||
generic = (
|
||||
_GENERIC_FILAMENT_IDS.get(material)
|
||||
or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
|
||||
GENERIC_FILAMENT_IDS.get(material)
|
||||
or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
if generic:
|
||||
|
||||
@@ -43,6 +43,7 @@ from backend.app.models.user import User
|
||||
from backend.app.schemas.spool import SpoolKProfileBase
|
||||
from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
||||
from backend.app.services.spoolman import (
|
||||
SpoolmanClient,
|
||||
SpoolmanClientError,
|
||||
@@ -55,6 +56,7 @@ from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_sl
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
|
||||
@@ -1221,7 +1223,7 @@ async def sync_spoolman_ams_weights(
|
||||
async def assign_spoolman_slot(
|
||||
body: SpoolSlotAssignmentRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
) -> dict:
|
||||
"""Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
|
||||
|
||||
@@ -1306,13 +1308,43 @@ async def assign_spoolman_slot(
|
||||
if len(tray_color) == 6:
|
||||
tray_color = tray_color + "FF"
|
||||
|
||||
material_upper = tray_type.upper().strip()
|
||||
tray_info_idx = (
|
||||
GENERIC_FILAMENT_IDS.get(material_upper)
|
||||
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
# #1713: resolve the spool's stored slicer_filament reference
|
||||
# (cloud preset, local preset, GF-prefix builtin, or numeric
|
||||
# LocalPreset id) to the printer-side tray_info_idx + setting_id.
|
||||
# Previously the Spoolman path dropped slicer_filament on the
|
||||
# floor and only the generic-material fallback fired; the user-
|
||||
# configured profile never reached the printer. Shared with the
|
||||
# internal-mode route via the same helper so the two flows can't
|
||||
# drift again.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
slicer_filament=mapped.get("slicer_filament"),
|
||||
slicer_filament_name=mapped.get("slicer_filament_name"),
|
||||
material=tray_type,
|
||||
)
|
||||
setting_id = ""
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
|
||||
material_upper = tray_type.upper().strip()
|
||||
# Fall back to generic-material id when slicer_filament is empty
|
||||
# or the resolver discarded an unresolvable value. Matches the
|
||||
# internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
|
||||
if not tray_info_idx:
|
||||
tray_info_idx = (
|
||||
GENERIC_FILAMENT_IDS.get(material_upper)
|
||||
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
|
||||
# Ensure setting_id is always derivable from tray_info_idx. The
|
||||
# local-preset path can leave it empty when the LP's setting JSON
|
||||
# has no filament_id and falls through to the generic material id;
|
||||
# without this fallback the slicer gets a half-configured slot
|
||||
# (filament id without setting id) and the slot detail modal
|
||||
# renders empty fields. Same pattern as the internal-mode tail.
|
||||
if tray_info_idx and not setting_id:
|
||||
setting_id = filament_id_to_setting_id(tray_info_idx)
|
||||
|
||||
temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
|
||||
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Shared spool ``slicer_filament`` → ``(tray_info_idx, setting_id)`` resolver.
|
||||
|
||||
The internal-inventory and Spoolman-inventory routes both need to translate
|
||||
a spool's stored slicer-preset reference (cloud preset ID / local preset ID /
|
||||
GF-prefix Bambu filament ID / free-text material name) into the two MQTT
|
||||
fields ``ams_filament_setting`` consumes: the printer-side ``tray_info_idx``
|
||||
(filament_id) and the slicer-side ``setting_id``. The two routes were drifting
|
||||
in lockstep before #1713 — internal mode resolved everything, Spoolman mode
|
||||
silently dropped slicer_filament on the floor and only the generic-material
|
||||
fallback fired. This module is the single chokepoint so the two flows can't
|
||||
diverge again.
|
||||
|
||||
Resolver outcomes:
|
||||
|
||||
- Returns ``("", "", None)`` when ``slicer_filament`` is empty, unresolvable,
|
||||
or sanitised away as a slicer-rejected value (literal material name,
|
||||
PFUS / PFCN cloud setting_id). The caller is responsible for the
|
||||
generic-material fallback when this happens.
|
||||
- Returns ``(tray_info_idx, setting_id, sub_brand_override)`` otherwise.
|
||||
The third element is non-empty when a cloud-detail lookup or a local-
|
||||
preset name provides a more specific brand label than the spool's own
|
||||
``"<brand> <material> <subtype>"`` concatenation — the caller should
|
||||
prefer it over its computed default.
|
||||
|
||||
The resolver is async because the GFS / PFUS / PFCN branches need cloud
|
||||
authentication and the local-preset branch reads ``LocalPreset`` from the
|
||||
DB. Pass ``current_user=None`` to skip cloud auth (the on_ams_change
|
||||
replay path uses this); cloud-prefix presets then fall back to a static
|
||||
``normalize_slicer_filament`` parse, which is correct when the slot was
|
||||
already configured by an earlier authenticated assign and the printer's
|
||||
calibration table preserves the real filament_id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.user import User
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
|
||||
|
||||
|
||||
async def resolve_slicer_filament(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
current_user: User | None,
|
||||
slicer_filament: str | None,
|
||||
slicer_filament_name: str | None,
|
||||
material: str | None,
|
||||
) -> tuple[str, str, str | None]:
|
||||
"""Resolve a spool's slicer-preset reference to printer-side ids.
|
||||
|
||||
``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
|
||||
``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or
|
||||
free-text). May be empty or None — returns the empty tuple in that case.
|
||||
|
||||
``slicer_filament_name``: optional builtin-name realignment hint. When
|
||||
set and the resolved tray_info_idx maps to a different builtin name,
|
||||
the resolver swaps to the builtin whose name matches (e.g. user picked
|
||||
"Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic").
|
||||
|
||||
``material``: spool material string for the local-preset fallback
|
||||
branch when the LocalPreset's setting JSON doesn't carry a filament_id.
|
||||
|
||||
Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
|
||||
when nothing resolved. ``sub_brand_override`` is non-None when a more
|
||||
specific brand label is available (cloud detail name or local preset
|
||||
name); ``None`` means the caller should use its own default.
|
||||
"""
|
||||
sf = (slicer_filament or "").strip()
|
||||
if not sf:
|
||||
return ("", "", None)
|
||||
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
sub_brand_override: str | None = None
|
||||
|
||||
base_sf = sf.split("_")[0] if "_" in sf else sf
|
||||
|
||||
# Cloud-side preset IDs in three known shapes:
|
||||
# GFS… — Bambu official cloud preset
|
||||
# PFUS… — cloud user-created preset
|
||||
# PFCN… — cloud shared / partner preset (e.g. Polymaker's "(Custom)"
|
||||
# Bambu Lab H2D variant, #1648)
|
||||
# All three need a cloud-detail lookup to extract the underlying
|
||||
# filament_id; without it the raw cloud id ends up in tray_info_idx
|
||||
# and the printer's calibration table can't resolve it.
|
||||
if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
|
||||
setting_id = base_sf
|
||||
try:
|
||||
from backend.app.api.routes.cloud import build_authenticated_cloud
|
||||
|
||||
cloud = await build_authenticated_cloud(db, current_user)
|
||||
if cloud is not None and cloud.is_authenticated:
|
||||
try:
|
||||
detail = await cloud.get_setting_detail(base_sf)
|
||||
if detail.get("filament_id"):
|
||||
tray_info_idx = detail["filament_id"]
|
||||
cloud_name = detail.get("name", "")
|
||||
if cloud_name:
|
||||
sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
|
||||
elif detail.get("base_id"):
|
||||
bid = detail["base_id"].split("_")[0]
|
||||
if bid.startswith("GFS") and len(bid) >= 5:
|
||||
tray_info_idx = f"GF{bid[3:]}"
|
||||
else:
|
||||
tray_info_idx = bid
|
||||
finally:
|
||||
await cloud.close()
|
||||
elif cloud is not None:
|
||||
await cloud.close()
|
||||
except Exception as e:
|
||||
logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e)
|
||||
|
||||
if not tray_info_idx:
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
elif base_sf.startswith("GF"):
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
else:
|
||||
try:
|
||||
local_id = int(sf)
|
||||
from backend.app.models.local_preset import LocalPreset as LP
|
||||
|
||||
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
|
||||
lp = lp_result.scalar_one_or_none()
|
||||
if lp:
|
||||
# Local preset's setting JSON carries the printer-recognized
|
||||
# filament_id (e.g. "P4d64437") — use that directly so the
|
||||
# slicer can resolve the specific preset. Falls through to
|
||||
# generic material id only when the JSON doesn't carry one.
|
||||
lp_filament_id = ""
|
||||
if lp.setting:
|
||||
try:
|
||||
setting_data = json.loads(lp.setting)
|
||||
raw_fid = setting_data.get("filament_id")
|
||||
if isinstance(raw_fid, str) and raw_fid:
|
||||
lp_filament_id = raw_fid
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
if lp_filament_id:
|
||||
tray_info_idx = lp_filament_id
|
||||
setting_id = filament_id_to_setting_id(lp_filament_id)
|
||||
else:
|
||||
mat = (material or lp.filament_type or "").upper().strip()
|
||||
tray_info_idx = (
|
||||
GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
|
||||
)
|
||||
if lp.name:
|
||||
sub_brand_override = lp.name.split("@")[0].strip()
|
||||
except (ValueError, TypeError):
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
|
||||
# Realign tray_info_idx to a builtin whose name matches slicer_filament_name
|
||||
# when the current resolution lands on a builtin with a different name
|
||||
# (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte).
|
||||
if tray_info_idx and slicer_filament_name:
|
||||
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
||||
|
||||
expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
|
||||
if expected_name and expected_name != slicer_filament_name:
|
||||
for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
|
||||
if fname == slicer_filament_name:
|
||||
tray_info_idx = fid
|
||||
setting_id = filament_id_to_setting_id(fid)
|
||||
break
|
||||
|
||||
# Defend against tray_info_idx values the slicer cannot resolve. Three
|
||||
# shapes leak through and must be discarded so the caller's generic-
|
||||
# material fallback can rescue the slot:
|
||||
# 1. Literal material names ("PLA", "PETG-CF") that pass through
|
||||
# normalize_slicer_filament unchanged when the spool's slicer_filament
|
||||
# is free-text rather than a real preset ID.
|
||||
# 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
|
||||
# by the slicer as tray_info_idx (the printer's calibration table
|
||||
# indexes by filament_id, and a PFUS isn't one). This normally gets
|
||||
# realigned to a P-prefix local id via the caller's printer_kp
|
||||
# lookup, but on the replay path in main.py.on_ams_change
|
||||
# current_user=None skips cloud auth and leaves the raw PFUS in
|
||||
# tray_info_idx — overwriting the correctly-configured slot from
|
||||
# the original assign.
|
||||
# 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
|
||||
# "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
|
||||
# Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
|
||||
# "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
|
||||
if tray_info_idx and (
|
||||
tray_info_idx.upper() in _KNOWN_MATERIALS
|
||||
or tray_info_idx.startswith("PFUS")
|
||||
or tray_info_idx.startswith("PFCN")
|
||||
):
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
|
||||
return (tray_info_idx, setting_id, sub_brand_override)
|
||||
@@ -6,6 +6,7 @@ Covers:
|
||||
- MQTT failure does NOT roll back the slot assignment
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -868,3 +869,171 @@ class TestAssignSpoolmanSlotKProfileRealignment:
|
||||
# extruder mismatch was hard-skipped pre-fix; now used as fallback
|
||||
cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
|
||||
assert cs_kwargs["cali_idx"] == 42
|
||||
|
||||
|
||||
# ---- #1713: slicer_filament resolved into tray_info_idx + setting_id --------
|
||||
#
|
||||
# Before this fix the Spoolman-mode assign route ignored the spool's stored
|
||||
# slicer_filament (the user's configured Bambu Studio / Orca filament profile)
|
||||
# and only filled tray_info_idx from the generic-material fallback. The user
|
||||
# saw ams_filament_setting publish with tray_info_idx=GFL99 / setting_id=""
|
||||
# even though they had assigned a real profile to the spool, and had to
|
||||
# manually re-configure each slot through the printer card. The internal-mode
|
||||
# route did the resolution correctly via _apply_spool_to_slot_inner; the
|
||||
# Spoolman route was never ported.
|
||||
#
|
||||
# These tests pin the parity: an assign of a Spoolman spool whose
|
||||
# bambu_slicer_filament extra-field points at a real preset must publish that
|
||||
# preset's tray_info_idx + setting_id, not the generic-material bucket.
|
||||
|
||||
|
||||
class TestSlicerFilamentResolutionParity:
|
||||
"""#1713: Spoolman-mode assign honours the spool's configured slicer
|
||||
filament profile, matching internal-mode behaviour."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_gf_prefix_slicer_filament_resolves_to_tray_info_idx(
|
||||
self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
|
||||
):
|
||||
"""GF-prefix Bambu official preset (e.g. ``GFA01``) routes straight
|
||||
through ``normalize_slicer_filament`` — the simplest path and the
|
||||
most common shape for users who picked their preset in the slicer."""
|
||||
mock_spoolman_client.get_spool = AsyncMock(
|
||||
return_value={**SAMPLE_SPOOL, "extra": {"bambu_slicer_filament": '"GFA01"'}}
|
||||
)
|
||||
|
||||
mqtt_mock = MagicMock()
|
||||
mqtt_mock.ams_set_filament_setting = MagicMock()
|
||||
mqtt_mock.extrusion_cali_sel = MagicMock()
|
||||
mqtt_mock.printer_state = None
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
|
||||
pm_mock.get_client = MagicMock(return_value=mqtt_mock)
|
||||
pm_mock.get_status = MagicMock(return_value=None)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/slot-assignments",
|
||||
json={
|
||||
"spoolman_spool_id": 10,
|
||||
"printer_id": test_printer.id,
|
||||
"ams_id": 0,
|
||||
"tray_id": 0,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
|
||||
assert call_kwargs["tray_info_idx"] == "GFA01", (
|
||||
"Pre-fix: dropped slicer_filament and published GFL99 generic-PLA bucket. "
|
||||
"Post-fix: must publish the actual preset id."
|
||||
)
|
||||
assert call_kwargs["setting_id"].startswith("GFSA01"), (
|
||||
"setting_id must be derived from the resolved filament_id, not left empty as the pre-fix path did."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_local_preset_int_id_resolves_to_filament_id_from_json(
|
||||
self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
|
||||
):
|
||||
"""#1713 regression: shaddowlink's exact case. Spool's slicer_filament
|
||||
is the integer id of a LocalPreset whose setting JSON carries the
|
||||
printer-side ``filament_id`` (e.g. ``P20bd830``). The publish must
|
||||
carry that filament_id + its derived setting_id — not the generic
|
||||
material bucket.
|
||||
|
||||
From his support bundle:
|
||||
11:33:01 — assign_spoolman_slot published tray_info_idx=GFL99 (BUG)
|
||||
11:33:13 — user manually fired /printers/.../configure with
|
||||
tray_info_idx=P20bd830, setting_id=PFUS3822acb73c88cc
|
||||
"""
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
|
||||
lp = LocalPreset(
|
||||
name="AMOLEN PLA Silk @0.4 nozzle",
|
||||
preset_type="filament",
|
||||
filament_type="PLA",
|
||||
setting=json.dumps({"filament_id": "P20bd830"}),
|
||||
)
|
||||
db_session.add(lp)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lp)
|
||||
|
||||
# Spoolman spool whose bambu_slicer_filament points at this LocalPreset
|
||||
# by integer id (the shape the inventory UI persists when the user
|
||||
# picks a local preset in the filament dropdown).
|
||||
mock_spoolman_client.get_spool = AsyncMock(
|
||||
return_value={
|
||||
**SAMPLE_SPOOL,
|
||||
"extra": {"bambu_slicer_filament": json.dumps(str(lp.id))},
|
||||
}
|
||||
)
|
||||
|
||||
mqtt_mock = MagicMock()
|
||||
mqtt_mock.ams_set_filament_setting = MagicMock()
|
||||
mqtt_mock.extrusion_cali_sel = MagicMock()
|
||||
mqtt_mock.printer_state = None
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
|
||||
pm_mock.get_client = MagicMock(return_value=mqtt_mock)
|
||||
pm_mock.get_status = MagicMock(return_value=None)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/slot-assignments",
|
||||
json={
|
||||
"spoolman_spool_id": 10,
|
||||
"printer_id": test_printer.id,
|
||||
"ams_id": 255,
|
||||
"tray_id": 0,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
|
||||
# Pre-fix the publish here was tray_info_idx="GFL99", setting_id="".
|
||||
assert call_kwargs["tray_info_idx"] == "P20bd830"
|
||||
assert call_kwargs["setting_id"], "setting_id must not be empty post-fix"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_slicer_filament_still_falls_back_to_generic_material(
|
||||
self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
|
||||
):
|
||||
"""Spools without a configured slicer_filament must still get the
|
||||
generic-material fallback so the slot is at least minimally
|
||||
configured. Guards against the resolver path swallowing the empty
|
||||
case and leaving tray_info_idx empty."""
|
||||
# extra dict has no bambu_slicer_filament key
|
||||
mock_spoolman_client.get_spool = AsyncMock(return_value={**SAMPLE_SPOOL, "extra": {}})
|
||||
|
||||
mqtt_mock = MagicMock()
|
||||
mqtt_mock.ams_set_filament_setting = MagicMock()
|
||||
mqtt_mock.extrusion_cali_sel = MagicMock()
|
||||
mqtt_mock.printer_state = None
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
|
||||
pm_mock.get_client = MagicMock(return_value=mqtt_mock)
|
||||
pm_mock.get_status = MagicMock(return_value=None)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/slot-assignments",
|
||||
json={
|
||||
"spoolman_spool_id": 10,
|
||||
"printer_id": test_printer.id,
|
||||
"ams_id": 0,
|
||||
"tray_id": 0,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
|
||||
# PLA → GFL99 (the generic-PLA bucket from GENERIC_FILAMENT_IDS).
|
||||
assert call_kwargs["tray_info_idx"] == "GFL99"
|
||||
# The generic-fallback path must STILL produce a non-empty setting_id
|
||||
# (matches the internal-mode tail). Pre-fix this was "".
|
||||
assert call_kwargs["setting_id"], (
|
||||
"Even on the generic-material fallback, setting_id must be "
|
||||
"filament_id_to_setting_id-derived so the slot detail modal "
|
||||
"doesn't render with empty fields."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user