diff --git a/screens/weather.py b/screens/weather.py index 037d7d4..f5ec06f 100644 --- a/screens/weather.py +++ b/screens/weather.py @@ -8,6 +8,7 @@ from PIL import Image, ImageDraw from screens.base import Screen from screens.render_utils import load_font, text_width +from screens.weather_icons import WMO_CODES, draw_weather_icon logger = logging.getLogger(__name__) @@ -17,34 +18,6 @@ FORECAST_DAY_CHOICES = (3, 5, 7) WEEKDAY_ABBR_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] -# WMO weather codes -> (German description, symbol). Not exhaustive, covers -# the common cases; unmapped codes fall back to a generic description. -WMO_CODES = { - 0: ("Klarer Himmel", "☀"), - 1: ("Überwiegend klar", "☀"), - 2: ("Teilweise bewölkt", "☁"), # DejaVu Sans has no ⛅ glyph, falls back to a tofu box - 3: ("Bedeckt", "☁"), - 45: ("Nebel", "☁"), - 48: ("Nebel", "☁"), - 51: ("Leichter Nieselregen", "☂"), - 53: ("Nieselregen", "☂"), - 55: ("Starker Nieselregen", "☂"), - 61: ("Leichter Regen", "☔"), - 63: ("Regen", "☔"), - 65: ("Starker Regen", "☔"), - 71: ("Leichter Schneefall", "❄"), - 73: ("Schneefall", "❄"), - 75: ("Starker Schneefall", "❄"), - 80: ("Regenschauer", "☔"), - 81: ("Regenschauer", "☔"), - 82: ("Heftige Regenschauer", "☔"), - 85: ("Schneeschauer", "❄"), - 86: ("Schneeschauer", "❄"), - 95: ("Gewitter", "⚡"), - 96: ("Gewitter mit Hagel", "⚡"), - 99: ("Gewitter mit Hagel", "⚡"), -} - class WeatherScreen(Screen): id = "weather" @@ -107,7 +80,7 @@ class WeatherScreen(Screen): return None code = int(current.get("weathercode", -1)) - description, symbol = WMO_CODES.get(code, ("Unbekannt", "?")) + description, kind = WMO_CODES.get(code, ("Unbekannt", "clear")) days = [] daily = payload.get("daily") or {} @@ -117,10 +90,10 @@ class WeatherScreen(Screen): except ValueError: continue day_code = int(daily["weather_code"][i]) - _, day_symbol = WMO_CODES.get(day_code, ("Unbekannt", "?")) + _, day_kind = WMO_CODES.get(day_code, ("Unbekannt", "clear")) days.append({ "label": WEEKDAY_ABBR_DE[weekday], - "symbol": day_symbol, + "kind": day_kind, "high": daily["temperature_2m_max"][i], "low": daily["temperature_2m_min"][i], }) @@ -129,7 +102,7 @@ class WeatherScreen(Screen): "temperature": current["temperature"], "windspeed": current.get("windspeed"), "description": description, - "symbol": symbol, + "kind": kind, "days": days, } @@ -150,49 +123,72 @@ class WeatherScreen(Screen): days = state.get("days") or [] has_forecast = bool(days) - current_h = height * (0.56 if has_forecast else 1.0) - symbol_font = load_font(size=int(scale * (0.20 if has_forecast else 0.28))) - temp_font = load_font(size=int(scale * (0.15 if has_forecast else 0.22)), bold=True) - desc_font = load_font(size=int(scale * 0.05)) - - symbol = state["symbol"] - sw = text_width(draw, symbol, symbol_font) - draw.text(((width - sw) / 2, current_h * 0.08), symbol, font=symbol_font, fill="black") + # Icon + temperature + description, stacked. Sized from `scale` + # directly (not a guessed fraction of current_h) and measured via + # actual text bounding boxes, so current_h can be *derived* from + # what the block actually needs -- rather than picking current_h + # first and hoping the content fits in it, which overflowed into + # the forecast row for some font metrics. + icon_size = scale * (0.24 if has_forecast else 0.5) + temp_font = load_font(size=int(scale * (0.13 if has_forecast else 0.22)), bold=True) + desc_font = load_font(size=int(scale * (0.045 if has_forecast else 0.05))) temp_str = f"{round(state['temperature'])}°" - tw = text_width(draw, temp_str, temp_font) - draw.text(((width - tw) / 2, current_h * 0.42), temp_str, font=temp_font, fill="black") - desc = state["description"] - dw = text_width(draw, desc, desc_font) - draw.text(((width - dw) / 2, current_h * 0.78), desc, font=desc_font, fill=(90, 90, 90)) + temp_left, temp_top, temp_right, temp_bottom = draw.textbbox((0, 0), temp_str, font=temp_font) + temp_h = temp_bottom - temp_top + desc_left, desc_top, desc_right, desc_bottom = draw.textbbox((0, 0), desc, font=desc_font) + desc_h = desc_bottom - desc_top + + icon_gap = icon_size * 0.12 + temp_gap = temp_h * 0.4 + block_h = icon_size + icon_gap + temp_h + temp_gap + desc_h + + if has_forecast: + top_pad = height * 0.04 + bottom_pad = height * 0.035 + current_h = block_h + top_pad + bottom_pad + block_top = top_pad + else: + current_h = height + block_top = (current_h - block_h) / 2 + + icon_cy = block_top + icon_size / 2 + draw_weather_icon(draw, state["kind"], width / 2, icon_cy, icon_size) + + temp_y = block_top + icon_size + icon_gap + tw = temp_right - temp_left + draw.text(((width - tw) / 2, temp_y - temp_top), temp_str, font=temp_font, fill="black") + + desc_y = temp_y + temp_h + temp_gap + dw = desc_right - desc_left + draw.text(((width - dw) / 2, desc_y - desc_top), desc, font=desc_font, fill=(90, 90, 90)) if has_forecast: forecast_y = current_h forecast_h = height - forecast_y - draw.line((width * 0.06, forecast_y, width * 0.94, forecast_y), fill=(224, 224, 224), width=2) + draw.rectangle((width * 0.06, forecast_y, width * 0.94, forecast_y + 2), fill=(0, 0, 0)) col_w = width / len(days) - label_font = load_font(size=int(scale * 0.042), bold=True) - day_symbol_font = load_font(size=int(scale * 0.085)) - day_temp_font = load_font(size=int(scale * 0.038)) + label_font = load_font(size=int(scale * 0.052), bold=True) + day_temp_font = load_font(size=int(scale * 0.046), bold=True) + day_icon_size = min(col_w * 0.75, forecast_h * 0.48) for i, day in enumerate(days): cx = col_w * i + col_w / 2 - y = forecast_y + forecast_h * 0.12 + y = forecast_y + forecast_h * 0.1 label = day["label"] lw = text_width(draw, label, label_font) - draw.text((cx - lw / 2, y), label, font=label_font, fill=(90, 90, 90)) - y += label_font.size * 1.5 + draw.text((cx - lw / 2, y), label, font=label_font, fill=(60, 60, 60)) + y += label_font.size * 1.4 - dsw = text_width(draw, day["symbol"], day_symbol_font) - draw.text((cx - dsw / 2, y), day["symbol"], font=day_symbol_font, fill="black") - y += day_symbol_font.size * 1.2 + draw_weather_icon(draw, day["kind"], cx, y + day_icon_size / 2, day_icon_size) + y += day_icon_size * 1.1 temps = f"{round(day['high'])}°/{round(day['low'])}°" dtw = text_width(draw, temps, day_temp_font) - draw.text((cx - dtw / 2, y), temps, font=day_temp_font, fill=(110, 110, 110)) + draw.text((cx - dtw / 2, y), temps, font=day_temp_font, fill=(50, 50, 50)) return image diff --git a/screens/weather_icons.py b/screens/weather_icons.py new file mode 100644 index 0000000..b2d6af1 --- /dev/null +++ b/screens/weather_icons.py @@ -0,0 +1,121 @@ +"""Hand-drawn weather icons using the panel's actual 6-color palette (from +epd4in0e's getbuffer() quantization palette: black, white, yellow, red, blue, +green) instead of relying on font glyph coverage for weather emoji, which +turned out to be incomplete (DejaVu Sans has no partly-cloudy glyph) and is +monochrome anyway. +""" + +import math + +BLACK = (0, 0, 0) +WHITE = (255, 255, 255) +YELLOW = (255, 255, 0) +BLUE = (0, 0, 255) + +# WMO weather code -> (description, icon kind). Not exhaustive, covers the +# common cases; unmapped codes fall back to "clear". +WMO_CODES = { + 0: ("Klarer Himmel", "clear"), + 1: ("Überwiegend klar", "clear"), + 2: ("Teilweise bewölkt", "partly_cloudy"), + 3: ("Bedeckt", "cloudy"), + 45: ("Nebel", "fog"), + 48: ("Nebel", "fog"), + 51: ("Leichter Nieselregen", "rain"), + 53: ("Nieselregen", "rain"), + 55: ("Starker Nieselregen", "rain"), + 61: ("Leichter Regen", "rain"), + 63: ("Regen", "rain"), + 65: ("Starker Regen", "rain"), + 71: ("Leichter Schneefall", "snow"), + 73: ("Schneefall", "snow"), + 75: ("Starker Schneefall", "snow"), + 80: ("Regenschauer", "rain"), + 81: ("Regenschauer", "rain"), + 82: ("Heftige Regenschauer", "rain"), + 85: ("Schneeschauer", "snow"), + 86: ("Schneeschauer", "snow"), + 95: ("Gewitter", "thunder"), + 96: ("Gewitter mit Hagel", "thunder"), + 99: ("Gewitter mit Hagel", "thunder"), +} + + +def _cloud_shape(draw, cx, cy, w, h, color): + draw.rounded_rectangle((cx - w / 2, cy - h * 0.1, cx + w / 2, cy + h * 0.55), radius=h * 0.28, fill=color) + draw.ellipse((cx - w * 0.42, cy - h * 0.48, cx - w * 0.02, cy + h * 0.12), fill=color) + draw.ellipse((cx - w * 0.18, cy - h * 0.62, cx + w * 0.22, cy + h * 0.02), fill=color) + draw.ellipse((cx + w * 0.05, cy - h * 0.42, cx + w * 0.45, cy + h * 0.18), fill=color) + + +def draw_cloud(draw, cx, cy, size, outline=BLACK, fill=WHITE): + """Simple line-art cloud: draw the silhouette in `outline`, then a + shrunk-down copy in `fill` on top -- fakes an outlined shape without + needing real path-offsetting.""" + w, h = size, size * 0.7 + border = max(2, size * 0.07) + _cloud_shape(draw, cx, cy, w, h, outline) + _cloud_shape(draw, cx, cy, w - border * 2.4, h - border * 2.4, fill) + + +def draw_sun(draw, cx, cy, r, color=YELLOW, outline=BLACK): + ray_w = max(2, int(r * 0.16)) + for angle_deg in range(0, 360, 45): + rad = math.radians(angle_deg) + # Kept within roughly r*1.4 so the whole icon (rays included) stays + # inside the nominal size x size box callers lay out around it. + inner, outer = r * 1.15, r * 1.4 + draw.line( + (cx + math.cos(rad) * inner, cy + math.sin(rad) * inner, + cx + math.cos(rad) * outer, cy + math.sin(rad) * outer), + fill=color, width=ray_w, + ) + draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color, outline=outline, width=max(2, int(r * 0.1))) + + +def _draw_snowflake(draw, cx, cy, r, color): + for angle_deg in range(0, 180, 60): + rad = math.radians(angle_deg) + draw.line( + (cx - math.cos(rad) * r, cy - math.sin(rad) * r, + cx + math.cos(rad) * r, cy + math.sin(rad) * r), + fill=color, width=max(2, int(r * 0.35)), + ) + + +def draw_weather_icon(draw, kind: str, cx: float, cy: float, size: float): + """Draws into a roughly `size` x `size` box centered on (cx, cy).""" + if kind == "clear": + draw_sun(draw, cx, cy, size * 0.34) + elif kind == "partly_cloudy": + draw_sun(draw, cx - size * 0.16, cy - size * 0.18, size * 0.22) + draw_cloud(draw, cx + size * 0.08, cy + size * 0.14, size * 0.75) + elif kind == "cloudy": + draw_cloud(draw, cx, cy, size * 0.9) + elif kind == "fog": + for i, dy in enumerate((-0.25, -0.05, 0.15, 0.35)): + band_w = size * (0.9 - i * 0.12) + yy = cy + size * dy + draw.line((cx - band_w / 2, yy, cx + band_w / 2, yy), fill=BLACK, width=max(2, int(size * 0.05))) + elif kind == "rain": + draw_cloud(draw, cx, cy - size * 0.12, size * 0.85) + for dx in (-0.28, -0.02, 0.24): + x0, y0 = cx + size * dx, cy + size * 0.3 + draw.line((x0, y0, x0 - size * 0.08, y0 + size * 0.24), fill=BLUE, width=max(2, int(size * 0.05))) + elif kind == "snow": + draw_cloud(draw, cx, cy - size * 0.12, size * 0.85) + for dx in (-0.28, -0.02, 0.24): + _draw_snowflake(draw, cx + size * dx, cy + size * 0.38, size * 0.08, BLUE) + elif kind == "thunder": + draw_cloud(draw, cx, cy - size * 0.16, size * 0.85) + bolt = [ + (cx + size * 0.05, cy + size * 0.16), + (cx - size * 0.08, cy + size * 0.42), + (cx + size * 0.02, cy + size * 0.42), + (cx - size * 0.1, cy + size * 0.7), + (cx + size * 0.18, cy + size * 0.36), + (cx + size * 0.06, cy + size * 0.36), + ] + draw.polygon(bolt, fill=YELLOW, outline=BLACK) + else: + draw_sun(draw, cx, cy, size * 0.34)