app.py.bak.1791389719 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. """Nutikodu andmete veebiliides (FastAPI).
  2. Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
  3. GET / -> veebirakendus (static/index.html)
  4. GET /api/meta -> seadmed, tz, andmete ulatus
  5. GET /api/temperatuur -> aegrida (temp + niiskus), agg=hour|day
  6. GET /api/ukseandur -> ukseolekute sündmused (tulevikuks)
  7. GET /api/vead -> häirete sündmused (tulevikuks)
  8. GET /api/latest -> viimased väärtused + lahtised häired
  9. GET /health
  10. ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
  11. `param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
  12. `FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
  13. Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
  14. """
  15. from __future__ import annotations
  16. import json
  17. import os
  18. from datetime import datetime, timedelta
  19. from pathlib import Path
  20. from zoneinfo import ZoneInfo
  21. import requests
  22. from fastapi import Depends, FastAPI, HTTPException, Query, Request
  23. from fastapi.responses import FileResponse
  24. from fastapi.staticfiles import StaticFiles
  25. ROOT = Path(__file__).parent
  26. STATIC_DIR = ROOT / "static"
  27. TZ = ZoneInfo("Europe/Tallinn")
  28. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  29. CH_USER = os.environ.get("CH_USER", "zigbee")
  30. CH_PASS = os.environ.get("CH_PASS", "")
  31. CH_DB = os.environ.get("CH_DB", "uus67")
  32. WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
  33. DEFAULT_RANGE_DAYS = 7
  34. MAX_RANGE_DAYS = 3660
  35. app = FastAPI(title="Nutikodu", version="0.2.0")
  36. @app.middleware("http")
  37. async def add_no_cache(request: Request, call_next):
  38. """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
  39. response = await call_next(request)
  40. path = request.url.path
  41. if (
  42. path == "/"
  43. or path.startswith("/api/")
  44. or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
  45. ):
  46. response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
  47. response.headers["Pragma"] = "no-cache"
  48. return response
  49. def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
  50. """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
  51. q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
  52. for key, value in (params or {}).items():
  53. q[f"param_{key}"] = value
  54. try:
  55. resp = requests.post(
  56. f"{CH_URL}/",
  57. params=q,
  58. auth=(CH_USER, CH_PASS),
  59. timeout=20,
  60. )
  61. except requests.RequestException as exc:
  62. raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
  63. if resp.status_code != 200:
  64. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
  65. text = resp.text.strip()
  66. if not text:
  67. return []
  68. try:
  69. return [json.loads(line) for line in text.splitlines() if line.strip()]
  70. except json.JSONDecodeError as exc:
  71. raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
  72. def check_token(request: Request) -> None:
  73. if not WEB_ACCESS_TOKEN:
  74. return
  75. token = request.headers.get("x-access-token") or request.query_params.get("token")
  76. if token != WEB_ACCESS_TOKEN:
  77. raise HTTPException(status_code=403, detail="Vale või puuduv access token")
  78. def parse_dt(value: str | None, default: datetime) -> str:
  79. """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
  80. if not value:
  81. return default.strftime("%Y-%m-%d %H:%M:%S")
  82. cleaned = value.strip().replace("T", " ")
  83. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
  84. try:
  85. return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
  86. except ValueError:
  87. continue
  88. raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
  89. def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
  90. now = datetime.now(TZ)
  91. to_s = parse_dt(to, now)
  92. to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
  93. from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
  94. from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
  95. if from_dt > to_dt:
  96. raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
  97. if (to_dt - from_dt).days > MAX_RANGE_DAYS:
  98. raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
  99. return from_s, to_s
  100. @app.get("/api/meta", dependencies=[Depends(check_token)])
  101. async def meta() -> dict:
  102. devices = [row["device"] for row in ch_query(
  103. f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
  104. )]
  105. bounds = ch_query(
  106. f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
  107. f"FROM {CH_DB}.temperatuur"
  108. )
  109. first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
  110. return {
  111. "tz": "Europe/Tallinn",
  112. "db": CH_DB,
  113. "devices": devices,
  114. "min": first["min_hour"],
  115. "max": first["max_hour"],
  116. "rows": first["rows"],
  117. "default_range_days": DEFAULT_RANGE_DAYS,
  118. }
  119. @app.get("/api/temperatuur", dependencies=[Depends(check_token)])
  120. async def temperatuur(
  121. from_: str | None = Query(None, alias="from"),
  122. to: str | None = Query(None),
  123. device: str | None = Query(None),
  124. agg: str = Query("hour", pattern="^(hour|day)$"),
  125. ) -> dict:
  126. from_s, to_s = resolve_range(from_, to)
  127. params = {"from": from_s, "to": to_s}
  128. where = (
  129. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  130. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  131. )
  132. if device:
  133. where += " AND device = {device:String}"
  134. params["device"] = device
  135. if agg == "day":
  136. select = (
  137. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
  138. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  139. "max(taidetud) AS taidetud"
  140. )
  141. group = "GROUP BY t, device"
  142. else:
  143. select = (
  144. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
  145. "temperature, humidity, taidetud"
  146. )
  147. group = ""
  148. rows = ch_query(
  149. f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
  150. params,
  151. )
  152. series: dict[str, dict] = {}
  153. for row in rows:
  154. s = series.setdefault(
  155. row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
  156. )
  157. s["t"].append(int(row["t"]))
  158. s["temperature"].append(row["temperature"])
  159. s["humidity"].append(row["humidity"])
  160. s["taidetud"].append(bool(row.get("taidetud")))
  161. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  162. @app.get("/api/valisilm", dependencies=[Depends(check_token)])
  163. async def valisilm(
  164. from_: str | None = Query(None, alias="from"),
  165. to: str | None = Query(None),
  166. agg: str = Query("hour", pattern="^(hour|day)$"),
  167. ) -> dict:
  168. from_s, to_s = resolve_range(from_, to)
  169. params = {"from": from_s, "to": to_s}
  170. where = (
  171. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  172. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  173. )
  174. if agg == "day":
  175. select = (
  176. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
  177. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  178. "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
  179. )
  180. group = "GROUP BY t"
  181. else:
  182. select = (
  183. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
  184. "temperature, humidity, wind, rain, taidetud"
  185. )
  186. group = ""
  187. rows = ch_query(
  188. f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
  189. params,
  190. )
  191. series: dict[str, list] = {
  192. "t": [],
  193. "temperature": [],
  194. "humidity": [],
  195. "wind": [],
  196. "rain": [],
  197. "taidetud": [],
  198. }
  199. for row in rows:
  200. series["t"].append(int(row["t"]))
  201. series["temperature"].append(row["temperature"])
  202. series["humidity"].append(row["humidity"])
  203. series["wind"].append(row["wind"])
  204. series["rain"].append(row["rain"])
  205. series["taidetud"].append(bool(row.get("taidetud")))
  206. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  207. @app.get("/api/ukseandur", dependencies=[Depends(check_token)])
  208. async def ukseandur(
  209. from_: str | None = Query(None, alias="from"),
  210. to: str | None = Query(None),
  211. device: str | None = Query(None),
  212. ) -> dict:
  213. from_s, to_s = resolve_range(from_, to)
  214. params = {"from": from_s, "to": to_s}
  215. where = (
  216. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  217. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  218. )
  219. if device:
  220. where += " AND device = {device:String}"
  221. params["device"] = device
  222. rows = ch_query(
  223. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
  224. f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
  225. params,
  226. )
  227. return {
  228. "from": from_s,
  229. "to": to_s,
  230. "events": [
  231. {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
  232. for r in rows
  233. ],
  234. }
  235. @app.get("/api/vead", dependencies=[Depends(check_token)])
  236. async def vead(
  237. from_: str | None = Query(None, alias="from"),
  238. to: str | None = Query(None),
  239. device: str | None = Query(None),
  240. ) -> dict:
  241. from_s, to_s = resolve_range(from_, to)
  242. params = {"from": from_s, "to": to_s}
  243. where = (
  244. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  245. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  246. )
  247. if device:
  248. where += " AND device = {device:String}"
  249. params["device"] = device
  250. rows = ch_query(
  251. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
  252. f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
  253. params,
  254. )
  255. return {
  256. "from": from_s,
  257. "to": to_s,
  258. "events": [
  259. {
  260. "t": int(r["t"]),
  261. "device": r["device"],
  262. "tyyp": r["tyyp"],
  263. "olek": r["olek"],
  264. "detail": r["detail"],
  265. "vaartus": r["vaartus"],
  266. }
  267. for r in rows
  268. ],
  269. }
  270. @app.get("/api/latest", dependencies=[Depends(check_token)])
  271. async def latest() -> dict:
  272. readings = ch_query(
  273. f"SELECT device, "
  274. f"argMax(temperature, hour) AS temperature, "
  275. f"argMax(humidity, hour) AS humidity, "
  276. f"toString(max(hour)) AS ts_str, "
  277. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  278. f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
  279. )
  280. faults = ch_query(
  281. f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
  282. f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
  283. f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
  284. )
  285. outside_rows = ch_query(
  286. f"SELECT toString(max(hour)) AS ts_str, "
  287. f"argMax(temperature, hour) AS temperature, "
  288. f"argMax(humidity, hour) AS humidity, "
  289. f"argMax(wind, hour) AS wind, "
  290. f"argMax(rain, hour) AS rain, "
  291. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  292. f"FROM {CH_DB}.valisilm FINAL"
  293. )
  294. outside: dict = {
  295. "temperature": None,
  296. "humidity": None,
  297. "wind": None,
  298. "rain": None,
  299. "hour": None,
  300. "t": None,
  301. }
  302. if outside_rows:
  303. r = outside_rows[0]
  304. outside = {
  305. "temperature": r.get("temperature"),
  306. "humidity": r.get("humidity"),
  307. "wind": r.get("wind"),
  308. "rain": r.get("rain"),
  309. "hour": r.get("ts_str"),
  310. "t": int(r["t"]) if r.get("t") is not None else None,
  311. }
  312. return {
  313. "readings": [
  314. {
  315. "device": r["device"],
  316. "temperature": r["temperature"],
  317. "humidity": r["humidity"],
  318. "hour": r["ts_str"],
  319. "t": int(r["t"]) if r.get("t") is not None else None,
  320. }
  321. for r in readings
  322. ],
  323. "open_faults": [
  324. {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
  325. for r in faults
  326. ],
  327. "outside": outside,
  328. }
  329. @app.get("/health")
  330. async def health() -> dict:
  331. try:
  332. ch_query("SELECT 1")
  333. ch_ok = True
  334. except HTTPException:
  335. ch_ok = False
  336. return {"status": "ok", "clickhouse": ch_ok}
  337. @app.get("/")
  338. async def index() -> FileResponse:
  339. return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
  340. app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
  341. if __name__ == "__main__":
  342. import uvicorn
  343. uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)