Files
epaper-dashboard/tools/render_mockup.py
T
Patrik 5a83826aa5 FEAT-NETATMO-06: V1 Status-Update Redesign + shared helpers foundation
Netatmo-Plugin komplett auf V1 Status-Update Layout umgebaut:
- 4-Sektionen: Aussen / Wind+Regen / Indoor / Vorhersage
- Echte 12h-History fuer MIN/MAX (vorher estimated +-2/+-4)
- Dynamische Forecast-Daten (datetime.now() + timedelta)
- DE strings durchgaengig (AUSSEN, FEUCHTE, DRUCK, Bo, HEUTE, Boerse, ...)
- 7-Farben ACeP palette (kein Greyscale, kein Anti-Alias)
- Indoor-Sensoren: rote Tag-Box + 22pt+ Temp + CO2-Bar + Batt aligned
- Chart mit 2-Tick Y-Grid (min/max), 12h Linie in Gruen
- Wetter-Icons (WMO codes) in Vorhersage, Regen-Drop-Icon pro Tag

Foundation / shared helpers:
- palette.py: BATT_*, DE_WEEKDAYS/MONTHS, WMO_ICONS, format_*,
              POWER_DEFAULTS, temp_color, co2_color, batt_color
- plugins/base.py: draw_battery, draw_compass, draw_trend,
                   draw_line_chart, draw_mini_bar, draw_weather_icon,
                   draw_deepsleep_indicator, draw_zzz_indicator,
                   _fit_size, _get_base_sans, de_* formatters
- designbase.py: THEMES dict (light/dark/retro/mag), Layout templates
                 (bereits importierbar)

Tools:
- tools/render_mockup.py: 3 design-Varianten Mockup-Generator
- tools/verify_netatmo.py: Self-verification mit dynamic mock data
2026-08-31 00:47:40 +02:00

1020 lines
47 KiB
Python

"""Mockup-Renderer für 3 Design-Varianten des Netatmo-4x4-Layouts.
Erzeugt 3 PNGs (800x480) mit denselben Mock-Daten aber unterschiedlichem
Design. So kann der User vor dem Refactor sehen welche Richtung gefällt.
Verwendung:
python tools/render_mockup.py
Output: docs/mockup_variant_{1,2,3}.png
Layout-Prinzip fuer Variante 1 (Status-Update):
- Strikt berechnete Zellen, keine magic numbers
- Jeder Text/Bereich hat reservierten Raum → keine Overlaps moeglich
- Maximale Lesbarkeit: grosse Zahlen, klare Hierarchie, viel Weissraum
- Farbe nur fuer echte Signale (CO2-Schwellen, Battery, Trend)
"""
from __future__ import annotations
import os, math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
HERE = Path(__file__).resolve().parent.parent
OUT_DIR = HERE / "docs"
OUT_DIR.mkdir(exist_ok=True)
# ── 7-Farben ACeP-Palette ────────────────────────────────────────────────────
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
RED = (255, 0, 0)
YELLOW = (255, 255, 0)
ORANGE = (255, 128, 0)
FG, BG = BLACK, WHITE
OK, WARN, ALERT = GREEN, YELLOW, RED
INFO, ACCENT = BLUE, ORANGE
# ── Fonts (Windows) ─────────────────────────────────────────────────────────
WIN_FONTS = r"C:\Windows\Fonts"
F_BODY = os.path.join(WIN_FONTS, "arial.ttf")
F_BOLD = os.path.join(WIN_FONTS, "arialbd.ttf")
F_MONO = os.path.join(WIN_FONTS, "consola.ttf")
def fnt(size, bold=False, mono=False):
path = F_MONO if mono else (F_BOLD if bold else F_BODY)
if not os.path.exists(path):
path = F_BODY
return ImageFont.truetype(path, size)
# ── Display & Layout-Master-Grid ────────────────────────────────────────────
DISP_W, DISP_H = 800, 480
# Vertikale Zonen (gestapelt, keine Ueberlappung moeglich)
HEADER_H = 32 # oberste Zeile: Station + Zeit
FOOTER_H = 22 # unterste Zeile: Letztes Update
BODY_Y = HEADER_H + 4
BODY_H = DISP_H - BODY_Y - FOOTER_H - 4 # 416 px
# Spalten (im Body-Bereich). Jede Spalte hat feste Breite + Inhalt.
COL_GAP = 8
COL_LEFT = 10
COL1_X = COL_LEFT
COL1_W = 220
COL2_X = COL1_X + COL1_W + COL_GAP
COL2_W = 160
COL3_X = COL2_X + COL2_W + COL_GAP
COL3_W = 250
COL4_X = COL3_X + COL3_W + COL_GAP
COL4_W = DISP_W - COL4_X - COL_LEFT # 142
# ── Mock-Daten ──────────────────────────────────────────────────────────────
MOCK = {
"station": "Daheim",
"time": "SO 30.08 18:24",
"update": "vor 12 s",
"outdoor": {"name": "Aussen", "temp": 18.4, "hum": 67, "battery": 78},
"indoor": [
{"tag": "M", "name": "Wohnen", "temp": 22.1, "co2": 720, "battery": None, "hum": 48},
{"tag": "2", "name": "Kueche", "temp": 23.5, "co2": 980, "battery": 88, "hum": 52},
{"tag": "3", "name": "Schlafen", "temp": 20.8, "co2": 1450, "battery": 41, "hum": 55},
{"tag": "4", "name": "Buero", "temp": 24.2, "co2": 610, "battery": 92, "hum": 44},
{"tag": "5", "name": "Keller", "temp": 17.9, "co2": 580, "battery": 12, "hum": 60},
],
"wind": {"speed": 12, "gust": 24, "deg": 215, "battery": 65},
"rain": {"rate": 0.0, "h1": 0.4, "h24": 6.2, "today": 8.4, "battery": 82},
"pressure":{"value": 1014, "trend": "down"},
"forecast":[
{"day": "MO", "tmin": 9, "tmax": 19, "rain": 0.0, "code": "sun"},
{"day": "DI", "tmin": 11, "tmax": 21, "rain": 2.4, "code": "cloud"},
{"day": "MI", "tmin": 8, "tmax": 17, "rain": 8.1, "code": "rain"},
],
}
# ── Low-Level Helpers ───────────────────────────────────────────────────────
def measure(draw, text, font):
b = draw.textbbox((0, 0), text, font=font)
return b[2] - b[0], b[3] - b[1]
def fit_font(draw, text, fonts_dict, max_w, max_h):
for key in sorted(fonts_dict.keys(), reverse=True):
f = fonts_dict[key]
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return fonts_dict[min(fonts_dict.keys())]
def text_in_box(draw, text, x, y, w, h, font, color, align="left"):
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
Ueberlappung mit anderen Elementen."""
tw, th = measure(draw, text, font)
if tw > w or th > h:
# Fallback: verkleinern
return False
if align == "left":
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
elif align == "right":
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
elif align == "center":
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
return True
def temp_color(t):
if t >= 28: return RED
if t >= 22: return ORANGE
if t <= 5: return BLUE
return FG
def co2_color(co2):
if co2 is None: return FG
if co2 >= 1500: return RED
if co2 >= 1000: return YELLOW
if co2 >= 600: return ORANGE
return GREEN
def batt_color(pct):
if pct is None: return FG
if pct < 25: return RED
if pct < 50: return ORANGE
return GREEN
def standard_fonts():
return {8: fnt(8), 10: fnt(10), 11: fnt(11), 12: fnt(12), 13: fnt(13),
14: fnt(14), 16: fnt(16), 18: fnt(18), 20: fnt(20), 24: fnt(24),
28: fnt(28), 32: fnt(32), 36: fnt(36), 40: fnt(40), 48: fnt(48),
60: fnt(60), 72: fnt(72), 84: fnt(84), 96: fnt(96)}
def draw_battery_compact(draw, x, y, w, h, pct):
"""Batterie-Icon, passt in (w x h) Box. Gibt genutzte Breite zurueck."""
if pct is None: return 0
pct = max(0, min(100, pct))
color = batt_color(pct)
# Body
draw.rectangle((x, y, x + w - 2, y + h - 1), outline=FG, width=1)
# Tip
draw.rectangle((x + w - 2, y + h // 4, x + w, y + 3 * h // 4 - 1), fill=FG)
# Fill
fill_w = max(0, int((w - 3) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
return w
def co2_bar_in_box(draw, x, y, w, h, co2):
"""CO2-Bar mit Schwellen-Farbe, in (x,y,w,h) Box."""
if co2 is None or w < 10 or h < 4:
return
color = co2_color(co2)
# Outline
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
# Fill
pct = min(100, co2 / 2000 * 100)
fill_w = max(0, int((w - 2) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
# WMO-artige Wetter-Icons (nur 7 Farben, einfache Geometrie)
def draw_weather_icon(draw, x, y, size, code):
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
Codes: 'sun', 'partly', 'cloud', 'rain', 'storm', 'snow', 'fog'
Verwendet nur 7 ACeP-Farben.
"""
cx, cy = x + size // 2, y + size // 2
r = max(3, size // 3)
if code == "sun" or code == 0:
# Gelbe Sonne mit Strahlen
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
# Strahlen
for ang in range(0, 360, 45):
rad = math.radians(ang)
x1 = cx + int((r + 2) * math.cos(rad))
y1 = cy + int((r + 2) * math.sin(rad))
x2 = cx + int((r + 5) * math.cos(rad))
y2 = cy + int((r + 5) * math.sin(rad))
draw.line((x1, y1, x2, y2), fill=FG, width=1)
elif code == "partly" or code in (1, 2, 3):
# Sonne + kleine Wolke
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
# Wolke
cw = size // 2
ch = size // 4
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch), fill=WHITE, outline=FG, width=1)
elif code == "cloud" or code in (45, 48):
# Wolke (Outline only, sonst nichts sichtbar auf weiss)
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
# Zweite Halbkugel für die "Wolkenform"
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
elif code == "rain" or code in (51, 53, 55, 61, 63, 65, 80, 81, 82):
# Wolke + 3 blaue Tropfen
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
# Tropfen
for dx in (-4, 0, 4):
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
elif code == "storm" or code in (95, 96, 99):
# Wolke + Blitz (gelb/orange)
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
# Blitz-Zickzack
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
fill=YELLOW)
elif code == "snow" or code in (71, 73, 75, 77, 85, 86):
# Wolke + weisse Schneeflocken (auf weiss unsichtbar, daher als X aus Strichen)
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
for dy in (0, 5):
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
fill=FG, width=1)
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
fill=FG, width=1)
else:
# Fallback: ? Symbol
f = fnt(size - 4, bold=True)
tw, th = measure(draw, "?", f)
draw.text((x + (size - tw) // 2, y + (size - th) // 2 - 2),
"?", font=f, fill=FG)
# ════════════════════════════════════════════════════════════════════════════
# VARIANTE 1: "Status-Update" — Minimalistisch, lesbar, strikt grid-basiert.
# Layout-Prinzip: jede Sektion ist eine Box mit bekannten Bounds. Kein Element
# kann ueber die Box-Grenze hinauszeichnen.
# ════════════════════════════════════════════════════════════════════════════
def render_v1():
img = Image.new("RGB", (DISP_W, DISP_H), BG)
draw = ImageDraw.Draw(img)
fonts = standard_fonts()
# === HEADER (y: 0-32) ===================================================
draw.text((COL_LEFT, 8), MOCK["station"].upper(),
font=fnt(12, bold=True), fill=FG)
# Zeit rechts
f_time = fnt(11)
tw, _ = measure(draw, MOCK["time"], f_time)
draw.text((DISP_W - COL_LEFT - tw, 10), MOCK["time"], font=f_time, fill=FG)
# Trennlinie
draw.line((COL_LEFT, HEADER_H, DISP_W - COL_LEFT, HEADER_H), fill=FG, width=2)
# === BODY (y: 36-462) ==================================================
# Spalten-Trennlinien (sehr duenn, nur visuell)
for sep_x in [COL2_X - 4, COL3_X - 4, COL4_X - 4]:
draw.line((sep_x, BODY_Y, sep_x, BODY_Y + BODY_H), fill=FG, width=1)
# ── SPALTE 1: AUSSEN (x:10-230, y:36-462) ────────────────────────────
aussen_box = (COL1_X, BODY_Y, COL1_W, BODY_H)
sec_header_h = 16
# Header-Zeile
text_in_box(draw, "AUSSEN", COL1_X, BODY_Y, COL1_W, sec_header_h,
fnt(11, bold=True), GREEN, "center")
# Body aufteilen in Big-Temp / Stats / Chart
body_inner_y = BODY_Y + sec_header_h + 2
body_inner_h = BODY_H - sec_header_h - 2
# Big Temp Block: 0..130
big_h = 100
out = MOCK["outdoor"]
big = f"{out['temp']:.1f}°"
f_big = fit_font(draw, big, fonts, COL1_W - 8, big_h)
text_in_box(draw, big, COL1_X, body_inner_y, COL1_W, big_h, f_big, FG, "center")
# Battery oben rechts in der Big-Temp-Box
if out.get("battery") is not None:
by = body_inner_y + 2
bx = COL1_X + COL1_W - 56
draw_battery_compact(draw, bx, by, 28, 9, out["battery"])
text_in_box(draw, f"{out['battery']}%", bx + 30, by - 1, 24, 11,
fnt(9, bold=True), batt_color(out["battery"]), "left")
# 4 Stats: 2x2 Grid
stats_y = body_inner_y + big_h
stats_h = 56
cell_w = (COL1_W - 6) // 2
cell_h = stats_h // 2
stats = [
("MIN", f"{out['temp']-2:.0f}°", FG),
("MAX", f"{out['temp']+4:.0f}°", FG),
("FEUCHTE", f"{out['hum']}%", BLUE),
("DRUCK", f"{MOCK['pressure']['value']}", FG),
]
for i, (lbl, val, col) in enumerate(stats):
cx = COL1_X + (i % 2) * (cell_w + 6) + 2
cy = stats_y + (i // 2) * cell_h
text_in_box(draw, lbl, cx, cy, cell_w, 11, fnt(8, bold=True), FG, "left")
text_in_box(draw, val, cx, cy + 11, cell_w, cell_h - 11,
fnt(15, bold=True), col, "left")
# Trend-Pfeil beim Druck (Zelle 4)
if lbl == "DRUCK":
trend = MOCK["pressure"]["trend"]
if trend != "stable":
tcol = RED if trend == "down" else GREEN
ax = cx + cell_w - 18
ay = cy + 12
if trend == "down":
draw.polygon([(ax+6, ay+2), (ax, ay+10), (ax+12, ay+10)], fill=tcol)
else:
draw.polygon([(ax, ay+2), (ax+12, ay+2), (ax+6, ay+10)], fill=tcol)
# Trennlinie vor Chart
chart_sep_y = stats_y + stats_h + 2
draw.line((COL1_X + 4, chart_sep_y, COL1_X + COL1_W - 4, chart_sep_y),
fill=FG, width=1)
# Chart-Bereich
chart_y = chart_sep_y + 2
chart_h = body_inner_y + body_inner_h - chart_y
# Chart-Label
text_in_box(draw, "12H VERLAUF", COL1_X, chart_y, COL1_W // 2, 12,
fnt(8, bold=True), FG, "left")
text_in_box(draw, f"{out['temp']-3:.0f}-{out['temp']+2:.0f}°",
COL1_X + COL1_W // 2, chart_y, COL1_W // 2, 12,
fnt(8, bold=True), FG, "right")
# Chart-Y-Grid (3 Linien mit Temperatur-Labels)
# Werte basieren auf Min/Max der Mock-Daten
y_min = math.floor((out['temp'] - 3) / 5) * 5
y_max = math.ceil((out['temp'] + 2) / 5) * 5
y_ticks = [y_min, (y_min + y_max) // 2, y_max]
line_y_top = chart_y + 14
line_y_bot = chart_y + chart_h - 14
line_h_avail = line_y_bot - line_y_top
grid_left = COL1_X + 22 # Platz fuer Y-Labels
grid_right = COL1_X + COL1_W - 4
for y_temp in y_ticks:
# y-Position: oben = max, unten = min
y_px = line_y_top + int((y_max - y_temp) / max(1, y_max - y_min) * line_h_avail)
# Y-Label links
text_in_box(draw, f"{y_temp}°", COL1_X + 2, y_px - 6, 20, 12,
fnt(8, bold=True), FG, "left")
# Gitter-Linie (sehr duenn, gestrichelt simuliert durch kurze Segmente)
for seg_x in range(grid_left, grid_right, 6):
draw.line((seg_x, y_px, min(seg_x + 3, grid_right), y_px), fill=FG, width=1)
# Chart-Linie (streng im reservierten Bereich)
pts = []
for i in range(36):
x = grid_left + int(i * (grid_right - grid_left) / 35)
# Y-Wert zwischen y_min und y_max mappen
# Synthetische Kurve
val = out['temp'] + 2.5 * math.sin(i / 2.5) + 1.0 * math.cos(i / 1.7)
val = max(y_min, min(y_max, val))
y_px = line_y_top + int((y_max - val) / max(1, y_max - y_min) * line_h_avail)
pts.append((x, y_px))
for a, b in zip(pts, pts[1:]):
draw.line([a, b], fill=GREEN, width=2)
# -12h / jetzt Labels (unter dem Chart)
text_in_box(draw, "-12h", COL1_X, line_y_bot, COL1_W // 2, 12,
fnt(8), FG, "left")
text_in_box(draw, "jetzt", COL1_X + COL1_W // 2, line_y_bot, COL1_W // 2, 12,
fnt(8, bold=True), FG, "right")
# ── SPALTE 2: WIND / REGEN (x:238-398, y:36-462) ─────────────────────
sec_h = (BODY_H - 4) // 2 # 2 Sub-Sections mit 4px Gap
# WIND
sec_y = BODY_Y
text_in_box(draw, "WIND", COL2_X, sec_y, COL2_W, sec_header_h,
fnt(11, bold=True), BLUE, "center")
# Battery oben rechts
if MOCK["wind"].get("battery") is not None:
bx = COL2_X + COL2_W - 50
by = sec_y + 2
draw_battery_compact(draw, bx, by, 24, 8, MOCK["wind"]["battery"])
text_in_box(draw, f"{MOCK['wind']['battery']}%", bx + 26, by - 1, 22, 10,
fnt(8, bold=True), batt_color(MOCK["wind"]["battery"]), "left")
# Big Speed
sec_body_y = sec_y + sec_header_h + 2
sec_body_h = sec_h - sec_header_h - 4
big_val = f"{MOCK['wind']['speed']}"
f_big = fit_font(draw, big_val, fonts, COL2_W - 30, sec_body_h - 30)
text_in_box(draw, big_val, COL2_X, sec_body_y, COL2_W - 24, sec_body_h - 30,
f_big, FG, "center")
# km/h daneben
tw_, th_ = measure(draw, big_val, f_big)
text_in_box(draw, "km/h",
COL2_X + (COL2_W - 24) // 2 + tw_ // 2 + 2,
sec_body_y + (sec_body_h - 30) - 14,
30, 14, fnt(11, bold=True), FG, "left")
# Bö
text_in_box(draw, f"Bö {MOCK['wind']['gust']} km/h",
COL2_X, sec_body_y + sec_body_h - 24, COL2_W, 14,
fnt(11, bold=True), FG, "center")
# Mini-Compass rechts
ccx = COL2_X + COL2_W - 22
ccy = sec_body_y + 26
cr = 14
draw.ellipse((ccx - cr, ccy - cr, ccx + cr, ccy + cr), outline=FG, width=1)
rad = math.radians(MOCK["wind"]["deg"] - 90)
tx = ccx + (cr - 3) * math.cos(rad)
ty = ccy + (cr - 3) * math.sin(rad)
draw.line((ccx, ccy, tx, ty), fill=BLUE, width=2)
draw.ellipse((ccx - 2, ccy - 2, ccx + 2, ccy + 2), fill=BLUE)
# REGEN
sec_y = BODY_Y + sec_h + 4
text_in_box(draw, "REGEN", COL2_X, sec_y, COL2_W, sec_header_h,
fnt(11, bold=True), BLUE, "center")
if MOCK["rain"].get("battery") is not None:
bx = COL2_X + COL2_W - 50
by = sec_y + 2
draw_battery_compact(draw, bx, by, 24, 8, MOCK["rain"]["battery"])
text_in_box(draw, f"{MOCK['rain']['battery']}%", bx + 26, by - 1, 22, 10,
fnt(8, bold=True), batt_color(MOCK["rain"]["battery"]), "left")
sec_body_y = sec_y + sec_header_h + 2
sec_body_h = sec_h - sec_header_h - 4
# Layout: Rate DOMINANT oben, HEUTE mittig, 1h/24h kompakt unten
rate_row_h = 80
today_row_h = 32
bars_row_h = sec_body_h - rate_row_h - today_row_h - 4
rate = MOCK["rain"]["rate"]
rate_str = f"{rate:.1f}" if rate > 0 else "—"
# Rate RIESIG, ohne "mm/h" Mini-Label (Einheit unten als eigener Block)
f_big = fit_font(draw, rate_str, fonts, COL2_W - 20, rate_row_h - 16)
text_in_box(draw, rate_str, COL2_X, sec_body_y, COL2_W - 4, rate_row_h,
f_big, BLUE if rate > 0 else FG, "center")
# Einheit "mm/h" zentriert unter der Rate
unit_y = sec_body_y + rate_row_h - 14
text_in_box(draw, "mm/h", COL2_X, unit_y, COL2_W - 4, 12,
fnt(10, bold=True), FG, "center")
# HEUTE prominent
today_y = sec_body_y + rate_row_h + 4
today_val_str = f"{MOCK['rain']['today']:.1f}"
# "HEUTE" Label klein oben, Wert GROSS darunter
text_in_box(draw, "HEUTE", COL2_X + 2, today_y, 50, 10,
fnt(8, bold=True), FG, "left")
text_in_box(draw, today_val_str, COL2_X, today_y + 10, COL2_W // 2 - 4, today_row_h - 10,
fnt(20, bold=True), BLUE if MOCK['rain']['today'] > 0 else FG, "left")
# "mm" rechts vom Wert
text_in_box(draw, "mm", COL2_X + COL2_W // 2, today_y + 14, COL2_W // 2 - 4, today_row_h - 14,
fnt(10, bold=True), FG, "left")
# 1h / 24h - kompakt, nur Wert ohne Label (zu klein sonst)
bar_h = 10
for j, (key, maxval) in enumerate([("h1", 5), ("h24", 20)]):
val = MOCK["rain"][key]
by = sec_body_y + sec_body_h - 2 * bar_h - 2 + j * (bar_h + 2)
# Label + Wert
text_in_box(draw, f"{key[:-1]}h", COL2_X, by, 18, bar_h,
fnt(8, bold=True), FG, "left")
# Mini-Bar
bar_x = COL2_X + 22
bar_w = COL2_W - 22 - 28
draw.rectangle((bar_x, by + 2, bar_x + bar_w - 1, by + bar_h - 1), outline=FG, width=1)
pct = min(100, val / maxval * 100)
if pct > 0:
fw = max(2, int((bar_w - 3) * pct / 100))
draw.rectangle((bar_x + 1, by + 3, bar_x + 1 + fw, by + bar_h - 2), fill=BLUE)
# Wert rechts
text_in_box(draw, f"{val:.1f}", COL2_X + COL2_W - 28, by, 26, bar_h,
fnt(9, bold=True), FG, "right")
# ── SPALTE 3: INDOOR (x:406-656, y:36-462) ───────────────────────────
text_in_box(draw, f"INNEN · {len(MOCK['indoor'])}",
COL3_X, BODY_Y, COL3_W, sec_header_h,
fnt(11, bold=True), RED, "center")
indoor = MOCK["indoor"]
# Verfuegbarer Platz pro Sensor
sec_body_y = BODY_Y + sec_header_h + 2
sec_body_h = BODY_H - sec_header_h - 2
row_h = sec_body_h // len(indoor) # = 80
line_h = 22 # Zeile 1: Tag + Name + Temp (groesser!)
bar_h = 10 # Zeile 2: CO2 Bar
val_h = 12 # Zeile 3: CO2 Wert + Battery
ROW_L1_Y_OFF = 2
ROW_L2_Y_OFF = ROW_L1_Y_OFF + line_h + 2
ROW_L3_Y_OFF = ROW_L2_Y_OFF + bar_h + 2
for i, s in enumerate(indoor):
ry = sec_body_y + i * row_h
if i > 0:
draw.line((COL3_X + 4, ry, COL3_X + COL3_W - 4, ry), fill=FG, width=1)
inner_x = COL3_X + 6
inner_w = COL3_W - 12
# Zeile 1: Tag | Name (GROSS) | Temp (GROSS, Farbe nach Temperatur)
tag_w = 18
text_in_box(draw, s["tag"], inner_x, ry + ROW_L1_Y_OFF, tag_w, line_h,
fnt(18, bold=True), RED, "center")
name_x = inner_x + tag_w + 4
name_w = inner_w - tag_w - 4 - 70
text_in_box(draw, s["name"], name_x, ry + ROW_L1_Y_OFF, name_w, line_h,
fnt(16, bold=True), FG, "left")
# Temp fett und gross, in Temperatur-Farbe
text_in_box(draw, f"{s['temp']:.1f}°",
inner_x + inner_w - 70, ry + ROW_L1_Y_OFF, 70, line_h,
fnt(22, bold=True), temp_color(s["temp"]), "right")
# Zeile 2: CO2 Bar
co2_bar_in_box(draw, inner_x, ry + ROW_L2_Y_OFF, inner_w, bar_h, s["co2"])
# Zeile 3: CO2 Wert + Battery (ohne "CO2" Label, nur Zahl+ppm)
co2_str = f"{s['co2']} ppm" if s["co2"] is not None else "—"
text_in_box(draw, co2_str, inner_x, ry + ROW_L3_Y_OFF, inner_w // 2, val_h,
fnt(12, bold=True), co2_color(s["co2"]), "left")
if s.get("battery") is not None:
pct_str = f"{s['battery']}%"
text_in_box(draw, pct_str, inner_x + inner_w - 24, ry + ROW_L3_Y_OFF - 1, 24, val_h,
fnt(10, bold=True), batt_color(s["battery"]), "right")
draw_battery_compact(draw, inner_x + inner_w - 22, ry + ROW_L3_Y_OFF, 20, 8, s["battery"])
# ── SPALTE 4: VORHERSAGE (x:664-790, y:36-462) ───────────────────────
text_in_box(draw, "VORHERSAGE", COL4_X, BODY_Y, COL4_W, sec_header_h,
fnt(11, bold=True), ORANGE, "center")
fc = MOCK["forecast"]
sec_body_y = BODY_Y + sec_header_h + 2
sec_body_h = BODY_H - sec_header_h - 2
row_h_fc = sec_body_h // len(fc)
# Layout: grosses Icon + Tag oben, dann Min/Max RIESIG, dann Regen-Wert
fc_l1_h = 32 # Tag + Icon (groesser)
fc_l2_h = 44 # Min/Max (DOMINANT)
fc_l3_h = row_h_fc - fc_l1_h - fc_l2_h
for i, day in enumerate(fc):
ry = sec_body_y + i * row_h_fc
if i > 0:
draw.line((COL4_X + 4, ry, COL4_X + COL4_W - 4, ry), fill=FG, width=1)
# Tag (links, fett, gross)
text_in_box(draw, day["day"], COL4_X + 4, ry, 50, fc_l1_h,
fnt(18, bold=True), FG, "left")
# Wetter-Icon (rechts, gross, 28x28)
icon_size = min(28, fc_l1_h - 4)
draw_weather_icon(draw, COL4_X + COL4_W - icon_size - 4,
ry + (fc_l1_h - icon_size) // 2,
icon_size, day.get("code", "cloud"))
# Min/Max (DOMINANT, riesig)
text_in_box(draw, f"{day['tmin']}°", COL4_X + 4, ry + fc_l1_h, COL4_W // 2, fc_l2_h,
fnt(28, bold=True), BLUE, "left")
text_in_box(draw, f"{day['tmax']}°", COL4_X + COL4_W // 2, ry + fc_l1_h,
COL4_W // 2 - 4, fc_l2_h,
fnt(28, bold=True), ORANGE, "left")
# Regen-Icon + Wert (ohne "REGEN" Text-Label, nur das Symbol)
rb_y = ry + fc_l1_h + fc_l2_h + 2
if fc_l3_h > 10:
# Mini-Tropfen-Icon links vom Wert
icon_x = COL4_X + 4
icon_y = rb_y + 1
if day["rain"] > 0:
# Blauer Tropfen
draw.polygon([(icon_x + 4, icon_y),
(icon_x, icon_y + 5),
(icon_x + 8, icon_y + 5)], fill=BLUE)
else:
# Leeres Tropfen-Outline
draw.polygon([(icon_x + 4, icon_y),
(icon_x, icon_y + 5),
(icon_x + 8, icon_y + 5)], outline=FG, width=1)
# Wert gross
rain_color = BLUE if day["rain"] > 0 else FG
text_in_box(draw, f"{day['rain']:.1f} mm", COL4_X + 18, rb_y,
COL4_W - 22, 16,
fnt(14, bold=True), rain_color, "left")
# === FOOTER (y: 462-480) ================================================
f_foot = fnt(9)
draw.text((COL_LEFT, DISP_H - FOOTER_H + 4),
f"NETATMO · letzte Aktualisierung {MOCK['update']}",
font=f_foot, fill=FG)
# Quellen rechts
draw.text((DISP_W - COL_LEFT - 130, DISP_H - FOOTER_H + 4),
"Daten: Netatmo + Open-Meteo", font=f_foot, fill=FG)
# Trennlinie
draw.line((COL_LEFT, DISP_H - FOOTER_H, DISP_W - COL_LEFT, DISP_H - FOOTER_H),
fill=FG, width=1)
img.save(OUT_DIR / "mockup_variant_1.png")
print(f" -> {OUT_DIR / 'mockup_variant_1.png'}")
# ════════════════════════════════════════════════════════════════════════════
# VARIANTE 2: "Warm Weather Station" — Farbige Header-Strips, traditionell.
# ════════════════════════════════════════════════════════════════════════════
def render_v2():
img = Image.new("RGB", (DISP_W, DISP_H), BG)
draw = ImageDraw.Draw(img)
fonts = standard_fonts()
draw.rectangle((0, 0, DISP_W, 6), fill=ORANGE)
draw.text((COL_LEFT, 14), "WETTER · " + MOCK["station"].upper(),
font=fnt(13, bold=True), fill=FG)
f_time = fnt(11)
tw, _ = measure(draw, MOCK["time"], f_time)
draw.text((DISP_W - COL_LEFT - tw, 16), MOCK["time"], font=f_time, fill=FG)
draw.line((COL_LEFT, 36, DISP_W - COL_LEFT, 36), fill=FG, width=2)
body_y = 46
PAD = 16
body_h = DISP_H - body_y - PAD
strip_h = 5
label_h = 22
inner_pad = 10
def card(x, y, w, h, color, label):
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=2)
draw.rectangle((x, y, x + w - 1, y + strip_h), fill=color)
draw.text((x + inner_pad, y + strip_h + 4), label,
font=fnt(13, bold=True), fill=color)
# Aussen
ax, aw = COL1_X, COL1_W
card(ax, body_y, aw, body_h, GREEN, "AUSSEN")
out = MOCK["outdoor"]
if out.get("battery") is not None:
bx = ax + aw - 50
by = body_y + strip_h + 8
draw_battery_compact(draw, bx, by, 24, 8, out["battery"])
text_in_box(draw, f"{out['battery']}%", bx + 26, by - 1, 22, 10,
fnt(8, bold=True), batt_color(out["battery"]), "left")
big = f"{out['temp']:.0f}°"
big_y = body_y + strip_h + label_h + 6
big_h = 100
f_big = fit_font(draw, big, fonts, aw - 2 * inner_pad, big_h)
text_in_box(draw, big, ax, big_y, aw, big_h, f_big, FG, "center")
stats_y = big_y + big_h + 4
s_w = (aw - 2 * inner_pad) // 4
stats = [
("MIN", f"{out['temp']-2:.0f}°", FG),
("MAX", f"{out['temp']+4:.0f}°", FG),
("FEUCHTE", f"{out['hum']}%", BLUE),
("DRUCK", f"{MOCK['pressure']['value']}", FG),
]
for i, (lbl, val, col) in enumerate(stats):
sx = ax + inner_pad + i * s_w
text_in_box(draw, lbl, sx, stats_y, s_w, 10, fnt(8, bold=True), FG, "left")
text_in_box(draw, val, sx, stats_y + 10, s_w, 18,
fit_font(draw, val, fonts, s_w, 18), col, "left")
chart_y = stats_y + 38
chart_h = body_h - (chart_y - body_y) - 8
if chart_h > 20:
text_in_box(draw, "12H VERLAUF", ax + inner_pad, chart_y, aw // 2, 12,
fnt(8, bold=True), FG, "left")
text_in_box(draw, f"{out['temp']-3:.0f}-{out['temp']+2:.0f}°",
ax + aw // 2, chart_y, aw // 2 - inner_pad, 12,
fnt(8), FG, "right")
line_y = chart_y + 14
line_h = chart_h - 24
pts = []
for i in range(24):
x = ax + inner_pad + int(i * (aw - 2 * inner_pad) / 23)
amp = max(4, line_h // 3)
y = line_y + line_h // 2 + int(amp * math.sin(i / 2.5))
pts.append((x, y))
for a, b in zip(pts, pts[1:]):
draw.line([a, b], fill=GREEN, width=2)
text_in_box(draw, "-12h", ax + inner_pad, line_y + line_h, aw // 2, 12,
fnt(8), FG, "left")
text_in_box(draw, "jetzt", ax + aw // 2, line_y + line_h, aw // 2 - inner_pad, 12,
fnt(8, bold=True), FG, "right")
# Wind/Regen
mx, mw = COL2_X, COL2_W
mh = (body_h - 6) // 2
# Wind
card(mx, body_y, mw, mh, BLUE, "WIND")
wind = MOCK["wind"]
if wind.get("battery") is not None:
bx = mx + mw - 50
by = body_y + strip_h + 8
draw_battery_compact(draw, bx, by, 24, 8, wind["battery"])
text_in_box(draw, f"{wind['battery']}%", bx + 26, by - 1, 22, 10,
fnt(8, bold=True), batt_color(wind["battery"]), "left")
sb_y = body_y + strip_h + label_h + 4
sb_h = mh - strip_h - label_h - 4
s_str = f"{wind['speed']}"
f_ws = fit_font(draw, s_str, fonts, mw - 50, sb_h - 30)
text_in_box(draw, s_str, mx + inner_pad, sb_y, mw - 50, sb_h - 30,
f_ws, FG, "left")
sw, sh = measure(draw, s_str, f_ws)
text_in_box(draw, "km/h", mx + inner_pad + sw + 4,
sb_y + (sb_h - 30) - 14, 40, 14, fnt(11), FG, "left")
text_in_box(draw, f"Bö {wind['gust']} km/h",
mx + inner_pad, sb_y + sb_h - 24, mw - inner_pad, 14,
fnt(10), FG, "left")
ccx = mx + mw - 26
ccy = sb_y + 24
cr = 18
draw.ellipse((ccx - cr, ccy - cr, ccx + cr, ccy + cr), outline=FG, width=1)
rad = math.radians(wind["deg"] - 90)
tx = ccx + (cr - 4) * math.cos(rad)
ty = ccy + (cr - 4) * math.sin(rad)
draw.line((ccx, ccy, tx, ty), fill=BLUE, width=2)
draw.ellipse((ccx - 2, ccy - 2, ccx + 2, ccy + 2), fill=BLUE)
# Regen
rry = body_y + mh + 6
card(mx, rry, mw, mh, BLUE, "REGEN")
rain = MOCK["rain"]
if rain.get("battery") is not None:
bx = mx + mw - 50
by = rry + strip_h + 8
draw_battery_compact(draw, bx, by, 24, 8, rain["battery"])
text_in_box(draw, f"{rain['battery']}%", bx + 26, by - 1, 22, 10,
fnt(8, bold=True), batt_color(rain["battery"]), "left")
rb_y = rry + strip_h + label_h + 4
rb_h = mh - strip_h - label_h - 4
r_str = f"{rain['rate']:.1f}"
f_rs = fit_font(draw, r_str, fonts, mw - 50, rb_h - 50)
text_in_box(draw, r_str, mx + inner_pad, rb_y, mw - 50, rb_h - 50,
f_rs, BLUE if rain["rate"] > 0 else FG, "left")
rw, rh = measure(draw, r_str, f_rs)
text_in_box(draw, "mm/h", mx + inner_pad + rw + 4,
rb_y + (rb_h - 50) - 12, 40, 12, fnt(11), FG, "left")
b1y = rb_y + rb_h - 50
text_in_box(draw, "1h", mx + inner_pad, b1y, 20, 10, fnt(8, bold=True), FG, "left")
b1x = mx + inner_pad + 22
b1w = mw - 2 * inner_pad - 22 - 26
draw.rectangle((b1x, b1y, b1x + b1w, b1y + 8), outline=FG, width=1)
p1 = min(100, rain["h1"] / 5 * 100)
if p1 > 0:
draw.rectangle((b1x + 1, b1y + 1, b1x + 1 + max(2, int((b1w - 2) * p1 / 100)),
b1y + 7), fill=BLUE)
text_in_box(draw, f"{rain['h1']:.1f}", mx + mw - inner_pad - 24, b1y - 1, 24, 10,
fnt(10, bold=True), FG, "right")
b2y = b1y + 18
text_in_box(draw, "24h", mx + inner_pad, b2y, 20, 10, fnt(8, bold=True), FG, "left")
draw.rectangle((b1x, b2y, b1x + b1w, b2y + 8), outline=FG, width=1)
p24 = min(100, rain["h24"] / 20 * 100)
if p24 > 0:
draw.rectangle((b1x + 1, b2y + 1, b1x + 1 + max(2, int((b1w - 2) * p24 / 100)),
b2y + 7), fill=BLUE)
text_in_box(draw, f"{rain['h24']:.1f}", mx + mw - inner_pad - 24, b2y - 1, 24, 10,
fnt(10, bold=True), FG, "right")
# Indoor
ix, iw = COL3_X, COL3_W
card(ix, body_y, iw, body_h, RED, f"INNEN · {len(MOCK['indoor'])} SENSOREN")
inner_x = ix + 8
inner_w = iw - 16
sec_body_y = body_y + strip_h + label_h + 6
sec_body_h = body_h - strip_h - label_h - 12
row_h = sec_body_h // len(MOCK["indoor"])
line_h, bar_h, val_h = 18, 10, 12
for i, s in enumerate(MOCK["indoor"]):
ry = sec_body_y + i * row_h
if i > 0:
draw.line((inner_x, ry, inner_x + inner_w, ry), fill=FG, width=1)
# Zeile 1: Tag | Name | Temp
tag_w = 22
text_in_box(draw, s["tag"], inner_x, ry, tag_w, line_h,
fnt(14, bold=True), RED, "left")
text_in_box(draw, s["name"], inner_x + tag_w, ry, inner_w - tag_w - 50, line_h,
fit_font(draw, s["name"], fonts, inner_w - tag_w - 50, line_h), FG, "left")
text_in_box(draw, f"{s['temp']:.1f}°", inner_x + inner_w - 50, ry, 50, line_h,
fnt(13, bold=True), temp_color(s["temp"]), "right")
# Zeile 2: CO2 Bar
co2_bar_in_box(draw, inner_x, ry + 20, inner_w, bar_h, s["co2"])
# Zeile 3: CO2 Wert + Battery
co2_text = f"CO₂ {s['co2']} ppm" if s["co2"] is not None else "—"
text_in_box(draw, co2_text, inner_x, ry + 20 + bar_h + 2, inner_w // 2, val_h,
fnt(9, bold=True), co2_color(s["co2"]), "left")
if s.get("battery") is not None:
pct_str = f"{s['battery']}%"
tw_p, _ = measure(draw, pct_str, fnt(9, bold=True))
text_in_box(draw, pct_str, inner_x + inner_w - 22 - tw_p - 2,
ry + 20 + bar_h + 1, tw_p + 2, val_h,
fnt(9, bold=True), batt_color(s["battery"]), "right")
draw_battery_compact(draw, inner_x + inner_w - 22, ry + 20 + bar_h + 2,
20, 8, s["battery"])
# Forecast
fx, fw = COL4_X, COL4_W
card(fx, body_y, fw, body_h, ORANGE, "VORHERSAGE")
sec_body_y = body_y + strip_h + label_h + 4
sec_body_h = body_h - strip_h - label_h - 8
row_h_fc = sec_body_h // len(MOCK["forecast"])
for i, day in enumerate(MOCK["forecast"]):
ry = sec_body_y + i * row_h_fc
if i > 0:
draw.line((fx + 8, ry, fx + fw - 8, ry), fill=FG, width=1)
text_in_box(draw, day["day"], fx + 8, ry, fw - 16, 16,
fnt(13, bold=True), FG, "left")
text_in_box(draw, f"{day['tmin']}° {day['tmax']}°",
fx + 8, ry + 18, fw - 16, 24,
fnt(16, bold=True), FG, "left")
rb_y = ry + row_h_fc - 10
rbar_w = fw - 56
if rbar_w > 10:
draw.rectangle((fx + 8, rb_y, fx + 8 + rbar_w, rb_y + 6), outline=FG, width=1)
if day["rain"] > 0:
fill_w = max(2, int((rbar_w - 2) * min(100, day["rain"] * 10) / 100))
draw.rectangle((fx + 9, rb_y + 1, fx + 9 + fill_w, rb_y + 5), fill=BLUE)
text_in_box(draw, f"{day['rain']:.1f}", fx + fw - 32, rb_y - 2, 28, 10,
fnt(10, bold=True), BLUE if day["rain"] > 0 else FG, "right")
# Footer
f_foot = fnt(9)
draw.text((COL_LEFT, DISP_H - FOOTER_H + 4),
f"NETATMO · letzte Aktualisierung {MOCK['update']}",
font=f_foot, fill=FG)
draw.line((COL_LEFT, DISP_H - FOOTER_H, DISP_W - COL_LEFT, DISP_H - FOOTER_H),
fill=FG, width=1)
img.save(OUT_DIR / "mockup_variant_2.png")
print(f" -> {OUT_DIR / 'mockup_variant_2.png'}")
# ════════════════════════════════════════════════════════════════════════════
# VARIANTE 3: "Old-School ePaper" — Mono-Font, dicke schwarze Linien.
# ════════════════════════════════════════════════════════════════════════════
def render_v3():
img = Image.new("RGB", (DISP_W, DISP_H), BG)
draw = ImageDraw.Draw(img)
fonts = standard_fonts()
# Heavy header
draw.rectangle((0, 0, DISP_W, 32), fill=BLACK)
draw.text((COL_LEFT, 8), "WETTERSTATION // " + MOCK["station"].upper(),
font=fnt(13, bold=True, mono=True), fill=WHITE)
f_time = fnt(12, mono=True)
tw, _ = measure(draw, MOCK["time"], f_time)
draw.text((DISP_W - COL_LEFT - tw, 10), MOCK["time"], font=f_time, fill=WHITE)
body_y = 44
body_h = DISP_H - body_y - 16
line_w = 3
# 4 dicke schwarze Rahmen um jede Spalte
for (x, w) in [(COL1_X, COL1_W), (COL2_X, COL2_W), (COL3_X, COL3_W), (COL4_X, COL4_W)]:
draw.rectangle((x, body_y, x + w - 1, body_y + body_h - 1),
outline=BLACK, width=line_w)
# Aussen
ax, aw = COL1_X, COL1_W
draw.rectangle((ax, body_y, ax + aw - 1, body_y + 24), fill=BLACK)
draw.text((ax + 8, body_y + 5), "AUSSEN", font=fnt(13, bold=True, mono=True), fill=WHITE)
out = MOCK["outdoor"]
if out.get("battery") is not None:
text_in_box(draw, f"BAT {out['battery']}%", ax + aw - 70, body_y + 8, 64, 12,
fnt(10, bold=True, mono=True), GREEN, "right")
big = f"{out['temp']:04.1f}°"
f_big = fit_font(draw, big, fonts, aw - 20, 90)
text_in_box(draw, big, ax, body_y + 30, aw, 90, f_big, BLACK, "center")
stats_y = body_y + 130
draw.line((ax + 10, stats_y, ax + aw - 10, stats_y), fill=BLACK, width=line_w)
stats = [
("MIN", f"{out['temp']-2:.0f}"),
("MAX", f"{out['temp']+4:.0f}"),
("FEUCHTE", f"{out['hum']}%"),
("DRUCK", f"{MOCK['pressure']['value']}"),
]
for i, (lbl, val) in enumerate(stats):
cx = ax + 10 + (i % 2) * (aw // 2 - 10)
cy = stats_y + 6 + (i // 2) * 36
text_in_box(draw, lbl + ":", cx, cy, aw // 2 - 10, 12,
fnt(10, bold=True, mono=True), BLACK, "left")
text_in_box(draw, val, cx, cy + 12, aw // 2 - 10, 20,
fit_font(draw, val, fonts, aw // 2 - 10, 20), BLACK, "left")
chart_y = stats_y + 80
draw.line((ax + 10, chart_y, ax + aw - 10, chart_y), fill=BLACK, width=line_w)
text_in_box(draw, "12H VERLAUF", ax + 10, chart_y + 4, aw - 20, 12,
fnt(10, bold=True, mono=True), BLACK, "left")
line_y = chart_y + 20
pts = []
for i in range(24):
x = ax + 10 + int(i * (aw - 20) / 23)
y = line_y + int(8 * math.sin(i / 2.5))
pts.append((x, y))
for a, b in zip(pts, pts[1:]):
draw.line([a, b], fill=BLACK, width=2)
# Wind/Regen
mx, mw = COL2_X, COL2_W
mh = (body_h - line_w) // 2
# Wind
draw.rectangle((mx, body_y, mx + mw - 1, body_y + 24), fill=BLACK)
draw.text((mx + 8, body_y + 5), "WIND", font=fnt(13, bold=True, mono=True), fill=WHITE)
wind = MOCK["wind"]
text_in_box(draw, f"BAT {wind['battery']}%", mx + mw - 70, body_y + 8, 64, 12,
fnt(10, bold=True, mono=True), GREEN, "right")
ws = f"{wind['speed']:02d}"
f_ws = fit_font(draw, ws, fonts, mw - 16, 60)
text_in_box(draw, ws, mx + 8, body_y + 36, mw - 16, 60, f_ws, BLACK, "left")
sw, sh = measure(draw, ws, f_ws)
text_in_box(draw, "km/h", mx + 8 + sw + 4, body_y + 36 + sh - 14, 50, 12,
fnt(11, mono=True), BLACK, "left")
text_in_box(draw, f"BOE {wind['gust']:02d} km/h", mx + 8, body_y + 100, mw - 16, 14,
fnt(10, mono=True), BLACK, "left")
ccx = mx + mw - 28
ccy = body_y + 50
cr = 18
draw.ellipse((ccx - cr, ccy - cr, ccx + cr, ccy + cr), outline=BLACK, width=2)
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
rad = math.radians(a - 90)
tx = ccx + (cr + 8) * math.cos(rad) - 4
ty = ccy + (cr + 8) * math.sin(rad) - 6
draw.text((tx, ty), letter, font=fnt(8, bold=True, mono=True), fill=BLACK)
rad = math.radians(wind["deg"] - 90)
tx = ccx + (cr - 4) * math.cos(rad)
ty = ccy + (cr - 4) * math.sin(rad)
draw.line((ccx, ccy, tx, ty), fill=BLACK, width=3)
draw.ellipse((ccx - 3, ccy - 3, ccx + 3, ccy + 3), fill=BLACK)
# Regen
rry = body_y + mh + line_w
rain = MOCK["rain"]
draw.rectangle((mx, rry, mx + mw - 1, rry + 24), fill=BLACK)
draw.text((mx + 8, rry + 5), "REGEN", font=fnt(13, bold=True, mono=True), fill=WHITE)
text_in_box(draw, f"BAT {rain['battery']}%", mx + mw - 70, rry + 8, 64, 12,
fnt(10, bold=True, mono=True), GREEN, "right")
rs = f"{rain['rate']:04.1f}"
f_rs = fit_font(draw, rs, fonts, mw - 16, 50)
text_in_box(draw, rs, mx + 8, rry + 36, mw - 16, 50, f_rs, BLACK, "left")
rw, rh = measure(draw, rs, f_rs)
text_in_box(draw, "mm/h", mx + 8 + rw + 4, rry + 36 + rh - 12, 50, 12,
fnt(11, mono=True), BLACK, "left")
b1y = rry + 36 + rh + 8
text_in_box(draw, "1H:", mx + 8, b1y, 30, 10, fnt(10, bold=True, mono=True), BLACK, "left")
b1x = mx + 36
b1w = mw - 70
draw.rectangle((b1x, b1y, b1x + b1w, b1y + 10), outline=BLACK, width=2)
p1 = min(100, rain["h1"] / 5 * 100)
if p1 > 0:
draw.rectangle((b1x + 2, b1y + 2, b1x + 2 + int((b1w - 4) * p1 / 100), b1y + 8),
fill=BLACK)
text_in_box(draw, f"{rain['h1']:.1f}", mx + mw - 36, b1y - 1, 30, 12,
fnt(11, bold=True, mono=True), BLACK, "right")
b2y = b1y + 20
text_in_box(draw, "24H:", mx + 8, b2y, 30, 10, fnt(10, bold=True, mono=True), BLACK, "left")
draw.rectangle((b1x, b2y, b1x + b1w, b2y + 10), outline=BLACK, width=2)
p24 = min(100, rain["h24"] / 20 * 100)
if p24 > 0:
draw.rectangle((b1x + 2, b2y + 2, b1x + 2 + int((b1w - 4) * p24 / 100), b2y + 8),
fill=BLACK)
text_in_box(draw, f"{rain['h24']:.1f}", mx + mw - 36, b2y - 1, 30, 12,
fnt(11, bold=True, mono=True), BLACK, "right")
# Indoor
ix, iw = COL3_X, COL3_W
draw.rectangle((ix, body_y, ix + iw - 1, body_y + 24), fill=BLACK)
draw.text((ix + 8, body_y + 5), f"INNEN // {len(MOCK['indoor'])} SENSOREN",
font=fnt(13, bold=True, mono=True), fill=WHITE)
inner_x = ix + 8
inner_w = iw - 16
row_h = (body_h - 28 - 8) // len(MOCK["indoor"])
for i, s in enumerate(MOCK["indoor"]):
ry = body_y + 28 + i * row_h
if i > 0:
draw.line((inner_x, ry, inner_x + inner_w, ry), fill=BLACK, width=1)
tag_w = 20
tag_h = 18
draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + tag_h),
fill=BLACK, outline=BLACK)
f_tag = fnt(13, bold=True, mono=True)
tag_str = s["tag"]
tw_, th_ = measure(draw, tag_str, f_tag)
draw.text((inner_x + (tag_w - tw_) // 2 - 1, ry + 2 + (tag_h - th_) // 2 - 1),
tag_str, font=f_tag, fill=WHITE)
name_x = inner_x + tag_w + 6
name_w = 70
f_name = fit_font(draw, s["name"], fonts, name_w, 14)
draw.text((name_x, ry + 4), s["name"], font=f_name, fill=BLACK)
t_str = f"{s['temp']:04.1f}"
text_in_box(draw, t_str, inner_x + inner_w - 56, ry + 4, 56, 14,
fnt(13, bold=True, mono=True), BLACK, "right")
cby = ry + 22
co2_bar_in_box(draw, inner_x, cby, inner_w, 5, s["co2"])
vwy = cby + 7
co2_str = f"CO2 {s['co2']}ppm" if s["co2"] is not None else "CO2 ----"
draw.text((inner_x, vwy), co2_str, font=fnt(8, bold=True, mono=True), fill=co2_color(s["co2"]))
if s.get("battery") is not None:
pct_str = f"{s['battery']}%"
tw_p, _ = measure(draw, pct_str, fnt(8, bold=True, mono=True))
text_in_box(draw, pct_str, inner_x + inner_w - 22 - tw_p, vwy - 1, tw_p, 10,
fnt(8, bold=True, mono=True), batt_color(s["battery"]), "right")
draw_battery_compact(draw, inner_x + inner_w - 20, vwy - 1, 18, 8, s["battery"])
# Forecast
fx, fw = COL4_X, COL4_W
draw.rectangle((fx, body_y, fx + fw - 1, body_y + 24), fill=BLACK)
draw.text((fx + 8, body_y + 5), "VORHERSAGE // 3TAGE",
font=fnt(12, bold=True, mono=True), fill=WHITE)
row_h_fc = (body_h - 28) // len(MOCK["forecast"])
for i, day in enumerate(MOCK["forecast"]):
ry = body_y + 28 + i * row_h_fc
if i > 0:
draw.line((fx + 8, ry, fx + fw - 8, ry), fill=BLACK, width=1)
draw.text((fx + 8, ry + 4), day["day"], font=fnt(14, bold=True, mono=True), fill=BLACK)
mm_str = f"{day['tmin']:02d}-{day['tmax']:02d}"
f_mm = fnt(18, bold=True, mono=True)
draw.text((fx + 8, ry + 22), mm_str, font=f_mm, fill=BLACK)
rb_y = ry + row_h_fc - 14
rbar_x = fx + 8
rbar_w = fw - 50
draw.rectangle((rbar_x, rb_y, rbar_x + rbar_w, rb_y + 8), outline=BLACK, width=2)
if day["rain"] > 0:
fill_w = max(2, int((rbar_w - 4) * min(100, day["rain"] * 10) / 100))
draw.rectangle((rbar_x + 2, rb_y + 2, rbar_x + 2 + fill_w, rb_y + 6), fill=BLACK)
text_in_box(draw, f"{day['rain']:04.1f}", fx + fw - 36, rb_y - 2, 32, 12,
fnt(11, bold=True, mono=True), BLACK, "right")
# Footer
f_foot = fnt(9, mono=True)
draw.text((COL_LEFT, DISP_H - 14), f"UPDATED {MOCK['update']} // NETATMO+OPEN-METEO",
font=f_foot, fill=BLACK)
img.save(OUT_DIR / "mockup_variant_3.png")
print(f" -> {OUT_DIR / 'mockup_variant_3.png'}")
if __name__ == "__main__":
print("Rendere 3 Mockup-Varianten...")
render_v1()
render_v2()
render_v3()
print("Fertig.")