From 87c871ca07a6105951ba31d021b78a7de6788646 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:54:23 -0700 Subject: [PATCH] Capture saved replay screens only for explicit mirror mode --- roadscore/prototype/demo_session.py | 15 +++-- .../prototype/native_prepared_showcase.py | 11 ++-- roadscore/prototype/paired_showcase.py | 6 +- roadscore/prototype/test_demo_session.py | 29 ++++++++++ .../test_native_prepared_showcase.py | 13 +++++ roadscore/prototype/test_paired_showcase.py | 56 ++++++++++++++++++- 6 files changed, 117 insertions(+), 13 deletions(-) diff --git a/roadscore/prototype/demo_session.py b/roadscore/prototype/demo_session.py index 7b09211977..60997f291a 100644 --- a/roadscore/prototype/demo_session.py +++ b/roadscore/prototype/demo_session.py @@ -52,6 +52,7 @@ class DemoSession: pid = owner.get('pid') running = type(pid) is int and pid > 1 and self.ticks(pid) == owner.get('start_ticks') and owner.get('start_ticks') is not None result = {key: owner.get(key) for key in ('request_id', 'alias', 'route', 'pid', 'out', 'muted')} + result['screen_mirror'] = owner.get('screen_mirror') is True result.update(running=bool(running), generation_invoked=False) if owner.get('out'): out = Path(owner['out']) @@ -65,17 +66,20 @@ class DemoSession: return result def start(self, data): - if (not isinstance(data, dict) or set(data) != {'alias', 'request_id', 'muted'} + required = {'alias', 'request_id', 'muted'} + if (not isinstance(data, dict) or not required <= set(data) or set(data) - required - {'screen_mirror'} or not isinstance(data['request_id'], str) or not re.fullmatch(r'[a-zA-Z0-9-]{8,80}', data['request_id']) - or type(data['muted']) is not bool): - raise ValueError('Saved demo requires an alias, request ID and explicit output choice') + or type(data['muted']) is not bool or type(data.get('screen_mirror', False)) is not bool): + raise ValueError('Saved demo requires an alias, request ID, explicit output choice and optional screen_mirror boolean') + data = {**data, 'screen_mirror': data.get('screen_mirror', False)} if not self.offroad(): raise ValueError('Saved demo playback requires the vehicle to be offroad') selected = entry(self.root, data['alias']) with self.lock: current = self.status() if current['running']: - if current['request_id'] == data['request_id'] and current['alias'] == data['alias'] and current['muted'] == data['muted']: + if (current['request_id'] == data['request_id'] and current['alias'] == data['alias'] + and current['muted'] == data['muted'] and current['screen_mirror'] == data['screen_mirror']): return current raise ValueError('A saved demo is already running; stop that owned session first') if read(self.owner_path).get('request_id') == data['request_id']: @@ -91,8 +95,9 @@ class DemoSession: '--score-archive', selected['archive']] if selected.get('curve_plan'): command += ['--curve-plan', selected['curve_plan']] if data['muted']: command.append('--muted') + if data['screen_mirror']: command.append('--screen-mirror') env = os.environ.copy() - for key in ('ZMQ', 'OPENPILOT_PREFIX', 'OPENPILOT_ZMQ_NAMESPACE', 'PARAMS_ROOT'): + for key in ('ZMQ', 'OPENPILOT_PREFIX', 'OPENPILOT_ZMQ_NAMESPACE', 'PARAMS_ROOT', 'ROADSCORE_MIRROR_DIR'): env.pop(key, None) env['PYTHONPATH'] = ':'.join((str(self.root/'prototype'), '/data/openpilot', '/data/roadscore-feasibility/venv/lib/python3.12/site-packages')) with (out/'launcher.log').open('wb') as log: diff --git a/roadscore/prototype/native_prepared_showcase.py b/roadscore/prototype/native_prepared_showcase.py index a35eecbf77..a9a9156f71 100644 --- a/roadscore/prototype/native_prepared_showcase.py +++ b/roadscore/prototype/native_prepared_showcase.py @@ -67,14 +67,14 @@ def start_deadline(path, session, now): except (OSError, ValueError):return None -def native_environment(project, root, out, session, inherited=None): +def native_environment(project, root, out, session, inherited=None, *, screen_mirror=False): env = dict(os.environ if inherited is None else inherited) - for key in ('ZMQ','PARAMS_ROOT','OPENPILOT_ZMQ_NAMESPACE','ROADSCORE_PCM_RETURN','ROADSCORE_RESIDENT','ROADSCORE_GENERATION_SEED','ROADSCORE_SEED_ORIGIN'): + for key in ('ZMQ','PARAMS_ROOT','OPENPILOT_ZMQ_NAMESPACE','ROADSCORE_PCM_RETURN','ROADSCORE_RESIDENT','ROADSCORE_GENERATION_SEED','ROADSCORE_SEED_ORIGIN','ROADSCORE_MIRROR_DIR'): env.pop(key, None) env.update(PYTHONDONTWRITEBYTECODE='1',OPENPILOT_PREFIX='roadscore_replay',BASEDIR=str(project),PWD=str(project),NOBOARD='1',SIMULATION='1',SKIP_FW_QUERY='1',BIG='0',ROADSCORE_PREPARED_SHOWCASE='1',ROADSCORE_SHOWCASE_SESSION=session,ROADSCORE_REPLAY_UI_CONTROLS='1',ROADSCORE_CLEAN_DEMO_UI='1',ROADSCORE_OVERLAY='1',ROADSCORE_STATUS_FILE=str(out/'status.json'),ROADSCORE_UI_AUDIT=str(out/'ui_audit.jsonl'),ROADSCORE_OVERLAY_CAPTURE=str(out/'overlay.png'),ROADSCORE_AUDIO_DRAIN_FILE=str(out/'audio_drained.json'),ROADSCORE_PRESENTATION_POLICY='conservative-v4',SP_ALLOW_DESKTOP_FAKE_WIFI='0',SP_ALLOW_DESKTOP_FAKE_BLUETOOTH='0',SP_ONROAD_NAV_DEMO='0',SP_ONROAD_CEM_DEMO='0') paths=[HERE,project,project/'starpilot/third_party',*project.glob('*_repo'),Path('/data/roadscore-feasibility/venv/lib/python3.12/site-packages')] env['PYTHONPATH']=':'.join(map(str,paths)) - env['ROADSCORE_MIRROR_DIR']=str(out/'mirror') + if screen_mirror:env['ROADSCORE_MIRROR_DIR']=str(out/'mirror') env['ROADSCORE_REPLAY_PRIME']='1' return env @@ -132,6 +132,7 @@ def main(): p.add_argument('--project-root',type=Path,default=Path('/data/openpilot')) p.add_argument('--score-archive',type=Path);p.add_argument('--curve-plan',type=Path) p.add_argument('--out',type=Path);p.add_argument('--muted',action='store_true');p.add_argument('--headless',action='store_true') + p.add_argument('--screen-mirror',action='store_true',help='Capture the native UI for the legacy screen-mirror viewer') p.add_argument('--check',action='store_true');p.add_argument('--hold-start',action='store_true') p.add_argument('--start-timeout',type=float,default=120.);p.add_argument('--duration',type=float,default=float('inf')) a=p.parse_args() @@ -163,7 +164,7 @@ def main(): for path in (py,replay,project/'selfdrive/ui/ui.py',HERE/'mac_showcase.py'): if not path.is_file():raise FileNotFoundError(path) args=replay_arguments(meta,local) - checked=dict(route=route,archive=str(archive),seconds=duration,local_cache=str(local),generation_invoked=False,requires_chestnut=False,replay_args=args) + checked=dict(route=route,archive=str(archive),seconds=duration,local_cache=str(local),generation_invoked=False,requires_chestnut=False,replay_args=args,screen_mirror=a.screen_mirror) if a.check: print(json.dumps(checked,indent=2));return from native_ownership import verify_offroad,DisplayOwner @@ -187,7 +188,7 @@ def main(): if any(path.name!='launcher.log' for path in out.iterdir()):raise ValueError('Prepared output directory must contain only its supervisor launcher.log') else:out.mkdir(parents=True) previous=install_current(root,out,session) - env=native_environment(project,root,out,session) + env=native_environment(project,root,out,session,screen_mirror=a.screen_mirror) env['ROADSCORE_FORCE_MUTE']='1' if muted else '0' Path('/dev/shm/msgq_roadscore_replay').mkdir(exist_ok=True) launch={**checked,'mode':'prepared-interactive-showcase','session_id':session,'muted':muted,'core_sha256':hashlib.sha256((archive/'dry.wav').read_bytes()).hexdigest(),'previous_current':str(previous) if previous else None,'curve_plan':str(a.curve_plan) if a.curve_plan else None} diff --git a/roadscore/prototype/paired_showcase.py b/roadscore/prototype/paired_showcase.py index 9402a25ec5..2684664e6e 100644 --- a/roadscore/prototype/paired_showcase.py +++ b/roadscore/prototype/paired_showcase.py @@ -114,7 +114,7 @@ def mirror_main(): signal.signal(signal.SIGTERM,interrupt) try: launched=True - peer.call('demo_start',{'alias':args.route,'request_id':request_id,'muted':args.muted}) + peer.call('demo_start',{'alias':args.route,'request_id':request_id,'muted':args.muted,'screen_mirror':True}) url=f'http://127.0.0.1:{server.server_port}' print('RoadScore saved demo. No model generation. Controls and mirrored screen: '+url,flush=True) if not args.no_browser:subprocess.Popen(['/usr/bin/open',url],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) @@ -231,8 +231,10 @@ def native_pair_main(): try: launched=True peer.call('demo_start',{'alias':args.route,'request_id':request_id,'muted':args.muted}) + env=os.environ.copy() + env.pop('ROADSCORE_MIRROR_DIR',None) with (out/'mac.log').open('wb') as log: - process=subprocess.Popen(command,cwd=project,stdin=subprocess.DEVNULL,stdout=log, + process=subprocess.Popen(command,cwd=project,env=env,stdin=subprocess.DEVNULL,stdout=log, stderr=subprocess.STDOUT,start_new_session=True,close_fds=True) print('Preparing saved RoadScore on comma and native Mac UI. No model generation.',flush=True) deadline=time.monotonic()+100 diff --git a/roadscore/prototype/test_demo_session.py b/roadscore/prototype/test_demo_session.py index aadddc39a7..4a357cf91c 100644 --- a/roadscore/prototype/test_demo_session.py +++ b/roadscore/prototype/test_demo_session.py @@ -4,6 +4,7 @@ from pathlib import Path import tempfile from types import SimpleNamespace import unittest +from unittest.mock import patch from demo_catalog import entry from demo_session import DemoSession, write @@ -45,12 +46,40 @@ class SavedDemoTests(unittest.TestCase): command,options=self.calls[0] self.assertTrue(command[1].endswith('/native_prepared_showcase.py')) self.assertIn('--hold-start',command);self.assertIn('--muted',command) + self.assertNotIn('--screen-mirror',command) + self.assertFalse(current['screen_mirror']) self.assertTrue(options['close_fds']);self.assertTrue(options['start_new_session']) with self.assertRaises(ValueError):self.service.start({**self.request,'request_id':'different-request'}) with self.assertRaises(ValueError):self.service.stop({'request_id':'different-request'}) self.service.stop({'request_id':self.request['request_id']}) self.assertEqual(self.stops,[123]) + def test_capture_is_explicit_and_part_of_launch_identity(self): + with patch.dict('os.environ',{'ROADSCORE_MIRROR_DIR':'/inherited/mirror'}): + current=self.service.start({**self.request,'screen_mirror':True}) + self.assertTrue(current['screen_mirror']) + command,options=self.calls[0] + self.assertIn('--screen-mirror',command) + self.assertNotIn('ROADSCORE_MIRROR_DIR',options['env']) + self.assertEqual(self.service.start({**self.request,'screen_mirror':True})['pid'],123) + with self.assertRaises(ValueError):self.service.start(self.request) + self.assertEqual(len(self.calls),1) + + def test_optional_capture_defaults_to_false_without_inherited_activation(self): + with patch.dict('os.environ',{'ROADSCORE_MIRROR_DIR':'/inherited/mirror'}): + current=self.service.start(self.request) + self.assertFalse(current['screen_mirror']) + self.assertEqual(self.service.start({**self.request,'screen_mirror':False})['pid'],123) + self.assertNotIn('ROADSCORE_MIRROR_DIR',self.calls[0][1]['env']) + with self.assertRaises(ValueError):self.service.start({**self.request,'screen_mirror':True}) + + def test_capture_api_rejects_nonbooleans_and_unknown_fields_before_spawning(self): + for value in (None,0,1,'true','false',[],{}): + with self.subTest(value=value),self.assertRaises(ValueError): + self.service.start({**self.request,'screen_mirror':value}) + with self.assertRaises(ValueError):self.service.start({**self.request,'mirror':True}) + self.assertEqual(self.calls,[]) + def test_matching_ready_session_is_required_for_release(self): current=self.service.start(self.request);out=Path(current['out']) request={'request_id':self.request['request_id'],'session_id':'prepared-session'} diff --git a/roadscore/prototype/test_native_prepared_showcase.py b/roadscore/prototype/test_native_prepared_showcase.py index 19a8afc84b..871594d8a8 100644 --- a/roadscore/prototype/test_native_prepared_showcase.py +++ b/roadscore/prototype/test_native_prepared_showcase.py @@ -63,6 +63,19 @@ class NativePreparedTests(unittest.TestCase): self.assertIn('--presentation-root',args);self.assertIn('bluealsa:AA:BB',args) self.assertTrue(args[1].endswith('/mac_showcase.py')) self.assertFalse(any('ace_worker' in item or 'power_worker' in item for item in args)) + def test_continuous_capture_is_absent_even_with_inherited_mirror_directory(self): + from screen_mirror import ScreenMirror + inherited={'ROADSCORE_MIRROR_DIR':'/old/session/mirror'} + for choice in ({}, {'screen_mirror':False}): + env=native_environment(Path(self.tmp.name),self.root,self.out,'session-123',inherited,**choice) + self.assertNotIn('ROADSCORE_MIRROR_DIR',env) + self.assertIsNone(ScreenMirror.from_environ(env)) + self.assertEqual(inherited['ROADSCORE_MIRROR_DIR'],'/old/session/mirror') + def test_explicit_legacy_mirror_uses_only_this_owned_output(self): + env=native_environment(Path(self.tmp.name),self.root,self.out,'session-123', + {'ROADSCORE_MIRROR_DIR':'/old/session/mirror'},screen_mirror=True) + self.assertEqual(env['ROADSCORE_MIRROR_DIR'],str(self.out/'mirror')) + self.assertEqual(env['ROADSCORE_REPLAY_PRIME'],'1') def test_native_replay_uses_only_given_local_cache(self): meta={'native_replay_args':['fixture','--data_dir','old','--start','149']} args=replay_arguments(meta,Path('/local/playback')) diff --git a/roadscore/prototype/test_paired_showcase.py b/roadscore/prototype/test_paired_showcase.py index b329addb41..8641c441d4 100644 --- a/roadscore/prototype/test_paired_showcase.py +++ b/roadscore/prototype/test_paired_showcase.py @@ -3,10 +3,11 @@ from pathlib import Path import tempfile from types import SimpleNamespace import unittest +from unittest.mock import Mock, patch from urllib.error import HTTPError from urllib.request import Request, urlopen -from paired_showcase import DemoPeer, control_server, local_start_deadline, mac_command, read_ready +from paired_showcase import DemoPeer, control_server, local_start_deadline, mac_command, read_ready, mirror_main, native_pair_main class NativePairTests(unittest.TestCase): @@ -48,6 +49,34 @@ class NativePairTests(unittest.TestCase): 'http://192.168.1.2:8082',Path('/owned/mac'),12.,fullscreen=True) self.assertIn('--fullscreen',command) + def test_normal_pair_never_requests_or_inherits_screen_capture(self): + calls=[] + def call(action,data=None): + calls.append((action,data)) + if action=='status':return {'offroad':True} + if action=='demo_ready':return dict(request_id='paired-test-request',route='private/route',running=True,prepared=True,ready_session_id='native-session') + if action=='demo_play':return dict(request_id='paired-test-request',session_id='native-session',start_at_wall=101.,server_wall=100.) + return {} + peer=SimpleNamespace(call=call,peer=SimpleNamespace(base_url='http://192.168.8.156:8082')) + process=Mock();process.poll.return_value=None + def local_ready(path,route): + path.parent.mkdir(exist_ok=True) + return {'session_id':'mac-session'} + with tempfile.TemporaryDirectory() as folder: + root=Path(folder)/'roadscore';(root/'assets').mkdir(parents=True);(root/'prototype').mkdir() + (root/'assets/demo_catalog.json').write_text(json.dumps({'paired_comma':peer.peer.base_url})) + with patch('paired_showcase.HERE',root/'prototype'),patch('paired_showcase.DemoPeer',return_value=peer), \ + patch('paired_showcase.signal.signal'),patch('paired_showcase.subprocess.Popen',return_value=process) as spawn, \ + patch('paired_showcase.stop_mac'),patch('paired_showcase.read_ready',side_effect=local_ready), \ + patch('paired_showcase.uuid.uuid4',return_value=SimpleNamespace(hex='paired-test-request')), \ + patch('demo_catalog.entry',return_value={'route':'private/route','archive':'/protected/core'}), \ + patch('mac_replay_ownership.preflight'),patch.dict('os.environ',{'ROADSCORE_MIRROR_DIR':'/inherited/mirror'}), \ + patch('sys.argv',['paired_showcase','--muted','--duration','0.000000001']): + native_pair_main() + self.assertNotIn('ROADSCORE_MIRROR_DIR',spawn.call_args.kwargs['env']) + payload=next(data for action,data in calls if action=='demo_start') + self.assertNotIn('screen_mirror',payload) + class MirrorControlTests(unittest.TestCase): def setUp(self): @@ -84,4 +113,29 @@ class MirrorControlTests(unittest.TestCase): with self.assertRaises(ValueError):DemoPeer('http://unrelated.example:8082') +class LegacyMirrorLaunchTests(unittest.TestCase): + def test_legacy_viewer_explicitly_requests_native_capture(self): + calls=[] + def call(action,data=None): + calls.append((action,data)) + if action=='status':return {'offroad':True} + if action=='demo_ready':return {'request_id':'mirror-test-request','running':True,'prepared':True,'ready_session_id':'native-session'} + return {} + peer=SimpleNamespace(call=call) + server=Mock(server_port=56976) + with tempfile.TemporaryDirectory() as folder: + root=Path(folder);(root/'assets').mkdir();(root/'prototype').mkdir() + (root/'assets/demo_catalog.json').write_text(json.dumps({'paired_comma':'http://192.168.8.156:8082'})) + with patch('paired_showcase.HERE',root/'prototype'),patch('paired_showcase.DemoPeer',return_value=peer), \ + patch('paired_showcase.control_server',return_value=server),patch('paired_showcase.signal.signal'), \ + patch('paired_showcase.uuid.uuid4',return_value=SimpleNamespace(hex='mirror-test-request')), \ + patch('demo_catalog.entry',return_value={'route':'private/route'}), \ + patch('sys.argv',['paired_showcase','--screen-mirror','--no-browser','--muted','--duration','0']): + mirror_main() + payload=next(data for action,data in calls if action=='demo_start') + self.assertEqual(payload,{'alias':'route1','request_id':'mirror-test-request','muted':True,'screen_mirror':True}) + self.assertIn(('demo_stop',{'request_id':'mirror-test-request'}),calls) + server.shutdown.assert_called_once();server.server_close.assert_called_once() + + if __name__=='__main__':unittest.main()