Warn of Agnos

This commit is contained in:
firestar5683
2026-07-05 13:17:18 -05:00
parent 8ab1b723ae
commit 1457b902ac
5 changed files with 562 additions and 28 deletions
+34 -17
View File
@@ -55,7 +55,7 @@ MAP_NEXT_REVIEW_DISTANCE_METERS = 120.0
MAP_TRANSITION_MISS_CAPTURE_COOLDOWN_SECONDS = 8.0
MAP_VISION_MATCH_WINDOW_SECONDS = 2.5
MODEL_PROPOSAL_MIN_CONFIDENCE = 0.0001
MODEL_PROPOSAL_MAX_COUNT = 16
MODEL_PROPOSAL_MAX_COUNT = 4
MODEL_PROPOSAL_MAX_AREA_RATIO = 0.18
MODEL_PROPOSAL_MIN_WIDTH = 10
MODEL_PROPOSAL_MIN_HEIGHT = 18
@@ -152,6 +152,7 @@ SCHOOL_ZONE_SPEED_VALUES = frozenset((15, 20, 25))
US_DETECTOR_MIN_CONFIDENCE = 0.10
US_CLASSIFIER_MIN_CONFIDENCE = 0.50
US_CLASSIFIER_REJECT_MIN_CONFIDENCE = 0.85
SEPARATE_REJECT_CLASSIFIER_ENABLED = False
US_REJECT_CLASSIFIER_MIN_CONFIDENCE = 0.85
DETECTOR_CLASSIFIER_EXPANSIONS = (
(0.00, 0.00, 0.00, 0.00, 1.10),
@@ -180,7 +181,7 @@ DETECTOR_CLASSIFIER_MIN_ACCEPT_HEIGHT = 40
DETECTOR_CLASSIFIER_RESCUE_MIN_WIDTH = 14
DETECTOR_CLASSIFIER_RESCUE_MIN_HEIGHT = 18
DETECTOR_CLASSIFIER_RESCUE_MIN_X_RATIO = 0.52
DETECTOR_CLASSIFIER_RESCUE_MIN_SUPPORT = 1
DETECTOR_CLASSIFIER_RESCUE_MIN_SUPPORT = 2
DETECTOR_CLASSIFIER_RESCUE_MIN_CONFIDENCE = 0.90
DETECTOR_CLASSIFIER_RESCUE_MAX_SCORE = 0.64
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_HEIGHT = 55
@@ -189,6 +190,8 @@ DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_PROPOSAL_CONFIDENCE = 0.18
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_X_RATIO = 0.52
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = 0.98
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT = 2
DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE = 0.70
DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_READ_CONFIDENCE = 0.995
SCHOOL_ZONE_SPEED_PRIOR = 0.12
SCHOOL_ZONE_SUPPORT_BONUS = 0.08
SCHOOL_ZONE_MIN_SUPPORT = 2
@@ -800,7 +803,7 @@ class SpeedLimitVisionDaemon:
self.classifier_net = cv2.dnn.readNetFromONNX(str(US_CLASSIFIER_MODEL_PATH))
self.classifier_net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
self.classifier_net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
if US_REJECT_CLASSIFIER_MODEL_PATH.is_file():
if SEPARATE_REJECT_CLASSIFIER_ENABLED and US_REJECT_CLASSIFIER_MODEL_PATH.is_file():
try:
self.reject_classifier_net = cv2.dnn.readNetFromONNX(str(US_REJECT_CLASSIFIER_MODEL_PATH))
self.reject_classifier_net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
@@ -1276,19 +1279,20 @@ class SpeedLimitVisionDaemon:
if self.classifier_net is None or sign_crop.size == 0:
return None
normalized_mask = self._extract_value_template_mask(sign_crop)
speed_class_count = len(US_CLASSIFIER_SPEED_VALUES)
if normalized_mask is not None and self.reject_classifier_net is not None:
reject_input = cv2.cvtColor(normalized_mask, cv2.COLOR_GRAY2BGR)
reject_crop = self._square_resize(reject_input, size=128)
reject_blob = cv2.dnn.blobFromImage(reject_crop, scalefactor=1 / 255.0, size=(128, 128), swapRB=True, crop=False)
self.reject_classifier_net.setInput(reject_blob)
reject_scores = np.array(self.reject_classifier_net.forward()).reshape(-1)
if reject_scores.size == speed_class_count + 1:
reject_probabilities = self._normalize_classifier_output(reject_scores)
if float(reject_probabilities[speed_class_count]) >= US_REJECT_CLASSIFIER_MIN_CONFIDENCE:
return None
if self.reject_classifier_net is not None:
normalized_mask = self._extract_value_template_mask(sign_crop)
if normalized_mask is not None:
reject_input = cv2.cvtColor(normalized_mask, cv2.COLOR_GRAY2BGR)
reject_crop = self._square_resize(reject_input, size=128)
reject_blob = cv2.dnn.blobFromImage(reject_crop, scalefactor=1 / 255.0, size=(128, 128), swapRB=True, crop=False)
self.reject_classifier_net.setInput(reject_blob)
reject_scores = np.array(self.reject_classifier_net.forward()).reshape(-1)
if reject_scores.size == speed_class_count + 1:
reject_probabilities = self._normalize_classifier_output(reject_scores)
if float(reject_probabilities[speed_class_count]) >= US_REJECT_CLASSIFIER_MIN_CONFIDENCE:
return None
padded_crop = self._square_resize(sign_crop, size=128)
blob = cv2.dnn.blobFromImage(padded_crop, scalefactor=1 / 255.0, size=(128, 128), swapRB=True, crop=False)
@@ -1427,7 +1431,19 @@ class SpeedLimitVisionDaemon:
proposal_confidence >= DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_PROPOSAL_CONFIDENCE and
model_read[1] >= DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE
)
needs_ocr_confirmation = class_id != 2 and (not is_regulatory or is_tiny_low_conf_box) and not trusted_model_read
strong_model_read = (
class_id == 0 and
model_read is not None and
not is_small_box and
proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE and
model_read[1] >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_READ_CONFIDENCE
)
needs_ocr_confirmation = (
class_id != 2 and
(not is_regulatory or is_tiny_low_conf_box) and
not trusted_model_read and
not strong_model_read
)
if model_read is None or needs_ocr_confirmation:
ocr_read = self._read_speed_limit_from_crop(sign_crop)
read_result = model_read or ocr_read
@@ -1440,7 +1456,7 @@ class SpeedLimitVisionDaemon:
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
speed_limit_mph, read_confidence = read_result
score_is_regulatory = is_regulatory or trusted_model_read
score_is_regulatory = is_regulatory or trusted_model_read or strong_model_read
if (
class_id == 2 and
proposal_confidence < SCHOOL_ZONE_FALLBACK_MIN_CONFIDENCE and
@@ -1459,7 +1475,7 @@ class SpeedLimitVisionDaemon:
speed_scores[speed_limit_mph] = speed_scores.get(speed_limit_mph, 0.0) + score
speed_best_confidences[speed_limit_mph] = max(speed_best_confidences.get(speed_limit_mph, 0.0), read_confidence)
speed_support_counts[speed_limit_mph] = speed_support_counts.get(speed_limit_mph, 0) + 1
if is_regulatory or class_id == 2:
if is_regulatory or class_id == 2 or strong_model_read:
speed_regulatory_support[speed_limit_mph] = speed_regulatory_support.get(speed_limit_mph, 0) + 1
if trusted_model_read:
speed_trusted_model_support[speed_limit_mph] = speed_trusted_model_support.get(speed_limit_mph, 0) + 1
@@ -1900,6 +1916,7 @@ class SpeedLimitVisionDaemon:
"modelMode": self.model_mode,
"detectorInputSize": self.detector_input_size,
"detectorRegionMode": DETECTOR_CLASSIFIER_REGION_MODE,
"separateRejectClassifierEnabled": SEPARATE_REJECT_CLASSIFIER_ENABLED,
"stream": self.stream_name,
"cameraConnected": camera_connected,
"debugSession": self.debug_session_id,
@@ -127,6 +127,29 @@
margin-top: var(--margin-base);
}
.updateAgnosWarning {
background: rgba(212, 160, 96, 0.12);
border-left: 3px solid var(--warning-bg);
border-radius: var(--border-radius-sm);
margin-top: var(--margin-base);
padding: var(--padding-sm) var(--padding-base);
}
.updateAgnosWarning p {
font-size: var(--font-size-sm);
margin: var(--margin-xs) 0;
}
.updateAgnosWarning ul {
font-size: var(--font-size-sm);
margin: var(--margin-sm) 0;
padding-left: 1.2rem;
}
.updateAgnosWarning li {
margin-bottom: var(--margin-xs);
}
.updateHint {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
@@ -17,6 +17,10 @@ const state = reactive({
switchBusy: false,
rollbackBusy: false,
recoveryBusy: false,
selectedBranchAgnosUpdate: null,
selectedBranchAgnosBusy: false,
selectedBranchAgnosTarget: "",
selectedBranchAgnosError: "",
})
let initialized = false
@@ -57,6 +61,52 @@ function isSelectedBranchDifferent() {
return !!currentBranch && !!selectedBranch && currentBranch !== selectedBranch
}
function clearSelectedBranchAgnosStatus() {
state.selectedBranchAgnosUpdate = null
state.selectedBranchAgnosBusy = false
state.selectedBranchAgnosTarget = ""
state.selectedBranchAgnosError = ""
}
function activeAgnosUpdate() {
if (isSelectedBranchDifferent()) {
return state.selectedBranchAgnosUpdate || null
}
return state.status?.agnosUpdate || null
}
function activeAgnosError() {
if (isSelectedBranchDifferent()) {
return state.selectedBranchAgnosError || ""
}
return state.status?.agnosUpdate?.error || ""
}
function agnosWarningItems(update = activeAgnosUpdate()) {
const warnings = Array.isArray(update?.warnings) ? update.warnings.filter(Boolean) : []
if (warnings.length) return warnings
return [
"This AGNOS firmware update will take much longer than a normal software update.",
"You must be able to physically access the device to press the on-device update button.",
"It downloads about 900 MB of data, so Wi-Fi is recommended.",
]
}
function agnosChangedPartitionsText(update = activeAgnosUpdate()) {
const partitions = Array.isArray(update?.changedPartitions) ? update.changedPartitions.filter(Boolean) : []
if (!partitions.length) return ""
return `Changed AGNOS partitions: ${partitions.join(", ")}`
}
function agnosConfirmationText(update = activeAgnosUpdate()) {
if (!update?.available) return ""
const warningLines = agnosWarningItems(update)
.map((warning) => `- ${warning}`)
.join("\n")
return `\n\nAGNOS firmware update included:\n\n${warningLines}`
}
function normalizeGithubRemote(remoteValue, commitsUrlValue = "") {
let remote = String(remoteValue || "").trim()
if (!remote) {
@@ -250,6 +300,12 @@ async function fetchStatus(showToast) {
state.selectedBranch = payload.branch
}
if (isSelectedBranchDifferent()) {
fetchSelectedBranchAgnosStatus(false)
} else {
clearSelectedBranchAgnosStatus()
}
if (shouldContinuePolling()) {
ensurePolling()
} else {
@@ -258,7 +314,10 @@ async function fetchStatus(showToast) {
if (showToast) {
state.checkedForUpdates = true
showSnackbar(payload.updateAvailable ? "Update available." : "No update available.")
const updateMessage = payload.updateAvailable
? (payload.agnosUpdate?.available ? "Update available. AGNOS firmware update included." : "Update available.")
: "No update available."
showSnackbar(updateMessage)
}
} catch (error) {
const isRebootTransitionError = !showToast
@@ -312,6 +371,12 @@ async function fetchBranches(showToast = false) {
state.hasManualBranchSelection = false
}
if (isSelectedBranchDifferent()) {
fetchSelectedBranchAgnosStatus(false)
} else {
clearSelectedBranchAgnosStatus()
}
if (showToast) {
showSnackbar(branches.length ? `Loaded ${branches.length} branches.` : "No branches found.")
}
@@ -326,6 +391,56 @@ async function fetchBranches(showToast = false) {
}
}
async function fetchSelectedBranchAgnosStatus(showToast = false, force = false) {
const targetBranch = String(state.selectedBranch || "").trim()
if (!targetBranch || !isSelectedBranchDifferent() || state.status?.running) {
clearSelectedBranchAgnosStatus()
return null
}
if (!force && state.selectedBranchAgnosUpdate?.targetBranch === targetBranch) {
return state.selectedBranchAgnosUpdate
}
if (state.selectedBranchAgnosBusy && state.selectedBranchAgnosTarget === targetBranch) {
return state.selectedBranchAgnosUpdate
}
state.selectedBranchAgnosBusy = true
state.selectedBranchAgnosTarget = targetBranch
state.selectedBranchAgnosError = ""
try {
const response = await fetch(`/api/update/agnos_status?branch=${encodeURIComponent(targetBranch)}`)
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to check AGNOS update status")
}
const agnosUpdate = payload.agnosUpdate || null
if (String(state.selectedBranch || "").trim() === targetBranch) {
state.selectedBranchAgnosUpdate = agnosUpdate
state.selectedBranchAgnosError = agnosUpdate?.error || ""
}
if (showToast && agnosUpdate?.available) {
showSnackbar("Selected branch includes an AGNOS firmware update.")
}
return agnosUpdate
} catch (error) {
const message = error?.message || "Failed to check AGNOS update status"
if (String(state.selectedBranch || "").trim() === targetBranch) {
state.selectedBranchAgnosUpdate = null
state.selectedBranchAgnosError = message
}
if (showToast) {
showSnackbar(message, "error")
}
return null
} finally {
if (state.selectedBranchAgnosTarget === targetBranch) {
state.selectedBranchAgnosBusy = false
}
}
}
function setAdvancedOptions(enabled) {
const next = !!enabled
state.showAdvancedOptions = next
@@ -336,6 +451,7 @@ function setAdvancedOptions(enabled) {
state.selectedBranch = currentBranch
}
state.hasManualBranchSelection = false
clearSelectedBranchAgnosStatus()
}
try {
@@ -389,10 +505,13 @@ async function runFastUpdate(skipConfirmation = false) {
}
if (!skipConfirmation) {
const agnosWarning = agnosConfirmationText()
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" +
"- Your device will reboot when the update is done." +
agnosWarning +
"\n\n" +
"Continue with fast update?"
)
if (!confirmed) return
@@ -452,10 +571,16 @@ async function runBranchSwitch(skipConfirmation = false) {
const currentBranch = String(state.status?.branch || "").trim()
const actionLabel = currentBranch && currentBranch === targetBranch ? "update" : "switch and update"
if (!skipConfirmation) {
if (isSelectedBranchDifferent()) {
await fetchSelectedBranchAgnosStatus(false, true)
}
const agnosWarning = agnosConfirmationText()
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" +
"- Your device will reboot when the update is done." +
agnosWarning +
"\n\n" +
"Continue?"
)
if (!confirmed) return
@@ -686,6 +811,23 @@ export function UpdateManager() {
${() => state.status?.isOnroad ? html`<p class="updateWarning"><strong>Onroad: actions disabled</strong></p>` : ""}
${() => isSelectedBranchDifferent() && state.selectedBranchAgnosBusy
? html`<p class="updateHint">Checking AGNOS firmware impact for ${state.selectedBranch}...</p>`
: ""}
${() => activeAgnosUpdate()?.available ? html`
<div class="updateAgnosWarning">
<strong>AGNOS firmware update included</strong>
<p>This update changes the AGNOS firmware manifest.</p>
<ul>
${() => agnosWarningItems(activeAgnosUpdate()).map((warning) => html`<li>${warning}</li>`)}
</ul>
${() => agnosChangedPartitionsText(activeAgnosUpdate())
? html`<p>${agnosChangedPartitionsText(activeAgnosUpdate())}</p>`
: ""}
</div>
` : ""}
<label class="updateToggleRow">
<span>Automatically install updates</span>
<input
@@ -723,6 +865,11 @@ export function UpdateManager() {
@change="${(event) => {
state.selectedBranch = String(event.target.value || "")
state.hasManualBranchSelection = true
if (isSelectedBranchDifferent()) {
fetchSelectedBranchAgnosStatus(false, true)
} else {
clearSelectedBranchAgnosStatus()
}
}}">
${() => state.branches.length
? state.branches.map((branch) => html`<option value="${branch}" selected="${() => branch === state.selectedBranch || false}">${branch}${branch === state.status?.branch ? " (current)" : ""}</option>`)
@@ -768,6 +915,7 @@ export function UpdateManager() {
${() => !isFactoryResetStatusActive() && state.status?.message && state.status?.stage !== "rebooting" ? html`<p class="updateMessage">${state.status.message}</p>` : ""}
${() => state.status?.remoteError ? html`<p class="updateError"><strong>Remote Check:</strong> ${state.status.remoteError}</p>` : ""}
${() => (state.checkedForUpdates || isSelectedBranchDifferent()) && activeAgnosError() ? html`<p class="updateError"><strong>AGNOS Check:</strong> ${activeAgnosError()}</p>` : ""}
${() => !isFactoryResetStatusActive() && state.status?.lastError ? html`<p class="updateError"><strong>Last Error:</strong> ${state.status.lastError}</p>` : ""}
${() => state.error ? html`<p class="updateError"><strong>Error:</strong> ${state.error}</p>` : ""}
@@ -0,0 +1,121 @@
import importlib.util
import json
from types import SimpleNamespace
from test_dashboard_stats import MODULE_DIR, _install_server_import_stubs
def _load_server_module():
_install_server_import_stubs()
spec = importlib.util.spec_from_file_location("agnos_update_server", MODULE_DIR / "the_galaxy.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _manifest(system_hash):
return json.dumps([
{
"name": "boot",
"url": "https://example.com/boot.img.xz",
"hash": "boot-hash",
"hash_raw": "boot-raw",
"size": 100,
},
{
"name": "system",
"url": f"https://example.com/{system_hash}.img.xz",
"hash": system_hash,
"hash_raw": system_hash,
"size": 900,
},
])
def test_agnos_status_detects_manifest_change_from_local_git_object(monkeypatch):
server = _load_server_module()
local_commit = "a" * 40
remote_commit = "b" * 40
local_manifest = _manifest("old-system")
remote_manifest = _manifest("new-system")
def fake_git_stdout(repo_path, args, timeout=15):
assert repo_path == "/repo"
if args == ["show", f"{local_commit}:{server._AGNOS_MANIFEST_PATH}"]:
return local_manifest
if args == ["show", f"{remote_commit}:{server._AGNOS_MANIFEST_PATH}"]:
return remote_manifest
raise AssertionError(args)
monkeypatch.setattr(server, "_git_stdout", fake_git_stdout)
monkeypatch.setattr(server, "_git_has_commit", lambda repo_path, commit: commit == remote_commit)
status = server._build_agnos_update_status("/repo", "owner/repo", local_commit, remote_commit, "main")
assert status["checked"] is True
assert status["available"] is True
assert status["changedPartitions"] == ["system"]
assert status["estimatedDownloadMb"] == 900
assert "physically access" in " ".join(status["warnings"])
def test_agnos_status_ignores_same_manifest_content(monkeypatch):
server = _load_server_module()
local_commit = "a" * 40
remote_commit = "b" * 40
manifest = _manifest("same-system")
def fake_git_stdout(repo_path, args, timeout=15):
if args in (
["show", f"{local_commit}:{server._AGNOS_MANIFEST_PATH}"],
["show", f"{remote_commit}:{server._AGNOS_MANIFEST_PATH}"],
):
return manifest
raise AssertionError(args)
monkeypatch.setattr(server, "_git_stdout", fake_git_stdout)
monkeypatch.setattr(server, "_git_has_commit", lambda repo_path, commit: commit == remote_commit)
status = server._build_agnos_update_status("/repo", "owner/repo", local_commit, remote_commit, "main")
assert status["checked"] is True
assert status["available"] is False
assert status["changedPartitions"] == []
def test_agnos_status_fetches_remote_manifest_from_github_when_commit_is_not_local(monkeypatch):
server = _load_server_module()
local_commit = "a" * 40
remote_commit = "b" * 40
local_manifest = _manifest("old-system")
remote_manifest = _manifest("new-system")
requested_urls = []
def fake_git_stdout(repo_path, args, timeout=15):
if args == ["show", f"{local_commit}:{server._AGNOS_MANIFEST_PATH}"]:
return local_manifest
raise AssertionError(args)
def fake_get(url, timeout):
requested_urls.append((url, timeout))
return SimpleNamespace(text=remote_manifest, raise_for_status=lambda: None)
monkeypatch.setattr(server, "_git_stdout", fake_git_stdout)
monkeypatch.setattr(server, "_git_has_commit", lambda repo_path, commit: False)
monkeypatch.setattr(server.requests, "get", fake_get)
status = server._build_agnos_update_status(
"/repo",
"https://github.com/owner/repo.git",
local_commit,
remote_commit,
"main",
)
assert status["checked"] is True
assert status["available"] is True
assert requested_urls == [(
f"https://raw.githubusercontent.com/owner/repo/{remote_commit}/{server._AGNOS_MANIFEST_PATH}",
server._AGNOS_REMOTE_MANIFEST_TIMEOUT_S,
)]
+233 -8
View File
@@ -702,6 +702,9 @@ _FAST_UPDATE_REBOOT_NOTICE_SECONDS = 6.0
_FAST_UPDATE_FETCH_TIMEOUT_S = 60
_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S = 60
_FAST_ROLLBACK_FETCH_TIMEOUT_S = 60
_AGNOS_MANIFEST_PATH = "system/hardware/tici/agnos.json"
_AGNOS_REMOTE_MANIFEST_TIMEOUT_S = 8
_AGNOS_UPDATE_ESTIMATED_DOWNLOAD_MB = 900
_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)
_ROLLBACK_REF = "refs/starpilot/rollback"
@@ -1265,6 +1268,179 @@ def _is_deferred_tls_error(exception):
return False
def _get_remote_branch_commit(repo_path, branch):
remote_commit = ""
remote_error = ""
branch_name = str(branch or "").strip()
if not branch_name or not _remote_git_check_allowed():
return remote_commit, remote_error
try:
remote_raw = _git_stdout(repo_path, ["ls-remote", "--heads", "origin", branch_name], timeout=20)
if remote_raw:
remote_commit = remote_raw.split()[0]
except Exception as exception:
if not _is_deferred_tls_error(exception):
remote_error = str(exception)
return remote_commit, remote_error
def _base_agnos_update_status(target_branch="", local_commit="", remote_commit=""):
return {
"available": False,
"checked": False,
"targetBranch": str(target_branch or "").strip(),
"manifestPath": _AGNOS_MANIFEST_PATH,
"localCommit": str(local_commit or "").strip(),
"remoteCommit": str(remote_commit or "").strip(),
"localManifestHash": "",
"remoteManifestHash": "",
"changedPartitions": [],
"estimatedDownloadMb": _AGNOS_UPDATE_ESTIMATED_DOWNLOAD_MB,
"warnings": [
"This AGNOS firmware update will take much longer than a normal software update.",
"You must be able to physically access the device to press the on-device update button.",
"It downloads about 900 MB of data, so Wi-Fi is recommended.",
],
"source": "",
"error": "",
}
def _canonical_agnos_manifest_text(manifest_text):
text = str(manifest_text or "").strip()
if not text:
return ""
try:
return json.dumps(json.loads(text), sort_keys=True, separators=(",", ":"))
except Exception:
return text
def _agnos_manifest_hash(manifest_text):
canonical = _canonical_agnos_manifest_text(manifest_text)
if not canonical:
return ""
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _agnos_partition_fingerprints(manifest_text):
try:
manifest = json.loads(str(manifest_text or ""))
except Exception:
return {}
if not isinstance(manifest, list):
return {}
partitions = {}
for item in manifest:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
partitions[name] = {
"hash": str(item.get("hash") or ""),
"hashRaw": str(item.get("hash_raw") or ""),
"url": str(item.get("url") or ""),
"size": str(item.get("size") or ""),
}
return partitions
def _agnos_changed_partitions(local_manifest_text, remote_manifest_text):
local_partitions = _agnos_partition_fingerprints(local_manifest_text)
remote_partitions = _agnos_partition_fingerprints(remote_manifest_text)
partition_names = sorted(set(local_partitions) | set(remote_partitions))
return [
name for name in partition_names
if local_partitions.get(name) != remote_partitions.get(name)
]
def _git_show_file_text(repo_path, ref, file_path):
safe_ref = str(ref or "").strip()
if not safe_ref:
raise RuntimeError("Missing git ref")
return _git_stdout(repo_path, ["show", f"{safe_ref}:{file_path}"], timeout=10)
def _github_raw_file_url(origin_remote, ref, file_path):
remote = utilities.normalize_github_remote(origin_remote)
if not remote:
return ""
slug = remote.split("https://github.com/", 1)[1]
parts = slug.split("/", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
return ""
owner = quote(parts[0], safe="")
repo = quote(parts[1], safe="")
quoted_ref = quote(str(ref or "").strip(), safe="")
quoted_path = "/".join(quote(part, safe="") for part in str(file_path or "").split("/") if part)
if not quoted_ref or not quoted_path:
return ""
return f"https://raw.githubusercontent.com/{owner}/{repo}/{quoted_ref}/{quoted_path}"
def _fetch_remote_file_text(origin_remote, ref, file_path):
raw_url = _github_raw_file_url(origin_remote, ref, file_path)
if not raw_url:
raise RuntimeError("AGNOS manifest comparison is only available for GitHub remotes when the remote commit is not available locally.")
response = requests.get(raw_url, timeout=_AGNOS_REMOTE_MANIFEST_TIMEOUT_S)
response.raise_for_status()
return response.text, raw_url
def _build_agnos_update_status(repo_path, origin_remote, local_commit, remote_commit, target_branch=""):
status = _base_agnos_update_status(target_branch, local_commit, remote_commit)
safe_local_commit = str(local_commit or "").strip()
safe_remote_commit = str(remote_commit or "").strip()
if not safe_local_commit or not safe_remote_commit:
status["error"] = "Missing commit information for AGNOS manifest comparison."
return status
if safe_local_commit == safe_remote_commit:
status["checked"] = True
status["source"] = "same-commit"
return status
try:
local_manifest_text = _git_show_file_text(repo_path, safe_local_commit, _AGNOS_MANIFEST_PATH)
except Exception as exception:
status["error"] = f"Unable to read local AGNOS manifest: {exception}"
return status
remote_manifest_text = ""
if _git_has_commit(repo_path, safe_remote_commit):
try:
remote_manifest_text = _git_show_file_text(repo_path, safe_remote_commit, _AGNOS_MANIFEST_PATH)
status["source"] = "git"
except Exception as exception:
status["error"] = f"Unable to read remote AGNOS manifest from git: {exception}"
return status
else:
try:
remote_manifest_text, raw_url = _fetch_remote_file_text(origin_remote, safe_remote_commit, _AGNOS_MANIFEST_PATH)
status["source"] = raw_url
except Exception as exception:
status["error"] = f"Unable to fetch remote AGNOS manifest: {exception}"
return status
local_hash = _agnos_manifest_hash(local_manifest_text)
remote_hash = _agnos_manifest_hash(remote_manifest_text)
changed_partitions = _agnos_changed_partitions(local_manifest_text, remote_manifest_text)
status.update({
"checked": True,
"available": bool(local_hash and remote_hash and local_hash != remote_hash),
"localManifestHash": local_hash,
"remoteManifestHash": remote_hash,
"changedPartitions": changed_partitions,
"error": "",
})
return status
def _build_shallow_fetch_args(branch):
return [
"-c", "gc.auto=0",
@@ -1697,6 +1873,7 @@ def _collect_fast_update_info(include_remote=True):
origin_remote = ""
commits_url = ""
rollback_data = _load_rollback_target(repo_path)
agnos_update = _base_agnos_update_status()
try:
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
@@ -1716,8 +1893,12 @@ def _collect_fast_update_info(include_remote=True):
"originRemote": origin_remote,
"commitsUrl": commits_url,
**rollback_data,
"agnosUpdate": agnos_update,
}
agnos_update["targetBranch"] = branch
agnos_update["localCommit"] = local_commit
if origin_remote:
remote = origin_remote.strip()
if remote.startswith("git@github.com:"):
@@ -1735,14 +1916,12 @@ def _collect_fast_update_info(include_remote=True):
commits_url = f"{remote}/commits/{quote(branch, safe='')}/"
if branch and include_remote and _remote_git_check_allowed():
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:
if not _is_deferred_tls_error(exception):
remote_error = str(exception)
remote_commit, remote_error = _get_remote_branch_commit(repo_path, branch)
update_available = bool(local_commit and remote_commit and local_commit != remote_commit)
if remote_commit:
agnos_update = _build_agnos_update_status(repo_path, origin_remote, local_commit, remote_commit, branch)
elif not include_remote:
agnos_update = _base_agnos_update_status(branch, local_commit, "")
return {
"repoPath": repo_path,
@@ -1753,6 +1932,7 @@ def _collect_fast_update_info(include_remote=True):
"remoteError": remote_error,
"originRemote": origin_remote,
"commitsUrl": commits_url,
"agnosUpdate": agnos_update,
**rollback_data,
}
@@ -5524,6 +5704,51 @@ def setup(app):
"running": state_data.get("running", False),
}), 200
@app.route("/api/update/agnos_status", methods=["GET"])
def get_agnos_update_status():
state_data = _get_fast_update_state()
if state_data.get("running", False):
return jsonify({"error": "Cannot check AGNOS update status while an update action is running."}), 409
repo_path = str(_get_openpilot_root())
try:
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
local_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
origin_remote = _git_stdout(repo_path, ["config", "--get", "remote.origin.url"])
except Exception as exception:
return jsonify({"error": str(exception)}), 500
target_branch = str(request.args.get("branch") or current_branch or "").strip()
if not target_branch:
return jsonify({"error": "Missing target branch."}), 400
if not _is_valid_git_branch_name(repo_path, target_branch):
return jsonify({"error": "Invalid branch name."}), 400
if not _remote_git_check_allowed():
agnos_update = _base_agnos_update_status(target_branch, local_commit, "")
agnos_update["error"] = "Remote checks are deferred until system time is valid."
return jsonify({
"currentBranch": current_branch,
"targetBranch": target_branch,
"localCommit": local_commit,
"remoteCommit": "",
"agnosUpdate": agnos_update,
}), 200
remote_commit, remote_error = _get_remote_branch_commit(repo_path, target_branch)
if not remote_commit:
agnos_update = _base_agnos_update_status(target_branch, local_commit, "")
agnos_update["error"] = remote_error or f"Remote branch '{target_branch}' was not found."
else:
agnos_update = _build_agnos_update_status(repo_path, origin_remote, local_commit, remote_commit, target_branch)
return jsonify({
"currentBranch": current_branch,
"targetBranch": target_branch,
"localCommit": local_commit,
"remoteCommit": remote_commit,
"agnosUpdate": agnos_update,
}), 200
@app.route("/api/update/fast", methods=["POST"])
def run_fast_update():
if params.get_bool("IsOnroad"):