diff --git a/roadscore/prototype/mac_showcase.html b/roadscore/prototype/mac_showcase.html
index 185ac347c3..2705f58ef4 100644
--- a/roadscore/prototype/mac_showcase.html
+++ b/roadscore/prototype/mac_showcase.html
@@ -30,7 +30,7 @@ async function refresh(){
document.querySelector('#state').textContent=j.state+' · '+Math.max(0,j.audio_s||0).toFixed(1)+' s · '+(j.muted?'MUTED':'system audio output');
const follow=j.paired_controls?.following;
document.querySelector('#scope').textContent=follow?.enabled?'Galaxy is master · phone and comma controls are mirrored to this Mac':j.paired_controls?.enabled?'Mac + comma controls enabled · each button requests the same change on both':'Mac-only controls · comma is not paired in this session';
- const followErrors={peer_session_changed:'Comma replay restarted · reconnect this Mac session',local_session_changed:'Mac replay changed · reconnect this session',peer_action_in_progress:'Waiting for the Mac request to reach Galaxy',local_replay_unready:'Waiting for the Mac replay',peer_not_ready_for_replay:'Waiting for a fresh comma replay',peer_status_stale:'Galaxy status is stale',peer_timeout:'Galaxy status timed out'};
+ const followErrors={peer_session_changed:'Comma replay restarted · reconnect this Mac session',local_session_changed:'Mac replay changed · reconnect this session',peer_route_mismatch:'Comma and Mac routes do not match',peer_route_changed:'Comma route changed · reconnect this Mac session',invalid_peer_route:'Galaxy route identity unavailable',peer_action_in_progress:'Waiting for the Mac request to reach Galaxy',local_replay_unready:'Waiting for the Mac replay',peer_not_ready_for_replay:'Waiting for a fresh comma replay',peer_status_stale:'Galaxy status is stale',peer_timeout:'Galaxy status timed out'};
document.querySelector('#following').textContent=!follow?.enabled?'':follow.status==='following'?'Mac controls match fresh Galaxy state':follow.status==='applying'?'Applying Galaxy controls on Mac':followErrors[follow.error]||'Galaxy control mirroring paused · status unavailable';
const local=j.demo.available?j.demo.mode+' / signals '+j.demo.signal_mode:'unready';
document.querySelector('#targets').textContent='Mac '+local+'; comma '+commaStatus(j.paired_controls);
diff --git a/roadscore/prototype/mac_showcase.py b/roadscore/prototype/mac_showcase.py
index 14fadc935b..356559087b 100644
--- a/roadscore/prototype/mac_showcase.py
+++ b/roadscore/prototype/mac_showcase.py
@@ -70,6 +70,8 @@ def control_server(project, out, shared, port, forwarder=None, *, follow_peer=Fa
following.update(status='pending',error='peer_action_in_progress',snapshot=None);return
demo=operator.demo_status()
if not demo['available']:raise ValueError('local_replay_unready')
+ local_route=demo.get('route') or module.read_json(out/'status.json').get('route')
+ if snapshot['route']!=local_route:raise ValueError('peer_route_mismatch')
if local_session is None:local_session=demo['session_id']
elif demo['session_id']!=local_session:raise ValueError('local_session_changed')
signature=(snapshot['session_id'],local_session,snapshot['mode'],snapshot['signal_mode'],sequence)
@@ -84,7 +86,8 @@ def control_server(project, out, shared, port, forwarder=None, *, follow_peer=Fa
following.update(status='following' if matching else 'applying',error=None,snapshot=snapshot)
except Exception as error:
known={'peer_action_in_progress','peer_session_changed','local_session_changed','local_replay_unready',
- 'peer_not_ready_for_replay','invalid_peer_session','forwarder_disabled'}
+ 'peer_not_ready_for_replay','invalid_peer_session','forwarder_disabled','invalid_peer_route',
+ 'peer_route_changed','peer_route_mismatch'}
reason=str(error) if str(error) in known else 'peer_timeout' if isinstance(error,TimeoutError) else 'peer_status_unavailable'
with control_lock:following.update(status='paused',error=reason,snapshot=None)
def follow():
@@ -235,6 +238,9 @@ def audio_worker(a):
if now-last_status>=.08:
shared.update(state='READY' if anchor else 'PREPARING',audio_s=max(0,(position or 0)/rate))
snapshot=dict(command_wall=now,input_mode='replay',compute='prepared-core',composer='ace',profile='prism',style='Prism',readiness=shared['state'],section='PREPARED PRISM',route=a.route,buffered=max(0,(len(audio)-(position or 0))/rate),presentation_session_id=session,demo_engagement_mode=controls.mode,demo_signal_mode=controls.signal_mode,engagement_presentation={'enabled':True},generation_invoked=False)
+ if anchor is not None and state['route_t'] is not None and np.isfinite(state['route_t']):
+ snapshot.update(route_t=float(state['route_t']),elapsed=max(0,(position or 0)/rate),
+ source_model_ns=int(sm.logMonoTime['modelV2']))
snapshot=delay.apply(snapshot,rendered)
# The command API must remain available during initial DAC lead-in.
snapshot.setdefault('engagement_presentation',{'enabled':True})
diff --git a/roadscore/prototype/paired_demo_controls.py b/roadscore/prototype/paired_demo_controls.py
index d0c2db7dab..0d7abd5c86 100644
--- a/roadscore/prototype/paired_demo_controls.py
+++ b/roadscore/prototype/paired_demo_controls.py
@@ -141,6 +141,7 @@ class PairedDemoControls:
self._active = None
self._closed = False
self._bound_session = None
+ self._bound_route = None
self._write_revision = 0
def read_status(self):
@@ -162,12 +163,15 @@ class PairedDemoControls:
received = time.monotonic()
if received-started >= min(self.timeout,.75):raise TimeoutError()
session = _session(status)
+ route = status['demo'].get('route')
+ if not isinstance(route,str) or not route or route=='live':raise ValueError('invalid_peer_route')
with self._lock:
if self._closed:raise ValueError('forwarder_disabled')
if revision != self._write_revision or self._active is not None:raise ValueError('peer_action_in_progress')
- if self._bound_session is None:self._bound_session=session
+ if self._bound_session is None:self._bound_session,self._bound_route=session,route
elif session != self._bound_session:raise ValueError('peer_session_changed')
- return dict(session_id=session,mode=status['demo']['mode'],signal_mode=status['demo']['signal_mode'],
+ elif route != self._bound_route:raise ValueError('peer_route_changed')
+ return dict(session_id=session,route=route,mode=status['demo']['mode'],signal_mode=status['demo']['signal_mode'],
request_started_wall=started,received_wall=received,peer_status=status)
def _result(self, status, action, value, *, acknowledged=False, applied=False,
diff --git a/roadscore/prototype/test_mac_showcase_controls.py b/roadscore/prototype/test_mac_showcase_controls.py
index b674345e37..a13a3bcf8e 100644
--- a/roadscore/prototype/test_mac_showcase_controls.py
+++ b/roadscore/prototype/test_mac_showcase_controls.py
@@ -191,7 +191,7 @@ class ControlTests(unittest.TestCase):
def following_peer(self):
status=dict(available=True,offroad=True,state='READY',live={'enabled':False},
- demo=dict(available=True,session_id='comma-session',mode='recorded',signal_mode='recorded'))
+ demo=dict(available=True,session_id='comma-session',route='fixture-route',mode='recorded',signal_mode='recorded'))
calls=[]
def transport(method,*args):
calls.append(method)
@@ -238,6 +238,13 @@ class ControlTests(unittest.TestCase):
self.wait_for(lambda:self.request()[1]['paired_controls']['following']['error']=='local_session_changed')
self.assertFalse((self.out/'demo_engagement.json').exists())
+ def test_different_local_route_cannot_receive_peer_controls(self):
+ peer,_=self.following_peer()
+ self.state['route']='other-route';self.write_state()
+ peer['demo']['mode']='engaged'
+ self.wait_for(lambda:self.request()[1]['paired_controls']['following']['error']=='peer_route_mismatch')
+ self.assertFalse((self.out/'demo_engagement.json').exists())
+
class RememberedConfigTests(unittest.TestCase):
def setUp(self):
diff --git a/roadscore/prototype/test_paired_demo_controls.py b/roadscore/prototype/test_paired_demo_controls.py
index ce29b3cba4..dc986bd91e 100644
--- a/roadscore/prototype/test_paired_demo_controls.py
+++ b/roadscore/prototype/test_paired_demo_controls.py
@@ -16,7 +16,7 @@ from paired_demo_controls import GalaxyPeer, PairedDemoControls, _NoRedirect, _h
BASE = 'https://galaxy.firestar.link/ABCDEFGHIJKLMNOP'
COOKIE = 'ABCDEFGHIJKLMNOP%3A' + 'a' * 64
READY = {'available': True, 'offroad': True, 'state': 'READY', 'live': {'enabled': False},
- 'demo': {'available': True, 'session_id': 'independent-peer-session',
+ 'demo': {'available': True, 'session_id': 'independent-peer-session', 'route':'fixture-route',
'mode': 'recorded', 'signal_mode': 'recorded'}}
@@ -153,6 +153,15 @@ class PairedTests(unittest.TestCase):
self.transport.status['demo']['readiness']='PREPARING'
with self.assertRaisesRegex(ValueError,'peer_not_ready_for_replay'):controls.read_status()
+ def test_follower_requires_and_pins_exact_route_identity(self):
+ controls=self.forwarder()
+ self.transport.status['demo'].pop('route')
+ with self.assertRaisesRegex(ValueError,'invalid_peer_route'):controls.read_status()
+ self.transport.status['demo']['route']='fixture-route'
+ self.assertEqual(controls.read_status()['route'],'fixture-route')
+ self.transport.status['demo']['route']='different-route'
+ with self.assertRaisesRegex(ValueError,'peer_route_changed'):controls.read_status()
+
def test_late_status_and_disabled_reader_never_return_applied_state(self):
controls = self.forwarder()
with patch('paired_demo_controls.time.monotonic',side_effect=[100.,101.]):