Files
matrix-screen-controller/核桃派软件源代码/scripts/measure_gpio_status.py
T

181 lines
7.8 KiB
Python

from __future__ import annotations
import argparse
import hashlib
import json
import statistics
import time
import urllib.request
from io import BytesIO
from pathlib import Path
from typing import Any
from PIL import Image
class MeasurementError(RuntimeError):
pass
def fetch(url: str, *, timeout: float = 2.0) -> tuple[bytes, float]:
started = time.monotonic()
with urllib.request.urlopen(url, timeout=timeout) as response:
body = response.read()
return body, (time.monotonic() - started) * 1000.0
def rgb_digest(body: bytes) -> str:
with Image.open(BytesIO(body)) as image:
rgb = image.convert("RGB")
return hashlib.sha256(rgb.tobytes()).hexdigest()
def percentile_95(values: list[float]) -> float:
if not values:
return 0.0
ordered = sorted(values)
return ordered[max(0, min(len(ordered) - 1, int(len(ordered) * 0.95 + 0.999999) - 1))]
def driver_status(sample: dict[str, Any]) -> dict[str, Any]:
driver = (sample.get("screen") or {}).get("driver_status")
if not isinstance(driver, dict):
raise MeasurementError("status does not contain screen.driver_status")
return driver
def validate_samples(
samples: list[dict[str, Any]],
*,
performance: str,
frame_before: str,
frame_after: str,
) -> dict[str, Any]:
if len(samples) < 2:
raise MeasurementError("at least two status samples are required")
drivers = [driver_status(sample) for sample in samples]
minimum_actual = min(float(item.get("actual_refresh_rate_hz") or 0) for item in drivers)
minimum_scan = min(float(item.get("panel_scan_rate_hz") or 0) for item in drivers)
if minimum_actual < 95.0 or minimum_scan < 95.0:
raise MeasurementError("actual or panel scan refresh rate fell below 95Hz")
if any(item.get("scans_per_frame") != 1 for item in drivers):
raise MeasurementError("scans_per_frame was not 1 throughout the sample")
stalled_intervals = 0
maximum_consecutive_stalls = 0
for previous, current in zip(drivers, drivers[1:]):
frame_delta = int(current.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0)
scan_delta = int(current.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0)
if frame_delta < 0 or scan_delta != frame_delta:
raise MeasurementError("completed frame and scan deltas were negative or unequal")
stalled_intervals = stalled_intervals + 1 if frame_delta == 0 else 0
maximum_consecutive_stalls = max(maximum_consecutive_stalls, stalled_intervals)
if maximum_consecutive_stalls >= 2:
raise MeasurementError("completed frames stalled for two consecutive samples")
frame_delta = int(drivers[-1].get("completed_frames") or 0) - int(drivers[0].get("completed_frames") or 0)
scan_delta = int(drivers[-1].get("completed_scans") or 0) - int(drivers[0].get("completed_scans") or 0)
miss_delta = int(drivers[-1].get("deadline_misses") or 0) - int(drivers[0].get("deadline_misses") or 0)
if frame_delta <= 0 or scan_delta != frame_delta:
raise MeasurementError("completed frame/scan totals did not advance equally")
if miss_delta < 0 or miss_delta / frame_delta > 0.001:
raise MeasurementError("deadline miss rate exceeded 0.1%")
fault_fields = sorted(
key for key, value in drivers[0].items()
if "fault" in key and isinstance(value, (int, float)) and not isinstance(value, bool)
)
for field in fault_fields:
if any(float(current.get(field) or 0) != float(drivers[0].get(field) or 0) for current in drivers[1:]):
raise MeasurementError(f"{field} changed during the measurement")
for field in ("driver_error", "oe_timing_error", "safeoff_error"):
if any(item.get(field) for item in drivers):
raise MeasurementError(f"{field} was not empty")
for item in drivers:
if item.get("cpu_affinity") != 3 or item.get("cpu_affinity_active") is not True:
raise MeasurementError("CPU3 affinity was not continuously active")
if item.get("realtime_priority") != 50 or item.get("realtime_priority_active") is not True:
raise MeasurementError("SCHED_FIFO 50 was not continuously active")
if item.get("memory_locked") is not True:
raise MeasurementError("refresh memory was not continuously locked")
for sample in samples:
status = ((sample.get("system") or {}).get("performance_mode") or {})
if performance == "on":
if status.get("requested") is not True or status.get("effective") is not True:
raise MeasurementError("performance mode was not requested and effective")
if not status.get("current_governors") or any(
governor != "performance" for governor in status["current_governors"].values()
):
raise MeasurementError("not every cpufreq policy used the performance governor")
elif performance == "off":
if status.get("effective") is not False:
raise MeasurementError("performance mode remained effective while required off")
current = status.get("current_governors") or {}
restore = status.get("restore_governors") or {}
if current and current != restore:
raise MeasurementError("governors were not restored while performance mode was off")
if frame_before != frame_after:
raise MeasurementError("current RGB frame changed during thumbnail browsing")
return {
"ok": True,
"samples": len(samples),
"minimum_actual_refresh_rate_hz": minimum_actual,
"minimum_panel_scan_rate_hz": minimum_scan,
"completed_frames_delta": frame_delta,
"completed_scans_delta": scan_delta,
"deadline_misses_delta": miss_delta,
"deadline_miss_rate": miss_delta / frame_delta,
"maximum_consecutive_stalled_intervals": maximum_consecutive_stalls,
"fault_fields_checked": fault_fields,
"current_frame_rgb_sha256": frame_before,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Measure real HUB75 GPIO refresh while browsing")
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
parser.add_argument("--seconds", type=int, default=60)
parser.add_argument("--performance", choices=("on", "off", "ignore"), default="ignore")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
if not 2 <= args.seconds <= 600:
parser.error("--seconds must be within 2..600")
frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame")
frame_before = rgb_digest(frame_body)
samples: list[dict[str, Any]] = []
latencies: list[float] = []
started = time.monotonic()
for second in range(args.seconds + 1):
target = started + second
if target > time.monotonic():
time.sleep(target - time.monotonic())
body, latency = fetch(f"{args.base_url}/api/status")
samples.append(json.loads(body.decode("utf-8")))
latencies.append(latency)
frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame")
result = validate_samples(
samples,
performance=args.performance,
frame_before=frame_before,
frame_after=rgb_digest(frame_body),
)
result["status_latency_p95_ms"] = percentile_95(latencies)
if result["status_latency_p95_ms"] > 500.0:
raise MeasurementError("/api/status p95 latency exceeded 500ms")
encoded = json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
if args.output:
args.output.write_text(encoded, encoding="utf-8", newline="\n")
print(encoded, end="")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except MeasurementError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
raise SystemExit(1)