openpilot v0.11.1 release

date: 2026-06-04T09:49:56
master commit: c0ab3550eca2e9daf197c46b7e4b24aa9637cf2e
This commit is contained in:
Vehicle Researcher
2026-06-04 09:50:05 -07:00
commit 6adb63b915
3381 changed files with 1044370 additions and 0 deletions
View File
+865
View File
@@ -0,0 +1,865 @@
import atexit
import cffi
import math
import os
import queue
import time
import signal
import sys
import pyray as rl
import threading
import platform
import subprocess
from contextlib import contextmanager
from collections.abc import Callable
from collections import deque
from enum import StrEnum
from pathlib import Path
from typing import NamedTuple
from importlib.resources import as_file, files
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.multilang import multilang
from openpilot.common.realtime import Ratekeeper
_DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20}.get(HARDWARE.get_device_type(), 60)))
FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops
FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning
FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions
MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
MAX_TOUCH_SLOTS = 2
TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out
BIG_UI = os.getenv("BIG", "0") == "1"
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == "1"
SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1"
STRICT_MODE = os.getenv("STRICT_MODE") == "1"
SCALE = float(os.getenv("SCALE", "1.0"))
GRID_SIZE = int(os.getenv("GRID", "0"))
PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0"))
PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output
RECORD = os.getenv("RECORD") == "1"
RECORD_OUTPUT = str(Path(os.getenv("RECORD_OUTPUT", "output")).with_suffix(".mp4"))
RECORD_QUALITY = int(os.getenv("RECORD_QUALITY", "23")) # Dynamic bitrate quality level (CRF); 0 is lossless (bigger size), max is 51, default is 23 for x264
RECORD_BITRATE = os.getenv("RECORD_BITRATE", "") # Target bitrate e.g. "2000k" (overrides RECORD_QUALITY when set)
RECORD_SPEED = int(os.getenv("RECORD_SPEED", "1")) # Speed multiplier
OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offline rendering
GL_VERSION = """
#version 300 es
precision highp float;
"""
if platform.system() == "Darwin":
GL_VERSION = """
#version 330 core
"""
BURN_IN_MODE = "BURN_IN" in os.environ
BURN_IN_VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
out vec2 fragTexCoord;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
out vec4 fragColor;
void main() {
vec4 sampled = texture(texture0, fragTexCoord);
float intensity = sampled.b;
// Map blue intensity to green -> yellow -> red to highlight burn-in risk.
vec3 start = vec3(0.0, 1.0, 0.0);
vec3 middle = vec3(1.0, 1.0, 0.0);
vec3 end = vec3(1.0, 0.0, 0.0);
vec3 gradient = mix(start, middle, clamp(intensity * 2.0, 0.0, 1.0));
gradient = mix(gradient, end, clamp((intensity - 0.5) * 2.0, 0.0, 1.0));
fragColor = vec4(gradient, sampled.a);
}
"""
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles
# The real scales for the fonts below range from 1.212 to 1.266
FONT_SCALE = 1.242 if BIG_UI else 1.16
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
BOLD = "Inter-Bold.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "unifont.fnt"
# Small UI fonts
DISPLAY_REGULAR = "Inter-Regular.fnt"
ROMAN = "Inter-Regular.fnt"
DISPLAY = "Inter-Bold.fnt"
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
if multilang.requires_unifont():
return gui_app.font(FontWeight.UNIFONT)
return font
class MousePos(NamedTuple):
x: float
y: float
class MousePosWithTime(NamedTuple):
x: float
y: float
t: float
class MouseEvent(NamedTuple):
pos: MousePos
slot: int
left_pressed: bool
left_released: bool
left_down: bool
t: float
class MouseState:
def __init__(self, scale: float = 1.0):
self._scale = scale
self._events: deque[MouseEvent] = deque(maxlen=MOUSE_THREAD_RATE) # bound event list
self._prev_mouse_event: list[MouseEvent | None] = [None] * MAX_TOUCH_SLOTS
self._rk = Ratekeeper(MOUSE_THREAD_RATE, print_delay_threshold=None)
self._lock = threading.Lock()
self._exit_event = threading.Event()
self._thread = None
def get_events(self) -> list[MouseEvent]:
with self._lock:
events = list(self._events)
self._events.clear()
return events
def start(self):
self._exit_event.clear()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._run_thread, daemon=True)
self._thread.start()
def stop(self):
self._exit_event.set()
if self._thread is not None and self._thread.is_alive():
self._thread.join()
def _run_thread(self):
while not self._exit_event.is_set():
rl.poll_input_events()
self._handle_mouse_event()
self._rk.keep_time()
def _handle_mouse_event(self):
# TODO: read touch events from evdev directly to get real kernel timestamps.
# Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll
# velocity oscillate (alternating high/low). Real timestamps would also let us
# detect swipe-stop-lift via event gaps instead of the fragile decel heuristic.
for slot in range(MAX_TOUCH_SLOTS):
mouse_pos = rl.get_touch_position(slot)
x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x
y = mouse_pos.y / self._scale if self._scale != 1.0 else mouse_pos.y
ev = MouseEvent(
MousePos(x, y),
slot,
rl.is_mouse_button_pressed(slot), # noqa: TID251
rl.is_mouse_button_released(slot), # noqa: TID251
rl.is_mouse_button_down(slot),
time.monotonic(),
)
# Only add changes
prev = self._prev_mouse_event[slot]
if prev is None or ev[:-1] != prev[:-1]:
with self._lock:
self._events.append(ev)
self._prev_mouse_event[slot] = ev
class GuiApplication:
def __init__(self, width: int | None = None, height: int | None = None):
self._set_log_callback()
self._fonts: dict[FontWeight, rl.Font] = {}
self._width = width if width is not None else GuiApplication._default_width()
self._height = height if height is not None else GuiApplication._default_height()
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
else:
self._scale = SCALE
# Scale, then ensure dimensions are even
self._scaled_width = int(self._width * self._scale)
self._scaled_height = int(self._height * self._scale)
self._scaled_width += self._scaled_width % 2
self._scaled_height += self._scaled_height % 2
self._render_texture: rl.RenderTexture | None = None
self._burn_in_shader: rl.Shader | None = None
self._ffmpeg_proc: subprocess.Popen | None = None
self._ffmpeg_queue: queue.Queue | None = None
self._ffmpeg_thread: threading.Thread | None = None
self._ffmpeg_stop_event: threading.Event | None = None
self._textures: dict[str, rl.Texture] = {}
self._target_fps: int = _DEFAULT_FPS
self._last_fps_log_time: float = time.monotonic()
self._frame = 0
self._window_close_requested = False
self._nav_stack: list[object] = []
self._nav_stack_ticks: list[Callable[[], None]] = []
self._nav_stack_widgets_to_render = 1 if self.big_ui() else 2
self._mouse = MouseState(self._scale)
self._mouse_events: list[MouseEvent] = []
self._last_mouse_event: MouseEvent = MouseEvent(MousePos(0, 0), 0, False, False, False, 0.0)
self._should_render = True
# Debug variables
self._mouse_history: deque[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE)
self._show_touches = SHOW_TOUCHES
self._show_fps = SHOW_FPS
self._grid_size = GRID_SIZE
self._profile_render_frames = PROFILE_RENDER
self._render_profiler = None
self._render_profile_start_time = None
@property
def frame(self):
return self._frame
def set_show_touches(self, show: bool):
self._show_touches = show
def set_show_fps(self, show: bool):
self._show_fps = show
@property
def show_touches(self) -> bool:
return self._show_touches
@property
def target_fps(self):
return self._target_fps
def request_close(self):
self._window_close_requested = True
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)
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
if ENABLE_VSYNC:
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
rl.set_config_flags(flags)
rl.init_window(self._scaled_width, self._scaled_height, title)
needs_render_texture = self._scale != 1.0 or BURN_IN_MODE or RECORD
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
if needs_render_texture:
self._render_texture = rl.load_render_texture(self._scaled_width, self._scaled_height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
if RECORD:
output_fps = fps * RECORD_SPEED
ffmpeg_args = [
'ffmpeg',
'-v', 'warning', # Reduce ffmpeg log spam
'-nostats', # Suppress encoding progress
'-f', 'rawvideo', # Input format
'-pix_fmt', 'rgba', # Input pixel format
'-s', f'{self._scaled_width}x{self._scaled_height}', # Input resolution
'-r', str(fps), # Input frame rate
'-i', 'pipe:0', # Input from stdin
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p
'-r', str(output_fps), # Output frame rate (for speed multiplier)
'-c:v', 'libx264',
'-preset', 'veryfast',
'-crf', str(RECORD_QUALITY)
]
if RECORD_BITRATE:
# NOTE: custom bitrate overrides crf setting
ffmpeg_args += ['-b:v', RECORD_BITRATE, '-maxrate', RECORD_BITRATE, '-bufsize', RECORD_BITRATE]
ffmpeg_args += [
'-y', # Overwrite existing file
'-f', 'mp4', # Output format
RECORD_OUTPUT, # Output file path
]
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
self._ffmpeg_queue = queue.Queue(maxsize=60) # Buffer up to 60 frames
self._ffmpeg_stop_event = threading.Event()
self._ffmpeg_thread = threading.Thread(target=self._ffmpeg_writer_thread, daemon=True)
self._ffmpeg_thread.start()
# four display runs slightly faster than 60 FPS, let it dictate rate so we don't drift and drop frames
vblank_control = HARDWARE.get_device_type() == 'mici'
rl.set_target_fps(0 if OFFSCREEN or vblank_control else fps)
self._target_fps = fps
self._set_styles()
self._load_fonts()
self._patch_text_functions()
self._patch_scissor_mode()
if BURN_IN_MODE and self._burn_in_shader is None:
self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER)
if not PC:
self._mouse.start()
@contextmanager
def _startup_profile_context(self):
if "PROFILE_STARTUP" not in os.environ:
yield
return
import cProfile
import io
import pstats
profiler = cProfile.Profile()
start_time = time.monotonic()
profiler.enable()
# do the init
yield
profiler.disable()
elapsed_ms = (time.monotonic() - start_time) * 1e3
stats_stream = io.StringIO()
pstats.Stats(profiler, stream=stats_stream).sort_stats("cumtime").print_stats(25)
print("\n=== Startup profile ===")
print(stats_stream.getvalue().rstrip())
green = "\033[92m"
reset = "\033[0m"
print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}")
sys.exit(0)
def _ffmpeg_writer_thread(self):
"""Background thread that writes frames to ffmpeg."""
while True:
try:
data = self._ffmpeg_queue.get(timeout=1.0)
if data is None: # Sentinel to stop
break
self._ffmpeg_proc.stdin.write(data)
except queue.Empty:
if self._ffmpeg_stop_event.is_set():
break
continue
except Exception:
break
def push_widget(self, widget: object):
if widget in self._nav_stack:
cloudlog.warning("Widget already in stack, cannot push again!")
return
# disable previous widget to prevent input processing
if len(self._nav_stack) > 0:
prev_widget = self._nav_stack[-1]
# TODO: change these to touch_valid
prev_widget.set_enabled(False)
self._nav_stack.append(widget)
widget.show_event()
widget.set_enabled(True)
def pop_widget(self, idx: int | None = None):
# Pops widget instantly without animation
if len(self._nav_stack) < 2:
cloudlog.warning("At least one widget should remain on the stack, ignoring pop!")
return
idx_to_pop = len(self._nav_stack) - 1 if idx is None else idx
if idx_to_pop <= 0 or idx_to_pop >= len(self._nav_stack):
cloudlog.warning(f"Invalid index {idx_to_pop} to pop, ignoring!")
return
# only re-enable previous widget if popping top widget
if idx_to_pop == len(self._nav_stack) - 1:
prev_widget = self._nav_stack[idx_to_pop - 1]
prev_widget.set_enabled(True)
widget = self._nav_stack.pop(idx_to_pop)
widget.hide_event()
def pop_widgets_to(self, widget: object, callback: Callable[[], None] | None = None, instant: bool = False):
# Pops middle widgets instantly without animation then dismisses top, animated out if NavWidget
if widget not in self._nav_stack:
cloudlog.warning("Widget not in stack, cannot pop to it!")
return
# Nothing to pop, ensure we still run callback
top_widget = self._nav_stack[-1]
if top_widget == widget:
if callback:
callback()
return
# instantly pop widgets in between, then dismiss top widget for animation
while len(self._nav_stack) > 1 and self._nav_stack[-2] != widget:
self.pop_widget(len(self._nav_stack) - 2)
if not instant:
top_widget.dismiss(callback)
else:
self.pop_widget()
def get_active_widget(self):
if len(self._nav_stack) > 0:
return self._nav_stack[-1]
return None
def widget_in_stack(self, widget: object) -> bool:
return widget in self._nav_stack
def add_nav_stack_tick(self, tick_function: Callable[[], None]):
if tick_function not in self._nav_stack_ticks:
self._nav_stack_ticks.append(tick_function)
def remove_nav_stack_tick(self, tick_function: Callable[[], None]):
if tick_function in self._nav_stack_ticks:
self._nav_stack_ticks.remove(tick_function)
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, flip_x: bool = False) -> rl.Texture:
if width is not None:
width = round(width)
if height is not None:
height = round(height)
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}_{keep_aspect_ratio}_{flip_x}"
if cache_key in self._textures:
return self._textures[cache_key]
with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio, flip_x)
texture_obj = self._load_texture_from_image(image_obj)
# Set logical size so widget layout math stays at 1x coordinates
if self._scale != 1.0 and width is not None and height is not None:
texture_obj.width = width
texture_obj.height = height
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, flip_x: bool = False) -> rl.Image:
"""Load and resize an image, storing it for later automatic unloading."""
image = rl.load_image(image_path)
if alpha_premultiply:
rl.image_alpha_premultiply(image)
# Scale up load size for sharper rendering, capped at source resolution
if self._scale != 1.0 and width is not None and height is not None:
width = min(int(width * self._scale), image.width)
height = min(int(height * self._scale), image.height)
if width is not None and height is not None:
same_dimensions = image.width == width and image.height == height
# Resize with aspect ratio preservation if requested
if not same_dimensions:
if keep_aspect_ratio:
orig_width = image.width
orig_height = image.height
scale_width = width / orig_width
scale_height = height / orig_height
# Calculate new dimensions
scale = min(scale_width, scale_height)
new_width = int(orig_width * scale)
new_height = int(orig_height * scale)
rl.image_resize(image, new_width, new_height)
else:
rl.image_resize(image, width, height)
else:
assert keep_aspect_ratio, "Cannot resize without specifying width and height"
if flip_x:
rl.image_flip_horizontal(image)
return image
def _load_texture_from_image(self, image: rl.Image) -> rl.Texture:
"""Send image to GPU and unload original image."""
texture = rl.load_texture_from_image(image)
# Set texture filtering to smooth the result
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
# prevent artifacts from wrapping coordinates
rl.set_texture_wrap(texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP)
rl.unload_image(image)
return texture
def close_ffmpeg(self):
if self._ffmpeg_thread is not None:
# Signal thread to stop, send sentinel, then wait for it to drain
self._ffmpeg_stop_event.set()
self._ffmpeg_queue.put(None)
self._ffmpeg_thread.join(timeout=30)
if self._ffmpeg_proc is not None:
self._ffmpeg_proc.stdin.flush()
self._ffmpeg_proc.stdin.close()
try:
self._ffmpeg_proc.wait(timeout=30)
except subprocess.TimeoutExpired:
self._ffmpeg_proc.terminate()
self._ffmpeg_proc.wait()
def close(self):
if not rl.is_window_ready():
return
for texture in self._textures.values():
rl.unload_texture(texture)
self._textures = {}
for font in self._fonts.values():
rl.unload_font(font)
self._fonts = {}
if self._render_texture is not None:
rl.unload_render_texture(self._render_texture)
self._render_texture = None
if self._burn_in_shader:
rl.unload_shader(self._burn_in_shader)
self._burn_in_shader = None
if not PC:
self._mouse.stop()
self.close_ffmpeg()
rl.close_window()
@property
def mouse_events(self) -> list[MouseEvent]:
return self._mouse_events
@property
def last_mouse_event(self) -> MouseEvent:
return self._last_mouse_event
def render(self):
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()
while not (self._window_close_requested or rl.window_should_close()):
frame_start = time.monotonic()
if PC:
# Thread is not used on PC, need to manually add mouse events
self._mouse._handle_mouse_event()
# Store all mouse events for the current frame
self._mouse_events = self._mouse.get_events()
if len(self._mouse_events) > 0:
self._last_mouse_event = self._mouse_events[-1]
# Skip rendering when screen is off
if not self._should_render:
if PC:
rl.poll_input_events()
time.sleep(1 / self._target_fps)
yield False, 0.0, 0.0
continue
if self._render_texture:
rl.begin_texture_mode(self._render_texture)
rl.clear_background(rl.BLACK)
else:
rl.begin_drawing()
rl.clear_background(rl.BLACK)
if self._scale != 1.0:
rl.rl_push_matrix()
rl.rl_scalef(self._scale, self._scale, 1.0)
# Allow a Widget to still run a function regardless of the stack depth
for tick in self._nav_stack_ticks:
tick()
# Only render top widgets
for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]:
widget.render(rl.Rectangle(0, 0, self.width, self.height))
frame_time = rl.get_frame_time()
cpu_time = time.monotonic() - frame_start
yield True, frame_time, cpu_time
if self._scale != 1.0:
rl.rl_pop_matrix()
if self._render_texture:
rl.end_texture_mode()
rl.begin_drawing()
rl.clear_background(rl.BLACK)
src_rect = rl.Rectangle(0, 0, float(self._scaled_width), -float(self._scaled_height))
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
texture = self._render_texture.texture
if texture:
if BURN_IN_MODE and self._burn_in_shader:
rl.begin_shader_mode(self._burn_in_shader)
rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
else:
rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
if self._show_fps:
rl.draw_fps(10, 10)
if self._show_touches:
self._draw_touch_points()
if self._grid_size > 0:
self._draw_grid()
rl.end_drawing()
if RECORD:
image = rl.load_image_from_texture(self._render_texture.texture)
data_size = image.width * image.height * 4
data = bytes(rl.ffi.buffer(image.data, data_size))
self._ffmpeg_queue.put(data) # Async write via background thread
rl.unload_image(image)
self._monitor_fps()
self._frame += 1
if self._profile_render_frames > 0 and self._frame >= self._profile_render_frames:
self._output_render_profile()
except KeyboardInterrupt:
pass
def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font:
return self._fonts[font_weight]
@property
def width(self):
return self._width
@property
def height(self):
return self._height
def _load_fonts(self):
for font_weight_file in FontWeight:
with as_file(FONT_DIR) as fspath:
fnt_path = fspath / font_weight_file
font = rl.load_font(fnt_path.as_posix())
if font_weight_file != FontWeight.UNIFONT:
rl.gen_texture_mipmaps(font.texture)
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
self._fonts[font_weight_file] = font
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
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)
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK))
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR))
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255)))
def _patch_text_functions(self):
# Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt
if not hasattr(rl, "_orig_draw_text_ex"):
rl._orig_draw_text_ex = rl.draw_text_ex
def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint):
font = font_fallback(font)
return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint)
rl.draw_text_ex = _draw_text_ex_scaled
def _patch_scissor_mode(self):
if self._scale == 1.0:
return
if not hasattr(rl, "_orig_begin_scissor_mode"):
rl._orig_begin_scissor_mode = rl.begin_scissor_mode
def _begin_scissor_mode_scaled(x, y, width, height):
return rl._orig_begin_scissor_mode(
int(x * self._scale), int(y * self._scale),
int(math.ceil(width * self._scale)), int(math.ceil(height * self._scale)))
rl.begin_scissor_mode = _begin_scissor_mode_scaled
def _set_log_callback(self):
ffi_libc = cffi.FFI()
ffi_libc.cdef("""
int vasprintf(char **strp, const char *fmt, void *ap);
void free(void *ptr);
""")
libc = ffi_libc.dlopen(None)
@rl.ffi.callback("void(int, char *, void *)")
def trace_log_callback(log_level, text, args):
try:
text_addr = int(rl.ffi.cast("uintptr_t", text))
args_addr = int(rl.ffi.cast("uintptr_t", args))
text_libc = ffi_libc.cast("char *", text_addr)
args_libc = ffi_libc.cast("void *", args_addr)
out = ffi_libc.new("char **")
if libc.vasprintf(out, text_libc, args_libc) >= 0 and out[0] != ffi_libc.NULL:
text_str = ffi_libc.string(out[0]).decode("utf-8", "replace")
libc.free(out[0])
else:
text_str = rl.ffi.string(text).decode("utf-8", "replace")
except Exception as e:
text_str = f"[Log decode error: {e}]"
if log_level == rl.TraceLogLevel.LOG_ERROR:
cloudlog.error(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_WARNING:
cloudlog.warning(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_INFO:
cloudlog.info(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_DEBUG:
cloudlog.debug(f"raylib: {text_str}")
else:
cloudlog.error(f"raylib: Unknown level {log_level}: {text_str}")
# ensure we get all the logs forwarded to us
rl.set_trace_log_level(rl.TraceLogLevel.LOG_DEBUG)
# Store callback reference
self._trace_log_callback = trace_log_callback
rl.set_trace_log_callback(self._trace_log_callback)
def _monitor_fps(self):
fps = rl.get_fps()
# Log FPS drop below threshold at regular intervals
if fps < self._target_fps * FPS_DROP_THRESHOLD:
current_time = time.monotonic()
if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL:
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}")
self._last_fps_log_time = current_time
# Strict mode: terminate UI if FPS drops too much
if STRICT_MODE and fps < self._target_fps * FPS_CRITICAL_THRESHOLD:
cloudlog.error(f"FPS dropped critically below {fps}. Shutting down UI.")
self.close_ffmpeg()
os._exit(1)
def _draw_touch_points(self):
current_time = time.monotonic()
for mouse_event in self._mouse_events:
if mouse_event.left_pressed:
self._mouse_history.clear()
self._mouse_history.append(MousePosWithTime(mouse_event.pos.x * self._scale, mouse_event.pos.y * self._scale, current_time))
# Remove old touch points that exceed the timeout
while self._mouse_history and (current_time - self._mouse_history[0].t) > TOUCH_HISTORY_TIMEOUT:
self._mouse_history.popleft()
if self._mouse_history:
mouse_pos = self._mouse_history[-1]
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 15, rl.RED)
for idx, mouse_pos in enumerate(self._mouse_history):
perc = idx / len(self._mouse_history)
color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255)
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color)
def _draw_grid(self):
grid_color = rl.Color(60, 60, 60, 255)
# Draw vertical lines
x = 0
while x <= self._scaled_width:
rl.draw_line(x, 0, x, self._scaled_height, grid_color)
x += self._grid_size
# Draw horizontal lines
y = 0
while y <= self._scaled_height:
rl.draw_line(0, y, self._scaled_width, y, grid_color)
y += self._grid_size
def _output_render_profile(self):
import io
import pstats
self._render_profiler.disable()
elapsed_ms = (time.monotonic() - self._render_profile_start_time) * 1e3
avg_frame_time = elapsed_ms / self._frame if self._frame > 0 else 0
stats_stream = io.StringIO()
pstats.Stats(self._render_profiler, stream=stats_stream).sort_stats("cumtime").print_stats(PROFILE_STATS)
print("\n=== Render loop profile ===")
print(stats_stream.getvalue().rstrip())
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}")
sys.exit(0)
def _calculate_auto_scale(self) -> float:
# 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()
if w == 0 or h == 0 or (w >= self._width and h >= self._height):
return 1.0
# Apply 0.95 factor for window decorations/taskbar margin
return max(0.3, min(w / self._width, h / self._height) * 0.95)
@staticmethod
def _default_width() -> int:
return 2160 if GuiApplication.big_ui() else 536
@staticmethod
def _default_height() -> int:
return 1080 if GuiApplication.big_ui() else 240
@staticmethod
def big_ui() -> bool:
return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI
gui_app = GuiApplication()
+181
View File
@@ -0,0 +1,181 @@
import os
import cffi
from dataclasses import dataclass
from typing import Any
from openpilot.common.swaglog import cloudlog
# EGL constants
EGL_LINUX_DMA_BUF_EXT = 0x3270
EGL_WIDTH = 0x3057
EGL_HEIGHT = 0x3056
EGL_LINUX_DRM_FOURCC_EXT = 0x3271
EGL_DMA_BUF_PLANE0_FD_EXT = 0x3272
EGL_DMA_BUF_PLANE0_OFFSET_EXT = 0x3273
EGL_DMA_BUF_PLANE0_PITCH_EXT = 0x3274
EGL_DMA_BUF_PLANE1_FD_EXT = 0x3275
EGL_DMA_BUF_PLANE1_OFFSET_EXT = 0x3276
EGL_DMA_BUF_PLANE1_PITCH_EXT = 0x3277
EGL_NONE = 0x3038
GL_TEXTURE0 = 0x84C0
GL_TEXTURE_EXTERNAL_OES = 0x8D65
# DRM Format for NV12
DRM_FORMAT_NV12 = 842094158
@dataclass
class EGLImage:
"""Container for EGL image and associated resources"""
egl_image: Any
fd: int
@dataclass
class EGLState:
"""Container for all EGL-related state"""
initialized: bool = False
ffi: Any = None
egl_lib: Any = None
gles_lib: Any = None
# EGL display connection - shared across all users
display: Any = None
# Constants
NO_CONTEXT: Any = None
NO_DISPLAY: Any = None
NO_IMAGE_KHR: Any = None
# Function pointers
get_current_display: Any = None
create_image_khr: Any = None
destroy_image_khr: Any = None
image_target_texture: Any = None
get_error: Any = None
bind_texture: Any = None
active_texture: Any = None
# Create a single instance of the state
_egl = EGLState()
def init_egl() -> bool:
"""Initialize EGL and load necessary functions"""
global _egl
# Don't re-initialize if already done
if _egl.initialized:
return True
try:
_egl.ffi = cffi.FFI()
_egl.ffi.cdef("""
typedef int EGLint;
typedef unsigned int EGLBoolean;
typedef unsigned int EGLenum;
typedef unsigned int GLenum;
typedef void *EGLContext;
typedef void *EGLDisplay;
typedef void *EGLClientBuffer;
typedef void *EGLImageKHR;
typedef void *GLeglImageOES;
EGLDisplay eglGetCurrentDisplay(void);
EGLint eglGetError(void);
EGLImageKHR eglCreateImageKHR(EGLDisplay dpy, EGLContext ctx,
EGLenum target, EGLClientBuffer buffer,
const EGLint *attrib_list);
EGLBoolean eglDestroyImageKHR(EGLDisplay dpy, EGLImageKHR image);
void glEGLImageTargetTexture2DOES(GLenum target, GLeglImageOES image);
void glBindTexture(GLenum target, unsigned int texture);
void glActiveTexture(GLenum texture);
""")
# Load libraries
_egl.egl_lib = _egl.ffi.dlopen("libEGL.so")
_egl.gles_lib = _egl.ffi.dlopen("libGLESv2.so")
# Cast NULL pointers
_egl.NO_CONTEXT = _egl.ffi.cast("void *", 0)
_egl.NO_DISPLAY = _egl.ffi.cast("void *", 0)
_egl.NO_IMAGE_KHR = _egl.ffi.cast("void *", 0)
# Bind functions
_egl.get_current_display = _egl.egl_lib.eglGetCurrentDisplay
_egl.create_image_khr = _egl.egl_lib.eglCreateImageKHR
_egl.destroy_image_khr = _egl.egl_lib.eglDestroyImageKHR
_egl.image_target_texture = _egl.gles_lib.glEGLImageTargetTexture2DOES
_egl.get_error = _egl.egl_lib.eglGetError
_egl.bind_texture = _egl.gles_lib.glBindTexture
_egl.active_texture = _egl.gles_lib.glActiveTexture
# Initialize EGL display once here
_egl.display = _egl.get_current_display()
if _egl.display == _egl.NO_DISPLAY:
raise RuntimeError("Failed to get EGL display")
_egl.initialized = True
return True
except Exception as e:
cloudlog.exception(f"EGL initialization failed: {e}")
_egl.initialized = False
return False
def create_egl_image(width: int, height: int, stride: int, fd: int, uv_offset: int) -> EGLImage | None:
assert _egl.initialized, "EGL not initialized"
try:
# Duplicate fd since EGL needs it
dup_fd = os.dup(fd)
except OSError as e:
cloudlog.exception(f"Failed to duplicate frame fd when creating EGL image: {e}")
return None
# Create image attributes for EGL
img_attrs = [
EGL_WIDTH, width,
EGL_HEIGHT, height,
EGL_LINUX_DRM_FOURCC_EXT, DRM_FORMAT_NV12,
EGL_DMA_BUF_PLANE0_FD_EXT, dup_fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0,
EGL_DMA_BUF_PLANE0_PITCH_EXT, stride,
EGL_DMA_BUF_PLANE1_FD_EXT, dup_fd,
EGL_DMA_BUF_PLANE1_OFFSET_EXT, uv_offset,
EGL_DMA_BUF_PLANE1_PITCH_EXT, stride,
EGL_NONE
]
attr_array = _egl.ffi.new("int[]", img_attrs)
egl_image = _egl.create_image_khr(_egl.display, _egl.NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, _egl.ffi.NULL, attr_array)
if egl_image == _egl.NO_IMAGE_KHR:
cloudlog.error(f"Failed to create EGL image: {_egl.get_error()}")
os.close(dup_fd)
return None
return EGLImage(egl_image=egl_image, fd=dup_fd)
def destroy_egl_image(egl_image: EGLImage) -> None:
assert _egl.initialized, "EGL not initialized"
_egl.destroy_image_khr(_egl.display, egl_image.egl_image)
# Close the duplicated fd we created in create_egl_image()
# We need to handle OSError since the fd might already be closed
try:
os.close(egl_image.fd)
except OSError:
pass
def bind_egl_image_to_texture(texture_id: int, egl_image: EGLImage) -> None:
assert _egl.initialized, "EGL not initialized"
_egl.active_texture(GL_TEXTURE0)
_egl.bind_texture(GL_TEXTURE_EXTERNAL_OES, texture_id)
_egl.image_target_texture(GL_TEXTURE_EXTERNAL_OES, egl_image.egl_image)
+55
View File
@@ -0,0 +1,55 @@
import io
import re
import functools
from importlib.resources import as_file
from PIL import Image, ImageDraw, ImageFont
import pyray as rl
from openpilot.system.ui.lib.application import FONT_DIR
_cache: dict[str, rl.Texture] = {}
EMOJI_REGEX = re.compile(
"""[\U0001F600-\U0001F64F
\U0001F300-\U0001F5FF
\U0001F680-\U0001F6FF
\U0001F1E0-\U0001F1FF
\U00002700-\U000027BF
\U0001F900-\U0001F9FF
\U00002600-\U000026FF
\U00002300-\U000023FF
\U00002B00-\U00002BFF
\U0001FA70-\U0001FAFF
\U0001F700-\U0001F77F
\u2640-\u2642
\u2600-\u2B55
\u200d
\u23cf
\u23e9
\u231a
\ufe0f
\u3030
]+""".replace("\n", ""),
flags=re.UNICODE
)
@functools.cache
def _load_emoji_font() -> ImageFont.FreeTypeFont:
with as_file(FONT_DIR.joinpath("NotoColorEmoji.ttf")) as font_path:
return ImageFont.truetype(io.BytesIO(font_path.read_bytes()), 109)
def find_emoji(text):
return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)]
def emoji_tex(emoji):
if emoji not in _cache:
img = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((0, 0), emoji, font=_load_emoji_font(), embedded_color=True)
with io.BytesIO() as buffer:
img.save(buffer, format="PNG")
l = buffer.tell()
buffer.seek(0)
_cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l))
return _cache[emoji]
+213
View File
@@ -0,0 +1,213 @@
from importlib.resources import files
import json
import os
import re
from openpilot.common.basedir import BASEDIR
from openpilot.common.swaglog import cloudlog
try:
from openpilot.common.params import Params
except ImportError:
Params = None
SYSTEM_UI_DIR = os.path.join(BASEDIR, "system", "ui")
UI_DIR = files("openpilot.selfdrive.ui")
TRANSLATIONS_DIR = UI_DIR.joinpath("translations")
LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json")
UNIFONT_LANGUAGES = [
"th",
"zh-CHT",
"zh-CHS",
"ko",
"ja",
]
# Plural form selectors for supported languages
PLURAL_SELECTORS = {
'en': lambda n: 0 if n == 1 else 1,
'de': lambda n: 0 if n == 1 else 1,
'fr': lambda n: 0 if n <= 1 else 1,
'pt-BR': lambda n: 0 if n <= 1 else 1,
'es': lambda n: 0 if n == 1 else 1,
'tr': lambda n: 0 if n == 1 else 1,
'uk': lambda n: 0 if n % 10 == 1 and n % 100 != 11 else (1 if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14 else 2),
'th': lambda n: 0,
'zh-CHT': lambda n: 0,
'zh-CHS': lambda n: 0,
'ko': lambda n: 0,
'ja': lambda n: 0,
}
def _parse_quoted(s: str) -> str:
"""Parse a PO-format quoted string."""
s = s.strip()
if not (s.startswith('"') and s.endswith('"')):
raise ValueError(f"Expected quoted string: {s!r}")
s = s[1:-1]
result: list[str] = []
i = 0
while i < len(s):
if s[i] == '\\' and i + 1 < len(s):
c = s[i + 1]
if c == 'n':
result.append('\n')
elif c == 't':
result.append('\t')
elif c == '"':
result.append('"')
elif c == '\\':
result.append('\\')
else:
result.append(s[i:i + 2])
i += 2
else:
result.append(s[i])
i += 1
return ''.join(result)
def load_translations(path) -> tuple[dict[str, str], dict[str, list[str]]]:
"""Parse a .po file and return (translations, plurals) dicts.
translations: msgid -> msgstr
plurals: msgid -> [msgstr[0], msgstr[1], ...]
"""
with path.open(encoding='utf-8') as f:
lines = f.readlines()
translations: dict[str, str] = {}
plurals: dict[str, list[str]] = {}
# Parser state
msgid = msgid_plural = msgstr = ""
msgstr_plurals: dict[int, str] = {}
field: str | None = None
plural_idx = 0
def finish():
nonlocal msgid, msgid_plural, msgstr, msgstr_plurals, field
if msgid: # skip header (empty msgid)
if msgid_plural:
max_idx = max(msgstr_plurals.keys()) if msgstr_plurals else 0
plurals[msgid] = [msgstr_plurals.get(i, '') for i in range(max_idx + 1)]
else:
translations[msgid] = msgstr
msgid = msgid_plural = msgstr = ""
msgstr_plurals = {}
field = None
for raw in lines:
line = raw.strip()
if not line:
finish()
continue
if line.startswith('#'):
continue
if line.startswith('msgid_plural '):
msgid_plural = _parse_quoted(line[len('msgid_plural '):])
field = 'msgid_plural'
continue
if line.startswith('msgid '):
msgid = _parse_quoted(line[len('msgid '):])
field = 'msgid'
continue
m = re.match(r'msgstr\[(\d+)]\s+(.*)', line)
if m:
plural_idx = int(m.group(1))
msgstr_plurals[plural_idx] = _parse_quoted(m.group(2))
field = 'msgstr_plural'
continue
if line.startswith('msgstr '):
msgstr = _parse_quoted(line[len('msgstr '):])
field = 'msgstr'
continue
if line.startswith('"'):
val = _parse_quoted(line)
if field == 'msgid':
msgid += val
elif field == 'msgid_plural':
msgid_plural += val
elif field == 'msgstr':
msgstr += val
elif field == 'msgstr_plural':
msgstr_plurals[plural_idx] += val
finish()
return translations, plurals
class Multilang:
def __init__(self):
self._params = Params() if Params is not None else None
self._language: str = "en"
self.languages: dict[str, str] = {}
self.codes: dict[str, str] = {}
self._translations: dict[str, str] = {}
self._plurals: dict[str, list[str]] = {}
self._plural_selector = PLURAL_SELECTORS.get('en', lambda n: 0)
self._load_languages()
@property
def language(self) -> str:
return self._language
def requires_unifont(self) -> bool:
"""Certain languages require unifont to render their glyphs."""
return self._language in UNIFONT_LANGUAGES
def setup(self):
try:
po_path = TRANSLATIONS_DIR.joinpath(f'app_{self._language}.po')
self._translations, self._plurals = load_translations(po_path)
self._plural_selector = PLURAL_SELECTORS.get(self._language, lambda n: 0)
cloudlog.debug(f"Loaded translations for language: {self._language}")
except FileNotFoundError:
cloudlog.error(f"No translation file found for language: {self._language}, using default.")
self._translations = {}
self._plurals = {}
def change_language(self, language_code: str) -> None:
self._params.put("LanguageSetting", language_code, block=True)
self._language = language_code
self.setup()
def tr(self, text: str) -> str:
return self._translations.get(text, text) or text
def trn(self, singular: str, plural: str, n: int) -> str:
if singular in self._plurals:
idx = self._plural_selector(n)
forms = self._plurals[singular]
if idx < len(forms) and forms[idx]:
return forms[idx]
return singular if n == 1 else plural
def _load_languages(self):
with LANGUAGES_FILE.open(encoding='utf-8') as f:
self.languages = json.load(f)
self.codes = {v: k for k, v in self.languages.items()}
if self._params is not None:
lang = str(self._params.get("LanguageSetting")).removeprefix("main_")
if lang in self.codes:
self._language = lang
multilang = Multilang()
multilang.setup()
tr, trn = multilang.tr, multilang.trn
# no-op marker for static strings translated later
def tr_noop(s: str) -> str:
return s
+64
View File
@@ -0,0 +1,64 @@
from enum import IntEnum
# NetworkManager device states
class NMDeviceState(IntEnum):
# https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceState
UNKNOWN = 0
UNMANAGED = 10
UNAVAILABLE = 20
DISCONNECTED = 30
PREPARE = 40
CONFIG = 50
NEED_AUTH = 60
IP_CONFIG = 70
IP_CHECK = 80
SECONDARIES = 90
ACTIVATED = 100
DEACTIVATING = 110
FAILED = 120
class NMDeviceStateReason(IntEnum):
# https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceStateReason
NONE = 0
UNKNOWN = 1
IP_CONFIG_UNAVAILABLE = 5
NO_SECRETS = 7
SUPPLICANT_DISCONNECT = 8
SUPPLICANT_TIMEOUT = 11
CONNECTION_REMOVED = 38
USER_REQUESTED = 39
SSID_NOT_FOUND = 53
NEW_ACTIVATION = 60
# NetworkManager constants
NM = "org.freedesktop.NetworkManager"
NM_PATH = '/org/freedesktop/NetworkManager'
NM_IFACE = 'org.freedesktop.NetworkManager'
NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint'
NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings'
NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings'
NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection'
NM_ACTIVE_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Connection.Active'
NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless'
NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'
NM_DEVICE_IFACE = 'org.freedesktop.NetworkManager.Device'
NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
NM_DEVICE_TYPE_WIFI = 2
NM_DEVICE_TYPE_MODEM = 8
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags
NM_802_11_AP_FLAGS_NONE = 0x0
NM_802_11_AP_FLAGS_PRIVACY = 0x1
NM_802_11_AP_FLAGS_WPS = 0x2
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags
NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001
NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002
NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010
NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020
NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100
NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200
+134
View File
@@ -0,0 +1,134 @@
import math
import pyray as rl
from enum import IntEnum
from openpilot.system.ui.lib.application import gui_app, MouseEvent
from openpilot.common.filter_simple import FirstOrderFilter
# Scroll constants for smooth scrolling behavior
MOUSE_WHEEL_SCROLL_SPEED = 50
BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps
MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click
DEBUG = False
class ScrollState(IntEnum):
IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia
DRAGGING_CONTENT = 1 # User is actively dragging the content
class GuiScrollPanel:
def __init__(self):
self._scroll_state: ScrollState = ScrollState.IDLE
self._last_mouse_y: float = 0.0
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection
self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self._last_drag_time: float = 0.0
def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float:
for mouse_event in gui_app.mouse_events:
if mouse_event.slot == 0:
self._handle_mouse_event(mouse_event, bounds, content)
self._update_state(bounds, content)
return float(self._offset_filter_y.x)
def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle):
if DEBUG:
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED)
# Handle mouse wheel
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED
max_scroll_distance = max(0, content.height - bounds.height)
if self._scroll_state == ScrollState.IDLE:
above_bounds, below_bounds = self._check_bounds(bounds, content)
# Decay velocity when idle
if abs(self._velocity_filter_y.x) > MIN_VELOCITY:
# Faster decay if bouncing back from out of bounds
friction = math.exp(-BOUNCE_RETURN_RATE * 1 / gui_app.target_fps)
self._velocity_filter_y.x *= friction ** 2 if (above_bounds or below_bounds) else friction
else:
self._velocity_filter_y.x = 0.0
if above_bounds or below_bounds:
if above_bounds:
self._offset_filter_y.update(0)
else:
self._offset_filter_y.update(-max_scroll_distance)
self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
# Mouse not moving, decay velocity
if not len(gui_app.mouse_events):
self._velocity_filter_y.update(0.0)
# Settle to exact bounds
if abs(self._offset_filter_y.x) < 1e-2:
self._offset_filter_y.x = 0.0
elif abs(self._offset_filter_y.x + max_scroll_distance) < 1e-2:
self._offset_filter_y.x = -max_scroll_distance
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle):
if self._scroll_state == ScrollState.IDLE:
if rl.check_collision_point_rec(mouse_event.pos, bounds):
if mouse_event.left_pressed:
self._start_mouse_y = mouse_event.pos.y
# Interrupt scrolling with new drag
# TODO: stop scrolling with any tap, need to fix is_touch_valid
if abs(self._velocity_filter_y.x) > MIN_VELOCITY_FOR_CLICKING:
self._scroll_state = ScrollState.DRAGGING_CONTENT
# Start velocity at initial measurement for more immediate response
self._velocity_filter_y.initialized = False
if mouse_event.left_down:
if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD:
self._scroll_state = ScrollState.DRAGGING_CONTENT
# Start velocity at initial measurement for more immediate response
self._velocity_filter_y.initialized = False
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
if mouse_event.left_released:
self._scroll_state = ScrollState.IDLE
else:
delta_y = mouse_event.pos.y - self._last_mouse_y
above_bounds, below_bounds = self._check_bounds(bounds, content)
# Rubber banding effect when out of bands
if above_bounds or below_bounds:
delta_y /= 3
self._offset_filter_y.x += delta_y
# Track velocity for inertia
dt = mouse_event.t - self._last_drag_time
if dt > 0:
drag_velocity = delta_y / dt
self._velocity_filter_y.update(drag_velocity)
# TODO: just store last mouse event!
self._last_drag_time = mouse_event.t
self._last_mouse_y = mouse_event.pos.y
def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]:
max_scroll_distance = max(0, content.height - bounds.height)
above_bounds = self._offset_filter_y.x > 0
below_bounds = self._offset_filter_y.x < -max_scroll_distance
return above_bounds, below_bounds
def is_touch_valid(self):
return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING
def set_offset(self, position: float) -> None:
self._offset_filter_y.x = position
self._velocity_filter_y.x = 0.0
self._scroll_state = ScrollState.IDLE
@property
def offset(self) -> float:
return float(self._offset_filter_y.x)
+268
View File
@@ -0,0 +1,268 @@
import os
import math
import pyray as rl
from collections.abc import Callable
from enum import Enum
from typing import cast
from openpilot.system.ui.lib.application import gui_app, MouseEvent
from openpilot.system.hardware import TICI
from collections import deque
MIN_VELOCITY = 10 # px/s, changes from auto scroll to steady state
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
MIN_DRAG_PIXELS = 12
AUTO_SCROLL_TC_SNAP = 0.025
AUTO_SCROLL_TC = 0.18
BOUNCE_RETURN_RATE = 10.0
SNAP_RATE = 6.3 # matches previous Scroller snapping. exp rate of approach to snap target, 1/s
REJECT_DECELERATION_FACTOR = 3
MAX_SPEED = 10000.0 # px/s
DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
# Weights older (steadier) velocity samples more heavily on release.
# Finger-lift samples are noisy; trusting earlier samples gives consistent fling velocity.
# Reverse-engineered from iOS UIScrollView (tuned at 120Hz touch) by Flutter team:
# https://github.com/flutter/flutter/pull/60501
# 3 samples ≈ 25ms at 120Hz (iOS) / ~21ms at 140Hz (comma). Scale if touch rate changes.
def weighted_velocity(buffer: deque) -> float:
if len(buffer) >= 3:
return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05
elif len(buffer) == 2:
return buffer[-2] * 0.7 + buffer[-1] * 0.3
elif len(buffer) == 1:
return buffer[-1]
return 0.0
# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
class ScrollState(Enum):
STEADY = 0
PRESSED = 1
MANUAL_SCROLL = 2
AUTO_SCROLL = 3
class GuiScrollPanel2:
def __init__(self, horizontal: bool = True) -> None:
self._horizontal = horizontal
self._state = ScrollState.STEADY
self._offset: rl.Vector2 = rl.Vector2(0, 0)
self._initial_click_event: MouseEvent | None = None
self._previous_mouse_event: MouseEvent | None = None
self._velocity = 0.0 # pixels per second
self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6)
self._enabled: bool | Callable[[], bool] = True
def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._enabled = enabled
@property
def enabled(self) -> bool:
return self._enabled() if callable(self._enabled) else self._enabled
def update(self, bounds: rl.Rectangle, content_size: float, snap_target: float | None = None) -> float:
if DEBUG:
print('Old state:', self._state)
bounds_size = bounds.width if self._horizontal else bounds.height
for mouse_event in gui_app.mouse_events:
self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
self._previous_mouse_event = mouse_event
self._update_state(bounds_size, content_size, snap_target)
if DEBUG:
print('Velocity:', self._velocity)
print('Offset X:', self._offset.x, 'Y:', self._offset.y)
print('New state:', self._state)
print()
return self.get_offset()
def _get_offset_bounds(self, bounds_size: float, content_size: float) -> tuple[float, float]:
"""Returns (max_offset, min_offset) for the given bounds and content size."""
return 0.0, min(0.0, bounds_size - content_size)
def _update_state(self, bounds_size: float, content_size: float, snap_target: float | None) -> None:
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
if self._state == ScrollState.STEADY:
# if we find ourselves out of bounds, scroll back in (from external layout dimension changes, etc.)
if self.get_offset() > max_offset or self.get_offset() < min_offset:
self._state = ScrollState.AUTO_SCROLL
elif self._state == ScrollState.AUTO_SCROLL:
# simple exponential return if out of bounds
# out of bounds is handled by snapping, so skip if set
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if out_of_bounds and snap_target is None:
target = max_offset if self.get_offset() > max_offset else min_offset
dt = rl.get_frame_time() or 1e-6
factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt)
dist = target - self.get_offset()
self.set_offset(self.get_offset() + dist * factor) # ease toward the edge
self._velocity *= (1.0 - factor) # damp any leftover fling
# Steady once we are close enough to the target
if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY:
self.set_offset(target)
self._velocity = 0.0
self._state = ScrollState.STEADY
elif abs(self._velocity) < MIN_VELOCITY:
self._velocity = 0.0
self._state = ScrollState.STEADY
# Update the offset based on the current velocity
dt = rl.get_frame_time()
self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity
# fast decay in snap mode so velocity yields to the snap pull instead of fighting it
auto_scroll_tc = AUTO_SCROLL_TC_SNAP if snap_target is not None else AUTO_SCROLL_TC
alpha = 1 - (dt / (auto_scroll_tc + dt))
self._velocity *= alpha
# Ease toward snap target when not in user control. Composes with velocity coast above:
# high velocity dominates initially, snap dominates as velocity decays.
if snap_target is not None and self._state not in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL):
snap_target = max(min_offset, min(max_offset, snap_target))
dist = snap_target - self.get_offset()
if abs(dist) < 1: # finished snap
self.set_offset(snap_target)
else:
dt = rl.get_frame_time() or 1e-6
factor = 1.0 - math.exp(-SNAP_RATE * dt)
self.set_offset(self.get_offset() + dist * factor)
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
# simple exponential return if out of bounds
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if DEBUG:
print('Mouse event:', mouse_event)
mouse_pos = self._get_mouse_pos(mouse_event)
if not self.enabled:
# Reset state if not enabled
self._state = ScrollState.STEADY
self._velocity = 0.0
self._velocity_buffer.clear()
elif self._state == ScrollState.STEADY:
if rl.check_collision_point_rec(mouse_event.pos, bounds):
if mouse_event.left_pressed:
self._state = ScrollState.PRESSED
self._initial_click_event = mouse_event
elif self._state == ScrollState.PRESSED:
initial_click_pos = self._get_mouse_pos(cast(MouseEvent, self._initial_click_event))
diff = abs(mouse_pos - initial_click_pos)
if mouse_event.left_released:
# Special handling for down and up clicks across two frames
# TODO: not sure what that means or if it's accurate anymore
if out_of_bounds:
self._state = ScrollState.AUTO_SCROLL
elif diff <= MIN_DRAG_PIXELS:
self._state = ScrollState.STEADY
else:
self._state = ScrollState.MANUAL_SCROLL
elif diff > MIN_DRAG_PIXELS:
self._state = ScrollState.MANUAL_SCROLL
elif self._state == ScrollState.MANUAL_SCROLL:
if mouse_event.left_released:
# Touch rejection: when releasing finger after swiping and stopping, panel
# reports a few erroneous touch events with high velocity, try to ignore.
# If velocity decelerates very quickly, assume user doesn't intend to auto scroll.
# Catches two cases: 1) swipe, stop finger, then lift (stale high velocity in buffer)
# 2) dirty finger lift where finger rotates/slides producing spurious velocity spike.
# TODO: this heuristic false-positives on fast swipes because 140Hz touch polling
# jitter causes velocity to oscillate (not real deceleration). Better approaches:
# - Use evdev kernel timestamps to eliminate velocity oscillation at the source
# - Replace with a time-since-last-event check (40ms timeout) for swipe-stop-lift
high_decel = False
if len(self._velocity_buffer) > 2:
# We limit max to first half since final few velocities can surpass first few
abs_velocity_buffer = [(abs(v), i) for i, v in enumerate(self._velocity_buffer)]
max_idx = max(abs_velocity_buffer[:len(abs_velocity_buffer) // 2])[1]
min_idx = min(abs_velocity_buffer)[1]
if DEBUG:
print('min_idx:', min_idx, 'max_idx:', max_idx, 'velocity buffer:', self._velocity_buffer)
if (abs(self._velocity_buffer[min_idx]) * REJECT_DECELERATION_FACTOR < abs(self._velocity_buffer[max_idx]) and
max_idx < min_idx):
if DEBUG:
print('deceleration too high, going to STEADY')
high_decel = True
self._velocity = weighted_velocity(self._velocity_buffer)
# If final velocity is below some threshold, switch to steady state too
low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin
if out_of_bounds or not (high_decel or low_speed):
self._state = ScrollState.AUTO_SCROLL
else:
# TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares
self._velocity = 0.0
self._state = ScrollState.STEADY
self._velocity_buffer.clear()
else:
# Update velocity for when we release the mouse button.
# Do not update velocity on the same frame the mouse was released
previous_mouse_pos = self._get_mouse_pos(cast(MouseEvent, self._previous_mouse_event))
delta_x = mouse_pos - previous_mouse_pos
delta_t = max((mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t), 1e-6)
self._velocity = delta_x / delta_t
self._velocity = max(-MAX_SPEED, min(MAX_SPEED, self._velocity))
self._velocity_buffer.append(self._velocity)
# rubber-banding: reduce dragging when out of bounds
# TODO: this drifts when dragging quickly
if out_of_bounds:
delta_x *= 0.25
# Update the offset based on the mouse movement
# Use internal _offset directly to preserve precision (don't round via get_offset())
# TODO: make get_offset return float
current_offset = self._offset.x if self._horizontal else self._offset.y
self.set_offset(current_offset + delta_x)
elif self._state == ScrollState.AUTO_SCROLL:
if mouse_event.left_pressed:
# Decide whether to click or scroll (block click if moving too fast)
if abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING:
# Traveling slow enough, click
self._state = ScrollState.PRESSED
self._initial_click_event = mouse_event
else:
# Go straight into manual scrolling to block erroneous input
self._state = ScrollState.MANUAL_SCROLL
# Reset velocity for touch down and up events that happen in back-to-back frames
self._velocity = 0.0
def _get_mouse_pos(self, mouse_event: MouseEvent) -> float:
return mouse_event.pos.x if self._horizontal else mouse_event.pos.y
def get_offset(self) -> float:
return self._offset.x if self._horizontal else self._offset.y
def set_offset(self, value: float) -> None:
if self._horizontal:
self._offset.x = value
else:
self._offset.y = value
@property
def state(self) -> ScrollState:
return self._state
def is_touch_valid(self) -> bool:
# MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state
return bool(self._state != ScrollState.MANUAL_SCROLL)
+238
View File
@@ -0,0 +1,238 @@
import pyray as rl
import numpy as np
from dataclasses import dataclass
from typing import Any, Optional, cast
from openpilot.system.ui.lib.application import gui_app, GL_VERSION
MAX_GRADIENT_COLORS = 20 # includes stops as well
@dataclass
class Gradient:
start: tuple[float, float]
end: tuple[float, float]
colors: list[rl.Color]
stops: list[float]
def __post_init__(self):
if len(self.colors) > MAX_GRADIENT_COLORS:
self.colors = self.colors[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient colors truncated to {MAX_GRADIENT_COLORS} entries")
if len(self.stops) > MAX_GRADIENT_COLORS:
self.stops = self.stops[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient stops truncated to {MAX_GRADIENT_COLORS} entries")
if not len(self.stops):
color_count = min(len(self.colors), MAX_GRADIENT_COLORS)
self.stops = [i / max(1, color_count - 1) for i in range(color_count)]
FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
uniform vec4 fillColor;
// Gradient line defined in *screen pixels*
uniform int useGradient;
uniform vec2 gradientStart; // e.g. vec2(0, 0)
uniform vec2 gradientEnd; // e.g. vec2(0, screenHeight)
uniform vec4 gradientColors[20];
uniform float gradientStops[20];
uniform int gradientColorCount;
vec4 getGradientColor(vec2 p) {
// Compute t from screen-space position
vec2 d = gradientStart - gradientEnd;
float len2 = max(dot(d, d), 1e-6);
float t = clamp(dot(p - gradientEnd, d) / len2, 0.0, 1.0);
// Clamp to range
float t0 = gradientStops[0];
float tn = gradientStops[gradientColorCount-1];
if (t <= t0) return gradientColors[0];
if (t >= tn) return gradientColors[gradientColorCount-1];
for (int i = 0; i < gradientColorCount - 1; i++) {
float a = gradientStops[i];
float b = gradientStops[i+1];
if (t >= a && t <= b) {
float k = (t - a) / max(b - a, 1e-6);
return mix(gradientColors[i], gradientColors[i+1], k);
}
}
return gradientColors[gradientColorCount-1];
}
void main() {
// TODO: do proper antialiasing
finalColor = useGradient == 1 ? getGradientColor(gl_FragCoord.xy) : fillColor;
}
"""
# Default vertex shader
VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
uniform mat4 mvp;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
UNIFORM_VEC4 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4
class ShaderState:
_instance: Any = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
if ShaderState._instance is not None:
raise Exception("This class is a singleton. Use get_instance() instead.")
self.initialized = False
self.shader = None
# Shader uniform locations
self.locations = {
'fillColor': None,
'useGradient': None,
'gradientStart': None,
'gradientEnd': None,
'gradientColors': None,
'gradientStops': None,
'gradientColorCount': None,
'mvp': None,
}
# Pre-allocated FFI objects
self.fill_color_ptr = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self.use_gradient_ptr = rl.ffi.new("int[]", [0])
self.color_count_ptr = rl.ffi.new("int[]", [0])
self.gradient_colors_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS * 4)
self.gradient_stops_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS)
def initialize(self):
if self.initialized:
return
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
# Cache all uniform locations
for uniform in self.locations.keys():
self.locations[uniform] = rl.get_shader_location(self.shader, uniform)
# Orthographic MVP (origin top-left)
proj = rl.matrix_ortho(0, gui_app.width, gui_app.height, 0, -1, 1)
rl.set_shader_value_matrix(self.shader, self.locations['mvp'], proj)
self.initialized = True
def cleanup(self):
if not self.initialized:
return
if self.shader:
rl.unload_shader(self.shader)
self.shader = None
self.initialized = False
def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
gradient: Gradient | None, origin_rect: rl.Rectangle):
assert (color is not None) != (gradient is not None), "Either color or gradient must be provided"
use_gradient = 1 if (gradient is not None and len(gradient.colors) >= 1) else 0
state.use_gradient_ptr[0] = use_gradient
rl.set_shader_value(state.shader, state.locations['useGradient'], state.use_gradient_ptr, UNIFORM_INT)
if use_gradient:
gradient = cast(Gradient, gradient)
state.color_count_ptr[0] = len(gradient.colors)
for i in range(len(gradient.colors)):
c = gradient.colors[i]
base = i * 4
state.gradient_colors_ptr[base:base + 4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0]
rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, len(gradient.colors))
for i in range(len(gradient.stops)):
s = float(gradient.stops[i])
state.gradient_stops_ptr[i] = 0.0 if s < 0.0 else 1.0 if s > 1.0 else s
rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, len(gradient.stops))
rl.set_shader_value(state.shader, state.locations['gradientColorCount'], state.color_count_ptr, UNIFORM_INT)
# Map normalized start/end to screen pixels
start_vec = rl.Vector2(origin_rect.x + gradient.start[0] * origin_rect.width, origin_rect.y + gradient.start[1] * origin_rect.height)
end_vec = rl.Vector2(origin_rect.x + gradient.end[0] * origin_rect.width, origin_rect.y + gradient.end[1] * origin_rect.height)
rl.set_shader_value(state.shader, state.locations['gradientStart'], start_vec, UNIFORM_VEC2)
rl.set_shader_value(state.shader, state.locations['gradientEnd'], end_vec, UNIFORM_VEC2)
else:
color = color or rl.WHITE
state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0]
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
"""Only supports simple polygons with two chains (ribbon)."""
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
# assert len(pts) % 2 == 0, "Interleaving expects even number of points"
if len(pts) % 2 != 0:
pts = pts[:-1]
tri_strip = []
for i in range(len(pts) // 2):
tri_strip.append(pts[i])
tri_strip.append(pts[-i - 1])
return cast(list, np.array(tri_strip).tolist())
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
color: Optional[rl.Color] = None, gradient: Gradient | None = None):
"""
Draw a ribbon polygon (two chains) with a triangle strip and gradient.
- Input must be [L0..Lk-1, Rk-1..R0], even count, no crossings/holes.
"""
if len(points) < 3:
return
# Initialize shader on-demand
state = ShaderState.get_instance()
state.initialize()
# Ensure (N,2) float32 contiguous array
pts = np.ascontiguousarray(points, dtype=np.float32)
assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)"
# Configure gradient shader
_configure_shader_color(state, color, gradient, origin_rect)
# Triangulate via interleaving
tri_strip = triangulate(pts)
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
def cleanup_shader_resources():
state = ShaderState.get_instance()
state.cleanup()
@@ -0,0 +1,906 @@
"""Tests for WifiManager._handle_state_change.
Tests the state machine in isolation by constructing a WifiManager with mocked
DBus, then calling _handle_state_change directly with NM state transitions.
"""
import pytest
from jeepney.low_level import MessageType
from pytest_mock import MockerFixture
from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason
from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus
def _make_wm(mocker: MockerFixture, connections=None):
"""Create a WifiManager with only the fields _handle_state_change touches."""
mocker.patch.object(WifiManager, '_initialize')
wm = WifiManager.__new__(WifiManager)
wm._exit = True # prevent stop() from doing anything in __del__
wm._conn_monitor = mocker.MagicMock()
wm._connections = dict(connections or {})
wm._wifi_state = WifiState()
wm._user_epoch = 0
wm._callback_queue = []
wm._need_auth = []
wm._activated = []
wm._update_networks = mocker.MagicMock()
wm._update_active_connection_info = mocker.MagicMock()
wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None))
return wm
def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN,
reason: int = NMDeviceStateReason.NONE) -> None:
"""Feed a state change into the handler."""
wm._handle_state_change(new_state, prev_state, reason)
def fire_wpa_connect(wm: WifiManager) -> None:
"""WPA handshake then IP negotiation through ACTIVATED, as seen on device."""
fire(wm, NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.IP_CONFIG)
fire(wm, NMDeviceState.IP_CHECK)
fire(wm, NMDeviceState.SECONDARIES)
fire(wm, NMDeviceState.ACTIVATED)
# ---------------------------------------------------------------------------
# Basic transitions
# ---------------------------------------------------------------------------
class TestDisconnected:
def test_generic_disconnect_clears_state(self, mocker):
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
wm._update_networks.assert_not_called()
def test_new_activation_is_noop(self, mocker):
"""NEW_ACTIVATION means NM is about to connect to another network — don't clear."""
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION)
assert wm._wifi_state.ssid == "OldNet"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_connection_removed_keeps_other_connecting(self, mocker):
"""Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B."""
wm = _make_wm(mocker, connections={"B": "/path/B"})
wm._set_connecting("B")
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_connection_removed_clears_when_forgotten(self, mocker):
"""Forget A: A is no longer in _connections, so state should clear."""
wm = _make_wm(mocker, connections={})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
class TestDeactivating:
def test_deactivating_noop_for_non_connection_removed(self, mocker):
"""DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op."""
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
assert wm._wifi_state.ssid == "Net"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
@pytest.mark.parametrize("status, expected_clears", [
(ConnectStatus.CONNECTED, True),
(ConnectStatus.CONNECTING, False),
])
def test_deactivating_connection_removed(self, mocker, status, expected_clears):
"""DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING.
CONNECTED: forgetting the current network. The forgotten callback fires between
DEACTIVATING and DISCONNECTED — must clear here so the UI doesn't flash "connected"
after the eager _network_forgetting flag resets.
CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal,
but B's CONNECTING state must be preserved.
"""
wm = _make_wm(mocker, connections={"B": "/path/B"})
wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status)
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED)
if expected_clears:
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
else:
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
class TestPrepareConfig:
def test_user_initiated_skips_dbus_lookup(self, mocker):
"""User called _set_connecting('B') — PREPARE must not overwrite via DBus.
Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's
stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting"
indicator briefly jump to the wrong network row then back.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._set_connecting("B")
wm._get_active_wifi_connection.return_value = ("/path/A", {})
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
wm._get_active_wifi_connection.assert_not_called()
@pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG])
def test_auto_connect_looks_up_ssid(self, mocker, state):
"""Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM."""
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
fire(wm, state)
assert wm._wifi_state.ssid == "AutoNet"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_auto_connect_dbus_fails(self, mocker):
"""Auto-connection but DBus returns None: ssid stays None, status CONNECTING."""
wm = _make_wm(mocker)
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_auto_connect_conn_path_not_in_connections(self, mocker):
"""DBus returns a conn_path that doesn't match any known connection."""
wm = _make_wm(mocker, connections={"Other": "/path/other"})
wm._get_active_wifi_connection.return_value = ("/path/unknown", {})
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.CONNECTING
class TestNeedAuth:
def test_wrong_password_fires_callback(self, mocker):
"""NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password."""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("SecNet")
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
wm.process_callbacks()
cb.assert_called_once_with("SecNet")
def test_failed_no_secrets_fires_callback(self, mocker):
"""FAILED+NO_SECRETS = wrong password (weak/gone network).
Confirmed on device: also fires when a hotspot turns off during connection.
NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS
rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows
"wrong password" — a false positive, but same signal path.
Real device sequence (new connection, hotspot turned off immediately):
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
"""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("WeakNet")
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
wm.process_callbacks()
cb.assert_called_once_with("WeakNet")
def test_need_auth_then_failed_no_double_fire(self, mocker):
"""Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back.
The first clears ssid, so the second must not fire a duplicate callback.
Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) → FAILED(NEED_AUTH, NO_SECRETS)
"""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("BadPass")
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert len(wm._callback_queue) == 1
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
reason=NMDeviceStateReason.NO_SECRETS)
assert len(wm._callback_queue) == 1 # no duplicate
wm.process_callbacks()
cb.assert_called_once_with("BadPass")
def test_no_ssid_no_callback(self, mocker):
"""If ssid is None when NEED_AUTH fires, no callback enqueued."""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert len(wm._callback_queue) == 0
def test_interrupted_auth_ignored(self, mocker):
"""Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback.
Reproduced on device: rapidly switching between two saved networks can trigger a
rare false "wrong password" dialog for the previous network, even though both have
correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG).
"""
wm = _make_wm(mocker)
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("A")
wm._set_connecting("B")
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
assert len(wm._callback_queue) == 0
class TestPassthroughStates:
"""NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops."""
@pytest.mark.parametrize("state", [
NMDeviceState.NEED_AUTH,
NMDeviceState.IP_CONFIG,
NMDeviceState.IP_CHECK,
NMDeviceState.SECONDARIES,
NMDeviceState.FAILED,
])
def test_passthrough_is_noop(self, mocker, state):
wm = _make_wm(mocker)
wm._set_connecting("Net")
fire(wm, state, reason=NMDeviceStateReason.NONE)
assert wm._wifi_state.ssid == "Net"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
assert len(wm._callback_queue) == 0
class TestActivated:
def test_sets_connected(self, mocker):
"""ACTIVATED sets status to CONNECTED and fires callback."""
wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"})
cb = mocker.MagicMock()
wm.add_callbacks(activated=cb)
wm._set_connecting("MyNet")
wm._get_active_wifi_connection.return_value = ("/path/mynet", {})
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "MyNet"
assert len(wm._callback_queue) == 1
wm.process_callbacks()
cb.assert_called_once()
def test_conn_path_none_still_connected(self, mocker):
"""ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged."""
wm = _make_wm(mocker)
wm._set_connecting("MyNet")
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "MyNet"
def test_activated_side_effects(self, mocker):
"""ACTIVATED persists the volatile connection to disk and updates active connection info."""
wm = _make_wm(mocker, connections={"Net": "/path/net"})
wm._set_connecting("Net")
wm._get_active_wifi_connection.return_value = ("/path/net", {})
fire(wm, NMDeviceState.ACTIVATED)
wm._conn_monitor.send_and_get_reply.assert_called_once()
wm._update_active_connection_info.assert_called_once()
wm._update_networks.assert_not_called()
# ---------------------------------------------------------------------------
# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread.
# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler.
# The epoch counter detects that a user action occurred during the slow DBus call
# and discards the stale update.
# ---------------------------------------------------------------------------
# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard
# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED
# guard) shrink these race windows significantly. The epoch counter closes the
# remaining gaps.
class TestThreadRaces:
def test_prepare_race_user_tap_during_dbus(self, mocker):
"""User taps B while PREPARE's DBus call is in flight for auto-connect.
Monitor thread reads wifi_state (ssid=None), starts DBus call.
Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
def user_taps_b_during_dbus(*args, **kwargs):
wm._set_connecting("B")
return ("/path/A", {})
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_activated_race_user_tap_during_dbus(self, mocker):
"""User taps B right as A finishes connecting (ACTIVATED handler running).
Monitor thread reads wifi_state (A, CONNECTING), starts DBus call.
Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._set_connecting("A")
def user_taps_b_during_dbus(*args, **kwargs):
wm._set_connecting("B")
return ("/path/A", {})
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
def test_init_wifi_state_race_user_tap_during_dbus(self, mocker):
"""User taps B while _init_wifi_state's DBus calls are in flight.
_init_wifi_state runs from set_active(True) or worker error paths. It does
2 DBus calls (device State property + _get_active_wifi_connection) then
unconditionally writes _wifi_state. If the user taps a network during those
calls, _set_connecting("B") is overwritten with stale NM ground truth.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._wifi_device = "/dev/wifi0"
wm._router_main = mocker.MagicMock()
state_reply = mocker.MagicMock()
state_reply.body = [('u', NMDeviceState.ACTIVATED)]
wm._router_main.send_and_get_reply.return_value = state_reply
def user_taps_b_during_dbus(*args, **kwargs):
wm._set_connecting("B")
return ("/path/A", {})
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
wm._init_wifi_state()
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# ---------------------------------------------------------------------------
# Full sequences (NM signal order from real devices)
# ---------------------------------------------------------------------------
class TestFullSequences:
def test_normal_connect(self, mocker):
"""User connects to saved network: full happy path.
Real device sequence (switching from another connected network):
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
"""
wm = _make_wm(mocker, connections={"Home": "/path/home"})
wm._get_active_wifi_connection.return_value = ("/path/home", {})
wm._set_connecting("Home")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.IP_CONFIG)
fire(wm, NMDeviceState.IP_CHECK)
fire(wm, NMDeviceState.SECONDARIES)
fire(wm, NMDeviceState.ACTIVATED)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "Home"
def test_wrong_password_then_retry(self, mocker):
"""Wrong password → NEED_AUTH → FAILED → NM auto-reconnects to saved network.
Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi.
Real device sequence (switching from a connected network):
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) ← WPA handshake
→ PREPARE(NEED_AUTH, NONE) → CONFIG
→ NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password
→ FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up
→ DISCONNECTED(FAILED, NONE)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED ← auto-reconnect to other saved network
"""
wm = _make_wm(mocker, connections={"Sec": "/path/sec"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("Sec")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
# FAILED(NO_SECRETS) follows but ssid is already cleared — no double-fire
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
assert len(wm._callback_queue) == 1
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
# Retry
wm._callback_queue.clear()
wm._set_connecting("Sec")
wm._get_active_wifi_connection.return_value = ("/path/sec", {})
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_switch_saved_networks(self, mocker):
"""Switch from A to B (both saved): NM signal sequence from real device.
Real device sequence:
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._get_active_wifi_connection.return_value = ("/path/B", {})
wm._set_connecting("B")
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.NEW_ACTIVATION)
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.NEW_ACTIVATION)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "B"
def test_rapid_switch_no_false_wrong_password(self, mocker):
"""Switch A→B quickly: A's interrupted NEED_AUTH must NOT show wrong password.
NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly
switching between networks with wrong/new passwords. Less common when switching between
saved networks with correct passwords. Not guaranteed — some switches skip it and go
straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED
for stale signals, so the prev_state guard reliably distinguishes them.
Worst-case signal sequence this protects against:
DEACTIVATING(NEW_ACTIVATION) → DISCONNECTED(NEW_ACTIVATION)
→ NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure
→ PREPARE → CONFIG → ... → ACTIVATED ← B connects
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._get_active_wifi_connection.return_value = ("/path/B", {})
wm._set_connecting("B")
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.NEW_ACTIVATION)
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.NEW_ACTIVATION)
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
assert len(wm._callback_queue) == 0
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_forget_while_connecting(self, mocker):
"""Forget the network we're currently connecting to (not yet ACTIVATED).
Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot
Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward.
Real device sequence (switching then forgetting mid-connection):
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG
→ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
→ PREPARE → CONFIG → ... → ACTIVATED ← NM auto-connects to other saved network
Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is
CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state
(ssid removed from _connections by ConnectionRemoved), then PREPARE recovers
via DBus lookup for the auto-connect.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"})
wm._get_active_wifi_connection.return_value = ("/path/other", {})
wm._set_connecting("A")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# User forgets A: ConnectionRemoved processed first, then state changes
del wm._connections["A"]
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# NM auto-connects to another saved network
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "Other"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "Other"
def test_forget_connected_network(self, mocker):
"""Forget the currently connected network (not switching to another).
Real device sequence:
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
ConnectionRemoved signal may or may not have been processed before state changes.
Either way, state must clear — we're forgetting what we're connected to, not switching.
"""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# DISCONNECTED follows — harmless since state is already cleared
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
def test_forget_A_connect_B(self, mocker):
"""Forget A while connecting to B: full signal sequence.
Real device sequence:
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
Signal order:
1. User: _set_connecting("B"), forget("A") removes A from _connections
2. NewConnection for B arrives → _connections["B"] = ...
3. DEACTIVATING(CONNECTION_REMOVED) — no-op
4. DISCONNECTED(CONNECTION_REMOVED) — B is in _connections, must not clear
5. PREPARE → CONFIG → NEED_AUTH → PREPARE → CONFIG → ... → ACTIVATED
"""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._set_connecting("B")
del wm._connections["A"]
wm._connections["B"] = "/path/B"
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
wm._get_active_wifi_connection.return_value = ("/path/B", {})
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "B"
def test_forget_A_connect_B_late_new_connection(self, mocker):
"""Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED.
This is the worst-case race: B isn't in _connections when DISCONNECTED fires,
so the guard can't protect it and state clears. PREPARE must recover by doing
the DBus lookup (ssid is None at that point).
Signal order:
1. User: _set_connecting("B"), forget("A") removes A from _connections
2. DEACTIVATING(CONNECTION_REMOVED) — B NOT in _connections, should be no-op
3. DISCONNECTED(CONNECTION_REMOVED) — B STILL NOT in _connections, clears state
4. NewConnection for B arrives late → _connections["B"] = ...
5. PREPARE (ssid=None, so DBus lookup recovers) → CONFIG → ACTIVATED
"""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._set_connecting("B")
del wm._connections["A"]
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
reason=NMDeviceStateReason.CONNECTION_REMOVED)
# B not in _connections yet, so state clears — this is the known edge case
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# NewConnection arrives late
wm._connections["B"] = "/path/B"
wm._get_active_wifi_connection.return_value = ("/path/B", {})
# PREPARE recovers: ssid is None so it looks up from DBus
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "B"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "B"
def test_auto_connect(self, mocker):
"""NM auto-connects (no user action, ssid starts None)."""
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
fire(wm, NMDeviceState.PREPARE)
assert wm._wifi_state.ssid == "AutoNet"
assert wm._wifi_state.status == ConnectStatus.CONNECTING
fire(wm, NMDeviceState.CONFIG)
fire_wpa_connect(wm)
assert wm._wifi_state.status == ConnectStatus.CONNECTED
assert wm._wifi_state.ssid == "AutoNet"
def test_network_lost_during_connection(self, mocker):
"""Hotspot turned off while connecting (before ACTIVATED).
Confirmed on device: started new connection to Shane's iPhone, immediately
turned off the hotspot. NM can't complete WPA handshake and reports
FAILED(NO_SECRETS) — same signal as wrong password (false positive).
Real device sequence:
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the
normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP
vanished mid-handshake.
"""
wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("Hotspot")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
fire(wm, NMDeviceState.CONFIG)
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# Second NEED_AUTH(CONFIG, NONE) — NM retries handshake, AP vanishing
fire(wm, NMDeviceState.NEED_AUTH)
assert wm._wifi_state.status == ConnectStatus.CONNECTING
# NM gives up — reports NO_SECRETS (same as wrong password)
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
reason=NMDeviceStateReason.NO_SECRETS)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert len(wm._callback_queue) == 1
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
wm.process_callbacks()
cb.assert_called_once_with("Hotspot")
@pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI")
def test_ssid_not_found(self, mocker):
"""Network drops off while connected — hotspot turned off.
NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found"
Confirmed on device: connected to Shane's iPhone, then turned off the hotspot.
No DEACTIVATING fires — NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND).
NM retries connecting (PREPARE → CONFIG → ... → FAILED(CONFIG, SSID_NOT_FOUND))
before finally giving up with DISCONNECTED.
NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically
produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets).
Real device sequence (hotspot turned off while connected):
FAILED(ACTIVATED, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
→ FAILED(CONFIG, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
The UI error callback mechanism is intentionally deferred — for now just clear state.
"""
wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"})
cb = mocker.MagicMock()
wm.add_callbacks(need_auth=cb)
wm._set_connecting("GoneNet")
fire(wm, NMDeviceState.PREPARE)
fire(wm, NMDeviceState.CONFIG)
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND)
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
assert wm._wifi_state.ssid is None
def test_failed_then_disconnected_clears_state(self, mocker):
"""After FAILED, NM always transitions to DISCONNECTED to clean up.
NM docs: FAILED (120) = "failed to connect, cleaning up the connection request"
Full sequence: ... → FAILED(reason) → DISCONNECTED(NONE)
"""
wm = _make_wm(mocker)
wm._set_connecting("Net")
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE)
assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
def test_user_requested_disconnect(self, mocker):
"""User explicitly disconnects from the network.
NM docs: USER_REQUESTED (39) = "Device disconnected by user or client"
Expected sequence: DEACTIVATING(USER_REQUESTED) → DISCONNECTED(USER_REQUESTED)
"""
wm = _make_wm(mocker)
wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED)
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED)
assert wm._wifi_state.ssid is None
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
# ---------------------------------------------------------------------------
# Worker error recovery: DBus errors in activate/connect re-sync with NM
# ---------------------------------------------------------------------------
# Verified on device: when ActivateConnection returns UnknownConnection error,
# NM emits no state signals. The worker error path is the only recovery point.
class TestWorkerErrorRecovery:
"""Worker threads re-sync with NM via _init_wifi_state on DBus errors,
preserving actual NM state instead of blindly clearing to DISCONNECTED."""
def _mock_init_restores(self, wm, mocker, ssid, status):
"""Replace _init_wifi_state with a mock that simulates NM reporting the given state."""
mock = mocker.MagicMock(
side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status))
)
wm._init_wifi_state = mock
return mock
def test_activate_dbus_error_resyncs(self, mocker):
"""ActivateConnection returns DBus error while A is connected.
NM rejects the request — no state signals emitted. Worker must re-read NM
state to discover A is still connected, not clear to DISCONNECTED.
"""
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
wm._wifi_device = "/dev/wifi0"
wm._nm = mocker.MagicMock()
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._router_main = mocker.MagicMock()
error_reply = mocker.MagicMock()
error_reply.header.message_type = MessageType.error
wm._router_main.send_and_get_reply.return_value = error_reply
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
wm.activate_connection("B", block=True)
mock_init.assert_called_once()
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
def test_connect_to_network_dbus_error_resyncs(self, mocker):
"""AddAndActivateConnection2 returns DBus error while A is connected."""
wm = _make_wm(mocker, connections={"A": "/path/A"})
wm._wifi_device = "/dev/wifi0"
wm._nm = mocker.MagicMock()
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
wm._router_main = mocker.MagicMock()
wm._forgotten = []
error_reply = mocker.MagicMock()
error_reply.header.message_type = MessageType.error
wm._router_main.send_and_get_reply.return_value = error_reply
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
# Run worker thread synchronously
workers = []
mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread',
side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})())
wm.connect_to_network("B", "password123")
workers[-1]()
mock_init.assert_called_once()
assert wm._wifi_state.ssid == "A"
assert wm._wifi_state.status == ConnectStatus.CONNECTED
+36
View File
@@ -0,0 +1,36 @@
import pyray as rl
from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback
from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
# Measure normal characters without emojis, then add standard width for each found emoji
emoji = find_emoji(text)
if emoji:
non_emoji_text = ""
last_index = 0
for start, end, _ in emoji:
non_emoji_text += text[last_index:start]
last_index = end
non_emoji_text += text[last_index:]
else:
non_emoji_text = text
result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251
if emoji:
result.x += len(emoji) * font_size * FONT_SCALE
# If just emoji assume a single line height
if result.y == 0:
result.y = font_size * FONT_SCALE
_cache[key] = result
return result
+18
View File
@@ -0,0 +1,18 @@
import pyray as rl
class GuiStyleContext:
def __init__(self, styles: list[tuple[int, int, int]]):
"""styles is a list of tuples (control, prop, new_value)"""
self.styles = styles
self.prev_styles: list[tuple[int, int, int]] = []
def __enter__(self):
for control, prop, new_value in self.styles:
prev_value = rl.gui_get_style(control, prop)
self.prev_styles.append((control, prop, prev_value))
rl.gui_set_style(control, prop, new_value)
def __exit__(self, exc_type, exc_value, traceback):
for control, prop, prev_value in self.prev_styles:
rl.gui_set_style(control, prop, prev_value)
+946
View File
@@ -0,0 +1,946 @@
import atexit
import threading
import time
import uuid
import subprocess
from collections.abc import Callable
from dataclasses import dataclass, replace
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 DBusConnection, 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
from openpilot.common.swaglog import cloudlog
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,
NM_802_11_AP_SEC_KEY_MGMT_802_1X, NM_802_11_AP_FLAGS_NONE,
NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS,
NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH,
NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE,
NM_DEVICE_TYPE_WIFI, NM_ACTIVE_CONNECTION_IFACE,
NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason)
try:
from openpilot.common.params import Params
except Exception:
Params = None
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
SCAN_PERIOD_SECONDS = 5
DEBUG = False
_dbus_call_idx = 0
def normalize_ssid(ssid: str) -> str:
return ssid.replace("", "'") # for iPhone hotspots
def _wrap_router(router):
def _wrap(orig):
def wrapper(msg, **kw):
global _dbus_call_idx
_dbus_call_idx += 1
if DEBUG:
h = msg.header.fields
print(f"[DBUS #{_dbus_call_idx}] {h.get(6, '?')} {h.get(3, '?')} {msg.body}")
return orig(msg, **kw)
return wrapper
router.send_and_get_reply = _wrap(router.send_and_get_reply)
router.send = _wrap(router.send)
class SecurityType(IntEnum):
OPEN = 0
WPA = 1
WPA2 = 2
WPA3 = 3
UNSUPPORTED = 4
class MeteredType(IntEnum):
UNKNOWN = 0
YES = 1
NO = 2
def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType:
wpa_props = wpa_flags | rsn_flags
# obtained by looking at flags of networks in the office as reported by an Android phone
supports_wpa = (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)
if (flags == NM_802_11_AP_FLAGS_NONE) or ((flags & NM_802_11_AP_FLAGS_WPS) and not (wpa_props & supports_wpa)):
return SecurityType.OPEN
elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & supports_wpa) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X):
return SecurityType.WPA
else:
cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}")
return SecurityType.UNSUPPORTED
@dataclass(frozen=True)
class Network:
ssid: str
strength: int
security_type: SecurityType
is_tethering: bool
@classmethod
def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_tethering: bool) -> "Network":
# we only want to show the strongest AP for each Network/SSID
strongest_ap = max(aps, key=lambda ap: ap.strength)
security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags)
return cls(
ssid=ssid,
strength=100 if is_tethering else strongest_ap.strength,
security_type=security_type,
is_tethering=is_tethering,
)
@dataclass(frozen=True)
class AccessPoint:
ssid: str
bssid: str
strength: int
flags: int
wpa_flags: int
rsn_flags: int
ap_path: str
@classmethod
def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str) -> "AccessPoint":
ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace")
bssid = str(ap_props['HwAddress'][1])
strength = int(ap_props['Strength'][1])
flags = int(ap_props['Flags'][1])
wpa_flags = int(ap_props['WpaFlags'][1])
rsn_flags = int(ap_props['RsnFlags'][1])
return cls(
ssid=ssid,
bssid=bssid,
strength=strength,
flags=flags,
wpa_flags=wpa_flags,
rsn_flags=rsn_flags,
ap_path=ap_path,
)
class ConnectStatus(IntEnum):
DISCONNECTED = 0
CONNECTING = 1
CONNECTED = 2
@dataclass(frozen=True)
class WifiState:
ssid: str | None = None
status: ConnectStatus = ConnectStatus.DISCONNECTED
class WifiManager:
def __init__(self):
self._networks: list[Network] = [] # an unsorted list of available Networks. a Network can be comprised of multiple APs
self._active = True # used to not run when not in settings
self._exit = False
# DBus connections
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
_wrap_router(self._router_main)
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")
self._router_main = None
self._conn_monitor = None
self._exit = True
# Store wifi device path
self._wifi_device: str | None = None
# State
self._connections: dict[str, str] = {} # ssid -> connection path, updated via NM signals
self._wifi_state: WifiState = WifiState()
self._user_epoch: int = 0
self._ipv4_address: str = ""
self._current_network_metered: MeteredType = MeteredType.UNKNOWN
self._tethering_password: str = ""
self._ipv4_forward = False
self._last_network_scan: float = 0.0
self._callback_queue: list[Callable] = []
self._tethering_ssid = "weedle"
if Params is not None:
dongle_id = Params().get("DongleId")
if dongle_id:
self._tethering_ssid += "-" + dongle_id[:4]
# Callbacks
self._need_auth: list[Callable[[str], None]] = []
self._activated: list[Callable[[], None]] = []
self._forgotten: list[Callable[[str | None], None]] = []
self._networks_updated: list[Callable[[list[Network]], None]] = []
self._disconnected: list[Callable[[], None]] = []
self._scan_lock = threading.Lock()
self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True)
self._state_thread = threading.Thread(target=self._monitor_state, daemon=True)
self._initialize()
atexit.register(self.stop)
def _initialize(self):
def worker():
self._wait_for_wifi_device()
# TODO: wait for state thread to start before adding tethering connection, tiny race currently
self._scan_thread.start()
self._state_thread.start()
self._init_connections()
if Params is not None and self._tethering_ssid not in self._connections:
self._add_tethering_connection()
self._init_wifi_state()
self._tethering_password = self._get_tethering_password()
cloudlog.debug("WifiManager initialized")
threading.Thread(target=worker, daemon=True).start()
def _init_wifi_state(self, block: bool = True):
def worker():
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
epoch = self._user_epoch
dev_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_DEVICE_IFACE)
dev_state = self._router_main.send_and_get_reply(Properties(dev_addr).get('State')).body[0][1]
ssid: str | None = None
status = ConnectStatus.DISCONNECTED
if NMDeviceState.PREPARE <= dev_state <= NMDeviceState.SECONDARIES and dev_state != NMDeviceState.NEED_AUTH:
status = ConnectStatus.CONNECTING
elif dev_state == NMDeviceState.ACTIVATED:
status = ConnectStatus.CONNECTED
conn_path, _ = self._get_active_wifi_connection()
if conn_path:
ssid = next((s for s, p in self._connections.items() if p == conn_path), None)
# Discard if user acted during DBus calls
if self._user_epoch != epoch:
return
self._wifi_state = WifiState(ssid=ssid, status=status)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def add_callbacks(self, need_auth: Callable[[str], None] | None = None,
activated: Callable[[], None] | None = None,
forgotten: Callable[[str], None] | None = None,
networks_updated: Callable[[list[Network]], None] | None = None,
disconnected: Callable[[], None] | None = None):
if need_auth is not None:
self._need_auth.append(need_auth)
if activated is not None:
self._activated.append(activated)
if forgotten is not None:
self._forgotten.append(forgotten)
if networks_updated is not None:
self._networks_updated.append(networks_updated)
if disconnected is not None:
self._disconnected.append(disconnected)
@property
def networks(self) -> list[Network]:
# Sort by connected/connecting, then known, then strength, then alphabetically. This is a pure UI ordering and should not affect underlying state.
return sorted(self._networks, key=lambda n: (n.ssid != self._wifi_state.ssid, not self.is_connection_saved(n.ssid), -n.strength, n.ssid.lower()))
@property
def wifi_state(self) -> WifiState:
return self._wifi_state
@property
def ipv4_address(self) -> str:
return self._ipv4_address
@property
def current_network_metered(self) -> MeteredType:
return self._current_network_metered
@property
def connecting_to_ssid(self) -> str | None:
wifi_state = self._wifi_state
return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTING else None
@property
def connected_ssid(self) -> str | None:
wifi_state = self._wifi_state
return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTED else None
@property
def tethering_password(self) -> str:
return self._tethering_password
def _set_connecting(self, ssid: str | None):
# Called by user action, or sequentially from state change handler
self._user_epoch += 1
self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING)
def _enqueue_callbacks(self, cbs: list[Callable], *args):
for cb in cbs:
self._callback_queue.append(lambda _cb=cb: _cb(*args))
def process_callbacks(self):
# Call from UI thread to run any pending callbacks
to_run, self._callback_queue = self._callback_queue, []
for cb in to_run:
cb()
def set_active(self, active: bool):
self._active = active
# Update networks and WiFi state (to self-heal) immediately when activating for UI
if active:
self._init_wifi_state(block=False)
self._update_networks(block=False)
def _monitor_state(self):
# Filter for signals
rules = (
MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
member="StateChanged",
path=self._wifi_device,
),
MatchRule(
type="signal",
interface=NM_SETTINGS_IFACE,
member="NewConnection",
path=NM_SETTINGS_PATH,
),
MatchRule(
type="signal",
interface=NM_SETTINGS_IFACE,
member="ConnectionRemoved",
path=NM_SETTINGS_PATH,
),
MatchRule(
type="signal",
interface=NM_PROPERTIES_IFACE,
member="PropertiesChanged",
path=self._wifi_device,
),
)
for rule in rules:
self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule))
with (self._conn_monitor.filter(rules[0], bufsize=SIGNAL_QUEUE_SIZE) as state_q,
self._conn_monitor.filter(rules[1], bufsize=SIGNAL_QUEUE_SIZE) as new_conn_q,
self._conn_monitor.filter(rules[2], bufsize=SIGNAL_QUEUE_SIZE) as removed_conn_q,
self._conn_monitor.filter(rules[3], bufsize=SIGNAL_QUEUE_SIZE) as props_q):
while not self._exit:
try:
self._conn_monitor.recv_messages(timeout=1)
except TimeoutError:
continue
# Connection added/removed
while len(removed_conn_q):
conn_path = removed_conn_q.popleft().body[0]
self._connection_removed(conn_path)
while len(new_conn_q):
conn_path = new_conn_q.popleft().body[0]
self._new_connection(conn_path)
# PropertiesChanged on wifi device (LastScan = scan complete)
while len(props_q):
iface, changed, _ = props_q.popleft().body
if iface == NM_WIRELESS_IFACE and 'LastScan' in changed:
self._update_networks()
# Device state changes
while len(state_q):
new_state, previous_state, change_reason = state_q.popleft().body
self._handle_state_change(new_state, previous_state, change_reason)
def _handle_state_change(self, new_state: int, prev_state: int, change_reason: int):
# Thread safety: _wifi_state is read/written by both the monitor thread (this handler)
# and the main thread (_set_connecting via connect/activate). PREPARE/CONFIG and ACTIVATED
# have a read-then-write pattern with a slow DBus call in between — if _set_connecting
# runs mid-call, the handler would overwrite the user's newer state with stale data.
#
# The _user_epoch counter solves this without locks. _set_connecting increments the epoch
# on every user action. Handlers snapshot the epoch before their DBus call and compare
# after: if it changed, a user action occurred during the call and the stale result is
# discarded. Combined with deterministic fixes (skip DBus lookup when ssid already set,
# DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED guard),
# all known race windows are closed.
# TODO: Handle (FAILED, SSID_NOT_FOUND) and emit for UI to show error
# Happens when network drops off after starting connection
if new_state == NMDeviceState.DISCONNECTED:
if change_reason == NMDeviceStateReason.NEW_ACTIVATION:
return
# Guard: forget A while connecting to B fires CONNECTION_REMOVED. Don't clear B's state
# if B is still a known connection. If B hasn't arrived in _connections yet (late
# NewConnection), state clears here but PREPARE recovers via DBus lookup.
if (change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.ssid and
self._wifi_state.ssid in self._connections):
return
self._set_connecting(None)
elif new_state in (NMDeviceState.PREPARE, NMDeviceState.CONFIG):
epoch = self._user_epoch
if self._wifi_state.ssid is not None:
self._wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING)
return
# Auto-connection when NetworkManager connects to known networks on its own (ssid=None): look up ssid from NM
wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING)
conn_path, _ = self._get_active_wifi_connection(self._conn_monitor)
# Discard if user acted during DBus call
if self._user_epoch != epoch:
return
if conn_path is None:
cloudlog.warning("Failed to get active wifi connection during PREPARE/CONFIG state")
else:
wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None))
self._wifi_state = wifi_state
# BAD PASSWORD
# - strong network rejects with NEED_AUTH+SUPPLICANT_DISCONNECT
# - weak/gone network fails with FAILED+NO_SECRETS
# TODO: sometimes on PC it's observed no future signals are fired if mouse is held down blocking wrong password dialog
elif ((new_state == NMDeviceState.NEED_AUTH and change_reason == NMDeviceStateReason.SUPPLICANT_DISCONNECT
and prev_state == NMDeviceState.CONFIG) or
(new_state == NMDeviceState.FAILED and change_reason == NMDeviceStateReason.NO_SECRETS)):
# prev_state guard: real auth failures come from CONFIG (supplicant handshake).
# Stale NEED_AUTH from a prior connection during network switching arrives with
# prev_state=DISCONNECTED and must be ignored to avoid a false wrong-password callback.
if self._wifi_state.ssid:
self._enqueue_callbacks(self._need_auth, self._wifi_state.ssid)
self._set_connecting(None)
elif new_state in (NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK,
NMDeviceState.SECONDARIES, NMDeviceState.FAILED):
pass
elif new_state == NMDeviceState.ACTIVATED:
# Note that IP address from Ip4Config may not be propagated immediately and could take until the next scan results
epoch = self._user_epoch
wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTED)
conn_path, _ = self._get_active_wifi_connection(self._conn_monitor)
# Discard if user acted during DBus call
if self._user_epoch != epoch:
return
if conn_path is None:
cloudlog.warning("Failed to get active wifi connection during ACTIVATED state")
else:
wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None))
self._wifi_state = wifi_state
self._enqueue_callbacks(self._activated)
self._update_active_connection_info()
# Persist volatile connections (created by AddAndActivateConnection2) to disk
if conn_path is not None:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
save_reply = self._conn_monitor.send_and_get_reply(new_method_call(conn_addr, 'Save'))
if save_reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to persist connection to disk: {save_reply}")
elif new_state == NMDeviceState.DEACTIVATING:
# Must clear state when forgetting the currently connected network so the UI
# doesn't flash "connected" after the eager "forgetting..." state resets
# (the forgotten callback fires between DEACTIVATING and DISCONNECTED).
# Only clear CONNECTED — CONNECTING must be preserved for forget-A-connect-B.
if change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.status == ConnectStatus.CONNECTED:
self._set_connecting(None)
def _network_scanner(self):
while not self._exit:
if self._active:
if time.monotonic() - self._last_network_scan > SCAN_PERIOD_SECONDS:
self._request_scan()
self._last_network_scan = time.monotonic()
time.sleep(1 / 2.)
def _wait_for_wifi_device(self):
while not self._exit:
device_path = self._get_adapter(NM_DEVICE_TYPE_WIFI)
if device_path is not None:
self._wifi_device = device_path
break
time.sleep(1)
def _get_adapter(self, adapter_type: int) -> str | None:
# Return the first NetworkManager device path matching adapter_type
try:
device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0]
for device_path in device_paths:
dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE)
dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1]
if dev_type == adapter_type:
return str(device_path)
except Exception as e:
cloudlog.exception(f"Error getting adapter type {adapter_type}: {e}")
return None
def _init_connections(self) -> None:
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0]
conns: dict[str, str] = {}
for conn_path in known_connections:
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
continue
if "802-11-wireless" in settings:
ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace")
if ssid != "":
conns[ssid] = conn_path
self._connections = conns
def _new_connection(self, conn_path: str):
settings = self._get_connection_settings(conn_path)
if "802-11-wireless" in settings:
ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace")
if ssid != "":
self._connections[ssid] = conn_path
def _connection_removed(self, conn_path: str):
self._connections = {ssid: path for ssid, path in self._connections.items() if path != conn_path}
def _get_active_connections(self, router: DBusConnection | DBusRouter | None = None):
# Returns list of ActiveConnection
if router is None:
router = self._router_main
return router.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1]
def _get_active_wifi_connection(self, router: DBusConnection | DBusRouter | None = None) -> tuple[str | None, dict | None]:
# Returns first Connection settings path and ActiveConnection props from ActiveConnections with Type 802-11-wireless
if router is None:
router = self._router_main
for active_conn in self._get_active_connections(router):
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
reply = router.send_and_get_reply(Properties(conn_addr).get_all())
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get active connection properties for {active_conn}: {reply}")
continue
props = reply.body[0]
conn_path = props.get('Connection', ('o', '/'))[1]
if props.get('Type', ('s', ''))[1] == '802-11-wireless' and conn_path != '/':
return conn_path, props
return None, None
def _get_connection_settings(self, conn_path: str) -> dict:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'GetSettings'))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to get connection settings: {reply}')
return {}
return dict(reply.body[0])
def _add_tethering_connection(self):
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', 'Hotspot'),
'autoconnect-retries': ('i', 0),
'interface-name': ('s', 'wlan0'),
'autoconnect': ('b', False),
},
'802-11-wireless': {
'band': ('s', 'bg'),
'mode': ('s', 'ap'),
'ssid': ('ay', self._tethering_ssid.encode("utf-8")),
},
'802-11-wireless-security': {
'group': ('as', ['ccmp']),
'key-mgmt': ('s', 'wpa-psk'),
'pairwise': ('as', ['ccmp']),
'proto': ('as', ['rsn']),
'psk': ('s', DEFAULT_TETHERING_PASSWORD),
},
'ipv4': {
'method': ('s', 'shared'),
'address-data': ('aa{sv}', [[
('address', ('s', TETHERING_IP_ADDRESS)),
('prefix', ('u', 24)),
]]),
'gateway': ('s', TETHERING_IP_ADDRESS),
'never-default': ('b', True),
},
'ipv6': {'method': ('s', 'ignore')},
}
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
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):
self._set_connecting(ssid)
def worker():
# Clear all connections that may already exist to the network we are connecting to
self.forget_connection(ssid, block=True)
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', f'openpilot connection {ssid}'),
'autoconnect-retries': ('i', 0),
},
'802-11-wireless': {
'ssid': ('ay', ssid.encode("utf-8")),
'hidden': ('b', hidden),
'mode': ('s', 'infrastructure'),
},
'ipv4': {
'method': ('s', 'auto'),
'dns-priority': ('i', 600),
},
'ipv6': {'method': ('s', 'ignore')},
}
if password:
connection['802-11-wireless-security'] = {
'key-mgmt': ('s', 'wpa-psk'),
'auth-alg': ('s', 'open'),
'psk': ('s', password),
}
# Volatile connection auto-deletes on disconnect (wrong password, user switches networks)
# Persisted to disk on ACTIVATED via Save()
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
return
reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'AddAndActivateConnection2', 'a{sa{sv}}ooa{sv}',
(connection, self._wifi_device, "/", {'persist': ('s', 'volatile')})))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to add and activate connection for {ssid}: {reply}")
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
threading.Thread(target=worker, daemon=True).start()
def forget_connection(self, ssid: str, block: bool = False):
def worker():
conn_path = self._connections.get(ssid, None)
if conn_path is None:
cloudlog.warning(f"Trying to forget unknown connection: {ssid}")
else:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete'))
self._enqueue_callbacks(self._forgotten, ssid)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def activate_connection(self, ssid: str, block: bool = False):
self._set_connecting(ssid)
def worker():
conn_path = self._connections.get(ssid, None)
if conn_path is None or self._wifi_device is None:
cloudlog.warning(f"Failed to activate connection for {ssid}: conn_path={conn_path}, wifi_device={self._wifi_device}")
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
return
reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo',
(conn_path, self._wifi_device, "/")))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to activate connection for {ssid}: {reply}")
# TODO: expose a failed connection state in the UI
self._init_wifi_state()
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def _deactivate_connection(self, ssid: str):
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject'))
if reply.header.message_type == MessageType.error:
continue # object gone (e.g. rapid connect/disconnect)
specific_obj_path = reply.body[0][1]
if specific_obj_path != "/":
ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE)
ap_reply = self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid'))
if ap_reply.header.message_type == MessageType.error:
continue # AP gone (e.g. mode switch)
ap_ssid = bytes(ap_reply.body[0][1]).decode("utf-8", "replace")
if ap_ssid == ssid:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,)))
return
def is_tethering_active(self) -> bool:
# Check ssid, not connected_ssid, to also catch connecting state
return self._wifi_state.ssid == self._tethering_ssid
def is_connection_saved(self, ssid: str) -> bool:
return ssid in self._connections
def set_tethering_password(self, password: str):
def worker():
conn_path = self._connections.get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
return
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get tethering settings for {conn_path}')
return
settings['802-11-wireless-security']['psk'] = ('s', password)
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to update tethering settings: {reply}')
return
self._tethering_password = password
if self.is_tethering_active():
self.activate_connection(self._tethering_ssid, block=True)
threading.Thread(target=worker, daemon=True).start()
def _get_tethering_password(self) -> str:
conn_path = self._connections.get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
return ''
reply = self._router_main.send_and_get_reply(new_method_call(
DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE),
'GetSecrets', 's', ('802-11-wireless-security',)
))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to get tethering password: {reply}')
return ''
secrets = reply.body[0]
if '802-11-wireless-security' not in secrets:
return ''
return str(secrets['802-11-wireless-security'].get('psk', ('s', ''))[1])
def set_ipv4_forward(self, enabled: bool):
self._ipv4_forward = enabled
def set_tethering_active(self, active: bool):
def worker():
if active:
self.activate_connection(self._tethering_ssid, block=True)
if not self._ipv4_forward:
time.sleep(5)
cloudlog.warning("net.ipv4.ip_forward = 0")
subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False)
else:
self._deactivate_connection(self._tethering_ssid)
threading.Thread(target=worker, daemon=True).start()
def set_current_network_metered(self, metered: MeteredType):
def worker():
if self.is_tethering_active():
return
conn_path, _ = self._get_active_wifi_connection()
if conn_path is None:
cloudlog.warning('No active WiFi connection found')
return
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
return
settings['connection']['metered'] = ('i', int(metered))
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to update metered settings: {reply}')
threading.Thread(target=worker, daemon=True).start()
def _request_scan(self):
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
wifi_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_WIRELESS_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'RequestScan', 'a{sv}', ({},)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to request scan: {reply}")
def _update_networks(self, block: bool = True):
if not self._active:
return
def worker():
with self._scan_lock:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
# NOTE: AccessPoints property may exclude hidden APs (use GetAllAccessPoints method if needed)
wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE)
wifi_props_reply = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all())
if wifi_props_reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get WiFi properties: {wifi_props_reply}")
return
ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1]
aps: dict[str, list[AccessPoint]] = {}
for ap_path in ap_paths:
ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE)
ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all())
# some APs have been seen dropping off during iteration
if ap_props.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get AP properties for {ap_path}")
continue
try:
ap = AccessPoint.from_dbus(ap_props.body[0], ap_path)
if ap.ssid == "":
continue
if ap.ssid not in aps:
aps[ap.ssid] = []
aps[ap.ssid].append(ap)
except Exception:
# catch all for parsing errors
cloudlog.exception(f"Failed to parse AP properties for {ap_path}")
self._networks = [Network.from_dbus(ssid, ap_list, ssid == self._tethering_ssid) for ssid, ap_list in aps.items()]
self._update_active_connection_info()
self._enqueue_callbacks(self._networks_updated, self.networks) # sorted
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def _update_active_connection_info(self):
ipv4_address = ""
metered = MeteredType.UNKNOWN
conn_path, props = self._get_active_wifi_connection()
if conn_path is not None and props is not None:
# IPv4 address
ip4config_path = props.get('Ip4Config', ('o', '/'))[1]
if ip4config_path != "/":
ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE)
address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1]
for entry in address_data:
if 'address' in entry:
ipv4_address = entry['address'][1]
break
# Metered status
settings = self._get_connection_settings(conn_path)
if len(settings) > 0:
metered_prop = settings['connection'].get('metered', ('i', 0))[1]
if metered_prop == MeteredType.YES:
metered = MeteredType.YES
elif metered_prop == MeteredType.NO:
metered = MeteredType.NO
self._ipv4_address = ipv4_address
self._current_network_metered = metered
def __del__(self):
self.stop()
def stop(self):
if not self._exit:
self._exit = True
if self._scan_thread.is_alive():
self._scan_thread.join()
if self._state_thread.is_alive():
self._state_thread.join()
if self._router_main is not None:
self._router_main.close()
self._router_main.conn.close()
if self._conn_monitor is not None:
self._conn_monitor.close()
+107
View File
@@ -0,0 +1,107 @@
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
if not word:
return []
parts = []
remaining = word
while remaining:
if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
# Binary search for the longest substring that fits
left, right = 1, len(remaining)
best_fit = 1
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
left = mid + 1
else:
right = mid - 1
# Add the part that fits
parts.append(remaining[:best_fit])
remaining = remaining[best_fit:]
return parts
_cache: dict[int, list[str]] = {}
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
if not text or max_width <= 0:
return []
# Split text by newlines first to preserve explicit line breaks
paragraphs = text.split('\n')
all_lines: list[str] = []
for paragraph in paragraphs:
# Handle empty paragraphs (preserve empty lines)
if not paragraph.strip():
all_lines.append("")
continue
# Process each paragraph separately
words = paragraph.split()
if not words:
all_lines.append("")
continue
lines: list[str] = []
current_line: list[str] = []
for word in words:
word_width = measure_text_cached(font, word, font_size, spacing).x
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width, spacing))
continue
# Measure the actual joined string to get accurate width (accounts for kerning, etc.)
test_line = " ".join(current_line + [word]) if current_line else word
test_width = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
current_line.append(word)
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
# Add all lines from this paragraph
all_lines.extend(lines)
_cache[key] = all_lines
return all_lines