отправка данных для mapping с агентом
This commit is contained in:
3
app.py
3
app.py
@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
|
|||||||
from khl_site.khl_data_center import APP as khl_site_app
|
from khl_site.khl_data_center import APP as khl_site_app
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BUILD_VERSION = "2026.08.20.17"
|
BUILD_VERSION = "2026.08.21.1"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.16"
|
# compatibility: BUILD_VERSION = "2026.08.20.16"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.15"
|
# compatibility: BUILD_VERSION = "2026.08.20.15"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.14"
|
# compatibility: BUILD_VERSION = "2026.08.20.14"
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ AGENT_PROTOCOL_VERSION = 1
|
|||||||
AGENT_ONLINE_WINDOW_SECONDS = 35
|
AGENT_ONLINE_WINDOW_SECONDS = 35
|
||||||
_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{6,128}$")
|
_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:
|
def _utcnow() -> datetime:
|
||||||
return datetime.utcnow()
|
return datetime.utcnow()
|
||||||
@@ -241,6 +246,9 @@ class VmixAgentHub:
|
|||||||
# BUILD90: one FIFO lock per Agent. Shortcut batches, Mapping refreshes and
|
# BUILD90: one FIFO lock per Agent. Shortcut batches, Mapping refreshes and
|
||||||
# timer control commands can no longer interleave on the same vMix instance.
|
# timer control commands can no longer interleave on the same vMix instance.
|
||||||
self._vmix_send_locks: dict[str, asyncio.Lock] = {}
|
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] = {}
|
||||||
self.mapping_data = MappingDataService(database, settings=settings)
|
self.mapping_data = MappingDataService(database, settings=settings)
|
||||||
self._auto_refresh_task: asyncio.Task[None] | None = None
|
self._auto_refresh_task: asyncio.Task[None] | None = None
|
||||||
self._auto_refresh_next: dict[tuple[str, str], float] = {}
|
self._auto_refresh_next: dict[tuple[str, str], float] = {}
|
||||||
@@ -673,6 +681,173 @@ class VmixAgentHub:
|
|||||||
self._vmix_send_locks[device_id] = lock
|
self._vmix_send_locks[device_id] = lock
|
||||||
return 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
|
||||||
|
|
||||||
|
@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:
|
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
|
||||||
request_id = str(message.get("request_id") or "").strip()
|
request_id = str(message.get("request_id") or "").strip()
|
||||||
if not request_id:
|
if not request_id:
|
||||||
@@ -1037,16 +1212,25 @@ class VmixAgentHub:
|
|||||||
result["ok"] = not result["errors"]
|
result["ok"] = not result["errors"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if self._agent_supports_batch(agent_version):
|
use_batch = self._agent_supports_batch(agent_version)
|
||||||
try:
|
result["transport"] = "chunked_batch" if use_batch else "legacy"
|
||||||
ack = await self.send_vmix_batch(
|
transport = await self._send_mapping_entries_resilient(
|
||||||
device_id,
|
device_id,
|
||||||
assignment_id=assignment_id,
|
assignment_id=assignment_id,
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
commands=[entry["command"] for entry in pending_entries],
|
entries=pending_entries,
|
||||||
timeout=max(6.0, min(15.0, 4.0 + len(pending_entries) * 0.03)),
|
use_batch=use_batch,
|
||||||
)
|
)
|
||||||
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
|
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):
|
for index, entry in enumerate(pending_entries):
|
||||||
item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
|
item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
|
||||||
if bool(item_ack.get("ok")):
|
if bool(item_ack.get("ok")):
|
||||||
@@ -1058,34 +1242,10 @@ class VmixAgentHub:
|
|||||||
else:
|
else:
|
||||||
result["errors"].append({
|
result["errors"].append({
|
||||||
"key": entry["data_key"],
|
"key": entry["data_key"],
|
||||||
"reason": str(item_ack.get("reason") or item_ack.get("error") or "vmix_batch_error"),
|
"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"),
|
||||||
})
|
})
|
||||||
except HTTPException as error:
|
|
||||||
result["errors"].append({"key": "*batch*", "reason": str(error.detail)})
|
|
||||||
except Exception as error:
|
|
||||||
result["errors"].append({"key": "*batch*", "reason": str(error)[:300]})
|
|
||||||
else:
|
|
||||||
for entry in pending_entries:
|
|
||||||
try:
|
|
||||||
ack = await self.send_vmix_command(
|
|
||||||
device_id,
|
|
||||||
assignment_id=assignment_id,
|
|
||||||
match_id=match_id,
|
|
||||||
command=entry["command"],
|
|
||||||
timeout=3.0,
|
|
||||||
)
|
|
||||||
if bool(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"], "reason": str(ack.get("reason") or ack.get("error") or "vmix_error")})
|
|
||||||
except HTTPException as error:
|
|
||||||
result["errors"].append({"key": entry["data_key"], "reason": str(error.detail)})
|
|
||||||
except Exception as error:
|
|
||||||
result["errors"].append({"key": entry["data_key"], "reason": str(error)[:300]})
|
|
||||||
result["ok"] = not result["errors"]
|
result["ok"] = not result["errors"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1519,14 +1679,21 @@ class VmixAgentHub:
|
|||||||
"command": {"Function": function, "Input": input_ref, "SelectedName": selected_name, "Value": value},
|
"command": {"Function": function, "Input": input_ref, "SelectedName": selected_name, "Value": value},
|
||||||
})
|
})
|
||||||
|
|
||||||
if self._agent_supports_batch(agent_version) and prepared:
|
if prepared:
|
||||||
try:
|
use_batch = self._agent_supports_batch(agent_version)
|
||||||
ack = await self.send_vmix_batch(
|
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,
|
device_id, assignment_id=assignment_id, match_id=match_id,
|
||||||
commands=[item["command"] for item in prepared],
|
entries=prepared, use_batch=use_batch,
|
||||||
timeout=max(6.0, min(15.0, 4.0 + len(prepared) * 0.03)),
|
|
||||||
)
|
)
|
||||||
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
|
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):
|
for pos, item in enumerate(prepared):
|
||||||
item_ack = ack_results[pos] if pos < len(ack_results) and isinstance(ack_results[pos], dict) else {}
|
item_ack = ack_results[pos] if pos < len(ack_results) and isinstance(ack_results[pos], dict) else {}
|
||||||
if bool(item_ack.get("ok")):
|
if bool(item_ack.get("ok")):
|
||||||
@@ -1534,26 +1701,8 @@ class VmixAgentHub:
|
|||||||
else:
|
else:
|
||||||
result["errors"].append({
|
result["errors"].append({
|
||||||
"index": item["index"], "key": item["key"], "field": item["field"],
|
"index": item["index"], "key": item["key"], "field": item["field"],
|
||||||
"reason": str(item_ack.get("reason") or item_ack.get("error") or "vmix_batch_error"),
|
"reason": str(item_ack.get("reason") or item_ack.get("error") or "mapping_transport_error"),
|
||||||
})
|
})
|
||||||
except Exception as error:
|
|
||||||
result["errors"].append({"index": -1, "key": "*batch*", "reason": str(getattr(error, "detail", error))[:300]})
|
|
||||||
else:
|
|
||||||
for item in prepared:
|
|
||||||
try:
|
|
||||||
ack = await self.send_vmix_command(
|
|
||||||
device_id, assignment_id=assignment_id, match_id=match_id,
|
|
||||||
command=item["command"],
|
|
||||||
)
|
|
||||||
if bool(ack.get("ok")):
|
|
||||||
result["applied"] += 1
|
|
||||||
else:
|
|
||||||
result["errors"].append({
|
|
||||||
"index": item["index"], "key": item["key"], "field": item["field"],
|
|
||||||
"reason": str(ack.get("reason") or ack.get("error") or "vmix_error"),
|
|
||||||
})
|
|
||||||
except Exception as error:
|
|
||||||
result["errors"].append({"index": item["index"], "key": item["key"], "field": item["field"], "reason": str(error)[:300]})
|
|
||||||
result["ok"] = not result["errors"]
|
result["ok"] = not result["errors"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -3815,7 +3964,10 @@ def create_hockey_agent_router(
|
|||||||
only_changed=only_changed,
|
only_changed=only_changed,
|
||||||
extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None,
|
extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None,
|
||||||
)
|
)
|
||||||
if not result.get("ok") and result.get("reason"):
|
# 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")))
|
raise HTTPException(status_code=409, detail=str(result.get("reason")))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -3854,7 +4006,8 @@ def create_hockey_agent_router(
|
|||||||
@router.post("/api/hockey/admin/vmix-mapping/apply/{device_id}", dependencies=admin)
|
@router.post("/api/hockey/admin/vmix-mapping/apply/{device_id}", dependencies=admin)
|
||||||
async def admin_apply_device_mapping(device_id: str) -> dict[str, Any]:
|
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")
|
result = await hub.apply_mapping_to_device(device_id, reason="admin_manual_apply")
|
||||||
if not result.get("ok") and result.get("reason"):
|
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")))
|
raise HTTPException(status_code=409, detail=str(result.get("reason")))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,20 @@
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mappingApplyTransportSummary(result) {
|
||||||
|
const chunksTotal = Number(result?.batch_chunks_total || 0);
|
||||||
|
const chunksApplied = Number(result?.batch_chunks_applied || 0);
|
||||||
|
const retries = Number(result?.batch_retries || 0);
|
||||||
|
const fallback = Number(result?.fallback_commands || 0);
|
||||||
|
const inputs = Number(result?.input_groups || 0);
|
||||||
|
const parts = [];
|
||||||
|
if (inputs) parts.push(`Input: ${inputs}`);
|
||||||
|
if (chunksTotal) parts.push(`пакетов ${chunksApplied}/${chunksTotal}`);
|
||||||
|
if (retries) parts.push(`повторов ${retries}`);
|
||||||
|
if (fallback) parts.push(`одиночных ${fallback}`);
|
||||||
|
return parts.length ? ` · ${parts.join(" · ")}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function mappingTransferReport(result, action = "Mapping перенесён") {
|
function mappingTransferReport(result, action = "Mapping перенесён") {
|
||||||
const report = result?.report || {};
|
const report = result?.report || {};
|
||||||
const mapped = Number(report.mapped || 0);
|
const mapped = Number(report.mapped || 0);
|
||||||
@@ -2846,7 +2860,7 @@
|
|||||||
if (!commands.length) return setStatus("У текущего Input нет настроенных связей с доступными данными.", true), renderMapping();
|
if (!commands.length) return setStatus("У текущего Input нет настроенных связей с доступными данными.", true), renderMapping();
|
||||||
try {
|
try {
|
||||||
const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
|
const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
|
||||||
const tail = `${missing.length ? ` · нет данных ${missing.length}` : ""}${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}`;
|
const tail = `${missing.length ? ` · нет данных ${missing.length}` : ""}${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}${mappingApplyTransportSummary(result)}`;
|
||||||
setStatus(`Input ${input?.number ? `#${input.number} ` : ""}${input?.title || ""}: отправлено ${Number(result.applied || 0)} из ${Number(result.total || commands.length)} полей${tail}.`, Boolean(result.errors?.length));
|
setStatus(`Input ${input?.number ? `#${input.number} ` : ""}${input?.title || ""}: отправлено ${Number(result.applied || 0)} из ${Number(result.total || commands.length)} полей${tail}.`, Boolean(result.errors?.length));
|
||||||
} catch (error) { setStatus(error.message, true); }
|
} catch (error) { setStatus(error.message, true); }
|
||||||
renderMapping();
|
renderMapping();
|
||||||
@@ -2886,8 +2900,8 @@
|
|||||||
if (!deviceId) return setStatus("Выберите online Agent для применения Mapping.", true), renderMapping();
|
if (!deviceId) return setStatus("Выберите online Agent для применения Mapping.", true), renderMapping();
|
||||||
try {
|
try {
|
||||||
const result = await request(`/api/hockey/admin/vmix-mapping/apply/${encodeURIComponent(deviceId)}`, { method: "POST" });
|
const result = await request(`/api/hockey/admin/vmix-mapping/apply/${encodeURIComponent(deviceId)}`, { method: "POST" });
|
||||||
const suffix = result.errors?.length ? ` · ошибок ${result.errors.length}` : "";
|
const suffix = `${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}${mappingApplyTransportSummary(result)}`;
|
||||||
setStatus(`Mapping применён: ${Number(result.applied || 0)} из ${Number(result.total || 0)} связей${suffix}.`);
|
setStatus(`Mapping применён: ${Number(result.applied || 0)} из ${Number(result.total || 0)} связей${suffix}.`, Boolean(result.errors?.length));
|
||||||
} catch (error) { setStatus(error.message, true); }
|
} catch (error) { setStatus(error.message, true); }
|
||||||
renderMapping();
|
renderMapping();
|
||||||
});
|
});
|
||||||
|
|||||||
154
tests/test_build97_mapping_chunked_transport.py
Normal file
154
tests/test_build97_mapping_chunked_transport.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from hockey_data.agent_bridge import MAPPING_BATCH_MAX_COMMANDS, VmixAgentHub
|
||||||
|
from tests.support import LocalTestDatabase
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(index: int, input_ref: str, *, value_size: int = 4) -> dict:
|
||||||
|
return {
|
||||||
|
"data_key": f"source.row.{index}",
|
||||||
|
"command": {
|
||||||
|
"Function": "SetText",
|
||||||
|
"Input": input_ref,
|
||||||
|
"SelectedName": f"Field{index}.Text",
|
||||||
|
"Value": "X" * value_size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_chunk_planner_groups_by_input_and_caps_packets(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "mapping-chunks.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
entries = [*[_entry(i, "INPUT-A") for i in range(95)], *[_entry(100 + i, "INPUT-B") for i in range(17)]]
|
||||||
|
chunks = hub._mapping_batch_chunks(entries)
|
||||||
|
|
||||||
|
assert len(chunks) == 4 # A: 40 + 40 + 15, B: 17
|
||||||
|
assert all(len(chunk) <= MAPPING_BATCH_MAX_COMMANDS for chunk in chunks)
|
||||||
|
assert all(len({item[1]["command"]["Input"] for item in chunk}) == 1 for chunk in chunks)
|
||||||
|
# ACK positions can still be restored to original Mapping order.
|
||||||
|
assert [index for chunk in chunks for index, _entry_item in chunk] == list(range(len(entries)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_chunk_transport_sends_300_plus_links_in_small_batches(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "mapping-300.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
packets: list[list[dict]] = []
|
||||||
|
|
||||||
|
async def fake_batch(device_id: str, *, assignment_id: str, match_id: str, commands: list[dict], timeout: float = 8.0) -> dict:
|
||||||
|
assert device_id == "GFX-MAP-300"
|
||||||
|
packets.append(commands)
|
||||||
|
return {"ok": True, "results": [{"ok": True} for _ in commands]}
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
hub.send_vmix_batch = fake_batch # type: ignore[method-assign]
|
||||||
|
entries: list[dict] = []
|
||||||
|
# 12 Inputs x 31 commands = 372 Mapping commands.
|
||||||
|
for input_index in range(12):
|
||||||
|
entries.extend(_entry(input_index * 1000 + row, f"INPUT-{input_index:02d}") for row in range(31))
|
||||||
|
result = await hub._send_mapping_entries_resilient(
|
||||||
|
"GFX-MAP-300",
|
||||||
|
assignment_id="assignment-300",
|
||||||
|
match_id="game-300",
|
||||||
|
entries=entries,
|
||||||
|
use_batch=True,
|
||||||
|
)
|
||||||
|
assert all(item["ok"] for item in result["results"])
|
||||||
|
assert result["input_groups"] == 12
|
||||||
|
assert result["chunks_total"] == 12
|
||||||
|
assert result["chunks_ok"] == 12
|
||||||
|
assert result["chunks_failed"] == 0
|
||||||
|
assert result["fallback_commands"] == 0
|
||||||
|
assert sum(len(packet) for packet in packets) == 372
|
||||||
|
assert max(len(packet) for packet in packets) <= MAPPING_BATCH_MAX_COMMANDS
|
||||||
|
assert all(len({command["Input"] for command in packet}) == 1 for packet in packets)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_transport_splits_timeout_batch_and_keeps_going(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "mapping-retry.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
packet_sizes: list[int] = []
|
||||||
|
|
||||||
|
async def flaky_batch(device_id: str, *, assignment_id: str, match_id: str, commands: list[dict], timeout: float = 8.0) -> dict:
|
||||||
|
packet_sizes.append(len(commands))
|
||||||
|
if len(commands) > 10:
|
||||||
|
raise HTTPException(status_code=504, detail="simulated batch timeout")
|
||||||
|
return {"ok": True, "results": [{"ok": True} for _ in commands]}
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
hub.send_vmix_batch = flaky_batch # type: ignore[method-assign]
|
||||||
|
entries = [_entry(i, "ONE-BIG-INPUT") for i in range(35)]
|
||||||
|
result = await hub._send_mapping_entries_resilient(
|
||||||
|
"GFX-MAP-RETRY",
|
||||||
|
assignment_id="assignment-retry",
|
||||||
|
match_id="game-retry",
|
||||||
|
entries=entries,
|
||||||
|
use_batch=True,
|
||||||
|
)
|
||||||
|
assert all(item["ok"] for item in result["results"])
|
||||||
|
assert result["chunks_total"] == 1
|
||||||
|
assert result["chunks_ok"] == 1
|
||||||
|
assert result["chunks_failed"] == 0
|
||||||
|
assert result["retries"] > 0
|
||||||
|
assert packet_sizes[0] == 35
|
||||||
|
assert any(size <= 10 for size in packet_sizes[1:])
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_transport_retries_isolated_failed_command(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "mapping-single-fallback.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
singles: list[dict] = []
|
||||||
|
|
||||||
|
async def partial_batch(device_id: str, *, assignment_id: str, match_id: str, commands: list[dict], timeout: float = 8.0) -> dict:
|
||||||
|
results = [{"ok": True} for _ in commands]
|
||||||
|
results[3] = {"ok": False, "reason": "temporary failure"}
|
||||||
|
return {"ok": False, "results": results}
|
||||||
|
|
||||||
|
async def single_ok(device_id: str, *, assignment_id: str, match_id: str, command: dict, timeout: float = 5.0) -> dict:
|
||||||
|
singles.append(command)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
hub.send_vmix_batch = partial_batch # type: ignore[method-assign]
|
||||||
|
hub.send_vmix_command = single_ok # type: ignore[method-assign]
|
||||||
|
entries = [_entry(i, "INPUT-FALLBACK") for i in range(8)]
|
||||||
|
result = await hub._send_mapping_entries_resilient(
|
||||||
|
"GFX-MAP-FALLBACK",
|
||||||
|
assignment_id="assignment-fallback",
|
||||||
|
match_id="game-fallback",
|
||||||
|
entries=entries,
|
||||||
|
use_batch=True,
|
||||||
|
)
|
||||||
|
assert all(item["ok"] for item in result["results"])
|
||||||
|
assert result["fallback_commands"] == 1
|
||||||
|
assert result["retries"] == 1
|
||||||
|
assert len(singles) == 1
|
||||||
|
assert singles[0]["SelectedName"] == "Field3.Text"
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_build97_runtime_version_and_mapping_diagnostics() -> None:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
app = (root / "app.py").read_text(encoding="utf-8")
|
||||||
|
bridge = (root / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8")
|
||||||
|
admin = (root / "hockey_data" / "static" / "admin-directories.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'BUILD_VERSION = "2026.08.21.1"' in app
|
||||||
|
assert 'result["transport"] = "chunked_batch" if use_batch else "legacy"' in bridge
|
||||||
|
assert 'MAPPING_BATCH_MAX_COMMANDS = 40' in bridge
|
||||||
|
assert 'mappingApplyTransportSummary' in admin
|
||||||
|
assert 'пакетов ${chunksApplied}/${chunksTotal}' in admin
|
||||||
Reference in New Issue
Block a user