collector.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. #!/usr/bin/env python3
  2. """Zigbee andurite koguja.
  3. Kaks voogu:
  4. 1. Temperatuur/niiskus (``MQTT_TOPIC``): mõõtmised koondatakse mälus täistunni
  5. kaupa ja kirjutatakse iga täistunni järel üks agregeeritud rida
  6. (``CH_TABLE``, vaikimisi ``uus67.temperatuur``).
  7. 2. Kontaktandurid (``DOOR_TOPICS``): salvestatakse **ainult olekumuutused**
  8. (``CH_DOOR_TABLE``, vaikimisi ``uus67.ukseandur``). Perioodilised
  9. südamelöögid (sama ``contact``) jäetakse vahele. Käivitusel seemendatakse
  10. viimane teadaolev olek andmebaasist; kui rida pole, salvestatakse esimene
  11. nähtud olek baasreana.
  12. Käivitub konteineris, mis on main_ai_network võrgus (ClickHouse nimega
  13. lahenduv). Välised seadistused tulevad keskkonnamuutujatest.
  14. """
  15. from __future__ import annotations
  16. import json
  17. import logging
  18. import os
  19. import signal
  20. import threading
  21. from datetime import datetime
  22. from typing import Any
  23. from zoneinfo import ZoneInfo
  24. import paho.mqtt.client as mqtt
  25. import requests
  26. logging.basicConfig(
  27. level=logging.INFO,
  28. format="%(asctime)s %(levelname)s %(message)s",
  29. )
  30. LOG = logging.getLogger("zigbee-collector")
  31. TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
  32. MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
  33. MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
  34. MQTT_USER = os.environ.get("MQTT_USER", "")
  35. MQTT_PASS = os.environ.get("MQTT_PASS", "")
  36. MQTT_TOPIC = os.environ.get("MQTT_TOPIC", "zigbee2mqtt/Temperatuur")
  37. DOOR_TOPICS = {
  38. t.strip()
  39. for t in os.environ.get("DOOR_TOPICS", "").split(",")
  40. if t.strip()
  41. }
  42. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  43. CH_USER = os.environ.get("CH_USER", "default")
  44. CH_PASS = os.environ.get("CH_PASS", "")
  45. CH_DB = os.environ.get("CH_DB", "uus67")
  46. CH_TABLE = os.environ.get("CH_TABLE", "temperatuur")
  47. CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
  48. # Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
  49. DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
  50. CHECK_INTERVAL_SEC = 20
  51. def current_hour(now: datetime | None = None) -> datetime:
  52. now = now or datetime.now(TZ)
  53. return now.replace(minute=0, second=0, microsecond=0)
  54. def _as_int(value: Any) -> int | None:
  55. if isinstance(value, bool) or not isinstance(value, (int, float)):
  56. return None
  57. return int(value)
  58. class HourBucket:
  59. """Ühe tunni jooksul kogutud temperatuuri/niiskuse mõõtmised."""
  60. def __init__(self, hour: datetime) -> None:
  61. self.hour = hour
  62. self.temps: list[float] = []
  63. self.hums: list[float] = []
  64. self.battery: int | None = None
  65. self.linkquality: int | None = None
  66. def add(self, payload: dict[str, Any]) -> None:
  67. if isinstance(payload.get("temperature"), (int, float)):
  68. self.temps.append(float(payload["temperature"]))
  69. if isinstance(payload.get("humidity"), (int, float)):
  70. self.hums.append(float(payload["humidity"]))
  71. if isinstance(payload.get("battery"), (int, float)):
  72. self.battery = int(payload["battery"])
  73. if isinstance(payload.get("linkquality"), (int, float)):
  74. self.linkquality = int(payload["linkquality"])
  75. @property
  76. def empty(self) -> bool:
  77. return not self.temps and not self.hums
  78. def row(self) -> dict[str, Any]:
  79. def avg(values: list[float]) -> float | None:
  80. return round(sum(values) / len(values), 2) if values else None
  81. return {
  82. "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
  83. "device": DEVICE,
  84. "temperature": avg(self.temps),
  85. "humidity": avg(self.hums),
  86. "temp_min": min(self.temps) if self.temps else None,
  87. "temp_max": max(self.temps) if self.temps else None,
  88. "humidity_min": min(self.hums) if self.hums else None,
  89. "humidity_max": max(self.hums) if self.hums else None,
  90. "samples": max(len(self.temps), len(self.hums)),
  91. "battery": self.battery,
  92. "linkquality": self.linkquality,
  93. }
  94. class DoorTracker:
  95. """Kontaktandurite olekumuutuste jälgija.
  96. ``last`` hoiab viimast teadaolevat ``contact`` väärtust anduri kohta.
  97. Südamelöögid (sama väärtus) ei tekita rida. Esimene nähtud väärtus anduri
  98. kohta, millel puudub varasem olek, salvestatakse baasreana.
  99. """
  100. def __init__(self, last_states: dict[str, bool] | None = None) -> None:
  101. self.last: dict[str, bool] = dict(last_states or {})
  102. def process(
  103. self, device: str, payload: dict[str, Any], ts: datetime
  104. ) -> dict[str, Any] | None:
  105. contact = payload.get("contact")
  106. if not isinstance(contact, bool):
  107. return None
  108. if device in self.last and self.last[device] == contact:
  109. return None
  110. self.last[device] = contact
  111. battery_low = payload.get("battery_low")
  112. return {
  113. "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
  114. "device": device,
  115. "contact": contact,
  116. # SNZB-04P: contact=true = suletud, false = avatud (Z2M HA discovery
  117. # payload_on=false, payload_off=true, device_class=door).
  118. "olek": "suletud" if contact else "avatud",
  119. "battery": _as_int(payload.get("battery")),
  120. "voltage": _as_int(payload.get("voltage")),
  121. "linkquality": _as_int(payload.get("linkquality")),
  122. "battery_low": battery_low if isinstance(battery_low, bool) else None,
  123. }
  124. class ClickHouseWriter:
  125. def __init__(self, table: str) -> None:
  126. self.table = table
  127. self.pending: list[dict[str, Any]] = []
  128. def enqueue(self, row: dict[str, Any]) -> None:
  129. self.pending.append(row)
  130. def flush(self) -> None:
  131. if not self.pending:
  132. return
  133. body = "\n".join(json.dumps(r, ensure_ascii=False) for r in self.pending)
  134. try:
  135. resp = requests.post(
  136. f"{CH_URL}/",
  137. params={
  138. "database": CH_DB,
  139. "query": f"INSERT INTO {CH_DB}.{self.table} FORMAT JSONEachRow",
  140. },
  141. data=body.encode("utf-8"),
  142. auth=(CH_USER, CH_PASS),
  143. timeout=15,
  144. )
  145. resp.raise_for_status()
  146. LOG.info("ClickHouse'i (%s) kirjutatud %d rida", self.table, len(self.pending))
  147. self.pending.clear()
  148. except Exception:
  149. LOG.exception(
  150. "ClickHouse'i (%s) kirjutamine ebaõnnestus, jätan %d rida järjekorda",
  151. self.table,
  152. len(self.pending),
  153. )
  154. def load_last_states() -> dict[str, bool]:
  155. """Loeb andmebaasist iga anduri viimase teadaoleva ``contact`` väärtuse."""
  156. query = (
  157. f"SELECT device, argMax(contact, ts) AS contact "
  158. f"FROM {CH_DB}.{CH_DOOR_TABLE} GROUP BY device FORMAT JSONEachRow"
  159. )
  160. try:
  161. resp = requests.post(
  162. f"{CH_URL}/",
  163. params={"database": CH_DB, "query": query},
  164. auth=(CH_USER, CH_PASS),
  165. timeout=15,
  166. )
  167. resp.raise_for_status()
  168. states: dict[str, bool] = {}
  169. for line in resp.text.splitlines():
  170. if not line.strip():
  171. continue
  172. obj = json.loads(line)
  173. value = obj["contact"]
  174. states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
  175. LOG.info("Seemendatud %d anduri viimane olek", len(states))
  176. return states
  177. except Exception:
  178. LOG.exception("Viimase oleku laadimine ebaõnnestus, alustan tühjalt")
  179. return {}
  180. def main() -> int:
  181. lock = threading.Lock()
  182. stop = threading.Event()
  183. bucket = HourBucket(current_hour())
  184. temp_writer = ClickHouseWriter(CH_TABLE)
  185. door_writer = ClickHouseWriter(CH_DOOR_TABLE)
  186. door_tracker = DoorTracker(load_last_states())
  187. def on_connect(client, userdata, flags, reason_code, properties=None):
  188. if reason_code == 0:
  189. topics = [MQTT_TOPIC, *sorted(DOOR_TOPICS)]
  190. LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
  191. for topic in topics:
  192. client.subscribe(topic, qos=1)
  193. else:
  194. LOG.error("MQTT ühendus ebaõnnestus: %s", reason_code)
  195. def on_disconnect(client, userdata, flags, reason_code, properties=None):
  196. LOG.warning("MQTT ühendus katkes: %s", reason_code)
  197. def on_message(client, userdata, msg):
  198. try:
  199. payload = json.loads(msg.payload.decode("utf-8"))
  200. except Exception:
  201. LOG.exception("Vigane MQTT teade teemal %s", msg.topic)
  202. return
  203. if not isinstance(payload, dict):
  204. return
  205. now = datetime.now(TZ)
  206. with lock:
  207. if msg.topic == MQTT_TOPIC:
  208. bucket.add(payload)
  209. elif msg.topic in DOOR_TOPICS:
  210. device = msg.topic.rsplit("/", 1)[-1]
  211. row = door_tracker.process(device, payload, now)
  212. if row is not None:
  213. LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
  214. door_writer.enqueue(row)
  215. client = mqtt.Client(
  216. mqtt.CallbackAPIVersion.VERSION2,
  217. client_id="zigbee-collector",
  218. )
  219. if MQTT_USER:
  220. client.username_pw_set(MQTT_USER, MQTT_PASS)
  221. client.on_connect = on_connect
  222. client.on_disconnect = on_disconnect
  223. client.on_message = on_message
  224. client.reconnect_delay_set(min_delay=1, max_delay=60)
  225. client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60)
  226. client.loop_start()
  227. def shutdown(signum, frame):
  228. LOG.info("Lõpetamissignaal (%s), sulgen", signum)
  229. stop.set()
  230. signal.signal(signal.SIGTERM, shutdown)
  231. signal.signal(signal.SIGINT, shutdown)
  232. LOG.info(
  233. "Koguja käivitatud: temp=%s.%s, uksed=%s.%s (%d teemat)",
  234. CH_DB,
  235. CH_TABLE,
  236. CH_DB,
  237. CH_DOOR_TABLE,
  238. len(DOOR_TOPICS),
  239. )
  240. while not stop.is_set():
  241. stop.wait(CHECK_INTERVAL_SEC)
  242. now = datetime.now(TZ)
  243. with lock:
  244. if current_hour(now) != bucket.hour:
  245. finished = bucket
  246. bucket = HourBucket(current_hour(now))
  247. else:
  248. finished = None
  249. if finished is not None and not finished.empty:
  250. temp_writer.enqueue(finished.row())
  251. temp_writer.flush()
  252. door_writer.flush()
  253. temp_writer.flush()
  254. door_writer.flush()
  255. client.loop_stop()
  256. client.disconnect()
  257. return 0
  258. if __name__ == "__main__":
  259. raise SystemExit(main())