1
0
forked from IQ.Lvbs/IQ.Pilot

IQ.Pilot Prebuilt Release @ ab07000

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:42 -05:00
commit 9f9c9a70cc
3729 changed files with 778697 additions and 0 deletions

View File

1116
system/ui/lib/application.py Normal file

File diff suppressed because it is too large Load Diff

204
system/ui/lib/egl.py Normal file
View File

@@ -0,0 +1,204 @@
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
swap_interval: Any = None
bind_texture: Any = None
active_texture: Any = None
flush: Any = None
finish: 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);
EGLBoolean eglSwapInterval(EGLDisplay dpy, EGLint interval);
void glEGLImageTargetTexture2DOES(GLenum target, GLeglImageOES image);
void glBindTexture(GLenum target, unsigned int texture);
void glActiveTexture(GLenum texture);
void glFlush(void);
void glFinish(void);
""")
# 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.swap_interval = _egl.egl_lib.eglSwapInterval
_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
_egl.flush = _egl.gles_lib.glFlush
_egl.finish = _egl.gles_lib.glFinish
# 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)
_egl.flush()
def set_swap_interval(interval: int) -> bool:
if not _egl.initialized and not init_egl():
return False
return bool(_egl.swap_interval(_egl.display, max(0, int(interval))))
def finish_gl() -> bool:
if not _egl.initialized and not init_egl():
return False
_egl.finish()
return True

67
system/ui/lib/emoji.py Normal file
View File

@@ -0,0 +1,67 @@
import io
import re
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(
"""[\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
)
_emoji_font_loaded = False
def _load_emoji_font() -> ImageFont.FreeTypeFont | None:
global _emoji_font, _emoji_font_loaded
if not _emoji_font_loaded:
_emoji_font_loaded = True
try:
# FONT_DIR is an importlib.resources path. Inside the setup zipapp it points into the archive,
# so str() yields a path through the .zip that PIL can't open ("cannot open resource"). Read
# the bytes and hand PIL a file object so it works both on disk and inside the zipapp.
_emoji_font = ImageFont.truetype(io.BytesIO(FONT_DIR.joinpath("NotoColorEmoji.ttf").read_bytes()), 109)
except Exception:
_emoji_font = None # never crash the whole UI over an emoji glyph
return _emoji_font
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:
font = _load_emoji_font()
if font is None:
return None
img = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((0, 0), emoji, font=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.get(emoji)

View File

@@ -0,0 +1,88 @@
from importlib.resources import files
import os
import json
import gettext
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 = [
"ar",
"th",
"zh-CHT",
"zh-CHS",
"ko",
"ja",
]
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._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:
with TRANSLATIONS_DIR.joinpath(f'app_{self._language}.mo').open('rb') as fh:
translation = gettext.GNUTranslations(fh)
translation.install()
self._translation = translation
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()
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)
def trn(self, singular: str, plural: str, n: int) -> str:
return self._translation.ngettext(singular, plural, n)
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

View File

@@ -0,0 +1,47 @@
from enum import IntEnum
# NetworkManager device states
class NMDeviceState(IntEnum):
UNKNOWN = 0
DISCONNECTED = 30
PREPARE = 40
STATE_CONFIG = 50
NEED_AUTH = 60
IP_CONFIG = 70
ACTIVATED = 100
DEACTIVATING = 110
# 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
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
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
NM_802_11_AP_SEC_KEY_MGMT_SAE = 0x00000400 # WPA3-Personal (SAE)

132
system/ui/lib/os_update.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
IQ.OS compatibility check + in-place AGNOS update for the setup flow.
A chosen IQ.Pilot channel may target a newer IQ.OS than the device is running
(its cloned tree pins the required version in launch_env.sh AGNOS_VERSION). When
that differs from the running /VERSION, the setup flow flashes the target IQ.OS
via comma's own agnos.py BEFORE writing continue.sh, so the single reboot lands
on a compatible OS. The risky flashing is delegated entirely to agnos.py; this
module only reads versions, picks the right manifest, and streams coarse
progress.
"""
import json
import os
import re
import subprocess
import threading
from typing import Callable
VERSION_PATH = "/VERSION"
def current_os_version() -> str:
try:
with open(VERSION_PATH) as f:
return f.read().strip()
except Exception:
return ""
def required_agnos_version(install_path: str) -> str:
"""Read the target OS version the cloned fork pins in launch_env.sh."""
path = os.path.join(install_path, "launch_env.sh")
try:
with open(path) as f:
for line in f:
m = re.search(r'AGNOS_VERSION\s*=\s*"([^"]+)"', line)
if m:
return m.group(1).strip()
except Exception:
pass
return ""
def agnos_manifest_path(install_path: str, device_type: str) -> str:
# comma 3 (tici) uses a different AGNOS manifest than comma 3x (tizi) / comma 4 (mici).
fname = "agnos_tici_15_1.json" if device_type == "tici" else "agnos.json"
return os.path.join(install_path, "system", "hardware", "tici", fname)
def os_update_needed(install_path: str) -> tuple[bool, str, str]:
"""Returns (needed, current, required)."""
current = current_os_version()
required = required_agnos_version(install_path)
needed = bool(required and current and required != current)
return needed, current, required
ProgressCb = Callable[[int, str], None]
def run_agnos_update(install_path: str, device_type: str, progress_cb: ProgressCb) -> bool:
"""Flash + swap to the target IQ.OS. Streams coarse partition-level progress
via progress_cb(percent, note). Returns True on success. The device must be
rebooted by the caller afterward for the new slot to take effect."""
manifest = agnos_manifest_path(install_path, device_type)
agnos_py = os.path.join(install_path, "system", "hardware", "tici", "agnos.py")
if not os.path.isfile(manifest) or not os.path.isfile(agnos_py):
progress_cb(0, "manifest_missing")
return False
try:
total_partitions = max(1, len(json.load(open(manifest))))
except Exception:
total_partitions = 1
progress_cb(1, "starting")
try:
proc = subprocess.Popen(
["python3", agnos_py, "--swap", manifest],
cwd=install_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env={**os.environ, "PYTHONPATH": install_path},
)
except Exception:
progress_cb(0, "launch_failed")
return False
completed = 0
swapping = False
assert proc.stdout is not None
for line in proc.stdout:
line = line.strip()
if "Downloading and writing" in line or "Already flashed" in line:
completed += 1
pct = min(94, int((completed / total_partitions) * 90) + 2)
progress_cb(pct, "flashing")
elif "Swapping to slot" in line or "AGNOS ready" in line:
swapping = True
progress_cb(96, "swapping")
proc.wait()
if proc.returncode == 0:
progress_cb(100, "done")
return True
progress_cb(0, "failed" if not swapping else "swap_failed")
return False
class OsUpdateCoordinator:
"""Bridges the setup UI's install thread and the BLE confirmation from the app.
The install thread posts a required-update, waits for the phone's confirm, then
runs the flash. On-screen setup can confirm locally too."""
def __init__(self):
self.confirmed = threading.Event()
self.needed = False
self.current = ""
self.required = ""
def request(self, current: str, required: str) -> None:
self.needed = True
self.current = current
self.required = required
self.confirmed.clear()
def confirm(self) -> None:
self.confirmed.set()
def wait_for_confirm(self, timeout: float) -> bool:
return self.confirmed.wait(timeout=timeout)

View File

@@ -0,0 +1,34 @@
"""Compatibility wrappers for raylib API differences between wheel generations.
PC dev environments install the pypi raylib 5.5 wheel, while devices ship a wheel
built from comma's raylib fork, which carries newer upstream API changes:
- DrawCircleGradient takes a Vector2 center instead of int x/y
- DrawRectangleGradientEx swapped its two right-corner color parameters
(old: topLeft, bottomLeft, topRight, bottomRight
new: topLeft, bottomLeft, bottomRight, topRight)
- LoadFontData grew an int *glyphCount out-param and returns a compacted array
(handled where it's used, in selfdrive/assets/fonts/process.py)
The wheel is a single snapshot, so one signature identifies the generation for all
of them. Detect it from the C function type via cffi rather than hardcoding either
variant — the pyray wrappers are *args shims and can't be introspected directly.
"""
import pyray as rl
import raylib as _raylib
_NEW_API = _raylib.ffi.typeof(_raylib.rl.DrawCircleGradient).args[0].cname != "int"
def draw_circle_gradient(center_x: float, center_y: float, radius: float, inner, outer) -> None:
if _NEW_API:
rl.draw_circle_gradient(rl.Vector2(center_x, center_y), radius, inner, outer)
else:
rl.draw_circle_gradient(int(center_x), int(center_y), radius, inner, outer)
def draw_rectangle_gradient_ex(rec, top_left, bottom_left, top_right, bottom_right) -> None:
if _NEW_API:
rl.draw_rectangle_gradient_ex(rec, top_left, bottom_left, bottom_right, top_right)
else:
rl.draw_rectangle_gradient_ex(rec, top_left, bottom_left, top_right, bottom_right)

View File

@@ -0,0 +1,293 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from __future__ import annotations
import math
import os
import queue
import shutil
import subprocess
import threading
import time
import pyray as rl
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.hw import Paths
PARAM_KEY = "ScreenRecording"
PARAM_POLL_INTERVAL = 0.5 # seconds between param checks
CAPTURE_FPS = int(os.getenv("SCREEN_RECORD_FPS", "15"))
MAX_DURATION_S = int(os.getenv("SCREEN_RECORD_MAX_S", str(10 * 60))) # auto-stop safety
MIN_FREE_DISK_BYTES = 2 * 1024**3 # refuse to record with < 2GB free
QUEUE_MAX_FRAMES = 30 # ~2s of frames before we start dropping
MIN_VALID_OUTPUT_BYTES = 8 * 1024
CRF = os.getenv("SCREEN_RECORD_CRF", "28")
MAX_BITRATE = os.getenv("SCREEN_RECORD_MAXRATE", "2M")
# capture at half res above this width (BIG UI 2160 -> 1080), native below (mici 536)
DOWNSCALE_THRESHOLD = 1200
# Skip capture while the UI frame time is above this multiple of target: the readback
# (blocking glReadPixels) and extra present-blit stall the render thread, so back off and
# let the UI recover rather than dragging its framerate down. The recorder self-throttles.
BACKOFF_FRAME_TIME_RATIO = float(os.getenv("SCREEN_RECORD_BACKOFF", "1.35"))
class ScreenRecorder:
def __init__(self, width: int, height: int, target_fps: int):
self._width = width
self._height = height
scale = 2 if width > DOWNSCALE_THRESHOLD else 1
# yuv420 needs even dimensions
self._rec_width = (width // scale) & ~1
self._rec_height = (height // scale) & ~1
self._target_fps = max(1, target_fps)
self._capture_fps = max(1, min(CAPTURE_FPS, target_fps))
self._frame_interval = max(1, round(target_fps / self._capture_fps))
self._target_frame_time = 1.0 / self._target_fps
self._params = Params()
self._active = False
self._owns_rt = False
self._rt: rl.RenderTexture | None = None
self._small_rt: rl.RenderTexture | None = None
self._proc: subprocess.Popen | None = None
self._queue: queue.Queue[bytes | None] | None = None
self._writer: threading.Thread | None = None
self._finalizers: list[threading.Thread] = []
self._frame_idx = 0
self._dropped = 0
self._backoff_skips = 0
self._start_time = 0.0
self._last_poll = 0.0
self._out_path = ""
# after a self-initiated stop, don't restart until the param is observed False
self._await_param_clear = False
# adaptive backoff: EMA of the render-loop frame time
self._last_frame_t = 0.0
self._frame_time_ema = 0.0
self._capture_this_frame = False
@property
def active(self) -> bool:
return self._active
@property
def render_texture(self) -> rl.RenderTexture | None:
return self._rt
def begin_frame(self) -> bool:
self._capture_this_frame = False
if not self._active:
return False
now = time.monotonic()
if self._last_frame_t:
dt = now - self._last_frame_t
self._frame_time_ema = dt if self._frame_time_ema == 0.0 else 0.85 * self._frame_time_ema + 0.15 * dt
self._last_frame_t = now
self._frame_idx += 1
if self._frame_idx % self._frame_interval != 0:
return False
if self._frame_time_ema > self._target_frame_time * BACKOFF_FRAME_TIME_RATIO:
self._backoff_skips += 1
if self._backoff_skips % 200 == 1:
cloudlog.warning(f"screen_recorder: UI busy, backing off capture ({self._backoff_skips} skips)")
return False
self._capture_this_frame = True
return True
def update(self, app_render_texture: rl.RenderTexture | None) -> None:
"""Called every frame from the render thread. Handles start/stop and safety limits."""
now = time.monotonic()
if self._active:
# encoder died (e.g. broken pipe) or hit the duration cap
if self._proc is not None and self._proc.poll() is not None:
cloudlog.error(f"screen_recorder: encoder exited unexpectedly rc={self._proc.returncode}")
self._stop(clear_param=True)
elif now - self._start_time > MAX_DURATION_S:
cloudlog.warning("screen_recorder: max duration reached, auto-stopping")
self._stop(clear_param=True)
if now - self._last_poll < PARAM_POLL_INTERVAL:
return
self._last_poll = now
want = self._params.get_bool(PARAM_KEY)
if self._await_param_clear:
if want:
return
self._await_param_clear = False
if want and not self._active:
self._start(app_render_texture)
elif not want and self._active:
self._stop(clear_param=False)
def _start(self, app_render_texture: rl.RenderTexture | None) -> None:
root = Paths.screen_recordings_root()
try:
os.makedirs(root, exist_ok=True)
if shutil.disk_usage(root).free < MIN_FREE_DISK_BYTES:
cloudlog.warning("screen_recorder: not enough free disk space, refusing to record")
self._params.put_bool_nonblocking(PARAM_KEY, False)
return
except OSError as e:
cloudlog.exception(f"screen_recorder: cannot prepare output dir: {e}")
self._params.put_bool_nonblocking(PARAM_KEY, False)
return
if app_render_texture is not None:
self._rt = app_render_texture
self._owns_rt = False
else:
self._rt = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._rt.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._owns_rt = True
self._small_rt = rl.load_render_texture(self._rec_width, self._rec_height)
self._out_path = os.path.join(root, time.strftime("screen_recording_%Y-%m-%d_%H-%M-%S.mp4"))
args = [
'ffmpeg', '-v', 'error', '-nostats',
'-f', 'rawvideo', '-pix_fmt', 'rgba',
'-s', f'{self._rec_width}x{self._rec_height}',
'-r', str(self._capture_fps), '-i', 'pipe:0',
'-vf', 'vflip,format=yuv420p',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-crf', CRF, '-maxrate', MAX_BITRATE, '-bufsize', '4M',
'-g', str(self._capture_fps * 2),
'-threads', '2',
# fragmented mp4: file stays playable even if we die before a clean stop
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
'-y', '-f', 'mp4', self._out_path,
]
try:
self._proc = subprocess.Popen(args, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=lambda: os.nice(15))
except OSError as e:
cloudlog.exception(f"screen_recorder: failed to start ffmpeg: {e}")
self._release_textures()
self._params.put_bool_nonblocking(PARAM_KEY, False)
return
self._queue = queue.Queue(maxsize=QUEUE_MAX_FRAMES)
self._writer = threading.Thread(target=self._writer_thread, args=(self._proc, self._queue), daemon=True)
self._writer.start()
self._frame_idx = 0
self._dropped = 0
self._backoff_skips = 0
self._last_frame_t = 0.0
self._frame_time_ema = 0.0
self._start_time = time.monotonic()
self._active = True
cloudlog.event("screen_recorder: started", path=self._out_path,
size=f"{self._rec_width}x{self._rec_height}", fps=self._capture_fps)
def _stop(self, clear_param: bool) -> None:
self._active = False
proc, q, writer = self._proc, self._queue, self._writer
self._proc, self._queue, self._writer = None, None, None
self._release_textures()
if clear_param:
self._params.put_bool_nonblocking(PARAM_KEY, False)
self._await_param_clear = True
# finalize off the render thread; ffmpeg needs a clean stdin close to flush
out_path = self._out_path
def _finalize():
try:
if q is not None:
q.put(None)
if writer is not None:
writer.join(timeout=10)
if proc is not None:
if proc.stdin is not None:
try:
proc.stdin.close()
except OSError:
pass
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=5)
try:
if out_path and os.path.getsize(out_path) < MIN_VALID_OUTPUT_BYTES:
os.remove(out_path)
cloudlog.warning(f"screen_recorder: discarded truncated output {out_path}")
except OSError:
pass
except Exception:
cloudlog.exception("screen_recorder: finalize failed")
t = threading.Thread(target=_finalize, daemon=True)
t.start()
self._finalizers = [f for f in self._finalizers if f.is_alive()] + [t]
cloudlog.event("screen_recorder: stopped", path=self._out_path, dropped=self._dropped)
def _release_textures(self) -> None:
# render thread only
if self._small_rt is not None:
rl.unload_render_texture(self._small_rt)
self._small_rt = None
if self._rt is not None and self._owns_rt:
rl.unload_render_texture(self._rt)
self._rt = None
self._owns_rt = False
def capture(self, frame_rt: rl.RenderTexture) -> None:
if not self._capture_this_frame or self._small_rt is None or self._queue is None:
return
if self._queue.full():
self._dropped += 1
if self._dropped % 100 == 1:
cloudlog.warning(f"screen_recorder: encoder falling behind, dropped {self._dropped} frames")
return
src = rl.Rectangle(0, 0, float(frame_rt.texture.width), -float(frame_rt.texture.height))
dst = rl.Rectangle(0, 0, float(self._rec_width), float(self._rec_height))
rl.begin_texture_mode(self._small_rt)
rl.draw_texture_pro(frame_rt.texture, src, dst, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_texture_mode()
image = rl.load_image_from_texture(self._small_rt.texture)
try:
data = bytes(rl.ffi.buffer(image.data, self._rec_width * self._rec_height * 4))
finally:
rl.unload_image(image)
try:
self._queue.put_nowait(data)
except queue.Full:
self._dropped += 1
def draw_indicator(self, width: int) -> None:
"""Pulsing red REC dot, drawn into the frame so it shows on screen and in the video."""
big = width > DOWNSCALE_THRESHOLD
radius = 14 if big else 7
margin = (28 if big else 14) + radius
alpha = int(155 + 100 * math.sin(time.monotonic() * 4.0))
rl.draw_circle(width - margin, margin, float(radius), rl.Color(255, 60, 60, alpha))
@staticmethod
def _writer_thread(proc: subprocess.Popen, q: queue.Queue[bytes | None]) -> None:
while True:
data = q.get()
if data is None:
break
try:
proc.stdin.write(data)
except (BrokenPipeError, OSError):
break
def close(self) -> None:
if self._active:
self._stop(clear_param=False)
for t in self._finalizers:
t.join(timeout=15)

View File

@@ -0,0 +1,136 @@
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)
# wheel scrolls this panel only while the cursor sits inside its bounds
wheel = rl.get_mouse_wheel_move()
if wheel and rl.check_collision_point_rec(rl.get_mouse_position(), bounds):
self._offset_filter_y.x += wheel * 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)

View File

@@ -0,0 +1,230 @@
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
REJECT_DECELERATION_FACTOR = 3
MAX_SPEED = 10000.0 # px/s
DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
def weighted_velocity(buffer: deque[float]) -> float:
if len(buffer) >= 3:
return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05
if len(buffer) == 2:
return buffer[-2] * 0.7 + buffer[-1] * 0.3
if 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, handle_out_of_bounds: bool = True) -> None:
self._horizontal = horizontal
self._handle_out_of_bounds = handle_out_of_bounds
self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC
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) -> 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)
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) -> 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)
# 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:
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
alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt))
self._velocity *= alpha
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:
high_decel = False
if len(self._velocity_buffer) > 2:
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)
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)

744
system/ui/lib/setup_ble.py Normal file
View File

@@ -0,0 +1,744 @@
#!/usr/bin/env python3
"""
Konn3kt BLE Setup transport (Phase A) — runs inside the setup zipapp, before any
IQ.Pilot install. Advertises the device on the setup screen so the konn3kt app
can drive Wi-Fi + install over Bluetooth. Self-contained (the compiled
ble-transportd bundle lives on the wiped /data and is unavailable here); uses the
AGNOS system python's gi/BlueZ D-Bus, mirroring the settings transport's GATT +
fragmentation + auth so the app can share client code.
See konn3kt_private/docs/konn3kt_ble_setup_protocol.md for the wire contract.
"""
import hashlib
import hmac
import json
import math
import os
import re
import secrets
import struct
import subprocess
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable
def _import_gi():
# In setup mode /data is wiped, so the installed system's gi symlink
# (/data/openpilot/gi) is gone and the venv has no gi of its own. The real gi
# dist-package + GI typelibs live in the rootfs and survive reset — make them
# importable before falling over. Works unchanged on an installed device too.
import sys
try:
import gi # noqa: F401
except Exception:
for extra in ("/usr/lib/python3/dist-packages", "/usr/lib/python3.12/dist-packages"):
import os
if os.path.isdir(os.path.join(extra, "gi")) and extra not in sys.path:
sys.path.append(extra)
import gi # noqa: F401
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
return gi, Gio, GLib
try:
gi, Gio, GLib = _import_gi()
_GI_AVAILABLE = True
except Exception:
_GI_AVAILABLE = False
BLUEZ_SERVICE = "org.bluez"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
GATT_MANAGER_IFACE = "org.bluez.GattManager1"
LE_ADV_MANAGER_IFACE = "org.bluez.LEAdvertisingManager1"
GATT_SERVICE_IFACE = "org.bluez.GattService1"
GATT_CHRC_IFACE = "org.bluez.GattCharacteristic1"
LE_ADV_IFACE = "org.bluez.LEAdvertisement1"
DEVICE_IFACE = "org.bluez.Device1"
ADAPTER_IFACE = "org.bluez.Adapter1"
SETUP_SERVICE_UUID = "73f2c700-5e40-4d0d-8b7f-fde61f729100"
SETUP_CONTROL_CHAR_UUID = "73f2c701-5e40-4d0d-8b7f-fde61f729100"
SETUP_REQUEST_CHAR_UUID = "73f2c702-5e40-4d0d-8b7f-fde61f729100"
SETUP_RESPONSE_CHAR_UUID = "73f2c703-5e40-4d0d-8b7f-fde61f729100"
# 16-bit ServiceData UUID for the advertisement — a 128-bit service UUID PLUS
# 128-bit service data overflows the 31-byte legacy adv budget, so the setupId
# rides in a compact 16-bit ServiceData AD instead (the full 128-bit service is
# still the GATT service, discovered after connect). BlueZ expands "fe01" to the
# Bluetooth base UUID; the app reads it as 0000fe01-0000-1000-8000-00805f9b34fb.
SETUP_SERVICE_DATA_UUID = "0000fe01-0000-1000-8000-00805f9b34fb"
PROTOCOL_VERSION = 1
FRAME_HEADER = struct.Struct(">IHH")
MAX_FRAGMENT_PAYLOAD = 180
ADAPTER_WAIT_TIMEOUT_S = 8.0
BLUEZ_REGISTER_TIMEOUT_MS = 15000
SESSION_IDLE_TIMEOUT_S = 120
SEQ_REPLAY_WINDOW = 128
HELLO_MAX_SKEW_MS = 300_000
MAX_AUTH_FAILURES = 5
AUTH_LOCKOUT_S = 30.0
def setup_id_for_serial(serial: str) -> str:
return hashlib.sha256(f"k3setup:{serial}".encode("utf-8")).hexdigest()[:16]
def _derive_setup_bdaddr(serial: str) -> str:
raw = bytearray(hashlib.sha256(f"konn3kt-bdaddr:{serial}".encode("utf-8")).digest()[:6])
raw[0] = (raw[0] | 0x02) & 0xFE
return ":".join(f"{x:02X}" for x in raw)
def _json_safe(value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return value
def _canonical(value: Any) -> bytes:
return json.dumps(_json_safe(value), ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def _safe_json(data: dict[str, Any]) -> bytes:
return _canonical(data)
def _hmac_hex(key: bytes, payload: bytes) -> str:
return hmac.new(key, payload, hashlib.sha256).hexdigest()
class SetupAuthError(Exception):
pass
# ---------------------------------------------------------------------------
# Session / auth (6-digit code, mirrors settings-transport HKDF + replay window)
# ---------------------------------------------------------------------------
@dataclass
class SetupSession:
client_id: str
setup_id: str
client_nonce: str
device_nonce: str
session_id: str
authenticated: bool = False
session_key: bytes = b""
last_seen: float = 0.0
highest_seq: int = 0
seen_seq_mask: int = 0
class SetupSessionManager:
def __init__(self, setup_id: str, code_getter: Callable[[], str]):
self._setup_id = setup_id
self._code_getter = code_getter
self._sessions: dict[str, SetupSession] = {}
self._auth_failures = 0
self._lockout_until = 0.0
self._lock = threading.Lock()
def prune(self) -> None:
now = time.monotonic()
with self._lock:
for cid in [c for c, s in self._sessions.items() if (now - s.last_seen) > SESSION_IDLE_TIMEOUT_S]:
self._sessions.pop(cid, None)
def begin_hello(self, client_id: str, setup_id: str, client_nonce: str, timestamp_ms: int) -> dict[str, Any]:
if setup_id != self._setup_id:
raise SetupAuthError("setup_id_mismatch")
now_ms = int(time.time() * 1000)
# NOTE: deliberately NO timestamp-skew check here. A freshly-reset device has
# no network, so its clock is arbitrarily wrong (often weeks off) — a skew
# check would reject every real setup. The 6-digit on-screen code is the
# actual authorization and the per-session nonce prevents replay, so the
# timestamp is informational only.
session = SetupSession(
client_id=client_id,
setup_id=self._setup_id,
client_nonce=str(client_nonce),
device_nonce=secrets.token_hex(8),
session_id=secrets.token_hex(8),
last_seen=time.monotonic(),
)
with self._lock:
self._sessions[client_id] = session
return {
"type": "helloAck",
"protocolVersion": PROTOCOL_VERSION,
"setupId": self._setup_id,
"deviceNonce": session.device_nonce,
"sessionId": session.session_id,
"codeRequired": True,
"timestampMs": now_ms,
}
def _session_key(self, session: SetupSession, code: str) -> bytes:
salt = f"{session.client_nonce}:{session.device_nonce}:{session.session_id}:{self._setup_id}".encode("utf-8")
prk = hmac.new(salt, f"k3setup:{code}".encode("utf-8"), hashlib.sha256).digest()
return hmac.new(prk, b"konn3kt-ble-setup-v1\x01", hashlib.sha256).digest()
def authenticate(self, client_id: str, session_id: str, timestamp_ms: int, proof_hex: str) -> dict[str, Any]:
if time.monotonic() < self._lockout_until:
raise SetupAuthError("auth_locked_out")
session = self._sessions.get(client_id)
if session is None or session.session_id != session_id:
raise SetupAuthError("unknown_session")
code = str(self._code_getter() or "")
key = self._session_key(session, code)
expected = _hmac_hex(key, _canonical({"role": "client-auth", "sessionId": session_id, "timestampMs": int(timestamp_ms)}))
if not hmac.compare_digest(expected, str(proof_hex or "").strip().lower()):
self._auth_failures += 1
if self._auth_failures >= MAX_AUTH_FAILURES:
self._lockout_until = time.monotonic() + AUTH_LOCKOUT_S
self._auth_failures = 0
raise SetupAuthError("auth_failed")
self._auth_failures = 0
session.authenticated = True
session.session_key = key
session.highest_seq = 0
session.seen_seq_mask = 0
session.last_seen = time.monotonic()
return {
"type": "authOk",
"sessionId": session_id,
"setupId": self._setup_id,
"timestampMs": int(time.time() * 1000),
"proof": _hmac_hex(key, _canonical({"role": "device-auth", "sessionId": session_id, "timestampMs": int(timestamp_ms)})),
}
def validate_request(self, client_id: str, session_id: str) -> SetupSession:
session = self._sessions.get(client_id)
if session is None or not session.authenticated:
raise SetupAuthError("session_not_authenticated")
if session.session_id != session_id:
raise SetupAuthError("session_id_mismatch")
session.last_seen = time.monotonic()
return session
def _check_seq(self, session: SetupSession, seq: int) -> None:
seq = int(seq)
if seq <= 0:
raise SetupAuthError("seq_invalid")
if seq > session.highest_seq:
return
offset = session.highest_seq - seq
if offset >= SEQ_REPLAY_WINDOW or (session.seen_seq_mask >> offset) & 1:
raise SetupAuthError("seq_replayed")
def _consume_seq(self, session: SetupSession, seq: int) -> None:
seq = int(seq)
if seq > session.highest_seq:
shift = seq - session.highest_seq
session.seen_seq_mask = ((session.seen_seq_mask << shift) | 1) & ((1 << SEQ_REPLAY_WINDOW) - 1)
session.highest_seq = seq
else:
session.seen_seq_mask |= 1 << (session.highest_seq - seq)
def validate_signed_request(self, client_id: str, session_id: str, request_id: Any, seq: int, method: str, params: Any, mac_hex: str) -> SetupSession:
session = self.validate_request(client_id, session_id)
if not session.session_key:
raise SetupAuthError("missing_session_key")
self._check_seq(session, seq)
payload = _canonical({"id": request_id, "method": method, "params": params, "seq": int(seq), "sessionId": session_id, "type": "request"})
if not hmac.compare_digest(_hmac_hex(session.session_key, payload), str(mac_hex or "").strip().lower()):
raise SetupAuthError("request_mac_invalid")
self._consume_seq(session, seq)
return session
def build_response(self, session: SetupSession | None, request_id: Any, seq: int | None, *, result: Any = None, error: str | None = None) -> dict[str, Any]:
rtype = "error" if error is not None else "response"
env: dict[str, Any] = {"type": rtype, "id": request_id}
if error is not None:
env["error"] = error
else:
env["result"] = result
if session is not None and session.session_key and seq is not None:
payload = error if error is not None else result
env["seq"] = int(seq)
env["mac"] = _hmac_hex(session.session_key, _canonical({"id": request_id, "payload": payload, "seq": int(seq), "sessionId": session.session_id, "type": rtype}))
return env
# ---------------------------------------------------------------------------
# GATT plumbing (adapted from the proven settings transport, + ServiceData adv)
# ---------------------------------------------------------------------------
def _variant(sig: str, val: Any):
return GLib.Variant(sig, val)
def frame_payload(payload: bytes) -> list[bytes]:
if not payload:
payload = b"{}"
chunk = max(1, MAX_FRAGMENT_PAYLOAD - FRAME_HEADER.size)
chunks = [payload[i:i + chunk] for i in range(0, len(payload), chunk)] or [b""]
total, count = len(payload), len(chunks)
return [FRAME_HEADER.pack(total, idx, count) + c for idx, c in enumerate(chunks)]
@dataclass
class _Reassembly:
total_length: int
fragment_count: int
chunks: dict[int, bytes] = field(default_factory=dict)
def add(self, idx: int, payload: bytes) -> bytes | None:
self.chunks[idx] = payload
if len(self.chunks) != self.fragment_count:
return None
return b"".join(self.chunks[i] for i in range(self.fragment_count))[:self.total_length]
OM_XML = '<node><interface name="org.freedesktop.DBus.ObjectManager"><method name="GetManagedObjects"><arg type="a{oa{sa{sv}}}" name="objects" direction="out"/></method></interface></node>'
PROPS_XML = '<node><interface name="org.freedesktop.DBus.Properties"><method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method><method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method></interface></node>'
SERVICE_XML = '<node><interface name="org.bluez.GattService1"><property name="UUID" type="s" access="read"/><property name="Primary" type="b" access="read"/><property name="Characteristics" type="ao" access="read"/></interface></node>'
CHAR_XML = '<node><interface name="org.bluez.GattCharacteristic1"><method name="ReadValue"><arg type="a{sv}" direction="in"/><arg type="ay" direction="out"/></method><method name="WriteValue"><arg type="ay" direction="in"/><arg type="a{sv}" direction="in"/></method><method name="StartNotify"/><method name="StopNotify"/><property name="UUID" type="s" access="read"/><property name="Service" type="o" access="read"/><property name="Flags" type="as" access="read"/><property name="Value" type="ay" access="read"/><property name="Notifying" type="b" access="read"/></interface></node>'
ADV_XML = '<node><interface name="org.bluez.LEAdvertisement1"><method name="Release"/><property name="Type" type="s" access="read"/><property name="ServiceUUIDs" type="as" access="read"/><property name="LocalName" type="s" access="read"/><property name="ServiceData" type="a{sv}" access="read"/><property name="Includes" type="as" access="read"/></interface></node>'
class _Exported:
def __init__(self, path, xml, methods=None, properties=None):
self.path = path
self.node = Gio.DBusNodeInfo.new_for_xml(xml)
self.methods = methods or {}
self.properties = properties or {}
self.ids: list[int] = []
def register(self, bus):
for iface in self.node.interfaces:
self.ids.append(bus.register_object(self.path, iface, self._call, self._get, None))
def unregister(self, bus):
for i in self.ids:
try:
bus.unregister_object(i)
except Exception:
pass
self.ids.clear()
def _call(self, conn, sender, path, iface, method, params, invocation):
handler = self.methods.get((iface, method))
if handler is None:
invocation.return_dbus_error("org.konn3kt.Error", f"unsupported:{iface}.{method}")
return
try:
result = handler(params)
invocation.return_value(result)
except Exception as e:
invocation.return_dbus_error("org.konn3kt.Error", str(e))
def _get(self, conn, sender, path, iface, name):
props = self.properties.get(iface, {})
return props[name]() if name in props else None
class SetupBleServer:
"""Advertise + serve the setup GATT service. Single central (1:1 setup)."""
def __init__(self, *, serial: str, on_control: Callable[[bytes], bytes | None], on_request: Callable[[bytes], bytes | None]):
self.serial = serial
self.setup_id = setup_id_for_serial(serial)
self.local_name = f"IQSetup-{serial[-6:]}"
self.on_control = on_control
self.on_request = on_request
self.bus = None
self.adapter_path: str | None = None
self.context = None
self.loop = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
self._error: Exception | None = None
self.running = False
self._install_in_progress = False
self._root = f"/io/konn3kt/setup/p{os.getpid()}"
self._objects: list[_Exported] = []
self._notify = {"control": False, "response": False}
self._values = {"control": b"", "request": b"", "response": b""}
self._reassembly: dict[str, _Reassembly] = {}
# ---- lifecycle -----------------------------------------------------------
def start(self, timeout_s: float = 30.0) -> None:
if not _GI_AVAILABLE:
raise RuntimeError("gi_unavailable")
if self.running:
return
self._ensure_unique_bdaddr()
self._thread = threading.Thread(target=self._run, name="setup_ble_gatt", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=timeout_s):
raise RuntimeError("setup_ble_start_timeout")
if self._error is not None:
raise self._error
def stop(self) -> None:
try:
if self.context is not None:
GLib.idle_add(self._stop_on_loop)
if self._thread is not None:
self._thread.join(timeout=3.0)
except Exception:
pass
self.running = False
def set_install_in_progress(self, active: bool) -> None:
self._install_in_progress = bool(active)
# ---- BD address ----------------------------------------------------------
def _run_priv(self, args: list[str], timeout_s: float = 10.0) -> bool:
cmds = ([args] if os.geteuid() == 0 else [["sudo", "-n", *args], args])
for cmd in cmds:
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_s, check=False)
if p.returncode == 0:
return True
except Exception:
continue
return False
def _read_bdaddr(self) -> str | None:
try:
out = subprocess.run(["hciconfig", "hci0"], stdout=subprocess.PIPE, text=True, timeout=5.0, check=False).stdout
m = re.search(r"BD Address:\s*([0-9A-Fa-f:]{17})", out or "")
return m.group(1).upper() if m else None
except Exception:
return None
def _ensure_unique_bdaddr(self) -> None:
current = self._read_bdaddr()
if not current or not current.startswith("00:00:00:00"):
return
target = _derive_setup_bdaddr(self.serial)
octets = [f"0x{p}" for p in target.split(":")]
if not self._run_priv(["hcitool", "-i", "hci0", "cmd", "0x3f", "0x0014", *octets]):
return
time.sleep(0.5)
self._run_priv(["hciconfig", "hci0", "reset"])
time.sleep(1.5)
self._run_priv(["systemctl", "restart", "bluetooth.service"], timeout_s=20.0)
time.sleep(2.0)
# ---- main loop -----------------------------------------------------------
def _run(self):
self.context = GLib.MainContext()
self.loop = GLib.MainLoop.new(self.context, False)
self.context.push_thread_default()
try:
src = GLib.idle_source_new()
src.set_callback(self._startup)
src.attach(self.context)
self.loop.run()
except Exception as e:
self._error = e
self._ready.set()
finally:
try:
self.context.pop_thread_default()
except Exception:
pass
def _find_adapter(self) -> str | None:
objs = self._managed_objects()
for path, ifaces in objs.items():
if GATT_MANAGER_IFACE in ifaces and LE_ADV_MANAGER_IFACE in ifaces and ADAPTER_IFACE in ifaces:
return path
return None
def _managed_objects(self) -> dict:
reply = self.bus.call_sync(BLUEZ_SERVICE, "/", DBUS_OM_IFACE, "GetManagedObjects", None,
GLib.VariantType.new("(a{oa{sa{sv}}})"), Gio.DBusCallFlags.NONE, 5000, None)
u = reply.unpack()
return u[0] if isinstance(u, tuple) else u
def _startup(self, *_):
try:
self._dbg("startup begin")
self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
# ensure powered
try:
self.adapter_path = None
start = time.monotonic()
while time.monotonic() - start < ADAPTER_WAIT_TIMEOUT_S:
self.adapter_path = self._find_adapter()
if self.adapter_path:
break
time.sleep(0.5)
if not self.adapter_path:
raise RuntimeError("bluetooth_adapter_not_found")
self._set_powered()
except Exception:
raise
self._dbg("adapter=%s, registering objects" % self.adapter_path)
self._register_objects()
self._dbg("objects registered, calling RegisterApplication")
self._register_with_bluez()
self._dbg("RegisterApplication call issued")
except Exception as e:
import traceback as _tb
self._dbg("startup EXC: %r\n%s" % (e, _tb.format_exc()))
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return False
def _set_powered(self):
try:
self.bus.call_sync(BLUEZ_SERVICE, self.adapter_path, DBUS_PROPS_IFACE, "Set",
GLib.Variant("(ssv)", (ADAPTER_IFACE, "Powered", GLib.Variant("b", True))),
None, Gio.DBusCallFlags.NONE, 5000, None)
except Exception:
pass
# paths
@property
def _app_path(self): return self._root
@property
def _service_path(self): return self._root + "/service0"
@property
def _control_path(self): return self._service_path + "/char0"
@property
def _request_path(self): return self._service_path + "/char1"
@property
def _response_path(self): return self._service_path + "/char2"
@property
def _adv_path(self): return self._root + "/advertisement0"
def _char_props(self, uuid, flags, kind):
return {GATT_CHRC_IFACE: {
"UUID": lambda: _variant("s", uuid),
"Service": lambda: _variant("o", self._service_path),
"Flags": lambda: _variant("as", flags),
"Value": lambda: _variant("ay", list(self._values[kind])),
"Notifying": lambda: _variant("b", self._notify.get(kind, False)),
}}
def _service_data_variant(self):
flags = 0x01 | (0x02 if self._install_in_progress else 0x00)
data = bytes([PROTOCOL_VERSION, flags]) + bytes.fromhex(self.setup_id)
return _variant("a{sv}", {SETUP_SERVICE_DATA_UUID: GLib.Variant("ay", list(data))})
def _register_objects(self):
chars = [self._control_path, self._request_path, self._response_path]
service_props = {GATT_SERVICE_IFACE: {
"UUID": lambda: _variant("s", SETUP_SERVICE_UUID),
"Primary": lambda: _variant("b", True),
"Characteristics": lambda: _variant("ao", chars),
}}
# Keep the adv within the 31-byte legacy budget: 16-bit ServiceData (setupId)
# + a short LocalName, no 128-bit ServiceUUID, no tx-power. The app scans by
# the ServiceData UUID and matches setupId locally against its known serials.
adv_props = {LE_ADV_IFACE: {
"Type": lambda: _variant("s", "peripheral"),
"LocalName": lambda: _variant("s", "IQSetup"),
"ServiceData": self._service_data_variant,
}}
def mk_props(path, pmap):
def get_prop(params):
iface, name = params.unpack()
getter = pmap.get(iface, {}).get(name)
if getter is None:
raise RuntimeError(f"unknown_property:{iface}.{name}")
return GLib.Variant("(v)", (getter(),))
def get_all(params):
(iface,) = params.unpack()
pm = pmap.get(iface)
if pm is None:
raise RuntimeError(f"unknown_interface:{iface}")
return GLib.Variant("(a{sv})", ({k: g() for k, g in pm.items()},))
return _Exported(path, PROPS_XML, methods={(DBUS_PROPS_IFACE, "Get"): get_prop, (DBUS_PROPS_IFACE, "GetAll"): get_all})
app = _Exported(self._app_path, OM_XML, methods={(DBUS_OM_IFACE, "GetManagedObjects"): self._get_managed})
service = _Exported(self._service_path, SERVICE_XML, properties=service_props)
control = self._mk_char("control", self._control_path, SETUP_CONTROL_CHAR_UUID, ["write", "notify"])
request = self._mk_char("request", self._request_path, SETUP_REQUEST_CHAR_UUID, ["write", "write-without-response"])
response = self._mk_char("response", self._response_path, SETUP_RESPONSE_CHAR_UUID, ["notify"])
adv = _Exported(self._adv_path, ADV_XML, methods={(LE_ADV_IFACE, "Release"): lambda _: None}, properties=adv_props)
self._objects = [
app, mk_props(self._app_path, {}),
service, mk_props(self._service_path, service_props),
control, mk_props(self._control_path, self._char_props(SETUP_CONTROL_CHAR_UUID, ["write", "notify"], "control")),
request, mk_props(self._request_path, self._char_props(SETUP_REQUEST_CHAR_UUID, ["write", "write-without-response"], "request")),
response, mk_props(self._response_path, self._char_props(SETUP_RESPONSE_CHAR_UUID, ["notify"], "response")),
adv, mk_props(self._adv_path, adv_props),
]
for o in self._objects:
o.register(self.bus)
def _mk_char(self, kind, path, uuid, flags):
def read_value(_params):
return GLib.Variant("(ay)", (list(self._values[kind]),))
def write_value(params):
value, options = params.unpack()
payload = bytes(int(x) & 0xFF for x in value) if isinstance(value, (list, tuple)) else bytes(value or b"")
full = self._consume_fragment(kind, payload)
if full is None:
return None
self._values[kind] = full
if kind == "control":
resp = self.on_control(full)
if resp:
self.notify("control", resp)
elif kind == "request":
# Handle on a worker so a slow op (wifi scan, connect) never stalls BLE.
threading.Thread(target=self._handle_request_worker, args=(full,), daemon=True).start()
return None
def start_notify(_params):
self._notify[kind] = True
return None
def stop_notify(_params):
self._notify[kind] = False
return None
return _Exported(path, CHAR_XML, methods={
(GATT_CHRC_IFACE, "ReadValue"): read_value,
(GATT_CHRC_IFACE, "WriteValue"): write_value,
(GATT_CHRC_IFACE, "StartNotify"): start_notify,
(GATT_CHRC_IFACE, "StopNotify"): stop_notify,
}, properties=self._char_props(uuid, flags, kind))
def _handle_request_worker(self, full: bytes):
try:
resp = self.on_request(full)
if resp:
self.notify("response", resp)
except Exception:
pass
def _get_managed(self, _params):
managed = {
self._service_path: {GATT_SERVICE_IFACE: {
"UUID": _variant("s", SETUP_SERVICE_UUID), "Primary": _variant("b", True),
"Characteristics": _variant("ao", [self._control_path, self._request_path, self._response_path])}},
self._control_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_CONTROL_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["write", "notify"])}},
self._request_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_REQUEST_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["write", "write-without-response"])}},
self._response_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_RESPONSE_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["notify"])}},
}
return GLib.Variant("(a{oa{sa{sv}}})", (managed,))
def _consume_fragment(self, kind, fragment):
if len(fragment) < FRAME_HEADER.size:
raise RuntimeError("fragment_too_small")
total, idx, count = FRAME_HEADER.unpack(fragment[:FRAME_HEADER.size])
if count <= 0 or idx >= count:
raise RuntimeError("invalid_fragment_header")
st = self._reassembly.get(kind)
if st is None or st.total_length != total or st.fragment_count != count:
st = _Reassembly(total_length=total, fragment_count=count)
self._reassembly[kind] = st
payload = st.add(idx, fragment[FRAME_HEADER.size:])
if payload is not None:
self._reassembly.pop(kind, None)
return payload
def notify(self, kind: str, payload: bytes):
path = {"control": self._control_path, "response": self._response_path}.get(kind)
if not self.running or self.bus is None or not self._notify.get(kind) or path is None:
return
for fragment in frame_payload(payload):
self._values[kind] = fragment
try:
self.bus.emit_signal(None, path, DBUS_PROPS_IFACE, "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (GATT_CHRC_IFACE, {"Value": _variant("ay", list(fragment))}, [])))
except Exception:
pass
def refresh_advertisement(self):
"""Re-emit ServiceData (e.g. install-in-progress flag flipped)."""
if not self.running or self.bus is None:
return
try:
self.bus.emit_signal(None, self._adv_path, DBUS_PROPS_IFACE, "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (LE_ADV_IFACE, {"ServiceData": self._service_data_variant()}, [])))
except Exception:
pass
# ---- bluez registration --------------------------------------------------
def _register_with_bluez(self):
self.bus.call(BLUEZ_SERVICE, self.adapter_path, GATT_MANAGER_IFACE, "RegisterApplication",
GLib.Variant("(oa{sv})", (self._app_path, {})), None, Gio.DBusCallFlags.NONE,
BLUEZ_REGISTER_TIMEOUT_MS, None, self._on_app_registered, None)
def _on_app_registered(self, conn, result, _ud):
try:
(conn or self.bus).call_finish(result)
self._dbg("app_registered OK adv_path=%s" % self._adv_path)
except GLib.GError as e:
self._dbg("app_registered GError: %s" % e)
if "AlreadyExists" not in str(e):
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return
self.bus.call(BLUEZ_SERVICE, self.adapter_path, LE_ADV_MANAGER_IFACE, "RegisterAdvertisement",
GLib.Variant("(oa{sv})", (self._adv_path, {})), None, Gio.DBusCallFlags.NONE,
BLUEZ_REGISTER_TIMEOUT_MS, None, self._on_adv_registered, None)
def _dbg(self, msg):
try:
import os as _os
fd = _os.open("/data/setup_test/setup_ble_dbg.log", _os.O_WRONLY | _os.O_CREAT | _os.O_APPEND, 0o644)
_os.write(fd, (msg + "\n").encode())
_os.close(fd)
except Exception:
pass
def _on_adv_registered(self, conn, result, _ud):
try:
(conn or self.bus).call_finish(result)
self._dbg("adv_registered OK")
except GLib.GError as e:
self._dbg("adv_registered GError: %s" % e)
if "AlreadyExists" not in str(e):
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return
self.running = True
self._error = None
self._ready.set()
def _clear_registrations(self):
if self.bus is None or self.adapter_path is None:
return
for iface, method, arg in (
(LE_ADV_MANAGER_IFACE, "UnregisterAdvertisement", self._adv_path),
(GATT_MANAGER_IFACE, "UnregisterApplication", self._app_path),
):
try:
self.bus.call_sync(BLUEZ_SERVICE, self.adapter_path, iface, method, GLib.Variant("(o)", (arg,)),
None, Gio.DBusCallFlags.NONE, 3000, None)
except Exception:
pass
def _stop_on_loop(self):
if self.bus is not None and self.adapter_path is not None:
self._clear_registrations()
if self.bus is not None:
for o in reversed(self._objects):
try:
o.unregister(self.bus)
except Exception:
pass
self._objects.clear()
self.running = False
if self.loop:
try:
self.loop.quit()
except Exception:
pass

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
Konn3kt setup BLE controller — owns the setup session, dispatches the setup
operations against the real device (Wi-Fi via WifiManager, hardware/cellular via
HARDWARE, install via an injected trigger), and exposes the 6-digit code + a
small observable status the setup UI renders.
Runs inside the setup zipapp. See konn3kt_ble_setup_protocol.md.
"""
import json
import secrets
import threading
import time
import urllib.request
from typing import Any, Callable
from openpilot.system.ui.lib.setup_ble import (
SetupBleServer,
SetupSessionManager,
SetupAuthError,
PROTOCOL_VERSION,
)
from openpilot.system.ui.lib.os_update import OsUpdateCoordinator
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
IQPILOT_CHANNELS = {"release": "IQLvbs/release", "beta": "IQLvbs/beta"}
# Setup ops served over BLE (Phase A only — deliberately tiny, no shell/params).
SETUP_METHODS = {
"getSetupInfo",
"scanWifi",
"connectWifi",
"forgetWifi",
"getNetworkStatus",
"startInstall",
"getInstallProgress",
"confirmOsUpdate",
"ping",
}
def _new_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
class SetupController:
def __init__(self, *, serial: str, hardware: Any, wifi_manager: Any,
on_start_install: Callable[[str], None], version: str = ""):
self.serial = serial
self.hardware = hardware
self.wifi = wifi_manager
self.on_start_install = on_start_install
self.version = version
self._code = _new_code()
self._code_lock = threading.Lock()
self.session_manager: SetupSessionManager | None = None
self.server: SetupBleServer | None = None
# Observable UI state
self._lock = threading.Lock()
self.phone_active = False # a phone has authenticated
self.install_state = "idle" # idle|downloading|os_update_required|os_updating|installing|failed|rebooting
self.install_percent = 0
self.install_error = ""
self.os_from = "" # current IQ.OS version when an OS update is needed
self.os_to = "" # target IQ.OS version
self.os_update = OsUpdateCoordinator() # bridges install thread <-> phone confirm
self._enabled = False
# ---- code ----------------------------------------------------------------
@property
def code(self) -> str:
with self._code_lock:
return self._code
def _rotate_code(self) -> None:
with self._code_lock:
self._code = _new_code()
# ---- lifecycle -----------------------------------------------------------
def start(self) -> bool:
if self._enabled:
return True
self._enabled = True
threading.Thread(target=self._start_blocking, name="setup_ble_start", daemon=True).start()
return True
def _start_blocking(self) -> None:
self.session_manager = SetupSessionManager(self._setup_id(), lambda: self.code)
server = SetupBleServer(
serial=self.serial,
on_control=self._on_control,
on_request=self._on_request,
)
try:
server.start()
except Exception as e:
print(f"[setup_ble] start failed: {e}")
return
self.server = server
if self.wifi is not None:
try:
self.wifi.set_active(True)
except Exception:
pass
print(f"[setup_ble] advertising setupId={self._setup_id()} name=IQSetup-{self.serial[-6:]}")
def stop(self) -> None:
self._enabled = False
if self.server is not None:
try:
self.server.stop()
except Exception:
pass
self.server = None
def _setup_id(self) -> str:
from openpilot.system.ui.lib.setup_ble import setup_id_for_serial
return setup_id_for_serial(self.serial)
# ---- observable state ----------------------------------------------------
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"phone_active": self.phone_active,
"install_state": self.install_state,
"install_percent": self.install_percent,
"install_error": self.install_error,
"osFrom": self.os_from,
"osTo": self.os_to,
}
def set_install_progress(self, state: str, percent: int = 0, error: str = "",
os_from: str | None = None, os_to: str | None = None) -> None:
with self._lock:
self.install_state = state
self.install_percent = int(percent)
self.install_error = error
if os_from is not None:
self.os_from = os_from
if os_to is not None:
self.os_to = os_to
cur_from, cur_to = self.os_from, self.os_to
if self.server is not None:
self.server.set_install_in_progress(state in ("downloading", "os_updating", "installing", "rebooting"))
self.server.refresh_advertisement()
# push unsolicited progress event to a connected phone
self.server.notify("response", json.dumps({
"type": "event", "event": "installProgress",
"state": state, "percent": int(percent), "error": error,
"osFrom": cur_from, "osTo": cur_to,
}, separators=(",", ":")).encode("utf-8"))
# ---- control (hello/auth/ping) -------------------------------------------
def _on_control(self, payload: bytes) -> bytes | None:
try:
msg = json.loads(payload.decode("utf-8"))
mtype = str(msg.get("type") or "")
client_id = "central" # single central for setup
self.session_manager.prune()
if mtype == "hello":
resp = self.session_manager.begin_hello(
client_id=client_id,
setup_id=str(msg.get("setupId") or ""),
client_nonce=str(msg.get("clientNonce") or ""),
timestamp_ms=int(msg.get("timestampMs") or 0),
)
# The code is generated once at setup start and shown persistently on the
# device screen (Chromecast-style) — it must NOT change mid-pairing, or
# the code the user is reading becomes stale the instant they connect.
return json.dumps(resp, separators=(",", ":")).encode("utf-8")
if mtype == "auth":
resp = self.session_manager.authenticate(
client_id=client_id,
session_id=str(msg.get("sessionId") or ""),
timestamp_ms=int(msg.get("timestampMs") or 0),
proof_hex=str(msg.get("proof") or ""),
)
with self._lock:
self.phone_active = True
return json.dumps(resp, separators=(",", ":")).encode("utf-8")
if mtype == "ping":
sid = str(msg.get("sessionId") or "")
self.session_manager.validate_request(client_id, sid)
return json.dumps({"type": "pong", "sessionId": sid, "timestampMs": int(time.time() * 1000)}, separators=(",", ":")).encode("utf-8")
raise SetupAuthError("unsupported_control_message")
except SetupAuthError as e:
return json.dumps({"type": "error", "error": str(e)}, separators=(",", ":")).encode("utf-8")
except Exception as e:
return json.dumps({"type": "error", "error": str(e)}, separators=(",", ":")).encode("utf-8")
# ---- request (authenticated ops) -----------------------------------------
def _on_request(self, payload: bytes) -> bytes | None:
request_id = None
session = None
seq = None
try:
msg = json.loads(payload.decode("utf-8"))
request_id = msg.get("id")
method = str(msg.get("method") or "")
# MAC must be verified over params EXACTLY as sent (None when omitted) —
# coercing to {} here would diverge from the client's canonical JSON.
params = msg.get("params")
seq = int(msg.get("seq") or 0)
session = self.session_manager.validate_signed_request(
"central", str(msg.get("sessionId") or ""), request_id, seq, method, params, str(msg.get("mac") or ""),
)
if method not in SETUP_METHODS:
raise SetupAuthError(f"method_not_allowed:{method}")
result = self._dispatch(method, params or {})
return json.dumps(self.session_manager.build_response(session, request_id, seq, result=result), separators=(",", ":")).encode("utf-8")
except SetupAuthError as e:
env = self.session_manager.build_response(session, request_id, seq, error=str(e)) if session else {"type": "error", "id": request_id, "error": str(e)}
return json.dumps(env, separators=(",", ":")).encode("utf-8")
except Exception as e:
env = self.session_manager.build_response(session, request_id, seq, error=str(e)) if session else {"type": "error", "id": request_id, "error": str(e)}
return json.dumps(env, separators=(",", ":")).encode("utf-8")
def _dispatch(self, method: str, params: dict) -> Any:
if method == "ping":
return {"ok": True}
if method == "getSetupInfo":
return self._setup_info()
if method == "scanWifi":
return {"networks": self._scan_wifi()}
if method == "connectWifi":
return self._connect_wifi(str(params.get("ssid") or ""), str(params.get("password") or ""))
if method == "forgetWifi":
return self._forget_wifi(str(params.get("ssid") or ""))
if method == "getNetworkStatus":
return self._network_status()
if method == "startInstall":
return self._start_install(str(params.get("channel") or "release"))
if method == "getInstallProgress":
s = self.snapshot()
return {"state": s["install_state"], "percent": s["install_percent"], "error": s["install_error"],
"osFrom": s["osFrom"], "osTo": s["osTo"]}
if method == "confirmOsUpdate":
# Phone approved flashing the newer IQ.OS — unblock the install thread.
self.os_update.confirm()
return {"confirmed": True}
raise SetupAuthError(f"method_not_allowed:{method}")
# ---- op implementations --------------------------------------------------
def _setup_info(self) -> dict[str, Any]:
cellular = self._cellular_status()
net = self._network_status()
voltage_ok = True
try:
v = self.hardware.get_voltage() if self.hardware else None
if v is not None:
voltage_ok = v > 8_000 # mV; matches setup low-voltage threshold spirit
except Exception:
pass
hw = "mici" if self._is_mici() else "tici"
return {
"serial": self.serial,
"setupId": self._setup_id(),
"hardware": hw,
"version": self.version,
"voltageOk": voltage_ok,
"cellular": cellular,
"wifiConnected": net["wifiConnected"],
"internetReachable": net["internetReachable"],
}
def _is_mici(self) -> bool:
try:
return getattr(self.hardware, "__class__", type("x", (), {})).__name__.lower().startswith("mici") or \
bool(getattr(self.hardware, "is_mici", lambda: False)())
except Exception:
return False
def _scan_wifi(self) -> list[dict[str, Any]]:
if self.wifi is None:
return []
try:
self.wifi.set_active(True)
except Exception:
pass
nets = []
try:
for n in self.wifi.get_networks():
nets.append({
"ssid": n.ssid,
"strength": int(n.strength),
"security": int(n.security_type),
"connected": bool(n.is_connected),
"saved": bool(n.is_saved),
})
except Exception:
pass
return nets
def _connect_wifi(self, ssid: str, password: str) -> dict[str, Any]:
if not ssid:
return {"success": False, "error": "missing_ssid"}
if self.wifi is None:
return {"success": False, "error": "wifi_unavailable"}
try:
self.wifi.connect_to_network(ssid, password)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
def _forget_wifi(self, ssid: str) -> dict[str, Any]:
if self.wifi is None:
return {"success": False, "error": "wifi_unavailable"}
try:
self.wifi.forget_connection(ssid)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
def _cellular_status(self) -> dict[str, Any]:
present = False
active = False
operator = None
try:
if self.hardware is not None and hasattr(self.hardware, "get_network_type"):
from openpilot.system.hardware.tici.hardware import NetworkType
nt = self.hardware.get_network_type()
active = nt in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G)
present = active
if self.hardware is not None and hasattr(self.hardware, "get_sim_info"):
sim = self.hardware.get_sim_info()
present = present or bool(sim and sim.get("sim_id"))
operator = (sim or {}).get("network_type")
except Exception:
pass
return {"present": present, "active": active, "operator": operator}
def _network_status(self) -> dict[str, Any]:
wifi_connected = False
ssid = None
try:
if self.wifi is not None:
for n in self.wifi.get_networks():
if n.is_connected:
wifi_connected = True
ssid = n.ssid
break
except Exception:
pass
cellular = self._cellular_status()
internet = False
try:
urllib.request.urlopen(NETWORK_CHECK_URL, timeout=3)
internet = True
except Exception:
internet = False
return {
"wifiConnected": wifi_connected,
"ssid": ssid,
"cellularActive": cellular["active"],
"internetReachable": internet,
}
def _start_install(self, channel: str) -> dict[str, Any]:
if channel not in IQPILOT_CHANNELS:
return {"started": False, "error": "invalid_channel"}
net = self._network_status()
if not net["internetReachable"]:
return {"started": False, "error": "no_internet"}
try:
self.set_install_progress("downloading", 0)
self.on_start_install(IQPILOT_CHANNELS[channel])
return {"started": True, "channel": channel}
except Exception as e:
self.set_install_progress("failed", 0, str(e))
return {"started": False, "error": str(e)}

View File

@@ -0,0 +1,238 @@
import pyray as rl
import numpy as np
from dataclasses import dataclass
from typing import Any, Optional, cast
from openpilot.system.ui.lib.application import gui_app, GL_VERSION
MAX_GRADIENT_COLORS = 20 # includes stops as well
@dataclass
class Gradient:
start: tuple[float, float]
end: tuple[float, float]
colors: list[rl.Color]
stops: list[float]
def __post_init__(self):
if len(self.colors) > MAX_GRADIENT_COLORS:
self.colors = self.colors[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient colors truncated to {MAX_GRADIENT_COLORS} entries")
if len(self.stops) > MAX_GRADIENT_COLORS:
self.stops = self.stops[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient stops truncated to {MAX_GRADIENT_COLORS} entries")
if not len(self.stops):
color_count = min(len(self.colors), MAX_GRADIENT_COLORS)
self.stops = [i / max(1, color_count - 1) for i in range(color_count)]
FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
uniform vec4 fillColor;
// Gradient line defined in *screen pixels*
uniform int useGradient;
uniform vec2 gradientStart; // e.g. vec2(0, 0)
uniform vec2 gradientEnd; // e.g. vec2(0, screenHeight)
uniform vec4 gradientColors[20];
uniform float gradientStops[20];
uniform int gradientColorCount;
vec4 getGradientColor(vec2 p) {
// Compute t from screen-space position
vec2 d = gradientStart - gradientEnd;
float len2 = max(dot(d, d), 1e-6);
float t = clamp(dot(p - gradientEnd, d) / len2, 0.0, 1.0);
// Clamp to range
float t0 = gradientStops[0];
float tn = gradientStops[gradientColorCount-1];
if (t <= t0) return gradientColors[0];
if (t >= tn) return gradientColors[gradientColorCount-1];
for (int i = 0; i < gradientColorCount - 1; i++) {
float a = gradientStops[i];
float b = gradientStops[i+1];
if (t >= a && t <= b) {
float k = (t - a) / max(b - a, 1e-6);
return mix(gradientColors[i], gradientColors[i+1], k);
}
}
return gradientColors[gradientColorCount-1];
}
void main() {
// TODO: do proper antialiasing
finalColor = useGradient == 1 ? getGradientColor(gl_FragCoord.xy) : fillColor;
}
"""
# Default vertex shader
VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
uniform mat4 mvp;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
UNIFORM_VEC4 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4
class ShaderState:
_instance: Any = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
if ShaderState._instance is not None:
raise Exception("This class is a singleton. Use get_instance() instead.")
self.initialized = False
self.shader = None
# Shader uniform locations
self.locations = {
'fillColor': None,
'useGradient': None,
'gradientStart': None,
'gradientEnd': None,
'gradientColors': None,
'gradientStops': None,
'gradientColorCount': None,
'mvp': None,
}
# Pre-allocated FFI objects
self.fill_color_ptr = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self.use_gradient_ptr = rl.ffi.new("int[]", [0])
self.color_count_ptr = rl.ffi.new("int[]", [0])
self.gradient_colors_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS * 4)
self.gradient_stops_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS)
def initialize(self):
if self.initialized:
return
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
# Cache all uniform locations
for uniform in self.locations.keys():
self.locations[uniform] = rl.get_shader_location(self.shader, uniform)
# Orthographic MVP (origin top-left)
proj = rl.matrix_ortho(0, gui_app.width, gui_app.height, 0, -1, 1)
rl.set_shader_value_matrix(self.shader, self.locations['mvp'], proj)
self.initialized = True
def cleanup(self):
if not self.initialized:
return
if self.shader:
rl.unload_shader(self.shader)
self.shader = None
self.initialized = False
def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
gradient: Gradient | None, origin_rect: rl.Rectangle):
assert (color is not None) != (gradient is not None), "Either color or gradient must be provided"
use_gradient = 1 if (gradient is not None and len(gradient.colors) >= 1) else 0
state.use_gradient_ptr[0] = use_gradient
rl.set_shader_value(state.shader, state.locations['useGradient'], state.use_gradient_ptr, UNIFORM_INT)
if use_gradient:
gradient = cast(Gradient, gradient)
state.color_count_ptr[0] = len(gradient.colors)
for i in range(len(gradient.colors)):
c = gradient.colors[i]
base = i * 4
state.gradient_colors_ptr[base:base + 4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0]
rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, len(gradient.colors))
for i in range(len(gradient.stops)):
s = float(gradient.stops[i])
state.gradient_stops_ptr[i] = 0.0 if s < 0.0 else 1.0 if s > 1.0 else s
rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, len(gradient.stops))
rl.set_shader_value(state.shader, state.locations['gradientColorCount'], state.color_count_ptr, UNIFORM_INT)
# Map normalized start/end to screen pixels
start_vec = rl.Vector2(origin_rect.x + gradient.start[0] * origin_rect.width, origin_rect.y + gradient.start[1] * origin_rect.height)
end_vec = rl.Vector2(origin_rect.x + gradient.end[0] * origin_rect.width, origin_rect.y + gradient.end[1] * origin_rect.height)
rl.set_shader_value(state.shader, state.locations['gradientStart'], start_vec, UNIFORM_VEC2)
rl.set_shader_value(state.shader, state.locations['gradientEnd'], end_vec, UNIFORM_VEC2)
else:
color = color or rl.WHITE
state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0]
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
"""Only supports simple polygons with two chains (ribbon)."""
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
# assert len(pts) % 2 == 0, "Interleaving expects even number of points"
if len(pts) % 2 != 0:
pts = pts[:-1]
tri_strip = []
for i in range(len(pts) // 2):
tri_strip.append(pts[i])
tri_strip.append(pts[-i - 1])
return cast(list, np.array(tri_strip).tolist())
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
color: Optional[rl.Color] = None, gradient: Gradient | None = None):
"""
Draw a ribbon polygon (two chains) with a triangle strip and gradient.
- Input must be [L0..Lk-1, Rk-1..R0], even count, no crossings/holes.
"""
if len(points) < 3:
return
# Initialize shader on-demand
state = ShaderState.get_instance()
state.initialize()
# Ensure (N,2) float32 contiguous array
pts = np.ascontiguousarray(points, dtype=np.float32)
assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)"
# Configure gradient shader
_configure_shader_color(state, color, gradient, origin_rect)
# Triangulate via interleaving
tri_strip = triangulate(pts)
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
def cleanup_shader_resources():
state = ShaderState.get_instance()
state.cleanup()

View File

@@ -0,0 +1,36 @@
import pyray as rl
from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback
from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
# Measure normal characters without emojis, then add standard width for each found emoji
emoji = find_emoji(text)
if emoji:
non_emoji_text = ""
last_index = 0
for start, end, _ in emoji:
non_emoji_text += text[last_index:start]
last_index = end
non_emoji_text += text[last_index:]
else:
non_emoji_text = text
result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251
if emoji:
result.x += len(emoji) * font_size * FONT_SCALE
# If just emoji assume a single line height
if result.y == 0:
result.y = font_size * FONT_SCALE
_cache[key] = result
return result

18
system/ui/lib/utils.py Normal file
View File

@@ -0,0 +1,18 @@
import pyray as rl
class GuiStyleContext:
def __init__(self, styles: list[tuple[int, int, int]]):
"""styles is a list of tuples (control, prop, new_value)"""
self.styles = styles
self.prev_styles: list[tuple[int, int, int]] = []
def __enter__(self):
for control, prop, new_value in self.styles:
prev_value = rl.gui_get_style(control, prop)
self.prev_styles.append((control, prop, prev_value))
rl.gui_set_style(control, prop, new_value)
def __exit__(self, exc_type, exc_value, traceback):
for control, prop, prev_value in self.prev_styles:
rl.gui_set_style(control, prop, prev_value)

File diff suppressed because it is too large Load Diff

107
system/ui/lib/wrap_text.py Normal file
View File

@@ -0,0 +1,107 @@
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
if not word:
return []
parts = []
remaining = word
while remaining:
if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
# Binary search for the longest substring that fits
left, right = 1, len(remaining)
best_fit = 1
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
left = mid + 1
else:
right = mid - 1
# Add the part that fits
parts.append(remaining[:best_fit])
remaining = remaining[best_fit:]
return parts
_cache: dict[int, list[str]] = {}
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
if not text or max_width <= 0:
return []
# Split text by newlines first to preserve explicit line breaks
paragraphs = text.split('\n')
all_lines: list[str] = []
for paragraph in paragraphs:
# Handle empty paragraphs (preserve empty lines)
if not paragraph.strip():
all_lines.append("")
continue
# Process each paragraph separately
words = paragraph.split()
if not words:
all_lines.append("")
continue
lines: list[str] = []
current_line: list[str] = []
for word in words:
word_width = measure_text_cached(font, word, font_size, spacing).x
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width, spacing))
continue
# Measure the actual joined string to get accurate width (accounts for kerning, etc.)
test_line = " ".join(current_line + [word]) if current_line else word
test_width = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
current_line.append(word)
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
# Add all lines from this paragraph
all_lines.extend(lines)
_cache[key] = all_lines
return all_lines