Files
hockey_new/hockey_data/agent_bridge.py
2026-08-24 18:38:12 +03:00

4561 lines
224 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, delete, desc, select
from .auth_bridge import HockeyUser
from .database import HockeyDatabase
from .mapping_context import MappingDataService
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle)
AGENT_PROTOCOL_VERSION = 1
AGENT_ONLINE_WINDOW_SECONDS = 35
_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{6,128}$")
# BUILD97: Mapping can easily exceed 300 links. Keep WebSocket/vMix packets small
# and predictable instead of sending the whole profile in one giant batch.
MAPPING_BATCH_MAX_COMMANDS = 40
MAPPING_BATCH_MAX_BYTES = 48 * 1024
def _utcnow() -> datetime:
return datetime.utcnow()
def _secret_hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
_MAPPING_TEST_FUNCTIONS = {
"SetText", "SetImage", "SetColor",
"SetTextColour",
"SetTextVisibleOn", "SetTextVisibleOff",
"SetImageVisibleOn", "SetImageVisibleOff",
}
def _mapping_value_function(field_type: Any) -> str:
kind = str(field_type or "text").strip().lower()
if kind in {"image", "source"}:
return "SetImage"
if kind in {"color", "colour"}:
return "SetColor"
return "SetText"
def _mapping_rule_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
try:
parsed = json.loads(str(value or "{}"))
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}
def _stable_vmix_inventory_fingerprint(inputs: list[dict[str, Any]]) -> str:
"""Fingerprint a vMix project without positional Input numbers/order.
vMix ``number`` is a slot position and changes when an operator moves an
Input. ``key`` is the durable identity, with title as fallback for old
inventories. Field indices are positional too, so only names/types are part
of the identity.
"""
stable_inputs: list[dict[str, Any]] = []
for item in inputs:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
title = str(item.get("title") or "").strip()
identity = key or title
if not identity:
# Last-resort legacy inventory. This is intentionally weaker, but
# avoids dropping projects produced by very old Agents.
identity = f"legacy:{str(item.get('number') or '').strip()}"
fields = item.get("fields") if isinstance(item.get("fields"), list) else []
stable_fields = sorted(
(
{
"name": str(field.get("name") or "").strip(),
"type": str(field.get("type") or "text").strip().lower(),
}
for field in fields
if isinstance(field, dict) and str(field.get("name") or "").strip()
),
key=lambda value: (value["name"].casefold(), value["type"]),
)
stable_inputs.append({
"identity": identity,
"key": key,
# A title rename must not create a new project when vMix supplied a
# durable key. For legacy inputs without key the title is identity.
"title": "" if key else title,
"type": str(item.get("type") or "").strip().lower(),
"fields": stable_fields,
})
stable_inputs.sort(key=lambda value: (value["identity"].casefold(), value["title"].casefold()))
payload = json.dumps(stable_inputs, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _stored_inventory_stable_fingerprint(value: Any) -> str:
try:
payload = json.loads(str(value or "{}")) if not isinstance(value, dict) else value
except Exception:
return ""
inputs = payload.get("inputs") if isinstance(payload, dict) and isinstance(payload.get("inputs"), list) else []
return _stable_vmix_inventory_fingerprint(inputs) if inputs else ""
def _mapping_numeric_value(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
text_value = str(value).strip()
if not text_value:
return None
compact = text_value.replace(" ", "").replace("%", "")
if re.fullmatch(r"[-+]?\d{1,4}:\d{1,2}(?::\d{1,2}(?:[.,]\d+)?)?", compact):
parts = compact.replace(",", ".").split(":")
try:
numbers = [float(part) for part in parts]
except ValueError:
return None
if len(numbers) == 2:
return numbers[0] * 60 + numbers[1]
if len(numbers) == 3:
return numbers[0] * 3600 + numbers[1] * 60 + numbers[2]
try:
return float(compact.replace(",", "."))
except ValueError:
return None
def _mapping_rule_matches(left: Any, operator: Any, right: Any = None) -> bool:
op = str(operator or "eq").strip().lower()
left_text = "" if left is None else str(left).strip()
right_text = "" if right is None else str(right).strip()
if op in {"empty", "is_empty"}:
return left_text == ""
if op in {"not_empty", "is_not_empty"}:
return left_text != ""
if op in {"contains", "not_contains"}:
matched = right_text.casefold() in left_text.casefold()
return (not matched) if op == "not_contains" else matched
left_number = _mapping_numeric_value(left)
right_number = _mapping_numeric_value(right)
if left_number is not None and right_number is not None:
a: Any = left_number
b: Any = right_number
else:
a = left_text.casefold()
b = right_text.casefold()
if op in {"gt", ">"}: return a > b
if op in {"lt", "<"}: return a < b
if op in {"gte", ">="}: return a >= b
if op in {"lte", "<="}: return a <= b
if op in {"neq", "!=", "<>"}: return a != b
return a == b
def _mapping_rule_command(
rule: dict[str, Any],
*,
field_type: Any,
input_ref: str,
selected_name: str,
default_data_key: str,
by_key: dict[str, dict[str, Any]],
) -> tuple[dict[str, Any] | None, str]:
if not rule or not bool(rule.get("enabled")):
return None, ""
kind = str(field_type or "text").strip().lower()
action = str(rule.get("action") or ("visibility" if kind in {"image", "source"} else "text_colour")).strip().lower()
if action == "text_colour" and kind != "text":
return None, "unsupported_action"
if action == "visibility" and kind not in {"text", "image", "source"}:
return None, "unsupported_action"
left_key = str(rule.get("left_key") or default_data_key or "").strip()
left_item = by_key.get(left_key)
if left_item is None:
return None, f"missing_left:{left_key}"
left_value = left_item.get("value")
operator = str(rule.get("operator") or "gt").strip().lower()
right_mode = str(rule.get("right_mode") or "field").strip().lower()
right_value: Any = None
if operator not in {"empty", "is_empty", "not_empty", "is_not_empty"}:
if right_mode == "value":
right_value = rule.get("right_value", "")
else:
right_key = str(rule.get("right_key") or "").strip()
right_item = by_key.get(right_key)
if right_item is None:
return None, f"missing_right:{right_key}"
right_value = right_item.get("value")
matched = _mapping_rule_matches(left_value, operator, right_value)
if action == "text_colour":
chosen = rule.get("true_value", "#E5CEA8") if matched else rule.get("false_value", "#FFFFFF")
if chosen is None or str(chosen).strip() == "":
return None, ""
return {
"Function": "SetTextColour",
"Input": input_ref,
"SelectedName": selected_name,
"Value": str(chosen).strip(),
}, ""
raw_state = rule.get("true_value", "on") if matched else rule.get("false_value", "off")
visible = str(raw_state).strip().lower() not in {"0", "false", "off", "hide", "hidden", "no"}
if kind in {"image", "source"}:
function = "SetImageVisibleOn" if visible else "SetImageVisibleOff"
else:
function = "SetTextVisibleOn" if visible else "SetTextVisibleOff"
return {"Function": function, "Input": input_ref, "SelectedName": selected_name}, ""
@dataclass(slots=True)
class LiveAgent:
websocket: WebSocket
connected_at: datetime
class VmixAgentHub:
"""Persistent device registry + live WebSocket routing for vMix agents."""
def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None:
self.database = database
self._live: dict[str, LiveAgent] = {}
self._lock = asyncio.Lock()
self._pending_commands: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {}
# BUILD90: one FIFO lock per Agent. Shortcut batches, Mapping refreshes and
# timer control commands can no longer interleave on the same vMix instance.
self._vmix_send_locks: dict[str, asyncio.Lock] = {}
# BUILD97: serialize Mapping applications per Agent while still letting
# runtime/timer commands use the normal vMix FIFO between Mapping chunks.
self._mapping_apply_locks: dict[str, asyncio.Lock] = {}
# BUILD102: interactive operator shortcuts use their own tiny send lock.
# They must never sit behind Mapping's ACK wait, otherwise a visible F-key
# or bottom-dock button can feel dead even though vMix itself is reachable.
self._vmix_shortcut_send_locks: dict[str, asyncio.Lock] = {}
self.mapping_data = MappingDataService(database, settings=settings)
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.
self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {}
self._auto_refresh_last_error = ""
@staticmethod
def normalise_device_id(value: Any) -> str:
device_id = str(value or "").strip()
if not _DEVICE_ID_RE.fullmatch(device_id):
raise ValueError("Некорректный device_id")
return device_id
@staticmethod
def _runtime_overlay_command(function_name: Any) -> tuple[str, str] | None:
text = re.sub(r"\s+", "", str(function_name or ""))
if text.lower() == "overlayinputalloff":
return ("all", "off")
match = re.search(r"OverlayInput([1-4])(In|Out|Off)?", text, flags=re.IGNORECASE)
if not match:
return None
raw = str(match.group(2) or "toggle").lower()
action = "in" if raw == "in" else "out" if raw == "out" else "off" if raw == "off" else "toggle"
return str(match.group(1)), action
def _track_runtime_overlay_command(
self,
device_id: str,
command: dict[str, Any],
*,
sequence_id: str = "",
sequence_name: str = "",
button_id: str = "",
) -> None:
parsed = self._runtime_overlay_command(command.get("Function") or command.get("function"))
if parsed is None:
return
layer, action = parsed
device_state = self._runtime_overlay_state.setdefault(device_id, {})
if layer == "all":
device_state.clear()
return
input_ref = str(command.get("Input") or command.get("input") or "").strip()
if action == "in":
device_state[layer] = {
"input": input_ref,
"sequence_id": str(sequence_id or ""),
"sequence_name": str(sequence_name or ""),
"button_id": str(button_id or ""),
"updated_at": _utcnow().isoformat(),
}
return
if action in {"out", "off"}:
device_state.pop(layer, None)
return
current = device_state.get(layer)
if current and (not input_ref or str(current.get("input") or "") == input_ref):
device_state.pop(layer, None)
else:
device_state[layer] = {
"input": input_ref,
"sequence_id": str(sequence_id or ""),
"sequence_name": str(sequence_name or ""),
"button_id": str(button_id or ""),
"updated_at": _utcnow().isoformat(),
}
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}
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 ""
with self.database.session() as session:
if requested:
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
if device is None:
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
else:
rows = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
if not rows:
return {"device_id": "", "overlays": {}}
device = rows[0]
result = self._runtime_overlay_payload(device.device_uuid)
result["online"] = device.device_uuid in self._live
result["vmix_connected"] = bool(device.vmix_connected)
return result
@staticmethod
def _mapping_source_code(data_key: Any) -> str:
key = str(data_key or "").strip()
return key.split(".", 1)[0] if key else ""
@staticmethod
def _agent_supports_batch(version: Any) -> bool:
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(version or ""))
if not match:
return False
return tuple(int(part) for part in match.groups()) >= (1, 4, 0)
@staticmethod
def _inventory_target_pairs(value: Any) -> set[tuple[str, str]]:
"""Return stable ``(Input key, field name)`` pairs from an inventory."""
try:
payload = json.loads(str(value or "{}")) if not isinstance(value, dict) else value
except Exception:
return set()
inputs = payload.get("inputs") if isinstance(payload, dict) and isinstance(payload.get("inputs"), list) else []
pairs: set[tuple[str, str]] = set()
for item in inputs:
if not isinstance(item, dict):
continue
input_key = str(item.get("key") or "").strip()
if not input_key:
continue
fields = item.get("fields") if isinstance(item.get("fields"), list) else []
for field in fields:
if not isinstance(field, dict):
continue
field_name = str(field.get("name") or "").strip()
if field_name:
pairs.add((input_key, field_name))
return pairs
def _compatible_mapping_profile_for_device(self, session: Any, device: VmixDevice) -> VmixMappingProfile | None:
"""Recover a profile when the project changed only in unrelated Inputs.
A profile is considered compatible only when *all* enabled mapped targets
identified by stable vMix Input keys still exist in the current project. If
several profiles tie for the best coverage we deliberately do not guess.
"""
current_pairs = self._inventory_target_pairs(device.project_inventory_json)
if not current_pairs:
return None
candidates: list[tuple[int, VmixMappingProfile]] = []
for profile in session.scalars(
select(VmixMappingProfile)
.where(VmixMappingProfile.active.is_(True))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
):
rows = list(session.scalars(
select(VmixMappingField).where(and_(
VmixMappingField.profile_id == profile.id,
VmixMappingField.enabled.is_(True),
))
))
targets = {
(str(row.vmix_input_key or "").strip(), str(row.vmix_field or "").strip())
for row in rows
if str(row.vmix_input_key or "").strip() and str(row.vmix_field or "").strip()
}
if not targets or not targets.issubset(current_pairs):
continue
candidates.append((len(targets), profile))
if not candidates:
return None
best_score = max(score for score, _ in candidates)
best = [profile for score, profile in candidates if score == best_score]
return best[0] if len(best) == 1 else None
def _active_mapping_profile_for_device(self, session: Any, device: VmixDevice) -> VmixMappingProfile | None:
"""Find/migrate the mapping profile for a device using stable inventory identity."""
fingerprint = str(device.project_fingerprint or "").strip()
if fingerprint:
profile = session.scalar(
select(VmixMappingProfile)
.where(and_(VmixMappingProfile.project_fingerprint == fingerprint, VmixMappingProfile.active.is_(True)))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if profile is not None:
return profile
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
stable_fingerprint = _stored_inventory_stable_fingerprint(inventory)
agent_fingerprint = str(inventory.get("agent_fingerprint") or "").strip().lower() if isinstance(inventory, dict) else ""
# Legacy build <=39 profile: raw Agent fingerprint was stored directly.
if agent_fingerprint:
profile = session.scalar(
select(VmixMappingProfile)
.where(and_(VmixMappingProfile.project_fingerprint == agent_fingerprint, VmixMappingProfile.active.is_(True)))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if profile is not None:
if stable_fingerprint:
profile.project_fingerprint = stable_fingerprint
device.project_fingerprint = stable_fingerprint
return profile
if stable_fingerprint:
candidates = list(session.scalars(
select(VmixMappingProfile)
.where(VmixMappingProfile.active.is_(True))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
))
profile = next((candidate for candidate in candidates
if _stored_inventory_stable_fingerprint(candidate.inventory_json) == stable_fingerprint), None)
if profile is not None:
profile.project_fingerprint = stable_fingerprint
device.project_fingerprint = stable_fingerprint
return profile
# Final safe recovery: if the operator added/removed unrelated Inputs, the
# whole-project fingerprint changes even though every linked target is still
# present. Reuse a profile only when the target set identifies one profile
# unambiguously. Do not rewrite its primary fingerprint here, because another
# active Agent may legitimately use the original project variant.
return self._compatible_mapping_profile_for_device(session, device)
def start_auto_refresh(self) -> None:
"""Start the server-side SQL → Mapping → vMix refresh scheduler."""
if self._auto_refresh_task is None or self._auto_refresh_task.done():
self._auto_refresh_task = asyncio.create_task(self._auto_refresh_loop(), name="hockey-mapping-auto-refresh")
async def stop_auto_refresh(self) -> None:
task = self._auto_refresh_task
self._auto_refresh_task = None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _auto_refresh_loop(self) -> None:
while True:
try:
await self.auto_refresh_once()
self._auto_refresh_last_error = ""
except asyncio.CancelledError:
raise
except Exception as error:
# The scheduler must never take the HTTP/WebSocket server down. A broken
# source is isolated to its next tick and can be fixed from the admin UI.
self._auto_refresh_last_error = str(error)[:500]
await asyncio.sleep(0.25)
async def auto_refresh_once(self) -> dict[str, Any]:
"""Apply due auto-refresh SQL sources to live devices, sending only changed values."""
async with self._lock:
live_ids = set(self._live.keys())
if not live_ids:
return {"ok": True, "devices": 0, "sources": 0, "applied": 0}
with self.database.session() as session:
source_rows = list(session.scalars(
select(MappingSqlDataSource).where(and_(
MappingSqlDataSource.enabled.is_(True),
MappingSqlDataSource.auto_refresh_enabled.is_(True),
))
))
source_intervals = {
str(row.code): max(1.0, min(3600.0, float(int(row.refresh_interval_ms or 1000)) / 1000.0))
for row in source_rows
}
if not source_intervals:
return {"ok": True, "devices": 0, "sources": 0, "applied": 0}
devices = list(session.scalars(
select(VmixDevice).where(and_(
VmixDevice.device_uuid.in_(live_ids),
VmixDevice.is_active_for_account.is_(True),
VmixDevice.vmix_connected.is_(True),
))
))
jobs: list[tuple[str, set[str]]] = []
loop_now = asyncio.get_running_loop().time()
for device in devices:
if not device.current_assignment_key or not device.current_match_external_id or not device.project_fingerprint:
continue
profile = self._active_mapping_profile_for_device(session, device)
if profile is None:
continue
data_keys = list(session.scalars(
select(VmixMappingField.data_key).where(and_(
VmixMappingField.profile_id == profile.id,
VmixMappingField.enabled.is_(True),
))
))
used_codes = {self._mapping_source_code(key) for key in data_keys}
due: set[str] = set()
for code in used_codes.intersection(source_intervals):
schedule_key = (str(device.device_uuid), code)
if loop_now + 1e-9 < self._auto_refresh_next.get(schedule_key, 0.0):
continue
due.add(code)
self._auto_refresh_next[schedule_key] = loop_now + source_intervals[code]
if due:
jobs.append((str(device.device_uuid), due))
if not jobs:
return {"ok": True, "devices": 0, "sources": 0, "applied": 0}
raw_results = await asyncio.gather(*(
self.apply_mapping_to_device(
device_id, reason="sql_auto_refresh", source_codes=source_codes, only_changed=True
)
for device_id, source_codes in jobs
), return_exceptions=True)
applied = 0
errors: list[str] = []
for value in raw_results:
if isinstance(value, Exception):
errors.append(str(value)[:300])
elif isinstance(value, dict):
applied += int(value.get("applied") or 0)
errors.extend(str(item)[:300] for item in (value.get("errors") or []))
return {
"ok": not errors,
"devices": len(jobs),
"sources": sum(len(codes) for _, codes in jobs),
"applied": applied,
"errors": errors,
}
async def register(self, websocket: WebSocket, hello: dict[str, Any]) -> dict[str, Any]:
device_id = self.normalise_device_id(hello.get("device_id"))
device_secret = str(hello.get("device_secret") or "").strip()
if len(device_secret) < 24:
raise PermissionError("device_secret слишком короткий")
now = _utcnow()
secret_hash = _secret_hash(device_secret)
vmix = hello.get("vmix") if isinstance(hello.get("vmix"), dict) else {}
client = websocket.client
client_ip = client.host if client else ""
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None:
row = VmixDevice(
device_uuid=device_id,
device_secret_hash=secret_hash,
name=str(hello.get("device_name") or hello.get("hostname") or device_id)[:200],
hostname=str(hello.get("hostname") or "")[:200],
agent_version=str(hello.get("agent_version") or "")[:64],
first_seen_at=now,
last_seen_at=now,
last_ip=client_ip[:128],
vmix_connected=bool(vmix.get("connected")),
vmix_version=str(vmix.get("version") or "")[:64],
vmix_url=str(vmix.get("url") or "")[:500],
)
session.add(row)
session.flush()
elif not hmac.compare_digest(row.device_secret_hash, secret_hash):
raise PermissionError("Этот device_id уже зарегистрирован с другим ключом")
else:
row.name = str(hello.get("device_name") or row.name or row.hostname or device_id)[:200]
row.hostname = str(hello.get("hostname") or row.hostname or "")[:200]
row.agent_version = str(hello.get("agent_version") or row.agent_version or "")[:64]
row.last_seen_at = now
row.last_ip = client_ip[:128]
row.vmix_connected = bool(vmix.get("connected"))
row.vmix_version = str(vmix.get("version") or row.vmix_version or "")[:64]
row.vmix_url = str(vmix.get("url") or row.vmix_url or "")[:500]
row.last_error = ""
session.flush()
payload = self._device_payload(row, viewer_user_id=row.wfl_user_id or "")
async with self._lock:
previous = self._live.get(device_id)
self._live[device_id] = LiveAgent(websocket=websocket, connected_at=now)
if previous is not None and previous.websocket is not websocket:
try:
await previous.websocket.close(code=4001, reason="Device reconnected")
except Exception:
pass
return payload
async def unregister(self, device_id: str, websocket: WebSocket) -> None:
async with self._lock:
current = self._live.get(device_id)
if current is not None and current.websocket is websocket:
self._live.pop(device_id, None)
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 = _utcnow()
async def receive_status(self, device_id: str, message: dict[str, Any]) -> None:
vmix = message.get("vmix") if isinstance(message.get("vmix"), dict) else {}
now = _utcnow()
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None:
return
row.last_seen_at = now
if "connected" in vmix:
row.vmix_connected = bool(vmix.get("connected"))
if not row.vmix_connected:
self._runtime_overlay_state.pop(device_id, None)
if vmix.get("version") is not None:
row.vmix_version = str(vmix.get("version") or "")[:64]
if vmix.get("url") is not None:
row.vmix_url = str(vmix.get("url") or "")[:500]
if message.get("error") is not None:
row.last_error = str(message.get("error") or "")[:1000]
async def send(self, device_id: str, payload: dict[str, Any]) -> bool:
async with self._lock:
live = self._live.get(device_id)
if live is None:
return False
try:
await live.websocket.send_json(payload)
return True
except Exception:
async with self._lock:
if self._live.get(device_id) is live:
self._live.pop(device_id, None)
return False
def _device_vmix_send_lock(self, device_id: str) -> asyncio.Lock:
lock = self._vmix_send_locks.get(device_id)
if lock is None:
lock = asyncio.Lock()
self._vmix_send_locks[device_id] = lock
return lock
def _device_mapping_apply_lock(self, device_id: str) -> asyncio.Lock:
lock = self._mapping_apply_locks.get(device_id)
if lock is None:
lock = asyncio.Lock()
self._mapping_apply_locks[device_id] = lock
return lock
def _device_vmix_shortcut_send_lock(self, device_id: str) -> asyncio.Lock:
lock = self._vmix_shortcut_send_locks.get(device_id)
if lock is None:
lock = asyncio.Lock()
self._vmix_shortcut_send_locks[device_id] = lock
return lock
@staticmethod
def _mapping_batch_chunks(entries: list[dict[str, Any]]) -> list[list[tuple[int, dict[str, Any]]]]:
"""Group Mapping commands by vMix Input, then cap packet count and bytes.
The returned tuples keep the original index so ACKs can be mapped back to
the original Mapping links even though commands are grouped by Input.
"""
groups: dict[str, list[tuple[int, dict[str, Any]]]] = {}
for index, entry in enumerate(entries):
command = entry.get("command") if isinstance(entry, dict) else None
input_ref = str((command or {}).get("Input") or "").strip()
groups.setdefault(input_ref, []).append((index, entry))
chunks: list[list[tuple[int, dict[str, Any]]]] = []
for group in groups.values():
current: list[tuple[int, dict[str, Any]]] = []
current_bytes = 0
for indexed_entry in group:
command = indexed_entry[1].get("command") or {}
try:
command_bytes = len(json.dumps(command, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + 96
except Exception:
command_bytes = 512
if current and (
len(current) >= MAPPING_BATCH_MAX_COMMANDS
or current_bytes + command_bytes > MAPPING_BATCH_MAX_BYTES
):
chunks.append(current)
current = []
current_bytes = 0
current.append(indexed_entry)
current_bytes += command_bytes
if current:
chunks.append(current)
return chunks
async def _send_mapping_entries_resilient(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
entries: list[dict[str, Any]],
use_batch: bool,
) -> dict[str, Any]:
"""Deliver Mapping commands reliably, with chunking and adaptive fallback.
Large mappings are grouped by Input and sent in bounded batches. A timed
out/failed batch is recursively split; a single failed command falls back
to the legacy one-command transport. Mapping commands are idempotent
(SetText/SetImage/SetColor/visibility), so this retry strategy is safe.
"""
outcomes: list[dict[str, Any] | None] = [None] * len(entries)
stats = {
"chunks_total": 0,
"chunks_ok": 0,
"chunks_failed": 0,
"packets_sent": 0,
"retries": 0,
"fallback_commands": 0,
"input_groups": len({
str(((entry.get("command") or {}).get("Input") or "")).strip()
for entry in entries
}),
}
async def send_single(
index: int, entry: dict[str, Any], previous_reason: str = "", *, count_fallback: bool = True
) -> None:
if count_fallback:
stats["fallback_commands"] += 1
try:
ack = await self.send_vmix_command(
device_id, assignment_id=assignment_id, match_id=match_id,
command=entry.get("command") or {}, timeout=5.0,
)
if bool(ack.get("ok")):
outcomes[index] = dict(ack)
else:
outcomes[index] = {
"ok": False,
"reason": str(ack.get("reason") or ack.get("error") or previous_reason or "vmix_error"),
}
except Exception as error:
outcomes[index] = {
"ok": False,
"reason": str(getattr(error, "detail", error) or previous_reason or "vmix_error")[:300],
}
async def send_chunk(chunk: list[tuple[int, dict[str, Any]]]) -> None:
if not chunk:
return
commands = [entry.get("command") or {} for _, entry in chunk]
stats["packets_sent"] += 1
try:
ack = await self.send_vmix_batch(
device_id, assignment_id=assignment_id, match_id=match_id, commands=commands,
timeout=max(6.0, min(10.0, 4.0 + len(commands) * 0.06)),
)
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else None
if ack_results is None:
if bool(ack.get("ok")):
for index, _entry in chunk:
outcomes[index] = {"ok": True}
return
raise RuntimeError(str(ack.get("reason") or ack.get("error") or "vmix_batch_error"))
failed: list[tuple[int, dict[str, Any], str]] = []
for position, (index, entry) in enumerate(chunk):
item_ack = ack_results[position] if position < len(ack_results) and isinstance(ack_results[position], dict) else {}
if bool(item_ack.get("ok")):
outcomes[index] = dict(item_ack)
else:
failed.append((
index, entry,
str(item_ack.get("reason") or item_ack.get("error") or "vmix_batch_error"),
))
# A batch ACK can contain isolated command failures. Retry only
# those commands once through the single-command transport.
for index, entry, reason in failed:
stats["retries"] += 1
await send_single(index, entry, reason)
return
except Exception as error:
reason = str(getattr(error, "detail", error))[:300]
if len(chunk) > 1:
stats["retries"] += 1
middle = max(1, len(chunk) // 2)
await send_chunk(chunk[:middle])
await send_chunk(chunk[middle:])
return
index, entry = chunk[0]
stats["retries"] += 1
await send_single(index, entry, reason)
async with self._device_mapping_apply_lock(device_id):
if use_batch:
chunks = self._mapping_batch_chunks(entries)
stats["chunks_total"] = len(chunks)
for chunk in chunks:
await send_chunk(chunk)
failed_now = sum(1 for index, _entry in chunk if outcomes[index] is None or not bool(outcomes[index].get("ok")))
if failed_now == 0:
stats["chunks_ok"] += 1
else:
stats["chunks_failed"] += 1
else:
stats["chunks_total"] = len(entries)
for index, entry in enumerate(entries):
await send_single(index, entry, count_fallback=False)
if outcomes[index] is not None and bool(outcomes[index].get("ok")):
stats["chunks_ok"] += 1
else:
stats["chunks_failed"] += 1
return {
"results": [item if isinstance(item, dict) else {"ok": False, "reason": "mapping_transport_no_ack"} for item in outcomes],
**stats,
}
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
request_id = str(message.get("request_id") or "").strip()
if not request_id:
return
pending = self._pending_commands.get(request_id)
if pending is None:
return
expected_device_id, future = pending
if expected_device_id != device_id or future.done():
return
payload = dict(message)
payload["device_id"] = device_id
future.set_result(payload)
async def _send_vmix_command_unlocked(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
command: dict[str, Any],
timeout: float = 5.0,
) -> dict[str, Any]:
request_id = secrets.token_urlsafe(12)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
self._pending_commands[request_id] = (device_id, future)
try:
delivered = await self.send(
device_id,
{
"type": "vmix.command",
"protocol": AGENT_PROTOCOL_VERSION,
"request_id": request_id,
"device_id": device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"command": command,
},
)
if not delivered:
raise HTTPException(status_code=409, detail="Agent сейчас offline")
try:
ack = await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError as error:
raise HTTPException(status_code=504, detail="Agent не подтвердил команду vMix") from error
return ack
finally:
pending = self._pending_commands.pop(request_id, None)
if pending is not None:
_, pending_future = pending
if not pending_future.done():
pending_future.cancel()
async def send_vmix_command(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
command: dict[str, Any],
timeout: float = 5.0,
) -> dict[str, Any]:
async with self._device_vmix_send_lock(device_id):
return await self._send_vmix_command_unlocked(
device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
)
async def _send_vmix_batch_unlocked(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
commands: list[dict[str, Any]],
timeout: float = 8.0,
) -> dict[str, Any]:
"""Send many concrete vMix commands in one WebSocket frame and wait for one ACK."""
request_id = secrets.token_urlsafe(12)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
self._pending_commands[request_id] = (device_id, future)
try:
delivered = await self.send(
device_id,
{
"type": "vmix.batch",
"protocol": AGENT_PROTOCOL_VERSION,
"request_id": request_id,
"device_id": device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"commands": commands,
},
)
if not delivered:
raise HTTPException(status_code=409, detail="Agent сейчас offline")
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError as error:
raise HTTPException(status_code=504, detail="Agent не подтвердил пакет команд vMix") from error
finally:
pending = self._pending_commands.pop(request_id, None)
if pending is not None:
_, pending_future = pending
if not pending_future.done():
pending_future.cancel()
async def send_vmix_batch(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
commands: list[dict[str, Any]],
timeout: float = 8.0,
) -> dict[str, Any]:
async with self._device_vmix_send_lock(device_id):
return await self._send_vmix_batch_unlocked(
device_id, assignment_id=assignment_id, match_id=match_id, commands=commands, timeout=timeout
)
async def test_set_text(
self,
device_id: str,
user: HockeyUser,
*,
input_ref: str,
selected_name: str,
value: str,
) -> dict[str, Any]:
device_id = self.normalise_device_id(device_id)
input_ref = str(input_ref or "").strip()
selected_name = str(selected_name or "").strip()
if not input_ref:
raise HTTPException(status_code=422, detail="Укажите Input (имя, номер или key)")
if not selected_name:
raise HTTPException(status_code=422, detail="Укажите имя текстового поля vMix")
if len(input_ref) > 300 or len(selected_name) > 300 or len(value) > 4000:
raise HTTPException(status_code=422, detail="Слишком длинные параметры тестовой команды")
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or device.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
if not device.is_active_for_account:
raise HTTPException(status_code=409, detail="Включите для этого Agent режим «Получать данные»")
assignment_id = str(device.current_assignment_key or "")
match_id = str(device.current_match_external_id or "")
if not assignment_id or not match_id:
raise HTTPException(status_code=409, detail="Сначала выберите матч в web-интерфейсе")
ack = await self.send_vmix_command(
device_id,
assignment_id=assignment_id,
match_id=match_id,
command={
"Function": "SetText",
"Input": input_ref,
"SelectedName": selected_name,
"Value": value,
},
)
if not bool(ack.get("ok")):
reason = str(ack.get("reason") or ack.get("error") or "vMix отклонил команду")
raise HTTPException(status_code=502, detail=f"Команда дошла до agent, но не выполнена: {reason}")
return {
"ok": True,
"device_id": device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"function": "SetText",
"input": input_ref,
"selected_name": selected_name,
"value": value,
"agent_ack": ack,
}
async def apply_mapping_to_device(
self,
device_id: str,
*,
reason: str = "manual",
source_codes: set[str] | None = None,
only_changed: bool = False,
extra_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Resolve the active mapping against the device's current match and push values to vMix.
Mapping configuration stays server-side. The Agent receives only concrete vMix commands,
so it never needs database access or knowledge of SQL/context keys.
"""
device_id = self.normalise_device_id(device_id)
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None:
return {"ok": False, "reason": "device_not_found", "device_id": device_id}
if not device.wfl_user_id:
return {"ok": False, "reason": "device_not_paired", "device_id": device_id}
assignment_id = str(device.current_assignment_key or "")
match_id = str(device.current_match_external_id or "")
fingerprint = str(device.project_fingerprint or "")
vmix_connected = bool(device.vmix_connected)
login = str(device.login_snapshot or "")
user_id = str(device.wfl_user_id or "")
agent_version = str(device.agent_version or "")
if not assignment_id or not match_id:
return {"ok": False, "reason": "no_match_assignment", "device_id": device_id}
if not fingerprint:
return {"ok": False, "reason": "no_project_inventory", "device_id": device_id}
if not vmix_connected:
return {"ok": False, "reason": "vmix_not_connected", "device_id": device_id}
assignment = session.scalar(
select(VmixAssignment).where(
and_(
VmixAssignment.device_id == device.id,
VmixAssignment.assignment_key == assignment_id,
VmixAssignment.active.is_(True),
)
)
)
tournament_id = str(assignment.tournament_external_id or "") if assignment is not None else ""
preference = session.get(UserPreference, user_id)
mapping_language = ""
if preference is not None:
preferred_vmix = str(preference.vmix_language or "").strip().lower()
preferred_display = str(preference.display_language or "").strip().lower()
if preferred_vmix in {"ru", "en"}:
mapping_language = preferred_vmix
elif preferred_display in {"ru", "en"}:
mapping_language = preferred_display
profile = self._active_mapping_profile_for_device(session, device)
if profile is None:
return {"ok": False, "reason": "mapping_missing", "device_id": device_id}
fields = list(
session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
)
)
if source_codes is not None:
wanted_sources = {str(code) for code in source_codes}
fields = [field for field in fields if self._mapping_source_code(field.data_key) in wanted_sources]
profile_id = profile.id
profile_name = profile.name
profile_version = profile.version
user = HockeyUser(id=user_id, login=login or user_id, display_name=login or user_id)
supplied_context = {
"game_id": match_id,
"tournament_id": tournament_id,
"device_id": device_id,
"assignment_id": assignment_id,
}
if isinstance(extra_context, dict):
for raw_key, raw_value in list(extra_context.items())[:32]:
key = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(raw_key or "").strip())[:96]
if not key or raw_value is None:
continue
supplied_context[key] = str(raw_value)[:8000]
if mapping_language:
supplied_context["ui_language"] = mapping_language
catalog_source_codes = None if source_codes is None else {str(code) for code in source_codes}
if catalog_source_codes is not None:
for field in fields:
rule = _mapping_rule_payload(getattr(field, "rule_json", "{}"))
if not bool(rule.get("enabled")):
continue
for dependency in (rule.get("left_key"), rule.get("right_key") if str(rule.get("right_mode") or "field").lower() != "value" else ""):
code = self._mapping_source_code(dependency)
if code:
catalog_source_codes.add(code)
catalog = (
self.mapping_data.data_catalog(user, supplied_context, source_codes=catalog_source_codes)
if catalog_source_codes is not None
else self.mapping_data.data_catalog(user, supplied_context)
)
by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")}
result: dict[str, Any] = {
"ok": True,
"reason": reason,
"device_id": device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"profile_id": profile_id,
"profile_name": profile_name,
"profile_version": profile_version,
"total": len(fields),
"applied": 0,
"rules_total": 0,
"rules_applied": 0,
"unchanged": 0,
"rules_unchanged": 0,
"rule_missing": [],
"source_codes": sorted(source_codes) if source_codes is not None else [],
"missing": [],
"errors": [],
"transport": "batch" if self._agent_supports_batch(agent_version) else "legacy",
}
pending_entries: list[dict[str, Any]] = []
for field in fields:
data_key = str(field.data_key or "")
item = by_key.get(data_key)
if item is None:
result["missing"].append(data_key)
continue
raw_value = item.get("value", "")
value = "" if raw_value is None else str(raw_value)
input_ref = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
selected_name = str(field.vmix_field or "").strip()
if not input_ref or not selected_name:
result["errors"].append({"key": data_key, "reason": "missing_vmix_target"})
continue
field_type = str(field.field_type or "text").lower()
function = _mapping_value_function(field_type)
cache_key = (device_id, assignment_id, int(profile_id), input_ref, selected_name, function, data_key)
if only_changed and self._mapping_value_cache.get(cache_key) == value:
result["unchanged"] += 1
else:
pending_entries.append({
"entry_kind": "value",
"data_key": data_key,
"value": value,
"cache_key": cache_key,
"command": {
"Function": function,
"Input": input_ref,
"SelectedName": selected_name,
"Value": value,
},
})
rule = _mapping_rule_payload(getattr(field, "rule_json", "{}"))
rule_command, rule_reason = _mapping_rule_command(
rule, field_type=field_type, input_ref=input_ref, selected_name=selected_name,
default_data_key=data_key, by_key=by_key,
)
if rule_reason.startswith("missing_"):
result["rule_missing"].append({"key": data_key, "reason": rule_reason})
elif rule_reason:
result["errors"].append({"key": data_key, "reason": f"rule:{rule_reason}"})
if rule_command is not None:
result["rules_total"] += 1
rule_function = str(rule_command.get("Function") or "")
rule_value = str(rule_command.get("Value") or rule_function)
rule_cache_key = (device_id, assignment_id, int(profile_id), input_ref, selected_name, rule_function, f"rule:{data_key}")
if only_changed and self._mapping_value_cache.get(rule_cache_key) == rule_value:
result["rules_unchanged"] += 1
else:
pending_entries.append({
"entry_kind": "rule",
"data_key": data_key,
"value": rule_value,
"cache_key": rule_cache_key,
"command": rule_command,
})
result["total_commands"] = len(pending_entries)
if not pending_entries:
result["ok"] = not result["errors"]
return result
use_batch = self._agent_supports_batch(agent_version)
result["transport"] = "chunked_batch" if use_batch else "legacy"
transport = await self._send_mapping_entries_resilient(
device_id,
assignment_id=assignment_id,
match_id=match_id,
entries=pending_entries,
use_batch=use_batch,
)
result["batch_chunks_total"] = int(transport.get("chunks_total") or 0)
result["batch_chunks_applied"] = int(transport.get("chunks_ok") or 0)
result["batch_chunks_failed"] = int(transport.get("chunks_failed") or 0)
result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
result["batch_retries"] = int(transport.get("retries") or 0)
result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
result["input_groups"] = int(transport.get("input_groups") or 0)
result["batch_max_commands"] = MAPPING_BATCH_MAX_COMMANDS
ack_results = transport.get("results") if isinstance(transport.get("results"), list) else []
for index, entry in enumerate(pending_entries):
item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
if bool(item_ack.get("ok")):
if entry.get("entry_kind") == "rule":
result["rules_applied"] += 1
else:
result["applied"] += 1
self._mapping_value_cache[entry["cache_key"]] = entry["value"]
else:
result["errors"].append({
"key": entry["data_key"],
"input": str((entry.get("command") or {}).get("Input") or ""),
"field": str((entry.get("command") or {}).get("SelectedName") or ""),
"reason": str(item_ack.get("reason") or item_ack.get("error") or "mapping_transport_error"),
})
result["ok"] = not result["errors"]
return result
async def apply_mapping_for_user(
self,
user: HockeyUser,
*,
reason: str = "context_changed",
device_id: str = "",
session_token: str = "",
only_changed: bool = False,
) -> dict[str, Any] | None:
"""Apply Mapping to exactly one browser/session Agent, never fan out by account."""
requested = str(device_id or "").strip()
token = str(session_token or "").strip()
with self.database.session() as session:
if not requested and token:
operator = session.scalar(select(OperatorSession).where(and_(
OperatorSession.session_token == token,
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)))
if operator is not None:
requested = str(operator.vmix_device_uuid or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(
VmixDevice.device_uuid == requested,
VmixDevice.wfl_user_id == user.id,
VmixDevice.is_active_for_account.is_(True),
)))
if device is None:
return {"ok": False, "reason": "device_not_available", "device_id": requested}
target = device.device_uuid
else:
devices = list(session.scalars(select(VmixDevice).where(and_(
VmixDevice.wfl_user_id == user.id,
VmixDevice.is_active_for_account.is_(True),
)).order_by(desc(VmixDevice.last_seen_at))))
if not devices:
return None
if len(devices) != 1:
return {"ok": False, "reason": "device_selection_required", "total_devices": len(devices)}
target = devices[0].device_uuid
return await self.apply_mapping_to_device(target, reason=reason, only_changed=only_changed)
async def apply_mapping_to_all_active_devices(self, *, reason: str = "global_runtime_changed") -> dict[str, Any]:
"""Admin-only explicit fan-out used for truly global maintenance actions."""
with self.database.session() as session:
device_ids = [
str(value)
for value in session.scalars(
select(VmixDevice.device_uuid)
.where(and_(
VmixDevice.wfl_user_id.is_not(None),
VmixDevice.is_active_for_account.is_(True),
VmixDevice.vmix_connected.is_(True),
))
.order_by(VmixDevice.id)
)
if str(value or "").strip()
]
if not device_ids:
return {"ok": True, "reason": reason, "total_devices": 0, "applied_devices": 0, "devices": []}
async def apply_one(target_device_id: str) -> dict[str, Any]:
try:
result = await self.apply_mapping_to_device(target_device_id, reason=reason)
return {"device_id": target_device_id, **result}
except Exception as error:
return {"ok": False, "device_id": target_device_id, "reason": str(error)[:500]}
results = list(await asyncio.gather(*(apply_one(target_device_id) for target_device_id in device_ids)))
applied_devices = sum(1 for item in results if bool(item.get("ok")))
return {
"ok": applied_devices == len(device_ids),
"reason": reason,
"total_devices": len(device_ids),
"applied_devices": applied_devices,
"devices": results,
}
async def _run_vmix_sequence_on_device(
self,
device_id: str,
assignment_id: str,
match_id: str,
commands: list[dict[str, Any]],
*,
timeout: float,
) -> dict[str, Any]:
"""Execute one sequence serially on one Agent."""
results: list[dict[str, Any]] = []
for index, raw in enumerate(commands):
function = str(raw.get("Function") or raw.get("function") or "").strip()
if not function:
raise HTTPException(status_code=422, detail=f"Команда {index + 1}: не указана Function")
command: dict[str, Any] = {"Function": function}
for key, value in raw.items():
if str(key).lower() == "function" or value is None:
continue
if str(key).lower() != "value" and str(value) == "":
continue
command[str(key)] = value
ack = await self.send_vmix_command(
device_id,
assignment_id=assignment_id,
match_id=match_id,
command=command,
timeout=timeout,
)
item = {
"index": index,
"function": function,
"ok": bool(ack.get("ok")),
"reason": str(ack.get("reason") or ack.get("error") or ""),
}
results.append(item)
if not item["ok"]:
raise HTTPException(
status_code=502,
detail={
"message": f"vMix command failed: {function}",
"index": index,
"results": results,
},
)
return {
"ok": True,
"device_id": device_id,
"match_id": match_id,
"assignment_id": assignment_id,
"applied": len(results),
"results": results,
}
async def run_vmix_sequence_for_user(
self,
user: HockeyUser,
commands: list[dict[str, Any]],
*,
device_id: str = "",
session_token: str = "",
sequence_id: str = "",
sequence_name: str = "",
button_id: str = "",
delivery_mode: str = "",
timeout: float = 4.0,
) -> dict[str, Any]:
"""Execute commands on exactly one Agent bound to this browser/match session."""
requested_device_id = str(device_id or "").strip()
session_token = str(session_token or "").strip()
operator_game_id = ""
operator_tournament_id = ""
bound_device_id = ""
with self.database.session() as session:
if session_token:
operator = session.scalar(
select(OperatorSession).where(
and_(
OperatorSession.session_token == session_token,
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)
)
)
if operator is None:
raise HTTPException(status_code=404, detail="Матчевая сессия не найдена")
operator_game_id = str(operator.game_external_id or "")
operator_tournament_id = str(operator.tournament_external_id or "")
bound_device_id = str(operator.vmix_device_uuid or "").strip()
target_device_id = requested_device_id or bound_device_id
if target_device_id:
target_device_id = self.normalise_device_id(target_device_id)
device = session.scalar(
select(VmixDevice).where(
and_(
VmixDevice.device_uuid == target_device_id,
VmixDevice.wfl_user_id == user.id,
VmixDevice.is_active_for_account.is_(True),
)
)
)
if device is None:
raise HTTPException(status_code=409, detail="Выбранный Hockey Agent недоступен или отключён")
else:
candidates = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
if not candidates:
raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
if len(candidates) > 1:
raise HTTPException(status_code=409, detail="Для этой панели выберите конкретный Hockey Agent")
device = candidates[0]
target_device_id = device.device_uuid
# An explicit browser device_id always wins. OperatorSession keeps a
# fallback device only for legacy/single-panel clients; several browser
# panels of the same account may legitimately target different Agents.
assignment = session.scalar(
select(VmixAssignment)
.where(
and_(
VmixAssignment.device_id == device.id,
VmixAssignment.active.is_(True),
)
)
.order_by(desc(VmixAssignment.id))
)
assignment_matches = bool(
assignment is not None
and (not operator_game_id or assignment.game_external_id == operator_game_id)
and (not session_token or not assignment.operator_session_token or assignment.operator_session_token == session_token)
)
need_assignment = not assignment_matches
if need_assignment:
if not operator_game_id:
raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
assigned = await self.assign_match(
wfl_user_id=user.id,
game_external_id=operator_game_id,
tournament_external_id=operator_tournament_id,
device_id=target_device_id,
operator_session_token=session_token,
)
if not assigned:
raise HTTPException(status_code=409, detail="Не удалось назначить матч выбранному Agent")
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
if device is None:
raise HTTPException(status_code=404, detail="Agent не найден")
assignment_id = str(device.current_assignment_key or "")
match_id = str(device.current_match_external_id or "")
agent_version = str(device.agent_version or "")
if not assignment_id or not match_id:
raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
if operator_game_id and match_id != operator_game_id:
raise HTTPException(status_code=409, detail="Agent назначен на другой матч")
prepared_commands: list[dict[str, Any]] = []
for index, raw in enumerate(commands):
function = str(raw.get("Function") or raw.get("function") or "").strip()
if not function:
raise HTTPException(status_code=422, detail=f"Команда {index + 1}: не указана Function")
command: dict[str, Any] = {"Function": function}
for key, value in raw.items():
if str(key).lower() == "function" or value is None:
continue
if str(key).lower() != "value" and str(value) == "":
continue
command[str(key)] = value
prepared_commands.append(command)
results: list[dict[str, Any]] = []
# BUILD102: operator Shortcut/Quick-panel traffic is fire-and-forget after
# the WebSocket frame has been handed to the connected Agent. We deliberately
# do NOT wait for command.ack here. Agent ACK can arrive later and is ignored
# for this transport. Mapping and generic runtime sync keep their existing ACK
# semantics so diagnostics are still useful where exact field delivery matters.
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
delivery_mode = str(delivery_mode or "").strip().lower()
fast_timer = delivery_mode in {"timer-fast", "timer-fast-ordered"}
ordered_timer = delivery_mode == "timer-fast-ordered"
no_ack_delivery = interactive_shortcut or fast_timer
supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
use_batch = (not no_ack_delivery) and supports_batch
if ordered_timer:
transport = "timer-ordered-no-ack"
elif fast_timer:
transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"
else:
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
if no_ack_delivery:
# BUILD106: realtime operator traffic bypasses Mapping's ACK queue. The new
# timer-fast-ordered mode deliberately sends each timer command as its own WebSocket
# frame in strict caller order and never waits for Agent/vMix ACK.
# Legacy timer-fast keeps vmix.batch compatibility. The shared realtime lock prevents
# interleaving with F-key traffic without reintroducing the slow Mapping lock.
async with self._device_vmix_shortcut_send_lock(target_device_id):
if fast_timer and supports_batch and not ordered_timer:
request_id = secrets.token_urlsafe(12)
delivered = await self.send(
target_device_id,
{
"type": "vmix.batch",
"protocol": AGENT_PROTOCOL_VERSION,
"request_id": request_id,
"device_id": target_device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"commands": prepared_commands,
},
)
for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "")
results.append({
"index": index,
"function": function,
"ok": bool(delivered),
"reason": "" if delivered else "Agent сейчас offline",
"delivery": "agent-websocket-batch",
"ack_waited": False,
})
if delivered:
self._track_runtime_overlay_command(
target_device_id,
command,
sequence_id=sequence_id,
sequence_name=sequence_name,
button_id=button_id,
)
else:
for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "")
request_id = secrets.token_urlsafe(12)
delivered = await self.send(
target_device_id,
{
"type": "vmix.command",
"protocol": AGENT_PROTOCOL_VERSION,
"request_id": request_id,
"device_id": target_device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"command": command,
},
)
item = {
"index": index,
"function": function,
"ok": bool(delivered),
"reason": "" if delivered else "Agent сейчас offline",
"delivery": "agent-websocket",
"ack_waited": False,
}
results.append(item)
if delivered:
# With no ACK wait the local ON AIR state is intentionally optimistic:
# success means delivery to the live Agent WebSocket, not vMix confirmation.
self._track_runtime_overlay_command(
target_device_id,
command,
sequence_id=sequence_id,
sequence_name=sequence_name,
button_id=button_id,
)
else:
break
else:
async with self._device_vmix_send_lock(target_device_id):
if use_batch:
ack = await self._send_vmix_batch_unlocked(
target_device_id,
assignment_id=assignment_id,
match_id=match_id,
commands=prepared_commands,
timeout=max(4.0, min(8.0, timeout + 2.0)),
)
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "")
item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok"))
item = {
"index": index,
"function": function,
"ok": ok,
"reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")),
}
results.append(item)
if ok:
self._track_runtime_overlay_command(target_device_id, command)
else:
for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "")
try:
ack = await self._send_vmix_command_unlocked(
target_device_id,
assignment_id=assignment_id,
match_id=match_id,
command=command,
timeout=timeout,
)
item = {
"index": index,
"function": function,
"ok": bool(ack.get("ok")),
"reason": str(ack.get("reason") or ack.get("error") or ""),
}
except Exception as error:
detail = getattr(error, "detail", None)
if isinstance(detail, dict):
reason = str(detail.get("message") or detail)
else:
reason = str(detail or error or "vmix_command_error")
item = {
"index": index,
"function": function,
"ok": False,
"reason": reason[:500],
}
results.append(item)
if item["ok"]:
self._track_runtime_overlay_command(target_device_id, command)
if not item["ok"]:
break
failed = [item for item in results if not item["ok"]]
return {
"ok": not failed and len(results) == len(prepared_commands),
"device_id": target_device_id,
"match_id": match_id,
"assignment_id": assignment_id,
"session_token": session_token,
"applied": sum(1 for item in results if item["ok"]),
"attempted": len(results),
"requested": len(prepared_commands),
"failed": len(failed),
"results": results,
"transport": transport,
# BUILD102 compatibility marker: "confirmation": "not_waited" if interactive_shortcut else "ack"
"confirmation": "not_waited" if no_ack_delivery else "ack",
"overlay_state": self._runtime_overlay_payload(target_device_id),
}
async def admin_test_mapping_value(
self,
device_id: str,
*,
input_ref: str,
selected_name: str,
value: str,
field_type: str = "text",
) -> dict[str, Any]:
"""Send one explicit Mapping Editor preview value to a live Agent.
This method is exposed only through the admin-protected mapping route.
It deliberately uses the device's *current* assignment so a stale
Mapping Editor cannot address a different match after the operator has
switched games.
"""
device_id = self.normalise_device_id(device_id)
input_ref = str(input_ref or "").strip()
selected_name = str(selected_name or "").strip()
value = str(value or "")
field_type = str(field_type or "text").strip().lower()
if not input_ref:
raise HTTPException(status_code=422, detail="Не определён vMix Input")
if not selected_name:
raise HTTPException(status_code=422, detail="Не определено поле vMix")
if len(input_ref) > 300 or len(selected_name) > 300 or len(value) > 4000:
raise HTTPException(status_code=422, detail="Слишком длинные параметры тестовой команды")
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None:
raise HTTPException(status_code=404, detail="Agent не найден")
assignment_id = str(device.current_assignment_key or "")
match_id = str(device.current_match_external_id or "")
if not assignment_id or not match_id:
raise HTTPException(status_code=409, detail="У этого Agent сейчас не назначен матч")
if not device.vmix_connected:
raise HTTPException(status_code=409, detail="vMix этого Agent сейчас не подключён")
function = _mapping_value_function(field_type)
ack = await self.send_vmix_command(
device_id,
assignment_id=assignment_id,
match_id=match_id,
command={
"Function": function,
"Input": input_ref,
"SelectedName": selected_name,
"Value": value,
},
)
if not bool(ack.get("ok")):
reason = str(ack.get("reason") or ack.get("error") or "vMix отклонил команду")
raise HTTPException(status_code=502, detail=f"Команда дошла до agent, но не выполнена: {reason}")
return {
"ok": True,
"device_id": device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"function": function,
"input": input_ref,
"selected_name": selected_name,
"value": value,
"agent_ack": ack,
}
async def admin_test_mapping_batch(self, device_id: str, commands: list[Any]) -> dict[str, Any]:
"""Apply many unsaved Mapping Editor values using batch transport when supported."""
device_id = self.normalise_device_id(device_id)
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None:
raise HTTPException(status_code=404, detail="Agent не найден")
assignment_id = str(device.current_assignment_key or "")
match_id = str(device.current_match_external_id or "")
agent_version = str(device.agent_version or "")
if not assignment_id or not match_id:
raise HTTPException(status_code=409, detail="У этого Agent сейчас не назначен матч")
if not device.vmix_connected:
raise HTTPException(status_code=409, detail="vMix этого Agent сейчас не подключён")
result: dict[str, Any] = {
"ok": True, "device_id": device_id, "assignment_id": assignment_id,
"match_id": match_id, "total": len(commands), "applied": 0, "errors": [],
"transport": "batch" if self._agent_supports_batch(agent_version) else "legacy",
}
prepared: list[dict[str, Any]] = []
for index, item in enumerate(commands):
input_ref = str(getattr(item, "input", "") or "").strip()
selected_name = str(getattr(item, "selected_name", "") or "").strip()
value = str(getattr(item, "value", "") or "")
field_type = str(getattr(item, "field_type", "text") or "text").strip().lower()
requested_function = str(getattr(item, "function", "") or "").strip()
data_key = str(getattr(item, "data_key", "") or "")
if not input_ref or not selected_name:
result["errors"].append({"index": index, "key": data_key, "reason": "missing_vmix_target"})
continue
if len(input_ref) > 300 or len(selected_name) > 300 or len(value) > 4000:
result["errors"].append({"index": index, "key": data_key, "reason": "parameters_too_long"})
continue
function = requested_function or _mapping_value_function(field_type)
if function not in _MAPPING_TEST_FUNCTIONS:
result["errors"].append({"index": index, "key": data_key, "reason": "unsupported_function"})
continue
prepared.append({
"index": index, "key": data_key, "field": selected_name,
"command": {"Function": function, "Input": input_ref, "SelectedName": selected_name, "Value": value},
})
if prepared:
use_batch = self._agent_supports_batch(agent_version)
result["transport"] = "chunked_batch" if use_batch else "legacy"
transport = await self._send_mapping_entries_resilient(
device_id, assignment_id=assignment_id, match_id=match_id,
entries=prepared, use_batch=use_batch,
)
result["batch_chunks_total"] = int(transport.get("chunks_total") or 0)
result["batch_chunks_applied"] = int(transport.get("chunks_ok") or 0)
result["batch_chunks_failed"] = int(transport.get("chunks_failed") or 0)
result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
result["batch_retries"] = int(transport.get("retries") or 0)
result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
result["input_groups"] = int(transport.get("input_groups") or 0)
ack_results = transport.get("results") if isinstance(transport.get("results"), list) else []
for pos, item in enumerate(prepared):
item_ack = ack_results[pos] if pos < len(ack_results) and isinstance(ack_results[pos], dict) else {}
if bool(item_ack.get("ok")):
result["applied"] += 1
else:
result["errors"].append({
"index": item["index"], "key": item["key"], "field": item["field"],
"reason": str(item_ack.get("reason") or item_ack.get("error") or "mapping_transport_error"),
})
result["ok"] = not result["errors"]
return result
async def list_for_user(self, user: HockeyUser) -> dict[str, Any]:
now = _utcnow()
visible_since = now - timedelta(minutes=5)
with self.database.session() as session:
rows = list(
session.scalars(
select(VmixDevice)
.where(
(VmixDevice.wfl_user_id == user.id)
| (VmixDevice.last_seen_at >= visible_since)
)
.order_by(desc(VmixDevice.is_active_for_account), desc(VmixDevice.last_seen_at))
)
)
devices = [self._device_payload(row, viewer_user_id=user.id) for row in rows]
live_ids = set(self._live)
for device in devices:
device["online"] = device["device_id"] in live_ids
if device["paired_to_me"]:
device["pair_state"] = "mine"
elif device["paired"]:
device["pair_state"] = "busy"
else:
device["pair_state"] = "free"
active_device_ids = [
item["device_id"] for item in devices
if item["paired_to_me"] and item["active_for_account"]
]
return {
"protocol": AGENT_PROTOCOL_VERSION,
"devices": devices,
# Kept for backward compatibility with older browser bundles.
"active_device_id": active_device_ids[0] if active_device_ids else "",
"active_device_ids": active_device_ids,
}
async def pair_device(self, device_id: str, user: HockeyUser, *, make_active: bool = True) -> dict[str, Any]:
"""Pair one Agent without disabling other Agents owned by the account."""
device_id = self.normalise_device_id(device_id)
if device_id not in self._live:
raise HTTPException(status_code=409, detail="Agent сейчас не подключён к серверу")
now = _utcnow()
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None:
raise HTTPException(status_code=404, detail="Device ID не найден")
if row.wfl_user_id and row.wfl_user_id != user.id:
raise HTTPException(status_code=409, detail="Это устройство уже прикреплено к другому аккаунту")
row.wfl_user_id = user.id
row.login_snapshot = user.login
row.paired_at = row.paired_at or now
if make_active:
row.is_active_for_account = True
session.flush()
payload = self._device_payload(row, viewer_user_id=user.id)
await self.send(
device_id,
{
"type": "pairing.confirmed",
"protocol": AGENT_PROTOCOL_VERSION,
"device_id": device_id,
"account": {"id": user.id, "login": user.login},
"active_for_account": payload["active_for_account"],
},
)
# Do not steal the browser's current match automatically. A match is
# assigned only when this concrete device is selected for that panel.
payload["assignment"] = None
return payload
async def activate_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
"""Enable one Agent. Other enabled Agents remain enabled."""
device_id = self.normalise_device_id(device_id)
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
row.is_active_for_account = True
session.flush()
payload = self._device_payload(row, viewer_user_id=user.id)
await self.send(device_id, {"type": "device.activated", "device_id": device_id})
return payload
async def deactivate_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
"""Stop routing this account's data/commands to one Agent only."""
device_id = self.normalise_device_id(device_id)
now = _utcnow()
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
for assignment in session.scalars(
select(VmixAssignment).where(
and_(VmixAssignment.device_id == row.id, VmixAssignment.active.is_(True))
)
):
assignment.active = False
assignment.ended_at = now
assignment.last_activity_at = now
row.is_active_for_account = False
row.current_match_external_id = ""
row.current_assignment_key = ""
session.flush()
payload = self._device_payload(row, viewer_user_id=user.id)
await self.send(device_id, {"type": "device.deactivated", "device_id": device_id, "reason": "disabled_by_operator"})
return payload
async def unpair_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(device_id)
now = _utcnow()
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
for assignment in session.scalars(
select(VmixAssignment).where(
and_(VmixAssignment.device_id == row.id, VmixAssignment.active.is_(True))
)
):
assignment.active = False
assignment.ended_at = now
row.wfl_user_id = None
row.login_snapshot = ""
row.paired_at = None
row.is_active_for_account = False
row.current_match_external_id = ""
row.current_assignment_key = ""
await self.send(device_id, {"type": "pairing.revoked", "device_id": device_id})
return {"ok": True, "device_id": device_id}
async def assign_current_match(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any] | None:
"""Assign the user's current operator-session match to one concrete Agent."""
with self.database.session() as session:
operator = session.scalar(
select(OperatorSession)
.where(
and_(
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)
)
.order_by(desc(OperatorSession.id))
)
if operator is None or not operator.game_external_id:
return None
game_id = str(operator.game_external_id)
tournament_id = str(operator.tournament_external_id)
session_token = str(operator.session_token or "")
selected_device_id = str(device_id or operator.vmix_device_uuid or "").strip()
return await self.assign_match(
wfl_user_id=user.id,
game_external_id=game_id,
tournament_external_id=tournament_id,
device_id=selected_device_id,
operator_session_token=session_token,
)
async def assign_match(
self,
*,
wfl_user_id: str,
game_external_id: str,
tournament_external_id: str = "",
device_id: str = "",
operator_session_token: str = "",
wait_for_mapping: bool = True,
) -> dict[str, Any] | None:
"""Assign a match to exactly one Agent. Never broadcast to all active Agents."""
game_external_id = str(game_external_id or "").strip()
requested_device_id = str(device_id or "").strip()
operator_session_token = str(operator_session_token or "").strip()
if not game_external_id:
return None
now = _utcnow()
with self.database.session() as session:
device = None
if requested_device_id:
requested_device_id = self.normalise_device_id(requested_device_id)
device = session.scalar(
select(VmixDevice).where(
and_(
VmixDevice.device_uuid == requested_device_id,
VmixDevice.wfl_user_id == str(wfl_user_id),
VmixDevice.is_active_for_account.is_(True),
)
)
)
if device is None:
# BUILD111: a browser may keep a stale localStorage device id after
# reconnect/update. Do not silently leave the new match unassigned.
# Automatic fallback is allowed only when routing is unambiguous:
# exactly one live active Agent, or exactly one active Agent total.
candidates = list(session.scalars(
select(VmixDevice)
.where(
and_(
VmixDevice.wfl_user_id == str(wfl_user_id),
VmixDevice.is_active_for_account.is_(True),
)
)
.order_by(desc(VmixDevice.last_seen_at))
))
live_candidates = [item for item in candidates if item.device_uuid in self._live]
if len(live_candidates) == 1:
device = live_candidates[0]
elif len(candidates) == 1:
device = candidates[0]
if device is None:
return None
if operator_session_token:
operator = session.scalar(
select(OperatorSession).where(
and_(
OperatorSession.session_token == operator_session_token,
OperatorSession.wfl_user_id == str(wfl_user_id),
OperatorSession.status == "active",
)
)
)
if operator is not None:
operator.vmix_device_uuid = device.device_uuid
operator.last_activity_at = now
current = session.scalar(
select(VmixAssignment)
.where(
and_(
VmixAssignment.device_id == device.id,
VmixAssignment.active.is_(True),
)
)
.order_by(desc(VmixAssignment.id))
)
same_binding = bool(
current is not None
and current.game_external_id == game_external_id
and (not operator_session_token or current.operator_session_token == operator_session_token)
)
if same_binding:
current.last_activity_at = now
assignment = current
else:
if current is not None:
current.active = False
current.ended_at = now
current.last_activity_at = now
assignment = VmixAssignment(
assignment_key=secrets.token_urlsafe(18),
device_id=device.id,
wfl_user_id=str(wfl_user_id),
operator_session_token=operator_session_token,
tournament_external_id=str(tournament_external_id or ""),
game_external_id=game_external_id,
active=True,
created_at=now,
last_activity_at=now,
)
session.add(assignment)
session.flush()
device.current_match_external_id = game_external_id
device.current_assignment_key = assignment.assignment_key
device.last_seen_at = device.last_seen_at or now
resolved_device_id = device.device_uuid
payload = self._assignment_payload(assignment, device_id=resolved_device_id)
delivered = await self.send(
resolved_device_id,
{
"type": "match.assign",
"protocol": AGENT_PROTOCOL_VERSION,
**payload,
},
)
payload["delivered"] = delivered
if delivered:
if wait_for_mapping:
payload["mapping_apply"] = await self.apply_mapping_to_device(
resolved_device_id, reason="match_assigned"
)
else:
payload["mapping_apply"] = {
"ok": True,
"deferred": True,
"reason": "match_assigned",
"device_id": resolved_device_id,
}
return payload
async def select_device_for_session(
self,
device_id: str,
user: HockeyUser,
*,
session_token: str = "",
wait_for_mapping: bool = True,
) -> dict[str, Any]:
"""Bind this browser/operator session to one enabled Agent."""
device_id = self.normalise_device_id(device_id)
session_token = str(session_token or "").strip()
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or device.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
if not device.is_active_for_account:
raise HTTPException(status_code=409, detail="Сначала включите получение данных для этого Agent")
operator = None
if session_token:
operator = session.scalar(
select(OperatorSession).where(
and_(
OperatorSession.session_token == session_token,
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)
)
)
if operator is None:
raise HTTPException(status_code=404, detail="Матчевая сессия не найдена")
operator.vmix_device_uuid = device_id
game_id = str(operator.game_external_id or "")
tournament_id = str(operator.tournament_external_id or "")
else:
game_id = ""
tournament_id = ""
assignment = None
if game_id:
assignment = await self.assign_match(
wfl_user_id=user.id,
game_external_id=game_id,
tournament_external_id=tournament_id,
device_id=device_id,
operator_session_token=session_token,
wait_for_mapping=bool(wait_for_mapping),
)
return {
"ok": True,
"device_id": device_id,
"session_token": session_token,
"assignment": assignment,
}
async def reconcile_device(self, device_id: str) -> None:
"""Restore only this Agent's own assignment after reconnect."""
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.wfl_user_id:
return
user_id = device.wfl_user_id
login = device.login_snapshot
active = device.is_active_for_account
assignment = None
if active and device.current_assignment_key:
assignment = session.scalar(
select(VmixAssignment).where(
and_(
VmixAssignment.device_id == device.id,
VmixAssignment.assignment_key == device.current_assignment_key,
VmixAssignment.active.is_(True),
)
)
)
assignment_payload = self._assignment_payload(assignment, device_id=device_id) if assignment is not None else None
await self.send(
device_id,
{
"type": "pairing.confirmed",
"protocol": AGENT_PROTOCOL_VERSION,
"device_id": device_id,
"account": {"id": user_id, "login": login},
"active_for_account": active,
},
)
if active and assignment_payload is not None:
delivered = await self.send(
device_id,
{"type": "match.assign", "protocol": AGENT_PROTOCOL_VERSION, **assignment_payload},
)
if delivered:
await self.apply_mapping_to_device(device_id, reason="agent_reconnected")
async def receive_inventory(self, device_id: str, message: dict[str, Any]) -> None:
inventory = message.get("inventory") if isinstance(message.get("inventory"), dict) else {}
fingerprint = str(inventory.get("fingerprint") or "").strip().lower()
inputs = inventory.get("inputs") if isinstance(inventory.get("inputs"), list) else []
if not re.fullmatch(r"[0-9a-f]{64}", fingerprint) or len(inputs) > 1000:
return
clean_inputs: list[dict[str, Any]] = []
field_count = 0
for raw_input in inputs:
if not isinstance(raw_input, dict):
continue
raw_fields = raw_input.get("fields") if isinstance(raw_input.get("fields"), list) else []
fields: list[dict[str, str]] = []
for raw_field in raw_fields[:500]:
if not isinstance(raw_field, dict):
continue
name = str(raw_field.get("name") or "")[:300]
if not name:
continue
fields.append({
"name": name,
"type": str(raw_field.get("type") or "text")[:32],
"index": str(raw_field.get("index") or "")[:32],
})
field_count += len(fields)
clean_inputs.append({
"key": str(raw_input.get("key") or "")[:128],
"number": str(raw_input.get("number") or "")[:32],
"title": str(raw_input.get("title") or "")[:300],
"type": str(raw_input.get("type") or "")[:64],
"fields": fields,
})
agent_fingerprint = fingerprint
stable_fingerprint = _stable_vmix_inventory_fingerprint(clean_inputs)
clean_inventory = {
"fingerprint": stable_fingerprint,
"agent_fingerprint": agent_fingerprint,
"inputs": clean_inputs,
"input_count": len(clean_inputs),
"field_count": field_count,
"vmix_version": str(inventory.get("vmix_version") or "")[:64],
}
now = _utcnow()
profile_payload: dict[str, Any] | None = None
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None:
return
# Server-side stable fingerprint deliberately ignores Input numbers
# and ordering. Moving an Input in vMix must not detach its Mapping.
device.project_fingerprint = stable_fingerprint
device.project_inventory_json = json.dumps(clean_inventory, ensure_ascii=False, separators=(",", ":"))
device.project_input_count = len(clean_inputs)
device.project_field_count = field_count
device.project_scanned_at = now
profile = self._active_mapping_profile_for_device(session, device)
if profile is not None:
# Refresh the profile inventory only when this project is its exact
# stable identity. A compatibility-recovered profile may be shared
# by another active Agent whose project contains a harmless extra
# Input, so do not make the primary identity bounce between devices.
if str(profile.project_fingerprint or "") == stable_fingerprint:
profile.inventory_json = device.project_inventory_json
profile.updated_at = now
profile_payload = self._mapping_profile_payload(session, profile, include_inventory=False, include_fields=True)
if profile_payload is not None:
await self.send(device_id, {"type": "mapping.assigned", **profile_payload})
else:
await self.send(device_id, {"type": "mapping.missing", "project_fingerprint": stable_fingerprint})
def _mapping_profile_payload(
self,
session: Any,
profile: VmixMappingProfile,
*,
include_inventory: bool = True,
include_fields: bool = True,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": profile.id,
"name": profile.name,
"description": profile.description,
"project_fingerprint": profile.project_fingerprint,
"version": profile.version,
"active": profile.active,
"created_by": profile.created_by,
"updated_by": profile.updated_by,
"created_at": profile.created_at.isoformat() if profile.created_at else "",
"updated_at": profile.updated_at.isoformat() if profile.updated_at else "",
}
if include_inventory:
try:
payload["inventory"] = json.loads(profile.inventory_json or "{}")
except Exception:
payload["inventory"] = {}
if include_fields:
rows = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == profile.id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
payload["fields"] = [
{
"id": row.id,
"graphic": row.graphic,
"data_key": row.data_key,
"vmix_input_key": row.vmix_input_key,
"vmix_input_number": row.vmix_input_number,
"vmix_input_title": row.vmix_input_title,
"vmix_field": row.vmix_field,
"field_type": row.field_type,
"rule": _mapping_rule_payload(row.rule_json),
"enabled": row.enabled,
"sort_order": row.sort_order,
}
for row in rows
]
return payload
def _mapping_device_display_payload(self, session: Any, profile: VmixMappingProfile) -> dict[str, Any]:
source_id = self._runtime_mapping_source_id(profile)
if source_id:
source = session.get(VmixMappingProfile, source_id)
if source is not None:
return {
"id": source.id,
"name": source.name,
"version": source.version,
"runtime_profile_id": profile.id,
"source_profile_id": source.id,
}
return {"id": profile.id, "name": profile.name, "version": profile.version, "source_profile_id": profile.id}
async def list_mapping_devices(self) -> dict[str, Any]:
with self.database.session() as session:
rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
items = []
for row in rows:
profile = self._active_mapping_profile_for_device(session, row)
items.append({
"device_id": row.device_uuid,
"name": row.name or row.hostname or row.device_uuid,
"hostname": row.hostname,
"online": row.device_uuid in self._live,
"vmix_connected": bool(row.vmix_connected),
"vmix_version": row.vmix_version,
"owner": row.login_snapshot,
"project_fingerprint": row.project_fingerprint,
"input_count": row.project_input_count,
"field_count": row.project_field_count,
"scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
"mapping": (self._mapping_device_display_payload(session, profile) if profile else None),
})
return {"devices": items}
@staticmethod
def _prepared_inventory_from_row(device: VmixDevice | None) -> dict[str, Any]:
if device is None:
return {}
try:
parsed = json.loads(device.project_inventory_json or "{}")
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@staticmethod
def _prepared_find_input(inventory: dict[str, Any], *, key: str = "", number: str = "", title: str = "") -> dict[str, Any] | None:
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
key = str(key or "").strip()
number = str(number or "").strip()
title = str(title or "").strip()
if key:
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("key") or "").strip() == key), None)
if found is not None:
return found
if number:
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("number") or "").strip() == number), None)
if found is not None:
return found
if title:
matches = [item for item in inputs if isinstance(item, dict) and str(item.get("title") or "").strip() == title]
if len(matches) == 1:
return matches[0]
return None
def _prepared_device_for_user(self, session: Any, user: HockeyUser, requested_device_id: str = "") -> VmixDevice:
requested = str(requested_device_id or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
if device is None:
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
return device
rows = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
if not rows:
raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
live = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected)), None)
return live or rows[0]
@staticmethod
def _prepared_title_payload(row: VmixPreparedTitle, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
try:
fields = json.loads(row.field_values_json or "{}")
if not isinstance(fields, dict):
fields = {}
except Exception:
fields = {}
clone_key = str(row.clone_input_key or "")
clone_number = str(row.clone_input_number or "")
clone_title = str(row.clone_input_title or "")
if inventory:
match = VmixAgentHub._prepared_find_input(inventory, key=clone_key, number=clone_number, title=clone_title)
if match is not None:
clone_key = str(match.get("key") or clone_key)
clone_number = str(match.get("number") or clone_number)
clone_title = str(match.get("title") or clone_title)
return {
"id": row.id,
"name": row.name,
"game_id": row.game_external_id,
"device_id": row.device_uuid,
"source_kind": row.source_kind,
"source_ref": row.source_ref,
"source_input": {
"key": row.source_input_key,
"number": row.source_input_number,
"title": row.source_input_title,
},
"clone_input": {"key": clone_key, "number": clone_number, "title": clone_title},
"field_values": fields,
"created_at": row.created_at.isoformat() if row.created_at else "",
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
@staticmethod
def _prepared_saved_fields(row: VmixPreparedTitle) -> dict[str, dict[str, str]]:
try:
payload = json.loads(row.field_values_json or "{}")
except Exception:
payload = {}
if not isinstance(payload, dict):
return {}
result: dict[str, dict[str, str]] = {}
for raw_name, raw_entry in list(payload.items())[:500]:
name = str(raw_name or "").strip()[:300]
if not name:
continue
if isinstance(raw_entry, dict):
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
field_type = str(raw_entry.get("type") or "text").strip().lower()
else:
value = "" if raw_entry is None else str(raw_entry)
field_type = "text"
if field_type not in {"text", "image", "source", "color", "colour"}:
field_type = "text"
result[name] = {"value": value[:8000], "type": field_type}
return result
@staticmethod
def _prepared_next_default_name(session: Any, *, user_id: str, device_uuid: str, game_id: str) -> str:
query = select(VmixPreparedTitle.name).where(VmixPreparedTitle.wfl_user_id == user_id)
if device_uuid:
query = query.where(VmixPreparedTitle.device_uuid == device_uuid)
if game_id:
query = query.where(VmixPreparedTitle.game_external_id == game_id)
used: set[int] = set()
for raw_name in session.scalars(query):
match = re.fullmatch(r"\s*Заготовка\s+(\d+)\s*", str(raw_name or ""), flags=re.IGNORECASE)
if match:
used.add(int(match.group(1)))
number = 1
while number in used:
number += 1
return f"Заготовка {number}"
@staticmethod
def _prepared_field_update_commands(
*,
input_ref: str,
available_fields: list[dict[str, Any]],
raw_values: Any,
existing_fields: dict[str, dict[str, str]] | None = None,
) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]]]:
values = raw_values if isinstance(raw_values, dict) else {}
saved_fields = dict(existing_fields or {})
allowed: dict[str, dict[str, Any]] = {
str(field.get("name") or ""): field
for field in available_fields
if isinstance(field, dict) and str(field.get("name") or "")
}
# An old Agent inventory may temporarily miss fields of the freshly
# cloned Input. Existing saved fields are still safe edit targets.
for name, entry in saved_fields.items():
allowed.setdefault(name, {"name": name, "type": str(entry.get("type") or "text")})
commands: list[dict[str, Any]] = []
for raw_name, raw_entry in list(values.items())[:240]:
name = str(raw_name or "").strip()[:300]
field = allowed.get(name)
if not name or field is None:
continue
if isinstance(raw_entry, dict):
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
requested_type = str(raw_entry.get("type") or "").strip().lower()
else:
value = "" if raw_entry is None else str(raw_entry)
requested_type = ""
existing_type = str(saved_fields.get(name, {}).get("type") or "").strip().lower()
field_type = str(field.get("type") or requested_type or existing_type or "text").strip().lower()
if field_type not in {"text", "image", "source", "color", "colour"}:
field_type = requested_type if requested_type in {"text", "image", "source", "color", "colour"} else "text"
saved_fields[name] = {"value": value[:8000], "type": field_type}
# Empty text deliberately clears inherited text. Some vMix title
# engines reject an empty image/source/color, so leave those alone.
if not value and field_type in {"image", "source", "color", "colour"}:
continue
commands.append({
"Function": _mapping_value_function(field_type),
"Input": input_ref,
"SelectedName": name,
"Value": value,
})
return saved_fields, commands
async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
resolved_game = str(game_id or device.current_match_external_id or "").strip()
query = select(VmixPreparedTitle).where(VmixPreparedTitle.wfl_user_id == user.id)
if resolved_game:
query = query.where(VmixPreparedTitle.game_external_id == resolved_game)
if device.device_uuid:
query = query.where(VmixPreparedTitle.device_uuid == device.device_uuid)
rows = list(session.scalars(query.order_by(desc(VmixPreparedTitle.created_at), desc(VmixPreparedTitle.id))))
inventory = self._prepared_inventory_from_row(device)
# Refresh clone key/title opportunistically after Agent inventory catches up.
for row in rows:
found = self._prepared_find_input(
inventory,
key=str(row.clone_input_key or ""),
number=str(row.clone_input_number or ""),
title=str(row.clone_input_title or ""),
)
if found is not None:
row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128]
row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32]
found_title = str(found.get("title") or "")[:300]
# Right after SetInputName the Agent inventory can lag by one
# scan. Do not overwrite a freshly stored desired title with
# the old inherited source name during that short window.
if found_title and (str(row.clone_input_title or "") != str(row.name or "") or found_title == str(row.name or "")):
row.clone_input_title = found_title
items = [self._prepared_title_payload(row, inventory) for row in rows]
return {
"device_id": device.device_uuid,
"game_id": resolved_game,
"items": items,
}
def _prepared_mapping_context(self, session: Any, device: VmixDevice, user: HockeyUser) -> tuple[VmixMappingProfile | None, dict[str, Any], dict[str, Any]]:
profile = self._active_mapping_profile_for_device(session, device)
if profile is None:
return None, {}, {}
assignment = session.scalar(
select(VmixAssignment)
.where(and_(VmixAssignment.device_id == device.id, VmixAssignment.active.is_(True)))
.order_by(desc(VmixAssignment.id))
)
context = {
"game_id": str(device.current_match_external_id or ""),
"device_id": device.device_uuid,
"assignment_id": str(device.current_assignment_key or ""),
"tournament_id": str(assignment.tournament_external_id or "") if assignment is not None else "",
}
preference = session.get(UserPreference, user.id)
if preference is not None:
language = str(preference.vmix_language or preference.display_language or "").strip().lower()
if language in {"ru", "en"}:
context["ui_language"] = language
inventory = self._prepared_inventory_from_row(device)
return profile, context, inventory
async def prepared_mapping_sources(self, user: HockeyUser, *, device_id: str = "", panel_id: str = "") -> dict[str, Any]:
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
profile, _context, inventory = self._prepared_mapping_context(session, device, user)
if profile is None:
return {"device_id": device.device_uuid, "profile_id": 0, "items": []}
fields = list(session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
if panel_id:
prefix = f"player_select.{panel_id}."
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
grouped: dict[str, dict[str, Any]] = {}
for field in fields:
identity = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
if not identity:
continue
item = grouped.setdefault(identity, {
"key": str(field.vmix_input_key or ""),
"number": str(field.vmix_input_number or ""),
"title": str(field.vmix_input_title or ""),
"mapped_fields": 0,
})
item["mapped_fields"] += 1
for item in grouped.values():
found = self._prepared_find_input(inventory, key=item["key"], number=item["number"], title=item["title"])
if found is not None:
item.update({
"key": str(found.get("key") or item["key"]),
"number": str(found.get("number") or item["number"]),
"title": str(found.get("title") or item["title"]),
"type": str(found.get("type") or ""),
})
items = sorted(grouped.values(), key=lambda value: (int(value.get("number") or 999999) if str(value.get("number") or "").isdigit() else 999999, str(value.get("title") or "").casefold()))
return {"device_id": device.device_uuid, "profile_id": profile.id, "items": items}
async def prepared_mapping_snapshot(
self,
user: HockeyUser,
*,
device_id: str = "",
input_key: str = "",
input_number: str = "",
input_title: str = "",
panel_id: str = "",
) -> dict[str, Any]:
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
profile, context, inventory = self._prepared_mapping_context(session, device, user)
if profile is None:
return {"device_id": device.device_uuid, "profile_id": 0, "fields": []}
source = self._prepared_find_input(inventory, key=input_key, number=input_number, title=input_title)
source_key = str(source.get("key") or input_key or "") if source else str(input_key or "")
source_number = str(source.get("number") or input_number or "") if source else str(input_number or "")
source_title = str(source.get("title") or input_title or "") if source else str(input_title or "")
fields = list(session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
if panel_id:
prefix = f"player_select.{panel_id}."
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
def target_matches(field: VmixMappingField) -> bool:
if source_key and str(field.vmix_input_key or "") == source_key:
return True
if source_title and str(field.vmix_input_title or "") == source_title:
return True
if source_number and str(field.vmix_input_number or "") == source_number:
return True
return False
fields = [field for field in fields if target_matches(field)]
catalog = self.mapping_data.data_catalog(user, context)
by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")}
result_fields = []
for field in fields:
item = by_key.get(str(field.data_key or ""))
if item is None:
continue
result_fields.append({
"name": str(field.vmix_field or ""),
"type": str(field.field_type or "text"),
"data_key": str(field.data_key or ""),
"value": "" if item.get("value") is None else str(item.get("value")),
})
return {
"device_id": device.device_uuid,
"game_id": str(device.current_match_external_id or ""),
"profile_id": profile.id,
"input": {"key": source_key, "number": source_number, "title": source_title},
"fields": result_fields,
}
async def create_prepared_title(self, user: HockeyUser, payload: Any) -> dict[str, Any]:
requested_device = str(getattr(payload, "device_id", "") or "").strip()
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, requested_device)
current_game_id = str(device.current_match_external_id or "").strip()
inventory = self._prepared_inventory_from_row(device)
source = self._prepared_find_input(
inventory,
key=str(getattr(payload, "source_input_key", "") or ""),
number=str(getattr(payload, "source_input_number", "") or ""),
title=str(getattr(payload, "source_input_title", "") or ""),
)
if source is None:
raise HTTPException(status_code=404, detail="Исходный vMix Input не найден в текущем проекте")
source_key = str(source.get("key") or "")
source_number = str(source.get("number") or "")
source_title = str(source.get("title") or "")
source_ref = source_key or source_number or source_title
before_inputs = [item for item in (inventory.get("inputs") or []) if isinstance(item, dict)]
before_keys = {str(item.get("key") or "") for item in before_inputs if str(item.get("key") or "")}
before_numbers = {str(item.get("number") or "") for item in before_inputs if str(item.get("number") or "")}
numeric_numbers = [int(value) for value in before_numbers if value.isdigit()]
expected_number = str((max(numeric_numbers) if numeric_numbers else len(before_inputs)) + 1)
create_result = await self.run_vmix_sequence_for_user(
user,
[{"Function": "CreateVirtualInput", "Input": source_ref}],
device_id=device.device_uuid,
session_token=session_token,
timeout=6.0,
)
target_device_id = str(create_result.get("device_id") or device.device_uuid)
match_id = str(create_result.get("match_id") or current_game_id or "").strip()
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
if requested_name:
title_name = requested_name
else:
with self.database.session() as session:
title_name = self._prepared_next_default_name(
session,
user_id=user.id,
device_uuid=target_device_id,
game_id=match_id,
)
clone: dict[str, Any] | None = None
current_inputs: list[dict[str, Any]] = []
for _ in range(12):
await asyncio.sleep(0.18)
with self.database.session() as session:
refreshed = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
current_inventory = self._prepared_inventory_from_row(refreshed)
current_inputs = [item for item in (current_inventory.get("inputs") or []) if isinstance(item, dict)]
candidates = [item for item in current_inputs if (str(item.get("key") or "") and str(item.get("key") or "") not in before_keys) or (str(item.get("number") or "") and str(item.get("number") or "") not in before_numbers)]
if candidates:
clone = sorted(candidates, key=lambda item: int(item.get("number") or 0) if str(item.get("number") or "").isdigit() else 0)[-1]
break
clone_key = str(clone.get("key") or "") if clone else ""
clone_number = str(clone.get("number") or expected_number) if clone else expected_number
clone_ref = clone_key or clone_number or str(clone.get("title") or source_title if clone else source_title)
raw_values = getattr(payload, "field_values", {})
source_fields = source.get("fields") if isinstance(source.get("fields"), list) else []
saved_fields, field_commands = self._prepared_field_update_commands(
input_ref=clone_ref,
available_fields=source_fields,
raw_values=raw_values,
)
# BUILD99: the prepared title name is also the actual vMix Input display
# name. vMix exposes SetInputName through the Shortcut/API surface.
commands: list[dict[str, Any]] = [
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
*field_commands,
]
# vMix supports selecting categories via API, but does not expose a
# supported shortcut to create/label a custom category or assign an
# Input to it. Keep the safe fallback requested by the operator: the
# prepared title stays at the end of the project. CreateVirtualInput
# normally appends already; MoveInput makes that deterministic if the
# project reordered while the clone was being discovered.
current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()]
end_position = max(current_numbers) if current_numbers else 0
if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position:
commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)})
clone_number = str(end_position)
set_result = await self.run_vmix_sequence_for_user(
user,
commands,
device_id=target_device_id,
session_token=session_token,
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
)
source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual"
source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
with self.database.session() as session:
row = VmixPreparedTitle(
wfl_user_id=user.id,
game_external_id=match_id,
device_uuid=target_device_id,
name=title_name,
source_kind=source_kind,
source_ref=source_ref_name,
source_input_key=source_key,
source_input_number=source_number,
source_input_title=source_title,
clone_input_key=clone_key,
clone_input_number=clone_number,
clone_input_title=title_name,
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
created_by=user.id,
created_at=_utcnow(),
updated_at=_utcnow(),
)
session.add(row)
session.flush()
result = self._prepared_title_payload(row)
result["ok"] = True
result["create_result"] = create_result
result["set_result"] = set_result
result["placement"] = {
"requested_category": "Заготовки",
"mode": "end",
"category_supported": False,
"reason": "vmix_shortcut_api_has_no_input_category_assignment",
}
return result
async def update_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
row = session.get(VmixPreparedTitle, int(prepared_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Заготовка не найдена")
requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
device = self._prepared_device_for_user(session, user, requested_device)
inventory = self._prepared_inventory_from_row(device)
clone = self._prepared_find_input(
inventory,
key=str(row.clone_input_key or ""),
number=str(row.clone_input_number or ""),
title=str(row.clone_input_title or row.name or ""),
)
source = self._prepared_find_input(
inventory,
key=str(row.source_input_key or ""),
number=str(row.source_input_number or ""),
title=str(row.source_input_title or ""),
)
clone_ref = str(clone.get("key") or clone.get("number") or "") if clone is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
if not clone_ref:
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
old_fields = self._prepared_saved_fields(row)
available_fields = clone.get("fields") if clone is not None and isinstance(clone.get("fields"), list) else []
if not available_fields and source is not None and isinstance(source.get("fields"), list):
available_fields = source.get("fields")
old_name = str(row.name or "").strip()
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
title_name = requested_name or old_name
if not title_name:
title_name = self._prepared_next_default_name(
session,
user_id=user.id,
device_uuid=str(row.device_uuid or device.device_uuid or ""),
game_id=str(row.game_external_id or device.current_match_external_id or ""),
)
saved_fields, field_commands = self._prepared_field_update_commands(
input_ref=clone_ref,
available_fields=available_fields,
raw_values=getattr(payload, "field_values", {}),
existing_fields=old_fields,
)
target_device_id = device.device_uuid
commands: list[dict[str, Any]] = [
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
*field_commands,
]
set_result = await self.run_vmix_sequence_for_user(
user,
commands,
device_id=target_device_id,
session_token=session_token,
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
)
with self.database.session() as session:
row = session.get(VmixPreparedTitle, int(prepared_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Заготовка не найдена")
row.name = title_name
row.clone_input_title = title_name[:300]
if clone is not None:
row.clone_input_key = str(clone.get("key") or row.clone_input_key or "")[:128]
row.clone_input_number = str(clone.get("number") or row.clone_input_number or "")[:32]
row.field_values_json = json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":"))
row.updated_at = _utcnow()
result = self._prepared_title_payload(row)
result["ok"] = True
result["set_result"] = set_result
result["updated_existing_input"] = True
return result
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
with self.database.session() as session:
row = session.get(VmixPreparedTitle, int(prepared_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Заготовка не найдена")
requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
device = self._prepared_device_for_user(session, user, requested_device)
inventory = self._prepared_inventory_from_row(device)
found = self._prepared_find_input(
inventory,
key=str(row.clone_input_key or ""),
number=str(row.clone_input_number or ""),
title=str(row.clone_input_title or ""),
)
input_ref = str(found.get("key") or found.get("number") or "") if found is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
if not input_ref:
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
return await self.run_vmix_sequence_for_user(
user,
[{"Function": "PreviewInput", "Input": input_ref}],
device_id=device.device_uuid,
session_token=str(getattr(payload, "session_token", "") or ""),
timeout=4.0,
)
async def delete_prepared_title(self, prepared_id: int, user: HockeyUser) -> dict[str, Any]:
with self.database.session() as session:
row = session.get(VmixPreparedTitle, int(prepared_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Заготовка не найдена")
# Deliberately do not RemoveInput in vMix: deleting a web list entry must
# never unexpectedly destroy a studio graphic that may already be used.
session.delete(row)
return {"ok": True, "id": int(prepared_id), "vmix_input_removed": False}
async def latest_vmix_inventory(self) -> dict[str, Any]:
"""Return the most recent connected vMix project inventory for editor helpers.
Inventory is reported by Hockey Agent and stored on the device row, so the
UI Builder can populate Input/SelectedName controls without talking to the
operator's local vMix HTTP API directly.
"""
with self.database.session() as session:
rows = list(session.scalars(select(VmixDevice).order_by(desc(VmixDevice.last_seen_at))))
devices: list[dict[str, Any]] = []
selected: VmixDevice | None = None
for row in rows:
has_inventory = bool(str(row.project_inventory_json or "").strip())
online = row.device_uuid in self._live
devices.append({
"device_id": row.device_uuid,
"name": row.name or row.hostname or row.device_uuid,
"hostname": row.hostname or "",
"online": online,
"vmix_connected": bool(row.vmix_connected),
"vmix_version": row.vmix_version or "",
"input_count": int(row.project_input_count or 0),
"field_count": int(row.project_field_count or 0),
"scanned_at": row.project_scanned_at.isoformat() if row.project_scanned_at else "",
"has_inventory": has_inventory,
})
if selected is None and has_inventory and online and bool(row.vmix_connected):
selected = row
if selected is None:
selected = next((row for row in rows if str(row.project_inventory_json or "").strip() and bool(row.vmix_connected)), None)
if selected is None:
selected = next((row for row in rows if str(row.project_inventory_json or "").strip()), None)
inventory: dict[str, Any] = {}
if selected is not None:
try:
parsed = json.loads(selected.project_inventory_json or "{}")
inventory = parsed if isinstance(parsed, dict) else {}
except Exception:
inventory = {}
return {
"device_id": selected.device_uuid if selected is not None else "",
"device_name": (selected.name or selected.hostname or selected.device_uuid) if selected is not None else "",
"online": bool(selected is not None and selected.device_uuid in self._live),
"vmix_connected": bool(selected.vmix_connected) if selected is not None else False,
"inventory": inventory,
"devices": devices,
}
async def vmix_inventory_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]:
"""Return vMix inventory for one device owned by the current operator.
This endpoint is intentionally read-only and available to regular hockey
operators so settings pages can store stable Input keys without requiring
the UI Builder editor PIN.
"""
requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else ""
with self.database.session() as session:
query = select(VmixDevice).where(VmixDevice.wfl_user_id == user.id)
rows = list(session.scalars(query.order_by(desc(VmixDevice.last_seen_at))))
selected: VmixDevice | None = None
if requested:
selected = next((row for row in rows if row.device_uuid == requested), None)
if selected is None:
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
else:
selected = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected) and str(row.project_inventory_json or "").strip()), None)
if selected is None:
selected = next((row for row in rows if str(row.project_inventory_json or "").strip()), None)
inventory: dict[str, Any] = {}
if selected is not None:
try:
parsed = json.loads(selected.project_inventory_json or "{}")
inventory = parsed if isinstance(parsed, dict) else {}
except Exception:
inventory = {}
return {
"device_id": selected.device_uuid if selected is not None else "",
"device_name": (selected.name or selected.hostname or selected.device_uuid) if selected is not None else "",
"online": bool(selected is not None and selected.device_uuid in self._live),
"vmix_connected": bool(selected.vmix_connected) if selected is not None else False,
"inventory": inventory,
}
@staticmethod
def _portable_text(value: Any) -> str:
return " ".join(str(value or "").strip().casefold().split())
@classmethod
def _rebind_portable_mapping_fields(
cls,
fields: list[dict[str, Any]],
inventory: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Rebind saved Mapping targets to another vMix project inventory.
Resolution order is deliberately conservative: stable Input key first,
then a unique title, then a unique positional number as a legacy fallback.
The selected vMix field itself must also exist on the resolved Input.
"""
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
by_key: dict[str, dict[str, Any]] = {}
by_title: dict[str, list[dict[str, Any]]] = {}
by_number: dict[str, list[dict[str, Any]]] = {}
for item in inputs:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
title = cls._portable_text(item.get("title"))
number = str(item.get("number") or "").strip()
if key:
by_key[key] = item
if title:
by_title.setdefault(title, []).append(item)
if number:
by_number.setdefault(number, []).append(item)
rebound: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
matched_by = {"key": 0, "title": 0, "number": 0}
seen: set[tuple[str, str, str]] = set()
for index, raw in enumerate((fields or [])[:2000]):
if not isinstance(raw, dict):
skipped.append({"index": index, "reason": "invalid_field"})
continue
data_key = str(raw.get("data_key") or "").strip()
vmix_field = str(raw.get("vmix_field") or "").strip()
if not data_key or not vmix_field:
skipped.append({"index": index, "data_key": data_key, "vmix_field": vmix_field, "reason": "missing_mapping_key"})
continue
source_key = str(raw.get("vmix_input_key") or "").strip()
source_title = str(raw.get("vmix_input_title") or "").strip()
source_number = str(raw.get("vmix_input_number") or "").strip()
target = by_key.get(source_key) if source_key else None
resolution = "key" if target is not None else ""
if target is None and source_title:
title_matches = by_title.get(cls._portable_text(source_title), [])
if len(title_matches) == 1:
target = title_matches[0]
resolution = "title"
elif len(title_matches) > 1:
skipped.append({"index": index, "data_key": data_key, "input": source_title, "vmix_field": vmix_field, "reason": "ambiguous_input_title"})
continue
if target is None and source_number:
number_matches = by_number.get(source_number, [])
if len(number_matches) == 1:
target = number_matches[0]
resolution = "number"
elif len(number_matches) > 1:
skipped.append({"index": index, "data_key": data_key, "input": source_number, "vmix_field": vmix_field, "reason": "ambiguous_input_number"})
continue
if target is None:
skipped.append({"index": index, "data_key": data_key, "input": source_title or source_key or source_number, "vmix_field": vmix_field, "reason": "input_not_found"})
continue
target_fields = target.get("fields") if isinstance(target.get("fields"), list) else []
exact = next((f for f in target_fields if isinstance(f, dict) and str(f.get("name") or "").strip() == vmix_field), None)
selected_name = vmix_field
if exact is None:
folded = cls._portable_text(vmix_field)
folded_matches = [f for f in target_fields if isinstance(f, dict) and cls._portable_text(f.get("name")) == folded]
if len(folded_matches) == 1:
selected_name = str(folded_matches[0].get("name") or "").strip()
else:
skipped.append({
"index": index, "data_key": data_key,
"input": str(target.get("title") or target.get("key") or target.get("number") or ""),
"vmix_field": vmix_field,
"reason": "field_not_found" if not folded_matches else "ambiguous_field",
})
continue
target_key = str(target.get("key") or "").strip()
target_title = str(target.get("title") or "").strip()
target_number = str(target.get("number") or "").strip()
dedupe = (data_key, target_key or target_title or target_number, selected_name)
if dedupe in seen:
skipped.append({"index": index, "data_key": data_key, "input": target_title, "vmix_field": selected_name, "reason": "duplicate_target"})
continue
seen.add(dedupe)
matched_by[resolution or "title"] = matched_by.get(resolution or "title", 0) + 1
rebound.append({
"graphic": str(raw.get("graphic") or "")[:100],
"data_key": data_key[:200],
"vmix_input_key": target_key[:128],
"vmix_input_number": target_number[:32],
"vmix_input_title": target_title[:300],
"vmix_field": selected_name[:300],
"field_type": str(raw.get("field_type") or "text")[:32],
"rule": dict(raw.get("rule") or {}) if isinstance(raw.get("rule"), dict) else {},
"enabled": bool(raw.get("enabled", True)),
"sort_order": len(rebound),
})
return rebound, {
"total": min(len(fields or []), 2000),
"mapped": len(rebound),
"skipped": len(skipped),
"matched_by": matched_by,
"skipped_items": skipped[:200],
}
@staticmethod
def _portable_profile_name(session: Any, desired: str, fallback: str) -> str:
base = str(desired or fallback or "Imported Mapping").strip()[:200] or "Imported Mapping"
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == base)) is None:
return base
stem = base[:185]
for number in range(2, 1000):
candidate = f"{stem} ({number})"[:200]
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == candidate)) is None:
return candidate
return f"{stem} {secrets.token_hex(3)}"[:200]
def _create_portable_profile_for_device(
self,
session: Any,
*,
device: VmixDevice,
fields: list[dict[str, Any]],
name: str,
description: str,
user: HockeyUser,
replace_existing: bool,
) -> tuple[VmixMappingProfile, dict[str, Any]]:
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
rebound, report = self._rebind_portable_mapping_fields(fields, inventory)
if not rebound:
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи Mapping с выбранным vMix")
conflict = session.scalar(
select(VmixMappingProfile)
.where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True)))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if conflict is not None:
if not replace_existing:
raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{conflict.name}». Разрешите замену при переносе.")
conflict.active = False
conflict.updated_by = user.login
conflict.updated_at = _utcnow()
now = _utcnow()
profile = VmixMappingProfile(
name=self._portable_profile_name(session, name, "Imported Mapping"),
description=str(description or "")[:4000],
project_fingerprint=str(device.project_fingerprint or "")[:64],
inventory_json=device.project_inventory_json or "{}",
version=1,
active=True,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(profile)
session.flush()
for index, item in enumerate(rebound):
session.add(VmixMappingField(
profile_id=profile.id,
graphic=str(item.get("graphic") or "")[:100],
data_key=str(item.get("data_key") or "")[:200],
vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
vmix_field=str(item.get("vmix_field") or "")[:300],
field_type=str(item.get("field_type") or "text")[:32],
rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
enabled=bool(item.get("enabled", True)),
sort_order=index,
))
session.flush()
return profile, report
@staticmethod
def _runtime_mapping_profile_name(source_profile_id: int, target_fingerprint: str) -> str:
"""Deterministic hidden profile used only to adapt one saved config to another vMix."""
safe_fp = re.sub(r"[^a-zA-Z0-9]+", "", str(target_fingerprint or ""))[:32] or "project"
return f"__AUTO_MAPPING__{int(source_profile_id)}__{safe_fp}"[:200]
@staticmethod
def _runtime_mapping_source_id(profile: VmixMappingProfile | None) -> int:
if profile is None:
return 0
match = re.match(r"^__AUTO_MAPPING__(\d+)__", str(profile.name or ""))
return int(match.group(1)) if match else 0
def _upsert_runtime_mapping_for_device(
self,
session: Any,
*,
source: VmixMappingProfile,
source_fields: list[dict[str, Any]],
device: VmixDevice,
user: HockeyUser,
) -> tuple[VmixMappingProfile, dict[str, Any]]:
"""Create/update a hidden rebinding of a saved config for one concrete vMix project."""
try:
inventory = json.loads(device.project_inventory_json or "{}")
except Exception:
inventory = {}
rebound, report = self._rebind_portable_mapping_fields(source_fields, inventory)
if not rebound:
raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи выбранного Mapping с вашим vMix")
target_fingerprint = str(device.project_fingerprint or "")[:64]
runtime_name = self._runtime_mapping_profile_name(source.id, target_fingerprint)
runtime = session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == runtime_name))
now = _utcnow()
# Only one concrete mapping can drive a vMix project at a time.
for other in session.scalars(
select(VmixMappingProfile).where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
))
):
if runtime is not None and other.id == runtime.id:
continue
other.active = False
other.updated_by = user.login
other.updated_at = now
if runtime is None:
runtime = VmixMappingProfile(
name=runtime_name,
description=f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000],
project_fingerprint=target_fingerprint,
inventory_json=device.project_inventory_json or "{}",
version=1,
active=True,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(runtime)
session.flush()
else:
runtime.description = f"Техническая адаптация Mapping #{source.id}: {source.name}"[:4000]
runtime.project_fingerprint = target_fingerprint
runtime.inventory_json = device.project_inventory_json or "{}"
runtime.version = int(runtime.version or 0) + 1
runtime.active = True
runtime.updated_by = user.login
runtime.updated_at = now
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
session.flush()
for index, item in enumerate(rebound):
session.add(VmixMappingField(
profile_id=runtime.id,
graphic=str(item.get("graphic") or "")[:100],
data_key=str(item.get("data_key") or "")[:200],
vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
vmix_field=str(item.get("vmix_field") or "")[:300],
field_type=str(item.get("field_type") or "text")[:32],
rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
enabled=bool(item.get("enabled", True)),
sort_order=index,
))
session.flush()
return runtime, report
async def use_mapping_profile_for_user(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
"""Use any saved Mapping config on the Agent selected for the user's current match."""
requested = str(getattr(payload, "device_id", "") or "").strip()
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None or self._runtime_mapping_source_id(source):
raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")
operator = None
if session_token:
operator = session.scalar(select(OperatorSession).where(and_(
OperatorSession.session_token == session_token,
OperatorSession.wfl_user_id == user.id,
OperatorSession.status == "active",
)))
if operator is None:
operator = session.scalar(
select(OperatorSession)
.where(and_(OperatorSession.wfl_user_id == user.id, OperatorSession.status == "active"))
.order_by(desc(OperatorSession.id))
)
if not requested and operator is not None:
requested = str(operator.vmix_device_uuid or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(
VmixDevice.device_uuid == requested,
VmixDevice.wfl_user_id == user.id,
VmixDevice.is_active_for_account.is_(True),
)))
else:
candidates = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
device = candidates[0] if len(candidates) == 1 else None
if device is not None:
requested = str(device.device_uuid)
if device is None:
raise HTTPException(status_code=409, detail="Не удалось определить ваш Agent. Выберите Agent для текущей сессии матча.")
if not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Ваш Agent ещё не передал структуру vMix")
if not bool(device.vmix_connected):
raise HTTPException(status_code=409, detail="На вашем Agent сейчас не подключён vMix")
# Make sure this concrete Agent is attached to the current match before sending values.
assignment = await self.assign_current_match(user, device_id=requested)
if assignment is None:
raise HTTPException(status_code=409, detail="Сначала откройте матч в основном интерфейсе")
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == requested))
if source is None or device is None:
raise HTTPException(status_code=404, detail="Mapping или Agent больше недоступен")
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
source_fields = list(source_payload.get("fields") or [])
target_fingerprint = str(device.project_fingerprint or "")
if str(source.project_fingerprint or "") == target_fingerprint:
now = _utcnow()
for other in session.scalars(select(VmixMappingProfile).where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
VmixMappingProfile.id != source.id,
))):
other.active = False
other.updated_by = user.login
other.updated_at = now
source.active = True
source.updated_by = user.login
source.updated_at = now
runtime = source
report = {
"total": len(source_fields), "mapped": len(source_fields), "skipped": 0,
"matched_by": {"key": len(source_fields), "title": 0, "number": 0},
"skipped_items": [], "reused": True,
}
else:
runtime, report = self._upsert_runtime_mapping_for_device(
session, source=source, source_fields=source_fields, device=device, user=user,
)
runtime_id = runtime.id
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = await self.apply_mapping_to_device(requested, reason="mapping_config_selected")
return {
"ok": True,
"device_id": requested,
"profile": {"id": source_payload.get("id"), "name": source_payload.get("name"), "version": source_payload.get("version")},
"runtime_profile_id": runtime_id,
"report": report,
"applied": applied,
}
async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id)
if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
payload = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
fields = []
for row in payload.get("fields") or []:
fields.append({key: value for key, value in row.items() if key != "id"})
inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
return {
"format": "hockey-vmix-mapping",
"schema_version": 1,
"exported_at": _utcnow().isoformat(),
"profile": {
"name": payload.get("name") or "",
"description": payload.get("description") or "",
"source_project_fingerprint": payload.get("project_fingerprint") or "",
"source_inventory": {
"input_count": inventory.get("input_count", 0),
"field_count": inventory.get("field_count", 0),
"vmix_version": inventory.get("vmix_version", ""),
},
"fields": fields,
},
}
async def copy_mapping_profile_to_device(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(payload.device_id)
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
target_fingerprint = str(device.project_fingerprint or "")
if str(source.project_fingerprint or "") == target_fingerprint:
other = session.scalar(
select(VmixMappingProfile)
.where(and_(
VmixMappingProfile.project_fingerprint == target_fingerprint,
VmixMappingProfile.active.is_(True),
VmixMappingProfile.id != source.id,
))
.order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
)
if other is not None and not bool(payload.replace_existing):
raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{other.name}». Разрешите замену при переносе.")
if other is not None:
other.active = False
other.updated_by = user.login
other.updated_at = _utcnow()
source.active = True
source.updated_by = user.login
source.updated_at = _utcnow()
result_profile = self._mapping_profile_payload(session, source, include_inventory=True, include_fields=True)
report = {"total": len(source_payload.get("fields") or []), "mapped": len(source_payload.get("fields") or []), "skipped": 0, "matched_by": {"key": len(source_payload.get("fields") or []), "title": 0, "number": 0}, "skipped_items": [], "reused": True}
else:
fallback_name = f"{source.name} · {device.name or device.hostname or device.device_uuid}"
created, report = self._create_portable_profile_for_device(
session, device=device, fields=list(source_payload.get("fields") or []),
name=str(payload.name or "").strip() or fallback_name, description=source.description, user=user,
replace_existing=bool(payload.replace_existing),
)
result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = None
if bool(payload.apply_now):
applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_copy")
return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
async def import_mapping_profile_to_device(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(payload.device_id)
document = dict(payload.document or {})
if document.get("format") not in {None, "", "hockey-vmix-mapping"}:
raise HTTPException(status_code=422, detail="Это не файл Hockey vMix Mapping")
try:
schema_version = int(document.get("schema_version") or 1)
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="Некорректная версия файла Mapping")
if schema_version != 1:
raise HTTPException(status_code=422, detail="Версия файла Mapping пока не поддерживается")
profile_doc = document.get("profile") if isinstance(document.get("profile"), dict) else document
fields = profile_doc.get("fields") if isinstance(profile_doc, dict) and isinstance(profile_doc.get("fields"), list) else []
if not fields:
raise HTTPException(status_code=422, detail="В файле Mapping нет связей")
with self.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
target_fingerprint = str(device.project_fingerprint or "")
imported_name = str(payload.name or "").strip() or str(profile_doc.get("name") or "Imported Mapping").strip()
created, report = self._create_portable_profile_for_device(
session, device=device, fields=fields, name=imported_name,
description=str(profile_doc.get("description") or ""), user=user,
replace_existing=bool(payload.replace_existing),
)
result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(target_fingerprint)
applied = None
if bool(payload.apply_now):
applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_import")
return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
async def list_mapping_profiles(self) -> dict[str, Any]:
with self.database.session() as session:
rows = list(session.scalars(
select(VmixMappingProfile)
.where(~VmixMappingProfile.name.like("__AUTO_MAPPING__%"))
.order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)
))
items = [self._mapping_profile_payload(session, row, include_inventory=False, include_fields=False) for row in rows]
for item, row in zip(items, rows):
item["field_count"] = len(list(session.scalars(select(VmixMappingField.id).where(VmixMappingField.profile_id == row.id))))
return {"profiles": items}
async def get_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session:
row = session.get(VmixMappingProfile, profile_id)
if row is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
return self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
async def create_mapping_profile(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
name = str(payload.name or "").strip()
device_id = self.normalise_device_id(payload.device_id)
if not name:
raise HTTPException(status_code=422, detail="Укажите название mapping-профиля")
now = _utcnow()
with self.database.session() as session:
if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == name)) is not None:
raise HTTPException(status_code=409, detail="Mapping с таким названием уже существует")
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or not device.project_fingerprint or not device.project_inventory_json:
raise HTTPException(status_code=409, detail="Agent ещё не передал структуру текущего vMix")
existing = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True))))
if existing is not None:
raise HTTPException(status_code=409, detail=f"Для этого проекта уже назначен mapping «{existing.name}»")
row = VmixMappingProfile(
name=name[:200],
description=str(payload.description or "")[:4000],
project_fingerprint=device.project_fingerprint,
inventory_json=device.project_inventory_json,
version=1,
active=True,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(row)
session.flush()
result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
async def duplicate_mapping_profile(self, profile_id: int, user: HockeyUser) -> dict[str, Any]:
"""Create an independent editable copy of a visible Mapping config.
The copy keeps the source inventory, all field links and rules, but starts
inactive so it cannot unexpectedly replace the live Mapping for the same
vMix project. It can then be rescanned against another Agent/project and
edited without touching the original config.
"""
now = _utcnow()
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None or self._runtime_mapping_source_id(source):
raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")
copy_name = self._portable_profile_name(
session,
f"{str(source.name or 'Mapping').strip()} — копия",
"Mapping — копия",
)
clone = VmixMappingProfile(
name=copy_name,
description=str(source.description or ""),
project_fingerprint=str(source.project_fingerprint or ""),
inventory_json=str(source.inventory_json or "{}"),
version=1,
active=False,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(clone)
session.flush()
source_fields = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == source.id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
for item in source_fields:
session.add(VmixMappingField(
profile_id=clone.id,
graphic=str(item.graphic or "")[:100],
data_key=str(item.data_key or "")[:200],
vmix_input_key=str(item.vmix_input_key or "")[:128],
vmix_input_number=str(item.vmix_input_number or "")[:32],
vmix_input_title=str(item.vmix_input_title or "")[:300],
vmix_field=str(item.vmix_field or "")[:300],
field_type=str(item.field_type or "text")[:32],
rule_json=str(item.rule_json or "{}")[:12000],
enabled=bool(item.enabled),
sort_order=int(item.sort_order or 0),
))
session.flush()
result = self._mapping_profile_payload(session, clone, include_inventory=True, include_fields=True)
result["copied_from_profile_id"] = source.id
result["copied_fields"] = len(source_fields)
return result
async def update_mapping_profile(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
now = _utcnow()
with self.database.session() as session:
row = session.get(VmixMappingProfile, profile_id)
if row is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
name = str(payload.name or "").strip()
if not name:
raise HTTPException(status_code=422, detail="Укажите название mapping-профиля")
duplicate = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.name == name, VmixMappingProfile.id != profile_id)))
if duplicate is not None:
raise HTTPException(status_code=409, detail="Mapping с таким названием уже существует")
row.name = name[:200]
row.description = str(payload.description or "")[:4000]
row.active = bool(payload.active)
# The web editor saves metadata and field links as one operation.
# Version is incremented by replace_mapping_fields(), so one click
# on Save produces exactly one new mapping version.
row.updated_by = user.login
row.updated_at = now
result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
@classmethod
def _mapping_inventory_additions(cls, old_inventory: dict[str, Any], new_inventory: dict[str, Any]) -> dict[str, int]:
"""Count newly discovered Inputs/fields while tolerating stable-key changes.
Existing Inputs are resolved with the same conservative identity order used
by portable Mapping rebinding: key, unique title, then unique number. This is
only a UI report; it never changes or guesses Mapping links.
"""
old_inputs = old_inventory.get("inputs") if isinstance(old_inventory, dict) and isinstance(old_inventory.get("inputs"), list) else []
new_inputs = new_inventory.get("inputs") if isinstance(new_inventory, dict) and isinstance(new_inventory.get("inputs"), list) else []
by_key: dict[str, dict[str, Any]] = {}
by_title: dict[str, list[dict[str, Any]]] = {}
by_number: dict[str, list[dict[str, Any]]] = {}
for item in new_inputs:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
title = cls._portable_text(item.get("title"))
number = str(item.get("number") or "").strip()
if key:
by_key[key] = item
if title:
by_title.setdefault(title, []).append(item)
if number:
by_number.setdefault(number, []).append(item)
used: set[int] = set()
added_fields = 0
for old in old_inputs:
if not isinstance(old, dict):
continue
target = None
key = str(old.get("key") or "").strip()
title = cls._portable_text(old.get("title"))
number = str(old.get("number") or "").strip()
if key:
target = by_key.get(key)
if target is None and title and len(by_title.get(title, [])) == 1:
target = by_title[title][0]
if target is None and number and len(by_number.get(number, [])) == 1:
target = by_number[number][0]
if target is None:
continue
used.add(id(target))
old_names = {cls._portable_text(field.get("name")) for field in (old.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
new_names = {cls._portable_text(field.get("name")) for field in (target.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
added_fields += len(new_names - old_names)
added_inputs = [item for item in new_inputs if isinstance(item, dict) and id(item) not in used]
added_fields += sum(len([field for field in (item.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()]) for item in added_inputs)
return {"new_inputs": len(added_inputs), "new_fields": added_fields}
async def refresh_mapping_inventory(self, profile_id: int, device_id: str, user: HockeyUser) -> dict[str, Any]:
"""Refresh vMix structure without resetting existing Mapping links.
New titles/fields only expand the inventory. Existing links are rebound to
the freshly scanned project by Input key -> unique title -> unique number,
and the exact GT field name must still exist. A link that cannot currently
be resolved is kept unchanged in the database instead of being deleted.
"""
device_id = self.normalise_device_id(device_id)
now = _utcnow()
with self.database.session() as session:
row = session.get(VmixMappingProfile, profile_id)
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
if device is None or not device.project_fingerprint or not device.project_inventory_json:
raise HTTPException(status_code=409, detail="Устройство не передало структуру vMix")
conflicting = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.id != row.id, VmixMappingProfile.active.is_(True))))
if conflicting is not None:
# Build 74 may have created a hidden runtime adaptation of this same
# visible config for the Agent. An explicit rescan means the admin now
# wants the visible config itself to adopt that concrete vMix structure.
if self._runtime_mapping_source_id(conflicting) == row.id:
conflicting.active = False
conflicting.updated_by = user.login
conflicting.updated_at = now
elif row.active:
raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»")
# An inactive visible copy is a draft. It may adopt the same vMix
# inventory as another live config so the admin can reuse the links,
# rescan against another project and edit the copy independently.
try:
old_inventory = json.loads(row.inventory_json or "{}")
if not isinstance(old_inventory, dict):
old_inventory = {}
except Exception:
old_inventory = {}
try:
new_inventory = json.loads(device.project_inventory_json or "{}")
if not isinstance(new_inventory, dict):
new_inventory = {}
except Exception:
new_inventory = {}
link_rows = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == profile_id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
matched_by = {"key": 0, "title": 0, "number": 0}
preserved = 0
unresolved = 0
unresolved_items: list[dict[str, Any]] = []
for field_row in link_rows:
raw = {
"graphic": field_row.graphic,
"data_key": field_row.data_key,
"vmix_input_key": field_row.vmix_input_key,
"vmix_input_number": field_row.vmix_input_number,
"vmix_input_title": field_row.vmix_input_title,
"vmix_field": field_row.vmix_field,
"field_type": field_row.field_type,
"rule": _mapping_rule_payload(field_row.rule_json),
"enabled": field_row.enabled,
}
rebound, report = self._rebind_portable_mapping_fields([raw], new_inventory)
if rebound:
target = rebound[0]
field_row.vmix_input_key = str(target.get("vmix_input_key") or "")[:128]
field_row.vmix_input_number = str(target.get("vmix_input_number") or "")[:32]
field_row.vmix_input_title = str(target.get("vmix_input_title") or "")[:300]
field_row.vmix_field = str(target.get("vmix_field") or field_row.vmix_field)[:300]
preserved += 1
for method in matched_by:
matched_by[method] += int((report.get("matched_by") or {}).get(method, 0) or 0)
else:
# Keep the old link. This protects operator work from a temporary
# incomplete scan and lets it recover automatically if the target
# returns on the next refresh.
unresolved += 1
if len(unresolved_items) < 100:
skipped = (report.get("skipped_items") or [{}])[0]
unresolved_items.append({
"data_key": field_row.data_key,
"input": field_row.vmix_input_title or field_row.vmix_input_key or field_row.vmix_input_number,
"vmix_field": field_row.vmix_field,
"reason": skipped.get("reason", "not_found"),
})
additions = self._mapping_inventory_additions(old_inventory, new_inventory)
old_fingerprint = row.project_fingerprint
row.project_fingerprint = device.project_fingerprint
row.inventory_json = device.project_inventory_json
row.version = int(row.version or 0) + 1
row.updated_by = user.login
row.updated_at = now
session.flush()
result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
result["refresh_report"] = {
"total_links": len(link_rows),
"preserved": preserved,
"unresolved": unresolved,
"matched_by": matched_by,
"unresolved_items": unresolved_items,
**additions,
}
if old_fingerprint and old_fingerprint != result["project_fingerprint"]:
await self._broadcast_mapping_for_fingerprint(old_fingerprint)
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
async def replace_mapping_fields(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
now = _utcnow()
with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id)
if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
for row in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
session.delete(row)
session.flush()
for index, item in enumerate(payload.fields[:2000]):
data_key = str(item.data_key or "").strip()
vmix_field = str(item.vmix_field or "").strip()
if not data_key or not vmix_field:
continue
session.add(VmixMappingField(
profile_id=profile_id,
graphic=str(item.graphic or "")[:100],
data_key=data_key[:200],
vmix_input_key=str(item.vmix_input_key or "")[:128],
vmix_input_number=("" if (str(item.vmix_input_key or "").strip() or str(item.vmix_input_title or "").strip()) else str(item.vmix_input_number or "")[:32]),
vmix_input_title=str(item.vmix_input_title or "")[:300],
vmix_field=vmix_field[:300],
field_type=str(item.field_type or "text")[:32],
rule_json=json.dumps(dict(getattr(item, "rule", {}) or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
enabled=bool(item.enabled),
sort_order=index,
))
profile.version = int(profile.version or 0) + 1
profile.updated_by = user.login
profile.updated_at = now
session.flush()
result = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
async def delete_mapping_profile(self, profile_id: int) -> dict[str, Any]:
with self.database.session() as session:
profile = session.get(VmixMappingProfile, profile_id)
if profile is None:
raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
fingerprint = profile.project_fingerprint
runtime_prefix = f"__AUTO_MAPPING__{profile_id}__%"
runtime_profiles = list(session.scalars(select(VmixMappingProfile).where(VmixMappingProfile.name.like(runtime_prefix))))
for runtime in runtime_profiles:
session.execute(delete(VmixMappingField).where(VmixMappingField.profile_id == runtime.id))
session.delete(runtime)
for field in list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == profile_id))):
session.delete(field)
session.delete(profile)
if fingerprint:
await self._broadcast_mapping_for_fingerprint(fingerprint)
return {"ok": True, "id": profile_id}
async def _broadcast_mapping_for_fingerprint(self, fingerprint: str) -> None:
if not fingerprint:
return
profile_payload: dict[str, Any] | None = None
device_ids: list[str] = []
with self.database.session() as session:
profile = session.scalar(
select(VmixMappingProfile)
.where(and_(VmixMappingProfile.project_fingerprint == fingerprint, VmixMappingProfile.active.is_(True)))
.order_by(desc(VmixMappingProfile.updated_at))
)
if profile is not None:
profile_payload = self._mapping_profile_payload(session, profile, include_inventory=False, include_fields=True)
# Include devices recovered by stable-key compatibility, not only
# devices whose whole-project fingerprint is byte-for-byte equal.
for row in session.scalars(select(VmixDevice).where(VmixDevice.project_fingerprint != "")):
resolved = self._active_mapping_profile_for_device(session, row)
if resolved is not None and resolved.id == profile.id:
device_ids.append(str(row.device_uuid))
else:
device_ids = [
str(row.device_uuid)
for row in session.scalars(select(VmixDevice).where(VmixDevice.project_fingerprint == fingerprint))
]
for device_id in dict.fromkeys(device_ids):
if profile_payload is None:
await self.send(device_id, {"type": "mapping.missing", "project_fingerprint": fingerprint})
else:
delivered = await self.send(device_id, {"type": "mapping.assigned", **profile_payload})
if delivered:
await self.apply_mapping_to_device(device_id, reason="mapping_loaded")
async def probe_device(self, device_id: str, user: HockeyUser) -> dict[str, Any]:
device_id = self.normalise_device_id(device_id)
with self.database.session() as session:
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
delivered = await self.send(device_id, {"type": "vmix.probe", "request_id": secrets.token_urlsafe(8)})
if not delivered:
raise HTTPException(status_code=409, detail="Agent сейчас offline")
return {"ok": True, "device_id": device_id}
@staticmethod
def _assignment_payload(row: VmixAssignment, *, device_id: str) -> dict[str, Any]:
return {
"assignment_id": row.assignment_key,
"device_id": device_id,
"game_id": row.game_external_id,
"tournament_id": row.tournament_external_id,
"operator_session_token": row.operator_session_token,
"created_at": row.created_at.isoformat(),
}
def _device_payload(self, row: VmixDevice, *, viewer_user_id: str) -> dict[str, Any]:
paired = bool(row.wfl_user_id)
paired_to_me = bool(viewer_user_id and row.wfl_user_id == viewer_user_id)
mapping = None
if row.project_fingerprint or str(row.project_inventory_json or "").strip():
with self.database.session() as mapping_session:
managed = mapping_session.get(VmixDevice, row.id)
profile = self._active_mapping_profile_for_device(mapping_session, managed) if managed is not None else None
if profile is not None:
mapping = {"id": profile.id, "name": profile.name, "version": profile.version}
return {
"device_id": row.device_uuid,
"name": row.name or row.hostname or row.device_uuid,
"hostname": row.hostname,
"agent_version": row.agent_version,
"paired": paired,
"paired_to_me": paired_to_me,
"active_for_account": bool(row.is_active_for_account and paired_to_me),
"owner": row.login_snapshot if paired_to_me else ("Другой оператор" if paired else ""),
"vmix_connected": bool(row.vmix_connected),
"vmix_version": row.vmix_version,
"vmix_url": row.vmix_url,
"current_match_id": row.current_match_external_id if paired_to_me else "",
"assignment_id": row.current_assignment_key if paired_to_me else "",
"last_seen": row.last_seen_at.isoformat() if row.last_seen_at else "",
"last_error": row.last_error if paired_to_me else "",
"project_fingerprint": row.project_fingerprint,
"project_input_count": row.project_input_count,
"project_field_count": row.project_field_count,
"mapping": mapping,
"online": row.device_uuid in self._live,
}
class PairDevicePayload(BaseModel):
make_active: bool = True
class TestSetTextPayload(BaseModel):
input: str = Field(min_length=1, max_length=300)
selected_name: str = Field(min_length=1, max_length=300)
value: str = Field(default="", max_length=4000)
class RuntimeVmixCommandPayload(BaseModel):
model_config = ConfigDict(extra="allow")
Function: str = Field(min_length=1, max_length=128)
Input: str = Field(default="", max_length=300)
Value: Any = ""
SelectedName: str = Field(default="", max_length=300)
Duration: str = Field(default="", max_length=64)
Mix: str = Field(default="", max_length=32)
class RuntimeVmixSequencePayload(BaseModel):
commands: list[RuntimeVmixCommandPayload] = Field(default_factory=list, min_length=1, max_length=80)
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
sequence_id: str = Field(default="", max_length=160)
sequence_name: str = Field(default="", max_length=300)
button_id: str = Field(default="", max_length=160)
delivery_mode: str = Field(default="", max_length=32)
class SelectSessionDevicePayload(BaseModel):
session_token: str = Field(default="", max_length=128)
# Runtime match switching can assign immediately and apply Mapping only
# after the new roster has been loaded. Existing Agent UI keeps True.
apply_mapping: bool = True
class MappingTestValuePayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
input: str = Field(min_length=1, max_length=300)
selected_name: str = Field(min_length=1, max_length=300)
value: str = Field(default="", max_length=4000)
field_type: str = Field(default="text", max_length=32)
class MappingTestBatchCommandPayload(BaseModel):
input: str = Field(min_length=1, max_length=300)
selected_name: str = Field(min_length=1, max_length=300)
value: str = Field(default="", max_length=4000)
field_type: str = Field(default="text", max_length=32)
function: str = Field(default="", max_length=64)
data_key: str = Field(default="", max_length=200)
class MappingTestBatchPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
commands: list[MappingTestBatchCommandPayload] = Field(default_factory=list, min_length=1, max_length=500)
class MappingProfileCreatePayload(BaseModel):
name: str = Field(min_length=1, max_length=200)
device_id: str = Field(min_length=6, max_length=128)
description: str = Field(default="", max_length=4000)
class MappingProfileUpdatePayload(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=4000)
active: bool = True
class MappingInventoryRefreshPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
class MappingFieldPayload(BaseModel):
graphic: str = Field(default="", max_length=100)
data_key: str = Field(min_length=1, max_length=200)
vmix_input_key: str = Field(default="", max_length=128)
vmix_input_number: str = Field(default="", max_length=32)
vmix_input_title: str = Field(default="", max_length=300)
vmix_field: str = Field(min_length=1, max_length=300)
field_type: str = Field(default="text", max_length=32)
rule: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class MappingFieldsReplacePayload(BaseModel):
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
class MappingProfileUsePayload(BaseModel):
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
class MappingProfileCopyPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
name: str = Field(default="", max_length=200)
replace_existing: bool = False
apply_now: bool = True
class MappingPortableImportPayload(BaseModel):
device_id: str = Field(min_length=6, max_length=128)
document: dict[str, Any] = Field(default_factory=dict)
name: str = Field(default="", max_length=200)
replace_existing: bool = False
apply_now: bool = True
class ContextVariablePayload(BaseModel):
key: str = Field(min_length=1, max_length=128)
label: str = Field(min_length=1, max_length=200)
category: str = Field(default="Пользовательские", max_length=100)
description: str = Field(default="", max_length=4000)
value_type: str = Field(default="id", max_length=32)
entity_type: str = Field(default="", max_length=32)
scope: str = Field(default="match", max_length=32)
source_type: str = Field(default="manual", max_length=32)
default_value: str = Field(default="", max_length=4000)
enabled: bool = True
sort_order: int = 1000
class ContextValuePayload(BaseModel):
value: str = Field(default="", max_length=8000)
context: dict[str, Any] = Field(default_factory=dict)
class ContextValuesPayload(BaseModel):
values: dict[str, Any] = Field(default_factory=dict)
context: dict[str, Any] = Field(default_factory=dict)
class SqlDataSourcePayload(BaseModel):
code: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
category: str = Field(default="Данные", max_length=100)
description: str = Field(default="", max_length=4000)
sql_text: str = Field(min_length=1, max_length=50000)
field_metadata: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
auto_refresh_enabled: bool = False
refresh_interval_ms: int = Field(default=1000, ge=1000, le=3600000)
sort_order: int = 1000
class MappingDataContextPayload(BaseModel):
context: dict[str, Any] = Field(default_factory=dict)
class MappingSqlPreviewPayload(BaseModel):
sql_text: str = Field(min_length=1, max_length=50000)
context: dict[str, Any] = Field(default_factory=dict)
class PreparedTitleCreatePayload(BaseModel):
name: str = Field(default="", max_length=200)
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
source_input_key: str = Field(default="", max_length=128)
source_input_number: str = Field(default="", max_length=32)
source_input_title: str = Field(default="", max_length=300)
source_kind: str = Field(default="manual", max_length=32)
source_ref: str = Field(default="", max_length=128)
field_values: dict[str, Any] = Field(default_factory=dict)
class PreparedTitleUpdatePayload(BaseModel):
name: str = Field(default="", max_length=200)
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
field_values: dict[str, Any] = Field(default_factory=dict)
class PreparedTitlePreviewPayload(BaseModel):
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
class AgentHelloPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
type: str = "hello"
protocol: int = Field(default=AGENT_PROTOCOL_VERSION)
device_id: str
device_secret: str
device_name: str = ""
hostname: str = ""
agent_version: str = ""
vmix: dict[str, Any] = Field(default_factory=dict)
def create_hockey_agent_router(
hub: VmixAgentHub,
*,
auth_dependency: Callable[..., Any],
admin_dependency: Callable[..., Any] | None = None,
) -> APIRouter:
router = APIRouter(tags=["Hockey vMix Agent"])
admin = [Depends(admin_dependency)] if admin_dependency else []
@router.get("/api/hockey/agents/devices")
async def list_devices(user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.list_for_user(user)
@router.get("/api/hockey/agents/vmix-inventory")
async def operator_vmix_inventory(
device_id: str = Query("", max_length=128),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.vmix_inventory_for_user(user, device_id=device_id)
@router.get("/api/hockey/prepared-titles")
async def prepared_titles_list(
device_id: str = Query("", max_length=128),
game_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.list_prepared_titles(user, device_id=device_id, game_id=game_id)
@router.get("/api/hockey/prepared-titles/mapping-sources")
async def prepared_titles_mapping_sources(
device_id: str = Query("", max_length=128),
panel_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.prepared_mapping_sources(user, device_id=device_id, panel_id=panel_id)
@router.get("/api/hockey/prepared-titles/mapping-snapshot")
async def prepared_titles_mapping_snapshot(
device_id: str = Query("", max_length=128),
input_key: str = Query("", max_length=128),
input_number: str = Query("", max_length=32),
input_title: str = Query("", max_length=300),
panel_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.prepared_mapping_snapshot(
user, device_id=device_id, input_key=input_key, input_number=input_number, input_title=input_title, panel_id=panel_id
)
@router.post("/api/hockey/prepared-titles")
async def prepared_title_create(
payload: PreparedTitleCreatePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.create_prepared_title(user, payload)
@router.put("/api/hockey/prepared-titles/{prepared_id}")
async def prepared_title_update(
prepared_id: int,
payload: PreparedTitleUpdatePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.update_prepared_title(prepared_id, user, payload)
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
async def prepared_title_preview(
prepared_id: int,
payload: PreparedTitlePreviewPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.preview_prepared_title(prepared_id, user, payload)
@router.delete("/api/hockey/prepared-titles/{prepared_id}")
async def prepared_title_delete(
prepared_id: int,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.delete_prepared_title(prepared_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/pair")
async def pair_device(
device_id: str,
payload: PairDevicePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.pair_device(device_id, user, make_active=payload.make_active)
@router.post("/api/hockey/agents/devices/{device_id}/activate")
async def activate_device(
device_id: str,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.activate_device(device_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/deactivate")
async def deactivate_device(
device_id: str,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.deactivate_device(device_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/select-session")
async def select_session_device(
device_id: str,
payload: SelectSessionDevicePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.select_device_for_session(
device_id,
user,
session_token=payload.session_token,
wait_for_mapping=bool(payload.apply_mapping),
)
@router.delete("/api/hockey/agents/devices/{device_id}/pair")
async def unpair_device(
device_id: str,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.unpair_device(device_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/probe")
async def probe_device(
device_id: str,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.probe_device(device_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/test-set-text")
async def test_set_text(
device_id: str,
payload: TestSetTextPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.test_set_text(
device_id,
user,
input_ref=payload.input,
selected_name=payload.selected_name,
value=payload.value,
)
@router.post("/api/hockey/agents/devices/{device_id}/apply-mapping")
async def apply_device_mapping(
device_id: str,
only_changed: bool = False,
active_tab: str = "",
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
device_id = hub.normalise_device_id(device_id)
with hub.database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
if device is None or device.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
if not device.is_active_for_account:
raise HTTPException(status_code=409, detail="Для этого Agent выключено получение данных")
result = await hub.apply_mapping_to_device(
device_id,
reason="runtime_strength_changed" if only_changed else "manual_apply",
only_changed=only_changed,
extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None,
)
# BUILD97: field-level vMix errors are returned as diagnostics instead of
# being collapsed into a misleading HTTP 409 (e.g. "manual_apply").
fatal_reasons = {"device_not_found", "device_not_paired", "no_match_assignment", "no_project_inventory", "vmix_not_connected", "mapping_missing"}
if not result.get("ok") and str(result.get("reason") or "") in fatal_reasons:
raise HTTPException(status_code=409, detail=str(result.get("reason")))
return result
@router.post("/api/hockey/agents/apply-active-mapping")
async def apply_active_mapping(user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
result = await hub.apply_mapping_for_user(user, reason="language_or_runtime_changed")
return result or {"ok": False, "reason": "no_active_device"}
@router.post("/api/hockey/vmix/sequence")
async def run_runtime_vmix_sequence(
payload: RuntimeVmixSequencePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
commands = [item.model_dump(exclude_none=True) for item in payload.commands]
return await hub.run_vmix_sequence_for_user(
user,
commands,
device_id=payload.device_id,
session_token=payload.session_token,
sequence_id=payload.sequence_id,
sequence_name=payload.sequence_name,
button_id=payload.button_id,
delivery_mode=payload.delivery_mode,
)
@router.get("/api/hockey/vmix/overlay-state")
async def runtime_vmix_overlay_state(
device_id: str = Query(default="", max_length=128),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return hub.runtime_overlay_state_for_user(user, device_id=device_id)
@router.post("/api/hockey/admin/vmix-mapping/apply-all-active", dependencies=admin)
async def admin_apply_all_active_mappings() -> dict[str, Any]:
return await hub.apply_mapping_to_all_active_devices(reason="ui_language_changed")
@router.post("/api/hockey/admin/vmix-mapping/apply/{device_id}", dependencies=admin)
async def admin_apply_device_mapping(device_id: str) -> dict[str, Any]:
result = await hub.apply_mapping_to_device(device_id, reason="admin_manual_apply")
fatal_reasons = {"device_not_found", "device_not_paired", "no_match_assignment", "no_project_inventory", "vmix_not_connected", "mapping_missing"}
if not result.get("ok") and str(result.get("reason") or "") in fatal_reasons:
raise HTTPException(status_code=409, detail=str(result.get("reason")))
return result
@router.post("/api/hockey/admin/vmix-mapping/test-value", dependencies=admin)
async def mapping_test_value(payload: MappingTestValuePayload) -> dict[str, Any]:
return await hub.admin_test_mapping_value(
payload.device_id,
input_ref=payload.input,
selected_name=payload.selected_name,
value=payload.value,
field_type=payload.field_type,
)
@router.post("/api/hockey/admin/vmix-mapping/test-batch", dependencies=admin)
async def mapping_test_batch(payload: MappingTestBatchPayload) -> dict[str, Any]:
return await hub.admin_test_mapping_batch(payload.device_id, payload.commands)
@router.get("/api/hockey/admin/vmix-mapping/devices", dependencies=admin)
async def mapping_devices() -> dict[str, Any]:
return await hub.list_mapping_devices()
@router.get("/api/hockey/admin/vmix-mapping/profiles", dependencies=admin)
async def mapping_profiles() -> dict[str, Any]:
return await hub.list_mapping_profiles()
@router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
async def mapping_profile(profile_id: int) -> dict[str, Any]:
return await hub.get_mapping_profile(profile_id)
@router.post("/api/hockey/admin/vmix-mapping/profiles", dependencies=admin)
async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.create_mapping_profile(payload, user)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use", dependencies=admin)
async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.use_mapping_profile_for_user(profile_id, payload, user)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate", dependencies=admin)
async def mapping_profile_duplicate(profile_id: int, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.duplicate_mapping_profile(profile_id, user)
@router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin)
async def mapping_profile_export(profile_id: int) -> dict[str, Any]:
return await hub.export_mapping_profile(profile_id)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/copy-to-device", dependencies=admin)
async def mapping_profile_copy_to_device(profile_id: int, payload: MappingProfileCopyPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.copy_mapping_profile_to_device(profile_id, payload, user)
@router.post("/api/hockey/admin/vmix-mapping/import", dependencies=admin)
async def mapping_profile_import(payload: MappingPortableImportPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.import_mapping_profile_to_device(payload, user)
@router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
async def mapping_profile_update(profile_id: int, payload: MappingProfileUpdatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.update_mapping_profile(profile_id, payload, user)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/inventory", dependencies=admin)
async def mapping_profile_inventory(profile_id: int, payload: MappingInventoryRefreshPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.refresh_mapping_inventory(profile_id, payload.device_id, user)
@router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/fields", dependencies=admin)
async def mapping_profile_fields(profile_id: int, payload: MappingFieldsReplacePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.replace_mapping_fields(profile_id, payload, user)
@router.delete("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
async def mapping_profile_delete(profile_id: int) -> dict[str, Any]:
return await hub.delete_mapping_profile(profile_id)
@router.get("/api/hockey/admin/mapping-context/variables", dependencies=admin)
async def mapping_context_variables() -> dict[str, Any]:
return hub.mapping_data.list_variables()
@router.post("/api/hockey/admin/mapping-context/variables", dependencies=admin)
async def mapping_context_variable_create(payload: ContextVariablePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.create_variable(payload, user)
@router.put("/api/hockey/admin/mapping-context/variables/{variable_id}", dependencies=admin)
async def mapping_context_variable_update(variable_id: int, payload: ContextVariablePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.update_variable(variable_id, payload, user)
@router.delete("/api/hockey/admin/mapping-context/variables/{variable_id}", dependencies=admin)
async def mapping_context_variable_delete(variable_id: int) -> dict[str, Any]:
return hub.mapping_data.delete_variable(variable_id)
@router.get("/api/hockey/admin/mapping-data/sources", dependencies=admin)
async def mapping_data_sources() -> dict[str, Any]:
return hub.mapping_data.list_sources()
@router.get("/api/hockey/admin/mapping-data/database-schema", dependencies=admin)
async def mapping_database_schema() -> dict[str, Any]:
return hub.mapping_data.database_schema_reference()
@router.post("/api/hockey/admin/mapping-data/sources", dependencies=admin)
async def mapping_data_source_create(payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.create_source(payload, user)
@router.put("/api/hockey/admin/mapping-data/sources/{source_id}", dependencies=admin)
async def mapping_data_source_update(source_id: int, payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.update_source(source_id, payload, user)
@router.delete("/api/hockey/admin/mapping-data/sources/{source_id}", dependencies=admin)
async def mapping_data_source_delete(source_id: int) -> dict[str, Any]:
return hub.mapping_data.delete_source(source_id)
@router.post("/api/hockey/admin/mapping-data/preview-sql", dependencies=admin)
async def mapping_data_preview_sql(payload: MappingSqlPreviewPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.preview_sql(payload.sql_text, user, payload.context)
@router.post("/api/hockey/admin/mapping-data/sources/{source_id}/preview", dependencies=admin)
async def mapping_data_source_preview(source_id: int, payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.preview_source(source_id, user, payload.context)
@router.post("/api/hockey/admin/mapping-data/catalog", dependencies=admin)
async def mapping_data_catalog(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.data_catalog(user, payload.context)
# BUILD86: static context routes MUST be registered before /{key}.
# Starlette matches routes in registration order; the old order treated the
# literal word "batch" (and "resolve") as a context variable key.
@router.post("/api/hockey/context/batch")
async def hockey_context_batch(payload: ContextValuesPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
if len(payload.values) > 32:
raise HTTPException(status_code=422, detail="Можно изменить не более 32 переменных за один запрос")
results = []
for key, value in payload.values.items():
results.append(hub.mapping_data.set_context_value(str(key), value, user, payload.context))
context = payload.context if isinstance(payload.context, dict) else {}
mapping_apply = await hub.apply_mapping_for_user(
user,
reason="context_batch_changed",
device_id=str(context.get("device_id") or ""),
session_token=str(context.get("session_token") or ""),
only_changed=True,
)
return {"ok": True, "items": results, "mapping_apply": mapping_apply}
@router.post("/api/hockey/context/resolve")
async def hockey_context_resolve(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
context, variables = hub.mapping_data.resolve_context(user, payload.context)
return {"context": context, "variables": variables}
@router.post("/api/hockey/context/{key}")
async def hockey_context_set(key: str, payload: ContextValuePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
result = hub.mapping_data.set_context_value(key, payload.value, user, payload.context)
result["mapping_apply"] = await hub.apply_mapping_for_user(user, reason=f"context_changed:{key}")
return result
@router.websocket("/ws/hockey-agent")
async def agent_socket(websocket: WebSocket) -> None:
await websocket.accept()
device_id = ""
try:
raw = await asyncio.wait_for(websocket.receive_json(), timeout=12.0)
hello = AgentHelloPayload.model_validate(raw)
if hello.type != "hello" or hello.protocol != AGENT_PROTOCOL_VERSION:
await websocket.close(code=4400, reason="Unsupported agent protocol")
return
hello_data = hello.model_dump()
registered = await hub.register(websocket, hello_data)
device_id = registered["device_id"]
await websocket.send_json(
{
"type": "hello.ok",
"protocol": AGENT_PROTOCOL_VERSION,
"device": registered,
"server_time": _utcnow().isoformat(),
}
)
await hub.reconcile_device(device_id)
while True:
message = await websocket.receive_json()
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 == "vmix.inventory":
await hub.receive_inventory(device_id, message)
if message_type in {"command.ack", "command.batch.ack"}:
await hub.receive_command_ack(device_id, message)
if message_type == "heartbeat":
await websocket.send_json({"type": "heartbeat.ack", "server_time": _utcnow().isoformat()})
except asyncio.TimeoutError:
await websocket.close(code=4408, reason="Agent hello timeout")
except (ValueError, PermissionError) as error:
await websocket.send_json({"type": "hello.error", "detail": str(error)})
await websocket.close(code=4403, reason="Agent registration rejected")
except WebSocketDisconnect:
pass
except Exception as error:
try:
await websocket.send_json({"type": "server.error", "detail": str(error)[:500]})
except Exception:
pass
finally:
if device_id:
await hub.unregister(device_id, websocket)
return router