mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-04 21:12:07 +08:00
UI
This commit is contained in:
+205
-218
@@ -1,6 +1,8 @@
|
||||
import atexit
|
||||
import cffi
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
@@ -11,7 +13,6 @@ import subprocess
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import Callable
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
@@ -40,6 +41,10 @@ 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
|
||||
@@ -51,9 +56,7 @@ if platform.system() == "Darwin":
|
||||
"""
|
||||
|
||||
BURN_IN_MODE = "BURN_IN" in os.environ
|
||||
BURN_IN_VERTEX_SHADER = (
|
||||
GL_VERSION
|
||||
+ """
|
||||
BURN_IN_VERTEX_SHADER = GL_VERSION + """
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
uniform mat4 mvp;
|
||||
@@ -63,10 +66,7 @@ void main() {
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
)
|
||||
BURN_IN_FRAGMENT_SHADER = (
|
||||
GL_VERSION
|
||||
+ """
|
||||
BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
|
||||
in vec2 fragTexCoord;
|
||||
uniform sampler2D texture0;
|
||||
out vec4 fragColor;
|
||||
@@ -82,7 +82,6 @@ void main() {
|
||||
fragColor = vec4(gradient, sampled.a);
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
DEFAULT_TEXT_SIZE = 60
|
||||
DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
|
||||
@@ -96,13 +95,10 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts")
|
||||
|
||||
|
||||
class FontWeight(StrEnum):
|
||||
LIGHT = "Inter-Light.fnt"
|
||||
NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
|
||||
MEDIUM = "Inter-Medium.fnt"
|
||||
BOLD = "Inter-Bold.fnt"
|
||||
SEMI_BOLD = "Inter-SemiBold.fnt"
|
||||
EXTRA_BOLD = "Inter-ExtraBold.fnt"
|
||||
BLACK = "Inter-Black.fnt"
|
||||
UNIFONT = "unifont.fnt"
|
||||
|
||||
# Small UI fonts
|
||||
@@ -118,12 +114,6 @@ def font_fallback(font: rl.Font) -> rl.Font:
|
||||
return font
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModalOverlay:
|
||||
overlay: object = None
|
||||
callback: Callable | None = None
|
||||
|
||||
|
||||
class MousePos(NamedTuple):
|
||||
x: float
|
||||
y: float
|
||||
@@ -179,6 +169,10 @@ class MouseState:
|
||||
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
|
||||
@@ -192,7 +186,8 @@ class MouseState:
|
||||
time.monotonic(),
|
||||
)
|
||||
# Only add changes
|
||||
if self._prev_mouse_event[slot] is None or ev[:-1] != self._prev_mouse_event[slot][:-1]:
|
||||
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
|
||||
@@ -200,6 +195,8 @@ class MouseState:
|
||||
|
||||
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()
|
||||
@@ -218,17 +215,17 @@ class GuiApplication:
|
||||
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._trace_log_callback = None
|
||||
self._progress_hook: Callable[[str], None] | None = None
|
||||
self._modal_overlay = ModalOverlay()
|
||||
self._modal_overlay_shown = False
|
||||
self._modal_overlay_tick: Callable[[], None] | None = None
|
||||
self._nav_stack: list = []
|
||||
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] = []
|
||||
@@ -255,6 +252,10 @@ class GuiApplication:
|
||||
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
|
||||
@@ -262,31 +263,14 @@ class GuiApplication:
|
||||
def request_close(self):
|
||||
self._window_close_requested = True
|
||||
|
||||
def set_progress_hook(self, hook: Callable[[str], None] | None):
|
||||
self._progress_hook = hook
|
||||
|
||||
def _mark_progress(self, phase: str):
|
||||
if self._progress_hook is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._progress_hook(phase)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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)
|
||||
|
||||
self._set_log_callback()
|
||||
rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING)
|
||||
|
||||
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
|
||||
if ENABLE_VSYNC:
|
||||
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
|
||||
@@ -298,44 +282,48 @@ class GuiApplication:
|
||||
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._width, self._height)
|
||||
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
|
||||
'-stats', # Show encoding progress
|
||||
'-f',
|
||||
'rawvideo', # Input format
|
||||
'-pix_fmt',
|
||||
'rgba', # Input pixel format
|
||||
'-s',
|
||||
f'{self._width}x{self._height}', # Input resolution
|
||||
'-r',
|
||||
str(fps), # Input frame rate
|
||||
'-i',
|
||||
'pipe:0', # Input from stdin
|
||||
'-vf',
|
||||
'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
|
||||
'-c:v',
|
||||
'libx264', # Video codec
|
||||
'-preset',
|
||||
'ultrafast', # Encoding speed
|
||||
'-y', # Overwrite existing file
|
||||
'-f',
|
||||
'mp4', # Output format
|
||||
RECORD_OUTPUT, # Output file path
|
||||
'-v', 'warning', # Reduce ffmpeg log spam
|
||||
'-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()
|
||||
|
||||
rl.set_target_fps(fps)
|
||||
# OFFSCREEN disables FPS limiting for fast offline rendering (e.g. clips)
|
||||
rl.set_target_fps(0 if OFFSCREEN 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)
|
||||
|
||||
@@ -372,93 +360,132 @@ class GuiApplication:
|
||||
print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}")
|
||||
sys.exit(0)
|
||||
|
||||
def set_modal_overlay(self, overlay, callback: Callable | None = None):
|
||||
if self._modal_overlay.overlay is not None:
|
||||
if hasattr(self._modal_overlay.overlay, 'hide_event'):
|
||||
self._modal_overlay.overlay.hide_event()
|
||||
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
|
||||
|
||||
if self._modal_overlay.callback is not None:
|
||||
self._modal_overlay.callback(-1)
|
||||
|
||||
self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback)
|
||||
|
||||
def set_modal_overlay_tick(self, tick_function: Callable | None):
|
||||
self._modal_overlay_tick = tick_function
|
||||
|
||||
def push_widget(self, widget):
|
||||
def push_widget(self, widget: object):
|
||||
if widget in self._nav_stack:
|
||||
cloudlog.warning("Widget already in stack, cannot push again!")
|
||||
return
|
||||
if self._nav_stack:
|
||||
prev = self._nav_stack[-1]
|
||||
if hasattr(prev, 'set_enabled'):
|
||||
prev.set_enabled(False)
|
||||
|
||||
# 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)
|
||||
if hasattr(widget, 'show_event'):
|
||||
widget.show_event()
|
||||
if hasattr(widget, 'set_enabled'):
|
||||
widget.set_enabled(True)
|
||||
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
|
||||
if idx_to_pop == len(self._nav_stack) - 1:
|
||||
prev = self._nav_stack[idx_to_pop - 1]
|
||||
if hasattr(prev, 'set_enabled'):
|
||||
prev.set_enabled(True)
|
||||
widget = self._nav_stack.pop(idx_to_pop)
|
||||
if hasattr(widget, 'hide_event'):
|
||||
widget.hide_event()
|
||||
|
||||
def _render_nav_stack(self) -> bool:
|
||||
if not self._nav_stack:
|
||||
return False
|
||||
widget = self._nav_stack[-1]
|
||||
if hasattr(widget, 'render'):
|
||||
widget.render(rl.Rectangle(0, 0, self.width, self.height))
|
||||
return True
|
||||
# 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):
|
||||
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
|
||||
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)
|
||||
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 starpilot_texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True):
|
||||
"""Load a texture from the StarPilot assets folder."""
|
||||
cache_key = f"starpilot_{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
|
||||
if cache_key in self._textures:
|
||||
return self._textures[cache_key]
|
||||
|
||||
starpilot_assets = files("openpilot.starpilot").joinpath("assets")
|
||||
with as_file(starpilot_assets.joinpath(asset_path)) as fspath:
|
||||
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
|
||||
texture_obj = self._load_texture_from_image(image_obj)
|
||||
self._textures[cache_key] = texture_obj
|
||||
return texture_obj
|
||||
|
||||
def _load_image_from_path(
|
||||
self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True
|
||||
) -> rl.Image:
|
||||
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 image.width == 0 or image.height == 0:
|
||||
return image
|
||||
|
||||
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
|
||||
|
||||
@@ -481,6 +508,10 @@ class GuiApplication:
|
||||
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:
|
||||
@@ -495,11 +526,17 @@ class GuiApplication:
|
||||
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=5)
|
||||
self._ffmpeg_proc.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._ffmpeg_proc.terminate()
|
||||
self._ffmpeg_proc.wait()
|
||||
@@ -539,17 +576,15 @@ class GuiApplication:
|
||||
def last_mouse_event(self) -> MouseEvent:
|
||||
return self._last_mouse_event
|
||||
|
||||
def render(self, render_callback: Callable[[], None] | None = None):
|
||||
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()):
|
||||
self._mark_progress("gui_app.loop_start")
|
||||
if PC:
|
||||
# Thread is not used on PC, need to manually add mouse events
|
||||
self._mouse._handle_mouse_event()
|
||||
@@ -561,7 +596,6 @@ class GuiApplication:
|
||||
|
||||
# Skip rendering when screen is off
|
||||
if not self._should_render:
|
||||
self._mark_progress("gui_app.skip_render")
|
||||
if PC:
|
||||
rl.poll_input_events()
|
||||
time.sleep(1 / self._target_fps)
|
||||
@@ -569,59 +603,43 @@ class GuiApplication:
|
||||
continue
|
||||
|
||||
if self._render_texture:
|
||||
self._mark_progress("gui_app.before_begin_texture_mode")
|
||||
rl.begin_texture_mode(self._render_texture)
|
||||
self._mark_progress("gui_app.after_begin_texture_mode")
|
||||
self._mark_progress("gui_app.before_clear_background")
|
||||
rl.clear_background(rl.BLACK)
|
||||
self._mark_progress("gui_app.after_clear_background")
|
||||
else:
|
||||
self._mark_progress("gui_app.before_begin_drawing")
|
||||
rl.begin_drawing()
|
||||
self._mark_progress("gui_app.after_begin_drawing")
|
||||
self._mark_progress("gui_app.before_clear_background")
|
||||
rl.clear_background(rl.BLACK)
|
||||
self._mark_progress("gui_app.after_clear_background")
|
||||
|
||||
# Handle modal overlay rendering and input processing
|
||||
if self._render_nav_stack():
|
||||
self._mark_progress("gui_app.nav_stack")
|
||||
yield False
|
||||
elif self._handle_modal_overlay():
|
||||
# Allow a Widget to still run a function while overlay is shown
|
||||
if self._modal_overlay_tick is not None:
|
||||
self._modal_overlay_tick()
|
||||
self._mark_progress("gui_app.modal_overlay")
|
||||
yield False
|
||||
else:
|
||||
self._mark_progress("gui_app.frame_ready")
|
||||
if render_callback is not None:
|
||||
self._mark_progress("gui_app.before_render_callback")
|
||||
render_callback()
|
||||
self._mark_progress("gui_app.after_render_callback")
|
||||
yield True
|
||||
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))
|
||||
|
||||
yield True
|
||||
|
||||
if self._scale != 1.0:
|
||||
rl.rl_pop_matrix()
|
||||
|
||||
if self._render_texture:
|
||||
self._mark_progress("gui_app.end_texture_mode")
|
||||
rl.end_texture_mode()
|
||||
self._mark_progress("gui_app.before_present_begin_drawing")
|
||||
rl.begin_drawing()
|
||||
self._mark_progress("gui_app.after_present_begin_drawing")
|
||||
self._mark_progress("gui_app.before_present_clear_background")
|
||||
rl.clear_background(rl.BLACK)
|
||||
self._mark_progress("gui_app.after_present_clear_background")
|
||||
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
|
||||
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:
|
||||
self._mark_progress("gui_app.before_present_draw_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)
|
||||
self._mark_progress("gui_app.after_present_draw_texture")
|
||||
|
||||
if self._show_fps:
|
||||
rl.draw_fps(10, 10)
|
||||
@@ -632,21 +650,17 @@ class GuiApplication:
|
||||
if self._grid_size > 0:
|
||||
self._draw_grid()
|
||||
|
||||
self._mark_progress("gui_app.end_drawing")
|
||||
rl.end_drawing()
|
||||
self._mark_progress("gui_app.after_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_proc.stdin.write(data)
|
||||
self._ffmpeg_proc.stdin.flush()
|
||||
self._ffmpeg_queue.put(data) # Async write via background thread
|
||||
rl.unload_image(image)
|
||||
|
||||
self._monitor_fps()
|
||||
self._frame += 1
|
||||
self._mark_progress("gui_app.frame_complete")
|
||||
|
||||
if self._profile_render_frames > 0 and self._frame >= self._profile_render_frames:
|
||||
self._output_render_profile()
|
||||
@@ -664,61 +678,17 @@ class GuiApplication:
|
||||
def height(self):
|
||||
return self._height
|
||||
|
||||
def _handle_modal_overlay(self) -> bool:
|
||||
if self._modal_overlay.overlay:
|
||||
if hasattr(self._modal_overlay.overlay, 'render'):
|
||||
result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height))
|
||||
elif callable(self._modal_overlay.overlay):
|
||||
result = self._modal_overlay.overlay()
|
||||
else:
|
||||
raise Exception
|
||||
|
||||
# Send show event to Widget
|
||||
if not self._modal_overlay_shown and hasattr(self._modal_overlay.overlay, 'show_event'):
|
||||
self._modal_overlay.overlay.show_event()
|
||||
self._modal_overlay_shown = True
|
||||
|
||||
if result >= 0:
|
||||
# Clear the overlay and execute the callback
|
||||
original_modal = self._modal_overlay
|
||||
self._modal_overlay = ModalOverlay()
|
||||
if hasattr(original_modal.overlay, 'hide_event'):
|
||||
original_modal.overlay.hide_event()
|
||||
if original_modal.callback is not None:
|
||||
original_modal.callback(result)
|
||||
return True
|
||||
else:
|
||||
self._modal_overlay_shown = False
|
||||
return False
|
||||
|
||||
def _load_fonts(self):
|
||||
self._ensure_font_atlases()
|
||||
with as_file(FONT_DIR) as fspath:
|
||||
for font_weight_file in FontWeight:
|
||||
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())
|
||||
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
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 _ensure_font_atlases(self):
|
||||
with as_file(FONT_DIR) as fspath:
|
||||
required_fonts = [fspath / fw.value for fw in FontWeight]
|
||||
missing_fonts = [font_path.name for font_path in required_fonts if not font_path.exists()]
|
||||
if not missing_fonts:
|
||||
return
|
||||
|
||||
process_script = fspath / "process.py"
|
||||
if not process_script.exists():
|
||||
cloudlog.warning(f"Missing font atlases {missing_fonts}, but no generator found at {process_script}")
|
||||
return
|
||||
|
||||
cloudlog.warning(f"Generating missing font atlases: {missing_fonts}")
|
||||
try:
|
||||
subprocess.run([sys.executable, process_script.as_posix()], check=True, cwd=fspath.as_posix())
|
||||
except Exception:
|
||||
cloudlog.exception("Failed to generate font atlases")
|
||||
|
||||
def _set_styles(self):
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0)
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE)
|
||||
@@ -737,6 +707,20 @@ class GuiApplication:
|
||||
|
||||
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("""
|
||||
@@ -773,6 +757,9 @@ class GuiApplication:
|
||||
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)
|
||||
@@ -842,11 +829,11 @@ class GuiApplication:
|
||||
green = "\033[92m"
|
||||
reset = "\033[0m"
|
||||
print(f"\n{green}Rendered {self._frame} frames in {elapsed_ms:.1f} ms{reset}")
|
||||
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000 / avg_frame_time:.1f} FPS){reset}")
|
||||
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000/avg_frame_time:.1f} FPS){reset}")
|
||||
sys.exit(0)
|
||||
|
||||
def _calculate_auto_scale(self) -> float:
|
||||
# Create temporary window to query monitor info
|
||||
# Create temporary window to query monitor info
|
||||
rl.init_window(1, 1, "")
|
||||
w, h = rl.get_monitor_width(0), rl.get_monitor_height(0)
|
||||
rl.close_window()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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
|
||||
|
||||
_emoji_font: ImageFont.FreeTypeFont | None = None
|
||||
_cache: dict[str, rl.Texture] = {}
|
||||
|
||||
EMOJI_REGEX = re.compile(
|
||||
@@ -33,11 +34,10 @@ EMOJI_REGEX = re.compile(
|
||||
flags=re.UNICODE
|
||||
)
|
||||
|
||||
def _load_emoji_font() -> ImageFont.FreeTypeFont | None:
|
||||
global _emoji_font
|
||||
if _emoji_font is None:
|
||||
_emoji_font = ImageFont.truetype(str(FONT_DIR.joinpath("NotoColorEmoji.ttf")), 109)
|
||||
return _emoji_font
|
||||
@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)]
|
||||
|
||||
+141
-16
@@ -1,7 +1,7 @@
|
||||
from importlib.resources import files
|
||||
import os
|
||||
import json
|
||||
import gettext
|
||||
import os
|
||||
import re
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
@@ -16,7 +16,6 @@ TRANSLATIONS_DIR = UI_DIR.joinpath("translations")
|
||||
LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json")
|
||||
|
||||
UNIFONT_LANGUAGES = [
|
||||
"ar",
|
||||
"th",
|
||||
"zh-CHT",
|
||||
"zh-CHS",
|
||||
@@ -24,14 +23,137 @@ UNIFONT_LANGUAGES = [
|
||||
"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 = {}
|
||||
self.codes = {}
|
||||
self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations()
|
||||
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
|
||||
@@ -44,27 +166,30 @@ class Multilang:
|
||||
|
||||
def setup(self):
|
||||
try:
|
||||
with TRANSLATIONS_DIR.joinpath(f'app_{self._language}.mo').open('rb') as fh:
|
||||
translation = gettext.GNUTranslations(fh)
|
||||
translation.install()
|
||||
self._translation = translation
|
||||
cloudlog.warning(f"Loaded translations for language: {self._language}")
|
||||
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.")
|
||||
gettext.install('app')
|
||||
self._translation = gettext.NullTranslations()
|
||||
self._translations = {}
|
||||
self._plurals = {}
|
||||
|
||||
def change_language(self, language_code: str) -> None:
|
||||
# Reinstall gettext with the selected language
|
||||
self._params.put("LanguageSetting", language_code)
|
||||
self._language = language_code
|
||||
self.setup()
|
||||
|
||||
def tr(self, text: str) -> str:
|
||||
return self._translation.gettext(text)
|
||||
return self._translations.get(text, text) or text
|
||||
|
||||
def trn(self, singular: str, plural: str, n: int) -> str:
|
||||
return self._translation.ngettext(singular, plural, n)
|
||||
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:
|
||||
|
||||
@@ -3,14 +3,34 @@ 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
|
||||
STATE_CONFIG = 50
|
||||
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
|
||||
@@ -29,8 +49,6 @@ NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
|
||||
|
||||
NM_DEVICE_TYPE_WIFI = 2
|
||||
NM_DEVICE_TYPE_MODEM = 8
|
||||
NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
|
||||
NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60
|
||||
|
||||
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags
|
||||
NM_802_11_AP_FLAGS_NONE = 0x0
|
||||
|
||||
@@ -20,6 +20,21 @@ 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
|
||||
@@ -73,8 +88,14 @@ class GuiScrollPanel2:
|
||||
|
||||
def _update_state(self, bounds_size: float, content_size: float) -> None:
|
||||
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
|
||||
if self._state == ScrollState.AUTO_SCROLL:
|
||||
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
|
||||
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 = self.get_offset() > max_offset or self.get_offset() < min_offset
|
||||
if out_of_bounds and self._handle_out_of_bounds:
|
||||
@@ -145,7 +166,13 @@ class GuiScrollPanel2:
|
||||
# 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
|
||||
# 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
|
||||
@@ -160,6 +187,8 @@ class GuiScrollPanel2:
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
+732
-564
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user