diff --git a/app.py b/app.py index 377caf3..7691ef4 100644 --- a/app.py +++ b/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 BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.24.2" +BUILD_VERSION = "2026.08.24.3" +# compatibility: BUILD_VERSION = "2026.08.24.2" # compatibility: BUILD_VERSION = "2026.08.24.1" # compatibility: BUILD_VERSION = "2026.08.21.1" # compatibility: BUILD_VERSION = "2026.08.20.17" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index d150f8b..f99b8b0 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -1505,9 +1505,18 @@ class VmixAgentHub: prepared_commands.append(command) results: list[dict[str, Any]] = [] - transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy" + # BUILD100: operator Shortcut/Quick-panel traffic is intentionally delivered + # as ordered single vmix.command frames with an ACK for every command. Mapping + # keeps its chunked batch transport and generic non-shortcut runtime sync may + # still use Agent 1.4 vmix.batch. Interactive title/timer actions are small, and + # losing the rest of a shortcut after one bad command is much worse than a few + # extra websocket frames. + interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip()) + use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 + transport = "batch" if use_batch else ("shortcut-sequential" if interactive_shortcut else "legacy") + async with self._device_vmix_send_lock(target_device_id): - if transport == "batch": + if use_batch: ack = await self._send_vmix_batch_unlocked( target_device_id, assignment_id=assignment_id, @@ -1531,40 +1540,60 @@ class VmixAgentHub: self._track_runtime_overlay_command( target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id ) - if not all(item["ok"] for item in results): - failed = next(item for item in results if not item["ok"]) - raise HTTPException( - status_code=502, - detail={"message": f"vMix command failed: {failed['function']}", "index": failed["index"], "results": results}, - ) else: for index, command in enumerate(prepared_commands): function = str(command.get("Function") or "") - 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 ""), - } - results.append(item) - if not item["ok"]: - raise HTTPException( - status_code=502, - detail={"message": f"vMix command failed: {function}", "index": index, "results": results}, + try: + ack = await self._send_vmix_command_unlocked( + target_device_id, + assignment_id=assignment_id, + match_id=match_id, + command=command, + timeout=timeout, ) - self._track_runtime_overlay_command( - target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id - ) + 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, + sequence_id=sequence_id, + sequence_name=sequence_name, + button_id=button_id, + ) + # For ordinary legacy non-shortcut runtime calls preserve the old + # fail-fast behavior. Shortcut delivery continues deliberately. + if not item["ok"] and not interactive_shortcut: + break + + failed = [item for item in results if not item["ok"]] return { - "ok": True, + "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": len(results), + "applied": sum(1 for item in results if item["ok"]), + "attempted": len(results), + "requested": len(prepared_commands), + "failed": len(failed), "results": results, "transport": transport, "overlay_state": self._runtime_overlay_payload(target_device_id), diff --git a/tests/test_build100_shortcut_delivery_key_edge.py b/tests/test_build100_shortcut_delivery_key_edge.py new file mode 100644 index 0000000..0b2987e --- /dev/null +++ b/tests/test_build100_shortcut_delivery_key_edge.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8") +APP = (ROOT / "app.py").read_text(encoding="utf-8") +BRIDGE = (ROOT / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8") + + +class FakeWebSocket: + def __init__(self) -> None: + self.client = SimpleNamespace(host="127.0.0.1") + self.sent: list[dict] = [] + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def close(self, **_kwargs) -> None: + return None + + +def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> None: + assert 'pressedShortcutKeys: new Set()' in APP_JS + assert 'state.pressedShortcutKeys.has(physicalKey)' in APP_JS + assert 'state.pressedShortcutKeys.delete(physicalKey)' in APP_JS + assert 'pendingShortcutSequenceRuns: new Map()' in APP_JS + assert 'if (!state.pendingShortcutSequenceRuns.has(sequence.id))' in APP_JS + assert 'one stale/broken title must not prevent the remaining title' in APP_JS + assert 'requestTimeoutMs = Math.min(60000' in APP_JS + assert 'BUILD_VERSION = "2026.08.24.3"' in APP + + +def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build100-shortcut.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + ws = FakeWebSocket() + user = HockeyUser(id="100", login="operator100", display_name="operator100") + + async def scenario() -> None: + await hub.register( + ws, # type: ignore[arg-type] + { + "device_id": "GFX-BUILD100", + "device_secret": "z" * 40, + "device_name": "Build100 GFX", + "hostname": "BUILD100-PC", + "agent_version": "1.4.0", + "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, + }, + ) + await hub.pair_device("GFX-BUILD100", user) + assigned = await hub.assign_match( + wfl_user_id=user.id, + tournament_external_id="1437", + game_external_id="100100", + ) + assert assigned is not None + + task = asyncio.create_task( + hub.run_vmix_sequence_for_user( + user, + [ + {"Function": "OverlayInput1In", "Input": "SCORE"}, + {"Function": "OverlayInput2In", "Input": "BROKEN"}, + {"Function": "OverlayInput3In", "Input": "LINEUP"}, + ], + sequence_id="shortcut-build100", + sequence_name="Titles", + ) + ) + + seen: list[dict] = [] + for index, ok in enumerate([True, False, True]): + command = None + for _ in range(100): + await asyncio.sleep(0) + commands = [item for item in ws.sent if item.get("type") == "vmix.command"] + if len(commands) > index: + command = commands[index] + break + assert command is not None + seen.append(command) + await hub.receive_command_ack( + "GFX-BUILD100", + { + "type": "command.ack", + "request_id": command["request_id"], + "ok": ok, + "reason": "bad input" if not ok else "", + }, + ) + + assert not [item for item in ws.sent if item.get("type") == "vmix.batch"] + assert [item["command"]["Input"] for item in seen] == ["SCORE", "BROKEN", "LINEUP"] + result = await task + assert result["transport"] == "shortcut-sequential" + assert result["requested"] == 3 + assert result["attempted"] == 3 + assert result["applied"] == 2 + assert result["failed"] == 1 + assert result["ok"] is False + assert result["results"][2]["ok"] is True + + asyncio.run(scenario()) + + +def test_build100_backend_marks_interactive_shortcuts_sequential() -> None: + assert 'interactive_shortcut = bool' in BRIDGE + assert 'transport = "batch" if use_batch else ("shortcut-sequential"' in BRIDGE + assert 'if not item["ok"] and not interactive_shortcut' in BRIDGE diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 150777c..b73c36a 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -76,10 +76,18 @@ runtimeResizeObserver: null, shortcutCapture: null, pressedShortcutModifiers: new Set(), + // BUILD100: track physical non-modifier keys until keyup. Browser key repeat, + // focus quirks or duplicated keydown events must never enqueue several toggle actions + // for one physical press (Space is especially important for the game clock). + pressedShortcutKeys: new Set(), modifierShortcutChordModifiers: new Set(), modifierShortcutChordUsedKey: false, modifierShortcutChordFired: false, runningShortcutSequences: new Set(), + // BUILD100: one second physical press while the same sequence is waiting for Agent ACK + // is remembered instead of being silently dropped. Extra impatient presses are + // coalesced, so they cannot build a future queue of Start/Stop toggles. + pendingShortcutSequenceRuns: new Map(), // BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents // two different shortcuts from interleaving commands while an Agent ACK is pending. vmixCommandQueue: Promise.resolve(), @@ -2250,6 +2258,15 @@ function startCustomTooltips() { return key.length === 1 ? key.toUpperCase() : key; } + function shortcutPhysicalKeyToken(event) { + const modifier = shortcutModifierFromEvent(event); + if (modifier) return `modifier:${modifier}:${String(event.code || event.key || modifier)}`; + const code = String(event.code || "").trim(); + if (code) return `key:${code}`; + const key = shortcutKeyFromEvent(event); + return key ? `key:${key}` : ""; + } + function shortcutFromKeyboardEvent(event) { const key = shortcutKeyFromEvent(event); if (!key) return ""; @@ -4567,7 +4584,11 @@ function startCustomTooltips() { if (!clean.length) return { ok: true, applied: 0, results: [] }; state.vmixCommandQueueDepth += 1; const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), 7000); + // BUILD100: runtime vMix delivery is ACKed command-by-command on the server. + // The browser timeout must cover the whole small sequence, otherwise fetch can + // abort while the server is still legitimately delivering later title/timer commands. + const requestTimeoutMs = Math.min(60000, Math.max(20000, 10000 + clean.length * 5000)); + const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs); try { const response = await fetch("/api/hockey/vmix/sequence", { method: "POST", @@ -4590,12 +4611,27 @@ function startCustomTooltips() { const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`; throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); } - trackRuntimeOverlayCommands(clean, execution); + // BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution) + // BUILD100 tracks only ACK-successful commands below, so a failed title cannot + // incorrectly light the ON AIR state. + const resultRows = Array.isArray(payload?.results) ? payload.results : []; + const successfulCommands = clean.filter((_command, index) => { + const row = resultRows[index]; + return !row || row.ok !== false; + }); + trackRuntimeOverlayCommands(successfulCommands, execution); if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); + if (payload?.ok === false) { + const failed = resultRows.filter((row) => row && row.ok === false); + const first = failed[0] || {}; + const label = first.function || "vMix"; + const reason = first.reason || payload?.error || "команда не выполнена"; + throw new Error(`${label}: ${reason}${failed.length > 1 ? ` · ошибок ${failed.length}` : ""}`); + } return payload; } catch (error) { if (error?.name === "AbortError") { - throw new Error("vMix/Agent не подтвердил команду за 7 секунд"); + throw new Error(`vMix/Agent не завершил очередь команд за ${Math.round(requestTimeoutMs / 1000)} сек.`); } throw error; } finally { @@ -5257,7 +5293,15 @@ function startCustomTooltips() { async function runShortcutSequence(sequenceOrId, meta = {}) { const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId; if (!sequence || sequence.enabled === false) return false; - if (state.runningShortcutSequences.has(sequence.id)) return false; + if (state.runningShortcutSequences.has(sequence.id)) { + // Remember at most ONE follow-up action. This makes Start → Stop reliable when + // Agent ACK is still pending, while repeated impatient Space presses cannot + // accumulate five future toggles and flip the timer back and forth later. + if (!state.pendingShortcutSequenceRuns.has(sequence.id)) { + state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true }); + } + return true; + } const wasOnAir = shortcutSequenceIsOnAir(sequence.id); state.runningShortcutSequences.add(sequence.id); const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") }; @@ -5283,7 +5327,23 @@ function startCustomTooltips() { else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`); return true; } - for (const step of sequence.steps || []) await runShortcutSequenceStep(sequence, step, execution); + const stepErrors = []; + for (const [stepIndex, step] of (sequence.steps || []).entries()) { + try { + await runShortcutSequenceStep(sequence, step, execution); + } catch (error) { + const functionName = step?.type === "vmix_command" ? String(step.function || "vMix") : String(step?.type || "step"); + const message = String(error?.message || error || "Ошибка шага"); + stepErrors.push({ index: stepIndex, function: functionName, message }); + console.error("UI Builder shortcut step error", { sequence, stepIndex, step, error }); + // BUILD100: one stale/broken title must not prevent the remaining title, + // timer and overlay steps from being sent to Agent. + } + } + if (stepErrors.length) { + const first = stepErrors[0]; + throw new Error(`шаг ${first.index + 1} (${first.function}): ${first.message}${stepErrors.length > 1 ? ` · всего ошибок ${stepErrors.length}` : ""}`); + } if (sequence.toggle_all_overlays_on_repeat) { state.shortcutSequenceOverlayState.set(sequence.id, true); state.quickPanelOnAirSequences.add(String(sequence.id || "")); @@ -5303,6 +5363,9 @@ function startCustomTooltips() { return false; } finally { state.runningShortcutSequences.delete(sequence.id); + const nextMeta = state.pendingShortcutSequenceRuns.get(sequence.id) || null; + state.pendingShortcutSequenceRuns.delete(sequence.id); + if (nextMeta) queueMicrotask(() => runShortcutSequence(sequence.id, nextMeta)); } } @@ -5336,10 +5399,21 @@ function startCustomTooltips() { } function handleConfiguredShortcuts(event) { + const physicalKey = shortcutPhysicalKeyToken(event); + if (physicalKey && state.pressedShortcutKeys.has(physicalKey)) { + // This key already fired a configured shortcut and has not been released yet. + // Swallow browser auto-repeat/default activation until keyup. + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation(); + return true; + } if (event.repeat) return false; const combo = shortcutFromKeyboardEvent(event); if (!combo) return false; - return handleConfiguredShortcutCombo(combo, event, "keyboard"); + const handled = handleConfiguredShortcutCombo(combo, event, "keyboard"); + if (handled && physicalKey) state.pressedShortcutKeys.add(physicalKey); + return handled; } function handleModifierOnlyShortcutRelease(event) { @@ -14514,6 +14588,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { }, true); window.addEventListener("keyup", (event) => { + const physicalKey = shortcutPhysicalKeyToken(event); + if (physicalKey) state.pressedShortcutKeys.delete(physicalKey); const modifier = shortcutModifierFromEvent(event); if (!modifier) return; @@ -14538,6 +14614,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { }, true); window.addEventListener("blur", () => { + state.pressedShortcutKeys.clear(); state.pressedShortcutModifiers.clear(); state.modifierShortcutChordModifiers.clear(); state.modifierShortcutChordUsedKey = false;