#!/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())