First stage of splitting the 6368-line kobrax_moonraker_bridge.py monolith.
The low-coupling, self-contained pieces move into their own modules;
kobrax_moonraker_bridge.py re-exports them so every caller (12 test files,
the PyInstaller spec) keeps working unchanged - not a single test or the
spec needed editing.
Extracted:
- spoolman_client.py <- SpoolmanClient (zero coupling)
- gcode_store.py <- GCodeStore (stdlib only)
- gcode_meta.py <- _parse_gcode_*/_extract_* metadata helpers
- camera.py <- CameraCache + _find_ffmpeg
- bridge_logging.py <- _BrowserLogHandler, the log ring buffer + SSE
queues, _set_verbose_http_log (the shared mutable
buffer/queues are re-imported so the log endpoints
still operate on the same objects the handler writes)
Facade shrinks from 6368 to 5566 lines. All 184 tests green after each
extraction. Verified the re-exported names resolve and the shared log
objects are identical by reference across modules. No behavior change.
183 lines
7.2 KiB
Python
183 lines
7.2 KiB
Python
"""
|
|
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 []
|