diff --git a/CHANGELOG.md b/CHANGELOG.md index 187f1ae..75fb40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ ## [Unreleased] ### Fixed +- **Slot kept showing/printing a stale filament type after a spool swap.** The + per-slot profile override (config.ini `[filament_profiles]`) stores only + vendor+name and was sticky: swapping the physical filament updated the AMS + colour and type live, but the saved profile persisted, so a slot that held + e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the + OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts. + The override is now applied only while its material *family* still matches the + loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so + within-family swaps never invalidate a valid profile). On a family change the + slot falls back to the generic default; the override is not deleted, so + reloading the original material reactivates it. - **Filament profiles not isolated between printers in a multi-printer bridge** (issue #74). The slot→profile mapping and `visible_vendors` were stored in a single global `[filament_profiles]` section, so configuring one printer diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8d3da7b..870e1c9 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1917,6 +1917,55 @@ class KobraXBridge: return _ALIASES[m] return m + @staticmethod + def _material_family(mat: str) -> str: + """Reduce a material to its base polymer family. + + PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc. + Used by the stale-profile guard: only a change of *family* (e.g. PETG -> + PLA) invalidates a saved slot profile — a change within the family + (PLA -> PLA SILK) must not discard an otherwise valid profile. + """ + if not mat: + return "" + m = KobraXBridge._normalize_material(mat) + # Longer prefixes first so "PETG" is not swallowed by "PET". + for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"): + if m.startswith(fam): + return fam + return m + + def _profile_material(self, profile: dict) -> str: + """Material type (e.g. "PETG") of a saved slot profile, resolved by + (vendor, name) from the Orca filament library. Returns "" when the + profile is not in the library — we do NOT guess in that case.""" + name = (profile or {}).get("name", "") + if not name: + return "" + vendor = profile.get("vendor", "") + for p in self._load_orca_filaments(): + if p.get("vendor") == vendor and p.get("name") == name: + return p.get("type", "") or "" + return "" + + def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict: + """Saved slot-profile override — but only while its material *family* + still matches the material currently loaded in the AMS. + + Non-destructive suppression (Option A): when the family no longer matches + (e.g. a PETG profile but PLA loaded) we return {} → the slot falls back to + the generic default. The override stays in config.ini and reactivates as + soon as the matching material is loaded again. When the profile's family + is unknown we do NOT suppress (fail-safe).""" + profile = self._filament_profiles.get(global_idx) or {} + if not profile.get("name"): + return {} + prof_fam = self._material_family(self._profile_material(profile)) + ams_fam = self._material_family(ams_material) + if prof_fam and ams_fam and prof_fam != ams_fam: + return {} + return profile + def _build_lane_data(self) -> dict: """Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0. @@ -1965,7 +2014,10 @@ class KobraXBridge: # 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle # 2. Generic fallback (_TRAY_INFO_IDX) per material type - no # vendor hint; OrcaSlicer then picks its own generic preset - user_profile = self._filament_profiles.get(slot_index) or {} + # Stale-profile guard: only apply the override while its material + # family still matches the loaded filament (PETG profile + PLA + # loaded -> dropped). + user_profile = self._effective_slot_profile(slot_index, material) if user_profile.get("name"): vendor = user_profile.get("vendor", "") fila_name = user_profile.get("name", "") @@ -2145,7 +2197,9 @@ class KobraXBridge: # preset name -> 'Anycubic PLA' matches the printer-specific # preset; an empty string previously led to Generic PLA. if occupied: - user_profile = self._filament_profiles.get(_global_index) or {} + # Stale-profile guard (see _effective_slot_profile): only apply the + # override while its material family still matches the loaded filament. + user_profile = self._effective_slot_profile(_global_index, material) fila_name = user_profile.get("name") or self._default_filament_name(material) gate_filament_name.append(fila_name) else: @@ -2484,7 +2538,9 @@ class KobraXBridge: slots = [] for i, s in enumerate(self._ams_slots): gidx = int(s.get("global_index", i)) - profile = self._filament_profiles.get(gidx) or {} + # Stale-profile guard: only show the override while its material + # family matches the loaded AMS material (else slot has no brand). + profile = self._effective_slot_profile(gidx, s.get("type", "")) slots.append({ "slot_index": gidx, "material": s.get("type", ""), diff --git a/tests/test_slot_profile_material_guard.py b/tests/test_slot_profile_material_guard.py new file mode 100644 index 0000000..c5b5d88 --- /dev/null +++ b/tests/test_slot_profile_material_guard.py @@ -0,0 +1,157 @@ +"""Stale slot-profile guard: suppress a saved per-slot filament profile when the +AMS now reports a *different material family* than the profile was assigned for. + +Real-world bug (KX1): slot 1 held a PETG spool and got the profile +"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the +colour (live) but the saved profile stuck on PETG, so the panel + the slicer +hint kept showing/using PETG. Restarting did not help — the override lives in +config.ini. + +Fix = non-destructive suppression (Option A): resolve the effective profile as +"the saved override only if its material *family* matches the current AMS +material; otherwise none (fall back to the generic default)". The override is +never deleted, so putting the original material back reactivates it. + +Comparison must be by *family*, never strict string equality — PLA / PLA+ / +PLA SILK / PLA MATTE are the same family and must NOT invalidate each other +(regression guard for the earlier over-strict material compare). +""" +import argparse +import json +import tempfile +from unittest.mock import MagicMock + +# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader. +from kobrax_moonraker_bridge import KobraXBridge + +# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type). +LIBRARY = [ + {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"}, + {"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"}, + {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"}, +] + +PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"} +SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"} +UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"} + + +def _bridge(): + c = MagicMock() + c.callbacks = {} + c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxguard-"), + ) + b = KobraXBridge(c, args=args) + b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is + return b + + +# ── _material_family ────────────────────────────────────────────────────────── + +def test_material_family_collapses_pla_variants(): + fam = KobraXBridge._material_family + assert fam("PLA") == "PLA" + assert fam("PLA+") == "PLA" + assert fam("PLA SILK") == "PLA" + assert fam("PLA MATTE") == "PLA" + assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction + + +def test_material_family_collapses_petg_variants(): + fam = KobraXBridge._material_family + assert fam("PETG") == "PETG" + assert fam("PETG+") == "PETG" + + +def test_material_family_distinguishes_pla_from_petg(): + fam = KobraXBridge._material_family + assert fam("PLA") != fam("PETG") + assert fam("PLA SILK") != fam("PETG") + + +def test_material_family_empty_for_empty_input(): + assert KobraXBridge._material_family("") == "" + assert KobraXBridge._material_family(None) == "" + + +# ── _effective_slot_profile ─────────────────────────────────────────────────── + +def test_suppressed_when_family_changes_petg_profile_pla_loaded(): + """The exact KX1 bug: PETG profile, AMS now reports PLA → suppress.""" + b = _bridge() + b._filament_profiles = {0: dict(PETG_PROFILE)} + assert b._effective_slot_profile(0, "PLA") == {} + + +def test_kept_when_family_matches_petg_profile_petg_loaded(): + b = _bridge() + b._filament_profiles = {0: dict(PETG_PROFILE)} + assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE + + +def test_kept_for_pla_variant_no_false_positive(): + """PLA+ profile with a PLA SILK spool loaded is the same family → keep.""" + b = _bridge() + b._filament_profiles = {1: dict(SILK_PROFILE)} + assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE + + +def test_kept_when_profile_material_unknown_failsafe(): + """If the profile is not in the library we cannot know its family → never + suppress on uncertainty (fail-safe keeps the user's choice).""" + b = _bridge() + b._filament_profiles = {2: dict(UNKNOWN_PROFILE)} + assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE + + +def test_empty_when_no_override(): + b = _bridge() + b._filament_profiles = {} + assert b._effective_slot_profile(3, "PLA") == {} + + +# ── Integration: display endpoint (the visible panel) ───────────────────────── + +async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded(): + """/kx/filament/slots must not show the stale PETG identity once PLA loads.""" + b = _bridge() + b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0] + assert row["material"] == "PLA" # AMS truth, always + assert row["filament_name"] == "" # stale PETG identity gone + assert row["filament_vendor"] == "" + + +async def test_display_endpoint_keeps_profile_when_family_matches(): + b = _bridge() + b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0] + assert row["filament_name"] == "KINGROON PETG Basic" + assert row["filament_vendor"] == "KINGROON" + + +# ── Integration: print path (lane_data sent to OrcaSlicer) ──────────────────── + +async def test_lane_data_does_not_leak_stale_petg_identity(): + b = _bridge() + b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + tray = b._build_lane_data()["ams"][0]["tray"][0] + assert tray["tray_type"] == "PLA" + assert "PETG" not in tray["name"].upper() + assert tray["vendor_name"] != "KINGROON" + + +async def test_lane_data_keeps_profile_when_family_matches(): + b = _bridge() + b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + tray = b._build_lane_data()["ams"][0]["tray"][0] + assert tray["name"] == "KINGROON PETG Basic" + assert tray["vendor_name"] == "KINGROON"