281 lines
9.8 KiB
Python
281 lines
9.8 KiB
Python
"""Anycubic ACE Sidecar (.acm) für Moonraker-Start."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
|
||
# Rinkhals mmu_ace: ohne `; referenced_tools = …` kein Auto-Feed / Nachschub.
|
||
TRAILER_WARN = "ACE-Nachschub braucht Slicer-Metadaten/Trailer"
|
||
|
||
|
||
def color_channels(value):
|
||
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||
out = []
|
||
for channel in value[:3]:
|
||
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
|
||
return []
|
||
out.append(max(0, min(255, int(round(float(channel))))))
|
||
return out
|
||
if value in (None, ""):
|
||
return []
|
||
text = str(value).strip()
|
||
if text.startswith("#"):
|
||
text = text[1:]
|
||
text = re.sub(r"[^0-9A-Fa-f]", "", text)
|
||
if len(text) == 8:
|
||
# Moonraker mmu.gate_color: RRGGBBAA
|
||
text = text[:6]
|
||
if len(text) != 6:
|
||
return []
|
||
try:
|
||
return [int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)]
|
||
except ValueError:
|
||
return []
|
||
|
||
|
||
def acm_filename(gcode_name):
|
||
"""Sidecar neben dem Start-Dateinamen: `job.gcode` → `job.gcode.acm`."""
|
||
name = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1]
|
||
if name.lower().endswith(".acm"):
|
||
return name
|
||
return name + ".acm"
|
||
|
||
|
||
def acm_upload_names(gcode_name):
|
||
"""Primärer Basename wie Start-filename, plus Stamm-Variante zum Überschreiben.
|
||
|
||
Anycubic/Rinkhals erwarten typisch `name.gcode.acm`. Parallel kann eine
|
||
ältere Identity-Map als `name.acm` liegen und Grotti-Map verdrängen.
|
||
"""
|
||
primary = acm_filename(gcode_name)
|
||
names = [primary]
|
||
base = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1]
|
||
if base.lower().endswith(".acm"):
|
||
base = base[: -len(".acm")]
|
||
if base.lower().endswith(".gcode"):
|
||
alt = base[: -len(".gcode")] + ".acm"
|
||
if alt.lower() != primary.lower():
|
||
names.append(alt)
|
||
return names
|
||
|
||
|
||
def normalize_mapping(raw):
|
||
if not isinstance(raw, list):
|
||
return []
|
||
out = []
|
||
seen = set()
|
||
for item in raw:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
try:
|
||
paint = int(item.get("paint_index"))
|
||
ams = int(item.get("ams_index"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
# paint_index = Slicer-T (oft 0–15 bei Bambu), ams_index = ACE-Platz 0–7
|
||
if paint < 0 or paint > 63 or ams < 0 or ams > 7 or paint in seen:
|
||
continue
|
||
seen.add(paint)
|
||
out.append({"paint_index": paint, "ams_index": ams})
|
||
return out
|
||
|
||
|
||
def filament_for_paint(filaments, paint):
|
||
"""Filament-Eintrag zur Slicer-T-Nummer; Fallback Array-Index für Alt-Daten."""
|
||
filaments = filaments or []
|
||
try:
|
||
paint = int(paint)
|
||
except (TypeError, ValueError):
|
||
return {}
|
||
for index, row in enumerate(filaments):
|
||
if not isinstance(row, dict):
|
||
continue
|
||
try:
|
||
key = int(row.get("paint_index"))
|
||
except (TypeError, ValueError):
|
||
key = index
|
||
if key == paint:
|
||
return row
|
||
if 0 <= paint < len(filaments) and isinstance(filaments[paint], dict):
|
||
return filaments[paint]
|
||
return {}
|
||
|
||
|
||
def densify_mapping(mapping):
|
||
"""Slicer-T mit Lücken (T1/T10) → dichte 0…n−1 für gklib/ACE.
|
||
|
||
gklib meldet bei zu hoher Filament-ID ``invalid filament id`` (10011700).
|
||
Rückgabe: (remap alt→neu, dichte Map). Identity → ({}, unverändert).
|
||
"""
|
||
mapping = normalize_mapping(mapping)
|
||
paints = sorted(item["paint_index"] for item in mapping)
|
||
if not paints or paints == list(range(len(paints))):
|
||
return {}, mapping
|
||
remap = {old: new for new, old in enumerate(paints)}
|
||
dense = [
|
||
{"paint_index": remap[item["paint_index"]], "ams_index": item["ams_index"]}
|
||
for item in mapping
|
||
]
|
||
return remap, dense
|
||
|
||
|
||
def remap_filaments(filaments, remap):
|
||
"""paint_index der Filament-Metadaten mit densify-Remap mitziehen."""
|
||
if not remap:
|
||
return list(filaments or [])
|
||
out = []
|
||
for row in filaments or []:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
copy = dict(row)
|
||
try:
|
||
old = int(row.get("paint_index"))
|
||
except (TypeError, ValueError):
|
||
out.append(copy)
|
||
continue
|
||
if old in remap:
|
||
copy["paint_index"] = remap[old]
|
||
out.append(copy)
|
||
return out
|
||
|
||
|
||
def align_tools_for_ace(content, mapping, filaments=None):
|
||
"""G-code-T und ACE-Map auf dichte 0…n−1 bringen.
|
||
|
||
Maßgeblich sind die ``Tn`` im G-code. Die UI-Map darf echte Slicer-Indizes
|
||
(T1/T10) oder fälschlich kompakte 0/1 haben — ACE-Plätze werden in
|
||
Sortierreihenfolge der Map-Einträge den sortierten T-Nummern zugeordnet.
|
||
Sonst: ACM 0/1 + G-code T10 → 10011700 invalid filament id.
|
||
"""
|
||
from grotti.parse import tools_used_in_gcode
|
||
|
||
mapping = normalize_mapping(mapping)
|
||
used = sorted(tools_used_in_gcode(content))
|
||
if not mapping:
|
||
return content, mapping, list(filaments or []), {}
|
||
if not used:
|
||
remap, dense = densify_mapping(mapping)
|
||
new_content = rewrite_gcode_tools(content, remap) if remap else content
|
||
return new_content, dense, remap_filaments(filaments, remap), remap
|
||
|
||
if len(mapping) != len(used):
|
||
from grotti.errors import GrottiError
|
||
|
||
raise GrottiError(
|
||
"ACE-Zuordnung (%d Farben) passt nicht zum G-code (T%s). "
|
||
"Datei neu lesen und Zuordnung erneut setzen."
|
||
% (len(mapping), ", T".join(str(t) for t in used)),
|
||
"ace",
|
||
)
|
||
|
||
mapped_paints = {item["paint_index"] for item in mapping}
|
||
paints_match_gcode = mapped_paints == set(used)
|
||
if paints_match_gcode:
|
||
ams_by_tool = {item["paint_index"]: item["ams_index"] for item in mapping}
|
||
ams_order = [ams_by_tool[tool] for tool in used]
|
||
else:
|
||
# UI kompakt 0…n−1, G-code sparse — Reihenfolge der Map = Reihenfolge der Farben
|
||
ordered = sorted(mapping, key=lambda item: item["paint_index"])
|
||
ams_order = [item["ams_index"] for item in ordered]
|
||
|
||
remap = {old: new for new, old in enumerate(used)}
|
||
dense = [
|
||
{"paint_index": index, "ams_index": ams_order[index]}
|
||
for index in range(len(used))
|
||
]
|
||
|
||
source = list(filaments or [])
|
||
if paints_match_gcode:
|
||
new_fils = remap_filaments(source, remap)
|
||
else:
|
||
# Metadaten folgen der Map-Reihenfolge (0,1,…), nicht den G-code-T
|
||
ordered_paints = sorted(item["paint_index"] for item in mapping)
|
||
new_fils = []
|
||
for index, tool in enumerate(used):
|
||
row = filament_for_paint(source, ordered_paints[index]) or (
|
||
source[index] if index < len(source) and isinstance(source[index], dict) else {}
|
||
)
|
||
if not row:
|
||
row = filament_for_paint(source, tool) or {}
|
||
copy = dict(row) if isinstance(row, dict) else {}
|
||
copy["paint_index"] = index
|
||
new_fils.append(copy)
|
||
|
||
# Identity-Remap weglassen (schon T0…n−1)
|
||
if used == list(range(len(used))):
|
||
return content, dense, new_fils, {}
|
||
new_content = rewrite_gcode_tools(content, remap)
|
||
return new_content, dense, new_fils, remap
|
||
|
||
|
||
_TOOL_NUM_RE = re.compile(
|
||
rb"^(?P<prefix>(?:MMU_CHANGE_TOOL(?:_STANDALONE)?[^\n]*?\bTOOL=)|T)(?P<num>\d{1,2})\b",
|
||
re.I,
|
||
)
|
||
_REF_TOOLS_RE = re.compile(rb"^(;\s*referenced_tools\s*=\s*)(.+?)\s*$", re.I)
|
||
|
||
|
||
def rewrite_gcode_tools(content, remap):
|
||
"""Tn / TOOL=n / referenced_tools laut Remap umschreiben (T10 vor T1)."""
|
||
if not content or not remap:
|
||
return content
|
||
text = content if isinstance(content, (bytes, bytearray)) else str(content).encode("utf-8", "replace")
|
||
lines = text.splitlines(keepends=True)
|
||
out = []
|
||
for line in lines:
|
||
stripped = line[:-2] if line.endswith(b"\r\n") else (line[:-1] if line.endswith(b"\n") else line)
|
||
ending = line[len(stripped) :]
|
||
ref = _REF_TOOLS_RE.match(stripped)
|
||
if ref:
|
||
parts = []
|
||
for bit in ref.group(2).split(b","):
|
||
raw = bit.strip()
|
||
try:
|
||
number = int(raw)
|
||
except ValueError:
|
||
parts.append(raw)
|
||
continue
|
||
parts.append(str(remap.get(number, number)).encode("ascii"))
|
||
out.append(ref.group(1) + b",".join(parts) + ending)
|
||
continue
|
||
match = _TOOL_NUM_RE.match(stripped)
|
||
if match:
|
||
old = int(match.group("num"))
|
||
if old in remap:
|
||
new = str(remap[old]).encode("ascii")
|
||
stripped = stripped[: match.start("num")] + new + stripped[match.end("num") :]
|
||
out.append(stripped + ending)
|
||
return b"".join(out)
|
||
|
||
|
||
def build_acm(filaments, mapping, slots):
|
||
filaments = filaments or []
|
||
slots = slots or []
|
||
slot_by_index = {int(slot.get("index")): slot for slot in slots if isinstance(slot.get("index"), int)}
|
||
entries = []
|
||
for item in normalize_mapping(mapping):
|
||
paint = item["paint_index"]
|
||
ams = item["ams_index"]
|
||
filament = filament_for_paint(filaments, paint)
|
||
slot = slot_by_index.get(ams) or {}
|
||
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
|
||
ams_color = color_channels(slot.get("color")) or paint_color
|
||
material = (filament.get("type") or slot.get("type") or "PLA").strip() or "PLA"
|
||
entries.append(
|
||
{
|
||
"paint_index": paint,
|
||
"paint_color": paint_color,
|
||
"material_type": material,
|
||
"ams_index": ams,
|
||
"ams_color": ams_color,
|
||
}
|
||
)
|
||
if not entries:
|
||
return None
|
||
return {"use_ams": True, "ams_box_mapping": entries}
|
||
|
||
|
||
def acm_bytes(payload):
|
||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|