"""Minu kodu veebiliides (FastAPI). Lihtsustatud vaade nutikodu lehest (https://nutikodu.odamus.com), mõeldud eraldiseisva lehe jaoks. Loeb ClickHouse'i `uus67` skeemist ainult välistemperatuuri (valisilm, Open-Meteo / Suuremõisa) ja serveerib transpordi vahelehe (bussid + praamid) identselt nutikodu lehele. GET / -> veebirakendus (static/index.html) GET /api/latest -> Suuremõisa välistemperatuur (hetkeväärtus) GET /api/buss -> Suuremõisa küla järgmised bussiväljumised (avalik) GET /api/praam -> Heltermaa–Rohuküla väljumised + ilmahoiatuse kontekst GET /health -> tervis ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja `param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse `FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta). """ from __future__ import annotations import html import json import os import re import threading import time from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo import requests from fastapi import FastAPI, HTTPException, Query from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles ROOT = Path(__file__).parent STATIC_DIR = ROOT / "static" TZ = ZoneInfo("Europe/Tallinn") CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/") CH_USER = os.environ.get("CH_USER", "zigbee") CH_PASS = os.environ.get("CH_PASS", "") CH_DB = os.environ.get("CH_DB", "uus67") # --- Transport (bussid + praamid) --- BUS_GRAPHQL = os.environ.get( "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql" ).rstrip("/") BUS_STOPS = [s.strip() for s in os.environ.get( "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()] PRAAM_API = os.environ.get( "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/") RII_HOIATUS_URL = os.environ.get( "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/" ) TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60")) UA = "Mozilla/5.0 (compatible; Minukodu-web/1.0)" PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"} SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"} PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"} SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"] WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"] _cache: dict[str, tuple[float, object]] = {} _cache_lock = threading.Lock() def cached(key: str, ttl: int, producer): """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks.""" now = time.monotonic() with _cache_lock: hit = _cache.get(key) if hit and now - hit[0] < ttl: return hit[1] value = producer() with _cache_lock: _cache[key] = (time.monotonic(), value) return value app = FastAPI(title="Minu kodu", version="1.0.0") @app.middleware("http") async def add_no_cache(request, call_next): """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML.""" response = await call_next(request) path = request.url.path if ( path == "/" or path.startswith("/api/") or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg")) ): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" return response def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]: """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena.""" q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"} for key, value in (params or {}).items(): q[f"param_{key}"] = value try: resp = requests.post( f"{CH_URL}/", params=q, auth=(CH_USER, CH_PASS), timeout=20, ) except requests.RequestException as exc: raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc if resp.status_code != 200: raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}") text = resp.text.strip() if not text: return [] try: return [json.loads(line) for line in text.splitlines() if line.strip()] except json.JSONDecodeError as exc: raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc @app.get("/api/latest") async def latest() -> dict: """Suuremõisa välistemperatuur (valisilm, viimane tunniväärtus).""" rows = ch_query( f"SELECT toString(max(hour)) AS ts_str, " f"argMax(temperature, hour) AS temperature, " f"argMax(humidity, hour) AS humidity, " f"argMax(wind, hour) AS wind, " f"argMax(rain, hour) AS rain, " f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t " f"FROM {CH_DB}.valisilm FINAL" ) outside: dict = { "temperature": None, "humidity": None, "wind": None, "rain": None, "hour": None, "t": None, } if rows: r = rows[0] outside = { "temperature": r.get("temperature"), "humidity": r.get("humidity"), "wind": r.get("wind"), "rain": r.get("rain"), "hour": r.get("ts_str"), "t": int(r["t"]) if r.get("t") is not None else None, } return {"outside": outside} # --- Transport: bussid (peatus.ee / Digitransit GraphQL) --- def fetch_bus_departures() -> dict: """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest.""" fields = ( "name stoptimesWithoutPatterns(numberOfDepartures: 40) { " "serviceDay scheduledDeparture realtimeDeparture realtime headsign " "trip { route { shortName longName } } }" ) aliases = " ".join( f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS) ) try: resp = requests.post( BUS_GRAPHQL, json={"query": "{ " + aliases + " }"}, headers={"User-Agent": UA}, timeout=18, ) except requests.RequestException as exc: raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc if resp.status_code != 200: raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}") try: payload = resp.json() except ValueError as exc: raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc if payload.get("errors"): msg = payload["errors"][0].get("message", "viga") raise HTTPException(status_code=502, detail=f"Bussi API: {msg}") departures = [] for stop in (payload.get("data") or {}).values(): if not stop or not stop.get("stoptimesWithoutPatterns"): continue stop_name = stop.get("name") or "Suuremõisa" for st in stop["stoptimesWithoutPatterns"]: service_day = st.get("serviceDay") if service_day is None: continue sched_s = st.get("scheduledDeparture") or 0 real_s = st.get("realtimeDeparture") dep_s = real_s if st.get("realtime") and real_s is not None else sched_s try: dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ) except (OverflowError, OSError, TypeError, ValueError): continue route = (st.get("trip") or {}).get("route") or {} delay = None if st.get("realtime") and real_s is not None: delay = round((int(real_s) - int(sched_s)) / 60) departures.append({ "t": int(dep_dt.timestamp() * 1000), "time": dep_dt.strftime("%H:%M"), "date": dep_dt.strftime("%Y-%m-%d"), "stop": stop_name, "route": route.get("shortName") or "", "headsign": st.get("headsign") or "", "realtime": bool(st.get("realtime")), "delay_min": delay, }) departures.sort(key=lambda d: d["t"]) now = datetime.now(TZ) return { "stops": BUS_STOPS, "today": now.strftime("%Y-%m-%d"), "departures": departures, "updated": now.strftime("%Y-%m-%d %H:%M"), } @app.get("/api/buss") async def buss(limit: int = Query(12, ge=1, le=40)) -> dict: """Järgmised bussiväljumised Suuremõisa külast (avalik).""" data = cached("buss", TRANSPORT_TTL, fetch_bus_departures) return {**data, "departures": data["departures"][:limit]} # --- Transport: praamid (praamid.ee) --- def _praam_events(day: str, direction: str) -> list[dict]: try: resp = requests.get( PRAAM_API, params={"direction": direction, "departure-date": day, "time-shift": "0"}, headers={"User-Agent": UA}, timeout=18, ) except requests.RequestException: return [] if resp.status_code != 200: return [] try: items = (resp.json() or {}).get("items") or [] except ValueError: return [] out = [] for e in items: try: dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ) except ValueError: continue ship = e.get("ship") ship_code = ship.get("code") if isinstance(ship, dict) else ship status = e.get("status") or "?" out.append({ "uid": e.get("uid"), "direction": direction, "direction_name": PRAAM_DIRECTIONS.get(direction, direction), "date": dep_dt.strftime("%Y-%m-%d"), "time": dep_dt.strftime("%H:%M"), "t": int(dep_dt.timestamp() * 1000), "ship": SHIP_NAMES.get(ship_code, ship_code or "?"), "ship_code": ship_code, "status": status, "status_et": PRAAM_STATUS.get(status, status), "disrupted": status != "CONFIRMED", }) return out def fetch_weather_warnings() -> list[dict]: """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus).""" try: resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18) except requests.RequestException: return [] if resp.status_code != 200: return [] text = html.unescape(resp.text) text = re.sub(r"", " ", text, flags=re.S) text = re.sub(r"<[^>]+>", " ", text) text = re.sub(r"\s+", " ", text) found, seen = [], set() for area in SEA_AREAS: i = 0 while True: i = text.find(area, i) if i < 0: break seg = text[max(0, i - 100): i + 700] if any(w in seg.lower() for w in WIND_TYPES): m = re.search(r"Kehtib[^.]{0,200}", seg) valid = m.group(0).strip() if m else "" key = (area, valid or seg[:80]) if key not in seen: seen.add(key) found.append({"area": area, "valid": valid, "text": seg.strip()[:300]}) i += len(area) return found def fetch_praam(days: int) -> dict: now = datetime.now(TZ) day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)] departures = [] for day in day_list: for direction in ("HR", "RH"): departures.extend(_praam_events(day, direction)) departures.sort(key=lambda d: (d["date"], d["time"])) return { "date": day_list[0], "today": now.strftime("%Y-%m-%d"), "departures": departures, "warnings": fetch_weather_warnings(), "disrupted": sum(1 for d in departures if d["disrupted"]), "updated": now.strftime("%Y-%m-%d %H:%M"), } @app.get("/api/praam") async def praam(days: int = Query(1, ge=1, le=3)) -> dict: """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik).""" return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days)) @app.get("/health") async def health() -> dict: try: ch_query("SELECT 1") ch_ok = True except HTTPException: ch_ok = False return {"status": "ok", "clickhouse": ch_ok} @app.get("/") async def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html", media_type="text/html") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") if __name__ == "__main__": import uvicorn uvicorn.run("app:app", host="0.0.0.0", port=8096, reload=True)