From 1e5b1ef5abdfda8ce45f3d92180d067cf9c1ac63 Mon Sep 17 00:00:00 2001
From: firestar5683 <168790843+firestar5683@users.noreply.github.com>
Date: Wed, 4 Mar 2026 23:34:58 -0600
Subject: [PATCH] Updater
---
.../the_pond/assets/components/home/home.css | 2 +-
.../the_pond/assets/components/router.js | 2 +
.../the_pond/assets/components/sidebar.js | 1 +
.../components/tools/update_manager.css | 206 ++++++
.../assets/components/tools/update_manager.js | 603 ++++++++++++++++
.../system/the_pond/templates/index.html | 1 +
frogpilot/system/the_pond/the_pond.py | 660 ++++++++++++++++++
7 files changed, 1474 insertions(+), 1 deletion(-)
create mode 100644 frogpilot/system/the_pond/assets/components/tools/update_manager.css
create mode 100644 frogpilot/system/the_pond/assets/components/tools/update_manager.js
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"}
+
+
+
+
+
+ ${() => state.status?.progressDetail ? html`
${state.status.progressDetail}
` : ""}
+
+
+ ${() => state.status?.isOnroad ? html`
Onroad: actions disabled
` : ""}
+
+
+
+
+
+ ${() => state.showAdvancedOptions ? html`
+
+
+
+ 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"