StarPilot

This commit is contained in:
firestar5683
2026-03-12 01:49:47 -05:00
parent 0e9ef526f7
commit d0e1db6766
2171 changed files with 590688 additions and 247754 deletions
+40 -3
View File
@@ -13,6 +13,8 @@ venv/
a.out
.hypothesis
.cache/
.comma_sysroot/
.venv-linux-arm64/
/docs_site/
@@ -44,14 +46,13 @@ clcache
compile_commands.json
compare_runtime*.html
selfdrive/pandad/pandad
cereal/services.h
cereal/gen
cereal/messaging/bridge
selfdrive/ui/translations/tmp
selfdrive/car/tests/cars_dump
system/camerad/camerad
system/camerad/test/ae_gray_test
selfdrive/ui/ui.macos
selfdrive/ui/ui.larch64
.coverage*
coverage.xml
@@ -65,6 +66,16 @@ cppcheck_report.txt
comma*.sh
selfdrive/modeld/models/*.pkl
!selfdrive/modeld/models/driving_vision_tinygrad.pkl
!selfdrive/modeld/models/driving_policy_tinygrad.pkl
!selfdrive/modeld/models/driving_vision_metadata.pkl
!selfdrive/modeld/models/driving_policy_metadata.pkl
!selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl
!selfdrive/modeld/models/dmonitoring_model_metadata.pkl
!selfdrive/modeld/models/warp_1928x1208_tinygrad.pkl
!selfdrive/modeld/models/warp_1344x760_tinygrad.pkl
!selfdrive/modeld/models/dm_warp_1928x1208_tinygrad.pkl
!selfdrive/modeld/models/dm_warp_1344x760_tinygrad.pkl
# openpilot log files
*.bz2
@@ -95,3 +106,29 @@ Pipfile
# Ignore all local history of files
.history
.ionide
# Keep prebuilt runtime artifacts trackable
!cereal/messaging/bridge
!system/camerad/camerad
!system/loggerd/loggerd
!system/loggerd/encoderd
!system/loggerd/bootlog
!selfdrive/pandad/pandad
!cereal/services.h
!cereal/libcereal.a
!cereal/libsocketmaster.a
!common/params_pyx.so
!common/params_pyx.cpp
!common/transformations/transformations.so
!selfdrive/modeld/models/commonmodel_pyx.so
!selfdrive/pandad/pandad_api_impl.so
!selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
!selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so
!selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
!selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so
!common/libcommon.a
!msgq_repo/msgq/ipc_pyx.so
!msgq_repo/msgq/visionipc/visionipc_pyx.so
!rednose_repo/rednose/helpers/ekf_sym_pyx.so
!panda/board/obj/
!panda/board/obj/**
+1
View File
@@ -4,5 +4,6 @@
"ms-vscode.cpptools",
"elagil.pre-commit-helper",
"charliermarsh.ruff",
"openai.chatgpt",
]
}
+162 -17
View File
@@ -1,4 +1,5 @@
import os
import shutil
import subprocess
import sys
import sysconfig
@@ -12,7 +13,8 @@ SCons.Warnings.warningAsException(True)
# pending upstream fix - https://github.com/SCons/scons/issues/4461
#SetOption('warn', 'all')
TICI = os.path.isfile('/TICI')
force_tici = os.environ.get("SP_FORCE_TICI", "").lower() in {"1", "true", "yes", "on"}
TICI = os.path.isfile('/TICI') or force_tici
AGNOS = TICI
Decider('MD5-timestamp')
@@ -61,47 +63,135 @@ AddOption('--minimal',
default=os.path.exists(File('#.lfsconfig').abspath), # minimal by default on release branch (where there's no LFS)
help='the minimum build to run openpilot. no tests, tools, etc.')
def maybe_delegate_to_laptop_device_builder() -> None:
if platform.system() != "Darwin":
return
if os.environ.get("SP_FORCE_ARCH"):
return
if os.environ.get("SP_SKIP_CONTAINER_REEXEC"):
return
if os.environ.get("SP_DISABLE_AUTO_DEVICE_SCONS", "").lower() in {"1", "true", "yes", "on"}:
return
basedir = Dir("#").abspath
sysroot_dir = os.environ.get("COMMA_SYSROOT_DIR", os.path.join(basedir, ".comma_sysroot"))
required_sysroot_dirs = (
"usr/local/lib",
"lib/aarch64-linux-gnu",
"usr/lib/aarch64-linux-gnu",
"system/vendor/lib64",
)
if not all(os.path.isdir(os.path.join(sysroot_dir, p)) for p in required_sysroot_dirs):
return
docker_bin = shutil.which("docker")
if docker_bin is None:
mac_docker = "/Applications/Docker.app/Contents/Resources/bin/docker"
if os.path.isfile(mac_docker):
docker_bin = mac_docker
if docker_bin is None and shutil.which("podman") is None:
return
builder = os.path.join(basedir, "scripts", "laptop_device_build.sh")
if not os.path.isfile(builder):
return
print(f"Auto-routing scons to laptop device build (sysroot: {sysroot_dir})", flush=True)
env = os.environ.copy()
env["SP_SKIP_CONTAINER_REEXEC"] = "1"
env.setdefault("COMMA_SYSROOT_DIR", sysroot_dir)
if docker_bin is not None and shutil.which("docker") is None:
docker_dir = os.path.dirname(docker_bin)
env["PATH"] = f"{docker_dir}:{env.get('PATH', '')}"
cmd = [builder, "build", *sys.argv[1:]]
raise SystemExit(subprocess.call(cmd, cwd=basedir, env=env))
maybe_delegate_to_laptop_device_builder()
## Architecture name breakdown (arch)
## - larch64: linux tici aarch64
## - aarch64: linux pc aarch64
## - x86_64: linux pc x64
## - Darwin: mac x64 or arm64
real_arch = arch = subprocess.check_output(["uname", "-m"], encoding='utf8').rstrip()
if platform.system() == "Darwin":
forced_arch = os.environ.get("SP_FORCE_ARCH")
if forced_arch:
arch = forced_arch
elif platform.system() == "Darwin":
arch = "Darwin"
brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip()
elif arch == "aarch64" and AGNOS:
arch = "larch64"
assert arch in ["larch64", "aarch64", "x86_64", "Darwin"]
# Homebrew llvm can shadow Apple clang and break macOS SDK header resolution.
# Use the system toolchain explicitly on macOS for reliable local builds.
cc = '/usr/bin/clang' if arch == "Darwin" else 'clang'
cxx = '/usr/bin/clang++' if arch == "Darwin" else 'clang++'
lenv = {
"PATH": os.environ['PATH'],
"PYTHONPATH": Dir("#").abspath + ':' + Dir(f"#third_party/acados").abspath,
"PYTHONPATH": ":".join([
Dir("#").abspath,
Dir("#third_party/acados").abspath,
Dir("#opendbc_repo").abspath,
]),
"ACADOS_SOURCE_DIR": Dir("#third_party/acados").abspath,
"ACADOS_PYTHON_INTERFACE_PATH": Dir("#third_party/acados/acados_template").abspath,
"TERA_PATH": Dir("#").abspath + f"/third_party/acados/{arch}/t_renderer"
}
# Allow callers to override cache/temp dirs used by subprocesses (e.g. tinygrad model compilation).
for key in ("HOME", "TMPDIR", "XDG_CACHE_HOME", "CACHEDB"):
if key in os.environ:
lenv[key] = os.environ[key]
rpath = []
arch_ldflags = []
def tici_libpath(path: str) -> str:
tici_sysroot = os.environ.get("SP_TICI_SYSROOT", "").strip().rstrip("/")
if arch != "larch64" or not tici_sysroot or not path.startswith("/"):
return path
return os.path.join(tici_sysroot, path.lstrip("/"))
if arch == "larch64":
cpppath = [
"#third_party/opencl/include",
tici_libpath("/usr/local/include"),
tici_libpath("/usr/include"),
tici_libpath("/usr/include/aarch64-linux-gnu"),
]
libpath = [
"/usr/local/lib",
"/system/vendor/lib64",
tici_libpath("/usr/local/lib"),
tici_libpath("/system/vendor/lib64"),
f"#third_party/acados/{arch}/lib",
]
libpath += [
"#third_party/libyuv/larch64/lib",
"/usr/lib/aarch64-linux-gnu"
tici_libpath("/lib/aarch64-linux-gnu"),
tici_libpath("/usr/lib/aarch64-linux-gnu")
]
cflags = ["-DQCOM2", "-mcpu=cortex-a57"]
cxxflags = ["-DQCOM2", "-mcpu=cortex-a57"]
cflags = ["-D__TICI__", "-DQCOM2", "-mcpu=cortex-a57"]
cxxflags = ["-D__TICI__", "-DQCOM2", "-mcpu=cortex-a57"]
arch_ldflags += [
f"-Wl,-rpath-link,{tici_libpath('/usr/local/lib')}",
f"-Wl,-rpath-link,{tici_libpath('/lib/aarch64-linux-gnu')}",
f"-Wl,-rpath-link,{tici_libpath('/usr/lib/aarch64-linux-gnu')}",
f"-Wl,-rpath-link,{tici_libpath('/system/vendor/lib64')}",
f"-Wl,-rpath-link,{tici_libpath('/vendor/lib64')}",
]
# On non-aarch64 hosts (e.g. Docker on macOS), force clang cross-targeting.
if platform.machine() not in ("aarch64", "arm64"):
cross_target = os.environ.get("SP_CROSS_TARGET", "aarch64-linux-gnu")
cflags += [f"--target={cross_target}"]
cxxflags += [f"--target={cross_target}"]
arch_ldflags += [f"--target={cross_target}"]
rpath += ["/usr/local/lib"]
else:
cflags = []
@@ -119,8 +209,12 @@ else:
"/System/Library/Frameworks/OpenGL.framework/Libraries",
]
cflags += ["-DGL_SILENCE_DEPRECATION"]
cxxflags += ["-DGL_SILENCE_DEPRECATION"]
# cereal headers in this tree were generated with capnp 1.0.1, while
# Homebrew currently ships newer capnp headers (1.3.x). For mac host
# tooling builds (desktop UI/runtime .so), force the expected version
# macro so generated headers remain buildable.
cflags += ["-DGL_SILENCE_DEPRECATION", "-DCAPNP_VERSION=1000001"]
cxxflags += ["-DGL_SILENCE_DEPRECATION", "-DCAPNP_VERSION=1000001"]
cpppath += [
f"{brew_prefix}/include",
f"{brew_prefix}/opt/openssl@3.0/include",
@@ -143,6 +237,13 @@ elif GetOption('ubsan'):
else:
ccflags = []
ldflags = []
ldflags += arch_ldflags
# AGNOS devices are memory-constrained during on-device C++ compiles.
# Building without debug symbols dramatically reduces peak clang memory and
# prevents lowmemorykiller SIGKILLs (Error -9) on large translation units.
use_debug_symbols = os.environ.get("SP_FORCE_DEBUG_SYMBOLS", "").lower() in {"1", "true", "yes", "on"}
debug_flag = "-g" if (not AGNOS or use_debug_symbols) else "-g0"
# no --as-needed on mac linker
if arch != "Darwin":
@@ -155,7 +256,7 @@ if ccflags_option:
env = Environment(
ENV=lenv,
CCFLAGS=[
"-g",
debug_flag,
"-fPIC",
"-O2",
"-Wunused",
@@ -181,8 +282,8 @@ env = Environment(
"#msgq",
],
CC='clang',
CXX='clang++',
CC=cc,
CXX=cxx,
LINKFLAGS=ldflags,
RPATH=rpath,
@@ -227,6 +328,10 @@ if os.environ.get('SCONS_PROGRESS'):
# Cython build environment
py_include = sysconfig.get_paths()['include']
if arch == "larch64" and platform.machine() not in ("aarch64", "arm64"):
tici_py_include = tici_libpath(f"/usr/include/python{sys.version_info.major}.{sys.version_info.minor}")
if os.path.isdir(tici_py_include):
py_include = tici_py_include
envCython = env.Clone()
envCython["CPPPATH"] += [py_include, np.get_include()]
envCython["CCFLAGS"] += ["-Wno-#warnings", "-Wno-shadow", "-Wno-deprecated-declarations"]
@@ -236,7 +341,7 @@ envCython["LIBS"] = []
if arch == "Darwin":
envCython["LINKFLAGS"] = ["-bundle", "-undefined", "dynamic_lookup"] + darwin_rpath_link_flags
else:
envCython["LINKFLAGS"] = ["-pthread", "-shared"]
envCython["LINKFLAGS"] = arch_ldflags + ["-pthread", "-shared"]
np_version = SCons.Script.Value(np.__version__)
Export('envCython', 'np_version')
@@ -256,8 +361,17 @@ if arch == "Darwin":
qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] + ["OpenGL"]
qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin"))
else:
qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip()
qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip()
if arch == "larch64":
qt_env.PrependENVPath('PATH', Dir("#third_party/qt5/larch64/bin/").abspath)
# For laptop/device builds that mount an AGNOS sysroot, always prefer Qt
# headers from that sysroot to keep headers/libs ABI-matched (Qt 5.12.x).
if arch == "larch64" and os.environ.get("SP_TICI_SYSROOT"):
qt_install_prefix = tici_libpath("/usr")
qt_install_headers = tici_libpath("/usr/include/aarch64-linux-gnu/qt5")
else:
qmake = os.environ.get("SP_QMAKE", "qmake")
qt_install_prefix = subprocess.check_output([qmake, '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip()
qt_install_headers = subprocess.check_output([qmake, '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip()
qt_env['QTDIR'] = qt_install_prefix
qt_dirs = [
@@ -272,11 +386,42 @@ else:
qt_libs = [f"Qt5{m}" for m in qt_modules]
if arch == "larch64":
qt_libs += ["GLESv2", "wayland-client"]
qt_env.PrependENVPath('PATH', Dir("#third_party/qt5/larch64/bin/").abspath)
elif arch != "Darwin":
qt_libs += ["GL"]
qt_env['QT3DIR'] = qt_env['QTDIR']
qt_env.Tool('qt3')
if arch == "larch64" and os.environ.get("SP_TICI_SYSROOT"):
qt_tool_bin = tici_libpath("/usr/lib/qt5/bin")
qt_tool_root = tici_libpath("/")
qt_arm_moc = os.path.join(qt_tool_bin, "moc")
qt_arm_uic = os.path.join(qt_tool_bin, "uic")
qt_arm_rcc = os.path.join(qt_tool_bin, "rcc")
if platform.machine() in ("aarch64", "arm64"):
if os.path.isfile(qt_arm_moc):
qt_env['QT3_MOC'] = qt_arm_moc
if os.path.isfile(qt_arm_uic):
qt_env['QT3_UIC'] = qt_arm_uic
if os.path.isfile(qt_arm_rcc):
qt_env['SP_QT_RCC'] = qt_arm_rcc
else:
qt_qemu = shutil.which("qemu-aarch64-static") or shutil.which("qemu-aarch64")
if qt_qemu and os.path.isfile(qt_arm_moc):
qt_env['QT3_MOC'] = f"{qt_qemu} -L {qt_tool_root} {qt_arm_moc}"
else:
qt_host_bin = os.environ.get("SP_QT_HOST_BIN", "/usr/lib/qt5/bin")
qt_env['QT3_MOC'] = os.environ.get("SP_QT_HOST_MOC", os.path.join(qt_host_bin, "moc"))
if qt_qemu and os.path.isfile(qt_arm_uic):
qt_env['QT3_UIC'] = f"{qt_qemu} -L {qt_tool_root} {qt_arm_uic}"
else:
qt_host_bin = os.environ.get("SP_QT_HOST_BIN", "/usr/lib/qt5/bin")
qt_env['QT3_UIC'] = os.environ.get("SP_QT_HOST_UIC", os.path.join(qt_host_bin, "uic"))
if qt_qemu and os.path.isfile(qt_arm_rcc):
qt_env['SP_QT_RCC'] = f"{qt_qemu} -L {qt_tool_root} {qt_arm_rcc}"
else:
qt_env['SP_QT_RCC'] = os.environ.get("SP_QT_HOST_RCC", "rcc")
qt_env['CPPPATH'] += qt_dirs + ["#third_party/qrcode"]
qt_flags = [
+2972
View File
File diff suppressed because it is too large Load Diff
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${ROOT_DIR}/scripts/laptop_device_build.sh" build "$@"
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${ROOT_DIR}/scripts/launch_ui_desktop.sh" "$@"
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${ROOT_DIR}/scripts/launch_ui_c4_desktop.sh" "$@"
+2
View File
@@ -81,6 +81,7 @@ struct FrogPilotCarState @0xf35cc4560bbf6ec2 {
pauseLongitudinal @12 :Bool;
sportGear @13 :Bool;
trafficModeEnabled @14 :Bool;
gasStack @15 :Bool; # Compatibility with older StarPilot payloads
}
struct FrogPilotDeviceState @0xda96579883444c35 {
@@ -183,6 +184,7 @@ struct FrogPilotPlan @0xf98d843bfd7004a3 {
vCruise @32 :Float32;
weatherDaytime @33 :Bool;
weatherId @34 :Int16;
disableThrottle @35 :Bool;
}
struct FrogPilotRadarState @0xb86e6369214c01c8 {
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
/* THIS IS AN AUTOGENERATED FILE, PLEASE EDIT services.py */
#ifndef __SERVICES_H
#define __SERVICES_H
#include <map>
#include <string>
struct service { std::string name; bool should_log; float frequency; int decimation; size_t queue_size; };
static std::map<std::string, service> services = {
{ "gyroscope", {"gyroscope", true, 104.000000, 104, 256000}},
{ "accelerometer", {"accelerometer", true, 104.000000, 104, 256000}},
{ "magnetometer", {"magnetometer", true, 25.000000, -1, 256000}},
{ "lightSensor", {"lightSensor", true, 100.000000, 100, 256000}},
{ "temperatureSensor", {"temperatureSensor", true, 2.000000, 200, 256000}},
{ "gpsNMEA", {"gpsNMEA", true, 9.000000, -1, 256000}},
{ "deviceState", {"deviceState", true, 2.000000, 1, 256000}},
{ "touch", {"touch", true, 20.000000, 1, 256000}},
{ "can", {"can", true, 100.000000, 2053, 10485760}},
{ "controlsState", {"controlsState", true, 100.000000, 10, 2097152}},
{ "selfdriveState", {"selfdriveState", true, 100.000000, 10, 256000}},
{ "pandaStates", {"pandaStates", true, 10.000000, 1, 256000}},
{ "peripheralState", {"peripheralState", true, 2.000000, 1, 256000}},
{ "radarState", {"radarState", true, 20.000000, 5, 256000}},
{ "roadEncodeIdx", {"roadEncodeIdx", false, 20.000000, 1, 256000}},
{ "liveTracks", {"liveTracks", true, 20.000000, -1, 256000}},
{ "sendcan", {"sendcan", true, 100.000000, 139, 2097152}},
{ "logMessage", {"logMessage", true, 0.000000, -1, 256000}},
{ "errorLogMessage", {"errorLogMessage", true, 0.000000, 1, 256000}},
{ "liveCalibration", {"liveCalibration", true, 4.000000, 4, 256000}},
{ "liveTorqueParameters", {"liveTorqueParameters", true, 4.000000, 1, 256000}},
{ "liveDelay", {"liveDelay", true, 4.000000, 1, 256000}},
{ "androidLog", {"androidLog", true, 0.000000, -1, 256000}},
{ "carState", {"carState", true, 100.000000, 10, 256000}},
{ "carControl", {"carControl", true, 100.000000, 10, 256000}},
{ "carOutput", {"carOutput", true, 100.000000, 10, 256000}},
{ "longitudinalPlan", {"longitudinalPlan", true, 20.000000, 10, 256000}},
{ "driverAssistance", {"driverAssistance", true, 20.000000, 20, 256000}},
{ "procLog", {"procLog", true, 0.500000, 15, 10485760}},
{ "gpsLocationExternal", {"gpsLocationExternal", true, 10.000000, 10, 256000}},
{ "gpsLocation", {"gpsLocation", true, 1.000000, 1, 256000}},
{ "ubloxGnss", {"ubloxGnss", true, 10.000000, -1, 256000}},
{ "qcomGnss", {"qcomGnss", true, 2.000000, -1, 256000}},
{ "gnssMeasurements", {"gnssMeasurements", true, 10.000000, 10, 256000}},
{ "clocks", {"clocks", true, 0.100000, 1, 256000}},
{ "ubloxRaw", {"ubloxRaw", true, 20.000000, -1, 256000}},
{ "livePose", {"livePose", true, 20.000000, 4, 256000}},
{ "liveParameters", {"liveParameters", true, 20.000000, 5, 256000}},
{ "cameraOdometry", {"cameraOdometry", true, 20.000000, 10, 256000}},
{ "thumbnail", {"thumbnail", true, 0.016667, 1, 256000}},
{ "onroadEvents", {"onroadEvents", true, 1.000000, 1, 256000}},
{ "carParams", {"carParams", true, 0.020000, 1, 256000}},
{ "roadCameraState", {"roadCameraState", true, 20.000000, 20, 256000}},
{ "driverCameraState", {"driverCameraState", true, 20.000000, 20, 256000}},
{ "driverEncodeIdx", {"driverEncodeIdx", false, 20.000000, 1, 256000}},
{ "driverStateV2", {"driverStateV2", true, 20.000000, 10, 256000}},
{ "driverMonitoringState", {"driverMonitoringState", true, 20.000000, 10, 256000}},
{ "wideRoadEncodeIdx", {"wideRoadEncodeIdx", false, 20.000000, 1, 256000}},
{ "wideRoadCameraState", {"wideRoadCameraState", true, 20.000000, 20, 256000}},
{ "drivingModelData", {"drivingModelData", true, 20.000000, 10, 256000}},
{ "modelV2", {"modelV2", true, 20.000000, -1, 10485760}},
{ "managerState", {"managerState", true, 2.000000, 1, 256000}},
{ "uploaderState", {"uploaderState", true, 0.000000, 1, 256000}},
{ "navInstruction", {"navInstruction", true, 1.000000, 10, 256000}},
{ "navRoute", {"navRoute", true, 0.000000, -1, 256000}},
{ "navThumbnail", {"navThumbnail", true, 0.000000, -1, 256000}},
{ "qRoadEncodeIdx", {"qRoadEncodeIdx", false, 20.000000, -1, 256000}},
{ "userBookmark", {"userBookmark", true, 0.000000, 1, 256000}},
{ "soundPressure", {"soundPressure", true, 10.000000, 10, 256000}},
{ "rawAudioData", {"rawAudioData", false, 20.000000, -1, 256000}},
{ "bookmarkButton", {"bookmarkButton", true, 0.000000, 1, 256000}},
{ "audioFeedback", {"audioFeedback", true, 0.000000, 1, 256000}},
{ "roadEncodeData", {"roadEncodeData", false, 20.000000, -1, 10485760}},
{ "driverEncodeData", {"driverEncodeData", false, 20.000000, -1, 10485760}},
{ "wideRoadEncodeData", {"wideRoadEncodeData", false, 20.000000, -1, 10485760}},
{ "qRoadEncodeData", {"qRoadEncodeData", false, 20.000000, -1, 10485760}},
{ "uiDebug", {"uiDebug", true, 0.000000, 1, 256000}},
{ "testJoystick", {"testJoystick", true, 0.000000, -1, 256000}},
{ "alertDebug", {"alertDebug", true, 20.000000, 5, 256000}},
{ "livestreamWideRoadEncodeIdx", {"livestreamWideRoadEncodeIdx", false, 20.000000, -1, 256000}},
{ "livestreamRoadEncodeIdx", {"livestreamRoadEncodeIdx", false, 20.000000, -1, 256000}},
{ "livestreamDriverEncodeIdx", {"livestreamDriverEncodeIdx", false, 20.000000, -1, 256000}},
{ "livestreamWideRoadEncodeData", {"livestreamWideRoadEncodeData", false, 20.000000, -1, 2097152}},
{ "livestreamRoadEncodeData", {"livestreamRoadEncodeData", false, 20.000000, -1, 2097152}},
{ "livestreamDriverEncodeData", {"livestreamDriverEncodeData", false, 20.000000, -1, 2097152}},
{ "customReservedRawData0", {"customReservedRawData0", true, 0.000000, -1, 256000}},
{ "customReservedRawData1", {"customReservedRawData1", true, 0.000000, -1, 256000}},
{ "customReservedRawData2", {"customReservedRawData2", true, 0.000000, -1, 256000}},
{ "frogpilotCarControl", {"frogpilotCarControl", true, 100.000000, 10, 256000}},
{ "frogpilotCarParams", {"frogpilotCarParams", true, 0.020000, 1, 256000}},
{ "frogpilotCarState", {"frogpilotCarState", true, 100.000000, 10, 256000}},
{ "frogpilotDeviceState", {"frogpilotDeviceState", true, 2.000000, 1, 256000}},
{ "frogpilotModelV2", {"frogpilotModelV2", true, 20.000000, -1, 256000}},
{ "frogpilotOnroadEvents", {"frogpilotOnroadEvents", true, 1.000000, 1, 256000}},
{ "frogpilotPlan", {"frogpilotPlan", true, 20.000000, 10, 256000}},
{ "frogpilotRadarState", {"frogpilotRadarState", true, 20.000000, 5, 256000}},
{ "frogpilotSelfdriveState", {"frogpilotSelfdriveState", true, 100.000000, 10, 256000}},
{ "mapdExtendedOut", {"mapdExtendedOut", true, 1.000000, 1, 2097152}},
{ "mapdIn", {"mapdIn", true, 1.000000, 1, 2097152}},
{ "mapdOut", {"mapdOut", true, 20.000000, 20, 2097152}},
};
#endif
+1
View File
@@ -1 +1,2 @@
*.cpp
!params_pyx.cpp
+32 -1
View File
@@ -53,6 +53,37 @@ void cl_print_build_errors(cl_program program, cl_device_id device) {
LOGE("build failed; status=%d, log: %s", status, log.c_str());
}
std::string resolve_program_path(const char *path) {
std::string resolved_path(path);
if (util::file_exists(resolved_path)) {
return resolved_path;
}
const std::string basedir = util::getenv("BASEDIR", "");
if (!basedir.empty()) {
// Handle repository-relative paths, e.g. "selfdrive/modeld/transforms/transform.cl".
if (!resolved_path.empty() && resolved_path.front() != '/') {
const std::string candidate = basedir + "/" + resolved_path;
if (util::file_exists(candidate)) {
return candidate;
}
}
// Recover from build-path-embedded absolute paths like "/work/selfdrive/...".
for (const char *marker : {"/selfdrive/", "/frogpilot/", "/common/"}) {
if (const size_t idx = resolved_path.find(marker); idx != std::string::npos) {
const std::string candidate = basedir + "/" + resolved_path.substr(idx + 1);
if (util::file_exists(candidate)) {
LOGW("OpenCL source path remapped: %s -> %s", resolved_path.c_str(), candidate.c_str());
return candidate;
}
}
}
}
return resolved_path;
}
} // namespace
cl_device_id cl_get_device_id(cl_device_type device_type) {
@@ -84,7 +115,7 @@ void cl_release_context(cl_context context) {
}
cl_program cl_program_from_file(cl_context ctx, cl_device_id device_id, const char* path, const char* args) {
return cl_program_from_source(ctx, device_id, util::read_file(path), args);
return cl_program_from_source(ctx, device_id, util::read_file(resolve_program_path(path)), args);
}
cl_program cl_program_from_source(cl_context ctx, cl_device_id device_id, const std::string& src, const char* args) {
+41
View File
@@ -0,0 +1,41 @@
import math
import os
from pathlib import Path
CHUNK_SIZE = 45 * 1024 * 1024 # 45MB, under GitHub's 50MB limit
def get_chunk_name(name, idx, num_chunks):
return f"{name}.chunk{idx + 1:02d}of{num_chunks:02d}"
def get_manifest_path(name):
return f"{name}.chunkmanifest"
def get_chunk_paths(path, file_size):
num_chunks = math.ceil(file_size / CHUNK_SIZE)
return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)]
def chunk_file(path, targets):
manifest_path, *chunk_paths = targets
with open(path, 'rb') as f:
data = f.read()
actual_num_chunks = max(1, math.ceil(len(data) / CHUNK_SIZE))
assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}"
for i, chunk_path in enumerate(chunk_paths):
with open(chunk_path, 'wb') as f:
f.write(data[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE])
Path(manifest_path).write_text(str(len(chunk_paths)))
os.remove(path)
def read_file_chunked(path):
manifest_path = get_manifest_path(path)
if os.path.isfile(manifest_path):
num_chunks = int(Path(manifest_path).read_text().strip())
return b''.join(Path(get_chunk_name(path, i, num_chunks)).read_bytes() for i in range(num_chunks))
if os.path.isfile(path):
return Path(path).read_bytes()
raise FileNotFoundError(path)
Binary file not shown.
+1 -1
View File
@@ -97,7 +97,7 @@ Params::Params(const std::string &path, bool memory) {
// FrogPilot variables
std::string params_folder;
if (memory) {
params_folder = "/dev/shm/params";
params_folder = Path::shm_path() + "/params";
} else {
cache_path = "/cache/params" + params_prefix + "/";
params_folder = path;
+64 -2
View File
@@ -1,9 +1,71 @@
from openpilot.common.params_pyx import Params, ParamKeyFlag, ParamKeyType, UnknownKeyName
assert Params
from openpilot.common.params_pyx import Params as _Params, ParamKeyFlag, ParamKeyType, UnknownKeyName
assert _Params
assert ParamKeyFlag
assert ParamKeyType
assert UnknownKeyName
class Params(_Params):
def get(self, key, block=False, return_default=False, encoding=None, default=None):
try:
value = super().get(key, block=block, return_default=return_default)
except UnknownKeyName:
return default
if value is None:
return default
if encoding is not None and isinstance(value, bytes):
try:
return value.decode(encoding)
except Exception:
return value.decode("utf-8", errors="replace")
return value
def get_bool(self, key, block=False, default=False):
try:
return super().get_bool(key, block=block)
except UnknownKeyName:
return bool(default)
def get_int(self, key, block=False, return_default=False, default=0):
val = self.get(key, block=block, return_default=return_default, encoding="utf-8")
if val is None or val == "":
return default
try:
return int(float(val))
except ValueError:
return default
def get_float(self, key, block=False, return_default=False, default=0.0):
val = self.get(key, block=block, return_default=return_default, encoding="utf-8")
if val is None or val == "":
return default
try:
return float(val)
except ValueError:
return default
def put_int(self, key, val):
t = self.get_type(key)
if t == ParamKeyType.FLOAT:
self.put(key, float(val))
elif t == ParamKeyType.INT:
self.put(key, int(val))
elif t == ParamKeyType.BOOL:
self.put(key, bool(val))
else:
self.put(key, str(int(val)))
def put_float(self, key, val):
t = self.get_type(key)
if t == ParamKeyType.FLOAT:
self.put(key, float(val))
elif t == ParamKeyType.INT:
self.put(key, int(val))
elif t == ParamKeyType.BOOL:
self.put(key, bool(val))
else:
self.put(key, str(float(val)))
if __name__ == "__main__":
import sys
+40 -10
View File
@@ -111,6 +111,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"RecordFrontLock", {PERSISTENT, BOOL}}, // for the internal fleet
{"SecOCKey", {PERSISTENT | DONT_LOG, STRING}},
{"ShowDebugInfo", {PERSISTENT, BOOL}},
{"UsePrebuilt", {PERSISTENT, BOOL, "1"}},
{"RouteCount", {PERSISTENT, INT, "0"}},
{"SnoozeUpdate", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"SshEnabled", {PERSISTENT, BOOL}},
@@ -139,9 +140,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"AdjacentPath", {PERSISTENT, BOOL, "0", "0", 3}},
{"AdjacentPathMetrics", {PERSISTENT, BOOL, "0", "0", 3}},
{"AdvancedCustomUI", {PERSISTENT, BOOL, "0", "0", 2}},
{"AdvancedLateralTune", {PERSISTENT, BOOL, "0", "0", 3}},
{"AdvancedLateralTune", {PERSISTENT, BOOL, "1", "0", 2}},
{"AdvancedLongitudinalTune", {PERSISTENT, BOOL, "0", "0", 3}},
{"AggressiveFollow", {PERSISTENT, FLOAT, "1.25", "1.25", 2}},
{"AggressiveFollowHigh", {PERSISTENT, FLOAT, "1.25", "1.25", 2}},
{"AggressiveJerkAcceleration", {PERSISTENT, FLOAT, "50.0", "50.0", 3}},
{"AggressiveJerkDanger", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"AggressiveJerkDeceleration", {PERSISTENT, FLOAT, "50.0", "50.0", 3}},
@@ -154,8 +156,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"AutomaticallyDownloadModels", {PERSISTENT, BOOL, "1", "0", 1}},
{"AutomaticUpdates", {PERSISTENT, BOOL, "1", "1", 0}},
{"AvailableModelNames", {PERSISTENT, STRING, "", "", 1}},
{"AvailableModelSeries", {PERSISTENT, STRING, "", "", 1}},
{"AvailableModels", {PERSISTENT, STRING, "", "", 1}},
{"BlacklistedModels", {PERSISTENT, STRING, "", "", 2}},
{"BootLogo", {PERSISTENT, STRING, "starpilot", "stock", 0}},
{"BuildMetadata", {PERSISTENT, STRING, "", "", 0}},
{"BlindSpotMetrics", {PERSISTENT, BOOL, "1", "0", 3}},
{"BlindSpotPath", {PERSISTENT, BOOL, "1", "0", 1}},
@@ -172,7 +176,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"CECurves", {PERSISTENT, BOOL, "0", "0", 1}},
{"CECurvesLead", {PERSISTENT, BOOL, "0", "0", 1}},
{"CELead", {PERSISTENT, BOOL, "0", "0", 1}},
{"CEModelStopTime", {PERSISTENT, FLOAT, "8.0", "0.0", 2}},
{"CEModelStopTime", {PERSISTENT, FLOAT, "7.0", "0.0", 2}},
{"CESignalLaneDetection", {PERSISTENT, BOOL, "1", "0", 2}},
{"CESignalSpeed", {PERSISTENT, FLOAT, "55.0", "0.0", 2}},
{"CESlowerLead", {PERSISTENT, BOOL, "0", "0", 1}},
@@ -184,7 +188,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ClusterOffset", {PERSISTENT, FLOAT, "1.015", "1.015", 2}},
{"ColorScheme", {PERSISTENT, STRING, "frog", "stock", 0}},
{"ColorToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"BootLogoToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"Compass", {PERSISTENT, BOOL, "0", "0", 1}},
{"CommunityFavorites", {PERSISTENT, STRING, "", "", 1}},
{"ConditionalExperimental", {PERSISTENT, BOOL, "1", "0", 1}},
{"CurvatureData", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}},
{"CurveSpeedController", {PERSISTENT, BOOL, "1", "0", 1}},
@@ -192,6 +198,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"CustomCruise", {PERSISTENT, FLOAT, "1.0", "1.0", 2}},
{"CustomCruiseLong", {PERSISTENT, FLOAT, "5.0", "5.0", 2}},
{"CustomPersonalities", {PERSISTENT, BOOL, "0", "0", 2}},
{"TrafficPersonalityProfile", {PERSISTENT, BOOL, "1", "1", 2}},
{"AggressivePersonalityProfile", {PERSISTENT, BOOL, "1", "1", 2}},
{"StandardPersonalityProfile", {PERSISTENT, BOOL, "1", "1", 2}},
{"RelaxedPersonalityProfile", {PERSISTENT, BOOL, "1", "1", 2}},
{"CustomThemes", {PERSISTENT, BOOL, "1", "0", 0}},
{"CustomUI", {PERSISTENT, BOOL, "1", "0", 1}},
{"DebugMode", {CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0", "0"}},
@@ -216,6 +226,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DistanceButtonControl", {PERSISTENT, INT, "1", "0", 2}},
{"DistanceIconPack", {PERSISTENT, STRING, "stock", "stock", 0}},
{"DistanceIconToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"DownloadableBootLogos", {PERSISTENT, STRING, "", ""}},
{"DownloadableColors", {PERSISTENT, STRING, "", ""}},
{"DownloadableDistanceIcons", {PERSISTENT, STRING, "", ""}},
{"DownloadableIcons", {PERSISTENT, STRING, "", ""}},
@@ -225,16 +236,22 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DownloadAllModels", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"DownloadMaps", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"DriverCamera", {PERSISTENT, BOOL, "0", "0", 1}},
{"DrivingModel", {PERSISTENT, STRING, "wmi-model_default", "wmi-model_default", 1}},
{"DrivingModelName", {PERSISTENT, STRING, "WMI model (Default)", "WMI model (Default)", 1}},
{"DrivingModelVersion", {PERSISTENT, STRING, "v9", "v9", 1}},
{"Model", {PERSISTENT, STRING, "sc", "sc", 1}},
{"ModelVersion", {PERSISTENT, STRING, "v11", "v11", 1}},
{"DrivingModel", {PERSISTENT, STRING, "sc", "sc", 1}},
{"DrivingModelName", {PERSISTENT, STRING, "South Carolina", "South Carolina", 1}},
{"DrivingModelVersion", {PERSISTENT, STRING, "v11", "v11", 1}},
{"DynamicPathWidth", {PERSISTENT, BOOL, "0", "0", 2}},
{"DynamicPedalsOnUI", {PERSISTENT, BOOL, "1", "0", 1}},
{"EngageVolume", {PERSISTENT, INT, "101", "101", 2}},
{"EVTuning", {PERSISTENT, BOOL, "0", "0", 3}},
{"Fahrenheit", {PERSISTENT, BOOL, "0", "0", 3}},
{"FlashPanda", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"GMPedalLongitudinal", {PERSISTENT, BOOL, "1", "1", 2}},
{"RemoteStartBootsComma", {PERSISTENT, BOOL, "0", "0"}},
{"RemapCancelToDistance", {PERSISTENT, BOOL, "0", "0"}},
{"ForceAutoTune", {PERSISTENT, BOOL, "0", "0", 3}},
{"ForceAutoTuneOff", {PERSISTENT, BOOL, "0", "0", 3}},
{"ForceAutoTuneOff", {PERSISTENT, BOOL, "1", "0", 2}},
{"ForceFingerprint", {PERSISTENT, BOOL, "0", "0", 2}},
{"ForceOffroad", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"ForceOnroad", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
@@ -257,8 +274,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"HideSpeedLimit", {PERSISTENT, BOOL, "0", "0", 2}},
{"HigherBitrate", {PERSISTENT, BOOL, "0", "0", 2}},
{"HolidayThemes", {PERSISTENT, BOOL, "1", "0", 0}},
{"HumanAcceleration", {PERSISTENT, BOOL, "1", "0", 2}},
{"HumanFollowing", {PERSISTENT, BOOL, "1", "0", 2}},
{"HumanAcceleration", {PERSISTENT, BOOL, "0", "0", 2}},
{"HumanFollowing", {PERSISTENT, BOOL, "0", "0", 2}},
{"HumanLaneChanges", {PERSISTENT, BOOL, "1", "0", 2}},
{"IconPack", {PERSISTENT, STRING, "frog-animated", "stock", 0}},
{"IconToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
@@ -290,6 +307,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LongDistanceButtonControl", {PERSISTENT, INT, "5", "0", 2}},
{"LongitudinalActuatorDelay", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"LongitudinalActuatorDelayStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"LongitudinalManeuverPaddleMode", {PERSISTENT, STRING, "auto", "auto"}},
{"LongitudinalManeuverStatus", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON, "{}", "{}"}},
{"LongitudinalTune", {PERSISTENT, BOOL, "1", "0", 0}},
{"LoudBlindspotAlert", {PERSISTENT, BOOL, "0", "0", 0}},
{"LowVoltageShutdown", {PERSISTENT, FLOAT, "11.8", "11.8", 3}},
@@ -308,12 +327,15 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"MinimumLaneChangeSpeed", {PERSISTENT, FLOAT, "20.0", "20.0", 2}},
{"ModelDownloadProgress", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"ModelDrivesAndScores", {PERSISTENT, JSON, "{}", "{}"}},
{"ModelReleasedDates", {PERSISTENT, STRING, "", "", 1}},
{"ModelRandomizer", {PERSISTENT, BOOL, "0", "0", 2}},
{"ModelSortMode", {PERSISTENT, STRING, "alphabetical", "alphabetical", 1}},
{"ModelToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
{"ModelUI", {PERSISTENT, BOOL, "1", "0", 2}},
{"ModelVersions", {PERSISTENT, STRING, "", "", 1}},
{"NavigationUI", {PERSISTENT, BOOL, "1", "0", 1}},
{"NNFF", {PERSISTENT, BOOL, "1", "0", 2}},
{"NNFFLite", {PERSISTENT, BOOL, "1", "0", 2}},
{"NNFF", {PERSISTENT, BOOL, "0", "0", 2}},
{"NNFFLite", {PERSISTENT, BOOL, "0", "0", 2}},
{"NNFFModelName", {CLEAR_ON_MANAGER_START, STRING, "", "", 0}},
{"NoLogging", {PERSISTENT, BOOL, "0", "0", 2}},
{"NoUploads", {PERSISTENT, BOOL, "0", "0", 2}},
@@ -361,12 +383,14 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ReduceLateralAccelerationSnow", {PERSISTENT, INT, "0", "0", 2}},
{"RefuseVolume", {PERSISTENT, INT, "101", "101", 2}},
{"RelaxedFollow", {PERSISTENT, FLOAT, "1.75", "1.75", 2}},
{"RelaxedFollowHigh", {PERSISTENT, FLOAT, "1.75", "1.75", 2}},
{"RelaxedJerkAcceleration", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"RelaxedJerkDanger", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"RelaxedJerkDeceleration", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"RelaxedJerkSpeed", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"RelaxedJerkSpeedDecrease", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"ReverseCruise", {PERSISTENT, BOOL, "0", "0", 1}},
{"RecoveryPower", {PERSISTENT, FLOAT, "1.0", "1.0", 2}},
{"RoadEdgesWidth", {PERSISTENT, FLOAT, "2.0", "2.0", 2}},
{"RoadNameUI", {PERSISTENT, BOOL, "1", "0", 1}},
{"RotatingWheel", {PERSISTENT, BOOL, "1", "0", 1}},
@@ -420,6 +444,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"SpeedLimitsFiltered", {PERSISTENT | DONT_LOG, JSON, "[]", "[]"}},
{"SpeedLimitSources", {PERSISTENT, BOOL, "0", "0", 3}},
{"StandardFollow", {PERSISTENT, FLOAT, "1.45", "1.45", 2}},
{"StandardFollowHigh", {PERSISTENT, FLOAT, "1.45", "1.45", 2}},
{"StandardJerkAcceleration", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"StandardJerkDanger", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
{"StandardJerkDeceleration", {PERSISTENT, FLOAT, "100.0", "100.0", 3}},
@@ -439,12 +464,15 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"SteerKPStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SteerLatAccel", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SteerLatAccelStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SteerOffset", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SteerOffsetStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SteerRatio", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SteerRatioStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"StockDongleId", {PERSISTENT, STRING, "", ""}},
{"StopAccel", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"StopAccelStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"StoppedTimer", {PERSISTENT, BOOL, "0", "0", 1}},
{"StopDistance", {PERSISTENT, FLOAT, "6.0", "6.0", 2}},
{"StoppingDecelRate", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"StoppingDecelRateStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"SubaruSNG", {PERSISTENT, BOOL, "1", "0", 2}},
@@ -463,6 +491,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"TrafficJerkDeceleration", {PERSISTENT, FLOAT, "50.0", "50.0", 3}},
{"TrafficJerkSpeed", {PERSISTENT, FLOAT, "50.0", "50.0", 3}},
{"TrafficJerkSpeedDecrease", {PERSISTENT, FLOAT, "50.0", "50.0", 3}},
{"TruckTuning", {PERSISTENT, BOOL, "0", "0", 3}},
{"TuningLevel", {PERSISTENT, INT, "0", "0", 0}},
{"TuningLevelConfirmed", {PERSISTENT, BOOL, "0", "0", 0}},
{"TurnDesires", {PERSISTENT, BOOL, "0", "0", 2}},
@@ -475,6 +504,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"UseActiveTheme", {CLEAR_ON_MANAGER_START, BOOL, "0", "0"}},
{"UseKonikServer", {PERSISTENT, BOOL, "0", "0", 2}},
{"UseSI", {PERSISTENT, BOOL, "1", "1", 3}},
{"UserFavorites", {PERSISTENT, STRING, "", "", 1}},
{"UseVienna", {PERSISTENT, BOOL, "0", "0", 1}},
{"VEgoStarting", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
{"VEgoStartingStock", {PERSISTENT, FLOAT, "0.0", "0.0", 3}},
+19413
View File
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -65,12 +65,35 @@ PYTHON_2_CPP = {
(list, JSON): json.dumps,
(bytes, BYTES): lambda v: v,
}
def _decode_int(v):
decoded = v.decode("utf-8")
try:
return int(decoded)
except ValueError:
return int(float(decoded))
def _decode_time(v):
decoded = v.decode("utf-8")
try:
return datetime.datetime.fromisoformat(decoded)
except ValueError:
for fmt in ("%B %d, %Y - %I:%M%p", "%B %d, %Y - %I:%M %p"):
try:
return datetime.datetime.strptime(decoded, fmt)
except ValueError:
pass
raise
CPP_2_PYTHON = {
STRING: lambda v: v.decode("utf-8"),
BOOL: lambda v: v == b"1",
INT: int,
INT: _decode_int,
FLOAT: float,
TIME: lambda v: datetime.datetime.fromisoformat(v.decode("utf-8")),
TIME: _decode_time,
JSON: json.loads,
BYTES: lambda v: v,
}
BIN
View File
Binary file not shown.
+20 -2
View File
@@ -5,10 +5,28 @@ from openpilot.common.basedir import BASEDIR
class Spinner:
def __init__(self):
self.spinner_proc = None
# Prefer the legacy compiled spinner on device.
legacy_spinner = os.path.join(BASEDIR, "selfdrive", "ui", "spinner")
if os.path.isfile("/TICI") and os.path.isfile(legacy_spinner) and os.access(legacy_spinner, os.X_OK):
try:
self.spinner_proc = subprocess.Popen([legacy_spinner],
stdin=subprocess.PIPE,
cwd=os.path.join(BASEDIR, "selfdrive", "ui"),
close_fds=True)
return
except OSError:
self.spinner_proc = None
# Raylib spinner requires Python deps from the repo virtualenv.
spinner_cwd = os.path.join(BASEDIR, "system", "ui")
venv_python = os.path.join(BASEDIR, ".venv", "bin", "python")
python_exec = venv_python if os.path.isfile(venv_python) else "python3"
try:
self.spinner_proc = subprocess.Popen(["./spinner.py"],
self.spinner_proc = subprocess.Popen([python_exec, "./spinner.py"],
stdin=subprocess.PIPE,
cwd=os.path.join(BASEDIR, "system", "ui"),
cwd=spinner_cwd,
close_fds=True)
except OSError:
self.spinner_proc = None
+19 -2
View File
@@ -7,10 +7,27 @@ from openpilot.common.basedir import BASEDIR
class TextWindow:
def __init__(self, text):
self.text_proc = None
# Prefer the legacy compiled text window on device.
legacy_text = os.path.join(BASEDIR, "selfdrive", "ui", "text")
if os.path.isfile("/TICI") and os.path.isfile(legacy_text) and os.access(legacy_text, os.X_OK):
try:
self.text_proc = subprocess.Popen([legacy_text, text],
stdin=subprocess.PIPE,
cwd=os.path.join(BASEDIR, "selfdrive", "ui"),
close_fds=True)
return
except OSError:
self.text_proc = None
text_cwd = os.path.join(BASEDIR, "system", "ui")
venv_python = os.path.join(BASEDIR, ".venv", "bin", "python")
python_exec = venv_python if os.path.isfile(venv_python) else "python3"
try:
self.text_proc = subprocess.Popen(["./text.py", text],
self.text_proc = subprocess.Popen([python_exec, "./text.py", text],
stdin=subprocess.PIPE,
cwd=os.path.join(BASEDIR, "system", "ui"),
cwd=text_cwd,
close_fds=True)
except OSError:
self.text_proc = None
Binary file not shown.
+122
View File
@@ -0,0 +1,122 @@
# Laptop Device Build (Mac/Linux)
This flow builds **device-target (`larch64`) binaries on your laptop** using a Linux/aarch64 container and a synced comma sysroot.
## Prerequisites
- Docker Desktop (or Podman) with Linux/aarch64 support.
- `rsync` and `ssh` on your laptop.
- Either:
- access to a comma device over SSH, or
- internet access to download AGNOS system image for sysroot extraction.
## One-time setup
Fast path (no physical comma):
```bash
cd /path/to/frogpilot
scripts/laptop_device_build.sh setup
```
Equivalent wrapper:
```bash
scripts/starpilot_build_flow.sh laptop-setup
```
### Option A: no physical comma (AGNOS-based)
```bash
cd /path/to/frogpilot
scripts/laptop_device_build.sh build-image
scripts/laptop_device_build.sh setup-sysroot-agnos
```
### Option B: copy sysroot from a comma device
```bash
cd /path/to/frogpilot
scripts/laptop_device_build.sh setup-sysroot <device-ip> comma 22
scripts/laptop_device_build.sh build-image
```
Optional all-in-one with physical device sysroot:
```bash
scripts/laptop_device_build.sh setup <device-ip> comma 22
```
## Build device-compatible artifacts
```bash
cd /path/to/frogpilot
scripts/laptop_device_build.sh build
```
Equivalent wrapper:
```bash
scripts/starpilot_build_flow.sh laptop-device
```
This runs:
- `uv sync` into `/work/.venv-linux-arm64` (inside container)
- full `scons` with:
- `SP_FORCE_ARCH=larch64`
- `SP_FORCE_TICI=1`
- `SP_TICI_SYSROOT=/opt/tici-sysroot`
- `touch prebuilt`
To run `scons` targets explicitly in the same device-compatible environment:
```bash
scripts/laptop_device_build.sh scons selfdrive/ui/ui
```
On macOS, once `.comma_sysroot` is present, plain `scons ...` auto-routes to this containerized device build.
Set `SP_DISABLE_AUTO_DEVICE_SCONS=1` to force native host `scons`.
## Quick checks
```bash
scripts/laptop_device_build.sh doctor
```
If `doctor` fails, fix the missing runtime/sysroot step before running `build`.
## One-command manager launch on desktop
`./launch_openpilot.sh` now auto-routes desktop/mac launches to the containerized larch64 manager path:
- runs `scripts/laptop_device_build.sh doctor`
- runs `setup` automatically if prerequisites are missing
- runs container manager launch, auto-building missing runtime artifacts first
Useful overrides:
- `SP_SKIP_DOCKER_AUTO_BUILD=1 ./launch_openpilot.sh` to skip auto-build.
- `SP_DOCKER_BUILD_JOBS=12 ./launch_openpilot.sh` to control build parallelism.
- `SP_FORCE_DEVICE_LAUNCH=1 ./launch_openpilot.sh` to force legacy device launch path.
Note:
- `manager` mode requires the container runtime machine to be `aarch64`.
- If your built image runs as `x86_64`, build mode still works for artifact generation, but manager launch in-container is not supported.
Desktop UI workaround (keep device build + still inspect UI on macOS):
```bash
scripts/launch_ui_desktop.sh
```
This builds a native macOS `selfdrive/ui/ui.macos`, restores device `selfdrive/ui/ui`, and launches the mac UI binary.
Desktop C4/raylib UI launcher:
```bash
scripts/launch_ui_c4_desktop.sh
```
This launches `selfdrive/ui/ui.py` in small-UI mode (`BIG=0`) with onboarding pre-accepted params.
+1
View File
@@ -0,0 +1 @@
../stock_theme/colors
+1
View File
@@ -0,0 +1 @@
../stock_theme/distance_icons
+1
View File
@@ -0,0 +1 @@
../stock_theme/icons
+1
View File
@@ -0,0 +1 @@
../stock_theme/signals
+1
View File
@@ -0,0 +1 @@
../stock_theme/sounds
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
import os
import requests
import tempfile
from datetime import datetime
from pathlib import Path
from openpilot.frogpilot.common.frogpilot_utilities import delete_file, is_url_pingable
RESOURCES_REPO = os.getenv("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources")
GITHUB_URL = f"https://raw.githubusercontent.com/{RESOURCES_REPO}"
GITLAB_URL = f"https://gitlab.com/{RESOURCES_REPO}/-/raw"
def check_github_rate_limit():
try:
response = requests.get("https://api.github.com/rate_limit")
response.raise_for_status()
rate_limit_info = response.json()
remaining = rate_limit_info["rate"]["remaining"]
print(f"GitHub API Requests Remaining: {remaining}")
if remaining > 0:
return True
reset_time = datetime.utcfromtimestamp(rate_limit_info["rate"]["reset"]).strftime("%Y-%m-%d %H:%M:%S")
print("GitHub rate limit reached")
print(f"GitHub Rate Limit Resets At (UTC): {reset_time}")
return False
except requests.exceptions.RequestException as error:
print(f"Error checking GitHub rate limit: {error}")
return False
def download_file(cancel_param, destination, progress_param, url, download_param, params_memory, allow_unknown_size=False, suppress_errors=False):
try:
destination.parent.mkdir(parents=True, exist_ok=True)
total_size = get_remote_file_size(url, suppress_errors=suppress_errors or allow_unknown_size)
if total_size == 0 and not allow_unknown_size:
if not url.endswith(".gif"):
if suppress_errors:
return
print(f"Download invalid for {url} (size 0)")
handle_error(None, "Download invalid...", "Download invalid...", download_param, progress_param, params_memory)
return
print(f"Starting download: {url} ({total_size} bytes)")
with requests.get(url, stream=True, timeout=10) as response:
response.raise_for_status()
with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as temp_file:
temp_file_path = Path(temp_file.name)
downloaded_size = 0
for chunk in response.iter_content(chunk_size=16384):
if params_memory.get_bool(cancel_param):
temp_file_path.unlink(missing_ok=True)
handle_error(None, "Download cancelled...", "Download cancelled...", download_param, progress_param, params_memory)
return
if chunk:
temp_file.write(chunk)
downloaded_size += len(chunk)
if total_size > 0:
progress = (downloaded_size / total_size) * 100
if progress != 100:
params_memory.put(progress_param, f"{progress:.0f}%")
else:
params_memory.put(progress_param, "Verifying authenticity...")
elif downloaded_size > 0:
params_memory.put(progress_param, "Verifying authenticity...")
temp_file_path.rename(destination)
print(f"Download complete: {destination.name}")
except Exception as error:
if suppress_errors:
return
print(f"Download request error: {error}")
handle_request_error(error, destination, download_param, progress_param, params_memory)
def get_remote_file_size(url, suppress_errors=False):
try:
response = requests.head(url, headers={"Accept-Encoding": "identity"}, timeout=10)
response.raise_for_status()
return int(response.headers.get("Content-Length", 0))
except Exception as error:
if not suppress_errors:
handle_request_error(error, None, None, None, None)
return 0
def get_repository_url():
if is_url_pingable("https://github.com"):
if check_github_rate_limit():
return GITHUB_URL
if is_url_pingable("https://gitlab.com"):
return GITLAB_URL
return None
def handle_error(destination, error_message, error, download_param, progress_param, params_memory):
if destination:
delete_file(destination)
if params_memory and progress_param and "404" not in error_message:
print(f"Error occurred: {error}")
params_memory.put(progress_param, error_message)
params_memory.remove(download_param)
def handle_request_error(error, destination, download_param, progress_param, params_memory):
error_map = {
requests.ConnectionError: "Connection dropped",
requests.HTTPError: lambda error: f"Server error ({error.response.status_code})" if error.response else "Server error",
requests.RequestException: "Network request error. Check connection",
requests.Timeout: "Download timed out"
}
error_message = error_map.get(type(error), "Unexpected error")
handle_error(destination, f"Failed: {error_message}", error, download_param, progress_param, params_memory)
def verify_download(file_path, url, allow_unknown_size=False):
remote_file_size = get_remote_file_size(url, suppress_errors=allow_unknown_size)
if remote_file_size == 0 and allow_unknown_size:
if not file_path.is_file():
print(f"File not found: {file_path}")
return False
if file_path.stat().st_size == 0:
print(f"File is empty: {file_path}")
return False
return True
if remote_file_size == 0:
print(f"Error fetching remote size for {file_path}")
return False
if not file_path.is_file():
print(f"File not found: {file_path}")
return False
if remote_file_size != file_path.stat().st_size:
print(f"File size mismatch for {file_path}")
return False
return True
+380
View File
@@ -0,0 +1,380 @@
#!/usr/bin/env python3
import json
import re
import urllib.request
from pathlib import Path
from openpilot.frogpilot.assets.download_functions import (
GITLAB_URL,
download_file,
get_repository_url,
handle_error,
handle_request_error,
verify_download,
)
from openpilot.frogpilot.common.frogpilot_utilities import delete_file
from openpilot.frogpilot.common.frogpilot_variables import MODELS_PATH
MANIFEST_CANDIDATES = ("v21",)
TINYGRAD_VERSIONS = {"v8", "v9", "v10", "v11", "v12"}
DEFAULT_MODEL_KEY = "sc"
MODEL_KEY_CANONICAL_MAP = {
"sc2": "sc",
}
CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
MODEL_DOWNLOAD_PARAM = "ModelToDownload"
MODEL_DOWNLOAD_ALL_PARAM = "DownloadAllModels"
UPDATE_TINYGRAD_PARAM = "UpdateTinygrad"
def _clean_model_name(name: str) -> str:
return re.sub(r"[🗺️👀📡]", "", str(name or "")).strip()
class ModelManager:
def __init__(self, params, params_memory, boot_run=False):
self.params = params
self.params_memory = params_memory
self.downloading_model = False
self.available_models = [entry for entry in (self.params.get("AvailableModels") or "").split(",") if entry]
self.model_versions = [entry for entry in (self.params.get("ModelVersions") or "").split(",") if entry]
self.model_series = [entry for entry in (self.params.get("AvailableModelSeries") or "").split(",") if entry]
self.available_model_names = [entry for entry in (self.params.get("AvailableModelNames") or "").split(",") if entry]
self._ensure_model_params()
if boot_run:
self._sync_selected_model_version()
@staticmethod
def _canonical_model_key(model_key: str) -> str:
key = (model_key or "").strip()
return MODEL_KEY_CANONICAL_MAP.get(key, key)
def _param_text(self, key: str) -> str:
raw = self.params.get(key)
if raw is None:
return ""
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="ignore").strip()
return str(raw).strip()
def _default_param_text(self, key: str) -> str:
try:
default_value = self.params.get_default_value(key)
except Exception:
return ""
if default_value is None:
return ""
if isinstance(default_value, bytes):
return default_value.decode("utf-8", errors="ignore").strip()
return str(default_value).strip()
def _set_model_param_keys(self, model_key: str | None = None, model_name: str | None = None, model_version: str | None = None):
if model_key is not None and model_key != "":
canonical_key = self._canonical_model_key(model_key)
self.params.put("Model", canonical_key)
self.params.put("DrivingModel", canonical_key)
if model_name is not None and model_name != "":
self.params.put("DrivingModelName", model_name)
if model_version is not None and model_version != "":
self.params.put("ModelVersion", model_version)
self.params.put("DrivingModelVersion", model_version)
def _ensure_model_params(self):
selected_model = self._selected_model()
current_version = self._param_text("ModelVersion") or self._param_text("DrivingModelVersion")
if not current_version:
current_version = self._default_param_text("ModelVersion") or self._default_param_text("DrivingModelVersion") or "v11"
selected_name = self._param_text("DrivingModelName")
if not selected_name and selected_model in self.available_models:
selected_index = self.available_models.index(selected_model)
if selected_index < len(self.available_model_names):
selected_name = self.available_model_names[selected_index]
self._set_model_param_keys(selected_model, selected_name, current_version)
def _model_key_aliases(self, model_key: str) -> list[str]:
canonical_key = self._canonical_model_key(model_key)
aliases = [canonical_key]
# Preserve legacy alias lookups (e.g. sc2) even when canonicalized to sc.
for alias, canonical in MODEL_KEY_CANONICAL_MAP.items():
if canonical == canonical_key:
aliases.append(alias)
if model_key.endswith("_default"):
aliases.append(model_key[:-8])
# v21 manifest uses legacy IDs with a trailing "2" (e.g. sc -> sc2).
if model_key and not model_key.endswith("2"):
aliases.append(f"{model_key}2")
return [alias for alias in dict.fromkeys(aliases) if alias]
def _model_version_map(self) -> dict[str, str]:
return {
model_key: self.model_versions[index]
for index, model_key in enumerate(self.available_models)
if index < len(self.model_versions) and model_key
}
def _selected_model(self) -> str:
selected = self._param_text("Model") or self._param_text("DrivingModel")
if selected:
return self._canonical_model_key(selected)
default_value = self._default_param_text("Model") or self._default_param_text("DrivingModel")
if default_value:
return self._canonical_model_key(default_value)
return DEFAULT_MODEL_KEY
def _required_files(self, model_key: str, model_version: str) -> list[str]:
if model_version not in TINYGRAD_VERSIONS:
return []
filenames = [
f"{model_key}_driving_policy_tinygrad.pkl",
f"{model_key}_driving_vision_tinygrad.pkl",
f"{model_key}_driving_policy_metadata.pkl",
f"{model_key}_driving_vision_metadata.pkl",
]
if model_version == "v12":
filenames += [
f"{model_key}_driving_off_policy_tinygrad.pkl",
f"{model_key}_driving_off_policy_metadata.pkl",
]
return filenames
def _is_model_downloaded(self, model_key: str, model_version: str) -> bool:
required_files = self._required_files(model_key, model_version)
if not required_files:
return False
return all((MODELS_PATH / filename).is_file() for filename in required_files)
def _sync_selected_model_version(self):
version_map = self._model_version_map()
name_map = {model_key: model_name for model_key, model_name in zip(self.available_models, self.available_model_names)}
selected = self._selected_model()
version = version_map.get(selected)
if version:
self._set_model_param_keys(selected, name_map.get(selected), version)
return
for alias in self._model_key_aliases(selected):
version = version_map.get(alias)
if version:
selected_name = name_map.get(selected) or name_map.get(alias) or self._param_text("DrivingModelName")
self._set_model_param_keys(selected, selected_name, version)
return
fallback_version = self._param_text("ModelVersion") or self._param_text("DrivingModelVersion")
if not fallback_version:
fallback_version = self._default_param_text("ModelVersion") or self._default_param_text("DrivingModelVersion") or "v11"
self._set_model_param_keys(selected, name_map.get(selected, ""), fallback_version)
@staticmethod
def _fetch_manifest(url: str) -> list[dict]:
try:
with urllib.request.urlopen(url, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
return payload.get("models", []) if isinstance(payload, dict) else []
except Exception as error:
handle_request_error(error, None, None, None, None)
return []
def _get_manifest(self, repo_url: str) -> tuple[str | None, list[dict]]:
for manifest_version in MANIFEST_CANDIDATES:
model_info = self._fetch_manifest(f"{repo_url}/Versions/model_names_{manifest_version}.json")
if not model_info:
continue
# Desktop/dev build is tinygrad-only.
filtered = [model for model in model_info if model.get("version") in TINYGRAD_VERSIONS]
if not filtered:
continue
return manifest_version, filtered
return None, []
def _remove_stale_model_files(self):
valid_keys = set(self.available_models)
for model_file in MODELS_PATH.glob("*_driving_*"):
model_key = model_file.name.split("_driving_", 1)[0]
if model_key not in valid_keys:
delete_file(model_file, print_error=False)
for temp_file in MODELS_PATH.glob("tmp*"):
delete_file(temp_file, print_error=False)
def _enforce_selected_model(self):
if not self.available_models:
return
selected = self._selected_model()
aliases = self._model_key_aliases(selected)
if any(alias in self.available_models for alias in aliases):
self._sync_selected_model_version()
return
try:
default_model = self._default_param_text("Model") or self._default_param_text("DrivingModel")
except Exception:
default_model = DEFAULT_MODEL_KEY
candidates = self._model_key_aliases(default_model) + self._model_key_aliases(DEFAULT_MODEL_KEY) + self.available_models
replacement = next((entry for entry in candidates if entry in self.available_models), self.available_models[0])
replacement_index = self.available_models.index(replacement)
replacement_name = self.available_model_names[replacement_index] if replacement_index < len(self.available_model_names) else replacement
self._set_model_param_keys(replacement, replacement_name, None)
self._sync_selected_model_version()
def update_model_params(self, model_info: list[dict], manifest_version: str):
del manifest_version
self.available_models = [str(model.get("id") or "").strip() for model in model_info]
self.available_model_names = [_clean_model_name(model.get("name")) for model in model_info]
self.model_versions = [str(model.get("version") or "").strip() for model in model_info]
self.model_series = [str(model.get("series") or "Custom Series").strip() for model in model_info]
released_dates = [str(model.get("released") or "2023-01-01").strip() for model in model_info]
community_favorites = [model_key for model_key, model in zip(self.available_models, model_info) if model.get("community_favorite", False)]
self.params.put("AvailableModels", ",".join(self.available_models))
self.params.put("AvailableModelNames", ",".join(self.available_model_names))
self.params.put("AvailableModelSeries", ",".join(self.model_series))
self.params.put("ModelReleasedDates", ",".join(released_dates))
self.params.put("ModelVersions", ",".join(self.model_versions))
self.params.put("CommunityFavorites", ",".join(community_favorites))
self._sync_selected_model_version()
try:
version_map = {model_key: version for model_key, version in zip(self.available_models, self.model_versions)}
versions_file = MODELS_PATH / ".model_versions.json"
versions_file.parent.mkdir(parents=True, exist_ok=True)
versions_file.write_text(json.dumps(version_map))
except Exception as error:
print(f"Failed to write model versions cache: {error}")
def check_models(self, boot_run: bool):
del boot_run # Not currently needed, retained for call-site parity.
self._remove_stale_model_files()
self._enforce_selected_model()
def update_models(self, boot_run=False):
if self.downloading_model:
return
repo_url = get_repository_url()
if repo_url is None:
print("GitHub and GitLab are offline...")
return
manifest_version, model_info = self._get_manifest(repo_url)
if not model_info:
print("No compatible tinygrad manifest found.")
return
self.update_model_params(model_info, manifest_version or "unknown")
self.check_models(boot_run)
def download_model(self, model_to_download: str):
self.downloading_model = True
repo_url = get_repository_url()
if not repo_url:
handle_error(None, "GitHub and GitLab are offline...", "Repository unavailable", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
version_map = self._model_version_map()
model_version = version_map.get(model_to_download)
required_files = self._required_files(model_to_download, model_version or "")
if not required_files:
handle_error(None, f"Unsupported model format for {model_to_download}", "Model download failed", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
for filename in required_files:
file_path = MODELS_PATH / filename
file_url = f"{repo_url}/Models/{filename}"
download_file(CANCEL_DOWNLOAD_PARAM, file_path, DOWNLOAD_PROGRESS_PARAM, file_url, MODEL_DOWNLOAD_PARAM, self.params_memory)
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
handle_error(None, "Download cancelled...", "Download cancelled...", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
if verify_download(file_path, file_url):
continue
fallback_url = f"{GITLAB_URL}/Models/{filename}"
download_file(CANCEL_DOWNLOAD_PARAM, file_path, DOWNLOAD_PROGRESS_PARAM, fallback_url, MODEL_DOWNLOAD_PARAM, self.params_memory)
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
handle_error(None, "Download cancelled...", "Download cancelled...", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
if not verify_download(file_path, fallback_url):
handle_error(file_path, "Verification failed...", f"Verification failed for {filename}", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
self.downloading_model = False
return
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
self.params_memory.remove(MODEL_DOWNLOAD_PARAM)
self.downloading_model = False
def download_all_models(self):
repo_url = get_repository_url()
if not repo_url:
handle_error(None, "GitHub and GitLab are offline...", "Repository unavailable", MODEL_DOWNLOAD_ALL_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return
manifest_version, model_info = self._get_manifest(repo_url)
if not model_info:
handle_error(None, "Unable to fetch models...", "Model list unavailable", MODEL_DOWNLOAD_ALL_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return
self.update_model_params(model_info, manifest_version or "unknown")
for model_key, model_name in zip(self.available_models, self.available_model_names):
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
handle_error(None, "Download cancelled...", "Download cancelled...", MODEL_DOWNLOAD_ALL_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory)
return
model_version = self._model_version_map().get(model_key, "")
if self._is_model_downloaded(model_key, model_version):
continue
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, f"Downloading \"{model_name}\"...")
self.download_model(model_key)
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
return
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "All models downloaded!")
self.params_memory.remove(MODEL_DOWNLOAD_ALL_PARAM)
def update_tinygrad(self):
# This branch ships tinygrad runtime in-tree. "Update" here refreshes local model files.
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Updating...")
for model_file in MODELS_PATH.glob("*_driving_*"):
if model_file.is_file():
delete_file(model_file, print_error=False)
model_versions_file = MODELS_PATH / ".model_versions.json"
if model_versions_file.is_file():
delete_file(model_versions_file, print_error=False)
self.params.put_bool("TinygradUpdateAvailable", False)
self.params_memory.remove(UPDATE_TINYGRAD_PARAM)
self.params_memory.remove(CANCEL_DOWNLOAD_PARAM)
if self.params.get_bool("AutomaticallyDownloadModels"):
self.params_memory.put_bool(MODEL_DOWNLOAD_ALL_PARAM, True)
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloading...")
else:
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Updated!")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+170 -66
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import glob
import json
import random
import requests
import shutil
@@ -36,6 +37,7 @@ HOLIDAY_SLUGS = {
}
THEME_COMPONENT_PARAMS = {
"boot_logos": "BootLogoToDownload",
"colors": "ColorToDownload",
"distance_icons": "DistanceIconToDownload",
"icons": "IconToDownload",
@@ -58,6 +60,11 @@ class ThemeManager:
self.theme_sizes_path = THEME_SAVE_PATH / "theme_sizes.json"
# Ensure theme storage layout exists on desktop and device alike.
(THEME_SAVE_PATH / "bootlogos").mkdir(parents=True, exist_ok=True)
(THEME_SAVE_PATH / "theme_packs").mkdir(parents=True, exist_ok=True)
(THEME_SAVE_PATH / "steering_wheels").mkdir(parents=True, exist_ok=True)
self.theme_sizes = load_json_file(self.theme_sizes_path)
self.session = requests.Session()
@@ -98,6 +105,12 @@ class ThemeManager:
steering_wheel_save_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(steering_wheel_image_path, steering_wheel_save_path)
default_boot_logo_path = Path(__file__).parent / "other_images/frogpilot_boot_logo.jpg"
boot_logo_save_path = THEME_SAVE_PATH / "bootlogos/starpilot.jpg"
boot_logo_save_path.parent.mkdir(parents=True, exist_ok=True)
if default_boot_logo_path.exists() and not boot_logo_save_path.exists():
shutil.copy2(default_boot_logo_path, boot_logo_save_path)
def download_theme(self, theme_component, theme_name, asset_param, frogpilot_toggles):
self.downloading_theme = True
@@ -107,56 +120,62 @@ class ThemeManager:
self.downloading_theme = False
return
if theme_component == "distance_icons":
if theme_component == "boot_logos":
download_link = f"{repo_url}/Themes/bootlogo"
download_path = THEME_SAVE_PATH / "bootlogos" / theme_name
extensions = [".png", ".jpg", ".jpeg"]
name_candidates = list(dict.fromkeys([theme_name, theme_name.replace("_", "-"), theme_name.replace("-", "_")]))
elif theme_component == "distance_icons":
download_link = f"{repo_url}/Distance-Icons/{theme_name}"
download_path = THEME_SAVE_PATH / "theme_packs" / theme_name / theme_component
extension = ".zip"
extensions = [".zip"]
name_candidates = [theme_name]
elif theme_component == "steering_wheels":
download_link = f"{repo_url}/Steering-Wheels/{theme_name}"
download_path = THEME_SAVE_PATH / theme_component / theme_name
extension = ".gif"
extensions = [".gif", ".png"]
name_candidates = [theme_name]
else:
download_link = f"{repo_url}/Themes/{theme_name}/{theme_component}"
download_path = THEME_SAVE_PATH / "theme_packs" / theme_name / theme_component
extension = ".zip"
extensions = [".zip"]
name_candidates = [theme_name]
theme_path = download_path.with_suffix(extension)
theme_url = download_link + extension
for extension in extensions:
theme_path = download_path.with_suffix(extension)
theme_urls = [f"{download_link}/{candidate}{extension}" for candidate in name_candidates] if theme_component == "boot_logos" else [download_link + extension]
delete_file(theme_path)
for theme_url in theme_urls:
delete_file(theme_path)
print(f"Downloading theme from GitHub: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, asset_param, self.params_memory, DOWNLOAD_PROGRESS_PARAM, self.session, theme_url)
print(f"Downloading theme from GitHub: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, asset_param, self.params_memory, DOWNLOAD_PROGRESS_PARAM, self.session, theme_url)
if theme_component == "steering_wheels" and not theme_path.exists() and theme_path.with_suffix(".png").exists():
theme_path = theme_path.with_suffix(".png")
extension = ".png"
theme_url = theme_url.replace(".gif", ".png")
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
delete_file(theme_path)
handle_error(None, asset_param, "Download cancelled...", "Download cancelled...", self.params_memory, DOWNLOAD_PROGRESS_PARAM)
if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
delete_file(theme_path)
handle_error(None, asset_param, "Download cancelled...", "Download cancelled...", self.params_memory, DOWNLOAD_PROGRESS_PARAM)
self.downloading_theme = False
return
self.downloading_theme = False
return
if verify_download(theme_path, self.params_memory, self.session, theme_url):
print(f"Theme {theme_name} downloaded and verified successfully from GitHub!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if verify_download(theme_path, self.params_memory, self.session, theme_url):
print(f"Theme {theme_name} downloaded and verified successfully from GitHub!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if extension == ".zip":
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
if extension == ".zip":
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
self.params_memory.remove(asset_param)
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
self.params_memory.remove(asset_param)
self.downloading_theme = False
self.downloading_theme = False
self.update_themes(frogpilot_toggles)
return
self.update_themes(frogpilot_toggles)
return
elif self.handle_verification_failure(extension, theme_component, theme_name, asset_param, theme_path, download_path, frogpilot_toggles):
return
if self.handle_verification_failure(extension, theme_component, theme_name, asset_param, theme_path, download_path, frogpilot_toggles):
return
handle_error(download_path, asset_param, "Download failed...", "Download failed...", self.params_memory, DOWNLOAD_PROGRESS_PARAM)
self.downloading_theme = False
@@ -167,7 +186,7 @@ class ThemeManager:
repo_encoded = quote_plus(RESOURCES_REPO)
assets = {"themes": {}, "wheels": []}
assets = {"boot_logos": [], "themes": {}, "wheels": []}
try:
def list_files(branch):
if is_github:
@@ -243,6 +262,20 @@ class ThemeManager:
theme_name, sub_path = item["path"].split("/", 1)
theme_path = sub_path.lower()
if theme_name.lower() == "bootlogo":
if Path(sub_path).suffix.lower() not in (".png", ".jpg", ".jpeg"):
continue
assets["boot_logos"].append(sub_path)
logo_name = Path(sub_path).stem
local_files = list((THEME_SAVE_PATH / "bootlogos").glob(f"{logo_name}.*"))
if local_files and expected_size > 0:
local_size = self.theme_sizes.get("boot_logos", {}).get(logo_name)
if local_size != expected_size:
print(f"boot logo {logo_name} is outdated, redownloading...")
self.download_theme("boot_logos", logo_name, THEME_COMPONENT_PARAMS["boot_logos"], frogpilot_toggles)
continue
for key in ("colors", "icons", "signals", "sounds"):
if key in theme_path:
assets["themes"].setdefault(theme_name, set()).add(key)
@@ -255,6 +288,7 @@ class ThemeManager:
self.download_theme(key, theme_name, THEME_COMPONENT_PARAMS[key], frogpilot_toggles)
break
assets["boot_logos"].sort()
assets["themes"] = {key: sorted(list(value)) for key, value in assets["themes"].items()}
assets["wheels"].sort()
return assets
@@ -324,42 +358,42 @@ class ThemeManager:
}
def handle_verification_failure(self, extension, theme_component, theme_name, asset_param, theme_path, download_path, frogpilot_toggles):
if theme_component == "distance_icons":
if theme_component == "boot_logos":
download_link = f"{GITLAB_URL}/Themes/bootlogo"
name_candidates = list(dict.fromkeys([theme_name, theme_name.replace("_", "-"), theme_name.replace("-", "_")]))
elif theme_component == "distance_icons":
download_link = f"{GITLAB_URL}/Distance-Icons/{theme_name}"
name_candidates = [theme_name]
elif theme_component == "steering_wheels":
download_link = f"{GITLAB_URL}/Steering-Wheels/{theme_name}"
name_candidates = [theme_name]
else:
download_link = f"{GITLAB_URL}/Themes/{theme_name}/{theme_component}"
name_candidates = [theme_name]
delete_file(theme_path)
for candidate in name_candidates:
delete_file(theme_path)
theme_url = download_link + extension
print(f"Downloading theme from GitLab: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, asset_param, self.params_memory, DOWNLOAD_PROGRESS_PARAM, self.session, theme_url)
theme_url = f"{download_link}/{candidate}{extension}" if theme_component == "boot_logos" else download_link + extension
print(f"Downloading theme from GitLab: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, asset_param, self.params_memory, DOWNLOAD_PROGRESS_PARAM, self.session, theme_url)
if theme_component == "steering_wheels" and not theme_path.exists() and theme_path.with_suffix(".png").exists():
theme_path = theme_path.with_suffix(".png")
extension = ".png"
theme_url = theme_url.replace(".gif", ".png")
if verify_download(theme_path, self.params_memory, self.session, theme_url):
print(f"Theme {theme_name} downloaded and verified successfully from GitLab!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if verify_download(theme_path, self.params_memory, self.session, theme_url):
print(f"Theme {theme_name} downloaded and verified successfully from GitLab!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if extension == ".zip":
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
if extension == ".zip":
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
self.params_memory.remove(asset_param)
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
self.params_memory.remove(asset_param)
self.downloading_theme = False
self.downloading_theme = False
self.update_themes(frogpilot_toggles)
return True
self.update_themes(frogpilot_toggles)
return True
handle_error(None, asset_param, "Download failed...", "Download failed...", self.params_memory, DOWNLOAD_PROGRESS_PARAM)
self.downloading_theme = False
return False
@staticmethod
@@ -423,6 +457,8 @@ class ThemeManager:
return random.choice(candidates) if candidates else "stock"
def update_active_theme(self, time_validated, frogpilot_toggles, boot_run=False, randomize_theme=False):
boot_logo = getattr(frogpilot_toggles, "boot_logo", "starpilot")
if time_validated and frogpilot_toggles.holiday_themes:
self.holiday_theme = self.update_holiday()
else:
@@ -430,6 +466,7 @@ class ThemeManager:
if self.holiday_theme != "stock":
asset_mappings = {
"boot_logo": ("boot_logo", boot_logo),
"color_scheme": ("colors", self.holiday_theme),
"distance_icons": ("distance_icons", self.holiday_theme),
"icon_pack": ("icons", self.holiday_theme),
@@ -446,6 +483,7 @@ class ThemeManager:
selected_theme = self.randomize_theme_asset(available_themes)
asset_mappings = {
"boot_logo": ("boot_logo", boot_logo),
"color_scheme": ("colors", selected_theme.replace("-animated", "")),
"distance_icons": ("distance_icons", self.randomize_distance_icons(available_themes, selected_theme.replace("-animated", ""))),
"icon_pack": ("icons", selected_theme),
@@ -455,6 +493,7 @@ class ThemeManager:
}
elif not frogpilot_toggles.random_themes:
asset_mappings = {
"boot_logo": ("boot_logo", boot_logo),
"color_scheme": ("colors", frogpilot_toggles.color_scheme),
"distance_icons": ("distance_icons", frogpilot_toggles.distance_icons),
"icon_pack": ("icons", frogpilot_toggles.icon_pack),
@@ -469,7 +508,9 @@ class ThemeManager:
for asset, (asset_type, current_value) in asset_mappings.items():
print(f"Updating {asset}: {asset_type} with value {current_value}")
if asset_type == "wheel_image":
if asset_type == "boot_logo":
self.update_boot_logo(current_value)
elif asset_type == "wheel_image":
self.update_wheel_image(current_value, boot_run=boot_run)
else:
self.update_theme_asset(asset_type, current_value, boot_run=boot_run)
@@ -510,18 +551,32 @@ class ThemeManager:
save_location.symlink_to(asset_location, target_is_directory=True)
print(f"Linked {save_location} to {asset_location}")
def update_theme_params(self, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels):
def update_theme_params(self, downloadable_boot_logos, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels):
boot_logos_dir = THEME_SAVE_PATH / "bootlogos"
theme_packs_dir = THEME_SAVE_PATH / "theme_packs"
steering_wheels_dir = THEME_SAVE_PATH / "steering_wheels"
boot_logos_dir.mkdir(parents=True, exist_ok=True)
theme_packs_dir.mkdir(parents=True, exist_ok=True)
steering_wheels_dir.mkdir(parents=True, exist_ok=True)
def update_param(key, assets, subfolder):
if subfolder == "boot_logos":
existing_assets = {item.stem.lower() for item in boot_logos_dir.glob("*") if item.is_file()}
pending_assets = [asset for asset in assets if asset.lower() not in existing_assets]
self.params.put(key, ",".join(sorted(set(pending_assets))))
print(f"{key} updated successfully")
return
if subfolder == "steering_wheels":
themes_path = THEME_SAVE_PATH / subfolder
themes_path = steering_wheels_dir
existing_assets = {self.format_name(item.name, "steering_wheels") for item in themes_path.glob("*") if item.is_file()}
else:
themes_path = THEME_SAVE_PATH / "theme_packs"
themes_path = theme_packs_dir
existing_assets = {self.format_name(item.parent.name, subfolder) for item in themes_path.glob(f"*/{subfolder}") if item.is_dir()}
self.params.put(key, ",".join(sorted(set(assets) - existing_assets)))
print(f"{key} updated successfully")
update_param("DownloadableBootLogos", downloadable_boot_logos, "boot_logos")
update_param("DownloadableColors", downloadable_colors, "colors")
update_param("DownloadableDistanceIcons", downloadable_distance_icons, "distance_icons")
update_param("DownloadableIcons", downloadable_icons, "icons")
@@ -530,7 +585,7 @@ class ThemeManager:
update_param("DownloadableWheels", downloadable_wheels, "steering_wheels")
downloaded_themes = {}
for theme_dir in (THEME_SAVE_PATH / "theme_packs").iterdir():
for theme_dir in theme_packs_dir.iterdir():
components = []
for component in ["colors", "distance_icons", "icons", "signals", "sounds"]:
if (theme_dir / component).is_dir():
@@ -540,12 +595,18 @@ class ThemeManager:
theme_name = self.format_name(theme_dir.name, "theme_packs")
downloaded_themes[theme_name] = sorted(components)
downloaded_boot_logos = []
for boot_logo_file in boot_logos_dir.iterdir():
if boot_logo_file.is_file():
downloaded_boot_logos.append(self.format_name(boot_logo_file.name, "boot_logos"))
downloaded_wheels = []
for wheel_file in (THEME_SAVE_PATH / "steering_wheels").iterdir():
for wheel_file in steering_wheels_dir.iterdir():
if wheel_file.is_file():
downloaded_wheels.append(self.format_name(wheel_file.name, "steering_wheels"))
self.params.put("ThemesDownloaded", {
"boot_logos": sorted(downloaded_boot_logos),
"themes": {key: downloaded_themes[key] for key in sorted(downloaded_themes)},
"steering_wheels": sorted(downloaded_wheels)
})
@@ -553,7 +614,9 @@ class ThemeManager:
print("ThemesDownloaded updated successfully")
def update_theme_size(self, theme_component, theme_name, file_size):
if theme_component == "steering_wheels":
if theme_component == "boot_logos":
key = "boot_logos"
elif theme_component == "steering_wheels":
key = "wheels"
else:
key = "themes"
@@ -561,7 +624,7 @@ class ThemeManager:
if key not in self.theme_sizes:
self.theme_sizes[key] = {}
if key == "wheels":
if key in {"boot_logos", "wheels"}:
self.theme_sizes[key][theme_name] = file_size
else:
if theme_name not in self.theme_sizes[key]:
@@ -583,6 +646,7 @@ class ThemeManager:
if not assets:
return
downloadable_boot_logos = []
downloadable_colors = []
downloadable_distance_icons = []
downloadable_icons = []
@@ -604,8 +668,10 @@ class ThemeManager:
if "sounds" in available_assets:
downloadable_sounds.append(theme_name)
downloadable_boot_logos = [Path(boot_logo).stem for boot_logo in assets["boot_logos"]]
downloadable_wheels = [self.format_name(wheel, "steering_wheels") for wheel in assets["wheels"]]
print(f"Downloadable Boot Logos: {downloadable_boot_logos}")
print(f"Downloadable Colors: {downloadable_colors}")
print(f"Downloadable Icons: {downloadable_icons}")
print(f"Downloadable Signals: {downloadable_signals}")
@@ -614,9 +680,26 @@ class ThemeManager:
print(f"Downloadable Wheels: {downloadable_wheels}")
if boot_run:
self.validate_themes(downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles)
self.validate_themes(downloadable_boot_logos, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles)
self.update_theme_params(downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels)
self.update_theme_params(downloadable_boot_logos, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels)
@staticmethod
def update_boot_logo(image):
default_boot_logo = Path(__file__).parent / "other_images/frogpilot_boot_logo.jpg"
if not default_boot_logo.exists():
return
image_name = image.replace(" ", "_").lower()
source_file = next((file for file in (THEME_SAVE_PATH / "bootlogos").glob("*") if file.is_file() and file.stem.lower() == image_name), default_boot_logo)
if source_file.resolve() == default_boot_logo.resolve():
print(f"Boot logo unchanged: {default_boot_logo}")
return
shutil.copy2(source_file, default_boot_logo)
print(f"Copied {source_file} to {default_boot_logo}")
def update_wheel_image(self, image, boot_run=False, random_event=False):
wheel_save_location = ACTIVE_THEME_PATH / "steering_wheel"
@@ -649,8 +732,26 @@ class ThemeManager:
destination_file.symlink_to(source_file)
print(f"Linked {destination_file} to {source_file}")
def validate_themes(self, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles):
def validate_themes(self, downloadable_boot_logos, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles):
downloaded_data = self.params.get("ThemesDownloaded")
if isinstance(downloaded_data, (bytes, bytearray)):
downloaded_data = downloaded_data.decode("utf-8", "ignore")
if isinstance(downloaded_data, str):
try:
downloaded_data = json.loads(downloaded_data)
except json.JSONDecodeError:
downloaded_data = {}
if not isinstance(downloaded_data, dict):
downloaded_data = {}
boot_logos_path = THEME_SAVE_PATH / "bootlogos"
for display_name in downloaded_data.get("boot_logos", []):
file_stem = display_name.replace(" ", "_").lower()
matching_files = list(boot_logos_path.glob(f"{file_stem}.*"))
if not matching_files:
print(f"Missing boot logo '{display_name}'. Downloading...")
self.download_theme("boot_logos", file_stem, THEME_COMPONENT_PARAMS["boot_logos"], frogpilot_toggles)
self.update_active_theme(True, frogpilot_toggles)
for display_name, components in downloaded_data.get("themes", {}).items():
raw_name = display_name.lower().replace(" ", "_").replace("(", "").replace(")", "")
@@ -672,8 +773,11 @@ class ThemeManager:
self.download_theme("steering_wheels", file_stem, THEME_COMPONENT_PARAMS["steering_wheels"], frogpilot_toggles)
self.update_active_theme(True, frogpilot_toggles)
protected_dirs = {THEME_SAVE_PATH / "bootlogos", THEME_SAVE_PATH / "theme_packs", THEME_SAVE_PATH / "steering_wheels"}
for dir_path in THEME_SAVE_PATH.glob("**/*"):
if dir_path.is_dir() and not any(dir_path.iterdir()):
if dir_path in protected_dirs:
continue
print(f"Deleting empty folder: {dir_path}")
delete_file(dir_path)
elif dir_path.is_file() and dir_path.name.startswith("tmp"):
+2 -1
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.system.hardware.hw import Paths
from openpilot.frogpilot.common.frogpilot_utilities import delete_file
from openpilot.frogpilot.common.frogpilot_variables import EXCLUDED_KEYS, FROGPILOT_BACKUPS, TOGGLE_BACKUPS
@@ -32,7 +33,7 @@ def backup_frogpilot(build_metadata, params):
def backup_toggles(params, boot_run=False):
params_backup = Params("/dev/shm/params_backup", return_defaults=True)
params_backup = Params(f"{Paths.shm_path()}/params_backup", return_defaults=True)
changes_found = False
for key in params.all_keys():
+46 -5
View File
@@ -108,7 +108,7 @@ def install_frogpilot(build_metadata, params):
register_device(build_metadata, params)
update_boot_logo(frogpilot=True)
update_boot_logo(frogpilot=True, selected_logo=params.get("BootLogo"))
if build_metadata.channel == "FrogPilot-Development" and is_FrogsGoMoo():
mount_options = run_cmd(["findmnt", "-n", "-o", "OPTIONS", "/persist"], "Successfully retrieved mount options", "Failed to retrieve mount options")
@@ -119,6 +119,18 @@ def install_frogpilot(build_metadata, params):
def register_device(build_metadata, params):
def register_thread():
dongle_id = params.get("DongleId")
if isinstance(dongle_id, bytes):
dongle_id = dongle_id.decode("utf-8", errors="ignore")
frogpilot_dongle_id = params.get("FrogPilotDongleId")
if isinstance(frogpilot_dongle_id, bytes):
frogpilot_dongle_id = frogpilot_dongle_id.decode("utf-8", errors="ignore")
# Keep a stable local identifier even if the remote registration endpoint
# is unavailable or slow to respond.
if dongle_id and not frogpilot_dongle_id:
params.put("FrogPilotDongleId", dongle_id)
while not is_url_pingable(FROGPILOT_API):
time.sleep(60)
@@ -126,7 +138,7 @@ def register_device(build_metadata, params):
"api_token": params.get("FrogPilotApiToken"),
"build_metadata": dataclasses.asdict(build_metadata),
"device": HARDWARE.get_device_type(),
"dongle_id": params.get("DongleId"),
"dongle_id": dongle_id,
"frogpilot_dongle_id": params.get("FrogPilotDongleId"),
}
@@ -149,11 +161,21 @@ def uninstall_frogpilot():
HARDWARE.uninstall()
def update_boot_logo(frogpilot=False, stock=False):
def update_boot_logo(frogpilot=False, stock=False, selected_logo=None):
if HARDWARE.get_device_type() == "pc":
return
boot_logo_location = Path("/usr/comma/bg.jpg")
if frogpilot:
target_logo = Path(BASEDIR) / "frogpilot/assets/other_images/frogpilot_boot_logo.jpg"
if selected_logo:
selected = selected_logo.decode("utf-8", "ignore") if isinstance(selected_logo, (bytes, bytearray)) else str(selected_logo)
selected = selected.strip().lower().replace(" ", "_")
if selected and selected not in {"stock", "default"}:
candidates = list((THEME_SAVE_PATH / "bootlogos").glob(f"{selected}.*"))
if candidates:
target_logo = candidates[0]
elif stock:
target_logo = Path(BASEDIR) / "frogpilot/assets/other_images/stock_bg.jpg"
else:
@@ -164,10 +186,29 @@ def update_boot_logo(frogpilot=False, stock=False):
print(f"Error: Target logo file not found at {target_logo}")
return
if boot_logo_location.read_bytes() != target_logo.read_bytes():
source_logo = target_logo
staged_logo = Path("/tmp/frogpilot_boot_logo.jpg")
try:
from PIL import Image
with Image.open(target_logo) as img:
# weston.service always writes a JPEG copy of /usr/comma/bg.jpg; make sure
# the source image is already RGB JPEG to avoid startup failure on RGBA assets.
if img.format != "JPEG" or img.mode != "RGB":
img.convert("RGB").save(staged_logo, format="JPEG", quality=95)
source_logo = staged_logo
except Exception as error:
print(f"Error normalizing boot logo {target_logo}: {error}")
if target_logo.suffix.lower() not in {".jpg", ".jpeg"}:
print("Skipping boot logo update to keep weston startup stable.")
return
current_logo = boot_logo_location.read_bytes() if boot_logo_location.is_file() else b""
desired_logo = source_logo.read_bytes()
if current_logo != desired_logo:
mount_options = run_cmd(["findmnt", "-n", "-o", "OPTIONS", "/"], "Successfully retrieved mount options", "Failed to retrieve mount options")
run_cmd(["sudo", "mount", "-o", "remount,rw", "/"], "Successfully remounted / as read-write", "Failed to remount /")
run_cmd(["sudo", "cp", target_logo, boot_logo_location], "Successfully replaced boot logo", "Failed to replace boot logo")
run_cmd(["sudo", "cp", source_logo, boot_logo_location], "Successfully replaced boot logo", "Failed to replace boot logo")
run_cmd(["sudo", "mount", "-o", f"remount,{mount_options}", "/"], "Successfully restored / mount options", "Failed to restore / mount options")
+43 -11
View File
@@ -5,6 +5,7 @@ import math
import numpy as np
import os
import requests
import shutil
import subprocess
import threading
import time
@@ -13,8 +14,6 @@ import zipfile
from functools import cache
from pathlib import Path
import openpilot.system.sentry as sentry
from cereal import log, messaging
from opendbc.can.parser import CANParser
from opendbc.car.toyota.carcontroller import LOCK_CMD
@@ -22,10 +21,19 @@ from openpilot.common.params import Params
from openpilot.common.realtime import DT_DMON, DT_HW
from openpilot.system.hardware import HARDWARE
from openpilot.system.version import get_build_metadata
from panda import Panda
from panda import Panda, FW_PATH
from openpilot.frogpilot.common.frogpilot_variables import EARTH_RADIUS, FROGPILOT_API, FROGS_GO_MOO_PATH, KONIK_PATH
def capture_exception(exception):
try:
import openpilot.system.sentry as sentry
sentry.capture_exception(exception)
except Exception:
pass
class ThreadManager:
def __init__(self):
self.thread_lock = threading.Lock()
@@ -52,7 +60,7 @@ class ThreadManager:
except Exception as exception:
print(f"Error in thread '{name}': {exception}")
if report:
sentry.capture_exception(exception)
capture_exception(exception)
thread = threading.Thread(args=args, daemon=True, target=wrapped_target)
thread.start()
@@ -141,9 +149,17 @@ def contains_event_type(events, frogpilot_events, *event_types):
def delete_file(path, print_error=True, report=True):
path = Path(path)
if path.is_file() or path.is_symlink():
run_cmd(["sudo", "rm", "-f", str(path)], f"Deleted file: {path}", f"Failed to delete file: {path}", report=report)
try:
path.unlink(missing_ok=True)
print(f"Deleted file: {path}")
except Exception:
run_cmd(["sudo", "rm", "-f", str(path)], f"Deleted file: {path}", f"Failed to delete file: {path}", report=report)
elif path.is_dir():
run_cmd(["sudo", "rm", "-rf", str(path)], f"Deleted directory: {path}", f"Failed to delete directory: {path}", report=report)
try:
shutil.rmtree(path)
print(f"Deleted directory: {path}")
except Exception:
run_cmd(["sudo", "rm", "-rf", str(path)], f"Deleted directory: {path}", f"Failed to delete directory: {path}", report=report)
elif print_error:
print(f"File not found: {path}")
@@ -158,14 +174,29 @@ def extract_zip(zip_file, extract_path):
def flash_panda(params_memory):
params = Params()
try:
remote_start = params.get_bool("RemoteStartBootsComma")
except Exception:
remote_start = False
for serial in Panda.list():
try:
with Panda(serial=serial) as panda:
print(f"Flashing Panda {serial}")
panda.flash()
flash_fn = None
if remote_start:
app_fn = panda.get_mcu_type().config.app_fn
remote_fn = "panda_h7_remote.bin.signed" if app_fn == "panda_h7.bin.signed" else "panda_remote.bin.signed"
candidate = os.path.join(FW_PATH, remote_fn)
if os.path.isfile(candidate):
flash_fn = candidate
else:
print(f"Remote-start panda firmware missing: {candidate}. Falling back to default firmware.")
panda.flash(fn=flash_fn)
except Exception as exception:
print(f"Failed to flash Panda {serial}: {exception}")
sentry.capture_exception(exception)
capture_exception(exception)
params_memory.remove("FlashPanda")
@@ -287,13 +318,13 @@ def run_cmd(cmd, success_message, fail_message, env=None, report=True):
print(f"Command failed with error: {exception.stderr}")
print(fail_message)
if report:
sentry.capture_exception(exception.stderr)
capture_exception(exception.stderr)
return None
except Exception as exception:
print(f"Unexpected error occurred: {exception}")
print(fail_message)
if report:
sentry.capture_exception(exception)
capture_exception(exception)
return None
@@ -314,7 +345,8 @@ def update_json_file(path, data):
@cache
def use_konik_server():
return KONIK_PATH.is_file()
# Prefer the persistent toggle over volatile cache files.
return Params().get_bool("UseKonikServer")
def wait_for_no_driver(params, sm, door_checks=False, time_threshold=60):
+175 -31
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import json
import math
import os
import random
import tomllib
@@ -13,8 +14,8 @@ import cereal.messaging as messaging
from cereal import car, custom, log
from opendbc.car import gen_empty_fingerprint
from opendbc.car.car_helpers import interfaces
from opendbc.car.gm.values import GMFlags
from opendbc.car.hyundai.values import HyundaiFlags
from opendbc.car.gm.values import CAR as GM_CAR, EV_CAR as GM_EV_CAR, GMFlags
from opendbc.car.hyundai.values import EV_CAR as HYUNDAI_EV_CAR, HyundaiFlags
from opendbc.car.interfaces import TORQUE_SUBSTITUTE_PATH, CarInterfaceBase, GearShifter
from opendbc.car.mock.values import CAR as MOCK
from opendbc.car.subaru.values import SubaruFlags
@@ -25,6 +26,7 @@ from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.latcontrol_torque import KP
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.system.hardware import HARDWARE
from openpilot.system.hardware.hw import Paths
from openpilot.system.hardware.power_monitoring import VBATT_PAUSE_CHARGING
from openpilot.system.version import get_build_metadata
@@ -43,33 +45,47 @@ NON_DRIVING_GEARS = [GearShifter.neutral, GearShifter.park, GearShifter.reverse,
FROGPILOT_API = "https://frogpilot.com/api"
RESOURCES_REPO = "FrogAi/FrogPilot-Resources"
LEGACY_CARMODEL_MIGRATIONS = {
"CHEVROLET_BOLT_CC_2019_2021": "CHEVROLET_BOLT_CC_2018_2021",
}
RESOURCES_REPO = os.getenv("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources")
ACTIVE_THEME_PATH = Path(BASEDIR) / "frogpilot/assets/active_theme"
METADATAS_PATH = Path(BASEDIR) / "frogpilot/assets/model_metadata"
MODELS_PATH = Path("/data/models")
RANDOM_EVENTS_PATH = Path(BASEDIR) / "frogpilot/assets/random_events"
STOCK_THEME_PATH = Path(BASEDIR) / "frogpilot/assets/stock_theme"
THEME_COLORS_PATH = (ACTIVE_THEME_PATH / "colors/colors.json")
THEME_SAVE_PATH = Path("/data/themes")
if HARDWARE.get_device_type() == "pc":
_FP_PC_ROOT = Path(Paths.comma_home()) / "frogpilot"
_FP_DATA_ROOT = _FP_PC_ROOT / "data"
_FP_CACHE_ROOT = _FP_PC_ROOT / "cache"
_FP_PERSIST_ROOT = Path(Paths.persist_root())
else:
_FP_DATA_ROOT = Path("/data")
_FP_CACHE_ROOT = Path("/cache")
_FP_PERSIST_ROOT = Path("/persist")
ERROR_LOGS_PATH = Path("/data/error_logs")
SCREEN_RECORDINGS_PATH = Path("/data/media/screen_recordings")
VIDEO_CACHE_PATH = Path("/data/video_cache")
MODELS_PATH = _FP_DATA_ROOT / "models"
THEME_SAVE_PATH = _FP_DATA_ROOT / "themes"
BACKUP_PATH = Path("/cache/on_backup")
FROGPILOT_BACKUPS = Path("/data/backups")
TOGGLE_BACKUPS = Path("/data/toggle_backups")
ERROR_LOGS_PATH = _FP_DATA_ROOT / "error_logs"
SCREEN_RECORDINGS_PATH = _FP_DATA_ROOT / "media/screen_recordings"
VIDEO_CACHE_PATH = _FP_DATA_ROOT / "video_cache"
FROGS_GO_MOO_PATH = Path("/persist/frogsgomoo.py")
BACKUP_PATH = _FP_CACHE_ROOT / "on_backup"
FROGPILOT_BACKUPS = _FP_DATA_ROOT / "backups"
TOGGLE_BACKUPS = _FP_DATA_ROOT / "toggle_backups"
HD_LOGS_PATH = Path("/data/media/0/realdata_HD")
HD_PATH = Path("/cache/use_HD")
FROGS_GO_MOO_PATH = _FP_PERSIST_ROOT / "frogsgomoo.py"
KONIK_LOGS_PATH = Path("/data/media/0/realdata_konik")
KONIK_PATH = Path("/cache/use_konik")
HD_LOGS_PATH = _FP_DATA_ROOT / "media/0/realdata_HD"
HD_PATH = _FP_CACHE_ROOT / "use_HD"
MAPS_PATH = Path("/data/media/0/osm/offline")
KONIK_LOGS_PATH = _FP_DATA_ROOT / "media/0/realdata_konik"
KONIK_PATH = _FP_CACHE_ROOT / "use_konik"
MAPS_PATH = _FP_DATA_ROOT / "media/0/osm/offline"
NNFF_MODELS_PATH = Path(BASEDIR) / "frogpilot/assets/nnff_models"
@@ -140,12 +156,14 @@ DEVICE_SHUTDOWN_TIMES = {
}
EXCLUDED_KEYS = {
"AvailableModelSeries",
"AvailableModelNames",
"AvailableModels",
"CalibratedLateralAcceleration",
"CalibrationProgress",
"CarBatteryCapacity",
"CarParamsPersistent",
"CommunityFavorites",
"CurvatureData",
"ExperimentalLongitudinalEnabled",
"FrogPilotCarParamsPersistent",
@@ -153,6 +171,9 @@ EXCLUDED_KEYS = {
"LastUpdateTime",
"MapBoxRequests",
"ModelDrivesAndScores",
"ModelReleasedDates",
"ModelSortMode",
"ModelVersions",
"openpilotMinutes",
"OverpassRequests",
"PandaSignatures",
@@ -164,6 +185,7 @@ EXCLUDED_KEYS = {
"UpdaterCurrentReleaseNotes",
"UpdaterFetchAvailable",
"UpdaterTargetBranch",
"UserFavorites",
"UptimeOffroad"
}
@@ -174,6 +196,10 @@ TUNING_LEVELS = {
"DEVELOPER": 3
}
# Shared params handles for modules that import these from frogpilot_variables.
params = Params(return_defaults=True)
params_memory = Params(memory=True)
@cache
def get_nnff_model_files():
return [file.stem for file in NNFF_MODELS_PATH.iterdir() if file.is_file()]
@@ -200,8 +226,27 @@ def nnff_supported(car_fingerprint):
return False
def normalize_legacy_car_model(car_model):
if car_model is None:
return None
if isinstance(car_model, bytes):
car_model = car_model.decode("utf-8", errors="ignore")
car_model = str(car_model)
normalized = LEGACY_CARMODEL_MIGRATIONS.get(car_model, car_model)
return normalized
def get_frogpilot_toggles(sm=messaging.SubMaster(["frogpilotPlan"])):
return process_frogpilot_toggles(sm["frogpilotPlan"].frogpilotToggles)
toggles = process_frogpilot_toggles(sm["frogpilotPlan"].frogpilotToggles)
# Force drive-state controls must be authoritative from params so they
# apply immediately even if frogpilotPlan publication is temporarily stale.
if not hasattr(get_frogpilot_toggles, "_params"):
get_frogpilot_toggles._params = Params(return_defaults=True)
toggles.force_offroad = get_frogpilot_toggles._params.get_bool("ForceOffroad")
toggles.force_onroad = get_frogpilot_toggles._params.get_bool("ForceOnroad")
return toggles
@cache
def process_frogpilot_toggles(toggles):
@@ -247,6 +292,7 @@ class FrogPilotVariables:
toggle.use_higher_bitrate &= not self.vetting_branch
toggle.use_higher_bitrate |= self.development_branch
HD_PATH.parent.mkdir(parents=True, exist_ok=True)
if not HD_PATH.is_file() and toggle.use_higher_bitrate:
HD_PATH.touch()
HARDWARE.reboot()
@@ -256,8 +302,8 @@ class FrogPilotVariables:
toggle.use_konik_server = device_management
toggle.use_konik_server &= self.get_value("UseKonikServer")
toggle.use_konik_server |= Path("/data/openpilot/not_vetted").is_file()
KONIK_PATH.parent.mkdir(parents=True, exist_ok=True)
if not KONIK_PATH.is_file() and toggle.use_konik_server:
KONIK_PATH.touch()
HARDWARE.reboot()
@@ -307,27 +353,50 @@ class FrogPilotVariables:
return value
def _sync_stock_param(self, key, stock_key, live_value):
try:
live_value = float(live_value)
except (TypeError, ValueError):
return
if math.isclose(live_value, 0.0, abs_tol=1e-6):
return
current_stock = self.params.get_float(stock_key)
if math.isclose(current_stock, live_value, abs_tol=1e-6):
return
current_value = self.params.get_float(key)
if math.isclose(current_value, current_stock, abs_tol=1e-6) or math.isclose(current_stock, 0.0, abs_tol=1e-6):
self.params.put_float(key, live_value)
self.params.put_float(stock_key, live_value)
def update(self, holiday_theme="stock", started=False):
toggle = self.frogpilot_toggles
toggle.tuning_level = self.params.get("TuningLevel") if self.params.get_bool("TuningLevelConfirmed") else TUNING_LEVELS["ADVANCED"]
fallback_platform = GM_CAR.CHEVROLET_BOLT_ACC_2022_2023 if HARDWARE.get_device_type() == "pc" else MOCK.MOCK
msg_bytes = self.params.get("CarParams" if started else "CarParamsPersistent", block=started)
if msg_bytes:
CP = messaging.log_from_bytes(msg_bytes, car.CarParams)
car_platform = CP.carFingerprint if CP.carFingerprint in interfaces else fallback_platform
else:
CP = interfaces[MOCK.MOCK].get_params(MOCK.MOCK, gen_empty_fingerprint(), [], False, False, False, toggle).as_reader()
car_platform = fallback_platform
CP = interfaces[car_platform].get_params(car_platform, gen_empty_fingerprint(), [], False, False, False, toggle).as_reader()
is_torque_car = CP.lateralTuning.which() == "torque"
if not is_torque_car:
CP_builder = CP.as_builder()
CarInterfaceBase.configure_torque_tune(MOCK.MOCK, CP_builder.lateralTuning)
CarInterfaceBase.configure_torque_tune(car_platform, CP_builder.lateralTuning)
CP = CP_builder.as_reader()
fpmsg_bytes = self.params.get("FrogPilotCarParams" if started else "FrogPilotCarParamsPersistent", block=started)
if fpmsg_bytes:
FPCP = messaging.log_from_bytes(fpmsg_bytes, custom.FrogPilotCarParams)
else:
FPCP = interfaces[MOCK.MOCK].get_frogpilot_params(MOCK.MOCK, gen_empty_fingerprint(), [], CP, toggle)
FPCP = interfaces[car_platform].get_frogpilot_params(car_platform, gen_empty_fingerprint(), [], CP, toggle)
alpha_longitudinal = CP.alphaLongitudinalAvailable
toggle.car_make = CP.brand
@@ -358,6 +427,20 @@ class FrogPilotVariables:
toggle.vEgoStarting = CP.vEgoStarting
toggle.vEgoStopping = CP.vEgoStopping
# Keep stock tuning params synchronized for all device UIs (Qt + raylib).
# Historically this only ran in Qt settings, which left C4 defaults at 0.
self._sync_stock_param("SteerDelay", "SteerDelayStock", steerActuatorDelay)
self._sync_stock_param("SteerFriction", "SteerFrictionStock", friction)
self._sync_stock_param("SteerKP", "SteerKPStock", steerKp)
self._sync_stock_param("SteerLatAccel", "SteerLatAccelStock", latAccelFactor)
self._sync_stock_param("LongitudinalActuatorDelay", "LongitudinalActuatorDelayStock", longitudinalActuatorDelay)
self._sync_stock_param("StartAccel", "StartAccelStock", startAccel)
self._sync_stock_param("SteerRatio", "SteerRatioStock", steerRatio)
self._sync_stock_param("StopAccel", "StopAccelStock", stopAccel)
self._sync_stock_param("StoppingDecelRate", "StoppingDecelRateStock", toggle.stoppingDecelRate)
self._sync_stock_param("VEgoStarting", "VEgoStartingStock", toggle.vEgoStarting)
self._sync_stock_param("VEgoStopping", "VEgoStoppingStock", toggle.vEgoStopping)
msg_bytes = self.params.get("LiveTorqueParameters")
if msg_bytes:
LTP = messaging.log_from_bytes(msg_bytes, log.LiveTorqueParametersData)
@@ -398,6 +481,28 @@ class FrogPilotVariables:
toggle.use_custom_steerRatio = bool(round(toggle.steerRatio, 2) != round(steerRatio, 2)) and not toggle.force_auto_tune or toggle.force_auto_tune_off
advanced_longitudinal_tuning = toggle.openpilot_longitudinal and self.get_value("AdvancedLongitudinalTune")
gm_ev_vehicle = toggle.car_make == "gm" and CP.carFingerprint in GM_EV_CAR
gm_ev_vehicle &= not (toggle.car_model.startswith("CHEVROLET_VOLT") and not toggle.car_model.endswith("_CC"))
gm_ev_vehicle &= toggle.car_model != "CHEVROLET_MALIBU_HYBRID_CC"
ev_vehicle = gm_ev_vehicle or (toggle.car_make == "hyundai" and CP.carFingerprint in HYUNDAI_EV_CAR)
ev_vehicle |= CP.transmissionType == car.CarParams.TransmissionType.direct
if self.params.get("EVTuning") == b"":
self.params.put_bool("EVTuning", ev_vehicle)
if self.params.get("TruckTuning") == b"":
self.params.put_bool("TruckTuning", False)
ev_tuning_param = self.params.get_bool("EVTuning")
truck_tuning_param = self.params.get_bool("TruckTuning")
# EV and truck tuning are mutually exclusive.
if truck_tuning_param and ev_tuning_param:
ev_tuning_param = False
self.params.put_bool("EVTuning", False)
toggle.ev_tuning = ev_tuning_param if advanced_longitudinal_tuning else ev_vehicle
toggle.truck_tuning = truck_tuning_param if advanced_longitudinal_tuning else False
toggle.longitudinalActuatorDelay = self.get_value("LongitudinalActuatorDelay", cast=float, condition=advanced_longitudinal_tuning, default=longitudinalActuatorDelay, min=0, max=1)
toggle.max_desired_acceleration = self.get_value("MaxDesiredAcceleration", cast=float, condition=advanced_longitudinal_tuning, min=0.1, max=MAX_ACCELERATION)
toggle.startAccel = self.get_value("StartAccel", cast=float, condition=advanced_longitudinal_tuning, default=startAccel, min=0, max=MAX_ACCELERATION)
@@ -422,7 +527,12 @@ class FrogPilotVariables:
toggle.automatic_updates = self.get_value("AutomaticUpdates", condition=(self.release_branch or self.vetting_branch or self.frogs_go_moo), default=True) and not BACKUP_PATH.is_file()
car_model = self.params.get("CarModel")
car_model = normalize_legacy_car_model(self.params.get("CarModel"))
if car_model != self.params.get("CarModel"):
self.params.put("CarModel", car_model)
car_model_name = self.params.get("CarModelName")
if car_model_name and "2019-21" in car_model_name:
self.params.put("CarModelName", car_model_name.replace("2019-21", "2018-21"))
toggle.force_fingerprint = self.get_value("ForceFingerprint", condition=car_model != self.default_values["CarModel"])
if toggle.force_fingerprint:
toggle.car_model = car_model
@@ -458,27 +568,34 @@ class FrogPilotVariables:
toggle.aggressive_jerk_danger = self.get_value("AggressiveJerkDanger", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.aggressive_jerk_speed = self.get_value("AggressiveJerkSpeed", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.aggressive_jerk_speed_decrease = self.get_value("AggressiveJerkSpeedDecrease", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.aggressive_follow = self.get_value("AggressiveFollow", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW)
aggressive_follow_low = float(self.get_value("AggressiveFollow", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW))
aggressive_follow_high = float(self.get_value("AggressiveFollowHigh", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW))
toggle.aggressive_follow = [aggressive_follow_low, aggressive_follow_high]
toggle.standard_jerk_acceleration = self.get_value("StandardJerkAcceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.standard_jerk_deceleration = self.get_value("StandardJerkDeceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.standard_jerk_danger = self.get_value("StandardJerkDanger", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.standard_jerk_speed = self.get_value("StandardJerkSpeed", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.standard_jerk_speed_decrease = self.get_value("StandardJerkSpeedDecrease", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.standard_follow = self.get_value("StandardFollow", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW)
standard_follow_low = float(self.get_value("StandardFollow", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW))
standard_follow_high = float(self.get_value("StandardFollowHigh", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW))
toggle.standard_follow = [standard_follow_low, standard_follow_high]
toggle.relaxed_jerk_acceleration = self.get_value("RelaxedJerkAcceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.relaxed_jerk_deceleration = self.get_value("RelaxedJerkDeceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.relaxed_jerk_danger = self.get_value("RelaxedJerkDanger", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.relaxed_jerk_speed = self.get_value("RelaxedJerkSpeed", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.relaxed_jerk_speed_decrease = self.get_value("RelaxedJerkSpeedDecrease", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0)
toggle.relaxed_follow = self.get_value("RelaxedFollow", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW)
relaxed_follow_low = float(self.get_value("RelaxedFollow", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW))
relaxed_follow_high = float(self.get_value("RelaxedFollowHigh", cast=float, condition=toggle.custom_personalities, min=1, max=MAX_T_FOLLOW))
toggle.relaxed_follow = [relaxed_follow_low, relaxed_follow_high]
toggle.traffic_mode_jerk_acceleration = [self.get_value("TrafficJerkAcceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0), toggle.aggressive_jerk_acceleration]
toggle.traffic_mode_jerk_deceleration = [self.get_value("TrafficJerkDeceleration", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0), toggle.aggressive_jerk_deceleration]
toggle.traffic_mode_jerk_danger = [self.get_value("TrafficJerkDanger", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0), toggle.aggressive_jerk_danger]
toggle.traffic_mode_jerk_speed = [self.get_value("TrafficJerkSpeed", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0), toggle.aggressive_jerk_speed]
toggle.traffic_mode_jerk_speed_decrease = [self.get_value("TrafficJerkSpeedDecrease", cast=float, condition=toggle.custom_personalities, conversion=0.01, min=0.25, max=2.0), toggle.aggressive_jerk_speed_decrease]
toggle.traffic_mode_follow = [self.get_value("TrafficFollow", cast=float, condition=toggle.custom_personalities, min=0.5, max=MAX_T_FOLLOW), toggle.aggressive_follow]
toggle.traffic_mode_follow = [float(self.get_value("TrafficFollow", cast=float, condition=toggle.custom_personalities, min=0.5, max=MAX_T_FOLLOW)), toggle.aggressive_follow[0]]
custom_themes = self.get_value("CustomThemes")
toggle.boot_logo = self.get_value("BootLogo", cast=None, default="starpilot")
toggle.color_scheme = self.get_value("ColorScheme", cast=None, condition=custom_themes, default="stock")
theme_colors = json.loads(THEME_COLORS_PATH.read_text()) if THEME_COLORS_PATH.is_file() else {}
toggle.lane_lines_color = self.get_color("LaneLines", theme_colors)
@@ -545,7 +662,9 @@ class FrogPilotVariables:
toggle.device_shutdown_time = DEVICE_SHUTDOWN_TIMES.get(self.get_value("DeviceShutdown", cast=int, condition=device_management))
toggle.increase_thermal_limits = self.get_value("IncreaseThermalLimits", condition=device_management)
toggle.low_voltage_shutdown = self.get_value("LowVoltageShutdown", cast=float, condition=device_management, min=VBATT_PAUSE_CHARGING, max=12.5)
toggle.no_logging = self.get_value("NoLogging", condition=device_management and not self.vetting_branch) or toggle.force_onroad
# Keep force-onroad desktop simulations from polluting logs, but never disable
# loggerd/encoderd on real devices because that breaks route continuity/uploads.
toggle.no_logging = self.get_value("NoLogging", condition=device_management and not self.vetting_branch) or (toggle.force_onroad and HARDWARE.get_device_type() == "pc")
toggle.no_uploads = self.get_value("NoUploads", condition=device_management and not self.vetting_branch)
toggle.no_onroad_uploads = self.get_value("DisableOnroadUploads", condition=toggle.no_uploads)
@@ -622,11 +741,26 @@ class FrogPilotVariables:
toggle.human_following = self.get_value("HumanFollowing", condition=longitudinal_tuning)
toggle.human_lane_changes = has_radar and self.get_value("HumanLaneChanges", condition=longitudinal_tuning)
toggle.lead_detection_probability = self.get_value("LeadDetectionThreshold", cast=float, condition=longitudinal_tuning, conversion=0.01, min=0.25, max=0.5)
toggle.recovery_power = self.get_value("RecoveryPower", cast=float, condition=longitudinal_tuning, default=1.0, min=0.5, max=2.0)
toggle.stop_distance = self.get_value("StopDistance", cast=float, condition=longitudinal_tuning, default=6.0)
toggle.taco_tune = self.get_value("TacoTune", condition=longitudinal_tuning)
toggle.model = self.default_values["DrivingModel"]
toggle.model_name = self.default_values["DrivingModelName"]
toggle.model_version = self.default_values["DrivingModelVersion"]
toggle.model = self.get_value("Model", cast=None, default="sc")
if not toggle.model:
toggle.model = self.get_value("DrivingModel", cast=None, default="sc")
toggle.model_name = self.get_value("DrivingModelName", cast=None, default="South Carolina")
toggle.model_version = self.get_value("ModelVersion", cast=None, default="v11")
if not toggle.model_version:
toggle.model_version = self.get_value("DrivingModelVersion", cast=None, default="v11")
if isinstance(toggle.model, bytes):
toggle.model = toggle.model.decode("utf-8", "ignore")
if isinstance(toggle.model_name, bytes):
toggle.model_name = toggle.model_name.decode("utf-8", "ignore")
if isinstance(toggle.model_version, bytes):
toggle.model_version = toggle.model_version.decode("utf-8", "ignore")
toggle.classic_model = toggle.model_version in {"v1", "v2", "v3", "v4"}
toggle.tinygrad_model = toggle.model_version in {"v8", "v9", "v10", "v11", "v12"}
toggle.tomb_raider = toggle.model == "space-lab"
toggle.model_ui = self.get_value("ModelUI")
toggle.dynamic_path_width = self.get_value("DynamicPathWidth", condition=toggle.model_ui and not toggle.debug_mode)
@@ -734,6 +868,16 @@ class FrogPilotVariables:
toggle.lock_doors = self.get_value("LockDoors", condition=toyota_doors)
toggle.unlock_doors = self.get_value("UnlockDoors", condition=toyota_doors)
toggle.gm_pedal_longitudinal = self.get_value(
"GMPedalLongitudinal",
condition=toggle.car_make == "gm" and toggle.has_pedal,
)
toggle.remote_start_boots_comma = self.get_value("RemoteStartBootsComma", condition=toggle.car_make == "gm")
toggle.remap_cancel_to_distance = self.get_value(
"RemapCancelToDistance",
condition=toggle.car_make == "gm" and toggle.has_pedal and "BOLT" in toggle.car_model,
)
toggle.volt_sng = self.get_value("VoltSNG", condition=toggle.car_model == "CHEVROLET_VOLT")
process_frogpilot_toggles.cache_clear()
+233
View File
@@ -0,0 +1,233 @@
import json
import threading
import time
from pathlib import Path
from openpilot.system.hardware import PC
TESTING_GROUNDS_SCHEMA_VERSION = 1
TESTING_GROUND_1 = "1"
TESTING_GROUND_2 = "2"
TESTING_GROUND_3 = "3"
TESTING_GROUND_4 = "4"
TESTING_GROUND_5 = "5"
TESTING_GROUND_6 = "6"
TESTING_GROUND_7 = "7"
TESTING_GROUND_IDS = (
TESTING_GROUND_1,
TESTING_GROUND_2,
TESTING_GROUND_3,
TESTING_GROUND_4,
TESTING_GROUND_5,
TESTING_GROUND_6,
TESTING_GROUND_7,
)
TESTING_GROUNDS_STATE_PATH = Path("/tmp/the_pond_testing_grounds_slots.json") if PC else Path("/data/testing_grounds/slots.json")
# Edit slot names/descriptions once here. The Pond and runtime checks share this table.
# Slots named "Unused" are hidden from the dropdown.
# Adding cLabel/dLabel/etc. automatically adds more mode buttons for that slot in Testing Ground.
TESTING_GROUNDS_SLOT_DEFINITIONS = (
{
"id": TESTING_GROUND_1,
"name": "GM Long Tune",
"description": "BoltLongTune A/B sandbox for GM ACC longitudinal testing.",
"aLabel": "A - Installed tune",
"bLabel": "B - BoltLongTune test",
},
{
"id": TESTING_GROUND_2,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_3,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_4,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_5,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_6,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_7,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
)
_DEFAULT_ACTIVE_SLOT = TESTING_GROUND_1
DEFAULT_TESTING_GROUND_VARIANT = "A"
TESTING_GROUND_TEST_VARIANT = "B"
_CACHE_LOCK = threading.Lock()
_CACHE_LAST_REFRESH = 0.0
_CACHE_LAST_MTIME_NS = -1
_CACHE_ACTIVE_SLOT = _DEFAULT_ACTIVE_SLOT
_CACHE_ACTIVE_VARIANT = DEFAULT_TESTING_GROUND_VARIANT
def _extract_variant_labels(slot_definition):
labels = {}
for key, value in dict(slot_definition).items():
if not isinstance(key, str) or not key.endswith("Label"):
continue
variant = key[:-5].upper()
if len(variant) != 1 or not variant.isalpha():
continue
label = str(value or "").strip()
if label:
labels[variant] = label
if DEFAULT_TESTING_GROUND_VARIANT not in labels:
labels[DEFAULT_TESTING_GROUND_VARIANT] = DEFAULT_TESTING_GROUND_VARIANT
return dict(sorted(labels.items()))
TESTING_GROUND_VARIANT_LABELS = {
str(slot.get("id") or "").strip(): _extract_variant_labels(slot)
for slot in TESTING_GROUNDS_SLOT_DEFINITIONS
}
TESTING_GROUND_VARIANTS = tuple(
sorted({
variant
for labels in TESTING_GROUND_VARIANT_LABELS.values()
for variant in labels.keys()
})
)
def _normalize_variant(value, slot_id=None):
normalized_slot_id = str(slot_id or "").strip()
allowed_variants = set(TESTING_GROUND_VARIANT_LABELS.get(normalized_slot_id, {}).keys())
if not allowed_variants:
allowed_variants = set(TESTING_GROUND_VARIANTS) or {DEFAULT_TESTING_GROUND_VARIANT}
variant = str(value or "").strip().upper()
return variant if variant in allowed_variants else DEFAULT_TESTING_GROUND_VARIANT
def get_testing_ground_selection(refresh_interval_s=0.5):
global _CACHE_LAST_REFRESH, _CACHE_LAST_MTIME_NS, _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
now = time.monotonic()
with _CACHE_LOCK:
if (now - _CACHE_LAST_REFRESH) < refresh_interval_s:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
_CACHE_LAST_REFRESH = now
try:
stat_result = TESTING_GROUNDS_STATE_PATH.stat()
except FileNotFoundError:
_CACHE_LAST_MTIME_NS = -1
_CACHE_ACTIVE_SLOT = _DEFAULT_ACTIVE_SLOT
_CACHE_ACTIVE_VARIANT = DEFAULT_TESTING_GROUND_VARIANT
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
except OSError:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
if stat_result.st_mtime_ns == _CACHE_LAST_MTIME_NS:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
try:
payload = json.loads(TESTING_GROUNDS_STATE_PATH.read_text(encoding="utf-8"))
except Exception:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
if not isinstance(payload, dict):
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
slot_id = str(payload.get("activeSlot") or _DEFAULT_ACTIVE_SLOT).strip()
if slot_id not in TESTING_GROUND_IDS:
slot_id = _DEFAULT_ACTIVE_SLOT
_CACHE_ACTIVE_SLOT = slot_id
_CACHE_ACTIVE_VARIANT = _normalize_variant(payload.get("activeVariant"), slot_id)
_CACHE_LAST_MTIME_NS = stat_result.st_mtime_ns
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
def is_testing_ground_active(slot_id, variant=TESTING_GROUND_TEST_VARIANT, refresh_interval_s=0.5):
active_slot, active_variant = get_testing_ground_selection(refresh_interval_s=refresh_interval_s)
normalized_slot_id = str(slot_id or "").strip()
target_variant = _normalize_variant(variant, normalized_slot_id)
return normalized_slot_id == active_slot and active_variant == target_variant
class _TestingGroundFlag:
__slots__ = ("slot_id", "variant")
def __init__(self, slot_id, variant=TESTING_GROUND_TEST_VARIANT):
self.slot_id = str(slot_id or "").strip()
self.variant = _normalize_variant(variant, self.slot_id)
def __bool__(self):
return is_testing_ground_active(self.slot_id, self.variant)
def __repr__(self):
state = "active" if bool(self) else "inactive"
return f"<TestingGroundFlag slot={self.slot_id} variant={self.variant} {state}>"
use_testing_ground_1 = _TestingGroundFlag(TESTING_GROUND_1)
use_testing_ground_2 = _TestingGroundFlag(TESTING_GROUND_2)
use_testing_ground_3 = _TestingGroundFlag(TESTING_GROUND_3)
use_testing_ground_4 = _TestingGroundFlag(TESTING_GROUND_4)
use_testing_ground_5 = _TestingGroundFlag(TESTING_GROUND_5)
use_testing_ground_6 = _TestingGroundFlag(TESTING_GROUND_6)
use_testing_ground_7 = _TestingGroundFlag(TESTING_GROUND_7)
class _TestingGroundNamespace:
__slots__ = ()
id_1 = TESTING_GROUND_1
id_2 = TESTING_GROUND_2
id_3 = TESTING_GROUND_3
id_4 = TESTING_GROUND_4
id_5 = TESTING_GROUND_5
id_6 = TESTING_GROUND_6
id_7 = TESTING_GROUND_7
use_1 = use_testing_ground_1
use_2 = use_testing_ground_2
use_3 = use_testing_ground_3
use_4 = use_testing_ground_4
use_5 = use_testing_ground_5
use_6 = use_testing_ground_6
use_7 = use_testing_ground_7
def use(self, slot_id, variant=TESTING_GROUND_TEST_VARIANT):
return is_testing_ground_active(slot_id, variant)
def selection(self):
return get_testing_ground_selection()
testing_ground = _TestingGroundNamespace()
+2 -2
View File
@@ -48,14 +48,14 @@ class FrogPilotCard:
def handle_experimental_mode(self, sm, frogpilot_toggles):
if frogpilot_toggles.conditional_experimental_mode:
if self.params_memory.get("CEStatus") in (CEStatus["USER_DISABLED"], CEStatus["USER_OVERRIDDEN"]):
if self.params_memory.get_int("CEStatus") in (CEStatus["USER_DISABLED"], CEStatus["USER_OVERRIDDEN"]):
override_value = CEStatus["OFF"]
elif sm["selfdriveState"].experimentalMode:
override_value = CEStatus["USER_DISABLED"]
else:
override_value = CEStatus["USER_OVERRIDDEN"]
self.params_memory.put("CEStatus", override_value)
self.params_memory.put_int("CEStatus", override_value)
else:
self.params.put_bool_nonblocking("ExperimentalMode", not sm["selfdriveState"].experimentalMode)
+12 -8
View File
@@ -61,17 +61,19 @@ class FrogPilotPlanner:
self.lead_one = sm["radarState"].leadOne
long_control_active = sm["carControl"].longActive
controls_enabled = sm["selfdriveState"].enabled
planner_active = controls_enabled or long_control_active
v_cruise = min(sm["carState"].vCruise, V_CRUISE_MAX) * CV.KPH_TO_MS
v_ego = max(sm["carState"].vEgo, 0)
if long_control_active:
if planner_active:
self.frogpilot_acceleration.update(v_ego, sm, frogpilot_toggles)
else:
self.frogpilot_acceleration.max_accel = 0
self.frogpilot_acceleration.min_accel = 0
if long_control_active and frogpilot_toggles.conditional_experimental_mode:
if planner_active and frogpilot_toggles.conditional_experimental_mode:
self.frogpilot_cem.update(v_ego, sm, frogpilot_toggles)
else:
self.frogpilot_cem.experimental_mode = False
@@ -79,9 +81,9 @@ class FrogPilotPlanner:
self.driving_in_curve = abs(self.lateral_acceleration) >= MINIMUM_LATERAL_ACCELERATION
self.frogpilot_events.update(long_control_active, v_cruise, sm, frogpilot_toggles)
self.frogpilot_events.update(planner_active, v_cruise, sm, frogpilot_toggles)
self.frogpilot_following.update(long_control_active, v_ego, sm, frogpilot_toggles)
self.frogpilot_following.update(planner_active, v_ego, sm, frogpilot_toggles)
gps_location = sm[self.gps_location_service]
self.gps_position = {
@@ -109,24 +111,25 @@ class FrogPilotPlanner:
self.model_length = sm["modelV2"].position.x[-1]
self.model_stopped = self.model_length < CRUISING_SPEED * PLANNER_TIME
self.model_stopped |= self.frogpilot_vcruise.forcing_stop
self.road_curvature, self.time_to_curve = calculate_road_curvature(sm["modelV2"])
self.road_curvature_detected = (1 / abs(self.road_curvature))**0.5 < v_ego > CRUISING_SPEED and not (sm["carState"].leftBlinker or sm["carState"].rightBlinker)
if not sm["carState"].standstill:
self.tracking_lead = self.update_lead_status()
self.tracking_lead = self.update_lead_status(frogpilot_toggles.stop_distance)
self.v_cruise = self.frogpilot_vcruise.update(long_control_active, now, time_validated, v_cruise, v_ego, sm, frogpilot_toggles)
self.v_cruise = self.frogpilot_vcruise.update(planner_active, now, time_validated, v_cruise, v_ego, sm, frogpilot_toggles)
if self.gps_valid and time_validated and frogpilot_toggles.weather_presets:
self.frogpilot_weather.update_weather(now, frogpilot_toggles)
else:
self.frogpilot_weather.weather_id = 0
def update_lead_status(self):
def update_lead_status(self, stop_distance=STOP_DISTANCE):
following_lead = self.lead_one.status
following_lead &= self.lead_one.dRel < self.model_length + STOP_DISTANCE
following_lead &= self.lead_one.dRel < self.model_length + max(float(stop_distance), 4.0)
self.tracking_lead_filter.update(following_lead)
return self.tracking_lead_filter.x >= THRESHOLD
@@ -147,6 +150,7 @@ class FrogPilotPlanner:
frogpilotPlan.cscTraining = self.frogpilot_vcruise.csc.enable_training
frogpilotPlan.desiredFollowDistance = int(self.frogpilot_following.desired_follow_distance)
frogpilotPlan.disableThrottle = self.frogpilot_following.disable_throttle
frogpilotPlan.experimentalMode = self.frogpilot_cem.experimental_mode or self.frogpilot_vcruise.slc.experimental_mode
@@ -1,4 +1,9 @@
#!/usr/bin/env python3
import time
import numpy as np
from openpilot.common.constants import CV
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.realtime import DT_MDL
@@ -17,27 +22,64 @@ CEStatus = {
}
class ConditionalExperimentalMode:
# Speed ranges: [0-35, 35-55, 55-70, 70+ mph]
FILTER_TIME_CURVES = [0.9, 0.8, 0.6, 0.5]
FILTER_TIME_LEADS = [0.9, 0.8, 0.7, 0.5]
FILTER_TIME_LIGHTS = [0.9, 0.8, 0.75, 0.55]
LIGHT_BOOSTS = [1.0, 1.2, 1.1, 1.0]
LIGHT_MAX_TIME = 9.0
CEM_TRANSITION_GUARD_TIME = 0.50
CEM_TRANSITION_BUFFER_TIME = 0.25
def __init__(self, FrogPilotPlanner):
self.frogpilot_planner = FrogPilotPlanner
self.curvature_filter = FirstOrderFilter(0, 0.5, DT_MDL)
self.slow_lead_filter = FirstOrderFilter(0, 1, DT_MDL)
self.stop_light_filter = FirstOrderFilter(0, 0.5, DT_MDL)
self.curvature_filter = FirstOrderFilter(0.0, self.FILTER_TIME_CURVES[1], DT_MDL)
self.slow_lead_filter = FirstOrderFilter(0.0, self.FILTER_TIME_LEADS[1], DT_MDL)
self.stop_light_filter = FirstOrderFilter(0.0, self.FILTER_TIME_LIGHTS[1], DT_MDL)
self.curve_detected = False
self.slow_lead_detected = False
self.experimental_mode = False
self.stop_light_detected = False
self.mode_hold_until = 0.0
self.mode_false_since = 0.0
def _update_filter_time_constants(self, v_ego):
speed_mph = v_ego * CV.MS_TO_MPH
curve_time = float(np.interp(speed_mph, [0, 35, 55, 70], self.FILTER_TIME_CURVES))
lead_time = float(np.interp(speed_mph, [0, 35, 55, 70], self.FILTER_TIME_LEADS))
light_time = float(np.interp(speed_mph, [0, 35, 55, 70], self.FILTER_TIME_LIGHTS))
self.curvature_filter = FirstOrderFilter(self.curvature_filter.x, curve_time, DT_MDL)
self.slow_lead_filter = FirstOrderFilter(self.slow_lead_filter.x, lead_time, DT_MDL)
self.stop_light_filter = FirstOrderFilter(self.stop_light_filter.x, light_time, DT_MDL)
def update(self, v_ego, sm, frogpilot_toggles):
now = time.monotonic()
if frogpilot_toggles.experimental_mode_via_press:
self.status_value = self.frogpilot_planner.params_memory.get("CEStatus")
self.status_value = self.frogpilot_planner.params_memory.get_int("CEStatus")
else:
self.status_value = CEStatus["OFF"]
if self.status_value not in (CEStatus["USER_DISABLED"], CEStatus["USER_OVERRIDDEN"]) and not sm["carState"].standstill:
self.update_conditions(v_ego, sm, frogpilot_toggles)
self.experimental_mode = self.check_conditions(v_ego, sm, frogpilot_toggles)
self.frogpilot_planner.params_memory.put("CEStatus", self.status_value)
triggered = self.check_conditions(v_ego, sm, frogpilot_toggles)
if triggered:
self.mode_hold_until = now + self.CEM_TRANSITION_GUARD_TIME
self.mode_false_since = 0.0
elif self.mode_false_since == 0.0:
self.mode_false_since = now
hold_active = now < self.mode_hold_until
transition_buffer_active = self.mode_false_since != 0.0 and (now - self.mode_false_since) < self.CEM_TRANSITION_BUFFER_TIME
self.experimental_mode = triggered or hold_active or transition_buffer_active
self.frogpilot_planner.params_memory.put_int("CEStatus", self.status_value if self.experimental_mode else CEStatus["OFF"])
else:
self.mode_hold_until = 0.0
self.mode_false_since = 0.0
self.experimental_mode &= sm["carState"].standstill and self.frogpilot_planner.model_stopped
self.experimental_mode &= self.status_value != CEStatus["USER_DISABLED"]
self.experimental_mode |= self.status_value == CEStatus["USER_OVERRIDDEN"]
@@ -46,7 +88,7 @@ class ConditionalExperimentalMode:
self.stop_light_filter.x = 0
def check_conditions(self, v_ego, sm, frogpilot_toggles):
if self.curve_detected and (not self.frogpilot_planner.frogpilot_following.following_lead or frogpilot_toggles.conditional_curves_lead) and frogpilot_toggles.conditional_curves:
if self.curve_detected and frogpilot_toggles.conditional_curves and (not self.frogpilot_planner.frogpilot_following.following_lead or frogpilot_toggles.conditional_curves_lead):
self.status_value = CEStatus["CURVATURE"]
return True
@@ -60,7 +102,9 @@ class ConditionalExperimentalMode:
self.status_value = CEStatus["SIGNAL"]
return True
if 1 <= v_ego < (frogpilot_toggles.conditional_limit_lead if self.frogpilot_planner.frogpilot_following.following_lead else frogpilot_toggles.conditional_limit):
below_speed = 1 <= v_ego < frogpilot_toggles.conditional_limit and not self.frogpilot_planner.frogpilot_following.following_lead
below_speed_with_lead = 1 <= v_ego < frogpilot_toggles.conditional_limit_lead and self.frogpilot_planner.frogpilot_following.following_lead
if below_speed or below_speed_with_lead:
self.status_value = CEStatus["SPEED"]
return True
@@ -75,6 +119,7 @@ class ConditionalExperimentalMode:
return False
def update_conditions(self, v_ego, sm, frogpilot_toggles):
self._update_filter_time_constants(v_ego)
self.curve_detection(v_ego, frogpilot_toggles)
self.slow_lead(v_ego, frogpilot_toggles)
self.stop_sign_and_light(v_ego, sm, frogpilot_toggles.conditional_model_stop_time)
@@ -86,14 +131,35 @@ class ConditionalExperimentalMode:
def slow_lead(self, v_ego, frogpilot_toggles):
if self.frogpilot_planner.tracking_lead:
slower_lead = (v_ego - self.frogpilot_planner.lead_one.vLead) > CRUISING_SPEED and frogpilot_toggles.conditional_slower_lead
slower_lead |= getattr(self.frogpilot_planner.frogpilot_following, "slower_lead", False) and frogpilot_toggles.conditional_slower_lead
stopped_lead = self.frogpilot_planner.lead_one.vLead < 1 and frogpilot_toggles.conditional_stopped_lead
self.slow_lead_filter.update(slower_lead or stopped_lead)
self.slow_lead_detected = self.slow_lead_filter.x >= THRESHOLD
lead_prob = getattr(self.frogpilot_planner.lead_one, 'modelProb', 1.0)
adjusted_threshold = THRESHOLD * (1.0 + 0.2 * (1.0 - lead_prob))
self.slow_lead_detected = self.slow_lead_filter.x >= adjusted_threshold
else:
self.slow_lead_filter.x = 0
self.slow_lead_detected = False
def stop_sign_and_light(self, v_ego, sm, model_time):
self.stop_light_filter.update((self.frogpilot_planner.model_length < v_ego * model_time) or self.frogpilot_planner.model_stopped)
self.stop_light_detected = self.stop_light_filter.x >= THRESHOLD and not self.frogpilot_planner.tracking_lead
if sm["frogpilotCarState"].trafficModeEnabled:
self.stop_light_filter.x = 0
self.stop_light_detected = False
return
speed_mph = v_ego * CV.MS_TO_MPH
if speed_mph > 75:
self.stop_light_filter.x = 0
self.stop_light_detected = False
return
light_boost = float(np.interp(speed_mph, [0, 35, 55, 70], self.LIGHT_BOOSTS))
cap_factor = float(np.interp(speed_mph, [0, 35, 45], [0.0, 0.0, 1.0]))
adjusted_model_time = model_time * light_boost
if cap_factor > 0:
adjusted_model_time = min(adjusted_model_time, self.LIGHT_MAX_TIME * cap_factor + model_time * (1.0 - cap_factor))
model_stopping = self.frogpilot_planner.model_length < v_ego * adjusted_model_time
self.stop_light_filter.update(self.frogpilot_planner.model_stopped or model_stopping)
self.stop_light_detected = self.stop_light_filter.x >= (THRESHOLD ** 2) and not self.frogpilot_planner.tracking_lead
@@ -5,13 +5,49 @@ from openpilot.selfdrive.controls.lib.longitudinal_planner import ACCEL_MIN, get
from openpilot.frogpilot.common.frogpilot_variables import CITY_SPEED_LIMIT
A_CRUISE_MIN_ECO = ACCEL_MIN / 2
def cubic_interp(x, xp, fp):
if x <= xp[0]:
return fp[0]
elif x >= xp[-1]:
return fp[-1]
i = np.searchsorted(xp, x) - 1
i = max(0, min(i, len(xp) - 2))
t = (x - xp[i]) / float(xp[i + 1] - xp[i])
return fp[i] * (1 - 3 * t ** 2 + 2 * t ** 3) + fp[i + 1] * (3 * t ** 2 - 2 * t ** 3)
def akima_interp(x, xp, fp):
if x <= xp[0]:
return fp[0]
elif x >= xp[-1]:
return fp[-1]
i = np.searchsorted(xp, x) - 1
i = max(0, min(i, len(xp) - 2))
t = (x - xp[i]) / float(xp[i + 1] - xp[i])
t2 = t * t
t3 = t2 * t
t4 = t2 * t2
return (fp[i] * (1 - 10 * t3 + 15 * t4 - 6 * t3 * t2)
+ fp[i + 1] * (10 * t3 - 15 * t4 + 6 * t3 * t2))
A_CRUISE_MIN_ECO = ACCEL_MIN / 2
A_CRUISE_MIN_SPORT = ACCEL_MIN * 2
# MPH = [0.0, 11, 22, 34, 45, 56, 89]
A_CRUISE_MAX_BP_CUSTOM = [0.0, 5., 10., 15., 20., 25., 40.]
A_CRUISE_MAX_VALS_ECO = [2.0, 1.5, 1.0, 0.8, 0.6, 0.4, 0.2]
A_CRUISE_MAX_VALS_SPORT = [3.0, 2.5, 2.0, 1.5, 1.0, 0.8, 0.6]
# MPH = [0.0, 11, 22, 34, 45, 56, 89]
A_CRUISE_MAX_BP_CUSTOM = [0.0, 5., 10., 15., 20., 25., 40.]
A_CRUISE_MAX_VALS_ECO_EV = [1.15, 1.15, 1.15, 1.15, 1.30, 1.30, 1.72]
A_CRUISE_MAX_VALS_STANDARD_EV = [1.25, 1.25, 1.25, 1.25, 1.45, 1.50, 2.00]
A_CRUISE_MAX_VALS_SPORT_EV = [1.35, 1.35, 1.35, 1.35, 1.60, 1.60, 2.10]
A_CRUISE_MAX_VALS_SPORT_PLUS_EV = [1.55, 1.55, 1.55, 1.55, 1.84, 1.84, 2.42]
A_CRUISE_MAX_VALS_ECO_GAS = [2.0, 1.5, 1.0, 0.8, 0.6, 0.4, 0.2]
A_CRUISE_MAX_VALS_SPORT_GAS = [3.0, 2.5, 2.0, 1.5, 1.0, 0.8, 0.6]
A_CRUISE_MAX_VALS_ECO_TRUCK = [3.00, 1.05, 0.60, 0.50, 0.50, 0.45, 0.35]
A_CRUISE_MAX_VALS_STANDARD_TRUCK = [6.00, 1.10, 0.70, 0.60, 0.55, 0.45, 0.35]
A_CRUISE_MAX_VALS_SPORT_TRUCK = [6.00, 1.15, 0.75, 0.70, 0.60, 0.50, 0.40]
A_CRUISE_MAX_VALS_SPORT_PLUS_TRUCK = [6.00, 1.30, 0.90, 0.80, 0.70, 0.60, 0.45]
ACCELERATION_PROFILES = {
"STANDARD": 0,
@@ -26,20 +62,43 @@ DECELERATION_PROFILES = {
"SPORT": 2
}
def get_max_accel_eco(v_ego):
return np.interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_ECO)
def get_max_accel_eco(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
cruise_vals = A_CRUISE_MAX_VALS_ECO_EV
elif truck_tuning:
cruise_vals = A_CRUISE_MAX_VALS_ECO_TRUCK
else:
cruise_vals = A_CRUISE_MAX_VALS_ECO_GAS
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, cruise_vals))
def get_max_accel_sport(v_ego):
return np.interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_SPORT)
def get_max_accel_sport(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
cruise_vals = A_CRUISE_MAX_VALS_SPORT_EV
elif truck_tuning:
cruise_vals = A_CRUISE_MAX_VALS_SPORT_TRUCK
else:
cruise_vals = A_CRUISE_MAX_VALS_SPORT_GAS
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, cruise_vals))
def get_max_accel_standard(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_STANDARD_EV))
if truck_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_STANDARD_TRUCK))
return get_max_accel(v_ego)
def get_max_accel_low_speeds(max_accel, v_cruise):
return np.interp(v_cruise, [0., CITY_SPEED_LIMIT / 2, CITY_SPEED_LIMIT], [max_accel / 4, max_accel / 2, max_accel])
return float(akima_interp(v_cruise, [0., CITY_SPEED_LIMIT / 2, CITY_SPEED_LIMIT], [max_accel / 4, max_accel / 2, max_accel]))
def get_max_accel_ramp_off(max_accel, v_cruise, v_ego):
return np.interp(v_cruise - v_ego, [0., 1., 5.], [0., 0.5, max_accel])
return float(akima_interp(v_cruise - v_ego, [0., 1., 5., 10.], [0., 0.5, 1.0, max_accel]))
def get_max_allowed_accel(v_ego):
return np.interp(v_ego, [0., 5., 20.], [4.0, 4.0, 2.0]) # ISO 15622:2018
def get_max_allowed_accel(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_SPORT_PLUS_EV))
if truck_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_SPORT_PLUS_TRUCK))
return float(akima_interp(v_ego, [0., 5., 20.], [4.0, 4.0, 2.0])) # ISO 15622:2018
class FrogPilotAcceleration:
def __init__(self, FrogPilotPlanner):
@@ -51,39 +110,39 @@ class FrogPilotAcceleration:
def update(self, v_ego, sm, frogpilot_toggles):
eco_gear = sm["frogpilotCarState"].ecoGear
sport_gear = sm["frogpilotCarState"].sportGear
ev_tuning = getattr(frogpilot_toggles, "ev_tuning", True)
truck_tuning = getattr(frogpilot_toggles, "truck_tuning", False)
if sm["frogpilotCarState"].trafficModeEnabled:
self.max_accel = get_max_accel(v_ego)
elif (eco_gear or sport_gear) and frogpilot_toggles.map_acceleration:
self.max_accel = get_max_accel_standard(v_ego, ev_tuning, truck_tuning)
elif frogpilot_toggles.map_acceleration and (eco_gear or sport_gear):
if eco_gear:
self.max_accel = get_max_accel_eco(v_ego)
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
else:
if frogpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["SPORT"]:
self.max_accel = get_max_accel_sport(v_ego)
self.max_accel = get_max_accel_sport(v_ego, ev_tuning, truck_tuning)
else:
self.max_accel = get_max_allowed_accel(v_ego)
self.max_accel = get_max_allowed_accel(v_ego, ev_tuning, truck_tuning)
else:
if frogpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["ECO"]:
self.max_accel = get_max_accel_eco(v_ego)
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
elif frogpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["SPORT"]:
self.max_accel = get_max_accel_sport(v_ego)
self.max_accel = get_max_accel_sport(v_ego, ev_tuning, truck_tuning)
elif frogpilot_toggles.acceleration_profile == ACCELERATION_PROFILES["SPORT_PLUS"]:
self.max_accel = get_max_allowed_accel(v_ego)
self.max_accel = get_max_allowed_accel(v_ego, ev_tuning, truck_tuning)
else:
self.max_accel = get_max_accel(v_ego)
self.max_accel = get_max_accel_standard(v_ego, ev_tuning, truck_tuning)
if frogpilot_toggles.human_acceleration:
self.max_accel = get_max_accel_low_speeds(self.max_accel, self.frogpilot_planner.v_cruise)
self.max_accel = min(get_max_accel_low_speeds(self.max_accel, self.frogpilot_planner.v_cruise), self.max_accel)
self.max_accel = min(get_max_accel_ramp_off(self.max_accel, self.frogpilot_planner.v_cruise, v_ego), self.max_accel)
if self.frogpilot_planner.frogpilot_weather.weather_id != 0:
self.max_accel -= self.max_accel * self.frogpilot_planner.frogpilot_weather.reduce_acceleration
if self.frogpilot_planner.tracking_lead:
self.min_accel = ACCEL_MIN
elif sm["frogpilotCarState"].forceCoast:
if sm["frogpilotCarState"].forceCoast:
self.min_accel = A_CRUISE_MIN_ECO
elif (eco_gear or sport_gear) and frogpilot_toggles.map_deceleration:
elif frogpilot_toggles.map_deceleration and (eco_gear or sport_gear):
if eco_gear:
self.min_accel = A_CRUISE_MIN_ECO
else:
+39 -9
View File
@@ -1,17 +1,22 @@
#!/usr/bin/env python3
import numpy as np
from openpilot.common.constants import CV
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import COMFORT_BRAKE, LEAD_DANGER_FACTOR, STOP_DISTANCE, desired_follow_distance, get_jerk_factor, get_T_FOLLOW
from openpilot.frogpilot.common.frogpilot_variables import CITY_SPEED_LIMIT, MAX_T_FOLLOW
TRAFFIC_MODE_BP = [0., CITY_SPEED_LIMIT]
PERSONALITY_BP = [45. * CV.MPH_TO_MS, 70. * CV.MPH_TO_MS]
HIGHWAY_DISABLE_THROTTLE_MIN_SPEED = 45. * CV.MPH_TO_MS
class FrogPilotFollowing:
def __init__(self, FrogPilotPlanner):
self.frogpilot_planner = FrogPilotPlanner
self.disable_throttle = False
self.following_lead = False
self.slower_lead = False
self.acceleration_jerk = 0
self.danger_jerk = 0
@@ -20,6 +25,8 @@ class FrogPilotFollowing:
self.t_follow = 0
def update(self, long_control_active, v_ego, sm, frogpilot_toggles):
stop_distance = max(float(getattr(frogpilot_toggles, "stop_distance", STOP_DISTANCE)), 4.0)
if long_control_active and sm["frogpilotCarState"].trafficModeEnabled:
if sm["carState"].aEgo >= 0:
self.base_acceleration_jerk = np.interp(v_ego, TRAFFIC_MODE_BP, frogpilot_toggles.traffic_mode_jerk_acceleration)
@@ -52,6 +59,10 @@ class FrogPilotFollowing:
frogpilot_toggles.relaxed_follow,
frogpilot_toggles.custom_personalities, sm["selfdriveState"].personality
)
if isinstance(self.t_follow, (list, tuple)):
self.t_follow = float(np.interp(v_ego, PERSONALITY_BP, self.t_follow))
else:
self.t_follow = float(self.t_follow)
else:
self.base_acceleration_jerk = 0
self.base_danger_jerk = 0
@@ -64,22 +75,40 @@ class FrogPilotFollowing:
self.speed_jerk = self.base_speed_jerk
self.following_lead = self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.dRel < (self.t_follow * 2) * v_ego
self.slower_lead = False
if self.frogpilot_planner.frogpilot_weather.weather_id != 0:
self.t_follow = min(self.t_follow + self.frogpilot_planner.frogpilot_weather.increase_following_distance, MAX_T_FOLLOW)
self.disable_throttle = False
if self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.status:
lead_distance = self.frogpilot_planner.lead_one.dRel
v_lead = self.frogpilot_planner.lead_one.vLead
closing_speed = max(0.0, v_ego - v_lead)
desired_gap = float(desired_follow_distance(v_ego, v_lead, self.t_follow, stop_distance))
ttc = lead_distance / max(closing_speed, 1e-3) if closing_speed > 0.1 else 1e6
coast_window_open = lead_distance > desired_gap + max(4.0, 0.2 * v_ego)
coast_window_far = lead_distance < desired_gap + max(25.0, 1.2 * v_ego)
gentle_closing = closing_speed < max(2.0, 0.12 * v_ego)
self.disable_throttle = (not self.following_lead and v_ego > HIGHWAY_DISABLE_THROTTLE_MIN_SPEED and coast_window_open and
coast_window_far and gentle_closing)
self.disable_throttle &= ttc > 6.0 and lead_distance > desired_gap + 6.0
if long_control_active and self.frogpilot_planner.tracking_lead:
if not sm["frogpilotCarState"].trafficModeEnabled and frogpilot_toggles.human_following:
self.update_follow_values(self.frogpilot_planner.lead_one.dRel, v_ego, self.frogpilot_planner.lead_one.vLead)
self.desired_follow_distance = desired_follow_distance(v_ego, self.frogpilot_planner.lead_one.vLead, self.t_follow)
if not sm["frogpilotCarState"].trafficModeEnabled:
self.update_follow_values(self.frogpilot_planner.lead_one.dRel, v_ego, self.frogpilot_planner.lead_one.vLead, frogpilot_toggles)
self.desired_follow_distance = int(desired_follow_distance(v_ego, self.frogpilot_planner.lead_one.vLead, self.t_follow, stop_distance))
else:
self.desired_follow_distance = 0
def update_follow_values(self, lead_distance, v_ego, v_lead):
def update_follow_values(self, lead_distance, v_ego, v_lead, frogpilot_toggles):
stop_distance = max(float(getattr(frogpilot_toggles, "stop_distance", STOP_DISTANCE)), 4.0)
# Offset by FrogAi for FrogPilot for a more natural approach to a faster lead
if v_lead > v_ego:
if frogpilot_toggles.human_following and v_lead > v_ego:
distance_factor = max(lead_distance - (v_ego * self.t_follow), 1)
accelerating_offset = np.clip(STOP_DISTANCE - v_ego, 1, distance_factor)
accelerating_offset = np.clip(stop_distance - v_ego, 1, distance_factor)
self.acceleration_jerk /= accelerating_offset
self.danger_factor -= ((v_lead - v_ego) / 100)
@@ -87,15 +116,16 @@ class FrogPilotFollowing:
self.t_follow /= accelerating_offset
# Offset by FrogAi for FrogPilot for a more natural approach to a slower lead
if v_lead < v_ego:
if (frogpilot_toggles.conditional_slower_lead or frogpilot_toggles.human_following) and v_lead < v_ego:
distance_factor = max(lead_distance - (v_lead * self.t_follow), 1)
braking_offset = np.clip(min(v_ego - v_lead, v_lead) - COMFORT_BRAKE, 1, distance_factor)
if lead_distance >= 100:
far_lead_offset = max(lead_distance - (v_ego * self.t_follow) - STOP_DISTANCE, 0)
if frogpilot_toggles.human_following and lead_distance >= 100:
far_lead_offset = max(lead_distance - (v_ego * self.t_follow) - stop_distance, 0)
braking_offset += far_lead_offset
if self.frogpilot_planner.tracking_lead_filter.x >= 0.9:
self.danger_factor += ((v_ego - v_lead) / 100)
self.t_follow /= braking_offset
self.slower_lead = braking_offset > 1
@@ -19,6 +19,8 @@ class FrogPilotVCruise:
self.override_force_stop = False
self.override_force_stop_timer = 0
self.force_stop_timer = 0.0
self.tracked_model_length = 0.0
def update(self, long_control_active, now, time_validated, v_cruise, v_ego, sm, frogpilot_toggles):
force_stop = self.frogpilot_planner.frogpilot_cem.stop_light_detected and long_control_active and frogpilot_toggles.force_stops
+71 -8
View File
@@ -1,25 +1,41 @@
#!/usr/bin/env python3
import datetime
import json
import requests
import time
from cereal import messaging
from openpilot.common.api import Api, api_get
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL, Priority, Ratekeeper, config_realtime_process
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
from openpilot.frogpilot.assets.model_manager import MODEL_DOWNLOAD_ALL_PARAM, MODEL_DOWNLOAD_PARAM, ModelManager
from openpilot.frogpilot.assets.theme_manager import THEME_COMPONENT_PARAMS, ThemeManager
from openpilot.frogpilot.common.frogpilot_backups import backup_toggles
from openpilot.frogpilot.common.frogpilot_functions import capture_report, update_maps, update_openpilot
from openpilot.frogpilot.common.frogpilot_utilities import ThreadManager, flash_panda, is_url_pingable, lock_doors
from openpilot.frogpilot.common.frogpilot_utilities import ThreadManager, flash_panda, is_url_pingable, lock_doors, use_konik_server
from openpilot.frogpilot.common.frogpilot_variables import ERROR_LOGS_PATH, FrogPilotVariables
from openpilot.frogpilot.controls.frogpilot_planner import FrogPilotPlanner
from openpilot.frogpilot.system.frogpilot_stats import send_stats
from openpilot.frogpilot.system.frogpilot_tracking import FrogPilotTracking
ASSET_CHECK_RATE = (1 / DT_MDL)
DRIVE_STATS_SYNC_RATE = 30
def check_assets(now, model_manager, theme_manager, thread_manager, params, params_memory, frogpilot_toggles):
if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM):
thread_manager.run_with_lock(model_manager.download_all_models)
elif params_memory.get_bool("UpdateTinygrad"):
thread_manager.run_with_lock(model_manager.update_tinygrad)
else:
model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM)
if isinstance(model_to_download, bytes):
model_to_download = model_to_download.decode("utf-8", errors="replace")
if model_to_download:
thread_manager.run_with_lock(model_manager.download_model, (model_to_download,))
def check_assets(now, theme_manager, thread_manager, params, params_memory, frogpilot_toggles):
for asset_type, asset_param in THEME_COMPONENT_PARAMS.items():
asset_to_download = params_memory.get(asset_param)
if asset_to_download:
@@ -36,6 +52,40 @@ def check_assets(now, theme_manager, thread_manager, params, params_memory, frog
if params_memory.get_bool("DownloadMaps"):
thread_manager.run_with_lock(update_maps, (now, params, params_memory, True))
def sync_drive_stats(params, session):
try:
dongle_id = params.get("DongleId")
if isinstance(dongle_id, bytes):
dongle_id = dongle_id.decode("utf-8", errors="replace")
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
token = Api(dongle_id).get_token(expiry_hours=2)
if not token:
return
response = api_get(f"v1.1/devices/{dongle_id}/stats", timeout=15, access_token=token, session=session)
if response.status_code != 200:
print(f"Failed to sync drive stats (HTTP {response.status_code})")
return
stats = response.json()
if not isinstance(stats, dict):
return
all_stats = stats.get("all")
week_stats = stats.get("week")
if not isinstance(all_stats, dict) or not isinstance(week_stats, dict):
return
params.put("ApiCache_DriveStats", stats)
all_minutes = all_stats.get("minutes")
if isinstance(all_minutes, (int, float)):
params.put_int("KonikMinutes" if use_konik_server() else "openpilotMinutes", int(all_minutes))
except Exception as exception:
print(f"Failed to sync drive stats: {exception}")
def transition_offroad(frogpilot_planner, theme_manager, thread_manager, time_validated, sm, params, frogpilot_toggles):
params.put("LastGPSPosition", json.dumps(frogpilot_planner.gps_position))
@@ -46,16 +96,17 @@ def transition_offroad(frogpilot_planner, theme_manager, thread_manager, time_va
theme_manager.update_active_theme(time_validated, frogpilot_toggles, randomize_theme=True)
if time_validated:
thread_manager.run_with_lock(send_stats, (params, frogpilot_toggles))
thread_manager.run_with_lock(send_stats)
def transition_onroad(error_log):
if error_log.is_file():
error_log.unlink()
def update_checks(now, theme_manager, thread_manager, params, params_memory, frogpilot_toggles, boot_run=False):
def update_checks(now, model_manager, theme_manager, thread_manager, params, params_memory, frogpilot_toggles, boot_run=False):
while not (is_url_pingable("https://github.com") or is_url_pingable("https://gitlab.com")):
time.sleep(60)
model_manager.update_models(boot_run)
theme_manager.update_themes(frogpilot_toggles, boot_run)
thread_manager.run_with_lock(update_maps, (now, params, params_memory))
@@ -99,11 +150,15 @@ def frogpilot_thread():
params_memory = Params(memory=True)
frogpilot_variables = FrogPilotVariables()
model_manager = ModelManager(params, params_memory)
theme_manager = ThemeManager(params, params_memory)
thread_manager = ThreadManager()
frogpilot_toggles = frogpilot_variables.frogpilot_toggles
drive_stats_session = requests.Session()
next_drive_stats_sync = 0.0
run_update_checks = False
started_previously = False
time_validated = False
@@ -145,8 +200,16 @@ def frogpilot_thread():
started_previously = started
if not started and time_validated and sm["deviceState"].screenBrightnessPercent > 0:
monotonic_now = time.monotonic()
if monotonic_now >= next_drive_stats_sync:
thread_manager.run_with_lock(sync_drive_stats, (params, drive_stats_session), report=False)
next_drive_stats_sync = monotonic_now + DRIVE_STATS_SYNC_RATE
elif started:
next_drive_stats_sync = 0.0
if rate_keeper.frame % ASSET_CHECK_RATE == 0:
check_assets(now, theme_manager, thread_manager, params, params_memory, frogpilot_toggles)
check_assets(now, model_manager, theme_manager, thread_manager, params, params_memory, frogpilot_toggles)
if params_memory.get_bool("FrogPilotTogglesUpdated") or theme_manager.theme_updated:
frogpilot_toggles = update_toggles(frogpilot_variables, started, theme_manager, thread_manager, time_validated, params, frogpilot_toggles)
@@ -157,7 +220,7 @@ def frogpilot_thread():
if run_update_checks:
theme_manager.update_active_theme(time_validated, frogpilot_toggles)
thread_manager.run_with_lock(update_checks, (now, theme_manager, thread_manager, params, params_memory, frogpilot_toggles))
thread_manager.run_with_lock(update_checks, (now, model_manager, theme_manager, thread_manager, params, params_memory, frogpilot_toggles))
run_update_checks = False
elif not time_validated:
@@ -168,8 +231,8 @@ def frogpilot_thread():
theme_manager.update_active_theme(time_validated, frogpilot_toggles)
thread_manager.run_with_lock(backup_toggles, (params, True))
thread_manager.run_with_lock(send_stats, (params, frogpilot_toggles))
thread_manager.run_with_lock(update_checks, (now, theme_manager, thread_manager, params, params_memory, frogpilot_toggles, True))
thread_manager.run_with_lock(send_stats)
thread_manager.run_with_lock(update_checks, (now, model_manager, theme_manager, thread_manager, params, params_memory, frogpilot_toggles, True))
rate_keeper.keep_time()
Binary file not shown.
Binary file not shown.
+219 -157
View File
@@ -1,199 +1,261 @@
import json
import os
import random
import requests
import sys
from cereal import car, custom
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "third_party"))
from openpilot.frogpilot.common.frogpilot_download_utilities import github_rate_limited
from openpilot.frogpilot.common.frogpilot_utilities import clean_model_name, get_frogpilot_api_info, is_url_pingable
from openpilot.frogpilot.common.frogpilot_variables import FROGPILOT_API
from collections import Counter
from datetime import datetime, timezone
from influxdb_client import InfluxDBClient, Point
from influxdb_client.client.write_api import SYNCHRONOUS
try:
from openpilot.common.conversions import Conversions as CV
except ModuleNotFoundError:
from openpilot.common.constants import CV
from openpilot.system.hardware import HARDWARE
from openpilot.system.version import get_build_metadata
from openpilot.frogpilot.common.frogpilot_utilities import clean_model_name, run_cmd
from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles, params
BASE_URL = "https://nominatim.openstreetmap.org"
GITHUB_API_URL = "https://api.github.com/repos/FrogAi/FrogPilot/commits"
MINIMUM_POPULATION = 100_000
SEARCH_RADIUS_DEGREES = 1.45
METER_TO_MILE = getattr(CV, "METER_TO_MILE", 0.000621371192237334)
TRACKED_BRANCHES = ["FrogPilot", "FrogPilot-Staging", "FrogPilot-Testing"]
def get_device_generation(device_type):
normalized = (device_type or "").lower()
mapping = {
"tici": "C3",
"tizi": "C3X",
"mici": "C4",
}
return mapping.get(normalized, (device_type or "Unknown").upper())
def get_branch_commits():
commits = []
def _json_object(value):
if value is None:
return {}
if isinstance(value, dict):
return value
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
with requests.Session() as session:
session.headers.update({
"Accept": "application/vnd.github.v3+json",
"Accept-Language": "en",
"User-Agent": "frogpilot-branch-commits-checker/1.0 (https://github.com/FrogAi/FrogPilot)"
})
def get_population_value(population_str):
if population_str is None:
return None
try:
return int(str(population_str).replace(",", "").split(";")[0].strip())
except Exception:
return None
if github_rate_limited(session):
print("Skipping commit check due to rate limits.")
return []
def search_nearby_major_cities(lat, lon, session, state_name, country_name):
viewbox = f"{lon - SEARCH_RADIUS_DEGREES},{lat + SEARCH_RADIUS_DEGREES},{lon + SEARCH_RADIUS_DEGREES},{lat - SEARCH_RADIUS_DEGREES}"
cities = (session.get(f"{BASE_URL}/search", params={
"addressdetails": 1, "bounded": 1, "extratags": 1, "format": "jsonv2", "limit": 20, "q": "city", "viewbox": viewbox
}, timeout=10).json() or [])
for branch in TRACKED_BRANCHES:
try:
response = session.get(f"{GITHUB_API_URL}/{branch}", timeout=10)
response.raise_for_status()
qualifying = [c for c in cities if (get_population_value((c.get("extratags") or {}).get("population")) or 0) >= MINIMUM_POPULATION]
if not qualifying:
return None
sha = response.json().get("sha")
if sha:
commits.append({"branch": branch, "commit": sha})
except requests.exceptions.RequestException as exception:
print(f"Failed to get commit for {branch}: {exception}")
nearest = min(qualifying, key=lambda c: (float(c["lat"]) - lat) ** 2 + (float(c["lon"]) - lon) ** 2)
addr = nearest.get("address") or {}
return float(nearest["lat"]), float(nearest["lon"]), addr.get("city") or addr.get("town") or nearest.get("display_name", "").split(",")[0], state_name, country_name
return commits
def get_city_center(latitude, longitude):
if latitude == 0 and longitude == 0:
return (0.0, 0.0, "N/A", "N/A", "N/A")
try:
with requests.Session() as session:
session.headers.update({
"Accept-Language": "en",
"User-Agent": "frogpilot-city-center-checker/1.0 (https://github.com/FrogAi/FrogPilot)"
})
session.headers.update({"Accept-Language": "en"})
session.headers.update({"User-Agent": "frogpilot-city-center-checker/1.0 (https://github.com/FrogAi/FrogPilot)"})
location_params = {
"addressdetails": 1, "format": "jsonv2",
"lat": latitude, "lon": longitude, "zoom": 13
}
response = session.get(f"{BASE_URL}/reverse", params=location_params, timeout=10)
response = session.get(f"{BASE_URL}/reverse", params={"addressdetails": 1, "extratags": 0, "format": "jsonv2", "lat": latitude, "lon": longitude, "namedetails": 0, "zoom": 14}, timeout=10)
response.raise_for_status()
address = response.json().get("address", {})
data = response.json() or {}
city_name = address.get("city") or address.get("town") or address.get("village") or address.get("hamlet")
state_name = address.get("province") or address.get("region") or address.get("state") or address.get("state_district") or "N/A"
country_name = address.get("country", "N/A")
address = data.get("address") or {}
city_name = address.get("city") or address.get("hamlet") or address.get("town") or address.get("village")
country_code = (address.get("country_code") or "").lower()
country_name = address.get("country") or "N/A"
state_name = address.get("province") or address.get("region") or address.get("state") or address.get("state_district") or "N/A"
if city_name:
city_query_params = {
"q": f"{city_name}, {state_name}, {country_name}",
"addressdetails": 1, "extratags": 1,
"format": "jsonv2", "limit": 1
}
response = session.get(f"{BASE_URL}/search", params=city_query_params, timeout=10)
response = session.get(f"{BASE_URL}/search", params={"addressdetails": 1, "extratags": 1, "format": "jsonv2", "limit": 1, "q": f"{city_name}, {state_name}, {country_name}"}, timeout=10)
response.raise_for_status()
city_results = response.json()
data = response.json() or []
if city_results:
city_result = city_results[0]
population = int(str(city_result.get("extratags", {}).get("population", "0")).replace(",", "").replace(" ", "").split(";")[0])
if data:
tags = data[0]
population_value = get_population_value((tags.get("extratags") or {}).get("population"))
if population >= MINIMUM_POPULATION:
city_address = city_result.get("address", {})
selected_city_name = city_address.get("city") or city_address.get("town") or city_name
return (float(city_result["lat"]), float(city_result["lon"]), selected_city_name, state_name, country_name)
if population_value is not None and population_value >= MINIMUM_POPULATION:
latitude_value = float(tags["lat"])
longitude_value = float(tags["lon"])
capital_query = (f"{state_name} state capital" if country_code == "us" else f"capital of {state_name}, {country_name}")
capital_query_params = {
"q": capital_query,
"addressdetails": 1, "extratags": 1,
"format": "jsonv2", "limit": 5
}
response = session.get(f"{BASE_URL}/search", params=capital_query_params, timeout=10)
resolved_address = tags.get("address") or {}
city_label = resolved_address.get("city") or resolved_address.get("town") or city_name
return latitude_value, longitude_value, city_label, state_name, country_name
nearby_result = search_nearby_major_cities(latitude, longitude, session, state_name, country_name)
if nearby_result:
return nearby_result
query = f"{state_name} state capital" if country_code == "us" else f"capital of {state_name}, {country_name}"
response = session.get(f"{BASE_URL}/search", params={"addressdetails": 1, "extratags": 1, "format": "jsonv2", "limit": 5, "q": query}, timeout=10)
response.raise_for_status()
capital_results = response.json()
candidates = response.json() or []
selected_capital = None
for capital_result in capital_results:
if capital_result is None:
continue
chosen_candidate = None
for candidate in candidates:
address = candidate.get("address") or {}
capital = (candidate.get("extratags") or {}).get("capital")
country = address.get("country")
state = address.get("province") or address.get("region") or address.get("state") or address.get("state_district")
capital_address = capital_result.get("address", {})
capital_state = (capital_address.get("province") or capital_address.get("region") or capital_address.get("state") or capital_address.get("state_district"))
capital_country = capital_address.get("country")
if capital_country != country_name:
continue
if state_name != "N/A" and capital_state != state_name:
continue
is_tagged_capital = (capital_result.get("extratags") or {}).get("capital") in ("administrative", "state", "yes")
if is_tagged_capital:
selected_capital = capital_result
if (state == state_name or state_name == "N/A") and country == country_name and (capital in ("administrative", "state", "yes") or address.get("city") or address.get("town")):
chosen_candidate = candidate
break
if selected_capital is None:
selected_capital = capital_result
if not chosen_candidate and candidates:
chosen_candidate = candidates[0]
if selected_capital:
selected_capital_address = selected_capital.get("address", {})
selected_city_name = (selected_capital_address.get("city") or selected_capital_address.get("town") or selected_capital.get("display_name", "").split(",")[0])
return (float(selected_capital["lat"]), float(selected_capital["lon"]), selected_city_name, state_name, country_name)
if chosen_candidate:
latitude_value = float(chosen_candidate["lat"])
longitude_value = float(chosen_candidate["lon"])
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
pass
chosen_address = chosen_candidate.get("address") or {}
city_label = chosen_address.get("city") or chosen_address.get("town") or (chosen_candidate.get("display_name") or "").split(",")[0]
return (0.0, 0.0, "N/A", "N/A", "N/A")
return latitude_value, longitude_value, city_label, state_name, country_name
def send_stats(params, frogpilot_toggles):
if not is_url_pingable(f"{FROGPILOT_API}"):
return
print(f"Falling back to (0, 0) for {latitude}, {longitude}")
return float(0.0), float(0.0), "N/A", "N/A", "N/A"
api_token, build_metadata, device_type, dongle_id = get_frogpilot_api_info()
car_params = "{}"
msg_bytes = params.get("CarParamsPersistent")
if msg_bytes:
with car.CarParams.from_bytes(msg_bytes) as CP:
cp_dict = CP.to_dict()
cp_dict.pop("carFw", None)
cp_dict.pop("carVin", None)
car_params = json.dumps(cp_dict)
frogpilot_car_params = "{}"
frogpilot_msg_bytes = params.get("FrogPilotCarParamsPersistent")
if frogpilot_msg_bytes:
with custom.FrogPilotCarParams.from_bytes(frogpilot_msg_bytes) as FPCP:
fpcp_dict = FPCP.to_dict()
fpcp_dict.pop("carFw", None)
fpcp_dict.pop("carVin", None)
frogpilot_car_params = json.dumps(fpcp_dict)
frogpilot_stats = params.get("FrogPilotStats")
location = json.loads(params.get("LastGPSPosition") or "{}") or {}
original_latitude = location.get("latitude", 0.0)
original_longitude = location.get("longitude", 0.0)
latitude, longitude, city, state, country = get_city_center(original_latitude, original_longitude)
payload = {
"api_token": api_token,
"branch_commits": get_branch_commits(),
"build_metadata": build_metadata,
"model_scores": [],
"user_stats": {
"calibrated_lateral_acceleration": params.get("CalibratedLateralAcceleration"),
"calibration_progress": params.get("CalibrationProgress"),
"car_params": car_params,
"city": city,
"country": country,
"device": device_type,
"frogpilot_car_params": frogpilot_car_params,
"frogpilot_dongle_id": dongle_id,
"frogpilot_stats": json.dumps(frogpilot_stats),
"latitude": latitude,
"longitude": longitude,
"state": state,
"toggles": json.dumps(frogpilot_toggles.__dict__),
"using_default_model": params.get("DrivingModel").endswith("_default"),
},
}
for model_name, data in sorted(params.get("ModelDrivesAndScores").items()):
drives = data.get("Drives", 0)
score = data.get("Score", 0)
if drives > 0:
payload["model_scores"].append({
"model_name": clean_model_name(model_name),
"drives": int(drives),
"score": int(score),
})
except Exception as exception:
print(f"Falling back to (0, 0) for {latitude}, {longitude}")
return float(0.0), float(0.0), "N/A", "N/A", "N/A"
def update_branch_commits(now):
points = []
branch = get_build_metadata().channel # Current running branch
try:
response = requests.post(f"{FROGPILOT_API}/stats", json=payload, headers={"Content-Type": "application/json", "User-Agent": "frogpilot-api/1.0"}, timeout=30)
response = requests.get(f"https://api.github.com/repos/firestar5683/StarPilot/commits/{branch}")
response.raise_for_status()
sha = response.json()["sha"]
points.append(Point("branch_commits").field("commit", sha).tag("branch", branch).time(now))
except Exception as e:
print(f"Failed to fetch commit for {branch}: {e}")
return points
def is_up_to_date(build_metadata):
remote_commit = run_cmd(["git", "ls-remote", "origin", build_metadata.channel], f"Fetched remote commit", "Failed to fetch remote commit", report=False)
if remote_commit:
return build_metadata.openpilot.git_commit == remote_commit.strip().split()[0]
return True
def send_stats():
try:
build_metadata = get_build_metadata()
frogpilot_toggles = get_frogpilot_toggles()
frogs_go_moo = getattr(frogpilot_toggles, "frogs_go_moo", getattr(frogpilot_toggles, "frogsgomoo_tweak", False))
if frogs_go_moo:
return
if frogpilot_toggles.car_make == "mock":
return
bucket = "StarPilot"
org_ID = "StarPilot"
url = "https://stats.firestar.link"
frogpilot_stats = _json_object(params.get("FrogPilotStats"))
location = _json_object(params.get("LastGPSPosition"))
if not (location.get("latitude") and location.get("longitude")):
return
original_latitude = location.get("latitude")
original_longitude = location.get("longitude")
latitude, longitude, city, state, country = get_city_center(original_latitude, original_longitude)
theme_sources = [
frogpilot_toggles.icon_pack.replace("-animated", ""),
frogpilot_toggles.color_scheme,
frogpilot_toggles.distance_icons.replace("-animated", ""),
frogpilot_toggles.signal_icons.replace("-animated", ""),
frogpilot_toggles.sound_pack
]
theme_counter = Counter(theme_sources)
most_common = theme_counter.most_common()
max_count = most_common[0][1]
selected_theme = random.choice([item for item, count in most_common if count == max_count]).replace("-user_created", "").replace("_", " ")
now = datetime.now(timezone.utc)
device_type = HARDWARE.get_device_type()
stats_dongle_id = params.get("FrogPilotDongleId", encoding="utf-8") or params.get("DongleId", encoding="utf-8") or "unknown"
user_point = (
Point("user_stats")
.tag("car_make", "GM" if frogpilot_toggles.car_make == "gm" else frogpilot_toggles.car_make.title())
.tag("car_model", frogpilot_toggles.car_model)
.tag("city", city)
.tag("country", country)
.tag("device", device_type)
.tag("device_generation", get_device_generation(device_type))
.tag("driving_model", clean_model_name(frogpilot_toggles.model_name))
.tag("state", state)
.tag("theme", selected_theme.title())
.tag("branch", build_metadata.channel)
.tag("dongle_id", stats_dongle_id)
.field("blocked_user", frogpilot_toggles.block_user)
.field("current_months_kilometers", int(frogpilot_stats.get("CurrentMonthsKilometers", 0)))
.field("event", 1)
.field("frogpilot_drives", int(frogpilot_stats.get("FrogPilotDrives", 0)))
.field("frogpilot_hours", float(frogpilot_stats.get("FrogPilotSeconds", 0)) / (60 * 60))
.field("frogpilot_miles", float(frogpilot_stats.get("FrogPilotMeters", 0)) * METER_TO_MILE)
.field("goat_scream", frogpilot_toggles.goat_scream_alert)
.field("has_cc_long", frogpilot_toggles.has_cc_long)
.field("has_openpilot_longitudinal", frogpilot_toggles.openpilot_longitudinal)
.field("has_pedal", frogpilot_toggles.has_pedal)
.field("has_sdsu", frogpilot_toggles.has_sdsu)
.field("has_sascm", getattr(frogpilot_toggles, "has_sascm", False))
.field("has_zss", frogpilot_toggles.has_zss)
.field("latitude", latitude)
.field("longitude", longitude)
.field("rainbow_path", frogpilot_toggles.rainbow_path)
.field("random_events", frogpilot_toggles.random_events)
.field("total_aol_seconds", float(frogpilot_stats.get("AOLTime", 0)))
.field("total_lateral_seconds", float(frogpilot_stats.get("LateralTime", 0)))
.field("total_longitudinal_seconds", float(frogpilot_stats.get("LongitudinalTime", 0)))
.field("total_tracked_seconds", float(frogpilot_stats.get("TrackedTime", 0)))
.field("tuning_level", params.get_int("TuningLevel") + 1 if params.get_bool("TuningLevelConfirmed") else 0)
.field("up_to_date", is_up_to_date(build_metadata))
.field("using_stock_acc", not (frogpilot_toggles.has_cc_long or frogpilot_toggles.openpilot_longitudinal))
.time(now)
)
all_points = [user_point] + update_branch_commits(now)
client = InfluxDBClient(org=org_ID, token=org_ID, url=url)
client.write_api(write_options=SYNCHRONOUS).write(bucket=bucket, org=org_ID, record=all_points)
print("Successfully sent FrogPilot stats!")
except requests.exceptions.RequestException as error:
print(f"Failed to send stats: {error}")
except Exception as exception:
print(f"Failed to send FrogPilot stats: {exception}")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
import hmac
import json
import platform
import shutil
import signal
import subprocess
import tarfile
import time
import threading
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from openpilot.common.params import Params
from openpilot.system.hardware import HARDWARE
from openpilot.system.hardware.hw import Paths
if HARDWARE.get_device_type() == "pc":
GALAXY_DIR = Path(Paths.comma_home()) / "frogpilot" / "data" / "galaxy"
else:
GALAXY_DIR = Path("/data/galaxy")
FRPC_VERSION = "0.67.0"
FRPC_LOG = GALAXY_DIR / "frpc.log"
AUTH_PORT = 8083
BUNDLED_FRPC_DIR = Path(__file__).resolve().parent / "bin"
process = None
auth_server = None
VALID_PATHS = {"/glxylogin": "glxyauth", "/glxyverify": "glxysession"}
class AuthHandler(BaseHTTPRequestHandler):
"""Validates login hashes and verifies session tokens."""
dongle_id = ""
def do_POST(self):
target = VALID_PATHS.get(self.path)
if not target:
self.send_response(404)
self.end_headers()
return
file_path = GALAXY_DIR / target
if not file_path.exists():
self.send_response(404)
self.end_headers()
return
try:
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8").strip()
if hmac.compare_digest(body, file_path.read_text().strip()):
self.send_response(200)
if self.path == "/glxylogin":
self.send_header("Content-Type", "application/json")
self.end_headers()
token = (GALAXY_DIR / "glxysession").read_text().strip()
self.wfile.write(json.dumps({"dongle_id": self.dongle_id, "token": token}).encode())
else:
self.end_headers()
return
except Exception:
pass
self.send_response(403)
self.end_headers()
def log_message(self, format, *args):
pass
def start_auth_server():
global auth_server
if auth_server is not None:
return
auth_server = HTTPServer(("127.0.0.1", AUTH_PORT), AuthHandler)
thread = threading.Thread(target=auth_server.serve_forever, daemon=True)
thread.start()
print(f"Galaxy: Auth server listening on 127.0.0.1:{AUTH_PORT}")
def cleanup_frpc(*_):
global process
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process = None
def get_arch_url():
arch = platform.machine()
system = platform.system()
if system == "Darwin":
if arch in ("aarch64", "arm64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_darwin_arm64.tar.gz", f"frp_{FRPC_VERSION}_darwin_arm64"
if arch in ("x86_64", "amd64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_darwin_amd64.tar.gz", f"frp_{FRPC_VERSION}_darwin_amd64"
elif system == "Linux":
if arch in ("aarch64", "arm64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", f"frp_{FRPC_VERSION}_linux_arm64"
if arch in ("x86_64", "amd64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_amd64.tar.gz", f"frp_{FRPC_VERSION}_linux_amd64"
return None, None
def get_bundled_frpc_path():
arch = platform.machine().lower()
system = platform.system().lower()
if system == "darwin":
if arch in ("aarch64", "arm64"):
return BUNDLED_FRPC_DIR / "frpc_darwin_arm64"
if arch in ("x86_64", "amd64"):
return BUNDLED_FRPC_DIR / "frpc_darwin_amd64"
elif system == "linux":
if arch in ("aarch64", "arm64"):
return BUNDLED_FRPC_DIR / "frpc_linux_arm64"
if arch in ("x86_64", "amd64"):
return BUNDLED_FRPC_DIR / "frpc_linux_amd64"
return None
def get_param_str(params, key):
value = params.get(key)
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value or "")
def setup_frpc():
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
frpc_bin = GALAXY_DIR / "frpc"
if not frpc_bin.exists():
bundled_bin = get_bundled_frpc_path()
if bundled_bin is not None and bundled_bin.exists():
try:
shutil.copy2(bundled_bin, frpc_bin)
frpc_bin.chmod(0o755)
print(f"Galaxy: Installed bundled frpc ({bundled_bin.name}).")
return True
except Exception as e:
print(f"Galaxy: Failed to install bundled frpc: {e}")
print("Galaxy: Downloading frpc (bundled binary unavailable)...")
url, folder_name = get_arch_url()
if not url:
print("Galaxy: Unsupported architecture")
return False
tar_path = GALAXY_DIR / "frp.tar.gz"
try:
urllib.request.urlretrieve(url, tar_path)
with tarfile.open(tar_path, "r:gz") as tar:
tar.extractall(path=GALAXY_DIR, filter='data')
# Move binary
extracted_bin = GALAXY_DIR / folder_name / "frpc"
extracted_bin.rename(frpc_bin)
frpc_bin.chmod(0o755)
# Cleanup
tar_path.unlink()
shutil.rmtree(GALAXY_DIR / folder_name)
print("Galaxy: frpc downloaded and installed.")
except Exception as e:
print(f"Galaxy: Failed to install frpc: {e}")
return False
return True
def main():
global process
params = Params()
signal.signal(signal.SIGTERM, cleanup_frpc)
signal.signal(signal.SIGINT, cleanup_frpc)
dongle_id = get_param_str(params, "DongleId")
while not dongle_id:
print("Galaxy: Waiting for DongleId...")
time.sleep(5)
dongle_id = get_param_str(params, "DongleId")
print(f"Galaxy: DongleId: {dongle_id}")
AuthHandler.dongle_id = dongle_id
print("Galaxy: Starting manager loop...")
last_slug = None
while True:
glxyauth_file = GALAXY_DIR / "glxyauth"
galaxy_password_hash = glxyauth_file.read_text().strip() if glxyauth_file.exists() else None
slug_file = GALAXY_DIR / "glxyslug"
slug = slug_file.read_text().strip() if slug_file.exists() else None
is_paired = galaxy_password_hash and len(galaxy_password_hash) == 64 and slug
if is_paired:
if process is None or process.poll() is not None or slug != last_slug:
cleanup_frpc()
if process is not None and slug == last_slug:
print(f"Galaxy: frpc exited with code {process.returncode}. Restarting...")
print("Galaxy: Password set. Preparing frpc tunnel...")
if not setup_frpc():
print("Galaxy: FRPC setup failed. Retrying later...")
time.sleep(10)
continue
start_auth_server()
frpc_toml = GALAXY_DIR / "frpc.toml"
config = f"""\
serverAddr = "galaxy.firestar.link"
serverPort = 7000
[transport]
tls.enable = true
poolCount = 2
[[proxies]]
name = "{slug}_pond"
type = "http"
localIP = "127.0.0.1"
localPort = 8082
customDomains = ["{slug}.devices.local"]
transport.useCompression = true
[[proxies]]
name = "{slug}_auth"
type = "http"
localIP = "127.0.0.1"
localPort = {AUTH_PORT}
customDomains = ["auth-{slug}.devices.local"]
"""
frpc_toml.write_text(config)
print(f"Galaxy: Starting frpc tunnel (slug: {slug[:4]}...)...")
log_file = open(FRPC_LOG, 'a')
process = subprocess.Popen(
[str(GALAXY_DIR / "frpc"), "-c", str(frpc_toml)],
stdout=log_file,
stderr=log_file
)
last_slug = slug
else:
if process is not None and process.poll() is None:
print("Galaxy: Password cleared. Stopping frpc tunnel...")
cleanup_frpc()
last_slug = None
time.sleep(3)
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
# The Pond
**The Pond** is a lightweight web-based interface for managing your device. It allows you to adjust settings remotely, view video streams, and download logs—all from your browser.
---
# Architecture
The Pond has two main components:
- A **Python Flask API**
- A **frontend built with Arrow.js** (a minimal reactive framework)
Because the frontend uses native ES modules, there's no need for a build step or Node.js tooling.
## API
The API is simple and defined in `the_pond.py`. It exposes a handful of JSON-based endpoints via standard Flask routes:
- Get and update settings
- Fetch error logs
- List recorded routes
- Load a specific route and video stream (ported from Fleet Manager)
- Save a navigation destination (triggers OpenPilot navigation)
## Web App
The frontend is built using [Arrow.js](https://www.arrow-js.com), chosen for its simplicity and native module support (no bundlers required). It includes the following main components:
- **Error Logs**
- **Navigation**
- **Routes**
- **Settings**
It also includes some layout-related components:
- `"router"` for dynamic view switching
- `"sidebar"` for navigation
---
# Development Guide
## Adding a New Page
To add a new page:
1. **Create the component**
Add a new file in the `components/` directory.
2. **Register the route**
Add your route to `router.js`. The router uses a simple list of route definitions:
```js
let routes = [
createRoute("errorLogs", "/error_logs", ErrorLogs),
createRoute("navdestination", "/navigation", NavDestination),
createRoute("root", "/", Home),
createRoute("route", "/routes/:routeDate", RecordedRoute),
createRoute("routes", "/routes", RecordedRoutes),
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("NAME", "/URL_PATH", ComponentName), <-- Insert your component here
];
```
3. **Add to sidebar**
Modify `sidebar.js` to include your new route:
```js
const MenuItems = {
...
navigation: [
{
name: "Set destination",
link: "/navigation",
icon: "bi-globe-americas",
},
{
name: "Sidebar title", <-- Add your new page here
link: "/URL_PATH", <-- Same url as in router.js
icon: "" <-- Add an icon here (Bootstrap icons are used, see https://icons.getbootstrap.com/
},
],
...
}
```
4. **Create the component**
Here's a simple example component:
```js
import { html, reactive } from "https://esm.sh/@arrow-js/core"
export function MyComponent() {
const state = reactive({
message: "Hello from MyComponent"
})
return html`
<div>
<h1>${state.message}</h1>
</div>
`
}
```
---
# Running The Pond
### Using Docker
```bash
docker build -t the_pond .
docker run -v $(pwd):/app --rm -ti -p 8084:8084 the_pond
```
### Run and debug on comma device (or computer with python)
```bash
./start.sh
```
@@ -0,0 +1,84 @@
.disk .bar {
background-color: var(--main-bg);
height: 100%;
position: absolute;
right: 0;
}
.disk .progress {
background: linear-gradient(to right, #5ec8c8 0%, #8b6cc5 60%, #e05577 85%, #c04466 100%);
border-radius: var(--border-radius-md);
height: var(--padding-base);
overflow: hidden;
position: relative;
}
.diskUsage,
.drivingStats,
.softwareInfo {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 80%;
padding: var(--border-radius-xl);
}
.diskUsage .disk h4,
.diskUsage .disk p,
.drivingStat div p {
margin: 0;
}
.diskUsage p,
.softwareGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.softwareGrid p {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.drivingStat {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
margin-bottom: var(--border-radius-xl);
}
.drivingStat div p:first-of-type {
font-size: var(--font-size-xl);
}
.drivingStat h2 {
font-size: var(--font-size-base);
grid-column: span 3;
padding: 0;
}
.drivingStat:last-of-type h2 {
color: var(--success-fg);
}
.softwareGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(3, 1fr);
}
.diskUsage:hover,
.drivingStats:hover,
.softwareInfo:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.softwareGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
@@ -0,0 +1,152 @@
import { html } from "/assets/vendor/arrow-core.js";
const HOME_STATE = {
status: "loading", // loading | ready | error
data: null,
unit: "miles",
error: "",
initialized: false,
};
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
promise.then((value) => {
clearTimeout(timerId);
resolve(value);
}).catch((err) => {
clearTimeout(timerId);
reject(err);
});
});
}
function formatInt(value) {
const n = Number(value || 0);
return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0";
}
function renderDiskUsageSection(state) {
const { data } = state;
const shell = document.getElementById("home_shell");
if (!shell) return;
if (state.status === "error") {
shell.innerHTML = `<p class="error">Failed to load data: ${state.error}</p>`;
return;
}
if (state.status !== "ready" || !data) {
shell.innerHTML = "<p>Loading...</p>";
return;
}
const driveStats = data.driveStats || {};
const softwareInfo = data.softwareInfo || {};
const diskUsage = Array.isArray(data.diskUsage) ? data.diskUsage : [];
const diskError = Array.isArray(data.diskError) ? data.diskError : [];
const statBlock = (title, stats = {}) => `
<div class="drivingStat">
<h2>${title}</h2>
<div><p>${formatInt(stats.drives)}</p><p>drives</p></div>
<div><p>${formatInt(stats.distance)}</p><p>${stats.unit || state.unit}</p></div>
<div><p>${formatInt(stats.hours)}</p><p>hours</p></div>
</div>
`;
const diskBlock = (disk = {}) => {
const usedPct = Number.parseFloat(disk.usedPercentage) || 0;
const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)";
return `
<div class="disk">
<p>${disk.used || "0 GB"} used of ${disk.size || "0 GB"}</p>
<div class="progress">
<div
class="bar"
style="
border-bottom-right-radius: ${rightRadius};
border-top-right-radius: ${rightRadius};
width: ${Math.max(0, 100 - usedPct)}%;
"
></div>
</div>
</div>
`;
};
const softwareFields = [
["Branch Name", softwareInfo.branchName],
["Build", softwareInfo.buildEnvironment],
["Commit Hash", softwareInfo.commitHash],
["Fork Maintainer", softwareInfo.forkMaintainer],
["Update Available", softwareInfo.updateAvailable],
["Version Date", softwareInfo.versionDate],
];
const softwareMarkup = softwareFields
.map(([label, value]) => `<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`)
.join("");
const diskMarkup = diskError.length
? `<p>${diskError.join("<br>")}</p>`
: (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({}));
shell.innerHTML = `
<div>
<h1>Galaxy</h1>
<div class="drivingStats">
${statBlock("All Time", driveStats.all)}
${statBlock("Past Week", driveStats.week)}
${statBlock("StarPilot", driveStats.frogpilot)}
</div>
<h2>Disk Usage</h2>
<div class="diskUsage">
${diskMarkup}
</div>
<h2>Software Info</h2>
<div class="softwareInfo">
<div class="softwareGrid">${softwareMarkup}</div>
</div>
</div>
`;
}
async function initializeHome() {
try {
const [statsResponse, unitResponse] = await Promise.all([
withTimeout(fetch("/api/stats"), 5000, "stats request"),
withTimeout(fetch("/api/params?key=IsMetric"), 5000, "metric request"),
]);
if (!statsResponse.ok) throw new Error(`stats API error: ${statsResponse.status}`);
if (!unitResponse.ok) throw new Error(`params API error: ${unitResponse.status}`);
const statsJson = await withTimeout(statsResponse.json(), 5000, "stats JSON parse");
const isMetricText = (await withTimeout(unitResponse.text(), 5000, "metric read")).trim();
HOME_STATE.data = statsJson;
HOME_STATE.unit = isMetricText === "1" ? "kilometers" : "miles";
HOME_STATE.status = "ready";
} catch (err) {
HOME_STATE.status = "error";
HOME_STATE.error = err?.message || String(err);
}
renderDiskUsageSection(HOME_STATE);
}
export function Home() {
setTimeout(() => {
renderDiskUsageSection(HOME_STATE);
if (!HOME_STATE.initialized) {
HOME_STATE.initialized = true;
initializeHome();
}
}, 0);
return html`<div id="home_shell"><p>Loading...</p></div>`;
}
@@ -0,0 +1,425 @@
:root {
/* Borders */
--border-color-base: var(--sidebar-border-color);
--border-radius-sm: 4px;
--border-radius-base: 5px;
--border-radius-md: 8px;
--border-radius-lg: 10px;
--border-radius-xl: 1rem;
--border-width-thin: 1px;
--border-width-base: 2px;
--border-width-thick: 5px;
--border-solid: solid;
--border-style-base: solid;
--border-style-input: 1px solid var(--sidebar-border-color);
/* Breakpoints */
--breakpoint-xs: 360px;
--breakpoint-sm: 480px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
/* Colors — Galaxy palette (cosmic purple · teal · rose · amber) */
--accent-bg: #8b6cc5;
--accent-hover-bg: #7558b0;
--card-bg: #121224;
--color-black: #000000;
--color-confirm: #8b6cc5;
--color-confirm-hover: #7558b0;
--color-gray-100: #f0f0f8;
--color-gray-200: #d8d8e4;
--color-gray-300: #b8b8cc;
--color-gray-400: #9898b0;
--color-gray-500: #7e7e98;
--color-gray-600: #636380;
--color-gray-700: #4a4a64;
--color-gray-800: #2e2e44;
--color-gray-900: #1a1a30;
--color-white: #ffffff;
--danger-bg: #e05577;
--danger-fg: #e05577;
--danger-hover-bg: #c04466;
--glow-primary: 0 0 0 2px var(--main-fg), 0 0 10px rgba(139, 108, 197, 0.35);
--input-bg: #161630;
--main-bg: #06060f;
--main-fg: #8b6cc5;
--secondary-bg: #0e0e1a;
--selected-camera-bg: #0b0b18;
--sidebar-active-bg: rgba(139, 108, 197, 0.15);
--sidebar-bg: #0a0a16;
--sidebar-border-color: #1e1e3e;
--sidebar-fg: #ffffff;
--sidebar-title-fg: #8b6cc5;
--success-bg: #5ec8c8;
--success-fg: #5ec8c8;
--success-hover-bg: #4ab3b3;
--switch-inactive-bg: #1e1e3e;
--text-color: #e8e8f0;
--text-muted: #8080a8;
--text-on-primary: var(--sidebar-fg);
--text-on-surface: var(--text-color);
--thumb-color: #8b6cc5;
--track-color: #14142e;
--warning-bg: #d4a060;
--warning-hover-bg: #b8884a;
/* Effects */
--disabled-opacity: 0.5;
--focus-ring-color: var(--main-fg);
--hover-opacity: 0.85;
--hover-scale-sm: scale(1.01);
--hover-scale-base: scale(1.05);
--hover-scale-lg: scale(1.1);
/* Fonts */
--font-body: "Inter", "Open Sans", sans-serif;
--font-mono: "Courier New", Courier, monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.85rem;
--font-size-base: 1rem;
--font-size-lg: 1.2rem;
--font-size-xl: 1.5rem;
--font-weight-normal: 400;
--font-weight-demi-bold: 550;
--font-weight-bold: 700;
--heading-font: var(--font-body);
/* Layout */
--max-width-content: 700px;
--sidebar-width: 250px;
--width-xs: 100px;
--width-sm: 150px;
--width-md: 300px;
--width-lg: 500px;
--width-xl: 750px;
--width-xxl: 900px;
--width-xxxl: 1050px;
--width-xxxxl: 1200px;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
/* Spacing */
--gap-xxs: 0.125rem;
--gap-xs: 0.25rem;
--gap-sm: 0.5rem;
--gap-md: 1rem;
--gap-lg: 1.5rem;
--gap-xl: 2rem;
--gap-xxl: 3rem;
--margin-xxs: 0.125rem;
--margin-xs: 0.3rem;
--margin-sm: 0.5rem;
--margin-base: 1rem;
--margin-lg: 1.5rem;
--margin-xl: 2rem;
--margin-xxl: 3rem;
--padding-xxs: 0.125rem;
--padding-xs: 0.3rem;
--padding-sm: 0.5rem;
--padding-base: 1rem;
--padding-lg: 1.5rem;
--padding-xl: 2rem;
--padding-xxl: 3rem;
--space-xxs: 0.125rem;
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-xxl: 3rem;
/* Transitions */
--transition-fast: 0.15s ease-in-out;
--transition-base: 0.3s ease;
--transition-slow: 0.5s ease;
/* Typography */
--letter-spacing-base: 0;
--line-height-base: 1.5;
/* Z-Index */
--z-overlay: 800;
--z-sidebar: 900;
--z-modal: 1000;
--z-tooltip: 1100;
}
/* ――― Base elements ――― */
a.button {
background-color: var(--sidebar-active-bg);
border: none;
border-radius: var(--border-radius-base);
color: var(--main-fg);
display: inline-block;
margin: var(--margin-base) 0;
padding: var(--padding-sm) var(--padding-base);
text-align: center;
text-decoration: none;
}
/* clickables share the pointer */
a[href],
button,
[role="button"],
input[type="button"],
input[type="submit"],
input[type="checkbox"],
input[type="radio"],
.manage-keys-link,
.route_card,
.sidebar .menu_section>li>ul>li,
.clickable {
cursor: pointer;
}
body {
padding-bottom: 0 !important;
}
/* ――― Layout containers ――― */
.content {
color: var(--main-fg);
flex-grow: 1;
margin-left: var(--sidebar-width, 250px);
overflow-y: auto;
padding-bottom: var(--padding-xl);
padding-left: var(--padding-lg);
}
/* ――― Headings ――― */
h1,
h2,
h3 {
color: var(--text-color);
margin: 0;
padding: var(--padding-base) 0 var(--heading-bottom, 10px);
}
/* ――― Helpers & states ――― */
.hidden {
display: none;
}
html {
cursor: default;
}
.no_scroll {
overflow: hidden;
}
.not_implemented {
cursor: not-allowed;
text-decoration: line-through;
}
/* ――― Base typography & page reset ――― */
html,
body {
background-color: var(--main-bg);
font-family: var(--font-body);
margin: 0;
min-height: 100dvh;
overflow-x: hidden;
padding: 0;
}
/* weight-override set once for all matching selectors */
html,
body,
h1,
h2,
h3,
.open-sans-regular {
font-family: var(--font-body);
font-optical-sizing: auto;
font-style: normal;
font-weight: var(--font-weight-demi-bold);
}
/* ――― HR line ――― */
hr {
background-color: var(--sidebar-border-color);
border: none;
height: var(--border-width-thin);
}
/* text-inputs keep the I-beam */
input[type="email"],
input[type="number"],
input[type="password"],
input[type="search"],
input[type="text"],
.navkeys-input,
.settings .textinput,
textarea {
cursor: text;
}
label[for] {
cursor: default;
}
/* ――― Snackbar component ――― */
.snackbar {
background-color: var(--color-confirm);
border-radius: var(--border-radius-base);
color: var(--text-color);
margin-bottom: var(--margin-base);
padding: var(--padding-base);
text-align: center;
transition: var(--snackbar-transition, 1s);
visibility: hidden;
}
.snackbar.show {
animation: fadein var(--snackbar-fade-time, 0.5s);
visibility: visible;
}
/* zero-ed default list / link styles */
ul,
li,
a {
display: block;
list-style: none;
margin: 0;
padding: 0;
}
/* ――― Snackbar wrapper ――― */
#snackbar_wrapper {
bottom: var(--snackbar-offset, 30px);
left: var(--sidebar-width);
margin: 0 auto;
position: fixed;
right: 0;
width: min(var(--snackbar-width, 300px), 70%);
z-index: var(--z-overlay);
}
/* ――― Animations ――― */
@keyframes fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: var(--snackbar-offset, 30px);
opacity: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* ――― Breakpoint overrides ――― */
@media only screen and (max-width: var(--breakpoint-md)) {
.content {
margin-left: 0;
}
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.content {
box-sizing: border-box;
margin-left: 0;
padding-left: 1rem;
padding-right: 1rem;
}
#snackbar_wrapper {
left: 50%;
transform: translateX(-50%);
width: 80%;
}
}
/* legacy WebKit keyframes */
@-webkit-keyframes fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: var(--snackbar-offset, 30px);
opacity: 1;
}
}
@-webkit-keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* ——— Tunnel notice banner ——— */
.tunnel-notice {
align-items: center;
background: linear-gradient(135deg, var(--card-bg), var(--secondary-bg));
border: var(--border-width-thin) var(--border-style-base) var(--sidebar-border-color);
border-left: 3px solid var(--main-fg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--text-color);
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin: var(--margin-xl) auto;
max-width: var(--width-xl);
padding: var(--padding-xl) var(--padding-xxl);
text-align: center;
}
.tunnel-notice-icon {
font-size: 2.5rem;
opacity: 0.8;
}
.tunnel-notice-title {
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin: 0;
}
.tunnel-notice-body {
color: var(--text-muted);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
margin: 0;
}
::-webkit-scrollbar {
height: 8px;
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--track-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--thumb-color);
border-radius: var(--border-radius-sm);
}
@@ -0,0 +1,107 @@
.modal {
background-color: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
color: var(--text-color);
max-width: var(--width-lg);
padding: var(--padding-sm);
text-align: center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
width: 90%;
}
.modal-actions {
display: flex;
gap: var(--gap-sm);
justify-content: center;
}
.modal-actions .btn {
background-color: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.modal-actions .btn.btn-danger {
background-color: var(--danger-bg);
color: var(--text-on-surface);
}
.modal-actions .btn.btn-danger:hover {
background-color: var(--danger-hover-bg);
transform: var(--hover-scale-base);
}
.modal-actions .btn.btn-primary {
background-color: var(--color-confirm);
color: var(--text-on-surface);
}
.modal-actions .btn.btn-primary:hover {
background-color: var(--color-confirm-hover);
transform: var(--hover-scale-base);
}
.modal-actions .btn:hover {
background-color: var(--success-hover-bg);
transform: var(--hover-scale-base);
}
.modal-body {
color: var(--text-base);
font-size: var(--font-size-base);
margin-bottom: 20px;
}
.modal-body strong {
color: var(--text-color);
}
.modal-header {
background-color: var(--sidebar-active-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin-bottom: var(--font-size-base);
padding: var(--padding-sm);
text-align: center;
}
.modal-input {
background-color: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-sm);
box-sizing: border-box;
color: var(--text-color);
font-size: var(--font-size-base);
padding: var(--padding-sm) var(--padding-base);
width: 100%;
}
.modal-overlay {
align-items: flex-start;
display: flex;
height: 100%;
justify-content: center;
left: 0;
padding-left: 137px;
padding-top: 5vh;
position: fixed;
top: 0;
width: 100%;
z-index: var(--z-tooltip);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.modal-overlay {
padding-left: 0px;
}
}
@@ -0,0 +1,40 @@
import { html } from "/assets/vendor/arrow-core.js";
export function Modal({
title,
message,
onConfirm,
onCancel,
confirmText = "Confirm",
cancelText = "Cancel",
confirmClass = "btn-danger",
customClass = ""
}) {
return html`
<div class="modal-overlay" tabindex="0"
@click="${(e) => {
if (e.target.classList.contains('modal-overlay')) {
onCancel && onCancel();
}
}}"
@keydown="${(e) => {
if (e.key === 'Enter' && onConfirm) {
e.preventDefault();
onConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel && onCancel();
}
}}"
>
<div class="modal ${customClass}">
<div class="modal-header">${title}</div>
<div class="modal-body">${message}</div>
<div class="modal-actions">
${onCancel ? html`<button class="btn" @click="${onCancel}">${cancelText}</button>` : ''}
${onConfirm ? html`<button class="btn ${confirmClass}" @click="${onConfirm}">${confirmText}</button>` : ''}
</div>
</div>
</div>
`;
}
@@ -0,0 +1,536 @@
#map {
height: 100%;
width: 100%;
}
#search-field {
align-items: center;
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--track-color);
border-radius: var(--border-radius-xl);
color: var(--text-color);
display: flex;
flex-direction: column;
font-size: var(--font-size-sm);
justify-content: center;
padding: var(--border-radius-xl);
transition: box-shadow 0.2s, border-color 0.2s;
width: 100%;
}
#search-field:focus {
border-color: var(--thumb-color);
box-shadow: 0 0 var(--border-radius-sm) var(--thumb-color);
outline: none;
}
#search-field::placeholder {
color: var(--text-muted);
}
#searchSuggestions p {
font-size: var(--font-size-sm);
margin: 0;
max-width: calc(100% - 8rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#searchSuggestions .suggestion-item {
align-items: center;
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--track-color);
box-sizing: border-box;
color: var(--text-color);
display: flex;
justify-content: space-between;
padding: var(--border-radius-xl);
position: relative;
width: 100%;
}
#searchSuggestions .suggestion-item:first-child {
border-top-left-radius: var(--border-radius-xl);
border-top-right-radius: var(--border-radius-xl);
}
#searchSuggestions .suggestion-item:last-child {
border-bottom-left-radius: var(--border-radius-xl);
border-bottom-right-radius: var(--border-radius-xl);
}
#searchSuggestions .suggestion-item:hover {
background-color: var(--main-fg);
color: var(--text-color) !important;
cursor: pointer;
}
.favorite-actions {
display: flex;
gap: 0.5rem;
}
.favorite-item {
align-items: center;
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--track-color);
border-radius: var(--border-radius-md);
display: flex;
gap: var(--padding-xs);
justify-content: space-between;
margin-bottom: var(--padding-xs);
padding: var(--border-radius-xl);
}
.favorite-marker {
cursor: pointer;
font-size: 24px;
text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5);
}
.favorite-marker:hover {
transform: scale(1.2);
}
.favorites-toggle-button {
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--track-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
min-width: 7rem;
padding: var(--padding-sm);
transition: box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.favorites-toggle-button:hover {
background-color: var(--main-fg);
box-shadow: var(--glow-primary);
color: var(--text-color);
font-weight: var(--font-weight-bold);
transform: var(--hover-scale-sm);
}
.home-favorite-button,
.work-favorite-button,
.edit-favorite-button,
.remove-favorite-button {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
transition: transform 0.1s ease;
}
.home-favorite-button:hover,
.work-favorite-button:hover,
.edit-favorite-button:hover,
.remove-favorite-button:hover {
transform: scale(1.2);
}
.home-favorite-button,
.work-favorite-button,
.edit-favorite-button {
color: var(--thumb-color);
}
.keys-required-button {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-weight: var(--font-weight-demi-bold);
padding: var(--border-radius-xl);
text-align: center;
text-decoration: none;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.keys-required-button:hover {
box-shadow: var(--shadow-md);
color: var(--secondary-fg);
transform: var(--hover-scale-sm);
}
.keys-required-text {
color: var(--text-color);
text-align: center;
}
.keys-required-title {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
padding: var(--padding-sm);
text-align: center;
width: 100%;
}
.keys-required-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--main-fg);
display: flex;
flex-direction: column;
margin-top: var(--padding-xl);
max-width: var(--width-lg);
padding: var(--padding-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.keys-required-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.keys-required-wrapper {
display: flex;
justify-content: center;
}
.loading-status {
align-items: center;
display: flex;
flex-direction: row;
font-size: 1.1em;
gap: 0.5em;
justify-content: flex-start;
padding: var(--padding-lg);
}
.manage-keys-link {
background-color: var(--main-fg);
border: var(--border-width-thin) solid var(--track-color);
border-radius: var(--border-radius-lg);
color: var(--main-fg);
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
justify-content: center;
padding: var(--border-radius-xl);
text-align: center;
text-decoration: none;
transition: background-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
}
.manage-keys-link:hover {
background-color: var(--input-bg);
color: var(--main-fg);
transform: var(--hover-scale-sm);
}
.map-wrapper {
height: 100vh;
left: var(--sidebar-width);
position: absolute;
width: calc(100vw - var(--sidebar-width));
}
.mapboxgl-popup-content {
background-color: var(--secondary-bg) !important;
border-radius: var(--border-radius-md) !important;
color: var(--text-color) !important;
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-xs) var(--padding-sm) !important;
}
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
border-top-color: var(--secondary-bg) !important;
}
.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
border-right-color: var(--secondary-bg) !important;
}
.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
border-left-color: var(--secondary-bg) !important;
}
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
border-bottom-color: var(--secondary-bg) !important;
}
.navigation-summary-title {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin-bottom: var(--padding-md);
text-align: center;
width: 100%;
}
.navigation-summary-widget {
align-items: stretch;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--text-color);
display: flex;
flex-direction: column;
margin-top: var(--padding-md);
max-width: var(--width-lg);
padding: var(--padding-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.navigation-summary-widget .buttonCluster {
display: flex;
gap: var(--padding-sm);
justify-content: center;
}
.navigation-summary-widget .buttonCluster i {
vertical-align: middle;
}
.navigation-summary-widget .navigation-details {
flex: 1;
}
.navigation-summary-widget .summary-row {
align-items: center;
display: grid;
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
grid-template-columns: 2rem 6rem 1fr;
}
.navigation-summary-widget .summary-row .emoji {
align-items: center;
display: flex;
font-size: var(--font-size-base);
height: 2rem;
justify-content: center;
}
.navigation-summary-widget button {
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--track-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
letter-spacing: var(--border-width-base);
margin: var(--border-radius-sm);
padding: var(--padding-xs) var(--padding-lg);
transition: background-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.navigation-summary-widget button.cancel {
background-color: var(--danger-bg);
}
.navigation-summary-widget button.cancel:hover {
background-color: var(--danger-hover-bg);
box-shadow: 0 0 0 2px var(--danger-fg), 0 0 8px var(--danger-fg);
color: var(--text-color);
transform: var(--hover-scale-sm);
}
.navigation-summary-widget button.directions {
background-color: var(--success-bg);
}
.navigation-summary-widget button.directions:hover {
background-color: var(--success-hover-bg);
box-shadow: var(--glow-primary);
color: var(--text-color);
transform: var(--hover-scale-sm);
}
.navigation-summary-widget button.favorite {
background-color: var(--danger-bg);
}
.navigation-summary-widget button.favorite:hover {
background-color: var(--danger-hover-bg);
box-shadow: 0 0 0 2px var(--danger-fg), 0 0 8px var(--danger-fg);
color: var(--text-color);
transform: var(--hover-scale-sm);
}
.navigation-summary-widget.horizontal-layout {
flex-direction: row;
gap: var(--padding-md);
justify-content: space-between;
}
.navigation-summary-widget.loading-status {
flex-direction: row;
}
.navigation-summary-widget p {
margin: var(--border-radius-xl) 0 0 var(--border-radius-xl);
}
.navigation-summary-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.next-step-panel {
align-items: center;
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
display: flex;
flex-direction: column;
justify-content: center;
min-width: 9rem;
padding: var(--padding-sm);
text-align: center;
}
.next-step-panel .step-icon {
font-size: 2rem;
margin-bottom: var(--padding-xs);
}
.next-step-panel .step-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
}
.remove-favorite-button {
color: var(--danger-fg);
}
.remove-favorite-button:hover {
color: var(--danger-hover-bg);
}
.route-tooltip .mapboxgl-popup-content {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-sm);
color: var(--color-white);
font-size: var(--font-size-base);
}
.route-tooltip .tooltip-row {
column-gap: var(--gap-xxs);
display: grid;
grid-template-columns: 1.4rem 5.5rem auto;
}
.route-tooltip .tooltip-row .emoji {
text-align: center;
}
.search-controls {
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: var(--border-radius-sm);
}
.search-provider-toggle {
display: flex;
flex-shrink: 0;
gap: var(--border-radius-md);
margin-left: var(--padding-xs);
}
.search-provider-toggle button {
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--track-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
min-width: 7rem;
padding: var(--padding-sm) var(--padding-xl);
transition: box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.search-provider-toggle button.active {
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.search-provider-toggle button:hover {
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.search-wrapper {
left: var(--border-radius-xl);
position: absolute;
top: var(--border-radius-xl);
width: 30%;
z-index: 1;
}
.search-wrapper #infobox .navigation-summary-widget {
align-items: flex-start;
animation: fadeInUp 0.3s ease-out;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-md);
color: var(--text-color);
display: flex;
flex-direction: column;
justify-content: center;
margin-top: var(--margin-sm);
overflow: hidden;
padding: var(--padding-sm);
}
.search-wrapper #infobox .navigation-summary-widget.loading-status {
align-items: center;
display: flex;
flex-direction: row;
gap: var(--padding-sm);
justify-content: flex-start;
padding-left: var(--padding-base);
}
.spinner {
animation: spin 1s linear infinite;
border: var(--border-width-base) solid var(--color-gray-300);
border-radius: 50%;
border-top: var(--border-width-base) solid var(--color-gray-900);
height: 1em;
width: 1em;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.map-wrapper {
left: 0;
width: 100vw;
}
.search-wrapper {
left: var(--padding-sm);
min-width: 0;
width: calc(100% - var(--padding-xl));
}
}
@@ -0,0 +1,855 @@
import { html, reactive } from "/assets/vendor/arrow-core.js";
import {
addRouteToMap,
formatMetersToHuman,
formatSecondsToHuman,
getCoordinatesFromSearch,
getRoutes,
removeRouteFromMap,
getOrdinalSuffix,
highlightRoute,
} from "./navigation_utilities.js";
import { Modal } from "/assets/components/modal.js";
function sha1hex(str) {
const rot = (v, s) => (v << s) | (v >>> (32 - s));
const bytes = new TextEncoder().encode(str);
const words = [];
for (let i = 0; i < bytes.length; i++) {
words[i >> 2] |= bytes[i] << ((3 - (i & 3)) << 3);
}
const bitLen = bytes.length << 3;
words[bitLen >> 5] |= 0x80 << (24 - (bitLen & 31));
words[((bitLen + 64 >> 9) << 4) + 15] = bitLen;
let [a, b, c, d, e] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
const w = new Array(80);
for (let i = 0; i < words.length; i += 16) {
for (let t = 0; t < 16; t++) w[t] = words[i + t] | 0;
for (let t = 16; t < 80; t++) {
w[t] = rot(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1);
}
let [aa, bb, cc, dd, ee] = [a, b, c, d, e];
for (let t = 0; t < 80; t++) {
const k = t < 20 ? 0x5a827999 : t < 40 ? 0x6ed9eba1 : t < 60 ? 0x8f1bbcdc : 0xca62c1d6;
const f = t < 20 ? (bb & cc) | (~bb & dd) : t < 40 ? bb ^ cc ^ dd : t < 60 ? (bb & cc) | (bb & dd) | (cc & dd) : bb ^ cc ^ dd;
const tmp = (rot(aa, 5) + f + ee + k + w[t]) >>> 0;
ee = dd;
dd = cc;
cc = rot(bb, 30) >>> 0;
bb = aa;
aa = tmp;
}
a = (a + aa) >>> 0;
b = (b + bb) >>> 0;
c = (c + cc) >>> 0;
d = (d + dd) >>> 0;
e = (e + ee) >>> 0;
}
return [a, b, c, d, e].map(x => x.toString(16).padStart(8, "0")).join("");
}
async function geometryHashFromRoute(route) {
const flat = route.geometry.coordinates.flat().join(",");
if (crypto?.subtle?.digest && window.isSecureContext) {
const buf = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(flat));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}
return sha1hex(flat);
}
async function setSpecial(favorite, type, state, loadFavoritesAlphabetically) {
try {
const isCurrentlyHome = favorite.is_home;
const isCurrentlyWork = favorite.is_work;
let newIsHome = null;
let newIsWork = null;
let message = "";
if (type === "home") {
if (isCurrentlyHome) {
newIsHome = false;
message = "Home location removed!";
} else {
newIsHome = true;
if (isCurrentlyWork) newIsWork = false;
message = "Home location set!";
}
} else if (type === "work") {
if (isCurrentlyWork) {
newIsWork = false;
message = "Work location removed!";
} else {
newIsWork = true;
if (isCurrentlyHome) newIsHome = false;
message = "Work location set!";
}
}
const body = { routeId: favorite.routeId, id: favorite.id };
if (newIsHome !== null) body.is_home = newIsHome;
if (newIsWork !== null) body.is_work = newIsWork;
await fetch("/api/navigation/favorite/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
showSnackbar(message);
const sorted = await loadFavoritesAlphabetically();
state.suggestions = "[]";
await new Promise(resolve => setTimeout(resolve, 0));
state.suggestions = JSON.stringify(sorted);
} catch {
showSnackbar(`Failed to update ${type} location...`);
}
}
let mapboxLoadPromise = null;
function loadMapboxGL() {
if (mapboxLoadPromise) return mapboxLoadPromise;
mapboxLoadPromise = new Promise((resolve, reject) => {
const link = document.createElement("link");
link.href = "https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css";
link.rel = "stylesheet";
document.head.appendChild(link);
const script = document.createElement("script");
script.src = "https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js";
script.onload = resolve;
script.onerror = () => reject(new Error("Failed to load Mapbox GL"));
document.head.appendChild(script);
});
return mapboxLoadPromise;
}
export function NavDestination() {
let map;
let destinationMarker;
let favoriteMarkers = [];
const state = reactive({
amap1Key: undefined,
amap2Key: undefined,
canToggleProvider: false,
confirmedRoute: null,
confirmedRouteRefresh: 0,
destination: undefined,
favoriteToRemove: null,
favoriteToRename: null,
favoritesCount: 0,
favoritesVisible: false,
initialized: false,
isMetric: true,
lastPosition: undefined,
loadingRoute: false,
mapboxPublic: undefined,
mapboxSecret: undefined,
missingKeys: null,
newFavoriteName: "",
previousDestinations: "[]",
searchProvider: "mapbox",
selectedRoute: null,
showRemoveFavoriteModal: false,
showRenameFavoriteModal: false,
suggestions: "[]"
});
const searchFieldState = reactive({ value: "" });
const sessionToken = crypto.randomUUID?.() || Math.random().toString(36).slice(2);
function areRoutesEqual(a, b) {
return a?.routeHash && b?.routeHash && a.routeHash === b.routeHash;
}
function confirmRemoveFavorite(favorite) {
state.favoriteToRemove = favorite;
state.showRemoveFavoriteModal = true;
}
function confirmRenameFavorite(fav) {
state.favoriteToRename = fav;
state.newFavoriteName = fav.name;
state.showRenameFavoriteModal = true;
}
async function setHome(favorite) {
await setSpecial(favorite, "home", state, loadFavoritesAlphabetically);
}
async function setWork(favorite) {
await setSpecial(favorite, "work", state, loadFavoritesAlphabetically);
}
async function initiateNavigation(destination, { resume = false } = {}) {
state.selectedRoute = null;
state.confirmedRoute = null;
state.loadingRoute = true;
try {
const { name, longitude, latitude } = destination;
const coords = [longitude, latitude];
const inputEl = document.getElementById("search-field");
if (inputEl && !resume) {
inputEl.value = name;
}
if (destinationMarker) destinationMarker.remove();
destinationMarker = new mapboxgl.Marker().setLngLat(coords).addTo(map);
const routes = await getRoutes(
`${state.lastPosition.longitude},${state.lastPosition.latitude}`,
`${coords[0]},${coords[1]}`,
state.mapboxPublic
);
removeRouteFromMap(map);
if (routes.length > 0) {
const selectedRouteId = "main";
const selectedRouteData = routes[0];
const routeHash = await geometryHashFromRoute(selectedRouteData);
const selected = {
name,
duration: selectedRouteData.duration,
distance: selectedRouteData.distance,
destinationCoordinates: coords,
startingCoordinates: [state.lastPosition.longitude, state.lastPosition.latitude],
routeId: selectedRouteId,
routeHash,
steps: selectedRouteData?.legs?.[0]?.steps || []
};
state.selectedRoute = selected;
if (resume) state.confirmedRoute = JSON.parse(JSON.stringify(selected));
localStorage.setItem("lastRouteId", selected.routeId);
addRouteToMap(
map,
routes,
[state.lastPosition.longitude, state.lastPosition.latitude],
coords,
(route, routeId) => {
state.selectedRoute = {
...state.selectedRoute,
duration: route.duration,
distance: route.distance,
routeId,
steps: route?.legs?.[0]?.steps || []
};
highlightRoute(map, routes, routeId);
},
state.isMetric,
() => state.selectedRoute?.routeId ?? null
);
if (resume && map) {
requestAnimationFrame(() => {
map.flyTo({
center: [state.lastPosition.longitude, state.lastPosition.latitude],
zoom: 18,
pitch: 45,
speed: 1,
curve: 1
});
});
}
}
state.suggestions = "[]";
} catch (err) {
console.error("Failed to calculate route:", err);
showSnackbar("Failed to calculate route…");
} finally {
state.loadingRoute = false;
}
}
async function getNavigationData() {
const res = await fetch("/api/navigation");
const data = await res.json();
state.mapboxPublic = data.mapboxPublic.trim();
state.mapboxSecret = data.mapboxSecret.trim();
state.amap1Key = data.amap1Key?.trim() || "";
state.amap2Key = data.amap2Key?.trim() || "";
state.isMetric = data.isMetric ?? true;
const hasMapbox = !!state.mapboxPublic && !!state.mapboxSecret;
const hasAMap = !!state.amap1Key && !!state.amap2Key;
state.missingKeys = !hasMapbox;
state.canToggleProvider = hasMapbox && hasAMap;
state.searchProvider = hasMapbox ? "mapbox" : "";
if (state.missingKeys) return;
state.lastPosition = {
latitude: parseFloat(data.lastPosition.latitude),
longitude: parseFloat(data.lastPosition.longitude)
};
try {
state.destination = JSON.parse(data.destination);
} catch { }
try {
const prev = JSON.parse(data.previousDestinations);
state.previousDestinations = prev.map(d => ({ name: d.place_name }));
state.suggestions = JSON.stringify(state.previousDestinations);
} catch { }
try {
await setupMap();
} catch {
showSnackbar("Failed to load map resources…");
return;
}
loadFavoritesAlphabetically();
}
async function handleFavoritesClick() {
if (state.favoritesVisible) {
state.suggestions = "[]";
state.favoritesVisible = false;
return;
}
searchFieldState.value = "";
state.selectedRoute = null;
state.confirmedRoute = null;
const sorted = await loadFavoritesAlphabetically();
state.suggestions = JSON.stringify(sorted);
state.favoritesVisible = true;
}
async function handleSearchKey(e) {
if (e.key === "Enter") {
clearTimeout(window.searchTimeout);
const val = e.target.value.trim();
searchFieldState.value = e.target.value;
if (val.length < 3) {
if (val.length === 0) state.suggestions = "[]";
return;
}
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = "[]";
if (state.searchProvider === "mapbox") {
const prox = `${state.lastPosition.longitude},${state.lastPosition.latitude}`;
const params = new URLSearchParams({
proximity: prox,
access_token: state.mapboxPublic,
session_token: sessionToken,
q: val,
limit: 4
});
const res = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`);
const data = await res.json();
state.suggestions = JSON.stringify(data.suggestions);
} else {
const auto = new AMap.Autocomplete({ city: "auto" });
auto.search(val, (status, result) => {
if (status === "complete" && result.tips) {
state.suggestions = JSON.stringify(result.tips);
}
});
}
}
}
function isRouteFavorited(route, favorites) {
return favorites.some(fav =>
fav.latitude === route.destinationCoordinates[1] &&
fav.longitude === route.destinationCoordinates[0]
);
}
function addFavoriteMarkers(favorites) {
favoriteMarkers.forEach(marker => marker.remove());
favoriteMarkers = [];
favorites.forEach(fav => {
const el = document.createElement("div");
el.className = "favorite-marker";
let icon = "❤️";
let popupText = fav.name;
if (fav.is_home) {
icon = "🏠";
el.className += " home-marker";
popupText = `Home: ${fav.name}`;
} else if (fav.is_work) {
icon = "💼";
el.className += " work-marker";
popupText = `Work: ${fav.name}`;
}
el.innerHTML = icon;
const marker = new mapboxgl.Marker(el)
.setLngLat([fav.longitude, fav.latitude])
.setPopup(new mapboxgl.Popup({ offset: 25, closeButton: false }).setText(popupText))
.addTo(map);
el.addEventListener("click", () => {
if (marker.getPopup().isOpen()) {
marker.togglePopup();
}
initiateNavigation(fav);
});
el.addEventListener("mouseenter", () => marker.togglePopup());
el.addEventListener("mouseleave", () => marker.togglePopup());
favoriteMarkers.push(marker);
});
}
async function loadFavoritesAlphabetically() {
try {
const res = await fetch("/api/navigation/favorite");
const json = await res.json();
const sorted = json.favorites.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
state.favoritesCount = sorted.length;
state.favoriteRoutes = sorted;
addFavoriteMarkers(sorted);
if (state.favoritesVisible) {
state.suggestions = JSON.stringify(sorted);
}
return sorted;
} catch {
showSnackbar("Failed to load favorites...");
return [];
}
}
async function removeFavorite() {
if (!state.favoriteToRemove) return;
const { id, name, latitude, longitude, routeId } = state.favoriteToRemove;
try {
await fetch("/api/navigation/favorite", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name, latitude, longitude, routeId })
});
await loadFavoritesAlphabetically();
showSnackbar("Favorite removed!");
} catch {
showSnackbar("Failed to remove favorite...");
} finally {
state.showRemoveFavoriteModal = false;
state.favoriteToRemove = null;
}
}
async function renameFavorite() {
const fav = state.favoriteToRename;
const newName = state.newFavoriteName.trim();
if (!fav || !newName || newName === fav.name) {
state.showRenameFavoriteModal = false;
return;
}
try {
await fetch("/api/navigation/favorite", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fav)
});
await fetch("/api/navigation/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName,
longitude: fav.longitude,
latitude: fav.latitude,
routeId: fav.routeId
})
});
if (state.favoritesVisible) {
state.suggestions = "[]";
state.favoritesVisible = false;
}
handleFavoritesClick();
showSnackbar(`"${fav.name}" renamed to "${newName}"!`, "success");
} catch {
showSnackbar("Failed to edit favorite name…");
} finally {
state.showRenameFavoriteModal = false;
}
}
async function searchInput(e) {
const newVal = e.target.value.trim();
searchFieldState.value = e.target.value;
clearTimeout(window.searchTimeout);
window.searchTimeout = setTimeout(async () => {
const val = newVal;
if (val.length < 3) {
if (val.length === 0) {
state.suggestions = "[]";
}
return;
}
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = "[]";
if (state.searchProvider === "mapbox") {
const prox = `${state.lastPosition.longitude},${state.lastPosition.latitude}`;
const params = new URLSearchParams({
proximity: prox,
access_token: state.mapboxPublic,
session_token: sessionToken,
q: val,
limit: 4
});
const res = await fetch(`https://api.mapbox.com/search/searchbox/v1/suggest?${params}`);
const data = await res.json();
state.suggestions = JSON.stringify(data.suggestions);
} else {
const auto = new AMap.Autocomplete({ city: "auto" });
auto.search(val, (status, result) => {
if (status === "complete" && result.tips) {
state.suggestions = JSON.stringify(result.tips);
}
});
}
}, 800);
}
async function selectSuggestion(sugg) {
const label = sugg.full_address || sugg.name || sugg.address || "Unnamed Location";
let coords;
if (sugg.routeId) {
initiateNavigation({
name: sugg.name,
longitude: sugg.longitude,
latitude: sugg.latitude,
routeId: sugg.routeId
});
return;
}
state.loadingRoute = true;
try {
if (state.searchProvider === "mapbox") {
if (sugg.geometry && Array.isArray(sugg.geometry.coordinates)) {
coords = sugg.geometry.coordinates;
} else if (sugg.mapbox_id) {
const url = new URL(`https://api.mapbox.com/search/searchbox/v1/retrieve/${encodeURIComponent(sugg.mapbox_id)}`);
url.searchParams.set("access_token", state.mapboxPublic);
url.searchParams.set("session_token", sessionToken);
const ret = await fetch(url);
const retJson = await ret.json();
coords = retJson.features[0].geometry.coordinates;
} else {
coords = await getCoordinatesFromSearch(label, state.mapboxPublic);
}
} else {
coords = [sugg.location.lng, sugg.location.lat];
}
if (coords) {
initiateNavigation({
name: label,
longitude: coords[0],
latitude: coords[1],
routeId: null
});
} else {
throw new Error("Could not determine location.");
}
} catch (err) {
console.error(err);
showSnackbar("Error: Could not determine location.", "error");
state.loadingRoute = false;
}
}
const setupMap = async (retries = 0) => {
if (!state.mapboxPublic || state.initialized) return;
if (typeof mapboxgl === "undefined") {
await loadMapboxGL();
}
const container = document.getElementById("map");
if (!container) {
if (retries >= 50) return;
await new Promise(r => requestAnimationFrame(r));
return setupMap(retries + 1);
}
state.initialized = true;
mapboxgl.accessToken = state.mapboxPublic;
map = new mapboxgl.Map({
container,
center: [state.lastPosition.longitude, state.lastPosition.latitude],
zoom: 15,
pitch: 45,
speed: 1,
curve: 1,
attributionControl: false,
logoPosition: "bottom-right",
style: "mapbox://styles/frogsgomoo/cmcfv151j000o01rcdxebhl76"
});
new mapboxgl.Marker().setLngLat([state.lastPosition.longitude, state.lastPosition.latitude]).addTo(map);
map.on("load", () => {
map.flyTo({
center: [state.lastPosition.longitude, state.lastPosition.latitude],
zoom: 18,
pitch: 45,
speed: 1,
curve: 1
});
if (state.destination) {
const savedId = localStorage.getItem("activeRouteId");
initiateNavigation({ ...state.destination, routeId: savedId }, { resume: true });
}
});
map.on("style.load", () => {
const labelLayer = map.getStyle().layers.find(l => l.type === "symbol" && l.layout["text-field"]).id;
map.addLayer(
{
id: "add-3d-buildings",
source: "composite",
"source-layer": "building",
filter: ["==", "extrude", "true"],
type: "fill-extrusion",
minzoom: 15,
paint: {
"fill-extrusion-color": "#aaa",
"fill-extrusion-height": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.05, ["get", "height"]],
"fill-extrusion-base": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.05, ["get", "min_height"]],
"fill-extrusion-opacity": 0.6
}
},
labelLayer
);
});
};
getNavigationData();
return html`
<div class="navigation-container">
${() => {
if (state.missingKeys === null) return "";
return state.missingKeys
? html`
<section class="keys-required-wrapper">
<div class="keys-required-widget">
<div class="keys-required-title">Mapbox Keys Required</div>
<p class="keys-required-text">You must set both your public and secret Mapbox keys before using navigation features.</p>
<a href="/navigation_keys" class="keys-required-button">Go to "Manage Keys"</a>
</div>
</section>
`
: html`
<div class="map-wrapper">
<div class="search-wrapper">
<div class="search-controls">
<input autocomplete="off" id="search-field" placeholder="Search here" value="${() => searchFieldState.value}" @input="${searchInput}" @keydown="${handleSearchKey}" />
${() => (state.favoritesCount > 0 ? html`<button class="favorites-toggle-button" @click="${handleFavoritesClick}">❤️ Favorites</button>` : "")}
${() => (state.canToggleProvider ? html`
<div class="search-provider-toggle">
<button class="${() => (state.searchProvider === "amap" ? "active" : "")}" @click="${() => { state.searchProvider = "amap"; state.suggestions = "[]"; }}">AMap</button>
<button class="${() => (state.searchProvider === "mapbox" ? "active" : "")}" @click="${() => { state.searchProvider = "mapbox"; state.suggestions = "[]"; }}">Mapbox</button>
</div>
` : "")}
</div>
<div id="infobox">
${() => {
if (state.loadingRoute) {
return html`<div class="navigation-summary-widget loading-status"><span class="spinner"></span> Calculating route...</div>`;
} else if (state.selectedRoute) {
return NavigationDestination({
...state.selectedRoute,
isFavorited: isRouteFavorited(state.selectedRoute, state.favoriteRoutes),
isConfirmed: () => areRoutesEqual(state.selectedRoute, state.confirmedRoute),
map,
isMetric: state.isMetric,
cancelNavigationFn: () => {
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = state.previousDestinations;
if (destinationMarker) destinationMarker.remove();
},
onConfirm: () => {
state.confirmedRoute = JSON.parse(JSON.stringify(state.selectedRoute));
state.confirmedRouteRefresh = Math.random();
},
loadFavorites: loadFavoritesAlphabetically,
removeFavorite: confirmRemoveFavorite,
searchFieldState,
favoriteRoutes: state.favoriteRoutes
}, state.confirmedRouteRefresh);
} else if (JSON.parse(state.suggestions).length > 0) {
return SearchSuggestions({
suggestions: JSON.parse(state.suggestions),
selectSuggestion,
removeFavorite: confirmRemoveFavorite,
renameFavorite: confirmRenameFavorite,
setHome: setHome,
setWork: setWork
});
}
}}
</div>
</div>
<div id="map"></div>
</div>
`;
}}
</div>
${() => (state.showRemoveFavoriteModal ? Modal({
title: "Remove Favorite",
message: `Are you sure you want to remove <strong>${state.favoriteToRemove?.name}</strong> from your favorites?`,
onConfirm: removeFavorite,
onCancel: () => { state.showRemoveFavoriteModal = false; state.favoriteToRemove = null; },
confirmText: "Remove"
}) : "")}
${() => (state.showRenameFavoriteModal ? Modal({
title: "Rename Favorite",
message: html`
<div>
<p>Rename <strong>${state.favoriteToRename.name}</strong> to:</p>
<div style="margin-top: 10px;">
<input class="modal-input" type="text" value="${state.newFavoriteName}" @click="${e => e.stopPropagation()}" @input="${e => state.newFavoriteName = e.target.value}" />
</div>
</div>
`,
onConfirm: renameFavorite,
onCancel: () => { state.showRenameFavoriteModal = false; },
confirmText: "Rename",
confirmClass: "btn-primary"
}) : "")}
`;
}
function SearchSuggestions({ suggestions, selectSuggestion, removeFavorite, renameFavorite, setHome, setWork }) {
const isFavorite = s => s.name && s.latitude != null && s.longitude != null && s.routeId;
const item = s => html`
<div class="suggestion-item" @click="${() => selectSuggestion(s)}">
<p>
${s.is_home ? "🏠 " : ""}
${s.is_work ? "💼 " : ""}
${s.name || s.address}
</p>
${isFavorite(s) ? html`
<div class="favorite-actions">
<button class="home-favorite-button ${s.is_home ? "active" : ""}" title="Set as Home" @click="${e => { e.stopPropagation(); setHome(s); }}">🏠</button>
<button class="work-favorite-button ${s.is_work ? "active" : ""}" title="Set as Work" @click="${e => { e.stopPropagation(); setWork(s); }}">💼</button>
<button class="edit-favorite-button" title="Rename Favorite" @click="${e => { e.stopPropagation(); renameFavorite(s); }}"></button>
<button class="remove-favorite-button" title="Remove from Favorites" @click="${e => { e.stopPropagation(); removeFavorite(s); }}">🗑</button>
</div>
` : ""}
</div>
`;
return html`<div id="searchSuggestions">${suggestions.map(item)}</div>`;
}
function NavigationDestination({
name,
duration,
distance,
routeId,
routeHash,
isConfirmed,
destinationCoordinates,
startingCoordinates,
isMetric,
map,
cancelNavigationFn,
onConfirm,
loadFavorites,
removeFavorite,
searchFieldState,
isFavorited,
favoriteRoutes = [],
steps = []
}) {
async function cancelNavigation() {
showSnackbar("Navigation cancelled...");
removeRouteFromMap(map);
cancelNavigationFn();
localStorage.removeItem("activeRouteId");
map.flyTo({ center: startingCoordinates, zoom: 15, pitch: 45, speed: 1, curve: 1 });
await fetch("/api/navigation", { method: "DELETE" });
}
async function confirmDestination() {
onConfirm?.();
showSnackbar("Navigation set!");
localStorage.setItem("activeRouteId", routeId);
await fetch("/api/navigation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
longitude: destinationCoordinates[0],
latitude: destinationCoordinates[1]
})
});
await loadFavorites();
const searchInputEl = document.getElementById("search-field");
if (searchInputEl) searchInputEl.value = "";
searchFieldState.value = "";
requestAnimationFrame(() => {
map?.flyTo({
center: startingCoordinates,
zoom: 18,
pitch: 45,
speed: 1,
curve: 1
});
});
}
async function favoriteDestination() {
try {
const res = await fetch("/api/navigation/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
longitude: destinationCoordinates[0],
latitude: destinationCoordinates[1],
routeId
})
});
const { message } = await res.json();
showSnackbar(message || "Added to favorites!");
await loadFavorites();
} catch {
showSnackbar("Failed to add to favorites…");
}
}
async function toggleFavorite() {
if (isFavorited) {
const fav = favoriteRoutes.find(
f => f.latitude === destinationCoordinates[1] && f.longitude === destinationCoordinates[0]
);
if (fav) {
removeFavorite(fav);
} else {
showSnackbar("Couldn't find favorite entry…");
}
} else {
await favoriteDestination();
}
}
const eta = new Date(Date.now() + duration * 1000);
const isLong = duration > 86400;
const timeStr = eta.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
const month = eta.toLocaleString([], { month: "long" });
const day = eta.getDate();
const year = eta.getFullYear();
const etaString = isLong ? `${month} ${day}${getOrdinalSuffix(day)}, ${year}, ${timeStr}` : timeStr;
return html`
<div class="navigation-summary-widget">
<div class="navigation-summary-title">${name}</div>
<div class="summary-row">
<span class="emoji">🛣</span>
<span class="label">Distance:</span>
<span class="value">${formatMetersToHuman(distance, isMetric)}</span>
</div>
<div class="summary-row">
<span class="emoji"></span>
<span class="label">Duration:</span>
<span class="value">${formatSecondsToHuman(duration)}</span>
</div>
<div class="summary-row">
<span class="emoji">🕗</span>
<span class="label">ETA:</span>
<span class="value">${etaString}</span>
</div>
<div class="buttonCluster">
${() =>
isConfirmed()
? html`<button class="cancel" @click="${cancelNavigation}"><i class="bi bi-x-lg"></i> Cancel Navigation</button>`
: html`<button class="directions" @click="${confirmDestination}"><i class="bi bi-sign-turn-right"></i> Start Navigation</button>`}
<button class="favorite" @click="${toggleFavorite}">${isFavorited ? "💔 Unfavorite" : "❤️ Favorite"}</button>
</div>
</div>
`;
}
@@ -0,0 +1,156 @@
.navkeys-btn {
all: unset;
background-color: var(--main-fg);
border-radius: var(--border-radius-sm);
cursor: pointer;
padding: var(--padding-xs) var(--padding-xs);
transition: background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.navkeys-btn.delete {
background-color: var(--danger-bg);
}
.navkeys-btn.delete:hover {
background-color: var(--danger-hover-bg);
transform: var(--hover-scale-base);
}
.navkeys-btn:hover:not(:disabled) {
background-color: var(--success-hover-bg);
transform: var(--hover-scale-base);
}
.navkeys-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.navkeys-container {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
flex: 1 1 400px;
max-width: var(--width-lg);
padding: var(--padding-sm) var(--font-size-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.navkeys-container:hover {
transform: var(--hover-scale-sm);
}
.navkeys-error {
color: var(--danger-bg);
font-size: var(--font-size-sm);
transition: opacity 0.5s ease;
}
.navkeys-group>.navkeys-row:last-of-type {
margin-bottom: 0;
}
.navkeys-help-icon {
cursor: pointer;
font-size: var(--font-size-sm);
margin-left: var(--gap-xxs);
position: relative;
top: -1px;
}
.navkeys-help-img img {
max-width: 100%;
}
.navkeys-input {
background-color: var(--input-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
border-radius: var(--border-radius-sm);
box-sizing: border-box;
color: var(--text-color);
cursor: text;
flex: 1;
font-size: var(--font-size-base);
padding: var(--padding-xs) var(--padding-sm);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
}
.navkeys-input:hover,
.navkeys-input:focus {
border-color: var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.navkeys-input::placeholder {
color: var(--text-muted);
}
.navkeys-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
margin-bottom: var(--margin-xs);
}
.navkeys-message {
color: var(--success-bg);
font-size: var(--font-size-sm);
transition: opacity 0.5s ease;
}
.navkeys-offset-top {
margin-top: var(--margin-xl);
}
.navkeys-row {
display: flex;
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
transition: transform var(--transition-fast);
}
.navkeys-row:hover {
transform: var(--hover-scale-sm);
}
.navkeys-status {
margin-top: var(--padding-xs);
min-height: var(--gap-lg);
}
.navkeys-title {
background-color: var(--sidebar-active-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin-bottom: var(--font-size-base);
padding: var(--padding-sm);
text-align: center;
}
.navkeys-wrapper {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: var(--gap-lg);
justify-content: center;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.navkeys-btn {
font-size: var(--font-size-sm);
}
.navkeys-container {
padding: var(--padding-sm);
width: 90%;
}
.navkeys-input {
font-size: var(--font-size-sm);
}
}
@@ -0,0 +1,315 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js";
export function NavKeys() {
const state = reactive({
initialMapboxComplete: false,
showMapboxHelp: false,
visible: false,
imageVersion: 0,
error: "",
lastGroup: "",
message: "",
amap1Key: "", amap2Key: "",
editA1: false, editA2: false,
savedA1: false, savedA2: false,
publicKey: "", secretKey: "",
editPublic: false, editSecret: false,
savedPublic: false, savedSecret: false,
showDeleteModal: false,
keyToDelete: null,
})
const bumpImageVersion = () => state.imageVersion++
let clearTimer = null
let fadeTimer = null
function showMessage(type, text, group) {
clearTimer && clearTimeout(clearTimer)
fadeTimer && clearTimeout(fadeTimer)
state.error = type === "error" ? text : ""
state.message = type === "message" ? text : ""
state.lastGroup = group
state.visible = true
clearTimer = setTimeout(() => { state.message = "", state.error = "" }, 5000)
fadeTimer = setTimeout(() => state.visible = false, 5000)
}
const util = {
prefix: (key, prefix) => key.startsWith(prefix) ? key : prefix ? prefix + key : key,
mask: (key) => {
if (!key) {
return ""
}
const prefix = ["pk.", "sk."].find(p => key.startsWith(p)) || ""
return prefix + "x".repeat(key.length - prefix.length)
},
req: async (url, opts) => {
const response = await fetch(url, opts)
return { ok: response.ok, data: await response.json().catch(() => ({})) }
}
}
const meta = {
amap1: { prop: "amap1Key", saved: "savedA1", edit: "editA1", prefix: "", body: "amap1", minLength: 39 },
amap2: { prop: "amap2Key", saved: "savedA2", edit: "editA2", prefix: "", body: "amap2", minLength: 39 },
public: { prop: "publicKey", saved: "savedPublic", edit: "editPublic", prefix: "pk.", body: "public", minLength: 80 },
secret: { prop: "secretKey", saved: "savedSecret", edit: "editSecret", prefix: "sk.", body: "secret", minLength: 80 }
}
const canSave = (kind) => {
const keyMeta = meta[kind];
if (!keyMeta) return false;
const value = state[keyMeta.prop]?.trim() || "";
if (!value) return false;
if (!state[keyMeta.saved]) {
const fullValue = util.prefix(value, keyMeta.prefix);
return fullValue.length >= keyMeta.minLength;
}
return false;
}
const getDeleteLabel = (kind) => {
switch (kind) {
case "amap1": return "Amap 1"
case "amap2": return "Amap 2"
case "public": return "Public Mapbox"
case "secret": return "Secret Mapbox"
default: return kind
}
}
const api = {
path: {
key: "/api/navigation_key",
nav: "/api/navigation"
},
load: async () => {
const { ok, data } = await util.req(api.path.nav)
if (!ok) {
return showMessage("error", "Failed to load keys...", "")
}
state.amap1Key = data.amap1Key ?? ""
state.amap2Key = data.amap2Key ?? ""
state.savedA1 = !!state.amap1Key
state.savedA2 = !!state.amap2Key
state.publicKey = data.mapboxPublic ?? ""
state.secretKey = data.mapboxSecret ?? ""
state.savedPublic = !!state.publicKey
state.savedSecret = !!state.secretKey
state.initialMapboxComplete = state.savedPublic && state.savedSecret
bumpImageVersion()
},
save: (kind) => async () => {
const group = kind.startsWith("amap") ? "amap" : "mapbox"
const keyMeta = meta[kind]
const value = util.prefix(state[keyMeta.prop].trim(), keyMeta.prefix)
const { ok, data } = await util.req(api.path.key, {
body: JSON.stringify({ [keyMeta.body]: value }),
headers: { "Content-Type": "application/json" },
method: "POST"
})
if (!ok) {
const input = document.getElementById(`${kind}-key`)
if (input) {
input.value = ""
state[keyMeta.edit] = true
state[keyMeta.saved] = false
state[keyMeta.prop] = ""
input.focus()
}
return showMessage("error", data.error || "Save failed...", group)
}
Object.assign(state, {
[keyMeta.edit]: false,
[keyMeta.saved]: true,
[keyMeta.prop]: value
})
const input = document.getElementById(`${kind}-key`)
if (input) {
input.blur()
input.value = ""
requestAnimationFrame(() => { input.value = util.mask(state[keyMeta.prop]) })
}
if (group === "mapbox") {
bumpImageVersion()
}
showMessage("message", data.message || "Saved!", group)
},
confirmDelete: (kind) => {
state.keyToDelete = kind;
state.showDeleteModal = true;
},
delete: async () => {
const kind = state.keyToDelete;
if (!kind) return;
const group = kind.startsWith("amap") ? "amap" : "mapbox"
const keyMeta = meta[kind]
const { ok, data } = await util.req(`${api.path.key}?type=${kind}`, {
method: "DELETE"
})
state.showDeleteModal = false;
if (!ok) {
return showMessage("error", data.error || "Delete failed...", group)
}
Object.assign(state, {
[keyMeta.saved]: false,
[keyMeta.prop]: ""
})
if (group === "mapbox") {
state.initialMapboxComplete = false
bumpImageVersion()
}
showMessage("message", data.message || "Deleted!", group)
}
}
queueMicrotask(api.load)
function renderGroup(title, kinds) {
const isMapbox = title === "Mapbox Keys"
return html`
<div class="navkeys-group">
<div class="navkeys-title">
${title}
${isMapbox ? html`
<span class="navkeys-help-icon" @click="${() => state.showMapboxHelp = !state.showMapboxHelp}">
<i class="bi bi-question-circle-fill"></i>
</span>
` : ""}
</div>
${kinds.map(kind => {
const keyMeta = meta[kind]
const label = kind[0].toUpperCase() + kind.slice(1).replace(/[0-9]/, d => " " + d)
return html`
<label class="navkeys-label" for="${kind}-key">${label} Key</label>
<div class="navkeys-row">
<input
autocomplete="off"
class="navkeys-input"
id="${kind}-key"
placeholder="${keyMeta.prefix || ""}xxxxxx..."
value="${() => state[keyMeta.saved] ? util.mask(state[keyMeta.prop]) : state[keyMeta.prop]}"
@keydown="${(e) => {
if (state[keyMeta.saved] && !state[keyMeta.edit]) {
state[keyMeta.edit] = true
state[keyMeta.saved] = false
state[keyMeta.prop] = ""
e.target.value = ""
}
}}"
@input="${(e) => state[keyMeta.prop] = e.target.value}"
/>
<button
class="${() => `navkeys-btn ${state[keyMeta.saved] ? "delete" : ""}`}"
@click="${() => state[keyMeta.saved] ? api.confirmDelete(kind) : api.save(kind)()}"
disabled="${() => !state[keyMeta.saved] && !canSave(kind)}">
${() => state[keyMeta.saved] ? "🗑️" : "💾"}
</button>
</div>
`
})}
${() => {
if (isMapbox && state.showMapboxHelp) {
return html`
<div class="navkeys-help-img">
<img
alt="Mapbox key setup guide"
src="${() => {
const bothKeysSet = state.savedPublic && state.savedSecret
let imageSource = "/mapbox-help/no_keys_set.png"
if (bothKeysSet) {
imageSource = state.initialMapboxComplete ? "/mapbox-help/setup_completed.png" : "/mapbox-help/both_keys_set.png"
} else if (state.savedPublic) {
imageSource = "/mapbox-help/public_key_set.png"
}
return `${imageSource}?v=${state.imageVersion}`
}}"
/>
</div>
`
}
return ""
}}
</div>
`
}
function renderStatus(group) {
return html`
<div class="navkeys-status">
<div
class="navkeys-message"
style="${() => state.lastGroup === group && state.message ? `opacity: ${state.visible ? 1 : 0}` : "opacity: 0"}">
${() => state.message}
</div>
<div
class="navkeys-error"
style="${() => state.lastGroup === group && state.error ? `opacity: ${state.visible ? 1 : 0}` : "opacity: 0"}">
${() => state.error}
</div>
</div>
`
}
return html`
<div class="navkeys-wrapper navkeys-offset-top">
<div class="navkeys-container">
${renderGroup("AMap Keys", ["amap1", "amap2"])}
${renderStatus("amap")}
</div>
<div class="navkeys-container">
${renderGroup("Mapbox Keys", ["public", "secret"])}
${renderStatus("mapbox")}
</div>
</div>
${() => state.showDeleteModal ? Modal({
title: "Confirm Delete",
message: `Are you sure you want to delete your <strong>${getDeleteLabel(state.keyToDelete)}</strong> key?`,
onConfirm: api.delete,
onCancel: () => { state.showDeleteModal = false },
confirmText: "Yes, Delete"
}) : ""}
`
}
@@ -0,0 +1,215 @@
export function highlightRoute(map, routes, selectedRouteId) {
if (!map.isStyleLoaded() || !routes) return;
routes.forEach((route, idx) => {
const routeId = idx === 0 ? 'main' : `alt-${idx}`;
const layerId = `route-line-${routeId}`;
if (map.getLayer(layerId)) {
const isSelected = routeId === selectedRouteId;
map.setPaintProperty(layerId, 'line-width', isSelected ? 5 : 3);
map.setPaintProperty(layerId, 'line-opacity', isSelected ? 1 : 0.5);
if (isSelected) {
map.moveLayer(layerId);
}
}
});
}
function addRouteSource(map, sourceId, feature) {
if (map.getSource(sourceId)) {
const layerId = `route-line-${sourceId.replace('route-', '')}`;
const clickLayerId = `route-click-${sourceId.replace('route-', '')}`;
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getLayer(clickLayerId)) map.removeLayer(clickLayerId);
map.removeSource(sourceId);
}
map.addSource(sourceId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [feature] },
lineMetrics: true
});
}
function addRouteLayers(map, sourceId, layerId, clickLayerId, route) {
map.addLayer({
id: layerId,
type: 'line',
source: sourceId,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-width': 3,
'line-opacity': 0.5,
'line-gradient': buildGradientExpression(route.geometry.coordinates, route.legs[0].annotation.congestion)
}
});
map.addLayer({
id: clickLayerId,
type: 'line',
source: sourceId,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-width': 25, 'line-opacity': 0 }
});
}
function safeGetId(fn) {
try { return fn?.() ?? null } catch { return null }
}
function handleRouteEvents(map, clickLayerId, onRouteSelect, routes, useMetric, feature, getSelectedRouteId) {
const layerId = clickLayerId.replace('click', 'line');
const showTooltip = (e) => {
map.getCanvas().style.cursor = 'pointer';
map.setPaintProperty(layerId, 'line-width', 5);
map.setPaintProperty(layerId, 'line-opacity', 1);
document.querySelectorAll('.mapboxgl-popup.route-tooltip').forEach(p => p.remove());
const props = feature.properties;
const duration = useMetric ? formatSecondsToHuman(props.duration) : formatSecondsToAmerican(props.duration);
const distance = useMetric ? formatMetersToHuman(props.distance, true) : formatMetersToMiles(props.distance);
const arrival = new Date(Date.now() + props.duration * 1000);
const isLong = props.duration > 24 * 3600;
const timeStr = arrival.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const month = arrival.toLocaleString([], { month: 'long' });
const day = arrival.getDate();
const year = arrival.getFullYear();
const suffix = getOrdinalSuffix(day);
const eta = isLong ? `${month} ${day}${suffix}, ${year}, ${timeStr}` : timeStr;
const tooltip = document.createElement('div');
tooltip.className = 'custom-tooltip';
tooltip.style.whiteSpace = 'nowrap';
tooltip.innerHTML = `
<div class="tooltip-row"><span class="emoji">🛣</span><span class="label">Distance:</span><span class="value">${distance}</span></div>
<div class="tooltip-row"><span class="emoji"></span><span class="label">Duration:</span><span class="value">${duration}</span></div>
<div class="tooltip-row"><span class="emoji">🕗</span><span class="label">ETA:</span><span class="value">${eta}</span></div>
`;
new mapboxgl.Popup({ closeButton: false, closeOnClick: true, className: 'route-tooltip', maxWidth: 'none' })
.setLngLat(e.lngLat)
.setDOMContent(tooltip)
.addTo(map);
};
map.on('click', clickLayerId, (e) => {
e.preventDefault();
const routeId = feature.properties.routeId;
const route = routes.find((r, i) => (i === 0 ? 'main' : `alt-${i}`) === routeId);
onRouteSelect(route, routeId);
showTooltip(e);
});
map.on('mouseenter', clickLayerId, showTooltip);
map.on('mouseleave', clickLayerId, () => {
map.getCanvas().style.cursor = '';
const id = safeGetId(getSelectedRouteId);
highlightRoute(map, routes, id);
document.querySelectorAll('.mapboxgl-popup').forEach(p => p.remove());
});
}
export function addRouteToMap(map, routes, start, dest, onRouteSelect, useMetric = true, getSelectedRouteId) {
routes.forEach((route, idx) => {
const routeId = idx === 0 ? 'main' : `alt-${idx}`;
const sourceId = `route-${routeId}`;
const layerId = `route-line-${routeId}`;
const clickLayerId = `route-click-${routeId}`;
const feature = {
type: 'Feature',
geometry: { type: 'LineString', coordinates: route.geometry.coordinates },
properties: {
congestion: route.legs[0].annotation.congestion,
routeId,
duration: route.duration,
distance: route.distance
}
};
addRouteSource(map, sourceId, feature);
addRouteLayers(map, sourceId, layerId, clickLayerId, route);
handleRouteEvents(map, clickLayerId, onRouteSelect, routes, useMetric, feature, getSelectedRouteId);
});
map.once('idle', () => {
const id = safeGetId(getSelectedRouteId);
highlightRoute(map, routes, id);
});
map.on('click', (e) => {
setTimeout(() => {
if (!e.defaultPrevented) {
document.querySelectorAll('.mapboxgl-popup').forEach(p => p.remove());
}
}, 100);
});
const padding = window.innerWidth < 600 ? 100 : 250;
map.fitBounds([start, dest], { padding, duration: 1000 });
}
export async function getCoordinatesFromSearch(searchValue, mapboxPublic) {
const params = new URLSearchParams({ access_token: mapboxPublic, q: searchValue });
const response = await fetch(`https://api.mapbox.com/search/geocode/v6/forward?${params.toString()}`);
const data = await response.json();
return data.features[0].geometry.coordinates;
}
export async function getRoutes(from, to, mapboxPublic) {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${from};${to}?geometries=geojson&annotations=congestion&overview=full&alternatives=true&access_token=${mapboxPublic}`;
const response = await fetch(url);
const data = await response.json();
return data.routes;
}
function buildGradientExpression(coords, congestion) {
const count = congestion.length;
if (count === 0 || coords.length < 2) {
return ['interpolate', ['linear'], ['line-progress'], 0, '#ccc', 1, '#ccc'];
}
const stops = [];
for (let i = 0; i < count; i++) {
stops.push(i / (count - 1), congestionToColor(congestion[i] || 'unknown'));
}
return ['interpolate', ['linear'], ['line-progress'], ...stops];
}
export function removeRouteFromMap(map) {
if (!map || !map.getStyle || !map.getStyle()) return;
const layers = map.getStyle().layers || [];
layers.forEach(l => {
if ((l.id.startsWith('route-line-') || l.id.startsWith('route-click-')) && map.getLayer(l.id)) {
map.removeLayer(l.id);
}
});
const sources = map.getStyle().sources || {};
Object.keys(sources).forEach(id => {
if (id.startsWith('route-') && map.getSource(id)) map.removeSource(id);
});
}
export function formatSecondsToHuman(s) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h > 0 ? `${h}h ${m} min` : `${m} min`;
}
export function formatMetersToHuman(m, metric = true) {
return metric ? (m >= 1000 ? `${(m / 1000).toFixed(1)} km` : `${Math.round(m)} m`) : (m * 3.28084 >= 5280 ? `${((m * 3.28084) / 5280).toFixed(1)} mi` : `${Math.round(m * 3.28084)} ft`);
}
export function formatSecondsToAmerican(s) {
const mins = Math.round(s / 60);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
}
export function formatMetersToMiles(m) {
const miles = m / 1609.34;
return miles >= 0.1 ? `${miles.toFixed(1)} mi` : `${(miles * 5280).toFixed(0)} ft`;
}
function congestionToColor(lvl) {
const map = { low: '#2ecc71', moderate: '#f1c40f', heavy: '#e67e22', severe: '#e74c3c', unknown: '#2ecc71' };
return map[lvl] || '#999';
}
export function getOrdinalSuffix(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
@@ -0,0 +1,269 @@
#duration {
opacity: 0.5;
}
.camera_selector {
background-color: var(--sidebar-bg);
border-radius: var(--border-radius-lg);
display: inline-flex;
margin: var(--border-radius-xl) 0;
max-width: calc(100% - var(--font-size-xl));
overflow: hidden;
}
.camera_selector > div {
padding: 0 var(--border-radius-xl);
}
.camera_selector > div:first-child {
border-right: var(--border-width-thin) solid var(--sidebar-border-color);
}
.camera_selector > div:last-child {
border-left: var(--border-width-thin) solid var(--sidebar-border-color);
}
.camera_selector .selected_camera {
background-color: var(--selected-camera-bg);
}
.camera_selector .unavailable {
cursor: not-allowed;
opacity: 0.5;
text-decoration: line-through;
}
.image_preview:hover {
opacity: 0;
}
.route_card {
background-color: var(--card-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
border-radius: var(--border-width-thick);
color: var(--main-fg);
position: relative;
text-decoration: none;
width: var(--width-md);
}
.route_card p {
padding: var(--border-radius-xl);
}
.recording-card:hover {
cursor: pointer;
transform: var(--hover-scale-base);
}
.route_grid {
display: grid;
grid-gap: var(--gap-xl) var(--padding-base);
grid-template-columns: repeat(auto-fill, minmax(var(--width-md), 1fr));
margin: var(--padding-base) 0;
}
.route_preview {
height: calc(var(--width-md) * 0.625);
position: relative;
width: var(--width-md);
}
.route_preview img {
border-top-left-radius: var(--border-width-thick);
border-top-right-radius: var(--border-width-thick);
display: inline-block;
height: calc(var(--width-md) * 0.625);
position: absolute;
width: var(--width-md);
}
.video_wrapper {
background-color: var(--sidebar-bg);
border-radius: var(--border-radius-lg) var(--border-radius-lg);
width: min(526px, calc(100% - var(--font-size-xl)));
}
.video_wrapper video {
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
height: calc(min(526px, calc(100% - var(--font-size-xl))) * 0.625 - var(--border-width-thin));
width: min(526px, 100%);
}
.videocontrols {
background-color: var(--sidebar-bg);
border-radius: var(--border-radius-lg);
display: flex;
gap: var(--padding-sm);
}
.videocontrols button {
background: none;
border: none;
color: var(--main-fg);
font-size: var(--font-size-xl);
}
.videocontrols p {
margin: 0;
}
.videocontrols #seekslider {
-webkit-appearance: none;
appearance: none;
background: transparent;
flex-grow: 1;
}
.videocontrols #seekslider::-webkit-slider-runnable-track,
.videocontrols #seekslider::-moz-range-track {
background: var(--track-color);
border-radius: var(--border-width-thick);
height: var(--border-radius-sm);
}
.videocontrols #seekslider::-webkit-slider-thumb,
.videocontrols #seekslider::-moz-range-thumb {
background-color: var(--thumb-color);
border: none;
border-radius: var(--border-radius-xl);
height: var(--border-radius-xl);
margin-top: calc(0.125rem - 0.5rem);
width: var(--border-radius-xl);
}
.recording-card {
position: relative;
}
.preserved-icon {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
cursor: pointer;
color: white;
font-size: 1.5rem;
text-shadow: 0 0 5px black;
}
.preserved-icon .bi-heart-fill {
color: red;
}
.preserved-icon .bi-heart:hover {
color: red;
}
.preserved-icon .bi-heart-fill:hover {
color: white;
}
.show-preserved-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
margin-top: var(--margin-sm);
padding: var(--padding-sm) var(--padding-base);
text-align: center;
transition:
background-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
width: auto;
}
.show-preserved-button:hover {
background-color: var(--success-hover-bg);
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.show-preserved-button[disabled] {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.delete-all-button {
background-color: var(--danger-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
margin-top: var(--margin-sm);
margin-left: var(--margin-sm);
padding: var(--padding-sm) var(--padding-base);
text-align: center;
transition:
background-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
width: auto;
}
.delete-all-button:hover {
background-color: var(--danger-hover-bg);
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.delete-all-button[disabled] {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.btn-cancel,
.btn-del {
border: none;
border-radius: var(--border-radius-sm);
padding: var(--padding-sm) var(--padding-base);
font-weight: var(--font-weight-demi-bold);
transition: background-color var(--transition-fast), transform var(--transition-fast);
cursor: pointer;
}
.btn-cancel {
background-color: var(--main-fg);
color: var(--text-on-surface);
}
.btn-del {
background-color: var(--danger-bg);
color: var(--text-on-surface);
}
.btn-cancel:hover {
background-color: var(--main-fg);
transform: var(--hover-scale-sm);
}
.btn-del:hover {
background-color: var(--danger-hover-bg);
transform: var(--hover-scale-sm);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.media-player-content {
width: 90%;
padding: var(--padding-sm);
}
.media-player-content video {
width: 90vw !important;
height: auto !important;
}
.button-row {
flex-wrap: wrap;
justify-content: center;
}
.media-player-content .close-button {
padding: var(--padding-sm) var(--padding-base);
font-size: var(--font-size-sm);
text-align: center;
width: 100%;
}
}
@@ -0,0 +1,509 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
import { Modal } from "/assets/components/modal.js";
const state = reactive({
loading: true,
error: null,
routes: [],
selectedRoute: null,
showPreservedOnly: false,
progress: 0,
total: 0,
showDeleteAllModal: false,
isDeletingAll: false,
truncated: false,
})
const MAX_RENDERED_ROUTES = 250
const ROUTE_FLUSH_INTERVAL_MS = 120
let routesAbortController = null
let routesRequestToken = 0
let pendingRoutes = []
let flushTimerId = null
let seenRouteNames = new Set()
function formatRouteDate(dateString) {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return dateString
}
const month = date.toLocaleString("en-US", { month: "long" })
const day = date.getDate()
const year = date.getFullYear()
let hour = date.getHours()
const minute = date.getMinutes()
const ampm = hour >= 12 ? "pm" : "am"
hour = hour % 12
hour = hour || 12
const minuteStr = minute < 10 ? "0" + minute : minute
return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}`
}
function resetRouteStreamState() {
pendingRoutes = []
if (flushTimerId !== null) {
clearTimeout(flushTimerId)
flushTimerId = null
}
seenRouteNames = new Set()
}
function flushPendingRoutes() {
if (pendingRoutes.length === 0) return
const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0)
if (availableSlots <= 0) {
pendingRoutes = []
state.truncated = true
return
}
const toAppend = pendingRoutes.slice(0, availableSlots)
pendingRoutes = []
if (toAppend.length > 0) {
state.routes = [...state.routes, ...toAppend]
}
if (state.routes.length >= MAX_RENDERED_ROUTES) {
state.truncated = true
}
}
function enqueueRoutes(rawRoutes) {
if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return
const nextRoutes = []
for (const route of rawRoutes) {
const name = String(route?.name || "")
if (!name || seenRouteNames.has(name)) continue
seenRouteNames.add(name)
nextRoutes.push({
...route,
timestamp: formatRouteDate(route.timestamp),
})
}
if (nextRoutes.length === 0) return
pendingRoutes.push(...nextRoutes)
if (flushTimerId === null) {
flushTimerId = setTimeout(() => {
flushTimerId = null
flushPendingRoutes()
}, ROUTE_FLUSH_INTERVAL_MS)
}
}
async function fetchRoutes() {
const requestToken = ++routesRequestToken
if (routesAbortController) {
routesAbortController.abort()
}
const controller = new AbortController()
routesAbortController = controller
try {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (requestToken !== routesRequestToken) return
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n\r?\n/);
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data:")) {
try {
const payload = line.substring(5).trim()
if (!payload) continue
const data = JSON.parse(payload);
if (data.progress !== undefined && data.total !== undefined) {
state.progress = data.progress;
state.total = data.total;
}
if (data.routes) {
enqueueRoutes(data.routes)
}
} catch (e) {
console.error("Failed to parse JSON:", e);
}
}
}
}
flushPendingRoutes()
} catch (error) {
if (error?.name !== "AbortError") {
state.error = "Couldn't load routes. Please try again later..."
}
} finally {
if (requestToken === routesRequestToken) {
flushPendingRoutes()
state.loading = false
if (routesAbortController === controller) {
routesAbortController = null
}
}
}
}
function refresh() {
state.loading = true
state.error = null
state.routes = []
state.progress = 0
state.total = 0
state.truncated = false
resetRouteStreamState()
fetchRoutes()
}
refresh()
let overlay = null
function openDialog(htmlStr) {
const o = document.createElement("div")
o.className = "dialog-overlay"
o.innerHTML = htmlStr
document.body.appendChild(o)
return o
}
function closeDialog(o) {
if (o) o.remove()
}
async function deleteRoute(route) {
const dlg = openDialog(`
<div class="dialog-box">
<p>Delete ${route.timestamp}?</p>
<div class="dialog-buttons">
<button class="btn btn-cancel" ...>Cancel</button>
<button class="btn btn-danger btn-del" ...>Delete</button>
</div>
</div>`)
dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg)
dlg.querySelector(".btn-del").onclick = async () => {
const res = await fetch(`/api/routes/${route.name}`, { method: "DELETE" })
if (res.ok) {
state.routes = state.routes.filter(r => r.name !== route.name)
closeDialog(dlg)
closeOverlay()
refresh()
showSnackbar("Route deleted!")
} else {
showSnackbar("Delete failed...", "error")
}
}
}
async function resetRouteName(route, dlg) {
const res = await fetch(`/api/routes/reset_name`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: route.name })
});
if (res.ok) {
const { timestamp } = await res.json();
closeDialog(dlg);
const routeInList = state.routes.find(r => r.name === route.name);
if (routeInList) {
routeInList.timestamp = formatRouteDate(timestamp);
}
route.timestamp = formatRouteDate(timestamp);
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = formatRouteDate(timestamp);
}
showSnackbar("Route name reset!");
} else {
showSnackbar("Resetting name failed...", "error");
}
}
async function renameRoute(route) {
const dlg = openDialog(`
<div class="dialog-box">
<p>Rename "${route.timestamp}"</p>
<input class="rn-input" value="${route.timestamp}" />
<div class="dialog-buttons">
<button class="btn-cancel">Cancel</button>
<button class="btn-reset">Reset</button>
<button class="btn-save">Save</button>
</div>
</div>`);
dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg);
dlg.querySelector(".btn-reset").onclick = () => resetRouteName(route, dlg);
dlg.querySelector(".btn-save").onclick = async () => {
const newName = dlg.querySelector(".rn-input").value.trim();
if (!newName) return;
const res = await fetch(`/api/routes/rename`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old: route.name, new: newName })
});
if (res.ok) {
closeDialog(dlg);
const routeInList = state.routes.find(r => r.name === route.name);
if (routeInList) {
routeInList.timestamp = newName;
}
route.timestamp = newName;
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = newName;
}
showSnackbar("Route renamed!");
} else {
showSnackbar("Rename failed...", "error");
}
};
}
async function openOverlay(route) {
if (overlay) return;
overlay = document.createElement("div");
overlay.className = "media-player-overlay";
overlay.innerHTML = `
<div class="media-player-content">
<div class="media-player-title">
<span>${route.timestamp}</span>
<i class="bi bi-pencil-fill action-rename-icon"></i>
</div>
<video controls autoplay muted>
<source src="/thumbnails/${route.name}--0/preview.png" type="video/mp4">
</video>
<div class="button-row">
<button class="close-button action-close">Close</button>
<button class="close-button camera-button active" data-camera="forward">Forward</button>
<button class="close-button camera-button" data-camera="wide">Wide</button>
<button class="close-button camera-button" data-camera="driver">Driver</button>
<button class="close-button action-download">Download</button>
<button class="close-button action-delete">Delete</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener("click", e => {
if (e.target === overlay) closeOverlay();
});
overlay.querySelector(".action-rename-icon").onclick = () => renameRoute(route);
overlay.querySelector(".action-close").onclick = closeOverlay;
overlay.querySelector(".action-delete").onclick = () => deleteRoute(route);
const vid = overlay.querySelector("video");
const downloadButton = overlay.querySelector(".action-download");
let segments;
let current = 0;
let selectedCamera = "forward";
downloadButton.onclick = () => {
const link = document.createElement("a");
const videoPath = `/video/${route.name}/combined?camera=${selectedCamera}`;
link.href = videoPath;
link.download = `${route.timestamp}-${selectedCamera}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
(async () => {
try {
const response = await fetch(`/api/routes/${route.name}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
segments = data.segment_urls;
if (!segments || segments.length === 0) {
segments = [`/video/${route.name}--0`];
}
vid.src = `${segments[0]}?camera=forward`;
vid.load();
vid.play();
} catch (error) {
showSnackbar("Error: Could not load combined route video.", "error");
}
})();
vid.addEventListener("ended", () => {
current++;
if (current < segments.length) {
const videoPath = segments[current].includes("?") ? `${segments[current]}&camera=${selectedCamera}` : `${segments[current]}?camera=${selectedCamera}`
vid.src = videoPath;
vid.load();
vid.play();
}
});
overlay.querySelectorAll(".camera-button").forEach(button => {
button.addEventListener("click", e => {
overlay.querySelectorAll(".camera-button").forEach(btn => btn.classList.remove("active"));
e.target.classList.add("active");
selectedCamera = e.target.dataset.camera;
vid.src = segments[current].includes("?") ? `${segments[current]}&camera=${selectedCamera}` : `${segments[current]}?camera=${selectedCamera}`;
vid.load();
vid.play();
});
});
}
function closeOverlay() {
if (!overlay) return
overlay.remove()
overlay = null
state.selectedRoute = null
}
async function togglePreserved(route, e) {
e.stopPropagation()
const newPreservedState = !route.is_preserved
const method = newPreservedState ? "POST" : "DELETE"
try {
const response = await fetch(`/api/routes/${route.name}/preserve`, { method })
if (response.ok) {
route.is_preserved = newPreservedState
} else {
const errorData = await response.json()
showSnackbar(errorData.error || "Failed to update preserved state...", "error")
}
} catch (_) {
showSnackbar("An error occurred...", "error")
}
}
async function deleteAllRoutes() {
state.showDeleteAllModal = false
state.isDeletingAll = true
try {
const res = await fetch("/api/routes/delete_all", { method: "DELETE" })
if (!res.ok) throw new Error()
await refresh()
showSnackbar("All routes deleted!")
} catch {
showSnackbar("An error occurred while deleting all routes...", "error")
} finally {
state.isDeletingAll = false
}
}
export function RouteRecordings() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Dashcam Routes Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Loading dashcam routes requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute);
return html`
<div class="screen-recordings-wrapper">
<div class="screen-recordings-widget">
<div class="screen-recordings-title">Dashcam Routes</div>
<button
class="show-preserved-button"
@click="${() => (state.showPreservedOnly = !state.showPreservedOnly)}"
?disabled="${state.loading && state.routes.length === 0}"
>
${() => (state.showPreservedOnly ? "Show All" : "Show Only Preserved Routes")}
</button>
${() => {
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
if (routesToShow.length === 0) {
if (state.loading && state.total > 0) {
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
}
if (state.loading && !state.isDeletingAll) {
return html`<p class="screen-recordings-message">Loading...</p>`;
}
if (state.isDeletingAll) {
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
}
if (state.showPreservedOnly) {
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
}
if (state.error) {
return html`<p class="screen-recordings-message">${state.error}</p>`;
}
return html`<p class="screen-recordings-message">No routes found...</p>`;
}
return html`
<div class="screen-recordings-grid">
${routesToShow.map(
route => html`
<div
class="recording-card"
@click="${() => {
state.selectedRoute = route;
}}"
>
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
</div>
<div class="recording-preview-container">
<img
src="${route.png}"
class="recording-preview recording-preview-png"
style="display:block;"
loading="lazy"
>
</div>
<p class="recording-filename">${route.timestamp}</p>
</div>
`
)}
</div>
`;
}}
${() => state.truncated ? html`
<p class="screen-recordings-message">Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.</p>
` : ""}
${() => {
if (state.routes.length > 0) {
return html`
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
?disabled="${state.isDeletingAll}"
>
${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
</button>
`;
}
return "";
}}
</div>
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all routes? This action cannot be undone...",
onConfirm: deleteAllRoutes,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
</div>
`;
}
@@ -0,0 +1,302 @@
.button-row {
display: flex;
gap: var(--gap-sm);
justify-content: center;
margin-top: var(--margin-sm);
}
.delete-all-button {
background-color: var(--danger-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
margin-top: var(--margin-sm);
padding: var(--padding-sm) var(--padding-base);
transition:
background-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.delete-all-button:hover {
background-color: var(--danger-hover-bg);
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.dialog-box {
background: var(--secondary-bg);
border-radius: var(--border-radius-lg);
color: var(--text-color);
min-width: var(--width-md);
padding: var(--padding-base);
text-align: center;
}
.dialog-box input {
background: var(--input-bg);
border: none;
border-radius: var(--border-radius-md);
color: var(--text-color);
padding: var(--padding-xs) var(--padding-sm);
width: 100%;
}
.dialog-box p {
margin: 0 0 var(--margin-sm) 0;
}
.dialog-buttons {
display: flex;
gap: var(--gap-sm);
justify-content: center;
margin-top: var(--margin-base);
}
.dialog-buttons button {
background: var(--input-bg);
border: none;
border-radius: var(--border-radius-md);
color: var(--text-color);
cursor: pointer;
padding: var(--padding-sm) var(--padding-base);
}
.dialog-overlay {
align-items: center;
background: rgba(0, 0, 0, var(--disabled-opacity));
color: var(--text-color);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: var(--z-tooltip);
}
.media-player-content {
background: var(--secondary-bg);
border-radius: var(--border-radius-lg);
padding: var(--padding-base);
text-align: center;
}
.media-player-content .close-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-md);
color: var(--text-color);
cursor: pointer;
margin-top: var(--margin-sm);
padding: var(--padding-base) var(--padding-xl);
font-size: var(--font-size-base);
}
.media-player-content video {
border-radius: var(--border-radius-md);
height: var(--width-lg);
width: var(--width-xxl);
}
.media-player-overlay {
align-items: center;
background: rgba(0, 0, 0, 0.7);
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: var(--z-modal);
}
.media-player-overlay .media-player-content .action-close {
background-color: var(--danger-bg);
}
.media-player-overlay .media-player-content .action-delete {
background-color: var(--danger-bg);
}
.media-player-overlay .media-player-content .action-rename {
background-color: var(--input-bg);
}
.media-player-overlay .media-player-content .action-download {
background-color: var(--color-confirm);
}
.media-player-overlay .media-player-content .action-download:hover {
background-color: var(--color-confirm-hover);
filter: brightness(90%);
}
.media-player-overlay .media-player-content .camera-button.active {
box-shadow: inset 0 0 0 2px var(--main-fg);
}
.media-player-overlay .media-player-content .close-button {
color: var(--text-color);
transition: filter var(--transition-fast);
}
.media-player-overlay .media-player-content .close-button:hover {
filter: brightness(90%);
}
.media-player-title {
align-items: center;
color: var(--text-color);
display: flex;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
justify-content: center;
margin-bottom: var(--margin-base);
text-align: center;
}
.action-rename-icon {
cursor: pointer;
margin-left: 10px;
}
.media-player-title .action-rename-route {
cursor: pointer;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.recording-card {
background-color: var(--input-bg);
border-radius: var(--border-radius-md);
overflow: hidden;
text-align: center;
transition: transform var(--transition-fast);
}
.recording-card:hover {
cursor: pointer;
transform: var(--hover-scale-base);
}
.recording-card a {
color: var(--text-color);
text-decoration: none;
}
.recording-filename {
font-size: var(--font-size-sm);
margin: 0;
overflow: hidden;
padding: var(--padding-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.recording-preview {
aspect-ratio: 16 / 9;
height: auto;
object-fit: cover;
width: 100%;
}
.recording-preview-container {
aspect-ratio: 16 / 9;
position: relative;
width: 100%;
}
.recording-preview-container .recording-preview {
height: 100%;
left: 0;
object-fit: cover;
position: absolute;
top: 0;
width: 100%;
}
.screen-recordings-grid {
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(auto-fill, minmax(var(--width-sm), 1fr));
margin-top: var(--padding-lg);
width: 100%;
}
.screen-recordings-grid.disabled {
pointer-events: none;
}
.screen-recordings-message {
color: var(--text-color);
font-size: var(--font-size-sm);
margin-top: var(--padding-xl);
text-align: center;
}
.screen-recordings-title {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
padding: var(--padding-sm);
text-align: center;
width: 100%;
}
.screen-recordings-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--text-color);
display: flex;
flex-direction: column;
margin-top: var(--padding-xl);
max-width: var(--width-xxl);
padding: var(--padding-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.screen-recordings-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.screen-recordings-wrapper {
display: flex;
justify-content: center;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.media-player-content {
width: 90%;
padding: var(--padding-sm);
}
.media-player-content video {
width: 90vw !important;
height: auto !important;
}
.button-row {
flex-wrap: wrap;
justify-content: center;
}
.media-player-content .close-button {
padding: var(--padding-base) var(--padding-xl);
font-size: var(--font-size-base);
text-align: center;
width: 100%;
}
}
@@ -0,0 +1,300 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js";
const state = reactive({
loading: true,
error: null,
recordings: [],
selectedRecording: null,
showDeleteModal: false,
recordingToDelete: null,
showDeleteAllModal: false,
progress: 0,
total: 0,
})
function getOrdinalSuffix(n) {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
function formatScreenRecordingDate(dateString) {
const date = new Date(dateString);
const month = date.toLocaleString("en-US", { month: "long" });
const day = date.getDate();
const year = date.getFullYear();
let hour = date.getHours();
const minute = date.getMinutes();
const ampm = hour >= 12 ? "pm" : "am";
hour = hour % 12;
hour = hour ? hour : 12;
const minuteStr = minute < 10 ? "0" + minute : minute;
return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}`;
}
async function fetchRecordings() {
try {
const response = await fetch("/api/screen_recordings/list");
if (!response.ok) throw new Error("Network response was not ok");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.substring(6));
if (data.progress !== undefined && data.total !== undefined) {
state.progress = data.progress;
state.total = data.total;
}
if (data.recordings) {
state.recordings.push(...data.recordings);
}
} catch (e) {
console.error("Failed to parse JSON:", e);
}
}
}
}
} catch (_) {
state.error = "Couldn't load recordings. Please try again later..."
} finally {
state.loading = false
}
}
fetchRecordings()
function refresh() {
state.loading = true
state.recordings = []
fetchRecordings()
}
let overlay = null
function openDialog(htmlStr) {
const o = document.createElement("div")
o.className = "dialog-overlay"
o.innerHTML = htmlStr
document.body.appendChild(o)
return o
}
function closeDialog(o) { if (o) o.remove() }
async function renameFile(rec) {
const base = rec.filename.replace(/\.mp4$/i, "")
const dlg = openDialog(`
<div class="dialog-box">
<p>Rename ${rec.filename}</p>
<input class="rn-input" value="${base}" />
<div class="dialog-buttons">
<button class="btn-cancel">Cancel</button>
<button class="btn-save">Save</button>
</div>
</div>`)
dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg)
dlg.querySelector(".btn-save").onclick = async () => {
const val = dlg.querySelector(".rn-input").value.trim()
if (!val) return
const oldFilename = rec.filename
const newFilename = val + ".mp4"
const res = await fetch("/api/screen_recordings/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old: oldFilename, new: newFilename }),
})
if (res.ok) {
closeDialog(dlg)
const recordingToUpdate = state.recordings.find(r => r.filename === oldFilename)
if (recordingToUpdate) {
recordingToUpdate.filename = newFilename
recordingToUpdate.is_custom_name = true
recordingToUpdate.png = `/screen_recordings/${val}.png`
}
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = val.replace(/_/g, " ");
}
showSnackbar("Recording renamed!")
} else {
showSnackbar("Rename failed...", "error")
}
}
}
function confirmDeleteFile(rec) {
state.recordingToDelete = rec;
state.showDeleteModal = true;
}
async function deleteFile() {
if (!state.recordingToDelete) return;
const rec = state.recordingToDelete;
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
if (res.ok) {
closeOverlay();
refresh();
showSnackbar("Recording deleted!");
} else {
showSnackbar("Delete failed...", "error");
}
state.showDeleteModal = false;
state.recordingToDelete = null;
}
function openOverlay(rec) {
if (overlay) return
overlay = document.createElement("div")
overlay.className = "media-player-overlay"
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "") : formatScreenRecordingDate(rec.timestamp);
overlay.innerHTML = `
<div class="media-player-content">
<div class="media-player-title">
<span>${displayName}</span>
<i class="bi bi-pencil-fill action-rename-icon"></i>
</div>
<video controls autoplay muted>
<source src="/api/screen_recordings/download/${rec.filename}" type="video/mp4">
</video>
<div class="button-row">
<button class="close-button action-close">Close</button>
<button class="close-button action-download">Download</button>
<button class="close-button action-delete">Delete</button>
</div>
</div>`
overlay.addEventListener("click", e => { if (e.target === overlay) closeOverlay() })
overlay.querySelector(".action-close").onclick = closeOverlay
overlay.querySelector(".action-rename-icon").onclick = () => renameFile(rec)
overlay.querySelector(".action-delete").onclick = () => confirmDeleteFile(rec)
overlay.querySelector(".action-download").onclick = () => {
const link = document.createElement("a");
link.href = `/api/screen_recordings/download/${rec.filename}`;
link.download = rec.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
document.body.appendChild(overlay)
}
function closeOverlay() {
if (!overlay) return
overlay.remove()
overlay = null
state.selectedRecording = null
}
async function deleteAllRecordings() {
state.showDeleteAllModal = false
state.isDeletingAll = true
try {
const res = await fetch("/api/screen_recordings/delete_all", { method: "DELETE" })
if (!res.ok) throw new Error()
await refresh()
showSnackbar("All screen recordings deleted!")
} catch {
showSnackbar("An error occurred while deleting all screen recordings...", "error")
} finally {
state.isDeletingAll = false
}
}
export function ScreenRecordings() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Screen Recordings Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Loading screen recordings requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (state.selectedRecording && !overlay) openOverlay(state.selectedRecording)
return html`
<div class="screen-recordings-wrapper">
<div class="screen-recordings-widget">
<div class="screen-recordings-title">Screen Recordings</div>
${() => {
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
if (state.progress > 0 && state.progress < state.total) {
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
}
if (state.recordings.length === 0 && !state.loading) {
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
}
return ""
}}
<div class="screen-recordings-grid">
${() => state.recordings.map(rec => {
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
return html`
<div
class="recording-card"
@click="${() => { state.selectedRecording = rec }}"
>
<div class="recording-preview-container">
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;" loading="lazy">
</div>
<p class="recording-filename">${displayName}</p>
</div>
`
})}
</div>
${() => {
if (state.recordings.length > 0) {
return html`
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
>
Delete All Recordings
</button>
`
}
return ""
}}
</div>
${() => state.showDeleteModal ? Modal({
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
onConfirm: deleteFile,
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
confirmText: "Delete"
}) : ""}
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
onConfirm: deleteAllRecordings,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
</div>
`
}
@@ -0,0 +1,180 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js"
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
import { SettingsView } from "/assets/components/settings.js"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t"
import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TestingGround } from "/assets/components/tools/testing_ground.js"
import { Troubleshoot } from "/assets/components/tools/troubleshoot.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
import { UpdateManager } from "/assets/components/tools/update_manager.js"
let router, routerState
function toRouterHref(href) {
try {
const url = new URL(href, window.location.origin)
return `${url.pathname}${url.search}${url.hash}`
} catch {
return href
}
}
function createRoute(id, path, component) {
return {
id,
path,
loader: () => { },
element: component,
}
}
function SafeHome() {
return html`
<div>
<h1>Galaxy</h1>
<p>Safe mode is active while UI rendering is being repaired.</p>
<p>
<a href="/galaxy">Galaxy Pairing</a> |
<a href="/device_settings">Toggles</a> |
<a href="/manage_updates">Software</a>
</p>
</div>
`
}
function Root() {
let routes = [
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing),
createRoute("navdestination", "/set_navigation_destination", NavDestination),
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
createRoute("root", "/", Home),
createRoute("routes", "/dashcam_routes", RouteRecordings),
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("model_manager", "/manage_models", ModelManager),
createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers),
createRoute("plots", "/plots", LivePlots),
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("testing_ground", "/testing_ground", TestingGround),
createRoute("troubleshoot", "/troubleshoot", Troubleshoot),
createRoute("tmux", "/manage_tmux", TmuxLog),
createRoute("toggles", "/manage_toggles", ToggleControl),
createRoute("updates", "/manage_updates", UpdateManager),
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
]
router = createRouter({
routes,
history: createBrowserHistory(),
}).initialize()
window.__thePondNavigate = (href) => {
router.navigate(toRouterHref(href))
window.scrollTo(0, 0)
}
routerState = reactive({
activePath: "/",
activePathFull: "/",
initialized: false,
navigation: { state: "loading" },
errors: [],
params: {},
activeMatch: null,
})
router.subscribe(({ initialized, navigation, matches, errors }) => {
const match = Array.isArray(matches) && matches.length > 0
? matches[matches.length - 1]
: null
Object.assign(routerState, {
initialized,
activePath: match?.route?.path || "",
activePathFull: match?.pathname || "",
navigation,
params: match?.params || {},
errors,
activeMatch: match,
})
})
return html`
${() => Sidebar(routerState.activePathFull)}
<div class="content">
${() => {
if (!routerState.initialized) {
return Home({ params: routerState.params })
}
if (routerState.errors?.root?.status === 404) {
return html`<h1>Not Found</h1>`
}
const routeElement = routerState.activeMatch?.route?.element
if (typeof routeElement !== "function") {
console.warn("[router] no route element for path:", routerState.activePathFull)
return Home({ params: routerState.params })
}
return routeElement({ params: routerState.params })
}}
</div>
`
}
export function Link(href, children, onClick, classes = "") {
return html`<a
class="${classes}"
href="${() => href}"
@click="${(e) => {
e.preventDefault()
router.navigate(toRouterHref(e.currentTarget.href))
hideSidebar()
onClick?.()
}}"
>${children}</a>`
}
export function Navigate(href) {
router.navigate(toRouterHref(href))
window.scrollTo(0, 0)
}
function mountRouterWhenReady() {
const mountNode = document.getElementById("app")
if (!mountNode) {
// Some embedded browsers execute module scripts before <body> is fully available.
setTimeout(mountRouterWhenReady, 0)
return
}
if (!window.__thePondRouterMounted) {
window.__thePondRouterMounted = true
Root()(mountNode)
} else {
console.warn("[router] duplicate mount prevented")
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountRouterWhenReady, { once: true })
} else {
mountRouterWhenReady()
}
@@ -0,0 +1,353 @@
@font-face {
font-family: "password";
font-style: normal;
font-weight: 400;
src: url(/assets/vendor/fonts/password.ttf);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.setting {
margin-bottom: var(--margin-sm);
max-width: calc(100vw - 3rem);
}
.setting .description.collapsed {
display: none;
}
.setting .title {
font-size: var(--font-size-sm);
}
.setting.subtoggle {
margin-left: 0;
}
}
.dropdown {
grid-area: b;
justify-self: end;
position: relative;
}
.dropdown>select {
appearance: none;
background-color: var(--sidebar-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
border-radius: var(--border-radius-sm);
color: var(--main-fg);
font-size: 1.15rem;
padding: var(--padding-sm) 2em 0.675em var(--font-size-base);
width: 100%;
}
.dropdown::after,
.dropdown::before {
--size: 0.3rem;
content: "";
pointer-events: none;
position: absolute;
right: var(--border-radius-xl);
}
.dropdown::before {
border-bottom: var(--size) solid var(--main-fg);
border-left: var(--size) solid transparent;
border-right: var(--size) solid transparent;
top: 40%;
}
.dropdown::after {
border-left: var(--size) solid transparent;
border-right: var(--size) solid transparent;
border-top: var(--size) solid var(--main-fg);
top: 55%;
}
input.password {
font-family: "password";
}
input.searchfield:focus {
border-color: var(--success-bg);
box-shadow: none;
outline: none;
}
input:checked+.slider {
background-color: var(--success-bg);
}
input:checked+.slider:before {
transform: translateX(26px);
}
input:checked+.slider.loading:before {
animation: rotationChecked 1s linear infinite;
}
input:focus+.slider {
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
}
.numberInput {
align-items: center;
border: var(--border-width-thin) solid var(--sidebar-border-color);
border-radius: var(--border-width-thick);
display: flex;
grid-column: 3;
grid-row: 1;
height: 50px;
justify-content: space-between;
white-space: nowrap;
width: 150px;
}
.numberInput button {
background: none;
border: none;
color: inherit;
font: inherit;
height: 100%;
outline: none;
padding: 0;
width: 40px;
}
.numberInput button:active:not([disabled]) {
background-color: var(--success-bg);
}
.numberInput button[disabled] {
color: grey;
}
.options {
color: var(--main-fg);
display: flex;
font-size: 1var(--border-width-base);
gap: var(--border-radius-xl);
max-width: 336px;
position: relative;
user-select: none;
z-index: 1;
}
.options>input {
display: none;
}
.options>input:checked+label {
background-color: var(--success-bg);
color: var(--text-color);
font-weight: bold;
}
.options label {
background-color: var(--sidebar-bg);
border-radius: 3rem;
flex: 1;
overflow: hidden;
padding: var(--padding-xs) var(--padding-xs);
text-align: center;
text-overflow: ellipsis;
transition: color 250ms cubic-bezier(0, 0.95, 0.38, 0.98);
white-space: nowrap;
}
.setting {
align-items: center;
display: grid;
grid-template-areas: "a b b" "c c d";
grid-template-columns: 4fr 4fr 1fr;
margin-bottom: var(--border-radius-xl);
padding-right: var(--font-size-xl);
}
.setting .description {
font-size: 0.8em;
grid-area: c;
grid-column: span 3;
margin: 0;
opacity: 0.5;
padding-top: var(--padding-xs);
}
.setting .description summary {
margin-bottom: var(--border-radius-lg);
}
.setting .options {
align-items: center;
grid-area: b;
grid-column: span 2;
justify-self: end;
width: 100%;
}
.setting .savebutton {
background-color: var(--success-bg);
border: var(--border-width-thin) solid transparent;
border-radius: var(--width-lg);
color: var(--text-color);
display: inline-block;
font-size: 14px;
font-weight: bold;
justify-self: end;
margin-left: var(--border-radius-xl);
padding: var(--border-radius-lg);
text-align: center;
transition: background-color var(--transition-base), border-color var(--transition-base), color var(--transition-base), box-shadow var(--transition-base), filter var(--transition-base);
white-space: normal;
width: 6var(--border-width-thick);
}
.setting .savebutton.loading {
cursor: progress;
}
.setting .textwrapper {
grid-column: span 2;
}
.setting .title {
font-size: 1.var(--border-radius-xl);
grid-area: a;
margin-right: var(--border-radius-xl);
}
.setting .title.wide {
grid-column: span 2;
}
.setting.subsetting_link {}
.setting.subtoggle {
margin: 0 0 0 var(--padding-xl);
}
.setting.subtoggle+.setting {
margin-top: var(--border-radius-xl);
}
.setting.subtoggle p {
margin: var(--margin-sm) 0;
}
.setting.subtoggle .title:before {
border-bottom: var(--border-width-base) solid;
border-color: gray;
border-left: var(--border-width-base) solid;
content: "";
display: inline-block;
height: var(--border-radius-lg);
margin: 0 var(--margin-base) var(--margin-xs) 0;
position: relative;
width: 1var(--border-width-base);
}
.settings {
max-width: var(--max-width-content);
}
.settings h1 {
margin-bottom: var(--border-radius-xl);
}
.settings .textinput,
input.searchfield {
appearance: none;
background: var(--sidebar-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
border-radius: var(--border-radius-sm);
color: var(--main-fg);
font-size: 13px;
height: 46px;
padding: var(--padding-xs) var(--padding-base);
transition: border 0.15s ease;
}
.settings input.searchfield {
margin-top: var(--border-radius-xl);
padding: 0.var(--border-radius-xl) var(--border-radius-xl);
width: calc(100% - 5rem);
}
.slider {
background-color: var(--switch-inactive-bg);
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: var(--transition-fast);
}
.slider:before {
background-color: var(--main-bg);
border-radius: var(--border-radius-xl);
bottom: var(--border-radius-sm);
content: "";
height: 26px;
left: var(--border-radius-sm);
position: absolute;
transition: var(--transition-fast);
width: 26px;
}
.slider.loading:before {
animation: rotation 1s linear infinite;
background-color: none !important;
border: var(--border-width-thick) solid var(--sidebar-fg);
border-bottom-color: var(--main-fg);
box-sizing: border-box;
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
.switch {
display: inline-block;
grid-column: 3;
grid-row: 1;
height: 34px;
justify-self: end;
position: relative;
width: 60px;
}
i.switch {
align-self: end;
width: 34px;
}
.switch input {
height: 0;
opacity: 0;
width: 0;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rotationChecked {
0% {
transform: translateX(26px) rotate(0deg);
}
100% {
transform: translateX(26px) rotate(360deg);
}
}
@@ -0,0 +1,58 @@
import { html } from "/assets/vendor/arrow-core.js"
import { upperFirst } from "/assets/js/utils.js"
import { Navigate } from "/assets/components/router.js"
export function SettingsView({ params }) {
const state = {
selectedSection: params.section,
selectedSubsection: params.subsection,
settings: "[]",
heading: params.section,
}
getSettings().then((results) => {
if (state.selectedSubsection) {
const setting = results.find((item) => item.key === state.selectedSubsection) ?? {}
if (!setting.subsettings || Object.keys(setting.subsettings).length === 0) {
return Navigate(`/settings/${state.selectedSection}`)
}
state.heading = setting.key
state.settings = JSON.stringify([
{
...setting,
description:
setting.description +
"<br><br><b>Disabling this will make settings below irrelevant</b>",
subsettings: undefined,
expanded: true,
},
...Object.entries(setting.subsettings).map(([key, value]) => ({
key,
section: state.selectedSection,
...value,
})),
])
return
}
state.settings = JSON.stringify(results)
})
return html`
<div class="settings" id="settingsWrapper">
<h1>${() => upperFirst(state.heading)} settings</h1>
${() => {
const settings = JSON.parse(state.settings).filter(
(el) => el.section === state.selectedSection
)
return html`<div>${SettingsList(settings)}</div>`
}}
</div>
`
}
function SettingsList(settings) {
return settings.map((setting) => html`<div>${setting.key}</div>`)
}
@@ -0,0 +1,287 @@
#menu_button {
align-items: center;
background-color: var(--main-fg);
border-radius: 50%;
bottom: var(--border-radius-xl);
display: flex;
height: 50px;
justify-content: center;
position: fixed;
right: var(--border-radius-xl);
width: 50px;
z-index: var(--z-modal);
}
#menu_button i {
color: var(--text-color);
font-size: var(--padding-xl);
}
#sidebarUnderlay {
background-color: rgba(0, 0, 0, 0.8);
height: 100dvh;
left: 0;
position: fixed;
top: 0;
transition: var(--transition-fast);
width: 100vw;
z-index: var(--z-overlay);
}
.hidden {
display: none !important;
}
.logo {
height: 50px;
margin-left: var(--margin-xs);
width: 50px;
}
.menu-item-link {
align-items: center;
border-radius: var(--border-radius-sm);
color: inherit;
display: flex;
flex-direction: row;
gap: var(--gap-sm);
height: 100%;
padding: var(--padding-xs);
text-decoration: none;
transition: all var(--transition-fast);
white-space: nowrap;
width: 100%;
}
.section-title {
display: block;
font-weight: var(--font-weight-bold);
padding: 0.5em 0.1em;
}
.sidebar {
background:
radial-gradient(ellipse at 30% 80%, rgba(139, 108, 197, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 70% 20%, rgba(94, 200, 200, 0.04) 0%, transparent 50%),
var(--sidebar-bg);
border-right: var(--border-width-thin) solid var(--sidebar-border-color);
display: flex;
flex-direction: column;
font-size: var(--border-radius-xl);
height: 100dvh;
justify-content: space-between;
left: 0;
min-width: var(--sidebar-width);
overflow-y: auto;
position: fixed;
top: 0;
width: var(--sidebar-width);
z-index: var(--z-sidebar);
}
.sidebar a,
.sidebar i,
.sidebar li {
color: var(--text-color);
font-weight: var(--font-weight-normal);
text-decoration: none;
}
.sidebar hr {
margin: 0;
}
.sidebar li a {
color: inherit;
display: block;
height: 100%;
text-decoration: none;
transition: color var(--transition-fast);
white-space: nowrap;
width: 100%;
}
.sidebar .menu_section {
padding: 0;
}
.sidebar .menu_section>li>a span {
color: var(--text-color);
display: inline-block;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin-bottom: var(--padding-xs);
padding-left: var(--padding-sm);
}
.sidebar .menu_section>li>ul {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar .menu_section>li>ul>li {
border-radius: var(--border-radius-sm);
margin-bottom: var(--padding-sm);
overflow: hidden;
padding: 0;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.sidebar .menu_section>li>ul>li>a.menu-item-link {
border-radius: inherit;
}
.sidebar .menu_section>li>ul>li.active,
.sidebar .menu_section>li>ul>li:hover {
background-color: var(--sidebar-active-bg);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.sidebar .menu_section>li>ul>li.active>a {
color: var(--color-white);
font-weight: var(--font-weight-bold);
}
.sidebar .menu_section>li>ul>li:hover>a {
color: var(--color-white);
font-weight: var(--font-weight-demi-bold);
}
.sidebar .title {
align-items: center;
background-color: var(--sidebar-active-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
column-gap: var(--gap-sm);
display: flex;
font-size: var(--font-size-xl);
margin: var(--margin-sm) auto;
max-width: 90%;
padding: var(--padding-sm);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.sidebar .title:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.sidebar .title a {
align-items: center;
display: flex;
justify-content: center;
}
.sidebar .title_text {
display: flex;
flex-direction: column;
}
.sidebar .title_text a {
line-height: var(--line-height-base);
margin: 0;
}
.sidebar .title_text a:last-of-type {
font-size: 0.6em;
}
.sidebar .title_text p {
font-weight: var(--font-weight-demi-bold);
line-height: var(--line-height-base);
margin: 0;
}
.sidebar.visible {
left: 0;
}
.sidebar_header a:last-of-type {
color: var(--text-muted);
font-size: var(--font-size-xs);
font-style: italic;
font-weight: var(--font-weight-demi-bold);
}
.sidebar_header p {
background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
letter-spacing: 0.05em;
}
.sidebar_widget {
background-color: var(--sidebar-active-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
margin: var(--margin-sm) auto;
max-width: 90%;
padding: var(--padding-sm);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.sidebar_widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
#sidebarUnderlay {
display: block;
}
.sidebar {
left: calc(-1 * var(--sidebar-width));
overflow-y: scroll;
transition: var(--transition-fast);
}
.sidebar .menu_section>li>a {
font-size: var(--padding-sm);
}
.sidebar .title {
flex-direction: row;
font-size: var(--border-radius-xl);
margin: 0;
}
}
@media only screen and (min-width: 768px) {
#menu_button {
display: none;
}
}
@media only screen and (max-width: var(--breakpoint-md)) {
#sidebarUnderlay {
display: block;
}
.sidebar {
left: calc(-1 * var(--sidebar-width));
overflow-y: scroll;
transition: var(--transition-fast);
}
.sidebar .menu_section>li>a {
font-size: var(--padding-sm);
}
.sidebar .title {
flex-direction: row;
font-size: var(--border-radius-xl);
margin: 0;
}
}
@media only screen and (min-width: var(--breakpoint-md)) {
#menu_button {
display: none;
}
}
@@ -0,0 +1,134 @@
import { html } from "/assets/vendor/arrow-core.js";
import { hideSidebar, upperFirst } from "/assets/js/utils.js";
const MENU_ITEMS = {
home: [
{ name: "Home", link: "/", icon: "bi-house-fill" },
],
recordings: [
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
],
tools: [
{ name: "Toggles", link: "/device_settings", icon: "bi-toggle-on" },
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Troubleshoot", link: "/troubleshoot", icon: "bi-tools" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
{ name: "Backup and Restore", link: "/manage_toggles", icon: "bi-arrow-repeat" },
{ name: "Software", link: "/manage_updates", icon: "bi-arrow-up-circle" },
{ name: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" },
],
};
function matchesPath(currentPath, link) {
if (link === "/") return currentPath === "/";
return currentPath === link || currentPath.startsWith(`${link}/`);
}
function buildSectionMarkup(section, links, currentPath) {
const linksMarkup = links.map((link) => {
const active = matchesPath(currentPath, link.link) ? "active" : "";
return `
<li class="${active}">
<a class="menu-item-link" href="${link.link}">
<i class="bi ${link.icon}"></i>
<span>${upperFirst(link.name)}</span>
</a>
</li>
`;
}).join("");
return `
<div class="sidebar_widget">
<ul class="menu_section">
<li>
<span class="section-title">${upperFirst(section)}</span>
<ul id="${section}">
${linksMarkup}
</ul>
</li>
</ul>
</div>
`;
}
function bindSidebarHandlers() {
const menuButton = document.getElementById("menu_button");
const underlay = document.getElementById("sidebarUnderlay");
if (!menuButton || !underlay) return;
if (!window.__thePondSidebarMenuBound) {
window.__thePondSidebarMenuBound = true;
menuButton.addEventListener("click", () => {
const sidebar = document.getElementById("sidebar");
const currentUnderlay = document.getElementById("sidebarUnderlay");
if (!sidebar || !currentUnderlay) return;
sidebar.classList.toggle("visible");
currentUnderlay.classList.toggle("hidden");
});
}
underlay.onclick = hideSidebar;
document.querySelectorAll("#sidebar a.menu-item-link").forEach((anchor) => {
if (anchor.dataset.boundClick === "1") return;
anchor.dataset.boundClick = "1";
anchor.addEventListener("click", (event) => {
if (event.defaultPrevented) return;
if (event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
const href = anchor.getAttribute("href") || "/";
const navigate = window.__thePondNavigate;
if (typeof navigate === "function") {
navigate(href);
} else {
window.location.assign(href);
}
hideSidebar();
window.scrollTo(0, 0);
});
});
}
function renderSidebarIntoShell(currentPath) {
const shell = document.getElementById("sidebar_shell");
if (!shell) return;
const activePath = currentPath || window.location.pathname;
const sectionsMarkup = Object.entries(MENU_ITEMS)
.map(([section, links]) => buildSectionMarkup(section, links, activePath))
.join("");
shell.innerHTML = `
<div id="sidebarUnderlay" class="hidden"></div>
<div id="sidebar" class="sidebar">
<div>
<div class="title">
<img class="logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<div class="title_text sidebar_header">
<p>Galaxy</p>
</div>
</div>
<hr />
${sectionsMarkup}
</div>
</div>
`;
bindSidebarHandlers();
}
export function Sidebar(currentPath) {
setTimeout(() => renderSidebarIntoShell(currentPath), 0);
return html`<div id="sidebar_shell"></div>`;
}
@@ -0,0 +1,82 @@
.tailscale-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
padding: var(--border-radius-xl);
text-align: center;
text-decoration: none;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.tailscale-button + .tailscale-button {
margin-top: var(--padding-sm);
}
.tailscale-button:hover {
box-shadow: var(--shadow-md);
color: var(--secondary-fg);
transform: var(--hover-scale-sm);
}
.tailscale-button-wrapper {
height: calc(100% + 20px);
position: relative;
width: 100%;
}
.tailscale-link {
color: var(--text-color);
font-size: var(--font-size-sm);
left: 50%;
margin-top: var(--border-radius-sm);
position: absolute;
text-decoration: underline;
top: 100%;
transform: translateX(-50%);
white-space: nowrap;
}
.tailscale-text {
color: var(--text-color);
text-align: center;
}
.tailscale-title {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
padding: var(--padding-sm);
text-align: center;
width: 100%;
}
.tailscale-wrapper {
display: flex;
justify-content: center;
}
.tailscale-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--main-fg);
display: flex;
flex-direction: column;
margin-top: var(--padding-xl);
max-width: var(--width-lg);
padding: var(--padding-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.tailscale-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
@@ -0,0 +1,91 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js";
export function TailscaleControl() {
const state = reactive({
status: "idle",
installed: false,
showUninstallModal: false,
});
async function checkInstallStatus() {
try {
const response = await fetch("/api/tailscale/installed")
const result = await response.json()
state.installed = result.installed
} catch (error) {
console.error("Failed to check Tailscale install status:", error)
}
}
function confirmUninstall() {
state.showUninstallModal = true;
}
async function handleAction() {
if (state.status !== "idle") {
return
}
const action = state.installed ? "uninstall" : "install"
state.status = state.installed ? "uninstalling" : "installing"
state.showUninstallModal = false;
showSnackbar(`${action.charAt(0).toUpperCase() + action.slice(1)} started...`)
const endpoint = state.installed ? "/api/tailscale/uninstall" : "/api/tailscale/setup"
const response = await fetch(endpoint, { method: "POST" })
const result = await response.json()
showSnackbar(result.message || `${action.charAt(0).toUpperCase() + action.slice(1)} triggered...`)
if (result.auth_url) {
window.location.href = result.auth_url;
}
// Refresh install status after action
await checkInstallStatus();
state.status = "idle"
}
checkInstallStatus()
return html`
<div class="toggle-control-widget" style="margin-top: 1.5rem">
<section class="tailscale-widget">
<div class="toggle-control-title">
${() => state.installed ? 'Uninstall Tailscale' : 'Install Tailscale'}
</div>
<p class="tailscale-text">
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!<br><br>
<strong style="color: #ff9494;">Note: Not recommended. Using Galaxy Tunnel is the preferred remote connection method.</strong>
</p>
<div class="tailscale-button-wrapper">
<button
class="tailscale-button"
@click="${() => state.installed ? confirmUninstall() : handleAction()}"
disabled="${() => state.status === 'installing' || state.status === 'uninstalling'}"
>
${() => {
if (state.status === 'installing') return 'Installing...'
if (state.status === 'uninstalling') return 'Uninstalling...'
if (state.installed) return 'Uninstall'
return 'Install'
}}
</button>
<a class="tailscale-link" href="https://tailscale.com/download" target="_blank">
Download Tailscale on your other devices
</a>
</div>
</section>
${() => state.showUninstallModal ? Modal({
title: "Confirm Uninstall",
message: "Are you sure you want to uninstall Tailscale?",
onConfirm: handleAction,
onCancel: () => { state.showUninstallModal = false; },
confirmText: "Uninstall"
}) : ""}
</div>
`
}
@@ -0,0 +1,498 @@
/* ――― Device Settings Page ――― */
.ds-wrapper {
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
/* ――― Section Tabs ――― */
.ds-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: var(--margin-base);
}
.ds-tab {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: 0.75rem;
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
font-family: var(--font-body);
font-size: 0.88rem;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.ds-tab i {
font-size: 0.9rem;
}
.ds-tab:hover {
border-color: var(--main-fg);
color: var(--text-color);
}
.ds-tab.active {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
/* ――― Search / Filter ――― */
.ds-search-row {
align-items: center;
display: flex;
gap: 0.5rem;
margin-bottom: var(--margin-lg);
}
.ds-search {
background-color: var(--input-bg);
border: var(--border-style-input);
border-radius: var(--border-radius-lg);
box-sizing: border-box;
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-base);
margin-bottom: 0;
outline: none;
padding: var(--padding-sm) var(--padding-base);
transition: box-shadow var(--transition-fast);
width: 100%;
flex: 1;
}
.ds-search:focus {
box-shadow: 0 0 0 2px var(--main-fg);
}
.ds-search-clear {
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
cursor: pointer;
font-family: var(--font-body);
font-size: var(--font-size-sm);
min-width: 4.75rem;
padding: 0.6rem 0.8rem;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.ds-search-clear:hover {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
/* ――― Section Cards ――― */
.ds-section {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--margin-lg);
overflow: hidden;
transition: box-shadow var(--transition-fast);
}
.ds-section:hover {
box-shadow: var(--shadow-md);
}
.ds-section-header {
align-items: center;
background-color: var(--input-bg);
display: flex;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
user-select: none;
}
.ds-static-header {
cursor: default;
}
.ds-section-header i {
color: var(--main-fg);
font-size: var(--font-size-lg);
margin-right: var(--margin-sm);
min-width: 1.5rem;
text-align: center;
}
.ds-section-title {
color: var(--text-color);
flex: 1;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
}
.ds-section-chevron {
color: var(--text-muted);
font-size: var(--font-size-sm);
transition: transform var(--transition-fast);
}
.ds-section.collapsed .ds-section-chevron {
transform: rotate(-90deg);
}
.ds-section-body {
max-height: 5000px;
overflow: hidden;
transition: max-height 0.3s ease-in-out;
}
.ds-section.collapsed .ds-section-body {
max-height: 0;
}
/* ――― Individual Toggle Rows ――― */
.ds-row {
align-items: center;
border-top: 1px solid var(--sidebar-border-color);
display: flex;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
transition: background-color var(--transition-fast);
}
.ds-row:hover {
background-color: var(--sidebar-active-bg);
}
.ds-row-label {
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
margin-right: var(--margin-base);
}
.ds-row-desc {
color: var(--text-muted);
font-size: var(--font-size-xs);
margin-top: 0.25rem;
line-height: 1.3;
white-space: pre-line;
}
/* ――― Child Row Modifier (Sub-menus) ――― */
.ds-child-modifier {
border-left: 2px solid var(--color-gray-200);
margin-left: 1rem;
padding-left: 1rem;
}
.ds-manage-btn {
display: inline-flex;
align-items: center;
background-color: var(--color-gray-200);
border: 1px solid var(--color-gray-300);
border-radius: 1rem;
color: var(--main-fg);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
margin-top: 0.6rem;
padding: 0.25rem 0.75rem;
transition: all var(--transition-fast);
user-select: none;
width: fit-content;
}
.ds-manage-btn:hover {
background-color: var(--color-gray-300);
}
.ds-manage-btn i {
margin-left: 0.3rem;
}
/* ――― Toggle Switch ――― */
.ds-toggle {
appearance: none;
background-color: var(--track-color);
border: none;
border-radius: 1rem;
flex-shrink: 0;
height: 1.5rem;
outline: none;
position: relative;
transition: background-color var(--transition-fast);
width: 2.75rem;
}
.ds-toggle::after {
background-color: var(--color-gray-300);
border-radius: 50%;
content: "";
height: 1.1rem;
left: 0.2rem;
position: absolute;
top: 0.2rem;
transition: transform var(--transition-fast), background-color var(--transition-fast);
width: 1.1rem;
}
.ds-toggle:checked {
background-color: var(--main-fg);
}
.ds-select {
appearance: none;
background-color: var(--color-gray-950);
border: 1px solid var(--track-color);
border-radius: 0.5rem;
color: var(--color-gray-200);
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
padding: 0.5rem 2rem 0.5rem 1rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
max-width: 250px;
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/200.svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1em;
}
.ds-select:hover {
border-color: var(--color-gray-500);
}
.ds-select:focus {
border-color: var(--main-fg);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
.ds-toggle:checked::after {
background-color: var(--color-white);
transform: translateX(1.25rem);
}
.ds-toggle:disabled {
opacity: var(--disabled-opacity);
}
/* ――― Loading State ――― */
.ds-loading {
color: var(--text-muted);
padding: var(--padding-xl);
text-align: center;
}
.ds-status-bar {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: var(--font-size-xs);
gap: var(--gap-sm);
justify-content: space-between;
margin-bottom: var(--margin-sm);
}
/* ――― Empty Filter State ――― */
.ds-empty {
color: var(--text-muted);
font-style: italic;
padding: var(--padding-lg);
text-align: center;
}
/* ――― Custom Range Slider ――― */
.ds-row-numeric {
align-items: flex-start;
flex-direction: column;
}
.ds-row-info {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: var(--margin-sm);
width: 100%;
}
.ds-row-value {
background-color: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-sm);
padding: 0.2rem 0.6rem;
}
.ds-stepper-container {
width: 100%;
}
@media (min-width: 768px) {
.ds-stepper-container {
max-width: 350px;
}
}
.ds-stepper {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
display: grid;
gap: var(--gap-sm);
grid-template-columns: 56px 1fr 56px;
padding: 0.45rem;
}
.ds-stepper-meta {
align-items: center;
color: var(--text-muted);
display: flex;
flex-direction: column;
font-size: var(--font-size-xs);
gap: 0.1rem;
justify-content: center;
min-height: 2rem;
text-align: center;
}
.ds-default-value {
color: var(--text-color);
font-weight: var(--font-weight-semibold);
}
.ds-step-value {
color: var(--text-muted);
}
.ds-manual-row {
align-items: center;
display: flex;
gap: 0.35rem;
margin-top: 0.15rem;
width: 100%;
}
.ds-manual-input {
background: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-sm);
color: var(--text-color);
flex: 1;
font-family: var(--font-body);
font-size: var(--font-size-xs);
min-width: 0;
padding: 0.22rem 0.35rem;
}
.ds-manual-input:focus {
border-color: var(--main-fg);
outline: none;
}
.ds-apply-btn {
background: transparent;
border: var(--border-style-main);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-xs);
padding: 0.2rem 0.45rem;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
}
.ds-apply-btn:hover:not(:disabled) {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
.ds-apply-btn:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.ds-reset-btn {
background: transparent;
border: var(--border-style-main);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-xs);
margin-top: 0.15rem;
padding: 0.2rem 0.45rem;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.ds-reset-btn:hover:not(:disabled) {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
.ds-reset-btn:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.ds-stepper-btn {
align-items: center;
background: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
cursor: pointer;
display: inline-flex;
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
height: 2.1rem;
justify-content: center;
line-height: 1;
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.ds-stepper-btn:hover:not(:disabled) {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
transform: translateY(-1px);
}
.ds-stepper-btn:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.ds-wrapper {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
.ds-search-row {
align-items: stretch;
flex-direction: column;
}
.ds-search-clear {
width: 100%;
}
.ds-tabs {
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.ds-tabs::-webkit-scrollbar {
display: none;
}
}
@@ -0,0 +1,846 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const endpointOptionsCache = {}
const endpointOptionsInflight = {}
// Plain variables — scheduling/routing flags that must NOT be reactive
let syncScheduled = false
let lastParams = null
// Module-level state (persists across route changes)
const state = reactive({
layout: [],
allKeys: [],
paramMetaByKey: {},
values: {},
defaultValues: {},
loadingLayout: true,
loadingValues: true,
filter: "",
expanded: {},
fetched: false,
activeSectionSlug: "",
numericUpdating: {},
})
function slugifySectionName(name) {
return String(name || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
function getSectionsWithSlug() {
return state.layout.map(section => ({
...section,
slug: slugifySectionName(section.name),
}))
}
function toSelectValue(value) {
return value === null || value === undefined ? "" : String(value)
}
function resolveEndpointTemplate(template) {
if (!template) return ""
return String(template).replace(/\{([A-Za-z0-9_]+)\}/g, (_, key) => {
return encodeURIComponent(toSelectValue(state.values[key]))
})
}
function scheduleSyncInputs() {
if (syncScheduled) return
syncScheduled = true
requestAnimationFrame(() => {
syncScheduled = false
syncInputs()
})
}
function applySelectOptions(el, options) {
el.innerHTML = ""
for (const opt of options || []) {
const o = document.createElement("option")
o.value = String(opt.value)
o.textContent = opt.label
el.appendChild(o)
}
}
async function hydrateEndpointOptions(el, key, endpoint) {
if (endpointOptionsCache[endpoint]) {
applySelectOptions(el, endpointOptionsCache[endpoint])
el.dataset.hydrated = "1"
el.value = toSelectValue(state.values[key])
return
}
if (!endpointOptionsInflight[endpoint]) {
endpointOptionsInflight[endpoint] = fetch(endpoint)
.then(r => r.json())
.then(options => {
endpointOptionsCache[endpoint] = options
return options
})
.catch(() => null)
.finally(() => {
delete endpointOptionsInflight[endpoint]
})
}
const options = await endpointOptionsInflight[endpoint]
if (!options || !el.isConnected) return
applySelectOptions(el, options)
el.dataset.hydrated = "1"
el.value = toSelectValue(state.values[key])
}
function syncInputs() {
// Sync checkboxes — set DOM property directly (attribute alone is unreliable)
for (const el of document.querySelectorAll("input[type='checkbox'].ds-toggle[id^='ds-']")) {
el.checked = !!state.values[el.id.slice(3)]
}
// Sync selects — hydrate options + set value
for (const el of document.querySelectorAll("select.ds-select[id^='ds-']")) {
const key = el.id.slice(3)
const endpointTemplate = el.getAttribute("data-endpoint")
const endpoint = resolveEndpointTemplate(endpointTemplate)
const inlineOptions = state.paramMetaByKey[key]?.options
if (endpoint) {
if (!el.dataset.hydrated || el.dataset.endpoint !== endpoint) {
el.dataset.endpoint = endpoint
hydrateEndpointOptions(el, key, endpoint)
} else {
el.value = toSelectValue(state.values[key])
}
continue
}
if (Array.isArray(inlineOptions) && inlineOptions.length > 0) {
if (!el.dataset.hydrated) {
applySelectOptions(el, inlineOptions)
el.dataset.hydrated = "1"
}
el.value = toSelectValue(state.values[key])
}
}
}
async function fetchDefaultValues() {
try {
const defaultsRes = await fetch("/api/params/defaults")
if (!defaultsRes.ok) return false
const defaultsData = await defaultsRes.json()
state.defaultValues = defaultsData || {}
return true
} catch (e) {
return false
}
}
async function fetchLayoutAndParams() {
state.loadingLayout = true
state.loadingValues = true
try {
const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json")
const rawLayoutData = await layoutRes.json()
const layoutData = rawLayoutData
.map(section => ({
...section,
params: (section.params || []).filter(param => param.key !== "Model"),
}))
.filter(section => section.params.length > 0)
state.layout = layoutData
const keys = []
const paramMetaByKey = {}
for (const section of layoutData) {
for (const p of section.params) {
keys.push(p.key)
paramMetaByKey[p.key] = p
}
}
state.allKeys = keys
state.paramMetaByKey = paramMetaByKey
} catch (e) {
console.error("Failed to fetch UI layout:", e)
}
state.loadingLayout = false
// Pull params once at page load; local state handles subsequent edits.
try {
const valuesRes = await fetch("/api/params/all")
const data = await valuesRes.json()
state.values = data
if (!(await fetchDefaultValues())) {
state.defaultValues = {}
}
} catch (e) {
console.error("Failed to fetch param values:", e)
state.defaultValues = {}
}
state.loadingValues = false
// Resolve slug now that layout is available (uses stored route params)
resolveActiveSectionSlug(lastParams)
scheduleSyncInputs()
}
function formatSliderValue(val, stepStr, precisionInt, key) {
if (val === null || val === undefined) return "--"
const v = parseFloat(val)
if (Number.isNaN(v)) return val
const volumeKeys = [
"DisengageVolume", "EngageVolume", "PromptVolume",
"PromptDistractedVolume", "RefuseVolume",
"WarningImmediateVolume", "WarningSoftVolume",
]
if (key && volumeKeys.includes(key)) {
if (v === 0) return "Muted"
if (v === 101) return "Auto"
return `${v}%`
}
if (precisionInt !== undefined && precisionInt !== null) {
return Number(v.toFixed(precisionInt)).toString()
}
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
const dec = stepStr.split(".")[1].length
return Number(v.toFixed(dec)).toString()
}
function formatNumericForInput(value, precision) {
const n = Number(value)
if (!Number.isFinite(n)) return ""
return Number(n.toFixed(precision)).toString()
}
function formatStepValue(step, precision) {
const n = Number(step)
if (!Number.isFinite(n)) return "1"
return Number(n.toFixed(Math.max(0, precision))).toString()
}
function numericBounds(param) {
const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1),
}
const toFinite = (value) => {
const n = Number(value)
return Number.isFinite(n) ? n : null
}
if (param.key === "ScreenBrightness") {
return { min: 1, max: 101, step: 1 }
}
if (param.key === "ScreenBrightnessOnroad") {
return { min: 0, max: 101, step: 1 }
}
// Personality jerk params are stored as percentage-style integers (25..200).
// Layout metadata currently uses normalized 0.5..3.0 ranges, which breaks
// the +/- stepper and clamps values like 50 down to 3.
if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
return { min: 25, max: 200, step: 1 }
}
if (param.key === "SteerKP") {
const base = toFinite(state.values.SteerKPStock) || toFinite(state.values.SteerKP) || 0.6
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
if (param.key === "SteerLatAccel") {
const base = toFinite(state.values.SteerLatAccelStock) || toFinite(state.values.SteerLatAccel) || 2.0
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 }
}
if (param.key === "SteerRatio") {
const base = toFinite(state.values.SteerRatioStock) || toFinite(state.values.SteerRatio) || 15.0
return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
return defaultBounds
}
function coerceValueByType(rawValue, dataType) {
if (dataType === "int") {
const n = Number.parseInt(rawValue, 10)
return Number.isFinite(n) ? n : rawValue
}
if (dataType === "float") {
const n = Number.parseFloat(rawValue)
return Number.isFinite(n) ? n : rawValue
}
return rawValue
}
function stepPrecision(step, explicitPrecision) {
if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
const parsed = Number.parseInt(explicitPrecision, 10)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
const stepStr = String(step ?? "")
if (!stepStr.includes(".")) return 0
return stepStr.split(".")[1].length
}
function clampNumeric(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function snapNumericToBoundsAndStep(rawValue, bounds, precision) {
const min = Number(bounds.min)
const max = Number(bounds.max)
const step = Number(bounds.step)
const value = Number(rawValue)
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null
const clamped = clampNumeric(value, min, max)
if (!Number.isFinite(step) || step <= 0) {
return clampNumeric(Number(clamped.toFixed(precision)), min, max)
}
const snapped = min + Math.round((clamped - min) / step) * step
return clampNumeric(Number(snapped.toFixed(precision)), min, max)
}
function resolveCurrentNumericValue(param, bounds) {
const raw = state.values[param.key]
const precision = stepPrecision(bounds.step, param.precision)
const snapped = snapNumericToBoundsAndStep(raw, bounds, precision)
if (snapped !== null) return snapped
const fallback = Number(bounds.min)
return Number.isFinite(fallback) ? fallback : 0
}
function resolveDefaultNumericValue(param, bounds) {
const precision = stepPrecision(bounds.step, param.precision)
const stockKey = `${param.key}Stock`
// Prefer live vehicle stock values when available.
const liveStock = snapNumericToBoundsAndStep(state.values?.[stockKey], bounds, precision)
if (liveStock !== null) return liveStock
// Fallback to default table stock value if present.
const defaultStock = snapNumericToBoundsAndStep(state.defaultValues?.[stockKey], bounds, precision)
if (defaultStock !== null) return defaultStock
// Final fallback: generic param default.
return snapNumericToBoundsAndStep(state.defaultValues?.[param.key], bounds, precision)
}
function isNumericUpdating(key) {
return !!state.numericUpdating[key]
}
function showParamSnackbar(message, level, timeout = 2200) {
showSnackbar(message, level, timeout, {
key: "device-settings-param-update",
replace: true,
})
}
function syncNumericDisplay(param, rawValue) {
const displayEl = document.getElementById(`ds-display-${param.key}`)
if (!displayEl) return
const bounds = numericBounds(param)
displayEl.textContent = formatSliderValue(
rawValue,
String(bounds.step),
param.precision,
param.key,
)
}
async function updateNumericParam(param, numericValue, options = {}) {
const key = param.key
const current = state.values[key]
const successMessage = options.successMessage
state.numericUpdating = { ...state.numericUpdating, [key]: true }
state.values = { ...state.values, [key]: numericValue }
syncNumericDisplay(param, numericValue)
try {
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value: coerceValueByType(numericValue, param.data_type) }),
})
const data = await res.json()
if (res.ok) {
const updated = (data.updated && typeof data.updated === "object") ? data.updated : {}
const resolvedValue = Object.prototype.hasOwnProperty.call(updated, key) ? updated[key] : numericValue
state.values = { ...state.values, [key]: resolvedValue, ...updated }
state.numericUpdating = { ...state.numericUpdating, [key]: false }
syncNumericDisplay(param, resolvedValue)
showParamSnackbar(successMessage || data.message || `Parameter '${key}' updated.`)
scheduleSyncInputs()
} else {
state.values = { ...state.values, [key]: current }
state.numericUpdating = { ...state.numericUpdating, [key]: false }
syncNumericDisplay(param, current)
showParamSnackbar(data.error || "Failed to update parameter", "error")
}
} catch (e) {
state.values = { ...state.values, [key]: current }
state.numericUpdating = { ...state.numericUpdating, [key]: false }
syncNumericDisplay(param, current)
showParamSnackbar("Network error — is the device reachable?", "error")
}
}
function stepNumericParam(param, direction) {
const bounds = numericBounds(param)
const min = Number(bounds.min)
const max = Number(bounds.max)
const step = Number(bounds.step)
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(step) || step <= 0) return
if (isNumericUpdating(param.key)) return
const current = resolveCurrentNumericValue(param, bounds)
const precision = stepPrecision(step, param.precision)
const epsilon = Math.pow(10, -(precision + 2))
const next = snapNumericToBoundsAndStep(current + (direction * step), bounds, precision)
if (next === null) return
if (Math.abs(next - current) <= epsilon) return
updateNumericParam(param, next)
}
function applyManualNumericParam(param) {
if (isNumericUpdating(param.key)) return
const inputEl = document.getElementById(`ds-manual-${param.key}`)
if (!inputEl) return
const raw = String(inputEl.value ?? "").trim()
if (!raw) {
showParamSnackbar("Enter a value first.", "error")
return
}
const parsed = Number.parseFloat(raw)
if (!Number.isFinite(parsed)) {
showParamSnackbar("Enter a valid number.", "error")
return
}
const bounds = numericBounds(param)
const precision = stepPrecision(bounds.step, param.precision)
const snapped = snapNumericToBoundsAndStep(parsed, bounds, precision)
if (snapped === null) {
showParamSnackbar("Value is out of range.", "error")
return
}
inputEl.value = formatNumericForInput(snapped, precision)
const current = resolveCurrentNumericValue(param, bounds)
const epsilon = Math.pow(10, -(precision + 2))
if (Math.abs(snapped - current) <= epsilon) return
updateNumericParam(param, snapped)
}
async function resetNumericParam(param) {
const bounds = numericBounds(param)
let defaultValue = resolveDefaultNumericValue(param, bounds)
if (defaultValue === null) {
const loaded = await fetchDefaultValues()
if (!loaded) {
showParamSnackbar("Couldn't load defaults. Try refreshing the page.", "error")
return
}
defaultValue = resolveDefaultNumericValue(param, bounds)
}
if (defaultValue === null) {
showParamSnackbar("No default value available for this setting.", "error")
return
}
if (isNumericUpdating(param.key)) return
const current = resolveCurrentNumericValue(param, bounds)
const precision = stepPrecision(bounds.step, param.precision)
const epsilon = Math.pow(10, -(precision + 2))
if (Math.abs(defaultValue - current) <= epsilon) return
updateNumericParam(param, defaultValue, {
successMessage: `Parameter '${param.key}' reset to default.`,
})
}
async function updateParam(key, elType) {
const current = state.values[key]
const el = document.getElementById(`ds-${key}`)
if (!el) return
const param = state.paramMetaByKey[key] || {}
let formattedVal
if (elType === "checkbox") {
formattedVal = !!el.checked
} else if (elType === "dropdown") {
formattedVal = coerceValueByType(el.value, param.data_type)
} else {
formattedVal = coerceValueByType(el.value, param.data_type)
}
try {
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value: formattedVal }),
})
const data = await res.json()
if (res.ok) {
const updated = (data.updated && typeof data.updated === "object") ? data.updated : {}
state.values = { ...state.values, [key]: formattedVal, ...updated }
showParamSnackbar(data.message || `Parameter '${key}' updated.`)
scheduleSyncInputs()
} else {
revertInput(key, current, elType)
showParamSnackbar(data.error || "Failed to update parameter", "error")
}
} catch (e) {
revertInput(key, current, elType)
showParamSnackbar("Network error — is the device reachable?", "error")
}
}
function revertInput(key, current, elType) {
const el = document.getElementById(`ds-${key}`)
if (!el) return
if (elType === "checkbox") {
el.checked = !!current
return
}
if (elType === "dropdown") {
el.value = toSelectValue(current)
return
}
el.value = current
}
function toggleManage(key) {
state.expanded = { ...state.expanded, [key]: !state.expanded[key] }
scheduleSyncInputs()
}
function matchesFilter(p) {
if (!state.filter) return true
const q = state.filter.toLowerCase()
return p.label.toLowerCase().includes(q) || p.key.toLowerCase().includes(q)
}
function clearSearchFilter() {
if (!state.filter) return
state.filter = ""
scheduleSyncInputs()
}
function handleSectionTabClick(sectionSlug, event) {
if (!sectionSlug || sectionSlug === state.activeSectionSlug) return
// Preserve horizontal tab strip position on mobile when switching sections.
const tabsEl = event?.currentTarget?.closest(".ds-tabs")
const preservedScrollLeft = tabsEl ? tabsEl.scrollLeft : null
state.activeSectionSlug = sectionSlug
if (preservedScrollLeft !== null) {
requestAnimationFrame(() => {
const nextTabsEl = document.getElementById("ds-tabs")
if (nextTabsEl) nextTabsEl.scrollLeft = preservedScrollLeft
})
}
}
function renderSettingRow(p) {
if (p.parent_key && !state.filter) {
if (!state.values[p.parent_key]) return ""
if (!state.expanded[p.parent_key]) return ""
}
const isNumeric = p.ui_type === "numeric"
const isChild = p.parent_key ? "ds-child-modifier" : ""
return html`
<div class="ds-row ${isNumeric ? "ds-row-numeric" : ""} ${isChild}">
<div class="ds-row-info">
<div class="ds-row-text">
<span class="ds-row-label">${p.label}</span>
${p.description ? html`<div class="ds-row-desc">${p.description}</div>` : ""}
${() => p.is_parent_toggle && state.values[p.key] ? html`
<div class="ds-manage-btn" @click="${() => toggleManage(p.key)}">
${state.expanded[p.key] ? "Close" : "Manage"}
<i class="bi bi-chevron-${state.expanded[p.key] ? "up" : "down"}"></i>
</div>
` : ""}
</div>
${isNumeric ? html`<span class="ds-row-value" id="ds-display-${p.key}">${() => {
const currentValue = state.values[p.key]
const bounds = numericBounds(p)
return currentValue !== undefined ? formatSliderValue(currentValue, String(bounds.step), p.precision, p.key) : ".."
}}</span>` : ""}
</div>
${isNumeric ? html`
<div class="ds-stepper-container">
${(() => {
const bounds = numericBounds(p)
const currentNumeric = resolveCurrentNumericValue(p, bounds)
const precision = stepPrecision(bounds.step, p.precision)
const epsilon = Math.pow(10, -(precision + 2))
const updating = isNumericUpdating(p.key)
const canDecrease = !updating && currentNumeric > (Number(bounds.min) + epsilon)
const canIncrease = !updating && currentNumeric < (Number(bounds.max) - epsilon)
const defaultNumeric = resolveDefaultNumericValue(p, bounds)
const defaultLabel = defaultNumeric !== null
? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key)
: "N/A"
const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon
const stepLabel = formatStepValue(bounds.step, precision)
return html`
<div class="ds-stepper">
<button
class="ds-stepper-btn"
?disabled="${!canDecrease}"
@click="${() => stepNumericParam(p, -1)}">-</button>
<div class="ds-stepper-meta">
<span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
<span class="ds-step-value">Step: ${stepLabel} per click</span>
<span class="ds-default-value">Default: ${defaultLabel}</span>
<div class="ds-manual-row">
<input
type="number"
class="ds-manual-input"
id="ds-manual-${p.key}"
min="${bounds.min}"
max="${bounds.max}"
step="${bounds.step}"
?disabled="${updating}"
value="${() => formatNumericForInput(resolveCurrentNumericValue(p, numericBounds(p)), precision)}"
@keydown="${(e) => {
if (e.key !== "Enter") return
e.preventDefault()
applyManualNumericParam(p)
}}" />
<button
class="ds-apply-btn"
?disabled="${updating}"
@click="${() => applyManualNumericParam(p)}">Apply</button>
</div>
<button
class="ds-reset-btn"
?disabled="${!canReset}"
@click="${() => resetNumericParam(p)}">Reset to Default</button>
</div>
<button
class="ds-stepper-btn"
?disabled="${!canIncrease}"
@click="${() => stepNumericParam(p, 1)}">+</button>
</div>
`
})()}
</div>
` : p.ui_type === "dropdown" ? html`
<select
class="ds-select"
id="ds-${p.key}"
data-endpoint="${p.options_endpoint || ""}"
@change="${() => updateParam(p.key, "dropdown")}">
<option value="">Loading...</option>
</select>
` : html`
<input
type="checkbox"
class="ds-toggle"
id="ds-${p.key}"
@change="${() => updateParam(p.key, "checkbox")}" />
`}
</div>
`
}
function hasChildParams(paramsList, key) {
return paramsList.some(param => param.parent_key === key)
}
function renderSettingTree(paramsList, parentKey = null) {
const directChildren = paramsList.filter(param => (param.parent_key || null) === parentKey)
const rendered = []
for (const param of directChildren) {
const row = renderSettingRow(param)
if (row) rendered.push(row)
if (!hasChildParams(paramsList, param.key)) continue
if (!state.values[param.key] || !state.expanded[param.key]) continue
rendered.push(...renderSettingTree(paramsList, param.key))
}
return rendered
}
// Resolve the active section slug imperatively — NEVER inside a reactive expression
function resolveActiveSectionSlug(params) {
if (state.layout.length === 0) return
const sections = getSectionsWithSlug()
const validSlugs = new Set(sections.map(s => s.slug))
const requestedSlug = String(params?.section || "").toLowerCase()
const fallbackSlug = sections[0].slug
const nextSlug = validSlugs.has(requestedSlug)
? requestedSlug
: (validSlugs.has(state.activeSectionSlug) ? state.activeSectionSlug : fallbackSlug)
if (state.activeSectionSlug !== nextSlug) {
state.activeSectionSlug = nextSlug
}
}
export function DeviceSettings({ params }) {
lastParams = params
if (!state.fetched) {
state.fetched = true
fetchLayoutAndParams()
}
// Resolve slug imperatively (safe: runs in function body, not reactive context)
resolveActiveSectionSlug(params)
return html`
<div class="ds-wrapper">
<h2>Toggles</h2>
<div class="ds-search-row">
<input
class="ds-search"
type="text"
placeholder="Search settings..."
value="${() => state.filter}"
@keydown="${(e) => {
if (e.key === "Escape") clearSearchFilter()
}}"
@input="${(e) => {
state.filter = e.target.value
scheduleSyncInputs()
}}" />
${() => state.filter ? html`
<button
class="ds-search-clear"
@click="${() => clearSearchFilter()}">
Clear
</button>
` : ""}
</div>
${() => {
if (state.loadingLayout || state.loadingValues) {
return html`<div class="ds-loading">Loading configuration...</div>`
}
const sections = getSectionsWithSlug()
if (sections.length === 0) {
return html`<div class="ds-empty">No settings available.</div>`
}
// Sync DOM inputs after ArrowJS renders (safe: syncScheduled is non-reactive)
scheduleSyncInputs()
// Search active → show matching results from ALL sections
if (state.filter) {
const MAX_PER_SECTION = 25
const searchResults = sections
.map(s => ({ ...s, matches: s.params.filter(p => matchesFilter(p)) }))
.filter(s => s.matches.length > 0)
const totalMatches = searchResults.reduce((n, s) => n + s.matches.length, 0)
return html`
<div class="ds-status-bar">
<span>${totalMatches} result${totalMatches !== 1 ? "s" : ""} across ${searchResults.length} section${searchResults.length !== 1 ? "s" : ""}</span>
<span>${state.allKeys.length} total mapped</span>
</div>
${searchResults.map(section => html`
<div class="ds-section">
<div class="ds-section-header ds-static-header">
<i class="bi ${section.icon}"></i>
<span class="ds-section-title">${section.name} (${section.matches.length})</span>
</div>
<div class="ds-section-body">
${section.matches.slice(0, MAX_PER_SECTION).map(p => renderSettingRow(p))}
${section.matches.length > MAX_PER_SECTION ? html`<div class="ds-row"><span class="ds-row-label" style="opacity:0.5">+${section.matches.length - MAX_PER_SECTION} more — refine your search</span></div>` : ""}
</div>
</div>
`)}
${totalMatches === 0 ? html`<div class="ds-empty">No settings match your search.</div>` : ""}
`
}
// No search → normal tab-based single-section view
const activeSection = sections.find(s => s.slug === state.activeSectionSlug) || sections[0]
const visibleParams = activeSection.params.filter(p => matchesFilter(p))
return html`
<div class="ds-tabs" id="ds-tabs">
${sections.map(section => html`
<button
class="ds-tab ${section.slug === state.activeSectionSlug ? "active" : ""}"
@click="${(e) => {
handleSectionTabClick(section.slug, e)
}}">
<i class="bi ${section.icon}"></i>
<span>${section.name}</span>
</button>
`)}
</div>
<div class="ds-status-bar">
<span>${activeSection.params.length} settings in ${activeSection.name}</span>
<span>${state.allKeys.length} total mapped</span>
</div>
<div class="ds-section">
<div class="ds-section-header ds-static-header">
<i class="bi ${activeSection.icon}"></i>
<span class="ds-section-title">${activeSection.name} (${visibleParams.length})</span>
</div>
<div class="ds-section-body">
${renderSettingTree(visibleParams)}
</div>
</div>
${visibleParams.length === 0 ? html`<div class="ds-empty">No settings match your search.</div>` : ""}
`
}}
</div>
`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
.door-control-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
padding: var(--border-radius-xl);
text-align: center;
text-decoration: none;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.door-control-button:hover {
box-shadow: var(--shadow-md);
color: var(--secondary-fg);
transform: var(--hover-scale-sm);
}
.door-control-button + .door-control-button {
margin-top: var(--padding-sm);
}
.door-control-text {
color: var(--text-color);
text-align: center;
}
.door-control-title {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
padding: var(--padding-sm);
text-align: center;
width: 100%;
}
.door-control-wrapper {
display: flex;
justify-content: center;
}
.door-control-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--main-fg);
display: flex;
flex-direction: column;
margin-top: var(--padding-xl);
max-width: var(--width-lg);
padding: var(--padding-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.door-control-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
@@ -0,0 +1,28 @@
import { html } from "/assets/vendor/arrow-core.js"
export function DoorControl () {
async function lockDoors () {
const response = await fetch("/api/doors/lock", { method: "POST" })
const result = await response.json()
showSnackbar(result.message || "Doors locked!")
}
async function unlockDoors () {
const response = await fetch("/api/doors/unlock", { method: "POST" })
const result = await response.json()
showSnackbar(result.message || "Doors unlocked!")
}
return html`
<div class="door-control-wrapper">
<section class="door-control-widget">
<div class="door-control-title">Lock/Unlock Doors</div>
<p class="door-control-text">
Remotely lock or unlock your car doors using the buttons below.
</p>
<button class="door-control-button" @click="${lockDoors}">🔒 Lock Doors</button>
<button class="door-control-button" @click="${unlockDoors}">🔓 Unlock Doors</button>
</section>
</div>
`
}
@@ -0,0 +1,180 @@
.error-logs-wrapper {
display: flex;
justify-content: center;
}
#errorLogs {
max-width: calc(100% - var(--padding-xl));
width: 100%;
}
#errorLogs > div {
display: flex;
flex-direction: column;
margin-top: var(--padding-xl);
overflow: hidden;
}
#errorLogs .fileEntry {
border: none;
display: flex;
justify-content: center;
padding: var(--padding-sm) var(--border-radius-xl);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
#errorLogs .fileEntry:hover {
background-color: var(--sidebar-active-bg);
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
#errorLogs #fileList {
color: var(--text-color);
margin-bottom: var(--border-radius-xl);
overflow-y: auto;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
#errorLogs #fileList,
#errorLogs #fileViewer {
background-color: var(--sidebar-bg);
border: none;
border-radius: var(--border-radius-lg);
max-width: calc(100% - var(--padding-xl));
scrollbar-color: var(--thumb-color) var(--track-color);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
padding: var(--padding-base);
width: 100%;
}
#errorLogs #fileList:hover,
#errorLogs #fileViewer:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
#errorLogs #fileViewer {
scrollbar-width: auto;
}
#errorLogs #fileViewer > div {
display: flex;
justify-content: space-between;
}
#errorLogs #fileViewer a {
color: inherit;
text-decoration: none;
}
#errorLogs #fileViewer button {
background-color: transparent;
border: none;
border-radius: 50%;
color: var(--secondary-fg);
height: calc(3 * var(--border-width-thick));
padding: var(--padding-sm);
transition:
background-color var(--transition-base),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
width: calc(3 * var(--border-width-thick));
}
#errorLogs #fileViewer button:hover {
transform: var(--hover-scale-sm);
}
#errorLogs #fileViewer pre {
color: var(--main-fg);
font-family: var(--font-mono);
white-space: pre-wrap;
word-wrap: break-word;
margin-top: var(--padding-sm);
}
#errorLogs p {
margin: 0;
padding: 0;
}
.time-since {
display: none;
}
@media only screen and (min-width: 769px) {
.error-logs-wrapper {
margin: 0 auto;
width: 50%;
}
#errorLogs .fileEntry {
justify-content: space-between;
}
.time-since {
display: block;
}
}
.error-logs-wrapper #errorLogs #fileList {
background-color: var(--sidebar-bg);
border-radius: var(--border-radius-lg);
margin-left: 0;
max-width: calc(100% - var(--padding-xl));
overflow-y: auto;
padding: var(--padding-base);
scrollbar-color: var(--thumb-color) var(--track-color);
scrollbar-width: auto;
width: 100%;
}
.error-logs-wrapper #errorLogs #fileList .fileEntry {
cursor: pointer;
display: flex;
padding: var(--padding-sm) var(--padding-base);
}
.error-logs-wrapper #errorLogs #fileList .fileEntry:not(:last-child) {
border-bottom: 1px solid var(--sidebar-border-color);
}
.error-logs-wrapper #errorLogs #fileList .fileEntry:hover {
background-color: var(--sidebar-active-bg);
}
.error-logs-wrapper #errorLogs #fileList .fileEntry.header {
background-color: var(--secondary-bg);
cursor: default;
font-weight: bold;
}
.delete-all-button {
background-color: var(--danger-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
margin-top: var(--margin-sm);
padding: var(--padding-sm) var(--padding-base);
text-align: center;
transition:
background-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
width: auto;
}
.delete-all-button:hover {
background-color: var(--danger-hover-bg);
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.delete-all-button[disabled] {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
@@ -0,0 +1,190 @@
import { html, reactive } from "/assets/vendor/arrow-core.js";
import { formatSecondsToHuman, parseErrorLogToDate } from "/assets/js/utils.js";
import { Modal } from "/assets/components/modal.js";
const state = reactive({
loading: true,
files: [],
selectedLog: undefined,
confirmDelete: {
visible: false,
filename: null,
},
showDeleteAllModal: false,
});
;(async () => {
const res = await fetch("/api/error_logs", {
headers: { Accept: "application/json" }
});
const data = await res.json();
state.files = data.map(f => {
const date = parseErrorLogToDate(f);
return {
filename: f,
date: date.toLocaleString(),
timeSince: (Date.now() - date.getTime()) / 1000,
};
});
state.loading = false;
})();
async function deleteAllLogs() {
state.showDeleteAllModal = false;
try {
const res = await fetch('/api/error_logs/delete_all', { method: 'DELETE' });
if (res.ok) {
showSnackbar("All error logs deleted!");
state.files = [];
state.selectedLog = undefined;
} else {
showSnackbar("Delete all failed...", "error");
}
} catch (err) {
showSnackbar("An error occurred while deleting error logs...", "error");
}
}
export function ErrorLogs() {
return html`
<div class="error-logs-wrapper">
<div id="errorLogs">
<div id="fileList">
${() =>
state.loading
? html`<div class="fileEntry"><p>Loading...</p></div>`
: state.files.length === 0
? html`<div class="fileEntry"><p>No error logs!</p></div>`
: state.files.map(file => html`
<div class="fileEntry"
@click="${() => {
state.selectedLog = state.selectedLog === file.filename ? undefined : file.filename;
}}">
<p>${file.date}</p>
<p class="time-since">
${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}
</p>
</div>
`)
}
${() =>
state.files.length > 0
? html`
<div style="text-align: center; padding: 1rem;">
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
>
Delete All Error Logs
</button>
</div>
`
: ""
}
</div>
${() =>
state.selectedLog ? Logviewer(state.selectedLog, () => (state.selectedLog = undefined)) : ""
}
</div>
</div>
${() => state.confirmDelete.visible ? Modal({
title: "Confirm Delete",
message: html`Are you sure you want to delete <strong>${state.confirmDelete.filename}</strong>?`,
onConfirm: async () => {
const filename = state.confirmDelete.filename;
if (!filename) return;
await fetch(`/api/error_logs/${filename}`, {
method: "DELETE"
});
state.files = state.files.filter(f => f.filename !== filename);
state.confirmDelete.visible = false;
state.confirmDelete.filename = null;
if (state.selectedLog === filename) {
state.selectedLog = undefined;
}
},
onCancel: () => {
state.confirmDelete.visible = false;
state.confirmDelete.filename = null;
}
}) : ""}
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all error logs? This action cannot be undone...",
onConfirm: deleteAllLogs,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
`;
}
function Logviewer(filename, closeFn) {
const logState = reactive({
loading: true,
content: ""
});
;(async () => {
const res = await fetch(`/api/error_logs/${filename}`);
logState.content = await res.text();
logState.loading = false;
setTimeout(() => {
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth'
});
}, 0);
})();
const deleteLog = () => {
state.confirmDelete.filename = filename;
state.confirmDelete.visible = true;
};
const copyLog = () => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(logState.content);
} else {
const textArea = document.createElement("textarea");
textArea.value = logState.content;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
} catch (err) {
console.error("Fallback: Oops, unable to copy", err);
}
document.body.removeChild(textArea);
}
showSnackbar("Copied to clipboard!");
};
return html`
<div id="fileViewer">
<div>
<p>${filename}</p>
<button @click="${closeFn}">
<i class="bi bi-x-lg"></i>
</button>
<button @click="${deleteLog}">
<i class="bi bi-trash"></i>
</button>
<button @click="${copyLog}">
<i class="bi bi-clipboard"></i>
</button>
<a href="/api/error_logs/${filename}" download>
<button>
<i class="bi bi-download"></i>
</button>
</a>
</div>
<pre>${() => (logState.loading ? "Loading..." : logState.content)}</pre>
</div>
`;
}
@@ -0,0 +1,137 @@
/* ――― Galaxy Pairing Widget ――― */
.galaxy-wrapper {
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
.galaxy-loading {
color: var(--text-muted);
padding: var(--padding-xl);
text-align: center;
}
.galaxy-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--gap-lg);
max-width: var(--width-lg);
padding: var(--padding-xl);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.galaxy-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
/* ――― Status Badge ――― */
.galaxy-status-badge {
align-items: center;
border-radius: 2rem;
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
gap: var(--gap-xs);
padding: 0.3rem 1rem;
}
.galaxy-paired {
background-color: rgba(94, 200, 200, 0.15);
color: var(--success-fg);
}
.galaxy-unpaired {
background-color: rgba(224, 85, 119, 0.15);
color: var(--danger-fg);
}
/* ――― Text ――― */
.galaxy-text {
color: var(--text-color);
line-height: var(--line-height-base);
text-align: center;
}
/* ――― URL Link ――― */
.galaxy-url {
color: var(--main-fg);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
text-decoration: underline;
text-underline-offset: 3px;
transition: color var(--transition-fast);
word-break: break-all;
}
.galaxy-url:hover {
color: var(--accent-hover-bg);
}
/* ――― Input Group ――― */
.galaxy-input-group {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
}
.galaxy-input {
background-color: var(--input-bg);
border: var(--border-style-input);
border-radius: var(--border-radius-lg);
box-sizing: border-box;
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-base);
outline: none;
padding: var(--padding-sm) var(--padding-base);
transition: box-shadow var(--transition-fast);
width: 100%;
}
.galaxy-input:focus {
box-shadow: 0 0 0 2px var(--main-fg);
}
/* ――― Buttons ――― */
.galaxy-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
text-align: center;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.galaxy-button:hover:not(:disabled) {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.galaxy-button:disabled {
opacity: var(--disabled-opacity);
}
.galaxy-button-danger {
background-color: rgba(224, 85, 119, 0.2);
color: var(--danger-fg);
}
.galaxy-button-danger:hover:not(:disabled) {
background-color: rgba(224, 85, 119, 0.35);
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.galaxy-wrapper {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
}
@@ -0,0 +1,166 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js"
const state = reactive({
paired: false,
url: "",
password: "",
loading: true,
submitting: false,
showUnpairModal: false,
fetched: false,
})
async function fetchStatus() {
state.loading = true
try {
const res = await fetch("/api/galaxy/status")
const data = await res.json()
state.paired = data.paired
state.url = data.url
} catch (e) {
console.error("Failed to fetch Galaxy status:", e)
}
state.loading = false
}
async function pair() {
if (state.submitting) return
const pw = state.password.trim()
if (pw.length < 6) {
showSnackbar("Password must be at least 6 characters.")
return
}
state.submitting = true
try {
const res = await fetch("/api/galaxy/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw }),
})
const data = await res.json()
if (res.ok) {
state.paired = true
state.url = data.url
state.password = ""
// Clear the DOM input value directly (Arrow.js doesn't two-way bind)
const input = document.querySelector(".galaxy-input")
if (input) input.value = ""
showSnackbar(data.message || "Paired!")
} else {
showSnackbar(data.error || "Pairing failed.")
}
} catch (e) {
showSnackbar("Network error — is the device reachable?")
}
state.submitting = false
}
async function unpair() {
state.showUnpairModal = false
state.submitting = true
try {
const res = await fetch("/api/galaxy/unpair", { method: "POST" })
const data = await res.json()
if (res.ok) {
state.paired = false
state.url = ""
showSnackbar(data.message || "Unpaired!")
} else {
showSnackbar(data.error || "Unpairing failed.")
}
} catch (e) {
showSnackbar("Network error — is the device reachable?")
}
state.submitting = false
}
export function GalaxyPairing() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Galaxy Pairing Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Galaxy pairing requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (!state.fetched) {
state.fetched = true
fetchStatus()
}
return html`
<div class="galaxy-wrapper">
<h2>Galaxy</h2>
${() => {
if (state.loading) {
return html`<div class="galaxy-loading">Checking pairing status…</div>`
}
if (state.paired) {
return html`
<section class="galaxy-widget">
<div class="galaxy-status-badge galaxy-paired">
<i class="bi bi-check-circle-fill"></i> Paired
</div>
<p class="galaxy-text">
Your device is paired with Galaxy. Access it remotely at:
</p>
<a class="galaxy-url" href="${state.url}" target="_blank" rel="noopener">
${state.url}
</a>
<button
class="galaxy-button galaxy-button-danger"
@click="${() => { state.showUnpairModal = true }}"
disabled="${() => state.submitting}"
>
${() => state.submitting ? "Unpairing…" : "Unpair"}
</button>
</section>
${() => state.showUnpairModal ? Modal({
title: "Confirm Unpair",
message: "Are you sure you want to unpair from Galaxy? You will lose remote access until you pair again.",
onConfirm: unpair,
onCancel: () => { state.showUnpairModal = false },
confirmText: "Unpair",
}) : ""}
`
}
return html`
<section class="galaxy-widget">
<div class="galaxy-status-badge galaxy-unpaired">
<i class="bi bi-x-circle-fill"></i> Not Paired
</div>
<p class="galaxy-text">
Pair your device with Galaxy to access The Pond remotely from anywhere.
Enter a password to secure your connection.
</p>
<div class="galaxy-input-group">
<input
class="galaxy-input"
type="password"
placeholder="Password (min 6 characters)"
@input="${(e) => { state.password = e.target.value }}"
@keydown="${(e) => { if (e.key === 'Enter') pair() }}"
/>
<button
class="galaxy-button"
@click="${pair}"
disabled="${() => state.submitting || state.password.trim().length < 6}"
>
${() => state.submitting ? "Pairing…" : "Pair"}
</button>
</div>
</section>
`
}}
</div>
`
}
@@ -0,0 +1,117 @@
.longManeuverPage {
max-width: var(--width-xxl);
}
.longManeuverCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 90%;
padding: var(--border-radius-xl);
}
.longManeuverIntro {
margin-top: 0;
}
.longManeuverActions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.longManeuverButton {
background: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
}
.longManeuverButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.longManeuverButton:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.longManeuverButton.danger {
background: var(--danger-bg);
}
.longManeuverButton.danger:hover {
background: var(--danger-hover-bg);
}
.longManeuverError {
color: var(--danger-fg);
margin-top: var(--margin-base);
}
.longManeuverMuted {
color: var(--text-muted);
}
.longManeuverStatusGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.longManeuverStatusGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.longManeuverCurrent {
margin-top: var(--margin-base);
}
.longManeuverCurrent p {
font-size: var(--font-size-sm);
margin: 0 0 var(--margin-xs);
}
.longManeuverInstructions,
.longManeuverHistory {
margin-top: var(--margin-base);
}
.longManeuverInstructions h3,
.longManeuverHistory h3 {
margin-bottom: var(--margin-xs);
}
.longManeuverInstructions ol,
.longManeuverHistory ol {
margin: var(--margin-xs) 0 0;
padding-left: 1.4rem;
}
.longManeuverInstructions li,
.longManeuverHistory li {
font-size: var(--font-size-sm);
margin-bottom: 0.25rem;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.longManeuverCard {
max-width: 100%;
}
.longManeuverActions {
flex-direction: column;
}
.longManeuverStatusGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,183 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
busy: false,
error: "",
data: null,
})
let initialized = false
let pollHandle = null
const POLL_INTERVAL_MS = 1000
function isLongitudinalManeuversRouteActive() {
return window.location.pathname === "/longitudinal_maneuvers"
}
function safeNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
function formatAgeSeconds(value) {
const sec = safeNumber(value, -1)
if (sec < 0) return "unknown"
if (sec < 1) return "just now"
if (sec < 60) return `${Math.round(sec)}s ago`
const min = sec / 60
if (min < 60) return `${Math.round(min)}m ago`
const hr = min / 60
return `${Math.round(hr)}h ago`
}
async function fetchStatus() {
try {
const response = await fetch("/api/longitudinal_maneuvers/status")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load maneuver status")
}
state.data = payload
state.error = ""
} catch (error) {
state.error = error?.message || "Failed to load maneuver status"
} finally {
state.loading = false
}
}
function stopPolling() {
if (!pollHandle) return
clearTimeout(pollHandle)
pollHandle = null
}
function ensurePolling() {
if (pollHandle) return
const poll = async () => {
if (!isLongitudinalManeuversRouteActive()) {
pollHandle = null
return
}
if (document.visibilityState !== "visible") {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
return
}
await fetchStatus()
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
async function runAction(action) {
if (state.busy) return
state.busy = true
try {
const response = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || `Failed to ${action} maneuvers`)
}
state.data = payload
state.error = ""
showSnackbar(payload.message || "Action complete.")
} catch (error) {
const message = error?.message || `Failed to ${action} maneuvers`
state.error = message
showSnackbar(message, "error")
} finally {
state.busy = false
}
}
function initialize() {
if (initialized) return
initialized = true
fetchStatus()
ensurePolling()
}
function statusLine(label, value) {
return html`<p><strong>${label}:</strong> ${value}</p>`
}
export function LongitudinalManeuvers() {
initialize()
return html`
<div class="longManeuverPage">
<h2>Long Maneuvers</h2>
<div class="longManeuverCard">
<p class="longManeuverIntro">
Run the longitudinal maneuver suite from your phone and monitor progress live.
</p>
<div class="longManeuverActions">
<button
class="longManeuverButton"
?disabled="${state.busy}"
@click="${() => runAction("start")}">
Start / Arm
</button>
<button
class="longManeuverButton danger"
?disabled="${state.busy}"
@click="${() => runAction("stop")}">
Stop
</button>
</div>
${() => state.loading ? html`<p class="longManeuverMuted">Loading status...</p>` : ""}
${() => state.error ? html`<p class="longManeuverError">${state.error}</p>` : ""}
${() => state.data ? html`
<div class="longManeuverStatusGrid">
${statusLine("Mode Enabled", state.data.modeEnabled ? "Yes" : "No")}
${statusLine("State", state.data.state || "idle")}
${statusLine("Onroad", state.data.isOnroad ? "Yes" : "No")}
${statusLine("Engaged", state.data.isEngaged ? "Yes" : "No")}
${statusLine("Phase", state.data.phase || "n/a")}
${statusLine("Paddle Mode", state.data.paddleMode || "auto")}
${statusLine("Step", `${safeNumber(state.data.stepIndex, 0)}/${safeNumber(state.data.stepTotal, 0)}`)}
${statusLine("Run", `${safeNumber(state.data.runIndex, 0)}/${safeNumber(state.data.runTotal, 0)}`)}
${statusLine("Updated", formatAgeSeconds(state.data.updatedAgeSec))}
</div>
<div class="longManeuverCurrent">
<p><strong>Current Maneuver:</strong> ${state.data.maneuver || "n/a"}</p>
<p><strong>Popup Text:</strong> ${state.data.uiText1 || ""} ${state.data.uiText2 ? `| ${state.data.uiText2}` : ""}</p>
</div>
<div class="longManeuverInstructions">
<h3>Quick Guide</h3>
<ol>
<li>Find a large, empty, straight road or lot with no traffic.</li>
<li>Press <strong>Start / Arm</strong> here, then engage openpilot with SET.</li>
<li>Keep full supervision and be ready to disengage at all times.</li>
<li>For GM pedal-long cars, the suite runs both pedal-only and pedal+paddle phases automatically.</li>
<li>When the status says complete, collect logs and generate your HTML report.</li>
</ol>
</div>
<div class="longManeuverHistory">
<h3>Progress Chain</h3>
${() => (state.data.history || []).length ? html`
<ol>
${(state.data.history || []).slice().reverse().map((line) => html`<li>${line}</li>`)}
</ol>
` : html`<p class="longManeuverMuted">No steps logged yet.</p>`}
</div>
` : ""}
</div>
</div>
`
}
@@ -0,0 +1,253 @@
.mm-wrapper {
color: var(--text-color);
max-width: 1200px;
padding-right: var(--padding-base);
}
.mm-toolbar {
align-items: center;
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-wrap: wrap;
gap: var(--gap-md);
justify-content: space-between;
margin: var(--margin-base) 0;
padding: var(--padding-base);
}
.mm-summary {
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: var(--gap-lg);
}
.mm-actions {
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
}
.mm-status {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
}
.mm-progress {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
color: var(--text-color);
padding: 0.35rem 0.6rem;
}
.mm-filters {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
}
.mm-search,
.mm-select {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
color: var(--text-color);
font-size: var(--font-size-base);
min-height: 2.15rem;
padding: 0.4rem 0.55rem;
}
.mm-search {
min-width: min(420px, 90vw);
}
.mm-filter-label,
.mm-filter-checkbox {
align-items: center;
color: var(--text-muted);
display: flex;
gap: var(--gap-xs);
}
.mm-filter-break {
display: none;
}
.mm-series {
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
margin-bottom: var(--margin-base);
overflow: hidden;
}
.mm-series-header {
align-items: center;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--sidebar-border-color);
display: flex;
justify-content: space-between;
padding: 0.55rem 0.8rem;
}
.mm-series-header h3 {
font-size: 1.02rem;
margin: 0;
padding: 0;
}
.mm-series-header span {
color: var(--text-muted);
}
.mm-row {
align-items: center;
border-bottom: 1px solid var(--sidebar-border-color);
display: flex;
gap: var(--gap-md);
justify-content: space-between;
padding: 0.75rem 0.8rem;
}
.mm-row:last-child {
border-bottom: none;
}
.mm-row-main {
min-width: 0;
}
.mm-row-title {
align-items: center;
display: flex;
font-size: 1rem;
gap: var(--gap-xs);
}
.mm-row-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.35rem;
}
.mm-row-actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--gap-sm);
}
.mm-btn {
border: none;
border-radius: var(--border-radius-base);
color: var(--text-color);
min-height: 2.2rem;
min-width: 7.5rem;
padding: 0.45rem 0.8rem;
}
.mm-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.mm-btn-primary {
background: var(--success-bg);
color: var(--color-black);
}
.mm-btn-primary:hover:not(:disabled) {
background: var(--success-hover-bg);
}
.mm-btn-secondary {
background: var(--sidebar-active-bg);
}
.mm-btn-secondary:hover:not(:disabled) {
background: var(--accent-hover-bg);
}
.mm-btn-danger {
background: var(--danger-bg);
}
.mm-btn-danger:hover:not(:disabled) {
background: var(--danger-hover-bg);
}
.mm-chip {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: 999px;
color: var(--text-muted);
display: inline-flex;
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
}
.mm-chip-active {
background: var(--success-bg);
border-color: transparent;
color: var(--color-black);
}
.mm-chip-favorite {
background: rgba(212, 160, 96, 0.18);
border-color: rgba(212, 160, 96, 0.5);
color: var(--warning-bg);
}
.mm-chip-warning {
background: rgba(224, 85, 119, 0.12);
border-color: rgba(224, 85, 119, 0.45);
color: var(--danger-fg);
}
.mm-star {
background: transparent;
border: none;
color: #e0b45a;
font-size: 1rem;
line-height: 1;
padding: 0;
}
.mm-empty {
color: var(--text-muted);
padding: 0.7rem 0;
}
@media (max-width: 850px) {
.mm-row {
align-items: flex-start;
flex-direction: column;
}
.mm-row-actions {
width: 100%;
}
.mm-btn {
width: 100%;
}
.mm-search {
min-width: 100%;
}
.mm-filter-break {
display: block;
flex-basis: 100%;
height: 0;
}
}
@@ -0,0 +1,586 @@
import { html, reactive } from "/assets/vendor/arrow-core.js";
const state = reactive({
loading: true,
refreshing: false,
error: "",
actionBusy: false,
sortMode: "alphabetical",
communityFavoriteFilter: "all",
models: [],
currentModel: "",
summary: { installed: 0, missing: 0, total: 0 },
status: {
modelToDownload: "",
downloadAll: false,
downloading: false,
cancelling: false,
progress: "",
isOnroad: false,
terminal: false,
},
});
let initialized = false;
let pollingHandle = null;
let statusInFlight = false;
let lastStatusSignature = "";
const REQUEST_TIMEOUT_MS = 20000;
const ACTIVE_POLL_INTERVAL_MS = 1000;
const IDLE_POLL_INTERVAL_MS = 4000;
function notify(message, variant = "success") {
if (typeof showSnackbar === "function") {
showSnackbar(message, variant);
} else if (variant === "error") {
console.error(message);
} else {
console.log(message);
}
}
function logDebug(message, details = null) {
if (details === null || details === undefined) {
console.log(`[ModelManager] ${message}`);
} else {
console.log(`[ModelManager] ${message}`, details);
}
}
function isModelRouteActive() {
return window.location.pathname === "/manage_models";
}
function safeText(value, fallback = "") {
if (value === null || value === undefined) return fallback;
return String(value);
}
function toBool(value) {
return !!value;
}
function toInt(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function parseReleased(value) {
const ts = Date.parse(safeText(value, ""));
return Number.isNaN(ts) ? 0 : ts;
}
function normalizeSeries(model) {
return safeText(model?.series, "Custom Series") || "Custom Series";
}
function modelSortCompare(a, b) {
if (state.sortMode === "release_date") {
const dateDelta = parseReleased(b?.released) - parseReleased(a?.released);
if (dateDelta !== 0) return dateDelta;
}
return safeText(a?.label, a?.value).localeCompare(
safeText(b?.label, b?.value),
undefined,
{ sensitivity: "base", numeric: true },
);
}
function getFilteredModels() {
let rows = [...state.models].filter(model => model && typeof model === "object");
if (state.communityFavoriteFilter === "yes") {
rows = rows.filter(model => !!model.communityFavorite);
} else if (state.communityFavoriteFilter === "no") {
rows = rows.filter(model => !model.communityFavorite);
}
return rows;
}
function getSeriesGroups() {
const grouped = {};
for (const model of getFilteredModels()) {
const seriesName = normalizeSeries(model);
if (!grouped[seriesName]) grouped[seriesName] = [];
grouped[seriesName].push(model);
}
const seriesNames = Object.keys(grouped);
for (const seriesName of seriesNames) {
grouped[seriesName].sort(modelSortCompare);
}
if (state.sortMode === "release_date") {
seriesNames.sort((a, b) => {
const aNewest = Math.max(...grouped[a].map(model => parseReleased(model?.released)));
const bNewest = Math.max(...grouped[b].map(model => parseReleased(model?.released)));
const delta = bNewest - aNewest;
if (delta !== 0) return delta;
return a.localeCompare(b, undefined, { sensitivity: "base" });
});
} else {
seriesNames.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
return { grouped, seriesNames };
}
function getVisibleModels() {
const { grouped, seriesNames } = getSeriesGroups();
const rows = [];
for (const seriesName of seriesNames) {
rows.push(...grouped[seriesName]);
}
return rows;
}
function getReleaseOrderedModels() {
return getFilteredModels().sort(modelSortCompare);
}
function getInstalledModels() {
const rows = state.sortMode === "release_date" ? getReleaseOrderedModels() : getVisibleModels();
return rows.filter(model => !!model.installed);
}
function getCurrentModelName() {
const current = safeText(state.currentModel, "");
if (!current) return "none";
const match = state.models.find(model => safeText(model?.value, "") === current);
if (!match) return current;
return safeText(match.label, current);
}
async function fetchJson(url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok) {
const message = payload?.error || payload?.message || `Request failed (${response.status})`;
throw new Error(message);
}
return payload;
} finally {
clearTimeout(timer);
}
}
async function fetchStatus() {
if (statusInFlight) return;
statusInFlight = true;
try {
const payload = await fetchJson("/api/models/status");
const models = Array.isArray(payload.models)
? payload.models.filter(model => model && typeof model === "object")
: [];
state.models = models;
state.currentModel = safeText(payload.currentModel, "");
const summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {};
state.summary = {
installed: toInt(summary.installed),
missing: toInt(summary.missing),
total: toInt(summary.total),
};
state.status = {
modelToDownload: safeText(payload.modelToDownload, ""),
downloadAll: toBool(payload.downloadAll),
downloading: toBool(payload.downloading),
cancelling: toBool(payload.cancelling),
progress: safeText(payload.progress, ""),
isOnroad: toBool(payload.isOnroad),
terminal: toBool(payload.terminal),
};
state.error = "";
const signature = [
state.models.length,
state.currentModel,
state.status.downloading,
state.status.downloadAll,
state.status.modelToDownload,
state.status.progress,
].join("|");
if (signature !== lastStatusSignature) {
lastStatusSignature = signature;
logDebug("Status updated", {
models: state.models.length,
currentModel: state.currentModel || "none",
downloading: state.status.downloading,
progress: state.status.progress || "Idle",
});
}
} catch (error) {
state.error = error?.message || String(error);
logDebug("Status fetch failed", state.error);
} finally {
statusInFlight = false;
state.loading = false;
state.refreshing = false;
}
}
async function refreshAll(showToast = false) {
state.refreshing = true;
if (state.models.length === 0) {
state.loading = true;
}
await fetchStatus();
if (showToast && !state.error) {
notify("Model list refreshed.");
}
}
function ensurePolling() {
if (pollingHandle) return;
const poll = async () => {
if (!isModelRouteActive()) {
pollingHandle = null;
return;
}
let nextDelay = IDLE_POLL_INTERVAL_MS;
try {
await fetchStatus();
nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
} finally {
pollingHandle = setTimeout(poll, nextDelay);
}
};
pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS);
}
async function setActiveModel(modelKey) {
const payload = await fetchJson("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "Model", value: modelKey }),
});
notify(payload.message || `Selected "${modelKey}".`);
}
async function startDownload(modelKey) {
const payload = await fetchJson("/api/models/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelKey }),
});
notify(payload.message || `Downloading "${modelKey}"...`);
}
async function startDownloadAll() {
const payload = await fetchJson("/api/models/download_all", { method: "POST" });
notify(payload.message || "Started downloading all models.");
}
async function cancelDownload() {
const payload = await fetchJson("/api/models/cancel", { method: "POST" });
notify(payload.message || "Cancellation requested.");
}
async function deleteModel(modelKey) {
const payload = await fetchJson("/api/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelKey }),
});
notify(payload.message || `Deleted files for "${modelKey}".`);
}
async function refreshManifest() {
const payload = await fetchJson("/api/models/refresh_manifest", { method: "POST" });
notify(payload.message || "Model manifest refreshed.");
}
async function runAction(action, modelKey = "") {
if (state.actionBusy) {
notify("Please wait for the current action to finish.", "error");
return;
}
state.actionBusy = true;
try {
if (action === "refresh") {
await refreshManifest();
await refreshAll(false);
return;
}
if (state.status.isOnroad && action !== "refresh") {
notify("Actions are blocked while onroad.", "error");
return;
}
if (action === "select") {
if (!modelKey) return;
await setActiveModel(modelKey);
} else if (action === "download") {
if (!modelKey) return;
await startDownload(modelKey);
} else if (action === "download-all") {
await startDownloadAll();
} else if (action === "cancel") {
await cancelDownload();
} else if (action === "delete") {
if (!modelKey) return;
const confirmed = window.confirm(`Delete local files for model \"${modelKey}\"?`);
if (!confirmed) return;
await deleteModel(modelKey);
}
await fetchStatus();
} catch (error) {
notify(error?.message || String(error), "error");
} finally {
state.actionBusy = false;
}
}
function bindDomHandlers() {
if (window.__modelManagerHandlersBound) return;
window.__modelManagerHandlersBound = true;
document.addEventListener("click", event => {
if (!isModelRouteActive()) return;
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest("[data-mm-action]");
if (!button) return;
const action = safeText(button.getAttribute("data-mm-action"), "");
const modelKey = safeText(button.getAttribute("data-model"), "");
runAction(action, modelKey).catch(() => {});
});
document.addEventListener("change", event => {
if (!isModelRouteActive()) return;
const target = event.target;
if (!(target instanceof HTMLSelectElement)) return;
if (target.id === "mm-active-model-select") {
const modelKey = safeText(target.value, "");
if (!modelKey) return;
runAction("select", modelKey).catch(() => {});
return;
}
if (target.id === "mm-sort-mode-select") {
const value = safeText(target.value, "alphabetical");
state.sortMode = value === "release_date" ? "release_date" : "alphabetical";
return;
}
if (target.id === "mm-community-filter-select") {
const value = safeText(target.value, "all");
if (value === "yes" || value === "no" || value === "all") {
state.communityFavoriteFilter = value;
} else {
state.communityFavoriteFilter = "all";
}
}
});
}
function renderActions(model) {
const modelKey = safeText(model.value, "");
const modelIsDownloading = state.status.downloading && !state.status.downloadAll && state.status.modelToDownload === modelKey;
if (state.currentModel === modelKey) {
return html`<span class="mm-chip mm-chip-active">Active</span>`;
}
if (state.status.downloading) {
if (state.status.downloadAll || modelIsDownloading) {
return html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel</button>`;
}
return html`<span class="mm-chip">Busy</span>`;
}
if (model.installed) {
return html`
<button class="mm-btn mm-btn-secondary" data-mm-action="select" data-model="${modelKey}">Set Active</button>
<button class="mm-btn mm-btn-danger" data-mm-action="delete" data-model="${modelKey}">Delete</button>
`;
}
return html`<button class="mm-btn mm-btn-primary" data-mm-action="download" data-model="${modelKey}">Download</button>`;
}
function renderModelRow(model) {
const label = safeText(model.label, safeText(model.value, "Unnamed"));
const key = safeText(model.value, "");
return html`
<div class="mm-row">
<div class="mm-row-main">
<div class="mm-row-title">
<span>${label}</span>
</div>
<div class="mm-row-meta">
<span class="mm-chip">${key}</span>
${state.sortMode === "release_date" ? "" : model.series ? html`<span class="mm-chip">${safeText(model.series)}</span>` : ""}
${model.version ? html`<span class="mm-chip">Version ${safeText(model.version)}</span>` : ""}
${model.released ? html`<span class="mm-chip">Released ${safeText(model.released)}</span>` : ""}
${model.communityFavorite ? html`<span class="mm-chip mm-chip-favorite">Community Favorite</span>` : ""}
${model.partial ? html`<span class="mm-chip mm-chip-warning">Partial Files</span>` : ""}
</div>
</div>
<div class="mm-row-actions">
${renderActions(model)}
</div>
</div>
`;
}
function renderSeriesSection(seriesName, models) {
return html`
<section class="mm-series">
<header class="mm-series-header">
<h3>${seriesName}</h3>
<span>${models.length}</span>
</header>
<div class="mm-series-body">
${models.map(model => renderModelRow(model))}
</div>
</section>
`;
}
export function ModelManager() {
if (!initialized) {
initialized = true;
bindDomHandlers();
logDebug("Initializing component");
refreshAll().catch(error => {
state.error = error?.message || String(error);
state.loading = false;
state.refreshing = false;
logDebug("Initial refresh failed", state.error);
});
}
ensurePolling();
return html`
<div class="mm-wrapper">
<h2>Model Manager</h2>
${() => state.error ? html`<div class="mm-error">${state.error}</div>` : ""}
<div class="mm-debug">
Available Models=${() => state.models.length}
Current Model=${() => getCurrentModelName()}
</div>
<div class="mm-toolbar">
<div class="mm-summary">
<span><b>${state.summary.installed}</b> installed</span>
<span><b>${state.summary.missing}</b> missing</span>
<span><b>${state.summary.total}</b> total</span>
</div>
<div class="mm-actions">
${() => state.status.downloading
? html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel Download</button>`
: html`<button class="mm-btn mm-btn-primary" data-mm-action="download-all">Download All Missing</button>`}
<button class="mm-btn mm-btn-secondary" data-mm-action="refresh">Refresh</button>
</div>
</div>
<div class="mm-status">
<span class="mm-chip">Current: ${getCurrentModelName()}</span>
<span class="mm-chip">Progress: ${safeText(state.status.progress, "Idle")}</span>
${() => state.status.isOnroad ? html`<span class="mm-chip mm-chip-warning">Onroad: actions disabled</span>` : ""}
</div>
<div class="mm-filters">
<label class="mm-filter-label" for="mm-active-model-select">Active Model</label>
<select class="mm-select" id="mm-active-model-select">
${(() => {
const orderedInstalled = getInstalledModels().sort((a, b) => {
const aCurrent = safeText(a.value) === state.currentModel ? 0 : 1;
const bCurrent = safeText(b.value) === state.currentModel ? 0 : 1;
if (aCurrent !== bCurrent) return aCurrent - bCurrent;
return safeText(a.label, a.value).localeCompare(safeText(b.label, b.value), undefined, { sensitivity: "base" });
});
return orderedInstalled.length > 0
? orderedInstalled.map(model => html`
<option value="${safeText(model.value)}" ${safeText(model.value) === state.currentModel ? "selected" : ""}>
${safeText(model.label, model.value)}
</option>
`)
: html`<option value="">No installed models</option>`;
})()}
</select>
<label class="mm-filter-label" for="mm-sort-mode-select">Sort</label>
<select class="mm-select" id="mm-sort-mode-select">
<option value="alphabetical" ${state.sortMode === "alphabetical" ? "selected" : ""}>Alphabetical</option>
<option value="release_date" ${state.sortMode === "release_date" ? "selected" : ""}>Release Date</option>
</select>
<div class="mm-filter-break"></div>
<label class="mm-filter-label" for="mm-community-filter-select">Community Favorite</label>
<select class="mm-select" id="mm-community-filter-select">
<option value="all" ${state.communityFavoriteFilter === "all" ? "selected" : ""}>All</option>
<option value="yes" ${state.communityFavoriteFilter === "yes" ? "selected" : ""}>Yes</option>
<option value="no" ${state.communityFavoriteFilter === "no" ? "selected" : ""}>No</option>
</select>
</div>
${() => state.loading ? html`<div class="mm-empty">Loading models...</div>` : ""}
${() => !state.loading ? html`
<div class="mm-list">
${(() => {
if (state.sortMode === "release_date") {
const models = getReleaseOrderedModels();
return models.length === 0
? html`<div class="mm-empty">No models available.</div>`
: models.map(model => renderModelRow(model));
}
const { grouped, seriesNames } = getSeriesGroups();
return seriesNames.length === 0
? html`<div class="mm-empty">No models available.</div>`
: seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
})()}
</div>
` : ""}
</div>
`;
}
@@ -0,0 +1,280 @@
.plotsPage {
max-width: var(--width-xxxxl);
}
.plotCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 95%;
padding: var(--border-radius-xl);
}
.plotStatusCard {
margin-bottom: var(--margin-base);
}
.plotDescription {
margin: 0;
}
.plotStatusGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.plotStatusGrid p,
.plotRangeRow span,
.plotLegendItem {
font-size: var(--font-size-sm);
margin: 0;
}
.plotActions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.plotButton {
background: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
}
.plotButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.plotCharts {
display: grid;
gap: var(--gap-sm);
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
max-width: 95%;
}
.plotAdvancedRow {
margin: var(--margin-base) 0;
}
.plotChartCard {
max-width: 100%;
}
.plotCardHeader {
align-items: baseline;
display: flex;
gap: var(--gap-sm);
justify-content: space-between;
}
.plotSource {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.plotLegend {
display: flex;
gap: var(--gap-sm);
margin-bottom: var(--margin-sm);
}
.plotLegendItem {
align-items: center;
display: inline-flex;
gap: var(--gap-xs);
}
.plotLegendLine {
border-radius: var(--border-radius-sm);
display: inline-block;
height: 3px;
width: 22px;
}
.plotLegendLine.desired,
.plotLine.desired {
background-color: var(--main-fg);
stroke: var(--main-fg);
}
.plotLegendLine.actual,
.plotLine.actual {
background-color: var(--success-fg);
stroke: var(--success-fg);
}
.plotLegendLine.p,
.plotLine.p {
background-color: #63b3ff;
stroke: #63b3ff;
}
.plotLegendLine.i,
.plotLine.i {
background-color: #63d79d;
stroke: #63d79d;
}
.plotLegendLine.d,
.plotLine.d {
background-color: #f0b35e;
stroke: #f0b35e;
}
.plotLegendLine.f,
.plotLine.f {
background-color: #d08bff;
stroke: #d08bff;
}
.plotSvgWrap {
background: #0b0b18;
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
min-height: 260px;
overflow: hidden;
}
.plotSvg {
display: block;
height: 260px;
width: 100%;
}
.plotLine {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.plotGridLine {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 1;
}
.plotZeroLine {
stroke: rgba(255, 255, 255, 0.35);
stroke-dasharray: 6 6;
stroke-width: 1;
}
.plotRangeRow {
display: flex;
justify-content: space-between;
margin-top: var(--margin-xs);
}
.plotEmpty {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: var(--font-size-sm);
height: 260px;
justify-content: center;
}
.plotError {
color: var(--danger-fg);
margin-top: var(--margin-sm);
}
.qualityDetail {
color: var(--text-muted);
font-size: calc(var(--font-size-sm) * 0.95);
}
.qualitySummaryGrid {
display: grid;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.qualitySummaryRow {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
padding: var(--padding-sm) var(--padding-base);
}
.qualitySentence {
align-items: baseline;
display: flex;
flex-wrap: wrap;
font-size: calc(var(--font-size-lg) * 1.05);
font-weight: var(--font-weight-demi-bold);
gap: var(--gap-xs);
line-height: 1.3;
margin: 0;
}
.qualityTone {
font-size: calc(var(--font-size-xl) * 1.05);
font-weight: var(--font-weight-extra-bold);
letter-spacing: 0.01em;
text-transform: uppercase;
}
.qualityTone-great {
color: #66e3ad;
}
.qualityTone-good {
color: #7fc2ff;
}
.qualityTone-fair {
color: #f8c36d;
}
.qualityTone-poor {
color: #ff8ca5;
}
.qualityTone-na {
color: #d7dcec;
}
.qualityMethodNote {
color: var(--text-muted);
font-size: calc(var(--font-size-sm) * 0.92);
margin-top: var(--margin-sm);
}
.qualityUserGuidance {
color: var(--text-muted);
font-size: calc(var(--font-size-sm) * 0.95);
margin: var(--margin-sm) 0 0 0;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.plotCard,
.plotCharts {
max-width: 100%;
}
.plotCharts {
grid-template-columns: 1fr;
}
.plotStatusGrid {
grid-template-columns: 1fr;
}
.plotActions {
flex-direction: column;
}
.plotLegend {
flex-direction: column;
gap: var(--gap-xs);
}
}
@@ -0,0 +1,666 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
error: "",
paused: false,
showAdvancedTerms: false,
live: null,
samples: [],
})
let initialized = false
let pollHandle = null
let lastTimestamp = 0
let visibilityListenerAttached = false
const MAX_POINTS = 240
const POLL_INTERVAL_MS = 750
const SVG_WIDTH = 1000
const SVG_HEIGHT = 260
const ADVANCED_TERMS_KEY = "plotsShowAdvancedTerms"
const QUALITY_WINDOW_SECONDS = 30
const QUALITY_MIN_SAMPLES = 12
const LATERAL_QUALITY_CONFIG = {
desiredKey: "desiredLateralAccel",
actualKey: "actualLateralAccel",
minSpeedMps: 0.5,
minDemand: 0.015,
allowLowDemandFallback: true,
fallbackMinSpeedMps: 1.0,
fallbackMinPeakDemand: 0.03,
great: 0.15,
good: 0.30,
fair: 0.50,
}
const LONGITUDINAL_QUALITY_CONFIG = {
desiredKey: "desiredLongitudinalAccel",
actualKey: "actualLongitudinalAccel",
minSpeedMps: 0.0,
minDemand: 0.08,
allowLowDemandFallback: false,
applyPersistenceRules: true,
warnError: 0.50,
severeError: 0.90,
great: 0.32,
good: 0.52,
fair: 0.78,
}
function isPlotsRouteActive() {
return window.location.pathname === "/plots"
}
function toNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
function formatValue(value, digits = 2) {
return toNumber(value).toFixed(digits)
}
function formatAge(seconds) {
const value = Math.max(0, toNumber(seconds))
if (value < 1) return `${Math.round(value * 1000)} ms`
return `${value.toFixed(1)} s`
}
function formatSourceLabel(kind, source) {
const normalizedKind = String(kind || "").toLowerCase()
const normalizedSource = String(source || "").toLowerCase()
if (normalizedKind === "lateral") {
if (normalizedSource === "torquestate") return "Steering controller output"
if (normalizedSource === "curvature") return "Path model estimate"
}
if (normalizedKind === "longitudinal") {
if (normalizedSource.includes("atarget")) return "Planner target acceleration + measured acceleration"
if (normalizedSource.includes("pid sum")) return "PID term sum + measured acceleration"
if (normalizedSource.includes("caroutput")) return "Final accel command + measured acceleration"
if (normalizedSource.includes("livelocationkalman")) return "Control output + calibrated acceleration"
if (normalizedSource === "controlsstate") return "Planner target output"
}
if (normalizedKind === "lateralterms") {
if (normalizedSource === "torquestate") return "Steering torque controller"
if (normalizedSource === "pidstate") return "Steering angle PID controller"
}
if (normalizedKind === "longitudinalterms") {
if (normalizedSource === "controlsstate") return "Longitudinal controller terms"
}
return "Live control signal"
}
function percentile(sortedValues, percentileValue) {
const values = Array.isArray(sortedValues) ? sortedValues : []
if (!values.length) return 0
const p = Math.max(0, Math.min(1, Number(percentileValue)))
const index = (values.length - 1) * p
const lower = Math.floor(index)
const upper = Math.ceil(index)
if (lower === upper) return values[lower]
const weight = index - lower
return values[lower] * (1 - weight) + values[upper] * weight
}
function formatQualityLabel(label) {
const text = String(label || "").trim()
return text || "N/A"
}
function computeMatchQuality(samples, config) {
const safeSamples = Array.isArray(samples) ? samples : []
if (safeSamples.length < 2) {
return { label: "N/A", value: null, detail: "Waiting for data" }
}
const latestTs = toNumber(safeSamples[safeSamples.length - 1]?.timestamp, 0)
const cutoffTs = latestTs > 0 ? latestTs - QUALITY_WINDOW_SECONDS : 0
const recentSamples = safeSamples.filter((sample) => toNumber(sample?.timestamp, 0) >= cutoffTs)
const demandEligibleSamples = recentSamples.filter((sample) => {
const speed = Math.abs(toNumber(sample?.speed, 0))
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
const speedOk = config.minSpeedMps <= 0 ? true : speed >= config.minSpeedMps
const demandOk = config.minDemand <= 0 ? true : desired >= config.minDemand
return speedOk && demandOk
})
let eligibleSamples = demandEligibleSamples
let usedLowDemandFallback = false
const allowLowDemandFallback = config.allowLowDemandFallback !== false
if (allowLowDemandFallback && eligibleSamples.length < QUALITY_MIN_SAMPLES && recentSamples.length >= QUALITY_MIN_SAMPLES) {
const totalSpeed = recentSamples.reduce((sum, sample) => sum + Math.abs(toNumber(sample?.speed, 0)), 0)
const avgSpeed = recentSamples.length > 0 ? (totalSpeed / recentSamples.length) : 0
const peakDemand = recentSamples.reduce((peak, sample) => {
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
return Math.max(peak, desired)
}, 0)
const fallbackMinSpeed = Math.max(0, toNumber(config.fallbackMinSpeedMps, 0))
const fallbackMinPeakDemand = Math.max(0, toNumber(config.fallbackMinPeakDemand, 0))
if (avgSpeed >= fallbackMinSpeed && peakDemand >= fallbackMinPeakDemand) {
eligibleSamples = recentSamples
usedLowDemandFallback = true
}
}
if (eligibleSamples.length < QUALITY_MIN_SAMPLES) {
if (allowLowDemandFallback) {
return {
label: "N/A",
value: null,
detail: `Need ${QUALITY_MIN_SAMPLES} samples (${eligibleSamples.length} eligible / ${recentSamples.length} total)`,
}
}
return {
label: "N/A",
value: null,
detail: `Need ${QUALITY_MIN_SAMPLES} demand samples (${eligibleSamples.length} demand / ${recentSamples.length} total)`,
}
}
const errors = eligibleSamples
.map((sample) => Math.abs(toNumber(sample?.[config.desiredKey], 0) - toNumber(sample?.[config.actualKey], 0)))
.sort((a, b) => a - b)
if (!errors.length) {
return { label: "N/A", value: null, detail: "No eligible samples" }
}
const p50 = percentile(errors, 0.50)
const p90 = percentile(errors, 0.90)
const robustError = (0.7 * p50) + (0.3 * p90)
const sampleSummary = `${eligibleSamples.length} samples / ${QUALITY_WINDOW_SECONDS}s${usedLowDemandFallback ? ", low-demand fallback" : ""}`
if (config.applyPersistenceRules) {
const warnThreshold = Math.max(config.warnError || 0.35, config.good || 0.35)
const severeThreshold = Math.max(config.severeError || 0.70, config.fair || 0.55)
const warnFrac = errors.filter((value) => value > warnThreshold).length / errors.length
const severeFrac = errors.filter((value) => value > severeThreshold).length / errors.length
let label = "Poor"
if (robustError <= config.great && warnFrac <= 0.18 && severeFrac <= 0.05) label = "Great"
else if (robustError <= config.good && warnFrac <= 0.34 && severeFrac <= 0.12) label = "Good"
else if (robustError <= config.fair && warnFrac <= 0.55 && severeFrac <= 0.24) label = "Fair"
const warnPct = Math.round(warnFrac * 100)
const severePct = Math.round(severeFrac * 100)
return {
label,
value: robustError,
detail: `${sampleSummary}, ${warnPct}% > ${formatValue(warnThreshold)} and ${severePct}% > ${formatValue(severeThreshold)}`,
}
}
let label = "Poor"
if (robustError <= config.great) label = "Great"
else if (robustError <= config.good) label = "Good"
else if (robustError <= config.fair) label = "Fair"
return {
label,
value: robustError,
detail: sampleSummary,
}
}
function qualityToneClass(label) {
const normalized = String(label || "").toLowerCase()
if (normalized === "great") return "qualityTone-great"
if (normalized === "good") return "qualityTone-good"
if (normalized === "fair") return "qualityTone-fair"
if (normalized === "poor") return "qualityTone-poor"
return "qualityTone-na"
}
function stopPolling() {
if (!pollHandle) return
clearTimeout(pollHandle)
pollHandle = null
}
function pushSample(payload) {
const timestamp = toNumber(payload.timestamp, 0)
if (!timestamp || timestamp <= 0 || timestamp === lastTimestamp) return
lastTimestamp = timestamp
state.samples.push({
timestamp,
speed: toNumber(payload.speed),
controlsActive: !!payload.controlsActive,
longitudinalControlActive: !!payload.longitudinalControlActive,
desiredLateralAccel: toNumber(payload.desiredLateralAccel),
actualLateralAccel: toNumber(payload.actualLateralAccel),
desiredLongitudinalAccel: toNumber(payload.desiredLongitudinalAccel),
actualLongitudinalAccel: toNumber(payload.actualLongitudinalAccel),
lateralP: toNumber(payload.lateralP),
lateralI: toNumber(payload.lateralI),
lateralD: toNumber(payload.lateralD),
lateralF: toNumber(payload.lateralF),
longitudinalUpAccelCmd: toNumber(payload.longitudinalUpAccelCmd),
longitudinalUiAccelCmd: toNumber(payload.longitudinalUiAccelCmd),
longitudinalUfAccelCmd: toNumber(payload.longitudinalUfAccelCmd),
})
if (state.samples.length > MAX_POINTS) {
state.samples.splice(0, state.samples.length - MAX_POINTS)
}
}
async function fetchLiveData() {
const response = await fetch("/api/plots/live")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load live plot data")
}
state.live = payload
state.error = ""
state.loading = false
if (!payload.stale) {
pushSample(payload)
}
}
function ensurePolling() {
if (pollHandle) return
const poll = async () => {
if (!isPlotsRouteActive()) {
pollHandle = null
return
}
if (document.visibilityState !== "visible") {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
return
}
if (!state.paused) {
try {
await fetchLiveData()
} catch (error) {
state.error = error?.message || String(error)
state.loading = false
}
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
function clearHistory() {
state.samples = []
lastTimestamp = 0
}
function togglePaused() {
state.paused = !state.paused
if (state.paused) {
stopPolling()
return
}
if (!isPlotsRouteActive()) return
fetchLiveData().catch((error) => {
state.error = error?.message || String(error)
})
ensurePolling()
}
function toggleAdvancedTerms() {
state.showAdvancedTerms = !state.showAdvancedTerms
try {
localStorage.setItem(ADVANCED_TERMS_KEY, state.showAdvancedTerms ? "1" : "0")
} catch (error) {
console.warn("Failed to persist plots advanced terms preference", error)
}
}
function yForValue(value, min, max) {
const safeMin = toNumber(min, -1)
const safeMax = toNumber(max, 1)
const clamped = Math.max(safeMin, Math.min(safeMax, toNumber(value)))
const span = Math.max(1e-6, safeMax - safeMin)
return ((safeMax - clamped) / span) * SVG_HEIGHT
}
function computeRange(samples, desiredKey, actualKey) {
let maxAbs = 0
for (const sample of samples) {
maxAbs = Math.max(
maxAbs,
Math.abs(toNumber(sample?.[desiredKey])),
Math.abs(toNumber(sample?.[actualKey])),
)
}
const halfSpan = Math.max(1.5, Math.ceil((maxAbs * 1.25) * 10) / 10)
return { min: -halfSpan, max: halfSpan }
}
function buildPolyline(samples, key, min, max) {
if (!samples.length) return ""
const lastIndex = Math.max(1, samples.length - 1)
return samples.map((sample, index) => {
const x = (index / lastIndex) * SVG_WIDTH
const y = yForValue(sample?.[key], min, max)
return `${x.toFixed(2)},${y.toFixed(2)}`
}).join(" ")
}
function computeRangeForKeys(samples, keys) {
let maxAbs = 0
for (const sample of samples) {
for (const key of keys) {
maxAbs = Math.max(maxAbs, Math.abs(toNumber(sample?.[key])))
}
}
const halfSpan = Math.max(0.15, Math.ceil((maxAbs * 1.35) * 1000) / 1000)
return { min: -halfSpan, max: halfSpan }
}
function latestSampleValue(key) {
const latest = state.samples[state.samples.length - 1]
if (latest) return latest[key]
return toNumber(state.live?.[key])
}
function PlotCard(title, desiredKey, actualKey, sourceKind, sourceLabel, desiredLabel = "Target", actualLabel = "Measured") {
const hasData = state.samples.length > 1
const range = computeRange(state.samples, desiredKey, actualKey)
const desiredPolyline = buildPolyline(state.samples, desiredKey, range.min, range.max)
const actualPolyline = buildPolyline(state.samples, actualKey, range.min, range.max)
const zeroY = yForValue(0, range.min, range.max)
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
return html`
<section class="plotCard plotChartCard">
<div class="plotCardHeader">
<h2>${title}</h2>
<span class="plotSource">Source: ${formatSourceLabel(sourceKind, sourceLabel)}</span>
</div>
<div class="plotLegend">
<span class="plotLegendItem"><i class="plotLegendLine desired"></i>${desiredLabel}: ${formatValue(latestSampleValue(desiredKey))}</span>
<span class="plotLegendItem"><i class="plotLegendLine actual"></i>${actualLabel}: ${formatValue(latestSampleValue(actualKey))}</span>
</div>
<div class="plotSvgWrap">
${hasData ? html`
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="${title} live plot">
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
<polyline class="plotLine desired" points="${desiredPolyline}"></polyline>
<polyline class="plotLine actual" points="${actualPolyline}"></polyline>
</svg>
` : html`
<div class="plotEmpty">Waiting for enough live samples...</div>
`}
</div>
<div class="plotRangeRow">
<span>${formatValue(range.max, 1)} m/</span>
<span>0.0 m/</span>
<span>${formatValue(range.min, 1)} m/</span>
</div>
</section>
`
}
function LateralTermsPlotCard(sourceLabel) {
const hasData = state.samples.length > 1
const keys = ["lateralP", "lateralI", "lateralD", "lateralF"]
const range = computeRangeForKeys(state.samples, keys)
const zeroY = yForValue(0, range.min, range.max)
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
return html`
<section class="plotCard plotChartCard">
<div class="plotCardHeader">
<h2>Lateral Controller Terms</h2>
<span class="plotSource">Source: ${formatSourceLabel("lateralTerms", sourceLabel)}</span>
</div>
<div class="plotLegend">
<span class="plotLegendItem"><i class="plotLegendLine p"></i>P: ${formatValue(latestSampleValue("lateralP"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine i"></i>I: ${formatValue(latestSampleValue("lateralI"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine d"></i>D: ${formatValue(latestSampleValue("lateralD"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine f"></i>F: ${formatValue(latestSampleValue("lateralF"), 3)}</span>
</div>
<div class="plotSvgWrap">
${hasData ? html`
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="Lateral controller terms live plot">
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
<polyline class="plotLine p" points="${buildPolyline(state.samples, "lateralP", range.min, range.max)}"></polyline>
<polyline class="plotLine i" points="${buildPolyline(state.samples, "lateralI", range.min, range.max)}"></polyline>
<polyline class="plotLine d" points="${buildPolyline(state.samples, "lateralD", range.min, range.max)}"></polyline>
<polyline class="plotLine f" points="${buildPolyline(state.samples, "lateralF", range.min, range.max)}"></polyline>
</svg>
` : html`
<div class="plotEmpty">Waiting for enough live samples...</div>
`}
</div>
<div class="plotRangeRow">
<span>${formatValue(range.max, 3)}</span>
<span>0.000</span>
<span>${formatValue(range.min, 3)}</span>
</div>
</section>
`
}
function LongitudinalTermsPlotCard(sourceLabel) {
const hasData = state.samples.length > 1
const keys = ["longitudinalUpAccelCmd", "longitudinalUiAccelCmd", "longitudinalUfAccelCmd"]
const range = computeRangeForKeys(state.samples, keys)
const zeroY = yForValue(0, range.min, range.max)
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
return html`
<section class="plotCard plotChartCard">
<div class="plotCardHeader">
<h2>Longitudinal Accel Cmd Terms</h2>
<span class="plotSource">Source: ${formatSourceLabel("longitudinalTerms", sourceLabel)}</span>
</div>
<div class="plotLegend">
<span class="plotLegendItem"><i class="plotLegendLine p"></i>Up: ${formatValue(latestSampleValue("longitudinalUpAccelCmd"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine i"></i>Ui: ${formatValue(latestSampleValue("longitudinalUiAccelCmd"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine f"></i>Uf: ${formatValue(latestSampleValue("longitudinalUfAccelCmd"), 3)}</span>
</div>
<div class="plotSvgWrap">
${hasData ? html`
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="Longitudinal accel cmd terms live plot">
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
<polyline class="plotLine p" points="${buildPolyline(state.samples, "longitudinalUpAccelCmd", range.min, range.max)}"></polyline>
<polyline class="plotLine i" points="${buildPolyline(state.samples, "longitudinalUiAccelCmd", range.min, range.max)}"></polyline>
<polyline class="plotLine f" points="${buildPolyline(state.samples, "longitudinalUfAccelCmd", range.min, range.max)}"></polyline>
</svg>
` : html`
<div class="plotEmpty">Waiting for enough live samples...</div>
`}
</div>
<div class="plotRangeRow">
<span>${formatValue(range.max, 3)}</span>
<span>0.000</span>
<span>${formatValue(range.min, 3)}</span>
</div>
</section>
`
}
async function initialize() {
try {
state.showAdvancedTerms = localStorage.getItem(ADVANCED_TERMS_KEY) === "1"
} catch (error) {
state.showAdvancedTerms = false
}
try {
await fetchLiveData()
} catch (error) {
state.error = error?.message || String(error)
state.loading = false
} finally {
ensurePolling()
}
if (!visibilityListenerAttached) {
visibilityListenerAttached = true
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") {
stopPolling()
return
}
if (isPlotsRouteActive() && !state.paused) {
fetchLiveData().catch((error) => {
state.error = error?.message || String(error)
state.loading = false
})
ensurePolling()
}
})
}
}
export function LivePlots() {
if (!initialized) {
initialized = true
initialize()
}
if (!state.paused) {
ensurePolling()
}
return html`
<div class="plotsPage">
<h1>Plots</h1>
<section class="plotCard plotStatusCard">
<p class="plotDescription">
Live comparison view for tuning diagnostics. These scores are a quick health check, not a final verdict. Short spikes from bumps, lane changes, traffic transitions, and manual inputs can temporarily lower a score.
</p>
<p class="qualityUserGuidance">
Work in progress: use this as trend guidance over time. If you see a brief "Poor," keep driving and look for repeated behavior across multiple situations before changing tune values.
</p>
<div class="plotStatusGrid">
<p><strong>Onroad:</strong> ${state.live?.isOnroad ? "Yes" : "No"}</p>
<p><strong>Sample Age:</strong> ${formatAge(state.live?.sampleAgeSeconds)}</p>
<p><strong>Vehicle Speed:</strong> ${formatValue(state.live?.speed)} m/s</p>
<p><strong>Samples:</strong> ${state.samples.length}</p>
</div>
${() => {
const lateralQuality = computeMatchQuality(state.samples, LATERAL_QUALITY_CONFIG)
const longQuality = computeMatchQuality(state.samples, LONGITUDINAL_QUALITY_CONFIG)
return html`
<div class="qualitySummaryGrid">
<div class="qualitySummaryRow">
<p class="qualitySentence">
Your lateral tuning is
<span class="qualityTone ${qualityToneClass(lateralQuality.label)}">${formatQualityLabel(lateralQuality.label)}</span>
</p>
<p class="qualityDetail">
${lateralQuality.value === null ? lateralQuality.detail : `${formatValue(lateralQuality.value)} m/s² error (${lateralQuality.detail})`}
</p>
</div>
<div class="qualitySummaryRow">
<p class="qualitySentence">
Your longitudinal tuning is
<span class="qualityTone ${qualityToneClass(longQuality.label)}">${formatQualityLabel(longQuality.label)}</span>
</p>
<p class="qualityDetail">
${longQuality.value === null ? longQuality.detail : `${formatValue(longQuality.value)} m/s² error (${longQuality.detail})`}
</p>
</div>
</div>
`
}}
<div class="plotActions">
<button class="plotButton" @click="${togglePaused}">
${state.paused ? "Resume Live" : "Pause Live"}
</button>
<button class="plotButton" @click="${clearHistory}">
Clear History
</button>
</div>
${state.error ? html`<p class="plotError"><strong>Error:</strong> ${state.error}</p>` : ""}
${state.live?.lastError ? html`<p class="plotError"><strong>Source Error:</strong> ${state.live.lastError}</p>` : ""}
<p class="qualityMethodNote">
Match rating uses a 30-second rolling window. Lateral prefers true steering-demand moments, and longitudinal also checks how much of the window stays above error limits so brief spikes are less likely to mark "Poor."
</p>
</section>
<div class="plotCharts">
${PlotCard(
"Lateral Response (m/s²)",
"desiredLateralAccel",
"actualLateralAccel",
"lateral",
state.live?.lateralSource,
"Target",
"Measured",
)}
${PlotCard(
"Longitudinal Response (m/s²)",
"desiredLongitudinalAccel",
"actualLongitudinalAccel",
"longitudinal",
state.live?.longitudinalSource,
"Target",
"Measured",
)}
</div>
<div class="plotAdvancedRow">
<button class="plotButton" @click="${toggleAdvancedTerms}">
${state.showAdvancedTerms ? "Hide Advanced Controller Terms" : "Show Advanced Controller Terms"}
</button>
</div>
${state.showAdvancedTerms ? html`
<div class="plotCharts">
${LateralTermsPlotCard(state.live?.lateralTermsSource)}
${LongitudinalTermsPlotCard(state.live?.longitudinalTermsSource)}
</div>
` : ""}
${state.loading ? html`<p>Loading live data...</p>` : ""}
</div>
`
}
@@ -0,0 +1,82 @@
.download-speed-limits-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
padding: var(--border-radius-xl);
text-align: center;
text-decoration: none;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.download-speed-limits-button + .download-speed-limits-button {
margin-top: var(--padding-sm);
}
.download-speed-limits-button:hover {
box-shadow: var(--shadow-md);
color: var(--secondary-fg);
transform: var(--hover-scale-sm);
}
.download-speed-limits-button-wrapper {
height: calc(100% + 20px);
position: relative;
width: 100%;
}
.download-speed-limits-link {
color: var(--text-color);
font-size: var(--font-size-sm);
left: 50%;
margin-top: var(--border-radius-sm);
position: absolute;
text-decoration: underline;
top: 100%;
transform: translateX(-50%);
white-space: nowrap;
}
.download-speed-limits-text {
color: var(--text-color);
text-align: center;
}
.download-speed-limits-title {
background-color: var(--input-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
padding: var(--padding-sm);
text-align: center;
width: 100%;
}
.download-speed-limits-wrapper {
display: flex;
justify-content: center;
}
.download-speed-limits-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--main-fg);
display: flex;
flex-direction: column;
margin-top: var(--padding-xl);
max-width: var(--width-lg);
padding: var(--padding-lg);
transform-origin: top center;
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.download-speed-limits-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
@@ -0,0 +1,29 @@
import { html } from "/assets/vendor/arrow-core.js"
export function SpeedLimits() {
function handleDownload() {
const link = document.createElement("a")
link.href = "/api/speed_limits"
link.download = "speed_limits.json"
link.click()
showSnackbar("Download started...")
}
return html`
<div class="download-speed-limits-wrapper">
<section class="download-speed-limits-widget">
<div class="download-speed-limits-title">Download Speed Limits</div>
<p class="download-speed-limits-text">
Download speed limit data collected using "Speed Limit Filler".
</p>
<div class="download-speed-limits-button-wrapper">
<button class="download-speed-limits-button" @click="${handleDownload}">Download</button>
<a class="download-speed-limits-link" href="https://SpeedLimitFiller.frogpilot.download" target="_blank">
Submit speed limits here
</a>
</div>
</section>
</div>
`
}
@@ -0,0 +1,152 @@
.testingGroundPage {
max-width: var(--width-xxl);
}
.testingGroundCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 90%;
padding: var(--border-radius-xl);
}
.testingGroundIntro {
margin-top: 0;
}
.testingGroundTopList {
margin-top: var(--margin-base);
}
.testingGroundTopList ol {
margin: var(--margin-xs) 0 0;
padding-left: 1.4rem;
}
.testingGroundTopList li {
font-size: var(--font-size-sm);
margin-bottom: 0.25rem;
}
.testingGroundStatusGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.testingGroundStatusGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.testingGroundSelectionRow {
align-items: center;
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.testingGroundSelect {
background-color: var(--main-bg);
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
min-width: 0;
padding: var(--padding-sm);
}
.testingGroundModeButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.testingGroundDetails {
margin-top: var(--margin-base);
}
.testingGroundDetails h3 {
margin-bottom: var(--margin-xs);
}
.testingGroundDetails p {
font-size: var(--font-size-sm);
margin: 0 0 var(--margin-xs);
}
.testingGroundMuted {
color: var(--text-muted);
}
.testingGroundActionGrid {
display: grid;
gap: var(--gap-sm);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin-top: var(--margin-base);
}
.testingGroundModeButton {
align-items: center;
background: #2a2752;
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
display: flex;
flex-direction: column;
gap: var(--gap-xs);
min-height: 120px;
padding: var(--padding-base);
}
.testingGroundModeButtonB {
background: #27434b;
}
.testingGroundModeButtonC {
background: #413029;
}
.testingGroundModeButton.active {
box-shadow: 0 0 0 2px var(--main-fg) inset;
}
.testingGroundModeLetter {
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: var(--font-weight-bold);
line-height: 1;
}
.testingGroundModeLabel {
font-size: var(--font-size-sm);
text-align: center;
}
.testingGroundError {
color: var(--danger-fg);
margin-top: var(--margin-base);
}
.testingGroundActiveSummary {
margin-top: var(--margin-base);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.testingGroundCard {
max-width: 100%;
}
.testingGroundStatusGrid {
grid-template-columns: 1fr;
}
.testingGroundSelectionRow {
align-items: stretch;
flex-direction: column;
}
.testingGroundActionGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,288 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
error: "",
busy: false,
data: null,
selectedSlot: "",
})
let initialized = false
function slotId(slot) {
return String(slot?.id || "").trim()
}
function isUnusedSlot(slot) {
const name = String(slot?.name || "").trim().toLowerCase()
return name === "unused" || name.startsWith("unused ")
}
function getSelectableSlots() {
const selectable = Array.isArray(state.data?.selectableSlots) ? state.data.selectableSlots : []
if (selectable.length) return selectable
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
return slots.filter((slot) => !isUnusedSlot(slot))
}
function getVisibleSlotLines() {
return getSelectableSlots().map((slot) => `${slot.id}. ${slot.name}`)
}
function getSelectedSlot() {
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
const selectedId = String(state.selectedSlot || "").trim()
if (!selectedId) return null
return slots.find((slot) => slotId(slot) === selectedId) || null
}
function getActiveSlot() {
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
const activeId = String(state.data?.activeSlot || "").trim()
if (!activeId) return null
return slots.find((slot) => slotId(slot) === activeId) || null
}
function getVariantLabels(slot) {
if (!slot || typeof slot !== "object") {
return { A: "A" }
}
const labels = {}
const rawVariantLabels = slot.variantLabels
if (rawVariantLabels && typeof rawVariantLabels === "object") {
Object.entries(rawVariantLabels).forEach(([rawMode, rawLabel]) => {
const mode = String(rawMode || "").trim().toUpperCase()
const label = String(rawLabel || "").trim()
if (mode.length === 1 && /^[A-Z]$/.test(mode) && label) {
labels[mode] = label
}
})
}
const aLabel = String(slot.aLabel || "").trim()
const bLabel = String(slot.bLabel || "").trim()
if (aLabel) labels.A = labels.A || aLabel
if (bLabel) labels.B = labels.B || bLabel
if (!labels.A) labels.A = "A"
return Object.keys(labels).sort().reduce((acc, key) => {
acc[key] = labels[key]
return acc
}, {})
}
function getVariantModes(slot) {
return Object.keys(getVariantLabels(slot))
}
function getDefaultMode(slot) {
const modes = getVariantModes(slot)
if (modes.includes("A")) return "A"
return modes[0] || "A"
}
function toModeLabel(slot, mode) {
const labels = getVariantLabels(slot)
return labels[String(mode || "").trim().toUpperCase()] || String(mode || "").trim().toUpperCase() || "A"
}
function isModeActive(mode) {
const normalizedMode = String(mode || "").trim().toUpperCase()
return String(state.data?.activeSlot || "").trim() === String(state.selectedSlot || "").trim() && String(state.data?.activeVariant || "").trim().toUpperCase() === normalizedMode
}
function getSelectedMode() {
const selectedSlot = getSelectedSlot()
if (!selectedSlot) return "A"
if (String(state.data?.activeSlot || "").trim() === String(state.selectedSlot || "").trim()) {
return String(state.data?.activeVariant || "").trim().toUpperCase() || getDefaultMode(selectedSlot)
}
return getDefaultMode(selectedSlot)
}
function modeButtonClass(mode) {
const normalizedMode = String(mode || "").trim().toUpperCase()
return [
"testingGroundModeButton",
`testingGroundModeButton${normalizedMode}`,
isModeActive(normalizedMode) ? "active" : "",
].filter(Boolean).join(" ")
}
async function fetchTestingGrounds() {
try {
const response = await fetch("/api/testing_grounds")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load testing grounds")
}
state.data = payload
state.error = ""
const selectable = Array.isArray(payload.selectableSlots) ? payload.selectableSlots : []
const hasCurrentSelection = selectable.some((slot) => slotId(slot) === String(state.selectedSlot || "").trim())
if (!hasCurrentSelection) {
const activeSlot = String(payload.activeSlot || "").trim()
const activeSelectable = selectable.some((slot) => slotId(slot) === activeSlot)
state.selectedSlot = activeSelectable ? activeSlot : slotId(selectable[0] || {})
}
} catch (error) {
const message = error?.message || "Failed to load testing grounds"
state.error = message
}
}
async function applySelection(slotValue, mode, showToast = true) {
const normalizedSlot = String(slotValue || "").trim()
const normalizedMode = String(mode || "").trim().toUpperCase()
if (!normalizedSlot || !normalizedMode) return false
state.busy = true
try {
const response = await fetch("/api/testing_grounds/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slotId: normalizedSlot, variant: normalizedMode }),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to update testing ground mode")
}
state.data = payload
state.error = ""
state.selectedSlot = normalizedSlot
if (showToast) {
showSnackbar(payload.message || `Testing Ground ${normalizedSlot} set to ${normalizedMode}.`)
}
return true
} catch (error) {
const message = error?.message || "Failed to update testing ground mode"
state.error = message
if (showToast) {
showSnackbar(message, "error")
}
return false
} finally {
state.busy = false
}
}
async function selectMode(mode) {
if (state.busy) return
const slot = getSelectedSlot()
const slotValue = slotId(slot)
if (!slotValue) {
showSnackbar("Select a testing ground first.", "error")
return
}
await applySelection(slotValue, mode, true)
}
async function selectSlot(slotValue) {
const normalizedSlot = String(slotValue || "").trim()
state.selectedSlot = normalizedSlot
if (!normalizedSlot || state.busy) return
const activeSlot = String(state.data?.activeSlot || "").trim()
if (normalizedSlot === activeSlot) return
const success = await applySelection(normalizedSlot, "A", false)
if (!success) {
state.selectedSlot = activeSlot
if (state.error) {
showSnackbar(state.error, "error")
}
}
}
function initialize() {
if (initialized) return
initialized = true
fetchTestingGrounds().finally(() => {
state.loading = false
})
}
export function TestingGround() {
initialize()
return html`
<div class="testingGroundPage">
<h2>Testing Ground</h2>
${() => state.loading ? html`<div class="testingGroundCard">Loading testing ground state...</div>` : ""}
${() => !state.loading ? html`
<div class="testingGroundCard">
<p class="testingGroundIntro">
A/B Tuning Sandbox. If you don't know what this is, you probably shouldn't be here ;)
</p>
<div class="testingGroundTopList">
<strong>Current Test Slots</strong>
<ol>
${getVisibleSlotLines().map((line) => html`<li>${line}</li>`)}
</ol>
</div>
<div class="testingGroundStatusGrid">
<p><strong>Selected Slot:</strong> ${getSelectedSlot()?.name || "Unknown"}</p>
<p><strong>Selected Mode:</strong> ${getSelectedMode()}</p>
<p><strong>Onroad:</strong> ${state.data?.isOnroad ? "Yes" : "No"}</p>
<p><strong>Selectable Slots:</strong> ${getSelectableSlots().length}</p>
</div>
<div class="testingGroundSelectionRow">
<select
class="testingGroundSelect"
?disabled="${state.busy || getSelectableSlots().length === 0}"
@change="${(event) => {
selectSlot(String(event.target.value || ""))
}}">
${() => getSelectableSlots().length
? getSelectableSlots().map((slot) => html`<option value="${slotId(slot)}" ${slotId(slot) === state.selectedSlot ? "selected" : ""}>${slot.id}. ${slot.name}</option>`)
: html`<option value="">No active test slots</option>`
}
</select>
</div>
${() => getSelectedSlot() ? html`
<div class="testingGroundDetails">
<h3>${getSelectedSlot().name}</h3>
${() => getSelectedSlot().description ? html`<p>${getSelectedSlot().description}</p>` : ""}
</div>
` : ""}
${() => getSelectedSlot() ? html`
<div class="testingGroundActionGrid">
${() => getVariantModes(getSelectedSlot()).map((mode) => html`
<button
class="${modeButtonClass(mode)}"
?disabled="${state.busy}"
@click="${() => selectMode(mode)}">
<span class="testingGroundModeLetter">${mode}</span>
<span class="testingGroundModeLabel">${toModeLabel(getSelectedSlot(), mode)}</span>
</button>
`)}
</div>
` : ""}
${() => state.error ? html`<p class="testingGroundError"><strong>Error:</strong> ${state.error}</p>` : ""}
${() => getActiveSlot() ? html`
<p class="testingGroundActiveSummary">
Currently active: <strong>${getActiveSlot().id}. ${getActiveSlot().name}</strong> in mode <strong>${state.data?.activeVariant || "A"}</strong>.
</p>
` : ""}
</div>
` : ""}
</div>
`
}

Some files were not shown because too many files have changed in this diff Show More