app.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  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 hmac
  17. import secrets
  18. import json
  19. import os
  20. import threading
  21. import time
  22. from collections import defaultdict
  23. from datetime import datetime, timedelta
  24. from pathlib import Path
  25. from zoneinfo import ZoneInfo
  26. import requests
  27. from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
  28. from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
  29. from fastapi.staticfiles import StaticFiles
  30. from starlette.middleware.sessions import SessionMiddleware
  31. ROOT = Path(__file__).parent
  32. STATIC_DIR = ROOT / "static"
  33. TZ = ZoneInfo("Europe/Tallinn")
  34. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  35. CH_USER = os.environ.get("CH_USER", "zigbee")
  36. CH_PASS = os.environ.get("CH_PASS", "")
  37. CH_DB = os.environ.get("CH_DB", "uus67")
  38. WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
  39. WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
  40. WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
  41. HA_URL = os.environ.get("HA_URL", "http://100.87.1.24:8123").rstrip("/")
  42. HA_TOKEN = os.environ.get("HA_TOKEN", "")
  43. AWAY_ENTITY = os.environ.get("AWAY_ENTITY", "input_boolean.kodust_eemal")
  44. SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "604800"))
  45. LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
  46. LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
  47. DEFAULT_RANGE_DAYS = 7
  48. MAX_RANGE_DAYS = 3660
  49. app = FastAPI(title="Nutikodu", version="0.2.0")
  50. app.add_middleware(
  51. SessionMiddleware,
  52. secret_key=WEB_SECRET_KEY,
  53. https_only=False,
  54. same_site="lax",
  55. max_age=SESSION_MAX_AGE,
  56. )
  57. @app.middleware("http")
  58. async def add_no_cache(request: Request, call_next):
  59. """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
  60. response = await call_next(request)
  61. path = request.url.path
  62. if (
  63. path == "/"
  64. or path.startswith("/api/")
  65. or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
  66. ):
  67. response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
  68. response.headers["Pragma"] = "no-cache"
  69. return response
  70. def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
  71. """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
  72. q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
  73. for key, value in (params or {}).items():
  74. q[f"param_{key}"] = value
  75. try:
  76. resp = requests.post(
  77. f"{CH_URL}/",
  78. params=q,
  79. auth=(CH_USER, CH_PASS),
  80. timeout=20,
  81. )
  82. except requests.RequestException as exc:
  83. raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
  84. if resp.status_code != 200:
  85. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
  86. text = resp.text.strip()
  87. if not text:
  88. return []
  89. try:
  90. return [json.loads(line) for line in text.splitlines() if line.strip()]
  91. except json.JSONDecodeError as exc:
  92. raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
  93. _login_failures: dict[str, list[float]] = defaultdict(list)
  94. _login_lock = threading.Lock()
  95. def client_ip(request: Request) -> str:
  96. forwarded = request.headers.get("x-forwarded-for")
  97. if forwarded:
  98. return forwarded.split(",")[0].strip()
  99. return request.client.host if request.client else "?"
  100. def retry_after_seconds(ip: str) -> int:
  101. now = time.monotonic()
  102. with _login_lock:
  103. history = [t for t in _login_failures.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
  104. _login_failures[ip] = history
  105. if len(history) >= LOGIN_MAX_ATTEMPTS:
  106. return int(LOGIN_WINDOW_SECONDS - (now - history[0])) + 1
  107. return 0
  108. def register_login_failure(ip: str) -> None:
  109. with _login_lock:
  110. _login_failures[ip].append(time.monotonic())
  111. def reset_login_failures(ip: str) -> None:
  112. with _login_lock:
  113. _login_failures.pop(ip, None)
  114. def is_authed(request: Request) -> bool:
  115. """Kas päring on autenditud: Bearer token VÕI sessiooniküpsis."""
  116. auth = request.headers.get("authorization", "")
  117. if auth.lower().startswith("bearer "):
  118. token = auth[7:].strip()
  119. if WEB_ACCESS_TOKEN and secrets.compare_digest(token, WEB_ACCESS_TOKEN):
  120. return True
  121. try:
  122. if request.session.get("auth") is True:
  123. return True
  124. except Exception:
  125. pass
  126. return False
  127. def require_auth(request: Request) -> None:
  128. if not is_authed(request):
  129. raise HTTPException(
  130. status_code=401,
  131. detail="Autentimine nõutud (Bearer token või sisselogimine)",
  132. headers={"WWW-Authenticate": "Bearer"},
  133. )
  134. def parse_dt(value: str | None, default: datetime) -> str:
  135. """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
  136. if not value:
  137. return default.strftime("%Y-%m-%d %H:%M:%S")
  138. cleaned = value.strip().replace("T", " ")
  139. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
  140. try:
  141. return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
  142. except ValueError:
  143. continue
  144. raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
  145. def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
  146. now = datetime.now(TZ)
  147. to_s = parse_dt(to, now)
  148. to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
  149. from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
  150. from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
  151. if from_dt > to_dt:
  152. raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
  153. if (to_dt - from_dt).days > MAX_RANGE_DAYS:
  154. raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
  155. return from_s, to_s
  156. @app.get("/api/meta")
  157. async def meta() -> dict:
  158. devices = [row["device"] for row in ch_query(
  159. f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
  160. )]
  161. bounds = ch_query(
  162. f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
  163. f"FROM {CH_DB}.temperatuur"
  164. )
  165. first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
  166. return {
  167. "tz": "Europe/Tallinn",
  168. "db": CH_DB,
  169. "devices": devices,
  170. "min": first["min_hour"],
  171. "max": first["max_hour"],
  172. "rows": first["rows"],
  173. "default_range_days": DEFAULT_RANGE_DAYS,
  174. }
  175. @app.get("/api/temperatuur")
  176. async def temperatuur(
  177. from_: str | None = Query(None, alias="from"),
  178. to: str | None = Query(None),
  179. device: str | None = Query(None),
  180. agg: str = Query("hour", pattern="^(hour|day)$"),
  181. ) -> dict:
  182. from_s, to_s = resolve_range(from_, to)
  183. params = {"from": from_s, "to": to_s}
  184. where = (
  185. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  186. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  187. )
  188. if device:
  189. where += " AND device = {device:String}"
  190. params["device"] = device
  191. if agg == "day":
  192. select = (
  193. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
  194. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  195. "max(taidetud) AS taidetud"
  196. )
  197. group = "GROUP BY t, device"
  198. else:
  199. select = (
  200. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
  201. "temperature, humidity, taidetud"
  202. )
  203. group = ""
  204. rows = ch_query(
  205. f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
  206. params,
  207. )
  208. series: dict[str, dict] = {}
  209. for row in rows:
  210. s = series.setdefault(
  211. row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
  212. )
  213. s["t"].append(int(row["t"]))
  214. s["temperature"].append(row["temperature"])
  215. s["humidity"].append(row["humidity"])
  216. s["taidetud"].append(bool(row.get("taidetud")))
  217. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  218. @app.get("/api/valisilm")
  219. async def valisilm(
  220. from_: str | None = Query(None, alias="from"),
  221. to: str | None = Query(None),
  222. agg: str = Query("hour", pattern="^(hour|day)$"),
  223. ) -> dict:
  224. from_s, to_s = resolve_range(from_, to)
  225. params = {"from": from_s, "to": to_s}
  226. where = (
  227. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  228. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  229. )
  230. if agg == "day":
  231. select = (
  232. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
  233. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  234. "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
  235. )
  236. group = "GROUP BY t"
  237. else:
  238. select = (
  239. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
  240. "temperature, humidity, wind, rain, taidetud"
  241. )
  242. group = ""
  243. rows = ch_query(
  244. f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
  245. params,
  246. )
  247. series: dict[str, list] = {
  248. "t": [],
  249. "temperature": [],
  250. "humidity": [],
  251. "wind": [],
  252. "rain": [],
  253. "taidetud": [],
  254. }
  255. for row in rows:
  256. series["t"].append(int(row["t"]))
  257. series["temperature"].append(row["temperature"])
  258. series["humidity"].append(row["humidity"])
  259. series["wind"].append(row["wind"])
  260. series["rain"].append(row["rain"])
  261. series["taidetud"].append(bool(row.get("taidetud")))
  262. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  263. @app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
  264. async def ukseandur(
  265. from_: str | None = Query(None, alias="from"),
  266. to: str | None = Query(None),
  267. device: str | None = Query(None),
  268. ) -> dict:
  269. from_s, to_s = resolve_range(from_, to)
  270. params = {"from": from_s, "to": to_s}
  271. where = (
  272. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  273. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  274. )
  275. if device:
  276. where += " AND device = {device:String}"
  277. params["device"] = device
  278. rows = ch_query(
  279. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
  280. f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
  281. params,
  282. )
  283. return {
  284. "from": from_s,
  285. "to": to_s,
  286. "events": [
  287. {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
  288. for r in rows
  289. ],
  290. }
  291. @app.get("/api/vead", dependencies=[Depends(require_auth)])
  292. async def vead(
  293. from_: str | None = Query(None, alias="from"),
  294. to: str | None = Query(None),
  295. device: str | None = Query(None),
  296. ) -> dict:
  297. from_s, to_s = resolve_range(from_, to)
  298. params = {"from": from_s, "to": to_s}
  299. where = (
  300. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  301. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  302. )
  303. if device:
  304. where += " AND device = {device:String}"
  305. params["device"] = device
  306. rows = ch_query(
  307. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
  308. f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
  309. params,
  310. )
  311. return {
  312. "from": from_s,
  313. "to": to_s,
  314. "events": [
  315. {
  316. "t": int(r["t"]),
  317. "device": r["device"],
  318. "tyyp": r["tyyp"],
  319. "olek": r["olek"],
  320. "detail": r["detail"],
  321. "vaartus": r["vaartus"],
  322. }
  323. for r in rows
  324. ],
  325. }
  326. @app.get("/api/liikumine", dependencies=[Depends(require_auth)])
  327. async def liikumine(
  328. from_: str | None = Query(None, alias="from"),
  329. to: str | None = Query(None),
  330. device: str | None = Query(None),
  331. ) -> dict:
  332. from_s, to_s = resolve_range(from_, to)
  333. params = {"from": from_s, "to": to_s}
  334. where = (
  335. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  336. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  337. )
  338. if device:
  339. where += " AND device = {device:String}"
  340. params["device"] = device
  341. rows = ch_query(
  342. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, occupancy, illumination, olek "
  343. f"FROM {CH_DB}.liikumine WHERE {where} ORDER BY ts",
  344. params,
  345. )
  346. return {
  347. "from": from_s,
  348. "to": to_s,
  349. "events": [
  350. {
  351. "t": int(r["t"]),
  352. "device": r["device"],
  353. "occupancy": bool(r["occupancy"]),
  354. "illumination": r["illumination"],
  355. "olek": r["olek"],
  356. }
  357. for r in rows
  358. ],
  359. }
  360. @app.get("/api/energia", dependencies=[Depends(require_auth)])
  361. async def energia(
  362. from_: str | None = Query(None, alias="from"),
  363. to: str | None = Query(None),
  364. device: str | None = Query(None),
  365. agg: str = Query("hour", pattern="^(hour|day)$"),
  366. ) -> dict:
  367. from_s, to_s = resolve_range(from_, to)
  368. params = {"from": from_s, "to": to_s}
  369. where = (
  370. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  371. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  372. )
  373. if device:
  374. where += " AND device = {device:String}"
  375. params["device"] = device
  376. if agg == "day":
  377. select = (
  378. "toUInt64(toUnixTimestamp(toStartOfDay(ts))) * 1000 AS t, device, "
  379. "round(avg(power), 2) AS power, round(avg(voltage), 1) AS voltage, "
  380. "round(avg(current), 3) AS current, max(energy) AS energy"
  381. )
  382. group = "GROUP BY t, device"
  383. else:
  384. select = (
  385. "toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, "
  386. "power, voltage, current, energy"
  387. )
  388. group = ""
  389. rows = ch_query(
  390. f"SELECT {select} FROM {CH_DB}.energia WHERE {where} {group} ORDER BY t",
  391. params,
  392. )
  393. series: dict[str, dict] = {}
  394. for row in rows:
  395. s2 = series.setdefault(
  396. row["device"], {"t": [], "power": [], "voltage": [], "current": [], "energy": []}
  397. )
  398. s2["t"].append(int(row["t"]))
  399. s2["power"].append(row["power"])
  400. s2["voltage"].append(row["voltage"])
  401. s2["current"].append(row["current"])
  402. s2["energy"].append(row["energy"])
  403. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  404. @app.get("/api/uus67_latest", dependencies=[Depends(require_auth)])
  405. async def uus67_latest() -> dict:
  406. energy = ch_query(
  407. f"SELECT device, argMax(power, ts) AS power, argMax(voltage, ts) AS voltage, "
  408. f"argMax(current, ts) AS current, argMax(energy, ts) AS energy, "
  409. f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
  410. f"FROM {CH_DB}.energia GROUP BY device ORDER BY device"
  411. )
  412. motion = ch_query(
  413. f"SELECT device, argMax(occupancy, ts) AS occupancy, "
  414. f"argMax(illumination, ts) AS illumination, "
  415. f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
  416. f"FROM {CH_DB}.liikumine GROUP BY device ORDER BY device"
  417. )
  418. return {
  419. "energy": [
  420. {
  421. "device": r["device"],
  422. "power": r.get("power"),
  423. "voltage": r.get("voltage"),
  424. "current": r.get("current"),
  425. "energy": r.get("energy"),
  426. "t": int(r["t"]) if r.get("t") is not None else None,
  427. }
  428. for r in energy
  429. ],
  430. "motion": [
  431. {
  432. "device": r["device"],
  433. "occupancy": bool(r.get("occupancy")),
  434. "illumination": r.get("illumination"),
  435. "t": int(r["t"]) if r.get("t") is not None else None,
  436. }
  437. for r in motion
  438. ],
  439. }
  440. @app.get("/api/latest")
  441. async def latest(request: Request) -> dict:
  442. readings = ch_query(
  443. f"SELECT device, "
  444. f"argMax(temperature, hour) AS temperature, "
  445. f"argMax(humidity, hour) AS humidity, "
  446. f"toString(max(hour)) AS ts_str, "
  447. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  448. f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
  449. )
  450. faults = ch_query(
  451. f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
  452. f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
  453. f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
  454. ) if is_authed(request) else []
  455. outside_rows = ch_query(
  456. f"SELECT toString(max(hour)) AS ts_str, "
  457. f"argMax(temperature, hour) AS temperature, "
  458. f"argMax(humidity, hour) AS humidity, "
  459. f"argMax(wind, hour) AS wind, "
  460. f"argMax(rain, hour) AS rain, "
  461. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  462. f"FROM {CH_DB}.valisilm FINAL"
  463. )
  464. outside: dict = {
  465. "temperature": None,
  466. "humidity": None,
  467. "wind": None,
  468. "rain": None,
  469. "hour": None,
  470. "t": None,
  471. }
  472. if outside_rows:
  473. r = outside_rows[0]
  474. outside = {
  475. "temperature": r.get("temperature"),
  476. "humidity": r.get("humidity"),
  477. "wind": r.get("wind"),
  478. "rain": r.get("rain"),
  479. "hour": r.get("ts_str"),
  480. "t": int(r["t"]) if r.get("t") is not None else None,
  481. }
  482. return {
  483. "readings": [
  484. {
  485. "device": r["device"],
  486. "temperature": r["temperature"],
  487. "humidity": r["humidity"],
  488. "hour": r["ts_str"],
  489. "t": int(r["t"]) if r.get("t") is not None else None,
  490. }
  491. for r in readings
  492. ],
  493. "open_faults": [
  494. {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
  495. for r in faults
  496. ],
  497. "outside": outside,
  498. }
  499. LOGIN_HTML = """<!doctype html>
  500. <html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  501. <title>Nutikodu — login</title>
  502. <style>
  503. body{font-family:system-ui,sans-serif;background:#0e1116;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
  504. form{background:#161b22;padding:28px 24px;border-radius:12px;border:1px solid #30363d;min-width:280px}
  505. h1{font-size:18px;margin:0 0 16px}
  506. input{width:100%;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;box-sizing:border-box;font-size:16px}
  507. button{margin-top:14px;width:100%;padding:10px;border-radius:8px;border:0;background:#1f6feb;color:#fff;font-size:16px;cursor:pointer}
  508. .err{color:#f85149;font-size:14px;margin-top:10px;min-height:18px}
  509. a{color:#58a6ff;font-size:13px;text-decoration:none}
  510. </style></head><body>
  511. <form method="post" action="/login">
  512. <h1>🔒 Nutikodu — sisselogimine</h1>
  513. <input type="password" name="password" placeholder="Parool" autofocus autocomplete="current-password">
  514. <button type="submit">Logi sisse</button>
  515. <div class="err">{{ERR}}</div>
  516. <div style="margin-top:14px"><a href="/">← Temperatuuri leht (avalik)</a></div>
  517. </form>
  518. </body></html>"""
  519. @app.get("/login")
  520. async def login_page():
  521. return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", ""))
  522. @app.post("/login")
  523. async def login_submit(request: Request, password: str = Form("")):
  524. ip = client_ip(request)
  525. ra = retry_after_seconds(ip)
  526. if ra:
  527. return HTMLResponse(
  528. LOGIN_HTML.replace("{{ERR}}", f"Liiga palju katseid. Proovi {ra} sekundi pärast."),
  529. status_code=429,
  530. )
  531. if WEB_PASSWORD and secrets.compare_digest(password, WEB_PASSWORD):
  532. reset_login_failures(ip)
  533. request.session["auth"] = True
  534. return RedirectResponse("/?auth=1", status_code=303)
  535. register_login_failure(ip)
  536. return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", "Vale parool"), status_code=401)
  537. @app.get("/logout")
  538. async def logout(request: Request):
  539. request.session.clear()
  540. return RedirectResponse("/", status_code=303)
  541. @app.get("/api/me")
  542. async def me(request: Request) -> dict:
  543. return {"authed": is_authed(request)}
  544. def _ha_headers() -> dict:
  545. return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
  546. def ha_get_away() -> bool | None:
  547. if not HA_TOKEN:
  548. return None
  549. try:
  550. r = requests.get(f"{HA_URL}/api/states/{AWAY_ENTITY}", headers=_ha_headers(), timeout=8)
  551. if r.status_code == 200:
  552. return r.json().get("state") == "on"
  553. except requests.RequestException:
  554. pass
  555. return None
  556. def ha_set_away(on: bool) -> None:
  557. service = "turn_on" if on else "turn_off"
  558. r = requests.post(
  559. f"{HA_URL}/api/services/input_boolean/{service}",
  560. headers=_ha_headers(),
  561. json={"entity_id": AWAY_ENTITY},
  562. timeout=8,
  563. )
  564. r.raise_for_status()
  565. @app.get("/api/away", dependencies=[Depends(require_auth)])
  566. async def away_state() -> dict:
  567. """Kohalolu ("kodust eemal") olek."""
  568. state = ha_get_away()
  569. return {"away": state, "available": state is not None}
  570. @app.post("/api/away", dependencies=[Depends(require_auth)])
  571. async def away_set(payload: dict = Body(...)) -> dict:
  572. """Lülita "kodust eemal" sisse/välja (keha {"on": true|false})."""
  573. on = bool(payload.get("on"))
  574. try:
  575. ha_set_away(on)
  576. except requests.RequestException as exc:
  577. raise HTTPException(status_code=502, detail=f"Home Assistant viga: {exc}") from exc
  578. return {"away": on, "available": True}
  579. @app.get("/health")
  580. async def health() -> dict:
  581. try:
  582. ch_query("SELECT 1")
  583. ch_ok = True
  584. except HTTPException:
  585. ch_ok = False
  586. return {"status": "ok", "clickhouse": ch_ok}
  587. @app.get("/")
  588. async def index() -> FileResponse:
  589. return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
  590. app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
  591. if __name__ == "__main__":
  592. import uvicorn
  593. uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)