From 25d9d41c9007e64de7bb28ff8f27c7148a34b5ab Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:55:28 -0700 Subject: [PATCH] esim: comma four profile management UI (#37844) * esim: MICI eSIM profile management UI * esim: align rename button position regardless of delete button * 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. * esim: satisfy ruff E731 in action_pressed helper * esim: get modem info from modem.py state instead of shelling out * esim: simplify switch lifecycle, drop active flag and settle window * esim: only show 'switching...' on the target profile * esim: read cell strength directly from HARDWARE * esim: hide checkmark and dim cell icon during switch; keep rename available * esim: disable active profile button (rename still clickable as overlay) * esim: stop rename/delete buttons and labels from flashing during operations * esim: show 'comma prime' on network button for comma profile * esim: move display_name and is_comma onto Profile dataclass * esim: anchor rename button to rightmost slot to prevent shift * esim: include iccid prefix check in Profile.is_comma * esim: drop process_notifications from cellular manager * esim: disable network button when SIM isn't an eUICC * esim: rename ESim* classes to Esim* * esim: sort imports * esim: default to 'loading...' instead of 'no active profile' * esim: rename PROFILE_POLL_INTERVAL to PROFILE_POLL_INTERVAL_S * esim: slim EsimNetworkButton * esim: use DEFAULT_TEXT_COLOR; load delete dialog texture in __init__ * esim: revert cell-icon index trick, name each NetworkStrength explicitly * esim: tighten delete/rename spacing so 'switch' label fits on one line * esim: shrink delete/rename buttons by 25% * esim: keep rename button at full size, only delete shrinks * Revert "disable modem.py for now" This reverts commit 1eeba86ec1826add74837dcf40beaf8ce81fb865. * Revert "modem.py is disabled" This reverts commit d238a1ccc4d72a9165d9483ea8bd84d218ad8f00. * lpa: inline comma iccid prefix * ui/cellular_manager: clear switching state when LPA returns * ui/cellular_manager: lock callback queue, drop poll log noise * esim: sort NetworkStrength/NetworkType imports * esim: drop switching_iccid concept * esim: show 'switching...' on the clicked profile button * esim: optimistic switch, skip post-switch list_profiles to avoid flicker * esim: only style profile button from local op flags, not global busy * esim: remove deleting/switching state, collapse profile button branches * esim: show GSM settings on full prime when non-comma profile is active * esim: expose CellularManager.active_profile, dedup callers * esim: apply comma prime defaults on switch (roaming, metered, no APN) * esim: restore re_sort on show_event to avoid first-frame jank * lpa: apply comma prime defaults inside TiciLPA.switch_profile * lpa: lazy-import Params to break circular import * lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc * esim: show 'obtaining IP...' on non-eUICC path while connecting * Revert "lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc" This reverts commit 475ca448ea914ff8f9892b4cbf4525d961d7618b. * esim: poll profiles every 5s * lpa: tighten Profile.is_comma to require both Webbing provider and comma BIN * lpa: fall back to '' instead of iccid prefix in display_name * esim: re-probe is_euicc each poll for runtime SIM swaps * esim_ui: rename EsimUIMici to EsimUI * esim: simplify cellular manager and profile UI - optimistic profile switch at click time so the active button no longer bounces back - unify LPA worker threads, refresh_profiles resets the poll timer - deterministic profile ordering on show - reuse wifi ForgetButton for delete, drop DeleteButton - construct CellularManager inside NetworkLayoutMici - drop comma prime param defaults from LPA.switch_profile (moved to a separate PR) * esim: drop eUICC state change log * ui: gate cellular settings on prime subscription * ui: disable eSIM management for full prime * ui: keep eSIM management disabled for full prime * ui: unify eSIM profile action buttons * ui: tighten eSIM profile action spacing * ui: place rename before delete in eSIM actions * esim: restrict individual profiles and process switch notifications * esim: defer poll after operations and confirm eUICC loss before clearing profiles * ui: allow cellular settings for unregistered and unpaired devices * ui: disable all profile switching for full prime * ui: simplify cellular access to not full prime * ui: make profile poll interval a CellularManager constant * ui: read modem state on the profile poll cadence * ui: scope eSIM profile colors to their class * ui: inline modem state read in cellular polling * ui: rely on hardware modem state fallback --- openpilot/common/esim/base.py | 7 + openpilot/common/hardware/base.py | 3 + openpilot/selfdrive/ui/lib/prime_state.py | 4 + .../mici/layouts/settings/network/__init__.py | 47 ++++ .../mici/layouts/settings/network/esim_ui.py | 230 ++++++++++++++++++ .../settings/network/network_layout.py | 24 +- openpilot/system/ui/lib/cellular_manager.py | 151 ++++++++++++ 7 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py create mode 100644 openpilot/system/ui/lib/cellular_manager.py diff --git a/openpilot/common/esim/base.py b/openpilot/common/esim/base.py index b783a630b2..472b523e2c 100644 --- a/openpilot/common/esim/base.py +++ b/openpilot/common/esim/base.py @@ -21,6 +21,13 @@ class Profile: def is_comma(self) -> bool: return self.provider == 'Webbing' and self.iccid.startswith('8985235') + @property + def display_name(self) -> str: + if self.is_comma: + return "comma prime" + name = self.nickname or self.provider or "" + return f"{name} (...{self.iccid[-4:]})" + class LPABase(ABC): @abstractmethod diff --git a/openpilot/common/hardware/base.py b/openpilot/common/hardware/base.py index 5d8f7770dc..7c8842a0ec 100644 --- a/openpilot/common/hardware/base.py +++ b/openpilot/common/hardware/base.py @@ -145,6 +145,9 @@ class HardwareBase(ABC): def get_modem_temperatures(self): return [] + def get_modem_state(self) -> dict: + return {} + def initialize_hardware(self): pass diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index b57380ac57..1fa87e31df 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -101,6 +101,10 @@ class PrimeState: with self._lock: return bool(self.prime_type > PrimeType.NONE) + def is_full_prime(self) -> bool: + with self._lock: + return self.prime_type > PrimeType.NONE and self.prime_type != PrimeType.LITE + def is_paired(self) -> bool: with self._lock: return self.prime_type > PrimeType.UNPAIRED diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478..4719feecb9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,10 +1,57 @@ import pyray as rl +from openpilot.cereal import log from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.common.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 +NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType + + +class EsimNetworkButton(BigButton): + def __init__(self, cellular_manager: CellularManager): + self._cellular_manager = cellular_manager + self._cell_icons = { + NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), + NetworkStrength.poor: gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47), + NetworkStrength.moderate: gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47), + NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), + NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), + } + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True) + + def _update_state(self): + super()._update_state() + self.set_enabled(self._cellular_manager.is_euicc is not False) + 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 + none_icon = self._cell_icons[NetworkStrength.unknown] + ip = cm.modem_state.get("ip_address") or "obtaining IP..." + if cm.is_euicc is False: + iccid = cm.modem_state.get("iccid") or "" + if not iccid: + return "sim", "no sim", none_icon + return f"sim (...{iccid[-4:]})", ip, self._cell_icon() + + active = cm.active_profile + if active is None: + return "esim", "loading...", none_icon + return active.display_name, ip, self._cell_icon() + + def _cell_icon(self): + # read directly from HARDWARE so it reflects modem state even when wifi is the active connection + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + return self._cell_icons.get(strength, self._cell_icons[NetworkStrength.unknown]) + class WifiNetworkButton(BigButton): def __init__(self, wifi_manager: WifiManager): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py new file mode 100644 index 0000000000..0e115b232f --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -0,0 +1,230 @@ +import pyray as rl +from collections.abc import Callable + +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog +from openpilot.common.esim.base import Profile +from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, 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 + + +class ProfileActionButton(Widget): + SIZE = 68 + MARGIN = 10 + HORIZONTAL_MARGIN = 4 + + def __init__(self, callback: Callable, delete: bool = False): + super().__init__() + self.set_click_callback(callback) + self._delete = delete + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 25, 30) if delete else None + + 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.HORIZONTAL_MARGIN * 2, self.SIZE + self.MARGIN * 2)) + + 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) + color = rl.Color(255, 105, 115, 255) if self._delete else DEFAULT_TEXT_COLOR + if not self.enabled: + color = rl.Color(color.r, color.g, color.b, 90) + if self._trash_txt: + rl.draw_texture_ex(self._trash_txt, (self._rect.x + (self._rect.width - self._trash_txt.width) / 2, + self._rect.y + (self._rect.height - self._trash_txt.height) / 2), 0, 1.0, color) + else: + gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) + + +class EsimProfileButton(BigButton): + SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) + CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + 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, profiles_enabled: Callable[[], bool]): + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + super().__init__(profile.display_name, scroll=True) + + self._profile = profile + + 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 = ProfileActionButton(self._on_delete, delete=True) + self._rename_btn = ProfileActionButton(self._on_rename) if not profile.is_comma else None + self._delete_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy and self._show_delete_btn) + if self._rename_btn: + self._rename_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy) + self.set_enabled(lambda: not self._profile.enabled and self._profiles_enabled() and not self._cellular_manager.busy) + self.update_profile(profile) + + @property + def profile(self) -> Profile: + return self._profile + + def update_profile(self, profile: Profile): + self._profile = profile + active = profile.enabled + self.set_text(profile.display_name) + self.set_value("active" if active else "switch") + + def _update_state(self): + super()._update_state() + self._sub_label.set_color(DEFAULT_TEXT_COLOR if self.enabled else self.SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD if self.enabled else FontWeight.ROMAN) + + @property + def _locked(self) -> bool: + return not self._profile.is_comma and not self._profiles_enabled() + + @property + def _show_delete_btn(self) -> bool: + return not self._profile.enabled and not self._profile.is_comma + + 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_delete(self): + icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) + gui_app.push_widget(BigConfirmationDialog("slide to delete", icon, self._delete_profile, red=True)) + + def _delete_profile(self): + if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + self._cellular_manager.delete_profile(self._profile.iccid) + + def _on_nickname_entered(self, nickname: str): + if not self._locked and not self._cellular_manager.busy: + 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._rename_btn is not None 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(self.SUB_LABEL_DISABLED if self._locked else 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) + + active = self._profile.enabled + + 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._rename_btn is not None 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) + + 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, self.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 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 - (ProfileActionButton.MARGIN - ProfileActionButton.HORIZONTAL_MARGIN) + 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._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, + self._rename_btn.rect.width, self._rename_btn.rect.height, + )) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + 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: + self._rename_btn.set_touch_valid_callback(touch_callback) + + +class EsimUI(NavScroller): + def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + super().__init__() + + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + + self._cellular_manager.on_profiles_updated = self._update_buttons + self._cellular_manager.on_operation_error = self._on_error + + def show_event(self): + super().show_event() + self._update_buttons(re_sort=True) + self._cellular_manager.refresh_profiles() + + def _update_buttons(self, re_sort: bool = False): + existing = {btn.profile.iccid: btn for btn in self._scroller.items} + buttons = [] + for profile in self._cellular_manager.profiles: + btn = existing.get(profile.iccid) + if btn is None: + btn = EsimProfileButton(profile, self._cellular_manager, self._profiles_enabled) + btn.set_click_callback(lambda btn=btn: self._on_profile_clicked(btn.profile)) + self._scroller.add_widget(btn) + else: + btn.update_profile(profile) + buttons.append(btn) + + if re_sort: + self._scroller.items[:] = sorted(buttons, key=lambda b: not b.profile.enabled) + else: + self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + + 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 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() + + active = self._cellular_manager.active_profile + 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, profile: Profile): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + self._cellular_manager.switch_profile(profile.iccid) + self._move_profile_to_front(profile.iccid, scroll=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77d..d991ed5806 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,12 +1,13 @@ -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.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUI 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): @@ -64,6 +65,15 @@ class NetworkLayoutMici(NavScroller): 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 = CellularManager() + self._esim_ui = EsimUI( + self._cellular_manager, + lambda: not ui_state.prime_state.is_full_prime(), + ) + 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 +88,7 @@ class NetworkLayoutMici(NavScroller): # Main scroller ---------------------------------- self._scroller.add_widgets([ self._wifi_button, + self._esim_button, self._network_metered_btn, self._tethering_toggle_btn, self._tethering_password_btn, @@ -91,8 +102,7 @@ class NetworkLayoutMici(NavScroller): 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) + show_cell_settings = not ui_state.prime_state.is_full_prime() self._wifi_manager.set_ipv4_forward(show_cell_settings) self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) @@ -102,14 +112,16 @@ class NetworkLayoutMici(NavScroller): super().show_event() self._wifi_manager.set_active(True) - # 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) 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/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py new file mode 100644 index 0000000000..97f20b5bea --- /dev/null +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -0,0 +1,151 @@ +import time +import threading +from collections.abc import Callable +from dataclasses import replace + +from openpilot.common.hardware import HARDWARE +from openpilot.common.swaglog import cloudlog +from openpilot.common.esim.base import LPABase, Profile +from openpilot.common.esim.esim import execute_and_process_notifications + + +class CellularManager: + PROFILE_POLL_INTERVAL_S = 5.0 + + def __init__(self): + self._lpa: LPABase | None = None + self._profiles: list[Profile] = [] + self._busy: bool = False + # re-probed every poll; SIM may be swapped at runtime on tray-accessible devices + self._is_euicc: bool | None = None + self._modem_state: dict = {} + + self._lock = threading.Lock() + self._callback_lock = threading.Lock() + self._callback_queue: list[Callable] = [] + + self.on_profiles_updated: Callable[[], None] | None = None + self.on_operation_error: Callable[[str], None] | None = None + + self._last_profile_poll: float = 0.0 + self._polling: bool = False + + @property + def modem_state(self) -> dict: + return self._modem_state + + def process_callbacks(self): + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= self.PROFILE_POLL_INTERVAL_S: + self._last_profile_poll = time.monotonic() + self._modem_state = HARDWARE.get_modem_state() + self._poll_profiles() + + @property + 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 + + @property + def is_euicc(self) -> bool | None: + return self._is_euicc + + def _ensure_lpa(self) -> LPABase: + if self._lpa is None: + self._lpa = HARDWARE.get_sim_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 _set_profiles(self, profiles: list[Profile]): + self._profiles = profiles + if self.on_profiles_updated: + self.on_profiles_updated() + + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): + self._busy = False + # defer the next poll a full interval; the eUICC can briefly report stale state after an operation + self._last_profile_poll = time.monotonic() + if profiles is not None: + self._set_profiles(profiles) + if error is not None: + self.refresh_profiles() + if self.on_operation_error: + self.on_operation_error(error) + + def _run_operation(self, fn: Callable[[LPABase], None], error_msg: str, relist: bool = True): + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + fn(lpa) + profiles = lpa.list_profiles() if relist else None + self._enqueue(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception(error_msg) + err = str(e) + self._enqueue(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def refresh_profiles(self): + # next process_callbacks tick polls, respecting busy/polling guards + self._last_profile_poll = 0.0 + + def _poll_profiles(self): + self._polling = True + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + is_euicc = lpa.is_euicc() + profiles = lpa.list_profiles() if is_euicc else [] + 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, is_euicc: bool, profiles: list[Profile]): + self._polling = False + if self._busy: + return + if not is_euicc and self._is_euicc: + # is_euicc() is False on any AT error (e.g. SIM busy during a profile refresh); confirm on the next poll + self._is_euicc = None + return + self._is_euicc = is_euicc + self._set_profiles(profiles) + + def switch_profile(self, iccid: str): + def switch(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.switch_profile(iccid)) + + # optimistic: list_profiles() can briefly return stale enabled state after a switch + self._set_profiles([replace(p, enabled=(p.iccid == iccid)) for p in self._profiles]) + self._run_operation(switch, "Failed to switch eSIM profile", relist=False) + + 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")