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 \
  && opentelemetry-bootstrap -a install
 
-COPY app.py .
+COPY app.py color.py .
 COPY static ./static
 
 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`):
   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.
+  **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`.
 
 ## 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/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/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 /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.
 
+**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
 
 - **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
 
+import asyncio
 import hmac
 import html
 import secrets
@@ -30,6 +31,9 @@ from datetime import datetime, timedelta
 from pathlib import Path
 from zoneinfo import ZoneInfo
 
+import color
+import numpy as np
+import paho.mqtt.client as mqtt
 import requests
 from fastapi import Body, Depends, FastAPI, Form, HTTPException, Query, Request
 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_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_PASSWORD = os.environ.get("WEB_PASSWORD", "")
 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) ---
 
-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")
 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)
     rows = ch_query(
         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"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 {
-        "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")
 async def varv_history(hours: int = Query(24, ge=1, le=168)) -> dict:
     """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}
 
 
+# --- 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")
 async def latest(request: Request) -> dict:
     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
 itsdangerous
 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");
   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");
   if (link) {
     link.href = authed ? "/logout" : "/login";
@@ -650,6 +657,7 @@ async function loadVarv() {
     renderVarvTimeline(history);
     renderVarvSpectrum();
     renderVarvChroma(varvLatest);
+    renderVarvCalibLive();
     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;
@@ -673,20 +681,24 @@ function renderVarvCurrent() {
     return;
   }
   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 =
-    `${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() {
   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.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) => {
     el.addEventListener("click", () => {
       varvSelected = Number(el.dataset.i) || 0;
@@ -711,13 +723,36 @@ function renderVarvTimeline(rows) {
   }).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() {
   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 refl = varvReflectance(c);
   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" });
+  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({
     grid: { left: 56, right: 16, top: 16, bottom: 32 },
     xAxis: {
@@ -727,17 +762,18 @@ function renderVarvSpectrum() {
     },
     yAxis: {
       type: "value",
-      axisLabel: { color: cssVar("--muted") },
+      max: refl ? 100 : undefined,
+      axisLabel: {
+        color: cssVar("--muted"),
+        formatter: refl ? "{value}%" : "{value}",
+      },
       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] },
-      })),
+      data,
     }],
   }, true);
   chartVarvSpectrum.resize();
@@ -791,6 +827,290 @@ function renderVarvChroma(rows) {
   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", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -816,6 +1136,7 @@ function switchView(view) {
     loadTransport();
   } else if (view === "varv") {
     loadVarv();
+    if (state.authed) { loadVarvPower(); loadVarvCalib(); loadVarvCcm(); }
   } else {
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
@@ -842,7 +1163,7 @@ function initTabs() {
     if (transport.active) loadTransport();
   }, 60000);
   setInterval(() => {
-    if (varvActive) loadVarv();
+    if (varvActive) { loadVarv(); if (state.authed) { loadVarvCalib(); loadVarvCcm(); } }
   }, 20000);
   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 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">
         <div class="varv-current">
           <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; }
   .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.
 
 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):
   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
 
@@ -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]
 
 
-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)
     if not rows:
-        print("Andmeid pole — mõõda esmalt valget pinda.")
+        print("Andmeid pole — mõõda esmalt (kaetud andur / valge pind).")
         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}")
+    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))
+    if kind == "white" and "dark" not in data:
+        print("NB! Tume referents puudub — soovitatav teha enne capture-dark (LED/tumedavool).")
     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] = {}
     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
+        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
 
 
 def show(last: int) -> int:
     rows = fetch_rows(last)
-    white = load_white()
+    data = load_calib()
+    dark = data.get("dark") or {}
+    white = data.get("white")
     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:
         channels = {b: row[b] for b in BANDS if row.get(b) is not None}
         raw = color.analyze(channels)
@@ -100,7 +125,7 @@ def show(last: int) -> int:
             f"(x={raw['x_chrom']:.3f} y={raw['y_chrom']:.3f})"
         )
         if white is not None:
-            cal = color.analyze(reflectance(channels, white))
+            cal = color.analyze(reflectance(channels, dark, white))
             line += (
                 f"  | kalib. {cal['rgb_hex']} "
                 f"(x={cal['x_chrom']:.3f} y={cal['y_chrom']:.3f})"
@@ -112,13 +137,17 @@ def show(last: int) -> int:
 def main() -> int:
     parser = argparse.ArgumentParser(description="AS7341 kalibreerimine")
     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.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-dark":
+        return capture_reference("dark", args.last)
     if args.cmd == "capture-white":
-        return capture_white(args.last)
+        return capture_reference("white", args.last)
     if args.cmd == "show":
         return show(args.last)
     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
 
 
-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)
+    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 {

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

@@ -46,6 +46,7 @@ mqtt:
   port: 8883
   username: !secret mqtt_user
   password: !secret mqtt_pass
+  topic_prefix: varvus
   certificate_authority: |-
     -----BEGIN CERTIFICATE-----
     MIICizCCAhGgAwIBAgIQXd1w3TH4AchcGGp6BLgK/jAKBggqhkjOPQQDAzAuMQsw
@@ -138,6 +139,16 @@ mqtt:
     emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
     -----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:
   sda: GPIO6
   scl: GPIO7
@@ -149,6 +160,7 @@ sensor:
   # vahetab baidid -> lisame lambdas vahetuse tagasi, et saada toelised 16-bit vaartused.
   - platform: as7341
     gain: X256
+    update_interval: 5s
     f1:
       name: "${friendly_name} F1 415nm"
       id: ch_f1
@@ -196,17 +208,21 @@ sensor:
 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};
+      - 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};

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


二進制
main/paperless/media/documents/thumbnails/0000073.webp