app.py 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472
  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 asyncio
  17. import hmac
  18. import html
  19. import secrets
  20. import json
  21. import os
  22. import re
  23. import threading
  24. import time
  25. from collections import defaultdict
  26. from datetime import datetime, timedelta
  27. from pathlib import Path
  28. from zoneinfo import ZoneInfo
  29. import color
  30. import numpy as np
  31. import paho.mqtt.client as mqtt
  32. import requests
  33. from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
  34. from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
  35. from fastapi.staticfiles import StaticFiles
  36. from starlette.middleware.sessions import SessionMiddleware
  37. ROOT = Path(__file__).parent
  38. STATIC_DIR = ROOT / "static"
  39. TZ = ZoneInfo("Europe/Tallinn")
  40. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  41. CH_USER = os.environ.get("CH_USER", "zigbee")
  42. CH_PASS = os.environ.get("CH_PASS", "")
  43. CH_DB = os.environ.get("CH_DB", "uus67")
  44. # Värvisensori juhtimine (MQTT, edge broker). OFF peatab ESP32 publitseerimise
  45. # -> ClickHouse'i ei kirjutata.
  46. VARV_MQTT_HOST = os.environ.get("VARV_MQTT_HOST", "")
  47. VARV_MQTT_PORT = int(os.environ.get("VARV_MQTT_PORT", "8883"))
  48. VARV_MQTT_TLS = os.environ.get("VARV_MQTT_TLS", "true").strip().lower() in ("1", "true", "yes", "on")
  49. VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
  50. VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
  51. VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
  52. VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
  53. VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
  54. VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
  55. VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
  56. VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
  57. VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
  58. WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
  59. WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
  60. WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
  61. HA_URL = os.environ.get("HA_URL", "http://100.87.1.24:8123").rstrip("/")
  62. HA_TOKEN = os.environ.get("HA_TOKEN", "")
  63. AWAY_ENTITY = os.environ.get("AWAY_ENTITY", "input_boolean.kodust_eemal")
  64. SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "604800"))
  65. LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
  66. LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
  67. DEFAULT_RANGE_DAYS = 7
  68. MAX_RANGE_DAYS = 3660
  69. # --- Transport (bussid + praamid) ---
  70. BUS_GRAPHQL = os.environ.get(
  71. "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
  72. ).rstrip("/")
  73. BUS_STOPS = [s.strip() for s in os.environ.get(
  74. "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
  75. PRAAM_API = os.environ.get(
  76. "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
  77. RII_HOIATUS_URL = os.environ.get(
  78. "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
  79. )
  80. TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
  81. UA = "Mozilla/5.0 (compatible; Nutikodu-web/1.0)"
  82. PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
  83. SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
  84. PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
  85. SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
  86. WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
  87. _cache: dict[str, tuple[float, object]] = {}
  88. _cache_lock = threading.Lock()
  89. def cached(key: str, ttl: int, producer):
  90. """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
  91. now = time.monotonic()
  92. with _cache_lock:
  93. hit = _cache.get(key)
  94. if hit and now - hit[0] < ttl:
  95. return hit[1]
  96. value = producer()
  97. with _cache_lock:
  98. _cache[key] = (time.monotonic(), value)
  99. return value
  100. app = FastAPI(title="Nutikodu", version="0.2.0")
  101. app.add_middleware(
  102. SessionMiddleware,
  103. secret_key=WEB_SECRET_KEY,
  104. https_only=False,
  105. same_site="lax",
  106. max_age=SESSION_MAX_AGE,
  107. )
  108. @app.middleware("http")
  109. async def add_no_cache(request: Request, call_next):
  110. """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
  111. response = await call_next(request)
  112. path = request.url.path
  113. if (
  114. path == "/"
  115. or path.startswith("/api/")
  116. or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
  117. ):
  118. response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
  119. response.headers["Pragma"] = "no-cache"
  120. return response
  121. def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
  122. """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
  123. q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
  124. for key, value in (params or {}).items():
  125. q[f"param_{key}"] = value
  126. try:
  127. resp = requests.post(
  128. f"{CH_URL}/",
  129. params=q,
  130. auth=(CH_USER, CH_PASS),
  131. timeout=20,
  132. )
  133. except requests.RequestException as exc:
  134. raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
  135. if resp.status_code != 200:
  136. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
  137. text = resp.text.strip()
  138. if not text:
  139. return []
  140. try:
  141. return [json.loads(line) for line in text.splitlines() if line.strip()]
  142. except json.JSONDecodeError as exc:
  143. raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
  144. _login_failures: dict[str, list[float]] = defaultdict(list)
  145. _login_lock = threading.Lock()
  146. def client_ip(request: Request) -> str:
  147. forwarded = request.headers.get("x-forwarded-for")
  148. if forwarded:
  149. return forwarded.split(",")[0].strip()
  150. return request.client.host if request.client else "?"
  151. def retry_after_seconds(ip: str) -> int:
  152. now = time.monotonic()
  153. with _login_lock:
  154. history = [t for t in _login_failures.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
  155. _login_failures[ip] = history
  156. if len(history) >= LOGIN_MAX_ATTEMPTS:
  157. return int(LOGIN_WINDOW_SECONDS - (now - history[0])) + 1
  158. return 0
  159. def register_login_failure(ip: str) -> None:
  160. with _login_lock:
  161. _login_failures[ip].append(time.monotonic())
  162. def reset_login_failures(ip: str) -> None:
  163. with _login_lock:
  164. _login_failures.pop(ip, None)
  165. def is_authed(request: Request) -> bool:
  166. """Kas päring on autenditud: Bearer token VÕI sessiooniküpsis."""
  167. auth = request.headers.get("authorization", "")
  168. if auth.lower().startswith("bearer "):
  169. token = auth[7:].strip()
  170. if WEB_ACCESS_TOKEN and secrets.compare_digest(token, WEB_ACCESS_TOKEN):
  171. return True
  172. try:
  173. if request.session.get("auth") is True:
  174. return True
  175. except Exception:
  176. pass
  177. return False
  178. def require_auth(request: Request) -> None:
  179. if not is_authed(request):
  180. raise HTTPException(
  181. status_code=401,
  182. detail="Autentimine nõutud (Bearer token või sisselogimine)",
  183. headers={"WWW-Authenticate": "Bearer"},
  184. )
  185. def parse_dt(value: str | None, default: datetime) -> str:
  186. """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
  187. if not value:
  188. return default.strftime("%Y-%m-%d %H:%M:%S")
  189. cleaned = value.strip().replace("T", " ")
  190. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
  191. try:
  192. return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
  193. except ValueError:
  194. continue
  195. raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
  196. def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
  197. now = datetime.now(TZ)
  198. to_s = parse_dt(to, now)
  199. to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
  200. from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
  201. from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
  202. if from_dt > to_dt:
  203. raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
  204. if (to_dt - from_dt).days > MAX_RANGE_DAYS:
  205. raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
  206. return from_s, to_s
  207. @app.get("/api/meta")
  208. async def meta() -> dict:
  209. devices = [row["device"] for row in ch_query(
  210. f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
  211. )]
  212. bounds = ch_query(
  213. f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
  214. f"FROM {CH_DB}.temperatuur"
  215. )
  216. first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
  217. return {
  218. "tz": "Europe/Tallinn",
  219. "db": CH_DB,
  220. "devices": devices,
  221. "min": first["min_hour"],
  222. "max": first["max_hour"],
  223. "rows": first["rows"],
  224. "default_range_days": DEFAULT_RANGE_DAYS,
  225. }
  226. @app.get("/api/temperatuur")
  227. async def temperatuur(
  228. from_: str | None = Query(None, alias="from"),
  229. to: str | None = Query(None),
  230. device: str | None = Query(None),
  231. agg: str = Query("hour", pattern="^(hour|day)$"),
  232. ) -> dict:
  233. from_s, to_s = resolve_range(from_, to)
  234. params = {"from": from_s, "to": to_s}
  235. where = (
  236. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  237. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  238. )
  239. if device:
  240. where += " AND device = {device:String}"
  241. params["device"] = device
  242. if agg == "day":
  243. select = (
  244. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
  245. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  246. "max(taidetud) AS taidetud"
  247. )
  248. group = "GROUP BY t, device"
  249. else:
  250. select = (
  251. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
  252. "temperature, humidity, taidetud"
  253. )
  254. group = ""
  255. rows = ch_query(
  256. f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
  257. params,
  258. )
  259. series: dict[str, dict] = {}
  260. for row in rows:
  261. s = series.setdefault(
  262. row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
  263. )
  264. s["t"].append(int(row["t"]))
  265. s["temperature"].append(row["temperature"])
  266. s["humidity"].append(row["humidity"])
  267. s["taidetud"].append(bool(row.get("taidetud")))
  268. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  269. @app.get("/api/valisilm")
  270. async def valisilm(
  271. from_: str | None = Query(None, alias="from"),
  272. to: str | None = Query(None),
  273. agg: str = Query("hour", pattern="^(hour|day)$"),
  274. ) -> dict:
  275. from_s, to_s = resolve_range(from_, to)
  276. params = {"from": from_s, "to": to_s}
  277. where = (
  278. f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  279. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  280. )
  281. if agg == "day":
  282. select = (
  283. "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
  284. "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
  285. "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
  286. )
  287. group = "GROUP BY t"
  288. else:
  289. select = (
  290. "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
  291. "temperature, humidity, wind, rain, taidetud"
  292. )
  293. group = ""
  294. rows = ch_query(
  295. f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
  296. params,
  297. )
  298. series: dict[str, list] = {
  299. "t": [],
  300. "temperature": [],
  301. "humidity": [],
  302. "wind": [],
  303. "rain": [],
  304. "taidetud": [],
  305. }
  306. for row in rows:
  307. series["t"].append(int(row["t"]))
  308. series["temperature"].append(row["temperature"])
  309. series["humidity"].append(row["humidity"])
  310. series["wind"].append(row["wind"])
  311. series["rain"].append(row["rain"])
  312. series["taidetud"].append(bool(row.get("taidetud")))
  313. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  314. @app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
  315. async def ukseandur(
  316. from_: str | None = Query(None, alias="from"),
  317. to: str | None = Query(None),
  318. device: str | None = Query(None),
  319. ) -> dict:
  320. from_s, to_s = resolve_range(from_, to)
  321. params = {"from": from_s, "to": to_s}
  322. where = (
  323. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  324. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  325. )
  326. if device:
  327. where += " AND device = {device:String}"
  328. params["device"] = device
  329. rows = ch_query(
  330. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
  331. f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
  332. params,
  333. )
  334. return {
  335. "from": from_s,
  336. "to": to_s,
  337. "events": [
  338. {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
  339. for r in rows
  340. ],
  341. }
  342. @app.get("/api/vead", dependencies=[Depends(require_auth)])
  343. async def vead(
  344. from_: str | None = Query(None, alias="from"),
  345. to: str | None = Query(None),
  346. device: str | None = Query(None),
  347. ) -> dict:
  348. from_s, to_s = resolve_range(from_, to)
  349. params = {"from": from_s, "to": to_s}
  350. where = (
  351. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  352. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  353. )
  354. if device:
  355. where += " AND device = {device:String}"
  356. params["device"] = device
  357. rows = ch_query(
  358. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
  359. f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
  360. params,
  361. )
  362. return {
  363. "from": from_s,
  364. "to": to_s,
  365. "events": [
  366. {
  367. "t": int(r["t"]),
  368. "device": r["device"],
  369. "tyyp": r["tyyp"],
  370. "olek": r["olek"],
  371. "detail": r["detail"],
  372. "vaartus": r["vaartus"],
  373. }
  374. for r in rows
  375. ],
  376. }
  377. @app.get("/api/liikumine", dependencies=[Depends(require_auth)])
  378. async def liikumine(
  379. from_: str | None = Query(None, alias="from"),
  380. to: str | None = Query(None),
  381. device: str | None = Query(None),
  382. ) -> dict:
  383. from_s, to_s = resolve_range(from_, to)
  384. params = {"from": from_s, "to": to_s}
  385. where = (
  386. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  387. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  388. )
  389. if device:
  390. where += " AND device = {device:String}"
  391. params["device"] = device
  392. rows = ch_query(
  393. f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, occupancy, illumination, olek "
  394. f"FROM {CH_DB}.liikumine WHERE {where} ORDER BY ts",
  395. params,
  396. )
  397. return {
  398. "from": from_s,
  399. "to": to_s,
  400. "events": [
  401. {
  402. "t": int(r["t"]),
  403. "device": r["device"],
  404. "occupancy": bool(r["occupancy"]),
  405. "illumination": r["illumination"],
  406. "olek": r["olek"],
  407. }
  408. for r in rows
  409. ],
  410. }
  411. @app.get("/api/energia", dependencies=[Depends(require_auth)])
  412. async def energia(
  413. from_: str | None = Query(None, alias="from"),
  414. to: str | None = Query(None),
  415. device: str | None = Query(None),
  416. agg: str = Query("hour", pattern="^(hour|day)$"),
  417. ) -> dict:
  418. from_s, to_s = resolve_range(from_, to)
  419. params = {"from": from_s, "to": to_s}
  420. where = (
  421. f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
  422. f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
  423. )
  424. if device:
  425. where += " AND device = {device:String}"
  426. params["device"] = device
  427. if agg == "day":
  428. select = (
  429. "toUInt64(toUnixTimestamp(toStartOfDay(ts))) * 1000 AS t, device, "
  430. "round(avg(power), 2) AS power, round(avg(voltage), 1) AS voltage, "
  431. "round(avg(current), 3) AS current, max(energy) AS energy"
  432. )
  433. group = "GROUP BY t, device"
  434. else:
  435. select = (
  436. "toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, "
  437. "power, voltage, current, energy"
  438. )
  439. group = ""
  440. rows = ch_query(
  441. f"SELECT {select} FROM {CH_DB}.energia WHERE {where} {group} ORDER BY t",
  442. params,
  443. )
  444. series: dict[str, dict] = {}
  445. for row in rows:
  446. s2 = series.setdefault(
  447. row["device"], {"t": [], "power": [], "voltage": [], "current": [], "energy": []}
  448. )
  449. s2["t"].append(int(row["t"]))
  450. s2["power"].append(row["power"])
  451. s2["voltage"].append(row["voltage"])
  452. s2["current"].append(row["current"])
  453. s2["energy"].append(row["energy"])
  454. return {"from": from_s, "to": to_s, "agg": agg, "series": series}
  455. @app.get("/api/uus67_latest", dependencies=[Depends(require_auth)])
  456. async def uus67_latest() -> dict:
  457. energy = ch_query(
  458. f"SELECT device, argMax(power, ts) AS power, argMax(voltage, ts) AS voltage, "
  459. f"argMax(current, ts) AS current, argMax(energy, ts) AS energy, "
  460. f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
  461. f"FROM {CH_DB}.energia GROUP BY device ORDER BY device"
  462. )
  463. motion = ch_query(
  464. f"SELECT device, argMax(occupancy, ts) AS occupancy, "
  465. f"argMax(illumination, ts) AS illumination, "
  466. f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
  467. f"FROM {CH_DB}.liikumine GROUP BY device ORDER BY device"
  468. )
  469. return {
  470. "energy": [
  471. {
  472. "device": r["device"],
  473. "power": r.get("power"),
  474. "voltage": r.get("voltage"),
  475. "current": r.get("current"),
  476. "energy": r.get("energy"),
  477. "t": int(r["t"]) if r.get("t") is not None else None,
  478. }
  479. for r in energy
  480. ],
  481. "motion": [
  482. {
  483. "device": r["device"],
  484. "occupancy": bool(r.get("occupancy")),
  485. "illumination": r.get("illumination"),
  486. "t": int(r["t"]) if r.get("t") is not None else None,
  487. }
  488. for r in motion
  489. ],
  490. }
  491. # --- Värvisensor (uus67.varvus, AS7341) ---
  492. def varv_load_calib() -> dict:
  493. """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
  494. try:
  495. rows = ch_query(
  496. f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
  497. f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
  498. )
  499. except HTTPException:
  500. return {}
  501. out: dict[str, dict] = {}
  502. for r in rows:
  503. out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
  504. return out
  505. def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
  506. """R = (S - D) / (W - D) kanali kohta."""
  507. out: dict[str, float] = {}
  508. for band in VARV_BANDS:
  509. v = channels.get(band)
  510. d = float(dark.get(band) or 0.0)
  511. w = float(white.get(band) or 0.0)
  512. if v is not None and (w - d) > 0:
  513. out[band] = max(0.0, (float(v) - d) / (w - d))
  514. return out
  515. # --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
  516. def varv_load_ccm() -> list[list[float]] | None:
  517. try:
  518. rows = ch_query(
  519. f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
  520. f"ORDER BY updated_at DESC LIMIT 1"
  521. )
  522. except HTTPException:
  523. return None
  524. if not rows:
  525. return None
  526. try:
  527. m = json.loads(rows[0]["matrix"])
  528. if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
  529. return [[float(x) for x in row] for row in m]
  530. except Exception:
  531. return None
  532. return None
  533. def _srgb_to_linear(v: float) -> float:
  534. c = v / 255.0
  535. return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
  536. def _linear_to_srgb8(c: float) -> int:
  537. c = max(0.0, min(1.0, c))
  538. v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
  539. return int(round(255 * max(0.0, min(1.0, v))))
  540. def varv_apply_ccm(matrix: list[list[float]], channels: dict, dark: dict | None = None, white: dict | None = None) -> str:
  541. src = varv_reflectance(channels, dark or {}, white) if white else channels
  542. vec = np.array([float(src.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
  543. lin = np.array(matrix, dtype=float) @ vec
  544. r = _linear_to_srgb8(float(lin[0]))
  545. g = _linear_to_srgb8(float(lin[1]))
  546. b = _linear_to_srgb8(float(lin[2]))
  547. return f"#{r:02X}{g:02X}{b:02X}"
  548. @app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
  549. async def varv_ref_set(payload: dict = Body(...)) -> dict:
  550. """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
  551. name = str(payload.get("name", "")).strip().lower()
  552. hexv = str(payload.get("hex", "")).strip().lstrip("#")
  553. if len(hexv) != 6:
  554. raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
  555. try:
  556. tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
  557. except ValueError:
  558. raise HTTPException(status_code=422, detail="vigane hex")
  559. last = max(1, min(int(payload.get("last", 5)), 30))
  560. rows = ch_query(
  561. f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
  562. f"ORDER BY ts DESC LIMIT {last}"
  563. )
  564. if not rows:
  565. raise HTTPException(status_code=409, detail="Mõõtmisi pole")
  566. ref: dict[str, float] = {}
  567. for f in VARV_CALIB_FIELDS:
  568. vals = [float(r[f]) for r in rows if r.get(f) is not None]
  569. ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
  570. total = sum(ref.get(b, 0.0) for b in VARV_BANDS)
  571. if (tr + tg + tb) >= 10 and total < 300:
  572. raise HTTPException(
  573. status_code=422,
  574. detail="Mõõtmine on liiga tume (värv ei ole ekraanil?). Kuva värv täisheledusel ja oota, kuni mõõtmine on HELE, siis salvesta.",
  575. )
  576. row = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
  577. resp = requests.post(
  578. f"{CH_URL}/",
  579. params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_TABLE} FORMAT JSONEachRow"},
  580. data=json.dumps(row) + "\n",
  581. auth=(CH_USER, CH_PASS),
  582. timeout=10,
  583. )
  584. if resp.status_code != 200:
  585. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
  586. return {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
  587. @app.get("/api/varv/ref")
  588. async def varv_ref_get() -> dict:
  589. rows = ch_query(
  590. f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
  591. f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
  592. )
  593. return {"refs": rows}
  594. def _lin_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
  595. x = 0.4124 * r + 0.3576 * g + 0.1805 * b
  596. y = 0.2126 * r + 0.7152 * g + 0.0722 * b
  597. z = 0.0193 * r + 0.1192 * g + 0.9505 * b
  598. def f(t: float) -> float:
  599. return t ** (1.0 / 3.0) if t > 0.008856 else (7.787 * t + 16.0 / 116.0)
  600. fx, fy, fz = f(x / 0.95047), f(y / 1.0), f(z / 1.08883)
  601. return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
  602. def _delta_e00(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
  603. import math
  604. L1, a1, b1 = lab1
  605. L2, a2, b2 = lab2
  606. C1 = math.hypot(a1, b1)
  607. C2 = math.hypot(a2, b2)
  608. Cb = (C1 + C2) / 2.0
  609. G = 0.5 * (1.0 - math.sqrt(Cb ** 7 / (Cb ** 7 + 25.0 ** 7))) if Cb > 0 else 0.0
  610. a1p, a2p = (1.0 + G) * a1, (1.0 + G) * a2
  611. C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2)
  612. h1p = math.degrees(math.atan2(b1, a1p)) % 360.0
  613. h2p = math.degrees(math.atan2(b2, a2p)) % 360.0
  614. dLp = L2 - L1
  615. dCp = C2p - C1p
  616. if C1p * C2p == 0:
  617. dhp = 0.0
  618. else:
  619. dhp = h2p - h1p
  620. if dhp > 180:
  621. dhp -= 360
  622. elif dhp < -180:
  623. dhp += 360
  624. dHp = 2.0 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp / 2.0))
  625. Lbp = (L1 + L2) / 2.0
  626. Cbp = (C1p + C2p) / 2.0
  627. if C1p * C2p == 0:
  628. hbp = h1p + h2p
  629. else:
  630. hbp = (h1p + h2p) / 2.0
  631. if abs(h1p - h2p) > 180:
  632. hbp = (h1p + h2p + 360.0) / 2.0 if (h1p + h2p) < 360 else (h1p + h2p - 360.0) / 2.0
  633. T = (1.0 - 0.17 * math.cos(math.radians(hbp - 30.0))
  634. + 0.24 * math.cos(math.radians(2.0 * hbp))
  635. + 0.32 * math.cos(math.radians(3.0 * hbp + 6.0))
  636. - 0.20 * math.cos(math.radians(4.0 * hbp - 63.0)))
  637. dtheta = 30.0 * math.exp(-((hbp - 275.0) / 25.0) ** 2)
  638. Rc = 2.0 * math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25.0 ** 7)) if Cbp > 0 else 0.0
  639. Sl = 1.0 + 0.015 * (Lbp - 50.0) ** 2 / math.sqrt(20.0 + (Lbp - 50.0) ** 2)
  640. Sc = 1.0 + 0.045 * Cbp
  641. Sh = 1.0 + 0.015 * Cbp * T
  642. Rt = -math.sin(math.radians(2.0 * dtheta)) * Rc
  643. return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2
  644. + Rt * (dCp / Sc) * (dHp / Sh))
  645. def _fit_ccm(C: "np.ndarray", T: "np.ndarray") -> "np.ndarray":
  646. CtC = C.T @ C
  647. CtT = C.T @ T
  648. lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
  649. return np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)
  650. def _predict_hex(M: "np.ndarray", c: "np.ndarray") -> str:
  651. lin = np.clip(c @ M, 0.0, 1.0)
  652. return "#" + "".join(f"{_linear_to_srgb8(float(v)):02X}" for v in lin)
  653. @app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
  654. async def varv_ccm_fit(prefix: str | None = Query(None)) -> dict:
  655. """Sobita 3x8 maatriks reflekteeruvusest -> lineaarne sRGB.
  656. prefix filtreerib referentsid (nt wizard: 'wiz').
  657. """
  658. where = ""
  659. if prefix:
  660. if not re.fullmatch(r"[a-z0-9_-]{1,16}", prefix):
  661. raise HTTPException(status_code=422, detail="vigane prefix")
  662. where = f" WHERE name LIKE '{prefix}%'"
  663. rows = ch_query(
  664. f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
  665. f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL{where} ORDER BY name"
  666. )
  667. if len(rows) < 6:
  668. raise HTTPException(status_code=422, detail="Vaja vähemalt 6 referentsvärvi (soovitav 12-24).")
  669. calib = varv_load_calib()
  670. dark, white = calib.get("dark"), calib.get("white")
  671. if not white:
  672. raise HTTPException(status_code=422, detail="Valge referents puudub — kalibreeri enne tume ja valge.")
  673. def _feat(r):
  674. ch = {b: r.get(b) for b in VARV_BANDS}
  675. ch = varv_reflectance(ch, dark or {}, white)
  676. return [float(ch.get(b) or 0.0) for b in VARV_BANDS]
  677. C = np.array([_feat(r) for r in rows], dtype=float)
  678. T = np.array(
  679. [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
  680. for r in rows],
  681. dtype=float,
  682. )
  683. M = _fit_ccm(C, T)
  684. matrix = M.T.tolist()
  685. # In-sample ja leave-one-out ΔE00
  686. def _de(pred: np.ndarray, target: np.ndarray) -> float:
  687. return _delta_e00(_lin_to_lab(*pred), _lin_to_lab(*target))
  688. insample = [_de(np.clip(C[i] @ M, 0, 1), T[i]) for i in range(len(rows))]
  689. loo = []
  690. for i in range(len(rows)):
  691. mask = np.ones(len(rows), dtype=bool)
  692. mask[i] = False
  693. Mi = _fit_ccm(C[mask], T[mask])
  694. loo.append(_de(np.clip(C[i] @ Mi, 0, 1), T[i]))
  695. def _stats(v):
  696. v = np.array(v)
  697. return {"mean": round(float(v.mean()), 2), "median": round(float(np.median(v)), 2), "max": round(float(v.max()), 2)}
  698. resp = requests.post(
  699. f"{CH_URL}/",
  700. params={
  701. "database": CH_DB,
  702. "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
  703. },
  704. data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\n",
  705. auth=(CH_USER, CH_PASS),
  706. timeout=10,
  707. )
  708. if resp.status_code != 200:
  709. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
  710. return {
  711. "n_refs": len(rows),
  712. "matrix": matrix,
  713. "de00_in_sample": _stats(insample),
  714. "de00_loo": _stats(loo),
  715. "preview": [
  716. {"name": r["name"], "target": f"#{int(r['tr']):02X}{int(r['tg']):02X}{int(r['tb']):02X}",
  717. "pred": _predict_hex(M, C[i]), "de00": round(insample[i], 2)}
  718. for i, r in enumerate(rows)
  719. ],
  720. }
  721. @app.get("/api/varv/ccm")
  722. async def varv_ccm_get() -> dict:
  723. m = varv_load_ccm()
  724. return {"matrix": m, "ready": m is not None}
  725. @app.get("/api/varv/latest")
  726. async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
  727. """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
  728. cols = ", ".join(VARV_BANDS)
  729. rows = ch_query(
  730. f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
  731. f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
  732. f"{cols}, clear, nir, gain "
  733. f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
  734. )
  735. calib = varv_load_calib()
  736. dark, white = calib.get("dark"), calib.get("white")
  737. ready = bool(dark and white)
  738. ccm = varv_load_ccm()
  739. out = []
  740. for r in rows:
  741. bands = {b: r.get(b) for b in VARV_BANDS}
  742. valid, reason = color.quality(bands, dark)
  743. raw = color.analyze(bands, dark=dark)
  744. item = {
  745. "ts": r["ts"],
  746. "t": int(r["t"]),
  747. "rgb_hex": raw["rgb_hex"],
  748. "r": raw["r"],
  749. "g": raw["g"],
  750. "b": raw["b"],
  751. "x_chrom": raw["x_chrom"],
  752. "y_chrom": raw["y_chrom"],
  753. "bands": bands,
  754. "clear": r.get("clear"),
  755. "nir": r.get("nir"),
  756. "gain": r.get("gain"),
  757. "valid": valid,
  758. "reason": reason,
  759. }
  760. if ready:
  761. cc = color.analyze(bands, dark=dark, white=white)
  762. item["rgb_hex_cal"] = cc["rgb_hex"]
  763. item["x_chrom_cal"] = cc["x_chrom"]
  764. item["y_chrom_cal"] = cc["y_chrom"]
  765. # CCM (3x8) on treenitud ekraaniprimaaridel TOORARVUDE domeenis.
  766. # Reflekteeruvusele rakendatuna annab peaaegu musta tulemuse, seega
  767. # kuni päris värvikaardi kalibratsioonini seda ei kuvata.
  768. # item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands, dark, white)
  769. out.append(item)
  770. return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
  771. @app.get("/api/varv/calib")
  772. async def varv_calib_get() -> dict:
  773. """Tume/valge referents (avalik lugemine)."""
  774. calib = varv_load_calib()
  775. return {
  776. "dark": calib.get("dark"),
  777. "white": calib.get("white"),
  778. "ready": bool(calib.get("dark") and calib.get("white")),
  779. }
  780. @app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
  781. async def varv_calib_set(payload: dict = Body(...)) -> dict:
  782. """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
  783. kind = str(payload.get("kind", "")).strip().lower()
  784. if kind not in ("dark", "white"):
  785. raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
  786. last = max(1, min(int(payload.get("last", 5)), 60))
  787. rows = ch_query(
  788. f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
  789. f"ORDER BY ts DESC LIMIT {last}"
  790. )
  791. if not rows:
  792. raise HTTPException(status_code=409, detail="Mõõtmisi pole")
  793. # Stabiilsuse kontroll: viimased mõõtmised peavad olema samast olekust.
  794. totals = [
  795. sum(float(r[b]) for b in VARV_BANDS if r.get(b) is not None)
  796. for r in rows
  797. ]
  798. tmax = max(totals) if totals else 0.0
  799. if tmax > 0 and (tmax - min(totals)) / tmax > 0.2:
  800. raise HTTPException(
  801. status_code=422,
  802. detail="Mõõtmine pole stabiliseerunud (viimased mõõtmised erinevad). Oota ~30 s pärast katmise/valge pinna muutust ja proovi uuesti.",
  803. )
  804. ref: dict[str, float] = {}
  805. for f in VARV_CALIB_FIELDS:
  806. vals = [float(r[f]) for r in rows if r.get(f) is not None]
  807. ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
  808. # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
  809. total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
  810. # Tume referents TOHIB olla null (LED eemaldatud, andur kaetud).
  811. if kind == "white" and total_new <= 0:
  812. raise HTTPException(status_code=422, detail="Valge mõõtmine on null — andur on pime või andmed puuduvad.")
  813. calib = varv_load_calib()
  814. other = calib.get("white" if kind == "dark" else "dark")
  815. if other:
  816. total_other = sum(float(other.get(b) or 0.0) for b in VARV_BANDS)
  817. if kind == "white" and total_new <= total_other * 1.05:
  818. raise HTTPException(
  819. status_code=422,
  820. detail="Valge ja tume on liiga sarnased (valge pole tumedast heledam). Kontrolli: tume = andur kaetud; valge = valge pind valgustatud, sama geomeetria.",
  821. )
  822. if kind == "dark" and total_new >= total_other * 0.95:
  823. raise HTTPException(
  824. status_code=422,
  825. detail="Tume ja valge on liiga sarnased (tume pole valgest tumedem). Kontrolli, et andur oli täiesti kaetud.",
  826. )
  827. row = {"kind": kind, **ref}
  828. resp = requests.post(
  829. f"{CH_URL}/",
  830. params={
  831. "database": CH_DB,
  832. "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
  833. },
  834. data=json.dumps(row) + "\n",
  835. auth=(CH_USER, CH_PASS),
  836. timeout=10,
  837. )
  838. if resp.status_code != 200:
  839. raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
  840. return {"kind": kind, "reference": ref, "rows": len(rows)}
  841. @app.get("/api/varv/history")
  842. async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
  843. """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
  844. rows = ch_query(
  845. f"SELECT toString(toStartOfHour(ts)) AS bucket, "
  846. f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
  847. f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
  848. f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
  849. f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
  850. f"GROUP BY toStartOfHour(ts) ORDER BY t"
  851. )
  852. out = []
  853. for r in rows:
  854. rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
  855. out.append({
  856. "ts": r["bucket"],
  857. "t": int(r["t"]),
  858. "r": rv, "g": gv, "b": bv,
  859. "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
  860. "x_chrom": r.get("x_chrom"),
  861. "y_chrom": r.get("y_chrom"),
  862. "n": int(r.get("n") or 0),
  863. })
  864. return {"hours": hours, "rows": out}
  865. # --- Värvisensori mõõtmise sisse/välja lüliti (MQTT, ainult sisselogitult) ---
  866. def _varv_mqtt_client() -> mqtt.Client:
  867. client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
  868. if VARV_MQTT_TLS:
  869. client.tls_set()
  870. if VARV_MQTT_USER:
  871. client.username_pw_set(VARV_MQTT_USER, VARV_MQTT_PASS)
  872. return client
  873. def varv_read_state(timeout: float = 4.0) -> str | None:
  874. """Loe lüliti retained olek edge-brokerist (lühiajaline ühendus)."""
  875. if not VARV_MQTT_HOST:
  876. return None
  877. box: dict[str, str] = {}
  878. def on_connect(c, userdata, flags, rc, props=None):
  879. if rc == 0:
  880. c.subscribe(VARV_STATE_TOPIC)
  881. def on_message(c, userdata, msg):
  882. box["state"] = msg.payload.decode().strip()
  883. client = _varv_mqtt_client()
  884. client.on_connect = on_connect
  885. client.on_message = on_message
  886. try:
  887. client.connect_async(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
  888. client.loop_start()
  889. deadline = time.monotonic() + timeout
  890. while time.monotonic() < deadline and "state" not in box:
  891. time.sleep(0.05)
  892. finally:
  893. client.loop_stop()
  894. try:
  895. client.disconnect()
  896. except Exception:
  897. pass
  898. state = box.get("state")
  899. return state if state in ("ON", "OFF") else None
  900. def varv_set_power(on: bool) -> None:
  901. if not VARV_MQTT_HOST:
  902. raise HTTPException(status_code=503, detail="Värvisensori MQTT pole seadistatud")
  903. client = _varv_mqtt_client()
  904. try:
  905. client.connect(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
  906. client.loop_start()
  907. info = client.publish(VARV_CMD_TOPIC, "ON" if on else "OFF", qos=1, retain=True)
  908. info.wait_for_publish(timeout=5)
  909. finally:
  910. client.loop_stop()
  911. try:
  912. client.disconnect()
  913. except Exception:
  914. pass
  915. @app.get("/api/varv/power", dependencies=[Depends(require_auth)])
  916. def varv_power_get() -> dict:
  917. """Värvisensori mõõtmise olek (ainult sisselogitult)."""
  918. state = varv_read_state()
  919. return {
  920. "available": bool(VARV_MQTT_HOST),
  921. "state": state,
  922. "on": None if state is None else (state == "ON"),
  923. }
  924. @app.post("/api/varv/power", dependencies=[Depends(require_auth)])
  925. async def varv_power_set(payload: dict = Body(...)) -> dict:
  926. """Lülita värvisensori mõõtmine sisse/välja (ainult sisselogitult).
  927. OFF peatab ESP32 publitseerimise -> ClickHouse'i ei kirjutata.
  928. """
  929. on = bool(payload.get("on"))
  930. await asyncio.to_thread(varv_set_power, on)
  931. state = await asyncio.to_thread(varv_read_state, 3.0)
  932. return {"on": on, "state": state}
  933. @app.get("/api/latest")
  934. async def latest(request: Request) -> dict:
  935. readings = ch_query(
  936. f"SELECT device, "
  937. f"argMax(temperature, hour) AS temperature, "
  938. f"argMax(humidity, hour) AS humidity, "
  939. f"toString(max(hour)) AS ts_str, "
  940. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  941. f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
  942. )
  943. faults = ch_query(
  944. f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
  945. f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
  946. f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
  947. ) if is_authed(request) else []
  948. outside_rows = ch_query(
  949. f"SELECT toString(max(hour)) AS ts_str, "
  950. f"argMax(temperature, hour) AS temperature, "
  951. f"argMax(humidity, hour) AS humidity, "
  952. f"argMax(wind, hour) AS wind, "
  953. f"argMax(rain, hour) AS rain, "
  954. f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
  955. f"FROM {CH_DB}.valisilm FINAL"
  956. )
  957. outside: dict = {
  958. "temperature": None,
  959. "humidity": None,
  960. "wind": None,
  961. "rain": None,
  962. "hour": None,
  963. "t": None,
  964. }
  965. if outside_rows:
  966. r = outside_rows[0]
  967. outside = {
  968. "temperature": r.get("temperature"),
  969. "humidity": r.get("humidity"),
  970. "wind": r.get("wind"),
  971. "rain": r.get("rain"),
  972. "hour": r.get("ts_str"),
  973. "t": int(r["t"]) if r.get("t") is not None else None,
  974. }
  975. return {
  976. "readings": [
  977. {
  978. "device": r["device"],
  979. "temperature": r["temperature"],
  980. "humidity": r["humidity"],
  981. "hour": r["ts_str"],
  982. "t": int(r["t"]) if r.get("t") is not None else None,
  983. }
  984. for r in readings
  985. ],
  986. "open_faults": [
  987. {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
  988. for r in faults
  989. ],
  990. "outside": outside,
  991. }
  992. # --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
  993. def fetch_bus_departures() -> dict:
  994. """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
  995. fields = (
  996. "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
  997. "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
  998. "trip { route { shortName longName } } }"
  999. )
  1000. aliases = " ".join(
  1001. f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
  1002. )
  1003. try:
  1004. resp = requests.post(
  1005. BUS_GRAPHQL,
  1006. json={"query": "{ " + aliases + " }"},
  1007. headers={"User-Agent": UA},
  1008. timeout=18,
  1009. )
  1010. except requests.RequestException as exc:
  1011. raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
  1012. if resp.status_code != 200:
  1013. raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
  1014. try:
  1015. payload = resp.json()
  1016. except ValueError as exc:
  1017. raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
  1018. if payload.get("errors"):
  1019. msg = payload["errors"][0].get("message", "viga")
  1020. raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
  1021. departures = []
  1022. for stop in (payload.get("data") or {}).values():
  1023. if not stop or not stop.get("stoptimesWithoutPatterns"):
  1024. continue
  1025. stop_name = stop.get("name") or "Suuremõisa"
  1026. for st in stop["stoptimesWithoutPatterns"]:
  1027. service_day = st.get("serviceDay")
  1028. if service_day is None:
  1029. continue
  1030. sched_s = st.get("scheduledDeparture") or 0
  1031. real_s = st.get("realtimeDeparture")
  1032. dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
  1033. try:
  1034. dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
  1035. except (OverflowError, OSError, TypeError, ValueError):
  1036. continue
  1037. route = (st.get("trip") or {}).get("route") or {}
  1038. delay = None
  1039. if st.get("realtime") and real_s is not None:
  1040. delay = round((int(real_s) - int(sched_s)) / 60)
  1041. departures.append({
  1042. "t": int(dep_dt.timestamp() * 1000),
  1043. "time": dep_dt.strftime("%H:%M"),
  1044. "date": dep_dt.strftime("%Y-%m-%d"),
  1045. "stop": stop_name,
  1046. "route": route.get("shortName") or "",
  1047. "headsign": st.get("headsign") or "",
  1048. "realtime": bool(st.get("realtime")),
  1049. "delay_min": delay,
  1050. })
  1051. departures.sort(key=lambda d: d["t"])
  1052. now = datetime.now(TZ)
  1053. return {
  1054. "stops": BUS_STOPS,
  1055. "today": now.strftime("%Y-%m-%d"),
  1056. "departures": departures,
  1057. "updated": now.strftime("%Y-%m-%d %H:%M"),
  1058. }
  1059. @app.get("/api/buss")
  1060. async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
  1061. """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
  1062. data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
  1063. return {**data, "departures": data["departures"][:limit]}
  1064. # --- Transport: praamid (praamid.ee) ---
  1065. def _praam_events(day: str, direction: str) -> list[dict]:
  1066. try:
  1067. resp = requests.get(
  1068. PRAAM_API,
  1069. params={"direction": direction, "departure-date": day, "time-shift": "0"},
  1070. headers={"User-Agent": UA},
  1071. timeout=18,
  1072. )
  1073. except requests.RequestException:
  1074. return []
  1075. if resp.status_code != 200:
  1076. return []
  1077. try:
  1078. items = (resp.json() or {}).get("items") or []
  1079. except ValueError:
  1080. return []
  1081. out = []
  1082. for e in items:
  1083. try:
  1084. dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
  1085. except ValueError:
  1086. continue
  1087. ship = e.get("ship")
  1088. ship_code = ship.get("code") if isinstance(ship, dict) else ship
  1089. status = e.get("status") or "?"
  1090. out.append({
  1091. "uid": e.get("uid"),
  1092. "direction": direction,
  1093. "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
  1094. "date": dep_dt.strftime("%Y-%m-%d"),
  1095. "time": dep_dt.strftime("%H:%M"),
  1096. "t": int(dep_dt.timestamp() * 1000),
  1097. "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
  1098. "ship_code": ship_code,
  1099. "status": status,
  1100. "status_et": PRAAM_STATUS.get(status, status),
  1101. "disrupted": status != "CONFIRMED",
  1102. })
  1103. return out
  1104. def fetch_weather_warnings() -> list[dict]:
  1105. """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
  1106. try:
  1107. resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
  1108. except requests.RequestException:
  1109. return []
  1110. if resp.status_code != 200:
  1111. return []
  1112. text = html.unescape(resp.text)
  1113. text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
  1114. text = re.sub(r"<[^>]+>", " ", text)
  1115. text = re.sub(r"\s+", " ", text)
  1116. found, seen = [], set()
  1117. for area in SEA_AREAS:
  1118. i = 0
  1119. while True:
  1120. i = text.find(area, i)
  1121. if i < 0:
  1122. break
  1123. seg = text[max(0, i - 100): i + 700]
  1124. if any(w in seg.lower() for w in WIND_TYPES):
  1125. m = re.search(r"Kehtib[^.]{0,200}", seg)
  1126. valid = m.group(0).strip() if m else ""
  1127. key = (area, valid or seg[:80])
  1128. if key not in seen:
  1129. seen.add(key)
  1130. found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
  1131. i += len(area)
  1132. return found
  1133. def fetch_praam(days: int) -> dict:
  1134. now = datetime.now(TZ)
  1135. day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
  1136. departures = []
  1137. for day in day_list:
  1138. for direction in ("HR", "RH"):
  1139. departures.extend(_praam_events(day, direction))
  1140. departures.sort(key=lambda d: (d["date"], d["time"]))
  1141. return {
  1142. "date": day_list[0],
  1143. "today": now.strftime("%Y-%m-%d"),
  1144. "departures": departures,
  1145. "warnings": fetch_weather_warnings(),
  1146. "disrupted": sum(1 for d in departures if d["disrupted"]),
  1147. "updated": now.strftime("%Y-%m-%d %H:%M"),
  1148. }
  1149. @app.get("/api/praam")
  1150. async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
  1151. """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
  1152. return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
  1153. LOGIN_HTML = """<!doctype html>
  1154. <html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  1155. <title>Nutikodu — login</title>
  1156. <style>
  1157. body{font-family:system-ui,sans-serif;background:#0e1116;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
  1158. form{background:#161b22;padding:28px 24px;border-radius:12px;border:1px solid #30363d;min-width:280px}
  1159. h1{font-size:18px;margin:0 0 16px}
  1160. input{width:100%;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;box-sizing:border-box;font-size:16px}
  1161. button{margin-top:14px;width:100%;padding:10px;border-radius:8px;border:0;background:#1f6feb;color:#fff;font-size:16px;cursor:pointer}
  1162. .err{color:#f85149;font-size:14px;margin-top:10px;min-height:18px}
  1163. a{color:#58a6ff;font-size:13px;text-decoration:none}
  1164. </style></head><body>
  1165. <form method="post" action="/login">
  1166. <h1>🔒 Nutikodu — sisselogimine</h1>
  1167. <input type="password" name="password" placeholder="Parool" autofocus autocomplete="current-password">
  1168. <button type="submit">Logi sisse</button>
  1169. <div class="err">{{ERR}}</div>
  1170. <div style="margin-top:14px"><a href="/">← Temperatuuri leht (avalik)</a></div>
  1171. </form>
  1172. </body></html>"""
  1173. @app.get("/login")
  1174. async def login_page():
  1175. return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", ""))
  1176. @app.post("/login")
  1177. async def login_submit(request: Request, password: str = Form("")):
  1178. ip = client_ip(request)
  1179. ra = retry_after_seconds(ip)
  1180. if ra:
  1181. return HTMLResponse(
  1182. LOGIN_HTML.replace("{{ERR}}", f"Liiga palju katseid. Proovi {ra} sekundi pärast."),
  1183. status_code=429,
  1184. )
  1185. if WEB_PASSWORD and secrets.compare_digest(password, WEB_PASSWORD):
  1186. reset_login_failures(ip)
  1187. request.session["auth"] = True
  1188. return RedirectResponse("/?auth=1", status_code=303)
  1189. register_login_failure(ip)
  1190. return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", "Vale parool"), status_code=401)
  1191. @app.get("/logout")
  1192. async def logout(request: Request):
  1193. request.session.clear()
  1194. return RedirectResponse("/", status_code=303)
  1195. @app.get("/api/me")
  1196. async def me(request: Request) -> dict:
  1197. return {"authed": is_authed(request)}
  1198. def _ha_headers() -> dict:
  1199. return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
  1200. def ha_get_away() -> bool | None:
  1201. if not HA_TOKEN:
  1202. return None
  1203. try:
  1204. r = requests.get(f"{HA_URL}/api/states/{AWAY_ENTITY}", headers=_ha_headers(), timeout=8)
  1205. if r.status_code == 200:
  1206. return r.json().get("state") == "on"
  1207. except requests.RequestException:
  1208. pass
  1209. return None
  1210. def ha_set_away(on: bool) -> None:
  1211. service = "turn_on" if on else "turn_off"
  1212. r = requests.post(
  1213. f"{HA_URL}/api/services/input_boolean/{service}",
  1214. headers=_ha_headers(),
  1215. json={"entity_id": AWAY_ENTITY},
  1216. timeout=8,
  1217. )
  1218. r.raise_for_status()
  1219. @app.get("/api/away", dependencies=[Depends(require_auth)])
  1220. async def away_state() -> dict:
  1221. """Kohalolu ("kodust eemal") olek."""
  1222. state = ha_get_away()
  1223. return {"away": state, "available": state is not None}
  1224. @app.post("/api/away", dependencies=[Depends(require_auth)])
  1225. async def away_set(payload: dict = Body(...)) -> dict:
  1226. """Lülita "kodust eemal" sisse/välja (keha {"on": true|false})."""
  1227. on = bool(payload.get("on"))
  1228. try:
  1229. ha_set_away(on)
  1230. except requests.RequestException as exc:
  1231. raise HTTPException(status_code=502, detail=f"Home Assistant viga: {exc}") from exc
  1232. return {"away": on, "available": True}
  1233. @app.post("/api/varv/capture", dependencies=[Depends(require_auth)])
  1234. async def varv_capture(payload: dict = Body(default={})) -> dict:
  1235. """Oota N uut mõõtmist ja tagasta keskmine + stabiilsus."""
  1236. n = max(2, min(int(payload.get("n", 3)), 10))
  1237. timeout = max(12, min(int(payload.get("timeout", 45)), 90))
  1238. t0 = datetime.now(TZ)
  1239. epoch = int(t0.timestamp())
  1240. deadline = time.time() + timeout
  1241. rows: list[dict] = []
  1242. sql = (
  1243. f"SELECT toString(ts) AS ts, {', '.join(VARV_CALIB_FIELDS)} "
  1244. f"FROM {CH_DB}.varvus WHERE toUnixTimestamp(ts) >= {epoch} ORDER BY ts"
  1245. )
  1246. while time.time() < deadline:
  1247. await asyncio.sleep(2.0)
  1248. rows = await asyncio.to_thread(ch_query, sql)
  1249. if len(rows) >= n:
  1250. break
  1251. if len(rows) < 2:
  1252. raise HTTPException(status_code=409, detail="Mõõtmisi ei tulnud — kas sensor on sees (varvus/switch/sensor)?")
  1253. rows = rows[-n:]
  1254. mean: dict[str, float] = {}
  1255. for f in VARV_CALIB_FIELDS:
  1256. vals = [float(r[f]) for r in rows if r.get(f) is not None]
  1257. mean[f] = round(sum(vals) / len(vals), 2) if vals else 0.0
  1258. clears = [float(r["clear"]) for r in rows if r.get("clear") is not None]
  1259. cv = 0.0
  1260. if len(clears) > 1 and sum(clears) > 0:
  1261. m = sum(clears) / len(clears)
  1262. var = sum((c - m) ** 2 for c in clears) / (len(clears) - 1)
  1263. cv = (var ** 0.5) / m if m else 0.0
  1264. peak = max((float(mean.get(b) or 0.0) for b in VARV_BANDS), default=0.0)
  1265. return {
  1266. "n": len(rows),
  1267. "channels": mean,
  1268. "clear": mean.get("clear"),
  1269. "cv": round(cv, 4),
  1270. "stable": cv <= 0.08,
  1271. "saturated": peak >= 64000 or float(mean.get("clear") or 0) >= 64000,
  1272. "ts": rows[-1].get("ts"),
  1273. }
  1274. @app.get("/kalibreer")
  1275. async def kalibreer_page() -> FileResponse:
  1276. return FileResponse(STATIC_DIR / "kalibreeri.html", media_type="text/html")
  1277. @app.get("/health")
  1278. async def health() -> dict:
  1279. try:
  1280. ch_query("SELECT 1")
  1281. ch_ok = True
  1282. except HTTPException:
  1283. ch_ok = False
  1284. return {"status": "ok", "clickhouse": ch_ok}
  1285. @app.get("/")
  1286. async def index() -> FileResponse:
  1287. return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
  1288. app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
  1289. if __name__ == "__main__":
  1290. import uvicorn
  1291. uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)