build 115 - кнопка в эфире будет загораться

This commit is contained in:
2026-08-26 12:59:31 +03:00
parent 077e9c496f
commit 2c9b0d4fcc
7 changed files with 357 additions and 154 deletions

View File

@@ -257,8 +257,12 @@ class VmixAgentHub:
self._auto_refresh_task: asyncio.Task[None] | None = None
self._auto_refresh_next: dict[tuple[str, str], float] = {}
self._mapping_value_cache: dict[tuple[str, str, int, str, str, str, str], str] = {}
# BUILD83: server-side mirror of ACK-confirmed runtime Overlay state.
# BUILD83: server-side mirror of runtime Overlay state.
self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {}
# BUILD115: true when Overlay state comes from the Agent reading the actual
# local vMix XML API rather than from optimistic command history.
self._runtime_overlay_authoritative: dict[str, bool] = {}
self._runtime_overlay_observed_at: dict[str, str] = {}
self._auto_refresh_last_error = ""
@staticmethod
@@ -292,6 +296,11 @@ class VmixAgentHub:
parsed = self._runtime_overlay_command(command.get("Function") or command.get("function"))
if parsed is None:
return
# A command only tells us what we asked vMix to do. Until the Agent reads
# the XML API again, this local mirror is optimistic rather than a tally.
if not hasattr(self, "_runtime_overlay_authoritative"):
self._runtime_overlay_authoritative = {}
self._runtime_overlay_authoritative[device_id] = False
layer, action = parsed
device_state = self._runtime_overlay_state.setdefault(device_id, {})
if layer == "all":
@@ -325,7 +334,15 @@ class VmixAgentHub:
def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]:
source = self._runtime_overlay_state.get(device_id, {})
overlays = {str(layer): dict(value) for layer, value in source.items() if str(layer) in {"1", "2", "3", "4"}}
return {"device_id": device_id, "overlays": overlays}
authoritative = bool(getattr(self, "_runtime_overlay_authoritative", {}).get(device_id, False))
observed = str(getattr(self, "_runtime_overlay_observed_at", {}).get(device_id) or "")
return {
"device_id": device_id,
"overlays": overlays,
"authoritative": authoritative,
"source": "agent_tally" if authoritative else "command_mirror",
"observed_at": observed,
}
def runtime_overlay_state_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]:
requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else ""
@@ -650,6 +667,8 @@ class VmixAgentHub:
current = self._live.get(device_id)
if current is not None and current.websocket is websocket:
self._live.pop(device_id, None)
self._runtime_overlay_state.pop(device_id, None)
self._runtime_overlay_authoritative[device_id] = False
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is not None:
@@ -667,6 +686,7 @@ class VmixAgentHub:
row.vmix_connected = bool(vmix.get("connected"))
if not row.vmix_connected:
self._runtime_overlay_state.pop(device_id, None)
self._runtime_overlay_authoritative[device_id] = False
if vmix.get("version") is not None:
row.vmix_version = str(vmix.get("version") or "")[:64]
if vmix.get("url") is not None:
@@ -674,6 +694,46 @@ class VmixAgentHub:
if message.get("error") is not None:
row.last_error = str(message.get("error") or "")[:1000]
async def receive_overlay_state(self, device_id: str, message: dict[str, Any]) -> None:
raw_overlays = message.get("overlays") if isinstance(message.get("overlays"), dict) else {}
next_state: dict[str, dict[str, str]] = {}
for layer in ("1", "2", "3", "4"):
raw = raw_overlays.get(layer)
if raw is None:
raw = raw_overlays.get(int(layer))
if not isinstance(raw, dict):
continue
entry = {
"input": str(raw.get("input") or "").strip(),
"input_number": str(raw.get("input_number") or "").strip(),
"input_key": str(raw.get("input_key") or "").strip(),
"input_title": str(raw.get("input_title") or "").strip(),
"sequence_id": "",
"sequence_name": "",
"button_id": "",
"source": "agent_tally",
}
if not any(entry.get(key) for key in ("input", "input_number", "input_key", "input_title")):
continue
next_state[layer] = entry
vmix_connected = bool(message.get("vmix_connected", True))
if vmix_connected:
self._runtime_overlay_state[device_id] = next_state
self._runtime_overlay_authoritative[device_id] = True
self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80]
else:
self._runtime_overlay_state.pop(device_id, None)
self._runtime_overlay_authoritative[device_id] = False
self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80]
now = _utcnow()
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is not None:
row.last_seen_at = now
row.vmix_connected = vmix_connected
async def send(self, device_id: str, payload: dict[str, Any]) -> bool:
async with self._lock:
live = self._live.get(device_id)
@@ -4558,8 +4618,11 @@ def create_hockey_agent_router(
if not isinstance(message, dict):
continue
message_type = str(message.get("type") or "")
if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory"}:
await hub.receive_status(device_id, message)
if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory", "vmix.overlay_state"}:
if message_type == "vmix.overlay_state":
await hub.receive_overlay_state(device_id, message)
else:
await hub.receive_status(device_id, message)
if message_type == "vmix.inventory":
await hub.receive_inventory(device_id, message)
if message_type in {"command.ack", "command.batch.ack"}: