fix fast updater hang; improve version selection list; schedule update install

This commit is contained in:
mtawata
2026-10-30 00:51:22 -07:00
committed by Michael Tawata
parent 0fd2353c55
commit 867ed373d6
34 changed files with 2383 additions and 354 deletions
@@ -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))
@@ -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
+7 -2
View File
@@ -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
@@ -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
@@ -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:
+16 -1
View File
@@ -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`,
+133 -29
View File
@@ -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:
+15 -2
View File
@@ -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
@@ -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,
@@ -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')
@@ -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)
@@ -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'<h1>Current</h1>', block=True)
self.params.put('UpdaterNewReleaseNotes', b'<h1>Next</h1>', block=True)
self.params.put('UpdaterCurrentReleaseNotes', b'<p>Current</p>', block=True)
self.params.put('UpdaterNewReleaseNotes', b'<p>Next</p>', 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)
@@ -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, /<summary>Advanced options<\/summary>/)
assert.match(SoftwarePage.template, /<summary>Versions &amp; release notes<\/summary>/)
assert.doesNotMatch(SoftwarePage.template, /<summary>Versions &amp; release notes<\/summary>/)
assert.match(SoftwarePage.template, /data\.updater\.lastCheckedAt/)
for (const progress of [{stage: "idle", detail: "", percent: null}, {stage: null, detail: null, percent: null}, {stage: "unknown", detail: "", percent: null}]) {
@@ -514,7 +472,7 @@ for (const stage of ["complete", "error"]) {
assert.equal(softwareProgress(state, {progress}).active, false)
assert.equal(softwareProgress(state, {progress}).percent, null)
}
assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Finalizing update")
assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Preparing update")
const lightweight = fixture(false)
lightweight.feed.start()
@@ -528,6 +486,11 @@ await lightweight.reply(1, snapshot({ operations: { ...operations, history } }))
assert.deepEqual(lightweight.states.at(-1).data.operations.history, history)
lightweight.feed.stop()
const versionRefreshActions = []
await SoftwarePage.methods.loadVersions.call({ actionDisabled: false, draftBranch: 'Dom',
operations: { selectedTarget: 'Dom' }, feed: { action: (...args) => versionRefreshActions.push(args) } })
assert.deepEqual(versionRefreshActions, [['versions', 'Dom']], 'version refresh must use the metadata-only operation')
const directBranch = fixture()
directBranch.feed.start()
await directBranch.reply(0,snapshot({operations:{...operations,availableBranches:['Dom','beta'],canFastUpdate:true}}))
@@ -544,7 +507,7 @@ directBranch.feed.stop()
// A visit checks immediately, without interrupting an update already in progress.
for (const [canCheck, pending, state, expected] of [
[true, false, 'idle', ['check']], [false, false, 'idle', []],
[true, true, 'idle', []], [true, false, 'downloading...', []],
[true, true, 'idle', []], [true, false, 'downloading...', ['check']],
]) {
const actions = []
const view = { mode: 'local', initialChecking: true, operations: { canCheck }, pending,
@@ -556,7 +519,7 @@ for (const [canCheck, pending, state, expected] of [
const updateView = { ...SoftwarePage.data(), data: snapshot(), operations: { ...operations },
requestMessage: SoftwarePage.methods.requestMessage }
for (const key of ['pending', 'reportedProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) {
for (const key of ['pending', 'reportedProgress', 'metadataProgress', 'backgroundProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) {
Object.defineProperty(updateView, key, { get: () => SoftwarePage.computed[key].call(updateView) })
}
assert.equal(updateView.updateProgress.label, 'Checking for updates')
@@ -576,7 +539,7 @@ updateView.data = snapshot({ updater: { ...updater, targetChangeFound: false } }
updateView.operations.request = request('check', 'complete')
assert.equal(updateView.updateBanner.message, 'No Update Available')
updateView.operations.request = request('check')
assert.equal(updateView.updateProgress.label, 'Checking for updates')
assert.equal(updateView.updateProgress.label, 'Checking for new versions')
assert.equal(updateView.updateBanner, null)
updateView.operations.request = request('check', 'failed', null, 'Network unavailable')
assert.equal(updateView.updateProgress, null)
@@ -652,7 +615,7 @@ for (const action of ['update', 'fast', 'rollback', 'install', 'version']) {
const f = fixture(true, baseline => restarts.push(baseline))
f.feed.start()
await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true, canRollback: true, canInstall: true,
history: { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null,
history: { installed: [], downloaded: [{ hash: 'b'.repeat(40), date: '', subject: '' }], currentReleaseNotes: null, downloadedReleaseNotes: null,
recent: { branch: 'Dom', head: installed.commit, entries: [{ hash: 'b'.repeat(40), date: '', subject: '' }] } } } }))
f.feed.action(action, 'Dom', { expectedCommit: installed.commit, selectedCommit: 'b'.repeat(40) })
assert.equal(f.requests.length, 2)
@@ -697,3 +660,146 @@ for (const [status, payload, unauthorized] of [
f.feed.stop()
}
console.log('Software status and tunnel-envelope regressions passed')
// Metadata checks remain separate from a background finalization and its real progress.
updateView.error = ''
updateView.operations = { ...operations, reason: 'Updater is busy', request: request('versions'),
progress: { stage: 'finalizing', detail: 'Copying update files: 44/100 (44%)', percent: 44, background: true } }
updateView.data = snapshot({ updater: { ...updater, state: 'finalizing update...',
fast: { stage: 'finalizing', check: { state: 'checking', detail: 'Fetching version metadata...' } } } })
assert.equal(updateView.updateProgress.label, 'Checking for new versions')
assert.equal(updateView.backgroundProgress.percent, 44)
assert.match(updateView.backgroundProgress.detail, /44\/100/)
assert.equal(SoftwarePage.computed.metadataActionDisabled.call(updateView), false)
updateView.data.updater.fast.check = { state: 'complete' }
updateView.operations.request = request('versions', 'complete')
assert.equal(updateView.updateProgress, null)
assert.equal(updateView.backgroundProgress.percent, 44)
// Clicking Check immediately replaces the previous Refresh versions result, before its POST returns.
const nextCheck = fixture()
nextCheck.feed.start()
await nextCheck.reply(0, snapshot({ operations: { ...operations, request: request('versions', 'complete') } }))
nextCheck.feed.action('check')
assert.equal(nextCheck.states.at(-1).submittingAction, 'check')
updateView.operations = { ...operations, request: request('versions', 'complete') }
updateView.data = snapshot()
updateView.busy = true
updateView.submittingAction = nextCheck.states.at(-1).submittingAction
assert.equal(updateView.updateProgress.label, 'Checking for new versions')
assert.equal(updateView.updateBanner, null)
await nextCheck.reply(1, snapshot({ operations: { ...operations, request: request('check') } }))
assert.equal(nextCheck.states.at(-1).submittingAction, null)
nextCheck.feed.stop()
updateView.busy = false
updateView.submittingAction = null
updateView.operations = { ...operations, request: null }
updateView.draftBranch = 'Dom'
updateView.data = snapshot({ updater: { ...updater, finalizedUpdateReady: true } })
assert.deepEqual(updateView.updateBanner, { tone: 'success', message: 'Downloaded · Ready to install' })
// Downloading a selected version pins it without entering restart recovery.
const selectedDownload = fixture()
const selectedHash = 'b'.repeat(40)
const selectionHistory = { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null,
recent: { branch: 'beta', head: selectedHash, entries: [{hash:selectedHash,date:'',subject:'Beta build'}] } }
selectedDownload.feed.start()
await selectedDownload.reply(0, snapshot({operations:{...operations, canFastUpdate:true, history:selectionHistory}}))
selectedDownload.feed.action('download-version', 'beta', {expectedCommit:installed.commit,selectedCommit:selectedHash})
assert.deepEqual(JSON.parse(selectedDownload.requests[1].options.body), {action:'download-version',branch:'beta',expectedCommit:installed.commit,selectedCommit:selectedHash})
assert.equal(selectedDownload.feed.installBaseline, null)
await selectedDownload.reply(1, snapshot({operations:{...operations,request:request('download-version','pending','beta')}}))
assert.equal(selectedDownload.feed.installBaseline, null)
selectedDownload.feed.stop()
const previousSelection = page('Dom',['Dom','beta'])
previousSelection.operations.history = {previous:{hash:selectedHash,date:'',subject:'Previous',branch:'beta'}}
previousSelection.syncDraftBranch = SoftwarePage.methods.syncDraftBranch.bind(previousSelection)
await SoftwarePage.methods.selectPreviousVersion.call(previousSelection)
assert.equal(previousSelection.draftBranch, 'beta')
assert.equal(previousSelection.version, selectedHash)
assert.deepEqual(previousSelection.calls, [], 'selecting a saved previous version must not refresh metadata')
assert.equal(previousSelection.dialog,null,'selecting previous must not install it')
// A finished metadata refresh must leave the actual update status visible.
updateView.error = ''
updateView.busy = false
updateView.submittingAction = null
updateView.initialChecking = false
updateView.draftBranch = 'Dom'
updateView.operations = { ...operations, request: request('versions', 'complete') }
updateView.data = snapshot({ updater: { ...updater, finalizedUpdateReady: true } })
assert.equal(updateView.updateBanner.message, 'Downloaded · Ready to install')
updateView.data.updater.finalizedUpdateReady = false
assert.equal(updateView.updateBanner.message, 'Update Available')
updateView.data.updater.targetChangeFound = false
assert.equal(updateView.updateBanner.message, 'No Update Available')
assert.equal(SoftwarePage.methods.requestMessage(request('versions', 'complete')), '')
const branchLayout = page()
branchLayout.changeBranch = SoftwarePage.methods.changeBranch.bind(branchLayout)
SoftwarePage.methods.syncDraftBranch.call(branchLayout, 'Dom')
assert.equal(branchLayout.primaryChoice, 'Dom')
branchLayout.primaryChoice = 'other:'
await SoftwarePage.methods.onPrimaryBranchChange.call(branchLayout)
assert.equal(branchLayout.draftBranch, '')
assert.deepEqual(branchLayout.calls, [], 'opening Other does not refresh metadata without a branch')
assert.deepEqual(branchLayout.otherBranches.map(branch => branch.name), ['beta'])
branchLayout.draftBranch = 'beta'
await branchLayout.changeBranch()
assert.deepEqual(branchLayout.calls, ['beta'])
branchLayout.primaryChoice = 'StarPilot'
await SoftwarePage.methods.onPrimaryBranchChange.call(branchLayout)
assert.equal(branchLayout.draftBranch, 'StarPilot')
assert.deepEqual(branchLayout.calls, ['beta', 'StarPilot'])
assert.equal(previousSelection.primaryChoice, 'other:', 'previous selection synchronizes both selectors without refreshing')
assert.doesNotMatch(SoftwarePage.template, /Refresh versions/)
assert.match(SoftwarePage.template, /@click="loadVersions">Refresh/ )
assert.match(SoftwarePage.template, /feed.action\('check'\)">Check for updates/)
assert.match(SoftwarePage.template, /GalaxySelect show-description v-model="primaryChoice"/)
assert.doesNotMatch(SoftwarePage.template, /<p v-if="primaryBranchHelp"/)
assert.match(SoftwarePage.template, /DriveStatePanel offroad-only :lockout="operations\?\.parked === false"/)
const installPreferences = fixture()
const installOptions = {...operations, canConfigure:true, automaticInstalls:false, restartSchedule:'0 0 * * *',
installPreferenceRevision:'0'.repeat(32),restartTimezone:'UTC'}
assert.ok(validSoftwareSnapshot(snapshot({operations:installOptions})))
assert.equal(validSoftwareSnapshot(snapshot({operations:{...installOptions,automaticInstalls:'yes'}})),false)
installPreferences.feed.start()
await installPreferences.reply(0,snapshot({operations:installOptions}))
installPreferences.feed.configureInstallPreferences(true,' 0 0 * * * ','Asia/Tokyo')
assert.deepEqual(JSON.parse(installPreferences.requests[1].options.body),{action:'install-preferences',automaticInstalls:true,
restartSchedule:'0 0 * * *',expectedRevision:'0'.repeat(32),restartTimezone:'Asia/Tokyo'})
await installPreferences.reply(1,snapshot({operations:{...installOptions,automaticInstalls:true,installPreferenceRevision:'a'.repeat(32)}}))
assert.equal(installPreferences.feed.installBaseline,null,'enabling the schedule does not install immediately')
installPreferences.feed.stop()
assert.match(SoftwarePage.template,/Install updates automatically/)
assert.match(SoftwarePage.template,/Automatic restart schedule/)
assert.deepEqual(SoftwarePage.data().restartCronFields,['0','0','*','*','*'])
const previousTimezone = process.env.TZ
try {
process.env.TZ = 'America/Los_Angeles'
const localTime = SoftwarePage.methods.reported('2026-11-01T00:00:00+00:00')
assert.equal(localTime, new Date("2026-11-01T00:00:00+00:00").toLocaleString())
} finally {
if (previousTimezone === undefined) delete process.env.TZ
else process.env.TZ = previousTimezone
}
assert.match(SoftwarePage.template, /is ready for install\. Click Update when ready\./)
assert.match(SoftwarePage.template, /Auto install will run at/)
assert.match(SoftwarePage.template, /reported\(operations.nextRestartAt\)/)
assert.ok(SoftwarePage.computed.restartTimezones.call({restartTimezone:'Asia/Tokyo',operations:{restartTimezone:'UTC'}}).includes('Asia/Tokyo'))
assert.equal(SoftwarePage.computed.restartScheduleChanged.call({restartCronFields:['0','0','*','*','*'],restartTimezone:'Asia/Tokyo',
operations:{restartSchedule:'0 0 * * *',restartTimezone:'UTC'}}),true)
let toggleSettings
SoftwarePage.methods.configureAutomaticInstalls.call({operations:{restartSchedule:'0 0 * * *',restartTimezone:'UTC'},restartTimezone:'Asia/Tokyo',
feed:{configureInstallPreferences:(...args)=>{toggleSettings=args}}},true)
assert.deepEqual(toggleSettings,[true,'0 0 * * *','UTC'],'toggle preserves the saved timezone')
@@ -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)
+25 -4
View File
@@ -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; }
@@ -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: `<section class="gx-card gx-force-drive" aria-label="Force Drive State" :inert="busy" :aria-busy="busy || undefined"><div class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">Force Drive State</span></div><div class="gx-panel">
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
template: `<section v-if="visible" class="gx-card gx-force-drive" aria-label="Force Drive State" :inert="busy" :aria-busy="busy || undefined"><div v-if="!offroadOnly" class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">Force Drive State</span></div><div class="gx-panel">
<GxNotice tone="danger" v-if="error || statusError">{{ error || statusError }}</GxNotice>
<GxNotice tone="danger" v-if="state && !state.available">The drive-state manager is unavailable. Reconnect to check again.</GxNotice>
<GxNotice v-if="pending" tone="info">Waiting for the device to switch {{ state.mode }}…</GxNotice>
<p>Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
<p v-if="!offroadOnly">Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
<p v-if="!offroadOnly">Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.</p>
<label v-if="offroadOnly" class="gx-row"><span>Force Offroad</span><input type="checkbox" role="switch"
<label v-if="offroadOnly" class="gx-row gx-row--borderless"><span class="gx-row__info"><span class="gx-row__label">Force device offroad</span><small class="gx-row__desc">Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state.</small></span><span class="gx-switch"><input type="checkbox" role="switch"
:checked="state?.mode === 'offroad'" :disabled="!state?.available"
@change="change($event.target.checked ? 'offroad' : 'auto')" /></label>
<p v-if="offroadOnly" class="gx-note">Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state.</p>
@change="toggleOffroad" /><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></span></label>
<div v-if="!offroadOnly" class="gx-force-drive__actions gx-actions"><button v-for="mode in ['onroad', 'auto']" :key="mode" type="button"
class="gx-btn" :class="state?.mode === mode ? '' : 'gx-btn--tonal'"
:disabled="!state?.available"
@@ -4,10 +4,11 @@ let nextId = 0
export const GalaxySelect = {
name: "GalaxySelect",
inheritAttrs: false,
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean },
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean, showDescription: Boolean },
emits: ["change", "update:modelValue"],
data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } },
computed: {
selectedDescription() { return this.items.find(item => item.value === this.selected)?.description || "" },
current() { return this.modelValue !== undefined ? this.modelValue : this.value },
buttonId() { return this.$attrs.id || this.uid },
nativeAttrs() {
@@ -129,7 +130,7 @@ export const GalaxySelect = {
<select ref="native" v-bind="nativeAttrs" hidden tabindex="-1" aria-hidden="true" :disabled="disabled" @change="change"><slot /></select>
<button ref="button" :id="buttonId" class="gx-select__button" type="button" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="uid + '-list'"
:aria-label="name" :aria-describedby="$attrs['aria-describedby']" :disabled="disabled" @click="show" @keydown="buttonKey">
<span>{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
<span class="gx-grow gx-wrap"><span>{{ label }}</span><small v-if="showDescription && selectedDescription" class="gx-note gx-select__description">{{ selectedDescription }}</small></span><i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<dialog ref="menu" class="gx-select-menu" @cancel.prevent="close" @click.self="close" @keydown="menuKey">
<div :id="uid + '-list'" role="listbox" :aria-label="name">
@@ -7,10 +7,9 @@ import { GalaxySelect } from "./galaxy-select.js"
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
import { RouteCleanup } from "./route-cleanup.js"
const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version"])
const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback", "versions", "version", "download-version"])
const REQUEST_STATES = new Set(["pending", "complete", "failed"])
const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update...", "updating..."])
const PRIMARY_BRANCHES = ["StarPilot", "Dom"]
const PRIMARY_BRANCH_LABELS = { StarPilot: "Stable — StarPilot", Dom: "Development — Dom" }
const PRIMARY_BRANCH_HELP = {
StarPilot: "Stable releases. Recommended for most users.",
@@ -36,6 +35,7 @@ export function validSoftwareSnapshot(data) {
typeof branch === "string" && branch.length > 0 && branch.length <= 128) &&
text(operations.selectedTarget) && text(operations.reason) &&
(operations.progress === undefined || operations.progress === null || typeof operations.progress === "object" &&
(operations.progress.background === undefined || typeof operations.progress.background === 'boolean') &&
text(operations.progress.stage) && (operations.progress.stage === null || operations.progress.stage.length <= 128) &&
text(operations.progress.detail) && (operations.progress.detail === null || operations.progress.detail.length <= 512) &&
(operations.progress.percent === null || Number.isFinite(operations.progress.percent) &&
@@ -44,6 +44,12 @@ export function validSoftwareSnapshot(data) {
(request?.selectedCommit === undefined || /^[0-9a-f]{40}$/.test(request.selectedCommit)) &&
(operations.canRollback === undefined || typeof operations.canRollback === "boolean") &&
(operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) &&
(operations.automaticInstalls === undefined || flag(operations.automaticInstalls)) &&
(operations.preparedCommit === undefined || operations.preparedCommit === null || /^[0-9a-f]{40}$/.test(operations.preparedCommit)) &&
(operations.nextRestartAt === undefined || text(operations.nextRestartAt)) &&
(operations.restartSchedule === undefined || typeof operations.restartSchedule === 'string' && operations.restartSchedule.length <= 128) &&
(operations.installPreferenceRevision === undefined || operations.installPreferenceRevision === null || /^[0-9a-f]{32}$/.test(operations.installPreferenceRevision)) &&
(operations.restartTimezone === undefined || text(operations.restartTimezone)) &&
(operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") &&
(operations.history === undefined || validHistory(operations.history)) &&
[operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") &&
@@ -59,8 +65,8 @@ export function softwareProgress(updater, operations = {}) {
const stage = progress?.stage || fast?.stage
const active = UPDATER_ACTIVE.has(updater.state) && !["unavailable", "timed_out", "error", "complete"].includes(stage)
const labels = { "checking...": "Checking for updates", "downloading...": "Downloading update",
"finalizing update...": "Finalizing update", "updating...": "Updating software",
checking: "Checking for updates", downloading: "Downloading update", finalizing: "Finalizing update",
"finalizing update...": "Preparing update", "updating...": "Updating software",
checking: "Checking for updates", downloading: "Downloading update", finalizing: "Preparing update",
preparing: "Preparing update", fetching: "Downloading update", validating: "Validating update",
applying: "Applying update", submodules: "Preparing dependencies", rebooting: "Restarting device",
complete: "Update complete", error: "Update failed", unavailable: "Updater status unavailable", timed_out: "Update is taking longer than expected" }
@@ -71,10 +77,13 @@ export function softwareProgress(updater, operations = {}) {
}
export function validHistory(value) {
const rows = (items) => Array.isArray(items) && items.length <= 20 && items.every((row) =>
const rows = (items) => Array.isArray(items) && items.length <= 100 && items.every((row) =>
/^[0-9a-f]{40}$/.test(row?.hash) && typeof row.date === "string" && row.date.length <= 64 &&
typeof row.subject === "string" && row.subject.length <= 512)
return !!value && rows(value.installed) && rows(value.downloaded) &&
(value.previous === undefined || value.previous === null || rows([value.previous])) &&
(value.downloadedCommits === undefined || Array.isArray(value.downloadedCommits) && value.downloadedCommits.length <= 201 &&
value.downloadedCommits.every(hash => /^[0-9a-f]{40}$/.test(hash))) &&
(value.recent === undefined || !!value.recent && (value.recent.branch === null || typeof value.recent.branch === "string") &&
(value.recent.head === null || /^[0-9a-f]{40}$/.test(value.recent.head)) && rows(value.recent.entries)) &&
[value.currentReleaseNotes, value.downloadedReleaseNotes].every((notes) => notes === null || typeof notes === "string" && notes.length <= 65536)
@@ -108,7 +117,7 @@ export class SoftwareStatusFeed {
emit(status) {
if (this.installBaseline && this.connectionLost) this.restarting(this.installBaseline)
this.publish({ status, data: this.data, busy: this.busy, uncertain: this.uncertain,
notice: this.notice, error: this.error }) }
notice: this.notice, error: this.error, submittingAction: this.mutating ? this.submittingAction : null }) }
stop() {
this.active = false
@@ -153,6 +162,7 @@ export class SoftwareStatusFeed {
this.controller = controller
this.busy = body !== null || this.data === null
this.mutating = body !== null
this.submittingAction = body?.action ?? null
if (body !== null) { this.error = ""; this.notice = "" }
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
this.timeout = this.later(() => {
@@ -205,7 +215,11 @@ export class SoftwareStatusFeed {
error.connectionLost = payload === null
throw error
}
const previous = this.data
this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload
if (this.data.operations.history === undefined && previous?.operations?.history &&
previous.installed.commit === this.data.installed.commit && previous.operations.selectedTarget === this.data.operations.selectedTarget)
this.data.operations = { ...this.data.operations, history: previous.operations.history }
this.attempted = true
this.blocked = false
this.startupFailures = 0
@@ -220,7 +234,8 @@ export class SoftwareStatusFeed {
this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch }
this.notice = "Restart requested. Waiting to reconnect and verify the installed build."
} else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` :
body.action === "preferences" ? "Automatic download preference saved." : ""
body.action === "preferences" ? "Automatic download preference saved." :
body.action === "install-preferences" ? "Automatic install preferences saved." : ""
} else {
const observed = payload.operations.request
const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action &&
@@ -231,7 +246,12 @@ export class SoftwareStatusFeed {
payload.operations.selectedTarget === this.uncertainAction.branch
const preferenceObserved = this.uncertainAction?.action === "preferences" &&
payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads
if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) {
const installPreferenceObserved = this.uncertainAction?.action === 'install-preferences' &&
payload.operations.automaticInstalls === this.uncertainAction.automaticInstalls &&
payload.operations.restartSchedule === this.uncertainAction.restartSchedule &&
payload.operations.restartTimezone === this.uncertainAction.restartTimezone &&
payload.operations.installPreferenceRevision !== this.uncertainAction.expectedRevision
if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved || installPreferenceObserved)) {
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
@@ -305,26 +325,39 @@ export class SoftwareStatusFeed {
expectedAutomaticDownloads: this.data.operations.automaticDownloads })
}
configureInstallPreferences(enabled, schedule, timezone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true ||
typeof enabled !== 'boolean' || typeof schedule !== 'string') return null
return this.run({action:'install-preferences', automaticInstalls:enabled, restartSchedule:schedule.trim().split(/\s+/).join(' '),
expectedRevision:this.data.operations.installPreferenceRevision, restartTimezone:timezone})
}
action(action, branch = null, selection = null) {
const operations = this.data?.operations
const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canFastUpdate", version: "canFastUpdate" }[action]
const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canCheck", version: "canFastUpdate", "download-version": "canFastUpdate" }[action]
if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked ||
operations[capability] !== true || operations.request?.state === "pending") return null
operations[capability] !== true || operations.request?.state === "pending" &&
!(["check", "versions"].includes(action) && ["download", "download-version"].includes(operations.request.action))) return null
if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null
if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null
if (["update", "fast", "versions", "version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null
if (["update", "fast", "versions", "version", "download-version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null
if (action === "rollback" && branch !== this.data.installed.branch) return null
if (action === "version") {
if (["version", "download-version"].includes(action)) {
if (selection?.expectedCommit !== this.data.installed.commit || !/^[0-9a-f]{40}$/.test(selection?.selectedCommit) ||
operations.history?.recent?.branch !== branch ||
!operations.history.recent.entries.some((row) => row.hash === selection.selectedCommit)) return null
![...operations.history.recent.entries, ...(branch === this.data.installed.branch ? operations.history.installed : [])]
.some((row) => row.hash === selection.selectedCommit)) return null
}
if (action === "install" && selection?.selectedCommit &&
(selection.expectedCommit !== this.data.installed.commit ||
operations.history?.downloaded?.[0]?.hash !== selection.selectedCommit)) return null
this.actionPriorRequestId = operations.request?.id ?? null
if (action === "install") this.installBaseline = { commit: this.data.installed.commit,
branch: this.data.installed.branch, target: branch, action }
branch: this.data.installed.branch, target: branch, action,
...(selection?.selectedCommit ? { selectedCommit: selection.selectedCommit } : {}) }
if (["update", "fast", "rollback", "version"].includes(action)) this.installBaseline = { commit: this.data.installed.commit, branch: this.data.installed.branch, target: branch, action, ...(action === "version" ? { selectedCommit: selection.selectedCommit } : {}) }
if (this.installBaseline) this.preparing(this.installBaseline)
if (action === "version") return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit })
if (["version", "download-version"].includes(action) || action === "install" && selection?.selectedCommit) return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit })
return this.run(action === "check" ? { action } : { action, branch })
}
}
@@ -334,11 +367,15 @@ export const SoftwarePage = {
name: "SoftwarePage",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, restarting: { type: Function, default: () => {} },
preparing: { type: Function, default: () => {} }, cancelPreparation: { type: Function, default: () => {} } },
data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "",
initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, historyKind: "recent", version: "" }),
data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "", submittingAction: null,
initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, newerVersionLimit: 2, olderVersionLimit: 1, collapsedOlderLimit: 0, version: "", restartCronFields: ["0", "0", "*", "*", "*"], scheduleTouched: false, restartTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
created() {
this.feed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => {
Object.assign(this.$data, update)
if (update.data?.operations.restartSchedule && !this.scheduleTouched) {
this.restartCronFields = update.data.operations.restartSchedule.split(' ')
this.restartTimezone = update.data.operations.restartTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone
}
if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget)
}, unauthorized: this.unauthorized, restarting: this.restarting,
preparing: this.preparing, cancelPreparation: this.cancelPreparation })
@@ -347,16 +384,34 @@ export const SoftwarePage = {
if (this.mode !== "local") return
try {
await this.feed.start()
if (this.feed.active && this.operations?.canCheck && !this.pending && !UPDATER_ACTIVE.has(this.data?.updater.state)) await this.feed.action("check")
if (this.feed.active && this.operations?.canCheck && (!this.pending || ['download', 'download-version'].includes(this.operations.request?.action)) &&
this.data?.updater.state !== 'updating...') await this.feed.action("check")
} finally { this.initialChecking = false }
},
beforeUnmount() { this.feed.stop() },
computed: {
operations() { return this.data?.operations },
restartScheduleChanged() { return this.restartCronFields.join(' ') !== this.operations?.restartSchedule ||
this.restartTimezone !== this.operations?.restartTimezone },
restartTimezones() {
return [...new Set(["UTC", this.restartTimezone, this.operations?.restartTimezone,
Intl.DateTimeFormat().resolvedOptions().timeZone, ...(Intl.supportedValuesOf?.("timeZone") || [])].filter(Boolean))].sort()
},
reportedProgress() { return softwareProgress(this.data?.updater, this.operations) },
metadataProgress() {
const check = this.data?.updater.fast?.check
if (['check', 'versions'].includes(this.submittingAction) || check?.state === 'checking' || this.pending && ['check', 'versions'].includes(this.operations?.request?.action))
return { active: true, percent: null, label: 'Checking for new versions', detail: check?.state === 'checking' ? check.detail : '' }
return null
},
backgroundProgress() {
return this.reportedProgress?.active && this.operations?.progress?.background ? this.reportedProgress : null
},
updateProgress() {
if (this.error || this.operations?.request?.state === "failed") return null
if (this.metadataProgress) return this.metadataProgress
const progress = this.reportedProgress
if (this.backgroundProgress) return null
if (progress?.active) return progress
if (progress?.failed) return null
if (this.initialChecking || this.busy || this.pending || this.uncertain) return {
@@ -379,12 +434,13 @@ export const SoftwarePage = {
(progress?.failed ? progress.detail || progress.label : "")
if (error) return { tone: "danger", message: error }
if (this.updateProgress) return null
if (this.operations?.reason) return { tone: "warn", message: this.operations.reason }
if (this.operations?.reason && !(this.backgroundProgress && this.operations.reason === 'Updater is busy')) return { tone: "warn", message: this.operations.reason }
if (this.updateAvailable && this.osUpdateRequired) return { tone: "warn",
message: this.data.updater.finalizedUpdateReady ? "Update ready — includes an operating system update. Restart to install." :
"Update available — operating system update required. This is a large download and may take a while. Keep the device powered and connected to Wi-Fi." }
if (this.data?.updater.finalizedUpdateReady && this.data.updater.targetBranch === (this.draftBranch || this.operations?.selectedTarget)) return { tone: "success", message: "Downloaded · Ready to install" }
if (this.notice) return { tone: "info", message: this.notice }
if (request && request.action !== "check") return { tone: "info", message: this.requestMessage(request) }
if (request && !["check", "versions"].includes(request.action)) return { tone: "info", message: this.requestMessage(request) }
if (this.updateAvailable) return { tone: "success", message: "Update Available" }
if (this.data?.updater.targetChangeFound === false) return { tone: "success", message: "No Update Available" }
return { tone: "warn", message: "Update status unavailable. Check for updates to try again." }
@@ -397,66 +453,171 @@ export const SoftwarePage = {
pending() { return this.operations?.request?.state === "pending" },
actionUnavailable() { return this.uncertain || this.pending || !!this.error || !this.operations?.parked },
actionDisabled() { return this.busy || this.actionUnavailable },
metadataActionDisabled() { return this.busy || this.uncertain || !!this.error || !this.operations?.parked },
branchOptions() {
const available = this.operations?.availableBranches || []
return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, ...available].filter(branch => branch && branch !== "other:"))]
return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, this.previousVersion?.branch, ...available].filter(branch => branch && branch !== "other:"))]
.map(name => ({ name, available: available.includes(name) || name === this.data?.installed.branch }))
},
primaryBranchHelp() { return PRIMARY_BRANCH_HELP[this.primaryChoice] || "" },
otherBranches() {
const available = this.operations?.availableBranches || []
const selected = this.operations?.selectedTarget
const installed = this.data?.installed?.branch
return [...new Set([selected, installed, ...available].filter(branch => branch && branch !== "other:" && !PRIMARY_BRANCHES.includes(branch)))]
.map(name => ({ name, listed: available.includes(name), current: name === installed }))
otherBranches() { return this.branchOptions.filter(branch => !['StarPilot', 'Dom'].includes(branch.name)) },
hasInstalledVersion() {
const recent = this.operations?.history?.recent
return this.draftBranch === this.data?.installed.branch ||
recent?.branch === this.draftBranch && recent.entries.some(entry => entry.hash === this.data?.installed.commit)
},
listedPreviousVersion() {
const previous = this.previousVersion
if (!previous) return null
const history = this.operations?.history
const recent = history?.recent?.branch === this.draftBranch ? history.recent.entries : []
const known = recent.some(entry => entry.hash === previous.hash) ||
this.hasInstalledVersion && history.installed.some(entry => entry.hash === previous.hash)
const locallySelected = this.version === previous.hash && this.draftBranch === (previous.branch || this.data?.installed.branch)
return known || locallySelected ? previous : null
},
canStageBranch() { return !!this.draftBranch && this.draftBranch !== "other:" && this.operations?.availableBranches.includes(this.draftBranch) && this.draftBranch !== this.operations.selectedTarget },
branchChanging() { return !!this.draftBranch && this.draftBranch !== this.data?.installed.branch },
versionEntries() {
const history = this.operations?.history
return this.historyKind === "recent" ? (history?.recent?.branch === this.draftBranch ? history.recent.entries : []) : history?.[this.historyKind] || []
if (!history) return []
const rows = new Map()
const recent = history.recent?.branch === this.draftBranch ? history.recent.entries : []
const downloaded = this.data.updater.finalizedUpdateReady && this.data.updater.targetBranch === this.draftBranch && this.operations.selectedTarget === this.draftBranch ? history.downloaded.slice(0, 1) : []
const installed = this.hasInstalledVersion ? history.installed : []
for (const entry of [...recent, ...downloaded, ...installed, ...(this.listedPreviousVersion ? [this.listedPreviousVersion] : [])]) rows.set(entry.hash, entry)
const current = this.data.installed.commit
if (this.hasInstalledVersion && current && !rows.has(current)) rows.set(current, { hash: current, date: "", subject: "Installed version" })
return [...rows.values()]
},
newerVersions() {
const index = this.versionEntries.findIndex(entry => entry.hash === this.data?.installed.commit)
return (index < 0 ? this.versionEntries : this.versionEntries.slice(0, index))
.filter(entry => entry.hash !== this.listedPreviousVersion?.hash)
},
olderVersions() {
const index = this.versionEntries.findIndex(entry => entry.hash === this.data?.installed.commit)
return index < 0 ? [] : this.versionEntries.slice(index + 1)
},
previousVersion() {
const previous = this.operations?.history?.previous
return previous && previous.hash !== this.data?.installed.commit ? previous : null
},
beforePrevious() {
const index = this.olderVersions.findIndex(entry => entry.hash === this.listedPreviousVersion?.hash)
return this.listedPreviousVersion && index >= 0 ? this.olderVersions.slice(0, index) : []
},
afterPrevious() {
const index = this.olderVersions.findIndex(entry => entry.hash === this.listedPreviousVersion?.hash)
return this.listedPreviousVersion && index >= 0 ? this.olderVersions.slice(index + 1) : this.olderVersions
},
visibleVersions() {
const installed = this.versionEntries.find(entry => entry.hash === this.data?.installed.commit)
const previous = this.listedPreviousVersion
return [...this.newerVersions.slice(0, this.newerVersionLimit), ...(installed ? [installed] : []),
...(previous ? [...this.beforePrevious.slice(0, this.collapsedOlderLimit), previous] : []),
...this.afterPrevious.slice(0, Math.max(0, this.olderVersionLimit - (previous ? 1 : 0)))]
},
hiddenNewerCount() { return Math.max(0, this.newerVersions.length - this.newerVersionLimit) },
hiddenCollapsedOlderCount() { return Math.max(0, this.beforePrevious.length - this.collapsedOlderLimit) },
hiddenOlderCount() { return Math.max(0, this.afterPrevious.length - Math.max(0, this.olderVersionLimit - (this.listedPreviousVersion ? 1 : 0))) },
downloadedVersions() {
return new Set([...(this.operations?.history?.downloadedCommits || []), this.downloadedCommit].filter(Boolean))
},
selectedVersion() { return this.versionEntries.find(entry => entry.hash === (this.version || this.data?.installed.commit)) },
downloadedCommit() {
return this.data?.updater.finalizedUpdateReady && this.data.updater.targetBranch === this.draftBranch && this.draftBranch === this.operations?.selectedTarget ?
this.operations?.history?.downloaded?.[0]?.hash : null
},
selectedVersionAction() {
if (!this.selectedVersion || this.selectedVersion.hash === this.data?.installed.commit) return "No changes selected"
if (this.selectedVersion.hash === this.previousVersion?.hash) return "Install selected version"
return this.downloadedVersions.has(this.selectedVersion.hash) ? "Install selected version" : "Download and install selected version"
},
canInstallSelection() {
if (this.actionDisabled || !this.selectedVersion || this.selectedVersion.hash === this.data?.installed.commit) return false
if (this.selectedVersion.hash === this.previousVersion?.hash) return this.operations?.canRollback === true
if (this.selectedVersion.hash === this.downloadedCommit) return this.operations?.canInstall === true
return this.operations?.canFastUpdate === true && this.operations.history?.recent?.branch === this.draftBranch &&
[...this.operations.history.recent.entries, ...(this.draftBranch === this.data.installed.branch ? this.operations.history.installed : [])]
.some(entry => entry.hash === this.selectedVersion.hash)
},
selectionNeedsInstall() {
return this.canInstallSelection && this.data?.updater.state !== 'updating...' && !this.feed?.installBaseline
},
canDownloadSelection() {
return !this.actionDisabled && this.operations?.canFastUpdate === true && !!this.selectedVersion &&
this.selectedVersion.hash !== this.data?.installed.commit && !this.downloadedVersions.has(this.selectedVersion.hash) &&
this.operations.history?.recent?.branch === this.draftBranch &&
[...this.operations.history.recent.entries, ...(this.draftBranch === this.data.installed.branch ? this.operations.history.installed : [])]
.some(entry => entry.hash === this.selectedVersion.hash)
},
selectedReleaseNotes() {
if (this.selectedVersion?.hash === this.data?.installed.commit) return this.operations?.history?.currentReleaseNotes
if (this.selectedVersion?.hash === this.downloadedCommit) return this.operations?.history?.downloadedReleaseNotes
return null
},
commitsUrl() { return this.data?.installed.branch ? `https://github.com/StarPilotHQ/StarPilot/commits/${encodeURIComponent(this.data.installed.branch)}/` : "" },
},
methods: {
switchRow,
configureAutomaticInstalls(enabled) {
return this.feed.configureInstallPreferences(enabled, this.operations.restartSchedule || '0 0 * * *', this.operations.restartTimezone || this.restartTimezone)
},
async saveRestartSchedule() {
const result = await this.feed.configureInstallPreferences(this.operations.automaticInstalls === true, this.restartCronFields.join(' '), this.restartTimezone)
if (result) {
this.scheduleTouched = false
this.restartCronFields = result.operations.restartSchedule.split(' ')
this.restartTimezone = result.operations.restartTimezone
}
},
syncDraftBranch(branch) {
this.draftBranch = this.operations?.availableBranches.includes(branch) || branch === this.data?.installed.branch ? branch : this.data?.installed.branch || ""
this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : ""
this.draftBranch = branch || this.data?.installed.branch || ""
this.primaryChoice = ['StarPilot', 'Dom'].includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? 'other:' : ''
},
changeBranch() { this.draftTouched = true; this.version = ""; this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" },
onPrimaryBranchChange() {
this.draftTouched = true
if (this.primaryChoice === 'other:') {
if (['StarPilot', 'Dom'].includes(this.draftBranch)) this.draftBranch = ''
return
}
this.draftBranch = this.primaryChoice
return this.changeBranch()
},
async changeBranch() {
this.primaryChoice = ['StarPilot', 'Dom'].includes(this.draftBranch) ? this.draftBranch : 'other:'
this.draftTouched = true
this.version = ""
if (this.primaryChoice === "other:") {
if (!this.otherBranches.some(option => option.name === this.draftBranch)) {
const selected = this.operations?.selectedTarget
this.draftBranch = this.otherBranches.some(option => option.name === selected) ? selected : ""
}
} else this.draftBranch = this.primaryChoice
this.newerVersionLimit = 2; this.olderVersionLimit = 1; this.collapsedOlderLimit = 0
return this.loadVersions()
},
selectPreviousVersion() {
if (this.actionDisabled || !this.previousVersion) return
const previous = this.previousVersion
this.draftTouched = true
this.syncDraftBranch(previous.branch || this.data.installed.branch)
this.version = previous.hash
this.newerVersionLimit = 2; this.olderVersionLimit = 1; this.collapsedOlderLimit = 0
},
downloadSelectedVersion() {
if (!this.canDownloadSelection) return
return this.feed.action('download-version', this.draftBranch,
{ expectedCommit: this.data.installed.commit, selectedCommit: this.selectedVersion.hash })
},
onOtherBranchChange() { this.draftTouched = true; this.version = "" },
branchLabel(branch) { return PRIMARY_BRANCH_LABELS[branch] || branch },
primaryBranchHelpFor(branch) { return PRIMARY_BRANCH_HELP[branch] || "" },
versionLabel(entry) { return `${entry.subject.trim() || this.shortCommit(entry.hash)} · ${entry.date ? new Date(entry.date).toLocaleDateString() : this.shortCommit(entry.hash)}` },
shown(value) { return value ?? "Unavailable" },
reported(value) { return value ? new Date(value).toLocaleString() : "Unavailable" },
shortCommit(value) { return value ? value.slice(0, 12) : "Unavailable" },
shortCommit(value) { return value ? value.slice(0, 8) : "Unavailable" },
requestMessage(request) {
if (!request) return ""
if (request.state === "failed") return request.error || "Update request failed. Refresh and try again."
if (["update", "fast", "rollback", "version"].includes(request.action)) return request.state === "pending" ? `Updating ${request.target}…` :
request.outcome === "up_to_date" ? "Installed branch is already up to date. No restart needed." :
"Update installed. Waiting to reconnect and verify the installed build."
if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : "Recent versions loaded."
if (request.action === "download-version") return request.state === "pending" ? "Downloading selected version…" : "Downloaded · Ready to install"
if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : ""
if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build."
if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…"
return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved."
},
chooseBranch() {
if (!this.operations?.canSelect || this.actionDisabled || !this.canStageBranch) return
this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch",
message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" }
return request.action === "check" ? "Update check finished." : ["download", "download-version"].includes(request.action) ? "Downloaded · Ready to install" : "Target branch saved."
},
askUpdate() {
const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch
@@ -473,18 +634,19 @@ export const SoftwarePage = {
},
async loadVersions() {
const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch
if (this.actionDisabled || !branch) return
if (branch !== this.operations.selectedTarget) {
const result = await this.feed.action("select", branch)
if (!result) return
}
if (this.metadataActionDisabled || !branch) return
this.feed.includeHistory = true
return this.feed.action("versions", branch)
},
askVersion(entry) {
const branch = this.operations?.history?.recent?.branch
if (!entry || this.actionDisabled || this.operations?.canFastUpdate !== true || !branch) return
this.dialog = { action: "version", branch, selectedCommit: entry.hash, expectedCommit: this.data.installed.commit,
title: "Install recent version", message: `Install ${entry.subject} (${this.shortCommit(entry.hash)}) on ${branch} and restart?`, label: "Install" }
askSelectedVersion() {
if (!this.canInstallSelection) return
const entry = this.selectedVersion
if (entry.hash === this.listedPreviousVersion?.hash) return this.askRollback()
const downloaded = entry.hash === this.downloadedCommit
this.dialog = { action: downloaded ? "install" : "version", branch: this.draftBranch,
selectedCommit: entry.hash, expectedCommit: this.data.installed.commit,
title: this.selectedVersionAction, label: this.selectedVersionAction,
message: `${downloaded ? "Install" : "Download and install"} ${entry.subject} (${this.shortCommit(entry.hash)}) and restart the device?` }
},
askRollback() {
const branch = this.data?.installed?.branch
@@ -533,62 +695,103 @@ export const SoftwarePage = {
</div><p v-if="updateProgress.detail" class="gx-note">{{ updateProgress.detail }}</p>
</div>
<div v-if="!initialChecking && !updateProgress" class="gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="metadataActionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button>
<button v-if="updateAvailable || branchChanging" type="button" class="gx-btn" :disabled="actionDisabled || !canUpdate" @click="askUpdate">Update</button>
</div>
<div v-if="backgroundProgress" class="gx-update-progress" role="status" aria-live="polite">
<div class="gx-update-progress__heading"><span>Background download · {{ backgroundProgress.label }}</span><span v-if="backgroundProgress.percent !== null">{{ Math.round(backgroundProgress.percent) }}%</span></div>
<div class="gx-update-progress__track" role="progressbar" aria-label="Background update download" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="backgroundProgress.percent ?? undefined">
<div class="gx-update-progress__fill" :class="{'gx-update-progress__fill--indeterminate': backgroundProgress.percent === null}" :style="backgroundProgress.percent !== null ? {width: backgroundProgress.percent + '%'} : {}"></div>
</div><p v-if="backgroundProgress.detail" class="gx-note">{{ backgroundProgress.detail }}</p>
</div>
<p v-if="data?.updater.lastCheckedAt || data?.updater.lastFetchAt" class="gx-note">
<span v-if="data.updater.lastCheckedAt">Checked {{ reported(data.updater.lastCheckedAt) }}</span>
<span v-if="data.updater.lastFetchAt"> · Downloaded {{ reported(data.updater.lastFetchAt) }}</span>
</p>
<p v-if="operations?.preparedCommit && operations.preparedCommit !== data?.installed.commit" class="gx-note">
<a v-if="repositoryUrl" class="gx-link" :href="repositoryUrl + '/commit/' + operations.preparedCommit" target="_blank" rel="noopener noreferrer" title="View commit on GitHub">{{ shortCommit(operations.preparedCommit) }}</a><span v-else>{{ shortCommit(operations.preparedCommit) }}</span> is ready for install. Click Update when ready.
<template v-if="operations.automaticInstalls && operations.nextRestartAt">Auto install will run at {{ reported(operations.nextRestartAt) }} while offroad.</template>
</p>
<GalaxySettingRow v-if="operations?.automaticDownloads !== undefined" class="gx-row--borderless"
:row="switchRow('Download updates automatically', operations.automaticDownloads, 'Prepare updates while parked; restart when ready.')"
:index="0" :busy="busy" :disabled="uncertain || !!error || !operations.canConfigure"
:save-value="(index, value) => feed.configureAutomaticDownloads(value === 'On')" />
<p v-if="operations?.automaticDownloads === null" class="gx-note">The download preference could not be read. Choose a setting while parked to repair it.</p>
<details v-if="data" class="gx-software-options"><summary>Advanced options</summary><div class="gx-stack"><DriveStatePanel offroad-only />
<div class="gx-software-branch"><label class="gx-field-group gx-grow"><span class="gx-row__label">Branch</span>
<GalaxySelect v-model="primaryChoice" class="gx-field gx-field--full" aria-label="Target branch" :disabled="actionUnavailable || !operations.canSelect" @change="onPrimaryBranchChange">
<option value="" disabled>Choose a branch</option>
<GalaxySettingRow v-if="operations?.automaticInstalls !== undefined" class="gx-row--borderless"
:row="switchRow('Install updates automatically', operations.automaticInstalls, 'Restart on the configured schedule, only while offroad.')"
:index="1" :busy="busy" :disabled="uncertain || !!error || !operations.canConfigure"
:save-value="(index, value) => configureAutomaticInstalls(value === 'On')" />
<p v-if="operations?.automaticInstalls === null" class="gx-note">Install preferences are unreadable. Save the schedule to repair them.</p>
<details v-if="data" class="gx-software-options" @toggle="loadHistory($event)"><summary>Advanced options</summary><div class="gx-stack">
<DriveStatePanel offroad-only :lockout="operations?.parked === false" />
<label class="gx-field-group"><span class="gx-row__label">Branch</span>
<GalaxySelect show-description v-model="primaryChoice" class="gx-field gx-field--full" aria-label="Version branch" :disabled="metadataActionDisabled || !operations.canCheck" @change="onPrimaryBranchChange">
<option value="StarPilot" :data-description="primaryBranchHelpFor('StarPilot')">Stable — StarPilot</option>
<option value="Dom" :data-description="primaryBranchHelpFor('Dom')">Development — Dom</option>
<option value="other:">Other branches…</option>
</GalaxySelect>
</label></div>
<p v-if="primaryBranchHelp" class="gx-note">{{ primaryBranchHelp }}</p>
<div v-if="primaryChoice === 'other:'" class="gx-software-other-branches">
<label for="gx-other-branch" class="gx-row__label">Other branches</label>
<p class="gx-note">Additional branches from this installation's repository.</p>
<GalaxySelect id="gx-other-branch" v-model="draftBranch" class="gx-field gx-field--full" aria-label="Other branches" :disabled="actionUnavailable || !operations.canSelect" @change="onOtherBranchChange">
</label>
<label v-if="primaryChoice === 'other:'" class="gx-field-group"><span class="gx-row__label">Other branches</span>
<GalaxySelect v-model="draftBranch" class="gx-field gx-field--full" aria-label="Other branches" :disabled="metadataActionDisabled || !operations.canCheck" @change="changeBranch">
<option value="" disabled>{{ otherBranches.length ? 'Select another branch' : 'No other branches available' }}</option>
<option v-for="branch in otherBranches" :key="branch.name" :value="branch.name" :disabled="!branch.listed">{{ branch.name }}{{ branch.current ? ' (current)' : '' }}{{ !branch.listed && !branch.current ? ' (unavailable)' : '' }}</option>
<option v-for="branch in otherBranches" :key="branch.name" :value="branch.name">{{ branch.name }}</option>
</GalaxySelect>
</label>
<div class="gx-actions gx-version-actions">
<button type="button" class="gx-btn" :class="{'gx-version-action--pending': selectionNeedsInstall}" :disabled="!canInstallSelection" @click="askSelectedVersion">{{ selectedVersionAction }}</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="metadataActionDisabled || !operations.canCheck" @click="loadVersions">Refresh</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canDownloadSelection" @click="downloadSelectedVersion">Download only</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionDisabled || !previousVersion" @click="selectPreviousVersion">Select previous version</button>
</div>
<p v-if="draftBranch && !operations.availableBranches.includes(draftBranch)" class="gx-note">This branch is not in the updater's available list and cannot be selected yet.</p>
<p v-else-if="!operations.availableBranches.length" class="gx-note">No branch list is available yet. Check for updates to refresh it.</p>
<p v-if="branchChanging" class="gx-note">Update installs {{ draftBranch }} and restarts the device.</p>
<div class="gx-actions">
<button v-if="canStageBranch" type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canSelect" @click="chooseBranch">Save target</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canDownload || !operations.selectedTarget || draftBranch !== operations.selectedTarget" @click="feed.action('download', operations.selectedTarget)">Download only</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || operations.canRollback !== true" @click="askRollback">Previous version</button>
<div v-if="visibleVersions.length" class="gx-version-scroll" role="region" aria-label="Versions" tabindex="0">
<ol class="gx-version-list">
<template v-for="entry in visibleVersions" :key="entry.hash">
<li v-if="hiddenNewerCount && entry.hash === data.installed.commit" class="gx-version-gap">
<button type="button" class="gx-btn gx-btn--tonal" @click="newerVersionLimit+=10">Load {{ Math.min(10, hiddenNewerCount) }}{{ hiddenNewerCount > 10 ? ' of ' + hiddenNewerCount : '' }} collapsed {{ hiddenNewerCount === 1 ? 'version' : 'versions' }}</button>
</li>
<li v-if="hiddenCollapsedOlderCount && entry.hash === listedPreviousVersion?.hash" class="gx-version-gap">
<button type="button" class="gx-btn gx-btn--tonal" @click="collapsedOlderLimit+=Math.min(10, hiddenCollapsedOlderCount)">Load {{ Math.min(10, hiddenCollapsedOlderCount) }}{{ hiddenCollapsedOlderCount > 10 ? ' of ' + hiddenCollapsedOlderCount : '' }} collapsed {{ hiddenCollapsedOlderCount === 1 ? 'version' : 'versions' }}</button>
</li>
<li>
<label class="gx-version-row" :class="{ 'gx-version-row--installed': entry.hash === data.installed.commit, 'gx-version-row--selected': entry.hash === (version || data.installed.commit) }">
<input type="radio" name="software-version" :value="entry.hash" :checked="entry.hash === (version || data.installed.commit)" @change="version=entry.hash" :disabled="actionDisabled" />
<span class="gx-version-content">
<strong>{{ entry.subject.trim() || shortCommit(entry.hash) }}</strong>
<small class="gx-version-meta">{{ entry.date ? reported(entry.date) + ' · ' : '' }}<a v-if="repositoryUrl" class="gx-link" :href="repositoryUrl + '/commit/' + entry.hash" target="_blank" rel="noopener noreferrer" title="View commit on GitHub" @click.stop>{{ shortCommit(entry.hash) }}</a><span v-else>{{ shortCommit(entry.hash) }}</span><span v-if="entry.hash === data.installed.commit && branchChanging"> · {{ data.installed.branch }}</span><span v-if="entry.hash === data.installed.commit" class="gx-version-badge gx-version-badge--installed">Installed</span><span v-else-if="entry.hash === previousVersion?.hash" class="gx-version-badge gx-version-badge--downloaded">Previous version</span><span v-else class="gx-version-badge" :class="{ 'gx-version-badge--downloaded': downloadedVersions.has(entry.hash) }">{{ downloadedVersions.has(entry.hash) ? 'Downloaded' : 'Not downloaded' }}</span></small>
</span>
</label>
</li>
</template>
<li v-if="!hasInstalledVersion && hiddenNewerCount" class="gx-version-gap">
<button type="button" class="gx-btn gx-btn--tonal" @click="newerVersionLimit+=10">Load {{ Math.min(10, hiddenNewerCount) }} more older {{ hiddenNewerCount === 1 ? 'version' : 'versions' }}</button>
</li>
<li v-if="hiddenOlderCount" class="gx-version-gap">
<button type="button" class="gx-btn gx-btn--tonal" @click="olderVersionLimit+=10">Load {{ Math.min(10, hiddenOlderCount) }} more older {{ hiddenOlderCount === 1 ? 'version' : 'versions' }}</button>
</li>
</ol>
</div>
<p class="gx-note">Download only prepares an update without restarting. Previous version restores your last installed build.</p>
</div></details>
<details v-if="data" class="gx-software-options" @toggle="loadHistory($event)"><summary>Versions &amp; release notes</summary><div class="gx-stack">
<div class="gx-tabs gx-actions" aria-label="Version source">
<button v-for="source in [{key:'recent',label:'Available'},{key:'installed',label:'Installed'}, ...(operations.history?.downloaded.length ? [{key:'downloaded',label:'Downloaded'}] : [])]" :key="source.key" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="historyKind === source.key" @click="historyKind=source.key; version=''">{{ source.label }}</button>
</div>
<template v-if="historyKind === 'recent'">
<div class="gx-actions">
<GalaxySelect v-if="versionEntries.length" v-model="version" class="gx-field gx-filter-field" aria-label="Version"><option value="" disabled>Choose a version</option>
<option v-for="entry in versionEntries" :key="entry.hash" :value="entry.hash">{{ versionLabel(entry) }}</option></GalaxySelect>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canFastUpdate" @click="loadVersions">Load versions</button>
<button v-if="versionEntries.length" type="button" class="gx-btn" :disabled="actionUnavailable || !version || version === data.installed.commit || !operations.canFastUpdate" @click="askVersion(versionEntries.find(entry => entry.hash === version))">Install version</button>
</div><p class="gx-note">Up to 20 compatible versions of {{ draftBranch }}.</p>
</template>
<ul v-else-if="versionEntries.length" class="gx-build-history" aria-label="Build history"><li v-for="entry in versionEntries" :key="entry.hash"><strong>{{ entry.subject.trim() || shortCommit(entry.hash) }}</strong><small>{{ reported(entry.date) }} · {{ shortCommit(entry.hash) }}</small></li></ul>
<p v-else class="gx-note">{{ operations.history ? 'No versions saved.' : 'Reading history…' }}</p>
<pre v-if="historyKind === 'installed' && operations.history?.currentReleaseNotes" class="gx-release-notes">{{ operations.history.currentReleaseNotes }}</pre>
<pre v-if="historyKind === 'downloaded' && operations.history?.downloadedReleaseNotes" class="gx-release-notes">{{ operations.history.downloadedReleaseNotes }}</pre>
<div v-if="selectedReleaseNotes?.trim()"><h4>Release notes</h4><pre class="gx-release-notes">{{ selectedReleaseNotes }}</pre></div>
<p v-else-if="selectedVersion" class="gx-note">Release notes are not available for this version.</p>
<form v-if="operations?.restartSchedule !== undefined" class="gx-stack" @submit.prevent="saveRestartSchedule">
<h4>Automatic restart schedule</h4>
<div class="gx-restart-cron">
<label v-for="(label, index) in ['Minute (0–59)', 'Hour (0–23)', 'Day (1–31)', 'Month (1–12)', 'Weekday (0–7)']" :key="index" class="gx-field-group">
<span class="gx-row__label">{{ label }}</span>
<input v-model="restartCronFields[index]" class="gx-field gx-field--full" type="text" maxlength="24" spellcheck="false"
:aria-label="label" :disabled="busy || uncertain || !operations.canConfigure" @input="scheduleTouched=true" />
</label>
<label class="gx-field-group">
<span class="gx-row__label">Timezone</span>
<GalaxySelect v-model="restartTimezone" class="gx-field gx-field--full" aria-label="Schedule timezone"
:disabled="busy || uncertain || !operations.canConfigure" @change="scheduleTouched=true">
<option v-for="zone in restartTimezones" :key="zone" :value="zone">{{ zone }}</option>
</GalaxySelect>
</label>
</div>
<p class="gx-note">Cron: {{ restartCronFields.join(' ') }} · Schedule timezone: {{ restartTimezone }}.<br />Default: daily at local midnight. Uses the selected timezone, including daylight saving time. Use * for every value; ranges, commas and steps are supported. Weekday 0 or 7 is Sunday. Schedule is skipped if onroad.</p>
<button type="submit" class="gx-btn gx-btn--tonal" :disabled="busy || uncertain || !operations.canConfigure || !restartScheduleChanged">Save restart schedule</button>
</form>
</div></details>
</section>
<ToggleBackup :unauthorized="unauthorized" />
@@ -0,0 +1,169 @@
"""Opt-in, offroad-only restarts on a numeric five-field cron schedule."""
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import uuid
from zoneinfo import ZoneInfo
from openpilot.starpilot.state_migration import _atomic_write, _private_dir
DEFAULT_CRON = '0 0 * * *'
_LIMITS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
def cron_fields(expression: str) -> list[set[int]]:
if not isinstance(expression, str) or len(expression) > 128 or len(expression.split()) != 5:
raise ValueError('Use five cron fields: minute hour day month weekday')
fields = []
for field, (low, high) in zip(expression.split(), _LIMITS, strict=True):
values = set()
for item in field.split(','):
parts = item.split('/')
if len(parts) > 2:
raise ValueError('Invalid cron step')
step = int(parts[1]) if len(parts) == 2 else 1
base = parts[0]
if base == '*':
start, end = low, high
elif '-' in base:
start, end = map(int, base.split('-'))
else:
start = int(base)
end = high if len(parts) == 2 else start
if not low <= start <= end <= high or not 1 <= step <= high - low + 1:
raise ValueError('Cron field is outside its allowed range')
values.update(range(start, end + 1, step))
fields.append(values)
return fields
def cron_matches(expression: str, now: datetime) -> bool:
minute, hour, day, month, weekday = cron_fields(expression)
dow = (now.weekday() + 1) % 7
day_matches, weekday_matches = now.day in day, dow in weekday or dow == 0 and 7 in weekday
parts = expression.split()
restricted = not parts[2].startswith('*') and not parts[4].startswith('*')
calendar_matches = (day_matches or weekday_matches) if restricted else day_matches and weekday_matches
return now.minute in minute and now.hour in hour and now.month in month and calendar_matches
def next_restart(expression: str, now: datetime) -> datetime | None:
minutes, hours, days, months, weekdays = cron_fields(expression)
restricted = not expression.split()[2].startswith('*') and not expression.split()[4].startswith('*')
# Eight years includes the longest gap between leap days.
for offset in range(366 * 8):
day = now + timedelta(days=offset)
dow = (day.weekday() + 1) % 7
day_matches = day.day in days
weekday_matches = dow in weekdays or dow == 0 and 7 in weekdays
if day.month not in months or not ((day_matches or weekday_matches) if restricted else day_matches and weekday_matches):
continue
for hour in sorted(hours):
for minute in sorted(minutes):
candidate = day.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate > now and candidate.astimezone(UTC).astimezone(now.tzinfo) == candidate:
return candidate
return None
class InstallSettings:
def __init__(self, root: Path | None = None):
self._root = Path(root) if root is not None else None
@property
def root(self) -> Path:
if self._root is None:
from openpilot.starpilot.storage import starpilot_storage_root
self._root = starpilot_storage_root() / 'software' / 'auto-install'
return self._root
@property
def path(self) -> Path:
return self.root / 'settings.json'
def read(self) -> dict | None:
try:
fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as source:
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
return None
raw = source.read(4097)
if len(raw) > 4096:
return None
value = json.loads(raw)
keys = {'version', 'enabled', 'cron', 'revision', 'lastAttempt'}
if (not isinstance(value, dict) or set(value) not in (keys, keys | {'timezone'}) or
type(value['version']) is not int or value['version'] != 1 or type(value['enabled']) is not bool or not isinstance(value['revision'], str) or
not re.fullmatch('[0-9a-f]{32}', value['revision']) or
value['lastAttempt'] is not None and (not isinstance(value['lastAttempt'], str) or len(value['lastAttempt']) > 64)):
return None
ZoneInfo(value.get('timezone', 'UTC'))
cron_fields(value['cron'])
return value
except FileNotFoundError:
return {'version': 1, 'enabled': False, 'cron': DEFAULT_CRON, 'revision': '0' * 32, 'lastAttempt': None}
except (OSError, ValueError, TypeError, KeyError):
return None
@contextmanager
def locked(self):
_private_dir(self.root)
fd = os.open(self.root / '.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def write(self, value: dict) -> None:
_atomic_write(self.path, json.dumps(value, sort_keys=True).encode())
def save(self, enabled: bool, expression: str, expected_revision: str | None, admitted, timezone: str | None = None) -> dict:
if type(enabled) is not bool:
raise ValueError('Choose whether to install updates automatically')
cron_fields(expression)
if timezone is not None:
ZoneInfo(timezone)
with self.locked():
current = self.read()
if (current['revision'] if current else None) != expected_revision:
raise ValueError('Install preferences changed; refresh before saving')
if not admitted():
raise PermissionError('Park the vehicle before changing install preferences')
value = {'version': 1, 'enabled': enabled, 'cron': ' '.join(expression.split()), 'revision': uuid.uuid4().hex,
'lastAttempt': current['lastAttempt'] if current else None}
value['timezone'] = timezone or (current.get('timezone', 'UTC') if current else 'UTC')
self.write(value)
if self.read() != value:
raise OSError('Install preferences could not be verified')
return value
def scheduled_restart(params, settings: InstallSettings, ready, parked, now: datetime) -> bool:
value = settings.read()
if value:
now = now.astimezone(ZoneInfo(value.get('timezone', 'UTC')))
if not value or not value['enabled'] or not cron_matches(value['cron'], now):
return False
occurrence = now.strftime('%Y-%m-%dT%H:%M%z')
def admitted():
return (params.get_bool('IsOffroad') and not params.get_bool('DisableUpdates') and
not params.get_bool('DoReboot') and ready() and parked())
with settings.locked():
current = settings.read()
if current is None or current != value or current['lastAttempt'] == occurrence or not admitted():
return False
# Reserve the occurrence before rebooting so a failed restart cannot loop within the same minute.
settings.write({**current, 'lastAttempt': occurrence})
if not admitted():
return False
params.put_bool('DoReboot', True, block=True)
return True
+50 -10
View File
@@ -5,8 +5,10 @@ import subprocess
import time
import selectors
import signal
import tempfile
from openpilot.starpilot.galaxy.companion import phone_uplink_active
from openpilot.starpilot.software.history import MAX_COMMITS
from openpilot.common.vendor_manifest import validate_revision
from openpilot.common.prebuilt_manifest import revision_digest, valid_receipt
@@ -72,26 +74,34 @@ def _stop_process(process):
process.wait(timeout=5)
def run(command, cwd, progress=None, *, timeout=None):
downloading = 'fetch' in command or 'pull' in command or 'clone' in command
def run(command, cwd, progress=None, *, timeout=None, download=False, cancelled=None, stdin=None):
downloading = download or 'fetch' in command or 'pull' in command or 'clone' in command
if downloading and phone_uplink_active():
raise FastUpdateError('Software downloads are disabled on the phone uplink')
environment = dict(os.environ, GIT_LFS_SKIP_SMUDGE='1', GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/false', SSH_ASKPASS='/bin/false')
if timeout is None:
timeout = 120 if "fetch" in command or "reset" in command or "checkout" in command else 30
output = bytearray()
with subprocess.Popen(command, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) as process:
with subprocess.Popen(command, cwd=cwd, env=environment, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) as process:
assert process.stdout is not None
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + timeout
pending = b""
progress_at = float('-inf')
pending_progress = None
try:
while selector.get_map():
if cancelled is not None and cancelled():
raise FastUpdateError('Updater operation stopped')
if downloading and phone_uplink_active():
raise FastUpdateError('Phone uplink activated; software download stopped')
if time.monotonic() >= deadline:
raise subprocess.TimeoutExpired(command, timeout, output=bytes(output))
if progress is not None and pending_progress is not None and time.monotonic() - progress_at >= 0.25:
progress(pending_progress)
pending_progress = None
progress_at = time.monotonic()
for key, _ in selector.select(min(0.1, max(0.0, deadline - time.monotonic()))):
chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk:
@@ -105,8 +115,18 @@ def run(command, cwd, progress=None, *, timeout=None):
detail = line.decode('utf-8', errors='replace')
pattern = r'(?:remote: )?(?:Enumerating objects|Counting objects|Compressing objects|Receiving objects|Resolving deltas|Total|Updating files):'
if progress is not None and re.match(pattern, detail):
progress(detail[-512:])
# Progress consumers persist Params synchronously. Git can emit
# hundreds of lines in a burst; keep draining its pipe instead
# of forcing a disk sync for each percentage point.
pending_progress = detail[-512:]
now = time.monotonic()
if now - progress_at >= 0.25:
progress(pending_progress)
pending_progress = None
progress_at = now
result = process.wait(timeout=max(0.01, deadline - time.monotonic()))
if progress is not None and pending_progress is not None:
progress(pending_progress)
if result:
raise subprocess.CalledProcessError(result, command, output=bytes(output))
except BaseException:
@@ -118,6 +138,18 @@ def run(command, cwd, progress=None, *, timeout=None):
_DEFAULT_RUN = run
def fetch_files(git, revision):
missing = [line[1:] for line in git('rev-list', '--objects', '--no-object-names', '--missing=print',
'--no-walk', revision, '--').splitlines() if line.startswith('?')]
if missing:
# Match Git's partial-clone bulk fetch, with visible progress and no argv-size limit.
with tempfile.TemporaryFile() as requested:
requested.write(('\n'.join(missing) + '\n').encode())
requested.seek(0)
git('-c', 'fetch.negotiationAlgorithm=noop', 'fetch', '--progress', '--no-tags', '--no-write-fetch-head',
'--no-recurse-submodules', '--filter=blob:none', 'origin', '--stdin', stdin=requested)
def fast_update(repo, branch, *, params, parked, expected_commit, current_os=None, invalidate=None, run=run, rollback=False,
progress=None, sleep=None, selected_commit=None, versions_only=False):
repo = Path(repo)
@@ -132,7 +164,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
notify('preparing', 'Resolving active branch...')
def git(*arguments):
def git(*arguments, stdin=None):
command = [
'git',
'-c',
@@ -152,8 +184,9 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
*arguments,
]
if run is _DEFAULT_RUN:
return run(command, cwd=str(repo), progress=(lambda detail: notify('fetching', detail)) if 'fetch' in arguments else None)
return run(command, cwd=str(repo))
return run(command, cwd=str(repo), download=not rollback and any(action in arguments for action in ('show', 'checkout', 'reset')),
progress=(lambda detail: notify('fetching', detail)) if 'fetch' in arguments else None, stdin=stdin)
return run(command, cwd=str(repo), **({'stdin': stdin} if stdin is not None else {}))
def admitted():
if not parked():
@@ -177,13 +210,16 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
notify('fetching', 'Previous installed version is available locally.')
else:
recent = versions_only or selected_commit is not None
history_limit = MAX_COMMITS if selected_commit is not None else RECENT_VERSION_LIMIT
notify('fetching', f'Fetching {"recent versions" if recent else "latest shallow commit"} on {branch}...')
git('fetch', '--progress', f'--depth={RECENT_VERSION_LIMIT if recent else 1}', '--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}')
git('fetch', '--progress', f'--depth={history_limit if recent else 1}', '--filter=blob:none',
'--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}')
target = git('rev-parse', 'FETCH_HEAD^{commit}').strip()
if recent:
members = git('log', '--first-parent', f'-{RECENT_VERSION_LIMIT}', '--format=%H', target, '--').splitlines()
members = git('log', '--first-parent', f'-{history_limit}', '--format=%H', target, '--').splitlines()
if selected_commit is not None:
if selected_commit not in members:
installed_history = git('log', '--first-parent', f'-{MAX_COMMITS}', '--format=%H', previous, '--').splitlines() if branch == initial_branch else []
if selected_commit not in members and selected_commit not in installed_history:
raise FastUpdateError('Selected version is no longer in recent branch history; refresh and try again')
target = selected_commit
else:
@@ -201,6 +237,9 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
version = validate_legacy_return(git, target, branch)
notify('validating', f'Returning to {branch} {version}; checking the legacy prebuilt release...')
else:
# Validation reads the manifest directly, so guard any initial blob download.
if git('ls-tree', target, '--', 'upstream-sync.json').strip().split()[:1] == ['100644']:
git('show', f'{target}:upstream-sync.json')
validate_revision(repo, target)
launch = git('show', f'{target}:launch_env.sh')
required_os = required_os_version(launch)
@@ -237,6 +276,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
for name in untracked:
if name in tracked or name in directories or any(str(parent) in tracked for parent in Path(name).parents):
raise FastUpdateError('Update would overwrite untracked data')
fetch_files(git, target)
admitted()
if git('rev-parse', 'HEAD^{commit}').strip() != previous:
raise FastUpdateError('Source changed during download')
+95 -2
View File
@@ -2,6 +2,7 @@
from datetime import datetime
from html.parser import HTMLParser
import os
from pathlib import Path
import re
import subprocess
@@ -9,8 +10,9 @@ import subprocess
from openpilot.starpilot.saved_source import read_saved
COMMIT = re.compile(r'[0-9a-f]{40}\Z')
MAX_COMMITS = 20
MAX_COMMITS = 100
MAX_NOTES_BYTES = 32768
METADATA_ROOT = Path(os.environ.get('UPDATER_METADATA_ROOT', '/data/starpilot/software/metadata.git'))
def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> list[dict]:
@@ -21,7 +23,7 @@ def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> li
['git', '-C', str(path), '--no-pager', 'log', '--no-decorate', '--no-color', '--no-show-signature',
*(['--first-parent'] if first_parent else []), f'-{MAX_COMMITS}', '--format=%H%x00%cI%x00%<(240,trunc)%s%x00', commit, '--'],
check=True, capture_output=True, text=True, timeout=2).stdout
if len(output.encode()) > 32768:
if len(output.encode()) > MAX_COMMITS * 800:
return []
fields = output.split('\0')
rows = []
@@ -38,19 +40,106 @@ def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> li
return []
def previous_version(path: Path) -> dict | None:
try:
commit = subprocess.run(['git', '-C', str(path), 'rev-parse', '--verify', 'refs/starpilot/previous^{commit}'],
check=True, capture_output=True, text=True, timeout=2).stdout.strip()
rows = commit_history(path, commit)
if not rows:
return None
result = rows[0]
saved = subprocess.run(['git', '-C', str(path), 'config', '--local', '--get', 'starpilot.previousVersion'],
capture_output=True, text=True, timeout=2).stdout.strip().split(' ', 1)
if len(saved) == 2 and saved[0] == commit and re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}', saved[1]):
result['branch'] = saved[1]
return result
except (OSError, subprocess.SubprocessError):
return None
def downloaded_versions(paths, commits):
"""Check local trees in batches, sharing checks of unchanged subtrees."""
requested = list(dict.fromkeys(commit for commit in commits if COMMIT.fullmatch(commit)))
available = set()
for path in paths:
def git(*args, data=None, path=path):
return subprocess.run(['git', '-C', str(path), *args], input=data, check=True,
capture_output=True, timeout=2).stdout
def objects(hashes):
output = git('cat-file', '--batch', data=''.join(sha + '\n' for sha in hashes).encode())
result = {}
offset = 0
while offset < len(output):
end = output.index(b'\n', offset)
sha, kind, size = output[offset:end].split()
offset = end + 1
size = int(size)
result[sha.decode()] = (kind, output[offset:offset + size])
offset += size + 1
return result
try:
# Enumerate local objects first. Never ask cat-file for a missing object,
# which could cause a partial clone to fetch it just to render a badge.
existing = set(git('cat-file', '--batch-all-objects', '--batch-check=%(objectname)').decode().splitlines())
pending = [sha for sha in requested if sha not in available and sha in existing]
if not pending:
continue
roots = {sha: body.split(b'\n', 1)[0][5:].decode() for sha, (kind, body) in objects(pending).items()
if kind == b'commit' and body.startswith(b'tree ')}
trees = {}
todo = set(roots.values()) & existing
while todo:
following = set()
for sha, (kind, body) in objects(todo).items():
if kind != b'tree':
continue
children = []
offset = 0
while offset < len(body):
space = body.index(b' ', offset)
end = body.index(b'\0', space)
child = body[end + 1:end + 21].hex()
is_tree = body[offset:space] == b'40000'
children.append((child, is_tree))
if is_tree and child in existing:
following.add(child)
offset = end + 21
trees[sha] = children
todo = following - trees.keys()
complete = {}
def has_tree(sha, complete=complete, trees=trees, existing=existing):
if sha not in complete:
children = trees.get(sha)
complete[sha] = children is not None and all(child in existing and (not tree or has_tree(child))
for child, tree in children)
return complete[sha]
available.update(sha for sha, root in roots.items() if has_tree(root))
except (OSError, ValueError, subprocess.SubprocessError):
continue
return [sha for sha in requested if sha in available]
class _NotesText(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
self.hidden = 0
self.heading = 0
self.content = []
def handle_starttag(self, tag, attrs):
if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
self.heading += 1
if tag in ('script', 'style'):
self.hidden += 1
elif not self.hidden and tag in ('br', 'p', 'li', 'h1', 'h2', 'h3'):
self.parts.append('\n')
def handle_endtag(self, tag):
if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
self.heading = max(0, self.heading - 1)
if tag in ('script', 'style'):
self.hidden = max(0, self.hidden - 1)
elif not self.hidden and tag in ('p', 'li', 'h1', 'h2', 'h3'):
@@ -59,6 +148,8 @@ class _NotesText(HTMLParser):
def handle_data(self, data):
if not self.hidden:
self.parts.append(data)
if not self.heading:
self.content.append(data)
def release_notes(params, key: str) -> str | None:
@@ -69,6 +160,8 @@ def release_notes(params, key: str) -> str | None:
parser = _NotesText()
parser.feed(raw.decode('utf-8'))
parser.close()
if not re.sub(r'^\s*#{1,6}\s+.*$', '', ''.join(parser.content), flags=re.MULTILINE).strip():
return None
return re.sub(r'\n{3,}', '\n\n', ''.join(parser.parts)).strip()
except (UnicodeError, ValueError):
return None
+2 -2
View File
@@ -15,5 +15,5 @@ def automatic_downloads(params) -> bool | None:
return raw != b'0' # Preserve the upstream schedule for existing installations.
def download_permitted(params, *, manual: bool, check_only: bool) -> bool:
return not check_only and (manual or automatic_downloads(params) is True)
def download_permitted(params, *, manual: bool) -> bool:
return manual or automatic_downloads(params) is True
@@ -0,0 +1,134 @@
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock
from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, cron_matches, next_restart, scheduled_restart
class Params:
def __init__(self):
self.values = {'IsOffroad': True}
self.writes = []
def get_bool(self, key):
return bool(self.values.get(key))
def put_bool(self, key, value, **kwargs):
self.values[key] = value
self.writes.append((key, value))
class AutoInstallTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.settings = InstallSettings(Path(temporary.name) / 'install')
self.params = Params()
self.now = datetime(2026, 10, 30, 0, 0, tzinfo=UTC)
def enable(self, expression=DEFAULT_CRON):
current = self.settings.read()
assert current is not None
return self.settings.save(True, expression, current['revision'], lambda: True)
def test_local_midnight_tracks_daylight_savings(self):
current = self.settings.read()
assert current is not None
self.settings.save(True, DEFAULT_CRON, current['revision'], lambda: True, timezone='America/Los_Angeles')
for month, utc_hour in [(7, 7), (12, 8)]:
with self.subTest(month=month):
now = datetime(2026, month, 15, utc_hour, tzinfo=UTC)
self.params.values['DoReboot'] = False
self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, now - timedelta(hours=1)))
self.assertTrue(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, now))
zone = ZoneInfo('America/Los_Angeles')
before = datetime(2026, 3, 8, 0, tzinfo=zone)
self.assertEqual(next_restart('30 2 * * *', before), datetime(2026, 3, 9, 2, 30, tzinfo=zone))
def test_next_restart(self):
now = datetime(2026, 10, 30, 12, tzinfo=UTC)
self.assertEqual(next_restart(DEFAULT_CRON, now), datetime(2026, 10, 31, tzinfo=UTC))
self.assertEqual(next_restart('15 2 * * 1-5', now), datetime(2026, 11, 2, 2, 15, tzinfo=UTC))
self.assertEqual(next_restart('0 0 29 2 *', now), datetime(2028, 2, 29, tzinfo=UTC))
self.assertIsNone(next_restart('0 0 31 2 *', now))
def test_default_is_disabled_and_daily_at_midnight(self):
value = self.settings.read()
assert value is not None
self.assertFalse(value['enabled'])
self.assertEqual(value['cron'], '0 0 * * *')
self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now))
self.assertEqual(self.params.writes, [])
def test_numeric_cron_ranges_lists_steps_and_calendar_semantics(self):
self.assertTrue(cron_matches(DEFAULT_CRON, self.now))
self.assertFalse(cron_matches(DEFAULT_CRON, self.now + timedelta(minutes=1)))
self.assertTrue(cron_matches('*/15 1-3 * * 1-5', self.now.replace(hour=2, minute=30)))
self.assertFalse(cron_matches('*/15 1-3 * * 1-5', (self.now + timedelta(days=1)).replace(hour=2, minute=30)))
sunday = datetime(2026, 11, 1, tzinfo=UTC)
self.assertTrue(cron_matches('0 0 * * 0,7', sunday))
self.assertTrue(cron_matches('0 0 1 * 5', self.now))
self.assertFalse(cron_matches('0 0 1 * */1', self.now))
def test_invalid_cron_is_rejected(self):
for value in ('@midnight', '* * * *', '60 0 * * *', '0 24 * * *', '0 0 0 * *', '0 0 * 13 *',
'0 0 * * 8', '*/0 * * * *', '10-2 * * * *', '0, * * * *', '1/100 * * * *', 'x' * 129):
with self.subTest(value=value), self.assertRaises(ValueError):
cron_fields(value)
def test_settings_revision_and_last_moment_authorization(self):
saved = self.enable(' 0 0 * * * ')
self.assertEqual(saved['cron'], DEFAULT_CRON)
with self.assertRaises(ValueError):
self.settings.save(False, DEFAULT_CRON, '0' * 32, lambda: True)
with self.assertRaises(PermissionError):
self.settings.save(False, DEFAULT_CRON, saved['revision'], lambda: False)
self.assertEqual(self.settings.read(), saved)
def test_corrupt_or_symlink_settings_never_enable_restarts(self):
self.enable()
for raw in (b'not json', b'{}', b'x' * 4097):
self.settings.path.write_bytes(raw)
self.assertIsNone(self.settings.read())
self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now))
self.settings.path.unlink()
self.settings.path.symlink_to(Path(__file__))
self.assertIsNone(self.settings.read())
def test_onroad_disabled_updater_no_update_and_no_park_proof_block_restart(self):
self.enable()
for flags, ready, parked in (({'IsOffroad': False}, True, True), ({'DisableUpdates': True}, True, True),
({'DoReboot': True}, True, True), ({}, False, True), ({}, True, False)):
with self.subTest(flags=flags, ready=ready, parked=parked):
params = Params()
params.values.update(flags)
self.assertFalse(scheduled_restart(params, self.settings, lambda ready=ready: ready, lambda parked=parked: parked, self.now))
self.assertEqual(params.writes, [])
def test_restart_once_per_scheduled_minute_is_durable(self):
self.enable()
self.assertTrue(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now))
self.params.values['DoReboot'] = False
other = InstallSettings(self.settings.root)
self.assertFalse(scheduled_restart(self.params, other, lambda: True, lambda: True, self.now))
self.assertEqual(self.params.writes, [('DoReboot', True)])
self.assertTrue(scheduled_restart(self.params, other, lambda: True, lambda: True, self.now + timedelta(days=1)))
def test_offroad_and_prepared_update_are_rechecked_before_reboot(self):
for ready, parked in ((Mock(side_effect=[True, False]), Mock(return_value=True)),
(Mock(return_value=True), Mock(side_effect=[True, False]))):
with self.subTest(ready=ready, parked=parked):
self.settings.path.unlink(missing_ok=True)
self.enable()
self.assertFalse(scheduled_restart(self.params, self.settings, ready, parked, self.now))
self.assertEqual(self.params.writes, [])
def test_no_catchup_restart_after_the_scheduled_minute(self):
self.enable()
ready, parked = Mock(return_value=True), Mock(return_value=True)
self.assertFalse(scheduled_restart(self.params, self.settings, ready, parked, self.now + timedelta(minutes=1)))
ready.assert_not_called()
parked.assert_not_called()
@@ -206,6 +206,7 @@ class TestFastUpdate(unittest.TestCase):
self.assertEqual(self.params.calls, [])
self.assertEqual(self.invalidations, [])
self.assertIn('--depth=20', next(command for command in commands if 'fetch' in command))
self.assertIn('--filter=blob:none', next(command for command in commands if 'fetch' in command))
def test_selected_older_version_uses_same_install_and_rollback_transaction(self):
self.update()
@@ -225,7 +226,7 @@ class TestFastUpdate(unittest.TestCase):
self.assertEqual(self.params.calls, [])
self.assertEqual(self.invalidations, [])
rejected('f' * 40)
for index in range(21):
for index in range(owner.MAX_COMMITS + 1):
(self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index))
self.git(self.remote, 'commit', '-am', 'recent build')
rejected(self.target)
@@ -235,6 +236,35 @@ class TestFastUpdate(unittest.TestCase):
self.git(self.remote, 'commit', '-am', 'replacement')
rejected(abandoned)
def test_installed_parent_remains_selectable_after_leaving_recent_window(self):
self.git(self.repo, 'fetch', 'origin')
self.git(self.repo, 'reset', '--hard', self.target)
for index in range(owner.MAX_COMMITS + 1):
(self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index))
self.git(self.remote, 'commit', '-am', 'newer build')
self.assertEqual(self.update(selected_commit=self.previous), 'reboot-requested')
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
def test_selected_version_fetches_only_its_artifacts(self):
self.git(self.remote, 'config', 'uploadpack.allowFilter', 'true')
self.git(self.remote, 'config', 'uploadpack.allowReachableSHA1InWant', 'true')
self.git(self.repo, 'remote', 'set-url', 'origin', self.remote.as_uri())
artifact = self.remote / 'openpilot/starpilot/galaxy/web/assets/artifact.png'
artifact.parent.mkdir(parents=True)
artifact.write_bytes(b'chosen artifact' * 10000)
self.git(self.remote, 'add', '.')
self.git(self.remote, 'commit', '-m', 'Chosen version')
selected = self.git(self.remote, 'rev-parse', 'HEAD')
selected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png')
artifact.write_bytes(b'unselected newer artifact' * 10000)
self.git(self.remote, 'commit', '-am', 'Unselected version')
unselected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png')
self.assertEqual(self.update(selected_commit=selected), 'reboot-requested')
objects = set(self.git(self.repo, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
self.assertIn(selected_blob, objects)
self.assertNotIn(unselected_blob, objects)
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), selected)
def test_selected_version_rechecks_os_and_native_artifact_contracts(self):
(self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="different"\n')
self.git(self.remote, 'commit', '-am', 'different OS')
@@ -1,8 +1,9 @@
from pathlib import Path
from types import SimpleNamespace
import subprocess
import shutil
from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions
from openpilot.starpilot.software.history import MAX_COMMITS, commit_history, downloaded_versions, release_notes, recent_versions, previous_version
def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
@@ -11,12 +12,12 @@ def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
'-c', 'user.name=Test', '-c', 'user.email=test@example.com', *args],
check=True, capture_output=True, text=True).stdout.strip()
git('init', '-q')
for index in range(23):
for index in range(MAX_COMMITS + 3):
git('commit', '--allow-empty', '-qm', f'Change {index:02d}')
commit = git('rev-parse', 'HEAD~1')
rows = commit_history(tmp_path, commit)
assert len(rows) == 20
assert rows[0]['hash'] == commit and rows[0]['subject'] == 'Change 21'
assert len(rows) == MAX_COMMITS
assert rows[0]['hash'] == commit and rows[0]['subject'] == f'Change {MAX_COMMITS + 1}'
assert rows[-1]['subject'] == 'Change 02'
assert all(row['date'] for row in rows)
assert commit_history(tmp_path, '--all') == []
@@ -27,6 +28,44 @@ def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
assert recent['head'] == commit and recent['branch'] == 'Dom' and recent['entries'] == rows
assert recent_versions(tmp_path, '../escape')['entries'] == []
assert recent_versions(tmp_path, 'Dom')['entries'][0]['hash'] == commit
assert previous_version(tmp_path) is None
git('update-ref', 'refs/starpilot/previous', commit)
assert previous_version(tmp_path) == rows[0]
git('config', '--local', 'starpilot.previousVersion', f'{commit} old-branch')
previous = previous_version(tmp_path)
assert previous is not None
assert previous['branch'] == 'old-branch'
git('update-ref', '-d', 'refs/starpilot/previous')
assert previous_version(tmp_path) is None
def test_downloaded_versions_require_all_files_and_accept_a_staging_cache(tmp_path):
repo = tmp_path / 'repo'
repo.mkdir()
def git(*args):
return subprocess.check_output(['git', '-C', str(repo), '-c', 'user.name=Test',
'-c', 'user.email=test@example.com', *args], text=True).strip()
git('init', '-q')
(repo / 'file').write_text('old')
(repo / 'nested').mkdir()
(repo / 'nested/shared').write_text('shared subtree')
git('add', '.')
git('commit', '-qm', 'Old')
old = git('rev-parse', 'HEAD')
(repo / 'file').write_text('new')
git('commit', '-qam', 'New')
new = git('rev-parse', 'HEAD')
cache = tmp_path / 'cache'
shutil.copytree(repo, cache)
assert downloaded_versions((repo,), [old, new, '--all']) == [old, new]
blob = git('rev-parse', 'HEAD:file')
(repo / '.git/objects' / blob[:2] / blob[2:]).unlink()
assert downloaded_versions((repo,), [old, new]) == [old]
assert downloaded_versions((repo, cache), [old, new]) == [old, new]
(repo / '.git/objects' / blob[:2] / blob[2:]).write_bytes((cache / '.git/objects' / blob[:2] / blob[2:]).read_bytes())
tree = git('rev-parse', 'HEAD^{tree}')
(repo / '.git/objects' / tree[:2] / tree[2:]).unlink()
assert downloaded_versions((repo,), [old, new]) == [old]
def test_release_notes_are_bounded_plain_text(tmp_path):
@@ -39,3 +78,8 @@ def test_release_notes_are_bounded_plain_text(tmp_path):
assert release_notes(params, path.name) is None
path.write_bytes(b'\xff')
assert release_notes(params, path.name) is None
for empty in ('', ' \n\t', '# StarPilot 7.0 — Domathon development\n', '<h1>StarPilot 7.0</h1>', '<h2>Version</h2><p> </p>'):
path.write_text(empty)
assert release_notes(params, path.name) is None
path.write_text('# Version\n\n* Fixed updates')
assert release_notes(params, path.name) == '# Version\n\n* Fixed updates'
@@ -12,10 +12,8 @@ def test_default_saved_and_invalid_values_preserve_manual_requests(tmp_path, raw
if raw is not None:
Path(params.get_param_path(AUTOMATIC_DOWNLOADS)).write_bytes(raw)
assert automatic_downloads(params) is expected
assert download_permitted(params, manual=False, check_only=False) is (expected is True)
assert download_permitted(params, manual=True, check_only=False)
assert not download_permitted(params, manual=False, check_only=True)
assert not download_permitted(params, manual=True, check_only=True)
assert download_permitted(params, manual=False) is (expected is True)
assert download_permitted(params, manual=True)
def test_nonregular_and_symlink_preferences_do_not_enable_automatic_downloads(tmp_path):
@@ -52,11 +52,22 @@ class UpdaterControlTests(unittest.TestCase):
with self.assertRaises(update_control.UpdaterControlError):
update_control._command('fast', 'Dom', expected, selected)
def test_download_version_pins_both_commits_without_becoming_an_install(self):
expected, selected = 'a' * 40, 'b' * 40
command = update_control._command('download-version', 'Dom', expected, selected)
self.assertEqual(update_control._parse(command + b'\n'), ('download-version', 'Dom', expected, selected))
self.assertLessEqual(len(update_control._command('download-version', 'x' * 128, expected, selected)) + 1,
update_control.MAX_COMMAND)
with self.assertRaises(update_control.UpdaterControlError):
update_control._command('download-version', 'Dom', expected, 'HEAD')
def test_server_delivers_fast_identity_and_legacy_callback_shape(self):
for raw, expected in ((b'update Dom ' + b'a' * 40 + b'\n', mock.call('update', branch='Dom', commit='a' * 40)),
(b'fast Dom ' + b'a' * 40 + b'\n', mock.call('fast', branch='Dom', commit='a' * 40)),
(b'version Dom ' + b'a' * 40 + b' ' + b'b' * 40 + b'\n',
mock.call('version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)),
(b'download-version Dom ' + b'a' * 40 + b' ' + b'b' * 40 + b'\n',
mock.call('download-version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)),
(b'versions Dom ' + b'a' * 40 + b'\n', mock.call('versions', branch='Dom', commit='a' * 40)),
(b'check\n', mock.call('check')), (b'download\n', mock.call('download'))):
with self.subTest(raw=raw):
@@ -88,6 +99,16 @@ class UpdaterControlTests(unittest.TestCase):
self.assertEqual(request, updated.UserRequest.ROLLBACK)
self.assertEqual(target, ('Dom', 'a' * 40))
def test_check_requests_background_work_but_versions_only_requests_metadata(self):
from openpilot.system.updated import updated
callback = mock.Mock()
helper = updated.WaitTimeHelper.__new__(updated.WaitTimeHelper)
helper.metadata = callback
helper._control_request('check')
callback.assert_called_with(branch=None, commit=None, download_after=True)
helper._control_request('versions', branch='Dom', commit='a' * 40)
callback.assert_called_with(branch='Dom', commit='a' * 40, download_after=False)
def test_same_process_control_and_stale_identity(self):
actions = []
accepted = threading.Event()
@@ -5,8 +5,9 @@ import sys
import tempfile
import time
import unittest
from unittest.mock import patch
from openpilot.starpilot.software.fast_update import run
from openpilot.starpilot.software.fast_update import FastUpdateError, run
class TestUpdateProcess(unittest.TestCase):
@@ -56,6 +57,39 @@ finally:
self.assertEqual(error.exception.output, b'locked\n')
self.assertFalse((self.directory / 'shallow.lock').exists())
def test_progress_burst_is_coalesced_without_losing_output(self):
command = self.command("for i in range(1000): print(f'Receiving objects: {i}\\r', end='')\n")
progress = []
result = run(command, self.directory, progress.append)
self.assertIn('Receiving objects: 500\r', result)
self.assertEqual(progress[0], 'Receiving objects: 0')
self.assertEqual(progress[-1], 'Receiving objects: 999')
self.assertLess(len(progress), 20)
def test_phone_uplink_interrupts_implicit_downloads(self):
command = self.command("from pathlib import Path\nimport time\nPath('started').touch()\ntime.sleep(60)\n")
with patch('openpilot.starpilot.software.fast_update.phone_uplink_active',
side_effect=lambda: (self.directory / 'started').exists()):
with self.assertRaisesRegex(FastUpdateError, 'software download stopped'):
run(command, self.directory, download=True, timeout=5)
def test_pending_progress_is_delivered_while_command_is_quiet(self):
command = self.command("""import time
from pathlib import Path
print('Receiving objects: 1%\\rReceiving objects: 99%', flush=True)
deadline = time.monotonic() + 2
while not Path('progress_received').exists() and time.monotonic() < deadline:
time.sleep(0.01)
print('received while running' if Path('progress_received').exists() else 'progress stalled')
""")
def progress(detail):
if detail == 'Receiving objects: 99%':
(self.directory / 'progress_received').touch()
result = run(command, self.directory, progress)
self.assertIn('received while running', result)
def test_stubborn_command_is_killed(self):
command = self.command("""import os
import signal
@@ -82,9 +82,9 @@ def available(pid: int, start: int) -> bool:
def _command(action: str, branch=None, commit=None, selected_commit=None) -> bytes:
if action == 'version' and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit):
if action in ('version', 'download-version') and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit):
base = _command('fast', branch, commit).decode('ascii').split(' ', 1)[1]
return f'version {base} {selected_commit}'.encode('ascii')
return f'{action} {base} {selected_commit}'.encode('ascii')
if selected_commit is not None:
raise UpdaterControlError('Invalid updater version target')
if action in ('check', 'download') and branch is None and commit is None:
@@ -100,7 +100,7 @@ def _parse(command: bytes):
return command[:-1].decode('ascii'), None, None
try:
parts = command[:-1].decode('ascii').split(' ')
if len(parts) == 4 and parts[0] == 'version' and command.endswith(b'\n'):
if len(parts) == 4 and parts[0] in ('version', 'download-version') and command.endswith(b'\n'):
action, branch, commit, selected = parts
if _command(action, branch, commit, selected) + b'\n' == command:
return action, branch, commit, selected
@@ -160,7 +160,7 @@ class UpdaterControlServer:
else:
parsed = _parse(command)
action, branch, commit = parsed[:3]
if action == 'version':
if action in ('version', 'download-version'):
self.request(action, branch=branch, commit=commit, selected_commit=parsed[3])
elif action in ('update', 'fast', 'rollback', 'versions'):
self.request(action, branch=branch, commit=commit)
@@ -177,3 +177,14 @@ class TestExternalPreferencesReset(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
def test_fresh_preferences_disable_scheduled_automatic_installation(tmp_path):
from openpilot.starpilot.software.auto_install import InstallSettings
settings = InstallSettings(tmp_path / 'software' / 'auto-install')
value = settings.read()
assert value is not None
settings.save(True, '0 0 * * *', value['revision'], lambda: True)
assert reset.reset_external_preferences(tmp_path, tmp_path / 'models') == ()
value = settings.read()
assert value is not None and not value['enabled']
@@ -801,6 +801,10 @@ class RuntimeSnapshotAdapter:
offroad=confirmed_offroad,
available_actions=frozenset(device_actions))
updater_state = _text(params, "UpdaterState")
fast = params.get('UpdaterFastState')
fast = fast if isinstance(fast, dict) else {}
checking_metadata = fast.get('check', {}).get('state') == 'checking'
background = fast.get('background') and updater_state in ('downloading...', 'finalizing update...')
available = _flag(params, "UpdaterFetchAvailable")
status = "updater status unavailable" if not updater_state else updater_state if updater_state != "idle" else (
"update available" if available else "up to date, last checked " + ("previously" if _text(params, "LastUpdateTime") else "never"))
@@ -810,6 +814,11 @@ class RuntimeSnapshotAdapter:
if updater_state == "idle":
software_actions.update((SoftwareRequest.FAST_UPDATE, SoftwareRequest.ROLLBACK))
software_actions.add(SoftwareRequest.DOWNLOAD_UPDATE if available else SoftwareRequest.CHECK_FOR_UPDATES)
elif background and not checking_metadata:
software_actions.add(SoftwareRequest.CHECK_FOR_UPDATES)
if checking_metadata:
software_actions.discard(SoftwareRequest.CHECK_FOR_UPDATES)
software_actions.discard(SoftwareRequest.DOWNLOAD_UPDATE)
if not _flag(params, "IsTestedBranch") and _text(params, "UpdaterAvailableBranches"):
software_actions.add(SoftwareRequest.OPEN_BRANCH_CHOOSER)
software = SoftwareState(current_version=_text(params, "UpdaterCurrentDescription"),
@@ -423,6 +423,18 @@ class TestRuntimeSnapshot(unittest.TestCase):
self.assertEqual(settings.software, first.software)
self.assertEqual(adapter.build(ShellMode.ONROAD, now_ns=NOW + 1_000_000_002).software, first.software)
def test_background_finalization_allows_metadata_check(self):
from openpilot.starpilot.ui.software_state import SoftwareRequest
ui = ui_fake()
ui.params.values['UpdaterState'] = 'finalizing update...'
ui.params.values['UpdaterFastState'] = {'background': True}
with patch.object(RuntimeSnapshotAdapter, '_parked', return_value=True):
state = RuntimeSnapshotAdapter(ui).build(ShellMode.SETTINGS, now_ns=NOW).software
self.assertIn(SoftwareRequest.CHECK_FOR_UPDATES, state.available_actions)
ui.params.values['UpdaterFastState']['check'] = {'state': 'checking'}
state = RuntimeSnapshotAdapter(ui).build(ShellMode.SETTINGS, now_ns=NOW).software
self.assertNotIn(SoftwareRequest.CHECK_FOR_UPDATES, state.available_actions)
def test_customization_uses_existing_refresh_and_invalidation(self):
adapter = RuntimeSnapshotAdapter(ui_fake())
first = default_document()
@@ -12,6 +12,8 @@ import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import ModuleType
@@ -19,6 +21,7 @@ from unittest.mock import Mock, patch
from openpilot.common.vendor_manifest import DEPENDENCIES
from openpilot.starpilot.software.auto_install import InstallSettings
BRANCH = "test-vendored"
@@ -30,6 +33,9 @@ class MemoryParams:
def get(self, key):
return self.values.get(key)
def get_bool(self, key):
return bool(self.values.get(key))
def put(self, key, value, **kwargs):
self.values[key] = value
@@ -100,14 +106,16 @@ class TestVendoredUpdate(unittest.TestCase):
self.updated = load_updater()
self.updated.OVERLAY_MERGED = str(self.merged)
self.updated.METADATA_ROOT = self.root / 'metadata.git'
self.updated.FINALIZED = str(self.finalized)
self.updated.BASEDIR = str(self.merged)
self.updated.set_consistent_flag = self.set_consistent_flag
self.updated.handle_agnos_update = Mock()
self.updated.init_overlay = Mock()
self.commands = []
real_run = self.updated.run
def bounded_run(command, cwd=None):
def bounded_run(command, cwd=None, **kwargs):
if cwd is None:
raise AssertionError("Every updater command must name its isolated working directory")
self.assertTrue(Path(cwd).resolve().is_relative_to(self.root.resolve()))
@@ -115,10 +123,11 @@ class TestVendoredUpdate(unittest.TestCase):
self.commands.append(command)
if command in (["git", "gc"], ["git", "lfs", "prune"]):
return ""
return real_run(command, cwd)
return real_run(command, cwd, **kwargs)
self.updated.run = bounded_run
self.updater = self.updated.Updater()
self.updater = self.updated.Updater(install_settings=InstallSettings(self.root / 'install-settings'))
self.addCleanup(self.updater.close)
@staticmethod
def git(repo, *args):
@@ -166,10 +175,89 @@ class TestVendoredUpdate(unittest.TestCase):
self.updater.fast_update(BRANCH, self.initial_commit)
normal.assert_not_called() # Explicit fast/rollback/version operations keep their contract.
@patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True)))
def test_download_selected_version_stages_exact_commit_without_installing(self, _physical):
installed = self.root / 'installed'
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
(self.source / 'version.txt').write_text('selected')
selected = self.commit_source('Selected version')
(self.source / 'version.txt').write_text('newest')
latest = self.commit_source('Newer version')
with patch.object(self.updater, 'set_params') as publish:
self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, selected, download_only=True), 'downloaded')
publish.assert_called_once_with(True, 0, None)
self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit)
self.assertEqual(self.git(self.finalized, 'rev-parse', 'HEAD'), selected)
self.assertEqual(self.git(self.finalized, 'rev-parse', 'refs/starpilot/previous'), self.initial_commit)
self.assertEqual(self.updater.branches[BRANCH], latest)
self.assertTrue(self.updater.update_ready)
self.assertFalse(self.updater.latest_update_ready)
self.assertFalse(self.updater.params.get_bool('DoReboot'))
@patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True)))
def test_download_selected_version_rejects_changed_source_and_unlisted_target(self, _physical):
for expected, selected in (('f' * 40, self.initial_commit), (self.initial_commit, 'f' * 40)):
with self.subTest(expected=expected), self.assertRaises(self.updated.FastUpdateError):
self.updater.fast_update(BRANCH, expected, selected, download_only=True)
self.updated.init_overlay.assert_not_called()
self.assertEqual(self.git(self.merged, 'rev-parse', 'HEAD'), self.initial_commit)
self.assertFalse(self.updater.params.get_bool('DoReboot'))
def test_metadata_refresh_does_not_wake_background_but_check_does(self):
(self.source / 'new.txt').write_text('available')
self.commit_source('New update')
self.updater.wake_automatic = Mock()
with patch.object(self.updated, 'download_permitted', return_value=True):
self.updater.request_check(branch=BRANCH)
worker = self.updater._check_thread
if worker is not None:
worker.join(timeout=5)
self.updater.wake_automatic.assert_not_called()
self.updated.init_overlay.assert_not_called()
self.updater.request_check(branch=BRANCH, download_after=True)
worker = self.updater._check_thread
if worker is not None:
worker.join(timeout=5)
self.updater.wake_automatic.assert_called_once_with()
def test_scheduled_install_requires_clock_idle_and_fresh_offroad_evidence(self):
installed = self.root / 'installed'
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
(self.source / 'scheduled.txt').write_text('prepared')
self.commit_source('Prepared update for scheduling')
self.assertTrue(self.updater.fetch_update())
self.updater.params.put('UpdaterState', 'idle')
self.updater.params.put_bool('IsOffroad', True)
settings = self.updater.install_settings
current = settings.read()
assert current is not None
settings.save(True, '* * * * *', current['revision'], lambda: True)
physical = Mock(update_ready=Mock(return_value=True))
with patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=physical), \
patch.object(self.updated, 'system_time_valid', return_value=False):
self.assertFalse(self.updater.maybe_install())
with patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=physical), \
patch.object(self.updated, 'system_time_valid', return_value=True):
self.assertFalse(self.updater.maybe_install(lambda: False))
self.updater.params.put_bool('IsOffroad', False)
self.assertFalse(self.updater.maybe_install())
self.assertFalse(self.updater.params.get_bool('DoReboot'))
physical.update_ready.assert_not_called()
self.updater.params.put_bool('IsOffroad', True)
self.assertTrue(self.updater.maybe_install())
self.assertTrue(self.updater.params.get_bool('DoReboot'))
self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit)
self.assertEqual(physical.update_ready.call_count, 2)
def test_normal_fallback_stages_flashes_and_requests_reboot_with_fresh_parked_state(self):
installed = self.root / "installed"
self.git(self.root, "clone", "--no-local", str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
(self.source / "panda/README.md").write_text("New native revision")
target = self.commit_source("Full update")
self.updated.AGNOS = True
@@ -187,6 +275,7 @@ class TestVendoredUpdate(unittest.TestCase):
installed = self.root / "installed"
self.git(self.root, "clone", "--no-local", str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
(self.source / "panda/README.md").write_text("New native revision")
self.commit_source("Full update")
parked = iter((True, False))
@@ -215,6 +304,7 @@ class TestVendoredUpdate(unittest.TestCase):
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), target)
self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target)
self.assertEqual((self.finalized / "panda" / "README.md").read_text(), "Updated ordinary tracked source\n")
self.assertEqual(self.git(self.finalized, "rev-parse", f"refs/starpilot/recent/{BRANCH}"), target)
self.assertTrue((self.finalized / ".overlay_consistent").is_file())
for name in DEPENDENCIES:
self.assertFalse((self.finalized / name / ".git").exists())
@@ -223,6 +313,176 @@ class TestVendoredUpdate(unittest.TestCase):
self.assertEqual(self.git(self.merged, "config", "fetch.recurseSubmodules"), "false")
self.updated.handle_agnos_update.assert_not_called()
def test_filtered_fetch_preserves_history_without_obsolete_artifact_contents(self):
self.git(self.source, 'config', 'uploadpack.allowFilter', 'true')
self.git(self.source, 'config', 'uploadpack.allowReachableSHA1InWant', 'true')
self.git(self.merged, 'remote', 'set-url', 'origin', self.source.as_uri())
artifact = self.source / 'artifact.bin'
artifact.write_bytes(os.urandom(128 * 1024))
middle = self.commit_source('Superseded large artifact')
obsolete_blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin')
artifact.write_bytes(os.urandom(128 * 1024))
target = self.commit_source('Chosen large artifact')
target_blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin')
self.updater.check_for_update()
objects = set(self.git(self.updated.METADATA_ROOT, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
self.assertIn(target, objects)
self.assertIn(middle, objects)
self.assertNotIn(target_blob, objects)
self.assertNotIn(obsolete_blob, objects)
metadata_fetches = sum('fetch' in command for command in self.commands)
self.assertTrue(self.updater.fetch_update())
self.assertEqual(sum('fetch' in command for command in self.commands), metadata_fetches + 2)
objects = set(self.git(self.merged, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
self.assertIn(target, objects)
self.assertNotIn(middle, objects)
self.assertIn(target_blob, objects)
self.assertNotIn(obsolete_blob, objects)
self.assertIn(middle, self.git(self.updated.METADATA_ROOT, 'log', '--first-parent', '--format=%H', f'refs/starpilot/recent/{BRANCH}').splitlines())
self.assertEqual((self.finalized / 'artifact.bin').read_bytes(), artifact.read_bytes())
def test_refresh_versions_fetches_only_metadata_in_staging(self):
self.git(self.source, 'config', 'uploadpack.allowFilter', 'true')
self.git(self.merged, 'remote', 'set-url', 'origin', self.source.as_uri())
artifact = self.source / 'artifact.bin'
artifact.write_bytes(os.urandom(128 * 1024))
target = self.commit_source('Version metadata to refresh')
blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin')
installed = self.root / 'installed'
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
self.updated.BASEDIR = str(installed)
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
self.git(self.root, 'clone', '--no-local', str(self.merged), str(self.finalized))
marker = self.finalized / '.overlay_consistent'
marker.touch()
self.updater.params.values['IsOffroad'] = True
with patch.object(self.updated, 'init_overlay'), \
patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True))):
self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, versions_only=True), 'versions-listed')
objects = set(self.git(self.updated.METADATA_ROOT, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
self.assertIn(target, objects)
self.assertNotIn(blob, objects)
self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit)
self.assertFalse((installed / '.git/FETCH_HEAD').exists())
self.assertTrue(marker.exists())
self.assertNotIn('DoReboot', self.updater.params.values)
self.assertTrue(self.updater.params.values['UpdaterFetchAvailable'])
self.assertFalse(self.updater.params.values['UpdateAvailable'])
def test_scheduled_check_stages_new_commit_when_automatic_downloads_are_enabled(self):
(self.source / "panda/README.md").write_text("New automatic download\n")
target = self.commit_source("Automatic download after checking")
params = self.updater.params
params.values["InstallDate"] = "already installed"
preference = self.root / "UpdaterAutomaticDownloads"
params.get_param_path = lambda key: str(self.root / key)
for enabled in (False, True):
with self.subTest(enabled=enabled):
preference.write_bytes(b"1" if enabled else b"0")
helper = Mock()
helper.current_request.side_effect = [
(self.updated.UserRequest.NONE, 0, None),
(self.updated.UserRequest.NONE, 1, None),
RuntimeError("test loop complete"),
]
with patch.object(self.updated, "Params", return_value=params), \
patch.object(self.updated, "Updater", return_value=self.updater), \
patch.object(self.updated, "WaitTimeHelper", return_value=helper), \
patch.object(self.updated, "init_overlay"), \
patch.object(self.updated, "LOCK_FILE", str(self.root / "updater.lock")), \
patch.object(self.updater, "set_params"), \
self.assertRaisesRegex(RuntimeError, "test loop complete"):
self.updated.main()
self.assertEqual((self.finalized / ".overlay_consistent").exists(), enabled)
self.assertEqual("UpdaterLastFetchTime" in params.values, enabled)
self.assertNotIn("DoReboot", params.values)
if enabled:
self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target)
else:
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), self.initial_commit)
def test_up_to_date_check_does_not_repeat_automatic_staging(self):
params = self.updater.params
params.values['InstallDate'] = 'already installed'
params.get_param_path = lambda key: str(self.root / key)
helper = Mock()
helper.current_request.side_effect = [
(self.updated.UserRequest.NONE, 0, None),
(self.updated.UserRequest.CHECK, 1, None),
RuntimeError('test loop complete'),
]
with patch.object(self.updated, 'Params', return_value=params), \
patch.object(self.updated, 'Updater', return_value=self.updater), \
patch.object(self.updated, 'WaitTimeHelper', return_value=helper), \
patch.object(self.updated, 'init_overlay'), \
patch.object(self.updated, 'LOCK_FILE', str(self.root / 'updater.lock')), \
patch.object(self.updater, 'set_params'), \
patch.object(self.updater, 'fetch_update') as download, \
self.assertRaisesRegex(RuntimeError, 'test loop complete'):
self.updated.main()
download.assert_not_called()
self.assertNotIn('UpdaterLastFetchTime', params.values)
self.assertFalse(self.finalized.exists())
def test_metadata_refresh_completes_while_background_finalization_is_blocked(self):
installed = self.root / 'installed'
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
self.updated.BASEDIR = str(installed)
(self.source / 'panda/README.md').write_text('New background version')
self.commit_source('Background download')
entered, release = threading.Event(), threading.Event()
finalize = self.updated.finalize_update
failures = []
def blocked_finalize(*args, **kwargs):
entered.set()
if not release.wait(8):
raise RuntimeError('Test did not release finalization')
return finalize(*args, **kwargs)
def download():
try:
self.updater.fetch_update(background=True)
except Exception as error:
failures.append(error)
with patch.object(self.updated, 'finalize_update', side_effect=blocked_finalize):
worker = threading.Thread(target=download)
worker.start()
try:
self.assertTrue(entered.wait(4))
started = time.monotonic()
self.updater.request_check(branch=BRANCH, commit=self.initial_commit)
deadline = started + 4
while time.monotonic() < deadline:
check = self.updater.params.values['UpdaterFastState'].get('check', {})
if check.get('completedAt', 0) >= started:
break
time.sleep(0.01)
self.assertEqual(check['state'], 'complete')
self.assertGreaterEqual(check['completedAt'], started)
self.assertEqual(self.updater.params.values['UpdaterState'], 'finalizing update...')
self.assertTrue(self.updater.params.values['UpdaterFastState']['background'])
finally:
release.set()
worker.join(5)
self.assertFalse(worker.is_alive())
self.assertEqual(failures, [])
def test_finalized_runtime_files_are_independent_and_git_objects_are_shared(self):
(self.source / 'panda/README.md').write_text('Prepared source')
self.commit_source('New release')
self.updater.fetch_update()
source = self.merged / 'panda/README.md'
copied = self.finalized / 'panda/README.md'
self.assertNotEqual(source.stat().st_ino, copied.stat().st_ino)
copied.write_text('Local edit in prepared tree')
self.assertEqual(source.read_text(), 'Prepared source')
objects = list((self.merged / '.git/objects/pack').glob('*.pack'))
self.assertTrue(objects)
for obj in objects:
final = self.finalized / obj.relative_to(self.merged)
self.assertEqual(obj.stat().st_ino, final.stat().st_ino)
self.assertFalse(any(command[:2] == ['git', 'gc'] for command in self.commands))
def test_invalid_manifest_rejected_before_checkout(self):
self.manifest["schema_version"] = 999
self.write_manifest()
+327 -73
View File
@@ -24,7 +24,11 @@ from openpilot.common.version import get_build_metadata
from openpilot.common.vendor_manifest import validate_revision
from openpilot.starpilot.software.update_control import UpdaterControlError, UpdaterControlServer
from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, download_permitted
from openpilot.starpilot.software.fast_update import FastUpdateError, FastUpdateIncompatible, required_os_version, run as run_git
from openpilot.starpilot.software.fast_update import (
FastUpdateError, FastUpdateIncompatible, fetch_files, required_os_version, run as run_git,
)
from openpilot.starpilot.software.history import MAX_COMMITS, METADATA_ROOT, commit_history
from openpilot.starpilot.software.auto_install import InstallSettings, cron_matches, scheduled_restart
LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock")
STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging")
@@ -51,16 +55,20 @@ class UserRequest:
FAST = 3
ROLLBACK = 4
VERSIONS = 5
DOWNLOAD_VERSION = 8
VERSION = 6
UPDATE = 7
class WaitTimeHelper:
def __init__(self):
def __init__(self, metadata=None, idle_tick=None):
self.ready_event = threading.Event()
self.user_request = UserRequest.NONE
self.request_lock = threading.RLock()
self.request_generation = 0
self.fast_target: tuple[str, str] | tuple[str, str, str] | None = None
self.metadata = metadata
self.idle_tick = idle_tick
self.automatic_pending = False
signal.signal(signal.SIGHUP, self.update_now)
signal.signal(signal.SIGUSR1, self.check_now)
self.control = UpdaterControlServer(self._control_request)
@@ -74,10 +82,10 @@ class WaitTimeHelper:
def _request(self, request: int, *, branch=None, commit=None, selected_commit=None) -> None:
with self.request_lock:
self.user_request = request
if request in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION):
if request in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION):
if not isinstance(branch, str) or not isinstance(commit, str):
raise ValueError("Update source identity is required")
if request == UserRequest.VERSION:
if request in (UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION):
if not isinstance(selected_commit, str):
raise ValueError("Selected version is required")
self.fast_target = (branch, commit, selected_commit)
@@ -89,9 +97,12 @@ class WaitTimeHelper:
self.ready_event.set()
def _control_request(self, action: str, *, branch=None, commit=None, selected_commit=None) -> None:
if self.metadata is not None and action in ('check', 'versions'):
self.metadata(branch=branch, commit=commit, download_after=action == 'check')
return
request = {'update': UserRequest.UPDATE, 'check': UserRequest.CHECK, 'download': UserRequest.FETCH,
'fast': UserRequest.FAST, 'rollback': UserRequest.ROLLBACK,
'versions': UserRequest.VERSIONS, 'version': UserRequest.VERSION}.get(action)
'versions': UserRequest.VERSIONS, 'version': UserRequest.VERSION, 'download-version': UserRequest.DOWNLOAD_VERSION}.get(action)
if request is not None:
self._request(request, branch=branch, commit=commit, selected_commit=selected_commit)
@@ -101,32 +112,48 @@ class WaitTimeHelper:
def check_now(self, signum: int, frame) -> None:
cloudlog.info("caught SIGUSR1, checking for updates")
self._request(UserRequest.CHECK)
self._control_request('check')
def current_request(self) -> tuple[int, int, tuple[str, str] | tuple[str, str, str] | None]:
with self.request_lock:
self.ready_event.clear()
self.automatic_pending = False
return self.user_request, self.request_generation, self.fast_target
def wake_automatic(self):
with self.request_lock:
self.automatic_pending = True
self.ready_event.set()
def finish_request(self, generation: int) -> bool:
with self.request_lock:
if self.request_generation != generation:
return False
self.user_request = UserRequest.NONE
self.fast_target = None
if self.automatic_pending:
return False
self.ready_event.clear()
return True
def sleep(self, t: float) -> None:
self.ready_event.wait(timeout=t)
deadline = time.monotonic() + t
while not self.ready_event.is_set():
tick = getattr(self, 'idle_tick', None)
if tick is not None:
tick()
remaining = deadline - time.monotonic()
if remaining <= 0 or self.ready_event.wait(timeout=min(30, remaining)):
return
def write_time_to_param(params, param) -> None:
t = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
params.put(param, t, block=True)
def run(cmd: list[str], cwd: str | None = None) -> str:
def run(cmd: list[str], cwd: str | None = None, *, download: bool = False, progress=None, cancelled=None, stdin=None, timeout=None) -> str:
if cmd and cmd[0] == 'git':
return run_git(cmd, cwd, timeout=300 if 'fetch' in cmd or 'gc' in cmd else 120)
return run_git(cmd, cwd, timeout=timeout or (300 if download or 'fetch' in cmd or 'gc' in cmd else 120),
download=download, progress=progress, cancelled=cancelled, stdin=stdin)
return subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT, encoding='utf8')
@@ -234,7 +261,7 @@ def init_overlay() -> None:
cloudlog.info(f"git diff output:\n{git_diff}")
def finalize_update() -> None:
def finalize_update(progress=None) -> None:
"""Take the current OverlayFS merged view and finalize a copy outside of
OverlayFS, ready to be swapped-in at BASEDIR. Copy using shutil.copytree"""
@@ -246,19 +273,45 @@ def finalize_update() -> None:
# Copy the merged overlay view and set the update ready flag
if os.path.exists(FINALIZED):
shutil.rmtree(FINALIZED)
shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True)
total = sum(not os.path.islink(os.path.join(root, name)) for root, _, files in os.walk(OVERLAY_MERGED) for name in files)
copied = 0
reported = 0.0
roots = (Path(OVERLAY_UPPER), Path(BASEDIR)) if os.path.ismount(OVERLAY_MERGED) else (Path(OVERLAY_MERGED),)
def copy_file(source, destination):
nonlocal copied, reported
relative = Path(source).relative_to(OVERLAY_MERGED)
immutable = re.fullmatch(r'\.git/objects/(?:[0-9a-f]{2}/[0-9a-f]{38}|pack/pack-[0-9a-f]{40}\.(?:pack|idx))', relative.as_posix())
linked = False
if immutable:
# Git objects are immutable; worktree files, refs and indexes remain independent copies.
for root in roots:
candidate = root / relative
try:
if not candidate.is_symlink():
os.link(candidate, destination)
linked = True
break
except OSError:
pass
if not linked:
shutil.copy2(source, destination)
copied += 1
now = time.monotonic()
if progress is not None and (now - reported >= 0.25 or copied == total):
progress(f'Copying update files: {copied}/{total} ({copied * 100 // max(1, total)}%)', copied * 100 / max(1, total))
reported = now
return destination
started = time.monotonic()
shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True, copy_function=copy_file)
cloudlog.event('update copy complete', duration=time.monotonic() - started, files=copied)
if progress is not None:
progress('Verifying the prepared update...', None)
run(["git", "reset", "--hard", "--no-recurse-submodules"], FINALIZED)
validate_revision(FINALIZED, "HEAD")
cloudlog.info("Starting git cleanup in finalized update")
t = time.monotonic()
try:
run(["git", "gc"], FINALIZED)
cloudlog.event("Done git cleanup", duration=time.monotonic() - t)
except subprocess.CalledProcessError:
cloudlog.exception(f"Failed git cleanup, took {time.monotonic() - t:.3f} s")
if progress is not None:
progress('Syncing the prepared update to storage...', None)
set_consistent_flag(True)
cloudlog.info("done finalizing overlay")
@@ -292,11 +345,96 @@ def handle_agnos_update() -> None:
class Updater:
def __init__(self):
def __init__(self, install_settings=None):
self.params = Params()
self.branches: dict[str, str] = {}
self._branches_checked = False
self._has_internet: bool = False
self._progress_lock = threading.RLock()
self._metadata_lock = threading.Lock()
self._request_lock = threading.Lock()
self._check_thread = None
self._pending_check = None
self._closed = threading.Event()
self._checked_at = 0.0
self._checked_branch = None
self.wake_automatic = None
self.install_settings = install_settings if install_settings is not None else InstallSettings()
def maybe_install(self, idle=lambda: True) -> bool:
from zoneinfo import ZoneInfo
value = self.install_settings.read()
now = datetime.datetime.now(ZoneInfo(value.get('timezone', 'UTC') if value else 'UTC'))
if not value or not value['enabled'] or not system_time_valid() or not cron_matches(value['cron'], now):
return False
from openpilot.starpilot.drive_state.evidence import PhysicalSource
source = PhysicalSource()
def parked():
deadline = time.monotonic() + 1.5
while True:
if source.update_ready(self.params.get_bool('IsOffroad')):
return True
if time.monotonic() >= deadline:
return False
time.sleep(0.02)
def ready():
if (not idle() or self.params.get('UpdaterState') != 'idle' or
self._check_thread is not None or not self.update_ready or Path(FINALIZED, '.overlay_consistent').is_symlink()):
return False
validate_revision(FINALIZED, 'HEAD')
return True
try:
return scheduled_restart(self.params, self.install_settings, ready, parked, now)
except (OSError, ValueError, subprocess.SubprocessError):
cloudlog.exception('Scheduled update restart was not admitted')
return False
finally:
source.close()
def _write_progress(self, **values):
if not hasattr(self, '_progress_lock'):
self._progress_lock = threading.RLock()
with self._progress_lock:
previous = self.params.get('UpdaterFastState')
state = dict(previous) if isinstance(previous, dict) else {'version': 1, 'stage': 'complete', 'detail': ''}
state.update(values)
self.params.put('UpdaterFastState', state, block=True)
def progress(self, stage, detail, *, background=False, percent=None):
if percent is None and (match := re.search(r'(\d+)%', detail)):
percent = min(100, int(match[1]))
self._write_progress(version=1, stage=stage, detail=detail[:512], background=background, percent=percent)
def request_check(self, *, branch=None, commit=None, download_after=False):
with self._request_lock:
download_after = download_after or bool(self._pending_check and self._pending_check[2])
self._pending_check = (branch or self.target_branch, commit, download_after)
if self._check_thread is None:
self._check_thread = threading.Thread(target=self._run_checks, name='updater-metadata', daemon=True)
self._check_thread.start()
def _run_checks(self):
while not self._closed.is_set():
with self._request_lock:
request, self._pending_check = self._pending_check, None
if request is None:
self._check_thread = None
return
branch, commit, download_after = request
try:
if commit is not None and self.get_commit_hash(BASEDIR) != commit:
raise FastUpdateError('Installed revision changed; refresh and try again')
self.check_for_update(branch)
if (download_after and self.wake_automatic is not None and not self.params.get_bool('DoReboot') and
download_permitted(self.params, manual=False) and self.update_available and not self.latest_update_ready):
self.wake_automatic()
except Exception as error:
self._write_progress(check={'state': 'error', 'branch': branch, 'detail': str(error)[:512], 'completedAt': time.monotonic()})
def close(self):
self._closed.set()
if self._check_thread is not None:
self._check_thread.join(timeout=6)
@property
def has_internet(self) -> bool:
@@ -318,19 +456,28 @@ class Updater:
@property
def update_ready(self) -> bool:
consistent_file = Path(os.path.join(FINALIZED, ".overlay_consistent"))
if self._branches_checked and consistent_file.is_file() and self.target_branch in self.branches:
hash_mismatch = self.get_commit_hash(BASEDIR) != self.branches[self.target_branch]
branch_mismatch = self.get_branch(BASEDIR) != self.target_branch
on_target_branch = self.get_branch(FINALIZED) == self.target_branch
finalized_at_remote_head = self.get_commit_hash(FINALIZED) == self.branches[self.target_branch]
return ((hash_mismatch or branch_mismatch) and on_target_branch and finalized_at_remote_head)
branch, branches = self.target_branch, self.branches
if self._branches_checked and consistent_file.is_file() and branch in branches:
branch_mismatch = self.get_branch(BASEDIR) != branch
on_target_branch = self.get_branch(FINALIZED) == branch
staged = getattr(self, '_staged_target', None)
expected = staged[1] if staged is not None and staged[0] == branch else branches[branch]
hash_mismatch = self.get_commit_hash(BASEDIR) != expected
finalized_at_target = self.get_commit_hash(FINALIZED) == expected
return ((hash_mismatch or branch_mismatch) and on_target_branch and finalized_at_target)
return False
@property
def latest_update_ready(self) -> bool:
staged = getattr(self, '_staged_target', None)
return self.update_ready and (staged is None or staged == (self.target_branch, self.branches.get(self.target_branch)))
@property
def update_available(self) -> bool:
if self._branches_checked and os.path.isdir(OVERLAY_MERGED) and self.target_branch in self.branches:
hash_mismatch = self.get_commit_hash(OVERLAY_MERGED) != self.branches[self.target_branch]
branch_mismatch = self.get_branch(OVERLAY_MERGED) != self.target_branch
branch, branches = self.target_branch, self.branches
if self._branches_checked and branch in branches:
hash_mismatch = self.get_commit_hash(BASEDIR) != branches[branch]
branch_mismatch = self.get_branch(BASEDIR) != branch
return hash_mismatch or branch_mismatch
return False
@@ -410,41 +557,70 @@ class Updater:
remaining = max(HOURS_NO_CONNECTIVITY_MAX - dt_uptime_onroad, 1)
set_offroad_alert("Offroad_ConnectivityNeededPrompt", True, extra_text=f"{remaining} hour{'' if remaining == 1 else 's'}.")
def check_for_update(self) -> None:
def check_for_update(self, branch: str | None = None) -> None:
branch = branch or self.target_branch
with self._metadata_lock:
self._write_progress(check={'state': 'checking', 'branch': branch, 'detail': 'Fetching version metadata...'})
try:
self._check_for_update(branch)
self._checked_at, self._checked_branch = time.monotonic(), branch
self._write_progress(check={'state': 'complete', 'branch': branch, 'detail': 'Version metadata refreshed.',
'completedAt': time.monotonic()})
except Exception as error:
self._write_progress(check={'state': 'error', 'branch': branch, 'detail': str(error)[:512], 'completedAt': time.monotonic()})
raise
def _check_for_update(self, branch) -> None:
cloudlog.info("checking for updates")
excluded_branches = ('release2', 'release2-staging')
self._branches_checked = False
self.params.put("UpdaterOSUpdate", {}, block=True)
setup_git_options(OVERLAY_MERGED)
metadata = Path(METADATA_ROOT)
metadata.parent.mkdir(parents=True, exist_ok=True)
if not (metadata / 'HEAD').is_file():
run(['git', 'init', '--bare', str(metadata)], str(metadata.parent))
remote = run(['git', 'remote', 'get-url', 'origin'], BASEDIR).strip()
run(['git', 'config', 'remote.origin.url', remote], str(metadata))
setup_git_options(str(metadata))
try:
output = run(["git", "ls-remote", "--heads", "origin"], OVERLAY_MERGED)
output = run(["git", "ls-remote", "--heads", "origin"], str(metadata), cancelled=self._closed.is_set, timeout=30)
except subprocess.CalledProcessError:
self._has_internet = False
raise
self._has_internet = True
self.branches.clear()
branches = {}
for line in output.split('\n'):
ls_remotes_re = r'(?P<commit_sha>\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P<branch_name>.*$)'
x = re.fullmatch(ls_remotes_re, line.strip())
if x is not None and x.group('branch_name') not in excluded_branches:
self.branches[x.group('branch_name')] = x.group('commit_sha')
cur_branch = self.get_branch(OVERLAY_MERGED)
cur_commit = self.get_commit_hash(OVERLAY_MERGED)
branches[x.group('branch_name')] = x.group('commit_sha')
self.branches = branches
cur_branch = self.get_branch(BASEDIR)
cur_commit = self.get_commit_hash(BASEDIR)
self._branches_checked = True
write_time_to_param(self.params, "UpdaterLastCheckTime")
new_branch = self.target_branch
new_branch = branch or self.target_branch
new_commit = self.branches.get(new_branch)
if new_commit is None:
cloudlog.info("target branch %s is not published on origin; no update available", new_branch)
self.params.put('UpdaterAvailableBranches', ','.join(self.branches), block=True)
self.params.put_bool('UpdaterFetchAvailable', False, block=True)
self.params.put_bool('UpdateAvailable', False, block=True)
write_time_to_param(self.params, 'UpdaterLastCheckTime')
return
# Fetch history and trees, not artifact contents, for the version list.
def metadata_progress(detail):
self._write_progress(check={'state': 'checking', 'branch': branch, 'detail': detail[:512]})
run(["git", "fetch", '--progress', f"--depth={MAX_COMMITS}", "--filter=blob:none", "--no-tags", "--no-recurse-submodules",
"origin", f"+{new_commit}:refs/remotes/origin/{new_branch}"], str(metadata),
progress=metadata_progress, cancelled=self._closed.is_set, timeout=30)
run(["git", "update-ref", f"refs/starpilot/recent/{new_branch}", new_commit], str(metadata))
if AGNOS:
try:
if cur_commit != new_commit:
run(["git", "fetch", "--depth=1", "--filter=blob:none", "--no-tags", "--no-recurse-submodules", "origin", new_commit], OVERLAY_MERGED)
version = required_os_version(run(["git", "show", f"{new_commit}:launch_env.sh"], OVERLAY_MERGED))
version = required_os_version(run(["git", "show", f"{new_commit}:launch_env.sh"], str(metadata),
download=True, cancelled=self._closed.is_set, timeout=30))
current_os = HARDWARE.get_os_version()
if version and current_os:
self.params.put("UpdaterOSUpdate", {"branch": new_branch, "required": version != current_os}, block=True)
@@ -454,8 +630,12 @@ class Updater:
cloudlog.info(f"update available, {cur_branch} ({str(cur_commit)[:7]}) -> {new_branch} ({str(new_commit)[:7]})")
else:
cloudlog.info(f"up to date on {cur_branch} ({str(cur_commit)[:7]})")
self.params.put("UpdaterAvailableBranches", ','.join(self.branches), block=True)
self.params.put_bool("UpdaterFetchAvailable", self.update_available, block=True)
self.params.put_bool("UpdateAvailable", self.update_ready, block=True)
write_time_to_param(self.params, 'UpdaterLastCheckTime')
def fast_update(self, branch: str, commit: str, selected_commit=None, *, rollback=False, versions_only=False, automatic=False) -> str:
def fast_update(self, branch: str, commit: str, selected_commit=None, *, rollback=False, versions_only=False, automatic=False, download_only=False) -> str:
from openpilot.starpilot.drive_state.evidence import PhysicalSource
from openpilot.starpilot.software.fast_update import fast_update
@@ -478,11 +658,47 @@ class Updater:
self._branches_checked = False
def progress(stage, detail):
self.params.put("UpdaterFastState", {"version": 1, "stage": stage, "detail": detail[:512], "branch": branch}, block=True)
self.progress(stage, detail)
try:
if versions_only:
initial_branch = self.get_branch(BASEDIR)
def admitted():
if not parked() or self.get_commit_hash(BASEDIR) != commit or self.get_branch(BASEDIR) != initial_branch:
raise FastUpdateError("Update source or parked state changed")
admitted()
run(["git", "check-ref-format", "--branch", branch], BASEDIR)
progress('fetching', 'Fetching version metadata...')
self.check_for_update(branch)
admitted()
if branch not in self.branches:
raise FastUpdateError("Target branch is no longer available")
progress('complete', 'Recent versions are ready to review.')
return 'versions-listed'
if download_only:
def admitted():
if not parked() or self.get_commit_hash(BASEDIR) != commit:
raise FastUpdateError('Installed revision or parked state changed; refresh and try again')
admitted()
self.check_for_update(branch)
members = commit_history(Path(METADATA_ROOT), self.branches.get(branch, ''), first_parent=True)
if branch == self.get_branch(BASEDIR):
members += commit_history(Path(BASEDIR), commit, first_parent=True)
if not any(row['hash'] == selected_commit for row in members):
raise FastUpdateError('Selected version is no longer in branch history; refresh and try again')
admitted()
self.params.put('UpdaterTargetBranch', branch, block=True)
if not self.fetch_update(selected_commit=selected_commit, admitted=admitted):
raise FastUpdateError('Selected version could not be downloaded')
admitted()
self.set_params(True, 0, None)
return 'downloaded'
def installed_progress(stage, detail):
if stage == 'complete':
self.params.put('UpdaterTargetBranch', self.get_branch(BASEDIR), block=True)
progress(stage, detail)
return fast_update(Path(BASEDIR), branch, expected_commit=commit, params=self.params, parked=parked,
current_os=HARDWARE.get_os_version(), invalidate=invalidate, rollback=rollback, progress=progress,
current_os=HARDWARE.get_os_version(), invalidate=invalidate, rollback=rollback, progress=installed_progress,
selected_commit=selected_commit, versions_only=versions_only)
except FastUpdateIncompatible:
if not automatic:
@@ -499,7 +715,6 @@ class Updater:
admitted()
progress("preparing", "Preparing full software update")
self.params.put("UpdaterTargetBranch", branch, block=True)
init_overlay()
self._branches_checked = False
self.params.put("UpdaterState", "checking...", block=True)
if phone_uplink_active():
@@ -513,10 +728,10 @@ class Updater:
self.params.put_bool("DoReboot", True, block=True)
return "reboot-requested"
def fetch_update(self) -> bool:
def fetch_update(self, *, background=False, selected_commit=None, admitted=None) -> bool:
if phone_uplink_active():
return False
if not self._branches_checked:
if not self._branches_checked or self._checked_branch != self.target_branch:
self.check_for_update()
if self.target_branch not in self.branches:
cloudlog.info("skipping fetch of unpublished target branch %s", self.target_branch)
@@ -525,6 +740,11 @@ class Updater:
cloudlog.info("attempting git fetch inside staging overlay")
self.params.put("UpdaterState", "downloading...", block=True)
self.progress('preparing', 'Preparing download staging...', background=background)
init_overlay()
self.progress('fetching', 'Downloading the selected version...', background=background)
def git_progress(detail):
self.progress('fetching', detail, background=background)
# TODO: cleanly interrupt this and invalidate old update
set_consistent_flag(False)
@@ -535,22 +755,37 @@ class Updater:
run(["git", "config", "--replace-all", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], OVERLAY_MERGED)
branch = self.target_branch
git_fetch_output = run(["git", "fetch", "--no-recurse-submodules", "origin", branch], OVERLAY_MERGED)
cloudlog.info("git fetch success: %s", git_fetch_output)
# Validate using the running updater before checking out or executing any update.
# A branch containing gitlinks or an invalid dependency manifest is not installable.
fetched_commit = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], OVERLAY_MERGED).strip()
fetched_commit = selected_commit or self.branches[branch]
run(['git', 'fetch', '--progress', '--depth=1', '--filter=blob:none', '--no-tags', '--no-recurse-submodules',
'origin', f'+{fetched_commit}:refs/remotes/origin/{branch}'], OVERLAY_MERGED, progress=git_progress)
# Fetch the manifest under the download guard before read-only validation.
if run(["git", "ls-tree", fetched_commit, "--", "upstream-sync.json"], OVERLAY_MERGED).split()[:1] == ['100644']:
run(["git", "show", f"{fetched_commit}:upstream-sync.json"], OVERLAY_MERGED, download=True)
validate_revision(OVERLAY_MERGED, fetched_commit)
def git(*args, stdin=None):
return run(['git', *args], OVERLAY_MERGED, progress=git_progress, stdin=stdin)
fetch_files(git, fetched_commit)
run(["git", "update-ref", f"refs/starpilot/recent/{branch}", fetched_commit], OVERLAY_MERGED)
if admitted is not None:
admitted()
cloudlog.info("git reset in progress")
self.progress('applying', 'Writing downloaded files...', background=background)
def apply_progress(detail):
self.progress('applying', detail, background=background)
installed_commit = self.get_commit_hash(BASEDIR)
installed_branch = self.get_branch(BASEDIR)
run(['git', 'update-ref', 'refs/starpilot/previous', installed_commit], OVERLAY_MERGED)
run(['git', 'config', '--local', 'starpilot.previousVersion', f'{installed_commit} {installed_branch}'], OVERLAY_MERGED)
cmds = [
["git", "checkout", "--force", "--no-recurse-submodules", "-B", branch, fetched_commit],
["git", "checkout", '--progress', "--force", "--no-recurse-submodules", "-B", branch, fetched_commit],
["git", "branch", "--set-upstream-to", f"origin/{branch}"],
["git", "reset", "--hard", "--no-recurse-submodules"],
["git", "clean", "-xdff"],
]
r = [run(cmd, OVERLAY_MERGED) for cmd in cmds]
r = [run(cmd, OVERLAY_MERGED, download=cmd[1] in ('checkout', 'reset'), progress=apply_progress) for cmd in cmds]
validate_revision(OVERLAY_MERGED, "HEAD")
cloudlog.info("git reset success: %s", '\n'.join(r))
@@ -560,18 +795,27 @@ class Updater:
# Create the finalized, ready-to-swap update
self.params.put("UpdaterState", "finalizing update...", block=True)
finalize_update()
self.progress('finalizing', 'Preparing the downloaded update...', background=background)
finalize_update(progress=lambda detail, percent: self.progress('finalizing', detail, background=background, percent=percent))
self._staged_target = (branch, fetched_commit)
self.progress('complete', 'Downloaded update is ready to install.', background=background)
cloudlog.info("finalize success!")
return True
def recover_fast_state(params):
previous = params.get("UpdaterFastState")
if isinstance(previous, dict) and previous.get("stage") not in ("complete", "error"):
params.put("UpdaterFastState", {
"version": 1, "stage": "error",
"detail": "Update process restarted before completion; check the installed version and retry.",
}, block=True)
if not isinstance(previous, dict):
return
state = dict(previous)
if state.get('check', {}).get('state') == 'checking':
state['check'] = {**state['check'], 'state': 'error', 'completedAt': time.monotonic(),
'detail': 'Metadata check was interrupted when the updater restarted. Retry the check.'}
if state.get('stage') not in ('complete', 'error'):
state.update(version=1, stage='error',
detail='Update process restarted before completion; check the installed version and retry.')
if state != previous:
params.put('UpdaterFastState', state, block=True)
def main() -> None:
@@ -597,7 +841,10 @@ def main() -> None:
updater = Updater()
update_failed_count = max(0, params.get("UpdateFailedCount") or 0)
wait_helper = WaitTimeHelper()
wait_helper = WaitTimeHelper(metadata=updater.request_check)
wait_helper.idle_tick = lambda: updater.maybe_install(lambda: not wait_helper.ready_event.is_set())
updater.wake_automatic = wait_helper.wake_automatic
atexit.register(updater.close)
# invalidate old finalized update
set_consistent_flag(False)
@@ -613,14 +860,18 @@ def main() -> None:
# Attempt an update
exception = None
fast_requested = requested in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION)
fast_requested = requested in (
UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION,
)
try:
if fast_requested:
update_failed_count += 1
params.put("UpdaterState", "updating...", block=True)
params.put("UpdaterState", "downloading..." if requested == UserRequest.DOWNLOAD_VERSION else "updating...", block=True)
if fast_target is None:
raise ValueError("Update source identity is required")
if requested == UserRequest.VERSIONS:
if requested == UserRequest.DOWNLOAD_VERSION:
updater.fast_update(*fast_target, download_only=True)
elif requested == UserRequest.VERSIONS:
updater.fast_update(*fast_target, versions_only=True)
elif requested == UserRequest.UPDATE:
updater.fast_update(*fast_target, automatic=True)
@@ -632,9 +883,6 @@ def main() -> None:
if requested != UserRequest.VERSIONS:
write_time_to_param(params, "LastUpdateTime")
else:
# TODO: reuse overlay from previous updated instance if it looks clean
init_overlay()
# ensure we have some params written soon after startup
updater.set_params(False, update_failed_count, exception)
@@ -646,21 +894,27 @@ def main() -> None:
update_failed_count += 1
# check for update
params.put("UpdaterState", "checking...", block=True)
updater.check_for_update()
if requested == UserRequest.CHECK or updater._checked_branch != updater.target_branch or time.monotonic() - updater._checked_at > 60:
updater.check_for_update()
# download update
last_fetch = params.get("UpdaterLastFetchTime")
timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3))
user_requested_fetch = requested == UserRequest.FETCH
if phone_uplink_active():
if params.get_bool('DoReboot'):
cloudlog.info('skipping fetch, restart pending')
elif requested == UserRequest.CHECK:
cloudlog.info('metadata-only check complete')
elif phone_uplink_active():
cloudlog.info("skipping fetch, phone uplink forbids updates")
elif params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch:
cloudlog.info("skipping fetch, connection metered")
elif not download_permitted(params, manual=user_requested_fetch, check_only=requested == UserRequest.CHECK):
cloudlog.info("skipping fetch, check-only request or automatic downloads disabled")
elif not download_permitted(params, manual=user_requested_fetch):
cloudlog.info("skipping fetch, automatic downloads disabled")
elif not user_requested_fetch and (updater.latest_update_ready or not updater.update_available):
cloudlog.info("skipping fetch, checked version is already installed or staged")
else:
if updater.fetch_update():
if updater.fetch_update(background=not user_requested_fetch):
write_time_to_param(params, "UpdaterLastFetchTime")
update_failed_count = 0
except subprocess.CalledProcessError as e:
@@ -683,7 +937,7 @@ def main() -> None:
update_successful = (update_failed_count == 0)
if fast_requested:
if exception is not None:
params.put("UpdaterFastState", {"version": 1, "stage": "error", "detail": exception[:512]}, block=True)
updater.progress('error', exception)
params.put("UpdateFailedCount", update_failed_count, block=True)
if exception is None:
params.remove("LastUpdateException")