diff --git a/.gitignore b/.gitignore index d86da6a..7beb455 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist/ releases/*/kx-bridge releases/*/extract_credentials releases/*/extract_credentials.exe +config/config.ini +data \ No newline at end of file diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 3a9c73a..dd6eff5 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -446,9 +446,13 @@ class KobraXBridge: "print_speed_mode": 2, "connection_error": "", "file_ready": "", + "filament_mode": "toolhead", } - self._ams_slots: list[dict] = [] - self._ams_loaded_slot: int = -1 + self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id + self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot + self._pending_load_slot: int = -1 # global slot index requested via /api/ams/feed type=1 + self._head_tools_model: int = -1 + self._filament_mode: str = "toolhead" self._last_uploaded_file: str = "" self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir @@ -601,34 +605,179 @@ class KobraXBridge: log.warning(f"update_file_objects fehlgeschlagen: {e}") self._push_status_update() + @staticmethod + def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str: + """Detect active filament topology mode. + + Modes: + - toolhead: only toolhead slots + - ace_direct: ACE channels directly mapped (no toolhead box present) + - ace_hub: toolhead + ACE via hub (slot 4 as hub path) + """ + toolhead = any(b.get("id") == -1 for b in boxes) + ace = any(b.get("id", -1) >= 0 for b in boxes) + if ace and toolhead: + return "ace_hub" + if ace: + return "ace_direct" + return "toolhead" + + @staticmethod + def _aggregate_slots(boxes: list, mode: str = "toolhead") -> tuple: + """Aggregate multi_color_box list into a flat global slot list.""" + toolhead = next((b for b in boxes if b.get("id") == -1), None) + ace_boxes = sorted( + [b for b in boxes if b.get("id", -1) >= 0], + key=lambda b: b["id"] + ) + + global_slots: list = [] + global_loaded: int = -1 + + if mode == "toolhead": + if toolhead: + for local_idx, s in enumerate(toolhead.get("slots") or []): + s = dict(s) + s["global_index"] = local_idx + s["box_id"] = -1 + global_slots.append(s) + loaded = toolhead.get("loaded_slot", -1) + if loaded >= 0: + global_loaded = loaded + return global_slots, global_loaded + + if mode == "ace_direct": + for ace in ace_boxes: + ace_id = ace["id"] + base = ace_id * 4 + for local_idx, s in enumerate(ace.get("slots") or []): + s = dict(s) + s["global_index"] = base + local_idx + s["box_id"] = ace_id + global_slots.append(s) + ace_loaded = ace.get("loaded_slot", -1) + if ace_loaded >= 0: + global_loaded = base + ace_loaded + return global_slots, global_loaded + + # ace_hub + if toolhead: + for local_idx, s in enumerate((toolhead.get("slots") or [])[:3]): + s = dict(s) + s["global_index"] = local_idx + s["box_id"] = -1 + global_slots.append(s) + th_loaded = toolhead.get("loaded_slot", -1) + if 0 <= th_loaded <= 2: + global_loaded = th_loaded + + for ace in ace_boxes: + ace_id = ace["id"] + base = 3 + ace_id * 4 + for local_idx, s in enumerate(ace.get("slots") or []): + s = dict(s) + s["global_index"] = base + local_idx + s["box_id"] = ace_id + global_slots.append(s) + ace_loaded = ace.get("loaded_slot", -1) + if ace_loaded >= 0: + global_loaded = base + ace_loaded + + return global_slots, global_loaded + + def _global_to_box_slot(self, global_index: int) -> tuple: + """Convert a global slot index to (box_id, local_slot_index).""" + for s in self._ams_slots: + if s.get("global_index") == global_index: + return s.get("box_id", -1), s.get("index", global_index) + + ace_present = any(s.get("box_id", -1) >= 0 for s in self._ams_slots) + if self._filament_mode == "ace_direct" and ace_present: + return global_index // 4, global_index % 4 + if not ace_present or global_index < 3: + return -1, global_index + offset = global_index - 3 + return offset // 4, offset % 4 + + def _box_local_to_global(self, box_id: int, local_slot: int, boxes: list) -> int: + """Convert (box_id, local slot) to global slot index for current topology.""" + if box_id == -1: + return local_slot + if self._filament_mode == "ace_direct": + return box_id * 4 + local_slot + return 3 + box_id * 4 + local_slot + + def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict: + """Build {global_slot_index: loading|unloading} from feed_status data.""" + activity: dict = {} + for box in boxes: + fs = box.get("feed_status") or {} + current_status = int(fs.get("current_status", -1)) + local_slot = int(fs.get("slot_index", -1)) + feed_type = int(fs.get("type", -1)) + if current_status in (-1, 10, 11) or local_slot < 0: + continue + box_slots = box.get("slots") or [] + if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5: + continue + if feed_type == 1: + act = "loading" + elif feed_type == 2: + act = "unloading" + else: + continue + global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes) + if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot: + # Ignore transient firmware-reported loading slots that differ from the requested target. + if global_loaded >= 0 and global_loaded != self._pending_load_slot: + activity[global_loaded] = "unloading" + continue + if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded: + # During a slot swap the firmware reports the target slot immediately, + # while the previously loaded slot is still being unloaded first. + activity[global_loaded] = "unloading" + activity[global_slot] = act + return activity + def _on_multicolor_box(self, payload: dict): - boxes = (payload.get("data") or {}).get("multi_color_box") or [] + data = payload.get("data") or {} + boxes = data.get("multi_color_box") or [] if not boxes: return - box = boxes[0] - slots = box.get("slots") or [] - loaded = box.get("loaded_slot", -1) - if loaded != -1: - self._ams_loaded_slot = loaded + self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) + self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) + self._state["filament_mode"] = self._filament_mode + + global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode) + self._ams_loaded_slot = global_loaded + if self._pending_load_slot >= 0 and global_loaded == self._pending_load_slot: + self._pending_load_slot = -1 + activity_map = self._slot_activity_map(boxes, global_loaded) + for s in global_slots: + s["activity"] = activity_map.get(s.get("global_index"), "") + # Tip-Forming: nach Einziehen (status=10) oder Ausziehen (status=11) - # schickt der originale Slicer automatisch type=3 (Extruder-Rückzug) - fs = box.get("feed_status") or {} - current_status = fs.get("current_status") - slot_index = fs.get("slot_index", 0) - if current_status in (10, 11): - import threading - def _tip_form(): - import time; time.sleep(2) - self.client.publish( - "multiColorBox", "feedFilament", - {"multi_color_box": [{"id": -1, "feed_status": {"slot_index": slot_index, "type": 3}}]}, - timeout=0 - ) - log.info(f"Tip-Forming (type=3) nach status={current_status} slot={slot_index}") - threading.Thread(target=_tip_form, daemon=True).start() - if slots: - self._ams_slots = slots - log.info(f"AMS-Slots empfangen: {len(slots)}, loaded_slot={self._ams_loaded_slot}") + # schickt der originale Slicer automatisch type=3 (Extruder-Rückzug). + # Check ALL boxes so ACE-triggered events are handled correctly. + for box in boxes: + fs = box.get("feed_status") or {} + current_status = fs.get("current_status") + slot_index = fs.get("slot_index", 0) + box_id = box.get("id", -1) + if current_status in (10, 11): + def _tip_form(bi=box_id, si=slot_index, cs=current_status): + import time; time.sleep(2) + self.client.publish( + "multiColorBox", "feedFilament", + {"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]}, + timeout=0 + ) + log.info(f"Tip-Forming (type=3) nach status={cs} box={bi} slot={si}") + threading.Thread(target=_tip_form, daemon=True).start() + + if global_slots: + self._ams_slots = global_slots + log.info(f"AMS-Slots empfangen: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}") self._push_status_update() def _on_light(self, payload: dict): @@ -758,8 +907,8 @@ class KobraXBridge: "gate_filament_name": [""] * num_gates, "gate_spool_id": [-1] * num_gates, "ttg_map": list(range(num_gates)), - "tool": max(self._ams_loaded_slot, 0), - "gate": max(self._ams_loaded_slot, 0), + "tool": self._ams_loaded_slot, + "gate": self._ams_loaded_slot, } def _build_printer_objects(self) -> dict: @@ -1620,11 +1769,26 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background: .home-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;justify-content:center} /* ── AMS ── */ -.ams-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px} +.ams-slots{display:flex;flex-direction:column;gap:12px} +.ams-box-group{} +.ams-box-label{font-size:11px;font-weight:700;color:var(--txt2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px;padding-left:2px} +.ams-box-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px} .ams-slot{background:var(--raised);border-radius:10px;padding:10px;text-align:center; border:2px solid transparent;transition:.2s;position:relative} .ams-slot.active{border-color:var(--slot-color,var(--accent)); box-shadow:0 0 12px rgba(var(--slot-rgb,0,200,255),.3)} +.ams-slot.loaded{border-color:var(--ok)!important; + box-shadow:0 0 0 2px rgba(64,220,120,.35),0 0 14px rgba(64,220,120,.35)} +.ams-slot.loading{border-color:var(--ok)!important;animation:amsPulseGreen 1s ease-in-out infinite} +.ams-slot.unloading{border-color:var(--err)!important;animation:amsPulseRed 1s ease-in-out infinite} +@keyframes amsPulseGreen{0%{box-shadow:0 0 0 0 rgba(64,220,120,.55)}50%{box-shadow:0 0 0 4px rgba(64,220,120,.25),0 0 18px rgba(64,220,120,.45)}100%{box-shadow:0 0 0 0 rgba(64,220,120,.55)}} +@keyframes amsPulseRed{0%{box-shadow:0 0 0 0 rgba(230,80,80,.55)}50%{box-shadow:0 0 0 4px rgba(230,80,80,.25),0 0 18px rgba(230,80,80,.45)}100%{box-shadow:0 0 0 0 rgba(230,80,80,.55)}} +.ams-slot-bridge{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px; + border:1px dashed var(--border);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01)); + color:var(--txt2);min-height:106px} +.ams-slot-bridge .bridge-chip{width:58px;height:58px;border:1px solid rgba(255,255,255,.14);border-radius:50%; + display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.04);color:var(--txt2); + font-size:13px;font-weight:700;letter-spacing:.04em} .slot-circle{width:36px;height:36px;border-radius:50%;margin:0 auto 6px;border:2px solid rgba(255,255,255,.15)} .slot-label{font-size:11px;color:var(--txt2);font-family:var(--mono)} .slot-material{font-size:12px;font-weight:600;margin-bottom:2px} @@ -1731,8 +1895,8 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; .temp-pair{grid-template-columns:1fr} .temp-card-inner{grid-template-columns:1fr} - /* AMS: 2 Spalten */ - .ams-slots{grid-template-columns:repeat(2,1fr)} + /* AMS: 2 Spalten auf kleinen Screens */ + .ams-box-slots{grid-template-columns:repeat(2,1fr)} /* Joypad etwas kleiner */ .joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px} @@ -2097,7 +2261,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;