collector.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. #!/usr/bin/env python3
  2. """Zigbee andurite koguja.
  3. Kolm 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``).
  9. 3. Häired (``CH_FAULT_TABLE``, vaikimisi ``uus67.vead``): **ainult muutused**
  10. (``aktiveeritud`` / ``taastatud``). Allikad: aku tühjenemine
  11. (``battery_low`` või ``battery`` lävend), nõrk signaal (``linkquality``),
  12. madal pinge (``voltage``), anduri kadumine (viimase teate timeout),
  13. koguja MQTT-ühenduse kadu ja Zigbee2MQTT bridge'i offline-olek.
  14. Käivitub konteineris, mis on main_ai_network võrgus (ClickHouse nimega
  15. lahenduv). Välised seadistused tulevad keskkonnamuutujatest.
  16. """
  17. from __future__ import annotations
  18. import json
  19. import logging
  20. import os
  21. import signal
  22. import threading
  23. from datetime import datetime, timedelta
  24. from typing import Any
  25. from zoneinfo import ZoneInfo
  26. import paho.mqtt.client as mqtt
  27. import requests
  28. logging.basicConfig(
  29. level=logging.INFO,
  30. format="%(asctime)s %(levelname)s %(message)s",
  31. )
  32. LOG = logging.getLogger("zigbee-collector")
  33. TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
  34. def _env_float(name: str, default: float) -> float:
  35. try:
  36. return float(os.environ.get(name, default))
  37. except (TypeError, ValueError):
  38. return default
  39. MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
  40. MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
  41. MQTT_USER = os.environ.get("MQTT_USER", "")
  42. MQTT_PASS = os.environ.get("MQTT_PASS", "")
  43. MQTT_TOPIC = os.environ.get("MQTT_TOPIC", "zigbee2mqtt/Temperatuur")
  44. DOOR_TOPICS = {
  45. t.strip()
  46. for t in os.environ.get("DOOR_TOPICS", "").split(",")
  47. if t.strip()
  48. }
  49. BRIDGE_STATE_TOPIC = os.environ.get("BRIDGE_STATE_TOPIC", "zigbee2mqtt/bridge/state")
  50. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  51. CH_USER = os.environ.get("CH_USER", "default")
  52. CH_PASS = os.environ.get("CH_PASS", "")
  53. CH_DB = os.environ.get("CH_DB", "uus67")
  54. CH_TABLE = os.environ.get("CH_TABLE", "temperatuur")
  55. CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
  56. CH_FAULT_TABLE = os.environ.get("CH_FAULT_TABLE", "vead")
  57. # Häirete lävendid (hüstereesiga: aktiveerub <= X, taastub >= Y).
  58. BATTERY_ACT = _env_float("BATTERY_LOW_THRESHOLD", 20)
  59. BATTERY_CLR = _env_float("BATTERY_CLEAR_THRESHOLD", 25)
  60. LQ_ACT = _env_float("LINKQUALITY_LOW_THRESHOLD", 20)
  61. LQ_CLR = _env_float("LINKQUALITY_CLEAR_THRESHOLD", 30)
  62. VOLT_ACT = _env_float("VOLTAGE_LOW_THRESHOLD", 2600)
  63. VOLT_CLR = _env_float("VOLTAGE_CLEAR_THRESHOLD", 2700)
  64. # Offline-tuvastuse timeoutid (minutites).
  65. OFFLINE_TEMP_MIN = _env_float("OFFLINE_TIMEOUT_TEMP_MIN", 120)
  66. OFFLINE_CONTACT_MIN = _env_float("OFFLINE_TIMEOUT_CONTACT_MIN", 240)
  67. # Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
  68. DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
  69. DOOR_DEVICES = {t.rsplit("/", 1)[-1] for t in DOOR_TOPICS}
  70. MONITORED_DEVICES = {DEVICE, *DOOR_DEVICES}
  71. SYSTEM_DEVICE_BRIDGE = "zigbee2mqtt"
  72. SYSTEM_DEVICE_COLLECTOR = "koguja"
  73. CHECK_INTERVAL_SEC = 20
  74. def current_hour(now: datetime | None = None) -> datetime:
  75. now = now or datetime.now(TZ)
  76. return now.replace(minute=0, second=0, microsecond=0)
  77. def _as_float(value: Any) -> float | None:
  78. if isinstance(value, bool) or not isinstance(value, (int, float)):
  79. return None
  80. return float(value)
  81. def _fault_row(
  82. ts: datetime,
  83. device: str,
  84. tyyp: str,
  85. olek: str,
  86. detail: str,
  87. value: float | None,
  88. ) -> dict[str, Any]:
  89. return {
  90. "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
  91. "device": device,
  92. "tyyp": tyyp,
  93. "olek": olek,
  94. "detail": detail,
  95. "vaartus": value,
  96. }
  97. class HourBucket:
  98. """Ühe tunni jooksul kogutud temperatuuri/niiskuse mõõtmised."""
  99. def __init__(self, hour: datetime) -> None:
  100. self.hour = hour
  101. self.temps: list[float] = []
  102. self.hums: list[float] = []
  103. def add(self, payload: dict[str, Any]) -> None:
  104. if isinstance(payload.get("temperature"), (int, float)):
  105. self.temps.append(float(payload["temperature"]))
  106. if isinstance(payload.get("humidity"), (int, float)):
  107. self.hums.append(float(payload["humidity"]))
  108. @property
  109. def empty(self) -> bool:
  110. return not self.temps and not self.hums
  111. def row(self) -> dict[str, Any]:
  112. def avg(values: list[float]) -> float | None:
  113. return round(sum(values) / len(values), 2) if values else None
  114. return {
  115. "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
  116. "device": DEVICE,
  117. "temperature": avg(self.temps),
  118. "humidity": avg(self.hums),
  119. }
  120. class DoorTracker:
  121. """Kontaktandurite olekumuutuste jälgija.
  122. ``last`` hoiab viimast teadaolevat ``contact`` väärtust anduri kohta.
  123. Südamelöögid (sama väärtus) ei tekita rida. Esimene nähtud väärtus anduri
  124. kohta, millel puudub varasem olek, salvestatakse baasreana.
  125. """
  126. def __init__(self, last_states: dict[str, bool] | None = None) -> None:
  127. self.last: dict[str, bool] = dict(last_states or {})
  128. def process(
  129. self, device: str, payload: dict[str, Any], ts: datetime
  130. ) -> dict[str, Any] | None:
  131. contact = payload.get("contact")
  132. if not isinstance(contact, bool):
  133. return None
  134. if device in self.last and self.last[device] == contact:
  135. return None
  136. self.last[device] = contact
  137. return {
  138. "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
  139. "device": device,
  140. "contact": contact,
  141. # SNZB-04P: contact=true = suletud, false = avatud (Z2M HA discovery
  142. # payload_on=false, payload_off=true, device_class=door).
  143. "olek": "suletud" if contact else "avatud",
  144. }
  145. class FaultTracker:
  146. """Häirete olekumuutuste jälgija (ainult muutused).
  147. ``current_keys`` hoiab iga seadme jaoks parajasti aktiivsete häirete
  148. tüüpe. Iga hindamise järel emiteeritakse read ainult üleminekute kohta
  149. (``aktiveeritud`` / ``taastatud``). Käivitusel seemendatakse aktiivsed
  150. häired andmebaasist, et restart ei dubleeriks kirjeid.
  151. """
  152. def __init__(self, active: dict[str, set[str]] | None = None) -> None:
  153. self.payload_faults: dict[str, dict[str, tuple[str, float | None]]] = {}
  154. self.offline_faults: dict[str, tuple[str, float | None]] = {}
  155. self.current_keys: dict[str, set[str]] = {
  156. device: set(tyybid) for device, tyybid in (active or {}).items()
  157. }
  158. def _low(self, device: str, tyyp: str, value: float, activate_at: float, clear_at: float) -> bool:
  159. if tyyp in self.current_keys.get(device, set()):
  160. return value < clear_at
  161. return value <= activate_at
  162. def _derive(self, device: str, payload: dict[str, Any]) -> dict[str, tuple[str, float | None]]:
  163. faults: dict[str, tuple[str, float | None]] = {}
  164. battery_low = payload.get("battery_low")
  165. battery = _as_float(payload.get("battery"))
  166. if battery_low is True:
  167. faults["low_battery"] = ("battery_low=true", battery)
  168. elif battery is not None and self._low(device, "low_battery", battery, BATTERY_ACT, BATTERY_CLR):
  169. faults["low_battery"] = (f"battery {battery:.0f}%", battery)
  170. lq = _as_float(payload.get("linkquality"))
  171. if lq is not None and self._low(device, "weak_signal", lq, LQ_ACT, LQ_CLR):
  172. faults["weak_signal"] = (f"linkquality {lq:.0f}", lq)
  173. voltage = _as_float(payload.get("voltage"))
  174. if voltage is not None and self._low(device, "low_voltage", voltage, VOLT_ACT, VOLT_CLR):
  175. faults["low_voltage"] = (f"voltage {voltage:.0f} mV", voltage)
  176. return faults
  177. def _reconcile(self, device: str, ts: datetime) -> list[dict[str, Any]]:
  178. current: dict[str, tuple[str, float | None]] = {}
  179. current.update(self.payload_faults.get(device, {}))
  180. if device in self.offline_faults:
  181. current["offline"] = self.offline_faults[device]
  182. prev = self.current_keys.get(device, set())
  183. rows: list[dict[str, Any]] = []
  184. for tyyp, (detail, value) in current.items():
  185. if tyyp not in prev:
  186. rows.append(_fault_row(ts, device, tyyp, "aktiveeritud", detail, value))
  187. for tyyp in prev - set(current):
  188. rows.append(_fault_row(ts, device, tyyp, "taastatud", "", None))
  189. self.current_keys[device] = set(current)
  190. return rows
  191. def on_device_message(
  192. self, device: str, payload: dict[str, Any], ts: datetime
  193. ) -> list[dict[str, Any]]:
  194. self.payload_faults[device] = self._derive(device, payload)
  195. self.offline_faults.pop(device, None)
  196. return self._reconcile(device, ts)
  197. def set_offline(
  198. self, device: str, offline: bool, detail: str, ts: datetime
  199. ) -> list[dict[str, Any]]:
  200. if offline:
  201. self.offline_faults[device] = (detail, None)
  202. else:
  203. self.offline_faults.pop(device, None)
  204. return self._reconcile(device, ts)
  205. def set_system_fault(
  206. self,
  207. device: str,
  208. tyyp: str,
  209. active: bool,
  210. detail: str,
  211. ts: datetime,
  212. ) -> list[dict[str, Any]]:
  213. faults = dict(self.payload_faults.get(device, {}))
  214. if active:
  215. faults[tyyp] = (detail, None)
  216. else:
  217. faults.pop(tyyp, None)
  218. self.payload_faults[device] = faults
  219. return self._reconcile(device, ts)
  220. class ClickHouseWriter:
  221. def __init__(self, table: str) -> None:
  222. self.table = table
  223. self.pending: list[dict[str, Any]] = []
  224. def enqueue(self, row: dict[str, Any]) -> None:
  225. self.pending.append(row)
  226. def enqueue_many(self, rows: list[dict[str, Any]]) -> None:
  227. self.pending.extend(rows)
  228. def flush(self) -> None:
  229. if not self.pending:
  230. return
  231. body = "\n".join(json.dumps(r, ensure_ascii=False) for r in self.pending)
  232. try:
  233. resp = requests.post(
  234. f"{CH_URL}/",
  235. params={
  236. "database": CH_DB,
  237. "query": f"INSERT INTO {CH_DB}.{self.table} FORMAT JSONEachRow",
  238. },
  239. data=body.encode("utf-8"),
  240. auth=(CH_USER, CH_PASS),
  241. timeout=15,
  242. )
  243. resp.raise_for_status()
  244. LOG.info("ClickHouse'i (%s) kirjutatud %d rida", self.table, len(self.pending))
  245. self.pending.clear()
  246. except Exception:
  247. LOG.exception(
  248. "ClickHouse'i (%s) kirjutamine ebaõnnestus, jätan %d rida järjekorda",
  249. self.table,
  250. len(self.pending),
  251. )
  252. def _ch_query(query: str) -> str:
  253. resp = requests.post(
  254. f"{CH_URL}/",
  255. params={"database": CH_DB, "query": query},
  256. auth=(CH_USER, CH_PASS),
  257. timeout=15,
  258. )
  259. resp.raise_for_status()
  260. return resp.text
  261. def load_last_states() -> dict[str, bool]:
  262. """Loeb andmebaasist iga anduri viimase teadaoleva ``contact`` väärtuse."""
  263. query = (
  264. f"SELECT device, argMax(contact, ts) AS contact "
  265. f"FROM {CH_DB}.{CH_DOOR_TABLE} GROUP BY device FORMAT JSONEachRow"
  266. )
  267. try:
  268. states: dict[str, bool] = {}
  269. for line in _ch_query(query).splitlines():
  270. if not line.strip():
  271. continue
  272. obj = json.loads(line)
  273. value = obj["contact"]
  274. states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
  275. LOG.info("Seemendatud %d anduri viimane olek", len(states))
  276. return states
  277. except Exception:
  278. LOG.exception("Viimase oleku laadimine ebaõnnestus, alustan tühjalt")
  279. return {}
  280. def load_active_faults() -> dict[str, set[str]]:
  281. """Loeb andmebaasist iga seadme parajasti aktiivsed häirete tüübid."""
  282. query = (
  283. f"SELECT device, tyyp, argMax(olek, ts) AS olek "
  284. f"FROM {CH_DB}.{CH_FAULT_TABLE} GROUP BY device, tyyp FORMAT JSONEachRow"
  285. )
  286. try:
  287. active: dict[str, set[str]] = {}
  288. for line in _ch_query(query).splitlines():
  289. if not line.strip():
  290. continue
  291. obj = json.loads(line)
  292. if obj.get("olek") == "aktiveeritud":
  293. active.setdefault(obj["device"], set()).add(obj["tyyp"])
  294. LOG.info("Seemendatud %d seadme aktiivsed häired", len(active))
  295. return active
  296. except Exception:
  297. LOG.exception("Aktiivsete häirete laadimine ebaõnnestus, alustan tühjalt")
  298. return {}
  299. def load_last_seen() -> dict[str, datetime]:
  300. """Loeb andmebaasist iga anduri viimase kirje aja (offline-baas)."""
  301. query = (
  302. "SELECT device, max(ts) AS ts FROM ("
  303. f"SELECT device, ts FROM {CH_DB}.{CH_DOOR_TABLE} "
  304. "UNION ALL "
  305. f"SELECT device, hour AS ts FROM {CH_DB}.{CH_TABLE}"
  306. ") GROUP BY device FORMAT JSONEachRow"
  307. )
  308. try:
  309. seen: dict[str, datetime] = {}
  310. for line in _ch_query(query).splitlines():
  311. if not line.strip():
  312. continue
  313. obj = json.loads(line)
  314. seen[obj["device"]] = datetime.strptime(obj["ts"], "%Y-%m-%d %H:%M:%S").replace(
  315. tzinfo=TZ
  316. )
  317. LOG.info("Seemendatud %d anduri viimane teate aeg", len(seen))
  318. return seen
  319. except Exception:
  320. LOG.exception("Viimaste teate aegade laadimine ebaõnnestus, alustan tühjalt")
  321. return {}
  322. def main() -> int:
  323. lock = threading.Lock()
  324. stop = threading.Event()
  325. bucket = HourBucket(current_hour())
  326. temp_writer = ClickHouseWriter(CH_TABLE)
  327. door_writer = ClickHouseWriter(CH_DOOR_TABLE)
  328. fault_writer = ClickHouseWriter(CH_FAULT_TABLE)
  329. door_tracker = DoorTracker(load_last_states())
  330. fault_tracker = FaultTracker(load_active_faults())
  331. start = datetime.now(TZ)
  332. last_seen: dict[str, datetime] = {}
  333. for device, seen in load_last_seen().items():
  334. last_seen[device] = seen
  335. for device in MONITORED_DEVICES:
  336. last_seen.setdefault(device, start)
  337. def offline_timeout(device: str) -> float:
  338. if device == DEVICE:
  339. return OFFLINE_TEMP_MIN
  340. return OFFLINE_CONTACT_MIN
  341. def on_connect(client, userdata, flags, reason_code, properties=None):
  342. if reason_code == 0:
  343. topics = [MQTT_TOPIC, BRIDGE_STATE_TOPIC, *sorted(DOOR_TOPICS)]
  344. LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
  345. for topic in topics:
  346. client.subscribe(topic, qos=1)
  347. if not stop.is_set():
  348. with lock:
  349. fault_writer.enqueue_many(
  350. fault_tracker.set_system_fault(
  351. SYSTEM_DEVICE_COLLECTOR,
  352. "mqtt_disconnected",
  353. False,
  354. "",
  355. datetime.now(TZ),
  356. )
  357. )
  358. else:
  359. LOG.error("MQTT ühendus ebaõnnestus: %s", reason_code)
  360. def on_disconnect(client, userdata, flags, reason_code, properties=None):
  361. LOG.warning("MQTT ühendus katkes: %s", reason_code)
  362. if not stop.is_set():
  363. with lock:
  364. fault_writer.enqueue_many(
  365. fault_tracker.set_system_fault(
  366. SYSTEM_DEVICE_COLLECTOR,
  367. "mqtt_disconnected",
  368. True,
  369. f"MQTT ühendus katkes: {reason_code}",
  370. datetime.now(TZ),
  371. )
  372. )
  373. def on_message(client, userdata, msg):
  374. try:
  375. payload = json.loads(msg.payload.decode("utf-8"))
  376. except Exception:
  377. LOG.exception("Vigane MQTT teade teemal %s", msg.topic)
  378. return
  379. if not isinstance(payload, dict):
  380. return
  381. now = datetime.now(TZ)
  382. with lock:
  383. if msg.topic == MQTT_TOPIC:
  384. bucket.add(payload)
  385. last_seen[DEVICE] = now
  386. fault_writer.enqueue_many(fault_tracker.on_device_message(DEVICE, payload, now))
  387. elif msg.topic in DOOR_TOPICS:
  388. device = msg.topic.rsplit("/", 1)[-1]
  389. last_seen[device] = now
  390. row = door_tracker.process(device, payload, now)
  391. if row is not None:
  392. LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
  393. door_writer.enqueue(row)
  394. fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
  395. elif msg.topic == BRIDGE_STATE_TOPIC:
  396. offline = payload.get("state") == "offline"
  397. fault_writer.enqueue_many(
  398. fault_tracker.set_system_fault(
  399. SYSTEM_DEVICE_BRIDGE,
  400. "bridge_offline",
  401. offline,
  402. "zigbee2mqtt bridge offline",
  403. now,
  404. )
  405. )
  406. client = mqtt.Client(
  407. mqtt.CallbackAPIVersion.VERSION2,
  408. client_id="zigbee-collector",
  409. )
  410. if MQTT_USER:
  411. client.username_pw_set(MQTT_USER, MQTT_PASS)
  412. client.on_connect = on_connect
  413. client.on_disconnect = on_disconnect
  414. client.on_message = on_message
  415. client.reconnect_delay_set(min_delay=1, max_delay=60)
  416. client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60)
  417. client.loop_start()
  418. def shutdown(signum, frame):
  419. LOG.info("Lõpetamissignaal (%s), sulgen", signum)
  420. stop.set()
  421. signal.signal(signal.SIGTERM, shutdown)
  422. signal.signal(signal.SIGINT, shutdown)
  423. LOG.info(
  424. "Koguja käivitatud: temp=%s.%s, uksed=%s.%s (%d teemat), häired=%s.%s",
  425. CH_DB,
  426. CH_TABLE,
  427. CH_DB,
  428. CH_DOOR_TABLE,
  429. len(DOOR_TOPICS),
  430. CH_DB,
  431. CH_FAULT_TABLE,
  432. )
  433. while not stop.is_set():
  434. stop.wait(CHECK_INTERVAL_SEC)
  435. now = datetime.now(TZ)
  436. with lock:
  437. if current_hour(now) != bucket.hour:
  438. finished = bucket
  439. bucket = HourBucket(current_hour(now))
  440. else:
  441. finished = None
  442. if finished is not None and not finished.empty:
  443. temp_writer.enqueue(finished.row())
  444. for device in sorted(MONITORED_DEVICES):
  445. seen = last_seen.get(device, start)
  446. timeout = timedelta(minutes=offline_timeout(device))
  447. offline = (now - seen) > timeout
  448. detail = (
  449. f"viimane teade {seen.strftime('%Y-%m-%d %H:%M:%S')}"
  450. if offline
  451. else ""
  452. )
  453. fault_writer.enqueue_many(
  454. fault_tracker.set_offline(device, offline, detail, now)
  455. )
  456. temp_writer.flush()
  457. door_writer.flush()
  458. fault_writer.flush()
  459. temp_writer.flush()
  460. door_writer.flush()
  461. fault_writer.flush()
  462. client.loop_stop()
  463. client.disconnect()
  464. return 0
  465. if __name__ == "__main__":
  466. raise SystemExit(main())