From ce87dff220b835813d6057efa672d5d21dd66c14 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 3 Jul 2026 15:50:26 +0200 Subject: [PATCH 1/3] feat: add image plugin Adds an Image widget for the ESP32 CYD dashboard: - New 'image' config field type in the plugin SDK and layout editor (file upload, canvas resize to cell size, JPEG base64 <= 48 KB with quality step-down and a 96 KB total budget across widgets) - plugins/image with upload and URL sources, contain/cover fit, background color and refresh interval - Firmware rendering through a single TJpg_Decoder path (mbedtls base64 for uploads, HTTP(S) fetch for URLs), per-cell render cache and Renderer::pushImage primitive Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TWQBuM5kVxqNpVsPHKXUPo --- app/src/components/Layout.tsx | 7 + .../LayoutCustomizationPanel.module.css | 15 + .../layout/LayoutCustomizationPanel.tsx | 142 +++++++ app/src/lib/imageField.test.ts | 70 +++ app/src/lib/imageField.ts | 117 +++++ app/tsconfig.tsbuildinfo | 2 +- docs/plugins.md | 9 +- firmware/generated/plugins.ini | 1 + firmware/include/Renderer.h | 2 + firmware/src/Renderer.cpp | 9 + .../src/generated/PluginRegistrations.cpp | 9 +- packages/plugin-sdk/src/manifest.test.ts | 24 ++ packages/plugin-sdk/src/manifest.ts | 2 +- plugins/generated/manifests.ts | 94 ++++ plugins/image/firmware/ImagePlugin.cpp | 402 ++++++++++++++++++ plugins/image/firmware/ImagePlugin.h | 5 + plugins/image/library.json | 16 + plugins/image/manifest.json | 71 ++++ scripts/generate-plugin-registry.mjs | 3 +- 19 files changed, 995 insertions(+), 5 deletions(-) create mode 100644 app/src/lib/imageField.test.ts create mode 100644 app/src/lib/imageField.ts create mode 100644 packages/plugin-sdk/src/manifest.test.ts create mode 100644 plugins/image/firmware/ImagePlugin.cpp create mode 100644 plugins/image/firmware/ImagePlugin.h create mode 100644 plugins/image/library.json create mode 100644 plugins/image/manifest.json diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index 5f4f7a6..ab0f3d1 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -14,6 +14,7 @@ import { type Orientation, orientations, } from "../lib/config"; +import { getTotalImageDataLength } from "../lib/imageField"; import type { LayoutCell } from "../models/LayoutCell"; import { GlobalVarsDialog } from "./GlobalVarsDialog"; import styles from "./Layout.module.css"; @@ -101,6 +102,10 @@ export function Layout({ () => fitCellsToOrientation(cells, orientation), [cells, orientation], ); + const totalImageDataLength = useMemo( + () => getTotalImageDataLength(fittedCells), + [fittedCells], + ); const currentPage = normalizeLayoutPage(activePage); const activePageCells = useMemo( () => getCellsForPage(fittedCells, currentPage), @@ -480,6 +485,8 @@ export function Layout({ manifest={selectedManifest} isSettingsOpen={isSettingsOpen} selectedArea={selectedArea} + orientation={orientation} + totalImageDataLength={totalImageDataLength} plugins={plugins} cellCount={fittedCells.length} maxCells={maxLayoutCells} diff --git a/app/src/components/layout/LayoutCustomizationPanel.module.css b/app/src/components/layout/LayoutCustomizationPanel.module.css index 188a983..96a5018 100644 --- a/app/src/components/layout/LayoutCustomizationPanel.module.css +++ b/app/src/components/layout/LayoutCustomizationPanel.module.css @@ -339,3 +339,18 @@ padding-top: 0; } } + +.image-field-preview { + display: block; + max-width: 100%; + max-height: 120px; + border-radius: 4px; + margin-bottom: 6px; + object-fit: contain; +} + +.image-field-error { + color: var(--color-danger, #e5484d); + font-size: 12px; + margin: 4px 0 0; +} diff --git a/app/src/components/layout/LayoutCustomizationPanel.tsx b/app/src/components/layout/LayoutCustomizationPanel.tsx index 573847a..33b7d49 100644 --- a/app/src/components/layout/LayoutCustomizationPanel.tsx +++ b/app/src/components/layout/LayoutCustomizationPanel.tsx @@ -1,12 +1,20 @@ import type { PluginConfigField, PluginManifest } from "@budpy/plugin-sdk"; import { ChevronDown, ClipboardPaste, Trash2, Variable } from "lucide-react"; +import type { ChangeEvent } from "react"; import { useEffect, useMemo, useState } from "react"; +import type { Orientation } from "../../lib/config"; import { type GlobalVar, makeVarRef, parseVarRef, readGlobalVars, } from "../../lib/globalVarsStorage"; +import { + encodeImageForCell, + getCellPixelSize, + type ImageFit, + maxTotalImageDataBase64Length, +} from "../../lib/imageField"; import type { LayoutCell } from "../../models/LayoutCell"; import styles from "./LayoutCustomizationPanel.module.css"; @@ -17,6 +25,8 @@ interface LayoutCustomizationPanelProps { manifest?: PluginManifest; isSettingsOpen: boolean; selectedArea: CellPlacement | null; + orientation: Orientation; + totalImageDataLength: number; plugins: readonly PluginManifest[]; cellCount: number; maxCells: number; @@ -31,6 +41,8 @@ interface LayoutCustomizationPanelProps { interface PluginSettingsPanelProps { cell: LayoutCell; manifest: PluginManifest; + orientation: Orientation; + totalImageDataLength: number; onConfigChange: (key: string, value: unknown) => void; onDeleteCell: (instanceId: string) => void; } @@ -68,15 +80,117 @@ function getColorInputValue(value: unknown, fallback: unknown): string { return "#ffffff"; } +function ImageFieldInput({ + field, + value, + cell, + orientation, + totalImageDataLength, + onConfigChange, +}: { + field: PluginConfigField; + value: unknown; + cell: LayoutCell; + orientation: Orientation; + totalImageDataLength: number; + onConfigChange: (key: string, value: unknown) => void; +}) { + const [error, setError] = useState(null); + const [isEncoding, setIsEncoding] = useState(false); + const imageData = typeof value === "string" && value.length > 0 ? value : null; + + async function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + const fit: ImageFit = cell.config["fit"] === "cover" ? "cover" : "contain"; + const backgroundColor = + typeof cell.config["backgroundColor"] === "string" && + colorValuePattern.test(cell.config["backgroundColor"]) + ? cell.config["backgroundColor"] + : "#000000"; + const size = getCellPixelSize(cell, orientation); + + setError(null); + setIsEncoding(true); + try { + const base64 = await encodeImageForCell(file, size, fit, backgroundColor); + const currentLength = typeof value === "string" ? value.length : 0; + const nextTotal = totalImageDataLength - currentLength + base64.length; + if (nextTotal > maxTotalImageDataBase64Length) { + setError("Total image budget exceeded (96 KB across all widgets)"); + return; + } + onConfigChange(field.key, base64); + } catch (encodeError) { + setError( + encodeError instanceof Error + ? encodeError.message + : "Could not process this image", + ); + } finally { + setIsEncoding(false); + } + } + + return ( +
+ + {imageData && ( + Widget preview + )} +
+ + {imageData && ( + + )} +
+ {error &&

{error}

} + {field.description && !error &&

{field.description}

} +
+ ); +} + function PluginConfigFieldInput({ field, value, + cell, + orientation, + totalImageDataLength, globalVars, colorVars, onConfigChange, }: { field: PluginConfigField; value: unknown; + cell: LayoutCell; + orientation: Orientation; + totalImageDataLength: number; globalVars: readonly GlobalVar[]; colorVars: readonly GlobalVar[]; onConfigChange: (key: string, value: unknown) => void; @@ -101,6 +215,19 @@ function PluginConfigFieldInput({ ); } + if (field.type === "image") { + return ( + + ); + } + if (field.type === "select") { return ( + + + + {screenSleepMode === "dim" && ( + + )}