Преглед на файлове

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 седмица
родител
ревизия
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`):
   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**.
 - PWA-manifest (installitav telefonis), `noindex`.
 
 ## 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/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/me` | kas päring on autenditud (`{"authed":bool}`) |
 | `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.
 
+**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

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

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

@@ -3,3 +3,4 @@ uvicorn
 requests
 itsdangerous
 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");
   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");
   if (link) {
     link.href = authed ? "/logout" : "/login";
@@ -791,6 +794,74 @@ 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();
+  }
+}
+
 window.addEventListener("resize", () => {
   if (chartVarvSpectrum) chartVarvSpectrum.resize();
   if (chartVarvChroma) chartVarvChroma.resize();
@@ -816,6 +887,7 @@ function switchView(view) {
     loadTransport();
   } else if (view === "varv") {
     loadVarv();
+    if (state.authed) loadVarvPower();
   } else {
     if (chart) chart.resize();
     if (chartEnergy) chartEnergy.resize();

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

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

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