esim: mici profile download UI (#38813)

* esim: MICI profile download UI

* Show temporary feedback for non-LPA QR codes

* Scan eSIM QR codes every 250 ms

* Shrink eSIM error text and log displayed errors

* Activate downloaded eSIM through the profile tap flow

* Let the eSIM connectivity error wrap naturally

* Release add-profile button feedback immediately

* Check internet before opening the eSIM QR scanner

* Use UI network state and standard connectivity dialog for eSIM

* Drop LPA prime settings change from eSIM download UI

* Scope eSIM UI constants to their owning classes

* Remove unused ot codespell exception

* esim: simplify profile download operation and UI state

* esim: share activation code validation with QR scanner

* esim: match QR scanner video to cabin preview

* esim: show errors in a scrollable text dialog

* esim: require nonblank profile nicknames
This commit is contained in:
Trey Moen
2026-09-08 23:16:17 -07:00
committed by GitHub
parent d70df67366
commit 179718dcb0
4 changed files with 197 additions and 13 deletions
+1 -1
View File
@@ -613,7 +613,7 @@ def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]:
if not activation_code.startswith("LPA:"):
raise ValueError("Invalid activation code format")
parts = activation_code[4:].split("$")
if len(parts) != 3:
if len(parts) != 3 or not all(parts):
raise ValueError("Invalid activation code format")
return parts[1], parts[2]
@@ -1,14 +1,27 @@
import threading
import numpy as np
import pyray as rl
from collections.abc import Callable
from openpilot.cereal import log
from openpilot.cereal.visionipc import VisionStreamType
from openpilot.common import qrcode
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraView
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.nav_widget import NavWidget
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.common.esim.lpa import parse_lpa_activation_code
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
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller
class ProfileActionButton(Widget):
@@ -40,6 +53,110 @@ class ProfileActionButton(Widget):
gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER)
class QRScannerDialog(NavWidget):
SCAN_INTERVAL_S = 0.25
INVALID_CODE_DURATION_S = 1.0
def __init__(self, on_qr_detected: Callable[[str], None]):
super().__init__()
self._on_qr_detected = on_qr_detected
self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN)
self._detected = False
self._last_scan_time = 0.0
self._invalid_code_until = 0.0
self._scan_thread: threading.Thread | None = None
self._scan_result: str | None = None
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
def show_event(self):
super().show_event()
ui_state.params.put_bool("DisableDriverCameraIR", True)
ui_state.params.put_bool("IsDriverViewEnabled", True)
def hide_event(self):
super().hide_event()
ui_state.params.put_bool("IsDriverViewEnabled", False)
ui_state.params.put_bool("DisableDriverCameraIR", False)
def __del__(self):
self._camera_view.close()
def _update_state(self):
super()._update_state()
now = rl.get_time()
if self._detected or not self._camera_view.frame or now < self._invalid_code_until:
return
if self._scan_thread is not None:
if self._scan_thread.is_alive():
return
self._scan_thread = None
data = self._scan_result
if data is not None:
try:
parse_lpa_activation_code(data)
except ValueError:
self._invalid_code_until = now + self.INVALID_CODE_DURATION_S
self._last_scan_time = self._invalid_code_until
else:
self._detected = True
self.dismiss(lambda: self._on_qr_detected(data))
return
if now - self._last_scan_time < self.SCAN_INTERVAL_S:
return
self._last_scan_time = now
frame = self._camera_view.frame
y = np.frombuffer(frame.data, dtype=np.uint8, count=frame.height * frame.stride).reshape(frame.height, frame.stride)
gray = y[:, :frame.width].copy() # the vision buffer is recycled under the scan thread
self._scan_thread = threading.Thread(target=self._scan, args=(gray,), daemon=True)
self._scan_thread.start()
def _scan(self, gray: np.ndarray):
self._scan_result = qrcode.decode(gray)
def _render(self, rect):
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._camera_view._render(rect)
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER)
else:
label_y = rect.y + rect.height * 3 / 4
label_rect = rl.Rectangle(rect.x, label_y + (rect.height - label_y) / 2 - 20, rect.width, 40)
text = "not an LPA code" if rl.get_time() < self._invalid_code_until else "hold QR code to camera"
gui_label(label_rect, text, font_size=32, font_weight=FontWeight.MEDIUM,
alignment=TextAlignment.CENTER,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
rl.end_scissor_mode()
class InstallingProfileDialog(BigDialog):
DOT_STEP = 0.6
def __init__(self):
super().__init__("installing profile", "please wait...")
self._show_time = 0.0
def show_event(self):
super().show_event()
self._nav_bar._alpha = 0.0
self._show_time = rl.get_time()
def _back_enabled(self) -> bool:
return False
def _render(self, _):
t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2)
dots = "." * min(int(t / (self.DOT_STEP / 4)), 3)
self._card.set_value(f"please wait{dots}")
super()._render(_)
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))
@@ -92,7 +209,8 @@ class EsimProfileButton(BigButton):
def _on_rename(self):
current = self._profile.nickname or ""
dlg = BigInputDialog("nickname", default_text=current, minimum_length=0, confirm_callback=self._on_nickname_entered)
dlg = BigInputDialog("nickname", default_text=current, confirm_callback=self._on_nickname_entered,
text_validator=lambda text: bool(text.strip()))
gui_app.push_widget(dlg)
def _on_delete(self):
@@ -171,6 +289,25 @@ class EsimProfileButton(BigButton):
self._rename_btn.set_touch_valid_callback(touch_callback)
class EsimErrorDialog(NavRawScrollPanel):
def __init__(self, error: str):
super().__init__()
self._title = UnifiedLabel("esim error", font_size=64, font_weight=FontWeight.BOLD)
self._error = UnifiedLabel(error, font_size=36, elide=False)
def _render(self, rect: rl.Rectangle):
width = int(rect.width - 80)
title_height = self._title.get_content_height(width)
error_height = self._error.get_content_height(width)
offset = self._scroll_panel.update(rect, title_height + error_height + 100)
y = rect.y + 40 + offset
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
self._title.render(rl.Rectangle(rect.x + 40, y, width, title_height))
self._error.render(rl.Rectangle(rect.x + 40, y + title_height + 20, width, error_height))
rl.end_scissor_mode()
class EsimUI(NavScroller):
def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]):
super().__init__()
@@ -178,7 +315,12 @@ class EsimUI(NavScroller):
self._cellular_manager = cellular_manager
self._profiles_enabled = profiles_enabled
self._cellular_manager.on_profiles_updated = self._update_buttons
self._add_profile_btn = BigButton("add profile", "scan QR code")
self._add_profile_btn.set_click_callback(self._on_add_profile)
self._scroller.add_widget(self._add_profile_btn)
self._installing_dialog: InstallingProfileDialog | None = None
self._cellular_manager.on_profiles_updated = self._on_profiles_updated
self._cellular_manager.on_operation_error = self._on_error
def show_event(self):
@@ -186,8 +328,18 @@ class EsimUI(NavScroller):
self._update_buttons(re_sort=True)
self._cellular_manager.refresh_profiles()
def _on_profiles_updated(self):
if self._installing_dialog:
existing = {btn.profile.iccid for btn in self._scroller.items if isinstance(btn, EsimProfileButton)}
added = [profile for profile in self._cellular_manager.profiles if profile.iccid not in existing]
# Start the normal tap-to-activate flow once the profile list is visible again.
self._installing_dialog.dismiss(lambda: self._on_profile_clicked(added[0]) if len(added) == 1 else None)
self._installing_dialog = None
self._update_buttons()
def _update_buttons(self, re_sort: bool = False):
existing = {btn.profile.iccid: btn for btn in self._scroller.items}
existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)}
buttons = []
for profile in self._cellular_manager.profiles:
btn = existing.get(profile.iccid)
@@ -204,8 +356,11 @@ class EsimUI(NavScroller):
else:
self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons]
self._scroller.items.append(self._add_profile_btn)
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
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)
@@ -216,13 +371,37 @@ class EsimUI(NavScroller):
def _update_state(self):
super()._update_state()
self._add_profile_btn.set_enabled(not self._cellular_manager.busy and self._profiles_enabled())
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)
def _on_add_profile(self):
if self._cellular_manager.busy or not self._profiles_enabled():
return
if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none:
gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again.")))
return
gui_app.push_widget(QRScannerDialog(on_qr_detected=self._on_qr_scanned))
def _on_qr_scanned(self, lpa_code: str):
dlg = BigInputDialog("enter a nickname...", text_validator=lambda text: bool(text.strip()),
confirm_callback=lambda nickname: self._download_profile(lpa_code, nickname))
gui_app.push_widget(dlg)
def _download_profile(self, lpa_code: str, nickname: str):
self._installing_dialog = InstallingProfileDialog()
gui_app.push_widget(self._installing_dialog)
self._cellular_manager.download_profile(lpa_code, nickname.strip())
def _on_error(self, error: str):
cloudlog.error("eSIM error: %s", error)
dlg = EsimErrorDialog(error)
if self._installing_dialog:
self._installing_dialog.dismiss(lambda: gui_app.push_widget(dlg))
self._installing_dialog = None
else:
gui_app.push_widget(dlg)
def _on_profile_clicked(self, profile: Profile):
if self._cellular_manager.busy or not self._profiles_enabled():
return
@@ -76,14 +76,15 @@ class BigInputDialog(BigDialogBase):
default_text: str = "",
minimum_length: int = 1,
confirm_callback: Callable[[str], None] | None = None,
auto_return_to_letters: str = ""):
auto_return_to_letters: str = "",
text_validator: Callable[[str], bool] | None = None):
super().__init__()
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
font_weight=FontWeight.MEDIUM)
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
self._keyboard.set_text(default_text)
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
self._minimum_length = minimum_length
self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text))
self._backspace_held_time: float | None = None
@@ -100,7 +101,8 @@ class BigInputDialog(BigDialogBase):
def confirm_callback_wrapper():
text = self._keyboard.text()
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
if self._text_valid(text):
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
self._confirm_callback = confirm_callback_wrapper
def _update_state(self):
@@ -185,7 +187,7 @@ class BigInputDialog(BigDialogBase):
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
# draw enter button
self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0)
self._enter_img_alpha.update(255 if self._text_valid(text) else 0)
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color)
color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x))
@@ -150,5 +150,8 @@ class CellularManager:
self._run_operation(delete, "Failed to delete eSIM profile")
def download_profile(self, qr: str, nickname: str | None = None):
self._run_operation(lambda lpa: lpa.download_profile(qr, nickname), "Failed to download 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")