mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-21 08:14:00 +08:00
StarPilot
This commit is contained in:
@@ -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,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',
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Regular → Executable
BIN
Binary file not shown.
Regular → Executable
@@ -1,4 +1 @@
|
||||
loggerd
|
||||
encoderd
|
||||
bootlog
|
||||
tests/test_logger
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
+1112
-48
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user