first commit
This commit is contained in:
486
timer COM3.py
Normal file
486
timer COM3.py
Normal file
@@ -0,0 +1,486 @@
|
||||
# vmix_serial_bridge_optimized.py
|
||||
# pip install pyserial requests
|
||||
|
||||
"""
|
||||
Оптимизированная версия COM -> vMix моста для максимально быстрой доставки
|
||||
данных в vMix. Ключевые изменения:
|
||||
- Немедленная обработка входящих байт: неблокирующее чтение с малой задержкой
|
||||
(timeout) и мгновенная отправка строк при появлении EOL или коротком «простоя».
|
||||
- Параметризуемые интервалы: --read-timeout, --idle-flush для тонкой настройки
|
||||
задержки между получением и отправкой.
|
||||
- Убраны лишние print'ы, добавлен флаг --verbose для отладки без штрафа к скорости.
|
||||
- Дедупликация значений к vMix включаемая флагом (по умолчанию включена).
|
||||
- Предкомпиляция регулярных выражений и мелкие микроптимизации парсера.
|
||||
- Очередь отправки без блокировок и аккуратное «сбросить-старое-сообщение» поведение
|
||||
на переполнении.
|
||||
|
||||
Совместима с прежней CLI, но добавлены новые опции:
|
||||
--read-timeout (сек, по умолчанию 0.03)
|
||||
--idle-flush (сек, по умолчанию 0.06)
|
||||
--max-chunk (байт, по умолчанию 512)
|
||||
--verbose (подробные логи)
|
||||
|
||||
Пример запуска:
|
||||
python vmix_serial_bridge_optimized.py --port COM2 --baud 19200 --autoeol \
|
||||
--send-quarter --dedupe --read-timeout 0.02 --idle-flush 0.05
|
||||
"""
|
||||
|
||||
import time
|
||||
import argparse
|
||||
import urllib.parse
|
||||
import requests
|
||||
import serial
|
||||
from serial.serialutil import SerialException
|
||||
from serial.tools import list_ports
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from threading import Thread, Event
|
||||
from queue import Queue, Empty
|
||||
from typing import Optional, Dict
|
||||
|
||||
VMIX_HOST = "http://127.0.0.1:8088"
|
||||
|
||||
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": "QUARTER.Text", "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": "FOULS1.Text", "SelectedIndex": None},
|
||||
"fouls_2": {"Function": "SetText", "Input": "SCOREBUG", "SelectedName": "FOULS2.Text", "SelectedIndex": None},
|
||||
}
|
||||
|
||||
# ---------------- HTTP client ----------------
|
||||
class VmixClient:
|
||||
def __init__(self, host: str, timeout: float = 0.01, retries: int = 2, backoff: float = 0.2):
|
||||
self.host = host.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.backoff = backoff
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"Connection": "keep-alive"})
|
||||
|
||||
def call(self, function: str, input_id: str, value: str, selected_name=None, selected_index=None) -> bool:
|
||||
params = {"Function": function, "Input": input_id, "Value": value}
|
||||
if selected_name:
|
||||
params["SelectedName"] = selected_name
|
||||
if selected_index is not None:
|
||||
params["SelectedIndex"] = selected_index
|
||||
url = f"{self.host}/api?{urllib.parse.urlencode(params, doseq=True, safe='')}"
|
||||
for attempt in range(1, self.retries + 2):
|
||||
try:
|
||||
r = self.session.get(url, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
if attempt > self.retries:
|
||||
return False
|
||||
time.sleep(self.backoff * attempt)
|
||||
return False
|
||||
|
||||
@dataclass
|
||||
class VmixMessage:
|
||||
field: str
|
||||
value: str
|
||||
|
||||
class VmixSender:
|
||||
def __init__(self, client: VmixClient, dedupe: bool = True, max_queue: int = 4096, verbose: bool = False):
|
||||
self.client = client
|
||||
self.queue: Queue[VmixMessage] = Queue(maxsize=max_queue)
|
||||
self.stop_event = Event()
|
||||
self.thread = Thread(target=self._run, name="vmix-sender", daemon=True)
|
||||
self.dedupe = dedupe
|
||||
self._last_sent: Dict[str, Optional[str]] = {}
|
||||
self.verbose = verbose
|
||||
|
||||
def start(self):
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
def send(self, field: str, value: Optional[str]):
|
||||
if value is None:
|
||||
return
|
||||
if field not in VMIX_TARGETS:
|
||||
return
|
||||
if self.dedupe and self._last_sent.get(field) == value:
|
||||
return
|
||||
if self.dedupe:
|
||||
self._last_sent[field] = value
|
||||
msg = VmixMessage(field, str(value))
|
||||
try:
|
||||
self.queue.put_nowait(msg)
|
||||
except Exception:
|
||||
# Сбрасываем самое старое сообщение, чтобы не накапливать задержку
|
||||
try:
|
||||
_ = self.queue.get_nowait()
|
||||
self.queue.task_done()
|
||||
except Empty:
|
||||
pass
|
||||
self.queue.put_nowait(msg)
|
||||
|
||||
def _run(self):
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
msg = self.queue.get(timeout=0.01)
|
||||
except Empty:
|
||||
continue
|
||||
t = VMIX_TARGETS[msg.field]
|
||||
ok = self.client.call(t["Function"], t["Input"], msg.value, t.get("SelectedName"), t.get("SelectedIndex"))
|
||||
if self.verbose:
|
||||
if ok:
|
||||
print(f"[vMix] {msg.field} <- {msg.value}")
|
||||
else:
|
||||
print(f"[vMix] ERROR send {msg.field} <- {msg.value}")
|
||||
self.queue.task_done()
|
||||
|
||||
# ---------------- Parser ----------------
|
||||
_QUARTER_RE = re.compile(r"\s([1-5])\s\s\d{6}\s")
|
||||
|
||||
def parse_time_5ch(chunk: str) -> Optional[str]:
|
||||
if len(chunk) != 5:
|
||||
return None
|
||||
try:
|
||||
if chunk[0:2] == "0 " or chunk[0:2] == " ":
|
||||
print(f"{int(chunk[1:3])}:{int(chunk[3:5]):02d}")
|
||||
return f"{int(chunk[1:3])}:{int(chunk[3:5]):02d}"
|
||||
else:
|
||||
return f"{int(chunk[1:3])}.{chunk[3]}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def format_shot_clock_from_tail_number(n: str) -> str:
|
||||
tail = n[-2:]
|
||||
if tail == "30":
|
||||
return "" # не обновлять
|
||||
num = n[:2]
|
||||
word_to_int = "ABCDEFGHI@"
|
||||
if n[1] in word_to_int:
|
||||
return f"{num[0]}.{word_to_int.index(num[1])+1 if num[1] != '@' else '0'}"
|
||||
else:
|
||||
return num
|
||||
|
||||
@dataclass
|
||||
class ParsedPacket:
|
||||
main_time: Optional[str] = None
|
||||
score1: Optional[str] = None
|
||||
score2: Optional[str] = None
|
||||
quarter: Optional[str] = None
|
||||
shot_clock: Optional[str] = None
|
||||
|
||||
def parse_line(line: str, verbose: bool = False) -> Optional[ParsedPacket]:
|
||||
clean = line.strip().replace("<EFBFBD>", "")
|
||||
if len(clean) != 52:
|
||||
clean = clean[-52:]
|
||||
print(clean)
|
||||
if not clean or clean[0] not in ["3", "7", "8"]:
|
||||
return None
|
||||
|
||||
# фиксированные позиции — всегда
|
||||
main_time_raw = clean[2:7]
|
||||
main_time = parse_time_5ch(main_time_raw)
|
||||
|
||||
# score1/score2 есть только если строка начинается с "3"
|
||||
score1 = None
|
||||
score2 = None
|
||||
if clean and clean[0] == "3":
|
||||
s1 = clean[7:10].strip()
|
||||
s2 = clean[10:13].strip()
|
||||
if s1:
|
||||
score1 = s1
|
||||
if s2:
|
||||
score2 = s2
|
||||
|
||||
# четверть — всегда, если найдётся
|
||||
quarter = None
|
||||
m = _QUARTER_RE.search(clean)
|
||||
if m:
|
||||
quarter = m.group(1)
|
||||
|
||||
# шот-клок — всегда, если хвост — число
|
||||
shot_clock = None
|
||||
tail = clean[-5:]
|
||||
shot_clock = format_shot_clock_from_tail_number(tail)
|
||||
|
||||
return ParsedPacket(
|
||||
main_time=main_time,
|
||||
score1=score1,
|
||||
score2=score2,
|
||||
quarter=quarter,
|
||||
shot_clock=shot_clock,
|
||||
)# ---------------- Serial reader ----------------
|
||||
EOLS = {"CR": b"\r", "LF": b"\n", "CRLF": b"\r\n"}
|
||||
|
||||
|
||||
def sniff_eol(sample: bytes) -> bytes:
|
||||
if b"\r\n" in sample:
|
||||
return EOLS["CRLF"]
|
||||
if b"\r" in sample and b"\n" not in sample:
|
||||
return EOLS["CR"]
|
||||
if b"\n" in sample:
|
||||
return EOLS["LF"]
|
||||
return EOLS["CR"]
|
||||
|
||||
class SerialReader:
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
baud: int,
|
||||
newline: str,
|
||||
strip_ws: bool,
|
||||
sender: VmixSender,
|
||||
send_quarter: bool,
|
||||
autoeol: bool,
|
||||
bytesize: int,
|
||||
parity: str,
|
||||
stopbits: float,
|
||||
rtscts: bool,
|
||||
xonxoff: bool,
|
||||
dsrdtr: bool,
|
||||
read_timeout: float = 0.03,
|
||||
idle_flush: float = 0.06,
|
||||
max_chunk: int = 512,
|
||||
verbose: bool = False,
|
||||
):
|
||||
self.port = port
|
||||
self.baud = baud
|
||||
self.eol = EOLS.get(newline.upper(), b"\r")
|
||||
self.strip_ws = strip_ws
|
||||
self.sender = sender
|
||||
self.send_quarter = send_quarter
|
||||
self.autoeol = autoeol
|
||||
self.bytesize = bytesize
|
||||
self.parity = getattr(serial, f"PARITY_{parity.upper()}", serial.PARITY_NONE)
|
||||
self.stopbits = {1: serial.STOPBITS_ONE, 1.5: serial.STOPBITS_ONE_POINT_FIVE, 2: serial.STOPBITS_TWO}[stopbits]
|
||||
self.rtscts = rtscts
|
||||
self.xonxoff = xonxoff
|
||||
self.dsrdtr = dsrdtr
|
||||
self.read_timeout = max(0.0, float(read_timeout))
|
||||
self.idle_flush = max(0.0, float(idle_flush))
|
||||
self.max_chunk = max(1, int(max_chunk))
|
||||
self.verbose = verbose
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
try:
|
||||
with serial.Serial(
|
||||
self.port,
|
||||
baudrate=self.baud,
|
||||
timeout=self.read_timeout, # КЛЮЧЕВОЕ: неблокирующее чтение
|
||||
write_timeout=0.01,
|
||||
bytesize=self.bytesize,
|
||||
parity=self.parity,
|
||||
stopbits=self.stopbits,
|
||||
rtscts=self.rtscts,
|
||||
xonxoff=self.xonxoff,
|
||||
dsrdtr=self.dsrdtr,
|
||||
) as ser:
|
||||
# if self.verbose:
|
||||
# print(
|
||||
# f"[serial] OPEN {ser.port} @ {self.baud} ({self.bytesize}{self._parity_name()}{self._stopbits_name()})"
|
||||
# )
|
||||
try:
|
||||
ser.setDTR(True)
|
||||
ser.setRTS(True)
|
||||
except Exception:
|
||||
pass
|
||||
ser.reset_input_buffer()
|
||||
ser.reset_output_buffer()
|
||||
|
||||
buffer = bytearray()
|
||||
last_feed = time.time()
|
||||
|
||||
# Авто-детект EOL: быстрый сэмпл
|
||||
if self.autoeol:
|
||||
sample_t0 = time.time()
|
||||
while time.time() - sample_t0 < 0.15: # ~150 мс
|
||||
n = ser.in_waiting
|
||||
if n:
|
||||
chunk = ser.read(min(n, self.max_chunk))
|
||||
if chunk:
|
||||
buffer.extend(chunk)
|
||||
if buffer:
|
||||
self.eol = sniff_eol(buffer)
|
||||
if self.verbose:
|
||||
print(f"[serial] auto EOL = {self._eol_name(self.eol)}")
|
||||
break
|
||||
|
||||
while True:
|
||||
n = ser.in_waiting
|
||||
if n:
|
||||
chunk = ser.read(min(n, self.max_chunk))
|
||||
else:
|
||||
# читаем «минимум 1 байт», чтобы не спиниться
|
||||
chunk = ser.read(1)
|
||||
|
||||
if chunk:
|
||||
buffer.extend(chunk)
|
||||
last_feed = time.time()
|
||||
|
||||
progressed = False
|
||||
while True:
|
||||
idx = buffer.find(self.eol)
|
||||
if idx == -1:
|
||||
break
|
||||
line = buffer[:idx]
|
||||
del buffer[: idx + len(self.eol)]
|
||||
text = self._decode(line, self.strip_ws)
|
||||
if text:
|
||||
self._process(text)
|
||||
progressed = True
|
||||
|
||||
# Если нет EOL и давно не приходили байты — отдаём накопленное
|
||||
if not progressed and buffer and (time.time() - last_feed) >= self.idle_flush:
|
||||
text = self._decode(buffer, self.strip_ws)
|
||||
buffer.clear()
|
||||
if text:
|
||||
self._process(text)
|
||||
|
||||
except SerialException as e:
|
||||
if self.verbose:
|
||||
print(f"[serial] ERROR open/read {self.port}: {e}. Retry in 0.5s...")
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
if self.verbose:
|
||||
print("\n[serial] Stopped by user.")
|
||||
return
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"[serial] UNEXPECTED: {e}. Retry in 0.5s...")
|
||||
time.sleep(0.5)
|
||||
|
||||
def _decode(self, raw: bytes, strip_ws: bool) -> Optional[str]:
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
text = raw.decode("latin-1", errors="replace")
|
||||
if strip_ws:
|
||||
text = text.strip()
|
||||
return text or None
|
||||
|
||||
def _process(self, text: str):
|
||||
pkt = parse_line(text, verbose=self.verbose)
|
||||
if not pkt:
|
||||
if self.verbose:
|
||||
print(f"[serial] RAW: {text!r}")
|
||||
return
|
||||
if pkt.main_time is not None:
|
||||
self.sender.send("main_time", pkt.main_time)
|
||||
if pkt.score1 is not None:
|
||||
self.sender.send("score_1", pkt.score1)
|
||||
if pkt.score2 is not None:
|
||||
self.sender.send("score_2", pkt.score2)
|
||||
if self.send_quarter and pkt.quarter is not None:
|
||||
self.sender.send("quarter", pkt.quarter)
|
||||
if pkt.shot_clock is not None:
|
||||
self.sender.send("shot_clock", pkt.shot_clock)
|
||||
|
||||
def _parity_name(self):
|
||||
return {"N": "N", "E": "E", "O": "O", "M": "M", "S": "S"}[self.parity]
|
||||
|
||||
def _stopbits_name(self):
|
||||
return {
|
||||
serial.STOPBITS_ONE: "1",
|
||||
serial.STOPBITS_ONE_POINT_FIVE: "1.5",
|
||||
serial.STOPBITS_TWO: "2",
|
||||
}[self.stopbits]
|
||||
|
||||
def _eol_name(self, e: bytes):
|
||||
return {b"\r": "CR", b"\n": "LF", b"\r\n": "CRLF"}.get(e, f"{e!r}")
|
||||
|
||||
|
||||
# ---------------- Probe (sniffer) ----------------
|
||||
|
||||
def probe_port(port: str, baud: int, seconds: int, **kwargs):
|
||||
print("[probe] Available ports:")
|
||||
for p in list_ports.comports():
|
||||
print(f" - {p.device}: {p.description}")
|
||||
try:
|
||||
with serial.Serial(port, baudrate=baud) as ser:
|
||||
print(f"[probe] OPEN {ser.port} @ {baud}. Sniffing {seconds}s...")
|
||||
ser.reset_input_buffer()
|
||||
t0 = time.time()
|
||||
total = 0
|
||||
while time.time() - t0 < seconds:
|
||||
b = ser.read(512)
|
||||
if not b:
|
||||
continue
|
||||
total += len(b)
|
||||
print(f"[{len(b):03d} bytes] HEX: {b.hex(' ')}")
|
||||
try:
|
||||
txt = b.decode("utf-8")
|
||||
except Exception:
|
||||
txt = b.decode("latin-1", errors="replace")
|
||||
print(f" TXT: {txt!r}")
|
||||
print(f"[probe] Done. Total bytes: {total}")
|
||||
except Exception as e:
|
||||
print(f"[probe] ERROR: {e}")
|
||||
|
||||
|
||||
# ---------------- CLI ----------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="COM -> vMix bridge (optimized)")
|
||||
ap.add_argument("--port", required=True, help="e.g. COM2 or /dev/ttyUSB0")
|
||||
ap.add_argument("--baud", type=int, default=19200)
|
||||
ap.add_argument("--newline", choices=["LF", "CRLF", "CR"], default="CR")
|
||||
ap.add_argument("--autoeol", action="store_true", help="Auto-detect EOL from stream")
|
||||
ap.add_argument("--no-strip", action="store_true")
|
||||
ap.add_argument("--vmix-host", default=VMIX_HOST)
|
||||
ap.add_argument("--timeout", type=float, default=0.01)
|
||||
ap.add_argument("--retries", type=int, default=2)
|
||||
ap.add_argument("--backoff", type=float, default=0.2)
|
||||
ap.add_argument("--dedupe", action="store_true", help="Deduplicate values before sending to vMix")
|
||||
ap.add_argument("--send-quarter", action="store_true")
|
||||
ap.add_argument("--read-timeout", type=float, default=0.03, help="Serial read timeout (s)")
|
||||
ap.add_argument("--idle-flush", type=float, default=0.06, help="Flush partial line after idle (s)")
|
||||
ap.add_argument("--max-chunk", type=int, default=512, help="Max bytes per read()")
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
# serial low-level
|
||||
ap.add_argument("--bytesize", type=int, choices=[5, 6, 7, 8], default=8)
|
||||
ap.add_argument("--parity", choices=["N", "E", "O", "M", "S"], default="N")
|
||||
ap.add_argument("--stopbits", type=float, choices=[1, 1.5, 2], default=1)
|
||||
ap.add_argument("--rtscts", action="store_true")
|
||||
ap.add_argument("--xonxoff", action="store_true")
|
||||
ap.add_argument("--dsrdtr", action="store_true")
|
||||
# tools
|
||||
ap.add_argument("--probe", type=int, metavar="SECONDS", help="Sniff raw bytes/text for N seconds and exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.probe:
|
||||
probe_port(args.port, args.baud, args.probe)
|
||||
return
|
||||
|
||||
client = VmixClient(host=args.vmix_host, timeout=args.timeout, retries=args.retries, backoff=args.backoff)
|
||||
sender = VmixSender(client=client, dedupe=args.dedupe or True, verbose=args.verbose)
|
||||
sender.start()
|
||||
|
||||
reader = SerialReader(
|
||||
port=args.port,
|
||||
baud=args.baud,
|
||||
newline=args.newline,
|
||||
strip_ws=not args.no_strip,
|
||||
sender=sender,
|
||||
send_quarter=args.send_quarter,
|
||||
autoeol=args.autoeol,
|
||||
bytesize=args.bytesize,
|
||||
parity=args.parity,
|
||||
stopbits=args.stopbits,
|
||||
rtscts=args.rtscts,
|
||||
xonxoff=args.xonxoff,
|
||||
dsrdtr=args.dsrdtr,
|
||||
read_timeout=args.read_timeout,
|
||||
idle_flush=args.idle_flush,
|
||||
max_chunk=args.max_chunk,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
try:
|
||||
reader.run()
|
||||
finally:
|
||||
sender.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user