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:
1 parent
132e133b1d
commit
27f4397b04
3 files changed
+218
-58
No files matched your search
+192
-58
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user