mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-08 23:22:04 +08:00
Warn of Agnos
This commit is contained in:
@@ -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,
|
||||
)]
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user