|
@@ -18,9 +18,11 @@ Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import hmac
|
|
import hmac
|
|
|
|
|
+import html
|
|
|
import secrets
|
|
import secrets
|
|
|
import json
|
|
import json
|
|
|
import os
|
|
import os
|
|
|
|
|
+import re
|
|
|
import threading
|
|
import threading
|
|
|
import time
|
|
import time
|
|
|
from collections import defaultdict
|
|
from collections import defaultdict
|
|
@@ -57,6 +59,43 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
|
|
|
DEFAULT_RANGE_DAYS = 7
|
|
DEFAULT_RANGE_DAYS = 7
|
|
|
MAX_RANGE_DAYS = 3660
|
|
MAX_RANGE_DAYS = 3660
|
|
|
|
|
|
|
|
|
|
+# --- 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; Nutikodu-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="Nutikodu", version="0.2.0")
|
|
app = FastAPI(title="Nutikodu", version="0.2.0")
|
|
|
app.add_middleware(
|
|
app.add_middleware(
|
|
|
SessionMiddleware,
|
|
SessionMiddleware,
|
|
@@ -555,6 +594,184 @@ async def latest(request: Request) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# --- 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))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
LOGIN_HTML = """<!doctype html>
|
|
LOGIN_HTML = """<!doctype html>
|
|
|
<html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
<title>Nutikodu — login</title>
|
|
<title>Nutikodu — login</title>
|