Weather: icon-left/temp-right layout, feels-like/high-low/rain

chance, and an hourly temperature graph

- Current conditions block redone horizontally (icon left, big temp
  + description + an extras line on the right) instead of stacked
  vertically.
- New extras line: "feels like" (apparent_temperature), today's
  high/low, and rain chance (precipitation_probability_max) -- each
  independently toggleable in settings, all on by default. Switched
  from the legacy current_weather=true param to Open-Meteo's current=
  param, since that's what exposes apparent_temperature; confirmed
  field names/formats live before wiring them in.
- New hourly-today temperature graph (also toggleable): a polyline
  across today's 24 hourly points, min/max temps labeled at their
  points, a red dot marking the current hour, and hour labels along
  the bottom.
- When both the graph and the daily forecast strip are shown, they
  split the remaining vertical space; either can be toggled off to
  give the other more room, matching how forecast_days already
  worked.
This commit is contained in:
Honey committed 2026-07-23 12:06:36 +02:00
1 parent 132e133b1d
commit 27f4397b04
3 files changed
+218 -58

No files matched your search

+192 -58
View File
@@ -1,6 +1,7 @@
"""Current weather + daily forecast via Open-Meteo (free, no API key)."""
import logging
import time
from datetime import date
import requests
@@ -10,6 +11,10 @@ from screens.base import Screen
from screens.render_utils import load_font, text_width
from screens.weather_icons import WMO_CODES, draw_weather_icon
BLACK = (0, 0, 0)
BLUE = (0, 0, 255)
RED = (200, 0, 0)
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 10
@@ -25,7 +30,13 @@ class WeatherScreen(Screen):
poll_interval = 1800 # fallback default (30 min) if settings has no update_minutes yet
def default_settings(self) -> dict:
return {"city": "", "latitude": None, "longitude": None, "forecast_days": 3, "update_minutes": 30}
return {
"city": "", "latitude": None, "longitude": None, "forecast_days": 3, "update_minutes": 30,
"show_feels_like": True,
"show_high_low": True,
"show_rain_chance": True,
"show_hourly_graph": True,
}
def get_poll_interval(self, settings: dict) -> int:
raw = settings.get("update_minutes")
@@ -76,8 +87,13 @@ class WeatherScreen(Screen):
params={
"latitude": lat,
"longitude": lon,
"current_weather": "true",
"daily": "weather_code,temperature_2m_max,temperature_2m_min",
# "current" (not the legacy "current_weather=true") is what
# exposes apparent_temperature. Confirmed live which fields
# come back plain-local (no Z suffix) under timezone=auto,
# same as the "daily" block already relied on below.
"current": "temperature_2m,apparent_temperature,weather_code",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
"hourly": "temperature_2m",
"forecast_days": forecast_days,
"timezone": "auto",
},
@@ -86,11 +102,11 @@ class WeatherScreen(Screen):
response.raise_for_status()
payload = response.json()
current = payload.get("current_weather") or {}
if "temperature" not in current:
current = payload.get("current") or {}
if "temperature_2m" not in current:
return None
code = int(current.get("weathercode", -1))
code = int(current.get("weather_code", -1))
description, kind = WMO_CODES.get(code, ("Unbekannt", "clear"))
days = []
@@ -109,12 +125,31 @@ class WeatherScreen(Screen):
"low": daily["temperature_2m_min"][i],
})
rain_chance = None
probs = daily.get("precipitation_probability_max") or []
if probs and probs[0] is not None:
rain_chance = int(probs[0])
# Today's hourly points, for the day-graph -- "hourly" spans all of
# forecast_days, filtered down to just today's date string prefix.
today_str = date.today().isoformat()
hourly = payload.get("hourly") or {}
hourly_today = [
{"hour": int(t[11:13]), "temp": temp}
for t, temp in zip(hourly.get("time", []), hourly.get("temperature_2m", []))
if t.startswith(today_str)
]
return {
"temperature": current["temperature"],
"windspeed": current.get("windspeed"),
"temperature": current["temperature_2m"],
"feels_like": current.get("apparent_temperature"),
"description": description,
"kind": kind,
"days": days,
"high_today": days[0]["high"] if days else None,
"low_today": days[0]["low"] if days else None,
"rain_chance": rain_chance,
"hourly_today": hourly_today,
}
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
@@ -122,6 +157,7 @@ class WeatherScreen(Screen):
image = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(image)
scale = min(width, height)
pad = width * 0.06
if not state:
font = load_font(size=int(scale * 0.06))
@@ -134,72 +170,170 @@ class WeatherScreen(Screen):
days = state.get("days") or []
has_forecast = bool(days)
hourly = state.get("hourly_today") or []
show_graph = settings.get("show_hourly_graph", True) and len(hourly) >= 2
lower_sections = int(has_forecast) + int(show_graph)
# 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)))
# Current conditions: icon on the left, temp/description/extras
# stacked on the right -- sized from `scale` and measured via actual
# text bounding boxes so current_h can be *derived* from what the
# block actually needs, same principle as the original stacked
# layout, just laid out horizontally now.
icon_size = scale * 0.34
temp_font = load_font(size=int(scale * 0.15), bold=True)
desc_font = load_font(size=int(scale * 0.055))
extra_font = load_font(size=int(scale * 0.04))
temp_str = f"{round(state['temperature'])}°"
desc = state["description"]
extra_parts = []
if settings.get("show_feels_like", True) and state.get("feels_like") is not None:
extra_parts.append(f"Gefühlt {round(state['feels_like'])}°")
if (
settings.get("show_high_low", True)
and state.get("high_today") is not None
and state.get("low_today") is not None
):
extra_parts.append(f"H {round(state['high_today'])}° L {round(state['low_today'])}°")
if settings.get("show_rain_chance", True) and state.get("rain_chance") is not None:
extra_parts.append(f"{state['rain_chance']}% Regen")
extra_line = " · ".join(extra_parts)
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
extra_top = extra_h = 0
if extra_line:
extra_left, extra_top, extra_right, extra_bottom = draw.textbbox((0, 0), extra_line, font=extra_font)
extra_h = extra_bottom - extra_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
text_gap = temp_h * 0.22
text_block_h = temp_h + text_gap + desc_h + ((text_gap + extra_h) if extra_line else 0)
current_h = max(icon_size, text_block_h)
top_pad = (height - current_h) / 2 if lower_sections == 0 else height * 0.05
block_top = top_pad
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_cx = pad + icon_size / 2
icon_cy = block_top + current_h / 2
draw_weather_icon(draw, state["kind"], icon_cx, icon_cy, icon_size)
icon_cy = block_top + icon_size / 2
draw_weather_icon(draw, state["kind"], width / 2, icon_cy, icon_size)
text_x = pad + icon_size + scale * 0.05
text_top = block_top + (current_h - text_block_h) / 2
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")
draw.text((text_x, text_top - 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="black")
desc_y = text_top + temp_h + text_gap
draw.text((text_x, desc_y - desc_top), desc, font=desc_font, fill="black")
if has_forecast:
forecast_y = current_h
forecast_h = height - forecast_y
draw.rectangle((width * 0.06, forecast_y, width * 0.94, forecast_y + 2), fill=(0, 0, 0))
if extra_line:
extra_y = desc_y + desc_h + text_gap
draw.text((text_x, extra_y - extra_top), extra_line, font=extra_font, fill="black")
col_w = width / len(days)
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)
if lower_sections == 0:
return image
for i, day in enumerate(days):
cx = col_w * i + col_w / 2
y = forecast_y + forecast_h * 0.1
current_bottom = block_top + current_h + height * 0.045
remaining_h = height - current_bottom - height * 0.02
content_w = width - 2 * pad
label = day["label"]
lw = text_width(draw, label, label_font)
draw.text((cx - lw / 2, y), label, font=label_font, fill="black")
y += label_font.size * 1.4
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="black")
if show_graph and has_forecast:
graph_h = remaining_h * 0.48
self._draw_hourly_graph(draw, hourly, pad, current_bottom, content_w, graph_h, scale)
self._draw_daily_forecast(
draw, days, pad, current_bottom + graph_h + height * 0.015,
content_w, remaining_h - graph_h - height * 0.015, scale,
)
elif show_graph:
self._draw_hourly_graph(draw, hourly, pad, current_bottom, content_w, remaining_h, scale)
elif has_forecast:
self._draw_daily_forecast(draw, days, pad, current_bottom, content_w, remaining_h, scale)
return image
def _draw_daily_forecast(self, draw, days, x, y, w, h, scale):
draw.rectangle((x, y, x + w, y + 2), fill=BLACK)
content_top = y + h * 0.1
content_h = h - h * 0.1
col_w = w / len(days)
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, content_h * 0.48)
for i, day in enumerate(days):
cx = x + col_w * i + col_w / 2
yy = content_top
label = day["label"]
lw = text_width(draw, label, label_font)
draw.text((cx - lw / 2, yy), label, font=label_font, fill="black")
yy += label_font.size * 1.4
draw_weather_icon(draw, day["kind"], cx, yy + day_icon_size / 2, day_icon_size)
yy += 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, yy), temps, font=day_temp_font, fill="black")
def _draw_hourly_graph(self, draw, hourly, x, y, w, h, scale):
"""A simple polyline of today's hourly temps -- min/max labeled at
their points, a red dot marking the current hour, and a handful of
evenly-spaced hour labels along the bottom."""
temps = [pt["temp"] for pt in hourly]
lo, hi = min(temps), max(temps)
span = max(hi - lo, 1.0) # avoid a divide-by-zero on a dead-flat day
point_font = load_font(size=int(scale * 0.032))
axis_font = load_font(size=int(scale * 0.028))
top_margin = point_font.size * 1.5
bottom_margin = axis_font.size * 1.6
plot_top = y + top_margin
plot_h = max(1.0, h - top_margin - bottom_margin)
n = len(hourly)
step_x = w / (n - 1)
def point_xy(i):
px = x + step_x * i
py = plot_top + plot_h * (1 - (hourly[i]["temp"] - lo) / span)
return px, py
points = [point_xy(i) for i in range(n)]
draw.line(points, fill=BLUE, width=max(2, int(scale * 0.006)), joint="curve")
def label_point(i):
# Always above the point, never below -- a below-point label on
# a trough sitting right at the plot's left/right edge would
# otherwise land in the same spot as the hour-axis labels.
px, py = points[i]
label = f"{round(hourly[i]['temp'])}°"
lw = text_width(draw, label, point_font)
ly = py - point_font.size * 1.25
lx = min(max(px - lw / 2, x), x + w - lw)
draw.text((lx, ly), label, font=point_font, fill="black")
max_i = max(range(n), key=lambda i: hourly[i]["temp"])
min_i = min(range(n), key=lambda i: hourly[i]["temp"])
label_point(max_i)
if min_i != max_i:
label_point(min_i)
now_hour = time.localtime().tm_hour
now_i = next((i for i, pt in enumerate(hourly) if pt["hour"] == now_hour), None)
if now_i is not None:
px, py = points[now_i]
r = max(3, int(scale * 0.011))
draw.ellipse((px - r, py - r, px + r, py + r), fill=RED)
axis_y = y + h - axis_font.size
tick_idxs = sorted(set((0, n // 4, n // 2, (3 * n) // 4, n - 1)))
for i in tick_idxs:
label = f"{hourly[i]['hour']:02d}"
lw = text_width(draw, label, axis_font)
px, _ = points[i]
lx = min(max(px - lw / 2, x), x + w - lw)
draw.text((lx, axis_y), label, font=axis_font, fill="black")
+8
View File
@@ -69,6 +69,12 @@ def create_app(config, screens: dict, scheduler) -> Flask:
if forecast_days not in (3, 5, 7):
forecast_days = 3
update_minutes = max(1, int(request.form.get("update_minutes", 30) or 30))
display_toggles = {
"show_hourly_graph": "show_hourly_graph" in request.form,
"show_feels_like": "show_feels_like" in request.form,
"show_high_low": "show_high_low" in request.form,
"show_rain_chance": "show_rain_chance" in request.form,
}
if city:
try:
@@ -84,6 +90,7 @@ def create_app(config, screens: dict, scheduler) -> Flask:
"longitude": result["longitude"],
"forecast_days": forecast_days,
"update_minutes": update_minutes,
**display_toggles,
})
return redirect(url_for("settings_weather", saved=1))
else:
@@ -93,6 +100,7 @@ def create_app(config, screens: dict, scheduler) -> Flask:
"longitude": float(lon_raw) if lon_raw else None,
"forecast_days": forecast_days,
"update_minutes": update_minutes,
**display_toggles,
})
return redirect(url_for("settings_weather", saved=1))
+18
View File
@@ -34,6 +34,24 @@
<input type="number" id="update_minutes" name="update_minutes" min="1" value="{{ settings.update_minutes or 30 }}">
<p class="hint">Weather itself only refreshes the panel when something actually changed, so a shorter interval here mostly just means fresher data, not more flashing.</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="show_hourly_graph" {{ "checked" if settings.show_hourly_graph != False }}>
Hourly temperature graph
</label>
<label class="checkbox">
<input type="checkbox" name="show_feels_like" {{ "checked" if settings.show_feels_like != False }}>
"Feels like" temperature
</label>
<label class="checkbox">
<input type="checkbox" name="show_high_low" {{ "checked" if settings.show_high_low != False }}>
Today's high/low
</label>
<label class="checkbox">
<input type="checkbox" name="show_rain_chance" {{ "checked" if settings.show_rain_chance != False }}>
Rain chance
</label>
</div>
</div>
<div class="card">