diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..a7acaab 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Feat: the GCode browser now has a second tab showing files stored directly on the printer's own internal storage (e.g. from prints started via Anycubic Slicer Next, bypassing the bridge) — list, multi-select, and delete them right from the Bridge UI instead of needing the printer's own display. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 5dc5dfd..71c2923 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1051,6 +1051,13 @@ class KobraXBridge: self._head_tools_model: int = -1 self._filament_mode: str = "toolhead" self._last_uploaded_file: str = "" + # Pending waiters for a specific file/report `action` (e.g. "listLocal", + # "deleteBatch"). publish()'s own return value for these actions is just + # a generic immediate ACK skeleton (code=0, all fields empty) - the real + # answer arrives later via the file/report callback (_on_file), same + # as the existing fileDetails fire-and-forget pattern. Format: + # {action: {"event": threading.Event(), "result": dict|None}}. + self._file_action_waiters: dict[str, dict] = {} 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 = "" @@ -1559,7 +1566,41 @@ 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 _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_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 "" @@ -2776,6 +2817,59 @@ class KobraXBridge: return self._json_cors({"result": "ok"}) return self._json_cors({"error": "not found"}, status=404) + async def handle_kx_printer_files(self, request): + """GET /kx/printer-files - lists files on the printer's OWN internal + storage (file/listLocal MQTT action), as opposed to /kx/files which + lists what the bridge itself has stored. Needed because prints + started directly from Anycubic Slicer Next (bypassing the bridge) + leave files on the printer that were previously only visible/ + deletable from the printer's own display (Issue #102 context).""" + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "listLocal", + lambda: self.client.publish( + "file", "listLocal", + {"page_num": 1, "page_size": 200, "path": "/"}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + records = (result.get("data") or {}).get("records") or [] + files = [r for r in records if not r.get("is_dir")] + return self._json_cors({"result": files}) + + async def handle_kx_printer_file_delete(self, request): + """POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}. + Single endpoint for both single and multi-select delete - the + printer's file/deleteBatch MQTT action natively accepts a list.""" + try: + body = await request.json() + except Exception: + body = {} + filenames = body.get("filenames") or [] + if not filenames: + return self._json_cors({"error": "no filenames given"}, status=400) + files = [{"path": "/", "filename": fn} for fn in filenames if fn] + loop = asyncio.get_event_loop() + def _delete(): + return self._wait_for_file_action( + "deleteBatch", + lambda: self.client.publish( + "file", "deleteBatch", + {"root": "local", "files": files}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _delete) + if not result or result.get("state") != "success": + return self._json_cors({"error": "delete failed", "detail": result}, status=502) + return self._json_cors({"result": "ok"}) + async def handle_kx_file_download(self, request): file_id = request.match_info["file_id"] f = self._store.get_file(file_id) @@ -5733,6 +5827,8 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete) r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download) r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify) + r.add_get("/kx/printer-files", bridge.handle_kx_printer_files) + r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete) 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) diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py new file mode 100644 index 0000000..d03070b --- /dev/null +++ b/tests/test_printer_files_endpoint.py @@ -0,0 +1,144 @@ +""" +Tests für /kx/printer-files (list) und /kx/printer-files/delete — +der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt +(via MQTT file/listLocal + file/deleteBatch, live gegen den echten +Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md). + +Important: publish()'s own return value for these actions is just a +generic immediate ACK skeleton (code=0, empty fields) - the real answer +arrives asynchronously via the file/report callback (_on_file), same as +the existing fileDetails fire-and-forget pattern. So publish() itself +returns None/skeleton here, and the "real" response is delivered by +firing bridge._on_file(...) from a background thread, simulating what +the MQTT reader thread would do when the printer's file/report arrives. +""" +import threading +import time + +import pytest + + +LISTLOCAL_SUCCESS = { + "action": "listLocal", + "code": 200, + "state": "success", + "data": { + "list_mode": 0, + "records": [ + {"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000}, + {"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000}, + {"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000}, + ], + }, +} + +LISTLOCAL_FAILED = { + "action": "listLocal", + "code": 10112, + "state": "failed", + "data": None, +} + +DELETEBATCH_SUCCESS = { + "action": "deleteBatch", + "code": 200, + "state": "success", + "data": None, + "msg": "done", +} + +DELETEBATCH_FAILED = { + "action": "deleteBatch", + "code": 10112, + "state": "failed", + "data": None, +} + + +def _deliver_async(bridge, payload, delay=0.05): + """Simulates the MQTT reader thread delivering a file/report a moment + after the fire-and-forget publish() call returns.""" + def _fire(): + time.sleep(delay) + bridge._on_file(payload) + threading.Thread(target=_fire, daemon=True).start() + + +@pytest.mark.asyncio +async def test_printer_files_lists_files_and_excludes_dirs(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 200 + data = await resp.json() + filenames = [f["filename"] for f in data["result"]] + assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded + + +@pytest.mark.asyncio +async def test_printer_files_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + await c.get("/kx/printer-files") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "listLocal" + assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"} + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_timeout(client): + c, bridge = client + bridge.client.publish.return_value = None # no file/report ever arrives + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_delete_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 200 + data = await resp.json() + assert data["result"] == "ok" + + +@pytest.mark.asyncio +async def test_printer_file_delete_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]}) + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "deleteBatch" + assert args[2] == { + "root": "local", + "files": [ + {"path": "/", "filename": "a.gcode"}, + {"path": "/", "filename": "b.gcode"}, + ], + } + + +@pytest.mark.asyncio +async def test_printer_file_delete_empty_filenames_returns_400(client): + c, _ = client + resp = await c.post("/kx/printer-files/delete", json={"filenames": []}) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_printer_file_delete_returns_502_on_printer_rejection(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 502 diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 8b583ec..ae98222 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -357,6 +357,11 @@ function applyLang(){ setText('store-lbl-select-all',T.store_select_all||'Select All'); setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); setText('store-lbl-exit-select',T.store_exit_select||'Cancel'); + setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded'); + setText('btab-lbl-printer',T.browser_tab_printer||'On Printer'); + setText('printer-store-lbl-select-all',T.store_select_all||'Select All'); + setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); + setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel'); // Dashboard card titles setText('d-card-progress',T.card_progress); setText('d-card-temps',T.card_temps); @@ -594,6 +599,17 @@ function showSettingsCat(name){ var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active'); } +// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien +// auf dem Drucker selbst (interner Speicher, via listLocal MQTT). +var _printerFilesLoaded=false; +function showBrowserTab(name){ + document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active'); + var t=document.getElementById('btab-'+name);if(t)t.classList.add('active'); + if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles(); +} + // ── Console log ── var consoleLogs=[]; var logAutoScroll=true; @@ -2987,6 +3003,143 @@ function storeDeleteSelected(){ }); } +// ── Files on the printer's own internal storage (Issue: 2nd Browser tab) ── +// Uses filename as the identity key (the printer has no numeric file id like +// the bridge's own GCodeStore) and a single batch-delete call, since the +// printer's file/deleteBatch MQTT action natively accepts a filename list. +var printerFiles=[]; +var _printerFilesSelectMode=false; +var _printerFilesSelected={}; // filename -> true + +function loadPrinterFiles(){ + var errEl=document.getElementById('printer-store-error'); + if(errEl)errEl.style.display='none'; + fetch(_apiUrl('/kx/printer-files')).then(function(r){return r.json()}).then(function(d){ + _printerFilesLoaded=true; + if(d.error){ + printerFiles=[]; + if(errEl){errEl.textContent=T.printer_store_unreachable||d.error;errEl.style.display='block';} + renderPrinterFiles(); + return; + } + printerFiles=d.result||[]; + _printerFilesSelected={}; + _printerFilesSelectMode=false; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='none'; + renderPrinterFiles(); + }).catch(function(e){ + _printerFilesLoaded=true; + if(errEl){errEl.textContent=T.printer_store_unreachable||String(e);errEl.style.display='block';} + }); +} + +function renderPrinterFiles(){ + var grid=document.getElementById('printer-store-grid'); + var empty=document.getElementById('printer-store-empty'); + if(!grid||!empty)return; + if(!printerFiles.length){ + empty.textContent=T.printer_store_empty||'No files on the printer.'; + grid.innerHTML=''; + empty.style.display='block'; + return; + } + empty.style.display='none'; + grid.innerHTML=printerFiles.map(function(f){ + var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename; + var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'–'; + var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):''; + var isSelected=!!_printerFilesSelected[f.filename]; + var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px'; + var checkbox=''+ + ''; + var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"': + 'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"'; + return '