"""Публикация телеметрии в MQTT с авто-обнаружением Home Assistant. Каждый трекер (по IMEI) автоматически появляется в Home Assistant как устройство со своими сущностями: напряжение питания/АКБ, температуры, вскрытие шкафа, входы, уровень GSM, код события, позиция на карте. HA дальше используется как движок сцен/автоматизаций и агро-панель. БУФЕРИЗАЦИЯ: при обрыве связи с брокером (туннель/mosquitto down) сообщения не теряются — пишутся в дисковый спул /opt/monitor-flex-ru2/spool/queue.jsonl (том, переживает пересборку), фоновый поток до-досылает при восстановлении. """ import json import logging import os import tempfile import threading import time import paho.mqtt.client as mqtt from . import config log = logging.getLogger("flex.mqtt") # ---- Дисковый спул ---- SPOOL_DIR = os.getenv("SPOOL_DIR", "/app/spool") SPOOL_FILE = os.path.join(SPOOL_DIR, "queue.jsonl") SPOOL_MAX_BYTES = 100 * 1024 * 1024 # 100 МБ — лимит спула SPOOL_TRIM_BYTES = 80 * 1024 * 1024 # при ротации ужимаем до 80 МБ (дропаем старейшие) DRAIN_INTERVAL = 5 # секунд между попытками до-досыла DRAIN_BATCH = 500 # сколько сообщений максимум за один проход # Числовые сенсоры: (ключ, имя, ед.изм., device_class, иконка) # Набор подобран под START S-2013: напряжение основного питания снимается с AIN3 # (см. README, профиль S-2013), pwr_int = «системное питание» ≈ АКБ модуля. # Температуры приходят с RS-485/Bluetooth-датчиков (если настроены в конфигураторе). SENSORS = [ ("pwr_ext", "Напряжение питания (AIN3→Ug+)", "V", "voltage", None), ("pwr_int", "Системное питание (АКБ)", "V", "voltage", None), ("temp1", "Температура 1", "°C", "temperature", None), ("temp2", "Температура 2", "°C", "temperature", None), ("gsm", "Уровень GSM", None, None, "mdi:signal"), ("event_code", "Код события", None, None, "mdi:alert-circle-outline"), ] # Бинарные датчики: (ключ, имя, device_class) # У S-2013 две дискретные линии (IN1, IN2); AIN3 занят под измерение напряжения. # Датчик двери/вскрытия шкафа подключается к IN1 или IN2. BINARY_SENSORS = [ ("in1", "Дверь шкафа (IN1)", "door"), ("in2", "Охрана (IN2)", "safety"), ("chasis_open", "Вскрытие корпуса", "tamper"), ] class MqttPublisher: def __init__(self): self.client = mqtt.Client(client_id=config.MQTT_CLIENT_ID, protocol=mqtt.MQTTv311) if config.MQTT_USER: self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASSWORD) self._announced = set() self._spool_lock = threading.Lock() self._connected = False self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect self._drain_stop = threading.Event() self._drain_thread = None # ---- paho callbacks: следим за реальным состоянием соединения ---- def _on_connect(self, client, userdata, flags, rc): self._connected = (rc == 0) if rc == 0: log.info("MQTT connected (on_connect)") else: log.warning("MQTT connect rc=%s", rc) def _on_disconnect(self, client, userdata, rc): self._connected = False if rc != 0: log.warning("MQTT disconnected rc=%s (буферизую на диск)", rc) def connect(self): # connect_async + loop_start: не блокируемся, авто-reconnect средствами paho self.client.reconnect_delay_set(min_delay=1, max_delay=30) self.client.connect_async(config.MQTT_HOST, config.MQTT_PORT, keepalive=60) self.client.loop_start() self._spool_ensure() self._drain_thread = threading.Thread(target=self._drain_loop, daemon=True) self._drain_thread.start() log.info("MQTT loop запущен -> %s:%s (spool %s)", config.MQTT_HOST, config.MQTT_PORT, SPOOL_FILE) def _state_topic(self, imei): return f"{config.MQTT_BASE_TOPIC}/{imei}/state" def _device_block(self, imei): return { "identifiers": [f"navtelecom_{imei}"], "name": f"Щитовой шкаф {imei}", "manufacturer": "Navtelecom", "model": "FLEX tracker", } def _announce(self, imei): """Опубликовать конфиги обнаружения HA (один раз на устройство).""" if imei in self._announced: return state_topic = self._state_topic(imei) dev = self._device_block(imei) pfx = config.HA_DISCOVERY_PREFIX for key, name, unit, dclass, icon in SENSORS: uid = f"navtelecom_{imei}_{key}" cfg = { "name": name, "unique_id": uid, "state_topic": state_topic, "value_template": f"{{{{ value_json.{key} }}}}", "device": dev, } if unit: cfg["unit_of_measurement"] = unit if dclass: cfg["device_class"] = dclass if dclass in ("voltage", "temperature"): cfg["state_class"] = "measurement" if icon: cfg["icon"] = icon self._pub(f"{pfx}/sensor/{uid}/config", cfg, retain=True) for key, name, dclass in BINARY_SENSORS: uid = f"navtelecom_{imei}_{key}" cfg = { "name": name, "unique_id": uid, "state_topic": state_topic, "value_template": f"{{{{ value_json.{key} }}}}", "payload_on": 1, "payload_off": 0, "device_class": dclass, "device": dev, } self._pub(f"{pfx}/binary_sensor/{uid}/config", cfg, retain=True) # Позиция на карте (device_tracker по GPS-атрибутам) uid = f"navtelecom_{imei}_tracker" self._pub(f"{pfx}/device_tracker/{uid}/config", { "name": f"Позиция {imei}", "unique_id": uid, "json_attributes_topic": state_topic, "source_type": "gps", "device": dev, }, retain=True) self._announced.add(imei) log.info("HA discovery опубликован для %s", imei) def publish(self, imei, record): """Опубликовать одну запись телеметрии.""" if not imei: return self._announce(imei) payload = dict(record) # device_tracker ждёт latitude/longitude if "lat" in record and "lon" in record: payload["latitude"] = record["lat"] payload["longitude"] = record["lon"] self._pub(self._state_topic(imei), payload, retain=True) def _pub(self, topic, obj, retain=False): """Публикация с буферизацией: если брокер недоступен — спуливаем на диск.""" data = json.dumps(obj, ensure_ascii=False) self._pub_raw(topic, data, retain) def _pub_raw(self, topic, data, retain=False): info = None if self._connected: try: info = self.client.publish(topic, data, retain=retain) except Exception as e: log.warning("MQTT publish exc: %s", e) info = None if info is None or info.rc != mqtt.MQTT_ERR_SUCCESS: # Брокер недоступен — не теряем, пишем в дисковый спул. self._spool_append(topic, data, retain) # ============================================================ # Дисковый спул: append+fsync при обрыве, дренаж при восстановлении # ============================================================ def _spool_ensure(self): try: os.makedirs(SPOOL_DIR, exist_ok=True) except OSError as e: log.error("spool mkdir err: %s", e) def _spool_append(self, topic, data, retain): line = json.dumps({"topic": topic, "payload": data, "retain": bool(retain)}, ensure_ascii=False) + "\n" with self._spool_lock: try: self._spool_rotate_if_needed() with open(SPOOL_FILE, "a", encoding="utf-8") as f: f.write(line) f.flush() os.fsync(f.fileno()) log.warning("SPOOLED %s (брокер недоступен)", topic) except OSError as e: log.error("spool append err: %s (СООБЩЕНИЕ ПОТЕРЯНО)", e) def _spool_rotate_if_needed(self): """Ограничение размера: дропаем старейшие строки, сохраняя свежий хвост.""" try: if not os.path.exists(SPOOL_FILE): return size = os.path.getsize(SPOOL_FILE) if size <= SPOOL_MAX_BYTES: return log.warning("spool rotate: %d байт > лимит, ужимаю до %d", size, SPOOL_TRIM_BYTES) with open(SPOOL_FILE, "rb") as f: f.seek(max(0, size - SPOOL_TRIM_BYTES)) f.readline() tail = f.read() tmp = SPOOL_FILE + ".tmp" with open(tmp, "wb") as f: f.write(tail) f.flush() os.fsync(f.fileno()) os.replace(tmp, SPOOL_FILE) except OSError as e: log.error("spool rotate err: %s", e) def _drain_once(self): with self._spool_lock: if not os.path.exists(SPOOL_FILE) or os.path.getsize(SPOOL_FILE) == 0: return 0 try: with open(SPOOL_FILE, "r", encoding="utf-8") as f: lines = f.readlines() except OSError as e: log.error("drain read err: %s", e) return 0 sent = 0 remainder = None for i, line in enumerate(lines[:DRAIN_BATCH]): line = line.strip() if not line: sent += 1 continue if not self._connected: remainder = lines[i:] break try: rec = json.loads(line) info = self.client.publish(rec["topic"], rec["payload"], retain=rec.get("retain", False)) if info.rc != mqtt.MQTT_ERR_SUCCESS: remainder = lines[i:] break sent += 1 except Exception as e: log.error("drain: битая строка спула, дроп: %s", e) sent += 1 if remainder is None: remainder = lines[DRAIN_BATCH:] # Перезаписываем спул оставшимся хвостом атомарно with self._spool_lock: try: if remainder: fd, tmp = tempfile.mkstemp(dir=SPOOL_DIR, prefix="q.", suffix=".tmp") with os.fdopen(fd, "w", encoding="utf-8") as f: f.writelines(remainder) f.flush() os.fsync(f.fileno()) os.replace(tmp, SPOOL_FILE) else: if os.path.exists(SPOOL_FILE): os.remove(SPOOL_FILE) except OSError as e: log.error("drain rewrite err: %s", e) if sent: log.info("DRAINED %d из спула, осталось %d", sent, len(remainder)) return sent def _drain_loop(self): while not self._drain_stop.is_set(): self._drain_stop.wait(DRAIN_INTERVAL) try: if os.path.exists(SPOOL_FILE) and os.path.getsize(SPOOL_FILE) > 0: self._drain_once() except Exception as e: log.error("drain_loop err: %s", e) def close(self): self._drain_stop.set() self.client.loop_stop() self.client.disconnect()