diff --git a/selfdrive/ui/layouts/settings/starpilot/maps.py b/selfdrive/ui/layouts/settings/starpilot/maps.py index 4b847ba36..ddfebf264 100644 --- a/selfdrive/ui/layouts/settings/starpilot/maps.py +++ b/selfdrive/ui/layouts/settings/starpilot/maps.py @@ -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") diff --git a/starpilot/common/maps_download_progress.py b/starpilot/common/maps_download_progress.py index 76497ae47..b948efd3f 100644 --- a/starpilot/common/maps_download_progress.py +++ b/starpilot/common/maps_download_progress.py @@ -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)}) diff --git a/starpilot/common/starpilot_functions.py b/starpilot/common/starpilot_functions.py index ce7f67799..1d5d08bec 100644 --- a/starpilot/common/starpilot_functions.py +++ b/starpilot/common/starpilot_functions.py @@ -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") diff --git a/starpilot/common/tests/test_maps_download_progress.py b/starpilot/common/tests/test_maps_download_progress.py index 3d37a3b6d..6c44cf010 100644 --- a/starpilot/common/tests/test_maps_download_progress.py +++ b/starpilot/common/tests/test_maps_download_progress.py @@ -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 diff --git a/starpilot/common/tests/test_starpilot_functions.py b/starpilot/common/tests/test_starpilot_functions.py index 8e19f9896..32ee370fd 100644 --- a/starpilot/common/tests/test_starpilot_functions.py +++ b/starpilot/common/tests/test_starpilot_functions.py @@ -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" diff --git a/starpilot/system/the_galaxy/assets/components/tools/maps.js b/starpilot/system/the_galaxy/assets/components/tools/maps.js index 4f0971bf9..b83b7f78c 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/maps.js +++ b/starpilot/system/the_galaxy/assets/components/tools/maps.js @@ -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`
${state.error}
` : ""} diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index 8e81d6a18..0034daf72 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -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" diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index c39b86860..88fb5b27a 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -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"])