Преглед на файлове

varundus 2026-10-08 07:00

ardo преди 2 седмици
родител
ревизия
66a875dc4d

+ 25 - 0
dev/.env.bak.1791390800

@@ -0,0 +1,25 @@
+# Zigbee andurite koguja + ClickHouse (dev)
+# NB! See fail on .gitignore'itud (**.env) — ära commiti.
+
+MQTT_HOST=100.80.254.54
+MQTT_PORT=1883
+MQTT_TOPIC=zigbee2mqtt/Temperatuur
+DOOR_TOPICS=zigbee2mqtt/Välisuks,zigbee2mqtt/Rõdu,zigbee2mqtt/Kontori uks,zigbee2mqtt/Kontori aken
+MQTT_USER=mqtt_user
+MQTT_PASS=PNpQa4drFYJqC6wJ
+
+CH_URL=http://clickhouse:8123
+CH_DB=uus67
+CH_TABLE=temperatuur
+CH_DOOR_TABLE=ukseandur
+CH_FAULT_TABLE=vead
+CH_USER=zigbee
+CH_PASS=bpY2PlY3j6NtqvXDhekW
+
+# ClickStack OTLP ingestiooni võti (HyperDX team apiKey)
+HYPERDX_INGEST_KEY=02a19ecf-f62e-4e17-819f-3ee1498d69f3
+
+# Telegram (alert-relay)
+TELEGRAM_BOT_TOKEN=8978401092:AAEZtWTiSRBThGjc4c-bh7Qr2pEURrlzC1Q
+MOTION_TOPICS=zigbee2mqtt/Mikrolaine,zigbee2mqtt/Liikumine
+ENERGY_TOPICS=zigbee2mqtt/Pistik tuba,zigbee2mqtt/Pistik kontor

+ 4 - 0
dev/docker-compose.yml

@@ -47,6 +47,10 @@ services:
       - MQTT_PORT=${MQTT_PORT:-1883}
       - MQTT_TOPIC=${MQTT_TOPIC:-zigbee2mqtt/Temperatuur}
       - DOOR_TOPICS=${DOOR_TOPICS:-}
+      - MOTION_TOPICS=${MOTION_TOPICS:-}
+      - ENERGY_TOPICS=${ENERGY_TOPICS:-}
+      - CH_MOTION_TABLE=${CH_MOTION_TABLE:-liikumine}
+      - CH_ENERGY_TABLE=${CH_ENERGY_TABLE:-energia}
       - CH_URL=${CH_URL:-http://clickhouse:8123}
       - CH_DB=${CH_DB:-uus67}
       - CH_TABLE=${CH_TABLE:-temperatuur}

+ 307 - 0
dev/docker-compose.yml.bak.1791388966

@@ -0,0 +1,307 @@
+services:
+  clickhouse:
+    image: clickhouse/clickhouse-server:26.8.1.2041
+    container_name: clickhouse
+    restart: unless-stopped
+    mem_limit: 2g
+    environment:
+      - TZ=Europe/Tallinn
+      - CLICKHOUSE_USER=default
+      - CLICKHOUSE_PASSWORD=dev_clickhouse_2026
+      - CLICKHOUSE_DB=default
+      - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
+    ports:
+      - "100.87.1.24:8125:8123"   # ClickHouse HTTP (ainult Tailscale)
+      - "100.87.1.24:9004:9000"   # native TCP (ainult Tailscale)
+    volumes:
+      - ./data/clickhouse:/var/lib/clickhouse
+      - ./config:/etc/clickhouse-server/config.d
+      - /etc/localtime:/etc/localtime:ro
+      - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
+    ulimits:
+      nofile:
+        soft: 262144
+        hard: 262144
+    # Healthcheck native TCP 9000 vastu (collector kasutab just seda, mitte HTTP 8123).
+    healthcheck:
+      test: ["CMD-SHELL", "clickhouse-client --password \"$$CLICKHOUSE_PASSWORD\" --query 'SELECT 1'"]
+      interval: 10s
+      timeout: 5s
+      retries: 12
+      start_period: 30s
+    networks:
+      - main_ai_network
+
+  # Zigbee andurite koguja: tellib valvuri MQTT-d ja kirjutab tunniaegregaadid ClickHouse'i.
+  # Töötab püsivalt; tunniaegregaat arvutatakse mälus ja flush'itakse iga täistunni järel.
+  zigbee-collector:
+    build: ./zigbee-collector
+    container_name: zigbee-collector
+    restart: unless-stopped
+    mem_limit: 128m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - MQTT_HOST=${MQTT_HOST:-100.80.254.54}
+      - MQTT_PORT=${MQTT_PORT:-1883}
+      - MQTT_TOPIC=${MQTT_TOPIC:-zigbee2mqtt/Temperatuur}
+      - DOOR_TOPICS=${DOOR_TOPICS:-}
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_TABLE=${CH_TABLE:-temperatuur}
+      - CH_DOOR_TABLE=${CH_DOOR_TABLE:-ukseandur}
+      - CH_FAULT_TABLE=${CH_FAULT_TABLE:-vead}
+      - OTEL_SERVICE_NAME=zigbee-collector
+      - OTEL_TRACES_EXPORTER=otlp
+      - OTEL_METRICS_EXPORTER=none
+      - OTEL_LOGS_EXPORTER=none
+      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
+      - OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+      - OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=ohmu
+    depends_on:
+      - clickhouse
+    networks:
+      - main_ai_network
+
+  # Välistemperatuuri koguja (Open-Meteo): backfill + tunnipoll -> uus67.valisilm.
+  weather-collector:
+    build: ./weather-collector
+    container_name: weather-collector
+    restart: unless-stopped
+    mem_limit: 128m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_USER=${CH_USER:-zigbee}
+      - CH_WEATHER_TABLE=${CH_WEATHER_TABLE:-valisilm}
+      - BACKFILL_FROM=${BACKFILL_FROM:-2026-09-17}
+      - OTEL_SERVICE_NAME=weather-collector
+      - OTEL_TRACES_EXPORTER=otlp
+      - OTEL_METRICS_EXPORTER=none
+      - OTEL_LOGS_EXPORTER=none
+      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
+      - OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+      - OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=ohmu
+    depends_on:
+      - clickhouse
+    networks:
+      - 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: 512m
+    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:-}
+      - OTEL_SERVICE_NAME=nutikodu-web
+      - OTEL_TRACES_EXPORTER=otlp
+      - OTEL_METRICS_EXPORTER=none
+      - OTEL_LOGS_EXPORTER=none
+      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
+      - OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+      - OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=ohmu
+    ports:
+      - "100.87.1.24:8095:8095"
+    depends_on:
+      - clickhouse
+    networks:
+      - main_ai_network
+      - prod_net
+
+  # ClickStack (ametlik "Docker Compose" arhitektuur): HyperDX app-only + eraldi Mongo.
+  # (Varem: all-in-one, mis dubleeris ClickHouse'i + Mongo.) NB: hostil port 8123 = Home Assistant,
+  # ClickHouse HTTP = 8125 (vt LOEMIND). UI = 18080.
+  clickstack:
+    image: docker.hyperdx.io/hyperdx/hyperdx:2.37.0
+    container_name: clickstack
+    restart: unless-stopped
+    environment:
+      - TZ=Europe/Tallinn
+      - FRONTEND_URL=http://100.87.1.24:18080
+      - SERVER_URL=http://127.0.0.1:8000
+      - HYPERDX_APP_URL=http://100.87.1.24:18080
+      - HYPERDX_APP_PORT=8080
+      - HYPERDX_API_PORT=8000
+      - HYPERDX_API_KEY=02a19ecf-f62e-4e17-819f-3ee1498d69f3
+      - MONGO_URI=mongodb://clickstack-db:27017/hyperdx
+      - OPAMP_PORT=4320
+      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
+      - OTEL_SERVICE_NAME=hdx-oss-app
+      - NEXT_PUBLIC_IS_LOCAL_MODE=false
+      # Brauseri (session replay) collector-URL peab olema kliendist kättesaadav (mitte localhost).
+      - NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT=http://100.87.1.24:4318
+      - EXPRESS_SESSION_SECRET=dev_session_secret_2026
+      - 'DEFAULT_CONNECTIONS=[{"name":"Local ClickHouse","host":"http://clickhouse:8123","username":"default","password":"dev_clickhouse_2026"}]'
+    ports:
+      - "127.0.0.1:18080:8080"
+      - "100.87.1.24:18080:8080"   # HyperDX UI
+    volumes:
+      - ./patches/4719-70b065addcfc053d.js:/app/packages/app/packages/app/.next/static/chunks/4719-70b065addcfc053d.js:ro
+      - ./patches/pages/clickhouse-46029ebbad38b40a.js:/app/packages/app/packages/app/.next/static/chunks/pages/clickhouse-46029ebbad38b40a.js:ro
+      - ./patches/4751-53b929fca5a575a9.js:/app/packages/app/packages/app/.next/static/chunks/4751-53b929fca5a575a9.js:ro
+      - ./patches/1021-40ca2f669b13138d.js:/app/packages/app/packages/app/.next/static/chunks/1021-40ca2f669b13138d.js:ro
+    # ClickHouse peab olema terve (native 9000), muidu collector'i seed ebaõnnestub.
+    depends_on:
+      clickstack-db:
+        condition: service_started
+      clickhouse:
+        condition: service_healthy
+    networks:
+      - main_ai_network
+
+  # Mongo: HyperDX rakenduse olek (sources/dashboards/alerts/users). Migreeritud all-in-one'ist.
+  clickstack-db:
+    image: mongo:5.0.32-focal
+    container_name: clickstack-db
+    restart: unless-stopped
+    mem_limit: 512m
+    environment:
+      - TZ=Europe/Tallinn
+    volumes:
+      - ./data/mongo:/data/db
+    networks:
+      - main_ai_network
+
+  # Reverse-proxy pordil 8124: /clickstack/ -> 302 -> :18080 (ühtlustatud UI),
+  # backendi teed ('/api/', /__ENV.js) -> HyperDX app, kõik muu -> ClickHouse (8124 jääb DBeaver-ile).
+  clickstack-proxy:
+    image: nginx:alpine
+    container_name: clickstack-proxy
+    restart: unless-stopped
+    ports:
+      - "127.0.0.1:8124:8124"
+      - "100.87.1.24:8124:8124"
+    volumes:
+      - ./config/clickstack-proxy/nginx.conf:/etc/nginx/conf.d/clickstack.conf:ro
+    depends_on:
+      - clickhouse
+      - clickstack
+    networks:
+      - main_ai_network
+
+  # Hosti metrikad (node-exporter) — Prometheus endpoint :9100
+  node-exporter:
+    image: prom/node-exporter:latest
+    container_name: node-exporter
+    restart: unless-stopped
+    mem_limit: 128m
+    command:
+      - "--path.rootfs=/host"
+      - "--path.procfs=/host/proc"
+      - "--path.sysfs=/host/sys"
+    volumes:
+      - /proc:/host/proc:ro
+      - /sys:/host/sys:ro
+      - /:/host:ro,rslave
+    networks:
+      - main_ai_network
+
+  # Konteinerite metrikad (cadvisor) — Prometheus endpoint :8080
+  cadvisor:
+    image: gcr.io/cadvisor/cadvisor:latest
+    container_name: cadvisor
+    restart: unless-stopped
+    mem_limit: 256m
+    privileged: true
+    command:
+      - "--docker_only=true"
+      - "--disable_metrics=advtcp,cpu_topology,cpuset,hugetlb,memory_numa,process,referenced_memory,resctrl,sched,tcp,udp,percpu"
+    devices:
+      - /dev/kmsg
+    volumes:
+      - /:/rootfs:ro
+      - /var/run:/var/run:ro
+      - /sys:/sys:ro
+      - /var/lib/docker/:/var/lib/docker:ro
+      - /dev/disk/:/dev/disk:ro
+    networks:
+      - main_ai_network
+
+  # ClickStack ametlik OTel-collector (OpAMP -> clickstack app). Base config app'ist (skeem/rollupid);
+  # custom.config.yaml lisab hosti-meetrikad (node/cadvisor/llamacpp) + konteinerilogid.
+  otel-collector:
+    image: docker.clickhouse.com/clickhouse/clickstack-otel-collector:2
+    container_name: otel-collector
+    restart: unless-stopped
+    mem_limit: 512m
+    user: "0:0"   # root: vaja lugeda /var/lib/docker/containers/*-json.log (710 root:root)
+    environment:
+      - TZ=Europe/Tallinn
+      - CLICKHOUSE_ENDPOINT=tcp://clickhouse:9000?dial_timeout=10s
+      - CLICKHOUSE_USER=default
+      - CLICKHOUSE_PASSWORD=dev_clickhouse_2026
+      - HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE=default
+      - OPAMP_SERVER_URL=http://clickstack:4320
+      - CUSTOM_OTELCOL_CONFIG_FILE=/etc/otelcol-contrib/custom.config.yaml
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    volumes:
+      - ./config/otel-collector/custom.config.yaml:/etc/otelcol-contrib/custom.config.yaml:ro
+      - /var/lib/docker/containers:/var/lib/docker/containers:ro
+    ports:
+      - "127.0.0.1:4317:4317"
+      - "100.87.1.24:4317:4317"
+      - "127.0.0.1:4318:4318"
+      - "100.87.1.24:4318:4318"
+    depends_on:
+      - clickstack
+      - clickhouse
+      - node-exporter
+      - cadvisor
+    networks:
+      - main_ai_network
+
+  # HyperDX alert-webhook → Telegram relay (generic webhook vastuvõtja)
+  alert-relay:
+    image: python:3.12-slim
+    container_name: alert-relay
+    restart: unless-stopped
+    mem_limit: 64m
+    command: ["python", "/app/alert_relay.py"]
+    environment:
+      - TZ=Europe/Tallinn
+      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
+      - TELEGRAM_CHAT_ID=5036685258
+    volumes:
+      - ./config/alert-relay/alert_relay.py:/app/alert_relay.py:ro
+    networks:
+      - main_ai_network
+
+  # Rate-limiter HyperDX OTLP ingest'ile (otel.odamus.com). Edge Caddy -> see -> otel-collector:4318.
+  # nginx limit_req (Caddy standardbuildis rate_limit plugin't pole).
+  otel-ratelimit:
+    image: nginx:alpine
+    container_name: otel-ratelimit
+    restart: unless-stopped
+    mem_limit: 32m
+    environment:
+      - TZ=Europe/Tallinn
+    volumes:
+      - ./config/otel-ratelimit/nginx.conf:/etc/nginx/nginx.conf:ro
+    ports:
+      - "100.87.1.24:4319:4319"
+    depends_on:
+      - otel-collector
+    networks:
+      - main_ai_network
+
+networks:
+  main_ai_network:
+    external: true
+    name: main_ai_network
+  prod_net:
+    external: true
+    name: prod_net

+ 39 - 5
dev/nutikodu-web/README.md

@@ -31,13 +31,42 @@ tume/hele teema.
 | `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 (UI tuleb hiljem) |
-| `GET /api/vead?from=&to=&device=` | häirete sündmused |
-| `GET /api/latest` | viimased väärtused + lahtised häired |
+| `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/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.
+
+## 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 |
@@ -45,7 +74,12 @@ Ajavahemikud kujul `YYYY-MM-DD[ HH:MM[:SS]]` või ISO (T-eraldaja). Vaikimisi vi
 | `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 |
+| `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) |
 
 ## Struktuur
 
@@ -87,4 +121,4 @@ docker logs -f nutikodu-web
 
 - Ukseanduri olekuribad ajateljel (`/api/ukseandur` on valmis).
 - Häirete markerid/annotatsioonid (`/api/vead` on valmis).
-- Võimalik `WEB_ACCESS_TOKEN` avaliku lehe kaitseks.
+- Autentimine: **tehtud 2026-10-07** (sessioon + Bearer-token; vt "Autentimine").

+ 310 - 16
dev/nutikodu-web/app.py

@@ -17,16 +17,22 @@ Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
 
 from __future__ import annotations
 
+import hmac
+import secrets
 import json
 import os
+import threading
+import time
+from collections import defaultdict
 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 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"
@@ -39,11 +45,26 @@ CH_PASS = os.environ.get("CH_PASS", "")
 CH_DB = os.environ.get("CH_DB", "uus67")
 
 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
 
 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")
@@ -86,12 +107,59 @@ def ch_query(query: str, params: dict[str, str] | None = None) -> list[dict]:
         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")
+_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:
@@ -120,7 +188,7 @@ def resolve_range(from_: str | None, to: str | None) -> tuple[str, str]:
     return from_s, to_s
 
 
-@app.get("/api/meta", dependencies=[Depends(check_token)])
+@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"
@@ -141,7 +209,7 @@ async def meta() -> dict:
     }
 
 
-@app.get("/api/temperatuur", dependencies=[Depends(check_token)])
+@app.get("/api/temperatuur")
 async def temperatuur(
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
@@ -187,7 +255,7 @@ async def temperatuur(
     return {"from": from_s, "to": to_s, "agg": agg, "series": series}
 
 
-@app.get("/api/valisilm", dependencies=[Depends(check_token)])
+@app.get("/api/valisilm")
 async def valisilm(
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
@@ -234,7 +302,7 @@ async def valisilm(
     return {"from": from_s, "to": to_s, "agg": agg, "series": series}
 
 
-@app.get("/api/ukseandur", dependencies=[Depends(check_token)])
+@app.get("/api/ukseandur", dependencies=[Depends(require_auth)])
 async def ukseandur(
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
@@ -264,7 +332,7 @@ async def ukseandur(
     }
 
 
-@app.get("/api/vead", dependencies=[Depends(check_token)])
+@app.get("/api/vead", dependencies=[Depends(require_auth)])
 async def vead(
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
@@ -301,8 +369,132 @@ async def vead(
     }
 
 
-@app.get("/api/latest", dependencies=[Depends(check_token)])
-async def latest() -> dict:
+
+
+@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
+        ],
+    }
+
+
+@app.get("/api/latest")
+async def latest(request: Request) -> dict:
     readings = ch_query(
         f"SELECT device, "
         f"argMax(temperature, hour) AS temperature, "
@@ -315,7 +507,7 @@ async def latest() -> dict:
         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, "
@@ -362,6 +554,108 @@ async def latest() -> dict:
     }
 
 
+
+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:

+ 386 - 0
dev/nutikodu-web/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)

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

@@ -1,3 +1,5 @@
 fastapi
 uvicorn
 requests
+itsdangerous
+python-multipart

+ 223 - 3
dev/nutikodu-web/static/app.js

@@ -259,6 +259,7 @@ async function load() {
     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"));
@@ -276,8 +277,7 @@ async function loadMeta() {
     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}`;
+    $("#subtitle").textContent = "Zigbee andurid";
     if (state.meta.min) {
       $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
     }
@@ -365,13 +365,233 @@ function init() {
     load();
   });
 
+  const awayBtn = document.getElementById("away-toggle");
+  if (awayBtn) awayBtn.addEventListener("click", toggleAway);
   window.addEventListener("resize", () => chart && chart.resize());
   window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
   window.addEventListener("load", () => chart && chart.resize());
 
-  loadMeta().then(() => applyPreset(7));
+  checkAuth().then(() => loadMeta().then(() => applyPreset(7)));
   loadLatest();
   setInterval(loadLatest, 60000);
+  setInterval(loadProtected, 60000);
+}
+
+
+
+// --- 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 applyAuth(authed) {
+  state.authed = authed;
+  const awayBtn = document.getElementById("away-toggle");
+  if (awayBtn) awayBtn.hidden = !authed;
+  const prot = document.getElementById("protected");
+  if (prot) prot.hidden = !authed;
+  if (authed) loadAway();
+  const link = document.getElementById("auth-link");
+  if (link) {
+    link.href = authed ? "/logout" : "/login";
+    link.title = authed ? "Logi välja" : "Sisselogimine";
+    link.textContent = authed ? "🔓" : "🔒";
+  }
+}
+
+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();
+  }
 }
 
 document.addEventListener("DOMContentLoaded", init);

+ 377 - 0
dev/nutikodu-web/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);

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

@@ -22,6 +22,8 @@
     </div>
     <div class="header-actions">
       <span class="status" id="status" title="Ühenduse olek"></span>
+      <button type="button" id="away-toggle" class="away-pill" hidden aria-label="Kodust eemal lüliti"><span class="away-ico">🏠</span><span id="away-title">Kodus</span></button>
+      <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>
@@ -58,6 +60,21 @@
       <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>
+
     <footer class="foot">
       <span id="range-label"></span>
     </footer>

+ 85 - 0
dev/nutikodu-web/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>

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

@@ -62,11 +62,14 @@ header {
 .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 {
@@ -119,4 +122,36 @@ main > * { min-width: 0; }
   .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: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);
 }

+ 130 - 2
dev/zigbee-collector/collector.py

@@ -69,6 +69,10 @@ CH_DB = os.environ.get("CH_DB", "uus67")
 CH_TABLE = os.environ.get("CH_TABLE", "temperatuur")
 CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
 CH_FAULT_TABLE = os.environ.get("CH_FAULT_TABLE", "vead")
+MOTION_TOPICS = {t.strip() for t in os.environ.get("MOTION_TOPICS", "").split(",") if t.strip()}
+ENERGY_TOPICS = {t.strip() for t in os.environ.get("ENERGY_TOPICS", "").split(",") if t.strip()}
+CH_MOTION_TABLE = os.environ.get("CH_MOTION_TABLE", "liikumine")
+CH_ENERGY_TABLE = os.environ.get("CH_ENERGY_TABLE", "energia")
 
 # Häirete lävendid (hüstereesiga: aktiveerub <= X, taastub >= Y).
 BATTERY_ACT = _env_float("BATTERY_LOW_THRESHOLD", 20)
@@ -85,7 +89,9 @@ OFFLINE_CONTACT_MIN = _env_float("OFFLINE_TIMEOUT_CONTACT_MIN", 240)
 # Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
 DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
 DOOR_DEVICES = {t.rsplit("/", 1)[-1] for t in DOOR_TOPICS}
-MONITORED_DEVICES = {DEVICE, *DOOR_DEVICES}
+MOTION_DEVICES = {t.rsplit("/", 1)[-1] for t in MOTION_TOPICS}
+ENERGY_DEVICES = {t.rsplit("/", 1)[-1] for t in ENERGY_TOPICS}
+MONITORED_DEVICES = {DEVICE, *DOOR_DEVICES, *MOTION_DEVICES, *ENERGY_DEVICES}
 
 SYSTEM_DEVICE_BRIDGE = "zigbee2mqtt"
 SYSTEM_DEVICE_COLLECTOR = "koguja"
@@ -196,6 +202,71 @@ class DoorTracker:
         }
 
 
+
+class MotionTracker:
+    """Liikumise/kohalolu olekumuutuste jälgija (ainult muutused)."""
+
+    def __init__(self, last_states=None):
+        self.last = dict(last_states or {})
+
+    def process(self, device, payload, ts):
+        occ = payload.get("occupancy")
+        if not isinstance(occ, bool):
+            return None
+        if device in self.last and self.last[device] == occ:
+            return None
+        self.last[device] = occ
+        illum = payload.get("illumination")
+        return {
+            "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
+            "device": device,
+            "occupancy": occ,
+            "illumination": illum if isinstance(illum, str) else "",
+            "olek": "liikumine" if occ else "rahu",
+        }
+
+
+class EnergyBucket:
+    """Pistikute energiatarbe tunnipõhine agregeerija (seadme kaupa)."""
+
+    def __init__(self, hour):
+        self.hour = hour
+        self.data = {}
+
+    def add(self, device, payload):
+        d = self.data.setdefault(
+            device, {"power": [], "voltage": [], "current": [], "energy_last": None}
+        )
+        p = _as_float(payload.get("power"))
+        v = _as_float(payload.get("voltage"))
+        c = _as_float(payload.get("current"))
+        e = _as_float(payload.get("energy"))
+        if p is not None:
+            d["power"].append(p)
+        if v is not None:
+            d["voltage"].append(v)
+        if c is not None:
+            d["current"].append(c)
+        if e is not None:
+            d["energy_last"] = e
+
+    def rows(self):
+        out = []
+        for device, d in self.data.items():
+            def avg(vals):
+                return round(sum(vals) / len(vals), 3) if vals else None
+            out.append({
+                "ts": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
+                "device": device,
+                "power": avg(d["power"]),
+                "voltage": avg(d["voltage"]),
+                "current": avg(d["current"]),
+                "energy": d["energy_last"],
+            })
+        return out
+
+
+
 class FaultTracker:
     """Häirete olekumuutuste jälgija (ainult muutused).
 
@@ -350,6 +421,28 @@ def load_last_states() -> dict[str, bool]:
         return {}
 
 
+
+def load_last_motion_states() -> dict[str, bool]:
+    """Loeb andmebaasist iga liikumisanduri viimase ``occupancy`` väärtuse."""
+    query = (
+        f"SELECT device, argMax(occupancy, ts) AS occupancy "
+        f"FROM {CH_DB}.{CH_MOTION_TABLE} GROUP BY device FORMAT JSONEachRow"
+    )
+    try:
+        states: dict[str, bool] = {}
+        for line in _ch_query(query).splitlines():
+            if not line.strip():
+                continue
+            obj = json.loads(line)
+            value = obj["occupancy"]
+            states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
+        LOG.info("Seemendatud %d liikumisanduri olek", len(states))
+        return states
+    except Exception:
+        LOG.exception("Liikumisolekute laadimine ebaõnnestus, alustan tühjalt")
+        return {}
+
+
 def load_active_faults() -> dict[str, set[str]]:
     """Loeb andmebaasist iga seadme parajasti aktiivsed häirete tüübid."""
     query = (
@@ -427,6 +520,10 @@ def main() -> int:
     door_tracker = DoorTracker(load_last_states())
     fault_tracker = FaultTracker(load_active_faults())
     last_values: dict[str, float | None] = load_last_temp()
+    motion_writer = ClickHouseWriter(CH_MOTION_TABLE)
+    energy_writer = ClickHouseWriter(CH_ENERGY_TABLE)
+    motion_tracker = MotionTracker(load_last_motion_states())
+    energy_bucket = EnergyBucket(current_hour())
 
     start = datetime.now(TZ)
     last_seen: dict[str, datetime] = {}
@@ -442,7 +539,13 @@ def main() -> int:
 
     def on_connect(client, userdata, flags, reason_code, properties=None):
         if reason_code == 0:
-            topics = [MQTT_TOPIC, BRIDGE_STATE_TOPIC, *sorted(DOOR_TOPICS)]
+            topics = [
+                MQTT_TOPIC,
+                BRIDGE_STATE_TOPIC,
+                *sorted(DOOR_TOPICS),
+                *sorted(MOTION_TOPICS),
+                *sorted(ENERGY_TOPICS),
+            ]
             LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
             for topic in topics:
                 client.subscribe(topic, qos=1)
@@ -502,6 +605,19 @@ def main() -> int:
                     LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
                     door_writer.enqueue(row)
                 fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
+            elif msg.topic in MOTION_TOPICS:
+                device = msg.topic.rsplit("/", 1)[-1]
+                last_seen[device] = now
+                mrow = motion_tracker.process(device, payload, now)
+                if mrow is not None:
+                    LOG.info("Liikumine: %s -> %s", device, mrow["olek"])
+                    motion_writer.enqueue(mrow)
+                fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
+            elif msg.topic in ENERGY_TOPICS:
+                device = msg.topic.rsplit("/", 1)[-1]
+                last_seen[device] = now
+                energy_bucket.add(device, payload)
+                fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
             elif msg.topic == BRIDGE_STATE_TOPIC:
                 offline = payload.get("state") == "offline"
                 fault_writer.enqueue_many(
@@ -554,6 +670,14 @@ def main() -> int:
                 bucket = HourBucket(current_hour(now))
             else:
                 finished = None
+            if current_hour(now) != energy_bucket.hour:
+                fe = energy_bucket
+                energy_bucket = EnergyBucket(current_hour(now))
+            else:
+                fe = None
+            if fe is not None:
+                for r in fe.rows():
+                    energy_writer.enqueue(r)
             if finished is not None:
                 if not finished.empty:
                     temp_writer.enqueue(finished.row())
@@ -582,10 +706,14 @@ def main() -> int:
                 )
         temp_writer.flush()
         door_writer.flush()
+        motion_writer.flush()
+        energy_writer.flush()
         fault_writer.flush()
 
     temp_writer.flush()
     door_writer.flush()
+    motion_writer.flush()
+    energy_writer.flush()
     fault_writer.flush()
     client.loop_stop()
     client.disconnect()

+ 596 - 0
dev/zigbee-collector/collector.py.bak.1791388956

@@ -0,0 +1,596 @@
+#!/usr/bin/env python3
+"""Zigbee andurite koguja.
+
+Kolm voogu:
+
+1. Temperatuur/niiskus (``MQTT_TOPIC``): mõõtmised koondatakse mälus täistunni
+   kaupa ja kirjutatakse iga täistunni järel üks agregeeritud rida
+   (``CH_TABLE``, vaikimisi ``uus67.temperatuur``). Kui tunnis mõõtmisi ei ole
+   (andur kättesaamatu), kirjutatakse ikkagi rida eelmise teadaoleva väärtusega
+   ja märgitakse ``taidetud = true`` — nii ei teki aegreas auke.
+2. Kontaktandurid (``DOOR_TOPICS``): salvestatakse **ainult olekumuutused**
+   (``CH_DOOR_TABLE``, vaikimisi ``uus67.ukseandur``).
+3. Häired (``CH_FAULT_TABLE``, vaikimisi ``uus67.vead``): **ainult muutused**
+   (``aktiveeritud`` / ``taastatud``). Allikad: aku tühjenemine
+   (``battery_low`` või ``battery`` lävend), nõrk signaal (``linkquality``),
+   madal pinge (``voltage``), anduri kadumine (viimase teate timeout),
+   koguja MQTT-ühenduse kadu ja Zigbee2MQTT bridge'i offline-olek.
+
+Käivitub konteineris, mis on main_ai_network võrgus (ClickHouse nimega
+lahenduv). Välised seadistused tulevad keskkonnamuutujatest.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import signal
+import threading
+from datetime import datetime, timedelta
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import paho.mqtt.client as mqtt
+import requests
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(message)s",
+)
+LOG = logging.getLogger("zigbee-collector")
+
+TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
+
+
+def _env_float(name: str, default: float) -> float:
+    try:
+        return float(os.environ.get(name, default))
+    except (TypeError, ValueError):
+        return default
+
+
+MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
+MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
+MQTT_USER = os.environ.get("MQTT_USER", "")
+MQTT_PASS = os.environ.get("MQTT_PASS", "")
+MQTT_TOPIC = os.environ.get("MQTT_TOPIC", "zigbee2mqtt/Temperatuur")
+DOOR_TOPICS = {
+    t.strip()
+    for t in os.environ.get("DOOR_TOPICS", "").split(",")
+    if t.strip()
+}
+BRIDGE_STATE_TOPIC = os.environ.get("BRIDGE_STATE_TOPIC", "zigbee2mqtt/bridge/state")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "default")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+CH_TABLE = os.environ.get("CH_TABLE", "temperatuur")
+CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
+CH_FAULT_TABLE = os.environ.get("CH_FAULT_TABLE", "vead")
+
+# Häirete lävendid (hüstereesiga: aktiveerub <= X, taastub >= Y).
+BATTERY_ACT = _env_float("BATTERY_LOW_THRESHOLD", 20)
+BATTERY_CLR = _env_float("BATTERY_CLEAR_THRESHOLD", 25)
+LQ_ACT = _env_float("LINKQUALITY_LOW_THRESHOLD", 20)
+LQ_CLR = _env_float("LINKQUALITY_CLEAR_THRESHOLD", 30)
+VOLT_ACT = _env_float("VOLTAGE_LOW_THRESHOLD", 2600)
+VOLT_CLR = _env_float("VOLTAGE_CLEAR_THRESHOLD", 2700)
+
+# Offline-tuvastuse timeoutid (minutites).
+OFFLINE_TEMP_MIN = _env_float("OFFLINE_TIMEOUT_TEMP_MIN", 120)
+OFFLINE_CONTACT_MIN = _env_float("OFFLINE_TIMEOUT_CONTACT_MIN", 240)
+
+# Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
+DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
+DOOR_DEVICES = {t.rsplit("/", 1)[-1] for t in DOOR_TOPICS}
+MONITORED_DEVICES = {DEVICE, *DOOR_DEVICES}
+
+SYSTEM_DEVICE_BRIDGE = "zigbee2mqtt"
+SYSTEM_DEVICE_COLLECTOR = "koguja"
+
+CHECK_INTERVAL_SEC = 20
+
+
+def current_hour(now: datetime | None = None) -> datetime:
+    now = now or datetime.now(TZ)
+    return now.replace(minute=0, second=0, microsecond=0)
+
+
+def _as_float(value: Any) -> float | None:
+    if isinstance(value, bool) or not isinstance(value, (int, float)):
+        return None
+    return float(value)
+
+
+def _fault_row(
+    ts: datetime,
+    device: str,
+    tyyp: str,
+    olek: str,
+    detail: str,
+    value: float | None,
+) -> dict[str, Any]:
+    return {
+        "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
+        "device": device,
+        "tyyp": tyyp,
+        "olek": olek,
+        "detail": detail,
+        "vaartus": value,
+    }
+
+
+class HourBucket:
+    """Ühe tunni jooksul kogutud temperatuuri/niiskuse mõõtmised."""
+
+    def __init__(self, hour: datetime) -> None:
+        self.hour = hour
+        self.temps: list[float] = []
+        self.hums: list[float] = []
+
+    def add(self, payload: dict[str, Any]) -> None:
+        if isinstance(payload.get("temperature"), (int, float)):
+            self.temps.append(float(payload["temperature"]))
+        if isinstance(payload.get("humidity"), (int, float)):
+            self.hums.append(float(payload["humidity"]))
+
+    @property
+    def empty(self) -> bool:
+        return not self.temps and not self.hums
+
+    def row(self, fallback: dict[str, float | None] | None = None) -> dict[str, Any]:
+        """Ühe tunni rida.
+
+        Kui tunnis mõõtmisi ei ole ja ``fallback`` on antud, kasutatakse
+        eelmise teadaoleva väärtuse andmeid ja märgitakse ``taidetud = true``.
+        """
+        def avg(values: list[float]) -> float | None:
+            return round(sum(values) / len(values), 2) if values else None
+
+        if self.empty and fallback is not None:
+            return {
+                "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
+                "device": DEVICE,
+                "temperature": fallback.get("temperature"),
+                "humidity": fallback.get("humidity"),
+                "taidetud": True,
+            }
+        return {
+            "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
+            "device": DEVICE,
+            "temperature": avg(self.temps),
+            "humidity": avg(self.hums),
+            "taidetud": False,
+        }
+
+
+class DoorTracker:
+    """Kontaktandurite olekumuutuste jälgija.
+
+    ``last`` hoiab viimast teadaolevat ``contact`` väärtust anduri kohta.
+    Südamelöögid (sama väärtus) ei tekita rida. Esimene nähtud väärtus anduri
+    kohta, millel puudub varasem olek, salvestatakse baasreana.
+    """
+
+    def __init__(self, last_states: dict[str, bool] | None = None) -> None:
+        self.last: dict[str, bool] = dict(last_states or {})
+
+    def process(
+        self, device: str, payload: dict[str, Any], ts: datetime
+    ) -> dict[str, Any] | None:
+        contact = payload.get("contact")
+        if not isinstance(contact, bool):
+            return None
+        if device in self.last and self.last[device] == contact:
+            return None
+        self.last[device] = contact
+        return {
+            "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
+            "device": device,
+            "contact": contact,
+            # SNZB-04P: contact=true = suletud, false = avatud (Z2M HA discovery
+            # payload_on=false, payload_off=true, device_class=door).
+            "olek": "suletud" if contact else "avatud",
+        }
+
+
+class FaultTracker:
+    """Häirete olekumuutuste jälgija (ainult muutused).
+
+    ``current_keys`` hoiab iga seadme jaoks parajasti aktiivsete häirete
+    tüüpe. Iga hindamise järel emiteeritakse read ainult üleminekute kohta
+    (``aktiveeritud`` / ``taastatud``). Käivitusel seemendatakse aktiivsed
+    häired andmebaasist, et restart ei dubleeriks kirjeid.
+    """
+
+    def __init__(self, active: dict[str, set[str]] | None = None) -> None:
+        self.payload_faults: dict[str, dict[str, tuple[str, float | None]]] = {}
+        self.offline_faults: dict[str, tuple[str, float | None]] = {}
+        self.current_keys: dict[str, set[str]] = {
+            device: set(tyybid) for device, tyybid in (active or {}).items()
+        }
+
+    def _low(self, device: str, tyyp: str, value: float, activate_at: float, clear_at: float) -> bool:
+        if tyyp in self.current_keys.get(device, set()):
+            return value < clear_at
+        return value <= activate_at
+
+    def _derive(self, device: str, payload: dict[str, Any]) -> dict[str, tuple[str, float | None]]:
+        faults: dict[str, tuple[str, float | None]] = {}
+        battery_low = payload.get("battery_low")
+        battery = _as_float(payload.get("battery"))
+        if battery_low is True:
+            faults["low_battery"] = ("battery_low=true", battery)
+        elif battery is not None and self._low(device, "low_battery", battery, BATTERY_ACT, BATTERY_CLR):
+            faults["low_battery"] = (f"battery {battery:.0f}%", battery)
+        lq = _as_float(payload.get("linkquality"))
+        if lq is not None and self._low(device, "weak_signal", lq, LQ_ACT, LQ_CLR):
+            faults["weak_signal"] = (f"linkquality {lq:.0f}", lq)
+        voltage = _as_float(payload.get("voltage"))
+        if voltage is not None and self._low(device, "low_voltage", voltage, VOLT_ACT, VOLT_CLR):
+            faults["low_voltage"] = (f"voltage {voltage:.0f} mV", voltage)
+        return faults
+
+    def _reconcile(self, device: str, ts: datetime) -> list[dict[str, Any]]:
+        current: dict[str, tuple[str, float | None]] = {}
+        current.update(self.payload_faults.get(device, {}))
+        if device in self.offline_faults:
+            current["offline"] = self.offline_faults[device]
+        prev = self.current_keys.get(device, set())
+        rows: list[dict[str, Any]] = []
+        for tyyp, (detail, value) in current.items():
+            if tyyp not in prev:
+                rows.append(_fault_row(ts, device, tyyp, "aktiveeritud", detail, value))
+        for tyyp in prev - set(current):
+            rows.append(_fault_row(ts, device, tyyp, "taastatud", "", None))
+        self.current_keys[device] = set(current)
+        return rows
+
+    def on_device_message(
+        self, device: str, payload: dict[str, Any], ts: datetime
+    ) -> list[dict[str, Any]]:
+        self.payload_faults[device] = self._derive(device, payload)
+        self.offline_faults.pop(device, None)
+        return self._reconcile(device, ts)
+
+    def set_offline(
+        self, device: str, offline: bool, detail: str, ts: datetime
+    ) -> list[dict[str, Any]]:
+        if offline:
+            self.offline_faults[device] = (detail, None)
+        else:
+            self.offline_faults.pop(device, None)
+        return self._reconcile(device, ts)
+
+    def set_system_fault(
+        self,
+        device: str,
+        tyyp: str,
+        active: bool,
+        detail: str,
+        ts: datetime,
+    ) -> list[dict[str, Any]]:
+        faults = dict(self.payload_faults.get(device, {}))
+        if active:
+            faults[tyyp] = (detail, None)
+        else:
+            faults.pop(tyyp, None)
+        self.payload_faults[device] = faults
+        return self._reconcile(device, ts)
+
+
+class ClickHouseWriter:
+    def __init__(self, table: str) -> None:
+        self.table = table
+        self.pending: list[dict[str, Any]] = []
+
+    def enqueue(self, row: dict[str, Any]) -> None:
+        self.pending.append(row)
+
+    def enqueue_many(self, rows: list[dict[str, Any]]) -> None:
+        self.pending.extend(rows)
+
+    def flush(self) -> None:
+        if not self.pending:
+            return
+        body = "\n".join(json.dumps(r, ensure_ascii=False) for r in self.pending)
+        try:
+            resp = requests.post(
+                f"{CH_URL}/",
+                params={
+                    "database": CH_DB,
+                    "query": f"INSERT INTO {CH_DB}.{self.table} FORMAT JSONEachRow",
+                },
+                data=body.encode("utf-8"),
+                auth=(CH_USER, CH_PASS),
+                timeout=15,
+            )
+            resp.raise_for_status()
+            LOG.info("ClickHouse'i (%s) kirjutatud %d rida", self.table, len(self.pending))
+            self.pending.clear()
+        except Exception:
+            LOG.exception(
+                "ClickHouse'i (%s) kirjutamine ebaõnnestus, jätan %d rida järjekorda",
+                self.table,
+                len(self.pending),
+            )
+
+
+def _ch_query(query: str) -> str:
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": query},
+        auth=(CH_USER, CH_PASS),
+        timeout=15,
+    )
+    resp.raise_for_status()
+    return resp.text
+
+
+def load_last_states() -> dict[str, bool]:
+    """Loeb andmebaasist iga anduri viimase teadaoleva ``contact`` väärtuse."""
+    query = (
+        f"SELECT device, argMax(contact, ts) AS contact "
+        f"FROM {CH_DB}.{CH_DOOR_TABLE} GROUP BY device FORMAT JSONEachRow"
+    )
+    try:
+        states: dict[str, bool] = {}
+        for line in _ch_query(query).splitlines():
+            if not line.strip():
+                continue
+            obj = json.loads(line)
+            value = obj["contact"]
+            states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
+        LOG.info("Seemendatud %d anduri viimane olek", len(states))
+        return states
+    except Exception:
+        LOG.exception("Viimase oleku laadimine ebaõnnestus, alustan tühjalt")
+        return {}
+
+
+def load_active_faults() -> dict[str, set[str]]:
+    """Loeb andmebaasist iga seadme parajasti aktiivsed häirete tüübid."""
+    query = (
+        f"SELECT device, tyyp, argMax(olek, ts) AS olek "
+        f"FROM {CH_DB}.{CH_FAULT_TABLE} GROUP BY device, tyyp FORMAT JSONEachRow"
+    )
+    try:
+        active: dict[str, set[str]] = {}
+        for line in _ch_query(query).splitlines():
+            if not line.strip():
+                continue
+            obj = json.loads(line)
+            if obj.get("olek") == "aktiveeritud":
+                active.setdefault(obj["device"], set()).add(obj["tyyp"])
+        LOG.info("Seemendatud %d seadme aktiivsed häired", len(active))
+        return active
+    except Exception:
+        LOG.exception("Aktiivsete häirete laadimine ebaõnnestus, alustan tühjalt")
+        return {}
+
+
+def load_last_seen() -> dict[str, datetime]:
+    """Loeb andmebaasist iga anduri viimase kirje aja (offline-baas)."""
+    query = (
+        "SELECT device, max(ts) AS ts FROM ("
+        f"SELECT device, ts FROM {CH_DB}.{CH_DOOR_TABLE} "
+        "UNION ALL "
+        f"SELECT device, hour AS ts FROM {CH_DB}.{CH_TABLE}"
+        ") GROUP BY device FORMAT JSONEachRow"
+    )
+    try:
+        seen: dict[str, datetime] = {}
+        for line in _ch_query(query).splitlines():
+            if not line.strip():
+                continue
+            obj = json.loads(line)
+            seen[obj["device"]] = datetime.strptime(obj["ts"], "%Y-%m-%d %H:%M:%S").replace(
+                tzinfo=TZ
+            )
+        LOG.info("Seemendatud %d anduri viimane teate aeg", len(seen))
+        return seen
+    except Exception:
+        LOG.exception("Viimaste teate aegade laadimine ebaõnnestus, alustan tühjalt")
+        return {}
+
+
+def load_last_temp() -> dict[str, float | None]:
+    """Loeb andmebaasist viimase teadaoleva temperatuuri/niiskuse (täitmise baas)."""
+    query = (
+        f"SELECT temperature, humidity FROM {CH_DB}.{CH_TABLE} "
+        "ORDER BY hour DESC LIMIT 1 FORMAT JSONEachRow"
+    )
+    try:
+        text = _ch_query(query).strip()
+        if text:
+            obj = json.loads(text.splitlines()[0])
+            values = {
+                "temperature": _as_float(obj.get("temperature")),
+                "humidity": _as_float(obj.get("humidity")),
+            }
+            LOG.info("Seemendatud viimane väärtus: %s", values)
+            return values
+    except Exception:
+        LOG.exception("Viimase väärtuse laadimine ebaõnnestus, alustan tühjalt")
+    return {"temperature": None, "humidity": None}
+
+
+def main() -> int:
+    lock = threading.Lock()
+    stop = threading.Event()
+    bucket = HourBucket(current_hour())
+    temp_writer = ClickHouseWriter(CH_TABLE)
+    door_writer = ClickHouseWriter(CH_DOOR_TABLE)
+    fault_writer = ClickHouseWriter(CH_FAULT_TABLE)
+    door_tracker = DoorTracker(load_last_states())
+    fault_tracker = FaultTracker(load_active_faults())
+    last_values: dict[str, float | None] = load_last_temp()
+
+    start = datetime.now(TZ)
+    last_seen: dict[str, datetime] = {}
+    for device, seen in load_last_seen().items():
+        last_seen[device] = seen
+    for device in MONITORED_DEVICES:
+        last_seen.setdefault(device, start)
+
+    def offline_timeout(device: str) -> float:
+        if device == DEVICE:
+            return OFFLINE_TEMP_MIN
+        return OFFLINE_CONTACT_MIN
+
+    def on_connect(client, userdata, flags, reason_code, properties=None):
+        if reason_code == 0:
+            topics = [MQTT_TOPIC, BRIDGE_STATE_TOPIC, *sorted(DOOR_TOPICS)]
+            LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
+            for topic in topics:
+                client.subscribe(topic, qos=1)
+            if not stop.is_set():
+                with lock:
+                    fault_writer.enqueue_many(
+                        fault_tracker.set_system_fault(
+                            SYSTEM_DEVICE_COLLECTOR,
+                            "mqtt_disconnected",
+                            False,
+                            "",
+                            datetime.now(TZ),
+                        )
+                    )
+        else:
+            LOG.error("MQTT ühendus ebaõnnestus: %s", reason_code)
+
+    def on_disconnect(client, userdata, flags, reason_code, properties=None):
+        LOG.warning("MQTT ühendus katkes: %s", reason_code)
+        if not stop.is_set():
+            with lock:
+                fault_writer.enqueue_many(
+                    fault_tracker.set_system_fault(
+                        SYSTEM_DEVICE_COLLECTOR,
+                        "mqtt_disconnected",
+                        True,
+                        f"MQTT ühendus katkes: {reason_code}",
+                        datetime.now(TZ),
+                    )
+                )
+
+    def on_message(client, userdata, msg):
+        try:
+            payload = json.loads(msg.payload.decode("utf-8"))
+        except Exception:
+            LOG.exception("Vigane MQTT teade teemal %s", msg.topic)
+            return
+        if not isinstance(payload, dict):
+            return
+        now = datetime.now(TZ)
+        with lock:
+            if msg.topic == MQTT_TOPIC:
+                bucket.add(payload)
+                last_seen[DEVICE] = now
+                temp = _as_float(payload.get("temperature"))
+                hum = _as_float(payload.get("humidity"))
+                if temp is not None:
+                    last_values["temperature"] = temp
+                if hum is not None:
+                    last_values["humidity"] = hum
+                fault_writer.enqueue_many(fault_tracker.on_device_message(DEVICE, payload, now))
+            elif msg.topic in DOOR_TOPICS:
+                device = msg.topic.rsplit("/", 1)[-1]
+                last_seen[device] = now
+                row = door_tracker.process(device, payload, now)
+                if row is not None:
+                    LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
+                    door_writer.enqueue(row)
+                fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
+            elif msg.topic == BRIDGE_STATE_TOPIC:
+                offline = payload.get("state") == "offline"
+                fault_writer.enqueue_many(
+                    fault_tracker.set_system_fault(
+                        SYSTEM_DEVICE_BRIDGE,
+                        "bridge_offline",
+                        offline,
+                        "zigbee2mqtt bridge offline",
+                        now,
+                    )
+                )
+
+    client = mqtt.Client(
+        mqtt.CallbackAPIVersion.VERSION2,
+        client_id="zigbee-collector",
+    )
+    if MQTT_USER:
+        client.username_pw_set(MQTT_USER, MQTT_PASS)
+    client.on_connect = on_connect
+    client.on_disconnect = on_disconnect
+    client.on_message = on_message
+    client.reconnect_delay_set(min_delay=1, max_delay=60)
+    client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60)
+    client.loop_start()
+
+    def shutdown(signum, frame):
+        LOG.info("Lõpetamissignaal (%s), sulgen", signum)
+        stop.set()
+
+    signal.signal(signal.SIGTERM, shutdown)
+    signal.signal(signal.SIGINT, shutdown)
+
+    LOG.info(
+        "Koguja käivitatud: temp=%s.%s, uksed=%s.%s (%d teemat), häired=%s.%s",
+        CH_DB,
+        CH_TABLE,
+        CH_DB,
+        CH_DOOR_TABLE,
+        len(DOOR_TOPICS),
+        CH_DB,
+        CH_FAULT_TABLE,
+    )
+
+    while not stop.is_set():
+        stop.wait(CHECK_INTERVAL_SEC)
+        now = datetime.now(TZ)
+        with lock:
+            if current_hour(now) != bucket.hour:
+                finished = bucket
+                bucket = HourBucket(current_hour(now))
+            else:
+                finished = None
+            if finished is not None:
+                if not finished.empty:
+                    temp_writer.enqueue(finished.row())
+                elif (
+                    last_values.get("temperature") is not None
+                    or last_values.get("humidity") is not None
+                ):
+                    LOG.info(
+                        "Tund %s ilma mõõtmisteta — täidan eelmise väärtusega (%s / %s)",
+                        finished.hour.strftime("%Y-%m-%d %H:%M"),
+                        last_values.get("temperature"),
+                        last_values.get("humidity"),
+                    )
+                    temp_writer.enqueue(finished.row(fallback=last_values))
+            for device in sorted(MONITORED_DEVICES):
+                seen = last_seen.get(device, start)
+                timeout = timedelta(minutes=offline_timeout(device))
+                offline = (now - seen) > timeout
+                detail = (
+                    f"viimane teade {seen.strftime('%Y-%m-%d %H:%M:%S')}"
+                    if offline
+                    else ""
+                )
+                fault_writer.enqueue_many(
+                    fault_tracker.set_offline(device, offline, detail, now)
+                )
+        temp_writer.flush()
+        door_writer.flush()
+        fault_writer.flush()
+
+    temp_writer.flush()
+    door_writer.flush()
+    fault_writer.flush()
+    client.loop_stop()
+    client.disconnect()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

BIN
main/paperless/media/documents/originals/Suuremõisa Uus 6-7_13.pdf


BIN
main/paperless/media/documents/originals/Suuremõisa Uus 6-7_14.pdf


BIN
main/paperless/media/documents/thumbnails/0000025.webp


BIN
main/paperless/media/documents/thumbnails/0000026.webp