_parse_combined_rfid_type()/_match_profile_by_vendor_family() (added in an earlier nightly) were only ever invoked inside _build_lane_data(), which is only reached when OrcaSlicer actively polls the Moonraker lane_data endpoint. The actual MQTT receive path (_on_multicolor_box -> self._ams_slots -> _push_status_update -> dashboard, and the /kx/filament/slots dashboard API, and Happy-Hare gate data) never touched this logic at all, so a combined RFID string like "GEEETECH PLA Bas" kept showing up unmatched everywhere except the one endpoint nobody was looking at - exactly what @Blaim's extensive debugging in the issue thread demonstrated. Centralized the matching into _effective_slot_profile() itself, since all three real consumers already call it. This fixes the dashboard and gate data with a single change instead of duplicating the matching logic per caller (which is what caused the gap in the first place). While centralizing, found and fixed a related edge case: the stale-profile guard compared a manual override's material family directly against the raw combined RFID string, which _material_family() can't parse past the vendor prefix - a valid manual override on an RFID-tagged slot would have been incorrectly treated as stale and dropped. Now resolves the plain material family through the RFID parser first for that comparison. Also added variant-token disambiguation to _match_profile_by_vendor_family() per @Blaim's follow-up request: when a vendor has multiple profiles of the same material family (e.g. "Geeetech PLA Basic" vs. "Geeetech PLA Matte"), the RFID string's truncated third token ("Bas") is now scored against candidate profile names instead of always picking the first match. New tests cover the actual runtime path end-to-end (a realistic multiColorBox/report payload through _on_multicolor_box, verified against the /kx/filament/slots response the dashboard consumes) - the previous test suite only exercised the helper functions and _build_lane_data() in isolation with hand-set state, which is how this gap went unnoticed.
237 lines
9.8 KiB
Python
237 lines
9.8 KiB
Python
"""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 <type>" 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") == []
|