mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 06:31:45 +08:00
2320 lines
100 KiB
Python
Executable File
2320 lines
100 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import getpass
|
|
import inspect
|
|
import json
|
|
import lzma
|
|
import os
|
|
import re
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from okvm_media import HDMI_COLOR_SEQUENCE, closest_hdmi_color, hex_to_rgb, jpeg_rgb_stats, percentile, rgb_error
|
|
from okvm_report import Reporter
|
|
|
|
try:
|
|
import httpx
|
|
except ImportError: # Keep --help usable before dependencies are installed.
|
|
httpx = None # type: ignore[assignment]
|
|
|
|
|
|
HID_KEY_USAGE: dict[str, tuple[int, int]] = {
|
|
**{chr(ord("a") + i): (0x04 + i, 0x00) for i in range(26)},
|
|
**{chr(ord("A") + i): (0x04 + i, 0x02) for i in range(26)},
|
|
"1": (0x1E, 0x00),
|
|
"2": (0x1F, 0x00),
|
|
"3": (0x20, 0x00),
|
|
"4": (0x21, 0x00),
|
|
"5": (0x22, 0x00),
|
|
"6": (0x23, 0x00),
|
|
"7": (0x24, 0x00),
|
|
"8": (0x25, 0x00),
|
|
"9": (0x26, 0x00),
|
|
"0": (0x27, 0x00),
|
|
"-": (0x2D, 0x00),
|
|
"_": (0x2D, 0x02),
|
|
" ": (0x2C, 0x00),
|
|
"\n": (0x28, 0x00),
|
|
}
|
|
|
|
HID_ALNUM_KEYS: list[tuple[str, int, int]] = [
|
|
*[(chr(ord("A") + i), 0x04 + i, 0x41 + i) for i in range(26)],
|
|
*[(str((i + 1) % 10), 0x1E + i, 0x31 + i if i < 9 else 0x30) for i in range(10)],
|
|
]
|
|
HID_ALNUM_TEXT = "abcdefghijklmnopqrstuvwxyz1234567890"
|
|
HID_FUNCTION_KEYS: list[tuple[str, int, int]] = [
|
|
(f"F{i}", 0x39 + i, 0x6F + i) for i in range(1, 13)
|
|
]
|
|
|
|
MOD_LEFT_CTRL = 0x01
|
|
MOD_LEFT_SHIFT = 0x02
|
|
|
|
HID_SAFE_COMBOS: list[tuple[str, int, int, int]] = [
|
|
("Ctrl+F9", 0x42, 0x78, MOD_LEFT_CTRL),
|
|
("Shift+F11", 0x44, 0x7A, MOD_LEFT_SHIFT),
|
|
("Ctrl+Shift+F12", 0x45, 0x7B, MOD_LEFT_CTRL | MOD_LEFT_SHIFT),
|
|
]
|
|
HID_LATENCY_KEY = ("F8", 0x41, 0x77)
|
|
VIDEO_LATENCY_OUTPUT_MODES = ("mjpeg", "h264", "h265")
|
|
CHROME_BROWSER_CHANNEL = "chrome"
|
|
CHROME_BROWSER_NAME = "Chrome"
|
|
CHROME_INSTALL_COMMAND = "python -m playwright install chrome"
|
|
CHROMIUM_WINDOWS_ARGS = [
|
|
"--enable-features=WebRtcAllowH265Receive",
|
|
"--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled",
|
|
"--enable-gpu",
|
|
"--ignore-gpu-blocklist",
|
|
"--use-angle=d3d11",
|
|
]
|
|
|
|
@dataclass
|
|
class VideoInputCase:
|
|
label: str
|
|
input_class: str
|
|
device: str
|
|
fmt: str
|
|
width: int
|
|
height: int
|
|
fps: float
|
|
|
|
class SSHRunner:
|
|
def __init__(self, host: str, user: str, password: str | None, port: int = 22):
|
|
self.host = host
|
|
self.user = user
|
|
self.password = password
|
|
self.port = port
|
|
|
|
def connect(self) -> Any:
|
|
try:
|
|
import paramiko
|
|
except ImportError as exc:
|
|
raise RuntimeError("paramiko is required for SSH; run pip install -r requirements.txt") from exc
|
|
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
client.connect(
|
|
self.host,
|
|
port=self.port,
|
|
username=self.user,
|
|
password=self.password,
|
|
look_for_keys=self.password is None,
|
|
allow_agent=self.password is None,
|
|
timeout=15,
|
|
)
|
|
return client
|
|
|
|
def run(self, command: str, timeout: int = 60) -> tuple[int, str, str]:
|
|
client = self.connect()
|
|
try:
|
|
_, stdout, stderr = client.exec_command(command, timeout=timeout)
|
|
out = stdout.read().decode("utf-8", errors="replace")
|
|
err = stderr.read().decode("utf-8", errors="replace")
|
|
code = stdout.channel.recv_exit_status()
|
|
return code, out, err
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
class ApiClient:
|
|
def __init__(self, target: str, port: int, timeout: float = 15.0):
|
|
if httpx is None:
|
|
raise RuntimeError("httpx is required; run pip install -r requirements.txt")
|
|
self.base = f"http://{target}:{port}"
|
|
self.client = httpx.Client(base_url=self.base, timeout=timeout, follow_redirects=True)
|
|
|
|
def close(self) -> None:
|
|
self.client.close()
|
|
|
|
def cookie_header(self) -> str:
|
|
return "; ".join(f"{cookie.name}={cookie.value}" for cookie in self.client.cookies.jar)
|
|
|
|
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
response = self.client.request(method, f"/api{path}", **kwargs)
|
|
content_type = response.headers.get("content-type", "")
|
|
data: Any
|
|
if "json" in content_type:
|
|
data = response.json()
|
|
else:
|
|
try:
|
|
data = response.json()
|
|
except Exception:
|
|
data = response.text
|
|
if response.status_code >= 400:
|
|
raise RuntimeError(f"{method} {path} -> HTTP {response.status_code}: {data}")
|
|
if isinstance(data, dict) and data.get("success") is False:
|
|
raise RuntimeError(f"{method} {path} failed: {data}")
|
|
return data
|
|
|
|
def get(self, path: str) -> Any:
|
|
return self.request("GET", path)
|
|
|
|
def post(self, path: str, payload: dict[str, Any] | None = None) -> Any:
|
|
return self.request("POST", path, json=payload or {})
|
|
|
|
def patch(self, path: str, payload: dict[str, Any]) -> Any:
|
|
return self.request("PATCH", path, json=payload)
|
|
|
|
def wait_health(self, timeout: int = 60) -> dict[str, Any]:
|
|
deadline = time.monotonic() + timeout
|
|
last_error: Exception | None = None
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
return self.get("/health")
|
|
except Exception as exc:
|
|
last_error = exc
|
|
time.sleep(1)
|
|
raise TimeoutError(f"One-KVM health check did not pass in {timeout}s: {last_error}")
|
|
|
|
|
|
class AgentClient:
|
|
def __init__(self, host: str, port: int, reporter: Reporter):
|
|
self.host = host
|
|
self.port = port
|
|
self.reporter = reporter
|
|
self.websocket: Any = None
|
|
self.hello: dict[str, Any] | None = None
|
|
self.connected = asyncio.Event()
|
|
self.lock = asyncio.Lock()
|
|
|
|
async def stop(self) -> None:
|
|
if self.websocket:
|
|
await self.websocket.close()
|
|
self.websocket = None
|
|
self.connected.clear()
|
|
|
|
async def wait_connected(self, timeout: int = 180) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
last_error: Exception | None = None
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
await self._connect_once()
|
|
return True
|
|
except Exception as exc:
|
|
last_error = exc
|
|
await asyncio.sleep(1)
|
|
self.reporter.add("windows_agent", "FAIL", f"failed to connect Windows agent: {last_error}")
|
|
return False
|
|
|
|
async def _connect_once(self) -> None:
|
|
if self.websocket and self.connected.is_set():
|
|
return
|
|
try:
|
|
import websockets
|
|
except ImportError as exc:
|
|
raise RuntimeError("websockets is required for agent support; run pip install -r requirements.txt") from exc
|
|
|
|
uri = f"ws://{self.host}:{self.port}/agent"
|
|
self.websocket = await websockets.connect(uri, max_size=8 * 1024 * 1024)
|
|
raw = await asyncio.wait_for(self.websocket.recv(), 10)
|
|
data = json.loads(raw)
|
|
if data.get("type") != "hello":
|
|
raise RuntimeError(f"unexpected agent hello: {data}")
|
|
self.hello = data
|
|
self.connected.set()
|
|
self.reporter.add("windows_agent", "PASS", "connected to Windows agent", hello=data)
|
|
|
|
async def command(self, name: str, payload: dict[str, Any] | None = None, timeout: int = 30) -> dict[str, Any]:
|
|
if not self.websocket or not self.connected.is_set():
|
|
raise RuntimeError("Windows agent is not connected")
|
|
msg_id = str(uuid.uuid4())
|
|
async with self.lock:
|
|
await self.websocket.send(json.dumps({"id": msg_id, "command": name, "payload": payload or {}}))
|
|
response = json.loads(await asyncio.wait_for(self.websocket.recv(), timeout))
|
|
if not response.get("ok"):
|
|
raise RuntimeError(response.get("error") or f"agent command failed: {name}")
|
|
return response.get("payload") or {}
|
|
|
|
|
|
class DeviceSelector:
|
|
CSI_HINTS = ("rkcif", "rk_hdmirx", "mipi", "csi", "platform", "hdmirx")
|
|
|
|
def __init__(self, lsusb_tree: str, devices: dict[str, Any]):
|
|
self.lsusb_tree = lsusb_tree
|
|
self.devices = devices
|
|
|
|
def classify(self, device: dict[str, Any]) -> str:
|
|
haystack = " ".join(
|
|
str(device.get(k, "")) for k in ("name", "driver", "path")
|
|
).lower()
|
|
if any(h in haystack for h in self.CSI_HINTS) or not device.get("usb_bus"):
|
|
return "csi_mipi"
|
|
if re.search(r"\b(5000|10000|20000)M\b", self.lsusb_tree):
|
|
return "usb3"
|
|
return "usb2"
|
|
|
|
@staticmethod
|
|
def _find_format(device: dict[str, Any], fmt: str) -> dict[str, Any] | None:
|
|
want = fmt.upper()
|
|
for item in device.get("formats", []):
|
|
got = str(item.get("format", "")).upper()
|
|
if want in got or got in want:
|
|
return item
|
|
return None
|
|
|
|
@staticmethod
|
|
def _pick_exact(fmt: dict[str, Any], width: int, height: int, fps: float) -> tuple[int, int, float] | None:
|
|
for res in fmt.get("resolutions", []):
|
|
if int(res.get("width", 0)) == width and int(res.get("height", 0)) == height:
|
|
fps_values = [float(x) for x in res.get("fps", [])]
|
|
if not fps_values:
|
|
continue
|
|
best = min(fps_values, key=lambda x: abs(x - fps))
|
|
if best >= fps * 0.9:
|
|
return width, height, best
|
|
return None
|
|
|
|
@staticmethod
|
|
def _pick_highest_1080(fmt: dict[str, Any]) -> tuple[int, int, float] | None:
|
|
candidates: list[tuple[int, int, float]] = []
|
|
for res in fmt.get("resolutions", []):
|
|
width = int(res.get("width", 0))
|
|
height = int(res.get("height", 0))
|
|
if width > 1920 or height > 1080:
|
|
continue
|
|
for fps in res.get("fps", []):
|
|
candidates.append((width, height, float(fps)))
|
|
if not candidates:
|
|
return None
|
|
return max(candidates, key=lambda x: (x[0] * x[1], x[2]))
|
|
|
|
def select(self) -> list[VideoInputCase]:
|
|
video_devices = self.devices.get("video", [])
|
|
if not video_devices:
|
|
return []
|
|
ordered = sorted(video_devices, key=lambda d: (not bool(d.get("has_signal")), d.get("path", "")))
|
|
device = ordered[0]
|
|
input_class = self.classify(device)
|
|
path = str(device["path"])
|
|
cases: list[VideoInputCase] = []
|
|
|
|
if input_class == "csi_mipi":
|
|
nv12 = self._find_format(device, "NV12")
|
|
if nv12:
|
|
picked = self._pick_exact(nv12, 1920, 1080, 60) or self._pick_highest_1080(nv12)
|
|
if picked:
|
|
w, h, f = picked
|
|
cases.append(VideoInputCase("csi_mipi_nv12", input_class, path, "NV12", w, h, f))
|
|
return cases
|
|
|
|
mjpeg = self._find_format(device, "MJPEG")
|
|
yuyv = self._find_format(device, "YUYV")
|
|
target_fps = 60 if input_class == "usb3" else 30
|
|
if mjpeg:
|
|
picked = self._pick_exact(mjpeg, 1920, 1080, target_fps) or self._pick_highest_1080(mjpeg)
|
|
if picked:
|
|
w, h, f = picked
|
|
cases.append(VideoInputCase(f"{input_class}_mjpeg", input_class, path, "MJPEG", w, h, f))
|
|
if yuyv:
|
|
picked = self._pick_highest_1080(yuyv)
|
|
if picked:
|
|
w, h, f = picked
|
|
cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f))
|
|
return cases
|
|
|
|
|
|
class AcceptanceRunner:
|
|
def __init__(self, args: argparse.Namespace):
|
|
self.args = args
|
|
self.run_id = args.run_id or time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
|
|
self.reporter = Reporter(Path(args.report_dir) / self.run_id, color=not args.no_color)
|
|
self.api = ApiClient(args.target, args.http_port)
|
|
self.ssh_password = args.ssh_password or os.environ.get("OKVM_SSH_PASSWORD")
|
|
self.agent: AgentClient | None = None
|
|
self.lsusb_tree = ""
|
|
self.selected_hid_backend = "none"
|
|
self.screenshot_keys: set[str] = set()
|
|
|
|
async def run(self) -> int:
|
|
if self.args.agent_host:
|
|
self.agent = AgentClient(self.args.agent_host, self.args.agent_port, self.reporter)
|
|
print(f"Connecting Windows agent at ws://{self.args.agent_host}:{self.args.agent_port}/agent")
|
|
|
|
try:
|
|
if self.args.reset:
|
|
self.reset_target()
|
|
self.api.wait_health(timeout=self.args.health_timeout)
|
|
self.run_network_latency_test()
|
|
self.setup_or_login()
|
|
self.collect_target_inventory()
|
|
devices = self.api.get("/devices")
|
|
self.configure_hid_and_msd(devices)
|
|
video_cases = self.select_video_cases(devices)
|
|
await self.wait_for_agent_if_requested()
|
|
await self.capture_key_screenshot("console_after_login", "登录后控制台首页")
|
|
await self.run_video_matrix(video_cases)
|
|
await self.run_hdmi_capture_tests(video_cases)
|
|
await self.run_hid_test()
|
|
await self.run_msd_test()
|
|
self.run_atx_api_test()
|
|
await self.capture_key_screenshot("console_after_io", "HID/MSD/ATX 后控制台状态")
|
|
self.collect_logs()
|
|
finally:
|
|
if self.agent:
|
|
await self.agent.stop()
|
|
self.api.close()
|
|
self.reporter.write(self.run_id, self.args.target)
|
|
|
|
return 1 if any(r.status == "FAIL" for r in self.reporter.results) else 0
|
|
|
|
def reset_target(self) -> None:
|
|
if not self.ssh_password and self.args.ssh_password_prompt:
|
|
self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ")
|
|
ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port)
|
|
data_dir = self.args.data_dir
|
|
cmd = f"""
|
|
set -eu
|
|
RUN_ID='{shell_quote(self.run_id)}'
|
|
DATA_DIR='{shell_quote(data_dir)}'
|
|
BACKUP="$DATA_DIR/test-backups/$RUN_ID"
|
|
mkdir -p "$BACKUP"
|
|
(systemctl stop one-kvm || service one-kvm stop || true)
|
|
if [ -f "$DATA_DIR/one-kvm.db" ]; then cp -a "$DATA_DIR/one-kvm.db" "$BACKUP/one-kvm.db"; fi
|
|
if [ -f "$DATA_DIR/one-kvm.db-wal" ]; then cp -a "$DATA_DIR/one-kvm.db-wal" "$BACKUP/one-kvm.db-wal"; fi
|
|
if [ -f "$DATA_DIR/one-kvm.db-shm" ]; then cp -a "$DATA_DIR/one-kvm.db-shm" "$BACKUP/one-kvm.db-shm"; fi
|
|
rm -f "$DATA_DIR/one-kvm.db" "$DATA_DIR/one-kvm.db-wal" "$DATA_DIR/one-kvm.db-shm"
|
|
(systemctl start one-kvm || service one-kvm start || nohup /usr/bin/one-kvm >"/tmp/one-kvm-test-$RUN_ID.log" 2>&1 &)
|
|
echo "$BACKUP"
|
|
"""
|
|
code, out, err = ssh.run(cmd, timeout=60)
|
|
if code != 0:
|
|
self.reporter.add("target_reset", "FAIL", "failed to reset target", stdout=out, stderr=err)
|
|
raise RuntimeError(err or out)
|
|
self.reporter.add("target_reset", "PASS", "database backed up and service restarted", backup=out.strip())
|
|
|
|
def setup_or_login(self) -> None:
|
|
setup = self.api.get("/setup")
|
|
if setup.get("needs_setup"):
|
|
self.api.post(
|
|
"/setup/init",
|
|
{
|
|
"username": self.args.web_user,
|
|
"password": self.args.web_password,
|
|
"hid_backend": "none",
|
|
"msd_enabled": False,
|
|
"ttyd_enabled": False,
|
|
"rustdesk_enabled": False,
|
|
},
|
|
)
|
|
self.reporter.add("setup_init", "PASS", "initial setup completed")
|
|
else:
|
|
self.reporter.add("setup_init", "WARN", "target already initialized; using login path")
|
|
self.authenticate("login", "authenticated with One-KVM")
|
|
|
|
def authenticate(self, check_name: str, detail: str) -> None:
|
|
self.api.post("/auth/login", {"username": self.args.web_user, "password": self.args.web_password})
|
|
self.reporter.add(check_name, "PASS", detail)
|
|
|
|
def run_network_latency_test(self) -> None:
|
|
samples = max(1, int(self.args.network_latency_samples))
|
|
timeout = max(0.1, float(self.args.network_latency_timeout))
|
|
tcp_values: list[float] = []
|
|
http_values: list[float] = []
|
|
errors: list[str] = []
|
|
|
|
for _ in range(samples):
|
|
start = time.perf_counter_ns()
|
|
try:
|
|
with socket.create_connection((self.args.target, self.args.http_port), timeout=timeout):
|
|
pass
|
|
tcp_values.append((time.perf_counter_ns() - start) / 1_000_000)
|
|
except Exception as exc:
|
|
errors.append(f"tcp_connect: {exc}")
|
|
time.sleep(0.05)
|
|
|
|
for _ in range(samples):
|
|
start = time.perf_counter_ns()
|
|
try:
|
|
self.api.get("/health")
|
|
http_values.append((time.perf_counter_ns() - start) / 1_000_000)
|
|
except Exception as exc:
|
|
errors.append(f"http_health: {exc}")
|
|
time.sleep(0.05)
|
|
|
|
tcp_stats = latency_stats(tcp_values)
|
|
http_stats = latency_stats(http_values)
|
|
if tcp_values:
|
|
self.reporter.metric("network_tcp_connect_p50", round(float(tcp_stats["p50_ms"]), 2), "ms")
|
|
self.reporter.metric("network_tcp_connect_p95", round(float(tcp_stats["p95_ms"]), 2), "ms")
|
|
self.reporter.metric("network_tcp_connect_max", round(float(tcp_stats["max_ms"]), 2), "ms")
|
|
if http_values:
|
|
self.reporter.metric("network_http_health_p50", round(float(http_stats["p50_ms"]), 2), "ms")
|
|
self.reporter.metric("network_http_health_p95", round(float(http_stats["p95_ms"]), 2), "ms")
|
|
self.reporter.metric("network_http_health_max", round(float(http_stats["max_ms"]), 2), "ms")
|
|
|
|
if tcp_values and http_values and not errors:
|
|
status = "PASS"
|
|
elif tcp_values or http_values:
|
|
status = "WARN"
|
|
else:
|
|
status = "FAIL"
|
|
self.reporter.add(
|
|
"network_latency",
|
|
status,
|
|
"measured controller-to-target TCP and HTTP latency" if status != "FAIL" else "failed to measure network latency",
|
|
target=self.args.target,
|
|
port=self.args.http_port,
|
|
samples=samples,
|
|
tcp_connect=tcp_stats,
|
|
http_health=http_stats,
|
|
errors=errors[:10],
|
|
)
|
|
|
|
def collect_target_inventory(self) -> None:
|
|
if not self.ssh_password and self.args.ssh_password_prompt:
|
|
self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ")
|
|
ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port)
|
|
commands = {
|
|
"lsusb-tree.txt": "lsusb -t || true",
|
|
"uname.txt": "uname -a || true",
|
|
"systemctl-status.txt": "systemctl status one-kvm --no-pager || true",
|
|
}
|
|
for filename, cmd in commands.items():
|
|
code, out, err = ssh.run(cmd, timeout=30)
|
|
text = out + ("\nSTDERR:\n" + err if err else "")
|
|
path = self.reporter.report_dir / "evidence" / filename
|
|
path.write_text(text, encoding="utf-8")
|
|
self.reporter.add_evidence("目标机清单", filename, path)
|
|
if filename == "lsusb-tree.txt":
|
|
self.lsusb_tree = out
|
|
self.reporter.add("target_inventory", "PASS", "collected lsusb/system evidence")
|
|
|
|
def configure_hid_and_msd(self, devices: dict[str, Any]) -> None:
|
|
udc = devices.get("udc", [])
|
|
serial = devices.get("serial", [])
|
|
if udc:
|
|
udc_name = udc[0]["name"]
|
|
self.api.patch(
|
|
"/config/hid",
|
|
{
|
|
"backend": "otg",
|
|
"otg_udc": udc_name,
|
|
"otg_profile": "full",
|
|
"otg_endpoint_budget": "auto",
|
|
"otg_keyboard_leds": False,
|
|
"mouse_absolute": True,
|
|
},
|
|
)
|
|
self.selected_hid_backend = "otg"
|
|
try:
|
|
self.api.patch("/config/msd", {"enabled": True})
|
|
except Exception as exc:
|
|
if not self.msd_available():
|
|
self.reporter.add("hid_msd_config", "WARN", f"configured OTG HID but failed to enable MSD: {exc}", udc=udc_name)
|
|
return
|
|
self.reporter.add("hid_msd_config", "PASS", "configured OTG HID and enabled MSD", udc=udc_name)
|
|
if not self.args.no_ventoy_sync:
|
|
try:
|
|
self.sync_ventoy_resources()
|
|
except Exception as exc:
|
|
self.reporter.add("ventoy_resources", "WARN", f"failed to sync Ventoy resources: {exc}")
|
|
if not self.args.no_msd_restart_after_enable:
|
|
try:
|
|
self.restart_target_service("msd_restart", "restarted One-KVM after enabling MSD")
|
|
except Exception as exc:
|
|
self.reporter.add("msd_restart", "WARN", f"failed to restart after enabling MSD: {exc}")
|
|
return
|
|
if serial:
|
|
port = serial[0]["path"]
|
|
self.api.patch(
|
|
"/config/hid",
|
|
{
|
|
"backend": "ch9329",
|
|
"ch9329_port": port,
|
|
"ch9329_baudrate": 9600,
|
|
"mouse_absolute": True,
|
|
},
|
|
)
|
|
self.selected_hid_backend = "ch9329"
|
|
self.reporter.add("hid_msd_config", "WARN", "configured CH9329 HID; MSD requires OTG and is skipped", port=port)
|
|
return
|
|
self.selected_hid_backend = "none"
|
|
self.reporter.add("hid_msd_config", "FAIL", "no UDC or CH9329 serial HID device found")
|
|
|
|
def msd_available(self) -> bool:
|
|
try:
|
|
status = self.api.get("/msd/status")
|
|
except Exception:
|
|
return False
|
|
state = status.get("state") if isinstance(status, dict) else None
|
|
return bool(
|
|
isinstance(status, dict)
|
|
and (
|
|
status.get("available")
|
|
or (isinstance(state, dict) and state.get("available"))
|
|
)
|
|
)
|
|
|
|
def restart_target_service(self, check_name: str, detail: str) -> None:
|
|
if not self.ssh_password and self.args.ssh_password_prompt:
|
|
self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ")
|
|
ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port)
|
|
code, out, err = ssh.run("systemctl restart one-kvm || service one-kvm restart", timeout=90)
|
|
if code != 0:
|
|
raise RuntimeError(err or out or "service restart command failed")
|
|
self.api.wait_health(timeout=self.args.health_timeout)
|
|
self.authenticate("login_after_restart", "authenticated after One-KVM restart")
|
|
self.reporter.add(check_name, "PASS", detail)
|
|
|
|
def sync_ventoy_resources(self) -> None:
|
|
source_dir = Path(self.args.ventoy_resources_dir) if self.args.ventoy_resources_dir else default_ventoy_resources_dir()
|
|
if not source_dir.exists():
|
|
self.reporter.add("ventoy_resources", "WARN", f"local Ventoy resources not found: {source_dir}")
|
|
return
|
|
|
|
remote_dir = self.args.data_dir.rstrip("/") + "/ventoy"
|
|
required = ("boot.img", "core.img", "ventoy.disk.img")
|
|
test_cmd = " && ".join(f"test -s {shell_arg(remote_dir + '/' + name)}" for name in required)
|
|
ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port)
|
|
code, _, _ = ssh.run(test_cmd, timeout=20)
|
|
if code == 0:
|
|
self.reporter.add("ventoy_resources", "PASS", "Ventoy resources already present on target", path=remote_dir)
|
|
return
|
|
|
|
code, out, err = ssh.run(f"mkdir -p {shell_arg(remote_dir)}", timeout=30)
|
|
if code != 0:
|
|
raise RuntimeError(err or out or f"failed to create {remote_dir}")
|
|
|
|
client = ssh.connect()
|
|
try:
|
|
sftp = client.open_sftp()
|
|
try:
|
|
for name in required:
|
|
local_plain = source_dir / name
|
|
local_xz = source_dir / f"{name}.xz"
|
|
remote_path = remote_dir + "/" + name
|
|
if local_plain.exists():
|
|
sftp.put(str(local_plain), remote_path)
|
|
elif local_xz.exists():
|
|
with lzma.open(local_xz, "rb") as src, sftp.open(remote_path, "wb") as dst:
|
|
while True:
|
|
chunk = src.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
dst.write(chunk)
|
|
else:
|
|
raise FileNotFoundError(f"{local_plain} or {local_xz}")
|
|
finally:
|
|
sftp.close()
|
|
finally:
|
|
client.close()
|
|
self.reporter.add("ventoy_resources", "PASS", "synced Ventoy resources to target", source=str(source_dir), path=remote_dir)
|
|
|
|
def select_video_cases(self, devices: dict[str, Any]) -> list[VideoInputCase]:
|
|
selector = DeviceSelector(self.lsusb_tree, devices)
|
|
cases = selector.select()
|
|
if not cases:
|
|
self.reporter.add("video_input_select", "FAIL", "no suitable video input case found", devices=devices.get("video", []))
|
|
return []
|
|
self.reporter.add("video_input_select", "PASS", "selected video test cases", cases=[c.__dict__ for c in cases])
|
|
return cases
|
|
|
|
async def wait_for_agent_if_requested(self) -> None:
|
|
if not self.agent:
|
|
self.reporter.add("windows_agent", "SKIP", "agent host not configured")
|
|
return
|
|
if not await self.agent.wait_connected(timeout=self.args.agent_timeout):
|
|
self.reporter.add("windows_agent", "FAIL", "Windows agent did not connect before timeout")
|
|
|
|
async def capture_video_screenshot(self, output_mode: str) -> None:
|
|
titles = {
|
|
"mjpeg": "MJPEG 模式网页截图",
|
|
"h264": "H264 WebRTC 模式网页截图",
|
|
"h265": "H265 WebRTC 尝试网页截图",
|
|
}
|
|
await self.capture_key_screenshot(
|
|
f"video_{output_mode}",
|
|
titles.get(output_mode, f"{output_mode} 网页截图"),
|
|
wait_video=True,
|
|
)
|
|
|
|
async def capture_key_screenshot(self, key: str, title: str, path: str = "/", wait_video: bool = False) -> None:
|
|
if self.args.no_screenshots or key in self.screenshot_keys:
|
|
return
|
|
self.screenshot_keys.add(key)
|
|
try:
|
|
from playwright.async_api import async_playwright
|
|
except ImportError:
|
|
self.reporter.add("screenshot", "WARN", "playwright is not installed; screenshot skipped", key=key)
|
|
return
|
|
|
|
output_dir = self.reporter.report_dir / "evidence" / "screenshots"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = output_dir / f"{key}.png"
|
|
try:
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch(**self.chromium_launch_kwargs())
|
|
context = await browser.new_context(ignore_https_errors=True, viewport={"width": 1440, "height": 960})
|
|
for part in self.api.cookie_header().split("; "):
|
|
if not part or "=" not in part:
|
|
continue
|
|
name, value = part.split("=", 1)
|
|
await context.add_cookies([{"name": name, "value": value, "url": self.api.base}])
|
|
page = await context.new_page()
|
|
await page.goto(self.api.base + path, wait_until="domcontentloaded", timeout=15000)
|
|
if wait_video:
|
|
await self.wait_for_video_screenshot_ready(page)
|
|
else:
|
|
await page.wait_for_timeout(1500)
|
|
await page.screenshot(path=str(output_path), full_page=True)
|
|
await browser.close()
|
|
self.reporter.add_evidence("网页截图", title, output_path)
|
|
except Exception as exc:
|
|
if is_playwright_runtime_error(str(exc).lower()):
|
|
self.reporter.add("screenshot", "WARN", f"Playwright {CHROME_BROWSER_NAME} cannot start; screenshot skipped: {exc}", key=key)
|
|
return
|
|
self.reporter.add("screenshot", "WARN", f"failed to capture screenshot {key}: {exc}", key=key)
|
|
|
|
async def wait_for_video_screenshot_ready(self, page: Any) -> None:
|
|
timeout = max(0, int(self.args.screenshot_wait_ms))
|
|
if timeout <= 0:
|
|
return
|
|
try:
|
|
await page.wait_for_function(VIDEO_SCREENSHOT_READY_JS, timeout=timeout)
|
|
except Exception:
|
|
await page.wait_for_timeout(min(timeout, 1500))
|
|
|
|
def chromium_launch_kwargs(self) -> dict[str, Any]:
|
|
return {
|
|
"headless": True,
|
|
"args": chromium_launch_args(),
|
|
"channel": CHROME_BROWSER_CHANNEL,
|
|
}
|
|
|
|
async def run_video_matrix(self, cases: list[VideoInputCase]) -> None:
|
|
if not cases:
|
|
return
|
|
codecs = self.available_codecs()
|
|
for case in cases:
|
|
self.configure_video_case(case)
|
|
for output_mode in ("mjpeg", "h264", "h265"):
|
|
if output_mode in ("h264", "h265") and output_mode not in codecs:
|
|
self.reporter.add(
|
|
f"video_{case.label}_{output_mode}",
|
|
"SKIP",
|
|
f"{output_mode} is not available",
|
|
case=case.__dict__,
|
|
)
|
|
continue
|
|
try:
|
|
self.apply_video_case(case)
|
|
if output_mode == "mjpeg":
|
|
motion_started = await self.start_mjpeg_motion()
|
|
try:
|
|
result = self.measure_mjpeg(case)
|
|
if motion_started:
|
|
result["dynamic_source_fps"] = self.args.mjpeg_motion_fps
|
|
finally:
|
|
if motion_started:
|
|
await self.stop_mjpeg_motion()
|
|
else:
|
|
result = await self.measure_webrtc(case, output_mode)
|
|
status = self.video_result_status(result)
|
|
self.reporter.add(f"video_{case.label}_{output_mode}", status, result.get("message", ""), **result)
|
|
await self.capture_video_screenshot(output_mode)
|
|
except Exception as exc:
|
|
status = "SKIP" if self.is_environment_skip(exc, output_mode) else "FAIL"
|
|
self.reporter.add(f"video_{case.label}_{output_mode}", status, str(exc), case=case.__dict__)
|
|
await self.capture_video_screenshot(output_mode)
|
|
|
|
async def start_mjpeg_motion(self) -> bool:
|
|
if not self.agent:
|
|
self.reporter.add("mjpeg_motion_source", "WARN", "Windows agent not connected; MJPEG fps may be affected by static-frame suppression")
|
|
return False
|
|
try:
|
|
await self.agent.command("start_dynamic", {"fps": self.args.mjpeg_motion_fps}, timeout=5)
|
|
return True
|
|
except Exception as exc:
|
|
self.reporter.add("mjpeg_motion_source", "WARN", f"failed to start dynamic MJPEG source: {exc}")
|
|
return False
|
|
|
|
async def stop_mjpeg_motion(self) -> None:
|
|
if not self.agent:
|
|
return
|
|
try:
|
|
await self.agent.command("stop_dynamic", {}, timeout=5)
|
|
except Exception as exc:
|
|
self.reporter.add("mjpeg_motion_source", "WARN", f"failed to stop dynamic MJPEG source: {exc}")
|
|
|
|
def video_result_status(self, result: dict[str, Any]) -> str:
|
|
if result.get("unsupported"):
|
|
return "SKIP"
|
|
if result.get("ok"):
|
|
return "PASS"
|
|
return "FAIL"
|
|
|
|
@staticmethod
|
|
def is_environment_skip(exc: Exception, output_mode: str) -> bool:
|
|
text = str(exc).lower()
|
|
if is_playwright_runtime_error(text):
|
|
return True
|
|
return output_mode == "h265" and is_webrtc_codec_unsupported_error(text)
|
|
|
|
def set_stream_mode(self, mode: str, timeout: int = 45) -> dict[str, Any]:
|
|
deadline = time.monotonic() + timeout
|
|
last: Any = None
|
|
while time.monotonic() < deadline:
|
|
response = self.api.client.post("/api/stream/mode", json={"mode": mode})
|
|
data = response.json()
|
|
last = data
|
|
if response.status_code >= 400:
|
|
raise RuntimeError(f"POST /stream/mode -> HTTP {response.status_code}: {data}")
|
|
if data.get("success") is False and not data.get("switching"):
|
|
raise RuntimeError(f"POST /stream/mode failed: {data}")
|
|
ready = self.wait_stream_mode_ready(mode, timeout=max(1, int(deadline - time.monotonic())))
|
|
if ready:
|
|
return data
|
|
time.sleep(0.5)
|
|
raise TimeoutError(f"stream mode {mode} did not become ready; last={last}")
|
|
|
|
def wait_stream_mode_ready(self, mode: str, timeout: int = 45) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
data = self.api.get("/stream/mode")
|
|
current = str(data.get("mode", "")).lower()
|
|
switching = bool(data.get("switching"))
|
|
if not switching and (current == mode or (mode == "webrtc" and current in {"h264", "h265", "vp8", "vp9"})):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
def available_codecs(self) -> set[str]:
|
|
try:
|
|
data = self.api.get("/stream/codecs")
|
|
return {c["id"] for c in data.get("codecs", []) if c.get("available")}
|
|
except Exception as exc:
|
|
self.reporter.add("stream_codecs", "WARN", f"failed to list codecs: {exc}")
|
|
return {"mjpeg", "h264"}
|
|
|
|
def apply_video_case(self, case: VideoInputCase) -> None:
|
|
self.api.patch(
|
|
"/config/video",
|
|
{
|
|
"device": case.device,
|
|
"format": case.fmt,
|
|
"width": case.width,
|
|
"height": case.height,
|
|
"fps": int(round(case.fps)),
|
|
"quality": self.args.jpeg_quality,
|
|
},
|
|
)
|
|
|
|
def configure_video_case(self, case: VideoInputCase) -> None:
|
|
self.apply_video_case(case)
|
|
self.reporter.add(
|
|
f"config_video_{case.label}",
|
|
"PASS",
|
|
f"{case.device} {case.fmt} {case.width}x{case.height}@{case.fps}",
|
|
case=case.__dict__,
|
|
)
|
|
|
|
def measure_mjpeg(self, case: VideoInputCase) -> dict[str, Any]:
|
|
self.set_stream_mode("mjpeg")
|
|
# One-KVM may prefer MJPEG capture when entering MJPEG/HTTP mode. Re-apply
|
|
# the requested input after the mode switch so YUYV/NV12 cases measure
|
|
# the intended capture format instead of the automatic MJPEG fallback.
|
|
self.apply_video_case(case)
|
|
self.api.post("/stream/start", {})
|
|
client_id = f"test-{self.run_id}-{case.label}"
|
|
deadline = time.monotonic() + self.args.sample_seconds
|
|
frame_count = 0
|
|
byte_count = 0
|
|
first_frame_s: float | None = None
|
|
for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds, video_case=case):
|
|
now = time.monotonic()
|
|
if now >= deadline:
|
|
break
|
|
if first_frame_s is None:
|
|
first_frame_s = frame_time
|
|
frame_count += 1
|
|
byte_count += len(frame)
|
|
if frame_count == 0:
|
|
raise RuntimeError("MJPEG stream did not become available before sample timeout")
|
|
duration = self.args.sample_seconds
|
|
measured_fps = frame_count / duration if duration else 0.0
|
|
self.reporter.metric("mjpeg_fps", round(measured_fps, 2), "fps", case=case.label)
|
|
self.reporter.metric("mjpeg_bytes", byte_count, "bytes", case=case.label)
|
|
min_fps = min(case.fps * 0.8, case.fps - 1) if case.fps >= 5 else case.fps * 0.8
|
|
functional = frame_count > 0
|
|
degraded = self.args.strict_performance and functional and measured_fps < min_fps
|
|
message = f"MJPEG {measured_fps:.1f} fps over {duration}s"
|
|
if degraded:
|
|
message += f" (below expected {min_fps:.1f} fps)"
|
|
result = {
|
|
"ok": functional and not degraded,
|
|
"degraded": degraded,
|
|
"message": message,
|
|
"case": case.__dict__,
|
|
"frames": frame_count,
|
|
"fps": measured_fps,
|
|
"input_requested_fps": case.fps,
|
|
"first_frame_ms": None if first_frame_s is None else max(0, (first_frame_s - (deadline - duration)) * 1000),
|
|
"bytes": byte_count,
|
|
}
|
|
if self.args.strict_performance:
|
|
result["min_expected_fps"] = min_fps
|
|
return result
|
|
|
|
async def measure_webrtc(self, case: VideoInputCase, codec: str) -> dict[str, Any]:
|
|
self.set_stream_mode(codec)
|
|
try:
|
|
from playwright.async_api import async_playwright
|
|
except ImportError:
|
|
return {"ok": False, "message": "playwright is not installed", "case": case.__dict__, "codec": codec}
|
|
|
|
cookie_header = self.api.cookie_header()
|
|
js = WEBRTC_MEASURE_JS
|
|
async with async_playwright() as p:
|
|
try:
|
|
browser = await p.chromium.launch(**self.chromium_launch_kwargs())
|
|
except Exception as exc:
|
|
text = str(exc).lower()
|
|
if is_playwright_runtime_error(text):
|
|
return {
|
|
"ok": False,
|
|
"unsupported": True,
|
|
"message": f"Playwright {CHROME_BROWSER_NAME} cannot start; run: {CHROME_INSTALL_COMMAND}",
|
|
"case": case.__dict__,
|
|
"codec": codec,
|
|
}
|
|
raise
|
|
context = await browser.new_context(ignore_https_errors=True)
|
|
for part in cookie_header.split("; "):
|
|
if not part or "=" not in part:
|
|
continue
|
|
name, value = part.split("=", 1)
|
|
await context.add_cookies([{"name": name, "value": value, "url": self.api.base}])
|
|
try:
|
|
page = await context.new_page()
|
|
await page.goto(self.api.base)
|
|
result = await page.evaluate(js, {"seconds": self.args.sample_seconds})
|
|
except Exception as exc:
|
|
text = str(exc)
|
|
if codec == "h265" and is_webrtc_codec_unsupported_error(text):
|
|
return {
|
|
"ok": False,
|
|
"unsupported": True,
|
|
"message": "H.265 WebRTC appears unsupported by browser or streamer",
|
|
"case": case.__dict__,
|
|
"codec": codec,
|
|
"error": text,
|
|
}
|
|
raise
|
|
finally:
|
|
await browser.close()
|
|
fps = float(result.get("avgFps") or 0)
|
|
rtt_ms = float(result.get("maxRtt") or 0) * 1000
|
|
jitter_ms = float(result.get("maxJitter") or 0) * 1000
|
|
self.reporter.metric("webrtc_fps", round(fps, 2), "fps", case=case.label, codec=codec)
|
|
self.reporter.metric("webrtc_rtt_max", round(rtt_ms, 2), "ms", case=case.label, codec=codec)
|
|
self.reporter.metric("webrtc_jitter_max", round(jitter_ms, 2), "ms", case=case.label, codec=codec)
|
|
unsupported = codec == "h265" and int(result.get("framesDecoded") or 0) == 0 and fps == 0
|
|
min_fps = max(1.0, min(case.fps * 0.75, case.fps - 2))
|
|
functional = bool(result.get("connected")) and fps > 0
|
|
degraded = self.args.strict_performance and functional and fps < min_fps
|
|
ok = (not unsupported) and functional and not degraded
|
|
message = "H.265 WebRTC appears unsupported by browser or decoder" if unsupported else f"{codec} WebRTC avg {fps:.1f} fps, max RTT {rtt_ms:.1f} ms"
|
|
if degraded:
|
|
message += f" (below expected {min_fps:.1f} fps)"
|
|
measured = {
|
|
"ok": bool(ok),
|
|
"unsupported": unsupported,
|
|
"degraded": degraded,
|
|
"message": message,
|
|
"case": case.__dict__,
|
|
"codec": codec,
|
|
"input_requested_fps": case.fps,
|
|
**result,
|
|
}
|
|
if self.args.strict_performance:
|
|
measured["min_expected_fps"] = min_fps
|
|
return measured
|
|
|
|
async def run_hdmi_capture_tests(self, cases: list[VideoInputCase]) -> None:
|
|
if self.args.no_hdmi_tests:
|
|
return
|
|
if not self.agent:
|
|
for name in ("hdmi_identity", "hdmi_color_range"):
|
|
self.reporter.add(name, "SKIP", "Windows agent not connected")
|
|
for case in cases:
|
|
for output_mode in VIDEO_LATENCY_OUTPUT_MODES:
|
|
self.reporter.add(
|
|
self.video_latency_check_name(case, output_mode),
|
|
"SKIP",
|
|
"Windows agent not connected",
|
|
video_case=case.__dict__,
|
|
output_mode=output_mode,
|
|
)
|
|
return
|
|
case = self.pick_hdmi_case(cases)
|
|
if not case:
|
|
for name in ("hdmi_identity", "hdmi_color_range"):
|
|
self.reporter.add(name, "SKIP", "no video input case available")
|
|
for output_mode in VIDEO_LATENCY_OUTPUT_MODES:
|
|
self.reporter.add(f"video_latency_{output_mode}", "SKIP", "no video input case available", output_mode=output_mode)
|
|
return
|
|
|
|
try:
|
|
self.configure_hdmi_probe_case(case)
|
|
except Exception as exc:
|
|
for name in ("hdmi_identity", "hdmi_color_range"):
|
|
self.reporter.add(name, "FAIL", f"failed to configure HDMI probe video case: {exc}")
|
|
for latency_case in cases:
|
|
for output_mode in VIDEO_LATENCY_OUTPUT_MODES:
|
|
self.reporter.add(
|
|
self.video_latency_check_name(latency_case, output_mode),
|
|
"FAIL",
|
|
f"failed to configure video case before latency test: {exc}",
|
|
video_case=latency_case.__dict__,
|
|
output_mode=output_mode,
|
|
)
|
|
return
|
|
|
|
try:
|
|
await self.run_hdmi_color_test(case)
|
|
except Exception as exc:
|
|
self.reporter.add("hdmi_identity", "FAIL", str(exc))
|
|
self.reporter.add("hdmi_color_range", "FAIL", str(exc))
|
|
|
|
codecs = self.available_codecs()
|
|
for latency_case in cases:
|
|
for output_mode in VIDEO_LATENCY_OUTPUT_MODES:
|
|
check_name = self.video_latency_check_name(latency_case, output_mode)
|
|
if output_mode in ("h264", "h265") and output_mode not in codecs:
|
|
self.reporter.add(
|
|
check_name,
|
|
"SKIP",
|
|
f"{output_mode} is not available",
|
|
video_case=latency_case.__dict__,
|
|
output_mode=output_mode,
|
|
)
|
|
continue
|
|
try:
|
|
self.apply_video_case(latency_case)
|
|
if output_mode == "mjpeg":
|
|
await self.run_mjpeg_latency_test(latency_case, check_name=check_name, save_evidence=False)
|
|
else:
|
|
await self.run_webrtc_latency_test(latency_case, output_mode, check_name=check_name)
|
|
except Exception as exc:
|
|
status = "SKIP" if self.is_environment_skip(exc, output_mode) else "FAIL"
|
|
self.reporter.add(check_name, status, str(exc), video_case=latency_case.__dict__, output_mode=output_mode)
|
|
|
|
@staticmethod
|
|
def pick_hdmi_case(cases: list[VideoInputCase]) -> VideoInputCase | None:
|
|
if not cases:
|
|
return None
|
|
for case in cases:
|
|
if case.fmt.upper() == "MJPEG" and case.width == 1920 and case.height == 1080:
|
|
return case
|
|
for case in cases:
|
|
if case.fmt.upper() == "MJPEG":
|
|
return case
|
|
return cases[0]
|
|
|
|
def configure_hdmi_probe_case(self, case: VideoInputCase) -> None:
|
|
self.api.patch(
|
|
"/config/video",
|
|
{
|
|
"device": case.device,
|
|
"format": case.fmt,
|
|
"width": case.width,
|
|
"height": case.height,
|
|
"fps": int(round(case.fps)),
|
|
"quality": self.args.jpeg_quality,
|
|
},
|
|
)
|
|
self.reporter.add(
|
|
"config_video_hdmi_probe",
|
|
"PASS",
|
|
f"{case.device} {case.fmt} {case.width}x{case.height}@{case.fps}",
|
|
case=case.__dict__,
|
|
)
|
|
|
|
@staticmethod
|
|
def video_latency_check_name(case: VideoInputCase, output_mode: str) -> str:
|
|
return f"video_latency_{safe_filename(case.label)}_{safe_filename(output_mode)}"
|
|
|
|
async def run_hdmi_color_test(self, case: VideoInputCase) -> None:
|
|
samples: list[dict[str, Any]] = []
|
|
for name, color in HDMI_COLOR_SEQUENCE:
|
|
await self.agent.command("show", {"color": color, "full": True}, timeout=5)
|
|
await asyncio.sleep(self.args.hdmi_settle_ms / 1000)
|
|
expected = hex_to_rgb(color)
|
|
stats = self.capture_mjpeg_rgb_mean(
|
|
f"hdmi_color_{name}",
|
|
timeout=self.args.hdmi_capture_timeout,
|
|
evidence_title=f"HDMI 纯色采集帧 {name}",
|
|
expected_rgb=expected,
|
|
threshold=self.args.hdmi_color_fail,
|
|
video_case=case,
|
|
)
|
|
err = rgb_error(expected, stats["mean_rgb"])
|
|
closest_name, closest_error = closest_hdmi_color(stats["mean_rgb"])
|
|
sample = {
|
|
"name": name,
|
|
"expected_hex": color,
|
|
"expected_rgb": expected,
|
|
"measured_rgb_mean": stats["mean_rgb"],
|
|
"measured_rgb_stddev": stats["stddev_rgb"],
|
|
"width": stats["width"],
|
|
"height": stats["height"],
|
|
"mean_abs_error": err["mean_abs_error"],
|
|
"max_abs_error": err["max_abs_error"],
|
|
"closest_color": closest_name,
|
|
"closest_error": closest_error,
|
|
"target_detected": bool(stats.get("target_detected")),
|
|
"frames_seen": int(stats.get("frames_seen") or 0),
|
|
}
|
|
sample["identity_match"] = self.hdmi_identity_match(sample)
|
|
samples.append(sample)
|
|
self.reporter.metric("hdmi_color_mean_abs_error", round(err["mean_abs_error"], 2), "rgb_level", color=name)
|
|
self.reporter.metric("hdmi_color_max_abs_error", round(err["max_abs_error"], 2), "rgb_level", color=name)
|
|
|
|
identity_colors = [s for s in samples if s["name"] in {"red", "green", "blue"}]
|
|
identity_ok = bool(identity_colors) and all(bool(s["identity_match"]) for s in identity_colors)
|
|
identity_status = "PASS" if identity_ok else "FAIL"
|
|
self.reporter.add(
|
|
"hdmi_identity",
|
|
identity_status,
|
|
"HDMI capture matches Windows fullscreen color sequence" if identity_ok else "HDMI capture did not match Windows fullscreen color sequence",
|
|
samples=identity_colors,
|
|
video_case=case.__dict__,
|
|
)
|
|
|
|
max_mean_error = max((float(s["mean_abs_error"]) for s in samples), default=999.0)
|
|
max_abs_error = max((float(s["max_abs_error"]) for s in samples), default=999.0)
|
|
if max_mean_error <= self.args.hdmi_color_warn:
|
|
color_status = "PASS"
|
|
elif max_mean_error <= self.args.hdmi_color_fail:
|
|
color_status = "WARN"
|
|
else:
|
|
color_status = "FAIL"
|
|
self.reporter.add(
|
|
"hdmi_color_range",
|
|
color_status,
|
|
f"max mean RGB error {max_mean_error:.1f}, max channel error {max_abs_error:.1f}",
|
|
samples=samples,
|
|
warn_threshold=self.args.hdmi_color_warn,
|
|
fail_threshold=self.args.hdmi_color_fail,
|
|
video_case=case.__dict__,
|
|
)
|
|
|
|
@staticmethod
|
|
def hdmi_identity_match(sample: dict[str, Any]) -> bool:
|
|
name = str(sample["name"])
|
|
mean = tuple(float(x) for x in sample["measured_rgb_mean"])
|
|
if name == "red":
|
|
return mean[0] > 120 and mean[0] > mean[1] * 1.8 and mean[0] > mean[2] * 1.8
|
|
if name == "green":
|
|
return mean[1] > 120 and mean[1] > mean[0] * 1.8 and mean[1] > mean[2] * 1.8
|
|
if name == "blue":
|
|
return mean[2] > 120 and mean[2] > mean[0] * 1.8 and mean[2] > mean[1] * 1.8
|
|
return float(sample["mean_abs_error"]) <= 60
|
|
|
|
async def run_mjpeg_latency_test(self, case: VideoInputCase, check_name: str = "hdmi_latency_mjpeg", save_evidence: bool = True) -> None:
|
|
if self.args.hdmi_latency_trials <= 0:
|
|
self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode="mjpeg")
|
|
return
|
|
offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt")
|
|
trials: list[dict[str, Any]] = []
|
|
colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")]
|
|
for i in range(self.args.hdmi_latency_trials):
|
|
source, target = colors[i % len(colors)]
|
|
await self.agent.command("show", {"color": source, "full": True}, timeout=5)
|
|
await asyncio.sleep(self.args.hdmi_settle_ms / 1000)
|
|
await self.agent.command("schedule_color", {"color": target, "delay_ms": self.args.hdmi_latency_delay_ms}, timeout=5)
|
|
detect_timeout = (self.args.hdmi_latency_delay_ms + self.args.hdmi_latency_timeout_ms) / 1000
|
|
detected = self.detect_mjpeg_color(
|
|
hex_to_rgb(target),
|
|
f"{check_name}_{i}",
|
|
timeout=detect_timeout,
|
|
threshold=self.args.hdmi_color_fail,
|
|
evidence_title=f"HDMI 延迟命中帧 {case.label} #{i + 1}" if save_evidence else None,
|
|
video_case=case,
|
|
)
|
|
display = await self.agent.command("display_state", {}, timeout=5)
|
|
actual_agent_ns = int(display.get("last_change_unix_nano") or 0)
|
|
actual_linux_ns = actual_agent_ns - offset_ns
|
|
latency_ms = (int(detected["wall_ns"]) - actual_linux_ns) / 1_000_000
|
|
trial = {
|
|
"trial": i + 1,
|
|
"source": source,
|
|
"target": target,
|
|
"latency_ms": latency_ms,
|
|
"detected_rgb_mean": detected["mean_rgb"],
|
|
"mean_abs_error": detected["mean_abs_error"],
|
|
"detected_wall_ns": detected["wall_ns"],
|
|
"agent_change_unix_nano": actual_agent_ns,
|
|
"clock_sync": sync,
|
|
}
|
|
trials.append(trial)
|
|
self.reporter.metric(check_name, round(latency_ms, 2), "ms", trial=i + 1, target=target, case=case.label)
|
|
|
|
latencies = [float(t["latency_ms"]) for t in trials]
|
|
p50 = percentile(latencies, 50)
|
|
p95 = percentile(latencies, 95)
|
|
max_latency = max(latencies) if latencies else 0.0
|
|
self.reporter.metric(f"{check_name}_p50", round(p50, 2), "ms", case=case.label)
|
|
self.reporter.metric(f"{check_name}_p95", round(p95, 2), "ms", case=case.label)
|
|
self.reporter.metric(f"{check_name}_max", round(max_latency, 2), "ms", case=case.label)
|
|
if not trials:
|
|
status = "FAIL"
|
|
elif p95 <= self.args.hdmi_latency_fail_ms:
|
|
status = "PASS"
|
|
else:
|
|
status = "FAIL"
|
|
self.reporter.add(
|
|
check_name,
|
|
status,
|
|
f"MJPEG visual latency for {case.label}: p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms",
|
|
trials=trials,
|
|
p50_ms=p50,
|
|
p95_ms=p95,
|
|
max_ms=max_latency,
|
|
functional_threshold_ms=self.args.hdmi_latency_fail_ms,
|
|
video_case=case.__dict__,
|
|
output_mode="mjpeg",
|
|
)
|
|
|
|
async def run_webrtc_latency_test(self, case: VideoInputCase, output_mode: str, check_name: str) -> None:
|
|
if self.args.hdmi_latency_trials <= 0:
|
|
self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode=output_mode)
|
|
return
|
|
self.set_stream_mode(output_mode)
|
|
self.apply_video_case(case)
|
|
try:
|
|
from playwright.async_api import async_playwright
|
|
except ImportError as exc:
|
|
raise RuntimeError("playwright is required for WebRTC visual latency tests; run pip install -r requirements.txt") from exc
|
|
|
|
offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt")
|
|
cookie_header = self.api.cookie_header()
|
|
trials: list[dict[str, Any]] = []
|
|
colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")]
|
|
async with async_playwright() as p:
|
|
try:
|
|
browser = await p.chromium.launch(**self.chromium_launch_kwargs())
|
|
except Exception as exc:
|
|
text = str(exc).lower()
|
|
if is_playwright_runtime_error(text):
|
|
raise RuntimeError(f"Playwright {CHROME_BROWSER_NAME} cannot start; run: {CHROME_INSTALL_COMMAND}") from exc
|
|
raise
|
|
context = await browser.new_context(ignore_https_errors=True)
|
|
for part in cookie_header.split("; "):
|
|
if not part or "=" not in part:
|
|
continue
|
|
name, value = part.split("=", 1)
|
|
await context.add_cookies([{"name": name, "value": value, "url": self.api.base}])
|
|
page = await context.new_page()
|
|
try:
|
|
await page.goto(self.api.base, wait_until="domcontentloaded", timeout=15000)
|
|
setup = await page.evaluate(WEBRTC_LATENCY_SETUP_JS, {"timeoutMs": 15000})
|
|
if not setup.get("connected"):
|
|
raise RuntimeError(f"{output_mode} WebRTC did not connect: {setup}")
|
|
for i in range(self.args.hdmi_latency_trials):
|
|
source, target = colors[i % len(colors)]
|
|
await self.agent.command("show", {"color": source, "full": True}, timeout=5)
|
|
await asyncio.sleep(self.args.hdmi_settle_ms / 1000)
|
|
source_seen = await page.evaluate(
|
|
WEBRTC_COLOR_DETECT_JS,
|
|
{
|
|
"targetRgb": hex_to_rgb(source),
|
|
"timeoutMs": max(1000, self.args.hdmi_settle_ms + 2000),
|
|
"threshold": self.args.hdmi_color_fail,
|
|
},
|
|
)
|
|
if not source_seen.get("target_detected"):
|
|
raise RuntimeError(f"{output_mode} WebRTC did not show source color before latency trial: {source_seen}")
|
|
|
|
detect_timeout_ms = self.args.hdmi_latency_delay_ms + self.args.hdmi_latency_timeout_ms
|
|
detect_task = asyncio.create_task(
|
|
page.evaluate(
|
|
WEBRTC_COLOR_DETECT_JS,
|
|
{
|
|
"targetRgb": hex_to_rgb(target),
|
|
"timeoutMs": detect_timeout_ms,
|
|
"threshold": self.args.hdmi_color_fail,
|
|
},
|
|
)
|
|
)
|
|
await self.agent.command("schedule_color", {"color": target, "delay_ms": self.args.hdmi_latency_delay_ms}, timeout=5)
|
|
detected = await detect_task
|
|
if not detected.get("target_detected"):
|
|
raise RuntimeError(f"{output_mode} WebRTC target color not detected before timeout: {detected}")
|
|
display = await self.agent.command("display_state", {}, timeout=5)
|
|
actual_agent_ns = int(display.get("last_change_unix_nano") or 0)
|
|
actual_linux_ns = actual_agent_ns - offset_ns
|
|
latency_ms = (int(detected["wall_ns"]) - actual_linux_ns) / 1_000_000
|
|
trial = {
|
|
"trial": i + 1,
|
|
"source": source,
|
|
"target": target,
|
|
"latency_ms": latency_ms,
|
|
"detected_rgb_mean": detected["mean_rgb"],
|
|
"mean_abs_error": detected["mean_abs_error"],
|
|
"detected_wall_ns": detected["wall_ns"],
|
|
"agent_change_unix_nano": actual_agent_ns,
|
|
"clock_sync": sync,
|
|
}
|
|
trials.append(trial)
|
|
self.reporter.metric(check_name, round(latency_ms, 2), "ms", trial=i + 1, target=target, case=case.label, codec=output_mode)
|
|
finally:
|
|
try:
|
|
await page.evaluate(WEBRTC_LATENCY_CLOSE_JS)
|
|
except Exception:
|
|
pass
|
|
await browser.close()
|
|
|
|
latencies = [float(t["latency_ms"]) for t in trials]
|
|
p50 = percentile(latencies, 50)
|
|
p95 = percentile(latencies, 95)
|
|
max_latency = max(latencies) if latencies else 0.0
|
|
self.reporter.metric(f"{check_name}_p50", round(p50, 2), "ms", case=case.label, codec=output_mode)
|
|
self.reporter.metric(f"{check_name}_p95", round(p95, 2), "ms", case=case.label, codec=output_mode)
|
|
self.reporter.metric(f"{check_name}_max", round(max_latency, 2), "ms", case=case.label, codec=output_mode)
|
|
status = "PASS" if trials and p95 <= self.args.hdmi_latency_fail_ms else "FAIL"
|
|
self.reporter.add(
|
|
check_name,
|
|
status,
|
|
f"{output_mode} visual latency for {case.label}: p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms",
|
|
trials=trials,
|
|
p50_ms=p50,
|
|
p95_ms=p95,
|
|
max_ms=max_latency,
|
|
functional_threshold_ms=self.args.hdmi_latency_fail_ms,
|
|
video_case=case.__dict__,
|
|
output_mode=output_mode,
|
|
)
|
|
|
|
async def sync_agent_clock(self, metric_name: str = "agent_clock_sync_rtt") -> tuple[int, dict[str, Any]]:
|
|
best: tuple[int, int, dict[str, Any]] | None = None
|
|
for _ in range(7):
|
|
t0 = time.time_ns()
|
|
payload = await self.agent.command("ping", {}, timeout=5)
|
|
t1 = time.time_ns()
|
|
rtt = t1 - t0
|
|
midpoint = (t0 + t1) // 2
|
|
offset = int(payload.get("unix_nano") or 0) - midpoint
|
|
detail = {
|
|
"offset_ns": offset,
|
|
"rtt_ns": rtt,
|
|
"offset_ms": offset / 1_000_000,
|
|
"rtt_ms": rtt / 1_000_000,
|
|
}
|
|
if best is None or rtt < best[0]:
|
|
best = (rtt, offset, detail)
|
|
await asyncio.sleep(0.05)
|
|
assert best is not None
|
|
self.reporter.metric(metric_name, round(best[2]["rtt_ms"], 3), "ms")
|
|
return best[1], best[2]
|
|
|
|
def capture_mjpeg_rgb_mean(
|
|
self,
|
|
client_label: str,
|
|
timeout: float = 6.0,
|
|
evidence_title: str | None = None,
|
|
expected_rgb: tuple[int, int, int] | None = None,
|
|
threshold: float | None = None,
|
|
video_case: VideoInputCase | None = None,
|
|
) -> dict[str, Any]:
|
|
threshold = self.args.hdmi_color_fail if threshold is None else threshold
|
|
required_matches = max(1, int(self.args.hdmi_match_frames))
|
|
best: dict[str, Any] | None = None
|
|
best_frame: bytes | None = None
|
|
last: dict[str, Any] | None = None
|
|
last_frame: bytes | None = None
|
|
consecutive_matches = 0
|
|
frames_seen = 0
|
|
|
|
for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case):
|
|
frames_seen += 1
|
|
stats = jpeg_rgb_stats(frame)
|
|
stats["wall_ns"] = wall_ns
|
|
stats["frames_seen"] = frames_seen
|
|
last = stats
|
|
last_frame = frame
|
|
if expected_rgb is None:
|
|
stats["target_detected"] = True
|
|
if evidence_title:
|
|
self.save_frame_evidence(client_label, evidence_title, frame)
|
|
return stats
|
|
|
|
err = rgb_error(expected_rgb, stats["mean_rgb"])
|
|
stats.update(err)
|
|
if best is None or float(err["mean_abs_error"]) < float(best.get("mean_abs_error", 999.0)):
|
|
best = dict(stats)
|
|
best_frame = frame
|
|
|
|
if float(err["mean_abs_error"]) <= threshold:
|
|
consecutive_matches += 1
|
|
if consecutive_matches >= required_matches:
|
|
stats["target_detected"] = True
|
|
if evidence_title:
|
|
self.save_frame_evidence(client_label, evidence_title, frame)
|
|
return stats
|
|
else:
|
|
consecutive_matches = 0
|
|
|
|
chosen = best or last
|
|
chosen_frame = best_frame or last_frame
|
|
if chosen is not None and chosen_frame is not None:
|
|
chosen["target_detected"] = False
|
|
chosen["frames_seen"] = frames_seen
|
|
if evidence_title:
|
|
self.save_frame_evidence(client_label, evidence_title, chosen_frame)
|
|
return chosen
|
|
raise RuntimeError(f"no MJPEG frame captured for {client_label}")
|
|
|
|
def detect_mjpeg_color(
|
|
self,
|
|
expected_rgb: tuple[int, int, int],
|
|
client_label: str,
|
|
timeout: float,
|
|
threshold: float,
|
|
evidence_title: str | None = None,
|
|
video_case: VideoInputCase | None = None,
|
|
) -> dict[str, Any]:
|
|
last: dict[str, Any] | None = None
|
|
last_frame: bytes | None = None
|
|
frames_seen = 0
|
|
for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case):
|
|
frames_seen += 1
|
|
stats = jpeg_rgb_stats(frame)
|
|
err = rgb_error(expected_rgb, stats["mean_rgb"])
|
|
stats.update(err)
|
|
stats["wall_ns"] = wall_ns
|
|
stats["frames_seen"] = frames_seen
|
|
last = stats
|
|
last_frame = frame
|
|
if float(err["mean_abs_error"]) <= threshold:
|
|
stats["target_detected"] = True
|
|
if evidence_title:
|
|
self.save_frame_evidence(client_label, evidence_title, frame)
|
|
return stats
|
|
if last_frame is not None and evidence_title:
|
|
self.save_frame_evidence(f"{client_label}_last", f"{evidence_title}(未命中末帧)", last_frame)
|
|
raise RuntimeError(f"target HDMI color not detected before timeout; last={last}")
|
|
|
|
def save_frame_evidence(self, label: str, title: str, frame: bytes) -> None:
|
|
output_dir = self.reporter.report_dir / "evidence" / "hdmi-frames"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
path = output_dir / f"{safe_filename(label)}.jpg"
|
|
path.write_bytes(frame)
|
|
self.reporter.add_evidence("HDMI 采集帧", title, path)
|
|
|
|
def iter_mjpeg_frames(self, client_label: str, timeout: float, video_case: VideoInputCase | None = None):
|
|
self.set_stream_mode("mjpeg")
|
|
if video_case is not None:
|
|
self.apply_video_case(video_case)
|
|
self.api.post("/stream/start", {})
|
|
client_id = f"test-{self.run_id}-{client_label}"
|
|
url = f"{self.api.base}/api/stream/mjpeg?client_id={client_id}"
|
|
deadline = time.monotonic() + timeout
|
|
buf = b""
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
stream_timeout = httpx.Timeout(timeout=max(1.0, timeout), connect=5.0, read=1.0, write=5.0, pool=5.0)
|
|
with self.api.client.stream("GET", url, timeout=stream_timeout) as response:
|
|
response.raise_for_status()
|
|
for chunk in response.iter_bytes():
|
|
if time.monotonic() >= deadline:
|
|
return
|
|
if not chunk:
|
|
continue
|
|
buf += chunk
|
|
while True:
|
|
soi = buf.find(b"\xff\xd8")
|
|
if soi < 0:
|
|
buf = buf[-2:]
|
|
break
|
|
eoi = buf.find(b"\xff\xd9", soi + 2)
|
|
if eoi < 0:
|
|
buf = buf[soi:]
|
|
if len(buf) > 8 * 1024 * 1024:
|
|
buf = buf[-1024 * 1024:]
|
|
break
|
|
frame = buf[soi : eoi + 2]
|
|
buf = buf[eoi + 2 :]
|
|
yield frame, time.monotonic(), time.time_ns()
|
|
except httpx.ReadTimeout:
|
|
continue
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code != 503:
|
|
raise
|
|
time.sleep(0.5)
|
|
self.set_stream_mode("mjpeg", timeout=10)
|
|
if video_case is not None:
|
|
self.apply_video_case(video_case)
|
|
self.api.post("/stream/start", {})
|
|
|
|
async def run_hid_test(self) -> None:
|
|
try:
|
|
status = self.api.get("/hid/status")
|
|
except Exception as exc:
|
|
self.reporter.add("hid_status", "FAIL", str(exc))
|
|
return
|
|
if not status.get("available"):
|
|
self.reporter.add("hid_status", "FAIL", "HID is not available", status=status)
|
|
return
|
|
self.reporter.add("hid_status", "PASS", "HID backend is available", status=status)
|
|
if not self.agent:
|
|
self.reporter.add("hid_input", "SKIP", "Windows agent not connected")
|
|
self.reporter.add("hid_latency", "SKIP", "Windows agent not connected")
|
|
return
|
|
try:
|
|
keyboard_events = await self.run_hid_keyboard_matrix()
|
|
keyboard = self.evaluate_hid_keyboard_events(keyboard_events)
|
|
mouse = await self.run_hid_mouse_matrix()
|
|
event_count = len(keyboard_events) + len(mouse.get("events", []))
|
|
ok = bool(keyboard.get("ok")) and bool(mouse.get("ok"))
|
|
message = (
|
|
"HID matrix passed: alphanumeric, function keys, safe combos, absolute/relative mouse"
|
|
if ok
|
|
else "HID matrix failed; see missing key/mouse details"
|
|
)
|
|
sample_events = [*keyboard_events[:60], *(mouse.get("events", [])[:20])]
|
|
self.reporter.add(
|
|
"hid_input",
|
|
"PASS" if ok else "FAIL",
|
|
message,
|
|
event_count=event_count,
|
|
chars=keyboard.get("chars", ""),
|
|
keyboard=keyboard,
|
|
mouse={k: v for k, v in mouse.items() if k != "events"},
|
|
events=sample_events,
|
|
)
|
|
except Exception as exc:
|
|
self.reporter.add("hid_input", "FAIL", str(exc))
|
|
try:
|
|
await self.run_hid_latency_test()
|
|
except Exception as exc:
|
|
self.reporter.add("hid_latency", "FAIL", str(exc))
|
|
|
|
async def connect_hid_websocket(self) -> Any:
|
|
try:
|
|
import websockets
|
|
except ImportError as exc:
|
|
raise RuntimeError("websockets is required for HID test") from exc
|
|
|
|
headers = {"Cookie": self.api.cookie_header()}
|
|
uri = f"ws://{self.args.target}:{self.args.http_port}/api/ws/hid"
|
|
header_arg = "additional_headers" if "additional_headers" in inspect.signature(websockets.connect).parameters else "extra_headers"
|
|
connect_kwargs = {"max_size": 1024 * 1024, header_arg: headers}
|
|
ws = await websockets.connect(uri, **connect_kwargs)
|
|
initial = await ws.recv()
|
|
if isinstance(initial, str) or not initial or initial[0] != 0:
|
|
await ws.close()
|
|
raise RuntimeError(f"HID WebSocket unavailable: {initial!r}")
|
|
return ws
|
|
|
|
async def run_hid_keyboard_matrix(self) -> list[dict[str, Any]]:
|
|
if not self.agent:
|
|
return []
|
|
await self.agent.command("begin_hid_capture", {}, timeout=10)
|
|
await asyncio.sleep(0.3)
|
|
ws = await self.connect_hid_websocket()
|
|
try:
|
|
for ch in HID_ALNUM_TEXT:
|
|
usage, mod = HID_KEY_USAGE[ch]
|
|
await self.send_hid_key(ws, usage, mod)
|
|
for _, usage, _ in HID_FUNCTION_KEYS:
|
|
await self.send_hid_key(ws, usage, 0)
|
|
for _, usage, _, mod in HID_SAFE_COMBOS:
|
|
await self.send_hid_key(ws, usage, mod)
|
|
finally:
|
|
await ws.close()
|
|
await asyncio.sleep(1)
|
|
payload = await self.agent.command("get_hid_events", {}, timeout=10)
|
|
return payload.get("events", [])
|
|
|
|
async def run_hid_mouse_matrix(self) -> dict[str, Any]:
|
|
if not self.agent:
|
|
return {"ok": False, "reason": "Windows agent not connected", "events": []}
|
|
|
|
screen = ((self.agent.hello or {}).get("screen") or {}) if self.agent else {}
|
|
width = int(screen.get("width") or 0)
|
|
height = int(screen.get("height") or 0)
|
|
if width <= 0 or height <= 0:
|
|
return {"ok": False, "reason": "Windows agent did not report screen size", "events": []}
|
|
|
|
await self.agent.command("begin_hid_capture", {}, timeout=10)
|
|
await asyncio.sleep(0.3)
|
|
ws = await self.connect_hid_websocket()
|
|
try:
|
|
await self.send_hid_mouse(ws, 0x01, 8192, 8192, 0)
|
|
await asyncio.sleep(0.2)
|
|
abs_x = 19660
|
|
abs_y = 19660
|
|
await self.send_hid_mouse(ws, 0x01, abs_x, abs_y, 0)
|
|
await asyncio.sleep(0.4)
|
|
payload = await self.agent.command("get_hid_events", {}, timeout=10)
|
|
base_events = payload.get("events", [])
|
|
base_move = self.last_event_of_type(base_events, "mouse_move")
|
|
|
|
rel_dx = 32
|
|
rel_dy = 24
|
|
await self.send_hid_mouse(ws, 0x00, rel_dx, rel_dy, 0)
|
|
await asyncio.sleep(0.4)
|
|
payload = await self.agent.command("get_hid_events", {}, timeout=10)
|
|
events = payload.get("events", [])
|
|
finally:
|
|
await ws.close()
|
|
|
|
target_x = self.hid_abs_to_pixel(abs_x, width)
|
|
target_y = self.hid_abs_to_pixel(abs_y, height)
|
|
abs_tolerance_x = max(100, int(width * 0.10))
|
|
abs_tolerance_y = max(80, int(height * 0.10))
|
|
abs_ok = bool(
|
|
base_move
|
|
and abs(int(base_move.get("x", -9999)) - target_x) <= abs_tolerance_x
|
|
and abs(int(base_move.get("y", -9999)) - target_y) <= abs_tolerance_y
|
|
)
|
|
|
|
relative_events = events[len(base_events) :]
|
|
rel_move = self.last_event_of_type(relative_events, "mouse_move")
|
|
rel_delta_x = int(rel_move.get("x", 0)) - int(base_move.get("x", 0)) if rel_move and base_move else 0
|
|
rel_delta_y = int(rel_move.get("y", 0)) - int(base_move.get("y", 0)) if rel_move and base_move else 0
|
|
rel_ok = bool(
|
|
base_move
|
|
and rel_move
|
|
and 2 <= rel_delta_x <= max(300, int(width * 0.25))
|
|
and 2 <= rel_delta_y <= max(300, int(height * 0.25))
|
|
)
|
|
|
|
return {
|
|
"ok": abs_ok and rel_ok,
|
|
"absolute_ok": abs_ok,
|
|
"relative_ok": rel_ok,
|
|
"screen": {"width": width, "height": height},
|
|
"absolute_target": {"x": target_x, "y": target_y},
|
|
"absolute_observed": {"x": base_move.get("x"), "y": base_move.get("y")} if base_move else None,
|
|
"relative_delta": {"x": rel_delta_x, "y": rel_delta_y},
|
|
"events": events,
|
|
}
|
|
|
|
async def run_hid_latency_test(self) -> None:
|
|
if not self.agent:
|
|
self.reporter.add("hid_latency", "SKIP", "Windows agent not connected")
|
|
return
|
|
trial_count = max(0, int(self.args.hid_latency_trials))
|
|
if trial_count <= 0:
|
|
self.reporter.add("hid_latency", "SKIP", "HID latency trials disabled")
|
|
return
|
|
|
|
key_name, usage, vk = HID_LATENCY_KEY
|
|
offset_ns, sync = await self.sync_agent_clock("hid_agent_clock_sync_rtt")
|
|
trials: list[dict[str, Any]] = []
|
|
missing = 0
|
|
ws = await self.connect_hid_websocket()
|
|
try:
|
|
for i in range(trial_count):
|
|
await self.agent.command("begin_hid_capture", {}, timeout=10)
|
|
await asyncio.sleep(0.1)
|
|
send_ns = time.time_ns()
|
|
await ws.send(bytes([0x01, 0x00, usage, 0x00]))
|
|
await asyncio.sleep(0.02)
|
|
await ws.send(bytes([0x01, 0x01, usage, 0x00]))
|
|
event = await self.wait_hid_key_down(vk, timeout=2.0)
|
|
if not event:
|
|
missing += 1
|
|
continue
|
|
event_agent_ns = int(event.get("unix_nano") or 0)
|
|
event_linux_ns = event_agent_ns - offset_ns
|
|
raw_latency_ms = (event_linux_ns - send_ns) / 1_000_000
|
|
latency_ms = max(0.0, raw_latency_ms)
|
|
trial = {
|
|
"trial": i + 1,
|
|
"key": key_name,
|
|
"latency_ms": latency_ms,
|
|
"raw_latency_ms": raw_latency_ms,
|
|
"sent_unix_nano": send_ns,
|
|
"event_agent_unix_nano": event_agent_ns,
|
|
"event_linux_unix_nano": event_linux_ns,
|
|
"clock_sync": sync,
|
|
}
|
|
trials.append(trial)
|
|
self.reporter.metric("hid_latency", round(latency_ms, 2), "ms", trial=i + 1, key=key_name)
|
|
await asyncio.sleep(0.08)
|
|
finally:
|
|
await ws.close()
|
|
|
|
latencies = [float(t["latency_ms"]) for t in trials]
|
|
p50 = percentile(latencies, 50)
|
|
p95 = percentile(latencies, 95)
|
|
max_latency = max(latencies) if latencies else 0.0
|
|
if latencies:
|
|
self.reporter.metric("hid_latency_p50", round(p50, 2), "ms", key=key_name)
|
|
self.reporter.metric("hid_latency_p95", round(p95, 2), "ms", key=key_name)
|
|
self.reporter.metric("hid_latency_max", round(max_latency, 2), "ms", key=key_name)
|
|
|
|
if not trials:
|
|
status = "FAIL"
|
|
elif p95 <= self.args.hid_latency_warn_ms and missing == 0:
|
|
status = "PASS"
|
|
elif p95 <= self.args.hid_latency_fail_ms:
|
|
status = "WARN"
|
|
else:
|
|
status = "FAIL"
|
|
self.reporter.add(
|
|
"hid_latency",
|
|
status,
|
|
f"HID key latency p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms",
|
|
key=key_name,
|
|
trials=trials,
|
|
missing_trials=missing,
|
|
p50_ms=p50,
|
|
p95_ms=p95,
|
|
max_ms=max_latency,
|
|
warn_threshold_ms=self.args.hid_latency_warn_ms,
|
|
fail_threshold_ms=self.args.hid_latency_fail_ms,
|
|
)
|
|
|
|
async def wait_hid_key_down(self, vk: int, timeout: float) -> dict[str, Any] | None:
|
|
if not self.agent:
|
|
return None
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
payload = await self.agent.command("get_hid_events", {}, timeout=10)
|
|
for event in payload.get("events", []):
|
|
if event.get("type") == "key_down" and int(event.get("code") or 0) == vk:
|
|
return event
|
|
await asyncio.sleep(0.05)
|
|
return None
|
|
|
|
async def send_hid_sequence(self, text: str) -> None:
|
|
ws = await self.connect_hid_websocket()
|
|
try:
|
|
for ch in text:
|
|
if ch not in HID_KEY_USAGE:
|
|
continue
|
|
usage, mod = HID_KEY_USAGE[ch]
|
|
await self.send_hid_key(ws, usage, mod)
|
|
finally:
|
|
await ws.close()
|
|
|
|
async def send_hid_key(self, ws: Any, usage: int, modifier: int = 0) -> None:
|
|
await ws.send(bytes([0x01, 0x00, usage, modifier]))
|
|
await asyncio.sleep(0.02)
|
|
await ws.send(bytes([0x01, 0x01, usage, modifier]))
|
|
await asyncio.sleep(0.02)
|
|
|
|
async def send_hid_mouse(self, ws: Any, event_type: int, x: int, y: int, value: int) -> None:
|
|
await ws.send(bytes([0x02, event_type]) + struct.pack("<hhB", x, y, value & 0xFF))
|
|
|
|
@staticmethod
|
|
def hid_abs_to_pixel(value: int, span: int) -> int:
|
|
return int(round(value * max(span - 1, 0) / 32767))
|
|
|
|
@staticmethod
|
|
def last_event_of_type(events: list[dict[str, Any]], event_type: str) -> dict[str, Any] | None:
|
|
for event in reversed(events):
|
|
if event.get("type") == event_type:
|
|
return event
|
|
return None
|
|
|
|
@staticmethod
|
|
def evaluate_hid_keyboard_events(events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
chars = "".join(e.get("char", "") for e in events if e.get("type") == "char")
|
|
down_codes = {int(e.get("code") or 0) for e in events if e.get("type") == "key_down"}
|
|
up_codes = {int(e.get("code") or 0) for e in events if e.get("type") == "key_up"}
|
|
|
|
missing_alnum_down = [label for label, _, vk in HID_ALNUM_KEYS if vk not in down_codes]
|
|
missing_alnum_up = [label for label, _, vk in HID_ALNUM_KEYS if vk not in up_codes]
|
|
missing_functions_down = [label for label, _, vk in HID_FUNCTION_KEYS if vk not in down_codes]
|
|
missing_functions_up = [label for label, _, vk in HID_FUNCTION_KEYS if vk not in up_codes]
|
|
missing_combos = [
|
|
name
|
|
for name, _, vk, mod in HID_SAFE_COMBOS
|
|
if not any(
|
|
e.get("type") == "key_down"
|
|
and int(e.get("code") or 0) == vk
|
|
and (int(e.get("modifiers") or 0) & mod) == mod
|
|
for e in events
|
|
)
|
|
]
|
|
|
|
ok = not (
|
|
missing_alnum_down
|
|
or missing_alnum_up
|
|
or missing_functions_down
|
|
or missing_functions_up
|
|
or missing_combos
|
|
)
|
|
return {
|
|
"ok": ok,
|
|
"chars": chars,
|
|
"event_count": len(events),
|
|
"alphanumeric_tested": len(HID_ALNUM_KEYS),
|
|
"function_keys_tested": len(HID_FUNCTION_KEYS),
|
|
"safe_combos_tested": len(HID_SAFE_COMBOS),
|
|
"missing_alphanumeric_down": missing_alnum_down,
|
|
"missing_alphanumeric_up": missing_alnum_up,
|
|
"missing_function_down": missing_functions_down,
|
|
"missing_function_up": missing_functions_up,
|
|
"missing_combos": missing_combos,
|
|
}
|
|
|
|
async def run_msd_test(self) -> None:
|
|
if self.selected_hid_backend != "otg":
|
|
self.reporter.add("msd", "SKIP", "MSD requires OTG HID backend")
|
|
return
|
|
if not self.agent:
|
|
self.reporter.add("msd", "SKIP", "Windows agent not connected")
|
|
return
|
|
try:
|
|
status = self.api.get("/msd/status")
|
|
if not status.get("available"):
|
|
self.reporter.add("msd", "FAIL", "MSD controller is unavailable", status=status)
|
|
return
|
|
snapshot = await self.agent.command("msd_snapshot", {}, timeout=10)
|
|
known = [d["root"] for d in snapshot.get("drives", [])]
|
|
self.api.post("/msd/drive/init", {"size_mb": self.args.msd_size_mb})
|
|
self.api.post("/msd/connect", {"mode": "drive"})
|
|
drive = await self.agent.command("msd_wait_new", {"known": known, "timeout_ms": 60000}, timeout=70)
|
|
root = drive.get("root")
|
|
verify = await self.agent.command(
|
|
"msd_write_read",
|
|
{"root": root, "filename": f"okvm-msd-{self.run_id}.bin", "size_bytes": self.args.msd_probe_bytes},
|
|
timeout=60,
|
|
)
|
|
if "write_mib_s" in verify:
|
|
self.reporter.metric("msd_write_mib_s", round(float(verify["write_mib_s"]), 2), "MiB/s")
|
|
if "read_mib_s" in verify:
|
|
self.reporter.metric("msd_read_mib_s", round(float(verify["read_mib_s"]), 2), "MiB/s")
|
|
if "cached_read_mib_s" in verify:
|
|
self.reporter.metric("msd_cached_read_mib_s", round(float(verify["cached_read_mib_s"]), 2), "MiB/s")
|
|
self.api.post("/msd/disconnect", {})
|
|
await self.agent.command("msd_wait_removed", {"root": root, "timeout_ms": 60000}, timeout=70)
|
|
self.reporter.add("msd", "PASS", "Windows detected virtual drive and read/write verification passed", drive=drive, verify=verify)
|
|
except Exception as exc:
|
|
try:
|
|
self.api.post("/msd/disconnect", {})
|
|
except Exception:
|
|
pass
|
|
status = "SKIP" if is_msd_environment_error(str(exc)) else "FAIL"
|
|
self.reporter.add("msd", status, str(exc))
|
|
|
|
def run_atx_api_test(self) -> None:
|
|
try:
|
|
status = self.api.get("/atx/status")
|
|
config = self.api.get("/config/atx")
|
|
wol = self.api.post("/atx/wol", {"mac_address": self.args.wol_mac})
|
|
self.reporter.add("atx_api", "PASS", "ATX status/config/WOL APIs responded", status=status, config=config, wol=wol)
|
|
except Exception as exc:
|
|
self.reporter.add("atx_api", "FAIL", str(exc))
|
|
|
|
def collect_logs(self) -> None:
|
|
if not self.ssh_password:
|
|
return
|
|
try:
|
|
ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port)
|
|
code, out, err = ssh.run("journalctl -u one-kvm -n 300 --no-pager || true", timeout=30)
|
|
path = self.reporter.report_dir / "evidence" / "journalctl-one-kvm-tail.txt"
|
|
path.write_text(out + err, encoding="utf-8")
|
|
self.reporter.add_evidence("日志", "one-kvm journal tail", path)
|
|
self.reporter.add("log_collection", "PASS", "collected one-kvm journal tail")
|
|
except Exception as exc:
|
|
self.reporter.add("log_collection", "WARN", str(exc))
|
|
|
|
|
|
VIDEO_SCREENSHOT_READY_JS = r"""
|
|
() => {
|
|
const text = (document.body?.innerText || '').toLowerCase();
|
|
if (text.includes('connection failed') || text.includes('operation failed')) {
|
|
return true;
|
|
}
|
|
if (text.includes('waiting for first frame') || text.includes('webrtc connected') || text.includes('please wait')) {
|
|
return false;
|
|
}
|
|
const media = Array.from(document.querySelectorAll('video, canvas, img')).filter((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return r.width >= 320 && r.height >= 180 && r.bottom > 120;
|
|
});
|
|
return media.length > 0;
|
|
}
|
|
"""
|
|
|
|
|
|
WEBRTC_MEASURE_JS = r"""
|
|
async ({seconds}) => {
|
|
const api = async (path, opts = {}) => {
|
|
const response = await fetch('/api' + path, {
|
|
credentials: 'include',
|
|
headers: {'Content-Type': 'application/json', ...(opts.headers || {})},
|
|
...opts
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(path + ' HTTP ' + response.status + ': ' + JSON.stringify(data));
|
|
return data;
|
|
};
|
|
|
|
const ice = await api('/webrtc/ice-servers');
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: (ice.ice_servers || []).map(s => ({urls: s.urls, username: s.username, credential: s.credential}))
|
|
});
|
|
pc.addTransceiver('video', {direction: 'recvonly'});
|
|
pc.addTransceiver('audio', {direction: 'recvonly'});
|
|
pc.createDataChannel('hid', {ordered: true, maxRetransmits: 3});
|
|
|
|
let sessionId = null;
|
|
const pending = [];
|
|
pc.onicecandidate = (event) => {
|
|
if (!event.candidate) return;
|
|
const item = {
|
|
candidate: event.candidate.candidate,
|
|
sdpMid: event.candidate.sdpMid,
|
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
usernameFragment: event.candidate.usernameFragment
|
|
};
|
|
if (sessionId) {
|
|
api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: item})}).catch(() => {});
|
|
} else {
|
|
pending.push(item);
|
|
}
|
|
};
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
const answer = await api('/webrtc/offer', {method: 'POST', body: JSON.stringify({sdp: offer.sdp})});
|
|
if (answer.success === false) {
|
|
throw new Error('webrtc_offer_failed: ' + (answer.message || JSON.stringify(answer)));
|
|
}
|
|
if (typeof answer.sdp !== 'string' || !answer.sdp.trim().startsWith('v=')) {
|
|
throw new Error('webrtc_offer_invalid_sdp: ' + JSON.stringify(answer).slice(0, 500));
|
|
}
|
|
sessionId = answer.session_id;
|
|
await pc.setRemoteDescription({type: 'answer', sdp: answer.sdp});
|
|
for (const c of answer.ice_candidates || []) {
|
|
try { await pc.addIceCandidate(c); } catch {}
|
|
}
|
|
for (const c of pending.splice(0)) {
|
|
await api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: c})}).catch(() => {});
|
|
}
|
|
|
|
const waitStart = performance.now();
|
|
while (pc.connectionState !== 'connected' && performance.now() - waitStart < 12000) {
|
|
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') break;
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
const samples = [];
|
|
const endAt = performance.now() + seconds * 1000;
|
|
while (performance.now() < endAt) {
|
|
const report = await pc.getStats();
|
|
let sample = {fps: 0, framesDecoded: 0, framesDropped: 0, rtt: 0, jitter: 0, bytes: 0};
|
|
report.forEach(stat => {
|
|
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
|
|
sample.fps = stat.framesPerSecond || 0;
|
|
sample.framesDecoded = stat.framesDecoded || 0;
|
|
sample.framesDropped = stat.framesDropped || 0;
|
|
sample.jitter = stat.jitter || 0;
|
|
sample.bytes = stat.bytesReceived || 0;
|
|
}
|
|
if (stat.type === 'candidate-pair' && (stat.nominated || stat.selected)) {
|
|
sample.rtt = stat.currentRoundTripTime || 0;
|
|
}
|
|
});
|
|
samples.push(sample);
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
const fpsValues = samples.map(s => s.fps).filter(v => v > 0);
|
|
const avgFps = fpsValues.length ? fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length : 0;
|
|
const maxRtt = Math.max(0, ...samples.map(s => s.rtt || 0));
|
|
const maxJitter = Math.max(0, ...samples.map(s => s.jitter || 0));
|
|
const last = samples[samples.length - 1] || {};
|
|
try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: sessionId})}); } catch {}
|
|
pc.close();
|
|
return {
|
|
connected: pc.connectionState === 'connected' || samples.length > 0,
|
|
avgFps,
|
|
maxRtt,
|
|
maxJitter,
|
|
framesDecoded: last.framesDecoded || 0,
|
|
framesDropped: last.framesDropped || 0,
|
|
bytesReceived: last.bytes || 0,
|
|
samples
|
|
};
|
|
}
|
|
"""
|
|
|
|
|
|
WEBRTC_LATENCY_SETUP_JS = r"""
|
|
async ({timeoutMs}) => {
|
|
const api = async (path, opts = {}) => {
|
|
const response = await fetch('/api' + path, {
|
|
credentials: 'include',
|
|
headers: {'Content-Type': 'application/json', ...(opts.headers || {})},
|
|
...opts
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(path + ' HTTP ' + response.status + ': ' + JSON.stringify(data));
|
|
return data;
|
|
};
|
|
|
|
if (window.__okvmLatency) {
|
|
try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: window.__okvmLatency.sessionId})}); } catch {}
|
|
try { window.__okvmLatency.pc.close(); } catch {}
|
|
window.__okvmLatency = null;
|
|
}
|
|
|
|
const ice = await api('/webrtc/ice-servers');
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: (ice.ice_servers || []).map(s => ({urls: s.urls, username: s.username, credential: s.credential}))
|
|
});
|
|
const media = new MediaStream();
|
|
const video = document.createElement('video');
|
|
video.muted = true;
|
|
video.autoplay = true;
|
|
video.playsInline = true;
|
|
video.style.cssText = 'position:fixed;left:-10000px;top:-10000px;width:640px;height:360px;';
|
|
video.srcObject = media;
|
|
document.body.appendChild(video);
|
|
pc.ontrack = (event) => {
|
|
if (event.track && event.track.kind === 'video') {
|
|
media.addTrack(event.track);
|
|
video.play().catch(() => {});
|
|
}
|
|
};
|
|
pc.addTransceiver('video', {direction: 'recvonly'});
|
|
pc.addTransceiver('audio', {direction: 'recvonly'});
|
|
pc.createDataChannel('hid', {ordered: true, maxRetransmits: 3});
|
|
|
|
let sessionId = null;
|
|
const pending = [];
|
|
pc.onicecandidate = (event) => {
|
|
if (!event.candidate) return;
|
|
const item = {
|
|
candidate: event.candidate.candidate,
|
|
sdpMid: event.candidate.sdpMid,
|
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
usernameFragment: event.candidate.usernameFragment
|
|
};
|
|
if (sessionId) {
|
|
api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: item})}).catch(() => {});
|
|
} else {
|
|
pending.push(item);
|
|
}
|
|
};
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
const answer = await api('/webrtc/offer', {method: 'POST', body: JSON.stringify({sdp: offer.sdp})});
|
|
if (answer.success === false) {
|
|
throw new Error('webrtc_offer_failed: ' + (answer.message || JSON.stringify(answer)));
|
|
}
|
|
if (typeof answer.sdp !== 'string' || !answer.sdp.trim().startsWith('v=')) {
|
|
throw new Error('webrtc_offer_invalid_sdp: ' + JSON.stringify(answer).slice(0, 500));
|
|
}
|
|
sessionId = answer.session_id;
|
|
await pc.setRemoteDescription({type: 'answer', sdp: answer.sdp});
|
|
for (const c of answer.ice_candidates || []) {
|
|
try { await pc.addIceCandidate(c); } catch {}
|
|
}
|
|
for (const c of pending.splice(0)) {
|
|
await api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: c})}).catch(() => {});
|
|
}
|
|
|
|
const deadline = performance.now() + timeoutMs;
|
|
while (performance.now() < deadline) {
|
|
if ((pc.connectionState === 'failed') || (pc.connectionState === 'closed')) break;
|
|
if ((pc.connectionState === 'connected' || pc.iceConnectionState === 'connected') && video.videoWidth > 0 && video.readyState >= 2) break;
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d', {willReadFrequently: true});
|
|
window.__okvmLatency = {pc, sessionId, video, canvas, ctx};
|
|
return {
|
|
connected: pc.connectionState === 'connected' || pc.iceConnectionState === 'connected' || video.videoWidth > 0,
|
|
connectionState: pc.connectionState,
|
|
iceConnectionState: pc.iceConnectionState,
|
|
videoWidth: video.videoWidth || 0,
|
|
videoHeight: video.videoHeight || 0,
|
|
readyState: video.readyState
|
|
};
|
|
}
|
|
"""
|
|
|
|
|
|
WEBRTC_COLOR_DETECT_JS = r"""
|
|
async ({targetRgb, timeoutMs, threshold}) => {
|
|
const state = window.__okvmLatency;
|
|
if (!state || !state.video || !state.ctx) {
|
|
throw new Error('WebRTC latency detector is not initialized');
|
|
}
|
|
const video = state.video;
|
|
const canvas = state.canvas;
|
|
const ctx = state.ctx;
|
|
const outW = 64;
|
|
const outH = 64;
|
|
canvas.width = outW;
|
|
canvas.height = outH;
|
|
const deadline = performance.now() + timeoutMs;
|
|
let framesSeen = 0;
|
|
let best = null;
|
|
|
|
const sample = () => {
|
|
const width = video.videoWidth || 0;
|
|
const height = video.videoHeight || 0;
|
|
if (width <= 0 || height <= 0 || video.readyState < 2) return null;
|
|
const sx = Math.max(0, Math.floor(width * 0.35));
|
|
const sy = Math.max(0, Math.floor(height * 0.35));
|
|
const sw = Math.max(1, Math.floor(width * 0.30));
|
|
const sh = Math.max(1, Math.floor(height * 0.30));
|
|
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, outW, outH);
|
|
const data = ctx.getImageData(0, 0, outW, outH).data;
|
|
let r = 0, g = 0, b = 0;
|
|
const pixels = outW * outH;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
r += data[i];
|
|
g += data[i + 1];
|
|
b += data[i + 2];
|
|
}
|
|
const mean = [r / pixels, g / pixels, b / pixels];
|
|
const errors = [
|
|
Math.abs(mean[0] - targetRgb[0]),
|
|
Math.abs(mean[1] - targetRgb[1]),
|
|
Math.abs(mean[2] - targetRgb[2])
|
|
];
|
|
return {
|
|
mean_rgb: mean.map(v => Math.round(v * 100) / 100),
|
|
mean_abs_error: (errors[0] + errors[1] + errors[2]) / 3,
|
|
max_abs_error: Math.max(...errors),
|
|
width,
|
|
height
|
|
};
|
|
};
|
|
|
|
while (performance.now() < deadline) {
|
|
const stats = sample();
|
|
if (stats) {
|
|
framesSeen += 1;
|
|
stats.frames_seen = framesSeen;
|
|
if (!best || stats.mean_abs_error < best.mean_abs_error) best = {...stats};
|
|
if (stats.mean_abs_error <= threshold) {
|
|
stats.target_detected = true;
|
|
stats.wall_ns = Math.round((performance.timeOrigin + performance.now()) * 1000000);
|
|
return stats;
|
|
}
|
|
}
|
|
await new Promise(r => setTimeout(r, 16));
|
|
}
|
|
const out = best || {mean_rgb: [0, 0, 0], mean_abs_error: 999, max_abs_error: 999, width: 0, height: 0};
|
|
out.target_detected = false;
|
|
out.frames_seen = framesSeen;
|
|
out.wall_ns = Math.round((performance.timeOrigin + performance.now()) * 1000000);
|
|
return out;
|
|
}
|
|
"""
|
|
|
|
|
|
WEBRTC_LATENCY_CLOSE_JS = r"""
|
|
async () => {
|
|
const state = window.__okvmLatency;
|
|
if (!state) return true;
|
|
const api = async (path, opts = {}) => {
|
|
const response = await fetch('/api' + path, {
|
|
credentials: 'include',
|
|
headers: {'Content-Type': 'application/json', ...(opts.headers || {})},
|
|
...opts
|
|
});
|
|
return response.json().catch(() => ({}));
|
|
};
|
|
try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: state.sessionId})}); } catch {}
|
|
try { state.pc.close(); } catch {}
|
|
try { state.video.remove(); } catch {}
|
|
window.__okvmLatency = null;
|
|
return true;
|
|
}
|
|
"""
|
|
|
|
|
|
def local_ip_for(target: str) -> str:
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.connect((target, 80))
|
|
return sock.getsockname()[0]
|
|
except Exception:
|
|
return "127.0.0.1"
|
|
finally:
|
|
try:
|
|
sock.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def is_playwright_dependency_error(text: str) -> bool:
|
|
return any(
|
|
marker in text
|
|
for marker in (
|
|
"host system is missing dependencies",
|
|
"missing libraries",
|
|
"error while loading shared libraries",
|
|
"cannot open shared object file",
|
|
)
|
|
)
|
|
|
|
|
|
def is_playwright_runtime_error(text: str) -> bool:
|
|
return (
|
|
"playwright install" in text
|
|
or "playwright is required" in text
|
|
or "executable doesn't exist" in text
|
|
or "browser distribution" in text
|
|
or "is not found at" in text
|
|
or "not found on your system" in text
|
|
or is_playwright_dependency_error(text)
|
|
)
|
|
|
|
|
|
def is_webrtc_codec_unsupported_error(text: str) -> bool:
|
|
lower = text.lower()
|
|
return any(
|
|
marker in lower
|
|
for marker in (
|
|
"webrtc_offer_failed",
|
|
"webrtc_offer_invalid_sdp",
|
|
"failed to parse sessiondescription",
|
|
"failed to execute 'setremotedescription'",
|
|
"unsupported",
|
|
"not supported",
|
|
"codec",
|
|
)
|
|
)
|
|
|
|
|
|
def is_msd_environment_error(text: str) -> bool:
|
|
lower = text.lower()
|
|
return any(
|
|
marker in lower
|
|
for marker in (
|
|
"resources not initialized",
|
|
"resource not found",
|
|
"boot.img not found",
|
|
"core.img not found",
|
|
"ventoy.disk.img not found",
|
|
"ventoy resources",
|
|
)
|
|
)
|
|
|
|
|
|
def default_ventoy_resources_dir() -> Path:
|
|
return Path(__file__).resolve().parents[2] / "libs" / "ventoy-img-rs" / "resources"
|
|
|
|
|
|
def shell_quote(value: str) -> str:
|
|
return value.replace("'", "'\"'\"'")
|
|
|
|
|
|
def shell_arg(value: str) -> str:
|
|
return "'" + shell_quote(value) + "'"
|
|
|
|
|
|
def safe_filename(value: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") or "evidence"
|
|
|
|
|
|
def chromium_launch_args() -> list[str]:
|
|
return list(CHROMIUM_WINDOWS_ARGS)
|
|
|
|
|
|
def is_windows_controller() -> bool:
|
|
return sys.platform.startswith("win")
|
|
|
|
|
|
def latency_stats(values: list[float]) -> dict[str, Any]:
|
|
if not values:
|
|
return {"samples": 0}
|
|
return {
|
|
"samples": len(values),
|
|
"p50_ms": percentile(values, 50),
|
|
"p95_ms": percentile(values, 95),
|
|
"max_ms": max(values),
|
|
"min_ms": min(values),
|
|
}
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="One-KVM automated acceptance test controller")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
run = sub.add_parser("run", help="run acceptance test")
|
|
run.add_argument("--target", required=True, help="One-KVM target IP")
|
|
run.add_argument("--http-port", type=int, default=8080)
|
|
run.add_argument("--ssh-user", default="root")
|
|
run.add_argument("--ssh-port", type=int, default=22)
|
|
run.add_argument("--ssh-password", default=None)
|
|
run.add_argument("--ssh-password-prompt", action="store_true")
|
|
run.add_argument("--data-dir", default="/etc/one-kvm")
|
|
run.add_argument("--reset", action="store_true", help="backup and remove One-KVM database before testing")
|
|
run.add_argument("--web-user", default="okvmtest")
|
|
run.add_argument("--web-password", default="okvmtest1234")
|
|
run.add_argument("--run-id", default=None)
|
|
run.add_argument("--report-dir", default="reports")
|
|
run.add_argument("--health-timeout", type=int, default=60)
|
|
run.add_argument("--network-latency-samples", type=int, default=7, help="controller-to-target network latency samples collected during setup")
|
|
run.add_argument("--network-latency-timeout", type=float, default=3.0, help="per-sample TCP/HTTP latency timeout in seconds")
|
|
run.add_argument("--sample-seconds", type=int, default=30)
|
|
run.add_argument("--jpeg-quality", type=int, default=80)
|
|
run.add_argument("--mjpeg-motion-fps", type=int, default=60, help="Windows agent dynamic source fps used during MJPEG/HTTP tests")
|
|
run.add_argument("--agent-host", default=None, help="Windows agent IP/hostname; omit to skip Windows-side checks")
|
|
run.add_argument("--agent-port", type=int, default=8765)
|
|
run.add_argument("--agent-timeout", type=int, default=180)
|
|
run.add_argument("--msd-size-mb", type=int, default=256)
|
|
run.add_argument("--msd-probe-bytes", type=int, default=1024 * 1024)
|
|
run.add_argument("--ventoy-resources-dir", default=None, help="local Ventoy resource directory; defaults to repo libs/ventoy-img-rs/resources")
|
|
run.add_argument("--no-ventoy-sync", action="store_true", help="do not copy Ventoy resources to the target before MSD testing")
|
|
run.add_argument("--no-msd-restart-after-enable", action="store_true", help="do not restart One-KVM after enabling MSD")
|
|
run.add_argument("--strict-performance", action="store_true", help="fail video results below the expected FPS threshold; default only requires fps > 0")
|
|
run.add_argument("--no-color", action="store_true", help="disable colored terminal output")
|
|
run.add_argument("--no-screenshots", action="store_true", help="skip Playwright webpage screenshots")
|
|
run.add_argument("--screenshot-wait-ms", type=int, default=6000, help="wait up to this long for video page screenshots to reach a stable state")
|
|
run.add_argument("--hid-latency-trials", type=int, default=5)
|
|
run.add_argument("--hid-latency-warn-ms", type=float, default=80.0)
|
|
run.add_argument("--hid-latency-fail-ms", type=float, default=200.0)
|
|
run.add_argument("--no-hdmi-tests", action="store_true", help="skip HDMI identity/color and video visual latency tests")
|
|
run.add_argument("--hdmi-settle-ms", type=int, default=600)
|
|
run.add_argument("--hdmi-capture-timeout", type=float, default=8.0)
|
|
run.add_argument("--hdmi-match-frames", type=int, default=1, help="consecutive matching MJPEG frames required for HDMI color detection")
|
|
run.add_argument("--hdmi-color-warn", type=float, default=30.0)
|
|
run.add_argument("--hdmi-color-fail", type=float, default=60.0)
|
|
run.add_argument("--video-latency-trials", "--hdmi-latency-trials", dest="hdmi_latency_trials", type=int, default=5)
|
|
run.add_argument("--video-latency-delay-ms", "--hdmi-latency-delay-ms", dest="hdmi_latency_delay_ms", type=int, default=2000)
|
|
run.add_argument("--video-latency-timeout-ms", "--hdmi-latency-timeout-ms", dest="hdmi_latency_timeout_ms", type=int, default=3000)
|
|
run.add_argument("--video-latency-warn-ms", "--hdmi-latency-warn-ms", dest="hdmi_latency_warn_ms", type=float, default=3000.0, help=argparse.SUPPRESS)
|
|
run.add_argument("--video-latency-fail-ms", "--hdmi-latency-fail-ms", dest="hdmi_latency_fail_ms", type=float, default=3000.0)
|
|
run.add_argument("--wol-mac", default="02:00:00:00:00:01")
|
|
return parser
|
|
|
|
|
|
async def async_main(argv: list[str]) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
if args.command == "run":
|
|
if not is_windows_controller():
|
|
print("okvm_testctl.py run is supported only on native Windows. Run it from Windows PowerShell, not WSL/Linux.", file=sys.stderr)
|
|
return 2
|
|
runner = AcceptanceRunner(args)
|
|
return await runner.run()
|
|
raise AssertionError(args.command)
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
raise SystemExit(asyncio.run(async_main(sys.argv[1:])))
|
|
except KeyboardInterrupt:
|
|
raise SystemExit(130)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|