Files
2025-12-13 10:19:52 +00:00

235 lines
8.4 KiB
Python

import asyncio
from typing import Any, Dict, List
import httpx
from PIL import Image, ImageDraw
from .base import PluginBase, PluginOutput
import logging
from .. import config
logger = logging.getLogger(__name__)
class WeatherPlugin(PluginBase):
"""
Plugin to fetch weather data and generate a Braun-inspired minimalist visualization.
Uses only black and white with clean lines and excellent typography.
"""
BASENAME = "weather"
OUTPUT_SUBDIR = "weather"
SET_PRIMARY = True
REGISTRY_ORDER = 10
DISPLAY_NAME = "Weather"
def __init__(self):
super().__init__()
# Glyphs temporarily disabled — keep keys for compatibility
self.icons = {
'sun': '',
'cloud': '',
'rain': '',
'wind': '',
'thermometer': '',
'droplet': '',
}
async def run(self, **kwargs):
"""
Fetch weather and generate Braun-inspired black and white image.
kwargs can contain 'latitude' and 'longitude'.
"""
lat = kwargs.get('latitude', 38.7223) # Default: Lisbon, Portugal
lon = kwargs.get('longitude', -9.1393)
output_dir = kwargs.get('output_dir', config.ASSETS_ROOT)
logger.info(f"Running WeatherPlugin for lat={lat}, lon={lon}")
try:
data = await self._fetch_weather_data(lat, lon)
current_weather = data.get('current_weather', {})
current_temp = current_weather.get('temperature', 'N/A')
current_windspeed = current_weather.get('windspeed', 'N/A')
hourly = data.get('hourly', {})
times = hourly.get('time', [])[:24] # Next 24 hours
temps = hourly.get('temperature_2m', [])[:24]
precip = hourly.get('precipitation', [])[:24]
if not times or not temps:
logger.error("No weather data found")
return
# Calculate temperature range
min_temp = min(temps)
max_temp = max(temps)
temp_range = max_temp - min_temp if max_temp != min_temp else 1
# Calculate precipitation range
max_precip = max(precip) if precip else 0
image = await asyncio.to_thread(
self._render_weather_panel,
current_temp,
current_windspeed,
times,
temps,
precip,
min_temp,
max_temp,
max_precip
)
output = await asyncio.to_thread(self.save_assets, image, output_dir, 'weather')
logger.info(
"Weather plugin execution complete. Assets saved to %s and %s",
output.monochrome_path,
output.grayscale_path
)
return output
except Exception as e:
logger.error(f"Error running WeatherPlugin: {e}")
raise
async def _fetch_weather_data(self, lat: float, lon: float) -> Dict[str, Any]:
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={lat}&longitude={lon}&hourly=temperature_2m,precipitation&current_weather=true"
)
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
def _render_weather_panel(
self,
current_temp: Any,
current_windspeed: Any,
times: List[str],
temps: List[float],
precip: List[float],
min_temp: float,
max_temp: float,
max_precip: float
) -> Image.Image:
img = Image.new('RGB', (800, 480), color='white')
draw = ImageDraw.Draw(img)
title_font = self.load_font(32)
temp_font = self.load_font(96)
label_font = self.load_font(18)
small_font = self.load_font(14)
tick_font = self.load_font(11)
# Use the project's default font for icons (Space Grotesk)
icon_font = self.load_font(24)
margin = 30
y_pos = margin
# Title (glyphs removed) — align at left margin
draw.text((margin, y_pos), "WEATHER", fill='black', font=title_font)
y_pos += 50
draw.line([(margin, y_pos), (800 - margin, y_pos)], fill='black', width=1)
y_pos += 25
draw.text((margin, y_pos), f"{current_temp}°", fill='black', font=temp_font)
wind_icon_x = margin + 180
# Wind glyph removed; draw only the text aligned where the icon used to be
draw.text((wind_icon_x, y_pos + 73), f"{current_windspeed} km/h",
fill='black', font=small_font)
y_pos += 120
draw.line([(margin, y_pos), (800 - margin, y_pos)], fill='black', width=1)
y_pos += 25
chart_y = y_pos
chart_height = 200
chart_width = 350
chart1_x = margin
draw.text((chart1_x, chart_y - 20), "TEMPERATURE", fill='black', font=label_font)
self._draw_braun_bar_chart(
draw, chart1_x, chart_y, chart_width, chart_height,
temps[::2], times[::2], min_temp, max_temp,
tick_font
)
chart2_x = margin + chart_width + 40
# Droplet glyph removed; align label at chart start
draw.text((chart2_x, chart_y - 20), "PRECIPITATION", fill='black', font=label_font)
self._draw_braun_bar_chart(
draw, chart2_x, chart_y, chart_width, chart_height,
precip[::2], times[::2], 0, max_precip if max_precip > 0 else 1,
tick_font
)
return img
def _draw_braun_bar_chart(self, draw, x, y, width, height, values, labels, min_val, max_val, font):
"""
Draw a Braun-inspired minimalist bar chart.
- Thin, clean lines
- No fills, only outlines
- Minimal labels
- Grid lines for readability
"""
# Draw thin axes
draw.line([(x, y + height), (x + width, y + height)], fill='black', width=1)
draw.line([(x, y), (x, y + height)], fill='black', width=1)
# Draw subtle horizontal grid lines (Braun style)
for i in range(1, 4):
grid_y = y + (i * height / 4)
draw.line([(x, grid_y), (x + width, grid_y)], fill='#CCCCCC', width=1)
# Calculate bar width
num_bars = len(values)
if num_bars == 0:
return
bar_spacing = 3
bar_width = (width - (num_bars + 1) * bar_spacing) / num_bars
# Draw bars - Braun style: outlined rectangles, no fill
val_range = max_val - min_val if max_val != min_val else 1
for i, val in enumerate(values):
bar_x = x + bar_spacing + i * (bar_width + bar_spacing)
bar_height = ((val - min_val) / val_range) * height
bar_y = y + height - bar_height
# Draw outlined bar (no fill for Braun aesthetic)
if bar_height > 2: # Only draw if visible
draw.rectangle(
[(bar_x, bar_y), (bar_x + bar_width, y + height)],
fill='black', # Solid black bars for contrast
outline='black',
width=1
)
# Draw minimal x-axis labels (every 4th)
for i in range(0, len(labels), 4):
if i < len(labels):
label_x = x + bar_spacing + i * (bar_width + bar_spacing)
time_str = labels[i].split('T')[1][:2] # Just hour
bbox = draw.textbbox((0, 0), time_str, font=font)
text_width = bbox[2] - bbox[0]
draw.text((label_x - text_width/2 + bar_width/2, y + height + 5),
time_str, fill='black', font=font)
# Draw minimal y-axis scale (3 ticks) - closer to axis
for i in range(3):
tick_val = min_val + (val_range * i / 2)
tick_y = y + height - (i / 2) * height
# Small tick mark
draw.line([(x - 3, tick_y), (x, tick_y)], fill='black', width=1)
# Value label - positioned closer to axis
label_text = f"{tick_val:.0f}"
bbox = draw.textbbox((0, 0), label_text, font=font)
text_width = bbox[2] - bbox[0]
draw.text((x - text_width - 8, tick_y - 6), label_text, fill='black', font=font)