from typing import Protocol class WeatherProvider(Protocol): def get_current(self) -> dict: ... def get_forecast(self) -> list[dict]: ... class MockWeatherProvider: def get_current(self): return { "location": "上海", "temperature": "--", "summary": "天气演示数据 · 尚未连接实时天气源", "mock": True, } def get_forecast(self): return [] class ConfigurableWeatherProvider: """On-demand public weather with a ten-minute cache and bounded error backoff.""" def __init__(self, db, transport=None): import threading import requests self.db = db self.transport = transport or requests.get self.lock = threading.Lock() self.cache = None self.next_fetch = 0.0 self.cached_config = None def config(self): import json from pigway_frame_flow.models import WeatherConfig row = self.db.one("SELECT payload FROM app_config WHERE key='weather'") return WeatherConfig.model_validate(json.loads(row["payload"]) if row else {}) def configure(self, config): from pigway_frame_flow.storage.database import encode with self.lock: self.db.execute( "INSERT OR REPLACE INTO app_config VALUES('weather',?)", (encode(config.model_dump()),), ) self.cache = None self.next_fetch = 0 self.cached_config = None return config @staticmethod def summary(code): if code == 0: return "晴 / Clear" if code in (1, 2, 3): return "多云 / Cloudy" if code in (45, 48): return "雾 / Fog" if code in (71, 73, 75, 77, 85, 86): return "雪 / Snow" if code in (95, 96, 99): return "雷雨 / Thunderstorm" if code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82): return "雨 / Rain" return "未知 / Unknown" def fetch(self): import copy import time config = self.config() if config.provider == "mock": current = MockWeatherProvider().get_current() current["location"] = config.location return {"current": current, "forecast": []} with self.lock: if self.cached_config != config: self.cache = None self.next_fetch = 0 self.cached_config = config if time.monotonic() < self.next_fetch: return copy.deepcopy(self.cache) try: response = self.transport( "https://api.open-meteo.com/v1/forecast", params={ "latitude": config.latitude, "longitude": config.longitude, "current": "temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m", "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max", "timezone": "Asia/Shanghai", "forecast_days": 3, }, timeout=8, allow_redirects=False, ) response.raise_for_status() raw = response.json() cur, daily = raw["current"], raw["daily"] current = { "location": config.location, "temperature": f"{cur['temperature_2m']}°C", "summary": self.summary(cur["weather_code"]), "humidity": cur.get("relative_humidity_2m"), "wind_kmh": cur.get("wind_speed_10m"), "observed_at": cur["time"] + "+08:00", "mock": False, "stale": False, "available": True, "attribution": "Open-Meteo / CC BY 4.0", } forecast = [ { "date": day, "summary": self.summary(daily["weather_code"][i]), "minimum_c": daily["temperature_2m_min"][i], "maximum_c": daily["temperature_2m_max"][i], "rain_probability": daily["precipitation_probability_max"][i], } for i, day in enumerate(daily["time"]) ] self.cache = {"current": current, "forecast": forecast} self.next_fetch = time.monotonic() + 600 except Exception: if self.cache: self.cache["current"]["stale"] = True else: self.cache = { "current": { "location": config.location, "temperature": "--", "summary": "天气暂不可用 / Weather unavailable", "mock": False, "stale": True, "available": False, "attribution": "Open-Meteo / CC BY 4.0", }, "forecast": [], } self.next_fetch = time.monotonic() + 120 return copy.deepcopy(self.cache) def get_current(self): return self.fetch()["current"] def get_forecast(self): return self.fetch()["forecast"]