Compare commits

...

1 Commits

Author SHA1 Message Date
Walter Almada B
bf94a8f563 fix(filament): suppress stale slot profile when AMS material family changes
Some checks failed
PR Check / lint-and-test (pull_request) Has been cancelled
Nightly Build / build (push) Successful in 14m49s
A per-slot filament profile override (config.ini [filament_profiles]) stores
only {vendor, name, id} and is sticky: swapping the physical filament updates
the AMS colour + type live, but the saved profile persisted. Loading yellow PLA
into a slot that held "KINGROON PETG Basic" kept showing/sending PETG in the
panel and the OrcaSlicer lane hint, and survived restarts (config.ini).

Resolve the effective profile per slot as the saved override only when 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 (guards against the earlier over-strict material compare). On a family
change the override is suppressed (slot falls back to the generic default) but
NOT deleted, so re-loading the original material reactivates it. Profile
material is resolved from the Orca filament library; unknown → never suppress.

Wired into the three resolution sites: handle_kx_filament_slots (panel),
_build_lane_data (OrcaSlicer AMS array), _build_mmu_object (gate_filament_name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:09:16 -07:00
3 changed files with 227 additions and 3 deletions

View File

@@ -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

View File

@@ -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", ""),

View File

@@ -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"