From 6d6df59ff2b56f1ab98f50e3052d2ba5c5ed3b35 Mon Sep 17 00:00:00 2001 From: viewit Date: Sun, 26 Jul 2026 23:19:36 +0200 Subject: [PATCH] feat(filament): auto-match combined ACE-RFID vendor+type strings (Issue #101) Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one `type` string for custom (third-party) RFID tags, e.g. "GEEETECH PLA Bas" for a Geeetech PLA spool. The bridge previously treated this whole string as an unknown material and fell back to a neutral "Generic " profile, even when the user had already imported a matching OrcaSlicer profile via the ZIP import feature (Issue #41) - forcing a manual per-slot reassignment every time that spool was loaded. Anycubic Slicer Next resolves the same tag correctly. Add two helpers next to the existing _normalize_material/_material_family: - _parse_combined_rfid_type(): splits the raw type string, recognizes a known vendor as the first token (checked against the merged system+user filament library, so custom vendors like "Geeetech" that only exist in the user's imported profiles are included), and extracts the material family from the remainder via the existing _material_family() prefix search. Returns ("", "") for a vendorless string like plain "PLA", leaving normal spool reports untouched. - _match_profile_by_vendor_family(): looks up an imported/system profile by (vendor, family) rather than exact name, since the truncated RFID string never contains the full profile name verbatim. Wired into _build_lane_data() as a third resolution layer, after the existing manual per-slot override and before the Generic-library fallback - not persisted to config.ini, so it re-derives fresh on every call and can't go stale if a differently-tagged spool is loaded later. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 60 +++++++++++++ tests/test_ace_rfid_vendor_matching.py | 111 +++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 tests/test_ace_rfid_vendor_matching.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 6752d5a..12b7a39 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -6,3 +6,4 @@ - Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim) - Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction) - Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim) +- Feat: custom ACE-RFID filament tags (e.g. written via the "ACE RFID" app for third-party spools) are now matched automatically against your imported OrcaSlicer profiles. Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one type string (e.g. "GEEETECH PLA Bas") for custom tags — the bridge now recognizes the vendor prefix and resolves it to your already-imported profile instead of falling back to a generic default, so no manual per-slot reassignment is needed anymore (Issue #101, thanks @Blaim) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index cdc56d2..0f2d081 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -2123,6 +2123,51 @@ class KobraXBridge: return fam return m + def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]: + """Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g. + "GEEETECH PLA Bas", written via third-party RFID tools) into + (vendor, material_family). + + Anycubic's ACE RFID system concatenates vendor + material + a + truncated serial/variant into one `type` string for custom tags - + unlike a normal spool report where `type` is just "PLA"/"PETG"/etc. + Returns ("", "") when the first token isn't a known vendor (from the + merged system+user filament library), which leaves plain type + strings like "PLA" completely unaffected (Issue #101). + """ + tokens = raw_type.split() + if len(tokens) < 2: + return "", "" + first = tokens[0].strip().lower() + vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()} + vendor = vendors.get(first) + if not vendor: + return "", "" + family = self._material_family(" ".join(tokens[1:])) + if not family: + return "", "" + return vendor, family + + def _match_profile_by_vendor_family(self, vendor: str, family: str) -> 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.""" + matches = [ + p for p in self._load_orca_filaments() + if p.get("vendor", "").lower() == vendor.lower() + and self._material_family(p.get("type", "")) == family + ] + 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] + 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 @@ -2206,6 +2251,21 @@ class KobraXBridge: # 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"): vendor = user_profile.get("vendor", "") fila_name = user_profile.get("name", "") diff --git a/tests/test_ace_rfid_vendor_matching.py b/tests/test_ace_rfid_vendor_matching.py new file mode 100644 index 0000000..88b9135 --- /dev/null +++ b/tests/test_ace_rfid_vendor_matching.py @@ -0,0 +1,111 @@ +"""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"