diff --git a/frogpilot/system/the_pond/assets/components/home/home.css b/frogpilot/system/the_pond/assets/components/home/home.css index ca70e2bc0..2b707acfd 100644 --- a/frogpilot/system/the_pond/assets/components/home/home.css +++ b/frogpilot/system/the_pond/assets/components/home/home.css @@ -75,4 +75,4 @@ gap: 0.5em 1em; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } -} \ No newline at end of file +} diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js index b2a9910f6..a4b99f5f1 100644 --- a/frogpilot/system/the_pond/assets/components/router.js +++ b/frogpilot/system/the_pond/assets/components/router.js @@ -17,6 +17,7 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603 import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { TmuxLog } from "/assets/components/tools/tmux.js" import { ToggleControl } from "/assets/components/tools/toggles.js" +import { UpdateManager } from "/assets/components/tools/update_manager.js" let router, routerState @@ -45,6 +46,7 @@ function Root() { createRoute("thememaker", "/theme_maker", ThemeMaker), createRoute("tmux", "/manage_tmux", TmuxLog), createRoute("toggles", "/manage_toggles", ToggleControl), + createRoute("updates", "/manage_updates", UpdateManager), createRoute("vehicle_features", "/vehicle_features", VehicleFeatures), ] diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js index e07bf07b9..d37bb99d4 100644 --- a/frogpilot/system/the_pond/assets/components/sidebar.js +++ b/frogpilot/system/the_pond/assets/components/sidebar.js @@ -23,6 +23,7 @@ const MenuItems = { { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" }, { name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" }, { name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" }, + { name: "Software", link: "/manage_updates", icon: "bi-arrow-up-circle" }, { name: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" }, ], }; diff --git a/frogpilot/system/the_pond/assets/components/tools/update_manager.css b/frogpilot/system/the_pond/assets/components/tools/update_manager.css new file mode 100644 index 000000000..1b63baced --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/update_manager.css @@ -0,0 +1,206 @@ +.updateManager { + max-width: var(--width-xxl); +} + +.updateActions { + display: flex; + gap: var(--gap-sm); + margin-top: var(--margin-base); +} + +.updateButton { + background: var(--main-fg); + border: none; + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + padding: var(--padding-sm) var(--padding-base); +} + +.updateButton.danger { + background: var(--danger-bg); +} + +.updateButton.danger:hover { + background: var(--danger-hover-bg); +} + +.updateButton:hover { + transform: var(--hover-scale-sm); + transition: transform var(--transition-fast), background-color var(--transition-fast); +} + +.updateButton:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; +} + +.updateCard { + background-color: var(--secondary-bg); + border-radius: var(--border-radius-md); + color: var(--text-color); + max-width: 80%; + padding: var(--border-radius-xl); +} + +.updateError { + color: var(--danger-fg); + margin-top: var(--margin-sm); +} + +.updateGrid { + display: grid; + gap: 0.5em 1em; + grid-template-columns: repeat(3, 1fr); +} + +.updateGrid p { + font-size: var(--font-size-sm); + margin: 0; +} + +.updateLink { + color: var(--main-fg); + display: inline-block; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); +} + +.updateMessage { + margin-top: var(--margin-sm); +} + +.updateProgressCard { + margin-top: var(--margin-base); +} + +.updateProgressDetail { + font-size: var(--font-size-sm); + margin-top: var(--margin-sm); +} + +.updateProgressDetail.error { + color: var(--danger-fg); +} + +.updateProgressFill { + background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%); + border-radius: var(--border-radius-sm); + height: 100%; + transition: width var(--transition-base); +} + +.updateProgressFill.error { + background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%); +} + +.updateProgressHeader { + display: flex; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + justify-content: space-between; + margin-bottom: var(--margin-xs); +} + +.updateProgressTrack { + background: var(--track-color); + border-radius: var(--border-radius-sm); + height: 12px; + overflow: hidden; +} + +.updateProgressTrack.error { + border: 1px solid var(--danger-bg); +} + +.updateToggleRow { + align-items: center; + display: flex; + gap: var(--gap-sm); + justify-content: space-between; + margin-top: var(--margin-base); +} + +.updateWarning { + margin-top: var(--margin-base); +} + +.updateHint { + font-size: var(--font-size-sm); + margin-top: var(--margin-sm); +} + +.updateRebootNotice { + font-weight: var(--font-weight-bold); +} + +.updateFooter { + border-top: 1px solid var(--track-color); + margin-top: var(--margin-base); + padding-top: var(--margin-sm); +} + +.updateBranchSection { + margin-top: var(--margin-base); +} + +.updateAdvancedNote { + font-size: var(--font-size-sm); + margin-top: var(--margin-sm); +} + +.updateBranchHeader { + align-items: center; + display: flex; + font-size: var(--font-size-sm); + justify-content: space-between; +} + +.updateBranchRow { + align-items: center; + display: flex; + gap: var(--gap-sm); + margin-top: var(--margin-sm); +} + +.updateSelect { + background-color: var(--main-bg); + border: 1px solid var(--track-color); + border-radius: var(--border-radius-sm); + color: var(--text-color); + flex: 1; + font-size: var(--font-size-sm); + min-width: 0; + padding: var(--padding-sm); +} + +.updateSelect:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.updateToggleRow input:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +@media only screen and (max-width: 768px) and (orientation: portrait) { + .updateActions { + flex-direction: column; + } + + .updateBranchRow { + flex-direction: column; + align-items: stretch; + } + + .updateCard { + max-width: 100%; + } + + .updateGrid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } +} diff --git a/frogpilot/system/the_pond/assets/components/tools/update_manager.js b/frogpilot/system/the_pond/assets/components/tools/update_manager.js new file mode 100644 index 000000000..27b4c6f7c --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/update_manager.js @@ -0,0 +1,603 @@ +import { html, reactive } from "https://esm.sh/@arrow-js/core" + +const state = reactive({ + loading: true, + error: "", + status: null, + checkedForUpdates: false, + checkBusy: false, + updateBusy: false, + toggleBusy: false, + showAdvancedOptions: false, + branches: [], + selectedBranch: "", + hasManualBranchSelection: false, + branchesError: "", + branchesBusy: false, + switchBusy: false, +}) + +let initialized = false +let pollHandle = null +let reconnectPending = false +let rebootNoticeShown = false +const POLL_INTERVAL_MS = 1000 +const ADVANCED_OPTIONS_KEY = "softwareShowAdvancedOptions" + +function shortHash(value) { + const text = String(value || "").trim() + return text ? text.slice(0, 10) : "Unknown" +} + +function toPercent(value) { + const n = Number(value) + if (!Number.isFinite(n)) return 0 + return Math.max(0, Math.min(100, n)) +} + +function findBranchInList(branches, target) { + const wanted = String(target || "").trim() + if (!wanted || !Array.isArray(branches) || branches.length === 0) return "" + if (branches.includes(wanted)) return wanted + + const wantedLower = wanted.toLowerCase() + const caseInsensitive = branches.find((branch) => String(branch || "").toLowerCase() === wantedLower) + return caseInsensitive || "" +} + +function isSelectedBranchDifferent() { + const currentBranch = String(state.status?.branch || "").trim() + const selectedBranch = String(state.selectedBranch || "").trim() + return !!currentBranch && !!selectedBranch && currentBranch !== selectedBranch +} + +function normalizeGithubRemote(remoteValue, commitsUrlValue = "") { + let remote = String(remoteValue || "").trim() + if (!remote) { + const commitsUrl = String(commitsUrlValue || "").trim() + const marker = "/commits/" + const markerIndex = commitsUrl.indexOf(marker) + if (commitsUrl.startsWith("https://github.com/") && markerIndex > 0) { + remote = commitsUrl.slice(0, markerIndex) + } else { + return "" + } + } + + if (remote.startsWith("git@github.com:")) { + remote = `https://github.com/${remote.split(":", 2)[1] || ""}` + } else if (remote.startsWith("ssh://git@github.com/")) { + remote = `https://github.com/${remote.split("ssh://git@github.com/", 2)[1] || ""}` + } else if (remote.startsWith("http://github.com/")) { + remote = `https://github.com/${remote.split("http://github.com/", 2)[1] || ""}` + } + + if (!remote.startsWith("https://github.com/")) return "" + remote = remote.replace(/\/+$/, "") + if (remote.endsWith(".git")) remote = remote.slice(0, -4) + return remote +} + +function currentOrSelectedBranchForCommits() { + const selected = String(state.selectedBranch || "").trim() + if (selected) return selected + return String(state.status?.branch || "").trim() +} + +function activeCommitsUrl() { + const branch = currentOrSelectedBranchForCommits() + if (!branch) return "" + + const remote = normalizeGithubRemote(state.status?.originRemote, state.status?.commitsUrl) + if (!remote) return "" + return `${remote}/commits/${encodeURIComponent(branch)}/` +} + +function activeCommitsLabel() { + const branch = currentOrSelectedBranchForCommits() + return branch + ? `View latest commit for the "${branch}" branch` + : "View latest commit for this branch" +} + +function shouldShowPrimaryUpdateAction() { + if (state.status?.running) return true + if (isSelectedBranchDifferent()) return true + return !!state.checkedForUpdates && !!state.status?.updateAvailable +} + +function shouldContinuePolling() { + return !!state.status?.running || state.status?.stage === "rebooting" || reconnectPending +} + +function shouldShowRebootNotice() { + if (state.status?.stage === "rebooting") return true + if (reconnectPending) return true + const message = String(state.status?.message || "").toLowerCase() + return message.includes("reboot") +} + +function isHtmlLike(text) { + const value = String(text || "").trim() + return value.startsWith(" { + await fetchStatus(false) + if (shouldContinuePolling()) { + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) + } else { + pollHandle = null + } + } + + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) +} + +async function fetchStatus(showToast) { + if (showToast) state.checkBusy = true + try { + const response = await fetch("/api/update/fast/status") + const payload = await readJsonPayload(response) + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to load update status") + } + + state.status = payload + state.error = "" + + if (reconnectPending && !payload.running && payload.stage !== "rebooting") { + reconnectPending = false + rebootNoticeShown = false + showSnackbar("Connection reestablished.") + } + + if (payload.stage === "rebooting" && !rebootNoticeShown) { + rebootNoticeShown = true + showSnackbar("Device rebooting, please wait for reconnection...") + } + + if (!state.selectedBranch && payload.branch) { + state.selectedBranch = payload.branch + } + + if (shouldContinuePolling()) { + ensurePolling() + } else { + stopPolling() + } + + if (showToast) { + state.checkedForUpdates = true + showSnackbar(payload.updateAvailable ? "Update available." : "No update available.") + } + } catch (error) { + const isRebootTransitionError = !showToast + && (state.status?.running || state.status?.stage === "rebooting" || reconnectPending) + && (isHtmlLike(error?.bodyText) || isRebootRelatedConnectionError(error)) + + if (isRebootTransitionError) { + reconnectPending = true + setRebootingUiState("Device rebooting, please wait for reconnection...") + if (!rebootNoticeShown) { + rebootNoticeShown = true + showSnackbar("Device rebooting, please wait for reconnection...") + } + ensurePolling() + return + } + + const message = error?.message || String(error) + state.error = message + if (showToast) { + showSnackbar(message, "error") + } + } finally { + if (showToast) state.checkBusy = false + } +} + +async function fetchBranches(showToast = false) { + state.branchesBusy = true + try { + const response = await fetch("/api/update/branches") + const payload = await readJsonPayload(response) + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to load branches") + } + + const branches = Array.isArray(payload.branches) ? payload.branches : [] + const currentBranchRaw = String(payload.currentBranch || state.status?.branch || "").trim() + const matchedCurrentBranch = findBranchInList(branches, currentBranchRaw) + state.branches = branches + state.branchesError = payload.remoteError || "" + + if (branches.length === 0) { + state.selectedBranch = currentBranchRaw + state.hasManualBranchSelection = false + } else if (matchedCurrentBranch && !state.hasManualBranchSelection) { + // Default to the branch currently installed unless the user picked one explicitly. + state.selectedBranch = matchedCurrentBranch + } else if (!state.selectedBranch || !branches.includes(state.selectedBranch)) { + state.selectedBranch = matchedCurrentBranch || branches[0] + state.hasManualBranchSelection = false + } + + if (showToast) { + showSnackbar(branches.length ? `Loaded ${branches.length} branches.` : "No branches found.") + } + } catch (error) { + const message = error?.message || "Failed to load branches" + state.branchesError = message + if (showToast) { + showSnackbar(message, "error") + } + } finally { + state.branchesBusy = false + } +} + +function setAdvancedOptions(enabled) { + const next = !!enabled + state.showAdvancedOptions = next + + if (!next) { + const currentBranch = String(state.status?.branch || "").trim() + if (currentBranch) { + state.selectedBranch = currentBranch + } + state.hasManualBranchSelection = false + } + + try { + localStorage.setItem(ADVANCED_OPTIONS_KEY, next ? "1" : "0") + } catch (error) { + console.warn("Failed to persist advanced options preference", error) + } + + if (next && state.branches.length === 0 && !state.branchesBusy) { + fetchBranches(false) + } +} + +async function setAutomaticUpdates(enabled) { + if (state.toggleBusy) return + if (state.status?.isOnroad) { + showSnackbar("Actions are blocked while onroad.", "error") + return + } + state.toggleBusy = true + try { + const response = await fetch("/api/params", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: "AutomaticUpdates", value: !!enabled }), + }) + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to update Automatic Updates") + } + + state.status = { ...(state.status || {}), automaticUpdates: !!enabled } + showSnackbar(payload.message || "Automatic updates updated.") + } catch (error) { + showSnackbar(error?.message || "Failed to update Automatic Updates", "error") + await fetchStatus(false) + } finally { + state.toggleBusy = false + } +} + +async function runFastUpdate() { + if (state.updateBusy) return + if (state.status?.running) { + showSnackbar("Fast update is already running.") + return + } + if (state.status?.isOnroad) { + showSnackbar("Actions are blocked while onroad.", "error") + return + } + + const confirmed = window.confirm( + "Fast update warning:\n\n" + + "- This update method skips backup creation.\n" + + "- Your device will reboot when the update is done.\n\n" + + "Continue with fast update?" + ) + if (!confirmed) return + + state.updateBusy = true + try { + reconnectPending = false + rebootNoticeShown = false + const response = await fetch("/api/update/fast", { method: "POST" }) + const payload = await readJsonPayload(response) + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to start fast update") + } + + state.status = { + ...(state.status || {}), + running: true, + stage: "starting", + progressStep: 1, + progressTotalSteps: state.status?.progressTotalSteps || 5, + progressStepPercent: 0, + progressPercent: 0, + progressLabel: "Preparing update", + progressDetail: "Initializing update process...", + message: payload.message || "Fast update started. Device will reboot when complete.", + lastError: "", + } + state.error = "" + showSnackbar(payload.message || "Fast update started.") + await fetchStatus(false) + ensurePolling() + } catch (error) { + showSnackbar(error?.message || "Failed to start fast update", "error") + } finally { + state.updateBusy = false + } +} + +async function runBranchSwitch() { + if (state.switchBusy) return + if (state.status?.running) { + showSnackbar("An update action is already running.") + return + } + if (state.status?.isOnroad) { + showSnackbar("Actions are blocked while onroad.", "error") + return + } + + const targetBranch = String(state.selectedBranch || "").trim() + if (!targetBranch) { + showSnackbar("Select a target branch first.", "error") + return + } + + const currentBranch = String(state.status?.branch || "").trim() + const actionLabel = currentBranch && currentBranch === targetBranch ? "update" : "switch and update" + const confirmed = window.confirm( + `This will ${actionLabel} to the '${targetBranch}' branch.\n\n` + + "- This update method skips backup creation.\n" + + "- Your device will reboot when the update is done.\n\n" + + "Continue?" + ) + if (!confirmed) return + + state.switchBusy = true + try { + reconnectPending = false + rebootNoticeShown = false + const response = await fetch("/api/update/branch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branch: targetBranch }), + }) + const payload = await readJsonPayload(response) + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to start branch switch") + } + + state.status = { + ...(state.status || {}), + running: true, + stage: "starting", + progressStep: 1, + progressTotalSteps: state.status?.progressTotalSteps || 5, + progressStepPercent: 0, + progressPercent: 0, + progressLabel: "Preparing branch switch", + progressDetail: "Initializing branch switch...", + message: payload.message || `Branch switch started for '${targetBranch}'. Device will reboot when complete.`, + lastError: "", + } + state.error = "" + showSnackbar(payload.message || "Branch switch started.") + await fetchStatus(false) + ensurePolling() + } catch (error) { + showSnackbar(error?.message || "Failed to start branch switch", "error") + } finally { + state.switchBusy = false + } +} + +function initialize() { + if (initialized) return + initialized = true + + try { + state.showAdvancedOptions = localStorage.getItem(ADVANCED_OPTIONS_KEY) === "1" + } catch (error) { + state.showAdvancedOptions = false + } + + const initTasks = [fetchStatus(false)] + if (state.showAdvancedOptions) { + initTasks.push(fetchBranches(false)) + } + + Promise.all(initTasks).finally(() => { + state.loading = false + }) +} + +export function UpdateManager() { + initialize() + + return html` +
+

Software

+ + ${() => state.loading ? html`
Loading update status...
` : ""} + ${() => !state.loading ? html` +
+
+

Current Branch: ${state.status?.branch || "Unknown"}

+

Installed Commit: ${shortHash(state.status?.localCommit)}

+

Latest Commit: ${shortHash(state.status?.remoteCommit)}

+

Update Available: ${state.status?.updateAvailable ? "Yes" : "No"}

+

Status: ${state.status?.stage || "idle"}

+

Onroad: ${state.status?.isOnroad ? "Yes" : "No"}

+
+ +
+
+ + Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}: + ${state.status?.progressLabel || "Idle"} + + ${Math.round(toPercent(state.status?.progressPercent))}% +
+
+
+
+ ${() => state.status?.progressDetail ? html`

${state.status.progressDetail}

` : ""} +
+ + ${() => state.status?.isOnroad ? html`

Onroad: actions disabled

` : ""} + + + + + + ${() => state.showAdvancedOptions ? html` +
+
+ Branch switching + ${state.branchesBusy ? html`Loading...` : ""} +
+

+ For advanced users only. Switching to test/dev branches can introduce instability. +

+
+ + +
+ ${() => state.branchesError ? html`

Branch List: ${state.branchesError}

` : ""} +
+ ` : ""} + + ${() => state.status?.message && state.status?.stage !== "rebooting" ? html`

${state.status.message}

` : ""} + ${() => state.status?.remoteError ? html`

Remote Check: ${state.status.remoteError}

` : ""} + ${() => state.status?.lastError ? html`

Last Error: ${state.status.lastError}

` : ""} + ${() => state.error ? html`

Error: ${state.error}

` : ""} + +
+ ${() => !isSelectedBranchDifferent() ? html` + + ` : ""} + ${() => shouldShowPrimaryUpdateAction() ? html` + + ` : ""} +
+ ${() => !state.status?.running && !shouldShowPrimaryUpdateAction() + ? html`

Run Check for Updates first, or select a different branch in advanced options.

` + : ""} + ${() => shouldShowRebootNotice() + ? html`

Device rebooting, please wait for reconnection...

` + : ""} + ${() => activeCommitsUrl() ? html` + + ` : ""} +
+ ` : ""} +
+ ` +} diff --git a/frogpilot/system/the_pond/templates/index.html b/frogpilot/system/the_pond/templates/index.html index 5c5e5c4cb..8060a1ad4 100644 --- a/frogpilot/system/the_pond/templates/index.html +++ b/frogpilot/system/the_pond/templates/index.html @@ -32,6 +32,7 @@ + diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index b7e865550..88d79b9ec 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -14,11 +14,14 @@ import os import re import requests import secrets +import selectors import shutil import signal import subprocess +import threading import time import traceback +from urllib.parse import quote from cereal import car, messaging from opendbc.can.parser import CANParser @@ -134,9 +137,558 @@ _FINGERPRINT_VALID_NAME_RE = re.compile(r'^[A-Za-z0-9 \u0160.()\-]+$') _openpilot_root_cache = None _fingerprint_catalog_cache = None +_fast_update_lock = threading.Lock() +_FAST_UPDATE_TOTAL_STEPS = 5 +_FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S = 5.0 +_FAST_UPDATE_REBOOT_NOTICE_SECONDS = 6.0 +_FAST_UPDATE_FETCH_TIMEOUT_S = 60 +_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S = 60 +_GIT_PROGRESS_PERCENT_RE = re.compile(r'([A-Za-z][A-Za-z /_-]+):\s*([0-9]{1,3})%') +_GIT_SUBMODULE_SECTION_RE = re.compile(r'^\s*\[submodule\s+"[^"]+"\]\s*$', re.MULTILINE) +_fast_update_state = { + "running": False, + "stage": "idle", + "message": "", + "lastError": "", + "lastBranch": "", + "lastMode": "", + "startedAt": 0.0, + "finishedAt": 0.0, + "progressStep": 0, + "progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS, + "progressStepPercent": 0.0, + "progressPercent": 0.0, + "progressLabel": "Idle", + "progressDetail": "", +} + def _normalize_fingerprint_make_key(make_value): return str(make_value or "").strip().lower() +def _set_fast_update_state(**kwargs): + with _fast_update_lock: + _fast_update_state.update(kwargs) + +def _get_fast_update_state(): + with _fast_update_lock: + return dict(_fast_update_state) + +def _set_fast_update_progress(step, label, step_percent=0.0, detail=""): + safe_step = max(1, min(_FAST_UPDATE_TOTAL_STEPS, int(step))) + safe_step_percent = float(max(0.0, min(100.0, step_percent))) + overall_percent = (((safe_step - 1) + (safe_step_percent / 100.0)) / _FAST_UPDATE_TOTAL_STEPS) * 100.0 + + _set_fast_update_state( + progressStep=safe_step, + progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS, + progressStepPercent=round(safe_step_percent, 1), + progressPercent=round(overall_percent, 1), + progressLabel=label, + progressDetail=detail, + ) + +def _parse_git_progress_line(raw_line): + text = str(raw_line or "").replace("\x1b", "").strip() + while text.startswith("remote:"): + text = text[len("remote:"):].strip() + + if not text: + return None, "", "" + + match = _GIT_PROGRESS_PERCENT_RE.search(text) + if not match: + return None, text, "" + + try: + percent = float(match.group(2)) + except Exception: + percent = None + + phase = str(match.group(1) or "").strip().lower() + return percent, text, phase + +def _normalize_git_phase_percent(phase, percent): + safe_percent = max(0.0, min(100.0, float(percent))) + phase_text = str(phase or "").strip().lower() + + # Git progress lines are per-phase and can hit 100% multiple times before the + # command actually exits. Map known phases to a monotonic 0..99% envelope. + if "counting objects" in phase_text: + return min(20.0, safe_percent * 0.20) + if "compressing objects" in phase_text: + return min(45.0, 20.0 + (safe_percent * 0.25)) + if "receiving objects" in phase_text: + return min(85.0, 45.0 + (safe_percent * 0.40)) + if "resolving deltas" in phase_text: + return min(99.0, 85.0 + (safe_percent * 0.14)) + + # Unknown phase: keep below 100 until the process exits. + return min(99.0, safe_percent) + +def _git_command_env(): + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + env["GIT_ASKPASS"] = "/bin/false" + env["SSH_ASKPASS"] = "/bin/false" + env["GCM_INTERACTIVE"] = "Never" + if not env.get("GIT_SSH_COMMAND"): + env["GIT_SSH_COMMAND"] = "ssh -oBatchMode=yes" + return env + +def _build_shallow_fetch_args(branch): + return [ + "-c", "gc.auto=0", + "-c", "maintenance.auto=false", + "fetch", + "--progress", + "--depth=1", + "--no-recurse-submodules", + "origin", + branch, + ] + +def _run_git_with_progress(repo_path, args, timeout, step, label): + cmd = ["git", *args] + + process = subprocess.Popen( + cmd, + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=_git_command_env(), + ) + + if process.stdout is None: + raise RuntimeError("Failed to open git output stream") + + fd = process.stdout.fileno() + os.set_blocking(fd, False) + + selector = selectors.DefaultSelector() + selector.register(fd, selectors.EVENT_READ) + + started_at = time.monotonic() + last_activity_at = started_at + last_emit_at = 0.0 + last_percent = None + last_detail = "" + output_tail = [] + buffer = "" + + def consume_text(text): + nonlocal buffer + for char in text: + if char in ("\r", "\n"): + if buffer: + handle_line(buffer) + buffer = "" + else: + buffer += char + + def append_tail(text): + if not text: + return + output_tail.append(text) + if len(output_tail) > 180: + del output_tail[:-180] + + def handle_line(text): + nonlocal last_activity_at, last_emit_at, last_percent, last_detail + + percent, detail, phase = _parse_git_progress_line(text) + append_tail(detail or text) + + now = time.monotonic() + last_activity_at = now + should_emit = False + + if percent is not None: + safe_percent = _normalize_git_phase_percent(phase, percent) + if safe_percent in (0.0, 99.0): + should_emit = True + elif now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S: + should_emit = last_percent is None or abs(safe_percent - last_percent) >= 1.0 + + if should_emit: + _set_fast_update_progress(step, label, safe_percent, detail or label) + last_emit_at = now + last_percent = safe_percent + last_detail = detail or label + else: + if last_percent is None: + last_percent = safe_percent + else: + if detail and now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S and detail != last_detail: + fallback_percent = last_percent if last_percent is not None else 0.0 + _set_fast_update_progress(step, label, fallback_percent, detail) + last_emit_at = now + last_detail = detail + + try: + while True: + now = time.monotonic() + if timeout and (now - last_activity_at) > timeout: + try: + process.kill() + except Exception: + pass + tail = output_tail[-1] if output_tail else "" + suffix = f" (last output: {tail})" if tail else "" + raise TimeoutError(f"git {' '.join(args)} stalled for {int(timeout)}s without output{suffix}") + + events = selector.select(timeout=0.5) + if not events: + if process.poll() is not None: + try: + trailing = os.read(fd, 4096) + except BlockingIOError: + trailing = b"" + if trailing: + last_activity_at = time.monotonic() + consume_text(trailing.decode("utf-8", errors="replace")) + continue + break + + # Heartbeat: if git is quiet (no progress lines), still surface activity. + now = time.monotonic() + if now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S: + if timeout: + inferred_percent = min(95.0, max(0.0, ((now - started_at) / timeout) * 95.0)) + else: + inferred_percent = min(95.0, (last_percent or 0.0) + 1.0) + if last_percent is None or inferred_percent > last_percent: + last_percent = inferred_percent + heartbeat_detail = last_detail or f"{label}..." + _set_fast_update_progress(step, label, last_percent or 0.0, heartbeat_detail) + last_emit_at = now + continue + + reached_eof = False + for _, _ in events: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + chunk = b"" + + if not chunk: + # Selector can keep reporting readability on EOF; exit once process ended. + if process.poll() is not None: + reached_eof = True + break + continue + + last_activity_at = time.monotonic() + consume_text(chunk.decode("utf-8", errors="replace")) + + if reached_eof: + break + + if buffer: + handle_line(buffer) + + return_code = process.wait(timeout=2) + finally: + try: + selector.unregister(fd) + except Exception: + pass + selector.close() + try: + process.stdout.close() + except Exception: + pass + + if return_code == 0: + _set_fast_update_progress(step, label, 100.0, last_detail or "Done") + + return return_code, "\n".join(output_tail[-40:]) + +def _run_git(repo_path, args, timeout=30): + return subprocess.run( + ["git", *args], + cwd=repo_path, + capture_output=True, + text=True, + timeout=timeout, + check=False, + env=_git_command_env(), + ) + +def _git_stdout(repo_path, args, timeout=15): + result = _run_git(repo_path, args, timeout=timeout) + if result.returncode != 0: + stderr = (result.stderr or "").strip() or (result.stdout or "").strip() or "git command failed" + raise RuntimeError(stderr) + return (result.stdout or "").strip() + +def _is_valid_git_branch_name(repo_path, branch_name): + branch = str(branch_name or "").strip() + if not branch or branch.startswith("-") or "\x00" in branch: + return False + + result = _run_git(repo_path, ["check-ref-format", "--branch", branch], timeout=10) + return result.returncode == 0 + +def _list_origin_branches(repo_path, include_remote=True): + branches = set() + remote_error = "" + + if include_remote: + try: + remote_heads = _git_stdout(repo_path, ["ls-remote", "--heads", "origin"], timeout=25) + for line in remote_heads.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + + ref = parts[1].strip() + if not ref.startswith("refs/heads/"): + continue + + branch = ref[len("refs/heads/"):].strip() + if branch: + branches.add(branch) + except Exception as exception: + remote_error = str(exception) + + if not branches: + try: + local_refs = _git_stdout( + repo_path, + ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"], + timeout=15, + ) + for line in local_refs.splitlines(): + ref = line.strip() + if not ref or ref in ("origin/HEAD",): + continue + if ref.startswith("origin/"): + ref = ref[len("origin/"):] + if ref.endswith("/HEAD"): + continue + if ref: + branches.add(ref) + except Exception as exception: + if not remote_error: + remote_error = str(exception) + + return sorted(branches, key=lambda branch: branch.lower()), remote_error + +def _repo_has_submodule_entries(repo_path): + gitmodules_path = Path(repo_path) / ".gitmodules" + if not gitmodules_path.is_file(): + return False + + try: + content = gitmodules_path.read_text(encoding="utf-8", errors="replace") + except Exception: + # If we cannot inspect .gitmodules, stay conservative and try syncing. + return True + + return bool(_GIT_SUBMODULE_SECTION_RE.search(content)) + +def _run_submodule_update_if_needed(repo_path, step=4): + if not _repo_has_submodule_entries(repo_path): + _set_fast_update_progress(step, "Updating submodules", 100.0, "No submodules configured.") + return + + _set_fast_update_progress(step, "Updating submodules", 0.0, "Syncing submodules...") + submodule_rc, submodule_output = _run_git_with_progress( + repo_path, + ["submodule", "update", "--init", "--recursive", "--depth=1", "--progress"], + timeout=240, + step=step, + label="Updating submodules", + ) + if submodule_rc != 0: + raise RuntimeError(submodule_output.strip() or "git submodule update failed") + +def _finish_update_and_reboot(message): + _set_fast_update_progress(5, "Rebooting device", 100.0, "Update complete. Please wait for device to reboot.") + _set_fast_update_state( + running=False, + stage="rebooting", + message=message, + finishedAt=time.time(), + ) + # Keep the service online briefly so the UI can fetch and render the reboot notice. + time.sleep(_FAST_UPDATE_REBOOT_NOTICE_SECONDS) + HARDWARE.reboot() + +def _set_fast_update_error_state(message, exception): + error_text = str(exception).strip() or "Unknown error" + _set_fast_update_state( + running=False, + stage="error", + message=message, + lastError=error_text, + finishedAt=time.time(), + progressStep=0, + progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS, + progressStepPercent=0.0, + progressPercent=0.0, + progressLabel="Failed", + progressDetail="Update failed. See Last Error below.", + ) + +def _collect_fast_update_info(include_remote=True): + repo_path = str(_get_openpilot_root()) + + branch = "" + local_commit = "" + remote_commit = "" + update_available = False + remote_error = "" + origin_remote = "" + commits_url = "" + + try: + branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"]) + local_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"]) + try: + origin_remote = _git_stdout(repo_path, ["config", "--get", "remote.origin.url"]) + except Exception: + origin_remote = "" + except Exception as exception: + return { + "repoPath": repo_path, + "branch": branch, + "localCommit": local_commit, + "remoteCommit": remote_commit, + "updateAvailable": False, + "remoteError": str(exception), + "originRemote": origin_remote, + "commitsUrl": commits_url, + } + + if origin_remote: + remote = origin_remote.strip() + if remote.startswith("git@github.com:"): + remote = "https://github.com/" + remote.split(":", 1)[1] + elif remote.startswith("ssh://git@github.com/"): + remote = "https://github.com/" + remote.split("ssh://git@github.com/", 1)[1] + elif remote.startswith("http://github.com/"): + remote = "https://github.com/" + remote.split("http://github.com/", 1)[1] + + if remote.startswith("https://github.com/"): + remote = remote.rstrip("/") + if remote.endswith(".git"): + remote = remote[:-4] + if branch: + commits_url = f"{remote}/commits/{quote(branch, safe='')}/" + + if branch and include_remote: + try: + remote_raw = _git_stdout(repo_path, ["ls-remote", "--heads", "origin", branch], timeout=20) + if remote_raw: + remote_commit = remote_raw.split()[0] + update_available = bool(local_commit and remote_commit and local_commit != remote_commit) + except Exception as exception: + remote_error = str(exception) + + return { + "repoPath": repo_path, + "branch": branch, + "localCommit": local_commit, + "remoteCommit": remote_commit, + "updateAvailable": update_available, + "remoteError": remote_error, + "originRemote": origin_remote, + "commitsUrl": commits_url, + } + +def _fast_update_worker(): + started_at = time.time() + repo_path = str(_get_openpilot_root()) + + try: + _set_fast_update_progress(1, "Preparing update", 10.0, "Resolving active branch...") + branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"]) + _set_fast_update_progress(1, "Preparing update", 100.0, f"Branch: {branch}") + _set_fast_update_state( + running=True, + stage="updating", + message=f"Applying shallow update on '{branch}'...", + lastError="", + lastBranch=branch, + lastMode="fetch-reset", + startedAt=started_at, + finishedAt=0.0, + ) + + _set_fast_update_progress(2, "Fetching branch snapshot", 0.0, "Fetching latest shallow commit...") + fetch_rc, fetch_output = _run_git_with_progress( + repo_path, + _build_shallow_fetch_args(branch), + timeout=_FAST_UPDATE_FETCH_TIMEOUT_S, + step=2, + label="Fetching branch snapshot", + ) + if fetch_rc != 0: + raise RuntimeError(fetch_output.strip() or "git fetch failed") + + _set_fast_update_progress(3, "Applying fetched commit", 20.0, "Resetting repository to fetched head...") + reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120) + if reset.returncode != 0: + raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip()) + _set_fast_update_progress(3, "Applying fetched commit", 100.0, "Repository reset complete.") + + _run_submodule_update_if_needed(repo_path, step=4) + _finish_update_and_reboot( + "Update successful. Device is rebooting now. Please wait for reconnection." + ) + except Exception as exception: + _set_fast_update_error_state("Fast update failed.", exception) + +def _branch_switch_worker(target_branch): + started_at = time.time() + repo_path = str(_get_openpilot_root()) + + try: + _set_fast_update_progress(1, "Preparing branch switch", 10.0, f"Target: {target_branch}") + current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"]) + _set_fast_update_progress(1, "Preparing branch switch", 100.0, f"{current_branch} -> {target_branch}") + _set_fast_update_state( + running=True, + stage="switching", + message=f"Switching to '{target_branch}' with shallow fetch...", + lastError="", + lastBranch=target_branch, + lastMode="branch-switch", + startedAt=started_at, + finishedAt=0.0, + ) + + _set_fast_update_progress(2, "Fetching branch snapshot", 0.0, f"Fetching '{target_branch}' from origin...") + fetch_rc, fetch_output = _run_git_with_progress( + repo_path, + _build_shallow_fetch_args(target_branch), + timeout=_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S, + step=2, + label="Fetching branch snapshot", + ) + if fetch_rc != 0: + raise RuntimeError(fetch_output.strip() or f"git fetch failed for '{target_branch}'") + + _set_fast_update_progress(3, "Switching branch", 20.0, f"Checking out '{target_branch}'...") + checkout = _run_git(repo_path, ["checkout", "--force", "-B", target_branch, "FETCH_HEAD"], timeout=120) + if checkout.returncode != 0: + raise RuntimeError((checkout.stderr or checkout.stdout or "git checkout failed").strip()) + + reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120) + if reset.returncode != 0: + raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip()) + + _run_git(repo_path, ["branch", "--set-upstream-to", f"origin/{target_branch}", target_branch], timeout=30) + _set_fast_update_progress(3, "Switching branch", 100.0, f"Now on '{target_branch}'.") + + _run_submodule_update_if_needed(repo_path, step=4) + _finish_update_and_reboot( + f"Switched to '{target_branch}'. Device is rebooting now. Please wait for reconnection." + ) + except Exception as exception: + _set_fast_update_error_state("Fast branch switch failed.", exception) + def _get_openpilot_root(): global _openpilot_root_cache if _openpilot_root_cache is not None: @@ -639,6 +1191,9 @@ def setup(app): name = friendly_names.get(key, key) return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403 + if key == "AutomaticUpdates" and params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot change Automatic Updates while driving."}), 403 + if key == "CarMake": catalog = _get_fingerprint_catalog() normalized_make = _normalize_fingerprint_make_key(str_val) @@ -1381,6 +1936,111 @@ def setup(app): }, } + @app.route("/api/update/fast/status", methods=["GET"]) + def get_fast_update_status(): + state_data = _get_fast_update_state() + git_data = _collect_fast_update_info(include_remote=not state_data.get("running", False)) + return jsonify({ + **state_data, + **git_data, + "isOnroad": params.get_bool("IsOnroad"), + "automaticUpdates": params.get_bool("AutomaticUpdates"), + "warning": "Fast update skips backup creation and finalization safeguards.", + }), 200 + + @app.route("/api/update/branches", methods=["GET"]) + def get_update_branches(): + state_data = _get_fast_update_state() + repo_path = str(_get_openpilot_root()) + + try: + current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"]) + except Exception as exception: + return jsonify({"error": str(exception)}), 500 + + branches, remote_error = _list_origin_branches(repo_path, include_remote=not state_data.get("running", False)) + if current_branch and current_branch not in branches: + branches = sorted([*branches, current_branch], key=lambda branch: branch.lower()) + + return jsonify({ + "currentBranch": current_branch, + "branches": branches, + "remoteError": remote_error, + "isOnroad": params.get_bool("IsOnroad"), + "running": state_data.get("running", False), + }), 200 + + @app.route("/api/update/fast", methods=["POST"]) + def run_fast_update(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot run a fast update while driving."}), 409 + + with _fast_update_lock: + if _fast_update_state.get("running"): + return jsonify({"error": "Fast update already in progress."}), 409 + _fast_update_state.update({ + "running": True, + "stage": "starting", + "message": "Starting fast update...", + "lastError": "", + "startedAt": time.time(), + "finishedAt": 0.0, + "progressStep": 1, + "progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS, + "progressStepPercent": 0.0, + "progressPercent": 0.0, + "progressLabel": "Preparing update", + "progressDetail": "Initializing update process...", + }) + + threading.Thread(target=_fast_update_worker, daemon=True).start() + + return jsonify({ + "message": "Fast update started. Device will reboot when complete.", + "warning": "Fast update skips backup creation and finalization safeguards.", + }), 202 + + @app.route("/api/update/branch", methods=["POST"]) + def run_branch_switch(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Cannot switch branches while driving."}), 409 + + request_data = request.get_json() or {} + target_branch = str(request_data.get("branch") or "").strip() + if not target_branch: + return jsonify({"error": "Missing 'branch' in request body."}), 400 + + repo_path = str(_get_openpilot_root()) + if not _is_valid_git_branch_name(repo_path, target_branch): + return jsonify({"error": "Invalid branch name."}), 400 + + with _fast_update_lock: + if _fast_update_state.get("running"): + return jsonify({"error": "Another update action is already in progress."}), 409 + _fast_update_state.update({ + "running": True, + "stage": "starting", + "message": f"Starting branch switch to '{target_branch}'...", + "lastError": "", + "lastBranch": target_branch, + "lastMode": "branch-switch", + "startedAt": time.time(), + "finishedAt": 0.0, + "progressStep": 1, + "progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS, + "progressStepPercent": 0.0, + "progressPercent": 0.0, + "progressLabel": "Preparing branch switch", + "progressDetail": "Initializing branch switch...", + }) + + threading.Thread(target=_branch_switch_worker, args=(target_branch,), daemon=True).start() + + return jsonify({ + "message": f"Branch switch started for '{target_branch}'. Device will reboot when complete.", + "warning": "Fast update skips backup creation and finalization safeguards.", + }), 202 + # ── Galaxy pairing (mirrors settings.cc L262-282) ────────────────── GALAXY_DIR = Path("/data/galaxy") GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"