collector.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  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``). Kui tunnis mõõtmisi ei ole
  7. (andur kättesaamatu), kirjutatakse ikkagi rida eelmise teadaoleva väärtusega
  8. ja märgitakse ``taidetud = true`` — nii ei teki aegreas auke.
  9. 2. Kontaktandurid (``DOOR_TOPICS``): salvestatakse **ainult olekumuutused**
  10. (``CH_DOOR_TABLE``, vaikimisi ``uus67.ukseandur``).
  11. 3. Häired (``CH_FAULT_TABLE``, vaikimisi ``uus67.vead``): **ainult muutused**
  12. (``aktiveeritud`` / ``taastatud``). Allikad: aku tühjenemine
  13. (``battery_low`` või ``battery`` lävend), nõrk signaal (``linkquality``),
  14. madal pinge (``voltage``), anduri kadumine (viimase teate timeout),
  15. koguja MQTT-ühenduse kadu ja Zigbee2MQTT bridge'i offline-olek.
  16. Käivitub konteineris, mis on main_ai_network võrgus (ClickHouse nimega
  17. lahenduv). Välised seadistused tulevad keskkonnamuutujatest.
  18. """
  19. from __future__ import annotations
  20. import json
  21. import logging
  22. import os
  23. import signal
  24. import threading
  25. from datetime import datetime, timedelta
  26. from typing import Any
  27. from zoneinfo import ZoneInfo
  28. import paho.mqtt.client as mqtt
  29. import requests
  30. logging.basicConfig(
  31. level=logging.INFO,
  32. format="%(asctime)s %(levelname)s %(message)s",
  33. )
  34. LOG = logging.getLogger("zigbee-collector")
  35. TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
  36. def _env_float(name: str, default: float) -> float:
  37. try:
  38. return float(os.environ.get(name, default))
  39. except (TypeError, ValueError):
  40. return default
  41. MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
  42. MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
  43. MQTT_USER = os.environ.get("MQTT_USER", "")
  44. MQTT_PASS = os.environ.get("MQTT_PASS", "")
  45. MQTT_TOPIC = os.environ.get("MQTT_TOPIC", "zigbee2mqtt/Temperatuur")
  46. DOOR_TOPICS = {
  47. t.strip()
  48. for t in os.environ.get("DOOR_TOPICS", "").split(",")
  49. if t.strip()
  50. }
  51. BRIDGE_STATE_TOPIC = os.environ.get("BRIDGE_STATE_TOPIC", "zigbee2mqtt/bridge/state")
  52. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  53. CH_USER = os.environ.get("CH_USER", "default")
  54. CH_PASS = os.environ.get("CH_PASS", "")
  55. CH_DB = os.environ.get("CH_DB", "uus67")
  56. CH_TABLE = os.environ.get("CH_TABLE", "temperatuur")
  57. CH_DOOR_TABLE = os.environ.get("CH_DOOR_TABLE", "ukseandur")
  58. CH_FAULT_TABLE = os.environ.get("CH_FAULT_TABLE", "vead")
  59. MOTION_TOPICS = {t.strip() for t in os.environ.get("MOTION_TOPICS", "").split(",") if t.strip()}
  60. ENERGY_TOPICS = {t.strip() for t in os.environ.get("ENERGY_TOPICS", "").split(",") if t.strip()}
  61. CH_MOTION_TABLE = os.environ.get("CH_MOTION_TABLE", "liikumine")
  62. CH_ENERGY_TABLE = os.environ.get("CH_ENERGY_TABLE", "energia")
  63. # Häirete lävendid (hüstereesiga: aktiveerub <= X, taastub >= Y).
  64. BATTERY_ACT = _env_float("BATTERY_LOW_THRESHOLD", 20)
  65. BATTERY_CLR = _env_float("BATTERY_CLEAR_THRESHOLD", 25)
  66. LQ_ACT = _env_float("LINKQUALITY_LOW_THRESHOLD", 20)
  67. LQ_CLR = _env_float("LINKQUALITY_CLEAR_THRESHOLD", 30)
  68. VOLT_ACT = _env_float("VOLTAGE_LOW_THRESHOLD", 2600)
  69. VOLT_CLR = _env_float("VOLTAGE_CLEAR_THRESHOLD", 2700)
  70. # Offline-tuvastuse timeoutid (minutites).
  71. OFFLINE_TEMP_MIN = _env_float("OFFLINE_TIMEOUT_TEMP_MIN", 120)
  72. OFFLINE_CONTACT_MIN = _env_float("OFFLINE_TIMEOUT_CONTACT_MIN", 240)
  73. # Anduri nimi tuletatakse teemast (zigbee2mqtt/Temperatuur -> Temperatuur).
  74. DEVICE = os.environ.get("DEVICE") or MQTT_TOPIC.rsplit("/", 1)[-1]
  75. DOOR_DEVICES = {t.rsplit("/", 1)[-1] for t in DOOR_TOPICS}
  76. MOTION_DEVICES = {t.rsplit("/", 1)[-1] for t in MOTION_TOPICS}
  77. ENERGY_DEVICES = {t.rsplit("/", 1)[-1] for t in ENERGY_TOPICS}
  78. MONITORED_DEVICES = {DEVICE, *DOOR_DEVICES, *MOTION_DEVICES, *ENERGY_DEVICES}
  79. SYSTEM_DEVICE_BRIDGE = "zigbee2mqtt"
  80. SYSTEM_DEVICE_COLLECTOR = "koguja"
  81. CHECK_INTERVAL_SEC = 20
  82. def current_hour(now: datetime | None = None) -> datetime:
  83. now = now or datetime.now(TZ)
  84. return now.replace(minute=0, second=0, microsecond=0)
  85. def _as_float(value: Any) -> float | None:
  86. if isinstance(value, bool) or not isinstance(value, (int, float)):
  87. return None
  88. return float(value)
  89. def _fault_row(
  90. ts: datetime,
  91. device: str,
  92. tyyp: str,
  93. olek: str,
  94. detail: str,
  95. value: float | None,
  96. ) -> dict[str, Any]:
  97. return {
  98. "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
  99. "device": device,
  100. "tyyp": tyyp,
  101. "olek": olek,
  102. "detail": detail,
  103. "vaartus": value,
  104. }
  105. class HourBucket:
  106. """Ühe tunni jooksul kogutud temperatuuri/niiskuse mõõtmised."""
  107. def __init__(self, hour: datetime) -> None:
  108. self.hour = hour
  109. self.temps: list[float] = []
  110. self.hums: list[float] = []
  111. def add(self, payload: dict[str, Any]) -> None:
  112. if isinstance(payload.get("temperature"), (int, float)):
  113. self.temps.append(float(payload["temperature"]))
  114. if isinstance(payload.get("humidity"), (int, float)):
  115. self.hums.append(float(payload["humidity"]))
  116. @property
  117. def empty(self) -> bool:
  118. return not self.temps and not self.hums
  119. def row(self, fallback: dict[str, float | None] | None = None) -> dict[str, Any]:
  120. """Ühe tunni rida.
  121. Kui tunnis mõõtmisi ei ole ja ``fallback`` on antud, kasutatakse
  122. eelmise teadaoleva väärtuse andmeid ja märgitakse ``taidetud = true``.
  123. """
  124. def avg(values: list[float]) -> float | None:
  125. return round(sum(values) / len(values), 2) if values else None
  126. if self.empty and fallback is not None:
  127. return {
  128. "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
  129. "device": DEVICE,
  130. "temperature": fallback.get("temperature"),
  131. "humidity": fallback.get("humidity"),
  132. "taidetud": True,
  133. }
  134. return {
  135. "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
  136. "device": DEVICE,
  137. "temperature": avg(self.temps),
  138. "humidity": avg(self.hums),
  139. "taidetud": False,
  140. }
  141. class DoorTracker:
  142. """Kontaktandurite olekumuutuste jälgija.
  143. ``last`` hoiab viimast teadaolevat ``contact`` väärtust anduri kohta.
  144. Südamelöögid (sama väärtus) ei tekita rida. Esimene nähtud väärtus anduri
  145. kohta, millel puudub varasem olek, salvestatakse baasreana.
  146. """
  147. def __init__(self, last_states: dict[str, bool] | None = None) -> None:
  148. self.last: dict[str, bool] = dict(last_states or {})
  149. def process(
  150. self, device: str, payload: dict[str, Any], ts: datetime
  151. ) -> dict[str, Any] | None:
  152. contact = payload.get("contact")
  153. if not isinstance(contact, bool):
  154. return None
  155. if device in self.last and self.last[device] == contact:
  156. return None
  157. self.last[device] = contact
  158. return {
  159. "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
  160. "device": device,
  161. "contact": contact,
  162. # SNZB-04P: contact=true = suletud, false = avatud (Z2M HA discovery
  163. # payload_on=false, payload_off=true, device_class=door).
  164. "olek": "suletud" if contact else "avatud",
  165. }
  166. class MotionTracker:
  167. """Liikumise/kohalolu olekumuutuste jälgija (ainult muutused)."""
  168. def __init__(self, last_states=None):
  169. self.last = dict(last_states or {})
  170. def process(self, device, payload, ts):
  171. occ = payload.get("occupancy")
  172. if not isinstance(occ, bool):
  173. return None
  174. if device in self.last and self.last[device] == occ:
  175. return None
  176. self.last[device] = occ
  177. illum = payload.get("illumination")
  178. return {
  179. "ts": ts.strftime("%Y-%m-%d %H:%M:%S"),
  180. "device": device,
  181. "occupancy": occ,
  182. "illumination": illum if isinstance(illum, str) else "",
  183. "olek": "liikumine" if occ else "rahu",
  184. }
  185. class EnergyBucket:
  186. """Pistikute energiatarbe tunnipõhine agregeerija (seadme kaupa)."""
  187. def __init__(self, hour):
  188. self.hour = hour
  189. self.data = {}
  190. def add(self, device, payload):
  191. d = self.data.setdefault(
  192. device, {"power": [], "voltage": [], "current": [], "energy_last": None}
  193. )
  194. p = _as_float(payload.get("power"))
  195. v = _as_float(payload.get("voltage"))
  196. c = _as_float(payload.get("current"))
  197. e = _as_float(payload.get("energy"))
  198. if p is not None:
  199. d["power"].append(p)
  200. if v is not None:
  201. d["voltage"].append(v)
  202. if c is not None:
  203. d["current"].append(c)
  204. if e is not None:
  205. d["energy_last"] = e
  206. def rows(self):
  207. out = []
  208. for device, d in self.data.items():
  209. def avg(vals):
  210. return round(sum(vals) / len(vals), 3) if vals else None
  211. out.append({
  212. "ts": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
  213. "device": device,
  214. "power": avg(d["power"]),
  215. "voltage": avg(d["voltage"]),
  216. "current": avg(d["current"]),
  217. "energy": d["energy_last"],
  218. })
  219. return out
  220. class FaultTracker:
  221. """Häirete olekumuutuste jälgija (ainult muutused).
  222. ``current_keys`` hoiab iga seadme jaoks parajasti aktiivsete häirete
  223. tüüpe. Iga hindamise järel emiteeritakse read ainult üleminekute kohta
  224. (``aktiveeritud`` / ``taastatud``). Käivitusel seemendatakse aktiivsed
  225. häired andmebaasist, et restart ei dubleeriks kirjeid.
  226. """
  227. def __init__(self, active: dict[str, set[str]] | None = None) -> None:
  228. self.payload_faults: dict[str, dict[str, tuple[str, float | None]]] = {}
  229. self.offline_faults: dict[str, tuple[str, float | None]] = {}
  230. self.current_keys: dict[str, set[str]] = {
  231. device: set(tyybid) for device, tyybid in (active or {}).items()
  232. }
  233. def _low(self, device: str, tyyp: str, value: float, activate_at: float, clear_at: float) -> bool:
  234. if tyyp in self.current_keys.get(device, set()):
  235. return value < clear_at
  236. return value <= activate_at
  237. def _derive(self, device: str, payload: dict[str, Any]) -> dict[str, tuple[str, float | None]]:
  238. faults: dict[str, tuple[str, float | None]] = {}
  239. battery_low = payload.get("battery_low")
  240. battery = _as_float(payload.get("battery"))
  241. if battery_low is True:
  242. faults["low_battery"] = ("battery_low=true", battery)
  243. elif battery is not None and self._low(device, "low_battery", battery, BATTERY_ACT, BATTERY_CLR):
  244. faults["low_battery"] = (f"battery {battery:.0f}%", battery)
  245. lq = _as_float(payload.get("linkquality"))
  246. if lq is not None and self._low(device, "weak_signal", lq, LQ_ACT, LQ_CLR):
  247. faults["weak_signal"] = (f"linkquality {lq:.0f}", lq)
  248. voltage = _as_float(payload.get("voltage"))
  249. if voltage is not None and self._low(device, "low_voltage", voltage, VOLT_ACT, VOLT_CLR):
  250. faults["low_voltage"] = (f"voltage {voltage:.0f} mV", voltage)
  251. return faults
  252. def _reconcile(self, device: str, ts: datetime) -> list[dict[str, Any]]:
  253. current: dict[str, tuple[str, float | None]] = {}
  254. current.update(self.payload_faults.get(device, {}))
  255. if device in self.offline_faults:
  256. current["offline"] = self.offline_faults[device]
  257. prev = self.current_keys.get(device, set())
  258. rows: list[dict[str, Any]] = []
  259. for tyyp, (detail, value) in current.items():
  260. if tyyp not in prev:
  261. rows.append(_fault_row(ts, device, tyyp, "aktiveeritud", detail, value))
  262. for tyyp in prev - set(current):
  263. rows.append(_fault_row(ts, device, tyyp, "taastatud", "", None))
  264. self.current_keys[device] = set(current)
  265. return rows
  266. def on_device_message(
  267. self, device: str, payload: dict[str, Any], ts: datetime
  268. ) -> list[dict[str, Any]]:
  269. self.payload_faults[device] = self._derive(device, payload)
  270. self.offline_faults.pop(device, None)
  271. return self._reconcile(device, ts)
  272. def set_offline(
  273. self, device: str, offline: bool, detail: str, ts: datetime
  274. ) -> list[dict[str, Any]]:
  275. if offline:
  276. self.offline_faults[device] = (detail, None)
  277. else:
  278. self.offline_faults.pop(device, None)
  279. return self._reconcile(device, ts)
  280. def set_system_fault(
  281. self,
  282. device: str,
  283. tyyp: str,
  284. active: bool,
  285. detail: str,
  286. ts: datetime,
  287. ) -> list[dict[str, Any]]:
  288. faults = dict(self.payload_faults.get(device, {}))
  289. if active:
  290. faults[tyyp] = (detail, None)
  291. else:
  292. faults.pop(tyyp, None)
  293. self.payload_faults[device] = faults
  294. return self._reconcile(device, ts)
  295. class ClickHouseWriter:
  296. def __init__(self, table: str) -> None:
  297. self.table = table
  298. self.pending: list[dict[str, Any]] = []
  299. def enqueue(self, row: dict[str, Any]) -> None:
  300. self.pending.append(row)
  301. def enqueue_many(self, rows: list[dict[str, Any]]) -> None:
  302. self.pending.extend(rows)
  303. def flush(self) -> None:
  304. if not self.pending:
  305. return
  306. body = "\n".join(json.dumps(r, ensure_ascii=False) for r in self.pending)
  307. try:
  308. resp = requests.post(
  309. f"{CH_URL}/",
  310. params={
  311. "database": CH_DB,
  312. "query": f"INSERT INTO {CH_DB}.{self.table} FORMAT JSONEachRow",
  313. },
  314. data=body.encode("utf-8"),
  315. auth=(CH_USER, CH_PASS),
  316. timeout=15,
  317. )
  318. resp.raise_for_status()
  319. LOG.info("ClickHouse'i (%s) kirjutatud %d rida", self.table, len(self.pending))
  320. self.pending.clear()
  321. except Exception:
  322. LOG.exception(
  323. "ClickHouse'i (%s) kirjutamine ebaõnnestus, jätan %d rida järjekorda",
  324. self.table,
  325. len(self.pending),
  326. )
  327. def _ch_query(query: str) -> str:
  328. resp = requests.post(
  329. f"{CH_URL}/",
  330. params={"database": CH_DB, "query": query},
  331. auth=(CH_USER, CH_PASS),
  332. timeout=15,
  333. )
  334. resp.raise_for_status()
  335. return resp.text
  336. def load_last_states() -> dict[str, bool]:
  337. """Loeb andmebaasist iga anduri viimase teadaoleva ``contact`` väärtuse."""
  338. query = (
  339. f"SELECT device, argMax(contact, ts) AS contact "
  340. f"FROM {CH_DB}.{CH_DOOR_TABLE} GROUP BY device FORMAT JSONEachRow"
  341. )
  342. try:
  343. states: dict[str, bool] = {}
  344. for line in _ch_query(query).splitlines():
  345. if not line.strip():
  346. continue
  347. obj = json.loads(line)
  348. value = obj["contact"]
  349. states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
  350. LOG.info("Seemendatud %d anduri viimane olek", len(states))
  351. return states
  352. except Exception:
  353. LOG.exception("Viimase oleku laadimine ebaõnnestus, alustan tühjalt")
  354. return {}
  355. def load_last_motion_states() -> dict[str, bool]:
  356. """Loeb andmebaasist iga liikumisanduri viimase ``occupancy`` väärtuse."""
  357. query = (
  358. f"SELECT device, argMax(occupancy, ts) AS occupancy "
  359. f"FROM {CH_DB}.{CH_MOTION_TABLE} GROUP BY device FORMAT JSONEachRow"
  360. )
  361. try:
  362. states: dict[str, bool] = {}
  363. for line in _ch_query(query).splitlines():
  364. if not line.strip():
  365. continue
  366. obj = json.loads(line)
  367. value = obj["occupancy"]
  368. states[obj["device"]] = value if isinstance(value, bool) else bool(int(value))
  369. LOG.info("Seemendatud %d liikumisanduri olek", len(states))
  370. return states
  371. except Exception:
  372. LOG.exception("Liikumisolekute laadimine ebaõnnestus, alustan tühjalt")
  373. return {}
  374. def load_active_faults() -> dict[str, set[str]]:
  375. """Loeb andmebaasist iga seadme parajasti aktiivsed häirete tüübid."""
  376. query = (
  377. f"SELECT device, tyyp, argMax(olek, ts) AS olek "
  378. f"FROM {CH_DB}.{CH_FAULT_TABLE} GROUP BY device, tyyp FORMAT JSONEachRow"
  379. )
  380. try:
  381. active: dict[str, set[str]] = {}
  382. for line in _ch_query(query).splitlines():
  383. if not line.strip():
  384. continue
  385. obj = json.loads(line)
  386. if obj.get("olek") == "aktiveeritud":
  387. active.setdefault(obj["device"], set()).add(obj["tyyp"])
  388. LOG.info("Seemendatud %d seadme aktiivsed häired", len(active))
  389. return active
  390. except Exception:
  391. LOG.exception("Aktiivsete häirete laadimine ebaõnnestus, alustan tühjalt")
  392. return {}
  393. def load_last_seen() -> dict[str, datetime]:
  394. """Loeb andmebaasist iga anduri viimase kirje aja (offline-baas)."""
  395. query = (
  396. "SELECT device, max(ts) AS ts FROM ("
  397. f"SELECT device, ts FROM {CH_DB}.{CH_DOOR_TABLE} "
  398. "UNION ALL "
  399. f"SELECT device, hour AS ts FROM {CH_DB}.{CH_TABLE}"
  400. ") GROUP BY device FORMAT JSONEachRow"
  401. )
  402. try:
  403. seen: dict[str, datetime] = {}
  404. for line in _ch_query(query).splitlines():
  405. if not line.strip():
  406. continue
  407. obj = json.loads(line)
  408. seen[obj["device"]] = datetime.strptime(obj["ts"], "%Y-%m-%d %H:%M:%S").replace(
  409. tzinfo=TZ
  410. )
  411. LOG.info("Seemendatud %d anduri viimane teate aeg", len(seen))
  412. return seen
  413. except Exception:
  414. LOG.exception("Viimaste teate aegade laadimine ebaõnnestus, alustan tühjalt")
  415. return {}
  416. def load_last_temp() -> dict[str, float | None]:
  417. """Loeb andmebaasist viimase teadaoleva temperatuuri/niiskuse (täitmise baas)."""
  418. query = (
  419. f"SELECT temperature, humidity FROM {CH_DB}.{CH_TABLE} "
  420. "ORDER BY hour DESC LIMIT 1 FORMAT JSONEachRow"
  421. )
  422. try:
  423. text = _ch_query(query).strip()
  424. if text:
  425. obj = json.loads(text.splitlines()[0])
  426. values = {
  427. "temperature": _as_float(obj.get("temperature")),
  428. "humidity": _as_float(obj.get("humidity")),
  429. }
  430. LOG.info("Seemendatud viimane väärtus: %s", values)
  431. return values
  432. except Exception:
  433. LOG.exception("Viimase väärtuse laadimine ebaõnnestus, alustan tühjalt")
  434. return {"temperature": None, "humidity": None}
  435. def main() -> int:
  436. lock = threading.Lock()
  437. stop = threading.Event()
  438. bucket = HourBucket(current_hour())
  439. temp_writer = ClickHouseWriter(CH_TABLE)
  440. door_writer = ClickHouseWriter(CH_DOOR_TABLE)
  441. fault_writer = ClickHouseWriter(CH_FAULT_TABLE)
  442. door_tracker = DoorTracker(load_last_states())
  443. fault_tracker = FaultTracker(load_active_faults())
  444. last_values: dict[str, float | None] = load_last_temp()
  445. motion_writer = ClickHouseWriter(CH_MOTION_TABLE)
  446. energy_writer = ClickHouseWriter(CH_ENERGY_TABLE)
  447. motion_tracker = MotionTracker(load_last_motion_states())
  448. energy_bucket = EnergyBucket(current_hour())
  449. start = datetime.now(TZ)
  450. last_seen: dict[str, datetime] = {}
  451. for device, seen in load_last_seen().items():
  452. last_seen[device] = seen
  453. for device in MONITORED_DEVICES:
  454. last_seen.setdefault(device, start)
  455. def offline_timeout(device: str) -> float:
  456. if device == DEVICE:
  457. return OFFLINE_TEMP_MIN
  458. return OFFLINE_CONTACT_MIN
  459. def on_connect(client, userdata, flags, reason_code, properties=None):
  460. if reason_code == 0:
  461. topics = [
  462. MQTT_TOPIC,
  463. BRIDGE_STATE_TOPIC,
  464. *sorted(DOOR_TOPICS),
  465. *sorted(MOTION_TOPICS),
  466. *sorted(ENERGY_TOPICS),
  467. ]
  468. LOG.info("MQTT ühendatud (%s:%s), tellin %s", MQTT_HOST, MQTT_PORT, ", ".join(topics))
  469. for topic in topics:
  470. client.subscribe(topic, qos=1)
  471. if not stop.is_set():
  472. with lock:
  473. fault_writer.enqueue_many(
  474. fault_tracker.set_system_fault(
  475. SYSTEM_DEVICE_COLLECTOR,
  476. "mqtt_disconnected",
  477. False,
  478. "",
  479. datetime.now(TZ),
  480. )
  481. )
  482. else:
  483. LOG.error("MQTT ühendus ebaõnnestus: %s", reason_code)
  484. def on_disconnect(client, userdata, flags, reason_code, properties=None):
  485. LOG.warning("MQTT ühendus katkes: %s", reason_code)
  486. if not stop.is_set():
  487. with lock:
  488. fault_writer.enqueue_many(
  489. fault_tracker.set_system_fault(
  490. SYSTEM_DEVICE_COLLECTOR,
  491. "mqtt_disconnected",
  492. True,
  493. f"MQTT ühendus katkes: {reason_code}",
  494. datetime.now(TZ),
  495. )
  496. )
  497. def on_message(client, userdata, msg):
  498. try:
  499. payload = json.loads(msg.payload.decode("utf-8"))
  500. except Exception:
  501. LOG.exception("Vigane MQTT teade teemal %s", msg.topic)
  502. return
  503. if not isinstance(payload, dict):
  504. return
  505. now = datetime.now(TZ)
  506. with lock:
  507. if msg.topic == MQTT_TOPIC:
  508. bucket.add(payload)
  509. last_seen[DEVICE] = now
  510. temp = _as_float(payload.get("temperature"))
  511. hum = _as_float(payload.get("humidity"))
  512. if temp is not None:
  513. last_values["temperature"] = temp
  514. if hum is not None:
  515. last_values["humidity"] = hum
  516. fault_writer.enqueue_many(fault_tracker.on_device_message(DEVICE, payload, now))
  517. elif msg.topic in DOOR_TOPICS:
  518. device = msg.topic.rsplit("/", 1)[-1]
  519. last_seen[device] = now
  520. row = door_tracker.process(device, payload, now)
  521. if row is not None:
  522. LOG.info("Olekumuutus: %s -> %s", device, row["olek"])
  523. door_writer.enqueue(row)
  524. fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
  525. elif msg.topic in MOTION_TOPICS:
  526. device = msg.topic.rsplit("/", 1)[-1]
  527. last_seen[device] = now
  528. mrow = motion_tracker.process(device, payload, now)
  529. if mrow is not None:
  530. LOG.info("Liikumine: %s -> %s", device, mrow["olek"])
  531. motion_writer.enqueue(mrow)
  532. fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
  533. elif msg.topic in ENERGY_TOPICS:
  534. device = msg.topic.rsplit("/", 1)[-1]
  535. last_seen[device] = now
  536. energy_bucket.add(device, payload)
  537. fault_writer.enqueue_many(fault_tracker.on_device_message(device, payload, now))
  538. elif msg.topic == BRIDGE_STATE_TOPIC:
  539. offline = payload.get("state") == "offline"
  540. fault_writer.enqueue_many(
  541. fault_tracker.set_system_fault(
  542. SYSTEM_DEVICE_BRIDGE,
  543. "bridge_offline",
  544. offline,
  545. "zigbee2mqtt bridge offline",
  546. now,
  547. )
  548. )
  549. client = mqtt.Client(
  550. mqtt.CallbackAPIVersion.VERSION2,
  551. client_id="zigbee-collector",
  552. )
  553. if MQTT_USER:
  554. client.username_pw_set(MQTT_USER, MQTT_PASS)
  555. client.on_connect = on_connect
  556. client.on_disconnect = on_disconnect
  557. client.on_message = on_message
  558. client.reconnect_delay_set(min_delay=1, max_delay=60)
  559. client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60)
  560. client.loop_start()
  561. def shutdown(signum, frame):
  562. LOG.info("Lõpetamissignaal (%s), sulgen", signum)
  563. stop.set()
  564. signal.signal(signal.SIGTERM, shutdown)
  565. signal.signal(signal.SIGINT, shutdown)
  566. LOG.info(
  567. "Koguja käivitatud: temp=%s.%s, uksed=%s.%s (%d teemat), häired=%s.%s",
  568. CH_DB,
  569. CH_TABLE,
  570. CH_DB,
  571. CH_DOOR_TABLE,
  572. len(DOOR_TOPICS),
  573. CH_DB,
  574. CH_FAULT_TABLE,
  575. )
  576. while not stop.is_set():
  577. stop.wait(CHECK_INTERVAL_SEC)
  578. now = datetime.now(TZ)
  579. with lock:
  580. if current_hour(now) != bucket.hour:
  581. finished = bucket
  582. bucket = HourBucket(current_hour(now))
  583. else:
  584. finished = None
  585. if current_hour(now) != energy_bucket.hour:
  586. fe = energy_bucket
  587. energy_bucket = EnergyBucket(current_hour(now))
  588. else:
  589. fe = None
  590. if fe is not None:
  591. for r in fe.rows():
  592. energy_writer.enqueue(r)
  593. if finished is not None:
  594. if not finished.empty:
  595. temp_writer.enqueue(finished.row())
  596. elif (
  597. last_values.get("temperature") is not None
  598. or last_values.get("humidity") is not None
  599. ):
  600. LOG.info(
  601. "Tund %s ilma mõõtmisteta — täidan eelmise väärtusega (%s / %s)",
  602. finished.hour.strftime("%Y-%m-%d %H:%M"),
  603. last_values.get("temperature"),
  604. last_values.get("humidity"),
  605. )
  606. temp_writer.enqueue(finished.row(fallback=last_values))
  607. for device in sorted(MONITORED_DEVICES):
  608. seen = last_seen.get(device, start)
  609. timeout = timedelta(minutes=offline_timeout(device))
  610. offline = (now - seen) > timeout
  611. detail = (
  612. f"viimane teade {seen.strftime('%Y-%m-%d %H:%M:%S')}"
  613. if offline
  614. else ""
  615. )
  616. fault_writer.enqueue_many(
  617. fault_tracker.set_offline(device, offline, detail, now)
  618. )
  619. temp_writer.flush()
  620. door_writer.flush()
  621. motion_writer.flush()
  622. energy_writer.flush()
  623. fault_writer.flush()
  624. temp_writer.flush()
  625. door_writer.flush()
  626. motion_writer.flush()
  627. energy_writer.flush()
  628. fault_writer.flush()
  629. client.loop_stop()
  630. client.disconnect()
  631. return 0
  632. if __name__ == "__main__":
  633. raise SystemExit(main())