Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
8e0b703
esim: MICI eSIM profile management UI
greatgitsby Apr 16, 2026
3deab57
esim: align rename button position regardless of delete button
greatgitsby Apr 17, 2026
2ea758b
esim: skip profile UI when SIM is not an eUICC
greatgitsby Apr 19, 2026
ab9eb90
esim: satisfy ruff E731 in action_pressed helper
greatgitsby Apr 19, 2026
eceecbf
esim: get modem info from modem.py state instead of shelling out
greatgitsby May 2, 2026
8604017
esim: simplify switch lifecycle, drop active flag and settle window
greatgitsby May 2, 2026
9235d04
esim: only show 'switching...' on the target profile
greatgitsby May 3, 2026
b55c857
esim: read cell strength directly from HARDWARE
greatgitsby May 3, 2026
4686d54
esim: hide checkmark and dim cell icon during switch; keep rename ava…
greatgitsby May 3, 2026
a181bb2
esim: disable active profile button (rename still clickable as overlay)
greatgitsby May 3, 2026
808ab05
esim: stop rename/delete buttons and labels from flashing during oper…
greatgitsby May 3, 2026
864dee3
esim: show 'comma prime' on network button for comma profile
greatgitsby May 3, 2026
62a2b5e
esim: move display_name and is_comma onto Profile dataclass
greatgitsby May 3, 2026
a68d99f
esim: anchor rename button to rightmost slot to prevent shift
greatgitsby May 3, 2026
1009754
esim: include iccid prefix check in Profile.is_comma
greatgitsby May 3, 2026
554e1d2
esim: drop process_notifications from cellular manager
greatgitsby May 3, 2026
681e1e8
esim: disable network button when SIM isn't an eUICC
greatgitsby May 3, 2026
67b9c0e
esim: rename ESim* classes to Esim*
greatgitsby May 3, 2026
addd6c3
esim: sort imports
greatgitsby May 3, 2026
290c562
esim: default to 'loading...' instead of 'no active profile'
greatgitsby May 3, 2026
2c779fd
esim: rename PROFILE_POLL_INTERVAL to PROFILE_POLL_INTERVAL_S
greatgitsby May 3, 2026
538e7b9
esim: slim EsimNetworkButton
greatgitsby May 3, 2026
caf7f77
esim: use DEFAULT_TEXT_COLOR; load delete dialog texture in __init__
greatgitsby May 3, 2026
f90438d
esim: revert cell-icon index trick, name each NetworkStrength explicitly
greatgitsby May 3, 2026
9d3478a
esim: tighten delete/rename spacing so 'switch' label fits on one line
greatgitsby May 3, 2026
c6edd41
esim: shrink delete/rename buttons by 25%
greatgitsby May 3, 2026
9749b3e
esim: keep rename button at full size, only delete shrinks
greatgitsby May 3, 2026
286994c
Merge branch 'master' into ui-mici-esim-manage
greatgitsby May 5, 2026
f6d1c2e
Revert "disable modem.py for now"
greatgitsby May 5, 2026
0fdfc19
Revert "modem.py is disabled"
greatgitsby May 5, 2026
819a948
Merge remote-tracking branch 'upstream/master' into ui-mici-esim-manage
greatgitsby May 7, 2026
0e2d825
lpa: inline comma iccid prefix
greatgitsby May 7, 2026
574c324
ui/cellular_manager: clear switching state when LPA returns
greatgitsby May 7, 2026
4e6b457
ui/cellular_manager: lock callback queue, drop poll log noise
greatgitsby May 7, 2026
4f90440
Merge remote-tracking branch 'upstream/master' into ui-mici-esim-manage
greatgitsby May 7, 2026
12043a8
esim: sort NetworkStrength/NetworkType imports
greatgitsby May 7, 2026
c769ca5
esim: drop switching_iccid concept
greatgitsby May 7, 2026
b4318a5
esim: show 'switching...' on the clicked profile button
greatgitsby May 7, 2026
cdf5f75
esim: optimistic switch, skip post-switch list_profiles to avoid flicker
greatgitsby May 7, 2026
567406d
esim: only style profile button from local op flags, not global busy
greatgitsby May 7, 2026
6eedc35
esim: remove deleting/switching state, collapse profile button branches
greatgitsby May 7, 2026
89fc25e
esim: show GSM settings on full prime when non-comma profile is active
greatgitsby May 7, 2026
61533c6
esim: expose CellularManager.active_profile, dedup callers
greatgitsby May 7, 2026
5fbc37f
esim: apply comma prime defaults on switch (roaming, metered, no APN)
greatgitsby May 7, 2026
963056b
esim: restore re_sort on show_event to avoid first-frame jank
greatgitsby May 7, 2026
e2b5024
lpa: apply comma prime defaults inside TiciLPA.switch_profile
greatgitsby May 7, 2026
d610fb3
lpa: lazy-import Params to break circular import
greatgitsby May 7, 2026
475ca44
lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc
greatgitsby May 7, 2026
0ab7c18
esim: show 'obtaining IP...' on non-eUICC path while connecting
greatgitsby May 7, 2026
bf6ac2a
Revert "lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc"
greatgitsby May 7, 2026
2bd377e
esim: poll profiles every 5s
greatgitsby May 7, 2026
91280fc
lpa: tighten Profile.is_comma to require both Webbing provider and co…
greatgitsby May 7, 2026
ca67676
lpa: fall back to '<unnamed>' instead of iccid prefix in display_name
greatgitsby May 7, 2026
b8edbd7
esim: re-probe is_euicc each poll for runtime SIM swaps
greatgitsby May 7, 2026
1a1f533
Merge branch 'master' into ui-mici-esim-manage
greatgitsby May 7, 2026
1dc798b
Merge branch 'master' of ssh://github.com/commaai/openpilot into ui-m…
adeebshihadeh Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions selfdrive/ui/mici/layouts/settings/network/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,60 @@
import pyray as rl

from 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.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

NetworkStrength = log.DeviceState.NetworkStrength
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", "loading...", self._cell_none_icon, 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
if cm.is_euicc is False:
iccid = cm.modem_state.get("iccid") or ""
if not iccid:
return "sim", "no sim", self._cell_none_icon
value = cm.modem_ip or "obtaining IP..."
return f"sim (...{iccid[-4:]})", value, self._cell_icon()

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()

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 {
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):
def __init__(self, wifi_manager: WifiManager):
Expand Down
256 changes: 256 additions & 0 deletions selfdrive/ui/mici/layouts/settings/network/esim_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
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 BigConfirmationDialog, BigDialog, BigInputDialog
from openpilot.system.hardware.base import Profile
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))
CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))


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", 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, self.SIZE + self.MARGIN * 2, self.SIZE + self.MARGIN * 2))

def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigConfirmationDialog("slide to delete", self._dialog_trash_txt, 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)


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
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 = 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
def profile(self) -> Profile:
return self._profile

def update_profile(self, profile: Profile):
self._profile = profile
if profile.display_name != self.text:
self.set_text(profile.display_name)

@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_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._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(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
# 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:
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 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
btn_bottom = btn_y + self._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,
))
if self._show_delete_btn:
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,
))

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)

def _update_state(self):
super()._update_state()
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):
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()

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, 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)
33 changes: 25 additions & 8 deletions selfdrive/ui/mici/layouts/settings/network/network_layout.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton
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):
def __init__(self):
def __init__(self, cellular_manager: CellularManager):
super().__init__()

self._wifi_manager = WifiManager()
Expand Down Expand Up @@ -64,6 +66,13 @@ 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")
Expand All @@ -78,6 +87,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,
Expand All @@ -91,25 +101,32 @@ 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 = 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)
self._apn_btn.set_visible(show_cell_settings)
self._cellular_metered_btn.set_visible(show_cell_settings)

def show_event(self):
super().show_event()
self._wifi_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)

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):
Expand Down
Loading
Loading