app.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
  37. """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
  38. q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
  39. for key, value in (params or {}).items():
  40. q[f"param_{key}"] = value
  41. try:
  42. resp = requests.post(
  43. f"{CH_URL}/",
  44. params=q,
  45. auth=(CH_USER, CH_PASS),
  46. timeout=20,
  47. )
  48. except requests.RequestException as exc:
  49. raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
  50. if resp.status_code != 200:
  51. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
  52. text = resp.text.strip()
  53. if not text:
  54. return []
  55. try:
  56. return [json.loads(line) for line in text.splitlines() if line.strip()]
  57. except json.JSONDecodeError as exc:
  58. raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
  59. def check_token(request: Request) -> None:
  60. if not WEB_ACCESS_TOKEN:
  61. return
  62. token = request.headers.get("x-access-token") or request.query_params.get("token")
  63. if token != WEB_ACCESS_TOKEN:
  64. raise HTTPException(status_code=403, detail="Vale või puuduv access token")
  65. def parse_dt(value: str | None, default: datetime) -> str:
  66. """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
  67. if not value:
  68. return default.strftime("%Y-%m-%d %H:%M:%S")
  69. cleaned = value.strip().replace("T", " ")
  70. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
  71. try:
  72. return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
  73. except ValueError:
  74. continue
  75. raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
  76. def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
  77. now = datetime.now(TZ)
  78. to_s = parse_dt(to, now)
  79. to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
  80. from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
  81. from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
  82. if from_dt > to_dt:
  83. raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
  84. if (to_dt - from_dt).days > MAX_RANGE_DAYS:
  85. raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
  86. return from_s, to_s
  87. @app.get("/api/meta", dependencies=[Depends(check_token)])
  88. async def meta() -> dict:
  89. devices = [row["device"] for row in ch_query(
  90. f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
  91. )]
  92. bounds = ch_query(
  93. f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
  94. f"FROM {CH_DB}.temperatuur"
  95. )
  96. first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
  97. return {
  98. "tz": "Europe/Tallinn",
  99. "db": CH_DB,
  100. "devices": devices,
  101. "min": first["min_hour"],
  102. "max": first["max_hour"],
  103. "rows": first["rows"],
  104. "default_range_days": DEFAULT_RANGE_DAYS,
  105. }
  106. @app.get("/api/temperatuur", dependencies=[Depends(check_token)])
  107. async def temperatuur(
  108. from_: str | None = Query(None, alias="from"),
  109. to: str | None = Query(None),
  110. device: str | None = Query(None),
  111. agg: str = Query("hour", pattern="^(hour|day)$"),
  112. ) -> dict:
  113. from_s, to_s = resolve_range(from_, to)
  114. params = {"from": from_s, "to": to_s}
  115. where = (
  116. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  117. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  118. )
  119. if device:
  120. where += " AND device = {device:String}"
  121. params["device"] = device
  122. if agg == "day":
  123. select = (
  124. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
  125. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  126. "max(taidetud) AS taidetud"
  127. )
  128. group = "GROUP BY t, device"
  129. else:
  130. select = (
  131. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
  132. "temperature, humidity, taidetud"
  133. )
  134. group = ""
  135. rows = ch_query(
  136. f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
  137. params,
  138. )
  139. series: dict[str, dict] = {}
  140. for row in rows:
  141. s = series.setdefault(
  142. row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
  143. )
  144. s["t"].append(int(row["t"]))
  145. s["temperature"].append(row["temperature"])
  146. s["humidity"].append(row["humidity"])
  147. s["taidetud"].append(bool(row.get("taidetud")))
  148. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  149. @app.get("/api/ukseandur", dependencies=[Depends(check_token)])
  150. async def ukseandur(
  151. from_: str | None = Query(None, alias="from"),
  152. to: str | None = Query(None),
  153. device: str | None = Query(None),
  154. ) -> dict:
  155. from_s, to_s = resolve_range(from_, to)
  156. params = {"from": from_s, "to": to_s}
  157. where = (
  158. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  159. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  160. )
  161. if device:
  162. where += " AND device = {device:String}"
  163. params["device"] = device
  164. rows = ch_query(
  165. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
  166. f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
  167. params,
  168. )
  169. return {
  170. "from": from_s,
  171. "to": to_s,
  172. "events": [
  173. {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
  174. for r in rows
  175. ],
  176. }
  177. @app.get("/api/vead", dependencies=[Depends(check_token)])
  178. async def vead(
  179. from_: str | None = Query(None, alias="from"),
  180. to: str | None = Query(None),
  181. device: str | None = Query(None),
  182. ) -> dict:
  183. from_s, to_s = resolve_range(from_, to)
  184. params = {"from": from_s, "to": to_s}
  185. where = (
  186. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  187. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  188. )
  189. if device:
  190. where += " AND device = {device:String}"
  191. params["device"] = device
  192. rows = ch_query(
  193. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
  194. f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
  195. params,
  196. )
  197. return {
  198. "from": from_s,
  199. "to": to_s,
  200. "events": [
  201. {
  202. "t": int(r["t"]),
  203. "device": r["device"],
  204. "tyyp": r["tyyp"],
  205. "olek": r["olek"],
  206. "detail": r["detail"],
  207. "vaartus": r["vaartus"],
  208. }
  209. for r in rows
  210. ],
  211. }
  212. @app.get("/api/latest", dependencies=[Depends(check_token)])
  213. async def latest() -> dict:
  214. readings = ch_query(
  215. f"SELECT device, "
  216. f"argMax(temperature, hour) AS temperature, "
  217. f"argMax(humidity, hour) AS humidity, "
  218. f"toString(max(hour)) AS ts_str, "
  219. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  220. f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
  221. )
  222. faults = ch_query(
  223. f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
  224. f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
  225. f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
  226. )
  227. return {
  228. "readings": [
  229. {
  230. "device": r["device"],
  231. "temperature": r["temperature"],
  232. "humidity": r["humidity"],
  233. "hour": r["ts_str"],
  234. "t": int(r["t"]) if r.get("t") is not None else None,
  235. }
  236. for r in readings
  237. ],
  238. "open_faults": [
  239. {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
  240. for r in faults
  241. ],
  242. }
  243. @app.get("/health")
  244. async def health() -> dict:
  245. try:
  246. ch_query("SELECT 1")
  247. ch_ok = True
  248. except HTTPException:
  249. ch_ok = False
  250. return {"status": "ok", "clickhouse": ch_ok}
  251. @app.get("/")
  252. async def index() -> FileResponse:
  253. return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
  254. app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
  255. if __name__ == "__main__":
  256. import uvicorn
  257. uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)