2
0

4 Incheckningar 06a13576b8 ... 1a5dc9ccb4

Upphovsman SHA1 Meddelande Datum
  ardo 1a5dc9ccb4 nutikodu-web: README — Varv vaheleht + /api/varv otspunktid 1 vecka sedan
  ardo 62c2d48e86 nutikodu-web: uus Varv vaheleht (AS7341 varviandur) 1 vecka sedan
  ardo d7df5234c0 varvusensor: AS7341 varvisensor + avalik MQTT broker (edge), CH parandused 1 vecka sedan
  ardo 89e2cf6a1b minukodu-web: uus leht jyri.odamus.com (Suuremõisa välistemperatuur + transport) 1 vecka sedan
34 ändrade filer med 2221 tillägg och 15 borttagningar
  1. 14 6
      dev/LOEMIND.md
  2. 35 0
      dev/docker-compose.yml
  3. 15 0
      dev/minukodu-web/Dockerfile
  4. 74 0
      dev/minukodu-web/README.md
  5. 358 0
      dev/minukodu-web/app.py
  6. 5 0
      dev/minukodu-web/requirements.txt
  7. 227 0
      dev/minukodu-web/static/app.js
  8. 6 0
      dev/minukodu-web/static/icon.svg
  9. 71 0
      dev/minukodu-web/static/index.html
  10. 12 0
      dev/minukodu-web/static/manifest.webmanifest
  11. 217 0
      dev/minukodu-web/static/style.css
  12. 5 0
      dev/nutikodu-web/README.md
  13. 62 0
      dev/nutikodu-web/app.py
  14. 196 9
      dev/nutikodu-web/static/app.js
  15. 38 0
      dev/nutikodu-web/static/index.html
  16. 31 0
      dev/nutikodu-web/static/style.css
  17. 11 0
      dev/users.d/zz-network-compression.xml
  18. 5 0
      dev/varvusensor/.gitignore
  19. 10 0
      dev/varvusensor/collector/Dockerfile
  20. 128 0
      dev/varvusensor/collector/calibrate.py
  21. 106 0
      dev/varvusensor/collector/collector.py
  22. 101 0
      dev/varvusensor/collector/color.py
  23. 95 0
      dev/varvusensor/collector/live.py
  24. 2 0
      dev/varvusensor/collector/requirements.txt
  25. 45 0
      dev/varvusensor/collector/settings.py
  26. 5 0
      dev/varvusensor/config/.gitignore
  27. 212 0
      dev/varvusensor/config/varvusensor.yaml
  28. 40 0
      dev/varvusensor/docker-compose.yml
  29. 3 0
      dev/varvusensor/edge-mqtt/acl
  30. 19 0
      dev/varvusensor/edge-mqtt/docker-compose.yml
  31. 12 0
      dev/varvusensor/edge-mqtt/mosquitto.conf
  32. 48 0
      dev/varvusensor/edge-mqtt/sync-certs.sh
  33. 3 0
      dev/varvusensor/mosquitto/acl
  34. 10 0
      dev/varvusensor/mosquitto/mosquitto.conf

+ 14 - 6
dev/LOEMIND.md

@@ -9,19 +9,27 @@ mille eesmärk on Oracle-baaside ülemineku ettevalmistamine).
 
 | Omadus | Väärtus |
 |--------|---------|
-| Pilt | `clickhouse/clickhouse-server:26.8.1.2041` |
+| Pilt | `clickhouse/clickhouse-server:26.9.14.10` |
 | Konteiner | `clickhouse` |
 | Püsivus | **alati aktiivne** (ei ole enam `experimental` profiilis), `restart: unless-stopped`, `mem_limit: 2g` |
-| HTTP API | **8125**, seotud **ainult Tailscale'ile** (`100.87.1.24:8125`) — **8124** läbi `clickstack-proxy` |
-| Native TCP | **9004**, seotud ainult Tailscale'ile (`100.87.1.24:9004`) |
+| HTTP API | **8125** — `100.87.1.24:8125` (Tailscale) + `192.168.68.114:8125` (LAN); **8124** läbi `clickstack-proxy` |
+| Native TCP | **9004** — `100.87.1.24:9004` (Tailscale) + `192.168.68.114:9004` (LAN) |
 | Kasutaja | `default` (ja `dev`, `zigbee`) |
 | Parool | `dev_clickhouse_2026` (default/dev); `zigbee` kasutaja parool on `dev/.env` failis |
 | Andmed | `./data/clickhouse` (gitignore'd) |
-| Config | `./config/` (merge'd `/etc/clickhouse-server/config.d`) |
+| Config | `./config/` → `config.d` (server); `./users.d/` → `users.d` (users + **profiilid**) |
 | Võrk | `main_ai_network` |
 
-> **Turvamärkus (2026-09-17):** pordid 8125/9004 ei ole enam `0.0.0.0` peal, vaid
-> ainult Tailscale'i aadressil `100.87.1.24`. LAN-ist ligi ei pääse.
+> **Turvamärkus (2026-09-17, uuendatud 2026-10-17):** pordid 8125/9004 ei ole
+> `0.0.0.0` peal. Algselt ainult Tailscale'i aadressil `100.87.1.24`; 2026-10-17
+> lisati ka **LAN-IP `192.168.68.114`** (DBeaver). Auth on kohustuslik.
+
+> **LZ4/ZSTD (2026-10-17):** ClickHouse 26.x vaikimisi
+> `network_compression_method=ZSTD` murdis vanemad clickhouse-jdbc/DBeaver
+> kliendid → *"Invalid LZ4 magic byte"* (bait `0x90` = ZSTD, klient ootab LZ4
+> `0x82`). Lahendus: `./users.d/zz-network-compression.xml` seab profiilis
+> `default` → `network_compression_method=lz4`. **NB:** profiilid kuuluvad
+> `users.d`-sse, MITTE `config.d`-sse (config.xml-s pole `<profiles>`).
 
 ## Web UI
 

+ 35 - 0
dev/docker-compose.yml

@@ -13,9 +13,12 @@ services:
     ports:
       - "100.87.1.24:8125:8123"   # ClickHouse HTTP (ainult Tailscale)
       - "100.87.1.24:9004:9000"   # native TCP (ainult Tailscale)
+      - "192.168.68.114:8125:8123"   # ClickHouse HTTP (LAN, DBeaver)
+      - "192.168.68.114:9004:9000"   # native TCP (LAN)
     volumes:
       - ./data/clickhouse:/var/lib/clickhouse
       - ./config:/etc/clickhouse-server/config.d
+      - ./users.d/zz-network-compression.xml:/etc/clickhouse-server/users.d/zz-network-compression.xml:ro
       - /etc/localtime:/etc/localtime:ro
       - /usr/share/zoneinfo:/usr/share/zoneinfo:ro
     ulimits:
@@ -131,6 +134,38 @@ services:
       - main_ai_network
       - prod_net
 
+  # Minu kodu veebiliides (jyri.odamus.com): lihtsustatud vaade nutikodu lehest —
+  # ainult Suuremõisa välistemperatuur + identne transpordi vaheleht (FastAPI).
+  # Avalik https://jyri.odamus.com · Tailscale: http://100.87.1.24:8096
+  minukodu-web:
+    build: ./minukodu-web
+    container_name: minukodu-web
+    restart: unless-stopped
+    mem_limit: 256m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_USER=${CH_USER:-zigbee}
+      - OTEL_SERVICE_NAME=minukodu-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
+      # ClickStack collector nõuab OTLP-s authorization: <HYPERDX_INGEST_KEY>
+      - OTEL_EXPORTER_OTLP_HEADERS=authorization=${HYPERDX_INGEST_KEY}
+      - OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=ohmu
+    ports:
+      - "100.87.1.24:8096:8096"
+    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.

+ 15 - 0
dev/minukodu-web/Dockerfile

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

+ 74 - 0
dev/minukodu-web/README.md

@@ -0,0 +1,74 @@
+# Minu kodu veebiliides
+
+Lihtsustatud vaade `nutikodu` lehest (https://nutikodu.odamus.com), eraldi
+lehe jaoks. FastAPI backend + üks staatiline leht. Eestikeelne, mobiilisõbralik,
+tume/hele teema.
+
+## Ligipääs
+
+| Keskkond | URL |
+|----------|-----|
+| Avalik | https://jyri.odamus.com |
+| Tailscale | http://100.87.1.24:8096 |
+
+## Funktsioonid
+
+- **Vaheleht "Andurid"** — ainult üks kaart: Suuremõisa **välistemperatuur**
+  (ClickHouse `uus67.valisilm`, Open-Meteo), 60 s värskendus. Graafikuid ei ole.
+- **Vaheleht "Transport"** — identne nutikodu lehele: tänased bussiväljumised
+  Suuremõisa külast (peatus.ee) ja Heltermaa–Rohuküla praamiajad + seisakud/
+  tühistused (praamid.ee) ning Väinamere ilmahoiatuse kontekst (RII).
+  60 s värskendus.
+- PWA-manifest (installitav telefonis), `noindex`.
+
+## API
+
+| Otspunkt | Kirjeldus |
+|----------|-----------|
+| `GET /api/latest` | Suuremõisa välistemperatuur (hetkeväärtus, avalik) |
+| `GET /api/buss?limit=12` | Suuremõisa küla järgmised bussiväljumised (avalik) |
+| `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (avalik) |
+| `GET /health` | tervis |
+
+## Keskkonnamuutujad
+
+| Muutuja | Vaikimisi | Kirjeldus |
+|---------|-----------|-----------|
+| `CH_URL` | `http://clickhouse:8123` | ClickHouse HTTP |
+| `CH_DB` | `uus67` | andmebaas |
+| `CH_USER` / `CH_PASS` | `zigbee` / – | ainult SELECT `uus67.*` |
+| `BUS_GRAPHQL` | peatus.ee Digitransit | bussigraafiku GraphQL API |
+| `BUS_STOPS` | `estonia:24703,estonia:27213` | Suuremõisa küla GTFS peatus-ID-d (komaga) |
+| `PRAAM_API` | `https://www.praamid.ee/online/events` | praamigraafiku API |
+| `TRANSPORT_TTL` | `60` | bussi/praami väliste päringute vahemälu (sek) |
+
+## Struktuur
+
+```
+minukodu-web/
+├── app.py                 # FastAPI backend (ClickHouse HTTP, JSONEachRow)
+├── requirements.txt
+├── Dockerfile             # python:3.12-slim, port 8096
+└── static/
+    ├── index.html         # üks leht, eestikeelne
+    ├── style.css          # light/dark, mobiil-first
+    ├── app.js             # välistemperatuuri kaart, perioodilüliti, teema, transport
+    ├── manifest.webmanifest
+    └── icon.svg
+```
+
+## Juurutus
+
+Teenus on `dev/docker-compose.yml`-is (võrgud `main_ai_network` + `prod_net`).
+
+```bash
+docker compose -f /opt/docker-compose/dev/docker-compose.yml up -d --build minukodu-web
+docker logs -f minukodu-web
+```
+
+### Avalik ligipääs
+
+- **DNS:** DigitalOcean A-kirje `jyri.odamus.com` → `188.166.84.16`.
+- **Edge Caddy** (`kanad`, `/opt/edge/Caddyfile`): `jyri.odamus.com { reverse_proxy http://100.91.118.16:80 }`.
+- **Sisemine Caddy** (ohmu `prod_caddy`, `/opt/prod/Caddyfile`): `jyri.odamus.com:80 { reverse_proxy minukodu-web:8096 }`.
+- TLS väljastab edge automaatselt.

+ 358 - 0
dev/minukodu-web/app.py

@@ -0,0 +1,358 @@
+"""Minu kodu veebiliides (FastAPI).
+
+Lihtsustatud vaade nutikodu lehest (https://nutikodu.odamus.com), mõeldud
+eraldiseisva lehe jaoks. Loeb ClickHouse'i `uus67` skeemist ainult
+välistemperatuuri (valisilm, Open-Meteo / Suuremõisa) ja serveerib
+transpordi vahelehe (bussid + praamid) identselt nutikodu lehele.
+
+  GET /                 -> veebirakendus (static/index.html)
+  GET /api/latest       -> Suuremõisa välistemperatuur (hetkeväärtus)
+  GET /api/buss         -> Suuremõisa küla järgmised bussiväljumised (avalik)
+  GET /api/praam        -> Heltermaa–Rohuküla väljumised + ilmahoiatuse kontekst
+  GET /health           -> tervis
+
+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).
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import os
+import re
+import threading
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import requests
+from fastapi import FastAPI, HTTPException, Query
+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")
+
+# --- Transport (bussid + praamid) ---
+BUS_GRAPHQL = os.environ.get(
+    "BUS_GRAPHQL", "https://api.peatus.ee/routing/v1/routers/estonia/index/graphql"
+).rstrip("/")
+BUS_STOPS = [s.strip() for s in os.environ.get(
+    "BUS_STOPS", "estonia:24703,estonia:27213").split(",") if s.strip()]
+PRAAM_API = os.environ.get(
+    "PRAAM_API", "https://www.praamid.ee/online/events").rstrip("/")
+RII_HOIATUS_URL = os.environ.get(
+    "RII_HOIATUS_URL", "https://www.ilmateenistus.ee/ilm/prognoosid/hoiatused/"
+)
+TRANSPORT_TTL = int(os.environ.get("TRANSPORT_TTL", "60"))
+UA = "Mozilla/5.0 (compatible; Minukodu-web/1.0)"
+
+PRAAM_DIRECTIONS = {"HR": "Heltermaa → Rohuküla", "RH": "Rohuküla → Heltermaa"}
+SHIP_NAMES = {"TI": "Tiiu", "LE": "Leiger", "RE": "Regu"}
+PRAAM_STATUS = {"CONFIRMED": "plaanipärane", "CANCELLED": "tühistatud", "SUSPENDED": "seisatud"}
+SEA_AREAS = ["Väinameri", "Hiiu maakond", "Lääne maakond"]
+WIND_TYPES = ["tugev tuul", "väga tugev tuul", "torm", "merehoiatus", "tugev tuul ja lained"]
+
+_cache: dict[str, tuple[float, object]] = {}
+_cache_lock = threading.Lock()
+
+
+def cached(key: str, ttl: int, producer):
+    """Lihtne mälusisene TTL-vahemälu väliste API-de jaoks."""
+    now = time.monotonic()
+    with _cache_lock:
+        hit = _cache.get(key)
+        if hit and now - hit[0] < ttl:
+            return hit[1]
+    value = producer()
+    with _cache_lock:
+        _cache[key] = (time.monotonic(), value)
+    return value
+
+
+app = FastAPI(title="Minu kodu", version="1.0.0")
+
+
+@app.middleware("http")
+async def add_no_cache(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
+
+
+@app.get("/api/latest")
+async def latest() -> dict:
+    """Suuremõisa välistemperatuur (valisilm, viimane tunniväärtus)."""
+    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 rows:
+        r = 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 {"outside": outside}
+
+
+# --- Transport: bussid (peatus.ee / Digitransit GraphQL) ---
+
+
+def fetch_bus_departures() -> dict:
+    """Tänased/tulevased bussiväljumised Suuremõisa küla peatustest."""
+    fields = (
+        "name stoptimesWithoutPatterns(numberOfDepartures: 40) { "
+        "serviceDay scheduledDeparture realtimeDeparture realtime headsign "
+        "trip { route { shortName longName } } }"
+    )
+    aliases = " ".join(
+        f's{i}: stop(id: "{sid}") {{ {fields} }}' for i, sid in enumerate(BUS_STOPS)
+    )
+    try:
+        resp = requests.post(
+            BUS_GRAPHQL,
+            json={"query": "{ " + aliases + " }"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException as exc:
+        raise HTTPException(status_code=502, detail=f"Bussi API ühendus: {exc}") from exc
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"Bussi API: HTTP {resp.status_code}")
+    try:
+        payload = resp.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=502, detail="Bussi API: vigane JSON") from exc
+    if payload.get("errors"):
+        msg = payload["errors"][0].get("message", "viga")
+        raise HTTPException(status_code=502, detail=f"Bussi API: {msg}")
+
+    departures = []
+    for stop in (payload.get("data") or {}).values():
+        if not stop or not stop.get("stoptimesWithoutPatterns"):
+            continue
+        stop_name = stop.get("name") or "Suuremõisa"
+        for st in stop["stoptimesWithoutPatterns"]:
+            service_day = st.get("serviceDay")
+            if service_day is None:
+                continue
+            sched_s = st.get("scheduledDeparture") or 0
+            real_s = st.get("realtimeDeparture")
+            dep_s = real_s if st.get("realtime") and real_s is not None else sched_s
+            try:
+                dep_dt = datetime.fromtimestamp(int(service_day) + int(dep_s), TZ)
+            except (OverflowError, OSError, TypeError, ValueError):
+                continue
+            route = (st.get("trip") or {}).get("route") or {}
+            delay = None
+            if st.get("realtime") and real_s is not None:
+                delay = round((int(real_s) - int(sched_s)) / 60)
+            departures.append({
+                "t": int(dep_dt.timestamp() * 1000),
+                "time": dep_dt.strftime("%H:%M"),
+                "date": dep_dt.strftime("%Y-%m-%d"),
+                "stop": stop_name,
+                "route": route.get("shortName") or "",
+                "headsign": st.get("headsign") or "",
+                "realtime": bool(st.get("realtime")),
+                "delay_min": delay,
+            })
+    departures.sort(key=lambda d: d["t"])
+    now = datetime.now(TZ)
+    return {
+        "stops": BUS_STOPS,
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/buss")
+async def buss(limit: int = Query(12, ge=1, le=40)) -> dict:
+    """Järgmised bussiväljumised Suuremõisa külast (avalik)."""
+    data = cached("buss", TRANSPORT_TTL, fetch_bus_departures)
+    return {**data, "departures": data["departures"][:limit]}
+
+
+# --- Transport: praamid (praamid.ee) ---
+
+
+def _praam_events(day: str, direction: str) -> list[dict]:
+    try:
+        resp = requests.get(
+            PRAAM_API,
+            params={"direction": direction, "departure-date": day, "time-shift": "0"},
+            headers={"User-Agent": UA},
+            timeout=18,
+        )
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    try:
+        items = (resp.json() or {}).get("items") or []
+    except ValueError:
+        return []
+    out = []
+    for e in items:
+        try:
+            dep_dt = datetime.strptime((e.get("dtstart") or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=TZ)
+        except ValueError:
+            continue
+        ship = e.get("ship")
+        ship_code = ship.get("code") if isinstance(ship, dict) else ship
+        status = e.get("status") or "?"
+        out.append({
+            "uid": e.get("uid"),
+            "direction": direction,
+            "direction_name": PRAAM_DIRECTIONS.get(direction, direction),
+            "date": dep_dt.strftime("%Y-%m-%d"),
+            "time": dep_dt.strftime("%H:%M"),
+            "t": int(dep_dt.timestamp() * 1000),
+            "ship": SHIP_NAMES.get(ship_code, ship_code or "?"),
+            "ship_code": ship_code,
+            "status": status,
+            "status_et": PRAAM_STATUS.get(status, status),
+            "disrupted": status != "CONFIRMED",
+        })
+    return out
+
+
+def fetch_weather_warnings() -> list[dict]:
+    """RII hoiatusleht -> Väinamere-liiklusega seotud aktiivsed hoiatused (parim pingutus)."""
+    try:
+        resp = requests.get(RII_HOIATUS_URL, headers={"User-Agent": UA}, timeout=18)
+    except requests.RequestException:
+        return []
+    if resp.status_code != 200:
+        return []
+    text = html.unescape(resp.text)
+    text = re.sub(r"<script.*?</script>", " ", text, flags=re.S)
+    text = re.sub(r"<[^>]+>", " ", text)
+    text = re.sub(r"\s+", " ", text)
+    found, seen = [], set()
+    for area in SEA_AREAS:
+        i = 0
+        while True:
+            i = text.find(area, i)
+            if i < 0:
+                break
+            seg = text[max(0, i - 100): i + 700]
+            if any(w in seg.lower() for w in WIND_TYPES):
+                m = re.search(r"Kehtib[^.]{0,200}", seg)
+                valid = m.group(0).strip() if m else ""
+                key = (area, valid or seg[:80])
+                if key not in seen:
+                    seen.add(key)
+                    found.append({"area": area, "valid": valid, "text": seg.strip()[:300]})
+            i += len(area)
+    return found
+
+
+def fetch_praam(days: int) -> dict:
+    now = datetime.now(TZ)
+    day_list = [(now + timedelta(days=n)).strftime("%Y-%m-%d") for n in range(days)]
+    departures = []
+    for day in day_list:
+        for direction in ("HR", "RH"):
+            departures.extend(_praam_events(day, direction))
+    departures.sort(key=lambda d: (d["date"], d["time"]))
+    return {
+        "date": day_list[0],
+        "today": now.strftime("%Y-%m-%d"),
+        "departures": departures,
+        "warnings": fetch_weather_warnings(),
+        "disrupted": sum(1 for d in departures if d["disrupted"]),
+        "updated": now.strftime("%Y-%m-%d %H:%M"),
+    }
+
+
+@app.get("/api/praam")
+async def praam(days: int = Query(1, ge=1, le=3)) -> dict:
+    """Heltermaa–Rohuküla praamiväljumised + ilmahoiatuse kontekst (avalik)."""
+    return cached(f"praam:{days}", TRANSPORT_TTL, lambda: fetch_praam(days))
+
+
+@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=8096, reload=True)

+ 5 - 0
dev/minukodu-web/requirements.txt

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

+ 227 - 0
dev/minukodu-web/static/app.js

@@ -0,0 +1,227 @@
+"use strict";
+
+const $ = (sel) => document.querySelector(sel);
+
+function pad(n) {
+  return String(n).padStart(2, "0");
+}
+
+function setStatus(ok) {
+  const el = $("#status");
+  if (!el) return;
+  el.classList.toggle("ok", ok === true);
+  el.classList.toggle("err", ok === false);
+}
+
+// --- Välistemperatuur (Suuremõisa) ---
+async function loadLatest() {
+  setStatus(null);
+  try {
+    const res = await fetch("/api/latest");
+    if (!res.ok) throw new Error(await res.text());
+    const data = await res.json();
+    renderCards(data);
+    setStatus(true);
+  } catch (e) {
+    console.error(e);
+    setStatus(false);
+  }
+}
+
+function renderCards(data) {
+  const host = $("#cards");
+  if (!host) return;
+  const out = data.outside || {};
+  const temp = out.temperature == null ? "–" : out.temperature;
+  const updated = out.hour ? `<div class="card-sub">Uuendatud ${out.hour}</div>` : "";
+  host.innerHTML = `<div class="card out">
+      <div class="k">🌤️ Välistemperatuur</div>
+      <div class="v">${temp}<small> °C</small></div>
+      <div class="card-sub">Suuremõisa</div>
+      ${updated}
+    </div>`;
+}
+
+// --- Teema ---
+function setTheme(theme) {
+  document.documentElement.setAttribute("data-theme", theme);
+  localStorage.setItem("minukodu-theme", theme);
+  const toggle = $("#theme-toggle");
+  if (toggle) toggle.textContent = theme === "dark" ? "☀️" : "🌙";
+  const meta = $("#meta-theme");
+  if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
+}
+
+// --- Vahelehed (tabid): Andurid | Transport ---
+const transport = { active: false, days: 1 };
+
+function switchView(view) {
+  const isTransport = view === "transport";
+  document.querySelectorAll("#tabs .tab").forEach((b) => {
+    b.classList.toggle("active", b.dataset.view === view);
+  });
+  const vAnd = document.getElementById("view-andurid");
+  const vTr = document.getElementById("view-transport");
+  if (vAnd) vAnd.hidden = isTransport;
+  if (vTr) vTr.hidden = !isTransport;
+  transport.active = isTransport;
+  if (location.hash !== "#" + view) history.replaceState(null, "", "#" + view);
+  if (isTransport) loadTransport();
+}
+
+function initTabs() {
+  document.querySelectorAll("#tabs .tab").forEach((btn) => {
+    btn.addEventListener("click", () => switchView(btn.dataset.view));
+  });
+  const days = document.getElementById("praam-days");
+  if (days) {
+    days.querySelectorAll("button").forEach((b) => {
+      b.addEventListener("click", () => {
+        days.querySelectorAll("button").forEach((x) => x.classList.remove("active"));
+        b.classList.add("active");
+        transport.days = Number(b.dataset.days) || 1;
+        loadPraam();
+      });
+    });
+  }
+  setInterval(() => {
+    if (transport.active) loadTransport();
+  }, 60000);
+  switchView(location.hash === "#transport" ? "transport" : "andurid");
+}
+
+function loadTransport() {
+  loadBus();
+  loadPraam();
+}
+
+function fmtDayLabel(iso, today) {
+  if (!iso || iso === today) return "";
+  const d = new Date(iso + "T00:00:00");
+  const t = new Date((today || iso) + "T00:00:00");
+  const diff = Math.round((d - t) / 864e5);
+  if (diff === 1) return "homme";
+  return d.toLocaleDateString("et-EE", { weekday: "short", day: "numeric", month: "short" });
+}
+
+function setTransportUpdated(updated) {
+  const el = document.getElementById("transport-updated");
+  if (el && updated) el.textContent = `Uuendatud ${updated}`;
+}
+
+async function loadBus() {
+  const hint = document.getElementById("bus-hint");
+  try {
+    const res = await fetch("/api/buss?limit=12");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderBus(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Bussiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderBus(data) {
+  const list = document.getElementById("bus-list");
+  const hint = document.getElementById("bus-hint");
+  if (!list) return;
+  const rows = data.departures || [];
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Täna enam busse ei välju."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  list.innerHTML = rows.map((d) => {
+    const day = fmtDayLabel(d.date, data.today);
+    const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+    let rt = "";
+    if (d.realtime && d.delay_min != null && d.delay_min !== 0) {
+      rt = d.delay_min > 0
+        ? `<span class="badge warn">+${d.delay_min} min</span>`
+        : `<span class="badge ok">${d.delay_min} min</span>`;
+    }
+    return `<div class="dep">
+      <span class="dep-time">${d.time}</span>
+      <span class="dep-route">${d.route || ""}</span>
+      <span class="dep-dest">→ ${d.headsign || "?"}</span>
+      ${rt}${dayBadge}
+    </div>`;
+  }).join("");
+}
+
+async function loadPraam() {
+  const hint = document.getElementById("praam-hint");
+  try {
+    const res = await fetch(`/api/praam?days=${transport.days}`);
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderPraam(await res.json());
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Praamiaegade laadimine ebaõnnestus."; }
+  }
+}
+
+function renderPraam(data) {
+  const list = document.getElementById("praam-list");
+  const hint = document.getElementById("praam-hint");
+  const warn = document.getElementById("praam-warning");
+  const rows = data.departures || [];
+
+  if (warn) {
+    if (Array.isArray(data.warnings) && data.warnings.length) {
+      warn.hidden = false;
+      warn.innerHTML = "⚠️ <b>Ilmahoiatuse kontekst (Väinameri)</b>" +
+        data.warnings.map((w) => `<div>• ${w.area}: ${w.valid || w.text}</div>`).join("") +
+        `<div class="warn-sub">Praamiliiklus võib häiruda — kontrolli ` +
+        `<a href="https://praamid.ee" target="_blank" rel="noopener">praamid.ee</a>.</div>`;
+    } else {
+      warn.hidden = true;
+    }
+  }
+
+  if (!rows.length) {
+    list.innerHTML = "";
+    if (hint) { hint.hidden = false; hint.textContent = "Väljumisi ei leitud."; }
+    return;
+  }
+  if (hint) hint.hidden = true;
+  setTransportUpdated(data.updated);
+
+  const groups = {};
+  for (const d of rows) (groups[d.direction] = groups[d.direction] || []).push(d);
+  list.innerHTML = ["HR", "RH"].filter((k) => groups[k]).map((k) => {
+    const items = groups[k].map((d) => {
+      const day = fmtDayLabel(d.date, data.today);
+      const dayBadge = day ? `<span class="badge muted">${day}</span>` : "";
+      const st = d.disrupted
+        ? `<span class="badge err">${d.status_et}</span>`
+        : `<span class="badge ok">${d.status_et}</span>`;
+      return `<div class="dep">
+        <span class="dep-time">${d.time}</span>
+        <span class="dep-ship">${d.ship || ""}</span>
+        <span class="dep-dest"></span>
+        ${st}${dayBadge}
+      </div>`;
+    }).join("");
+    return `<div class="dep-group"><div class="dep-group-title">${groups[k][0].direction_name}</div>${items}</div>`;
+  }).join("");
+}
+
+function init() {
+  const saved = localStorage.getItem("minukodu-theme");
+  const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+  setTheme(saved || (prefersDark ? "dark" : "light"));
+  const toggle = $("#theme-toggle");
+  if (toggle) {
+    toggle.addEventListener("click", () => {
+      setTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+    });
+  }
+
+  initTabs();
+  loadLatest();
+  setInterval(loadLatest, 60000);
+}
+
+document.addEventListener("DOMContentLoaded", init);

+ 6 - 0
dev/minukodu-web/static/icon.svg

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

+ 71 - 0
dev/minukodu-web/static/index.html

@@ -0,0 +1,71 @@
+<!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>Minu kodu</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>Minu kodu</h1>
+        <div class="sub" id="subtitle">Suuremõisa</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>
+    <nav class="tabs" id="tabs" aria-label="Vahelehed">
+      <button type="button" class="tab active" data-view="andurid">🏠 Andurid</button>
+      <button type="button" class="tab" data-view="transport">🚌 Transport</button>
+    </nav>
+
+    <div id="view-andurid" class="view">
+      <section class="cards single" id="cards"></section>
+
+      <footer class="foot">
+        <span id="range-label"></span>
+      </footer>
+    </div>
+
+    <div id="view-transport" class="view" hidden>
+      <section class="panel">
+        <h2 class="panel-title">🚌 Suuremõisa küla — bussid</h2>
+        <div id="bus-list" class="dep-list"></div>
+        <div id="bus-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <div class="panel-head">
+          <h2 class="panel-title">🚢 Heltermaa – Rohuküla</h2>
+          <div class="seg" id="praam-days">
+            <button type="button" data-days="1" class="active">Täna</button>
+            <button type="button" data-days="2">Täna + homme</button>
+          </div>
+        </div>
+        <div id="praam-warning" class="warn-banner" hidden></div>
+        <div id="praam-list" class="dep-list"></div>
+        <div id="praam-hint" class="hint" hidden></div>
+      </section>
+
+      <footer class="foot">
+        <span id="transport-updated"></span>
+      </footer>
+    </div>
+  </main>
+
+  <script src="/app.js"></script>
+</body>
+</html>

+ 12 - 0
dev/minukodu-web/static/manifest.webmanifest

@@ -0,0 +1,12 @@
+{
+  "name": "Minu kodu",
+  "short_name": "Minu kodu",
+  "description": "Suuremõisa välistemperatuur ja transport",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#0e1116",
+  "theme_color": "#1f6feb",
+  "icons": [
+    { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
+  ]
+}

+ 217 - 0
dev/minukodu-web/static/style.css

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

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

@@ -25,6 +25,9 @@ tume/hele teema.
 - **Vaheleht "Transport"** — tänased bussiväljumised Suuremõisa külast (peatus.ee)
   ja Heltermaa–Rohuküla praamiajad + seisakud/tühistused (praamid.ee) ning
   Väinamere ilmahoiatuse kontekst (RII, parim pingutus). 60 s värskendus.
+- **Vaheleht "Värv"** — AS7341 spektri-värvisensori tulemused (`uus67.varvus`):
+  praegune värv, värviruutude ruudustik, kanalite spekter (f1–f8), värvitelg
+  (viimased 24 h tunnipõhiselt) ja kromaatilisuse (x, y) hajuvus. 20 s värskendus.
 - PWA-manifest (installitav telefonis), `noindex`.
 
 ## API
@@ -42,6 +45,8 @@ tume/hele teema.
 | `GET /api/latest` | viimased väärtused (lahtised häired ainult sisselogitult) |
 | `GET /api/buss?limit=12` | Suuremõisa küla järgmised bussiväljumised (peatus.ee, avalik) |
 | `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (praamid.ee, avalik) |
+| `GET /api/varv/latest?limit=60` | AS7341 viimased mõõtmised (rgb, kanalid f1–f8, kromaatilisus) — avalik |
+| `GET /api/varv/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
 | `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `GET /health` | tervis |
 

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

@@ -532,6 +532,68 @@ async def uus67_latest() -> dict:
     }
 
 
+# --- Värvisensor (uus67.varvus, AS7341) ---
+
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+
+
+@app.get("/api/varv/latest")
+async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
+    """Värvisensori viimased mõõtmised (avalik)."""
+    cols = ", ".join(VARV_BANDS)
+    rows = ch_query(
+        f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
+        f"rgb_hex, r, g, b, round(x_chrom, 4) AS x_chrom, round(y_chrom, 4) AS y_chrom, "
+        f"{cols}, clear, nir, gain "
+        f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
+    )
+    return {
+        "rows": [
+            {
+                "ts": r["ts"],
+                "t": int(r["t"]),
+                "rgb_hex": r.get("rgb_hex") or "#000000",
+                "r": int(r.get("r") or 0),
+                "g": int(r.get("g") or 0),
+                "b": int(r.get("b") or 0),
+                "x_chrom": r.get("x_chrom"),
+                "y_chrom": r.get("y_chrom"),
+                "bands": {b: r.get(b) for b in VARV_BANDS},
+                "clear": r.get("clear"),
+                "nir": r.get("nir"),
+                "gain": r.get("gain"),
+            }
+            for r in rows
+        ]
+    }
+
+
+@app.get("/api/varv/history")
+async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
+    """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
+    rows = ch_query(
+        f"SELECT toString(toStartOfHour(ts)) AS bucket, "
+        f"toUInt64(toUnixTimestamp(toStartOfHour(ts))) * 1000 AS t, "
+        f"toUInt8(round(avg(r))) AS r, toUInt8(round(avg(g))) AS g, toUInt8(round(avg(b))) AS b, "
+        f"round(avg(x_chrom), 4) AS x_chrom, round(avg(y_chrom), 4) AS y_chrom, count() AS n "
+        f"FROM {CH_DB}.varvus WHERE ts >= now() - INTERVAL {int(hours)} HOUR "
+        f"GROUP BY toStartOfHour(ts) ORDER BY t"
+    )
+    out = []
+    for r in rows:
+        rv, gv, bv = int(r.get("r") or 0), int(r.get("g") or 0), int(r.get("b") or 0)
+        out.append({
+            "ts": r["bucket"],
+            "t": int(r["t"]),
+            "r": rv, "g": gv, "b": bv,
+            "rgb_hex": f"#{rv:02X}{gv:02X}{bv:02X}",
+            "x_chrom": r.get("x_chrom"),
+            "y_chrom": r.get("y_chrom"),
+            "n": int(r.get("n") or 0),
+        })
+    return {"hours": hours, "rows": out}
+
+
 @app.get("/api/latest")
 async def latest(request: Request) -> dict:
     readings = ch_query(

+ 196 - 9
dev/nutikodu-web/static/app.js

@@ -616,22 +616,206 @@ async function toggleAway() {
   }
 }
 
-// --- Vahelehed (tabid): Andurid | Transport ---
+// --- Värvisensor (AS7341, uus67.varvus) ---
+let varvActive = false;
+let chartVarvSpectrum = null;
+let chartVarvChroma = null;
+let varvLatest = [];
+let varvSelected = 0;
+
+const VARV_BANDS = [
+  ["f1", "415 nm", "#7b2ff7"],
+  ["f2", "445 nm", "#3b5bdb"],
+  ["f3", "480 nm", "#1c7ed6"],
+  ["f4", "515 nm", "#2fb344"],
+  ["f5", "555 nm", "#9acd32"],
+  ["f6", "590 nm", "#f0b000"],
+  ["f7", "630 nm", "#e8590c"],
+  ["f8", "680 nm", "#c92a2a"],
+];
+
+async function loadVarv() {
+  const hint = document.getElementById("varv-hint");
+  try {
+    const [r1, r2] = await Promise.all([
+      fetch("/api/varv/latest?limit=60"),
+      fetch("/api/varv/history?hours=24"),
+    ]);
+    if (!r1.ok || !r2.ok) throw new Error("HTTP " + r1.status + "/" + r2.status);
+    varvLatest = (await r1.json()).rows || [];
+    const history = (await r2.json()).rows || [];
+    if (varvSelected >= varvLatest.length) varvSelected = 0;
+    renderVarvCurrent();
+    renderVarvGrid();
+    renderVarvTimeline(history);
+    renderVarvSpectrum();
+    renderVarvChroma(varvLatest);
+    const upd = document.getElementById("varv-updated");
+    if (upd) upd.textContent = varvLatest.length ? "Viimane mõõtmine " + varvLatest[0].ts : "Andmeid pole";
+    if (hint) hint.hidden = true;
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Värviandmete laadimine ebaõnnestus."; }
+  }
+}
+
+const varvFmt = (v) => (v == null ? "–" : Number(v).toFixed(3));
+
+function renderVarvCurrent() {
+  const sw = document.getElementById("varv-current-swatch");
+  const hex = document.getElementById("varv-hex");
+  const meta = document.getElementById("varv-current-meta");
+  if (!sw) return;
+  if (!varvLatest.length) {
+    sw.style.background = "transparent";
+    if (hex) hex.textContent = "#------";
+    if (meta) meta.textContent = "Andmeid pole.";
+    return;
+  }
+  const c = varvLatest[0];
+  sw.style.background = c.rgb_hex;
+  if (hex) hex.textContent = c.rgb_hex.toUpperCase();
+  if (meta) meta.innerHTML =
+    `${c.ts}<br>RGB ${c.r} · ${c.g} · ${c.b}<br>` +
+    `x = ${varvFmt(c.x_chrom)} · y = ${varvFmt(c.y_chrom)} · gain ${c.gain}`;
+}
+
+function renderVarvGrid() {
+  const grid = document.getElementById("varv-grid");
+  if (!grid) return;
+  grid.innerHTML = varvLatest.map((c, i) =>
+    `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
+    `style="background:${c.rgb_hex}" title="${c.ts}  ${c.rgb_hex}" data-i="${i}"></button>`
+  ).join("");
+  grid.querySelectorAll(".swatch").forEach((el) => {
+    el.addEventListener("click", () => {
+      varvSelected = Number(el.dataset.i) || 0;
+      renderVarvGrid();
+      renderVarvSpectrum();
+    });
+  });
+}
+
+function renderVarvTimeline(rows) {
+  const el = document.getElementById("varv-timeline");
+  if (!el) return;
+  if (!rows.length) {
+    el.innerHTML = '<span class="varv-sub">Viimase 24 h andmeid pole.</span>';
+    return;
+  }
+  el.innerHTML = rows.map((r) => {
+    const d = new Date(r.t);
+    const hh = pad(d.getHours()) + ":00";
+    return `<div class="swatch tl" style="background:${r.rgb_hex}" ` +
+      `title="${r.ts.slice(0, 10)} ${hh}  ${r.rgb_hex} · ${r.n} mõõtmist"></div>`;
+  }).join("");
+}
+
+function renderVarvSpectrum() {
+  const el = document.getElementById("chart-varv-spectrum");
+  if (!el || !varvLatest.length) { if (chartVarvSpectrum) chartVarvSpectrum.clear(); return; }
+  const c = varvLatest[Math.min(varvSelected, varvLatest.length - 1)];
+  const label = document.getElementById("varv-spectrum-label");
+  if (label) label.textContent = `— ${c.ts} · ${c.rgb_hex.toUpperCase()}`;
+  if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" });
+  chartVarvSpectrum.setOption({
+    grid: { left: 56, right: 16, top: 16, bottom: 32 },
+    xAxis: {
+      type: "category",
+      data: VARV_BANDS.map((b) => b[1]),
+      axisLabel: { color: cssVar("--muted") },
+    },
+    yAxis: {
+      type: "value",
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: { trigger: "axis" },
+    series: [{
+      type: "bar",
+      barWidth: "62%",
+      data: VARV_BANDS.map((b) => ({
+        value: c.bands[b[0]] || 0,
+        itemStyle: { color: b[2] },
+      })),
+    }],
+  }, true);
+  chartVarvSpectrum.resize();
+}
+
+function renderVarvChroma(rows) {
+  const el = document.getElementById("chart-varv-chroma");
+  if (!el) return;
+  if (!chartVarvChroma) chartVarvChroma = echarts.init(el, null, { renderer: "canvas" });
+  const pts = rows
+    .filter((r) => r.x_chrom != null && r.y_chrom != null)
+    .map((r) => [Number(r.x_chrom), Number(r.y_chrom), r.ts, r.rgb_hex]);
+  chartVarvChroma.setOption({
+    grid: { left: 52, right: 24, top: 20, bottom: 40 },
+    xAxis: {
+      type: "value", min: 0, max: 0.75, name: "x",
+      nameTextStyle: { color: cssVar("--muted") },
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    yAxis: {
+      type: "value", min: 0, max: 0.85, name: "y",
+      nameTextStyle: { color: cssVar("--muted") },
+      axisLabel: { color: cssVar("--muted") },
+      splitLine: { lineStyle: { color: cssVar("--border") } },
+    },
+    tooltip: {
+      formatter: (p) => {
+        if (p.seriesName === "D65") return "valge punkt D65";
+        return `${p.value[3]}<br>x=${p.value[0].toFixed(3)} y=${p.value[1].toFixed(3)}<br>${p.value[2]}`;
+      },
+    },
+    series: [
+      {
+        name: "mõõtmised",
+        type: "scatter",
+        symbolSize: 11,
+        data: pts,
+        itemStyle: { color: "rgba(31,111,235,0.7)" },
+      },
+      {
+        name: "D65",
+        type: "scatter",
+        symbol: "pin",
+        symbolSize: 26,
+        data: [[0.3127, 0.329]],
+        itemStyle: { color: "#8b949e" },
+      },
+    ],
+  }, true);
+  chartVarvChroma.resize();
+}
+
+window.addEventListener("resize", () => {
+  if (chartVarvSpectrum) chartVarvSpectrum.resize();
+  if (chartVarvChroma) chartVarvChroma.resize();
+});
+
+// --- Vahelehed (tabid): Andurid | Transport | Värv ---
 const transport = { active: false, days: 1 };
+const VIEWS = ["andurid", "transport", "varv"];
 
 function switchView(view) {
-  const isTransport = view === "transport";
+  if (!VIEWS.includes(view)) view = "andurid";
   document.querySelectorAll("#tabs .tab").forEach((b) => {
     b.classList.toggle("active", b.dataset.view === view);
   });
-  const vAnd = document.getElementById("view-andurid");
-  const vTr = document.getElementById("view-transport");
-  if (vAnd) vAnd.hidden = isTransport;
-  if (vTr) vTr.hidden = !isTransport;
-  transport.active = isTransport;
+  for (const v of VIEWS) {
+    const el = document.getElementById("view-" + v);
+    if (el) el.hidden = v !== view;
+  }
+  transport.active = view === "transport";
+  varvActive = view === "varv";
   if (location.hash !== "#" + view) history.replaceState(null, "", "#" + view);
-  if (isTransport) {
+  if (view === "transport") {
     loadTransport();
+  } else if (view === "varv") {
+    loadVarv();
   } else {
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
@@ -657,7 +841,10 @@ function initTabs() {
   setInterval(() => {
     if (transport.active) loadTransport();
   }, 60000);
-  switchView(location.hash === "#transport" ? "transport" : "andurid");
+  setInterval(() => {
+    if (varvActive) loadVarv();
+  }, 20000);
+  switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
 }
 
 function loadTransport() {

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

@@ -31,6 +31,7 @@
     <nav class="tabs" id="tabs" aria-label="Vahelehed">
       <button type="button" class="tab active" data-view="andurid">🏠 Andurid</button>
       <button type="button" class="tab" data-view="transport">🚌 Transport</button>
+      <button type="button" class="tab" data-view="varv">🎨 Värv</button>
     </nav>
 
     <div id="view-andurid" class="view">
@@ -110,6 +111,43 @@
         <span id="transport-updated"></span>
       </footer>
     </div>
+
+    <div id="view-varv" class="view" hidden>
+      <section class="panel">
+        <div class="varv-current">
+          <div id="varv-current-swatch" class="varv-current-swatch"></div>
+          <div class="varv-current-info">
+            <div class="varv-hex" id="varv-hex">#------</div>
+            <div class="varv-meta" id="varv-current-meta">Laadin…</div>
+          </div>
+        </div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🎨 Viimased mõõtmised</h2>
+        <div id="varv-grid" class="swatch-grid"></div>
+        <div id="varv-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">📊 Spekter <span class="varv-sub" id="varv-spectrum-label"></span></h2>
+        <div id="chart-varv-spectrum" style="height:280px" aria-label="Kanali spekter"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🕓 Värvitelg (viimased 24 h)</h2>
+        <div id="varv-timeline" class="swatch-timeline"></div>
+      </section>
+
+      <section class="panel">
+        <h2 class="panel-title">🧭 Kromaatilisus (x, y)</h2>
+        <div id="chart-varv-chroma" style="height:340px" aria-label="Kromaatilisuse hajuvus"></div>
+      </section>
+
+      <footer class="foot">
+        <span id="varv-updated"></span>
+      </footer>
+    </div>
   </main>
 
   <script src="/vendor/echarts.min.js"></script>

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

@@ -213,3 +213,34 @@ main > * { min-width: 0; }
   .dep-time { min-width: 44px; font-size: 15px; }
   .dep-route { min-width: 0; }
 }
+
+/* --- Värvisensor (varv) --- */
+.varv-current { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
+.varv-current-swatch {
+  width: 110px; height: 110px; border-radius: 14px;
+  border: 1px solid var(--border); flex: 0 0 auto; box-shadow: var(--shadow);
+}
+.varv-current-info { min-width: 0; }
+.varv-hex {
+  font-size: 26px; font-weight: 700; letter-spacing: .5px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.varv-meta { color: var(--muted); font-size: 13px; margin-top: 6px; line-height: 1.6; }
+.varv-sub { color: var(--muted); font-size: 13px; font-weight: 400; }
+
+.swatch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)); gap: 6px; }
+.swatch {
+  aspect-ratio: 1 / 1; width: 100%; border-radius: 8px; border: 1px solid var(--border);
+  cursor: pointer; padding: 0; transition: transform .08s ease;
+}
+.swatch:active { transform: scale(.95); }
+.swatch.sel { outline: 2px solid var(--accent); outline-offset: 1px; }
+
+.swatch-timeline { display: flex; flex-wrap: wrap; gap: 4px; }
+.swatch-timeline .swatch { width: 30px; height: 30px; aspect-ratio: auto; cursor: default; }
+
+@media (max-width: 520px) {
+  .varv-current-swatch { width: 84px; height: 84px; }
+  .varv-hex { font-size: 22px; }
+  .swatch-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
+}

+ 11 - 0
dev/users.d/zz-network-compression.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<clickhouse>
+    <profiles>
+        <default>
+            <!-- ClickHouse 26.x vaikimisi network_compression_method=ZSTD
+                 murdis vanemad clickhouse-jdbc / DBeaver klientid (eeldavad
+                 LZ4 -> "Invalid LZ4 magic byte"). Tagasi klassikaliseks LZ4-ks. -->
+            <network_compression_method>lz4</network_compression_method>
+        </default>
+    </profiles>
+</clickhouse>

+ 5 - 0
dev/varvusensor/.gitignore

@@ -0,0 +1,5 @@
+# ESPHome arenduskeskkonna jäägid
+.venv/
+compile.log
+# mosquitto paroolihash (loodud eraldi, vt LOEMIND)
+mosquitto/passwd

+ 10 - 0
dev/varvusensor/collector/Dockerfile

@@ -0,0 +1,10 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY collector.py color.py ./
+
+CMD ["python", "-u", "collector.py"]

+ 128 - 0
dev/varvusensor/collector/calibrate.py

@@ -0,0 +1,128 @@
+"""Kalibreerimise abivahend AS7341 värviandurile.
+
+Loeb ClickHouse'ist (vaikimisi uus67.varvus) ja toetab:
+  * capture-white: salvesta valge referents (viimased N mõõtmist) -> calibration.json
+  * show:          näita viimaseid mõõtmisi (toores hex vs kalibreeritud hex)
+
+Kalibreeritud värvus = iga kanal jagatud valge referentsiga (reflectance),
+mis eemaldab valgustuse värvitooni ja anduri kanalite erinevuse.
+
+Käivitamine (projekti .venv):
+  CH_URL=http://100.87.1.24:8125 CH_USER=zigbee CH_PASS=... \
+    .venv/bin/python varvusensor/collector/calibrate.py capture-white --last 5
+  CH_URL=http://100.87.1.24:8125 CH_USER=zigbee CH_PASS=... \
+    .venv/bin/python varvusensor/collector/calibrate.py show --last 10
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import statistics
+import sys
+from pathlib import Path
+
+import requests
+
+import color
+from settings import CH_URL, CH_DB, CH_TABLE, CH_USER, CH_PASS
+
+CALIB_FILE = Path(
+    os.environ.get("CALIB_FILE", Path(__file__).with_name("calibration.json"))
+)
+
+FIELDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "clear", "nir")
+BANDS = tuple(color.CHANNEL_WAVELENGTHS)  # f1..f8
+
+
+def ch_query(sql: str) -> str:
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": sql},
+        auth=(CH_USER, CH_PASS),
+        timeout=15,
+    )
+    resp.raise_for_status()
+    return resp.text
+
+
+def fetch_rows(last: int) -> list[dict]:
+    cols = ", ".join(FIELDS + ("rgb_hex",))
+    sql = (
+        f"SELECT ts, {cols} FROM {CH_DB}.{CH_TABLE} "
+        f"ORDER BY ts DESC LIMIT {int(last)} FORMAT JSONEachRow"
+    )
+    return [json.loads(line) for line in ch_query(sql).strip().splitlines() if line]
+
+
+def capture_white(last: int) -> int:
+    rows = fetch_rows(last)
+    if not rows:
+        print("Andmeid pole — mõõda esmalt valget pinda.")
+        return 1
+    ref: dict[str, float] = {}
+    for field in FIELDS:
+        values = [r[field] for r in rows if r.get(field) is not None]
+        ref[field] = round(statistics.mean(values), 3) if values else 0.0
+    payload = {"reference_rows": len(rows), "white": ref}
+    CALIB_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
+    print(f"Valge referents salvestatud ({len(rows)} reast) -> {CALIB_FILE}")
+    print(json.dumps(ref, indent=2))
+    return 0
+
+
+def load_white() -> dict[str, float] | None:
+    if not CALIB_FILE.exists():
+        return None
+    return json.loads(CALIB_FILE.read_text()).get("white")
+
+
+def reflectance(channels: dict[str, float], white: dict[str, float]) -> dict[str, float]:
+    out: dict[str, float] = {}
+    for band in BANDS:
+        ref = white.get(band, 0.0)
+        value = channels.get(band)
+        if value is not None and ref:
+            out[band] = value / ref
+    return out
+
+
+def show(last: int) -> int:
+    rows = fetch_rows(last)
+    white = load_white()
+    if white is None:
+        print("(kalibratsioon puudub — näitan ainult toorest; tee capture-white)")
+    for row in rows:
+        channels = {b: row[b] for b in BANDS if row.get(b) is not None}
+        raw = color.analyze(channels)
+        line = (
+            f"{row['ts']}  toores {raw['rgb_hex']} "
+            f"(x={raw['x_chrom']:.3f} y={raw['y_chrom']:.3f})"
+        )
+        if white is not None:
+            cal = color.analyze(reflectance(channels, white))
+            line += (
+                f"  | kalib. {cal['rgb_hex']} "
+                f"(x={cal['x_chrom']:.3f} y={cal['y_chrom']:.3f})"
+            )
+        print(line)
+    return 0
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="AS7341 kalibreerimine")
+    sub = parser.add_subparsers(dest="cmd", required=True)
+    cw = sub.add_parser("capture-white", help="salvesta valge referents")
+    cw.add_argument("--last", type=int, default=5)
+    sh = sub.add_parser("show", help="näita viimaseid mõõtmisi")
+    sh.add_argument("--last", type=int, default=10)
+    args = parser.parse_args()
+    if args.cmd == "capture-white":
+        return capture_white(args.last)
+    if args.cmd == "show":
+        return show(args.last)
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 106 - 0
dev/varvusensor/collector/collector.py

@@ -0,0 +1,106 @@
+"""AS7341 värvianduri koguja: MQTT -> ClickHouse (uus67.varvus).
+
+Tellib MQTT-teemat (vaikimisi varvus/spectral), kus ESPHome saadab
+JSON-i kanalitega f1..f8, clear, nir (+ gain). Iga sonum = uks rida.
+Lisaks arvutatakse ligikaudne värvus (CIE XYZ -> sRGB, vt color.py).
+
+Tellimus tehakse on_connect-is, et see taastuks ka parast brokeri
+taaskäivitust / ühenduse katkemist (clean session).
+"""
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+import paho.mqtt.client as mqtt
+import requests
+
+import color
+
+MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
+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", "varvus/spectral")
+MQTT_TLS = os.environ.get("MQTT_TLS", "false").strip().lower() in ("1", "true", "yes", "on")
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_DB = os.environ.get("CH_DB", "uus67")
+CH_TABLE = os.environ.get("CH_TABLE", "varvus")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+
+TZ = ZoneInfo("Europe/Tallinn")
+FIELDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "clear", "nir")
+
+
+def insert(row: dict) -> None:
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{CH_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row, ensure_ascii=False) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    resp.raise_for_status()
+
+
+def on_connect(client, userdata, flags, reason_code, properties=None) -> None:
+    if reason_code == 0:
+        client.subscribe(MQTT_TOPIC, qos=0)
+        print(f"MQTT ühendatud, tellitud {MQTT_TOPIC}", flush=True)
+    else:
+        print(f"MQTT ühenduse viga: {reason_code}", flush=True)
+
+
+def on_message(client, userdata, msg) -> None:
+    try:
+        payload = json.loads(msg.payload.decode())
+    except Exception as exc:
+        print(f"JSON viga: {exc}", flush=True)
+        return
+    row: dict = {
+        "ts": datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S"),
+        "device": "varvusensor",
+    }
+    channels: dict[str, float] = {}
+    for field in FIELDS:
+        value = payload.get(field)
+        if value is not None:
+            channels[field] = float(value)
+            row[field] = float(value)
+    gain = payload.get("gain")
+    if gain is not None:
+        row["gain"] = int(gain)
+    row.update(color.analyze(channels))
+    try:
+        insert(row)
+        print(
+            f"salvestatud: {row['rgb_hex']} x={row['x_chrom']} y={row['y_chrom']} "
+            f"f5={row.get('f5')} f6={row.get('f6')}",
+            flush=True,
+        )
+    except Exception as exc:
+        print(f"ClickHouse viga: {exc}", flush=True)
+
+
+def main() -> None:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
+    if MQTT_TLS:
+        client.tls_set()
+    if MQTT_USER:
+        client.username_pw_set(MQTT_USER, MQTT_PASS)
+    client.on_connect = on_connect
+    client.on_message = on_message
+    client.connect(MQTT_HOST, MQTT_PORT, 60)
+    print(f"Koguja käivitub, broker {MQTT_HOST}:{MQTT_PORT} (TLS={MQTT_TLS})", flush=True)
+    client.loop_forever()
+
+
+if __name__ == "__main__":
+    main()

+ 101 - 0
dev/varvusensor/collector/color.py

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

+ 95 - 0
dev/varvusensor/collector/live.py

@@ -0,0 +1,95 @@
+"""Reaalajas AS7341 värvinäitaja.
+
+Loeb ClickHouse'ist viimase mõõtmise ja kuvab terminalis värvinäidise (24-bitine
+ANSI) + kanalite spektri. Mõeldud katseteks (eri värvi pindade mõõtmine).
+
+Käivitamine (projekti .venv):
+  CH_URL=http://100.87.1.24:8125 CH_USER=zigbee CH_PASS=<parool> \
+    .venv/bin/python varvusensor/collector/live.py [--interval 2] [--once]
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import time
+
+import requests
+
+import color
+from settings import CH_URL, CH_DB, CH_TABLE, CH_USER, CH_PASS
+
+BANDS = color.CHANNEL_WAVELENGTHS  # {f1: 415, ...}
+RESET = "\x1b[0m"
+
+
+def fetch_latest() -> dict | None:
+    cols = ", ".join(BANDS)
+    sql = (
+        f"SELECT ts, {cols}, rgb_hex, x_chrom, y_chrom, r, g, b "
+        f"FROM {CH_DB}.{CH_TABLE} ORDER BY ts DESC LIMIT 1 FORMAT JSONEachRow"
+    )
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": sql},
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    resp.raise_for_status()
+    lines = [line for line in resp.text.strip().splitlines() if line]
+    return json.loads(lines[0]) if lines else None
+
+
+def swatch(r: int, g: int, b: int, width: int = 28, height: int = 3) -> str:
+    block = f"\x1b[48;2;{r};{g};{b}m" + " " * width + RESET
+    return "\n".join([block] * height)
+
+
+def spectrum_bar(channels: dict[str, float]) -> str:
+    vmax = max((channels.get(band) or 0.0) for band in BANDS) or 1.0
+    lines = []
+    for band, wavelength in BANDS.items():
+        value = channels.get(band) or 0.0
+        bars = int(round(40 * value / vmax))
+        lines.append(f"  {band} {wavelength:3d}nm |{'█' * bars:<40}| {value:8.1f}")
+    return "\n".join(lines)
+
+
+def render(row: dict) -> str:
+    r, g, b = int(row["r"]), int(row["g"]), int(row["b"])
+    channels = {band: row.get(band) for band in BANDS}
+    return "\n".join(
+        [
+            f"  Aeg:     {row['ts']}",
+            f"  Värvus:  {row['rgb_hex']}  x={row['x_chrom']:.3f} y={row['y_chrom']:.3f}",
+            "",
+            swatch(r, g, b),
+            "",
+            "  Spekter (kanalid):",
+            spectrum_bar(channels),
+        ]
+    )
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="AS7341 reaalajas värvinäitaja")
+    parser.add_argument("--interval", type=float, default=2.0, help="värskendus sekundites")
+    parser.add_argument("--once", action="store_true", help="kuva üks kord ja välju")
+    args = parser.parse_args()
+    try:
+        while True:
+            row = fetch_latest()
+            os.system("clear")
+            print("AS7341 värvinäitaja  (Ctrl+C väljumiseks)")
+            print("=" * 54)
+            print(render(row) if row else "  Andmeid pole.")
+            if args.once:
+                break
+            time.sleep(args.interval)
+    except KeyboardInterrupt:
+        pass
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 2 - 0
dev/varvusensor/collector/requirements.txt

@@ -0,0 +1,2 @@
+paho-mqtt>=2.0,<3
+requests>=2.31

+ 45 - 0
dev/varvusensor/collector/settings.py

@@ -0,0 +1,45 @@
+"""Ühised seaded CH-tööriistadele (live.py, calibrate.py jne).
+
+Loeb .env failist (kui olemas) kasutaja/parooli, siis keskkonnamuutujatest.
+CH_URL jäetakse .env-ist vahele (seal on see konteineri jaoks `clickhouse`);
+vaikimisi kasutame Tailscale IP-d, mis töötab nii ohmu hostil kui mujal
+tailnetis. Vajadusel saab CH_URL-i ikka keskkonnamuutujaga üle kirjutada.
+
+NB! Ilma auth-ita ei saa: sea CH_USER/CH_PASS (või pane need .env-i).
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+_ENV_CANDIDATES = (
+    Path(__file__).resolve().parents[1] / ".env",        # varvusensor/.env
+    Path("/opt/docker-compose/dev/varvusensor/.env"),     # ohmu live-stack
+)
+# Konteinerispetsiifilised võtmed, mida host-tööriistadele ei laadita.
+_SKIP_FROM_ENV = {"CH_URL", "MQTT_HOST", "MQTT_PORT", "MQTT_TOPIC"}
+
+
+def _load_env() -> None:
+    for path in _ENV_CANDIDATES:
+        if not path.is_file():
+            continue
+        for line in path.read_text().splitlines():
+            line = line.strip()
+            if not line or line.startswith("#") or "=" not in line:
+                continue
+            key, _, value = line.partition("=")
+            key = key.strip()
+            if key in _SKIP_FROM_ENV:
+                continue
+            os.environ.setdefault(key, value.strip())
+        break
+
+
+_load_env()
+
+CH_URL = os.environ.get("CH_URL", "http://100.87.1.24:8125").rstrip("/")
+CH_DB = os.environ.get("CH_DB", "uus67")
+CH_TABLE = os.environ.get("CH_TABLE", "varvus")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")

+ 5 - 0
dev/varvusensor/config/.gitignore

@@ -0,0 +1,5 @@
+# Gitignore settings for ESPHome
+# This is an example and may include too much for your use-case.
+# You can modify this file to suit your needs.
+/.esphome/
+/secrets.yaml

+ 212 - 0
dev/varvusensor/config/varvusensor.yaml

@@ -0,0 +1,212 @@
+substitutions:
+  name: varvusensor
+  friendly_name: Varvusensor
+  mqtt_prefix: varvus
+  gain_num: 256
+
+esphome:
+  name: ${name}
+  friendly_name: ${friendly_name}
+
+esp32:
+  board: esp32-c6-devkitc-1
+  variant: esp32c6
+  framework:
+    type: esp-idf
+
+logger:
+  level: DEBUG
+  hardware_uart: USB_SERIAL_JTAG
+
+api:
+  encryption:
+    key: !secret api_key
+
+ota:
+  - platform: esphome
+    password: !secret fallback_ap_password
+
+wifi:
+  networks:
+    - ssid: !secret wifi_ssid
+      password: !secret wifi_password
+    - ssid: "SIPELGAS"
+      password: "misonminuwifinimi"
+    - ssid: "Ristumine peateega"
+      password: "Mikihiir"
+  ap:
+    ssid: "${friendly_name} Fallback"
+    password: !secret fallback_ap_password
+
+captive_portal:
+
+# Avalik MQTT broker edge-serveril (TLS 8883) — toimib igast võrgust.
+mqtt:
+  broker: mqtt.odamus.com
+  port: 8883
+  username: !secret mqtt_user
+  password: !secret mqtt_pass
+  certificate_authority: |-
+    -----BEGIN CERTIFICATE-----
+    MIICizCCAhGgAwIBAgIQXd1w3TH4AchcGGp6BLgK/jAKBggqhkjOPQQDAzAuMQsw
+    CQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZRTAeFw0y
+    NTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYD
+    VQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZRTEwdjAQBgcqhkjOPQIBBgUr
+    gQQAIgNiAAQHZVB1/mimla2hfSurylScjPMZaOJXLz/NnAc2sylm8WDyhU9Ccp+z
+    ASQi5vSwGGJjSGklkD9fdPR8GpyDIOIjCEfrnbt/v+ZSEPLLEGbaM6EccDbN7p9x
+    teIm2Avf+ryjge4wgeswDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
+    BwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLsgykcL/tflnPmPCSqj
+    jDdFsbzYMB8GA1UdIwQYMBaAFKPIJlqOoUzQNWP8myPIOq5W809WMDIGCCsGAQUF
+    BwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3llLmkubGVuY3Iub3JnLzATBgNV
+    HSAEDDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veWUuYy5s
+    ZW5jci5vcmcvMAoGCCqGSM49BAMDA2gAMGUCMQDgjUEahFT/h3DRakqiPZpLvPgf
+    Zwkt6K2EOMmh1nvEzl83eMLYcod4GCl3b0J1Nn0CMBNYmEQJb4CEG5WoOe7aRn/L
+    VKu6saHmHEynI7ysIPd8zQsK1HdmhlHKlw9Z5GpGvA==
+    -----END CERTIFICATE-----
+    -----BEGIN CERTIFICATE-----
+    MIICpjCCAiugAwIBAgIRAIchZfw0tuX7qK3Vs3BftTowCgYIKoZIzj0EAwMwTzEL
+    MAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNo
+    IEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDIwHhcNMjYwNTEzMDAwMDAwWhcN
+    MzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4G
+    A1UEAxMHUm9vdCBZRTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDwS/6vhrcVqcbBo
+    +wgdI3fwn9x7DNJJOY/lTOti0vkwuRN87RhEhTH17E7XyFjWsPYhIPt/wzOqxTd2
+    b+4ZJNy9ID04YywF9U5zasDVyGSNErVNtz8uSGh5izW87j77GaOB6zCB6DAOBgNV
+    HQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB
+    /zAdBgNVHQ4EFgQUo8gmWo6hTNA1Y/ybI8g6rlbzT1YwHwYDVR0jBBgwFoAUfEKW
+    rt5LSDv6kviejM9ti6lyN5UwMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZo
+    dHRwOi8veDIuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1Ud
+    HwQgMB4wHKAaoBiGFmh0dHA6Ly94Mi5jLmxlbmNyLm9yZy8wCgYIKoZIzj0EAwMD
+    aQAwZgIxAMU19WCtmxVND8UHBZRoma49Z7jPs64Dma0eTu1OChVbB/2J7GV3nvYK
+    Ax54uk1G9QIxAO0miLVJu8PLNiXXXkiE/gsK3CTRTF/aeo4bMX42Zw40csRU6AC2
+    6hSW1/IWaas6dg==
+    -----END CERTIFICATE-----
+    -----BEGIN CERTIFICATE-----
+    MIIEcDCCAligAwIBAgIQbI8dxyfHEX97r4U6yYD5zTANBgkqhkiG9w0BAQsFADBP
+    MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
+    Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNjA1MTMwMDAwMDBa
+    Fw0zMjA5MDIyMzU5NTlaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5l
+    dCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgy
+    MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0H
+    ttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7
+    AlF9ItgKbppbd9/w+kHsOdx1ymgHDB/qo4H1MIHyMA4GA1UdDwEB/wQEAwIBBjAd
+    BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd
+    BgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwHwYDVR0jBBgwFoAUebRZ5nu2
+    5eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRw
+    Oi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1UdHwQg
+    MB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcNAQELBQAD
+    ggIBAD2/e9frmMxNpCV03qUHegg+MV2wz9644YoXdqtH8RyWYcBO7xfjjGEXdU1e
+    /o0OkEFiynUCOSIk/vLLo7ttz6CPAeNlWfC0XNkoGeWgK6jjXvozBaGuGH5n0Ufo
+    shMeWTuURqNN5G00sSXDTBrpp2+mgvdZQjb8K11TYMA25QA+YHNfbIEL0BniAhKS
+    2gsnJjSzrdZLI+EZ7SEyqdR2rkjd1KutLDU+n3TFyxjniZVGur4YlhMP3mY/dV95
+    IruAkkjOZier6hGBdEgZXXvaCz9u9iVEadsIE75pAGL8oHV5vxdARDiotRpul1IN
+    /UZwzAbrfUFcw1HkAcYD/mlZfnQ2ieCF2MS7j3Vhv7JPDKp45fmykmzYNSrumRW0
+    upFFKDBOoF7hsOb7oLyHS+Uft6jOUfOrogj8YUx38hKb2K20r42OgsSdDdxdeYWc
+    MS3Sb6mwJeSZEYxJ2gaXnDSPaKhhrNkYwljyVQyr4Nq+MEJytXNTnHqaAcrNwZlV
+    pcJL1KBnMrMjP7eanvUwL3FYj3cF17jtboLt7gLoi4+2rWZFvn+w54jmd/FIuhhZ
+    cEaU/wvU6BUNMtcVquVGHp7itQeDth5j+XL3j4WJ2SABwzUl6OeYdgpIt/ITZa+p
+    TT0mQ/r5XyA4MEAiabn7XJjvCERlF2dcn2wqJw+CreTkkQ2R
+    -----END CERTIFICATE-----
+    -----BEGIN CERTIFICATE-----
+    MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
+    TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
+    cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
+    WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
+    ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
+    MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
+    h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
+    0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
+    A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
+    T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
+    B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
+    B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
+    KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
+    OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
+    jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
+    qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
+    rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
+    HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
+    hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
+    ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
+    3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
+    NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
+    ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
+    TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
+    jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
+    oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
+    4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
+    mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
+    emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
+    -----END CERTIFICATE-----
+
+i2c:
+  sda: GPIO6
+  scl: GPIO7
+  scan: true
+  frequency: 100kHz
+
+sensor:
+  # AS7341 saadab madala baidi esimesena (little-endian), aga ESPHome i2ctohs
+  # vahetab baidid -> lisame lambdas vahetuse tagasi, et saada toelised 16-bit vaartused.
+  - platform: as7341
+    gain: X256
+    f1:
+      name: "${friendly_name} F1 415nm"
+      id: ch_f1
+      filters: &as7341_swap
+        - lambda: |-
+            uint16_t v = (uint16_t) x;
+            return (float) (uint16_t) ((v >> 8) | ((v & 0xFF) << 8));
+    f2:
+      name: "${friendly_name} F2 445nm"
+      id: ch_f2
+      filters: *as7341_swap
+    f3:
+      name: "${friendly_name} F3 480nm"
+      id: ch_f3
+      filters: *as7341_swap
+    f4:
+      name: "${friendly_name} F4 515nm"
+      id: ch_f4
+      filters: *as7341_swap
+    f5:
+      name: "${friendly_name} F5 555nm"
+      id: ch_f5
+      filters: *as7341_swap
+    f6:
+      name: "${friendly_name} F6 590nm"
+      id: ch_f6
+      filters: *as7341_swap
+    f7:
+      name: "${friendly_name} F7 630nm"
+      id: ch_f7
+      filters: *as7341_swap
+    f8:
+      name: "${friendly_name} F8 680nm"
+      id: ch_f8
+      filters: *as7341_swap
+    clear:
+      name: "${friendly_name} Clear"
+      id: ch_clear
+      filters: *as7341_swap
+    nir:
+      name: "${friendly_name} NIR"
+      id: ch_nir
+      filters: *as7341_swap
+
+interval:
+  - interval: 5s
+    then:
+      - mqtt.publish_json:
+          topic: ${mqtt_prefix}/spectral
+          payload: |-
+            if (!isnan(id(ch_f1).state))    root["f1"]    = id(ch_f1).state;
+            if (!isnan(id(ch_f2).state))    root["f2"]    = id(ch_f2).state;
+            if (!isnan(id(ch_f3).state))    root["f3"]    = id(ch_f3).state;
+            if (!isnan(id(ch_f4).state))    root["f4"]    = id(ch_f4).state;
+            if (!isnan(id(ch_f5).state))    root["f5"]    = id(ch_f5).state;
+            if (!isnan(id(ch_f6).state))    root["f6"]    = id(ch_f6).state;
+            if (!isnan(id(ch_f7).state))    root["f7"]    = id(ch_f7).state;
+            if (!isnan(id(ch_f8).state))    root["f8"]    = id(ch_f8).state;
+            if (!isnan(id(ch_clear).state)) root["clear"] = id(ch_clear).state;
+            if (!isnan(id(ch_nir).state))   root["nir"]   = id(ch_nir).state;
+            root["gain"] = ${gain_num};

+ 40 - 0
dev/varvusensor/docker-compose.yml

@@ -0,0 +1,40 @@
+services:
+  # Kohalik MQTT-broker (ESP32 jaoks, kuna valvuri broker on Tailscale'i taga).
+  mosquitto:
+    image: eclipse-mosquitto:2
+    container_name: varvus-mosquitto
+    restart: unless-stopped
+    mem_limit: 64m
+    user: "1883:1883"
+    environment:
+      - TZ=Europe/Tallinn
+    ports:
+      - "192.168.68.114:1883:1883"
+      - "100.87.1.24:1883:1883"
+    volumes:
+      - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
+      - ./mosquitto/acl:/mosquitto/config/acl:ro
+      - ./mosquitto/passwd:/mosquitto/config/passwd:ro
+      - ./data/mosquitto:/mosquitto/data
+    networks:
+      - main_ai_network
+
+  # AS7341 värvianduri koguja: MQTT -> ClickHouse (uus67.varvus).
+  varvus-collector:
+    build: ./collector
+    container_name: varvus-collector
+    restart: unless-stopped
+    mem_limit: 128m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+    depends_on:
+      - mosquitto
+    networks:
+      - main_ai_network
+
+networks:
+  main_ai_network:
+    external: true
+    name: main_ai_network

+ 3 - 0
dev/varvusensor/edge-mqtt/acl

@@ -0,0 +1,3 @@
+# mqtt-mosquitto ACL — mqtt_user pääseb ainult varvus-teemadele
+user mqtt_user
+topic readwrite varvus/#

+ 19 - 0
dev/varvusensor/edge-mqtt/docker-compose.yml

@@ -0,0 +1,19 @@
+services:
+  # Avalik MQTT broker (TLS 8883) varvusensorile ja teistele.
+  # Sert pärineb Caddyst (edge_caddy), sünkroonitakse sync-certs.sh-iga.
+  mosquitto:
+    image: eclipse-mosquitto:2
+    container_name: mqtt-mosquitto
+    restart: unless-stopped
+    mem_limit: 64m
+    user: "1883:1883"
+    environment:
+      - TZ=Europe/Tallinn
+    ports:
+      - "8883:8883"
+    volumes:
+      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
+      - ./passwd:/mosquitto/config/passwd:ro
+      - ./acl:/mosquitto/config/acl:ro
+      - ./certs:/mosquitto/certs:ro
+      - ./data:/mosquitto/data

+ 12 - 0
dev/varvusensor/edge-mqtt/mosquitto.conf

@@ -0,0 +1,12 @@
+listener 8883 0.0.0.0
+allow_anonymous false
+password_file /mosquitto/config/passwd
+acl_file /mosquitto/config/acl
+certfile /mosquitto/certs/server.crt
+keyfile /mosquitto/certs/server.key
+persistence true
+persistence_location /mosquitto/data/
+log_dest stdout
+log_type error
+log_type warning
+log_type notice

+ 48 - 0
dev/varvusensor/edge-mqtt/sync-certs.sh

@@ -0,0 +1,48 @@
+#!/bin/bash
+# Sünkroonib Caddy (edge_caddy) Let's Encrypt serdi mqtt.odamus.com jaoks
+# mosquitto jaoks loetavaks. Käivitub cronist (iga päev). Kui sert muutus,
+# taaskäivitab mosquitto, et uus sert laaditaks.
+#
+# NB: LE ahel lõpeb ISRG Root X2-ga, mis on rist-signeeritud ISRG Root X1-ga
+# (self-signed X1 ei ole ahelas). Seetõttu lisame CA-hunnikusse ka ISRG Root X1.
+set -euo pipefail
+
+SRC=/var/lib/docker/volumes/edge_caddy_data/_data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mqtt.odamus.com
+BASE=/opt/mqtt
+
+mkdir -p "$BASE/certs"
+
+NEW_HASH=$(sha256sum "$SRC/mqtt.odamus.com.crt" | cut -d' ' -f1)
+OLD_HASH=$(cat "$BASE/certs/.cert.hash" 2>/dev/null || echo none)
+
+cp "$SRC/mqtt.odamus.com.crt" "$BASE/certs/server.crt"
+cp "$SRC/mqtt.odamus.com.key" "$BASE/certs/server.key"
+
+# ISRG Root X1 (self-signed, kehtiv kuni 2035) — vajalik ahela lõpetamiseks.
+if [ ! -s "$BASE/certs/isrgrootx1.pem" ]; then
+  curl -fsS https://letsencrypt.org/certs/isrgrootx1.pem -o "$BASE/certs/isrgrootx1.pem" || true
+fi
+
+python3 - <<'PY'
+import re
+
+data = open("/opt/mqtt/certs/server.crt").read()
+certs = re.findall(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", data, re.S)
+chain = "\n".join(certs[1:]) + "\n"
+try:
+    root = open("/opt/mqtt/certs/isrgrootx1.pem").read()
+except FileNotFoundError:
+    root = ""
+open("/opt/mqtt/certs/esp32-ca.pem", "w").write(chain + root)
+print(f"ahelas {len(certs)} serti + ISRG Root X1")
+PY
+
+echo "$NEW_HASH" > "$BASE/certs/.cert.hash"
+chown -R 1883:1883 "$BASE/certs"
+chmod 644 "$BASE/certs/server.crt" "$BASE/certs/esp32-ca.pem"
+chmod 600 "$BASE/certs/server.key"
+
+if [ "$NEW_HASH" != "$OLD_HASH" ]; then
+  docker restart mqtt-mosquitto >/dev/null 2>&1 || true
+fi
+echo "sert sünkroonitud; hash=$NEW_HASH"

+ 3 - 0
dev/varvusensor/mosquitto/acl

@@ -0,0 +1,3 @@
+# varvus-mosquitto ACL — mqtt_user pääseb ainult varvus-teemadele
+user mqtt_user
+topic readwrite varvus/#

+ 10 - 0
dev/varvusensor/mosquitto/mosquitto.conf

@@ -0,0 +1,10 @@
+listener 1883 0.0.0.0
+allow_anonymous false
+password_file /mosquitto/config/passwd
+persistence true
+persistence_location /mosquitto/data/
+log_dest stdout
+log_type error
+log_type warning
+log_type notice
+acl_file /mosquitto/config/acl