поправил таймер для Саратова:
1. если идет таймут, то на 24 секундах не отображается больше 24 секунд 2. когда заканчивается четверть, то в титр не приходят 2:00 или 15:00 (время перерыва) какое то время (хватит чтобы снять спокойно верхний счет) 3. таймер работает даже если vMix отключен (в случае перезагрузки vMix не нужно ребутать таймер) 4. в случае если подвиснет MOXA в получении данных, то соединение то должно переподключиться (нужно проверить) 5. убрал в парсинге сплит по FF 52, и добавил сплит, если пакет больше 18 байт, то сплит по 18 байту и дальше парсинг (проверил на старых и новых логах, все ок)
This commit is contained in:
334
timer_saratov.py
334
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user