mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-18 10:23:43 +08:00
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user