diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..770ea42 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Fix: **combined ACE-RFID filament tags (e.g. "GEEETECH PLA Bas" from third-party RFID tools) still weren't auto-matching to imported OrcaSlicer profiles**, even after the first attempt at this in a previous nightly — the matching logic was only ever wired into the OrcaSlicer slicer-sync endpoint, never into the actual MQTT status path that feeds the dashboard and Happy-Hare gate data, so the dashboard kept showing the raw unmatched RFID string. Centralized the matching so all three places that resolve a slot's filament profile benefit identically. Also added variant-token disambiguation (e.g. "Bas" vs. "Matte") for when a vendor has multiple profiles of the same material, and fixed a related edge case where a manual per-slot override could be incorrectly treated as stale on an RFID-tagged spool (Issue #101, thanks @Blaim for the extensive debugging that pinpointed this). diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index d6ca743..3400f5a 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -2243,12 +2243,32 @@ class KobraXBridge: return "", "" return vendor, family - def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict: + @staticmethod + def _rfid_variant_tokens(raw_type: str) -> list[str]: + """Tokens after "VENDOR TYPE" in a combined ACE-RFID string (e.g. + ["bas"] for "GEEETECH PLA Bas") - the truncated variant/serial that + distinguishes multiple profiles of the same (vendor, material family), + e.g. "Basic" vs. "Matte". Kept separate from _parse_combined_rfid_type() + so that function's 2-tuple signature (and its existing callers/tests) + stay unchanged (Issue #101).""" + tokens = raw_type.split() + return [t.lower() for t in tokens[2:]] + + def _match_profile_by_vendor_family(self, vendor: str, family: str, + variant_tokens: list[str] | None = None) -> dict: """Find an imported/system filament profile by (vendor, material family) - used to auto-resolve a combined ACE-RFID type string to the user's already-imported OrcaSlicer profile (Issue #101), since the exact profile `name` never appears verbatim in the truncated - RFID string.""" + RFID string. + + When multiple profiles share the same (vendor, family) - e.g. "Geeetech + PLA Basic" and "Geeetech PLA Matte" both matching (Geeetech, PLA) - + variant_tokens (the RFID string's remaining tokens, e.g. ["bas"] for + "Basic") are scored against each candidate's name: a word-prefix match + scores higher than a plain substring match, so "bas" prefers "Basic" + over "Matte" or an unrelated profile name containing "bas" as noise. + Falls back to the first match when nothing disambiguates.""" matches = [ p for p in self._load_orca_filaments() if p.get("vendor", "").lower() == vendor.lower() @@ -2256,12 +2276,28 @@ class KobraXBridge: ] if not matches: return {} - if len(matches) > 1: - log.debug( - f"_match_profile_by_vendor_family: {len(matches)} profiles match " - f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}" - ) - return matches[0] + if len(matches) == 1 or not variant_tokens: + return matches[0] + + best = matches[0] + best_score = -1 + for p in matches: + name_words = p.get("name", "").lower().split() + score = 0 + for tok in variant_tokens: + if any(w.startswith(tok) for w in name_words): + score += 2 + elif tok in p.get("name", "").lower(): + score += 1 + if score > best_score: + best_score = score + best = p + log.debug( + f"_match_profile_by_vendor_family: {len(matches)} profiles match " + f"vendor={vendor!r} family={family!r}, variant_tokens={variant_tokens!r} " + f"-> {best.get('name')!r} (score={best_score})" + ) + return best def _profile_material(self, profile: dict) -> str: """Material type (e.g. "PETG") of a saved slot profile, resolved by @@ -2278,21 +2314,44 @@ class KobraXBridge: 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. + still matches the material currently loaded in the AMS. Falls back to + auto-resolving a combined ACE-RFID type string (Issue #101) when there + is no (usable) manual override. 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).""" + (e.g. a PETG profile but PLA loaded) the override is skipped → falls + through to the RFID auto-match / 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). + + Centralized here (rather than duplicated per caller) so every consumer - + the dashboard's /kx/filament/slots, Happy-Hare gate data, and the + OrcaSlicer lane-data sync - benefits from RFID auto-matching identically, + instead of only the one call site that happened to also call + _parse_combined_rfid_type() directly.""" + # A combined ACE-RFID string ("GEEETECH PLA Bas") carries a vendor + # prefix that _material_family() alone can't see past (it only + # strips known polymer prefixes, so "GEEETECH PLA BAS" resolves to + # itself, not "PLA") - resolve the plain material family through the + # RFID parser first so the stale-profile guard below compares against + # the actual polymer family, not the raw combined string. + vendor, family = self._parse_combined_rfid_type(ams_material) + plain_material = family or ams_material + 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 + if profile.get("name"): + prof_fam = self._material_family(self._profile_material(profile)) + ams_fam = self._material_family(plain_material) + if not (prof_fam and ams_fam and prof_fam != ams_fam): + return profile + + if vendor: + variant_tokens = self._rfid_variant_tokens(ams_material) + auto = self._match_profile_by_vendor_family(vendor, family, variant_tokens) + if auto.get("name"): + return auto + + return {} def _build_lane_data(self) -> dict: """Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0. @@ -2338,30 +2397,19 @@ class KobraXBridge: # The vendor is sent along (tray_sub_brands + filament_vendor), # so a patched OrcaSlicer can match by brand + type + # color (analogous to SnapmakerPrinterAgent). - # Two-layer resolution for the filament hint sent to OrcaSlicer: + # Three-layer resolution for the filament hint sent to OrcaSlicer, + # all handled inside _effective_slot_profile() (Issue #101): # 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle - # 2. Generic fallback (_TRAY_INFO_IDX) per material type - no + # 2. Combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g. + # "GEEETECH PLA Bas") auto-matched against the user's + # already-imported profile library. Not persisted to + # config.ini - re-derives on every call, so a differently + # tagged spool loaded later isn't stuck with a stale match. + # 3. Generic fallback (_TRAY_INFO_IDX) per material type - no # vendor hint; OrcaSlicer then picks its own generic preset - # 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 not user_profile.get("name"): - # Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE - # SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party - # RFID tools) against the user's already-imported profile - # library, instead of falling through to the neutral Generic - # fallback (Issue #101). Not persisted to config.ini - this - # re-derives on every _build_lane_data() call, so a - # differently-tagged spool loaded later isn't stuck with a - # stale match. - vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", "")) - if vendor_guess: - auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess) - if auto_profile.get("name"): - user_profile = auto_profile - material = family_guess if user_profile.get("name"): + material = self._material_family(user_profile.get("type", material)) or material vendor = user_profile.get("vendor", "") fila_name = user_profile.get("name", "") tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99") diff --git a/tests/test_ace_rfid_vendor_matching.py b/tests/test_ace_rfid_vendor_matching.py index 88b9135..987973d 100644 --- a/tests/test_ace_rfid_vendor_matching.py +++ b/tests/test_ace_rfid_vendor_matching.py @@ -109,3 +109,128 @@ def test_build_lane_data_plain_type_still_uses_generic_fallback(): tray = lane["ams"][0]["tray"][0] assert tray["name"] == "Generic PLA" assert tray["vendor_name"] == "Generic" + + +# ─── Centralized matching via _effective_slot_profile() ──────────────────── +# +# The bug reported in Issue #101 by @Blaim (nightly45 not working, despite +# _parse_combined_rfid_type()/_match_profile_by_vendor_family() existing): +# those two helpers were ONLY ever invoked inside _build_lane_data(), which +# is only reached when OrcaSlicer polls the Moonraker lane_data endpoint - +# never as part of the real MQTT receive path (_on_multicolor_box -> +# self._ams_slots -> _push_status_update -> dashboard / /kx/filament/slots). +# So the dashboard and the Happy-Hare gate data never saw a match, matching +# exactly what the user's screenshots showed. Fixed by moving the matching +# logic into _effective_slot_profile() itself, which all three consumers +# already call. + +def test_effective_slot_profile_auto_resolves_raw_rfid_string_without_override(): + """The core Issue #101 regression: _effective_slot_profile() itself (not + just _build_lane_data()) must resolve a combined RFID string when there is + no manual per-slot override in config.ini.""" + b = _bridge() + b._filament_profiles = {} + profile = b._effective_slot_profile(0, "GEEETECH PLA Bas") + assert profile.get("name") == "Geeetech PLA Basic" + assert profile.get("vendor") == "Geeetech" + + +def test_effective_slot_profile_manual_override_still_wins(): + b = _bridge() + b._filament_profiles = {0: {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic"}} + profile = b._effective_slot_profile(0, "GEEETECH PLA Bas") + assert profile.get("name") == "Generic PLA" + + +def test_effective_slot_profile_plain_type_no_override_returns_empty(): + """Regression guard: a plain type="PLA" slot with no override must still + fall through to {} (the caller's own generic-name fallback), not be + treated as an RFID string.""" + b = _bridge() + b._filament_profiles = {} + assert b._effective_slot_profile(0, "PLA") == {} + + +def _multicolor_box_report(raw_type: str, color=(238, 190, 152)) -> dict: + """A realistic multiColorBox/report payload for one ACE box (id=0) with + a toolhead (id=-1), matching the real Kobra X topology captured live + during Issue #100/#101 investigation - drives _detect_filament_mode() + to "ace_hub", same as on real hardware.""" + return { + "state": "success", + "data": { + "head_tools_model": 1, + "multi_color_box": [ + { + "id": -1, "loaded_slot": -1, + "slots": [{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]}], + }, + { + "id": 0, "loaded_slot": -1, + "slots": [ + {"index": 0, "status": 0, "type": "", "color": [0, 0, 0]}, + {"index": 1, "status": 0, "type": "", "color": [0, 0, 0]}, + { + "index": 2, "status": 5, "type": raw_type, + "color": list(color), "sku": "", + }, + {"index": 3, "status": 0, "type": "", "color": [0, 0, 0]}, + ], + }, + ], + }, + } + + +def test_on_multicolor_box_end_to_end_resolves_rfid_slot_for_dashboard(): + """End-to-end regression test for Issue #101: feed a raw MQTT + multiColorBox/report payload through the real receive path + (_on_multicolor_box), then check that the dashboard-facing + /kx/filament/slots data (handle_kx_filament_slots) - which is what + populates the dashboard's window._slotProfileMap in the browser - + actually reflects the matched profile, not the raw "GEEETECH PLA Bas" + string. This is the exact path that was broken and untested before.""" + b = _bridge() + b._filament_profiles = {} + b._on_multicolor_box(_multicolor_box_report("GEEETECH PLA Bas")) + + assert b._filament_mode == "ace_hub" + # global_index 6 = box_id 0 * 4 + local slot 2 in ace_hub mode's ACE block + slot = next(s for s in b._ams_slots if s.get("type") == "GEEETECH PLA Bas") + global_idx = slot["global_index"] + + profile = b._effective_slot_profile(global_idx, slot["type"]) + assert profile.get("name") == "Geeetech PLA Basic" + assert profile.get("vendor") == "Geeetech" + + +def test_match_profile_by_vendor_family_disambiguates_via_variant_tokens(): + """Issue #101 follow-up (@Blaim): two profiles of the same vendor+family + ("Geeetech PLA Basic" vs. "Geeetech PLA Matte") must resolve to the one + matching the RFID string's truncated variant token ("bas" -> Basic), + not just "whichever loads first".""" + profiles = USER_PROFILES + [ + {"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True}, + ] + b = _bridge(profiles) + + basic = b._match_profile_by_vendor_family("Geeetech", "PLA", ["bas"]) + assert basic.get("name") == "Geeetech PLA Basic" + + matte = b._match_profile_by_vendor_family("Geeetech", "PLA", ["mat"]) + assert matte.get("name") == "Geeetech PLA Matte" + + +def test_match_profile_by_vendor_family_no_variant_tokens_falls_back_to_first(): + profiles = USER_PROFILES + [ + {"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True}, + ] + b = _bridge(profiles) + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") == "Geeetech PLA Basic" + + +def test_rfid_variant_tokens_extracts_tokens_after_vendor_and_family(): + assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA Bas") == ["bas"] + assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA") == [] + assert KobraXBridge._rfid_variant_tokens("PLA") == []