feat: Spoolman filament tracking integration

Adds optional Spoolman (https://github.com/Donkie/Spoolman) integration
for live filament consumption tracking.

Config (config.ini [spoolman] section or env vars):
  SPOOLMAN_SERVER    = http://192.168.x.x:7912
  SPOOLMAN_SYNC_RATE = 30   # mid-print sync interval in seconds (0=end only)

How it works:
- Uses supplies_usage from the printer's own MQTT print/report payload —
  the printer's cumulative extrusion counter in mm, reset each print.
  Discovered via MQTT payload inspection: matches slicer estimate within
  ~1% for model filament, and also captures the printer's purge/prime
  sequence that the slicer doesn't count.
- Reports to Spoolman via PUT /api/v1/spool/{id}/use with use_length (mm),
  letting Spoolman convert to weight using the spool's filament profile.
- Accurate for both completed and cancelled prints — no estimation needed.
- For multi-slot prints, total filament is split equally across mapped spools
  (v1 approximation; per-slot MQTT breakdown not available).

UI changes:
- Filament assignment dialog gains a Spoolman section (hidden when not
  configured): one spool dropdown per assigned AMS slot, loaded async
  from GET /kx/spoolman/spools.
- AMS slot display shows a 🧵 #N badge on any slot with a mapped spool.
- Spool assignments persist across prints (sticky until changed).

New API endpoints:
  GET  /kx/spoolman/status        — configured flag, server URL, slot map
  GET  /kx/spoolman/spools        — spool list proxied from Spoolman
  POST /kx/spoolman/active-spool  — body: {"slot_map": {"0": 42, "1": 17}}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Phil Merricks
2026-06-18 17:28:54 +01:00
parent 36a49c6343
commit 385216108a
5 changed files with 616 additions and 53 deletions

View File

@@ -34,3 +34,11 @@ auto_leveling = 1
[bridge]
# Poll-Intervall in Sekunden
poll_interval = 3
[spoolman]
# URL der Spoolman-Instanz (leer lassen um Spoolman zu deaktivieren)
# server = http://192.168.x.x:7912
# Wie oft (Sekunden) der Filamentverbrauch während des Drucks gemeldet wird
# (0 = nur beim Druckende)
# sync_rate = 0

View File

@@ -13,6 +13,7 @@ _BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) els
CONFIG_SECTION_CONNECTION = "connection"
CONFIG_SECTION_PRINT = "print"
CONFIG_SECTION_BRIDGE = "bridge"
CONFIG_SECTION_SPOOLMAN = "spoolman"
def _find_config_file() -> pathlib.Path | None:
@@ -62,6 +63,8 @@ def _load_config_file(path: pathlib.Path):
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
}
for env_key, (section, option) in mapping.items():
if env_key not in os.environ:
@@ -259,3 +262,5 @@ DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))

View File

@@ -222,7 +222,7 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
elif unit == "m": secs += int(val) * 60
elif unit == "s": secs += int(val)
if secs:
log.info(f"Slicer-Schätzzeit: {secs}s ({m.group(1).strip()})")
log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})")
return secs
@@ -732,6 +732,40 @@ class CameraCache:
await asyncio.sleep(2.0)
class SpoolmanClient:
"""Thin synchronous HTTP client for Spoolman filament tracking.
Designed to be called from daemon threads (poll loop, _on_print callbacks).
Uses requests (already in requirements) so no event-loop dependency.
"""
def __init__(self, server_url: str, sync_rate: int = 0):
self.server_url = server_url.rstrip("/")
self.sync_rate = sync_rate
def _req(self, method: str, path: str, **kwargs):
import requests
r = requests.request(method, f"{self.server_url}{path}", timeout=5, **kwargs)
r.raise_for_status()
return r.json()
def health_check(self) -> bool:
try:
self._req("GET", "/api/v1/health")
return True
except Exception:
return False
def list_spools(self) -> list:
return self._req("GET", "/api/v1/spool")
def use_filament(self, spool_id: int, use_length_mm: float) -> None:
"""Report consumed filament length in mm. Spoolman converts to weight
using the spool's filament profile density."""
self._req("PUT", f"/api/v1/spool/{spool_id}/use",
json={"use_length": round(use_length_mm, 2)})
class KobraXBridge:
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
self.client = client
@@ -784,6 +818,7 @@ class KobraXBridge:
"connection_error": "",
"file_ready": "",
"filament_mode": "toolhead",
"supplies_usage": 0,
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
}
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
@@ -798,6 +833,7 @@ class KobraXBridge:
self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = ""
self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # User hat Kamera während Druck manuell gestoppt
self.camera_cache: CameraCache = CameraCache()
self._thumbnail_b64: str = ""
@@ -806,10 +842,20 @@ class KobraXBridge:
# Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10)
self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0}
# Spoolman filament tracking
_sm_url = (getattr(args, "spoolman_server", "") or "").strip()
self._spoolman: SpoolmanClient | None = (
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
if _sm_url else None
)
self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id}
self._spoolman_reported_mm: float = 0.0
self._spoolman_last_sync: float = 0.0
# Theme-Name prüfen (keine Sonderzeichen oder Umlaute)
raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
if not _UI_THEME_NAME_RE.match(raw_theme):
log.warning("Ungültiger UI-Theme-Name %r nutze default", raw_theme)
log.warning("Invalid UI theme name %r using default", raw_theme)
raw_theme = "default"
self._ui_theme = raw_theme
self._index_tpl_cache: str | None = None
@@ -824,6 +870,117 @@ class KobraXBridge:
client.callbacks["light/report"] = self._on_light
client.callbacks["skip/report"] = self._on_skip
if self._spoolman:
threading.Thread(
target=lambda: log.info(
f"Spoolman: {'OK' if self._spoolman.health_check() else 'unreachable'} "
f"at {self._spoolman.server_url}"
),
daemon=True, name="spoolman-health",
).start()
# ── Spoolman helpers ──────────────────────────────────────────────────────
def _spoolman_filament_mm(self) -> float:
"""Total filament_used_mm for the current print file from the GCode DB."""
filename = self._state.get("filename", "")
if not filename:
return 0.0
try:
gf = self._store.get_file_by_name(filename)
return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
except Exception:
return 0.0
def _spoolman_notify_end(self):
"""Report remaining filament to Spoolman on print end (fire-and-forget).
Uses supplies_usage from the MQTT stream — the printer's own cumulative
extrusion counter in mm, reset each print. Accurate for both completed
and cancelled prints with no estimation needed.
For multi-slot prints the delta is split equally across all mapped spools
(v1 approximation — per-slot breakdown is not in the MQTT stream)."""
if not self._spoolman or not self._spoolman_slot_spools:
return
used_mm = self._state.get("supplies_usage", 0)
delta_mm = used_mm - self._spoolman_reported_mm
if delta_mm < 0.1:
return
n = len(self._spoolman_slot_spools)
per_spool_mm = delta_mm / n
sm = self._spoolman
self._spoolman_reported_mm = used_mm
for spool_id in self._spoolman_slot_spools.values():
def _report(sid=spool_id, mm=per_spool_mm):
try:
sm.use_filament(sid, mm)
log.info(f"Spoolman: reported {mm:.1f} mm to spool {sid}")
except Exception as e:
log.warning(f"Spoolman: end-report failed (spool {sid}): {e}")
threading.Thread(target=_report, daemon=True, name="spoolman-end").start()
def _spoolman_sync_midprint(self):
"""Report incremental filament usage to Spoolman during a print."""
if not self._spoolman or not self._spoolman_slot_spools:
return
used_mm = self._state.get("supplies_usage", 0)
delta_mm = used_mm - self._spoolman_reported_mm
if delta_mm < 10.0:
return
n = len(self._spoolman_slot_spools)
per_spool_mm = delta_mm / n
sm = self._spoolman
self._spoolman_reported_mm = used_mm
for spool_id in self._spoolman_slot_spools.values():
def _report(sid=spool_id, mm=per_spool_mm):
try:
sm.use_filament(sid, mm)
log.info(f"Spoolman: mid-print {mm:.1f} mm to spool {sid}")
except Exception as e:
log.warning(f"Spoolman: mid-print sync failed (spool {sid}): {e}")
threading.Thread(target=_report, daemon=True, name="spoolman-sync").start()
# ── Spoolman API handlers ─────────────────────────────────────────────────
async def handle_kx_spoolman_status(self, request):
"""GET /kx/spoolman/status"""
return self._json_cors({
"configured": bool(self._spoolman),
"server": self._spoolman.server_url if self._spoolman else "",
"sync_rate": self._spoolman.sync_rate if self._spoolman else 0,
"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()},
})
async def handle_kx_spoolman_spools(self, request):
"""GET /kx/spoolman/spools — proxied from Spoolman."""
if not self._spoolman:
return self._json_cors({"error": "Spoolman not configured"}, status=503)
try:
spools = await asyncio.get_event_loop().run_in_executor(
None, self._spoolman.list_spools
)
return self._json_cors({"spools": spools})
except Exception as e:
log.warning(f"Spoolman: list_spools failed: {e}")
return self._json_cors({"error": str(e)}, status=502)
async def handle_kx_spoolman_set_active(self, request):
"""POST /kx/spoolman/active-spool
Body: {"slot_map": {"0": 42, "2": 17}} — AMS slot index → Spoolman spool ID.
An empty slot_map clears all assignments."""
try:
data = await request.json()
except Exception:
return self._json_cors({"error": "invalid JSON"}, status=400)
slot_map = data.get("slot_map") or {}
self._spoolman_slot_spools = {
int(k): int(v) for k, v in slot_map.items()
if str(v).isdigit() and int(v) > 0
}
self._spoolman_reported_mm = 0.0
return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}})
def _default_ace_dry_presets(self) -> dict[str, dict]:
return {
"pla": {"temp": 45, "duration_sec": 4 * 3600},
@@ -914,7 +1071,9 @@ class KobraXBridge:
# Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI).
# _camera_autostarted verhindert Mehrfach-Trigger pro Druck.
if kobra_state == "printing":
if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False):
if (getattr(self._args, "camera_on_print", 0)
and not self._camera_autostarted
and not self._camera_user_stopped):
self._camera_autostarted = True
try:
self.client.start_camera()
@@ -923,6 +1082,7 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # für nächsten Druck freigeben
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
@@ -934,16 +1094,20 @@ class KobraXBridge:
gcode_file_id=gf["id"],
printer_id=self._printer_id,
)
log.info(f"Job gestartet: {self._current_job_id} für {filename}")
log.info(f"Job started: {self._current_job_id} for {filename}")
self._spoolman_reported_mm = 0.0
self._spoolman_last_sync = 0.0
# Job-History: Druckende erkennen
if kobra_state in ("finished",) and self._current_job_id:
self._store.finish_job(self._current_job_id, status="completed")
log.info(f"Job abgeschlossen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
elif kobra_state in ("stoped", "canceled") and self._current_job_id:
self._store.finish_job(self._current_job_id, status="cancelled")
log.info(f"Job abgebrochen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
# Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der
@@ -960,6 +1124,7 @@ class KobraXBridge:
self._state["slicer_time"] = 0
self._state["layer_height"] = 0.0
self._state["first_layer_height"] = 0.0
self._state["supplies_usage"] = 0
self._thumbnail_b64 = ""
self._state["filename"] = d.get("filename", self._state["filename"])
if "progress" in d:
@@ -974,6 +1139,8 @@ class KobraXBridge:
self._state["total_layers"] = d["total_layers"]
if "taskid" in d:
self._state["taskid"] = str(d["taskid"])
if "supplies_usage" in d:
self._state["supplies_usage"] = int(d["supplies_usage"])
settings = d.get("settings") or {}
if "print_speed_mode" in settings:
self._state["print_speed_mode"] = int(settings["print_speed_mode"])
@@ -1001,7 +1168,9 @@ class KobraXBridge:
# Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report).
# _camera_autostarted-Guard verhindert Doppel-Start mit _on_print.
if kobra_state == "printing":
if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False):
if (getattr(self._args, "camera_on_print", 0)
and not self._camera_autostarted
and not self._camera_user_stopped):
self._camera_autostarted = True
try:
self.client.start_camera()
@@ -1010,6 +1179,7 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # für nächsten Druck freigeben
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
@@ -1075,7 +1245,7 @@ class KobraXBridge:
if filename:
try:
self._store.update_file_objects(filename, objs, svg)
log.info(f"Skip-Objekte für {filename}: {len(objs)} ({'mit SVG' if svg else 'ohne SVG'})")
log.info(f"Skip objects for {filename}: {len(objs)} ({'with SVG' if svg else 'no SVG'})")
except Exception as e:
log.warning(f"update_file_objects fehlgeschlagen: {e}")
self._push_status_update()
@@ -1494,12 +1664,30 @@ class KobraXBridge:
self._push_status_update()
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
# Default-Mapping pro Material-Typ wenn der User keinen Slot-Profil-
# Override gesetzt hat. Für den Kobra X bevorzugen wir Anycubic-eigene
# Filament-IDs aus den `@Anycubic Kobra X 0.4 nozzle`-Profilen — die
# sind druckerspezifisch is_compatible und werden von OrcaSlicer direkt
# gematched. Library-Fallbacks (OGF*) nur für Material-Typen ohne
# Kobra-X-spezifisches Anycubic-Profil — deren @System-Profile haben
# `compatible_printers: []` (= mit allen Druckern kompatibel).
_TRAY_INFO_IDX = {
"PLA": "OGFL99", "PLA-CF": "OGFL98", "PLA SILK": "OGFL96",
"PETG": "OGFG99", "PETG-CF": "OGFG98",
"ABS": "OGFB99", "ASA": "OGFB98",
"TPU": "OGFT99", "PA": "OGFP99", "PA-CF": "OGFP98",
"PC": "OGFC99", "HIPS": "OGFH99", "PVA": "OGFV99",
# Anycubic-eigene Kobra-X-Profile
"PLA": "GFPLA",
"PLA+": "GFPLA+",
"PLA SILK": "GFPLA Silk",
"PETG": "GFPETG",
"ABS": "GFABS",
"ASA": "GFASA",
"TPU": "GFTPU 95A",
"PVA": "GFPVA",
# Kein Anycubic-Kobra-X-Profil → Library-Fallback
"PLA-CF": "OGFL98",
"PETG-CF": "OGFG98",
"PA": "OGFN99",
"PA-CF": "OGFN98",
"PC": "OGFC99",
"HIPS": "OGFS98",
}
def _build_lane_data(self) -> dict:
@@ -1556,9 +1744,13 @@ class KobraXBridge:
fila_name = user_profile.get("name", "")
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
else:
vendor = ""
fila_name = ""
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
# Default: Library-Generic-Profil (siehe _default_filament_name) —
# ist mit allen Druckern kompatibel und garantiert sichtbar.
# Der User wählt pro Slot bewusst eine konkrete Marke wenn er
# eine will; Default bleibt neutral.
fila_name = self._default_filament_name(material)
vendor = "Generic" if fila_name.startswith("Generic ") else ""
tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
tray_array.append({
"id": str(slot_id),
"tag_uid": "0000000000000000",
@@ -1566,17 +1758,20 @@ class KobraXBridge:
"tray_type": material,
"tray_color": color_hex,
"tray_sub_brands": vendor,
# OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
# `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
# filament_id_by_type). Wir senden beide Schreibweisen mit
# damit ältere Patch-Varianten + zukünftige Upstream-PRs beide
# bedient sind.
"name": fila_name,
"vendor_name": vendor,
# Aliase für ältere Patch-Varianten (Variante 2,
# MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
# sonst preset-Name per find_preset() auflösen.
"filament_id": tray_info_idx,
"filament_vendor": vendor,
# Für den OrcaSlicer-Empfangs-Patch (Variante 2,
# MoonrakerPrinterAgent.cpp): `filament_id` direkt
# übernehmen (exakt), sonst `preset`-Name per
# find_preset() auflösen. tray_info_idx ist im Orca-
# Datenmodell nicht eindeutig (z.B. OGFL99 für 136
# Profile), aber der Bare-Name aus orca_filaments.json
# ist eindeutig — find_preset() parsed @-Suffixe weg.
"filament_id": tray_info_idx,
"preset": fila_name,
"filament_name": fila_name, # ältere Aliase
"filament_name": fila_name,
"preset": fila_name,
})
else:
tray_array.append({
@@ -1695,6 +1890,7 @@ class KobraXBridge:
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
num_gates = len(slots)
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
gate_filament_name = []
for _global_index, slot in slots:
occupied = slot.get("status") == 5
gate_status.append(1 if occupied else 0)
@@ -1706,6 +1902,17 @@ class KobraXBridge:
gate_color.append("{:02X}{:02X}{:02X}".format(*c[:3]) if occupied else "")
gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)] if occupied else [0.0, 0.0, 0.0])
gate_temperature.append(_TEMP.get(material, 210) if occupied else 0)
# gate_filament_name aus User-Override oder Material-Default für den
# HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den
# HH-Pfad wählt (MMU-Erkennung), wertet PR #13719 dieses Feld als
# Preset-Namen aus → 'Anycubic PLA' matched das druckerspezifische
# Preset, leerer String führte vorher auf Generic PLA.
if occupied:
user_profile = self._filament_profiles.get(_global_index) or {}
fila_name = user_profile.get("name") or self._default_filament_name(material)
gate_filament_name.append(fila_name)
else:
gate_filament_name.append("")
loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(slots)}
active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
@@ -1717,13 +1924,36 @@ class KobraXBridge:
"gate_color": gate_color,
"gate_temperature": gate_temperature,
"gate_color_rgb": gate_color_rgb,
"gate_filament_name": [""] * num_gates,
"gate_filament_name": gate_filament_name,
"gate_spool_id": [-1] * num_gates,
"ttg_map": list(range(num_gates)),
"tool": active_gate,
"gate": active_gate,
}
def _default_filament_name(self, material: str) -> str:
"""Default-Name für `gate_filament_name`/`name` in lane_data wenn kein
User-Override gesetzt ist. Bewusste Designentscheidung: **immer
Generic <Typ>** als Default — das Library-Profil ist `compatible_printers:[]`
(= mit jedem Drucker kompatibel) und damit garantiert sichtbar.
OrcaSlicer matcht dann das neutrale Generic-Preset und der User
kann pro Slot eine konkrete Marke setzen wenn er das will."""
if not material:
return ""
mat = material.upper().strip()
profs = self._load_orca_filaments()
def _match_type(p: dict) -> bool:
pt = (p.get("type") or "").upper()
return pt == mat or pt.startswith(mat + "-") or pt.startswith(mat + " ")
# Library-Generic-Profil (immer is_visible+is_compatible)
for p in profs:
if p.get("vendor") == "Generic" and p.get("name", "").startswith("Generic ") and _match_type(p):
return p.get("name", "")
# Falls die Library-Generic für diesen exotischen Material-Typ fehlt,
# liefern wir nichts — OrcaSlicer fällt auf filament_id_by_type zurück.
return ""
def _build_printer_objects(self) -> dict:
s = self._state
return {
@@ -1824,6 +2054,43 @@ class KobraXBridge:
"gcode_macro TIMELAPSE_TAKE_FRAME": {
"is_paused": False,
},
# configfile stub — Mobileraker und andere Clients crashen ohne
# dieses Objekt (Missing field: configFile). Werte aus der
# entschlüsselten avata_main.conf (ACCFG1.0 — Kobra X Firmware).
"configfile": {
"config": {},
"settings": {
"printer": {
"kinematics": "cartesian",
"max_velocity": 450,
"max_accel": 10000,
"max_z_velocity": 12,
"max_z_accel": 100,
"square_corner_velocity": 20.0,
},
"extruder": {
"nozzle_diameter": 0.4,
"filament_diameter": 1.75,
"sensor_type": "ATC Semitec 104GT-2",
"min_temp": 0,
"max_temp": 320,
"min_extrude_temp": 10,
},
"heater_bed": {
"min_temp": 0,
"max_temp": 120,
},
"stepper_x": {"position_min": -18.5, "position_max": 280},
"stepper_y": {"position_min": -6.5, "position_max": 272.5},
"stepper_z": {"position_min": -4, "position_max": 262},
"virtual_sdcard": {"path": "/data/gcodes"},
"pause_resume": {},
"display_status": {},
},
"warnings": [],
"save_config_pending": False,
"save_config_pending_items": {},
},
}
# -------------------------------------------------------------------------
@@ -1946,6 +2213,128 @@ class KobraXBridge:
profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter]
return self._json_cors({"result": profiles})
async def handle_kx_filament_profiles_user_list(self, request):
"""GET /kx/filament/profiles/user — nur die User-importierten Profile,
für den Settings-Tab (Verwaltung mit Lösch-Buttons)."""
path = self._orca_filaments_user_path()
if not os.path.isfile(path):
return self._json_cors({"result": []})
try:
with open(path, encoding="utf-8") as f:
user_profiles = json.load(f) or []
except Exception:
user_profiles = []
return self._json_cors({"result": user_profiles})
async def handle_kx_filament_profiles_import(self, request):
"""POST /kx/filament/profiles/user — multipart-Upload mit einer
ZIP-Datei oder mehreren `.json`-Files aus
~/.config/OrcaSlicer/user/<id>/filament/.
Bestehende User-Profile mit gleichem (vendor, name)-Key werden
überschrieben. Geparste Profile haben dasselbe Schema wie
orca_filaments.json (id, name, vendor, type, color)."""
import io, zipfile
from orca_filaments import parse_profile_bytes
added: list[dict] = []
skipped: int = 0
# System-Index für Inherits-Resolve: User-Profile referenzieren
# System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit
# können wir filament_id/vendor/type/color aus dem System-Parent
# ziehen wenn das User-Profil sie selbst nicht setzt.
sys_idx = [p for p in self._load_orca_filaments() if not p.get("is_user")]
try:
reader = await request.multipart()
except Exception:
return self._json_cors({"error": "expected multipart"}, status=400)
async for part in reader:
if part.name not in ("file", "files", "upload"):
continue
blob = await part.read()
fn = (part.filename or "").lower()
if fn.endswith(".zip"):
try:
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
for inner in zf.namelist():
if not inner.lower().endswith(".json"):
continue
try:
with zf.open(inner) as zf_in:
p = parse_profile_bytes(zf_in.read(), source_name=inner, system_index=sys_idx)
except Exception:
skipped += 1
continue
if p:
added.append(p)
else:
skipped += 1
except zipfile.BadZipFile:
return self._json_cors({"error": "bad zip"}, status=400)
elif fn.endswith(".json"):
p = parse_profile_bytes(blob, source_name=fn, system_index=sys_idx)
if p:
added.append(p)
else:
skipped += 1
if not added:
return self._json_cors({"result": "ok", "added": 0, "skipped": skipped})
# Merge mit existierender User-JSON (gleicher (vendor,name) → ersetzen)
path = self._orca_filaments_user_path()
existing: list[dict] = []
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as f:
existing = json.load(f) or []
except Exception:
existing = []
by_key = {(p.get("vendor"), p.get("name")): p for p in existing}
for p in added:
by_key[(p.get("vendor"), p.get("name"))] = p
merged = sorted(by_key.values(), key=lambda x: (x.get("vendor",""), x.get("name","")))
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(merged, f, indent=2, ensure_ascii=False)
f.write("\n")
except Exception as e:
return self._json_cors({"error": f"write failed: {e}"}, status=500)
self._invalidate_filaments_cache()
return self._json_cors({"result": "ok",
"added": len(added),
"skipped": skipped,
"total_user": len(merged)})
async def handle_kx_filament_profiles_user_delete(self, request):
"""DELETE /kx/filament/profiles/user — löscht entweder einen einzelnen
Eintrag (?vendor=…&name=…) oder alle wenn keine Query angegeben."""
vendor = request.rel_url.query.get("vendor", "").strip()
name = request.rel_url.query.get("name", "").strip()
path = self._orca_filaments_user_path()
if not os.path.isfile(path):
return self._json_cors({"result": "ok", "removed": 0})
try:
with open(path, encoding="utf-8") as f:
existing = json.load(f) or []
except Exception:
existing = []
before = len(existing)
if vendor and name:
existing = [p for p in existing
if not (p.get("vendor") == vendor and p.get("name") == name)]
else:
existing = []
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(existing, f, indent=2, ensure_ascii=False)
f.write("\n")
except Exception as e:
return self._json_cors({"error": str(e)}, status=500)
self._invalidate_filaments_cache()
return self._json_cors({"result": "ok",
"removed": before - len(existing),
"total_user": len(existing)})
def _find_orca_filaments_json(self) -> str | None:
"""Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/
— in allen 3 Deployment-Modi:
@@ -2022,21 +2411,45 @@ class KobraXBridge:
"id": entry.get("id", "")})
def _load_orca_filaments(self) -> list[dict]:
"""Lädt orca_filaments.json einmalig in den Cache. Bei wiederholten
Aufrufen wird die Liste aus dem RAM geliefert."""
"""Lädt System- + User-Profile aus dem Cache. System-Profile kommen
aus bridge/data/orca_filaments.json (Image-embedded), User-Profile
aus <KX_DATA_DIR>/orca_filaments.user.json (Volume-persistent —
überlebt Image-Updates). User-Profile bekommen ein `is_user: True`-
Flag damit das Frontend sie markieren kann."""
if getattr(self, "_orca_filaments_cache", None) is not None:
return self._orca_filaments_cache
self._orca_filaments_cache = []
data_path = self._find_orca_filaments_json()
if not data_path or not os.path.isfile(data_path):
return self._orca_filaments_cache
try:
with open(data_path, encoding="utf-8") as f:
self._orca_filaments_cache = json.load(f) or []
except Exception as e:
log.warning(f"orca_filaments.json read error: {e}")
merged: list[dict] = []
# System
sys_path = self._find_orca_filaments_json()
if sys_path and os.path.isfile(sys_path):
try:
with open(sys_path, encoding="utf-8") as f:
merged.extend(json.load(f) or [])
except Exception as e:
log.warning(f"orca_filaments.json read error: {e}")
# User
usr_path = self._orca_filaments_user_path()
if usr_path and os.path.isfile(usr_path):
try:
with open(usr_path, encoding="utf-8") as f:
for p in (json.load(f) or []):
p["is_user"] = True
merged.append(p)
except Exception as e:
log.warning(f"orca_filaments.user.json read error: {e}")
self._orca_filaments_cache = merged
return self._orca_filaments_cache
def _orca_filaments_user_path(self) -> str:
"""Pfad zur User-Profile-JSON. Liegt im Volume-Mount (KX_DATA_DIR),
damit Image-Updates die Daten nicht zerstören."""
data_dir = os.environ.get("KX_DATA_DIR") or os.path.join(_WEB_BASE, "data")
os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, "orca_filaments.user.json")
def _invalidate_filaments_cache(self):
self._orca_filaments_cache = None
def _lookup_filament_id(self, vendor: str, name: str) -> str:
"""Sucht in orca_filaments.json die filament_id zu einem (vendor,name)-
Tupel. Liefert '' wenn nicht gefunden."""
@@ -2449,7 +2862,20 @@ class KobraXBridge:
return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}})
async def handle_webcams_list(self, request):
"""Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier."""
"""Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier.
Wenn der Client von einem anderen Host kommt (z.B. moonraker-obico auf
separatem Server), braucht er absolute URLs damit er den Stream erreicht.
Host-Header mit localhost/127.0.0.1 wird durch die echte LAN-IP ersetzt."""
host_hdr = request.headers.get("Host", "") if request else ""
host_name = (host_hdr or "").split(":")[0]
port_part = f":{host_hdr.split(':')[1]}" if ":" in (host_hdr or "") else f":{self._args.port}"
local_ip = getattr(self, "_local_ip", None) or host_name
if host_name in ("localhost", "127.0.0.1", ""):
host_name = local_ip
base = f"http://{host_name}{port_part}"
stream_url = f"{base}/api/camera/stream"
snapshot_url = f"{base}/api/camera/snapshot"
return web.json_response({
"result": {
"webcams": [
@@ -2461,8 +2887,8 @@ class KobraXBridge:
"icon": "mdiWebcam",
"target_fps": 5,
"target_fps_idle": 2,
"stream_url": "/api/camera/stream",
"snapshot_url": "/api/camera/snapshot",
"stream_url": stream_url,
"snapshot_url": snapshot_url,
"flip_horizontal": False,
"flip_vertical": False,
"rotation": 0,
@@ -2647,7 +3073,7 @@ class KobraXBridge:
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')}")
log.info(f"Print start confirmed: state={result.get('state')}")
else:
log.warning("Druckstart: keine Antwort vom Drucker")
@@ -2902,7 +3328,7 @@ class KobraXBridge:
return web.json_response({"result": "disconnected"})
async def handle_api_restart(self, request):
log.info("Neustart über API angefordert")
log.info("Restart requested via API")
response = web.json_response({"status": "restarting"})
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
return response
@@ -3182,6 +3608,9 @@ class KobraXBridge:
await loop.run_in_executor(None, lambda: self.client.publish(
"video", "stopCapture", None, timeout=0
))
# Verhindert dass der Autostart-Guard die Kamera während des
# laufenden Drucks wieder einschaltet (State-Flicker-Problem).
self._camera_user_stopped = True
return web.json_response({"result": "ok"})
async def handle_api_camera_snapshot(self, request):
@@ -3241,7 +3670,7 @@ class KobraXBridge:
stderr=asyncio.subprocess.DEVNULL,
)
except (FileNotFoundError, OSError) as e:
log.warning("Kamera: ffmpeg nicht gefunden Kamerastream nicht verfügbar")
log.warning("Camera: ffmpeg not found camera stream unavailable")
return web.Response(status=503, text="ffmpeg not found")
except Exception as e:
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}")
@@ -3336,7 +3765,7 @@ class KobraXBridge:
if not os.path.isfile(serve_path):
return web.Response(status=404, text="not found")
size = os.path.getsize(serve_path)
log.info(f"Drucker lädt Datei ab: {filename} ({size} bytes)")
log.info(f"Printer downloading file: {filename} ({size} bytes)")
return web.FileResponse(serve_path, headers={
"Content-Disposition": f'attachment; filename="{filename}"'
})
@@ -3374,6 +3803,7 @@ class KobraXBridge:
"remain_time": s["remain_time"],
"curr_layer": s["curr_layer"],
"total_layers": s["total_layers"],
"z_mm": self._estimate_current_z(),
"filename": s["filename"],
"slicer_time": slicer_time,
"camera_url": s["camera_url"],
@@ -3651,7 +4081,7 @@ class KobraXBridge:
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n\n")
cfg.write(f)
log.info(f"Drucker '{name or creds['model']}' als {sec} hinzugefügt (Port {new_port})")
log.info(f"Printer '{name or creds['model']}' added as {sec} (port {new_port})")
response = self._json_cors({"status": "restarting", "section": sec, "http_port": new_port})
asyncio.get_event_loop().call_later(0.5, self._restart_bridge)
return response
@@ -3726,13 +4156,13 @@ class KobraXBridge:
# die alten Werte statt der geänderten config.ini.
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
"BRIDGE_PRINTER_NAME"):
"CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "BRIDGE_PRINTER_NAME"):
os.environ.pop(_k, None)
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")
if in_docker:
# Docker/systemd: Prozess beenden reicht der Supervisor startet neu (frische environ)
log.info("Container-Umgebung erkannt beende Prozess für Supervisor-Restart")
log.info("Container environment detected exiting for supervisor restart")
os._exit(0)
frozen = getattr(sys, "frozen", False)
@@ -3767,7 +4197,9 @@ class KobraXBridge:
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
def _read_version(self) -> str:
for base in (pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
# PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach
# sys._MEIPASS — daher _WEB_BASE statt _BASE benutzen.
for base in (pathlib.Path(_WEB_BASE), pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
p = base / "VERSION"
if p.is_file():
return p.read_text(encoding="utf-8").strip()
@@ -3909,7 +4341,7 @@ class KobraXBridge:
if fname == "kobrax_moonraker_bridge.py":
return web.json_response(
{"error": f"Download {fname}: HTTP {resp.status}"}, status=502)
log.warning(f"Update: {fname} nicht im Release ({resp.status}) übersprungen")
log.warning(f"Update: {fname} not found in release ({resp.status}) skipped")
continue
downloaded.append((app_dir / fname, await resp.read()))
# Phase 2: atomar ersetzen (erst nach komplettem, erfolgreichem Download)
@@ -4186,11 +4618,14 @@ class KobraXBridge:
# Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
result = "ok"
elif method == "server.webcams.list":
# WS-Variante des HTTP-Endpoints
# WS-Variante: absolute URL mit echter LAN-IP statt localhost
_lip = getattr(self, "_local_ip", None) or "127.0.0.1"
_base = f"http://{_lip}:{self._args.port}"
result = {"webcams": [{
"name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer",
"enabled": True, "stream_url": "/api/camera/stream",
"snapshot_url": "/api/camera/snapshot",
"enabled": True,
"stream_url": f"{_base}/api/camera/stream",
"snapshot_url": f"{_base}/api/camera/snapshot",
"flip_horizontal": False, "flip_vertical": False, "rotation": 0,
"target_fps": 5, "aspect_ratio": "16:9",
}]}
@@ -4240,7 +4675,7 @@ class KobraXBridge:
log.debug(f"Unbekannte RPC-Methode: {method}")
result = {}
except Exception as e:
log.error(f"RPC-Fehler für {method}: {e}")
log.error(f"RPC error for {method}: {e}")
error = {"code": -32603, "message": str(e)}
if rpc_id is not None:
@@ -4303,6 +4738,14 @@ class KobraXBridge:
print_r = self.client.publish("print", "query", timeout=3.0)
if print_r:
self._on_print(print_r)
# Spoolman mid-print sync
if (self._spoolman and self._spoolman.sync_rate > 0
and self._spoolman_slot_spools
and self._state.get("print_state") == "printing"):
now = time.time()
if now - self._spoolman_last_sync >= self._spoolman.sync_rate:
self._spoolman_sync_midprint()
self._spoolman_last_sync = now
box = self.client.query_multicolor_box()
if box:
data = box.get("data") or {}
@@ -4439,12 +4882,21 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
# Custom-Profile-Import (Issue #41) — User lädt eigene Orca-Filament-
# Profile als ZIP/JSON hoch (z.B. aus ~/.config/OrcaSlicer/user/<id>/filament/),
# weil die Bridge typischerweise nicht auf demselben Host wie OrcaSlicer läuft.
r.add_get("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_list)
r.add_post("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_import)
r.add_delete("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_delete)
r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
r.add_post("/kx/skip", bridge.handle_kx_skip)
r.add_post("/kx/skip/query", bridge.handle_kx_skip_query)
r.add_get("/kx/skip/state", bridge.handle_kx_skip_state)
r.add_get("/kx/spoolman/status", bridge.handle_kx_spoolman_status)
r.add_get("/kx/spoolman/spools", bridge.handle_kx_spoolman_spools)
r.add_post("/kx/spoolman/active-spool", bridge.handle_kx_spoolman_set_active)
r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options)
# Root + Printer-Routen (Single-Page, JS liest Pathname)
@@ -4548,7 +5000,7 @@ async def run_bridge(args):
site = web.TCPSite(runner, args.host, per_args.port)
await site.start()
runners.append((runner, client, pid))
log.info(f"[Drucker {pid}] Bridge läuft auf http://{args.host}:{per_args.port}")
log.info(f"[Printer {pid}] Bridge running on http://{args.host}:{per_args.port}")
import socket as _socket
try:
@@ -4557,6 +5009,9 @@ async def run_bridge(args):
_local_ip = _s.getsockname()[0]
except Exception:
_local_ip = args.host
# An alle Bridge-Instanzen weitergeben — wird für absolute Webcam-URLs genutzt
for _b in all_bridges.values():
_b._local_ip = _local_ip
log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Ports: " +
", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values()))
log.info("Ctrl-C zum Beenden")
@@ -4605,6 +5060,10 @@ def main():
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER,
help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable")
parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE,
help="Mid-print filament sync interval in seconds (0 = only on print end)")
parser.add_argument("--host", default="0.0.0.0",
help="Bind-Adresse für den Bridge-Server")

View File

@@ -37,6 +37,66 @@ var ACE_DRY_PRESETS={
custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600}
};
// Spoolman state
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
var _spoolmanSpools=[];
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
function _loadSpoolmanStatus(){
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
_spoolmanStatus=d;
_slotSpoolMap=d.slot_spools||{};
}).catch(function(){});
}
function _buildSpoolmanSection(){
var sec=document.getElementById('fd-spoolman-section');
var rows=document.getElementById('fd-spoolman-rows');
var loading=document.getElementById('fd-spoolman-loading');
if(!sec||!rows)return;
if(!_spoolmanStatus.configured){sec.style.display='none';return;}
sec.style.display='';
rows.innerHTML='';
if(loading)loading.style.display='';
// Collect the unique AMS slots the user has selected in the paint→slot rows
var usedSlots={};
document.querySelectorAll('#fd-slots select').forEach(function(sel){
var idx=parseInt(sel.value);
if(idx>=0){
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
}
});
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
if(loading)loading.style.display='none';
_spoolmanSpools=d.spools||[];
var slotKeys=Object.keys(usedSlots).map(Number).sort(function(a,b){return a-b;});
if(!slotKeys.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)"></span>';return;}
rows.innerHTML=slotKeys.map(function(idx){
var slot=usedSlots[idx];
var col=(slot.color_hex||'#888');
var currentSpool=_slotSpoolMap[String(idx)]||'';
var opts='<option value=""></option>'+_spoolmanSpools.map(function(sp){
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':'';
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
}).join('');
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
'<span style="color:var(--txt2);min-width:46px">Slot '+(idx+1)+'</span>'+
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:11px">'+opts+'</select>'+
'</div>';
}).join('');
}).catch(function(){
if(loading)loading.style.display='none';
if(rows)rows.innerHTML='<span style="font-size:11px;color:var(--err)">Spoolman unavailable</span>';
});
}
function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];}
function _aceAutoRefillSet(aceId,on){
aceAutoRefillPrefs[String(aceId)]=!!on;
@@ -436,6 +496,7 @@ function ensureAceDryCards(){
fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){
if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();}
}).catch(function(){});
_loadSpoolmanStatus();
});
})();
@@ -787,11 +848,16 @@ function applyState(){
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
}
var spoolId=_slotSpoolMap&&_slotSpoolMap[String(globalIdx)];
var spoolBadge=(!empty&&spoolId)
?'<div class="slot-label" style="font-size:9px;color:var(--ok);margin-top:1px" title="Spoolman spool #'+spoolId+'">🧵 #'+spoolId+'</div>'
:'';
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
+'<div class="slot-circle" style="background:'+col+'"></div>'
+'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+vendorBadge
+spoolBadge
+'<div class="slot-label">'+slotLabel+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
@@ -2112,6 +2178,8 @@ function openFilamentDialog(slots){
}).join('');
}
if(dlg)dlg.classList.add('open');
// Spoolman spool picker — loaded async after dialog is visible
setTimeout(_buildSpoolmanSection, 0);
}
function closeFilamentDialog(){
@@ -2156,6 +2224,22 @@ function confirmFilamentPrint(){
}
// Pre-Print Skip: Namen der abgehakten Objekte sammeln
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
// Spoolman: collect slot→spool assignments and submit to backend
if(_spoolmanStatus.configured){
var newSlotMap={};
document.querySelectorAll('[data-spool-slot]').forEach(function(sel){
var spoolId=parseInt(sel.value);
if(!Number.isNaN(spoolId)&&spoolId>0)newSlotMap[sel.dataset.spoolSlot]=spoolId;
});
fetch(_apiUrl('/kx/spoolman/active-spool'),{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({slot_map:newSlotMap})
}).then(function(r){return r.json();}).then(function(d){
_slotSpoolMap=d.slot_spools||{};
}).catch(function(){});
}
closeFilamentDialog();
if(_filamentDialogMode==='banner'){
// Banner-Modus: normaler print/start mit Slot-Override

View File

@@ -511,6 +511,13 @@
<div id="fd-objects-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:8px;text-align:center"></div>
<div id="fd-objects" style="display:flex;flex-direction:column;gap:6px;max-height:140px;overflow-y:auto"></div>
</div>
<div id="fd-spoolman-section" style="display:none;margin-bottom:16px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:12px;color:var(--txt2);margin-bottom:8px;display:flex;align-items:center;gap:6px">
<span id="fd-spoolman-lbl">🧵 Spoolman</span>
<span id="fd-spoolman-loading" style="display:none;font-size:10px"></span>
</p>
<div id="fd-spoolman-rows" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="fd-print" onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>