feat: add WT32-SC01 LCD support + dynamic display resolution

- Add WT32-SC01 V1 (ESP32-WROVER, 480x320 LCD) as second board option
- Split ESP32 code into epaper/ and lcd/ subdirectories
- LCD version: LovyanGFX, color display, touch-to-refresh, no deep sleep
- Add display_type, width, height to displays table (migration 003)
- Frontend: display type selector with predefined resolutions
- Preview and layout editor now use dynamic resolution from display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:12:08 +02:00
co-authored by Claude Opus 4.6
parent 52dddfd74f
commit 77c9a77645
12 changed files with 589 additions and 22 deletions
@@ -143,7 +143,7 @@ export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, l
if (el.type === 'line') {
return <div key={idx} style={{
position: 'absolute', left, top,
width: (el.width || 800) * SCALE,
width: (el.width || layoutWidth) * SCALE,
height: Math.max(1, (el.height || 2) * SCALE),
backgroundColor: '#000',
}} />;
+53 -3
View File
@@ -5,6 +5,9 @@ import { api } from '../api/client';
interface Display {
id: number;
name: string;
display_type: string;
width: number;
height: number;
layout_id: number | null;
api_token: string | null;
last_seen: string | null;
@@ -12,23 +15,27 @@ interface Display {
}
interface Layout { id: number; name: string }
interface DisplayType { width: number; height: number; label: string }
const emptyForm = { name: '', layout_id: '', refresh_sec: '300' };
const emptyForm = { name: '', display_type: 'epaper-7.5', layout_id: '', refresh_sec: '300' };
export default function Displays() {
const [displays, setDisplays] = useState<Display[]>([]);
const [layouts, setLayouts] = useState<Layout[]>([]);
const [types, setTypes] = useState<Record<string, DisplayType>>({});
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
async function load() {
try {
const [d, l] = await Promise.all([
const [d, l, t] = await Promise.all([
api.get<Display[]>('/api/displays'),
api.get<Layout[]>('/api/layouts'),
api.get<Record<string, DisplayType>>('/api/displays/types'),
]);
setDisplays(d);
setLayouts(l);
setTypes(t);
} catch (e) {
setError((e as Error).message);
}
@@ -40,8 +47,12 @@ export default function Displays() {
e.preventDefault();
setError('');
try {
const dtype = types[form.display_type];
await api.post('/api/displays', {
name: form.name,
display_type: form.display_type,
width: dtype?.width ?? 800,
height: dtype?.height ?? 480,
layout_id: form.layout_id ? parseInt(form.layout_id) : null,
refresh_sec: parseInt(form.refresh_sec),
});
@@ -61,6 +72,20 @@ export default function Displays() {
}
}
async function handleTypeChange(id: number, display_type: string) {
try {
const dtype = types[display_type];
await api.put(`/api/displays/${id}`, {
display_type,
width: dtype?.width ?? 800,
height: dtype?.height ?? 480,
});
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Display wirklich loeschen?')) return;
try {
@@ -81,6 +106,7 @@ export default function Displays() {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Display-Typ</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Layout</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Refresh (s)</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">API URL</th>
@@ -90,11 +116,23 @@ export default function Displays() {
</thead>
<tbody className="divide-y divide-gray-100">
{displays.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
<tr><td colSpan={7} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
)}
{displays.map((d) => (
<tr key={d.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-800">{d.name}</td>
<td className="px-4 py-2">
<select
className="border border-gray-300 rounded px-2 py-1 text-xs"
value={d.display_type}
onChange={(e) => handleTypeChange(d.id, e.target.value)}
>
{Object.entries(types).map(([key, t]) => (
<option key={key} value={key}>{t.label}</option>
))}
</select>
<span className="ml-2 text-xs text-gray-400">{d.width}x{d.height}</span>
</td>
<td className="px-4 py-2">
<select
className="border border-gray-300 rounded px-2 py-1 text-xs"
@@ -133,6 +171,18 @@ export default function Displays() {
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Display-Typ</label>
<select
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
value={form.display_type}
onChange={(e) => setForm({ ...form, display_type: e.target.value })}
>
{Object.entries(types).map(([key, t]) => (
<option key={key} value={key}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Layout</label>
<select
+1 -1
View File
@@ -358,7 +358,7 @@ export default function Layouts() {
</div>
{/* E-paper preview */}
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau (800x480)</h4>
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau ({selected.width}x{selected.height})</h4>
<EpaperPreview
layoutId={selected.id}
elements={selected.elements}