diff --git a/openpilot/common/esim/lpa.py b/openpilot/common/esim/lpa.py index fb128c32e1..600869fcb7 100644 --- a/openpilot/common/esim/lpa.py +++ b/openpilot/common/esim/lpa.py @@ -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] diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 0e115b232f..9af7bcde88 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -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 diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index 77dfa3cb97..c023369933 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -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)) diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py index afbea475ca..7cd19c4cb3 100644 --- a/openpilot/system/ui/lib/cellular_manager.py +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -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")