Spruce it up

This commit is contained in:
firestarsdog
2026-08-31 03:45:32 -04:00
parent 764417845f
commit 5d32cccaf1
8 changed files with 406 additions and 123 deletions
+21 -64
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import shutil
import threading
from dataclasses import dataclass, replace
from pathlib import Path
from cereal import log, messaging
import pyray as rl
@@ -44,12 +43,13 @@ from openpilot.starpilot.common.maps_catalog import (
sanitize_selected_locations_csv,
schedule_label,
)
from openpilot.starpilot.common.maps_selection import COUNTRY_PREFIX, STATE_PREFIX
from openpilot.starpilot.common.maps_download_progress import MAPS_STORAGE_CACHE_PARAM, load_maps_storage_cache
from openpilot.starpilot.common.maps_selection import COUNTRY_PREFIX
from openpilot.starpilot.common.starpilot_variables import MAPS_PATH as OFFLINE_MAPS_PATH
NetworkType = log.DeviceState.NetworkType
OFFLINE_MAPS_PATH = Path("/data/media/0/osm/offline")
CANCEL_REQUEST_TIMEOUT = 3.0
PANEL_STYLE = DEFAULT_PANEL_STYLE
MAPS_METRICS = replace(AETHER_LIST_METRICS, header_height=0)
@@ -435,13 +435,10 @@ class StarPilotMapsLayout(_SettingsPage):
self._worker_params = Params()
self._map_sm = messaging.SubMaster(["mapdExtendedOut", "starpilotCarState"])
self._storage_text = "0 MB"
self._storage_text = tr("Calculating...")
self._storage_known = False
self._has_downloaded_data = False
self._storage_updated_at = 0.0
self._storage_refresh_thread: threading.Thread | None = None
self._storage_refresh_pending = False
self._storage_refresh_generation = 0
self._pending_storage_state: tuple[int, str, bool] | None = None
self._download_started_at: float | None = None
self._cancel_requested_at: float | None = None
self._cancel_visual_until = 0.0
@@ -467,24 +464,19 @@ class StarPilotMapsLayout(_SettingsPage):
self._manager_view = MapsManagerView(self)
self._refresh_storage_cache(force=True)
self._refresh_storage_state(force=True)
self._sync_download_state(force=True)
def show_event(self):
super().show_event()
# On panel launch, if no offline map files exist on disk and no download is actively running,
# reset any stale or default MapsSelected parameter so 0 regions show as selected on clean boot!
if not self._has_downloaded_data and not self._download_in_flight():
raw_selected = self._params.get("MapsSelected", encoding="utf-8") or ""
if raw_selected:
self._params.put("MapsSelected", "")
self._cached_selected_tokens = set()
self._refresh_storage_state(force=True)
raw_selected = self._params.get("MapsSelected", encoding="utf-8") or ""
self._cached_selected_tokens = _selected_token_set(raw_selected)
if self._cancel_requested() and self._cancel_requested_at is None:
self._cancel_requested_at = rl.get_time()
if self._cancel_requested() and self._cancel_visual_until <= rl.get_time():
self._cancel_visual_until = rl.get_time() + 2.5
self._refresh_storage_cache(force=True)
self._sync_download_state(force=True)
def hide_event(self):
@@ -497,15 +489,7 @@ class StarPilotMapsLayout(_SettingsPage):
self._params.get("MapsSelected", encoding="utf-8") or ""
)
self._sync_download_state()
if self._pending_storage_state is not None:
generation, storage_text, has_downloaded_data = self._pending_storage_state
self._pending_storage_state = None
if generation == self._storage_refresh_generation:
self._storage_text = storage_text
self._has_downloaded_data = has_downloaded_data
self._storage_updated_at = rl.get_time()
self._storage_refresh_pending = False
self._refresh_storage_cache()
self._refresh_storage_state()
if self._download_state.active:
device.set_override_interactive_timeout(300)
@@ -559,46 +543,17 @@ class StarPilotMapsLayout(_SettingsPage):
progress_text=progress_text,
)
def _refresh_storage_cache(self, force: bool = False):
def _refresh_storage_state(self, force: bool = False):
now = rl.get_time()
if self._storage_refresh_pending:
return
if not force and (now - self._storage_updated_at) < 4.0:
return
generation = self._storage_refresh_generation + 1
self._storage_refresh_generation = generation
def refresh_worker():
result: tuple[str, bool] | None = None
try:
result = self._calculate_storage_state()
finally:
if result is None:
self._storage_refresh_pending = False
else:
self._pending_storage_state = (generation, result[0], result[1])
self._storage_refresh_pending = True
cache = load_maps_storage_cache(self._worker_params.get(MAPS_STORAGE_CACHE_PARAM, encoding="utf-8") or "")
total_size = cache.storage_bytes
self._storage_known = cache.storage_known
self._has_downloaded_data = bool(cache.maps_present)
self._storage_text = _format_mb(total_size) if total_size is not None else tr("Calculating...")
self._storage_updated_at = now
self._storage_refresh_thread = threading.Thread(target=refresh_worker, daemon=True)
self._storage_refresh_thread.start()
def _calculate_storage_state(self) -> tuple[str, bool]:
if not OFFLINE_MAPS_PATH.exists():
return "0 MB", False
total_size = 0
has_files = False
for path in OFFLINE_MAPS_PATH.rglob("*"):
try:
if not path.is_file():
continue
has_files = True
total_size += path.stat().st_size
except OSError:
continue
return _format_mb(total_size), has_files
def _selected_tokens(self) -> set[str]:
return self._cached_selected_tokens
@@ -909,9 +864,9 @@ class StarPilotMapsLayout(_SettingsPage):
if OFFLINE_MAPS_PATH.exists():
shutil.rmtree(OFFLINE_MAPS_PATH, ignore_errors=True)
OFFLINE_MAPS_PATH.mkdir(parents=True, exist_ok=True)
self._storage_refresh_generation += 1
self._pending_storage_state = (self._storage_refresh_generation, "0 MB", False)
self._storage_refresh_pending = False
cache = load_maps_storage_cache(self._worker_params.get(MAPS_STORAGE_CACHE_PARAM, encoding="utf-8") or "")
cache.clear()
self._worker_params.put(MAPS_STORAGE_CACHE_PARAM, cache.to_json())
self._storage_updated_at = 0.0
threading.Thread(target=remove_worker, daemon=True).start()
@@ -932,6 +887,8 @@ class StarPilotMapsLayout(_SettingsPage):
return tr("Starting Download")
if self._has_downloaded_data:
return tr("Offline Maps")
if not self._storage_known:
return tr("Checking Offline Maps")
if self._selected_count() == 0:
return tr("Select Map Data")
return tr("Download Readiness")
@@ -1,5 +1,6 @@
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
@@ -51,6 +52,15 @@ def estimate_eta_seconds(estimated_download_bytes, storage_delta_bytes, bytes_pe
return max(1, math.ceil(remaining_bytes / bytes_per_second))
def estimate_file_eta_seconds(elapsed_seconds, total_files, downloaded_files):
elapsed_seconds = max(float(elapsed_seconds or 0.0), 0.0)
total_files = nonnegative_int(total_files)
downloaded_files = min(nonnegative_int(downloaded_files), total_files)
if elapsed_seconds <= 0 or downloaded_files <= 0 or downloaded_files >= total_files:
return 0
return max(1, math.ceil(elapsed_seconds * (total_files - downloaded_files) / downloaded_files))
def load_size_cache(raw_value):
if isinstance(raw_value, bytes):
raw_value = raw_value.decode("utf-8", errors="ignore")
@@ -61,3 +71,88 @@ def load_size_cache(raw_value):
except (TypeError, ValueError):
return {}
return value if isinstance(value, dict) else {}
MAPS_STORAGE_CACHE_PARAM = "MapsDownloadSizeCache"
MAPS_STORAGE_CACHE_VERSION = 2
@dataclass
class MapsStorageCache:
"""Persisted map storage state; ``None`` means it has not been reconciled yet."""
storage_bytes: int | None = None
_selections: dict[str, dict] = field(default_factory=dict)
@property
def storage_known(self) -> bool:
return self.storage_bytes is not None
@property
def maps_present(self) -> bool | None:
total_storage_bytes = self.storage_bytes
if total_storage_bytes is None:
return None
return total_storage_bytes > 0
def selection_estimate_bytes(self, selected_key):
entry = self._selections.get(selected_key, {})
if not isinstance(entry, dict):
return 0
return nonnegative_int(entry.get("estimatedAdditionalStorageBytes", entry.get("downloadBytes", 0)))
def selection_total_files(self, selected_key):
entry = self._selections.get(selected_key, {})
return nonnegative_int(entry.get("totalFiles", 0)) if isinstance(entry, dict) else 0
def selection_updated_at(self, selected_key):
entry = self._selections.get(selected_key, {})
return str(entry.get("updatedAt", "")) if isinstance(entry, dict) else ""
def reconcile(self, total_storage_bytes, *, selection_key=None, baseline_storage_bytes=None, total_files=0, updated_at=""):
self.storage_bytes = nonnegative_int(total_storage_bytes)
if not selection_key:
return
entry = {
"estimatedAdditionalStorageBytes": 0,
"totalFiles": nonnegative_int(total_files),
"updatedAt": str(updated_at or ""),
}
if baseline_storage_bytes is not None:
entry["estimatedAdditionalStorageBytes"] = max(self.storage_bytes - nonnegative_int(baseline_storage_bytes), 0)
self._selections[str(selection_key)] = entry
def clear(self):
self.storage_bytes = 0
def mark_unknown(self):
self.storage_bytes = None
def to_json(self):
selections = {
key: {
"estimatedAdditionalStorageBytes": self.selection_estimate_bytes(key),
"totalFiles": self.selection_total_files(key),
"updatedAt": self.selection_updated_at(key),
}
for key, entry in self._selections.items()
}
return json.dumps({
"version": MAPS_STORAGE_CACHE_VERSION,
"storageBytes": self.storage_bytes,
"selections": selections,
}, separators=(",", ":"))
def load_maps_storage_cache(raw_value):
value = load_size_cache(raw_value)
if value.get("version") == MAPS_STORAGE_CACHE_VERSION:
storage_bytes_value = value.get("storageBytes")
selections = value.get("selections")
return MapsStorageCache(
storage_bytes=nonnegative_int(storage_bytes_value) if storage_bytes_value is not None else None,
_selections={str(key): dict(entry) for key, entry in selections.items() if isinstance(entry, dict)} if isinstance(selections, dict) else {},
)
return MapsStorageCache(_selections={str(key): dict(entry) for key, entry in value.items() if isinstance(entry, dict)})
+81 -35
View File
@@ -21,9 +21,9 @@ from openpilot.starpilot.common.starpilot_backups import backup_starpilot
from openpilot.starpilot.common.connect_server import sync_konik_dongle_id
from openpilot.starpilot.common.maps_catalog import normalize_schedule_value, sanitize_selected_locations_csv
from openpilot.starpilot.common.maps_download_progress import (
estimate_download_bytes,
estimate_eta_seconds,
load_size_cache,
MAPS_STORAGE_CACHE_PARAM,
estimate_file_eta_seconds,
load_maps_storage_cache,
nonnegative_int,
selection_key,
storage_bytes,
@@ -234,15 +234,32 @@ def update_boot_logo(starpilot=False, stock=False, selected_logo=None):
MAPS_DOWNLOAD_PROGRESS_PARAM = "MapsDownloadProgress"
MAPS_DOWNLOAD_SIZE_CACHE_PARAM = "MapsDownloadSizeCache"
MAPS_DOWNLOAD_SIZE_CACHE_PARAM = MAPS_STORAGE_CACHE_PARAM
def _decode_map_param(value):
return value.decode("utf-8", errors="ignore") if isinstance(value, bytes) else value
def _get_map_size_cache(params):
return load_size_cache(_decode_map_param(params.get(MAPS_DOWNLOAD_SIZE_CACHE_PARAM)))
def _get_maps_storage_cache(params):
return load_maps_storage_cache(_decode_map_param(params.get(MAPS_DOWNLOAD_SIZE_CACHE_PARAM)))
def _save_maps_storage_cache(params, cache):
params.put(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, cache.to_json())
def _reconcile_maps_storage(params, cache, *, selected_key=None, baseline_storage_bytes=None, total_files=0, updated_at=""):
total_storage_bytes = storage_bytes(MAPS_PATH)
cache.reconcile(
total_storage_bytes,
selection_key=selected_key,
baseline_storage_bytes=baseline_storage_bytes,
total_files=total_files,
updated_at=updated_at,
)
_save_maps_storage_cache(params, cache)
return total_storage_bytes
def _publish_maps_progress(
@@ -257,6 +274,7 @@ def _publish_maps_progress(
completed=False,
phase="starting",
cached_estimate_bytes=0,
current_storage_bytes=None,
):
total_files = 0
downloaded_files = 0
@@ -278,24 +296,24 @@ def _publish_maps_progress(
active = bool(progress.active) if progress is not None else False
cancelled = bool(cancelled or progress_cancelled)
elapsed_seconds = max(time.monotonic() - started_at, 0.0)
current_storage_bytes = storage_bytes(MAPS_PATH)
storage_delta_bytes = max(current_storage_bytes - baseline_storage_bytes, 0)
bytes_per_second = storage_delta_bytes / elapsed_seconds if elapsed_seconds > 0 else 0.0
estimated_bytes = estimate_download_bytes(storage_delta_bytes, total_files, downloaded_files)
estimate_source = "live_file_rate" if estimated_bytes else ""
if not estimated_bytes and cached_estimate_bytes:
estimated_bytes = int(cached_estimate_bytes)
estimate_source = "previous_download"
fraction = min(max(downloaded_files / float(total_files), 0.0), 1.0) if total_files > 0 else (1.0 if completed else 0.0)
if completed:
percent = 100
elif estimated_bytes > 0:
percent = min(99, int(storage_delta_bytes * 100 / estimated_bytes))
elif total_files > 0:
percent = min(99, int(downloaded_files * 100 / total_files))
percent = min(99, int(fraction * 100))
else:
percent = 0
estimated_bytes = nonnegative_int(cached_estimate_bytes)
storage_known = current_storage_bytes is not None or baseline_storage_bytes is not None
effective_storage_bytes = current_storage_bytes if current_storage_bytes is not None else baseline_storage_bytes
storage_delta_bytes = (
max(nonnegative_int(current_storage_bytes) - nonnegative_int(baseline_storage_bytes), 0)
if current_storage_bytes is not None and baseline_storage_bytes is not None else 0
)
payload = {
"active": bool(active),
"cancelled": cancelled,
@@ -303,23 +321,43 @@ def _publish_maps_progress(
"downloadedBytes": storage_delta_bytes,
"downloadedFiles": downloaded_files,
"estimatedDownloadBytes": estimated_bytes,
"estimateSource": estimate_source,
"etaSeconds": estimate_eta_seconds(estimated_bytes, storage_delta_bytes, bytes_per_second) if not completed else 0,
"estimateSource": "previous_additional_storage" if estimated_bytes > 0 else "",
"etaSeconds": estimate_file_eta_seconds(elapsed_seconds, total_files, downloaded_files) if not completed else 0,
"percent": percent,
"phase": phase,
"primaryLocation": primary_location,
"selectedKey": selection_key(maps_selected),
"selectedLocations": [location for location in maps_selected.split(",") if location],
"storageBytes": current_storage_bytes,
"storageBytes": nonnegative_int(effective_storage_bytes) if storage_known else 0,
"storageKnown": storage_known,
"totalFiles": total_files,
"updatedAt": time.time(),
"bytesPerSecond": round(bytes_per_second, 2),
"bytesPerSecond": 0,
}
params_memory.put(MAPS_DOWNLOAD_PROGRESS_PARAM, json.dumps(payload, separators=(",", ":")))
return payload
def _wait_for_map_download_to_stop(sm, last_progress, timeout_seconds=5.0):
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
sm.update(1000)
if not sm.updated["mapdExtendedOut"]:
continue
last_progress = sm["mapdExtendedOut"].downloadProgress
if not last_progress.active:
return last_progress, True
return last_progress, False
def update_maps(now, params, params_memory, manual_update=False):
size_cache = _get_maps_storage_cache(params)
if not size_cache.storage_known:
_reconcile_maps_storage(params, size_cache)
baseline_storage_bytes = size_cache.storage_bytes
maps_downloaded = bool(size_cache.maps_present)
maps_selected_raw = params.get("MapsSelected")
maps_selected = sanitize_selected_locations_csv(maps_selected_raw)
if not maps_selected:
@@ -334,7 +372,6 @@ def update_maps(now, params, params_memory, manual_update=False):
is_sunday = now.weekday() == 6
schedule = normalize_schedule_value(params.get("PreferredSchedule"))
maps_downloaded = MAPS_PATH.exists() and any(path.is_file() for path in MAPS_PATH.rglob("*"))
if maps_downloaded and (schedule == 0 or (schedule == 1 and not is_sunday) or (schedule == 2 and not is_first)) and not manual_update:
return
@@ -347,10 +384,8 @@ def update_maps(now, params, params_memory, manual_update=False):
pm = messaging.PubMaster(["mapdIn"])
sm = messaging.SubMaster(["mapdExtendedOut"])
size_cache = _get_map_size_cache(params)
cached_entry = size_cache.get(selection_key(maps_selected), {})
cached_estimate_bytes = nonnegative_int(cached_entry.get("downloadBytes", 0)) if isinstance(cached_entry, dict) else 0
baseline_storage_bytes = storage_bytes(MAPS_PATH)
selected_key = selection_key(maps_selected)
cached_estimate_bytes = size_cache.selection_estimate_bytes(selected_key)
started_at = time.monotonic()
_publish_maps_progress(
params_memory,
@@ -379,16 +414,25 @@ def update_maps(now, params, params_memory, manual_update=False):
msg.mapdIn.type = 27
pm.send("mapdIn", msg)
last_progress, stopped = _wait_for_map_download_to_stop(sm, last_progress)
final_storage = None
if stopped:
final_storage = _reconcile_maps_storage(params, size_cache)
else:
size_cache.mark_unknown()
_save_maps_storage_cache(params, size_cache)
_publish_maps_progress(
params_memory,
maps_selected,
baseline_storage_bytes,
baseline_storage_bytes if stopped else None,
started_at,
progress=last_progress,
active=False,
cancelled=True,
phase="cancelled",
cached_estimate_bytes=cached_estimate_bytes,
current_storage_bytes=final_storage,
)
params_memory.remove("CancelDownloadMaps")
params_memory.remove("DownloadMaps")
@@ -414,7 +458,15 @@ def update_maps(now, params, params_memory, manual_update=False):
if not progress.active and started:
break
final_progress = _publish_maps_progress(
final_storage = _reconcile_maps_storage(
params,
size_cache,
selected_key=selected_key,
baseline_storage_bytes=baseline_storage_bytes,
total_files=int(last_progress.totalFiles) if last_progress is not None else 0,
updated_at=now.isoformat(),
)
_publish_maps_progress(
params_memory,
maps_selected,
baseline_storage_bytes,
@@ -424,14 +476,8 @@ def update_maps(now, params, params_memory, manual_update=False):
completed=True,
phase="complete",
cached_estimate_bytes=cached_estimate_bytes,
current_storage_bytes=final_storage,
)
if final_progress["downloadedBytes"] > 0:
size_cache[selection_key(maps_selected)] = {
"downloadBytes": final_progress["downloadedBytes"],
"totalFiles": final_progress["totalFiles"],
"updatedAt": now.isoformat(),
}
params.put(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, json.dumps(size_cache, separators=(",", ":")))
params.put("LastMapsUpdate", todays_date)
params_memory.remove("DownloadMaps")
@@ -1,6 +1,8 @@
from openpilot.starpilot.common.maps_download_progress import (
estimate_download_bytes,
estimate_file_eta_seconds,
estimate_eta_seconds,
load_maps_storage_cache,
load_size_cache,
selection_key,
storage_bytes,
@@ -29,3 +31,63 @@ def test_load_size_cache_rejects_invalid_values():
assert load_size_cache(b'{"us-ca":{"downloadBytes":123}}')["us-ca"]["downloadBytes"] == 123
assert load_size_cache("not json") == {}
assert load_size_cache("[]") == {}
def test_storage_cache_migrates_legacy_selection_without_claiming_storage_total():
cache = load_maps_storage_cache('{"country:CA":{"downloadBytes":123,"totalFiles":4}}')
assert cache.storage_bytes is None
assert cache.maps_present is None
assert cache.selection_estimate_bytes("country:CA") == 123
assert cache.selection_total_files("country:CA") == 4
migrated = load_maps_storage_cache(cache.to_json())
assert migrated.storage_bytes is None
assert migrated.selection_estimate_bytes("country:CA") == 123
def test_storage_cache_records_selection_delta_not_aggregate_storage():
cache = load_maps_storage_cache("")
cache.reconcile(10_000)
cache.reconcile(
18_000,
selection_key="country:CA",
baseline_storage_bytes=10_000,
total_files=80,
updated_at="2026-08-31T00:00:00",
)
assert cache.storage_bytes == 18_000
assert cache.maps_present is True
assert cache.selection_estimate_bytes("country:CA") == 8_000
assert cache.selection_total_files("country:CA") == 80
cache.reconcile(
18_000,
selection_key="country:CA",
baseline_storage_bytes=18_000,
total_files=80,
updated_at="2026-09-01T00:00:00",
)
assert cache.selection_estimate_bytes("country:CA") == 0
def test_storage_cache_can_be_cleared_or_marked_unknown_without_false_presence():
cache = load_maps_storage_cache("")
cache.reconcile(128)
cache.clear()
assert cache.storage_bytes == 0
assert cache.maps_present is False
cache.mark_unknown()
assert cache.storage_bytes is None
assert cache.maps_present is None
def test_file_eta_uses_mapd_file_progress_without_storage_scans():
assert estimate_file_eta_seconds(elapsed_seconds=10, total_files=10, downloaded_files=2) == 40
assert estimate_file_eta_seconds(elapsed_seconds=10, total_files=10, downloaded_files=0) == 0
assert estimate_file_eta_seconds(elapsed_seconds=10, total_files=2, downloaded_files=2) == 0
@@ -1,4 +1,6 @@
import json
import shutil
from types import SimpleNamespace
import pytest
from PIL import Image
@@ -32,6 +34,57 @@ class FakeThreadManager:
return False
def test_publish_maps_progress_uses_mapd_file_progress_without_synthetic_storage(monkeypatch):
params_memory = FakeParams()
progress = SimpleNamespace(
active=True,
cancelled=False,
downloadedFiles=2,
totalFiles=10,
locationDetails=[],
locations=[],
)
monkeypatch.setattr(sf.time, "monotonic", lambda: 110.0)
payload = sf._publish_maps_progress(
params_memory,
"country:CA",
baseline_storage_bytes=1_000,
started_at=100.0,
progress=progress,
cached_estimate_bytes=800,
)
assert payload["storageBytes"] == 1_000
assert payload["storageKnown"] is True
assert payload["downloadedBytes"] == 0
assert payload["bytesPerSecond"] == 0
assert payload["etaSeconds"] == 40
assert payload["estimateSource"] == "previous_additional_storage"
assert json.loads(params_memory.get(sf.MAPS_DOWNLOAD_PROGRESS_PARAM))["storageBytes"] == 1_000
def test_reconcile_maps_storage_records_selection_delta(monkeypatch):
params = FakeParams()
cache = sf.load_maps_storage_cache("")
monkeypatch.setattr(sf, "storage_bytes", lambda _path: 18_000)
total_storage = sf._reconcile_maps_storage(
params,
cache,
selected_key="country:CA",
baseline_storage_bytes=10_000,
total_files=80,
updated_at="2026-08-31T00:00:00",
)
persisted = sf.load_maps_storage_cache(params.get(sf.MAPS_DOWNLOAD_SIZE_CACHE_PARAM))
assert total_storage == 18_000
assert persisted.storage_bytes == 18_000
assert persisted.selection_estimate_bytes("country:CA") == 8_000
assert persisted.selection_total_files("country:CA") == 80
@pytest.mark.parametrize("extension,image_format", [("jpg", "JPEG"), ("png", "PNG")])
def test_update_boot_logo_writes_agnos_jpeg_and_png(monkeypatch, tmp_path, extension, image_format):
themes_path = tmp_path / "themes"
@@ -30,6 +30,7 @@ const state = reactive({
scheduleLabel: "Monthly",
selectedCount: 0,
storageBytes: 0,
storageKnown: false,
downloadProgress: {
active: false,
cancelled: false,
@@ -42,6 +43,7 @@ const state = reactive({
percent: 0,
phase: "idle",
primaryLocation: "",
storageKnown: false,
totalFiles: 0,
},
},
@@ -120,6 +122,7 @@ function normalizeDownloadProgress(progress) {
percent: Math.max(0, Math.min(100, Number(value.percent || 0))),
phase: String(value.phase || "idle"),
primaryLocation: String(value.primaryLocation || ""),
storageKnown: Boolean(value.storageKnown),
totalFiles: Number(value.totalFiles || 0),
};
}
@@ -185,6 +188,7 @@ function applyStatus(payload) {
scheduleLabel: payload.scheduleLabel || "Monthly",
selectedCount: Number(payload.selectedCount || 0),
storageBytes: Number(payload.storageBytes || 0),
storageKnown: Boolean(payload.storageKnown),
downloadProgress: normalizeDownloadProgress(payload.downloadProgress),
};
state.selectedSaved = selectedLocations;
@@ -421,14 +425,18 @@ function renderSelectedSummary() {
function downloadSizeLabel() {
const progress = state.status.downloadProgress;
if (!selectionDirty() && progress.estimatedDownloadBytes > 0) {
return `~${formatBytes(progress.estimatedDownloadBytes)}`;
return `~${formatBytes(progress.estimatedDownloadBytes)} additional`;
}
if (state.selectedDraft.length > 0) {
return "Calculated during download";
return "Not yet available";
}
return "Select regions";
}
function storageLabel() {
return state.status.storageKnown ? formatBytes(state.status.storageBytes) : "Calculating…";
}
function renderDownloadProgress() {
const progress = state.status.downloadProgress;
const visible = state.status.downloading || (!selectionDirty() && (progress.completed || progress.cancelled || progress.estimatedDownloadBytes > 0));
@@ -436,13 +444,13 @@ function renderDownloadProgress() {
const isActive = state.status.downloading;
const title = isActive ? "Download Progress" : progress.completed ? "Last Download" : progress.cancelled ? "Download Cancelled" : "Download Estimate";
const sizeLabel = progress.estimatedDownloadBytes > 0 ? `~${formatBytes(progress.estimatedDownloadBytes)} total` : "Calculating total size...";
const storedLabel = progress.downloadedBytes > 0 ? `${formatBytes(progress.downloadedBytes)} stored` : "No files stored yet";
const sizeLabel = progress.estimatedDownloadBytes > 0 ? `~${formatBytes(progress.estimatedDownloadBytes)} additional storage` : "Storage estimate unavailable";
const storedLabel = progress.downloadedBytes > 0 ? `${formatBytes(progress.downloadedBytes)} added storage` : "Storage reconciles after completion";
const filesLabel = progress.totalFiles > 0 ? `${progress.downloadedFiles} / ${progress.totalFiles} files` : "Waiting for map service...";
const etaLabel = isActive && progress.etaSeconds > 0 ? `About ${formatDuration(progress.etaSeconds)} remaining` : "ETA unavailable until files start arriving";
const sourceLabel = progress.estimateSource === "previous_download"
? "Estimate based on the last download of this exact selection."
: "Size is estimated from the map files as they arrive.";
const sourceLabel = progress.estimateSource === "previous_additional_storage"
? "Estimate based on additional storage from the last download of this exact selection."
: "File progress comes from mapd; storage is reconciled after the transfer ends.";
return html`
<div class="maps-progress-card">
@@ -523,7 +531,7 @@ export function MapsManager() {
<span class="maps-stat-value">${() => state.status.selectedCount}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Download Size</span>
<span class="maps-stat-label">Additional Storage</span>
<span class="maps-stat-value">${() => downloadSizeLabel()}</span>
</div>
<div class="maps-stat">
@@ -532,7 +540,7 @@ export function MapsManager() {
</div>
<div class="maps-stat">
<span class="maps-stat-label">Storage Used</span>
<span class="maps-stat-value">${() => formatBytes(state.status.storageBytes)}</span>
<span class="maps-stat-value">${() => storageLabel()}</span>
</div>
</div>
${() => state.error ? html`<p class="maps-error">${state.error}</p>` : ""}
@@ -1736,6 +1736,55 @@ def test_clear_generated_build_state_preserves_prebuilts_and_user_data(tmp_path)
assert user_model.read_text() == "test"
def test_maps_status_uses_cache_without_scanning_legacy_storage(monkeypatch):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
app = server.Flask(
"maps_status_test",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
server.setup(app)
monkeypatch.setattr(server, "params", FakeParams({
"MapsDownloadSizeCache": '{"country:CA":{"downloadBytes":123}}',
"MapsSelected": "",
}))
monkeypatch.setattr(server, "params_memory", FakeParams())
monkeypatch.setattr(server, "MAPS_PATH", SimpleNamespace(rglob=lambda *_args: (_ for _ in ()).throw(AssertionError("status must not scan maps"))))
response = app.test_client().get("/api/maps/status")
payload = response.get_json()
assert response.status_code == 200
assert payload["storageKnown"] is False
assert payload["storageBytes"] == 0
assert payload["mapsPresent"] is False
def test_maps_status_returns_known_storage_from_v2_cache(monkeypatch):
server = _load_server_module()
assert server._import_galaxy_web_symbols()
app = server.Flask(
"maps_status_known_test",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
server.setup(app)
monkeypatch.setattr(server, "params", FakeParams({
"MapsDownloadSizeCache": '{"version":2,"storageBytes":4096,"selections":{}}',
"MapsSelected": "",
}))
monkeypatch.setattr(server, "params_memory", FakeParams())
payload = app.test_client().get("/api/maps/status").get_json()
assert payload["storageKnown"] is True
assert payload["storageBytes"] == 4096
assert payload["mapsPresent"] is True
def test_sentry_notification_rate_limit_persists_and_expires(monkeypatch, tmp_path):
server = _load_server_module()
rate_limit_path = tmp_path / "sentry_notification_rate_limit.json"
+28 -15
View File
@@ -73,7 +73,12 @@ from openpilot.starpilot.common.maps_catalog import (
schedule_label,
schedule_param_value,
)
from openpilot.starpilot.common.maps_download_progress import load_size_cache, nonnegative_int, selection_key
from openpilot.starpilot.common.maps_download_progress import (
MAPS_STORAGE_CACHE_PARAM,
load_maps_storage_cache,
nonnegative_int,
selection_key,
)
from openpilot.starpilot.common.experimental_state import sync_persist_chill_state, sync_persist_experimental_state
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_SLOTS_PARAM,
@@ -1511,7 +1516,7 @@ MODEL_USER_FAVORITES_PARAM = "UserFavorites"
MAPS_DOWNLOAD_PARAM = "DownloadMaps"
MAPS_CANCEL_DOWNLOAD_PARAM = "CancelDownloadMaps"
MAPS_DOWNLOAD_PROGRESS_PARAM = "MapsDownloadProgress"
MAPS_DOWNLOAD_SIZE_CACHE_PARAM = "MapsDownloadSizeCache"
MAPS_DOWNLOAD_SIZE_CACHE_PARAM = MAPS_STORAGE_CACHE_PARAM
def _get_galaxy_dir():
@@ -6332,13 +6337,10 @@ def setup(app):
selected_entries = get_selected_map_entries(selected_raw)
selected_locations = [entry["token"] for entry in selected_entries]
maps_present = MAPS_PATH.exists() and any(path.is_file() for path in MAPS_PATH.rglob("*"))
storage_bytes = 0
if MAPS_PATH.exists():
try:
storage_bytes = sum(path.stat().st_size for path in MAPS_PATH.rglob("*") if path.is_file())
except Exception:
storage_bytes = 0
size_cache = load_maps_storage_cache(params.get(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, encoding="utf-8") or "")
storage_known = size_cache.storage_known
storage_bytes = size_cache.storage_bytes if storage_known else 0
maps_present = bool(size_cache.maps_present)
selected_key = selection_key(selected_locations)
raw_progress = params_memory.get(MAPS_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
@@ -6349,10 +6351,13 @@ def setup(app):
if not isinstance(download_progress, dict):
download_progress = {}
if params_memory.get_bool(MAPS_DOWNLOAD_PARAM) and "storageBytes" in download_progress:
storage_known = bool(download_progress.get("storageKnown", True))
storage_bytes = nonnegative_int(download_progress.get("storageBytes", 0)) if storage_known else 0
maps_present = storage_bytes > 0 if storage_known else False
if not params_memory.get_bool(MAPS_DOWNLOAD_PARAM) and selected_key and download_progress.get("selectedKey") != selected_key:
size_cache = load_size_cache(params.get(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, encoding="utf-8") or "")
cached_entry = size_cache.get(selected_key, {})
cached_bytes = nonnegative_int(cached_entry.get("downloadBytes", 0)) if isinstance(cached_entry, dict) else 0
cached_bytes = size_cache.selection_estimate_bytes(selected_key)
if cached_bytes > 0:
download_progress = {
"active": False,
@@ -6361,7 +6366,7 @@ def setup(app):
"downloadedBytes": 0,
"downloadedFiles": 0,
"estimatedDownloadBytes": cached_bytes,
"estimateSource": "previous_download",
"estimateSource": "previous_additional_storage",
"etaSeconds": 0,
"percent": 0,
"phase": "idle",
@@ -6369,8 +6374,9 @@ def setup(app):
"selectedKey": selected_key,
"selectedLocations": selected_locations,
"storageBytes": storage_bytes,
"totalFiles": nonnegative_int(cached_entry.get("totalFiles", 0)),
"updatedAt": cached_entry.get("updatedAt", ""),
"storageKnown": storage_known,
"totalFiles": size_cache.selection_total_files(selected_key),
"updatedAt": size_cache.selection_updated_at(selected_key),
"bytesPerSecond": 0,
}
else:
@@ -6389,6 +6395,7 @@ def setup(app):
"selectedKey": selected_key,
"selectedLocations": selected_locations,
"storageBytes": storage_bytes,
"storageKnown": storage_known,
"totalFiles": 0,
"updatedAt": "",
"bytesPerSecond": 0,
@@ -6404,6 +6411,7 @@ def setup(app):
"isOnroad": params.get_bool("IsOnroad"),
"lastUpdate": params.get("LastMapsUpdate", encoding="utf-8") or "Never",
"mapsPresent": maps_present,
"storageKnown": storage_known,
"scheduleLabel": schedule_label(params.get("PreferredSchedule")),
"scheduleOptions": MAP_SCHEDULE_OPTIONS,
"scheduleValue": schedule_param_value(params.get("PreferredSchedule")),
@@ -6490,6 +6498,11 @@ def setup(app):
if MAPS_PATH.exists():
shutil.rmtree(MAPS_PATH, ignore_errors=True)
size_cache = load_maps_storage_cache(params.get(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, encoding="utf-8") or "")
size_cache.clear()
params.put(MAPS_DOWNLOAD_SIZE_CACHE_PARAM, size_cache.to_json())
params_memory.remove(MAPS_DOWNLOAD_PROGRESS_PARAM)
return jsonify({"message": "Maps removed.", "status": _get_maps_status_payload()}), 200
@app.route("/api/params_memory", methods=["GET"])