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
This commit is contained in:
Patrik
2026-08-31 00:47:40 +02:00
parent c5a09833c9
commit 5a83826aa5
5 changed files with 2082 additions and 571 deletions
+186
View File
@@ -235,3 +235,189 @@ 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")