Железный Егорин — форк Орбиты: тёплый стиль (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>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""Публикация телеметрии в 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()
|
||||
Reference in New Issue
Block a user