Files
egorin/deploy/pcn-receiver.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

510 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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