"""Auto-matching for custom ACE-RFID filament tags (Issue #101). Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one `type` string for custom (third-party) tags, e.g. "GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for "Basic"). Previously the bridge treated this whole string as an unknown material and fell back to a neutral "Generic " profile, even though the user had already imported a matching OrcaSlicer profile via the ZIP import feature (Issue #41) - requiring a manual per-slot reassignment every time that spool was loaded. _parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this automatically against the merged system+user filament library. """ import argparse import tempfile from unittest.mock import MagicMock from kobrax_moonraker_bridge import KobraXBridge USER_PROFILES = [ {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""}, {"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True}, ] def _bridge(profiles=USER_PROFILES): 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="kxrfid-"), ) b = KobraXBridge(c, args=args) b._orca_filaments_cache = profiles return b def test_combined_rfid_type_parses_known_vendor_and_family(): b = _bridge() vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas") assert vendor == "Geeetech" assert family == "PLA" def test_plain_type_string_is_unaffected(): """Regression guard: a normal type="PLA" report (no vendor prefix) must not be mistaken for a combined RFID string.""" b = _bridge() vendor, family = b._parse_combined_rfid_type("PLA") assert vendor == "" assert family == "" def test_unknown_vendor_prefix_returns_no_match(): b = _bridge() vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas") assert vendor == "" assert family == "" def test_match_profile_by_vendor_family_finds_imported_profile(): b = _bridge() profile = b._match_profile_by_vendor_family("Geeetech", "PLA") assert profile.get("name") == "Geeetech PLA Basic" def test_match_profile_by_vendor_family_no_match_returns_empty(): b = _bridge() profile = b._match_profile_by_vendor_family("Geeetech", "PETG") assert profile == {} def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing(): profiles = USER_PROFILES + [ {"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True}, ] b = _bridge(profiles) profile = b._match_profile_by_vendor_family("Geeetech", "PLA") assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk") def test_build_lane_data_auto_resolves_combined_rfid_slot(): """End-to-end: a slot reporting the combined RFID string should surface the imported Geeetech profile in lane_data instead of the Generic fallback.""" b = _bridge() b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path b._filament_mode = "ace_hub" b._ams_slots = [ {"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]}, ] lane = b._build_lane_data() tray = lane["ams"][0]["tray"][0] assert tray["vendor_name"] == "Geeetech" assert tray["name"] == "Geeetech PLA Basic" def test_build_lane_data_plain_type_still_uses_generic_fallback(): """Regression guard: everyday type="PLA" slots must keep using the existing Generic-library fallback, unaffected by the new matching path.""" b = _bridge() b._filament_profiles = {} # no manual per-slot override - isolate the fallback path b._filament_mode = "ace_hub" b._ams_slots = [ {"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]}, ] lane = b._build_lane_data() 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") == []