Bladeren bron

nutikodu-web: Varv — mõõtja sisse/välja lüliti (ainult sisselogitult)

- ESPHome: template switch sensor_on, topic_prefix varvus; interval gate (OFF = ei publitseeri -> CH ei kirjutata)

- nutikodu-web: GET/POST /api/varv/power (require_auth), MQTT TLS edge-brokerile, retained olek

- UI: Varv vahelehel lüliti nupud, nahtavad ainult sisselogitult; anonüümne saab 401
ardo 1 week geleden
bovenliggende
commit
4326152a0f

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

@@ -28,6 +28,9 @@ 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**.
 - PWA-manifest (installitav telefonis), `noindex`.
 - PWA-manifest (installitav telefonis), `noindex`.
 
 
 ## API
 ## API
@@ -47,6 +50,8 @@ 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/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 +59,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

+ 96 - 0
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,7 @@ from datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
+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 +48,16 @@ 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")
+
 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)
@@ -594,6 +606,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(

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

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

+ 72 - 0
dev/nutikodu-web/static/app.js

@@ -539,6 +539,9 @@ 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;
+  if (authed) { initVarvPowerButtons(); loadVarvPower(); }
   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";
@@ -791,6 +794,74 @@ 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();
+  }
+}
+
 window.addEventListener("resize", () => {
 window.addEventListener("resize", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -816,6 +887,7 @@ function switchView(view) {
     loadTransport();
     loadTransport();
   } else if (view === "varv") {
   } else if (view === "varv") {
     loadVarv();
     loadVarv();
+    if (state.authed) loadVarvPower();
   } else {
   } else {
     if (chart) chart.resize();
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();
     if (chartEnergy) chartEnergy.resize();

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

@@ -113,6 +113,18 @@
     </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">
       <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; }

+ 29 - 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
@@ -196,17 +207,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};