mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-21 16:23:46 +08:00
StarPilot
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
+1112
-48
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user