ardo 2 недель назад
Родитель
Сommit
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_PORT=${MQTT_PORT:-1883}
       - MQTT_TOPIC=${MQTT_TOPIC:-zigbee2mqtt/Temperatuur}
       - MQTT_TOPIC=${MQTT_TOPIC:-zigbee2mqtt/Temperatuur}
       - DOOR_TOPICS=${DOOR_TOPICS:-}
       - 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_URL=${CH_URL:-http://clickhouse:8123}
       - CH_DB=${CH_DB:-uus67}
       - CH_DB=${CH_DB:-uus67}
       - CH_TABLE=${CH_TABLE:-temperatuur}
       - 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/meta` | seadmed, tz, andmete ulatus |
 | `GET /api/temperatuur?from=&to=&device=&agg=hour\|day` | aegrida |
 | `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/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 |
 | `GET /health` | tervis |
 
 
 Ajavahemikud kujul `YYYY-MM-DD[ HH:MM[:SS]]` või ISO (T-eraldaja). Vaikimisi viimased 7 päeva.
 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
 ## Keskkonnamuutujad
 
 
 | Muutuja | Vaikimisi | Kirjeldus |
 | 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_URL` | `http://clickhouse:8123` | ClickHouse HTTP |
 | `CH_DB` | `uus67` | andmebaas |
 | `CH_DB` | `uus67` | andmebaas |
 | `CH_USER` / `CH_PASS` | `zigbee` / – | ainult SELECT `uus67.*` |
 | `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
 ## Struktuur
 
 
@@ -87,4 +121,4 @@ docker logs -f nutikodu-web
 
 
 - Ukseanduri olekuribad ajateljel (`/api/ukseandur` on valmis).
 - Ukseanduri olekuribad ajateljel (`/api/ukseandur` on valmis).
 - Häirete markerid/annotatsioonid (`/api/vead` 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
 from __future__ import annotations
 
 
+import hmac
+import secrets
 import json
 import json
 import os
 import os
+import threading
+import time
+from collections import defaultdict
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
 import requests
 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 fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
 
 
 ROOT = Path(__file__).parent
 ROOT = Path(__file__).parent
 STATIC_DIR = ROOT / "static"
 STATIC_DIR = ROOT / "static"
@@ -39,11 +45,26 @@ CH_PASS = os.environ.get("CH_PASS", "")
 CH_DB = os.environ.get("CH_DB", "uus67")
 CH_DB = os.environ.get("CH_DB", "uus67")
 
 
 WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
 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
 DEFAULT_RANGE_DAYS = 7
 MAX_RANGE_DAYS = 3660
 MAX_RANGE_DAYS = 3660
 
 
 app = FastAPI(title="Nutikodu", version="0.2.0")
 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")
 @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
         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:
 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
     return from_s, to_s
 
 
 
 
-@app.get("/api/meta", dependencies=[Depends(check_token)])
+@app.get("/api/meta")
 async def meta() -> dict:
 async def meta() -> dict:
     devices = [row["device"] for row in ch_query(
     devices = [row["device"] for row in ch_query(
         f"SELECT DISTINCT device FROM {CH_DB}.temperatuur ORDER BY device"
         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(
 async def temperatuur(
     from_: str | None = Query(None, alias="from"),
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
     to: str | None = Query(None),
@@ -187,7 +255,7 @@ async def temperatuur(
     return {"from": from_s, "to": to_s, "agg": agg, "series": series}
     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(
 async def valisilm(
     from_: str | None = Query(None, alias="from"),
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
     to: str | None = Query(None),
@@ -234,7 +302,7 @@ async def valisilm(
     return {"from": from_s, "to": to_s, "agg": agg, "series": series}
     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(
 async def ukseandur(
     from_: str | None = Query(None, alias="from"),
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
     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(
 async def vead(
     from_: str | None = Query(None, alias="from"),
     from_: str | None = Query(None, alias="from"),
     to: str | None = Query(None),
     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(
     readings = ch_query(
         f"SELECT device, "
         f"SELECT device, "
         f"argMax(temperature, hour) AS temperature, "
         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"SELECT device, tyyp, argMax(detail, ts) AS detail_now, "
         f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_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'"
         f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
-    )
+    ) if is_authed(request) else []
     outside_rows = ch_query(
     outside_rows = ch_query(
         f"SELECT toString(max(hour)) AS ts_str, "
         f"SELECT toString(max(hour)) AS ts_str, "
         f"argMax(temperature, hour) AS temperature, "
         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")
 @app.get("/health")
 async def health() -> dict:
 async def health() -> dict:
     try:
     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
 fastapi
 uvicorn
 uvicorn
 requests
 requests
+itsdangerous
+python-multipart

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

@@ -259,6 +259,7 @@ async function load() {
     currentData = data;
     currentData = data;
     currentSeries = data.series;
     currentSeries = data.series;
     render(data);
     render(data);
+    loadProtected();
     setStatus(true);
     setStatus(true);
     const f = new Date(state.from.replace(" ", "T"));
     const f = new Date(state.from.replace(" ", "T"));
     const t = new Date(state.to.replace(" ", "T"));
     const t = new Date(state.to.replace(" ", "T"));
@@ -276,8 +277,7 @@ async function loadMeta() {
     const res = await fetch("/api/meta");
     const res = await fetch("/api/meta");
     if (!res.ok) throw new Error();
     if (!res.ok) throw new Error();
     state.meta = await res.json();
     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) {
     if (state.meta.min) {
       $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
       $("#range-label").textContent = `Andmed alates ${state.meta.min}`;
     }
     }
@@ -365,13 +365,233 @@ function init() {
     load();
     load();
   });
   });
 
 
+  const awayBtn = document.getElementById("away-toggle");
+  if (awayBtn) awayBtn.addEventListener("click", toggleAway);
   window.addEventListener("resize", () => chart && chart.resize());
   window.addEventListener("resize", () => chart && chart.resize());
   window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
   window.addEventListener("orientationchange", () => setTimeout(() => chart && chart.resize(), 250));
   window.addEventListener("load", () => chart && chart.resize());
   window.addEventListener("load", () => chart && chart.resize());
 
 
-  loadMeta().then(() => applyPreset(7));
+  checkAuth().then(() => loadMeta().then(() => applyPreset(7)));
   loadLatest();
   loadLatest();
   setInterval(loadLatest, 60000);
   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);
 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>
     <div class="header-actions">
     <div class="header-actions">
       <span class="status" id="status" title="Ühenduse olek"></span>
       <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>
       <button id="theme-toggle" class="icon-btn" title="Vaheta teema" aria-label="Vaheta teema">🌙</button>
     </div>
     </div>
   </header>
   </header>
@@ -58,6 +60,21 @@
       <div id="hint" class="hint" hidden></div>
       <div id="hint" class="hint" hidden></div>
     </section>
     </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">
     <footer class="foot">
       <span id="range-label"></span>
       <span id="range-label"></span>
     </footer>
     </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 {
 .icon-btn {
   border: 1px solid var(--border); background: var(--panel); color: var(--text);
   border: 1px solid var(--border); background: var(--panel); color: var(--text);
   width: 40px; height: 40px; border-radius: 12px; font-size: 18px; cursor: pointer;
   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); }
 .icon-btn:active { transform: scale(.96); }
 
 
 main { padding: 14px; max-width: 1100px; margin: 0 auto; display: grid; gap: 14px; }
 main { padding: 14px; max-width: 1100px; margin: 0 auto; display: grid; gap: 14px; }
 main > * { min-width: 0; }
 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; }
 .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
 .card {
 .card {
@@ -119,4 +122,36 @@ main > * { min-width: 0; }
   .brand h1 { font-size: 16px; }
   .brand h1 { font-size: 16px; }
   #chart { height: 50vh; min-height: 280px; }
   #chart { height: 50vh; min-height: 280px; }
   .seg { margin-left: 0; }
   .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_TABLE = os.environ.get("CH_TABLE", "temperatuur")
 CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
 CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
 CH_FAULT_TABLE = os.environ.get("CH_FAULT_TABLE", "vead")
 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).
 # Häirete lävendid (hüstereesiga: aktiveerub <= X, taastub >= Y).
 BATTERY_ACT = _env_float("BATTERY_LOW_THRESHOLD", 20)
 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).
 # Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
 DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
 DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
 DOOR_DEVICES = {t.rsplit("/", 1)[-1] for t in DOOR_TOPICS}
 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_BRIDGE = "zigbee2mqtt"
 SYSTEM_DEVICE_COLLECTOR = "koguja"
 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:
 class FaultTracker:
     """Häirete olekumuutuste jälgija (ainult muutused).
     """Häirete olekumuutuste jälgija (ainult muutused).
 
 
@@ -350,6 +421,28 @@ def load_last_states() -> dict[str, bool]:
         return {}
         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]]:
 def load_active_faults() -> dict[str, set[str]]:
     """Loeb andmebaasist iga seadme parajasti aktiivsed häirete tüübid."""
     """Loeb andmebaasist iga seadme parajasti aktiivsed häirete tüübid."""
     query = (
     query = (
@@ -427,6 +520,10 @@ def main() -> int:
     door_tracker = DoorTracker(load_last_states())
     door_tracker = DoorTracker(load_last_states())
     fault_tracker = FaultTracker(load_active_faults())
     fault_tracker = FaultTracker(load_active_faults())
     last_values: dict[str, float | None] = load_last_temp()
     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)
     start = datetime.now(TZ)
     last_seen: dict[str, datetime] = {}
     last_seen: dict[str, datetime] = {}
@@ -442,7 +539,13 @@ def main() -> int:
 
 
     def on_connect(client, userdata, flags, reason_code, properties=None):
     def on_connect(client, userdata, flags, reason_code, properties=None):
         if reason_code == 0:
         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))
             LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
             for topic in topics:
             for topic in topics:
                 client.subscribe(topic, qos=1)
                 client.subscribe(topic, qos=1)
@@ -502,6 +605,19 @@ def main() -> int:
                     LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
                     LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
                     door_writer.enqueue(row)
                     door_writer.enqueue(row)
                 fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
                 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:
             elif msg.topic == BRIDGE_STATE_TOPIC:
                 offline = payload.get("state") == "offline"
                 offline = payload.get("state") == "offline"
                 fault_writer.enqueue_many(
                 fault_writer.enqueue_many(
@@ -554,6 +670,14 @@ def main() -> int:
                 bucket = HourBucket(current_hour(now))
                 bucket = HourBucket(current_hour(now))
             else:
             else:
                 finished = None
                 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 finished is not None:
                 if not finished.empty:
                 if not finished.empty:
                     temp_writer.enqueue(finished.row())
                     temp_writer.enqueue(finished.row())
@@ -582,10 +706,14 @@ def main() -> int:
                 )
                 )
         temp_writer.flush()
         temp_writer.flush()
         door_writer.flush()
         door_writer.flush()
+        motion_writer.flush()
+        energy_writer.flush()
         fault_writer.flush()
         fault_writer.flush()
 
 
     temp_writer.flush()
     temp_writer.flush()
     door_writer.flush()
     door_writer.flush()
+    motion_writer.flush()
+    energy_writer.flush()
     fault_writer.flush()
     fault_writer.flush()
     client.loop_stop()
     client.loop_stop()
     client.disconnect()
     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