forked from viewit/KX-Bridge-Release
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 <img> 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.
This commit is contained in:
@ -1,2 +1,3 @@
|
|||||||
## Changes in this build
|
## 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.
|
||||||
|
|||||||
@ -1058,6 +1058,10 @@ class KobraXBridge:
|
|||||||
# as the existing fileDetails fire-and-forget pattern. Format:
|
# as the existing fileDetails fire-and-forget pattern. Format:
|
||||||
# {action: {"event": threading.Event(), "result": dict|None}}.
|
# {action: {"event": threading.Event(), "result": dict|None}}.
|
||||||
self._file_action_waiters: dict[str, dict] = {}
|
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._store = store if store is not None else GCodeStore(args.data_dir)
|
||||||
self._serve_dir_path: str = self._store._gcode_dir
|
self._serve_dir_path: str = self._store._gcode_dir
|
||||||
self._current_job_id: str = ""
|
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({"error": "delete failed", "detail": result}, status=502)
|
||||||
return self._json_cors({"result": "ok"})
|
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):
|
async def handle_kx_file_download(self, request):
|
||||||
file_id = request.match_info["file_id"]
|
file_id = request.match_info["file_id"]
|
||||||
f = self._store.get_file(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_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify)
|
||||||
r.add_get("/kx/printer-files", bridge.handle_kx_printer_files)
|
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_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/slots", bridge.handle_kx_filament_slots)
|
||||||
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
|
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)
|
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
|
||||||
|
|||||||
@ -54,6 +54,40 @@ DELETEBATCH_FAILED = {
|
|||||||
"data": None,
|
"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):
|
def _deliver_async(bridge, payload, delay=0.05):
|
||||||
"""Simulates the MQTT reader thread delivering a file/report a moment
|
"""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]
|
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"]})
|
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
|
||||||
assert resp.status == 502
|
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
|
||||||
|
|||||||
@ -3045,7 +3045,7 @@ function renderPrinterFiles(){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
empty.style.display='none';
|
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 name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename;
|
||||||
var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'–';
|
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 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"></span>';
|
'style="width:16px;height:16px;margin:0"></span>';
|
||||||
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"':
|
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"';
|
'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
|
||||||
|
? '<img id="'+thumbId+'" src="data:image/png;base64,'+cachedThumb+'" style="width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px">'
|
||||||
|
: '<div id="'+thumbId+'" data-filename="'+encodeURIComponent(f.filename)+'" style="width:100%;height:60px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:28px">🖨</div>';
|
||||||
return '<div '+cardClick+'>'+
|
return '<div '+cardClick+'>'+
|
||||||
checkbox+
|
checkbox+
|
||||||
'<div style="width:100%;height:60px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:28px">🖨</div>'+
|
thumbHtml+
|
||||||
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+'</div>'+
|
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+'</div>'+
|
||||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">💾 '+sizeKb+'</div>'+
|
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">💾 '+sizeKb+'</div>'+
|
||||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
|
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
|
||||||
@ -3072,6 +3077,46 @@ function renderPrinterFiles(){
|
|||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
_printerFilesUpdateSelectBar();
|
_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(){
|
function _printerFilesUpdateSelectBar(){
|
||||||
|
|||||||
Reference in New Issue
Block a user