StarPilot

This commit is contained in:
firestar5683
2026-03-12 01:49:47 -05:00
parent 0e9ef526f7
commit d0e1db6766
2171 changed files with 590688 additions and 247754 deletions
+77 -23
View File
@@ -51,7 +51,9 @@ if platform.system() == "Darwin":
"""
BURN_IN_MODE = "BURN_IN" in os.environ
BURN_IN_VERTEX_SHADER = GL_VERSION + """
BURN_IN_VERTEX_SHADER = (
GL_VERSION
+ """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
@@ -61,7 +63,10 @@ void main() {
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
)
BURN_IN_FRAGMENT_SHADER = (
GL_VERSION
+ """
in vec2 fragTexCoord;
uniform sampler2D texture0;
out vec4 fragColor;
@@ -77,6 +82,7 @@ void main() {
fragColor = vec4(gradient, sampled.a);
}
"""
)
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
@@ -254,9 +260,11 @@ class GuiApplication:
def init_window(self, title: str, fps: int = _DEFAULT_FPS):
with self._startup_profile_context():
def _close(sig, frame):
self.close()
sys.exit(0)
signal.signal(signal.SIGINT, _close)
atexit.register(self.close)
@@ -280,19 +288,29 @@ class GuiApplication:
if RECORD:
ffmpeg_args = [
'ffmpeg',
'-v', 'warning', # Reduce ffmpeg log spam
'-stats', # Show encoding progress
'-f', 'rawvideo', # Input format
'-pix_fmt', 'rgba', # Input pixel format
'-s', f'{self._width}x{self._height}', # Input resolution
'-r', str(fps), # Input frame rate
'-i', 'pipe:0', # Input from stdin
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v', 'libx264', # Video codec
'-preset', 'ultrafast', # Encoding speed
'-y', # Overwrite existing file
'-f', 'mp4', # Output format
RECORD_OUTPUT, # Output file path
'-v',
'warning', # Reduce ffmpeg log spam
'-stats', # Show encoding progress
'-f',
'rawvideo', # Input format
'-pix_fmt',
'rgba', # Input pixel format
'-s',
f'{self._width}x{self._height}', # Input resolution
'-r',
str(fps), # Input frame rate
'-i',
'pipe:0', # Input from stdin
'-vf',
'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v',
'libx264', # Video codec
'-preset',
'ultrafast', # Encoding speed
'-y', # Overwrite existing file
'-f',
'mp4', # Output format
RECORD_OUTPUT, # Output file path
]
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
@@ -354,8 +372,7 @@ class GuiApplication:
def set_should_render(self, should_render: bool):
self._should_render = should_render
def texture(self, asset_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply=False, keep_aspect_ratio=True):
def texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
@@ -366,11 +383,28 @@ class GuiApplication:
self._textures[cache_key] = texture_obj
return texture_obj
def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply: bool = False, keep_aspect_ratio: bool = True) -> rl.Image:
def starpilot_texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True):
"""Load a texture from the FrogPilot assets folder."""
cache_key = f"starpilot_{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
frogpilot_assets = files("openpilot.frogpilot").joinpath("assets")
with as_file(frogpilot_assets.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
def _load_image_from_path(
self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True
) -> rl.Image:
"""Load and resize an image, storing it for later automatic unloading."""
image = rl.load_image(image_path)
if image.width == 0 or image.height == 0:
return image
if alpha_premultiply:
rl.image_alpha_premultiply(image)
@@ -458,6 +492,7 @@ class GuiApplication:
try:
if self._profile_render_frames > 0:
import cProfile
self._render_profiler = cProfile.Profile()
self._render_profile_start_time = time.monotonic()
self._render_profiler.enable()
@@ -577,8 +612,9 @@ class GuiApplication:
return False
def _load_fonts(self):
for font_weight_file in FontWeight:
with as_file(FONT_DIR) as fspath:
self._ensure_font_atlases()
with as_file(FONT_DIR) as fspath:
for font_weight_file in FontWeight:
fnt_path = fspath / font_weight_file
font = rl.load_font(fnt_path.as_posix())
if font_weight_file != FontWeight.UNIFONT:
@@ -586,6 +622,24 @@ class GuiApplication:
self._fonts[font_weight_file] = font
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
def _ensure_font_atlases(self):
with as_file(FONT_DIR) as fspath:
required_fonts = [fspath / fw.value for fw in FontWeight]
missing_fonts = [font_path.name for font_path in required_fonts if not font_path.exists()]
if not missing_fonts:
return
process_script = fspath / "process.py"
if not process_script.exists():
cloudlog.warning(f"Missing font atlases {missing_fonts}, but no generator found at {process_script}")
return
cloudlog.warning(f"Generating missing font atlases: {missing_fonts}")
try:
subprocess.run([sys.executable, process_script.as_posix()], check=True, cwd=fspath.as_posix())
except Exception:
cloudlog.exception("Failed to generate font atlases")
def _set_styles(self):
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0)
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE)
@@ -709,11 +763,11 @@ class GuiApplication:
green = "\033[92m"
reset = "\033[0m"
print(f"\n{green}Rendered {self._frame} frames in {elapsed_ms:.1f} ms{reset}")
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000/avg_frame_time:.1f} FPS){reset}")
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000 / avg_frame_time:.1f} FPS){reset}")
sys.exit(0)
def _calculate_auto_scale(self) -> float:
# Create temporary window to query monitor info
# Create temporary window to query monitor info
rl.init_window(1, 1, "")
w, h = rl.get_monitor_width(0), rl.get_monitor_height(0)
rl.close_window()
+434 -13
View File
@@ -1,21 +1,47 @@
import atexit
import os
import threading
import time
import uuid
import subprocess
import shutil
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
from jeepney import DBusAddress, new_method_call
from jeepney.bus_messages import MatchRule, message_bus
from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking
from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
try:
from jeepney import DBusAddress, new_method_call
from jeepney.bus_messages import MatchRule, message_bus
from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking
from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
JEEPNY_AVAILABLE = True
JEEPNY_IMPORT_ERROR: Exception | None = None
except Exception as e:
JEEPNY_AVAILABLE = False
JEEPNY_IMPORT_ERROR = e
DBusAddress = Any # type: ignore[assignment]
DBusRouter = Any # type: ignore[assignment]
MatchRule = Any # type: ignore[assignment]
MessageType = Any # type: ignore[assignment]
Properties = Any # type: ignore[assignment]
def new_method_call(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def message_bus(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def open_dbus_connection_blocking(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def open_dbus_connection_threading(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40,
NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40,
NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK,
@@ -36,6 +62,8 @@ TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
SCAN_PERIOD_SECONDS = 5
DESKTOP_FAKE_IP = "192.168.1.42"
TRUE_VALUES = {"1", "true", "yes", "on"}
class SecurityType(IntEnum):
@@ -130,17 +158,42 @@ class WifiManager:
self._networks: list[Network] = [] # a network can be comprised of multiple APs
self._active = True # used to not run when not in settings
self._exit = False
self._fake_networking = False
self._nmcli_networking = False
self._dbus_available = False
allow_desktop_fake = PC and os.getenv("SP_ALLOW_DESKTOP_FAKE_WIFI", "0").lower() in TRUE_VALUES
has_nmcli = shutil.which("nmcli") is not None
# DBus connections
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
except FileNotFoundError:
cloudlog.exception("Failed to connect to system D-Bus")
if not JEEPNY_AVAILABLE:
cloudlog.warning(f"jeepney unavailable: {JEEPNY_IMPORT_ERROR}")
self._router_main = None
self._conn_monitor = None
self._exit = True
self._nm = None
if allow_desktop_fake:
self._fake_networking = True
elif has_nmcli:
self._nmcli_networking = True
else:
cloudlog.error("No networking backend available (jeepney missing, nmcli unavailable)")
else:
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
self._dbus_available = True
except Exception as e:
cloudlog.warning(f"Failed to connect to system D-Bus: {e}")
self._router_main = None
self._conn_monitor = None
self._nm = None
if allow_desktop_fake:
self._fake_networking = True
elif has_nmcli:
self._nmcli_networking = True
else:
cloudlog.error("No networking backend available (D-Bus unavailable, nmcli unavailable)")
# Store wifi device path
self._wifi_device: str | None = None
@@ -154,12 +207,16 @@ class WifiManager:
self._last_network_update: float = 0.0
self._callback_queue: list[Callable] = []
self._fake_connected_ssid: str | None = None
self._fake_known_networks: dict[str, dict[str, Any]] = {}
self._tethering_ssid = "weedle"
if Params is not None:
dongle_id = Params().get("DongleId")
if dongle_id:
self._tethering_ssid += "-" + dongle_id[:4]
if self._fake_networking:
self._init_fake_networking()
# Callbacks
self._need_auth: list[Callable[[str], None]] = []
@@ -176,6 +233,19 @@ class WifiManager:
def _initialize(self):
def worker():
if self._fake_networking:
self._update_networks()
cloudlog.debug("WifiManager initialized in fake networking mode")
return
if self._nmcli_networking:
self._update_networks()
self._scan_thread.start()
cloudlog.debug("WifiManager initialized in nmcli networking mode")
return
if not self._dbus_available:
cloudlog.error("WifiManager unavailable: no active networking backend")
return
self._wait_for_wifi_device()
self._scan_thread.start()
@@ -189,6 +259,51 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _init_fake_networking(self):
primary_ssid = os.getenv("FAKE_WIFI_SSID", "Laptop Wi-Fi")
self._fake_known_networks = {
primary_ssid: {"security": SecurityType.WPA, "saved": True, "strength": 96},
"Coffee Shop": {"security": SecurityType.OPEN, "saved": False, "strength": 68},
"Phone Hotspot": {"security": SecurityType.WPA, "saved": False, "strength": 54},
}
self._fake_connected_ssid = primary_ssid
self._tethering_password = DEFAULT_TETHERING_PASSWORD
self._current_network_metered = MeteredType.NO
self._ipv4_address = DESKTOP_FAKE_IP
def _update_networks_fake(self):
with self._lock:
networks: list[Network] = []
for ssid, values in self._fake_known_networks.items():
networks.append(Network(
ssid=ssid,
strength=int(values["strength"]),
is_connected=ssid == self._fake_connected_ssid,
security_type=values["security"],
is_saved=bool(values["saved"]),
))
if self._fake_connected_ssid == self._tethering_ssid:
if self._tethering_ssid not in self._fake_known_networks:
networks.append(Network(
ssid=self._tethering_ssid,
strength=100,
is_connected=True,
security_type=SecurityType.WPA,
is_saved=True,
))
self._ipv4_address = TETHERING_IP_ADDRESS
self._current_network_metered = MeteredType.UNKNOWN
elif self._fake_connected_ssid is None:
self._ipv4_address = ""
self._current_network_metered = MeteredType.UNKNOWN
else:
self._ipv4_address = DESKTOP_FAKE_IP
networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()))
self._networks = networks
self._enqueue_callbacks(self._networks_updated, self._networks)
def add_callbacks(self, need_auth: Callable[[str], None] | None = None,
activated: Callable[[], None] | None = None,
forgotten: Callable[[], None] | None = None,
@@ -229,12 +344,19 @@ class WifiManager:
def set_active(self, active: bool):
self._active = active
if self._fake_networking or self._nmcli_networking:
if active:
self._update_networks()
return
# Scan immediately if we haven't scanned in a while
if active and time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS / 2:
self._last_network_update = 0.0
def _monitor_state(self):
if not self._dbus_available:
return
rule = MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
@@ -374,6 +496,44 @@ class WifiManager:
self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,)))
def connect_to_network(self, ssid: str, password: str, hidden: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("connect_to_network called with no available networking backend")
return
if self._fake_networking:
def worker():
self._connecting_to_ssid = ssid
security = SecurityType.WPA if password else SecurityType.OPEN
if ssid not in self._fake_known_networks:
self._fake_known_networks[ssid] = {"security": security, "saved": True, "strength": 82}
else:
self._fake_known_networks[ssid]["saved"] = True
self._fake_known_networks[ssid]["security"] = security
self._fake_connected_ssid = ssid
self._connecting_to_ssid = ""
self._update_networks()
self._enqueue_callbacks(self._activated)
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
self._connecting_to_ssid = ssid
cmd = ["nmcli", "device", "wifi", "connect", ssid]
if password:
cmd += ["password", password]
if hidden:
cmd += ["hidden", "yes"]
result = subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._connecting_to_ssid = ""
self._update_networks()
if result.returncode == 0:
self._enqueue_callbacks(self._activated)
else:
self._enqueue_callbacks(self._need_auth, ssid)
threading.Thread(target=worker, daemon=True).start()
return
def worker():
# Clear all connections that may already exist to the network we are connecting to
self._connecting_to_ssid = ssid
@@ -412,6 +572,54 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def forget_connection(self, ssid: str, block: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("forget_connection called with no available networking backend")
return
if self._fake_networking:
def worker():
self._fake_known_networks.pop(ssid, None)
was_connected = self._fake_connected_ssid == ssid
if was_connected:
replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None)
self._fake_connected_ssid = replacement
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if was_connected and self._fake_connected_ssid is None:
self._enqueue_callbacks(self._disconnected)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
try:
conns = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
check=False, capture_output=True, text=True,
)
deleted = False
for line in conns.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) >= 3 and parts[1] == "802-11-wireless" and (parts[0] == ssid or parts[2] == ssid):
subprocess.run(["nmcli", "connection", "delete", "id", parts[0]], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deleted = True
if not deleted:
subprocess.run(["nmcli", "connection", "delete", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
cloudlog.warning(f"nmcli forget failed for {ssid}: {e}")
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
@@ -428,6 +636,44 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def activate_connection(self, ssid: str, block: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("activate_connection called with no available networking backend")
return
if self._fake_networking:
def worker():
if ssid not in self._fake_known_networks and ssid != self._tethering_ssid:
return
self._connecting_to_ssid = ssid
if ssid == self._tethering_ssid and ssid not in self._fake_known_networks:
self._fake_known_networks[ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100}
else:
self._fake_known_networks[ssid]["saved"] = True
self._fake_connected_ssid = ssid
self._connecting_to_ssid = ""
self._update_networks()
self._enqueue_callbacks(self._activated)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
self._connecting_to_ssid = ssid
result = subprocess.run(["nmcli", "connection", "up", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._connecting_to_ssid = ""
self._update_networks()
if result.returncode == 0:
self._enqueue_callbacks(self._activated)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
@@ -445,6 +691,19 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _deactivate_connection(self, ssid: str):
if self._fake_networking:
if self._fake_connected_ssid == ssid:
self._fake_connected_ssid = None
self._update_networks()
self._enqueue_callbacks(self._disconnected)
return
if self._nmcli_networking:
subprocess.run(["nmcli", "connection", "down", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._update_networks()
self._enqueue_callbacks(self._disconnected)
return
for conn_path in self._get_active_connections():
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1]
@@ -464,6 +723,13 @@ class WifiManager:
return False
def set_tethering_password(self, password: str):
if self._fake_networking:
self._tethering_password = password
return
if self._nmcli_networking:
self._tethering_password = password
return
def worker():
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
@@ -490,6 +756,11 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _get_tethering_password(self) -> str:
if self._fake_networking:
return self._tethering_password
if self._nmcli_networking:
return self._tethering_password or DEFAULT_TETHERING_PASSWORD
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
@@ -514,6 +785,24 @@ class WifiManager:
self._ipv4_forward = enabled
def set_tethering_active(self, active: bool):
if self._fake_networking:
def worker():
if active:
if self._tethering_ssid not in self._fake_known_networks:
self._fake_known_networks[self._tethering_ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100}
self._fake_connected_ssid = self._tethering_ssid
else:
if self._fake_connected_ssid == self._tethering_ssid:
replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None)
self._fake_connected_ssid = replacement
self._update_networks()
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
cloudlog.warning("Tethering control is not supported via nmcli fallback backend")
return
def worker():
if active:
self.activate_connection(self._tethering_ssid, block=True)
@@ -528,6 +817,10 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _update_current_network_metered(self) -> None:
if self._nmcli_networking:
self._current_network_metered = MeteredType.UNKNOWN
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
@@ -556,6 +849,15 @@ class WifiManager:
return
def set_current_network_metered(self, metered: MeteredType):
if self._fake_networking:
self._current_network_metered = metered
self._enqueue_callbacks(self._networks_updated, self._networks)
return
if self._nmcli_networking:
self._current_network_metered = metered
self._enqueue_callbacks(self._networks_updated, self._networks)
return
def worker():
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
@@ -583,6 +885,10 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _request_scan(self):
if self._nmcli_networking:
subprocess.run(["nmcli", "device", "wifi", "rescan"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
@@ -594,6 +900,13 @@ class WifiManager:
cloudlog.warning(f"Failed to request scan: {reply}")
def _update_networks(self):
if self._fake_networking:
self._update_networks_fake()
return
if self._nmcli_networking:
self._update_networks_nmcli()
return
with self._lock:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
@@ -640,6 +953,32 @@ class WifiManager:
self._enqueue_callbacks(self._networks_updated, self._networks)
def _update_ipv4_address(self):
if self._nmcli_networking:
self._ipv4_address = ""
try:
status = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device", "status"],
check=False, capture_output=True, text=True,
)
wifi_dev = None
for line in status.stdout.splitlines():
parts = line.split(":")
if len(parts) >= 3 and parts[1] == "wifi" and parts[2].startswith("connected"):
wifi_dev = parts[0]
break
if wifi_dev:
addr = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", wifi_dev],
check=False, capture_output=True, text=True,
)
for row in addr.stdout.splitlines():
if row:
self._ipv4_address = row.split(":", 1)[-1].split("/", 1)[0]
break
except Exception as e:
cloudlog.warning(f"nmcli ipv4 lookup failed: {e}")
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
@@ -666,6 +1005,11 @@ class WifiManager:
def update_gsm_settings(self, roaming: bool, apn: str, metered: bool):
"""Update GSM settings for cellular connection"""
if self._fake_networking:
return
if self._nmcli_networking:
cloudlog.warning("GSM settings update is unavailable in nmcli fallback mode")
return
def worker():
try:
@@ -760,3 +1104,80 @@ class WifiManager:
self._router_main.conn.close()
if self._conn_monitor is not None:
self._conn_monitor.close()
def _parse_nmcli_line(self, line: str) -> list[str]:
out: list[str] = []
cur = []
escaped = False
for ch in line:
if escaped:
cur.append(ch)
escaped = False
elif ch == "\\":
escaped = True
elif ch == ":":
out.append("".join(cur))
cur = []
else:
cur.append(ch)
out.append("".join(cur))
return out
def _update_networks_nmcli(self):
with self._lock:
networks_by_ssid: dict[str, Network] = {}
saved_ssids: set[str] = set()
try:
saved = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
check=False, capture_output=True, text=True,
)
for line in saved.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) >= 3 and parts[1] == "802-11-wireless" and parts[2]:
saved_ssids.add(parts[2])
saved_ssids.add(parts[0])
except Exception as e:
cloudlog.warning(f"nmcli saved networks query failed: {e}")
try:
result = subprocess.run(
["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"],
check=False, capture_output=True, text=True,
)
for line in result.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) < 4:
continue
in_use, ssid, signal, security = parts[:4]
if not ssid:
continue
try:
strength = int(signal or 0)
except ValueError:
strength = 0
security_type = SecurityType.OPEN if security in ("", "--") else SecurityType.WPA
is_connected = in_use.startswith("*")
is_saved = ssid in saved_ssids
existing = networks_by_ssid.get(ssid)
if existing is None or strength > existing.strength or is_connected:
networks_by_ssid[ssid] = Network(
ssid=ssid,
strength=strength,
is_connected=is_connected and is_saved,
security_type=security_type,
is_saved=is_saved,
)
except Exception as e:
cloudlog.warning(f"nmcli scan failed: {e}")
self._networks = sorted(
networks_by_ssid.values(),
key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()),
)
self._update_ipv4_address()
self._current_network_metered = MeteredType.UNKNOWN
self._enqueue_callbacks(self._networks_updated, self._networks)
+14 -1
View File
@@ -7,7 +7,6 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.slider import SmallSlider
@@ -16,6 +15,7 @@ from openpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON"))
class ResetMode(IntEnum):
@@ -56,10 +56,23 @@ class Reset(Widget):
os.system("sudo reboot")
def _backup_ssh_params(self):
if PC:
return
backup_dir = "/cache/reset_backup"
os.system(f"sudo rm -rf {backup_dir}")
os.system(f"sudo mkdir -p {backup_dir}")
for key in ("GithubSshKeys", "SshEnabled"):
os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true")
os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true")
def _do_erase(self):
if PC:
return
self._backup_ssh_params()
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
+15 -5
View File
@@ -30,7 +30,8 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
NetworkType = log.DeviceState.NetworkType
OPENPILOT_URL = "https://openpilot.comma.ai"
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
@@ -77,7 +78,7 @@ class NetworkConnectivityMonitor:
while not self._stop_event.is_set():
if self._should_check():
try:
request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
request = urllib.request.Request(NETWORK_CHECK_URL, method="HEAD")
urllib.request.urlopen(request, timeout=0.5)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
@@ -112,12 +113,16 @@ class StartPage(Widget):
self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224)
self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224)
# Match The Galaxy accent palette while keeping existing setup assets/layout intact.
self._start_bg_tint = rl.Color(94, 200, 200, 255)
self._start_bg_pressed_tint = rl.Color(75, 168, 168, 255)
def _render(self, rect: rl.Rectangle):
draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE)
tint = self._start_bg_pressed_tint if self.is_pressed else self._start_bg_tint
rl.draw_texture(texture, int(draw_x), int(draw_y), tint)
self._title.render(rect)
@@ -127,7 +132,7 @@ class SoftwareSelectionPage(Widget):
use_custom_software_callback: Callable):
super().__init__()
self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback)
self._openpilot_slider = LargerSlider("slide to use\nstarpilot", use_openpilot_callback)
self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
def reset(self):
@@ -620,7 +625,7 @@ class Setup(Widget):
def _network_setup_continue_button_callback(self):
self._network_monitor.stop()
if self.state == SetupState.NETWORK_SETUP:
self.download(OPENPILOT_URL)
self.download(DEFAULT_INSTALLER_URL)
elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
self._set_state(SetupState.CUSTOM_SOFTWARE)
@@ -658,6 +663,8 @@ class Setup(Widget):
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(DEFAULT_INSTALLER_URL)
# give time for installer UI to take over
time.sleep(0.1)
@@ -723,6 +730,9 @@ class Setup(Widget):
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
if os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
+4 -2
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE
import openpilot.system.ui.tici_reset as tici_reset
import openpilot.system.ui.mici_reset as mici_reset
def main():
if gui_app.big_ui():
# Use actual hardware type, not UI scale/env flags, to choose reset UI.
# This prevents mici devices from launching tici reset layouts.
if HARDWARE.get_device_type() in ("tici", "tizi"):
tici_reset.main()
else:
mici_reset.main()
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE
import openpilot.system.ui.tici_setup as tici_setup
import openpilot.system.ui.mici_setup as mici_setup
def main():
if gui_app.big_ui():
if HARDWARE.get_device_type() in ("tici", "tizi"):
tici_setup.main()
else:
mici_setup.main()
+62
View File
@@ -1,7 +1,12 @@
#!/usr/bin/env python3
import os
import pyray as rl
import select
import subprocess
import sys
import time
from collections import deque
from pathlib import Path
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -26,6 +31,8 @@ MARGIN_H = 100
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
RESET_TAP_COUNT = 8
RESET_TAP_WINDOW_S = 4.0
# FrogPilot variables
GREEN = (23, 134, 68, 242)
@@ -35,6 +42,17 @@ def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value)
def get_device_type() -> str:
model_path = Path("/sys/firmware/devicetree/base/model")
if model_path.is_file():
try:
model = model_path.read_text().strip("\x00")
return model.split("comma ")[-1].strip().lower()
except Exception:
pass
return ""
class Spinner(Widget):
def __init__(self):
super().__init__()
@@ -43,6 +61,10 @@ class Spinner(Widget):
self._rotation = 0.0
self._progress: int | None = None
self._wrapped_lines: list[str] = []
self._logo_rect = rl.Rectangle(0, 0, 0, 0)
self._tap_times = deque(maxlen=RESET_TAP_COUNT)
self._launch_reset = False
self._allow_reset_gesture = os.path.isfile("/TICI") and get_device_type() not in ("tici", "tizi")
def set_text(self, text: str) -> None:
if text.isdigit():
@@ -67,6 +89,7 @@ class Spinner(Widget):
center = rl.Vector2(rect.width / 2.0, center_y)
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
self._logo_rect = rl.Rectangle(comma_position.x, comma_position.y, TEXTURE_SIZE, TEXTURE_SIZE)
delta_time = rl.get_frame_time()
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
@@ -90,6 +113,23 @@ class Spinner(Widget):
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
FONT_SIZE, 0.0, rl.WHITE)
def _handle_mouse_release(self, mouse_pos):
if not self._allow_reset_gesture:
return
if not rl.check_collision_point_rec(mouse_pos, self._logo_rect):
return
now = time.monotonic()
self._tap_times.append(now)
if len(self._tap_times) == RESET_TAP_COUNT and (now - self._tap_times[0]) <= RESET_TAP_WINDOW_S:
self._tap_times.clear()
self._launch_reset = True
@property
def should_launch_reset(self) -> bool:
return self._launch_reset
def _read_stdin():
"""Non-blocking read of available lines from stdin."""
@@ -114,6 +154,28 @@ def main():
spinner.set_text(text_list[-1])
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if spinner.should_launch_reset:
reset_script = Path(__file__).with_name("reset.py")
try:
proc = subprocess.Popen(
[sys.executable, str(reset_script)],
cwd=str(reset_script.parent),
close_fds=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
spinner.set_text("Failed to launch reset UI")
continue
# Keep spinner alive if reset process exits immediately (prevents blank screen).
time.sleep(0.2)
if proc.poll() is not None:
spinner.set_text("Reset UI failed to start")
continue
gui_app.request_close()
break
if __name__ == "__main__":
+21 -1
View File
@@ -7,7 +7,6 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
@@ -16,6 +15,7 @@ from openpilot.system.ui.widgets.label import gui_label, gui_text_box
NVME = "/dev/nvme0n1"
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON"))
class ResetMode(IntEnum):
@@ -45,10 +45,23 @@ class Reset(Widget):
def _cancel_callback(self):
self._render_status = False
def _backup_ssh_params(self):
if PC:
return
backup_dir = "/cache/reset_backup"
os.system(f"sudo rm -rf {backup_dir}")
os.system(f"sudo mkdir -p {backup_dir}")
for key in ("GithubSshKeys", "SshEnabled"):
os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true")
os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true")
def _do_erase(self):
if PC:
return
self._backup_ssh_params()
# Best effort to wipe NVME
os.system(f"sudo umount {NVME}")
os.system(f"yes | sudo mkfs.ext4 {NVME}")
@@ -118,6 +131,13 @@ class Reset(Widget):
def main():
# Safety fallback: if this module is launched on a small-UI device,
# hand off to the mici reset implementation to avoid off-screen layout.
if not gui_app.big_ui():
import openpilot.system.ui.mici_reset as mici_reset
mici_reset.main()
return
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
+10 -4
View File
@@ -32,7 +32,8 @@ BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
OPENPILOT_URL = "https://openpilot.comma.ai"
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
@@ -92,7 +93,7 @@ class Setup(Widget):
self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_openpilot_button = ButtonRadio("StarPilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
@@ -189,7 +190,7 @@ class Setup(Widget):
def _network_setup_continue_button_callback(self):
self.stop_network_check_thread.set()
if self._software_selection_openpilot_button.selected:
self.download(OPENPILOT_URL)
self.download(DEFAULT_INSTALLER_URL)
else:
self.state = SetupState.CUSTOM_SOFTWARE
@@ -218,7 +219,7 @@ class Setup(Widget):
while not self.stop_network_check_thread.is_set():
if self.state == SetupState.NETWORK_SETUP:
try:
urllib.request.urlopen(OPENPILOT_URL, timeout=2)
urllib.request.urlopen(NETWORK_CHECK_URL, timeout=2)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
@@ -349,6 +350,8 @@ class Setup(Widget):
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(DEFAULT_INSTALLER_URL)
# give time for installer UI to take over
time.sleep(0.1)
@@ -415,6 +418,9 @@ class Setup(Widget):
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
if os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE
import openpilot.system.ui.tici_updater as tici_updater
import openpilot.system.ui.mici_updater as mici_updater
def main():
if gui_app.big_ui():
if HARDWARE.get_device_type() in ("tici", "tizi"):
tici_updater.main()
else:
mici_updater.main()
+7 -7
View File
@@ -8,8 +8,10 @@ from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLO
try:
from openpilot.selfdrive.ui.ui_state import device
except ImportError:
class Device:
awake = True
device = Device() # type: ignore
@@ -38,8 +40,7 @@ class Widget(abc.ABC):
return self._rect
def set_rect(self, rect: rl.Rectangle) -> None:
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
changed = self._rect.x != rect.x or self._rect.y != rect.y or self._rect.width != rect.width or self._rect.height != rect.height
self._rect = rect
if changed:
self._update_layout_rects()
@@ -79,7 +80,7 @@ class Widget(abc.ABC):
return self._touch_valid_callback() if self._touch_valid_callback else True
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
changed = self._rect.x != x or self._rect.y != y
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
if changed:
self._update_layout_rects()
@@ -228,6 +229,7 @@ class NavWidget(Widget, abc.ABC):
"""
A full screen widget that supports back navigation by swiping down from the top.
"""
BACK_TOUCH_AREA_PERCENTAGE = 0.65
def __init__(self):
@@ -318,12 +320,10 @@ class NavWidget(Widget, abc.ABC):
self._set_up = True
if hasattr(self, '_scroller'):
original_enabled = self._scroller._enabled
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
elif hasattr(self, '_scroll_panel'):
original_enabled = self._scroll_panel.enabled
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
if self._trigger_animate_in:
self._pos_filter.x = self._rect.height
+8 -1
View File
@@ -1,3 +1,4 @@
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
@@ -17,7 +18,7 @@ BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class ConfirmDialog(Widget):
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False):
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False, on_close: Callable[[DialogResult], None] | None = None):
super().__init__()
if cancel_text is None:
cancel_text = tr("Cancel")
@@ -27,6 +28,7 @@ class ConfirmDialog(Widget):
self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._rich = rich
self._dialog_result = DialogResult.NO_ACTION
self._on_close = on_close
self._cancel_text = cancel_text
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
@@ -38,12 +40,15 @@ class ConfirmDialog(Widget):
def reset(self):
self._dialog_result = DialogResult.NO_ACTION
self._on_close = on_close
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
if self._on_close: self._on_close(self._dialog_result)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close: self._on_close(self._dialog_result)
def _render(self, rect: rl.Rectangle):
dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
@@ -74,8 +79,10 @@ class ConfirmDialog(Widget):
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
self._dialog_result = DialogResult.CONFIRM
if self._on_close: self._on_close(self._dialog_result)
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
self._dialog_result = DialogResult.CANCEL
if self._on_close: self._on_close(self._dialog_result)
if self._cancel_text:
self._confirm_button.render(confirm_button)
+118
View File
@@ -0,0 +1,118 @@
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label, FontWeight
from openpilot.system.ui.widgets.keyboard import Keyboard, KeyboardLayout
MARGIN = 50
BUTTON_HEIGHT = 160
OUTER_MARGIN_X = 200
OUTER_MARGIN_Y = 150
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class InputDialog(Widget):
def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None):
super().__init__()
self._title = title
self._text = default_text
self._hint = hint_text
self._on_close = on_close
self._dialog_result = DialogResult.NO_ACTION
self._title_label = Label(title, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255))
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._confirm_button = Button("Confirm", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._keyboard = Keyboard(self._on_key_pressed, self._on_keyboard_done, layout=KeyboardLayout.QWERTY)
self._font = gui_app.font(FontWeight.MEDIUM)
def _on_key_pressed(self, key: str):
if key == "\b":
self._text = self._text[:-1]
else:
self._text += key
def _on_keyboard_done(self):
self._confirm_button_callback()
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
if self._on_close:
self._on_close(self._dialog_result, self._text)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close:
self._on_close(self._dialog_result, self._text)
@property
def result(self) -> DialogResult:
return self._dialog_result
@property
def text(self) -> str:
return self._text
def show_event(self):
super().show_event()
self._dialog_result = DialogResult.NO_ACTION
def _render(self, rect: rl.Rectangle):
# Dim background
rl.draw_rectangle(0, 0, int(rect.width), int(rect.height), rl.Color(0, 0, 0, 200))
# Dialog Box
dialog_rect = rl.Rectangle(
rect.x + OUTER_MARGIN_X,
rect.y + OUTER_MARGIN_Y,
rect.width - 2 * OUTER_MARGIN_X,
rect.height - 2 * OUTER_MARGIN_Y,
)
rl.draw_rectangle_rounded(dialog_rect, 0.05, 10, BACKGROUND_COLOR)
# Title
title_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, dialog_rect.width - 2 * MARGIN, 100)
self._title_label.render(title_rect)
# Text Input Field
input_rect = rl.Rectangle(dialog_rect.x + MARGIN, title_rect.y + title_rect.height + 40, dialog_rect.width - 2 * MARGIN, 120)
rl.draw_rectangle_rounded(input_rect, 0.1, 10, rl.Color(40, 40, 40, 255))
display_text = self._text
text_color = rl.WHITE
if not display_text:
display_text = self._hint
text_color = rl.Color(128, 128, 128, 255)
text_size = rl.measure_text_ex(self._font, display_text, 50, 0)
text_pos = rl.Vector2(input_rect.x + 40, input_rect.y + (input_rect.height - text_size.y) / 2)
rl.draw_text_ex(self._font, display_text, text_pos, 50, 0, text_color)
# Blinking cursor
if (rl.get_time() % 1.0) < 0.5:
cursor_x = text_pos.x + (text_size.x if self._text else 0) + 5
rl.draw_rectangle(int(cursor_x), int(text_pos.y), 4, 50, rl.WHITE)
# Keyboard
keyboard_rect = rl.Rectangle(
dialog_rect.x + MARGIN,
input_rect.y + input_rect.height + 40,
dialog_rect.width - 2 * MARGIN,
400
)
self._keyboard.render(keyboard_rect)
# Buttons
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
cancel_rect = rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT)
confirm_rect = rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT)
self._cancel_button.render(cancel_rect)
self._confirm_button.render(confirm_rect)
File diff suppressed because it is too large Load Diff
+317
View File
@@ -0,0 +1,317 @@
from enum import IntEnum
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.scroller_tici import Scroller
SELECTION_COLOR = rl.Color(70, 91, 234, 255) # #465BEA
HEADER_BG = rl.Color(51, 51, 51, 255) # #333333
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) # #1B1B1B
BORDER_COLOR = rl.Color(80, 80, 80, 255)
MARGIN = 40
OUTER_MARGIN_X = 100
OUTER_MARGIN_Y = 80
BUTTON_HEIGHT = 90
class SortMode(IntEnum):
ALPHABETICAL = 0
DATE_NEWEST = 1
DATE_OLDEST = 2
FAVORITES = 3
class SelectionHeader(Widget):
def __init__(self, text: str, is_expanded: bool, callback: Callable[[str], None]):
super().__init__()
self._text = text
self._is_expanded = is_expanded
self._callback = callback
self._font = gui_app.font(FontWeight.BOLD)
self._font_size = 40
self._pressed = False
self.set_rect(rl.Rectangle(0, 0, 0, 70))
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, rect: rl.Rectangle):
# Header background - Match Qt .series-header {#333333}
bg_color = rl.Color(64, 64, 64, 255) if self._pressed else HEADER_BG
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
# Arrow - Match Qt text-based arrows
arrow = "" if self._is_expanded else ""
arrow_pos = rl.Vector2(rect.x + 30, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, arrow, arrow_pos, self._font_size, 0, rl.WHITE)
# Text - Match Qt padding-left: 80px
text_pos = rl.Vector2(rect.x + 80, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
class SelectionItem(Widget):
def __init__(self, text: str, is_selected: bool, is_favorite: bool, callback: Callable[[str], None], fav_callback: Callable[[str], None] = None):
super().__init__()
self._text = text
self._is_selected = is_selected
self._is_favorite = is_favorite
self._callback = callback
self._fav_callback = fav_callback
self._font = gui_app.font(FontWeight.MEDIUM)
self._font_size = 48
self._pressed = False
self._fav_pressed = False
self.set_rect(rl.Rectangle(0, 0, 0, 110))
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, rect: rl.Rectangle):
# Background for item - Match Qt .model-option:checked {#465BEA}
if self._is_selected:
bg_color = rl.Color(70, 91, 234, 255) # #465BEA
else:
bg_color = rl.Color(90, 90, 90, 255) if self._pressed else rl.Color(79, 79, 79, 255) # #4F4F4F
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
# Selection Border - Match Qt {3px WHITE}
if self._is_selected:
rl.draw_rectangle_rounded_lines_ex(rect, 0.1, 10, 3, rl.WHITE)
# Favorite Star - Left side
star = "" if self._is_favorite else ""
star_pos = rl.Vector2(rect.x + 25, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, star, star_pos, self._font_size + 10, 0, rl.WHITE)
# Text
text_size = rl.measure_text_ex(self._font, self._text, self._font_size, 0)
text_pos = rl.Vector2(rect.x + 90, rect.y + (rect.height - text_size.y) / 2)
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
# Indicator (Dot for selection instead of radio)
if self._is_selected:
circle_center = rl.Vector2(rect.x + rect.width - 50, rect.y + rect.height / 2)
rl.draw_circle_v(circle_center, 12, rl.WHITE)
@property
def _fav_rect(self) -> rl.Rectangle:
return rl.Rectangle(self._rect.x, self._rect.y, 80, self._rect.height)
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._fav_rect):
self._fav_pressed = True
elif rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._fav_pressed and rl.check_collision_point_rec(mouse_pos, self._fav_rect):
if self._fav_callback:
self._fav_callback(self._text)
elif self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
self._fav_pressed = False
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
class SelectionDialog(Widget):
def __init__(self, title: str, options, current_selection: str = "",
on_close: Callable[[DialogResult, str], None] | None = None,
model_released_dates: dict[str, str] | None = None,
model_file_to_name: dict[str, str] | None = None,
user_favorites: list[str] | None = None,
community_favorites: list[str] | None = None,
on_favorite_toggled: Callable[[str], None] | None = None):
super().__init__()
self._title = title
self._options_raw = options
self._selected_value = current_selection
self._on_close = on_close
self._model_released_dates = model_released_dates or {}
self._name_to_file = {v: k for k, v in (model_file_to_name or {}).items()}
self._user_favorites = user_favorites or []
self._community_favorites = community_favorites or []
self._on_favorite_toggled = on_favorite_toggled
self._sort_mode = SortMode.ALPHABETICAL
self._expanded_series = {s: True for s in (options.keys() if isinstance(options, dict) else [])}
self._title_label = Label(title, 60, FontWeight.BOLD, text_color=rl.WHITE)
self._sort_button = Button("Alphabetical", self._toggle_sort, button_style=ButtonStyle.NORMAL)
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._confirm_button = Button("Select", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._scroller = None
self._build_scroller()
def _toggle_sort(self):
self._sort_mode = SortMode((int(self._sort_mode) + 1) % 4)
modes = ["Alphabetical", "Date (Newest)", "Date (Oldest)", "Favorites First"]
self._sort_button.set_text(modes[int(self._sort_mode)])
self._build_scroller()
def _toggle_series(self, series: str):
self._expanded_series[series] = not self._expanded_series.get(series, True)
self._build_scroller()
def _build_scroller(self):
items = []
if isinstance(self._options_raw, dict):
series_keys = list(self._options_raw.keys())
priority_series = ["FrogPilot", "Comma", "Experimental"]
sorted_series_keys = []
for p in priority_series:
if p in series_keys:
sorted_series_keys.append(p)
series_keys.remove(p)
sorted_series_keys.extend(sorted(series_keys))
for series in sorted_series_keys:
models = self._options_raw[series]
if not models:
continue
items.append(SelectionHeader(series, self._expanded_series.get(series, True), self._toggle_series))
if self._expanded_series.get(series, True):
sorted_models = list(models)
if self._sort_mode == SortMode.ALPHABETICAL:
sorted_models.sort()
elif self._sort_mode == SortMode.DATE_NEWEST:
def get_date(m):
key = self._name_to_file.get(m, m)
return self._model_released_dates.get(key, "0000-00-00")
sorted_models.sort(key=get_date, reverse=True)
elif self._sort_mode == SortMode.DATE_OLDEST:
def get_date(m):
key = self._name_to_file.get(m, m)
return self._model_released_dates.get(key, "9999-99-99")
sorted_models.sort(key=get_date)
elif self._sort_mode == SortMode.FAVORITES:
def is_fav(m):
key = self._name_to_file.get(m, m)
return key in self._user_favorites or key in self._community_favorites
sorted_models.sort(key=is_fav, reverse=True)
for model in sorted_models:
key = self._name_to_file.get(model, model)
is_selected = (model == self._selected_value or key == self._selected_value)
is_fav = key in self._user_favorites or key in self._community_favorites
items.append(SelectionItem(
text=model,
is_selected=is_selected,
is_favorite=is_fav,
callback=self._on_item_selected,
fav_callback=self._toggle_favorite
))
else:
for option in self._options_raw:
items.append(SelectionItem(
text=option,
is_selected=(option == self._selected_value),
is_favorite=False,
callback=self._on_item_selected
))
self._scroller = Scroller(items, line_separator=False, spacing=10)
self._scroller.show_event()
def _toggle_favorite(self, model_name: str):
key = self._name_to_file.get(model_name, model_name)
if self._on_favorite_toggled:
self._on_favorite_toggled(key)
# Update local state for instant feedback
if key in self._user_favorites:
self._user_favorites.remove(key)
else:
self._user_favorites.append(key)
self._build_scroller()
def _on_item_selected(self, val):
self._selected_value = val
# Instant visual update
if self._scroller:
for item in self._scroller._items:
if isinstance(item, SelectionItem):
item._is_selected = (item._text == val)
def _cancel_button_callback(self):
if self._on_close:
self._on_close(DialogResult.CANCEL, "")
gui_app.set_modal_overlay(None)
def _confirm_button_callback(self):
if self._on_close:
self._on_close(DialogResult.CONFIRM, self._selected_value)
gui_app.set_modal_overlay(None)
def show_event(self):
super().show_event()
if self._scroller:
self._scroller.show_event()
def _render(self, rect: rl.Rectangle):
# Dim background
rl.draw_rectangle(0, 0, int(rl.get_screen_width()), int(rl.get_screen_height()), rl.Color(0, 0, 0, 180))
# Dialog Box
dialog_rect = rl.Rectangle(
rect.x + OUTER_MARGIN_X,
rect.y + OUTER_MARGIN_Y,
rect.width - 2 * OUTER_MARGIN_X,
rect.height - 2 * OUTER_MARGIN_Y,
)
rl.draw_rectangle_rounded(dialog_rect, 0.04, 12, BACKGROUND_COLOR)
rl.draw_rectangle_rounded_lines_ex(dialog_rect, 0.04, 12, 2, BORDER_COLOR)
# Title
title_width = dialog_rect.width - 2 * MARGIN - 260
self._title_label.render(rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, title_width, 80))
# Sort Button
self._sort_button.render(rl.Rectangle(dialog_rect.x + dialog_rect.width - MARGIN - 240, dialog_rect.y + MARGIN, 240, 80))
# Bottom Buttons
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
self._cancel_button.render(rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT))
self._confirm_button.render(rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT))
# Scrollable Options List
scroller_y = dialog_rect.y + MARGIN + 80 + 20
scroller_rect = rl.Rectangle(
dialog_rect.x + MARGIN,
scroller_y,
dialog_rect.width - 2 * MARGIN,
btn_y - scroller_y - 20
)
self._scroller.render(scroller_rect)
return DialogResult.NO_ACTION