mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-18 12:33:43 +08:00
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 commit1eeba86ec1. * Revert "modem.py is disabled" This reverts commitd238a1ccc4. * 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 '<unnamed>' 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
This commit is contained in:
@@ -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 "<unnamed>"
|
||||
return f"{name} (...{self.iccid[-4:]})"
|
||||
|
||||
|
||||
class LPABase(ABC):
|
||||
@abstractmethod
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user