first commit

This commit is contained in:
2025-11-05 18:29:49 +03:00
commit a80c03a27d
41 changed files with 7713 additions and 0 deletions

345
timer COM.py Normal file
View File

@@ -0,0 +1,345 @@
# serial_to_vmix.py
# pip install pyserial requests
import time
import argparse
import socket
import urllib.parse
import requests
import serial
from serial.serialutil import SerialException
# ---------------------------
# 1) ВАША функция отправки в vMix
# Замените содержимое по своему желанию.
# ---------------------------
# --- ВСТАВЬТЕ ВАШ serial_to_vmix.py вместо заглушки parse() ---
import re
from dataclasses import dataclass, asdict
session = requests.Session()
session.headers.update({"Connection": "keep-alive"})
VMIX_HOST = "http://127.0.0.1:8088" # адрес vMix Web Controller
# куда писать поля в vMix — укажите свои Inputs/поля титров
# 1) В блоке VMIX_TARGETS добавьте цель для секундника:
VMIX_TARGETS = {
"main_time": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": "TIMER.Text", "SelectedIndex": None},
"shot_clock": {"Function": "SetText", "Input": "SCOREBUG","SelectedName": "24sec.Text", "SelectedIndex": None}, # ⬅️ ДОБАВЛЕНО
"quarter": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": None, "SelectedIndex": None},
"score_1": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": "SCORE1.Text", "SelectedIndex": None},
"score_2": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": "SCORE2.Text", "SelectedIndex": None},
"fouls_1": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": None, "SelectedIndex": None},
"fouls_2": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": None, "SelectedIndex": None},
}
def vmix_call(function: str, input_id: str, value: str, selected_name=None, selected_index=None, timeout=1.5):
params = {"Function": function, "Input": input_id, "Value": value}
if selected_name:
params["SelectedName"] = selected_name
if selected_index:
params["SelectedIndex"] = selected_index
url = f"{VMIX_HOST}/api?{urllib.parse.urlencode(params, doseq=True, safe='')}"
try:
r = session.get(url, timeout=timeout)
r.raise_for_status()
except requests.RequestException as e:
print(f"[vMix] HTTP ошибка: {e} -> {url}")
# 2) В State замените shot_clock (int) на текстовый shot_clock (str), чтобы иногда писать десятые:
@dataclass
class State:
main_time: str | None = None # "M:SS"
shot_clock: str | None = None # ⬅️ теперь строка: "21" или "4.9"
quarter: int | None = None
score_1: int | None = None
score_2: int | None = None
fouls_1: int | None = None
fouls_2: int | None = None
timeouts_1: int | None = None
timeouts_2: int | None = None
class SmartBasketParser:
# строка «наша» если начинается с мусора и цифры 3, и содержит блок 0000
full_line_guard = re.compile(r'^\D*3\b')
re_numbers = re.compile(r"\d+")
def __init__(self):
self.state = State()
@staticmethod
def _mmss_to_str(n: int) -> str:
"""
Универсальный форматтер:
- < 100 -> 'ss:0'
- 3 цифры, s<=59 -> 'M:SS' (MMSS)
- 3 цифры, s>59 -> 'ss:ms' (SSd, где ms = десятые)
- 4 цифры -> 'M:SS' (MMSS)
"""
if n < 0:
return "0:00"
# только секунды
if n < 100:
ss = max(0, min(n, 59))
return f"{ss}:0"
# 3 цифры: либо M:SS, либо SS.d
if 100 <= n <= 999:
m = n // 100
s = n % 100
if s <= 59:
return f"{m}:{s:02d}" # 102 -> 1:02
else:
ss = n // 10 # 199 -> 19
d = n % 10 # 199 -> 9
return f"{ss}.{d}" # 199 -> 19:9
# 4 цифры: MMSS
if 1000 <= n <= 5959:
m, s = divmod(n, 100)
if s >= 60:
m += s // 60
s = s % 60
return f"{m}:{s:02d}"
# всё прочее не считаем временем
return "0:00"
def _pick_main_time(self, nums: list[int]) -> str | None:
"""
В 'полной' строке основное время идёт сразу после ведущей '3'.
Если следом '0' — берём через один.
Примеры:
[3, 0, 58, 35, 35, 2200, 1, ...] -> 58:0
[3, 199, 2, 13200, 1, ...] -> 19:9
[3, 102, 35, 35, 0000, 1, ...] -> 1:02
"""
if not nums:
return None
# nums[0] — это 3
idx = 1
if len(nums) > 1 and nums[1] == 0:
idx = 2
if idx < len(nums):
t = nums[idx]
# считаем временем только разумные токены: секунды/3-цифр. спец./MMSS
if (0 <= t <= 59) or (100 <= t <= 999) or (1000 <= t <= 5959):
return self._mmss_to_str(t)
# fallback (на всякий): ищем первый разумный токен до первого 4-значного блока
idx_flags = next((i for i, n in enumerate(nums) if len(str(n)) == 4), None)
search_upto = idx_flags if idx_flags is not None else len(nums)
for n in nums[:search_upto]:
if (0 <= n <= 59) or (100 <= n <= 999) or (1000 <= n <= 5959):
return self._mmss_to_str(n)
return None
@staticmethod
def _format_main_time_compact(n: int) -> str:
"""
Компактный формат 'MMSSd' (например, 13200 -> 13:20.0).
m = n // 1000
s = (n // 10) % 100
d = n % 10 (десятые)
Вывод: если m>0 -> 'M:SS', если m==0 -> 'SS:d' (как вы просили '< 1 мин — ss:ms').
"""
if n < 0:
return "0:00"
m = n // 1000
s = (n // 10) % 100
d = n % 10
if s >= 60: # подстраховка
m += s // 60
s = s % 60
if m > 0:
return f"{m}:{s:02d}"
else:
return f"{s}:{d}"
def _format_shot_clock_from_tail_number(self, n: int) -> str | None:
"""
n — последнее число в строке (например, 21031).
Правило валидности: если последние две цифры == 31 — обновляем таймер,
если == 30 (или не 31) — не обновляем (возвращаем None).
Расчёт: отбрасываем две последние цифры -> n // 100 (секунды * 10).
>= 50 -> целые секунды ('21', '20', ...), иначе -> десятые ('4.9', ...).
"""
if n < 0:
return None
tail = n % 100
if tail != 31:
# 30 — специально игнорируем; всё, что не 31 — тоже игнорим
return None
sec_tenths = n // 100 # «секунды × 10»
if sec_tenths >= 50:
return str(sec_tenths // 10) # «21», «20», ...
else:
return f"{sec_tenths / 10:.1f}" # «4.9», «4.8», ..., «0.0»
def _pick_scores_flags_and_quarter(self, nums: list[int]):
"""
Ищем последовательность: ... s1 s2 F1F2T1T2 Q ...
СЧЁТ ОБНОВЛЯЕМ ТОЛЬКО если нашли 4-значный блок флагов (1000..9999).
Если флагов нет — возвращаем None для счёта/флагов/четверти,
чтобы не перезатирать корректные прошлые значения.
"""
score1 = score2 = fouls1 = fouls2 = to1 = to2 = quarter = None
# найдём первый 4-значный блок (флаги)
idx_flags = next((i for i, n in enumerate(nums) if 1000 <= n <= 9999), None)
if idx_flags is None:
# нет флагов -> ничего не обновляем
return score1, score2, fouls1, fouls2, to1, to2, quarter
# перед флагами должны стоять два счёта
if idx_flags >= 2:
s1, s2 = nums[idx_flags - 2], nums[idx_flags - 1]
if 0 <= s1 <= 300 and 0 <= s2 <= 300:
score1, score2 = s1, s2
# разбираем флаги F1F2T1T2
f = f"{nums[idx_flags]:04d}"
fouls1, fouls2, to1, to2 = map(int, f)
# четверть — число сразу после флагов (1..10)
if idx_flags + 1 < len(nums):
q = nums[idx_flags + 1]
if 1 <= q <= 10:
quarter = q
return score1, score2, fouls1, fouls2, to1, to2, quarter
# 4) В parse_line() после nums = [...] добавьте вычисление секундника:
def parse_line(self, line: str) -> State | None:
if not (self.full_line_guard.search(line) and "0000" in line):
return None
parts = self.re_numbers.findall(line)
if not parts:
return None
nums = [int(p) for p in parts]
main_time_str = self._pick_main_time(nums)
s1, s2, f1, f2, to1, to2, q = self._pick_scores_flags_and_quarter(nums)
# ⬇️ новый блок: секундник из последнего числа
# shot_clock_text = ""
# ⬇️ новый безопасный блок секундника
shot_clock_text = self.state.shot_clock # по умолчанию — предыдущее значение
if nums: # nums уже есть выше: nums = [int(p) for p in parts]
sc_new = self._format_shot_clock_from_tail_number(nums[-1])
if sc_new is not None:
shot_clock_text = sc_new
out = State(
main_time=main_time_str or self.state.main_time,
shot_clock=shot_clock_text,
score_1 = s1 if s1 is not None else self.state.score_1,
score_2 = s2 if s2 is not None else self.state.score_2,
fouls_1 = f1 if f1 is not None else self.state.fouls_1,
fouls_2 = f2 if f2 is not None else self.state.fouls_2,
timeouts_1 = to1 if to1 is not None else self.state.timeouts_1,
timeouts_2 = to2 if to2 is not None else self.state.timeouts_2,
quarter = q if q is not None else self.state.quarter,
)
return out
def update_and_push(self, new_state: State):
old = self.state
for k, v in asdict(new_state).items():
if getattr(old, k) != v and v is not None:
self._push_to_vmix(k, v)
self.state = new_state
# 5) В _push_to_vmix оставьте как есть — он уже приводит value к строке.
# Если хотите реально отправлять в vMix — раскомментируйте vmix_call:
def _push_to_vmix(self, key: str, value):
t = VMIX_TARGETS.get(key)
if not t:
return
# 👉 РАСКОММЕНТИРУЙТЕ для отправки в vMix
vmix_call(
function=t["Function"],
input_id=t["Input"],
value=str(value),
selected_name=t.get("SelectedName"),
selected_index=t.get("SelectedIndex"),
)
# print(f"[push] {key} = {value}")
_parser = SmartBasketParser()
def parse(data: str):
print(data)
st = _parser.parse_line(data)
if st:
_parser.update_and_push(st)
else:
# полезно видеть, что игнорируем — закомментируйте, если мешает
print("[parse] пропуск строки (не формат <20><><EFBFBD><EFBFBD><EFBFBD>3 + 0000)")
# ---------------------------
# 2) Чтение из COM-порта и вызов parse
# ---------------------------
def read_loop(port: str, baud: int, newline: str, strip_whitespace: bool):
# мапинг для окончания строк в pyserial
eol = {"CRLF": b"\r\n", "LF": b"\n", "CR": b"\r"}.get(newline.upper(), b"\n")
while True:
try:
with serial.Serial(port, baudrate=baud) as ser:
print(f"[serial] открыт {ser.port} @ {baud} бод. Ожидаю данные...")
buffer = bytearray()
while True:
chunk = ser.read(1024)
if not chunk:
continue
buffer.extend(chunk)
# разбираем по разделителю строк
while True:
idx = buffer.find(eol)
if idx == -1:
break
line = buffer[:idx]
del buffer[:idx + len(eol)]
try:
text = line.decode("utf-8", errors="replace")
except Exception:
text = line.decode("latin-1", errors="replace")
if strip_whitespace:
text = text.strip()
if text != "":
parse(text)
except SerialException as e:
print(f"[serial] не удалось открыть/читать {port}: {e}. Повтор через 3 сек...")
time.sleep(3)
except KeyboardInterrupt:
print("\n[serial] остановлено пользователем.")
break
except Exception as e:
print(f"[serial] непредвиденная ошибка: {e}. Повтор через 3 сек...")
time.sleep(3)
def main():
ap = argparse.ArgumentParser(description="Чтение COM и отправка в vMix через parse()")
ap.add_argument("--port", required=True, help="COM-порт, напр. COM3 (Windows) или /dev/ttyUSB0 (Linux)")
ap.add_argument("--baud", type=int, default=19200, help="Скорость, по умолчанию 19200")
ap.add_argument("--newline", choices=["LF", "CRLF", "CR"], default="CR",
help="Разделитель строк, по умолчанию LF")
ap.add_argument("--no-strip", action="store_true",
help="Не обрезать пробелы по краям (по умолчанию обрезаем)")
args = ap.parse_args()
read_loop(args.port, args.baud, args.newline, strip_whitespace=not args.no_strip)
if __name__ == "__main__":
main()