diff --git a/config/config.ini.example b/config/config.ini.example index b954b90..2fe3f48 100644 --- a/config/config.ini.example +++ b/config/config.ini.example @@ -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 diff --git a/config_loader.py b/config_loader.py index 807f911..c7a7746 100644 --- a/config_loader.py +++ b/config_loader.py @@ -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")) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 37b8f29..56b36d5 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -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 ** 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//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 /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//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") diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 5e5c786..9c305c0 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -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='';return;} + rows.innerHTML=slotKeys.map(function(idx){ + var slot=usedSlots[idx]; + var col=(slot.color_hex||'#888'); + var currentSpool=_slotSpoolMap[String(idx)]||''; + var opts=''+_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 ''; + }).join(''); + return '
'+ + ''+ + 'Slot '+(idx+1)+''+ + ''+ + '
'; + }).join(''); + }).catch(function(){ + if(loading)loading.style.display='none'; + if(rows)rows.innerHTML='Spoolman unavailable'; + }); +} + 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='
'+profile.vendor+'
'; } + var spoolId=_slotSpoolMap&&_slotSpoolMap[String(globalIdx)]; + var spoolBadge=(!empty&&spoolId) + ?'
🧵 #'+spoolId+'
' + :''; html+='
' +'
' +'
'+(empty?'–':(slot.type||slot.material_type||'–'))+'
' +vendorBadge + +spoolBadge +'
'+slotLabel+'
' +'
'+pct+'
' +'
' @@ -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 diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 803d2af..5d04c3a 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -511,6 +511,13 @@
+