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
+ ? ''
+ : '