import sys import json from socket import * import logging import os import time import binascii import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() # Полностью отключаем внутренние ретраи urllib3/requests adapter = HTTPAdapter( max_retries=Retry(total=0, connect=0, read=0, redirect=0, status=0) ) session.mount("http://", adapter) session.mount("https://", adapter) session.headers.update({"Connection": "keep-alive"}) SETTING = "19200,None,8,1,None,Enable,RS-485(2 wire)" HOST = "192.168.127.254" PORT = 1993 _last_timer_text = None _hold_zero_active = False _hold_zero_t0 = 0.0 _HOLD_ZERO_SEC = 1.0 # длительность удержания 0:00 (сек) _VMIX_URL = "http://127.0.0.1:8088/API/" _VMIX_INPUT = "SCOREBUG" _VMIX_COOLDOWN = 5.0 # сек: пауза после неудачи _vmix_suppress_until = 0.0 # монотоник-время до которого не шлём _vmix_online = None # None/True/False — для логов «online/offline» один раз # --- MOXA reconnect/watchdog --- _SOCK_RECV_TIMEOUT = 2.0 # сек: таймаут чтения сокета _IDLE_RECONNECT = 5.0 # сек: если давно нет данных — переподключиться _RETRY_BACKOFF = 1.0 # сек: начальный бэкофф между попытками _RETRY_BACKOFF_MAX = 10.0 # сек: верхняя граница бэкоффа _last_rx_mono = 0.0 # монотоник-время последнего полученного байта/кадра def _hexspace(bs: bytes) -> str: return " ".join(f"{b:02X}" for b in bs) FRAME_SIZE = 18 SYNC = 0xA0 # первый байт кадра (из лога видно 0xA0) TAIL = 0x00 # 18-й байт должен быть 0x00 _rx_buf = bytearray() def send_data(name: str, value): """ «Мягкая» отправка в vMix. Любые сетевые ошибки подавляются, чтобы парсер продолжал работать. Возвращает True/False. """ global _vmix_suppress_until, _vmix_online # Не спамим, если недавно был фейл now = time.monotonic() if now < _vmix_suppress_until: return False try: kind = name.split(".")[1] except Exception: kind = "Text" par = "SetText" if kind == "Text" else "SetImage" params = { "Function": par, "Input": _VMIX_INPUT, "SelectedName": name, "Value": value, } try: # Короткий таймаут, чтобы не блокировать парсер resp = session.get(_VMIX_URL, params=params, timeout=0.25) # если раньше считали оффлайн — сообщим, что теперь онлайн if _vmix_online is not True: print("[vMix] online") _vmix_online = True return True except requests.exceptions.RequestException as e: # Перешли в оффлайн — логнём один раз и включим cooldown if _vmix_online is not False: msg = f"[vMix] offline: {e.__class__.__name__}: {e}" print(msg) _vmix_online = False _vmix_suppress_until = now + _VMIX_COOLDOWN return False def _process_frame(frame: bytes): # frame — ровно 18 байт edata = _hexspace(frame) # print("edata:", edata) items = edata.split() # индексы как у тебя minutes = int(items[5], 16) seconds_raw = int(items[6], 16) # --- нормализация секунд --- if seconds_raw < 60: seconds = seconds_raw timer_str_calc = f"{minutes}:{seconds:02d}" else: seconds = seconds_raw - 128 timer_str_calc = f"{minutes}.{seconds}" # --- анти-дребезг 0.0 / 2:00 --- global _hold_zero_active, _hold_zero_t0, _last_timer_text now = time.monotonic() # флаг: текущее время == 0:00 is_zero_time = minutes == 0 and seconds == 0 # флаг: внезапно появилось 2:00 (рывок после сирены) is_two_zero = minutes == 2 and seconds == 0 if is_zero_time: # удерживаем 0.0 _hold_zero_active = True _hold_zero_t0 = now timer_str = "0.0" elif _hold_zero_active and is_two_zero: # игнорируем «рывок» 2:00 и продолжаем показывать 0.0 timer_str = "0.0" # сбрасываем удержание, если прошло больше HOLD_ZERO_SEC if now - _hold_zero_t0 > _HOLD_ZERO_SEC: _hold_zero_active = False else: # обычный случай if _hold_zero_active and (now - _hold_zero_t0 > _HOLD_ZERO_SEC): _hold_zero_active = False timer_str = timer_str_calc seconds_24_byte = int(items[7], 16) timer_attack = "" if seconds_24_byte == 0xFF: # Специальное значение: не показывать timer_attack = "" elif seconds_24_byte > 0x7F: # Режим десятых: значение в десятых после -128 (0..127 => 0.0..12.7 с) t_dec = seconds_24_byte - 0x80 # десятые секунды # правило ">24 c" тут не актуально: максимум 12.7 с, значит всегда показываем timer_attack = f"{t_dec // 10}.{t_dec % 10}" else: # Режим целых секунд (0..127 c) — показываем только если ≤ 24 c if seconds_24_byte <= 24: timer_attack = str(seconds_24_byte) else: timer_attack = "" # > 24 c — не отображать quarter = int(items[14][1], 16) score1 = int(items[8], 16) score2 = int(items[9], 16) data = { "TIMER.Text": timer_str, "24sec.Text": timer_attack, "SCORE1.Text": score1, "SCORE2.Text": score2, } send_data("TIMER.Text", data["TIMER.Text"]) _last_timer_text = timer_str send_data("24sec.Text", data["24sec.Text"]) send_data("SCORE1.Text", data["SCORE1.Text"]) send_data("SCORE2.Text", data["SCORE2.Text"]) x = [int(x, 16) for x in items] print(f"{edata}, timer: {timer_str}, attack: {timer_attack}, Q: {quarter}, {x}") def parse_new(chunk: bytes): global _rx_buf, _last_rx_mono if chunk == b"\xff" or not chunk: return _last_rx_mono = time.monotonic() # получили порцию данных — сброс idle _rx_buf.extend(chunk) # пытаемся вычленять кадры, пока хватает данных while len(_rx_buf) >= FRAME_SIZE: # если начало не синхронно — смещаемся к следующему SYNC (0xA0) if _rx_buf[0] != SYNC: # ищем ближайший возможный старт try: pos = _rx_buf.index(SYNC) # отбросим мусор до sync del _rx_buf[:pos] except ValueError: # sync не нашли — чистим всё, ждём ещё _rx_buf.clear() return # если данных меньше кадра — ждём if len(_rx_buf) < FRAME_SIZE: return frame = _rx_buf[:FRAME_SIZE] # валидация «хвоста» кадра (18-й байт == 0x00) if frame[FRAME_SIZE - 1] != TAIL: # старт совпал, но не попали в границу кадра — сдвигаемся на байт del _rx_buf[0] continue # опционально: простая проверка «RU» на позициях 9-10 (0x52 0x55) из твоего лога # if not (frame[8] == 0x52 and frame[9] == 0x55): # del _rx_buf[0] # continue # есть валидный кадр — обрабатываем _process_frame(bytes(frame)) # удаляем его из буфера del _rx_buf[:FRAME_SIZE] def read_logs(): with open( # r"C:\Code\timer\LOGS\timer_SARATOV_full.log", r"C:\Code\timer\LOGS\timer_SARATOV copy.log", # r"C:\Users\soule\Downloads\Telegram Desktop\timer_SARATOV.log", "r", ) as file: for line in file: parts = line.strip().split(" DEBUG ") if len(parts) == 2: timestamp = parts[0] data = eval(parts[1]) parse_new(data) time.sleep(0.025) def _connect_socket(): """Создаёт и настраивает TCP-сокет до MOXA.""" s = socket(AF_INET, SOCK_STREAM) # небольшой общий таймаут на операции, чтобы не подвисать s.settimeout(_SOCK_RECV_TIMEOUT) # опционально: keepalive (на Windows просто включает флаг) try: s.setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1) except OSError: pass s.connect((HOST, PORT)) # приветствие, как у тебя hello = b"hello" s.send(hello) return s def run_client(): """Главный цикл: читает данные, парсит, в случае проблем — переподключается.""" global _last_rx_mono, _rx_buf backoff = _RETRY_BACKOFF s = None _rx_buf = bytearray() _last_rx_mono = time.monotonic() while True: try: if s is None: # попытка подключения print(f"[MOXA] connecting to {HOST}:{PORT} ...") s = _connect_socket() print("[MOXA] connected") backoff = _RETRY_BACKOFF # сброс бэкоффа после успеха _last_rx_mono = time.monotonic() # если слишком давно не приходят данные — форсим реконнект if time.monotonic() - _last_rx_mono > _IDLE_RECONNECT: raise TimeoutError(f"idle {time.monotonic() - _last_rx_mono:.1f}s") # читаем порциями (таймаут на сокете стоит) try: chunk = s.recv(1024) if not chunk: # 0 байт => соединение закрыто удалённой стороной raise ConnectionResetError("socket closed by peer") # отдаём парсеру logging.debug(chunk) parse_new(chunk) except timeout: # это socket.timeout — просто проверим idle на следующей итерации pass except (ConnectionError, OSError, TimeoutError) as e: # Любая ошибка — мягко закрываем и ждём перед повтором if s is not None: try: s.close() except Exception: pass s = None print(f"[MOXA] reconnect in {backoff:.1f}s due to: {e}") time.sleep(backoff) backoff = min(backoff * 2, _RETRY_BACKOFF_MAX) continue def main(): if not os.path.isdir("LOGS"): os.mkdir("LOGS") LogFileName = f"LOGS/timer_SARATOV.log" logging.basicConfig( level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s", filename=LogFileName, filemode="w", ) try: run_client() except KeyboardInterrupt: sys.exit(1) if __name__ == "__main__": try: main() # read_logs() except KeyboardInterrupt: sys.exit(1)