archweather / archweather/core.pymain
▤ archweather/core.py8736 bytes
"""Weather API, settings, cache and history. Stdlib networking, no API key."""
from __future__ import annotations

import csv
import json
from contextlib import closing
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen

APP = "archweather"
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP
DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / APP
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / APP
DEFAULTS = {"location": "Amsterdam", "refresh_seconds": 5, "api_seconds": 300,
            "theme": "arch", "favorites": ["Amsterdam", "Rotterdam", "Utrecht", "Berlijn"],
            "alerts": {"wind_kmh": 75, "heat_c": 35, "frost_c": 0, "rain_probability": 85}, "units": "metric"}
THEMES = ["arch", "matrix", "dark", "minimal"]
CODES = {0:"Helder",1:"Overwegend helder",2:"Halfbewolkt",3:"Bewolkt",45:"Mist",48:"Rijpmist",51:"Lichte motregen",53:"Motregen",55:"Zware motregen",56:"IJzel",57:"Zware ijzel",61:"Lichte regen",63:"Regen",65:"Zware regen",66:"IJzelregen",67:"Zware ijzelregen",71:"Lichte sneeuw",73:"Sneeuw",75:"Zware sneeuw",77:"Sneeuwkorrels",80:"Regenbuien",81:"Stevige buien",82:"Zware buien",85:"Sneeuwbuien",86:"Zware sneeuwbuien",95:"Onweer",96:"Onweer met hagel",99:"Zwaar onweer met hagel"}


def now_iso():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def read_json(path, fallback=None):
    try:
        return json.loads(path.read_text(encoding="utf8"))
    except (OSError, ValueError):
        return fallback


def atomic_write(path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_suffix(path.suffix + ".tmp")
    temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf8")
    temp.replace(path)


def settings():
    data = read_json(CONFIG_DIR / "config.json", {})
    cfg = dict(DEFAULTS)
    if isinstance(data, dict):
        cfg.update(data)
    cfg["refresh_seconds"] = max(1, min(3600, int(cfg["refresh_seconds"])))
    cfg["api_seconds"] = max(60, min(86400, int(cfg["api_seconds"])))
    if cfg["theme"] not in THEMES:
        cfg["theme"] = "arch"
    return cfg


def save_settings(cfg):
    atomic_write(CONFIG_DIR / "config.json", cfg)


def http_json(base, params=None):
    url = base + (("?" + urlencode(params)) if params else "")
    req = Request(url, headers={"User-Agent": "ArchWeatherLive/1.0 (terminal weather client)", "Accept":"application/json"})
    with urlopen(req, timeout=12) as response:
        result = json.load(response)
    if isinstance(result, dict) and result.get("error"):
        raise ValueError(str(result.get("reason", "API-fout")))
    return result


def find_places(query):
    query = query.strip()
    if not query:
        raise ValueError("Geef een plaatsnaam op.")
    if "," in query:
        try:
            lat, lon = map(float, query.split(",", 1))
            if not (-90 <= lat <= 90 and -180 <= lon <= 180):
                raise ValueError("Coördinaten buiten bereik")
            return [{"name": f"{lat:.4f}, {lon:.4f}", "latitude":lat, "longitude":lon, "country":"Coördinaten"}]
        except ValueError:
            pass
    result = http_json("https://geocoding-api.open-meteo.com/v1/search", {"name":query,"count":8,"language":"nl","format":"json"})
    return result.get("results", [])


def display_place(place):
    return ", ".join(dict.fromkeys(filter(None, [place.get("name"), place.get("admin1"), place.get("country_code") or place.get("country")])) )


def forecast(place):
    return http_json("https://api.open-meteo.com/v1/forecast", {
        "latitude":place["latitude"], "longitude":place["longitude"], "timezone":"auto",
        "forecast_days": 16,
        "current": "temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m",
        "hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,precipitation_probability,precipitation,wind_speed_10m,wind_gusts_10m,uv_index,weather_code,cloud_cover,visibility,is_day",
        "daily":"weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,daylight_duration,sunshine_duration,uv_index_max,precipitation_sum,precipitation_probability_max,wind_speed_10m_max"
    })


def cache_key(place):
    return f"{float(place['latitude']):.3f}_{float(place['longitude']):.3f}".replace("-", "m")


def cache_save(place, data):
    atomic_write(CACHE_DIR / (cache_key(place) + ".json"), {"place":place,"fetched_at":now_iso(),"data":data})


def cache_load(place):
    return read_json(CACHE_DIR / (cache_key(place) + ".json"))


def db_connect():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    db = sqlite3.connect(DATA_DIR / "history.sqlite3")
    db.execute("CREATE TABLE IF NOT EXISTS observations (id INTEGER PRIMARY KEY, fetched_at TEXT NOT NULL, location TEXT NOT NULL, latitude REAL, longitude REAL, temperature REAL, humidity REAL, wind REAL, precipitation REAL, weather_code INTEGER)")
    return db


def save_observation(place, data):
    c = data.get("current", {})
    with closing(db_connect()) as db, db:
        db.execute("INSERT INTO observations (fetched_at,location,latitude,longitude,temperature,humidity,wind,precipitation,weather_code) VALUES (?,?,?,?,?,?,?,?,?)", (now_iso(), display_place(place), place["latitude"],place["longitude"],c.get("temperature_2m"),c.get("relative_humidity_2m"),c.get("wind_speed_10m"),c.get("precipitation"),c.get("weather_code")))


def history(limit=100, location=None):
    with closing(db_connect()) as db:
        db.row_factory = sqlite3.Row
        if location:
            rows = db.execute("SELECT * FROM observations WHERE location LIKE ? ORDER BY id DESC LIMIT ?", (f"%{location}%",limit)).fetchall()
        else:
            rows = db.execute("SELECT * FROM observations ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
        return [dict(r) for r in rows]


def export_history(path, format_name="csv"):
    records = history(100000)
    path = Path(path).expanduser()
    path.parent.mkdir(parents=True, exist_ok=True)
    if format_name == "json":
        path.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf8")
    else:
        with path.open("w", newline="", encoding="utf8") as f:
            writer = csv.DictWriter(f, fieldnames=["id","fetched_at","location","latitude","longitude","temperature","humidity","wind","precipitation","weather_code"])
            writer.writeheader()
            writer.writerows(records)
    return len(records)


def warnings(data, cfg):
    c = data.get("current", {})
    h = data.get("hourly", {})
    a = cfg.get("alerts", DEFAULTS["alerts"])
    out = []
    if (c.get("wind_gusts_10m") or 0) >= a["wind_kmh"]:
        out.append("Harde windstoten")
    if (c.get("temperature_2m") or 0) >= a["heat_c"]:
        out.append("Hitte")
    if c.get("temperature_2m") is not None and c["temperature_2m"] <= a["frost_c"]:
        out.append("Vorst")
    try:
        idx = h["time"].index(c["time"][:13] + ":00")
        if max(h["precipitation_probability"][idx:idx+3]) >= a["rain_probability"]:
            out.append("Hoge regenkans komende uren")
    except (KeyError, ValueError, TypeError):
        pass
    return out


def compass(deg):
    if deg is None:
        return "?"
    return ["N","NO","O","ZO","Z","ZW","W","NW"][round(float(deg)/45)%8]


def n(value, unit="", digits=0):
    return "—" if value is None else f"{value:.{digits}f}{unit}"


def weather_icon(code, is_day=True):
    if code in (0, 1): return "☀" if is_day else "☾"
    if code in (2, 3): return "☁"
    if code in (45, 48): return "≋"
    if code in (51, 53, 55, 61, 63, 65, 80, 81, 82): return "☂"
    if code in (56, 57, 66, 67, 71, 73, 75, 77, 85, 86): return "❄"
    if code in (95, 96, 99): return "⚡"
    return "◌"

def comfort_level(current):
    temperature = current.get("temperature_2m")
    humidity = current.get("relative_humidity_2m")
    if temperature is None: return "Onbekend"
    if temperature <= 0: return "Vrieskou"
    if temperature < 10: return "Koud"
    if temperature >= 32: return "Erg heet"
    if temperature >= 26: return "Warm"
    if humidity is not None and humidity >= 85: return "Vochtig"
    return "Aangenaam" if 17 <= temperature <= 25 else "Fris"

def sunshine_hours(daily, index=0):
    values = daily.get("sunshine_duration") or []
    if index < len(values) and values[index] is not None:
        return f"{values[index]/3600:.1f} uur"
    return "—"