266 lines
20 KiB
Python
266 lines
20 KiB
Python
"""Passive, scoped broadcast checks. Viewing never executes a source or sends a command."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import select
|
||
|
||
from .async_work import run_db
|
||
from .models import VmixAssignment, VmixDevice, VmixMappingField, VmixMappingProfile
|
||
|
||
HEARTBEAT_MAX_AGE_SECONDS = 35
|
||
MAX_MAPPING_RESULTS = 256
|
||
_UNSPECIFIED = object()
|
||
NOTICE = "Показана последняя обработка источников/Mapping. Актуальность данных внешних источников этой проверкой не подтверждается."
|
||
LIMITATIONS = {"source_sql_executed": False, "upstream_freshness_checked": False}
|
||
_LABELS = {
|
||
"selection": "Выбранный матч и Agent", "assignment": "Назначение Agent",
|
||
"agent": "Связь с Agent", "vmix": "Подключение vMix", "inventory": "Схема текущего vMix",
|
||
"publication": "Опубликованный Mapping", "targets": "Поля Mapping в vMix",
|
||
"delivery": "Последняя обработка источников/Mapping", "pending": "Ожидающие команды",
|
||
}
|
||
|
||
|
||
def _utc_naive(value: datetime) -> datetime:
|
||
return value.astimezone(timezone.utc).replace(tzinfo=None) if value.tzinfo is not None else value
|
||
|
||
|
||
def _iso(value: datetime) -> str:
|
||
return _utc_naive(value).replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def _results(hub: Any) -> dict[str, dict[str, Any]]:
|
||
results = getattr(hub, "_broadcast_mapping_results", None)
|
||
if not isinstance(results, dict):
|
||
results = {}
|
||
hub._broadcast_mapping_results = results
|
||
return results
|
||
|
||
|
||
def _number(value: Any) -> int:
|
||
try:
|
||
return max(0, int(value or 0))
|
||
except (TypeError, ValueError, OverflowError):
|
||
return 0
|
||
|
||
|
||
def _errors(result: dict[str, Any]) -> int:
|
||
count = sum(len(result[key]) for key in ("errors", "missing", "rule_missing", "warnings") if isinstance(result.get(key), list))
|
||
return max(count, 1 if result.get("ok") is not True else 0)
|
||
|
||
|
||
def begin_mapping_attempt(hub: Any, device_id: str, generation: int, *, full: bool,
|
||
configuration_token: Any = _UNSPECIFIED) -> None:
|
||
token = getattr(hub, "_broadcast_configuration_token", None) if configuration_token is _UNSPECIFIED else configuration_token
|
||
if full and token is getattr(hub, "_broadcast_configuration_token", None) and generation == getattr(hub, "_mapping_generations", {}).get(device_id, 0):
|
||
# An interrupted new full apply cannot leave an older success green.
|
||
_results(hub).pop(device_id, None)
|
||
|
||
|
||
def record_mapping_result(hub: Any, device_id: str, generation: int, result: dict[str, Any], *, full: bool,
|
||
configuration_token: Any = _UNSPECIFIED) -> None:
|
||
token = getattr(hub, "_broadcast_configuration_token", None) if configuration_token is _UNSPECIFIED else configuration_token
|
||
if token is not getattr(hub, "_broadcast_configuration_token", None) or generation != getattr(hub, "_mapping_generations", {}).get(device_id, 0):
|
||
return
|
||
errors = _errors(result)
|
||
if not full and not errors:
|
||
return # A partial success is never proof for the full profile.
|
||
scope = {"match_id": str(result.get("match_id") or ""),
|
||
"assignment_id": str(result.get("assignment_id") or ""),
|
||
"profile_id": _number(result.get("profile_id")),
|
||
"profile_version": _number(result.get("profile_version"))}
|
||
if not all(scope.values()):
|
||
# Early preparation failures have no trustworthy publication scope.
|
||
_results(hub).pop(device_id, None)
|
||
return
|
||
confirmed = sum(_number(result.get(key)) for key in ("applied", "rules_applied", "unchanged", "rules_unchanged"))
|
||
record = {**scope, "generation": generation, "configuration_token": token, "completed_at": _iso(datetime.utcnow()),
|
||
"full_profile": full, "outcome": "error" if errors else "ready",
|
||
"errors_count": errors, "confirmed": confirmed,
|
||
"commands_sent": _number(result.get("mapping_commands_sent"))}
|
||
_results(hub).pop(device_id, None)
|
||
_results(hub)[device_id] = record
|
||
while len(_results(hub)) > MAX_MAPPING_RESULTS:
|
||
_results(hub).pop(next(iter(_results(hub))))
|
||
|
||
|
||
def record_mapping_exception(hub: Any, device_id: str, generation: int, *, full: bool,
|
||
configuration_token: Any = _UNSPECIFIED) -> None:
|
||
token = getattr(hub, "_broadcast_configuration_token", None) if configuration_token is _UNSPECIFIED else configuration_token
|
||
if token is not getattr(hub, "_broadcast_configuration_token", None) or generation != getattr(hub, "_mapping_generations", {}).get(device_id, 0):
|
||
return
|
||
prior = _results(hub).get(device_id)
|
||
if not full and prior and prior.get("generation") == generation and prior.get("configuration_token") is token:
|
||
# Preserve the known scope, never the exception text or source values.
|
||
_results(hub)[device_id] = {**prior, "completed_at": _iso(datetime.utcnow()),
|
||
"full_profile": False, "outcome": "error", "errors_count": max(1, _number(prior.get("errors_count"))),
|
||
"confirmed": 0, "commands_sent": 0}
|
||
else:
|
||
_results(hub).pop(device_id, None)
|
||
|
||
|
||
def _check(key: str, status: str, reason: str, message: str, **details: Any) -> dict[str, Any]:
|
||
return {"key": key, "status": status, "label": _LABELS[key], "reason": reason,
|
||
"message": message, "details": details}
|
||
|
||
|
||
def _response(device_id: str, game_id: str, checks: list[dict[str, Any]], now: datetime) -> dict[str, Any]:
|
||
present = {item["key"]: item for item in checks}
|
||
selection_required = present.get("selection", {}).get("reason") == "selection_required"
|
||
checks = [present.get(key) or _check(key, "unknown",
|
||
"selection_required" if selection_required else "state_unavailable",
|
||
"Для проверки выберите матч и Agent" if selection_required else "Нет данных для проверки") for key in _LABELS]
|
||
summary = {status: sum(item["status"] == status for item in checks) for status in ("ready", "warning", "error", "unknown")}
|
||
status = next((status for status in ("error", "unknown", "warning") if summary[status]), "ready")
|
||
return {"ok": True, "device_id": device_id, "game_id": game_id, "checked_at": _iso(now),
|
||
"status": status, "checks": checks, "summary": summary, "notice": NOTICE,
|
||
"limitations": dict(LIMITATIONS)}
|
||
|
||
|
||
def _load_metadata(hub: Any, user_id: str, device_id: str, game_id: str) -> dict[str, Any]:
|
||
with hub.database.session() as session:
|
||
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id,
|
||
VmixDevice.wfl_user_id == user_id, VmixDevice.is_active_for_account.is_(True)))
|
||
if device is None:
|
||
raise HTTPException(404, detail="Выбранный Agent недоступен")
|
||
assignment = session.scalar(select(VmixAssignment).where(VmixAssignment.device_id == device.id,
|
||
VmixAssignment.assignment_key == device.current_assignment_key,
|
||
VmixAssignment.wfl_user_id == user_id, VmixAssignment.game_external_id == game_id,
|
||
VmixAssignment.active.is_(True)))
|
||
assigned = bool(assignment and str(device.current_match_external_id or "") == game_id
|
||
and str(device.current_assignment_key or ""))
|
||
data: dict[str, Any] = {"assigned": assigned, "assignment_id": str(device.current_assignment_key or ""),
|
||
"fingerprint": str(device.project_fingerprint or ""), "inventory_json": device.project_inventory_json,
|
||
"scanned_at": device.project_scanned_at, "profile_id": _number(device.mapping_profile_id),
|
||
"profile_version": _number(device.mapping_profile_version), "profile": None, "fields": []}
|
||
# Never invoke a profile resolver here: legacy resolution publishes rows.
|
||
if assigned and data["profile_id"]:
|
||
profile = session.get(VmixMappingProfile, data["profile_id"])
|
||
if profile is not None:
|
||
source_id = _number(device.mapping_source_profile_id)
|
||
source = session.get(VmixMappingProfile, source_id) if source_id else None
|
||
data["profile"] = {"version": _number(profile.version), "source_deleted": bool(source_id and source is None),
|
||
"update_available": bool(source and _number(source.version) != _number(profile.version))}
|
||
data["fields"] = [{"input": str(field.vmix_input_title or ""), "field": str(field.vmix_field or "").strip()}
|
||
for field in session.scalars(select(VmixMappingField).where(
|
||
VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))]
|
||
return data
|
||
|
||
|
||
def _target_counts(fields: list[dict[str, str]], inputs: list[Any]) -> dict[str, int]:
|
||
by_title: dict[str, list[dict[str, Any]]] = {}
|
||
for item in inputs:
|
||
if isinstance(item, dict):
|
||
by_title.setdefault(str(item.get("title") or ""), []).append(item)
|
||
counts = {"enabled_links": len(fields), "target_count": 0, "matched_count": 0,
|
||
"missing_count": 0, "ambiguous_count": 0, "invalid_count": 0}
|
||
targets = {(row["input"], row["field"]) for row in fields}
|
||
counts["target_count"] = len(targets)
|
||
for title, name in targets:
|
||
if not title.strip() or not name or re.fullmatch(r"[+-]?\d+|\{?[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?", title):
|
||
counts["invalid_count"] += 1
|
||
continue
|
||
matches = by_title.get(title, [])
|
||
if len(matches) > 1:
|
||
counts["ambiguous_count"] += 1
|
||
continue
|
||
if not matches:
|
||
counts["missing_count"] += 1
|
||
continue
|
||
actual_fields = matches[0].get("fields")
|
||
exact = [field for field in actual_fields if isinstance(field, dict) and str(field.get("name") or "").strip() == name] if isinstance(actual_fields, list) else []
|
||
counts["matched_count" if len(exact) == 1 else "ambiguous_count" if exact else "missing_count"] += 1
|
||
return counts
|
||
|
||
|
||
async def broadcast_readiness(hub: Any, user: Any, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
|
||
"""Observe one explicitly selected owned output; no selection or mutation fallback."""
|
||
now = datetime.utcnow()
|
||
device_id, game_id = str(device_id or "").strip(), str(game_id or "").strip()
|
||
if not device_id or not game_id:
|
||
return _response(device_id, game_id, [_check("selection", "unknown", "selection_required", "Выберите матч и Agent в этом браузере")], now)
|
||
try:
|
||
device_id = hub.normalise_device_id(device_id)
|
||
except (HTTPException, ValueError):
|
||
raise HTTPException(404, detail="Выбранный Agent недоступен") from None
|
||
generation = getattr(hub, "_mapping_generations", {}).get(device_id, 0)
|
||
configuration_token = getattr(hub, "_broadcast_configuration_token", None)
|
||
connection = hub._live.get(device_id)
|
||
try:
|
||
metadata = await run_db(_load_metadata, hub, str(user.id), device_id, game_id)
|
||
except HTTPException:
|
||
raise
|
||
except Exception:
|
||
# Neither database exceptions nor source/query text reach this response.
|
||
return _response(device_id, game_id, [_check(key, "unknown", "state_unavailable",
|
||
"Проверка готовности временно недоступна") for key in _LABELS], now)
|
||
# Copy event-loop state synchronously; reading must never take transport locks.
|
||
now = datetime.utcnow()
|
||
scope_changed = (generation != getattr(hub, "_mapping_generations", {}).get(device_id, 0)
|
||
or connection is not hub._live.get(device_id)
|
||
or configuration_token is not getattr(hub, "_broadcast_configuration_token", None))
|
||
live = hub._live.get(device_id)
|
||
heartbeat = hub._diagnostic_heartbeat_at.get(device_id)
|
||
age = max(0.0, (now - _utc_naive(heartbeat)).total_seconds()) if isinstance(heartbeat, datetime) else None
|
||
current_heartbeat = bool(live and isinstance(heartbeat, datetime) and _utc_naive(heartbeat) >= _utc_naive(live.connected_at))
|
||
fresh = bool(current_heartbeat and age is not None and age <= HEARTBEAT_MAX_AGE_SECONDS)
|
||
state = dict(hub._device_status.get(device_id, {}))
|
||
inventory_current = device_id in hub._mapping_inventory_ready and not scope_changed and live is not None
|
||
record = dict(getattr(hub, "_broadcast_mapping_results", {}).get(device_id, {}))
|
||
pending_count = sum(owner == device_id and not future.done() for owner, future in hub._pending_commands.values())
|
||
busy = any(lock is not None and lock.locked() for lock in (
|
||
hub._mapping_resolve_locks.get(device_id), hub._mapping_apply_locks.get(device_id)))
|
||
checks = [_check("selection", "unknown" if scope_changed else "ready", "state_changed" if scope_changed else "selected",
|
||
"Состояние изменилось во время проверки; повторите её" if scope_changed else "Проверяется выбранный матч и Agent")]
|
||
assigned = metadata["assigned"]
|
||
checks.append(_check("assignment", "ready" if assigned else "error", "assigned" if assigned else "assignment_mismatch",
|
||
"Agent назначен выбранному матчу" if assigned else "У Agent нет действующего назначения на выбранный матч"))
|
||
agent_status = "error" if live is None or (current_heartbeat and not fresh) else "ready" if fresh else "unknown"
|
||
checks.append(_check("agent", agent_status, "online" if fresh else "offline" if live is None else "heartbeat_stale" if current_heartbeat else "heartbeat_pending",
|
||
"Agent на связи" if fresh else "Agent отключён" if live is None else "Ждём актуальное подтверждение связи от Agent",
|
||
heartbeat_age_seconds=round(age, 1) if current_heartbeat and age is not None else None))
|
||
connected = state.get("vmix_connected")
|
||
vmix_status = "ready" if connected is True and fresh else "error" if connected is False and live is not None else "unknown"
|
||
checks.append(_check("vmix", vmix_status, "connected" if vmix_status == "ready" else "disconnected" if vmix_status == "error" else "connection_unknown",
|
||
"Agent сообщил о подключении vMix" if vmix_status == "ready" else "Agent сообщил об отключении vMix" if vmix_status == "error" else "Нет актуального состояния подключения vMix"))
|
||
try:
|
||
inventory = json.loads(metadata["inventory_json"] or "{}")
|
||
except (ValueError, TypeError):
|
||
inventory = {}
|
||
valid_inventory = isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) and bool(re.fullmatch(r"[0-9a-f]{64}", metadata["fingerprint"]))
|
||
inputs = inventory["inputs"] if valid_inventory else []
|
||
inventory_status = "ready" if inventory_current and valid_inventory else "unknown"
|
||
checks.append(_check("inventory", inventory_status, "current_inventory" if inventory_status == "ready" else "inventory_pending",
|
||
"Схема получена в текущем подключении Agent" if inventory_status == "ready" else "Нужна схема vMix из текущего подключения Agent",
|
||
current_connection=inventory_current, scanned_at=_iso(metadata["scanned_at"]) if isinstance(metadata["scanned_at"], datetime) else "",
|
||
input_count=len(inputs), field_count=sum(len(item.get("fields", [])) for item in inputs if isinstance(item, dict) and isinstance(item.get("fields"), list))))
|
||
publication = metadata["profile"]
|
||
publication_valid = bool(assigned and publication and metadata["profile_version"] > 0 and publication["version"] == metadata["profile_version"])
|
||
publication_status = "unknown" if not assigned else "error" if metadata["profile_id"] and not publication_valid else "unknown" if not publication else "warning" if publication["source_deleted"] or publication["update_available"] else "ready"
|
||
checks.append(_check("publication", publication_status, "publication_current" if publication_status == "ready" else "template_changed" if publication_status == "warning" else "publication_missing" if not publication else "publication_mismatch",
|
||
"Используется опубликованная версия Mapping" if publication_status == "ready" else "Опубликованная версия сохранена; шаблон изменён или удалён" if publication_status == "warning" else "Нет подтверждённой опубликованной версии Mapping",
|
||
version=publication["version"] if publication else None, update_available=bool(publication and publication["update_available"])))
|
||
counts = _target_counts(metadata["fields"], inputs) if assigned and publication_valid and inventory_status == "ready" else {}
|
||
targets_status = "unknown" if not counts else "error" if not counts["enabled_links"] or counts["matched_count"] != counts["target_count"] else "ready"
|
||
checks.append(_check("targets", targets_status, "targets_matched" if targets_status == "ready" else "targets_invalid" if targets_status == "error" else "targets_unknown",
|
||
"Все включённые поля Mapping найдены по именам" if targets_status == "ready" else "Есть отсутствующие, неоднозначные или недопустимые цели Mapping" if targets_status == "error" else "Для проверки полей нужны назначение, публикация и схема текущего vMix", **counts))
|
||
record_current = bool(record and not scope_changed and assigned and publication_valid and
|
||
record.get("generation") == generation and record.get("configuration_token") is configuration_token and
|
||
record.get("configuration_token") is getattr(hub, "_broadcast_configuration_token", None) and record.get("match_id") == game_id and
|
||
record.get("assignment_id") == metadata["assignment_id"] and record.get("profile_id") == metadata["profile_id"] and
|
||
record.get("profile_version") == metadata["profile_version"])
|
||
delivery_status = "unknown"
|
||
if record_current:
|
||
delivery_status = "error" if record.get("outcome") == "error" else "ready" if record.get("full_profile") is True and record.get("outcome") == "ready" else "unknown"
|
||
delivery_details = {key: record[key] for key in ("completed_at", "confirmed", "errors_count", "full_profile") if record_current and key in record}
|
||
checks.append(_check("delivery", delivery_status, "full_mapping_confirmed" if delivery_status == "ready" else "mapping_incomplete" if delivery_status == "error" else "mapping_not_confirmed",
|
||
"Полная обработка Mapping завершена; команды подтверждены Agent или ранее подтверждённым кэшем" if delivery_status == "ready" else "Последняя обработка Mapping содержит ошибки или неполные данные; нужна успешная полная обработка" if delivery_status == "error" else "Нет результата полной обработки для текущего назначения и конфигурации Mapping/источников", **delivery_details))
|
||
checks.append(_check("pending", "warning" if pending_count or busy else "ready", "mapping_busy" if busy else "ack_pending" if pending_count else "idle",
|
||
"Обработка Mapping или подтверждение команд ещё продолжается" if pending_count or busy else "Нет команд, ожидающих подтверждения Agent",
|
||
count=pending_count, mapping_busy=busy))
|
||
return _response(device_id, game_id, checks, now)
|