Files
Dual-PID/P4_Display_Firmware/Werkzeuge/symbole_erzeugen.py
raw-designsandClaude Opus 5 7e26161174 feat(P4): Oberflaeche mit Sinnbildern, getrennten Kacheln und gruppiertem Menue (1.13.0)
Startseite und Menü bekommen die Gestaltung, die im Entwurf abgestimmt wurde.

Eco-Zeit und Tassenablage-Temperatur teilten sich bisher eine Kachel und wechselten
sich alle fünf Sekunden ab - wer die Temperatur ablesen wollte, musste warten, bis sie
wieder an der Reihe war. Beide haben jetzt eine eigene Kachel und stehen dauerhaft. Die
Flow-Kachel steht ebenfalls dauerhaft, statt erst mit dem Bezug zu erscheinen: Dabei
rückten alle anderen Kacheln zur Seite, ausgerechnet in dem Moment, in dem man hinschaut.
Aus demselben Grund sind die Fortschrittsbalken jetzt durchsichtig statt versteckt - ein
verstecktes Element fällt aus dem Flex-Layout heraus und verschiebt die Zahl darüber.

Zielwerte stehen nicht mehr im Messwert, sondern in einer eigenen Nebenzeile. Bisher
teilten sich Ist und Ziel denselben Platz und dieselbe Größe, obwohl das Ziel sich
während eines Bezugs nicht ändert. Die Nebenzeile trägt auch im Ruhezustand etwas: Dauer
und mittlere Flussrate des letzten Bezugs, Zielgewicht, Dampf-Abschaltzeit, Grund des
Eco-Countdowns. Der Waage-Zielwert ist dabei immer der eingestellte Wert ohne
Nachlauf-Offset.

Das Menü ist linksbündig, trägt Sinnbilder und ist in vier Gruppen geteilt. Zwölf
Einträge als eine Liste waren zwölf gleichwertige Zeilen, durch die man suchen musste;
als vier kurze Listen weiß man vorher, wo man nachsieht. Rechts in der Zeile steht der
Nebenwert der Seite dahinter. Alle zwölf Seiten passen ohne Scrollen auf das Panel.

In den Verlaufskurven markiert eine feine Waagerechte den Sollwert; die Verlaufs-
Darstellung ist die neue Vorgabe und verzichtet dort auf den Fortschrittsbalken am
Kartenfuß. Zeiten, Gewichte und Flussraten schreiben jetzt das deutsche Dezimalkomma,
das die Temperaturen längst benutzen.

Die Sinnbilder sind ein eigener Satz von 22 Zeichen - der in LVGL eingebaute Zeichensatz
hat weder Tasse noch Thermometer, Dampf, Waage oder Schneeflocke. Sie liegen als reine
Deckkraftbilder (A8) im Flash und werden beim Zeichnen über 'image_recolor' eingefärbt:
ein Zeichen genügt für alle Farben, in denen es vorkommt, und im RAM steht nur der
Bildkopf. Erzeugt werden sie von Werkzeuge/symbole_erzeugen.py, wo auch die Zeichnungen
als Strichgeometrie liegen; eine Symbolschrift samt Fremdwerkzeug bleibt damit erspart.

Flash 47 %, RAM unverändert 98 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GS6yPCpxJmmbF5Z9J6hiHp
2026-09-13 00:59:13 +02:00

375 lines
15 KiB
Python

#!/usr/bin/env python3
# =====================================================================================
# symbole_erzeugen.py - erzeugt ui_symbole.c aus den Zeichnungen weiter unten
# =====================================================================================
# Die Oberflaeche des Displays braucht Sinnbilder, die es im eingebauten LVGL-Zeichensatz
# nicht gibt: Tasse, Thermometer, Dampf, Waage, Schneeflocke. Statt eine komplette
# Symbolschrift einzubinden, wird jedes Zeichen hier als Strichzeichnung beschrieben und
# zu einem reinen Deckkraftbild (LV_COLOR_FORMAT_A8) gerastert.
#
# Warum Deckkraftbilder und keine Schrift:
# - Ein A8-Bild traegt nur Deckkraft, keine Farbe. LVGL faerbt es beim Zeichnen mit
# 'image_recolor' ein - dasselbe Zeichen dient also in Gold, Tuerkis, Grau und Rot,
# ohne dass es mehrfach im Speicher liegt.
# - Die Daten sind const und liegen damit im Flash, nicht im ohnehin knappen RAM.
# - Es braucht keine Fremdwerkzeuge (lv_font_conv, Node); dieses Skript genuegt.
#
# Aufruf (aus diesem Ordner): python symbole_erzeugen.py
# Schreibt: ../ui_symbole.c
#
# Gezeichnet wird in einem 24x24-Raster (wie bei den ueblichen Strichsymbolsaetzen) und
# auf SIZE herunterskaliert. Die Kantenglaettung entsteht aus dem Abstand zum Strich:
# Ein Punkt genau auf der Strichkante bekommt halbe Deckkraft, ein Punkt eine halbe
# Pixelbreite daneben gar keine mehr.
# =====================================================================================
import math
import os
GRID = 24.0 # Koordinatenraster der Zeichnungen
SIZE = 20 # Kantenlaenge des erzeugten Bildes in Pixeln
STROKE = 1.8 # Strichbreite im 24er-Raster
# ---------------------------------------------------------------------------------
# Bausteine
# ---------------------------------------------------------------------------------
def cubic(p0, p1, p2, p3, steps=18):
"""Bezierkurve zu einem Streckenzug aufloesen."""
out = []
for i in range(steps + 1):
t = i / steps
u = 1.0 - t
x = u*u*u*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t*t*t*p3[0]
y = u*u*u*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t*t*t*p3[1]
out.append((x, y))
return out
def arc(cx, cy, r, a0, a1, steps=None):
"""Kreisbogen als Streckenzug. Winkel in Grad, 0 = rechts, im Uhrzeigersinn."""
if steps is None:
steps = max(6, int(abs(a1 - a0) / 8))
out = []
for i in range(steps + 1):
a = math.radians(a0 + (a1 - a0) * i / steps)
out.append((cx + r * math.cos(a), cy + r * math.sin(a)))
return out
def circle(cx, cy, r):
return arc(cx, cy, r, 0, 360)
def teardrop(apex, cx, cy, r):
"""Tropfen: zwei Tangenten von der Spitze an den Kreis, dazu der grosse Bogen."""
dx, dy = cx - apex[0], cy - apex[1]
d = math.hypot(dx, dy)
if d <= r:
return circle(cx, cy, r)
base = math.atan2(dy, dx)
off = math.acos(r / d)
a_l = base + off
a_r = base - off
t_l = (cx + r * math.cos(a_l + math.pi), cy + r * math.sin(a_l + math.pi))
t_r = (cx + r * math.cos(a_r + math.pi), cy + r * math.sin(a_r + math.pi))
bogen = arc(cx, cy, r,
math.degrees(a_l + math.pi),
math.degrees(a_r + math.pi) + 360)
return [apex, t_l] + bogen + [t_r, apex]
def zahnrad(cx, cy, r_aussen, r_innen, zaehne=8, laenge=1.9):
"""Zahnrad: Ring mit kurzen Zaehnen nach aussen."""
teile = [circle(cx, cy, r_aussen), circle(cx, cy, r_innen)]
for i in range(zaehne):
a = math.radians(360.0 * i / zaehne)
ca, sa = math.cos(a), math.sin(a)
teile.append([(cx + ca * (r_aussen - 0.4), cy + sa * (r_aussen - 0.4)),
(cx + ca * (r_aussen + laenge), cy + sa * (r_aussen + laenge))])
return teile
def welle(x, y_oben, y_unten, weite):
"""Stehende S-Kurve - der Dampf steigt geschlaengelt auf."""
h = (y_unten - y_oben) / 2.0
a = cubic((x, y_unten), (x + weite, y_unten - h * 0.7),
(x + weite, y_oben + h * 1.3), (x, y_oben + h))
b = cubic((x, y_oben + h), (x - weite, y_oben + h - h * 0.7),
(x - weite, y_oben + h * 0.3), (x, y_oben))
return a + b
# ---------------------------------------------------------------------------------
# Die Zeichnungen
# ---------------------------------------------------------------------------------
# Jeder Eintrag ist eine Liste von Streckenzuegen. Ein Streckenzug ist entweder
# ('s', [punkte...]) - als Strich gezeichnet
# ('f', [punkte...]) - als gefuellte Flaeche gezeichnet (fuer Punkte und Vollformen)
# ---------------------------------------------------------------------------------
def s(pts):
return ('s', pts)
def f(pts):
return ('f', pts)
def linie(x1, y1, x2, y2):
return s([(x1, y1), (x2, y2)])
SYMBOLE = {
# --- Kessel und Messwerte ---------------------------------------------------
'tropfen': [ # Wasserkessel
s(teardrop((12, 3.4), 12, 12.9, 5.6)),
],
'dampf': [ # Dampfkessel
s(welle(7.5, 10.4, 21.0, 1.5)),
s(welle(12.0, 10.4, 21.0, 1.5)),
s(welle(16.5, 10.4, 21.0, 1.5)),
linie(5, 6.6, 19, 6.6),
],
'stoppuhr': [ # Shot-Timer
s(circle(12, 13.8, 7.0)),
linie(12, 13.8, 12, 9.9),
linie(9.4, 3.2, 14.6, 3.2),
linie(12, 3.2, 12, 6.8),
linie(18.3, 7.4, 19.8, 5.9),
],
'waage': [ # Waage
linie(12, 4.6, 12, 19.6),
linie(5.5, 19.6, 18.5, 19.6),
linie(4, 8.6, 20, 8.6),
s([(7, 8.6), (4, 13.8), (10, 13.8), (7, 8.6)]),
s([(17, 8.6), (14, 13.8), (20, 13.8), (17, 8.6)]),
],
'fluss': [ # Flussrate
s(teardrop((12, 3.4), 12, 12.9, 5.6)),
s(arc(12, 12.9, 2.8, 90, 180)),
],
'blatt': [ # Eco
s(cubic((19.8, 4.4), (19.8, 13.0), (15.0, 16.9), (10.9, 16.9)) +
cubic((10.9, 16.9), (8.0, 16.9), (6.3, 15.1), (6.3, 12.6)) +
cubic((6.3, 12.6), (6.3, 7.5), (12.6, 4.4), (19.8, 4.4))),
s(cubic((8.4, 19.8), (9.4, 15.2), (12.0, 12.1), (16.0, 10.2))),
],
'tasse': [ # Tassenablage
s([(4.0, 8.6), (15.5, 8.6), (15.5, 13.6)] +
arc(10.3, 13.6, 5.2, 0, 90) +
arc(9.2, 13.6, 5.2, 90, 180) +
[(4.0, 13.6), (4.0, 8.6)]),
s(arc(15.5, 12.2, 2.4, -90, 90)),
linie(3.4, 21.2, 16.6, 21.2),
],
# --- Menue und Zustand ------------------------------------------------------
'haus': [ # Dashboard
s([(4, 11.2), (12, 4.2), (20, 11.2)]),
s([(6.5, 10.0), (6.5, 19.8), (17.5, 19.8), (17.5, 10.0)]),
],
'kurve': [ # Verlauf
s([(4, 4.6), (4, 19.6), (20, 19.6)]),
s([(7, 15.4), (11, 10.2), (14, 13.2), (18, 7.2)]),
],
'balken': [ # Statistik
linie(5.6, 19.8, 5.6, 10.6),
linie(12, 19.8, 12, 4.6),
linie(18.4, 19.8, 18.4, 13.2),
],
'thermometer': [ # Temperatur
s([(10, 13.6), (10, 6.2)] + arc(12, 6.2, 2.0, 180, 360) + [(14, 13.6)]),
s(arc(12, 17.2, 4.2, -60, 240)),
linie(12, 9.2, 12, 15.4),
],
'schneeflocke': [ # Cold Extraction
linie(12, 3.2, 12, 20.8),
linie(4.4, 7.6, 19.6, 16.4),
linie(19.6, 7.6, 4.4, 16.4),
s([(9.2, 5.4), (12, 7.2), (14.8, 5.4)]),
s([(9.2, 18.6), (12, 16.8), (14.8, 18.6)]),
],
'zahnrad': [ # Reinigung/Wartung
] + [s(t) for t in zahnrad(12, 12, 6.2, 2.6)],
'lesezeichen': [ # Profile
s([(6.6, 4.2), (6.6, 20.4), (12, 16.4), (17.4, 20.4), (17.4, 4.2), (6.6, 4.2)]),
],
'gluehbirne': [ # Licht
s(arc(12, 9.4, 5.3, 145, 395) + [(9.7, 14.0), (9.7, 16.2)]),
s([(14.3, 14.0), (14.3, 16.2)]),
linie(9.5, 17.4, 14.5, 17.4),
linie(10.0, 20.2, 14.0, 20.2),
],
'wlan': [ # WiFi
s(arc(12, 18.4, 10.4, 206, 334)),
s(arc(12, 18.4, 6.9, 202, 338)),
s(arc(12, 18.4, 3.4, 196, 344)),
f(circle(12, 18.4, 1.25)),
],
'regler': [ # Darstellung
linie(4, 7.6, 13.4, 7.6),
s(circle(16.2, 7.6, 2.4)),
linie(19.0, 7.6, 20, 7.6),
linie(20, 16.4, 10.6, 16.4),
s(circle(7.8, 16.4, 2.4)),
linie(5.0, 16.4, 4, 16.4),
],
'info': [ # Info
s(circle(12, 12, 8.6)),
linie(12, 11.4, 12, 16.8),
f(circle(12, 7.8, 1.0)),
],
'menue': [ # Menueknopf
linie(4.5, 7, 19.5, 7),
linie(4.5, 12, 19.5, 12),
linie(4.5, 17, 19.5, 17),
],
'haken': [ # Statuszeile: alles in Ordnung
s([(4.8, 12.4), (9.6, 17.2), (19.2, 7.0)]),
],
'warnung': [ # Statuszeile/Menue: Hinweis
s([(12, 4.2), (20.6, 19.6), (3.4, 19.6), (12, 4.2)]),
linie(12, 10.2, 12, 14.2),
f(circle(12, 16.9, 1.0)),
],
'kreuz': [ # Menueknopf, wenn die Schublade offen ist
linie(5.6, 6.6, 18.4, 17.4),
linie(18.4, 6.6, 5.6, 17.4),
],
}
# ---------------------------------------------------------------------------------
# Rasterung
# ---------------------------------------------------------------------------------
def dist_segment(px, py, ax, ay, bx, by):
vx, vy = bx - ax, by - ay
wx, wy = px - ax, py - ay
ll = vx * vx + vy * vy
t = 0.0 if ll == 0.0 else (wx * vx + wy * vy) / ll
t = max(0.0, min(1.0, t))
return math.hypot(px - (ax + t * vx), py - (ay + t * vy))
def punkt_in_polygon(px, py, pts):
drin = False
n = len(pts)
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % n]
if (y1 > py) != (y2 > py):
xs = x1 + (py - y1) * (x2 - x1) / (y2 - y1)
if px < xs:
drin = not drin
return drin
def rastern(zuege):
"""Zeichnung zu einem SIZE x SIZE grossen Deckkraftbild machen."""
skala = SIZE / GRID
halb = STROKE * skala / 2.0
# Koordinaten einmal umrechnen
vorbereitet = []
for art, pts in zuege:
sp = [(x * skala, y * skala) for x, y in pts]
vorbereitet.append((art, sp))
daten = bytearray(SIZE * SIZE)
for py in range(SIZE):
for px in range(SIZE):
cx, cy = px + 0.5, py + 0.5
deckung = 0.0
for art, pts in vorbereitet:
if art == 'f':
# Gefuellt: innen voll, an der Kante weich auslaufend
d = min(dist_segment(cx, cy, pts[i][0], pts[i][1],
pts[(i + 1) % len(pts)][0], pts[(i + 1) % len(pts)][1])
for i in range(len(pts)))
if punkt_in_polygon(cx, cy, pts):
a = 1.0
else:
a = 0.5 - d
else:
d = min(dist_segment(cx, cy, pts[i][0], pts[i][1],
pts[i + 1][0], pts[i + 1][1])
for i in range(len(pts) - 1))
a = halb - d + 0.5
if a > deckung:
deckung = a
deckung = max(0.0, min(1.0, deckung))
daten[py * SIZE + px] = int(deckung * 255.0 + 0.5)
return daten
# ---------------------------------------------------------------------------------
# Ausgabe
# ---------------------------------------------------------------------------------
KOPF = """// =====================================================================================
// ui_symbole.c - Sinnbilder der Oberflaeche als Deckkraftbilder
// =====================================================================================
// ERZEUGT - nicht von Hand aendern.
// Quelle: Werkzeuge/symbole_erzeugen.py (dort liegen die Zeichnungen)
// Neu erzeugen: cd P4_Display_Firmware/Werkzeuge && python symbole_erzeugen.py
//
// Jedes Bild traegt nur Deckkraft (LV_COLOR_FORMAT_A8), keine Farbe. Eingefaerbt wird
// beim Zeichnen ueber den Stil 'image_recolor' - dasselbe Zeichen dient damit in Gold,
// Tuerkis, Gruen, Orange oder Rot, ohne mehrfach im Speicher zu liegen. Die Daten sind
// const und liegen im Flash; im RAM steht nur der Bildkopf.
// =====================================================================================
#include "ui_symbole.h"
"""
def c_name(name):
return 'sym_' + name
def main():
hier = os.path.dirname(os.path.abspath(__file__))
ziel_c = os.path.join(hier, '..', 'ui_symbole.c')
ziel_h = os.path.join(hier, '..', 'ui_symbole.h')
namen = sorted(SYMBOLE.keys())
teile = [KOPF]
for name in namen:
daten = rastern(SYMBOLE[name])
cn = c_name(name)
zeilen = []
for i in range(0, len(daten), 20):
zeilen.append(' ' + ' '.join('0x%02x,' % b for b in daten[i:i + 20]))
teile.append('static const uint8_t %s_map[] = {\n%s\n};\n\n' % (cn, '\n'.join(zeilen)))
teile.append(
'const lv_image_dsc_t %s = {\n'
' .header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_A8,\n'
' .flags = 0, .w = %d, .h = %d, .stride = %d, .reserved_2 = 0 },\n'
' .data_size = sizeof(%s_map),\n'
' .data = %s_map,\n'
' .reserved = NULL,\n'
'};\n\n' % (cn, SIZE, SIZE, SIZE, cn, cn))
with open(ziel_c, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(''.join(teile))
kopf = ['// =====================================================================================\n',
'// ui_symbole.h - Sinnbilder der Oberflaeche (erzeugt, siehe ui_symbole.c)\n',
'// =====================================================================================\n',
'\n#pragma once\n\n#include <lvgl.h>\n\n',
'#ifdef __cplusplus\nextern "C" {\n#endif\n\n',
'// Kantenlaenge aller Sinnbilder in Pixeln\n',
'#define UI_SYMBOL_SIZE %d\n\n' % SIZE]
for name in namen:
kopf.append('extern const lv_image_dsc_t %s;\n' % c_name(name))
kopf.append('\n#ifdef __cplusplus\n}\n#endif\n')
with open(ziel_h, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(''.join(kopf))
print('%d Sinnbilder erzeugt (%dx%d Pixel)' % (len(namen), SIZE, SIZE))
if __name__ == '__main__':
main()