collector.py 20 KB

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