""" gcode_meta.py - GCode file metadata extraction helpers (estimated print time, layer heights, embedded thumbnail, per-slot filament info). Extracted from kobrax_moonraker_bridge.py; re-exported from there so existing call sites keep working. Used by the file-upload and print-start paths. ──────────────────────────────────────────────────────────────────────────── Copyright (C) 2026 viewit (KX-Bridge contributors) Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md. """ import re import base64 import logging log = logging.getLogger("bridge") def _parse_gcode_estimated_time(data: bytes) -> int: """Reads the estimated print time from GCode (OrcaSlicer + PrusaSlicer). Returns seconds, 0 when not found. PrusaSlicer writes the time into the header (first 16KB), OrcaSlicer writes it at the end of the file (last 16KB).""" import re # Search the beginning + end of the file (OrcaSlicer writes the time at the end) search_text = (data[:16384] + data[-65536:]).decode("utf-8", errors="ignore") # OrcaSlicer: ; total estimated time: 9m 20s # PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s m = (re.search(r";\s*total estimated time:\s*(.*)", search_text) or re.search(r";\s*estimated printing time \(normal mode\)\s*=\s*(.*)", search_text)) if not m: return 0 parts = re.findall(r"(\d+)\s*([hms])", m.group(1)) secs = 0 for val, unit in parts: if unit == "h": secs += int(val) * 3600 elif unit == "m": secs += int(val) * 60 elif unit == "s": secs += int(val) if secs: log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})") return secs def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]: """Reads (layer_height, initial_layer_height) from the OrcaSlicer/PrusaSlicer GCode header. Both are stored as a config block at the end of the GCode. Beispiel-Zeilen: ; layer_height = 0.2 ; initial_layer_print_height = 0.2 Returns (0.0, 0.0) when not found - the caller decides what to do (typisch: keinen Z-Wert anzeigen).""" import re head = data[:16384].decode("utf-8", errors="ignore") tail = data[-65536:].decode("utf-8", errors="ignore") search = head + "\n" + tail def _grab(pat): m = re.search(pat, search) if not m: return 0.0 try: return float(m.group(1)) except Exception: return 0.0 layer_h = _grab(r";\s*layer_height\s*=\s*([0-9.]+)") first_h = (_grab(r";\s*initial_layer_print_height\s*=\s*([0-9.]+)") or _grab(r";\s*first_layer_height\s*=\s*([0-9.]+)") or layer_h) return layer_h, first_h def _extract_thumbnail(data: bytes) -> str: """Extracts the base64 PNG thumbnail from GCode (OrcaSlicer format).""" try: marker = b"; thumbnail begin" end_marker = b"; thumbnail end" start = data.find(marker) if start == -1: return "" start = data.find(b"\n", start) + 1 end = data.find(end_marker, start) if end == -1: return "" lines = data[start:end].split(b"\n") b64 = b"".join( line[2:].strip() if line.startswith(b"; ") else line.strip() for line in lines ) return b64.decode("ascii") except Exception: return "" def _extract_filament_info(data: bytes) -> list[dict]: """Reads filament colors/materials incl. tool order from Orca/Prusa GCode. Gibt Liste von {slot_index, color_hex, material} in Tool-/Paint-Reihenfolge (T0, T1, ...). Searches both the start and the end of the file since Orca can insert large thumbnail blocks, pushing the metadata into the tail. """ 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: 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 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 []