From a59a299d733203e9454bea66598afb135ad16263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Tue, 11 Nov 2025 23:44:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=A1=D0=B0=D1=80=D0=B0=D1=82=D0=BE=D0=B2=D0=B0:=201.?= =?UTF-8?q?=20=D0=B5=D1=81=D0=BB=D0=B8=20=D0=B8=D0=B4=D0=B5=D1=82=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BC=D1=83=D1=82,=20=D1=82=D0=BE=20=D0=BD=D0=B0?= =?UTF-8?q?=2024=20=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4=D0=B0=D1=85=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5?= =?UTF-8?q?=2024=20=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4=202.=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=BD=D1=87?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=82=D1=8C,=20=D1=82=D0=BE=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=B8=D1=82=D1=80=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D1=8F=D1=82=202:00=20=D0=B8=D0=BB=D0=B8=2015?= =?UTF-8?q?:00=20(=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=D0=B2=D0=B0)=20=D0=BA=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20(?= =?UTF-8?q?=D1=85=D0=B2=D0=B0=D1=82=D0=B8=D1=82=20=D1=87=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BD=D1=8F=D1=82=D1=8C=20=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=D0=BD=D0=BE=20=D0=B2=D0=B5=D1=80=D1=85=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=81=D1=87=D0=B5=D1=82)=203.=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BC=D0=B5=D1=80=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B4=D0=B0=D0=B6=D0=B5=20=D0=B5=D1=81=D0=BB?= =?UTF-8?q?=D0=B8=20vMix=20=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=20(=D0=B2=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B8=20vMix=20=D0=BD=D0=B5=20=D0=BD=D1=83=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B1=D1=83=D1=82=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80)=204.=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D0=B0=D0=B5=20=D0=B5=D1=81=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B2=D0=B8=D1=81=D0=BD=D0=B5=D1=82=20MOXA?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85,=20=D1=82=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=82=D0=BE=20=D0=B4=D0=BE=D0=BB=D0=B6=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=D1=81=D1=8F=20(=D0=BD=D1=83=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B8=D1=82=D1=8C?= =?UTF-8?q?)=205.=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=BD=D0=B3=D0=B5=20=D1=81=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=20FF=2052,=20=D0=B8=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=81=D0=BF=D0=BB=D0=B8=D1=82,=20?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=2018=20=D0=B1=D0=B0=D0=B9?= =?UTF-8?q?=D1=82,=20=D1=82=D0=BE=20=D1=81=D0=BF=D0=BB=D0=B8=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=2018=20=D0=B1=D0=B0=D0=B9=D1=82=D1=83=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20(=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BD=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=85,=20=D0=B2=D1=81=D0=B5=20=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timer_saratov.py | 334 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 260 insertions(+), 74 deletions(-) diff --git a/timer_saratov.py b/timer_saratov.py index 0e96c60..c93f3c7 100644 --- a/timer_saratov.py +++ b/timer_saratov.py @@ -6,88 +6,223 @@ 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» один раз -def hexspace(string, length): - return " ".join(string[i : i + length] for i in range(0, len(string), length)) +# --- 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 # монотоник-время последнего полученного байта/кадра -session = requests.Session() -session.headers.update({"Connection": "keep-alive"}) +def _hexspace(bs: bytes) -> str: + return " ".join(f"{b:02X}" for b in bs) -def send_data(name, value): - url = "http://127.0.0.1:8088/API/" - par = "SetText" if name.split(".")[1] == "Text" else "SetImage" +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": "SCOREBUG", + "Input": _VMIX_INPUT, "SelectedName": name, "Value": value, } - session.get(url, params=params) + + 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 parse_new(line): - if line == b"\xff": - return +def _process_frame(frame: bytes): + # frame — ровно 18 байт + edata = _hexspace(frame) + # print("edata:", edata) - cdata = binascii.hexlify(line) - ddata = cdata.decode("utf-8").upper() - edata = hexspace(ddata, 2) - temp = edata.split("FF 52") - for i in temp: - minutes = int(i.split()[5], 16) - seconds = int(i.split()[6], 16) - if seconds < 60: - timer_str = f"{minutes}:{seconds:02d}" - else: - timer_str = f"{minutes}.{seconds-128}" - seconds_24 = int(i.split()[7], 16) + 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 = "" - timer_pic = "D:\\YandexDisk\\Графика\\БАСКЕТБОЛ\\ЕДИНАЯ ЛИГА ВТБ 2022-2023\\Scorebug Indicators\\24Sec_Orange.png" - if seconds_24 == 255: - 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: - if seconds_24 > 127: - seconds_24 = seconds_24 - 128 - timer_attack = f"{seconds_24//10}.{seconds_24%10}" - timer_pic = "D:\\YandexDisk\\Графика\\БАСКЕТБОЛ\\ЕДИНАЯ ЛИГА ВТБ 2022-2023\\Scorebug Indicators\\24Sec_Red.png" - else: - timer_attack = seconds_24 + timer_attack = "" # > 24 c — не отображать - quarter = int(i.split()[14][1], 16) - timeout1 = int(i.split()[10], 16) - timeout2 = int(i.split()[11], 16) - score1 = int(i.split()[8], 16) - score2 = int(i.split()[9], 16) - foul1 = int(i.split()[12], 16) - foul2 = int(i.split()[13], 16) - data = { - "TIMER.Text": timer_str, - "24sec.Text": timer_attack, - "SCORE1.Text": score1, - "SCORE2.Text": score2, - } - send_data("TIMER.Text", data["TIMER.Text"]) - send_data("24sec.Text", data["24sec.Text"]) - send_data("SCORE1.Text", data["SCORE1.Text"]) - send_data("SCORE2.Text", data["SCORE2.Text"]) - # send_data("fouls1.Source", data["fouls1.Source"]) - # send_data("fouls2.Source", data["fouls2.Source"]) - # send_data("24SECBACKGROUND.Source", data["24SECBACKGROUND.Source"]) - x = [int(x, 16) for x in i.split()] - print(f"{i}, timer: {timer_str}, attack: {timer_attack}, Q: {quarter}, {x}") + 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_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: @@ -96,7 +231,74 @@ def read_logs(): timestamp = parts[0] data = eval(parts[1]) parse_new(data) - time.sleep(0.1) + 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(): @@ -112,24 +314,8 @@ def main(): ) try: - try: - tcp_socket = socket(AF_INET, SOCK_STREAM) - tcp_socket.connect((HOST, PORT)) - data = str.encode("hello") - tcp_socket.send(data) - data = bytes.decode(data) - while True: - data = tcp_socket.recv(1024) - print(data) - logging.debug(data) - try: - parse_new(data) - except Exception as ex: - print(f"\n{ex}\n") - except Exception as ex: - pass + run_client() except KeyboardInterrupt: - tcp_socket.close() sys.exit(1)