From 8e0b7032e64ca8ef6ae8edc48c0106acc2e047f5 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 15 Apr 2026 22:36:01 -0700 Subject: [PATCH 01/51] esim: MICI eSIM profile management UI Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mici/layouts/settings/network/__init__.py | 46 +++ .../mici/layouts/settings/network/esim_ui.py | 291 ++++++++++++++++++ .../settings/network/network_layout.py | 20 +- .../ui/mici/layouts/settings/settings.py | 4 +- system/ui/lib/cellular_manager.py | 200 ++++++++++++ 5 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 selfdrive/ui/mici/layouts/settings/network/esim_ui.py create mode 100644 system/ui/lib/cellular_manager.py diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478cb24..89c24591e5742f 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,10 +1,56 @@ import pyray as rl +from cereal import log +from openpilot.system.ui.lib.cellular_manager import CellularManager, profile_display_name from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +NetworkType = log.DeviceState.NetworkType + + +class ESimNetworkButton(BigButton): + def __init__(self, cellular_manager: CellularManager): + self._cellular_manager = cellular_manager + self._cell_none_icon = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47) + self._cell_low_icon = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47) + self._cell_medium_icon = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47) + self._cell_high_icon = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47) + self._cell_full_icon = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47) + super().__init__("esim", "no active profile", self._cell_none_icon, scroll=True) + + def _update_state(self): + super()._update_state() + + if self._cellular_manager.busy: + self.set_text("esim") + self.set_value("switching...") + self.set_icon(self._cell_none_icon) + else: + active = next((p for p in self._cellular_manager.profiles if p.enabled), None) + if active: + name = profile_display_name(active) + self.set_text(f"{name} (...{active.iccid[-4:]})") + self.set_value(self._cellular_manager.modem_ip or "obtaining IP...") + self.set_icon(self._get_cell_icon()) + else: + self.set_text("esim") + self.set_value("no active profile") + self.set_icon(self._cell_none_icon) + + def _get_cell_icon(self): + device_state = ui_state.sm['deviceState'] + net_type = device_state.networkType + if net_type not in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): + return self._cell_none_icon + strength = device_state.networkStrength + level = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 + icons = (self._cell_none_icon, self._cell_none_icon, self._cell_low_icon, + self._cell_medium_icon, self._cell_high_icon, self._cell_full_icon) + return icons[level] + class WifiNetworkButton(BigButton): def __init__(self, wifi_manager: WifiManager): diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py new file mode 100644 index 00000000000000..de322be71eb15b --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -0,0 +1,291 @@ +import pyray as rl +from collections.abc import Callable + +from openpilot.system.ui.lib.cellular_manager import CellularManager, profile_display_name +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog, BigInputDialog +from openpilot.system.hardware.base import Profile +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.scroller import NavScroller + +SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) +SUB_LABEL_ACTIVE = rl.Color(255, 255, 255, int(255 * 0.9)) +CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + + +class DeleteButton(Widget): + MARGIN = 12 + + def __init__(self, delete_callback: Callable): + super().__init__() + self._delete_callback = delete_callback + + self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84) + self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84) + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35) + self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2)) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + dlg = BigConfirmationDialog("slide to delete", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), + self._delete_callback, red=True) + gui_app.push_widget(dlg) + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + + trash_x = self._rect.x + (self._rect.width - self._trash_txt.width) / 2 + trash_y = self._rect.y + (self._rect.height - self._trash_txt.height) / 2 + rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE) + + +class RenameButton(Widget): + SIZE = 84 + MARGIN = 12 + + def __init__(self, rename_callback: Callable): + super().__init__() + self._rename_callback = rename_callback + + self._bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", self.SIZE, self.SIZE) + self._bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", self.SIZE, self.SIZE) + self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.MARGIN * 2, self.SIZE + self.MARGIN * 2)) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + self._rename_callback() + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + icon_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height) + gui_label(icon_rect, "Aa", 32, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + +def _mici_profile_name(profile: Profile) -> str: + return f"{profile_display_name(profile)} (...{profile.iccid[-4:]})" + + +class ESimProfileButton(BigButton): + LABEL_PADDING = 98 + LABEL_WIDTH = 402 - 98 - 28 + SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 + + def __init__(self, profile: Profile, cellular_manager: CellularManager): + self._cellular_manager = cellular_manager + is_comma = cellular_manager.is_comma_profile(profile.iccid) + display_name = "comma prime" if is_comma else _mici_profile_name(profile) + super().__init__(display_name, scroll=True) + + self._profile = profile + self._deleting = False + + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) + self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) + self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if is_comma else None + + self._delete_btn = DeleteButton(self._on_delete) + self._rename_btn = RenameButton(self._on_rename) if not is_comma else None + + @property + def profile(self) -> Profile: + return self._profile + + def update_profile(self, profile: Profile): + self._profile = profile + self._deleting = False + is_comma = self._cellular_manager.is_comma_profile(profile.iccid) + self.set_text("comma prime" if is_comma else _mici_profile_name(profile)) + + @property + def _show_rename_btn(self) -> bool: + if self._deleting or self._cellular_manager.busy: + return False + return self._rename_btn is not None + + @property + def _show_delete_btn(self) -> bool: + if self._deleting or self._profile.enabled or self._cellular_manager.busy: + return False + return not self._cellular_manager.is_comma_profile(self._profile.iccid) + + def _on_delete(self): + if self._deleting: + return + self._deleting = True + self._cellular_manager.delete_profile(self._profile.iccid) + + def _on_rename(self): + current = self._profile.nickname or "" + dlg = BigInputDialog("nickname", default_text=current, minimum_length=0, confirm_callback=self._on_nickname_entered) + gui_app.push_widget(dlg) + + def _on_nickname_entered(self, nickname: str): + self._cellular_manager.nickname_profile(self._profile.iccid, nickname.strip()) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect): + return + if self._show_rename_btn and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): + return + super()._handle_mouse_release(mouse_pos) + + def _get_label_font_size(self): + return 48 + + def _draw_content(self, btn_y: float): + self._label.set_color(LABEL_COLOR) + label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, + self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + if self.value: + sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING + label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING + action_w = self._delete_btn.rect.width if self._show_delete_btn else 0 + sub_label_w = self.SUB_LABEL_WIDTH - action_w + sub_label_height = self._sub_label.get_content_height(sub_label_w) + + if self._profile.enabled and not self._deleting: + check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, CHECK_ICON_COLOR) + sub_label_x += self._check_txt.width + 14 + + sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) + self._sub_label.render(sub_label_rect) + + if self._comma_txt: + rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE) + else: + cell_icon = self._cell_full_txt if self._profile.enabled else self._cell_none_txt + rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) + + btn_x = self._rect.x + self._rect.width - (0 if self._show_delete_btn else 12) + btn_bottom = btn_y + self._rect.height + if self._show_delete_btn: + btn_x -= self._delete_btn.rect.width + self._delete_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._delete_btn.rect.height, + self._delete_btn.rect.width, self._delete_btn.rect.height, + )) + if self._show_rename_btn: + btn_x -= self._rename_btn.rect.width + self._rename_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._rename_btn.rect.height, + self._rename_btn.rect.width, self._rename_btn.rect.height, + )) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + action_pressed = lambda: self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) + super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed()) + self._delete_btn.set_touch_valid_callback(touch_callback) + if self._rename_btn: + self._rename_btn.set_touch_valid_callback(touch_callback) + + def _update_state(self): + super()._update_state() + + if self._cellular_manager.busy or self._deleting: + self.set_enabled(False) + self._sub_label.set_color(SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.ROMAN) + + if self._deleting: + self.set_value("deleting...") + elif self._cellular_manager.busy: + self.set_value("switching..." if not self._profile.enabled else "active") + elif self._profile.enabled: + self.set_value("active") + self.set_enabled(True) + self._sub_label.set_color(SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.ROMAN) + else: + self.set_value("switch") + self.set_enabled(True) + self._sub_label.set_color(SUB_LABEL_ACTIVE) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD) + + +class ESimUIMici(NavScroller): + def __init__(self, cellular_manager: CellularManager): + super().__init__() + + self._cellular_manager = cellular_manager + + self._cellular_manager.add_callbacks( + profiles_updated=self._on_profiles_updated, + operation_error=self._on_error, + ) + + def show_event(self): + super().show_event() + self._update_buttons(re_sort=True) + self._cellular_manager.refresh_profiles() + + def _on_profiles_updated(self, profiles: list[Profile]): + self._update_buttons() + + def _update_buttons(self, re_sort: bool = False): + existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, ESimProfileButton)} + profiles = self._cellular_manager.profiles + current_iccids = {p.iccid for p in profiles} + + for profile in profiles: + if profile.iccid in existing: + existing[profile.iccid].update_profile(profile) + else: + btn = ESimProfileButton(profile, self._cellular_manager) + btn.set_click_callback(lambda iccid=profile.iccid: self._on_profile_clicked(iccid)) + self._scroller.add_widget(btn) + + if re_sort: + btn_map = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, ESimProfileButton)} + self._scroller.items[:] = sorted( + [btn_map[iccid] for iccid in current_iccids if iccid in btn_map], + key=lambda b: not b.profile.enabled, + ) + else: + self._scroller.items[:] = [ + btn for btn in self._scroller.items + if not isinstance(btn, ESimProfileButton) or btn.profile.iccid in current_iccids + ] + + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) + if isinstance(btn, ESimProfileButton) and + btn.profile.iccid == iccid), None) if iccid else None + + if front_btn_idx is not None and front_btn_idx > 0: + self._scroller.move_item(front_btn_idx, 0) + + if scroll: + self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True) + + def _update_state(self): + super()._update_state() + + iccid = self._cellular_manager.switching_iccid + if iccid is None: + active = next((p for p in self._cellular_manager.profiles if p.enabled), None) + iccid = active.iccid if active else None + self._move_profile_to_front(iccid) + + def _on_error(self, error: str): + dlg = BigDialog("esim error", error) + gui_app.push_widget(dlg) + + def _on_profile_clicked(self, iccid: str): + if self._cellular_manager.busy: + return + profile = next((p for p in self._cellular_manager.profiles if p.iccid == iccid), None) + if profile is None or profile.enabled: + return + + self._cellular_manager.switch_profile(iccid) + self._move_profile_to_front(iccid, scroll=True) diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77dceeb..b7a072433dae79 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,5 +1,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network import ESimNetworkButton, WifiNetworkButton +from openpilot.system.ui.lib.cellular_manager import CellularManager +from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import ESimUIMici from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog @@ -10,7 +12,7 @@ class NetworkLayoutMici(NavScroller): - def __init__(self): + def __init__(self, cellular_manager: CellularManager): super().__init__() self._wifi_manager = WifiManager() @@ -64,6 +66,12 @@ def network_metered_callback(value: str): self._wifi_button = WifiNetworkButton(self._wifi_manager) self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + # ******** eSIM ******** + self._cellular_manager = cellular_manager + self._esim_ui = ESimUIMici(self._cellular_manager) + self._esim_button = ESimNetworkButton(self._cellular_manager) + self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) + # ******** Advanced settings ******** # ******** Roaming toggle ******** self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") @@ -78,6 +86,7 @@ def network_metered_callback(value: str): # Main scroller ---------------------------------- self._scroller.add_widgets([ self._wifi_button, + self._esim_button, self._network_metered_btn, self._tethering_toggle_btn, self._tethering_password_btn, @@ -101,15 +110,20 @@ def _update_state(self): def show_event(self): super().show_event() self._wifi_manager.set_active(True) + self._cellular_manager.set_active(True) + self._cellular_manager.refresh_profiles() - # Process wifi callbacks while at any point in the nav stack + # Process wifi and esim callbacks while at any point in the nav stack gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.add_nav_stack_tick(self._cellular_manager.process_callbacks) def hide_event(self): super().hide_event() self._wifi_manager.set_active(False) + self._cellular_manager.set_active(False) gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks) def _edit_apn(self): def update_apn(apn: str): diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 4ccc5ba139ac48..11af0de7ef4479 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -4,6 +4,7 @@ from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout from openpilot.system.ui.lib.application import gui_app, FontWeight @@ -23,7 +24,8 @@ def __init__(self): toggles_btn = SettingsBigButton("toggles", "", gui_app.texture("icons_mici/settings.png", 64, 64)) toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel)) - network_panel = NetworkLayoutMici() + cellular_manager = CellularManager() + network_panel = NetworkLayoutMici(cellular_manager) network_btn = SettingsBigButton("network", "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56)) network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel)) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py new file mode 100644 index 00000000000000..2e19bdbb85fbe7 --- /dev/null +++ b/system/ui/lib/cellular_manager.py @@ -0,0 +1,200 @@ +import subprocess +import time +import threading +from collections.abc import Callable + +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware.base import LPABase, Profile + + +def profile_display_name(profile: Profile) -> str: + return profile.nickname or profile.provider or profile.iccid[:12] + + +MODEM_IP_POLL_INTERVAL = 5.0 +PROFILE_POLL_INTERVAL = 30.0 +SWITCH_SETTLE_S = 15.0 + + +def _get_modem_ip() -> str: + for iface in ("ppp0", "wwan0"): + try: + out = subprocess.check_output(["ip", "-4", "-o", "addr", "show", iface], timeout=1, text=True, stderr=subprocess.DEVNULL) + parts = out.split() + for i, part in enumerate(parts): + if part == "inet" and i + 1 < len(parts): + return parts[i + 1].split("/")[0] + except Exception: + pass + return "" + + +def _get_lpa() -> LPABase: + from openpilot.system.hardware import HARDWARE + return HARDWARE.get_sim_lpa() + + +class CellularManager: + def __init__(self): + self._lpa: LPABase | None = None + self._profiles: list[Profile] = [] + self._busy: bool = False + self._switching_iccid: str | None = None + + self._lock = threading.Lock() + self._callback_queue: list[Callable] = [] + + self._profiles_updated_cbs: list[Callable[[list[Profile]], None]] = [] + self._operation_error_cbs: list[Callable[[str], None]] = [] + + self._modem_ip: str = "" + self._ip_polling: bool = False + self._last_ip_poll: float = 0.0 + self._last_profile_poll: float = 0.0 + self._no_poll_until: float = 0.0 + self._polling: bool = False + self._active: bool = False + + def add_callbacks(self, profiles_updated: Callable | None = None, operation_error: Callable | None = None): + if profiles_updated: + self._profiles_updated_cbs.append(profiles_updated) + if operation_error: + self._operation_error_cbs.append(operation_error) + + def set_active(self, active: bool): + self._active = active + + @property + def modem_ip(self) -> str: + return self._modem_ip + + def process_callbacks(self): + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + if not self._active: + return + + now = time.monotonic() + if now - self._last_ip_poll >= MODEM_IP_POLL_INTERVAL and not self._ip_polling: + self._last_ip_poll = now + self._ip_polling = True + threading.Thread(target=self._poll_modem_ip, daemon=True).start() + + if not self._busy and not self._polling and now >= self._no_poll_until and now - self._last_profile_poll >= PROFILE_POLL_INTERVAL: + self._last_profile_poll = now + self._poll_profiles() + + @property + def profiles(self) -> list[Profile]: + return self._profiles + + @property + def busy(self) -> bool: + return self._busy + + @property + def switching_iccid(self) -> str | None: + return self._switching_iccid + + def _poll_modem_ip(self): + ip = _get_modem_ip() + self._callback_queue.append(lambda: self._finish_ip_poll(ip)) + + def _finish_ip_poll(self, ip: str): + self._ip_polling = False + self._modem_ip = ip + + def is_comma_profile(self, iccid: str) -> bool: + return any(p.iccid == iccid and p.provider == 'Webbing' for p in self._profiles) + + def _ensure_lpa(self) -> LPABase: + if self._lpa is None: + self._lpa = _get_lpa() + return self._lpa + + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): + self._busy = False + self._switching_iccid = None + if profiles is not None: + self._profiles = profiles + for cb in self._profiles_updated_cbs: + cb(profiles) + if error is not None: + for cb in self._operation_error_cbs: + cb(error) + + def _run_operation(self, fn: Callable, error_msg: str): + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + fn(lpa) + lpa.process_notifications() + profiles = lpa.list_profiles() + self._callback_queue.append(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception(error_msg) + err = str(e) + self._callback_queue.append(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def refresh_profiles(self): + self._poll_profiles(is_refresh=True) + + def _poll_profiles(self, is_refresh: bool = False): + self._polling = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + cloudlog.info("eSIM: processing notifications") + lpa.process_notifications() + cloudlog.info("eSIM: listing profiles") + profiles = lpa.list_profiles() + cloudlog.info(f"eSIM: got {len(profiles)} profiles") + self._callback_queue.append(lambda: self._finish_poll(profiles)) + except Exception: + cloudlog.exception("Failed to poll eSIM profiles") + self._callback_queue.append(lambda: setattr(self, '_polling', False)) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_poll(self, profiles: list[Profile]): + self._polling = False + if self._busy or time.monotonic() < self._no_poll_until: + return + self._profiles = profiles + for cb in self._profiles_updated_cbs: + cb(profiles) + + def switch_profile(self, iccid: str): + self._switching_iccid = iccid + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + lpa.switch_profile(iccid) + # optimistic update: flip enabled flags locally + profiles = [Profile(iccid=p.iccid, nickname=p.nickname, enabled=(p.iccid == iccid), provider=p.provider) for p in self._profiles] + self._no_poll_until = time.monotonic() + SWITCH_SETTLE_S + self._callback_queue.append(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception("Failed to switch eSIM profile") + err = str(e) + self._callback_queue.append(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def delete_profile(self, iccid: str): + self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") + + def nickname_profile(self, iccid: str, nickname: str): + self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 3deab572dd278ac4a83b8768de84b5a3f630db95 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Thu, 16 Apr 2026 17:55:37 -0700 Subject: [PATCH 02/51] esim: align rename button position regardless of delete button Co-Authored-By: Claude Opus 4.7 (1M context) --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index de322be71eb15b..db10bef6e1f1ef 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -166,7 +166,7 @@ def _draw_content(self, btn_y: float): cell_icon = self._cell_full_txt if self._profile.enabled else self._cell_none_txt rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) - btn_x = self._rect.x + self._rect.width - (0 if self._show_delete_btn else 12) + btn_x = self._rect.x + self._rect.width btn_bottom = btn_y + self._rect.height if self._show_delete_btn: btn_x -= self._delete_btn.rect.width From 2ea758b93841a502cfcdacb53bac12468795416c Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sun, 19 Apr 2026 14:38:04 -0700 Subject: [PATCH 03/51] esim: skip profile UI when SIM is not an eUICC Probe is_euicc() on the first profile poll and cache it; non-eUICC SIMs avoid list_profiles/process_notifications (which hang on a plain SIM) and the eSIM button shows ICCID/MCC-MNC metadata instead of opening the profile management screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mici/layouts/settings/network/__init__.py | 13 ++++++ .../settings/network/network_layout.py | 7 +++- system/ui/lib/cellular_manager.py | 42 ++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 89c24591e5742f..4d8a4bd3440b66 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -24,6 +24,19 @@ def __init__(self, cellular_manager: CellularManager): def _update_state(self): super()._update_state() + if self._cellular_manager.is_euicc is False: + info = self._cellular_manager.sim_info + sim_id = info.get("sim_id") or "" + state = (info.get("sim_state") or [""])[0] if isinstance(info.get("sim_state"), list) else "" + if sim_id: + self.set_text(f"sim (...{sim_id[-4:]})") + self.set_value(self._cellular_manager.modem_ip or info.get("mcc_mnc") or "no IP") + else: + self.set_text("sim") + self.set_value(state.lower() if state else "no sim") + self.set_icon(self._get_cell_icon() if sim_id else self._cell_none_icon) + return + if self._cellular_manager.busy: self.set_text("esim") self.set_value("switching...") diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index b7a072433dae79..60e0b6e0abb526 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -70,7 +70,12 @@ def network_metered_callback(value: str): self._cellular_manager = cellular_manager self._esim_ui = ESimUIMici(self._cellular_manager) self._esim_button = ESimNetworkButton(self._cellular_manager) - self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) + + def open_esim_ui(): + if self._cellular_manager.is_euicc is False: + return + gui_app.push_widget(self._esim_ui) + self._esim_button.set_click_callback(open_esim_ui) # ******** Advanced settings ******** # ******** Roaming toggle ******** diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 2e19bdbb85fbe7..cf009aedd53ce6 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -34,12 +34,24 @@ def _get_lpa() -> LPABase: return HARDWARE.get_sim_lpa() +def _get_sim_info() -> dict: + from openpilot.system.hardware import HARDWARE + try: + return HARDWARE.get_sim_info() + except Exception: + return {} + + class CellularManager: def __init__(self): self._lpa: LPABase | None = None self._profiles: list[Profile] = [] self._busy: bool = False self._switching_iccid: str | None = None + # None = not yet checked, True/False = cached result. SIM cannot be swapped + # without disassembling the device, so we probe once and keep the result. + self._is_euicc: bool | None = None + self._sim_info: dict = {} self._lock = threading.Lock() self._callback_queue: list[Callable] = [] @@ -84,7 +96,11 @@ def process_callbacks(self): if not self._busy and not self._polling and now >= self._no_poll_until and now - self._last_profile_poll >= PROFILE_POLL_INTERVAL: self._last_profile_poll = now - self._poll_profiles() + if self._is_euicc is False: + # confirmed non-eUICC: don't touch LPA, but keep sim_info fresh for the UI + self._sim_info = _get_sim_info() + else: + self._poll_profiles() @property def profiles(self) -> list[Profile]: @@ -98,6 +114,14 @@ def busy(self) -> bool: def switching_iccid(self) -> str | None: return self._switching_iccid + @property + def is_euicc(self) -> bool | None: + return self._is_euicc + + @property + def sim_info(self) -> dict: + return self._sim_info + def _poll_modem_ip(self): ip = _get_modem_ip() self._callback_queue.append(lambda: self._finish_ip_poll(ip)) @@ -144,6 +168,8 @@ def worker(): threading.Thread(target=worker, daemon=True).start() def refresh_profiles(self): + if self._is_euicc is False: + return self._poll_profiles(is_refresh=True) def _poll_profiles(self, is_refresh: bool = False): @@ -153,6 +179,14 @@ def worker(): try: with self._lock: lpa = self._ensure_lpa() + if self._is_euicc is None: + cloudlog.info("eSIM: checking eUICC presence") + self._is_euicc = lpa.is_euicc() + cloudlog.info(f"eSIM: is_euicc={self._is_euicc}") + if not self._is_euicc: + sim_info = _get_sim_info() + self._callback_queue.append(lambda: self._finish_poll_non_euicc(sim_info)) + return cloudlog.info("eSIM: processing notifications") lpa.process_notifications() cloudlog.info("eSIM: listing profiles") @@ -165,6 +199,12 @@ def worker(): threading.Thread(target=worker, daemon=True).start() + def _finish_poll_non_euicc(self, sim_info: dict): + self._polling = False + self._sim_info = sim_info + for cb in self._profiles_updated_cbs: + cb(self._profiles) + def _finish_poll(self, profiles: list[Profile]): self._polling = False if self._busy or time.monotonic() < self._no_poll_until: From ab9eb90f115c7db2d473d0912adf03e60b71da4b Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sun, 19 Apr 2026 14:38:46 -0700 Subject: [PATCH 04/51] esim: satisfy ruff E731 in action_pressed helper Co-Authored-By: Claude Opus 4.7 (1M context) --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index db10bef6e1f1ef..6bc1251ec43738 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -182,7 +182,8 @@ def _draw_content(self, btn_y: float): )) def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: - action_pressed = lambda: self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) + def action_pressed() -> bool: + return self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed()) self._delete_btn.set_touch_valid_callback(touch_callback) if self._rename_btn: From eceecbfbf0216dd7cc3c0f4bae46070c73c2aa0c Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 13:23:30 -0700 Subject: [PATCH 05/51] esim: get modem info from modem.py state instead of shelling out --- .../mici/layouts/settings/network/__init__.py | 15 ++-- system/ui/lib/cellular_manager.py | 68 ++++--------------- 2 files changed, 21 insertions(+), 62 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 4d8a4bd3440b66..41e1a4ef2427d2 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -25,16 +25,15 @@ def _update_state(self): super()._update_state() if self._cellular_manager.is_euicc is False: - info = self._cellular_manager.sim_info - sim_id = info.get("sim_id") or "" - state = (info.get("sim_state") or [""])[0] if isinstance(info.get("sim_state"), list) else "" - if sim_id: - self.set_text(f"sim (...{sim_id[-4:]})") - self.set_value(self._cellular_manager.modem_ip or info.get("mcc_mnc") or "no IP") + ms = self._cellular_manager.modem_state + iccid = ms.get("iccid") or "" + if iccid: + self.set_text(f"sim (...{iccid[-4:]})") + self.set_value(self._cellular_manager.modem_ip or ms.get("mcc_mnc") or "no IP") else: self.set_text("sim") - self.set_value(state.lower() if state else "no sim") - self.set_icon(self._get_cell_icon() if sim_id else self._cell_none_icon) + self.set_value("no sim") + self.set_icon(self._get_cell_icon() if iccid else self._cell_none_icon) return if self._cellular_manager.busy: diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index cf009aedd53ce6..df03c193814757 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -1,4 +1,3 @@ -import subprocess import time import threading from collections.abc import Callable @@ -11,33 +10,19 @@ def profile_display_name(profile: Profile) -> str: return profile.nickname or profile.provider or profile.iccid[:12] -MODEM_IP_POLL_INTERVAL = 5.0 PROFILE_POLL_INTERVAL = 30.0 SWITCH_SETTLE_S = 15.0 -def _get_modem_ip() -> str: - for iface in ("ppp0", "wwan0"): - try: - out = subprocess.check_output(["ip", "-4", "-o", "addr", "show", iface], timeout=1, text=True, stderr=subprocess.DEVNULL) - parts = out.split() - for i, part in enumerate(parts): - if part == "inet" and i + 1 < len(parts): - return parts[i + 1].split("/")[0] - except Exception: - pass - return "" - - def _get_lpa() -> LPABase: from openpilot.system.hardware import HARDWARE return HARDWARE.get_sim_lpa() -def _get_sim_info() -> dict: +def _get_modem_state() -> dict: from openpilot.system.hardware import HARDWARE try: - return HARDWARE.get_sim_info() + return HARDWARE.get_modem_state() except Exception: return {} @@ -51,7 +36,7 @@ def __init__(self): # None = not yet checked, True/False = cached result. SIM cannot be swapped # without disassembling the device, so we probe once and keep the result. self._is_euicc: bool | None = None - self._sim_info: dict = {} + self._modem_state: dict = {} self._lock = threading.Lock() self._callback_queue: list[Callable] = [] @@ -59,9 +44,6 @@ def __init__(self): self._profiles_updated_cbs: list[Callable[[list[Profile]], None]] = [] self._operation_error_cbs: list[Callable[[str], None]] = [] - self._modem_ip: str = "" - self._ip_polling: bool = False - self._last_ip_poll: float = 0.0 self._last_profile_poll: float = 0.0 self._no_poll_until: float = 0.0 self._polling: bool = False @@ -78,7 +60,11 @@ def set_active(self, active: bool): @property def modem_ip(self) -> str: - return self._modem_ip + return self._modem_state.get("ip_address", "") + + @property + def modem_state(self) -> dict: + return self._modem_state def process_callbacks(self): to_run, self._callback_queue = self._callback_queue, [] @@ -88,18 +74,11 @@ def process_callbacks(self): if not self._active: return - now = time.monotonic() - if now - self._last_ip_poll >= MODEM_IP_POLL_INTERVAL and not self._ip_polling: - self._last_ip_poll = now - self._ip_polling = True - threading.Thread(target=self._poll_modem_ip, daemon=True).start() - - if not self._busy and not self._polling and now >= self._no_poll_until and now - self._last_profile_poll >= PROFILE_POLL_INTERVAL: - self._last_profile_poll = now - if self._is_euicc is False: - # confirmed non-eUICC: don't touch LPA, but keep sim_info fresh for the UI - self._sim_info = _get_sim_info() - else: + self._modem_state = _get_modem_state() + + if not self._busy and not self._polling and time.monotonic() >= self._no_poll_until and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL: + self._last_profile_poll = time.monotonic() + if self._is_euicc is not False: self._poll_profiles() @property @@ -118,18 +97,6 @@ def switching_iccid(self) -> str | None: def is_euicc(self) -> bool | None: return self._is_euicc - @property - def sim_info(self) -> dict: - return self._sim_info - - def _poll_modem_ip(self): - ip = _get_modem_ip() - self._callback_queue.append(lambda: self._finish_ip_poll(ip)) - - def _finish_ip_poll(self, ip: str): - self._ip_polling = False - self._modem_ip = ip - def is_comma_profile(self, iccid: str) -> bool: return any(p.iccid == iccid and p.provider == 'Webbing' for p in self._profiles) @@ -184,8 +151,7 @@ def worker(): self._is_euicc = lpa.is_euicc() cloudlog.info(f"eSIM: is_euicc={self._is_euicc}") if not self._is_euicc: - sim_info = _get_sim_info() - self._callback_queue.append(lambda: self._finish_poll_non_euicc(sim_info)) + self._callback_queue.append(lambda: setattr(self, '_polling', False)) return cloudlog.info("eSIM: processing notifications") lpa.process_notifications() @@ -199,12 +165,6 @@ def worker(): threading.Thread(target=worker, daemon=True).start() - def _finish_poll_non_euicc(self, sim_info: dict): - self._polling = False - self._sim_info = sim_info - for cb in self._profiles_updated_cbs: - cb(self._profiles) - def _finish_poll(self, profiles: list[Profile]): self._polling = False if self._busy or time.monotonic() < self._no_poll_until: From 8604017711dcded2750990090228f88556114a39 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 13:30:25 -0700 Subject: [PATCH 06/51] esim: simplify switch lifecycle, drop active flag and settle window --- .../mici/layouts/settings/network/__init__.py | 2 +- .../mici/layouts/settings/network/esim_ui.py | 12 ++++---- .../settings/network/network_layout.py | 2 -- system/ui/lib/cellular_manager.py | 28 ++++++++----------- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 41e1a4ef2427d2..517557dea3ce53 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -36,7 +36,7 @@ def _update_state(self): self.set_icon(self._get_cell_icon() if iccid else self._cell_none_icon) return - if self._cellular_manager.busy: + if self._cellular_manager.switching_iccid is not None: self.set_text("esim") self.set_value("switching...") self.set_icon(self._cell_none_icon) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 6bc1251ec43738..e664633b960b1f 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -105,13 +105,13 @@ def update_profile(self, profile: Profile): @property def _show_rename_btn(self) -> bool: - if self._deleting or self._cellular_manager.busy: + if self._deleting or self._cellular_manager.busy or self._cellular_manager.switching_iccid is not None: return False return self._rename_btn is not None @property def _show_delete_btn(self) -> bool: - if self._deleting or self._profile.enabled or self._cellular_manager.busy: + if self._deleting or self._profile.enabled or self._cellular_manager.busy or self._cellular_manager.switching_iccid is not None: return False return not self._cellular_manager.is_comma_profile(self._profile.iccid) @@ -192,14 +192,16 @@ def action_pressed() -> bool: def _update_state(self): super()._update_state() - if self._cellular_manager.busy or self._deleting: + switching = self._cellular_manager.switching_iccid is not None + + if self._cellular_manager.busy or switching or self._deleting: self.set_enabled(False) self._sub_label.set_color(SUB_LABEL_DISABLED) self._sub_label.set_font_weight(FontWeight.ROMAN) if self._deleting: self.set_value("deleting...") - elif self._cellular_manager.busy: + elif switching: self.set_value("switching..." if not self._profile.enabled else "active") elif self._profile.enabled: self.set_value("active") @@ -282,7 +284,7 @@ def _on_error(self, error: str): gui_app.push_widget(dlg) def _on_profile_clicked(self, iccid: str): - if self._cellular_manager.busy: + if self._cellular_manager.busy or self._cellular_manager.switching_iccid is not None: return profile = next((p for p in self._cellular_manager.profiles if p.iccid == iccid), None) if profile is None or profile.enabled: diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 60e0b6e0abb526..8701c2a0d2f0d9 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -115,7 +115,6 @@ def _update_state(self): def show_event(self): super().show_event() self._wifi_manager.set_active(True) - self._cellular_manager.set_active(True) self._cellular_manager.refresh_profiles() # Process wifi and esim callbacks while at any point in the nav stack @@ -125,7 +124,6 @@ def show_event(self): def hide_event(self): super().hide_event() self._wifi_manager.set_active(False) - self._cellular_manager.set_active(False) gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index df03c193814757..24cecbc9c54b8e 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -11,7 +11,6 @@ def profile_display_name(profile: Profile) -> str: PROFILE_POLL_INTERVAL = 30.0 -SWITCH_SETTLE_S = 15.0 def _get_lpa() -> LPABase: @@ -45,9 +44,7 @@ def __init__(self): self._operation_error_cbs: list[Callable[[str], None]] = [] self._last_profile_poll: float = 0.0 - self._no_poll_until: float = 0.0 self._polling: bool = False - self._active: bool = False def add_callbacks(self, profiles_updated: Callable | None = None, operation_error: Callable | None = None): if profiles_updated: @@ -55,9 +52,6 @@ def add_callbacks(self, profiles_updated: Callable | None = None, operation_erro if operation_error: self._operation_error_cbs.append(operation_error) - def set_active(self, active: bool): - self._active = active - @property def modem_ip(self) -> str: return self._modem_state.get("ip_address", "") @@ -71,12 +65,12 @@ def process_callbacks(self): for cb in to_run: cb() - if not self._active: - return - self._modem_state = _get_modem_state() - if not self._busy and not self._polling and time.monotonic() >= self._no_poll_until and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL: + if self._switching_iccid and self._modem_state.get("iccid") == self._switching_iccid: + self._switching_iccid = None + + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL: self._last_profile_poll = time.monotonic() if self._is_euicc is not False: self._poll_profiles() @@ -107,7 +101,6 @@ def _ensure_lpa(self) -> LPABase: def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): self._busy = False - self._switching_iccid = None if profiles is not None: self._profiles = profiles for cb in self._profiles_updated_cbs: @@ -137,9 +130,9 @@ def worker(): def refresh_profiles(self): if self._is_euicc is False: return - self._poll_profiles(is_refresh=True) + self._poll_profiles() - def _poll_profiles(self, is_refresh: bool = False): + def _poll_profiles(self): self._polling = True def worker(): @@ -167,13 +160,14 @@ def worker(): def _finish_poll(self, profiles: list[Profile]): self._polling = False - if self._busy or time.monotonic() < self._no_poll_until: + if self._busy: return self._profiles = profiles for cb in self._profiles_updated_cbs: cb(profiles) def switch_profile(self, iccid: str): + # _switching_iccid stays set across _finish; cleared when modem state's ICCID matches (or on error) self._switching_iccid = iccid self._busy = True @@ -184,12 +178,14 @@ def worker(): lpa.switch_profile(iccid) # optimistic update: flip enabled flags locally profiles = [Profile(iccid=p.iccid, nickname=p.nickname, enabled=(p.iccid == iccid), provider=p.provider) for p in self._profiles] - self._no_poll_until = time.monotonic() + SWITCH_SETTLE_S self._callback_queue.append(lambda: self._finish(profiles=profiles)) except Exception as e: cloudlog.exception("Failed to switch eSIM profile") err = str(e) - self._callback_queue.append(lambda: self._finish(error=err)) + def fail(): + self._switching_iccid = None + self._finish(error=err) + self._callback_queue.append(fail) threading.Thread(target=worker, daemon=True).start() From 9235d049954a83cace11d0c2f4e3b65a6ef23c11 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 18:53:28 -0700 Subject: [PATCH 07/51] esim: only show 'switching...' on the target profile --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index e664633b960b1f..19e5e119f52885 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -201,8 +201,8 @@ def _update_state(self): if self._deleting: self.set_value("deleting...") - elif switching: - self.set_value("switching..." if not self._profile.enabled else "active") + elif self._cellular_manager.switching_iccid == self._profile.iccid: + self.set_value("switching...") elif self._profile.enabled: self.set_value("active") self.set_enabled(True) From b55c857d15cb6cddea41b0201389fdd2b2afc540 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 18:58:35 -0700 Subject: [PATCH 08/51] esim: read cell strength directly from HARDWARE --- .../mici/layouts/settings/network/__init__.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 517557dea3ce53..5b4a4599c1fc40 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,14 +1,15 @@ import pyray as rl from cereal import log +from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.cellular_manager import CellularManager, profile_display_name from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton -from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid NetworkType = log.DeviceState.NetworkType +NetworkStrength = log.DeviceState.NetworkStrength class ESimNetworkButton(BigButton): @@ -53,15 +54,16 @@ def _update_state(self): self.set_icon(self._cell_none_icon) def _get_cell_icon(self): - device_state = ui_state.sm['deviceState'] - net_type = device_state.networkType - if net_type not in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): - return self._cell_none_icon - strength = device_state.networkStrength - level = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 - icons = (self._cell_none_icon, self._cell_none_icon, self._cell_low_icon, - self._cell_medium_icon, self._cell_high_icon, self._cell_full_icon) - return icons[level] + # always read cell strength from HARDWARE so it reflects modem state even when wifi is the active connection + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + icons = { + NetworkStrength.unknown: self._cell_none_icon, + NetworkStrength.poor: self._cell_low_icon, + NetworkStrength.moderate: self._cell_medium_icon, + NetworkStrength.good: self._cell_high_icon, + NetworkStrength.great: self._cell_full_icon, + } + return icons.get(strength, self._cell_none_icon) class WifiNetworkButton(BigButton): From 4686d54c3956243489595cb60a0d0b6631343893 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:03:18 -0700 Subject: [PATCH 09/51] esim: hide checkmark and dim cell icon during switch; keep rename available --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 19e5e119f52885..f96758e1ce368f 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -105,7 +105,7 @@ def update_profile(self, profile: Profile): @property def _show_rename_btn(self) -> bool: - if self._deleting or self._cellular_manager.busy or self._cellular_manager.switching_iccid is not None: + if self._deleting or self._cellular_manager.busy: return False return self._rename_btn is not None @@ -145,6 +145,8 @@ def _draw_content(self, btn_y: float): self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) self._label.render(label_rect) + active = self._profile.enabled and self._cellular_manager.switching_iccid is None + if self.value: sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING @@ -152,7 +154,7 @@ def _draw_content(self, btn_y: float): sub_label_w = self.SUB_LABEL_WIDTH - action_w sub_label_height = self._sub_label.get_content_height(sub_label_w) - if self._profile.enabled and not self._deleting: + if active and not self._deleting: check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, CHECK_ICON_COLOR) sub_label_x += self._check_txt.width + 14 @@ -163,7 +165,7 @@ def _draw_content(self, btn_y: float): if self._comma_txt: rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE) else: - cell_icon = self._cell_full_txt if self._profile.enabled else self._cell_none_txt + cell_icon = self._cell_full_txt if active else self._cell_none_txt rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) btn_x = self._rect.x + self._rect.width From a181bb286f517ac2195751ee85dd2c78326a4e2a Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:06:31 -0700 Subject: [PATCH 10/51] esim: disable active profile button (rename still clickable as overlay) --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index f96758e1ce368f..9f627b593d329a 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -207,7 +207,7 @@ def _update_state(self): self.set_value("switching...") elif self._profile.enabled: self.set_value("active") - self.set_enabled(True) + self.set_enabled(False) self._sub_label.set_color(SUB_LABEL_DISABLED) self._sub_label.set_font_weight(FontWeight.ROMAN) else: From 808ab054a66e7db797da86c652cd032804f4447d Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:12:06 -0700 Subject: [PATCH 11/51] esim: stop rename/delete buttons and labels from flashing during operations --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 9f627b593d329a..3814742bce5863 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -101,17 +101,19 @@ def update_profile(self, profile: Profile): self._profile = profile self._deleting = False is_comma = self._cellular_manager.is_comma_profile(profile.iccid) - self.set_text("comma prime" if is_comma else _mici_profile_name(profile)) + new_text = "comma prime" if is_comma else _mici_profile_name(profile) + if new_text != self.text: + self.set_text(new_text) @property def _show_rename_btn(self) -> bool: - if self._deleting or self._cellular_manager.busy: + if self._deleting: return False return self._rename_btn is not None @property def _show_delete_btn(self) -> bool: - if self._deleting or self._profile.enabled or self._cellular_manager.busy or self._cellular_manager.switching_iccid is not None: + if self._deleting or self._profile.enabled or self._cellular_manager.switching_iccid is not None: return False return not self._cellular_manager.is_comma_profile(self._profile.iccid) From 864dee35a6021f51b941c66927741b1590f50c39 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:24:13 -0700 Subject: [PATCH 12/51] esim: show 'comma prime' on network button for comma profile --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 5b4a4599c1fc40..286767f4b05033 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -44,8 +44,10 @@ def _update_state(self): else: active = next((p for p in self._cellular_manager.profiles if p.enabled), None) if active: - name = profile_display_name(active) - self.set_text(f"{name} (...{active.iccid[-4:]})") + if self._cellular_manager.is_comma_profile(active.iccid): + self.set_text("comma prime") + else: + self.set_text(f"{profile_display_name(active)} (...{active.iccid[-4:]})") self.set_value(self._cellular_manager.modem_ip or "obtaining IP...") self.set_icon(self._get_cell_icon()) else: From 62a2b5e0b40cbb3fec7917964226a0d8eb9e5f9a Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:28:46 -0700 Subject: [PATCH 13/51] esim: move display_name and is_comma onto Profile dataclass --- .../mici/layouts/settings/network/__init__.py | 7 ++---- .../mici/layouts/settings/network/esim_ui.py | 22 ++++++------------- system/hardware/base.py | 11 ++++++++++ system/ui/lib/cellular_manager.py | 6 +---- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 286767f4b05033..56ba141cf133bc 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -2,7 +2,7 @@ from cereal import log from openpilot.system.hardware import HARDWARE -from openpilot.system.ui.lib.cellular_manager import CellularManager, profile_display_name +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.system.ui.lib.application import gui_app @@ -44,10 +44,7 @@ def _update_state(self): else: active = next((p for p in self._cellular_manager.profiles if p.enabled), None) if active: - if self._cellular_manager.is_comma_profile(active.iccid): - self.set_text("comma prime") - else: - self.set_text(f"{profile_display_name(active)} (...{active.iccid[-4:]})") + self.set_text(active.display_name) self.set_value(self._cellular_manager.modem_ip or "obtaining IP...") self.set_icon(self._get_cell_icon()) else: diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 3814742bce5863..e56b49e3f84daa 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -1,7 +1,7 @@ import pyray as rl from collections.abc import Callable -from openpilot.system.ui.lib.cellular_manager import CellularManager, profile_display_name +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog, BigInputDialog from openpilot.system.hardware.base import Profile @@ -67,10 +67,6 @@ def _render(self, _): gui_label(icon_rect, "Aa", 32, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) -def _mici_profile_name(profile: Profile) -> str: - return f"{profile_display_name(profile)} (...{profile.iccid[-4:]})" - - class ESimProfileButton(BigButton): LABEL_PADDING = 98 LABEL_WIDTH = 402 - 98 - 28 @@ -78,9 +74,7 @@ class ESimProfileButton(BigButton): def __init__(self, profile: Profile, cellular_manager: CellularManager): self._cellular_manager = cellular_manager - is_comma = cellular_manager.is_comma_profile(profile.iccid) - display_name = "comma prime" if is_comma else _mici_profile_name(profile) - super().__init__(display_name, scroll=True) + super().__init__(profile.display_name, scroll=True) self._profile = profile self._deleting = False @@ -88,10 +82,10 @@ def __init__(self, profile: Profile, cellular_manager: CellularManager): self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) - self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if is_comma else None + self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None self._delete_btn = DeleteButton(self._on_delete) - self._rename_btn = RenameButton(self._on_rename) if not is_comma else None + self._rename_btn = RenameButton(self._on_rename) if not profile.is_comma else None @property def profile(self) -> Profile: @@ -100,10 +94,8 @@ def profile(self) -> Profile: def update_profile(self, profile: Profile): self._profile = profile self._deleting = False - is_comma = self._cellular_manager.is_comma_profile(profile.iccid) - new_text = "comma prime" if is_comma else _mici_profile_name(profile) - if new_text != self.text: - self.set_text(new_text) + if profile.display_name != self.text: + self.set_text(profile.display_name) @property def _show_rename_btn(self) -> bool: @@ -115,7 +107,7 @@ def _show_rename_btn(self) -> bool: def _show_delete_btn(self) -> bool: if self._deleting or self._profile.enabled or self._cellular_manager.switching_iccid is not None: return False - return not self._cellular_manager.is_comma_profile(self._profile.iccid) + return not self._profile.is_comma def _on_delete(self): if self._deleting: diff --git a/system/hardware/base.py b/system/hardware/base.py index 0f578dffa46436..81eeeb01166859 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -20,6 +20,17 @@ class Profile: enabled: bool provider: str + @property + def is_comma(self) -> bool: + return self.provider == 'Webbing' + + @property + def display_name(self) -> str: + if self.is_comma: + return "comma prime" + name = self.nickname or self.provider or self.iccid[:12] + return f"{name} (...{self.iccid[-4:]})" + @dataclass class ThermalZone: # a zone from /sys/class/thermal/thermal_zone* diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 24cecbc9c54b8e..6386aa648d9982 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -6,10 +6,6 @@ from openpilot.system.hardware.base import LPABase, Profile -def profile_display_name(profile: Profile) -> str: - return profile.nickname or profile.provider or profile.iccid[:12] - - PROFILE_POLL_INTERVAL = 30.0 @@ -92,7 +88,7 @@ def is_euicc(self) -> bool | None: return self._is_euicc def is_comma_profile(self, iccid: str) -> bool: - return any(p.iccid == iccid and p.provider == 'Webbing' for p in self._profiles) + return any(p.iccid == iccid and p.is_comma for p in self._profiles) def _ensure_lpa(self) -> LPABase: if self._lpa is None: From a68d99fc16c96d4c95a51ca0547f5bd3a4ac7488 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:34:07 -0700 Subject: [PATCH 14/51] esim: anchor rename button to rightmost slot to prevent shift --- .../ui/mici/layouts/settings/network/esim_ui.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index e56b49e3f84daa..aa20699b801616 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -144,7 +144,8 @@ def _draw_content(self, btn_y: float): if self.value: sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - action_w = self._delete_btn.rect.width if self._show_delete_btn else 0 + action_w = self._rename_btn.rect.width if self._show_rename_btn else 0 + action_w += self._delete_btn.rect.width if self._show_delete_btn else 0 sub_label_w = self.SUB_LABEL_WIDTH - action_w sub_label_height = self._sub_label.get_content_height(sub_label_w) @@ -164,18 +165,18 @@ def _draw_content(self, btn_y: float): btn_x = self._rect.x + self._rect.width btn_bottom = btn_y + self._rect.height - if self._show_delete_btn: - btn_x -= self._delete_btn.rect.width - self._delete_btn.render(rl.Rectangle( - btn_x, btn_bottom - self._delete_btn.rect.height, - self._delete_btn.rect.width, self._delete_btn.rect.height, - )) if self._show_rename_btn: btn_x -= self._rename_btn.rect.width self._rename_btn.render(rl.Rectangle( btn_x, btn_bottom - self._rename_btn.rect.height, self._rename_btn.rect.width, self._rename_btn.rect.height, )) + if self._show_delete_btn: + btn_x -= self._delete_btn.rect.width + self._delete_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._delete_btn.rect.height, + self._delete_btn.rect.width, self._delete_btn.rect.height, + )) def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: def action_pressed() -> bool: From 100975433036fc76a6b44298b09ebb5d0f04db8d Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:35:43 -0700 Subject: [PATCH 15/51] esim: include iccid prefix check in Profile.is_comma --- system/hardware/base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/system/hardware/base.py b/system/hardware/base.py index 81eeeb01166859..1e91546e1888b8 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -13,6 +13,9 @@ class LPAError(RuntimeError): class LPAProfileNotFoundError(LPAError): pass +COMMA_ICCID_PREFIXES = ('8985235',) + + @dataclass class Profile: iccid: str @@ -22,7 +25,7 @@ class Profile: @property def is_comma(self) -> bool: - return self.provider == 'Webbing' + return self.provider == 'Webbing' or any(self.iccid.startswith(p) for p in COMMA_ICCID_PREFIXES) @property def display_name(self) -> str: @@ -110,7 +113,7 @@ def is_euicc(self) -> bool: pass def is_comma_profile(self, iccid: str) -> bool: - return any(iccid.startswith(prefix) for prefix in ('8985235',)) + return any(iccid.startswith(prefix) for prefix in COMMA_ICCID_PREFIXES) class HardwareBase(ABC): @staticmethod From 554e1d2e56aef9411a9febfef3436bd4ce15c05f Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:43:12 -0700 Subject: [PATCH 16/51] esim: drop process_notifications from cellular manager --- system/ui/lib/cellular_manager.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 6386aa648d9982..d88a4de0054f0f 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -113,7 +113,6 @@ def worker(): with self._lock: lpa = self._ensure_lpa() fn(lpa) - lpa.process_notifications() profiles = lpa.list_profiles() self._callback_queue.append(lambda: self._finish(profiles=profiles)) except Exception as e: @@ -142,8 +141,6 @@ def worker(): if not self._is_euicc: self._callback_queue.append(lambda: setattr(self, '_polling', False)) return - cloudlog.info("eSIM: processing notifications") - lpa.process_notifications() cloudlog.info("eSIM: listing profiles") profiles = lpa.list_profiles() cloudlog.info(f"eSIM: got {len(profiles)} profiles") From 681e1e8355d3e1893113cd68c6be85ea6f784734 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:49:30 -0700 Subject: [PATCH 17/51] esim: disable network button when SIM isn't an eUICC --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 2 ++ .../ui/mici/layouts/settings/network/network_layout.py | 6 +----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 56ba141cf133bc..0fb4c87ee0770e 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -25,6 +25,8 @@ def __init__(self, cellular_manager: CellularManager): def _update_state(self): super()._update_state() + self.set_enabled(self._cellular_manager.is_euicc is not False) + if self._cellular_manager.is_euicc is False: ms = self._cellular_manager.modem_state iccid = ms.get("iccid") or "" diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 8701c2a0d2f0d9..04600a7ea55f07 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -71,11 +71,7 @@ def network_metered_callback(value: str): self._esim_ui = ESimUIMici(self._cellular_manager) self._esim_button = ESimNetworkButton(self._cellular_manager) - def open_esim_ui(): - if self._cellular_manager.is_euicc is False: - return - gui_app.push_widget(self._esim_ui) - self._esim_button.set_click_callback(open_esim_ui) + self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) # ******** Advanced settings ******** # ******** Roaming toggle ******** From 67b9c0e4ff09b27d99caf986bcd8b950769e7470 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:50:09 -0700 Subject: [PATCH 18/51] esim: rename ESim* classes to Esim* --- .../ui/mici/layouts/settings/network/__init__.py | 2 +- .../ui/mici/layouts/settings/network/esim_ui.py | 14 +++++++------- .../layouts/settings/network/network_layout.py | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 0fb4c87ee0770e..b2beb1d766c47c 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -12,7 +12,7 @@ NetworkStrength = log.DeviceState.NetworkStrength -class ESimNetworkButton(BigButton): +class EsimNetworkButton(BigButton): def __init__(self, cellular_manager: CellularManager): self._cellular_manager = cellular_manager self._cell_none_icon = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index aa20699b801616..f96e12b45d7f5f 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -67,7 +67,7 @@ def _render(self, _): gui_label(icon_rect, "Aa", 32, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) -class ESimProfileButton(BigButton): +class EsimProfileButton(BigButton): LABEL_PADDING = 98 LABEL_WIDTH = 402 - 98 - 28 SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 @@ -212,7 +212,7 @@ def _update_state(self): self._sub_label.set_font_weight(FontWeight.SEMI_BOLD) -class ESimUIMici(NavScroller): +class EsimUIMici(NavScroller): def __init__(self, cellular_manager: CellularManager): super().__init__() @@ -232,7 +232,7 @@ def _on_profiles_updated(self, profiles: list[Profile]): self._update_buttons() def _update_buttons(self, re_sort: bool = False): - existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, ESimProfileButton)} + existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} profiles = self._cellular_manager.profiles current_iccids = {p.iccid for p in profiles} @@ -240,12 +240,12 @@ def _update_buttons(self, re_sort: bool = False): if profile.iccid in existing: existing[profile.iccid].update_profile(profile) else: - btn = ESimProfileButton(profile, self._cellular_manager) + btn = EsimProfileButton(profile, self._cellular_manager) btn.set_click_callback(lambda iccid=profile.iccid: self._on_profile_clicked(iccid)) self._scroller.add_widget(btn) if re_sort: - btn_map = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, ESimProfileButton)} + btn_map = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} self._scroller.items[:] = sorted( [btn_map[iccid] for iccid in current_iccids if iccid in btn_map], key=lambda b: not b.profile.enabled, @@ -253,12 +253,12 @@ def _update_buttons(self, re_sort: bool = False): else: self._scroller.items[:] = [ btn for btn in self._scroller.items - if not isinstance(btn, ESimProfileButton) or btn.profile.iccid in current_iccids + if not isinstance(btn, EsimProfileButton) or btn.profile.iccid in current_iccids ] def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) - if isinstance(btn, ESimProfileButton) and + if isinstance(btn, EsimProfileButton) and btn.profile.iccid == iccid), None) if iccid else None if front_btn_idx is not None and front_btn_idx > 0: diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 04600a7ea55f07..1cff36ad43bc44 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,7 +1,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network import ESimNetworkButton, WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton from openpilot.system.ui.lib.cellular_manager import CellularManager -from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import ESimUIMici +from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUIMici from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog @@ -68,8 +68,8 @@ def network_metered_callback(value: str): # ******** eSIM ******** self._cellular_manager = cellular_manager - self._esim_ui = ESimUIMici(self._cellular_manager) - self._esim_button = ESimNetworkButton(self._cellular_manager) + self._esim_ui = EsimUIMici(self._cellular_manager) + self._esim_button = EsimNetworkButton(self._cellular_manager) self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) From addd6c34d7011681e92da2638036fe7c6234cdf1 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:51:54 -0700 Subject: [PATCH 19/51] esim: sort imports --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 4 ++-- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 2 +- .../ui/mici/layouts/settings/network/network_layout.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index b2beb1d766c47c..35bf117edcdafc 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,11 +1,11 @@ import pyray as rl from cereal import log -from openpilot.system.hardware import HARDWARE -from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid NetworkType = log.DeviceState.NetworkType diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index f96e12b45d7f5f..f92f9697ccdbb3 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -1,11 +1,11 @@ import pyray as rl from collections.abc import Callable -from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog, BigInputDialog from openpilot.system.hardware.base import Profile from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller import NavScroller diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 1cff36ad43bc44..d04be8314dbd61 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,14 +1,14 @@ -from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton -from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUIMici from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType +from openpilot.system.ui.widgets.scroller import NavScroller class NetworkLayoutMici(NavScroller): From 290c56231df3d02ce4293a4468b6224dd3944dbc Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 19:54:52 -0700 Subject: [PATCH 20/51] esim: default to 'loading...' instead of 'no active profile' --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 35bf117edcdafc..e1f95715f401de 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -20,7 +20,7 @@ def __init__(self, cellular_manager: CellularManager): self._cell_medium_icon = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47) self._cell_high_icon = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47) self._cell_full_icon = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47) - super().__init__("esim", "no active profile", self._cell_none_icon, scroll=True) + super().__init__("esim", "loading...", self._cell_none_icon, scroll=True) def _update_state(self): super()._update_state() @@ -51,7 +51,7 @@ def _update_state(self): self.set_icon(self._get_cell_icon()) else: self.set_text("esim") - self.set_value("no active profile") + self.set_value("loading...") self.set_icon(self._cell_none_icon) def _get_cell_icon(self): From 2c779fd4b85979a1af3faeb8412c916edbed2138 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:04:31 -0700 Subject: [PATCH 21/51] esim: rename PROFILE_POLL_INTERVAL to PROFILE_POLL_INTERVAL_S --- system/ui/lib/cellular_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index d88a4de0054f0f..c40d3c3066bb2c 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -6,7 +6,7 @@ from openpilot.system.hardware.base import LPABase, Profile -PROFILE_POLL_INTERVAL = 30.0 +PROFILE_POLL_INTERVAL_S = 30.0 def _get_lpa() -> LPABase: @@ -66,7 +66,7 @@ def process_callbacks(self): if self._switching_iccid and self._modem_state.get("iccid") == self._switching_iccid: self._switching_iccid = None - if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL: + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL_S: self._last_profile_poll = time.monotonic() if self._is_euicc is not False: self._poll_profiles() From 538e7b9c660a70182d9d67db4ca071adaa7f0828 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:07:19 -0700 Subject: [PATCH 22/51] esim: slim EsimNetworkButton --- .../mici/layouts/settings/network/__init__.py | 77 ++++++++----------- 1 file changed, 30 insertions(+), 47 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index e1f95715f401de..7f7d828d7cb754 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -9,62 +9,45 @@ from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid NetworkType = log.DeviceState.NetworkType -NetworkStrength = log.DeviceState.NetworkStrength class EsimNetworkButton(BigButton): + # indexed by NetworkStrength enum value: unknown, poor, moderate, good, great + CELL_ICONS = ("none", "low", "medium", "high", "full") + def __init__(self, cellular_manager: CellularManager): self._cellular_manager = cellular_manager - self._cell_none_icon = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47) - self._cell_low_icon = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47) - self._cell_medium_icon = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47) - self._cell_high_icon = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47) - self._cell_full_icon = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47) - super().__init__("esim", "loading...", self._cell_none_icon, scroll=True) + self._cell_icons = [gui_app.texture(f"icons_mici/settings/network/cell_strength_{n}.png", 64, 47) for n in self.CELL_ICONS] + super().__init__("esim", "loading...", self._cell_icons[0], scroll=True) def _update_state(self): super()._update_state() - self.set_enabled(self._cellular_manager.is_euicc is not False) - - if self._cellular_manager.is_euicc is False: - ms = self._cellular_manager.modem_state - iccid = ms.get("iccid") or "" - if iccid: - self.set_text(f"sim (...{iccid[-4:]})") - self.set_value(self._cellular_manager.modem_ip or ms.get("mcc_mnc") or "no IP") - else: - self.set_text("sim") - self.set_value("no sim") - self.set_icon(self._get_cell_icon() if iccid else self._cell_none_icon) - return - - if self._cellular_manager.switching_iccid is not None: - self.set_text("esim") - self.set_value("switching...") - self.set_icon(self._cell_none_icon) - else: - active = next((p for p in self._cellular_manager.profiles if p.enabled), None) - if active: - self.set_text(active.display_name) - self.set_value(self._cellular_manager.modem_ip or "obtaining IP...") - self.set_icon(self._get_cell_icon()) - else: - self.set_text("esim") - self.set_value("loading...") - self.set_icon(self._cell_none_icon) - - def _get_cell_icon(self): - # always read cell strength from HARDWARE so it reflects modem state even when wifi is the active connection - strength = HARDWARE.get_network_strength(NetworkType.cell4G) - icons = { - NetworkStrength.unknown: self._cell_none_icon, - NetworkStrength.poor: self._cell_low_icon, - NetworkStrength.moderate: self._cell_medium_icon, - NetworkStrength.good: self._cell_high_icon, - NetworkStrength.great: self._cell_full_icon, - } - return icons.get(strength, self._cell_none_icon) + text, value, icon = self._compute_state() + self.set_text(text) + self.set_value(value) + self.set_icon(icon) + + def _compute_state(self): + cm = self._cellular_manager + if cm.is_euicc is False: + iccid = cm.modem_state.get("iccid") or "" + if not iccid: + return "sim", "no sim", self._cell_icons[0] + value = cm.modem_ip or cm.modem_state.get("mcc_mnc") or "no IP" + return f"sim (...{iccid[-4:]})", value, self._cell_icon() + + if cm.switching_iccid is not None: + return "esim", "switching...", self._cell_icons[0] + + active = next((p for p in cm.profiles if p.enabled), None) + if active is None: + return "esim", "loading...", self._cell_icons[0] + return active.display_name, cm.modem_ip or "obtaining IP...", self._cell_icon() + + def _cell_icon(self): + # read directly from HARDWARE so it reflects modem state even when wifi is the active connection + return self._cell_icons[HARDWARE.get_network_strength(NetworkType.cell4G)] class WifiNetworkButton(BigButton): From caf7f77f7a4befad948f52fe30842ff20dae8b4f Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:10:04 -0700 Subject: [PATCH 23/51] esim: use DEFAULT_TEXT_COLOR; load delete dialog texture in __init__ --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index f92f9697ccdbb3..9d89c3e704a7d7 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -4,14 +4,13 @@ from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog, BigInputDialog from openpilot.system.hardware.base import Profile -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, gui_app from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller import NavScroller SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) -SUB_LABEL_ACTIVE = rl.Color(255, 255, 255, int(255 * 0.9)) CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) @@ -25,12 +24,12 @@ def __init__(self, delete_callback: Callable): self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84) self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84) self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35) + self._dialog_trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2)) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) - dlg = BigConfirmationDialog("slide to delete", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), - self._delete_callback, red=True) + dlg = BigConfirmationDialog("slide to delete", self._dialog_trash_txt, self._delete_callback, red=True) gui_app.push_widget(dlg) def _render(self, _): @@ -208,7 +207,7 @@ def _update_state(self): else: self.set_value("switch") self.set_enabled(True) - self._sub_label.set_color(SUB_LABEL_ACTIVE) + self._sub_label.set_color(DEFAULT_TEXT_COLOR) self._sub_label.set_font_weight(FontWeight.SEMI_BOLD) From f90438d8b69949b9a0a9c4a2d2ac23f48204a8ea Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:12:58 -0700 Subject: [PATCH 24/51] esim: revert cell-icon index trick, name each NetworkStrength explicitly --- .../mici/layouts/settings/network/__init__.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 7f7d828d7cb754..5fa212ab0a96c8 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -9,16 +9,18 @@ from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid NetworkType = log.DeviceState.NetworkType +NetworkStrength = log.DeviceState.NetworkStrength class EsimNetworkButton(BigButton): - # indexed by NetworkStrength enum value: unknown, poor, moderate, good, great - CELL_ICONS = ("none", "low", "medium", "high", "full") - def __init__(self, cellular_manager: CellularManager): self._cellular_manager = cellular_manager - self._cell_icons = [gui_app.texture(f"icons_mici/settings/network/cell_strength_{n}.png", 64, 47) for n in self.CELL_ICONS] - super().__init__("esim", "loading...", self._cell_icons[0], scroll=True) + self._cell_none_icon = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47) + self._cell_low_icon = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47) + self._cell_medium_icon = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47) + self._cell_high_icon = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47) + self._cell_full_icon = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47) + super().__init__("esim", "loading...", self._cell_none_icon, scroll=True) def _update_state(self): super()._update_state() @@ -33,21 +35,28 @@ def _compute_state(self): if cm.is_euicc is False: iccid = cm.modem_state.get("iccid") or "" if not iccid: - return "sim", "no sim", self._cell_icons[0] + return "sim", "no sim", self._cell_none_icon value = cm.modem_ip or cm.modem_state.get("mcc_mnc") or "no IP" return f"sim (...{iccid[-4:]})", value, self._cell_icon() if cm.switching_iccid is not None: - return "esim", "switching...", self._cell_icons[0] + return "esim", "switching...", self._cell_none_icon active = next((p for p in cm.profiles if p.enabled), None) if active is None: - return "esim", "loading...", self._cell_icons[0] + return "esim", "loading...", self._cell_none_icon return active.display_name, cm.modem_ip or "obtaining IP...", self._cell_icon() def _cell_icon(self): # read directly from HARDWARE so it reflects modem state even when wifi is the active connection - return self._cell_icons[HARDWARE.get_network_strength(NetworkType.cell4G)] + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + return { + NetworkStrength.unknown: self._cell_none_icon, + NetworkStrength.poor: self._cell_low_icon, + NetworkStrength.moderate: self._cell_medium_icon, + NetworkStrength.good: self._cell_high_icon, + NetworkStrength.great: self._cell_full_icon, + }.get(strength, self._cell_none_icon) class WifiNetworkButton(BigButton): From 9d3478a422c89b49157a4e424c44dc5ee7726109 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:44:29 -0700 Subject: [PATCH 25/51] esim: tighten delete/rename spacing so 'switch' label fits on one line --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 9d89c3e704a7d7..af0549360cd55f 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -144,7 +144,8 @@ def _draw_content(self, btn_y: float): sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING action_w = self._rename_btn.rect.width if self._show_rename_btn else 0 - action_w += self._delete_btn.rect.width if self._show_delete_btn else 0 + # delete sits just inside rename's left edge (overlap their inner margins) so the sub_label has more room + action_w += self._delete_btn.rect.width - DeleteButton.MARGIN if self._show_delete_btn else 0 sub_label_w = self.SUB_LABEL_WIDTH - action_w sub_label_height = self._sub_label.get_content_height(sub_label_w) @@ -171,7 +172,7 @@ def _draw_content(self, btn_y: float): self._rename_btn.rect.width, self._rename_btn.rect.height, )) if self._show_delete_btn: - btn_x -= self._delete_btn.rect.width + btn_x -= self._delete_btn.rect.width - DeleteButton.MARGIN self._delete_btn.render(rl.Rectangle( btn_x, btn_bottom - self._delete_btn.rect.height, self._delete_btn.rect.width, self._delete_btn.rect.height, From c6edd417e16e4463e0a900bfd65cc137cc43745b Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:49:43 -0700 Subject: [PATCH 26/51] esim: shrink delete/rename buttons by 25% --- .../ui/mici/layouts/settings/network/esim_ui.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index af0549360cd55f..91db110ced24dd 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -15,17 +15,18 @@ class DeleteButton(Widget): + SIZE = 63 MARGIN = 12 def __init__(self, delete_callback: Callable): super().__init__() self._delete_callback = delete_callback - self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84) - self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84) - self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35) + self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", self.SIZE, self.SIZE) + self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", self.SIZE, self.SIZE) + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 22, 26) self._dialog_trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) - self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2)) + self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.MARGIN * 2, self.SIZE + self.MARGIN * 2)) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) @@ -43,7 +44,7 @@ def _render(self, _): class RenameButton(Widget): - SIZE = 84 + SIZE = 63 MARGIN = 12 def __init__(self, rename_callback: Callable): @@ -63,7 +64,7 @@ def _render(self, _): rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) icon_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height) - gui_label(icon_rect, "Aa", 32, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(icon_rect, "Aa", 24, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) class EsimProfileButton(BigButton): From 9749b3e70f73d8967ee43b98c8ce8ec5aa7e18bd Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sat, 2 May 2026 20:53:17 -0700 Subject: [PATCH 27/51] esim: keep rename button at full size, only delete shrinks --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 91db110ced24dd..018e8198478da4 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -44,7 +44,7 @@ def _render(self, _): class RenameButton(Widget): - SIZE = 63 + SIZE = 84 MARGIN = 12 def __init__(self, rename_callback: Callable): @@ -64,7 +64,7 @@ def _render(self, _): rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) icon_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height) - gui_label(icon_rect, "Aa", 24, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(icon_rect, "Aa", 32, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) class EsimProfileButton(BigButton): From f6d1c2e512e0699c2e15375a392482898c88e405 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Mon, 4 May 2026 22:54:36 -0700 Subject: [PATCH 28/51] Revert "disable modem.py for now" This reverts commit 1eeba86ec1826add74837dcf40beaf8ce81fb865. --- system/manager/process_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/manager/process_config.py b/system/manager/process_config.py index f39af059c0c369..f5a6c510720958 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -106,7 +106,7 @@ def and_(*fns): PythonProcess("lateral_maneuversd", "tools.lateral_maneuvers.lateral_maneuversd", lat_maneuver), PythonProcess("radard", "selfdrive.controls.radard", only_onroad), PythonProcess("hardwared", "system.hardware.hardwared", always_run), - PythonProcess("modem", "system.hardware.tici.modem", always_run, enabled=False), + PythonProcess("modem", "system.hardware.tici.modem", always_run, enabled=TICI), PythonProcess("tombstoned", "system.tombstoned", always_run, enabled=not PC), PythonProcess("updated", "system.updated.updated", only_offroad, enabled=not PC), PythonProcess("uploader", "system.loggerd.uploader", always_run), From 0fdfc19ca40382c9a59789b184ecaa16e8b5f5b7 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Mon, 4 May 2026 22:55:27 -0700 Subject: [PATCH 29/51] Revert "modem.py is disabled" This reverts commit d238a1ccc4d72a9165d9483ea8bd84d218ad8f00. --- selfdrive/test/test_onroad.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/test/test_onroad.py b/selfdrive/test/test_onroad.py index 0ef558c0333284..c2dacb63a78f4f 100644 --- a/selfdrive/test/test_onroad.py +++ b/selfdrive/test/test_onroad.py @@ -68,7 +68,7 @@ "system.loggerd.deleter": 1.0, "./pandad": 19.0, "system.qcomgpsd.qcomgpsd": 1.0, - #"system.hardware.tici.modem": 2.0, + "system.hardware.tici.modem": 2.0, } TIMINGS = { From 0e2d8258fe79990c3a97832622ac0ce463130fb5 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 19:37:57 -0700 Subject: [PATCH 30/51] lpa: inline comma iccid prefix --- system/hardware/base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/system/hardware/base.py b/system/hardware/base.py index 89ea076b33f0ee..fc9094602fb95f 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -13,9 +13,6 @@ class LPAError(RuntimeError): class LPAProfileNotFoundError(LPAError): pass -COMMA_ICCID_PREFIXES = ('8985235',) - - @dataclass class Profile: iccid: str @@ -25,7 +22,7 @@ class Profile: @property def is_comma(self) -> bool: - return self.provider == 'Webbing' or any(self.iccid.startswith(p) for p in COMMA_ICCID_PREFIXES) + return self.provider == 'Webbing' or self.iccid.startswith('8985235') @property def display_name(self) -> str: From 574c324797bccfc8d7412d2f4310d52cea14ae4f Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:20:35 -0700 Subject: [PATCH 31/51] ui/cellular_manager: clear switching state when LPA returns --- system/ui/lib/cellular_manager.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index c40d3c3066bb2c..e277c751777586 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -63,9 +63,6 @@ def process_callbacks(self): self._modem_state = _get_modem_state() - if self._switching_iccid and self._modem_state.get("iccid") == self._switching_iccid: - self._switching_iccid = None - if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL_S: self._last_profile_poll = time.monotonic() if self._is_euicc is not False: @@ -160,7 +157,6 @@ def _finish_poll(self, profiles: list[Profile]): cb(profiles) def switch_profile(self, iccid: str): - # _switching_iccid stays set across _finish; cleared when modem state's ICCID matches (or on error) self._switching_iccid = iccid self._busy = True @@ -171,7 +167,10 @@ def worker(): lpa.switch_profile(iccid) # optimistic update: flip enabled flags locally profiles = [Profile(iccid=p.iccid, nickname=p.nickname, enabled=(p.iccid == iccid), provider=p.provider) for p in self._profiles] - self._callback_queue.append(lambda: self._finish(profiles=profiles)) + def done(): + self._switching_iccid = None + self._finish(profiles=profiles) + self._callback_queue.append(done) except Exception as e: cloudlog.exception("Failed to switch eSIM profile") err = str(e) From 4e6b457549c4b4625c026a1ba1e7fb15fe03c592 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:29:39 -0700 Subject: [PATCH 32/51] ui/cellular_manager: lock callback queue, drop poll log noise --- system/ui/lib/cellular_manager.py | 34 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index e277c751777586..4b090abece85f7 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -1,6 +1,7 @@ import time import threading from collections.abc import Callable +from dataclasses import replace from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.base import LPABase, Profile @@ -34,6 +35,7 @@ def __init__(self): self._modem_state: dict = {} self._lock = threading.Lock() + self._callback_lock = threading.Lock() self._callback_queue: list[Callable] = [] self._profiles_updated_cbs: list[Callable[[list[Profile]], None]] = [] @@ -57,7 +59,8 @@ def modem_state(self) -> dict: return self._modem_state def process_callbacks(self): - to_run, self._callback_queue = self._callback_queue, [] + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] for cb in to_run: cb() @@ -92,6 +95,13 @@ def _ensure_lpa(self) -> LPABase: self._lpa = _get_lpa() return self._lpa + def _enqueue(self, cb: Callable): + with self._callback_lock: + self._callback_queue.append(cb) + + def _stop_polling(self): + self._polling = False + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): self._busy = False if profiles is not None: @@ -111,11 +121,11 @@ def worker(): lpa = self._ensure_lpa() fn(lpa) profiles = lpa.list_profiles() - self._callback_queue.append(lambda: self._finish(profiles=profiles)) + self._enqueue(lambda: self._finish(profiles=profiles)) except Exception as e: cloudlog.exception(error_msg) err = str(e) - self._callback_queue.append(lambda: self._finish(error=err)) + self._enqueue(lambda: self._finish(error=err)) threading.Thread(target=worker, daemon=True).start() @@ -126,25 +136,25 @@ def refresh_profiles(self): def _poll_profiles(self): self._polling = True + first_check = self._is_euicc is None def worker(): try: with self._lock: lpa = self._ensure_lpa() if self._is_euicc is None: - cloudlog.info("eSIM: checking eUICC presence") self._is_euicc = lpa.is_euicc() cloudlog.info(f"eSIM: is_euicc={self._is_euicc}") if not self._is_euicc: - self._callback_queue.append(lambda: setattr(self, '_polling', False)) + self._enqueue(self._stop_polling) return - cloudlog.info("eSIM: listing profiles") profiles = lpa.list_profiles() - cloudlog.info(f"eSIM: got {len(profiles)} profiles") - self._callback_queue.append(lambda: self._finish_poll(profiles)) + if first_check: + cloudlog.info(f"eSIM: got {len(profiles)} profiles") + self._enqueue(lambda: self._finish_poll(profiles)) except Exception: cloudlog.exception("Failed to poll eSIM profiles") - self._callback_queue.append(lambda: setattr(self, '_polling', False)) + self._enqueue(self._stop_polling) threading.Thread(target=worker, daemon=True).start() @@ -166,18 +176,18 @@ def worker(): lpa = self._ensure_lpa() lpa.switch_profile(iccid) # optimistic update: flip enabled flags locally - profiles = [Profile(iccid=p.iccid, nickname=p.nickname, enabled=(p.iccid == iccid), provider=p.provider) for p in self._profiles] + profiles = [replace(p, enabled=(p.iccid == iccid)) for p in self._profiles] def done(): self._switching_iccid = None self._finish(profiles=profiles) - self._callback_queue.append(done) + self._enqueue(done) except Exception as e: cloudlog.exception("Failed to switch eSIM profile") err = str(e) def fail(): self._switching_iccid = None self._finish(error=err) - self._callback_queue.append(fail) + self._enqueue(fail) threading.Thread(target=worker, daemon=True).start() From 12043a82e50d6cb35765710e6885639503e4b5bd Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:31:19 -0700 Subject: [PATCH 33/51] esim: sort NetworkStrength/NetworkType imports --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 2 +- system/hardware/base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 5fa212ab0a96c8..8d282bac48ff30 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -8,8 +8,8 @@ from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid -NetworkType = log.DeviceState.NetworkType NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType class EsimNetworkButton(BigButton): diff --git a/system/hardware/base.py b/system/hardware/base.py index fc9094602fb95f..46ddfb10b22645 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -4,8 +4,8 @@ from cereal import log -NetworkType = log.DeviceState.NetworkType NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType class LPAError(RuntimeError): pass From c769ca559549f91443666c67e77da189023b9905 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:35:21 -0700 Subject: [PATCH 34/51] esim: drop switching_iccid concept --- .../mici/layouts/settings/network/__init__.py | 3 -- .../mici/layouts/settings/network/esim_ui.py | 19 ++++-------- system/ui/lib/cellular_manager.py | 31 ++----------------- 3 files changed, 9 insertions(+), 44 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 8d282bac48ff30..4a4087c174c8e6 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -39,9 +39,6 @@ def _compute_state(self): value = cm.modem_ip or cm.modem_state.get("mcc_mnc") or "no IP" return f"sim (...{iccid[-4:]})", value, self._cell_icon() - if cm.switching_iccid is not None: - return "esim", "switching...", self._cell_none_icon - active = next((p for p in cm.profiles if p.enabled), None) if active is None: return "esim", "loading...", self._cell_none_icon diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 018e8198478da4..7734c879a42a1a 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -105,7 +105,7 @@ def _show_rename_btn(self) -> bool: @property def _show_delete_btn(self) -> bool: - if self._deleting or self._profile.enabled or self._cellular_manager.switching_iccid is not None: + if self._deleting or self._profile.enabled or self._cellular_manager.busy: return False return not self._profile.is_comma @@ -139,7 +139,7 @@ def _draw_content(self, btn_y: float): self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) self._label.render(label_rect) - active = self._profile.enabled and self._cellular_manager.switching_iccid is None + active = self._profile.enabled if self.value: sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING @@ -190,17 +190,13 @@ def action_pressed() -> bool: def _update_state(self): super()._update_state() - switching = self._cellular_manager.switching_iccid is not None - - if self._cellular_manager.busy or switching or self._deleting: + if self._cellular_manager.busy or self._deleting: self.set_enabled(False) self._sub_label.set_color(SUB_LABEL_DISABLED) self._sub_label.set_font_weight(FontWeight.ROMAN) if self._deleting: self.set_value("deleting...") - elif self._cellular_manager.switching_iccid == self._profile.iccid: - self.set_value("switching...") elif self._profile.enabled: self.set_value("active") self.set_enabled(False) @@ -271,18 +267,15 @@ def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): def _update_state(self): super()._update_state() - iccid = self._cellular_manager.switching_iccid - if iccid is None: - active = next((p for p in self._cellular_manager.profiles if p.enabled), None) - iccid = active.iccid if active else None - self._move_profile_to_front(iccid) + active = next((p for p in self._cellular_manager.profiles if p.enabled), None) + self._move_profile_to_front(active.iccid if active else None) def _on_error(self, error: str): dlg = BigDialog("esim error", error) gui_app.push_widget(dlg) def _on_profile_clicked(self, iccid: str): - if self._cellular_manager.busy or self._cellular_manager.switching_iccid is not None: + if self._cellular_manager.busy: return profile = next((p for p in self._cellular_manager.profiles if p.iccid == iccid), None) if profile is None or profile.enabled: diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 4b090abece85f7..465b70896f1e96 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -1,7 +1,6 @@ import time import threading from collections.abc import Callable -from dataclasses import replace from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.base import LPABase, Profile @@ -28,7 +27,6 @@ def __init__(self): self._lpa: LPABase | None = None self._profiles: list[Profile] = [] self._busy: bool = False - self._switching_iccid: str | None = None # None = not yet checked, True/False = cached result. SIM cannot be swapped # without disassembling the device, so we probe once and keep the result. self._is_euicc: bool | None = None @@ -79,10 +77,6 @@ def profiles(self) -> list[Profile]: def busy(self) -> bool: return self._busy - @property - def switching_iccid(self) -> str | None: - return self._switching_iccid - @property def is_euicc(self) -> bool | None: return self._is_euicc @@ -167,29 +161,10 @@ def _finish_poll(self, profiles: list[Profile]): cb(profiles) def switch_profile(self, iccid: str): - self._switching_iccid = iccid - self._busy = True + def op(lpa: LPABase): + lpa.switch_profile(iccid) - def worker(): - try: - with self._lock: - lpa = self._ensure_lpa() - lpa.switch_profile(iccid) - # optimistic update: flip enabled flags locally - profiles = [replace(p, enabled=(p.iccid == iccid)) for p in self._profiles] - def done(): - self._switching_iccid = None - self._finish(profiles=profiles) - self._enqueue(done) - except Exception as e: - cloudlog.exception("Failed to switch eSIM profile") - err = str(e) - def fail(): - self._switching_iccid = None - self._finish(error=err) - self._enqueue(fail) - - threading.Thread(target=worker, daemon=True).start() + self._run_operation(op, "Failed to switch eSIM profile") def delete_profile(self, iccid: str): self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") From b4318a5bc89df070cb007f767989f0c0a177fb3b Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:46:15 -0700 Subject: [PATCH 35/51] esim: show 'switching...' on the clicked profile button --- .../ui/mici/layouts/settings/network/esim_ui.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 7734c879a42a1a..bcd3c30cc2714b 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -78,6 +78,7 @@ def __init__(self, profile: Profile, cellular_manager: CellularManager): self._profile = profile self._deleting = False + self._switching = False self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) @@ -94,9 +95,13 @@ def profile(self) -> Profile: def update_profile(self, profile: Profile): self._profile = profile self._deleting = False + self._switching = False if profile.display_name != self.text: self.set_text(profile.display_name) + def mark_switching(self): + self._switching = True + @property def _show_rename_btn(self) -> bool: if self._deleting: @@ -197,6 +202,8 @@ def _update_state(self): if self._deleting: self.set_value("deleting...") + elif self._switching: + self.set_value("switching...") elif self._profile.enabled: self.set_value("active") self.set_enabled(False) @@ -281,5 +288,10 @@ def _on_profile_clicked(self, iccid: str): if profile is None or profile.enabled: return + btn = next((b for b in self._scroller.items + if isinstance(b, EsimProfileButton) and b.profile.iccid == iccid), None) + if btn is not None: + btn.mark_switching() + self._cellular_manager.switch_profile(iccid) self._move_profile_to_front(iccid, scroll=True) From cdf5f759b2c03dc7a35376133cec97ec074d47de Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:52:15 -0700 Subject: [PATCH 36/51] esim: optimistic switch, skip post-switch list_profiles to avoid flicker --- system/ui/lib/cellular_manager.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 465b70896f1e96..2098ab6b101fef 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -1,6 +1,7 @@ import time import threading from collections.abc import Callable +from dataclasses import replace from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.base import LPABase, Profile @@ -161,10 +162,23 @@ def _finish_poll(self, profiles: list[Profile]): cb(profiles) def switch_profile(self, iccid: str): - def op(lpa: LPABase): - lpa.switch_profile(iccid) + self._busy = True - self._run_operation(op, "Failed to switch eSIM profile") + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + lpa.switch_profile(iccid) + # optimistic: switch_profile() succeeded, flip enabled flags locally + # without calling list_profiles() (which can briefly return stale state) + profiles = [replace(p, enabled=(p.iccid == iccid)) for p in self._profiles] + self._enqueue(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception("Failed to switch eSIM profile") + err = str(e) + self._enqueue(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() def delete_profile(self, iccid: str): self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") From 567406dc6851480eb653ee207397859e691e76e8 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 21:54:45 -0700 Subject: [PATCH 37/51] esim: only style profile button from local op flags, not global busy --- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index bcd3c30cc2714b..1a8e912b3b49fb 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -110,7 +110,7 @@ def _show_rename_btn(self) -> bool: @property def _show_delete_btn(self) -> bool: - if self._deleting or self._profile.enabled or self._cellular_manager.busy: + if self._deleting or self._profile.enabled: return False return not self._profile.is_comma @@ -195,15 +195,11 @@ def action_pressed() -> bool: def _update_state(self): super()._update_state() - if self._cellular_manager.busy or self._deleting: + if self._deleting or self._switching: self.set_enabled(False) self._sub_label.set_color(SUB_LABEL_DISABLED) self._sub_label.set_font_weight(FontWeight.ROMAN) - - if self._deleting: - self.set_value("deleting...") - elif self._switching: - self.set_value("switching...") + self.set_value("deleting..." if self._deleting else "switching...") elif self._profile.enabled: self.set_value("active") self.set_enabled(False) From 6eedc35859dc0d02c3a29716dd1788ab9d41bb1c Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:02:08 -0700 Subject: [PATCH 38/51] esim: remove deleting/switching state, collapse profile button branches --- .../mici/layouts/settings/network/esim_ui.py | 78 ++++--------------- system/ui/lib/cellular_manager.py | 6 +- 2 files changed, 18 insertions(+), 66 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 1a8e912b3b49fb..3437ac3f9830b3 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -77,15 +77,13 @@ def __init__(self, profile: Profile, cellular_manager: CellularManager): super().__init__(profile.display_name, scroll=True) self._profile = profile - self._deleting = False - self._switching = False self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None - self._delete_btn = DeleteButton(self._on_delete) + self._delete_btn = DeleteButton(lambda: self._cellular_manager.delete_profile(self._profile.iccid)) self._rename_btn = RenameButton(self._on_rename) if not profile.is_comma else None @property @@ -94,31 +92,12 @@ def profile(self) -> Profile: def update_profile(self, profile: Profile): self._profile = profile - self._deleting = False - self._switching = False if profile.display_name != self.text: self.set_text(profile.display_name) - def mark_switching(self): - self._switching = True - - @property - def _show_rename_btn(self) -> bool: - if self._deleting: - return False - return self._rename_btn is not None - @property def _show_delete_btn(self) -> bool: - if self._deleting or self._profile.enabled: - return False - return not self._profile.is_comma - - def _on_delete(self): - if self._deleting: - return - self._deleting = True - self._cellular_manager.delete_profile(self._profile.iccid) + return not self._profile.enabled and not self._profile.is_comma def _on_rename(self): current = self._profile.nickname or "" @@ -131,7 +110,7 @@ def _on_nickname_entered(self, nickname: str): def _handle_mouse_release(self, mouse_pos: MousePos): if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect): return - if self._show_rename_btn and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): + if self._rename_btn is not None and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): return super()._handle_mouse_release(mouse_pos) @@ -149,13 +128,13 @@ def _draw_content(self, btn_y: float): if self.value: sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - action_w = self._rename_btn.rect.width if self._show_rename_btn else 0 + action_w = self._rename_btn.rect.width if self._rename_btn is not None else 0 # delete sits just inside rename's left edge (overlap their inner margins) so the sub_label has more room action_w += self._delete_btn.rect.width - DeleteButton.MARGIN if self._show_delete_btn else 0 sub_label_w = self.SUB_LABEL_WIDTH - action_w sub_label_height = self._sub_label.get_content_height(sub_label_w) - if active and not self._deleting: + if active: check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, CHECK_ICON_COLOR) sub_label_x += self._check_txt.width + 14 @@ -171,7 +150,7 @@ def _draw_content(self, btn_y: float): btn_x = self._rect.x + self._rect.width btn_bottom = btn_y + self._rect.height - if self._show_rename_btn: + if self._rename_btn is not None: btn_x -= self._rename_btn.rect.width self._rename_btn.render(rl.Rectangle( btn_x, btn_bottom - self._rename_btn.rect.height, @@ -194,22 +173,11 @@ def action_pressed() -> bool: def _update_state(self): super()._update_state() - - if self._deleting or self._switching: - self.set_enabled(False) - self._sub_label.set_color(SUB_LABEL_DISABLED) - self._sub_label.set_font_weight(FontWeight.ROMAN) - self.set_value("deleting..." if self._deleting else "switching...") - elif self._profile.enabled: - self.set_value("active") - self.set_enabled(False) - self._sub_label.set_color(SUB_LABEL_DISABLED) - self._sub_label.set_font_weight(FontWeight.ROMAN) - else: - self.set_value("switch") - self.set_enabled(True) - self._sub_label.set_color(DEFAULT_TEXT_COLOR) - self._sub_label.set_font_weight(FontWeight.SEMI_BOLD) + active = self._profile.enabled + self.set_value("active" if active else "switch") + self.set_enabled(not active) + self._sub_label.set_color(SUB_LABEL_DISABLED if active else DEFAULT_TEXT_COLOR) + self._sub_label.set_font_weight(FontWeight.ROMAN if active else FontWeight.SEMI_BOLD) class EsimUIMici(NavScroller): @@ -225,13 +193,13 @@ def __init__(self, cellular_manager: CellularManager): def show_event(self): super().show_event() - self._update_buttons(re_sort=True) + self._update_buttons() self._cellular_manager.refresh_profiles() def _on_profiles_updated(self, profiles: list[Profile]): self._update_buttons() - def _update_buttons(self, re_sort: bool = False): + def _update_buttons(self): existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} profiles = self._cellular_manager.profiles current_iccids = {p.iccid for p in profiles} @@ -244,17 +212,10 @@ def _update_buttons(self, re_sort: bool = False): btn.set_click_callback(lambda iccid=profile.iccid: self._on_profile_clicked(iccid)) self._scroller.add_widget(btn) - if re_sort: - btn_map = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} - self._scroller.items[:] = sorted( - [btn_map[iccid] for iccid in current_iccids if iccid in btn_map], - key=lambda b: not b.profile.enabled, - ) - else: - self._scroller.items[:] = [ - btn for btn in self._scroller.items - if not isinstance(btn, EsimProfileButton) or btn.profile.iccid in current_iccids - ] + self._scroller.items[:] = [ + btn for btn in self._scroller.items + if not isinstance(btn, EsimProfileButton) or btn.profile.iccid in current_iccids + ] def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) @@ -284,10 +245,5 @@ def _on_profile_clicked(self, iccid: str): if profile is None or profile.enabled: return - btn = next((b for b in self._scroller.items - if isinstance(b, EsimProfileButton) and b.profile.iccid == iccid), None) - if btn is not None: - btn.mark_switching() - self._cellular_manager.switch_profile(iccid) self._move_profile_to_front(iccid, scroll=True) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 2098ab6b101fef..90359f382a54c5 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -82,9 +82,6 @@ def busy(self) -> bool: def is_euicc(self) -> bool | None: return self._is_euicc - def is_comma_profile(self, iccid: str) -> bool: - return any(p.iccid == iccid and p.is_comma for p in self._profiles) - def _ensure_lpa(self) -> LPABase: if self._lpa is None: self._lpa = _get_lpa() @@ -169,8 +166,7 @@ def worker(): with self._lock: lpa = self._ensure_lpa() lpa.switch_profile(iccid) - # optimistic: switch_profile() succeeded, flip enabled flags locally - # without calling list_profiles() (which can briefly return stale state) + # avoid list_profiles(): can briefly return stale enabled state profiles = [replace(p, enabled=(p.iccid == iccid)) for p in self._profiles] self._enqueue(lambda: self._finish(profiles=profiles)) except Exception as e: From 89fc25e29b1a42fc62534c7e0e2c12c73708d06c Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:08:58 -0700 Subject: [PATCH 39/51] esim: show GSM settings on full prime when non-comma profile is active --- .../ui/mici/layouts/settings/network/network_layout.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index d04be8314dbd61..b9b4f32e50ab88 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -101,9 +101,13 @@ def network_metered_callback(value: str): def _update_state(self): super()._update_state() - # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) - self._wifi_manager.set_ipv4_forward(show_cell_settings) + prime = ui_state.prime_state.get_type() + self._wifi_manager.set_ipv4_forward(prime in (PrimeType.NONE, PrimeType.LITE)) + + # full prime hides GSM settings only when the comma profile is the active one + active = next((p for p in self._cellular_manager.profiles if p.enabled), None) + on_comma_profile = active is not None and active.is_comma + show_cell_settings = prime in (PrimeType.NONE, PrimeType.LITE) or not on_comma_profile self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) self._cellular_metered_btn.set_visible(show_cell_settings) From 61533c632466d75e3c809d26e7b1c2b0180316ff Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:10:42 -0700 Subject: [PATCH 40/51] esim: expose CellularManager.active_profile, dedup callers --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 2 +- selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 2 +- selfdrive/ui/mici/layouts/settings/network/network_layout.py | 2 +- system/ui/lib/cellular_manager.py | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 4a4087c174c8e6..ecd29ae1ea32e4 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -39,7 +39,7 @@ def _compute_state(self): value = cm.modem_ip or cm.modem_state.get("mcc_mnc") or "no IP" return f"sim (...{iccid[-4:]})", value, self._cell_icon() - active = next((p for p in cm.profiles if p.enabled), None) + active = cm.active_profile if active is None: return "esim", "loading...", self._cell_none_icon return active.display_name, cm.modem_ip or "obtaining IP...", self._cell_icon() diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 3437ac3f9830b3..3cbecdd3a91e13 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -231,7 +231,7 @@ def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): def _update_state(self): super()._update_state() - active = next((p for p in self._cellular_manager.profiles if p.enabled), None) + active = self._cellular_manager.active_profile self._move_profile_to_front(active.iccid if active else None) def _on_error(self, error: str): diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py index b9b4f32e50ab88..53770ae2d42f23 100644 --- a/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -105,7 +105,7 @@ def _update_state(self): self._wifi_manager.set_ipv4_forward(prime in (PrimeType.NONE, PrimeType.LITE)) # full prime hides GSM settings only when the comma profile is the active one - active = next((p for p in self._cellular_manager.profiles if p.enabled), None) + active = self._cellular_manager.active_profile on_comma_profile = active is not None and active.is_comma show_cell_settings = prime in (PrimeType.NONE, PrimeType.LITE) or not on_comma_profile self._roaming_btn.set_visible(show_cell_settings) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 90359f382a54c5..5663e191b27200 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -74,6 +74,10 @@ def process_callbacks(self): def profiles(self) -> list[Profile]: return self._profiles + @property + def active_profile(self) -> Profile | None: + return next((p for p in self._profiles if p.enabled), None) + @property def busy(self) -> bool: return self._busy From 5fbc37f255f6b083cd8d9e41ed66f0d2f7816c69 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:22:49 -0700 Subject: [PATCH 41/51] esim: apply comma prime defaults on switch (roaming, metered, no APN) --- system/ui/lib/cellular_manager.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 5663e191b27200..959b76faf22c67 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import replace +from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.base import LPABase, Profile @@ -164,12 +165,19 @@ def _finish_poll(self, profiles: list[Profile]): def switch_profile(self, iccid: str): self._busy = True + target = next((p for p in self._profiles if p.iccid == iccid), None) def worker(): try: with self._lock: lpa = self._ensure_lpa() lpa.switch_profile(iccid) + if target is not None and target.is_comma: + # comma prime: roams, metered, no APN override + params = Params() + params.put_bool("GsmRoaming", True) + params.put_bool("GsmMetered", True) + params.put("GsmApn", "") # avoid list_profiles(): can briefly return stale enabled state profiles = [replace(p, enabled=(p.iccid == iccid)) for p in self._profiles] self._enqueue(lambda: self._finish(profiles=profiles)) From 963056b6b022efd19c17bf42032526245b186770 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:25:21 -0700 Subject: [PATCH 42/51] esim: restore re_sort on show_event to avoid first-frame jank --- .../mici/layouts/settings/network/esim_ui.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 3cbecdd3a91e13..515b372f624f71 100644 --- a/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -193,13 +193,13 @@ def __init__(self, cellular_manager: CellularManager): def show_event(self): super().show_event() - self._update_buttons() + self._update_buttons(re_sort=True) self._cellular_manager.refresh_profiles() def _on_profiles_updated(self, profiles: list[Profile]): self._update_buttons() - def _update_buttons(self): + def _update_buttons(self, re_sort: bool = False): existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} profiles = self._cellular_manager.profiles current_iccids = {p.iccid for p in profiles} @@ -212,10 +212,17 @@ def _update_buttons(self): btn.set_click_callback(lambda iccid=profile.iccid: self._on_profile_clicked(iccid)) self._scroller.add_widget(btn) - self._scroller.items[:] = [ - btn for btn in self._scroller.items - if not isinstance(btn, EsimProfileButton) or btn.profile.iccid in current_iccids - ] + if re_sort: + btn_map = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + self._scroller.items[:] = sorted( + [btn_map[iccid] for iccid in current_iccids if iccid in btn_map], + key=lambda b: not b.profile.enabled, + ) + else: + self._scroller.items[:] = [ + btn for btn in self._scroller.items + if not isinstance(btn, EsimProfileButton) or btn.profile.iccid in current_iccids + ] def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) From e2b50242510475d8481996d68cb83497fe7abd96 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:30:28 -0700 Subject: [PATCH 43/51] lpa: apply comma prime defaults inside TiciLPA.switch_profile --- system/hardware/tici/lpa.py | 8 ++++++++ system/ui/lib/cellular_manager.py | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/system/hardware/tici/lpa.py b/system/hardware/tici/lpa.py index 3b8a6c6a84e951..2a03b4c8a3a633 100644 --- a/system/hardware/tici/lpa.py +++ b/system/hardware/tici/lpa.py @@ -18,6 +18,7 @@ from pathlib import Path +from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid from openpilot.system.hardware.base import LPABase, LPAError, LPAProfileNotFoundError, Profile @@ -764,6 +765,7 @@ def _enable_profile(self, iccid: str) -> int: return require_tag(require_tag(response, TAG_ENABLE_PROFILE, "EnableProfileResponse"), TAG_STATUS, "EnableProfile status")[0] def switch_profile(self, iccid: str) -> None: + target = next((p for p in self.list_profiles() if p.iccid == iccid), None) with self._acquire_channel(): code = self._enable_profile(iccid) if code == PROFILE_CAT_BUSY: # stale eUICC transaction, reset and retry @@ -772,6 +774,12 @@ def switch_profile(self, iccid: str) -> None: code = self._enable_profile(iccid) if code not in (PROFILE_OK, PROFILE_NOT_IN_DISABLED_STATE): raise LPAError(f"EnableProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") + if target is not None and target.is_comma: + # comma prime: roams, metered, no APN override + params = Params() + params.put_bool("GsmRoaming", True) + params.put_bool("GsmMetered", True) + params.put("GsmApn", "") def is_euicc(self) -> bool: # +CCHO: -> eUICC; bare ERROR -> applet absent, non-eUICC; +CME ERROR -> applet diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 959b76faf22c67..5663e191b27200 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -3,7 +3,6 @@ from collections.abc import Callable from dataclasses import replace -from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.base import LPABase, Profile @@ -165,19 +164,12 @@ def _finish_poll(self, profiles: list[Profile]): def switch_profile(self, iccid: str): self._busy = True - target = next((p for p in self._profiles if p.iccid == iccid), None) def worker(): try: with self._lock: lpa = self._ensure_lpa() lpa.switch_profile(iccid) - if target is not None and target.is_comma: - # comma prime: roams, metered, no APN override - params = Params() - params.put_bool("GsmRoaming", True) - params.put_bool("GsmMetered", True) - params.put("GsmApn", "") # avoid list_profiles(): can briefly return stale enabled state profiles = [replace(p, enabled=(p.iccid == iccid)) for p in self._profiles] self._enqueue(lambda: self._finish(profiles=profiles)) From d610fb3e1ac8685adaa423f1635f6fe36195280e Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:34:15 -0700 Subject: [PATCH 44/51] lpa: lazy-import Params to break circular import --- system/hardware/tici/lpa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/hardware/tici/lpa.py b/system/hardware/tici/lpa.py index 2a03b4c8a3a633..770052b2b667e6 100644 --- a/system/hardware/tici/lpa.py +++ b/system/hardware/tici/lpa.py @@ -18,7 +18,6 @@ from pathlib import Path -from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid from openpilot.system.hardware.base import LPABase, LPAError, LPAProfileNotFoundError, Profile @@ -776,6 +775,7 @@ def switch_profile(self, iccid: str) -> None: raise LPAError(f"EnableProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") if target is not None and target.is_comma: # comma prime: roams, metered, no APN override + from openpilot.common.params import Params params = Params() params.put_bool("GsmRoaming", True) params.put_bool("GsmMetered", True) From 475ca448ea914ff8f9892b4cbf4525d961d7618b Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:48:03 -0700 Subject: [PATCH 45/51] lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc --- system/hardware/tici/lpa.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/system/hardware/tici/lpa.py b/system/hardware/tici/lpa.py index 770052b2b667e6..56b57b7505f5f1 100644 --- a/system/hardware/tici/lpa.py +++ b/system/hardware/tici/lpa.py @@ -782,13 +782,18 @@ def switch_profile(self, iccid: str) -> None: params.put("GsmApn", "") def is_euicc(self) -> bool: - # +CCHO: -> eUICC; bare ERROR -> applet absent, non-eUICC; +CME ERROR -> applet - # exists but bus busy or modem in transient state, still eUICC. + # +CCHO: -> ISD-R applet present, eUICC. + # bare ERROR -> applet absent, non-eUICC. + # +CME ERROR: 13 (SIM failure) -> non-eUICC; the SIM rejects the AID. + # other +CME ERROR (14 busy, 16 wrong card, etc.) -> applet may exist, treat as eUICC. with self._acquire_lock(): try: lines = self._client.query(f'AT+CCHO="{ISDR_AID}"') except RuntimeError as e: - return "+CME ERROR" in str(e) + msg = str(e) + if "+CME ERROR: 13" in msg: + return False + return "+CME ERROR" in msg for line in lines: if line.startswith("+CCHO:") and (ch := line.split(":", 1)[1].strip()): try: From 0ab7c1811714ff107f10c5d97d241f66705802e8 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:51:50 -0700 Subject: [PATCH 46/51] esim: show 'obtaining IP...' on non-eUICC path while connecting --- selfdrive/ui/mici/layouts/settings/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index ecd29ae1ea32e4..617864ea56f9bd 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -36,7 +36,7 @@ def _compute_state(self): iccid = cm.modem_state.get("iccid") or "" if not iccid: return "sim", "no sim", self._cell_none_icon - value = cm.modem_ip or cm.modem_state.get("mcc_mnc") or "no IP" + value = cm.modem_ip or "obtaining IP..." return f"sim (...{iccid[-4:]})", value, self._cell_icon() active = cm.active_profile From bf6ac2a485caf5f72cdb01b83b86ee50c87612cf Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 22:57:30 -0700 Subject: [PATCH 47/51] Revert "lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc" This reverts commit 475ca448ea914ff8f9892b4cbf4525d961d7618b. --- system/hardware/tici/lpa.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/system/hardware/tici/lpa.py b/system/hardware/tici/lpa.py index 56b57b7505f5f1..770052b2b667e6 100644 --- a/system/hardware/tici/lpa.py +++ b/system/hardware/tici/lpa.py @@ -782,18 +782,13 @@ def switch_profile(self, iccid: str) -> None: params.put("GsmApn", "") def is_euicc(self) -> bool: - # +CCHO: -> ISD-R applet present, eUICC. - # bare ERROR -> applet absent, non-eUICC. - # +CME ERROR: 13 (SIM failure) -> non-eUICC; the SIM rejects the AID. - # other +CME ERROR (14 busy, 16 wrong card, etc.) -> applet may exist, treat as eUICC. + # +CCHO: -> eUICC; bare ERROR -> applet absent, non-eUICC; +CME ERROR -> applet + # exists but bus busy or modem in transient state, still eUICC. with self._acquire_lock(): try: lines = self._client.query(f'AT+CCHO="{ISDR_AID}"') except RuntimeError as e: - msg = str(e) - if "+CME ERROR: 13" in msg: - return False - return "+CME ERROR" in msg + return "+CME ERROR" in str(e) for line in lines: if line.startswith("+CCHO:") and (ch := line.split(":", 1)[1].strip()): try: From 2bd377e1f95c846039cc91555b92bafaa0401712 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 23:06:49 -0700 Subject: [PATCH 48/51] esim: poll profiles every 5s --- system/ui/lib/cellular_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index 5663e191b27200..ba2e90976a43e3 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -7,7 +7,7 @@ from openpilot.system.hardware.base import LPABase, Profile -PROFILE_POLL_INTERVAL_S = 30.0 +PROFILE_POLL_INTERVAL_S = 5.0 def _get_lpa() -> LPABase: From 91280fcacb1d00f93d44a275f2b04f3b8c733f4c Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 23:20:27 -0700 Subject: [PATCH 49/51] lpa: tighten Profile.is_comma to require both Webbing provider and comma BIN --- system/hardware/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/hardware/base.py b/system/hardware/base.py index 46ddfb10b22645..a7d04c786cad8e 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -22,7 +22,7 @@ class Profile: @property def is_comma(self) -> bool: - return self.provider == 'Webbing' or self.iccid.startswith('8985235') + return self.provider == 'Webbing' and self.iccid.startswith('8985235') @property def display_name(self) -> str: From ca676767ecb04f134ceed0742ef0f71060ed50a0 Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 23:21:50 -0700 Subject: [PATCH 50/51] lpa: fall back to '' instead of iccid prefix in display_name --- system/hardware/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/hardware/base.py b/system/hardware/base.py index a7d04c786cad8e..13ad991594878d 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -28,7 +28,7 @@ def is_comma(self) -> bool: def display_name(self) -> str: if self.is_comma: return "comma prime" - name = self.nickname or self.provider or self.iccid[:12] + name = self.nickname or self.provider or "" return f"{name} (...{self.iccid[-4:]})" @dataclass From b8edbd7a68118b1b56058f08ac563b7cfad8a8df Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Wed, 6 May 2026 23:23:36 -0700 Subject: [PATCH 51/51] esim: re-probe is_euicc each poll for runtime SIM swaps --- system/ui/lib/cellular_manager.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/system/ui/lib/cellular_manager.py b/system/ui/lib/cellular_manager.py index ba2e90976a43e3..b64061655587c8 100644 --- a/system/ui/lib/cellular_manager.py +++ b/system/ui/lib/cellular_manager.py @@ -28,8 +28,7 @@ def __init__(self): self._lpa: LPABase | None = None self._profiles: list[Profile] = [] self._busy: bool = False - # None = not yet checked, True/False = cached result. SIM cannot be swapped - # without disassembling the device, so we probe once and keep the result. + # re-probed every poll; SIM may be swapped at runtime on tray-accessible devices self._is_euicc: bool | None = None self._modem_state: dict = {} @@ -67,8 +66,7 @@ def process_callbacks(self): if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= PROFILE_POLL_INTERVAL_S: self._last_profile_poll = time.monotonic() - if self._is_euicc is not False: - self._poll_profiles() + self._poll_profiles() @property def profiles(self) -> list[Profile]: @@ -126,38 +124,32 @@ def worker(): threading.Thread(target=worker, daemon=True).start() def refresh_profiles(self): - if self._is_euicc is False: - return self._poll_profiles() def _poll_profiles(self): self._polling = True - first_check = self._is_euicc is None + prev_is_euicc = self._is_euicc def worker(): try: with self._lock: lpa = self._ensure_lpa() - if self._is_euicc is None: - self._is_euicc = lpa.is_euicc() - cloudlog.info(f"eSIM: is_euicc={self._is_euicc}") - if not self._is_euicc: - self._enqueue(self._stop_polling) - return - profiles = lpa.list_profiles() - if first_check: - cloudlog.info(f"eSIM: got {len(profiles)} profiles") - self._enqueue(lambda: self._finish_poll(profiles)) + is_euicc = lpa.is_euicc() + profiles = lpa.list_profiles() if is_euicc else [] + if is_euicc != prev_is_euicc: + cloudlog.info(f"eSIM: is_euicc={is_euicc}") + self._enqueue(lambda: self._finish_poll(is_euicc, profiles)) except Exception: cloudlog.exception("Failed to poll eSIM profiles") self._enqueue(self._stop_polling) threading.Thread(target=worker, daemon=True).start() - def _finish_poll(self, profiles: list[Profile]): + def _finish_poll(self, is_euicc: bool, profiles: list[Profile]): self._polling = False if self._busy: return + self._is_euicc = is_euicc self._profiles = profiles for cb in self._profiles_updated_cbs: cb(profiles)