collector.py.bak.1791388956 22 KB

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