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
424 lines
14 KiB
Python
424 lines
14 KiB
Python
"""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")
|
||
|