Sfoglia il codice sorgente

dev: ClickHouse püsivaks + Zigbee koguja (uus67.temperatuur, uus67.ukseandur)

- clickhouse: eemaldatud experimental profiil, pordid ainult Tailscale'ile, mem_limit 2g
- zigbee-collector: valvuri MQTT -> ClickHouse; temp tunniaegregaat, uksed ainult olekumuutused
- LOEMIND: skeemid ja koguja dokumenteeritud
ardo 1 mese fa
parent
commit
74668d50c1

+ 104 - 5
dev/LOEMIND.md

@@ -11,14 +11,18 @@ mille eesmärk on Oracle-baaside ülemineku ettevalmistamine).
 |--------|---------|
 | Pilt | `clickhouse/clickhouse-server:26.8.1.2041` |
 | Konteiner | `clickhouse` |
-| HTTP API | **8125** otse (ClickHouse'i vaikimisi 8123 on Home Assistant'il) — **8124** läbi `clickstack-proxy` |
-| Native TCP | **9004** (vaikimisi 9000 on portainer'il) |
-| Kasutaja | `default` (ja `dev`) |
-| Parool | `dev_clickhouse_2026` (mõlemal) |
+| Püsivus | **alati aktiivne** (ei ole enam `experimental` profiilis), `restart: unless-stopped`, `mem_limit: 2g` |
+| HTTP API | **8125**, seotud **ainult Tailscale'ile** (`100.87.1.24:8125`) — **8124** läbi `clickstack-proxy` |
+| Native TCP | **9004**, seotud ainult Tailscale'ile (`100.87.1.24:9004`) |
+| Kasutaja | `default` (ja `dev`, `zigbee`) |
+| Parool | `dev_clickhouse_2026` (default/dev); `zigbee` kasutaja parool on `dev/.env` failis |
 | Andmed | `./data/clickhouse` (gitignore'd) |
 | Config | `./config/` (merge'd `/etc/clickhouse-server/config.d`) |
 | Võrk | `main_ai_network` |
 
+> **Turvamärkus (2026-09-17):** pordid 8125/9004 ei ole enam `0.0.0.0` peal, vaid
+> ainult Tailscale'i aadressil `100.87.1.24`. LAN-ist ligi ei pääse.
+
 ## Web UI
 
 ClickHouse'il on **sisseehitatud veebiliides** (`/play` — SQL playground):
@@ -44,6 +48,101 @@ docker-compose -f /opt/docker-compose/dev/docker-compose.yml logs -f clickhouse
 docker exec -it clickhouse clickhouse-client --user default --password dev_clickhouse_2026
 ```
 
+## Zigbee andurite koguja (`zigbee-collector`)
+
+Valvuri (`valvur`, Orange Pi Zero 3, `100.80.254.54`) Zigbee2MQTT andurite
+andmed kogutakse ClickHouse'i. **Arhitektuur: ohmu loeb (pull)** — koguja
+jookseb ohmu peal, tellib valvuri MQTT-d ja kirjutab kohalikku ClickHouse'i.
+Valvurile ei lähe andmebaasi kredentiaale.
+
+| Omadus | Väärtus |
+|--------|---------|
+| Kood | `./zigbee-collector/` (`collector.py`, `Dockerfile`, `requirements.txt`) |
+| Konteiner | `zigbee-collector` (püsiv, `restart: unless-stopped`, `mem_limit: 128m`) |
+| Konf/paroolid | `dev/.env` (gitignore'itud, chmod 600) |
+| MQTT allikas | `mqtt://100.80.254.54:1883` |
+| Temperatuuriandur | SONOFF SNZB-02P (`0x70d07efffea3e4f6`, `Temperatuur`) → `uus67.temperatuur` |
+| Kontaktandurid | SONOFF SNZB-04PR2 ×4 (Välisuks, Rõdu, Kontori uks, Kontori aken) → `uus67.ukseandur` |
+
+**Loogika:**
+- **Temperatuur** (`MQTT_TOPIC`): mõõtmised kogutakse mälus täistunni kaupa ja
+  kirjutatakse iga täistunni järel üks agregeeritud rida (`temperature`/`humidity`
+  = keskmine, lisaks min/max, `samples`, `battery`, `linkquality`). Andur
+  raporteerib u. iga 10 min → tunnis mitu mõõtmist.
+- **Uksed** (`DOOR_TOPICS`): salvestatakse **ainult olekumuutused**. Perioodilised
+  ~2 h südamelöögid (sama `contact`) jäetakse vahele. Käivitusel seemendatakse
+  viimane teadaolev olek andmebaasist; esimene nähtud olek salvestatakse baasreana.
+
+### Skeem
+
+```sql
+CREATE DATABASE IF NOT EXISTS uus67;
+
+CREATE TABLE uus67.temperatuur (
+    hour          DateTime('Europe/Tallinn'),
+    device        LowCardinality(String),
+    temperature   Nullable(Float32),   -- tunni keskmine
+    humidity      Nullable(Float32),   -- tunni keskmine
+    temp_min      Nullable(Float32),
+    temp_max      Nullable(Float32),
+    humidity_min  Nullable(Float32),
+    humidity_max  Nullable(Float32),
+    samples       UInt32,
+    battery       Nullable(UInt8),
+    linkquality   Nullable(UInt8)
+) ENGINE = MergeTree
+PARTITION BY toYYYYMM(hour)
+ORDER BY (device, hour);
+```
+
+Kontaktandurite tabel (ainult olekumuutused):
+
+```sql
+CREATE TABLE uus67.ukseandur (
+    ts          DateTime('Europe/Tallinn'),
+    device      LowCardinality(String),
+    contact     Bool,                   -- toorväärtus Z2M-st
+    olek        LowCardinality(String), -- 'avatud' / 'suletud'
+    battery     Nullable(UInt8),
+    voltage     Nullable(UInt16),
+    linkquality Nullable(UInt8),
+    battery_low Nullable(Bool)
+) ENGINE = MergeTree
+PARTITION BY toYYYYMM(ts)
+ORDER BY (device, ts);
+```
+
+> **`contact` semantika (SNZB-04PR2):** `contact: true` = **suletud**,
+> `contact: false` = **avatud**. Kinnitatud Zigbee2MQTT HA-discovery'st
+> (`device_class: door`, `payload_on: false`, `payload_off: true`).
+
+### Käsud
+
+```bash
+# Koguja logid
+docker logs -f zigbee-collector
+
+# Viimased tunniread
+docker exec clickhouse clickhouse-client --user default --password dev_clickhouse_2026 \
+  --query "SELECT * FROM uus67.temperatuur ORDER BY hour DESC LIMIT 24"
+
+# Viimased ukseoleku muutused
+docker exec clickhouse clickhouse-client --user default --password dev_clickhouse_2026 \
+  --query "SELECT * FROM uus67.ukseandur ORDER BY ts DESC LIMIT 24"
+
+# Koguja taaskäivitamine (nt .env muudatuse järel)
+docker compose -f /opt/docker-compose/dev/docker-compose.yml up -d --build zigbee-collector
+```
+
+### Valvuri MQTT turvakõvadus (2026-09-17)
+
+- `mosquitto.conf`: kaks listener'it — `127.0.0.1:1883` (kohalik Z2M) ja
+  `100.80.254.54:1883` (Tailscale). LAN-ist MQTT-le ligi ei pääse.
+- Zigbee2MQTT seadmetel (`Temperatuur` + 4 kontaktandurit) on `retain: true` →
+  koguja taaskäivitumisel saab kohe viimased väärtused (varem teated ei olnud retained).
+- Valvuri konfiguratsioonid on root-omanduses; muutmiseks on vaja `sudo`-t
+  (varukoopiad: `*.bak-20260917`).
+
 ## ClickStack (HyperDX) — observability UI
 
 ClickStack = HyperDX UI + ClickHouse + OTel-kollektor (logid/traces/meetrikud).
@@ -116,7 +215,7 @@ docker exec -it clickhouse clickhouse-client --user default --password dev_click
 
 ## Märkused
 
-- Pordid valitud konfliktivabaks: 8124 (HTTP), 9004 (native). 8123 = Home Assistant, 9000 = portainer.
+- Pordid valitud konfliktivabaks: 8125 (HTTP, Tailscale-only), 9004 (native, Tailscale-only), 8124 (proxy, clickstack). 8123 = Home Assistant, 9000 = portainer.
 - Oracle → ClickHouse: ClickHouse on veerupõhine OLAP; sobib analüüsi/aruandluse koormuste jaoks.
   Postgres-migratsioon on eeskujuks: `postgresql()` tabelifunktsioon lihtsustab oluliselt (Oracle'i jaoks
   tuleb vastav funktsioon/ODBC-vood eraldi uurida).

+ 27 - 3
dev/docker-compose.yml

@@ -2,8 +2,8 @@ services:
   clickhouse:
     image: clickhouse/clickhouse-server:26.8.1.2041
     container_name: clickhouse
-    profiles: ["experimental"]
     restart: unless-stopped
+    mem_limit: 2g
     environment:
       - TZ=Europe/Tallinn
       - CLICKHOUSE_USER=default
@@ -11,8 +11,8 @@ services:
       - CLICKHOUSE_DB=default
       - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
     ports:
-      - "8125:8123"   # ClickHouse HTTP (8124 on nüüd clickstack-proxy; DBeaver saab 8124 kaudu ikka ligi)
-      - "9004:9000"   # native TCP (clickhouse-client)
+      - "100.87.1.24:8125:8123"   # ClickHouse HTTP (ainult Tailscale)
+      - "100.87.1.24:9004:9000"   # native TCP (ainult Tailscale)
     volumes:
       - ./data/clickhouse:/var/lib/clickhouse
       - ./config:/etc/clickhouse-server/config.d
@@ -25,6 +25,30 @@ services:
     networks:
       - main_ai_network
 
+  # Zigbee andurite koguja: tellib valvuri MQTT-d ja kirjutab tunniaegregaadid ClickHouse'i.
+  # Töötab püsivalt; tunniaegregaat arvutatakse mälus ja flush'itakse iga täistunni järel.
+  zigbee-collector:
+    build: ./zigbee-collector
+    container_name: zigbee-collector
+    restart: unless-stopped
+    mem_limit: 128m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - MQTT_HOST=${MQTT_HOST:-100.80.254.54}
+      - MQTT_PORT=${MQTT_PORT:-1883}
+      - MQTT_TOPIC=${MQTT_TOPIC:-zigbee2mqtt/Temperatuur}
+      - DOOR_TOPICS=${DOOR_TOPICS:-}
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_TABLE=${CH_TABLE:-temperatuur}
+      - CH_DOOR_TABLE=${CH_DOOR_TABLE:-ukseandur}
+    depends_on:
+      - clickhouse
+    networks:
+      - main_ai_network
+
   # ClickStack (HyperDX all-in-one) — observability UI (logs/traces/metrics)
   # NB! Port 8124 on ClickHouse'i HTTP; ClickStack UI jookseb pordil 18080.
   clickstack:

+ 10 - 0
dev/zigbee-collector/Dockerfile

@@ -0,0 +1,10 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY collector.py .
+
+CMD ["python", "-u", "collector.py"]

+ 306 - 0
dev/zigbee-collector/collector.py

@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""Zigbee andurite koguja.
+
+Kaks voogu:
+
+1. Temperatuur/niiskus (``MQTT_TOPIC``): mõõtmised koondatakse mälus täistunni
+   kaupa ja kirjutatakse iga täistunni järel üks agregeeritud rida
+   (``CH_TABLE``, vaikimisi ``uus67.temperatuur``).
+2. Kontaktandurid (``DOOR_TOPICS``): salvestatakse **ainult olekumuutused**
+   (``CH_DOOR_TABLE``, vaikimisi ``uus67.ukseandur``). Perioodilised
+   südamelöögid (sama ``contact``) jäetakse vahele. Käivitusel seemendatakse
+   viimane teadaolev olek andmebaasist; kui rida pole, salvestatakse esimene
+   nähtud olek baasreana.
+
+Käivitub konteineris, mis on main_ai_network võrgus (ClickHouse nimega
+lahenduv). Välised seadistused tulevad keskkonnamuutujatest.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import signal
+import threading
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import paho.mqtt.client as mqtt
+import requests
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(message)s",
+)
+LOG = logging.getLogger("zigbee-collector")
+
+TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
+
+MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
+MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
+MQTT_USER = os.environ.get("MQTT_USER", "")
+MQTT_PASS = os.environ.get("MQTT_PASS", "")
+MQTT_TOPIC = os.environ.get("MQTT_TOPIC", "zigbee2mqtt/Temperatuur")
+DOOR_TOPICS = {
+    t.strip()
+    for t in os.environ.get("DOOR_TOPICS", "").split(",")
+    if t.strip()
+}
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "default")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+CH_TABLE = os.environ.get("CH_TABLE", "temperatuur")
+CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
+
+# Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
+DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
+
+CHECK_INTERVAL_SEC = 20
+
+
+def current_hour(now: datetime | None = None) -> datetime:
+    now = now or datetime.now(TZ)
+    return now.replace(minute=0, second=0, microsecond=0)
+
+
+def _as_int(value: Any) -> int | None:
+    if isinstance(value, bool) or not isinstance(value, (int, float)):
+        return None
+    return int(value)
+
+
+class HourBucket:
+    """Ühe tunni jooksul kogutud temperatuuri/niiskuse mõõtmised."""
+
+    def __init__(self, hour: datetime) -> None:
+        self.hour = hour
+        self.temps: list[float] = []
+        self.hums: list[float] = []
+        self.battery: int | None = None
+        self.linkquality: int | None = None
+
+    def add(self, payload: dict[str, Any]) -> None:
+        if isinstance(payload.get("temperature"), (int, float)):
+            self.temps.append(float(payload["temperature"]))
+        if isinstance(payload.get("humidity"), (int, float)):
+            self.hums.append(float(payload["humidity"]))
+        if isinstance(payload.get("battery"), (int, float)):
+            self.battery = int(payload["battery"])
+        if isinstance(payload.get("linkquality"), (int, float)):
+            self.linkquality = int(payload["linkquality"])
+
+    @property
+    def empty(self) -> bool:
+        return not self.temps and not self.hums
+
+    def row(self) -> dict[str, Any]:
+        def avg(values: list[float]) -> float | None:
+            return round(sum(values) / len(values), 2) if values else None
+
+        return {
+            "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
+            "device": DEVICE,
+            "temperature": avg(self.temps),
+            "humidity": avg(self.hums),
+            "temp_min": min(self.temps) if self.temps else None,
+            "temp_max": max(self.temps) if self.temps else None,
+            "humidity_min": min(self.hums) if self.hums else None,
+            "humidity_max": max(self.hums) if self.hums else None,
+            "samples": max(len(self.temps), len(self.hums)),
+            "battery": self.battery,
+            "linkquality": self.linkquality,
+        }
+
+
+class DoorTracker:
+    """Kontaktandurite olekumuutuste jälgija.
+
+    ``last`` hoiab viimast teadaolevat ``contact`` väärtust anduri kohta.
+    Südamelöögid (sama väärtus) ei tekita rida. Esimene nähtud väärtus anduri
+    kohta, millel puudub varasem olek, salvestatakse baasreana.
+    """
+
+    def __init__(self, last_states: dict[str, bool] | None = None) -> None:
+        self.last: dict[str, bool] = dict(last_states or {})
+
+    def process(
+        self, device: str, payload: dict[str, Any], ts: datetime
+    ) -> dict[str, Any] | None:
+        contact = payload.get("contact")
+        if not isinstance(contact, bool):
+            return None
+        if device in self.last and self.last[device] == contact:
+            return None
+        self.last[device] = contact
+        battery_low = payload.get("battery_low")
+        return {
+            "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
+            "device": device,
+            "contact": contact,
+            # SNZB-04P: contact=true = suletud, false = avatud (Z2M HA discovery
+            # payload_on=false, payload_off=true, device_class=door).
+            "olek": "suletud" if contact else "avatud",
+            "battery": _as_int(payload.get("battery")),
+            "voltage": _as_int(payload.get("voltage")),
+            "linkquality": _as_int(payload.get("linkquality")),
+            "battery_low": battery_low if isinstance(battery_low, bool) else None,
+        }
+
+
+class ClickHouseWriter:
+    def __init__(self, table: str) -> None:
+        self.table = table
+        self.pending: list[dict[str, Any]] = []
+
+    def enqueue(self, row: dict[str, Any]) -> None:
+        self.pending.append(row)
+
+    def flush(self) -> None:
+        if not self.pending:
+            return
+        body = "\n".join(json.dumps(r, ensure_ascii=False) for r in self.pending)
+        try:
+            resp = requests.post(
+                f"{CH_URL}/",
+                params={
+                    "database": CH_DB,
+                    "query": f"INSERT INTO {CH_DB}.{self.table} FORMAT JSONEachRow",
+                },
+                data=body.encode("utf-8"),
+                auth=(CH_USER, CH_PASS),
+                timeout=15,
+            )
+            resp.raise_for_status()
+            LOG.info("ClickHouse'i (%s) kirjutatud %d rida", self.table, len(self.pending))
+            self.pending.clear()
+        except Exception:
+            LOG.exception(
+                "ClickHouse'i (%s) kirjutamine ebaõnnestus, jätan %d rida järjekorda",
+                self.table,
+                len(self.pending),
+            )
+
+
+def load_last_states() -> dict[str, bool]:
+    """Loeb andmebaasist iga anduri viimase teadaoleva ``contact`` väärtuse."""
+    query = (
+        f"SELECT device, argMax(contact, ts) AS contact "
+        f"FROM {CH_DB}.{CH_DOOR_TABLE} GROUP BY device FORMAT JSONEachRow"
+    )
+    try:
+        resp = requests.post(
+            f"{CH_URL}/",
+            params={"database": CH_DB, "query": query},
+            auth=(CH_USER, CH_PASS),
+            timeout=15,
+        )
+        resp.raise_for_status()
+        states: dict[str, bool] = {}
+        for line in resp.text.splitlines():
+            if not line.strip():
+                continue
+            obj = json.loads(line)
+            value = obj["contact"]
+            states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
+        LOG.info("Seemendatud %d anduri viimane olek", len(states))
+        return states
+    except Exception:
+        LOG.exception("Viimase oleku laadimine ebaõnnestus, alustan tühjalt")
+        return {}
+
+
+def main() -> int:
+    lock = threading.Lock()
+    stop = threading.Event()
+    bucket = HourBucket(current_hour())
+    temp_writer = ClickHouseWriter(CH_TABLE)
+    door_writer = ClickHouseWriter(CH_DOOR_TABLE)
+    door_tracker = DoorTracker(load_last_states())
+
+    def on_connect(client, userdata, flags, reason_code, properties=None):
+        if reason_code == 0:
+            topics = [MQTT_TOPIC, *sorted(DOOR_TOPICS)]
+            LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
+            for topic in topics:
+                client.subscribe(topic, qos=1)
+        else:
+            LOG.error("MQTT ühendus ebaõnnestus: %s", reason_code)
+
+    def on_disconnect(client, userdata, flags, reason_code, properties=None):
+        LOG.warning("MQTT ühendus katkes: %s", reason_code)
+
+    def on_message(client, userdata, msg):
+        try:
+            payload = json.loads(msg.payload.decode("utf-8"))
+        except Exception:
+            LOG.exception("Vigane MQTT teade teemal %s", msg.topic)
+            return
+        if not isinstance(payload, dict):
+            return
+        now = datetime.now(TZ)
+        with lock:
+            if msg.topic == MQTT_TOPIC:
+                bucket.add(payload)
+            elif msg.topic in DOOR_TOPICS:
+                device = msg.topic.rsplit("/", 1)[-1]
+                row = door_tracker.process(device, payload, now)
+                if row is not None:
+                    LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
+                    door_writer.enqueue(row)
+
+    client = mqtt.Client(
+        mqtt.CallbackAPIVersion.VERSION2,
+        client_id="zigbee-collector",
+    )
+    if MQTT_USER:
+        client.username_pw_set(MQTT_USER, MQTT_PASS)
+    client.on_connect = on_connect
+    client.on_disconnect = on_disconnect
+    client.on_message = on_message
+    client.reconnect_delay_set(min_delay=1, max_delay=60)
+    client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60)
+    client.loop_start()
+
+    def shutdown(signum, frame):
+        LOG.info("Lõpetamissignaal (%s), sulgen", signum)
+        stop.set()
+
+    signal.signal(signal.SIGTERM, shutdown)
+    signal.signal(signal.SIGINT, shutdown)
+
+    LOG.info(
+        "Koguja käivitatud: temp=%s.%s, uksed=%s.%s (%d teemat)",
+        CH_DB,
+        CH_TABLE,
+        CH_DB,
+        CH_DOOR_TABLE,
+        len(DOOR_TOPICS),
+    )
+
+    while not stop.is_set():
+        stop.wait(CHECK_INTERVAL_SEC)
+        now = datetime.now(TZ)
+        with lock:
+            if current_hour(now) != bucket.hour:
+                finished = bucket
+                bucket = HourBucket(current_hour(now))
+            else:
+                finished = None
+        if finished is not None and not finished.empty:
+            temp_writer.enqueue(finished.row())
+        temp_writer.flush()
+        door_writer.flush()
+
+    temp_writer.flush()
+    door_writer.flush()
+    client.loop_stop()
+    client.disconnect()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 2 - 0
dev/zigbee-collector/requirements.txt

@@ -0,0 +1,2 @@
+paho-mqtt>=2.0,<3
+requests>=2.31