From 1f1d60d571fe660bd4e6970fec2e436488436b98 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 14:25:50 +0200 Subject: [PATCH] feat(browser): show real GCode thumbnails in the "On Printer" tab The printer's file/fileDetails MQTT action extracts and base64-encodes the embedded "; thumbnail begin" block from a GCode file's header on demand and returns it inline as data.file_details.thumbnail - verified live against a real Kobra X (a valid 230x110 PNG came back for an existing print file). New endpoint GET /kx/printer-files/{filename}/thumbnail wraps this via the existing _wait_for_file_action() helper (same fire-and-forget + file/report-callback pattern as listLocal/deleteBatch). Results are cached in-memory per filename (self._printer_thumbnail_cache) - a file's thumbnail never changes while it exists on the printer, and the tab can list 100+ files at once. Frontend fetches thumbnails lazily through a small sequential queue (_printerThumbQueue/_pumpPrinterThumbQueue) after rendering the file list, one request at a time rather than firing 100+ concurrent MQTT roundtrips, and swaps each card's placeholder printer icon for the real once its thumbnail arrives. Client-side cache (_printerThumbCache) avoids re-fetching on re-render (e.g. after a selection change). Verified end-to-end against the real printer and visually via Playwright: all 10 listed files rendered their actual, distinct print preview thumbnails instead of the generic icon. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 39 ++++++++++++ tests/test_printer_files_endpoint.py | 89 ++++++++++++++++++++++++++++ web/themes/default/app.js | 49 ++++++++++++++- 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..7ceb2a1 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Feat: the "On Printer" browser tab now shows each file's actual embedded GCode thumbnail instead of a generic printer icon — fetched lazily and cached, so switching to the tab doesn't need to query the printer 100+ times at once. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 71c2923..db32088 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1058,6 +1058,10 @@ class KobraXBridge: # as the existing fileDetails fire-and-forget pattern. Format: # {action: {"event": threading.Event(), "result": dict|None}}. self._file_action_waiters: dict[str, dict] = {} + # Thumbnail cache for files on the printer's own storage (filename -> + # base64 PNG string, "" if the file has no embedded thumbnail). + # In-memory only - not persisted, cleared on restart. + self._printer_thumbnail_cache: dict[str, str] = {} 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 = "" @@ -2870,6 +2874,40 @@ class KobraXBridge: return self._json_cors({"error": "delete failed", "detail": result}, status=502) return self._json_cors({"result": "ok"}) + async def handle_kx_printer_file_thumbnail(self, request): + """GET /kx/printer-files/{filename}/thumbnail - fetches the embedded + GCode thumbnail for a file on the printer's own storage, via + file/fileDetails. The printer extracts and base64-encodes the + "; thumbnail begin"-block from the GCode header on demand and + returns it inline in data.file_details.thumbnail - no separate + download/presigned-URL step needed (verified live against a real + Kobra X). Cached in-memory per filename since a file's thumbnail + never changes while it exists on the printer, and re-querying on + every render/scroll would mean one MQTT roundtrip per visible card.""" + filename = request.match_info.get("filename", "") + if not filename: + return self._json_cors({"error": "no filename given"}, status=400) + cached = self._printer_thumbnail_cache.get(filename) + if cached is not None: + return self._json_cors({"result": {"thumbnail": cached}}) + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "fileDetails", + lambda: self.client.publish( + "file", "fileDetails", + {"root": "local", "filename": filename}, + 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) + thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or "" + self._printer_thumbnail_cache[filename] = thumb + return self._json_cors({"result": {"thumbnail": thumb}}) + async def handle_kx_file_download(self, request): file_id = request.match_info["file_id"] f = self._store.get_file(file_id) @@ -5829,6 +5867,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: 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/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail) 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 index d03070b..6550f89 100644 --- a/tests/test_printer_files_endpoint.py +++ b/tests/test_printer_files_endpoint.py @@ -54,6 +54,40 @@ DELETEBATCH_FAILED = { "data": None, } +FILEDETAILS_SUCCESS = { + "action": "fileDetails", + "code": 200, + "state": "done", + "data": { + "file_details": { + "thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + "png_image": "", + "svg_image": "", + "objects_skip_parts": [], + }, + "filename": "a.gcode", + "root": "local", + }, +} + +FILEDETAILS_NO_THUMBNAIL = { + "action": "fileDetails", + "code": 200, + "state": "done", + "data": { + "file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []}, + "filename": "a.gcode", + "root": "local", + }, +} + +FILEDETAILS_FAILED = { + "action": "fileDetails", + "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 @@ -142,3 +176,58 @@ async def test_printer_file_delete_returns_502_on_printer_rejection(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 + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + await c.get("/kx/printer-files/a.gcode/thumbnail") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "fileDetails" + assert args[2] == {"root": "local", "filename": "a.gcode"} + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["thumbnail"] == "" + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_is_cached_after_first_fetch(client): + """Second request for the same filename must not call publish() again.""" + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp1.status == 200 + call_count_after_first = bridge.client.publish.call_count + + resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp2.status == 200 + data2 = await resp2.json() + assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call diff --git a/web/themes/default/app.js b/web/themes/default/app.js index ae98222..aa8d591 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -3045,7 +3045,7 @@ function renderPrinterFiles(){ return; } empty.style.display='none'; - grid.innerHTML=printerFiles.map(function(f){ + grid.innerHTML=printerFiles.map(function(f,idx){ 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):''; @@ -3059,9 +3059,14 @@ function renderPrinterFiles(){ 'style="width:16px;height:16px;margin:0">'; 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"'; + var thumbId='pft-'+idx; + var cachedThumb=_printerThumbCache[f.filename]; + var thumbHtml=cachedThumb + ? '' + : '
🖨
'; return '
'+ checkbox+ - '
🖨
'+ + thumbHtml+ '
'+name+'
'+ '
💾 '+sizeKb+'
'+ '
📅 '+date+'
'+ @@ -3072,6 +3077,46 @@ function renderPrinterFiles(){ '
'; }).join(''); _printerFilesUpdateSelectBar(); + _loadVisiblePrinterThumbnails(); +} + +// Thumbnails are fetched lazily, one at a time, only for cards not already +// cached - fetching all of them upfront would mean one MQTT roundtrip per +// file (145+ files is common), overwhelming the single MQTT connection. +var _printerThumbCache={}; // filename -> base64 PNG string ("" = no thumbnail) +var _printerThumbQueue=[]; +var _printerThumbLoading=false; + +function _loadVisiblePrinterThumbnails(){ + var placeholders=document.querySelectorAll('#printer-store-grid [data-filename]'); + _printerThumbQueue=Array.prototype.slice.call(placeholders); + _pumpPrinterThumbQueue(); +} + +function _pumpPrinterThumbQueue(){ + if(_printerThumbLoading||!_printerThumbQueue.length)return; + var el=_printerThumbQueue.shift(); + if(!el||!el.isConnected){_pumpPrinterThumbQueue();return;} + var encodedName=el.getAttribute('data-filename'); + _printerThumbLoading=true; + fetch(_apiUrl('/kx/printer-files/'+encodedName+'/thumbnail')) + .then(function(r){return r.json()}) + .then(function(d){ + var thumb=(d.result&&d.result.thumbnail)||''; + _printerThumbCache[decodeURIComponent(encodedName)]=thumb; + if(thumb&&el.isConnected){ + var img=document.createElement('img'); + img.src='data:image/png;base64,'+thumb; + img.style.cssText='width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px'; + img.id=el.id; + el.replaceWith(img); + } + }) + .catch(function(){/* keep the placeholder icon on failure */}) + .finally(function(){ + _printerThumbLoading=false; + _pumpPrinterThumbQueue(); + }); } function _printerFilesUpdateSelectBar(){