Przeglądaj źródła

dev: nutikodu veebiliides (uus67 ajatelg, FastAPI+ECharts, avalik nutikodu.odamus.com)

- nutikodu-web: FastAPI + ECharts ajatelg (temperatuur/niiskus), perioodivalik, mobiilne, tume/hele teema
- API: /api/meta, /api/temperatuur, /api/ukseandur, /api/vead, /api/latest
- compose: nutikodu-web võrkudes main_ai_network + prod_net, port 100.87.1.24:8095
- avalik: DNS + edge Caddy + prod_caddy vhost (https://nutikodu.odamus.com)
- LOEMIND: nutikodu-web sektsioon ja DNS-vahemälu märkus
ardo 1 miesiąc temu
rodzic
commit
1a50b8e9f2

+ 45 - 0
dev/LOEMIND.md

@@ -248,3 +248,48 @@ docker exec -it clickhouse clickhouse-client --user default --password dev_click
 - Oracle → ClickHouse: ClickHouse on veerupõhine OLAP; sobib analüüsi/aruandluse koormuste jaoks.
 - Oracle → ClickHouse: ClickHouse on veerupõhine OLAP; sobib analüüsi/aruandluse koormuste jaoks.
   Postgres-migratsioon on eeskujuks: `postgresql()` tabelifunktsioon lihtsustab oluliselt (Oracle'i jaoks
   Postgres-migratsioon on eeskujuks: `postgresql()` tabelifunktsioon lihtsustab oluliselt (Oracle'i jaoks
   tuleb vastav funktsioon/ODBC-vood eraldi uurida).
   tuleb vastav funktsioon/ODBC-vood eraldi uurida).
+
+## Nutikodu veebiliides (`nutikodu-web`)
+
+Interaktiivne ajatelg `uus67` skeemi zigbee andurite andmetele. FastAPI backend +
+üks staatiline leht (ECharts), eestikeelne, mobiilisõbralik, tume/hele teema.
+
+| Omadus | Väärtus |
+|--------|---------|
+| Kood | `./nutikodu-web/` (`app.py`, `Dockerfile`, `static/`, `README.md`) |
+| Konteiner | `nutikodu-web` (püsiv, `restart: unless-stopped`, `mem_limit: 192m`) |
+| Võrgud | `main_ai_network` (ClickHouse) + `prod_net` (prod_caddy) |
+| Tailscale | `http://100.87.1.24:8095` · `http://ohmu.tail20e23.ts.net:8095` |
+| Avalik | **https://nutikodu.odamus.com** |
+| ClickHouse | `http://clickhouse:8123`, kasutaja `zigbee` (SELECT `uus67.*`) |
+
+**Avaliku ligipääsu ahel (2026-09-17):**
+1. **DNS:** DigitalOcean A-kirje `nutikodu.odamus.com` → `188.166.84.16`.
+2. **Edge Caddy** (`kanad`/edge, `/opt/edge/Caddyfile`):
+   `nutikodu.odamus.com { reverse_proxy http://100.91.118.16:80 }`. TLS automaatne (Let's Encrypt).
+3. **Sisemine Caddy** (ohmu `prod_caddy`, `/opt/prod/Caddyfile`):
+   `nutikodu.odamus.com:80 { reverse_proxy nutikodu-web:8095 }`.
+   (`100.91.118.16` = `ts-prod` tailnet IP; `prod_caddy` töötab `network_mode: container:ts-prod`.)
+
+**NB! DNS-vahemälu:** uue domeeni lisamisel tekib kohati negatiivne NXDOMAIN-vahemälu.
+Kui `nutikodu.odamus.com` ei lahendu, tühjenda:
+```bash
+# ohmu
+sudo resolvectl flush-caches
+# pihole (valvur, 192.168.2.50)
+docker restart pihole
+```
+
+**API:** `/api/meta`, `/api/temperatuur` (hour/day), `/api/ukseandur`, `/api/vead`,
+`/api/latest`, `/health`. Vaata täpsemalt `nutikodu-web/README.md`.
+
+**Käsud:**
+```bash
+docker logs -f nutikodu-web
+curl -s https://nutikodu.odamus.com/health
+docker compose -f /opt/docker-compose/dev/docker-compose.yml up -d --build nutikodu-web
+```
+
+**Edasi:** ukseanduri olekuribad ja häirete markerid ajateljel (`/api/ukseandur`,
+`/api/vead` on valmis); võimalik `WEB_ACCESS_TOKEN` avaliku lehe kaitseks.
+

+ 26 - 0
dev/docker-compose.yml

@@ -50,6 +50,29 @@ services:
     networks:
     networks:
       - main_ai_network
       - main_ai_network
 
 
+  # Nutikodu veebiliides: uus67 zigbee andmete interaktiivne ajatelg (FastAPI + ECharts).
+  # Ligipääs ainult Tailscale'ist: http://100.87.1.24:8095
+  nutikodu-web:
+    build: ./nutikodu-web
+    container_name: nutikodu-web
+    restart: unless-stopped
+    mem_limit: 192m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_USER=${CH_USER:-zigbee}
+      - WEB_ACCESS_TOKEN=${WEB_ACCESS_TOKEN:-}
+    ports:
+      - "100.87.1.24:8095:8095"
+    depends_on:
+      - clickhouse
+    networks:
+      - main_ai_network
+      - prod_net
+
   # ClickStack (HyperDX all-in-one) — observability UI (logs/traces/metrics)
   # ClickStack (HyperDX all-in-one) — observability UI (logs/traces/metrics)
   # NB! Port 8124 on ClickHouse'i HTTP; ClickStack UI jookseb pordil 18080.
   # NB! Port 8124 on ClickHouse'i HTTP; ClickStack UI jookseb pordil 18080.
   clickstack:
   clickstack:
@@ -99,3 +122,6 @@ networks:
   main_ai_network:
   main_ai_network:
     external: true
     external: true
     name: main_ai_network
     name: main_ai_network
+  prod_net:
+    external: true
+    name: prod_net

+ 13 - 0
dev/nutikodu-web/Dockerfile

@@ -0,0 +1,13 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app.py .
+COPY static ./static
+
+EXPOSE 8095
+
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8095"]

+ 84 - 0
dev/nutikodu-web/README.md

@@ -0,0 +1,84 @@
+# 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.
+- Viimaste väärtuste kaardid + lahtiste häirete ülevaade (60 s värskendus).
+- 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/ukseandur?from=&to=&device=` | ukseolekute sündmused (UI tuleb hiljem) |
+| `GET /api/vead?from=&to=&device=` | häirete sündmused |
+| `GET /api/latest` | viimased väärtused + lahtised häired |
+| `GET /health` | tervis |
+
+Ajavahemikud kujul `YYYY-MM-DD[ HH:MM[:SS]]` või ISO (T-eraldaja). Vaikimisi viimased 7 päeva.
+
+## Keskkonnamuutujad
+
+| Muutuja | Vaikimisi | Kirjeldus |
+|---------|-----------|-----------|
+| `CH_URL` | `http://clickhouse:8123` | ClickHouse HTTP |
+| `CH_DB` | `uus67` | andmebaas |
+| `CH_USER` / `CH_PASS` | `zigbee` / – | ainult SELECT `uus67.*` |
+| `WEB_ACCESS_TOKEN` | tühi | kui seatud, nõuab `?token=` või `X-Access-Token` päist |
+
+## 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).
+- Võimalik `WEB_ACCESS_TOKEN` avaliku lehe kaitseks.

+ 289 - 0
dev/nutikodu-web/app.py

@@ -0,0 +1,289 @@
+"""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")
+
+
+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"
+        )
+        group = "GROUP BY t, device"
+    else:
+        select = "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, temperature, humidity"
+        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": []})
+        s["t"].append(int(row["t"]))
+        s["temperature"].append(row["temperature"])
+        s["humidity"].append(row["humidity"])
+    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'"
+    )
+    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
+        ],
+    }
+
+
+@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)

+ 3 - 0
dev/nutikodu-web/requirements.txt

@@ -0,0 +1,3 @@
+fastapi
+uvicorn
+requests

+ 300 - 0
dev/nutikodu-web/static/app.js

@@ -0,0 +1,300 @@
+"use strict";
+
+const state = {
+  from: null,
+  to: null,
+  agg: "hour",
+  device: "",
+  showTemp: true,
+  showHum: true,
+  meta: null,
+};
+
+let chart = null;
+
+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();
+}
+
+function buildOption(data) {
+  const text = cssVar("--text");
+  const muted = cssVar("--muted");
+  const border = cssVar("--border");
+  const tempColor = cssVar("--temp");
+  const humColor = cssVar("--hum");
+  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 (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]]),
+      });
+    }
+  }
+
+  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) => {
+        if (!params || !params.length) return "";
+        const d = new Date(params[0].value[0]);
+        const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+        const lines = params.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,
+  };
+}
+
+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 res = await fetch(`/api/temperatuur?${params}`);
+    if (!res.ok) throw new Error(await res.text());
+    const data = await res.json();
+    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.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({ series: currentSeries }), 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(); });
+  $("#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/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>

+ 68 - 0
dev/nutikodu-web/static/index.html

@@ -0,0 +1,68 @@
+<!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>
+          <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>
+</body>
+</html>

+ 12 - 0
dev/nutikodu-web/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" }
+  ]
+}

+ 119 - 0
dev/nutikodu-web/static/style.css

@@ -0,0 +1,119 @@
+:root {
+  --bg: #f4f6fb;
+  --panel: #ffffff;
+  --text: #1c2430;
+  --muted: #64708a;
+  --border: #dde3ee;
+  --accent: #1f6feb;
+  --accent-soft: #e7f0ff;
+  --temp: #e8590c;
+  --hum: #1f9ed4;
+  --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;
+  --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;
+}
+.icon-btn:active { transform: scale(.96); }
+
+main { padding: 14px; max-width: 1100px; margin: 0 auto; display: grid; gap: 14px; }
+main > * { min-width: 0; }
+
+.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.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; }
+}

Plik diff jest za duży
+ 34 - 0
dev/nutikodu-web/static/vendor/echarts.min.js


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików