feat(print): add option to delete file from printer after successful print

Frees up the printer's own limited storage automatically once a print
finishes, while keeping the file safely in the bridge's own GCode store.

Deliberately scoped to files uploaded through the bridge itself only
(matched via the GCode store, same lookup the job-history feature already
uses) - a print started directly from the printer or Anycubic Slicer has
no backup anywhere else, so it's never touched regardless of the setting.
Only triggers on a clean "finished" state, not on stopped/canceled prints,
since the user may want to retry those.

Off by default. The delete request is fire-and-forget, sent directly from
_on_print() (which runs on the MQTT reader thread) rather than through the
existing _wait_for_file_action() helper - that helper blocks waiting for a
reply dispatched from that same thread, which would deadlock if called
from within it.
This commit is contained in:
2026-08-03 13:12:47 +02:00
parent 23e3831232
commit 5a44d0abab
13 changed files with 224 additions and 0 deletions

View File

@@ -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.

View File

@@ -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"))

View File

@@ -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", "")

View File

@@ -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,

View File

@@ -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

View File

@@ -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||'',

View File

@@ -603,6 +603,11 @@
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-delete-printer-file-after-print" style="width:auto;margin:0">
<label id="lbl-delete-printer-file-after-print" style="margin:0;cursor:pointer" for="s-delete-printer-file-after-print">Delete file from printer after successful print</label>
</div>
<small id="lbl-delete-printer-file-after-print-hint" style="color:var(--txt2)"></small>
</div>
</div>

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "Auswahl speichern",
"settings_visible_vendors_save_label": "Auswahl speichern",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"settings_delete_printer_file_after_print": "Datei nach erfolgreichem Druck vom Drucker löschen",
"settings_delete_printer_file_after_print_hint": "Gilt nur für Drucke, die über diese Bridge gestartet wurden (selbst hochgeladene Dateien) - direkt am Drucker oder über Anycubic Slicer gestartete Drucke werden nie gelöscht, da davon sonst keine Kopie mehr existiert.",
"sf_all": "Alle",
"sf_err": "✗ Fehler",
"sf_new": "Neu",

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "Save selection",
"settings_visible_vendors_save_label": "Save selection",
"settings_web_upload_warning": "Show warning when printing web uploads",
"settings_delete_printer_file_after_print": "Delete file from printer after successful print",
"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.",
"sf_all": "All",
"sf_err": "✗ Failed",
"sf_new": "New",

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "Guardar selección",
"settings_visible_vendors_save_label": "Guardar selección",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"settings_delete_printer_file_after_print": "Eliminar archivo de la impresora tras una impresión exitosa",
"settings_delete_printer_file_after_print_hint": "Solo aplica a impresiones iniciadas a través de este bridge (archivos que él mismo subió) - las impresiones iniciadas directamente desde la impresora o Anycubic Slicer nunca se eliminan, ya que no existe ninguna copia en otro lugar.",
"sf_all": "Todos",
"sf_err": "✗ Fallido",
"sf_new": "Nuevo",

View File

@@ -279,6 +279,8 @@
"settings_visible_vendors_save": "Enregistrer la sélection",
"settings_visible_vendors_save_label": "Enregistrer la sélection",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"settings_delete_printer_file_after_print": "Supprimer le fichier de l'imprimante après une impression réussie",
"settings_delete_printer_file_after_print_hint": "S'applique uniquement aux impressions lancées via ce bridge (fichiers qu'il a lui-même téléversés) - les impressions lancées directement depuis l'imprimante ou Anycubic Slicer ne sont jamais supprimées, car aucune copie n'existe ailleurs.",
"sf_all": "Tout",
"sf_err": "✗ Échoués",
"sf_new": "Nouveau",

View File

@@ -279,6 +279,8 @@
"settings_visible_vendors_save": "Salva selezione",
"settings_visible_vendors_save_label": "Salva selezione",
"settings_web_upload_warning": "Mostra un avviso quando si stampano caricamenti web",
"settings_delete_printer_file_after_print": "Elimina il file dalla stampante dopo una stampa riuscita",
"settings_delete_printer_file_after_print_hint": "Si applica solo alle stampe avviate tramite questo bridge (file caricati da esso) - le stampe avviate direttamente dalla stampante o da Anycubic Slicer non vengono mai eliminate, poiché non ne esiste alcuna copia altrove.",
"sf_all": "Tutti",
"sf_err": "✗ Fallito",
"sf_new": "Nuovo",

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "保存选择",
"settings_visible_vendors_save_label": "保存选择",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"settings_delete_printer_file_after_print": "打印成功后从打印机删除文件",
"settings_delete_printer_file_after_print_hint": "仅适用于通过此网桥启动的打印(即由网桥自己上传的文件)——直接从打印机或 Anycubic Slicer 启动的打印任务永远不会被删除,因为它们没有其他备份。",
"sf_all": "全部",
"sf_err": "✗ 失败",
"sf_new": "新",