diff --git a/filament-dialog.js b/filament-dialog.js deleted file mode 100644 index be35051..0000000 --- a/filament-dialog.js +++ /dev/null @@ -1,50 +0,0 @@ -// JS: Filament-Dialog für Store-Print (GCode-Filamente + Slots) - -var _filamentDialogMode = 'store'; // 'store' oder 'banner' - -var _gcodeFilaments = []; - -function storePrint(fileId, filename) { - _storeFileId = fileId; - _storeFilename = filename; - _filamentDialogMode = 'store'; - // GCode-Filamente aus Store-Datei holen (für Vorschau im Dialog) - var fileObj = storeFiles.find(function (f) { - return f.id === fileId; - }); - try { - _gcodeFilaments = fileObj && fileObj.gcode_filaments ? JSON.parse(fileObj.gcode_filaments) : []; - } catch (e) { - _gcodeFilaments = []; - } - fetch(_apiUrl('/kx/filament/slots')) - .then(function (r) { - return r.json(); - }) - .then(function (d) { - openFilamentDialog(_gcodeFilaments, d.result || []); - }) - .catch(function () { - openFilamentDialog(_gcodeFilaments, []); - }); -} - -function openFilamentDialog(gcodeFilaments, slots) { - // Update the left side of the UI with gcodeFilaments - updateLeftSideUI(gcodeFilaments); - - // Update the right side of the UI with available slots - updateRightSideUI(slots); -} - -function updateLeftSideUI(gcodeFilaments) { - // Logic to populate the left side of the UI with gcodeFilaments - console.log('Updating left side with GCode filaments:', gcodeFilaments); - // Add your UI update logic here -} - -function updateRightSideUI(slots) { - // Logic to populate the right side of the UI with available slots - console.log('Updating right side with available slots:', slots); - // Add your UI update logic here -} \ No newline at end of file diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index a46d9c0..7a2f5a6 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -212,9 +212,10 @@ def _extract_thumbnail(data: bytes) -> str: def _extract_filament_info(data: bytes) -> list[dict]: - """Liest filament_colour + filament_type aus GCode-Header (OrcaSlicer/PrusaSlicer). + """Liest Filament-Farben/Materialien inkl. Tool-Reihenfolge aus Orca/Prusa-GCode. - Gibt Liste von {color_hex, material} pro Slot zurück, leer wenn nicht gefunden. + Gibt Liste von {slot_index, color_hex, material} in Tool-/Paint-Reihenfolge + (T0, T1, ...) zurück. Sucht sowohl am Anfang als auch am Ende der Datei, da Orca große Thumbnail-Blöcke einfügen kann und Metadaten dann im Tail stehen. """ @@ -223,6 +224,8 @@ def _extract_filament_info(data: bytes) -> list[dict]: tail = data[-131072:] if len(data) > 131072 else b"" header = (head + b"\n" + tail).decode("utf-8", errors="ignore") colors, materials = [], [] + paint_count_hint = 0 + tool_filament_order = [] for line in header.splitlines(): if re.match(r"^\s*;\s*filament_colour\s*=", line): val = line.split("=", 1)[-1].strip() @@ -232,15 +235,64 @@ def _extract_filament_info(data: bytes) -> list[dict]: colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()] elif re.match(r"^\s*;\s*filament_type\s*=", line): val = line.split("=", 1)[-1].strip() - materials = [m.strip() for m in val.split(";") if m.strip()] - if not colors: + parts = [m.strip() for m in re.split(r"[;,]", val) if m.strip()] + materials = parts + paint_count_hint = max(paint_count_hint, len(parts)) + elif re.match(r"^\s*;\s*filament_density\s*:", line): + val = line.split(":", 1)[-1].strip() + parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()] + paint_count_hint = max(paint_count_hint, len(parts)) + elif re.match(r"^\s*;\s*filament_diameter\s*:", line): + val = line.split(":", 1)[-1].strip() + parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()] + paint_count_hint = max(paint_count_hint, len(parts)) + elif re.match(r"^\s*;\s*filament\s*:", line): + raw = line.split(":", 1)[-1] + parsed = [] + for p in [x.strip() for x in raw.split(",") if x.strip()]: + try: + parsed.append(int(p)) + except Exception: + pass + if parsed: + tool_filament_order = parsed + total_paints = max(len(colors), len(materials), paint_count_hint) + if tool_filament_order: + total_paints = max(total_paints, max(tool_filament_order)) + if total_paints <= 0: return [] + + # Keep full paint list visible; mark paints referenced by Orca tool order as used. + if len(colors) < total_paints: + colors.extend(["FFFFFF"] * (total_paints - len(colors))) + if len(materials) < total_paints: + materials.extend(["PLA"] * (total_paints - len(materials))) + # Prefer actual tool-change commands from the GCode body. + # This avoids forwarding paints that are present in metadata but never used. + used_paints_zero_based = set() + try: + for m in re.finditer(br"(?m)^[ \t]*T([0-9]+)\b", data): + used_paints_zero_based.add(int(m.group(1))) + except Exception: + used_paints_zero_based = set() + + # Fallback for slicers that only provide paint usage in header metadata. + used_paints_from_header = set() + for n in tool_filament_order: + try: + # Orca/Prusa filament: list is typically 1-based. + used_paints_from_header.add(max(0, int(n) - 1)) + except Exception: + pass + result = [] - for i, hex_color in enumerate(colors): + for i in range(total_paints): + hex_color = colors[i] if i < len(colors) else "FFFFFF" result.append({ "slot_index": i, "color_hex": "#" + hex_color.upper() if hex_color else "#FFFFFF", "material": materials[i] if i < len(materials) else "PLA", + "is_used": (i in used_paints_zero_based) if used_paints_zero_based else ((i in used_paints_from_header) if used_paints_from_header else True), }) return result except Exception: @@ -733,17 +785,19 @@ class KobraXBridge: 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 + # ace_direct exposes exactly 4 channels total. + # If firmware reports multiple ACE boxes, keep only the first one. + if ace_boxes: + ace = ace_boxes[0] + ace_id = ace["id"] + for local_idx, s in enumerate((ace.get("slots") or [])[:4]): + s = dict(s) + s["global_index"] = local_idx + s["box_id"] = ace_id + global_slots.append(s) + ace_loaded = ace.get("loaded_slot", -1) + if 0 <= ace_loaded < 4: + global_loaded = ace_loaded return global_slots, global_loaded # ace_hub @@ -785,45 +839,91 @@ class KobraXBridge: offset = global_index - 3 return offset // 4, offset % 4 + def _slot_to_print_ams_index(self, global_index: int) -> int: + """Convert UI/global slot index to printer print/start ams_index. + + In ace_hub mode, print/start uses global channel numbering where + toolhead channels occupy 1..3 and ACE0 starts at index 4. + """ + idx = int(global_index) + if self._filament_mode == "ace_hub": + box_id, local_slot = self._global_to_box_slot(idx) + if box_id >= 0: + return 4 + box_id * 4 + int(local_slot) + return idx + return idx + + def _slot_usable_for_print(self, global_index: int) -> bool: + """Whether a global slot can be used for current filament mode.""" + slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == int(global_index)), None) + if not slot: + return False + if int(slot.get("status", 0)) != 5: + return False + + box_id = int(slot.get("box_id", -1)) + if self._filament_mode == "ace_hub": + # In hub mode, print channels come from ACE units only. + return box_id >= 0 + if self._filament_mode == "ace_direct": + return box_id >= 0 + return box_id == -1 + + def _loaded_slots_for_print(self) -> list[tuple[int, dict]]: + """Loaded slots filtered for current filament mode.""" + loaded = [ + (int(s.get("global_index", i)), s) + for i, s in enumerate(self._ams_slots) + if s.get("status") == 5 + ] + return loaded + 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 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 + """Build {global_slot_index: loading|unloading} from feed_status data.""" + activity: dict = {} + primary_ace_id = -1 + if self._filament_mode == "ace_direct": + ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0) + if ace_ids: + primary_ace_id = ace_ids[0] + for box in boxes: + if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id: + continue + 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): data = payload.get("data") or {} @@ -1155,7 +1255,15 @@ class KobraXBridge: # Legacy-Einträge ohne gespeicherte Filament-Metadaten nachziehen, # damit Dialog links die GCode-Farben statt AMS-Slots zeigt. for f in files: - if f.get("gcode_filaments"): + needs_refresh = not f.get("gcode_filaments") + if not needs_refresh: + try: + cached = f.get("gcode_filaments") + parsed_cached = cached if isinstance(cached, list) else json.loads(cached) + needs_refresh = any("is_used" not in item for item in (parsed_cached or [])) + except Exception: + needs_refresh = True + if not needs_refresh: continue path = f.get("path") or "" if not path or not os.path.isfile(path): @@ -1189,8 +1297,9 @@ class KobraXBridge: async def handle_kx_filament_slots(self, request): slots = [] for i, s in enumerate(self._ams_slots): + gidx = int(s.get("global_index", i)) slots.append({ - "slot_index": i, + "slot_index": gidx, "material": s.get("type", ""), "color_hex": "#{:02X}{:02X}{:02X}".format(*s.get("color", [0,0,0])[:3]), "status": "loaded" if s.get("status") == 5 else "empty", @@ -1338,20 +1447,45 @@ class KobraXBridge: excluded_objects = [] if assignments: - ams_box_mapping = [ - { - "paint_index": a.get("paint_index", i), - "ams_index": a["slot_index"], + ams_box_mapping = [] + dropped = 0 + for i, a in enumerate(assignments): + try: + if a.get("is_used") is False: + dropped += 1 + continue + global_slot = int(a["slot_index"]) + except (ValueError, TypeError, KeyError): + dropped += 1 + continue + # Skip unused paints (slot_index < 0 means no assignment) + if global_slot < 0: + dropped += 1 + continue + if not self._slot_usable_for_print(global_slot): + dropped += 1 + continue + slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == global_slot), {}) + slot_color = slot.get("color", [255, 255, 255]) + if not (isinstance(slot_color, list) and len(slot_color) >= 3): + slot_color = [255, 255, 255] + slot_rgba = [int(slot_color[0]), int(slot_color[1]), int(slot_color[2]), 255] + ams_box_mapping.append({ + # Preserve slicer paint indices (can be sparse when paint 0 is unused). + "paint_index": a.get("paint_index", i), + "ams_index": self._slot_to_print_ams_index(global_slot), "paint_color": a.get("paint_color", [255, 255, 255, 255]), - "ams_color": a.get("ams_color", [255, 255, 255, 255]), - "material_type": a.get("material", "PLA"), - } - for i, a in enumerate(assignments) - ] + "ams_color": slot_rgba, + "material_type": slot.get("type", a.get("material", "PLA")), + }) + if dropped: + log.warning(f"Ignored {dropped} unused or unusable filament assignments for mode={self._filament_mode}") + if not ams_box_mapping: + return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) else: # Kein Dialog → alle belegten Slots wie bei normalem Upload-Druck default_slot = getattr(self._args, "default_ams_slot", "auto") - all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5] + all_loaded = self._loaded_slots_for_print() if default_slot != "auto": try: slot_idx = int(default_slot) @@ -1362,13 +1496,13 @@ class KobraXBridge: loaded = all_loaded ams_box_mapping = [ { - "paint_index": i, - "ams_index": i, + "paint_index": pidx, + "ams_index": self._slot_to_print_ams_index(gidx), "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], + "ams_color": [int(c[0]), int(c[1]), int(c[2]), 255] if (isinstance(c := s.get("color", [255, 255, 255]), list) and len(c) >= 3) else [255, 255, 255, 255], "material_type": s.get("type", "PLA"), } - for i, s in loaded + for pidx, (gidx, s) in enumerate(loaded) ] use_ams = len(ams_box_mapping) > 0 @@ -1404,6 +1538,12 @@ class KobraXBridge: }, } + log.info( + f"print mapping mode={self._filament_mode} " + f"assignments={assignments if assignments else 'auto'} " + f"ams_box_mapping={ams_box_mapping}" + ) + log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}") loop = asyncio.get_event_loop() result = await loop.run_in_executor( @@ -1627,7 +1767,7 @@ class KobraXBridge: def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0): self._state["file_ready"] = "" default_slot = getattr(self._args, "default_ams_slot", "auto") - all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5] + all_loaded = self._loaded_slots_for_print() if default_slot != "auto": try: slot_idx = int(default_slot) @@ -1642,13 +1782,13 @@ class KobraXBridge: use_ams = len(loaded) > 0 ams_box_mapping = [ { - "paint_index": i, - "ams_index": i, + "paint_index": pidx, + "ams_index": self._slot_to_print_ams_index(gidx), "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], + "ams_color": [int(c[0]), int(c[1]), int(c[2]), 255] if (isinstance(c := s.get("color", [255, 255, 255]), list) and len(c) >= 3) else [255, 255, 255, 255], "material_type": s.get("type", "PLA"), } - for i, s in loaded + for pidx, (gidx, s) in enumerate(loaded) ] log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}") auto_leveling = getattr(self._args, "auto_leveling", 1) @@ -1676,6 +1816,10 @@ class KobraXBridge: "model_objects_skip_parts": [], }, } + log.info( + f"print mapping mode={self._filament_mode} auto_start=1 " + f"ams_box_mapping={ams_box_mapping}" + ) log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots") result = self.client.publish("print", "start", payload, timeout=15.0) if result: @@ -1703,20 +1847,44 @@ class KobraXBridge: if not isinstance(excluded_objects, list): excluded_objects = [] if filament_assignments is not None: - ams_box_mapping = [ - { - "paint_index": a.get("paint_index", i), - "ams_index": a["slot_index"], + ams_box_mapping = [] + dropped = 0 + for i, a in enumerate(filament_assignments): + if a.get("is_used") is False: + dropped += 1 + continue + try: + global_slot = int(a["slot_index"]) + except (ValueError, TypeError, KeyError): + dropped += 1 + continue + # Skip unused paints (slot_index < 0 means no assignment) + if global_slot < 0: + dropped += 1 + continue + if not self._slot_usable_for_print(global_slot): + dropped += 1 + continue + slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == global_slot), {}) + slot_color = slot.get("color", [255, 255, 255]) + if not (isinstance(slot_color, list) and len(slot_color) >= 3): + slot_color = [255, 255, 255] + slot_rgba = [int(slot_color[0]), int(slot_color[1]), int(slot_color[2]), 255] + ams_box_mapping.append({ + "paint_index": a.get("paint_index", i), + "ams_index": self._slot_to_print_ams_index(global_slot), "paint_color": a.get("paint_color", [255, 255, 255, 255]), - "ams_color": a.get("ams_color", [255, 255, 255, 255]), - "material_type": a.get("material", "PLA"), - } - for i, a in enumerate(filament_assignments) - ] + "ams_color": slot_rgba, + "material_type": slot.get("type", a.get("material", "PLA")), + }) + if dropped: + log.warning(f"Ignored {dropped} unused or unusable filament assignments for mode={self._filament_mode}") + if not ams_box_mapping: + return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) else: # AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen default_slot = getattr(self._args, "default_ams_slot", "auto") - all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5] + all_loaded = self._loaded_slots_for_print() if default_slot != "auto": try: slot_idx = int(default_slot) @@ -1727,13 +1895,13 @@ class KobraXBridge: loaded = all_loaded ams_box_mapping = [ { - "paint_index": i, - "ams_index": i, + "paint_index": pidx, + "ams_index": self._slot_to_print_ams_index(gidx), "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], + "ams_color": [int(c[0]), int(c[1]), int(c[2]), 255] if (isinstance(c := s.get("color", [255, 255, 255]), list) and len(c) >= 3) else [255, 255, 255, 255], "material_type": s.get("type", "PLA"), } - for i, s in loaded + for pidx, (gidx, s) in enumerate(loaded) ] use_ams = len(ams_box_mapping) > 0 @@ -1767,6 +1935,12 @@ class KobraXBridge: }, } + log.info( + f"print mapping mode={self._filament_mode} print_start_api=1 " + f"assignments={filament_assignments if filament_assignments is not None else 'auto'} " + f"ams_box_mapping={ams_box_mapping}" + ) + loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, lambda: self.client.publish("print", "start", payload, timeout=15.0) @@ -3314,9 +3488,6 @@ function applyState(){ var activity=(slot.activity||''); var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–'); var slotLabel='Slot '+(globalIdx+1); - if(bid>=0){ - slotLabel='ACE '+(bid+1)+'.'+(slot.index!=null?slot.index+1:(i-2)); - } html+='
Keine belegten AMS-Slots.
Druck trotzdem starten?