diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 770ea42..90efd1a 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,3 +1,4 @@ ## Changes in this build - Fix: **combined ACE-RFID filament tags (e.g. "GEEETECH PLA Bas" from third-party RFID tools) still weren't auto-matching to imported OrcaSlicer profiles**, even after the first attempt at this in a previous nightly — the matching logic was only ever wired into the OrcaSlicer slicer-sync endpoint, never into the actual MQTT status path that feeds the dashboard and Happy-Hare gate data, so the dashboard kept showing the raw unmatched RFID string. Centralized the matching so all three places that resolve a slot's filament profile benefit identically. Also added variant-token disambiguation (e.g. "Bas" vs. "Matte") for when a vendor has multiple profiles of the same material, and fixed a related edge case where a manual per-slot override could be incorrectly treated as stale on an RFID-tagged spool (Issue #101, thanks @Blaim for the extensive debugging that pinpointed this). +- Feat: new setting under Settings → Print — "Delete file from printer after successful print" — automatically removes a GCode file from the printer's own storage once it finishes printing successfully, keeping only the copy in the bridge's own GCode store. Off by default, and only ever applies to files that were uploaded through the bridge itself (so there's always a backup); files started directly from the printer or Anycubic Slicer are never touched. diff --git a/config_loader.py b/config_loader.py index f443e87..eaa674a 100644 --- a/config_loader.py +++ b/config_loader.py @@ -67,6 +67,7 @@ CONFIG_ENV_MAPPING = { "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), + "DELETE_PRINTER_FILE_AFTER_PRINT": (CONFIG_SECTION_PRINT, "delete_printer_file_after_print"), "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), "BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"), @@ -459,6 +460,7 @@ AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) +DELETE_PRINTER_FILE_AFTER_PRINT = int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "") SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0")) diff --git a/env_loader.py b/env_loader.py index 7cbe68f..ee60a52 100644 --- a/env_loader.py +++ b/env_loader.py @@ -54,5 +54,6 @@ AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) +DELETE_PRINTER_FILE_AFTER_PRINT = int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "") diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 3400f5a..1ea5cbd 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1073,6 +1073,11 @@ class KobraXBridge: self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" + # Filename of the file backing _current_job_id, kept alongside it so + # the "finished" handler can still delete it from the printer's own + # storage (Issue: delete-after-print) after self._state["filename"] + # has already been cleared as part of the terminal-state reset below. + self._current_job_filename: str = "" self._camera_autostarted: bool = False self._camera_user_stopped: bool = False # user manually stopped the camera during a print self.camera_cache: CameraCache = CameraCache() @@ -1396,6 +1401,7 @@ class KobraXBridge: 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 = {} @@ -1408,11 +1414,21 @@ class KobraXBridge: 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 @@ -1579,6 +1595,25 @@ class KobraXBridge: 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 @@ -3595,6 +3630,7 @@ class KobraXBridge: printer_id=getattr(self._args, "device_id", "unknown"), filament_assignments=assignments, ) + self._current_job_filename = filename return self._json_cors({"result": "ok", "filename": filename}) @@ -5044,6 +5080,7 @@ class KobraXBridge: "vibration_compensation": getattr(self._args, "vibration_compensation", 0), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "delete_printer_file_after_print": getattr(self._args, "delete_printer_file_after_print", 0), "print_start_dialog": getattr(self._args, "print_start_dialog", 1), "poll_interval": getattr(self._args, "poll_interval", 3), "verbose_http_log": getattr(self._args, "verbose_http_log", 0), @@ -5085,6 +5122,7 @@ class KobraXBridge: cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) + cfg.set("print", "delete_printer_file_after_print", str(int(bool(data.get("delete_printer_file_after_print", getattr(self._args, "delete_printer_file_after_print", 0)))))) cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) if "poll_interval" in data: try: @@ -6267,6 +6305,10 @@ def main(): parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION) 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("--delete-printer-file-after-print", type=int, + default=env_loader.DELETE_PRINTER_FILE_AFTER_PRINT, + help="After a successful print, delete the file from the printer's " + "own storage if it's also in the bridge's own GCode store") parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG) parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int) parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER, diff --git a/tests/test_delete_printer_file_after_print.py b/tests/test_delete_printer_file_after_print.py new file mode 100644 index 0000000..e0d6bb2 --- /dev/null +++ b/tests/test_delete_printer_file_after_print.py @@ -0,0 +1,157 @@ +"""Optional auto-delete of a printed file from the printer's own storage +after a successful print (Settings -> Print -> "Delete file from printer +after successful print"). + +Only applies to files that are also backed by the bridge's own GCode store +(otherwise the file would be gone with no copy left anywhere) and only on a +clean "finished" state - not on stoped/canceled prints, and never when the +setting is off (the default). +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import GCodeStore, KobraXBridge + + +def _bridge(delete_after_print=1): + c = MagicMock() + c.callbacks = {} + c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxdelafterprint-"), + delete_printer_file_after_print=delete_after_print, + ) + store = GCodeStore(args.data_dir) + b = KobraXBridge(c, args=args, store=store) + return b, c + + +def _seed_file(bridge, filename="test.gcode"): + file_id = "abc123" + bridge._store.save_file(file_id, filename, b"; gcode content") + return file_id + + +def _print_report(state, filename=None): + payload = {"state": state, "data": {}} + if filename is not None: + payload["data"]["filename"] = filename + return payload + + +def test_finished_print_deletes_printer_file_when_enabled_and_in_store(): + b, c = _bridge(delete_after_print=1) + _seed_file(b, "test.gcode") + + b._on_print(_print_report("printing", "test.gcode")) + assert b._current_job_id + assert b._current_job_filename == "test.gcode" + + b._on_print(_print_report("finished")) + + delete_calls = [ + call for call in c.publish.call_args_list + if call.args[:2] == ("file", "deleteBatch") + ] + assert len(delete_calls) == 1 + payload = delete_calls[0].args[2] + assert payload == {"root": "local", "files": [{"path": "/", "filename": "test.gcode"}]} + + +def test_finished_print_no_delete_when_setting_disabled(): + b, c = _bridge(delete_after_print=0) + _seed_file(b, "test.gcode") + + b._on_print(_print_report("printing", "test.gcode")) + b._on_print(_print_report("finished")) + + delete_calls = [ + call for call in c.publish.call_args_list + if call.args[:2] == ("file", "deleteBatch") + ] + assert delete_calls == [] + + +def test_finished_print_no_delete_when_file_not_in_bridge_store(): + """Files started directly from the printer/Anycubic Slicer aren't in the + bridge's own GCode store - must never be deleted, since that would leave + no copy anywhere.""" + b, c = _bridge(delete_after_print=1) + # No _seed_file() call - the file is not in the store. + + b._on_print(_print_report("printing", "not_in_store.gcode")) + assert not b._current_job_id # no store match -> no job tracked either + + b._on_print(_print_report("finished")) + + delete_calls = [ + call for call in c.publish.call_args_list + if call.args[:2] == ("file", "deleteBatch") + ] + assert delete_calls == [] + + +def test_canceled_print_does_not_delete_file(): + """Only a clean "finished" triggers the delete - a stopped/canceled + print keeps its file, since the user may want to retry it.""" + b, c = _bridge(delete_after_print=1) + _seed_file(b, "test.gcode") + + b._on_print(_print_report("printing", "test.gcode")) + b._on_print(_print_report("canceled")) + + delete_calls = [ + call for call in c.publish.call_args_list + if call.args[:2] == ("file", "deleteBatch") + ] + assert delete_calls == [] + assert b._current_job_filename == "" + + +def test_stoped_print_does_not_delete_file(): + b, c = _bridge(delete_after_print=1) + _seed_file(b, "test.gcode") + + b._on_print(_print_report("printing", "test.gcode")) + b._on_print(_print_report("stoped")) + + delete_calls = [ + call for call in c.publish.call_args_list + if call.args[:2] == ("file", "deleteBatch") + ] + assert delete_calls == [] + + +def test_current_job_filename_reset_after_finished(): + """Regression guard: _current_job_filename must not leak into the next + print's finished-handling if that next print isn't itself tracked.""" + b, c = _bridge(delete_after_print=1) + _seed_file(b, "test.gcode") + + b._on_print(_print_report("printing", "test.gcode")) + b._on_print(_print_report("finished")) + assert b._current_job_filename == "" + + # A second "finished" with no new job in between must not re-trigger a delete. + c.publish.reset_mock() + b._on_print(_print_report("finished")) + delete_calls = [ + call for call in c.publish.call_args_list + if call.args[:2] == ("file", "deleteBatch") + ] + assert delete_calls == [] + + +def test_delete_publish_failure_does_not_raise(): + """A broken MQTT send during the delete request must not propagate out + of _on_print() - it runs on the MQTT reader thread, and an unhandled + exception there would break processing of subsequent messages.""" + b, c = _bridge(delete_after_print=1) + _seed_file(b, "test.gcode") + c.publish.side_effect = RuntimeError("send failed") + + b._on_print(_print_report("printing", "test.gcode")) + b._on_print(_print_report("finished")) # must not raise diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 98dd81a..c34e172 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -464,6 +464,8 @@ function applyLang(){ setText('opt-file-ready-banner',T.settings_file_ready_banner); setText('lbl-camera-on-print',T.settings_camera_on_print); setText('lbl-web-upload-warning',T.settings_web_upload_warning); + setText('lbl-delete-printer-file-after-print',T.settings_delete_printer_file_after_print||'Delete file from printer after successful print'); + setText('lbl-delete-printer-file-after-print-hint',T.settings_delete_printer_file_after_print_hint||'Only applies to prints started through this bridge (files it uploaded itself) - prints started directly from the printer or Anycubic Slicer are never deleted, since no copy of those exists anywhere else.'); setText('fd-options-title',T.fd_options_title); setText('fd-lbl-auto-leveling',T.print_auto_leveling); @@ -1141,6 +1143,7 @@ function openSettings(){ var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0)); var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); + var dpfap=document.getElementById('s-delete-printer-file-after-print');if(dpfap)dpfap.checked=!!d.delete_printer_file_after_print; // Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage var pi=document.getElementById('s-poll-interval'); if(pi){ @@ -1912,6 +1915,7 @@ function saveSettings(){ camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10), web_upload_warning:webUploadWarning, + delete_printer_file_after_print: (document.getElementById('s-delete-printer-file-after-print')||{}).checked?1:0, poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)), verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0, spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'', diff --git a/web/themes/default/index.html b/web/themes/default/index.html index bbcbef6..c12fe1d 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -603,6 +603,11 @@ +