fix(spoolbuddy): close #1815 — preserve PFUS/PFCN setting_id in resolver

SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
  Bambu Studio showing "Generic <Material>" instead of the user's
  custom preset. Root cause: the defensive filter that catches
  PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
  but PFUS/PFCN are VALID setting_id values, just not valid
  tray_info_idx values. When the cloud detail lookup didn't return
  a filament_id (cloud unauth on the on_ams_change replay path,
  transient failure, or older custom presets), both fields got
  cleared and the caller's generic-material fallback overwrote
  setting_id with GFSG99 — slicer resolved to Generic PETG.

  Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
  name leaks, but preserves setting_id when it's a valid slicer
  reference (PFUS / PFCN / GFS). Material-name leaks still clear
  both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
  for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
  to load the actual user preset).

  What stays the same: Bambuddy's own AMS card still displays
  the generic material on cloud-unauth paths — same fundamental
  limitation as today. Fixing that needs a deeper layered fallback
  (LocalPreset name match, printer kprofile query, cloud-detail
  cache) and is out of scope for this drop. Slicer-side fix is
  the reporter's explicit ask.
This commit is contained in:
maziggy
2026-06-25 09:26:33 +02:00
parent 4b0150b0ec
commit 261c376d1f
3 changed files with 158 additions and 1 deletions
+2
View File
File diff suppressed because one or more lines are too long
@@ -200,6 +200,19 @@ async def resolve_slicer_filament(
or tray_info_idx.startswith("PFCN")
):
tray_info_idx = ""
setting_id = ""
# Preserve setting_id when it's still a valid slicer reference
# (PFUS / PFCN cloud user/shared preset, or GFS Bambu official
# preset). The slicer accepts these as setting_id even though
# they're rejected as tray_info_idx; without preservation the
# slicer falls back to whatever generic filament the caller's
# tray_info_idx fallback produces and shows "Generic <Material>"
# instead of the user's actual custom preset (#1815). Material-name
# leaks (e.g. setting_id="PETG") are still cleared — those are
# never valid slicer references.
if not (
setting_id
and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS"))
):
setting_id = ""
return (tray_info_idx, setting_id, sub_brand_override)
@@ -0,0 +1,142 @@
"""Tests for ``resolve_slicer_filament`` (#1815).
The defensive filter at the end of the resolver clears ``tray_info_idx``
when its value isn't slicer-acceptable (literal material names + PFUS /
PFCN cloud-preset prefixes that the printer's calibration table can't
key on). Pre-#1815 it cleared ``setting_id`` alongside, which dropped
the slicer's only handle on the user's actual custom preset and forced
the caller into the generic-material fallback — Bambu Studio then
displayed "Generic <Material>" for spools whose Bambu Cloud detail
lookup didn't resolve a ``filament_id`` (cloud unauth on the on_ams_change
replay path, transient cloud failure, or custom presets whose detail
JSON omits ``filament_id``).
Post-#1815 the filter preserves a setting_id that's still a valid
slicer reference (PFUS / PFCN cloud user/shared preset, or GFS Bambu
official preset) even when ``tray_info_idx`` is cleared.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
@pytest.mark.asyncio
async def test_pfus_cloud_unavailable_preserves_setting_id():
"""Reporter scenario: PFUS cloud user preset, cloud lookup fails to
return a filament_id. setting_id must survive so the slicer can
still find the user's actual custom preset."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFUS990b6e19965353",
slicer_filament_name="Jayo PETG HF",
material="PETG",
)
assert tray_info_idx == ""
assert setting_id == "PFUS990b6e19965353"
assert sub_brand is None
@pytest.mark.asyncio
async def test_pfcn_cloud_unavailable_preserves_setting_id():
"""PFCN partner/shared cloud preset (e.g. Polymaker H2D variants,
#1648) shares the same shape problem as PFUS."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PFCN1234567890",
slicer_filament_name="Polymaker PolyTerra PLA",
material="PLA",
)
assert tray_info_idx == ""
assert setting_id == "PFCN1234567890"
assert sub_brand is None
@pytest.mark.asyncio
async def test_pfus_cloud_resolves_filament_id_regression_guard():
"""When cloud auth works and returns a filament_id, the resolver
keeps its existing behaviour: tray_info_idx = real filament_id,
setting_id = original PFUS reference."""
db = MagicMock()
cloud_mock = MagicMock()
cloud_mock.is_authenticated = True
cloud_mock.get_setting_detail = AsyncMock(return_value={"filament_id": "P285e239", "name": "Jayo PETG HF @P1S"})
cloud_mock.close = AsyncMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=cloud_mock),
):
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
db=db,
current_user=MagicMock(),
slicer_filament="PFUS990b6e19965353",
slicer_filament_name="Jayo PETG HF",
material="PETG",
)
assert tray_info_idx == "P285e239"
assert setting_id == "PFUS990b6e19965353"
assert sub_brand == "Jayo PETG HF"
@pytest.mark.asyncio
async def test_gfs_cloud_unavailable_resolves_via_normalize():
"""GFS Bambu official preset + cloud unavailable: normalize strips
the 'S' to give a real filament_id ('GFG02'), so tray_info_idx is
valid and the defensive filter doesn't trigger. setting_id stays as
the original GFS reference. Regression guard for the cloud-down
Bambu-official path."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="GFSG02",
slicer_filament_name=None,
material="PETG",
)
assert tray_info_idx == "GFG02"
assert setting_id == "GFSG02"
assert sub_brand is None
@pytest.mark.asyncio
async def test_literal_material_name_clears_both():
"""slicer_filament='PETG' (free-text material leak from legacy
spools): both tray_info_idx and setting_id must be cleared so the
caller's generic-material fallback rescues the slot. Regression
guard that the PFUS preservation doesn't accidentally preserve
literal material names."""
db = MagicMock()
with patch(
"backend.app.api.routes.cloud.build_authenticated_cloud",
AsyncMock(return_value=None),
):
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
db=db,
current_user=None,
slicer_filament="PETG",
slicer_filament_name=None,
material="PETG",
)
assert tray_info_idx == ""
assert setting_id == ""
assert sub_brand is None