451 lines
16 KiB
Python
451 lines
16 KiB
Python
import sys
|
||
import json
|
||
from socket import *
|
||
from datetime import datetime
|
||
import logging
|
||
import os
|
||
import time
|
||
import binascii
|
||
import requests
|
||
import ast
|
||
from requests.adapters import HTTPAdapter
|
||
from urllib3.util.retry import Retry
|
||
|
||
HOST = "192.168.127.254"
|
||
PORT = 1993
|
||
# --- vMix ---
|
||
_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
|
||
_vmix_state = { # сюда складываем последнее известное состояние
|
||
"TIMER.Text": None,
|
||
"24sec.Text": None,
|
||
"SCORE1.Text": None,
|
||
"SCORE2.Text": None,
|
||
# при желании добавь картинки и др. поля:
|
||
# "24SECBACKGROUND.Source": None,
|
||
}
|
||
|
||
# --- MOXA reconnect/watchdog ---
|
||
_SOCK_RECV_TIMEOUT = 2.0 # socket recv() timeout
|
||
_IDLE_RECONNECT = 5.0 # если нет данных столько сек — реконнект
|
||
_RETRY_BACKOFF = 1.0
|
||
_RETRY_BACKOFF_MAX = 10.0
|
||
_last_rx_mono = 0.0
|
||
_attack_last = None # последнее принятое значение в секундах (float)
|
||
_attack_last_t = 0.0 # когда его приняли (monotonic)
|
||
_attack_off_t = -1.0 # когда видели OFF (monotonic)
|
||
_ATTACK_OFF_GRACE = 0.8 # окно (сек) после OFF: первое новое значение принимаем всегда
|
||
_ATTACK_UP_GLITCH = 0.3 # если выросло больше чем на 0.3 c без OFF — игнорируем
|
||
# --- анти-дребезг основного таймера ---
|
||
_hold_zero_active = False # сейчас удерживаем "0.0"
|
||
_hold_zero_t0 = 0.0 # момент начала удержания
|
||
_HOLD_ZERO_SEC = 1.0 # сколько секунд держать "0.0" (можно 0.8–1.5)
|
||
_last_timer_text = None # последнее отправленное значение
|
||
|
||
|
||
|
||
session = requests.Session()
|
||
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"})
|
||
|
||
|
||
def hexspace(string, length):
|
||
return " ".join(string[i : i + length] for i in range(0, len(string), length))
|
||
|
||
|
||
def _vmix_resend_full_state():
|
||
"""Переотправляет ВСЕ последние значения, когда vMix вернулся онлайн."""
|
||
for name, val in _vmix_state.items():
|
||
if val is None:
|
||
continue
|
||
_send_to_vmix(name, val)
|
||
|
||
|
||
def _send_to_vmix(name: str, value):
|
||
"""Низкоуровневый вызов vMix API (без защиты)."""
|
||
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,
|
||
}
|
||
# короткий таймаут, чтобы не блокировать
|
||
session.get(_VMIX_URL, params=params, timeout=0.25)
|
||
|
||
|
||
def send_data(name: str, value):
|
||
"""
|
||
«Мягкая» отправка в vMix: не роняем процесс при оффлайне,
|
||
вводим cooldown, и при появлении онлайна переотправляем весь стейт.
|
||
"""
|
||
global _vmix_suppress_until, _vmix_online
|
||
|
||
# запоминаем последнее значение
|
||
_vmix_state[name] = value
|
||
|
||
now = time.monotonic()
|
||
if now < _vmix_suppress_until:
|
||
return False
|
||
|
||
try:
|
||
_send_to_vmix(name, value)
|
||
# если раньше были оффлайн — объявим «online» и добьём состояние
|
||
if _vmix_online is not True:
|
||
print("[vMix] online")
|
||
_vmix_online = True
|
||
_vmix_resend_full_state()
|
||
return True
|
||
except requests.exceptions.RequestException as e:
|
||
# оффлайн
|
||
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):
|
||
global _attack_last, _attack_last_t, _attack_off_t
|
||
global _hold_zero_active, _hold_zero_t0, _last_timer_text
|
||
|
||
# Преобразуем входящие байты в читаемый HEX
|
||
if line == b"\xff":
|
||
return
|
||
|
||
try:
|
||
cdata = binascii.hexlify(line)
|
||
ddata = cdata.decode("utf-8").upper()
|
||
edata = hexspace(ddata, 2)
|
||
except Exception:
|
||
return
|
||
|
||
# Разделяем поток на пакеты по сигнатуре начала
|
||
parts = [seg.strip() for seg in edata.split("FF FF 51 7E") if seg.strip()]
|
||
if not parts:
|
||
return
|
||
|
||
for i in parts:
|
||
arr = i.split()
|
||
print(arr)
|
||
# ---------------------- 24 СЕКУНД – OFF / RESET ----------------------
|
||
if "89 4E C8 05" in i:
|
||
print(arr, "возможно гашение/включение")
|
||
send_data("24sec.Text", "")
|
||
_attack_last = None
|
||
_attack_off_t = time.monotonic()
|
||
continue
|
||
if "79 84 C0 08" in i:
|
||
print(arr, '2/4 секунды значение')
|
||
idx = None
|
||
for k in range(len(arr) - 3):
|
||
if arr[k:k+4] == ["79", "84", "C0", "08"]:
|
||
idx = k
|
||
break
|
||
if idx is None or len(arr) <= idx + 13:
|
||
continue
|
||
|
||
seconds_24 = int(arr[idx + 10])
|
||
tenths_24 = int(arr[idx + 11])
|
||
|
||
# Гашение при > 24 или 0.0
|
||
if seconds_24 > 24 or (seconds_24 == 0 and tenths_24 == 0):
|
||
send_data("24sec.Text", "")
|
||
_attack_last = None
|
||
continue
|
||
|
||
# Формирование отображаемого значения
|
||
shown = f"{seconds_24}.{tenths_24}" if seconds_24 <= 4 else str(seconds_24)
|
||
if shown in ("0", "0.0"):
|
||
shown = ""
|
||
|
||
# Анти-рывок: не принимаем «рост» без OFF
|
||
now = time.monotonic()
|
||
new_val = seconds_24 + (tenths_24 / 10.0 if seconds_24 <= 5 else 0.0)
|
||
accept = False
|
||
|
||
if _attack_last is None:
|
||
accept = True
|
||
elif (now - _attack_off_t) <= _ATTACK_OFF_GRACE:
|
||
accept = True
|
||
else:
|
||
accept = new_val <= _attack_last + _ATTACK_UP_GLITCH
|
||
|
||
if accept:
|
||
send_data("24sec.Text", shown)
|
||
_attack_last = new_val
|
||
_attack_last_t = now
|
||
continue
|
||
if "79 84 C8 06" in i:
|
||
idx = None
|
||
for k in range(len(arr) - 3):
|
||
if arr[k:k+4] == ["79", "84", "C8", "06"]:
|
||
idx = k
|
||
break
|
||
if idx is None or len(arr) <= idx + 13:
|
||
continue
|
||
|
||
seconds_24 = int(arr[idx + 10])
|
||
tenths_24 = int(arr[idx + 11])
|
||
|
||
# Гашение при > 24 или 0.0
|
||
if seconds_24 > 24 or (seconds_24 == 0 and tenths_24 == 0):
|
||
send_data("24sec.Text", "")
|
||
_attack_last = None
|
||
continue
|
||
|
||
# Формирование отображаемого значения
|
||
shown = f"{seconds_24}.{tenths_24}" if seconds_24 <= 4 else str(seconds_24)
|
||
if shown in ("0", "0.0"):
|
||
shown = ""
|
||
|
||
# Анти-рывок: не принимаем «рост» без OFF
|
||
now = time.monotonic()
|
||
new_val = seconds_24 + (tenths_24 / 10.0 if seconds_24 <= 5 else 0.0)
|
||
accept = False
|
||
|
||
if _attack_last is None:
|
||
accept = True
|
||
elif (now - _attack_off_t) <= _ATTACK_OFF_GRACE:
|
||
accept = True
|
||
else:
|
||
accept = new_val <= _attack_last + _ATTACK_UP_GLITCH
|
||
|
||
if accept:
|
||
send_data("24sec.Text", shown)
|
||
_attack_last = new_val
|
||
_attack_last_t = now
|
||
continue
|
||
|
||
# ---------------------- 24 СЕКУНД – VALUE ----------------------
|
||
if "79 84 C0 0A" in i:
|
||
# print(arr, '24 секунды значение')
|
||
idx = None
|
||
for k in range(len(arr) - 3):
|
||
if arr[k:k+4] == ["79", "84", "C0", "0A"]:
|
||
idx = k
|
||
break
|
||
if idx is None or len(arr) <= idx + 13:
|
||
continue
|
||
|
||
seconds_24 = int(arr[idx + 12])
|
||
tenths_24 = int(arr[idx + 13])
|
||
# Гашение при > 24 или 0.0
|
||
if seconds_24 > 24 or (seconds_24 == 0 and tenths_24 == 0):
|
||
send_data("24sec.Text", "")
|
||
_attack_last = None
|
||
continue
|
||
|
||
# Формирование отображаемого значения
|
||
shown = f"{seconds_24}.{tenths_24}" if seconds_24 <= 4 else str(seconds_24)
|
||
if shown in ("0", "0.0"):
|
||
shown = ""
|
||
|
||
# Анти-рывок: не принимаем «рост» без OFF
|
||
now = time.monotonic()
|
||
new_val = seconds_24 + (tenths_24 / 10.0 if seconds_24 <= 5 else 0.0)
|
||
accept = False
|
||
|
||
if _attack_last is None:
|
||
accept = True
|
||
elif (now - _attack_off_t) <= _ATTACK_OFF_GRACE:
|
||
accept = True
|
||
else:
|
||
accept = new_val <= _attack_last + _ATTACK_UP_GLITCH
|
||
|
||
if accept:
|
||
send_data("24sec.Text", shown)
|
||
_attack_last = new_val
|
||
_attack_last_t = now
|
||
continue
|
||
|
||
# ---------------------- ОСНОВНОЕ ВРЕМЯ ----------------------
|
||
if "7D 4A C0 0A" in i:
|
||
try:
|
||
idx = None
|
||
for k in range(len(arr) - 3):
|
||
if arr[k:k+4] == ["7D", "4A", "C0", "0A"]:
|
||
idx = k
|
||
break
|
||
if idx is None or len(arr) <= idx + 13:
|
||
continue
|
||
|
||
def bcd_to_dec(b):
|
||
return ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
||
|
||
# секунды и минуты
|
||
seconds_b = int(arr[idx + 12], 16)
|
||
minutes_b = int(arr[idx + 11], 16)
|
||
|
||
# отбросить флаговые старшие биты минут
|
||
minutes_b = minutes_b & 0x0F | ((minutes_b >> 4) & 0x07) << 4
|
||
|
||
minutes = bcd_to_dec(minutes_b)
|
||
seconds_raw = bcd_to_dec(seconds_b)
|
||
|
||
# нормальный таймер
|
||
if seconds_raw < 60:
|
||
timer_calc = f"{minutes}:{seconds_raw:02d}"
|
||
else:
|
||
timer_calc = f"{minutes}.{seconds_raw - 128}"
|
||
|
||
# анти-дребезг конца периода
|
||
now = time.monotonic()
|
||
is_zero = (minutes == 0 and seconds_raw == 0)
|
||
is_two0 = (minutes == 2 and seconds_raw == 0)
|
||
|
||
if is_zero:
|
||
_hold_zero_active = True
|
||
_hold_zero_t0 = now
|
||
timer_str = "0.0"
|
||
elif _hold_zero_active and is_two0:
|
||
timer_str = "0.0"
|
||
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_calc
|
||
|
||
send_data("TIMER.Text", timer_str)
|
||
_last_timer_text = timer_str
|
||
|
||
except Exception:
|
||
continue
|
||
|
||
|
||
# ---------------------- СЧЁТ ----------------------
|
||
if "7D 4A C0 07" in i:
|
||
try:
|
||
idx = None
|
||
for k in range(len(arr) - 3):
|
||
if arr[k:k+4] == ["7D", "4A", "C0", "07"]:
|
||
idx = k
|
||
break
|
||
if idx is None or len(arr) <= idx + 7:
|
||
continue
|
||
|
||
score1 = int(arr[idx + 6], 16)
|
||
score2 = int(arr[idx + 7], 16)
|
||
|
||
send_data("SCORE1.Text", score1)
|
||
send_data("SCORE2.Text", score2)
|
||
except Exception:
|
||
continue
|
||
|
||
|
||
def _connect_socket():
|
||
s = socket(AF_INET, SOCK_STREAM)
|
||
s.settimeout(_SOCK_RECV_TIMEOUT)
|
||
try:
|
||
s.setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1)
|
||
except OSError:
|
||
pass
|
||
s.connect((HOST, PORT))
|
||
s.send(b"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()
|
||
|
||
# idle-watchdog: давно нет данных — форсим реконнект
|
||
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:
|
||
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 read_logs():
|
||
path = r"C:\Code\timer\LOGS\timer_Nata_Nport_2025-04-25_14-53-51.log"
|
||
# path = r"C:\Code\timer\LOGS\timer_Nata_Nport_2025-04-25_15-15-42.log"
|
||
with open(path, "r", encoding="utf-8", errors="ignore") as file:
|
||
for line in file:
|
||
parts = line.strip().split(" DEBUG ", 1)
|
||
if len(parts) != 2:
|
||
continue
|
||
|
||
payload = parts[1].strip()
|
||
|
||
# читаем только байтовые строки
|
||
if payload.startswith("b'") or payload.startswith('b"'):
|
||
try:
|
||
data = ast.literal_eval(payload)
|
||
if isinstance(data, (bytes, bytearray)):
|
||
parse_new(bytes(data))
|
||
except Exception:
|
||
pass
|
||
|
||
time.sleep(0.005)
|
||
|
||
|
||
def main():
|
||
if not os.path.isdir("LOGS"):
|
||
os.mkdir("LOGS")
|
||
|
||
LogFileName = "LOGS/timer_SARATOV.log"
|
||
logging.basicConfig(
|
||
level=logging.DEBUG,
|
||
format="%(asctime)s %(levelname)s %(message)s",
|
||
filename=LogFileName,
|
||
filemode="w",
|
||
)
|
||
|
||
try:
|
||
run_client() # ← онлайн-режим с авто-реконнектом
|
||
# read_logs() # ← оставь для оффлайн-прогона логов (ручной переключатель)
|
||
except KeyboardInterrupt:
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|