${group.title}
+${selectedCount} selected
+diff --git a/selfdrive/ui/layouts/settings/starpilot/maps.py b/selfdrive/ui/layouts/settings/starpilot/maps.py index c82f8b38..82c48e27 100644 --- a/selfdrive/ui/layouts/settings/starpilot/maps.py +++ b/selfdrive/ui/layouts/settings/starpilot/maps.py @@ -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)) diff --git a/selfdrive/ui/mici/layouts/settings/network/action_state.py b/selfdrive/ui/mici/layouts/settings/network/action_state.py new file mode 100644 index 00000000..9f8b6761 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/network/action_state.py @@ -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) diff --git a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index f7cfeba4..20c92649 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -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, diff --git a/selfdrive/ui/tests/test_wifi_ui.py b/selfdrive/ui/tests/test_wifi_ui.py new file mode 100644 index 00000000..f05e512d --- /dev/null +++ b/selfdrive/ui/tests/test_wifi_ui.py @@ -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)) diff --git a/starpilot/common/maps_catalog.py b/starpilot/common/maps_catalog.py new file mode 100644 index 00000000..5e76615b --- /dev/null +++ b/starpilot/common/maps_catalog.py @@ -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) + ] diff --git a/starpilot/common/maps_selection.py b/starpilot/common/maps_selection.py new file mode 100644 index 00000000..73dd1338 --- /dev/null +++ b/starpilot/common/maps_selection.py @@ -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) diff --git a/starpilot/common/starpilot_functions.py b/starpilot/common/starpilot_functions.py index ed4d5a79..f7be8f4b 100644 --- a/starpilot/common/starpilot_functions.py +++ b/starpilot/common/starpilot_functions.py @@ -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: diff --git a/starpilot/system/the_pond/assets/components/router.js b/starpilot/system/the_pond/assets/components/router.js index 68d6659f..0bafbb63 100644 --- a/starpilot/system/the_pond/assets/components/router.js +++ b/starpilot/system/the_pond/assets/components/router.js @@ -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), diff --git a/starpilot/system/the_pond/assets/components/sidebar.js b/starpilot/system/the_pond/assets/components/sidebar.js index 1a52e13f..35f3cfbd 100644 --- a/starpilot/system/the_pond/assets/components/sidebar.js +++ b/starpilot/system/the_pond/assets/components/sidebar.js @@ -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" }, diff --git a/starpilot/system/the_pond/assets/components/tools/maps.css b/starpilot/system/the_pond/assets/components/tools/maps.css new file mode 100644 index 00000000..ac473d79 --- /dev/null +++ b/starpilot/system/the_pond/assets/components/tools/maps.css @@ -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; + } +} diff --git a/starpilot/system/the_pond/assets/components/tools/maps.js b/starpilot/system/the_pond/assets/components/tools/maps.js new file mode 100644 index 00000000..c6a34a2f --- /dev/null +++ b/starpilot/system/the_pond/assets/components/tools/maps.js @@ -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`
No regions selected.
`; + } + + return html` +${selectedCount} selected
+Select regions, start downloads, and manage offline maps entirely from Galaxy.
+${state.error}
` : ""} + ${() => state.status.isOnroad ? html`Map downloads and removal are blocked while driving.
` : ""} + ${() => selectionDirty() ? html`You have unsaved region changes. Downloading now will use the current Galaxy selection.
` : ""} + ${() => scheduleDirty() ? html`You have an unsaved schedule change. Downloading now will also apply it.
` : ""} +Loading map catalog...
No regions match the current search.
${selectedCountForSection(section)} selected
+