Железный Егорин — форк Орбиты: тёплый стиль (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:
German
2026-07-07 23:57:16 +03:00
co-authored by Claude Opus 4.8
commit b1c52ab748
114 changed files with 14340 additions and 0 deletions
+509
View File
@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""
ПЦН-приёмник (ru3): Surgard MLR2-DG / Contact ID (:5111) + SIA DC-09 (:5112).
Декодирует account + код события, публикует в mosquitto (raw MQTT 3.1.1, stdlib-only)
топик guard/<account>/event JSON.
ПЕРВАЯ рабочая версия — минимальный, но живой приёмник.
TODO: полная валидация SIA DC-09 CRC/шифрование; edge-cases Contact ID checksum.
"""
import asyncio
import json
import os
import re
import socket
import struct
import tempfile
import threading
import time
import logging
# ---- Конфиг ----
MQTT_HOST = "10.50.0.48" # mosquitto vm-mts1 по туннелю awg-mesh
MQTT_PORT = 1883
MQTT_KEEPALIVE = 60
SURGARD_PORT = 5111
SIA_PORT = 5112
CLIENT_ID = "pcn-ru3"
# ---- Дисковый спул (буферизация при обрыве связи с брокером) ----
SPOOL_DIR = "/var/spool/pcn"
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 = 200 # сколько сообщений максимум за один проход
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("pcn")
# ---- Соответствие SIA <-> Surgard Contact ID (из src/data/security.ts) ----
SIA_TO_CID = {
"BA": "E130", "FA": "E110", "PA": "E120", "HA": "E121", "TA": "E137",
"OP": "E401", "CL": "R401", "AT": "E301", "AR": "R301", "BT": "E302",
"YC": "E354", "NL": "E381",
}
CID_TO_SIA = {v: k for k, v in SIA_TO_CID.items()}
# ============================================================
# Минимальный raw-MQTT publisher (3.1.1), без внешних зависимостей
# ============================================================
def _mqtt_remaining_length(n: int) -> bytes:
out = bytearray()
while True:
b = n % 128
n //= 128
if n > 0:
b |= 0x80
out.append(b)
if n == 0:
break
return bytes(out)
def _mqtt_str(s: str) -> bytes:
b = s.encode("utf-8")
return struct.pack("!H", len(b)) + b
class MqttPublisher:
"""Синхронный публикатор в отдельном потоке-неблокирующий он не нужен:
вызываем из executor, чтобы не блокировать asyncio-loop."""
def __init__(self, host, port, client_id, keepalive=60):
self.host = host
self.port = port
self.client_id = client_id
self.keepalive = keepalive
self.sock = None
# publish() и дренаж исполняются в разных потоках executor'а — сериализуем сокет.
self._lock = threading.Lock()
def _connect(self):
s = socket.create_connection((self.host, self.port), timeout=8)
# CONNECT packet
payload = _mqtt_str(self.client_id)
var_header = _mqtt_str("MQTT") + bytes([0x04, 0x02]) + struct.pack("!H", self.keepalive)
body = var_header + payload
pkt = bytes([0x10]) + _mqtt_remaining_length(len(body)) + body
s.sendall(pkt)
# CONNACK (4 bytes)
resp = s.recv(4)
if len(resp) < 4 or resp[0] != 0x20 or resp[3] != 0x00:
s.close()
raise ConnectionError(f"MQTT CONNACK failed: {resp!r}")
self.sock = s
def publish(self, topic, payload, qos=0):
with self._lock:
if self.sock is None:
self._connect()
try:
self._publish(topic, payload)
except (OSError, ConnectionError) as e:
# reconnect once
log.warning("MQTT publish err (%s), reconnecting", e)
try:
if self.sock:
self.sock.close()
except OSError:
pass
self.sock = None
self._connect()
self._publish(topic, payload)
def _publish(self, topic, payload):
body = _mqtt_str(topic) + payload.encode("utf-8")
pkt = bytes([0x30]) + _mqtt_remaining_length(len(body)) + body
self.sock.sendall(pkt)
_pub = MqttPublisher(MQTT_HOST, MQTT_PORT, CLIENT_ID, MQTT_KEEPALIVE)
# ============================================================
# Дисковый спул: append+fsync при обрыве, дренаж при восстановлении
# ============================================================
def _spool_ensure():
try:
os.makedirs(SPOOL_DIR, exist_ok=True)
except OSError as e:
log.error("spool mkdir err: %s", e)
def _spool_append(topic, payload):
"""Атомарно (append+fsync) дописать одно сообщение в очередь.
Строка формата: {"topic":..,"payload":..}\n"""
_spool_ensure()
line = json.dumps({"topic": topic, "payload": payload}, ensure_ascii=False) + "\n"
try:
_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 (queue on disk)", topic)
except OSError as e:
log.error("spool append err: %s (СООБЩЕНИЕ ПОТЕРЯНО)", e)
def _spool_rotate_if_needed():
"""Ограничение размера: если спул > SPOOL_MAX, дропаем старейшие строки
до SPOOL_TRIM (сохраняем хвост = самые свежие события)."""
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():
"""Синхронно (в executor): попытаться до-дослать спул.
Читает весь файл, публикует по одному; при первой ошибке — сохраняет
непосланный остаток обратно (атомарно) и выходит. Возвращает кол-во посланных."""
if not os.path.exists(SPOOL_FILE) or os.path.getsize(SPOOL_FILE) == 0:
return 0
sent = 0
remainder = None
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
for i, line in enumerate(lines[:DRAIN_BATCH]):
line = line.strip()
if not line:
sent += 1
continue
try:
rec = json.loads(line)
_pub.publish(rec["topic"], rec["payload"])
sent += 1
except (OSError, ConnectionError) as e:
log.warning("drain: брокер снова недоступен (%s), стоп на %d", e, i)
remainder = lines[i:]
break
except Exception as e:
log.error("drain: битая строка спула, дроп: %s", e)
sent += 1
if remainder is None:
remainder = lines[DRAIN_BATCH:]
# Перезаписываем спул оставшимся хвостом атомарно
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:
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
async def drain_loop():
"""Фоновая задача: раз в DRAIN_INTERVAL секунд пытается до-дослать спул."""
loop = asyncio.get_event_loop()
while True:
await asyncio.sleep(DRAIN_INTERVAL)
try:
if os.path.exists(SPOOL_FILE) and os.path.getsize(SPOOL_FILE) > 0:
await loop.run_in_executor(None, _drain_once)
except Exception as e:
log.error("drain_loop err: %s", e)
async def publish_event(account, proto, cid, sia, zone, raw):
doc = {
"account": account,
"proto": proto,
"cid": cid,
"sia": sia,
"zone": zone,
"raw": raw,
"ts": int(time.time()),
}
topic = f"guard/{account}/event"
payload = json.dumps(doc, ensure_ascii=False)
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, _pub.publish, topic, payload)
log.info("PUB %s %s", topic, payload)
except Exception as e:
# Брокер/туннель недоступны — НЕ теряем: спуливаем на диск.
log.error("MQTT publish failed: %s -> спул", e)
await loop.run_in_executor(None, _spool_append, topic, payload)
# ============================================================
# Разбор Contact ID кода -> (cid, sia)
# ============================================================
def cid_to_pair(qualifier, event3):
"""qualifier '1'=new/E , '3'=restore/R ; event3 = 3 цифры (130,110,...)"""
prefix = "R" if qualifier == "3" else "E"
cid = f"{prefix}{event3}"
sia = CID_TO_SIA.get(cid, "")
return cid, sia
# ============================================================
# Surgard MLR2-DG / Contact ID приёмник (:5111)
# ============================================================
# Форматы, которые пытаемся распознать:
# 1) Sur-Gard 5-2-1: "5BB 18 <account> 18 <CID> <qual> <zone>"
# напр. "5011 18 1042 18 130 01 002"
# 2) ADM-CID Contact ID: "<ACCT> 18 <MT> <Q> <XYZ> <GG> <CCC>"
# где MT=18(DTMF)/98, Q=1/3, XYZ=код, GG=group/partition, CCC=zone/user
SURGARD_ACK = b"\x06" # приёмник подтверждает событие символом ACK
def parse_surgard(line: str):
line = line.strip()
if not line:
return None
toks = line.split()
# Попытка: найти шаблон Contact ID где-то в строке.
# Ищем последовательность: <acct> 18 <q><xyz> ... либо явные поля.
# Нормализуем: убираем ведущий "5xx" код линии Sur-Gard если есть.
# Универсальный разбор по токенам:
# ищем токен "18" (Contact ID message type), после него qual+code.
account = None
cid = None
sia = None
zone = None
# Вариант A: пробельно-разделённые поля (тестовый формат)
# "5011 18 1042 18 130 01 002"
# idx: 0=lineprefix 1=18 2=account 3=18 4=code 5=group 6=zone
if len(toks) >= 7 and toks[1] == "18" and toks[3] == "18":
account = toks[2]
code = toks[4] # 130
# qualifier может идти как отдельная 1/3 или быть слит; тут по группе.
# В классике group '01'/'02' — event; берём E по умолчанию.
qual = "1"
cid, sia = cid_to_pair(qual, code.zfill(3)[:3])
zone = toks[6]
return account, cid, sia, zone
# Вариант B: слитный Contact ID "ACCT 18 Q XYZ GG CCC"
# напр. "1042 18 1 130 01 002"
if len(toks) >= 6 and toks[1] in ("18", "98"):
account = toks[0]
qual = toks[2]
code = toks[3]
cid, sia = cid_to_pair(qual, code.zfill(3)[:3])
zone = toks[-1]
return account, cid, sia, zone
# Вариант C: полностью слитная ADM-CID строка "10421813001002"
# ACCT(4) 18 Q(1) XYZ(3) GG(2) CCC(3)
m = re.match(r"^(\d{3,4})18([13])(\d{3})(\d{2})(\d{3})$", line.replace(" ", ""))
if m:
account = m.group(1)
qual = m.group(2)
code = m.group(3)
zone = m.group(5)
cid, sia = cid_to_pair(qual, code)
return account, cid, sia, zone
# Не распознали — вернём хотя бы raw с пустыми полями (account=UNKNOWN)
return None
async def handle_surgard(reader, writer):
peer = writer.get_extra_info("peername")
log.info("Surgard conn from %s", peer)
try:
while True:
data = await asyncio.wait_for(reader.readline(), timeout=120)
if not data:
break
raw = data.decode("latin-1", errors="replace").rstrip("\r\n")
if not raw:
continue
log.info("Surgard raw: %r", raw)
parsed = parse_surgard(raw)
if parsed:
account, cid, sia, zone = parsed
await publish_event(account, "surgard", cid, sia, zone, raw)
else:
log.warning("Surgard: unparsed %r -> publishing raw under UNKNOWN", raw)
await publish_event("UNKNOWN", "surgard", "", "", None, raw)
# ACK приёмника
writer.write(SURGARD_ACK)
await writer.drain()
except asyncio.TimeoutError:
log.info("Surgard %s idle timeout", peer)
except Exception as e:
log.error("Surgard handler err: %s", e)
finally:
try:
writer.close()
except Exception:
pass
log.info("Surgard conn closed %s", peer)
# ============================================================
# SIA DC-09 приёмник (:5112)
# ============================================================
# Кадр DC-09 (упрощённо):
# \x0A <CRC4hex> <LEN4hex> "<id>" <seq> R<rcvr> L<line> #<account> [<data>] <ts> \x0D
# id: "SIA-DCS" | "ADM-CID" | "NULL" | ...
# data для ADM-CID: |< contact-id ...> ; для SIA-DCS: |Nri1/BA01]
# Ответ ACK: \x0A <CRC> <LEN> "ACK" <seq> R.. L.. #acct [] <ts> \x0D
LF = 0x0A
CR = 0x0D
def _dc09_crc(msg: bytes) -> int:
"""CRC-16 (polynomial 0x8005, reflected) как в DC-09 (для ACK)."""
crc = 0
for b in msg:
crc ^= b
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc & 0xFFFF
def parse_sia(frame: str):
"""frame — содержимое между LF и CR (без обрамляющих).
Возвращает (account, cid, sia, zone, seq, id_token)."""
account = None
cid = None
sia = None
zone = None
seq = "0000"
id_token = ""
# id в кавычках
m_id = re.search(r'"([A-Z\-]+)"', frame)
if m_id:
id_token = m_id.group(1)
# seq — 4 цифры сразу после закрывающей кавычки id
m_seq = re.search(r'"[A-Z\-]+"(\d{4})', frame)
if m_seq:
seq = m_seq.group(1)
# account: #ACCT
m_acct = re.search(r"#([0-9A-Fa-f]+)", frame)
if m_acct:
account = m_acct.group(1)
# payload между [ ... ] (у SIA-DCS) или после последнего |
# SIA-DCS data: |Nri1/BA01] -> code=BA zone=01
m_sia = re.search(r"/([A-Z]{2})(\d{1,3})", frame)
if m_sia:
sia = m_sia.group(1)
zone = m_sia.group(2)
cid = SIA_TO_CID.get(sia, "")
else:
# ADM-CID внутри DC-09: |1 XYZ GG CCC| или похожее
m_cid = re.search(r"\|(\d)?\s?(\d{3})\s?(\d{2})?\s?(\d{2,3})?", frame)
if m_cid:
qual = m_cid.group(1) or "1"
code = m_cid.group(2)
zone = m_cid.group(4)
prefix = "R" if qual == "3" else "E"
cid = f"{prefix}{code}"
sia = CID_TO_SIA.get(cid, "")
return account, cid, sia, zone, seq, id_token
def build_sia_ack(seq: str, account: str) -> bytes:
"""Строим минимальный DC-09 ACK-кадр."""
ts = time.strftime("_%H:%M:%S,%m-%d-%Y", time.gmtime())
acct = account or "0"
body = f'"ACK"{seq}R0L0#{acct}[]{ts}'
b = body.encode("ascii", errors="replace")
crc = _dc09_crc(b)
length = len(b)
frame = f'\n{crc:04X}{length:04X}{body}\r'
return frame.encode("ascii", errors="replace")
async def handle_sia(reader, writer):
peer = writer.get_extra_info("peername")
log.info("SIA conn from %s", peer)
try:
while True:
# читаем до CR (0x0D) — конец кадра DC-09
data = await asyncio.wait_for(reader.readuntil(b"\r"), timeout=120)
if not data:
break
raw = data.decode("latin-1", errors="replace")
log.info("SIA raw: %r", raw)
# вырезаем содержимое между LF и CR
inner = raw
if inner and inner[0] == "\n":
inner = inner[1:]
inner = inner.rstrip("\r")
account, cid, sia, zone, seq, id_token = parse_sia(inner)
if account is None:
account = "UNKNOWN"
await publish_event(account, "sia", cid, sia, zone, raw.strip())
# ACK-кадр
try:
ack = build_sia_ack(seq, account if account != "UNKNOWN" else "0")
writer.write(ack)
await writer.drain()
except Exception as e:
log.warning("SIA ACK build/send err: %s", e)
except asyncio.IncompleteReadError:
log.info("SIA %s incomplete/closed", peer)
except asyncio.TimeoutError:
log.info("SIA %s idle timeout", peer)
except Exception as e:
log.error("SIA handler err: %s", e)
finally:
try:
writer.close()
except Exception:
pass
log.info("SIA conn closed %s", peer)
# ============================================================
async def main():
srv_sur = await asyncio.start_server(handle_surgard, "0.0.0.0", SURGARD_PORT)
srv_sia = await asyncio.start_server(handle_sia, "0.0.0.0", SIA_PORT)
_spool_ensure()
drain = asyncio.ensure_future(drain_loop())
log.info("PCN receiver up: Surgard :%d, SIA DC-09 :%d -> mqtt %s:%d (spool %s)",
SURGARD_PORT, SIA_PORT, MQTT_HOST, MQTT_PORT, SPOOL_FILE)
async with srv_sur, srv_sia:
await asyncio.gather(srv_sur.serve_forever(), srv_sia.serve_forever(), drain)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass