mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
Arabesque time sync to prevent Firefox issues
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC
|
||||
from email.utils import parsedate_to_datetime
|
||||
import subprocess
|
||||
from threading import Event, Thread
|
||||
|
||||
from openpilot.starpilot.ui.raylib import rl
|
||||
|
||||
from openpilot.common.hardware import PC
|
||||
from openpilot.common.time_helpers import MIN_DATE, MAX_DATE
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.theme import DEFAULT_THEME
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.tools.browser import Browser
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.tools.files import Files
|
||||
@@ -27,6 +33,32 @@ class ArabesqueLayout(Widget):
|
||||
self._pressed_rect: rl.Rectangle | None = None
|
||||
self._active_tool: Widget | None = None
|
||||
self._active_name = ""
|
||||
self._time_sync_thread: Thread | None = None
|
||||
self._time_sync_cancelled = Event()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
if PC or not self._permitted() or self._time_sync_thread is not None and self._time_sync_thread.is_alive():
|
||||
return
|
||||
self._time_sync_cancelled = Event()
|
||||
self._time_sync_thread = Thread(target=self._sync_time, args=(self._time_sync_cancelled,), daemon=True)
|
||||
self._time_sync_thread.start()
|
||||
|
||||
def _sync_time(self, cancelled: Event):
|
||||
try:
|
||||
# shortcut: HTTP recovery time is unauthenticated, replace when an authenticated bootstrap source is available.
|
||||
headers = subprocess.run(
|
||||
['curl', '-fsI', '-m', '10', '-H', 'Cache-Control: no-cache', 'http://www.google.com'],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=True, timeout=11,
|
||||
).stdout
|
||||
date = next((line.partition(':')[2].strip() for line in headers.splitlines() if line.lower().startswith('date:')), '')
|
||||
net_time = parsedate_to_datetime(date)
|
||||
if net_time.tzinfo is None or not MIN_DATE < net_time.astimezone(UTC).replace(tzinfo=None) < MAX_DATE or cancelled.is_set():
|
||||
return
|
||||
subprocess.run(['sudo', '-n', 'date', '-u', '-s', net_time.isoformat()],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=2)
|
||||
except (OSError, ValueError, TypeError, OverflowError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
def _update_layout_rects(self):
|
||||
rect = self._rect
|
||||
@@ -67,10 +99,14 @@ class ArabesqueLayout(Widget):
|
||||
tool.hide_event()
|
||||
|
||||
def hide_event(self):
|
||||
# MainLayout also hides this view when offroad access is revoked.
|
||||
self._time_sync_cancelled.set()
|
||||
super().hide_event()
|
||||
self._close_tool()
|
||||
|
||||
def poll_runtime(self, awake: bool):
|
||||
if self._time_sync_cancelled.is_set() and self._time_sync_thread is not None and not self._time_sync_thread.is_alive():
|
||||
self.show_event()
|
||||
if isinstance(self._active_tool, Browser):
|
||||
self._active_tool.poll_runtime(awake)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from dataclasses import replace
|
||||
from enum import IntEnum
|
||||
import gc
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from threading import Event
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import Mock
|
||||
import weakref
|
||||
@@ -35,6 +37,7 @@ def layout_classes(path, names, namespace):
|
||||
@pytest.fixture
|
||||
def shell(monkeypatch):
|
||||
import openpilot.system.ui.widgets as widgets
|
||||
monkeypatch.setattr(desktop, 'PC', True)
|
||||
monkeypatch.setattr(widgets, 'device', NS(awake=True))
|
||||
monkeypatch.setattr(gui_app, '_mouse_events', [])
|
||||
monkeypatch.setattr(gui_app, '_show_touches', False)
|
||||
@@ -425,6 +428,7 @@ def test_authority_revocation_closes_tool_without_rendering(main, probe):
|
||||
main.layout.poll_arabesque()
|
||||
assert main.layout._current_mode == main.state.ONROAD
|
||||
assert view._active_tool is None
|
||||
assert view._time_sync_cancelled.is_set()
|
||||
probe.hide_event.assert_called_once()
|
||||
|
||||
|
||||
@@ -492,3 +496,68 @@ def test_star_large_routes_shell_and_uses_confirmed_offroad(main, probe):
|
||||
assert view._active_tool is None
|
||||
layout.star.close.assert_called_once()
|
||||
layout._native_onroad.close.assert_called_once()
|
||||
|
||||
|
||||
def test_time_sync_startup_cancellation_and_reentry(shell, monkeypatch):
|
||||
thread, run = Mock(), Mock(return_value=NS(stdout='dAtE: Mon, 02 Nov 2026 18:00:00 GMT\r\n'))
|
||||
monkeypatch.setattr(desktop, 'Thread', thread)
|
||||
monkeypatch.setattr(desktop.subprocess, 'run', run)
|
||||
shell.show_event()
|
||||
thread.assert_not_called()
|
||||
monkeypatch.setattr(desktop, 'PC', False)
|
||||
shell._permitted = Mock(return_value=False)
|
||||
shell.show_event()
|
||||
thread.assert_not_called()
|
||||
shell._permitted.return_value = True
|
||||
shell.show_event()
|
||||
run.assert_not_called()
|
||||
assert thread.call_args.kwargs['daemon']
|
||||
thread.return_value.start.assert_called_once()
|
||||
shell.show_event()
|
||||
thread.assert_called_once()
|
||||
shell.hide_event()
|
||||
shell.show_event()
|
||||
shell.poll_runtime(True)
|
||||
thread.assert_called_once()
|
||||
thread.call_args.kwargs['target'](*thread.call_args.kwargs['args'])
|
||||
assert run.call_count == 1 # Closing cancels the correction after the request returns.
|
||||
thread.return_value.is_alive.return_value = False
|
||||
shell._permitted.return_value = False
|
||||
shell.poll_runtime(True)
|
||||
thread.assert_called_once()
|
||||
shell._permitted.return_value = True
|
||||
shell.poll_runtime(True)
|
||||
assert thread.call_count == 2
|
||||
thread.call_args.kwargs['target'](*thread.call_args.kwargs['args'])
|
||||
assert run.call_count == 3
|
||||
fetch, apply = run.call_args_list[-2:]
|
||||
assert fetch.args[0] == ['curl', '-fsI', '-m', '10', '-H', 'Cache-Control: no-cache', 'http://www.google.com']
|
||||
assert fetch.kwargs['timeout'] == 11
|
||||
assert apply.args[0] == ['sudo', '-n', 'date', '-u', '-s', '2026-11-02T18:00:00+00:00']
|
||||
assert apply.kwargs['timeout'] == 2
|
||||
assert apply.kwargs['stdout'] == apply.kwargs['stderr'] == subprocess.DEVNULL
|
||||
shell.poll_runtime(True)
|
||||
assert thread.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('response', [
|
||||
'', 'HTTP/1.1 200 OK\r\n', 'Date: invalid\r\n',
|
||||
'Date: Sat, 01 Jan 2000 00:00:00 GMT\r\n', 'Date: Tue, 01 Jan 2036 00:00:00 GMT\r\n',
|
||||
'Date: Mon, 02 Nov 2026 18:00:00\r\n',
|
||||
subprocess.CalledProcessError(22, ['curl'], output='Date: Mon, 02 Nov 2026 18:00:00 GMT\r\n'),
|
||||
subprocess.CalledProcessError(28, ['curl'], output='Date: Mon, 02 Nov 2026 18:00:00 GMT\r\n'),
|
||||
subprocess.TimeoutExpired(['curl'], 11), FileNotFoundError('curl'),
|
||||
])
|
||||
def test_failed_or_invalid_time_sync_never_sets_clock(shell, monkeypatch, response):
|
||||
run = Mock(side_effect=response) if isinstance(response, Exception) else Mock(return_value=NS(stdout=response))
|
||||
monkeypatch.setattr(desktop.subprocess, 'run', run)
|
||||
shell._sync_time(Event())
|
||||
assert run.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('error', [subprocess.CalledProcessError(1, ['sudo']), subprocess.TimeoutExpired(['sudo'], 2), FileNotFoundError('sudo')])
|
||||
def test_time_sync_permission_or_command_failures_stay_quiet(shell, monkeypatch, error):
|
||||
run = Mock(side_effect=[NS(stdout='Date: Mon, 02 Nov 2026 18:00:00 GMT\r\n'), error])
|
||||
monkeypatch.setattr(desktop.subprocess, 'run', run)
|
||||
shell._sync_time(Event())
|
||||
assert run.call_count == 2
|
||||
|
||||
Reference in New Issue
Block a user