Files
epaper-dashboard/palette.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

424 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""7-Farben-Palette für Waveshare 7.3-inch ACeP (F) HAT.
Der Treiber epd7in3f.EPD.getbuffer() quantisiert ein RGB-Bild automatisch
auf die 7 vom Panel unterstützten Farben. Wir liefern RGB; das macht unseren
Renderer-Code lesbar.
Reihenfolge der Farbnamen folgt der Wiki-Spezifikation:
Black, White, Green, Blue, Red, Yellow, Orange
Zusätzlich semantische Aliase:
FG/BG = Vorder-/Hintergrund
OK/WARN/ALERT = Statusbalken
INFO = Blue
ACCENT = Orange
INVERT_FG/INVERT_BG = invertiertes Feld (UV/AQI high)
"""
from PIL import ImageDraw, ImageFont
# ---- 7 Panel-Farben (RGB) ----
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)
# ---- Semantische Aliase ----
FG = BLACK
BG = WHITE
INVERT_BG = BLACK
INVERT_FG = WHITE
OK = GREEN
WARN = YELLOW
ALERT = RED
INFO = BLUE
ACCENT = ORANGE
_TABLE = {
"black": BLACK, "white": WHITE, "green": GREEN, "blue": BLUE,
"red": RED, "yellow": YELLOW, "orange": ORANGE,
"fg": FG, "bg": BG,
"invert_fg": INVERT_FG, "invert_bg": INVERT_BG,
"ok": OK, "warn": WARN, "alert": ALERT, "info": INFO, "accent": ACCENT,
}
def fill_for(name: str) -> tuple:
if name not in _TABLE:
raise ValueError(f"unknown color name: {name!r}")
return _TABLE[name]
def measure(draw: ImageDraw.Draw, text: str, font: ImageFont.FreeTypeFont):
try:
b = draw.textbbox((0, 0), text, font=font)
return b[2] - b[0], b[3] - b[1]
except AttributeError:
return draw.textsize(text, font=font)
def text_wh(draw, text, font):
return measure(draw, text, font)
# ============================================================================
# Layout helpers — damit Widgets slot-relative und größen-responsive arbeiten
# ============================================================================
def fit_font(draw, text: str, fonts: dict, max_w: int, max_h: int,
candidates: list[str] | None = None) -> ImageFont.FreeTypeFont | None:
"""Wählt die größte Schrift aus `candidates` (oder '80','60','48','32','28','24','20','16'),
deren gerenderter Text in max_w × max_h passt.
`fonts` ist das Dictionary aus dashboard.load_fonts().
"""
if candidates is None:
candidates = ["clock", "80", "60", "48", "32", "28", "24", "20", "16"]
for key in candidates:
f = fonts.get(key)
if f is None:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return fonts.get("16") or fonts.get("default")
def centered_text(draw, text, x, y, w, h, font, color):
"""Zentriert einen Text in der gegebenen Box (x,y,w,h). Returns (px, py)."""
tw, th = measure(draw, text, font)
px = x + max(0, (w - tw) // 2)
py = y + max(0, (h - th) // 2)
draw.text((px, py), text, font=font, fill=color)
return px, py
def hbar(draw, x, y, w, h, pct: float, fg=None, bg=None, border_w: int = 2,
thresholds: list | None = None, gradient: bool = False):
"""Horizontaler Prozentbalken mit konfigurierbarem Farbverlauf.
Args:
pct: 0..100 (wird geclampt)
fg: fallback-Farbe (falls keine thresholds)
thresholds: Liste von (max_pct, color) Tupeln, sortiert aufsteigend.
Beispiel: [(50, OK), (80, WARN), (100, ALERT)]
Bei pct < 50 → OK, pct < 80 → WARN, sonst ALERT.
gradient: wenn True, wird der Balken in mehrere Segmente mit den
Threshold-Farben aufgeteilt, statt ein einfarbiger Fill zu sein.
Wenn `gradient=False` und `thresholds` gesetzt: einfarbiger Fill in der
ersten passenden Threshold-Farbe.
Wenn beides None: einfarbig in fg (oder FG-Fallback).
"""
fg = fg or FG
bg = bg or BG
if h < 6 or w < 6:
return
pct = max(0.0, min(100.0, pct))
# Welche Farbe für pct?
def color_for(p: float) -> tuple:
if thresholds:
for max_p, c in thresholds:
if p <= max_p:
return c
return thresholds[-1][1]
return fg
# Frame
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=fg, width=border_w)
if gradient and thresholds and len(thresholds) >= 2:
# Multi-Segment-Bar: zeichne für jeden Threshold-Bereich sein eigenes Segment
inner_x = x + border_w
inner_w = w - 2 * border_w
if inner_w > 0:
prev_max = 0.0
for max_p, c in thresholds:
seg_pct_start = prev_max
seg_pct_end = min(max_p, pct)
if seg_pct_end > seg_pct_start:
seg_x0 = inner_x + int(inner_w * seg_pct_start / 100)
seg_x1 = inner_x + int(inner_w * seg_pct_end / 100)
draw.rectangle((seg_x0, y + border_w,
seg_x1, y + h - 1 - border_w), fill=c)
prev_max = max_p
# Falls pct die höchste Threshold überschreitet
if pct > thresholds[-1][0]:
seg_x0 = inner_x + int(inner_w * thresholds[-1][0] / 100)
draw.rectangle((seg_x0, y + border_w,
inner_x + inner_w - 1, y + h - 1 - border_w),
fill=thresholds[-1][1])
else:
# Einfarbiger Fill in passender Farbe
fill_w = int((w - 2 * border_w) * pct / 100)
if fill_w > 0:
draw.rectangle((x + border_w, y + border_w,
x + border_w + fill_w, y + h - 1 - border_w),
fill=color_for(pct))
# Default-Schwellen: (max_pct, color)
DEFAULT_THRESHOLDS = [
(50, OK),
(80, WARN),
(95, ALERT),
]
def parse_thresholds(spec) -> list:
"""Parst Threshold-Spec aus Plugin-Config.
Akzeptiert entweder:
- String "ok,warn,alert" (3 stufig, default-Werte 50/80/95)
- String "ok@50,warn@80,alert@95" (custom Schwellen)
- Liste von Dicts [{"pct": 50, "color": "ok"}, ...]
- None → DEFAULT_THRESHOLDS
"""
if spec is None or spec == "":
return list(DEFAULT_THRESHOLDS)
if isinstance(spec, list):
result = []
for item in spec:
try:
if isinstance(item, dict):
p = float(item.get("pct", 100))
c = fill_for(item.get("color", "fg"))
else:
# tuple-like
p = float(item[0])
c = fill_for(item[1])
result.append((p, c))
except (ValueError, TypeError, KeyError):
continue
return sorted(result, key=lambda x: x[0]) if result else list(DEFAULT_THRESHOLDS)
if isinstance(spec, str):
parts = [s.strip() for s in spec.split(",") if s.strip()]
# Default-Stufen wenn keine @-Syntax
default_pcts = [50, 80, 95]
result = []
for i, p in enumerate(parts):
if "@" in p:
name, val = p.split("@", 1)
try:
pct = float(val)
except ValueError:
continue
else:
name = p
pct = default_pcts[i] if i < len(default_pcts) else 100
try:
color = fill_for(name)
except ValueError:
continue
result.append((pct, color))
return sorted(result, key=lambda x: x[0]) if result else list(DEFAULT_THRESHOLDS)
return list(DEFAULT_THRESHOLDS)
def is_small(w: int, h: int) -> bool:
"""True wenn Slot klein ist (1x1 oder ähnlich). Widgets können darauf
vereinfachtes Layout zeigen."""
return w < 280 or h < 180
def is_wide(w: int, h: int) -> bool:
"""True wenn Slot breit aber flach ist (4x1, 2x1)."""
return w >= 400 and h < 200
def is_tall(w: int, h: int) -> bool:
"""True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
return h >= 280 and w < 280
# ============================================================================
# Batterie-Farben — Single Source of Truth
# ============================================================================
BATT_LOW = RED # < 25% → kritisch
BATT_MID = ORANGE # < 50% → bald wechseln
BATT_OK = GREEN # >= 50% → OK
# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt)
BATT_THRESHOLDS = (
(25, BATT_LOW),
(50, BATT_MID),
)
def batt_color(pct: int | None) -> tuple:
"""Liefert die passende Batterie-Farbe fuer einen Prozent-Wert.
None → FG (keine Anzeige), sonst BATT_LOW/MID/OK.
"""
if pct is None:
return FG
pct = max(0, min(100, pct))
for threshold, color in BATT_THRESHOLDS:
if pct < threshold:
return color
return BATT_OK
# ============================================================================
# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr)
# ============================================================================
DE_WEEKDAYS_LONG = [
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag",
]
DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DE_MONTHS_LONG = [
"Januar", "Februar", "Maerz", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
def de_weekday_long(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/..."""
return DE_WEEKDAYS_LONG[weekday]
def de_weekday_short(weekday: int) -> str:
"""datetime.weekday() (0=Montag) → 'Mo'/'Di'/..."""
return DE_WEEKDAYS_SHORT[weekday]
def de_month_short(month: int) -> str:
"""datetime.month (1-12) → 'Jan'/'Feb'/..."""
return DE_MONTHS_SHORT[month - 1]
def de_date(dt, short: bool = True) -> str:
"""Datum als '30. Aug 2026' (default short=True)."""
if short:
return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}"
return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}"
def de_weekday_date(dt, short: bool = True) -> str:
"""'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'."""
if short:
return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}"
return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}"
def de_time(dt, show_seconds: bool = False) -> str:
"""24h-Zeit 'HH:MM' oder 'HH:MM:SS'."""
return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M")
# ============================================================================
# Wind / Temp Helper — einheitliche Formatierung
# ============================================================================
def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str:
"""Temperatur formatieren. None → em-dash."""
if value is None:
return "—"
return f"{value:.{decimals}f}°{unit}"
def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str:
"""Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'."""
if speed is None:
return "—"
label = "km/h" if unit == "kmh" else "m/s"
return f"{speed:.{decimals}f} {label}"
def format_rain(rate_mm: float | None, decimals: int = 1) -> str:
"""Regenrate 'X.X mm/h'."""
if rate_mm is None:
return "—"
return f"{rate_mm:.{decimals}f} mm/h"
def temp_color(value: float | None) -> tuple:
"""Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot."""
if value is None:
return FG
if value >= 28:
return RED
if value >= 22:
return ORANGE
if value <= 5:
return BLUE
return FG
def co2_color(ppm: int | float | None) -> tuple:
"""CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot."""
if ppm is None:
return FG
if ppm >= 1500:
return RED
if ppm >= 1000:
return YELLOW
if ppm >= 600:
return ORANGE
return GREEN
# ============================================================================
# Power-Management — Defaults
# ============================================================================
POWER_DEFAULTS = {
"deepsleep_enabled": False,
"deepsleep_start": "23:30",
"deepsleep_end": "06:30",
"cpu_throttle": True, # waehrend DeepSleep auf 600MHz
"wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit
}
# ============================================================================
# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback)
# ============================================================================
# WMO Weather interpretation codes (https://open-meteo.com/en/docs)
WMO_ICONS = {
0: "sun", # Clear sky
1: "partly", # Mainly clear
2: "partly", # Partly cloudy
3: "cloud", # Overcast
45: "fog", # Fog
48: "fog", # Depositing rime fog
51: "rain", # Drizzle: light
53: "rain", # Drizzle: moderate
55: "rain", # Drizzle: dense
56: "rain", # Freezing Drizzle: light
57: "rain", # Freezing Drizzle: dense
61: "rain", # Rain: slight
63: "rain", # Rain: moderate
65: "rain", # Rain: heavy
66: "rain", # Freezing Rain: light
67: "rain", # Freezing Rain: heavy
71: "snow", # Snow fall: slight
73: "snow", # Snow fall: moderate
75: "snow", # Snow fall: heavy
77: "snow", # Snow grains
80: "rain", # Rain showers: slight
81: "rain", # Rain showers: moderate
82: "rain", # Rain showers: violent
85: "snow", # Snow showers: slight
86: "snow", # Snow showers: heavy
95: "storm", # Thunderstorm
96: "storm", # Thunderstorm with slight hail
99: "storm", # Thunderstorm with heavy hail
}
def wmo_to_icon(code: int | str | None) -> str:
"""WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/..."""
if code is None:
return "cloud"
if isinstance(code, str):
return code # schon ein symbol
return WMO_ICONS.get(code, "cloud")