|
@@ -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(
|