| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358 |
- """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"<script.*?</script>", " ", 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)
|