diff --git a/openpilot/selfdrive/ui/layouts/settings/software.py b/openpilot/selfdrive/ui/layouts/settings/software.py index 10a34fe020..e4ae065637 100644 --- a/openpilot/selfdrive/ui/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/layouts/settings/software.py @@ -133,6 +133,13 @@ class SoftwareLayout(Widget): self._download_btn.set_visible(ui_state.is_offroad()) updater_state = ui_state.params.get("UpdaterState") or "idle" + fast = ui_state.params.get('UpdaterFastState') or {} + check = fast.get('check', {}) if isinstance(fast, dict) else {} + checking_metadata = check.get('state') == 'checking' + if checking_metadata: + updater_state = 'checking...' + if check.get('completedAt', 0) >= getattr(self, '_waiting_start_ts', float('inf')) and check.get('state') in ('complete', 'error'): + self._waiting_for_updater = False failed_count = ui_state.params.get("UpdateFailedCount") or 0 fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable") update_available = ui_state.params.get_bool("UpdateAvailable") @@ -143,6 +150,14 @@ class SoftwareLayout(Widget): self._download_btn.action_item.set_enabled(False) # Use the mapping, with a fallback to the original state string display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state) + if checking_metadata: + display_text = tr('Checking for new versions') + elif isinstance(fast, dict) and fast.get('detail'): + display_text = fast['detail'] + if fast.get('background'): + display_text = tr('Background download') + ': ' + display_text + self._download_btn.action_item.set_text(tr('CHECK')) + self._download_btn.action_item.set_enabled(True) self._download_btn.action_item.set_value(display_text) else: if failed_count > 0: @@ -152,7 +167,7 @@ class SoftwareLayout(Widget): self._download_btn.action_item.set_value(tr("update available")) self._download_btn.action_item.set_text(tr("DOWNLOAD")) else: - last_update = ui_state.params.get("LastUpdateTime") + last_update = ui_state.params.get("UpdaterLastCheckTime") or ui_state.params.get("LastUpdateTime") if last_update: formatted = time_ago(last_update) self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted)) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 5e30aa158d..4180a3c5dd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -1,5 +1,6 @@ import subprocess import threading +import time import pyray as rl from enum import IntEnum from collections.abc import Callable @@ -83,6 +84,7 @@ class CheckUpdateButton(BigButton): self._signal_updater(self.CHECK_FOR_UPDATE) def _signal_updater(self, sig: str): + self._check_requested_at = time.monotonic() if sig == self.CHECK_FOR_UPDATE else None self.set_enabled(False) self._state = UpdaterState.WAITING_FOR_UPDATER self._hide_value_t = None @@ -109,6 +111,22 @@ class CheckUpdateButton(BigButton): return updater_state = ui_state.params.get("UpdaterState") or "" + fast = ui_state.params.get('UpdaterFastState') or {} + check = fast.get('check', {}) if isinstance(fast, dict) else {} + requested = getattr(self, '_check_requested_at', None) + if requested is not None and check.get('completedAt', 0) >= requested and check.get('state') in ('complete', 'error'): + self._state = UpdaterState.IDLE + self._check_requested_at = None + self._hide_value_t = rl.get_time() + if check.get('state') == 'checking': + self.set_rotate_icon(True) + self.set_value('checking version metadata...') + return + if isinstance(fast, dict) and fast.get('background') and updater_state in ('downloading...', 'finalizing update...'): + self.set_rotate_icon(False) + self.set_enabled(True) + self.set_value('Background download: ' + (fast.get('detail') or updater_state)) + return failed_count = ui_state.params.get("UpdateFailedCount") or 0 failed = int(failed_count) > 0 diff --git a/openpilot/starpilot/drive_state/control.py b/openpilot/starpilot/drive_state/control.py index 6972bff4f9..da714a1871 100644 --- a/openpilot/starpilot/drive_state/control.py +++ b/openpilot/starpilot/drive_state/control.py @@ -4,6 +4,7 @@ import threading import time from .resolver import Mode +from .owner import Rejected LABELS = {Mode.AUTO: 'Auto', Mode.OFFROAD: 'Offroad', Mode.ONROAD: 'Onroad'} @@ -39,8 +40,12 @@ class DriveStateControl: return result def change_galaxy(self, mode, revision, authorized): - result = self.owner.request(mode, expected_revision=revision, authorized=authorized, - override_allowed=lambda: self.physical.status(require_park=True)['allowed']) + def offroad_allowed(): + status = self.physical.status(require_park=True) + if not status['allowed']: + raise Rejected(status.get('reason') or 'Park the vehicle and disengage before forcing Offroad') + return True + result = self.owner.request(mode, expected_revision=revision, authorized=authorized, override_allowed=offroad_allowed) with self.lock: self.cached = None return result diff --git a/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py b/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py index e4fcc657f5..df119f6ef2 100644 --- a/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py +++ b/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py @@ -183,3 +183,19 @@ def test_local_force_offroad_with_ignition_on_keeps_park_authority_separate(fixt assert f.source.allowed() is (not moving and not engaged) if moving or engaged: assert not f.source.update_ready(True) + + +def test_galaxy_force_offroad_preserves_exact_rejection_reason_and_auto_recovery(tmp_path): + params = Params() + owner = DriveStateOwner(params, tmp_path / 'reason', alive=lambda _: True) + owner.initialize(pid=1, birth=2, boot=BOOT) + physical = NS(status=Mock(return_value={'allowed': False, 'reason': 'Current speed and gear are unavailable'})) + control = DriveStateControl(owner, physical, effective=lambda: False) + revision = owner.snapshot().revision + before = list(params.writes) + with pytest.raises(Rejected, match='Current speed and gear are unavailable'): + control.change_galaxy('offroad', revision, lambda: True) + assert params.writes == before + assert owner.snapshot().mode == Mode.AUTO + control.change_galaxy('auto', revision, lambda: True) + assert owner.snapshot().mode == Mode.AUTO diff --git a/openpilot/starpilot/external_preferences_reset.py b/openpilot/starpilot/external_preferences_reset.py index dc2ec31242..e6e24eef24 100644 --- a/openpilot/starpilot/external_preferences_reset.py +++ b/openpilot/starpilot/external_preferences_reset.py @@ -64,10 +64,12 @@ def reset_external_preferences(storage, model_root): locks.enter_context(_owner_lock(state, '.lock')) locks.enter_context(_owner_lock(model_root, '.state.lock')) locks.enter_context(_owner_lock(navigation_root, '.lock')) + locks.enter_context(_owner_lock(storage / 'software' / 'auto-install', '.lock')) if _read(state / 'complete.json') == identity: return () paths = {'model_preferences': model_root / 'preferences.json', - 'navigation_settings': navigation_root / 'settings.json'} + 'navigation_settings': navigation_root / 'settings.json', + 'software_install': storage / 'software' / 'auto-install' / 'settings.json'} journal_path = state / 'journal.json' journal_raw = _read(journal_path) if journal_raw is None: diff --git a/openpilot/starpilot/galaxy/README.md b/openpilot/starpilot/galaxy/README.md index 64e81ea212..a91e1b6e21 100644 --- a/openpilot/starpilot/galaxy/README.md +++ b/openpilot/starpilot/galaxy/README.md @@ -38,7 +38,7 @@ it labels its sample System Monitor data and cannot edit a device. | Home and Tools | Local status cards, navigation, theme, drawer, pinned tiles, and hash routes. | | System Monitor and Plots | Bounded local process/device and driving-message samples. Unavailable data stays unknown; leaving a page stops its polling. | | Logs and Routes | Crash-report previews and a shared route workspace for camera footage, driving UI replay, segment details, and scoped footage/replay/log downloads. | -| Software & Updates | Read-only installed and updater status. Refresh rereads reported Params; it does not check, download, switch branches, install, roll back, or reboot. | +| Software & Updates | Parked update controls and a newest-first version list. Installed and fully cached commits are marked; selecting a commit offers installation or download and installation. Initially shows up to two newer commits, the installed commit, and the previous installed commit. Collapsed gaps above and below the installed commit expand independently; older history remains available below the previous version. Each gap shows its own remaining count and reveals at most ten commits per click inside a scroll area bounded by the viewport. Release notes follow the selected installed or prepared version. | | Driving and Device Preferences | Saved settings through typed owners shared with native controls, including SLC, lateral and longitudinal tuning, conditional modes, display, cameras, and sounds. | | Vehicle Controls | One saved manual vehicle choice for the next start, or Auto for normal detection. | | Models and Model Laboratory | Verified model catalog management and parked Laboratory configuration. Paired inference is unavailable in the production model runner. | @@ -48,6 +48,21 @@ it labels its sample System Monitor data and cannot edit a device. | Sound packs | Catalog status, verified download and cancellation through the local sound owner. | | Galaxy Access & Install | Remote link pairing, Galaxy installation, and an opt-in Wi-Fi hotspot. Local addresses are on Home. | +`Refresh` and branch changes request filtered +history metadata in a separate Git cache and worker. They remain available during +background staging. `Check for updates` also wakes the background updater after the metadata check. When automatic downloads are enabled, a newer commit wakes +the separate download phase. Update and download actions retrieve the chosen +snapshot's missing file contents; version browsing uses `--filter=blob:none` with +bounded history depth. The branch selector lives in the version list and refreshes its metadata without changing the updater target. `Download only` prepares the selected commit without restarting; `Select previous version` selects the saved branch and commit without installing it. Downloads report Git object progress and finalization +reports copied files; Git garbage collection is outside the finalization path. + +Automatic installation is opt-in and defaults off. Its five numeric cron fields +(minute, hour, day, month, weekday) default to `0 0 * * *`: daily at midnight in +the saved local timezone shown in Advanced options. The timezone selector saves an IANA timezone with the schedule, including daylight-saving rules. The updater only restarts for a +validated, prepared update while idle and freshly confirmed offroad. It skips +onroad occurrences and reserves each scheduled minute before restarting to avoid +reboot loops. These preferences live in the StarPilot software state directory. + The Vue router in `web/js/app.js` and the route handlers in `server.py` are the current route map. API groups include `/api/auth`, `/api/system/monitor`, `/api/software/status`, `/api/settings`, `/api/models`, `/api/maps`, diff --git a/openpilot/starpilot/galaxy/software_operations.py b/openpilot/starpilot/galaxy/software_operations.py index ccd8526110..9a6ce0ebd8 100644 --- a/openpilot/starpilot/galaxy/software_operations.py +++ b/openpilot/starpilot/galaxy/software_operations.py @@ -11,6 +11,8 @@ import subprocess import threading import time from collections.abc import Callable +from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from openpilot.common.basedir import BASEDIR from openpilot.common.hardware import COMMA_HARDWARE @@ -20,7 +22,8 @@ from openpilot.starpilot.galaxy.software_status import SoftwareStatus, SoftwareU from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.software.update_control import UpdaterControlError from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, automatic_downloads -from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions +from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, next_restart +from openpilot.starpilot.software.history import METADATA_ROOT, commit_history, downloaded_versions, previous_version, release_notes, recent_versions FINALIZED = Path(os.environ.get("UPDATER_STAGING_ROOT", "/data/safe_staging")) / "finalized" @@ -28,7 +31,7 @@ BRANCH_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z") COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z") MAX_BRANCH_LIST = 32768 MAX_BRANCHES = 256 -SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version")) +SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version", "download-version")) PROCESS_TITLE = b"openpilot.system.updated.updated" @@ -103,7 +106,7 @@ class UpdaterProcess: return self.current_identity() is not None def send(self, action: str, *, branch=None, commit=None, selected_commit=None) -> tuple[int, int]: - if action not in ("check", "download", "update", "fast", "rollback", "versions", "version"): + if action not in ("check", "download", "update", "fast", "rollback", "versions", "version", "download-version"): raise SoftwareOperationError("Invalid updater command", 400) with self.lock: pid = self._manager_pid() @@ -111,7 +114,7 @@ class UpdaterProcess: if self._manager_pid() != pid or self._proc_identity(pid) != start: raise SoftwareOperationError("Updater process changed", 503) try: - if action == 'version': + if action in ('version', 'download-version'): self.control.send(pid, start, action, branch=branch, commit=commit, selected_commit=selected_commit) elif action in ("update", "fast", "rollback", "versions"): self.control.send(pid, start, action, branch=branch, commit=commit) @@ -146,7 +149,8 @@ def _git_identity(path: Path) -> tuple[str, str] | None: class SoftwareOperations: def __init__(self, params=None, status=None, parked=None, process=None, *, finalized=FINALIZED, - installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None): + installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None, + metadata=METADATA_ROOT, install_settings=None): self.params = params if params is not None else Params() self.status = status if status is not None else SoftwareStatus(self.params) if parked is None: @@ -160,6 +164,9 @@ class SoftwareOperations: self.process = process if process is not None else UpdaterProcess() self.refresh = refresh self.finalized = Path(finalized) + self.staging = self.finalized.parent / 'merged' + self.metadata = Path(metadata) + self.install_settings = install_settings if install_settings is not None else InstallSettings() self.installed = Path(installed) self.git_identity = git_identity self.clock = clock @@ -252,6 +259,20 @@ class SoftwareOperations: if self._baseline is None or request is None or request["state"] != "pending" or request["action"] == "install": return state = status["updater"]["state"] + check = (status['updater'].get('fast') or {}).get('check') + if request['action'] in ('check', 'versions') and check is not None: + completed = check.get('completedAt') + if (type(completed) in (int, float) and completed >= self._started and + (request['target'] is None or check['branch'] == request['target'])): + if check['state'] == 'complete': + request.update(state='complete', error=None) + elif check['state'] == 'error': + request.update(state='failed', error=check['detail']) + elif current_owner is not None and self._request_owner is not None and current_owner != self._request_owner: + request.update(state='failed', error='Metadata check was interrupted when the updater restarted. Retry the check.') + elif self.clock() - self._started > 90: + request.update(state='failed', error='Metadata check did not report completion. Retry the check.') + return if (request["action"] not in SOURCE_ACTIONS and request["target"] is not None and status["updater"]["targetBranch"] != request["target"]): request.update(state="failed", error="Updater target changed") @@ -273,7 +294,12 @@ class SoftwareOperations: if status["updater"]["lastFetchAt"] != self._baseline[1]: installed = self.git_identity(self.installed) restarting = self._flag("DoReboot") is True or installed is not None and installed[1] != self._fast_commit - if request['action'] == 'versions': + if request['action'] == 'download-version': + if self._ready(status, request['target']) and self.git_identity(self.finalized) == (request['target'], request['selectedCommit']): + request.update(state='complete', error=None) + else: + request.update(state='failed', error='Selected version was not prepared; refresh and try again') + elif request['action'] == 'versions': request.update(state='complete', error=None) else: request.update(state="complete", error=None, outcome="restarting" if restarting else "up_to_date") @@ -330,10 +356,15 @@ class SoftwareOperations: branches = self._branches() updater_idle = status["updater"]["state"] == "idle" pending = self.request is not None and self.request["state"] == "pending" + fast = status['updater'].get('fast') or {} + metadata_busy = fast.get('check', {}).get('state') == 'checking' or pending and self.request['action'] in ('check', 'versions') disabled_state = self._flag("DisableUpdates") disabled = disabled_state is not False reboot = self._flag("DoReboot") is not False - usable = parked and updater_idle and not disabled and not pending and not reboot and process + usable = parked and updater_idle and not disabled and not pending and not reboot and process and not metadata_busy + can_check = (parked and not disabled and not reboot and process and not metadata_busy and + status['updater']['state'] in ('idle', 'checking...', 'downloading...', 'finalizing update...') and + (not pending or self.request['action'] in ('download', 'download-version'))) target = self.selected_target or status["updater"]["targetBranch"] or status["installed"]["branch"] selected = target is not None and target in branches and status["updater"]["targetBranch"] == target reason = None @@ -345,28 +376,41 @@ class SoftwareOperations: reason = "Reboot is pending" elif not process: reason = "Getting the updater ready…" if COMMA_HARDWARE else "Updater status is unavailable" - elif pending or not updater_idle: + elif pending or not updater_idle or metadata_busy: reason = "Updater is busy" - fast = status["updater"].get("fast") - stage = fast["stage"] if fast and status["updater"]["state"] == "updating..." else status["updater"]["state"] + fast = status["updater"].get("fast") or {} + active = (status['updater']['state'] == 'updating...' or + status['updater']['state'] in ('downloading...', 'finalizing update...') and + fast.get('stage') not in ('complete', 'error')) + stage = fast["stage"] if fast and active else status["updater"]["state"] if stage == "idle": stage = None - detail = fast["detail"] if fast and status["updater"]["state"] == "updating..." else "" + detail = fast["detail"] if fast and active else "" if not process and not updater_idle: stage = "unavailable" detail = ("Waiting for the device updater service…" if COMMA_HARDWARE else "Update checks require the device updater service, which is unavailable when Galaxy runs alone on a development computer.") elif self.request is not None and self.request.get("error") == "Updater did not report completion": stage, detail = "timed_out", "Request timed out; the updater may still be running" + install = self.install_settings.read() + prepared = self.git_identity(self.finalized) if status["updater"]["finalizedUpdateReady"] else None + restart = next_restart(install["cron"], datetime.now(ZoneInfo(install.get("timezone", "UTC")))) if install and install["enabled"] and prepared else None return { "repository": self._repository(), - "updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": None}, + "updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": fast.get('percent') if active else None, + **({'background': fast['background']} if active and 'background' in fast else {})}, "parked": parked, "availableBranches": branches, "selectedTarget": target, - "canCheck": usable, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches), + "canCheck": can_check, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches), "canRollback": usable and self._rollback_available(), "canDownload": usable and selected, "canInstall": usable and selected and self._ready(status, target), "reason": reason, "request": dict(self.request) if self.request is not None else None, "automaticDownloads": automatic_downloads(self.params), "canConfigure": parked and not reboot, + "automaticInstalls": install['enabled'] if install else None, + "restartSchedule": install['cron'] if install else DEFAULT_CRON, + "installPreferenceRevision": install['revision'] if install else None, + "restartTimezone": install.get("timezone", "UTC") if install else "UTC", + "preparedCommit": prepared[1] if prepared else None, + "nextRestartAt": restart.isoformat() if restart else None, **({"history": self._history_snapshot(status)} if include_history else {}), } @@ -377,20 +421,32 @@ class SoftwareOperations: def _history_snapshot(self, status: dict) -> dict: branch = self.selected_target or status['updater']['targetBranch'] or status['installed']['branch'] - key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch, status['updater']['lastFetchAt']) + key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch, + status['updater']['lastFetchAt'], status['updater'].get('lastCheckedAt'), status['updater']['state']) now = self.clock() if self._history is not None and self._history_key == key and 0 <= now - self._history_at < 30.: return self._history recent = recent_versions(self.installed, branch) + staged_recent = recent_versions(self.staging, branch) + if staged_recent['head'] is not None: + recent = staged_recent + elif recent['head'] is None: + recent = recent_versions(self.finalized, branch) + metadata = recent_versions(self.metadata, branch) + if metadata['head'] is not None: + recent = metadata installed = self.git_identity(self.installed) downloaded = self.git_identity(self.finalized) - current_rows = self.history_reader(self.installed, installed[1]) if installed else [] + current_rows = self.history_reader(self.installed, installed[1], first_parent=True) if installed else [] + previous = previous_version(self.installed) downloaded_rows = (self.history_reader(self.finalized, downloaded[1]) if downloaded and downloaded != installed else []) # Readiness and installation still require _ready(); a history entry never # grants permission to boot or execute a revision. self._history = { - 'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent, + 'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent, 'previous': previous, + 'downloadedCommits': downloaded_versions((self.staging if self.staging.exists() else self.finalized, self.installed), + (row['hash'] for row in [*recent['entries'], *current_rows, *([previous] if previous else [])])), 'currentReleaseNotes': release_notes(self.params, 'UpdaterCurrentReleaseNotes'), 'downloadedReleaseNotes': release_notes(self.params, 'UpdaterNewReleaseNotes') if downloaded_rows else None, } @@ -419,9 +475,41 @@ class SoftwareOperations: raise SoftwareOperationError('Update preference could not be verified', 503) return self.snapshot() + def _install_preferences(self, payload: dict, authorized: Callable[[], bool]) -> dict: + if (set(payload) not in ({'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision'}, + {'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision', 'restartTimezone'}) or + type(payload['automaticInstalls']) is not bool or type(payload['restartSchedule']) is not str or + payload['expectedRevision'] is not None and + (type(payload['expectedRevision']) is not str or not re.fullmatch('[0-9a-f]{32}', payload['expectedRevision']))): + raise SoftwareOperationError('Invalid install preferences', 400) + try: + cron_fields(payload['restartSchedule']) + if 'restartTimezone' in payload: + if not isinstance(payload['restartTimezone'], str) or len(payload['restartTimezone']) > 128: + raise ValueError('Invalid timezone') + ZoneInfo(payload['restartTimezone']) + except (ValueError, ZoneInfoNotFoundError) as error: + raise SoftwareOperationError(str(error), 400) from error + with self.lock: + if not authorized(): + raise SoftwareOperationError('Session expired', 403) + if not self._parked() or self._flag('DoReboot') is not False: + raise SoftwareOperationError('Park the vehicle to change install preferences', 409) + def admitted(): + return authorized() and self._parked() and self._flag('DoReboot') is False + try: + self.install_settings.save(payload['automaticInstalls'], payload['restartSchedule'], payload['expectedRevision'], admitted, + timezone=payload.get('restartTimezone')) + except PermissionError as error: + raise SoftwareOperationError(str(error), 403) from error + except ValueError as error: + raise SoftwareOperationError(str(error), 409) from error + return self.snapshot() + def action(self, action: str, payload: object, *, authorized: Callable[[], bool]) -> dict: if (type(payload) is not dict or type(action) is not str or - action not in ("check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version")): + action not in ("check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback", + "versions", "version", "download-version")): raise SoftwareOperationError("Invalid software action", 400) assert isinstance(payload, dict) payload = dict(payload) @@ -429,24 +517,31 @@ class SoftwareOperations: raise SoftwareOperationError("Invalid software action", 400) if action == 'preferences': return self._preferences(payload, authorized) + if action == 'install-preferences': + return self._install_preferences(payload, authorized) fields = set(payload) - {"action"} if action == "check": if fields: raise SoftwareOperationError("Invalid software action", 400) branch = None else: - required_fields = {"branch", "expectedCommit", "selectedCommit"} if action == "version" else {"branch"} + pinned_install = action == "install" and fields != {"branch"} + required_fields = {"branch", "expectedCommit", "selectedCommit"} if action in ("version", "download-version") or pinned_install else {"branch"} if fields != required_fields or not self._valid_branch(payload.get("branch")): raise SoftwareOperationError("Invalid branch", 400) + if pinned_install and any(type(payload[key]) is not str or not COMMIT_PATTERN.fullmatch(payload[key]) + for key in ('expectedCommit', 'selectedCommit')): + raise SoftwareOperationError("Invalid selected version", 400) branch = payload["branch"] assert isinstance(branch, str) with self.lock: if not authorized(): raise SoftwareOperationError("Session expired", 403) - view = self.snapshot() + metadata_action = action in ('check', 'versions') + view = self.snapshot(include_history=action in ('version', 'download-version')) if not view["parked"]: raise SoftwareOperationError("Vehicle is not safely parked", 409) - if view["reason"] is not None: + if metadata_action and not view['canCheck'] or not metadata_action and view["reason"] is not None: raise SoftwareOperationError(view["reason"], 503 if view["reason"] == "Updater status is unavailable" else 409) if action not in SOURCE_ACTIONS and branch is not None and branch not in view["availableBranches"]: raise SoftwareOperationError("Branch is not in the current updater list", 409) @@ -456,35 +551,44 @@ class SoftwareOperations: if action in SOURCE_ACTIONS: identity = self.git_identity(self.installed) if (identity is None or not self._valid_branch(identity[0]) or - (action in ("update", "fast", "versions", "version") and branch != identity[0] and branch not in view["availableBranches"]) or + (action in ("update", "fast", "versions", "version", "download-version") and + branch != identity[0] and branch not in view["availableBranches"]) or (action == "rollback" and (branch != identity[0] or not view["canRollback"]))): raise SoftwareOperationError("Installed checkout or update target is unavailable", 409) - if action == 'version': + if action in ('version', 'download-version'): target = payload['selectedCommit'] recent = view['history']['recent'] + selectable = [*recent['entries'], *(view['history']['installed'] if branch == identity[0] else [])] if (payload['expectedCommit'] != identity[1] or type(target) is not str or not COMMIT_PATTERN.fullmatch(target) or - recent['branch'] != branch or not any(row['hash'] == target for row in recent['entries'])): + recent['branch'] != branch or not any(row['hash'] == target for row in selectable)): raise SoftwareOperationError('Selected version or installed revision changed; refresh and try again', 409) if action == "install" and not view["canInstall"]: raise SoftwareOperationError("Finalized update does not match selected branch", 409) if not authorized() or not self._parked(): raise SoftwareOperationError("Session or parked state changed", 403) current = self.status.snapshot() - if (current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or + if (not metadata_action and current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or self._flag("DoReboot") is not False or not self.process.available() or (action not in SOURCE_ACTIONS and branch is not None and branch not in self._branches())): raise SoftwareOperationError("Updater state changed", 409) + if action == 'versions': + self.selected_target = branch if action == "select": self.params.put("UpdaterTargetBranch", branch, block=True) self.selected_target = branch return self.snapshot() accepted_owner = None + request_started = self.clock() if action == "install": status = self.status.snapshot() if not self._ready(status, branch) or self._flag("DoReboot") is not False: raise SoftwareOperationError("Update is no longer ready", 409) if not authorized() or not self._parked(): raise SoftwareOperationError("Session or parked state changed", 403) + if 'selectedCommit' in payload and (status['installed']['commit'] != payload['expectedCommit'] or + self.git_identity(self.installed) != (status['installed']['branch'], payload['expectedCommit']) or + self.git_identity(self.finalized) != (branch, payload['selectedCommit'])): + raise SoftwareOperationError("Selected version changed; refresh before installing", 409) self.params.put_bool("DoReboot", True, block=True) else: status = self.status.snapshot() @@ -496,11 +600,11 @@ class SoftwareOperations: latest_identity = self.git_identity(self.installed) if latest_identity is None or latest_identity != identity: raise SoftwareOperationError("Installed checkout changed while preparing the update; retry when it finishes", 409) - if action == 'version' and identity[1] != payload['expectedCommit']: + if action in ('version', 'download-version') and identity[1] != payload['expectedCommit']: raise SoftwareOperationError('Installed revision changed before version request; refresh and try again', 409) self._fast_commit = identity[1] if action in SOURCE_ACTIONS: - if action == 'version': + if action in ('version', 'download-version'): accepted_owner = self.process.send(action, branch=branch, commit=identity[1], selected_commit=payload['selectedCommit']) else: accepted_owner = self.process.send(action, branch=branch, commit=identity[1]) @@ -509,12 +613,12 @@ class SoftwareOperations: self.request = {"id": secrets.token_hex(8), "action": action, "target": branch if branch is not None else status["updater"]["targetBranch"], "state": "pending", "error": None} - if action == 'version': + if action in ('version', 'download-version', 'install') and 'selectedCommit' in payload: self.request['selectedCommit'] = payload['selectedCommit'] self._baseline = baseline if action != "install" else None self._request_owner = accepted_owner - self._started = self.clock() - return self.snapshot() + self._started = request_started + return self.snapshot(include_history=not metadata_action) def close(self) -> None: if self.context is not None: diff --git a/openpilot/starpilot/galaxy/software_status.py b/openpilot/starpilot/galaxy/software_status.py index bead574d85..72bf6ebefa 100644 --- a/openpilot/starpilot/galaxy/software_status.py +++ b/openpilot/starpilot/galaxy/software_status.py @@ -27,12 +27,25 @@ def _fast_state(raw): try: value = json.loads(raw) if raw is not None else None if not isinstance(value, dict) or value.get("version") != 1 or value.get("stage") not in ( - "preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error"): + "preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error", "finalizing"): return None detail = value.get("detail") if not isinstance(detail, str) or len(detail) > 512: return None - return {"stage": value["stage"], "detail": detail} + result = {"stage": value["stage"], "detail": detail} + if type(value.get('background')) is bool: + result['background'] = value['background'] + percent = value.get('percent') + if type(percent) in (int, float) and 0 <= percent <= 100: + result['percent'] = percent + check = value.get('check') + if (isinstance(check, dict) and check.get('state') in ('checking', 'complete', 'error') and + isinstance(check.get('branch'), str) and len(check['branch']) <= 128 and + isinstance(check.get('detail'), str) and len(check['detail']) <= 512): + completed = check.get('completedAt') + if completed is None or type(completed) in (int, float) and 0 <= completed < float('inf'): + result['check'] = {key: check[key] for key in ('state', 'branch', 'detail', 'completedAt') if key in check} + return result except (TypeError, ValueError): return None diff --git a/openpilot/starpilot/galaxy/tests/browser/design-system.cjs b/openpilot/starpilot/galaxy/tests/browser/design-system.cjs index a74ce8aa2a..814d985e15 100644 --- a/openpilot/starpilot/galaxy/tests/browser/design-system.cjs +++ b/openpilot/starpilot/galaxy/tests/browser/design-system.cjs @@ -123,8 +123,11 @@ const assert = require('node:assert/strict') const url = new URL(route.request().url()) softwareRequests.push(url.search) await new Promise(resolve => setTimeout(resolve,250)) + if (route.request().method() === 'POST' && route.request().postDataJSON().action === 'versions') snapshot.operations.selectedTarget = route.request().postDataJSON().branch const operations = {...snapshot.operations} - if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''})),downloaded:[], + if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:[{hash:'a'.repeat(40),date:'2026-10-01T12:00:00Z',subject:'Installed fixture'}],downloaded:[], + previous:{hash:'b'.repeat(40),date:'2026-09-28T12:00:00Z',subject:'Previous fixture',branch:'Dom'}, + recent:{branch:operations.selectedTarget,head:'0'.repeat(40),entries:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''}))}, currentReleaseNotes:'Fixture release notes',downloadedReleaseNotes:null} await route.fulfill({json:{...snapshot,operations}}) }) @@ -166,25 +169,28 @@ const assert = require('node:assert/strict') await page.getByRole('dialog',{name:'Update software'}).waitFor() await page.getByRole('button',{name:'Cancel',exact:true}).click() await page.getByText('Advanced options',{exact:true}).click() - await page.getByRole('button',{name:'Previous version',exact:true}).click() - await page.getByRole('dialog').waitFor() - await page.getByRole('button',{name:'Cancel',exact:true}).click() - await page.getByRole('combobox',{name:'Target branch',exact:true}).click() - await page.getByRole('option',{name:/Development — Dom/}).click() - await page.getByRole('button',{name:'Update',exact:true}).click() - await page.getByRole('dialog',{name:'Update software'}).waitFor() - assert.equal(await page.getByRole('dialog').getByText('Install the latest version of Dom and restart?',{exact:true}).count(),1) - await page.getByRole('button',{name:'Cancel',exact:true}).click() - await page.getByRole('button',{name:'Save target',exact:true}).click() - await page.getByRole('dialog',{name:'Change target branch'}).waitFor() - await page.getByRole('button',{name:'Cancel',exact:true}).click() - await page.getByText('Versions & release notes',{exact:true}).click() - await page.getByRole('button',{name:'Installed',exact:true}).click() await page.getByText('Fixture release notes',{exact:true}).waitFor() - assert.equal(softwareRequests.at(-1),'','history is fetched only after opening release notes') - assert.equal(await page.locator('.gx-build-history li').count(),20) - assert.equal(await page.locator('.gx-build-history li strong').first().textContent(),'000000000000') - assert.equal(await page.locator('.gx-build-history').evaluate(element=>getComputedStyle(element).listStyleType),'none') + assert.equal(await page.locator('.gx-version-row').count(),3) + assert.equal(await page.locator('.gx-version-row strong').first().textContent(),'00000000') + assert.equal(await page.locator('.gx-version-list').evaluate(element=>getComputedStyle(element).listStyleType),'none') + assert.equal(await page.getByRole('button',{name:'No changes selected',exact:true}).isEnabled(),false) + await page.getByRole('button',{name:'Load 10 of 18 collapsed versions',exact:true}).click() + assert.equal(await page.locator('.gx-version-row').count(),13) + await page.getByRole('button',{name:'Load 8 collapsed versions',exact:true}).click() + assert.equal(await page.locator('.gx-version-row').count(),21) + await page.getByRole('button',{name:'Select previous version',exact:true}).click() + await page.getByRole('combobox',{name:'Version branch',exact:true}).filter({hasText:'Development — Dom'}).waitFor() + assert.equal(await page.getByRole('dialog').count(),0,'selecting previous does not install it') + await page.getByRole('button',{name:'Install selected version',exact:true}).click() + await page.getByRole('dialog',{name:'Previous version'}).waitFor() + await page.getByRole('button',{name:'Cancel',exact:true}).click() + await page.getByRole('combobox',{name:'Version branch',exact:true}).click() + await page.getByRole('option',{name:/Stable — StarPilot/}).click() + await page.getByText('Fixture release notes',{exact:true}).waitFor() + await page.getByRole('button',{name:'Load 10 of 18 collapsed versions',exact:true}).click() + await page.getByRole('button',{name:'Load 8 collapsed versions',exact:true}).click() + const scroll = await page.locator('.gx-version-scroll').evaluate(element=>({height:element.clientHeight,content:element.scrollHeight,viewport:innerHeight})) + assert.ok(scroll.content>scroll.height && scroll.height<=scroll.viewport*.65+1) assert.equal(await page.getByRole('button',{name:'Downloaded',exact:true}).count(),0) assert.equal(await check.isEnabled(),true,'history refresh keeps update controls enabled') const backup = {format:'galaxy-toggles',version:1,createdAt:'2026-10-07T12:00:00Z',metric:false,vehicle:null, diff --git a/openpilot/starpilot/galaxy/tests/test_drive_state.mjs b/openpilot/starpilot/galaxy/tests/test_drive_state.mjs index 61485cf47b..85ac900a65 100644 --- a/openpilot/starpilot/galaxy/tests/test_drive_state.mjs +++ b/openpilot/starpilot/galaxy/tests/test_drive_state.mjs @@ -26,7 +26,7 @@ last.resolve(response(snapshot('offroad')));await pending assert.equal(value.state.mode,'onroad') const denied=panel();const loading=denied.load();requests.at(-1).resolve(response({error:'Sign in to Galaxy'},401));await loading assert.equal(denied.state,null) -assert.equal(denied.error,'Drive state unavailable') +assert.equal(denied.statusError,'Drive state unavailable') console.log('PASS: strict status; captured revision; aborted/out-of-order status; requested versus effective; unmount; unauthorized state') const onroad=panel();onroad.state={...snapshot(),effective:'onroad',overrideAllowed:false} @@ -49,3 +49,27 @@ requests.at(-1).resolve(response(live));await forceOnroad;await flush() assert.equal(onroad.state.mode,'onroad') assert.equal(DriveStatePanel.computed.pending.call(onroad),false) console.log('PASS: Galaxy Offroad dispatch uses server admission; Force Onroad remains available') + +for (const [offroadOnly, lockout, mode, expected] of [ + [true,true,'auto',true], [true,false,'auto',false], [true,false,'offroad',true], [false,false,'auto',true], +]) assert.equal(DriveStatePanel.computed.visible.call({offroadOnly,lockout,state:snapshot(mode)}),expected) +assert.match(DriveStatePanel.template,/Force device offroad/) + +// A rejected enable must not look enabled or lose its error during status polling. +const rejectedToggle=panel();rejectedToggle.offroadOnly=true;rejectedToggle.lockout=false;rejectedToggle.state=snapshot() +const input={checked:true} +const attempt=rejectedToggle.toggleOffroad({target:input}) +assert.equal(input.checked,false,'wait for the server to accept the mode') +requests.at(-1).resolve(response({error:'Current speed and gear are unavailable'},409));await flush() +requests.at(-1).resolve(response(snapshot()));await attempt;await flush() +assert.equal(input.checked,false) +assert.equal(rejectedToggle.state.mode,'auto') +assert.equal(rejectedToggle.error,'Current speed and gear are unavailable') +assert.equal(DriveStatePanel.computed.visible.call(rejectedToggle),true,'keep the rejected action visible') +input.checked=true +const retry=rejectedToggle.toggleOffroad({target:input}) +assert.equal(rejectedToggle.error,'') +requests.at(-1).resolve(response(snapshot('offroad','e'.repeat(32))));await flush() +requests.at(-1).resolve(response(snapshot('offroad','e'.repeat(32))));await retry;await flush() +assert.equal(input.checked,true) +assert.equal(rejectedToggle.state.mode,'offroad') diff --git a/openpilot/starpilot/galaxy/tests/test_software_http.py b/openpilot/starpilot/galaxy/tests/test_software_http.py index 33cc170d92..431ebe3ded 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_http.py +++ b/openpilot/starpilot/galaxy/tests/test_software_http.py @@ -15,6 +15,7 @@ from openpilot.starpilot.galaxy.server import make_remote_server, make_server from openpilot.starpilot.galaxy.software_operations import SoftwareOperations from openpilot.starpilot.galaxy.software_status import SoftwareStatus from openpilot.starpilot.galaxy.tests.test_software_operations import FakeProcess +from openpilot.starpilot.software.auto_install import InstallSettings class SoftwareHttpTest(unittest.TestCase): @@ -37,7 +38,8 @@ class SoftwareHttpTest(unittest.TestCase): self.process = FakeProcess() self.parked = True self.operations = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process, - installed=installed, finalized=root / 'finalized') + installed=installed, finalized=root / 'finalized', metadata=root / 'metadata.git', + install_settings=InstallSettings(root / 'install-settings')) self.access = GalaxyAccessOwner(root / 'access') self.pairing = RemotePairing(root / 'pairing') self.local = make_server(port=0, owner=self.access, remote_pairing=self.pairing, @@ -103,6 +105,24 @@ class SoftwareHttpTest(unittest.TestCase): self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409) self.assertEqual(self.process.sent, []) + def test_local_and_remote_auto_install_schedule_roundtrip_requires_offroad(self): + for remote in (False, True): + cookie = self.remote_cookie if remote else self.local_cookie + status, value, _ = self.request('/api/software/status?history=0', remote=remote, cookie=cookie) + self.assertEqual(status, 200) + payload = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '30 2 * * 1-5', + 'expectedRevision': value['operations']['installPreferenceRevision']} + self.parked = False + self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409) + self.parked = True + status, saved, _ = self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie) + self.assertEqual(status, 200) + self.assertTrue(saved['operations']['automaticInstalls']) + self.assertEqual(saved['operations']['restartSchedule'], '30 2 * * 1-5') + self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409) + self.assertEqual(self.process.sent, []) + self.assertFalse(self.params.get_bool('DoReboot')) + def test_remote_auth_origin_and_parked_rules_cannot_be_bypassed(self): payload = {'action': 'preferences', 'automaticDownloads': False, 'expectedAutomaticDownloads': True} self.assertEqual(self.request('/api/software/status', remote=True)[0], 401) diff --git a/openpilot/starpilot/galaxy/tests/test_software_operations.py b/openpilot/starpilot/galaxy/tests/test_software_operations.py index 4032509405..f5b155d59b 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_operations.py +++ b/openpilot/starpilot/galaxy/tests/test_software_operations.py @@ -10,6 +10,7 @@ from openpilot.starpilot.galaxy.software_operations import ( SoftwareOperations, SoftwareOperationError, UpdaterProcess, PROCESS_TITLE, ) from openpilot.starpilot.software.update_control import UpdaterControlError +from openpilot.starpilot.software.auto_install import InstallSettings class FakeProcess: @@ -34,6 +35,12 @@ class FakeProcess: class SoftwareOperationsTest(unittest.TestCase): def setUp(self): + hardware = mock.patch('openpilot.starpilot.galaxy.software_operations.COMMA_HARDWARE', False) + hardware.start() + self.addCleanup(hardware.stop) + downloads = mock.patch('openpilot.starpilot.galaxy.software_operations.downloaded_versions', return_value=[]) + downloads.start() + self.addCleanup(downloads.stop) self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) root = Path(self.temp.name) @@ -51,13 +58,15 @@ class SoftwareOperationsTest(unittest.TestCase): self.process = FakeProcess() self.parked = True self.authorized = True + self.metadata = root / "metadata.git" self.finalized = root / "finalized" self.finalized.mkdir() self.installed = root / "installed" self.installed.mkdir() self.git = {self.installed: ("main", "a" * 40), self.finalized: ("release", "b" * 40)} self.owner = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process, - finalized=self.finalized, installed=self.installed, + finalized=self.finalized, metadata=self.metadata, installed=self.installed, + install_settings=InstallSettings(root / 'install-settings'), git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) @@ -86,7 +95,7 @@ class SoftwareOperationsTest(unittest.TestCase): self.parked = False self.owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, - finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) + finalized=self.finalized, metadata=self.metadata, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) for expected in (True, False): order.clear() @@ -105,7 +114,7 @@ class SoftwareOperationsTest(unittest.TestCase): refresh = mock.Mock(side_effect=outcome) if isinstance(outcome, Exception) else mock.Mock(return_value=outcome) parked = mock.Mock(return_value=True) owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, - finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) + finalized=self.finalized, metadata=self.metadata, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(owner.close) view = owner.snapshot(include_history=False) refresh.assert_called_once_with() @@ -174,7 +183,7 @@ class SoftwareOperationsTest(unittest.TestCase): "detail": "Request timed out; the updater may still be running", "percent": None}) self.assertTrue(view["updaterAvailable"]) self.assertEqual(view["request"]["state"], "failed") - self.assertFalse(view["canCheck"]) + self.assertTrue(view["canCheck"]) self.assertEqual(self.params.get("UpdaterState"), "finalizing update...") def test_recent_version_admission_pins_installed_snapshot_and_listed_target(self): @@ -197,6 +206,47 @@ class SoftwareOperationsTest(unittest.TestCase): assert self.owner.request is not None self.assertEqual(self.owner.request['selectedCommit'], selected) + def test_branch_browsing_fetches_metadata_without_changing_updater_target(self): + result = self.act('versions', 'release') + self.assertEqual(self.params.get('UpdaterTargetBranch'), 'main') + self.assertEqual(result['selectedTarget'], 'release') + self.assertEqual(self.process.sent, ['versions']) + self.assertFalse(self.params.get_bool('DoReboot')) + + def test_download_selected_commit_pins_request_and_does_not_reboot(self): + selected = 'c' * 40 + recent = {'branch': 'main', 'head': selected, 'entries': [{'hash': selected, 'date': '', 'subject': 'Selected'}]} + payload = {'action': 'download-version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected} + with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \ + mock.patch.object(self.process, 'send') as send: + self.owner.action('download-version', payload, authorized=lambda: True) + send.assert_called_once_with('download-version', branch='main', commit='a' * 40, selected_commit=selected) + request = self.owner.request + assert request is not None + self.assertEqual(request['selectedCommit'], selected) + self.assertFalse(self.params.get_bool('DoReboot')) + self.git[self.finalized] = ('main', selected) + (self.finalized / '.overlay_consistent').touch() + self.params.put('UpdaterNewDescription', 'test / main / ccccccc / Oct 30', block=True) + self.params.put_bool('UpdateAvailable', True, block=True) + self.params.put('LastUpdateTime', datetime(2026, 10, 30), block=True) + self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 30), block=True) + with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}): + self.assertEqual(self.owner.snapshot()['request']['state'], 'complete') + self.assertFalse(self.params.get_bool('DoReboot')) + + def test_download_completion_handles_finalized_identity_disappearing(self): + request = {'action': 'download-version', 'state': 'pending', 'target': 'main', 'selectedCommit': 'c' * 40} + self.owner.request = request + self.owner._baseline = (None, None, 0) + self.params.put('LastUpdateTime', datetime(2026, 10, 30), block=True) + self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 30), block=True) + with mock.patch.object(self.owner, '_ready', return_value=True), \ + mock.patch.object(self.owner, 'git_identity', return_value=None): + self.owner._refresh_request(self.owner.status.snapshot()) + self.assertEqual(request['state'], 'failed') + self.assertEqual(request['error'], 'Selected version was not prepared; refresh and try again') + def test_version_installed_snapshot_cannot_change_at_final_dispatch(self): selected = 'c' * 40 recent = {'branch': 'main', 'head': selected, @@ -308,6 +358,25 @@ class SoftwareOperationsTest(unittest.TestCase): self.denied("install", 409, "release") self.assertEqual(self.process.sent, []) + def test_metadata_check_is_fast_and_completes_during_background_finalization(self): + self.params.put('UpdaterState', 'finalizing update...', block=True) + fast = {'version': 1, 'stage': 'finalizing', 'detail': 'Copying files: 44%', 'percent': 44, 'background': True} + self.params.put('UpdaterFastState', fast, block=True) + with mock.patch.object(self.owner, '_history_snapshot') as history: + view = self.owner.snapshot(include_history=False) + self.assertTrue(view['canCheck']) + self.assertFalse(view['canFastUpdate']) + result = self.act('check') + history.assert_not_called() + self.assertEqual(result['request']['state'], 'pending') + fast['check'] = {'state': 'complete', 'branch': 'main', 'detail': 'Metadata ready', 'completedAt': self.owner._started + 1} + self.params.put('UpdaterFastState', fast, block=True) + result = self.owner.snapshot(include_history=False) + self.assertEqual(result['request']['state'], 'complete') + self.assertEqual(result['progress']['percent'], 44) + self.assertTrue(result['progress']['background']) + self.assertEqual(self.params.get('UpdaterState'), 'finalizing update...') + def test_target_change_or_failure_is_reported(self): self.act("select", "release") self.act("download", "release") @@ -318,6 +387,39 @@ class SoftwareOperationsTest(unittest.TestCase): self.params.put("UpdateFailedCount", 1, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") + def test_selected_install_rejects_a_different_downloaded_commit(self): + self.act('select', 'release') + self.params.put_bool('UpdateAvailable', True, block=True) + self.params.put('UpdaterNewDescription', 'test / release / bbbbbbb / Sep 27', block=True) + (self.finalized / '.overlay_consistent').touch() + payload = {'action': 'install', 'branch': 'release', 'expectedCommit': 'a' * 40, 'selectedCommit': 'c' * 40} + with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}): + with self.assertRaisesRegex(SoftwareOperationError, 'Selected version changed'): + self.owner.action('install', payload, authorized=lambda: True) + self.assertFalse(self.params.get_bool('DoReboot')) + payload['selectedCommit'] = 'b' * 40 + result = self.owner.action('install', payload, authorized=lambda: True) + self.assertEqual(result['request']['selectedCommit'], 'b' * 40) + self.assertTrue(self.params.get_bool('DoReboot')) + + def test_selected_install_rechecks_commit_after_authorization(self): + self.act('select', 'release') + self.params.put_bool('UpdateAvailable', True, block=True) + self.params.put('UpdaterNewDescription', 'test / release / bbbbbbb / Sep 27', block=True) + (self.finalized / '.overlay_consistent').touch() + payload = {'action': 'install', 'branch': 'release', 'expectedCommit': 'a' * 40, 'selectedCommit': 'b' * 40} + calls = 0 + def authorized(): + nonlocal calls + calls += 1 + if calls == 3: + self.git[self.finalized] = ('release', 'c' * 40) + return True + with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}): + with self.assertRaisesRegex(SoftwareOperationError, 'Selected version changed'): + self.owner.action('install', payload, authorized=authorized) + self.assertFalse(self.params.get_bool('DoReboot')) + def test_counter_reset_does_not_claim_a_completed_check(self): self.params.put("UpdateFailedCount", 2, block=True) self.act("check") @@ -417,11 +519,56 @@ class SoftwareOperationsTest(unittest.TestCase): history.assert_called_once() self.assertEqual(light, {key: value for key, value in full.items() if key != 'history'}) + def test_install_preferences_default_off_and_validate_cron_and_revision(self): + initial = self.owner.snapshot(include_history=False) + self.assertFalse(initial['automaticInstalls']) + self.assertEqual(initial['restartSchedule'], '0 0 * * *') + body = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '0 0 * * *', + 'expectedRevision': initial['installPreferenceRevision'], 'restartTimezone': 'America/Los_Angeles'} + result = self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertTrue(result['automaticInstalls']) + self.assertEqual(result['restartTimezone'], 'America/Los_Angeles') + self.assertFalse(self.params.get_bool('DoReboot')) + self.assertEqual(self.process.sent, []) + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 409) + body['expectedRevision'] = result['installPreferenceRevision'] + body['restartTimezone'] = 'Invalid/Timezone' + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 400) + body['restartTimezone'] = 'America/Los_Angeles' + body['restartSchedule'] = '0 24 * * *' + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 400) + saved = self.owner.install_settings.read() + assert saved is not None + self.assertEqual(saved['cron'], '0 0 * * *') + + def test_install_preferences_recheck_offroad_before_saving(self): + body = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '0 0 * * *', 'expectedRevision': '0' * 32} + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: False) + self.assertEqual(caught.exception.status, 403) + checks = iter((True, False)) + with mock.patch.object(self.owner, '_parked', side_effect=lambda: next(checks)), self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 403) + self.assertFalse(self.owner.install_settings.path.exists()) + self.assertFalse(self.params.get_bool('DoReboot')) + + def test_history_includes_previous_installed_version(self): + previous = {'hash': 'd' * 40, 'date': '2026-09-28T12:00:00+00:00', 'subject': 'Previous installed build'} + with mock.patch('openpilot.starpilot.galaxy.software_operations.previous_version', return_value=previous): + self.assertEqual(self.owner.snapshot()['history']['previous'], previous) + def test_history_caches_local_reads_and_refreshes_when_download_identity_changes(self): - reader = mock.Mock(side_effect=lambda path, sha: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}]) + reader = mock.Mock(side_effect=lambda path, sha, **kwargs: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}]) self.owner.history_reader = reader - self.params.put('UpdaterCurrentReleaseNotes', b'
Current
', block=True) + self.params.put('UpdaterNewReleaseNotes', b'Next
', block=True) with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value={'branch': 'main', 'head': 'c' * 40, 'entries': []}) as recent: first = self.owner.snapshot()['history'] @@ -432,15 +579,18 @@ class SoftwareOperationsTest(unittest.TestCase): self.assertEqual(reader.call_count, 2) self.assertEqual(self.owner.snapshot()['history'], first) self.assertEqual(reader.call_count, 2) - self.assertEqual(recent.call_count, 1) + self.assertEqual(recent.call_count, 3) self.params.put('UpdaterNewDescription', 'new description', block=True) self.owner.snapshot() self.assertEqual(reader.call_count, 4) - self.assertEqual(recent.call_count, 2) + self.assertEqual(recent.call_count, 6) self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 4, 12), block=True) self.owner.snapshot() - self.assertEqual(recent.call_count, 3) + self.assertEqual(recent.call_count, 9) self.assertEqual(reader.call_count, 6) + self.params.put('UpdaterLastCheckTime', datetime(2026, 10, 4, 13), block=True) + self.owner.snapshot() + self.assertEqual(recent.call_count, 12) def test_unified_update_pins_source_and_tracks_completion(self): result = self.act("update", "release") @@ -455,6 +605,26 @@ class SoftwareOperationsTest(unittest.TestCase): self.assertEqual(result["state"], "complete") self.assertEqual(result["outcome"], "restarting") + def test_version_list_uses_checked_history_before_download_finishes(self): + old = {'branch': 'main', 'head': 'b' * 40, 'entries': []} + latest = {'branch': 'main', 'head': 'c' * 40, + 'entries': [{'hash': 'c' * 40, 'date': '2026-10-29T12:00:00+00:00', 'subject': 'Not downloaded yet'}]} + with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', + side_effect=lambda path, branch: latest if path == self.metadata else old): + result = self.owner.snapshot()['history'] + self.assertEqual(result['recent'], latest) + self.assertEqual(result['downloaded'], []) + + def test_older_installed_history_is_selectable_without_being_in_recent_list(self): + older = {'hash': 'b' * 40, 'date': '2026-10-01T12:00:00+00:00', 'subject': 'Older installed history'} + self.owner.history_reader = lambda path, sha, **kwargs: [older] if path == self.installed else [] + recent = {'branch': 'main', 'head': 'c' * 40, 'entries': []} + with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent): + result = self.owner.action('version', {'action': 'version', 'branch': 'main', + 'expectedCommit': 'a' * 40, 'selectedCommit': older['hash']}, authorized=lambda: True) + self.assertEqual(result['request']['selectedCommit'], older['hash']) + self.assertEqual(self.process.sent, ['version']) + def test_fast_current_branch_without_branch_list_or_selected_target(self): self.params.remove("UpdaterAvailableBranches") self.params.put("UpdaterTargetBranch", "release", block=True) diff --git a/openpilot/starpilot/galaxy/tests/test_software_status.mjs b/openpilot/starpilot/galaxy/tests/test_software_status.mjs index 093874177b..f8d43bc166 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_status.mjs +++ b/openpilot/starpilot/galaxy/tests/test_software_status.mjs @@ -313,76 +313,34 @@ await revoked.reply(0, { error: "Sign in" }, 401) assert.equal(revoked.unauthorized, 1) assert.equal(revoked.states.at(-1).status, "idle") -const page = (selectedTarget = "Dom", availableBranches = ["StarPilot", "Dom", "beta"], installedBranch = "Dom") => { - const state = { operations: { ...operations, selectedTarget, availableBranches }, data: { - installed: { ...installed, branch: installedBranch } }, actionDisabled: false, - draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null } - Object.defineProperty(state,"branchOptions",{get:()=>SoftwarePage.computed.branchOptions.call(state)}) - Object.defineProperty(state,"otherBranches",{get:()=>SoftwarePage.computed.otherBranches.call(state)}) - Object.defineProperty(state,"primaryBranchHelp",{get:()=>SoftwarePage.computed.primaryBranchHelp.call(state)}) - Object.defineProperty(state,"canStageBranch",{get:()=>SoftwarePage.computed.canStageBranch.call(state)}) +const page = (selectedTarget = 'Dom', availableBranches = ['StarPilot', 'Dom', 'beta'], installedBranch = 'Dom') => { + const state = { operations: { ...operations, selectedTarget, availableBranches }, + data: { installed: { ...installed, branch: installedBranch } }, actionDisabled: false, + draftBranch: '', draftTouched: false, dialog: null, version: '', calls: [], + async loadVersions() { this.calls.push(this.draftBranch) } } + for (const key of ['branchOptions', 'previousVersion', 'otherBranches']) + Object.defineProperty(state, key, {get:()=>SoftwarePage.computed[key].call(state)}) return state } const staged = page() -SoftwarePage.methods.syncDraftBranch.call(staged,"Dom") -assert.equal(staged.draftBranch,"Dom") -staged.draftBranch="beta" -SoftwarePage.methods.changeBranch.call(staged) -assert.equal(staged.draftTouched,true) -assert.equal(staged.canStageBranch,true) -SoftwarePage.methods.chooseBranch.call(staged) -assert.equal(staged.dialog.action,"select") // Normal updates can still stage a target without restarting. -assert.equal(staged.dialog.branch,"beta") -SoftwarePage.methods.askUpdate.call({...staged, operations:{...staged.operations,canFastUpdate:true}}) -const switchPage={...staged,operations:{...staged.operations,canFastUpdate:true}} +SoftwarePage.methods.syncDraftBranch.call(staged, 'Dom') +staged.draftBranch = 'beta' +await SoftwarePage.methods.changeBranch.call(staged) +assert.equal(staged.draftTouched, true) +assert.deepEqual(staged.calls, ['beta'], 'branch changes automatically refresh metadata') +const switchPage = {...staged, operations: {...staged.operations, canFastUpdate:true}} SoftwarePage.methods.askUpdate.call(switchPage) -assert.equal(switchPage.dialog.action,"update") -assert.equal(switchPage.dialog.branch,"beta") -assert.equal(switchPage.dialog.label,"Update & restart") -const alternateTarget=page("legacy",["StarPilot","Dom","beta"],"installed-only") -SoftwarePage.methods.syncDraftBranch.call(alternateTarget,"legacy") -assert.equal(alternateTarget.canStageBranch,false) -assert.equal(alternateTarget.draftBranch,"installed-only") -assert.deepEqual(alternateTarget.branchOptions.map(branch=>branch.name),["legacy","installed-only","StarPilot","Dom","beta"]) -assert.equal(alternateTarget.branchOptions[0].available,false) -assert.equal(alternateTarget.branchOptions[1].available,true) -const sentinel = fixture() -sentinel.feed.start() -await sentinel.reply(0, snapshot({ operations: { ...operations, availableBranches: ["Dom", "other:"] } })) -sentinel.feed.action("select", "other:") -assert.equal(sentinel.requests.length, 1) -assert.deepEqual(page("Dom", ["Dom", "other:"]).branchOptions.map(branch=>branch.name), ["Dom"]) -sentinel.feed.stop() - -assert.equal(SoftwarePage.methods.branchLabel("StarPilot"), "Stable — StarPilot") -assert.equal(SoftwarePage.methods.branchLabel("Dom"), "Development — Dom") - -const namedBranches = page("StarPilot", ["StarPilot", "Dom", "beta"], "StarPilot") -SoftwarePage.methods.syncDraftBranch.call(namedBranches, "StarPilot") -assert.equal(namedBranches.primaryChoice, "StarPilot") -assert.equal(namedBranches.draftBranch, "StarPilot") -const otherBranches = page("beta", ["StarPilot", "Dom", "beta"], "Dom") -SoftwarePage.methods.syncDraftBranch.call(otherBranches, "beta") -assert.equal(otherBranches.primaryChoice, "other:") -assert.equal(otherBranches.draftBranch, "beta") -assert.deepEqual(otherBranches.otherBranches.map(branch => branch.name), ["beta"]) -otherBranches.primaryChoice = "Dom" -assert.equal(otherBranches.primaryBranchHelp, "Latest features and fixes under development. Updates regularly and may introduce bugs.") -otherBranches.draftBranch = "beta" -otherBranches.primaryChoice = "other:" -SoftwarePage.methods.onPrimaryBranchChange.call(otherBranches) -assert.equal(otherBranches.draftBranch, "beta") -assert.match(SoftwarePage.template, /Stable — StarPilot/) -assert.match(SoftwarePage.template, /Development — Dom/) -assert.match(SoftwarePage.template, /Other branches…/) -assert.match(SoftwarePage.template, /onPrimaryBranchChange/) -assert.match(SoftwarePage.template, /Check for updates/) +assert.equal(switchPage.dialog.action, 'update') +assert.equal(switchPage.dialog.branch, 'beta') +assert.equal(SoftwarePage.methods.branchLabel('StarPilot'), 'Stable — StarPilot') +assert.equal(SoftwarePage.methods.branchLabel('Dom'), 'Development — Dom') +assert.deepEqual(page('Dom', ['Dom', 'other:']).branchOptions.map(branch=>branch.name), ['Dom']) +assert.match(SoftwarePage.template, /aria-label="Version branch"/) +assert.match(SoftwarePage.template, /@change="changeBranch"/) assert.match(SoftwarePage.template, /Download only/) -assert.match(SoftwarePage.template, /@click="askUpdate">Update<\/button>/) +assert.match(SoftwarePage.template, /Select previous version/) +assert.doesNotMatch(SoftwarePage.template, /Save target/) assert.match(SoftwarePage.template, /displayVersion \?\? data\.installed\.version/) -assert.match(SoftwarePage.template, /@click="askRollback">Previous version/ ) -assert.match(SoftwarePage.template, /operations\.canRollback !== true/) -assert.doesNotMatch(SoftwarePage.template, /historical version|status only/i) const preferences = fixture() preferences.feed.start() @@ -492,7 +450,7 @@ assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canRo assert.equal(softwareProgress(updater), null) assert.equal(softwareProgress(updater, {progress: {stage: null, detail: "", percent: null}}), null) assert.deepEqual(softwareProgress({...updater, state: "finalizing update..."}), - {active: true, failed: false, percent: null, label: "Finalizing update", detail: ""}) + {active: true, failed: false, percent: null, label: "Preparing update", detail: ""}) assert.equal(softwareProgress({...updater, state: "downloading..."}, {progress: {stage: "Downloading", detail: "", percent: 37}}).percent, 37) for (const percent of [NaN, Infinity, -1, 101]) { assert.equal(validSoftwareSnapshot(snapshot({operations: {...operations, progress: {stage: "fetching", detail: "", percent}}})), false) @@ -501,7 +459,7 @@ assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {prog assert.ok(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: null}}))) assert.equal(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: 12}})), false) assert.match(SoftwarePage.template, /{toggleSettings=args}}},true)
+assert.deepEqual(toggleSettings,[true,'0 0 * * *','UTC'],'toggle preserves the saved timezone')
diff --git a/openpilot/starpilot/galaxy/tests/test_version_timeline.mjs b/openpilot/starpilot/galaxy/tests/test_version_timeline.mjs
new file mode 100644
index 0000000000..14ac7e6fd6
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_version_timeline.mjs
@@ -0,0 +1,173 @@
+import assert from 'node:assert/strict'
+import { compile } from '../web/vendor/vue/vue.esm-browser.js'
+import { SoftwarePage } from '../web/js/software-status.js'
+
+const rows = Array.from({ length: 20 }, (_, i) => ({ hash: (i + 1).toString(16).padStart(40, '0'),
+ date: new Date(Date.UTC(2026, 9, 29 - i)).toISOString(), subject: `Version ${20 - i}` }))
+const vm = { ...SoftwarePage.data(), initialChecking: false, mode: 'local',
+ draftBranch: 'Dom', data: { installed: { branch: 'Dom', commit: rows[15].hash },
+ updater: { state: 'idle', targetBranch: 'Dom', finalizedUpdateReady: true }, operations: {
+ parked: true, canFastUpdate: true, canInstall: true, selectedTarget: 'Dom', availableBranches: ['Dom'],
+ history: { installed: rows.slice(15), downloaded: rows.slice(3), currentReleaseNotes: 'Installed notes',
+ downloadedReleaseNotes: 'Downloaded notes', downloadedCommits: [rows[3].hash, rows[4].hash], recent: { branch: 'Dom', head: rows[0].hash, entries: rows } } } } }
+for (const [name, getter] of Object.entries(SoftwarePage.computed))
+ Object.defineProperty(vm, name, { get: () => getter.call(vm) })
+for (const [name, method] of Object.entries(SoftwarePage.methods)) vm[name] = method.bind(vm)
+
+assert.equal(vm.shortCommit('1234567890abcdef'), '12345678')
+assert.equal(vm.versionEntries.length, 20, 'merge overlapping sources without duplicates')
+assert.equal(vm.visibleVersions.length, 4)
+assert.equal(vm.visibleVersions[0].hash, rows[0].hash, 'keep newest at the top')
+assert.equal(vm.visibleVersions.at(-2).hash, rows[15].hash, 'keep installed visible')
+assert.equal(vm.visibleVersions.at(-1).hash, rows[16].hash, 'show one older version')
+assert.equal(vm.hiddenNewerCount, 13)
+assert.equal(vm.hiddenOlderCount, 3)
+assert.equal(vm.selectedVersion.hash, rows[15].hash)
+assert.equal(vm.selectedVersionAction, 'No changes selected')
+assert.equal(vm.canInstallSelection, false)
+assert.equal(vm.selectedReleaseNotes, 'Installed notes')
+
+vm.version = rows[3].hash
+assert.equal(vm.selectedVersionAction, 'Install selected version')
+assert.equal(vm.selectedReleaseNotes, 'Downloaded notes')
+assert.equal(vm.canInstallSelection, true)
+vm.askSelectedVersion()
+assert.deepEqual([vm.dialog.action, vm.dialog.selectedCommit, vm.dialog.expectedCommit],
+ ['install', rows[3].hash, rows[15].hash])
+
+vm.version = rows[0].hash
+assert.equal(vm.selectedVersionAction, 'Download and install selected version')
+assert.equal(vm.canInstallSelection, true)
+assert.equal(vm.selectedReleaseNotes, null)
+vm.askSelectedVersion()
+assert.equal(vm.dialog.action, 'version')
+assert.equal(vm.dialog.selectedCommit, rows[0].hash)
+
+vm.data.updater.finalizedUpdateReady = false
+vm.version = rows[3].hash
+assert.equal(vm.selectedVersionAction, 'Install selected version', 'complete cached files remain downloaded when staging is not ready')
+vm.version = rows[4].hash
+assert.equal(vm.selectedVersionAction, 'Install selected version', 'cached commits need not be the prepared head')
+vm.askSelectedVersion()
+assert.equal(vm.dialog.action, 'version', 'cached versions still pass through pinned validation')
+vm.newerVersionLimit += 10
+assert.equal(vm.hiddenNewerCount, 3)
+assert.equal(vm.visibleVersions.length, 14, 'newer paging must not reveal older rows')
+assert.equal(vm.hiddenOlderCount, 3)
+vm.newerVersionLimit += 10
+vm.olderVersionLimit += 10
+assert.equal(vm.visibleVersions.length, 20)
+assert.equal(vm.hiddenOlderCount, 0)
+vm.newerVersionLimit = 2
+vm.olderVersionLimit = 1
+assert.equal(vm.visibleVersions.length, 4)
+
+// Separate gaps remain exact across multiple pages of ten.
+const longRows = Array.from({length: 60}, (_, i) => ({hash:(i + 100).toString(16).padStart(40, '0'),date:'',subject:`Build ${i}`}))
+vm.data.operations.history.recent.entries = longRows
+vm.data.operations.history.installed = longRows.slice(35)
+vm.data.installed.commit = longRows[35].hash
+assert.equal(vm.hiddenNewerCount, 33)
+assert.equal(vm.hiddenOlderCount, 23)
+for (const remaining of [23, 13, 3, 0]) {
+ const before = vm.visibleVersions.length
+ const count = Math.min(10, vm.hiddenNewerCount)
+ vm.newerVersionLimit += 10
+ assert.equal(vm.visibleVersions.length - before, count)
+ assert.equal(vm.hiddenNewerCount, remaining)
+ assert.equal(vm.hiddenOlderCount, 23)
+}
+for (const remaining of [13, 3, 0]) {
+ const before = vm.visibleVersions.length
+ const count = Math.min(10, vm.hiddenOlderCount)
+ vm.olderVersionLimit += 10
+ assert.equal(vm.visibleVersions.length - before, count)
+ assert.equal(vm.hiddenOlderCount, remaining)
+}
+vm.data.operations.history.recent.entries = rows
+vm.data.operations.history.installed = rows.slice(15)
+vm.data.installed.commit = rows[15].hash
+vm.data.operations.history.recent.entries = rows.slice(0, 10)
+vm.version = rows[19].hash
+assert.equal(vm.canInstallSelection, true, 'older installed history remains selectable outside the recent list')
+vm.data.operations.history.recent.entries = rows
+vm.newerVersionLimit = 2
+vm.olderVersionLimit = 1
+vm.data.installed.commit = 'f'.repeat(40)
+vm.version = ''
+assert.equal(vm.visibleVersions.at(-1).hash, 'f'.repeat(40), 'retain installed outside fetched history on its own branch')
+assert.equal(vm.selectedVersionAction, 'No changes selected')
+vm.busy = true
+vm.version = rows[0].hash
+assert.equal(vm.canInstallSelection, false)
+vm.busy = false
+vm.data.operations.parked = false
+assert.equal(vm.canInstallSelection, false)
+
+compile(SoftwarePage.template, { decodeEntities: value => value })
+console.log('Version timeline selection, limits, expansion, and template passed')
+
+// Pin the actual previous installed commit with independent gaps above and below it.
+vm.data.operations.parked = true
+vm.data.operations.canRollback = true
+vm.data.installed.commit = longRows[15].hash
+vm.data.operations.history.recent.entries = longRows
+vm.data.operations.history.installed = longRows.slice(15)
+vm.data.operations.history.previous = longRows[40]
+vm.newerVersionLimit = 2
+vm.olderVersionLimit = 1
+vm.collapsedOlderLimit = 0
+vm.version = ''
+assert.deepEqual(vm.visibleVersions.map(row => row.hash), [...longRows.slice(0, 2), longRows[15], longRows[40]].map(row => row.hash))
+assert.equal(vm.hiddenNewerCount, 13)
+assert.equal(vm.hiddenCollapsedOlderCount, 24)
+assert.equal(vm.hiddenOlderCount, 19)
+for (const remaining of [14, 4, 0]) {
+ const before = vm.visibleVersions.length
+ const count = Math.min(10, vm.hiddenCollapsedOlderCount)
+ vm.collapsedOlderLimit += count
+ assert.equal(vm.visibleVersions.length - before, count)
+ assert.equal(vm.hiddenCollapsedOlderCount, remaining)
+ assert.equal(vm.hiddenOlderCount, 19)
+}
+vm.olderVersionLimit += 10
+assert.equal(vm.hiddenOlderCount, 9)
+assert.equal(vm.visibleVersions.at(-1).hash, longRows[50].hash)
+vm.version = longRows[40].hash
+assert.equal(vm.selectedVersionAction, 'Install selected version')
+assert.equal(vm.canInstallSelection, true)
+vm.askSelectedVersion()
+assert.equal(vm.dialog.action, 'rollback')
+vm.data.operations.history.previous = longRows[0]
+assert.equal(vm.visibleVersions.filter(row => row.hash === longRows[0].hash).length, 1, 'previous version may be newer after a rollback')
+
+// Diverged branches contain neither installed history nor a previous-version anchor.
+vm.data.operations.history.previous = { ...longRows[40], branch:'Dom' }
+vm.draftBranch = 'StarPilot'
+vm.data.operations.history.recent = {branch:'StarPilot',head:rows[0].hash,entries:rows.slice(0,10)}
+vm.newerVersionLimit = 2
+vm.version = ''
+assert.equal(vm.hasInstalledVersion, false)
+assert.equal(vm.listedPreviousVersion, null)
+assert.deepEqual(vm.visibleVersions.map(row => row.hash), rows.slice(0,2).map(row => row.hash))
+assert.equal(vm.hiddenNewerCount,8)
+vm.newerVersionLimit += 10
+assert.equal(vm.visibleVersions.length,10,'diverged history remains pageable without installed anchors')
+vm.syncDraftBranch(vm.previousVersion.branch)
+vm.version = vm.previousVersion.hash
+assert.equal(vm.listedPreviousVersion.hash,vm.previousVersion.hash,'explicit local previous selection needs no metadata refresh')
+
+// Glow only while a different selectable commit is waiting to be installed.
+assert.equal(vm.selectionNeedsInstall, true)
+vm.data.updater.state = 'updating...'
+assert.equal(vm.selectionNeedsInstall, false)
+vm.data.updater.state = 'idle'
+vm.feed = {installBaseline:{}}
+assert.equal(vm.selectionNeedsInstall, false)
+vm.feed.installBaseline = null
+assert.equal(vm.selectionNeedsInstall, true)
+vm.busy = true
+assert.equal(vm.selectionNeedsInstall, false)
+vm.busy = false
+vm.version = vm.data.installed.commit
+assert.equal(vm.selectionNeedsInstall, false)
diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css
index e11a4fd8c0..6eb1491c66 100644
--- a/openpilot/starpilot/galaxy/web/css/galaxy.css
+++ b/openpilot/starpilot/galaxy/web/css/galaxy.css
@@ -1228,16 +1228,37 @@ button.gx-chip:hover {
.gx-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.gx-release-notes{white-space:pre-wrap;font:inherit;line-height:1.6;overflow-wrap:anywhere}
-.gx-build-history { display: grid; gap: var(--sp-3); max-height: 320px; overflow-y: auto; }
-.gx-build-history li { min-width: 0; padding: var(--sp-3); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); overflow-wrap: anywhere; }
-.gx-build-history strong { display: block; color: var(--on-surface); }
-.gx-build-history small { display: block; margin-top: var(--sp-1); color: var(--on-surface-variant); }
.gx-software-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: var(--sp-3); margin: 0; }
.gx-software-summary dt { font-size: var(--fs-sm); color: var(--on-surface-variant); }
.gx-software-summary dd { margin: var(--sp-1) 0 0; font-weight: var(--fw-bold); overflow-wrap: anywhere; }
.gx-software-options { border-top: 1px solid var(--glass-border); }
.gx-software-options > .gx-stack { padding-top: var(--sp-4); }
.gx-software-card details{padding:12px 0}.gx-software-card summary{cursor:pointer;font-weight:600}
+.gx-version-action--pending { animation: gx-version-heartbeat 1.8s ease-in-out infinite; }
+@keyframes gx-version-heartbeat {
+ 0%, 45%, 100% { box-shadow: 0 0 0 0 transparent; }
+ 12%, 32% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 0 20px color-mix(in srgb, var(--primary) 55%, transparent); }
+ 22% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 15%, transparent), 0 0 10px color-mix(in srgb, var(--primary) 30%, transparent); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .gx-version-action--pending { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 35%, transparent); }
+}
+.gx-restart-cron { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(180px, 2fr); gap: var(--sp-3); }
+@media (max-width: 800px) { .gx-restart-cron { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.gx-version-scroll { max-height: min(760px, 65dvh); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 3px; }
+.gx-version-list { display: grid; gap: var(--sp-2); list-style: none; margin: 0; padding: 0; }
+.gx-version-row { display: flex; align-items: center; gap: var(--sp-3); min-height: 64px; padding: var(--sp-3); border: 2px solid var(--outline-variant); border-radius: var(--radius-sm); cursor: pointer; }
+.gx-version-row:hover { background: color-mix(in srgb, var(--primary) 6%, transparent); }
+.gx-version-row--installed { background: color-mix(in srgb, var(--primary) 9%, transparent); }
+.gx-version-row--selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
+.gx-version-row:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
+.gx-version-row input { flex: 0 0 auto; width: 20px; height: 20px; accent-color: var(--primary); }
+.gx-version-content { min-width: 0; display: grid; gap: var(--sp-1); overflow-wrap: anywhere; }
+.gx-version-content small { color: var(--on-surface-variant); }
+.gx-version-meta .gx-version-badge { display: inline-block; margin-inline-start: var(--sp-2); white-space: nowrap; }
+.gx-version-badge { padding: 2px 8px; border-radius: 999px; font-size: var(--fs-sm); color: var(--on-surface-variant); background: var(--surface-container-high); }
+.gx-version-badge--installed, .gx-version-badge--downloaded { color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); }
+.gx-version-gap { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-2); }
.gx-navigation-map { position:relative; border-radius:12px; overflow:hidden; background:#20303b; margin-bottom:16px; }
.gx-navigation-map canvas { display:block; width:100%; height:360px; touch-action:none; cursor:grab; }
diff --git a/openpilot/starpilot/galaxy/web/js/drive-state.js b/openpilot/starpilot/galaxy/web/js/drive-state.js
index e6e14ea86a..e0330b1e82 100644
--- a/openpilot/starpilot/galaxy/web/js/drive-state.js
+++ b/openpilot/starpilot/galaxy/web/js/drive-state.js
@@ -7,9 +7,10 @@ export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad
export const DriveStatePanel = {
components: { GxNotice },
- props: { offroadOnly: { type: Boolean, default: false } },
- data: () => ({ state: null, error: "", busy: false }),
+ props: { offroadOnly: { type: Boolean, default: false }, lockout: { type: Boolean, default: true } },
+ data: () => ({ state: null, error: "", statusError: "", busy: false }),
computed: {
+ visible() { return !this.offroadOnly || this.lockout || this.state?.mode === 'offroad' || !!this.error },
pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode },
},
mounted() {
@@ -31,13 +32,21 @@ export const DriveStatePanel = {
const response = await fetch("./api/drive-state/status", { credentials: "same-origin", cache: "no-store", signal: this.controller.signal })
const value = await response.json()
if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable")
- if (this.active && generation === this.generation) { this.state = value; this.error = "" }
- } catch (error) { if (this.active && generation === this.generation) { this.state = null; this.error = "Drive state unavailable" } }
+ if (this.active && generation === this.generation) { this.state = value; this.statusError = "" }
+ } catch (error) { if (this.active && generation === this.generation) { this.state = null; this.statusError = "Drive state unavailable" } }
finally { clearTimeout(deadline); if (generation === this.generation) this.loading = false }
},
+ async toggleOffroad(event) {
+ const input = event.target
+ const mode = input.checked ? 'offroad' : 'auto'
+ input.checked = this.state?.mode === 'offroad'
+ await this.change(mode)
+ input.checked = this.state?.mode === 'offroad'
+ },
async change(mode) {
if (this.busy || !this.state?.available) return
if (!["onroad", "offroad", "auto"].includes(mode)) return
+ this.error = ""
const feedback = beginSave()
const revision = this.state.revision
this.busy = true
@@ -57,16 +66,15 @@ export const DriveStatePanel = {
finally { clearTimeout(deadline); this.busy = false; if (this.active && generation === this.generation) this.load() }
},
},
- template: ` Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }} Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }} Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation. Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state. {{ backgroundProgress.detail }}
Checked {{ reported(data.updater.lastCheckedAt) }}
· Downloaded {{ reported(data.updater.lastFetchAt) }}
+ {{ shortCommit(operations.preparedCommit) }}{{ shortCommit(operations.preparedCommit) }} is ready for install. Click Update when ready.
+ Auto install will run at {{ reported(operations.nextRestartAt) }} while offroad.
+ The download preference could not be read. Choose a setting while parked to repair it.