forked from IQ.Lvbs/IQ.Pilot
IQ.Pilot Prebuilt Release @ ab07000
This commit is contained in:
0
system/ui/lib/__init__.py
Normal file
0
system/ui/lib/__init__.py
Normal file
1116
system/ui/lib/application.py
Normal 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
204
system/ui/lib/egl.py
Normal 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
67
system/ui/lib/emoji.py
Normal 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)
|
||||
88
system/ui/lib/multilang.py
Normal file
88
system/ui/lib/multilang.py
Normal 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
|
||||
47
system/ui/lib/networkmanager.py
Normal file
47
system/ui/lib/networkmanager.py
Normal 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
132
system/ui/lib/os_update.py
Normal 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)
|
||||
34
system/ui/lib/raylib_compat.py
Normal file
34
system/ui/lib/raylib_compat.py
Normal 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)
|
||||
293
system/ui/lib/screen_recorder.py
Normal file
293
system/ui/lib/screen_recorder.py
Normal 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)
|
||||
136
system/ui/lib/scroll_panel.py
Normal file
136
system/ui/lib/scroll_panel.py
Normal 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)
|
||||
230
system/ui/lib/scroll_panel2.py
Normal file
230
system/ui/lib/scroll_panel2.py
Normal 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
744
system/ui/lib/setup_ble.py
Normal 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
|
||||
372
system/ui/lib/setup_controller.py
Normal file
372
system/ui/lib/setup_controller.py
Normal 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)}
|
||||
238
system/ui/lib/shader_polygon.py
Normal file
238
system/ui/lib/shader_polygon.py
Normal 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()
|
||||
36
system/ui/lib/text_measure.py
Normal file
36
system/ui/lib/text_measure.py
Normal 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
18
system/ui/lib/utils.py
Normal 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)
|
||||
1091
system/ui/lib/wifi_manager.py
Normal file
1091
system/ui/lib/wifi_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
107
system/ui/lib/wrap_text.py
Normal file
107
system/ui/lib/wrap_text.py
Normal 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
|
||||
Reference in New Issue
Block a user