This commit is contained in:
firestar5683
2026-04-03 14:58:48 -05:00
parent 19da449199
commit 766cd1ed92
13 changed files with 1274 additions and 87 deletions
+58 -68
View File
@@ -1,5 +1,5 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
@@ -9,28 +9,23 @@ from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.selection_dialog import SelectionDialog
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
# --- Map Data Definitions ---
MIDWEST_MAP = {"IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "MI": "Michigan", "MN": "Minnesota", "MO": "Missouri", "NE": "Nebraska", "ND": "North Dakota", "OH": "Ohio", "SD": "South Dakota", "WI": "Wisconsin"}
NORTHEAST_MAP = {"CT": "Connecticut", "ME": "Maine", "MA": "Massachusetts", "NH": "New Hampshire", "NJ": "New Jersey", "NY": "New York", "PA": "Pennsylvania", "RI": "Rhode Island", "VT": "Vermont"}
SOUTH_MAP = {"AL": "Alabama", "AR": "Arkansas", "DE": "Delaware", "DC": "District of Columbia", "FL": "Florida", "GA": "Georgia", "KY": "Kentucky", "LA": "Louisiana", "MD": "Maryland", "MS": "Mississippi", "NC": "North Carolina", "OK": "Oklahoma", "SC": "South Carolina", "TN": "Tennessee", "TX": "Texas", "VA": "Virginia", "WV": "West Virginia"}
WEST_MAP = {"AK": "Alaska", "AZ": "Arizona", "CA": "California", "CO": "Colorado", "HI": "Hawaii", "ID": "Idaho", "MT": "Montana", "NV": "Nevada", "NM": "New Mexico", "OR": "Oregon", "UT": "Utah", "WA": "Washington", "WY": "Wyoming"}
TERRITORIES_MAP = {"AS": "American Samoa", "GU": "Guam", "MP": "Northern Mariana Islands", "PR": "Puerto Rico", "VI": "Virgin Islands"}
AFRICA_MAP = {"DZ": "Algeria", "AO": "Angola", "BJ": "Benin", "BW": "Botswana", "BF": "Burkina Faso", "BI": "Burundi", "CM": "Cameroon", "CF": "Central African Republic", "TD": "Chad", "KM": "Comoros", "CG": "Congo (Brazzaville)", "CD": "Congo (Kinshasa)", "DJ": "Djibouti", "EG": "Egypt", "GQ": "Equatorial Guinea", "ER": "Eritrea", "ET": "Ethiopia", "GA": "Gabon", "GM": "Gambia", "GH": "Ghana", "GN": "Guinea", "GW": "Guinea-Bissau", "CI": "Ivory Coast", "KE": "Kenya", "LS": "Lesotho", "LR": "Liberia", "LY": "Libya", "MG": "Madagascar", "MW": "Malawi", "ML": "Mali", "MR": "Mauritania", "MA": "Morocco", "MZ": "Mozambique", "NA": "Namibia", "NE": "Niger", "NG": "Nigeria", "RW": "Rwanda", "SN": "Senegal", "SL": "Sierra Leone", "SO": "Somalia", "ZA": "South Africa", "SS": "South Sudan", "SD": "Sudan", "SZ": "Swaziland", "TZ": "Tanzania", "TG": "Togo", "TN": "Tunisia", "UG": "Uganda", "ZM": "Zambia", "ZW": "Zimbabwe"}
ANTARCTICA_MAP = {"AQ": "Antarctica"}
ASIA_MAP = {"AF": "Afghanistan", "AM": "Armenia", "AZ": "Azerbaijan", "BH": "Bahrain", "BD": "Bangladesh", "BT": "Bhutan", "BN": "Brunei", "KH": "Cambodia", "CN": "China", "CY": "Cyprus", "TL": "East Timor", "HK": "Hong Kong", "IN": "India", "ID": "Indonesia", "IR": "Iran", "IQ": "Iraq", "IL": "Israel", "JP": "Japan", "JO": "Jordan", "KZ": "Kazakhstan", "KW": "Kuwait", "KG": "Kyrgyzstan", "LA": "Laos", "LB": "Lebanon", "MY": "Malaysia", "MV": "Maldives", "MO": "Macao", "MN": "Mongolia", "MM": "Myanmar", "NP": "Nepal", "KP": "North Korea", "OM": "Oman", "PK": "Pakistan", "PS": "Palestine", "PH": "Philippines", "QA": "Qatar", "RU": "Russia", "SA": "Saudi Arabia", "SG": "Singapore", "KR": "South Korea", "LK": "Sri Lanka", "SY": "Syria", "TW": "Taiwan", "TJ": "Tajikistan", "TH": "Thailand", "TR": "Turkey", "TM": "Turkmenistan", "AE": "United Arab Emirates", "UZ": "Uzbekistan", "VN": "Vietnam", "YE": "Yemen"}
EUROPE_MAP = {"AL": "Albania", "AT": "Austria", "BY": "Belarus", "BE": "Belgium", "BA": "Bosnia and Herzegovina", "BG": "Bulgaria", "HR": "Croatia", "CZ": "Czech Republic", "DK": "Denmark", "EE": "Estonia", "FI": "Finland", "FR": "France", "GE": "Georgia", "DE": "Germany", "GR": "Greece", "HU": "Hungary", "IS": "Iceland", "IE": "Ireland", "IT": "Italy", "KZ": "Kazakhstan", "LV": "Latvia", "LT": "Lithuania", "LU": "Luxembourg", "MK": "Macedonia", "MD": "Moldova", "ME": "Montenegro", "NL": "Netherlands", "NO": "Norway", "PL": "Poland", "PT": "Portugal", "RO": "Romania", "RS": "Serbia", "SK": "Slovakia", "SI": "Slovenia", "ES": "Spain", "SE": "Sweden", "CH": "Switzerland", "TR": "Turkey", "UA": "Ukraine", "GB": "United Kingdom"}
NORTH_AMERICA_MAP = {"BS": "Bahamas", "BZ": "Belize", "CA": "Canada", "CR": "Costa Rica", "CU": "Cuba", "DO": "Dominican Republic", "SV": "El Salvador", "GL": "Greenland", "GD": "Grenada", "GT": "Guatemala", "HT": "Haiti", "HN": "Honduras", "JM": "Jamaica", "MX": "Mexico", "NI": "Nicaragua", "PA": "Panama", "TT": "Trinidad and Tobago", "US": "United States"}
OCEANIA_MAP = {"AU": "Australia", "FJ": "Fiji", "TF": "French Southern Territories", "NC": "New Caledonia", "NZ": "New Zealand", "PG": "Papua New Guinea", "SB": "Solomon Islands", "VU": "Vanuatu"}
SOUTH_AMERICA_MAP = {"AR": "Argentina", "BO": "Bolivia", "BR": "Brazil", "CL": "Chile", "CO": "Colombia", "EC": "Ecuador", "FK": "Falkland Islands", "GY": "Guyana", "PY": "Paraguay", "PE": "Peru", "SR": "Suriname", "UY": "Uruguay", "VE": "Venezuela"}
from openpilot.starpilot.common.maps_catalog import (
COUNTRY_REGION_GROUPS,
MAP_SCHEDULE_LABELS,
MAP_SECTIONS,
STATE_REGION_GROUPS,
sanitize_selected_locations_csv,
schedule_label,
schedule_param_value,
)
class StarPilotMapRegionLayout(StarPilotPanel):
def __init__(self, region_map: dict[str, str]):
def __init__(self, region_map: dict[str, str], prefix: str):
super().__init__()
self._prefix = prefix
self.CATEGORIES = []
for key, name in sorted(region_map.items(), key=lambda item: item[1]):
self.CATEGORIES.append({
"title": name,
@@ -42,115 +37,109 @@ class StarPilotMapRegionLayout(StarPilotPanel):
self._rebuild_grid()
def _get_map_state(self, key):
selected_raw = self._params.get("MapsSelected", encoding='utf-8') or ""
selected = [k.strip() for k in selected_raw.split(",") if k.strip()]
return key in selected
selected = set(sanitize_selected_locations_csv(self._params.get("MapsSelected", encoding="utf-8") or "").split(","))
selected.discard("")
return f"{self._prefix}{key}" in selected
def _set_map_state(self, key, state):
selected_raw = self._params.get("MapsSelected", encoding='utf-8') or ""
selected = [k.strip() for k in selected_raw.split(",") if k.strip()]
if state and key not in selected:
selected.append(key)
elif not state and key in selected:
selected.remove(key)
self._params.put("MapsSelected", ",".join(selected))
selected = set(sanitize_selected_locations_csv(self._params.get("MapsSelected", encoding="utf-8") or "").split(","))
selected.discard("")
prefixed_key = f"{self._prefix}{key}"
if state:
selected.add(prefixed_key)
else:
selected.discard(prefixed_key)
self._params.put("MapsSelected", sanitize_selected_locations_csv(sorted(selected)))
class StarPilotMapCountriesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Africa"), "panel": "africa", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Antarctica"), "panel": "antarctica", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Asia"), "panel": "asia", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Europe"), "panel": "europe", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("North America"), "panel": "north_america", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Oceania"), "panel": "oceania", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("South America"), "panel": "south_america", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop(group["title"]), "panel": group["key"], "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"}
for group in COUNTRY_REGION_GROUPS
]
self._rebuild_grid()
class StarPilotMapStatesLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self.CATEGORIES = [
{"title": tr_noop("Midwest"), "panel": "midwest", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Northeast"), "panel": "northeast", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("South"), "panel": "south", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("West"), "panel": "west", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Territories"), "panel": "territories", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop(group["title"]), "panel": group["key"], "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"}
for group in STATE_REGION_GROUPS
]
self._rebuild_grid()
class StarPilotMapsLayout(StarPilotPanel):
def __init__(self):
super().__init__()
self._sub_panels = {
"countries": StarPilotMapCountriesLayout(),
"states": StarPilotMapStatesLayout(),
"africa": StarPilotMapRegionLayout(AFRICA_MAP),
"antarctica": StarPilotMapRegionLayout(ANTARCTICA_MAP),
"asia": StarPilotMapRegionLayout(ASIA_MAP),
"europe": StarPilotMapRegionLayout(EUROPE_MAP),
"north_america": StarPilotMapRegionLayout(NORTH_AMERICA_MAP),
"oceania": StarPilotMapRegionLayout(OCEANIA_MAP),
"south_america": StarPilotMapRegionLayout(SOUTH_AMERICA_MAP),
"midwest": StarPilotMapRegionLayout(MIDWEST_MAP),
"northeast": StarPilotMapRegionLayout(NORTHEAST_MAP),
"south": StarPilotMapRegionLayout(SOUTH_MAP),
"west": StarPilotMapRegionLayout(WEST_MAP),
"territories": StarPilotMapRegionLayout(TERRITORIES_MAP),
}
for section in MAP_SECTIONS:
for group in section["groups"]:
self._sub_panels[group["key"]] = StarPilotMapRegionLayout(group["regions"], section["prefix"])
self.CATEGORIES = [
{"title": tr_noop("Download Maps"), "type": "hub", "on_click": self._on_download, "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Auto Update Schedule"), "type": "value", "get_value": lambda: self._params.get("PreferredSchedule", encoding='utf-8') or "Manually", "on_click": self._on_schedule, "icon": "toggle_icons/icon_calendar.png", "color": "#68ACA3"},
{"title": tr_noop("Auto Update Schedule"), "type": "value", "get_value": lambda: schedule_label(self._params.get("PreferredSchedule")), "on_click": self._on_schedule, "icon": "toggle_icons/icon_calendar.png", "color": "#68ACA3"},
{"title": tr_noop("Countries"), "panel": "countries", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("U.S. States"), "panel": "states", "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
{"title": tr_noop("Storage Used"), "type": "value", "get_value": self._get_storage, "on_click": lambda: None, "icon": "toggle_icons/icon_system.png", "color": "#68ACA3"},
{"title": tr_noop("Remove Maps"), "type": "hub", "on_click": self._on_remove, "icon": "toggle_icons/icon_map.png", "color": "#68ACA3"},
]
for name, panel in self._sub_panels.items():
if hasattr(panel, 'set_navigate_callback'): panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, 'set_back_callback'): panel.set_back_callback(self._go_back)
for _, panel in self._sub_panels.items():
if hasattr(panel, "set_navigate_callback"):
panel.set_navigate_callback(self._navigate_to)
if hasattr(panel, "set_back_callback"):
panel.set_back_callback(self._go_back)
self._rebuild_grid()
def _get_storage(self) -> str:
maps_path = Path("/data/media/0/osm/offline")
if not maps_path.exists():
return "0 MB"
total_size = sum(f.stat().st_size for f in maps_path.rglob('*') if f.is_file())
total_size = sum(f.stat().st_size for f in maps_path.rglob("*") if f.is_file())
mb = total_size / (1024 * 1024)
if mb > 1024:
return f"{(mb / 1024):.2f} GB"
return f"{mb:.2f} MB"
def _on_schedule(self):
options = ["Manually", "Weekly", "Monthly"]
current = self._params.get("PreferredSchedule", encoding='utf-8') or "Manually"
options = list(MAP_SCHEDULE_LABELS.values())
current = schedule_label(self._params.get("PreferredSchedule"))
def on_select(res, val):
if res == DialogResult.CONFIRM:
self._params.put("PreferredSchedule", val)
self._params.put("PreferredSchedule", schedule_param_value(val))
self._rebuild_grid()
gui_app.set_modal_overlay(SelectionDialog(tr("Auto Update Schedule"), options, current, on_close=on_select))
def _on_download(self):
selected_raw = self._params.get("MapsSelected", encoding='utf-8') or ""
current_selected = self._params.get("MapsSelected", encoding="utf-8") or ""
selected_raw = sanitize_selected_locations_csv(current_selected)
if selected_raw != current_selected:
self._params.put("MapsSelected", selected_raw)
selected = [k.strip() for k in selected_raw.split(",") if k.strip()]
if not selected:
gui_app.set_modal_overlay(alert_dialog(tr("Please select at least one region or state first!")))
return
def on_confirm(res):
if res == DialogResult.CONFIRM:
self._params_memory.put_bool("DownloadMaps", True)
gui_app.set_modal_overlay(alert_dialog(tr("Map download started in background.")))
gui_app.set_modal_overlay(ConfirmDialog(tr("Start downloading maps for selected regions?"), tr("Download"), on_close=on_confirm))
def _on_remove(self):
@@ -161,4 +150,5 @@ class StarPilotMapsLayout(StarPilotPanel):
shutil.rmtree(maps_path, ignore_errors=True)
gui_app.set_modal_overlay(alert_dialog(tr("Maps removed.")))
self._rebuild_grid()
gui_app.set_modal_overlay(ConfirmDialog(tr("Delete all downloaded map data?"), tr("Remove"), on_close=on_confirm))
@@ -0,0 +1,5 @@
def should_show_forget_button(network=None, *, is_saved: bool = False, is_connected: bool = False) -> bool:
if network is not None:
return bool(network.is_saved or network.is_connected)
return bool(is_saved or is_connected)
@@ -9,6 +9,7 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, Big
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.widgets import Widget, NavWidget
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType
from openpilot.selfdrive.ui.mici.layouts.settings.network.action_state import should_show_forget_button
def normalize_ssid(ssid: str) -> str:
@@ -210,6 +211,7 @@ class NetworkInfoPage(NavWidget):
# State
self._network: Network | None = None
self._connecting: Callable[[], str | None] | None = None
self._show_forget_btn = False
def show_event(self):
super().show_event()
@@ -233,7 +235,8 @@ class NetworkInfoPage(NavWidget):
if self._network is None:
return
self._connect_btn.set_full(not self._network.is_saved and not self._is_connecting)
self._show_forget_btn = should_show_forget_button(self._network)
self._connect_btn.set_full(not self._show_forget_btn)
if self._is_connecting:
self._connect_btn.set_label("connecting...")
self._connect_btn.set_enabled(False)
@@ -298,7 +301,7 @@ class NetworkInfoPage(NavWidget):
self._connect_btn.rect.height,
))
if not self._connect_btn.full:
if self._show_forget_btn:
self._forget_btn.render(rl.Rectangle(
self._rect.x + self._rect.width - self._forget_btn.rect.width,
self._rect.y + self._rect.height - self._forget_btn.rect.height,
+29
View File
@@ -0,0 +1,29 @@
import importlib.util
import unittest
from pathlib import Path
from types import SimpleNamespace
MODULE_PATH = Path(__file__).resolve().parents[1] / "mici" / "layouts" / "settings" / "network" / "action_state.py"
SPEC = importlib.util.spec_from_file_location("wifi_ui_action_state_under_test", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC is not None and SPEC.loader is not None
SPEC.loader.exec_module(MODULE)
should_show_forget_button = MODULE.should_show_forget_button
class TestWifiUI(unittest.TestCase):
def test_should_show_forget_button_for_connected_network_without_saved_flag(self):
network = SimpleNamespace(is_saved=False, is_connected=True)
self.assertTrue(should_show_forget_button(network))
def test_should_show_forget_button_for_saved_network(self):
network = SimpleNamespace(is_saved=True, is_connected=False)
self.assertTrue(should_show_forget_button(network))
def test_should_hide_forget_button_for_unsaved_disconnected_network(self):
network = SimpleNamespace(is_saved=False, is_connected=False)
self.assertFalse(should_show_forget_button(network))
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from openpilot.starpilot.common.maps_selection import COUNTRY_PREFIX, STATE_PREFIX, normalize_maps_selected
MAP_SCHEDULE_LABELS = {
0: "Manually",
1: "Weekly",
2: "Monthly",
}
MAP_SCHEDULE_VALUE_BY_LABEL = {label: value for value, label in MAP_SCHEDULE_LABELS.items()}
MAP_SCHEDULE_OPTIONS = [
{"value": value, "label": label}
for value, label in MAP_SCHEDULE_LABELS.items()
]
COUNTRY_REGION_GROUPS = (
{"key": "africa", "title": "Africa", "regions": {"DZ": "Algeria", "AO": "Angola", "BJ": "Benin", "BW": "Botswana", "BF": "Burkina Faso", "BI": "Burundi", "CM": "Cameroon", "CF": "Central African Republic", "TD": "Chad", "KM": "Comoros", "CG": "Congo (Brazzaville)", "CD": "Congo (Kinshasa)", "DJ": "Djibouti", "EG": "Egypt", "GQ": "Equatorial Guinea", "ER": "Eritrea", "ET": "Ethiopia", "GA": "Gabon", "GM": "Gambia", "GH": "Ghana", "GN": "Guinea", "GW": "Guinea-Bissau", "CI": "Ivory Coast", "KE": "Kenya", "LS": "Lesotho", "LR": "Liberia", "LY": "Libya", "MG": "Madagascar", "MW": "Malawi", "ML": "Mali", "MR": "Mauritania", "MA": "Morocco", "MZ": "Mozambique", "NA": "Namibia", "NE": "Niger", "NG": "Nigeria", "RW": "Rwanda", "SN": "Senegal", "SL": "Sierra Leone", "SO": "Somalia", "ZA": "South Africa", "SS": "South Sudan", "SD": "Sudan", "SZ": "Swaziland", "TZ": "Tanzania", "TG": "Togo", "TN": "Tunisia", "UG": "Uganda", "ZM": "Zambia", "ZW": "Zimbabwe"}},
{"key": "antarctica", "title": "Antarctica", "regions": {"AQ": "Antarctica"}},
{"key": "asia", "title": "Asia", "regions": {"AF": "Afghanistan", "AM": "Armenia", "AZ": "Azerbaijan", "BH": "Bahrain", "BD": "Bangladesh", "BT": "Bhutan", "BN": "Brunei", "KH": "Cambodia", "CN": "China", "CY": "Cyprus", "TL": "East Timor", "HK": "Hong Kong", "IN": "India", "ID": "Indonesia", "IR": "Iran", "IQ": "Iraq", "IL": "Israel", "JP": "Japan", "JO": "Jordan", "KZ": "Kazakhstan", "KW": "Kuwait", "KG": "Kyrgyzstan", "LA": "Laos", "LB": "Lebanon", "MY": "Malaysia", "MV": "Maldives", "MO": "Macao", "MN": "Mongolia", "MM": "Myanmar", "NP": "Nepal", "KP": "North Korea", "OM": "Oman", "PK": "Pakistan", "PS": "Palestine", "PH": "Philippines", "QA": "Qatar", "RU": "Russia", "SA": "Saudi Arabia", "SG": "Singapore", "KR": "South Korea", "LK": "Sri Lanka", "SY": "Syria", "TW": "Taiwan", "TJ": "Tajikistan", "TH": "Thailand", "TR": "Turkey", "TM": "Turkmenistan", "AE": "United Arab Emirates", "UZ": "Uzbekistan", "VN": "Vietnam", "YE": "Yemen"}},
{"key": "europe", "title": "Europe", "regions": {"AL": "Albania", "AT": "Austria", "BY": "Belarus", "BE": "Belgium", "BA": "Bosnia and Herzegovina", "BG": "Bulgaria", "HR": "Croatia", "CZ": "Czech Republic", "DK": "Denmark", "EE": "Estonia", "FI": "Finland", "FR": "France", "GE": "Georgia", "DE": "Germany", "GR": "Greece", "HU": "Hungary", "IS": "Iceland", "IE": "Ireland", "IT": "Italy", "KZ": "Kazakhstan", "LV": "Latvia", "LT": "Lithuania", "LU": "Luxembourg", "MK": "Macedonia", "MD": "Moldova", "ME": "Montenegro", "NL": "Netherlands", "NO": "Norway", "PL": "Poland", "PT": "Portugal", "RO": "Romania", "RS": "Serbia", "SK": "Slovakia", "SI": "Slovenia", "ES": "Spain", "SE": "Sweden", "CH": "Switzerland", "TR": "Turkey", "UA": "Ukraine", "GB": "United Kingdom"}},
{"key": "north_america", "title": "North America", "regions": {"BS": "Bahamas", "BZ": "Belize", "CA": "Canada", "CR": "Costa Rica", "CU": "Cuba", "DO": "Dominican Republic", "SV": "El Salvador", "GL": "Greenland", "GD": "Grenada", "GT": "Guatemala", "HT": "Haiti", "HN": "Honduras", "JM": "Jamaica", "MX": "Mexico", "NI": "Nicaragua", "PA": "Panama", "TT": "Trinidad and Tobago", "US": "United States"}},
{"key": "oceania", "title": "Oceania", "regions": {"AU": "Australia", "FJ": "Fiji", "TF": "French Southern Territories", "NC": "New Caledonia", "NZ": "New Zealand", "PG": "Papua New Guinea", "SB": "Solomon Islands", "VU": "Vanuatu"}},
{"key": "south_america", "title": "South America", "regions": {"AR": "Argentina", "BO": "Bolivia", "BR": "Brazil", "CL": "Chile", "CO": "Colombia", "EC": "Ecuador", "FK": "Falkland Islands", "GY": "Guyana", "PY": "Paraguay", "PE": "Peru", "SR": "Suriname", "UY": "Uruguay", "VE": "Venezuela"}},
)
STATE_REGION_GROUPS = (
{"key": "midwest", "title": "Midwest", "regions": {"IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "MI": "Michigan", "MN": "Minnesota", "MO": "Missouri", "NE": "Nebraska", "ND": "North Dakota", "OH": "Ohio", "SD": "South Dakota", "WI": "Wisconsin"}},
{"key": "northeast", "title": "Northeast", "regions": {"CT": "Connecticut", "ME": "Maine", "MA": "Massachusetts", "NH": "New Hampshire", "NJ": "New Jersey", "NY": "New York", "PA": "Pennsylvania", "RI": "Rhode Island", "VT": "Vermont"}},
{"key": "south", "title": "South", "regions": {"AL": "Alabama", "AR": "Arkansas", "DE": "Delaware", "DC": "District of Columbia", "FL": "Florida", "GA": "Georgia", "KY": "Kentucky", "LA": "Louisiana", "MD": "Maryland", "MS": "Mississippi", "NC": "North Carolina", "OK": "Oklahoma", "SC": "South Carolina", "TN": "Tennessee", "TX": "Texas", "VA": "Virginia", "WV": "West Virginia"}},
{"key": "west", "title": "West", "regions": {"AK": "Alaska", "AZ": "Arizona", "CA": "California", "CO": "Colorado", "HI": "Hawaii", "ID": "Idaho", "MT": "Montana", "NV": "Nevada", "NM": "New Mexico", "OR": "Oregon", "UT": "Utah", "WA": "Washington", "WY": "Wyoming"}},
{"key": "territories", "title": "Territories", "regions": {"AS": "American Samoa", "GU": "Guam", "MP": "Northern Mariana Islands", "PR": "Puerto Rico", "VI": "Virgin Islands"}},
)
MAP_SECTIONS = (
{"key": "countries", "title": "Countries", "prefix": COUNTRY_PREFIX, "groups": COUNTRY_REGION_GROUPS},
{"key": "states", "title": "U.S. States", "prefix": STATE_PREFIX, "groups": STATE_REGION_GROUPS},
)
def normalize_schedule_value(value) -> int:
if isinstance(value, bytes):
value = value.decode("utf-8", errors="ignore")
if isinstance(value, str):
value = value.strip()
if not value:
return 2
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
value = int(value)
else:
value = MAP_SCHEDULE_VALUE_BY_LABEL.get(value, 2)
try:
normalized = int(value)
except (TypeError, ValueError):
return 2
return normalized if normalized in MAP_SCHEDULE_LABELS else 2
def schedule_label(value) -> str:
return MAP_SCHEDULE_LABELS[normalize_schedule_value(value)]
def schedule_param_value(value) -> str:
return str(normalize_schedule_value(value))
def _sorted_regions(regions):
return sorted(regions.items(), key=lambda item: item[1])
def get_maps_catalog():
sections = []
for section in MAP_SECTIONS:
groups = []
for group in section["groups"]:
regions = [
{
"code": code,
"label": label,
"token": f"{section['prefix']}{code}",
}
for code, label in _sorted_regions(group["regions"])
]
groups.append({
"key": group["key"],
"title": group["title"],
"prefix": section["prefix"],
"regions": regions,
})
sections.append({
"key": section["key"],
"title": section["title"],
"prefix": section["prefix"],
"groups": groups,
})
return sections
MAPS_CATALOG = get_maps_catalog()
MAP_TOKEN_LABELS = {
region["token"]: region["label"]
for section in MAPS_CATALOG
for group in section["groups"]
for region in group["regions"]
}
VALID_MAP_TOKENS = frozenset(MAP_TOKEN_LABELS)
def get_selected_map_tokens(selected_raw) -> list[str]:
normalized = normalize_maps_selected(selected_raw)
return [token for token in normalized.split(",") if token and token in VALID_MAP_TOKENS]
def sanitize_selected_locations_csv(values) -> str:
if isinstance(values, str):
raw = values
elif values is None:
raw = ""
else:
raw = ",".join(str(value).strip() for value in values if str(value).strip())
tokens = get_selected_map_tokens(raw)
return ",".join(tokens)
def get_selected_map_entries(selected_raw) -> list[dict[str, str]]:
return [
{"token": token, "label": MAP_TOKEN_LABELS[token]}
for token in get_selected_map_tokens(selected_raw)
]
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import json
COUNTRY_PREFIX = "nation."
STATE_PREFIX = "us_state."
# Legacy C3 map selection stored bare region codes instead of the prefixed
# keys consumed by mapd and the Qt settings path.
US_STATE_CODES = frozenset({
"AK", "AL", "AR", "AS", "AZ", "CA", "CO", "CT", "DC", "DE", "FL", "GA",
"GU", "HI", "IA", "ID", "IL", "IN", "KS", "KY", "LA", "MA", "MD", "ME",
"MI", "MN", "MO", "MP", "MS", "MT", "NC", "ND", "NE", "NH", "NJ", "NM",
"NV", "NY", "OH", "OK", "OR", "PA", "PR", "RI", "SC", "SD", "TN", "TX",
"UT", "VA", "VI", "WA", "WI", "WV", "WY",
})
COUNTRY_CODES = frozenset({
"AF", "AL", "AM", "AO", "AQ", "AR", "AT", "AU", "AZ", "BA", "BD", "BE",
"BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BY",
"BZ", "CA", "CD", "CF", "CG", "CH", "CI", "CL", "CM", "CN", "CO", "CR",
"CU", "CY", "CZ", "DE", "DJ", "DK", "DO", "DZ", "EC", "EE", "EG", "ER",
"ES", "ET", "FJ", "FK", "FR", "GA", "GB", "GD", "GE", "GH", "GL", "GM",
"GN", "GQ", "GR", "GT", "GU", "GW", "GY", "HK", "HN", "HR", "HT", "HU",
"ID", "IE", "IL", "IN", "IQ", "IR", "IS", "IT", "JM", "JO", "JP", "KE",
"KG", "KH", "KM", "KP", "KR", "KW", "KZ", "LA", "LB", "LK", "LR", "LS",
"LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MK", "ML", "MM",
"MN", "MO", "MR", "MS", "MT", "MV", "MW", "MX", "MY", "MZ", "NA", "NC",
"NE", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH",
"PK", "PL", "PS", "PT", "PY", "QA", "RO", "RS", "RU", "RW", "SA", "SB",
"SC", "SD", "SE", "SG", "SI", "SK", "SL", "SN", "SO", "SR", "SS", "SV",
"SY", "SZ", "TD", "TF", "TG", "TH", "TJ", "TL", "TM", "TN", "TR", "TT",
"TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VE", "VN", "VU", "YE", "ZA",
"ZM", "ZW",
})
LEGACY_AMBIGUOUS_CODES = COUNTRY_CODES & US_STATE_CODES
def _normalize_json_selection(selected_raw: str) -> str | None:
try:
data = json.loads(selected_raw)
except (json.JSONDecodeError, TypeError, ValueError):
return None
if not isinstance(data, dict):
return None
normalized = []
for nation in data.get("nations", []):
normalized.append(f"{COUNTRY_PREFIX}{nation}")
for state in data.get("states", []):
normalized.append(f"{STATE_PREFIX}{state}")
return ",".join(sorted(dict.fromkeys(normalized)))
def normalize_map_token(token: str) -> str | None:
token = token.strip()
if not token:
return None
if token.startswith(COUNTRY_PREFIX) or token.startswith(STATE_PREFIX):
return token
if token in US_STATE_CODES and token not in COUNTRY_CODES:
return f"{STATE_PREFIX}{token}"
if token in COUNTRY_CODES and token not in US_STATE_CODES:
return f"{COUNTRY_PREFIX}{token}"
if token in LEGACY_AMBIGUOUS_CODES:
# Old C3 selections are ambiguous for a handful of bare codes like CA/IN.
# Prefer U.S. states here so the common state-download flow keeps working;
# users who intended the country can reselect once in the fixed UI.
return f"{STATE_PREFIX}{token}"
return None
def normalize_maps_selected(selected_raw: str | bytes | None) -> str:
if isinstance(selected_raw, bytes):
selected_raw = selected_raw.decode("utf-8", errors="ignore")
if not selected_raw:
return ""
json_normalized = _normalize_json_selection(selected_raw)
if json_normalized is not None:
return json_normalized
normalized = []
seen = set()
for token in selected_raw.split(","):
normalized_token = normalize_map_token(token)
if normalized_token is not None and normalized_token not in seen:
normalized.append(normalized_token)
seen.add(normalized_token)
normalized.sort()
return ",".join(normalized)
+14 -16
View File
@@ -17,6 +17,7 @@ from openpilot.system.version import get_build_metadata
from openpilot.starpilot.assets.theme_manager import ThemeManager
from openpilot.starpilot.common.starpilot_backups import backup_starpilot
from openpilot.starpilot.common.maps_catalog import normalize_schedule_value, sanitize_selected_locations_csv
from openpilot.starpilot.common.starpilot_utilities import get_starpilot_api_info, is_FrogsGoMoo, is_url_pingable, run_cmd, use_konik_server
from openpilot.starpilot.common.starpilot_variables import (
ERROR_LOGS_PATH, STARPILOT_API, FROGS_GO_MOO_PATH, HD_LOGS_PATH, KONIK_LOGS_PATH, MAPS_PATH, THEME_SAVE_PATH,
@@ -27,20 +28,12 @@ from openpilot.starpilot.common.starpilot_variables import (
def starpilot_boot_functions(build_metadata, params):
params_memory = Params(memory=True)
maps_selected = params.get("MapsSelected")
if maps_selected:
try:
data = json.loads(maps_selected)
if isinstance(data, dict):
new_items = []
for nation in data.get("nations", []):
new_items.append(f"nation.{nation}")
for state in data.get("states", []):
new_items.append(f"us_state.{state}")
new_items.sort()
params.put("MapsSelected", ",".join(new_items))
except (json.JSONDecodeError, TypeError, ValueError):
pass
maps_selected_raw = params.get("MapsSelected")
maps_selected = sanitize_selected_locations_csv(maps_selected_raw)
if isinstance(maps_selected_raw, bytes):
maps_selected_raw = maps_selected_raw.decode("utf-8", errors="ignore")
if maps_selected != (maps_selected_raw or ""):
params.put("MapsSelected", maps_selected)
params.put("BuildMetadata", json.dumps(dataclasses.asdict(build_metadata)))
@@ -184,14 +177,19 @@ def update_boot_logo(starpilot=False, stock=False, selected_logo=None):
def update_maps(now, params, params_memory, manual_update=False):
maps_selected = params.get("MapsSelected")
maps_selected_raw = params.get("MapsSelected")
maps_selected = sanitize_selected_locations_csv(maps_selected_raw)
if not maps_selected:
return
if isinstance(maps_selected_raw, bytes):
maps_selected_raw = maps_selected_raw.decode("utf-8", errors="ignore")
if maps_selected != (maps_selected_raw or ""):
params.put("MapsSelected", maps_selected)
day = now.day
is_first = day == 1
is_sunday = now.weekday() == 6
schedule = params.get("PreferredSchedule")
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:
@@ -7,6 +7,7 @@ import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
import { MapsManager } from "/assets/components/tools/maps.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js"
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
@@ -71,6 +72,7 @@ function Root() {
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("model_manager", "/manage_models", ModelManager),
createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers),
createRoute("maps", "/manage_maps", MapsManager),
createRoute("plots", "/plots", LivePlots),
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("testing_ground", "/testing_ground", TestingGround),
@@ -15,6 +15,7 @@ const MENU_ITEMS = {
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" },
{ name: "Maps", link: "/manage_maps", icon: "bi-map" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
@@ -0,0 +1,286 @@
.maps-page {
display: flex;
flex-direction: column;
gap: var(--gap-lg);
max-width: 1400px;
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
.maps-card {
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
padding: var(--padding-lg);
}
.maps-hero {
display: flex;
flex-direction: column;
gap: var(--gap-lg);
}
.maps-hero-copy h1,
.maps-selected-summary h2,
.maps-section-header h2,
.maps-group-header h3 {
margin: 0;
}
.maps-hero-copy p,
.maps-section-header p,
.maps-group-header p,
.maps-muted,
.maps-error,
.maps-warning {
margin: 0;
}
.maps-status-grid {
display: grid;
gap: var(--gap-sm);
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.maps-stat {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 5.25rem;
padding: 0.8rem;
}
.maps-stat-label {
color: var(--text-muted);
font-size: 0.82rem;
text-transform: uppercase;
}
.maps-stat-value {
font-size: 1rem;
font-weight: var(--font-weight-bold);
}
.maps-error {
color: var(--danger-fg);
}
.maps-warning {
color: var(--warning-bg);
}
.maps-action-row,
.maps-toolbar-row,
.maps-toolbar-meta,
.maps-group-actions {
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
}
.maps-btn {
border: none;
border-radius: var(--border-radius-base);
color: var(--text-color);
min-height: 2.35rem;
padding: 0.5rem 0.9rem;
}
.maps-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.maps-btn-primary {
background: var(--success-bg);
color: var(--color-black);
}
.maps-btn-primary:hover:not(:disabled) {
background: var(--success-hover-bg);
}
.maps-btn-secondary {
background: var(--sidebar-active-bg);
}
.maps-btn-secondary:hover:not(:disabled) {
background: var(--accent-hover-bg);
}
.maps-btn-danger {
background: var(--danger-bg);
}
.maps-btn-danger:hover:not(:disabled) {
background: var(--danger-hover-bg);
}
.maps-btn-small {
min-height: 1.9rem;
padding: 0.3rem 0.65rem;
}
.maps-toolbar-card {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.maps-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.maps-field span {
color: var(--text-muted);
font-size: 0.84rem;
}
.maps-search-field {
flex: 1 1 24rem;
}
.maps-schedule-field {
min-width: 13rem;
}
.maps-input,
.maps-select {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
color: var(--text-color);
font-size: var(--font-size-base);
min-height: 2.3rem;
padding: 0.45rem 0.6rem;
}
.maps-schedule-button {
align-self: flex-end;
}
.maps-selected-summary {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.maps-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.maps-chip {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: 999px;
color: var(--text-color);
font-size: 0.82rem;
padding: 0.2rem 0.6rem;
}
.maps-section-card {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.maps-section-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.maps-section-header p,
.maps-group-header p {
color: var(--text-muted);
}
.maps-group-grid {
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
}
.maps-group-card {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
padding: 0.85rem;
}
.maps-group-header {
align-items: flex-start;
display: flex;
gap: var(--gap-sm);
justify-content: space-between;
}
.maps-region-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 21rem;
overflow-y: auto;
padding-right: 0.15rem;
}
.maps-region-row {
align-items: center;
background: rgba(255, 255, 255, 0.02);
border: 1px solid transparent;
border-radius: var(--border-radius-sm);
display: grid;
gap: 0.6rem;
grid-template-columns: auto 1fr auto;
padding: 0.4rem 0.5rem;
}
.maps-region-row:hover {
border-color: var(--sidebar-border-color);
}
.maps-region-label {
min-width: 0;
}
.maps-region-code {
color: var(--text-muted);
font-size: 0.8rem;
}
@media (max-width: 900px) {
.maps-page {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
.maps-action-row,
.maps-toolbar-row {
flex-direction: column;
}
.maps-btn,
.maps-schedule-field,
.maps-search-field {
width: 100%;
}
.maps-schedule-button {
align-self: stretch;
}
.maps-section-header,
.maps-group-header {
flex-direction: column;
}
}
@@ -0,0 +1,520 @@
import { html, reactive } from "/assets/vendor/arrow-core.js";
const REQUEST_TIMEOUT_MS = 15000;
const ACTIVE_POLL_INTERVAL_MS = 1000;
const IDLE_POLL_INTERVAL_MS = 4000;
const state = reactive({
actionBusy: false,
catalogLoaded: false,
catalogSections: [],
error: "",
fetched: false,
loadingCatalog: true,
loadingStatus: true,
savingSchedule: false,
savingSelection: false,
scheduleDraft: "2",
scheduleSaved: "2",
scheduleOptions: [],
search: "",
selectedDraft: [],
selectedSaved: [],
status: {
cancelling: false,
downloading: false,
hasSelection: false,
isOnroad: false,
lastUpdate: "Never",
mapsPresent: false,
scheduleLabel: "Monthly",
selectedCount: 0,
storageBytes: 0,
},
tokenLabels: {},
});
let pollingHandle = null;
let statusInFlight = false;
function notify(message, variant = "success") {
if (typeof showSnackbar === "function") {
showSnackbar(message, variant);
} else if (variant === "error") {
console.error(message);
} else {
console.log(message);
}
}
function isMapsRouteActive() {
return window.location.pathname === "/manage_maps";
}
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
promise.then((value) => {
clearTimeout(timerId);
resolve(value);
}).catch((error) => {
clearTimeout(timerId);
reject(error);
});
});
}
async function fetchJson(url, options = {}) {
const response = await withTimeout(fetch(url, options), REQUEST_TIMEOUT_MS, `${url} request`);
const payload = await withTimeout(response.json(), REQUEST_TIMEOUT_MS, `${url} JSON parse`);
if (!response.ok) {
throw new Error(payload?.error || payload?.message || `Request failed (${response.status})`);
}
return payload;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value <= 0) return "0 MB";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const scaled = value / (1024 ** index);
return `${scaled >= 10 || index === 0 ? scaled.toFixed(0) : scaled.toFixed(2)} ${units[index]}`;
}
function uniqueSorted(values) {
return [...new Set(values)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
function selectionDirty() {
return !arraysEqual(state.selectedDraft, state.selectedSaved);
}
function scheduleDirty() {
return state.scheduleDraft !== state.scheduleSaved;
}
function getTokenLabel(token) {
return state.tokenLabels[token] || token;
}
function setDraftSelection(nextSelection) {
state.selectedDraft = uniqueSorted(nextSelection);
}
function toggleToken(token, enabled) {
const next = new Set(state.selectedDraft);
if (enabled) {
next.add(token);
} else {
next.delete(token);
}
setDraftSelection([...next]);
}
function setGroup(group, enabled) {
const next = new Set(state.selectedDraft);
for (const region of group.regions || []) {
if (enabled) {
next.add(region.token);
} else {
next.delete(region.token);
}
}
setDraftSelection([...next]);
}
function applyStatus(payload) {
const hadSelectionChanges = selectionDirty();
const hadScheduleChanges = scheduleDirty();
const selectedLocations = Array.isArray(payload.selectedLocations) ? uniqueSorted(payload.selectedLocations) : [];
state.status = {
cancelling: Boolean(payload.cancelling),
downloading: Boolean(payload.downloading),
hasSelection: Boolean(payload.hasSelection),
isOnroad: Boolean(payload.isOnroad),
lastUpdate: payload.lastUpdate || "Never",
mapsPresent: Boolean(payload.mapsPresent),
scheduleLabel: payload.scheduleLabel || "Monthly",
selectedCount: Number(payload.selectedCount || 0),
storageBytes: Number(payload.storageBytes || 0),
};
state.selectedSaved = selectedLocations;
if (!hadSelectionChanges) {
state.selectedDraft = selectedLocations;
}
const scheduleValue = String(payload.scheduleValue ?? "2");
state.scheduleSaved = scheduleValue;
if (!hadScheduleChanges) {
state.scheduleDraft = scheduleValue;
}
}
async function fetchCatalog() {
const payload = await fetchJson("/api/maps/catalog");
state.catalogSections = Array.isArray(payload.sections) ? payload.sections : [];
state.scheduleOptions = Array.isArray(payload.scheduleOptions) ? payload.scheduleOptions : [];
const tokenLabels = {};
for (const section of state.catalogSections) {
for (const group of section.groups || []) {
for (const region of group.regions || []) {
tokenLabels[region.token] = region.label;
}
}
}
state.tokenLabels = tokenLabels;
state.catalogLoaded = true;
state.loadingCatalog = false;
}
async function fetchStatus() {
if (statusInFlight) return;
statusInFlight = true;
try {
const payload = await fetchJson("/api/maps/status");
applyStatus(payload);
state.error = "";
} catch (error) {
state.error = error?.message || String(error);
} finally {
state.loadingStatus = false;
statusInFlight = false;
}
}
async function initializeMaps() {
state.loadingCatalog = true;
state.loadingStatus = true;
try {
await fetchCatalog();
await fetchStatus();
} catch (error) {
state.error = error?.message || String(error);
state.loadingCatalog = false;
state.loadingStatus = false;
}
}
function ensurePolling() {
if (pollingHandle) return;
const poll = async () => {
if (!isMapsRouteActive()) {
pollingHandle = null;
return;
}
try {
await fetchStatus();
} finally {
const nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
pollingHandle = setTimeout(poll, nextDelay);
}
};
pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS);
}
async function saveSelection() {
if (state.savingSelection || !selectionDirty()) return;
state.savingSelection = true;
try {
const payload = await fetchJson("/api/maps/selection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedLocations: state.selectedDraft }),
});
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map selection saved.");
} catch (error) {
notify(error?.message || "Failed to save map selection.", "error");
} finally {
state.savingSelection = false;
}
}
async function saveSchedule() {
if (state.savingSchedule || !scheduleDirty()) return;
state.savingSchedule = true;
try {
const payload = await fetchJson("/api/maps/schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ schedule: state.scheduleDraft }),
});
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map schedule updated.");
} catch (error) {
notify(error?.message || "Failed to update map schedule.", "error");
} finally {
state.savingSchedule = false;
}
}
async function startDownload() {
if (state.actionBusy || state.status.downloading || state.status.isOnroad || state.selectedDraft.length === 0) {
return;
}
state.actionBusy = true;
try {
const payload = await fetchJson("/api/maps/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
selectedLocations: state.selectedDraft,
schedule: state.scheduleDraft,
}),
});
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map download started.");
} catch (error) {
notify(error?.message || "Failed to start map download.", "error");
} finally {
state.actionBusy = false;
}
}
async function cancelDownload() {
if (state.actionBusy || !state.status.downloading) return;
state.actionBusy = true;
try {
const payload = await fetchJson("/api/maps/cancel", { method: "POST" });
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map download cancellation requested.");
} catch (error) {
notify(error?.message || "Failed to cancel map download.", "error");
} finally {
state.actionBusy = false;
}
}
async function removeMaps() {
if (state.actionBusy || state.status.downloading || state.status.isOnroad || !state.status.mapsPresent) {
return;
}
state.actionBusy = true;
try {
const payload = await fetchJson("/api/maps/remove", { method: "POST" });
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Maps removed.");
} catch (error) {
notify(error?.message || "Failed to remove maps.", "error");
} finally {
state.actionBusy = false;
}
}
function resetDraftSelection() {
state.selectedDraft = [...state.selectedSaved];
}
function clearDraftSelection() {
state.selectedDraft = [];
}
function getVisibleRegions(group) {
const query = state.search.trim().toLowerCase();
if (!query) return group.regions || [];
return (group.regions || []).filter((region) => {
const haystack = `${region.label} ${region.code} ${region.token}`.toLowerCase();
return haystack.includes(query);
});
}
function getVisibleSections() {
return state.catalogSections.map((section) => {
const groups = (section.groups || [])
.map((group) => ({ ...group, visibleRegions: getVisibleRegions(group) }))
.filter((group) => group.visibleRegions.length > 0);
return { ...section, groups };
}).filter((section) => section.groups.length > 0);
}
function selectedCountForGroup(group) {
const groupTokens = new Set((group.regions || []).map((region) => region.token));
return state.selectedDraft.filter((token) => groupTokens.has(token)).length;
}
function selectedCountForSection(section) {
const sectionTokens = new Set(section.groups.flatMap((group) => (group.regions || []).map((region) => region.token)));
return state.selectedDraft.filter((token) => sectionTokens.has(token)).length;
}
function renderSelectedSummary() {
if (state.selectedDraft.length === 0) {
return html`<p class="maps-muted">No regions selected.</p>`;
}
return html`
<div class="maps-chip-list">
${state.selectedDraft.map((token) => html`<span class="maps-chip">${getTokenLabel(token)}</span>`)}
</div>
`;
}
function renderGroup(group) {
const selectedCount = selectedCountForGroup(group);
return html`
<article class="maps-group-card">
<div class="maps-group-header">
<div>
<h3>${group.title}</h3>
<p>${selectedCount} selected</p>
</div>
<div class="maps-group-actions">
<button class="maps-btn maps-btn-secondary maps-btn-small" @click="${() => setGroup(group, true)}">Select All</button>
<button class="maps-btn maps-btn-secondary maps-btn-small" @click="${() => setGroup(group, false)}">Clear</button>
</div>
</div>
<div class="maps-region-list">
${group.visibleRegions.map((region) => html`
<label class="maps-region-row">
<input
type="checkbox"
checked="${() => state.selectedDraft.includes(region.token)}"
@change="${(event) => toggleToken(region.token, event.target.checked)}"
/>
<span class="maps-region-label">${region.label}</span>
<span class="maps-region-code">${region.code}</span>
</label>
`)}
</div>
</article>
`;
}
export function MapsManager() {
if (!state.fetched) {
state.fetched = true;
initializeMaps();
}
ensurePolling();
const visibleSections = () => getVisibleSections();
return html`
<div class="maps-page">
<section class="maps-card maps-hero">
<div class="maps-hero-copy">
<h1>Maps</h1>
<p>Select regions, start downloads, and manage offline maps entirely from Galaxy.</p>
</div>
<div class="maps-status-grid">
<div class="maps-stat">
<span class="maps-stat-label">Downloader</span>
<span class="maps-stat-value">${() => state.loadingStatus ? "Checking..." : (state.status.downloading ? (state.status.cancelling ? "Cancelling" : "Downloading") : "Idle")}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Saved Regions</span>
<span class="maps-stat-value">${() => state.status.selectedCount}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Last Updated</span>
<span class="maps-stat-value">${() => state.status.lastUpdate}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Storage Used</span>
<span class="maps-stat-value">${() => formatBytes(state.status.storageBytes)}</span>
</div>
</div>
${() => state.error ? html`<p class="maps-error">${state.error}</p>` : ""}
${() => state.status.isOnroad ? html`<p class="maps-warning">Map downloads and removal are blocked while driving.</p>` : ""}
${() => selectionDirty() ? html`<p class="maps-warning">You have unsaved region changes. Downloading now will use the current Galaxy selection.</p>` : ""}
${() => scheduleDirty() ? html`<p class="maps-warning">You have an unsaved schedule change. Downloading now will also apply it.</p>` : ""}
<div class="maps-action-row">
<button
class="maps-btn maps-btn-primary"
@click="${() => state.status.downloading ? cancelDownload() : startDownload()}"
disabled="${() => state.loadingStatus || state.actionBusy || state.status.isOnroad || (!state.status.downloading && state.selectedDraft.length === 0)}"
>
${() => state.status.downloading ? (state.status.cancelling ? "Cancelling..." : "Cancel Download") : "Download Maps"}
</button>
<button
class="maps-btn maps-btn-danger"
@click="${removeMaps}"
disabled="${() => state.loadingStatus || state.actionBusy || state.status.downloading || state.status.isOnroad || !state.status.mapsPresent}"
>Remove Maps</button>
<button
class="maps-btn maps-btn-secondary"
@click="${saveSelection}"
disabled="${() => state.loadingCatalog || state.savingSelection || !selectionDirty()}"
>${() => state.savingSelection ? "Saving..." : "Save Selection"}</button>
<button class="maps-btn maps-btn-secondary" @click="${resetDraftSelection}" disabled="${() => !selectionDirty()}">Reset</button>
<button class="maps-btn maps-btn-secondary" @click="${clearDraftSelection}" disabled="${() => state.selectedDraft.length === 0}">Clear All</button>
</div>
</section>
<section class="maps-card maps-toolbar-card">
<div class="maps-toolbar-row">
<label class="maps-field maps-search-field">
<span>Search Regions</span>
<input
class="maps-input"
type="search"
placeholder="Filter by name or code"
value="${() => state.search}"
@input="${(event) => { state.search = event.target.value; }}"
/>
</label>
<label class="maps-field maps-schedule-field">
<span>Auto Update Schedule</span>
<select class="maps-select" value="${() => state.scheduleDraft}" @change="${(event) => { state.scheduleDraft = event.target.value; }}">
${() => state.scheduleOptions.map((option) => html`<option value="${option.value}">${option.label}</option>`)}
</select>
</label>
<button class="maps-btn maps-btn-secondary maps-schedule-button" @click="${saveSchedule}" disabled="${() => state.savingSchedule || !scheduleDirty()}">
${() => state.savingSchedule ? "Applying..." : "Apply Schedule"}
</button>
</div>
<div class="maps-toolbar-meta">
<span class="maps-muted">Saved schedule: ${() => state.status.scheduleLabel}</span>
<span class="maps-muted">Draft regions: ${() => state.selectedDraft.length}</span>
</div>
<div class="maps-selected-summary">
<h2>Selected Regions</h2>
${() => renderSelectedSummary()}
</div>
</section>
${() => state.loadingCatalog ? html`<section class="maps-card"><p class="maps-muted">Loading map catalog...</p></section>` : ""}
${() => !state.loadingCatalog && visibleSections().length === 0 ? html`<section class="maps-card"><p class="maps-muted">No regions match the current search.</p></section>` : ""}
${() => visibleSections().map((section) => html`
<section class="maps-card maps-section-card">
<div class="maps-section-header">
<div>
<h2>${section.title}</h2>
<p>${selectedCountForSection(section)} selected</p>
</div>
</div>
<div class="maps-group-grid">
${section.groups.map((group) => renderGroup(group))}
</div>
</section>
`)}
</div>
`;
}
@@ -28,6 +28,7 @@
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
<link rel="stylesheet" href="/assets/components/tools/doors.css">
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/maps.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/plots.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
+124 -1
View File
@@ -41,8 +41,16 @@ from openpilot.system.version import get_build_metadata
from panda import Panda
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
from openpilot.starpilot.common.maps_catalog import (
MAPS_CATALOG,
MAP_SCHEDULE_OPTIONS,
get_selected_map_entries,
sanitize_selected_locations_csv,
schedule_label,
schedule_param_value,
)
from openpilot.starpilot.common.starpilot_utilities import delete_file, get_lock_status, run_cmd
from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\
from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\
default_ev_tuning_enabled, update_starpilot_toggles
from openpilot.starpilot.common.testing_grounds import (
DEFAULT_TESTING_GROUND_VARIANT as SHARED_DEFAULT_TESTING_GROUND_VARIANT,
@@ -409,6 +417,8 @@ MODEL_DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
MODEL_CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
MODEL_SORT_MODE_PARAM = "ModelSortMode"
MODEL_USER_FAVORITES_PARAM = "UserFavorites"
MAPS_DOWNLOAD_PARAM = "DownloadMaps"
MAPS_CANCEL_DOWNLOAD_PARAM = "CancelDownloadMaps"
FINGERPRINT_MAKE_LABELS = [
"Acura",
@@ -3552,6 +3562,119 @@ def setup(app):
return jsonify({"message": f"Deleted {len(deleted)} file(s) for \"{model['label']}\"."}), 200
def _get_maps_status_payload():
current_selected = params.get("MapsSelected", encoding="utf-8") or ""
selected_raw = sanitize_selected_locations_csv(current_selected)
if selected_raw != current_selected:
params.put("MapsSelected", selected_raw)
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
return {
"selectedLocations": selected_locations,
"selectedEntries": selected_entries,
"selectedCount": len(selected_locations),
"hasSelection": bool(selected_locations),
"downloading": params_memory.get_bool(MAPS_DOWNLOAD_PARAM),
"cancelling": params_memory.get_bool(MAPS_CANCEL_DOWNLOAD_PARAM),
"isOnroad": params.get_bool("IsOnroad"),
"lastUpdate": params.get("LastMapsUpdate", encoding="utf-8") or "Never",
"mapsPresent": maps_present,
"scheduleLabel": schedule_label(params.get("PreferredSchedule")),
"scheduleOptions": MAP_SCHEDULE_OPTIONS,
"scheduleValue": schedule_param_value(params.get("PreferredSchedule")),
"storageBytes": storage_bytes,
}
@app.route("/api/maps/catalog", methods=["GET"])
def get_maps_catalog():
return jsonify({
"sections": MAPS_CATALOG,
"scheduleOptions": MAP_SCHEDULE_OPTIONS,
}), 200
@app.route("/api/maps/status", methods=["GET"])
def get_maps_status():
return jsonify(_get_maps_status_payload()), 200
@app.route("/api/maps/selection", methods=["POST"])
def set_maps_selection():
payload = request.get_json(silent=True) or {}
selected_raw = sanitize_selected_locations_csv(payload.get("selectedLocations"))
params.put("MapsSelected", selected_raw)
return jsonify({
"message": f"Saved {len([entry for entry in selected_raw.split(',') if entry])} selected map region(s).",
"status": _get_maps_status_payload(),
}), 200
@app.route("/api/maps/schedule", methods=["POST"])
def set_maps_schedule():
payload = request.get_json(silent=True) or {}
schedule_value = schedule_param_value(payload.get("schedule"))
params.put("PreferredSchedule", schedule_value)
return jsonify({
"message": f"Map auto-update schedule set to {schedule_label(schedule_value)}.",
"status": _get_maps_status_payload(),
}), 200
@app.route("/api/maps/download", methods=["POST"])
def start_maps_download():
payload = request.get_json(silent=True) or {}
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot download maps while driving."}), 403
if params_memory.get_bool(MAPS_DOWNLOAD_PARAM):
return jsonify({"error": "A map download is already in progress."}), 409
if "selectedLocations" in payload:
params.put("MapsSelected", sanitize_selected_locations_csv(payload.get("selectedLocations")))
if "schedule" in payload:
params.put("PreferredSchedule", schedule_param_value(payload.get("schedule")))
selected_raw = sanitize_selected_locations_csv(params.get("MapsSelected", encoding="utf-8") or "")
if not selected_raw:
return jsonify({"error": "No map regions are selected."}), 400
params.put("MapsSelected", selected_raw)
params_memory.remove(MAPS_CANCEL_DOWNLOAD_PARAM)
params_memory.put_bool(MAPS_DOWNLOAD_PARAM, True)
return jsonify({
"message": f"Started downloading {len([entry for entry in selected_raw.split(',') if entry])} selected map region(s).",
"status": _get_maps_status_payload(),
}), 200
@app.route("/api/maps/cancel", methods=["POST"])
def cancel_maps_download():
if not params_memory.get_bool(MAPS_DOWNLOAD_PARAM):
return jsonify({"message": "No active map download to cancel.", "status": _get_maps_status_payload()}), 200
params_memory.put_bool(MAPS_CANCEL_DOWNLOAD_PARAM, True)
return jsonify({"message": "Map download cancellation requested.", "status": _get_maps_status_payload()}), 200
@app.route("/api/maps/remove", methods=["POST"])
def remove_maps_data():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot remove maps while driving."}), 403
if params_memory.get_bool(MAPS_DOWNLOAD_PARAM):
return jsonify({"error": "Cannot remove maps while a download is in progress."}), 409
if MAPS_PATH.exists():
shutil.rmtree(MAPS_PATH, ignore_errors=True)
return jsonify({"message": "Maps removed.", "status": _get_maps_status_payload()}), 200
@app.route("/api/params_memory", methods=["GET"])
def get_param_memory():
return params_memory.get(request.args.get("key")) or "", 200