diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index f6070a9..88ba52f 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -212,33 +212,91 @@ 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. - Liest nur die ersten 8KB (Header-Bereich). - """ + 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. + """ + try: + head = data[:131072] + 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() + colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()] + elif re.match(r"^\s*;\s*filament_multi_colour\s*=", line) and not colors: + val = line.split("=", 1)[-1].strip() + 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() + 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: - header = data[:8192].decode("utf-8", errors="ignore") - colors, materials = [], [] - for line in header.splitlines(): - if line.startswith("; filament_colour"): - val = line.split("=", 1)[-1].strip() - colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()] - elif line.startswith("; filament_type"): - val = line.split("=", 1)[-1].strip() - materials = [m.strip() for m in val.split(";") if m.strip()] - if not colors: - return [] - result = [] - for i, hex_color in enumerate(colors): - result.append({ - "slot_index": i, - "color_hex": "#" + hex_color.upper() if hex_color else "#FFFFFF", - "material": materials[i] if i < len(materials) else "PLA", - }) - return result + for m in re.finditer(br"(?m)^[ \t]*T([0-9]+)\b", data): + used_paints_zero_based.add(int(m.group(1))) except Exception: - return [] + 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 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: + return [] class GCodeStore: @@ -351,6 +409,15 @@ class GCodeStore: ) self._conn.commit() + def update_file_filaments(self, file_id: str, gcode_filaments: list | None) -> None: + """Aktualisiert geparste GCode-Filamente für einen bestehenden DB-Eintrag.""" + with self._lock: + self._conn.execute( + "UPDATE gcode_files SET gcode_filaments=? WHERE id=?", + (json.dumps(gcode_filaments) if gcode_filaments else None, file_id), + ) + self._conn.commit() + def delete_file(self, file_id: str) -> bool: row = self.get_file(file_id) if not row: @@ -718,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 @@ -770,45 +839,180 @@ 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, toolhead channels (0..2) and ACE channels are both printable. + return box_id == -1 or 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 and self._slot_usable_for_print(int(s.get("global_index", i))) + ] + return loaded + + def _select_loaded_slots_for_print(self, warn_on_empty_default: bool = False) -> list[tuple[int, dict]]: + """Return loaded slots, honoring default_ams_slot when configured.""" + default_slot = getattr(self._args, "default_ams_slot", "auto") + all_loaded = self._loaded_slots_for_print() + if default_slot == "auto": + return all_loaded + + try: + slot_idx = int(default_slot) + except ValueError: + return all_loaded + + selected = [(i, s) for i, s in all_loaded if i == slot_idx] + if selected: + return selected + + if warn_on_empty_default: + log.warning(f"Standard-Slot {slot_idx} ist leer – fallback auf Auto") + return all_loaded + + @staticmethod + def _slot_color_rgba(slot: dict) -> list[int]: + color = slot.get("color", [255, 255, 255]) + if isinstance(color, list) and len(color) >= 3: + return [int(color[0]), int(color[1]), int(color[2]), 255] + return [255, 255, 255, 255] + + def _build_auto_ams_box_mapping( + self, + warn_on_empty_default: bool = False, + loaded_slots: list[tuple[int, dict]] | None = None, + ) -> list[dict]: + """Build print mapping from currently loaded slots (no explicit dialog assignments).""" + loaded = loaded_slots + if loaded is None: + loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default) + return [ + { + "paint_index": pidx, + "ams_index": self._slot_to_print_ams_index(gidx), + "paint_color": [255, 255, 255, 255], + "ams_color": self._slot_color_rgba(s), + "material_type": s.get("type", "PLA"), + } + for pidx, (gidx, s) in enumerate(loaded) + ] + + def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: + """Build print mapping from UI filament assignments. + + Returns (mapping, unused_count, invalid_count). + """ + slot_by_global_index = { + int(s.get("global_index", i)): s + for i, s in enumerate(self._ams_slots) + } + ams_box_mapping: list[dict] = [] + unused_count = 0 + invalid_count = 0 + + for i, a in enumerate(assignments): + try: + if a.get("is_used") is False: + unused_count += 1 + continue + global_slot = int(a["slot_index"]) + except (ValueError, TypeError, KeyError): + invalid_count += 1 + continue + + if global_slot < 0: + unused_count += 1 + continue + if not self._slot_usable_for_print(global_slot): + invalid_count += 1 + continue + + slot = slot_by_global_index.get(global_slot, {}) + 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": self._slot_color_rgba(slot), + "material_type": slot.get("type", a.get("material", "PLA")), + }) + + return ams_box_mapping, unused_count, invalid_count + 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 {} @@ -962,63 +1166,57 @@ class KobraXBridge: } def _build_lane_data(self) -> dict: - """Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0.""" - slots = self._ams_slots - total = len(slots) - if total == 0: - return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"} + """Build lane_data for filament sync from loaded, printable slots only. - ams_count = (total + 3) // 4 - ams_exist_bits = 0 - tray_exist_bits = 0 - ams_array = [] + Slots are compacted in sync order (installed slots first) so Orca/Happy + Hare does not infer empty gaps between tray ids. + """ + loaded_slots = self._loaded_slots_for_print() + if not loaded_slots: + return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"} - for ams_id in range(ams_count): - ams_exist_bits |= (1 << ams_id) - tray_array = [] - max_slot = min(3, total - ams_id * 4 - 1) - for slot_id in range(max_slot + 1): - slot_index = ams_id * 4 + slot_id - slot = slots[slot_index] if slot_index < total else {} - occupied = slot.get("status") == 5 + ams_buckets: dict[int, list[tuple[int, dict]]] = {} + tray_exist_bits = 0 - if occupied: - tray_exist_bits |= (1 << slot_index) - color_raw = slot.get("color", [255, 255, 255]) - if isinstance(color_raw, list) and len(color_raw) >= 3: - color_hex = "{:02X}{:02X}{:02X}FF".format( - int(color_raw[0]), int(color_raw[1]), int(color_raw[2]) - ) - elif isinstance(color_raw, str) and len(color_raw) >= 6: - color_hex = color_raw[:6].upper() + "FF" - else: - color_hex = "FFFFFFFF" - material = slot.get("type", "PLA").upper() - tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99") - tray_array.append({ - "id": str(slot_id), - "tag_uid": "0000000000000000", - "tray_info_idx": tray_info_idx, - "tray_type": material, - "tray_color": color_hex, - }) - else: - tray_array.append({ - "id": str(slot_id), - "tag_uid": "0000000000000000", - "tray_info_idx": "", - "tray_type": "", - "tray_color": "00000000", - "tray_slot_placeholder": "1", - }) + for sync_index, (_global_index, slot) in enumerate(sorted(loaded_slots, key=lambda item: item[0])): + ams_id = sync_index // 4 + slot_id = sync_index % 4 + tray_exist_bits |= (1 << sync_index) + ams_buckets.setdefault(ams_id, []).append((slot_id, slot)) - ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array}) + ams_exist_bits = 0 + ams_array = [] + for ams_id in sorted(ams_buckets.keys()): + ams_exist_bits |= (1 << ams_id) + tray_array = [] + for slot_id, slot in sorted(ams_buckets[ams_id], key=lambda item: item[0]): + color_raw = slot.get("color", [255, 255, 255]) + if isinstance(color_raw, list) and len(color_raw) >= 3: + color_hex = "{:02X}{:02X}{:02X}FF".format( + int(color_raw[0]), int(color_raw[1]), int(color_raw[2]) + ) + elif isinstance(color_raw, str) and len(color_raw) >= 6: + color_hex = color_raw[:6].upper() + "FF" + else: + color_hex = "FFFFFFFF" - return { - "ams": ams_array, - "ams_exist_bits": format(ams_exist_bits, "X"), - "tray_exist_bits": format(tray_exist_bits, "X"), - } + material = slot.get("type", "PLA").upper() + tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99") + tray_array.append({ + "id": str(slot_id), + "tag_uid": "0000000000000000", + "tray_info_idx": tray_info_idx, + "tray_type": material, + "tray_color": color_hex, + }) + + ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array}) + + return { + "ams": ams_array, + "ams_exist_bits": format(ams_exist_bits, "X"), + "tray_exist_bits": format(tray_exist_bits, "X"), + } # ------------------------------------------------------------------------- # WebSocket push @@ -1042,40 +1240,39 @@ class KobraXBridge: self.ws_clients -= dead def _build_mmu_object(self) -> dict: - slots = self._ams_slots - if not slots: - return {} - _TEMP = {"PLA": 210, "PETG": 230, "ABS": 240, "ASA": 250, - "TPU": 220, "PA": 260, "PC": 270, "HIPS": 220} - num_gates = len(slots) - gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], [] - for slot in slots: - occupied = slot.get("status") == 5 - gate_status.append(1 if occupied else 0) - material = slot.get("type", "PLA").upper() if occupied else "" - gate_material.append(material) - c = slot.get("color", [0, 0, 0]) - if occupied: - gate_color.append("#{:02X}{:02X}{:02X}".format(*c[:3])) - gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)]) - else: - gate_color.append("") - gate_color_rgb.append([0.0, 0.0, 0.0]) - gate_temperature.append(_TEMP.get(material, 210) if occupied else 0) - return { - "num_gates": num_gates, - "enabled": True, - "gate_status": gate_status, - "gate_material": gate_material, - "gate_color": gate_color, - "gate_temperature": gate_temperature, - "gate_color_rgb": gate_color_rgb, - "gate_filament_name": [""] * num_gates, - "gate_spool_id": [-1] * num_gates, - "ttg_map": list(range(num_gates)), - "tool": self._ams_loaded_slot, - "gate": self._ams_loaded_slot, - } + loaded_slots = sorted(self._loaded_slots_for_print(), key=lambda item: item[0]) + if not loaded_slots: + return {} + + _TEMP = {"PLA": 210, "PETG": 230, "ABS": 240, "ASA": 250, + "TPU": 220, "PA": 260, "PC": 270, "HIPS": 220} + num_gates = len(loaded_slots) + gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], [] + for _global_index, slot in loaded_slots: + gate_status.append(1) + material = slot.get("type", "PLA").upper() + gate_material.append(material) + c = slot.get("color", [0, 0, 0]) + gate_color.append("#{:02X}{:02X}{:02X}".format(*c[:3])) + gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)]) + gate_temperature.append(_TEMP.get(material, 210)) + + loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(loaded_slots)} + active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1) + return { + "num_gates": num_gates, + "enabled": True, + "gate_status": gate_status, + "gate_material": gate_material, + "gate_color": gate_color, + "gate_temperature": gate_temperature, + "gate_color_rgb": gate_color_rgb, + "gate_filament_name": [""] * num_gates, + "gate_spool_id": [-1] * num_gates, + "ttg_map": list(range(num_gates)), + "tool": active_gate, + "gate": active_gate, + } def _build_printer_objects(self) -> dict: s = self._state @@ -1137,6 +1334,30 @@ class KobraXBridge: async def handle_kx_files(self, request): files = self._store.list_files() + # Legacy-Einträge ohne gespeicherte Filament-Metadaten nachziehen, + # damit Dialog links die GCode-Farben statt AMS-Slots zeigt. + for f in files: + 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): + continue + try: + with open(path, "rb") as fh: + parsed_filaments = _extract_filament_info(fh.read()) + if parsed_filaments: + f["gcode_filaments"] = json.dumps(parsed_filaments) + self._store.update_file_filaments(f["id"], parsed_filaments) + except Exception: + pass # Letzten Job-Status + Dauer pro Datei ergänzen jobs = self._store.list_jobs(limit=500) last_job: dict = {} @@ -1158,8 +1379,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", @@ -1307,38 +1529,16 @@ class KobraXBridge: excluded_objects = [] if assignments: - ams_box_mapping = [ - { - "paint_index": a.get("paint_index", i), - "ams_index": a["slot_index"], - "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_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(assignments) + if unused_count: + log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") + if invalid_count: + log.warning(f"Ignored {invalid_count} unusable filament assignment(s) 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] - if default_slot != "auto": - try: - slot_idx = int(default_slot) - loaded = [(i, s) for i, s in all_loaded if i == slot_idx] or all_loaded - except ValueError: - loaded = all_loaded - else: - loaded = all_loaded - ams_box_mapping = [ - { - "paint_index": i, - "ams_index": i, - "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], - "material_type": s.get("type", "PLA"), - } - for i, s in loaded - ] + ams_box_mapping = self._build_auto_ams_box_mapping() use_ams = len(ams_box_mapping) > 0 auto_leveling = getattr(self._args, "auto_leveling", 1) @@ -1448,12 +1648,14 @@ class KobraXBridge: async def handle_objects_query(self, request): objects = self._build_printer_objects() - # filter by requested objects if specified - requested = dict(request.rel_url.query) - if requested: - filtered = {k: objects[k] for k in requested if k in objects} - else: - filtered = objects + requested = [] + query = request.rel_url.query + if "objects" in query: + requested = [x.strip() for x in str(query.get("objects", "")).split(",") if x.strip()] + elif query: + requested = [k for k in query.keys() if k] + + filtered = {k: objects[k] for k in requested if k in objects} if requested else objects return web.json_response({"result": {"status": filtered, "eventtime": time.time()}}) async def handle_objects_list(self, request): @@ -1595,31 +1797,10 @@ 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] - if default_slot != "auto": - try: - slot_idx = int(default_slot) - loaded = [(i, s) for i, s in all_loaded if i == slot_idx] - if not loaded: - log.warning(f"Standard-Slot {slot_idx} ist leer – fallback auf Auto") - loaded = all_loaded - except ValueError: - loaded = all_loaded - else: - loaded = all_loaded + loaded = self._select_loaded_slots_for_print(warn_on_empty_default=True) use_ams = len(loaded) > 0 - ams_box_mapping = [ - { - "paint_index": i, - "ams_index": i, - "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], - "material_type": s.get("type", "PLA"), - } - for i, s in loaded - ] - log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}") + ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) + log.debug(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i, _ in loaded]}") auto_leveling = getattr(self._args, "auto_leveling", 1) payload = { "taskid": "-1", @@ -1645,7 +1826,7 @@ class KobraXBridge: "model_objects_skip_parts": [], }, } - log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots") + log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}") result = self.client.publish("print", "start", payload, timeout=15.0) if result: log.info(f"Druckstart bestätigt: state={result.get('state')}") @@ -1672,38 +1853,16 @@ 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"], - "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_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) + if unused_count: + log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") + if invalid_count: + log.warning(f"Ignored {invalid_count} unusable filament assignment(s) 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] - if default_slot != "auto": - try: - slot_idx = int(default_slot) - loaded = [(i, s) for i, s in all_loaded if i == slot_idx] or all_loaded - except ValueError: - loaded = all_loaded - else: - loaded = all_loaded - ams_box_mapping = [ - { - "paint_index": i, - "ams_index": i, - "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], - "material_type": s.get("type", "PLA"), - } - for i, s in loaded - ] + ams_box_mapping = self._build_auto_ams_box_mapping() use_ams = len(ams_box_mapping) > 0 auto_leveling = getattr(self._args, "auto_leveling", 1) @@ -1736,6 +1895,11 @@ class KobraXBridge: }, } + log.info( + f"print/start api=1 mode={self._filament_mode} " + f"ams={len(ams_box_mapping)} slots assignments={filament_assignments is not None}" + ) + loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, lambda: self.client.publish("print", "start", payload, timeout=15.0) @@ -3283,9 +3447,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+='
' +'
' @@ -4101,14 +4262,27 @@ var _filamentDialogMode='store'; // 'store' oder 'banner' var _gcodeFilaments=[]; +function _setGcodeFilamentsFromFileObj(fileObj){ + try{ + if(fileObj&&Array.isArray(fileObj.gcode_filaments)){ + _gcodeFilaments=fileObj.gcode_filaments; + }else if(fileObj&&typeof fileObj.gcode_filaments==='string'&&fileObj.gcode_filaments){ + _gcodeFilaments=JSON.parse(fileObj.gcode_filaments); + }else{ + _gcodeFilaments=[]; + } + }catch(e){ + _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=[]; } + _setGcodeFilamentsFromFileObj(fileObj); fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){ openFilamentDialog(d.result||[]); }).catch(function(){openFilamentDialog([]);}); @@ -4117,9 +4291,36 @@ function storePrint(fileId, filename){ function startReadyFileWithSlots(){ _filamentDialogMode='banner'; _storeFilename=S.file_ready||''; - fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){ - openFilamentDialog(d.result||[]); - }).catch(function(){openFilamentDialog([]);}); + // Banner must never reuse stale store-file context. + _storeFileId=null; + _gcodeFilaments=[]; + + function openWithSlots(){ + fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){ + openFilamentDialog(d.result||[]); + }).catch(function(){openFilamentDialog([]);}); + } + + var fileObj=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;}); + if(fileObj){ + _storeFileId=fileObj.id; + _setGcodeFilamentsFromFileObj(fileObj); + openWithSlots(); + return; + } + + // Fallback: refresh file list, then resolve current file by filename. + fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){ + storeFiles=d.result||[]; + var refreshed=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;}); + if(refreshed){ + _storeFileId=refreshed.id; + _setGcodeFilamentsFromFileObj(refreshed); + } + openWithSlots(); + }).catch(function(){ + openWithSlots(); + }); } var _amsSlots=[]; @@ -4137,6 +4338,15 @@ function _contrastText(hex){ var y=(r*299 + g*587 + b*114)/1000; return y>=140?'#111':'#fff'; } +function _normalizeMaterialKey(material){ + var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,''); + // Orca often uses PLA for PLA+, while AMS may report PLA+. + if(key==='PLA+'||key==='PLAPLUS') return 'PLA'; + return key; +} +function _materialsCompatible(a,b){ + return _normalizeMaterialKey(a)===_normalizeMaterialKey(b); +} function _updateSlotMarker(sel){ var opt=sel.options[sel.selectedIndex]; var color=opt&&opt.dataset.color?opt.dataset.color:'#888'; @@ -4151,7 +4361,9 @@ function _updateSlotMarker(sel){ } function openFilamentDialog(slots){ - _amsSlots=slots.filter(function(s){return s.status==='loaded';}); + _amsSlots=slots + .filter(function(s){return s.status==='loaded';}) + .sort(function(a,b){return (a.slot_index||0)-(b.slot_index||0);}); var dlg=document.getElementById('filament-dialog'); var title=document.getElementById('fd-title'); var body=document.getElementById('fd-slots'); @@ -4161,7 +4373,7 @@ function openFilamentDialog(slots){ _printObjectsSvg=''; var objSection=document.getElementById('fd-objects-section'); if(objSection)objSection.style.display='none'; - if(_storeFileId){ + if(_filamentDialogMode==='store'&&_storeFileId){ fetch(_apiUrl('/kx/files/'+encodeURIComponent(_storeFileId)+'/objects')) .then(function(r){return r.json()}) .then(function(d){ @@ -4180,30 +4392,79 @@ function openFilamentDialog(slots){ return {slot_index:i,color_hex:s.color_hex,material:s.material}; }); + // Default mapping strategy: + // 1) keep order where possible (row i -> nearest compatible slot i) + // 2) keep defaults unique while compatible slots are available + // 3) use color proximity as tie-breaker + function _hexToRgb(hex){ + var c=(hex||'').replace('#',''); + if(c.length===3)c=c[0]+c[0]+c[1]+c[1]+c[2]+c[2]; + if(c.length<6)return [255,255,255]; + return [parseInt(c.slice(0,2),16),parseInt(c.slice(2,4),16),parseInt(c.slice(4,6),16)]; + } + function _colorDist(a,b){ + var ar=_hexToRgb(a), br=_hexToRgb(b); + var dr=ar[0]-br[0], dg=ar[1]-br[1], db=ar[2]-br[2]; + return (dr*dr + dg*dg + db*db); + } + var defaultSlotByPaint={}; + var usedDefaultSlot={}; + channels.forEach(function(gc,i){ + var compatible=_amsSlots.filter(function(s){ + return _materialsCompatible(gc.material, s.material); + }); + if(!compatible.length){ + defaultSlotByPaint[i]=-1; + return; + } + + var ranked=compatible.slice().sort(function(a,b){ + var da=Math.abs((a.slot_index||0)-i), db=Math.abs((b.slot_index||0)-i); + if(da!==db)return da-db; + var ca=_colorDist(gc.color_hex, a.color_hex), cb=_colorDist(gc.color_hex, b.color_hex); + if(ca!==cb)return ca-cb; + return (a.slot_index||0)-(b.slot_index||0); + }); + + var chosen=ranked.find(function(s){return !usedDefaultSlot[s.slot_index];}) || ranked[0]; + defaultSlotByPaint[i]=chosen?chosen.slot_index:-1; + if(chosen) usedDefaultSlot[chosen.slot_index]=1; + }); + if(!_amsSlots.length){ body.innerHTML='

Keine belegten AMS-Slots.
Druck trotzdem starten?

'; } else { body.innerHTML=channels.map(function(gc,i){ - // Passende Slots: gleicher Materialtyp - var compatible=_amsSlots.filter(function(s){return s.material.toUpperCase()===gc.material.toUpperCase();}); - if(!compatible.length) compatible=_amsSlots; // Fallback: alle - // Standard-Auswahl: Slot mit gleichem Index oder erster kompatibler - var defaultSlot=compatible.find(function(s){return s.slot_index===gc.slot_index;})||compatible[0]; + var isUsed=(gc&&gc.is_used!==false); + // Only allow material-compatible slots. + var compatible=_amsSlots.filter(function(s){ + return _materialsCompatible(gc.material, s.material); + }); + + var defaultSlotIndex=(defaultSlotByPaint.hasOwnProperty(i)?defaultSlotByPaint[i]:-1); + var defaultSlot=compatible.find(function(s){return s.slot_index===defaultSlotIndex;})||null; var opts=compatible.map(function(s){ - var sel=(s.slot_index===defaultSlot.slot_index)?'selected':''; + var sel=(defaultSlot&&s.slot_index===defaultSlot.slot_index)?'selected':''; return ''; }).join(''); + if(!compatible.length){ + opts=''; + } // Kanal-Box (links): farbige Box mit Nummer + auto Kontrast-Text var txt=_contrastText(gc.color_hex); var slotColor=defaultSlot?defaultSlot.color_hex:'#888'; var slotTxt=_contrastText(slotColor); + var usedBadge=isUsed + ? 'USED' + : 'USED'; return '
'+ ''+(i+1)+''+ ''+gc.material+''+ + usedBadge+ ''+ ''+(defaultSlot?defaultSlot.slot_index+1:'?')+''+ - ''+ opts+''+ '
'; }).join(''); @@ -4219,11 +4480,18 @@ function closeFilamentDialog(){ function confirmFilamentPrint(){ var selects=document.querySelectorAll('#fd-slots select'); var assignments=[]; + var missingCompatible=0; selects.forEach(function(sel){ var paintIdx=parseInt(sel.dataset.paint); var paintColor=sel.dataset.paintColor; + var isUsed=(sel.dataset.isUsed==='1'); + var hasCompatible=(sel.dataset.hasCompatible==='1'); var opt=sel.options[sel.selectedIndex]; - var amsIdx=parseInt(opt.value); + var amsIdx=parseInt(opt&&opt.value); + if(!hasCompatible || Number.isNaN(amsIdx) || amsIdx < 0){ + if(isUsed) missingCompatible += 1; + amsIdx = -1; + } var amsSlot=_amsSlots.find(function(s){return s.slot_index===amsIdx;})||{}; // Farbe als [R,G,B,255] function hexToRgba(h){ @@ -4233,12 +4501,17 @@ function confirmFilamentPrint(){ } assignments.push({ paint_index: paintIdx, + is_used: isUsed, slot_index: amsIdx, material: opt.dataset.material||'PLA', paint_color: hexToRgba(paintColor||'#ffffff'), ams_color: hexToRgba(amsSlot.color_hex||'#ffffff'), }); }); + if(missingCompatible>0){ + clog('Cannot start print: '+missingCompatible+' used paint(s) have no matching material slot','msg-err'); + return; + } // Pre-Print Skip: Namen der abgehakten Objekte sammeln var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;}); closeFilamentDialog();