Files
sunnypilot-tesla/selfdrive/ui/tests/diff/replay.py
Jason Wen d5b25e14fd Merge branch 'upstream/openpilot/master' into sync-20260317
# Conflicts:
#	.github/workflows/auto_pr_review.yaml
#	.gitignore
#	opendbc_repo
#	panda
#	selfdrive/ui/mici/layouts/home.py
#	selfdrive/ui/mici/layouts/onboarding.py
#	selfdrive/ui/mici/layouts/settings/device.py
#	selfdrive/ui/tests/diff/replay.py
#	selfdrive/ui/translations/app_fr.po
#	system/ui/mici_setup.py
Sync: `commaai/opendbc:master` → `sunnypilot/opendbc:master`
Sync: `commaai/panda:master` → `sunnypilot/panda:master`
2026-03-17 23:02:10 -04:00

152 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import argparse
import coverage
import pyray as rl
from tqdm import tqdm
from typing import Literal
from collections.abc import Callable
from cereal.messaging import PubMaster
from openpilot.common.api import Api
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.ui.tests.diff.diff import DIFF_OUT_DIR
from openpilot.system.updated.updated import parse_release_notes
from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version
LayoutVariant = Literal["mici", "tizi"]
FPS = 60
HEADLESS = os.getenv("WINDOWED", "0") != "1"
def setup_state():
params = Params()
params.put("HasAcceptedTerms", terms_version)
params.put("CompletedTrainingVersion", training_version)
params.put("DongleId", "test123456789")
# Combined description for layouts that still use it (BIG home, settings/software)
params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30")
params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR))
params.put("HasAcceptedTermsSP", terms_version_sp)
params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
# Params for mici home
params.put("Version", "0.10.1")
params.put("GitBranch", "test-branch")
params.put("GitCommit", "abc12340ff9131237ba23a1d0fbd8edf9c80e87")
params.put("GitCommitDate", "'1732924800 2024-11-30 00:00:00 +0000'")
# Patch Api.get_token to return a static token so the pairing QR code is deterministic across runs
Api.get_token = lambda self, payload_extra=None, expiry_hours=0: "test_token"
def run_replay(variant: LayoutVariant) -> None:
if HEADLESS:
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
os.environ["OFFSCREEN"] = "1" # Run UI without FPS limit (set before importing gui_app)
setup_state()
os.makedirs(DIFF_OUT_DIR, exist_ok=True)
from openpilot.selfdrive.ui.ui_state import ui_state, device # Import within OpenpilotPrefix context so param values are setup correctly
from openpilot.system.ui.lib.application import gui_app # Import here for accurate coverage
from openpilot.selfdrive.ui.tests.diff.replay_script import build_script
gui_app.init_window("ui diff test", fps=FPS)
# Dynamically import main layout based on variant
if variant == "mici":
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout as MainLayout
else:
from openpilot.selfdrive.ui.layouts.main import MainLayout
main_layout = MainLayout()
# Disable interactive timeout — replay clicks use left_down=False so they never reset the timer,
# and after 30s of real wall-clock time the settings panel would close automatically.
device.set_override_interactive_timeout(99999)
pm = PubMaster(["deviceState", "pandaStates", "driverStateV2", "selfdriveState"])
script = build_script(pm, main_layout, variant)
script_index = 0
send_fn: Callable | None = None
frame = 0
# Override raylib timing functions to return deterministic values based on frame count instead of real time
rl.get_frame_time = lambda: 1.0 / FPS
rl.get_time = lambda: frame / FPS
# Main loop to replay events and render frames
with tqdm(total=script[-1][0] + 1, desc="Replaying", unit="frame", disable=bool(os.getenv("CI"))) as pbar:
for _ in gui_app.render():
# Handle all events for the current frame
while script_index < len(script) and script[script_index][0] == frame:
_, event = script[script_index]
# Call setup function, if any
if event.setup:
event.setup()
# Send mouse events to the application
if event.mouse_events:
with gui_app._mouse._lock:
gui_app._mouse._events.extend(event.mouse_events)
# Update persistent send function
if event.send_fn is not None:
send_fn = event.send_fn
# Move to next script event
script_index += 1
# Keep sending cereal messages for persistent states (onroad, alerts)
if send_fn:
send_fn()
ui_state.update()
frame += 1
pbar.update(1)
if script_index >= len(script):
break
gui_app.close()
print(f"Total frames: {frame}")
print(f"Video saved to: {os.environ['RECORD_OUTPUT']}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--big', action='store_true', help='Use big UI layout (tizi/tici) instead of mici layout')
args = parser.parse_args()
variant: LayoutVariant = 'tizi' if args.big else 'mici'
if args.big:
os.environ["BIG"] = "1"
os.environ["RECORD"] = "1"
os.environ["RECORD_QUALITY"] = "0" # Use CRF 0 ("lossless" encode) for deterministic output across different machines
os.environ["RECORD_OUTPUT"] = os.path.join(DIFF_OUT_DIR, os.environ.get("RECORD_OUTPUT", f"{variant}_ui_replay.mp4"))
print(f"Running {variant} UI replay...")
with OpenpilotPrefix():
sources = ["openpilot.system.ui"]
if variant == "mici":
sources.append("openpilot.selfdrive.ui.mici")
omit = ["**/*tizi*", "**/*tici*"] # exclude files containing "tizi" or "tici"
else:
sources.extend(["openpilot.selfdrive.ui.layouts", "openpilot.selfdrive.ui.onroad", "openpilot.selfdrive.ui.widgets"])
omit = ["**/*mici*"] # exclude files containing "mici"
cov = coverage.Coverage(source=sources, omit=omit)
with cov.collect():
run_replay(variant)
cov.save()
cov.report()
directory = os.path.join(DIFF_OUT_DIR, f"htmlcov-{variant}")
cov.html_report(directory=directory)
print(f"HTML report: {directory}/index.html")
if __name__ == "__main__":
main()