diff --git a/bridge_constants.py b/bridge_constants.py new file mode 100644 index 0000000..521c947 --- /dev/null +++ b/bridge_constants.py @@ -0,0 +1,35 @@ +""" +bridge_constants.py - shared constants for the bridge modules. + +Extracted so the mixin modules can import these without a circular dependency +on the kobrax_moonraker_bridge facade. Re-exported from there for callers. + +──────────────────────────────────────────────────────────────────────────── +Copyright (C) 2026 viewit (KX-Bridge contributors) + +Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md. +""" + +# Maps the printer's own MQTT state strings to Klipper/Moonraker print states. +KOBRA_TO_KLIPPER_STATE = { + "free": "standby", + "busy": "printing", + "printing": "printing", + "preheating": "printing", + "auto_leveling": "printing", + "checking": "printing", + "updated": "printing", + "init": "printing", + "pausing": "paused", + "paused": "paused", + "resuming": "printing", + "resumed": "printing", + "stopping": "printing", + "stoped": "standby", + "finished": "complete", + "failed": "error", + "canceled": "standby", +} + +MOONRAKER_VERSION = "v0.9.3-1" +KLIPPER_VERSION = "v0.12.0-1" diff --git a/bridge_mqtt.py b/bridge_mqtt.py new file mode 100644 index 0000000..ef834b5 --- /dev/null +++ b/bridge_mqtt.py @@ -0,0 +1,412 @@ +""" +bridge_mqtt.py - MqttCallbacksMixin for KobraXBridge. + +The MQTT reader-thread callbacks (_on_temp/_on_print/_on_info/_on_skip/ +_on_buried/_on_file) and their helpers (_wait_for_file_action, +_delete_printer_file_fire_and_forget, _apply_preprint_skip_after_start). +Mixed into KobraXBridge; relies on shared bridge state (self._state, +self.client, self._store, self._current_job_*, self._spoolman*, etc.). + +──────────────────────────────────────────────────────────────────────────── +Copyright (C) 2026 viewit (KX-Bridge contributors) + +Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md. +""" + +import copy +import time +import asyncio +import logging +import threading + +try: + import config_loader as env_loader +except ImportError: + import env_loader + +from bridge_constants import KOBRA_TO_KLIPPER_STATE + +log = logging.getLogger("bridge") + + +class MqttCallbacksMixin: + # ------------------------------------------------------------------------- + # MQTT callbacks (called from reader thread) + # ------------------------------------------------------------------------- + + def _on_temp(self, payload: dict): + d = payload.get("data") or {} + self._state["nozzle_temp"] = float(d.get("curr_nozzle_temp", 0)) + self._state["nozzle_target"] = float(d.get("target_nozzle_temp", 0)) + self._state["bed_temp"] = float(d.get("curr_hotbed_temp", 0)) + self._state["bed_target"] = float(d.get("target_hotbed_temp", 0)) + self._push_status_update() + + def _on_print(self, payload: dict): + d = payload.get("data") or {} + kobra_state = payload.get("state", "") + self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") + if kobra_state: + self._state["kobra_state"] = kobra_state + + # Automatically switch on the camera at print start (settings option). + # Centralized here so it covers all print start paths (OrcaSlicer + UI). + # _camera_autostarted verhindert Mehrfach-Trigger pro Druck. + if kobra_state == "printing": + 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() + log.info("Camera switched on automatically at print start") + except Exception as e: + log.warning(f"Camera auto-start failed: {e}") + elif kobra_state in ("free", "finished", "stoped", "canceled"): + self._camera_autostarted = False + self._camera_user_stopped = False # release for the next print + + if kobra_state in ("pause", "paused"): + pause_msg = payload.get("msg", "") + if pause_msg: + error_code = payload.get("code", 0) + self._state["error_code"] = error_code + self._state["pause_msg"] = pause_msg + log.warning(f"Printer paused: [{error_code}] {pause_msg}") + elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"): + self._state["error_code"] = 0 + self._state["pause_msg"] = "" + + # Job-History: Druckstart erkennen + if kobra_state == "printing" and not self._current_job_id: + filename = d.get("filename", self._state.get("filename", "")) + if filename: + gf = self._store.get_file_by_name(filename) + if gf: + self._current_job_id = self._store.start_job( + gcode_file_id=gf["id"], + printer_id=self._printer_id, + ) + self._current_job_filename = filename + log.info(f"Job started: {self._current_job_id} for {filename}") + self._spoolman_slot_usage = {} + self._spoolman_slot_reported = {} + self._spoolman_last_usage = 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 = "" + # Optional cleanup (Settings -> Print): only for files that are + # also backed by the bridge's own GCode store - never for prints + # started directly from the printer/Anycubic Slicer, which would + # otherwise be deleted with no copy left anywhere (Issue: delete + # printer file after successful print). Deliberately only on a + # clean "finished" - stoped/canceled prints keep their file. + if getattr(self._args, "delete_printer_file_after_print", 0) and self._current_job_filename: + self._delete_printer_file_fire_and_forget(self._current_job_filename) + self._current_job_filename = "" + 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 = "" + self._current_job_filename = "" + + # Terminal states (successful finish AND stop/cancel) must leave the + # same clean end state - a "finished" print used to only clear + # file_ready (Issue #29), leaving progress/filename/duration/layer + # fields stuck at the last job's values until the *next* print + # happened to overwrite them (Issue #102). + if kobra_state in ("finished", "stoped", "canceled"): + self._state["progress"] = 0.0 + self._state["filename"] = "" + self._state["file_ready"] = "" + self._state["print_duration"] = 0 + self._state["remain_time"] = 0 + self._state["slicer_time"] = 0 + self._state["layer_height"] = 0.0 + self._state["first_layer_height"] = 0.0 + self._state["supplies_usage"] = 0 + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 + self._thumbnail_b64 = "" + else: + # Only adopt the payload's filename outside terminal states - the + # printer often still reports the just-finished job's filename in + # the same "finished"/"stoped"/"canceled" message that triggered + # the reset above, which would otherwise immediately undo it. + self._state["filename"] = d.get("filename", self._state["filename"]) + # Pre-print phases (leveling/preheating/checking) report their own + # "progress" - passing it through would make display_status.progress/ + # virtual_sdcard.progress jump non-monotonically once real printing + # starts and the value resets (Issue #102). + if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): + self._state["progress"] = float(d["progress"]) / 100.0 + if "print_time" in d: + self._state["print_duration"] = int(d["print_time"]) * 60 + if "remain_time" in d: + self._state["remain_time"] = int(d["remain_time"]) * 60 + if "curr_layer" in d: + self._state["curr_layer"] = d["curr_layer"] + if "total_layers" in d: + 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"]) + self._push_status_update() + + def _on_info(self, payload: dict): + d = payload.get("data") or {} + # Only adopt the MQTT name if no custom name is set (env or per-printer config) + if not env_loader.get("BRIDGE_PRINTER_NAME") and not getattr(self, "_name_locked", False): + self._state["printer_name"] = d.get("printerName", self._state["printer_name"]) + self._state["firmware_version"] = d.get("version", self._state["firmware_version"]) + # The real print state lives in info/report inside the nested + # project.state ("printing"/"paused"/...). The top-level data.state is only + # the device state ("busy"/"free") and would swallow "paused". + project = d.get("project") or {} + proj_state = project.get("state", "") + kobra_state = proj_state or d.get("state", "") + if kobra_state: + self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby") + self._state["kobra_state"] = kobra_state + # Hide the upload banner after the print ends (Issue #29) - the state also + # arrives via info/report (project.state) depending on the printer, not only print/report. + # Layer fields must reset here too (Issue #102) - info/report is the + # only source for curr_layer/total_layers on some printers, and they + # otherwise stay stuck at the last job's values indefinitely. + if kobra_state in ("finished", "stoped", "canceled"): + self._state["file_ready"] = "" + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 + # Camera auto-start here as well (OrcaSlicer often reports the start via info/report). + # The _camera_autostarted guard prevents a double start with _on_print. + if kobra_state == "printing": + 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() + log.info("Camera switched on automatically at print start") + except Exception as e: + log.warning(f"Camera auto-start failed: {e}") + elif kobra_state in ("free", "finished", "stoped", "canceled"): + self._camera_autostarted = False + self._camera_user_stopped = False # release for the next print + if project: + if "filename" in project: + self._state["filename"] = project["filename"] + # Same non-monotonic-progress guard as _on_print (Issue #102). + if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): + self._state["progress"] = float(project["progress"]) / 100.0 + if "print_time" in project: + self._state["print_duration"] = int(project["print_time"]) * 60 + if "remain_time" in project: + self._state["remain_time"] = int(project["remain_time"]) * 60 + if "curr_layer" in project: + self._state["curr_layer"] = project["curr_layer"] + if "total_layers" in project: + self._state["total_layers"] = project["total_layers"] + t = d.get("temp") or {} + if t: + self._state["nozzle_temp"] = float(t.get("curr_nozzle_temp", 0)) + self._state["nozzle_target"] = float(t.get("target_nozzle_temp", 0)) + self._state["bed_temp"] = float(t.get("curr_hotbed_temp", 0)) + self._state["bed_target"] = float(t.get("target_hotbed_temp", 0)) + urls = d.get("urls") or {} + if urls.get("fileUploadurl"): + self._state["upload_url"] = urls["fileUploadurl"] + if urls.get("rtspUrl"): + self._state["camera_url"] = urls["rtspUrl"] + self.camera_cache.set_url(urls["rtspUrl"]) + fan = d.get("fan_speed_pct") + if fan is not None: + self._state["fan_speed"] = int(fan) + speed_mode = d.get("print_speed_mode") + if speed_mode is not None: + self._state["print_speed_mode"] = int(speed_mode) + self._push_status_update() + + def _on_skip(self, payload: dict): + """skip/report-Callback (Part-Skip-Feature, v0.9.10). + + The printer ALWAYS reports the list of already-skipped objects here + (objects_skip_parts), whether on query_obj or after skip/start. + The full object list comes from file/report. + """ + d = payload.get("data") or {} + skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or [] + # While a pre-print skip is still pending, ignore empty early reports + # so the UI doesn't snap back before the printer confirms the skip. + now = time.time() + if (not skipped and self._pending_preprint_skip + and now <= self._pending_preprint_skip_deadline): + return + + # During an active print, skip states are effectively monotonic. + # Some firmware reports come back empty/partial in between; + # those must not remove already-confirmed skip objects from the UI. + existing_skipped = [str(n) for n in (self._skip_state.get("skipped") or []) if n] + existing_set = set(existing_skipped) + incoming_skipped = [str(n) for n in (skipped or []) if n] + incoming_set = set(incoming_skipped) + active_print = self._state.get("print_state") in ("printing", "paused") + if active_print and existing_set: + if not incoming_set: + skipped = list(existing_skipped) + elif not incoming_set.issuperset(existing_set): + merged = list(existing_skipped) + for n in incoming_skipped: + if n not in existing_set: + merged.append(n) + skipped = merged + + # Release the pending lock once the printer confirms the requested objects + if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip): + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + self._skip_state = { + "skipped": list(skipped), + "ts": int(time.time()), + } + if payload.get("state") == "done" or payload.get("code") == 200: + log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") + + def _delete_printer_file_fire_and_forget(self, filename: str) -> None: + """Deletes a file from the printer's own storage without waiting for + the response - called from _on_print(), which runs on the MQTT + reader thread itself, so blocking here (like _wait_for_file_action + does) would deadlock: the file/report reply that would unblock it is + dispatched from that same thread. Fire-and-forget is safe because the + bridge's own copy in the GCode store is what matters for correctness + here; a failed delete just leaves the printer's storage as it is + (Settings -> Print -> "Delete file from printer after successful print").""" + try: + self.client.publish( + "file", "deleteBatch", + {"root": "local", "files": [{"path": "/", "filename": filename}]}, + timeout=0, + ) + log.info(f"Requested printer-storage delete for {filename} after successful print") + except Exception as e: + log.warning(f"Delete-after-print request failed for {filename}: {e}") + + def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None: + """Sends a file/* MQTT request (via send_fn, which must call + self.client.publish(..., timeout=0) fire-and-forget) and blocks the + calling thread until a matching file/report with this `action` + arrives via _on_file, or the timeout elapses. + + Needed because the printer's publish() return value for actions like + listLocal/deleteBatch is just a generic immediate ACK skeleton + (code=0, empty fields) - the real response is a separate, later + file/report message, same as the existing fileDetails pattern. + Must be called from a worker thread (e.g. via run_in_executor), not + the asyncio event loop, since it blocks on a threading.Event. + """ + event = threading.Event() + waiter = {"event": event, "result": None} + self._file_action_waiters[action] = waiter + try: + send_fn() + event.wait(timeout) + return waiter["result"] + finally: + if self._file_action_waiters.get(action) is waiter: + del self._file_action_waiters[action] + + def _on_buried(self, payload: dict): + """buried/report - the printer's own analytics event, fired once per + print start (verified live against a real Kobra X: fires identically + for prints started via Anycubic Slicer Next and via OrcaSlicer/the + bridge). Carries gcode_size/estimate_duration/total_layers, which + _build_file_metadata() falls back to for files not in our own + GCodeStore (Issue #102), plus printer storage usage.""" + d = payload.get("data") or {} + task_name = d.get("task_name") or "" + if not task_name: + return + self._buried_cache = { + "task_name": task_name, + "gcode_size": int(d.get("gcode_size") or 0), + "estimate_duration": int(d.get("estimate_duration") or 0), + "total_layers": int(d.get("total_layers") or 0), + } + self._state["storage_total_mb"] = int(d.get("storage_total") or 0) + self._state["storage_used_mb"] = int(d.get("storage_used") or 0) + log.info( + f"buried/report: {task_name} size={d.get('gcode_size')} " + f"est={d.get('estimate_duration')}s layers={d.get('total_layers')}" + ) + + def _on_file(self, payload: dict): + # Deliver to any pending listLocal/deleteBatch waiter first (see + # _wait_for_file_action) - these actions carry no file_details/ + # thumbnail payload of their own, so this doesn't interfere with the + # handling below. + action = payload.get("action") or "" + waiter = self._file_action_waiters.get(action) + if waiter is not None: + waiter["result"] = payload + waiter["event"].set() + + d = payload.get("data") or {} + details = d.get("file_details") or {} + thumb = details.get("thumbnail") or details.get("png_image") or "" + file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file + active_print = self._state.get("print_state") in ("printing", "paused") + current_print_file = self._state.get("filename") or "" + # Uploads during a running print must not overwrite the active + # progress preview. + if thumb and (not active_print or (file_name and file_name == current_print_file)): + self._thumbnail_b64 = thumb + log.info(f"Thumbnail received: {len(thumb)} base64 chars") + # Part-Skip: Objekt-Liste + optionales SVG (v0.9.10) + objs = details.get("objects_skip_parts") or [] + svg = details.get("svg_image") or "" + if objs: + filename = file_name + if filename: + try: + self._store.update_file_objects(filename, objs, 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 failed: {e}") + self._push_status_update() + + def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75): + """Sends the skip command only after the printer switched to the printing state. + + Before that, the command goes nowhere (no active print). + """ + wanted = [str(n) for n in (names or []) if isinstance(n, str) and n] + if not wanted: + return False + for i in range(max(1, int(retries))): + try: + if self._state.get("print_state") not in ("printing", "paused"): + time.sleep(max(0.1, float(delay_s))) + continue + resp = self.client.skip_objects(wanted) + if resp is not None: + log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}") + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + return True + except Exception as e: + log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}") + time.sleep(max(0.1, float(delay_s))) + log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts") + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + return False + diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 51a24bf..98c398a 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -68,6 +68,7 @@ from gcode_meta import ( from camera import CameraCache, _find_ffmpeg from credentials import _kx_fetch_credentials, _kx_generate_signature, _kx_decrypt_info from bridge_spoolman import SpoolmanMixin +from bridge_mqtt import MqttCallbacksMixin try: @@ -112,31 +113,10 @@ from bridge_logging import ( _browser_handler, ) -KOBRA_TO_KLIPPER_STATE = { - "free": "standby", - "busy": "printing", - "printing": "printing", - "preheating": "printing", - "auto_leveling": "printing", - "checking": "printing", - "updated": "printing", - "init": "printing", - "pausing": "paused", - "paused": "paused", - "resuming": "printing", - "resumed": "printing", - "stopping": "printing", - "stoped": "standby", - "finished": "complete", - "failed": "error", - "canceled": "standby", -} - -MOONRAKER_VERSION = "v0.9.3-1" -KLIPPER_VERSION = "v0.12.0-1" +from bridge_constants import KOBRA_TO_KLIPPER_STATE, MOONRAKER_VERSION, KLIPPER_VERSION -class KobraXBridge(SpoolmanMixin): +class KobraXBridge(SpoolmanMixin, MqttCallbacksMixin): def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None): self.client = client self._args = args @@ -371,386 +351,6 @@ class KobraXBridge(SpoolmanMixin): out[key]["name"] = name or str(d.get("name", "Custom")) return out - # ------------------------------------------------------------------------- - # MQTT callbacks (called from reader thread) - # ------------------------------------------------------------------------- - - def _on_temp(self, payload: dict): - d = payload.get("data") or {} - self._state["nozzle_temp"] = float(d.get("curr_nozzle_temp", 0)) - self._state["nozzle_target"] = float(d.get("target_nozzle_temp", 0)) - self._state["bed_temp"] = float(d.get("curr_hotbed_temp", 0)) - self._state["bed_target"] = float(d.get("target_hotbed_temp", 0)) - self._push_status_update() - - def _on_print(self, payload: dict): - d = payload.get("data") or {} - kobra_state = payload.get("state", "") - self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") - if kobra_state: - self._state["kobra_state"] = kobra_state - - # Automatically switch on the camera at print start (settings option). - # Centralized here so it covers all print start paths (OrcaSlicer + UI). - # _camera_autostarted verhindert Mehrfach-Trigger pro Druck. - if kobra_state == "printing": - 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() - log.info("Camera switched on automatically at print start") - except Exception as e: - log.warning(f"Camera auto-start failed: {e}") - elif kobra_state in ("free", "finished", "stoped", "canceled"): - self._camera_autostarted = False - self._camera_user_stopped = False # release for the next print - - if kobra_state in ("pause", "paused"): - pause_msg = payload.get("msg", "") - if pause_msg: - error_code = payload.get("code", 0) - self._state["error_code"] = error_code - self._state["pause_msg"] = pause_msg - log.warning(f"Printer paused: [{error_code}] {pause_msg}") - elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"): - self._state["error_code"] = 0 - self._state["pause_msg"] = "" - - # Job-History: Druckstart erkennen - if kobra_state == "printing" and not self._current_job_id: - filename = d.get("filename", self._state.get("filename", "")) - if filename: - gf = self._store.get_file_by_name(filename) - if gf: - self._current_job_id = self._store.start_job( - gcode_file_id=gf["id"], - printer_id=self._printer_id, - ) - self._current_job_filename = filename - log.info(f"Job started: {self._current_job_id} for {filename}") - self._spoolman_slot_usage = {} - self._spoolman_slot_reported = {} - self._spoolman_last_usage = 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 = "" - # Optional cleanup (Settings -> Print): only for files that are - # also backed by the bridge's own GCode store - never for prints - # started directly from the printer/Anycubic Slicer, which would - # otherwise be deleted with no copy left anywhere (Issue: delete - # printer file after successful print). Deliberately only on a - # clean "finished" - stoped/canceled prints keep their file. - if getattr(self._args, "delete_printer_file_after_print", 0) and self._current_job_filename: - self._delete_printer_file_fire_and_forget(self._current_job_filename) - self._current_job_filename = "" - 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 = "" - self._current_job_filename = "" - - # Terminal states (successful finish AND stop/cancel) must leave the - # same clean end state - a "finished" print used to only clear - # file_ready (Issue #29), leaving progress/filename/duration/layer - # fields stuck at the last job's values until the *next* print - # happened to overwrite them (Issue #102). - if kobra_state in ("finished", "stoped", "canceled"): - self._state["progress"] = 0.0 - self._state["filename"] = "" - self._state["file_ready"] = "" - self._state["print_duration"] = 0 - self._state["remain_time"] = 0 - self._state["slicer_time"] = 0 - self._state["layer_height"] = 0.0 - self._state["first_layer_height"] = 0.0 - self._state["supplies_usage"] = 0 - self._state["curr_layer"] = 0 - self._state["total_layers"] = 0 - self._thumbnail_b64 = "" - else: - # Only adopt the payload's filename outside terminal states - the - # printer often still reports the just-finished job's filename in - # the same "finished"/"stoped"/"canceled" message that triggered - # the reset above, which would otherwise immediately undo it. - self._state["filename"] = d.get("filename", self._state["filename"]) - # Pre-print phases (leveling/preheating/checking) report their own - # "progress" - passing it through would make display_status.progress/ - # virtual_sdcard.progress jump non-monotonically once real printing - # starts and the value resets (Issue #102). - if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): - self._state["progress"] = float(d["progress"]) / 100.0 - if "print_time" in d: - self._state["print_duration"] = int(d["print_time"]) * 60 - if "remain_time" in d: - self._state["remain_time"] = int(d["remain_time"]) * 60 - if "curr_layer" in d: - self._state["curr_layer"] = d["curr_layer"] - if "total_layers" in d: - 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"]) - self._push_status_update() - - def _on_info(self, payload: dict): - d = payload.get("data") or {} - # Only adopt the MQTT name if no custom name is set (env or per-printer config) - if not env_loader.get("BRIDGE_PRINTER_NAME") and not getattr(self, "_name_locked", False): - self._state["printer_name"] = d.get("printerName", self._state["printer_name"]) - self._state["firmware_version"] = d.get("version", self._state["firmware_version"]) - # The real print state lives in info/report inside the nested - # project.state ("printing"/"paused"/...). The top-level data.state is only - # the device state ("busy"/"free") and would swallow "paused". - project = d.get("project") or {} - proj_state = project.get("state", "") - kobra_state = proj_state or d.get("state", "") - if kobra_state: - self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby") - self._state["kobra_state"] = kobra_state - # Hide the upload banner after the print ends (Issue #29) - the state also - # arrives via info/report (project.state) depending on the printer, not only print/report. - # Layer fields must reset here too (Issue #102) - info/report is the - # only source for curr_layer/total_layers on some printers, and they - # otherwise stay stuck at the last job's values indefinitely. - if kobra_state in ("finished", "stoped", "canceled"): - self._state["file_ready"] = "" - self._state["curr_layer"] = 0 - self._state["total_layers"] = 0 - # Camera auto-start here as well (OrcaSlicer often reports the start via info/report). - # The _camera_autostarted guard prevents a double start with _on_print. - if kobra_state == "printing": - 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() - log.info("Camera switched on automatically at print start") - except Exception as e: - log.warning(f"Camera auto-start failed: {e}") - elif kobra_state in ("free", "finished", "stoped", "canceled"): - self._camera_autostarted = False - self._camera_user_stopped = False # release for the next print - if project: - if "filename" in project: - self._state["filename"] = project["filename"] - # Same non-monotonic-progress guard as _on_print (Issue #102). - if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): - self._state["progress"] = float(project["progress"]) / 100.0 - if "print_time" in project: - self._state["print_duration"] = int(project["print_time"]) * 60 - if "remain_time" in project: - self._state["remain_time"] = int(project["remain_time"]) * 60 - if "curr_layer" in project: - self._state["curr_layer"] = project["curr_layer"] - if "total_layers" in project: - self._state["total_layers"] = project["total_layers"] - t = d.get("temp") or {} - if t: - self._state["nozzle_temp"] = float(t.get("curr_nozzle_temp", 0)) - self._state["nozzle_target"] = float(t.get("target_nozzle_temp", 0)) - self._state["bed_temp"] = float(t.get("curr_hotbed_temp", 0)) - self._state["bed_target"] = float(t.get("target_hotbed_temp", 0)) - urls = d.get("urls") or {} - if urls.get("fileUploadurl"): - self._state["upload_url"] = urls["fileUploadurl"] - if urls.get("rtspUrl"): - self._state["camera_url"] = urls["rtspUrl"] - self.camera_cache.set_url(urls["rtspUrl"]) - fan = d.get("fan_speed_pct") - if fan is not None: - self._state["fan_speed"] = int(fan) - speed_mode = d.get("print_speed_mode") - if speed_mode is not None: - self._state["print_speed_mode"] = int(speed_mode) - self._push_status_update() - - def _on_skip(self, payload: dict): - """skip/report-Callback (Part-Skip-Feature, v0.9.10). - - The printer ALWAYS reports the list of already-skipped objects here - (objects_skip_parts), whether on query_obj or after skip/start. - The full object list comes from file/report. - """ - d = payload.get("data") or {} - skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or [] - # While a pre-print skip is still pending, ignore empty early reports - # so the UI doesn't snap back before the printer confirms the skip. - now = time.time() - if (not skipped and self._pending_preprint_skip - and now <= self._pending_preprint_skip_deadline): - return - - # During an active print, skip states are effectively monotonic. - # Some firmware reports come back empty/partial in between; - # those must not remove already-confirmed skip objects from the UI. - existing_skipped = [str(n) for n in (self._skip_state.get("skipped") or []) if n] - existing_set = set(existing_skipped) - incoming_skipped = [str(n) for n in (skipped or []) if n] - incoming_set = set(incoming_skipped) - active_print = self._state.get("print_state") in ("printing", "paused") - if active_print and existing_set: - if not incoming_set: - skipped = list(existing_skipped) - elif not incoming_set.issuperset(existing_set): - merged = list(existing_skipped) - for n in incoming_skipped: - if n not in existing_set: - merged.append(n) - skipped = merged - - # Release the pending lock once the printer confirms the requested objects - if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip): - self._pending_preprint_skip = [] - self._pending_preprint_skip_deadline = 0.0 - self._skip_state = { - "skipped": list(skipped), - "ts": int(time.time()), - } - if payload.get("state") == "done" or payload.get("code") == 200: - log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") - - def _delete_printer_file_fire_and_forget(self, filename: str) -> None: - """Deletes a file from the printer's own storage without waiting for - the response - called from _on_print(), which runs on the MQTT - reader thread itself, so blocking here (like _wait_for_file_action - does) would deadlock: the file/report reply that would unblock it is - dispatched from that same thread. Fire-and-forget is safe because the - bridge's own copy in the GCode store is what matters for correctness - here; a failed delete just leaves the printer's storage as it is - (Settings -> Print -> "Delete file from printer after successful print").""" - try: - self.client.publish( - "file", "deleteBatch", - {"root": "local", "files": [{"path": "/", "filename": filename}]}, - timeout=0, - ) - log.info(f"Requested printer-storage delete for {filename} after successful print") - except Exception as e: - log.warning(f"Delete-after-print request failed for {filename}: {e}") - - def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None: - """Sends a file/* MQTT request (via send_fn, which must call - self.client.publish(..., timeout=0) fire-and-forget) and blocks the - calling thread until a matching file/report with this `action` - arrives via _on_file, or the timeout elapses. - - Needed because the printer's publish() return value for actions like - listLocal/deleteBatch is just a generic immediate ACK skeleton - (code=0, empty fields) - the real response is a separate, later - file/report message, same as the existing fileDetails pattern. - Must be called from a worker thread (e.g. via run_in_executor), not - the asyncio event loop, since it blocks on a threading.Event. - """ - event = threading.Event() - waiter = {"event": event, "result": None} - self._file_action_waiters[action] = waiter - try: - send_fn() - event.wait(timeout) - return waiter["result"] - finally: - if self._file_action_waiters.get(action) is waiter: - del self._file_action_waiters[action] - - def _on_buried(self, payload: dict): - """buried/report - the printer's own analytics event, fired once per - print start (verified live against a real Kobra X: fires identically - for prints started via Anycubic Slicer Next and via OrcaSlicer/the - bridge). Carries gcode_size/estimate_duration/total_layers, which - _build_file_metadata() falls back to for files not in our own - GCodeStore (Issue #102), plus printer storage usage.""" - d = payload.get("data") or {} - task_name = d.get("task_name") or "" - if not task_name: - return - self._buried_cache = { - "task_name": task_name, - "gcode_size": int(d.get("gcode_size") or 0), - "estimate_duration": int(d.get("estimate_duration") or 0), - "total_layers": int(d.get("total_layers") or 0), - } - self._state["storage_total_mb"] = int(d.get("storage_total") or 0) - self._state["storage_used_mb"] = int(d.get("storage_used") or 0) - log.info( - f"buried/report: {task_name} size={d.get('gcode_size')} " - f"est={d.get('estimate_duration')}s layers={d.get('total_layers')}" - ) - - def _on_file(self, payload: dict): - # Deliver to any pending listLocal/deleteBatch waiter first (see - # _wait_for_file_action) - these actions carry no file_details/ - # thumbnail payload of their own, so this doesn't interfere with the - # handling below. - action = payload.get("action") or "" - waiter = self._file_action_waiters.get(action) - if waiter is not None: - waiter["result"] = payload - waiter["event"].set() - - d = payload.get("data") or {} - details = d.get("file_details") or {} - thumb = details.get("thumbnail") or details.get("png_image") or "" - file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file - active_print = self._state.get("print_state") in ("printing", "paused") - current_print_file = self._state.get("filename") or "" - # Uploads during a running print must not overwrite the active - # progress preview. - if thumb and (not active_print or (file_name and file_name == current_print_file)): - self._thumbnail_b64 = thumb - log.info(f"Thumbnail received: {len(thumb)} base64 chars") - # Part-Skip: Objekt-Liste + optionales SVG (v0.9.10) - objs = details.get("objects_skip_parts") or [] - svg = details.get("svg_image") or "" - if objs: - filename = file_name - if filename: - try: - self._store.update_file_objects(filename, objs, 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 failed: {e}") - self._push_status_update() - - def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75): - """Sends the skip command only after the printer switched to the printing state. - - Before that, the command goes nowhere (no active print). - """ - wanted = [str(n) for n in (names or []) if isinstance(n, str) and n] - if not wanted: - return False - for i in range(max(1, int(retries))): - try: - if self._state.get("print_state") not in ("printing", "paused"): - time.sleep(max(0.1, float(delay_s))) - continue - resp = self.client.skip_objects(wanted) - if resp is not None: - log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}") - self._pending_preprint_skip = [] - self._pending_preprint_skip_deadline = 0.0 - return True - except Exception as e: - log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}") - time.sleep(max(0.1, float(delay_s))) - log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts") - self._pending_preprint_skip = [] - self._pending_preprint_skip_deadline = 0.0 - return False - @staticmethod def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str: """Detect active filament topology mode.