fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)

Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
  the spool from the Dashboard left the slicer's filament dropdown
  showing "unknown", but clicking Configure right after made the
  slicer recognize it correctly. "Configure" felt like a mandatory
  follow-up step rather than a refinement.

  Bambu cloud uses three preset-ID shapes:
    GFS…   — Bambu official cloud preset
    PFUS…  — cloud user-created preset
    PFCN…  — cloud shared / partner preset (Polymaker's "(Custom)"
             Bambu Lab H2D variants ship this prefix)

  apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
  cloud-detail lookup that extracts the underlying filament_id. PFCN
  slipped past the cloud-lookup branch, fell into the local-preset
  int() parse path, raised ValueError, dropped into
  normalize_slicer_filament which returns any P-prefix unchanged, and
  the raw PFCN landed in tray_info_idx. The printer's calibration
  table can't index that, so the slicer rendered "unknown". The
  Configure modal rescued every assign because it does its own
  getCloudSettingDetail and writes the resolved filament_id.

  Extend the cloud-detail-lookup branch (inventory.py:129) and the
  discard safety net (inventory.py:223) to include PFCN alongside
  GFS/PFUS. Three behaviours fall out:

    * Cloud-authenticated: the real filament_id from
      detail["filament_id"] ships as tray_info_idx (Polymaker PLA
      Matte resolves to GFL05).
    * Cloud unavailable: raw PFCN discarded, the slot reuses an
      existing valid P-prefix preset if material matches.

  Source comment now lists all three cloud-ID shapes so the next time
  Bambu invents a new prefix the maintainer doesn't have to re-derive
  the structure from a bug report.
This commit is contained in:
maziggy
2026-06-06 10:17:09 +02:00
parent b8916ac3de
commit d82f4e032f
3 changed files with 154 additions and 4 deletions
+19 -4
View File
@@ -126,7 +126,15 @@ async def apply_spool_to_slot_via_mqtt(
if sf:
base_sf = sf.split("_")[0] if "_" in sf else sf
if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
# 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
@@ -204,7 +212,7 @@ async def apply_spool_to_slot_via_mqtt(
setting_id = filament_id_to_setting_id(fid)
break
# Defend against tray_info_idx values the slicer cannot resolve. Two
# 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
@@ -217,10 +225,16 @@ async def apply_spool_to_slot_via_mqtt(
# 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").
# "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")):
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 = ""
@@ -229,6 +243,7 @@ async def apply_spool_to_slot_via_mqtt(
current_tray_info_idx
and current_tray_info_idx not in _generic_id_values
and not current_tray_info_idx.startswith("PFUS")
and not current_tray_info_idx.startswith("PFCN")
and current_tray_info_idx.upper() not in _known_materials
and current_tray_type
and current_tray_type.upper() == tray_type.upper()
@@ -1141,3 +1141,137 @@ class TestAssignSpoolEmptyDetection:
body = response.json()
assert body["pending_config"] is False
assert body["configured"] is True
class TestAssignSpoolPfcnCloudPreset:
"""Assign path for PFCN-prefix cloud presets (#1648).
PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
and GFS (Bambu official) — used for cloud-shared / partner-uploaded
presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
the assign path skipped the cloud-detail lookup and left the raw PFCN
string in tray_info_idx, which the printer's calibration table can't
resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
itself, making "Configure" feel like a mandatory follow-up step.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
self, async_client: AsyncClient, printer_factory, spool_factory
):
"""When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
the raw PFCN must be discarded as slicer-invalid and the slot configures
with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
was to leak the raw PFCN, which the slicer can't resolve."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# PFCN never leaks into tray_info_idx — must resolve to the
# generic-material fallback when cloud lookup couldn't.
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
"""Symmetry with the PFUS case: when the spool's PFCN is discarded as
slicer-invalid, the slot's existing valid P-prefix preset is reused
if material matches — preserves calibration context instead of
resetting to generic."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
status = _make_mock_status(
ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
)
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
self, async_client: AsyncClient, printer_factory, spool_factory
):
"""When the user is authenticated against Bambu Cloud, the PFCN setting_id
triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
filament_id from `detail["filament_id"]` and ships that as
tray_info_idx. This is the happy path the Configure modal already had
but the assign path didn't, #1648."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
# Cloud responds with a real filament_id for the PFCN preset — exactly
# what the Configure modal already exploits.
mock_cloud = MagicMock()
mock_cloud.is_authenticated = True
async def fake_get_detail(setting_id):
assert setting_id == "PFCN80e80c1f79db85"
return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
async def fake_close():
return None
mock_cloud.get_setting_detail = fake_get_detail
mock_cloud.close = fake_close
async def fake_build_cloud(_db, _user):
return mock_cloud
with (
patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
):
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# tray_info_idx is the resolved cloud filament_id; setting_id is the
# original PFCN (which the slicer needs separately).
assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"