Compare commits
276 Commits
yukon
...
Dom-3.24.26
| Author | SHA1 | Date | |
|---|---|---|---|
| 2435cd8d7f | |||
| e71cedc4d2 | |||
| 727342b94d | |||
| 08f35492b6 | |||
| 41424d267b | |||
| 4dac64e666 | |||
| 7d793ae9fa | |||
| 613dc0e682 | |||
| 4e6659c028 | |||
| d451243252 | |||
| bde7dbcd7c | |||
| 9c50ee7c25 | |||
| 52d87de588 | |||
| 39ad7e897f | |||
| e96cd128e6 | |||
| c473cf96fe | |||
| ac11827e66 | |||
| 3da63a160c | |||
| f0fe28ddee | |||
| 8582d9c4db | |||
| 94fd00a1ec | |||
| be1c67550c | |||
| d7d844ae35 | |||
| b631895e66 | |||
| 831a719d3e | |||
| 6f65751a4e | |||
| c596c000aa | |||
| fd4b85b19e | |||
| 8b51817a18 | |||
| 8b8a7cde77 | |||
| 8c5767c328 | |||
| 9d16f6c96b | |||
| 8531d88df4 | |||
| 8dbb0bdad0 | |||
| 596633dcfa | |||
| 102922c900 | |||
| f371fcb18a | |||
| cea19bf478 | |||
| 056544acbe | |||
| 91b6a9c0b8 | |||
| 1693532d00 | |||
| 958d28478d | |||
| 0e4f0dc78d | |||
| c70456887d | |||
| 36f3fb56b8 | |||
| 199f1fd8cd | |||
| a2b5ea29d2 | |||
| 3bd7152f0a | |||
| 4a7c85b721 | |||
| a43027c1ad | |||
| 97605eb016 | |||
| 22a3fb0ee3 | |||
| fb0e9e206f | |||
| 1e5b1ef5ab | |||
| 9849588374 | |||
| 9e94c14604 | |||
| cdc78701a1 | |||
| a3182ec7ed | |||
| e76004ff14 | |||
| 7d48dfd3d2 | |||
| bb972faba1 | |||
| 73823319be | |||
| d80329f402 | |||
| 23a3be9bb7 | |||
| b8b51272da | |||
| 90f19e7632 | |||
| 85e27ddf3a | |||
| ef05ecb9da | |||
| dd207d6424 | |||
| a36310cb9d | |||
| 4df3f0c9fc | |||
| 69b5d4686f | |||
| da6cf10912 | |||
| da00bfe423 | |||
| 7383d35b99 | |||
| d9d5aef2ae | |||
| c99b32db9c | |||
| 9aed2d98a5 | |||
| ce839a919a | |||
| 3a65cf8977 | |||
| 151edd7f2a | |||
| 0566184f1e | |||
| 541cd5a2c2 | |||
| f16199729d | |||
| b14bb8ff38 | |||
| b663026ec0 | |||
| bc8337017b | |||
| d344dc498f | |||
| 0b750177f1 | |||
| 0fce8a996d | |||
| f0c3a4fc06 | |||
| a7f5f3b21e | |||
| d174c7ded9 | |||
| 0586cf3ee0 | |||
| fc5b00a633 | |||
| ce85d3cf2c | |||
| 2633b39c55 | |||
| f8d1f75c4d | |||
| 6c16b9a14b | |||
| c93c19128e | |||
| ec5289a0a9 | |||
| 184585beaa | |||
| 7cc750e77d | |||
| ed9027fb9c | |||
| 6fba035118 | |||
| 986a66f8f1 | |||
| 3976ed8d9a | |||
| 4bac569626 | |||
| d80b973e6f | |||
| 26305c70f0 | |||
| 4cfbaf1149 | |||
| ac847558fa | |||
| 309e9dcaf4 | |||
| a0b6461d92 | |||
| 03b4debc39 | |||
| 6495fe73e1 | |||
| 4e1d7e1917 | |||
| 0a8f96f266 | |||
| b3b27c2b60 | |||
| 2d8d2a4360 | |||
| 8833ac6ff5 | |||
| 28a82e85c2 | |||
| 39532ba0ee | |||
| 823ceb2228 | |||
| 0267dd4f98 | |||
| bba72c85c7 | |||
| 743863a4a5 | |||
| 78da8d7641 | |||
| 9be0df54c2 | |||
| 245c39ed0d | |||
| 21effc702e | |||
| 24687b1f79 | |||
| c7751a283a | |||
| 17f38ea13e | |||
| 25d904d146 | |||
| c39aec3bcf | |||
| c4d22c7917 | |||
| 9120aecdae | |||
| 0635bd4dcd | |||
| 1fb31425b6 | |||
| 2a67bdb589 | |||
| bbdb6b99bf | |||
| 5f8513f4d3 | |||
| a80201c553 | |||
| 4c3c5ddbbb | |||
| a4acab2d5c | |||
| 4503026936 | |||
| ce941e9f25 | |||
| 8cb1514465 | |||
| 8a09dbc5b0 | |||
| e5fec364bb | |||
| e82e034a31 | |||
| 104a60c9e0 | |||
| 5ecfae7ede | |||
| 7294fe03be | |||
| cf606c06aa | |||
| 2b5a613c65 | |||
| b9ae966de7 | |||
| 7eb280bfb1 | |||
| 1acb197111 | |||
| b2ed87c3d7 | |||
| b52177f8b6 | |||
| dc191e833e | |||
| 51c54cc353 | |||
| 456e3435d5 | |||
| 69fa82d3b4 | |||
| bcd9a35be8 | |||
| 67ad32c0f5 | |||
| d9ca40bc22 | |||
| 0f09e149ec | |||
| 8a44631529 | |||
| 7c55dbe49a | |||
| 1a1e9f0736 | |||
| 4492123c26 | |||
| ab77c06512 | |||
| fd320de1c1 | |||
| eefa06eab1 | |||
| dd7e289d95 | |||
| e7e8facca9 | |||
| a757ef5b65 | |||
| 47bc5af5ee | |||
| 61a04f8367 | |||
| 7299f52b24 | |||
| 3648900095 | |||
| 65bada6b31 | |||
| 4e63b10a5d | |||
| 264e9d8a77 | |||
| fb85f286c9 | |||
| 1f5c54e570 | |||
| aca330de59 | |||
| 6852e8ff22 | |||
| 37b8cf3a09 | |||
| e426e9c935 | |||
| 51a1f124eb | |||
| dc9f3a15d6 | |||
| 0d2ccaf1c8 | |||
| df4f2ef5f5 | |||
| 0e0c2b0a33 | |||
| 0e5acdb660 | |||
| 6218550e6d | |||
| ac3290c0e7 | |||
| f25410c6e9 | |||
| e502094d04 | |||
| e78ac3497a | |||
| 4d6a44c836 | |||
| bc1e51836b | |||
| ae4b450c0b | |||
| 95e9627674 | |||
| 078de0467d | |||
| 42f712949b | |||
| 2eeea3072e | |||
| 60927c16a6 | |||
| 5498f28556 | |||
| fbf0c26d0f | |||
| 7da7df2297 | |||
| 4607e1bce2 | |||
| 05d668e2fd | |||
| e876ebdc31 | |||
| 5d0edc8cbb | |||
| 60ef10bd32 | |||
| 4b24894f0c | |||
| 1c17ee1869 | |||
| a8d95255d7 | |||
| 629e02e961 | |||
| 9daa218754 | |||
| 0f60736928 | |||
| 59f9aa6d92 | |||
| 8a9d0e5e6a | |||
| 0bc9d08c12 | |||
| f703cf3429 | |||
| 9a72ba61ce | |||
| d03dfd8e65 | |||
| 7e403c0ad4 | |||
| b0189d0828 | |||
| 4677300fde | |||
| 3548375b17 | |||
| 88816e4f66 | |||
| 9027259e0c | |||
| 4084424c89 | |||
| dff7a4dbb3 | |||
| 699c986eec | |||
| 43f592b10b | |||
| 74e1c1b4dd | |||
| b5a46ee191 | |||
| 2e59f0f06a | |||
| 0b940638ed | |||
| f79f390dca | |||
| 000ed604fe | |||
| af4b46c96e | |||
| 77160afa1a | |||
| 445262d4f2 | |||
| c7fde11c1b | |||
| 1a9751d321 | |||
| aab6b6f6ab | |||
| 47d73bfe3d | |||
| da60a98f32 | |||
| 0d1a6de0f3 | |||
| b59929ae09 | |||
| e3f9a9fb6c | |||
| 1c91819689 | |||
| 9eab9cd574 | |||
| 8a0f3a86f6 | |||
| 0155bd7e1b | |||
| 038a5e20e2 | |||
| caeeb30cc3 | |||
| b87ba63846 | |||
| 7c1cad687c | |||
| a4a7e3bbaa | |||
| 21f58becce | |||
| f819e72075 | |||
| cbf66c5e9b | |||
| fdf04aac54 | |||
| 3530b623d8 | |||
| e139430025 | |||
| b4b15b555a | |||
| 1e36f01748 |
@@ -105,7 +105,7 @@ struct FrogPilotCarParams @0xf35cc4560bbf6ec2 {
|
||||
isHDA2 @3 :Bool;
|
||||
openpilotLongitudinalControlDisabled @4 :Bool;
|
||||
safetyConfigs @5 :List(SafetyConfig);
|
||||
|
||||
canUseSASCM @6 :Bool;
|
||||
|
||||
struct SafetyConfig {
|
||||
safetyParam @0 :UInt16;
|
||||
|
||||
@@ -164,6 +164,9 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"LiveDelay", PERSISTENT},
|
||||
{"LiveParameters", PERSISTENT},
|
||||
{"LiveTorqueParameters", PERSISTENT | DONT_LOG},
|
||||
{"LongitudinalManeuverMode", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||
{"LongitudinalManeuverPaddleMode", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||
{"LongitudinalManeuverStatus", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||
{"LongitudinalPersonality", PERSISTENT},
|
||||
{"NavDestination", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||
{"NavDestinationWaypoints", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||
@@ -224,6 +227,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"AdvancedLateralTune", PERSISTENT},
|
||||
{"AdvancedLongitudinalTune", PERSISTENT},
|
||||
{"AggressiveFollow", PERSISTENT},
|
||||
{"AggressiveFollowHigh", PERSISTENT},
|
||||
{"AggressiveJerkAcceleration", PERSISTENT},
|
||||
{"AggressiveJerkDanger", PERSISTENT},
|
||||
{"AggressiveJerkDeceleration", PERSISTENT},
|
||||
@@ -234,6 +238,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"AlwaysOnLateral", PERSISTENT},
|
||||
{"AlwaysOnLateralLKAS", PERSISTENT},
|
||||
{"AlwaysOnLateralMain", PERSISTENT},
|
||||
{"AlwaysAllowUploads", PERSISTENT},
|
||||
{"AMapKey1", PERSISTENT | DONT_LOG},
|
||||
{"AMapKey2", PERSISTENT | DONT_LOG},
|
||||
{"ApiCache_DriveStats", PERSISTENT},
|
||||
@@ -246,6 +251,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"BlacklistedModels", PERSISTENT},
|
||||
{"BlindSpotMetrics", PERSISTENT},
|
||||
{"BlindSpotPath", PERSISTENT},
|
||||
{"BootLogo", PERSISTENT},
|
||||
{"BorderMetrics", PERSISTENT},
|
||||
{"CalibratedLateralAcceleration", PERSISTENT},
|
||||
{"CalibrationProgress", PERSISTENT},
|
||||
@@ -271,6 +277,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"CEStatus", CLEAR_ON_OFFROAD_TRANSITION},
|
||||
{"CEStoppedLead", PERSISTENT},
|
||||
{"ClusterOffset", PERSISTENT},
|
||||
{"BootLogoToDownload", CLEAR_ON_MANAGER_START},
|
||||
{"ColorToDownload", CLEAR_ON_MANAGER_START},
|
||||
{"Compass", PERSISTENT},
|
||||
{"ConditionalExperimental", PERSISTENT},
|
||||
@@ -309,6 +316,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"DisengageVolume", PERSISTENT},
|
||||
{"DoToggleReset", PERSISTENT},
|
||||
{"DoToggleResetStock", PERSISTENT},
|
||||
{"DownloadableBootLogos", PERSISTENT},
|
||||
{"DownloadableColors", PERSISTENT},
|
||||
{"DownloadableDistanceIcons", PERSISTENT},
|
||||
{"DownloadableIcons", PERSISTENT},
|
||||
@@ -320,7 +328,8 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"DynamicPathWidth", PERSISTENT},
|
||||
{"DynamicPedalsOnUI", PERSISTENT},
|
||||
{"EngageVolume", PERSISTENT},
|
||||
{"ExperimentalGMTune", PERSISTENT},
|
||||
{"EVTuning", PERSISTENT},
|
||||
{"TruckTuning", PERSISTENT},
|
||||
{"Fahrenheit", PERSISTENT},
|
||||
{"FavoriteDestinations", PERSISTENT | DONT_LOG},
|
||||
{"FlashPanda", CLEAR_ON_MANAGER_START},
|
||||
@@ -348,6 +357,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"GasRegenCmd", PERSISTENT},
|
||||
{"GMPedalLongitudinal", PERSISTENT},
|
||||
{"GoatScream", PERSISTENT},
|
||||
{"GoatScreamCriticalAlerts", PERSISTENT},
|
||||
{"GreenLightAlert", PERSISTENT},
|
||||
{"HideAlerts", PERSISTENT},
|
||||
{"HideLeadMarker", PERSISTENT},
|
||||
@@ -456,6 +466,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"RandomThemes", PERSISTENT},
|
||||
{"RefuseVolume", PERSISTENT},
|
||||
{"RelaxedFollow", PERSISTENT},
|
||||
{"RelaxedFollowHigh", PERSISTENT},
|
||||
{"RelaxedJerkAcceleration", PERSISTENT},
|
||||
{"RelaxedJerkDanger", PERSISTENT},
|
||||
{"RelaxedJerkDeceleration", PERSISTENT},
|
||||
@@ -466,6 +477,9 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"RoadEdgesWidth", PERSISTENT},
|
||||
{"RoadName", CLEAR_ON_MANAGER_START},
|
||||
{"RoadNameUI", PERSISTENT},
|
||||
{"RedPanda", PERSISTENT},
|
||||
{"RemapCancelToDistance", PERSISTENT},
|
||||
{"RemoteStartBootsComma", PERSISTENT},
|
||||
{"RotatingWheel", PERSISTENT},
|
||||
{"ScreenBrightness", PERSISTENT},
|
||||
{"ScreenBrightnessOnroad", PERSISTENT},
|
||||
@@ -515,6 +529,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"SpeedLimitsFiltered", PERSISTENT | DONT_LOG},
|
||||
{"SpeedLimitSources", PERSISTENT},
|
||||
{"StandardFollow", PERSISTENT},
|
||||
{"StandardFollowHigh", PERSISTENT},
|
||||
{"StandardJerkAcceleration", PERSISTENT},
|
||||
{"StandardJerkDanger", PERSISTENT},
|
||||
{"StandardJerkDeceleration", PERSISTENT},
|
||||
@@ -531,6 +546,8 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"SteerDelayStock", PERSISTENT},
|
||||
{"SteerFriction", PERSISTENT},
|
||||
{"SteerFrictionStock", PERSISTENT},
|
||||
{"SteerOffset", PERSISTENT},
|
||||
{"SteerOffsetStock", PERSISTENT},
|
||||
{"SteerLatAccel", PERSISTENT},
|
||||
{"SteerLatAccelStock", PERSISTENT},
|
||||
{"SteerKP", PERSISTENT},
|
||||
@@ -545,6 +562,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"StoppedTimer", PERSISTENT},
|
||||
{"TacoTune", PERSISTENT},
|
||||
{"TacoTuneHacks", PERSISTENT},
|
||||
{"TrailerLoad", PERSISTENT},
|
||||
{"TestAlert", CLEAR_ON_MANAGER_START},
|
||||
{"TetheringEnabled", PERSISTENT},
|
||||
{"ThemeDownloadProgress", CLEAR_ON_MANAGER_START},
|
||||
@@ -584,6 +602,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"WheelIcon", PERSISTENT},
|
||||
{"WheelSpeed", PERSISTENT},
|
||||
{"StopDistance", PERSISTENT},
|
||||
{"RecoveryPower", PERSISTENT},
|
||||
{"WheelToDownload", CLEAR_ON_MANAGER_START},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,13 +29,15 @@ def check_github_rate_limit():
|
||||
print(f"Error checking GitHub rate limit: {error}")
|
||||
return False
|
||||
|
||||
def download_file(cancel_param, destination, progress_param, url, download_param, params_memory):
|
||||
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)
|
||||
if total_size == 0:
|
||||
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
|
||||
handle_error(None, "Download invalid...", "Download invalid...", download_param, progress_param, params_memory)
|
||||
return
|
||||
|
||||
@@ -56,24 +58,30 @@ def download_file(cancel_param, destination, progress_param, url, download_param
|
||||
temp_file.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
progress = (downloaded_size / total_size) * 100
|
||||
if progress != 100:
|
||||
params_memory.put(progress_param, f"{progress:.0f}%")
|
||||
else:
|
||||
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)
|
||||
|
||||
except Exception as error:
|
||||
if suppress_errors:
|
||||
return
|
||||
handle_request_error(error, destination, download_param, progress_param, params_memory)
|
||||
|
||||
def get_remote_file_size(url):
|
||||
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:
|
||||
handle_request_error(error, None, None, None, None)
|
||||
if not suppress_errors:
|
||||
handle_request_error(error, None, None, None, None)
|
||||
return 0
|
||||
|
||||
def get_repository_url():
|
||||
@@ -104,8 +112,17 @@ def handle_request_error(error, destination, download_param, progress_param, par
|
||||
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):
|
||||
remote_file_size = get_remote_file_size(url)
|
||||
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}")
|
||||
|
||||
@@ -13,7 +13,7 @@ from openpilot.frogpilot.assets.download_functions import GITLAB_URL, download_f
|
||||
from openpilot.frogpilot.common.frogpilot_utilities import delete_file
|
||||
from openpilot.frogpilot.common.frogpilot_variables import DEFAULT_MODEL, MODELS_PATH, params, params_default, params_memory
|
||||
|
||||
VERSION = "v20"
|
||||
VERSION = "v21"
|
||||
|
||||
CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
|
||||
DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
|
||||
@@ -107,7 +107,7 @@ class ModelManager:
|
||||
self.downloading_model = False
|
||||
return
|
||||
|
||||
if model_version in ("v8", "v9", "v10", "v11"):
|
||||
if model_version in ("v8", "v9", "v10", "v11", "v12"):
|
||||
# Download all PKL and metadata files for multi-file tinygrad models (v8 and v9)
|
||||
filenames = [
|
||||
f"{model_to_download}_driving_policy_tinygrad.pkl",
|
||||
@@ -115,6 +115,11 @@ class ModelManager:
|
||||
f"{model_to_download}_driving_policy_metadata.pkl",
|
||||
f"{model_to_download}_driving_vision_metadata.pkl",
|
||||
]
|
||||
if model_version == "v12":
|
||||
filenames += [
|
||||
f"{model_to_download}_driving_off_policy_tinygrad.pkl",
|
||||
f"{model_to_download}_driving_off_policy_metadata.pkl",
|
||||
]
|
||||
for filename in filenames:
|
||||
model_path = MODELS_PATH / filename
|
||||
model_url = f"{repo_url}/Models/{filename}"
|
||||
@@ -208,13 +213,18 @@ class ModelManager:
|
||||
except Exception:
|
||||
model_version = None
|
||||
|
||||
if model_version in ("v8", "v9", "v10", "v11"):
|
||||
if model_version in ("v8", "v9", "v10", "v11", "v12"):
|
||||
v8_v9_files = [
|
||||
f"{model}_driving_policy_tinygrad.pkl",
|
||||
f"{model}_driving_vision_tinygrad.pkl",
|
||||
f"{model}_driving_policy_metadata.pkl",
|
||||
f"{model}_driving_vision_metadata.pkl",
|
||||
]
|
||||
if model_version == "v12":
|
||||
v8_v9_files += [
|
||||
f"{model}_driving_off_policy_tinygrad.pkl",
|
||||
f"{model}_driving_off_policy_metadata.pkl",
|
||||
]
|
||||
if all((MODELS_PATH / f).is_file() for f in v8_v9_files):
|
||||
downloaded_models.add(model)
|
||||
elif model_version == "v7":
|
||||
@@ -259,13 +269,18 @@ class ModelManager:
|
||||
except Exception:
|
||||
model_version = None
|
||||
|
||||
if model_version in ("v8", "v9", "v10", "v11"):
|
||||
if model_version in ("v8", "v9", "v10", "v11", "v12"):
|
||||
v8_v9_files = [
|
||||
f"{model}_driving_policy_tinygrad.pkl",
|
||||
f"{model}_driving_vision_tinygrad.pkl",
|
||||
f"{model}_driving_policy_metadata.pkl",
|
||||
f"{model}_driving_vision_metadata.pkl",
|
||||
]
|
||||
if model_version == "v12":
|
||||
v8_v9_files += [
|
||||
f"{model}_driving_off_policy_tinygrad.pkl",
|
||||
f"{model}_driving_off_policy_metadata.pkl",
|
||||
]
|
||||
for filename in v8_v9_files:
|
||||
path = MODELS_PATH / filename
|
||||
expected_size = model_sizes.get(filename.rsplit(".", 1)[0])
|
||||
@@ -378,13 +393,18 @@ class ModelManager:
|
||||
handle_error(None, "Download cancelled...", "Download cancelled...", MODEL_DOWNLOAD_ALL_PARAM, DOWNLOAD_PROGRESS_PARAM, params_memory)
|
||||
return
|
||||
|
||||
if model_version in ("v8", "v9", "v10", "v11"):
|
||||
if model_version in ("v8", "v9", "v10", "v11", "v12"):
|
||||
required_files = [
|
||||
f"{model}_driving_policy_tinygrad.pkl",
|
||||
f"{model}_driving_vision_tinygrad.pkl",
|
||||
f"{model}_driving_policy_metadata.pkl",
|
||||
f"{model}_driving_vision_metadata.pkl",
|
||||
]
|
||||
if model_version == "v12":
|
||||
required_files += [
|
||||
f"{model}_driving_off_policy_tinygrad.pkl",
|
||||
f"{model}_driving_off_policy_metadata.pkl",
|
||||
]
|
||||
missing = [f for f in required_files if not (MODELS_PATH / f).is_file()]
|
||||
if missing:
|
||||
print(f"Tinygrad model {model} is missing files. Preparing to download...")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
|
||||
@@ -37,6 +37,7 @@ HOLIDAY_SLUGS = {
|
||||
}
|
||||
|
||||
THEME_COMPONENT_PARAMS = {
|
||||
"boot_logos": "BootLogoToDownload",
|
||||
"colors": "ColorToDownload",
|
||||
"distance_icons": "DistanceIconToDownload",
|
||||
"icons": "IconToDownload",
|
||||
@@ -93,8 +94,16 @@ 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.png"
|
||||
boot_logo_save_path = THEME_SAVE_PATH / "bootlogos/starpilot.png"
|
||||
boot_logo_save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if 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
|
||||
allow_unknown_size = theme_component in {"boot_logos", "steering_wheels"}
|
||||
name_candidates = [theme_name]
|
||||
|
||||
repo_url = get_repository_url()
|
||||
if not repo_url:
|
||||
@@ -102,7 +111,12 @@ 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
|
||||
extensions = [".zip"]
|
||||
@@ -117,37 +131,40 @@ class ThemeManager:
|
||||
|
||||
for extension in extensions:
|
||||
theme_path = download_path.with_suffix(extension)
|
||||
theme_url = download_link + extension
|
||||
theme_urls = [f"{download_link}/{candidate}{extension}" for candidate in name_candidates]
|
||||
if theme_component != "boot_logos":
|
||||
theme_urls = [download_link + extension]
|
||||
|
||||
delete_file(theme_path)
|
||||
|
||||
print(f"Downloading theme from GitHub: {theme_name}")
|
||||
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory)
|
||||
|
||||
if params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
|
||||
for theme_url in theme_urls:
|
||||
delete_file(theme_path)
|
||||
handle_error(None, "Download cancelled...", "Download cancelled...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
|
||||
|
||||
self.downloading_theme = False
|
||||
return
|
||||
print(f"Downloading theme from GitHub: {theme_name}")
|
||||
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory, allow_unknown_size=allow_unknown_size, suppress_errors=allow_unknown_size)
|
||||
|
||||
if verify_download(theme_path, 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 params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
|
||||
delete_file(theme_path)
|
||||
handle_error(None, "Download cancelled...", "Download cancelled...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
|
||||
|
||||
if extension == ".zip":
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
|
||||
extract_zip(theme_path, download_path)
|
||||
self.downloading_theme = False
|
||||
return
|
||||
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
|
||||
params_memory.remove(asset_param)
|
||||
if verify_download(theme_path, theme_url, allow_unknown_size=allow_unknown_size):
|
||||
print(f"Theme {theme_name} downloaded and verified successfully from GitHub!")
|
||||
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
|
||||
|
||||
self.downloading_theme = False
|
||||
if extension == ".zip":
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
|
||||
extract_zip(theme_path, download_path)
|
||||
|
||||
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
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
|
||||
params_memory.remove(asset_param)
|
||||
|
||||
self.downloading_theme = False
|
||||
|
||||
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
|
||||
|
||||
handle_error(download_path, "Download failed...", "Download failed...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
|
||||
self.downloading_theme = False
|
||||
@@ -158,7 +175,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:
|
||||
@@ -234,6 +251,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)
|
||||
@@ -246,6 +277,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
|
||||
@@ -265,7 +297,7 @@ class ThemeManager:
|
||||
parts = base.replace("_", "-").split("-")
|
||||
capitalized_parts = [part.capitalize() for part in parts if part]
|
||||
|
||||
if len(capitalized_parts) > 1 and component != "steering_wheels":
|
||||
if len(capitalized_parts) > 1 and component not in {"boot_logos", "steering_wheels"}:
|
||||
display = f"{capitalized_parts[0]} ({' '.join(capitalized_parts[1:])})"
|
||||
else:
|
||||
display = " ".join(capitalized_parts)
|
||||
@@ -321,37 +353,44 @@ 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":
|
||||
allow_unknown_size = theme_component in {"boot_logos", "steering_wheels"}
|
||||
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, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory)
|
||||
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, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory, allow_unknown_size=allow_unknown_size, suppress_errors=allow_unknown_size)
|
||||
|
||||
if verify_download(theme_path, 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, theme_url, allow_unknown_size=allow_unknown_size):
|
||||
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":
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
|
||||
extract_zip(theme_path, download_path)
|
||||
if extension == ".zip":
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
|
||||
extract_zip(theme_path, download_path)
|
||||
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
|
||||
params_memory.remove(asset_param)
|
||||
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
|
||||
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, "Download failed...", "Download failed...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
|
||||
self.downloading_theme = False
|
||||
# Let the caller continue trying alternate extensions before surfacing failure.
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@@ -415,6 +454,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", "stock")
|
||||
|
||||
if time_validated and frogpilot_toggles.holiday_themes:
|
||||
self.holiday_theme = self.update_holiday()
|
||||
else:
|
||||
@@ -422,6 +463,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),
|
||||
@@ -434,6 +476,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),
|
||||
@@ -444,6 +487,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),
|
||||
@@ -458,7 +502,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)
|
||||
@@ -499,9 +545,16 @@ 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):
|
||||
def update_param(key, assets, subfolder):
|
||||
if subfolder == "steering_wheels":
|
||||
if subfolder == "boot_logos":
|
||||
themes_path = THEME_SAVE_PATH / "bootlogos"
|
||||
existing_assets = {item.stem.lower() for item in themes_path.glob("*") if item.is_file()}
|
||||
pending_assets = [asset for asset in assets if asset.lower() not in existing_assets]
|
||||
params.put(key, ",".join(sorted(set(pending_assets))))
|
||||
print(f"{key} updated successfully")
|
||||
return
|
||||
elif subfolder == "steering_wheels":
|
||||
themes_path = THEME_SAVE_PATH / subfolder
|
||||
existing_assets = {self.format_name(item.name, "steering_wheels") for item in themes_path.glob("*") if item.is_file()}
|
||||
else:
|
||||
@@ -511,6 +564,7 @@ class ThemeManager:
|
||||
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")
|
||||
@@ -529,12 +583,20 @@ class ThemeManager:
|
||||
theme_name = self.format_name(theme_dir.name, "theme_packs")
|
||||
downloaded_themes[theme_name] = sorted(components)
|
||||
|
||||
(THEME_SAVE_PATH / "bootlogos").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
downloaded_boot_logos = []
|
||||
for boot_logo_file in (THEME_SAVE_PATH / "bootlogos").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():
|
||||
if wheel_file.is_file():
|
||||
downloaded_wheels.append(self.format_name(wheel_file.name, "steering_wheels"))
|
||||
|
||||
params.put("ThemesDownloaded", json.dumps({
|
||||
"boot_logos": sorted(downloaded_boot_logos),
|
||||
"themes": {key: downloaded_themes[key] for key in sorted(downloaded_themes)},
|
||||
"steering_wheels": sorted(downloaded_wheels)
|
||||
}))
|
||||
@@ -542,7 +604,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"
|
||||
@@ -550,7 +614,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]:
|
||||
@@ -572,6 +636,7 @@ class ThemeManager:
|
||||
if not assets:
|
||||
return
|
||||
|
||||
downloadable_boot_logos = []
|
||||
downloadable_colors = []
|
||||
downloadable_distance_icons = []
|
||||
downloadable_icons = []
|
||||
@@ -593,8 +658,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}")
|
||||
@@ -605,7 +672,21 @@ class ThemeManager:
|
||||
if boot_run:
|
||||
self.validate_themes(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):
|
||||
boot_logo_location = Path(__file__).parent / "other_images/frogpilot_boot_logo.png"
|
||||
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), boot_logo_location)
|
||||
|
||||
# Avoid SameFileError when the selected/fallback source is already the active boot logo.
|
||||
if source_file.resolve() == boot_logo_location.resolve():
|
||||
print(f"Boot logo unchanged: {boot_logo_location}")
|
||||
return
|
||||
|
||||
shutil.copy2(source_file, boot_logo_location)
|
||||
print(f"Copied {source_file} to {boot_logo_location}")
|
||||
|
||||
def update_wheel_image(self, image, boot_run=False, random_event=False):
|
||||
wheel_save_location = ACTIVE_THEME_PATH / "steering_wheel"
|
||||
@@ -641,6 +722,15 @@ class ThemeManager:
|
||||
def validate_themes(self, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles):
|
||||
downloaded_data = json.loads(params.get("ThemesDownloaded") or "{}")
|
||||
|
||||
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(")", "")
|
||||
theme_folder_name = raw_name.replace("_animated", "-animated")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import json
|
||||
import math
|
||||
import numpy as np
|
||||
import os
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -22,7 +23,7 @@ from opendbc.can.parser import CANParser
|
||||
from openpilot.common.realtime import DT_DMON, DT_HW
|
||||
from openpilot.selfdrive.car.toyota.carcontroller import LOCK_CMD
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from panda import Panda
|
||||
from panda import Panda, FW_PATH
|
||||
|
||||
from openpilot.frogpilot.common.frogpilot_variables import EARTH_RADIUS, KONIK_PATH, MAPD_PATH, MAPS_PATH, params, params_cache, params_memory
|
||||
|
||||
@@ -151,11 +152,21 @@ def extract_zip(zip_file, extract_path):
|
||||
print(f"Extraction completed: {zip_file} has been removed")
|
||||
|
||||
def flash_panda():
|
||||
remote_start = params.get_bool("RemoteStartBootsComma")
|
||||
for serial in Panda.list():
|
||||
try:
|
||||
panda = Panda(serial)
|
||||
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.reset(enter_bootstub=True)
|
||||
panda.flash()
|
||||
panda.flash(fn=flash_fn)
|
||||
panda.close()
|
||||
except Exception as exception:
|
||||
print(f"Error flashing Panda {serial}: {exception}")
|
||||
|
||||
@@ -13,7 +13,8 @@ from openpilot.common.conversions import Conversions as CV
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.car import gen_empty_fingerprint
|
||||
from openpilot.selfdrive.car.car_helpers import interfaces
|
||||
from openpilot.selfdrive.car.gm.values import GMFlags
|
||||
from openpilot.selfdrive.car.gm.values import EV_CAR as GM_EV_CAR, GMFlags
|
||||
from openpilot.selfdrive.car.hyundai.values import EV_CAR as HYUNDAI_EV_CAR
|
||||
from openpilot.selfdrive.car.interfaces import CarInterfaceBase
|
||||
from openpilot.selfdrive.car.mock.interface import CarInterface
|
||||
from openpilot.selfdrive.car.mock.values import CAR as MOCK
|
||||
@@ -32,6 +33,7 @@ params_memory = Params("/dev/shm/params")
|
||||
|
||||
GearShifter = car.CarState.GearShifter
|
||||
SafetyModel = car.CarParams.SafetyModel
|
||||
TransmissionType = car.CarParams.TransmissionType
|
||||
|
||||
CITY_SPEED_LIMIT = 25 # 55mph is typically the minimum speed for highways
|
||||
CRUISING_SPEED = 5 # Roughly the speed cars go when not touching the gas while in drive
|
||||
@@ -94,6 +96,8 @@ EXCLUDED_KEYS = {
|
||||
}
|
||||
|
||||
TINYGRAD_FILES = [
|
||||
("driving_off_policy_metadata.pkl", "off-policy metadata"),
|
||||
("driving_off_policy_tinygrad.pkl", "off-policy model"),
|
||||
("driving_policy_metadata.pkl", "policy metadata"),
|
||||
("driving_policy_tinygrad.pkl", "policy model"),
|
||||
("driving_vision_metadata.pkl", "vision metadata"),
|
||||
@@ -126,9 +130,12 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("AdjacentPath", "0", 3, "0"),
|
||||
("AdjacentPathMetrics", "0", 3, "0"),
|
||||
("AdvancedCustomUI", "0", 2, "0"),
|
||||
("AdvancedLateralTune", "0", 2, "0"),
|
||||
("AdvancedLateralTune", "1", 2, "0"),
|
||||
("AdvancedLongitudinalTune", "0", 3, "0"),
|
||||
("EVTuning", "", 3, "0"),
|
||||
("TruckTuning", "0", 3, "0"),
|
||||
("AggressiveFollow", "1.25", 2, "1.25"),
|
||||
("AggressiveFollowHigh", "1.25", 2, "1.25"),
|
||||
("AggressiveJerkAcceleration", "50", 3, "50"),
|
||||
("AggressiveJerkDanger", "100", 3, "100"),
|
||||
("AggressiveJerkDeceleration", "50", 3, "50"),
|
||||
@@ -140,6 +147,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("AlwaysOnLateral", "1", 0, "0"),
|
||||
("AlwaysOnLateralLKAS", "1", 0, "0"),
|
||||
("AlwaysOnLateralMain", "1", 0, "0"),
|
||||
("AlwaysAllowUploads", "0", 2, "0"),
|
||||
("AMapKey1", "", 0, ""),
|
||||
("AMapKey2", "", 0, ""),
|
||||
("AutomaticallyDownloadModels", "1", 1, "0"),
|
||||
@@ -153,7 +161,8 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("BlacklistedModels", "", 2, ""),
|
||||
("BlindSpotMetrics", "1", 3, "0"),
|
||||
("BlindSpotPath", "1", 1, "0"),
|
||||
("BorderMetrics", "0", 3, "0"),
|
||||
("BootLogo", "starpilot", 0, "stock"),
|
||||
("BorderMetrics", "1", 3, "0"),
|
||||
("CalibratedLateralAcceleration", str(DEFAULT_LATERAL_ACCELERATION), 2, str(DEFAULT_LATERAL_ACCELERATION)),
|
||||
("CalibrationProgress", "0", 3, "0"),
|
||||
("CameraView", "3", 2, "0"),
|
||||
@@ -164,7 +173,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("CECurves", "0", 1, "0"),
|
||||
("CECurvesLead", "0", 1, "0"),
|
||||
("CELead", "0", 1, "0"),
|
||||
("CEModelStopTime", str(PLANNER_TIME - 2), 2, "0"),
|
||||
("CEModelStopTime", "7", 2, "0"),
|
||||
("CENavigation", "0", 2, "0"),
|
||||
("CENavigationIntersections", "1", 2, "0"),
|
||||
("CENavigationLead", "1", 2, "0"),
|
||||
@@ -192,7 +201,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("CustomUI", "1", 1, "0"),
|
||||
("DecelerationProfile", "1", 2, "0"),
|
||||
("DeveloperMetrics", "1", 3, "0"),
|
||||
("DeveloperSidebar", "1", 3, "0"),
|
||||
("DeveloperSidebar", "0", 3, "0"),
|
||||
("DeveloperSidebarMetric1", "1", 3, "0"),
|
||||
("DeveloperSidebarMetric2", "2", 3, "0"),
|
||||
("DeveloperSidebarMetric3", "3", 3, "0"),
|
||||
@@ -201,7 +210,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("DeveloperSidebarMetric6", "6", 3, "0"),
|
||||
("DeveloperSidebarMetric7", "7", 3, "0"),
|
||||
("DeveloperWidgets", "1", 3, "0"),
|
||||
("DeveloperUI", "0", 3, "0"),
|
||||
("DeveloperUI", "1", 3, "0"),
|
||||
("DeviceManagement", "1", 1, "0"),
|
||||
("DeviceShutdown", "9", 1, "33"),
|
||||
("DisableOnroadUploads", "0", 2, "0"),
|
||||
@@ -213,13 +222,12 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("DynamicPathWidth", "0", 2, "0"),
|
||||
("DynamicPedalsOnUI", "1", 2, "0"),
|
||||
("EngageVolume", "101", 2, "101"),
|
||||
("ExperimentalGMTune", "0", 2, "0"),
|
||||
("ExperimentalLongitudinalEnabled", "0", 0, "0"),
|
||||
("ExperimentalModeConfirmed", "0", 0, "0"),
|
||||
("Fahrenheit", "0", 3, "0"),
|
||||
("FavoriteDestinations", "", 0, ""),
|
||||
("ForceAutoTune", "0", 2, "0"),
|
||||
("ForceAutoTuneOff", "0", 2, "0"),
|
||||
("ForceAutoTuneOff", "1", 2, "0"),
|
||||
("ForceFingerprint", "0", 2, "0"),
|
||||
("ForceMPHDashboard", "0", 2, "0"),
|
||||
("ForceStops", "0", 2, "0"),
|
||||
@@ -231,9 +239,13 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("FullMap", "0", 2, "0"),
|
||||
("GasRegenCmd", "1", 2, "0"),
|
||||
("GMPedalLongitudinal", "1", 2, "1"),
|
||||
("RemapCancelToDistance", "0", 2, "0"),
|
||||
("RedPanda", "0", 3, "0"),
|
||||
("RemoteStartBootsComma", "0", 3, "0"),
|
||||
("GithubSshKeys", "", 0, ""),
|
||||
("GithubUsername", "", 0, ""),
|
||||
("GoatScream", "0", 1, "0"),
|
||||
("GoatScreamCriticalAlerts", "0", 1, "0"),
|
||||
("GreenLightAlert", "0", 0, "0"),
|
||||
("GsmApn", "", 0, ""),
|
||||
("GsmRoaming", "1", 0, "0"),
|
||||
@@ -270,6 +282,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("LongitudinalActuatorDelay", "", 3, ""),
|
||||
("LongitudinalActuatorDelayStock", "", 3, ""),
|
||||
("LongitudinalTune", "1", 0, "0"),
|
||||
("TrailerLoad", "0", 2, "0"),
|
||||
("LongPitch", "1", 2, "0"),
|
||||
("LoudBlindspotAlert", "0", 0, "0"),
|
||||
("LowVoltageShutdown", str(VBATT_PAUSE_CHARGING), 2, str(VBATT_PAUSE_CHARGING)),
|
||||
@@ -329,13 +342,14 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("RecordFront", "0", 0, "0"),
|
||||
("RefuseVolume", "101", 2, "101"),
|
||||
("RelaxedFollow", "1.75", 2, "1.75"),
|
||||
("RelaxedFollowHigh", "1.75", 2, "1.75"),
|
||||
("RelaxedJerkAcceleration", "50", 3, "50"),
|
||||
("RelaxedJerkDanger", "100", 3, "100"),
|
||||
("RelaxedJerkDeceleration", "50", 3, "50"),
|
||||
("RelaxedJerkSpeed", "50", 3, "50"),
|
||||
("RelaxedJerkSpeedDecrease", "50", 3, "50"),
|
||||
("RelaxedPersonalityProfile", "1", 2, "0"),
|
||||
("ReverseCruise", "0", 1, "0"),
|
||||
("ReverseCruise", "1", 1, "0"),
|
||||
("RoadEdgesWidth", "2", 2, "2"),
|
||||
("RoadNameUI", "1", 2, "0"),
|
||||
("RotatingWheel", "1", 1, "0"),
|
||||
@@ -359,7 +373,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("ShownToggleDescriptions", "", 0, ""),
|
||||
("ShowSLCOffset", "1", 0, "0"),
|
||||
("ShowSpeedLimits", "1", 1, "0"),
|
||||
("ShowSteering", "0", 3, "0"),
|
||||
("ShowSteering", "1", 3, "0"),
|
||||
("ShowStoppingPoint", "0", 2, "0"),
|
||||
("ShowStoppingPointMetrics", "0", 2, "0"),
|
||||
("ShowStorageLeft", "0", 3, "0"),
|
||||
@@ -386,6 +400,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("StartupMessageBottom", "Human-tested, frog-approved 🐸", 0, "Always keep hands on wheel and eyes on road"),
|
||||
("StartupMessageTop", "Hop in and buckle up!", 0, "Be ready to take over at any time"),
|
||||
("StandardFollow", "1.45", 2, "1.45"),
|
||||
("StandardFollowHigh", "1.45", 2, "1.45"),
|
||||
("StandardJerkAcceleration", "50", 3, "50"),
|
||||
("StandardJerkDanger", "100", 3, "100"),
|
||||
("StandardJerkDeceleration", "50", 3, "50"),
|
||||
@@ -400,6 +415,8 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("SteerDelayStock", "", 3, ""),
|
||||
("SteerFriction", "", 3, ""),
|
||||
("SteerFrictionStock", "", 3, ""),
|
||||
("SteerOffset", "", 3, ""),
|
||||
("SteerOffsetStock", "", 3, ""),
|
||||
("SteerKP", "", 3, ""),
|
||||
("SteerKPStock", "", 3, ""),
|
||||
("SteerLatAccel", "", 3, ""),
|
||||
@@ -422,8 +439,8 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("TrafficJerkSpeed", "50", 3, "50"),
|
||||
("TrafficJerkSpeedDecrease", "50", 3, "50"),
|
||||
("TrafficPersonalityProfile", "1", 2, "0"),
|
||||
("TuningLevel", "0", 0, "0"),
|
||||
("TuningLevelConfirmed", "0", 0, "0"),
|
||||
("TuningLevel", "3", 0, "0"),
|
||||
("TuningLevelConfirmed", "1", 0, "0"),
|
||||
("TurnDesires", "0", 2, "0"),
|
||||
("UnlimitedLength", "1", 2, "0"),
|
||||
("UnlockDoors", "1", 0, "0"),
|
||||
@@ -441,7 +458,8 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
||||
("WarningSoftVolume", "101", 2, "101"),
|
||||
("WheelIcon", "frog", 0, "stock"),
|
||||
("WheelSpeed", "0", 2, "0"),
|
||||
("StopDistance", "6", 3, "6")
|
||||
("StopDistance", "6.0", 3, "6.0"),
|
||||
("RecoveryPower", "1.0", 2, "1.0")
|
||||
]
|
||||
|
||||
misc_tuning_levels: list[tuple[str, str | bytes, int, str]] = [
|
||||
@@ -559,8 +577,10 @@ class FrogPilotVariables:
|
||||
toggle.has_cc_long = toggle.car_make == "gm" and bool(CP.flags & GMFlags.CC_LONG.value)
|
||||
has_nnff = nnff_supported(toggle.car_model)
|
||||
toggle.has_pedal = CP.enableGasInterceptor
|
||||
params_default.put("IncreasedStoppedDistance", "4" if toggle.has_pedal else "0")
|
||||
has_radar = not CP.radarUnavailable
|
||||
toggle.has_sdsu = toggle.car_make == "toyota" and bool(CP.flags & ToyotaFlags.SMART_DSU.value)
|
||||
toggle.has_sascm = toggle.car_make == "gm" and bool(CP.flags & GMFlags.SASCM.value)
|
||||
has_sng = CP.autoResumeSng
|
||||
toggle.has_zss = toggle.car_make == "toyota" and bool(FPCP.fpFlags & ToyotaFrogPilotFlags.ZSS.value)
|
||||
is_angle_car = CP.steerControlType == car.CarParams.SteerControlType.angle
|
||||
@@ -604,14 +624,37 @@ class FrogPilotVariables:
|
||||
toggle.steerActuatorDelay = np.clip(params.get_float("SteerDelay"), 0.01, 1.0) if advanced_lateral_tuning and tuning_level >= level["SteerDelay"] else steerActuatorDelay
|
||||
toggle.use_custom_steerActuatorDelay = bool(round(toggle.steerActuatorDelay, 2) != round(steerActuatorDelay, 2))
|
||||
toggle.friction = np.clip(params.get_float("SteerFriction"), 0, 0.5) if advanced_lateral_tuning and tuning_level >= level["SteerFriction"] else friction
|
||||
toggle.steer_offset = np.clip(params.get_float("SteerOffset"), -0.2, 0.2) if advanced_lateral_tuning and tuning_level >= level["SteerOffset"] and toggle.car_make == "gm" else 0.0
|
||||
toggle.use_custom_friction = bool(round(toggle.friction, 2) != round(friction, 2)) and is_torque_car and not toggle.force_auto_tune or toggle.force_auto_tune_off
|
||||
toggle.steerKp = [[0], [np.clip(params.get_float("SteerKP"), steerKp * 0.5, steerKp * 1.5) if advanced_lateral_tuning and is_torque_car and tuning_level >= level["SteerKP"] else steerKp]]
|
||||
toggle.latAccelFactor = np.clip(params.get_float("SteerLatAccel"), latAccelFactor * 0.75, latAccelFactor * 1.25) if advanced_lateral_tuning and tuning_level >= level["SteerLatAccel"] else latAccelFactor
|
||||
toggle.latAccelFactor = np.clip(params.get_float("SteerLatAccel"), latAccelFactor * 0.5, latAccelFactor * 1.25) if advanced_lateral_tuning and tuning_level >= level["SteerLatAccel"] else latAccelFactor
|
||||
toggle.use_custom_latAccelFactor = bool(round(toggle.latAccelFactor, 2) != round(latAccelFactor, 2)) and is_torque_car and not toggle.force_auto_tune or toggle.force_auto_tune_off
|
||||
toggle.steerRatio = np.clip(params.get_float("SteerRatio"), steerRatio * 0.5, steerRatio * 1.5) if advanced_lateral_tuning and tuning_level >= level["SteerRatio"] else steerRatio
|
||||
toggle.steerRatio = np.clip(params.get_float("SteerRatio"), steerRatio * 0.25, steerRatio * 1.5) if advanced_lateral_tuning and tuning_level >= level["SteerRatio"] else steerRatio
|
||||
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 = params.get_bool("AdvancedLongitudinalTune") if tuning_level >= level["AdvancedLongitudinalTune"] else default.get_bool("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 == TransmissionType.direct
|
||||
|
||||
if params.get("EVTuning") == b"":
|
||||
params.put_bool("EVTuning", ev_vehicle)
|
||||
|
||||
if params.get("TruckTuning") == b"":
|
||||
params.put_bool("TruckTuning", False)
|
||||
|
||||
ev_tuning_param = params.get_bool("EVTuning")
|
||||
truck_tuning_param = params.get_bool("TruckTuning")
|
||||
|
||||
# Enforce exclusivity between EV and Truck tuning.
|
||||
if truck_tuning_param and ev_tuning_param:
|
||||
ev_tuning_param = False
|
||||
params.put_bool("EVTuning", False)
|
||||
|
||||
toggle.ev_tuning = ev_tuning_param if advanced_longitudinal_tuning and tuning_level >= level["EVTuning"] else ev_vehicle
|
||||
toggle.truck_tuning = truck_tuning_param if advanced_longitudinal_tuning and tuning_level >= level["TruckTuning"] else False
|
||||
toggle.longitudinalActuatorDelay = np.clip(params.get_float("LongitudinalActuatorDelay"), 0, 1) if advanced_longitudinal_tuning and tuning_level >= level["LongitudinalActuatorDelay"] else longitudinalActuatorDelay
|
||||
toggle.startAccel = np.clip(params.get_float("StartAccel"), 0, 4) if advanced_longitudinal_tuning and tuning_level >= level["StartAccel"] else startAccel
|
||||
toggle.stopAccel = np.clip(params.get_float("StopAccel"), -4, 0) if advanced_longitudinal_tuning and tuning_level >= level["StopAccel"] else stopAccel
|
||||
@@ -621,6 +664,8 @@ class FrogPilotVariables:
|
||||
|
||||
toggle.stop_distance = params.get_float("StopDistance") if advanced_longitudinal_tuning and tuning_level >= level["StopDistance"] else 6.0
|
||||
|
||||
toggle.recovery_power = np.clip(params.get_float("RecoveryPower"), 0.5, 2.0) if advanced_longitudinal_tuning and tuning_level >= level["RecoveryPower"] else 1.0
|
||||
|
||||
toggle.alert_volume_controller = params.get_bool("AlertVolumeControl") if tuning_level >= level["AlertVolumeControl"] else default.get_bool("AlertVolumeControl")
|
||||
toggle.disengage_volume = params.get_int("DisengageVolume") if toggle.alert_volume_controller and tuning_level >= level["DisengageVolume"] else default.get_int("DisengageVolume")
|
||||
toggle.engage_volume = params.get_int("EngageVolume") if toggle.alert_volume_controller and tuning_level >= level["EngageVolume"] else default.get_int("EngageVolume")
|
||||
@@ -640,7 +685,7 @@ class FrogPilotVariables:
|
||||
|
||||
toggle.car_model = params.get("CarModel", encoding="utf-8") or toggle.car_model
|
||||
|
||||
toggle.cluster_offset = params.get_float("ClusterOffset") if toggle.car_make == "toyota" and tuning_level >= level["ClusterOffset"] else default.get_float("ClusterOffset")
|
||||
toggle.cluster_offset = params.get_float("ClusterOffset") if toggle.car_make in {"gm", "toyota"} and tuning_level >= level["ClusterOffset"] else default.get_float("ClusterOffset")
|
||||
|
||||
toggle.conditional_experimental_mode = toggle.openpilot_longitudinal and (params.get_bool("ConditionalExperimental") if tuning_level >= level["ConditionalExperimental"] else default.get_bool("ConditionalExperimental"))
|
||||
toggle.conditional_curves = toggle.conditional_experimental_mode and (params.get_bool("CECurves") if tuning_level >= level["CECurves"] else default.get_bool("CECurves"))
|
||||
@@ -664,6 +709,7 @@ class FrogPilotVariables:
|
||||
|
||||
toggle.custom_alerts = params.get_bool("CustomAlerts") if tuning_level >= level["CustomAlerts"] else default.get_bool("CustomAlerts")
|
||||
toggle.goat_scream_alert = toggle.custom_alerts and (params.get_bool("GoatScream") if tuning_level >= level["GoatScream"] else default.get_bool("GoatScream"))
|
||||
toggle.goat_scream_critical_alerts = toggle.custom_alerts and (params.get_bool("GoatScreamCriticalAlerts") if tuning_level >= level["GoatScreamCriticalAlerts"] else default.get_bool("GoatScreamCriticalAlerts"))
|
||||
toggle.green_light_alert = toggle.custom_alerts and (params.get_bool("GreenLightAlert") if tuning_level >= level["GreenLightAlert"] else default.get_bool("GreenLightAlert"))
|
||||
toggle.lead_departing_alert = toggle.custom_alerts and (params.get_bool("LeadDepartingAlert") if tuning_level >= level["LeadDepartingAlert"] else default.get_bool("LeadDepartingAlert"))
|
||||
toggle.loud_blindspot_alert = has_bsm and toggle.custom_alerts and (params.get_bool("LoudBlindspotAlert") if tuning_level >= level["LoudBlindspotAlert"] else default.get_bool("LoudBlindspotAlert"))
|
||||
@@ -676,28 +722,31 @@ class FrogPilotVariables:
|
||||
toggle.aggressive_jerk_danger = np.clip(params.get_int("AggressiveJerkDanger") / 100, 0.25, 2) if aggressive_profile and tuning_level >= level["AggressiveJerkDanger"] else default.get_int("AggressiveJerkDanger") / 100
|
||||
toggle.aggressive_jerk_speed = np.clip(params.get_int("AggressiveJerkSpeed") / 100, 0.25, 2) if aggressive_profile and tuning_level >= level["AggressiveJerkSpeed"] else default.get_int("AggressiveJerkSpeed") / 100
|
||||
toggle.aggressive_jerk_speed_decrease = np.clip(params.get_int("AggressiveJerkSpeedDecrease") / 100, 0.25, 2) if aggressive_profile and tuning_level >= level["AggressiveJerkSpeedDecrease"] else default.get_int("AggressiveJerkSpeedDecrease") / 100
|
||||
toggle.aggressive_follow = np.clip(params.get_float("AggressiveFollow"), 1, MAX_T_FOLLOW) if aggressive_profile and tuning_level >= level["AggressiveFollow"] else default.get_float("AggressiveFollow")
|
||||
toggle.aggressive_follow = [np.clip(params.get_float("AggressiveFollow"), 1, MAX_T_FOLLOW) if aggressive_profile and tuning_level >= level["AggressiveFollow"] else default.get_float("AggressiveFollow"),
|
||||
np.clip(params.get_float("AggressiveFollowHigh"), 1, MAX_T_FOLLOW) if aggressive_profile and tuning_level >= level["AggressiveFollowHigh"] else default.get_float("AggressiveFollowHigh")]
|
||||
standard_profile = toggle.custom_personalities and (params.get_bool("StandardPersonalityProfile") if tuning_level >= level["StandardPersonalityProfile"] else default.get_bool("StandardPersonalityProfile"))
|
||||
toggle.standard_jerk_acceleration = np.clip(params.get_int("StandardJerkAcceleration") / 100, 0.25, 2) if standard_profile and tuning_level >= level["StandardJerkAcceleration"] else default.get_int("StandardJerkAcceleration") / 100
|
||||
toggle.standard_jerk_deceleration = np.clip(params.get_int("StandardJerkDeceleration") / 100, 0.25, 2) if standard_profile and tuning_level >= level["StandardJerkDeceleration"] else default.get_int("StandardJerkDeceleration") / 100
|
||||
toggle.standard_jerk_danger = np.clip(params.get_int("StandardJerkDanger") / 100, 0.25, 2) if standard_profile and tuning_level >= level["StandardJerkDanger"] else default.get_int("StandardJerkDanger") / 100
|
||||
toggle.standard_jerk_speed = np.clip(params.get_int("StandardJerkSpeed") / 100, 0.25, 2) if standard_profile and tuning_level >= level["StandardJerkSpeed"] else default.get_int("StandardJerkSpeed") / 100
|
||||
toggle.standard_jerk_speed_decrease = np.clip(params.get_int("StandardJerkSpeedDecrease") / 100, 0.25, 2) if standard_profile and tuning_level >= level["StandardJerkSpeedDecrease"] else default.get_int("StandardJerkSpeedDecrease") / 100
|
||||
toggle.standard_follow = np.clip(params.get_float("StandardFollow"), 1, MAX_T_FOLLOW) if standard_profile and tuning_level >= level["StandardFollow"] else default.get_float("StandardFollow")
|
||||
toggle.standard_follow = [np.clip(params.get_float("StandardFollow"), 1, MAX_T_FOLLOW) if standard_profile and tuning_level >= level["StandardFollow"] else default.get_float("StandardFollow"),
|
||||
np.clip(params.get_float("StandardFollowHigh"), 1, MAX_T_FOLLOW) if standard_profile and tuning_level >= level["StandardFollowHigh"] else default.get_float("StandardFollowHigh")]
|
||||
relaxed_profile = toggle.custom_personalities and (params.get_bool("RelaxedPersonalityProfile") if tuning_level >= level["RelaxedPersonalityProfile"] else default.get_bool("RelaxedPersonalityProfile"))
|
||||
toggle.relaxed_jerk_acceleration = np.clip(params.get_int("RelaxedJerkAcceleration") / 100, 0.25, 2) if relaxed_profile and tuning_level >= level["RelaxedJerkAcceleration"] else default.get_int("RelaxedJerkAcceleration") / 100
|
||||
toggle.relaxed_jerk_deceleration = np.clip(params.get_int("RelaxedJerkDeceleration") / 100, 0.25, 2) if relaxed_profile and tuning_level >= level["RelaxedJerkDeceleration"] else default.get_int("RelaxedJerkDeceleration") / 100
|
||||
toggle.relaxed_jerk_danger = np.clip(params.get_int("RelaxedJerkDanger") / 100, 0.25, 2) if relaxed_profile and tuning_level >= level["RelaxedJerkDanger"] else default.get_int("RelaxedJerkDanger") / 100
|
||||
toggle.relaxed_jerk_speed = np.clip(params.get_int("RelaxedJerkSpeed") / 100, 0.25, 2) if relaxed_profile and tuning_level >= level["RelaxedJerkSpeed"] else default.get_int("RelaxedJerkSpeed") / 100
|
||||
toggle.relaxed_jerk_speed_decrease = np.clip(params.get_int("RelaxedJerkSpeedDecrease") / 100, 0.25, 2) if relaxed_profile and tuning_level >= level["RelaxedJerkSpeedDecrease"] else default.get_int("RelaxedJerkSpeedDecrease") / 100
|
||||
toggle.relaxed_follow = np.clip(params.get_float("RelaxedFollow"), 1, MAX_T_FOLLOW) if relaxed_profile and tuning_level >= level["RelaxedFollow"] else default.get_float("RelaxedFollow")
|
||||
toggle.relaxed_follow = [np.clip(params.get_float("RelaxedFollow"), 1, MAX_T_FOLLOW) if relaxed_profile and tuning_level >= level["RelaxedFollow"] else default.get_float("RelaxedFollow"),
|
||||
np.clip(params.get_float("RelaxedFollowHigh"), 1, MAX_T_FOLLOW) if relaxed_profile and tuning_level >= level["RelaxedFollowHigh"] else default.get_float("RelaxedFollowHigh")]
|
||||
traffic_profile = toggle.custom_personalities and (params.get_bool("TrafficPersonalityProfile") if tuning_level >= level["TrafficPersonalityProfile"] else default.get_bool("TrafficPersonalityProfile"))
|
||||
toggle.traffic_mode_jerk_acceleration = [np.clip(params.get_int("TrafficJerkAcceleration") / 100, 0.25, 2) if traffic_profile and tuning_level >= level["TrafficJerkAcceleration"] else default.get_int("TrafficJerkAcceleration") / 100, toggle.aggressive_jerk_acceleration]
|
||||
toggle.traffic_mode_jerk_deceleration = [np.clip(params.get_int("TrafficJerkDeceleration") / 100, 0.25, 2) if traffic_profile and tuning_level >= level["TrafficJerkDeceleration"] else default.get_int("TrafficJerkDeceleration") / 100, toggle.aggressive_jerk_deceleration]
|
||||
toggle.traffic_mode_jerk_danger = [np.clip(params.get_int("TrafficJerkDanger") / 100, 0.25, 2) if traffic_profile and tuning_level >= level["TrafficJerkDanger"] else default.get_int("TrafficJerkDanger") / 100, toggle.aggressive_jerk_danger]
|
||||
toggle.traffic_mode_jerk_speed = [np.clip(params.get_int("TrafficJerkSpeed") / 100, 0.25, 2) if traffic_profile and tuning_level >= level["TrafficJerkSpeed"] else default.get_int("TrafficJerkSpeed") / 100, toggle.aggressive_jerk_speed]
|
||||
toggle.traffic_mode_jerk_speed_decrease = [np.clip(params.get_int("TrafficJerkSpeedDecrease") / 100, 0.25, 2) if traffic_profile and tuning_level >= level["TrafficJerkSpeedDecrease"] else default.get_int("TrafficJerkSpeedDecrease") / 100, toggle.aggressive_jerk_speed_decrease]
|
||||
toggle.traffic_mode_follow = [np.clip(params.get_float("TrafficFollow"), 0.5, MAX_T_FOLLOW) if traffic_profile and tuning_level >= level["TrafficFollow"] else default.get_float("TrafficFollow"), toggle.aggressive_follow]
|
||||
toggle.traffic_mode_follow = [np.clip(params.get_float("TrafficFollow"), 0.5, MAX_T_FOLLOW) if traffic_profile and tuning_level >= level["TrafficFollow"] else default.get_float("TrafficFollow"), toggle.aggressive_follow[0]]
|
||||
|
||||
custom_ui = params.get_bool("CustomUI") if tuning_level >= level["CustomUI"] else default.get_bool("CustomUI")
|
||||
toggle.acceleration_path = toggle.openpilot_longitudinal and (custom_ui and (params.get_bool("AccelerationPath") if tuning_level >= level["AccelerationPath"] else default.get_bool("AccelerationPath")) or toggle.debug_mode)
|
||||
@@ -746,6 +795,7 @@ class FrogPilotVariables:
|
||||
toggle.device_shutdown_time = (device_shutdown_setting - 3) * 3600 if device_shutdown_setting >= 4 else device_shutdown_setting * (60 * 15)
|
||||
toggle.increase_thermal_limits = device_management and (params.get_bool("IncreaseThermalLimits") if tuning_level >= level["IncreaseThermalLimits"] else default.get_bool("IncreaseThermalLimits"))
|
||||
toggle.low_voltage_shutdown = np.clip(params.get_float("LowVoltageShutdown"), VBATT_PAUSE_CHARGING, 12.5) if device_management and tuning_level >= level["LowVoltageShutdown"] else default.get_float("LowVoltageShutdown")
|
||||
toggle.always_allow_uploads = device_management and (params.get_bool("AlwaysAllowUploads") if tuning_level >= level["AlwaysAllowUploads"] else default.get_bool("AlwaysAllowUploads"))
|
||||
toggle.no_logging = device_management and (params.get_bool("NoLogging") if tuning_level >= level["NoLogging"] else default.get_bool("NoLogging")) and not self.vetting_branch or toggle.force_onroad
|
||||
toggle.no_uploads = device_management and (params.get_bool("NoUploads") if tuning_level >= level["NoUploads"] else default.get_bool("NoUploads")) and not self.vetting_branch or toggle.use_higher_bitrate
|
||||
toggle.no_onroad_uploads = toggle.no_uploads and (params.get_bool("DisableOnroadUploads") if tuning_level >= level["DisableOnroadUploads"] else default.get_bool("DisableOnroadUploads")) and not toggle.use_higher_bitrate
|
||||
@@ -777,10 +827,11 @@ class FrogPilotVariables:
|
||||
toggle.personality_profile_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["PERSONALITY_PROFILE"]
|
||||
toggle.traffic_mode_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"]
|
||||
|
||||
toggle.experimental_gm_tune = toggle.openpilot_longitudinal and toggle.car_make == "gm" and (params.get_bool("ExperimentalGMTune") if tuning_level >= level["ExperimentalGMTune"] else default.get_bool("ExperimentalGMTune"))
|
||||
toggle.stoppingDecelRate = 0.3 if toggle.experimental_gm_tune else toggle.stoppingDecelRate
|
||||
toggle.vEgoStarting = 0.15 if toggle.experimental_gm_tune else toggle.vEgoStarting
|
||||
toggle.vEgoStopping = 0.15 if toggle.experimental_gm_tune else toggle.vEgoStopping
|
||||
toggle.red_panda = toggle.car_make == "gm" and (params.get_bool("RedPanda") if tuning_level >= level["RedPanda"] else default.get_bool("RedPanda"))
|
||||
toggle.remote_start_boots_comma = toggle.car_make == "gm" and (params.get_bool("RemoteStartBootsComma") if tuning_level >= level["RemoteStartBootsComma"] else default.get_bool("RemoteStartBootsComma"))
|
||||
toggle.remap_cancel_to_distance = toggle.car_make == "gm" and toggle.openpilot_longitudinal and (
|
||||
params.get_bool("RemapCancelToDistance") if tuning_level >= level["RemapCancelToDistance"] else default.get_bool("RemapCancelToDistance")
|
||||
)
|
||||
|
||||
toggle.force_fingerprint = (params.get_bool("ForceFingerprint") if tuning_level >= level["ForceFingerprint"] else default.get_bool("ForceFingerprint")) and toggle.car_model is not None
|
||||
|
||||
@@ -826,6 +877,7 @@ class FrogPilotVariables:
|
||||
toggle.human_following = longitudinal_tuning and (params.get_bool("HumanFollowing") if tuning_level >= level["HumanFollowing"] else default.get_bool("HumanFollowing"))
|
||||
toggle.lead_detection_probability = np.clip(params.get_int("LeadDetectionThreshold") / 100, 0.25, 0.50) if longitudinal_tuning and tuning_level >= level["LeadDetectionThreshold"] else default.get_int("LeadDetectionThreshold") / 100
|
||||
toggle.max_desired_acceleration = np.clip(params.get_float("MaxDesiredAcceleration"), 0.1, 4.0) if longitudinal_tuning and tuning_level >= level["MaxDesiredAcceleration"] else default.get_float("MaxDesiredAcceleration")
|
||||
toggle.trailer_load_kg = (np.clip(params.get_int("TrailerLoad"), 0, 15000) if longitudinal_tuning and tuning_level >= level["TrailerLoad"] else default.get_int("TrailerLoad")) * CV.LB_TO_KG
|
||||
toggle.taco_tune = longitudinal_tuning and (params.get_bool("TacoTune") if tuning_level >= level["TacoTune"] else default.get_bool("TacoTune"))
|
||||
|
||||
toggle.available_models = params.get("AvailableModels", encoding="utf-8") or ""
|
||||
@@ -866,7 +918,7 @@ class FrogPilotVariables:
|
||||
toggle.model_version = DEFAULT_MODEL_VERSION
|
||||
toggle.classic_longitudinal = toggle.model_version in {"v1", "v2", "v3", "v4"}
|
||||
toggle.classic_model = toggle.model_version in {"v1", "v2", "v3", "v4"}
|
||||
toggle.tinygrad_model = toggle.model_version in {"v8", "v9", "v10", "v11"}
|
||||
toggle.tinygrad_model = toggle.model_version in {"v8", "v9", "v10", "v11", "v12"}
|
||||
toggle.tomb_raider = toggle.model == "space-lab"
|
||||
|
||||
toggle.model_ui = params.get_bool("ModelUI") if tuning_level >= level["ModelUI"] else default.get_bool("ModelUI")
|
||||
@@ -890,6 +942,7 @@ class FrogPilotVariables:
|
||||
toggle.old_long_api |= toggle.openpilot_longitudinal and toggle.car_make == "hyundai" and not (params.get_bool("NewLongAPI") if tuning_level >= level["NewLongAPI"] else default.get_bool("NewLongAPI"))
|
||||
|
||||
personalize_openpilot = params.get_bool("PersonalizeOpenpilot") if tuning_level >= level["PersonalizeOpenpilot"] else default.get_bool("PersonalizeOpenpilot")
|
||||
toggle.boot_logo = params.get("BootLogo", encoding="utf-8") or "starpilot"
|
||||
toggle.color_scheme = toggle.current_holiday_theme if toggle.current_holiday_theme != "stock" else params.get("CustomColors", encoding="utf-8") if personalize_openpilot else "stock"
|
||||
toggle.distance_icons = toggle.current_holiday_theme if toggle.current_holiday_theme != "stock" else params.get("CustomDistanceIcons", encoding="utf-8") if personalize_openpilot else "stock"
|
||||
toggle.icon_pack = toggle.current_holiday_theme if toggle.current_holiday_theme != "stock" else params.get("CustomIcons", encoding="utf-8") if personalize_openpilot else "stock"
|
||||
@@ -979,7 +1032,14 @@ class FrogPilotVariables:
|
||||
toggle.lock_doors = toyota_doors and (params.get_bool("LockDoors") if tuning_level >= level["LockDoors"] else default.get_bool("LockDoors"))
|
||||
toggle.unlock_doors = toyota_doors and (params.get_bool("UnlockDoors") if tuning_level >= level["UnlockDoors"] else default.get_bool("UnlockDoors"))
|
||||
|
||||
toggle.volt_sng = toggle.car_model == "CHEVROLET_VOLT" and (params.get_bool("VoltSNG") if tuning_level >= level["VoltSNG"] else default.get_bool("VoltSNG"))
|
||||
volt_models = {
|
||||
"CHEVROLET_VOLT",
|
||||
"CHEVROLET_VOLT_2019",
|
||||
"CHEVROLET_VOLT_ASCM",
|
||||
"CHEVROLET_VOLT_CAMERA",
|
||||
}
|
||||
|
||||
toggle.volt_sng = toggle.car_model in volt_models and (params.get_bool("VoltSNG") if tuning_level >= level["VoltSNG"] else default.get_bool("VoltSNG"))
|
||||
|
||||
toggle.gm_pedal_longitudinal = params.get_bool("GMPedalLongitudinal") if tuning_level >= level["GMPedalLongitudinal"] else default.get_bool("GMPedalLongitudinal")
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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()
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.numpy_fast import interp
|
||||
@@ -32,6 +34,10 @@ class ConditionalExperimentalMode:
|
||||
LIGHT_BOOST_LOW = 1.15
|
||||
LIGHT_BOOST_HIGH = 1.2
|
||||
|
||||
# Small latch to avoid frame-to-frame mode chatter.
|
||||
CEM_TRANSITION_GUARD_TIME = 0.50
|
||||
CEM_TRANSITION_BUFFER_TIME = 0.25
|
||||
|
||||
@staticmethod
|
||||
def get_speed_based_param(speed_mph, param_array):
|
||||
"""Get parameter value based on current speed using smooth interpolation between breakpoints [0, 35, 55, 70]"""
|
||||
@@ -49,8 +55,12 @@ class ConditionalExperimentalMode:
|
||||
self.experimental_mode = False
|
||||
self.stop_light_detected = False
|
||||
self.prev_experimental_mode = False # For hysteresis
|
||||
self.mode_hold_until = 0.0
|
||||
self.mode_false_since = 0.0
|
||||
|
||||
def update(self, v_ego, sm, frogpilot_toggles):
|
||||
now = time.monotonic()
|
||||
|
||||
if frogpilot_toggles.experimental_mode_via_press:
|
||||
self.status_value = params_memory.get_int("CEStatus")
|
||||
else:
|
||||
@@ -58,28 +68,23 @@ class ConditionalExperimentalMode:
|
||||
|
||||
if self.status_value not in {1, 2} and not sm["carState"].standstill:
|
||||
self.update_conditions(v_ego, sm, frogpilot_toggles)
|
||||
new_experimental_mode = self.check_conditions(v_ego, sm, frogpilot_toggles)
|
||||
|
||||
# Add hysteresis to prevent rapid toggling
|
||||
if new_experimental_mode and not self.prev_experimental_mode:
|
||||
# Require weaker conditions to turn on
|
||||
hysteresis_factor = 0.9
|
||||
elif not new_experimental_mode and self.prev_experimental_mode:
|
||||
# Require stronger conditions to turn off
|
||||
hysteresis_factor = 1.2
|
||||
else:
|
||||
hysteresis_factor = 1.0
|
||||
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
|
||||
|
||||
# Apply hysteresis to key conditions
|
||||
if hasattr(self, 'slow_lead_detected'):
|
||||
self.slow_lead_detected = self.slow_lead_detected if hysteresis_factor == 1.0 else (self.slow_lead_filter.x >= scale_threshold(v_ego) * hysteresis_factor)
|
||||
if hasattr(self, 'curve_detected'):
|
||||
self.curve_detected = self.curve_detected if hysteresis_factor == 1.0 else (self.curvature_filter.x >= THRESHOLD * hysteresis_factor)
|
||||
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 = self.check_conditions(v_ego, sm, frogpilot_toggles)
|
||||
self.experimental_mode = triggered or hold_active or transition_buffer_active
|
||||
self.prev_experimental_mode = self.experimental_mode
|
||||
params_memory.put_int("CEStatus", self.status_value if self.experimental_mode else 0)
|
||||
else:
|
||||
self.mode_hold_until = 0.0
|
||||
self.mode_false_since = 0.0
|
||||
self.experimental_mode = self.status_value == 2 or sm["carState"].standstill and self.experimental_mode and self.frogpilot_planner.model_stopped
|
||||
self.stop_light_detected &= self.status_value not in {1, 2}
|
||||
self.stop_light_filter.x = 0
|
||||
|
||||
@@ -53,14 +53,41 @@ A_CRUISE_MIN_SPORT = A_CRUISE_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 = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
|
||||
A_CRUISE_MAX_VALS_SPORT = [1.25, 1.25, 1.25, 1.25, 1.5, 1.5, 2.0]
|
||||
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]
|
||||
|
||||
def get_max_accel_eco(v_ego):
|
||||
return float(akima_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 float(akima_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 float(akima_interp(v_cruise, [0., CITY_SPEED_LIMIT / 2, CITY_SPEED_LIMIT], [max_accel / 4, max_accel / 2, max_accel]))
|
||||
@@ -68,7 +95,11 @@ def get_max_accel_low_speeds(max_accel, v_cruise):
|
||||
def get_max_accel_ramp_off(max_accel, v_cruise, v_ego):
|
||||
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):
|
||||
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:
|
||||
@@ -81,26 +112,28 @@ class FrogPilotAcceleration:
|
||||
def update(self, v_ego, sm, frogpilot_toggles):
|
||||
eco_gear = sm["frogpilotCarState"].ecoGear
|
||||
sport_gear = sm["frogpilotCarState"].sportGear
|
||||
ev_tuning = frogpilot_toggles.ev_tuning
|
||||
truck_tuning = frogpilot_toggles.truck_tuning
|
||||
|
||||
if sm["frogpilotCarState"].trafficModeEnabled:
|
||||
self.max_accel = get_max_accel(v_ego)
|
||||
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 == 2:
|
||||
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 == 1:
|
||||
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 == 2:
|
||||
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 == 3:
|
||||
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 = min(get_max_accel_low_speeds(self.max_accel, self.frogpilot_planner.v_cruise), self.max_accel)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.conversions import Conversions as CV
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import COMFORT_BRAKE, desired_follow_distance, get_jerk_factor, get_T_FOLLOW
|
||||
|
||||
from openpilot.frogpilot.common.frogpilot_variables import CITY_SPEED_LIMIT
|
||||
|
||||
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):
|
||||
@@ -48,12 +51,16 @@ class FrogPilotFollowing:
|
||||
frogpilot_toggles.custom_personalities, sm["controlsState"].personality
|
||||
)
|
||||
|
||||
self.t_follow = get_T_FOLLOW(
|
||||
t_follow_param = get_T_FOLLOW(
|
||||
frogpilot_toggles.aggressive_follow,
|
||||
frogpilot_toggles.standard_follow,
|
||||
frogpilot_toggles.relaxed_follow,
|
||||
frogpilot_toggles.custom_personalities, sm["controlsState"].personality
|
||||
)
|
||||
if isinstance(t_follow_param, list):
|
||||
self.t_follow = float(np.interp(v_ego, PERSONALITY_BP, t_follow_param))
|
||||
else:
|
||||
self.t_follow = float(t_follow_param)
|
||||
else:
|
||||
self.base_acceleration_jerk = 0
|
||||
self.base_danger_jerk = 0
|
||||
@@ -67,9 +74,24 @@ class FrogPilotFollowing:
|
||||
self.following_lead = self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.dRel < (self.t_follow * 2) * v_ego
|
||||
|
||||
|
||||
self.disable_throttle = self.frogpilot_planner.tracking_lead and not self.following_lead
|
||||
self.disable_throttle &= self.frogpilot_planner.lead_one.dRel + 6.0 < (self.t_follow * 2 * 2) * v_ego
|
||||
self.disable_throttle &= self.frogpilot_planner.lead_one.vLead < v_ego * 0.75
|
||||
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))
|
||||
ttc = lead_distance / max(closing_speed, 1e-3) if closing_speed > 0.1 else 1e6
|
||||
|
||||
# Keep a mild coasting behavior only for far/low-risk slower leads.
|
||||
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)
|
||||
|
||||
# Highway-only coast suppression: keep low-speed lead departures responsive.
|
||||
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)
|
||||
# Never coast when we are entering a potentially late-braking scenario.
|
||||
self.disable_throttle &= ttc > 6.0 and lead_distance > desired_gap + 6.0
|
||||
|
||||
if sm["controlsState"].enabled and self.frogpilot_planner.tracking_lead:
|
||||
self.update_follow_values(self.frogpilot_planner.lead_one.dRel, v_ego, self.frogpilot_planner.lead_one.vLead, frogpilot_toggles)
|
||||
|
||||
@@ -51,7 +51,7 @@ class FrogPilotTracking:
|
||||
self.sound = FrogPilotAudibleAlert.none
|
||||
self.state = State.disabled
|
||||
|
||||
self.model_name = clean_model_name(dict(zip(frogpilot_toggles.available_models.split(","), frogpilot_toggles.available_model_names.split(",")))[frogpilot_toggles.model])
|
||||
self.model_name = clean_model_name(frogpilot_toggles.model_name)
|
||||
|
||||
def update(self, now, time_validated, sm, frogpilot_toggles):
|
||||
v_cruise = min(sm["controlsState"].vCruiseCluster, V_CRUISE_MAX) * CV.KPH_TO_MS
|
||||
|
||||
@@ -128,7 +128,7 @@ def get_nn_model_path(car, eps_firmware) -> str | None:
|
||||
def find_valid_model(*queries):
|
||||
for query in queries:
|
||||
path, score = best_model_path(query)
|
||||
if path and candidate in path and score >= 0.9:
|
||||
if path and score >= 0.9:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
@@ -20,6 +20,30 @@ from openpilot.frogpilot.common.frogpilot_variables import get_frogpilot_toggles
|
||||
|
||||
BASE_URL = "https://nominatim.openstreetmap.org"
|
||||
MINIMUM_POPULATION = 100_000
|
||||
SEARCH_RADIUS_DEGREES = 1.45
|
||||
|
||||
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
|
||||
|
||||
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 [])
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_city_center(latitude, longitude):
|
||||
try:
|
||||
@@ -44,14 +68,7 @@ def get_city_center(latitude, longitude):
|
||||
|
||||
if data:
|
||||
tags = data[0]
|
||||
population = (tags.get("extratags") or {}).get("population")
|
||||
|
||||
population_value = None
|
||||
if population is not None:
|
||||
try:
|
||||
population_value = int(str(population).replace(",", "").split(";")[0].strip())
|
||||
except Exception:
|
||||
population_value = None
|
||||
population_value = get_population_value((tags.get("extratags") or {}).get("population"))
|
||||
|
||||
if population_value is not None and population_value >= MINIMUM_POPULATION:
|
||||
latitude_value = float(tags["lat"])
|
||||
@@ -62,6 +79,10 @@ def get_city_center(latitude, longitude):
|
||||
|
||||
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()
|
||||
@@ -99,14 +120,14 @@ def get_city_center(latitude, longitude):
|
||||
|
||||
def update_branch_commits(now):
|
||||
points = []
|
||||
for branch in ["FrogPilot", "FrogPilot-Staging", "FrogPilot-Testing"]:
|
||||
try:
|
||||
response = requests.get(f"https://api.github.com/repos/FrogAi/FrogPilot/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}")
|
||||
branch = get_build_metadata().channel # Current running branch
|
||||
try:
|
||||
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
|
||||
|
||||
@@ -129,11 +150,9 @@ def send_stats():
|
||||
if frogpilot_toggles.car_make == "mock":
|
||||
return
|
||||
|
||||
bucket = os.environ.get("STATS_BUCKET", "")
|
||||
org_ID = os.environ.get("STATS_ORG_ID", "")
|
||||
token = os.environ.get("STATS_TOKEN", "")
|
||||
url = os.environ.get("STATS_URL", "")
|
||||
|
||||
bucket = "StarPilot"
|
||||
org_ID = "StarPilot"
|
||||
url = "https://stats.firestar.link"
|
||||
frogpilot_stats = json.loads(params.get("FrogPilotStats") or "{}")
|
||||
|
||||
location = json.loads(params.get("LastGPSPosition") or "{}")
|
||||
@@ -161,14 +180,19 @@ def send_stats():
|
||||
|
||||
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", HARDWARE.get_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", params.get("FrogPilotDongleId", encoding="utf-8"))
|
||||
|
||||
.field("blocked_user", frogpilot_toggles.block_user)
|
||||
.field("car_make", "GM" if frogpilot_toggles.car_make == "gm" else frogpilot_toggles.car_make.title())
|
||||
.field("car_model", frogpilot_toggles.car_model)
|
||||
.field("city", city)
|
||||
.field("country", country)
|
||||
.field("current_months_kilometers", int(frogpilot_stats.get("CurrentMonthsKilometers", 0)))
|
||||
.field("device", HARDWARE.get_device_type())
|
||||
.field("driving_model", clean_model_name(frogpilot_toggles.model_name))
|
||||
.field("event", 1)
|
||||
.field("frogpilot_drives", int(frogpilot_stats.get("FrogPilotDrives", 0)))
|
||||
.field("frogpilot_hours", float(frogpilot_stats.get("FrogPilotSeconds", 0)) / (60 * 60))
|
||||
@@ -178,13 +202,12 @@ def send_stats():
|
||||
.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", frogpilot_toggles.has_sascm)
|
||||
.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("state", state)
|
||||
.field("theme", selected_theme.title())
|
||||
.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)))
|
||||
@@ -193,15 +216,12 @@ def send_stats():
|
||||
.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))
|
||||
|
||||
.tag("branch", build_metadata.channel)
|
||||
.tag("dongle_id", params.get("FrogPilotDongleId", encoding="utf-8"))
|
||||
|
||||
.time(now)
|
||||
)
|
||||
|
||||
all_points = [user_point] + update_branch_commits(now)
|
||||
|
||||
client = InfluxDBClient(org=org_ID, token=token, url=url)
|
||||
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 Exception as exception:
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/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
|
||||
|
||||
GALAXY_DIR = Path("/data/galaxy")
|
||||
FRPC_VERSION = "0.67.0"
|
||||
FRPC_LOG = GALAXY_DIR / "frpc.log"
|
||||
AUTH_PORT = 8083
|
||||
|
||||
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()
|
||||
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"
|
||||
elif 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 setup_frpc():
|
||||
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frpc_bin = GALAXY_DIR / "frpc"
|
||||
|
||||
if not frpc_bin.exists():
|
||||
print("Galaxy: Downloading frpc...")
|
||||
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 = params.get("DongleId", encoding='utf8')
|
||||
while not dongle_id:
|
||||
print("Galaxy: Waiting for DongleId...")
|
||||
time.sleep(5)
|
||||
dongle_id = params.get("DongleId", encoding='utf8')
|
||||
|
||||
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()
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.disk .progress {
|
||||
background: linear-gradient(to right, green 0%, yellow 80%, orange 90%, red 100%);
|
||||
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;
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
.diskUsage,
|
||||
.drivingStats,
|
||||
.firehoseStats,
|
||||
.softwareInfo {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--border-radius-md);
|
||||
@@ -31,12 +30,17 @@
|
||||
}
|
||||
|
||||
.diskUsage p,
|
||||
.firehoseStats 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;
|
||||
@@ -65,7 +69,6 @@
|
||||
|
||||
.diskUsage:hover,
|
||||
.drivingStats:hover,
|
||||
.firehoseStats:hover,
|
||||
.softwareInfo:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: var(--hover-scale-sm);
|
||||
|
||||
@@ -1,148 +1,152 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
|
||||
function DiskUsage(disk) {
|
||||
const used = parseFloat(disk.usedPercentage) || 0;
|
||||
const rightRadius = used >= 100 ? "0" : "var(--border-radius-md)";
|
||||
const HOME_STATE = {
|
||||
status: "loading", // loading | ready | error
|
||||
data: null,
|
||||
unit: "miles",
|
||||
error: "",
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="disk">
|
||||
<p>${disk.used} used of ${disk.size}</p>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bar"
|
||||
style="
|
||||
border-bottom-right-radius: ${rightRadius};
|
||||
border-top-right-radius: ${rightRadius};
|
||||
width: ${100 - used}%;
|
||||
"
|
||||
></div>
|
||||
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("FrogPilot", 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>
|
||||
`;
|
||||
}
|
||||
|
||||
function DriveStat(title, stats = {}, defaultUnit) {
|
||||
const format = (n) =>
|
||||
n?.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}) ?? "0";
|
||||
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"),
|
||||
]);
|
||||
|
||||
return html`
|
||||
<div class="drivingStat">
|
||||
<h2>${title}</h2>
|
||||
<div><p>${format(stats.drives)}</p><p>drives</p></div>
|
||||
<div><p>${format(stats.distance)}</p><p>${stats.unit ?? defaultUnit}</p></div>
|
||||
<div><p>${format(stats.hours)}</p><p>hours</p></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!statsResponse.ok) throw new Error(`stats API error: ${statsResponse.status}`);
|
||||
if (!unitResponse.ok) throw new Error(`params API error: ${unitResponse.status}`);
|
||||
|
||||
function renderSoftwareInfo(info = {}) {
|
||||
const fields = [
|
||||
["Branch Name", info.branchName],
|
||||
["Build", info.buildEnvironment],
|
||||
["Commit Hash", info.commitHash],
|
||||
["Fork Maintainer", info.forkMaintainer],
|
||||
["Update Available", info.updateAvailable],
|
||||
["Version Date", info.versionDate],
|
||||
];
|
||||
const statsJson = await withTimeout(statsResponse.json(), 5000, "stats JSON parse");
|
||||
const isMetricText = (await withTimeout(unitResponse.text(), 5000, "metric read")).trim();
|
||||
|
||||
return fields.map(
|
||||
([label, value]) =>
|
||||
html`<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderDiskUsageSection({ diskError, diskUsage }) {
|
||||
if (diskError) {
|
||||
return html`<p>${diskError.join("<br>")}</p>`;
|
||||
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);
|
||||
}
|
||||
if (diskUsage?.length) {
|
||||
return diskUsage.map(DiskUsage);
|
||||
}
|
||||
return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" });
|
||||
|
||||
renderDiskUsageSection(HOME_STATE);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const state = reactive({
|
||||
data: null,
|
||||
unit: "miles",
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const [statsResponse, unitResponse] = await Promise.all([
|
||||
fetch("/api/stats"),
|
||||
fetch("/api/params?key=IsMetric"),
|
||||
]);
|
||||
|
||||
if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
|
||||
if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
|
||||
|
||||
const statsJson = await statsResponse.json();
|
||||
const isMetricText = (await unitResponse.text()).trim();
|
||||
const isMetric = isMetricText === "1";
|
||||
|
||||
state.data = statsJson;
|
||||
state.unit = isMetric ? "kilometers" : "miles";
|
||||
localStorage.setItem("isMetric", isMetricText);
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize component:", err);
|
||||
state.error = err.message;
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
setTimeout(() => {
|
||||
renderDiskUsageSection(HOME_STATE);
|
||||
if (!HOME_STATE.initialized) {
|
||||
HOME_STATE.initialized = true;
|
||||
initializeHome();
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
|
||||
initialize();
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${() => {
|
||||
if (state.isLoading) {
|
||||
return html`<p>Loading...</p>`;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return html`<p class="error">Failed to load data: ${state.error}</p>`;
|
||||
}
|
||||
|
||||
if (state.data) {
|
||||
const { driveStats, firehoseStats, softwareInfo } = state.data;
|
||||
return html`
|
||||
<h1>The Pond</h1>
|
||||
|
||||
<div class="drivingStats">
|
||||
${DriveStat("All Time", driveStats?.all, state.unit)}
|
||||
${DriveStat("Past Week", driveStats?.week, state.unit)}
|
||||
${DriveStat("FrogPilot", driveStats?.frogpilot, state.unit)}
|
||||
</div>
|
||||
|
||||
<h2>Disk Usage</h2>
|
||||
<div class="diskUsage">
|
||||
${renderDiskUsageSection(state.data)}
|
||||
</div>
|
||||
|
||||
<h2>Firehose Segments</h2>
|
||||
<div class="firehoseStats">
|
||||
<p>
|
||||
<strong>${(firehoseStats?.segments ?? 0).toLocaleString("en-US")}</strong>
|
||||
segments in training data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Software Info</h2>
|
||||
<div class="softwareInfo">
|
||||
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<p>No data available.</p>`;
|
||||
}}
|
||||
</div>
|
||||
`;
|
||||
return html`<div id="home_shell"><p>Loading...</p></div>`;
|
||||
}
|
||||
|
||||
@@ -20,47 +20,49 @@
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
|
||||
/* Colors */
|
||||
--accent-bg: #673ab7;
|
||||
--accent-hover-bg: #512da8;
|
||||
--card-bg: #234423;
|
||||
/* Colors — Galaxy palette (cosmic purple · teal · rose · amber) */
|
||||
--accent-bg: #8b6cc5;
|
||||
--accent-hover-bg: #7558b0;
|
||||
--card-bg: #121224;
|
||||
--color-black: #000000;
|
||||
--color-confirm: #1a73e8;
|
||||
--color-confirm-hover: #0758ad;
|
||||
--color-gray-100: #f5f5f5;
|
||||
--color-gray-200: #e0e0e0;
|
||||
--color-gray-300: #c2c2c2;
|
||||
--color-gray-400: #a4a4a4;
|
||||
--color-gray-500: #8f8f8f;
|
||||
--color-gray-600: #737373;
|
||||
--color-gray-700: #595959;
|
||||
--color-gray-800: #333333;
|
||||
--color-gray-900: #1a1a1a;
|
||||
--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: #b71c1c;
|
||||
--danger-fg: #ef1313;
|
||||
--danger-hover-bg: #d32f2f;
|
||||
--input-bg: #2f5432;
|
||||
--main-bg: #0b1b0b;
|
||||
--main-fg: #178643;
|
||||
--secondary-bg: #264026;
|
||||
--selected-camera-bg: #1f2f1f;
|
||||
--sidebar-active-bg: #64c87826;
|
||||
--sidebar-bg: #264026;
|
||||
--sidebar-border-color: #1e2e1e;
|
||||
--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: #193446;
|
||||
--success-bg: #039226;
|
||||
--success-fg: #00a100;
|
||||
--success-hover-bg: #01be2d;
|
||||
--text-color: #ffffff;
|
||||
--text-muted: #a0a0a0;
|
||||
--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: #178643;
|
||||
--track-color: #1a3a1a;
|
||||
--warning-bg: #ff9800;
|
||||
--warning-hover-bg: #f57c00;
|
||||
--thumb-color: #8b6cc5;
|
||||
--track-color: #14142e;
|
||||
--warning-bg: #d4a060;
|
||||
--warning-hover-bg: #b8884a;
|
||||
|
||||
/* Effects */
|
||||
--disabled-opacity: 0.5;
|
||||
@@ -71,7 +73,7 @@
|
||||
--hover-scale-lg: scale(1.1);
|
||||
|
||||
/* Fonts */
|
||||
--font-body: "Open Sans", sans-serif;
|
||||
--font-body: "Inter", "Open Sans", sans-serif;
|
||||
--font-mono: "Courier New", Courier, monospace;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.85rem;
|
||||
@@ -96,9 +98,9 @@
|
||||
--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);
|
||||
--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;
|
||||
@@ -172,12 +174,14 @@ input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.manage-keys-link,
|
||||
.route_card,
|
||||
.sidebar .menu_section > li > ul > li,
|
||||
.sidebar .menu_section>li>ul>li,
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body { padding-bottom: 0 !important; }
|
||||
body {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* ――― Layout containers ――― */
|
||||
.content {
|
||||
@@ -199,9 +203,17 @@ h3 {
|
||||
}
|
||||
|
||||
/* ――― Helpers & states ――― */
|
||||
.hidden { display: none; }
|
||||
html { cursor: default; }
|
||||
.no_scroll { overflow: hidden; }
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no_scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.not_implemented {
|
||||
cursor: not-allowed;
|
||||
@@ -251,7 +263,9 @@ textarea {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
label[for] { cursor: default; }
|
||||
label[for] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ――― Snackbar component ――― */
|
||||
.snackbar {
|
||||
@@ -297,6 +311,7 @@ a {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: var(--snackbar-offset, 30px);
|
||||
opacity: 1;
|
||||
@@ -304,13 +319,20 @@ a {
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ――― Breakpoint overrides ――― */
|
||||
@media only screen and (max-width: var(--breakpoint-md)) {
|
||||
.content { margin-left: 0; }
|
||||
.content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
@@ -320,6 +342,7 @@ a {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
#snackbar_wrapper {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
@@ -333,6 +356,7 @@ a {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: var(--snackbar-offset, 30px);
|
||||
opacity: 1;
|
||||
@@ -340,18 +364,62 @@ a {
|
||||
}
|
||||
|
||||
@-webkit-keyframes fadeout {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "https://esm.sh/@arrow-js/core";
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
.favorites-toggle-button:hover {
|
||||
background-color: var(--main-fg);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
color: var(--text-color);
|
||||
font-weight: var(--font-weight-bold);
|
||||
transform: var(--hover-scale-sm);
|
||||
@@ -352,7 +352,7 @@
|
||||
|
||||
.navigation-summary-widget button.directions:hover {
|
||||
background-color: var(--success-hover-bg);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
color: var(--text-color);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
@@ -466,12 +466,12 @@
|
||||
}
|
||||
|
||||
.search-provider-toggle button.active {
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
.search-provider-toggle button:hover {
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
@@ -533,4 +533,4 @@
|
||||
min-width: 0;
|
||||
width: calc(100% - var(--padding-xl));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import {
|
||||
addRouteToMap,
|
||||
formatMetersToHuman,
|
||||
@@ -101,6 +101,25 @@ async function setSpecial(favorite, type, state, loadFavoritesAlphabetically) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -262,13 +281,18 @@ export function NavDestination() {
|
||||
};
|
||||
try {
|
||||
state.destination = JSON.parse(data.destination);
|
||||
} catch {}
|
||||
} catch { }
|
||||
try {
|
||||
const prev = JSON.parse(data.previousDestinations);
|
||||
state.previousDestinations = prev.map(d => ({ name: d.place_name }));
|
||||
state.suggestions = JSON.stringify(state.previousDestinations);
|
||||
} catch {}
|
||||
setupMap();
|
||||
} catch { }
|
||||
try {
|
||||
await setupMap();
|
||||
} catch {
|
||||
showSnackbar("Failed to load map resources…");
|
||||
return;
|
||||
}
|
||||
loadFavoritesAlphabetically();
|
||||
}
|
||||
|
||||
@@ -524,12 +548,18 @@ export function NavDestination() {
|
||||
}
|
||||
}
|
||||
|
||||
const setupMap = async () => {
|
||||
const setupMap = async (retries = 0) => {
|
||||
if (!state.mapboxPublic || state.initialized) return;
|
||||
|
||||
if (typeof mapboxgl === "undefined") {
|
||||
await loadMapboxGL();
|
||||
}
|
||||
|
||||
const container = document.getElementById("map");
|
||||
if (!container) {
|
||||
requestAnimationFrame(setupMap);
|
||||
return;
|
||||
if (retries >= 50) return;
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
return setupMap(retries + 1);
|
||||
}
|
||||
state.initialized = true;
|
||||
mapboxgl.accessToken = state.mapboxPublic;
|
||||
@@ -585,9 +615,9 @@ export function NavDestination() {
|
||||
return html`
|
||||
<div class="navigation-container">
|
||||
${() => {
|
||||
if (state.missingKeys === null) return "";
|
||||
return state.missingKeys
|
||||
? html`
|
||||
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>
|
||||
@@ -596,7 +626,7 @@ export function NavDestination() {
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
: html`
|
||||
: html`
|
||||
<div class="map-wrapper">
|
||||
<div class="search-wrapper">
|
||||
<div class="search-controls">
|
||||
@@ -611,47 +641,47 @@ export function NavDestination() {
|
||||
</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
|
||||
});
|
||||
}
|
||||
}}
|
||||
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",
|
||||
@@ -815,9 +845,9 @@ function NavigationDestination({
|
||||
</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>`}
|
||||
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>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.navkeys-group > .navkeys-row:last-of-type {
|
||||
.navkeys-group>.navkeys-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
.navkeys-input:hover,
|
||||
.navkeys-input:focus {
|
||||
border-color: var(--thumb-color);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
@@ -153,4 +153,4 @@
|
||||
.navkeys-input {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
export function NavKeys() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
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";
|
||||
|
||||
@@ -12,8 +13,18 @@ const state = reactive({
|
||||
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())) {
|
||||
@@ -31,10 +42,73 @@ function formatRouteDate(dateString) {
|
||||
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)}`);
|
||||
const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -45,24 +119,24 @@ async function fetchRoutes() {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
if (requestToken !== routesRequestToken) return
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
const lines = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
if (line.startsWith("data:")) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
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) {
|
||||
const routes = data.routes.map(route => ({
|
||||
...route,
|
||||
timestamp: formatRouteDate(route.timestamp),
|
||||
}));
|
||||
state.routes.push(...routes);
|
||||
enqueueRoutes(data.routes)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON:", e);
|
||||
@@ -70,21 +144,35 @@ async function fetchRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
state.error = "Couldn't load routes. Please try again later..."
|
||||
flushPendingRoutes()
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
state.error = "Couldn't load routes. Please try again later..."
|
||||
}
|
||||
} finally {
|
||||
state.loading = false
|
||||
if (requestToken === routesRequestToken) {
|
||||
flushPendingRoutes()
|
||||
state.loading = false
|
||||
if (routesAbortController === controller) {
|
||||
routesAbortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchRoutes()
|
||||
|
||||
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) {
|
||||
@@ -124,27 +212,27 @@ async function deleteRoute(route) {
|
||||
}
|
||||
|
||||
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");
|
||||
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) {
|
||||
@@ -317,6 +405,16 @@ async function deleteAllRoutes() {
|
||||
}
|
||||
|
||||
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`
|
||||
@@ -326,81 +424,41 @@ export function RouteRecordings() {
|
||||
<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")}
|
||||
disabled="${() => (state.loading && state.routes.length === 0) || false}"
|
||||
> ${() => (state.showPreservedOnly ? "Show All" : "Show Only Preserved Routes")}
|
||||
</button>
|
||||
|
||||
${() => {
|
||||
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
|
||||
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>`;
|
||||
}
|
||||
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`
|
||||
return html`
|
||||
<div class="screen-recordings-grid">
|
||||
${routesToShow.map(
|
||||
route => html`
|
||||
route => html`
|
||||
<div
|
||||
class="recording-card"
|
||||
@mouseenter="${e => {
|
||||
if (state.selectedRoute) return;
|
||||
|
||||
const card = e.currentTarget;
|
||||
const gif = card.querySelector(".recording-preview-gif");
|
||||
const png = card.querySelector(".recording-preview-png");
|
||||
|
||||
if (card.dataset.gifLoaded) {
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
card.dataset.loadingGif = "true";
|
||||
const preloader = new Image();
|
||||
preloader.onload = () => {
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
gif.src = preloader.src;
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
card.dataset.gifLoaded = true;
|
||||
}
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
preloader.onerror = () => {
|
||||
console.error("Failed to load preview GIF:", preloader.src);
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
|
||||
preloader.src = gif.dataset.src;
|
||||
}}"
|
||||
@mouseleave="${e => {
|
||||
const card = e.currentTarget;
|
||||
card.querySelector(".recording-preview-png").style.display = "block";
|
||||
card.querySelector(".recording-preview-gif").style.display = "none";
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
delete card.dataset.loadingGif;
|
||||
}
|
||||
}}"
|
||||
@click="${() => {
|
||||
state.selectedRoute = route;
|
||||
}}"
|
||||
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>`}
|
||||
@@ -410,42 +468,40 @@ export function RouteRecordings() {
|
||||
src="${route.png}"
|
||||
class="recording-preview recording-preview-png"
|
||||
style="display:block;"
|
||||
>
|
||||
<img
|
||||
data-src="${route.gif}"
|
||||
class="recording-preview recording-preview-gif"
|
||||
style="display:none;"
|
||||
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`
|
||||
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")}
|
||||
disabled="${() => state.isDeletingAll || false}"
|
||||
> ${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}}
|
||||
}
|
||||
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"
|
||||
}) : ""}
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
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({
|
||||
@@ -126,7 +127,6 @@ async function renameFile(rec) {
|
||||
if (recordingToUpdate) {
|
||||
recordingToUpdate.filename = newFilename
|
||||
recordingToUpdate.is_custom_name = true
|
||||
recordingToUpdate.gif = `/screen_recordings/${val}.gif`
|
||||
recordingToUpdate.png = `/screen_recordings/${val}.png`
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ async function renameFile(rec) {
|
||||
}
|
||||
|
||||
function confirmDeleteFile(rec) {
|
||||
state.recordingToDelete = rec;
|
||||
state.showDeleteModal = true;
|
||||
state.recordingToDelete = rec;
|
||||
state.showDeleteModal = true;
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
@@ -152,11 +152,11 @@ async function deleteFile() {
|
||||
|
||||
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
|
||||
if (res.ok) {
|
||||
closeOverlay();
|
||||
refresh();
|
||||
showSnackbar("Recording deleted!");
|
||||
closeOverlay();
|
||||
refresh();
|
||||
showSnackbar("Recording deleted!");
|
||||
} else {
|
||||
showSnackbar("Delete failed...", "error");
|
||||
showSnackbar("Delete failed...", "error");
|
||||
}
|
||||
|
||||
state.showDeleteModal = false;
|
||||
@@ -221,6 +221,16 @@ async function deleteAllRecordings() {
|
||||
}
|
||||
|
||||
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`
|
||||
@@ -229,77 +239,37 @@ export function ScreenRecordings() {
|
||||
<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 ""
|
||||
}}
|
||||
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`
|
||||
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
|
||||
return html`
|
||||
<div
|
||||
class="recording-card"
|
||||
@mouseenter="${e => {
|
||||
if (state.selectedRecording) return;
|
||||
|
||||
const card = e.currentTarget;
|
||||
const gif = card.querySelector(".recording-preview-gif");
|
||||
const png = card.querySelector(".recording-preview-png");
|
||||
|
||||
if (card.dataset.gifLoaded) {
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
card.dataset.loadingGif = "true";
|
||||
const preloader = new Image();
|
||||
preloader.onload = () => {
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
gif.src = preloader.src;
|
||||
png.style.display = "none";
|
||||
gif.style.display = "block";
|
||||
card.dataset.gifLoaded = true;
|
||||
}
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
preloader.onerror = () => {
|
||||
console.error("Failed to load preview GIF:", preloader.src);
|
||||
delete card.dataset.loadingGif;
|
||||
};
|
||||
|
||||
preloader.src = gif.dataset.src;
|
||||
}}"
|
||||
@mouseleave="${e => {
|
||||
const card = e.currentTarget;
|
||||
card.querySelector(".recording-preview-png").style.display = "block";
|
||||
card.querySelector(".recording-preview-gif").style.display = "none";
|
||||
if (card.dataset.loadingGif === "true") {
|
||||
delete card.dataset.loadingGif;
|
||||
}
|
||||
}}"
|
||||
@click="${() => { state.selectedRecording = rec }}"
|
||||
>
|
||||
<div class="recording-preview-container">
|
||||
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;">
|
||||
<img data-src="${rec.gif}" class="recording-preview recording-preview-gif" style="display:none;">
|
||||
<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`
|
||||
if (state.recordings.length > 0) {
|
||||
return html`
|
||||
<button
|
||||
class="delete-all-button"
|
||||
@click="${() => (state.showDeleteAllModal = true)}"
|
||||
@@ -307,24 +277,24 @@ export function ScreenRecordings() {
|
||||
Delete All Recordings
|
||||
</button>
|
||||
`
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
}
|
||||
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"
|
||||
}) : ""}
|
||||
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"
|
||||
}) : ""}
|
||||
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>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
|
||||
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 { DoorControl } from "/assets/components/tools/doors.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"
|
||||
@@ -11,27 +14,54 @@ 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 { TailscaleControl } from "/assets/components/tailscale/tailscale.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 { TSKManager } from "/assets/components/tools/tsk_manager.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: () => {},
|
||||
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("doors", "/lock_or_unlock_doors", DoorControl),
|
||||
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),
|
||||
@@ -39,11 +69,16 @@ function Root() {
|
||||
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
|
||||
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
|
||||
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
|
||||
createRoute("tailscale", "/manage_tailscale", TailscaleControl),
|
||||
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("tsk_manager", "/tsk_manager", TSKManager),
|
||||
createRoute("updates", "/manage_updates", UpdateManager),
|
||||
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
|
||||
]
|
||||
|
||||
router = createRouter({
|
||||
@@ -51,6 +86,11 @@ function Root() {
|
||||
history: createBrowserHistory(),
|
||||
}).initialize()
|
||||
|
||||
window.__thePondNavigate = (href) => {
|
||||
router.navigate(toRouterHref(href))
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
routerState = reactive({
|
||||
activePath: "/",
|
||||
activePathFull: "/",
|
||||
@@ -58,17 +98,21 @@ function Root() {
|
||||
navigation: { state: "loading" },
|
||||
errors: [],
|
||||
params: {},
|
||||
activeMatch: null,
|
||||
})
|
||||
|
||||
router.subscribe(({ initialized, navigation, matches, errors }) => {
|
||||
const [match] = matches
|
||||
const match = Array.isArray(matches) && matches.length > 0
|
||||
? matches[matches.length - 1]
|
||||
: null
|
||||
Object.assign(routerState, {
|
||||
initialized,
|
||||
activePath: match.route.path,
|
||||
activePathFull: match.pathname,
|
||||
activePath: match?.route?.path || "",
|
||||
activePathFull: match?.pathname || "",
|
||||
navigation,
|
||||
params: match.params,
|
||||
params: match?.params || {},
|
||||
errors,
|
||||
activeMatch: match,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,17 +120,21 @@ function Root() {
|
||||
${() => Sidebar(routerState.activePathFull)}
|
||||
<div class="content">
|
||||
${() => {
|
||||
if (!routerState.initialized || routerState.navigation.state === "loading") {
|
||||
return html`<div>Loading...</div>`
|
||||
}
|
||||
if (!routerState.initialized) {
|
||||
return Home({ params: routerState.params })
|
||||
}
|
||||
|
||||
if (routerState.errors?.root?.status === 404) {
|
||||
return html`<h1>Not Found</h1>`
|
||||
}
|
||||
if (routerState.errors?.root?.status === 404) {
|
||||
return html`<h1>Not Found</h1>`
|
||||
}
|
||||
|
||||
const match = routes.find(r => r.path === routerState.activePath)
|
||||
return match.element({ params: routerState.params })
|
||||
}}
|
||||
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>
|
||||
`
|
||||
}
|
||||
@@ -97,7 +145,7 @@ export function Link(href, children, onClick, classes = "") {
|
||||
href="${() => href}"
|
||||
@click="${(e) => {
|
||||
e.preventDefault()
|
||||
router.navigate(e.currentTarget.href)
|
||||
router.navigate(toRouterHref(e.currentTarget.href))
|
||||
hideSidebar()
|
||||
onClick?.()
|
||||
}}"
|
||||
@@ -105,8 +153,28 @@ export function Link(href, children, onClick, classes = "") {
|
||||
}
|
||||
|
||||
export function Navigate(href) {
|
||||
router.navigate(href)
|
||||
router.navigate(toRouterHref(href))
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
Root()(document.getElementById("app"))
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown > select {
|
||||
.dropdown>select {
|
||||
appearance: none;
|
||||
background-color: var(--sidebar-bg);
|
||||
border: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||
@@ -74,19 +74,19 @@ input.searchfield:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
input:checked+.slider {
|
||||
background-color: var(--success-bg);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
input:checked+.slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
input:checked + .slider.loading:before {
|
||||
input:checked+.slider.loading:before {
|
||||
animation: rotationChecked 1s linear infinite;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
input:focus+.slider {
|
||||
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
|
||||
}
|
||||
|
||||
@@ -133,11 +133,11 @@ input:focus + .slider {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.options > input {
|
||||
.options>input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.options > input:checked + label {
|
||||
.options>input:checked+label {
|
||||
background-color: var(--success-bg);
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
@@ -220,14 +220,13 @@ input:focus + .slider {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.setting.subsetting_link {
|
||||
}
|
||||
.setting.subsetting_link {}
|
||||
|
||||
.setting.subtoggle {
|
||||
margin: 0 0 0 var(--padding-xl);
|
||||
}
|
||||
|
||||
.setting.subtoggle + .setting {
|
||||
.setting.subtoggle+.setting {
|
||||
margin-top: var(--border-radius-xl);
|
||||
}
|
||||
|
||||
@@ -300,7 +299,7 @@ input.searchfield {
|
||||
animation: rotation 1s linear infinite;
|
||||
background-color: none !important;
|
||||
border: var(--border-width-thick) solid var(--sidebar-fg);
|
||||
border-bottom-color: #46439b;
|
||||
border-bottom-color: var(--main-fg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -337,6 +336,7 @@ i.switch {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -346,7 +346,8 @@ i.switch {
|
||||
0% {
|
||||
transform: translateX(26px) rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(26px) rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "https://esm.sh/@arrow-js/core"
|
||||
import { html } from "/assets/vendor/arrow-core.js"
|
||||
import { upperFirst } from "/assets/js/utils.js"
|
||||
import { Navigate } from "/assets/components/router.js"
|
||||
|
||||
|
||||
@@ -60,7 +60,10 @@
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: var(--sidebar-bg);
|
||||
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;
|
||||
@@ -102,7 +105,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > a span {
|
||||
.sidebar .menu_section>li>a span {
|
||||
color: var(--text-color);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-lg);
|
||||
@@ -111,13 +114,13 @@
|
||||
padding-left: var(--padding-sm);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul {
|
||||
.sidebar .menu_section>li>ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li {
|
||||
.sidebar .menu_section>li>ul>li {
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: var(--padding-sm);
|
||||
overflow: hidden;
|
||||
@@ -125,23 +128,23 @@
|
||||
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 {
|
||||
.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 {
|
||||
.sidebar .menu_section>li>ul>li.active,
|
||||
.sidebar .menu_section>li>ul>li:hover {
|
||||
background-color: var(--sidebar-active-bg);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > ul > li.active > a {
|
||||
.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 {
|
||||
.sidebar .menu_section>li>ul>li:hover>a {
|
||||
color: var(--color-white);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
}
|
||||
@@ -203,9 +206,13 @@
|
||||
}
|
||||
|
||||
.sidebar_header p {
|
||||
color: var(--success-hover-bg);
|
||||
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 {
|
||||
@@ -234,7 +241,7 @@
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > a {
|
||||
.sidebar .menu_section>li>a {
|
||||
font-size: var(--padding-sm);
|
||||
}
|
||||
|
||||
@@ -262,7 +269,7 @@
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar .menu_section > li > a {
|
||||
.sidebar .menu_section>li>a {
|
||||
font-size: var(--padding-sm);
|
||||
}
|
||||
|
||||
@@ -277,4 +284,4 @@
|
||||
#menu_button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { Link } from "/assets/components/router.js";
|
||||
import { upperFirst, hideSidebar } from "/assets/js/utils.js";
|
||||
import { html } from "/assets/vendor/arrow-core.js";
|
||||
import { hideSidebar, upperFirst } from "/assets/js/utils.js";
|
||||
|
||||
const MenuItems = {
|
||||
const MENU_ITEMS = {
|
||||
home: [
|
||||
{ name: "Home", link: "/", icon: "bi-house-fill" },
|
||||
],
|
||||
@@ -14,138 +13,126 @@ const MenuItems = {
|
||||
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
|
||||
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
|
||||
],
|
||||
tailscale: [
|
||||
{ name: "Tailscale", link: "/manage_tailscale", icon: "bi-wifi" },
|
||||
],
|
||||
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: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
|
||||
{ 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: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
|
||||
{ name: "Toyota Security Keys", link: "/tsk_manager", icon: "bi-key-fill" },
|
||||
{ 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" },
|
||||
],
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
doorsVisible: false,
|
||||
isDoorsFetched: false,
|
||||
isTSKFetched: false,
|
||||
tskVisible: false,
|
||||
function matchesPath(currentPath, link) {
|
||||
if (link === "/") return currentPath === "/";
|
||||
return currentPath === link || currentPath.startsWith(`${link}/`);
|
||||
}
|
||||
|
||||
activeRoute: ""
|
||||
});
|
||||
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("");
|
||||
|
||||
export function Sidebar() {
|
||||
const currentPath = window.location.pathname;
|
||||
const activeItem = Object.values(MenuItems).flat().find(item => item.link === currentPath);
|
||||
state.activeRoute = activeItem?.name ?? "";
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!state.isDoorsFetched) {
|
||||
state.isDoorsFetched = true;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/doors_available");
|
||||
const data = await response.json();
|
||||
state.doorsVisible = data.result;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch door availability:", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
function bindSidebarHandlers() {
|
||||
const menuButton = document.getElementById("menu_button");
|
||||
const underlay = document.getElementById("sidebarUnderlay");
|
||||
|
||||
if (!state.isTSKFetched) {
|
||||
state.isTSKFetched = true;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/tsk_available");
|
||||
const data = await response.json();
|
||||
state.tskVisible = data.result;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch TSK availability:", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
if (!menuButton || !underlay) return;
|
||||
|
||||
function navigate(link) {
|
||||
state.activeRoute = link.name;
|
||||
window.scrollTo(0, 0);
|
||||
hideSidebar();
|
||||
|
||||
document.querySelectorAll('.sidebar li').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
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");
|
||||
});
|
||||
|
||||
const linkElement = document.querySelector(`.sidebar li a[href="${link.link}"]`);
|
||||
if (linkElement) {
|
||||
linkElement.parentElement.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
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="FrogPilot logo" />
|
||||
<img class="logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
|
||||
<div class="title_text sidebar_header">
|
||||
<p>The Pond</p>
|
||||
<a href="https://github.com/Aidenir">by Aidenir</a>
|
||||
<p>Galaxy</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
${() => Object.entries(MenuItems).map(([section, links]) => html`
|
||||
<div class="sidebar_widget">
|
||||
<ul class="menu_section">
|
||||
<li>
|
||||
<span class="section-title">${upperFirst(section)}</span>
|
||||
<ul id="${section}">
|
||||
${links.map(link => {
|
||||
if (link.name === "Lock/Unlock Doors" && !state.doorsVisible) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isActive = state.activeRoute === link.name;
|
||||
const classList = [isActive && "active"].filter(Boolean).join(" ");
|
||||
|
||||
const content = html`
|
||||
<div class="menu-item-link">
|
||||
<i class="bi ${link.icon}"></i>
|
||||
<span>${upperFirst(link.name)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<li class="${classList}">
|
||||
${Link(link.link, content, () => navigate(link))}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`)}
|
||||
${sectionsMarkup}
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
`;
|
||||
|
||||
bindSidebarHandlers();
|
||||
}
|
||||
|
||||
function setupMenuButton() {
|
||||
const button = document.getElementById("menu_button");
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const underlay = document.getElementById("sidebarUnderlay");
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("visible");
|
||||
underlay.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
underlay.addEventListener("click", hideSidebar);
|
||||
export function Sidebar(currentPath) {
|
||||
setTimeout(() => renderSidebarIntoShell(currentPath), 0);
|
||||
return html`<div id="sidebar_shell"></div>`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupMenuButton, false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
export function TailscaleControl() {
|
||||
@@ -52,13 +52,14 @@ export function TailscaleControl() {
|
||||
checkInstallStatus()
|
||||
|
||||
return html`
|
||||
<div class="tailscale-wrapper">
|
||||
<div class="toggle-control-widget" style="margin-top: 1.5rem">
|
||||
<section class="tailscale-widget">
|
||||
<div class="tailscale-title">
|
||||
<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!
|
||||
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
|
||||
@@ -67,11 +68,11 @@ export function TailscaleControl() {
|
||||
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'
|
||||
}}
|
||||
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
|
||||
@@ -79,12 +80,12 @@ export function TailscaleControl() {
|
||||
</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"
|
||||
}) : ""}
|
||||
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,824 @@
|
||||
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 || false}"
|
||||
@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 || false}"
|
||||
@click="${() => resetNumericParam(p)}">Reset to Default</button>
|
||||
</div>
|
||||
<button
|
||||
class="ds-stepper-btn"
|
||||
disabled="${() => !canIncrease || false}"
|
||||
@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>
|
||||
`
|
||||
}
|
||||
|
||||
// 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">
|
||||
${visibleParams.map(p => renderSettingRow(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${visibleParams.length === 0 ? html`<div class="ds-empty">No settings match your search.</div>` : ""}
|
||||
`
|
||||
}}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "https://esm.sh/@arrow-js/core"
|
||||
import { html } from "/assets/vendor/arrow-core.js"
|
||||
|
||||
export function DoorControl () {
|
||||
async function lockDoors () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import { formatSecondsToHuman, parseErrorLogToDate } from "/assets/js/utils.js";
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
|
||||
@@ -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,182 @@
|
||||
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 || false}"
|
||||
@click="${() => runAction("start")}">
|
||||
Start / Arm
|
||||
</button>
|
||||
<button
|
||||
class="longManeuverButton danger"
|
||||
disabled="${() => state.busy || false}"
|
||||
@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,585 @@
|
||||
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)}" selected="${() => safeText(model.value) === state.currentModel || false}">
|
||||
${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" selected="${() => state.sortMode === "alphabetical" || false}">Alphabetical</option>
|
||||
<option value="release_date" selected="${() => state.sortMode === "release_date" || false}">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" selected="${() => state.communityFavoriteFilter === "all" || false}">All</option>
|
||||
<option value="yes" selected="${() => state.communityFavoriteFilter === "yes" || false}">Yes</option>
|
||||
<option value="no" selected="${() => state.communityFavoriteFilter === "no"}">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>
|
||||
`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "https://esm.sh/@arrow-js/core"
|
||||
import { html } from "/assets/vendor/arrow-core.js"
|
||||
|
||||
export function SpeedLimits() {
|
||||
function handleDownload() {
|
||||
|
||||
@@ -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 || false}"
|
||||
@change="${(event) => {
|
||||
selectSlot(String(event.target.value || ""))
|
||||
}}">
|
||||
${() => getSelectableSlots().length
|
||||
? getSelectableSlots().map((slot) => html`<option value="${slotId(slot)}" selected="${() => slotId(slot) === state.selectedSlot || false}">${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 || false}"
|
||||
@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>
|
||||
`
|
||||
}
|
||||
@@ -77,11 +77,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox {
|
||||
.checklist-item input[type="checkbox"]:checked~.custom-checkbox {
|
||||
background-color: var(--success-bg);
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox::before {
|
||||
.checklist-item input[type="checkbox"]:checked~.custom-checkbox::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast), transform 0.2s;
|
||||
}
|
||||
|
||||
.file-clear-button:hover {
|
||||
color: var(--danger-hover-bg);
|
||||
transform: scale(1.1);
|
||||
@@ -613,7 +614,7 @@ label.file-upload-button {
|
||||
.turn-signal-input:focus,
|
||||
.turn-signal-input:hover {
|
||||
border-color: var(--thumb-color);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
outline: none;
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
@@ -690,8 +691,15 @@ label.file-upload-button {
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
@@ -713,8 +721,8 @@ label.file-upload-button {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-button-wrapper > button {
|
||||
.save-button-wrapper>button {
|
||||
flex-basis: calc(50% - 1rem);
|
||||
margin-bottom: var(--gap-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js";
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
const defaultColors = {
|
||||
@@ -97,7 +97,8 @@ const state = reactive({
|
||||
showDeleteConfirmModal: false,
|
||||
themeToDelete: null,
|
||||
themes: [],
|
||||
activeTab: "colors"
|
||||
activeTab: "colors",
|
||||
fetched: false,
|
||||
});
|
||||
|
||||
let draggedIndex = -1;
|
||||
@@ -405,17 +406,19 @@ const fetchDownloadables = async () => {
|
||||
};
|
||||
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/params?key=DiscordUsername");
|
||||
state.discordUsername = await response.text();
|
||||
await loadDefaultTheme();
|
||||
await fetchDownloadables();
|
||||
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
export function ThemeMaker() {
|
||||
if (!state.fetched) {
|
||||
state.fetched = true;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/params?key=DiscordUsername");
|
||||
state.discordUsername = await response.text();
|
||||
await loadDefaultTheme();
|
||||
await fetchDownloadables();
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
|
||||
const normalize = (str) => (str || "")
|
||||
.toString()
|
||||
.trim()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { formatSecondsToHuman } from "/assets/js/utils.js"
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { formatSecondsToHuman, isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js";
|
||||
|
||||
const logSelectorState = reactive({
|
||||
@@ -12,6 +12,16 @@ const logSelectorState = reactive({
|
||||
newName: ""
|
||||
});
|
||||
|
||||
let liveEventSource = null;
|
||||
let livePollTimer = null;
|
||||
let tmuxLifecycleVersion = 0;
|
||||
const REMOTE_POLL_INTERVAL_MS = 2000;
|
||||
const TRANSPORT_LIFECYCLE_INTERVAL_MS = 500;
|
||||
|
||||
function isTmuxRouteActive() {
|
||||
return window.location.pathname === "/manage_tmux";
|
||||
}
|
||||
|
||||
async function loadTmuxLogs() {
|
||||
if (logSelectorState.loading || logSelectorState.logsLoadedOnce) return;
|
||||
|
||||
@@ -122,34 +132,34 @@ function TmuxLogSelector({ action, closeFn }) {
|
||||
</div>
|
||||
|
||||
${() => {
|
||||
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
|
||||
return html`<div class="fileEntry"><p>Loading...</p></div>`;
|
||||
}
|
||||
if (logSelectorState.files.length === 0) {
|
||||
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
|
||||
}
|
||||
return logSelectorState.files.map(file => html`
|
||||
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
|
||||
return html`<div class="fileEntry"><p>Loading...</p></div>`;
|
||||
}
|
||||
if (logSelectorState.files.length === 0) {
|
||||
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
|
||||
}
|
||||
return logSelectorState.files.map(file => html`
|
||||
<div class="fileEntry" @click="${() => handleFileClick(file)}">
|
||||
<p><span class="label">Filename:</span> <span class="value">${file.filename}</span></p>
|
||||
<p><span class="label">Date:</span> <span class="value">${file.date}</span></p>
|
||||
<p><span class="label">Age:</span> <span class="value">${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}</span></p>
|
||||
</div>
|
||||
`);
|
||||
}}
|
||||
}}
|
||||
|
||||
<button @click="${closeFn}" class="cancel-button">Close</button>
|
||||
|
||||
${() => logSelectorState.logToDelete ? Modal({
|
||||
title: "Confirm Delete",
|
||||
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
|
||||
onConfirm: confirmDeleteFile,
|
||||
onCancel: () => { logSelectorState.logToDelete = null },
|
||||
confirmText: "Yes, Delete"
|
||||
}) : ""}
|
||||
title: "Confirm Delete",
|
||||
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
|
||||
onConfirm: confirmDeleteFile,
|
||||
onCancel: () => { logSelectorState.logToDelete = null },
|
||||
confirmText: "Yes, Delete"
|
||||
}) : ""}
|
||||
|
||||
${() => logSelectorState.logToRename ? Modal({
|
||||
title: "Rename Log",
|
||||
message: html`
|
||||
title: "Rename Log",
|
||||
message: html`
|
||||
<div>
|
||||
<p>Rename <strong>${logSelectorState.logToRename.filename}</strong> to:</p>
|
||||
<div style="margin-top: 10px;">
|
||||
@@ -163,14 +173,14 @@ function TmuxLogSelector({ action, closeFn }) {
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
onConfirm: confirmRenameFile,
|
||||
onCancel: () => {
|
||||
logSelectorState.logToRename = null;
|
||||
logSelectorState.newName = "";
|
||||
},
|
||||
confirmText: "Rename",
|
||||
confirmClass: "btn-primary"
|
||||
}) : ""}
|
||||
onConfirm: confirmRenameFile,
|
||||
onCancel: () => {
|
||||
logSelectorState.logToRename = null;
|
||||
logSelectorState.newName = "";
|
||||
},
|
||||
confirmText: "Rename",
|
||||
confirmClass: "btn-primary"
|
||||
}) : ""}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -182,23 +192,143 @@ export function TmuxLog() {
|
||||
latest: '',
|
||||
log: '',
|
||||
selectorAction: null,
|
||||
transport: "connecting",
|
||||
});
|
||||
const lifecycleVersion = ++tmuxLifecycleVersion;
|
||||
let lifecycleTimer = null;
|
||||
|
||||
const event_source = new EventSource("/api/tmux_log/live");
|
||||
|
||||
event_source.onmessage = e => {
|
||||
state.latest = e.data;
|
||||
function applyIncomingLog(data) {
|
||||
state.latest = data || "";
|
||||
if (!state.paused) {
|
||||
state.log = state.latest;
|
||||
}
|
||||
}
|
||||
|
||||
event_source.onerror = err => {
|
||||
console.error("Error receiving tmux log:", err);
|
||||
event_source.close();
|
||||
function stopLiveTransport() {
|
||||
if (liveEventSource) {
|
||||
liveEventSource.close();
|
||||
liveEventSource = null;
|
||||
}
|
||||
if (livePollTimer) {
|
||||
clearInterval(livePollTimer);
|
||||
livePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause () {
|
||||
async function fetchSnapshot() {
|
||||
try {
|
||||
const response = await fetch("/api/tmux_log/snapshot");
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const payload = await response.json();
|
||||
applyIncomingLog(payload?.data || "");
|
||||
} catch (err) {
|
||||
console.error("Error fetching tmux log snapshot:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (livePollTimer) return;
|
||||
if (liveEventSource) {
|
||||
liveEventSource.close();
|
||||
liveEventSource = null;
|
||||
}
|
||||
state.transport = "polling";
|
||||
fetchSnapshot();
|
||||
livePollTimer = setInterval(() => {
|
||||
if (!isTmuxRouteActive()) {
|
||||
stopLiveTransport();
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
fetchSnapshot();
|
||||
}, REMOTE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function startEventStream() {
|
||||
if (liveEventSource) return;
|
||||
if (livePollTimer) {
|
||||
clearInterval(livePollTimer);
|
||||
livePollTimer = null;
|
||||
}
|
||||
state.transport = "streaming";
|
||||
liveEventSource = new EventSource("/api/tmux_log/live");
|
||||
let receivedAnyMessage = false;
|
||||
|
||||
liveEventSource.onmessage = e => {
|
||||
if (!isTmuxRouteActive() || document.visibilityState !== "visible") return;
|
||||
receivedAnyMessage = true;
|
||||
applyIncomingLog(e.data);
|
||||
};
|
||||
|
||||
liveEventSource.onerror = err => {
|
||||
console.error("Error receiving tmux log stream:", err);
|
||||
if (!isTmuxRouteActive()) {
|
||||
stopLiveTransport();
|
||||
return;
|
||||
}
|
||||
startPolling();
|
||||
};
|
||||
|
||||
// If SSE connects but no payload arrives quickly (common through tunnels), fallback.
|
||||
setTimeout(() => {
|
||||
if (!receivedAnyMessage && state.transport === "streaming" && isTmuxRouteActive()) {
|
||||
startPolling();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function updateTransportForLifecycle() {
|
||||
if (lifecycleVersion !== tmuxLifecycleVersion) {
|
||||
stopLiveTransport();
|
||||
if (lifecycleTimer) {
|
||||
clearInterval(lifecycleTimer);
|
||||
lifecycleTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTmuxRouteActive()) {
|
||||
stopLiveTransport();
|
||||
state.transport = "inactive";
|
||||
if (lifecycleTimer) {
|
||||
clearInterval(lifecycleTimer);
|
||||
lifecycleTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.visibilityState !== "visible") {
|
||||
stopLiveTransport();
|
||||
state.transport = "paused";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGalaxyTunnel()) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!liveEventSource && !livePollTimer) {
|
||||
startEventStream();
|
||||
}
|
||||
}
|
||||
|
||||
updateTransportForLifecycle();
|
||||
lifecycleTimer = setInterval(updateTransportForLifecycle, TRANSPORT_LIFECYCLE_INTERVAL_MS);
|
||||
|
||||
function getStatusLabel() {
|
||||
if (state.transport === "polling") {
|
||||
return "Tmux Live Log (Remote Mode)";
|
||||
}
|
||||
if (state.transport === "streaming") {
|
||||
return "Tmux Live Log";
|
||||
}
|
||||
return "Tmux Live Log";
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
state.paused = !state.paused;
|
||||
if (!state.paused) {
|
||||
state.log = state.latest;
|
||||
@@ -248,7 +378,7 @@ export function TmuxLog() {
|
||||
<div class="tmux-block">
|
||||
<div class="tmux-wrapper">
|
||||
<div class="tmuxContainer">
|
||||
<div class="tmuxHeader">Tmux Live Log</div>
|
||||
<div class="tmuxHeader">${() => getStatusLabel()}</div>
|
||||
<pre class="tmuxLog">${() => state.log}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,20 +393,20 @@ export function TmuxLog() {
|
||||
</div>
|
||||
|
||||
${() => state.selectorAction
|
||||
? TmuxLogSelector({
|
||||
action: state.selectorAction,
|
||||
closeFn: () => (state.selectorAction = null)
|
||||
})
|
||||
: ""
|
||||
}
|
||||
? TmuxLogSelector({
|
||||
action: state.selectorAction,
|
||||
closeFn: () => (state.selectorAction = null)
|
||||
})
|
||||
: ""
|
||||
}
|
||||
|
||||
${() => logSelectorState.showDeleteAllModal ? Modal({
|
||||
title: "Delete All Logs",
|
||||
message: "Are you sure you want to delete all of your session logs?",
|
||||
onConfirm: deleteAllSessions,
|
||||
onCancel: () => { logSelectorState.showDeleteAllModal = false },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
title: "Delete All Logs",
|
||||
message: "Are you sure you want to delete all of your session logs?",
|
||||
onConfirm: deleteAllSessions,
|
||||
onCancel: () => { logSelectorState.showDeleteAllModal = false },
|
||||
confirmText: "Delete All"
|
||||
}) : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
|
||||
|
||||
export function ToggleControl () {
|
||||
export function ToggleControl() {
|
||||
const state = reactive({
|
||||
showResetDefaultModal: false,
|
||||
showResetStockModal: false,
|
||||
@@ -14,7 +15,7 @@ export function ToggleControl () {
|
||||
fileInput.addEventListener("change", restoreToggles)
|
||||
document.body.appendChild(fileInput)
|
||||
|
||||
async function backupToggles () {
|
||||
async function backupToggles() {
|
||||
const response = await fetch("/api/toggles/backup", { method: "POST" })
|
||||
const blob = await response.blob()
|
||||
|
||||
@@ -26,7 +27,7 @@ export function ToggleControl () {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}
|
||||
|
||||
async function restoreToggles (event) {
|
||||
async function restoreToggles(event) {
|
||||
const uploadedFile = event.target.files[0]
|
||||
if (uploadedFile) {
|
||||
const fileContents = await uploadedFile.text()
|
||||
@@ -45,11 +46,11 @@ export function ToggleControl () {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmResetDefault () {
|
||||
function confirmResetDefault() {
|
||||
state.showResetDefaultModal = true;
|
||||
}
|
||||
|
||||
async function resetTogglesToDefault () {
|
||||
async function resetTogglesToDefault() {
|
||||
state.showResetDefaultModal = false;
|
||||
showSnackbar("Resetting toggles to their default values...");
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -58,11 +59,11 @@ export function ToggleControl () {
|
||||
await fetch("/api/toggles/reset_default", { method: "POST" });
|
||||
}
|
||||
|
||||
function confirmResetStock () {
|
||||
function confirmResetStock() {
|
||||
state.showResetStockModal = true;
|
||||
}
|
||||
|
||||
async function resetTogglesToStock () {
|
||||
async function resetTogglesToStock() {
|
||||
state.showResetStockModal = false;
|
||||
showSnackbar("Resetting toggles to stock openpilot values...");
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -71,7 +72,7 @@ export function ToggleControl () {
|
||||
await fetch("/api/toggles/reset_stock", { method: "POST" });
|
||||
}
|
||||
|
||||
function triggerRestorePrompt () {
|
||||
function triggerRestorePrompt() {
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
@@ -87,9 +88,9 @@ export function ToggleControl () {
|
||||
</section>
|
||||
|
||||
<section class="toggle-control-widget" style="margin-left: 1.5rem">
|
||||
<div class="toggle-control-title">Reset Toggles to Default FrogPilot/Stock openpilot</div>
|
||||
<div class="toggle-control-title">Reset Toggles to Default StarPilot</div>
|
||||
<p class="toggle-control-text">
|
||||
Reset all toggles to default FrogPilot/stock openpilot settings.
|
||||
Reset all toggles to default StarPilot settings.
|
||||
</p>
|
||||
<button class="toggle-control-button" @click="${confirmResetDefault}">
|
||||
Reset Toggles to Default
|
||||
@@ -98,20 +99,22 @@ export function ToggleControl () {
|
||||
Reset Toggles to Stock
|
||||
</button>
|
||||
</section>
|
||||
|
||||
${TailscaleControl()}
|
||||
</div>
|
||||
${() => state.showResetDefaultModal ? Modal({
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
|
||||
onConfirm: resetTogglesToDefault,
|
||||
onCancel: () => { state.showResetDefaultModal = false; },
|
||||
confirmText: "Reset to Default"
|
||||
}) : ""}
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
|
||||
onConfirm: resetTogglesToDefault,
|
||||
onCancel: () => { state.showResetDefaultModal = false; },
|
||||
confirmText: "Reset to Default"
|
||||
}) : ""}
|
||||
${() => state.showResetStockModal ? Modal({
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to stock openpilot values?",
|
||||
onConfirm: resetTogglesToStock,
|
||||
onCancel: () => { state.showResetStockModal = false; },
|
||||
confirmText: "Reset to Stock"
|
||||
}) : ""}
|
||||
title: "Reset Toggles",
|
||||
message: "Are you sure you want to reset all toggles to stock openpilot values?",
|
||||
onConfirm: resetTogglesToStock,
|
||||
onCancel: () => { state.showResetStockModal = false; },
|
||||
confirmText: "Reset to Stock"
|
||||
}) : ""}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
.troubleshootPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.troubleshootCard {
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.troubleshootIntro {
|
||||
margin: 0 0 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.troubleshootActionRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.troubleshootButton {
|
||||
background: linear-gradient(135deg, #7a62b8, #8b6cc5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.troubleshootButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.troubleshootDanger {
|
||||
background: linear-gradient(135deg, #b14a6b, #d95a7b);
|
||||
}
|
||||
|
||||
.troubleshootError {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.troubleshootStatusLine {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.troubleshootSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.troubleshootSectionHeader h3,
|
||||
.troubleshootCard h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.troubleshootHeaderRow,
|
||||
.troubleshootItemRow,
|
||||
.troubleshootSnapshotRow {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--sidebar-border-color);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.troubleshootHeaderRow {
|
||||
font-weight: 700;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.troubleshootHeaderRow {
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.troubleshootHeaderRowSnapshot {
|
||||
grid-template-columns: 2fr 2fr;
|
||||
}
|
||||
|
||||
.troubleshootSnapshotRow {
|
||||
grid-template-columns: 2fr 2fr;
|
||||
}
|
||||
|
||||
.troubleshootItemRow {
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.troubleshootItemLabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.troubleshootItemValue,
|
||||
.troubleshootItemDefault {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.troubleshootHeaderRow,
|
||||
.troubleshootItemRow {
|
||||
grid-template-columns: 1.3fr 1fr 1fr;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.troubleshootSnapshotRow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.troubleshootSectionHeader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
error: "",
|
||||
snapshot: [],
|
||||
sections: [],
|
||||
isOnroad: false,
|
||||
refreshing: false,
|
||||
busySection: "",
|
||||
})
|
||||
|
||||
let initialized = false
|
||||
|
||||
function formatValue(value) {
|
||||
if (typeof value === "boolean") return value ? "On" : "Off"
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value)) return String(value)
|
||||
return String(Number(value.toFixed(4)))
|
||||
}
|
||||
if (value === null || value === undefined) return "n/a"
|
||||
const text = String(value).trim()
|
||||
return text || "(empty)"
|
||||
}
|
||||
|
||||
function buildReportText() {
|
||||
const lines = []
|
||||
lines.push("StarPilot Troubleshoot Report")
|
||||
lines.push(`Generated: ${new Date().toISOString()}`)
|
||||
lines.push(`Onroad: ${state.isOnroad ? "Yes" : "No"}`)
|
||||
lines.push("")
|
||||
lines.push("Snapshot")
|
||||
for (const item of state.snapshot) {
|
||||
lines.push(`- ${item.label}: ${formatValue(item.value)}`)
|
||||
}
|
||||
|
||||
for (const section of state.sections) {
|
||||
lines.push("")
|
||||
lines.push(section.title)
|
||||
for (const item of section.items || []) {
|
||||
lines.push(`- ${item.label}: ${formatValue(item.value)} (default: ${formatValue(item.defaultValue)})`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
const text = buildReportText()
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
showSnackbar("Troubleshoot report copied to clipboard.", "", 2500, { key: "troubleshoot-copy" })
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to copy report.", "error", 3000, { key: "troubleshoot-copy" })
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTroubleshoot(showToast = false) {
|
||||
state.refreshing = true
|
||||
try {
|
||||
const response = await fetch("/api/troubleshoot")
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to load troubleshoot data")
|
||||
}
|
||||
|
||||
state.snapshot = Array.isArray(payload.snapshot) ? payload.snapshot : []
|
||||
state.sections = Array.isArray(payload.sections) ? payload.sections : []
|
||||
state.isOnroad = !!payload.isOnroad
|
||||
state.error = ""
|
||||
|
||||
if (showToast) {
|
||||
showSnackbar("Troubleshoot data refreshed.", "", 1800, { key: "troubleshoot-refresh" })
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.message || "Failed to load troubleshoot data"
|
||||
state.error = message
|
||||
if (showToast) {
|
||||
showSnackbar(message, "error", 3000, { key: "troubleshoot-refresh" })
|
||||
}
|
||||
} finally {
|
||||
state.loading = false
|
||||
state.refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSection(section) {
|
||||
const sectionId = String(section?.id || "")
|
||||
if (!sectionId || state.busySection) return
|
||||
|
||||
const sectionTitle = String(section?.title || "this section")
|
||||
if (!confirm(`Reset ${sectionTitle} to defaults?`)) return
|
||||
|
||||
state.busySection = sectionId
|
||||
try {
|
||||
const response = await fetch("/api/troubleshoot/reset", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sectionId }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to reset section")
|
||||
}
|
||||
|
||||
showSnackbar(payload.message || `${sectionTitle} reset.`, "", 2500, { key: "troubleshoot-reset" })
|
||||
await fetchTroubleshoot(false)
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to reset section.", "error", 3000, { key: "troubleshoot-reset" })
|
||||
} finally {
|
||||
state.busySection = ""
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
fetchTroubleshoot(false)
|
||||
}
|
||||
|
||||
function itemRows(section) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
return items.map((item) => html`
|
||||
<div class="troubleshootItemRow">
|
||||
<div class="troubleshootItemLabel">${item.label}</div>
|
||||
<div class="troubleshootItemValue">${formatValue(item.value)}</div>
|
||||
<div class="troubleshootItemDefault">${formatValue(item.defaultValue)}</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
export function Troubleshoot() {
|
||||
initialize()
|
||||
|
||||
return html`
|
||||
<div class="troubleshootPage">
|
||||
<h2>Troubleshoot</h2>
|
||||
|
||||
${() => state.loading ? html`<div class="troubleshootCard">Loading troubleshoot data...</div>` : ""}
|
||||
|
||||
${() => !state.loading ? html`
|
||||
<div class="troubleshootCard">
|
||||
<p class="troubleshootIntro">
|
||||
Quick diagnostics snapshot for weird behavior reports and copy-ready debug logs.
|
||||
</p>
|
||||
<div class="troubleshootActionRow">
|
||||
<button class="troubleshootButton" disabled="${() => state.refreshing || false}" @click="${() => fetchTroubleshoot(true)}"> ${state.refreshing ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
<button class="troubleshootButton" @click="${copyToClipboard}">
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
</div>
|
||||
${() => state.error ? html`<p class="troubleshootError"><strong>Error:</strong> ${state.error}</p>` : ""}
|
||||
<p class="troubleshootStatusLine"><strong>Onroad:</strong> ${state.isOnroad ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
|
||||
<div class="troubleshootCard">
|
||||
<h3>Snapshot</h3>
|
||||
<div class="troubleshootHeaderRow troubleshootHeaderRowSnapshot">
|
||||
<span>Field</span>
|
||||
<span>Value</span>
|
||||
</div>
|
||||
${() => state.snapshot.map((item) => html`
|
||||
<div class="troubleshootSnapshotRow">
|
||||
<div class="troubleshootItemLabel">${item.label}</div>
|
||||
<div class="troubleshootItemValue">${formatValue(item.value)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
${() => state.sections.map((section) => html`
|
||||
<div class="troubleshootCard">
|
||||
<div class="troubleshootSectionHeader">
|
||||
<h3>${section.title}</h3>
|
||||
${section.resettable ? html`
|
||||
<button
|
||||
class="troubleshootButton troubleshootDanger"
|
||||
disabled="${() => state.busySection === section.id || false}"
|
||||
@click="${() => resetSection(section)}"> ${state.busySection === section.id ? "Resetting..." : "Reset to Default"}
|
||||
</button>
|
||||
` : ""}
|
||||
</div>
|
||||
<div class="troubleshootHeaderRow">
|
||||
<span>Setting</span>
|
||||
<span>Current</span>
|
||||
<span>Default</span>
|
||||
</div>
|
||||
${itemRows(section)}
|
||||
</div>
|
||||
`)}
|
||||
` : ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
|
||||
.tskkeys-group > .tskkeys-row:last-of-type {
|
||||
.tskkeys-group>.tskkeys-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
.tskkeys-input:hover,
|
||||
.tskkeys-input:focus {
|
||||
border-color: var(--thumb-color);
|
||||
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
|
||||
box-shadow: var(--glow-primary);
|
||||
transform: var(--hover-scale-sm);
|
||||
}
|
||||
|
||||
@@ -211,4 +211,4 @@
|
||||
.tskkeys-input {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
|
||||
const state = reactive({
|
||||
@@ -165,15 +165,16 @@ const api = {
|
||||
}
|
||||
}
|
||||
|
||||
let hasLoaded = false
|
||||
if (!hasLoaded) {
|
||||
hasLoaded = true
|
||||
api.load()
|
||||
}
|
||||
|
||||
export function TSKManager() {
|
||||
return html`
|
||||
<div class="tskkeys-wrapper tskkeys-offset-top">
|
||||
let hasLoaded = false
|
||||
|
||||
export function TSKManager() {
|
||||
if (!hasLoaded) {
|
||||
hasLoaded = true
|
||||
api.load()
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="tskkeys-wrapper tskkeys-offset-top">
|
||||
<div class="tskkeys-container">
|
||||
<div class="tskkeys-title">Toyota Security Key Manager</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
.updateManager {
|
||||
max-width: var(--width-xxl);
|
||||
}
|
||||
|
||||
.updateActions {
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateButton {
|
||||
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);
|
||||
}
|
||||
|
||||
.updateButton.danger {
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.updateButton.danger:hover {
|
||||
background: var(--danger-hover-bg);
|
||||
}
|
||||
|
||||
.updateButton:hover {
|
||||
transform: var(--hover-scale-sm);
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.updateButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.updateCard {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
max-width: 80%;
|
||||
padding: var(--border-radius-xl);
|
||||
}
|
||||
|
||||
.updateError {
|
||||
color: var(--danger-fg);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateGrid {
|
||||
display: grid;
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.updateGrid p {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.updateLink {
|
||||
color: var(--main-fg);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.updateMessage {
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateProgressCard {
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateProgressDetail {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateProgressDetail.error {
|
||||
color: var(--danger-fg);
|
||||
}
|
||||
|
||||
.updateProgressFill {
|
||||
background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 100%;
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
.updateProgressFill.error {
|
||||
background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%);
|
||||
}
|
||||
|
||||
.updateProgressHeader {
|
||||
display: flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--margin-xs);
|
||||
}
|
||||
|
||||
.updateProgressTrack {
|
||||
background: var(--track-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.updateProgressTrack.error {
|
||||
border: 1px solid var(--danger-bg);
|
||||
}
|
||||
|
||||
.updateToggleRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateWarning {
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateHint {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateRebootNotice {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.updateFooter {
|
||||
border-top: 1px solid var(--track-color);
|
||||
margin-top: var(--margin-base);
|
||||
padding-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateBranchSection {
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateAdvancedNote {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateBranchHeader {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: var(--font-size-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.updateBranchRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateSelect {
|
||||
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);
|
||||
}
|
||||
|
||||
.updateSelect:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.updateToggleRow input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
.updateActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.updateBranchRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.updateCard {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.updateGrid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
error: "",
|
||||
status: null,
|
||||
checkedForUpdates: false,
|
||||
checkBusy: false,
|
||||
updateBusy: false,
|
||||
toggleBusy: false,
|
||||
showAdvancedOptions: false,
|
||||
branches: [],
|
||||
selectedBranch: "",
|
||||
hasManualBranchSelection: false,
|
||||
branchesError: "",
|
||||
branchesBusy: false,
|
||||
switchBusy: false,
|
||||
})
|
||||
|
||||
let initialized = false
|
||||
let pollHandle = null
|
||||
let reconnectPending = false
|
||||
let rebootNoticeShown = false
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const ADVANCED_OPTIONS_KEY = "softwareShowAdvancedOptions"
|
||||
|
||||
function isUpdateRouteActive() {
|
||||
return window.location.pathname === "/manage_updates"
|
||||
}
|
||||
|
||||
function shortHash(value) {
|
||||
const text = String(value || "").trim()
|
||||
return text ? text.slice(0, 10) : "Unknown"
|
||||
}
|
||||
|
||||
function toPercent(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return 0
|
||||
return Math.max(0, Math.min(100, n))
|
||||
}
|
||||
|
||||
function findBranchInList(branches, target) {
|
||||
const wanted = String(target || "").trim()
|
||||
if (!wanted || !Array.isArray(branches) || branches.length === 0) return ""
|
||||
if (branches.includes(wanted)) return wanted
|
||||
|
||||
const wantedLower = wanted.toLowerCase()
|
||||
const caseInsensitive = branches.find((branch) => String(branch || "").toLowerCase() === wantedLower)
|
||||
return caseInsensitive || ""
|
||||
}
|
||||
|
||||
function isSelectedBranchDifferent() {
|
||||
const currentBranch = String(state.status?.branch || "").trim()
|
||||
const selectedBranch = String(state.selectedBranch || "").trim()
|
||||
return !!currentBranch && !!selectedBranch && currentBranch !== selectedBranch
|
||||
}
|
||||
|
||||
function normalizeGithubRemote(remoteValue, commitsUrlValue = "") {
|
||||
let remote = String(remoteValue || "").trim()
|
||||
if (!remote) {
|
||||
const commitsUrl = String(commitsUrlValue || "").trim()
|
||||
const marker = "/commits/"
|
||||
const markerIndex = commitsUrl.indexOf(marker)
|
||||
if (commitsUrl.startsWith("https://github.com/") && markerIndex > 0) {
|
||||
remote = commitsUrl.slice(0, markerIndex)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
if (remote.startsWith("git@github.com:")) {
|
||||
remote = `https://github.com/${remote.split(":", 2)[1] || ""}`
|
||||
} else if (remote.startsWith("ssh://git@github.com/")) {
|
||||
remote = `https://github.com/${remote.split("ssh://git@github.com/", 2)[1] || ""}`
|
||||
} else if (remote.startsWith("http://github.com/")) {
|
||||
remote = `https://github.com/${remote.split("http://github.com/", 2)[1] || ""}`
|
||||
}
|
||||
|
||||
if (!remote.startsWith("https://github.com/")) return ""
|
||||
remote = remote.replace(/\/+$/, "")
|
||||
if (remote.endsWith(".git")) remote = remote.slice(0, -4)
|
||||
return remote
|
||||
}
|
||||
|
||||
function currentOrSelectedBranchForCommits() {
|
||||
const selected = String(state.selectedBranch || "").trim()
|
||||
if (selected) return selected
|
||||
return String(state.status?.branch || "").trim()
|
||||
}
|
||||
|
||||
function activeCommitsUrl() {
|
||||
const branch = currentOrSelectedBranchForCommits()
|
||||
if (!branch) return ""
|
||||
|
||||
const remote = normalizeGithubRemote(state.status?.originRemote, state.status?.commitsUrl)
|
||||
if (!remote) return ""
|
||||
return `${remote}/commits/${encodeURIComponent(branch)}/`
|
||||
}
|
||||
|
||||
function activeCommitsLabel() {
|
||||
const branch = currentOrSelectedBranchForCommits()
|
||||
return branch
|
||||
? `View latest commit for the "${branch}" branch`
|
||||
: "View latest commit for this branch"
|
||||
}
|
||||
|
||||
function shouldShowPrimaryUpdateAction() {
|
||||
if (state.status?.running) return true
|
||||
if (isSelectedBranchDifferent()) return true
|
||||
return !!state.checkedForUpdates && !!state.status?.updateAvailable
|
||||
}
|
||||
|
||||
function shouldContinuePolling() {
|
||||
return !!state.status?.running || state.status?.stage === "rebooting" || reconnectPending
|
||||
}
|
||||
|
||||
function shouldShowRebootNotice() {
|
||||
if (state.status?.stage === "rebooting") return true
|
||||
if (reconnectPending) return true
|
||||
const message = String(state.status?.message || "").toLowerCase()
|
||||
return message.includes("reboot")
|
||||
}
|
||||
|
||||
function isHtmlLike(text) {
|
||||
const value = String(text || "").trim()
|
||||
return value.startsWith("<!DOCTYPE") || value.startsWith("<html") || value.startsWith("<")
|
||||
}
|
||||
|
||||
async function readJsonPayload(response) {
|
||||
const bodyText = await response.text()
|
||||
if (!bodyText) return {}
|
||||
|
||||
try {
|
||||
return JSON.parse(bodyText)
|
||||
} catch (parseError) {
|
||||
const error = new Error("Invalid JSON response")
|
||||
error.cause = parseError
|
||||
error.bodyText = bodyText
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function setRebootingUiState(detail = "Reboot in progress...") {
|
||||
state.status = {
|
||||
...(state.status || {}),
|
||||
running: false,
|
||||
stage: "rebooting",
|
||||
progressStep: 5,
|
||||
progressTotalSteps: state.status?.progressTotalSteps || 5,
|
||||
progressStepPercent: 100,
|
||||
progressPercent: 100,
|
||||
progressLabel: "Rebooting device",
|
||||
progressDetail: detail,
|
||||
message: "Update complete. Device is rebooting. Please wait...",
|
||||
}
|
||||
state.error = ""
|
||||
}
|
||||
|
||||
function isRebootRelatedConnectionError(error) {
|
||||
const message = String(error?.message || "")
|
||||
return /Invalid JSON response|Unexpected token '<'|Failed to fetch|NetworkError|Load failed/i.test(message)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (!pollHandle) return
|
||||
clearTimeout(pollHandle)
|
||||
pollHandle = null
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (pollHandle) return
|
||||
|
||||
const poll = async () => {
|
||||
if (!isUpdateRouteActive()) {
|
||||
pollHandle = null
|
||||
return
|
||||
}
|
||||
|
||||
if (document.visibilityState !== "visible") {
|
||||
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
return
|
||||
}
|
||||
|
||||
await fetchStatus(false)
|
||||
if (shouldContinuePolling()) {
|
||||
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
} else {
|
||||
pollHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function fetchStatus(showToast) {
|
||||
if (showToast) state.checkBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/update/fast/status")
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to load update status")
|
||||
}
|
||||
|
||||
state.status = payload
|
||||
state.error = ""
|
||||
|
||||
if (reconnectPending && !payload.running && payload.stage !== "rebooting") {
|
||||
reconnectPending = false
|
||||
rebootNoticeShown = false
|
||||
showSnackbar("Connection reestablished.")
|
||||
}
|
||||
|
||||
if (payload.stage === "rebooting" && !rebootNoticeShown) {
|
||||
rebootNoticeShown = true
|
||||
showSnackbar("Device rebooting, please wait for reconnection...")
|
||||
}
|
||||
|
||||
if (!state.selectedBranch && payload.branch) {
|
||||
state.selectedBranch = payload.branch
|
||||
}
|
||||
|
||||
if (shouldContinuePolling()) {
|
||||
ensurePolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
state.checkedForUpdates = true
|
||||
showSnackbar(payload.updateAvailable ? "Update available." : "No update available.")
|
||||
}
|
||||
} catch (error) {
|
||||
const isRebootTransitionError = !showToast
|
||||
&& (state.status?.running || state.status?.stage === "rebooting" || reconnectPending)
|
||||
&& (isHtmlLike(error?.bodyText) || isRebootRelatedConnectionError(error))
|
||||
|
||||
if (isRebootTransitionError) {
|
||||
reconnectPending = true
|
||||
setRebootingUiState("Device rebooting, please wait for reconnection...")
|
||||
if (!rebootNoticeShown) {
|
||||
rebootNoticeShown = true
|
||||
showSnackbar("Device rebooting, please wait for reconnection...")
|
||||
}
|
||||
ensurePolling()
|
||||
return
|
||||
}
|
||||
|
||||
const message = error?.message || String(error)
|
||||
state.error = message
|
||||
if (showToast) {
|
||||
showSnackbar(message, "error")
|
||||
}
|
||||
} finally {
|
||||
if (showToast) state.checkBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBranches(showToast = false) {
|
||||
state.branchesBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/update/branches")
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to load branches")
|
||||
}
|
||||
|
||||
const branches = Array.isArray(payload.branches) ? payload.branches : []
|
||||
const currentBranchRaw = String(payload.currentBranch || state.status?.branch || "").trim()
|
||||
const matchedCurrentBranch = findBranchInList(branches, currentBranchRaw)
|
||||
state.branches = branches
|
||||
state.branchesError = payload.remoteError || ""
|
||||
|
||||
if (branches.length === 0) {
|
||||
state.selectedBranch = currentBranchRaw
|
||||
state.hasManualBranchSelection = false
|
||||
} else if (matchedCurrentBranch && !state.hasManualBranchSelection) {
|
||||
// Default to the branch currently installed unless the user picked one explicitly.
|
||||
state.selectedBranch = matchedCurrentBranch
|
||||
} else if (!state.selectedBranch || !branches.includes(state.selectedBranch)) {
|
||||
state.selectedBranch = matchedCurrentBranch || branches[0]
|
||||
state.hasManualBranchSelection = false
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
showSnackbar(branches.length ? `Loaded ${branches.length} branches.` : "No branches found.")
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.message || "Failed to load branches"
|
||||
state.branchesError = message
|
||||
if (showToast) {
|
||||
showSnackbar(message, "error")
|
||||
}
|
||||
} finally {
|
||||
state.branchesBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function setAdvancedOptions(enabled) {
|
||||
const next = !!enabled
|
||||
state.showAdvancedOptions = next
|
||||
|
||||
if (!next) {
|
||||
const currentBranch = String(state.status?.branch || "").trim()
|
||||
if (currentBranch) {
|
||||
state.selectedBranch = currentBranch
|
||||
}
|
||||
state.hasManualBranchSelection = false
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(ADVANCED_OPTIONS_KEY, next ? "1" : "0")
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist advanced options preference", error)
|
||||
}
|
||||
|
||||
if (next && state.branches.length === 0 && !state.branchesBusy) {
|
||||
fetchBranches(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function setAutomaticUpdates(enabled) {
|
||||
if (state.toggleBusy) return
|
||||
if (state.status?.isOnroad) {
|
||||
showSnackbar("Actions are blocked while onroad.", "error")
|
||||
return
|
||||
}
|
||||
state.toggleBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/params", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "AutomaticUpdates", value: !!enabled }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to update Automatic Updates")
|
||||
}
|
||||
|
||||
state.status = { ...(state.status || {}), automaticUpdates: !!enabled }
|
||||
showSnackbar(payload.message || "Automatic updates updated.")
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to update Automatic Updates", "error")
|
||||
await fetchStatus(false)
|
||||
} finally {
|
||||
state.toggleBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFastUpdate() {
|
||||
if (state.updateBusy) return
|
||||
if (state.status?.running) {
|
||||
showSnackbar("Fast update is already running.")
|
||||
return
|
||||
}
|
||||
if (state.status?.isOnroad) {
|
||||
showSnackbar("Actions are blocked while onroad.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"Fast update warning:\n\n" +
|
||||
"- This update method skips backup creation.\n" +
|
||||
"- Your device will reboot when the update is done.\n\n" +
|
||||
"Continue with fast update?"
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
state.updateBusy = true
|
||||
try {
|
||||
reconnectPending = false
|
||||
rebootNoticeShown = false
|
||||
const response = await fetch("/api/update/fast", { method: "POST" })
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to start fast update")
|
||||
}
|
||||
|
||||
state.status = {
|
||||
...(state.status || {}),
|
||||
running: true,
|
||||
stage: "starting",
|
||||
progressStep: 1,
|
||||
progressTotalSteps: state.status?.progressTotalSteps || 5,
|
||||
progressStepPercent: 0,
|
||||
progressPercent: 0,
|
||||
progressLabel: "Preparing update",
|
||||
progressDetail: "Initializing update process...",
|
||||
message: payload.message || "Fast update started. Device will reboot when complete.",
|
||||
lastError: "",
|
||||
}
|
||||
state.error = ""
|
||||
showSnackbar(payload.message || "Fast update started.")
|
||||
await fetchStatus(false)
|
||||
ensurePolling()
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to start fast update", "error")
|
||||
} finally {
|
||||
state.updateBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runBranchSwitch() {
|
||||
if (state.switchBusy) return
|
||||
if (state.status?.running) {
|
||||
showSnackbar("An update action is already running.")
|
||||
return
|
||||
}
|
||||
if (state.status?.isOnroad) {
|
||||
showSnackbar("Actions are blocked while onroad.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const targetBranch = String(state.selectedBranch || "").trim()
|
||||
if (!targetBranch) {
|
||||
showSnackbar("Select a target branch first.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const currentBranch = String(state.status?.branch || "").trim()
|
||||
const actionLabel = currentBranch && currentBranch === targetBranch ? "update" : "switch and update"
|
||||
const confirmed = window.confirm(
|
||||
`This will ${actionLabel} to the '${targetBranch}' branch.\n\n` +
|
||||
"- This update method skips backup creation.\n" +
|
||||
"- Your device will reboot when the update is done.\n\n" +
|
||||
"Continue?"
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
state.switchBusy = true
|
||||
try {
|
||||
reconnectPending = false
|
||||
rebootNoticeShown = false
|
||||
const response = await fetch("/api/update/branch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ branch: targetBranch }),
|
||||
})
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to start branch switch")
|
||||
}
|
||||
|
||||
state.status = {
|
||||
...(state.status || {}),
|
||||
running: true,
|
||||
stage: "starting",
|
||||
progressStep: 1,
|
||||
progressTotalSteps: state.status?.progressTotalSteps || 5,
|
||||
progressStepPercent: 0,
|
||||
progressPercent: 0,
|
||||
progressLabel: "Preparing branch switch",
|
||||
progressDetail: "Initializing branch switch...",
|
||||
message: payload.message || `Branch switch started for '${targetBranch}'. Device will reboot when complete.`,
|
||||
lastError: "",
|
||||
}
|
||||
state.error = ""
|
||||
showSnackbar(payload.message || "Branch switch started.")
|
||||
await fetchStatus(false)
|
||||
ensurePolling()
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to start branch switch", "error")
|
||||
} finally {
|
||||
state.switchBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
state.showAdvancedOptions = localStorage.getItem(ADVANCED_OPTIONS_KEY) === "1"
|
||||
} catch (error) {
|
||||
state.showAdvancedOptions = false
|
||||
}
|
||||
|
||||
const initTasks = [fetchStatus(false)]
|
||||
if (state.showAdvancedOptions) {
|
||||
initTasks.push(fetchBranches(false))
|
||||
}
|
||||
|
||||
Promise.all(initTasks).finally(() => {
|
||||
state.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
export function UpdateManager() {
|
||||
initialize()
|
||||
|
||||
return html`
|
||||
<div class="updateManager">
|
||||
<h2>Software</h2>
|
||||
|
||||
${() => state.loading ? html`<div class="updateCard">Loading update status...</div>` : ""}
|
||||
${() => !state.loading ? html`
|
||||
<div class="updateCard">
|
||||
<div class="updateGrid">
|
||||
<p><strong>Current Branch:</strong> ${state.status?.branch || "Unknown"}</p>
|
||||
<p><strong>Installed Commit:</strong> ${shortHash(state.status?.localCommit)}</p>
|
||||
<p><strong>Latest Commit:</strong> ${shortHash(state.status?.remoteCommit)}</p>
|
||||
<p><strong>Update Available:</strong> ${state.status?.updateAvailable ? "Yes" : "No"}</p>
|
||||
<p><strong>Status:</strong> ${state.status?.stage || "idle"}</p>
|
||||
<p><strong>Onroad:</strong> ${state.status?.isOnroad ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
|
||||
<div class="updateProgressCard">
|
||||
<div class="updateProgressHeader">
|
||||
<span>
|
||||
Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}:
|
||||
${state.status?.progressLabel || "Idle"}
|
||||
</span>
|
||||
<span>${Math.round(toPercent(state.status?.progressPercent))}%</span>
|
||||
</div>
|
||||
<div class="updateProgressTrack ${state.status?.stage === "error" ? "error" : ""}">
|
||||
<div class="updateProgressFill ${state.status?.stage === "error" ? "error" : ""}" style="width: ${toPercent(state.status?.progressPercent)}%;"></div>
|
||||
</div>
|
||||
${() => state.status?.progressDetail ? html`<p class="updateProgressDetail ${state.status?.stage === "error" ? "error" : ""}">${state.status.progressDetail}</p>` : ""}
|
||||
</div>
|
||||
|
||||
${() => state.status?.isOnroad ? html`<p class="updateWarning"><strong>Onroad: actions disabled</strong></p>` : ""}
|
||||
|
||||
<label class="updateToggleRow">
|
||||
<span>Automatically install updates</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ds-toggle"
|
||||
checked="${() => !!state.status?.automaticUpdates || false}"
|
||||
disabled="${() => !!state.status?.isOnroad || state.toggleBusy || !!state.status?.running || false}"
|
||||
@change="${(event) => setAutomaticUpdates(!!event.target.checked)}"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="updateToggleRow updateAdvancedToggle">
|
||||
<span>Show advanced options</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ds-toggle"
|
||||
checked="${() => state.showAdvancedOptions || false}"
|
||||
@change="${(event) => setAdvancedOptions(!!event.target.checked)}"
|
||||
/>
|
||||
</label>
|
||||
|
||||
${() => state.showAdvancedOptions ? html`
|
||||
<div class="updateBranchSection">
|
||||
<div class="updateBranchHeader">
|
||||
<strong>Branch switching</strong>
|
||||
${state.branchesBusy ? html`<span>Loading...</span>` : ""}
|
||||
</div>
|
||||
<p class="updateAdvancedNote">
|
||||
For advanced users only. Switching to test/dev branches can introduce instability.
|
||||
</p>
|
||||
<div class="updateBranchRow">
|
||||
<select
|
||||
class="updateSelect"
|
||||
disabled="${() => !!state.status?.isOnroad || !!state.status?.running || state.switchBusy || state.branchesBusy || state.branches.length === 0 || false}"
|
||||
@change="${(event) => {
|
||||
state.selectedBranch = String(event.target.value || "")
|
||||
state.hasManualBranchSelection = true
|
||||
}}">
|
||||
${() => state.branches.length
|
||||
? state.branches.map((branch) => html`<option value="${branch}" selected="${() => branch === state.selectedBranch || false}">${branch}${branch === state.status?.branch ? " (current)" : ""}</option>`)
|
||||
: html`<option value="">No branches found</option>`
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
class="updateButton"
|
||||
disabled="${() => state.branchesBusy || !!state.status?.running || false}"
|
||||
@click="${() => fetchBranches(true)}">
|
||||
${state.branchesBusy ? "Refreshing..." : "Refresh Branches"}
|
||||
</button>
|
||||
</div>
|
||||
${() => state.branchesError ? html`<p class="updateError"><strong>Branch List:</strong> ${state.branchesError}</p>` : ""}
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${() => state.status?.message && state.status?.stage !== "rebooting" ? html`<p class="updateMessage">${state.status.message}</p>` : ""}
|
||||
${() => state.status?.remoteError ? html`<p class="updateError"><strong>Remote Check:</strong> ${state.status.remoteError}</p>` : ""}
|
||||
${() => state.status?.lastError ? html`<p class="updateError"><strong>Last Error:</strong> ${state.status.lastError}</p>` : ""}
|
||||
${() => state.error ? html`<p class="updateError"><strong>Error:</strong> ${state.error}</p>` : ""}
|
||||
|
||||
<div class="updateActions">
|
||||
${() => !isSelectedBranchDifferent() ? html`
|
||||
<button class="updateButton" @click="${() => fetchStatus(true)}">
|
||||
${state.checkBusy ? "Checking..." : "Check for Updates"}
|
||||
</button>
|
||||
` : ""}
|
||||
${() => shouldShowPrimaryUpdateAction() ? html`
|
||||
<button
|
||||
class="updateButton danger"
|
||||
disabled="${() => !!state.status?.isOnroad || !!state.status?.running || (isSelectedBranchDifferent() ? state.switchBusy : state.updateBusy) || false}"
|
||||
@click="${() => isSelectedBranchDifferent() ? runBranchSwitch() : runFastUpdate()}">
|
||||
${state.status?.running
|
||||
? "Update Running..."
|
||||
: (isSelectedBranchDifferent()
|
||||
? (state.switchBusy ? "Starting..." : "Switch + Update")
|
||||
: (state.updateBusy ? "Starting..." : "Fast Update (No Backup)"))}
|
||||
</button>
|
||||
` : ""}
|
||||
</div>
|
||||
${() => !state.status?.running && !shouldShowPrimaryUpdateAction()
|
||||
? html`<p class="updateHint">Run <strong>Check for Updates</strong> first, or select a different branch in advanced options.</p>`
|
||||
: ""}
|
||||
${() => shouldShowRebootNotice()
|
||||
? html`<p class="updateHint updateRebootNotice">Device rebooting, please wait for reconnection...</p>`
|
||||
: ""}
|
||||
${() => activeCommitsUrl() ? html`
|
||||
<div class="updateFooter">
|
||||
<a class="updateLink" href="${activeCommitsUrl()}" target="_blank" rel="noopener noreferrer">
|
||||
${activeCommitsLabel()}
|
||||
</a>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { DoorControl } from "/assets/components/tools/doors.js"
|
||||
import { TSKManager } from "/assets/components/tools/tsk_manager.js"
|
||||
|
||||
const state = reactive({
|
||||
activeTool: null,
|
||||
toolStatus: {
|
||||
doors: "untested", // untested, allowed, denied
|
||||
tsk: "untested"
|
||||
},
|
||||
loading: false
|
||||
})
|
||||
|
||||
async function checkToolAvailability(toolName) {
|
||||
if (state.toolStatus[toolName] !== "untested") {
|
||||
state.activeTool = toolName;
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
state.activeTool = toolName;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/car_features_check?tool=${toolName}`);
|
||||
const data = await response.json();
|
||||
state.toolStatus[toolName] = data.result ? "allowed" : "denied";
|
||||
} catch (error) {
|
||||
console.error("Failed to check feature availability:", error);
|
||||
state.toolStatus[toolName] = "denied";
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function VehicleFeatures() {
|
||||
return html`
|
||||
<style>
|
||||
.vf-container {
|
||||
padding: var(--padding-lg);
|
||||
max-width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.vf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
margin-top: var(--margin-lg);
|
||||
}
|
||||
.vf-card {
|
||||
background: var(--card-bg);
|
||||
border: var(--border-width-thin) var(--border-style-base) var(--sidebar-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--padding-lg);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.vf-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--main-fg);
|
||||
}
|
||||
.vf-card i {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--margin-sm);
|
||||
color: var(--main-fg);
|
||||
}
|
||||
.vf-card h3 {
|
||||
margin: 0 0 var(--margin-xs) 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
.vf-card p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.vf-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--main-fg);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
padding: 0;
|
||||
margin-bottom: var(--margin-lg);
|
||||
}
|
||||
.vf-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.vf-error {
|
||||
margin-top: var(--margin-lg);
|
||||
padding: var(--padding-base);
|
||||
background: rgba(224, 85, 119, 0.1);
|
||||
border: var(--border-width-thin) var(--border-style-base) var(--danger-fg);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--danger-fg);
|
||||
text-align: center;
|
||||
}
|
||||
.vf-loader {
|
||||
text-align: center;
|
||||
padding: var(--padding-xxl);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="vf-container">
|
||||
${() => {
|
||||
if (!state.activeTool) {
|
||||
return html`
|
||||
<h1>Vehicle Specific Features</h1>
|
||||
<p style="color: var(--text-muted); margin-bottom: var(--margin-xl);">
|
||||
Select a feature below to access it. These features verify vehicle compatibility when launched.
|
||||
</p>
|
||||
<div class="vf-grid">
|
||||
<div class="vf-card" @click="${() => checkToolAvailability('doors')}">
|
||||
<i class="bi bi-door-closed"></i>
|
||||
<h3>Lock/Unlock Doors</h3>
|
||||
<p>Send lock or unlock commands remotely to your vehicle.</p>
|
||||
</div>
|
||||
<div class="vf-card" @click="${() => checkToolAvailability('tsk')}">
|
||||
<i class="bi bi-key-fill"></i>
|
||||
<h3>Toyota Security Keys</h3>
|
||||
<p>Manage and apply security keys for secOC protected devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button class="vf-back" @click="${() => { state.activeTool = null; }}">
|
||||
<i class="bi bi-arrow-left"></i> Back to Features
|
||||
</button>
|
||||
|
||||
${() => {
|
||||
if (state.loading) {
|
||||
return html`<div class="vf-loader"><i class="bi bi-hourglass-split"></i> Verifying vehicle compatibility...</div>`;
|
||||
}
|
||||
|
||||
if (state.toolStatus[state.activeTool] === "denied") {
|
||||
const toolNames = { doors: "Lock/Unlock Doors", tsk: "Toyota Security Keys" };
|
||||
return html`
|
||||
<div class="vf-error">
|
||||
<i class="bi bi-exclamation-triangle-fill" style="font-size: 2rem; display: block; margin-bottom: var(--margin-sm);"></i>
|
||||
<strong>${toolNames[state.activeTool]}</strong> is not supported for your current vehicle.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.activeTool === "doors") return DoorControl();
|
||||
if (state.activeTool === "tsk") return TSKManager();
|
||||
return "";
|
||||
}}
|
||||
`;
|
||||
}}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1008 KiB After Width: | Height: | Size: 332 KiB |
@@ -1,26 +1,50 @@
|
||||
function showSnackbar(msg, level, timeout = 3500) {
|
||||
function showSnackbar(msg, level, timeout = 3500, options = {}) {
|
||||
const wrapper = document.getElementById("snackbar_wrapper")
|
||||
if (!wrapper) return
|
||||
|
||||
const key = String(options?.key || "").trim()
|
||||
const replace = options?.replace !== false
|
||||
|
||||
const dismissSnackbar = (snackbar) => {
|
||||
if (!snackbar) return
|
||||
if (snackbar.__hideTimer) clearTimeout(snackbar.__hideTimer)
|
||||
if (snackbar.__removeTimer) clearTimeout(snackbar.__removeTimer)
|
||||
snackbar.style.opacity = 0
|
||||
snackbar.__removeTimer = setTimeout(() => snackbar.remove(), 1000)
|
||||
}
|
||||
|
||||
const setSnackbarContent = (snackbar) => {
|
||||
snackbar.innerHTML = msg
|
||||
snackbar.className = "snackbar show"
|
||||
if (level === "error") {
|
||||
snackbar.style.backgroundColor = "#f44336"
|
||||
} else {
|
||||
snackbar.style.backgroundColor = ""
|
||||
}
|
||||
}
|
||||
|
||||
if (key && replace) {
|
||||
const existing = Array.from(wrapper.children).find((el) => el.dataset?.snackbarKey === key)
|
||||
if (existing) {
|
||||
setSnackbarContent(existing)
|
||||
existing.style.opacity = 1
|
||||
if (existing.__hideTimer) clearTimeout(existing.__hideTimer)
|
||||
if (existing.__removeTimer) clearTimeout(existing.__removeTimer)
|
||||
existing.__hideTimer = setTimeout(() => dismissSnackbar(existing), timeout)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure max 2 snackbars are visible
|
||||
if (wrapper.children.length >= 2) {
|
||||
const first = wrapper.children[0]
|
||||
first.style.opacity = 0
|
||||
setTimeout(() => first.remove(), 1000)
|
||||
dismissSnackbar(wrapper.children[0])
|
||||
}
|
||||
|
||||
const snackbar = document.createElement("div")
|
||||
snackbar.innerHTML = msg
|
||||
snackbar.className = "snackbar show"
|
||||
snackbar.id = `snackbar_${Math.random().toString(36).slice(5)}`
|
||||
|
||||
if (level === "error") {
|
||||
snackbar.style.backgroundColor = "#f44336"
|
||||
}
|
||||
if (key) snackbar.dataset.snackbarKey = key
|
||||
setSnackbarContent(snackbar)
|
||||
|
||||
wrapper.appendChild(snackbar)
|
||||
|
||||
setTimeout(() => {
|
||||
snackbar.style.opacity = 0
|
||||
setTimeout(() => snackbar.remove(), 1000)
|
||||
}, timeout)
|
||||
snackbar.__hideTimer = setTimeout(() => dismissSnackbar(snackbar), timeout)
|
||||
}
|
||||
|
||||
@@ -65,3 +65,11 @@ export function hideSidebar() {
|
||||
document.getElementById("sidebarUnderlay")?.classList.add("hidden")
|
||||
html.classList.remove("no_scroll")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the page is being accessed through the Galaxy tunnel
|
||||
* (the public-facing domain is galaxy.firestar.link)
|
||||
*/
|
||||
export function isGalaxyTunnel() {
|
||||
return window.location.hostname === 'galaxy.firestar.link';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "The Pond",
|
||||
"name": "Galaxy",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
@@ -17,4 +17,4 @@
|
||||
"background_color": "#151414",
|
||||
"theme_color": "#151414",
|
||||
"display": "standalone"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en" id="htmlElement" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/manifest.json">
|
||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
||||
|
||||
<meta name="theme-color" content="#4285f4" />
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
|
||||
<meta name="theme-color" content="#8b6cc5" />
|
||||
|
||||
<link rel="stylesheet" href="/assets/components/home/home.css">
|
||||
<link rel="stylesheet" href="/assets/components/main.css">
|
||||
<link rel="stylesheet" href="/assets/components/modal.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
|
||||
<link rel="stylesheet" href="/assets/components/settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/sidebar.css">
|
||||
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/doors.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
|
||||
<script type="module" src="/assets/components/router.js"></script>
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
<link rel="stylesheet" href="/assets/components/home/home.css">
|
||||
<link rel="stylesheet" href="/assets/components/main.css">
|
||||
<link rel="stylesheet" href="/assets/components/modal.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
|
||||
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
|
||||
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
|
||||
<link rel="stylesheet" href="/assets/components/settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/sidebar.css">
|
||||
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/doors.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/plots.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/testing_ground.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/troubleshoot.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
|
||||
<script type="module">
|
||||
import("/assets/components/router.js").catch((err) => {
|
||||
console.error("[the_pond] bootstrap failed", err);
|
||||
const target = document.getElementById("app") || document.body;
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.whiteSpace = "pre-wrap";
|
||||
pre.style.color = "#ff8080";
|
||||
pre.style.background = "#200";
|
||||
pre.style.padding = "12px";
|
||||
pre.style.margin = "12px";
|
||||
pre.textContent = `[the_pond] failed to load router:\n${err?.stack || err}`;
|
||||
target.appendChild(pre);
|
||||
});
|
||||
</script>
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300..800&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<title>The Pond</title>
|
||||
</head>
|
||||
<title>Galaxy</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Menu button for phone -->
|
||||
<div id="menu_button">
|
||||
<i class="bi bi-list"></i>
|
||||
</div>
|
||||
<!-- Snackbar for messages -->
|
||||
<div id="snackbar_wrapper"></div>
|
||||
</body>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Menu button for phone -->
|
||||
<div id="menu_button">
|
||||
<i class="bi bi-list"></i>
|
||||
</div>
|
||||
<!-- Snackbar for messages -->
|
||||
<div id="snackbar_wrapper"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,6 @@ import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -563,20 +562,11 @@ def process_route(footage_path, route_name):
|
||||
|
||||
return {
|
||||
"name": route_name,
|
||||
"gif": f"/thumbnails/{route_name}--0/preview.gif",
|
||||
"png": f"/thumbnails/{route_name}--0/preview.png",
|
||||
"timestamp": route_timestamp_str,
|
||||
"is_preserved": has_preserve_attr(segment_path)
|
||||
}
|
||||
|
||||
def process_route_gif(footage_path, route_name):
|
||||
segment_path = f"{footage_path}{route_name}--0"
|
||||
qcamera_path = f"{segment_path}/qcamera.ts"
|
||||
gif_output_path = os.path.join(segment_path, "preview.gif")
|
||||
|
||||
if not os.path.exists(gif_output_path):
|
||||
video_to_gif(qcamera_path, gif_output_path)
|
||||
|
||||
def process_screen_recording(mp4):
|
||||
stem = mp4.with_suffix("")
|
||||
png_path = stem.with_suffix(".png")
|
||||
@@ -592,44 +582,29 @@ def process_screen_recording(mp4):
|
||||
|
||||
return {
|
||||
"filename": mp4.name,
|
||||
"gif": f"/screen_recordings/{stem.with_suffix('.gif').name}",
|
||||
"png": f"/screen_recordings/{png_path.name}",
|
||||
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
|
||||
"is_custom_name": is_custom_name
|
||||
}
|
||||
|
||||
def process_screen_recording_gif(mp4):
|
||||
stem = mp4.with_suffix("")
|
||||
gif_path = stem.with_suffix(".gif")
|
||||
if not gif_path.exists():
|
||||
video_to_gif(mp4, gif_path)
|
||||
|
||||
def run_ffmpeg(args):
|
||||
process = subprocess.Popen(["ffmpeg", "-hide_banner", "-loglevel", "error"] + args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
stdout, stderr = process.communicate()
|
||||
return stdout
|
||||
|
||||
def segment_to_segment_name(data_dir, segment):
|
||||
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
|
||||
return SegmentName(full_path)
|
||||
|
||||
def video_to_gif(input_path, output_path):
|
||||
output_path = Path(output_path)
|
||||
sped_up_path = output_path.with_suffix(f".{uuid.uuid4()}.spedup.mp4")
|
||||
|
||||
run_ffmpeg(["-i", str(input_path), "-an", "-vf", "setpts=PTS/35", str(sped_up_path)])
|
||||
run_ffmpeg(["-i", str(sped_up_path), "-loop", "0", str(output_path)])
|
||||
|
||||
if os.path.exists(sped_up_path):
|
||||
os.remove(sped_up_path)
|
||||
|
||||
def video_to_png(input_path, output_path):
|
||||
run_ffmpeg([
|
||||
"-ss", str(get_video_duration(input_path) / 2),
|
||||
"-i", str(input_path),
|
||||
"-frames:v", "1",
|
||||
str(output_path)
|
||||
])
|
||||
try:
|
||||
subprocess.run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-ss", "1",
|
||||
"-i", str(input_path),
|
||||
"-frames:v", "1",
|
||||
"-y",
|
||||
str(output_path)
|
||||
], capture_output=True, check=True, text=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to generate PNG for {input_path}")
|
||||
if e.stderr:
|
||||
print(e.stderr)
|
||||
|
||||
def xor_encrypt_decrypt(data, key):
|
||||
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
|
||||
|
||||
@@ -32,7 +32,10 @@ lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LI
|
||||
tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x]
|
||||
|
||||
# Get model metadata
|
||||
for model_name in ['driving_vision', 'driving_policy']:
|
||||
model_metadata_names = ['driving_vision', 'driving_policy']
|
||||
if File("models/driving_off_policy.onnx").exists():
|
||||
model_metadata_names.append('driving_off_policy')
|
||||
for model_name in model_metadata_names:
|
||||
fn = File(f"models/{model_name}").abspath
|
||||
script_files = [File(Dir("#frogpilot/tinygrad_modeld").File("get_model_metadata.py").abspath)]
|
||||
cmd = f'python3 {Dir("#frogpilot/tinygrad_modeld").abspath}/get_model_metadata.py {fn}.onnx'
|
||||
@@ -48,7 +51,10 @@ def tg_compile(flags, model_name):
|
||||
)
|
||||
|
||||
# Compile small models
|
||||
for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']:
|
||||
model_compile_names = ['driving_vision', 'driving_policy', 'dmonitoring_model']
|
||||
if File("models/driving_off_policy.onnx").exists():
|
||||
model_compile_names.append('driving_off_policy')
|
||||
for model_name in model_compile_names:
|
||||
flags = {
|
||||
'larch64': 'DEV=QCOM',
|
||||
'Darwin': 'DEV=CPU IMAGE=0',
|
||||
|
||||
@@ -11,16 +11,15 @@ ConfidenceClass = log.ModelDataV2.ConfidenceClass
|
||||
|
||||
# Return curvature for lateral action. If the model outputs desired_curvature and we're not in mlsim mode,
|
||||
# use it directly; otherwise derive from the plan using yaw and yaw-rate.
|
||||
def get_curvature_from_output(output: dict, v_ego: float, lat_action_t: float, mlsim: bool) -> float:
|
||||
def get_curvature_from_output(output: dict, plan: np.ndarray, v_ego: float, lat_action_t: float, mlsim: bool) -> float:
|
||||
if not mlsim:
|
||||
desired = output.get('desired_curvature')
|
||||
if desired is not None:
|
||||
return float(desired[0, 0])
|
||||
|
||||
plan_out = output['plan'][0]
|
||||
# Use yaw (index 2) and yaw_rate (index 2)
|
||||
theta = plan_out[:, Plan.T_FROM_CURRENT_EULER][:, 2]
|
||||
theta_dot = plan_out[:, Plan.ORIENTATION_RATE][:, 2]
|
||||
theta = plan[:, Plan.T_FROM_CURRENT_EULER][:, 2]
|
||||
theta_dot = plan[:, Plan.ORIENTATION_RATE][:, 2]
|
||||
return float(get_curvature_from_plan(theta, theta_dot, ModelConstants.T_IDXS, v_ego, lat_action_t))
|
||||
|
||||
|
||||
|
||||
@@ -97,12 +97,16 @@ class Parser:
|
||||
self.parse_mdn('lead', outs, in_N=ModelConstants.LEAD_MHP_N, out_N=ModelConstants.LEAD_MHP_SELECTION,
|
||||
out_shape=(ModelConstants.LEAD_TRAJ_LEN, ModelConstants.LEAD_WIDTH))
|
||||
if 'plan' in outs:
|
||||
if outs['plan'].shape[1] == 2 * ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH:
|
||||
self.parse_mdn('plan', outs, in_N=0, out_N=0,
|
||||
out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
||||
else:
|
||||
self.parse_mdn('plan', outs, in_N=ModelConstants.PLAN_MHP_N, out_N=ModelConstants.PLAN_MHP_SELECTION,
|
||||
out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
||||
plan_mhp = outs['plan'].shape[1] != 2 * ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH
|
||||
plan_in_N, plan_out_N = (ModelConstants.PLAN_MHP_N, ModelConstants.PLAN_MHP_SELECTION) if plan_mhp else (0, 0)
|
||||
self.parse_mdn('plan', outs, in_N=plan_in_N, out_N=plan_out_N,
|
||||
out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
||||
if 'planplus' in outs:
|
||||
self.parse_mdn('planplus', outs, in_N=plan_in_N, out_N=plan_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
||||
elif 'planplus' in outs:
|
||||
planplus_mhp = outs['planplus'].shape[1] != 2 * ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH
|
||||
planplus_in_N, planplus_out_N = (ModelConstants.PLAN_MHP_N, ModelConstants.PLAN_MHP_SELECTION) if planplus_mhp else (0, 0)
|
||||
self.parse_mdn('planplus', outs, in_N=planplus_in_N, out_N=planplus_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
||||
if 'lane_lines' in outs:
|
||||
self.parse_mdn('lane_lines', outs, in_N=0, out_N=0,
|
||||
out_shape=(ModelConstants.NUM_LANE_LINES, ModelConstants.IDX_N, ModelConstants.LANE_LINES_WIDTH))
|
||||
|
||||
@@ -35,14 +35,16 @@ PROCESS_NAME = "frogpilot.tinygrad_modeld.tinygrad_modeld"
|
||||
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
|
||||
|
||||
|
||||
LAT_SMOOTH_SECONDS = 0.1
|
||||
LAT_SMOOTH_SECONDS = 0.0
|
||||
LONG_SMOOTH_SECONDS = 0.3
|
||||
MIN_LAT_CONTROL_SPEED = 0.3
|
||||
|
||||
|
||||
def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action,
|
||||
lat_action_t: float, long_action_t: float, v_ego: float, mlsim: bool, is_v9: bool) -> log.ModelDataV2.Action:
|
||||
lat_action_t: float, long_action_t: float, v_ego: float, mlsim: bool, is_v9: bool, frogpilot_toggles) -> log.ModelDataV2.Action:
|
||||
plan = model_output['plan'][0]
|
||||
if 'planplus' in model_output:
|
||||
plan = plan + frogpilot_toggles.recovery_power*model_output['planplus'][0]
|
||||
desired_accel, should_stop = get_accel_from_plan_tomb_raider(plan[:,Plan.VELOCITY][:,0],
|
||||
plan[:,Plan.ACCELERATION][:,0],
|
||||
ModelConstants.T_IDXS,
|
||||
@@ -56,7 +58,7 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log.
|
||||
else:
|
||||
desired_curvature = prev_action.desiredCurvature
|
||||
else:
|
||||
desired_curvature = get_curvature_from_output(model_output, v_ego, lat_action_t, mlsim=mlsim)
|
||||
desired_curvature = get_curvature_from_output(model_output, plan, v_ego, lat_action_t, mlsim=mlsim)
|
||||
if v_ego > MIN_LAT_CONTROL_SPEED:
|
||||
desired_curvature = smooth_value(desired_curvature, prev_action.desiredCurvature, LAT_SMOOTH_SECONDS)
|
||||
else:
|
||||
@@ -81,6 +83,34 @@ class ModelState:
|
||||
output: np.ndarray
|
||||
prev_desire: np.ndarray # for tracking the rising edge of the pulse
|
||||
|
||||
def _build_policy_inputs(self, input_shapes: dict[str, tuple[int, ...]]) -> tuple[dict[str, np.ndarray], str | None]:
|
||||
numpy_inputs: dict[str, np.ndarray] = {}
|
||||
|
||||
# Always-supported inputs (if model expects them)
|
||||
desire_key_init = next((k for k in input_shapes if k.startswith('desire')), None)
|
||||
if desire_key_init:
|
||||
numpy_inputs[desire_key_init] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.DESIRE_LEN), dtype=np.float32)
|
||||
if 'traffic_convention' in input_shapes:
|
||||
numpy_inputs['traffic_convention'] = np.zeros((1, ModelConstants.TRAFFIC_CONVENTION_LEN), dtype=np.float32)
|
||||
if 'features_buffer' in input_shapes:
|
||||
numpy_inputs['features_buffer'] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32)
|
||||
|
||||
# Optional inputs for non-v11 (and some v10/v9 variants)
|
||||
# Lateral control params
|
||||
if 'lateral_control_params' in input_shapes:
|
||||
numpy_inputs['lateral_control_params'] = np.zeros((1, ModelConstants.LATERAL_CONTROL_PARAMS_LEN), dtype=np.float32)
|
||||
|
||||
# Previous desired curvature: handle both singular and plural key names across model versions
|
||||
prev_desired_curv_key = None
|
||||
if 'prev_desired_curv' in input_shapes:
|
||||
prev_desired_curv_key = 'prev_desired_curv'
|
||||
numpy_inputs['prev_desired_curv'] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.PREV_DESIRED_CURV_LEN), dtype=np.float32)
|
||||
elif 'prev_desired_curvs' in input_shapes:
|
||||
prev_desired_curv_key = 'prev_desired_curvs'
|
||||
numpy_inputs['prev_desired_curvs'] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.PREV_DESIRED_CURV_LEN), dtype=np.float32)
|
||||
|
||||
return numpy_inputs, prev_desired_curv_key
|
||||
|
||||
def __init__(self, context: CLContext):
|
||||
# Dynamically build paths based on current model ID
|
||||
params = Params()
|
||||
@@ -99,13 +129,17 @@ class ModelState:
|
||||
models_dir = Path(__file__).parent / "models"
|
||||
VISION_PKL_PATH = models_dir / "driving_vision_tinygrad.pkl"
|
||||
POLICY_PKL_PATH = models_dir / "driving_policy_tinygrad.pkl"
|
||||
OFF_POLICY_PKL_PATH = models_dir / "driving_off_policy_tinygrad.pkl"
|
||||
VISION_METADATA_PATH = models_dir / "driving_vision_metadata.pkl"
|
||||
POLICY_METADATA_PATH = models_dir / "driving_policy_metadata.pkl"
|
||||
OFF_POLICY_METADATA_PATH = models_dir / "driving_off_policy_metadata.pkl"
|
||||
else:
|
||||
VISION_PKL_PATH = model_dir / f"{model_id}_driving_vision_tinygrad.pkl"
|
||||
POLICY_PKL_PATH = model_dir / f"{model_id}_driving_policy_tinygrad.pkl"
|
||||
OFF_POLICY_PKL_PATH = model_dir / f"{model_id}_driving_off_policy_tinygrad.pkl"
|
||||
VISION_METADATA_PATH = model_dir / f"{model_id}_driving_vision_metadata.pkl"
|
||||
POLICY_METADATA_PATH = model_dir / f"{model_id}_driving_policy_metadata.pkl"
|
||||
OFF_POLICY_METADATA_PATH = model_dir / f"{model_id}_driving_off_policy_metadata.pkl"
|
||||
|
||||
# If ModelVersion is not set or not available, try to determine it from available model data
|
||||
if not model_version:
|
||||
@@ -156,8 +190,10 @@ class ModelState:
|
||||
# Add policy_generation attribute after loading policy_metadata
|
||||
self.policy_generation = model_version or "v8"
|
||||
self.is_v11 = (self.policy_generation == "v11")
|
||||
self.is_v10 = (self.policy_generation == "v10")
|
||||
self.is_v12 = (self.policy_generation == "v12")
|
||||
self.is_v9 = (self.policy_generation == "v9")
|
||||
self.mlsim = (self.policy_generation in ("v8", "v10", "v11"))
|
||||
self.mlsim = (self.policy_generation in ("v8", "v10", "v11", "v12"))
|
||||
|
||||
self.frames = {name: DrivingModelFrame(context, ModelConstants.TEMPORAL_SKIP) for name in self.vision_input_names}
|
||||
self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32)
|
||||
@@ -168,33 +204,50 @@ class ModelState:
|
||||
|
||||
|
||||
# policy inputs (built dynamically to support all generations)
|
||||
self.numpy_inputs = {}
|
||||
self.numpy_inputs, self.prev_desired_curv_key = self._build_policy_inputs(self.policy_input_shapes)
|
||||
|
||||
# Always-supported inputs (if model expects them)
|
||||
desire_key_init = next((k for k in self.policy_input_shapes if k.startswith('desire')), None)
|
||||
if desire_key_init:
|
||||
self.numpy_inputs[desire_key_init] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.DESIRE_LEN), dtype=np.float32)
|
||||
if 'traffic_convention' in self.policy_input_shapes:
|
||||
self.numpy_inputs['traffic_convention'] = np.zeros((1, ModelConstants.TRAFFIC_CONVENTION_LEN), dtype=np.float32)
|
||||
if 'features_buffer' in self.policy_input_shapes:
|
||||
self.numpy_inputs['features_buffer'] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.FEATURE_LEN), dtype=np.float32)
|
||||
# Off-policy model (optional)
|
||||
self.off_policy_enabled = False
|
||||
self.off_policy_input_shapes: dict[str, tuple[int, ...]] = {}
|
||||
self.off_policy_output_slices: dict[str, slice] = {}
|
||||
self.off_policy_numpy_inputs: dict[str, np.ndarray] = {}
|
||||
self.off_policy_prev_desired_curv_key: str | None = None
|
||||
self.off_policy_desire_key: str | None = None
|
||||
self.off_policy_inputs: dict[str, Tensor] | None = None
|
||||
self.off_policy_output: np.ndarray | None = None
|
||||
|
||||
# Optional inputs for non-v11 (and some v10/v9 variants)
|
||||
# Lateral control params
|
||||
if 'lateral_control_params' in self.policy_input_shapes:
|
||||
self.numpy_inputs['lateral_control_params'] = np.zeros((1, ModelConstants.LATERAL_CONTROL_PARAMS_LEN), dtype=np.float32)
|
||||
off_policy_metadata = None
|
||||
if self.policy_generation == "v12" or OFF_POLICY_METADATA_PATH.is_file() or OFF_POLICY_PKL_PATH.is_file():
|
||||
try:
|
||||
with open(OFF_POLICY_METADATA_PATH, 'rb') as f:
|
||||
off_policy_metadata = pickle.load(f)
|
||||
except FileNotFoundError:
|
||||
cloudlog.error(f"Missing metadata {OFF_POLICY_METADATA_PATH}, downloading...")
|
||||
from openpilot.frogpilot.assets.model_manager import ModelManager
|
||||
ModelManager().download_model(model_id)
|
||||
try:
|
||||
with open(OFF_POLICY_METADATA_PATH, 'rb') as f:
|
||||
off_policy_metadata = pickle.load(f)
|
||||
except FileNotFoundError:
|
||||
cloudlog.warning(f"Off-policy metadata still missing: {OFF_POLICY_METADATA_PATH}")
|
||||
|
||||
# Previous desired curvature: handle both singular and plural key names across model versions
|
||||
self.prev_desired_curv_key = None
|
||||
if 'prev_desired_curv' in self.policy_input_shapes:
|
||||
self.prev_desired_curv_key = 'prev_desired_curv'
|
||||
self.numpy_inputs['prev_desired_curv'] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.PREV_DESIRED_CURV_LEN), dtype=np.float32)
|
||||
elif 'prev_desired_curvs' in self.policy_input_shapes:
|
||||
self.prev_desired_curv_key = 'prev_desired_curvs'
|
||||
self.numpy_inputs['prev_desired_curvs'] = np.zeros((1, ModelConstants.INPUT_HISTORY_BUFFER_LEN, ModelConstants.PREV_DESIRED_CURV_LEN), dtype=np.float32)
|
||||
if off_policy_metadata is not None:
|
||||
self.off_policy_input_shapes = off_policy_metadata['input_shapes']
|
||||
self.off_policy_output_slices = off_policy_metadata['output_slices']
|
||||
off_policy_output_size = off_policy_metadata['output_shapes']['outputs'][1]
|
||||
self.off_policy_numpy_inputs, self.off_policy_prev_desired_curv_key = self._build_policy_inputs(self.off_policy_input_shapes)
|
||||
self.off_policy_desire_key = next((k for k in self.off_policy_numpy_inputs if k.startswith('desire')), None)
|
||||
self.off_policy_inputs = {k: Tensor(v, device='NPY').realize() for k, v in self.off_policy_numpy_inputs.items()}
|
||||
self.off_policy_output = np.zeros(off_policy_output_size, dtype=np.float32)
|
||||
try:
|
||||
with open(OFF_POLICY_PKL_PATH, "rb") as f:
|
||||
self.off_policy_run = pickle.load(f)
|
||||
self.off_policy_enabled = True
|
||||
except FileNotFoundError:
|
||||
cloudlog.warning(f"Missing off-policy model {OFF_POLICY_PKL_PATH}, skipping off-policy")
|
||||
|
||||
# Optional temporal buffer for previous desired curvature (allocate only if the policy expects it)
|
||||
if getattr(self, 'prev_desired_curv_key', None) is not None:
|
||||
# Optional temporal buffer for previous desired curvature (allocate only if any model expects it)
|
||||
if self.prev_desired_curv_key is not None or self.off_policy_prev_desired_curv_key is not None:
|
||||
self.full_prev_desired_curv = np.zeros((1, ModelConstants.FULL_HISTORY_BUFFER_LEN, ModelConstants.PREV_DESIRED_CURV_LEN), dtype=np.float32)
|
||||
|
||||
|
||||
@@ -204,6 +257,7 @@ class ModelState:
|
||||
self.policy_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()}
|
||||
self.policy_output = np.zeros(policy_output_size, dtype=np.float32)
|
||||
self.parser = Parser()
|
||||
self.off_policy_parser = Parser(ignore_missing=True)
|
||||
|
||||
with open(VISION_PKL_PATH, "rb") as f:
|
||||
self.vision_run = pickle.load(f)
|
||||
@@ -229,10 +283,18 @@ class ModelState:
|
||||
self.full_desire[0,:-1] = self.full_desire[0,1:]
|
||||
self.full_desire[0,-1] = new_desire
|
||||
self.numpy_inputs[self.desire_key][:] = self.full_desire.reshape((1,ModelConstants.INPUT_HISTORY_BUFFER_LEN,ModelConstants.TEMPORAL_SKIP,-1)).max(axis=2)
|
||||
if self.off_policy_enabled and self.off_policy_desire_key is not None:
|
||||
self.off_policy_numpy_inputs[self.off_policy_desire_key][:] = self.numpy_inputs[self.desire_key]
|
||||
|
||||
if 'traffic_convention' in self.numpy_inputs:
|
||||
self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention']
|
||||
if self.off_policy_enabled and 'traffic_convention' in self.off_policy_numpy_inputs:
|
||||
self.off_policy_numpy_inputs['traffic_convention'][:] = inputs['traffic_convention']
|
||||
|
||||
self.numpy_inputs['traffic_convention'][:] = inputs['traffic_convention']
|
||||
if 'lateral_control_params' in self.numpy_inputs:
|
||||
self.numpy_inputs['lateral_control_params'][:] = inputs['lateral_control_params']
|
||||
if self.off_policy_enabled and 'lateral_control_params' in self.off_policy_numpy_inputs:
|
||||
self.off_policy_numpy_inputs['lateral_control_params'][:] = inputs['lateral_control_params']
|
||||
|
||||
if prepare_only:
|
||||
return None
|
||||
@@ -254,7 +316,10 @@ class ModelState:
|
||||
|
||||
self.full_features_buffer[0,:-1] = self.full_features_buffer[0,1:]
|
||||
self.full_features_buffer[0,-1] = vision_outputs_dict['hidden_state'][0, :]
|
||||
self.numpy_inputs['features_buffer'][:] = self.full_features_buffer[0, self.temporal_idxs]
|
||||
if 'features_buffer' in self.numpy_inputs:
|
||||
self.numpy_inputs['features_buffer'][:] = self.full_features_buffer[0, self.temporal_idxs]
|
||||
if self.off_policy_enabled and 'features_buffer' in self.off_policy_numpy_inputs:
|
||||
self.off_policy_numpy_inputs['features_buffer'][:] = self.full_features_buffer[0, self.temporal_idxs]
|
||||
|
||||
self.policy_output = self.policy_run(**self.policy_inputs).contiguous().realize().uop.base.buffer.numpy()
|
||||
policy_outputs_dict = self.parser.parse_policy_outputs(self.slice_outputs(self.policy_output, self.policy_output_slices))
|
||||
@@ -265,15 +330,30 @@ class ModelState:
|
||||
self.full_prev_desired_curv[0,-1,:] = policy_outputs_dict['desired_curvature'][0, :]
|
||||
|
||||
if self.prev_desired_curv_key is not None:
|
||||
# v9 models expect zeros for prev_desired_curv(s); others use history
|
||||
if self.is_v9:
|
||||
# v9/v10/v11/v12 models expect zeros for prev_desired_curv(s); others use history
|
||||
if self.is_v9 or self.is_v10 or self.is_v11 or self.is_v12:
|
||||
self.numpy_inputs[self.prev_desired_curv_key][:] = 0 * self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||
else:
|
||||
self.numpy_inputs[self.prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||
|
||||
if self.off_policy_enabled and self.off_policy_prev_desired_curv_key is not None:
|
||||
if self.is_v9 or self.is_v12:
|
||||
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = 0 * self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||
else:
|
||||
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||
|
||||
combined_outputs_dict = {**vision_outputs_dict, **policy_outputs_dict}
|
||||
if self.off_policy_enabled:
|
||||
self.off_policy_output = self.off_policy_run(**self.off_policy_inputs).contiguous().realize().uop.base.buffer.numpy()
|
||||
off_policy_outputs_dict = self.off_policy_parser.parse_policy_outputs(
|
||||
self.slice_outputs(self.off_policy_output, self.off_policy_output_slices)
|
||||
)
|
||||
combined_outputs_dict.update(off_policy_outputs_dict)
|
||||
if SEND_RAW_PRED:
|
||||
combined_outputs_dict['raw_pred'] = np.concatenate([self.vision_output.copy(), self.policy_output.copy()])
|
||||
raw_pred = [self.vision_output.copy(), self.policy_output.copy()]
|
||||
if self.off_policy_enabled and self.off_policy_output is not None:
|
||||
raw_pred.append(self.off_policy_output.copy())
|
||||
combined_outputs_dict['raw_pred'] = np.concatenate(raw_pred)
|
||||
|
||||
return combined_outputs_dict
|
||||
|
||||
@@ -442,7 +522,7 @@ def main(demo=False):
|
||||
drivingdata_send = messaging.new_message('drivingModelData')
|
||||
posenet_send = messaging.new_message('cameraOdometry')
|
||||
|
||||
action = get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego, model.mlsim, model.is_v9)
|
||||
action = get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego, model.mlsim, model.is_v9, frogpilot_toggles)
|
||||
prev_action = action
|
||||
fill_model_msg(drivingdata_send, modelv2_send, model_output, action,
|
||||
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
|
||||
@@ -481,4 +561,4 @@ if __name__ == "__main__":
|
||||
cloudlog.warning(f"child {PROCESS_NAME} got SIGINT")
|
||||
except Exception:
|
||||
sentry.capture_exception()
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -37,6 +37,7 @@ FrogPilotDevicePanel::FrogPilotDevicePanel(FrogPilotSettingsWindow *parent) : Fr
|
||||
{"DeviceShutdown", tr("Device Shutdown Timer"), tr("<b>Keep the device on for the set amount of time after a drive</b> before it shuts down automatically."), ""},
|
||||
{"NoLogging", tr("Disable Logging"), QString("<b>%1</b><br><br>%2").arg(tr("WARNING: This will prevent your drives from being recorded and all data will be unobtainable!")).arg(tr("<b>Prevent the device from saving driving data.</b>")), ""},
|
||||
{"NoUploads", tr("Disable Uploads"), QString("<b>%1</b><br><br>%2").arg(tr("WARNING: This will prevent your drives from being uploaded to <b>comma connect</b> which will impact debugging and official support from comma!")).arg(tr("<b>Prevent the device from uploading driving data.</b>")), ""},
|
||||
{"AlwaysAllowUploads", tr("Always Allow Uploads"), tr("<b>Force driving data uploads regardless of network type, metered status, and upload restrictions.</b> This overrides normal upload blocking checks."), ""},
|
||||
{"HigherBitrate", tr("High-Quality Recording"), tr("<b>Save drive footage in higher video quality.</b>"), ""},
|
||||
{"LowVoltageShutdown", tr("Low-Voltage Cutoff"), tr("<b>While parked, if the battery voltage falls below the set level, the device shuts down</b> to prevent excessive battery drain."), ""},
|
||||
{"IncreaseThermalLimits", tr("Raise Temperature Limits"), QString("<b>%1</b><br><br>%2").arg(tr("WARNING: Running at higher temperatures may damage your device!")).arg(tr("<b>Allow the device to run at higher temperatures</b> before throttling or shutting down. Use only if you understand the risks!")), ""},
|
||||
|
||||
@@ -25,7 +25,7 @@ private:
|
||||
|
||||
std::map<QString, AbstractControl*> toggles;
|
||||
|
||||
QSet<QString> deviceManagementKeys = {"DeviceShutdown", "HigherBitrate", "IncreaseThermalLimits", "LowVoltageShutdown", "NoLogging", "NoUploads", "UseKonikServer"};
|
||||
QSet<QString> deviceManagementKeys = {"AlwaysAllowUploads", "DeviceShutdown", "HigherBitrate", "IncreaseThermalLimits", "LowVoltageShutdown", "NoLogging", "NoUploads", "UseKonikServer"};
|
||||
QSet<QString> screenKeys = {"ScreenBrightness", "ScreenBrightnessOnroad", "ScreenRecorder", "ScreenTimeout", "ScreenTimeoutOnroad", "StandbyMode"};
|
||||
|
||||
QSet<QString> parentKeys;
|
||||
|
||||
@@ -269,10 +269,14 @@ void FrogPilotSettingsWindow::updateVariables() {
|
||||
hasPedal = CP.getEnableGasInterceptor();
|
||||
hasRadar = !CP.getRadarUnavailable();
|
||||
hasSDSU = frogpilot_toggles.value("has_sdsu").toBool();
|
||||
hasSASCM = frogpilot_toggles.value("has_sascm").toBool();
|
||||
hasSNG = hasOpenpilotLongitudinal && CP.getAutoResumeSng();
|
||||
hasZSS = frogpilot_toggles.value("has_zss").toBool();
|
||||
isAngleCar = CP.getSteerControlType() == cereal::CarParams::SteerControlType::ANGLE;
|
||||
isBolt = carFingerprint == "CHEVROLET_BOLT_CC" || carFingerprint == "CHEVROLET_BOLT_EUV";
|
||||
isBolt = carFingerprint == "CHEVROLET_BOLT_ACC_2022_2023" ||
|
||||
carFingerprint == "CHEVROLET_BOLT_CC_2022_2023" ||
|
||||
carFingerprint == "CHEVROLET_BOLT_CC_2019_2021" ||
|
||||
carFingerprint == "CHEVROLET_BOLT_CC_2017";
|
||||
isGM = carMake == "gm";
|
||||
isHKG = carMake == "hyundai";
|
||||
isHKGCanFd = isHKG && safetyModel == cereal::CarParams::SafetyModel::HYUNDAI_CANFD;
|
||||
@@ -280,10 +284,12 @@ void FrogPilotSettingsWindow::updateVariables() {
|
||||
isTorqueCar = CP.getLateralTuning().which() == cereal::CarParams::LateralTuning::TORQUE;
|
||||
isToyota = carMake == "toyota";
|
||||
isTSK = CP.getSecOcRequired();
|
||||
isVolt = carFingerprint == "CHEVROLET_VOLT";
|
||||
isVolt = carFingerprint.find("CHEVROLET_VOLT") == 0;
|
||||
if (isVolt) hasSNG = false;
|
||||
longitudinalActuatorDelay = CP.getLongitudinalActuatorDelay();
|
||||
startAccel = CP.getStartAccel();
|
||||
steerActuatorDelay = CP.getSteerActuatorDelay();
|
||||
steerOffset = 0.0f;
|
||||
steerKp = CP.getLateralTuning().which() == cereal::CarParams::LateralTuning::PID ? CP.getLateralTuning().getPid().getKpV()[0] : 0.6;
|
||||
steerRatio = CP.getSteerRatio();
|
||||
stopAccel = CP.getStopAccel();
|
||||
@@ -295,6 +301,7 @@ void FrogPilotSettingsWindow::updateVariables() {
|
||||
|
||||
float currentDelayStock = params.getFloat("SteerDelayStock");
|
||||
float currentFrictionStock = params.getFloat("SteerFrictionStock");
|
||||
float currentSteerOffsetStock = params.getFloat("SteerOffsetStock");
|
||||
float currentKPStock = params.getFloat("SteerKPStock");
|
||||
float currentLatAccelStock = params.getFloat("SteerLatAccelStock");
|
||||
float currentLongDelayStock = params.getFloat("LongitudinalActuatorDelayStock");
|
||||
@@ -319,6 +326,13 @@ void FrogPilotSettingsWindow::updateVariables() {
|
||||
params.putFloat("SteerFrictionStock", friction);
|
||||
}
|
||||
|
||||
if (currentSteerOffsetStock != steerOffset) {
|
||||
if (params.getFloat("SteerOffset") == currentSteerOffsetStock) {
|
||||
params.putFloat("SteerOffset", steerOffset);
|
||||
}
|
||||
params.putFloat("SteerOffsetStock", steerOffset);
|
||||
}
|
||||
|
||||
if (currentKPStock != steerKp && steerKp != 0) {
|
||||
if (params.getFloat("SteerKP") == currentKPStock || currentKPStock == 0) {
|
||||
params.putFloat("SteerKP", steerKp);
|
||||
@@ -391,6 +405,7 @@ void FrogPilotSettingsWindow::updateVariables() {
|
||||
|
||||
canUsePedal = FPCP.getCanUsePedal();
|
||||
canUseSDSU = FPCP.getCanUseSDSU();
|
||||
canUseSASCM = FPCP.getCanUseSASCM();
|
||||
openpilotLongitudinalControlDisabled = FPCP.getOpenpilotLongitudinalControlDisabled();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public:
|
||||
|
||||
bool canUsePedal = false;
|
||||
bool canUseSDSU = false;
|
||||
bool canUseSASCM = false;
|
||||
bool forceOpenDescriptions = false;
|
||||
bool hasAutoTune = true;
|
||||
bool hasBSM = true;
|
||||
@@ -24,6 +25,7 @@ public:
|
||||
bool hasPedal = false;
|
||||
bool hasRadar = true;
|
||||
bool hasSDSU = false;
|
||||
bool hasSASCM = false;
|
||||
bool hasSNG = false;
|
||||
bool hasZSS = false;
|
||||
bool isAngleCar = false;
|
||||
@@ -45,6 +47,7 @@ public:
|
||||
float longitudinalActuatorDelay;
|
||||
float startAccel;
|
||||
float steerActuatorDelay;
|
||||
float steerOffset;
|
||||
float steerKp;
|
||||
float steerRatio;
|
||||
float stopAccel;
|
||||
|
||||
@@ -41,6 +41,7 @@ FrogPilotLateralPanel::FrogPilotLateralPanel(FrogPilotSettingsWindow *parent) :
|
||||
{"AdvancedLateralTune", tr("Advanced Lateral Tuning"), tr("<b>Advanced steering control changes to fine-tune how openpilot drives.</b>"), "../../frogpilot/assets/toggle_icons/icon_advanced_lateral_tune.png"},
|
||||
{"SteerDelay", parent->steerActuatorDelay != 0 ? QString(tr("Actuator Delay (Default: %1)")).arg(QString::number(parent->steerActuatorDelay, 'f', 2)) : tr("Actuator Delay"), tr("<b>The time between openpilot's steering command and the vehicle's response.</b> Increase if the vehicle reacts late; decrease if it feels jumpy. Auto-learned by default."), ""},
|
||||
{"SteerFriction", parent->friction != 0 ? QString(tr("Friction (Default: %1)")).arg(QString::number(parent->friction, 'f', 2)) : tr("Friction"), tr("<b>Compensates for steering friction.</b> Increase if the wheel sticks near center; decrease if it jitters. Auto-learned by default."), ""},
|
||||
{"SteerOffset", parent->steerOffset != 0 ? QString(tr("Steer Offset (Default: %1)")).arg(QString::number(parent->steerOffset, 'f', 3)) : tr("Steer Offset"), tr("<b>Offsets steering torque to help compensate for alignment or tire issues.</b> More negative pulls the car right; more positive pulls it left. Most users should not need to touch this."), ""},
|
||||
{"SteerKP", parent->steerKp != 0 ? QString(tr("Kp Factor (Default: %1)")).arg(QString::number(parent->steerKp, 'f', 2)) : tr("Kp Factor"), tr("<b>How strongly openpilot corrects lane position.</b> Higher is tighter but twitchier; lower is smoother but slower. Auto-learned by default."), ""},
|
||||
{"SteerLatAccel", parent->latAccelFactor != 0 ? QString(tr("Lateral Acceleration (Default: %1)")).arg(QString::number(parent->latAccelFactor, 'f', 2)) : tr("Lateral Acceleration"), tr("<b>Maps steering torque to turning response.</b> Increase for sharper turns; decrease for gentler steering. Auto-learned by default."), ""},
|
||||
{"SteerRatio", parent->steerRatio != 0 ? QString(tr("Steer Ratio (Default: %1)")).arg(QString::number(parent->steerRatio, 'f', 2)) : tr("Steer Ratio"), tr("<b>The relationship between steering wheel rotation and road wheel angle.</b> Increase if steering feels too quick or twitchy; decrease if it feels too slow or weak. Auto-learned by default."), ""},
|
||||
@@ -87,15 +88,18 @@ FrogPilotLateralPanel::FrogPilotLateralPanel(FrogPilotSettingsWindow *parent) :
|
||||
} else if (param == "SteerFriction") {
|
||||
std::vector<QString> steerFrictionButton{"Reset"};
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, 0, 0.5, QString(), std::map<float, QString>(), 0.01, false, {}, steerFrictionButton, false, false);
|
||||
} else if (param == "SteerOffset") {
|
||||
std::vector<QString> steerOffsetButton{"Reset"};
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, -0.2, 0.2, QString(), std::map<float, QString>(), 0.005, false, {}, steerOffsetButton, false, false);
|
||||
} else if (param == "SteerKP") {
|
||||
std::vector<QString> steerKPButton{"Reset"};
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, parent->steerKp * 0.5, parent->steerKp * 1.5, QString(), std::map<float, QString>(), 0.01, false, {}, steerKPButton, false, false);
|
||||
} else if (param == "SteerLatAccel") {
|
||||
std::vector<QString> steerLatAccelButton{"Reset"};
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, parent->latAccelFactor * 0.75, parent->latAccelFactor * 1.25, QString(), std::map<float, QString>(), 0.01, false, {}, steerLatAccelButton, false, false);
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, parent->latAccelFactor * 0.5, parent->latAccelFactor * 1.25, QString(), std::map<float, QString>(), 0.01, false, {}, steerLatAccelButton, false, false);
|
||||
} else if (param == "SteerRatio") {
|
||||
std::vector<QString> steerRatioButton{"Reset"};
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, parent->steerRatio * 0.5, parent->steerRatio * 1.5, QString(), std::map<float, QString>(), 0.01, false, {}, steerRatioButton, false, false);
|
||||
lateralToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, parent->steerRatio * 0.25, parent->steerRatio * 1.5, QString(), std::map<float, QString>(), 0.01, false, {}, steerRatioButton, false, false);
|
||||
|
||||
} else if (param == "AlwaysOnLateral") {
|
||||
FrogPilotManageControl *aolToggle = new FrogPilotManageControl(param, title, desc, icon);
|
||||
@@ -216,6 +220,14 @@ FrogPilotLateralPanel::FrogPilotLateralPanel(FrogPilotSettingsWindow *parent) :
|
||||
}
|
||||
});
|
||||
|
||||
steerOffsetToggle = static_cast<FrogPilotParamValueButtonControl*>(toggles["SteerOffset"]);
|
||||
QObject::connect(steerOffsetToggle, &FrogPilotParamValueButtonControl::buttonClicked, [parent, this]() {
|
||||
if (FrogPilotConfirmationDialog::yesorno(tr("Reset <b>Steer Offset</b> to its default value?"), this)) {
|
||||
params.putFloat("SteerOffset", parent->steerOffset);
|
||||
steerOffsetToggle->refresh();
|
||||
}
|
||||
});
|
||||
|
||||
steerKPToggle = static_cast<FrogPilotParamValueButtonControl*>(toggles["SteerKP"]);
|
||||
QObject::connect(steerKPToggle, &FrogPilotParamValueButtonControl::buttonClicked, [parent, this]() {
|
||||
if (FrogPilotConfirmationDialog::yesorno(tr("Reset <b>Kp Factor</b> to its default value?"), this)) {
|
||||
@@ -255,12 +267,13 @@ void FrogPilotLateralPanel::showEvent(QShowEvent *event) {
|
||||
|
||||
steerDelayToggle->setTitle(QString(tr("Actuator Delay (Default: %1)")).arg(QString::number(parent->steerActuatorDelay, 'f', 2)));
|
||||
steerFrictionToggle->setTitle(QString(tr("Friction (Default: %1)")).arg(QString::number(parent->friction, 'f', 2)));
|
||||
steerOffsetToggle->setTitle(QString(tr("Steer Offset (Default: %1)")).arg(QString::number(parent->steerOffset, 'f', 3)));
|
||||
steerKPToggle->setTitle(QString(tr("Kp Factor (Default: %1)")).arg(QString::number(parent->steerKp, 'f', 2)));
|
||||
steerKPToggle->updateControl(parent->steerKp * 0.5, parent->steerKp * 1.5);
|
||||
steerLatAccelToggle->setTitle(QString(tr("Lateral Accel (Default: %1)")).arg(QString::number(parent->latAccelFactor, 'f', 2)));
|
||||
steerLatAccelToggle->updateControl(parent->latAccelFactor * 0.75, parent->latAccelFactor * 1.25);
|
||||
steerLatAccelToggle->updateControl(parent->latAccelFactor * 0.5, parent->latAccelFactor * 1.25);
|
||||
steerRatioToggle->setTitle(QString(tr("Steer Ratio (Default: %1)")).arg(QString::number(parent->steerRatio, 'f', 2)));
|
||||
steerRatioToggle->updateControl(parent->steerRatio * 0.5, parent->steerRatio * 1.5);
|
||||
steerRatioToggle->updateControl(parent->steerRatio * 0.25, parent->steerRatio * 1.5);
|
||||
|
||||
updateToggles();
|
||||
}
|
||||
@@ -340,7 +353,6 @@ void FrogPilotLateralPanel::updateToggles() {
|
||||
}
|
||||
}
|
||||
|
||||
bool forcingAutoTune = !parent->hasAutoTune && params.getBool("ForceAutoTune");
|
||||
bool forcingAutoTuneOff = parent->hasAutoTune && params.getBool("ForceAutoTuneOff");
|
||||
bool forcingTorqueController = !parent->isAngleCar && params.getBool("ForceTorqueController");
|
||||
bool usingNNFF = parent->hasNNFFLog && params.getBool("LateralTune") && params.getBool("NNFF");
|
||||
@@ -401,7 +413,13 @@ void FrogPilotLateralPanel::updateToggles() {
|
||||
|
||||
else if (key == "SteerFriction") {
|
||||
setVisible &= parent->friction != 0;
|
||||
setVisible &= parent->hasAutoTune ? forcingAutoTuneOff : !forcingAutoTune;
|
||||
setVisible &= parent->hasAutoTune ? forcingAutoTuneOff : true;
|
||||
setVisible &= parent->isTorqueCar || forcingTorqueController;
|
||||
setVisible &= !usingNNFF;
|
||||
}
|
||||
|
||||
else if (key == "SteerOffset") {
|
||||
setVisible &= parent->isGM;
|
||||
setVisible &= parent->isTorqueCar || forcingTorqueController;
|
||||
setVisible &= !usingNNFF;
|
||||
}
|
||||
@@ -413,14 +431,14 @@ void FrogPilotLateralPanel::updateToggles() {
|
||||
|
||||
else if (key == "SteerLatAccel") {
|
||||
setVisible &= parent->latAccelFactor != 0;
|
||||
setVisible &= parent->hasAutoTune ? forcingAutoTuneOff : !forcingAutoTune;
|
||||
setVisible &= parent->hasAutoTune ? forcingAutoTuneOff : true;
|
||||
setVisible &= parent->isTorqueCar || forcingTorqueController;
|
||||
setVisible &= !usingNNFF;
|
||||
}
|
||||
|
||||
else if (key == "SteerRatio") {
|
||||
setVisible &= parent->steerRatio != 0;
|
||||
setVisible &= parent->hasAutoTune ? forcingAutoTuneOff : !forcingAutoTune;
|
||||
setVisible &= parent->hasAutoTune ? forcingAutoTuneOff : true;
|
||||
}
|
||||
|
||||
toggle->setVisible(setVisible);
|
||||
|
||||
@@ -31,6 +31,7 @@ private:
|
||||
float friction;
|
||||
float latAccelFactor;
|
||||
float steerActuatorDelay;
|
||||
float steerOffset;
|
||||
float steerKp;
|
||||
float steerRatio;
|
||||
|
||||
@@ -38,7 +39,7 @@ private:
|
||||
|
||||
std::map<QString, AbstractControl*> toggles;
|
||||
|
||||
QSet<QString> advancedLateralTuneKeys = {"ForceAutoTune", "ForceAutoTuneOff", "ForceTorqueController", "SteerDelay", "SteerFriction", "SteerLatAccel", "SteerKP", "SteerRatio"};
|
||||
QSet<QString> advancedLateralTuneKeys = {"ForceAutoTune", "ForceAutoTuneOff", "ForceTorqueController", "SteerDelay", "SteerFriction", "SteerOffset", "SteerLatAccel", "SteerKP", "SteerRatio"};
|
||||
QSet<QString> aolKeys = {"AlwaysOnLateralLKAS", "AlwaysOnLateralMain", "PauseAOLOnBrake"};
|
||||
QSet<QString> laneChangeKeys = {"LaneChangeTime", "LaneDetectionWidth", "MinimumLaneChangeSpeed", "NudgelessLaneChange", "OneLaneChange"};
|
||||
QSet<QString> lateralTuneKeys = {"NNFF", "NNFFLite", "TurnDesires"};
|
||||
@@ -48,6 +49,7 @@ private:
|
||||
|
||||
FrogPilotParamValueButtonControl *steerDelayToggle;
|
||||
FrogPilotParamValueButtonControl *steerFrictionToggle;
|
||||
FrogPilotParamValueButtonControl *steerOffsetToggle;
|
||||
FrogPilotParamValueButtonControl *steerLatAccelToggle;
|
||||
FrogPilotParamValueButtonControl *steerKPToggle;
|
||||
FrogPilotParamValueButtonControl *steerRatioToggle;
|
||||
|
||||
@@ -40,19 +40,19 @@ private:
|
||||
|
||||
std::map<QString, AbstractControl*> toggles;
|
||||
|
||||
QSet<QString> advancedLongitudinalTuneKeys = {"LongitudinalActuatorDelay", "StartAccel", "StopAccel", "StoppingDecelRate", "VEgoStarting", "VEgoStopping"};
|
||||
QSet<QString> aggressivePersonalityKeys = {"AggressiveFollow", "AggressiveJerkAcceleration", "AggressiveJerkDeceleration", "AggressiveJerkDanger", "AggressiveJerkSpeed", "AggressiveJerkSpeedDecrease", "ResetAggressivePersonality"};
|
||||
QSet<QString> advancedLongitudinalTuneKeys = {"EVTuning", "TruckTuning", "LongitudinalActuatorDelay", "StartAccel", "StopAccel", "StoppingDecelRate", "VEgoStarting", "VEgoStopping"};
|
||||
QSet<QString> aggressivePersonalityKeys = {"AggressiveFollow", "AggressiveFollowHigh", "AggressiveJerkAcceleration", "AggressiveJerkDeceleration", "AggressiveJerkDanger", "AggressiveJerkSpeed", "AggressiveJerkSpeedDecrease", "ResetAggressivePersonality"};
|
||||
QSet<QString> conditionalExperimentalKeys = {"CESpeed", "CESpeedLead", "CECurves", "CELead", "CEModelStopTime", "CENavigation", "CESignalSpeed", "ShowCEMStatus"};
|
||||
QSet<QString> curveSpeedKeys = {"CalibratedLateralAcceleration", "CalibrationProgress", "ResetCurveData", "ShowCSCStatus"};
|
||||
QSet<QString> customDrivingPersonalityKeys = {"AggressivePersonalityProfile", "RelaxedPersonalityProfile", "StandardPersonalityProfile", "TrafficPersonalityProfile"};
|
||||
QSet<QString> longitudinalTuneKeys = {"AccelerationProfile", "DecelerationProfile", "HumanAcceleration", "HumanFollowing", "LeadDetectionThreshold", "MaxDesiredAcceleration", "TacoTune"};
|
||||
QSet<QString> longitudinalTuneKeys = {"AccelerationProfile", "DecelerationProfile", "HumanAcceleration", "HumanFollowing", "LeadDetectionThreshold", "MaxDesiredAcceleration", "TrailerLoad", "TacoTune"};
|
||||
QSet<QString> qolKeys = {"CustomCruise", "CustomCruiseLong", "ForceStops", "IncreasedStoppedDistance", "MapGears", "ReverseCruise", "SetSpeedOffset"};
|
||||
QSet<QString> relaxedPersonalityKeys = {"RelaxedFollow", "RelaxedJerkAcceleration", "RelaxedJerkDeceleration", "RelaxedJerkDanger", "RelaxedJerkSpeed", "RelaxedJerkSpeedDecrease", "ResetRelaxedPersonality"};
|
||||
QSet<QString> relaxedPersonalityKeys = {"RelaxedFollow", "RelaxedFollowHigh", "RelaxedJerkAcceleration", "RelaxedJerkDeceleration", "RelaxedJerkDanger", "RelaxedJerkSpeed", "RelaxedJerkSpeedDecrease", "ResetRelaxedPersonality"};
|
||||
QSet<QString> speedLimitControllerKeys = {"SLCOffsets", "SLCFallback", "SLCOverride", "SLCPriority", "SLCQOL", "SLCVisuals"};
|
||||
QSet<QString> speedLimitControllerOffsetsKeys = {"Offset1", "Offset2", "Offset3", "Offset4", "Offset5", "Offset6", "Offset7"};
|
||||
QSet<QString> speedLimitControllerQOLKeys = {"ForceMPHDashboard", "SetSpeedLimit", "SLCConfirmation", "SLCLookaheadHigher", "SLCLookaheadLower", "SLCMapboxFiller"};
|
||||
QSet<QString> speedLimitControllerVisualKeys = {"ShowSLCOffset", "SpeedLimitSources"};
|
||||
QSet<QString> standardPersonalityKeys = {"StandardFollow", "StandardJerkAcceleration", "StandardJerkDeceleration", "StandardJerkDanger", "StandardJerkSpeed", "StandardJerkSpeedDecrease", "ResetStandardPersonality"};
|
||||
QSet<QString> standardPersonalityKeys = {"StandardFollow", "StandardFollowHigh", "StandardJerkAcceleration", "StandardJerkDeceleration", "StandardJerkDanger", "StandardJerkSpeed", "StandardJerkSpeedDecrease", "ResetStandardPersonality"};
|
||||
QSet<QString> trafficPersonalityKeys = {"TrafficFollow", "TrafficJerkAcceleration", "TrafficJerkDeceleration", "TrafficJerkDanger", "TrafficJerkSpeed", "TrafficJerkSpeedDecrease", "ResetTrafficPersonality"};
|
||||
|
||||
QSet<QString> parentKeys;
|
||||
|
||||
@@ -30,12 +30,14 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog
|
||||
{"DeleteModel", tr("Delete Driving Models"), tr("Delete driving models from the device."), ""},
|
||||
{"DownloadModel", tr("Download Driving Models"), tr("Download driving models to the device."), ""},
|
||||
{"ModelRandomizer", tr("Model Randomizer"), tr("Driving models are chosen at random each drive and feedback prompts are used to find the model that best suits your needs."), ""},
|
||||
{"RecoveryPower", tr("Recovery Power"), tr("Adjust the strength of planplus lane recovery corrections (0.5 to 2.0)."), ""},
|
||||
{"StopDistance", tr("Stop Distance"), tr("Adjust the model's stopping distance in meters (minimum 4 for safety). Most users prefer 6."), ""},
|
||||
{"ManageBlacklistedModels", tr("Manage Model Blacklist"), tr("Add or remove models from the <b>Model Randomizer</b>'s blacklist list."), ""},
|
||||
{"ManageScores", tr("Manage Model Ratings"), tr("Reset or view the saved ratings for the driving models."), ""},
|
||||
{"SelectModel", tr("Select Driving Model"), tr("Select the active driving model."), ""},
|
||||
};
|
||||
|
||||
FrogPilotParamValueButtonControl *recoveryPowerToggle = nullptr;
|
||||
FrogPilotParamValueButtonControl *stopDistanceToggle = nullptr;
|
||||
|
||||
for (const auto &[param, title, desc, icon] : modelToggles) {
|
||||
@@ -431,6 +433,10 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog
|
||||
});
|
||||
modelToggle = selectModelButton;
|
||||
|
||||
} else if (param == "RecoveryPower") {
|
||||
std::vector<QString> recoveryPowerButton{"Reset"};
|
||||
modelToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, 0.5, 2.0, QString(), std::map<float, QString>(), 0.1, false, {}, recoveryPowerButton, false, false);
|
||||
recoveryPowerToggle = static_cast<FrogPilotParamValueButtonControl*>(modelToggle);
|
||||
} else if (param == "StopDistance") {
|
||||
std::vector<QString> stopDistanceButton{"Reset"};
|
||||
modelToggle = new FrogPilotParamValueButtonControl(param, title, desc, icon, 4.0, 10.0, QString(), std::map<float, QString>(), 0.1, false, {}, stopDistanceButton, false, false);
|
||||
@@ -463,6 +469,16 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog
|
||||
}
|
||||
});
|
||||
|
||||
if (recoveryPowerToggle) {
|
||||
QObject::connect(recoveryPowerToggle, &FrogPilotParamValueButtonControl::buttonClicked, [this, recoveryPowerToggle]() {
|
||||
if (ConfirmationDialog::confirm(tr("Are you sure you want to reset your <b>Recovery Power</b> to the default of 1.0?"), tr("Reset"), this)) {
|
||||
params.putFloat("RecoveryPower", 1.0);
|
||||
recoveryPowerToggle->refresh();
|
||||
updateFrogPilotToggles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (stopDistanceToggle) {
|
||||
QObject::connect(stopDistanceToggle, &FrogPilotParamValueButtonControl::buttonClicked, [this, stopDistanceToggle]() {
|
||||
if (ConfirmationDialog::confirm(tr("Are you sure you want to reset your <b>Stop Distance</b> to the default of 6 meters?"), tr("Reset"), this)) {
|
||||
@@ -487,6 +503,8 @@ bool FrogPilotModelPanel::isModelInstalled(const QString &key) const {
|
||||
bool has_policy_tg = false;
|
||||
bool has_vision_meta = false;
|
||||
bool has_vision_tg = false;
|
||||
bool has_off_policy_meta = false;
|
||||
bool has_off_policy_tg = false;
|
||||
bool foundAny = false;
|
||||
|
||||
for (const QString &file : modelDir.entryList(QDir::Files)) {
|
||||
@@ -505,6 +523,10 @@ bool FrogPilotModelPanel::isModelInstalled(const QString &key) const {
|
||||
has_policy_meta = true;
|
||||
} else if (base.contains("_driving_policy_tinygrad")) {
|
||||
has_policy_tg = true;
|
||||
} else if (base.contains("_driving_off_policy_metadata")) {
|
||||
has_off_policy_meta = true;
|
||||
} else if (base.contains("_driving_off_policy_tinygrad")) {
|
||||
has_off_policy_tg = true;
|
||||
} else if (base.contains("_driving_vision_metadata")) {
|
||||
has_vision_meta = true;
|
||||
} else if (base.contains("_driving_vision_tinygrad")) {
|
||||
@@ -518,6 +540,9 @@ bool FrogPilotModelPanel::isModelInstalled(const QString &key) const {
|
||||
}
|
||||
|
||||
if (has_policy_meta && has_policy_tg && has_vision_meta && has_vision_tg) {
|
||||
if (has_off_policy_meta || has_off_policy_tg) {
|
||||
return has_off_policy_meta && has_off_policy_tg;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -740,6 +765,8 @@ void FrogPilotModelPanel::updateToggles() {
|
||||
setVisible &= params.getBool("ModelRandomizer");
|
||||
} else if (key == "SelectModel") {
|
||||
setVisible &= !params.getBool("ModelRandomizer");
|
||||
} else if (key == "RecoveryPower") {
|
||||
setVisible &= (tuningLevel == 3); // Only visible in developer tuning level
|
||||
} else if (key == "StopDistance") {
|
||||
setVisible &= (tuningLevel == 3); // Only visible in developer tuning level
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ FrogPilotSoundsPanel::FrogPilotSoundsPanel(FrogPilotSettingsWindow *parent) : Fr
|
||||
|
||||
{"CustomAlerts", tr("FrogPilot Alerts"), tr("<b>Optional FrogPilot alerts</b> that highlight driving events in a more noticeable way."), "../../frogpilot/assets/toggle_icons/icon_green_light.png"},
|
||||
{"GoatScream", tr("Goat Scream"), tr("<b>Play the infamous \"Goat Scream\" when the steering controller reaches its limit.</b> Based on the \"Turn Exceeds Steering Limit\" event."), ""},
|
||||
{"GoatScreamCriticalAlerts", tr("Goat Scream Critical Alerts"), tr("<b>Play the infamous \"Goat Scream\" for full-screen critical alerts that require immediate takeover.</b><br><br>Examples include: \"TAKE CONTROL IMMEDIATELY\" and \"Stock AEB: Risk of Collision\"."), ""},
|
||||
{"GreenLightAlert", tr("Green Light Alert"), tr("<b>Play an alert when the model predicts a red light has turned green.</b><br><br><i><b>Disclaimer</b>: openpilot does not explicitly detect traffic lights. This alert is based on end-to-end model predictions from camera input and may trigger even when the light has not changed.</i>"), ""},
|
||||
{"LeadDepartingAlert", tr("Lead Departing Alert"), tr("<b>Play an alert when the lead vehicle departs from a stop.</b>"), ""},
|
||||
{"LoudBlindspotAlert", tr("Loud \"Car Detected in Blindspot\" Alert"), tr("<b>Play a louder alert if a vehicle is in the blind spot when attempting to change lanes.</b> Based on the \"Car Detected in Blindspot\" event."), ""},
|
||||
|
||||