app.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. """Minu kodu veebiliides (FastAPI).
  2. Lihtsustatud vaade nutikodu lehest (https://nutikodu.odamus.com), mõeldud
  3. eraldiseisva lehe jaoks. Loeb ClickHouse'i `uus67` skeemist ainult
  4. välistemperatuuri (valisilm, Open-Meteo / Suuremõisa) ja serveerib
  5. transpordi vahelehe (bussid + praamid) identselt nutikodu lehele.
  6. GET / -> veebirakendus (static/index.html)
  7. GET /api/latest -> Suuremõisa välistemperatuur (hetkeväärtus)
  8. GET /api/buss -> Suuremõisa küla järgmised bussiväljumised (avalik)
  9. GET /api/praam -> Heltermaa–Rohuküla väljumised + ilmahoiatuse kontekst
  10. GET /health -> tervis
  11. ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
  12. `param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
  13. `FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
  14. """
  15. from __future__ import annotations
  16. import html
  17. import json
  18. import os
  19. import re
  20. import threading
  21. import time
  22. from datetime import datetime, timedelta
  23. from pathlib import Path
  24. from zoneinfo import ZoneInfo
  25. import requests
  26. from fastapi import FastAPI, HTTPException, Query
  27. from fastapi.responses import FileResponse
  28. from fastapi.staticfiles import StaticFiles
  29. ROOT = Path(__file__).parent
  30. STATIC_DIR = ROOT / "static"
  31. TZ = ZoneInfo("Europe/Tallinn")
  32. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  33. CH_USER = os.environ.get("CH_USER", "zigbee")
  34. CH_PASS = os.environ.get("CH_PASS", "")
  35. CH_DB = os.environ.get("CH_DB", "uus67")
  36. # --- Transport (bussid + praamid) ---
  37. BUS_GRAPHQL = os.environ.get(
  38. "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
  39. ).rstrip("/")
  40. BUS_STOPS = [s.strip() for s in os.environ.get(
  41. "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
  42. PRAAM_API = os.environ.get(
  43. "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
  44. RII_HOIATUS_URL = os.environ.get(
  45. "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
  46. )
  47. TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
  48. UA = "Mozilla/5.0 (compatible; Minukodu-web/1.0)"
  49. PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
  50. SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
  51. PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
  52. SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
  53. WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
  54. _cache: dict[str, tuple[float, object]] = {}
  55. _cache_lock = threading.Lock()
  56. def cached(key: str, ttl: int, producer):
  57. """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
  58. now = time.monotonic()
  59. with _cache_lock:
  60. hit = _cache.get(key)
  61. if hit and now - hit[0] < ttl:
  62. return hit[1]
  63. value = producer()
  64. with _cache_lock:
  65. _cache[key] = (time.monotonic(), value)
  66. return value
  67. app = FastAPI(title="Minu kodu", version="1.0.0")
  68. @app.middleware("http")
  69. async def add_no_cache(request, call_next):
  70. """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
  71. response = await call_next(request)
  72. path = request.url.path
  73. if (
  74. path == "/"
  75. or path.startswith("/api/")
  76. or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
  77. ):
  78. response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
  79. response.headers["Pragma"] = "no-cache"
  80. return response
  81. def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
  82. """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
  83. q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
  84. for key, value in (params or {}).items():
  85. q[f"param_{key}"] = value
  86. try:
  87. resp = requests.post(
  88. f"{CH_URL}/",
  89. params=q,
  90. auth=(CH_USER, CH_PASS),
  91. timeout=20,
  92. )
  93. except requests.RequestException as exc:
  94. raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
  95. if resp.status_code != 200:
  96. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
  97. text = resp.text.strip()
  98. if not text:
  99. return []
  100. try:
  101. return [json.loads(line) for line in text.splitlines() if line.strip()]
  102. except json.JSONDecodeError as exc:
  103. raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
  104. @app.get("/api/latest")
  105. async def latest() -> dict:
  106. """Suuremõisa välistemperatuur (valisilm, viimane tunniväärtus)."""
  107. rows = ch_query(
  108. f"SELECT toString(max(hour)) AS ts_str, "
  109. f"argMax(temperature, hour) AS temperature, "
  110. f"argMax(humidity, hour) AS humidity, "
  111. f"argMax(wind, hour) AS wind, "
  112. f"argMax(rain, hour) AS rain, "
  113. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  114. f"FROM {CH_DB}.valisilm FINAL"
  115. )
  116. outside: dict = {
  117. "temperature": None,
  118. "humidity": None,
  119. "wind": None,
  120. "rain": None,
  121. "hour": None,
  122. "t": None,
  123. }
  124. if rows:
  125. r = rows[0]
  126. outside = {
  127. "temperature": r.get("temperature"),
  128. "humidity": r.get("humidity"),
  129. "wind": r.get("wind"),
  130. "rain": r.get("rain"),
  131. "hour": r.get("ts_str"),
  132. "t": int(r["t"]) if r.get("t") is not None else None,
  133. }
  134. return {"outside": outside}
  135. # --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
  136. def fetch_bus_departures() -> dict:
  137. """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
  138. fields = (
  139. "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
  140. "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
  141. "trip { route { shortName longName } } }"
  142. )
  143. aliases = " ".join(
  144. f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
  145. )
  146. try:
  147. resp = requests.post(
  148. BUS_GRAPHQL,
  149. json={"query": "{ " + aliases + " }"},
  150. headers={"User-Agent": UA},
  151. timeout=18,
  152. )
  153. except requests.RequestException as exc:
  154. raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
  155. if resp.status_code != 200:
  156. raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
  157. try:
  158. payload = resp.json()
  159. except ValueError as exc:
  160. raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
  161. if payload.get("errors"):
  162. msg = payload["errors"][0].get("message", "viga")
  163. raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
  164. departures = []
  165. for stop in (payload.get("data") or {}).values():
  166. if not stop or not stop.get("stoptimesWithoutPatterns"):
  167. continue
  168. stop_name = stop.get("name") or "Suuremõisa"
  169. for st in stop["stoptimesWithoutPatterns"]:
  170. service_day = st.get("serviceDay")
  171. if service_day is None:
  172. continue
  173. sched_s = st.get("scheduledDeparture") or 0
  174. real_s = st.get("realtimeDeparture")
  175. dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
  176. try:
  177. dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
  178. except (OverflowError, OSError, TypeError, ValueError):
  179. continue
  180. route = (st.get("trip") or {}).get("route") or {}
  181. delay = None
  182. if st.get("realtime") and real_s is not None:
  183. delay = round((int(real_s) - int(sched_s)) / 60)
  184. departures.append({
  185. "t": int(dep_dt.timestamp() * 1000),
  186. "time": dep_dt.strftime("%H:%M"),
  187. "date": dep_dt.strftime("%Y-%m-%d"),
  188. "stop": stop_name,
  189. "route": route.get("shortName") or "",
  190. "headsign": st.get("headsign") or "",
  191. "realtime": bool(st.get("realtime")),
  192. "delay_min": delay,
  193. })
  194. departures.sort(key=lambda d: d["t"])
  195. now = datetime.now(TZ)
  196. return {
  197. "stops": BUS_STOPS,
  198. "today": now.strftime("%Y-%m-%d"),
  199. "departures": departures,
  200. "updated": now.strftime("%Y-%m-%d %H:%M"),
  201. }
  202. @app.get("/api/buss")
  203. async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
  204. """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
  205. data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
  206. return {**data, "departures": data["departures"][:limit]}
  207. # --- Transport: praamid (praamid.ee) ---
  208. def _praam_events(day: str, direction: str) -> list[dict]:
  209. try:
  210. resp = requests.get(
  211. PRAAM_API,
  212. params={"direction": direction, "departure-date": day, "time-shift": "0"},
  213. headers={"User-Agent": UA},
  214. timeout=18,
  215. )
  216. except requests.RequestException:
  217. return []
  218. if resp.status_code != 200:
  219. return []
  220. try:
  221. items = (resp.json() or {}).get("items") or []
  222. except ValueError:
  223. return []
  224. out = []
  225. for e in items:
  226. try:
  227. dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
  228. except ValueError:
  229. continue
  230. ship = e.get("ship")
  231. ship_code = ship.get("code") if isinstance(ship, dict) else ship
  232. status = e.get("status") or "?"
  233. out.append({
  234. "uid": e.get("uid"),
  235. "direction": direction,
  236. "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
  237. "date": dep_dt.strftime("%Y-%m-%d"),
  238. "time": dep_dt.strftime("%H:%M"),
  239. "t": int(dep_dt.timestamp() * 1000),
  240. "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
  241. "ship_code": ship_code,
  242. "status": status,
  243. "status_et": PRAAM_STATUS.get(status, status),
  244. "disrupted": status != "CONFIRMED",
  245. })
  246. return out
  247. def fetch_weather_warnings() -> list[dict]:
  248. """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
  249. try:
  250. resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
  251. except requests.RequestException:
  252. return []
  253. if resp.status_code != 200:
  254. return []
  255. text = html.unescape(resp.text)
  256. text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
  257. text = re.sub(r"<[^>]+>", " ", text)
  258. text = re.sub(r"\s+", " ", text)
  259. found, seen = [], set()
  260. for area in SEA_AREAS:
  261. i = 0
  262. while True:
  263. i = text.find(area, i)
  264. if i < 0:
  265. break
  266. seg = text[max(0, i - 100): i + 700]
  267. if any(w in seg.lower() for w in WIND_TYPES):
  268. m = re.search(r"Kehtib[^.]{0,200}", seg)
  269. valid = m.group(0).strip() if m else ""
  270. key = (area, valid or seg[:80])
  271. if key not in seen:
  272. seen.add(key)
  273. found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
  274. i += len(area)
  275. return found
  276. def fetch_praam(days: int) -> dict:
  277. now = datetime.now(TZ)
  278. day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
  279. departures = []
  280. for day in day_list:
  281. for direction in ("HR", "RH"):
  282. departures.extend(_praam_events(day, direction))
  283. departures.sort(key=lambda d: (d["date"], d["time"]))
  284. return {
  285. "date": day_list[0],
  286. "today": now.strftime("%Y-%m-%d"),
  287. "departures": departures,
  288. "warnings": fetch_weather_warnings(),
  289. "disrupted": sum(1 for d in departures if d["disrupted"]),
  290. "updated": now.strftime("%Y-%m-%d %H:%M"),
  291. }
  292. @app.get("/api/praam")
  293. async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
  294. """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
  295. return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
  296. @app.get("/health")
  297. async def health() -> dict:
  298. try:
  299. ch_query("SELECT 1")
  300. ch_ok = True
  301. except HTTPException:
  302. ch_ok = False
  303. return {"status": "ok", "clickhouse": ch_ok}
  304. @app.get("/")
  305. async def index() -> FileResponse:
  306. return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
  307. app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
  308. if __name__ == "__main__":
  309. import uvicorn
  310. uvicorn.run("app:app", host="0.0.0.0", port=8096, reload=True)