Files
egorin/deploy/ru2-mqtt_publisher.py
T
GermanandClaude Opus 4.8 b1c52ab748 Железный Егорин — форк Орбиты: тёплый стиль (amber), логин с odyssey WebGL-молнией справа, энергомониторинг+панель
Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент.
Доступ агентам: репо публичный, push german-токеном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:57:16 +03:00

297 lines
13 KiB
Python

"""Публикация телеметрии в 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()