StarPilot

This commit is contained in:
firestar5683
2026-03-12 01:49:47 -05:00
parent 0e9ef526f7
commit d0e1db6766
2171 changed files with 590688 additions and 247754 deletions
+3 -3
View File
@@ -472,8 +472,8 @@ def setRouteViewed(route: str) -> dict[str, int | str]:
# maintain a list of the last 10 routes viewed in connect
params = Params()
r = params.get("AthenadRecentlyViewedRoutes")
routes = [] if r is None else r.split(",")
r = params.get("AthenadRecentlyViewedRoutes", encoding="utf-8")
routes = [] if r is None else [item for item in r.split(",") if item]
routes.append(route)
# remove duplicates
@@ -494,7 +494,7 @@ def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local
cloudlog.debug("athena.startLocalProxy.starting")
dongle_id = Params().get("DongleId")
dongle_id = Params().get("DongleId", encoding="utf-8")
identity_token = Api(dongle_id).get_token()
ws = create_connection(remote_ws_uri,
cookie="jwt=" + identity_token,
+1 -1
View File
@@ -1,6 +1,6 @@
Import('env', 'arch', 'messaging', 'common', 'gpucommon', 'visionipc')
libs = [common, 'OpenCL', messaging, visionipc, gpucommon]
libs = ['m', 'pthread', common, 'jpeg', 'OpenCL', 'yuv', messaging, visionipc, gpucommon, 'atomic']
if arch != "Darwin":
camera_obj = env.Object(['cameras/camera_qcom2.cc', 'cameras/camera_common.cc', 'cameras/spectra.cc',
BIN
View File
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
# Python version of system/camerad/cameras/nv12_info.h
# Calculations from third_party/linux/include/msm_media_info.h (VENUS_BUFFER_SIZE)
def align(val: int, alignment: int) -> int:
return ((val + alignment - 1) // alignment) * alignment
def get_nv12_info(width: int, height: int) -> tuple[int, int, int, int]:
"""Returns (stride, y_height, uv_height, buffer_size) for NV12 frame dimensions."""
stride = align(width, 128)
y_height = align(height, 32)
uv_height = align(height // 2, 16)
# VENUS_BUFFER_SIZE for NV12
y_plane = stride * y_height
uv_plane = stride * uv_height + 4096
size = y_plane + uv_plane + max(16 * 1024, 8 * stride)
size = align(size, 4096)
size += align(width, 512) * 512 # kernel padding for non-aligned frames
size = align(size, 4096)
return stride, y_height, uv_height, size
+5 -3
View File
@@ -1,14 +1,16 @@
#include "system/camerad/cameras/camera_common.h"
#include <cassert>
#include "common/params.h"
#include "common/swaglog.h"
#include "common/util.h"
int main(int argc, char *argv[]) {
// doesn't need RT priority since we're using isolcpus
int ret = util::set_core_affinity({6});
assert(ret == 0 || Params().getBool("IsOffroad")); // failure ok while offroad due to offlining cores
if (ret != 0 && !Params().getBool("IsOffroad")) {
// Keep camerad alive in forced/demo bring-up scenarios where affinity can fail.
LOGW("camerad: set_core_affinity failed (%d), continuing", ret);
}
camerad_thread();
return 0;
+6 -11
View File
@@ -67,18 +67,13 @@
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img.xz",
"hash": "1468d50b7ad0fda0f04074755d21e786e3b1b6ca5dd5b17eb2608202025e6126",
"hash_raw": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087",
"url": "https://www.dropbox.com/scl/fi/1l949sdyse7mgiz2n09t7/system.img.xz?rlkey=63kws9ktx9pr9c7su2vgtf9xc&st=sm8qfkp3&dl=1",
"hash": "d26f8e0f105a93ea0b386f259e1597d1cb10720cb6406491c570749e01d550c1",
"hash_raw": "d26f8e0f105a93ea0b386f259e1597d1cb10720cb6406491c570749e01d550c1",
"size": 5368709120,
"sparse": true,
"sparse": false,
"full_check": false,
"has_ab": true,
"ondevice_hash": "242aa5adad1c04e1398e00e2440d1babf962022eb12b89adf2e60ee3068946e7",
"alt": {
"hash": "e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087",
"url": "https://commadist.azureedge.net/agnosupdate/system-e0007afa5d1026671c1943d44bb7f7ad26259f673392dd00a03073a2870df087.img",
"size": 5368709120
}
"ondevice_hash": "242aa5adad1c04e1398e00e2440d1babf962022eb12b89adf2e60ee3068946e7"
}
]
]
+1 -1
View File
@@ -125,7 +125,7 @@ class Tici(HardwareBase):
return int(f.read())
def set_ir_power(self, percent: int):
if self.get_device_type() in ("tici", "tizi"):
if self.get_device_type() == "tizi":
return
value = int((percent / 100) * 300)
+37 -2
View File
@@ -10,8 +10,43 @@ if [[ ! -f "$AGNOS_PY" || ! -f "$MANIFEST" ]]; then
exit 1
fi
if systemctl is-active --quiet weston-ready; then
PYTHON_BIN="python3"
if [[ -x /usr/local/venv/bin/python3 ]]; then
PYTHON_BIN="/usr/local/venv/bin/python3"
fi
# Ensure AGNOS python can import openpilot modules when called directly.
REPO_ROOT="$(cd "$DIR/../../.." >/dev/null 2>&1 && pwd)"
if [[ -d "$REPO_ROOT" ]]; then
export PYTHONPATH="$REPO_ROOT/frogpilot/third_party:$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}"
fi
run_headless_swap() {
echo "jeepney unavailable; falling back to headless AGNOS updater (--swap)"
exec "$PYTHON_BIN" "$AGNOS_PY" --swap "$MANIFEST"
}
run_magic_with_fallback() {
"$DIR/updater_magic" "$AGNOS_PY" "$MANIFEST"
local rc=$?
if [[ $rc -ne 0 ]]; then
echo "updater_magic failed (rc=$rc); falling back to headless AGNOS updater"
run_headless_swap
fi
}
DEVICE_TYPE=""
if [[ -f /sys/firmware/devicetree/base/model ]]; then
MODEL="$(tr -d '\000' </sys/firmware/devicetree/base/model 2>/dev/null)"
DEVICE_TYPE="${MODEL##*comma }"
fi
# non-tici/tizi devices (comma four variants) use the small raylib UI stack.
# Route through updater_magic so setup/reset/update screens stay on the small UI.
if [[ "$DEVICE_TYPE" != "tici" && "$DEVICE_TYPE" != "tizi" ]]; then
run_magic_with_fallback
elif systemctl is-active --quiet weston-ready; then
$DIR/updater_weston $AGNOS_PY $MANIFEST
else
$DIR/updater_magic $AGNOS_PY $MANIFEST
run_magic_with_fallback
fi
BIN
View File
Binary file not shown.
View File
-3
View File
@@ -1,4 +1 @@
loggerd
encoderd
bootlog
tests/test_logger
+9 -7
View File
@@ -1,19 +1,21 @@
Import('env', 'arch', 'messaging', 'common', 'visionipc')
libs = [common, messaging, visionipc,
'avformat', 'avcodec', 'avutil',
'yuv', 'OpenCL', 'pthread', 'zstd']
'avformat', 'avcodec', 'swresample', 'avutil', 'x264',
'pthread', 'z', 'm', 'zstd']
src = ['logger.cc', 'zstd_writer.cc', 'video_writer.cc', 'encoder/encoder.cc', 'encoder/v4l_encoder.cc', 'encoder/jpeg_encoder.cc']
if arch != "larch64":
src += ['encoder/ffmpeg_encoder.cc']
libs += ['yuv']
if arch == "Darwin":
# fix OpenCL
del libs[libs.index('OpenCL')]
env['FRAMEWORKS'] = ['OpenCL']
# exclude v4l
# Exclude V4L on desktop mac builds.
del src[src.index('encoder/v4l_encoder.cc')]
env.Append(FRAMEWORKS=['OpenCL'])
else:
# visionipc ion/cl buffers reference OpenCL symbols at final link.
libs += ['OpenCL']
logger_lib = env.Library('logger', src)
libs.insert(0, logger_lib)
@@ -23,4 +25,4 @@ env.Program('encoderd', ['encoderd.cc'], LIBS=libs + ["jpeg"])
env.Program('bootlog.cc', LIBS=libs)
if GetOption('extras'):
env.Program('tests/test_logger', ['tests/test_runner.cc', 'tests/test_logger.cc', 'tests/test_zstd_writer.cc'], LIBS=libs + ['curl', 'crypto'])
env.Program('tests/test_logger', ['tests/test_runner.cc', 'tests/test_logger.cc', 'tests/test_zstd_writer.cc'], LIBS=libs)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@
#include "system/loggerd/loggerd.h"
#include "system/loggerd/encoder/jpeg_encoder.h"
#ifdef QCOM2
#ifdef __TICI__
#include "system/loggerd/encoder/v4l_encoder.h"
#define Encoder V4LEncoder
#else
BIN
View File
Binary file not shown.
+3 -15
View File
@@ -19,8 +19,6 @@ from openpilot.system.hardware.hw import Paths
from openpilot.system.loggerd.xattr_cache import getxattr, setxattr
from openpilot.common.swaglog import cloudlog
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
NetworkType = log.DeviceState.NetworkType
UPLOAD_ATTR_NAME = 'user.upload'
UPLOAD_ATTR_VALUE = b'1'
@@ -106,6 +104,7 @@ class Uploader:
for name in sorted(names, key=lambda n: self.immediate_priority.get(n, 1000)):
key = os.path.join(logdir, name)
fn = os.path.join(path, name)
# skip files already uploaded
try:
ctime = os.path.getctime(fn)
@@ -228,7 +227,7 @@ class Uploader:
return self.upload(name, key, fn, network_type, metered)
def main(exit_event: threading.Event = None) -> None:
def main(exit_event: threading.Event | None = None) -> None:
if exit_event is None:
exit_event = threading.Event()
@@ -250,18 +249,11 @@ def main(exit_event: threading.Event = None) -> None:
uploader = Uploader(dongle_id, Paths.log_root())
backoff = 0.1
# FrogPilot variables
sm = sm.extend(['frogpilotPlan'])
frogpilot_toggles = get_frogpilot_toggles()
while not exit_event.is_set():
sm.update(0)
offroad = params.get_bool("IsOffroad")
network_type = sm['deviceState'].networkType if not force_wifi else NetworkType.wifi
at_home = offroad and network_type in (NetworkType.ethernet, NetworkType.wifi)
if network_type == NetworkType.none or not at_home and frogpilot_toggles.no_onroad_uploads:
if network_type == NetworkType.none:
if allow_sleep:
time.sleep(60 if offroad else 5)
continue
@@ -277,9 +269,5 @@ def main(exit_event: threading.Event = None) -> None:
if allow_sleep:
time.sleep(backoff + random.uniform(0, backoff))
# FrogPilot variables
frogpilot_toggles = get_frogpilot_toggles(sm)
if __name__ == "__main__":
main()
+8 -4
View File
@@ -3,6 +3,7 @@
#include "system/loggerd/video_writer.h"
#include "common/swaglog.h"
#include "common/util.h"
#include <libavutil/version.h>
VideoWriter::VideoWriter(const char *path, const char *filename, bool remuxing, int width, int height, int fps, cereal::EncodeIndex::Type codec)
: remuxing(remuxing) {
@@ -57,10 +58,11 @@ void VideoWriter::initialize_audio(int sample_rate) {
assert(this->audio_codec_ctx);
this->audio_codec_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP;
this->audio_codec_ctx->sample_rate = sample_rate;
#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 28, 100) // FFmpeg 5.1+
av_channel_layout_default(&this->audio_codec_ctx->ch_layout, 1);
#if defined(AV_CHANNEL_LAYOUT_MONO)
this->audio_codec_ctx->ch_layout = AV_CHANNEL_LAYOUT_MONO;
#else
this->audio_codec_ctx->channel_layout = AV_CH_LAYOUT_MONO;
this->audio_codec_ctx->channels = 1;
#endif
this->audio_codec_ctx->bit_rate = 32000;
this->audio_codec_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
@@ -77,10 +79,12 @@ void VideoWriter::initialize_audio(int sample_rate) {
this->audio_frame = av_frame_alloc();
assert(this->audio_frame);
this->audio_frame->format = this->audio_codec_ctx->sample_fmt;
#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 28, 100) // FFmpeg 5.1+
av_channel_layout_copy(&this->audio_frame->ch_layout, &this->audio_codec_ctx->ch_layout);
#if defined(AV_CHANNEL_LAYOUT_MONO)
// Keep this symbol-free for older libavutil on-device: assign mono layout directly.
this->audio_frame->ch_layout = AV_CHANNEL_LAYOUT_MONO;
#else
this->audio_frame->channel_layout = this->audio_codec_ctx->channel_layout;
this->audio_frame->channels = this->audio_codec_ctx->channels;
#endif
this->audio_frame->sample_rate = this->audio_codec_ctx->sample_rate;
this->audio_frame->nb_samples = this->audio_codec_ctx->frame_size;
+62 -5
View File
@@ -1,17 +1,57 @@
import errno
import xattr
import os
try:
import xattr # type: ignore[import-not-found]
except ImportError:
xattr = None
_cached_attributes: dict[tuple, bytes | None] = {}
_backend_disabled = False
def _backend_getxattr(path: str, attr_name: str) -> bytes:
if xattr is not None:
return xattr.getxattr(path, attr_name)
if hasattr(os, "getxattr"):
return os.getxattr(path, attr_name)
raise OSError(errno.ENOTSUP, "xattr backend unavailable")
def _backend_setxattr(path: str, attr_name: str, attr_value: bytes) -> None:
if xattr is not None:
xattr.setxattr(path, attr_name, attr_value)
return
if hasattr(os, "setxattr"):
os.setxattr(path, attr_name, attr_value)
return
raise OSError(errno.ENOTSUP, "xattr backend unavailable")
def _is_missing_attr_error(e: OSError) -> bool:
return e.errno == errno.ENODATA or (hasattr(errno, "ENOATTR") and e.errno == errno.ENOATTR)
def _is_unsupported_error(e: OSError) -> bool:
unsupported_errnos = {errno.ENOTSUP, errno.EOPNOTSUPP, errno.ENOSYS}
return e.errno in unsupported_errnos
def getxattr(path: str, attr_name: str) -> bytes | None:
global _backend_disabled
key = (path, attr_name)
if key not in _cached_attributes:
response: bytes | None = None
try:
response = xattr.getxattr(path, attr_name)
if not _backend_disabled:
response = _backend_getxattr(path, attr_name)
except OSError as e:
# ENODATA (Linux) or ENOATTR (macOS) means attribute hasn't been set
if e.errno == errno.ENODATA or (hasattr(errno, 'ENOATTR') and e.errno == errno.ENOATTR):
if _is_missing_attr_error(e):
response = None
elif _is_unsupported_error(e):
_backend_disabled = True
response = None
else:
raise
@@ -19,5 +59,22 @@ def getxattr(path: str, attr_name: str) -> bytes | None:
return _cached_attributes[key]
def setxattr(path: str, attr_name: str, attr_value: bytes) -> None:
_cached_attributes.pop((path, attr_name), None)
xattr.setxattr(path, attr_name, attr_value)
global _backend_disabled
key = (path, attr_name)
if _backend_disabled:
# In environments without xattr support, keep behavior stable for this process.
_cached_attributes[key] = attr_value
return
try:
_backend_setxattr(path, attr_name, attr_value)
except OSError as e:
if _is_unsupported_error(e):
_backend_disabled = True
_cached_attributes[key] = attr_value
return
_cached_attributes.pop(key, None)
raise
_cached_attributes[key] = attr_value
+32 -10
View File
@@ -29,13 +29,22 @@ def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
if AGNOS:
HARDWARE.set_power_save(False)
os.sched_setaffinity(0, range(8)) # ensure we can use the isolcpus cores
# Extremely conservative on-device compile to avoid lowmemorykiller SIGKILLs.
attempts = [1]
else:
attempts = [nproc, max(1, nproc // 2), 1]
# building with all cores can result in using too
# much memory, so retry with less parallelism
# Preserve order while de-duplicating.
attempts = list(dict.fromkeys(max(1, int(n)) for n in attempts))
# building with all cores can result in using too much memory,
# so retry with less parallelism.
compile_output: list[bytes] = []
for n in (nproc, nproc/2, 1):
compile_output.clear()
scons: subprocess.Popen = subprocess.Popen(["scons", f"-j{int(n)}", "--cache-populate", *extra_args], cwd=BASEDIR, env=env, stderr=subprocess.PIPE)
last_returncode = 0
def run_scons(n: int, cache_args: list[str]) -> int:
nonlocal compile_output, spinner, env
scons: subprocess.Popen = subprocess.Popen(["scons", f"-j{int(n)}", *cache_args, *extra_args], cwd=BASEDIR, env=env, stderr=subprocess.PIPE)
assert scons.stderr is not None
# Read progress from stderr and update spinner
@@ -56,14 +65,27 @@ def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
except Exception:
pass
if scons.returncode == 0:
if scons.returncode != 0 and scons.stderr is not None:
compile_output += scons.stderr.read().split(b'\n')
return scons.returncode
for n in attempts:
compile_output.clear()
last_returncode = run_scons(n, ["--cache-populate"])
if last_returncode == 0:
break
if scons.returncode != 0:
# Read remaining output
if scons.stderr is not None:
compile_output += scons.stderr.read().split(b'\n')
if last_returncode != 0:
# OOM-ish builds often fail with "Error -9" when clang gets SIGKILL.
# Do one final conservative retry before surfacing a hard failure.
output_blob = b"\n".join(compile_output)
if AGNOS and b"Error -9" in output_blob:
cloudlog.warning("scons likely OOM-killed (Error -9), retrying with -j1 --cache-disable")
print("Build retry: detected Error -9, retrying with -j1 --cache-disable")
compile_output.clear()
last_returncode = run_scons(1, ["--cache-disable"])
if last_returncode != 0:
# Build failed log errors
error_s = b"\n".join(compile_output).decode('utf8', 'replace')
add_file_handler(cloudlog)
+239 -3
View File
@@ -1,16 +1,18 @@
#!/usr/bin/env python3
import datetime
import json
import os
from pathlib import Path
import signal
import sys
import time
import traceback
from cereal import log
from cereal import car, log
import cereal.messaging as messaging
import openpilot.system.sentry as sentry
from openpilot.common.utils import atomic_write
from openpilot.common.params import Params, ParamKeyFlag
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType
from openpilot.common.text_window import TextWindow
from openpilot.system.hardware import HARDWARE
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog
@@ -25,6 +27,229 @@ from openpilot.frogpilot.common.frogpilot_functions import frogpilot_boot_functi
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
LEGACY_BOLT_FP_MIGRATION_FLAG = Path("/data") / "legacy_bolt_fp_migration_v1"
STARPILOT_DEFAULTS_PARITY_MIGRATION_FLAG = Path("/data") / "starpilot_defaults_parity_v1"
STARPILOT_PARAM_CANONICALIZATION_MIGRATION_FLAG = Path("/data") / "starpilot_param_canonicalization_v1"
LEGACY_CARMODEL_MIGRATIONS = {
"CHEVROLET_BOLT_CC_2019_2021": "CHEVROLET_BOLT_CC_2018_2021",
}
def _to_text(value):
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore")
return str(value)
def migrate_legacy_bolt_fingerprint(params: Params) -> None:
old_fp, new_fp = next(iter(LEGACY_CARMODEL_MIGRATIONS.items()))
carparams_keys = ("CarParams", "CarParamsCache", "CarParamsPersistent", "CarParamsPrevRoute")
keys_to_clear = (
"CarParams",
"CarParamsCache",
"CarParamsPersistent",
"CarParamsPrevRoute",
"FrogPilotCarParams",
"FrogPilotCarParamsPersistent",
)
car_model = _to_text(params.get("CarModel"))
legacy_detected = car_model == old_fp
if not legacy_detected:
old_fp_bytes = old_fp.encode()
for key in carparams_keys:
raw = params.get(key)
if raw is None:
continue
raw_bytes = raw if isinstance(raw, bytes) else str(raw).encode()
# Fast path for payloads that still embed the legacy fingerprint string.
if old_fp_bytes in raw_bytes:
legacy_detected = True
break
# Fallback decode for payloads that don't expose the raw string directly.
try:
with car.CarParams.from_bytes(raw_bytes) as cp:
if cp.carFingerprint == old_fp:
legacy_detected = True
break
except Exception:
continue
if not legacy_detected:
return
cleared_keys: list[str] = []
for key in keys_to_clear:
if params.get(key) is None:
continue
params.remove(key)
cleared_keys.append(key)
if car_model == old_fp:
params.put("CarModel", new_fp)
car_model_name = _to_text(params.get("CarModelName")) or ""
if "2019-21" in car_model_name:
params.put("CarModelName", car_model_name.replace("2019-21", "2018-21"))
cloudlog.warning(
f"Detected legacy Bolt fingerprint {old_fp}; cleared={cleared_keys}, remapped CarModel to {new_fp}"
)
try:
LEGACY_BOLT_FP_MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
LEGACY_BOLT_FP_MIGRATION_FLAG.write_text(f"{datetime.datetime.now(datetime.UTC).isoformat()}\n")
except Exception:
cloudlog.exception(f"Failed to write migration flag: {LEGACY_BOLT_FP_MIGRATION_FLAG}")
def migrate_starpilot_default_parity(params: Params, params_cache: Params) -> None:
if STARPILOT_DEFAULTS_PARITY_MIGRATION_FLAG.exists():
return
desired_bool_values = {
"AdvancedLateralTune": True,
"ForceAutoTuneOff": True,
"HumanAcceleration": False,
"HumanFollowing": False,
"NNFF": False,
"NNFFLite": False,
}
for key, value in desired_bool_values.items():
params.put_bool(key, value)
params_cache.put_bool(key, value)
params.put_float("CEModelStopTime", 7.0)
params_cache.put_float("CEModelStopTime", 7.0)
cloudlog.warning("Applied one-time StarPilot default parity migration for lateral/longitudinal toggles")
try:
STARPILOT_DEFAULTS_PARITY_MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
STARPILOT_DEFAULTS_PARITY_MIGRATION_FLAG.write_text(f"{datetime.datetime.now(datetime.UTC).isoformat()}\n")
except Exception:
cloudlog.exception(f"Failed to write migration flag: {STARPILOT_DEFAULTS_PARITY_MIGRATION_FLAG}")
def _read_raw_param_bytes(params: Params, key: str | bytes):
try:
path = params.get_param_path(key)
except Exception:
return None
if not path or not os.path.isfile(path):
return None
try:
with open(path, "rb") as f:
return f.read()
except Exception:
return None
def _parse_legacy_time(raw_text: str):
text = raw_text.strip()
if not text:
return None
try:
return datetime.datetime.fromisoformat(text)
except ValueError:
pass
for fmt in ("%B %d, %Y - %I:%M%p", "%B %d, %Y - %I:%M %p"):
try:
return datetime.datetime.strptime(text, fmt)
except ValueError:
continue
return None
def migrate_param_type_canonicalization(params: Params) -> None:
if STARPILOT_PARAM_CANONICALIZATION_MIGRATION_FLAG.exists():
return
normalized_keys: list[str] = []
for raw_key in params.all_keys():
key = raw_key.decode() if isinstance(raw_key, bytes) else str(raw_key)
raw_value = _read_raw_param_bytes(params, raw_key)
if not raw_value:
continue
try:
text_value = raw_value.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError:
continue
if not text_value:
continue
try:
expected_type = params.get_type(raw_key)
except Exception:
continue
try:
if expected_type == ParamKeyType.INT:
parsed = float(text_value)
# Canonicalize decimal/exponent forms into integer storage.
canonical = str(int(parsed))
if canonical != text_value:
params.put_int(raw_key, int(parsed))
normalized_keys.append(key)
elif expected_type == ParamKeyType.FLOAT:
parsed = float(text_value)
canonical = str(parsed)
if canonical != text_value:
params.put_float(raw_key, parsed)
normalized_keys.append(key)
elif expected_type == ParamKeyType.BOOL:
lowered = text_value.lower()
if lowered in ("1", "true", "yes", "on"):
if text_value != "1":
params.put_bool(raw_key, True)
normalized_keys.append(key)
elif lowered in ("0", "false", "no", "off"):
if text_value != "0":
params.put_bool(raw_key, False)
normalized_keys.append(key)
elif expected_type == ParamKeyType.TIME:
dt = _parse_legacy_time(text_value)
if dt is not None:
if dt.tzinfo is not None:
dt = dt.astimezone(datetime.UTC).replace(tzinfo=None)
if text_value != dt.isoformat():
params.put(raw_key, dt)
normalized_keys.append(key)
elif expected_type == ParamKeyType.JSON:
parsed = json.loads(text_value)
canonical = json.dumps(parsed, separators=(",", ":"))
if canonical != text_value:
params.put(raw_key, parsed)
normalized_keys.append(key)
except Exception:
continue
if normalized_keys:
cloudlog.warning(f"Canonicalized legacy param values for {len(normalized_keys)} keys")
try:
STARPILOT_PARAM_CANONICALIZATION_MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
STARPILOT_PARAM_CANONICALIZATION_MIGRATION_FLAG.write_text(f"{datetime.datetime.now(datetime.UTC).isoformat()}\n")
except Exception:
cloudlog.exception(f"Failed to write migration flag: {STARPILOT_PARAM_CANONICALIZATION_MIGRATION_FLAG}")
def manager_init() -> None:
save_bootlog()
@@ -42,7 +267,14 @@ def manager_init() -> None:
params.put_bool("RecordFront", True)
# FrogPilot variables
params_cache = Params("/cache/params", return_defaults=True)
cache_params_path = "/cache/params"
if HARDWARE.get_device_type() == "pc":
cache_params_path = os.path.join(Paths.comma_home(), "cache", "params")
params_cache = Params(cache_params_path, return_defaults=True)
# Canonicalize legacy string encodings (e.g. INT params stored as "26.000000")
# before bulk reads below to avoid repeated cast warnings and UI-side churn.
migrate_param_type_canonicalization(params)
# set unset params to their default value
for k in params.all_keys():
@@ -75,6 +307,10 @@ def manager_init() -> None:
params.put_bool("IsReleaseBranch", build_metadata.release_channel)
params.put("HardwareSerial", serial)
# Branch migration: rename legacy Bolt fingerprint persisted in CarParams.
migrate_legacy_bolt_fingerprint(params)
migrate_starpilot_default_parity(params, params_cache)
# set dongle id
reg_res = register(show_spinner=True)
if reg_res:
+19 -6
View File
@@ -22,8 +22,11 @@ from openpilot.common.watchdog import WATCHDOG_FN
ENABLE_WATCHDOG = os.getenv("NO_WATCHDOG") is None
def launcher(proc: str, name: str) -> None:
def launcher(proc: str, name: str, nice: int | None = None) -> None:
try:
if nice is not None:
os.nice(nice)
# import the process
mod = importlib.import_module(proc)
@@ -48,7 +51,10 @@ def launcher(proc: str, name: str) -> None:
raise
def nativelauncher(pargs: list[str], cwd: str, name: str) -> None:
def nativelauncher(pargs: list[str], cwd: str, name: str, nice: int | None = None) -> None:
if nice is not None:
os.nice(nice)
os.environ['MANAGER_DAEMON'] = name
# exec the process
@@ -168,7 +174,7 @@ class ManagerProcess(ABC):
class NativeProcess(ManagerProcess):
def __init__(self, name, cwd, cmdline, should_run, enabled=True, sigkill=False, watchdog_max_dt=None):
def __init__(self, name, cwd, cmdline, should_run, enabled=True, sigkill=False, watchdog_max_dt=None, nice=None):
self.name = name
self.cwd = cwd
self.cmdline = cmdline
@@ -176,6 +182,7 @@ class NativeProcess(ManagerProcess):
self.enabled = enabled
self.sigkill = sigkill
self.watchdog_max_dt = watchdog_max_dt
self.nice = nice
self.launcher = nativelauncher
def prepare(self) -> None:
@@ -191,20 +198,21 @@ class NativeProcess(ManagerProcess):
cwd = os.path.join(BASEDIR, self.cwd)
cloudlog.info(f"starting process {self.name}")
self.proc = Process(name=self.name, target=self.launcher, args=(self.cmdline, cwd, self.name))
self.proc = Process(name=self.name, target=self.launcher, args=(self.cmdline, cwd, self.name, self.nice))
self.proc.start()
self.watchdog_seen = False
self.shutting_down = False
class PythonProcess(ManagerProcess):
def __init__(self, name, module, should_run, enabled=True, sigkill=False, watchdog_max_dt=None):
def __init__(self, name, module, should_run, enabled=True, sigkill=False, watchdog_max_dt=None, nice=None):
self.name = name
self.module = module
self.should_run = should_run
self.enabled = enabled
self.sigkill = sigkill
self.watchdog_max_dt = watchdog_max_dt
self.nice = nice
self.launcher = launcher
def prepare(self) -> None:
@@ -225,7 +233,7 @@ class PythonProcess(ManagerProcess):
name = self.name if "modeld" not in self.name else "MainProcess"
cloudlog.info(f"starting python {self.module}")
self.proc = Process(name=name, target=self.launcher, args=(self.module, self.name))
self.proc = Process(name=name, target=self.launcher, args=(self.module, self.name, self.nice))
self.proc.start()
self.watchdog_seen = False
self.shutting_down = False
@@ -284,6 +292,11 @@ def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None
running = []
for p in procs:
# Reap crashed processes so they can be cleanly restarted below.
if p.proc is not None and p.proc.exitcode is not None and not p.shutting_down:
cloudlog.error(f"Process {p.name} crashed with exitcode {p.proc.exitcode}, restarting")
p.stop(retry=False)
if p.enabled and p.name not in not_run and p.should_run(started, params, CP, frogpilot_toggles):
running.append(p)
else:
+11 -7
View File
@@ -68,7 +68,7 @@ def allow_logging(started: bool, params: Params, CP: car.CarParams, frogpilot_to
return not frogpilot_toggles.no_logging
def allow_uploads(started: bool, params: Params, CP: car.CarParams, frogpilot_toggles: SimpleNamespace) -> bool:
return not frogpilot_toggles.no_uploads or frogpilot_toggles.no_onroad_uploads
return params.get_bool("AlwaysAllowUploads") or not frogpilot_toggles.no_uploads or frogpilot_toggles.no_onroad_uploads
def run_speed_limit_filler(started: bool, params: Params, CP: car.CarParams, frogpilot_toggles: SimpleNamespace) -> bool:
return frogpilot_toggles.speed_limit_filler
@@ -88,8 +88,8 @@ procs = [
PythonProcess("micd", "system.micd", iscar),
PythonProcess("timed", "system.timed", always_run, enabled=not PC),
PythonProcess("modeld", "selfdrive.modeld.modeld", only_onroad),
PythonProcess("dmonitoringmodeld", "selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)),
PythonProcess("modeld", "frogpilot.tinygrad_modeld.tinygrad_modeld", only_onroad),
PythonProcess("dmonitoringmodeld", "frogpilot.tinygrad_modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)),
PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC),
PythonProcess("soundd", "selfdrive.ui.soundd", driverview),
@@ -127,14 +127,18 @@ procs = [
]
# FrogPilot variables
if HARDWARE.get_device_type() == "mici":
procs.append(PythonProcess("ui", "selfdrive.ui.ui", always_run))
elif TICI:
procs.append(NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=5)),
device_type = HARDWARE.get_device_type()
if device_type in ("tici", "tizi"):
procs.append(NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=5))
else:
# C4 (mici) runs the Python raylib UI path; keep watchdog parity with C3/C3X.
procs.append(PythonProcess("ui", "selfdrive.ui.ui", always_run, watchdog_max_dt=5))
procs += [
PythonProcess("device_syncd", "frogpilot.system.device_syncd", always_run),
PythonProcess("frogpilot_process", "frogpilot.frogpilot_process", always_run),
NativeProcess("mapd", "frogpilot/navigation", ["./mapd"], always_run),
PythonProcess("the_pond", "frogpilot.system.the_pond.the_pond", always_run, nice=19),
PythonProcess("galaxy", "frogpilot.system.galaxy.galaxy", always_run, nice=19),
PythonProcess("speed_limit_filler", "frogpilot.system.speed_limit_filler", run_speed_limit_filler),
]
+46 -6
View File
@@ -2,6 +2,7 @@
import numpy as np
from functools import cache
import threading
import time
from cereal import messaging
from openpilot.common.realtime import Ratekeeper
@@ -95,20 +96,59 @@ class Mic:
self.measurements = self.measurements[FFT_SAMPLES:]
@retry(attempts=10, delay=3)
def get_stream(self, sd):
def get_stream(self, sd, device=None):
# reload sounddevice to reinitialize portaudio
sd._terminate()
sd._initialize()
return sd.InputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER)
kwargs = {
"channels": 1,
"samplerate": SAMPLE_RATE,
"callback": self.callback,
"blocksize": SAMPLE_BUFFER,
}
if device is not None:
kwargs["device"] = device
return sd.InputStream(**kwargs)
def get_input_devices(self, sd):
# Try default first, then explicit input-capable devices as fallback.
devices = [None]
try:
for i, dev in enumerate(sd.query_devices()):
if dev.get("max_input_channels", 0) > 0:
devices.append(i)
except Exception:
cloudlog.exception("micd: failed to enumerate audio devices")
# Preserve order while deduplicating.
return list(dict.fromkeys(devices))
def micd_thread(self):
# sounddevice must be imported after forking processes
import sounddevice as sd
with self.get_stream(sd) as stream:
cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}")
while True:
self.update()
while True:
stream = None
for device in self.get_input_devices(sd):
try:
stream = self.get_stream(sd, device=device)
break
except Exception:
cloudlog.exception(f"micd: failed to open input stream (device={device})")
if stream is None:
cloudlog.error("micd: no valid input device, retrying")
time.sleep(5)
continue
try:
with stream:
cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}")
while True:
self.update()
except Exception:
cloudlog.exception("micd: stream failed, restarting")
time.sleep(1)
def main():
+56 -33
View File
@@ -1,4 +1,6 @@
"""Install exception handler for process crash."""
import glob
import os
import re
import sentry_sdk
import traceback
from datetime import datetime
@@ -6,20 +8,18 @@ from enum import Enum
from sentry_sdk.integrations.threading import ThreadingIntegration
from openpilot.common.params import Params
from openpilot.system.athena.registration import is_registered_device
from openpilot.system.hardware import HARDWARE, PC
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.hw import Paths
from openpilot.system.version import get_build_metadata, get_version
from openpilot.frogpilot.common.frogpilot_utilities import get_sentry_dsn
from openpilot.frogpilot.common.frogpilot_variables import ERROR_LOGS_PATH
from openpilot.frogpilot.common.frogpilot_variables import ERROR_LOGS_PATH, params
class SentryProject(Enum):
# python project
SELFDRIVE = "https://6f3c7076c1e14b2aa10f5dde6dda0cc4@o33823.ingest.sentry.io/77924"
SELFDRIVE = "https://7305139359a548fcb348ec09497dc389@bugsink.firestar.link/1"
# native project
SELFDRIVE_NATIVE = "https://3e4b586ed21a4479ad5d85083b639bc6@o33823.ingest.sentry.io/157615"
SELFDRIVE_NATIVE = "https://7305139359a548fcb348ec09497dc389@bugsink.firestar.link/1"
def report_tombstone(fn: str, message: str, contents: str) -> None:
@@ -28,35 +28,63 @@ def report_tombstone(fn: str, message: str, contents: str) -> None:
with sentry_sdk.configure_scope() as scope:
scope.set_extra("tombstone_fn", fn)
scope.set_extra("tombstone", contents)
# Attach qlog for debugging context
qlogs = glob.glob(f"{Paths.log_root()}/*/qlog")
if qlogs:
scope.add_attachment(path=max(qlogs, key=os.path.getmtime), filename="qlog")
sentry_sdk.capture_message(message=message)
sentry_sdk.flush()
def capture_block() -> None:
def capture_block():
with sentry_sdk.push_scope() as scope:
sentry_sdk.capture_message("Blocked user from using the development branch", level="info")
sentry_sdk.capture_message("Blocked user from using the development branch", level='info')
sentry_sdk.flush()
def capture_exception(*args, crash_log=True, **kwargs) -> None:
exc_text = traceback.format_exc()
errors_to_ignore = [
phrases_to_check = [
"already exists. To overwrite it, set 'overwrite' to True",
"failed after retry",
]
if any(error in exc_text for error in errors_to_ignore):
if any(phrase in exc_text for phrase in phrases_to_check):
return
save_exception(exc_text, crash_log)
cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1))
try:
sentry_sdk.capture_exception(*args, **kwargs)
sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291
with sentry_sdk.push_scope() as scope:
# Attach qlog for debugging context
qlogs = glob.glob(f"{Paths.log_root()}/*/qlog")
if qlogs:
scope.add_attachment(path=max(qlogs, key=os.path.getmtime), filename="qlog")
sentry_sdk.capture_exception(*args, **kwargs)
sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291
except Exception:
cloudlog.exception("sentry exception")
def capture_report(discord_user, report, frogpilot_toggles):
error_file_path = ERROR_LOGS_PATH / "error.txt"
error_content = "No error log found."
if error_file_path.exists():
error_content = error_file_path.read_text()
with sentry_sdk.push_scope() as scope:
scope.set_context("Error Log", {"content": error_content})
scope.set_context("Toggle Values", frogpilot_toggles)
sentry_sdk.capture_message(f"{discord_user} submitted report: {report}", level="fatal")
sentry_sdk.flush()
def set_tag(key: str, value: str) -> None:
sentry_sdk.set_tag(key, value)
@@ -76,32 +104,25 @@ def save_exception(exc_text: str, crash_log) -> None:
def init(project: SentryProject) -> bool:
build_metadata = get_build_metadata()
# forks like to mess with this, so double check
FrogPilot = "frogai" in build_metadata.openpilot.git_origin.lower()
if not FrogPilot or PC:
if PC:
return False
build_metadata = get_build_metadata()
short_branch = build_metadata.channel
if short_branch in ["COMMA", "HEAD"]:
return False
elif short_branch == "FrogPilot-Development":
env = "Development"
elif build_metadata.release_channel:
env = "Release"
elif short_branch == "FrogPilot-Testing":
env = short_branch
if re.search("test", short_branch, re.IGNORECASE):
env = "Testing"
elif build_metadata.tested_channel:
env = "Staging"
else:
env = short_branch
dongle_id = params.get("DongleId", encoding="utf-8")
installed = params.get("InstallDate", encoding="utf-8")
updated = params.get("Updated", encoding="utf-8")
integrations = []
if project == SentryProject.SELFDRIVE:
integrations.append(ThreadingIntegration(propagate_hub=True))
sentry_sdk.init(get_sentry_dsn(),
sentry_sdk.init(project.value,
default_integrations=False,
release=get_version(),
integrations=integrations,
@@ -109,12 +130,14 @@ def init(project: SentryProject) -> bool:
max_value_length=8192,
environment=env)
params = Params()
sentry_sdk.set_user({"id": params.get("DongleId")})
sentry_sdk.set_user({"id": dongle_id})
sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin)
sentry_sdk.set_tag("branch", short_branch)
sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit)
sentry_sdk.set_tag("updated", params.get("Updated"))
sentry_sdk.set_tag("updated", updated)
sentry_sdk.set_tag("installed", installed)
if project == SentryProject.SELFDRIVE:
sentry_sdk.Hub.current.start_session()
return True
+16 -7
View File
@@ -3,7 +3,6 @@ import datetime
import os
import subprocess
import time
from timezonefinder import TimezoneFinder
from typing import NoReturn
import cereal.messaging as messaging
@@ -13,6 +12,11 @@ from openpilot.common.params import Params
from openpilot.common.gps import get_gps_location_service
from openpilot.system.hardware import AGNOS
try:
from timezonefinder import TimezoneFinder
except Exception:
TimezoneFinder = None
def set_time(new_time):
diff = datetime.datetime.now() - new_time
@@ -62,7 +66,8 @@ def main() -> NoReturn:
sm = messaging.SubMaster([gps_location_service])
# FrogPilot variables
tf = TimezoneFinder()
tf = TimezoneFinder() if TimezoneFinder is not None else None
timezonefinder_logged = False
last_timezone = params.get("Timezone")
if last_timezone is not None:
@@ -88,11 +93,15 @@ def main() -> NoReturn:
set_time(gps_time)
# FrogPilot variables
timezone = tf.timezone_at(lng=gps.longitude, lat=gps.latitude)
if timezone is not None and timezone != last_timezone:
set_timezone(timezone)
params.put_nonblocking("Timezone", timezone)
last_timezone = timezone
if tf is not None:
timezone = tf.timezone_at(lng=gps.longitude, lat=gps.latitude)
if timezone is not None and timezone != last_timezone:
set_timezone(timezone)
params.put_nonblocking("Timezone", timezone)
last_timezone = timezone
elif not timezonefinder_logged:
cloudlog.warning("TimezoneFinder unavailable, skipping automatic timezone updates")
timezonefinder_logged = True
time.sleep(10)
+77 -23
View File
@@ -51,7 +51,9 @@ if platform.system() == "Darwin":
"""
BURN_IN_MODE = "BURN_IN" in os.environ
BURN_IN_VERTEX_SHADER = GL_VERSION + """
BURN_IN_VERTEX_SHADER = (
GL_VERSION
+ """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
@@ -61,7 +63,10 @@ void main() {
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
)
BURN_IN_FRAGMENT_SHADER = (
GL_VERSION
+ """
in vec2 fragTexCoord;
uniform sampler2D texture0;
out vec4 fragColor;
@@ -77,6 +82,7 @@ void main() {
fragColor = vec4(gradient, sampled.a);
}
"""
)
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
@@ -254,9 +260,11 @@ class GuiApplication:
def init_window(self, title: str, fps: int = _DEFAULT_FPS):
with self._startup_profile_context():
def _close(sig, frame):
self.close()
sys.exit(0)
signal.signal(signal.SIGINT, _close)
atexit.register(self.close)
@@ -280,19 +288,29 @@ class GuiApplication:
if RECORD:
ffmpeg_args = [
'ffmpeg',
'-v', 'warning', # Reduce ffmpeg log spam
'-stats', # Show encoding progress
'-f', 'rawvideo', # Input format
'-pix_fmt', 'rgba', # Input pixel format
'-s', f'{self._width}x{self._height}', # Input resolution
'-r', str(fps), # Input frame rate
'-i', 'pipe:0', # Input from stdin
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v', 'libx264', # Video codec
'-preset', 'ultrafast', # Encoding speed
'-y', # Overwrite existing file
'-f', 'mp4', # Output format
RECORD_OUTPUT, # Output file path
'-v',
'warning', # Reduce ffmpeg log spam
'-stats', # Show encoding progress
'-f',
'rawvideo', # Input format
'-pix_fmt',
'rgba', # Input pixel format
'-s',
f'{self._width}x{self._height}', # Input resolution
'-r',
str(fps), # Input frame rate
'-i',
'pipe:0', # Input from stdin
'-vf',
'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v',
'libx264', # Video codec
'-preset',
'ultrafast', # Encoding speed
'-y', # Overwrite existing file
'-f',
'mp4', # Output format
RECORD_OUTPUT, # Output file path
]
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
@@ -354,8 +372,7 @@ class GuiApplication:
def set_should_render(self, should_render: bool):
self._should_render = should_render
def texture(self, asset_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply=False, keep_aspect_ratio=True):
def texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
@@ -366,11 +383,28 @@ class GuiApplication:
self._textures[cache_key] = texture_obj
return texture_obj
def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply: bool = False, keep_aspect_ratio: bool = True) -> rl.Image:
def starpilot_texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True):
"""Load a texture from the FrogPilot assets folder."""
cache_key = f"starpilot_{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
frogpilot_assets = files("openpilot.frogpilot").joinpath("assets")
with as_file(frogpilot_assets.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
def _load_image_from_path(
self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True
) -> rl.Image:
"""Load and resize an image, storing it for later automatic unloading."""
image = rl.load_image(image_path)
if image.width == 0 or image.height == 0:
return image
if alpha_premultiply:
rl.image_alpha_premultiply(image)
@@ -458,6 +492,7 @@ class GuiApplication:
try:
if self._profile_render_frames > 0:
import cProfile
self._render_profiler = cProfile.Profile()
self._render_profile_start_time = time.monotonic()
self._render_profiler.enable()
@@ -577,8 +612,9 @@ class GuiApplication:
return False
def _load_fonts(self):
for font_weight_file in FontWeight:
with as_file(FONT_DIR) as fspath:
self._ensure_font_atlases()
with as_file(FONT_DIR) as fspath:
for font_weight_file in FontWeight:
fnt_path = fspath / font_weight_file
font = rl.load_font(fnt_path.as_posix())
if font_weight_file != FontWeight.UNIFONT:
@@ -586,6 +622,24 @@ class GuiApplication:
self._fonts[font_weight_file] = font
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
def _ensure_font_atlases(self):
with as_file(FONT_DIR) as fspath:
required_fonts = [fspath / fw.value for fw in FontWeight]
missing_fonts = [font_path.name for font_path in required_fonts if not font_path.exists()]
if not missing_fonts:
return
process_script = fspath / "process.py"
if not process_script.exists():
cloudlog.warning(f"Missing font atlases {missing_fonts}, but no generator found at {process_script}")
return
cloudlog.warning(f"Generating missing font atlases: {missing_fonts}")
try:
subprocess.run([sys.executable, process_script.as_posix()], check=True, cwd=fspath.as_posix())
except Exception:
cloudlog.exception("Failed to generate font atlases")
def _set_styles(self):
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0)
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE)
@@ -709,11 +763,11 @@ class GuiApplication:
green = "\033[92m"
reset = "\033[0m"
print(f"\n{green}Rendered {self._frame} frames in {elapsed_ms:.1f} ms{reset}")
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000/avg_frame_time:.1f} FPS){reset}")
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000 / avg_frame_time:.1f} FPS){reset}")
sys.exit(0)
def _calculate_auto_scale(self) -> float:
# Create temporary window to query monitor info
# Create temporary window to query monitor info
rl.init_window(1, 1, "")
w, h = rl.get_monitor_width(0), rl.get_monitor_height(0)
rl.close_window()
+434 -13
View File
@@ -1,21 +1,47 @@
import atexit
import os
import threading
import time
import uuid
import subprocess
import shutil
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
from jeepney import DBusAddress, new_method_call
from jeepney.bus_messages import MatchRule, message_bus
from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking
from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
try:
from jeepney import DBusAddress, new_method_call
from jeepney.bus_messages import MatchRule, message_bus
from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking
from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
JEEPNY_AVAILABLE = True
JEEPNY_IMPORT_ERROR: Exception | None = None
except Exception as e:
JEEPNY_AVAILABLE = False
JEEPNY_IMPORT_ERROR = e
DBusAddress = Any # type: ignore[assignment]
DBusRouter = Any # type: ignore[assignment]
MatchRule = Any # type: ignore[assignment]
MessageType = Any # type: ignore[assignment]
Properties = Any # type: ignore[assignment]
def new_method_call(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def message_bus(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def open_dbus_connection_blocking(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
def open_dbus_connection_threading(*_args, **_kwargs):
raise RuntimeError("jeepney is unavailable")
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40,
NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40,
NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK,
@@ -36,6 +62,8 @@ TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
SCAN_PERIOD_SECONDS = 5
DESKTOP_FAKE_IP = "192.168.1.42"
TRUE_VALUES = {"1", "true", "yes", "on"}
class SecurityType(IntEnum):
@@ -130,17 +158,42 @@ class WifiManager:
self._networks: list[Network] = [] # a network can be comprised of multiple APs
self._active = True # used to not run when not in settings
self._exit = False
self._fake_networking = False
self._nmcli_networking = False
self._dbus_available = False
allow_desktop_fake = PC and os.getenv("SP_ALLOW_DESKTOP_FAKE_WIFI", "0").lower() in TRUE_VALUES
has_nmcli = shutil.which("nmcli") is not None
# DBus connections
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
except FileNotFoundError:
cloudlog.exception("Failed to connect to system D-Bus")
if not JEEPNY_AVAILABLE:
cloudlog.warning(f"jeepney unavailable: {JEEPNY_IMPORT_ERROR}")
self._router_main = None
self._conn_monitor = None
self._exit = True
self._nm = None
if allow_desktop_fake:
self._fake_networking = True
elif has_nmcli:
self._nmcli_networking = True
else:
cloudlog.error("No networking backend available (jeepney missing, nmcli unavailable)")
else:
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
self._dbus_available = True
except Exception as e:
cloudlog.warning(f"Failed to connect to system D-Bus: {e}")
self._router_main = None
self._conn_monitor = None
self._nm = None
if allow_desktop_fake:
self._fake_networking = True
elif has_nmcli:
self._nmcli_networking = True
else:
cloudlog.error("No networking backend available (D-Bus unavailable, nmcli unavailable)")
# Store wifi device path
self._wifi_device: str | None = None
@@ -154,12 +207,16 @@ class WifiManager:
self._last_network_update: float = 0.0
self._callback_queue: list[Callable] = []
self._fake_connected_ssid: str | None = None
self._fake_known_networks: dict[str, dict[str, Any]] = {}
self._tethering_ssid = "weedle"
if Params is not None:
dongle_id = Params().get("DongleId")
if dongle_id:
self._tethering_ssid += "-" + dongle_id[:4]
if self._fake_networking:
self._init_fake_networking()
# Callbacks
self._need_auth: list[Callable[[str], None]] = []
@@ -176,6 +233,19 @@ class WifiManager:
def _initialize(self):
def worker():
if self._fake_networking:
self._update_networks()
cloudlog.debug("WifiManager initialized in fake networking mode")
return
if self._nmcli_networking:
self._update_networks()
self._scan_thread.start()
cloudlog.debug("WifiManager initialized in nmcli networking mode")
return
if not self._dbus_available:
cloudlog.error("WifiManager unavailable: no active networking backend")
return
self._wait_for_wifi_device()
self._scan_thread.start()
@@ -189,6 +259,51 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _init_fake_networking(self):
primary_ssid = os.getenv("FAKE_WIFI_SSID", "Laptop Wi-Fi")
self._fake_known_networks = {
primary_ssid: {"security": SecurityType.WPA, "saved": True, "strength": 96},
"Coffee Shop": {"security": SecurityType.OPEN, "saved": False, "strength": 68},
"Phone Hotspot": {"security": SecurityType.WPA, "saved": False, "strength": 54},
}
self._fake_connected_ssid = primary_ssid
self._tethering_password = DEFAULT_TETHERING_PASSWORD
self._current_network_metered = MeteredType.NO
self._ipv4_address = DESKTOP_FAKE_IP
def _update_networks_fake(self):
with self._lock:
networks: list[Network] = []
for ssid, values in self._fake_known_networks.items():
networks.append(Network(
ssid=ssid,
strength=int(values["strength"]),
is_connected=ssid == self._fake_connected_ssid,
security_type=values["security"],
is_saved=bool(values["saved"]),
))
if self._fake_connected_ssid == self._tethering_ssid:
if self._tethering_ssid not in self._fake_known_networks:
networks.append(Network(
ssid=self._tethering_ssid,
strength=100,
is_connected=True,
security_type=SecurityType.WPA,
is_saved=True,
))
self._ipv4_address = TETHERING_IP_ADDRESS
self._current_network_metered = MeteredType.UNKNOWN
elif self._fake_connected_ssid is None:
self._ipv4_address = ""
self._current_network_metered = MeteredType.UNKNOWN
else:
self._ipv4_address = DESKTOP_FAKE_IP
networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()))
self._networks = networks
self._enqueue_callbacks(self._networks_updated, self._networks)
def add_callbacks(self, need_auth: Callable[[str], None] | None = None,
activated: Callable[[], None] | None = None,
forgotten: Callable[[], None] | None = None,
@@ -229,12 +344,19 @@ class WifiManager:
def set_active(self, active: bool):
self._active = active
if self._fake_networking or self._nmcli_networking:
if active:
self._update_networks()
return
# Scan immediately if we haven't scanned in a while
if active and time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS / 2:
self._last_network_update = 0.0
def _monitor_state(self):
if not self._dbus_available:
return
rule = MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
@@ -374,6 +496,44 @@ class WifiManager:
self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,)))
def connect_to_network(self, ssid: str, password: str, hidden: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("connect_to_network called with no available networking backend")
return
if self._fake_networking:
def worker():
self._connecting_to_ssid = ssid
security = SecurityType.WPA if password else SecurityType.OPEN
if ssid not in self._fake_known_networks:
self._fake_known_networks[ssid] = {"security": security, "saved": True, "strength": 82}
else:
self._fake_known_networks[ssid]["saved"] = True
self._fake_known_networks[ssid]["security"] = security
self._fake_connected_ssid = ssid
self._connecting_to_ssid = ""
self._update_networks()
self._enqueue_callbacks(self._activated)
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
self._connecting_to_ssid = ssid
cmd = ["nmcli", "device", "wifi", "connect", ssid]
if password:
cmd += ["password", password]
if hidden:
cmd += ["hidden", "yes"]
result = subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._connecting_to_ssid = ""
self._update_networks()
if result.returncode == 0:
self._enqueue_callbacks(self._activated)
else:
self._enqueue_callbacks(self._need_auth, ssid)
threading.Thread(target=worker, daemon=True).start()
return
def worker():
# Clear all connections that may already exist to the network we are connecting to
self._connecting_to_ssid = ssid
@@ -412,6 +572,54 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def forget_connection(self, ssid: str, block: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("forget_connection called with no available networking backend")
return
if self._fake_networking:
def worker():
self._fake_known_networks.pop(ssid, None)
was_connected = self._fake_connected_ssid == ssid
if was_connected:
replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None)
self._fake_connected_ssid = replacement
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if was_connected and self._fake_connected_ssid is None:
self._enqueue_callbacks(self._disconnected)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
try:
conns = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
check=False, capture_output=True, text=True,
)
deleted = False
for line in conns.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) >= 3 and parts[1] == "802-11-wireless" and (parts[0] == ssid or parts[2] == ssid):
subprocess.run(["nmcli", "connection", "delete", "id", parts[0]], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deleted = True
if not deleted:
subprocess.run(["nmcli", "connection", "delete", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
cloudlog.warning(f"nmcli forget failed for {ssid}: {e}")
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
@@ -428,6 +636,44 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def activate_connection(self, ssid: str, block: bool = False):
if not (self._dbus_available or self._fake_networking or self._nmcli_networking):
cloudlog.warning("activate_connection called with no available networking backend")
return
if self._fake_networking:
def worker():
if ssid not in self._fake_known_networks and ssid != self._tethering_ssid:
return
self._connecting_to_ssid = ssid
if ssid == self._tethering_ssid and ssid not in self._fake_known_networks:
self._fake_known_networks[ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100}
else:
self._fake_known_networks[ssid]["saved"] = True
self._fake_connected_ssid = ssid
self._connecting_to_ssid = ""
self._update_networks()
self._enqueue_callbacks(self._activated)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
def worker():
self._connecting_to_ssid = ssid
result = subprocess.run(["nmcli", "connection", "up", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._connecting_to_ssid = ""
self._update_networks()
if result.returncode == 0:
self._enqueue_callbacks(self._activated)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
return
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
@@ -445,6 +691,19 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _deactivate_connection(self, ssid: str):
if self._fake_networking:
if self._fake_connected_ssid == ssid:
self._fake_connected_ssid = None
self._update_networks()
self._enqueue_callbacks(self._disconnected)
return
if self._nmcli_networking:
subprocess.run(["nmcli", "connection", "down", "id", ssid], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._update_networks()
self._enqueue_callbacks(self._disconnected)
return
for conn_path in self._get_active_connections():
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1]
@@ -464,6 +723,13 @@ class WifiManager:
return False
def set_tethering_password(self, password: str):
if self._fake_networking:
self._tethering_password = password
return
if self._nmcli_networking:
self._tethering_password = password
return
def worker():
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
@@ -490,6 +756,11 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _get_tethering_password(self) -> str:
if self._fake_networking:
return self._tethering_password
if self._nmcli_networking:
return self._tethering_password or DEFAULT_TETHERING_PASSWORD
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
@@ -514,6 +785,24 @@ class WifiManager:
self._ipv4_forward = enabled
def set_tethering_active(self, active: bool):
if self._fake_networking:
def worker():
if active:
if self._tethering_ssid not in self._fake_known_networks:
self._fake_known_networks[self._tethering_ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100}
self._fake_connected_ssid = self._tethering_ssid
else:
if self._fake_connected_ssid == self._tethering_ssid:
replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None)
self._fake_connected_ssid = replacement
self._update_networks()
threading.Thread(target=worker, daemon=True).start()
return
if self._nmcli_networking:
cloudlog.warning("Tethering control is not supported via nmcli fallback backend")
return
def worker():
if active:
self.activate_connection(self._tethering_ssid, block=True)
@@ -528,6 +817,10 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _update_current_network_metered(self) -> None:
if self._nmcli_networking:
self._current_network_metered = MeteredType.UNKNOWN
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
@@ -556,6 +849,15 @@ class WifiManager:
return
def set_current_network_metered(self, metered: MeteredType):
if self._fake_networking:
self._current_network_metered = metered
self._enqueue_callbacks(self._networks_updated, self._networks)
return
if self._nmcli_networking:
self._current_network_metered = metered
self._enqueue_callbacks(self._networks_updated, self._networks)
return
def worker():
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
@@ -583,6 +885,10 @@ class WifiManager:
threading.Thread(target=worker, daemon=True).start()
def _request_scan(self):
if self._nmcli_networking:
subprocess.run(["nmcli", "device", "wifi", "rescan"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
@@ -594,6 +900,13 @@ class WifiManager:
cloudlog.warning(f"Failed to request scan: {reply}")
def _update_networks(self):
if self._fake_networking:
self._update_networks_fake()
return
if self._nmcli_networking:
self._update_networks_nmcli()
return
with self._lock:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
@@ -640,6 +953,32 @@ class WifiManager:
self._enqueue_callbacks(self._networks_updated, self._networks)
def _update_ipv4_address(self):
if self._nmcli_networking:
self._ipv4_address = ""
try:
status = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device", "status"],
check=False, capture_output=True, text=True,
)
wifi_dev = None
for line in status.stdout.splitlines():
parts = line.split(":")
if len(parts) >= 3 and parts[1] == "wifi" and parts[2].startswith("connected"):
wifi_dev = parts[0]
break
if wifi_dev:
addr = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", wifi_dev],
check=False, capture_output=True, text=True,
)
for row in addr.stdout.splitlines():
if row:
self._ipv4_address = row.split(":", 1)[-1].split("/", 1)[0]
break
except Exception as e:
cloudlog.warning(f"nmcli ipv4 lookup failed: {e}")
return
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
@@ -666,6 +1005,11 @@ class WifiManager:
def update_gsm_settings(self, roaming: bool, apn: str, metered: bool):
"""Update GSM settings for cellular connection"""
if self._fake_networking:
return
if self._nmcli_networking:
cloudlog.warning("GSM settings update is unavailable in nmcli fallback mode")
return
def worker():
try:
@@ -760,3 +1104,80 @@ class WifiManager:
self._router_main.conn.close()
if self._conn_monitor is not None:
self._conn_monitor.close()
def _parse_nmcli_line(self, line: str) -> list[str]:
out: list[str] = []
cur = []
escaped = False
for ch in line:
if escaped:
cur.append(ch)
escaped = False
elif ch == "\\":
escaped = True
elif ch == ":":
out.append("".join(cur))
cur = []
else:
cur.append(ch)
out.append("".join(cur))
return out
def _update_networks_nmcli(self):
with self._lock:
networks_by_ssid: dict[str, Network] = {}
saved_ssids: set[str] = set()
try:
saved = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
check=False, capture_output=True, text=True,
)
for line in saved.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) >= 3 and parts[1] == "802-11-wireless" and parts[2]:
saved_ssids.add(parts[2])
saved_ssids.add(parts[0])
except Exception as e:
cloudlog.warning(f"nmcli saved networks query failed: {e}")
try:
result = subprocess.run(
["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"],
check=False, capture_output=True, text=True,
)
for line in result.stdout.splitlines():
parts = self._parse_nmcli_line(line)
if len(parts) < 4:
continue
in_use, ssid, signal, security = parts[:4]
if not ssid:
continue
try:
strength = int(signal or 0)
except ValueError:
strength = 0
security_type = SecurityType.OPEN if security in ("", "--") else SecurityType.WPA
is_connected = in_use.startswith("*")
is_saved = ssid in saved_ssids
existing = networks_by_ssid.get(ssid)
if existing is None or strength > existing.strength or is_connected:
networks_by_ssid[ssid] = Network(
ssid=ssid,
strength=strength,
is_connected=is_connected and is_saved,
security_type=security_type,
is_saved=is_saved,
)
except Exception as e:
cloudlog.warning(f"nmcli scan failed: {e}")
self._networks = sorted(
networks_by_ssid.values(),
key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()),
)
self._update_ipv4_address()
self._current_network_metered = MeteredType.UNKNOWN
self._enqueue_callbacks(self._networks_updated, self._networks)
+14 -1
View File
@@ -7,7 +7,6 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.slider import SmallSlider
@@ -16,6 +15,7 @@ from openpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON"))
class ResetMode(IntEnum):
@@ -56,10 +56,23 @@ class Reset(Widget):
os.system("sudo reboot")
def _backup_ssh_params(self):
if PC:
return
backup_dir = "/cache/reset_backup"
os.system(f"sudo rm -rf {backup_dir}")
os.system(f"sudo mkdir -p {backup_dir}")
for key in ("GithubSshKeys", "SshEnabled"):
os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true")
os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true")
def _do_erase(self):
if PC:
return
self._backup_ssh_params()
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
+15 -5
View File
@@ -30,7 +30,8 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
NetworkType = log.DeviceState.NetworkType
OPENPILOT_URL = "https://openpilot.comma.ai"
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
@@ -77,7 +78,7 @@ class NetworkConnectivityMonitor:
while not self._stop_event.is_set():
if self._should_check():
try:
request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
request = urllib.request.Request(NETWORK_CHECK_URL, method="HEAD")
urllib.request.urlopen(request, timeout=0.5)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
@@ -112,12 +113,16 @@ class StartPage(Widget):
self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224)
self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224)
# Match The Galaxy accent palette while keeping existing setup assets/layout intact.
self._start_bg_tint = rl.Color(94, 200, 200, 255)
self._start_bg_pressed_tint = rl.Color(75, 168, 168, 255)
def _render(self, rect: rl.Rectangle):
draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE)
tint = self._start_bg_pressed_tint if self.is_pressed else self._start_bg_tint
rl.draw_texture(texture, int(draw_x), int(draw_y), tint)
self._title.render(rect)
@@ -127,7 +132,7 @@ class SoftwareSelectionPage(Widget):
use_custom_software_callback: Callable):
super().__init__()
self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback)
self._openpilot_slider = LargerSlider("slide to use\nstarpilot", use_openpilot_callback)
self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
def reset(self):
@@ -620,7 +625,7 @@ class Setup(Widget):
def _network_setup_continue_button_callback(self):
self._network_monitor.stop()
if self.state == SetupState.NETWORK_SETUP:
self.download(OPENPILOT_URL)
self.download(DEFAULT_INSTALLER_URL)
elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
self._set_state(SetupState.CUSTOM_SOFTWARE)
@@ -658,6 +663,8 @@ class Setup(Widget):
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(DEFAULT_INSTALLER_URL)
# give time for installer UI to take over
time.sleep(0.1)
@@ -723,6 +730,9 @@ class Setup(Widget):
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
if os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
+4 -2
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE
import openpilot.system.ui.tici_reset as tici_reset
import openpilot.system.ui.mici_reset as mici_reset
def main():
if gui_app.big_ui():
# Use actual hardware type, not UI scale/env flags, to choose reset UI.
# This prevents mici devices from launching tici reset layouts.
if HARDWARE.get_device_type() in ("tici", "tizi"):
tici_reset.main()
else:
mici_reset.main()
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE
import openpilot.system.ui.tici_setup as tici_setup
import openpilot.system.ui.mici_setup as mici_setup
def main():
if gui_app.big_ui():
if HARDWARE.get_device_type() in ("tici", "tizi"):
tici_setup.main()
else:
mici_setup.main()
+62
View File
@@ -1,7 +1,12 @@
#!/usr/bin/env python3
import os
import pyray as rl
import select
import subprocess
import sys
import time
from collections import deque
from pathlib import Path
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -26,6 +31,8 @@ MARGIN_H = 100
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
RESET_TAP_COUNT = 8
RESET_TAP_WINDOW_S = 4.0
# FrogPilot variables
GREEN = (23, 134, 68, 242)
@@ -35,6 +42,17 @@ def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value)
def get_device_type() -> str:
model_path = Path("/sys/firmware/devicetree/base/model")
if model_path.is_file():
try:
model = model_path.read_text().strip("\x00")
return model.split("comma ")[-1].strip().lower()
except Exception:
pass
return ""
class Spinner(Widget):
def __init__(self):
super().__init__()
@@ -43,6 +61,10 @@ class Spinner(Widget):
self._rotation = 0.0
self._progress: int | None = None
self._wrapped_lines: list[str] = []
self._logo_rect = rl.Rectangle(0, 0, 0, 0)
self._tap_times = deque(maxlen=RESET_TAP_COUNT)
self._launch_reset = False
self._allow_reset_gesture = os.path.isfile("/TICI") and get_device_type() not in ("tici", "tizi")
def set_text(self, text: str) -> None:
if text.isdigit():
@@ -67,6 +89,7 @@ class Spinner(Widget):
center = rl.Vector2(rect.width / 2.0, center_y)
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
self._logo_rect = rl.Rectangle(comma_position.x, comma_position.y, TEXTURE_SIZE, TEXTURE_SIZE)
delta_time = rl.get_frame_time()
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
@@ -90,6 +113,23 @@ class Spinner(Widget):
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
FONT_SIZE, 0.0, rl.WHITE)
def _handle_mouse_release(self, mouse_pos):
if not self._allow_reset_gesture:
return
if not rl.check_collision_point_rec(mouse_pos, self._logo_rect):
return
now = time.monotonic()
self._tap_times.append(now)
if len(self._tap_times) == RESET_TAP_COUNT and (now - self._tap_times[0]) <= RESET_TAP_WINDOW_S:
self._tap_times.clear()
self._launch_reset = True
@property
def should_launch_reset(self) -> bool:
return self._launch_reset
def _read_stdin():
"""Non-blocking read of available lines from stdin."""
@@ -114,6 +154,28 @@ def main():
spinner.set_text(text_list[-1])
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if spinner.should_launch_reset:
reset_script = Path(__file__).with_name("reset.py")
try:
proc = subprocess.Popen(
[sys.executable, str(reset_script)],
cwd=str(reset_script.parent),
close_fds=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
spinner.set_text("Failed to launch reset UI")
continue
# Keep spinner alive if reset process exits immediately (prevents blank screen).
time.sleep(0.2)
if proc.poll() is not None:
spinner.set_text("Reset UI failed to start")
continue
gui_app.request_close()
break
if __name__ == "__main__":
+21 -1
View File
@@ -7,7 +7,6 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
@@ -16,6 +15,7 @@ from openpilot.system.ui.widgets.label import gui_label, gui_text_box
NVME = "/dev/nvme0n1"
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON"))
class ResetMode(IntEnum):
@@ -45,10 +45,23 @@ class Reset(Widget):
def _cancel_callback(self):
self._render_status = False
def _backup_ssh_params(self):
if PC:
return
backup_dir = "/cache/reset_backup"
os.system(f"sudo rm -rf {backup_dir}")
os.system(f"sudo mkdir -p {backup_dir}")
for key in ("GithubSshKeys", "SshEnabled"):
os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true")
os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true")
def _do_erase(self):
if PC:
return
self._backup_ssh_params()
# Best effort to wipe NVME
os.system(f"sudo umount {NVME}")
os.system(f"yes | sudo mkfs.ext4 {NVME}")
@@ -118,6 +131,13 @@ class Reset(Widget):
def main():
# Safety fallback: if this module is launched on a small-UI device,
# hand off to the mici reset implementation to avoid off-screen layout.
if not gui_app.big_ui():
import openpilot.system.ui.mici_reset as mici_reset
mici_reset.main()
return
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
+10 -4
View File
@@ -32,7 +32,8 @@ BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
OPENPILOT_URL = "https://openpilot.comma.ai"
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
@@ -92,7 +93,7 @@ class Setup(Widget):
self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_openpilot_button = ButtonRadio("StarPilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
@@ -189,7 +190,7 @@ class Setup(Widget):
def _network_setup_continue_button_callback(self):
self.stop_network_check_thread.set()
if self._software_selection_openpilot_button.selected:
self.download(OPENPILOT_URL)
self.download(DEFAULT_INSTALLER_URL)
else:
self.state = SetupState.CUSTOM_SOFTWARE
@@ -218,7 +219,7 @@ class Setup(Widget):
while not self.stop_network_check_thread.is_set():
if self.state == SetupState.NETWORK_SETUP:
try:
urllib.request.urlopen(OPENPILOT_URL, timeout=2)
urllib.request.urlopen(NETWORK_CHECK_URL, timeout=2)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
@@ -349,6 +350,8 @@ class Setup(Widget):
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(DEFAULT_INSTALLER_URL)
# give time for installer UI to take over
time.sleep(0.1)
@@ -415,6 +418,9 @@ class Setup(Widget):
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
if os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE
import openpilot.system.ui.tici_updater as tici_updater
import openpilot.system.ui.mici_updater as mici_updater
def main():
if gui_app.big_ui():
if HARDWARE.get_device_type() in ("tici", "tizi"):
tici_updater.main()
else:
mici_updater.main()
+7 -7
View File
@@ -8,8 +8,10 @@ from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLO
try:
from openpilot.selfdrive.ui.ui_state import device
except ImportError:
class Device:
awake = True
device = Device() # type: ignore
@@ -38,8 +40,7 @@ class Widget(abc.ABC):
return self._rect
def set_rect(self, rect: rl.Rectangle) -> None:
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
changed = self._rect.x != rect.x or self._rect.y != rect.y or self._rect.width != rect.width or self._rect.height != rect.height
self._rect = rect
if changed:
self._update_layout_rects()
@@ -79,7 +80,7 @@ class Widget(abc.ABC):
return self._touch_valid_callback() if self._touch_valid_callback else True
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
changed = self._rect.x != x or self._rect.y != y
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
if changed:
self._update_layout_rects()
@@ -228,6 +229,7 @@ class NavWidget(Widget, abc.ABC):
"""
A full screen widget that supports back navigation by swiping down from the top.
"""
BACK_TOUCH_AREA_PERCENTAGE = 0.65
def __init__(self):
@@ -318,12 +320,10 @@ class NavWidget(Widget, abc.ABC):
self._set_up = True
if hasattr(self, '_scroller'):
original_enabled = self._scroller._enabled
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
elif hasattr(self, '_scroll_panel'):
original_enabled = self._scroll_panel.enabled
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
if self._trigger_animate_in:
self._pos_filter.x = self._rect.height
+8 -1
View File
@@ -1,3 +1,4 @@
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
@@ -17,7 +18,7 @@ BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class ConfirmDialog(Widget):
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False):
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False, on_close: Callable[[DialogResult], None] | None = None):
super().__init__()
if cancel_text is None:
cancel_text = tr("Cancel")
@@ -27,6 +28,7 @@ class ConfirmDialog(Widget):
self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._rich = rich
self._dialog_result = DialogResult.NO_ACTION
self._on_close = on_close
self._cancel_text = cancel_text
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
@@ -38,12 +40,15 @@ class ConfirmDialog(Widget):
def reset(self):
self._dialog_result = DialogResult.NO_ACTION
self._on_close = on_close
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
if self._on_close: self._on_close(self._dialog_result)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close: self._on_close(self._dialog_result)
def _render(self, rect: rl.Rectangle):
dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
@@ -74,8 +79,10 @@ class ConfirmDialog(Widget):
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
self._dialog_result = DialogResult.CONFIRM
if self._on_close: self._on_close(self._dialog_result)
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
self._dialog_result = DialogResult.CANCEL
if self._on_close: self._on_close(self._dialog_result)
if self._cancel_text:
self._confirm_button.render(confirm_button)
+118
View File
@@ -0,0 +1,118 @@
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label, FontWeight
from openpilot.system.ui.widgets.keyboard import Keyboard, KeyboardLayout
MARGIN = 50
BUTTON_HEIGHT = 160
OUTER_MARGIN_X = 200
OUTER_MARGIN_Y = 150
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class InputDialog(Widget):
def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None):
super().__init__()
self._title = title
self._text = default_text
self._hint = hint_text
self._on_close = on_close
self._dialog_result = DialogResult.NO_ACTION
self._title_label = Label(title, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255))
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._confirm_button = Button("Confirm", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._keyboard = Keyboard(self._on_key_pressed, self._on_keyboard_done, layout=KeyboardLayout.QWERTY)
self._font = gui_app.font(FontWeight.MEDIUM)
def _on_key_pressed(self, key: str):
if key == "\b":
self._text = self._text[:-1]
else:
self._text += key
def _on_keyboard_done(self):
self._confirm_button_callback()
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
if self._on_close:
self._on_close(self._dialog_result, self._text)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close:
self._on_close(self._dialog_result, self._text)
@property
def result(self) -> DialogResult:
return self._dialog_result
@property
def text(self) -> str:
return self._text
def show_event(self):
super().show_event()
self._dialog_result = DialogResult.NO_ACTION
def _render(self, rect: rl.Rectangle):
# Dim background
rl.draw_rectangle(0, 0, int(rect.width), int(rect.height), rl.Color(0, 0, 0, 200))
# Dialog Box
dialog_rect = rl.Rectangle(
rect.x + OUTER_MARGIN_X,
rect.y + OUTER_MARGIN_Y,
rect.width - 2 * OUTER_MARGIN_X,
rect.height - 2 * OUTER_MARGIN_Y,
)
rl.draw_rectangle_rounded(dialog_rect, 0.05, 10, BACKGROUND_COLOR)
# Title
title_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, dialog_rect.width - 2 * MARGIN, 100)
self._title_label.render(title_rect)
# Text Input Field
input_rect = rl.Rectangle(dialog_rect.x + MARGIN, title_rect.y + title_rect.height + 40, dialog_rect.width - 2 * MARGIN, 120)
rl.draw_rectangle_rounded(input_rect, 0.1, 10, rl.Color(40, 40, 40, 255))
display_text = self._text
text_color = rl.WHITE
if not display_text:
display_text = self._hint
text_color = rl.Color(128, 128, 128, 255)
text_size = rl.measure_text_ex(self._font, display_text, 50, 0)
text_pos = rl.Vector2(input_rect.x + 40, input_rect.y + (input_rect.height - text_size.y) / 2)
rl.draw_text_ex(self._font, display_text, text_pos, 50, 0, text_color)
# Blinking cursor
if (rl.get_time() % 1.0) < 0.5:
cursor_x = text_pos.x + (text_size.x if self._text else 0) + 5
rl.draw_rectangle(int(cursor_x), int(text_pos.y), 4, 50, rl.WHITE)
# Keyboard
keyboard_rect = rl.Rectangle(
dialog_rect.x + MARGIN,
input_rect.y + input_rect.height + 40,
dialog_rect.width - 2 * MARGIN,
400
)
self._keyboard.render(keyboard_rect)
# Buttons
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
cancel_rect = rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT)
confirm_rect = rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT)
self._cancel_button.render(cancel_rect)
self._confirm_button.render(confirm_rect)
File diff suppressed because it is too large Load Diff
+317
View File
@@ -0,0 +1,317 @@
from enum import IntEnum
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.scroller_tici import Scroller
SELECTION_COLOR = rl.Color(70, 91, 234, 255) # #465BEA
HEADER_BG = rl.Color(51, 51, 51, 255) # #333333
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) # #1B1B1B
BORDER_COLOR = rl.Color(80, 80, 80, 255)
MARGIN = 40
OUTER_MARGIN_X = 100
OUTER_MARGIN_Y = 80
BUTTON_HEIGHT = 90
class SortMode(IntEnum):
ALPHABETICAL = 0
DATE_NEWEST = 1
DATE_OLDEST = 2
FAVORITES = 3
class SelectionHeader(Widget):
def __init__(self, text: str, is_expanded: bool, callback: Callable[[str], None]):
super().__init__()
self._text = text
self._is_expanded = is_expanded
self._callback = callback
self._font = gui_app.font(FontWeight.BOLD)
self._font_size = 40
self._pressed = False
self.set_rect(rl.Rectangle(0, 0, 0, 70))
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, rect: rl.Rectangle):
# Header background - Match Qt .series-header {#333333}
bg_color = rl.Color(64, 64, 64, 255) if self._pressed else HEADER_BG
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
# Arrow - Match Qt text-based arrows
arrow = "" if self._is_expanded else ""
arrow_pos = rl.Vector2(rect.x + 30, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, arrow, arrow_pos, self._font_size, 0, rl.WHITE)
# Text - Match Qt padding-left: 80px
text_pos = rl.Vector2(rect.x + 80, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
class SelectionItem(Widget):
def __init__(self, text: str, is_selected: bool, is_favorite: bool, callback: Callable[[str], None], fav_callback: Callable[[str], None] = None):
super().__init__()
self._text = text
self._is_selected = is_selected
self._is_favorite = is_favorite
self._callback = callback
self._fav_callback = fav_callback
self._font = gui_app.font(FontWeight.MEDIUM)
self._font_size = 48
self._pressed = False
self._fav_pressed = False
self.set_rect(rl.Rectangle(0, 0, 0, 110))
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, rect: rl.Rectangle):
# Background for item - Match Qt .model-option:checked {#465BEA}
if self._is_selected:
bg_color = rl.Color(70, 91, 234, 255) # #465BEA
else:
bg_color = rl.Color(90, 90, 90, 255) if self._pressed else rl.Color(79, 79, 79, 255) # #4F4F4F
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
# Selection Border - Match Qt {3px WHITE}
if self._is_selected:
rl.draw_rectangle_rounded_lines_ex(rect, 0.1, 10, 3, rl.WHITE)
# Favorite Star - Left side
star = "" if self._is_favorite else ""
star_pos = rl.Vector2(rect.x + 25, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, star, star_pos, self._font_size + 10, 0, rl.WHITE)
# Text
text_size = rl.measure_text_ex(self._font, self._text, self._font_size, 0)
text_pos = rl.Vector2(rect.x + 90, rect.y + (rect.height - text_size.y) / 2)
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
# Indicator (Dot for selection instead of radio)
if self._is_selected:
circle_center = rl.Vector2(rect.x + rect.width - 50, rect.y + rect.height / 2)
rl.draw_circle_v(circle_center, 12, rl.WHITE)
@property
def _fav_rect(self) -> rl.Rectangle:
return rl.Rectangle(self._rect.x, self._rect.y, 80, self._rect.height)
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._fav_rect):
self._fav_pressed = True
elif rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._fav_pressed and rl.check_collision_point_rec(mouse_pos, self._fav_rect):
if self._fav_callback:
self._fav_callback(self._text)
elif self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
self._fav_pressed = False
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
class SelectionDialog(Widget):
def __init__(self, title: str, options, current_selection: str = "",
on_close: Callable[[DialogResult, str], None] | None = None,
model_released_dates: dict[str, str] | None = None,
model_file_to_name: dict[str, str] | None = None,
user_favorites: list[str] | None = None,
community_favorites: list[str] | None = None,
on_favorite_toggled: Callable[[str], None] | None = None):
super().__init__()
self._title = title
self._options_raw = options
self._selected_value = current_selection
self._on_close = on_close
self._model_released_dates = model_released_dates or {}
self._name_to_file = {v: k for k, v in (model_file_to_name or {}).items()}
self._user_favorites = user_favorites or []
self._community_favorites = community_favorites or []
self._on_favorite_toggled = on_favorite_toggled
self._sort_mode = SortMode.ALPHABETICAL
self._expanded_series = {s: True for s in (options.keys() if isinstance(options, dict) else [])}
self._title_label = Label(title, 60, FontWeight.BOLD, text_color=rl.WHITE)
self._sort_button = Button("Alphabetical", self._toggle_sort, button_style=ButtonStyle.NORMAL)
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._confirm_button = Button("Select", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._scroller = None
self._build_scroller()
def _toggle_sort(self):
self._sort_mode = SortMode((int(self._sort_mode) + 1) % 4)
modes = ["Alphabetical", "Date (Newest)", "Date (Oldest)", "Favorites First"]
self._sort_button.set_text(modes[int(self._sort_mode)])
self._build_scroller()
def _toggle_series(self, series: str):
self._expanded_series[series] = not self._expanded_series.get(series, True)
self._build_scroller()
def _build_scroller(self):
items = []
if isinstance(self._options_raw, dict):
series_keys = list(self._options_raw.keys())
priority_series = ["FrogPilot", "Comma", "Experimental"]
sorted_series_keys = []
for p in priority_series:
if p in series_keys:
sorted_series_keys.append(p)
series_keys.remove(p)
sorted_series_keys.extend(sorted(series_keys))
for series in sorted_series_keys:
models = self._options_raw[series]
if not models:
continue
items.append(SelectionHeader(series, self._expanded_series.get(series, True), self._toggle_series))
if self._expanded_series.get(series, True):
sorted_models = list(models)
if self._sort_mode == SortMode.ALPHABETICAL:
sorted_models.sort()
elif self._sort_mode == SortMode.DATE_NEWEST:
def get_date(m):
key = self._name_to_file.get(m, m)
return self._model_released_dates.get(key, "0000-00-00")
sorted_models.sort(key=get_date, reverse=True)
elif self._sort_mode == SortMode.DATE_OLDEST:
def get_date(m):
key = self._name_to_file.get(m, m)
return self._model_released_dates.get(key, "9999-99-99")
sorted_models.sort(key=get_date)
elif self._sort_mode == SortMode.FAVORITES:
def is_fav(m):
key = self._name_to_file.get(m, m)
return key in self._user_favorites or key in self._community_favorites
sorted_models.sort(key=is_fav, reverse=True)
for model in sorted_models:
key = self._name_to_file.get(model, model)
is_selected = (model == self._selected_value or key == self._selected_value)
is_fav = key in self._user_favorites or key in self._community_favorites
items.append(SelectionItem(
text=model,
is_selected=is_selected,
is_favorite=is_fav,
callback=self._on_item_selected,
fav_callback=self._toggle_favorite
))
else:
for option in self._options_raw:
items.append(SelectionItem(
text=option,
is_selected=(option == self._selected_value),
is_favorite=False,
callback=self._on_item_selected
))
self._scroller = Scroller(items, line_separator=False, spacing=10)
self._scroller.show_event()
def _toggle_favorite(self, model_name: str):
key = self._name_to_file.get(model_name, model_name)
if self._on_favorite_toggled:
self._on_favorite_toggled(key)
# Update local state for instant feedback
if key in self._user_favorites:
self._user_favorites.remove(key)
else:
self._user_favorites.append(key)
self._build_scroller()
def _on_item_selected(self, val):
self._selected_value = val
# Instant visual update
if self._scroller:
for item in self._scroller._items:
if isinstance(item, SelectionItem):
item._is_selected = (item._text == val)
def _cancel_button_callback(self):
if self._on_close:
self._on_close(DialogResult.CANCEL, "")
gui_app.set_modal_overlay(None)
def _confirm_button_callback(self):
if self._on_close:
self._on_close(DialogResult.CONFIRM, self._selected_value)
gui_app.set_modal_overlay(None)
def show_event(self):
super().show_event()
if self._scroller:
self._scroller.show_event()
def _render(self, rect: rl.Rectangle):
# Dim background
rl.draw_rectangle(0, 0, int(rl.get_screen_width()), int(rl.get_screen_height()), rl.Color(0, 0, 0, 180))
# Dialog Box
dialog_rect = rl.Rectangle(
rect.x + OUTER_MARGIN_X,
rect.y + OUTER_MARGIN_Y,
rect.width - 2 * OUTER_MARGIN_X,
rect.height - 2 * OUTER_MARGIN_Y,
)
rl.draw_rectangle_rounded(dialog_rect, 0.04, 12, BACKGROUND_COLOR)
rl.draw_rectangle_rounded_lines_ex(dialog_rect, 0.04, 12, 2, BORDER_COLOR)
# Title
title_width = dialog_rect.width - 2 * MARGIN - 260
self._title_label.render(rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, title_width, 80))
# Sort Button
self._sort_button.render(rl.Rectangle(dialog_rect.x + dialog_rect.width - MARGIN - 240, dialog_rect.y + MARGIN, 240, 80))
# Bottom Buttons
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
self._cancel_button.render(rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT))
self._confirm_button.render(rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT))
# Scrollable Options List
scroller_y = dialog_rect.y + MARGIN + 80 + 20
scroller_rect = rl.Rectangle(
dialog_rect.x + MARGIN,
scroller_y,
dialog_rect.width - 2 * MARGIN,
btn_y - scroller_y - 20
)
self._scroller.render(scroller_rect)
return DialogResult.NO_ACTION