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
1020 lines
47 KiB
Python
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.")
|