mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-31 21:23:49 +08:00
Spruce it up
This commit is contained in:
@@ -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)})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user