Skip to content

Commit 7e10586

Browse files
committed
feat(WebServer): add LVGL binary image preview via icu tool
Double-click .bin files to preview LVGL images: download from device, convert to PNG via 'icu convert /dev/stdin --stdout' pipe (no temp files), display in image preview tab. If icu is not installed, prompt user to open https://i.to01.icu for installation.
1 parent ad3f344 commit 7e10586

8 files changed

Lines changed: 283 additions & 15 deletions

File tree

Tools/WebServer/app/routes/files.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,65 @@ def api_file_write_binary():
216216
return jsonify({"success": False, "error": f"Invalid hex data: {e}"})
217217
except Exception as e:
218218
return jsonify({"success": False, "error": str(e)})
219+
220+
221+
@bp.route("/convert/lvgl-to-png", methods=["POST"])
222+
def api_convert_lvgl_to_png():
223+
"""Convert LVGL binary image to PNG using icu CLI tool.
224+
225+
JSON body:
226+
data: base64-encoded LVGL binary image data
227+
228+
Returns:
229+
JSON with base64-encoded PNG data
230+
"""
231+
import base64
232+
import shutil
233+
import subprocess
234+
235+
data = request.json or {}
236+
b64_data = data.get("data", "")
237+
238+
if not b64_data:
239+
return jsonify({"success": False, "error": "No data provided"})
240+
241+
# Check if icu tool is available
242+
icu_path = shutil.which("icu")
243+
if not icu_path:
244+
return jsonify({"success": False, "error": "icu tool not installed"})
245+
246+
try:
247+
bin_data = base64.b64decode(b64_data)
248+
except Exception as e:
249+
return jsonify({"success": False, "error": f"Invalid base64 data: {e}"})
250+
251+
try:
252+
result = subprocess.run(
253+
[
254+
icu_path,
255+
"convert",
256+
"/dev/stdin",
257+
"-f",
258+
"lvgl-v9",
259+
"-F",
260+
"png",
261+
"--stdout",
262+
],
263+
input=bin_data,
264+
capture_output=True,
265+
timeout=10,
266+
)
267+
268+
if result.returncode != 0 or not result.stdout:
269+
error_msg = result.stderr.decode("utf-8", errors="replace").strip()
270+
return jsonify(
271+
{"success": False, "error": error_msg or "Conversion failed"}
272+
)
273+
274+
png_b64 = base64.b64encode(result.stdout).decode("ascii")
275+
return jsonify({"success": True, "data": png_b64, "size": len(result.stdout)})
276+
277+
except subprocess.TimeoutExpired:
278+
return jsonify({"success": False, "error": "Conversion timed out"})
279+
except Exception as e:
280+
return jsonify({"success": False, "error": str(e)})

Tools/WebServer/static/js/features/transfer.js

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,8 @@ async function refreshDeviceFiles() {
496496
refreshDeviceFiles();
497497
} else if (_isImageFile(entry.name)) {
498498
previewDeviceImage(item.dataset.path, entry.name);
499+
} else if (_isLvglBinFile(entry.name)) {
500+
previewDeviceImage(item.dataset.path, entry.name, true);
499501
} else if (_isTextFile(entry.name)) {
500502
openDeviceTextFile(item.dataset.path, entry.name);
501503
}
@@ -776,6 +778,55 @@ function _isImageFile(name) {
776778
return _IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
777779
}
778780

781+
/** LVGL binary image extensions */
782+
const _LVGL_BIN_EXTENSIONS = ['.bin'];
783+
784+
/**
785+
* Check if a filename is an LVGL binary image file.
786+
*/
787+
function _isLvglBinFile(name) {
788+
const lower = name.toLowerCase();
789+
return _LVGL_BIN_EXTENSIONS.some((ext) => lower.endsWith(ext));
790+
}
791+
792+
/**
793+
* Convert LVGL binary data to PNG via backend icu tool.
794+
* @param {Blob} blob - LVGL binary data
795+
* @returns {Promise<{success: boolean, blob?: Blob, error?: string}>}
796+
*/
797+
async function convertLvglToPng(blob) {
798+
try {
799+
const arrayBuffer = await blob.arrayBuffer();
800+
const bytes = new Uint8Array(arrayBuffer);
801+
let binary = '';
802+
for (let i = 0; i < bytes.length; i++) {
803+
binary += String.fromCharCode(bytes[i]);
804+
}
805+
const b64 = btoa(binary);
806+
807+
const res = await fetch('/api/convert/lvgl-to-png', {
808+
method: 'POST',
809+
headers: { 'Content-Type': 'application/json' },
810+
body: JSON.stringify({ data: b64 }),
811+
});
812+
const data = await res.json();
813+
814+
if (!data.success) {
815+
return { success: false, error: data.error || 'Conversion failed' };
816+
}
817+
818+
const pngBinary = atob(data.data);
819+
const pngBytes = new Uint8Array(pngBinary.length);
820+
for (let i = 0; i < pngBinary.length; i++) {
821+
pngBytes[i] = pngBinary.charCodeAt(i);
822+
}
823+
const pngBlob = new Blob([pngBytes], { type: 'image/png' });
824+
return { success: true, blob: pngBlob };
825+
} catch (e) {
826+
return { success: false, error: e.message || String(e) };
827+
}
828+
}
829+
779830
/* ===========================
780831
TEXT FILE SUPPORT
781832
=========================== */
@@ -1335,8 +1386,11 @@ async function refreshPreviewTab(tabId) {
13351386
/**
13361387
* Preview an image file from the device in an editor tab.
13371388
* Downloads the file, creates a blob URL, and displays it in a new tab.
1389+
* @param {string} remotePath - File path on device
1390+
* @param {string} fileName - File name for display
1391+
* @param {boolean} isLvgl - If true, convert LVGL binary to PNG before display
13381392
*/
1339-
async function previewDeviceImage(remotePath, fileName) {
1393+
async function previewDeviceImage(remotePath, fileName, isLvgl = false) {
13401394
const state = window.FPBState;
13411395
if (!state.isConnected) {
13421396
log.error('Not connected');
@@ -1376,20 +1430,48 @@ async function previewDeviceImage(remotePath, fileName) {
13761430
return;
13771431
}
13781432

1379-
// Create blob URL from downloaded data
1380-
const ext = fileName.split('.').pop().toLowerCase();
1381-
const mimeMap = {
1382-
png: 'image/png',
1383-
jpg: 'image/jpeg',
1384-
jpeg: 'image/jpeg',
1385-
gif: 'image/gif',
1386-
bmp: 'image/bmp',
1387-
svg: 'image/svg+xml',
1388-
webp: 'image/webp',
1389-
ico: 'image/x-icon',
1390-
};
1391-
const mime = mimeMap[ext] || 'image/png';
1392-
const blob = result.blob || new Blob([result.data], { type: mime });
1433+
let blob;
1434+
if (isLvgl) {
1435+
// Convert LVGL binary to PNG via backend
1436+
const rawBlob = result.blob || new Blob([result.data]);
1437+
log.info('Converting LVGL image...');
1438+
const convResult = await convertLvglToPng(rawBlob);
1439+
if (!convResult.success) {
1440+
if (
1441+
convResult.error &&
1442+
convResult.error.includes('icu tool not installed')
1443+
) {
1444+
const install = confirm(
1445+
t(
1446+
'transfer.icu_not_installed',
1447+
'The "icu" tool is required to preview LVGL images but is not installed.\nOpen the installation page?',
1448+
),
1449+
);
1450+
if (install) {
1451+
window.open('https://i.to01.icu', '_blank');
1452+
}
1453+
} else {
1454+
log.error(`LVGL convert failed: ${convResult.error}`);
1455+
}
1456+
return;
1457+
}
1458+
blob = convResult.blob;
1459+
} else {
1460+
// Standard image
1461+
const ext = fileName.split('.').pop().toLowerCase();
1462+
const mimeMap = {
1463+
png: 'image/png',
1464+
jpg: 'image/jpeg',
1465+
jpeg: 'image/jpeg',
1466+
gif: 'image/gif',
1467+
bmp: 'image/bmp',
1468+
svg: 'image/svg+xml',
1469+
webp: 'image/webp',
1470+
ico: 'image/x-icon',
1471+
};
1472+
const mime = mimeMap[ext] || 'image/png';
1473+
blob = result.blob || new Blob([result.data], { type: mime });
1474+
}
13931475
const blobUrl = URL.createObjectURL(blob);
13941476

13951477
// Create editor tab
@@ -2336,6 +2418,8 @@ window.hideTransferContextMenu = hideTransferContextMenu;
23362418
window.transferContextAction = transferContextAction;
23372419
window.previewDeviceImage = previewDeviceImage;
23382420
window._isImageFile = _isImageFile;
2421+
window._isLvglBinFile = _isLvglBinFile;
2422+
window.convertLvglToPng = convertLvglToPng;
23392423
window._isTextFile = _isTextFile;
23402424
window._getAceMode = _getAceMode;
23412425
window.openDeviceTextFile = openDeviceTextFile;

Tools/WebServer/static/js/locales/en.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,8 @@ window.i18nResources['en'] = {
396396
unsaved_changes: 'File {{name}} has unsaved changes. Discard?',
397397
save_to_device: 'Save to device (Ctrl+S)',
398398
download_to_pc: 'Download to PC',
399+
icu_not_installed:
400+
'The "icu" tool is required to preview LVGL images but is not installed.\nOpen the installation page?',
399401
},
400402

401403
// Device

Tools/WebServer/static/js/locales/zh-CN.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,8 @@ window.i18nResources['zh-CN'] = {
388388
unsaved_changes: '文件 {{name}} 有未保存的修改,确定丢弃?',
389389
save_to_device: '保存到设备 (Ctrl+S)',
390390
download_to_pc: '下载到本地',
391+
icu_not_installed:
392+
'预览 LVGL 图片需要安装 "icu" 工具,当前未安装。\n是否打开安装页面?',
391393
},
392394

393395
// 设备

Tools/WebServer/static/js/locales/zh-TW.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ window.i18nResources['zh-TW'] = {
389389
unsaved_changes: '檔案 {{name}} 有未儲存的修改,確定捨棄?',
390390
save_to_device: '儲存到裝置 (Ctrl+S)',
391391
download_to_pc: '下載到本機',
392+
icu_not_installed:
393+
'預覽 LVGL 圖片需要安裝 "icu" 工具,目前未安裝。\n是否開啟安裝頁面?',
392394
},
393395

394396
// 裝置

Tools/WebServer/tests/js/test_transfer.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2912,6 +2912,44 @@ module.exports = function (w) {
29122912
});
29132913
});
29142914

2915+
describe('LVGL Binary Image Functions', () => {
2916+
it('_isLvglBinFile recognizes .bin files', () =>
2917+
assertTrue(w._isLvglBinFile('image.bin')));
2918+
it('_isLvglBinFile is case insensitive', () =>
2919+
assertTrue(w._isLvglBinFile('IMAGE.BIN')));
2920+
it('_isLvglBinFile rejects .png files', () =>
2921+
assertFalse(w._isLvglBinFile('image.png')));
2922+
it('_isLvglBinFile rejects .txt files', () =>
2923+
assertFalse(w._isLvglBinFile('data.txt')));
2924+
it('convertLvglToPng is async function', () =>
2925+
assertTrue(w.convertLvglToPng.constructor.name === 'AsyncFunction'));
2926+
2927+
it('convertLvglToPng handles API error', async () => {
2928+
setFetchResponse('/api/convert/lvgl-to-png', {
2929+
success: false,
2930+
error: 'icu tool not installed',
2931+
});
2932+
const blob = new Blob([new Uint8Array([1, 2, 3])]);
2933+
const result = await w.convertLvglToPng(blob);
2934+
assertFalse(result.success);
2935+
assertTrue(result.error.includes('icu'));
2936+
});
2937+
2938+
it('convertLvglToPng returns PNG blob on success', async () => {
2939+
// Mock a successful conversion returning base64 PNG
2940+
const fakePng = btoa('fake png');
2941+
setFetchResponse('/api/convert/lvgl-to-png', {
2942+
success: true,
2943+
data: fakePng,
2944+
size: 8,
2945+
});
2946+
const blob = new Blob([new Uint8Array([1, 2, 3])]);
2947+
const result = await w.convertLvglToPng(blob);
2948+
assertTrue(result.success);
2949+
assertTrue(result.blob instanceof Blob);
2950+
});
2951+
});
2952+
29152953
describe('closeTab dirty confirmation', () => {
29162954
it('prompts confirm for dirty textfile tab', () => {
29172955
const origConfirm = browserGlobals.confirm;

Tools/WebServer/tests/test_file_routes.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""Tests for app/routes/files.py"""
33

44
import os
5+
import subprocess
56
import sys
67
import tempfile
78
import unittest
@@ -307,5 +308,80 @@ def test_write_binary_watch_dir_parent_allowed(self):
307308
self.assertTrue(data["success"])
308309

309310

311+
class TestConvertLvglToPng(FileRoutesBase):
312+
"""Tests for /api/convert/lvgl-to-png endpoint."""
313+
314+
def test_no_data(self):
315+
res = self.client.post("/api/convert/lvgl-to-png", json={})
316+
data = res.get_json()
317+
self.assertFalse(data["success"])
318+
self.assertIn("No data", data["error"])
319+
320+
def test_empty_data(self):
321+
res = self.client.post("/api/convert/lvgl-to-png", json={"data": ""})
322+
data = res.get_json()
323+
self.assertFalse(data["success"])
324+
325+
def test_invalid_base64(self):
326+
res = self.client.post(
327+
"/api/convert/lvgl-to-png", json={"data": "!!!invalid!!!"}
328+
)
329+
data = res.get_json()
330+
self.assertFalse(data["success"])
331+
self.assertIn("Invalid base64", data["error"])
332+
333+
@patch("shutil.which", return_value=None)
334+
def test_icu_not_installed(self, mock_which):
335+
import base64
336+
337+
b64 = base64.b64encode(b"\x00" * 10).decode()
338+
res = self.client.post("/api/convert/lvgl-to-png", json={"data": b64})
339+
data = res.get_json()
340+
self.assertFalse(data["success"])
341+
self.assertIn("icu tool not installed", data["error"])
342+
343+
@patch("subprocess.run")
344+
@patch("shutil.which", return_value="/usr/bin/icu")
345+
def test_conversion_failure(self, mock_which, mock_run):
346+
import base64
347+
348+
mock_run.return_value = unittest.mock.Mock(
349+
returncode=1, stdout=b"", stderr=b"Not a valid LVGL image"
350+
)
351+
b64 = base64.b64encode(b"\x00" * 10).decode()
352+
res = self.client.post("/api/convert/lvgl-to-png", json={"data": b64})
353+
data = res.get_json()
354+
self.assertFalse(data["success"])
355+
356+
@patch("subprocess.run")
357+
@patch("shutil.which", return_value="/usr/bin/icu")
358+
def test_conversion_success(self, mock_which, mock_run):
359+
import base64
360+
361+
fake_png = b"\x89PNG\r\n\x1a\nfake"
362+
mock_run.return_value = unittest.mock.Mock(
363+
returncode=0, stdout=fake_png, stderr=b""
364+
)
365+
b64 = base64.b64encode(b"\x00" * 10).decode()
366+
res = self.client.post("/api/convert/lvgl-to-png", json={"data": b64})
367+
data = res.get_json()
368+
self.assertTrue(data["success"])
369+
self.assertEqual(data["size"], len(fake_png))
370+
# Verify returned data decodes to the fake PNG
371+
decoded = base64.b64decode(data["data"])
372+
self.assertEqual(decoded, fake_png)
373+
374+
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired("icu", 10))
375+
@patch("shutil.which", return_value="/usr/bin/icu")
376+
def test_conversion_timeout(self, mock_which, mock_run):
377+
import base64
378+
379+
b64 = base64.b64encode(b"\x00" * 10).decode()
380+
res = self.client.post("/api/convert/lvgl-to-png", json={"data": b64})
381+
data = res.get_json()
382+
self.assertFalse(data["success"])
383+
self.assertIn("timed out", data["error"])
384+
385+
310386
if __name__ == "__main__":
311387
unittest.main()

Tools/WebServer/tests/test_frontend.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ const globalFunctions = [
112112
'formatFileSize',
113113
'refreshTextFileTab',
114114
'refreshPreviewTab',
115+
'_isLvglBinFile',
116+
'convertLvglToPng',
115117
// Config schema functions
116118
'loadConfigSchema',
117119
'getConfigSchema',

0 commit comments

Comments
 (0)