2
0

18 Коммиты 1a5dc9ccb4 ... 80e6d882de

Автор SHA1 Сообщение Дата
  ardo 80e6d882de varundus 2026-10-18 07:00 1 неделя назад
  ardo f893d7d15e nutikodu-web: CCM — referentsi nimi = hex (valtida duplikaate) 1 неделя назад
  ardo 536d7a6ccc nutikodu-web: CCM — keeldu pimedast referentsist (summa < 300, v.a must) 1 неделя назад
  ardo fe013e1e31 nutikodu-web: CCM — suvalise varvi salvestus (color picker + suur eelvaade) 1 неделя назад
  ardo a83c80fcbc nutikodu-web: CCM — ridge-regulaarsus (8x8 sobitus oli haige; alpha=1e-5 LOO-jargi) 1 неделя назад
  ardo fdfb1bb3ce nutikodu-web: Varv — varvikaardi (CCM) kalibratsioon (3x8 maatriks) 1 неделя назад
  ardo b49528bcd1 nutikodu-web: Varv — spekter naitab reflekteeruvust (S/W), mitte valgusti-domineeritud tooreid kanaleid 1 неделя назад
  ardo ef4f79fc58 nutikodu-web: Varv — kalibratsioon värskendub automaatselt (20s) 1 неделя назад
  ardo 1ae1e1eda1 nutikodu-web: Varv — pime mootmine naitab PIME/hall, mitte muravarvi 1 неделя назад
  ardo 9c81c5e297 varvusensor/nutikodu-web: varvi valge tasakaal (valge -> D65) 1 неделя назад
  ardo a4fb189db3 nutikodu-web: Varv — kalibratsiooni live naitab ka stabiilsust 1 неделя назад
  ardo c679617c1d nutikodu-web: Varv — kalibratsiooni live-vihje (pime/hamar/hele) 1 неделя назад
  ardo b5376fb2d1 nutikodu-web: Varv — kalibratsioonivaate selgem olek (vaja tume+valge) 1 неделя назад
  ardo 70d41426dd varvusensor + nutikodu-web: kalibratsiooni robustsus 1 неделя назад
  ardo 234d16782b nutikodu-web: Varv — kalibratsiooni moistlikkuse kontroll + viimase mootmise nait 1 неделя назад
  ardo 12a8e70c8b nutikodu-web: Varv — veebipohine tume/valge kalibreerimine (auth) 1 неделя назад
  ardo 8f1db39437 varvusensor: calibrate.py — tume+valge referents (R=(S-D)/(W-D)) 1 неделя назад
  ardo 4326152a0f nutikodu-web: Varv — mõõtja sisse/välja lüliti (ainult sisselogitult) 1 неделя назад

+ 1 - 1
dev/nutikodu-web/Dockerfile

@@ -7,7 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt \
  && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
  && pip install --no-cache-dir opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc \
  && opentelemetry-bootstrap -a install
  && opentelemetry-bootstrap -a install
 
 
-COPY app.py .
+COPY app.py color.py .
 COPY static ./static
 COPY static ./static
 
 
 EXPOSE 8095
 EXPOSE 8095

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

@@ -28,6 +28,11 @@ tume/hele teema.
 - **Vaheleht "Värv"** — AS7341 spektri-värvisensori tulemused (`uus67.varvus`):
 - **Vaheleht "Värv"** — AS7341 spektri-värvisensori tulemused (`uus67.varvus`):
   praegune värv, värviruutude ruudustik, kanalite spekter (f1–f8), värvitelg
   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.
   (viimased 24 h tunnipõhiselt) ja kromaatilisuse (x, y) hajuvus. 20 s värskendus.
+  **Sisselogitult** saab mõõtmise sisse/välja lülitada (MQTT,
+  `varvus/switch/sensor`); OFF peatab ESP32 publitseerimise → **ka ClickHouse'i
+  kirjutamise**. Sisselogitult saab ka salvestada **tume/valge referentsi**
+  (`uus67.varvus_calib`) ja vaadata **kalibreeritud** värvi
+  (R = (S−D)/(W−D)).
 - PWA-manifest (installitav telefonis), `noindex`.
 - PWA-manifest (installitav telefonis), `noindex`.
 
 
 ## API
 ## API
@@ -47,6 +52,10 @@ tume/hele teema.
 | `GET /api/praam?days=1` | Heltermaa–Rohuküla väljumised + staatus + ilmahoiatuse kontekst (praamid.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/latest?limit=60` | AS7341 viimased mõõtmised (rgb, kanalid f1–f8, kromaatilisus) — avalik |
 | `GET /api/varv/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
 | `GET /api/varv/history?hours=24` | tunnipõhine keskmine värv (värvitelg) — avalik |
+| `GET /api/varv/power` | 🔒 värvisensori mõõtmise olek (ON/OFF) |
+| `POST /api/varv/power` | 🔒 lülita mõõtmine sisse/välja (keha `{"on":true\|false}`) |
+| `GET /api/varv/calib` | tume/valge referents (avalik) |
+| `POST /api/varv/calib` | 🔒 salvesta tume/valge referents (keha `{"kind":"dark"\|"white","last":5}`) |
 | `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `GET /api/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `GET /health` | tervis |
 | `GET /health` | tervis |
 
 
@@ -54,6 +63,10 @@ Ajavahemikud kujul `YYYY-MM-DD[ HH:MM[:SS]]` või ISO (T-eraldaja). Vaikimisi vi
 
 
 **🔒 = nõuab autentimist** (Bearer-token automaatikale või sessiooniküpsis brauserile). Ülejäänud on avalikud.
 **🔒 = nõuab autentimist** (Bearer-token automaatikale või sessiooniküpsis brauserile). Ülejäänud on avalikud.
 
 
+**Värvisensori juhtimine** vajab keskkonnamuutujaid (`dev/.env`):
+`VARV_MQTT_HOST=mqtt.odamus.com`, `VARV_MQTT_PORT=8883`, `VARV_MQTT_TLS=true`,
+`VARV_MQTT_USER`, `VARV_MQTT_PASS` (edge-brokeri konto).
+
 ## Autentimine
 ## Autentimine
 
 
 - **Brauser:** ava `/login`, sisesta parool → allkirjastatud sessiooniküpsis
 - **Brauser:** ava `/login`, sisesta parool → allkirjastatud sessiooniküpsis

+ 369 - 19
dev/nutikodu-web/app.py

@@ -17,6 +17,7 @@ Valikuline kaitse: WEB_ACCESS_TOKEN keskkonnamuutuja (tühi = kaitseta).
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
+import asyncio
 import hmac
 import hmac
 import html
 import html
 import secrets
 import secrets
@@ -30,6 +31,9 @@ from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
+import color
+import numpy as np
+import paho.mqtt.client as mqtt
 import requests
 import requests
 from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
 from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
 from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
 from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
@@ -46,6 +50,21 @@ CH_USER = os.environ.get("CH_USER", "zigbee")
 CH_PASS = os.environ.get("CH_PASS", "")
 CH_PASS = os.environ.get("CH_PASS", "")
 CH_DB = os.environ.get("CH_DB", "uus67")
 CH_DB = os.environ.get("CH_DB", "uus67")
 
 
+# Värvisensori juhtimine (MQTT, edge broker). OFF peatab ESP32 publitseerimise
+# -> ClickHouse'i ei kirjutata.
+VARV_MQTT_HOST = os.environ.get("VARV_MQTT_HOST", "")
+VARV_MQTT_PORT = int(os.environ.get("VARV_MQTT_PORT", "8883"))
+VARV_MQTT_TLS = os.environ.get("VARV_MQTT_TLS", "true").strip().lower() in ("1", "true", "yes", "on")
+VARV_MQTT_USER = os.environ.get("VARV_MQTT_USER", "")
+VARV_MQTT_PASS = os.environ.get("VARV_MQTT_PASS", "")
+VARV_CMD_TOPIC = os.environ.get("VARV_CMD_TOPIC", "varvus/switch/sensor/command")
+VARV_STATE_TOPIC = os.environ.get("VARV_STATE_TOPIC", "varvus/switch/sensor/state")
+VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+VARV_CALIB_FIELDS = VARV_BANDS + ("clear", "nir")
+VARV_CALIB_TABLE = os.environ.get("VARV_CALIB_TABLE", "varvus_calib")
+VARV_REF_TABLE = os.environ.get("VARV_REF_TABLE", "varvus_ccm_ref")
+VARV_CCM_TABLE = os.environ.get("VARV_CCM_TABLE", "varvus_ccm")
+
 WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
 WEB_ACCESS_TOKEN = os.environ.get("WEB_ACCESS_TOKEN", "")
 WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
 WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
 WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
 WEB_SECRET_KEY = os.environ.get("WEB_SECRET_KEY", "") or secrets.token_hex(32)
@@ -534,12 +553,173 @@ async def uus67_latest() -> dict:
 
 
 # --- Värvisensor (uus67.varvus, AS7341) ---
 # --- Värvisensor (uus67.varvus, AS7341) ---
 
 
-VARV_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8")
+
+def varv_load_calib() -> dict:
+    """Loe tume/valge referents (uus67.varvus_calib, viimane kirje liigi kohta)."""
+    try:
+        rows = ch_query(
+            f"SELECT kind, {', '.join(VARV_CALIB_FIELDS)} "
+            f"FROM {CH_DB}.{VARV_CALIB_TABLE} FINAL"
+        )
+    except HTTPException:
+        return {}
+    out: dict[str, dict] = {}
+    for r in rows:
+        out[r["kind"]] = {f: r.get(f) for f in VARV_CALIB_FIELDS}
+    return out
+
+
+def varv_reflectance(channels: dict, dark: dict, white: dict) -> dict:
+    """R = (S - D) / (W - D) kanali kohta."""
+    out: dict[str, float] = {}
+    for band in VARV_BANDS:
+        v = channels.get(band)
+        d = float(dark.get(band) or 0.0)
+        w = float(white.get(band) or 0.0)
+        if v is not None and (w - d) > 0:
+            out[band] = max(0.0, (float(v) - d) / (w - d))
+    return out
+
+
+# --- Värvikaardi (CCM) kalibratsioon: 3x8 maatriks kanalitest -> lineaarne sRGB ---
+
+
+def varv_load_ccm() -> list[list[float]] | None:
+    try:
+        rows = ch_query(
+            f"SELECT matrix FROM {CH_DB}.{VARV_CCM_TABLE} FINAL "
+            f"ORDER BY updated_at DESC LIMIT 1"
+        )
+    except HTTPException:
+        return None
+    if not rows:
+        return None
+    try:
+        m = json.loads(rows[0]["matrix"])
+        if len(m) == 3 and all(len(row) == len(VARV_BANDS) for row in m):
+            return [[float(x) for x in row] for row in m]
+    except Exception:
+        return None
+    return None
+
+
+def _srgb_to_linear(v: float) -> float:
+    c = v / 255.0
+    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb8(c: float) -> int:
+    c = max(0.0, min(1.0, c))
+    v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
+    return int(round(255 * max(0.0, min(1.0, v))))
+
+
+def varv_apply_ccm(matrix: list[list[float]], channels: dict) -> str:
+    vec = np.array([float(channels.get(b) or 0.0) for b in VARV_BANDS], dtype=float)
+    lin = np.array(matrix, dtype=float) @ vec
+    r = _linear_to_srgb8(float(lin[0]))
+    g = _linear_to_srgb8(float(lin[1]))
+    b = _linear_to_srgb8(float(lin[2]))
+    return f"#{r:02X}{g:02X}{b:02X}"
+
+
+@app.post("/api/varv/ref", dependencies=[Depends(require_auth)])
+async def varv_ref_set(payload: dict = Body(...)) -> dict:
+    """Salvesta referentsvärv (ekraanil kuvatud) CCM-i jaoks."""
+    name = str(payload.get("name", "")).strip().lower()
+    hexv = str(payload.get("hex", "")).strip().lstrip("#")
+    if len(hexv) != 6:
+        raise HTTPException(status_code=422, detail="hex peab olema 6-kohaline (nt FF0000)")
+    try:
+        tr, tg, tb = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
+    except ValueError:
+        raise HTTPException(status_code=422, detail="vigane hex")
+    last = max(1, min(int(payload.get("last", 5)), 30))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+    total = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if (tr + tg + tb) >= 10 and total < 300:
+        raise HTTPException(
+            status_code=422,
+            detail="Mõõtmine on liiga tume (värv ei ole ekraanil?). Kuva värv täisheledusel ja oota, kuni mõõtmine on HELE, siis salvesta.",
+        )
+    row = {"name": name, "tr": tr, "tg": tg, "tb": tb, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": f"INSERT INTO {CH_DB}.{VARV_REF_TABLE} FORMAT JSONEachRow"},
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"name": name, "target": hexv.upper(), "reference": ref, "rows": len(rows)}
+
+
+@app.get("/api/varv/ref")
+async def varv_ref_get() -> dict:
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_CALIB_FIELDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    return {"refs": rows}
+
+
+@app.post("/api/varv/ccm/fit", dependencies=[Depends(require_auth)])
+async def varv_ccm_fit() -> dict:
+    """Sobita 3x8 maatriks (kanalid -> lineaarne sRGB) referentsidest."""
+    rows = ch_query(
+        f"SELECT name, tr, tg, tb, {', '.join(VARV_BANDS)} "
+        f"FROM {CH_DB}.{VARV_REF_TABLE} FINAL ORDER BY name"
+    )
+    if len(rows) < 3:
+        raise HTTPException(status_code=422, detail="Vaja vähemalt 3 referentsvärvi (soovitav 6-8).")
+    C = np.array([[float(r[b] or 0.0) for b in VARV_BANDS] for r in rows], dtype=float)
+    T = np.array(
+        [[_srgb_to_linear(float(r["tr"])), _srgb_to_linear(float(r["tg"])), _srgb_to_linear(float(r["tb"]))]
+         for r in rows],
+        dtype=float,
+    )
+    # Ridge-regulaarsus: 8x8 süsteem on tinglikult haige (cond ~1e7) — ilma
+    # regulaarsuseta maatriks overfitib ja uued mõõtmised annavad rämpsu.
+    # alpha=1e-5 valitud leave-one-out testiga.
+    CtC = C.T @ C
+    CtT = C.T @ T
+    lam = 1e-5 * float(np.trace(CtC)) / len(VARV_BANDS)
+    M = np.linalg.solve(CtC + lam * np.eye(len(VARV_BANDS)), CtT)  # (8,3)
+    matrix = M.T.tolist()  # (3,8)
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CCM_TABLE} (id, matrix, n_refs) FORMAT JSONEachRow",
+        },
+        data=json.dumps({"id": 1, "matrix": json.dumps(matrix), "n_refs": len(rows)}) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"n_refs": len(rows), "matrix": matrix}
+
+
+@app.get("/api/varv/ccm")
+async def varv_ccm_get() -> dict:
+    m = varv_load_ccm()
+    return {"matrix": m, "ready": m is not None}
 
 
 
 
 @app.get("/api/varv/latest")
 @app.get("/api/varv/latest")
 async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
 async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
-    """Värvisensori viimased mõõtmised (avalik)."""
+    """Värvisensori viimased mõõtmised (avalik; kalibreeritud väljad kui referentsid olemas)."""
     cols = ", ".join(VARV_BANDS)
     cols = ", ".join(VARV_BANDS)
     rows = ch_query(
     rows = ch_query(
         f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
         f"SELECT toString(ts) AS ts, toUInt64(toUnixTimestamp(ts)) * 1000 AS t, "
@@ -547,27 +727,113 @@ async def varv_latest(limit: int = Query(60, ge=1, le=300)) -> dict:
         f"{cols}, clear, nir, gain "
         f"{cols}, clear, nir, gain "
         f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
         f"FROM {CH_DB}.varvus ORDER BY ts DESC LIMIT {int(limit)}"
     )
     )
+    calib = varv_load_calib()
+    dark, white = calib.get("dark"), calib.get("white")
+    ready = bool(dark and white)
+    ccm = varv_load_ccm()
+    out = []
+    for r in rows:
+        bands = {b: r.get(b) for b in VARV_BANDS}
+        raw = color.analyze(bands)
+        item = {
+            "ts": r["ts"],
+            "t": int(r["t"]),
+            "rgb_hex": raw["rgb_hex"],
+            "r": raw["r"],
+            "g": raw["g"],
+            "b": raw["b"],
+            "x_chrom": raw["x_chrom"],
+            "y_chrom": raw["y_chrom"],
+            "bands": bands,
+            "clear": r.get("clear"),
+            "nir": r.get("nir"),
+            "gain": r.get("gain"),
+        }
+        if ready:
+            cc = color.analyze(varv_reflectance(bands, dark, white))
+            item["rgb_hex_cal"] = cc["rgb_hex"]
+            item["x_chrom_cal"] = cc["x_chrom"]
+            item["y_chrom_cal"] = cc["y_chrom"]
+        if ccm:
+            item["rgb_hex_ccm"] = varv_apply_ccm(ccm, bands)
+        out.append(item)
+    return {"rows": out, "calibrated": ready, "ccm": ccm is not None}
+
+
+@app.get("/api/varv/calib")
+async def varv_calib_get() -> dict:
+    """Tume/valge referents (avalik lugemine)."""
+    calib = varv_load_calib()
     return {
     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
-        ]
+        "dark": calib.get("dark"),
+        "white": calib.get("white"),
+        "ready": bool(calib.get("dark") and calib.get("white")),
     }
     }
 
 
 
 
+@app.post("/api/varv/calib", dependencies=[Depends(require_auth)])
+async def varv_calib_set(payload: dict = Body(...)) -> dict:
+    """Salvesta tume/valge referents viimasest N mõõtmisest (ainult sisselogitult)."""
+    kind = str(payload.get("kind", "")).strip().lower()
+    if kind not in ("dark", "white"):
+        raise HTTPException(status_code=422, detail="kind peab olema 'dark' või 'white'")
+    last = max(1, min(int(payload.get("last", 5)), 60))
+    rows = ch_query(
+        f"SELECT {', '.join(VARV_CALIB_FIELDS)} FROM {CH_DB}.varvus "
+        f"ORDER BY ts DESC LIMIT {last}"
+    )
+    if not rows:
+        raise HTTPException(status_code=409, detail="Mõõtmisi pole")
+    # Stabiilsuse kontroll: viimased mõõtmised peavad olema samast olekust.
+    totals = [
+        sum(float(r[b]) for b in VARV_BANDS if r.get(b) is not None)
+        for r in rows
+    ]
+    tmax = max(totals) if totals else 0.0
+    if tmax > 0 and (tmax - min(totals)) / tmax > 0.2:
+        raise HTTPException(
+            status_code=422,
+            detail="Mõõtmine pole stabiliseerunud (viimased mõõtmised erinevad). Oota ~30 s pärast katmise/valge pinna muutust ja proovi uuesti.",
+        )
+    ref: dict[str, float] = {}
+    for f in VARV_CALIB_FIELDS:
+        vals = [float(r[f]) for r in rows if r.get(f) is not None]
+        ref[f] = round(sum(vals) / len(vals), 3) if vals else 0.0
+
+    # Mõistlikkuse kontroll: tume ja valge ei tohi olla sarnased (W-D > 0).
+    total_new = sum(ref.get(b, 0.0) for b in VARV_BANDS)
+    if total_new <= 0:
+        raise HTTPException(status_code=422, detail="Mõõtmine on null — andur on pime või andmed puuduvad.")
+    calib = varv_load_calib()
+    other = calib.get("white" if kind == "dark" else "dark")
+    if other:
+        total_other = sum(float(other.get(b) or 0.0) for b in VARV_BANDS)
+        if kind == "white" and total_new <= total_other * 1.05:
+            raise HTTPException(
+                status_code=422,
+                detail="Valge ja tume on liiga sarnased (valge pole tumedast heledam). Kontrolli: tume = andur kaetud; valge = valge pind valgustatud, sama geomeetria.",
+            )
+        if kind == "dark" and total_new >= total_other * 0.95:
+            raise HTTPException(
+                status_code=422,
+                detail="Tume ja valge on liiga sarnased (tume pole valgest tumedem). Kontrolli, et andur oli täiesti kaetud.",
+            )
+    row = {"kind": kind, **ref}
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{VARV_CALIB_TABLE} FORMAT JSONEachRow",
+        },
+        data=json.dumps(row) + "\n",
+        auth=(CH_USER, CH_PASS),
+        timeout=10,
+    )
+    if resp.status_code != 200:
+        raise HTTPException(status_code=502, detail=f"ClickHouse: {resp.text[:200]}")
+    return {"kind": kind, "reference": ref, "rows": len(rows)}
+
+
 @app.get("/api/varv/history")
 @app.get("/api/varv/history")
 async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
 async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
     """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
     """Värvitelg: tunnipõhine keskmine värv viimaste tundide jooksul (avalik)."""
@@ -594,6 +860,90 @@ async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
     return {"hours": hours, "rows": out}
     return {"hours": hours, "rows": out}
 
 
 
 
+# --- Värvisensori mõõtmise sisse/välja lüliti (MQTT, ainult sisselogitult) ---
+
+
+def _varv_mqtt_client() -> mqtt.Client:
+    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
+    if VARV_MQTT_TLS:
+        client.tls_set()
+    if VARV_MQTT_USER:
+        client.username_pw_set(VARV_MQTT_USER, VARV_MQTT_PASS)
+    return client
+
+
+def varv_read_state(timeout: float = 4.0) -> str | None:
+    """Loe lüliti retained olek edge-brokerist (lühiajaline ühendus)."""
+    if not VARV_MQTT_HOST:
+        return None
+    box: dict[str, str] = {}
+
+    def on_connect(c, userdata, flags, rc, props=None):
+        if rc == 0:
+            c.subscribe(VARV_STATE_TOPIC)
+
+    def on_message(c, userdata, msg):
+        box["state"] = msg.payload.decode().strip()
+
+    client = _varv_mqtt_client()
+    client.on_connect = on_connect
+    client.on_message = on_message
+    try:
+        client.connect_async(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline and "state" not in box:
+            time.sleep(0.05)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+    state = box.get("state")
+    return state if state in ("ON", "OFF") else None
+
+
+def varv_set_power(on: bool) -> None:
+    if not VARV_MQTT_HOST:
+        raise HTTPException(status_code=503, detail="Värvisensori MQTT pole seadistatud")
+    client = _varv_mqtt_client()
+    try:
+        client.connect(VARV_MQTT_HOST, VARV_MQTT_PORT, 10)
+        client.loop_start()
+        info = client.publish(VARV_CMD_TOPIC, "ON" if on else "OFF", qos=1, retain=True)
+        info.wait_for_publish(timeout=5)
+    finally:
+        client.loop_stop()
+        try:
+            client.disconnect()
+        except Exception:
+            pass
+
+
+@app.get("/api/varv/power", dependencies=[Depends(require_auth)])
+def varv_power_get() -> dict:
+    """Värvisensori mõõtmise olek (ainult sisselogitult)."""
+    state = varv_read_state()
+    return {
+        "available": bool(VARV_MQTT_HOST),
+        "state": state,
+        "on": None if state is None else (state == "ON"),
+    }
+
+
+@app.post("/api/varv/power", dependencies=[Depends(require_auth)])
+async def varv_power_set(payload: dict = Body(...)) -> dict:
+    """Lülita värvisensori mõõtmine sisse/välja (ainult sisselogitult).
+
+    OFF peatab ESP32 publitseerimise -> ClickHouse'i ei kirjutata.
+    """
+    on = bool(payload.get("on"))
+    await asyncio.to_thread(varv_set_power, on)
+    state = await asyncio.to_thread(varv_read_state, 3.0)
+    return {"on": on, "state": state}
+
+
 @app.get("/api/latest")
 @app.get("/api/latest")
 async def latest(request: Request) -> dict:
 async def latest(request: Request) -> dict:
     readings = ch_query(
     readings = ch_query(

+ 127 - 0
dev/nutikodu-web/color.py

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

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

@@ -3,3 +3,5 @@ uvicorn
 requests
 requests
 itsdangerous
 itsdangerous
 python-multipart
 python-multipart
+paho-mqtt>=2.0,<3
+numpy

+ 336 - 15
dev/nutikodu-web/static/app.js

@@ -539,6 +539,13 @@ function applyAuth(authed) {
   }
   }
   const prot = document.getElementById("protected");
   const prot = document.getElementById("protected");
   if (prot) prot.hidden = !authed;
   if (prot) prot.hidden = !authed;
+  const vp = document.getElementById("varv-power");
+  if (vp) vp.hidden = !authed;
+  const vc = document.getElementById("varv-calib-panel");
+  if (vc) vc.hidden = !authed;
+  const vccm = document.getElementById("varv-ccm-panel");
+  if (vccm) vccm.hidden = !authed;
+  if (authed) { initVarvPowerButtons(); loadVarvPower(); initVarvCalibButtons(); loadVarvCalib(); initVarvCcmButtons(); loadVarvCcm(); }
   const link = document.getElementById("auth-link");
   const link = document.getElementById("auth-link");
   if (link) {
   if (link) {
     link.href = authed ? "/logout" : "/login";
     link.href = authed ? "/logout" : "/login";
@@ -650,6 +657,7 @@ async function loadVarv() {
     renderVarvTimeline(history);
     renderVarvTimeline(history);
     renderVarvSpectrum();
     renderVarvSpectrum();
     renderVarvChroma(varvLatest);
     renderVarvChroma(varvLatest);
+    renderVarvCalibLive();
     const upd = document.getElementById("varv-updated");
     const upd = document.getElementById("varv-updated");
     if (upd) upd.textContent = varvLatest.length ? "Viimane mõõtmine " + varvLatest[0].ts : "Andmeid pole";
     if (upd) upd.textContent = varvLatest.length ? "Viimane mõõtmine " + varvLatest[0].ts : "Andmeid pole";
     if (hint) hint.hidden = true;
     if (hint) hint.hidden = true;
@@ -673,20 +681,24 @@ function renderVarvCurrent() {
     return;
     return;
   }
   }
   const c = varvLatest[0];
   const c = varvLatest[0];
-  sw.style.background = c.rgb_hex;
-  if (hex) hex.textContent = c.rgb_hex.toUpperCase();
+  const p = varvPick(c);
+  sw.style.background = p.hex;
+  if (hex) hex.textContent = p.dark ? "PIME" : p.hex.toUpperCase();
   if (meta) meta.innerHTML =
   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}`;
+    `${c.ts}${varvCalibView ? " · kalibreeritud" : ""}` +
+    (p.dark
+      ? "<br>pime — värv ei ole usaldusväärne (valgusta pinda)"
+      : `<br>x = ${varvFmt(p.x)} · y = ${varvFmt(p.y)} · gain ${c.gain}`);
 }
 }
 
 
 function renderVarvGrid() {
 function renderVarvGrid() {
   const grid = document.getElementById("varv-grid");
   const grid = document.getElementById("varv-grid");
   if (!grid) return;
   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.innerHTML = varvLatest.map((c, i) => {
+    const p = varvPick(c);
+    return `<button type="button" class="swatch${i === varvSelected ? " sel" : ""}" ` +
+      `style="background:${p.hex}" title="${c.ts}  ${p.hex}" data-i="${i}"></button>`;
+  }).join("");
   grid.querySelectorAll(".swatch").forEach((el) => {
   grid.querySelectorAll(".swatch").forEach((el) => {
     el.addEventListener("click", () => {
     el.addEventListener("click", () => {
       varvSelected = Number(el.dataset.i) || 0;
       varvSelected = Number(el.dataset.i) || 0;
@@ -711,13 +723,36 @@ function renderVarvTimeline(rows) {
   }).join("");
   }).join("");
 }
 }
 
 
+function varvReflectance(c) {
+  const d = varvCalibData && varvCalibData.dark;
+  const w = varvCalibData && varvCalibData.white;
+  if (!d || !w) return null;
+  const out = {};
+  for (const b of VARV_BANDS) {
+    const s = Number(c.bands[b[0]]) || 0;
+    const dv = Number(d[b[0]]) || 0;
+    const wv = Number(w[b[0]]) || 0;
+    out[b[0]] = (wv - dv) > 0 ? Math.max(0, (s - dv) / (wv - dv)) : 0;
+  }
+  return out;
+}
+
 function renderVarvSpectrum() {
 function renderVarvSpectrum() {
   const el = document.getElementById("chart-varv-spectrum");
   const el = document.getElementById("chart-varv-spectrum");
   if (!el || !varvLatest.length) { if (chartVarvSpectrum) chartVarvSpectrum.clear(); return; }
   if (!el || !varvLatest.length) { if (chartVarvSpectrum) chartVarvSpectrum.clear(); return; }
   const c = varvLatest[Math.min(varvSelected, varvLatest.length - 1)];
   const c = varvLatest[Math.min(varvSelected, varvLatest.length - 1)];
+  const refl = varvReflectance(c);
   const label = document.getElementById("varv-spectrum-label");
   const label = document.getElementById("varv-spectrum-label");
-  if (label) label.textContent = `— ${c.ts} · ${c.rgb_hex.toUpperCase()}`;
+  if (label) {
+    label.textContent = refl
+      ? `— ${c.ts} · ${((c.rgb_hex_cal || c.rgb_hex) || "").toUpperCase()} · reflekteeruvus %`
+      : `— ${c.ts} · ${c.rgb_hex.toUpperCase()} · toored kanalid (valgusti-domineeritud)`;
+  }
   if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" });
   if (!chartVarvSpectrum) chartVarvSpectrum = echarts.init(el, null, { renderer: "canvas" });
+  const data = VARV_BANDS.map((b) => ({
+    value: refl ? Math.round((refl[b[0]] || 0) * 1000) / 10 : (c.bands[b[0]] || 0),
+    itemStyle: { color: b[2] },
+  }));
   chartVarvSpectrum.setOption({
   chartVarvSpectrum.setOption({
     grid: { left: 56, right: 16, top: 16, bottom: 32 },
     grid: { left: 56, right: 16, top: 16, bottom: 32 },
     xAxis: {
     xAxis: {
@@ -727,17 +762,18 @@ function renderVarvSpectrum() {
     },
     },
     yAxis: {
     yAxis: {
       type: "value",
       type: "value",
-      axisLabel: { color: cssVar("--muted") },
+      max: refl ? 100 : undefined,
+      axisLabel: {
+        color: cssVar("--muted"),
+        formatter: refl ? "{value}%" : "{value}",
+      },
       splitLine: { lineStyle: { color: cssVar("--border") } },
       splitLine: { lineStyle: { color: cssVar("--border") } },
     },
     },
     tooltip: { trigger: "axis" },
     tooltip: { trigger: "axis" },
     series: [{
     series: [{
       type: "bar",
       type: "bar",
       barWidth: "62%",
       barWidth: "62%",
-      data: VARV_BANDS.map((b) => ({
-        value: c.bands[b[0]] || 0,
-        itemStyle: { color: b[2] },
-      })),
+      data,
     }],
     }],
   }, true);
   }, true);
   chartVarvSpectrum.resize();
   chartVarvSpectrum.resize();
@@ -791,6 +827,290 @@ function renderVarvChroma(rows) {
   chartVarvChroma.resize();
   chartVarvChroma.resize();
 }
 }
 
 
+// --- Värv: mõõtmise sisse/välja lüliti (ainult sisselogitult) ---
+let varvPowerBusy = false;
+let varvPowerWired = false;
+
+function initVarvPowerButtons() {
+  if (varvPowerWired) return;
+  const onBtn = document.getElementById("varv-on");
+  const offBtn = document.getElementById("varv-off");
+  if (onBtn) onBtn.addEventListener("click", () => setVarvPower(true));
+  if (offBtn) offBtn.addEventListener("click", () => setVarvPower(false));
+  varvPowerWired = true;
+}
+
+function renderVarvPower(d) {
+  const st = document.getElementById("varv-power-status");
+  const onBtn = document.getElementById("varv-on");
+  const offBtn = document.getElementById("varv-off");
+  const on = d ? d.on : null;
+  if (st) {
+    if (on === true) { st.textContent = "SEES"; st.className = "badge ok"; }
+    else if (on === false) { st.textContent = "VÄLJAS"; st.className = "badge err"; }
+    else { st.textContent = "teadmata"; st.className = "badge muted"; }
+  }
+  if (onBtn) onBtn.disabled = varvPowerBusy || on === true;
+  if (offBtn) offBtn.disabled = varvPowerBusy || on === false;
+}
+
+async function loadVarvPower() {
+  if (!state.authed) return;
+  try {
+    const res = await fetch("/api/varv/power");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvPower(await res.json());
+  } catch (e) {
+    console.error(e);
+    renderVarvPower({ on: null });
+  }
+}
+
+async function setVarvPower(on) {
+  if (!state.authed || varvPowerBusy) return;
+  varvPowerBusy = true;
+  const hint = document.getElementById("varv-power-hint");
+  renderVarvPower({ on });
+  try {
+    const res = await fetch("/api/varv/power", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ on }),
+    });
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvPower(await res.json());
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = on
+        ? "Mõõtmine sisse lülitatud."
+        : "Mõõtmine välja lülitatud — ClickHouse'i ei kirjutata.";
+      setTimeout(() => { hint.hidden = true; }, 5000);
+    }
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = "Lülitamine ebaõnnestus."; }
+  } finally {
+    varvPowerBusy = false;
+    loadVarvPower();
+  }
+}
+
+// --- Värv: kalibreerimine (tume/valge; nupud ainult sisselogitult) ---
+let varvCalibView = false;
+let varvCalibData = null;
+let varvCalibWired = false;
+
+function varvBandSum(c) {
+  return Object.values(c.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0);
+}
+
+function varvPick(c) {
+  if (varvBandSum(c) < 250) return { hex: "#33383f", x: null, y: null, dark: true };
+  if (varvCalibView && c.rgb_hex_ccm) {
+    return { hex: c.rgb_hex_ccm, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  if (varvCalibView && c.rgb_hex_cal) {
+    return { hex: c.rgb_hex_cal, x: c.x_chrom_cal, y: c.y_chrom_cal };
+  }
+  return { hex: c.rgb_hex, x: c.x_chrom, y: c.y_chrom };
+}
+
+function initVarvCalibButtons() {
+  if (varvCalibWired) return;
+  const d = document.getElementById("varv-calib-dark");
+  const w = document.getElementById("varv-calib-white");
+  const t = document.getElementById("varv-calib-view");
+  if (d) d.addEventListener("click", () => captureVarvCalib("dark"));
+  if (w) w.addEventListener("click", () => captureVarvCalib("white"));
+  if (t) t.addEventListener("change", (e) => {
+    varvCalibView = e.target.checked;
+    renderVarvCurrent();
+    renderVarvGrid();
+  });
+  varvCalibWired = true;
+}
+
+function renderVarvCalib(d) {
+  varvCalibData = d;
+  const st = document.getElementById("varv-calib-status");
+  if (st) {
+    const haveD = !!d.dark;
+    const haveW = !!d.white;
+    if (haveD && haveW) { st.textContent = "valmis"; st.className = "badge ok"; }
+    else {
+      st.textContent = `${haveD ? "tume ✓" : "tume ✗"} / ${haveW ? "valge ✓" : "valge ✗"}`;
+      st.className = "badge warn";
+    }
+  }
+  const view = document.getElementById("varv-calib-view");
+  if (view) view.disabled = !(d.dark && d.white);
+  const lbl = document.getElementById("varv-calib-view-label");
+  if (lbl) {
+    lbl.textContent = (d.dark && d.white)
+      ? "Kuva kalibreeritud värvi"
+      : "Kuva kalibreeritud värvi (vaja tume + valge)";
+  }
+  renderVarvSpectrum();
+}
+
+async function loadVarvCalib() {
+  try {
+    const res = await fetch("/api/varv/calib");
+    if (!res.ok) throw new Error("HTTP " + res.status);
+    renderVarvCalib(await res.json());
+  } catch (e) { console.error(e); }
+}
+
+function renderVarvCalibLive() {
+  const el = document.getElementById("varv-calib-live");
+  if (!el) return;
+  if (!varvLatest.length) { el.textContent = ""; return; }
+  const bandSum = (r) => Object.values(r.bands || {}).reduce((a, v) => a + (Number(v) || 0), 0);
+  const c = varvLatest[0];
+  const sum = bandSum(c);
+  const sums = varvLatest.slice(0, 5).map(bandSum);
+  const mx = Math.max(...sums);
+  const mn = Math.min(...sums);
+  const unstable = mx > 0 && (mx - mn) / mx > 0.2;
+  let light;
+  if (sum < 50) light = "PIME — valge jaoks vaja valgust";
+  else if (sum < 200) light = "hämar";
+  else light = "HELE";
+  const st = unstable ? "MUUTUB (oota stabiliseerumist)" : "stabiilne ✓";
+  el.textContent = `Viimane: ${(c.rgb_hex || "").toUpperCase()} · heledus ${Math.round(sum)} · ${light} · ${st}`;
+}
+
+async function captureVarvCalib(kind) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-calib-hint");
+  try {
+    const res = await fetch("/api/varv/calib", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ kind, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = `Salvestamine ebaõnnestus (HTTP ${res.status}).`;
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) {
+      hint.hidden = false;
+      hint.textContent = `Salvestatud: ${kind} (${d.rows} mõõtmist).`;
+      setTimeout(() => { hint.hidden = true; }, 4500);
+    }
+    await loadVarvCalib();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) { hint.hidden = false; hint.textContent = e.message || "Salvestamine ebaõnnestus."; }
+  }
+}
+
+// --- Värv: CCM (värvikaart) kalibratsioon ---
+let varvCcmWired = false;
+
+function initVarvCcmButtons() {
+  if (varvCcmWired) return;
+  document.querySelectorAll("[data-ref]").forEach((btn) => {
+    btn.addEventListener("click", () => captureVarvRef(String(btn.dataset.hex).toLowerCase(), btn.dataset.hex));
+  });
+  const fit = document.getElementById("varv-ccm-fit");
+  if (fit) fit.addEventListener("click", fitVarvCcm);
+
+  const colEl = document.getElementById("varv-ccm-color");
+  const hexEl = document.getElementById("varv-ccm-hex");
+  const prevEl = document.getElementById("varv-ccm-preview");
+  const capEl = document.getElementById("varv-ccm-capture");
+  const applyColor = (raw) => {
+    const h = String(raw || "").replace("#", "").trim().toUpperCase();
+    if (!/^[0-9A-F]{6}$/.test(h)) return;
+    if (prevEl) prevEl.style.background = "#" + h;
+    if (colEl) colEl.value = "#" + h.toLowerCase();
+    if (hexEl) hexEl.value = h;
+  };
+  if (colEl) colEl.addEventListener("input", () => applyColor(colEl.value));
+  if (hexEl) hexEl.addEventListener("change", () => applyColor(hexEl.value));
+  if (capEl) capEl.addEventListener("click", () => {
+    const h = (hexEl ? hexEl.value : "").replace("#", "").trim().toUpperCase();
+    if (!/^[0-9A-F]{6}$/.test(h)) return;
+    captureVarvRef(h.toLowerCase(), h);
+  });
+
+  varvCcmWired = true;
+}
+
+function renderVarvCcm(ccm, refs) {
+  const st = document.getElementById("varv-ccm-status");
+  if (st) {
+    if (ccm && ccm.ready) { st.textContent = "maatriks olemas"; st.className = "badge ok"; }
+    else { st.textContent = "maatriks puudub"; st.className = "badge warn"; }
+  }
+  const list = document.getElementById("varv-ccm-list");
+  const names = (refs && refs.refs ? refs.refs : []).map((r) => r.name);
+  if (list) list.textContent = names.length ? "Salvestatud: " + names.join(", ") : "Ühtegi referentsi pole veel.";
+}
+
+async function loadVarvCcm() {
+  try {
+    const [r1, r2] = await Promise.all([fetch("/api/varv/ccm"), fetch("/api/varv/ref")]);
+    if (!r1.ok || !r2.ok) return;
+    renderVarvCcm(await r1.json(), await r2.json());
+  } catch (e) { console.error(e); }
+}
+
+async function captureVarvRef(name, hex) {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ref", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ name, hex, last: 5 }),
+    });
+    if (!res.ok) {
+      let msg = "Salvestamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    if (hint) hint.textContent = `Salvestatud: ${name} (${hex})`;
+    loadVarvCcm();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
+async function fitVarvCcm() {
+  if (!state.authed) return;
+  const hint = document.getElementById("varv-ccm-hint");
+  try {
+    const res = await fetch("/api/varv/ccm/fit", { method: "POST" });
+    if (!res.ok) {
+      let msg = "Sobitamine ebaõnnestus.";
+      try {
+        const e = await res.json();
+        if (typeof e.detail === "string") msg = e.detail;
+      } catch (ignored) { /* ignore */ }
+      throw new Error(msg);
+    }
+    const d = await res.json();
+    if (hint) hint.textContent = `Maatriks arvutatud (${d.n_refs} värvi).`;
+    loadVarvCcm();
+    loadVarv();
+  } catch (e) {
+    console.error(e);
+    if (hint) hint.textContent = e.message || "Viga";
+  }
+}
+
 window.addEventListener("resize", () => {
 window.addEventListener("resize", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -816,6 +1136,7 @@ function switchView(view) {
     loadTransport();
     loadTransport();
   } else if (view === "varv") {
   } else if (view === "varv") {
     loadVarv();
     loadVarv();
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); }
   } else {
   } else {
     if (chart) chart.resize();
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
     if (chartEnergy) chartEnergy.resize();
@@ -842,7 +1163,7 @@ function initTabs() {
     if (transport.active) loadTransport();
     if (transport.active) loadTransport();
   }, 60000);
   }, 60000);
   setInterval(() => {
   setInterval(() => {
-    if (varvActive) loadVarv();
+    if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } }
   }, 20000);
   }, 20000);
   switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
   switchView(VIEWS.includes(location.hash.slice(1)) ? location.hash.slice(1) : "andurid");
 }
 }

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

@@ -113,6 +113,78 @@
     </div>
     </div>
 
 
     <div id="view-varv" class="view" hidden>
     <div id="view-varv" class="view" hidden>
+      <section class="panel" id="varv-power" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🔌 Värvusemõõtja</h2>
+          <span id="varv-power-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-on" class="btn">Lülita sisse</button>
+            <button type="button" id="varv-off" class="btn">Lülita välja</button>
+          </div>
+        </div>
+        <div id="varv-power-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel" id="varv-calib-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Kalibreerimine</h2>
+          <span id="varv-calib-status" class="badge muted">—</span>
+          <div class="varv-power-btns">
+            <button type="button" id="varv-calib-dark" class="btn">Salvesta tume</button>
+            <button type="button" id="varv-calib-white" class="btn">Salvesta valge</button>
+          </div>
+        </div>
+        <div class="varv-sub" style="margin-top:8px">
+          1) Kata andur → „Salvesta tume". 2) Pane valge pind sama valgustusega → „Salvesta valge".
+          Oota ~30 s, kuni mõõtmine stabiliseerub.
+        </div>
+        <div id="varv-calib-live" class="varv-sub" style="margin-top:4px"></div>
+        <label class="chk" style="margin-top:10px;display:inline-flex">
+          <input type="checkbox" id="varv-calib-view" /> <span id="varv-calib-view-label">Kuva kalibreeritud värvi</span>
+        </label>
+        <div id="varv-calib-hint" class="hint" hidden></div>
+      </section>
+
+      <section class="panel" id="varv-ccm-panel" hidden>
+        <div class="varv-power-row">
+          <h2 class="panel-title" style="margin:0">🎯 Värvikaardi (CCM) kalibratsioon</h2>
+          <span id="varv-ccm-status" class="badge muted">—</span>
+        </div>
+        <div class="varv-sub" style="margin-top:6px">
+          Kuva ekraanil üks värv korraga (täisekraanil) ja vajuta vastavat nuppu — mõõdetakse viimased 5.
+          Soovitav: punane, roheline, sinine, valge, must, kollane, tsüaan, magenta.
+        </div>
+        <div class="varv-power-btns" style="margin:8px 0; justify-content:flex-start; flex-wrap:wrap">
+          <button type="button" class="btn" data-ref="red" data-hex="FF0000">Punane</button>
+          <button type="button" class="btn" data-ref="green" data-hex="00FF00">Roheline</button>
+          <button type="button" class="btn" data-ref="blue" data-hex="0000FF">Sinine</button>
+          <button type="button" class="btn" data-ref="white" data-hex="FFFFFF">Valge</button>
+          <button type="button" class="btn" data-ref="black" data-hex="000000">Must</button>
+          <button type="button" class="btn" data-ref="yellow" data-hex="FFFF00">Kollane</button>
+          <button type="button" class="btn" data-ref="cyan" data-hex="00FFFF">Tsüaan</button>
+          <button type="button" class="btn" data-ref="magenta" data-hex="FF00FF">Magenta</button>
+        </div>
+        <div class="varv-power-row" style="margin-top:8px">
+          <label class="chk" style="display:inline-flex;align-items:center;gap:8px">
+            <span>Värv:</span>
+            <input type="color" id="varv-ccm-color" value="#00ff00"
+                   style="width:48px;height:32px;border:0;background:none;padding:0" />
+            <input type="text" id="varv-ccm-hex" value="00FF00" maxlength="7"
+                   style="width:96px;padding:6px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text)" />
+          </label>
+          <button type="button" id="varv-ccm-capture" class="btn">Salvesta see värv</button>
+        </div>
+        <div id="varv-ccm-preview"
+             style="width:220px;height:220px;border-radius:12px;border:1px solid var(--border);margin:8px 0;background:#00ff00"></div>
+        <div class="varv-sub">Hoia andurit selle ruudu vastas (ekraan täisheledusel), oota kuni mõõtmine on stabiilne (HELE), siis „Salvesta see värv".</div>
+
+        <div class="varv-power-row" style="margin-top:8px">
+          <button type="button" id="varv-ccm-fit" class="btn">Arvuta maatriks</button>
+          <span id="varv-ccm-hint" class="varv-sub"></span>
+        </div>
+        <div id="varv-ccm-list" class="varv-sub" style="margin-top:6px"></div>
+      </section>
+
       <section class="panel">
       <section class="panel">
         <div class="varv-current">
         <div class="varv-current">
           <div id="varv-current-swatch" class="varv-current-swatch"></div>
           <div id="varv-current-swatch" class="varv-current-swatch"></div>

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

@@ -244,3 +244,7 @@ main > * { min-width: 0; }
   .varv-hex { font-size: 22px; }
   .varv-hex { font-size: 22px; }
   .swatch-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
   .swatch-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); }
 }
 }
+
+.varv-power-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.varv-power-btns { display: flex; gap: 8px; margin-left: auto; }
+.btn:disabled { opacity: .5; cursor: not-allowed; }

+ 55 - 26
dev/varvusensor/collector/calibrate.py

@@ -1,17 +1,23 @@
 """Kalibreerimise abivahend AS7341 värviandurile.
 """Kalibreerimise abivahend AS7341 värviandurile.
 
 
 Loeb ClickHouse'ist (vaikimisi uus67.varvus) ja toetab:
 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)
+  * capture-dark:  salvesta tume referents (andur kaetud) -> calibration.json
+  * capture-white: salvesta valge referents (valge pind, SAMA valgustus) -> calibration.json
+  * show:          näita viimaseid mõõtmisi (toores vs tume+valge kalibreeritud)
 
 
-Kalibreeritud värvus = iga kanal jagatud valge referentsiga (reflectance),
-mis eemaldab valgustuse värvitooni ja anduri kanalite erinevuse.
+Kalibreeritud reflekteeruvus:  R = (S - D) / (W - D)  iga kanali kohta.
+  * Tume (D) eemaldab tumedavoolu ja plaadi toite-LED-i (roheline) panuse.
+  * Valge (W) eemaldab valgustuse spektri ja kanalite tundlikkuse erinevuse.
+
+Protseduur:
+  1. Fikseeri valgustus ja geomeetria (nurk, kaugus) kogu seeria jooksul.
+  2. Kata andur (täiesti pime) -> capture-dark --last 5
+  3. Mõõda valge pind (nt valge paber/PTFE) -> capture-white --last 5
+  4. Mõõda proovid -> show --last 10
 
 
 Käivitamine (projekti .venv):
 Käivitamine (projekti .venv):
   CH_URL=http://100.87.1.24:8125 CH_USER=zigbee CH_PASS=... \
   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
+    .venv/bin/python varvusensor/collector/calibrate.py capture-dark --last 5
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
@@ -55,43 +61,62 @@ def fetch_rows(last: int) -> list[dict]:
     return [json.loads(line) for line in ch_query(sql).strip().splitlines() if line]
     return [json.loads(line) for line in ch_query(sql).strip().splitlines() if line]
 
 
 
 
-def capture_white(last: int) -> int:
+def load_calib() -> dict:
+    if not CALIB_FILE.exists():
+        return {}
+    try:
+        return json.loads(CALIB_FILE.read_text())
+    except Exception:
+        return {}
+
+
+def save_calib(data: dict) -> None:
+    CALIB_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
+
+
+def capture_reference(kind: str, last: int) -> int:
     rows = fetch_rows(last)
     rows = fetch_rows(last)
     if not rows:
     if not rows:
-        print("Andmeid pole — mõõda esmalt valget pinda.")
+        print("Andmeid pole — mõõda esmalt (kaetud andur / valge pind).")
         return 1
         return 1
     ref: dict[str, float] = {}
     ref: dict[str, float] = {}
     for field in FIELDS:
     for field in FIELDS:
         values = [r[field] for r in rows if r.get(field) is not None]
         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
         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}")
+    data = load_calib()
+    data[kind] = ref
+    data[f"{kind}_rows"] = len(rows)
+    save_calib(data)
+    label = "Tume" if kind == "dark" else "Valge"
+    print(f"{label} referents salvestatud ({len(rows)} reast) -> {CALIB_FILE}")
     print(json.dumps(ref, indent=2))
     print(json.dumps(ref, indent=2))
+    if kind == "white" and "dark" not in data:
+        print("NB! Tume referents puudub — soovitatav teha enne capture-dark (LED/tumedavool).")
     return 0
     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]:
+def reflectance(
+    channels: dict[str, float], dark: dict[str, float], white: dict[str, float]
+) -> dict[str, float]:
     out: dict[str, float] = {}
     out: dict[str, float] = {}
     for band in BANDS:
     for band in BANDS:
-        ref = white.get(band, 0.0)
         value = channels.get(band)
         value = channels.get(band)
-        if value is not None and ref:
-            out[band] = value / ref
+        d = dark.get(band, 0.0) or 0.0
+        w = white.get(band, 0.0) or 0.0
+        if value is not None and (w - d) > 0:
+            out[band] = max(0.0, (value - d) / (w - d))
     return out
     return out
 
 
 
 
 def show(last: int) -> int:
 def show(last: int) -> int:
     rows = fetch_rows(last)
     rows = fetch_rows(last)
-    white = load_white()
+    data = load_calib()
+    dark = data.get("dark") or {}
+    white = data.get("white")
     if white is None:
     if white is None:
-        print("(kalibratsioon puudub — näitan ainult toorest; tee capture-white)")
+        print("(valge kalibratsioon puudub — näitan ainult toorest; tee capture-white)")
+    elif not dark:
+        print("(tume referents puudub — kasutan D=0; soovitatav capture-dark)")
     for row in rows:
     for row in rows:
         channels = {b: row[b] for b in BANDS if row.get(b) is not None}
         channels = {b: row[b] for b in BANDS if row.get(b) is not None}
         raw = color.analyze(channels)
         raw = color.analyze(channels)
@@ -100,7 +125,7 @@ def show(last: int) -> int:
             f"(x={raw['x_chrom']:.3f} y={raw['y_chrom']:.3f})"
             f"(x={raw['x_chrom']:.3f} y={raw['y_chrom']:.3f})"
         )
         )
         if white is not None:
         if white is not None:
-            cal = color.analyze(reflectance(channels, white))
+            cal = color.analyze(reflectance(channels, dark, white))
             line += (
             line += (
                 f"  | kalib. {cal['rgb_hex']} "
                 f"  | kalib. {cal['rgb_hex']} "
                 f"(x={cal['x_chrom']:.3f} y={cal['y_chrom']:.3f})"
                 f"(x={cal['x_chrom']:.3f} y={cal['y_chrom']:.3f})"
@@ -112,13 +137,17 @@ def show(last: int) -> int:
 def main() -> int:
 def main() -> int:
     parser = argparse.ArgumentParser(description="AS7341 kalibreerimine")
     parser = argparse.ArgumentParser(description="AS7341 kalibreerimine")
     sub = parser.add_subparsers(dest="cmd", required=True)
     sub = parser.add_subparsers(dest="cmd", required=True)
+    cd = sub.add_parser("capture-dark", help="salvesta tume referents (andur kaetud)")
+    cd.add_argument("--last", type=int, default=5)
     cw = sub.add_parser("capture-white", help="salvesta valge referents")
     cw = sub.add_parser("capture-white", help="salvesta valge referents")
     cw.add_argument("--last", type=int, default=5)
     cw.add_argument("--last", type=int, default=5)
     sh = sub.add_parser("show", help="näita viimaseid mõõtmisi")
     sh = sub.add_parser("show", help="näita viimaseid mõõtmisi")
     sh.add_argument("--last", type=int, default=10)
     sh.add_argument("--last", type=int, default=10)
     args = parser.parse_args()
     args = parser.parse_args()
+    if args.cmd == "capture-dark":
+        return capture_reference("dark", args.last)
     if args.cmd == "capture-white":
     if args.cmd == "capture-white":
-        return capture_white(args.last)
+        return capture_reference("white", args.last)
     if args.cmd == "show":
     if args.cmd == "show":
         return show(args.last)
         return show(args.last)
     return 1
     return 1

+ 28 - 2
dev/varvusensor/collector/color.py

@@ -86,9 +86,35 @@ def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
     return x / total, y / total
     return x / total, y / total
 
 
 
 
-def analyze(channels: dict[str, float]) -> dict:
-    """Tagastab tuletatud värviväljad ClickHouse'i rea jaoks."""
+# D65 valgepunkt (CIE XYZ)
+D65_WHITE = (0.95047, 1.0, 1.08883)
+
+
+def _white_reference_xyz() -> tuple[float, float, float]:
+    """XYZ, kui kõik kanalid = 1 (st ühtlane/valge spekter)."""
+    x = y = z = 0.0
+    for wavelength in CHANNEL_WAVELENGTHS.values():
+        cx, cy, cz = cie_xyz_cmf(wavelength)
+        x += cx
+        y += cy
+        z += cz
+    return x, y, z
+
+
+_WHITE_XYZ = _white_reference_xyz()
+
+
+def analyze(channels: dict[str, float], white_balance: bool = True) -> dict:
+    """Tagastab tuletatud värviväljad ClickHouse'i rea jaoks.
+
+    white_balance=True korral skaleeritakse XYZ nii, et ühtlased kanalid
+    (valge) vastavad D65 valgele — muidu annab mudel kerge roosa nihke.
+    """
     x, y, z = channels_to_xyz(channels)
     x, y, z = channels_to_xyz(channels)
+    if white_balance and _WHITE_XYZ[0] > 0 and _WHITE_XYZ[1] > 0 and _WHITE_XYZ[2] > 0:
+        x *= D65_WHITE[0] / _WHITE_XYZ[0]
+        y *= D65_WHITE[1] / _WHITE_XYZ[1]
+        z *= D65_WHITE[2] / _WHITE_XYZ[2]
     cx, cy = chromaticity_xy(x, y, z)
     cx, cy = chromaticity_xy(x, y, z)
     r, g, b = xyz_to_srgb(x, y, z)
     r, g, b = xyz_to_srgb(x, y, z)
     return {
     return {

+ 30 - 14
dev/varvusensor/config/varvusensor.yaml

@@ -46,6 +46,7 @@ mqtt:
   port: 8883
   port: 8883
   username: !secret mqtt_user
   username: !secret mqtt_user
   password: !secret mqtt_pass
   password: !secret mqtt_pass
+  topic_prefix: varvus
   certificate_authority: |-
   certificate_authority: |-
     -----BEGIN CERTIFICATE-----
     -----BEGIN CERTIFICATE-----
     MIICizCCAhGgAwIBAgIQXd1w3TH4AchcGGp6BLgK/jAKBggqhkjOPQQDAzAuMQsw
     MIICizCCAhGgAwIBAgIQXd1w3TH4AchcGGp6BLgK/jAKBggqhkjOPQQDAzAuMQsw
@@ -138,6 +139,16 @@ mqtt:
     emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
     emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
     -----END CERTIFICATE-----
     -----END CERTIFICATE-----
 
 
+# Mõõtmise sisse/välja lüliti. MQTT: varvus/switch/sensor/command (ON/OFF);
+# olek: varvus/switch/sensor/state. OFF = ei publitseeri varvus/spectral ->
+# ClickHouse'i ei kirjutata (koguja saab andmed ainult MQTT kaudu).
+switch:
+  - platform: template
+    name: "Sensor"
+    id: sensor_on
+    optimistic: true
+    restore_mode: RESTORE_DEFAULT_ON
+
 i2c:
 i2c:
   sda: GPIO6
   sda: GPIO6
   scl: GPIO7
   scl: GPIO7
@@ -149,6 +160,7 @@ sensor:
   # vahetab baidid -> lisame lambdas vahetuse tagasi, et saada toelised 16-bit vaartused.
   # vahetab baidid -> lisame lambdas vahetuse tagasi, et saada toelised 16-bit vaartused.
   - platform: as7341
   - platform: as7341
     gain: X256
     gain: X256
+    update_interval: 5s
     f1:
     f1:
       name: "${friendly_name} F1 415nm"
       name: "${friendly_name} F1 415nm"
       id: ch_f1
       id: ch_f1
@@ -196,17 +208,21 @@ sensor:
 interval:
 interval:
   - interval: 5s
   - interval: 5s
     then:
     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};
+      - if:
+          condition:
+            switch.is_on: sensor_on
+          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};

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


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