openpilot v0.11.1 release
date: 2026-06-04T09:49:56 master commit: c0ab3550eca2e9daf197c46b7e4b24aa9637cf2e
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user