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).""" """True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
return h >= 280 and w < 280 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")
+354 -1
View File
@@ -4,17 +4,29 @@ Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
exportiert. Die Klasse wird beim Start dynamisch geladen und in der exportiert. Die Klasse wird beim Start dynamisch geladen und in der
Admin-UI zur Auswahl angeboten. Admin-UI zur Auswahl angeboten.
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
Display konsistent aus.
Minimal-Beispiel siehe plugins/hello.py. Minimal-Beispiel siehe plugins/hello.py.
""" """
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
import math
import time import time
import urllib.request import urllib.request
import urllib.error import urllib.error
import socket as _socket import socket as _socket
from PIL import ImageDraw from PIL import ImageDraw, ImageFont
from palette import (
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
BATT_LOW, BATT_MID, BATT_OK, batt_color,
measure, text_wh,
)
# ============================================================================ # ============================================================================
@@ -153,6 +165,13 @@ class Widget(ABC):
merged = dict(self.default_config) merged = dict(self.default_config)
merged.update(config or {}) merged.update(config or {})
self.config = merged self.config = merged
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
self.theme: Any = None # Theme-Instanz (von designbase), optional
def set_fonts(self, fonts: dict):
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
self.fonts = fonts
@abstractmethod @abstractmethod
def fetch(self) -> dict: def fetch(self) -> dict:
@@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]:
classes.append(cls) classes.append(cls)
classes.sort(key=lambda c: c.label or c.name) classes.sort(key=lambda c: c.label or c.name)
return classes return classes
# ============================================================================
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
# das Display konsistent aussieht.
# ============================================================================
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
_BASE_SANS_PATHS = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/seguisb.ttf",
]
_BASE_SANS = None
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
global _BASE_SANS
if _BASE_SANS is not None:
return _BASE_SANS
import os
for p in _BASE_SANS_PATHS:
if os.path.exists(p):
try:
_BASE_SANS = ImageFont.truetype(p, 14)
return _BASE_SANS
except Exception:
continue
try:
_BASE_SANS = ImageFont.load_default()
except Exception:
_BASE_SANS = None
return _BASE_SANS
def _fit_size(draw, text: str, max_w: int, max_h: int,
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
"""Iteriere durch Font-Groessen, waehle die erste die passt.
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
"""
if candidates is None:
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
base = _get_base_sans()
base_path = getattr(base, "path", None) if base else None
for sz in candidates:
try:
sz_int = int(sz)
if base_path:
f = ImageFont.truetype(base_path, sz_int)
else:
f = base
except Exception:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return base
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
align: 'left' (default), 'right', 'center'
"""
tw, th = measure(draw, text, font)
if tw > w or th > h:
return False # wuerde ueberlaufen
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
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
with_text: bool = True, text_color: tuple | None = None):
"""Zeichnet ein konsistentes Batterie-Icon.
Args:
pct: Prozent (0-100), None → noop
w, h: Icon-Groesse (ohne Tip)
with_text: ob "%" daneben gezeichnet werden soll
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
"""
if pct is None:
return 0
pct = max(0, min(100, pct))
color = text_color or batt_color(pct)
# Body
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
# Tip
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
# Fill
fill_w = max(0, int((w - 4) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
used = w
if with_text:
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
used += 30
return used
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
color = color or FG
cx, cy = x + size // 2, y + size // 2
if trend == "up":
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
(cx, y + 2), (x + size - 2, y + size - 2),
(x + size, y + size - 2)], fill=color)
elif trend == "down":
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
(cx, y + size - 2), (x + size - 2, y + 2),
(x + size, y + 2)], fill=color)
else: # stable
draw.line((x, cy, x + size, cy), fill=color, width=2)
# ---- Windrose / Compass --------------------------------------------------
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
with_labels: bool = False):
"""Zeichnet eine Windrose.
Args:
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
"""
if r < 4:
return
# Aeusserer Kreis
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
# Cardinal Ticks
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
rd = math.radians(a - 90)
x1 = cx + (r - 6) * math.cos(rd)
y1 = cy + (r - 6) * math.sin(rd)
x2 = cx + (r - 2) * math.cos(rd)
y2 = cy + (r - 2) * math.sin(rd)
draw.line((x1, y1, x2, y2), fill=FG, width=1)
# Pfeil
rad = math.radians(deg - 90)
tx = cx + (r - 4) * math.cos(rad)
ty = cy + (r - 4) * math.sin(rad)
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
# Optionale N/O/S/W Labels
if with_labels and r >= 14:
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
rd = math.radians(a - 90)
lx = cx + (r + 7) * math.cos(rd) - 4
ly = cy + (r + 7) * math.sin(rd) - 6
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
draw.text((lx, ly), letter, font=f, fill=FG)
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
def draw_weather_icon(draw, x, y, size: int, code: int | str):
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
Verwendet nur die 7 ACeP-Farben.
"""
from palette import wmo_to_icon
sym = wmo_to_icon(code) if isinstance(code, int) else code
cx, cy = x + size // 2, y + size // 2
r = max(3, size // 3)
if sym in ("sun", "clear"):
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
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 sym == "partly":
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
cw = size // 2
ch = size // 4
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
fill=WHITE, outline=FG, width=1)
elif sym == "cloud":
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)
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
elif sym == "rain":
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):
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
elif sym == "storm":
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)
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 sym == "snow":
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)
elif sym == "fog":
cw = int(size * 0.7)
for i, dy in enumerate((-2, 1, 4)):
y_pos = cy + dy
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
else:
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
y_min: float | None = None, y_max: float | None = None,
with_y_grid: bool = True, n_ticks: int = 3):
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
Args:
data: Liste von Werten
color: Linien-Farbe
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
n_ticks: Anzahl Y-Ticks (default 3)
"""
if len(data) < 2 or w <= 10 or h <= 10:
return
# Y-Range bestimmen
if y_min is None:
y_min = min(data)
if y_max is None:
y_max = max(data)
if y_min == y_max:
y_min -= 1
y_max += 1
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
if with_y_grid:
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
ticks = []
if n_ticks >= 2:
ticks = [y_min, y_max]
elif n_ticks == 1:
ticks = [(y_min + y_max) / 2]
for tick in ticks:
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
# Gestrichelte Linie
for seg_x in range(x, x + w, 6):
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
# Daten-Linie
pad_top, pad_bot = 4, 4
pts = []
for i, v in enumerate(data):
px = x + int(i * w / (len(data) - 1))
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
pts.append((px, py))
for i in range(len(pts) - 1):
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
fill=color, width=2)
for px, py in pts:
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
color: tuple | None = None):
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
if w < 6 or h < 4:
return
color = color or FG
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
pct = min(100, max(0, value / max(0.001, max_value) * 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)
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
def draw_zzz_indicator(draw, x, y, size: int = 16):
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
aktiv ist.
"""
cx, cy = x + size // 2, y + size // 2
r = size // 3
# Mond (Yellow circle, halb verdeckt)
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
# 'Z' Buchstaben daneben
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
Args:
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
"""
if w < 60 or h < 12:
return
# Gelber Mond-Icon
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
# 'Schlafend bis' Text
if next_wake:
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")
+450 -560
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
"""Verifizierungs-Script: rendert Netatmo mit dynamischen Mock-Daten.
Findet und behebt:
1. Statische Forecast-Daten (jetzt relativ zu heute)
2. Synthetische Outdoor-History mit groesserer Range (damit Chart nicht flach)
3. Bessere Indoor Batt-Darstellung
"""
import sys
sys.path.insert(0, '.')
from PIL import Image, ImageDraw, ImageFont
from plugins.netatmo import Widget
from datetime import datetime, timedelta
import math
import os
# FIX 1: Mock-Daten mit dynamischen Daten (heute + 2 Tage)
now = datetime.now()
forecast_days = [(now + timedelta(days=i)).date().isoformat() for i in range(3)]
# FIX 2: Synthetische Outdoor-History mit groesserem Temperatur-Range
# 12h-Verlauf mit sin-voller Swing (12.5 - 22.5 Grad, also 10 Grad Range)
outdoor_temps = []
for h in range(12):
t = 17.5 + 4.5 * math.sin(h / 2.2) + 1.0 * math.cos(h / 1.3)
outdoor_temps.append(round(t, 1))
temp_history = ';'.join(str(t) for t in outdoor_temps)
print(f'History: min={min(outdoor_temps)}, max={max(outdoor_temps)}, n={len(outdoor_temps)}')
mock = {
'station_name': 'Daheim', 'place': {},
'main': {'name': 'Daheim', 'data': {'Temperature': 21.5, 'CO2': 620, 'Humidity': 48, 'Pressure': 1014, 'Pressure_trend': 'down'}},
'modules': [
# Aussentemperatur = letzter Wert der History
{'type': 'NAModule1', 'name': 'Aussen', 'data': {'Temperature': outdoor_temps[-1], 'Humidity': 67, 'Temp_history': temp_history}, 'battery_pct': 78},
{'type': 'NAModule2', 'name': 'Wind', 'data': {'WindStrength': 12, 'GustStrength': 24, 'WindAngle': 215}, 'battery_pct': 65},
{'type': 'NAModule3', 'name': 'Regen', 'data': {'RainRate': 0.0, 'sum_rain_1': 0.4, 'sum_rain_24': 6.2}, 'battery_pct': 82},
{'type': 'NAModule4', 'name': 'Kueche', 'data': {'Temperature': 23.5, 'CO2': 980}, 'battery_pct': 88},
{'type': 'NAModule4', 'name': 'Schlafen', 'data': {'Temperature': 20.8, 'CO2': 1450}, 'battery_pct': 41},
],
'forecast': {
'daily': {
'time': forecast_days, # DYNAMISCH: heute + 2 Tage
'temperature_2m_max': [19, 21, 17],
'temperature_2m_min': [9, 11, 8],
'precipitation_sum': [0.0, 2.4, 8.1],
'weather_code': [0, 3, 61],
},
},
}
w = Widget({})
w.fetch = lambda: mock
fonts = {}
for sz in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 40, 48, 60, 72, 84, 96]:
fonts[str(sz)] = ImageFont.truetype(os.path.join('C:/Windows/Fonts/arial.ttf'), sz)
fonts['default'] = ImageFont.load_default()
img = Image.new('RGB', (800, 480), (255, 255, 255))
draw = ImageDraw.Draw(img)
w.set_fonts(fonts)
w.render(draw, fonts, 0, 0, 800, 480)
img.save('docs/test_netatmo_v10.png')
print(f'OK saved v10 ({now.strftime("%H:%M")})')