ardo 2 дней назад
Родитель
Сommit
e8c6d2e904
33 измененных файлов с 11990 добавлено и 97 удалено
  1. 15 0
      dev/nutikodu-web-v2/Dockerfile
  2. 151 0
      dev/nutikodu-web-v2/README.md
  3. 1322 0
      dev/nutikodu-web-v2/app.py
  4. 386 0
      dev/nutikodu-web-v2/app.py.bak.1791389719
  5. 176 0
      dev/nutikodu-web-v2/color.py
  6. 7 0
      dev/nutikodu-web-v2/requirements.txt
  7. 1299 0
      dev/nutikodu-web-v2/static/app.js
  8. 377 0
      dev/nutikodu-web-v2/static/app.js.bak.1791389719
  9. 6 0
      dev/nutikodu-web-v2/static/icon.svg
  10. 244 0
      dev/nutikodu-web-v2/static/index.html
  11. 85 0
      dev/nutikodu-web-v2/static/index.html.bak.1791389719
  12. 12 0
      dev/nutikodu-web-v2/static/manifest.webmanifest
  13. 250 0
      dev/nutikodu-web-v2/static/style.css
  14. 34 0
      dev/nutikodu-web-v2/static/vendor/echarts.min.js
  15. 15 0
      dev/nutikodu-web.bak.20261024-2022/Dockerfile
  16. 151 0
      dev/nutikodu-web.bak.20261024-2022/README.md
  17. 1309 0
      dev/nutikodu-web.bak.20261024-2022/app.py
  18. 386 0
      dev/nutikodu-web.bak.20261024-2022/app.py.bak.1791389719
  19. 127 0
      dev/nutikodu-web.bak.20261024-2022/color.py
  20. 7 0
      dev/nutikodu-web.bak.20261024-2022/requirements.txt
  21. 1299 0
      dev/nutikodu-web.bak.20261024-2022/static/app.js
  22. 377 0
      dev/nutikodu-web.bak.20261024-2022/static/app.js.bak.1791389719
  23. 6 0
      dev/nutikodu-web.bak.20261024-2022/static/icon.svg
  24. 244 0
      dev/nutikodu-web.bak.20261024-2022/static/index.html
  25. 85 0
      dev/nutikodu-web.bak.20261024-2022/static/index.html.bak.1791389719
  26. 12 0
      dev/nutikodu-web.bak.20261024-2022/static/manifest.webmanifest
  27. 250 0
      dev/nutikodu-web.bak.20261024-2022/static/style.css
  28. 34 0
      dev/nutikodu-web.bak.20261024-2022/static/vendor/echarts.min.js
  29. 186 23
      dev/nutikodu-web/app.py
  30. 1324 0
      dev/nutikodu-web/app.py.bak.20261024-203254
  31. 1471 0
      dev/nutikodu-web/app.py.bak.20261024-2048
  32. 123 74
      dev/nutikodu-web/color.py
  33. 220 0
      dev/nutikodu-web/static/kalibreeri.html

+ 15 - 0
dev/nutikodu-web-v2/Dockerfile

@@ -0,0 +1,15 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt \
+ && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
+ && opentelemetry-bootstrap -a install
+
+COPY app.py color.py .
+COPY static ./static
+
+EXPOSE 8095
+
+CMD ["opentelemetry-instrument", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8095"]

+ 151 - 0
dev/nutikodu-web-v2/README.md

@@ -0,0 +1,151 @@
+# Nutikodu veebiliides
+
+Interaktiivne ajatelg `uus67` skeemi zigbee andurite andmetele (ClickHouse).
+FastAPI backend + üks staatiline leht (ECharts). Eestikeelne, mobiilisõbralik,
+tume/hele teema.
+
+## Ligipääs
+
+| Keskkond | URL |
+|----------|-----|
+| Avalik | https://nutikodu.odamus.com |
+| Tailscale | http://100.87.1.24:8095 · http://ohmu.tail20e23.ts.net:8095 |
+
+## Funktsioonid (v1)
+
+- Temperatuuri ja niiskuse ajatelg (kahe teljega), interaktiivne zoom (pinch/pan).
+- Perioodivalik: 24 h / 7 p / 30 p / 90 p / 1 a / Kõik + kohandatud vahemik.
+- Agregeerimine: tund / päev.
+- **Täidetud tunnid** (kui andur ei olnud kättesaadav) on koguja poolt eelmise
+  väärtusega asendatud (`taidetud = true`) ja kuvatakse graafikul **tühjade
+  ringidena** — aegreas ei teki auke.
+- **Välistemperatuur** (eraldi koguja, Open-Meteo) kuvatakse samal graafikul
+  rohelise joonena; niiskus/tuul/sademed salvestatakse andmebaasi.
+- Viimaste väärtuste kaardid + lahtiste häirete ülevaade (60 s värskendus).
+- **Vaheleht "Transport"** — tänased bussiväljumised Suuremõisa külast (peatus.ee)
+  ja Heltermaa–Rohuküla praamiajad + seisakud/tühistused (praamid.ee) ning
+  Väinamere ilmahoiatuse kontekst (RII, parim pingutus). 60 s värskendus.
+- **Vaheleht "Värv"** — AS7341 spektri-värvisensori tulemused (`uus67.varvus`):
+  praegune värv, värviruutude ruudustik, kanalite spekter (f1–f8), värvitelg
+  (viimased 24 h tunnipõhiselt) ja kromaatilisuse (x, y) hajuvus. 20 s värskendus.
+  **Sisselogitult** saab mõõtmise sisse/välja lülitada (MQTT,
+  `varvus/switch/sensor`); OFF peatab ESP32 publitseerimise → **ka ClickHouse'i
+  kirjutamise**. Sisselogitult saab ka salvestada **tume/valge referentsi**
+  (`uus67.varvus_calib`) ja vaadata **kalibreeritud** värvi
+  (R = (S−D)/(W−D)).
+- PWA-manifest (installitav telefonis), `noindex`.
+
+## API
+
+| Otspunkt | Kirjeldus |
+|----------|-----------|
+| `GET /api/meta` | seadmed, tz, andmete ulatus |
+| `GET /api/temperatuur?from=&to=&device=&agg=hour\|day` | aegrida |
+| `GET /api/valisilm?from=&to=&agg=hour\|day` | välistemperatuuri aegrida (temp, niiskus, tuul, sademed) |
+| `GET /api/ukseandur?from=&to=&device=` | 🔒 ukseolekute sündmused |
+| `GET /api/vead?from=&to=&device=` | 🔒 häirete sündmused |
+| `GET /api/liikumine?from=&to=&device=` | 🔒 liikumise/kohalolu sündmused |
+| `GET /api/energia?from=&to=&device=&agg=hour\|day` | 🔒 pistikute energiatarve |
+| `GET /api/uus67_latest` | 🔒 viimane energia + liikumine |
+| `GET /api/latest` | viimased väärtused (lahtised häired ainult sisselogitult) |
+| `GET /api/buss?limit=12` | Suuremõisa küla järgmised bussiväljumised (peatus.ee, avalik) |
+| `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (praamid.ee, avalik) |
+| `GET /api/varv/latest?limit=60` | AS7341 viimased mõõtmised (rgb, kanalid f1–f8, kromaatilisus) — avalik |
+| `GET /api/varv/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
+| `GET /api/varv/power` | 🔒 värvisensori mõõtmise olek (ON/OFF) |
+| `POST /api/varv/power` | 🔒 lülita mõõtmine sisse/välja (keha `{"on":true\|false}`) |
+| `GET /api/varv/calib` | tume/valge referents (avalik) |
+| `POST /api/varv/calib` | 🔒 salvesta tume/valge referents (keha `{"kind":"dark"\|"white","last":5}`) |
+| `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
+| `GET /health` | tervis |
+
+Ajavahemikud kujul `YYYY-MM-DD[ HH:MM[:SS]]` või ISO (T-eraldaja). Vaikimisi viimased 7 päeva.
+
+**🔒 = nõuab autentimist** (Bearer-token automaatikale või sessiooniküpsis brauserile). Ülejäänud on avalikud.
+
+**Värvisensori juhtimine** vajab keskkonnamuutujaid (`dev/.env`):
+`VARV_MQTT_HOST=mqtt.odamus.com`, `VARV_MQTT_PORT=8883`, `VARV_MQTT_TLS=true`,
+`VARV_MQTT_USER`, `VARV_MQTT_PASS` (edge-brokeri konto).
+
+## Autentimine
+
+- **Brauser:** ava `/login`, sisesta parool → allkirjastatud sessiooniküpsis
+  (`HttpOnly`, `SameSite=Lax`). Välja: `/logout`.
+- **API / automaatika:** saada päis `Authorization: Bearer <WEB_ACCESS_TOKEN>`.
+- **Kaitstud:** kõik `ukseandur` / `vead` / `liikumine` / `energia` / `uus67_latest`
+  päringud. Avalikud on ainult temp/niiskus/väli/meta.
+- **Login-rate-limit:** 5 ebaõnnestunud katset / 300 s / IP (libisev aken).
+
+### Näide (API-integratsioon, nt Uptime Kuma, curl, skript)
+
+```bash
+# Kaitstud lõpp-punkt Bearer-tokeniga:
+curl -H "Authorization: Bearer $WEB_ACCESS_TOKEN" \
+     "http://100.87.1.24:8095/api/uus67_latest"
+
+# Tervisekontroll (avalik, Uptime Kuma monitor):
+curl "http://100.87.1.24:8095/health"
+```
+
+`WEB_ACCESS_TOKEN` väärtus on `nutikodu-web` `.env`-is (ohmu:
+`/opt/docker-compose/dev/.env`). Ära pane tokenit URL-i ega jaga avalikult.
+
+## Keskkonnamuutujad
+
+| Muutuja | Vaikimisi | Kirjeldus |
+|---------|-----------|-----------|
+| `CH_URL` | `http://clickhouse:8123` | ClickHouse HTTP |
+| `CH_DB` | `uus67` | andmebaas |
+| `CH_USER` / `CH_PASS` | `zigbee` / – | ainult SELECT `uus67.*` |
+| `WEB_PASSWORD` | tühi | brauseri sisselogimise parool (`/login`) |
+| `WEB_ACCESS_TOKEN` | tühi | API Bearer-token (automaatikaks) |
+| `WEB_SECRET_KEY` | juhuslik | sessiooniküpsise allkirjastamise võti (**sea püsivaks!**) |
+| `SESSION_MAX_AGE` | `604800` | sessiooni kehtivus sekundites (7 p) |
+| `LOGIN_MAX_ATTEMPTS` | `5` | ebaõnnestunud logimiskatsed enne blokeeringut |
+| `LOGIN_WINDOW_SECONDS` | `300` | logimise blokeerimise aken (sek) |
+| `BUS_GRAPHQL` | peatus.ee Digitransit | bussigraafiku GraphQL API |
+| `BUS_STOPS` | `estonia:24703,estonia:27213` | Suuremõisa küla GTFS peatus-ID-d (komaga) |
+| `PRAAM_API` | `https://www.praamid.ee/online/events` | praamigraafiku API (HR=Heltermaa→Rohuküla) |
+| `TRANSPORT_TTL` | `60` | bussi/praami väliste päringute vahemälu (sek) |
+
+## Struktuur
+
+```
+nutikodu/web/
+├── app.py                 # FastAPI backend (ClickHouse HTTP, JSONEachRow)
+├── requirements.txt
+├── Dockerfile             # python:3.12-slim, port 8095
+└── static/
+    ├── index.html         # üks leht, eestikeelne
+    ├── style.css          # light/dark, mobiil-first
+    ├── app.js             # ECharts ajatelg, perioodivalik, teema
+    ├── manifest.webmanifest
+    ├── icon.svg
+    └── vendor/echarts.min.js
+```
+
+## Arendus / juurutus
+
+Kood elab workspace'is (`nutikodu/web/`) ja on juurutatud ohmu'le
+`/opt/docker-compose/dev/nutikodu-web/` (teenus `dev/docker-compose.yml`-is,
+võrkudes `main_ai_network` + `prod_net`).
+
+```bash
+# Muudatuse järel (ohmu):
+scp app.py static/* ohmu:/opt/docker-compose/dev/nutikodu-web/...
+docker compose -f /opt/docker-compose/dev/docker-compose.yml up -d --build nutikodu-web
+docker logs -f nutikodu-web
+```
+
+### Avalik ligipääs (2026-09-17)
+
+- **DNS:** DigitalOcean A-kirje `nutikodu.odamus.com` → `188.166.84.16`.
+- **Edge Caddy** (`kanad`, `/opt/edge/Caddyfile`): `nutikodu.odamus.com { reverse_proxy http://100.91.118.16:80 }`.
+- **Sisemine Caddy** (ohmu `prod_caddy`, `/opt/prod/Caddyfile`): `nutikodu.odamus.com:80 { reverse_proxy nutikodu-web:8095 }`.
+- TLS väljastab edge automaatselt.
+
+## Edasi (järgmised iteratsioonid)
+
+- Ukseanduri olekuribad ajateljel (`/api/ukseandur` on valmis).
+- Häirete markerid/annotatsioonid (`/api/vead` on valmis).
+- Autentimine: **tehtud 2026-10-07** (sessioon + Bearer-token; vt "Autentimine").

+ 1322 - 0
dev/nutikodu-web-v2/app.py

@@ -0,0 +1,1322 @@
+"""Nutikodu andmete veebiliides (FastAPI).
+
+Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
+  GET /                      -> veebirakendus (static/index.html)
+  GET /api/meta              -> seadmed, tz, andmete ulatus
+  GET /api/temperatuur       -> aegrida (temp + niiskus), agg=hour|day
+  GET /api/ukseandur         -> ukseolekute sündmused (tulevikuks)
+  GET /api/vead              -> häirete sündmused (tulevikuks)
+  GET /api/latest            -> viimased väärtused + lahtised häired
+  GET /health
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hmac
+import html
+import secrets
+import json
+import os
+import re
+import threading
+import time
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import color
+import numpy as np
+import paho.mqtt.client as mqtt
+import requests
+from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
+from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+# Värvisensori juhtimine (MQTT, edge broker). OFF peatab ESP32 publitseerimise
+# -> ClickHouse'i ei kirjutata.
+VARV_MQTT_HOST = os.environ.get("VARV_MQTT_HOST", "")
+VARV_MQTT_PORT = int(os.environ.get("VARV_MQTT_PORT", "8883"))
+VARV_MQTT_TLS = os.environ.get("VARV_MQTT_TLS", "true").strip().lower() in ("1", "true", "yes", "on")
+VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
+VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
+VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
+VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
+VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
+VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
+VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
+
+WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
+WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
+WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
+HA_URL = os.environ.get("HA_URL", "http://100.87.1.24:8123").rstrip("/")
+HA_TOKEN = os.environ.get("HA_TOKEN", "")
+AWAY_ENTITY = os.environ.get("AWAY_ENTITY", "input_boolean.kodust_eemal")
+SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "604800"))
+LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
+LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
+
+DEFAULT_RANGE_DAYS = 7
+MAX_RANGE_DAYS = 3660
+
+# --- Transport (bussid + praamid) ---
+BUS_GRAPHQL = os.environ.get(
+    "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
+).rstrip("/")
+BUS_STOPS = [s.strip() for s in os.environ.get(
+    "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
+PRAAM_API = os.environ.get(
+    "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
+RII_HOIATUS_URL = os.environ.get(
+    "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
+)
+TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
+UA = "Mozilla/5.0 (compatible; Nutikodu-web/1.0)"
+
+PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
+SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
+PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
+SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
+WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
+
+_cache: dict[str, tuple[float, object]] = {}
+_cache_lock = threading.Lock()
+
+
+def cached(key: str, ttl: int, producer):
+    """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
+    now = time.monotonic()
+    with _cache_lock:
+        hit = _cache.get(key)
+        if hit and now - hit[0] < ttl:
+            return hit[1]
+    value = producer()
+    with _cache_lock:
+        _cache[key] = (time.monotonic(), value)
+    return value
+
+
+app = FastAPI(title="Nutikodu", version="0.2.0")
+app.add_middleware(
+    SessionMiddleware,
+    secret_key=WEB_SECRET_KEY,
+    https_only=False,
+    same_site="lax",
+    max_age=SESSION_MAX_AGE,
+)
+
+
+@app.middleware("http")
+async def add_no_cache(request: Request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+_login_failures: dict[str, list[float]] = defaultdict(list)
+_login_lock = threading.Lock()
+
+
+def client_ip(request: Request) -> str:
+    forwarded = request.headers.get("x-forwarded-for")
+    if forwarded:
+        return forwarded.split(",")[0].strip()
+    return request.client.host if request.client else "?"
+
+
+def retry_after_seconds(ip: str) -> int:
+    now = time.monotonic()
+    with _login_lock:
+        history = [t for t in _login_failures.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
+        _login_failures[ip] = history
+        if len(history) >= LOGIN_MAX_ATTEMPTS:
+            return int(LOGIN_WINDOW_SECONDS - (now - history[0])) + 1
+    return 0
+
+
+def register_login_failure(ip: str) -> None:
+    with _login_lock:
+        _login_failures[ip].append(time.monotonic())
+
+
+def reset_login_failures(ip: str) -> None:
+    with _login_lock:
+        _login_failures.pop(ip, None)
+
+
+def is_authed(request: Request) -> bool:
+    """Kas päring on autenditud: Bearer token VÕI sessiooniküpsis."""
+    auth = request.headers.get("authorization", "")
+    if auth.lower().startswith("bearer "):
+        token = auth[7:].strip()
+        if WEB_ACCESS_TOKEN and secrets.compare_digest(token, WEB_ACCESS_TOKEN):
+            return True
+    try:
+        if request.session.get("auth") is True:
+            return True
+    except Exception:
+        pass
+    return False
+
+
+def require_auth(request: Request) -> None:
+    if not is_authed(request):
+        raise HTTPException(
+            status_code=401,
+            detail="Autentimine nõutud (Bearer token või sisselogimine)",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+
+def parse_dt(value: str | None, default: datetime) -> str:
+    """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
+    if not value:
+        return default.strftime("%Y-%m-%d %H:%M:%S")
+    cleaned = value.strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
+        except ValueError:
+            continue
+    raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
+
+
+def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
+    now = datetime.now(TZ)
+    to_s = parse_dt(to, now)
+    to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
+    from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
+    from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
+    if from_dt > to_dt:
+        raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
+    if (to_dt - from_dt).days > MAX_RANGE_DAYS:
+        raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
+    return from_s, to_s
+
+
+@app.get("/api/meta")
+async def meta() -> dict:
+    devices = [row["device"] for row in ch_query(
+        f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
+    )]
+    bounds = ch_query(
+        f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
+        f"FROM {CH_DB}.temperatuur"
+    )
+    first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
+    return {
+        "tz": "Europe/Tallinn",
+        "db": CH_DB,
+        "devices": devices,
+        "min": first["min_hour"],
+        "max": first["max_hour"],
+        "rows": first["rows"],
+        "default_range_days": DEFAULT_RANGE_DAYS,
+    }
+
+
+@app.get("/api/temperatuur")
+async def temperatuur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
+        params,
+    )
+
+    series: dict[str, dict] = {}
+    for row in rows:
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/valisilm")
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
+async def ukseandur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
+        f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/vead", dependencies=[Depends(require_auth)])
+async def vead(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
+        f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "tyyp": r["tyyp"],
+                "olek": r["olek"],
+                "detail": r["detail"],
+                "vaartus": r["vaartus"],
+            }
+            for r in rows
+        ],
+    }
+
+
+
+
+@app.get("/api/liikumine", dependencies=[Depends(require_auth)])
+async def liikumine(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, occupancy, illumination, olek "
+        f"FROM {CH_DB}.liikumine WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "occupancy": bool(r["occupancy"]),
+                "illumination": r["illumination"],
+                "olek": r["olek"],
+            }
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/energia", dependencies=[Depends(require_auth)])
+async def energia(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(ts))) * 1000 AS t, device, "
+            "round(avg(power), 2) AS power, round(avg(voltage), 1) AS voltage, "
+            "round(avg(current), 3) AS current, max(energy) AS energy"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, "
+            "power, voltage, current, energy"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.energia WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, dict] = {}
+    for row in rows:
+        s2 = series.setdefault(
+            row["device"], {"t": [], "power": [], "voltage": [], "current": [], "energy": []}
+        )
+        s2["t"].append(int(row["t"]))
+        s2["power"].append(row["power"])
+        s2["voltage"].append(row["voltage"])
+        s2["current"].append(row["current"])
+        s2["energy"].append(row["energy"])
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+
+
+@app.get("/api/uus67_latest", dependencies=[Depends(require_auth)])
+async def uus67_latest() -> dict:
+    energy = ch_query(
+        f"SELECT device, argMax(power, ts) AS power, argMax(voltage, ts) AS voltage, "
+        f"argMax(current, ts) AS current, argMax(energy, ts) AS energy, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.energia GROUP BY device ORDER BY device"
+    )
+    motion = ch_query(
+        f"SELECT device, argMax(occupancy, ts) AS occupancy, "
+        f"argMax(illumination, ts) AS illumination, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.liikumine GROUP BY device ORDER BY device"
+    )
+    return {
+        "energy": [
+            {
+                "device": r["device"],
+                "power": r.get("power"),
+                "voltage": r.get("voltage"),
+                "current": r.get("current"),
+                "energy": r.get("energy"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in energy
+        ],
+        "motion": [
+            {
+                "device": r["device"],
+                "occupancy": bool(r.get("occupancy")),
+                "illumination": r.get("illumination"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in motion
+        ],
+    }
+
+
+# --- Värvisensor (uus67.varvus, AS7341) ---
+
+
+def varv_load_calib() -> dict:
+    """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
+    try:
+        rows = ch_query(
+            f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
+            f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
+        )
+    except HTTPException:
+        return {}
+    out: dict[str, dict] = {}
+    for r in rows:
+        out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
+    return out
+
+
+def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
+    """R = (S - D) / (W - D) kanali kohta."""
+    out: dict[str, float] = {}
+    for band in VARV_BANDS:
+        v = channels.get(band)
+        d = float(dark.get(band) or 0.0)
+        w = float(white.get(band) or 0.0)
+        if v is not None and (w - d) > 0:
+            out[band] = max(0.0, (float(v) - d) / (w - d))
+    return out
+
+
+# --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
+
+
+def varv_load_ccm() -> list[list[float]] | None:
+    try:
+        rows = ch_query(
+            f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
+            f"ORDER BY updated_at DESC LIMIT 1"
+        )
+    except HTTPException:
+        return None
+    if not rows:
+        return None
+    try:
+        m = json.loads(rows[0]["matrix"])
+        if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
+            return [[float(x) for x in row] for row in m]
+    except Exception:
+        return None
+    return None
+
+
+def _srgb_to_linear(v: float) -> float:
+    c = v / 255.0
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb8(c: float) -> int:
+    c = max(0.0, min(1.0, c))
+    v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+    return int(round(255 * max(0.0, min(1.0, v))))
+
+
+def varv_apply_ccm(matrix: list[list[float]], channels: dict, dark: dict | None = None, white: dict | None = None) -> str:
+    src = varv_reflectance(channels, dark or {}, white) if white else channels
+    vec = np.array([float(src.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+    lin = np.array(matrix, dtype=float) @ vec
+    r = _linear_to_srgb8(float(lin[0]))
+    g = _linear_to_srgb8(float(lin[1]))
+    b = _linear_to_srgb8(float(lin[2]))
+    return f"#{r:02X}{g:02X}{b:02X}"
+
+
+@app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
+async def varv_ref_set(payload: dict = Body(...)) -> dict:
+    """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
+    name = str(payload.get("name", "")).strip().lower()
+    hexv = str(payload.get("hex", "")).strip().lstrip("#")
+    if len(hexv) != 6:
+        raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
+    try:
+        tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
+    except ValueError:
+        raise HTTPException(status_code=422, detail="vigane hex")
+    last = max(1, min(int(payload.get("last", 5)), 30))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+    total = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if (tr + tg + tb) >= 10 and total < 300:
+        raise HTTPException(
+            status_code=422,
+            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.",
+        )
+    row = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_TABLE} FORMAT JSONEachRow"},
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/ref")
+async def varv_ref_get() -> dict:
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    return {"refs": rows}
+
+
+@app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
+async def varv_ccm_fit() -> dict:
+    """Sobita 3x8 maatriks (kanalid -> lineaarne sRGB) referentsidest."""
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    if len(rows) < 3:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 3 referentsvärvi (soovitav 6-8).")
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+
+    def _feat(r):
+        ch = {b: r.get(b) for b in VARV_BANDS}
+        if white:
+            ch = varv_reflectance(ch, dark or {}, white)
+        return [float(ch.get(b) or 0.0) for b in VARV_BANDS]
+
+    C = np.array([_feat(r) for r in rows], dtype=float)
+    T = np.array(
+        [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
+         for r in rows],
+        dtype=float,
+    )
+    # Ridge-regulaarsus: 8x8 süsteem on tinglikult haige (cond ~1e7) — ilma
+    # regulaarsuseta maatriks overfitib ja uued mõõtmised annavad rämpsu.
+    # alpha=1e-5 valitud leave-one-out testiga.
+    CtC = C.T @ C
+    CtT = C.T @ T
+    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
+    M = np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)  # (8,3)
+    matrix = M.T.tolist()  # (3,8)
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
+        },
+        data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"n_refs": len(rows), "matrix": matrix}
+
+
+@app.get("/api/varv/ccm")
+async def varv_ccm_get() -> dict:
+    m = varv_load_ccm()
+    return {"matrix": m, "ready": m is not None}
+
+
+@app.get("/api/varv/latest")
+async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
+    """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
+    cols = ", ".join(VARV_BANDS)
+    rows = ch_query(
+        f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
+        f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
+        f"{cols}, clear, nir, gain "
+        f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
+    )
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    ready = bool(dark and white)
+    ccm = varv_load_ccm()
+    out = []
+    for r in rows:
+        bands = {b: r.get(b) for b in VARV_BANDS}
+        valid, reason = color.quality(bands, dark)
+        raw = color.analyze(bands, dark=dark)
+        item = {
+            "ts": r["ts"],
+            "t": int(r["t"]),
+            "rgb_hex": raw["rgb_hex"],
+            "r": raw["r"],
+            "g": raw["g"],
+            "b": raw["b"],
+            "x_chrom": raw["x_chrom"],
+            "y_chrom": raw["y_chrom"],
+            "bands": bands,
+            "clear": r.get("clear"),
+            "nir": r.get("nir"),
+            "gain": r.get("gain"),
+            "valid": valid,
+            "reason": reason,
+        }
+        if ready:
+            cc = color.analyze(bands, dark=dark, white=white)
+            item["rgb_hex_cal"] = cc["rgb_hex"]
+            item["x_chrom_cal"] = cc["x_chrom"]
+            item["y_chrom_cal"] = cc["y_chrom"]
+        if ccm:
+            item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands, dark, white)
+        out.append(item)
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
+
+
+@app.get("/api/varv/calib")
+async def varv_calib_get() -> dict:
+    """Tume/valge referents (avalik lugemine)."""
+    calib = varv_load_calib()
+    return {
+        "dark": calib.get("dark"),
+        "white": calib.get("white"),
+        "ready": bool(calib.get("dark") and calib.get("white")),
+    }
+
+
+@app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
+async def varv_calib_set(payload: dict = Body(...)) -> dict:
+    """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
+    kind = str(payload.get("kind", "")).strip().lower()
+    if kind not in ("dark", "white"):
+        raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
+    last = max(1, min(int(payload.get("last", 5)), 60))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    # Stabiilsuse kontroll: viimased mõõtmised peavad olema samast olekust.
+    totals = [
+        sum(float(r[b]) for b in VARV_BANDS if r.get(b) is not None)
+        for r in rows
+    ]
+    tmax = max(totals) if totals else 0.0
+    if tmax > 0 and (tmax - min(totals)) / tmax > 0.2:
+        raise HTTPException(
+            status_code=422,
+            detail="Mõõtmine pole stabiliseerunud (viimased mõõtmised erinevad). Oota ~30 s pärast katmise/valge pinna muutust ja proovi uuesti.",
+        )
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+
+    # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
+    total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if total_new <= 0:
+        raise HTTPException(status_code=422, detail="Mõõtmine on null — andur on pime või andmed puuduvad.")
+    calib = varv_load_calib()
+    other = calib.get("white" if kind == "dark" else "dark")
+    if other:
+        total_other = sum(float(other.get(b) or 0.0) for b in VARV_BANDS)
+        if kind == "white" and total_new <= total_other * 1.05:
+            raise HTTPException(
+                status_code=422,
+                detail="Valge ja tume on liiga sarnased (valge pole tumedast heledam). Kontrolli: tume = andur kaetud; valge = valge pind valgustatud, sama geomeetria.",
+            )
+        if kind == "dark" and total_new >= total_other * 0.95:
+            raise HTTPException(
+                status_code=422,
+                detail="Tume ja valge on liiga sarnased (tume pole valgest tumedem). Kontrolli, et andur oli täiesti kaetud.",
+            )
+    row = {"kind": kind, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"kind": kind, "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/history")
+async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
+    """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
+    rows = ch_query(
+        f"SELECT toString(toStartOfHour(ts)) AS bucket, "
+        f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
+        f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
+        f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
+        f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
+        f"GROUP BY toStartOfHour(ts) ORDER BY t"
+    )
+    out = []
+    for r in rows:
+        rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
+        out.append({
+            "ts": r["bucket"],
+            "t": int(r["t"]),
+            "r": rv, "g": gv, "b": bv,
+            "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "n": int(r.get("n") or 0),
+        })
+    return {"hours": hours, "rows": out}
+
+
+# --- Värvisensori mõõtmise sisse/välja lüliti (MQTT, ainult sisselogitult) ---
+
+
+def _varv_mqtt_client() -> mqtt.Client:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
+    if VARV_MQTT_TLS:
+        client.tls_set()
+    if VARV_MQTT_USER:
+        client.username_pw_set(VARV_MQTT_USER, VARV_MQTT_PASS)
+    return client
+
+
+def varv_read_state(timeout: float = 4.0) -> str | None:
+    """Loe lüliti retained olek edge-brokerist (lühiajaline ühendus)."""
+    if not VARV_MQTT_HOST:
+        return None
+    box: dict[str, str] = {}
+
+    def on_connect(c, userdata, flags, rc, props=None):
+        if rc == 0:
+            c.subscribe(VARV_STATE_TOPIC)
+
+    def on_message(c, userdata, msg):
+        box["state"] = msg.payload.decode().strip()
+
+    client = _varv_mqtt_client()
+    client.on_connect = on_connect
+    client.on_message = on_message
+    try:
+        client.connect_async(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline and "state" not in box:
+            time.sleep(0.05)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+    state = box.get("state")
+    return state if state in ("ON", "OFF") else None
+
+
+def varv_set_power(on: bool) -> None:
+    if not VARV_MQTT_HOST:
+        raise HTTPException(status_code=503, detail="Värvisensori MQTT pole seadistatud")
+    client = _varv_mqtt_client()
+    try:
+        client.connect(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        info = client.publish(VARV_CMD_TOPIC, "ON" if on else "OFF", qos=1, retain=True)
+        info.wait_for_publish(timeout=5)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+
+
+@app.get("/api/varv/power", dependencies=[Depends(require_auth)])
+def varv_power_get() -> dict:
+    """Värvisensori mõõtmise olek (ainult sisselogitult)."""
+    state = varv_read_state()
+    return {
+        "available": bool(VARV_MQTT_HOST),
+        "state": state,
+        "on": None if state is None else (state == "ON"),
+    }
+
+
+@app.post("/api/varv/power", dependencies=[Depends(require_auth)])
+async def varv_power_set(payload: dict = Body(...)) -> dict:
+    """Lülita värvisensori mõõtmine sisse/välja (ainult sisselogitult).
+
+    OFF peatab ESP32 publitseerimise -> ClickHouse'i ei kirjutata.
+    """
+    on = bool(payload.get("on"))
+    await asyncio.to_thread(varv_set_power, on)
+    state = await asyncio.to_thread(varv_read_state, 3.0)
+    return {"on": on, "state": state}
+
+
+@app.get("/api/latest")
+async def latest(request: Request) -> dict:
+    readings = ch_query(
+        f"SELECT device, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"toString(max(hour)) AS ts_str, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
+    )
+    faults = ch_query(
+        f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
+        f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
+        f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
+    ) if is_authed(request) else []
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {
+        "readings": [
+            {
+                "device": r["device"],
+                "temperature": r["temperature"],
+                "humidity": r["humidity"],
+                "hour": r["ts_str"],
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in readings
+        ],
+        "open_faults": [
+            {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
+            for r in faults
+        ],
+        "outside": outside,
+    }
+
+
+
+# --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
+
+
+def fetch_bus_departures() -> dict:
+    """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
+    fields = (
+        "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
+        "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
+        "trip { route { shortName longName } } }"
+    )
+    aliases = " ".join(
+        f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
+    )
+    try:
+        resp = requests.post(
+            BUS_GRAPHQL,
+            json={"query": "{ " + aliases + " }"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
+    try:
+        payload = resp.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
+    if payload.get("errors"):
+        msg = payload["errors"][0].get("message", "viga")
+        raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
+
+    departures = []
+    for stop in (payload.get("data") or {}).values():
+        if not stop or not stop.get("stoptimesWithoutPatterns"):
+            continue
+        stop_name = stop.get("name") or "Suuremõisa"
+        for st in stop["stoptimesWithoutPatterns"]:
+            service_day = st.get("serviceDay")
+            if service_day is None:
+                continue
+            sched_s = st.get("scheduledDeparture") or 0
+            real_s = st.get("realtimeDeparture")
+            dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
+            try:
+                dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
+            except (OverflowError, OSError, TypeError, ValueError):
+                continue
+            route = (st.get("trip") or {}).get("route") or {}
+            delay = None
+            if st.get("realtime") and real_s is not None:
+                delay = round((int(real_s) - int(sched_s)) / 60)
+            departures.append({
+                "t": int(dep_dt.timestamp() * 1000),
+                "time": dep_dt.strftime("%H:%M"),
+                "date": dep_dt.strftime("%Y-%m-%d"),
+                "stop": stop_name,
+                "route": route.get("shortName") or "",
+                "headsign": st.get("headsign") or "",
+                "realtime": bool(st.get("realtime")),
+                "delay_min": delay,
+            })
+    departures.sort(key=lambda d: d["t"])
+    now = datetime.now(TZ)
+    return {
+        "stops": BUS_STOPS,
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/buss")
+async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
+    """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
+    data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
+    return {**data, "departures": data["departures"][:limit]}
+
+
+# --- Transport: praamid (praamid.ee) ---
+
+
+def _praam_events(day: str, direction: str) -> list[dict]:
+    try:
+        resp = requests.get(
+            PRAAM_API,
+            params={"direction": direction, "departure-date": day, "time-shift": "0"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    try:
+        items = (resp.json() or {}).get("items") or []
+    except ValueError:
+        return []
+    out = []
+    for e in items:
+        try:
+            dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
+        except ValueError:
+            continue
+        ship = e.get("ship")
+        ship_code = ship.get("code") if isinstance(ship, dict) else ship
+        status = e.get("status") or "?"
+        out.append({
+            "uid": e.get("uid"),
+            "direction": direction,
+            "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
+            "date": dep_dt.strftime("%Y-%m-%d"),
+            "time": dep_dt.strftime("%H:%M"),
+            "t": int(dep_dt.timestamp() * 1000),
+            "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
+            "ship_code": ship_code,
+            "status": status,
+            "status_et": PRAAM_STATUS.get(status, status),
+            "disrupted": status != "CONFIRMED",
+        })
+    return out
+
+
+def fetch_weather_warnings() -> list[dict]:
+    """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
+    try:
+        resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    text = html.unescape(resp.text)
+    text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = re.sub(r"\s+", " ", text)
+    found, seen = [], set()
+    for area in SEA_AREAS:
+        i = 0
+        while True:
+            i = text.find(area, i)
+            if i < 0:
+                break
+            seg = text[max(0, i - 100): i + 700]
+            if any(w in seg.lower() for w in WIND_TYPES):
+                m = re.search(r"Kehtib[^.]{0,200}", seg)
+                valid = m.group(0).strip() if m else ""
+                key = (area, valid or seg[:80])
+                if key not in seen:
+                    seen.add(key)
+                    found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
+            i += len(area)
+    return found
+
+
+def fetch_praam(days: int) -> dict:
+    now = datetime.now(TZ)
+    day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
+    departures = []
+    for day in day_list:
+        for direction in ("HR", "RH"):
+            departures.extend(_praam_events(day, direction))
+    departures.sort(key=lambda d: (d["date"], d["time"]))
+    return {
+        "date": day_list[0],
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "warnings": fetch_weather_warnings(),
+        "disrupted": sum(1 for d in departures if d["disrupted"]),
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/praam")
+async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
+    """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
+    return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
+
+
+LOGIN_HTML = """<!doctype html>
+<html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Nutikodu — login</title>
+<style>
+  body{font-family:system-ui,sans-serif;background:#0e1116;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
+  form{background:#161b22;padding:28px 24px;border-radius:12px;border:1px solid #30363d;min-width:280px}
+  h1{font-size:18px;margin:0 0 16px}
+  input{width:100%;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;box-sizing:border-box;font-size:16px}
+  button{margin-top:14px;width:100%;padding:10px;border-radius:8px;border:0;background:#1f6feb;color:#fff;font-size:16px;cursor:pointer}
+  .err{color:#f85149;font-size:14px;margin-top:10px;min-height:18px}
+  a{color:#58a6ff;font-size:13px;text-decoration:none}
+</style></head><body>
+<form method="post" action="/login">
+  <h1>🔒 Nutikodu — sisselogimine</h1>
+  <input type="password" name="password" placeholder="Parool" autofocus autocomplete="current-password">
+  <button type="submit">Logi sisse</button>
+  <div class="err">{{ERR}}</div>
+  <div style="margin-top:14px"><a href="/">← Temperatuuri leht (avalik)</a></div>
+</form>
+</body></html>"""
+
+
+@app.get("/login")
+async def login_page():
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", ""))
+
+
+@app.post("/login")
+async def login_submit(request: Request, password: str = Form("")):
+    ip = client_ip(request)
+    ra = retry_after_seconds(ip)
+    if ra:
+        return HTMLResponse(
+            LOGIN_HTML.replace("{{ERR}}", f"Liiga palju katseid. Proovi {ra} sekundi pärast."),
+            status_code=429,
+        )
+    if WEB_PASSWORD and secrets.compare_digest(password, WEB_PASSWORD):
+        reset_login_failures(ip)
+        request.session["auth"] = True
+        return RedirectResponse("/?auth=1", status_code=303)
+    register_login_failure(ip)
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", "Vale parool"), status_code=401)
+
+
+@app.get("/logout")
+async def logout(request: Request):
+    request.session.clear()
+    return RedirectResponse("/", status_code=303)
+
+
+@app.get("/api/me")
+async def me(request: Request) -> dict:
+    return {"authed": is_authed(request)}
+
+
+
+def _ha_headers() -> dict:
+    return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
+
+
+def ha_get_away() -> bool | None:
+    if not HA_TOKEN:
+        return None
+    try:
+        r = requests.get(f"{HA_URL}/api/states/{AWAY_ENTITY}", headers=_ha_headers(), timeout=8)
+        if r.status_code == 200:
+            return r.json().get("state") == "on"
+    except requests.RequestException:
+        pass
+    return None
+
+
+def ha_set_away(on: bool) -> None:
+    service = "turn_on" if on else "turn_off"
+    r = requests.post(
+        f"{HA_URL}/api/services/input_boolean/{service}",
+        headers=_ha_headers(),
+        json={"entity_id": AWAY_ENTITY},
+        timeout=8,
+    )
+    r.raise_for_status()
+
+
+@app.get("/api/away", dependencies=[Depends(require_auth)])
+async def away_state() -> dict:
+    """Kohalolu ("kodust eemal") olek."""
+    state = ha_get_away()
+    return {"away": state, "available": state is not None}
+
+
+@app.post("/api/away", dependencies=[Depends(require_auth)])
+async def away_set(payload: dict = Body(...)) -> dict:
+    """Lülita "kodust eemal" sisse/välja (keha {"on": true|false})."""
+    on = bool(payload.get("on"))
+    try:
+        ha_set_away(on)
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Home Assistant viga: {exc}") from exc
+    return {"away": on, "available": True}
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)

+ 386 - 0
dev/nutikodu-web-v2/app.py.bak.1791389719

@@ -0,0 +1,386 @@
+"""Nutikodu andmete veebiliides (FastAPI).
+
+Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
+  GET /                      -> veebirakendus (static/index.html)
+  GET /api/meta              -> seadmed, tz, andmete ulatus
+  GET /api/temperatuur       -> aegrida (temp + niiskus), agg=hour|day
+  GET /api/ukseandur         -> ukseolekute sündmused (tulevikuks)
+  GET /api/vead              -> häirete sündmused (tulevikuks)
+  GET /api/latest            -> viimased väärtused + lahtised häired
+  GET /health
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import requests
+from fastapi import Depends, FastAPI, HTTPException, Query, Request
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
+
+DEFAULT_RANGE_DAYS = 7
+MAX_RANGE_DAYS = 3660
+
+app = FastAPI(title="Nutikodu", version="0.2.0")
+
+
+@app.middleware("http")
+async def add_no_cache(request: Request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+def check_token(request: Request) -> None:
+    if not WEB_ACCESS_TOKEN:
+        return
+    token = request.headers.get("x-access-token") or request.query_params.get("token")
+    if token != WEB_ACCESS_TOKEN:
+        raise HTTPException(status_code=403, detail="Vale või puuduv access token")
+
+
+def parse_dt(value: str | None, default: datetime) -> str:
+    """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
+    if not value:
+        return default.strftime("%Y-%m-%d %H:%M:%S")
+    cleaned = value.strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
+        except ValueError:
+            continue
+    raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
+
+
+def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
+    now = datetime.now(TZ)
+    to_s = parse_dt(to, now)
+    to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
+    from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
+    from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
+    if from_dt > to_dt:
+        raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
+    if (to_dt - from_dt).days > MAX_RANGE_DAYS:
+        raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
+    return from_s, to_s
+
+
+@app.get("/api/meta", dependencies=[Depends(check_token)])
+async def meta() -> dict:
+    devices = [row["device"] for row in ch_query(
+        f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
+    )]
+    bounds = ch_query(
+        f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
+        f"FROM {CH_DB}.temperatuur"
+    )
+    first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
+    return {
+        "tz": "Europe/Tallinn",
+        "db": CH_DB,
+        "devices": devices,
+        "min": first["min_hour"],
+        "max": first["max_hour"],
+        "rows": first["rows"],
+        "default_range_days": DEFAULT_RANGE_DAYS,
+    }
+
+
+@app.get("/api/temperatuur", dependencies=[Depends(check_token)])
+async def temperatuur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
+        params,
+    )
+
+    series: dict[str, dict] = {}
+    for row in rows:
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/valisilm", dependencies=[Depends(check_token)])
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/ukseandur", dependencies=[Depends(check_token)])
+async def ukseandur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
+        f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/vead", dependencies=[Depends(check_token)])
+async def vead(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
+        f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "tyyp": r["tyyp"],
+                "olek": r["olek"],
+                "detail": r["detail"],
+                "vaartus": r["vaartus"],
+            }
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/latest", dependencies=[Depends(check_token)])
+async def latest() -> dict:
+    readings = ch_query(
+        f"SELECT device, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"toString(max(hour)) AS ts_str, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
+    )
+    faults = ch_query(
+        f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
+        f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
+        f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
+    )
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {
+        "readings": [
+            {
+                "device": r["device"],
+                "temperature": r["temperature"],
+                "humidity": r["humidity"],
+                "hour": r["ts_str"],
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in readings
+        ],
+        "open_faults": [
+            {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
+            for r in faults
+        ],
+        "outside": outside,
+    }
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)

+ 176 - 0
dev/nutikodu-web-v2/color.py

@@ -0,0 +1,176 @@
+"""AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB), v2.
+
+Erinevused v1-st:
+* tumekorrutis (dark) ja valge referents (white) on sisseehitatud;
+* kanalid integreeritakse CMF-iga koos kanali ribalaiusega (FWHM), mitte
+  delta-funktsioonina;
+* valge normaliseeritakse D65-le, värviesitus säilitab heleduse ja hallid;
+* tulemus sisaldab kehtivuslippu (pime / küllastunud / puuduv kanal).
+"""
+from __future__ import annotations
+
+import math
+
+# AS7341 kanalid: (nimi, kesk-lainepikkus nm, FWHM nm)
+CHANNELS = (
+    ("f1", 415.0, 26.0),
+    ("f2", 445.0, 30.0),
+    ("f3", 480.0, 36.0),
+    ("f4", 515.0, 39.0),
+    ("f5", 555.0, 39.0),
+    ("f6", 590.0, 40.0),
+    ("f7", 630.0, 52.0),
+    ("f8", 680.0, 122.0),
+)
+CHANNEL_WAVELENGTHS: dict[str, int] = {n: int(w) for n, w, _ in CHANNELS}
+FWHM: dict[str, float] = {n: f for n, _, f in CHANNELS}
+
+# CIE D65 suhteline spektraaljaotus kanalite keskkohtadel (560 nm = 100)
+D65_SPD = {415: 91.0, 445: 97.2, 480: 99.0, 515: 99.9, 555: 99.8, 590: 96.0, 630: 88.0, 680: 78.2}
+
+D65_WHITE = (0.95047, 1.0, 1.08883)
+SATURATION = 65000.0
+
+
+def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
+    sigma = sigma1 if x < mu else sigma2
+    return math.exp(-0.5 * ((x - mu) / sigma) ** 2)
+
+
+def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]:
+    """Wyman et al. (2013) lähendus CIE 1931 2° värvusfunktsioonidele."""
+    x_bar = (
+        1.056 * _piecewise_gaussian(wavelength_nm, 599.8, 37.9, 31.0)
+        + 0.362 * _piecewise_gaussian(wavelength_nm, 442.0, 16.0, 26.7)
+        - 0.065 * _piecewise_gaussian(wavelength_nm, 501.1, 20.4, 26.2)
+    )
+    y_bar = (
+        0.821 * _piecewise_gaussian(wavelength_nm, 568.8, 46.9, 40.5)
+        + 0.286 * _piecewise_gaussian(wavelength_nm, 530.9, 16.3, 31.1)
+    )
+    z_bar = (
+        1.217 * _piecewise_gaussian(wavelength_nm, 437.0, 11.8, 36.0)
+        + 0.681 * _piecewise_gaussian(wavelength_nm, 459.0, 26.0, 13.8)
+    )
+    return x_bar, y_bar, z_bar
+
+
+def _weights() -> dict[str, tuple[float, float, float]]:
+    out: dict[str, tuple[float, float, float]] = {}
+    for name, wl, fwhm in CHANNELS:
+        cx, cy, cz = cie_xyz_cmf(wl)
+        k = (D65_SPD[wl] / 100.0) * (fwhm / 100.0)
+        out[name] = (cx * k, cy * k, cz * k)
+    return out
+
+
+_W = _weights()
+# Valge (kõik kanalid = 1) annab D65 valge: normaliseerime Y = 1.
+_W_Y = sum(v[1] for v in _W.values())
+
+
+def _white_raw() -> tuple[float, float, float]:
+    x = sum(v[0] for v in _W.values()) / _W_Y
+    y = 1.0
+    z = sum(v[2] for v in _W.values()) / _W_Y
+    return x, y, z
+
+
+_WHITE_RAW = _white_raw()
+_WB_X = D65_WHITE[0] / _WHITE_RAW[0] if _WHITE_RAW[0] else 1.0
+_WB_Z = D65_WHITE[2] / _WHITE_RAW[2] if _WHITE_RAW[2] else 1.0
+
+
+def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
+    x = y = z = 0.0
+    for name, (wx, wy, wz) in _W.items():
+        value = channels.get(name)
+        if value is None:
+            continue
+        x += value * wx
+        y += value * wy
+        z += value * wz
+    # Kvadratuuri nihe: valge (kõik 1) peab olema täpselt D65.
+    return (x / _W_Y) * _WB_X, y / _W_Y, (z / _W_Y) * _WB_Z
+
+
+def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
+    total = x + y + z
+    if total <= 1e-12:
+        return 0.0, 0.0
+    return x / total, y / total
+
+
+def _xyz_to_srgb8(x: float, y: float, z: float) -> tuple[int, int, int]:
+    """XYZ -> sRGB 8-bit. Y on absoluutne heledus (0..1 = täisskaala)."""
+    if y <= 1e-9:
+        return 0, 0, 0
+    r = 3.2406 * x - 1.5372 * y - 0.4986 * z
+    g = -0.9689 * x + 1.8758 * y + 0.0415 * z
+    b = 0.0557 * x - 0.2040 * y + 1.0570 * z
+    # Gamut: negatiivsed komponendid -> lisa kõigile (desatureeri valge suunas)
+    m = min(r, g, b)
+    if m < 0:
+        r, g, b = r - m, g - m, b - m
+    # Kui üle 1, skaleeri (heledus väheneb, toon jääb)
+    mx = max(r, g, b)
+    if mx > 1.0:
+        r, g, b = r / mx, g / mx, b / mx
+
+    def enc(c: float) -> int:
+        c = max(0.0, min(1.0, c))
+        v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+        return int(round(255 * max(0.0, min(1.0, v))))
+    return enc(r), enc(g), enc(b)
+
+
+def quality(channels: dict[str, float], dark: dict | None = None) -> tuple[bool, str]:
+    """Kas mõõtmine on kasutatav (pime / küllastunud / puuduv kanal)."""
+    for name, _, _ in CHANNELS:
+        if channels.get(name) is None:
+            return False, "puuduv kanal"
+    d = dark or {}
+    clear = channels.get("clear")
+    dclear = float(d.get("clear") or 0.0)
+    if clear is not None and float(clear) < max(dclear * 3.0, dclear + 20.0):
+        return False, "liiga tume"
+    if clear is not None and float(clear) >= SATURATION:
+        return False, "küllastunud"
+    for name, _, _ in CHANNELS:
+        if float(channels.get(name) or 0.0) >= SATURATION:
+            return False, "küllastunud"
+    return True, ""
+
+
+def analyze(channels: dict[str, float], dark: dict | None = None, white: dict | None = None,
+            target_y: float = 1.0) -> dict:
+    """Tagastab tuletatud värviväljad.
+
+    dark  = tumereferents (lahutatakse)
+    white = valgereferents; kui antud, arvutatakse reflekteeruvus (S-D)/(W-D)
+    """
+    valid, reason = quality(channels, dark)
+    vec: dict[str, float] = {}
+    for name, _, _ in CHANNELS:
+        v = float(channels.get(name) or 0.0)
+        dv = float((dark or {}).get(name) or 0.0)
+        v -= dv
+        if white is not None:
+            wv = float(white.get(name) or 0.0) - dv
+            v = (v / wv) if wv > 1e-6 else 0.0
+        else:
+            v = v / 65535.0
+        vec[name] = max(0.0, min(v, 1.5))
+    x, y, z = channels_to_xyz(vec)
+    cx, cy = chromaticity_xy(x, y, z)
+    r, g, b = _xyz_to_srgb8(x, y, z)
+    return {
+        "x_chrom": round(cx, 5),
+        "y_chrom": round(cy, 5),
+        "r": r,
+        "g": g,
+        "b": b,
+        "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
+        "valid": valid,
+        "reason": reason,
+    }

+ 7 - 0
dev/nutikodu-web-v2/requirements.txt

@@ -0,0 +1,7 @@
+fastapi
+uvicorn
+requests
+itsdangerous
+python-multipart
+paho-mqtt>=2.0,<3
+numpy

+ 1299 - 0
dev/nutikodu-web-v2/static/app.js

@@ -0,0 +1,1299 @@
+"use strict";
+
+const state = {
+  from: null,
+  to: null,
+  agg: "hour",
+  device: "",
+  showTemp: true,
+  showHum: true,
+  showOut: true,
+  meta: null,
+};
+
+let chart = null;
+let currentData = { series: {} };
+
+const $ = (sel) => document.querySelector(sel);
+
+function cssVar(name) {
+  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+function pad(n) {
+  return String(n).padStart(2, "0");
+}
+
+function toLocalInput(date) {
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+function setStatus(ok) {  const el = $("#status");
+  el.classList.toggle("ok", ok === true);
+  el.classList.toggle("err", ok === false);
+}
+
+function applyPreset(days) {
+  const now = new Date();
+  let from;
+  if (days === "all") {
+    const min = state.meta && state.meta.min ? new Date(state.meta.min.replace(" ", "T")) : null;
+    from = min && !isNaN(min) ? min : new Date(now.getTime() - 7 * 864e5);
+  } else {
+    from = new Date(now.getTime() - Number(days) * 864e5);
+  }
+  state.from = toLocalInput(from);
+  state.to = toLocalInput(now);
+  $("#from").value = state.from;
+  $("#to").value = state.to;
+  load();
+}
+
+// Kärbi kõik seeriad ühisesse ajavahemikku, et jooned algaksid ja lõpeksid koos.
+function clipToCommonRange(series) {
+  // "· täidetud" hajuvusmärgistused ei tohi ühist vahemikku määrata: viimane märge
+  // võib olla varasem kui viimane mõõtmine → muidu kärbitakse telg liiga vara ja
+  // kaob viimane tund (nt 17:00).
+  const isMarker = (s) => String(s.name || "").endsWith("· täidetud");
+  const active = series.filter((s) => Array.isArray(s.data) && s.data.length && !isMarker(s));
+  if (active.length < 2) return series;
+  const start = Math.max(...active.map((s) => s.data[0][0]));
+  const end = Math.min(...active.map((s) => s.data[s.data.length - 1][0]));
+  if (!(start < end)) return series;
+  return series.map((s) => {
+    if (!Array.isArray(s.data)) return s;
+    return { ...s, data: s.data.filter((d) => d && d[0] >= start && d[0] <= end) };
+  });
+}
+
+function buildOption(data) {
+  const text = cssVar("--text");
+  const muted = cssVar("--muted");
+  const border = cssVar("--border");
+  const tempColor = cssVar("--temp");
+  const humColor = cssVar("--hum");
+  const outColor = cssVar("--out");
+  const series = [];
+  const palette = ["#1f6feb", "#e8590c", "#2fb344", "#9c36b5", "#f08c00", "#0ca678"];
+  const devices = Object.keys(data.series).sort();
+  let pi = 0;
+
+  const humAxis = state.showHum;
+
+  for (const dev of devices) {
+    const s = data.series[dev];
+    const baseColor = palette[pi % palette.length];
+    pi += 1;
+    if (state.showTemp) {
+      series.push({
+        name: `${dev} · °C`,
+        type: "line",
+        yAxisIndex: 0,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 2.4, color: tempColor },
+        itemStyle: { color: tempColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.temperature[i]]),
+      });
+      if (Array.isArray(s.taidetud) && s.taidetud.some(Boolean)) {
+        series.push({
+          name: `${dev} · täidetud`,
+          type: "scatter",
+          yAxisIndex: 0,
+          symbol: "emptyCircle",
+          symbolSize: 9,
+          itemStyle: { color: tempColor, borderWidth: 2 },
+          z: 3,
+          data: s.t
+            .map((t, i) => (s.taidetud[i] ? [t, s.temperature[i]] : null))
+            .filter(Boolean),
+        });
+      }
+    }
+    if (state.showHum) {
+      series.push({
+        name: `${dev} · %`,
+        type: "line",
+        yAxisIndex: 1,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 1.8, type: "dashed", color: humColor },
+        itemStyle: { color: humColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.humidity[i]]),
+      });
+    }
+  }
+
+  const out = data.outside;
+  if (state.showOut && out && Array.isArray(out.t) && out.t.length) {
+    series.push({
+      name: "Väli · °C",
+      type: "line",
+      yAxisIndex: 0,
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2, color: outColor },
+      itemStyle: { color: outColor },
+      emphasis: { focus: "series" },
+      data: out.t.map((t, i) => [t, out.temperature[i]]),
+    });
+    if (Array.isArray(out.taidetud) && out.taidetud.some(Boolean)) {
+      series.push({
+        name: "Väli · täidetud",
+        type: "scatter",
+        yAxisIndex: 0,
+        symbol: "emptyCircle",
+        symbolSize: 9,
+        itemStyle: { color: outColor, borderWidth: 2 },
+        z: 3,
+        data: out.t
+          .map((t, i) => (out.taidetud[i] ? [t, out.temperature[i]] : null))
+          .filter(Boolean),
+      });
+    }
+  }
+
+  return {
+    backgroundColor: "transparent",
+    animationDuration: 300,
+    color: palette,
+    grid: { left: 6, right: humAxis ? 6 : 6, top: 40, bottom: 62, containLabel: true },
+    legend: {
+      top: 4,
+      type: "scroll",
+      textStyle: { color: text, fontSize: 12 },
+      inactiveColor: muted,
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: cssVar("--panel"),
+      borderColor: border,
+      textStyle: { color: text, fontSize: 12 },
+      axisPointer: { type: "cross", label: { backgroundColor: muted } },
+      valueFormatter: null,
+      formatter: (params) => {
+        const items = (params || []).filter(
+          (p) => !String(p.seriesName).endsWith("· täidetud")
+        );
+        if (!items.length) return "";
+        const d = new Date(items[0].value[0]);
+        const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+        const lines = items.map((p) => {
+          const v = p.value[1];
+          const unit = p.seriesName.endsWith("°C") ? " °C" : " %";
+          return `<div style="display:flex;justify-content:space-between;gap:14px">
+            <span>${p.marker} ${p.seriesName}</span><b>${v == null ? "–" : v + unit}</b></div>`;
+        });
+        return `<div style="font-weight:600;margin-bottom:4px">${head}</div>${lines.join("")}`;
+      },
+    },
+    xAxis: {
+      type: "time",
+      axisLine: { lineStyle: { color: border } },
+      axisLabel: { color: muted, hideOverlap: true },
+      splitLine: { show: false },
+    },
+    yAxis: [
+      {
+        type: "value",
+        name: "°C",
+        scale: true,
+        axisLine: { show: false },
+        axisLabel: { color: muted },
+        splitLine: { lineStyle: { color: border, type: "dashed" } },
+      },
+      ...(humAxis
+        ? [
+            {
+              type: "value",
+              name: "%",
+              scale: true,
+              position: "right",
+              axisLine: { show: false },
+              axisLabel: { color: muted },
+              splitLine: { show: false },
+            },
+          ]
+        : []),
+    ],
+    dataZoom: [
+      { type: "inside", throttle: 50 },
+      { type: "slider", height: 22, bottom: 14, borderColor: border, textStyle: { color: muted }, fillColor: cssVar("--accent-soft") },
+    ],
+    series: clipToCommonRange(series),
+  };
+}
+
+function render(data) {
+  const chartEl = $("#chart");
+  const hasData = Object.keys(data.series).length > 0 && Object.values(data.series).some((s) => s.t.length);
+  $("#hint").hidden = hasData;
+  if (!hasData) {
+    $("#hint").textContent = "Valitud vahemikus andmeid ei ole. Andur salvestab andmeid alates 2026-09-17.";
+  }
+  if (!chart) chart = echarts.init(chartEl, null, { renderer: "canvas" });
+  chart.setOption(buildOption(data), true);
+  chart.resize();
+}
+
+async function load() {
+  const params = new URLSearchParams({
+    from: state.from,
+    to: state.to,
+    agg: state.agg,
+  });
+  if (state.device) params.set("device", state.device);
+  setStatus(null);
+  try {
+    const [tempRes, outRes] = await Promise.all([
+      fetch(`/api/temperatuur?${params}`),
+      fetch(`/api/valisilm?${params}`),
+    ]);
+    if (!tempRes.ok) throw new Error(await tempRes.text());
+    const data = await tempRes.json();
+    if (outRes.ok) {
+      try {
+        data.outside = (await outRes.json()).series;
+      } catch {
+        data.outside = null;
+      }
+    }
+    currentData = data;
+    currentSeries = data.series;
+    render(data);
+    loadProtected();
+    setStatus(true);
+    const f = new Date(state.from.replace(" ", "T"));
+    const t = new Date(state.to.replace(" ", "T"));
+    $("#range-label").textContent = `${f.toLocaleString("et-EE")} → ${t.toLocaleString("et-EE")}`;
+  } catch (err) {
+    console.error(err);
+    setStatus(false);
+    $("#hint").hidden = false;
+    $("#hint").textContent = "Andmete laadimine ebaõnnestus.";
+  }
+}
+
+async function loadMeta() {
+  try {
+    const res = await fetch("/api/meta");
+    if (!res.ok) throw new Error();
+    state.meta = await res.json();
+    $("#subtitle").textContent = "Zigbee andurid";
+    if (state.meta.min) {
+      $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
+    }
+  } catch {
+    $("#subtitle").textContent = "Zigbee andurid";
+  }
+}
+
+async function loadLatest() {
+  try {
+    const res = await fetch("/api/latest");
+    if (!res.ok) return;
+    const data = await res.json();
+    renderCards(data);
+  } catch {
+    /* vaikne */
+  }
+}
+
+function renderCards(data) {
+  const cards = [];
+  for (const r of data.readings) {
+    cards.push(`<div class="card temp"><div class="k">🌡️ ${r.device} · temperatuur</div>
+      <div class="v">${r.temperature ?? "–"}<small> °C</small></div></div>`);
+    cards.push(`<div class="card hum"><div class="k">💧 ${r.device} · niiskus</div>
+      <div class="v">${r.humidity ?? "–"}<small> %</small></div></div>`);
+  }
+  if (data.outside && data.outside.temperature != null) {
+    cards.push(`<div class="card out"><div class="k">🌤️ Väli · temperatuur</div>
+      <div class="v">${data.outside.temperature}<small> °C</small></div></div>`);
+  }
+  if (data.open_faults.length) {
+    const items = data.open_faults
+      .map((f) => `<div>⚠️ <b>${f.device}</b> · ${f.tyyp}${f.detail ? " · " + f.detail : ""}</div>`)
+      .join("");
+    cards.push(`<div class="card wide"><div class="k">Lahtised häired (${data.open_faults.length})</div>
+      <div style="font-size:13px;margin-top:6px;line-height:1.7">${items}</div></div>`);
+  }
+  $("#cards").innerHTML = cards.join("");
+}
+
+function setTheme(theme) {
+  document.documentElement.setAttribute("data-theme", theme);
+  localStorage.setItem("nutikodu-theme", theme);
+  $("#theme-toggle").textContent = theme === "dark" ? "☀️" : "🌙";
+  const meta = $("#meta-theme");
+  if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
+  if (chart) chart.setOption(buildOption(currentData), true);
+}
+
+let currentSeries = {};
+
+function init() {
+  const saved = localStorage.getItem("nutikodu-theme");
+  const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+  setTheme(saved || (prefersDark ? "dark" : "light"));
+  $("#theme-toggle").addEventListener("click", () => {
+    setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+  });
+
+  document.querySelectorAll("#presets button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      applyPreset(btn.dataset.days);
+    });
+  });
+
+  document.querySelectorAll("#agg button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#agg button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      state.agg = btn.dataset.agg;
+      load();
+    });
+  });
+
+  $("#show-temp").addEventListener("change", (e) => { state.showTemp = e.target.checked; load(); });
+  $("#show-hum").addEventListener("change", (e) => { state.showHum = e.target.checked; load(); });
+  $("#show-out").addEventListener("change", (e) => { state.showOut = e.target.checked; load(); });
+  $("#apply").addEventListener("click", () => {
+    state.from = $("#from").value;
+    state.to = $("#to").value;
+    document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+    load();
+  });
+
+  window.addEventListener("resize", () => chart && chart.resize());
+  window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
+  window.addEventListener("load", () => chart && chart.resize());
+
+  checkAuth().then(() => loadMeta().then(() => applyPreset(7)));
+  loadLatest();
+  setInterval(loadLatest, 60000);
+  setInterval(loadProtected, 60000);
+  initTabs();
+}
+
+
+
+// --- Uus67: energia + liikumine ---
+let chartEnergy = null;
+let chartMotion = null;
+
+function palColors() {
+  return ["#1f6feb", "#e8590c", "#2fb344", "#9c36b7", "#f08c00", "#0ca678"];
+}
+
+async function loadEnergy() {
+  if (!state.from) return;
+  const params = new URLSearchParams({ from: state.from, to: state.to, agg: state.agg });
+  try {
+    const res = await fetch(`/api/energia?${params}`);
+    if (!res.ok) return;
+    renderEnergy(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderEnergy(data) {
+  const el = document.getElementById("chart-energy");
+  if (!el) return;
+  const seriesData = data.series || {};
+  const hasData = Object.keys(seriesData).length > 0 && Object.values(seriesData).some((s) => s.t.length);
+  const hint = document.getElementById("hint-energy");
+  if (hint) { hint.hidden = hasData; if (!hasData) hint.textContent = "Energia andmeid veel pole (koguneb tunnipõhiselt, esimene rida täistunnil)."; }
+  if (!hasData) { if (chartEnergy) chartEnergy.clear(); return; }
+  if (!chartEnergy) chartEnergy = echarts.init(el, null, { renderer: "canvas" });
+  const pal = palColors();
+  const series = Object.keys(seriesData).sort().map((dev, i) => {
+    const s = seriesData[dev];
+    return {
+      name: `${dev} · W`,
+      type: "line",
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2.2, color: pal[i % pal.length] },
+      itemStyle: { color: pal[i % pal.length] },
+      data: s.t.map((t, k) => [t, s.power[k]]),
+    };
+  });
+  chartEnergy.setOption({
+    backgroundColor: "transparent",
+    tooltip: { trigger: "axis" },
+    legend: { top: 0, type: "scroll", textStyle: { color: cssVar("--text") } },
+    grid: { left: 6, right: 6, top: 34, bottom: 44, containLabel: true },
+    xAxis: { type: "time", axisLabel: { color: cssVar("--muted") }, axisLine: { lineStyle: { color: cssVar("--border") } } },
+    yAxis: { type: "value", name: "W", scale: true, axisLabel: { color: cssVar("--muted") }, splitLine: { lineStyle: { color: cssVar("--border"), type: "dashed" } } },
+    dataZoom: [{ type: "inside" }, { type: "slider", height: 18, bottom: 8, borderColor: cssVar("--border") }],
+    series,
+  }, true);
+  chartEnergy.resize();
+}
+
+async function loadMotion() {
+  if (!state.from) return;
+  const params = new URLSearchParams({ from: state.from, to: state.to });
+  try {
+    const res = await fetch(`/api/liikumine?${params}`);
+    if (!res.ok) return;
+    renderMotion(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderMotion(data) {
+  const el = document.getElementById("chart-motion");
+  if (!el) return;
+  const ev = data.events || [];
+  const hint = document.getElementById("hint-motion");
+  if (hint) { hint.hidden = ev.length > 0; if (!ev.length) hint.textContent = "Liikumise sündmusi veel pole."; }
+  if (!ev.length) { if (chartMotion) chartMotion.clear(); return; }
+  if (!chartMotion) chartMotion = echarts.init(el, null, { renderer: "canvas" });
+  const pal = palColors();
+  const devices = [...new Set(ev.map((e) => e.device))].sort();
+  const series = devices.map((dev, i) => ({
+    name: dev,
+    type: "scatter",
+    symbolSize: 13,
+    itemStyle: { color: pal[i % pal.length] },
+    data: ev.filter((e) => e.device === dev && e.occupancy).map((e) => [e.t, 1]),
+  }));
+  chartMotion.setOption({
+    backgroundColor: "transparent",
+    tooltip: {
+      trigger: "axis",
+      formatter: (p) => {
+        if (!p || !p.length) return "";
+        const d = new Date(p[0].value[0]);
+        return `${d.toLocaleString("et-EE")}<br>${p.map((x) => x.seriesName).join(", ")}`;
+      },
+    },
+    legend: { top: 0, textStyle: { color: cssVar("--text") } },
+    grid: { left: 6, right: 6, top: 34, bottom: 44, containLabel: true },
+    xAxis: { type: "time", axisLabel: { color: cssVar("--muted") }, axisLine: { lineStyle: { color: cssVar("--border") } } },
+    yAxis: { type: "value", min: 0, max: 1.4, axisLabel: { show: false }, splitLine: { show: false } },
+    dataZoom: [{ type: "inside" }, { type: "slider", height: 18, bottom: 8, borderColor: cssVar("--border") }],
+    series,
+  }, true);
+  chartMotion.resize();
+}
+
+async function loadUus67Latest() {
+  try {
+    const res = await fetch("/api/uus67_latest");
+    if (!res.ok) return;
+    const data = await res.json();
+    renderUus67Cards(data);
+  } catch (e) { console.error(e); }
+}
+
+function renderUus67Cards(data) {
+  const host = document.getElementById("cards-uus67");
+  if (!host) return;
+  const cards = [];
+  for (const e of data.energy || []) {
+    cards.push(`<div class="card"><div class="k">⚡ ${e.device} · võimsus</div>
+      <div class="v">${e.power ?? "–"}<small> W</small></div></div>`);
+    if (e.energy != null) {
+      cards.push(`<div class="card"><div class="k">🔌 ${e.device} · kWh</div>
+        <div class="v">${e.energy}<small> kWh</small></div></div>`);
+    }
+  }
+  for (const m of data.motion || []) {
+    cards.push(`<div class="card ${m.occupancy ? "temp" : ""}"><div class="k">🚶 ${m.device}</div>
+      <div class="v">${m.occupancy ? "liikumine" : "rahu"}</div></div>`);
+  }
+  host.innerHTML = cards.join("");
+}
+
+
+
+// --- Autentimine ---
+function ensureAwayButton() {
+  if (document.getElementById("away-toggle")) return;
+  const actions = document.querySelector(".header-actions");
+  const authLink = document.getElementById("auth-link");
+  if (!actions || !authLink) return;
+  const btn = document.createElement("button");
+  btn.type = "button";
+  btn.id = "away-toggle";
+  btn.className = "away-pill";
+  btn.setAttribute("aria-label", "Kodust eemal lüliti");
+  btn.innerHTML = '<span class="away-ico">\ud83c\udfe0</span><span id="away-title">Kodus</span>';
+  btn.addEventListener("click", toggleAway);
+  actions.insertBefore(btn, authLink);
+}
+
+function applyAuth(authed) {
+  state.authed = authed;
+  if (authed) {
+    ensureAwayButton();
+    loadAway();
+  } else {
+    const awayBtn = document.getElementById("away-toggle");
+    if (awayBtn) awayBtn.remove();
+  }
+  const prot = document.getElementById("protected");
+  if (prot) prot.hidden = !authed;
+  const vp = document.getElementById("varv-power");
+  if (vp) vp.hidden = !authed;
+  const vc = document.getElementById("varv-calib-panel");
+  if (vc) vc.hidden = !authed;
+  const vccm = document.getElementById("varv-ccm-panel");
+  if (vccm) vccm.hidden = !authed;
+  if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); initVarvCcmButtons(); loadVarvCcm(); }
+  const link = document.getElementById("auth-link");
+  if (link) {
+    link.href = authed ? "/logout" : "/login";
+    link.title = authed ? "Logi välja" : "Sisselogimine";
+    link.textContent = authed ? "\ud83d\udd13" : "\ud83d\udd12";
+  }
+}
+
+async function checkAuth() {
+  try {
+    const res = await fetch("/api/me");
+    if (!res.ok) { applyAuth(false); return; }
+    const data = await res.json();
+    applyAuth(!!data.authed);
+  } catch { applyAuth(false); }
+}
+
+async function loadProtected() {
+  if (!state.authed) return;
+  loadEnergy();
+  loadMotion();
+  loadUus67Latest();
+}
+
+
+
+// --- Kodust eemal (HA input_boolean) ---
+let awayState = null;
+
+async function loadAway() {
+  if (!state.authed) return;
+  try {
+    const res = await fetch("/api/away");
+    if (!res.ok) return;
+    const d = await res.json();
+    awayState = d.away;
+    renderAway(d.away, d.available);
+  } catch (e) { console.error(e); }
+}
+
+function renderAway(away, available) {
+  const btn = document.getElementById("away-toggle");
+  if (!btn) return;
+  const ico = btn.querySelector(".away-ico");
+  const title = document.getElementById("away-title");
+  if (away === true) {
+    btn.classList.add("away-on");
+    if (ico) ico.textContent = "🚨";
+    if (title) title.textContent = "Eemal";
+  } else {
+    btn.classList.remove("away-on");
+    if (ico) ico.textContent = "🏠";
+    if (title) title.textContent = "Kodus";
+    if (available === false && title) title.textContent = "HA offline";
+  }
+}
+
+async function toggleAway() {
+  const on = !(awayState === true);
+  awayState = on;
+  renderAway(on, true);
+  try {
+    const res = await fetch("/api/away", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ on }),
+    });
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    const d = await res.json();
+    awayState = d.away;
+    renderAway(d.away, true);
+  } catch (e) {
+    console.error(e);
+    loadAway();
+  }
+}
+
+// --- Värvisensor (AS7341, uus67.varvus) ---
+let varvActive = false;
+let chartVarvSpectrum = null;
+let chartVarvChroma = null;
+let varvLatest = [];
+let varvSelected = 0;
+
+const VARV_BANDS = [
+  ["f1", "415 nm", "#7b2ff7"],
+  ["f2", "445 nm", "#3b5bdb"],
+  ["f3", "480 nm", "#1c7ed6"],
+  ["f4", "515 nm", "#2fb344"],
+  ["f5", "555 nm", "#9acd32"],
+  ["f6", "590 nm", "#f0b000"],
+  ["f7", "630 nm", "#e8590c"],
+  ["f8", "680 nm", "#c92a2a"],
+];
+
+async function loadVarv() {
+  const hint = document.getElementById("varv-hint");
+  try {
+    const [r1, r2] = await Promise.all([
+      fetch("/api/varv/latest?limit=60"),
+      fetch("/api/varv/history?hours=24"),
+    ]);
+    if (!r1.ok || !r2.ok) throw new Error("HTTP " + r1.status + "/" + r2.status);
+    varvLatest = (await r1.json()).rows || [];
+    const history = (await r2.json()).rows || [];
+    if (varvSelected >= varvLatest.length) varvSelected = 0;
+    renderVarvCurrent();
+    renderVarvGrid();
+    renderVarvTimeline(history);
+    renderVarvSpectrum();
+    renderVarvChroma(varvLatest);
+    renderVarvCalibLive();
+    const upd = document.getElementById("varv-updated");
+    if (upd) upd.textContent = varvLatest.length ? "Viimane mõõtmine " + varvLatest[0].ts : "Andmeid pole";
+    if (hint) hint.hidden = true;
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Värviandmete laadimine ebaõnnestus."; }
+  }
+}
+
+const varvFmt = (v) => (v == null ? "–" : Number(v).toFixed(3));
+
+function renderVarvCurrent() {
+  const sw = document.getElementById("varv-current-swatch");
+  const hex = document.getElementById("varv-hex");
+  const meta = document.getElementById("varv-current-meta");
+  if (!sw) return;
+  if (!varvLatest.length) {
+    sw.style.background = "transparent";
+    if (hex) hex.textContent = "#------";
+    if (meta) meta.textContent = "Andmeid pole.";
+    return;
+  }
+  const c = varvLatest[0];
+  const p = varvPick(c);
+  sw.style.background = p.hex;
+  if (hex) hex.textContent = p.dark ? "PIME" : p.hex.toUpperCase();
+  if (meta) meta.innerHTML =
+    `${c.ts}${varvCalibView ? " · kalibreeritud" : ""}` +
+    (p.dark
+      ? "<br>pime — värv ei ole usaldusväärne (valgusta pinda)"
+      : `<br>x = ${varvFmt(p.x)} · y = ${varvFmt(p.y)} · gain ${c.gain}`);
+}
+
+function renderVarvGrid() {
+  const grid = document.getElementById("varv-grid");
+  if (!grid) return;
+  grid.innerHTML = varvLatest.map((c, i) => {
+    const p = varvPick(c);
+    return `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
+      `style="background:${p.hex}" title="${c.ts}  ${p.hex}" data-i="${i}"></button>`;
+  }).join("");
+  grid.querySelectorAll(".swatch").forEach((el) => {
+    el.addEventListener("click", () => {
+      varvSelected = Number(el.dataset.i) || 0;
+      renderVarvGrid();
+      renderVarvSpectrum();
+    });
+  });
+}
+
+function renderVarvTimeline(rows) {
+  const el = document.getElementById("varv-timeline");
+  if (!el) return;
+  if (!rows.length) {
+    el.innerHTML = '<span class="varv-sub">Viimase 24 h andmeid pole.</span>';
+    return;
+  }
+  el.innerHTML = rows.map((r) => {
+    const d = new Date(r.t);
+    const hh = pad(d.getHours()) + ":00";
+    return `<div class="swatch tl" style="background:${r.rgb_hex}" ` +
+      `title="${r.ts.slice(0, 10)} ${hh}  ${r.rgb_hex} · ${r.n} mõõtmist"></div>`;
+  }).join("");
+}
+
+function varvReflectance(c) {
+  const d = varvCalibData && varvCalibData.dark;
+  const w = varvCalibData && varvCalibData.white;
+  if (!d || !w) return null;
+  const out = {};
+  for (const b of VARV_BANDS) {
+    const s = Number(c.bands[b[0]]) || 0;
+    const dv = Number(d[b[0]]) || 0;
+    const wv = Number(w[b[0]]) || 0;
+    out[b[0]] = (wv - dv) > 0 ? Math.max(0, (s - dv) / (wv - dv)) : 0;
+  }
+  return out;
+}
+
+function renderVarvSpectrum() {
+  const el = document.getElementById("chart-varv-spectrum");
+  if (!el || !varvLatest.length) { if (chartVarvSpectrum) chartVarvSpectrum.clear(); return; }
+  const c = varvLatest[Math.min(varvSelected, varvLatest.length - 1)];
+  const refl = varvReflectance(c);
+  const label = document.getElementById("varv-spectrum-label");
+  if (label) {
+    label.textContent = refl
+      ? `— ${c.ts} · ${((c.rgb_hex_cal || c.rgb_hex) || "").toUpperCase()} · reflekteeruvus %`
+      : `— ${c.ts} · ${c.rgb_hex.toUpperCase()} · toored kanalid (valgusti-domineeritud)`;
+  }
+  if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" });
+  const data = VARV_BANDS.map((b) => ({
+    value: refl ? Math.round((refl[b[0]] || 0) * 1000) / 10 : (c.bands[b[0]] || 0),
+    itemStyle: { color: b[2] },
+  }));
+  chartVarvSpectrum.setOption({
+    grid: { left: 56, right: 16, top: 16, bottom: 32 },
+    xAxis: {
+      type: "category",
+      data: VARV_BANDS.map((b) => b[1]),
+      axisLabel: { color: cssVar("--muted") },
+    },
+    yAxis: {
+      type: "value",
+      max: refl ? 100 : undefined,
+      axisLabel: {
+        color: cssVar("--muted"),
+        formatter: refl ? "{value}%" : "{value}",
+      },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: { trigger: "axis" },
+    series: [{
+      type: "bar",
+      barWidth: "62%",
+      data,
+    }],
+  }, true);
+  chartVarvSpectrum.resize();
+}
+
+function renderVarvChroma(rows) {
+  const el = document.getElementById("chart-varv-chroma");
+  if (!el) return;
+  if (!chartVarvChroma) chartVarvChroma = echarts.init(el, null, { renderer: "canvas" });
+  const pts = rows
+    .filter((r) => r.x_chrom != null && r.y_chrom != null)
+    .map((r) => [Number(r.x_chrom), Number(r.y_chrom), r.ts, r.rgb_hex]);
+  chartVarvChroma.setOption({
+    grid: { left: 52, right: 24, top: 20, bottom: 40 },
+    xAxis: {
+      type: "value", min: 0, max: 0.75, name: "x",
+      nameTextStyle: { color: cssVar("--muted") },
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    yAxis: {
+      type: "value", min: 0, max: 0.85, name: "y",
+      nameTextStyle: { color: cssVar("--muted") },
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: {
+      formatter: (p) => {
+        if (p.seriesName === "D65") return "valge punkt D65";
+        return `${p.value[3]}<br>x=${p.value[0].toFixed(3)} y=${p.value[1].toFixed(3)}<br>${p.value[2]}`;
+      },
+    },
+    series: [
+      {
+        name: "mõõtmised",
+        type: "scatter",
+        symbolSize: 11,
+        data: pts,
+        itemStyle: { color: "rgba(31,111,235,0.7)" },
+      },
+      {
+        name: "D65",
+        type: "scatter",
+        symbol: "pin",
+        symbolSize: 26,
+        data: [[0.3127, 0.329]],
+        itemStyle: { color: "#8b949e" },
+      },
+    ],
+  }, true);
+  chartVarvChroma.resize();
+}
+
+// --- Värv: mõõtmise sisse/välja lüliti (ainult sisselogitult) ---
+let varvPowerBusy = false;
+let varvPowerWired = false;
+
+function initVarvPowerButtons() {
+  if (varvPowerWired) return;
+  const onBtn = document.getElementById("varv-on");
+  const offBtn = document.getElementById("varv-off");
+  if (onBtn) onBtn.addEventListener("click", () => setVarvPower(true));
+  if (offBtn) offBtn.addEventListener("click", () => setVarvPower(false));
+  varvPowerWired = true;
+}
+
+function renderVarvPower(d) {
+  const st = document.getElementById("varv-power-status");
+  const onBtn = document.getElementById("varv-on");
+  const offBtn = document.getElementById("varv-off");
+  const on = d ? d.on : null;
+  if (st) {
+    if (on === true) { st.textContent = "SEES"; st.className = "badge ok"; }
+    else if (on === false) { st.textContent = "VÄLJAS"; st.className = "badge err"; }
+    else { st.textContent = "teadmata"; st.className = "badge muted"; }
+  }
+  if (onBtn) onBtn.disabled = varvPowerBusy || on === true;
+  if (offBtn) offBtn.disabled = varvPowerBusy || on === false;
+}
+
+async function loadVarvPower() {
+  if (!state.authed) return;
+  try {
+    const res = await fetch("/api/varv/power");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvPower(await res.json());
+  } catch (e) {
+    console.error(e);
+    renderVarvPower({ on: null });
+  }
+}
+
+async function setVarvPower(on) {
+  if (!state.authed || varvPowerBusy) return;
+  varvPowerBusy = true;
+  const hint = document.getElementById("varv-power-hint");
+  renderVarvPower({ on });
+  try {
+    const res = await fetch("/api/varv/power", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ on }),
+    });
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvPower(await res.json());
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = on
+        ? "Mõõtmine sisse lülitatud."
+        : "Mõõtmine välja lülitatud — ClickHouse'i ei kirjutata.";
+      setTimeout(() => { hint.hidden = true; }, 5000);
+    }
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Lülitamine ebaõnnestus."; }
+  } finally {
+    varvPowerBusy = false;
+    loadVarvPower();
+  }
+}
+
+// --- Värv: kalibreerimine (tume/valge; nupud ainult sisselogitult) ---
+let varvCalibView = false;
+let varvCalibData = null;
+let varvCalibWired = false;
+let varvCalibUserSet = false;
+
+function varvBandSum(c) {
+  return Object.values(c.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0);
+}
+
+function varvPick(c) {
+  if (varvBandSum(c) < 250) return { hex: "#33383f", x: null, y: null, dark: true };
+  if (varvCalibView && c.rgb_hex_ccm) {
+    return { hex: c.rgb_hex_ccm, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  if (varvCalibView && c.rgb_hex_cal) {
+    return { hex: c.rgb_hex_cal, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  return { hex: c.rgb_hex, x: c.x_chrom, y: c.y_chrom };
+}
+
+function initVarvCalibButtons() {
+  if (varvCalibWired) return;
+  const d = document.getElementById("varv-calib-dark");
+  const w = document.getElementById("varv-calib-white");
+  const t = document.getElementById("varv-calib-view");
+  if (d) d.addEventListener("click", () => captureVarvCalib("dark"));
+  if (w) w.addEventListener("click", () => captureVarvCalib("white"));
+  if (t) t.addEventListener("change", (e) => {
+    varvCalibUserSet = true;
+    varvCalibView = e.target.checked;
+    renderVarvCurrent();
+    renderVarvGrid();
+  });
+  varvCalibWired = true;
+}
+
+function renderVarvCalib(d) {
+  varvCalibData = d;
+  const st = document.getElementById("varv-calib-status");
+  if (st) {
+    const haveD = !!d.dark;
+    const haveW = !!d.white;
+    if (haveD && haveW) { st.textContent = "valmis"; st.className = "badge ok"; }
+    else {
+      st.textContent = `${haveD ? "tume ✓" : "tume ✗"} / ${haveW ? "valge ✓" : "valge ✗"}`;
+      st.className = "badge warn";
+    }
+  }
+  const view = document.getElementById("varv-calib-view");
+  if (view) view.disabled = !(d.dark && d.white);
+  const lbl = document.getElementById("varv-calib-view-label");
+  if (lbl) {
+    lbl.textContent = (d.dark && d.white)
+      ? "Kuva kalibreeritud värvi"
+      : "Kuva kalibreeritud värvi (vaja tume + valge)";
+  }
+  // Kui kalibratsioon on valmis ja kasutaja pole ise lülitit puutunud,
+  // lülita kalibreeritud vaade automaatselt sisse.
+  if (d.dark && d.white && !varvCalibUserSet && !varvCalibView) {
+    const cb = document.getElementById("varv-calib-view");
+    if (cb) cb.checked = true;
+    varvCalibView = true;
+    if (varvLatest.length) { renderVarvCurrent(); renderVarvGrid(); }
+  }
+  renderVarvSpectrum();
+}
+
+async function loadVarvCalib() {
+  try {
+    const res = await fetch("/api/varv/calib");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvCalib(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderVarvCalibLive() {
+  const el = document.getElementById("varv-calib-live");
+  if (!el) return;
+  if (!varvLatest.length) { el.textContent = ""; return; }
+  const bandSum = (r) => Object.values(r.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0);
+  const c = varvLatest[0];
+  const sum = bandSum(c);
+  const sums = varvLatest.slice(0, 5).map(bandSum);
+  const mx = Math.max(...sums);
+  const mn = Math.min(...sums);
+  const unstable = mx > 0 && (mx - mn) / mx > 0.2;
+  let light;
+  if (sum < 50) light = "PIME — valge jaoks vaja valgust";
+  else if (sum < 200) light = "hämar";
+  else light = "HELE";
+  const st = unstable ? "MUUTUB (oota stabiliseerumist)" : "stabiilne ✓";
+  el.textContent = `Viimane: ${(c.rgb_hex || "").toUpperCase()} · heledus ${Math.round(sum)} · ${light} · ${st}`;
+}
+
+async function captureVarvCalib(kind) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-calib-hint");
+  try {
+    const res = await fetch("/api/varv/calib", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ kind, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = `Salvestamine ebaõnnestus (HTTP ${res.status}).`;
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = `Salvestatud: ${kind} (${d.rows} mõõtmist).`;
+      setTimeout(() => { hint.hidden = true; }, 4500);
+    }
+    await loadVarvCalib();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = e.message || "Salvestamine ebaõnnestus."; }
+  }
+}
+
+// --- Värv: CCM (värvikaart) kalibratsioon ---
+let varvCcmWired = false;
+
+function initVarvCcmButtons() {
+  if (varvCcmWired) return;
+  document.querySelectorAll("[data-ref]").forEach((btn) => {
+    btn.addEventListener("click", () => captureVarvRef(String(btn.dataset.hex).toLowerCase(), btn.dataset.hex));
+  });
+  const fit = document.getElementById("varv-ccm-fit");
+  if (fit) fit.addEventListener("click", fitVarvCcm);
+
+  const colEl = document.getElementById("varv-ccm-color");
+  const hexEl = document.getElementById("varv-ccm-hex");
+  const prevEl = document.getElementById("varv-ccm-preview");
+  const capEl = document.getElementById("varv-ccm-capture");
+  const applyColor = (raw) => {
+    const h = String(raw || "").replace("#", "").trim().toUpperCase();
+    if (!/^[0-9A-F]{6}$/.test(h)) return;
+    if (prevEl) prevEl.style.background = "#" + h;
+    if (colEl) colEl.value = "#" + h.toLowerCase();
+    if (hexEl) hexEl.value = h;
+  };
+  if (colEl) colEl.addEventListener("input", () => applyColor(colEl.value));
+  if (hexEl) hexEl.addEventListener("change", () => applyColor(hexEl.value));
+  if (capEl) capEl.addEventListener("click", () => {
+    const h = (hexEl ? hexEl.value : "").replace("#", "").trim().toUpperCase();
+    if (!/^[0-9A-F]{6}$/.test(h)) return;
+    captureVarvRef(h.toLowerCase(), h);
+  });
+
+  varvCcmWired = true;
+}
+
+function renderVarvCcm(ccm, refs) {
+  const st = document.getElementById("varv-ccm-status");
+  if (st) {
+    if (ccm && ccm.ready) { st.textContent = "maatriks olemas"; st.className = "badge ok"; }
+    else { st.textContent = "maatriks puudub"; st.className = "badge warn"; }
+  }
+  const list = document.getElementById("varv-ccm-list");
+  const names = (refs && refs.refs ? refs.refs : []).map((r) => r.name);
+  if (list) list.textContent = names.length ? "Salvestatud: " + names.join(", ") : "Ühtegi referentsi pole veel.";
+}
+
+async function loadVarvCcm() {
+  try {
+    const [r1, r2] = await Promise.all([fetch("/api/varv/ccm"), fetch("/api/varv/ref")]);
+    if (!r1.ok || !r2.ok) return;
+    renderVarvCcm(await r1.json(), await r2.json());
+  } catch (e) { console.error(e); }
+}
+
+async function captureVarvRef(name, hex) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ref", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ name, hex, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = "Salvestamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    if (hint) hint.textContent = `Salvestatud: ${name} (${hex})`;
+    loadVarvCcm();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
+async function fitVarvCcm() {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ccm/fit", { method: "POST" });
+    if (!res.ok) {
+      let msg = "Sobitamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) hint.textContent = `Maatriks arvutatud (${d.n_refs} värvi).`;
+    loadVarvCcm();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
+window.addEventListener("resize", () => {
+  if (chartVarvSpectrum) chartVarvSpectrum.resize();
+  if (chartVarvChroma) chartVarvChroma.resize();
+});
+
+// --- Vahelehed (tabid): Andurid | Transport | Värv ---
+const transport = { active: false, days: 1 };
+const VIEWS = ["andurid", "transport", "varv"];
+
+function switchView(view) {
+  if (!VIEWS.includes(view)) view = "andurid";
+  document.querySelectorAll("#tabs .tab").forEach((b) => {
+    b.classList.toggle("active", b.dataset.view === view);
+  });
+  for (const v of VIEWS) {
+    const el = document.getElementById("view-" + v);
+    if (el) el.hidden = v !== view;
+  }
+  transport.active = view === "transport";
+  varvActive = view === "varv";
+  if (location.hash !== "#" + view) history.replaceState(null, "", "#" + view);
+  if (view === "transport") {
+    loadTransport();
+  } else if (view === "varv") {
+    loadVarv();
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); }
+  } else {
+    if (chart) chart.resize();
+    if (chartEnergy) chartEnergy.resize();
+    if (chartMotion) chartMotion.resize();
+  }
+}
+
+function initTabs() {
+  document.querySelectorAll("#tabs .tab").forEach((btn) => {
+    btn.addEventListener("click", () => switchView(btn.dataset.view));
+  });
+  const days = document.getElementById("praam-days");
+  if (days) {
+    days.querySelectorAll("button").forEach((b) => {
+      b.addEventListener("click", () => {
+        days.querySelectorAll("button").forEach((x) => x.classList.remove("active"));
+        b.classList.add("active");
+        transport.days = Number(b.dataset.days) || 1;
+        loadPraam();
+      });
+    });
+  }
+  setInterval(() => {
+    if (transport.active) loadTransport();
+  }, 60000);
+  setInterval(() => {
+    if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } }
+  }, 20000);
+  switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
+}
+
+function loadTransport() {
+  loadBus();
+  loadPraam();
+}
+
+function fmtDayLabel(iso, today) {
+  if (!iso || iso === today) return "";
+  const d = new Date(iso + "T00:00:00");
+  const t = new Date((today || iso) + "T00:00:00");
+  const diff = Math.round((d - t) / 864e5);
+  if (diff === 1) return "homme";
+  return d.toLocaleDateString("et-EE", { weekday: "short", day: "numeric", month: "short" });
+}
+
+function setTransportUpdated(updated) {
+  const el = document.getElementById("transport-updated");
+  if (el && updated) el.textContent = `Uuendatud ${updated}`;
+}
+
+async function loadBus() {
+  const hint = document.getElementById("bus-hint");
+  try {
+    const res = await fetch("/api/buss?limit=12");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderBus(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Bussiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderBus(data) {
+  const list = document.getElementById("bus-list");
+  const hint = document.getElementById("bus-hint");
+  if (!list) return;
+  const rows = data.departures || [];
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Täna enam busse ei välju."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  list.innerHTML = rows.map((d) => {
+    const day = fmtDayLabel(d.date, data.today);
+    const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+    let rt = "";
+    if (d.realtime && d.delay_min != null && d.delay_min !== 0) {
+      rt = d.delay_min > 0
+        ? `<span class="badge warn">+${d.delay_min} min</span>`
+        : `<span class="badge ok">${d.delay_min} min</span>`;
+    }
+    return `<div class="dep">
+      <span class="dep-time">${d.time}</span>
+      <span class="dep-route">${d.route || ""}</span>
+      <span class="dep-dest">→ ${d.headsign || "?"}</span>
+      ${rt}${dayBadge}
+    </div>`;
+  }).join("");
+}
+
+async function loadPraam() {
+  const hint = document.getElementById("praam-hint");
+  try {
+    const res = await fetch(`/api/praam?days=${transport.days}`);
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderPraam(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Praamiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderPraam(data) {
+  const list = document.getElementById("praam-list");
+  const hint = document.getElementById("praam-hint");
+  const warn = document.getElementById("praam-warning");
+  const rows = data.departures || [];
+
+  if (warn) {
+    if (Array.isArray(data.warnings) && data.warnings.length) {
+      warn.hidden = false;
+      warn.innerHTML = "⚠️ <b>Ilmahoiatuse kontekst (Väinameri)</b>" +
+        data.warnings.map((w) => `<div>• ${w.area}: ${w.valid || w.text}</div>`).join("") +
+        `<div class="warn-sub">Praamiliiklus võib häiruda — kontrolli ` +
+        `<a href="https://praamid.ee" target="_blank" rel="noopener">praamid.ee</a>.</div>`;
+    } else {
+      warn.hidden = true;
+    }
+  }
+
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Väljumisi ei leitud."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  setTransportUpdated(data.updated);
+
+  const groups = {};
+  for (const d of rows) (groups[d.direction] = groups[d.direction] || []).push(d);
+  list.innerHTML = ["HR", "RH"].filter((k) => groups[k]).map((k) => {
+    const items = groups[k].map((d) => {
+      const day = fmtDayLabel(d.date, data.today);
+      const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+      const st = d.disrupted
+        ? `<span class="badge err">${d.status_et}</span>`
+        : `<span class="badge ok">${d.status_et}</span>`;
+      return `<div class="dep">
+        <span class="dep-time">${d.time}</span>
+        <span class="dep-ship">${d.ship || ""}</span>
+        <span class="dep-dest"></span>
+        ${st}${dayBadge}
+      </div>`;
+    }).join("");
+    return `<div class="dep-group"><div class="dep-group-title">${groups[k][0].direction_name}</div>${items}</div>`;
+  }).join("");
+}
+
+document.addEventListener("DOMContentLoaded", init);

+ 377 - 0
dev/nutikodu-web-v2/static/app.js.bak.1791389719

@@ -0,0 +1,377 @@
+"use strict";
+
+const state = {
+  from: null,
+  to: null,
+  agg: "hour",
+  device: "",
+  showTemp: true,
+  showHum: true,
+  showOut: true,
+  meta: null,
+};
+
+let chart = null;
+let currentData = { series: {} };
+
+const $ = (sel) => document.querySelector(sel);
+
+function cssVar(name) {
+  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+function pad(n) {
+  return String(n).padStart(2, "0");
+}
+
+function toLocalInput(date) {
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+function setStatus(ok) {  const el = $("#status");
+  el.classList.toggle("ok", ok === true);
+  el.classList.toggle("err", ok === false);
+}
+
+function applyPreset(days) {
+  const now = new Date();
+  let from;
+  if (days === "all") {
+    const min = state.meta && state.meta.min ? new Date(state.meta.min.replace(" ", "T")) : null;
+    from = min && !isNaN(min) ? min : new Date(now.getTime() - 7 * 864e5);
+  } else {
+    from = new Date(now.getTime() - Number(days) * 864e5);
+  }
+  state.from = toLocalInput(from);
+  state.to = toLocalInput(now);
+  $("#from").value = state.from;
+  $("#to").value = state.to;
+  load();
+}
+
+// Kärbi kõik seeriad ühisesse ajavahemikku, et jooned algaksid ja lõpeksid koos.
+function clipToCommonRange(series) {
+  const active = series.filter((s) => Array.isArray(s.data) && s.data.length);
+  if (active.length < 2) return series;
+  const start = Math.max(...active.map((s) => s.data[0][0]));
+  const end = Math.min(...active.map((s) => s.data[s.data.length - 1][0]));
+  if (!(start < end)) return series;
+  return series.map((s) => {
+    if (!Array.isArray(s.data)) return s;
+    return { ...s, data: s.data.filter((d) => d && d[0] >= start && d[0] <= end) };
+  });
+}
+
+function buildOption(data) {
+  const text = cssVar("--text");
+  const muted = cssVar("--muted");
+  const border = cssVar("--border");
+  const tempColor = cssVar("--temp");
+  const humColor = cssVar("--hum");
+  const outColor = cssVar("--out");
+  const series = [];
+  const palette = ["#1f6feb", "#e8590c", "#2fb344", "#9c36b5", "#f08c00", "#0ca678"];
+  const devices = Object.keys(data.series).sort();
+  let pi = 0;
+
+  const humAxis = state.showHum;
+
+  for (const dev of devices) {
+    const s = data.series[dev];
+    const baseColor = palette[pi % palette.length];
+    pi += 1;
+    if (state.showTemp) {
+      series.push({
+        name: `${dev} · °C`,
+        type: "line",
+        yAxisIndex: 0,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 2.4, color: tempColor },
+        itemStyle: { color: tempColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.temperature[i]]),
+      });
+      if (Array.isArray(s.taidetud) && s.taidetud.some(Boolean)) {
+        series.push({
+          name: `${dev} · täidetud`,
+          type: "scatter",
+          yAxisIndex: 0,
+          symbol: "emptyCircle",
+          symbolSize: 9,
+          itemStyle: { color: tempColor, borderWidth: 2 },
+          z: 3,
+          data: s.t
+            .map((t, i) => (s.taidetud[i] ? [t, s.temperature[i]] : null))
+            .filter(Boolean),
+        });
+      }
+    }
+    if (state.showHum) {
+      series.push({
+        name: `${dev} · %`,
+        type: "line",
+        yAxisIndex: 1,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 1.8, type: "dashed", color: humColor },
+        itemStyle: { color: humColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.humidity[i]]),
+      });
+    }
+  }
+
+  const out = data.outside;
+  if (state.showOut && out && Array.isArray(out.t) && out.t.length) {
+    series.push({
+      name: "Väli · °C",
+      type: "line",
+      yAxisIndex: 0,
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2, color: outColor },
+      itemStyle: { color: outColor },
+      emphasis: { focus: "series" },
+      data: out.t.map((t, i) => [t, out.temperature[i]]),
+    });
+    if (Array.isArray(out.taidetud) && out.taidetud.some(Boolean)) {
+      series.push({
+        name: "Väli · täidetud",
+        type: "scatter",
+        yAxisIndex: 0,
+        symbol: "emptyCircle",
+        symbolSize: 9,
+        itemStyle: { color: outColor, borderWidth: 2 },
+        z: 3,
+        data: out.t
+          .map((t, i) => (out.taidetud[i] ? [t, out.temperature[i]] : null))
+          .filter(Boolean),
+      });
+    }
+  }
+
+  return {
+    backgroundColor: "transparent",
+    animationDuration: 300,
+    color: palette,
+    grid: { left: 6, right: humAxis ? 6 : 6, top: 40, bottom: 62, containLabel: true },
+    legend: {
+      top: 4,
+      type: "scroll",
+      textStyle: { color: text, fontSize: 12 },
+      inactiveColor: muted,
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: cssVar("--panel"),
+      borderColor: border,
+      textStyle: { color: text, fontSize: 12 },
+      axisPointer: { type: "cross", label: { backgroundColor: muted } },
+      valueFormatter: null,
+      formatter: (params) => {
+        const items = (params || []).filter(
+          (p) => !String(p.seriesName).endsWith("· täidetud")
+        );
+        if (!items.length) return "";
+        const d = new Date(items[0].value[0]);
+        const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+        const lines = items.map((p) => {
+          const v = p.value[1];
+          const unit = p.seriesName.endsWith("°C") ? " °C" : " %";
+          return `<div style="display:flex;justify-content:space-between;gap:14px">
+            <span>${p.marker} ${p.seriesName}</span><b>${v == null ? "–" : v + unit}</b></div>`;
+        });
+        return `<div style="font-weight:600;margin-bottom:4px">${head}</div>${lines.join("")}`;
+      },
+    },
+    xAxis: {
+      type: "time",
+      axisLine: { lineStyle: { color: border } },
+      axisLabel: { color: muted, hideOverlap: true },
+      splitLine: { show: false },
+    },
+    yAxis: [
+      {
+        type: "value",
+        name: "°C",
+        scale: true,
+        axisLine: { show: false },
+        axisLabel: { color: muted },
+        splitLine: { lineStyle: { color: border, type: "dashed" } },
+      },
+      ...(humAxis
+        ? [
+            {
+              type: "value",
+              name: "%",
+              scale: true,
+              position: "right",
+              axisLine: { show: false },
+              axisLabel: { color: muted },
+              splitLine: { show: false },
+            },
+          ]
+        : []),
+    ],
+    dataZoom: [
+      { type: "inside", throttle: 50 },
+      { type: "slider", height: 22, bottom: 14, borderColor: border, textStyle: { color: muted }, fillColor: cssVar("--accent-soft") },
+    ],
+    series: clipToCommonRange(series),
+  };
+}
+
+function render(data) {
+  const chartEl = $("#chart");
+  const hasData = Object.keys(data.series).length > 0 && Object.values(data.series).some((s) => s.t.length);
+  $("#hint").hidden = hasData;
+  if (!hasData) {
+    $("#hint").textContent = "Valitud vahemikus andmeid ei ole. Andur salvestab andmeid alates 2026-09-17.";
+  }
+  if (!chart) chart = echarts.init(chartEl, null, { renderer: "canvas" });
+  chart.setOption(buildOption(data), true);
+  chart.resize();
+}
+
+async function load() {
+  const params = new URLSearchParams({
+    from: state.from,
+    to: state.to,
+    agg: state.agg,
+  });
+  if (state.device) params.set("device", state.device);
+  setStatus(null);
+  try {
+    const [tempRes, outRes] = await Promise.all([
+      fetch(`/api/temperatuur?${params}`),
+      fetch(`/api/valisilm?${params}`),
+    ]);
+    if (!tempRes.ok) throw new Error(await tempRes.text());
+    const data = await tempRes.json();
+    if (outRes.ok) {
+      try {
+        data.outside = (await outRes.json()).series;
+      } catch {
+        data.outside = null;
+      }
+    }
+    currentData = data;
+    currentSeries = data.series;
+    render(data);
+    setStatus(true);
+    const f = new Date(state.from.replace(" ", "T"));
+    const t = new Date(state.to.replace(" ", "T"));
+    $("#range-label").textContent = `${f.toLocaleString("et-EE")} → ${t.toLocaleString("et-EE")}`;
+  } catch (err) {
+    console.error(err);
+    setStatus(false);
+    $("#hint").hidden = false;
+    $("#hint").textContent = "Andmete laadimine ebaõnnestus.";
+  }
+}
+
+async function loadMeta() {
+  try {
+    const res = await fetch("/api/meta");
+    if (!res.ok) throw new Error();
+    state.meta = await res.json();
+    const devs = state.meta.devices.length ? state.meta.devices.join(", ") : "—";
+    $("#subtitle").textContent = `Zigbee andurid · ${devs}`;
+    if (state.meta.min) {
+      $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
+    }
+  } catch {
+    $("#subtitle").textContent = "Zigbee andurid";
+  }
+}
+
+async function loadLatest() {
+  try {
+    const res = await fetch("/api/latest");
+    if (!res.ok) return;
+    const data = await res.json();
+    renderCards(data);
+  } catch {
+    /* vaikne */
+  }
+}
+
+function renderCards(data) {
+  const cards = [];
+  for (const r of data.readings) {
+    cards.push(`<div class="card temp"><div class="k">🌡️ ${r.device} · temperatuur</div>
+      <div class="v">${r.temperature ?? "–"}<small> °C</small></div></div>`);
+    cards.push(`<div class="card hum"><div class="k">💧 ${r.device} · niiskus</div>
+      <div class="v">${r.humidity ?? "–"}<small> %</small></div></div>`);
+  }
+  if (data.outside && data.outside.temperature != null) {
+    cards.push(`<div class="card out"><div class="k">🌤️ Väli · temperatuur</div>
+      <div class="v">${data.outside.temperature}<small> °C</small></div></div>`);
+  }
+  if (data.open_faults.length) {
+    const items = data.open_faults
+      .map((f) => `<div>⚠️ <b>${f.device}</b> · ${f.tyyp}${f.detail ? " · " + f.detail : ""}</div>`)
+      .join("");
+    cards.push(`<div class="card wide"><div class="k">Lahtised häired (${data.open_faults.length})</div>
+      <div style="font-size:13px;margin-top:6px;line-height:1.7">${items}</div></div>`);
+  }
+  $("#cards").innerHTML = cards.join("");
+}
+
+function setTheme(theme) {
+  document.documentElement.setAttribute("data-theme", theme);
+  localStorage.setItem("nutikodu-theme", theme);
+  $("#theme-toggle").textContent = theme === "dark" ? "☀️" : "🌙";
+  const meta = $("#meta-theme");
+  if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
+  if (chart) chart.setOption(buildOption(currentData), true);
+}
+
+let currentSeries = {};
+
+function init() {
+  const saved = localStorage.getItem("nutikodu-theme");
+  const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+  setTheme(saved || (prefersDark ? "dark" : "light"));
+  $("#theme-toggle").addEventListener("click", () => {
+    setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+  });
+
+  document.querySelectorAll("#presets button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      applyPreset(btn.dataset.days);
+    });
+  });
+
+  document.querySelectorAll("#agg button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#agg button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      state.agg = btn.dataset.agg;
+      load();
+    });
+  });
+
+  $("#show-temp").addEventListener("change", (e) => { state.showTemp = e.target.checked; load(); });
+  $("#show-hum").addEventListener("change", (e) => { state.showHum = e.target.checked; load(); });
+  $("#show-out").addEventListener("change", (e) => { state.showOut = e.target.checked; load(); });
+  $("#apply").addEventListener("click", () => {
+    state.from = $("#from").value;
+    state.to = $("#to").value;
+    document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+    load();
+  });
+
+  window.addEventListener("resize", () => chart && chart.resize());
+  window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
+  window.addEventListener("load", () => chart && chart.resize());
+
+  loadMeta().then(() => applyPreset(7));
+  loadLatest();
+  setInterval(loadLatest, 60000);
+}
+
+document.addEventListener("DOMContentLoaded", init);

+ 6 - 0
dev/nutikodu-web-v2/static/icon.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+  <rect width="64" height="64" rx="14" fill="#1f6feb"/>
+  <path d="M32 12 13 28h6v20h10V36h6v12h10V28h6L32 12z" fill="#fff"/>
+  <rect x="30" y="20" width="4" height="14" rx="2" fill="#e8590c"/>
+  <circle cx="32" cy="37" r="4" fill="#e8590c"/>
+</svg>

+ 244 - 0
dev/nutikodu-web-v2/static/index.html

@@ -0,0 +1,244 @@
+<!DOCTYPE html>
+<html lang="et" data-theme="light">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+  <meta name="theme-color" content="#1f6feb" id="meta-theme" />
+  <meta name="robots" content="noindex" />
+  <title>Nutikodu — andurid</title>
+  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
+  <link rel="manifest" href="/manifest.webmanifest" />
+  <link rel="apple-touch-icon" href="/icon.svg" />
+  <link rel="stylesheet" href="/style.css" />
+</head>
+<body>
+  <header>
+    <div class="brand">
+      <span class="logo" aria-hidden="true">🏠</span>
+      <div>
+        <h1>Nutikodu</h1>
+        <div class="sub" id="subtitle">Zigbee andurid</div>
+      </div>
+    </div>
+    <div class="header-actions">
+      <span class="status" id="status" title="Ühenduse olek"></span>
+      <a id="auth-link" class="icon-btn" href="/login" title="Sisselogimine" style="text-decoration:none">🔒</a>
+      <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
+    </div>
+  </header>
+
+  <main>
+    <nav class="tabs" id="tabs" aria-label="Vahelehed">
+      <button type="button" class="tab active" data-view="andurid">🏠 Andurid</button>
+      <button type="button" class="tab" data-view="transport">🚌 Transport</button>
+      <button type="button" class="tab" data-view="varv">🎨 Värv</button>
+    </nav>
+
+    <div id="view-andurid" class="view">
+      <section class="cards" id="cards"></section>
+
+      <section class="panel">
+        <div class="controls">
+          <div class="presets" id="presets">
+            <button type="button" data-days="1">24 h</button>
+            <button type="button" data-days="7" class="active">7 p</button>
+            <button type="button" data-days="30">30 p</button>
+            <button type="button" data-days="90">90 p</button>
+            <button type="button" data-days="365">1 a</button>
+            <button type="button" data-days="all">Kõik</button>
+          </div>
+          <div class="custom-range">
+            <label>Algus<input type="datetime-local" id="from" /></label>
+            <label>Lõpp<input type="datetime-local" id="to" /></label>
+            <button type="button" id="apply" class="btn">Kuva</button>
+          </div>
+          <div class="toggles">
+            <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
+            <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
+            <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
+            <div class="seg" id="agg">
+              <button type="button" data-agg="hour" class="active">Tund</button>
+              <button type="button" data-agg="day">Päev</button>
+            </div>
+          </div>
+        </div>
+        <div id="chart" aria-label="Temperatuuri ja niiskuse ajatelg"></div>
+        <div id="hint" class="hint" hidden></div>
+      </section>
+
+      <div id="protected" hidden>
+        <section class="cards" id="cards-uus67"></section>
+
+        <section class="panel">
+          <h2 class="panel-title">⚡ Energia (pistikud)</h2>
+          <div id="chart-energy" aria-label="Energia ajatelg" style="height:300px"></div>
+          <div id="hint-energy" class="hint" hidden></div>
+        </section>
+
+        <section class="panel">
+          <h2 class="panel-title">🚶 Liikumine / kohalolu</h2>
+          <div id="chart-motion" aria-label="Liikumise ajatelg" style="height:220px"></div>
+          <div id="hint-motion" class="hint" hidden></div>
+        </section>
+      </div>
+
+      <footer class="foot">
+        <span id="range-label"></span>
+      </footer>
+    </div>
+
+    <div id="view-transport" class="view" hidden>
+      <section class="panel">
+        <h2 class="panel-title">🚌 Suuremõisa küla — bussid</h2>
+        <div id="bus-list" class="dep-list"></div>
+        <div id="bus-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <div class="panel-head">
+          <h2 class="panel-title">🚢 Heltermaa – Rohuküla</h2>
+          <div class="seg" id="praam-days">
+            <button type="button" data-days="1" class="active">Täna</button>
+            <button type="button" data-days="2">Täna + homme</button>
+          </div>
+        </div>
+        <div id="praam-warning" class="warn-banner" hidden></div>
+        <div id="praam-list" class="dep-list"></div>
+        <div id="praam-hint" class="hint" hidden></div>
+      </section>
+
+      <footer class="foot">
+        <span id="transport-updated"></span>
+      </footer>
+    </div>
+
+    <div id="view-varv" class="view" hidden>
+      <section class="panel" id="varv-power" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🔌 Värvusemõõtja</h2>
+          <span id="varv-power-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-on" class="btn">Lülita sisse</button>
+            <button type="button" id="varv-off" class="btn">Lülita välja</button>
+          </div>
+        </div>
+        <div id="varv-power-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel" id="varv-calib-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Kalibreerimine</h2>
+          <span id="varv-calib-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-calib-dark" class="btn">Salvesta tume</button>
+            <button type="button" id="varv-calib-white" class="btn">Salvesta valge</button>
+          </div>
+        </div>
+        <div class="varv-sub" style="margin-top:8px">
+          1) Kata andur → „Salvesta tume". 2) Pane valge pind sama valgustusega → „Salvesta valge".
+          Oota ~30 s, kuni mõõtmine stabiliseerub.
+        </div>
+        <div id="varv-calib-live" class="varv-sub" style="margin-top:4px"></div>
+        <label class="chk" style="margin-top:10px;display:inline-flex">
+          <input type="checkbox" id="varv-calib-view" /> <span id="varv-calib-view-label">Kuva kalibreeritud värvi</span>
+        </label>
+        <div id="varv-calib-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel" id="varv-ccm-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Värvikaardi (CCM) kalibratsioon</h2>
+          <span id="varv-ccm-status" class="badge muted">—</span>
+        </div>
+        <div class="varv-sub" style="margin-top:6px">
+          Kuva ekraanil üks värv korraga (täisekraanil) ja vajuta vastavat nuppu — mõõdetakse viimased 5.
+          Soovitav: punane, roheline, sinine, valge, must, kollane, tsüaan, magenta.
+        </div>
+        <div class="varv-power-btns" style="margin:8px 0; justify-content:flex-start; flex-wrap:wrap">
+          <button type="button" class="btn" data-ref="red" data-hex="FF0000">Punane</button>
+          <button type="button" class="btn" data-ref="green" data-hex="00FF00">Roheline</button>
+          <button type="button" class="btn" data-ref="blue" data-hex="0000FF">Sinine</button>
+          <button type="button" class="btn" data-ref="white" data-hex="FFFFFF">Valge</button>
+          <button type="button" class="btn" data-ref="black" data-hex="000000">Must</button>
+          <button type="button" class="btn" data-ref="yellow" data-hex="FFFF00">Kollane</button>
+          <button type="button" class="btn" data-ref="cyan" data-hex="00FFFF">Tsüaan</button>
+          <button type="button" class="btn" data-ref="magenta" data-hex="FF00FF">Magenta</button>
+        </div>
+        <div class="varv-power-row" style="margin-top:8px">
+          <label class="chk" style="display:inline-flex;align-items:center;gap:8px">
+            <span>Värv:</span>
+            <input type="color" id="varv-ccm-color" value="#00ff00"
+                   style="width:48px;height:32px;border:0;background:none;padding:0" />
+            <input type="text" id="varv-ccm-hex" value="00FF00" maxlength="7"
+                   style="width:96px;padding:6px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text)" />
+          </label>
+          <button type="button" id="varv-ccm-capture" class="btn">Salvesta see värv</button>
+        </div>
+        <div id="varv-ccm-preview"
+             style="width:220px;height:220px;border-radius:12px;border:1px solid var(--border);margin:8px 0;background:#00ff00"></div>
+        <div class="varv-sub">Hoia andurit selle ruudu vastas (ekraan täisheledusel), oota kuni mõõtmine on stabiilne (HELE), siis „Salvesta see värv".</div>
+
+        <div class="varv-power-row" style="margin-top:8px">
+          <button type="button" id="varv-ccm-fit" class="btn">Arvuta maatriks</button>
+          <span id="varv-ccm-hint" class="varv-sub"></span>
+        </div>
+        <div id="varv-ccm-list" class="varv-sub" style="margin-top:6px"></div>
+      </section>
+
+      <section class="panel">
+        <div class="varv-current">
+          <div id="varv-current-swatch" class="varv-current-swatch"></div>
+          <div class="varv-current-info">
+            <div class="varv-hex" id="varv-hex">#------</div>
+            <div class="varv-meta" id="varv-current-meta">Laadin…</div>
+          </div>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🎨 Viimased mõõtmised</h2>
+        <div id="varv-grid" class="swatch-grid"></div>
+        <div id="varv-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">📊 Spekter <span class="varv-sub" id="varv-spectrum-label"></span></h2>
+        <div id="chart-varv-spectrum" style="height:280px" aria-label="Kanali spekter"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🕓 Värvitelg (viimased 24 h)</h2>
+        <div id="varv-timeline" class="swatch-timeline"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🧭 Kromaatilisus (x, y)</h2>
+        <div id="chart-varv-chroma" style="height:340px" aria-label="Kromaatilisuse hajuvus"></div>
+      </section>
+
+      <footer class="foot">
+        <span id="varv-updated"></span>
+      </footer>
+    </div>
+  </main>
+
+  <script src="/vendor/echarts.min.js"></script>
+  <script src="/app.js"></script>
+
+  <!-- HyperDX Browser Integration: session replays + RUM (vt LOEMIND) -->
+  <script src="https://unpkg.com/@hyperdx/browser@0.26.0/build/index.js"></script>
+  <script>
+    if (window.HyperDX) {
+      HyperDX.init({
+        apiKey: '02a19ecf-f62e-4e17-819f-3ee1498d69f3',
+        service: 'nutikodu-web',
+        url: 'https://otel.odamus.com',
+        consoleCapture: true,
+        advancedNetworkCapture: true,
+        maskAllInputs: true,
+        maskAllText: true,
+      });
+    }
+  </script>
+</body>
+</html>

+ 85 - 0
dev/nutikodu-web-v2/static/index.html.bak.1791389719

@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<html lang="et" data-theme="light">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+  <meta name="theme-color" content="#1f6feb" id="meta-theme" />
+  <meta name="robots" content="noindex" />
+  <title>Nutikodu — andurid</title>
+  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
+  <link rel="manifest" href="/manifest.webmanifest" />
+  <link rel="apple-touch-icon" href="/icon.svg" />
+  <link rel="stylesheet" href="/style.css" />
+</head>
+<body>
+  <header>
+    <div class="brand">
+      <span class="logo" aria-hidden="true">🏠</span>
+      <div>
+        <h1>Nutikodu</h1>
+        <div class="sub" id="subtitle">Zigbee andurid</div>
+      </div>
+    </div>
+    <div class="header-actions">
+      <span class="status" id="status" title="Ühenduse olek"></span>
+      <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
+    </div>
+  </header>
+
+  <main>
+    <section class="cards" id="cards"></section>
+
+    <section class="panel">
+      <div class="controls">
+        <div class="presets" id="presets">
+          <button type="button" data-days="1">24 h</button>
+          <button type="button" data-days="7" class="active">7 p</button>
+          <button type="button" data-days="30">30 p</button>
+          <button type="button" data-days="90">90 p</button>
+          <button type="button" data-days="365">1 a</button>
+          <button type="button" data-days="all">Kõik</button>
+        </div>
+        <div class="custom-range">
+          <label>Algus<input type="datetime-local" id="from" /></label>
+          <label>Lõpp<input type="datetime-local" id="to" /></label>
+          <button type="button" id="apply" class="btn">Kuva</button>
+        </div>
+        <div class="toggles">
+          <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
+          <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
+          <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
+          <div class="seg" id="agg">
+            <button type="button" data-agg="hour" class="active">Tund</button>
+            <button type="button" data-agg="day">Päev</button>
+          </div>
+        </div>
+      </div>
+      <div id="chart" aria-label="Temperatuuri ja niiskuse ajatelg"></div>
+      <div id="hint" class="hint" hidden></div>
+    </section>
+
+    <footer class="foot">
+      <span id="range-label"></span>
+    </footer>
+  </main>
+
+  <script src="/vendor/echarts.min.js"></script>
+  <script src="/app.js"></script>
+
+  <!-- HyperDX Browser Integration: session replays + RUM (vt LOEMIND) -->
+  <script src="https://unpkg.com/@hyperdx/browser@0.26.0/build/index.js"></script>
+  <script>
+    if (window.HyperDX) {
+      HyperDX.init({
+        apiKey: '02a19ecf-f62e-4e17-819f-3ee1498d69f3',
+        service: 'nutikodu-web',
+        url: 'https://otel.odamus.com',
+        consoleCapture: true,
+        advancedNetworkCapture: true,
+        maskAllInputs: true,
+        maskAllText: true,
+      });
+    }
+  </script>
+</body>
+</html>

+ 12 - 0
dev/nutikodu-web-v2/static/manifest.webmanifest

@@ -0,0 +1,12 @@
+{
+  "name": "Nutikodu andurid",
+  "short_name": "Nutikodu",
+  "description": "Zigbee andurite ajalugu (temperatuur, niiskus)",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#0e1116",
+  "theme_color": "#1f6feb",
+  "icons": [
+    { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
+  ]
+}

+ 250 - 0
dev/nutikodu-web-v2/static/style.css

@@ -0,0 +1,250 @@
+:root {
+  --bg: #f4f6fb;
+  --panel: #ffffff;
+  --text: #1c2430;
+  --muted: #64708a;
+  --border: #dde3ee;
+  --accent: #1f6feb;
+  --accent-soft: #e7f0ff;
+  --temp: #e8590c;
+  --hum: #1f9ed4;
+  --out: #2fb344;
+  --ok: #2fb344;
+  --warn: #e0a400;
+  --err: #d64545;
+  --shadow: 0 8px 24px rgba(20, 40, 80, .10);
+}
+[data-theme="dark"] {
+  --bg: #0e1116;
+  --panel: #171c24;
+  --text: #e8edf5;
+  --muted: #93a0b5;
+  --border: #28303c;
+  --accent: #5b9bff;
+  --accent-soft: #1b2a44;
+  --temp: #ff8a4c;
+  --hum: #56c3ee;
+  --out: #45d06a;
+  --ok: #45d06a;
+  --warn: #f0c000;
+  --err: #ff6b6b;
+  --shadow: 0 8px 24px rgba(0, 0, 0, .45);
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+  background: var(--bg);
+  color: var(--text);
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+  -webkit-font-smoothing: antialiased;
+  padding-bottom: env(safe-area-inset-bottom);
+  overflow-x: hidden;
+}
+
+header {
+  position: sticky; top: 0; z-index: 20;
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 12px; padding: 14px 16px calc(14px + env(safe-area-inset-top)) 16px;
+  padding-top: calc(14px + env(safe-area-inset-top));
+  background: color-mix(in srgb, var(--panel) 88%, transparent);
+  backdrop-filter: saturate(180%) blur(12px);
+  border-bottom: 1px solid var(--border);
+}
+.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.logo { font-size: 26px; line-height: 1; }
+.brand h1 { font-size: 18px; margin: 0; letter-spacing: -.2px; }
+.brand .sub { font-size: 12px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.header-actions { display: flex; align-items: center; gap: 8px; }
+.status { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 4px color-mix(in srgb, var(--muted) 20%, transparent); }
+.status.ok { background: var(--ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 20%, transparent); }
+.status.err { background: var(--err); box-shadow: 0 0 0 4px color-mix(in srgb, var(--err) 20%, transparent); }
+.icon-btn {
+  border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  width: 40px; height: 40px; border-radius: 12px; font-size: 18px; cursor: pointer;
+  display: inline-flex; align-items: center; justify-content: center;
+  padding: 0; line-height: 1; text-decoration: none;
+}
+.icon-btn:active { transform: scale(.96); }
+
+main { padding: 14px; max-width: 1100px; margin: 0 auto; display: grid; gap: 14px; }
+main > * { min-width: 0; }
+#protected:not([hidden]) { display: grid; gap: 14px; }
+
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
+.card {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
+  padding: 14px; box-shadow: var(--shadow);
+}
+.card .k { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
+.card .v { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -.5px; }
+.card .v small { font-size: 14px; font-weight: 500; color: var(--muted); }
+.card.temp .v { color: var(--temp); }
+.card.hum .v { color: var(--hum); }
+.card.out .v { color: var(--out); }
+.card.wide { grid-column: 1 / -1; }
+
+.panel {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
+  box-shadow: var(--shadow); padding: 12px; overflow: hidden;
+}
+.controls { display: grid; gap: 10px; margin-bottom: 6px; }
+.presets { display: flex; flex-wrap: wrap; gap: 8px; }
+.presets button, .seg button {
+  border: 1px solid var(--border); background: var(--bg); color: var(--text);
+  padding: 8px 14px; border-radius: 999px; font-size: 13px; cursor: pointer; font-weight: 600;
+}
+.presets button.active, .seg button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
+.custom-range { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; }
+.custom-range label { display: grid; gap: 4px; font-size: 11px; color: var(--muted); flex: 1 1 140px; min-width: 0; }
+.custom-range input {
+  border: 1px solid var(--border); background: var(--bg); color: var(--text);
+  border-radius: 10px; padding: 8px 10px; font-size: 13px; width: 100%; min-width: 0; max-width: 100%;
+}
+.btn {
+  border: none; background: var(--accent); color: #fff; border-radius: 10px;
+  padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
+}
+.btn:active { filter: brightness(.94); }
+.toggles { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
+.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
+.chk input { accent-color: var(--accent); width: 16px; height: 16px; }
+.seg { display: inline-flex; gap: 0; margin-left: auto; }
+.seg button { border-radius: 0; padding: 8px 14px; }
+.seg button:first-child { border-radius: 999px 0 0 999px; }
+.seg button:last-child { border-radius: 0 999px 999px 0; border-left: none; }
+
+#chart { width: 100%; height: 56vh; min-height: 320px; }
+.hint { color: var(--muted); font-size: 13px; text-align: center; padding: 20px; }
+.foot { color: var(--muted); font-size: 12px; text-align: center; padding: 4px 0 20px; }
+
+@media (max-width: 520px) {
+  .brand h1 { font-size: 16px; }
+  #chart { height: 50vh; min-height: 280px; }
+  .seg { margin-left: 0; }
+  .header-actions { display: grid; grid-template-columns: 10px 40px 40px; gap: 8px; align-items: center; justify-content: end; }
+  .header-actions .status { grid-area: 1 / 1; }
+  #auth-link { grid-area: 1 / 2; }
+  #theme-toggle { grid-area: 1 / 3; }
+  .away-pill { grid-area: 2 / 2 / 3 / 4; justify-content: center; font-size: 12px; padding: 0 6px; }
+}
+
+.away-bar { margin: 0; }
+.away-toggle {
+  width: 100%; border: 1px solid var(--border); background: var(--panel);
+  border-radius: 16px; padding: 16px 18px; display: flex; align-items: center; gap: 14px;
+  cursor: pointer; color: var(--text); text-align: left; box-shadow: var(--shadow);
+  transition: border-color .15s, background .15s;
+}
+.away-toggle:active { transform: scale(.99); }
+.away-toggle .away-ico { font-size: 30px; line-height: 1; }
+.away-toggle .away-txt { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.away-toggle .away-txt b { font-size: 18px; }
+.away-toggle .away-txt small { font-size: 12px; color: var(--muted); }
+.away-toggle.away-on { border-color: var(--err); background: color-mix(in srgb, var(--err) 14%, var(--panel)); }
+.away-toggle.away-on .away-txt b { color: var(--err); }
+
+.away-pill {
+  display: inline-flex; align-items: center; gap: 6px;
+  border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  height: 40px; border-radius: 12px; padding: 0 14px; font-size: 13px; font-weight: 600;
+  cursor: pointer; white-space: nowrap; line-height: 1;
+}
+.away-pill .away-ico { font-size: 15px; line-height: 1; }
+.away-pill[hidden] { display: none; }
+.away-pill:active { transform: scale(.96); }
+.away-pill.away-on {
+  border-color: var(--err); background: color-mix(in srgb, var(--err) 16%, var(--panel)); color: var(--err);
+}
+
+/* --- Vahelehed (tabid) --- */
+.tabs {
+  display: flex; gap: 6px; background: var(--panel); border: 1px solid var(--border);
+  border-radius: 14px; padding: 5px; box-shadow: var(--shadow);
+}
+.tab {
+  flex: 1 1 0; border: 0; background: transparent; color: var(--muted);
+  font-size: 14px; font-weight: 600; padding: 10px 12px; border-radius: 10px; cursor: pointer;
+}
+.tab.active { background: var(--accent); color: #fff; }
+.tab:active { transform: scale(.98); }
+
+.view { display: grid; gap: 14px; }
+.view[hidden] { display: none; }
+
+.panel-head {
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 10px; flex-wrap: wrap; margin-bottom: 8px;
+}
+.panel-title { font-size: 15px; margin: 0 0 8px; }
+.panel-head .panel-title { margin: 0; }
+
+.dep-list { display: grid; gap: 6px; }
+.dep-group { display: grid; gap: 6px; margin-bottom: 8px; }
+.dep-group-title {
+  font-size: 12px; color: var(--muted); font-weight: 700;
+  text-transform: uppercase; letter-spacing: .4px; margin-top: 4px;
+}
+.dep {
+  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
+  border: 1px solid var(--border); border-radius: 12px; background: var(--bg);
+}
+.dep-time { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 16px; min-width: 52px; }
+.dep-route { font-weight: 600; color: var(--accent); min-width: 44px; }
+.dep-ship { font-weight: 600; min-width: 56px; }
+.dep-dest { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.badge {
+  font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
+  white-space: nowrap; border: 1px solid transparent;
+}
+.badge.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
+.badge.err { color: var(--err); background: color-mix(in srgb, var(--err) 16%, transparent); }
+.badge.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 18%, transparent); }
+.badge.muted { color: var(--muted); background: color-mix(in srgb, var(--muted) 14%, transparent); }
+
+.warn-banner {
+  border: 1px solid var(--warn); background: color-mix(in srgb, var(--warn) 14%, var(--panel));
+  border-radius: 12px; padding: 10px 12px; font-size: 13px; line-height: 1.55; margin-bottom: 8px;
+}
+.warn-banner a { color: var(--accent); }
+.warn-sub { margin-top: 6px; color: var(--muted); font-size: 12px; }
+
+@media (max-width: 520px) {
+  .dep-time { min-width: 44px; font-size: 15px; }
+  .dep-route { min-width: 0; }
+}
+
+/* --- Värvisensor (varv) --- */
+.varv-current { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
+.varv-current-swatch {
+  width: 110px; height: 110px; border-radius: 14px;
+  border: 1px solid var(--border); flex: 0 0 auto; box-shadow: var(--shadow);
+}
+.varv-current-info { min-width: 0; }
+.varv-hex {
+  font-size: 26px; font-weight: 700; letter-spacing: .5px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.varv-meta { color: var(--muted); font-size: 13px; margin-top: 6px; line-height: 1.6; }
+.varv-sub { color: var(--muted); font-size: 13px; font-weight: 400; }
+
+.swatch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)); gap: 6px; }
+.swatch {
+  aspect-ratio: 1 / 1; width: 100%; border-radius: 8px; border: 1px solid var(--border);
+  cursor: pointer; padding: 0; transition: transform .08s ease;
+}
+.swatch:active { transform: scale(.95); }
+.swatch.sel { outline: 2px solid var(--accent); outline-offset: 1px; }
+
+.swatch-timeline { display: flex; flex-wrap: wrap; gap: 4px; }
+.swatch-timeline .swatch { width: 30px; height: 30px; aspect-ratio: auto; cursor: default; }
+
+@media (max-width: 520px) {
+  .varv-current-swatch { width: 84px; height: 84px; }
+  .varv-hex { font-size: 22px; }
+  .swatch-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
+}
+
+.varv-power-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.varv-power-btns { display: flex; gap: 8px; margin-left: auto; }
+.btn:disabled { opacity: .5; cursor: not-allowed; }

Разница между файлами не показана из-за своего большого размера
+ 34 - 0
dev/nutikodu-web-v2/static/vendor/echarts.min.js


+ 15 - 0
dev/nutikodu-web.bak.20261024-2022/Dockerfile

@@ -0,0 +1,15 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt \
+ && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
+ && opentelemetry-bootstrap -a install
+
+COPY app.py color.py .
+COPY static ./static
+
+EXPOSE 8095
+
+CMD ["opentelemetry-instrument", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8095"]

+ 151 - 0
dev/nutikodu-web.bak.20261024-2022/README.md

@@ -0,0 +1,151 @@
+# Nutikodu veebiliides
+
+Interaktiivne ajatelg `uus67` skeemi zigbee andurite andmetele (ClickHouse).
+FastAPI backend + üks staatiline leht (ECharts). Eestikeelne, mobiilisõbralik,
+tume/hele teema.
+
+## Ligipääs
+
+| Keskkond | URL |
+|----------|-----|
+| Avalik | https://nutikodu.odamus.com |
+| Tailscale | http://100.87.1.24:8095 · http://ohmu.tail20e23.ts.net:8095 |
+
+## Funktsioonid (v1)
+
+- Temperatuuri ja niiskuse ajatelg (kahe teljega), interaktiivne zoom (pinch/pan).
+- Perioodivalik: 24 h / 7 p / 30 p / 90 p / 1 a / Kõik + kohandatud vahemik.
+- Agregeerimine: tund / päev.
+- **Täidetud tunnid** (kui andur ei olnud kättesaadav) on koguja poolt eelmise
+  väärtusega asendatud (`taidetud = true`) ja kuvatakse graafikul **tühjade
+  ringidena** — aegreas ei teki auke.
+- **Välistemperatuur** (eraldi koguja, Open-Meteo) kuvatakse samal graafikul
+  rohelise joonena; niiskus/tuul/sademed salvestatakse andmebaasi.
+- Viimaste väärtuste kaardid + lahtiste häirete ülevaade (60 s värskendus).
+- **Vaheleht "Transport"** — tänased bussiväljumised Suuremõisa külast (peatus.ee)
+  ja Heltermaa–Rohuküla praamiajad + seisakud/tühistused (praamid.ee) ning
+  Väinamere ilmahoiatuse kontekst (RII, parim pingutus). 60 s värskendus.
+- **Vaheleht "Värv"** — AS7341 spektri-värvisensori tulemused (`uus67.varvus`):
+  praegune värv, värviruutude ruudustik, kanalite spekter (f1–f8), värvitelg
+  (viimased 24 h tunnipõhiselt) ja kromaatilisuse (x, y) hajuvus. 20 s värskendus.
+  **Sisselogitult** saab mõõtmise sisse/välja lülitada (MQTT,
+  `varvus/switch/sensor`); OFF peatab ESP32 publitseerimise → **ka ClickHouse'i
+  kirjutamise**. Sisselogitult saab ka salvestada **tume/valge referentsi**
+  (`uus67.varvus_calib`) ja vaadata **kalibreeritud** värvi
+  (R = (S−D)/(W−D)).
+- PWA-manifest (installitav telefonis), `noindex`.
+
+## API
+
+| Otspunkt | Kirjeldus |
+|----------|-----------|
+| `GET /api/meta` | seadmed, tz, andmete ulatus |
+| `GET /api/temperatuur?from=&to=&device=&agg=hour\|day` | aegrida |
+| `GET /api/valisilm?from=&to=&agg=hour\|day` | välistemperatuuri aegrida (temp, niiskus, tuul, sademed) |
+| `GET /api/ukseandur?from=&to=&device=` | 🔒 ukseolekute sündmused |
+| `GET /api/vead?from=&to=&device=` | 🔒 häirete sündmused |
+| `GET /api/liikumine?from=&to=&device=` | 🔒 liikumise/kohalolu sündmused |
+| `GET /api/energia?from=&to=&device=&agg=hour\|day` | 🔒 pistikute energiatarve |
+| `GET /api/uus67_latest` | 🔒 viimane energia + liikumine |
+| `GET /api/latest` | viimased väärtused (lahtised häired ainult sisselogitult) |
+| `GET /api/buss?limit=12` | Suuremõisa küla järgmised bussiväljumised (peatus.ee, avalik) |
+| `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (praamid.ee, avalik) |
+| `GET /api/varv/latest?limit=60` | AS7341 viimased mõõtmised (rgb, kanalid f1–f8, kromaatilisus) — avalik |
+| `GET /api/varv/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
+| `GET /api/varv/power` | 🔒 värvisensori mõõtmise olek (ON/OFF) |
+| `POST /api/varv/power` | 🔒 lülita mõõtmine sisse/välja (keha `{"on":true\|false}`) |
+| `GET /api/varv/calib` | tume/valge referents (avalik) |
+| `POST /api/varv/calib` | 🔒 salvesta tume/valge referents (keha `{"kind":"dark"\|"white","last":5}`) |
+| `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
+| `GET /health` | tervis |
+
+Ajavahemikud kujul `YYYY-MM-DD[ HH:MM[:SS]]` või ISO (T-eraldaja). Vaikimisi viimased 7 päeva.
+
+**🔒 = nõuab autentimist** (Bearer-token automaatikale või sessiooniküpsis brauserile). Ülejäänud on avalikud.
+
+**Värvisensori juhtimine** vajab keskkonnamuutujaid (`dev/.env`):
+`VARV_MQTT_HOST=mqtt.odamus.com`, `VARV_MQTT_PORT=8883`, `VARV_MQTT_TLS=true`,
+`VARV_MQTT_USER`, `VARV_MQTT_PASS` (edge-brokeri konto).
+
+## Autentimine
+
+- **Brauser:** ava `/login`, sisesta parool → allkirjastatud sessiooniküpsis
+  (`HttpOnly`, `SameSite=Lax`). Välja: `/logout`.
+- **API / automaatika:** saada päis `Authorization: Bearer <WEB_ACCESS_TOKEN>`.
+- **Kaitstud:** kõik `ukseandur` / `vead` / `liikumine` / `energia` / `uus67_latest`
+  päringud. Avalikud on ainult temp/niiskus/väli/meta.
+- **Login-rate-limit:** 5 ebaõnnestunud katset / 300 s / IP (libisev aken).
+
+### Näide (API-integratsioon, nt Uptime Kuma, curl, skript)
+
+```bash
+# Kaitstud lõpp-punkt Bearer-tokeniga:
+curl -H "Authorization: Bearer $WEB_ACCESS_TOKEN" \
+     "http://100.87.1.24:8095/api/uus67_latest"
+
+# Tervisekontroll (avalik, Uptime Kuma monitor):
+curl "http://100.87.1.24:8095/health"
+```
+
+`WEB_ACCESS_TOKEN` väärtus on `nutikodu-web` `.env`-is (ohmu:
+`/opt/docker-compose/dev/.env`). Ära pane tokenit URL-i ega jaga avalikult.
+
+## Keskkonnamuutujad
+
+| Muutuja | Vaikimisi | Kirjeldus |
+|---------|-----------|-----------|
+| `CH_URL` | `http://clickhouse:8123` | ClickHouse HTTP |
+| `CH_DB` | `uus67` | andmebaas |
+| `CH_USER` / `CH_PASS` | `zigbee` / – | ainult SELECT `uus67.*` |
+| `WEB_PASSWORD` | tühi | brauseri sisselogimise parool (`/login`) |
+| `WEB_ACCESS_TOKEN` | tühi | API Bearer-token (automaatikaks) |
+| `WEB_SECRET_KEY` | juhuslik | sessiooniküpsise allkirjastamise võti (**sea püsivaks!**) |
+| `SESSION_MAX_AGE` | `604800` | sessiooni kehtivus sekundites (7 p) |
+| `LOGIN_MAX_ATTEMPTS` | `5` | ebaõnnestunud logimiskatsed enne blokeeringut |
+| `LOGIN_WINDOW_SECONDS` | `300` | logimise blokeerimise aken (sek) |
+| `BUS_GRAPHQL` | peatus.ee Digitransit | bussigraafiku GraphQL API |
+| `BUS_STOPS` | `estonia:24703,estonia:27213` | Suuremõisa küla GTFS peatus-ID-d (komaga) |
+| `PRAAM_API` | `https://www.praamid.ee/online/events` | praamigraafiku API (HR=Heltermaa→Rohuküla) |
+| `TRANSPORT_TTL` | `60` | bussi/praami väliste päringute vahemälu (sek) |
+
+## Struktuur
+
+```
+nutikodu/web/
+├── app.py                 # FastAPI backend (ClickHouse HTTP, JSONEachRow)
+├── requirements.txt
+├── Dockerfile             # python:3.12-slim, port 8095
+└── static/
+    ├── index.html         # üks leht, eestikeelne
+    ├── style.css          # light/dark, mobiil-first
+    ├── app.js             # ECharts ajatelg, perioodivalik, teema
+    ├── manifest.webmanifest
+    ├── icon.svg
+    └── vendor/echarts.min.js
+```
+
+## Arendus / juurutus
+
+Kood elab workspace'is (`nutikodu/web/`) ja on juurutatud ohmu'le
+`/opt/docker-compose/dev/nutikodu-web/` (teenus `dev/docker-compose.yml`-is,
+võrkudes `main_ai_network` + `prod_net`).
+
+```bash
+# Muudatuse järel (ohmu):
+scp app.py static/* ohmu:/opt/docker-compose/dev/nutikodu-web/...
+docker compose -f /opt/docker-compose/dev/docker-compose.yml up -d --build nutikodu-web
+docker logs -f nutikodu-web
+```
+
+### Avalik ligipääs (2026-09-17)
+
+- **DNS:** DigitalOcean A-kirje `nutikodu.odamus.com` → `188.166.84.16`.
+- **Edge Caddy** (`kanad`, `/opt/edge/Caddyfile`): `nutikodu.odamus.com { reverse_proxy http://100.91.118.16:80 }`.
+- **Sisemine Caddy** (ohmu `prod_caddy`, `/opt/prod/Caddyfile`): `nutikodu.odamus.com:80 { reverse_proxy nutikodu-web:8095 }`.
+- TLS väljastab edge automaatselt.
+
+## Edasi (järgmised iteratsioonid)
+
+- Ukseanduri olekuribad ajateljel (`/api/ukseandur` on valmis).
+- Häirete markerid/annotatsioonid (`/api/vead` on valmis).
+- Autentimine: **tehtud 2026-10-07** (sessioon + Bearer-token; vt "Autentimine").

+ 1309 - 0
dev/nutikodu-web.bak.20261024-2022/app.py

@@ -0,0 +1,1309 @@
+"""Nutikodu andmete veebiliides (FastAPI).
+
+Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
+  GET /                      -> veebirakendus (static/index.html)
+  GET /api/meta              -> seadmed, tz, andmete ulatus
+  GET /api/temperatuur       -> aegrida (temp + niiskus), agg=hour|day
+  GET /api/ukseandur         -> ukseolekute sündmused (tulevikuks)
+  GET /api/vead              -> häirete sündmused (tulevikuks)
+  GET /api/latest            -> viimased väärtused + lahtised häired
+  GET /health
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hmac
+import html
+import secrets
+import json
+import os
+import re
+import threading
+import time
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import color
+import numpy as np
+import paho.mqtt.client as mqtt
+import requests
+from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
+from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+# Värvisensori juhtimine (MQTT, edge broker). OFF peatab ESP32 publitseerimise
+# -> ClickHouse'i ei kirjutata.
+VARV_MQTT_HOST = os.environ.get("VARV_MQTT_HOST", "")
+VARV_MQTT_PORT = int(os.environ.get("VARV_MQTT_PORT", "8883"))
+VARV_MQTT_TLS = os.environ.get("VARV_MQTT_TLS", "true").strip().lower() in ("1", "true", "yes", "on")
+VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
+VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
+VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
+VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
+VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
+VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
+VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
+
+WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
+WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
+WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
+HA_URL = os.environ.get("HA_URL", "http://100.87.1.24:8123").rstrip("/")
+HA_TOKEN = os.environ.get("HA_TOKEN", "")
+AWAY_ENTITY = os.environ.get("AWAY_ENTITY", "input_boolean.kodust_eemal")
+SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "604800"))
+LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
+LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
+
+DEFAULT_RANGE_DAYS = 7
+MAX_RANGE_DAYS = 3660
+
+# --- Transport (bussid + praamid) ---
+BUS_GRAPHQL = os.environ.get(
+    "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
+).rstrip("/")
+BUS_STOPS = [s.strip() for s in os.environ.get(
+    "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
+PRAAM_API = os.environ.get(
+    "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
+RII_HOIATUS_URL = os.environ.get(
+    "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
+)
+TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
+UA = "Mozilla/5.0 (compatible; Nutikodu-web/1.0)"
+
+PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
+SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
+PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
+SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
+WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
+
+_cache: dict[str, tuple[float, object]] = {}
+_cache_lock = threading.Lock()
+
+
+def cached(key: str, ttl: int, producer):
+    """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
+    now = time.monotonic()
+    with _cache_lock:
+        hit = _cache.get(key)
+        if hit and now - hit[0] < ttl:
+            return hit[1]
+    value = producer()
+    with _cache_lock:
+        _cache[key] = (time.monotonic(), value)
+    return value
+
+
+app = FastAPI(title="Nutikodu", version="0.2.0")
+app.add_middleware(
+    SessionMiddleware,
+    secret_key=WEB_SECRET_KEY,
+    https_only=False,
+    same_site="lax",
+    max_age=SESSION_MAX_AGE,
+)
+
+
+@app.middleware("http")
+async def add_no_cache(request: Request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+_login_failures: dict[str, list[float]] = defaultdict(list)
+_login_lock = threading.Lock()
+
+
+def client_ip(request: Request) -> str:
+    forwarded = request.headers.get("x-forwarded-for")
+    if forwarded:
+        return forwarded.split(",")[0].strip()
+    return request.client.host if request.client else "?"
+
+
+def retry_after_seconds(ip: str) -> int:
+    now = time.monotonic()
+    with _login_lock:
+        history = [t for t in _login_failures.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
+        _login_failures[ip] = history
+        if len(history) >= LOGIN_MAX_ATTEMPTS:
+            return int(LOGIN_WINDOW_SECONDS - (now - history[0])) + 1
+    return 0
+
+
+def register_login_failure(ip: str) -> None:
+    with _login_lock:
+        _login_failures[ip].append(time.monotonic())
+
+
+def reset_login_failures(ip: str) -> None:
+    with _login_lock:
+        _login_failures.pop(ip, None)
+
+
+def is_authed(request: Request) -> bool:
+    """Kas päring on autenditud: Bearer token VÕI sessiooniküpsis."""
+    auth = request.headers.get("authorization", "")
+    if auth.lower().startswith("bearer "):
+        token = auth[7:].strip()
+        if WEB_ACCESS_TOKEN and secrets.compare_digest(token, WEB_ACCESS_TOKEN):
+            return True
+    try:
+        if request.session.get("auth") is True:
+            return True
+    except Exception:
+        pass
+    return False
+
+
+def require_auth(request: Request) -> None:
+    if not is_authed(request):
+        raise HTTPException(
+            status_code=401,
+            detail="Autentimine nõutud (Bearer token või sisselogimine)",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+
+def parse_dt(value: str | None, default: datetime) -> str:
+    """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
+    if not value:
+        return default.strftime("%Y-%m-%d %H:%M:%S")
+    cleaned = value.strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
+        except ValueError:
+            continue
+    raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
+
+
+def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
+    now = datetime.now(TZ)
+    to_s = parse_dt(to, now)
+    to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
+    from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
+    from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
+    if from_dt > to_dt:
+        raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
+    if (to_dt - from_dt).days > MAX_RANGE_DAYS:
+        raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
+    return from_s, to_s
+
+
+@app.get("/api/meta")
+async def meta() -> dict:
+    devices = [row["device"] for row in ch_query(
+        f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
+    )]
+    bounds = ch_query(
+        f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
+        f"FROM {CH_DB}.temperatuur"
+    )
+    first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
+    return {
+        "tz": "Europe/Tallinn",
+        "db": CH_DB,
+        "devices": devices,
+        "min": first["min_hour"],
+        "max": first["max_hour"],
+        "rows": first["rows"],
+        "default_range_days": DEFAULT_RANGE_DAYS,
+    }
+
+
+@app.get("/api/temperatuur")
+async def temperatuur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
+        params,
+    )
+
+    series: dict[str, dict] = {}
+    for row in rows:
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/valisilm")
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
+async def ukseandur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
+        f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/vead", dependencies=[Depends(require_auth)])
+async def vead(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
+        f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "tyyp": r["tyyp"],
+                "olek": r["olek"],
+                "detail": r["detail"],
+                "vaartus": r["vaartus"],
+            }
+            for r in rows
+        ],
+    }
+
+
+
+
+@app.get("/api/liikumine", dependencies=[Depends(require_auth)])
+async def liikumine(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, occupancy, illumination, olek "
+        f"FROM {CH_DB}.liikumine WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "occupancy": bool(r["occupancy"]),
+                "illumination": r["illumination"],
+                "olek": r["olek"],
+            }
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/energia", dependencies=[Depends(require_auth)])
+async def energia(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(ts))) * 1000 AS t, device, "
+            "round(avg(power), 2) AS power, round(avg(voltage), 1) AS voltage, "
+            "round(avg(current), 3) AS current, max(energy) AS energy"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, "
+            "power, voltage, current, energy"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.energia WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, dict] = {}
+    for row in rows:
+        s2 = series.setdefault(
+            row["device"], {"t": [], "power": [], "voltage": [], "current": [], "energy": []}
+        )
+        s2["t"].append(int(row["t"]))
+        s2["power"].append(row["power"])
+        s2["voltage"].append(row["voltage"])
+        s2["current"].append(row["current"])
+        s2["energy"].append(row["energy"])
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+
+
+@app.get("/api/uus67_latest", dependencies=[Depends(require_auth)])
+async def uus67_latest() -> dict:
+    energy = ch_query(
+        f"SELECT device, argMax(power, ts) AS power, argMax(voltage, ts) AS voltage, "
+        f"argMax(current, ts) AS current, argMax(energy, ts) AS energy, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.energia GROUP BY device ORDER BY device"
+    )
+    motion = ch_query(
+        f"SELECT device, argMax(occupancy, ts) AS occupancy, "
+        f"argMax(illumination, ts) AS illumination, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.liikumine GROUP BY device ORDER BY device"
+    )
+    return {
+        "energy": [
+            {
+                "device": r["device"],
+                "power": r.get("power"),
+                "voltage": r.get("voltage"),
+                "current": r.get("current"),
+                "energy": r.get("energy"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in energy
+        ],
+        "motion": [
+            {
+                "device": r["device"],
+                "occupancy": bool(r.get("occupancy")),
+                "illumination": r.get("illumination"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in motion
+        ],
+    }
+
+
+# --- Värvisensor (uus67.varvus, AS7341) ---
+
+
+def varv_load_calib() -> dict:
+    """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
+    try:
+        rows = ch_query(
+            f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
+            f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
+        )
+    except HTTPException:
+        return {}
+    out: dict[str, dict] = {}
+    for r in rows:
+        out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
+    return out
+
+
+def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
+    """R = (S - D) / (W - D) kanali kohta."""
+    out: dict[str, float] = {}
+    for band in VARV_BANDS:
+        v = channels.get(band)
+        d = float(dark.get(band) or 0.0)
+        w = float(white.get(band) or 0.0)
+        if v is not None and (w - d) > 0:
+            out[band] = max(0.0, (float(v) - d) / (w - d))
+    return out
+
+
+# --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
+
+
+def varv_load_ccm() -> list[list[float]] | None:
+    try:
+        rows = ch_query(
+            f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
+            f"ORDER BY updated_at DESC LIMIT 1"
+        )
+    except HTTPException:
+        return None
+    if not rows:
+        return None
+    try:
+        m = json.loads(rows[0]["matrix"])
+        if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
+            return [[float(x) for x in row] for row in m]
+    except Exception:
+        return None
+    return None
+
+
+def _srgb_to_linear(v: float) -> float:
+    c = v / 255.0
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb8(c: float) -> int:
+    c = max(0.0, min(1.0, c))
+    v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+    return int(round(255 * max(0.0, min(1.0, v))))
+
+
+def varv_apply_ccm(matrix: list[list[float]], channels: dict) -> str:
+    vec = np.array([float(channels.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+    lin = np.array(matrix, dtype=float) @ vec
+    r = _linear_to_srgb8(float(lin[0]))
+    g = _linear_to_srgb8(float(lin[1]))
+    b = _linear_to_srgb8(float(lin[2]))
+    return f"#{r:02X}{g:02X}{b:02X}"
+
+
+@app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
+async def varv_ref_set(payload: dict = Body(...)) -> dict:
+    """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
+    name = str(payload.get("name", "")).strip().lower()
+    hexv = str(payload.get("hex", "")).strip().lstrip("#")
+    if len(hexv) != 6:
+        raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
+    try:
+        tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
+    except ValueError:
+        raise HTTPException(status_code=422, detail="vigane hex")
+    last = max(1, min(int(payload.get("last", 5)), 30))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+    total = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if (tr + tg + tb) >= 10 and total < 300:
+        raise HTTPException(
+            status_code=422,
+            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.",
+        )
+    row = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_TABLE} FORMAT JSONEachRow"},
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/ref")
+async def varv_ref_get() -> dict:
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    return {"refs": rows}
+
+
+@app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
+async def varv_ccm_fit() -> dict:
+    """Sobita 3x8 maatriks (kanalid -> lineaarne sRGB) referentsidest."""
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    if len(rows) < 3:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 3 referentsvärvi (soovitav 6-8).")
+    C = np.array([[float(r[b] or 0.0) for b in VARV_BANDS] for r in rows], dtype=float)
+    T = np.array(
+        [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
+         for r in rows],
+        dtype=float,
+    )
+    # Ridge-regulaarsus: 8x8 süsteem on tinglikult haige (cond ~1e7) — ilma
+    # regulaarsuseta maatriks overfitib ja uued mõõtmised annavad rämpsu.
+    # alpha=1e-5 valitud leave-one-out testiga.
+    CtC = C.T @ C
+    CtT = C.T @ T
+    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
+    M = np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)  # (8,3)
+    matrix = M.T.tolist()  # (3,8)
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
+        },
+        data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"n_refs": len(rows), "matrix": matrix}
+
+
+@app.get("/api/varv/ccm")
+async def varv_ccm_get() -> dict:
+    m = varv_load_ccm()
+    return {"matrix": m, "ready": m is not None}
+
+
+@app.get("/api/varv/latest")
+async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
+    """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
+    cols = ", ".join(VARV_BANDS)
+    rows = ch_query(
+        f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
+        f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
+        f"{cols}, clear, nir, gain "
+        f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
+    )
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    ready = bool(dark and white)
+    ccm = varv_load_ccm()
+    out = []
+    for r in rows:
+        bands = {b: r.get(b) for b in VARV_BANDS}
+        raw = color.analyze(bands)
+        item = {
+            "ts": r["ts"],
+            "t": int(r["t"]),
+            "rgb_hex": raw["rgb_hex"],
+            "r": raw["r"],
+            "g": raw["g"],
+            "b": raw["b"],
+            "x_chrom": raw["x_chrom"],
+            "y_chrom": raw["y_chrom"],
+            "bands": bands,
+            "clear": r.get("clear"),
+            "nir": r.get("nir"),
+            "gain": r.get("gain"),
+        }
+        if ready:
+            cc = color.analyze(varv_reflectance(bands, dark, white))
+            item["rgb_hex_cal"] = cc["rgb_hex"]
+            item["x_chrom_cal"] = cc["x_chrom"]
+            item["y_chrom_cal"] = cc["y_chrom"]
+        if ccm:
+            item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands)
+        out.append(item)
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
+
+
+@app.get("/api/varv/calib")
+async def varv_calib_get() -> dict:
+    """Tume/valge referents (avalik lugemine)."""
+    calib = varv_load_calib()
+    return {
+        "dark": calib.get("dark"),
+        "white": calib.get("white"),
+        "ready": bool(calib.get("dark") and calib.get("white")),
+    }
+
+
+@app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
+async def varv_calib_set(payload: dict = Body(...)) -> dict:
+    """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
+    kind = str(payload.get("kind", "")).strip().lower()
+    if kind not in ("dark", "white"):
+        raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
+    last = max(1, min(int(payload.get("last", 5)), 60))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    # Stabiilsuse kontroll: viimased mõõtmised peavad olema samast olekust.
+    totals = [
+        sum(float(r[b]) for b in VARV_BANDS if r.get(b) is not None)
+        for r in rows
+    ]
+    tmax = max(totals) if totals else 0.0
+    if tmax > 0 and (tmax - min(totals)) / tmax > 0.2:
+        raise HTTPException(
+            status_code=422,
+            detail="Mõõtmine pole stabiliseerunud (viimased mõõtmised erinevad). Oota ~30 s pärast katmise/valge pinna muutust ja proovi uuesti.",
+        )
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+
+    # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
+    total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if total_new <= 0:
+        raise HTTPException(status_code=422, detail="Mõõtmine on null — andur on pime või andmed puuduvad.")
+    calib = varv_load_calib()
+    other = calib.get("white" if kind == "dark" else "dark")
+    if other:
+        total_other = sum(float(other.get(b) or 0.0) for b in VARV_BANDS)
+        if kind == "white" and total_new <= total_other * 1.05:
+            raise HTTPException(
+                status_code=422,
+                detail="Valge ja tume on liiga sarnased (valge pole tumedast heledam). Kontrolli: tume = andur kaetud; valge = valge pind valgustatud, sama geomeetria.",
+            )
+        if kind == "dark" and total_new >= total_other * 0.95:
+            raise HTTPException(
+                status_code=422,
+                detail="Tume ja valge on liiga sarnased (tume pole valgest tumedem). Kontrolli, et andur oli täiesti kaetud.",
+            )
+    row = {"kind": kind, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"kind": kind, "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/history")
+async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
+    """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
+    rows = ch_query(
+        f"SELECT toString(toStartOfHour(ts)) AS bucket, "
+        f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
+        f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
+        f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
+        f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
+        f"GROUP BY toStartOfHour(ts) ORDER BY t"
+    )
+    out = []
+    for r in rows:
+        rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
+        out.append({
+            "ts": r["bucket"],
+            "t": int(r["t"]),
+            "r": rv, "g": gv, "b": bv,
+            "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "n": int(r.get("n") or 0),
+        })
+    return {"hours": hours, "rows": out}
+
+
+# --- Värvisensori mõõtmise sisse/välja lüliti (MQTT, ainult sisselogitult) ---
+
+
+def _varv_mqtt_client() -> mqtt.Client:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
+    if VARV_MQTT_TLS:
+        client.tls_set()
+    if VARV_MQTT_USER:
+        client.username_pw_set(VARV_MQTT_USER, VARV_MQTT_PASS)
+    return client
+
+
+def varv_read_state(timeout: float = 4.0) -> str | None:
+    """Loe lüliti retained olek edge-brokerist (lühiajaline ühendus)."""
+    if not VARV_MQTT_HOST:
+        return None
+    box: dict[str, str] = {}
+
+    def on_connect(c, userdata, flags, rc, props=None):
+        if rc == 0:
+            c.subscribe(VARV_STATE_TOPIC)
+
+    def on_message(c, userdata, msg):
+        box["state"] = msg.payload.decode().strip()
+
+    client = _varv_mqtt_client()
+    client.on_connect = on_connect
+    client.on_message = on_message
+    try:
+        client.connect_async(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline and "state" not in box:
+            time.sleep(0.05)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+    state = box.get("state")
+    return state if state in ("ON", "OFF") else None
+
+
+def varv_set_power(on: bool) -> None:
+    if not VARV_MQTT_HOST:
+        raise HTTPException(status_code=503, detail="Värvisensori MQTT pole seadistatud")
+    client = _varv_mqtt_client()
+    try:
+        client.connect(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        info = client.publish(VARV_CMD_TOPIC, "ON" if on else "OFF", qos=1, retain=True)
+        info.wait_for_publish(timeout=5)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+
+
+@app.get("/api/varv/power", dependencies=[Depends(require_auth)])
+def varv_power_get() -> dict:
+    """Värvisensori mõõtmise olek (ainult sisselogitult)."""
+    state = varv_read_state()
+    return {
+        "available": bool(VARV_MQTT_HOST),
+        "state": state,
+        "on": None if state is None else (state == "ON"),
+    }
+
+
+@app.post("/api/varv/power", dependencies=[Depends(require_auth)])
+async def varv_power_set(payload: dict = Body(...)) -> dict:
+    """Lülita värvisensori mõõtmine sisse/välja (ainult sisselogitult).
+
+    OFF peatab ESP32 publitseerimise -> ClickHouse'i ei kirjutata.
+    """
+    on = bool(payload.get("on"))
+    await asyncio.to_thread(varv_set_power, on)
+    state = await asyncio.to_thread(varv_read_state, 3.0)
+    return {"on": on, "state": state}
+
+
+@app.get("/api/latest")
+async def latest(request: Request) -> dict:
+    readings = ch_query(
+        f"SELECT device, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"toString(max(hour)) AS ts_str, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
+    )
+    faults = ch_query(
+        f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
+        f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
+        f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
+    ) if is_authed(request) else []
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {
+        "readings": [
+            {
+                "device": r["device"],
+                "temperature": r["temperature"],
+                "humidity": r["humidity"],
+                "hour": r["ts_str"],
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in readings
+        ],
+        "open_faults": [
+            {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
+            for r in faults
+        ],
+        "outside": outside,
+    }
+
+
+
+# --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
+
+
+def fetch_bus_departures() -> dict:
+    """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
+    fields = (
+        "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
+        "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
+        "trip { route { shortName longName } } }"
+    )
+    aliases = " ".join(
+        f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
+    )
+    try:
+        resp = requests.post(
+            BUS_GRAPHQL,
+            json={"query": "{ " + aliases + " }"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
+    try:
+        payload = resp.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
+    if payload.get("errors"):
+        msg = payload["errors"][0].get("message", "viga")
+        raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
+
+    departures = []
+    for stop in (payload.get("data") or {}).values():
+        if not stop or not stop.get("stoptimesWithoutPatterns"):
+            continue
+        stop_name = stop.get("name") or "Suuremõisa"
+        for st in stop["stoptimesWithoutPatterns"]:
+            service_day = st.get("serviceDay")
+            if service_day is None:
+                continue
+            sched_s = st.get("scheduledDeparture") or 0
+            real_s = st.get("realtimeDeparture")
+            dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
+            try:
+                dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
+            except (OverflowError, OSError, TypeError, ValueError):
+                continue
+            route = (st.get("trip") or {}).get("route") or {}
+            delay = None
+            if st.get("realtime") and real_s is not None:
+                delay = round((int(real_s) - int(sched_s)) / 60)
+            departures.append({
+                "t": int(dep_dt.timestamp() * 1000),
+                "time": dep_dt.strftime("%H:%M"),
+                "date": dep_dt.strftime("%Y-%m-%d"),
+                "stop": stop_name,
+                "route": route.get("shortName") or "",
+                "headsign": st.get("headsign") or "",
+                "realtime": bool(st.get("realtime")),
+                "delay_min": delay,
+            })
+    departures.sort(key=lambda d: d["t"])
+    now = datetime.now(TZ)
+    return {
+        "stops": BUS_STOPS,
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/buss")
+async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
+    """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
+    data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
+    return {**data, "departures": data["departures"][:limit]}
+
+
+# --- Transport: praamid (praamid.ee) ---
+
+
+def _praam_events(day: str, direction: str) -> list[dict]:
+    try:
+        resp = requests.get(
+            PRAAM_API,
+            params={"direction": direction, "departure-date": day, "time-shift": "0"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    try:
+        items = (resp.json() or {}).get("items") or []
+    except ValueError:
+        return []
+    out = []
+    for e in items:
+        try:
+            dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
+        except ValueError:
+            continue
+        ship = e.get("ship")
+        ship_code = ship.get("code") if isinstance(ship, dict) else ship
+        status = e.get("status") or "?"
+        out.append({
+            "uid": e.get("uid"),
+            "direction": direction,
+            "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
+            "date": dep_dt.strftime("%Y-%m-%d"),
+            "time": dep_dt.strftime("%H:%M"),
+            "t": int(dep_dt.timestamp() * 1000),
+            "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
+            "ship_code": ship_code,
+            "status": status,
+            "status_et": PRAAM_STATUS.get(status, status),
+            "disrupted": status != "CONFIRMED",
+        })
+    return out
+
+
+def fetch_weather_warnings() -> list[dict]:
+    """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
+    try:
+        resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    text = html.unescape(resp.text)
+    text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = re.sub(r"\s+", " ", text)
+    found, seen = [], set()
+    for area in SEA_AREAS:
+        i = 0
+        while True:
+            i = text.find(area, i)
+            if i < 0:
+                break
+            seg = text[max(0, i - 100): i + 700]
+            if any(w in seg.lower() for w in WIND_TYPES):
+                m = re.search(r"Kehtib[^.]{0,200}", seg)
+                valid = m.group(0).strip() if m else ""
+                key = (area, valid or seg[:80])
+                if key not in seen:
+                    seen.add(key)
+                    found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
+            i += len(area)
+    return found
+
+
+def fetch_praam(days: int) -> dict:
+    now = datetime.now(TZ)
+    day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
+    departures = []
+    for day in day_list:
+        for direction in ("HR", "RH"):
+            departures.extend(_praam_events(day, direction))
+    departures.sort(key=lambda d: (d["date"], d["time"]))
+    return {
+        "date": day_list[0],
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "warnings": fetch_weather_warnings(),
+        "disrupted": sum(1 for d in departures if d["disrupted"]),
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/praam")
+async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
+    """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
+    return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
+
+
+LOGIN_HTML = """<!doctype html>
+<html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Nutikodu — login</title>
+<style>
+  body{font-family:system-ui,sans-serif;background:#0e1116;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
+  form{background:#161b22;padding:28px 24px;border-radius:12px;border:1px solid #30363d;min-width:280px}
+  h1{font-size:18px;margin:0 0 16px}
+  input{width:100%;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;box-sizing:border-box;font-size:16px}
+  button{margin-top:14px;width:100%;padding:10px;border-radius:8px;border:0;background:#1f6feb;color:#fff;font-size:16px;cursor:pointer}
+  .err{color:#f85149;font-size:14px;margin-top:10px;min-height:18px}
+  a{color:#58a6ff;font-size:13px;text-decoration:none}
+</style></head><body>
+<form method="post" action="/login">
+  <h1>🔒 Nutikodu — sisselogimine</h1>
+  <input type="password" name="password" placeholder="Parool" autofocus autocomplete="current-password">
+  <button type="submit">Logi sisse</button>
+  <div class="err">{{ERR}}</div>
+  <div style="margin-top:14px"><a href="/">← Temperatuuri leht (avalik)</a></div>
+</form>
+</body></html>"""
+
+
+@app.get("/login")
+async def login_page():
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", ""))
+
+
+@app.post("/login")
+async def login_submit(request: Request, password: str = Form("")):
+    ip = client_ip(request)
+    ra = retry_after_seconds(ip)
+    if ra:
+        return HTMLResponse(
+            LOGIN_HTML.replace("{{ERR}}", f"Liiga palju katseid. Proovi {ra} sekundi pärast."),
+            status_code=429,
+        )
+    if WEB_PASSWORD and secrets.compare_digest(password, WEB_PASSWORD):
+        reset_login_failures(ip)
+        request.session["auth"] = True
+        return RedirectResponse("/?auth=1", status_code=303)
+    register_login_failure(ip)
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", "Vale parool"), status_code=401)
+
+
+@app.get("/logout")
+async def logout(request: Request):
+    request.session.clear()
+    return RedirectResponse("/", status_code=303)
+
+
+@app.get("/api/me")
+async def me(request: Request) -> dict:
+    return {"authed": is_authed(request)}
+
+
+
+def _ha_headers() -> dict:
+    return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
+
+
+def ha_get_away() -> bool | None:
+    if not HA_TOKEN:
+        return None
+    try:
+        r = requests.get(f"{HA_URL}/api/states/{AWAY_ENTITY}", headers=_ha_headers(), timeout=8)
+        if r.status_code == 200:
+            return r.json().get("state") == "on"
+    except requests.RequestException:
+        pass
+    return None
+
+
+def ha_set_away(on: bool) -> None:
+    service = "turn_on" if on else "turn_off"
+    r = requests.post(
+        f"{HA_URL}/api/services/input_boolean/{service}",
+        headers=_ha_headers(),
+        json={"entity_id": AWAY_ENTITY},
+        timeout=8,
+    )
+    r.raise_for_status()
+
+
+@app.get("/api/away", dependencies=[Depends(require_auth)])
+async def away_state() -> dict:
+    """Kohalolu ("kodust eemal") olek."""
+    state = ha_get_away()
+    return {"away": state, "available": state is not None}
+
+
+@app.post("/api/away", dependencies=[Depends(require_auth)])
+async def away_set(payload: dict = Body(...)) -> dict:
+    """Lülita "kodust eemal" sisse/välja (keha {"on": true|false})."""
+    on = bool(payload.get("on"))
+    try:
+        ha_set_away(on)
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Home Assistant viga: {exc}") from exc
+    return {"away": on, "available": True}
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)

+ 386 - 0
dev/nutikodu-web.bak.20261024-2022/app.py.bak.1791389719

@@ -0,0 +1,386 @@
+"""Nutikodu andmete veebiliides (FastAPI).
+
+Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
+  GET /                      -> veebirakendus (static/index.html)
+  GET /api/meta              -> seadmed, tz, andmete ulatus
+  GET /api/temperatuur       -> aegrida (temp + niiskus), agg=hour|day
+  GET /api/ukseandur         -> ukseolekute sündmused (tulevikuks)
+  GET /api/vead              -> häirete sündmused (tulevikuks)
+  GET /api/latest            -> viimased väärtused + lahtised häired
+  GET /health
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import requests
+from fastapi import Depends, FastAPI, HTTPException, Query, Request
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
+
+DEFAULT_RANGE_DAYS = 7
+MAX_RANGE_DAYS = 3660
+
+app = FastAPI(title="Nutikodu", version="0.2.0")
+
+
+@app.middleware("http")
+async def add_no_cache(request: Request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+def check_token(request: Request) -> None:
+    if not WEB_ACCESS_TOKEN:
+        return
+    token = request.headers.get("x-access-token") or request.query_params.get("token")
+    if token != WEB_ACCESS_TOKEN:
+        raise HTTPException(status_code=403, detail="Vale või puuduv access token")
+
+
+def parse_dt(value: str | None, default: datetime) -> str:
+    """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
+    if not value:
+        return default.strftime("%Y-%m-%d %H:%M:%S")
+    cleaned = value.strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
+        except ValueError:
+            continue
+    raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
+
+
+def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
+    now = datetime.now(TZ)
+    to_s = parse_dt(to, now)
+    to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
+    from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
+    from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
+    if from_dt > to_dt:
+        raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
+    if (to_dt - from_dt).days > MAX_RANGE_DAYS:
+        raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
+    return from_s, to_s
+
+
+@app.get("/api/meta", dependencies=[Depends(check_token)])
+async def meta() -> dict:
+    devices = [row["device"] for row in ch_query(
+        f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
+    )]
+    bounds = ch_query(
+        f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
+        f"FROM {CH_DB}.temperatuur"
+    )
+    first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
+    return {
+        "tz": "Europe/Tallinn",
+        "db": CH_DB,
+        "devices": devices,
+        "min": first["min_hour"],
+        "max": first["max_hour"],
+        "rows": first["rows"],
+        "default_range_days": DEFAULT_RANGE_DAYS,
+    }
+
+
+@app.get("/api/temperatuur", dependencies=[Depends(check_token)])
+async def temperatuur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
+        params,
+    )
+
+    series: dict[str, dict] = {}
+    for row in rows:
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/valisilm", dependencies=[Depends(check_token)])
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/ukseandur", dependencies=[Depends(check_token)])
+async def ukseandur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
+        f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/vead", dependencies=[Depends(check_token)])
+async def vead(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
+        f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "tyyp": r["tyyp"],
+                "olek": r["olek"],
+                "detail": r["detail"],
+                "vaartus": r["vaartus"],
+            }
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/latest", dependencies=[Depends(check_token)])
+async def latest() -> dict:
+    readings = ch_query(
+        f"SELECT device, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"toString(max(hour)) AS ts_str, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
+    )
+    faults = ch_query(
+        f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
+        f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
+        f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
+    )
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {
+        "readings": [
+            {
+                "device": r["device"],
+                "temperature": r["temperature"],
+                "humidity": r["humidity"],
+                "hour": r["ts_str"],
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in readings
+        ],
+        "open_faults": [
+            {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
+            for r in faults
+        ],
+        "outside": outside,
+    }
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)

+ 127 - 0
dev/nutikodu-web.bak.20261024-2022/color.py

@@ -0,0 +1,127 @@
+"""AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB).
+
+Kasutab Wyman, Sloan & Shirley (2013) analüütilist CIE 1931 2° CMF
+lähendust 8 kanali kesk lainepikkustel.
+
+NB! See EI ole kalibreeritud kolorimeetria — absoluutse värvuse jaoks on
+vaja valge referentsi ja fikseeritud valgustust (vt LOEMIND.md).
+"""
+from __future__ import annotations
+
+import math
+
+# ESPHome as7341 kanalite F1..F8 kesk lainepikkused (nm)
+CHANNEL_WAVELENGTHS: dict[str, int] = {
+    "f1": 415,
+    "f2": 445,
+    "f3": 480,
+    "f4": 515,
+    "f5": 555,
+    "f6": 590,
+    "f7": 630,
+    "f8": 680,
+}
+
+
+def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
+    sigma = sigma1 if x < mu else sigma2
+    return math.exp(-0.5 * ((x - mu) / sigma) ** 2)
+
+
+def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]:
+    """Wyman et al. (2013) lähendus CIE 1931 2° värvusfunktsioonidele."""
+    x_bar = (
+        1.056 * _piecewise_gaussian(wavelength_nm, 599.8, 37.9, 31.0)
+        + 0.362 * _piecewise_gaussian(wavelength_nm, 442.0, 16.0, 26.7)
+        - 0.065 * _piecewise_gaussian(wavelength_nm, 501.1, 20.4, 26.2)
+    )
+    y_bar = (
+        0.821 * _piecewise_gaussian(wavelength_nm, 568.8, 46.9, 40.5)
+        + 0.286 * _piecewise_gaussian(wavelength_nm, 530.9, 16.3, 31.1)
+    )
+    z_bar = (
+        1.217 * _piecewise_gaussian(wavelength_nm, 437.0, 11.8, 36.0)
+        + 0.681 * _piecewise_gaussian(wavelength_nm, 459.0, 26.0, 13.8)
+    )
+    return x_bar, y_bar, z_bar
+
+
+def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
+    """Ligikaudne XYZ: iga kanali väärtus kaalutakse CMF-iga kesk lainepikkusel."""
+    x = y = z = 0.0
+    for name, wavelength in CHANNEL_WAVELENGTHS.items():
+        value = channels.get(name)
+        if value is None:
+            continue
+        cx, cy, cz = cie_xyz_cmf(wavelength)
+        x += value * cx
+        y += value * cy
+        z += value * cz
+    return x, y, z
+
+
+def xyz_to_srgb(x: float, y: float, z: float) -> tuple[int, int, int]:
+    """XYZ -> lineaarne sRGB (D65) -> gamma -> 8-bit. Heledus normaliseeritakse."""
+    r_lin = 3.2406 * x - 1.5372 * y - 0.4986 * z
+    g_lin = -0.9689 * x + 1.8758 * y + 0.0415 * z
+    b_lin = 0.0557 * x - 0.2040 * y + 1.0570 * z
+
+    def gamma(c: float) -> float:
+        c = max(0.0, c)
+        return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
+
+    r, g, b = gamma(r_lin), gamma(g_lin), gamma(b_lin)
+    scale = max(r, g, b, 1e-9)
+    return (
+        int(round(255 * r / scale)),
+        int(round(255 * g / scale)),
+        int(round(255 * b / scale)),
+    )
+
+
+def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
+    total = x + y + z
+    if total <= 0:
+        return 0.0, 0.0
+    return x / total, y / total
+
+
+# D65 valgepunkt (CIE XYZ)
+D65_WHITE = (0.95047, 1.0, 1.08883)
+
+
+def _white_reference_xyz() -> tuple[float, float, float]:
+    """XYZ, kui kõik kanalid = 1 (st ühtlane/valge spekter)."""
+    x = y = z = 0.0
+    for wavelength in CHANNEL_WAVELENGTHS.values():
+        cx, cy, cz = cie_xyz_cmf(wavelength)
+        x += cx
+        y += cy
+        z += cz
+    return x, y, z
+
+
+_WHITE_XYZ = _white_reference_xyz()
+
+
+def analyze(channels: dict[str, float], white_balance: bool = True) -> dict:
+    """Tagastab tuletatud värviväljad ClickHouse'i rea jaoks.
+
+    white_balance=True korral skaleeritakse XYZ nii, et ühtlased kanalid
+    (valge) vastavad D65 valgele — muidu annab mudel kerge roosa nihke.
+    """
+    x, y, z = channels_to_xyz(channels)
+    if white_balance and _WHITE_XYZ[0] > 0 and _WHITE_XYZ[1] > 0 and _WHITE_XYZ[2] > 0:
+        x *= D65_WHITE[0] / _WHITE_XYZ[0]
+        y *= D65_WHITE[1] / _WHITE_XYZ[1]
+        z *= D65_WHITE[2] / _WHITE_XYZ[2]
+    cx, cy = chromaticity_xy(x, y, z)
+    r, g, b = xyz_to_srgb(x, y, z)
+    return {
+        "x_chrom": round(cx, 5),
+        "y_chrom": round(cy, 5),
+        "r": r,
+        "g": g,
+        "b": b,
+        "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
+    }

+ 7 - 0
dev/nutikodu-web.bak.20261024-2022/requirements.txt

@@ -0,0 +1,7 @@
+fastapi
+uvicorn
+requests
+itsdangerous
+python-multipart
+paho-mqtt>=2.0,<3
+numpy

+ 1299 - 0
dev/nutikodu-web.bak.20261024-2022/static/app.js

@@ -0,0 +1,1299 @@
+"use strict";
+
+const state = {
+  from: null,
+  to: null,
+  agg: "hour",
+  device: "",
+  showTemp: true,
+  showHum: true,
+  showOut: true,
+  meta: null,
+};
+
+let chart = null;
+let currentData = { series: {} };
+
+const $ = (sel) => document.querySelector(sel);
+
+function cssVar(name) {
+  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+function pad(n) {
+  return String(n).padStart(2, "0");
+}
+
+function toLocalInput(date) {
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+function setStatus(ok) {  const el = $("#status");
+  el.classList.toggle("ok", ok === true);
+  el.classList.toggle("err", ok === false);
+}
+
+function applyPreset(days) {
+  const now = new Date();
+  let from;
+  if (days === "all") {
+    const min = state.meta && state.meta.min ? new Date(state.meta.min.replace(" ", "T")) : null;
+    from = min && !isNaN(min) ? min : new Date(now.getTime() - 7 * 864e5);
+  } else {
+    from = new Date(now.getTime() - Number(days) * 864e5);
+  }
+  state.from = toLocalInput(from);
+  state.to = toLocalInput(now);
+  $("#from").value = state.from;
+  $("#to").value = state.to;
+  load();
+}
+
+// Kärbi kõik seeriad ühisesse ajavahemikku, et jooned algaksid ja lõpeksid koos.
+function clipToCommonRange(series) {
+  // "· täidetud" hajuvusmärgistused ei tohi ühist vahemikku määrata: viimane märge
+  // võib olla varasem kui viimane mõõtmine → muidu kärbitakse telg liiga vara ja
+  // kaob viimane tund (nt 17:00).
+  const isMarker = (s) => String(s.name || "").endsWith("· täidetud");
+  const active = series.filter((s) => Array.isArray(s.data) && s.data.length && !isMarker(s));
+  if (active.length < 2) return series;
+  const start = Math.max(...active.map((s) => s.data[0][0]));
+  const end = Math.min(...active.map((s) => s.data[s.data.length - 1][0]));
+  if (!(start < end)) return series;
+  return series.map((s) => {
+    if (!Array.isArray(s.data)) return s;
+    return { ...s, data: s.data.filter((d) => d && d[0] >= start && d[0] <= end) };
+  });
+}
+
+function buildOption(data) {
+  const text = cssVar("--text");
+  const muted = cssVar("--muted");
+  const border = cssVar("--border");
+  const tempColor = cssVar("--temp");
+  const humColor = cssVar("--hum");
+  const outColor = cssVar("--out");
+  const series = [];
+  const palette = ["#1f6feb", "#e8590c", "#2fb344", "#9c36b5", "#f08c00", "#0ca678"];
+  const devices = Object.keys(data.series).sort();
+  let pi = 0;
+
+  const humAxis = state.showHum;
+
+  for (const dev of devices) {
+    const s = data.series[dev];
+    const baseColor = palette[pi % palette.length];
+    pi += 1;
+    if (state.showTemp) {
+      series.push({
+        name: `${dev} · °C`,
+        type: "line",
+        yAxisIndex: 0,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 2.4, color: tempColor },
+        itemStyle: { color: tempColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.temperature[i]]),
+      });
+      if (Array.isArray(s.taidetud) && s.taidetud.some(Boolean)) {
+        series.push({
+          name: `${dev} · täidetud`,
+          type: "scatter",
+          yAxisIndex: 0,
+          symbol: "emptyCircle",
+          symbolSize: 9,
+          itemStyle: { color: tempColor, borderWidth: 2 },
+          z: 3,
+          data: s.t
+            .map((t, i) => (s.taidetud[i] ? [t, s.temperature[i]] : null))
+            .filter(Boolean),
+        });
+      }
+    }
+    if (state.showHum) {
+      series.push({
+        name: `${dev} · %`,
+        type: "line",
+        yAxisIndex: 1,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 1.8, type: "dashed", color: humColor },
+        itemStyle: { color: humColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.humidity[i]]),
+      });
+    }
+  }
+
+  const out = data.outside;
+  if (state.showOut && out && Array.isArray(out.t) && out.t.length) {
+    series.push({
+      name: "Väli · °C",
+      type: "line",
+      yAxisIndex: 0,
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2, color: outColor },
+      itemStyle: { color: outColor },
+      emphasis: { focus: "series" },
+      data: out.t.map((t, i) => [t, out.temperature[i]]),
+    });
+    if (Array.isArray(out.taidetud) && out.taidetud.some(Boolean)) {
+      series.push({
+        name: "Väli · täidetud",
+        type: "scatter",
+        yAxisIndex: 0,
+        symbol: "emptyCircle",
+        symbolSize: 9,
+        itemStyle: { color: outColor, borderWidth: 2 },
+        z: 3,
+        data: out.t
+          .map((t, i) => (out.taidetud[i] ? [t, out.temperature[i]] : null))
+          .filter(Boolean),
+      });
+    }
+  }
+
+  return {
+    backgroundColor: "transparent",
+    animationDuration: 300,
+    color: palette,
+    grid: { left: 6, right: humAxis ? 6 : 6, top: 40, bottom: 62, containLabel: true },
+    legend: {
+      top: 4,
+      type: "scroll",
+      textStyle: { color: text, fontSize: 12 },
+      inactiveColor: muted,
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: cssVar("--panel"),
+      borderColor: border,
+      textStyle: { color: text, fontSize: 12 },
+      axisPointer: { type: "cross", label: { backgroundColor: muted } },
+      valueFormatter: null,
+      formatter: (params) => {
+        const items = (params || []).filter(
+          (p) => !String(p.seriesName).endsWith("· täidetud")
+        );
+        if (!items.length) return "";
+        const d = new Date(items[0].value[0]);
+        const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+        const lines = items.map((p) => {
+          const v = p.value[1];
+          const unit = p.seriesName.endsWith("°C") ? " °C" : " %";
+          return `<div style="display:flex;justify-content:space-between;gap:14px">
+            <span>${p.marker} ${p.seriesName}</span><b>${v == null ? "–" : v + unit}</b></div>`;
+        });
+        return `<div style="font-weight:600;margin-bottom:4px">${head}</div>${lines.join("")}`;
+      },
+    },
+    xAxis: {
+      type: "time",
+      axisLine: { lineStyle: { color: border } },
+      axisLabel: { color: muted, hideOverlap: true },
+      splitLine: { show: false },
+    },
+    yAxis: [
+      {
+        type: "value",
+        name: "°C",
+        scale: true,
+        axisLine: { show: false },
+        axisLabel: { color: muted },
+        splitLine: { lineStyle: { color: border, type: "dashed" } },
+      },
+      ...(humAxis
+        ? [
+            {
+              type: "value",
+              name: "%",
+              scale: true,
+              position: "right",
+              axisLine: { show: false },
+              axisLabel: { color: muted },
+              splitLine: { show: false },
+            },
+          ]
+        : []),
+    ],
+    dataZoom: [
+      { type: "inside", throttle: 50 },
+      { type: "slider", height: 22, bottom: 14, borderColor: border, textStyle: { color: muted }, fillColor: cssVar("--accent-soft") },
+    ],
+    series: clipToCommonRange(series),
+  };
+}
+
+function render(data) {
+  const chartEl = $("#chart");
+  const hasData = Object.keys(data.series).length > 0 && Object.values(data.series).some((s) => s.t.length);
+  $("#hint").hidden = hasData;
+  if (!hasData) {
+    $("#hint").textContent = "Valitud vahemikus andmeid ei ole. Andur salvestab andmeid alates 2026-09-17.";
+  }
+  if (!chart) chart = echarts.init(chartEl, null, { renderer: "canvas" });
+  chart.setOption(buildOption(data), true);
+  chart.resize();
+}
+
+async function load() {
+  const params = new URLSearchParams({
+    from: state.from,
+    to: state.to,
+    agg: state.agg,
+  });
+  if (state.device) params.set("device", state.device);
+  setStatus(null);
+  try {
+    const [tempRes, outRes] = await Promise.all([
+      fetch(`/api/temperatuur?${params}`),
+      fetch(`/api/valisilm?${params}`),
+    ]);
+    if (!tempRes.ok) throw new Error(await tempRes.text());
+    const data = await tempRes.json();
+    if (outRes.ok) {
+      try {
+        data.outside = (await outRes.json()).series;
+      } catch {
+        data.outside = null;
+      }
+    }
+    currentData = data;
+    currentSeries = data.series;
+    render(data);
+    loadProtected();
+    setStatus(true);
+    const f = new Date(state.from.replace(" ", "T"));
+    const t = new Date(state.to.replace(" ", "T"));
+    $("#range-label").textContent = `${f.toLocaleString("et-EE")} → ${t.toLocaleString("et-EE")}`;
+  } catch (err) {
+    console.error(err);
+    setStatus(false);
+    $("#hint").hidden = false;
+    $("#hint").textContent = "Andmete laadimine ebaõnnestus.";
+  }
+}
+
+async function loadMeta() {
+  try {
+    const res = await fetch("/api/meta");
+    if (!res.ok) throw new Error();
+    state.meta = await res.json();
+    $("#subtitle").textContent = "Zigbee andurid";
+    if (state.meta.min) {
+      $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
+    }
+  } catch {
+    $("#subtitle").textContent = "Zigbee andurid";
+  }
+}
+
+async function loadLatest() {
+  try {
+    const res = await fetch("/api/latest");
+    if (!res.ok) return;
+    const data = await res.json();
+    renderCards(data);
+  } catch {
+    /* vaikne */
+  }
+}
+
+function renderCards(data) {
+  const cards = [];
+  for (const r of data.readings) {
+    cards.push(`<div class="card temp"><div class="k">🌡️ ${r.device} · temperatuur</div>
+      <div class="v">${r.temperature ?? "–"}<small> °C</small></div></div>`);
+    cards.push(`<div class="card hum"><div class="k">💧 ${r.device} · niiskus</div>
+      <div class="v">${r.humidity ?? "–"}<small> %</small></div></div>`);
+  }
+  if (data.outside && data.outside.temperature != null) {
+    cards.push(`<div class="card out"><div class="k">🌤️ Väli · temperatuur</div>
+      <div class="v">${data.outside.temperature}<small> °C</small></div></div>`);
+  }
+  if (data.open_faults.length) {
+    const items = data.open_faults
+      .map((f) => `<div>⚠️ <b>${f.device}</b> · ${f.tyyp}${f.detail ? " · " + f.detail : ""}</div>`)
+      .join("");
+    cards.push(`<div class="card wide"><div class="k">Lahtised häired (${data.open_faults.length})</div>
+      <div style="font-size:13px;margin-top:6px;line-height:1.7">${items}</div></div>`);
+  }
+  $("#cards").innerHTML = cards.join("");
+}
+
+function setTheme(theme) {
+  document.documentElement.setAttribute("data-theme", theme);
+  localStorage.setItem("nutikodu-theme", theme);
+  $("#theme-toggle").textContent = theme === "dark" ? "☀️" : "🌙";
+  const meta = $("#meta-theme");
+  if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
+  if (chart) chart.setOption(buildOption(currentData), true);
+}
+
+let currentSeries = {};
+
+function init() {
+  const saved = localStorage.getItem("nutikodu-theme");
+  const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+  setTheme(saved || (prefersDark ? "dark" : "light"));
+  $("#theme-toggle").addEventListener("click", () => {
+    setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+  });
+
+  document.querySelectorAll("#presets button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      applyPreset(btn.dataset.days);
+    });
+  });
+
+  document.querySelectorAll("#agg button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#agg button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      state.agg = btn.dataset.agg;
+      load();
+    });
+  });
+
+  $("#show-temp").addEventListener("change", (e) => { state.showTemp = e.target.checked; load(); });
+  $("#show-hum").addEventListener("change", (e) => { state.showHum = e.target.checked; load(); });
+  $("#show-out").addEventListener("change", (e) => { state.showOut = e.target.checked; load(); });
+  $("#apply").addEventListener("click", () => {
+    state.from = $("#from").value;
+    state.to = $("#to").value;
+    document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+    load();
+  });
+
+  window.addEventListener("resize", () => chart && chart.resize());
+  window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
+  window.addEventListener("load", () => chart && chart.resize());
+
+  checkAuth().then(() => loadMeta().then(() => applyPreset(7)));
+  loadLatest();
+  setInterval(loadLatest, 60000);
+  setInterval(loadProtected, 60000);
+  initTabs();
+}
+
+
+
+// --- Uus67: energia + liikumine ---
+let chartEnergy = null;
+let chartMotion = null;
+
+function palColors() {
+  return ["#1f6feb", "#e8590c", "#2fb344", "#9c36b7", "#f08c00", "#0ca678"];
+}
+
+async function loadEnergy() {
+  if (!state.from) return;
+  const params = new URLSearchParams({ from: state.from, to: state.to, agg: state.agg });
+  try {
+    const res = await fetch(`/api/energia?${params}`);
+    if (!res.ok) return;
+    renderEnergy(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderEnergy(data) {
+  const el = document.getElementById("chart-energy");
+  if (!el) return;
+  const seriesData = data.series || {};
+  const hasData = Object.keys(seriesData).length > 0 && Object.values(seriesData).some((s) => s.t.length);
+  const hint = document.getElementById("hint-energy");
+  if (hint) { hint.hidden = hasData; if (!hasData) hint.textContent = "Energia andmeid veel pole (koguneb tunnipõhiselt, esimene rida täistunnil)."; }
+  if (!hasData) { if (chartEnergy) chartEnergy.clear(); return; }
+  if (!chartEnergy) chartEnergy = echarts.init(el, null, { renderer: "canvas" });
+  const pal = palColors();
+  const series = Object.keys(seriesData).sort().map((dev, i) => {
+    const s = seriesData[dev];
+    return {
+      name: `${dev} · W`,
+      type: "line",
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2.2, color: pal[i % pal.length] },
+      itemStyle: { color: pal[i % pal.length] },
+      data: s.t.map((t, k) => [t, s.power[k]]),
+    };
+  });
+  chartEnergy.setOption({
+    backgroundColor: "transparent",
+    tooltip: { trigger: "axis" },
+    legend: { top: 0, type: "scroll", textStyle: { color: cssVar("--text") } },
+    grid: { left: 6, right: 6, top: 34, bottom: 44, containLabel: true },
+    xAxis: { type: "time", axisLabel: { color: cssVar("--muted") }, axisLine: { lineStyle: { color: cssVar("--border") } } },
+    yAxis: { type: "value", name: "W", scale: true, axisLabel: { color: cssVar("--muted") }, splitLine: { lineStyle: { color: cssVar("--border"), type: "dashed" } } },
+    dataZoom: [{ type: "inside" }, { type: "slider", height: 18, bottom: 8, borderColor: cssVar("--border") }],
+    series,
+  }, true);
+  chartEnergy.resize();
+}
+
+async function loadMotion() {
+  if (!state.from) return;
+  const params = new URLSearchParams({ from: state.from, to: state.to });
+  try {
+    const res = await fetch(`/api/liikumine?${params}`);
+    if (!res.ok) return;
+    renderMotion(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderMotion(data) {
+  const el = document.getElementById("chart-motion");
+  if (!el) return;
+  const ev = data.events || [];
+  const hint = document.getElementById("hint-motion");
+  if (hint) { hint.hidden = ev.length > 0; if (!ev.length) hint.textContent = "Liikumise sündmusi veel pole."; }
+  if (!ev.length) { if (chartMotion) chartMotion.clear(); return; }
+  if (!chartMotion) chartMotion = echarts.init(el, null, { renderer: "canvas" });
+  const pal = palColors();
+  const devices = [...new Set(ev.map((e) => e.device))].sort();
+  const series = devices.map((dev, i) => ({
+    name: dev,
+    type: "scatter",
+    symbolSize: 13,
+    itemStyle: { color: pal[i % pal.length] },
+    data: ev.filter((e) => e.device === dev && e.occupancy).map((e) => [e.t, 1]),
+  }));
+  chartMotion.setOption({
+    backgroundColor: "transparent",
+    tooltip: {
+      trigger: "axis",
+      formatter: (p) => {
+        if (!p || !p.length) return "";
+        const d = new Date(p[0].value[0]);
+        return `${d.toLocaleString("et-EE")}<br>${p.map((x) => x.seriesName).join(", ")}`;
+      },
+    },
+    legend: { top: 0, textStyle: { color: cssVar("--text") } },
+    grid: { left: 6, right: 6, top: 34, bottom: 44, containLabel: true },
+    xAxis: { type: "time", axisLabel: { color: cssVar("--muted") }, axisLine: { lineStyle: { color: cssVar("--border") } } },
+    yAxis: { type: "value", min: 0, max: 1.4, axisLabel: { show: false }, splitLine: { show: false } },
+    dataZoom: [{ type: "inside" }, { type: "slider", height: 18, bottom: 8, borderColor: cssVar("--border") }],
+    series,
+  }, true);
+  chartMotion.resize();
+}
+
+async function loadUus67Latest() {
+  try {
+    const res = await fetch("/api/uus67_latest");
+    if (!res.ok) return;
+    const data = await res.json();
+    renderUus67Cards(data);
+  } catch (e) { console.error(e); }
+}
+
+function renderUus67Cards(data) {
+  const host = document.getElementById("cards-uus67");
+  if (!host) return;
+  const cards = [];
+  for (const e of data.energy || []) {
+    cards.push(`<div class="card"><div class="k">⚡ ${e.device} · võimsus</div>
+      <div class="v">${e.power ?? "–"}<small> W</small></div></div>`);
+    if (e.energy != null) {
+      cards.push(`<div class="card"><div class="k">🔌 ${e.device} · kWh</div>
+        <div class="v">${e.energy}<small> kWh</small></div></div>`);
+    }
+  }
+  for (const m of data.motion || []) {
+    cards.push(`<div class="card ${m.occupancy ? "temp" : ""}"><div class="k">🚶 ${m.device}</div>
+      <div class="v">${m.occupancy ? "liikumine" : "rahu"}</div></div>`);
+  }
+  host.innerHTML = cards.join("");
+}
+
+
+
+// --- Autentimine ---
+function ensureAwayButton() {
+  if (document.getElementById("away-toggle")) return;
+  const actions = document.querySelector(".header-actions");
+  const authLink = document.getElementById("auth-link");
+  if (!actions || !authLink) return;
+  const btn = document.createElement("button");
+  btn.type = "button";
+  btn.id = "away-toggle";
+  btn.className = "away-pill";
+  btn.setAttribute("aria-label", "Kodust eemal lüliti");
+  btn.innerHTML = '<span class="away-ico">\ud83c\udfe0</span><span id="away-title">Kodus</span>';
+  btn.addEventListener("click", toggleAway);
+  actions.insertBefore(btn, authLink);
+}
+
+function applyAuth(authed) {
+  state.authed = authed;
+  if (authed) {
+    ensureAwayButton();
+    loadAway();
+  } else {
+    const awayBtn = document.getElementById("away-toggle");
+    if (awayBtn) awayBtn.remove();
+  }
+  const prot = document.getElementById("protected");
+  if (prot) prot.hidden = !authed;
+  const vp = document.getElementById("varv-power");
+  if (vp) vp.hidden = !authed;
+  const vc = document.getElementById("varv-calib-panel");
+  if (vc) vc.hidden = !authed;
+  const vccm = document.getElementById("varv-ccm-panel");
+  if (vccm) vccm.hidden = !authed;
+  if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); initVarvCcmButtons(); loadVarvCcm(); }
+  const link = document.getElementById("auth-link");
+  if (link) {
+    link.href = authed ? "/logout" : "/login";
+    link.title = authed ? "Logi välja" : "Sisselogimine";
+    link.textContent = authed ? "\ud83d\udd13" : "\ud83d\udd12";
+  }
+}
+
+async function checkAuth() {
+  try {
+    const res = await fetch("/api/me");
+    if (!res.ok) { applyAuth(false); return; }
+    const data = await res.json();
+    applyAuth(!!data.authed);
+  } catch { applyAuth(false); }
+}
+
+async function loadProtected() {
+  if (!state.authed) return;
+  loadEnergy();
+  loadMotion();
+  loadUus67Latest();
+}
+
+
+
+// --- Kodust eemal (HA input_boolean) ---
+let awayState = null;
+
+async function loadAway() {
+  if (!state.authed) return;
+  try {
+    const res = await fetch("/api/away");
+    if (!res.ok) return;
+    const d = await res.json();
+    awayState = d.away;
+    renderAway(d.away, d.available);
+  } catch (e) { console.error(e); }
+}
+
+function renderAway(away, available) {
+  const btn = document.getElementById("away-toggle");
+  if (!btn) return;
+  const ico = btn.querySelector(".away-ico");
+  const title = document.getElementById("away-title");
+  if (away === true) {
+    btn.classList.add("away-on");
+    if (ico) ico.textContent = "🚨";
+    if (title) title.textContent = "Eemal";
+  } else {
+    btn.classList.remove("away-on");
+    if (ico) ico.textContent = "🏠";
+    if (title) title.textContent = "Kodus";
+    if (available === false && title) title.textContent = "HA offline";
+  }
+}
+
+async function toggleAway() {
+  const on = !(awayState === true);
+  awayState = on;
+  renderAway(on, true);
+  try {
+    const res = await fetch("/api/away", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ on }),
+    });
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    const d = await res.json();
+    awayState = d.away;
+    renderAway(d.away, true);
+  } catch (e) {
+    console.error(e);
+    loadAway();
+  }
+}
+
+// --- Värvisensor (AS7341, uus67.varvus) ---
+let varvActive = false;
+let chartVarvSpectrum = null;
+let chartVarvChroma = null;
+let varvLatest = [];
+let varvSelected = 0;
+
+const VARV_BANDS = [
+  ["f1", "415 nm", "#7b2ff7"],
+  ["f2", "445 nm", "#3b5bdb"],
+  ["f3", "480 nm", "#1c7ed6"],
+  ["f4", "515 nm", "#2fb344"],
+  ["f5", "555 nm", "#9acd32"],
+  ["f6", "590 nm", "#f0b000"],
+  ["f7", "630 nm", "#e8590c"],
+  ["f8", "680 nm", "#c92a2a"],
+];
+
+async function loadVarv() {
+  const hint = document.getElementById("varv-hint");
+  try {
+    const [r1, r2] = await Promise.all([
+      fetch("/api/varv/latest?limit=60"),
+      fetch("/api/varv/history?hours=24"),
+    ]);
+    if (!r1.ok || !r2.ok) throw new Error("HTTP " + r1.status + "/" + r2.status);
+    varvLatest = (await r1.json()).rows || [];
+    const history = (await r2.json()).rows || [];
+    if (varvSelected >= varvLatest.length) varvSelected = 0;
+    renderVarvCurrent();
+    renderVarvGrid();
+    renderVarvTimeline(history);
+    renderVarvSpectrum();
+    renderVarvChroma(varvLatest);
+    renderVarvCalibLive();
+    const upd = document.getElementById("varv-updated");
+    if (upd) upd.textContent = varvLatest.length ? "Viimane mõõtmine " + varvLatest[0].ts : "Andmeid pole";
+    if (hint) hint.hidden = true;
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Värviandmete laadimine ebaõnnestus."; }
+  }
+}
+
+const varvFmt = (v) => (v == null ? "–" : Number(v).toFixed(3));
+
+function renderVarvCurrent() {
+  const sw = document.getElementById("varv-current-swatch");
+  const hex = document.getElementById("varv-hex");
+  const meta = document.getElementById("varv-current-meta");
+  if (!sw) return;
+  if (!varvLatest.length) {
+    sw.style.background = "transparent";
+    if (hex) hex.textContent = "#------";
+    if (meta) meta.textContent = "Andmeid pole.";
+    return;
+  }
+  const c = varvLatest[0];
+  const p = varvPick(c);
+  sw.style.background = p.hex;
+  if (hex) hex.textContent = p.dark ? "PIME" : p.hex.toUpperCase();
+  if (meta) meta.innerHTML =
+    `${c.ts}${varvCalibView ? " · kalibreeritud" : ""}` +
+    (p.dark
+      ? "<br>pime — värv ei ole usaldusväärne (valgusta pinda)"
+      : `<br>x = ${varvFmt(p.x)} · y = ${varvFmt(p.y)} · gain ${c.gain}`);
+}
+
+function renderVarvGrid() {
+  const grid = document.getElementById("varv-grid");
+  if (!grid) return;
+  grid.innerHTML = varvLatest.map((c, i) => {
+    const p = varvPick(c);
+    return `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
+      `style="background:${p.hex}" title="${c.ts}  ${p.hex}" data-i="${i}"></button>`;
+  }).join("");
+  grid.querySelectorAll(".swatch").forEach((el) => {
+    el.addEventListener("click", () => {
+      varvSelected = Number(el.dataset.i) || 0;
+      renderVarvGrid();
+      renderVarvSpectrum();
+    });
+  });
+}
+
+function renderVarvTimeline(rows) {
+  const el = document.getElementById("varv-timeline");
+  if (!el) return;
+  if (!rows.length) {
+    el.innerHTML = '<span class="varv-sub">Viimase 24 h andmeid pole.</span>';
+    return;
+  }
+  el.innerHTML = rows.map((r) => {
+    const d = new Date(r.t);
+    const hh = pad(d.getHours()) + ":00";
+    return `<div class="swatch tl" style="background:${r.rgb_hex}" ` +
+      `title="${r.ts.slice(0, 10)} ${hh}  ${r.rgb_hex} · ${r.n} mõõtmist"></div>`;
+  }).join("");
+}
+
+function varvReflectance(c) {
+  const d = varvCalibData && varvCalibData.dark;
+  const w = varvCalibData && varvCalibData.white;
+  if (!d || !w) return null;
+  const out = {};
+  for (const b of VARV_BANDS) {
+    const s = Number(c.bands[b[0]]) || 0;
+    const dv = Number(d[b[0]]) || 0;
+    const wv = Number(w[b[0]]) || 0;
+    out[b[0]] = (wv - dv) > 0 ? Math.max(0, (s - dv) / (wv - dv)) : 0;
+  }
+  return out;
+}
+
+function renderVarvSpectrum() {
+  const el = document.getElementById("chart-varv-spectrum");
+  if (!el || !varvLatest.length) { if (chartVarvSpectrum) chartVarvSpectrum.clear(); return; }
+  const c = varvLatest[Math.min(varvSelected, varvLatest.length - 1)];
+  const refl = varvReflectance(c);
+  const label = document.getElementById("varv-spectrum-label");
+  if (label) {
+    label.textContent = refl
+      ? `— ${c.ts} · ${((c.rgb_hex_cal || c.rgb_hex) || "").toUpperCase()} · reflekteeruvus %`
+      : `— ${c.ts} · ${c.rgb_hex.toUpperCase()} · toored kanalid (valgusti-domineeritud)`;
+  }
+  if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" });
+  const data = VARV_BANDS.map((b) => ({
+    value: refl ? Math.round((refl[b[0]] || 0) * 1000) / 10 : (c.bands[b[0]] || 0),
+    itemStyle: { color: b[2] },
+  }));
+  chartVarvSpectrum.setOption({
+    grid: { left: 56, right: 16, top: 16, bottom: 32 },
+    xAxis: {
+      type: "category",
+      data: VARV_BANDS.map((b) => b[1]),
+      axisLabel: { color: cssVar("--muted") },
+    },
+    yAxis: {
+      type: "value",
+      max: refl ? 100 : undefined,
+      axisLabel: {
+        color: cssVar("--muted"),
+        formatter: refl ? "{value}%" : "{value}",
+      },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: { trigger: "axis" },
+    series: [{
+      type: "bar",
+      barWidth: "62%",
+      data,
+    }],
+  }, true);
+  chartVarvSpectrum.resize();
+}
+
+function renderVarvChroma(rows) {
+  const el = document.getElementById("chart-varv-chroma");
+  if (!el) return;
+  if (!chartVarvChroma) chartVarvChroma = echarts.init(el, null, { renderer: "canvas" });
+  const pts = rows
+    .filter((r) => r.x_chrom != null && r.y_chrom != null)
+    .map((r) => [Number(r.x_chrom), Number(r.y_chrom), r.ts, r.rgb_hex]);
+  chartVarvChroma.setOption({
+    grid: { left: 52, right: 24, top: 20, bottom: 40 },
+    xAxis: {
+      type: "value", min: 0, max: 0.75, name: "x",
+      nameTextStyle: { color: cssVar("--muted") },
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    yAxis: {
+      type: "value", min: 0, max: 0.85, name: "y",
+      nameTextStyle: { color: cssVar("--muted") },
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: {
+      formatter: (p) => {
+        if (p.seriesName === "D65") return "valge punkt D65";
+        return `${p.value[3]}<br>x=${p.value[0].toFixed(3)} y=${p.value[1].toFixed(3)}<br>${p.value[2]}`;
+      },
+    },
+    series: [
+      {
+        name: "mõõtmised",
+        type: "scatter",
+        symbolSize: 11,
+        data: pts,
+        itemStyle: { color: "rgba(31,111,235,0.7)" },
+      },
+      {
+        name: "D65",
+        type: "scatter",
+        symbol: "pin",
+        symbolSize: 26,
+        data: [[0.3127, 0.329]],
+        itemStyle: { color: "#8b949e" },
+      },
+    ],
+  }, true);
+  chartVarvChroma.resize();
+}
+
+// --- Värv: mõõtmise sisse/välja lüliti (ainult sisselogitult) ---
+let varvPowerBusy = false;
+let varvPowerWired = false;
+
+function initVarvPowerButtons() {
+  if (varvPowerWired) return;
+  const onBtn = document.getElementById("varv-on");
+  const offBtn = document.getElementById("varv-off");
+  if (onBtn) onBtn.addEventListener("click", () => setVarvPower(true));
+  if (offBtn) offBtn.addEventListener("click", () => setVarvPower(false));
+  varvPowerWired = true;
+}
+
+function renderVarvPower(d) {
+  const st = document.getElementById("varv-power-status");
+  const onBtn = document.getElementById("varv-on");
+  const offBtn = document.getElementById("varv-off");
+  const on = d ? d.on : null;
+  if (st) {
+    if (on === true) { st.textContent = "SEES"; st.className = "badge ok"; }
+    else if (on === false) { st.textContent = "VÄLJAS"; st.className = "badge err"; }
+    else { st.textContent = "teadmata"; st.className = "badge muted"; }
+  }
+  if (onBtn) onBtn.disabled = varvPowerBusy || on === true;
+  if (offBtn) offBtn.disabled = varvPowerBusy || on === false;
+}
+
+async function loadVarvPower() {
+  if (!state.authed) return;
+  try {
+    const res = await fetch("/api/varv/power");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvPower(await res.json());
+  } catch (e) {
+    console.error(e);
+    renderVarvPower({ on: null });
+  }
+}
+
+async function setVarvPower(on) {
+  if (!state.authed || varvPowerBusy) return;
+  varvPowerBusy = true;
+  const hint = document.getElementById("varv-power-hint");
+  renderVarvPower({ on });
+  try {
+    const res = await fetch("/api/varv/power", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ on }),
+    });
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvPower(await res.json());
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = on
+        ? "Mõõtmine sisse lülitatud."
+        : "Mõõtmine välja lülitatud — ClickHouse'i ei kirjutata.";
+      setTimeout(() => { hint.hidden = true; }, 5000);
+    }
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Lülitamine ebaõnnestus."; }
+  } finally {
+    varvPowerBusy = false;
+    loadVarvPower();
+  }
+}
+
+// --- Värv: kalibreerimine (tume/valge; nupud ainult sisselogitult) ---
+let varvCalibView = false;
+let varvCalibData = null;
+let varvCalibWired = false;
+let varvCalibUserSet = false;
+
+function varvBandSum(c) {
+  return Object.values(c.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0);
+}
+
+function varvPick(c) {
+  if (varvBandSum(c) < 250) return { hex: "#33383f", x: null, y: null, dark: true };
+  if (varvCalibView && c.rgb_hex_ccm) {
+    return { hex: c.rgb_hex_ccm, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  if (varvCalibView && c.rgb_hex_cal) {
+    return { hex: c.rgb_hex_cal, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  return { hex: c.rgb_hex, x: c.x_chrom, y: c.y_chrom };
+}
+
+function initVarvCalibButtons() {
+  if (varvCalibWired) return;
+  const d = document.getElementById("varv-calib-dark");
+  const w = document.getElementById("varv-calib-white");
+  const t = document.getElementById("varv-calib-view");
+  if (d) d.addEventListener("click", () => captureVarvCalib("dark"));
+  if (w) w.addEventListener("click", () => captureVarvCalib("white"));
+  if (t) t.addEventListener("change", (e) => {
+    varvCalibUserSet = true;
+    varvCalibView = e.target.checked;
+    renderVarvCurrent();
+    renderVarvGrid();
+  });
+  varvCalibWired = true;
+}
+
+function renderVarvCalib(d) {
+  varvCalibData = d;
+  const st = document.getElementById("varv-calib-status");
+  if (st) {
+    const haveD = !!d.dark;
+    const haveW = !!d.white;
+    if (haveD && haveW) { st.textContent = "valmis"; st.className = "badge ok"; }
+    else {
+      st.textContent = `${haveD ? "tume ✓" : "tume ✗"} / ${haveW ? "valge ✓" : "valge ✗"}`;
+      st.className = "badge warn";
+    }
+  }
+  const view = document.getElementById("varv-calib-view");
+  if (view) view.disabled = !(d.dark && d.white);
+  const lbl = document.getElementById("varv-calib-view-label");
+  if (lbl) {
+    lbl.textContent = (d.dark && d.white)
+      ? "Kuva kalibreeritud värvi"
+      : "Kuva kalibreeritud värvi (vaja tume + valge)";
+  }
+  // Kui kalibratsioon on valmis ja kasutaja pole ise lülitit puutunud,
+  // lülita kalibreeritud vaade automaatselt sisse.
+  if (d.dark && d.white && !varvCalibUserSet && !varvCalibView) {
+    const cb = document.getElementById("varv-calib-view");
+    if (cb) cb.checked = true;
+    varvCalibView = true;
+    if (varvLatest.length) { renderVarvCurrent(); renderVarvGrid(); }
+  }
+  renderVarvSpectrum();
+}
+
+async function loadVarvCalib() {
+  try {
+    const res = await fetch("/api/varv/calib");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvCalib(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderVarvCalibLive() {
+  const el = document.getElementById("varv-calib-live");
+  if (!el) return;
+  if (!varvLatest.length) { el.textContent = ""; return; }
+  const bandSum = (r) => Object.values(r.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0);
+  const c = varvLatest[0];
+  const sum = bandSum(c);
+  const sums = varvLatest.slice(0, 5).map(bandSum);
+  const mx = Math.max(...sums);
+  const mn = Math.min(...sums);
+  const unstable = mx > 0 && (mx - mn) / mx > 0.2;
+  let light;
+  if (sum < 50) light = "PIME — valge jaoks vaja valgust";
+  else if (sum < 200) light = "hämar";
+  else light = "HELE";
+  const st = unstable ? "MUUTUB (oota stabiliseerumist)" : "stabiilne ✓";
+  el.textContent = `Viimane: ${(c.rgb_hex || "").toUpperCase()} · heledus ${Math.round(sum)} · ${light} · ${st}`;
+}
+
+async function captureVarvCalib(kind) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-calib-hint");
+  try {
+    const res = await fetch("/api/varv/calib", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ kind, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = `Salvestamine ebaõnnestus (HTTP ${res.status}).`;
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = `Salvestatud: ${kind} (${d.rows} mõõtmist).`;
+      setTimeout(() => { hint.hidden = true; }, 4500);
+    }
+    await loadVarvCalib();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = e.message || "Salvestamine ebaõnnestus."; }
+  }
+}
+
+// --- Värv: CCM (värvikaart) kalibratsioon ---
+let varvCcmWired = false;
+
+function initVarvCcmButtons() {
+  if (varvCcmWired) return;
+  document.querySelectorAll("[data-ref]").forEach((btn) => {
+    btn.addEventListener("click", () => captureVarvRef(String(btn.dataset.hex).toLowerCase(), btn.dataset.hex));
+  });
+  const fit = document.getElementById("varv-ccm-fit");
+  if (fit) fit.addEventListener("click", fitVarvCcm);
+
+  const colEl = document.getElementById("varv-ccm-color");
+  const hexEl = document.getElementById("varv-ccm-hex");
+  const prevEl = document.getElementById("varv-ccm-preview");
+  const capEl = document.getElementById("varv-ccm-capture");
+  const applyColor = (raw) => {
+    const h = String(raw || "").replace("#", "").trim().toUpperCase();
+    if (!/^[0-9A-F]{6}$/.test(h)) return;
+    if (prevEl) prevEl.style.background = "#" + h;
+    if (colEl) colEl.value = "#" + h.toLowerCase();
+    if (hexEl) hexEl.value = h;
+  };
+  if (colEl) colEl.addEventListener("input", () => applyColor(colEl.value));
+  if (hexEl) hexEl.addEventListener("change", () => applyColor(hexEl.value));
+  if (capEl) capEl.addEventListener("click", () => {
+    const h = (hexEl ? hexEl.value : "").replace("#", "").trim().toUpperCase();
+    if (!/^[0-9A-F]{6}$/.test(h)) return;
+    captureVarvRef(h.toLowerCase(), h);
+  });
+
+  varvCcmWired = true;
+}
+
+function renderVarvCcm(ccm, refs) {
+  const st = document.getElementById("varv-ccm-status");
+  if (st) {
+    if (ccm && ccm.ready) { st.textContent = "maatriks olemas"; st.className = "badge ok"; }
+    else { st.textContent = "maatriks puudub"; st.className = "badge warn"; }
+  }
+  const list = document.getElementById("varv-ccm-list");
+  const names = (refs && refs.refs ? refs.refs : []).map((r) => r.name);
+  if (list) list.textContent = names.length ? "Salvestatud: " + names.join(", ") : "Ühtegi referentsi pole veel.";
+}
+
+async function loadVarvCcm() {
+  try {
+    const [r1, r2] = await Promise.all([fetch("/api/varv/ccm"), fetch("/api/varv/ref")]);
+    if (!r1.ok || !r2.ok) return;
+    renderVarvCcm(await r1.json(), await r2.json());
+  } catch (e) { console.error(e); }
+}
+
+async function captureVarvRef(name, hex) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ref", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ name, hex, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = "Salvestamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    if (hint) hint.textContent = `Salvestatud: ${name} (${hex})`;
+    loadVarvCcm();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
+async function fitVarvCcm() {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ccm/fit", { method: "POST" });
+    if (!res.ok) {
+      let msg = "Sobitamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) hint.textContent = `Maatriks arvutatud (${d.n_refs} värvi).`;
+    loadVarvCcm();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
+window.addEventListener("resize", () => {
+  if (chartVarvSpectrum) chartVarvSpectrum.resize();
+  if (chartVarvChroma) chartVarvChroma.resize();
+});
+
+// --- Vahelehed (tabid): Andurid | Transport | Värv ---
+const transport = { active: false, days: 1 };
+const VIEWS = ["andurid", "transport", "varv"];
+
+function switchView(view) {
+  if (!VIEWS.includes(view)) view = "andurid";
+  document.querySelectorAll("#tabs .tab").forEach((b) => {
+    b.classList.toggle("active", b.dataset.view === view);
+  });
+  for (const v of VIEWS) {
+    const el = document.getElementById("view-" + v);
+    if (el) el.hidden = v !== view;
+  }
+  transport.active = view === "transport";
+  varvActive = view === "varv";
+  if (location.hash !== "#" + view) history.replaceState(null, "", "#" + view);
+  if (view === "transport") {
+    loadTransport();
+  } else if (view === "varv") {
+    loadVarv();
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); }
+  } else {
+    if (chart) chart.resize();
+    if (chartEnergy) chartEnergy.resize();
+    if (chartMotion) chartMotion.resize();
+  }
+}
+
+function initTabs() {
+  document.querySelectorAll("#tabs .tab").forEach((btn) => {
+    btn.addEventListener("click", () => switchView(btn.dataset.view));
+  });
+  const days = document.getElementById("praam-days");
+  if (days) {
+    days.querySelectorAll("button").forEach((b) => {
+      b.addEventListener("click", () => {
+        days.querySelectorAll("button").forEach((x) => x.classList.remove("active"));
+        b.classList.add("active");
+        transport.days = Number(b.dataset.days) || 1;
+        loadPraam();
+      });
+    });
+  }
+  setInterval(() => {
+    if (transport.active) loadTransport();
+  }, 60000);
+  setInterval(() => {
+    if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } }
+  }, 20000);
+  switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
+}
+
+function loadTransport() {
+  loadBus();
+  loadPraam();
+}
+
+function fmtDayLabel(iso, today) {
+  if (!iso || iso === today) return "";
+  const d = new Date(iso + "T00:00:00");
+  const t = new Date((today || iso) + "T00:00:00");
+  const diff = Math.round((d - t) / 864e5);
+  if (diff === 1) return "homme";
+  return d.toLocaleDateString("et-EE", { weekday: "short", day: "numeric", month: "short" });
+}
+
+function setTransportUpdated(updated) {
+  const el = document.getElementById("transport-updated");
+  if (el && updated) el.textContent = `Uuendatud ${updated}`;
+}
+
+async function loadBus() {
+  const hint = document.getElementById("bus-hint");
+  try {
+    const res = await fetch("/api/buss?limit=12");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderBus(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Bussiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderBus(data) {
+  const list = document.getElementById("bus-list");
+  const hint = document.getElementById("bus-hint");
+  if (!list) return;
+  const rows = data.departures || [];
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Täna enam busse ei välju."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  list.innerHTML = rows.map((d) => {
+    const day = fmtDayLabel(d.date, data.today);
+    const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+    let rt = "";
+    if (d.realtime && d.delay_min != null && d.delay_min !== 0) {
+      rt = d.delay_min > 0
+        ? `<span class="badge warn">+${d.delay_min} min</span>`
+        : `<span class="badge ok">${d.delay_min} min</span>`;
+    }
+    return `<div class="dep">
+      <span class="dep-time">${d.time}</span>
+      <span class="dep-route">${d.route || ""}</span>
+      <span class="dep-dest">→ ${d.headsign || "?"}</span>
+      ${rt}${dayBadge}
+    </div>`;
+  }).join("");
+}
+
+async function loadPraam() {
+  const hint = document.getElementById("praam-hint");
+  try {
+    const res = await fetch(`/api/praam?days=${transport.days}`);
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderPraam(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Praamiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderPraam(data) {
+  const list = document.getElementById("praam-list");
+  const hint = document.getElementById("praam-hint");
+  const warn = document.getElementById("praam-warning");
+  const rows = data.departures || [];
+
+  if (warn) {
+    if (Array.isArray(data.warnings) && data.warnings.length) {
+      warn.hidden = false;
+      warn.innerHTML = "⚠️ <b>Ilmahoiatuse kontekst (Väinameri)</b>" +
+        data.warnings.map((w) => `<div>• ${w.area}: ${w.valid || w.text}</div>`).join("") +
+        `<div class="warn-sub">Praamiliiklus võib häiruda — kontrolli ` +
+        `<a href="https://praamid.ee" target="_blank" rel="noopener">praamid.ee</a>.</div>`;
+    } else {
+      warn.hidden = true;
+    }
+  }
+
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Väljumisi ei leitud."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  setTransportUpdated(data.updated);
+
+  const groups = {};
+  for (const d of rows) (groups[d.direction] = groups[d.direction] || []).push(d);
+  list.innerHTML = ["HR", "RH"].filter((k) => groups[k]).map((k) => {
+    const items = groups[k].map((d) => {
+      const day = fmtDayLabel(d.date, data.today);
+      const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+      const st = d.disrupted
+        ? `<span class="badge err">${d.status_et}</span>`
+        : `<span class="badge ok">${d.status_et}</span>`;
+      return `<div class="dep">
+        <span class="dep-time">${d.time}</span>
+        <span class="dep-ship">${d.ship || ""}</span>
+        <span class="dep-dest"></span>
+        ${st}${dayBadge}
+      </div>`;
+    }).join("");
+    return `<div class="dep-group"><div class="dep-group-title">${groups[k][0].direction_name}</div>${items}</div>`;
+  }).join("");
+}
+
+document.addEventListener("DOMContentLoaded", init);

+ 377 - 0
dev/nutikodu-web.bak.20261024-2022/static/app.js.bak.1791389719

@@ -0,0 +1,377 @@
+"use strict";
+
+const state = {
+  from: null,
+  to: null,
+  agg: "hour",
+  device: "",
+  showTemp: true,
+  showHum: true,
+  showOut: true,
+  meta: null,
+};
+
+let chart = null;
+let currentData = { series: {} };
+
+const $ = (sel) => document.querySelector(sel);
+
+function cssVar(name) {
+  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+function pad(n) {
+  return String(n).padStart(2, "0");
+}
+
+function toLocalInput(date) {
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+function setStatus(ok) {  const el = $("#status");
+  el.classList.toggle("ok", ok === true);
+  el.classList.toggle("err", ok === false);
+}
+
+function applyPreset(days) {
+  const now = new Date();
+  let from;
+  if (days === "all") {
+    const min = state.meta && state.meta.min ? new Date(state.meta.min.replace(" ", "T")) : null;
+    from = min && !isNaN(min) ? min : new Date(now.getTime() - 7 * 864e5);
+  } else {
+    from = new Date(now.getTime() - Number(days) * 864e5);
+  }
+  state.from = toLocalInput(from);
+  state.to = toLocalInput(now);
+  $("#from").value = state.from;
+  $("#to").value = state.to;
+  load();
+}
+
+// Kärbi kõik seeriad ühisesse ajavahemikku, et jooned algaksid ja lõpeksid koos.
+function clipToCommonRange(series) {
+  const active = series.filter((s) => Array.isArray(s.data) && s.data.length);
+  if (active.length < 2) return series;
+  const start = Math.max(...active.map((s) => s.data[0][0]));
+  const end = Math.min(...active.map((s) => s.data[s.data.length - 1][0]));
+  if (!(start < end)) return series;
+  return series.map((s) => {
+    if (!Array.isArray(s.data)) return s;
+    return { ...s, data: s.data.filter((d) => d && d[0] >= start && d[0] <= end) };
+  });
+}
+
+function buildOption(data) {
+  const text = cssVar("--text");
+  const muted = cssVar("--muted");
+  const border = cssVar("--border");
+  const tempColor = cssVar("--temp");
+  const humColor = cssVar("--hum");
+  const outColor = cssVar("--out");
+  const series = [];
+  const palette = ["#1f6feb", "#e8590c", "#2fb344", "#9c36b5", "#f08c00", "#0ca678"];
+  const devices = Object.keys(data.series).sort();
+  let pi = 0;
+
+  const humAxis = state.showHum;
+
+  for (const dev of devices) {
+    const s = data.series[dev];
+    const baseColor = palette[pi % palette.length];
+    pi += 1;
+    if (state.showTemp) {
+      series.push({
+        name: `${dev} · °C`,
+        type: "line",
+        yAxisIndex: 0,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 2.4, color: tempColor },
+        itemStyle: { color: tempColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.temperature[i]]),
+      });
+      if (Array.isArray(s.taidetud) && s.taidetud.some(Boolean)) {
+        series.push({
+          name: `${dev} · täidetud`,
+          type: "scatter",
+          yAxisIndex: 0,
+          symbol: "emptyCircle",
+          symbolSize: 9,
+          itemStyle: { color: tempColor, borderWidth: 2 },
+          z: 3,
+          data: s.t
+            .map((t, i) => (s.taidetud[i] ? [t, s.temperature[i]] : null))
+            .filter(Boolean),
+        });
+      }
+    }
+    if (state.showHum) {
+      series.push({
+        name: `${dev} · %`,
+        type: "line",
+        yAxisIndex: 1,
+        showSymbol: false,
+        smooth: 0.25,
+        lineStyle: { width: 1.8, type: "dashed", color: humColor },
+        itemStyle: { color: humColor },
+        emphasis: { focus: "series" },
+        data: s.t.map((t, i) => [t, s.humidity[i]]),
+      });
+    }
+  }
+
+  const out = data.outside;
+  if (state.showOut && out && Array.isArray(out.t) && out.t.length) {
+    series.push({
+      name: "Väli · °C",
+      type: "line",
+      yAxisIndex: 0,
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2, color: outColor },
+      itemStyle: { color: outColor },
+      emphasis: { focus: "series" },
+      data: out.t.map((t, i) => [t, out.temperature[i]]),
+    });
+    if (Array.isArray(out.taidetud) && out.taidetud.some(Boolean)) {
+      series.push({
+        name: "Väli · täidetud",
+        type: "scatter",
+        yAxisIndex: 0,
+        symbol: "emptyCircle",
+        symbolSize: 9,
+        itemStyle: { color: outColor, borderWidth: 2 },
+        z: 3,
+        data: out.t
+          .map((t, i) => (out.taidetud[i] ? [t, out.temperature[i]] : null))
+          .filter(Boolean),
+      });
+    }
+  }
+
+  return {
+    backgroundColor: "transparent",
+    animationDuration: 300,
+    color: palette,
+    grid: { left: 6, right: humAxis ? 6 : 6, top: 40, bottom: 62, containLabel: true },
+    legend: {
+      top: 4,
+      type: "scroll",
+      textStyle: { color: text, fontSize: 12 },
+      inactiveColor: muted,
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: cssVar("--panel"),
+      borderColor: border,
+      textStyle: { color: text, fontSize: 12 },
+      axisPointer: { type: "cross", label: { backgroundColor: muted } },
+      valueFormatter: null,
+      formatter: (params) => {
+        const items = (params || []).filter(
+          (p) => !String(p.seriesName).endsWith("· täidetud")
+        );
+        if (!items.length) return "";
+        const d = new Date(items[0].value[0]);
+        const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+        const lines = items.map((p) => {
+          const v = p.value[1];
+          const unit = p.seriesName.endsWith("°C") ? " °C" : " %";
+          return `<div style="display:flex;justify-content:space-between;gap:14px">
+            <span>${p.marker} ${p.seriesName}</span><b>${v == null ? "–" : v + unit}</b></div>`;
+        });
+        return `<div style="font-weight:600;margin-bottom:4px">${head}</div>${lines.join("")}`;
+      },
+    },
+    xAxis: {
+      type: "time",
+      axisLine: { lineStyle: { color: border } },
+      axisLabel: { color: muted, hideOverlap: true },
+      splitLine: { show: false },
+    },
+    yAxis: [
+      {
+        type: "value",
+        name: "°C",
+        scale: true,
+        axisLine: { show: false },
+        axisLabel: { color: muted },
+        splitLine: { lineStyle: { color: border, type: "dashed" } },
+      },
+      ...(humAxis
+        ? [
+            {
+              type: "value",
+              name: "%",
+              scale: true,
+              position: "right",
+              axisLine: { show: false },
+              axisLabel: { color: muted },
+              splitLine: { show: false },
+            },
+          ]
+        : []),
+    ],
+    dataZoom: [
+      { type: "inside", throttle: 50 },
+      { type: "slider", height: 22, bottom: 14, borderColor: border, textStyle: { color: muted }, fillColor: cssVar("--accent-soft") },
+    ],
+    series: clipToCommonRange(series),
+  };
+}
+
+function render(data) {
+  const chartEl = $("#chart");
+  const hasData = Object.keys(data.series).length > 0 && Object.values(data.series).some((s) => s.t.length);
+  $("#hint").hidden = hasData;
+  if (!hasData) {
+    $("#hint").textContent = "Valitud vahemikus andmeid ei ole. Andur salvestab andmeid alates 2026-09-17.";
+  }
+  if (!chart) chart = echarts.init(chartEl, null, { renderer: "canvas" });
+  chart.setOption(buildOption(data), true);
+  chart.resize();
+}
+
+async function load() {
+  const params = new URLSearchParams({
+    from: state.from,
+    to: state.to,
+    agg: state.agg,
+  });
+  if (state.device) params.set("device", state.device);
+  setStatus(null);
+  try {
+    const [tempRes, outRes] = await Promise.all([
+      fetch(`/api/temperatuur?${params}`),
+      fetch(`/api/valisilm?${params}`),
+    ]);
+    if (!tempRes.ok) throw new Error(await tempRes.text());
+    const data = await tempRes.json();
+    if (outRes.ok) {
+      try {
+        data.outside = (await outRes.json()).series;
+      } catch {
+        data.outside = null;
+      }
+    }
+    currentData = data;
+    currentSeries = data.series;
+    render(data);
+    setStatus(true);
+    const f = new Date(state.from.replace(" ", "T"));
+    const t = new Date(state.to.replace(" ", "T"));
+    $("#range-label").textContent = `${f.toLocaleString("et-EE")} → ${t.toLocaleString("et-EE")}`;
+  } catch (err) {
+    console.error(err);
+    setStatus(false);
+    $("#hint").hidden = false;
+    $("#hint").textContent = "Andmete laadimine ebaõnnestus.";
+  }
+}
+
+async function loadMeta() {
+  try {
+    const res = await fetch("/api/meta");
+    if (!res.ok) throw new Error();
+    state.meta = await res.json();
+    const devs = state.meta.devices.length ? state.meta.devices.join(", ") : "—";
+    $("#subtitle").textContent = `Zigbee andurid · ${devs}`;
+    if (state.meta.min) {
+      $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
+    }
+  } catch {
+    $("#subtitle").textContent = "Zigbee andurid";
+  }
+}
+
+async function loadLatest() {
+  try {
+    const res = await fetch("/api/latest");
+    if (!res.ok) return;
+    const data = await res.json();
+    renderCards(data);
+  } catch {
+    /* vaikne */
+  }
+}
+
+function renderCards(data) {
+  const cards = [];
+  for (const r of data.readings) {
+    cards.push(`<div class="card temp"><div class="k">🌡️ ${r.device} · temperatuur</div>
+      <div class="v">${r.temperature ?? "–"}<small> °C</small></div></div>`);
+    cards.push(`<div class="card hum"><div class="k">💧 ${r.device} · niiskus</div>
+      <div class="v">${r.humidity ?? "–"}<small> %</small></div></div>`);
+  }
+  if (data.outside && data.outside.temperature != null) {
+    cards.push(`<div class="card out"><div class="k">🌤️ Väli · temperatuur</div>
+      <div class="v">${data.outside.temperature}<small> °C</small></div></div>`);
+  }
+  if (data.open_faults.length) {
+    const items = data.open_faults
+      .map((f) => `<div>⚠️ <b>${f.device}</b> · ${f.tyyp}${f.detail ? " · " + f.detail : ""}</div>`)
+      .join("");
+    cards.push(`<div class="card wide"><div class="k">Lahtised häired (${data.open_faults.length})</div>
+      <div style="font-size:13px;margin-top:6px;line-height:1.7">${items}</div></div>`);
+  }
+  $("#cards").innerHTML = cards.join("");
+}
+
+function setTheme(theme) {
+  document.documentElement.setAttribute("data-theme", theme);
+  localStorage.setItem("nutikodu-theme", theme);
+  $("#theme-toggle").textContent = theme === "dark" ? "☀️" : "🌙";
+  const meta = $("#meta-theme");
+  if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
+  if (chart) chart.setOption(buildOption(currentData), true);
+}
+
+let currentSeries = {};
+
+function init() {
+  const saved = localStorage.getItem("nutikodu-theme");
+  const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+  setTheme(saved || (prefersDark ? "dark" : "light"));
+  $("#theme-toggle").addEventListener("click", () => {
+    setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+  });
+
+  document.querySelectorAll("#presets button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      applyPreset(btn.dataset.days);
+    });
+  });
+
+  document.querySelectorAll("#agg button").forEach((btn) => {
+    btn.addEventListener("click", () => {
+      document.querySelectorAll("#agg button").forEach((b) => b.classList.remove("active"));
+      btn.classList.add("active");
+      state.agg = btn.dataset.agg;
+      load();
+    });
+  });
+
+  $("#show-temp").addEventListener("change", (e) => { state.showTemp = e.target.checked; load(); });
+  $("#show-hum").addEventListener("change", (e) => { state.showHum = e.target.checked; load(); });
+  $("#show-out").addEventListener("change", (e) => { state.showOut = e.target.checked; load(); });
+  $("#apply").addEventListener("click", () => {
+    state.from = $("#from").value;
+    state.to = $("#to").value;
+    document.querySelectorAll("#presets button").forEach((b) => b.classList.remove("active"));
+    load();
+  });
+
+  window.addEventListener("resize", () => chart && chart.resize());
+  window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
+  window.addEventListener("load", () => chart && chart.resize());
+
+  loadMeta().then(() => applyPreset(7));
+  loadLatest();
+  setInterval(loadLatest, 60000);
+}
+
+document.addEventListener("DOMContentLoaded", init);

+ 6 - 0
dev/nutikodu-web.bak.20261024-2022/static/icon.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+  <rect width="64" height="64" rx="14" fill="#1f6feb"/>
+  <path d="M32 12 13 28h6v20h10V36h6v12h10V28h6L32 12z" fill="#fff"/>
+  <rect x="30" y="20" width="4" height="14" rx="2" fill="#e8590c"/>
+  <circle cx="32" cy="37" r="4" fill="#e8590c"/>
+</svg>

+ 244 - 0
dev/nutikodu-web.bak.20261024-2022/static/index.html

@@ -0,0 +1,244 @@
+<!DOCTYPE html>
+<html lang="et" data-theme="light">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+  <meta name="theme-color" content="#1f6feb" id="meta-theme" />
+  <meta name="robots" content="noindex" />
+  <title>Nutikodu — andurid</title>
+  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
+  <link rel="manifest" href="/manifest.webmanifest" />
+  <link rel="apple-touch-icon" href="/icon.svg" />
+  <link rel="stylesheet" href="/style.css" />
+</head>
+<body>
+  <header>
+    <div class="brand">
+      <span class="logo" aria-hidden="true">🏠</span>
+      <div>
+        <h1>Nutikodu</h1>
+        <div class="sub" id="subtitle">Zigbee andurid</div>
+      </div>
+    </div>
+    <div class="header-actions">
+      <span class="status" id="status" title="Ühenduse olek"></span>
+      <a id="auth-link" class="icon-btn" href="/login" title="Sisselogimine" style="text-decoration:none">🔒</a>
+      <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
+    </div>
+  </header>
+
+  <main>
+    <nav class="tabs" id="tabs" aria-label="Vahelehed">
+      <button type="button" class="tab active" data-view="andurid">🏠 Andurid</button>
+      <button type="button" class="tab" data-view="transport">🚌 Transport</button>
+      <button type="button" class="tab" data-view="varv">🎨 Värv</button>
+    </nav>
+
+    <div id="view-andurid" class="view">
+      <section class="cards" id="cards"></section>
+
+      <section class="panel">
+        <div class="controls">
+          <div class="presets" id="presets">
+            <button type="button" data-days="1">24 h</button>
+            <button type="button" data-days="7" class="active">7 p</button>
+            <button type="button" data-days="30">30 p</button>
+            <button type="button" data-days="90">90 p</button>
+            <button type="button" data-days="365">1 a</button>
+            <button type="button" data-days="all">Kõik</button>
+          </div>
+          <div class="custom-range">
+            <label>Algus<input type="datetime-local" id="from" /></label>
+            <label>Lõpp<input type="datetime-local" id="to" /></label>
+            <button type="button" id="apply" class="btn">Kuva</button>
+          </div>
+          <div class="toggles">
+            <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
+            <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
+            <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
+            <div class="seg" id="agg">
+              <button type="button" data-agg="hour" class="active">Tund</button>
+              <button type="button" data-agg="day">Päev</button>
+            </div>
+          </div>
+        </div>
+        <div id="chart" aria-label="Temperatuuri ja niiskuse ajatelg"></div>
+        <div id="hint" class="hint" hidden></div>
+      </section>
+
+      <div id="protected" hidden>
+        <section class="cards" id="cards-uus67"></section>
+
+        <section class="panel">
+          <h2 class="panel-title">⚡ Energia (pistikud)</h2>
+          <div id="chart-energy" aria-label="Energia ajatelg" style="height:300px"></div>
+          <div id="hint-energy" class="hint" hidden></div>
+        </section>
+
+        <section class="panel">
+          <h2 class="panel-title">🚶 Liikumine / kohalolu</h2>
+          <div id="chart-motion" aria-label="Liikumise ajatelg" style="height:220px"></div>
+          <div id="hint-motion" class="hint" hidden></div>
+        </section>
+      </div>
+
+      <footer class="foot">
+        <span id="range-label"></span>
+      </footer>
+    </div>
+
+    <div id="view-transport" class="view" hidden>
+      <section class="panel">
+        <h2 class="panel-title">🚌 Suuremõisa küla — bussid</h2>
+        <div id="bus-list" class="dep-list"></div>
+        <div id="bus-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <div class="panel-head">
+          <h2 class="panel-title">🚢 Heltermaa – Rohuküla</h2>
+          <div class="seg" id="praam-days">
+            <button type="button" data-days="1" class="active">Täna</button>
+            <button type="button" data-days="2">Täna + homme</button>
+          </div>
+        </div>
+        <div id="praam-warning" class="warn-banner" hidden></div>
+        <div id="praam-list" class="dep-list"></div>
+        <div id="praam-hint" class="hint" hidden></div>
+      </section>
+
+      <footer class="foot">
+        <span id="transport-updated"></span>
+      </footer>
+    </div>
+
+    <div id="view-varv" class="view" hidden>
+      <section class="panel" id="varv-power" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🔌 Värvusemõõtja</h2>
+          <span id="varv-power-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-on" class="btn">Lülita sisse</button>
+            <button type="button" id="varv-off" class="btn">Lülita välja</button>
+          </div>
+        </div>
+        <div id="varv-power-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel" id="varv-calib-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Kalibreerimine</h2>
+          <span id="varv-calib-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-calib-dark" class="btn">Salvesta tume</button>
+            <button type="button" id="varv-calib-white" class="btn">Salvesta valge</button>
+          </div>
+        </div>
+        <div class="varv-sub" style="margin-top:8px">
+          1) Kata andur → „Salvesta tume". 2) Pane valge pind sama valgustusega → „Salvesta valge".
+          Oota ~30 s, kuni mõõtmine stabiliseerub.
+        </div>
+        <div id="varv-calib-live" class="varv-sub" style="margin-top:4px"></div>
+        <label class="chk" style="margin-top:10px;display:inline-flex">
+          <input type="checkbox" id="varv-calib-view" /> <span id="varv-calib-view-label">Kuva kalibreeritud värvi</span>
+        </label>
+        <div id="varv-calib-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel" id="varv-ccm-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Värvikaardi (CCM) kalibratsioon</h2>
+          <span id="varv-ccm-status" class="badge muted">—</span>
+        </div>
+        <div class="varv-sub" style="margin-top:6px">
+          Kuva ekraanil üks värv korraga (täisekraanil) ja vajuta vastavat nuppu — mõõdetakse viimased 5.
+          Soovitav: punane, roheline, sinine, valge, must, kollane, tsüaan, magenta.
+        </div>
+        <div class="varv-power-btns" style="margin:8px 0; justify-content:flex-start; flex-wrap:wrap">
+          <button type="button" class="btn" data-ref="red" data-hex="FF0000">Punane</button>
+          <button type="button" class="btn" data-ref="green" data-hex="00FF00">Roheline</button>
+          <button type="button" class="btn" data-ref="blue" data-hex="0000FF">Sinine</button>
+          <button type="button" class="btn" data-ref="white" data-hex="FFFFFF">Valge</button>
+          <button type="button" class="btn" data-ref="black" data-hex="000000">Must</button>
+          <button type="button" class="btn" data-ref="yellow" data-hex="FFFF00">Kollane</button>
+          <button type="button" class="btn" data-ref="cyan" data-hex="00FFFF">Tsüaan</button>
+          <button type="button" class="btn" data-ref="magenta" data-hex="FF00FF">Magenta</button>
+        </div>
+        <div class="varv-power-row" style="margin-top:8px">
+          <label class="chk" style="display:inline-flex;align-items:center;gap:8px">
+            <span>Värv:</span>
+            <input type="color" id="varv-ccm-color" value="#00ff00"
+                   style="width:48px;height:32px;border:0;background:none;padding:0" />
+            <input type="text" id="varv-ccm-hex" value="00FF00" maxlength="7"
+                   style="width:96px;padding:6px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text)" />
+          </label>
+          <button type="button" id="varv-ccm-capture" class="btn">Salvesta see värv</button>
+        </div>
+        <div id="varv-ccm-preview"
+             style="width:220px;height:220px;border-radius:12px;border:1px solid var(--border);margin:8px 0;background:#00ff00"></div>
+        <div class="varv-sub">Hoia andurit selle ruudu vastas (ekraan täisheledusel), oota kuni mõõtmine on stabiilne (HELE), siis „Salvesta see värv".</div>
+
+        <div class="varv-power-row" style="margin-top:8px">
+          <button type="button" id="varv-ccm-fit" class="btn">Arvuta maatriks</button>
+          <span id="varv-ccm-hint" class="varv-sub"></span>
+        </div>
+        <div id="varv-ccm-list" class="varv-sub" style="margin-top:6px"></div>
+      </section>
+
+      <section class="panel">
+        <div class="varv-current">
+          <div id="varv-current-swatch" class="varv-current-swatch"></div>
+          <div class="varv-current-info">
+            <div class="varv-hex" id="varv-hex">#------</div>
+            <div class="varv-meta" id="varv-current-meta">Laadin…</div>
+          </div>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🎨 Viimased mõõtmised</h2>
+        <div id="varv-grid" class="swatch-grid"></div>
+        <div id="varv-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">📊 Spekter <span class="varv-sub" id="varv-spectrum-label"></span></h2>
+        <div id="chart-varv-spectrum" style="height:280px" aria-label="Kanali spekter"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🕓 Värvitelg (viimased 24 h)</h2>
+        <div id="varv-timeline" class="swatch-timeline"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🧭 Kromaatilisus (x, y)</h2>
+        <div id="chart-varv-chroma" style="height:340px" aria-label="Kromaatilisuse hajuvus"></div>
+      </section>
+
+      <footer class="foot">
+        <span id="varv-updated"></span>
+      </footer>
+    </div>
+  </main>
+
+  <script src="/vendor/echarts.min.js"></script>
+  <script src="/app.js"></script>
+
+  <!-- HyperDX Browser Integration: session replays + RUM (vt LOEMIND) -->
+  <script src="https://unpkg.com/@hyperdx/browser@0.26.0/build/index.js"></script>
+  <script>
+    if (window.HyperDX) {
+      HyperDX.init({
+        apiKey: '02a19ecf-f62e-4e17-819f-3ee1498d69f3',
+        service: 'nutikodu-web',
+        url: 'https://otel.odamus.com',
+        consoleCapture: true,
+        advancedNetworkCapture: true,
+        maskAllInputs: true,
+        maskAllText: true,
+      });
+    }
+  </script>
+</body>
+</html>

+ 85 - 0
dev/nutikodu-web.bak.20261024-2022/static/index.html.bak.1791389719

@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<html lang="et" data-theme="light">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+  <meta name="theme-color" content="#1f6feb" id="meta-theme" />
+  <meta name="robots" content="noindex" />
+  <title>Nutikodu — andurid</title>
+  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
+  <link rel="manifest" href="/manifest.webmanifest" />
+  <link rel="apple-touch-icon" href="/icon.svg" />
+  <link rel="stylesheet" href="/style.css" />
+</head>
+<body>
+  <header>
+    <div class="brand">
+      <span class="logo" aria-hidden="true">🏠</span>
+      <div>
+        <h1>Nutikodu</h1>
+        <div class="sub" id="subtitle">Zigbee andurid</div>
+      </div>
+    </div>
+    <div class="header-actions">
+      <span class="status" id="status" title="Ühenduse olek"></span>
+      <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
+    </div>
+  </header>
+
+  <main>
+    <section class="cards" id="cards"></section>
+
+    <section class="panel">
+      <div class="controls">
+        <div class="presets" id="presets">
+          <button type="button" data-days="1">24 h</button>
+          <button type="button" data-days="7" class="active">7 p</button>
+          <button type="button" data-days="30">30 p</button>
+          <button type="button" data-days="90">90 p</button>
+          <button type="button" data-days="365">1 a</button>
+          <button type="button" data-days="all">Kõik</button>
+        </div>
+        <div class="custom-range">
+          <label>Algus<input type="datetime-local" id="from" /></label>
+          <label>Lõpp<input type="datetime-local" id="to" /></label>
+          <button type="button" id="apply" class="btn">Kuva</button>
+        </div>
+        <div class="toggles">
+          <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
+          <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
+          <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
+          <div class="seg" id="agg">
+            <button type="button" data-agg="hour" class="active">Tund</button>
+            <button type="button" data-agg="day">Päev</button>
+          </div>
+        </div>
+      </div>
+      <div id="chart" aria-label="Temperatuuri ja niiskuse ajatelg"></div>
+      <div id="hint" class="hint" hidden></div>
+    </section>
+
+    <footer class="foot">
+      <span id="range-label"></span>
+    </footer>
+  </main>
+
+  <script src="/vendor/echarts.min.js"></script>
+  <script src="/app.js"></script>
+
+  <!-- HyperDX Browser Integration: session replays + RUM (vt LOEMIND) -->
+  <script src="https://unpkg.com/@hyperdx/browser@0.26.0/build/index.js"></script>
+  <script>
+    if (window.HyperDX) {
+      HyperDX.init({
+        apiKey: '02a19ecf-f62e-4e17-819f-3ee1498d69f3',
+        service: 'nutikodu-web',
+        url: 'https://otel.odamus.com',
+        consoleCapture: true,
+        advancedNetworkCapture: true,
+        maskAllInputs: true,
+        maskAllText: true,
+      });
+    }
+  </script>
+</body>
+</html>

+ 12 - 0
dev/nutikodu-web.bak.20261024-2022/static/manifest.webmanifest

@@ -0,0 +1,12 @@
+{
+  "name": "Nutikodu andurid",
+  "short_name": "Nutikodu",
+  "description": "Zigbee andurite ajalugu (temperatuur, niiskus)",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#0e1116",
+  "theme_color": "#1f6feb",
+  "icons": [
+    { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
+  ]
+}

+ 250 - 0
dev/nutikodu-web.bak.20261024-2022/static/style.css

@@ -0,0 +1,250 @@
+:root {
+  --bg: #f4f6fb;
+  --panel: #ffffff;
+  --text: #1c2430;
+  --muted: #64708a;
+  --border: #dde3ee;
+  --accent: #1f6feb;
+  --accent-soft: #e7f0ff;
+  --temp: #e8590c;
+  --hum: #1f9ed4;
+  --out: #2fb344;
+  --ok: #2fb344;
+  --warn: #e0a400;
+  --err: #d64545;
+  --shadow: 0 8px 24px rgba(20, 40, 80, .10);
+}
+[data-theme="dark"] {
+  --bg: #0e1116;
+  --panel: #171c24;
+  --text: #e8edf5;
+  --muted: #93a0b5;
+  --border: #28303c;
+  --accent: #5b9bff;
+  --accent-soft: #1b2a44;
+  --temp: #ff8a4c;
+  --hum: #56c3ee;
+  --out: #45d06a;
+  --ok: #45d06a;
+  --warn: #f0c000;
+  --err: #ff6b6b;
+  --shadow: 0 8px 24px rgba(0, 0, 0, .45);
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+  background: var(--bg);
+  color: var(--text);
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+  -webkit-font-smoothing: antialiased;
+  padding-bottom: env(safe-area-inset-bottom);
+  overflow-x: hidden;
+}
+
+header {
+  position: sticky; top: 0; z-index: 20;
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 12px; padding: 14px 16px calc(14px + env(safe-area-inset-top)) 16px;
+  padding-top: calc(14px + env(safe-area-inset-top));
+  background: color-mix(in srgb, var(--panel) 88%, transparent);
+  backdrop-filter: saturate(180%) blur(12px);
+  border-bottom: 1px solid var(--border);
+}
+.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.logo { font-size: 26px; line-height: 1; }
+.brand h1 { font-size: 18px; margin: 0; letter-spacing: -.2px; }
+.brand .sub { font-size: 12px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.header-actions { display: flex; align-items: center; gap: 8px; }
+.status { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 4px color-mix(in srgb, var(--muted) 20%, transparent); }
+.status.ok { background: var(--ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 20%, transparent); }
+.status.err { background: var(--err); box-shadow: 0 0 0 4px color-mix(in srgb, var(--err) 20%, transparent); }
+.icon-btn {
+  border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  width: 40px; height: 40px; border-radius: 12px; font-size: 18px; cursor: pointer;
+  display: inline-flex; align-items: center; justify-content: center;
+  padding: 0; line-height: 1; text-decoration: none;
+}
+.icon-btn:active { transform: scale(.96); }
+
+main { padding: 14px; max-width: 1100px; margin: 0 auto; display: grid; gap: 14px; }
+main > * { min-width: 0; }
+#protected:not([hidden]) { display: grid; gap: 14px; }
+
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
+.card {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
+  padding: 14px; box-shadow: var(--shadow);
+}
+.card .k { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
+.card .v { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -.5px; }
+.card .v small { font-size: 14px; font-weight: 500; color: var(--muted); }
+.card.temp .v { color: var(--temp); }
+.card.hum .v { color: var(--hum); }
+.card.out .v { color: var(--out); }
+.card.wide { grid-column: 1 / -1; }
+
+.panel {
+  background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
+  box-shadow: var(--shadow); padding: 12px; overflow: hidden;
+}
+.controls { display: grid; gap: 10px; margin-bottom: 6px; }
+.presets { display: flex; flex-wrap: wrap; gap: 8px; }
+.presets button, .seg button {
+  border: 1px solid var(--border); background: var(--bg); color: var(--text);
+  padding: 8px 14px; border-radius: 999px; font-size: 13px; cursor: pointer; font-weight: 600;
+}
+.presets button.active, .seg button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
+.custom-range { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; }
+.custom-range label { display: grid; gap: 4px; font-size: 11px; color: var(--muted); flex: 1 1 140px; min-width: 0; }
+.custom-range input {
+  border: 1px solid var(--border); background: var(--bg); color: var(--text);
+  border-radius: 10px; padding: 8px 10px; font-size: 13px; width: 100%; min-width: 0; max-width: 100%;
+}
+.btn {
+  border: none; background: var(--accent); color: #fff; border-radius: 10px;
+  padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
+}
+.btn:active { filter: brightness(.94); }
+.toggles { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
+.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
+.chk input { accent-color: var(--accent); width: 16px; height: 16px; }
+.seg { display: inline-flex; gap: 0; margin-left: auto; }
+.seg button { border-radius: 0; padding: 8px 14px; }
+.seg button:first-child { border-radius: 999px 0 0 999px; }
+.seg button:last-child { border-radius: 0 999px 999px 0; border-left: none; }
+
+#chart { width: 100%; height: 56vh; min-height: 320px; }
+.hint { color: var(--muted); font-size: 13px; text-align: center; padding: 20px; }
+.foot { color: var(--muted); font-size: 12px; text-align: center; padding: 4px 0 20px; }
+
+@media (max-width: 520px) {
+  .brand h1 { font-size: 16px; }
+  #chart { height: 50vh; min-height: 280px; }
+  .seg { margin-left: 0; }
+  .header-actions { display: grid; grid-template-columns: 10px 40px 40px; gap: 8px; align-items: center; justify-content: end; }
+  .header-actions .status { grid-area: 1 / 1; }
+  #auth-link { grid-area: 1 / 2; }
+  #theme-toggle { grid-area: 1 / 3; }
+  .away-pill { grid-area: 2 / 2 / 3 / 4; justify-content: center; font-size: 12px; padding: 0 6px; }
+}
+
+.away-bar { margin: 0; }
+.away-toggle {
+  width: 100%; border: 1px solid var(--border); background: var(--panel);
+  border-radius: 16px; padding: 16px 18px; display: flex; align-items: center; gap: 14px;
+  cursor: pointer; color: var(--text); text-align: left; box-shadow: var(--shadow);
+  transition: border-color .15s, background .15s;
+}
+.away-toggle:active { transform: scale(.99); }
+.away-toggle .away-ico { font-size: 30px; line-height: 1; }
+.away-toggle .away-txt { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.away-toggle .away-txt b { font-size: 18px; }
+.away-toggle .away-txt small { font-size: 12px; color: var(--muted); }
+.away-toggle.away-on { border-color: var(--err); background: color-mix(in srgb, var(--err) 14%, var(--panel)); }
+.away-toggle.away-on .away-txt b { color: var(--err); }
+
+.away-pill {
+  display: inline-flex; align-items: center; gap: 6px;
+  border: 1px solid var(--border); background: var(--panel); color: var(--text);
+  height: 40px; border-radius: 12px; padding: 0 14px; font-size: 13px; font-weight: 600;
+  cursor: pointer; white-space: nowrap; line-height: 1;
+}
+.away-pill .away-ico { font-size: 15px; line-height: 1; }
+.away-pill[hidden] { display: none; }
+.away-pill:active { transform: scale(.96); }
+.away-pill.away-on {
+  border-color: var(--err); background: color-mix(in srgb, var(--err) 16%, var(--panel)); color: var(--err);
+}
+
+/* --- Vahelehed (tabid) --- */
+.tabs {
+  display: flex; gap: 6px; background: var(--panel); border: 1px solid var(--border);
+  border-radius: 14px; padding: 5px; box-shadow: var(--shadow);
+}
+.tab {
+  flex: 1 1 0; border: 0; background: transparent; color: var(--muted);
+  font-size: 14px; font-weight: 600; padding: 10px 12px; border-radius: 10px; cursor: pointer;
+}
+.tab.active { background: var(--accent); color: #fff; }
+.tab:active { transform: scale(.98); }
+
+.view { display: grid; gap: 14px; }
+.view[hidden] { display: none; }
+
+.panel-head {
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 10px; flex-wrap: wrap; margin-bottom: 8px;
+}
+.panel-title { font-size: 15px; margin: 0 0 8px; }
+.panel-head .panel-title { margin: 0; }
+
+.dep-list { display: grid; gap: 6px; }
+.dep-group { display: grid; gap: 6px; margin-bottom: 8px; }
+.dep-group-title {
+  font-size: 12px; color: var(--muted); font-weight: 700;
+  text-transform: uppercase; letter-spacing: .4px; margin-top: 4px;
+}
+.dep {
+  display: flex; align-items: center; gap: 10px; padding: 10px 12px;
+  border: 1px solid var(--border); border-radius: 12px; background: var(--bg);
+}
+.dep-time { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 16px; min-width: 52px; }
+.dep-route { font-weight: 600; color: var(--accent); min-width: 44px; }
+.dep-ship { font-weight: 600; min-width: 56px; }
+.dep-dest { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.badge {
+  font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
+  white-space: nowrap; border: 1px solid transparent;
+}
+.badge.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
+.badge.err { color: var(--err); background: color-mix(in srgb, var(--err) 16%, transparent); }
+.badge.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 18%, transparent); }
+.badge.muted { color: var(--muted); background: color-mix(in srgb, var(--muted) 14%, transparent); }
+
+.warn-banner {
+  border: 1px solid var(--warn); background: color-mix(in srgb, var(--warn) 14%, var(--panel));
+  border-radius: 12px; padding: 10px 12px; font-size: 13px; line-height: 1.55; margin-bottom: 8px;
+}
+.warn-banner a { color: var(--accent); }
+.warn-sub { margin-top: 6px; color: var(--muted); font-size: 12px; }
+
+@media (max-width: 520px) {
+  .dep-time { min-width: 44px; font-size: 15px; }
+  .dep-route { min-width: 0; }
+}
+
+/* --- Värvisensor (varv) --- */
+.varv-current { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
+.varv-current-swatch {
+  width: 110px; height: 110px; border-radius: 14px;
+  border: 1px solid var(--border); flex: 0 0 auto; box-shadow: var(--shadow);
+}
+.varv-current-info { min-width: 0; }
+.varv-hex {
+  font-size: 26px; font-weight: 700; letter-spacing: .5px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.varv-meta { color: var(--muted); font-size: 13px; margin-top: 6px; line-height: 1.6; }
+.varv-sub { color: var(--muted); font-size: 13px; font-weight: 400; }
+
+.swatch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)); gap: 6px; }
+.swatch {
+  aspect-ratio: 1 / 1; width: 100%; border-radius: 8px; border: 1px solid var(--border);
+  cursor: pointer; padding: 0; transition: transform .08s ease;
+}
+.swatch:active { transform: scale(.95); }
+.swatch.sel { outline: 2px solid var(--accent); outline-offset: 1px; }
+
+.swatch-timeline { display: flex; flex-wrap: wrap; gap: 4px; }
+.swatch-timeline .swatch { width: 30px; height: 30px; aspect-ratio: auto; cursor: default; }
+
+@media (max-width: 520px) {
+  .varv-current-swatch { width: 84px; height: 84px; }
+  .varv-hex { font-size: 22px; }
+  .swatch-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
+}
+
+.varv-power-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.varv-power-btns { display: flex; gap: 8px; margin-left: auto; }
+.btn:disabled { opacity: .5; cursor: not-allowed; }

Разница между файлами не показана из-за своего большого размера
+ 34 - 0
dev/nutikodu-web.bak.20261024-2022/static/vendor/echarts.min.js


+ 186 - 23
dev/nutikodu-web/app.py

@@ -614,8 +614,9 @@ def _linear_to_srgb8(c: float) -> int:
     return int(round(255 * max(0.0, min(1.0, v))))
     return int(round(255 * max(0.0, min(1.0, v))))
 
 
 
 
-def varv_apply_ccm(matrix: list[list[float]], channels: dict) -> str:
-    vec = np.array([float(channels.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+def varv_apply_ccm(matrix: list[list[float]], channels: dict, dark: dict | None = None, white: dict | None = None) -> str:
+    src = varv_reflectance(channels, dark or {}, white) if white else channels
+    vec = np.array([float(src.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
     lin = np.array(matrix, dtype=float) @ vec
     lin = np.array(matrix, dtype=float) @ vec
     r = _linear_to_srgb8(float(lin[0]))
     r = _linear_to_srgb8(float(lin[0]))
     g = _linear_to_srgb8(float(lin[1]))
     g = _linear_to_srgb8(float(lin[1]))
@@ -673,29 +674,127 @@ async def varv_ref_get() -> dict:
     return {"refs": rows}
     return {"refs": rows}
 
 
 
 
+def _lin_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
+    x = 0.4124 * r + 0.3576 * g + 0.1805 * b
+    y = 0.2126 * r + 0.7152 * g + 0.0722 * b
+    z = 0.0193 * r + 0.1192 * g + 0.9505 * b
+
+    def f(t: float) -> float:
+        return t ** (1.0 / 3.0) if t > 0.008856 else (7.787 * t + 16.0 / 116.0)
+
+    fx, fy, fz = f(x / 0.95047), f(y / 1.0), f(z / 1.08883)
+    return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
+
+
+def _delta_e00(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
+    import math
+    L1, a1, b1 = lab1
+    L2, a2, b2 = lab2
+    C1 = math.hypot(a1, b1)
+    C2 = math.hypot(a2, b2)
+    Cb = (C1 + C2) / 2.0
+    G = 0.5 * (1.0 - math.sqrt(Cb ** 7 / (Cb ** 7 + 25.0 ** 7))) if Cb > 0 else 0.0
+    a1p, a2p = (1.0 + G) * a1, (1.0 + G) * a2
+    C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2)
+    h1p = math.degrees(math.atan2(b1, a1p)) % 360.0
+    h2p = math.degrees(math.atan2(b2, a2p)) % 360.0
+    dLp = L2 - L1
+    dCp = C2p - C1p
+    if C1p * C2p == 0:
+        dhp = 0.0
+    else:
+        dhp = h2p - h1p
+        if dhp > 180:
+            dhp -= 360
+        elif dhp < -180:
+            dhp += 360
+    dHp = 2.0 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp / 2.0))
+    Lbp = (L1 + L2) / 2.0
+    Cbp = (C1p + C2p) / 2.0
+    if C1p * C2p == 0:
+        hbp = h1p + h2p
+    else:
+        hbp = (h1p + h2p) / 2.0
+        if abs(h1p - h2p) > 180:
+            hbp = (h1p + h2p + 360.0) / 2.0 if (h1p + h2p) < 360 else (h1p + h2p - 360.0) / 2.0
+    T = (1.0 - 0.17 * math.cos(math.radians(hbp - 30.0))
+         + 0.24 * math.cos(math.radians(2.0 * hbp))
+         + 0.32 * math.cos(math.radians(3.0 * hbp + 6.0))
+         - 0.20 * math.cos(math.radians(4.0 * hbp - 63.0)))
+    dtheta = 30.0 * math.exp(-((hbp - 275.0) / 25.0) ** 2)
+    Rc = 2.0 * math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25.0 ** 7)) if Cbp > 0 else 0.0
+    Sl = 1.0 + 0.015 * (Lbp - 50.0) ** 2 / math.sqrt(20.0 + (Lbp - 50.0) ** 2)
+    Sc = 1.0 + 0.045 * Cbp
+    Sh = 1.0 + 0.015 * Cbp * T
+    Rt = -math.sin(math.radians(2.0 * dtheta)) * Rc
+    return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2
+                     + Rt * (dCp / Sc) * (dHp / Sh))
+
+
+def _fit_ccm(C: "np.ndarray", T: "np.ndarray") -> "np.ndarray":
+    CtC = C.T @ C
+    CtT = C.T @ T
+    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
+    return np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)
+
+
+def _predict_hex(M: "np.ndarray", c: "np.ndarray") -> str:
+    lin = np.clip(c @ M, 0.0, 1.0)
+    return "#" + "".join(f"{_linear_to_srgb8(float(v)):02X}" for v in lin)
+
+
 @app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
 @app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
-async def varv_ccm_fit() -> dict:
-    """Sobita 3x8 maatriks (kanalid -> lineaarne sRGB) referentsidest."""
+async def varv_ccm_fit(prefix: str | None = Query(None)) -> dict:
+    """Sobita 3x8 maatriks reflekteeruvusest -> lineaarne sRGB.
+
+    prefix filtreerib referentsid (nt wizard: 'wiz').
+    """
+    where = ""
+    if prefix:
+        if not re.fullmatch(r"[a-z0-9_-]{1,16}", prefix):
+            raise HTTPException(status_code=422, detail="vigane prefix")
+        where = f" WHERE name LIKE '{prefix}%'"
     rows = ch_query(
     rows = ch_query(
         f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
         f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
-        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL{where} ORDER BY name"
     )
     )
-    if len(rows) < 3:
-        raise HTTPException(status_code=422, detail="Vaja vähemalt 3 referentsvärvi (soovitav 6-8).")
-    C = np.array([[float(r[b] or 0.0) for b in VARV_BANDS] for r in rows], dtype=float)
+    if len(rows) < 6:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 6 referentsvärvi (soovitav 12-24).")
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    if not white:
+        raise HTTPException(status_code=422, detail="Valge referents puudub — kalibreeri enne tume ja valge.")
+
+    def _feat(r):
+        ch = {b: r.get(b) for b in VARV_BANDS}
+        ch = varv_reflectance(ch, dark or {}, white)
+        return [float(ch.get(b) or 0.0) for b in VARV_BANDS]
+
+    C = np.array([_feat(r) for r in rows], dtype=float)
     T = np.array(
     T = np.array(
         [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
         [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
          for r in rows],
          for r in rows],
         dtype=float,
         dtype=float,
     )
     )
-    # Ridge-regulaarsus: 8x8 süsteem on tinglikult haige (cond ~1e7) — ilma
-    # regulaarsuseta maatriks overfitib ja uued mõõtmised annavad rämpsu.
-    # alpha=1e-5 valitud leave-one-out testiga.
-    CtC = C.T @ C
-    CtT = C.T @ T
-    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
-    M = np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)  # (8,3)
-    matrix = M.T.tolist()  # (3,8)
+    M = _fit_ccm(C, T)
+    matrix = M.T.tolist()
+
+    # In-sample ja leave-one-out ΔE00
+    def _de(pred: np.ndarray, target: np.ndarray) -> float:
+        return _delta_e00(_lin_to_lab(*pred), _lin_to_lab(*target))
+
+    insample = [_de(np.clip(C[i] @ M, 0, 1), T[i]) for i in range(len(rows))]
+    loo = []
+    for i in range(len(rows)):
+        mask = np.ones(len(rows), dtype=bool)
+        mask[i] = False
+        Mi = _fit_ccm(C[mask], T[mask])
+        loo.append(_de(np.clip(C[i] @ Mi, 0, 1), T[i]))
+
+    def _stats(v):
+        v = np.array(v)
+        return {"mean": round(float(v.mean()), 2), "median": round(float(np.median(v)), 2), "max": round(float(v.max()), 2)}
+
     resp = requests.post(
     resp = requests.post(
         f"{CH_URL}/",
         f"{CH_URL}/",
         params={
         params={
@@ -708,7 +807,17 @@ async def varv_ccm_fit() -> dict:
     )
     )
     if resp.status_code != 200:
     if resp.status_code != 200:
         raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
         raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
-    return {"n_refs": len(rows), "matrix": matrix}
+    return {
+        "n_refs": len(rows),
+        "matrix": matrix,
+        "de00_in_sample": _stats(insample),
+        "de00_loo": _stats(loo),
+        "preview": [
+            {"name": r["name"], "target": f"#{int(r['tr']):02X}{int(r['tg']):02X}{int(r['tb']):02X}",
+             "pred": _predict_hex(M, C[i]), "de00": round(insample[i], 2)}
+            for i, r in enumerate(rows)
+        ],
+    }
 
 
 
 
 @app.get("/api/varv/ccm")
 @app.get("/api/varv/ccm")
@@ -734,7 +843,8 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
     out = []
     out = []
     for r in rows:
     for r in rows:
         bands = {b: r.get(b) for b in VARV_BANDS}
         bands = {b: r.get(b) for b in VARV_BANDS}
-        raw = color.analyze(bands)
+        valid, reason = color.quality(bands, dark)
+        raw = color.analyze(bands, dark=dark)
         item = {
         item = {
             "ts": r["ts"],
             "ts": r["ts"],
             "t": int(r["t"]),
             "t": int(r["t"]),
@@ -748,14 +858,18 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
             "clear": r.get("clear"),
             "clear": r.get("clear"),
             "nir": r.get("nir"),
             "nir": r.get("nir"),
             "gain": r.get("gain"),
             "gain": r.get("gain"),
+            "valid": valid,
+            "reason": reason,
         }
         }
         if ready:
         if ready:
-            cc = color.analyze(varv_reflectance(bands, dark, white))
+            cc = color.analyze(bands, dark=dark, white=white)
             item["rgb_hex_cal"] = cc["rgb_hex"]
             item["rgb_hex_cal"] = cc["rgb_hex"]
             item["x_chrom_cal"] = cc["x_chrom"]
             item["x_chrom_cal"] = cc["x_chrom"]
             item["y_chrom_cal"] = cc["y_chrom"]
             item["y_chrom_cal"] = cc["y_chrom"]
-        if ccm:
-            item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands)
+        # CCM (3x8) on treenitud ekraaniprimaaridel TOORARVUDE domeenis.
+        # Reflekteeruvusele rakendatuna annab peaaegu musta tulemuse, seega
+        # kuni päris värvikaardi kalibratsioonini seda ei kuvata.
+        # item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands, dark, white)
         out.append(item)
         out.append(item)
     return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
     return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
 
 
@@ -802,8 +916,9 @@ async def varv_calib_set(payload: dict = Body(...)) -> dict:
 
 
     # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
     # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
     total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
     total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
-    if total_new <= 0:
-        raise HTTPException(status_code=422, detail="Mõõtmine on null — andur on pime või andmed puuduvad.")
+    # Tume referents TOHIB olla null (LED eemaldatud, andur kaetud).
+    if kind == "white" and total_new <= 0:
+        raise HTTPException(status_code=422, detail="Valge mõõtmine on null — andur on pime või andmed puuduvad.")
     calib = varv_load_calib()
     calib = varv_load_calib()
     other = calib.get("white" if kind == "dark" else "dark")
     other = calib.get("white" if kind == "dark" else "dark")
     if other:
     if other:
@@ -1285,6 +1400,54 @@ async def away_set(payload: dict = Body(...)) -> dict:
     return {"away": on, "available": True}
     return {"away": on, "available": True}
 
 
 
 
+@app.post("/api/varv/capture", dependencies=[Depends(require_auth)])
+async def varv_capture(payload: dict = Body(default={})) -> dict:
+    """Oota N uut mõõtmist ja tagasta keskmine + stabiilsus."""
+    n = max(2, min(int(payload.get("n", 3)), 10))
+    timeout = max(12, min(int(payload.get("timeout", 45)), 90))
+    t0 = datetime.now(TZ)
+    epoch = int(t0.timestamp())
+    deadline = time.time() + timeout
+    rows: list[dict] = []
+    sql = (
+        f"SELECT toString(ts) AS ts, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.varvus WHERE toUnixTimestamp(ts) >= {epoch} ORDER BY ts"
+    )
+    while time.time() < deadline:
+        await asyncio.sleep(2.0)
+        rows = await asyncio.to_thread(ch_query, sql)
+        if len(rows) >= n:
+            break
+    if len(rows) < 2:
+        raise HTTPException(status_code=409, detail="Mõõtmisi ei tulnud — kas sensor on sees (varvus/switch/sensor)?")
+    rows = rows[-n:]
+    mean: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        mean[f] = round(sum(vals) / len(vals), 2) if vals else 0.0
+    clears = [float(r["clear"]) for r in rows if r.get("clear") is not None]
+    cv = 0.0
+    if len(clears) > 1 and sum(clears) > 0:
+        m = sum(clears) / len(clears)
+        var = sum((c - m) ** 2 for c in clears) / (len(clears) - 1)
+        cv = (var ** 0.5) / m if m else 0.0
+    peak = max((float(mean.get(b) or 0.0) for b in VARV_BANDS), default=0.0)
+    return {
+        "n": len(rows),
+        "channels": mean,
+        "clear": mean.get("clear"),
+        "cv": round(cv, 4),
+        "stable": cv <= 0.08,
+        "saturated": peak >= 64000 or float(mean.get("clear") or 0) >= 64000,
+        "ts": rows[-1].get("ts"),
+    }
+
+
+@app.get("/kalibreer")
+async def kalibreer_page() -> FileResponse:
+    return FileResponse(STATIC_DIR / "kalibreeri.html", media_type="text/html")
+
+
 @app.get("/health")
 @app.get("/health")
 async def health() -> dict:
 async def health() -> dict:
     try:
     try:

+ 1324 - 0
dev/nutikodu-web/app.py.bak.20261024-203254

@@ -0,0 +1,1324 @@
+"""Nutikodu andmete veebiliides (FastAPI).
+
+Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
+  GET /                      -> veebirakendus (static/index.html)
+  GET /api/meta              -> seadmed, tz, andmete ulatus
+  GET /api/temperatuur       -> aegrida (temp + niiskus), agg=hour|day
+  GET /api/ukseandur         -> ukseolekute sündmused (tulevikuks)
+  GET /api/vead              -> häirete sündmused (tulevikuks)
+  GET /api/latest            -> viimased väärtused + lahtised häired
+  GET /health
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hmac
+import html
+import secrets
+import json
+import os
+import re
+import threading
+import time
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import color
+import numpy as np
+import paho.mqtt.client as mqtt
+import requests
+from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
+from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+# Värvisensori juhtimine (MQTT, edge broker). OFF peatab ESP32 publitseerimise
+# -> ClickHouse'i ei kirjutata.
+VARV_MQTT_HOST = os.environ.get("VARV_MQTT_HOST", "")
+VARV_MQTT_PORT = int(os.environ.get("VARV_MQTT_PORT", "8883"))
+VARV_MQTT_TLS = os.environ.get("VARV_MQTT_TLS", "true").strip().lower() in ("1", "true", "yes", "on")
+VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
+VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
+VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
+VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
+VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
+VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
+VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
+
+WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
+WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
+WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
+HA_URL = os.environ.get("HA_URL", "http://100.87.1.24:8123").rstrip("/")
+HA_TOKEN = os.environ.get("HA_TOKEN", "")
+AWAY_ENTITY = os.environ.get("AWAY_ENTITY", "input_boolean.kodust_eemal")
+SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "604800"))
+LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
+LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
+
+DEFAULT_RANGE_DAYS = 7
+MAX_RANGE_DAYS = 3660
+
+# --- Transport (bussid + praamid) ---
+BUS_GRAPHQL = os.environ.get(
+    "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
+).rstrip("/")
+BUS_STOPS = [s.strip() for s in os.environ.get(
+    "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
+PRAAM_API = os.environ.get(
+    "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
+RII_HOIATUS_URL = os.environ.get(
+    "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
+)
+TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
+UA = "Mozilla/5.0 (compatible; Nutikodu-web/1.0)"
+
+PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
+SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
+PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
+SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
+WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
+
+_cache: dict[str, tuple[float, object]] = {}
+_cache_lock = threading.Lock()
+
+
+def cached(key: str, ttl: int, producer):
+    """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
+    now = time.monotonic()
+    with _cache_lock:
+        hit = _cache.get(key)
+        if hit and now - hit[0] < ttl:
+            return hit[1]
+    value = producer()
+    with _cache_lock:
+        _cache[key] = (time.monotonic(), value)
+    return value
+
+
+app = FastAPI(title="Nutikodu", version="0.2.0")
+app.add_middleware(
+    SessionMiddleware,
+    secret_key=WEB_SECRET_KEY,
+    https_only=False,
+    same_site="lax",
+    max_age=SESSION_MAX_AGE,
+)
+
+
+@app.middleware("http")
+async def add_no_cache(request: Request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+_login_failures: dict[str, list[float]] = defaultdict(list)
+_login_lock = threading.Lock()
+
+
+def client_ip(request: Request) -> str:
+    forwarded = request.headers.get("x-forwarded-for")
+    if forwarded:
+        return forwarded.split(",")[0].strip()
+    return request.client.host if request.client else "?"
+
+
+def retry_after_seconds(ip: str) -> int:
+    now = time.monotonic()
+    with _login_lock:
+        history = [t for t in _login_failures.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
+        _login_failures[ip] = history
+        if len(history) >= LOGIN_MAX_ATTEMPTS:
+            return int(LOGIN_WINDOW_SECONDS - (now - history[0])) + 1
+    return 0
+
+
+def register_login_failure(ip: str) -> None:
+    with _login_lock:
+        _login_failures[ip].append(time.monotonic())
+
+
+def reset_login_failures(ip: str) -> None:
+    with _login_lock:
+        _login_failures.pop(ip, None)
+
+
+def is_authed(request: Request) -> bool:
+    """Kas päring on autenditud: Bearer token VÕI sessiooniküpsis."""
+    auth = request.headers.get("authorization", "")
+    if auth.lower().startswith("bearer "):
+        token = auth[7:].strip()
+        if WEB_ACCESS_TOKEN and secrets.compare_digest(token, WEB_ACCESS_TOKEN):
+            return True
+    try:
+        if request.session.get("auth") is True:
+            return True
+    except Exception:
+        pass
+    return False
+
+
+def require_auth(request: Request) -> None:
+    if not is_authed(request):
+        raise HTTPException(
+            status_code=401,
+            detail="Autentimine nõutud (Bearer token või sisselogimine)",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+
+def parse_dt(value: str | None, default: datetime) -> str:
+    """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
+    if not value:
+        return default.strftime("%Y-%m-%d %H:%M:%S")
+    cleaned = value.strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
+        except ValueError:
+            continue
+    raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
+
+
+def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
+    now = datetime.now(TZ)
+    to_s = parse_dt(to, now)
+    to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
+    from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
+    from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
+    if from_dt > to_dt:
+        raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
+    if (to_dt - from_dt).days > MAX_RANGE_DAYS:
+        raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
+    return from_s, to_s
+
+
+@app.get("/api/meta")
+async def meta() -> dict:
+    devices = [row["device"] for row in ch_query(
+        f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
+    )]
+    bounds = ch_query(
+        f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
+        f"FROM {CH_DB}.temperatuur"
+    )
+    first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
+    return {
+        "tz": "Europe/Tallinn",
+        "db": CH_DB,
+        "devices": devices,
+        "min": first["min_hour"],
+        "max": first["max_hour"],
+        "rows": first["rows"],
+        "default_range_days": DEFAULT_RANGE_DAYS,
+    }
+
+
+@app.get("/api/temperatuur")
+async def temperatuur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
+        params,
+    )
+
+    series: dict[str, dict] = {}
+    for row in rows:
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/valisilm")
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
+async def ukseandur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
+        f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/vead", dependencies=[Depends(require_auth)])
+async def vead(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
+        f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "tyyp": r["tyyp"],
+                "olek": r["olek"],
+                "detail": r["detail"],
+                "vaartus": r["vaartus"],
+            }
+            for r in rows
+        ],
+    }
+
+
+
+
+@app.get("/api/liikumine", dependencies=[Depends(require_auth)])
+async def liikumine(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, occupancy, illumination, olek "
+        f"FROM {CH_DB}.liikumine WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "occupancy": bool(r["occupancy"]),
+                "illumination": r["illumination"],
+                "olek": r["olek"],
+            }
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/energia", dependencies=[Depends(require_auth)])
+async def energia(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(ts))) * 1000 AS t, device, "
+            "round(avg(power), 2) AS power, round(avg(voltage), 1) AS voltage, "
+            "round(avg(current), 3) AS current, max(energy) AS energy"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, "
+            "power, voltage, current, energy"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.energia WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, dict] = {}
+    for row in rows:
+        s2 = series.setdefault(
+            row["device"], {"t": [], "power": [], "voltage": [], "current": [], "energy": []}
+        )
+        s2["t"].append(int(row["t"]))
+        s2["power"].append(row["power"])
+        s2["voltage"].append(row["voltage"])
+        s2["current"].append(row["current"])
+        s2["energy"].append(row["energy"])
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+
+
+@app.get("/api/uus67_latest", dependencies=[Depends(require_auth)])
+async def uus67_latest() -> dict:
+    energy = ch_query(
+        f"SELECT device, argMax(power, ts) AS power, argMax(voltage, ts) AS voltage, "
+        f"argMax(current, ts) AS current, argMax(energy, ts) AS energy, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.energia GROUP BY device ORDER BY device"
+    )
+    motion = ch_query(
+        f"SELECT device, argMax(occupancy, ts) AS occupancy, "
+        f"argMax(illumination, ts) AS illumination, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.liikumine GROUP BY device ORDER BY device"
+    )
+    return {
+        "energy": [
+            {
+                "device": r["device"],
+                "power": r.get("power"),
+                "voltage": r.get("voltage"),
+                "current": r.get("current"),
+                "energy": r.get("energy"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in energy
+        ],
+        "motion": [
+            {
+                "device": r["device"],
+                "occupancy": bool(r.get("occupancy")),
+                "illumination": r.get("illumination"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in motion
+        ],
+    }
+
+
+# --- Värvisensor (uus67.varvus, AS7341) ---
+
+
+def varv_load_calib() -> dict:
+    """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
+    try:
+        rows = ch_query(
+            f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
+            f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
+        )
+    except HTTPException:
+        return {}
+    out: dict[str, dict] = {}
+    for r in rows:
+        out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
+    return out
+
+
+def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
+    """R = (S - D) / (W - D) kanali kohta."""
+    out: dict[str, float] = {}
+    for band in VARV_BANDS:
+        v = channels.get(band)
+        d = float(dark.get(band) or 0.0)
+        w = float(white.get(band) or 0.0)
+        if v is not None and (w - d) > 0:
+            out[band] = max(0.0, (float(v) - d) / (w - d))
+    return out
+
+
+# --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
+
+
+def varv_load_ccm() -> list[list[float]] | None:
+    try:
+        rows = ch_query(
+            f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
+            f"ORDER BY updated_at DESC LIMIT 1"
+        )
+    except HTTPException:
+        return None
+    if not rows:
+        return None
+    try:
+        m = json.loads(rows[0]["matrix"])
+        if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
+            return [[float(x) for x in row] for row in m]
+    except Exception:
+        return None
+    return None
+
+
+def _srgb_to_linear(v: float) -> float:
+    c = v / 255.0
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb8(c: float) -> int:
+    c = max(0.0, min(1.0, c))
+    v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+    return int(round(255 * max(0.0, min(1.0, v))))
+
+
+def varv_apply_ccm(matrix: list[list[float]], channels: dict, dark: dict | None = None, white: dict | None = None) -> str:
+    src = varv_reflectance(channels, dark or {}, white) if white else channels
+    vec = np.array([float(src.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+    lin = np.array(matrix, dtype=float) @ vec
+    r = _linear_to_srgb8(float(lin[0]))
+    g = _linear_to_srgb8(float(lin[1]))
+    b = _linear_to_srgb8(float(lin[2]))
+    return f"#{r:02X}{g:02X}{b:02X}"
+
+
+@app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
+async def varv_ref_set(payload: dict = Body(...)) -> dict:
+    """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
+    name = str(payload.get("name", "")).strip().lower()
+    hexv = str(payload.get("hex", "")).strip().lstrip("#")
+    if len(hexv) != 6:
+        raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
+    try:
+        tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
+    except ValueError:
+        raise HTTPException(status_code=422, detail="vigane hex")
+    last = max(1, min(int(payload.get("last", 5)), 30))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+    total = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if (tr + tg + tb) >= 10 and total < 300:
+        raise HTTPException(
+            status_code=422,
+            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.",
+        )
+    row = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_TABLE} FORMAT JSONEachRow"},
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/ref")
+async def varv_ref_get() -> dict:
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    return {"refs": rows}
+
+
+@app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
+async def varv_ccm_fit() -> dict:
+    """Sobita 3x8 maatriks (kanalid -> lineaarne sRGB) referentsidest."""
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    if len(rows) < 3:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 3 referentsvärvi (soovitav 6-8).")
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+
+    def _feat(r):
+        ch = {b: r.get(b) for b in VARV_BANDS}
+        if white:
+            ch = varv_reflectance(ch, dark or {}, white)
+        return [float(ch.get(b) or 0.0) for b in VARV_BANDS]
+
+    C = np.array([_feat(r) for r in rows], dtype=float)
+    T = np.array(
+        [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
+         for r in rows],
+        dtype=float,
+    )
+    # Ridge-regulaarsus: 8x8 süsteem on tinglikult haige (cond ~1e7) — ilma
+    # regulaarsuseta maatriks overfitib ja uued mõõtmised annavad rämpsu.
+    # alpha=1e-5 valitud leave-one-out testiga.
+    CtC = C.T @ C
+    CtT = C.T @ T
+    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
+    M = np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)  # (8,3)
+    matrix = M.T.tolist()  # (3,8)
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
+        },
+        data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"n_refs": len(rows), "matrix": matrix}
+
+
+@app.get("/api/varv/ccm")
+async def varv_ccm_get() -> dict:
+    m = varv_load_ccm()
+    return {"matrix": m, "ready": m is not None}
+
+
+@app.get("/api/varv/latest")
+async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
+    """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
+    cols = ", ".join(VARV_BANDS)
+    rows = ch_query(
+        f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
+        f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
+        f"{cols}, clear, nir, gain "
+        f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
+    )
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    ready = bool(dark and white)
+    ccm = varv_load_ccm()
+    out = []
+    for r in rows:
+        bands = {b: r.get(b) for b in VARV_BANDS}
+        valid, reason = color.quality(bands, dark)
+        raw = color.analyze(bands, dark=dark)
+        item = {
+            "ts": r["ts"],
+            "t": int(r["t"]),
+            "rgb_hex": raw["rgb_hex"],
+            "r": raw["r"],
+            "g": raw["g"],
+            "b": raw["b"],
+            "x_chrom": raw["x_chrom"],
+            "y_chrom": raw["y_chrom"],
+            "bands": bands,
+            "clear": r.get("clear"),
+            "nir": r.get("nir"),
+            "gain": r.get("gain"),
+            "valid": valid,
+            "reason": reason,
+        }
+        if ready:
+            cc = color.analyze(bands, dark=dark, white=white)
+            item["rgb_hex_cal"] = cc["rgb_hex"]
+            item["x_chrom_cal"] = cc["x_chrom"]
+            item["y_chrom_cal"] = cc["y_chrom"]
+        # CCM (3x8) on treenitud ekraaniprimaaridel TOORARVUDE domeenis.
+        # Reflekteeruvusele rakendatuna annab peaaegu musta tulemuse, seega
+        # kuni päris värvikaardi kalibratsioonini seda ei kuvata.
+        # item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands, dark, white)
+        out.append(item)
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
+
+
+@app.get("/api/varv/calib")
+async def varv_calib_get() -> dict:
+    """Tume/valge referents (avalik lugemine)."""
+    calib = varv_load_calib()
+    return {
+        "dark": calib.get("dark"),
+        "white": calib.get("white"),
+        "ready": bool(calib.get("dark") and calib.get("white")),
+    }
+
+
+@app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
+async def varv_calib_set(payload: dict = Body(...)) -> dict:
+    """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
+    kind = str(payload.get("kind", "")).strip().lower()
+    if kind not in ("dark", "white"):
+        raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
+    last = max(1, min(int(payload.get("last", 5)), 60))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    # Stabiilsuse kontroll: viimased mõõtmised peavad olema samast olekust.
+    totals = [
+        sum(float(r[b]) for b in VARV_BANDS if r.get(b) is not None)
+        for r in rows
+    ]
+    tmax = max(totals) if totals else 0.0
+    if tmax > 0 and (tmax - min(totals)) / tmax > 0.2:
+        raise HTTPException(
+            status_code=422,
+            detail="Mõõtmine pole stabiliseerunud (viimased mõõtmised erinevad). Oota ~30 s pärast katmise/valge pinna muutust ja proovi uuesti.",
+        )
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+
+    # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
+    total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if total_new <= 0:
+        raise HTTPException(status_code=422, detail="Mõõtmine on null — andur on pime või andmed puuduvad.")
+    calib = varv_load_calib()
+    other = calib.get("white" if kind == "dark" else "dark")
+    if other:
+        total_other = sum(float(other.get(b) or 0.0) for b in VARV_BANDS)
+        if kind == "white" and total_new <= total_other * 1.05:
+            raise HTTPException(
+                status_code=422,
+                detail="Valge ja tume on liiga sarnased (valge pole tumedast heledam). Kontrolli: tume = andur kaetud; valge = valge pind valgustatud, sama geomeetria.",
+            )
+        if kind == "dark" and total_new >= total_other * 0.95:
+            raise HTTPException(
+                status_code=422,
+                detail="Tume ja valge on liiga sarnased (tume pole valgest tumedem). Kontrolli, et andur oli täiesti kaetud.",
+            )
+    row = {"kind": kind, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"kind": kind, "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/history")
+async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
+    """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
+    rows = ch_query(
+        f"SELECT toString(toStartOfHour(ts)) AS bucket, "
+        f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
+        f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
+        f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
+        f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
+        f"GROUP BY toStartOfHour(ts) ORDER BY t"
+    )
+    out = []
+    for r in rows:
+        rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
+        out.append({
+            "ts": r["bucket"],
+            "t": int(r["t"]),
+            "r": rv, "g": gv, "b": bv,
+            "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "n": int(r.get("n") or 0),
+        })
+    return {"hours": hours, "rows": out}
+
+
+# --- Värvisensori mõõtmise sisse/välja lüliti (MQTT, ainult sisselogitult) ---
+
+
+def _varv_mqtt_client() -> mqtt.Client:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
+    if VARV_MQTT_TLS:
+        client.tls_set()
+    if VARV_MQTT_USER:
+        client.username_pw_set(VARV_MQTT_USER, VARV_MQTT_PASS)
+    return client
+
+
+def varv_read_state(timeout: float = 4.0) -> str | None:
+    """Loe lüliti retained olek edge-brokerist (lühiajaline ühendus)."""
+    if not VARV_MQTT_HOST:
+        return None
+    box: dict[str, str] = {}
+
+    def on_connect(c, userdata, flags, rc, props=None):
+        if rc == 0:
+            c.subscribe(VARV_STATE_TOPIC)
+
+    def on_message(c, userdata, msg):
+        box["state"] = msg.payload.decode().strip()
+
+    client = _varv_mqtt_client()
+    client.on_connect = on_connect
+    client.on_message = on_message
+    try:
+        client.connect_async(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline and "state" not in box:
+            time.sleep(0.05)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+    state = box.get("state")
+    return state if state in ("ON", "OFF") else None
+
+
+def varv_set_power(on: bool) -> None:
+    if not VARV_MQTT_HOST:
+        raise HTTPException(status_code=503, detail="Värvisensori MQTT pole seadistatud")
+    client = _varv_mqtt_client()
+    try:
+        client.connect(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        info = client.publish(VARV_CMD_TOPIC, "ON" if on else "OFF", qos=1, retain=True)
+        info.wait_for_publish(timeout=5)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+
+
+@app.get("/api/varv/power", dependencies=[Depends(require_auth)])
+def varv_power_get() -> dict:
+    """Värvisensori mõõtmise olek (ainult sisselogitult)."""
+    state = varv_read_state()
+    return {
+        "available": bool(VARV_MQTT_HOST),
+        "state": state,
+        "on": None if state is None else (state == "ON"),
+    }
+
+
+@app.post("/api/varv/power", dependencies=[Depends(require_auth)])
+async def varv_power_set(payload: dict = Body(...)) -> dict:
+    """Lülita värvisensori mõõtmine sisse/välja (ainult sisselogitult).
+
+    OFF peatab ESP32 publitseerimise -> ClickHouse'i ei kirjutata.
+    """
+    on = bool(payload.get("on"))
+    await asyncio.to_thread(varv_set_power, on)
+    state = await asyncio.to_thread(varv_read_state, 3.0)
+    return {"on": on, "state": state}
+
+
+@app.get("/api/latest")
+async def latest(request: Request) -> dict:
+    readings = ch_query(
+        f"SELECT device, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"toString(max(hour)) AS ts_str, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
+    )
+    faults = ch_query(
+        f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
+        f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
+        f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
+    ) if is_authed(request) else []
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {
+        "readings": [
+            {
+                "device": r["device"],
+                "temperature": r["temperature"],
+                "humidity": r["humidity"],
+                "hour": r["ts_str"],
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in readings
+        ],
+        "open_faults": [
+            {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
+            for r in faults
+        ],
+        "outside": outside,
+    }
+
+
+
+# --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
+
+
+def fetch_bus_departures() -> dict:
+    """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
+    fields = (
+        "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
+        "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
+        "trip { route { shortName longName } } }"
+    )
+    aliases = " ".join(
+        f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
+    )
+    try:
+        resp = requests.post(
+            BUS_GRAPHQL,
+            json={"query": "{ " + aliases + " }"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
+    try:
+        payload = resp.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
+    if payload.get("errors"):
+        msg = payload["errors"][0].get("message", "viga")
+        raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
+
+    departures = []
+    for stop in (payload.get("data") or {}).values():
+        if not stop or not stop.get("stoptimesWithoutPatterns"):
+            continue
+        stop_name = stop.get("name") or "Suuremõisa"
+        for st in stop["stoptimesWithoutPatterns"]:
+            service_day = st.get("serviceDay")
+            if service_day is None:
+                continue
+            sched_s = st.get("scheduledDeparture") or 0
+            real_s = st.get("realtimeDeparture")
+            dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
+            try:
+                dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
+            except (OverflowError, OSError, TypeError, ValueError):
+                continue
+            route = (st.get("trip") or {}).get("route") or {}
+            delay = None
+            if st.get("realtime") and real_s is not None:
+                delay = round((int(real_s) - int(sched_s)) / 60)
+            departures.append({
+                "t": int(dep_dt.timestamp() * 1000),
+                "time": dep_dt.strftime("%H:%M"),
+                "date": dep_dt.strftime("%Y-%m-%d"),
+                "stop": stop_name,
+                "route": route.get("shortName") or "",
+                "headsign": st.get("headsign") or "",
+                "realtime": bool(st.get("realtime")),
+                "delay_min": delay,
+            })
+    departures.sort(key=lambda d: d["t"])
+    now = datetime.now(TZ)
+    return {
+        "stops": BUS_STOPS,
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/buss")
+async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
+    """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
+    data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
+    return {**data, "departures": data["departures"][:limit]}
+
+
+# --- Transport: praamid (praamid.ee) ---
+
+
+def _praam_events(day: str, direction: str) -> list[dict]:
+    try:
+        resp = requests.get(
+            PRAAM_API,
+            params={"direction": direction, "departure-date": day, "time-shift": "0"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    try:
+        items = (resp.json() or {}).get("items") or []
+    except ValueError:
+        return []
+    out = []
+    for e in items:
+        try:
+            dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
+        except ValueError:
+            continue
+        ship = e.get("ship")
+        ship_code = ship.get("code") if isinstance(ship, dict) else ship
+        status = e.get("status") or "?"
+        out.append({
+            "uid": e.get("uid"),
+            "direction": direction,
+            "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
+            "date": dep_dt.strftime("%Y-%m-%d"),
+            "time": dep_dt.strftime("%H:%M"),
+            "t": int(dep_dt.timestamp() * 1000),
+            "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
+            "ship_code": ship_code,
+            "status": status,
+            "status_et": PRAAM_STATUS.get(status, status),
+            "disrupted": status != "CONFIRMED",
+        })
+    return out
+
+
+def fetch_weather_warnings() -> list[dict]:
+    """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
+    try:
+        resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    text = html.unescape(resp.text)
+    text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = re.sub(r"\s+", " ", text)
+    found, seen = [], set()
+    for area in SEA_AREAS:
+        i = 0
+        while True:
+            i = text.find(area, i)
+            if i < 0:
+                break
+            seg = text[max(0, i - 100): i + 700]
+            if any(w in seg.lower() for w in WIND_TYPES):
+                m = re.search(r"Kehtib[^.]{0,200}", seg)
+                valid = m.group(0).strip() if m else ""
+                key = (area, valid or seg[:80])
+                if key not in seen:
+                    seen.add(key)
+                    found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
+            i += len(area)
+    return found
+
+
+def fetch_praam(days: int) -> dict:
+    now = datetime.now(TZ)
+    day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
+    departures = []
+    for day in day_list:
+        for direction in ("HR", "RH"):
+            departures.extend(_praam_events(day, direction))
+    departures.sort(key=lambda d: (d["date"], d["time"]))
+    return {
+        "date": day_list[0],
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "warnings": fetch_weather_warnings(),
+        "disrupted": sum(1 for d in departures if d["disrupted"]),
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/praam")
+async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
+    """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
+    return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
+
+
+LOGIN_HTML = """<!doctype html>
+<html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Nutikodu — login</title>
+<style>
+  body{font-family:system-ui,sans-serif;background:#0e1116;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
+  form{background:#161b22;padding:28px 24px;border-radius:12px;border:1px solid #30363d;min-width:280px}
+  h1{font-size:18px;margin:0 0 16px}
+  input{width:100%;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;box-sizing:border-box;font-size:16px}
+  button{margin-top:14px;width:100%;padding:10px;border-radius:8px;border:0;background:#1f6feb;color:#fff;font-size:16px;cursor:pointer}
+  .err{color:#f85149;font-size:14px;margin-top:10px;min-height:18px}
+  a{color:#58a6ff;font-size:13px;text-decoration:none}
+</style></head><body>
+<form method="post" action="/login">
+  <h1>🔒 Nutikodu — sisselogimine</h1>
+  <input type="password" name="password" placeholder="Parool" autofocus autocomplete="current-password">
+  <button type="submit">Logi sisse</button>
+  <div class="err">{{ERR}}</div>
+  <div style="margin-top:14px"><a href="/">← Temperatuuri leht (avalik)</a></div>
+</form>
+</body></html>"""
+
+
+@app.get("/login")
+async def login_page():
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", ""))
+
+
+@app.post("/login")
+async def login_submit(request: Request, password: str = Form("")):
+    ip = client_ip(request)
+    ra = retry_after_seconds(ip)
+    if ra:
+        return HTMLResponse(
+            LOGIN_HTML.replace("{{ERR}}", f"Liiga palju katseid. Proovi {ra} sekundi pärast."),
+            status_code=429,
+        )
+    if WEB_PASSWORD and secrets.compare_digest(password, WEB_PASSWORD):
+        reset_login_failures(ip)
+        request.session["auth"] = True
+        return RedirectResponse("/?auth=1", status_code=303)
+    register_login_failure(ip)
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", "Vale parool"), status_code=401)
+
+
+@app.get("/logout")
+async def logout(request: Request):
+    request.session.clear()
+    return RedirectResponse("/", status_code=303)
+
+
+@app.get("/api/me")
+async def me(request: Request) -> dict:
+    return {"authed": is_authed(request)}
+
+
+
+def _ha_headers() -> dict:
+    return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
+
+
+def ha_get_away() -> bool | None:
+    if not HA_TOKEN:
+        return None
+    try:
+        r = requests.get(f"{HA_URL}/api/states/{AWAY_ENTITY}", headers=_ha_headers(), timeout=8)
+        if r.status_code == 200:
+            return r.json().get("state") == "on"
+    except requests.RequestException:
+        pass
+    return None
+
+
+def ha_set_away(on: bool) -> None:
+    service = "turn_on" if on else "turn_off"
+    r = requests.post(
+        f"{HA_URL}/api/services/input_boolean/{service}",
+        headers=_ha_headers(),
+        json={"entity_id": AWAY_ENTITY},
+        timeout=8,
+    )
+    r.raise_for_status()
+
+
+@app.get("/api/away", dependencies=[Depends(require_auth)])
+async def away_state() -> dict:
+    """Kohalolu ("kodust eemal") olek."""
+    state = ha_get_away()
+    return {"away": state, "available": state is not None}
+
+
+@app.post("/api/away", dependencies=[Depends(require_auth)])
+async def away_set(payload: dict = Body(...)) -> dict:
+    """Lülita "kodust eemal" sisse/välja (keha {"on": true|false})."""
+    on = bool(payload.get("on"))
+    try:
+        ha_set_away(on)
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Home Assistant viga: {exc}") from exc
+    return {"away": on, "available": True}
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)

+ 1471 - 0
dev/nutikodu-web/app.py.bak.20261024-2048

@@ -0,0 +1,1471 @@
+"""Nutikodu andmete veebiliides (FastAPI).
+
+Loeb ClickHouse'i `uus67` skeemist (zigbee andurid) ja serveerib:
+  GET /                      -> veebirakendus (static/index.html)
+  GET /api/meta              -> seadmed, tz, andmete ulatus
+  GET /api/temperatuur       -> aegrida (temp + niiskus), agg=hour|day
+  GET /api/ukseandur         -> ukseolekute sündmused (tulevikuks)
+  GET /api/vead              -> häirete sündmused (tulevikuks)
+  GET /api/latest            -> viimased väärtused + lahtised häired
+  GET /health
+
+ClickHouse'i päringud kasutavad `zigbee` kasutajat (SELECT uus67.*) ja
+`param_*` parameetreid (SQL-süst välistatud). Tulemused loetakse
+`FORMAT JSONEachRow` kujul (üks JSON-objekt rea kohta).
+Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hmac
+import html
+import secrets
+import json
+import os
+import re
+import threading
+import time
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import color
+import numpy as np
+import paho.mqtt.client as mqtt
+import requests
+from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
+from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
+
+ROOT = Path(__file__).parent
+STATIC_DIR = ROOT / "static"
+
+TZ = ZoneInfo("Europe/Tallinn")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+
+# Värvisensori juhtimine (MQTT, edge broker). OFF peatab ESP32 publitseerimise
+# -> ClickHouse'i ei kirjutata.
+VARV_MQTT_HOST = os.environ.get("VARV_MQTT_HOST", "")
+VARV_MQTT_PORT = int(os.environ.get("VARV_MQTT_PORT", "8883"))
+VARV_MQTT_TLS = os.environ.get("VARV_MQTT_TLS", "true").strip().lower() in ("1", "true", "yes", "on")
+VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
+VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
+VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
+VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
+VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
+VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
+VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
+
+WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
+WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
+WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
+HA_URL = os.environ.get("HA_URL", "http://100.87.1.24:8123").rstrip("/")
+HA_TOKEN = os.environ.get("HA_TOKEN", "")
+AWAY_ENTITY = os.environ.get("AWAY_ENTITY", "input_boolean.kodust_eemal")
+SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "604800"))
+LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
+LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "300"))
+
+DEFAULT_RANGE_DAYS = 7
+MAX_RANGE_DAYS = 3660
+
+# --- Transport (bussid + praamid) ---
+BUS_GRAPHQL = os.environ.get(
+    "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
+).rstrip("/")
+BUS_STOPS = [s.strip() for s in os.environ.get(
+    "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
+PRAAM_API = os.environ.get(
+    "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
+RII_HOIATUS_URL = os.environ.get(
+    "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
+)
+TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
+UA = "Mozilla/5.0 (compatible; Nutikodu-web/1.0)"
+
+PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
+SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
+PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
+SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
+WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
+
+_cache: dict[str, tuple[float, object]] = {}
+_cache_lock = threading.Lock()
+
+
+def cached(key: str, ttl: int, producer):
+    """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
+    now = time.monotonic()
+    with _cache_lock:
+        hit = _cache.get(key)
+        if hit and now - hit[0] < ttl:
+            return hit[1]
+    value = producer()
+    with _cache_lock:
+        _cache[key] = (time.monotonic(), value)
+    return value
+
+
+app = FastAPI(title="Nutikodu", version="0.2.0")
+app.add_middleware(
+    SessionMiddleware,
+    secret_key=WEB_SECRET_KEY,
+    https_only=False,
+    same_site="lax",
+    max_age=SESSION_MAX_AGE,
+)
+
+
+@app.middleware("http")
+async def add_no_cache(request: Request, call_next):
+    """Staatika ja API ilma vahemäluta — muidu jääb brauserisse vana JS/HTML."""
+    response = await call_next(request)
+    path = request.url.path
+    if (
+        path == "/"
+        or path.startswith("/api/")
+        or path.endswith((".js", ".css", ".html", ".webmanifest", ".svg"))
+    ):
+        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+        response.headers["Pragma"] = "no-cache"
+    return response
+
+
+def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
+    """Käivita ClickHouse'i päring ja tagasta read JSON-objektidena."""
+    q = {"database": CH_DB, "query": f"{query} FORMAT JSONEachRow"}
+    for key, value in (params or {}).items():
+        q[f"param_{key}"] = value
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params=q,
+            auth=(CH_USER, CH_PASS),
+            timeout=20,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text.strip()[:400]}")
+    text = resp.text.strip()
+    if not text:
+        return []
+    try:
+        return [json.loads(line) for line in text.splitlines() if line.strip()]
+    except json.JSONDecodeError as exc:
+        raise HTTPException(status_code=502, detail=f"ClickHouse'i vastus: {text[:200]}") from exc
+
+
+_login_failures: dict[str, list[float]] = defaultdict(list)
+_login_lock = threading.Lock()
+
+
+def client_ip(request: Request) -> str:
+    forwarded = request.headers.get("x-forwarded-for")
+    if forwarded:
+        return forwarded.split(",")[0].strip()
+    return request.client.host if request.client else "?"
+
+
+def retry_after_seconds(ip: str) -> int:
+    now = time.monotonic()
+    with _login_lock:
+        history = [t for t in _login_failures.get(ip, []) if now - t < LOGIN_WINDOW_SECONDS]
+        _login_failures[ip] = history
+        if len(history) >= LOGIN_MAX_ATTEMPTS:
+            return int(LOGIN_WINDOW_SECONDS - (now - history[0])) + 1
+    return 0
+
+
+def register_login_failure(ip: str) -> None:
+    with _login_lock:
+        _login_failures[ip].append(time.monotonic())
+
+
+def reset_login_failures(ip: str) -> None:
+    with _login_lock:
+        _login_failures.pop(ip, None)
+
+
+def is_authed(request: Request) -> bool:
+    """Kas päring on autenditud: Bearer token VÕI sessiooniküpsis."""
+    auth = request.headers.get("authorization", "")
+    if auth.lower().startswith("bearer "):
+        token = auth[7:].strip()
+        if WEB_ACCESS_TOKEN and secrets.compare_digest(token, WEB_ACCESS_TOKEN):
+            return True
+    try:
+        if request.session.get("auth") is True:
+            return True
+    except Exception:
+        pass
+    return False
+
+
+def require_auth(request: Request) -> None:
+    if not is_authed(request):
+        raise HTTPException(
+            status_code=401,
+            detail="Autentimine nõutud (Bearer token või sisselogimine)",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+
+def parse_dt(value: str | None, default: datetime) -> str:
+    """Võta vastu 'YYYY-MM-DD[ HH:MM[:SS]]' või ISO 'T'-eraldajaga."""
+    if not value:
+        return default.strftime("%Y-%m-%d %H:%M:%S")
+    cleaned = value.strip().replace("T", " ")
+    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
+        except ValueError:
+            continue
+    raise HTTPException(status_code=422, detail=f"Vigane kuupäev: {value}")
+
+
+def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
+    now = datetime.now(TZ)
+    to_s = parse_dt(to, now)
+    to_dt = datetime.strptime(to_s, "%Y-%m-%d %H:%M:%S")
+    from_s = parse_dt(from_, to_dt - timedelta(days=DEFAULT_RANGE_DAYS))
+    from_dt = datetime.strptime(from_s, "%Y-%m-%d %H:%M:%S")
+    if from_dt > to_dt:
+        raise HTTPException(status_code=422, detail="'from' peab olema enne 'to'")
+    if (to_dt - from_dt).days > MAX_RANGE_DAYS:
+        raise HTTPException(status_code=422, detail="Vahemik on liiga pikk")
+    return from_s, to_s
+
+
+@app.get("/api/meta")
+async def meta() -> dict:
+    devices = [row["device"] for row in ch_query(
+        f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
+    )]
+    bounds = ch_query(
+        f"SELECT toString(min(hour)) AS min_hour, toString(max(hour)) AS max_hour, count() AS rows "
+        f"FROM {CH_DB}.temperatuur"
+    )
+    first = bounds[0] if bounds else {"min_hour": None, "max_hour": None, "rows": 0}
+    return {
+        "tz": "Europe/Tallinn",
+        "db": CH_DB,
+        "devices": devices,
+        "min": first["min_hour"],
+        "max": first["max_hour"],
+        "rows": first["rows"],
+        "default_range_days": DEFAULT_RANGE_DAYS,
+    }
+
+
+@app.get("/api/temperatuur")
+async def temperatuur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
+        params,
+    )
+
+    series: dict[str, dict] = {}
+    for row in rows:
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/valisilm")
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+@app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
+async def ukseandur(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, contact, olek "
+        f"FROM {CH_DB}.ukseandur WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {"t": int(r["t"]), "device": r["device"], "contact": bool(r["contact"]), "olek": r["olek"]}
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/vead", dependencies=[Depends(require_auth)])
+async def vead(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, tyyp, olek, detail, vaartus "
+        f"FROM {CH_DB}.vead WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "tyyp": r["tyyp"],
+                "olek": r["olek"],
+                "detail": r["detail"],
+                "vaartus": r["vaartus"],
+            }
+            for r in rows
+        ],
+    }
+
+
+
+
+@app.get("/api/liikumine", dependencies=[Depends(require_auth)])
+async def liikumine(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    rows = ch_query(
+        f"SELECT toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, occupancy, illumination, olek "
+        f"FROM {CH_DB}.liikumine WHERE {where} ORDER BY ts",
+        params,
+    )
+    return {
+        "from": from_s,
+        "to": to_s,
+        "events": [
+            {
+                "t": int(r["t"]),
+                "device": r["device"],
+                "occupancy": bool(r["occupancy"]),
+                "illumination": r["illumination"],
+                "olek": r["olek"],
+            }
+            for r in rows
+        ],
+    }
+
+
+@app.get("/api/energia", dependencies=[Depends(require_auth)])
+async def energia(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    device: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"ts BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if device:
+        where += " AND device = {device:String}"
+        params["device"] = device
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(ts))) * 1000 AS t, device, "
+            "round(avg(power), 2) AS power, round(avg(voltage), 1) AS voltage, "
+            "round(avg(current), 3) AS current, max(energy) AS energy"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(ts)) * 1000 AS t, device, "
+            "power, voltage, current, energy"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.energia WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, dict] = {}
+    for row in rows:
+        s2 = series.setdefault(
+            row["device"], {"t": [], "power": [], "voltage": [], "current": [], "energy": []}
+        )
+        s2["t"].append(int(row["t"]))
+        s2["power"].append(row["power"])
+        s2["voltage"].append(row["voltage"])
+        s2["current"].append(row["current"])
+        s2["energy"].append(row["energy"])
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
+
+
+@app.get("/api/uus67_latest", dependencies=[Depends(require_auth)])
+async def uus67_latest() -> dict:
+    energy = ch_query(
+        f"SELECT device, argMax(power, ts) AS power, argMax(voltage, ts) AS voltage, "
+        f"argMax(current, ts) AS current, argMax(energy, ts) AS energy, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.energia GROUP BY device ORDER BY device"
+    )
+    motion = ch_query(
+        f"SELECT device, argMax(occupancy, ts) AS occupancy, "
+        f"argMax(illumination, ts) AS illumination, "
+        f"toUInt64(toUnixTimestamp(max(ts))) * 1000 AS t "
+        f"FROM {CH_DB}.liikumine GROUP BY device ORDER BY device"
+    )
+    return {
+        "energy": [
+            {
+                "device": r["device"],
+                "power": r.get("power"),
+                "voltage": r.get("voltage"),
+                "current": r.get("current"),
+                "energy": r.get("energy"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in energy
+        ],
+        "motion": [
+            {
+                "device": r["device"],
+                "occupancy": bool(r.get("occupancy")),
+                "illumination": r.get("illumination"),
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in motion
+        ],
+    }
+
+
+# --- Värvisensor (uus67.varvus, AS7341) ---
+
+
+def varv_load_calib() -> dict:
+    """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
+    try:
+        rows = ch_query(
+            f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
+            f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
+        )
+    except HTTPException:
+        return {}
+    out: dict[str, dict] = {}
+    for r in rows:
+        out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
+    return out
+
+
+def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
+    """R = (S - D) / (W - D) kanali kohta."""
+    out: dict[str, float] = {}
+    for band in VARV_BANDS:
+        v = channels.get(band)
+        d = float(dark.get(band) or 0.0)
+        w = float(white.get(band) or 0.0)
+        if v is not None and (w - d) > 0:
+            out[band] = max(0.0, (float(v) - d) / (w - d))
+    return out
+
+
+# --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
+
+
+def varv_load_ccm() -> list[list[float]] | None:
+    try:
+        rows = ch_query(
+            f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
+            f"ORDER BY updated_at DESC LIMIT 1"
+        )
+    except HTTPException:
+        return None
+    if not rows:
+        return None
+    try:
+        m = json.loads(rows[0]["matrix"])
+        if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
+            return [[float(x) for x in row] for row in m]
+    except Exception:
+        return None
+    return None
+
+
+def _srgb_to_linear(v: float) -> float:
+    c = v / 255.0
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb8(c: float) -> int:
+    c = max(0.0, min(1.0, c))
+    v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+    return int(round(255 * max(0.0, min(1.0, v))))
+
+
+def varv_apply_ccm(matrix: list[list[float]], channels: dict, dark: dict | None = None, white: dict | None = None) -> str:
+    src = varv_reflectance(channels, dark or {}, white) if white else channels
+    vec = np.array([float(src.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+    lin = np.array(matrix, dtype=float) @ vec
+    r = _linear_to_srgb8(float(lin[0]))
+    g = _linear_to_srgb8(float(lin[1]))
+    b = _linear_to_srgb8(float(lin[2]))
+    return f"#{r:02X}{g:02X}{b:02X}"
+
+
+@app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
+async def varv_ref_set(payload: dict = Body(...)) -> dict:
+    """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
+    name = str(payload.get("name", "")).strip().lower()
+    hexv = str(payload.get("hex", "")).strip().lstrip("#")
+    if len(hexv) != 6:
+        raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
+    try:
+        tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
+    except ValueError:
+        raise HTTPException(status_code=422, detail="vigane hex")
+    last = max(1, min(int(payload.get("last", 5)), 30))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+    total = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if (tr + tg + tb) >= 10 and total < 300:
+        raise HTTPException(
+            status_code=422,
+            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.",
+        )
+    row = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_TABLE} FORMAT JSONEachRow"},
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/ref")
+async def varv_ref_get() -> dict:
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    return {"refs": rows}
+
+
+def _lin_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
+    x = 0.4124 * r + 0.3576 * g + 0.1805 * b
+    y = 0.2126 * r + 0.7152 * g + 0.0722 * b
+    z = 0.0193 * r + 0.1192 * g + 0.9505 * b
+
+    def f(t: float) -> float:
+        return t ** (1.0 / 3.0) if t > 0.008856 else (7.787 * t + 16.0 / 116.0)
+
+    fx, fy, fz = f(x / 0.95047), f(y / 1.0), f(z / 1.08883)
+    return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
+
+
+def _delta_e00(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
+    import math
+    L1, a1, b1 = lab1
+    L2, a2, b2 = lab2
+    C1 = math.hypot(a1, b1)
+    C2 = math.hypot(a2, b2)
+    Cb = (C1 + C2) / 2.0
+    G = 0.5 * (1.0 - math.sqrt(Cb ** 7 / (Cb ** 7 + 25.0 ** 7))) if Cb > 0 else 0.0
+    a1p, a2p = (1.0 + G) * a1, (1.0 + G) * a2
+    C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2)
+    h1p = math.degrees(math.atan2(b1, a1p)) % 360.0
+    h2p = math.degrees(math.atan2(b2, a2p)) % 360.0
+    dLp = L2 - L1
+    dCp = C2p - C1p
+    if C1p * C2p == 0:
+        dhp = 0.0
+    else:
+        dhp = h2p - h1p
+        if dhp > 180:
+            dhp -= 360
+        elif dhp < -180:
+            dhp += 360
+    dHp = 2.0 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp / 2.0))
+    Lbp = (L1 + L2) / 2.0
+    Cbp = (C1p + C2p) / 2.0
+    if C1p * C2p == 0:
+        hbp = h1p + h2p
+    else:
+        hbp = (h1p + h2p) / 2.0
+        if abs(h1p - h2p) > 180:
+            hbp = (h1p + h2p + 360.0) / 2.0 if (h1p + h2p) < 360 else (h1p + h2p - 360.0) / 2.0
+    T = (1.0 - 0.17 * math.cos(math.radians(hbp - 30.0))
+         + 0.24 * math.cos(math.radians(2.0 * hbp))
+         + 0.32 * math.cos(math.radians(3.0 * hbp + 6.0))
+         - 0.20 * math.cos(math.radians(4.0 * hbp - 63.0)))
+    dtheta = 30.0 * math.exp(-((hbp - 275.0) / 25.0) ** 2)
+    Rc = 2.0 * math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25.0 ** 7)) if Cbp > 0 else 0.0
+    Sl = 1.0 + 0.015 * (Lbp - 50.0) ** 2 / math.sqrt(20.0 + (Lbp - 50.0) ** 2)
+    Sc = 1.0 + 0.045 * Cbp
+    Sh = 1.0 + 0.015 * Cbp * T
+    Rt = -math.sin(math.radians(2.0 * dtheta)) * Rc
+    return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2
+                     + Rt * (dCp / Sc) * (dHp / Sh))
+
+
+def _fit_ccm(C: "np.ndarray", T: "np.ndarray") -> "np.ndarray":
+    CtC = C.T @ C
+    CtT = C.T @ T
+    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
+    return np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)
+
+
+def _predict_hex(M: "np.ndarray", c: "np.ndarray") -> str:
+    lin = np.clip(c @ M, 0.0, 1.0)
+    return "#" + "".join(f"{_linear_to_srgb8(float(v)):02X}" for v in lin)
+
+
+@app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
+async def varv_ccm_fit(prefix: str | None = Query(None)) -> dict:
+    """Sobita 3x8 maatriks reflekteeruvusest -> lineaarne sRGB.
+
+    prefix filtreerib referentsid (nt wizard: 'wiz').
+    """
+    where = ""
+    if prefix:
+        if not re.fullmatch(r"[a-z0-9_-]{1,16}", prefix):
+            raise HTTPException(status_code=422, detail="vigane prefix")
+        where = f" WHERE name LIKE '{prefix}%'"
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL{where} ORDER BY name"
+    )
+    if len(rows) < 6:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 6 referentsvärvi (soovitav 12-24).")
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    if not white:
+        raise HTTPException(status_code=422, detail="Valge referents puudub — kalibreeri enne tume ja valge.")
+
+    def _feat(r):
+        ch = {b: r.get(b) for b in VARV_BANDS}
+        ch = varv_reflectance(ch, dark or {}, white)
+        return [float(ch.get(b) or 0.0) for b in VARV_BANDS]
+
+    C = np.array([_feat(r) for r in rows], dtype=float)
+    T = np.array(
+        [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
+         for r in rows],
+        dtype=float,
+    )
+    M = _fit_ccm(C, T)
+    matrix = M.T.tolist()
+
+    # In-sample ja leave-one-out ΔE00
+    def _de(pred: np.ndarray, target: np.ndarray) -> float:
+        return _delta_e00(_lin_to_lab(*pred), _lin_to_lab(*target))
+
+    insample = [_de(np.clip(C[i] @ M, 0, 1), T[i]) for i in range(len(rows))]
+    loo = []
+    for i in range(len(rows)):
+        mask = np.ones(len(rows), dtype=bool)
+        mask[i] = False
+        Mi = _fit_ccm(C[mask], T[mask])
+        loo.append(_de(np.clip(C[i] @ Mi, 0, 1), T[i]))
+
+    def _stats(v):
+        v = np.array(v)
+        return {"mean": round(float(v.mean()), 2), "median": round(float(np.median(v)), 2), "max": round(float(v.max()), 2)}
+
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
+        },
+        data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {
+        "n_refs": len(rows),
+        "matrix": matrix,
+        "de00_in_sample": _stats(insample),
+        "de00_loo": _stats(loo),
+        "preview": [
+            {"name": r["name"], "target": f"#{int(r['tr']):02X}{int(r['tg']):02X}{int(r['tb']):02X}",
+             "pred": _predict_hex(M, C[i]), "de00": round(insample[i], 2)}
+            for i, r in enumerate(rows)
+        ],
+    }
+
+
+@app.get("/api/varv/ccm")
+async def varv_ccm_get() -> dict:
+    m = varv_load_ccm()
+    return {"matrix": m, "ready": m is not None}
+
+
+@app.get("/api/varv/latest")
+async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
+    """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
+    cols = ", ".join(VARV_BANDS)
+    rows = ch_query(
+        f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
+        f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
+        f"{cols}, clear, nir, gain "
+        f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
+    )
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    ready = bool(dark and white)
+    ccm = varv_load_ccm()
+    out = []
+    for r in rows:
+        bands = {b: r.get(b) for b in VARV_BANDS}
+        valid, reason = color.quality(bands, dark)
+        raw = color.analyze(bands, dark=dark)
+        item = {
+            "ts": r["ts"],
+            "t": int(r["t"]),
+            "rgb_hex": raw["rgb_hex"],
+            "r": raw["r"],
+            "g": raw["g"],
+            "b": raw["b"],
+            "x_chrom": raw["x_chrom"],
+            "y_chrom": raw["y_chrom"],
+            "bands": bands,
+            "clear": r.get("clear"),
+            "nir": r.get("nir"),
+            "gain": r.get("gain"),
+            "valid": valid,
+            "reason": reason,
+        }
+        if ready:
+            cc = color.analyze(bands, dark=dark, white=white)
+            item["rgb_hex_cal"] = cc["rgb_hex"]
+            item["x_chrom_cal"] = cc["x_chrom"]
+            item["y_chrom_cal"] = cc["y_chrom"]
+        # CCM (3x8) on treenitud ekraaniprimaaridel TOORARVUDE domeenis.
+        # Reflekteeruvusele rakendatuna annab peaaegu musta tulemuse, seega
+        # kuni päris värvikaardi kalibratsioonini seda ei kuvata.
+        # item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands, dark, white)
+        out.append(item)
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
+
+
+@app.get("/api/varv/calib")
+async def varv_calib_get() -> dict:
+    """Tume/valge referents (avalik lugemine)."""
+    calib = varv_load_calib()
+    return {
+        "dark": calib.get("dark"),
+        "white": calib.get("white"),
+        "ready": bool(calib.get("dark") and calib.get("white")),
+    }
+
+
+@app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
+async def varv_calib_set(payload: dict = Body(...)) -> dict:
+    """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
+    kind = str(payload.get("kind", "")).strip().lower()
+    if kind not in ("dark", "white"):
+        raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
+    last = max(1, min(int(payload.get("last", 5)), 60))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    # Stabiilsuse kontroll: viimased mõõtmised peavad olema samast olekust.
+    totals = [
+        sum(float(r[b]) for b in VARV_BANDS if r.get(b) is not None)
+        for r in rows
+    ]
+    tmax = max(totals) if totals else 0.0
+    if tmax > 0 and (tmax - min(totals)) / tmax > 0.2:
+        raise HTTPException(
+            status_code=422,
+            detail="Mõõtmine pole stabiliseerunud (viimased mõõtmised erinevad). Oota ~30 s pärast katmise/valge pinna muutust ja proovi uuesti.",
+        )
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+
+    # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
+    total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if total_new <= 0:
+        raise HTTPException(status_code=422, detail="Mõõtmine on null — andur on pime või andmed puuduvad.")
+    calib = varv_load_calib()
+    other = calib.get("white" if kind == "dark" else "dark")
+    if other:
+        total_other = sum(float(other.get(b) or 0.0) for b in VARV_BANDS)
+        if kind == "white" and total_new <= total_other * 1.05:
+            raise HTTPException(
+                status_code=422,
+                detail="Valge ja tume on liiga sarnased (valge pole tumedast heledam). Kontrolli: tume = andur kaetud; valge = valge pind valgustatud, sama geomeetria.",
+            )
+        if kind == "dark" and total_new >= total_other * 0.95:
+            raise HTTPException(
+                status_code=422,
+                detail="Tume ja valge on liiga sarnased (tume pole valgest tumedem). Kontrolli, et andur oli täiesti kaetud.",
+            )
+    row = {"kind": kind, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"kind": kind, "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/history")
+async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
+    """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
+    rows = ch_query(
+        f"SELECT toString(toStartOfHour(ts)) AS bucket, "
+        f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
+        f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
+        f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
+        f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
+        f"GROUP BY toStartOfHour(ts) ORDER BY t"
+    )
+    out = []
+    for r in rows:
+        rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
+        out.append({
+            "ts": r["bucket"],
+            "t": int(r["t"]),
+            "r": rv, "g": gv, "b": bv,
+            "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "n": int(r.get("n") or 0),
+        })
+    return {"hours": hours, "rows": out}
+
+
+# --- Värvisensori mõõtmise sisse/välja lüliti (MQTT, ainult sisselogitult) ---
+
+
+def _varv_mqtt_client() -> mqtt.Client:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
+    if VARV_MQTT_TLS:
+        client.tls_set()
+    if VARV_MQTT_USER:
+        client.username_pw_set(VARV_MQTT_USER, VARV_MQTT_PASS)
+    return client
+
+
+def varv_read_state(timeout: float = 4.0) -> str | None:
+    """Loe lüliti retained olek edge-brokerist (lühiajaline ühendus)."""
+    if not VARV_MQTT_HOST:
+        return None
+    box: dict[str, str] = {}
+
+    def on_connect(c, userdata, flags, rc, props=None):
+        if rc == 0:
+            c.subscribe(VARV_STATE_TOPIC)
+
+    def on_message(c, userdata, msg):
+        box["state"] = msg.payload.decode().strip()
+
+    client = _varv_mqtt_client()
+    client.on_connect = on_connect
+    client.on_message = on_message
+    try:
+        client.connect_async(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline and "state" not in box:
+            time.sleep(0.05)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+    state = box.get("state")
+    return state if state in ("ON", "OFF") else None
+
+
+def varv_set_power(on: bool) -> None:
+    if not VARV_MQTT_HOST:
+        raise HTTPException(status_code=503, detail="Värvisensori MQTT pole seadistatud")
+    client = _varv_mqtt_client()
+    try:
+        client.connect(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        info = client.publish(VARV_CMD_TOPIC, "ON" if on else "OFF", qos=1, retain=True)
+        info.wait_for_publish(timeout=5)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+
+
+@app.get("/api/varv/power", dependencies=[Depends(require_auth)])
+def varv_power_get() -> dict:
+    """Värvisensori mõõtmise olek (ainult sisselogitult)."""
+    state = varv_read_state()
+    return {
+        "available": bool(VARV_MQTT_HOST),
+        "state": state,
+        "on": None if state is None else (state == "ON"),
+    }
+
+
+@app.post("/api/varv/power", dependencies=[Depends(require_auth)])
+async def varv_power_set(payload: dict = Body(...)) -> dict:
+    """Lülita värvisensori mõõtmine sisse/välja (ainult sisselogitult).
+
+    OFF peatab ESP32 publitseerimise -> ClickHouse'i ei kirjutata.
+    """
+    on = bool(payload.get("on"))
+    await asyncio.to_thread(varv_set_power, on)
+    state = await asyncio.to_thread(varv_read_state, 3.0)
+    return {"on": on, "state": state}
+
+
+@app.get("/api/latest")
+async def latest(request: Request) -> dict:
+    readings = ch_query(
+        f"SELECT device, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"toString(max(hour)) AS ts_str, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.temperatuur GROUP BY device ORDER BY device"
+    )
+    faults = ch_query(
+        f"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
+        f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
+        f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
+    ) if is_authed(request) else []
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
+    return {
+        "readings": [
+            {
+                "device": r["device"],
+                "temperature": r["temperature"],
+                "humidity": r["humidity"],
+                "hour": r["ts_str"],
+                "t": int(r["t"]) if r.get("t") is not None else None,
+            }
+            for r in readings
+        ],
+        "open_faults": [
+            {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
+            for r in faults
+        ],
+        "outside": outside,
+    }
+
+
+
+# --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
+
+
+def fetch_bus_departures() -> dict:
+    """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
+    fields = (
+        "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
+        "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
+        "trip { route { shortName longName } } }"
+    )
+    aliases = " ".join(
+        f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
+    )
+    try:
+        resp = requests.post(
+            BUS_GRAPHQL,
+            json={"query": "{ " + aliases + " }"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
+    try:
+        payload = resp.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
+    if payload.get("errors"):
+        msg = payload["errors"][0].get("message", "viga")
+        raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
+
+    departures = []
+    for stop in (payload.get("data") or {}).values():
+        if not stop or not stop.get("stoptimesWithoutPatterns"):
+            continue
+        stop_name = stop.get("name") or "Suuremõisa"
+        for st in stop["stoptimesWithoutPatterns"]:
+            service_day = st.get("serviceDay")
+            if service_day is None:
+                continue
+            sched_s = st.get("scheduledDeparture") or 0
+            real_s = st.get("realtimeDeparture")
+            dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
+            try:
+                dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
+            except (OverflowError, OSError, TypeError, ValueError):
+                continue
+            route = (st.get("trip") or {}).get("route") or {}
+            delay = None
+            if st.get("realtime") and real_s is not None:
+                delay = round((int(real_s) - int(sched_s)) / 60)
+            departures.append({
+                "t": int(dep_dt.timestamp() * 1000),
+                "time": dep_dt.strftime("%H:%M"),
+                "date": dep_dt.strftime("%Y-%m-%d"),
+                "stop": stop_name,
+                "route": route.get("shortName") or "",
+                "headsign": st.get("headsign") or "",
+                "realtime": bool(st.get("realtime")),
+                "delay_min": delay,
+            })
+    departures.sort(key=lambda d: d["t"])
+    now = datetime.now(TZ)
+    return {
+        "stops": BUS_STOPS,
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/buss")
+async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
+    """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
+    data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
+    return {**data, "departures": data["departures"][:limit]}
+
+
+# --- Transport: praamid (praamid.ee) ---
+
+
+def _praam_events(day: str, direction: str) -> list[dict]:
+    try:
+        resp = requests.get(
+            PRAAM_API,
+            params={"direction": direction, "departure-date": day, "time-shift": "0"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    try:
+        items = (resp.json() or {}).get("items") or []
+    except ValueError:
+        return []
+    out = []
+    for e in items:
+        try:
+            dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
+        except ValueError:
+            continue
+        ship = e.get("ship")
+        ship_code = ship.get("code") if isinstance(ship, dict) else ship
+        status = e.get("status") or "?"
+        out.append({
+            "uid": e.get("uid"),
+            "direction": direction,
+            "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
+            "date": dep_dt.strftime("%Y-%m-%d"),
+            "time": dep_dt.strftime("%H:%M"),
+            "t": int(dep_dt.timestamp() * 1000),
+            "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
+            "ship_code": ship_code,
+            "status": status,
+            "status_et": PRAAM_STATUS.get(status, status),
+            "disrupted": status != "CONFIRMED",
+        })
+    return out
+
+
+def fetch_weather_warnings() -> list[dict]:
+    """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
+    try:
+        resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    text = html.unescape(resp.text)
+    text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = re.sub(r"\s+", " ", text)
+    found, seen = [], set()
+    for area in SEA_AREAS:
+        i = 0
+        while True:
+            i = text.find(area, i)
+            if i < 0:
+                break
+            seg = text[max(0, i - 100): i + 700]
+            if any(w in seg.lower() for w in WIND_TYPES):
+                m = re.search(r"Kehtib[^.]{0,200}", seg)
+                valid = m.group(0).strip() if m else ""
+                key = (area, valid or seg[:80])
+                if key not in seen:
+                    seen.add(key)
+                    found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
+            i += len(area)
+    return found
+
+
+def fetch_praam(days: int) -> dict:
+    now = datetime.now(TZ)
+    day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
+    departures = []
+    for day in day_list:
+        for direction in ("HR", "RH"):
+            departures.extend(_praam_events(day, direction))
+    departures.sort(key=lambda d: (d["date"], d["time"]))
+    return {
+        "date": day_list[0],
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "warnings": fetch_weather_warnings(),
+        "disrupted": sum(1 for d in departures if d["disrupted"]),
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/praam")
+async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
+    """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
+    return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
+
+
+LOGIN_HTML = """<!doctype html>
+<html lang="et"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Nutikodu — login</title>
+<style>
+  body{font-family:system-ui,sans-serif;background:#0e1116;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
+  form{background:#161b22;padding:28px 24px;border-radius:12px;border:1px solid #30363d;min-width:280px}
+  h1{font-size:18px;margin:0 0 16px}
+  input{width:100%;padding:10px;border-radius:8px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;box-sizing:border-box;font-size:16px}
+  button{margin-top:14px;width:100%;padding:10px;border-radius:8px;border:0;background:#1f6feb;color:#fff;font-size:16px;cursor:pointer}
+  .err{color:#f85149;font-size:14px;margin-top:10px;min-height:18px}
+  a{color:#58a6ff;font-size:13px;text-decoration:none}
+</style></head><body>
+<form method="post" action="/login">
+  <h1>🔒 Nutikodu — sisselogimine</h1>
+  <input type="password" name="password" placeholder="Parool" autofocus autocomplete="current-password">
+  <button type="submit">Logi sisse</button>
+  <div class="err">{{ERR}}</div>
+  <div style="margin-top:14px"><a href="/">← Temperatuuri leht (avalik)</a></div>
+</form>
+</body></html>"""
+
+
+@app.get("/login")
+async def login_page():
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", ""))
+
+
+@app.post("/login")
+async def login_submit(request: Request, password: str = Form("")):
+    ip = client_ip(request)
+    ra = retry_after_seconds(ip)
+    if ra:
+        return HTMLResponse(
+            LOGIN_HTML.replace("{{ERR}}", f"Liiga palju katseid. Proovi {ra} sekundi pärast."),
+            status_code=429,
+        )
+    if WEB_PASSWORD and secrets.compare_digest(password, WEB_PASSWORD):
+        reset_login_failures(ip)
+        request.session["auth"] = True
+        return RedirectResponse("/?auth=1", status_code=303)
+    register_login_failure(ip)
+    return HTMLResponse(LOGIN_HTML.replace("{{ERR}}", "Vale parool"), status_code=401)
+
+
+@app.get("/logout")
+async def logout(request: Request):
+    request.session.clear()
+    return RedirectResponse("/", status_code=303)
+
+
+@app.get("/api/me")
+async def me(request: Request) -> dict:
+    return {"authed": is_authed(request)}
+
+
+
+def _ha_headers() -> dict:
+    return {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}
+
+
+def ha_get_away() -> bool | None:
+    if not HA_TOKEN:
+        return None
+    try:
+        r = requests.get(f"{HA_URL}/api/states/{AWAY_ENTITY}", headers=_ha_headers(), timeout=8)
+        if r.status_code == 200:
+            return r.json().get("state") == "on"
+    except requests.RequestException:
+        pass
+    return None
+
+
+def ha_set_away(on: bool) -> None:
+    service = "turn_on" if on else "turn_off"
+    r = requests.post(
+        f"{HA_URL}/api/services/input_boolean/{service}",
+        headers=_ha_headers(),
+        json={"entity_id": AWAY_ENTITY},
+        timeout=8,
+    )
+    r.raise_for_status()
+
+
+@app.get("/api/away", dependencies=[Depends(require_auth)])
+async def away_state() -> dict:
+    """Kohalolu ("kodust eemal") olek."""
+    state = ha_get_away()
+    return {"away": state, "available": state is not None}
+
+
+@app.post("/api/away", dependencies=[Depends(require_auth)])
+async def away_set(payload: dict = Body(...)) -> dict:
+    """Lülita "kodust eemal" sisse/välja (keha {"on": true|false})."""
+    on = bool(payload.get("on"))
+    try:
+        ha_set_away(on)
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Home Assistant viga: {exc}") from exc
+    return {"away": on, "available": True}
+
+
+@app.post("/api/varv/capture", dependencies=[Depends(require_auth)])
+async def varv_capture(payload: dict = Body(default={})) -> dict:
+    """Oota N uut mõõtmist ja tagasta keskmine + stabiilsus."""
+    n = max(2, min(int(payload.get("n", 3)), 10))
+    timeout = max(12, min(int(payload.get("timeout", 45)), 90))
+    t0 = datetime.now(TZ)
+    epoch = int(t0.timestamp())
+    deadline = time.time() + timeout
+    rows: list[dict] = []
+    sql = (
+        f"SELECT toString(ts) AS ts, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.varvus WHERE toUnixTimestamp(ts) >= {epoch} ORDER BY ts"
+    )
+    while time.time() < deadline:
+        await asyncio.sleep(2.0)
+        rows = await asyncio.to_thread(ch_query, sql)
+        if len(rows) >= n:
+            break
+    if len(rows) < 2:
+        raise HTTPException(status_code=409, detail="Mõõtmisi ei tulnud — kas sensor on sees (varvus/switch/sensor)?")
+    rows = rows[-n:]
+    mean: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        mean[f] = round(sum(vals) / len(vals), 2) if vals else 0.0
+    clears = [float(r["clear"]) for r in rows if r.get("clear") is not None]
+    cv = 0.0
+    if len(clears) > 1 and sum(clears) > 0:
+        m = sum(clears) / len(clears)
+        var = sum((c - m) ** 2 for c in clears) / (len(clears) - 1)
+        cv = (var ** 0.5) / m if m else 0.0
+    peak = max((float(mean.get(b) or 0.0) for b in VARV_BANDS), default=0.0)
+    return {
+        "n": len(rows),
+        "channels": mean,
+        "clear": mean.get("clear"),
+        "cv": round(cv, 4),
+        "stable": cv <= 0.08,
+        "saturated": peak >= 64000 or float(mean.get("clear") or 0) >= 64000,
+        "ts": rows[-1].get("ts"),
+    }
+
+
+@app.get("/kalibreer")
+async def kalibreer_page() -> FileResponse:
+    return FileResponse(STATIC_DIR / "kalibreeri.html", media_type="text/html")
+
+
+@app.get("/health")
+async def health() -> dict:
+    try:
+        ch_query("SELECT 1")
+        ch_ok = True
+    except HTTPException:
+        ch_ok = False
+    return {"status": "ok", "clickhouse": ch_ok}
+
+
+@app.get("/")
+async def index() -> FileResponse:
+    return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
+
+
+app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("app:app", host="0.0.0.0", port=8095, reload=True)

+ 123 - 74
dev/nutikodu-web/color.py

@@ -1,26 +1,35 @@
-"""AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB).
-
-Kasutab Wyman, Sloan & Shirley (2013) analüütilist CIE 1931 2° CMF
-lähendust 8 kanali kesk lainepikkustel.
-
-NB! See EI ole kalibreeritud kolorimeetria — absoluutse värvuse jaoks on
-vaja valge referentsi ja fikseeritud valgustust (vt LOEMIND.md).
+"""AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB), v2.
+
+Erinevused v1-st:
+* tumekorrutis (dark) ja valge referents (white) on sisseehitatud;
+* kanalid integreeritakse CMF-iga koos kanali ribalaiusega (FWHM), mitte
+  delta-funktsioonina;
+* valge normaliseeritakse D65-le, värviesitus säilitab heleduse ja hallid;
+* tulemus sisaldab kehtivuslippu (pime / küllastunud / puuduv kanal).
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
 import math
 import math
 
 
-# ESPHome as7341 kanalite F1..F8 kesk lainepikkused (nm)
-CHANNEL_WAVELENGTHS: dict[str, int] = {
-    "f1": 415,
-    "f2": 445,
-    "f3": 480,
-    "f4": 515,
-    "f5": 555,
-    "f6": 590,
-    "f7": 630,
-    "f8": 680,
-}
+# AS7341 kanalid: (nimi, kesk-lainepikkus nm, FWHM nm)
+CHANNELS = (
+    ("f1", 415.0, 26.0),
+    ("f2", 445.0, 30.0),
+    ("f3", 480.0, 36.0),
+    ("f4", 515.0, 39.0),
+    ("f5", 555.0, 39.0),
+    ("f6", 590.0, 40.0),
+    ("f7", 630.0, 52.0),
+    ("f8", 680.0, 122.0),
+)
+CHANNEL_WAVELENGTHS: dict[str, int] = {n: int(w) for n, w, _ in CHANNELS}
+FWHM: dict[str, float] = {n: f for n, _, f in CHANNELS}
+
+# CIE D65 suhteline spektraaljaotus kanalite keskkohtadel (560 nm = 100)
+D65_SPD = {415: 91.0, 445: 97.2, 480: 99.0, 515: 99.9, 555: 99.8, 590: 96.0, 630: 88.0, 680: 78.2}
+
+D65_WHITE = (0.95047, 1.0, 1.08883)
+SATURATION = 65000.0
 
 
 
 
 def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
 def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
@@ -46,77 +55,115 @@ def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]:
     return x_bar, y_bar, z_bar
     return x_bar, y_bar, z_bar
 
 
 
 
-def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
-    """Ligikaudne XYZ: iga kanali väärtus kaalutakse CMF-iga kesk lainepikkusel."""
-    x = y = z = 0.0
-    for name, wavelength in CHANNEL_WAVELENGTHS.items():
-        value = channels.get(name)
-        if value is None:
-            continue
-        cx, cy, cz = cie_xyz_cmf(wavelength)
-        x += value * cx
-        y += value * cy
-        z += value * cz
-    return x, y, z
+def _weights() -> dict[str, tuple[float, float, float]]:
+    out: dict[str, tuple[float, float, float]] = {}
+    for name, wl, fwhm in CHANNELS:
+        cx, cy, cz = cie_xyz_cmf(wl)
+        k = (D65_SPD[wl] / 100.0) * (fwhm / 100.0)
+        out[name] = (cx * k, cy * k, cz * k)
+    return out
 
 
 
 
-def xyz_to_srgb(x: float, y: float, z: float) -> tuple[int, int, int]:
-    """XYZ -> lineaarne sRGB (D65) -> gamma -> 8-bit. Heledus normaliseeritakse."""
-    r_lin = 3.2406 * x - 1.5372 * y - 0.4986 * z
-    g_lin = -0.9689 * x + 1.8758 * y + 0.0415 * z
-    b_lin = 0.0557 * x - 0.2040 * y + 1.0570 * z
+_W = _weights()
+# Valge (kõik kanalid = 1) annab D65 valge: normaliseerime Y = 1.
+_W_Y = sum(v[1] for v in _W.values())
 
 
-    def gamma(c: float) -> float:
-        c = max(0.0, c)
-        return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
-
-    r, g, b = gamma(r_lin), gamma(g_lin), gamma(b_lin)
-    scale = max(r, g, b, 1e-9)
-    return (
-        int(round(255 * r / scale)),
-        int(round(255 * g / scale)),
-        int(round(255 * b / scale)),
-    )
 
 
-
-def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
-    total = x + y + z
-    if total <= 0:
-        return 0.0, 0.0
-    return x / total, y / total
+def _white_raw() -> tuple[float, float, float]:
+    x = sum(v[0] for v in _W.values()) / _W_Y
+    y = 1.0
+    z = sum(v[2] for v in _W.values()) / _W_Y
+    return x, y, z
 
 
 
 
-# D65 valgepunkt (CIE XYZ)
-D65_WHITE = (0.95047, 1.0, 1.08883)
+_WHITE_RAW = _white_raw()
+_WB_X = D65_WHITE[0] / _WHITE_RAW[0] if _WHITE_RAW[0] else 1.0
+_WB_Z = D65_WHITE[2] / _WHITE_RAW[2] if _WHITE_RAW[2] else 1.0
 
 
 
 
-def _white_reference_xyz() -> tuple[float, float, float]:
-    """XYZ, kui kõik kanalid = 1 (st ühtlane/valge spekter)."""
+def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
     x = y = z = 0.0
     x = y = z = 0.0
-    for wavelength in CHANNEL_WAVELENGTHS.values():
-        cx, cy, cz = cie_xyz_cmf(wavelength)
-        x += cx
-        y += cy
-        z += cz
-    return x, y, z
-
+    for name, (wx, wy, wz) in _W.items():
+        value = channels.get(name)
+        if value is None:
+            continue
+        x += value * wx
+        y += value * wy
+        z += value * wz
+    # Kvadratuuri nihe: valge (kõik 1) peab olema täpselt D65.
+    return (x / _W_Y) * _WB_X, y / _W_Y, (z / _W_Y) * _WB_Z
 
 
-_WHITE_XYZ = _white_reference_xyz()
 
 
+def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
+    total = x + y + z
+    if total <= 1e-12:
+        return 0.0, 0.0
+    return x / total, y / total
 
 
-def analyze(channels: dict[str, float], white_balance: bool = True) -> dict:
-    """Tagastab tuletatud värviväljad ClickHouse'i rea jaoks.
 
 
-    white_balance=True korral skaleeritakse XYZ nii, et ühtlased kanalid
-    (valge) vastavad D65 valgele — muidu annab mudel kerge roosa nihke.
+def _xyz_to_srgb8(x: float, y: float, z: float) -> tuple[int, int, int]:
+    """XYZ -> sRGB 8-bit. Y on absoluutne heledus (0..1 = täisskaala)."""
+    if y <= 1e-9:
+        return 0, 0, 0
+    r = 3.2406 * x - 1.5372 * y - 0.4986 * z
+    g = -0.9689 * x + 1.8758 * y + 0.0415 * z
+    b = 0.0557 * x - 0.2040 * y + 1.0570 * z
+    # Gamut: negatiivsed komponendid -> lisa kõigile (desatureeri valge suunas)
+    m = min(r, g, b)
+    if m < 0:
+        r, g, b = r - m, g - m, b - m
+    # Kui üle 1, skaleeri (heledus väheneb, toon jääb)
+    mx = max(r, g, b)
+    if mx > 1.0:
+        r, g, b = r / mx, g / mx, b / mx
+
+    def enc(c: float) -> int:
+        c = max(0.0, min(1.0, c))
+        v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+        return int(round(255 * max(0.0, min(1.0, v))))
+    return enc(r), enc(g), enc(b)
+
+
+def quality(channels: dict[str, float], dark: dict | None = None) -> tuple[bool, str]:
+    """Kas mõõtmine on kasutatav (pime / küllastunud / puuduv kanal)."""
+    for name, _, _ in CHANNELS:
+        if channels.get(name) is None:
+            return False, "puuduv kanal"
+    d = dark or {}
+    clear = channels.get("clear")
+    dclear = float(d.get("clear") or 0.0)
+    if clear is not None and float(clear) < max(dclear * 3.0, dclear + 20.0):
+        return False, "liiga tume"
+    if clear is not None and float(clear) >= SATURATION:
+        return False, "küllastunud"
+    for name, _, _ in CHANNELS:
+        if float(channels.get(name) or 0.0) >= SATURATION:
+            return False, "küllastunud"
+    return True, ""
+
+
+def analyze(channels: dict[str, float], dark: dict | None = None, white: dict | None = None,
+            target_y: float = 1.0) -> dict:
+    """Tagastab tuletatud värviväljad.
+
+    dark  = tumereferents (lahutatakse)
+    white = valgereferents; kui antud, arvutatakse reflekteeruvus (S-D)/(W-D)
     """
     """
-    x, y, z = channels_to_xyz(channels)
-    if white_balance and _WHITE_XYZ[0] > 0 and _WHITE_XYZ[1] > 0 and _WHITE_XYZ[2] > 0:
-        x *= D65_WHITE[0] / _WHITE_XYZ[0]
-        y *= D65_WHITE[1] / _WHITE_XYZ[1]
-        z *= D65_WHITE[2] / _WHITE_XYZ[2]
+    valid, reason = quality(channels, dark)
+    vec: dict[str, float] = {}
+    for name, _, _ in CHANNELS:
+        v = float(channels.get(name) or 0.0)
+        dv = float((dark or {}).get(name) or 0.0)
+        v -= dv
+        if white is not None:
+            wv = float(white.get(name) or 0.0) - dv
+            v = (v / wv) if wv > 1e-6 else 0.0
+        else:
+            v = v / 65535.0
+        vec[name] = max(0.0, min(v, 1.5))
+    x, y, z = channels_to_xyz(vec)
     cx, cy = chromaticity_xy(x, y, z)
     cx, cy = chromaticity_xy(x, y, z)
-    r, g, b = xyz_to_srgb(x, y, z)
+    r, g, b = _xyz_to_srgb8(x, y, z)
     return {
     return {
         "x_chrom": round(cx, 5),
         "x_chrom": round(cx, 5),
         "y_chrom": round(cy, 5),
         "y_chrom": round(cy, 5),
@@ -124,4 +171,6 @@ def analyze(channels: dict[str, float], white_balance: bool = True) -> dict:
         "g": g,
         "g": g,
         "b": b,
         "b": b,
         "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
         "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
+        "valid": valid,
+        "reason": reason,
     }
     }

+ 220 - 0
dev/nutikodu-web/static/kalibreeri.html

@@ -0,0 +1,220 @@
+<!doctype html>
+<html lang="et">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Värvikalibratsioon</title>
+<style>
+  :root{--bg:#12151a;--card:#1b2028;--fg:#e8ecf1;--muted:#93a0b4;--acc:#4da3ff;--ok:#3ddc97;--warn:#ffb454;--err:#ff6b6b}
+  *{box-sizing:border-box}
+  body{margin:0;font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg)}
+  header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid #2a3140}
+  header a{color:var(--acc);text-decoration:none}
+  main{max-width:720px;margin:0 auto;padding:18px}
+  .card{background:var(--card);border:1px solid #2a3140;border-radius:12px;padding:16px;margin:12px 0}
+  h1{font-size:20px;margin:0}
+  h2{font-size:16px;margin:0 0 8px}
+  p{margin:6px 0}
+  .muted{color:var(--muted)}
+  .row{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
+  button{font:inherit;padding:10px 16px;border-radius:9px;border:1px solid #33405a;background:#243044;color:var(--fg);cursor:pointer}
+  button.primary{background:var(--acc);border-color:var(--acc);color:#08111f;font-weight:600}
+  button:disabled{opacity:.45;cursor:not-allowed}
+  .badge{display:inline-block;padding:2px 8px;border-radius:99px;background:#243044;color:var(--muted);font-size:12px}
+  .badge.ok{background:#12362a;color:var(--ok)}
+  .badge.err{background:#3a1717;color:var(--err)}
+  .bar{height:8px;background:#243044;border-radius:99px;overflow:hidden;margin-top:10px}
+  .bar>i{display:block;height:100%;width:0;background:var(--acc);transition:width .25s}
+  #patch{position:fixed;inset:0;display:none;z-index:10}
+  #patch .bar2{position:absolute;left:0;right:0;bottom:0;display:flex;gap:10px;align-items:center;justify-content:center;padding:14px;background:rgba(0,0,0,.55);backdrop-filter:blur(4px)}
+  #patch .bar2 button{background:rgba(255,255,255,.92);color:#111;border:0;font-weight:600}
+  table{width:100%;border-collapse:collapse;font-size:13px}
+  td,th{padding:4px 6px;border-bottom:1px solid #2a3140;text-align:left}
+  code{background:#0e1218;padding:1px 5px;border-radius:5px}
+</style>
+</head>
+<body>
+<header><h1>🎨 Värvikalibratsioon</h1><a href="/">← Nutikodu</a></header>
+<main>
+  <div id="msg" class="card muted">Laen…</div>
+
+  <section id="intro" class="card" hidden>
+    <h2>Kuidas käib</h2>
+    <ol>
+      <li><b>Tume:</b> kata andur täiesti (must riie/karp; LED on plaadilt eemaldatud) → “Mõõda tume”. Nullväärtused on OK.</li>
+      <li><b>Valge:</b> näita ekraanil täisvalget (see leht teeb ise) samal kaugusel ja nurga all, kus hakkadki mõõtma → “Mõõda valge”.</li>
+      <li><b>24 plaastrit:</b> iga plaaster kuvatakse täisekraanil; vajuta “Mõõda” ja oota ~10–15 s.</li>
+      <li><b>Sobita:</b> arvutan 3×8 maatriksi ja ΔE00 vead.</li>
+    </ol>
+    <p class="muted">Tähtis: püsi kogu seeria jooksul samas valgustuses, kauguses ja nurgas. Ekraani heledus maksimumi, auto-heledus ja sinise valguse filter välja.</p>
+    <div class="row"><button class="primary" id="start">Alusta</button></div>
+  </section>
+
+  <section id="stage" class="card" hidden>
+    <h2 id="stage-title">—</h2>
+    <p id="stage-desc" class="muted"></p>
+    <div id="stage-status"></div>
+    <div class="bar"><i id="prog"></i></div>
+    <div class="row" id="stage-actions"></div>
+  </section>
+
+  <section id="result" class="card" hidden>
+    <h2>Tulemus</h2>
+    <div id="result-body"></div>
+  </section>
+</main>
+
+<div id="patch"><div class="bar2"><button id="measure">Mõõda see plaat</button><span id="patch-label" style="color:#fff;font:600 14px system-ui"></span></div></div>
+
+<script>
+const PATCHES = [
+ ["tumedanahk","735244"],["heledanahk","C29682"],["taevasinine","627A9D"],
+ ["lehestik","576C43"],["sinine lill","8580B1"],["rohekassinine","67BDAA"],
+ ["oranž","D67E2C"],["sinakaslilla","505BA6"],["mõõdukas punane","C15A63"],
+ ["lilla","5E3C6C"],["kollakasroheline","9DBC40"],["oranžkollane","E0A32E"],
+ ["sinine","383D96"],["roheline","469449"],["punane","AF363C"],
+ ["kollane","E7C71F"],["magenta","BB5695"],["tsüaan","0885A1"],
+ ["valge","F3F3F2"],["hall 8","C8C8C8"],["hall 6.5","A0A0A0"],
+ ["hall 5","7A7A79"],["hall 3.5","555555"],["must","343434"]
+];
+const $ = (id) => document.getElementById(id);
+let st = {dark:null, white:null, idx:0, samples:3};
+
+async function api(path, opts={}) {
+  const r = await fetch(path, {credentials:"same-origin", headers:{"Content-Type":"application/json"}, ...opts});
+  let data = null;
+  try { data = await r.json(); } catch(e) {}
+  if (!r.ok) throw new Error((data && (data.detail||data.error)) || ("HTTP "+r.status));
+  return data;
+}
+
+function say(text, kind) {
+  const m = $("msg");
+  m.hidden = false;
+  m.className = "card " + (kind || "muted");
+  m.textContent = text;
+}
+
+async function capture() {
+  return await api("/api/varv/capture", {method:"POST", body: JSON.stringify({n: st.samples, timeout: 45})});
+}
+
+function progress(p) { $("prog").style.width = Math.round(p*100) + "%"; }
+
+async function init() {
+  try {
+    const me = await api("/api/me");
+    if (!me.authed) { say("Vaja sisse logida. Mine /login ja tule tagasi.", "err"); return; }
+  } catch(e) { say("Server ei vasta: " + e.message, "err"); return; }
+  $("msg").hidden = true;
+  $("intro").hidden = false;
+  $("start").onclick = beginDark;
+}
+
+async function beginDark() {
+  $("intro").hidden = true;
+  $("stage").hidden = false;
+  $("stage-title").textContent = "1/3 · Tume referents";
+  $("stage-desc").textContent = "Kata andur täiesti (must riie või karp). Plaadi roheline toite-LED on eemaldatud — täielik pimedus ja nullväärtused on ootuspärased.";
+  $("stage-status").textContent = "";
+  $("stage-actions").innerHTML = '<button class="primary" id="go">Mõõda tume</button>';
+  $("go").onclick = async () => {
+    $("go").disabled = true;
+    try {
+      const r = await capture();
+      $("stage-status").innerHTML = '<span class="badge ' + (r.stable?"ok":"err") + '">clear ' + r.clear + ' · CV ' + (r.cv*100).toFixed(1) + '%</span>';
+      const res = await api("/api/varv/calib", {method:"POST", body: JSON.stringify({kind:"dark", last:r.n})});
+      st.dark = res.reference;
+      say("Tume salvestatud.", "ok");
+      beginWhite();
+    } catch(e) { $("go").disabled = false; say("Viga: " + e.message, "err"); }
+  };
+}
+
+async function beginWhite() {
+  $("stage-title").textContent = "2/3 · Valge referents";
+  $("stage-desc").textContent = "Näita ekraanil valget pinda (nt ava see leht täisekraanil ja suuna andur sellele) samas valgustuses ja geomeetrias.";
+  $("stage-status").textContent = "";
+  $("stage-actions").innerHTML = '<button class="primary" id="go">Mõõda valge</button>';
+  $("go").onclick = async () => {
+    $("go").disabled = true;
+    try {
+      const r = await capture();
+      $("stage-status").innerHTML = '<span class="badge ' + (r.stable?"ok":"err") + '">clear ' + r.clear + ' · CV ' + (r.cv*100).toFixed(1) + '%</span>';
+      const res = await api("/api/varv/calib", {method:"POST", body: JSON.stringify({kind:"white", last:r.n})});
+      st.white = res.reference;
+      say("Valge salvestatud.", "ok");
+      beginPatches();
+    } catch(e) { $("go").disabled = false; say("Viga: " + e.message, "err"); }
+  };
+}
+
+function beginPatches() {
+  st.idx = 0;
+  $("stage-title").textContent = "3/3 · Värvikaart";
+  showPatch();
+}
+
+function showPatch() {
+  const [name, hex] = PATCHES[st.idx];
+  progress(st.idx / PATCHES.length);
+  $("stage").hidden = true;
+  $("patch").style.display = "block";
+  $("patch").style.background = "#" + hex;
+  const r = parseInt(hex.slice(0,2),16), g = parseInt(hex.slice(2,4),16), b = parseInt(hex.slice(4,6),16);
+  const lum = (0.299*r + 0.587*g + 0.114*b);
+  $("patch-label").textContent = (st.idx+1) + "/24 · " + name;
+  $("patch-label").style.color = lum > 140 ? "#111" : "#fff";
+  $("measure").onclick = measurePatch;
+}
+
+async function measurePatch() {
+  $("measure").disabled = true;
+  $("patch-label").textContent = "Mõõdan… (~10–15 s)";
+  try {
+    const r = await capture();
+    if (r.saturated) throw new Error("Küllastunud — vähenda ekraani heledust või suurenda kaugust.");
+    if (!r.stable) throw new Error("Mõõtmine pole stabiilne (CV " + (r.cv*100).toFixed(1) + "%) — oota ja proovi uuesti.");
+    const [name, hex] = PATCHES[st.idx];
+    await api("/api/varv/ref", {method:"POST", body: JSON.stringify({name:"wiz"+String(st.idx+1).padStart(2,"0"), hex, last:r.n})});
+    st.idx++;
+    if (st.idx >= PATCHES.length) { finish(); return; }
+    showPatch();
+  } catch(e) {
+    $("patch-label").textContent = "Viga: " + e.message;
+  } finally {
+    $("measure").disabled = false;
+  }
+}
+
+async function finish() {
+  $("patch").style.display = "none";
+  $("stage").hidden = false;
+  $("stage-title").textContent = "Sobitan maatriksit…";
+  $("stage-desc").textContent = "24 referentsi on salvestatud. Arvutan 3×8 maatriksi ja ΔE00.";
+  $("stage-actions").innerHTML = "";
+  progress(1);
+  try {
+    const r = await api("/api/varv/ccm/fit?prefix=wiz", {method:"POST"});
+    $("result").hidden = false;
+    const s = r.de00_loo, i = r.de00_in_sample;
+    $("result-body").innerHTML =
+      '<p><b>Referentse:</b> ' + r.n_refs + '<br>' +
+      '<b>ΔE00 (in-sample):</b> keskmine ' + i.mean + ', mediaan ' + i.median + ', max ' + i.max + '<br>' +
+      '<b>ΔE00 (LOO):</b> keskmine ' + s.mean + ', mediaan ' + s.median + ', max ' + s.max + '</p>' +
+      '<p class="muted">LOO (leave-one-out) on aus hinnang uutele värvidele. Alla ~3 on hea, 3–6 rahuldav, üle 6 nõrk.</p>' +
+      '<table><tr><th>Plaaster</th><th>Siht</th><th>Tulemus</th><th>ΔE00</th></tr>' +
+      r.preview.map(p => '<tr><td>'+p.name+'</td><td><code>'+p.target+'</code></td><td><code>'+p.pred+'</code></td><td>'+p.de00+'</td></tr>').join('') +
+      '</table>';
+    $("stage-title").textContent = "Valmis";
+    $("stage-desc").textContent = "Maatriks on salvestatud. Mine Värv-vahelehele ja lülita sisse kalibreeritud vaade.";
+  } catch(e) {
+    $("stage-title").textContent = "Sobitamine ebaõnnestus";
+    $("stage-desc").textContent = e.message;
+  }
+}
+
+init();
+</script>
+</body>
+</html>

Некоторые файлы не были показаны из-за большого количества измененных файлов