StarPilot
@@ -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/**
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"ms-vscode.cpptools",
|
||||
"elagil.pre-commit-helper",
|
||||
"charliermarsh.ruff",
|
||||
"openai.chatgpt",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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" "$@"
|
||||
@@ -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" "$@"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
*.cpp
|
||||
!params_pyx.cpp
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
../stock_theme/colors
|
||||
@@ -0,0 +1 @@
|
||||
../stock_theme/distance_icons
|
||||
@@ -0,0 +1 @@
|
||||
../stock_theme/icons
|
||||
@@ -0,0 +1 @@
|
||||
../stock_theme/signals
|
||||
@@ -0,0 +1 @@
|
||||
../stock_theme/sounds
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
|
Before Width: | Height: | Size: 760 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 18 KiB |
@@ -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"):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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/s²</span>
|
||||
<span>0.0 m/s²</span>
|
||||
<span>${formatValue(range.min, 1)} m/s²</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>
|
||||
`
|
||||
}
|
||||