Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f341e4b3db | |||
| 5965d2389e | |||
| 43eeca188f | |||
| 7986f23b1c | |||
| d4ce22deaf | |||
| 782cef4c8b | |||
| 3ffe607452 | |||
| fc79fe1248 | |||
| 79e28d6f2e | |||
| 335e7cf74e | |||
| 978d57a674 | |||
| 33c0f0ef3a | |||
| c540998591 | |||
| 445e89e14c | |||
| 46e5eaa232 | |||
| d4d426f94e | |||
| 9364ffd6a8 | |||
| fcf8c3feb2 | |||
| 2881deb510 | |||
| 082418b012 | |||
| f8eab92141 | |||
| 208630f5ca | |||
| 0e0909cbd7 | |||
| 4320525df6 | |||
| 813e3183fc | |||
| b4cf18cdc2 | |||
| c8f18d1497 | |||
| 5ca3f503ee | |||
| 283e9b1d78 | |||
| 404922e80d | |||
| 77f314a170 | |||
| b0426254be | |||
| 0d911b2c31 | |||
| bd7f47eb20 | |||
| f14ac063b3 | |||
| b169d93464 | |||
| 14a3cb7025 | |||
| 9e6ef37d96 | |||
| aa36b2133e | |||
| 8eb6d5b447 | |||
| f2b9cc51a0 | |||
| f2d754e96d | |||
| cca4a277f1 | |||
| cd7cb862fd | |||
| e8009ad9b9 | |||
| cc700d9b81 | |||
| c24dd3552c | |||
| 2eced5b72c | |||
| 77ffd98a1a | |||
| d62d7dd9fc | |||
| c84acea993 | |||
| 876a45122f | |||
| f538eddb87 | |||
| 7c51e8aac1 | |||
| 4ab8b0e874 | |||
| b6f9f8180e | |||
| d07852b2bc | |||
| a70746918b | |||
| 0a6e03b7ca | |||
| f0f95d5d10 | |||
| cf735574a6 | |||
| d466d36429 | |||
| 33cb78cfa9 | |||
| e2a15a0985 | |||
| 984fd6a6cb | |||
| 7ef35817fc | |||
| 26e0b2406e | |||
| fed12740f2 | |||
| 030cf80148 | |||
| d082aa8a3c | |||
| fdcab8d127 | |||
| 266fda2c91 | |||
| 97a2b1522e | |||
| d59ab46e91 | |||
| 948cdf21d0 | |||
| f10391d9ce | |||
| 444edbe71b | |||
| bb41c76c62 | |||
| 1978e5fda1 | |||
| 85906ceb77 |
@@ -164,6 +164,9 @@ std::unordered_map<std::string, uint32_t> keys = {
|
|||||||
{"LiveDelay", PERSISTENT},
|
{"LiveDelay", PERSISTENT},
|
||||||
{"LiveParameters", PERSISTENT},
|
{"LiveParameters", PERSISTENT},
|
||||||
{"LiveTorqueParameters", PERSISTENT | DONT_LOG},
|
{"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},
|
{"LongitudinalPersonality", PERSISTENT},
|
||||||
{"NavDestination", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
{"NavDestination", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||||
{"NavDestinationWaypoints", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
{"NavDestinationWaypoints", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
|
||||||
@@ -235,6 +238,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
|||||||
{"AlwaysOnLateral", PERSISTENT},
|
{"AlwaysOnLateral", PERSISTENT},
|
||||||
{"AlwaysOnLateralLKAS", PERSISTENT},
|
{"AlwaysOnLateralLKAS", PERSISTENT},
|
||||||
{"AlwaysOnLateralMain", PERSISTENT},
|
{"AlwaysOnLateralMain", PERSISTENT},
|
||||||
|
{"AlwaysAllowUploads", PERSISTENT},
|
||||||
{"AMapKey1", PERSISTENT | DONT_LOG},
|
{"AMapKey1", PERSISTENT | DONT_LOG},
|
||||||
{"AMapKey2", PERSISTENT | DONT_LOG},
|
{"AMapKey2", PERSISTENT | DONT_LOG},
|
||||||
{"ApiCache_DriveStats", PERSISTENT},
|
{"ApiCache_DriveStats", PERSISTENT},
|
||||||
@@ -324,7 +328,6 @@ std::unordered_map<std::string, uint32_t> keys = {
|
|||||||
{"DynamicPathWidth", PERSISTENT},
|
{"DynamicPathWidth", PERSISTENT},
|
||||||
{"DynamicPedalsOnUI", PERSISTENT},
|
{"DynamicPedalsOnUI", PERSISTENT},
|
||||||
{"EngageVolume", PERSISTENT},
|
{"EngageVolume", PERSISTENT},
|
||||||
{"ExperimentalGMTune", PERSISTENT},
|
|
||||||
{"EVTuning", PERSISTENT},
|
{"EVTuning", PERSISTENT},
|
||||||
{"TruckTuning", PERSISTENT},
|
{"TruckTuning", PERSISTENT},
|
||||||
{"Fahrenheit", PERSISTENT},
|
{"Fahrenheit", PERSISTENT},
|
||||||
@@ -354,6 +357,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
|||||||
{"GasRegenCmd", PERSISTENT},
|
{"GasRegenCmd", PERSISTENT},
|
||||||
{"GMPedalLongitudinal", PERSISTENT},
|
{"GMPedalLongitudinal", PERSISTENT},
|
||||||
{"GoatScream", PERSISTENT},
|
{"GoatScream", PERSISTENT},
|
||||||
|
{"GoatScreamCriticalAlerts", PERSISTENT},
|
||||||
{"GreenLightAlert", PERSISTENT},
|
{"GreenLightAlert", PERSISTENT},
|
||||||
{"HideAlerts", PERSISTENT},
|
{"HideAlerts", PERSISTENT},
|
||||||
{"HideLeadMarker", PERSISTENT},
|
{"HideLeadMarker", PERSISTENT},
|
||||||
@@ -474,6 +478,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
|||||||
{"RoadName", CLEAR_ON_MANAGER_START},
|
{"RoadName", CLEAR_ON_MANAGER_START},
|
||||||
{"RoadNameUI", PERSISTENT},
|
{"RoadNameUI", PERSISTENT},
|
||||||
{"RedPanda", PERSISTENT},
|
{"RedPanda", PERSISTENT},
|
||||||
|
{"RemapCancelToDistance", PERSISTENT},
|
||||||
{"RemoteStartBootsComma", PERSISTENT},
|
{"RemoteStartBootsComma", PERSISTENT},
|
||||||
{"RotatingWheel", PERSISTENT},
|
{"RotatingWheel", PERSISTENT},
|
||||||
{"ScreenBrightness", PERSISTENT},
|
{"ScreenBrightness", PERSISTENT},
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ THEME_SAVE_PATH = Path("/data/themes")
|
|||||||
ERROR_LOGS_PATH = Path("/data/error_logs")
|
ERROR_LOGS_PATH = Path("/data/error_logs")
|
||||||
SCREEN_RECORDINGS_PATH = Path("/data/media/screen_recordings")
|
SCREEN_RECORDINGS_PATH = Path("/data/media/screen_recordings")
|
||||||
VIDEO_CACHE_PATH = Path("/data/video_cache")
|
VIDEO_CACHE_PATH = Path("/data/video_cache")
|
||||||
|
MODEL_MIGRATION_FLAG_PATH = Path("/data/params/d/ModelMigratedBd2ToSc")
|
||||||
|
|
||||||
BACKUP_PATH = Path("/cache/on_backup")
|
BACKUP_PATH = Path("/cache/on_backup")
|
||||||
|
|
||||||
@@ -75,8 +76,8 @@ MAPD_PATH = Path("/data/media/0/osm/mapd")
|
|||||||
MAPS_PATH = Path("/data/media/0/osm/offline")
|
MAPS_PATH = Path("/data/media/0/osm/offline")
|
||||||
NNFF_MODELS_PATH = Path(BASEDIR) / "frogpilot/assets/nnff_models"
|
NNFF_MODELS_PATH = Path(BASEDIR) / "frogpilot/assets/nnff_models"
|
||||||
|
|
||||||
DEFAULT_MODEL = "bd2"
|
DEFAULT_MODEL = "sc"
|
||||||
DEFAULT_MODEL_NAME = "Firehose (Default) 👀📡"
|
DEFAULT_MODEL_NAME = "South Carolina (Default) 👀📡"
|
||||||
DEFAULT_MODEL_VERSION = "v11"
|
DEFAULT_MODEL_VERSION = "v11"
|
||||||
|
|
||||||
BUTTON_FUNCTIONS = {
|
BUTTON_FUNCTIONS = {
|
||||||
@@ -147,6 +148,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
|||||||
("AlwaysOnLateral", "1", 0, "0"),
|
("AlwaysOnLateral", "1", 0, "0"),
|
||||||
("AlwaysOnLateralLKAS", "1", 0, "0"),
|
("AlwaysOnLateralLKAS", "1", 0, "0"),
|
||||||
("AlwaysOnLateralMain", "1", 0, "0"),
|
("AlwaysOnLateralMain", "1", 0, "0"),
|
||||||
|
("AlwaysAllowUploads", "0", 2, "0"),
|
||||||
("AMapKey1", "", 0, ""),
|
("AMapKey1", "", 0, ""),
|
||||||
("AMapKey2", "", 0, ""),
|
("AMapKey2", "", 0, ""),
|
||||||
("AutomaticallyDownloadModels", "1", 1, "0"),
|
("AutomaticallyDownloadModels", "1", 1, "0"),
|
||||||
@@ -172,7 +174,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
|||||||
("CECurves", "0", 1, "0"),
|
("CECurves", "0", 1, "0"),
|
||||||
("CECurvesLead", "0", 1, "0"),
|
("CECurvesLead", "0", 1, "0"),
|
||||||
("CELead", "0", 1, "0"),
|
("CELead", "0", 1, "0"),
|
||||||
("CEModelStopTime", str(PLANNER_TIME - 2), 2, "0"),
|
("CEModelStopTime", "7", 2, "0"),
|
||||||
("CENavigation", "0", 2, "0"),
|
("CENavigation", "0", 2, "0"),
|
||||||
("CENavigationIntersections", "1", 2, "0"),
|
("CENavigationIntersections", "1", 2, "0"),
|
||||||
("CENavigationLead", "1", 2, "0"),
|
("CENavigationLead", "1", 2, "0"),
|
||||||
@@ -221,7 +223,6 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
|||||||
("DynamicPathWidth", "0", 2, "0"),
|
("DynamicPathWidth", "0", 2, "0"),
|
||||||
("DynamicPedalsOnUI", "1", 2, "0"),
|
("DynamicPedalsOnUI", "1", 2, "0"),
|
||||||
("EngageVolume", "101", 2, "101"),
|
("EngageVolume", "101", 2, "101"),
|
||||||
("ExperimentalGMTune", "0", 2, "0"),
|
|
||||||
("ExperimentalLongitudinalEnabled", "0", 0, "0"),
|
("ExperimentalLongitudinalEnabled", "0", 0, "0"),
|
||||||
("ExperimentalModeConfirmed", "0", 0, "0"),
|
("ExperimentalModeConfirmed", "0", 0, "0"),
|
||||||
("Fahrenheit", "0", 3, "0"),
|
("Fahrenheit", "0", 3, "0"),
|
||||||
@@ -239,11 +240,13 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
|||||||
("FullMap", "0", 2, "0"),
|
("FullMap", "0", 2, "0"),
|
||||||
("GasRegenCmd", "1", 2, "0"),
|
("GasRegenCmd", "1", 2, "0"),
|
||||||
("GMPedalLongitudinal", "1", 2, "1"),
|
("GMPedalLongitudinal", "1", 2, "1"),
|
||||||
|
("RemapCancelToDistance", "0", 2, "0"),
|
||||||
("RedPanda", "0", 3, "0"),
|
("RedPanda", "0", 3, "0"),
|
||||||
("RemoteStartBootsComma", "0", 3, "0"),
|
("RemoteStartBootsComma", "0", 3, "0"),
|
||||||
("GithubSshKeys", "", 0, ""),
|
("GithubSshKeys", "", 0, ""),
|
||||||
("GithubUsername", "", 0, ""),
|
("GithubUsername", "", 0, ""),
|
||||||
("GoatScream", "0", 1, "0"),
|
("GoatScream", "0", 1, "0"),
|
||||||
|
("GoatScreamCriticalAlerts", "0", 1, "0"),
|
||||||
("GreenLightAlert", "0", 0, "0"),
|
("GreenLightAlert", "0", 0, "0"),
|
||||||
("GsmApn", "", 0, ""),
|
("GsmApn", "", 0, ""),
|
||||||
("GsmRoaming", "1", 0, "0"),
|
("GsmRoaming", "1", 0, "0"),
|
||||||
@@ -456,7 +459,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
|
|||||||
("WarningSoftVolume", "101", 2, "101"),
|
("WarningSoftVolume", "101", 2, "101"),
|
||||||
("WheelIcon", "frog", 0, "stock"),
|
("WheelIcon", "frog", 0, "stock"),
|
||||||
("WheelSpeed", "0", 2, "0"),
|
("WheelSpeed", "0", 2, "0"),
|
||||||
("StopDistance", "6", 3, "6"),
|
("StopDistance", "6.0", 3, "6.0"),
|
||||||
("RecoveryPower", "1.0", 2, "1.0")
|
("RecoveryPower", "1.0", 2, "1.0")
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -534,6 +537,18 @@ class FrogPilotVariables:
|
|||||||
|
|
||||||
tuning_level = params.get_int("TuningLevel") if params.get_bool("TuningLevelConfirmed") else 3
|
tuning_level = params.get_int("TuningLevel") if params.get_bool("TuningLevelConfirmed") else 3
|
||||||
|
|
||||||
|
# One-time migration: move legacy bd2 selections to the new default sc model.
|
||||||
|
if not MODEL_MIGRATION_FLAG_PATH.is_file():
|
||||||
|
if params.get("Model", encoding="utf-8") == "bd2":
|
||||||
|
params.put("Model", DEFAULT_MODEL)
|
||||||
|
params.put("ModelVersion", DEFAULT_MODEL_VERSION)
|
||||||
|
params_cache.put("Model", DEFAULT_MODEL)
|
||||||
|
params_cache.put("ModelVersion", DEFAULT_MODEL_VERSION)
|
||||||
|
try:
|
||||||
|
MODEL_MIGRATION_FLAG_PATH.touch(exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
toggle.is_metric = params.get_bool("IsMetric")
|
toggle.is_metric = params.get_bool("IsMetric")
|
||||||
distance_conversion = 1 if toggle.is_metric else CV.FOOT_TO_METER
|
distance_conversion = 1 if toggle.is_metric else CV.FOOT_TO_METER
|
||||||
small_distance_conversion = 1 if toggle.is_metric else CV.INCH_TO_CM
|
small_distance_conversion = 1 if toggle.is_metric else CV.INCH_TO_CM
|
||||||
@@ -575,6 +590,7 @@ class FrogPilotVariables:
|
|||||||
toggle.has_cc_long = toggle.car_make == "gm" and bool(CP.flags & GMFlags.CC_LONG.value)
|
toggle.has_cc_long = toggle.car_make == "gm" and bool(CP.flags & GMFlags.CC_LONG.value)
|
||||||
has_nnff = nnff_supported(toggle.car_model)
|
has_nnff = nnff_supported(toggle.car_model)
|
||||||
toggle.has_pedal = CP.enableGasInterceptor
|
toggle.has_pedal = CP.enableGasInterceptor
|
||||||
|
params_default.put("IncreasedStoppedDistance", "4" if toggle.has_pedal else "0")
|
||||||
has_radar = not CP.radarUnavailable
|
has_radar = not CP.radarUnavailable
|
||||||
toggle.has_sdsu = toggle.car_make == "toyota" and bool(CP.flags & ToyotaFlags.SMART_DSU.value)
|
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)
|
toggle.has_sascm = toggle.car_make == "gm" and bool(CP.flags & GMFlags.SASCM.value)
|
||||||
@@ -682,7 +698,7 @@ class FrogPilotVariables:
|
|||||||
|
|
||||||
toggle.car_model = params.get("CarModel", encoding="utf-8") or toggle.car_model
|
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_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"))
|
toggle.conditional_curves = toggle.conditional_experimental_mode and (params.get_bool("CECurves") if tuning_level >= level["CECurves"] else default.get_bool("CECurves"))
|
||||||
@@ -706,6 +722,7 @@ class FrogPilotVariables:
|
|||||||
|
|
||||||
toggle.custom_alerts = params.get_bool("CustomAlerts") if tuning_level >= level["CustomAlerts"] else default.get_bool("CustomAlerts")
|
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_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.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.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"))
|
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"))
|
||||||
@@ -791,6 +808,7 @@ class FrogPilotVariables:
|
|||||||
toggle.device_shutdown_time = (device_shutdown_setting - 3) * 3600 if device_shutdown_setting >= 4 else device_shutdown_setting * (60 * 15)
|
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.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.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_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_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
|
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
|
||||||
@@ -822,13 +840,11 @@ class FrogPilotVariables:
|
|||||||
toggle.personality_profile_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["PERSONALITY_PROFILE"]
|
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.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.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.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
|
toggle.force_fingerprint = (params.get_bool("ForceFingerprint") if tuning_level >= level["ForceFingerprint"] else default.get_bool("ForceFingerprint")) and toggle.car_model is not None
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
#!/usr/bin/env python3
|
||||||
|
import time
|
||||||
|
|
||||||
from openpilot.common.filter_simple import FirstOrderFilter
|
from openpilot.common.filter_simple import FirstOrderFilter
|
||||||
from openpilot.common.realtime import DT_MDL
|
from openpilot.common.realtime import DT_MDL
|
||||||
from openpilot.common.numpy_fast import interp
|
from openpilot.common.numpy_fast import interp
|
||||||
@@ -32,6 +34,10 @@ class ConditionalExperimentalMode:
|
|||||||
LIGHT_BOOST_LOW = 1.15
|
LIGHT_BOOST_LOW = 1.15
|
||||||
LIGHT_BOOST_HIGH = 1.2
|
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
|
@staticmethod
|
||||||
def get_speed_based_param(speed_mph, param_array):
|
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]"""
|
"""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.experimental_mode = False
|
||||||
self.stop_light_detected = False
|
self.stop_light_detected = False
|
||||||
self.prev_experimental_mode = False # For hysteresis
|
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):
|
def update(self, v_ego, sm, frogpilot_toggles):
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
if frogpilot_toggles.experimental_mode_via_press:
|
if frogpilot_toggles.experimental_mode_via_press:
|
||||||
self.status_value = params_memory.get_int("CEStatus")
|
self.status_value = params_memory.get_int("CEStatus")
|
||||||
else:
|
else:
|
||||||
@@ -58,28 +68,23 @@ class ConditionalExperimentalMode:
|
|||||||
|
|
||||||
if self.status_value not in {1, 2} and not sm["carState"].standstill:
|
if self.status_value not in {1, 2} and not sm["carState"].standstill:
|
||||||
self.update_conditions(v_ego, sm, frogpilot_toggles)
|
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
|
triggered = self.check_conditions(v_ego, sm, frogpilot_toggles)
|
||||||
if new_experimental_mode and not self.prev_experimental_mode:
|
if triggered:
|
||||||
# Require weaker conditions to turn on
|
self.mode_hold_until = now + self.CEM_TRANSITION_GUARD_TIME
|
||||||
hysteresis_factor = 0.9
|
self.mode_false_since = 0.0
|
||||||
elif not new_experimental_mode and self.prev_experimental_mode:
|
elif self.mode_false_since == 0.0:
|
||||||
# Require stronger conditions to turn off
|
self.mode_false_since = now
|
||||||
hysteresis_factor = 1.2
|
|
||||||
else:
|
|
||||||
hysteresis_factor = 1.0
|
|
||||||
|
|
||||||
# Apply hysteresis to key conditions
|
hold_active = now < self.mode_hold_until
|
||||||
if hasattr(self, 'slow_lead_detected'):
|
transition_buffer_active = self.mode_false_since != 0.0 and (now - self.mode_false_since) < self.CEM_TRANSITION_BUFFER_TIME
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
self.prev_experimental_mode = self.experimental_mode
|
||||||
params_memory.put_int("CEStatus", self.status_value if self.experimental_mode else 0)
|
params_memory.put_int("CEStatus", self.status_value if self.experimental_mode else 0)
|
||||||
else:
|
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.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_detected &= self.status_value not in {1, 2}
|
||||||
self.stop_light_filter.x = 0
|
self.stop_light_filter.x = 0
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ A_CRUISE_MIN_SPORT = A_CRUISE_MIN * 2
|
|||||||
|
|
||||||
# MPH = [0.0, 11, 22, 34, 45, 56, 89]
|
# 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_BP_CUSTOM = [0.0, 5., 10., 15., 20., 25., 40.]
|
||||||
A_CRUISE_MAX_VALS_ECO_EV = [1.0, 1.0, 1.0, 1.0, 1.12, 1.12, 1.45]
|
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.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.25, 1.25, 1.25, 1.25, 1.45, 1.5, 2.0]
|
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.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_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_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_ECO_TRUCK = [3.00, 1.05, 0.60, 0.50, 0.50, 0.45, 0.35]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import COMFO
|
|||||||
from openpilot.frogpilot.common.frogpilot_variables import CITY_SPEED_LIMIT
|
from openpilot.frogpilot.common.frogpilot_variables import CITY_SPEED_LIMIT
|
||||||
|
|
||||||
TRAFFIC_MODE_BP = [0., CITY_SPEED_LIMIT]
|
TRAFFIC_MODE_BP = [0., CITY_SPEED_LIMIT]
|
||||||
PERSONALITY_BP = [20. * CV.KPH_TO_MS, 90. * CV.KPH_TO_MS]
|
PERSONALITY_BP = [45. * CV.MPH_TO_MS, 70. * CV.MPH_TO_MS]
|
||||||
|
|
||||||
class FrogPilotFollowing:
|
class FrogPilotFollowing:
|
||||||
def __init__(self, FrogPilotPlanner):
|
def __init__(self, FrogPilotPlanner):
|
||||||
@@ -73,9 +73,23 @@ class FrogPilotFollowing:
|
|||||||
self.following_lead = self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.dRel < (self.t_follow * 2) * v_ego
|
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 = False
|
||||||
self.disable_throttle &= self.frogpilot_planner.lead_one.dRel + 6.0 < (self.t_follow * 2 * 2) * v_ego
|
if self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.status:
|
||||||
self.disable_throttle &= self.frogpilot_planner.lead_one.vLead < v_ego * 0.75
|
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)
|
||||||
|
|
||||||
|
self.disable_throttle = (not self.following_lead and v_ego > 5.0 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:
|
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)
|
self.update_follow_values(self.frogpilot_planner.lead_one.dRel, v_ego, self.frogpilot_planner.lead_one.vLead, frogpilot_toggles)
|
||||||
|
|||||||
@@ -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 {
|
.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);
|
border-radius: var(--border-radius-md);
|
||||||
height: var(--padding-base);
|
height: var(--padding-base);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
.diskUsage,
|
.diskUsage,
|
||||||
.drivingStats,
|
.drivingStats,
|
||||||
.firehoseStats,
|
|
||||||
.softwareInfo {
|
.softwareInfo {
|
||||||
background-color: var(--secondary-bg);
|
background-color: var(--secondary-bg);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.diskUsage p,
|
.diskUsage p,
|
||||||
.firehoseStats p,
|
|
||||||
.softwareGrid p {
|
.softwareGrid p {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -65,7 +63,6 @@
|
|||||||
|
|
||||||
.diskUsage:hover,
|
.diskUsage:hover,
|
||||||
.drivingStats:hover,
|
.drivingStats:hover,
|
||||||
.firehoseStats:hover,
|
|
||||||
.softwareInfo:hover {
|
.softwareInfo:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transform: var(--hover-scale-sm);
|
transform: var(--hover-scale-sm);
|
||||||
|
|||||||
@@ -64,56 +64,61 @@ function renderDiskUsageSection({ diskError, diskUsage }) {
|
|||||||
return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" });
|
return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Home() {
|
const state = reactive({
|
||||||
const state = reactive({
|
data: null,
|
||||||
data: null,
|
unit: "miles",
|
||||||
unit: "miles",
|
isLoading: true,
|
||||||
isLoading: true,
|
error: null,
|
||||||
error: null,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
async function initialize() {
|
let initialized = false;
|
||||||
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}`);
|
async function initialize() {
|
||||||
if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
|
try {
|
||||||
|
const [statsResponse, unitResponse] = await Promise.all([
|
||||||
|
fetch("/api/stats"),
|
||||||
|
fetch("/api/params?key=IsMetric"),
|
||||||
|
]);
|
||||||
|
|
||||||
const statsJson = await statsResponse.json();
|
if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
|
||||||
const isMetricText = (await unitResponse.text()).trim();
|
if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
|
||||||
const isMetric = isMetricText === "1";
|
|
||||||
|
|
||||||
state.data = statsJson;
|
const statsJson = await statsResponse.json();
|
||||||
state.unit = isMetric ? "kilometers" : "miles";
|
const isMetricText = (await unitResponse.text()).trim();
|
||||||
localStorage.setItem("isMetric", isMetricText);
|
const isMetric = isMetricText === "1";
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to initialize component:", err);
|
state.data = statsJson;
|
||||||
state.error = err.message;
|
state.unit = isMetric ? "kilometers" : "miles";
|
||||||
} finally {
|
localStorage.setItem("isMetric", isMetricText);
|
||||||
state.isLoading = false;
|
} catch (err) {
|
||||||
}
|
console.error("Failed to initialize component:", err);
|
||||||
|
state.error = err.message;
|
||||||
|
} finally {
|
||||||
|
state.isLoading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initialize();
|
export function Home() {
|
||||||
|
if (!initialized) {
|
||||||
|
initialized = true;
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div>
|
<div>
|
||||||
${() => {
|
${() => {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
return html`<p>Loading...</p>`;
|
return html`<p>Loading...</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
return html`<p class="error">Failed to load data: ${state.error}</p>`;
|
return html`<p class="error">Failed to load data: ${state.error}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.data) {
|
if (state.data) {
|
||||||
const { driveStats, firehoseStats, softwareInfo } = state.data;
|
const { driveStats, softwareInfo } = state.data;
|
||||||
return html`
|
return html`
|
||||||
<h1>The Pond</h1>
|
<h1>Galaxy</h1>
|
||||||
|
|
||||||
<div class="drivingStats">
|
<div class="drivingStats">
|
||||||
${DriveStat("All Time", driveStats?.all, state.unit)}
|
${DriveStat("All Time", driveStats?.all, state.unit)}
|
||||||
@@ -126,23 +131,15 @@ export function Home() {
|
|||||||
${renderDiskUsageSection(state.data)}
|
${renderDiskUsageSection(state.data)}
|
||||||
</div>
|
</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>
|
<h2>Software Info</h2>
|
||||||
<div class="softwareInfo">
|
<div class="softwareInfo">
|
||||||
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
|
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<p>No data available.</p>`;
|
return html`<p>No data available.</p>`;
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,47 +20,49 @@
|
|||||||
--breakpoint-lg: 1024px;
|
--breakpoint-lg: 1024px;
|
||||||
--breakpoint-xl: 1280px;
|
--breakpoint-xl: 1280px;
|
||||||
|
|
||||||
/* Colors */
|
/* Colors — Galaxy palette (cosmic purple · teal · rose · amber) */
|
||||||
--accent-bg: #673ab7;
|
--accent-bg: #8b6cc5;
|
||||||
--accent-hover-bg: #512da8;
|
--accent-hover-bg: #7558b0;
|
||||||
--card-bg: #234423;
|
--card-bg: #121224;
|
||||||
--color-black: #000000;
|
--color-black: #000000;
|
||||||
--color-confirm: #1a73e8;
|
--color-confirm: #8b6cc5;
|
||||||
--color-confirm-hover: #0758ad;
|
--color-confirm-hover: #7558b0;
|
||||||
--color-gray-100: #f5f5f5;
|
--color-gray-100: #f0f0f8;
|
||||||
--color-gray-200: #e0e0e0;
|
--color-gray-200: #d8d8e4;
|
||||||
--color-gray-300: #c2c2c2;
|
--color-gray-300: #b8b8cc;
|
||||||
--color-gray-400: #a4a4a4;
|
--color-gray-400: #9898b0;
|
||||||
--color-gray-500: #8f8f8f;
|
--color-gray-500: #7e7e98;
|
||||||
--color-gray-600: #737373;
|
--color-gray-600: #636380;
|
||||||
--color-gray-700: #595959;
|
--color-gray-700: #4a4a64;
|
||||||
--color-gray-800: #333333;
|
--color-gray-800: #2e2e44;
|
||||||
--color-gray-900: #1a1a1a;
|
--color-gray-900: #1a1a30;
|
||||||
--color-white: #ffffff;
|
--color-white: #ffffff;
|
||||||
--danger-bg: #b71c1c;
|
--danger-bg: #e05577;
|
||||||
--danger-fg: #ef1313;
|
--danger-fg: #e05577;
|
||||||
--danger-hover-bg: #d32f2f;
|
--danger-hover-bg: #c04466;
|
||||||
--input-bg: #2f5432;
|
--glow-primary: 0 0 0 2px var(--main-fg), 0 0 10px rgba(139, 108, 197, 0.35);
|
||||||
--main-bg: #0b1b0b;
|
--input-bg: #161630;
|
||||||
--main-fg: #178643;
|
--main-bg: #06060f;
|
||||||
--secondary-bg: #264026;
|
--main-fg: #8b6cc5;
|
||||||
--selected-camera-bg: #1f2f1f;
|
--secondary-bg: #0e0e1a;
|
||||||
--sidebar-active-bg: #64c87826;
|
--selected-camera-bg: #0b0b18;
|
||||||
--sidebar-bg: #264026;
|
--sidebar-active-bg: rgba(139, 108, 197, 0.15);
|
||||||
--sidebar-border-color: #1e2e1e;
|
--sidebar-bg: #0a0a16;
|
||||||
|
--sidebar-border-color: #1e1e3e;
|
||||||
--sidebar-fg: #ffffff;
|
--sidebar-fg: #ffffff;
|
||||||
--sidebar-title-fg: #193446;
|
--sidebar-title-fg: #8b6cc5;
|
||||||
--success-bg: #039226;
|
--success-bg: #5ec8c8;
|
||||||
--success-fg: #00a100;
|
--success-fg: #5ec8c8;
|
||||||
--success-hover-bg: #01be2d;
|
--success-hover-bg: #4ab3b3;
|
||||||
--text-color: #ffffff;
|
--switch-inactive-bg: #1e1e3e;
|
||||||
--text-muted: #a0a0a0;
|
--text-color: #e8e8f0;
|
||||||
|
--text-muted: #8080a8;
|
||||||
--text-on-primary: var(--sidebar-fg);
|
--text-on-primary: var(--sidebar-fg);
|
||||||
--text-on-surface: var(--text-color);
|
--text-on-surface: var(--text-color);
|
||||||
--thumb-color: #178643;
|
--thumb-color: #8b6cc5;
|
||||||
--track-color: #1a3a1a;
|
--track-color: #14142e;
|
||||||
--warning-bg: #ff9800;
|
--warning-bg: #d4a060;
|
||||||
--warning-hover-bg: #f57c00;
|
--warning-hover-bg: #b8884a;
|
||||||
|
|
||||||
/* Effects */
|
/* Effects */
|
||||||
--disabled-opacity: 0.5;
|
--disabled-opacity: 0.5;
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
--hover-scale-lg: scale(1.1);
|
--hover-scale-lg: scale(1.1);
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
--font-body: "Open Sans", sans-serif;
|
--font-body: "Inter", "Open Sans", sans-serif;
|
||||||
--font-mono: "Courier New", Courier, monospace;
|
--font-mono: "Courier New", Courier, monospace;
|
||||||
--font-size-xs: 0.75rem;
|
--font-size-xs: 0.75rem;
|
||||||
--font-size-sm: 0.85rem;
|
--font-size-sm: 0.85rem;
|
||||||
@@ -96,9 +98,9 @@
|
|||||||
--width-xxxxl: 1200px;
|
--width-xxxxl: 1200px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.2);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--gap-xxs: 0.125rem;
|
--gap-xxs: 0.125rem;
|
||||||
@@ -172,12 +174,14 @@ input[type="checkbox"],
|
|||||||
input[type="radio"],
|
input[type="radio"],
|
||||||
.manage-keys-link,
|
.manage-keys-link,
|
||||||
.route_card,
|
.route_card,
|
||||||
.sidebar .menu_section > li > ul > li,
|
.sidebar .menu_section>li>ul>li,
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
body { padding-bottom: 0 !important; }
|
body {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ――― Layout containers ――― */
|
/* ――― Layout containers ――― */
|
||||||
.content {
|
.content {
|
||||||
@@ -199,9 +203,17 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ――― Helpers & states ――― */
|
/* ――― Helpers & states ――― */
|
||||||
.hidden { display: none; }
|
.hidden {
|
||||||
html { cursor: default; }
|
display: none;
|
||||||
.no_scroll { overflow: hidden; }
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no_scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.not_implemented {
|
.not_implemented {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -251,7 +263,9 @@ textarea {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
label[for] { cursor: default; }
|
label[for] {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ――― Snackbar component ――― */
|
/* ――― Snackbar component ――― */
|
||||||
.snackbar {
|
.snackbar {
|
||||||
@@ -297,6 +311,7 @@ a {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
bottom: var(--snackbar-offset, 30px);
|
bottom: var(--snackbar-offset, 30px);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -304,13 +319,20 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeout {
|
@keyframes fadeout {
|
||||||
from { opacity: 1; }
|
from {
|
||||||
to { opacity: 0; }
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ――― Breakpoint overrides ――― */
|
/* ――― Breakpoint overrides ――― */
|
||||||
@media only screen and (max-width: var(--breakpoint-md)) {
|
@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) {
|
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||||
@@ -320,6 +342,7 @@ a {
|
|||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#snackbar_wrapper {
|
#snackbar_wrapper {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
@@ -333,6 +356,7 @@ a {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
bottom: var(--snackbar-offset, 30px);
|
bottom: var(--snackbar-offset, 30px);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -340,18 +364,62 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@-webkit-keyframes fadeout {
|
@-webkit-keyframes fadeout {
|
||||||
from { opacity: 1; }
|
from {
|
||||||
to { opacity: 0; }
|
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 {
|
::-webkit-scrollbar {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--track-color);
|
background: var(--track-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--thumb-color);
|
background-color: var(--thumb-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
|
|
||||||
.favorites-toggle-button:hover {
|
.favorites-toggle-button:hover {
|
||||||
background-color: var(--main-fg);
|
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);
|
color: var(--text-color);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
transform: var(--hover-scale-sm);
|
transform: var(--hover-scale-sm);
|
||||||
@@ -352,7 +352,7 @@
|
|||||||
|
|
||||||
.navigation-summary-widget button.directions:hover {
|
.navigation-summary-widget button.directions:hover {
|
||||||
background-color: var(--success-hover-bg);
|
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);
|
color: var(--text-color);
|
||||||
transform: var(--hover-scale-sm);
|
transform: var(--hover-scale-sm);
|
||||||
}
|
}
|
||||||
@@ -466,12 +466,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-provider-toggle button.active {
|
.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);
|
transform: var(--hover-scale-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-provider-toggle button:hover {
|
.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);
|
transform: var(--hover-scale-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,4 +533,4 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: calc(100% - var(--padding-xl));
|
width: calc(100% - var(--padding-xl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
export function NavDestination() {
|
||||||
let map;
|
let map;
|
||||||
let destinationMarker;
|
let destinationMarker;
|
||||||
@@ -262,13 +281,18 @@ export function NavDestination() {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
state.destination = JSON.parse(data.destination);
|
state.destination = JSON.parse(data.destination);
|
||||||
} catch {}
|
} catch { }
|
||||||
try {
|
try {
|
||||||
const prev = JSON.parse(data.previousDestinations);
|
const prev = JSON.parse(data.previousDestinations);
|
||||||
state.previousDestinations = prev.map(d => ({ name: d.place_name }));
|
state.previousDestinations = prev.map(d => ({ name: d.place_name }));
|
||||||
state.suggestions = JSON.stringify(state.previousDestinations);
|
state.suggestions = JSON.stringify(state.previousDestinations);
|
||||||
} catch {}
|
} catch { }
|
||||||
setupMap();
|
try {
|
||||||
|
await setupMap();
|
||||||
|
} catch {
|
||||||
|
showSnackbar("Failed to load map resources…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
loadFavoritesAlphabetically();
|
loadFavoritesAlphabetically();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,12 +548,18 @@ export function NavDestination() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupMap = async () => {
|
const setupMap = async (retries = 0) => {
|
||||||
if (!state.mapboxPublic || state.initialized) return;
|
if (!state.mapboxPublic || state.initialized) return;
|
||||||
|
|
||||||
|
if (typeof mapboxgl === "undefined") {
|
||||||
|
await loadMapboxGL();
|
||||||
|
}
|
||||||
|
|
||||||
const container = document.getElementById("map");
|
const container = document.getElementById("map");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
requestAnimationFrame(setupMap);
|
if (retries >= 50) return;
|
||||||
return;
|
await new Promise(r => requestAnimationFrame(r));
|
||||||
|
return setupMap(retries + 1);
|
||||||
}
|
}
|
||||||
state.initialized = true;
|
state.initialized = true;
|
||||||
mapboxgl.accessToken = state.mapboxPublic;
|
mapboxgl.accessToken = state.mapboxPublic;
|
||||||
@@ -585,9 +615,9 @@ export function NavDestination() {
|
|||||||
return html`
|
return html`
|
||||||
<div class="navigation-container">
|
<div class="navigation-container">
|
||||||
${() => {
|
${() => {
|
||||||
if (state.missingKeys === null) return "";
|
if (state.missingKeys === null) return "";
|
||||||
return state.missingKeys
|
return state.missingKeys
|
||||||
? html`
|
? html`
|
||||||
<section class="keys-required-wrapper">
|
<section class="keys-required-wrapper">
|
||||||
<div class="keys-required-widget">
|
<div class="keys-required-widget">
|
||||||
<div class="keys-required-title">Mapbox Keys Required</div>
|
<div class="keys-required-title">Mapbox Keys Required</div>
|
||||||
@@ -596,7 +626,7 @@ export function NavDestination() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<div class="map-wrapper">
|
<div class="map-wrapper">
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<div class="search-controls">
|
<div class="search-controls">
|
||||||
@@ -611,47 +641,47 @@ export function NavDestination() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="infobox">
|
<div id="infobox">
|
||||||
${() => {
|
${() => {
|
||||||
if (state.loadingRoute) {
|
if (state.loadingRoute) {
|
||||||
return html`<div class="navigation-summary-widget loading-status"><span class="spinner"></span> Calculating route...</div>`;
|
return html`<div class="navigation-summary-widget loading-status"><span class="spinner"></span> Calculating route...</div>`;
|
||||||
} else if (state.selectedRoute) {
|
} else if (state.selectedRoute) {
|
||||||
return NavigationDestination({
|
return NavigationDestination({
|
||||||
...state.selectedRoute,
|
...state.selectedRoute,
|
||||||
isFavorited: isRouteFavorited(state.selectedRoute, state.favoriteRoutes),
|
isFavorited: isRouteFavorited(state.selectedRoute, state.favoriteRoutes),
|
||||||
isConfirmed: () => areRoutesEqual(state.selectedRoute, state.confirmedRoute),
|
isConfirmed: () => areRoutesEqual(state.selectedRoute, state.confirmedRoute),
|
||||||
map,
|
map,
|
||||||
isMetric: state.isMetric,
|
isMetric: state.isMetric,
|
||||||
cancelNavigationFn: () => {
|
cancelNavigationFn: () => {
|
||||||
state.selectedRoute = null;
|
state.selectedRoute = null;
|
||||||
state.confirmedRoute = null;
|
state.confirmedRoute = null;
|
||||||
state.suggestions = state.previousDestinations;
|
state.suggestions = state.previousDestinations;
|
||||||
if (destinationMarker) destinationMarker.remove();
|
if (destinationMarker) destinationMarker.remove();
|
||||||
},
|
},
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
state.confirmedRoute = JSON.parse(JSON.stringify(state.selectedRoute));
|
state.confirmedRoute = JSON.parse(JSON.stringify(state.selectedRoute));
|
||||||
state.confirmedRouteRefresh = Math.random();
|
state.confirmedRouteRefresh = Math.random();
|
||||||
},
|
},
|
||||||
loadFavorites: loadFavoritesAlphabetically,
|
loadFavorites: loadFavoritesAlphabetically,
|
||||||
removeFavorite: confirmRemoveFavorite,
|
removeFavorite: confirmRemoveFavorite,
|
||||||
searchFieldState,
|
searchFieldState,
|
||||||
favoriteRoutes: state.favoriteRoutes
|
favoriteRoutes: state.favoriteRoutes
|
||||||
}, state.confirmedRouteRefresh);
|
}, state.confirmedRouteRefresh);
|
||||||
} else if (JSON.parse(state.suggestions).length > 0) {
|
} else if (JSON.parse(state.suggestions).length > 0) {
|
||||||
return SearchSuggestions({
|
return SearchSuggestions({
|
||||||
suggestions: JSON.parse(state.suggestions),
|
suggestions: JSON.parse(state.suggestions),
|
||||||
selectSuggestion,
|
selectSuggestion,
|
||||||
removeFavorite: confirmRemoveFavorite,
|
removeFavorite: confirmRemoveFavorite,
|
||||||
renameFavorite: confirmRenameFavorite,
|
renameFavorite: confirmRenameFavorite,
|
||||||
setHome: setHome,
|
setHome: setHome,
|
||||||
setWork: setWork
|
setWork: setWork
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
${() => (state.showRemoveFavoriteModal ? Modal({
|
${() => (state.showRemoveFavoriteModal ? Modal({
|
||||||
title: "Remove Favorite",
|
title: "Remove Favorite",
|
||||||
@@ -815,9 +845,9 @@ function NavigationDestination({
|
|||||||
</div>
|
</div>
|
||||||
<div class="buttonCluster">
|
<div class="buttonCluster">
|
||||||
${() =>
|
${() =>
|
||||||
isConfirmed()
|
isConfirmed()
|
||||||
? html`<button class="cancel" @click="${cancelNavigation}"><i class="bi bi-x-lg"></i> Cancel Navigation</button>`
|
? 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>`}
|
: 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>
|
<button class="favorite" @click="${toggleFavorite}">${isFavorited ? "💔 Unfavorite" : "❤️ Favorite"}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navkeys-group > .navkeys-row:last-of-type {
|
.navkeys-group>.navkeys-row:last-of-type {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
.navkeys-input:hover,
|
.navkeys-input:hover,
|
||||||
.navkeys-input:focus {
|
.navkeys-input:focus {
|
||||||
border-color: var(--thumb-color);
|
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);
|
transform: var(--hover-scale-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,4 +153,4 @@
|
|||||||
.navkeys-input {
|
.navkeys-input {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
|
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||||
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
|
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
|
||||||
import { Modal } from "/assets/components/modal.js";
|
import { Modal } from "/assets/components/modal.js";
|
||||||
|
|
||||||
@@ -124,27 +125,27 @@ async function deleteRoute(route) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetRouteName(route, dlg) {
|
async function resetRouteName(route, dlg) {
|
||||||
const res = await fetch(`/api/routes/reset_name`, {
|
const res = await fetch(`/api/routes/reset_name`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: route.name })
|
body: JSON.stringify({ name: route.name })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { timestamp } = await res.json();
|
const { timestamp } = await res.json();
|
||||||
closeDialog(dlg);
|
closeDialog(dlg);
|
||||||
const routeInList = state.routes.find(r => r.name === route.name);
|
const routeInList = state.routes.find(r => r.name === route.name);
|
||||||
if (routeInList) {
|
if (routeInList) {
|
||||||
routeInList.timestamp = formatRouteDate(timestamp);
|
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");
|
|
||||||
}
|
}
|
||||||
|
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) {
|
async function renameRoute(route) {
|
||||||
@@ -317,6 +318,16 @@ async function deleteAllRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RouteRecordings() {
|
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);
|
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@@ -332,75 +343,36 @@ export function RouteRecordings() {
|
|||||||
</button>
|
</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 (routesToShow.length === 0) {
|
||||||
if (state.loading && state.total > 0) {
|
if (state.loading && state.total > 0) {
|
||||||
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
|
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
|
||||||
}
|
}
|
||||||
if (state.loading && !state.isDeletingAll) {
|
if (state.loading && !state.isDeletingAll) {
|
||||||
return html`<p class="screen-recordings-message">Loading...</p>`;
|
return html`<p class="screen-recordings-message">Loading...</p>`;
|
||||||
}
|
}
|
||||||
if (state.isDeletingAll) {
|
if (state.isDeletingAll) {
|
||||||
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
|
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
|
||||||
}
|
}
|
||||||
if (state.showPreservedOnly) {
|
if (state.showPreservedOnly) {
|
||||||
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
|
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
|
||||||
}
|
}
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
return html`<p class="screen-recordings-message">${state.error}</p>`;
|
return html`<p class="screen-recordings-message">${state.error}</p>`;
|
||||||
}
|
}
|
||||||
return html`<p class="screen-recordings-message">No routes found...</p>`;
|
return html`<p class="screen-recordings-message">No routes found...</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="screen-recordings-grid">
|
<div class="screen-recordings-grid">
|
||||||
${routesToShow.map(
|
${routesToShow.map(
|
||||||
route => html`
|
route => html`
|
||||||
<div
|
<div
|
||||||
class="recording-card"
|
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="${() => {
|
@click="${() => {
|
||||||
state.selectedRoute = route;
|
state.selectedRoute = route;
|
||||||
}}"
|
}}"
|
||||||
>
|
>
|
||||||
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
|
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
|
||||||
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
|
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
|
||||||
@@ -410,23 +382,19 @@ export function RouteRecordings() {
|
|||||||
src="${route.png}"
|
src="${route.png}"
|
||||||
class="recording-preview recording-preview-png"
|
class="recording-preview recording-preview-png"
|
||||||
style="display:block;"
|
style="display:block;"
|
||||||
>
|
loading="lazy"
|
||||||
<img
|
|
||||||
data-src="${route.gif}"
|
|
||||||
class="recording-preview recording-preview-gif"
|
|
||||||
style="display:none;"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="recording-filename">${route.timestamp}</p>
|
<p class="recording-filename">${route.timestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}}
|
}}
|
||||||
${() => {
|
${() => {
|
||||||
if (state.routes.length > 0) {
|
if (state.routes.length > 0) {
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class="delete-all-button"
|
class="delete-all-button"
|
||||||
@click="${() => (state.showDeleteAllModal = true)}"
|
@click="${() => (state.showDeleteAllModal = true)}"
|
||||||
@@ -435,17 +403,17 @@ export function RouteRecordings() {
|
|||||||
${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
|
${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
${() => state.showDeleteAllModal ? Modal({
|
${() => state.showDeleteAllModal ? Modal({
|
||||||
title: "Confirm Delete All",
|
title: "Confirm Delete All",
|
||||||
message: "Are you sure you want to delete all routes? This action cannot be undone...",
|
message: "Are you sure you want to delete all routes? This action cannot be undone...",
|
||||||
onConfirm: deleteAllRoutes,
|
onConfirm: deleteAllRoutes,
|
||||||
onCancel: () => { state.showDeleteAllModal = false; },
|
onCancel: () => { state.showDeleteAllModal = false; },
|
||||||
confirmText: "Delete All"
|
confirmText: "Delete All"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
|
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||||
import { Modal } from "/assets/components/modal.js";
|
import { Modal } from "/assets/components/modal.js";
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -126,7 +127,6 @@ async function renameFile(rec) {
|
|||||||
if (recordingToUpdate) {
|
if (recordingToUpdate) {
|
||||||
recordingToUpdate.filename = newFilename
|
recordingToUpdate.filename = newFilename
|
||||||
recordingToUpdate.is_custom_name = true
|
recordingToUpdate.is_custom_name = true
|
||||||
recordingToUpdate.gif = `/screen_recordings/${val}.gif`
|
|
||||||
recordingToUpdate.png = `/screen_recordings/${val}.png`
|
recordingToUpdate.png = `/screen_recordings/${val}.png`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +142,8 @@ async function renameFile(rec) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmDeleteFile(rec) {
|
function confirmDeleteFile(rec) {
|
||||||
state.recordingToDelete = rec;
|
state.recordingToDelete = rec;
|
||||||
state.showDeleteModal = true;
|
state.showDeleteModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile() {
|
async function deleteFile() {
|
||||||
@@ -152,11 +152,11 @@ async function deleteFile() {
|
|||||||
|
|
||||||
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
|
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
refresh();
|
refresh();
|
||||||
showSnackbar("Recording deleted!");
|
showSnackbar("Recording deleted!");
|
||||||
} else {
|
} else {
|
||||||
showSnackbar("Delete failed...", "error");
|
showSnackbar("Delete failed...", "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
state.showDeleteModal = false;
|
state.showDeleteModal = false;
|
||||||
@@ -221,6 +221,16 @@ async function deleteAllRecordings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenRecordings() {
|
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)
|
if (state.selectedRecording && !overlay) openOverlay(state.selectedRecording)
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@@ -229,77 +239,37 @@ export function ScreenRecordings() {
|
|||||||
<div class="screen-recordings-title">Screen Recordings</div>
|
<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.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.error) return html`<p class="screen-recordings-message">${state.error}</p>`
|
||||||
if (state.progress > 0 && state.progress < state.total) {
|
if (state.progress > 0 && state.progress < state.total) {
|
||||||
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
|
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
|
||||||
}
|
}
|
||||||
if (state.recordings.length === 0 && !state.loading) {
|
if (state.recordings.length === 0 && !state.loading) {
|
||||||
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
|
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}}
|
}}
|
||||||
|
|
||||||
<div class="screen-recordings-grid">
|
<div class="screen-recordings-grid">
|
||||||
${() => state.recordings.map(rec => {
|
${() => state.recordings.map(rec => {
|
||||||
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
|
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="recording-card"
|
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 }}"
|
@click="${() => { state.selectedRecording = rec }}"
|
||||||
>
|
>
|
||||||
<div class="recording-preview-container">
|
<div class="recording-preview-container">
|
||||||
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;">
|
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;" loading="lazy">
|
||||||
<img data-src="${rec.gif}" class="recording-preview recording-preview-gif" style="display:none;">
|
|
||||||
</div>
|
</div>
|
||||||
<p class="recording-filename">${displayName}</p>
|
<p class="recording-filename">${displayName}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${() => {
|
${() => {
|
||||||
if (state.recordings.length > 0) {
|
if (state.recordings.length > 0) {
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class="delete-all-button"
|
class="delete-all-button"
|
||||||
@click="${() => (state.showDeleteAllModal = true)}"
|
@click="${() => (state.showDeleteAllModal = true)}"
|
||||||
@@ -307,24 +277,24 @@ export function ScreenRecordings() {
|
|||||||
Delete All Recordings
|
Delete All Recordings
|
||||||
</button>
|
</button>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
${() => state.showDeleteModal ? Modal({
|
${() => state.showDeleteModal ? Modal({
|
||||||
title: "Confirm Delete",
|
title: "Confirm Delete",
|
||||||
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
|
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
|
||||||
onConfirm: deleteFile,
|
onConfirm: deleteFile,
|
||||||
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
|
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
|
||||||
confirmText: "Delete"
|
confirmText: "Delete"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
${() => state.showDeleteAllModal ? Modal({
|
${() => state.showDeleteAllModal ? Modal({
|
||||||
title: "Confirm Delete All",
|
title: "Confirm Delete All",
|
||||||
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
|
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
|
||||||
onConfirm: deleteAllRecordings,
|
onConfirm: deleteAllRecordings,
|
||||||
onCancel: () => { state.showDeleteAllModal = false; },
|
onCancel: () => { state.showDeleteAllModal = false; },
|
||||||
confirmText: "Delete All"
|
confirmText: "Delete All"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
|
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
|
||||||
import { hideSidebar } from "/assets/js/utils.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 { 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 { 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 { NavDestination } from "/assets/components/navigation/navigation_destination.js"
|
||||||
import { NavKeys } from "/assets/components/navigation/navigation_keys.js"
|
import { NavKeys } from "/assets/components/navigation/navigation_keys.js"
|
||||||
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
|
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
|
||||||
@@ -11,11 +14,14 @@ import { SettingsView } from "/assets/components/settings.js"
|
|||||||
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
|
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
|
||||||
import { Sidebar } from "/assets/components/sidebar.js"
|
import { Sidebar } from "/assets/components/sidebar.js"
|
||||||
import { SpeedLimits } from "/assets/components/tools/speed_limits.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 { 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 { TmuxLog } from "/assets/components/tools/tmux.js"
|
||||||
import { ToggleControl } from "/assets/components/tools/toggles.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
|
let router, routerState
|
||||||
|
|
||||||
@@ -23,15 +29,16 @@ function createRoute(id, path, component) {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
path,
|
path,
|
||||||
loader: () => {},
|
loader: () => { },
|
||||||
element: component,
|
element: component,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
let routes = [
|
let routes = [
|
||||||
createRoute("doors", "/lock_or_unlock_doors", DoorControl),
|
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
|
||||||
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
|
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
|
||||||
|
createRoute("galaxy", "/galaxy", GalaxyPairing),
|
||||||
createRoute("navdestination", "/set_navigation_destination", NavDestination),
|
createRoute("navdestination", "/set_navigation_destination", NavDestination),
|
||||||
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
|
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
|
||||||
createRoute("root", "/", Home),
|
createRoute("root", "/", Home),
|
||||||
@@ -39,11 +46,16 @@ function Root() {
|
|||||||
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
|
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
|
||||||
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
|
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
|
||||||
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
|
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("thememaker", "/theme_maker", ThemeMaker),
|
||||||
|
createRoute("testing_ground", "/testing_ground", TestingGround),
|
||||||
|
createRoute("troubleshoot", "/troubleshoot", Troubleshoot),
|
||||||
createRoute("tmux", "/manage_tmux", TmuxLog),
|
createRoute("tmux", "/manage_tmux", TmuxLog),
|
||||||
createRoute("toggles", "/manage_toggles", ToggleControl),
|
createRoute("toggles", "/manage_toggles", ToggleControl),
|
||||||
createRoute("tsk_manager", "/tsk_manager", TSKManager),
|
createRoute("updates", "/manage_updates", UpdateManager),
|
||||||
|
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
|
||||||
]
|
]
|
||||||
|
|
||||||
router = createRouter({
|
router = createRouter({
|
||||||
@@ -61,13 +73,13 @@ function Root() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.subscribe(({ initialized, navigation, matches, errors }) => {
|
router.subscribe(({ initialized, navigation, matches, errors }) => {
|
||||||
const [match] = matches
|
const [match] = matches || []
|
||||||
Object.assign(routerState, {
|
Object.assign(routerState, {
|
||||||
initialized,
|
initialized,
|
||||||
activePath: match.route.path,
|
activePath: match?.route?.path || "",
|
||||||
activePathFull: match.pathname,
|
activePathFull: match?.pathname || "",
|
||||||
navigation,
|
navigation,
|
||||||
params: match.params,
|
params: match?.params || {},
|
||||||
errors,
|
errors,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -76,17 +88,21 @@ function Root() {
|
|||||||
${() => Sidebar(routerState.activePathFull)}
|
${() => Sidebar(routerState.activePathFull)}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
${() => {
|
${() => {
|
||||||
if (!routerState.initialized || routerState.navigation.state === "loading") {
|
if (!routerState.initialized) {
|
||||||
return html`<div>Loading...</div>`
|
return Home({ params: routerState.params })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (routerState.errors?.root?.status === 404) {
|
if (routerState.errors?.root?.status === 404) {
|
||||||
return html`<h1>Not Found</h1>`
|
return html`<h1>Not Found</h1>`
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = routes.find(r => r.path === routerState.activePath)
|
const match = routes.find(r => r.path === routerState.activePath)
|
||||||
return match.element({ params: routerState.params })
|
if (!match) {
|
||||||
}}
|
console.warn("[router] no route match for path:", routerState.activePathFull)
|
||||||
|
return Home({ params: routerState.params })
|
||||||
|
}
|
||||||
|
return match.element({ params: routerState.params })
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -109,4 +125,9 @@ export function Navigate(href) {
|
|||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
Root()(document.getElementById("app"))
|
if (!window.__thePondRouterMounted) {
|
||||||
|
window.__thePondRouterMounted = true
|
||||||
|
Root()(document.getElementById("app"))
|
||||||
|
} else {
|
||||||
|
console.warn("[router] duplicate mount prevented")
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown > select {
|
.dropdown>select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-color: var(--sidebar-bg);
|
background-color: var(--sidebar-bg);
|
||||||
border: var(--border-width-thin) solid var(--sidebar-border-color);
|
border: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||||
@@ -74,19 +74,19 @@ input.searchfield:focus {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .slider {
|
input:checked+.slider {
|
||||||
background-color: var(--success-bg);
|
background-color: var(--success-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .slider:before {
|
input:checked+.slider:before {
|
||||||
transform: translateX(26px);
|
transform: translateX(26px);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .slider.loading:before {
|
input:checked+.slider.loading:before {
|
||||||
animation: rotationChecked 1s linear infinite;
|
animation: rotationChecked 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus + .slider {
|
input:focus+.slider {
|
||||||
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
|
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,11 +133,11 @@ input:focus + .slider {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options > input {
|
.options>input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options > input:checked + label {
|
.options>input:checked+label {
|
||||||
background-color: var(--success-bg);
|
background-color: var(--success-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -220,14 +220,13 @@ input:focus + .slider {
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting.subsetting_link {
|
.setting.subsetting_link {}
|
||||||
}
|
|
||||||
|
|
||||||
.setting.subtoggle {
|
.setting.subtoggle {
|
||||||
margin: 0 0 0 var(--padding-xl);
|
margin: 0 0 0 var(--padding-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting.subtoggle + .setting {
|
.setting.subtoggle+.setting {
|
||||||
margin-top: var(--border-radius-xl);
|
margin-top: var(--border-radius-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +299,7 @@ input.searchfield {
|
|||||||
animation: rotation 1s linear infinite;
|
animation: rotation 1s linear infinite;
|
||||||
background-color: none !important;
|
background-color: none !important;
|
||||||
border: var(--border-width-thick) solid var(--sidebar-fg);
|
border: var(--border-width-thick) solid var(--sidebar-fg);
|
||||||
border-bottom-color: #46439b;
|
border-bottom-color: var(--main-fg);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +336,7 @@ i.switch {
|
|||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
@@ -346,7 +346,8 @@ i.switch {
|
|||||||
0% {
|
0% {
|
||||||
transform: translateX(26px) rotate(0deg);
|
transform: translateX(26px) rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(26px) rotate(360deg);
|
transform: translateX(26px) rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.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);
|
border-right: var(--border-width-thin) solid var(--sidebar-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -102,7 +105,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .menu_section > li > a span {
|
.sidebar .menu_section>li>a span {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
@@ -111,13 +114,13 @@
|
|||||||
padding-left: var(--padding-sm);
|
padding-left: var(--padding-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .menu_section > li > ul {
|
.sidebar .menu_section>li>ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .menu_section > li > ul > li {
|
.sidebar .menu_section>li>ul>li {
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
margin-bottom: var(--padding-sm);
|
margin-bottom: var(--padding-sm);
|
||||||
overflow: hidden;
|
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);
|
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;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .menu_section > li > ul > li.active,
|
.sidebar .menu_section>li>ul>li.active,
|
||||||
.sidebar .menu_section > li > ul > li:hover {
|
.sidebar .menu_section>li>ul>li:hover {
|
||||||
background-color: var(--sidebar-active-bg);
|
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);
|
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);
|
color: var(--color-white);
|
||||||
font-weight: var(--font-weight-bold);
|
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);
|
color: var(--color-white);
|
||||||
font-weight: var(--font-weight-demi-bold);
|
font-weight: var(--font-weight-demi-bold);
|
||||||
}
|
}
|
||||||
@@ -203,9 +206,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar_header p {
|
.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-size: var(--font-size-xl);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar_widget {
|
.sidebar_widget {
|
||||||
@@ -234,7 +241,7 @@
|
|||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .menu_section > li > a {
|
.sidebar .menu_section>li>a {
|
||||||
font-size: var(--padding-sm);
|
font-size: var(--padding-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +269,7 @@
|
|||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .menu_section > li > a {
|
.sidebar .menu_section>li>a {
|
||||||
font-size: var(--padding-sm);
|
font-size: var(--padding-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,4 +284,4 @@
|
|||||||
#menu_button {
|
#menu_button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,59 +14,37 @@ const MenuItems = {
|
|||||||
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
|
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
|
||||||
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
|
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
|
||||||
],
|
],
|
||||||
tailscale: [
|
|
||||||
{ name: "Tailscale", link: "/manage_tailscale", icon: "bi-wifi" },
|
|
||||||
],
|
|
||||||
tools: [
|
tools: [
|
||||||
|
{ name: "Toggles", link: "/device_settings", icon: "bi-toggle-on" },
|
||||||
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
|
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
|
||||||
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
|
{ 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: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
|
||||||
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
|
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
|
||||||
{ name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
|
{ name: "Backup and Restore", link: "/manage_toggles", icon: "bi-arrow-repeat" },
|
||||||
{ name: "Toyota Security Keys", link: "/tsk_manager", icon: "bi-key-fill" },
|
{ name: "Software", link: "/manage_updates", icon: "bi-arrow-up-circle" },
|
||||||
|
{ name: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
doorsVisible: false,
|
|
||||||
isDoorsFetched: false,
|
|
||||||
isTSKFetched: false,
|
|
||||||
tskVisible: false,
|
|
||||||
|
|
||||||
activeRoute: ""
|
activeRoute: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const activeItem = Object.values(MenuItems).flat().find(item => item.link === currentPath);
|
const matchesPath = (link) => {
|
||||||
|
if (link === "/") return currentPath === "/";
|
||||||
|
return currentPath === link || currentPath.startsWith(`${link}/`);
|
||||||
|
};
|
||||||
|
const activeItem = Object.values(MenuItems).flat().find(item => matchesPath(item.link));
|
||||||
state.activeRoute = activeItem?.name ?? "";
|
state.activeRoute = activeItem?.name ?? "";
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(link) {
|
function navigate(link) {
|
||||||
state.activeRoute = link.name;
|
state.activeRoute = link.name;
|
||||||
@@ -88,10 +66,9 @@ export function Sidebar() {
|
|||||||
<div id="sidebar" class="sidebar">
|
<div id="sidebar" class="sidebar">
|
||||||
<div>
|
<div>
|
||||||
<div class="title">
|
<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">
|
<div class="title_text sidebar_header">
|
||||||
<p>The Pond</p>
|
<p>Galaxy</p>
|
||||||
<a href="https://github.com/Aidenir">by Aidenir</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -102,30 +79,22 @@ export function Sidebar() {
|
|||||||
<span class="section-title">${upperFirst(section)}</span>
|
<span class="section-title">${upperFirst(section)}</span>
|
||||||
<ul id="${section}">
|
<ul id="${section}">
|
||||||
${links.map(link => {
|
${links.map(link => {
|
||||||
if (link.name === "Lock/Unlock Doors" && !state.doorsVisible) {
|
const isActive = state.activeRoute === link.name;
|
||||||
return "";
|
const classList = [isActive && "active"].filter(Boolean).join(" ");
|
||||||
}
|
|
||||||
|
|
||||||
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
|
const content = html`
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = state.activeRoute === link.name;
|
|
||||||
const classList = [isActive && "active"].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
const content = html`
|
|
||||||
<div class="menu-item-link">
|
<div class="menu-item-link">
|
||||||
<i class="bi ${link.icon}"></i>
|
<i class="bi ${link.icon}"></i>
|
||||||
<span>${upperFirst(link.name)}</span>
|
<span>${upperFirst(link.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li class="${classList}">
|
<li class="${classList}">
|
||||||
${Link(link.link, content, () => navigate(link))}
|
${Link(link.link, content, () => navigate(link))}
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ export function TailscaleControl() {
|
|||||||
checkInstallStatus()
|
checkInstallStatus()
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tailscale-wrapper">
|
<div class="toggle-control-widget" style="margin-top: 1.5rem">
|
||||||
<section class="tailscale-widget">
|
<section class="tailscale-widget">
|
||||||
<div class="tailscale-title">
|
<div class="toggle-control-title">
|
||||||
${() => state.installed ? 'Uninstall Tailscale' : 'Install Tailscale'}
|
${() => state.installed ? 'Uninstall Tailscale' : 'Install Tailscale'}
|
||||||
</div>
|
</div>
|
||||||
<p class="tailscale-text">
|
<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>
|
</p>
|
||||||
<div class="tailscale-button-wrapper">
|
<div class="tailscale-button-wrapper">
|
||||||
<button
|
<button
|
||||||
@@ -67,11 +68,11 @@ export function TailscaleControl() {
|
|||||||
disabled="${() => state.status === 'installing' || state.status === 'uninstalling'}"
|
disabled="${() => state.status === 'installing' || state.status === 'uninstalling'}"
|
||||||
>
|
>
|
||||||
${() => {
|
${() => {
|
||||||
if (state.status === 'installing') return 'Installing...'
|
if (state.status === 'installing') return 'Installing...'
|
||||||
if (state.status === 'uninstalling') return 'Uninstalling...'
|
if (state.status === 'uninstalling') return 'Uninstalling...'
|
||||||
if (state.installed) return 'Uninstall'
|
if (state.installed) return 'Uninstall'
|
||||||
return 'Install'
|
return 'Install'
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
<a class="tailscale-link" href="https://tailscale.com/download" target="_blank">
|
<a class="tailscale-link" href="https://tailscale.com/download" target="_blank">
|
||||||
Download Tailscale on your other devices
|
Download Tailscale on your other devices
|
||||||
@@ -79,12 +80,12 @@ export function TailscaleControl() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
${() => state.showUninstallModal ? Modal({
|
${() => state.showUninstallModal ? Modal({
|
||||||
title: "Confirm Uninstall",
|
title: "Confirm Uninstall",
|
||||||
message: "Are you sure you want to uninstall Tailscale?",
|
message: "Are you sure you want to uninstall Tailscale?",
|
||||||
onConfirm: handleAction,
|
onConfirm: handleAction,
|
||||||
onCancel: () => { state.showUninstallModal = false; },
|
onCancel: () => { state.showUninstallModal = false; },
|
||||||
confirmText: "Uninstall"
|
confirmText: "Uninstall"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/* ――― 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-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,756 @@
|
|||||||
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
return html`
|
||||||
|
<div class="ds-stepper">
|
||||||
|
<button
|
||||||
|
class="ds-stepper-btn"
|
||||||
|
?disabled="${!canDecrease}"
|
||||||
|
@click="${() => stepNumericParam(p, -1)}">-</button>
|
||||||
|
<div class="ds-stepper-meta">
|
||||||
|
<span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
|
||||||
|
<span class="ds-default-value">Default: ${defaultLabel}</span>
|
||||||
|
<button
|
||||||
|
class="ds-reset-btn"
|
||||||
|
?disabled="${!canReset}"
|
||||||
|
@click="${() => resetNumericParam(p)}">Reset to Default</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ds-stepper-btn"
|
||||||
|
?disabled="${!canIncrease}"
|
||||||
|
@click="${() => stepNumericParam(p, 1)}">+</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
` : p.ui_type === "dropdown" ? html`
|
||||||
|
<select
|
||||||
|
class="ds-select"
|
||||||
|
id="ds-${p.key}"
|
||||||
|
data-endpoint="${p.options_endpoint || ""}"
|
||||||
|
@change="${() => updateParam(p.key, "dropdown")}">
|
||||||
|
<option value="">Loading...</option>
|
||||||
|
</select>
|
||||||
|
` : html`
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="ds-toggle"
|
||||||
|
id="ds-${p.key}"
|
||||||
|
@change="${() => updateParam(p.key, "checkbox")}" />
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -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 "https://esm.sh/@arrow-js/core"
|
||||||
|
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||||
|
import { Modal } from "/assets/components/modal.js"
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
paired: false,
|
||||||
|
url: "",
|
||||||
|
password: "",
|
||||||
|
loading: true,
|
||||||
|
submitting: false,
|
||||||
|
showUnpairModal: false,
|
||||||
|
fetched: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
state.loading = true
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/galaxy/status")
|
||||||
|
const data = await res.json()
|
||||||
|
state.paired = data.paired
|
||||||
|
state.url = data.url
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch Galaxy status:", e)
|
||||||
|
}
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pair() {
|
||||||
|
if (state.submitting) return
|
||||||
|
const pw = state.password.trim()
|
||||||
|
if (pw.length < 6) {
|
||||||
|
showSnackbar("Password must be at least 6 characters.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.submitting = true
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/galaxy/pair", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: pw }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
state.paired = true
|
||||||
|
state.url = data.url
|
||||||
|
state.password = ""
|
||||||
|
// Clear the DOM input value directly (Arrow.js doesn't two-way bind)
|
||||||
|
const input = document.querySelector(".galaxy-input")
|
||||||
|
if (input) input.value = ""
|
||||||
|
showSnackbar(data.message || "Paired!")
|
||||||
|
} else {
|
||||||
|
showSnackbar(data.error || "Pairing failed.")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSnackbar("Network error — is the device reachable?")
|
||||||
|
}
|
||||||
|
state.submitting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unpair() {
|
||||||
|
state.showUnpairModal = false
|
||||||
|
state.submitting = true
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/galaxy/unpair", { method: "POST" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
state.paired = false
|
||||||
|
state.url = ""
|
||||||
|
showSnackbar(data.message || "Unpaired!")
|
||||||
|
} else {
|
||||||
|
showSnackbar(data.error || "Unpairing failed.")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showSnackbar("Network error — is the device reachable?")
|
||||||
|
}
|
||||||
|
state.submitting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GalaxyPairing() {
|
||||||
|
if (isGalaxyTunnel()) {
|
||||||
|
return html`
|
||||||
|
<div class="tunnel-notice">
|
||||||
|
<div class="tunnel-notice-icon">🛰️</div>
|
||||||
|
<h3 class="tunnel-notice-title">Galaxy Pairing Unavailable via Galaxy</h3>
|
||||||
|
<p class="tunnel-notice-body">Galaxy pairing requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.fetched) {
|
||||||
|
state.fetched = true
|
||||||
|
fetchStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="galaxy-wrapper">
|
||||||
|
<h2>Galaxy</h2>
|
||||||
|
|
||||||
|
${() => {
|
||||||
|
if (state.loading) {
|
||||||
|
return html`<div class="galaxy-loading">Checking pairing status…</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.paired) {
|
||||||
|
return html`
|
||||||
|
<section class="galaxy-widget">
|
||||||
|
<div class="galaxy-status-badge galaxy-paired">
|
||||||
|
<i class="bi bi-check-circle-fill"></i> Paired
|
||||||
|
</div>
|
||||||
|
<p class="galaxy-text">
|
||||||
|
Your device is paired with Galaxy. Access it remotely at:
|
||||||
|
</p>
|
||||||
|
<a class="galaxy-url" href="${state.url}" target="_blank" rel="noopener">
|
||||||
|
${state.url}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="galaxy-button galaxy-button-danger"
|
||||||
|
@click="${() => { state.showUnpairModal = true }}"
|
||||||
|
disabled="${() => state.submitting}"
|
||||||
|
>
|
||||||
|
${() => state.submitting ? "Unpairing…" : "Unpair"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${() => state.showUnpairModal ? Modal({
|
||||||
|
title: "Confirm Unpair",
|
||||||
|
message: "Are you sure you want to unpair from Galaxy? You will lose remote access until you pair again.",
|
||||||
|
onConfirm: unpair,
|
||||||
|
onCancel: () => { state.showUnpairModal = false },
|
||||||
|
confirmText: "Unpair",
|
||||||
|
}) : ""}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="galaxy-widget">
|
||||||
|
<div class="galaxy-status-badge galaxy-unpaired">
|
||||||
|
<i class="bi bi-x-circle-fill"></i> Not Paired
|
||||||
|
</div>
|
||||||
|
<p class="galaxy-text">
|
||||||
|
Pair your device with Galaxy to access The Pond remotely from anywhere.
|
||||||
|
Enter a password to secure your connection.
|
||||||
|
</p>
|
||||||
|
<div class="galaxy-input-group">
|
||||||
|
<input
|
||||||
|
class="galaxy-input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password (min 6 characters)"
|
||||||
|
@input="${(e) => { state.password = e.target.value }}"
|
||||||
|
@keydown="${(e) => { if (e.key === 'Enter') pair() }}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="galaxy-button"
|
||||||
|
@click="${pair}"
|
||||||
|
disabled="${() => state.submitting || state.password.trim().length < 6}"
|
||||||
|
>
|
||||||
|
${() => state.submitting ? "Pairing…" : "Pair"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
.longManeuverPage {
|
||||||
|
max-width: var(--width-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverCard {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: var(--text-color);
|
||||||
|
max-width: 90%;
|
||||||
|
padding: var(--border-radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverIntro {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverActions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverButton {
|
||||||
|
background: var(--main-fg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-demi-bold);
|
||||||
|
padding: var(--padding-sm) var(--padding-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverButton:hover {
|
||||||
|
transform: var(--hover-scale-sm);
|
||||||
|
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverButton:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverButton.danger {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverButton.danger:hover {
|
||||||
|
background: var(--danger-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverError {
|
||||||
|
color: var(--danger-fg);
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverMuted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverStatusGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em 1em;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverStatusGrid p {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverCurrent {
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverCurrent p {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0 0 var(--margin-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverInstructions,
|
||||||
|
.longManeuverHistory {
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverInstructions h3,
|
||||||
|
.longManeuverHistory h3 {
|
||||||
|
margin-bottom: var(--margin-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverInstructions ol,
|
||||||
|
.longManeuverHistory ol {
|
||||||
|
margin: var(--margin-xs) 0 0;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverInstructions li,
|
||||||
|
.longManeuverHistory li {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||||
|
.longManeuverCard {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverActions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.longManeuverStatusGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: true,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
data: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
let pollHandle = null
|
||||||
|
const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
function isLongitudinalManeuversRouteActive() {
|
||||||
|
return window.location.pathname === "/longitudinal_maneuvers"
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNumber(value, fallback = 0) {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAgeSeconds(value) {
|
||||||
|
const sec = safeNumber(value, -1)
|
||||||
|
if (sec < 0) return "unknown"
|
||||||
|
if (sec < 1) return "just now"
|
||||||
|
if (sec < 60) return `${Math.round(sec)}s ago`
|
||||||
|
const min = sec / 60
|
||||||
|
if (min < 60) return `${Math.round(min)}m ago`
|
||||||
|
const hr = min / 60
|
||||||
|
return `${Math.round(hr)}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/longitudinal_maneuvers/status")
|
||||||
|
const payload = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || response.statusText || "Failed to load maneuver status")
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data = payload
|
||||||
|
state.error = ""
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error?.message || "Failed to load maneuver status"
|
||||||
|
} finally {
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (!pollHandle) return
|
||||||
|
clearTimeout(pollHandle)
|
||||||
|
pollHandle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePolling() {
|
||||||
|
if (pollHandle) return
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!isLongitudinalManeuversRouteActive()) {
|
||||||
|
pollHandle = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchStatus()
|
||||||
|
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action) {
|
||||||
|
if (state.busy) return
|
||||||
|
state.busy = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
|
||||||
|
const payload = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || response.statusText || `Failed to ${action} maneuvers`)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data = payload
|
||||||
|
state.error = ""
|
||||||
|
showSnackbar(payload.message || "Action complete.")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || `Failed to ${action} maneuvers`
|
||||||
|
state.error = message
|
||||||
|
showSnackbar(message, "error")
|
||||||
|
} finally {
|
||||||
|
state.busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
if (initialized) return
|
||||||
|
initialized = true
|
||||||
|
fetchStatus()
|
||||||
|
ensurePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLine(label, value) {
|
||||||
|
return html`<p><strong>${label}:</strong> ${value}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LongitudinalManeuvers() {
|
||||||
|
initialize()
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="longManeuverPage">
|
||||||
|
<h2>Long Maneuvers</h2>
|
||||||
|
|
||||||
|
<div class="longManeuverCard">
|
||||||
|
<p class="longManeuverIntro">
|
||||||
|
Run the longitudinal maneuver suite from your phone and monitor progress live.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="longManeuverActions">
|
||||||
|
<button
|
||||||
|
class="longManeuverButton"
|
||||||
|
?disabled="${state.busy}"
|
||||||
|
@click="${() => runAction("start")}">
|
||||||
|
Start / Arm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="longManeuverButton danger"
|
||||||
|
?disabled="${state.busy}"
|
||||||
|
@click="${() => runAction("stop")}">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${() => state.loading ? html`<p class="longManeuverMuted">Loading status...</p>` : ""}
|
||||||
|
${() => state.error ? html`<p class="longManeuverError">${state.error}</p>` : ""}
|
||||||
|
|
||||||
|
${() => state.data ? html`
|
||||||
|
<div class="longManeuverStatusGrid">
|
||||||
|
${statusLine("Mode Enabled", state.data.modeEnabled ? "Yes" : "No")}
|
||||||
|
${statusLine("State", state.data.state || "idle")}
|
||||||
|
${statusLine("Onroad", state.data.isOnroad ? "Yes" : "No")}
|
||||||
|
${statusLine("Engaged", state.data.isEngaged ? "Yes" : "No")}
|
||||||
|
${statusLine("Phase", state.data.phase || "n/a")}
|
||||||
|
${statusLine("Paddle Mode", state.data.paddleMode || "auto")}
|
||||||
|
${statusLine("Step", `${safeNumber(state.data.stepIndex, 0)}/${safeNumber(state.data.stepTotal, 0)}`)}
|
||||||
|
${statusLine("Run", `${safeNumber(state.data.runIndex, 0)}/${safeNumber(state.data.runTotal, 0)}`)}
|
||||||
|
${statusLine("Updated", formatAgeSeconds(state.data.updatedAgeSec))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="longManeuverCurrent">
|
||||||
|
<p><strong>Current Maneuver:</strong> ${state.data.maneuver || "n/a"}</p>
|
||||||
|
<p><strong>Popup Text:</strong> ${state.data.uiText1 || ""} ${state.data.uiText2 ? `| ${state.data.uiText2}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="longManeuverInstructions">
|
||||||
|
<h3>Quick Guide</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Find a large, empty, straight road or lot with no traffic.</li>
|
||||||
|
<li>Press <strong>Start / Arm</strong> here, then engage openpilot with SET.</li>
|
||||||
|
<li>Keep full supervision and be ready to disengage at all times.</li>
|
||||||
|
<li>For GM pedal-long cars, the suite runs both pedal-only and pedal+paddle phases automatically.</li>
|
||||||
|
<li>When the status says complete, collect logs and generate your HTML report.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="longManeuverHistory">
|
||||||
|
<h3>Progress Chain</h3>
|
||||||
|
${() => (state.data.history || []).length ? html`
|
||||||
|
<ol>
|
||||||
|
${(state.data.history || []).slice().reverse().map((line) => html`<li>${line}</li>`)}
|
||||||
|
</ol>
|
||||||
|
` : html`<p class="longManeuverMuted">No steps logged yet.</p>`}
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
.mm-wrapper {
|
||||||
|
color: var(--text-color);
|
||||||
|
max-width: 1200px;
|
||||||
|
padding-right: var(--padding-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: var(--margin-base) 0;
|
||||||
|
padding: var(--padding-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-summary {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-status {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
margin-bottom: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-progress {
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-filters {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
margin-bottom: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-search,
|
||||||
|
.mm-select {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
min-height: 2.15rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-search {
|
||||||
|
min-width: min(420px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-filter-label,
|
||||||
|
.mm-filter-checkbox {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-filter-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-series {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
margin-bottom: var(--margin-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-series-header {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid var(--sidebar-border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-series-header h3 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-series-header span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--sidebar-border-color);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row-title {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
color: var(--text-color);
|
||||||
|
min-height: 2.2rem;
|
||||||
|
min-width: 7.5rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn-primary {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--success-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn-secondary {
|
||||||
|
background: var(--sidebar-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn-danger {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--danger-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-chip {
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-chip-active {
|
||||||
|
background: var(--success-bg);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-chip-favorite {
|
||||||
|
background: rgba(212, 160, 96, 0.18);
|
||||||
|
border-color: rgba(212, 160, 96, 0.5);
|
||||||
|
color: var(--warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-chip-warning {
|
||||||
|
background: rgba(224, 85, 119, 0.12);
|
||||||
|
border-color: rgba(224, 85, 119, 0.45);
|
||||||
|
color: var(--danger-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-star {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #e0b45a;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.mm-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-row-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-search {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-filter-break {
|
||||||
|
display: block;
|
||||||
|
flex-basis: 100%;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import { html, reactive } from "https://esm.sh/@arrow-js/core";
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: true,
|
||||||
|
refreshing: false,
|
||||||
|
error: "",
|
||||||
|
actionBusy: false,
|
||||||
|
sortMode: "alphabetical",
|
||||||
|
communityFavoriteFilter: "all",
|
||||||
|
models: [],
|
||||||
|
currentModel: "",
|
||||||
|
summary: { installed: 0, missing: 0, total: 0 },
|
||||||
|
status: {
|
||||||
|
modelToDownload: "",
|
||||||
|
downloadAll: false,
|
||||||
|
downloading: false,
|
||||||
|
cancelling: false,
|
||||||
|
progress: "",
|
||||||
|
isOnroad: false,
|
||||||
|
terminal: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
let pollingHandle = null;
|
||||||
|
let statusInFlight = false;
|
||||||
|
let lastStatusSignature = "";
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 20000;
|
||||||
|
const ACTIVE_POLL_INTERVAL_MS = 1000;
|
||||||
|
const IDLE_POLL_INTERVAL_MS = 4000;
|
||||||
|
|
||||||
|
function notify(message, variant = "success") {
|
||||||
|
if (typeof showSnackbar === "function") {
|
||||||
|
showSnackbar(message, variant);
|
||||||
|
} else if (variant === "error") {
|
||||||
|
console.error(message);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDebug(message, details = null) {
|
||||||
|
if (details === null || details === undefined) {
|
||||||
|
console.log(`[ModelManager] ${message}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[ModelManager] ${message}`, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelRouteActive() {
|
||||||
|
return window.location.pathname === "/manage_models";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeText(value, fallback = "") {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value) {
|
||||||
|
return !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReleased(value) {
|
||||||
|
const ts = Date.parse(safeText(value, ""));
|
||||||
|
return Number.isNaN(ts) ? 0 : ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSeries(model) {
|
||||||
|
return safeText(model?.series, "Custom Series") || "Custom Series";
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSortCompare(a, b) {
|
||||||
|
if (state.sortMode === "release_date") {
|
||||||
|
const dateDelta = parseReleased(b?.released) - parseReleased(a?.released);
|
||||||
|
if (dateDelta !== 0) return dateDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeText(a?.label, a?.value).localeCompare(
|
||||||
|
safeText(b?.label, b?.value),
|
||||||
|
undefined,
|
||||||
|
{ sensitivity: "base", numeric: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredModels() {
|
||||||
|
let rows = [...state.models].filter(model => model && typeof model === "object");
|
||||||
|
|
||||||
|
if (state.communityFavoriteFilter === "yes") {
|
||||||
|
rows = rows.filter(model => !!model.communityFavorite);
|
||||||
|
} else if (state.communityFavoriteFilter === "no") {
|
||||||
|
rows = rows.filter(model => !model.communityFavorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeriesGroups() {
|
||||||
|
const grouped = {};
|
||||||
|
|
||||||
|
for (const model of getFilteredModels()) {
|
||||||
|
const seriesName = normalizeSeries(model);
|
||||||
|
if (!grouped[seriesName]) grouped[seriesName] = [];
|
||||||
|
grouped[seriesName].push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesNames = Object.keys(grouped);
|
||||||
|
for (const seriesName of seriesNames) {
|
||||||
|
grouped[seriesName].sort(modelSortCompare);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.sortMode === "release_date") {
|
||||||
|
seriesNames.sort((a, b) => {
|
||||||
|
const aNewest = Math.max(...grouped[a].map(model => parseReleased(model?.released)));
|
||||||
|
const bNewest = Math.max(...grouped[b].map(model => parseReleased(model?.released)));
|
||||||
|
const delta = bNewest - aNewest;
|
||||||
|
if (delta !== 0) return delta;
|
||||||
|
return a.localeCompare(b, undefined, { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
seriesNames.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { grouped, seriesNames };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleModels() {
|
||||||
|
const { grouped, seriesNames } = getSeriesGroups();
|
||||||
|
const rows = [];
|
||||||
|
for (const seriesName of seriesNames) {
|
||||||
|
rows.push(...grouped[seriesName]);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReleaseOrderedModels() {
|
||||||
|
return getFilteredModels().sort(modelSortCompare);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstalledModels() {
|
||||||
|
const rows = state.sortMode === "release_date" ? getReleaseOrderedModels() : getVisibleModels();
|
||||||
|
return rows.filter(model => !!model.installed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentModelName() {
|
||||||
|
const current = safeText(state.currentModel, "");
|
||||||
|
if (!current) return "none";
|
||||||
|
|
||||||
|
const match = state.models.find(model => safeText(model?.value, "") === current);
|
||||||
|
if (!match) return current;
|
||||||
|
|
||||||
|
return safeText(match.label, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = payload?.error || payload?.message || `Request failed (${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
if (statusInFlight) return;
|
||||||
|
statusInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson("/api/models/status");
|
||||||
|
|
||||||
|
const models = Array.isArray(payload.models)
|
||||||
|
? payload.models.filter(model => model && typeof model === "object")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
state.models = models;
|
||||||
|
state.currentModel = safeText(payload.currentModel, "");
|
||||||
|
|
||||||
|
const summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {};
|
||||||
|
state.summary = {
|
||||||
|
installed: toInt(summary.installed),
|
||||||
|
missing: toInt(summary.missing),
|
||||||
|
total: toInt(summary.total),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.status = {
|
||||||
|
modelToDownload: safeText(payload.modelToDownload, ""),
|
||||||
|
downloadAll: toBool(payload.downloadAll),
|
||||||
|
downloading: toBool(payload.downloading),
|
||||||
|
cancelling: toBool(payload.cancelling),
|
||||||
|
progress: safeText(payload.progress, ""),
|
||||||
|
isOnroad: toBool(payload.isOnroad),
|
||||||
|
terminal: toBool(payload.terminal),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.error = "";
|
||||||
|
|
||||||
|
const signature = [
|
||||||
|
state.models.length,
|
||||||
|
state.currentModel,
|
||||||
|
state.status.downloading,
|
||||||
|
state.status.downloadAll,
|
||||||
|
state.status.modelToDownload,
|
||||||
|
state.status.progress,
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
if (signature !== lastStatusSignature) {
|
||||||
|
lastStatusSignature = signature;
|
||||||
|
logDebug("Status updated", {
|
||||||
|
models: state.models.length,
|
||||||
|
currentModel: state.currentModel || "none",
|
||||||
|
downloading: state.status.downloading,
|
||||||
|
progress: state.status.progress || "Idle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error?.message || String(error);
|
||||||
|
logDebug("Status fetch failed", state.error);
|
||||||
|
} finally {
|
||||||
|
statusInFlight = false;
|
||||||
|
state.loading = false;
|
||||||
|
state.refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll(showToast = false) {
|
||||||
|
state.refreshing = true;
|
||||||
|
if (state.models.length === 0) {
|
||||||
|
state.loading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchStatus();
|
||||||
|
|
||||||
|
if (showToast && !state.error) {
|
||||||
|
notify("Model list refreshed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePolling() {
|
||||||
|
if (pollingHandle) return;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!isModelRouteActive()) {
|
||||||
|
pollingHandle = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextDelay = IDLE_POLL_INTERVAL_MS;
|
||||||
|
try {
|
||||||
|
await fetchStatus();
|
||||||
|
nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
||||||
|
} finally {
|
||||||
|
pollingHandle = setTimeout(poll, nextDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActiveModel(modelKey) {
|
||||||
|
const payload = await fetchJson("/api/params", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ key: "Model", value: modelKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
notify(payload.message || `Selected "${modelKey}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownload(modelKey) {
|
||||||
|
const payload = await fetchJson("/api/models/download", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: modelKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
notify(payload.message || `Downloading "${modelKey}"...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownloadAll() {
|
||||||
|
const payload = await fetchJson("/api/models/download_all", { method: "POST" });
|
||||||
|
notify(payload.message || "Started downloading all models.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelDownload() {
|
||||||
|
const payload = await fetchJson("/api/models/cancel", { method: "POST" });
|
||||||
|
notify(payload.message || "Cancellation requested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteModel(modelKey) {
|
||||||
|
const payload = await fetchJson("/api/models/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: modelKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
notify(payload.message || `Deleted files for "${modelKey}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshManifest() {
|
||||||
|
const payload = await fetchJson("/api/models/refresh_manifest", { method: "POST" });
|
||||||
|
notify(payload.message || "Model manifest refreshed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action, modelKey = "") {
|
||||||
|
if (state.actionBusy) {
|
||||||
|
notify("Please wait for the current action to finish.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.actionBusy = true;
|
||||||
|
try {
|
||||||
|
if (action === "refresh") {
|
||||||
|
await refreshManifest();
|
||||||
|
await refreshAll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status.isOnroad && action !== "refresh") {
|
||||||
|
notify("Actions are blocked while onroad.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "select") {
|
||||||
|
if (!modelKey) return;
|
||||||
|
await setActiveModel(modelKey);
|
||||||
|
} else if (action === "download") {
|
||||||
|
if (!modelKey) return;
|
||||||
|
await startDownload(modelKey);
|
||||||
|
} else if (action === "download-all") {
|
||||||
|
await startDownloadAll();
|
||||||
|
} else if (action === "cancel") {
|
||||||
|
await cancelDownload();
|
||||||
|
} else if (action === "delete") {
|
||||||
|
if (!modelKey) return;
|
||||||
|
const confirmed = window.confirm(`Delete local files for model \"${modelKey}\"?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
await deleteModel(modelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchStatus();
|
||||||
|
} catch (error) {
|
||||||
|
notify(error?.message || String(error), "error");
|
||||||
|
} finally {
|
||||||
|
state.actionBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDomHandlers() {
|
||||||
|
if (window.__modelManagerHandlersBound) return;
|
||||||
|
window.__modelManagerHandlersBound = true;
|
||||||
|
|
||||||
|
document.addEventListener("click", event => {
|
||||||
|
if (!isModelRouteActive()) return;
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
|
||||||
|
const button = target.closest("[data-mm-action]");
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
const action = safeText(button.getAttribute("data-mm-action"), "");
|
||||||
|
const modelKey = safeText(button.getAttribute("data-model"), "");
|
||||||
|
|
||||||
|
runAction(action, modelKey).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("change", event => {
|
||||||
|
if (!isModelRouteActive()) return;
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLSelectElement)) return;
|
||||||
|
if (target.id === "mm-active-model-select") {
|
||||||
|
const modelKey = safeText(target.value, "");
|
||||||
|
if (!modelKey) return;
|
||||||
|
runAction("select", modelKey).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === "mm-sort-mode-select") {
|
||||||
|
const value = safeText(target.value, "alphabetical");
|
||||||
|
state.sortMode = value === "release_date" ? "release_date" : "alphabetical";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === "mm-community-filter-select") {
|
||||||
|
const value = safeText(target.value, "all");
|
||||||
|
if (value === "yes" || value === "no" || value === "all") {
|
||||||
|
state.communityFavoriteFilter = value;
|
||||||
|
} else {
|
||||||
|
state.communityFavoriteFilter = "all";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActions(model) {
|
||||||
|
const modelKey = safeText(model.value, "");
|
||||||
|
const modelIsDownloading = state.status.downloading && !state.status.downloadAll && state.status.modelToDownload === modelKey;
|
||||||
|
|
||||||
|
if (state.currentModel === modelKey) {
|
||||||
|
return html`<span class="mm-chip mm-chip-active">Active</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status.downloading) {
|
||||||
|
if (state.status.downloadAll || modelIsDownloading) {
|
||||||
|
return html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel</button>`;
|
||||||
|
}
|
||||||
|
return html`<span class="mm-chip">Busy</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.installed) {
|
||||||
|
return html`
|
||||||
|
<button class="mm-btn mm-btn-secondary" data-mm-action="select" data-model="${modelKey}">Set Active</button>
|
||||||
|
<button class="mm-btn mm-btn-danger" data-mm-action="delete" data-model="${modelKey}">Delete</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<button class="mm-btn mm-btn-primary" data-mm-action="download" data-model="${modelKey}">Download</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModelRow(model) {
|
||||||
|
const label = safeText(model.label, safeText(model.value, "Unnamed"));
|
||||||
|
const key = safeText(model.value, "");
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="mm-row">
|
||||||
|
<div class="mm-row-main">
|
||||||
|
<div class="mm-row-title">
|
||||||
|
<span>${label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mm-row-meta">
|
||||||
|
<span class="mm-chip">${key}</span>
|
||||||
|
${state.sortMode === "release_date" ? "" : model.series ? html`<span class="mm-chip">${safeText(model.series)}</span>` : ""}
|
||||||
|
${model.version ? html`<span class="mm-chip">Version ${safeText(model.version)}</span>` : ""}
|
||||||
|
${model.released ? html`<span class="mm-chip">Released ${safeText(model.released)}</span>` : ""}
|
||||||
|
${model.communityFavorite ? html`<span class="mm-chip mm-chip-favorite">Community Favorite</span>` : ""}
|
||||||
|
${model.partial ? html`<span class="mm-chip mm-chip-warning">Partial Files</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mm-row-actions">
|
||||||
|
${renderActions(model)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeriesSection(seriesName, models) {
|
||||||
|
return html`
|
||||||
|
<section class="mm-series">
|
||||||
|
<header class="mm-series-header">
|
||||||
|
<h3>${seriesName}</h3>
|
||||||
|
<span>${models.length}</span>
|
||||||
|
</header>
|
||||||
|
<div class="mm-series-body">
|
||||||
|
${models.map(model => renderModelRow(model))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelManager() {
|
||||||
|
if (!initialized) {
|
||||||
|
initialized = true;
|
||||||
|
bindDomHandlers();
|
||||||
|
logDebug("Initializing component");
|
||||||
|
refreshAll().catch(error => {
|
||||||
|
state.error = error?.message || String(error);
|
||||||
|
state.loading = false;
|
||||||
|
state.refreshing = false;
|
||||||
|
logDebug("Initial refresh failed", state.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ensurePolling();
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="mm-wrapper">
|
||||||
|
<h2>Model Manager</h2>
|
||||||
|
|
||||||
|
${() => state.error ? html`<div class="mm-error">${state.error}</div>` : ""}
|
||||||
|
|
||||||
|
<div class="mm-debug">
|
||||||
|
Available Models=${() => state.models.length}
|
||||||
|
Current Model=${() => getCurrentModelName()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-toolbar">
|
||||||
|
<div class="mm-summary">
|
||||||
|
<span><b>${state.summary.installed}</b> installed</span>
|
||||||
|
<span><b>${state.summary.missing}</b> missing</span>
|
||||||
|
<span><b>${state.summary.total}</b> total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-actions">
|
||||||
|
${() => state.status.downloading
|
||||||
|
? html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel Download</button>`
|
||||||
|
: html`<button class="mm-btn mm-btn-primary" data-mm-action="download-all">Download All Missing</button>`}
|
||||||
|
<button class="mm-btn mm-btn-secondary" data-mm-action="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-status">
|
||||||
|
<span class="mm-chip">Current: ${getCurrentModelName()}</span>
|
||||||
|
<span class="mm-chip">Progress: ${safeText(state.status.progress, "Idle")}</span>
|
||||||
|
${() => state.status.isOnroad ? html`<span class="mm-chip mm-chip-warning">Onroad: actions disabled</span>` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-filters">
|
||||||
|
<label class="mm-filter-label" for="mm-active-model-select">Active Model</label>
|
||||||
|
<select class="mm-select" id="mm-active-model-select">
|
||||||
|
${(() => {
|
||||||
|
const orderedInstalled = getInstalledModels().sort((a, b) => {
|
||||||
|
const aCurrent = safeText(a.value) === state.currentModel ? 0 : 1;
|
||||||
|
const bCurrent = safeText(b.value) === state.currentModel ? 0 : 1;
|
||||||
|
if (aCurrent !== bCurrent) return aCurrent - bCurrent;
|
||||||
|
return safeText(a.label, a.value).localeCompare(safeText(b.label, b.value), undefined, { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderedInstalled.length > 0
|
||||||
|
? orderedInstalled.map(model => html`
|
||||||
|
<option value="${safeText(model.value)}" ${safeText(model.value) === state.currentModel ? "selected" : ""}>
|
||||||
|
${safeText(model.label, model.value)}
|
||||||
|
</option>
|
||||||
|
`)
|
||||||
|
: html`<option value="">No installed models</option>`;
|
||||||
|
})()}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="mm-filter-label" for="mm-sort-mode-select">Sort</label>
|
||||||
|
<select class="mm-select" id="mm-sort-mode-select">
|
||||||
|
<option value="alphabetical" ${state.sortMode === "alphabetical" ? "selected" : ""}>Alphabetical</option>
|
||||||
|
<option value="release_date" ${state.sortMode === "release_date" ? "selected" : ""}>Release Date</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="mm-filter-break"></div>
|
||||||
|
|
||||||
|
<label class="mm-filter-label" for="mm-community-filter-select">Community Favorite</label>
|
||||||
|
<select class="mm-select" id="mm-community-filter-select">
|
||||||
|
<option value="all" ${state.communityFavoriteFilter === "all" ? "selected" : ""}>All</option>
|
||||||
|
<option value="yes" ${state.communityFavoriteFilter === "yes" ? "selected" : ""}>Yes</option>
|
||||||
|
<option value="no" ${state.communityFavoriteFilter === "no" ? "selected" : ""}>No</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${() => state.loading ? html`<div class="mm-empty">Loading models...</div>` : ""}
|
||||||
|
|
||||||
|
${() => !state.loading ? html`
|
||||||
|
<div class="mm-list">
|
||||||
|
${(() => {
|
||||||
|
if (state.sortMode === "release_date") {
|
||||||
|
const models = getReleaseOrderedModels();
|
||||||
|
return models.length === 0
|
||||||
|
? html`<div class="mm-empty">No models available.</div>`
|
||||||
|
: models.map(model => renderModelRow(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { grouped, seriesNames } = getSeriesGroups();
|
||||||
|
return seriesNames.length === 0
|
||||||
|
? html`<div class="mm-empty">No models available.</div>`
|
||||||
|
: seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
.plotsPage {
|
||||||
|
max-width: var(--width-xxxxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotCard {
|
||||||
|
background-color: var(--secondary-bg);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: var(--text-color);
|
||||||
|
max-width: 95%;
|
||||||
|
padding: var(--border-radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotStatusCard {
|
||||||
|
margin-bottom: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotDescription {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotStatusGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em 1em;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotStatusGrid p,
|
||||||
|
.plotRangeRow span,
|
||||||
|
.plotLegendItem {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotActions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotButton {
|
||||||
|
background: var(--main-fg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-demi-bold);
|
||||||
|
padding: var(--padding-sm) var(--padding-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotButton:hover {
|
||||||
|
transform: var(--hover-scale-sm);
|
||||||
|
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotCharts {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotAdvancedRow {
|
||||||
|
margin: var(--margin-base) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotChartCard {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotCardHeader {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotSource {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegend {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
margin-bottom: var(--margin-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendItem {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine {
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
display: inline-block;
|
||||||
|
height: 3px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine.desired,
|
||||||
|
.plotLine.desired {
|
||||||
|
background-color: var(--main-fg);
|
||||||
|
stroke: var(--main-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine.actual,
|
||||||
|
.plotLine.actual {
|
||||||
|
background-color: var(--success-fg);
|
||||||
|
stroke: var(--success-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine.p,
|
||||||
|
.plotLine.p {
|
||||||
|
background-color: #63b3ff;
|
||||||
|
stroke: #63b3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine.i,
|
||||||
|
.plotLine.i {
|
||||||
|
background-color: #63d79d;
|
||||||
|
stroke: #63d79d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine.d,
|
||||||
|
.plotLine.d {
|
||||||
|
background-color: #f0b35e;
|
||||||
|
stroke: #f0b35e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegendLine.f,
|
||||||
|
.plotLine.f {
|
||||||
|
background-color: #d08bff;
|
||||||
|
stroke: #d08bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotSvgWrap {
|
||||||
|
background: #0b0b18;
|
||||||
|
border: 1px solid var(--track-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
min-height: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotSvg {
|
||||||
|
display: block;
|
||||||
|
height: 260px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLine {
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotGridLine {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotZeroLine {
|
||||||
|
stroke: rgba(255, 255, 255, 0.35);
|
||||||
|
stroke-dasharray: 6 6;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotRangeRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: var(--margin-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotEmpty {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
height: 260px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotError {
|
||||||
|
color: var(--danger-fg);
|
||||||
|
margin-top: var(--margin-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityDetail {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: calc(var(--font-size-sm) * 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualitySummaryGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
margin-top: var(--margin-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualitySummaryRow {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--track-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--padding-sm) var(--padding-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualitySentence {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: calc(var(--font-size-lg) * 1.05);
|
||||||
|
font-weight: var(--font-weight-demi-bold);
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityTone {
|
||||||
|
font-size: calc(var(--font-size-xl) * 1.05);
|
||||||
|
font-weight: var(--font-weight-extra-bold);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityTone-great {
|
||||||
|
color: #66e3ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityTone-good {
|
||||||
|
color: #7fc2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityTone-fair {
|
||||||
|
color: #f8c36d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityTone-poor {
|
||||||
|
color: #ff8ca5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityTone-na {
|
||||||
|
color: #d7dcec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityMethodNote {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: calc(var(--font-size-sm) * 0.92);
|
||||||
|
margin-top: var(--margin-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qualityUserGuidance {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: calc(var(--font-size-sm) * 0.95);
|
||||||
|
margin: var(--margin-sm) 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||||
|
.plotCard,
|
||||||
|
.plotCharts {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotCharts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotStatusGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotActions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plotLegend {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
paused: false,
|
||||||
|
showAdvancedTerms: false,
|
||||||
|
live: null,
|
||||||
|
samples: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
let pollHandle = null
|
||||||
|
let lastTimestamp = 0
|
||||||
|
let visibilityListenerAttached = false
|
||||||
|
|
||||||
|
const MAX_POINTS = 240
|
||||||
|
const POLL_INTERVAL_MS = 750
|
||||||
|
const SVG_WIDTH = 1000
|
||||||
|
const SVG_HEIGHT = 260
|
||||||
|
const ADVANCED_TERMS_KEY = "plotsShowAdvancedTerms"
|
||||||
|
const QUALITY_WINDOW_SECONDS = 30
|
||||||
|
const QUALITY_MIN_SAMPLES = 12
|
||||||
|
|
||||||
|
const LATERAL_QUALITY_CONFIG = {
|
||||||
|
desiredKey: "desiredLateralAccel",
|
||||||
|
actualKey: "actualLateralAccel",
|
||||||
|
minSpeedMps: 0.5,
|
||||||
|
minDemand: 0.015,
|
||||||
|
allowLowDemandFallback: true,
|
||||||
|
fallbackMinSpeedMps: 1.0,
|
||||||
|
fallbackMinPeakDemand: 0.03,
|
||||||
|
great: 0.15,
|
||||||
|
good: 0.30,
|
||||||
|
fair: 0.50,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LONGITUDINAL_QUALITY_CONFIG = {
|
||||||
|
desiredKey: "desiredLongitudinalAccel",
|
||||||
|
actualKey: "actualLongitudinalAccel",
|
||||||
|
minSpeedMps: 0.0,
|
||||||
|
minDemand: 0.08,
|
||||||
|
allowLowDemandFallback: false,
|
||||||
|
applyPersistenceRules: true,
|
||||||
|
warnError: 0.50,
|
||||||
|
severeError: 0.90,
|
||||||
|
great: 0.32,
|
||||||
|
good: 0.52,
|
||||||
|
fair: 0.78,
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlotsRouteActive() {
|
||||||
|
return window.location.pathname === "/plots"
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value, fallback = 0) {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value, digits = 2) {
|
||||||
|
return toNumber(value).toFixed(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(seconds) {
|
||||||
|
const value = Math.max(0, toNumber(seconds))
|
||||||
|
if (value < 1) return `${Math.round(value * 1000)} ms`
|
||||||
|
return `${value.toFixed(1)} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSourceLabel(kind, source) {
|
||||||
|
const normalizedKind = String(kind || "").toLowerCase()
|
||||||
|
const normalizedSource = String(source || "").toLowerCase()
|
||||||
|
|
||||||
|
if (normalizedKind === "lateral") {
|
||||||
|
if (normalizedSource === "torquestate") return "Steering controller output"
|
||||||
|
if (normalizedSource === "curvature") return "Path model estimate"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKind === "longitudinal") {
|
||||||
|
if (normalizedSource.includes("atarget")) return "Planner target acceleration + measured acceleration"
|
||||||
|
if (normalizedSource.includes("pid sum")) return "PID term sum + measured acceleration"
|
||||||
|
if (normalizedSource.includes("caroutput")) return "Final accel command + measured acceleration"
|
||||||
|
if (normalizedSource.includes("livelocationkalman")) return "Control output + calibrated acceleration"
|
||||||
|
if (normalizedSource === "controlsstate") return "Planner target output"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKind === "lateralterms") {
|
||||||
|
if (normalizedSource === "torquestate") return "Steering torque controller"
|
||||||
|
if (normalizedSource === "pidstate") return "Steering angle PID controller"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKind === "longitudinalterms") {
|
||||||
|
if (normalizedSource === "controlsstate") return "Longitudinal controller terms"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Live control signal"
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile(sortedValues, percentileValue) {
|
||||||
|
const values = Array.isArray(sortedValues) ? sortedValues : []
|
||||||
|
if (!values.length) return 0
|
||||||
|
const p = Math.max(0, Math.min(1, Number(percentileValue)))
|
||||||
|
const index = (values.length - 1) * p
|
||||||
|
const lower = Math.floor(index)
|
||||||
|
const upper = Math.ceil(index)
|
||||||
|
if (lower === upper) return values[lower]
|
||||||
|
const weight = index - lower
|
||||||
|
return values[lower] * (1 - weight) + values[upper] * weight
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQualityLabel(label) {
|
||||||
|
const text = String(label || "").trim()
|
||||||
|
return text || "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMatchQuality(samples, config) {
|
||||||
|
const safeSamples = Array.isArray(samples) ? samples : []
|
||||||
|
if (safeSamples.length < 2) {
|
||||||
|
return { label: "N/A", value: null, detail: "Waiting for data" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTs = toNumber(safeSamples[safeSamples.length - 1]?.timestamp, 0)
|
||||||
|
const cutoffTs = latestTs > 0 ? latestTs - QUALITY_WINDOW_SECONDS : 0
|
||||||
|
const recentSamples = safeSamples.filter((sample) => toNumber(sample?.timestamp, 0) >= cutoffTs)
|
||||||
|
|
||||||
|
const demandEligibleSamples = recentSamples.filter((sample) => {
|
||||||
|
const speed = Math.abs(toNumber(sample?.speed, 0))
|
||||||
|
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
|
||||||
|
|
||||||
|
const speedOk = config.minSpeedMps <= 0 ? true : speed >= config.minSpeedMps
|
||||||
|
const demandOk = config.minDemand <= 0 ? true : desired >= config.minDemand
|
||||||
|
return speedOk && demandOk
|
||||||
|
})
|
||||||
|
|
||||||
|
let eligibleSamples = demandEligibleSamples
|
||||||
|
let usedLowDemandFallback = false
|
||||||
|
const allowLowDemandFallback = config.allowLowDemandFallback !== false
|
||||||
|
if (allowLowDemandFallback && eligibleSamples.length < QUALITY_MIN_SAMPLES && recentSamples.length >= QUALITY_MIN_SAMPLES) {
|
||||||
|
const totalSpeed = recentSamples.reduce((sum, sample) => sum + Math.abs(toNumber(sample?.speed, 0)), 0)
|
||||||
|
const avgSpeed = recentSamples.length > 0 ? (totalSpeed / recentSamples.length) : 0
|
||||||
|
const peakDemand = recentSamples.reduce((peak, sample) => {
|
||||||
|
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
|
||||||
|
return Math.max(peak, desired)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const fallbackMinSpeed = Math.max(0, toNumber(config.fallbackMinSpeedMps, 0))
|
||||||
|
const fallbackMinPeakDemand = Math.max(0, toNumber(config.fallbackMinPeakDemand, 0))
|
||||||
|
if (avgSpeed >= fallbackMinSpeed && peakDemand >= fallbackMinPeakDemand) {
|
||||||
|
eligibleSamples = recentSamples
|
||||||
|
usedLowDemandFallback = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligibleSamples.length < QUALITY_MIN_SAMPLES) {
|
||||||
|
if (allowLowDemandFallback) {
|
||||||
|
return {
|
||||||
|
label: "N/A",
|
||||||
|
value: null,
|
||||||
|
detail: `Need ${QUALITY_MIN_SAMPLES} samples (${eligibleSamples.length} eligible / ${recentSamples.length} total)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "N/A",
|
||||||
|
value: null,
|
||||||
|
detail: `Need ${QUALITY_MIN_SAMPLES} demand samples (${eligibleSamples.length} demand / ${recentSamples.length} total)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = eligibleSamples
|
||||||
|
.map((sample) => Math.abs(toNumber(sample?.[config.desiredKey], 0) - toNumber(sample?.[config.actualKey], 0)))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
if (!errors.length) {
|
||||||
|
return { label: "N/A", value: null, detail: "No eligible samples" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const p50 = percentile(errors, 0.50)
|
||||||
|
const p90 = percentile(errors, 0.90)
|
||||||
|
const robustError = (0.7 * p50) + (0.3 * p90)
|
||||||
|
|
||||||
|
const sampleSummary = `${eligibleSamples.length} samples / ${QUALITY_WINDOW_SECONDS}s${usedLowDemandFallback ? ", low-demand fallback" : ""}`
|
||||||
|
|
||||||
|
if (config.applyPersistenceRules) {
|
||||||
|
const warnThreshold = Math.max(config.warnError || 0.35, config.good || 0.35)
|
||||||
|
const severeThreshold = Math.max(config.severeError || 0.70, config.fair || 0.55)
|
||||||
|
const warnFrac = errors.filter((value) => value > warnThreshold).length / errors.length
|
||||||
|
const severeFrac = errors.filter((value) => value > severeThreshold).length / errors.length
|
||||||
|
|
||||||
|
let label = "Poor"
|
||||||
|
if (robustError <= config.great && warnFrac <= 0.18 && severeFrac <= 0.05) label = "Great"
|
||||||
|
else if (robustError <= config.good && warnFrac <= 0.34 && severeFrac <= 0.12) label = "Good"
|
||||||
|
else if (robustError <= config.fair && warnFrac <= 0.55 && severeFrac <= 0.24) label = "Fair"
|
||||||
|
|
||||||
|
const warnPct = Math.round(warnFrac * 100)
|
||||||
|
const severePct = Math.round(severeFrac * 100)
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value: robustError,
|
||||||
|
detail: `${sampleSummary}, ${warnPct}% > ${formatValue(warnThreshold)} and ${severePct}% > ${formatValue(severeThreshold)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = "Poor"
|
||||||
|
if (robustError <= config.great) label = "Great"
|
||||||
|
else if (robustError <= config.good) label = "Good"
|
||||||
|
else if (robustError <= config.fair) label = "Fair"
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value: robustError,
|
||||||
|
detail: sampleSummary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityToneClass(label) {
|
||||||
|
const normalized = String(label || "").toLowerCase()
|
||||||
|
if (normalized === "great") return "qualityTone-great"
|
||||||
|
if (normalized === "good") return "qualityTone-good"
|
||||||
|
if (normalized === "fair") return "qualityTone-fair"
|
||||||
|
if (normalized === "poor") return "qualityTone-poor"
|
||||||
|
return "qualityTone-na"
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (!pollHandle) return
|
||||||
|
clearTimeout(pollHandle)
|
||||||
|
pollHandle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSample(payload) {
|
||||||
|
const timestamp = toNumber(payload.timestamp, 0)
|
||||||
|
if (!timestamp || timestamp <= 0 || timestamp === lastTimestamp) return
|
||||||
|
|
||||||
|
lastTimestamp = timestamp
|
||||||
|
state.samples.push({
|
||||||
|
timestamp,
|
||||||
|
speed: toNumber(payload.speed),
|
||||||
|
controlsActive: !!payload.controlsActive,
|
||||||
|
longitudinalControlActive: !!payload.longitudinalControlActive,
|
||||||
|
desiredLateralAccel: toNumber(payload.desiredLateralAccel),
|
||||||
|
actualLateralAccel: toNumber(payload.actualLateralAccel),
|
||||||
|
desiredLongitudinalAccel: toNumber(payload.desiredLongitudinalAccel),
|
||||||
|
actualLongitudinalAccel: toNumber(payload.actualLongitudinalAccel),
|
||||||
|
lateralP: toNumber(payload.lateralP),
|
||||||
|
lateralI: toNumber(payload.lateralI),
|
||||||
|
lateralD: toNumber(payload.lateralD),
|
||||||
|
lateralF: toNumber(payload.lateralF),
|
||||||
|
longitudinalUpAccelCmd: toNumber(payload.longitudinalUpAccelCmd),
|
||||||
|
longitudinalUiAccelCmd: toNumber(payload.longitudinalUiAccelCmd),
|
||||||
|
longitudinalUfAccelCmd: toNumber(payload.longitudinalUfAccelCmd),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (state.samples.length > MAX_POINTS) {
|
||||||
|
state.samples.splice(0, state.samples.length - MAX_POINTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLiveData() {
|
||||||
|
const response = await fetch("/api/plots/live")
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || response.statusText || "Failed to load live plot data")
|
||||||
|
}
|
||||||
|
|
||||||
|
state.live = payload
|
||||||
|
state.error = ""
|
||||||
|
state.loading = false
|
||||||
|
|
||||||
|
if (!payload.stale) {
|
||||||
|
pushSample(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePolling() {
|
||||||
|
if (pollHandle) return
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!isPlotsRouteActive()) {
|
||||||
|
pollHandle = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.paused) {
|
||||||
|
try {
|
||||||
|
await fetchLiveData()
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error?.message || String(error)
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
state.samples = []
|
||||||
|
lastTimestamp = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePaused() {
|
||||||
|
state.paused = !state.paused
|
||||||
|
if (state.paused) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlotsRouteActive()) return
|
||||||
|
|
||||||
|
fetchLiveData().catch((error) => {
|
||||||
|
state.error = error?.message || String(error)
|
||||||
|
})
|
||||||
|
ensurePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAdvancedTerms() {
|
||||||
|
state.showAdvancedTerms = !state.showAdvancedTerms
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ADVANCED_TERMS_KEY, state.showAdvancedTerms ? "1" : "0")
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to persist plots advanced terms preference", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function yForValue(value, min, max) {
|
||||||
|
const safeMin = toNumber(min, -1)
|
||||||
|
const safeMax = toNumber(max, 1)
|
||||||
|
const clamped = Math.max(safeMin, Math.min(safeMax, toNumber(value)))
|
||||||
|
const span = Math.max(1e-6, safeMax - safeMin)
|
||||||
|
return ((safeMax - clamped) / span) * SVG_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRange(samples, desiredKey, actualKey) {
|
||||||
|
let maxAbs = 0
|
||||||
|
for (const sample of samples) {
|
||||||
|
maxAbs = Math.max(
|
||||||
|
maxAbs,
|
||||||
|
Math.abs(toNumber(sample?.[desiredKey])),
|
||||||
|
Math.abs(toNumber(sample?.[actualKey])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfSpan = Math.max(1.5, Math.ceil((maxAbs * 1.25) * 10) / 10)
|
||||||
|
return { min: -halfSpan, max: halfSpan }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPolyline(samples, key, min, max) {
|
||||||
|
if (!samples.length) return ""
|
||||||
|
|
||||||
|
const lastIndex = Math.max(1, samples.length - 1)
|
||||||
|
return samples.map((sample, index) => {
|
||||||
|
const x = (index / lastIndex) * SVG_WIDTH
|
||||||
|
const y = yForValue(sample?.[key], min, max)
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||||
|
}).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRangeForKeys(samples, keys) {
|
||||||
|
let maxAbs = 0
|
||||||
|
for (const sample of samples) {
|
||||||
|
for (const key of keys) {
|
||||||
|
maxAbs = Math.max(maxAbs, Math.abs(toNumber(sample?.[key])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfSpan = Math.max(0.15, Math.ceil((maxAbs * 1.35) * 1000) / 1000)
|
||||||
|
return { min: -halfSpan, max: halfSpan }
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestSampleValue(key) {
|
||||||
|
const latest = state.samples[state.samples.length - 1]
|
||||||
|
if (latest) return latest[key]
|
||||||
|
return toNumber(state.live?.[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlotCard(title, desiredKey, actualKey, sourceKind, sourceLabel, desiredLabel = "Target", actualLabel = "Measured") {
|
||||||
|
const hasData = state.samples.length > 1
|
||||||
|
const range = computeRange(state.samples, desiredKey, actualKey)
|
||||||
|
const desiredPolyline = buildPolyline(state.samples, desiredKey, range.min, range.max)
|
||||||
|
const actualPolyline = buildPolyline(state.samples, actualKey, range.min, range.max)
|
||||||
|
const zeroY = yForValue(0, range.min, range.max)
|
||||||
|
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
|
||||||
|
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="plotCard plotChartCard">
|
||||||
|
<div class="plotCardHeader">
|
||||||
|
<h2>${title}</h2>
|
||||||
|
<span class="plotSource">Source: ${formatSourceLabel(sourceKind, sourceLabel)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotLegend">
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine desired"></i>${desiredLabel}: ${formatValue(latestSampleValue(desiredKey))}</span>
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine actual"></i>${actualLabel}: ${formatValue(latestSampleValue(actualKey))}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotSvgWrap">
|
||||||
|
${hasData ? html`
|
||||||
|
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="${title} live plot">
|
||||||
|
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
|
||||||
|
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
|
||||||
|
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
|
||||||
|
<polyline class="plotLine desired" points="${desiredPolyline}"></polyline>
|
||||||
|
<polyline class="plotLine actual" points="${actualPolyline}"></polyline>
|
||||||
|
</svg>
|
||||||
|
` : html`
|
||||||
|
<div class="plotEmpty">Waiting for enough live samples...</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotRangeRow">
|
||||||
|
<span>${formatValue(range.max, 1)} m/s²</span>
|
||||||
|
<span>0.0 m/s²</span>
|
||||||
|
<span>${formatValue(range.min, 1)} m/s²</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function LateralTermsPlotCard(sourceLabel) {
|
||||||
|
const hasData = state.samples.length > 1
|
||||||
|
const keys = ["lateralP", "lateralI", "lateralD", "lateralF"]
|
||||||
|
const range = computeRangeForKeys(state.samples, keys)
|
||||||
|
const zeroY = yForValue(0, range.min, range.max)
|
||||||
|
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
|
||||||
|
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="plotCard plotChartCard">
|
||||||
|
<div class="plotCardHeader">
|
||||||
|
<h2>Lateral Controller Terms</h2>
|
||||||
|
<span class="plotSource">Source: ${formatSourceLabel("lateralTerms", sourceLabel)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotLegend">
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine p"></i>P: ${formatValue(latestSampleValue("lateralP"), 3)}</span>
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine i"></i>I: ${formatValue(latestSampleValue("lateralI"), 3)}</span>
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine d"></i>D: ${formatValue(latestSampleValue("lateralD"), 3)}</span>
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine f"></i>F: ${formatValue(latestSampleValue("lateralF"), 3)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotSvgWrap">
|
||||||
|
${hasData ? html`
|
||||||
|
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="Lateral controller terms live plot">
|
||||||
|
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
|
||||||
|
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
|
||||||
|
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
|
||||||
|
<polyline class="plotLine p" points="${buildPolyline(state.samples, "lateralP", range.min, range.max)}"></polyline>
|
||||||
|
<polyline class="plotLine i" points="${buildPolyline(state.samples, "lateralI", range.min, range.max)}"></polyline>
|
||||||
|
<polyline class="plotLine d" points="${buildPolyline(state.samples, "lateralD", range.min, range.max)}"></polyline>
|
||||||
|
<polyline class="plotLine f" points="${buildPolyline(state.samples, "lateralF", range.min, range.max)}"></polyline>
|
||||||
|
</svg>
|
||||||
|
` : html`
|
||||||
|
<div class="plotEmpty">Waiting for enough live samples...</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotRangeRow">
|
||||||
|
<span>${formatValue(range.max, 3)}</span>
|
||||||
|
<span>0.000</span>
|
||||||
|
<span>${formatValue(range.min, 3)}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function LongitudinalTermsPlotCard(sourceLabel) {
|
||||||
|
const hasData = state.samples.length > 1
|
||||||
|
const keys = ["longitudinalUpAccelCmd", "longitudinalUiAccelCmd", "longitudinalUfAccelCmd"]
|
||||||
|
const range = computeRangeForKeys(state.samples, keys)
|
||||||
|
const zeroY = yForValue(0, range.min, range.max)
|
||||||
|
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
|
||||||
|
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="plotCard plotChartCard">
|
||||||
|
<div class="plotCardHeader">
|
||||||
|
<h2>Longitudinal Accel Cmd Terms</h2>
|
||||||
|
<span class="plotSource">Source: ${formatSourceLabel("longitudinalTerms", sourceLabel)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotLegend">
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine p"></i>Up: ${formatValue(latestSampleValue("longitudinalUpAccelCmd"), 3)}</span>
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine i"></i>Ui: ${formatValue(latestSampleValue("longitudinalUiAccelCmd"), 3)}</span>
|
||||||
|
<span class="plotLegendItem"><i class="plotLegendLine f"></i>Uf: ${formatValue(latestSampleValue("longitudinalUfAccelCmd"), 3)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotSvgWrap">
|
||||||
|
${hasData ? html`
|
||||||
|
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="Longitudinal accel cmd terms live plot">
|
||||||
|
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
|
||||||
|
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
|
||||||
|
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
|
||||||
|
<polyline class="plotLine p" points="${buildPolyline(state.samples, "longitudinalUpAccelCmd", range.min, range.max)}"></polyline>
|
||||||
|
<polyline class="plotLine i" points="${buildPolyline(state.samples, "longitudinalUiAccelCmd", range.min, range.max)}"></polyline>
|
||||||
|
<polyline class="plotLine f" points="${buildPolyline(state.samples, "longitudinalUfAccelCmd", range.min, range.max)}"></polyline>
|
||||||
|
</svg>
|
||||||
|
` : html`
|
||||||
|
<div class="plotEmpty">Waiting for enough live samples...</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotRangeRow">
|
||||||
|
<span>${formatValue(range.max, 3)}</span>
|
||||||
|
<span>0.000</span>
|
||||||
|
<span>${formatValue(range.min, 3)}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
try {
|
||||||
|
state.showAdvancedTerms = localStorage.getItem(ADVANCED_TERMS_KEY) === "1"
|
||||||
|
} catch (error) {
|
||||||
|
state.showAdvancedTerms = false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchLiveData()
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error?.message || String(error)
|
||||||
|
state.loading = false
|
||||||
|
} finally {
|
||||||
|
ensurePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visibilityListenerAttached) {
|
||||||
|
visibilityListenerAttached = true
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlotsRouteActive() && !state.paused) {
|
||||||
|
fetchLiveData().catch((error) => {
|
||||||
|
state.error = error?.message || String(error)
|
||||||
|
state.loading = false
|
||||||
|
})
|
||||||
|
ensurePolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LivePlots() {
|
||||||
|
if (!initialized) {
|
||||||
|
initialized = true
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
if (!state.paused) {
|
||||||
|
ensurePolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="plotsPage">
|
||||||
|
<h1>Plots</h1>
|
||||||
|
|
||||||
|
<section class="plotCard plotStatusCard">
|
||||||
|
<p class="plotDescription">
|
||||||
|
Live comparison view for tuning diagnostics. These scores are a quick health check, not a final verdict. Short spikes from bumps, lane changes, traffic transitions, and manual inputs can temporarily lower a score.
|
||||||
|
</p>
|
||||||
|
<p class="qualityUserGuidance">
|
||||||
|
Work in progress: use this as trend guidance over time. If you see a brief "Poor," keep driving and look for repeated behavior across multiple situations before changing tune values.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="plotStatusGrid">
|
||||||
|
<p><strong>Onroad:</strong> ${state.live?.isOnroad ? "Yes" : "No"}</p>
|
||||||
|
<p><strong>Sample Age:</strong> ${formatAge(state.live?.sampleAgeSeconds)}</p>
|
||||||
|
<p><strong>Vehicle Speed:</strong> ${formatValue(state.live?.speed)} m/s</p>
|
||||||
|
<p><strong>Samples:</strong> ${state.samples.length}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${() => {
|
||||||
|
const lateralQuality = computeMatchQuality(state.samples, LATERAL_QUALITY_CONFIG)
|
||||||
|
const longQuality = computeMatchQuality(state.samples, LONGITUDINAL_QUALITY_CONFIG)
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="qualitySummaryGrid">
|
||||||
|
<div class="qualitySummaryRow">
|
||||||
|
<p class="qualitySentence">
|
||||||
|
Your lateral tuning is
|
||||||
|
<span class="qualityTone ${qualityToneClass(lateralQuality.label)}">${formatQualityLabel(lateralQuality.label)}</span>
|
||||||
|
</p>
|
||||||
|
<p class="qualityDetail">
|
||||||
|
${lateralQuality.value === null ? lateralQuality.detail : `${formatValue(lateralQuality.value)} m/s² error (${lateralQuality.detail})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="qualitySummaryRow">
|
||||||
|
<p class="qualitySentence">
|
||||||
|
Your longitudinal tuning is
|
||||||
|
<span class="qualityTone ${qualityToneClass(longQuality.label)}">${formatQualityLabel(longQuality.label)}</span>
|
||||||
|
</p>
|
||||||
|
<p class="qualityDetail">
|
||||||
|
${longQuality.value === null ? longQuality.detail : `${formatValue(longQuality.value)} m/s² error (${longQuality.detail})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div class="plotActions">
|
||||||
|
<button class="plotButton" @click="${togglePaused}">
|
||||||
|
${state.paused ? "Resume Live" : "Pause Live"}
|
||||||
|
</button>
|
||||||
|
<button class="plotButton" @click="${clearHistory}">
|
||||||
|
Clear History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${state.error ? html`<p class="plotError"><strong>Error:</strong> ${state.error}</p>` : ""}
|
||||||
|
${state.live?.lastError ? html`<p class="plotError"><strong>Source Error:</strong> ${state.live.lastError}</p>` : ""}
|
||||||
|
<p class="qualityMethodNote">
|
||||||
|
Match rating uses a 30-second rolling window. Lateral prefers true steering-demand moments, and longitudinal also checks how much of the window stays above error limits so brief spikes are less likely to mark "Poor."
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="plotCharts">
|
||||||
|
${PlotCard(
|
||||||
|
"Lateral Response (m/s²)",
|
||||||
|
"desiredLateralAccel",
|
||||||
|
"actualLateralAccel",
|
||||||
|
"lateral",
|
||||||
|
state.live?.lateralSource,
|
||||||
|
"Target",
|
||||||
|
"Measured",
|
||||||
|
)}
|
||||||
|
${PlotCard(
|
||||||
|
"Longitudinal Response (m/s²)",
|
||||||
|
"desiredLongitudinalAccel",
|
||||||
|
"actualLongitudinalAccel",
|
||||||
|
"longitudinal",
|
||||||
|
state.live?.longitudinalSource,
|
||||||
|
"Target",
|
||||||
|
"Measured",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plotAdvancedRow">
|
||||||
|
<button class="plotButton" @click="${toggleAdvancedTerms}">
|
||||||
|
${state.showAdvancedTerms ? "Hide Advanced Controller Terms" : "Show Advanced Controller Terms"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${state.showAdvancedTerms ? html`
|
||||||
|
<div class="plotCharts">
|
||||||
|
${LateralTermsPlotCard(state.live?.lateralTermsSource)}
|
||||||
|
${LongitudinalTermsPlotCard(state.live?.longitudinalTermsSource)}
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
${state.loading ? html`<p>Loading live data...</p>` : ""}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 "https://esm.sh/@arrow-js/core"
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
busy: false,
|
||||||
|
data: null,
|
||||||
|
selectedSlot: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
function slotId(slot) {
|
||||||
|
return String(slot?.id || "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnusedSlot(slot) {
|
||||||
|
const name = String(slot?.name || "").trim().toLowerCase()
|
||||||
|
return name === "unused" || name.startsWith("unused ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectableSlots() {
|
||||||
|
const selectable = Array.isArray(state.data?.selectableSlots) ? state.data.selectableSlots : []
|
||||||
|
if (selectable.length) return selectable
|
||||||
|
|
||||||
|
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
|
||||||
|
return slots.filter((slot) => !isUnusedSlot(slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleSlotLines() {
|
||||||
|
return getSelectableSlots().map((slot) => `${slot.id}. ${slot.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedSlot() {
|
||||||
|
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
|
||||||
|
const selectedId = String(state.selectedSlot || "").trim()
|
||||||
|
if (!selectedId) return null
|
||||||
|
return slots.find((slot) => slotId(slot) === selectedId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveSlot() {
|
||||||
|
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
|
||||||
|
const activeId = String(state.data?.activeSlot || "").trim()
|
||||||
|
if (!activeId) return null
|
||||||
|
return slots.find((slot) => slotId(slot) === activeId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantLabels(slot) {
|
||||||
|
if (!slot || typeof slot !== "object") {
|
||||||
|
return { A: "A" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = {}
|
||||||
|
const rawVariantLabels = slot.variantLabels
|
||||||
|
if (rawVariantLabels && typeof rawVariantLabels === "object") {
|
||||||
|
Object.entries(rawVariantLabels).forEach(([rawMode, rawLabel]) => {
|
||||||
|
const mode = String(rawMode || "").trim().toUpperCase()
|
||||||
|
const label = String(rawLabel || "").trim()
|
||||||
|
if (mode.length === 1 && /^[A-Z]$/.test(mode) && label) {
|
||||||
|
labels[mode] = label
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const aLabel = String(slot.aLabel || "").trim()
|
||||||
|
const bLabel = String(slot.bLabel || "").trim()
|
||||||
|
if (aLabel) labels.A = labels.A || aLabel
|
||||||
|
if (bLabel) labels.B = labels.B || bLabel
|
||||||
|
if (!labels.A) labels.A = "A"
|
||||||
|
|
||||||
|
return Object.keys(labels).sort().reduce((acc, key) => {
|
||||||
|
acc[key] = labels[key]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantModes(slot) {
|
||||||
|
return Object.keys(getVariantLabels(slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultMode(slot) {
|
||||||
|
const modes = getVariantModes(slot)
|
||||||
|
if (modes.includes("A")) return "A"
|
||||||
|
return modes[0] || "A"
|
||||||
|
}
|
||||||
|
|
||||||
|
function toModeLabel(slot, mode) {
|
||||||
|
const labels = getVariantLabels(slot)
|
||||||
|
return labels[String(mode || "").trim().toUpperCase()] || String(mode || "").trim().toUpperCase() || "A"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModeActive(mode) {
|
||||||
|
const normalizedMode = String(mode || "").trim().toUpperCase()
|
||||||
|
return String(state.data?.activeSlot || "").trim() === String(state.selectedSlot || "").trim() && String(state.data?.activeVariant || "").trim().toUpperCase() === normalizedMode
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedMode() {
|
||||||
|
const selectedSlot = getSelectedSlot()
|
||||||
|
if (!selectedSlot) return "A"
|
||||||
|
if (String(state.data?.activeSlot || "").trim() === String(state.selectedSlot || "").trim()) {
|
||||||
|
return String(state.data?.activeVariant || "").trim().toUpperCase() || getDefaultMode(selectedSlot)
|
||||||
|
}
|
||||||
|
return getDefaultMode(selectedSlot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeButtonClass(mode) {
|
||||||
|
const normalizedMode = String(mode || "").trim().toUpperCase()
|
||||||
|
return [
|
||||||
|
"testingGroundModeButton",
|
||||||
|
`testingGroundModeButton${normalizedMode}`,
|
||||||
|
isModeActive(normalizedMode) ? "active" : "",
|
||||||
|
].filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTestingGrounds() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/testing_grounds")
|
||||||
|
const payload = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || response.statusText || "Failed to load testing grounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data = payload
|
||||||
|
state.error = ""
|
||||||
|
|
||||||
|
const selectable = Array.isArray(payload.selectableSlots) ? payload.selectableSlots : []
|
||||||
|
const hasCurrentSelection = selectable.some((slot) => slotId(slot) === String(state.selectedSlot || "").trim())
|
||||||
|
if (!hasCurrentSelection) {
|
||||||
|
const activeSlot = String(payload.activeSlot || "").trim()
|
||||||
|
const activeSelectable = selectable.some((slot) => slotId(slot) === activeSlot)
|
||||||
|
state.selectedSlot = activeSelectable ? activeSlot : slotId(selectable[0] || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || "Failed to load testing grounds"
|
||||||
|
state.error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySelection(slotValue, mode, showToast = true) {
|
||||||
|
const normalizedSlot = String(slotValue || "").trim()
|
||||||
|
const normalizedMode = String(mode || "").trim().toUpperCase()
|
||||||
|
if (!normalizedSlot || !normalizedMode) return false
|
||||||
|
|
||||||
|
state.busy = true
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/testing_grounds/select", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slotId: normalizedSlot, variant: normalizedMode }),
|
||||||
|
})
|
||||||
|
const payload = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || response.statusText || "Failed to update testing ground mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data = payload
|
||||||
|
state.error = ""
|
||||||
|
state.selectedSlot = normalizedSlot
|
||||||
|
if (showToast) {
|
||||||
|
showSnackbar(payload.message || `Testing Ground ${normalizedSlot} set to ${normalizedMode}.`)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || "Failed to update testing ground mode"
|
||||||
|
state.error = message
|
||||||
|
if (showToast) {
|
||||||
|
showSnackbar(message, "error")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
state.busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMode(mode) {
|
||||||
|
if (state.busy) return
|
||||||
|
|
||||||
|
const slot = getSelectedSlot()
|
||||||
|
const slotValue = slotId(slot)
|
||||||
|
if (!slotValue) {
|
||||||
|
showSnackbar("Select a testing ground first.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await applySelection(slotValue, mode, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectSlot(slotValue) {
|
||||||
|
const normalizedSlot = String(slotValue || "").trim()
|
||||||
|
state.selectedSlot = normalizedSlot
|
||||||
|
if (!normalizedSlot || state.busy) return
|
||||||
|
|
||||||
|
const activeSlot = String(state.data?.activeSlot || "").trim()
|
||||||
|
if (normalizedSlot === activeSlot) return
|
||||||
|
|
||||||
|
const success = await applySelection(normalizedSlot, "A", false)
|
||||||
|
if (!success) {
|
||||||
|
state.selectedSlot = activeSlot
|
||||||
|
if (state.error) {
|
||||||
|
showSnackbar(state.error, "error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
if (initialized) return
|
||||||
|
initialized = true
|
||||||
|
|
||||||
|
fetchTestingGrounds().finally(() => {
|
||||||
|
state.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestingGround() {
|
||||||
|
initialize()
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="testingGroundPage">
|
||||||
|
<h2>Testing Ground</h2>
|
||||||
|
${() => state.loading ? html`<div class="testingGroundCard">Loading testing ground state...</div>` : ""}
|
||||||
|
${() => !state.loading ? html`
|
||||||
|
<div class="testingGroundCard">
|
||||||
|
<p class="testingGroundIntro">
|
||||||
|
A/B Tuning Sandbox. If you don't know what this is, you probably shouldn't be here ;)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="testingGroundTopList">
|
||||||
|
<strong>Current Test Slots</strong>
|
||||||
|
<ol>
|
||||||
|
${getVisibleSlotLines().map((line) => html`<li>${line}</li>`)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="testingGroundStatusGrid">
|
||||||
|
<p><strong>Selected Slot:</strong> ${getSelectedSlot()?.name || "Unknown"}</p>
|
||||||
|
<p><strong>Selected Mode:</strong> ${getSelectedMode()}</p>
|
||||||
|
<p><strong>Onroad:</strong> ${state.data?.isOnroad ? "Yes" : "No"}</p>
|
||||||
|
<p><strong>Selectable Slots:</strong> ${getSelectableSlots().length}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="testingGroundSelectionRow">
|
||||||
|
<select
|
||||||
|
class="testingGroundSelect"
|
||||||
|
?disabled="${state.busy || getSelectableSlots().length === 0}"
|
||||||
|
@change="${(event) => {
|
||||||
|
selectSlot(String(event.target.value || ""))
|
||||||
|
}}">
|
||||||
|
${() => getSelectableSlots().length
|
||||||
|
? getSelectableSlots().map((slot) => html`<option value="${slotId(slot)}" ${slotId(slot) === state.selectedSlot ? "selected" : ""}>${slot.id}. ${slot.name}</option>`)
|
||||||
|
: html`<option value="">No active test slots</option>`
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${() => getSelectedSlot() ? html`
|
||||||
|
<div class="testingGroundDetails">
|
||||||
|
<h3>${getSelectedSlot().name}</h3>
|
||||||
|
${() => getSelectedSlot().description ? html`<p>${getSelectedSlot().description}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
${() => getSelectedSlot() ? html`
|
||||||
|
<div class="testingGroundActionGrid">
|
||||||
|
${() => getVariantModes(getSelectedSlot()).map((mode) => html`
|
||||||
|
<button
|
||||||
|
class="${modeButtonClass(mode)}"
|
||||||
|
?disabled="${state.busy}"
|
||||||
|
@click="${() => selectMode(mode)}">
|
||||||
|
<span class="testingGroundModeLetter">${mode}</span>
|
||||||
|
<span class="testingGroundModeLabel">${toModeLabel(getSelectedSlot(), mode)}</span>
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
|
${() => state.error ? html`<p class="testingGroundError"><strong>Error:</strong> ${state.error}</p>` : ""}
|
||||||
|
|
||||||
|
${() => getActiveSlot() ? html`
|
||||||
|
<p class="testingGroundActiveSummary">
|
||||||
|
Currently active: <strong>${getActiveSlot().id}. ${getActiveSlot().name}</strong> in mode <strong>${state.data?.activeVariant || "A"}</strong>.
|
||||||
|
</p>
|
||||||
|
` : ""}
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -77,11 +77,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox {
|
.checklist-item input[type="checkbox"]:checked~.custom-checkbox {
|
||||||
background-color: var(--success-bg);
|
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);
|
transform: translateX(24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: color var(--transition-fast), transform 0.2s;
|
transition: color var(--transition-fast), transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-clear-button:hover {
|
.file-clear-button:hover {
|
||||||
color: var(--danger-hover-bg);
|
color: var(--danger-hover-bg);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
@@ -613,7 +614,7 @@ label.file-upload-button {
|
|||||||
.turn-signal-input:focus,
|
.turn-signal-input:focus,
|
||||||
.turn-signal-input:hover {
|
.turn-signal-input:hover {
|
||||||
border-color: var(--thumb-color);
|
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;
|
outline: none;
|
||||||
transform: var(--hover-scale-sm);
|
transform: var(--hover-scale-sm);
|
||||||
}
|
}
|
||||||
@@ -690,8 +691,15 @@ label.file-upload-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modalFadeIn {
|
@keyframes modalFadeIn {
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
from {
|
||||||
to { opacity: 1; transform: scale(1); }
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||||
@@ -713,8 +721,8 @@ label.file-upload-button {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button-wrapper > button {
|
.save-button-wrapper>button {
|
||||||
flex-basis: calc(50% - 1rem);
|
flex-basis: calc(50% - 1rem);
|
||||||
margin-bottom: var(--gap-sm);
|
margin-bottom: var(--gap-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,8 @@ const state = reactive({
|
|||||||
showDeleteConfirmModal: false,
|
showDeleteConfirmModal: false,
|
||||||
themeToDelete: null,
|
themeToDelete: null,
|
||||||
themes: [],
|
themes: [],
|
||||||
activeTab: "colors"
|
activeTab: "colors",
|
||||||
|
fetched: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let draggedIndex = -1;
|
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() {
|
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 || "")
|
const normalize = (str) => (str || "")
|
||||||
.toString()
|
.toString()
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
import { formatSecondsToHuman } from "/assets/js/utils.js"
|
import { formatSecondsToHuman, isGalaxyTunnel } from "/assets/js/utils.js"
|
||||||
import { Modal } from "/assets/components/modal.js";
|
import { Modal } from "/assets/components/modal.js";
|
||||||
|
|
||||||
const logSelectorState = reactive({
|
const logSelectorState = reactive({
|
||||||
@@ -12,6 +12,16 @@ const logSelectorState = reactive({
|
|||||||
newName: ""
|
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() {
|
async function loadTmuxLogs() {
|
||||||
if (logSelectorState.loading || logSelectorState.logsLoadedOnce) return;
|
if (logSelectorState.loading || logSelectorState.logsLoadedOnce) return;
|
||||||
|
|
||||||
@@ -122,34 +132,34 @@ function TmuxLogSelector({ action, closeFn }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${() => {
|
${() => {
|
||||||
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
|
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
|
||||||
return html`<div class="fileEntry"><p>Loading...</p></div>`;
|
return html`<div class="fileEntry"><p>Loading...</p></div>`;
|
||||||
}
|
}
|
||||||
if (logSelectorState.files.length === 0) {
|
if (logSelectorState.files.length === 0) {
|
||||||
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
|
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
|
||||||
}
|
}
|
||||||
return logSelectorState.files.map(file => html`
|
return logSelectorState.files.map(file => html`
|
||||||
<div class="fileEntry" @click="${() => handleFileClick(file)}">
|
<div class="fileEntry" @click="${() => handleFileClick(file)}">
|
||||||
<p><span class="label">Filename:</span> <span class="value">${file.filename}</span></p>
|
<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">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>
|
<p><span class="label">Age:</span> <span class="value">${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}</span></p>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
<button @click="${closeFn}" class="cancel-button">Close</button>
|
<button @click="${closeFn}" class="cancel-button">Close</button>
|
||||||
|
|
||||||
${() => logSelectorState.logToDelete ? Modal({
|
${() => logSelectorState.logToDelete ? Modal({
|
||||||
title: "Confirm Delete",
|
title: "Confirm Delete",
|
||||||
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
|
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
|
||||||
onConfirm: confirmDeleteFile,
|
onConfirm: confirmDeleteFile,
|
||||||
onCancel: () => { logSelectorState.logToDelete = null },
|
onCancel: () => { logSelectorState.logToDelete = null },
|
||||||
confirmText: "Yes, Delete"
|
confirmText: "Yes, Delete"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
|
|
||||||
${() => logSelectorState.logToRename ? Modal({
|
${() => logSelectorState.logToRename ? Modal({
|
||||||
title: "Rename Log",
|
title: "Rename Log",
|
||||||
message: html`
|
message: html`
|
||||||
<div>
|
<div>
|
||||||
<p>Rename <strong>${logSelectorState.logToRename.filename}</strong> to:</p>
|
<p>Rename <strong>${logSelectorState.logToRename.filename}</strong> to:</p>
|
||||||
<div style="margin-top: 10px;">
|
<div style="margin-top: 10px;">
|
||||||
@@ -163,14 +173,14 @@ function TmuxLogSelector({ action, closeFn }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
onConfirm: confirmRenameFile,
|
onConfirm: confirmRenameFile,
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
logSelectorState.logToRename = null;
|
logSelectorState.logToRename = null;
|
||||||
logSelectorState.newName = "";
|
logSelectorState.newName = "";
|
||||||
},
|
},
|
||||||
confirmText: "Rename",
|
confirmText: "Rename",
|
||||||
confirmClass: "btn-primary"
|
confirmClass: "btn-primary"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@@ -182,23 +192,143 @@ export function TmuxLog() {
|
|||||||
latest: '',
|
latest: '',
|
||||||
log: '',
|
log: '',
|
||||||
selectorAction: null,
|
selectorAction: null,
|
||||||
|
transport: "connecting",
|
||||||
});
|
});
|
||||||
|
const lifecycleVersion = ++tmuxLifecycleVersion;
|
||||||
|
let lifecycleTimer = null;
|
||||||
|
|
||||||
const event_source = new EventSource("/api/tmux_log/live");
|
function applyIncomingLog(data) {
|
||||||
|
state.latest = data || "";
|
||||||
event_source.onmessage = e => {
|
|
||||||
state.latest = e.data;
|
|
||||||
if (!state.paused) {
|
if (!state.paused) {
|
||||||
state.log = state.latest;
|
state.log = state.latest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event_source.onerror = err => {
|
function stopLiveTransport() {
|
||||||
console.error("Error receiving tmux log:", err);
|
if (liveEventSource) {
|
||||||
event_source.close();
|
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;
|
state.paused = !state.paused;
|
||||||
if (!state.paused) {
|
if (!state.paused) {
|
||||||
state.log = state.latest;
|
state.log = state.latest;
|
||||||
@@ -248,7 +378,7 @@ export function TmuxLog() {
|
|||||||
<div class="tmux-block">
|
<div class="tmux-block">
|
||||||
<div class="tmux-wrapper">
|
<div class="tmux-wrapper">
|
||||||
<div class="tmuxContainer">
|
<div class="tmuxContainer">
|
||||||
<div class="tmuxHeader">Tmux Live Log</div>
|
<div class="tmuxHeader">${() => getStatusLabel()}</div>
|
||||||
<pre class="tmuxLog">${() => state.log}</pre>
|
<pre class="tmuxLog">${() => state.log}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,20 +393,20 @@ export function TmuxLog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${() => state.selectorAction
|
${() => state.selectorAction
|
||||||
? TmuxLogSelector({
|
? TmuxLogSelector({
|
||||||
action: state.selectorAction,
|
action: state.selectorAction,
|
||||||
closeFn: () => (state.selectorAction = null)
|
closeFn: () => (state.selectorAction = null)
|
||||||
})
|
})
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
${() => logSelectorState.showDeleteAllModal ? Modal({
|
${() => logSelectorState.showDeleteAllModal ? Modal({
|
||||||
title: "Delete All Logs",
|
title: "Delete All Logs",
|
||||||
message: "Are you sure you want to delete all of your session logs?",
|
message: "Are you sure you want to delete all of your session logs?",
|
||||||
onConfirm: deleteAllSessions,
|
onConfirm: deleteAllSessions,
|
||||||
onCancel: () => { logSelectorState.showDeleteAllModal = false },
|
onCancel: () => { logSelectorState.showDeleteAllModal = false },
|
||||||
confirmText: "Delete All"
|
confirmText: "Delete All"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
import { Modal } from "/assets/components/modal.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({
|
const state = reactive({
|
||||||
showResetDefaultModal: false,
|
showResetDefaultModal: false,
|
||||||
showResetStockModal: false,
|
showResetStockModal: false,
|
||||||
@@ -14,7 +15,7 @@ export function ToggleControl () {
|
|||||||
fileInput.addEventListener("change", restoreToggles)
|
fileInput.addEventListener("change", restoreToggles)
|
||||||
document.body.appendChild(fileInput)
|
document.body.appendChild(fileInput)
|
||||||
|
|
||||||
async function backupToggles () {
|
async function backupToggles() {
|
||||||
const response = await fetch("/api/toggles/backup", { method: "POST" })
|
const response = await fetch("/api/toggles/backup", { method: "POST" })
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export function ToggleControl () {
|
|||||||
URL.revokeObjectURL(downloadUrl)
|
URL.revokeObjectURL(downloadUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreToggles (event) {
|
async function restoreToggles(event) {
|
||||||
const uploadedFile = event.target.files[0]
|
const uploadedFile = event.target.files[0]
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
const fileContents = await uploadedFile.text()
|
const fileContents = await uploadedFile.text()
|
||||||
@@ -45,11 +46,11 @@ export function ToggleControl () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmResetDefault () {
|
function confirmResetDefault() {
|
||||||
state.showResetDefaultModal = true;
|
state.showResetDefaultModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetTogglesToDefault () {
|
async function resetTogglesToDefault() {
|
||||||
state.showResetDefaultModal = false;
|
state.showResetDefaultModal = false;
|
||||||
showSnackbar("Resetting toggles to their default values...");
|
showSnackbar("Resetting toggles to their default values...");
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
@@ -58,11 +59,11 @@ export function ToggleControl () {
|
|||||||
await fetch("/api/toggles/reset_default", { method: "POST" });
|
await fetch("/api/toggles/reset_default", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmResetStock () {
|
function confirmResetStock() {
|
||||||
state.showResetStockModal = true;
|
state.showResetStockModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetTogglesToStock () {
|
async function resetTogglesToStock() {
|
||||||
state.showResetStockModal = false;
|
state.showResetStockModal = false;
|
||||||
showSnackbar("Resetting toggles to stock openpilot values...");
|
showSnackbar("Resetting toggles to stock openpilot values...");
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
@@ -71,7 +72,7 @@ export function ToggleControl () {
|
|||||||
await fetch("/api/toggles/reset_stock", { method: "POST" });
|
await fetch("/api/toggles/reset_stock", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerRestorePrompt () {
|
function triggerRestorePrompt() {
|
||||||
fileInput.click()
|
fileInput.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +88,9 @@ export function ToggleControl () {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="toggle-control-widget" style="margin-left: 1.5rem">
|
<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">
|
<p class="toggle-control-text">
|
||||||
Reset all toggles to default FrogPilot/stock openpilot settings.
|
Reset all toggles to default StarPilot settings.
|
||||||
</p>
|
</p>
|
||||||
<button class="toggle-control-button" @click="${confirmResetDefault}">
|
<button class="toggle-control-button" @click="${confirmResetDefault}">
|
||||||
Reset Toggles to Default
|
Reset Toggles to Default
|
||||||
@@ -98,20 +99,22 @@ export function ToggleControl () {
|
|||||||
Reset Toggles to Stock
|
Reset Toggles to Stock
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
${TailscaleControl()}
|
||||||
</div>
|
</div>
|
||||||
${() => state.showResetDefaultModal ? Modal({
|
${() => state.showResetDefaultModal ? Modal({
|
||||||
title: "Reset Toggles",
|
title: "Reset Toggles",
|
||||||
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
|
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
|
||||||
onConfirm: resetTogglesToDefault,
|
onConfirm: resetTogglesToDefault,
|
||||||
onCancel: () => { state.showResetDefaultModal = false; },
|
onCancel: () => { state.showResetDefaultModal = false; },
|
||||||
confirmText: "Reset to Default"
|
confirmText: "Reset to Default"
|
||||||
}) : ""}
|
}) : ""}
|
||||||
${() => state.showResetStockModal ? Modal({
|
${() => state.showResetStockModal ? Modal({
|
||||||
title: "Reset Toggles",
|
title: "Reset Toggles",
|
||||||
message: "Are you sure you want to reset all toggles to stock openpilot values?",
|
message: "Are you sure you want to reset all toggles to stock openpilot values?",
|
||||||
onConfirm: resetTogglesToStock,
|
onConfirm: resetTogglesToStock,
|
||||||
onCancel: () => { state.showResetStockModal = false; },
|
onCancel: () => { state.showResetStockModal = false; },
|
||||||
confirmText: "Reset to Stock"
|
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,208 @@
|
|||||||
|
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||||
|
|
||||||
|
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}" @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}"
|
||||||
|
@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);
|
transition: opacity var(--transition-slow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tskkeys-group > .tskkeys-row:last-of-type {
|
.tskkeys-group>.tskkeys-row:last-of-type {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
.tskkeys-input:hover,
|
.tskkeys-input:hover,
|
||||||
.tskkeys-input:focus {
|
.tskkeys-input:focus {
|
||||||
border-color: var(--thumb-color);
|
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);
|
transform: var(--hover-scale-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +211,4 @@
|
|||||||
.tskkeys-input {
|
.tskkeys-input {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,15 +165,16 @@ const api = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasLoaded = false
|
let hasLoaded = false
|
||||||
if (!hasLoaded) {
|
|
||||||
hasLoaded = true
|
export function TSKManager() {
|
||||||
api.load()
|
if (!hasLoaded) {
|
||||||
}
|
hasLoaded = true
|
||||||
|
api.load()
|
||||||
export function TSKManager() {
|
}
|
||||||
return html`
|
|
||||||
<div class="tskkeys-wrapper tskkeys-offset-top">
|
return html`
|
||||||
|
<div class="tskkeys-wrapper tskkeys-offset-top">
|
||||||
<div class="tskkeys-container">
|
<div class="tskkeys-container">
|
||||||
<div class="tskkeys-title">Toyota Security Key Manager</div>
|
<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 "https://esm.sh/@arrow-js/core"
|
||||||
|
|
||||||
|
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}"
|
||||||
|
?disabled="${!!state.status?.isOnroad || state.toggleBusy || !!state.status?.running}"
|
||||||
|
@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}"
|
||||||
|
@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}"
|
||||||
|
@change="${(event) => {
|
||||||
|
state.selectedBranch = String(event.target.value || "")
|
||||||
|
state.hasManualBranchSelection = true
|
||||||
|
}}">
|
||||||
|
${() => state.branches.length
|
||||||
|
? state.branches.map((branch) => html`<option value="${branch}" ${branch === state.selectedBranch ? "selected" : ""}>${branch}${branch === state.status?.branch ? " (current)" : ""}</option>`)
|
||||||
|
: html`<option value="">No branches found</option>`
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="updateButton"
|
||||||
|
?disabled="${state.branchesBusy || !!state.status?.running}"
|
||||||
|
@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)}"
|
||||||
|
@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 "https://esm.sh/@arrow-js/core"
|
||||||
|
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")
|
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
|
// Ensure max 2 snackbars are visible
|
||||||
if (wrapper.children.length >= 2) {
|
if (wrapper.children.length >= 2) {
|
||||||
const first = wrapper.children[0]
|
dismissSnackbar(wrapper.children[0])
|
||||||
first.style.opacity = 0
|
|
||||||
setTimeout(() => first.remove(), 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const snackbar = document.createElement("div")
|
const snackbar = document.createElement("div")
|
||||||
snackbar.innerHTML = msg
|
|
||||||
snackbar.className = "snackbar show"
|
|
||||||
snackbar.id = `snackbar_${Math.random().toString(36).slice(5)}`
|
snackbar.id = `snackbar_${Math.random().toString(36).slice(5)}`
|
||||||
|
if (key) snackbar.dataset.snackbarKey = key
|
||||||
if (level === "error") {
|
setSnackbarContent(snackbar)
|
||||||
snackbar.style.backgroundColor = "#f44336"
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.appendChild(snackbar)
|
wrapper.appendChild(snackbar)
|
||||||
|
snackbar.__hideTimer = setTimeout(() => dismissSnackbar(snackbar), timeout)
|
||||||
setTimeout(() => {
|
|
||||||
snackbar.style.opacity = 0
|
|
||||||
setTimeout(() => snackbar.remove(), 1000)
|
|
||||||
}, timeout)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,11 @@ export function hideSidebar() {
|
|||||||
document.getElementById("sidebarUnderlay")?.classList.add("hidden")
|
document.getElementById("sidebarUnderlay")?.classList.add("hidden")
|
||||||
html.classList.remove("no_scroll")
|
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": "",
|
"short_name": "",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -17,4 +17,4 @@
|
|||||||
"background_color": "#151414",
|
"background_color": "#151414",
|
||||||
"theme_color": "#151414",
|
"theme_color": "#151414",
|
||||||
"display": "standalone"
|
"display": "standalone"
|
||||||
}
|
}
|
||||||
@@ -1,59 +1,66 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" id="htmlElement" data-theme="dark">
|
<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" />
|
<meta name="theme-color" content="#8b6cc5" />
|
||||||
<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">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/assets/components/home/home.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/components/main.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.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">
|
|
||||||
|
|
||||||
<script type="module" src="/assets/components/router.js"></script>
|
<link rel="stylesheet" href="/assets/components/home/home.css">
|
||||||
<script src="/assets/js/snackbar.js"></script>
|
<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" src="/assets/components/router.js"></script>
|
||||||
|
<script src="/assets/js/snackbar.js"></script>
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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"
|
<link
|
||||||
rel="stylesheet">
|
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>
|
<title>Galaxy</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- Menu button for phone -->
|
<!-- Menu button for phone -->
|
||||||
<div id="menu_button">
|
<div id="menu_button">
|
||||||
<i class="bi bi-list"></i>
|
<i class="bi bi-list"></i>
|
||||||
</div>
|
</div>
|
||||||
<!-- Snackbar for messages -->
|
<!-- Snackbar for messages -->
|
||||||
<div id="snackbar_wrapper"></div>
|
<div id="snackbar_wrapper"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import secrets
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -563,20 +562,11 @@ def process_route(footage_path, route_name):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"name": route_name,
|
"name": route_name,
|
||||||
"gif": f"/thumbnails/{route_name}--0/preview.gif",
|
|
||||||
"png": f"/thumbnails/{route_name}--0/preview.png",
|
"png": f"/thumbnails/{route_name}--0/preview.png",
|
||||||
"timestamp": route_timestamp_str,
|
"timestamp": route_timestamp_str,
|
||||||
"is_preserved": has_preserve_attr(segment_path)
|
"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):
|
def process_screen_recording(mp4):
|
||||||
stem = mp4.with_suffix("")
|
stem = mp4.with_suffix("")
|
||||||
png_path = stem.with_suffix(".png")
|
png_path = stem.with_suffix(".png")
|
||||||
@@ -592,44 +582,29 @@ def process_screen_recording(mp4):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"filename": mp4.name,
|
"filename": mp4.name,
|
||||||
"gif": f"/screen_recordings/{stem.with_suffix('.gif').name}",
|
|
||||||
"png": f"/screen_recordings/{png_path.name}",
|
"png": f"/screen_recordings/{png_path.name}",
|
||||||
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
|
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
|
||||||
"is_custom_name": is_custom_name
|
"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):
|
def segment_to_segment_name(data_dir, segment):
|
||||||
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
|
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
|
||||||
return SegmentName(full_path)
|
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):
|
def video_to_png(input_path, output_path):
|
||||||
run_ffmpeg([
|
try:
|
||||||
"-ss", str(get_video_duration(input_path) / 2),
|
subprocess.run([
|
||||||
"-i", str(input_path),
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
"-frames:v", "1",
|
"-ss", "1",
|
||||||
str(output_path)
|
"-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):
|
def xor_encrypt_decrypt(data, key):
|
||||||
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
|
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ class Parser:
|
|||||||
out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
|
||||||
if 'planplus' in outs:
|
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))
|
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:
|
if 'lane_lines' in outs:
|
||||||
self.parse_mdn('lane_lines', outs, in_N=0, out_N=0,
|
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))
|
out_shape=(ModelConstants.NUM_LANE_LINES, ModelConstants.IDX_N, ModelConstants.LANE_LINES_WIDTH))
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ class ModelState:
|
|||||||
cloudlog.warning(f"ModelVersion parameter not available: {e}")
|
cloudlog.warning(f"ModelVersion parameter not available: {e}")
|
||||||
|
|
||||||
model_dir = MODELS_PATH
|
model_dir = MODELS_PATH
|
||||||
# For the default "bd2" model, use built-in files from the models directory
|
# For the default "sc" model, use built-in files from the models directory
|
||||||
if model_id == "bd2":
|
if model_id == "sc":
|
||||||
models_dir = Path(__file__).parent / "models"
|
models_dir = Path(__file__).parent / "models"
|
||||||
VISION_PKL_PATH = models_dir / "driving_vision_tinygrad.pkl"
|
VISION_PKL_PATH = models_dir / "driving_vision_tinygrad.pkl"
|
||||||
POLICY_PKL_PATH = models_dir / "driving_policy_tinygrad.pkl"
|
POLICY_PKL_PATH = models_dir / "driving_policy_tinygrad.pkl"
|
||||||
@@ -191,6 +191,8 @@ class ModelState:
|
|||||||
# Add policy_generation attribute after loading policy_metadata
|
# Add policy_generation attribute after loading policy_metadata
|
||||||
self.policy_generation = model_version or "v8"
|
self.policy_generation = model_version or "v8"
|
||||||
self.is_v11 = (self.policy_generation == "v11")
|
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.is_v9 = (self.policy_generation == "v9")
|
||||||
self.mlsim = (self.policy_generation in ("v8", "v10", "v11", "v12"))
|
self.mlsim = (self.policy_generation in ("v8", "v10", "v11", "v12"))
|
||||||
|
|
||||||
@@ -329,14 +331,14 @@ class ModelState:
|
|||||||
self.full_prev_desired_curv[0,-1,:] = policy_outputs_dict['desired_curvature'][0, :]
|
self.full_prev_desired_curv[0,-1,:] = policy_outputs_dict['desired_curvature'][0, :]
|
||||||
|
|
||||||
if self.prev_desired_curv_key is not None:
|
if self.prev_desired_curv_key is not None:
|
||||||
# v9 models expect zeros for prev_desired_curv(s); others use history
|
# v9/v10/v11/v12 models expect zeros for prev_desired_curv(s); others use history
|
||||||
if self.is_v9:
|
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]
|
self.numpy_inputs[self.prev_desired_curv_key][:] = 0 * self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||||
else:
|
else:
|
||||||
self.numpy_inputs[self.prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
|
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.off_policy_enabled and self.off_policy_prev_desired_curv_key is not None:
|
||||||
if self.is_v9:
|
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]
|
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = 0 * self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||||
else:
|
else:
|
||||||
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
|
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
|
||||||
|
|||||||
@@ -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."), ""},
|
{"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>")), ""},
|
{"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>")), ""},
|
{"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>"), ""},
|
{"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."), ""},
|
{"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!")), ""},
|
{"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;
|
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> screenKeys = {"ScreenBrightness", "ScreenBrightnessOnroad", "ScreenRecorder", "ScreenTimeout", "ScreenTimeoutOnroad", "StandbyMode"};
|
||||||
|
|
||||||
QSet<QString> parentKeys;
|
QSet<QString> parentKeys;
|
||||||
|
|||||||
@@ -1651,7 +1651,6 @@ void FrogPilotLongitudinalPanel::updateToggles() {
|
|||||||
|
|
||||||
else if (key == "StoppingDecelRate" || key == "VEgoStarting" ||
|
else if (key == "StoppingDecelRate" || key == "VEgoStarting" ||
|
||||||
key == "VEgoStopping") {
|
key == "VEgoStopping") {
|
||||||
setVisible &= !isGM || !params.getBool("ExperimentalGMTune");
|
|
||||||
setVisible &= !isToyota || !params.getBool("FrogsGoMoosTweak");
|
setVisible &= !isToyota || !params.getBool("FrogsGoMoosTweak");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
{"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."), ""},
|
{"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>"), ""},
|
{"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>"), ""},
|
{"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."), ""},
|
{"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."), ""},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ private:
|
|||||||
std::map<QString, AbstractControl*> toggles;
|
std::map<QString, AbstractControl*> toggles;
|
||||||
|
|
||||||
QSet<QString> alertVolumeControlKeys {"DisengageVolume", "EngageVolume", "PromptDistractedVolume", "PromptVolume", "RefuseVolume", "WarningImmediateVolume", "WarningSoftVolume"};
|
QSet<QString> alertVolumeControlKeys {"DisengageVolume", "EngageVolume", "PromptDistractedVolume", "PromptVolume", "RefuseVolume", "WarningImmediateVolume", "WarningSoftVolume"};
|
||||||
QSet<QString> customAlertsKeys {"GoatScream", "GreenLightAlert", "LeadDepartingAlert", "LoudBlindspotAlert", "SpeedLimitChangedAlert"};
|
QSet<QString> customAlertsKeys {"GoatScream", "GoatScreamCriticalAlerts", "GreenLightAlert", "LeadDepartingAlert", "LoudBlindspotAlert", "SpeedLimitChangedAlert"};
|
||||||
|
|
||||||
QSet<QString> parentKeys;
|
QSet<QString> parentKeys;
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ FrogPilotVehiclesPanel::FrogPilotVehiclesPanel(FrogPilotSettingsWindow *parent)
|
|||||||
|
|
||||||
std::vector<std::tuple<QString, QString, QString, QString>> vehicleToggles {
|
std::vector<std::tuple<QString, QString, QString, QString>> vehicleToggles {
|
||||||
{"GMToggles", tr("General Motors Settings"), tr("<b>FrogPilot features for General Motors vehicles.</b>"), ""},
|
{"GMToggles", tr("General Motors Settings"), tr("<b>FrogPilot features for General Motors vehicles.</b>"), ""},
|
||||||
{"ExperimentalGMTune", tr("FrogsGoMoo's Experimental Tune"), tr("<b>Experimental GM tune by FrogsGoMoo</b> that attempts to smoothen stopping and takeoff control. Use at your own risk!"), ""},
|
|
||||||
{"GMPedalLongitudinal", tr("Use Pedal for Longitudinal Control"), tr("<b>Use the pedal interceptor for longitudinal control</b> instead of camera ACC/Redneck when available."), ""},
|
{"GMPedalLongitudinal", tr("Use Pedal for Longitudinal Control"), tr("<b>Use the pedal interceptor for longitudinal control</b> instead of camera ACC/Redneck when available."), ""},
|
||||||
|
{"RemapCancelToDistance", tr("Remap cancel to distance"), tr("<b>Treat CANCEL as distance-button input</b> on supported pedal-long GM platforms. Toggle requires a reboot to take effect."), ""},
|
||||||
{"LongPitch", tr("Smooth Pedal Response on Hills"), tr("<b>Smoothen acceleration and braking</b> when driving downhill/uphill."), ""},
|
{"LongPitch", tr("Smooth Pedal Response on Hills"), tr("<b>Smoothen acceleration and braking</b> when driving downhill/uphill."), ""},
|
||||||
{"RedPanda", tr("Red Panda"), tr("<b>Enable Red Panda behavior</b> for GM (alternate safety config and bus numbering). Requires a reboot to take effect."), ""},
|
{"RedPanda", tr("Red Panda"), tr("<b>Enable Red Panda behavior</b> for GM (alternate safety config and bus numbering). Requires a reboot to take effect."), ""},
|
||||||
{"RemoteStartBootsComma", tr("Remote Start Boots Comma"), tr("<b>Use GM C9 SystemPowerMode</b> for ignition detection. Toggle requires a panda firmware update and a reboot to take effect."), ""},
|
{"RemoteStartBootsComma", tr("Remote Start Boots Comma"), tr("<b>Use GM C9 SystemPowerMode</b> for ignition detection. Toggle requires a panda firmware update and a reboot to take effect."), ""},
|
||||||
@@ -288,7 +288,7 @@ FrogPilotVehiclesPanel::FrogPilotVehiclesPanel(FrogPilotSettingsWindow *parent)
|
|||||||
|
|
||||||
static_cast<FrogPilotParamValueControl*>(toggles["LockDoorsTimer"])->setWarning("<b>Warning:</b> openpilot can't detect if keys are still inside the car, so ensure you have a spare key to prevent accidental lockouts!");
|
static_cast<FrogPilotParamValueControl*>(toggles["LockDoorsTimer"])->setWarning("<b>Warning:</b> openpilot can't detect if keys are still inside the car, so ensure you have a spare key to prevent accidental lockouts!");
|
||||||
|
|
||||||
QSet<QString> rebootKeys = {"NewLongAPI", "TacoTuneHacks"};
|
QSet<QString> rebootKeys = {"NewLongAPI", "RemapCancelToDistance", "TacoTuneHacks"};
|
||||||
for (const QString &key : rebootKeys) {
|
for (const QString &key : rebootKeys) {
|
||||||
QObject::connect(static_cast<ToggleControl*>(toggles[key]), &ToggleControl::toggleFlipped, [key, this](bool state) {
|
QObject::connect(static_cast<ToggleControl*>(toggles[key]), &ToggleControl::toggleFlipped, [key, this](bool state) {
|
||||||
if (started) {
|
if (started) {
|
||||||
@@ -405,7 +405,9 @@ void FrogPilotVehiclesPanel::updateToggles() {
|
|||||||
|
|
||||||
bool setVisible = tuningLevel >= frogpilotToggleLevels[key].toDouble();
|
bool setVisible = tuningLevel >= frogpilotToggleLevels[key].toDouble();
|
||||||
|
|
||||||
if (gmKeys.contains(key)) {
|
if (key == "ClusterOffset") {
|
||||||
|
setVisible &= isGM || isToyota;
|
||||||
|
} else if (gmKeys.contains(key)) {
|
||||||
setVisible &= isGM;
|
setVisible &= isGM;
|
||||||
} else if (hkgKeys.contains(key)) {
|
} else if (hkgKeys.contains(key)) {
|
||||||
setVisible &= isHKG;
|
setVisible &= isHKG;
|
||||||
@@ -431,7 +433,7 @@ void FrogPilotVehiclesPanel::updateToggles() {
|
|||||||
setVisible &= isHKGCanFd;
|
setVisible &= isHKGCanFd;
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (key == "GMPedalLongitudinal") {
|
else if (key == "GMPedalLongitudinal" || key == "RemapCancelToDistance") {
|
||||||
setVisible &= hasPedal;
|
setVisible &= hasPedal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ private:
|
|||||||
|
|
||||||
std::map<QString, AbstractControl*> toggles;
|
std::map<QString, AbstractControl*> toggles;
|
||||||
|
|
||||||
QSet<QString> gmKeys = {"ExperimentalGMTune", "GMPedalLongitudinal", "LongPitch", "RedPanda", "RemoteStartBootsComma", "VoltSNG"};
|
QSet<QString> gmKeys = {"GMPedalLongitudinal", "LongPitch", "RedPanda", "RemapCancelToDistance", "RemoteStartBootsComma", "VoltSNG"};
|
||||||
QSet<QString> hkgKeys = {"NewLongAPI", "TacoTuneHacks"};
|
QSet<QString> hkgKeys = {"NewLongAPI", "TacoTuneHacks"};
|
||||||
QSet<QString> longitudinalKeys = {"ExperimentalGMTune", "FrogsGoMoosTweak", "LongPitch", "NewLongAPI", "SNGHack", "VoltSNG"};
|
QSet<QString> longitudinalKeys = {"FrogsGoMoosTweak", "LongPitch", "NewLongAPI", "RemapCancelToDistance", "SNGHack", "VoltSNG"};
|
||||||
QSet<QString> toyotaKeys = {"ClusterOffset", "FrogsGoMoosTweak", "LockDoorsTimer", "SNGHack", "ToyotaDoors"};
|
QSet<QString> toyotaKeys = {"FrogsGoMoosTweak", "LockDoorsTimer", "SNGHack", "ToyotaDoors"};
|
||||||
QSet<QString> vehicleInfoKeys = {"BlindSpotSupport", "HardwareDetected", "OpenpilotLongitudinal", "PedalSupport", "RadarSupport", "SASCMSupport", "SDSUSupport", "SNGSupport"};
|
QSet<QString> vehicleInfoKeys = {"BlindSpotSupport", "HardwareDetected", "OpenpilotLongitudinal", "PedalSupport", "RadarSupport", "SASCMSupport", "SDSUSupport", "SNGSupport"};
|
||||||
|
|
||||||
QSet<QString> parentKeys;
|
QSet<QString> parentKeys;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
const uint8_t gitversion[] = "DEV-4d1efbb2-DEBUG";
|
const uint8_t gitversion[] = "DEV-45631cf2-DEBUG";
|
||||||
|
|||||||
@@ -48,16 +48,16 @@ const CanMsg GM_ASCM_TX_MSGS[] = {{0x180, 0, 4}, {0x409, 0, 7}, {0x40A, 0, 7}, {
|
|||||||
{0xA1, 1, 7}, {0x306, 1, 8}, {0x308, 1, 7}, {0x310, 1, 2}, // obs bus
|
{0xA1, 1, 7}, {0x306, 1, 8}, {0x308, 1, 7}, {0x310, 1, 2}, // obs bus
|
||||||
{0x315, 2, 5}}; // ch bus
|
{0x315, 2, 5}}; // ch bus
|
||||||
|
|
||||||
const CanMsg GM_CAM_TX_MSGS[] = {{0x180, 0, 4}, {0x200, 0, 6}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
const CanMsg GM_CAM_TX_MSGS[] = {{0x180, 0, 4}, {0x370, 0, 6}, {0x200, 0, 6}, {0x1E1, 0, 7}, {0x3D1, 0, 8}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||||
{0x1E1, 2, 7}, {0x184, 2, 8}}; // camera bus
|
{0x1E1, 2, 7}, {0x184, 2, 8}}; // camera bus
|
||||||
|
|
||||||
const CanMsg GM_CAM_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x315, 0, 5}, {0x2CB, 0, 8}, {0x370, 0, 6}, {0x200, 0, 6}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
const CanMsg GM_CAM_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x315, 0, 5}, {0x2CB, 0, 8}, {0x370, 0, 6}, {0x200, 0, 6}, {0x3D1, 0, 8}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||||
{0x315, 2, 5}, {0x1E1, 2, 7}, {0x184, 2, 8}}; // camera bus
|
{0x315, 2, 5}, {0x1E1, 2, 7}, {0x184, 2, 8}}; // camera bus
|
||||||
|
|
||||||
const CanMsg GM_SDGM_TX_MSGS[] = {{0x180, 0, 4}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
const CanMsg GM_SDGM_TX_MSGS[] = {{0x180, 0, 4}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||||
{0x184, 2, 8}}; // camera bus
|
{0x184, 2, 8}}; // camera bus
|
||||||
|
|
||||||
const CanMsg GM_CC_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
const CanMsg GM_CC_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x370, 0, 6}, {0x1E1, 0, 7}, {0x3D1, 0, 8}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||||
{0x184, 2, 8}, {0x1E1, 2, 7}}; // camera bus
|
{0x184, 2, 8}, {0x1E1, 2, 7}}; // camera bus
|
||||||
|
|
||||||
// TODO: do checksum and counter checks. Add correct timestep, 0.1s for now.
|
// TODO: do checksum and counter checks. Add correct timestep, 0.1s for now.
|
||||||
@@ -72,6 +72,17 @@ RxCheck gm_rx_checks[] = {
|
|||||||
{.msg = {{0xC9, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
{.msg = {{0xC9, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Volt ASCM / ASCM_INT stock-camera path should pin 0x1E1/0xF1 to bus 0 to avoid
|
||||||
|
// latching bus 2 when mirrored startup traffic is present.
|
||||||
|
RxCheck gm_ascm_int_stock_cam_rx_checks[] = {
|
||||||
|
{.msg = {{0x184, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
|
{.msg = {{0x34A, 0, 5, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
|
{.msg = {{0x1E1, 0, 7, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
|
{.msg = {{0xF1, 0, 6, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
|
{.msg = {{0x1C4, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
|
{.msg = {{0xC9, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||||
|
};
|
||||||
|
|
||||||
const uint16_t GM_PARAM_HW_CAM = 1;
|
const uint16_t GM_PARAM_HW_CAM = 1;
|
||||||
const uint16_t GM_PARAM_HW_CAM_LONG = 2;
|
const uint16_t GM_PARAM_HW_CAM_LONG = 2;
|
||||||
const uint16_t GM_PARAM_CC_LONG = 4;
|
const uint16_t GM_PARAM_CC_LONG = 4;
|
||||||
@@ -86,6 +97,20 @@ const uint16_t GM_PARAM_HW_SDGM = 1024;
|
|||||||
const uint16_t GM_PARAM_BOLT_2017 = 2048;
|
const uint16_t GM_PARAM_BOLT_2017 = 2048;
|
||||||
const uint16_t GM_PARAM_BOLT_2022_PEDAL = 4096;
|
const uint16_t GM_PARAM_BOLT_2022_PEDAL = 4096;
|
||||||
const uint16_t GM_PARAM_REMOTE_START_BOOTS_COMMA = 8192;
|
const uint16_t GM_PARAM_REMOTE_START_BOOTS_COMMA = 8192;
|
||||||
|
const uint16_t GM_PARAM_PANDA_3D1_SCHED = 16384;
|
||||||
|
const uint16_t GM_PARAM_PANDA_PADDLE_SCHED = 32768;
|
||||||
|
|
||||||
|
const uint32_t GM_3D1_PERIOD_US = 100000U;
|
||||||
|
const uint32_t GM_3D1_TX_OFFSET_US = 0U;
|
||||||
|
const uint32_t GM_3D1_LOCK_TOLERANCE_US = 20000U;
|
||||||
|
const uint32_t GM_PADDLE_PERIOD_US = 25000U;
|
||||||
|
const uint32_t GM_PADDLE_TX_OFFSET_US = 0U;
|
||||||
|
const uint32_t GM_PADDLE_LOCK_TOLERANCE_US = 5000U;
|
||||||
|
const uint32_t GM_PADDLE_STALE_US = 100000U;
|
||||||
|
const uint32_t GM_PADDLE_FEED_STALE_US = 100000U;
|
||||||
|
|
||||||
|
void can_send(CANPacket_t *to_push, uint8_t bus_number, bool skip_tx_hook);
|
||||||
|
void can_set_checksum(CANPacket_t *packet);
|
||||||
|
|
||||||
enum {
|
enum {
|
||||||
GM_BTN_UNPRESS = 1,
|
GM_BTN_UNPRESS = 1,
|
||||||
@@ -111,6 +136,111 @@ bool gm_bolt_2022_pedal = false;
|
|||||||
bool gm_ascm_int = false;
|
bool gm_ascm_int = false;
|
||||||
bool gm_force_brake_c9 = false;
|
bool gm_force_brake_c9 = false;
|
||||||
bool gm_remote_start_boots_comma = false;
|
bool gm_remote_start_boots_comma = false;
|
||||||
|
bool gm_panda_3d1_sched = false;
|
||||||
|
bool gm_panda_paddle_sched = false;
|
||||||
|
|
||||||
|
bool gm_3d1_spoof_valid = false;
|
||||||
|
bool gm_3d1_internal_tx = false;
|
||||||
|
uint8_t gm_3d1_spoof_data[8] = {0U};
|
||||||
|
uint32_t gm_3d1_next_tx_us = 0U;
|
||||||
|
uint32_t gm_3d1_expected_stock_us = 0U;
|
||||||
|
uint32_t gm_3d1_last_stock_us = 0U;
|
||||||
|
bool gm_3d1_phase_locked = false;
|
||||||
|
bool gm_paddle_internal_tx = false;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool spoof_valid;
|
||||||
|
uint8_t spoof_data[8];
|
||||||
|
uint32_t next_tx_us;
|
||||||
|
uint32_t expected_stock_us;
|
||||||
|
uint32_t last_stock_us;
|
||||||
|
uint32_t last_feed_us;
|
||||||
|
bool phase_locked;
|
||||||
|
} GmPeriodicSpoofState;
|
||||||
|
|
||||||
|
GmPeriodicSpoofState gm_bd_state = {0};
|
||||||
|
GmPeriodicSpoofState gm_prndl2_state = {0};
|
||||||
|
|
||||||
|
static void gm_update_periodic_phase(GmPeriodicSpoofState *state, uint32_t now_us, uint32_t period_us, uint32_t lock_tolerance_us) {
|
||||||
|
state->last_stock_us = now_us;
|
||||||
|
if (!state->phase_locked) {
|
||||||
|
state->phase_locked = true;
|
||||||
|
state->expected_stock_us = now_us + period_us;
|
||||||
|
} else {
|
||||||
|
int32_t phase_err_us = (int32_t)(now_us - state->expected_stock_us);
|
||||||
|
if (phase_err_us < 0) {
|
||||||
|
phase_err_us = -phase_err_us;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((uint32_t)phase_err_us <= lock_tolerance_us) {
|
||||||
|
state->expected_stock_us += period_us;
|
||||||
|
} else {
|
||||||
|
state->expected_stock_us = now_us + period_us;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool gm_periodic_scheduler_ready(const GmPeriodicSpoofState *state, uint32_t now_us, uint32_t stale_us, uint32_t feed_stale_us) {
|
||||||
|
bool stock_stale = (state->last_stock_us == 0U) || (get_ts_elapsed(now_us, state->last_stock_us) > stale_us);
|
||||||
|
bool feed_stale = (state->last_feed_us == 0U) || (get_ts_elapsed(now_us, state->last_feed_us) > feed_stale_us);
|
||||||
|
return state->phase_locked && !stock_stale && !feed_stale;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void gm_try_send_periodic_spoof(uint32_t now_us, uint32_t addr, uint8_t dlc, GmPeriodicSpoofState *state, uint32_t period_us) {
|
||||||
|
if (!(gm_panda_paddle_sched && state->spoof_valid && (state->next_tx_us != 0U))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gm_periodic_scheduler_ready(state, now_us, GM_PADDLE_STALE_US, GM_PADDLE_FEED_STALE_US)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int32_t)(now_us - state->next_tx_us) < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CANPacket_t to_send = {0};
|
||||||
|
to_send.returned = 0U;
|
||||||
|
to_send.rejected = 0U;
|
||||||
|
to_send.extended = 0U;
|
||||||
|
to_send.addr = addr;
|
||||||
|
to_send.bus = 0U;
|
||||||
|
to_send.data_len_code = dlc;
|
||||||
|
(void)memcpy(to_send.data, state->spoof_data, 8U);
|
||||||
|
can_set_checksum(&to_send);
|
||||||
|
|
||||||
|
gm_paddle_internal_tx = true;
|
||||||
|
can_send(&to_send, 0U, false);
|
||||||
|
gm_paddle_internal_tx = false;
|
||||||
|
|
||||||
|
state->next_tx_us += period_us;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void gm_try_send_3d1_spoof(uint32_t now_us) {
|
||||||
|
if (!(gm_panda_3d1_sched && gm_3d1_spoof_valid && (gm_3d1_next_tx_us != 0U))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int32_t)(now_us - gm_3d1_next_tx_us) < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CANPacket_t to_send = {0};
|
||||||
|
to_send.returned = 0U;
|
||||||
|
to_send.rejected = 0U;
|
||||||
|
to_send.extended = 0U;
|
||||||
|
to_send.addr = 0x3D1U;
|
||||||
|
to_send.bus = 0U;
|
||||||
|
to_send.data_len_code = 8U;
|
||||||
|
(void)memcpy(to_send.data, gm_3d1_spoof_data, 8U);
|
||||||
|
can_set_checksum(&to_send);
|
||||||
|
|
||||||
|
gm_3d1_internal_tx = true;
|
||||||
|
can_send(&to_send, 0U, false);
|
||||||
|
gm_3d1_internal_tx = false;
|
||||||
|
|
||||||
|
gm_3d1_next_tx_us += GM_3D1_PERIOD_US;
|
||||||
|
}
|
||||||
|
|
||||||
static void gm_rx_hook(const CANPacket_t *to_push) {
|
static void gm_rx_hook(const CANPacket_t *to_push) {
|
||||||
if (GET_BUS(to_push) == 0U) {
|
if (GET_BUS(to_push) == 0U) {
|
||||||
@@ -133,6 +263,13 @@ static void gm_rx_hook(const CANPacket_t *to_push) {
|
|||||||
// ACC steering wheel buttons (GM_CAM is tied to the PCM)
|
// ACC steering wheel buttons (GM_CAM is tied to the PCM)
|
||||||
if ((addr == 0x1E1) && (!gm_pcm_cruise || gm_cc_long)) {
|
if ((addr == 0x1E1) && (!gm_pcm_cruise || gm_cc_long)) {
|
||||||
int button = (GET_BYTE(to_push, 5) & 0x70U) >> 4;
|
int button = (GET_BYTE(to_push, 5) & 0x70U) >> 4;
|
||||||
|
bool remap_cancel_to_distance = (alternative_experience & ALT_EXP_GM_REMAP_CANCEL_TO_DISTANCE) != 0;
|
||||||
|
// Malibu Hybrid pedal-long observed format uses byte3 bit0 set (typically 0x01/0x41) on ASCMSteeringButton.
|
||||||
|
// Don't treat that physical CANCEL frame as OP disengage.
|
||||||
|
bool malibu_cancel_passthrough = remap_cancel_to_distance && gm_bolt_2022_pedal && gm_pedal_long && ((GET_BYTE(to_push, 3) & 0x1U) != 0U);
|
||||||
|
// Gen1 Bolt pedal-long behavior: let wheel CANCEL act as an in-drive aux button (personality cycling),
|
||||||
|
// so safety should not force a controls drop on this RX cancel edge.
|
||||||
|
bool bolt_cancel_passthrough = remap_cancel_to_distance && gm_pedal_long && !gm_has_acc && !gm_bolt_2022_pedal;
|
||||||
|
|
||||||
// enter controls on falling edge of set or rising edge of resume (avoids fault)
|
// enter controls on falling edge of set or rising edge of resume (avoids fault)
|
||||||
bool set = (button != GM_BTN_SET) && (cruise_button_prev == GM_BTN_SET);
|
bool set = (button != GM_BTN_SET) && (cruise_button_prev == GM_BTN_SET);
|
||||||
@@ -142,7 +279,7 @@ static void gm_rx_hook(const CANPacket_t *to_push) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// exit controls on cancel press
|
// exit controls on cancel press
|
||||||
if (button == GM_BTN_CANCEL) {
|
if ((button == GM_BTN_CANCEL) && !(malibu_cancel_passthrough || bolt_cancel_passthrough)) {
|
||||||
controls_allowed = false;
|
controls_allowed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,16 +320,53 @@ static void gm_rx_hook(const CANPacket_t *to_push) {
|
|||||||
|
|
||||||
// Cruise check for CC only cars
|
// Cruise check for CC only cars
|
||||||
if ((addr == 0x3D1) && !gm_has_acc) {
|
if ((addr == 0x3D1) && !gm_has_acc) {
|
||||||
|
uint32_t now_us = microsecond_timer_get();
|
||||||
|
gm_3d1_last_stock_us = now_us;
|
||||||
bool cruise_engaged = (GET_BYTE(to_push, 4) >> 7) != 0U;
|
bool cruise_engaged = (GET_BYTE(to_push, 4) >> 7) != 0U;
|
||||||
if (gm_cc_long) {
|
if (gm_cc_long) {
|
||||||
pcm_cruise_check(cruise_engaged);
|
pcm_cruise_check(cruise_engaged);
|
||||||
} else {
|
} else {
|
||||||
cruise_engaged_prev = cruise_engaged;
|
cruise_engaged_prev = cruise_engaged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gm_panda_3d1_sched) {
|
||||||
|
if (!gm_3d1_phase_locked) {
|
||||||
|
gm_3d1_phase_locked = true;
|
||||||
|
gm_3d1_expected_stock_us = now_us + GM_3D1_PERIOD_US;
|
||||||
|
} else {
|
||||||
|
int32_t phase_err_us = (int32_t)(now_us - gm_3d1_expected_stock_us);
|
||||||
|
if (phase_err_us < 0) {
|
||||||
|
phase_err_us = -phase_err_us;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((uint32_t)phase_err_us <= GM_3D1_LOCK_TOLERANCE_US) {
|
||||||
|
gm_3d1_expected_stock_us += GM_3D1_PERIOD_US;
|
||||||
|
} else {
|
||||||
|
gm_3d1_expected_stock_us = now_us + GM_3D1_PERIOD_US;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gm_3d1_next_tx_us = now_us + GM_3D1_TX_OFFSET_US;
|
||||||
|
gm_try_send_3d1_spoof(now_us);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addr == 0xBD) {
|
if (addr == 0xBD) {
|
||||||
regen_braking = (GET_BYTE(to_push, 0) >> 4) != 0U;
|
regen_braking = (GET_BYTE(to_push, 0) >> 4) != 0U;
|
||||||
|
|
||||||
|
if (gm_panda_paddle_sched) {
|
||||||
|
uint32_t now_us = microsecond_timer_get();
|
||||||
|
gm_update_periodic_phase(&gm_bd_state, now_us, GM_PADDLE_PERIOD_US, GM_PADDLE_LOCK_TOLERANCE_US);
|
||||||
|
gm_bd_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||||
|
gm_try_send_periodic_spoof(now_us, 0xBDU, 7U, &gm_bd_state, GM_PADDLE_PERIOD_US);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((addr == 0x1F5) && gm_panda_paddle_sched) {
|
||||||
|
uint32_t now_us = microsecond_timer_get();
|
||||||
|
gm_update_periodic_phase(&gm_prndl2_state, now_us, GM_PADDLE_PERIOD_US, GM_PADDLE_LOCK_TOLERANCE_US);
|
||||||
|
gm_prndl2_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||||
|
gm_try_send_periodic_spoof(now_us, 0x1F5U, 8U, &gm_prndl2_state, GM_PADDLE_PERIOD_US);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pedal Interceptor
|
// Pedal Interceptor
|
||||||
@@ -291,12 +465,51 @@ static bool gm_tx_hook(const CANPacket_t *to_send) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cruise status spoofing only for non-ACC (CC-only) paths
|
||||||
|
if (addr == 0x3D1) {
|
||||||
|
bool allowed_cruise_status = !gm_has_acc;
|
||||||
|
if (!allowed_cruise_status) {
|
||||||
|
tx = false;
|
||||||
|
} else if (gm_panda_3d1_sched) {
|
||||||
|
if (gm_3d1_internal_tx) {
|
||||||
|
tx = true;
|
||||||
|
} else {
|
||||||
|
uint32_t now_us = microsecond_timer_get();
|
||||||
|
(void)memcpy(gm_3d1_spoof_data, to_send->data, 8U);
|
||||||
|
gm_3d1_spoof_valid = true;
|
||||||
|
if (gm_3d1_next_tx_us == 0U) {
|
||||||
|
gm_3d1_next_tx_us = now_us + GM_3D1_TX_OFFSET_US;
|
||||||
|
}
|
||||||
|
bool stock_stale = (gm_3d1_last_stock_us == 0U) || (get_ts_elapsed(now_us, gm_3d1_last_stock_us) > 300000U);
|
||||||
|
bool scheduler_ready = gm_3d1_phase_locked && !stock_stale;
|
||||||
|
tx = !scheduler_ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// REGEN PADDLE
|
// REGEN PADDLE
|
||||||
if (addr == 0xBD) {
|
if (addr == 0xBD) {
|
||||||
bool regen_apply = GET_BIT(to_send, 7) || GET_BIT(to_send, 6) || GET_BIT(to_send, 5) || GET_BIT(to_send, 4);
|
bool regen_apply = GET_BIT(to_send, 7) || GET_BIT(to_send, 6) || GET_BIT(to_send, 5) || GET_BIT(to_send, 4);
|
||||||
if (!controls_allowed && regen_apply) {
|
if (!controls_allowed && regen_apply) {
|
||||||
tx = false;
|
tx = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gm_panda_paddle_sched && !gm_paddle_internal_tx) {
|
||||||
|
uint32_t now_us = microsecond_timer_get();
|
||||||
|
if (tx) {
|
||||||
|
(void)memcpy(gm_bd_state.spoof_data, to_send->data, 7U);
|
||||||
|
gm_bd_state.spoof_data[7] = 0U;
|
||||||
|
gm_bd_state.spoof_valid = true;
|
||||||
|
gm_bd_state.last_feed_us = now_us;
|
||||||
|
if (gm_bd_state.next_tx_us == 0U) {
|
||||||
|
gm_bd_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool scheduler_ready = gm_periodic_scheduler_ready(&gm_bd_state, now_us, GM_PADDLE_STALE_US, GM_PADDLE_FEED_STALE_US);
|
||||||
|
if (scheduler_ready) {
|
||||||
|
tx = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRNDL2 regen check (7 for Gen0, Gen1. 5 For Gen2)
|
// PRNDL2 regen check (7 for Gen0, Gen1. 5 For Gen2)
|
||||||
@@ -306,6 +519,22 @@ static bool gm_tx_hook(const CANPacket_t *to_send) {
|
|||||||
if (!controls_allowed && prndl_apply) {
|
if (!controls_allowed && prndl_apply) {
|
||||||
tx = false;
|
tx = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gm_panda_paddle_sched && !gm_paddle_internal_tx) {
|
||||||
|
uint32_t now_us = microsecond_timer_get();
|
||||||
|
if (tx) {
|
||||||
|
(void)memcpy(gm_prndl2_state.spoof_data, to_send->data, 8U);
|
||||||
|
gm_prndl2_state.spoof_valid = true;
|
||||||
|
gm_prndl2_state.last_feed_us = now_us;
|
||||||
|
if (gm_prndl2_state.next_tx_us == 0U) {
|
||||||
|
gm_prndl2_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool scheduler_ready = gm_periodic_scheduler_ready(&gm_prndl2_state, now_us, GM_PADDLE_STALE_US, GM_PADDLE_FEED_STALE_US);
|
||||||
|
if (scheduler_ready) {
|
||||||
|
tx = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
@@ -317,26 +546,32 @@ static int gm_fwd_hook(int bus_num, int addr) {
|
|||||||
if (bus_num == 0) {
|
if (bus_num == 0) {
|
||||||
// block PSCMStatus; forwarded through openpilot to hide an alert from the camera
|
// block PSCMStatus; forwarded through openpilot to hide an alert from the camera
|
||||||
bool is_pscm_msg = (addr == 0x184);
|
bool is_pscm_msg = (addr == 0x184);
|
||||||
if (!is_pscm_msg) {
|
// For non-ACC camera/SDGM paths, keep stock ECMCruiseControl off camera side
|
||||||
|
// so openpilot's spoofed 0x3D1 is the only cruise-status source there.
|
||||||
|
bool is_ecm_cruise_status_msg = (addr == 0x3D1) && !gm_has_acc;
|
||||||
|
if (!is_pscm_msg && !is_ecm_cruise_status_msg) {
|
||||||
bus_fwd = 2;
|
bus_fwd = 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bus_num == 2) {
|
if (bus_num == 2) {
|
||||||
bool is_lkas_msg = (addr == 0x180);
|
bool is_lkas_msg = (addr == 0x180);
|
||||||
bool block_msg = false;
|
bool is_acc_status_msg = (addr == 0x370);
|
||||||
if (gm_bolt_2022_pedal) {
|
bool is_acc_actuation_msg = (addr == 0x315) || (addr == 0x2CB);
|
||||||
// Block 0x370 only for experimental long without pedal interceptor
|
|
||||||
bool is_acc_msg = (addr == 0x315) || (addr == 0x2CB);
|
// Block steering if we are controlling LKA
|
||||||
if (gm_cam_long && !enable_gas_interceptor) {
|
bool block_msg = is_lkas_msg;
|
||||||
is_acc_msg = is_acc_msg || (addr == 0x370);
|
|
||||||
}
|
// Block Dashboard Status if we are in Native Long OR Pedal Long
|
||||||
block_msg = is_lkas_msg || (is_acc_msg && gm_cam_long);
|
if (gm_cam_long || gm_pedal_long) {
|
||||||
} else {
|
block_msg |= is_acc_status_msg;
|
||||||
// block lkas message and acc messages if gm_cam_long, forward all others
|
|
||||||
bool is_acc_msg = (addr == 0x315) || (addr == 0x2CB) || (addr == 0x370);
|
|
||||||
block_msg = is_lkas_msg || (is_acc_msg && gm_cam_long);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block Native Actuation ONLY if we are in Native Long (not Pedal)
|
||||||
|
if (gm_cam_long) {
|
||||||
|
block_msg |= is_acc_actuation_msg;
|
||||||
|
}
|
||||||
|
|
||||||
if (!block_msg) {
|
if (!block_msg) {
|
||||||
bus_fwd = 0;
|
bus_fwd = 0;
|
||||||
}
|
}
|
||||||
@@ -376,6 +611,33 @@ static safety_config gm_init(uint16_t param) {
|
|||||||
gm_has_acc = !GET_FLAG(param, GM_PARAM_NO_ACC);
|
gm_has_acc = !GET_FLAG(param, GM_PARAM_NO_ACC);
|
||||||
gm_force_brake_c9 = GET_FLAG(param, GM_PARAM_FORCE_BRAKE_C9);
|
gm_force_brake_c9 = GET_FLAG(param, GM_PARAM_FORCE_BRAKE_C9);
|
||||||
gm_remote_start_boots_comma = GET_FLAG(param, GM_PARAM_REMOTE_START_BOOTS_COMMA);
|
gm_remote_start_boots_comma = GET_FLAG(param, GM_PARAM_REMOTE_START_BOOTS_COMMA);
|
||||||
|
gm_panda_3d1_sched = GET_FLAG(param, GM_PARAM_PANDA_3D1_SCHED) && gm_pedal_long && !gm_has_acc && !gm_bolt_2022_pedal;
|
||||||
|
gm_panda_paddle_sched = GET_FLAG(param, GM_PARAM_PANDA_PADDLE_SCHED) && gm_pedal_long && enable_gas_interceptor;
|
||||||
|
bool gm_ascm_int_stock_cam = gm_ascm_int && (gm_hw == GM_CAM) && gm_pcm_cruise && !gm_cam_long && !gm_pedal_long && !gm_cc_long;
|
||||||
|
|
||||||
|
gm_3d1_spoof_valid = false;
|
||||||
|
gm_3d1_internal_tx = false;
|
||||||
|
gm_3d1_next_tx_us = 0U;
|
||||||
|
gm_3d1_expected_stock_us = 0U;
|
||||||
|
gm_3d1_last_stock_us = 0U;
|
||||||
|
gm_3d1_phase_locked = false;
|
||||||
|
gm_paddle_internal_tx = false;
|
||||||
|
|
||||||
|
gm_bd_state.spoof_valid = false;
|
||||||
|
gm_bd_state.next_tx_us = 0U;
|
||||||
|
gm_bd_state.expected_stock_us = 0U;
|
||||||
|
gm_bd_state.last_stock_us = 0U;
|
||||||
|
gm_bd_state.last_feed_us = 0U;
|
||||||
|
gm_bd_state.phase_locked = false;
|
||||||
|
(void)memset(gm_bd_state.spoof_data, 0, sizeof(gm_bd_state.spoof_data));
|
||||||
|
|
||||||
|
gm_prndl2_state.spoof_valid = false;
|
||||||
|
gm_prndl2_state.next_tx_us = 0U;
|
||||||
|
gm_prndl2_state.expected_stock_us = 0U;
|
||||||
|
gm_prndl2_state.last_stock_us = 0U;
|
||||||
|
gm_prndl2_state.last_feed_us = 0U;
|
||||||
|
gm_prndl2_state.phase_locked = false;
|
||||||
|
(void)memset(gm_prndl2_state.spoof_data, 0, sizeof(gm_prndl2_state.spoof_data));
|
||||||
|
|
||||||
safety_config ret = BUILD_SAFETY_CFG(gm_rx_checks, GM_ASCM_TX_MSGS);
|
safety_config ret = BUILD_SAFETY_CFG(gm_rx_checks, GM_ASCM_TX_MSGS);
|
||||||
if (gm_hw == GM_CAM) {
|
if (gm_hw == GM_CAM) {
|
||||||
@@ -387,6 +649,9 @@ static safety_config gm_init(uint16_t param) {
|
|||||||
ret = BUILD_SAFETY_CFG(gm_rx_checks, GM_CAM_TX_MSGS);
|
ret = BUILD_SAFETY_CFG(gm_rx_checks, GM_CAM_TX_MSGS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (gm_ascm_int_stock_cam) {
|
||||||
|
SET_RX_CHECKS(gm_ascm_int_stock_cam_rx_checks, ret);
|
||||||
|
}
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -275,3 +275,6 @@ const uint32_t RELAY_TRNS_TIMEOUT = 1U;
|
|||||||
|
|
||||||
// Always on Lateral
|
// Always on Lateral
|
||||||
#define ALT_EXP_ALWAYS_ON_LATERAL 32
|
#define ALT_EXP_ALWAYS_ON_LATERAL 32
|
||||||
|
|
||||||
|
// GM pedal-long option: interpret wheel CANCEL as an auxiliary distance-style button.
|
||||||
|
#define ALT_EXP_GM_REMAP_CANCEL_TO_DISTANCE 64
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class ALTERNATIVE_EXPERIENCE:
|
|||||||
RAISE_LONGITUDINAL_LIMITS_TO_ISO_MAX = 8
|
RAISE_LONGITUDINAL_LIMITS_TO_ISO_MAX = 8
|
||||||
ALLOW_AEB = 16
|
ALLOW_AEB = 16
|
||||||
ALWAYS_ON_LATERAL = 32
|
ALWAYS_ON_LATERAL = 32
|
||||||
|
GM_REMAP_CANCEL_TO_DISTANCE = 64
|
||||||
|
|
||||||
class Panda:
|
class Panda:
|
||||||
|
|
||||||
@@ -243,6 +244,8 @@ class Panda:
|
|||||||
FLAG_GM_BOLT_2017 = 2048
|
FLAG_GM_BOLT_2017 = 2048
|
||||||
FLAG_GM_BOLT_2022_PEDAL = 4096
|
FLAG_GM_BOLT_2022_PEDAL = 4096
|
||||||
FLAG_GM_REMOTE_START_BOOTS_COMMA = 8192
|
FLAG_GM_REMOTE_START_BOOTS_COMMA = 8192
|
||||||
|
FLAG_GM_PANDA_3D1_SCHED = 16384
|
||||||
|
FLAG_GM_PANDA_PADDLE_SCHED = 32768
|
||||||
|
|
||||||
FLAG_FORD_LONG_CONTROL = 1
|
FLAG_FORD_LONG_CONTROL = 1
|
||||||
FLAG_FORD_CANFD = 2
|
FLAG_FORD_CANFD = 2
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ interface_names = _get_interface_names()
|
|||||||
interfaces = load_interfaces(interface_names)
|
interfaces = load_interfaces(interface_names)
|
||||||
|
|
||||||
|
|
||||||
def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict]]:
|
def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict], dict[int, set[int]]]:
|
||||||
finger = gen_empty_fingerprint()
|
finger = gen_empty_fingerprint()
|
||||||
|
nonzero_addrs = {bus: set() for bus in finger}
|
||||||
candidate_cars = {i: all_legacy_fingerprint_cars() for i in [0, 1]} # attempt fingerprint on both bus 0 and 1
|
candidate_cars = {i: all_legacy_fingerprint_cars() for i in [0, 1]} # attempt fingerprint on both bus 0 and 1
|
||||||
frame = 0
|
frame = 0
|
||||||
car_fingerprint = None
|
car_fingerprint = None
|
||||||
@@ -86,7 +87,10 @@ def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict]]:
|
|||||||
if can.src < 128:
|
if can.src < 128:
|
||||||
if can.src not in finger:
|
if can.src not in finger:
|
||||||
finger[can.src] = {}
|
finger[can.src] = {}
|
||||||
|
nonzero_addrs[can.src] = set()
|
||||||
finger[can.src][can.address] = len(can.dat)
|
finger[can.src][can.address] = len(can.dat)
|
||||||
|
if any(can.dat):
|
||||||
|
nonzero_addrs[can.src].add(can.address)
|
||||||
|
|
||||||
for b in candidate_cars:
|
for b in candidate_cars:
|
||||||
# Ignore extended messages and VIN query response.
|
# Ignore extended messages and VIN query response.
|
||||||
@@ -107,7 +111,7 @@ def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict]]:
|
|||||||
|
|
||||||
frame += 1
|
frame += 1
|
||||||
|
|
||||||
return car_fingerprint, finger
|
return car_fingerprint, finger, nonzero_addrs
|
||||||
|
|
||||||
|
|
||||||
# **** for use live only ****
|
# **** for use live only ****
|
||||||
@@ -164,7 +168,7 @@ def fingerprint(logcan, sendcan, num_pandas):
|
|||||||
# CAN fingerprint
|
# CAN fingerprint
|
||||||
# drain CAN socket so we get the latest messages
|
# drain CAN socket so we get the latest messages
|
||||||
messaging.drain_sock_raw(logcan)
|
messaging.drain_sock_raw(logcan)
|
||||||
car_fingerprint, finger = can_fingerprint(lambda: get_one_can(logcan))
|
car_fingerprint, finger, nonzero_addrs = can_fingerprint(lambda: get_one_can(logcan))
|
||||||
|
|
||||||
exact_match = True
|
exact_match = True
|
||||||
source = car.CarParams.FingerprintSource.can
|
source = car.CarParams.FingerprintSource.can
|
||||||
@@ -183,7 +187,7 @@ def fingerprint(logcan, sendcan, num_pandas):
|
|||||||
fw_count=len(car_fw), ecu_responses=list(ecu_rx_addrs), vin_rx_addr=vin_rx_addr, vin_rx_bus=vin_rx_bus,
|
fw_count=len(car_fw), ecu_responses=list(ecu_rx_addrs), vin_rx_addr=vin_rx_addr, vin_rx_bus=vin_rx_bus,
|
||||||
fingerprints=repr(finger), fw_query_time=fw_query_time, error=True)
|
fingerprints=repr(finger), fw_query_time=fw_query_time, error=True)
|
||||||
|
|
||||||
return car_fingerprint, finger, vin, car_fw, source, exact_match
|
return car_fingerprint, finger, nonzero_addrs, vin, car_fw, source, exact_match
|
||||||
|
|
||||||
|
|
||||||
def get_car_interface(CP, FPCP):
|
def get_car_interface(CP, FPCP):
|
||||||
@@ -250,7 +254,7 @@ def migrate_legacy_bolt_candidate(candidate: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_car(logcan, sendcan, experimental_long_allowed, params, num_pandas=1, frogpilot_toggles=None):
|
def get_car(logcan, sendcan, experimental_long_allowed, params, num_pandas=1, frogpilot_toggles=None):
|
||||||
candidate, fingerprints, vin, car_fw, source, exact_match = fingerprint(logcan, sendcan, num_pandas)
|
candidate, fingerprints, nonzero_addrs, vin, car_fw, source, exact_match = fingerprint(logcan, sendcan, num_pandas)
|
||||||
|
|
||||||
if candidate is None or frogpilot_toggles.force_fingerprint:
|
if candidate is None or frogpilot_toggles.force_fingerprint:
|
||||||
if frogpilot_toggles.car_model is not None:
|
if frogpilot_toggles.car_model is not None:
|
||||||
@@ -299,12 +303,12 @@ def get_car(logcan, sendcan, experimental_long_allowed, params, num_pandas=1, fr
|
|||||||
if year_code in year_map:
|
if year_code in year_map:
|
||||||
vin_candidate = year_map[year_code]
|
vin_candidate = year_map[year_code]
|
||||||
if vin_candidate == GM_CAR.CHEVROLET_BOLT_ACC_2022_2023:
|
if vin_candidate == GM_CAR.CHEVROLET_BOLT_ACC_2022_2023:
|
||||||
has_acc_msg = (
|
has_acc_data = (
|
||||||
0x370 in fingerprints.get(GMCanBus.CAMERA, {}) or
|
0x370 in nonzero_addrs.get(GMCanBus.CAMERA, set()) or
|
||||||
0x370 in fingerprints.get(GMCanBus.POWERTRAIN, {})
|
0x370 in nonzero_addrs.get(GMCanBus.POWERTRAIN, set())
|
||||||
)
|
)
|
||||||
has_pedal_msg = 0x201 in fingerprints.get(GMCanBus.POWERTRAIN, {})
|
has_pedal_msg = 0x201 in fingerprints.get(GMCanBus.POWERTRAIN, {})
|
||||||
if has_acc_msg:
|
if has_acc_data:
|
||||||
vin_candidate = GM_CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL if has_pedal_msg else GM_CAR.CHEVROLET_BOLT_ACC_2022_2023
|
vin_candidate = GM_CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL if has_pedal_msg else GM_CAR.CHEVROLET_BOLT_ACC_2022_2023
|
||||||
else:
|
else:
|
||||||
vin_candidate = GM_CAR.CHEVROLET_BOLT_CC_2022_2023
|
vin_candidate = GM_CAR.CHEVROLET_BOLT_CC_2022_2023
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ class Car:
|
|||||||
|
|
||||||
# set alternative experiences from parameters
|
# set alternative experiences from parameters
|
||||||
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
|
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
|
||||||
self.CP.alternativeExperience = 0
|
if self.disengage_on_accelerator:
|
||||||
if not self.disengage_on_accelerator:
|
self.CP.alternativeExperience &= ~ALTERNATIVE_EXPERIENCE.DISABLE_DISENGAGE_ON_GAS
|
||||||
|
else:
|
||||||
self.CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.DISABLE_DISENGAGE_ON_GAS
|
self.CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.DISABLE_DISENGAGE_ON_GAS
|
||||||
|
|
||||||
openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle")
|
openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from openpilot.selfdrive.car.gm.values import CAR, DBC, AccState, CanBus, CarCon
|
|||||||
from openpilot.selfdrive.car.interfaces import CarControllerBase
|
from openpilot.selfdrive.car.interfaces import CarControllerBase
|
||||||
from openpilot.selfdrive.controls.lib.drive_helpers import apply_deadzone
|
from openpilot.selfdrive.controls.lib.drive_helpers import apply_deadzone
|
||||||
from openpilot.selfdrive.controls.lib.vehicle_model import ACCELERATION_DUE_TO_GRAVITY
|
from openpilot.selfdrive.controls.lib.vehicle_model import ACCELERATION_DUE_TO_GRAVITY
|
||||||
from openpilot.common.swaglog import cloudlog
|
from openpilot.frogpilot.common.testing_grounds import testing_ground
|
||||||
|
|
||||||
VisualAlert = car.CarControl.HUDControl.VisualAlert
|
VisualAlert = car.CarControl.HUDControl.VisualAlert
|
||||||
NetworkLocation = car.CarParams.NetworkLocation
|
NetworkLocation = car.CarParams.NetworkLocation
|
||||||
@@ -27,15 +27,6 @@ TransmissionType = car.CarParams.TransmissionType
|
|||||||
CAMERA_CANCEL_DELAY_FRAMES = 10
|
CAMERA_CANCEL_DELAY_FRAMES = 10
|
||||||
# Enforce a minimum interval between steering messages to avoid a fault
|
# Enforce a minimum interval between steering messages to avoid a fault
|
||||||
MIN_STEER_MSG_INTERVAL_MS = 15
|
MIN_STEER_MSG_INTERVAL_MS = 15
|
||||||
# Two‑sided spacing tuned for ~33 Hz steer; target a 10 ms wide window per interval
|
|
||||||
# Paddle spoofing and scheduling constants
|
|
||||||
PADDLE_STEER_GAP_MIN_NS = 5_000_000 # ≥5 ms each side (EPS guard)
|
|
||||||
PADDLE_STEER_GAP_MAX_NS = 12_000_000 # cap for long intervals
|
|
||||||
PADDLE_GAP_TARGET_NS = 5_000_000 # aim per‑side gap even if interval//2 − early is larger
|
|
||||||
PADDLE_NONBLOCK_GAP_NS = 1_000_000 # ≥1 ms since last paddle send
|
|
||||||
PADDLE_SLOT_EARLY_NS = 1_000_000 # allow firing up to 1 ms before slot
|
|
||||||
OVERFLOW_THRESH = 1.00 # fire one extra slot whenever credits ≥ 1.0
|
|
||||||
PADDLE_TARGET_HZ = 42.0 # desired paddle rate (Hz) when regen active; steer is ~33 Hz
|
|
||||||
# Constants for pitch compensation
|
# Constants for pitch compensation
|
||||||
BRAKE_PITCH_FACTOR_BP = [5., 10.] # [m/s] smoothly revert to planned accel at low speeds
|
BRAKE_PITCH_FACTOR_BP = [5., 10.] # [m/s] smoothly revert to planned accel at low speeds
|
||||||
BRAKE_PITCH_FACTOR_V = [0., 1.] # [unitless in [0,1]]; don't touch
|
BRAKE_PITCH_FACTOR_V = [0., 1.] # [unitless in [0,1]]; don't touch
|
||||||
@@ -52,10 +43,6 @@ class CarController(CarControllerBase):
|
|||||||
self.frame = 0
|
self.frame = 0
|
||||||
self.last_steer_frame = 0
|
self.last_steer_frame = 0
|
||||||
self.last_steer_ts_ns = 0
|
self.last_steer_ts_ns = 0
|
||||||
self.last_regen_active = False
|
|
||||||
self.prev_steer_ts_ns = 0
|
|
||||||
self.last_spoof_ts_ns = 0
|
|
||||||
self.last_paddle_ts_ns = 0
|
|
||||||
self.last_button_frame = 0
|
self.last_button_frame = 0
|
||||||
self.cancel_counter = 0
|
self.cancel_counter = 0
|
||||||
self.pedal_steady = 0.
|
self.pedal_steady = 0.
|
||||||
@@ -79,7 +66,9 @@ class CarController(CarControllerBase):
|
|||||||
self.malibu_cancel_phase = 0
|
self.malibu_cancel_phase = 0
|
||||||
self.malibu_cancel_last_ts = 0.0
|
self.malibu_cancel_last_ts = 0.0
|
||||||
self.malibu_cancel_frame = 0
|
self.malibu_cancel_frame = 0
|
||||||
|
self.malibu_cancel_frame_offset = 0
|
||||||
self.malibu_button_phase = 0
|
self.malibu_button_phase = 0
|
||||||
|
self.malibu_last_button_ts_nanos = 0
|
||||||
|
|
||||||
self.packer_pt = CANPacker(DBC[self.CP.carFingerprint]['pt'])
|
self.packer_pt = CANPacker(DBC[self.CP.carFingerprint]['pt'])
|
||||||
self.packer_obj = CANPacker(DBC[self.CP.carFingerprint]['radar'])
|
self.packer_obj = CANPacker(DBC[self.CP.carFingerprint]['radar'])
|
||||||
@@ -92,60 +81,163 @@ class CarController(CarControllerBase):
|
|||||||
self.regen_paddle_pressed = False
|
self.regen_paddle_pressed = False
|
||||||
self.aego = 0.0
|
self.aego = 0.0
|
||||||
self.regen_paddle_timer = 0
|
self.regen_paddle_timer = 0
|
||||||
|
self.regen_press_counter = 0
|
||||||
|
self.regen_release_counter = 0
|
||||||
|
self.regen_min_on_frames = 0
|
||||||
|
self.regen_min_off_frames = 0
|
||||||
self.planner_regen_hold = False
|
self.planner_regen_hold = False
|
||||||
|
self.paddle_handoff_frames = 0
|
||||||
|
self.pedal_active_last = False
|
||||||
|
self.maneuver_paddle_mode = "auto"
|
||||||
# Midpoint + overflow spoof accumulator and flags
|
|
||||||
self.spoof_accum = 0.0
|
|
||||||
self.spoof_mid_sent = False
|
|
||||||
self.spoof_over_sent = False
|
|
||||||
self.last_interval_ns = 0
|
|
||||||
|
|
||||||
def calc_pedal_command(self, accel: float, long_active: bool, car_velocity) -> Tuple[float, bool]:
|
def calc_pedal_command(self, accel: float, long_active: bool, car_velocity) -> Tuple[float, bool]:
|
||||||
if not long_active:
|
if not long_active:
|
||||||
self.planner_regen_hold = False
|
self.planner_regen_hold = False
|
||||||
|
self.regen_paddle_pressed = False
|
||||||
|
self.regen_paddle_timer = 0
|
||||||
|
self.regen_press_counter = 0
|
||||||
|
self.regen_release_counter = 0
|
||||||
|
self.regen_min_on_frames = 0
|
||||||
|
self.regen_min_off_frames = 0
|
||||||
|
self.pedal_active_last = False
|
||||||
|
self.pedal_steady = 0.0
|
||||||
return 0., False
|
return 0., False
|
||||||
|
|
||||||
# Regen paddle hysteresis (frame-based): hold 10 frames, with decrement dead-zone
|
supports_regen_paddle = self.CP.carFingerprint in CC_REGEN_PADDLE_CAR
|
||||||
if not hasattr(self, 'regen_paddle_timer'):
|
switched_state = False
|
||||||
self.regen_paddle_timer = 0 # frames
|
press_regen_paddle = False
|
||||||
|
if supports_regen_paddle:
|
||||||
|
# Regen paddle state machine: speed-aware thresholds + min on/off duration for robust paddle transitions.
|
||||||
|
press_cmd_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.90, -0.82, -0.72, -0.65])
|
||||||
|
release_cmd_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.10, -0.17, -0.24, -0.30])
|
||||||
|
press_aego_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.95, -0.86, -0.76, -0.70])
|
||||||
|
release_aego_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.16, -0.23, -0.30, -0.36])
|
||||||
|
|
||||||
# Regen paddle hysteresis (frame‑based): count frames when decelerating hard, decrement only when truly released
|
press_confirm_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [8.0, 6.0, 5.0, 4.0])))
|
||||||
if self.aego < -0.7:
|
release_confirm_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [18.0, 15.0, 12.0, 10.0])))
|
||||||
self.regen_paddle_timer += 1
|
min_on_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [34.0, 27.0, 20.0, 16.0])))
|
||||||
elif self.aego > -0.3:
|
min_off_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [16.0, 14.0, 12.0, 10.0])))
|
||||||
self.regen_paddle_timer = max(self.regen_paddle_timer - 1, 0)
|
# Extra hysteresis in the 8-20 mph band to reduce paddle edge pulsing.
|
||||||
# else: hold timer between -0.7 and -0.3
|
confirm_boost = int(round(interp(car_velocity, [0.0, 6.0, 8.0, 20.0, 25.0], [0.0, 0.0, 3.0, 3.0, 0.0])))
|
||||||
|
press_confirm_frames += confirm_boost
|
||||||
|
release_confirm_frames += confirm_boost
|
||||||
|
|
||||||
# Base paddle press hysteresis
|
want_press = self.planner_regen_hold or accel <= press_cmd_threshold or self.aego <= press_aego_threshold
|
||||||
self.regen_paddle_pressed = self.regen_paddle_timer >= 10 # 10 frames
|
want_release = (not self.planner_regen_hold) and accel >= release_cmd_threshold and self.aego >= release_aego_threshold
|
||||||
press_regen_paddle = self.regen_paddle_pressed or self.planner_regen_hold
|
|
||||||
|
|
||||||
|
if want_press:
|
||||||
|
self.regen_press_counter += 1
|
||||||
|
else:
|
||||||
|
self.regen_press_counter = max(self.regen_press_counter - 1, 0)
|
||||||
|
|
||||||
|
if want_release:
|
||||||
|
self.regen_release_counter += 1
|
||||||
|
else:
|
||||||
|
self.regen_release_counter = max(self.regen_release_counter - 1, 0)
|
||||||
|
|
||||||
|
# Strong planner request can skip most of debounce delay.
|
||||||
|
if self.planner_regen_hold and accel <= (press_cmd_threshold - 0.30):
|
||||||
|
self.regen_press_counter = max(self.regen_press_counter, press_confirm_frames)
|
||||||
|
|
||||||
|
if self.regen_min_on_frames > 0:
|
||||||
|
self.regen_min_on_frames -= 1
|
||||||
|
if self.regen_min_off_frames > 0:
|
||||||
|
self.regen_min_off_frames -= 1
|
||||||
|
|
||||||
|
if self.regen_paddle_pressed:
|
||||||
|
if self.regen_min_on_frames == 0 and self.regen_release_counter >= release_confirm_frames:
|
||||||
|
self.regen_paddle_pressed = False
|
||||||
|
self.regen_min_off_frames = min_off_frames
|
||||||
|
self.regen_release_counter = 0
|
||||||
|
switched_state = True
|
||||||
|
else:
|
||||||
|
if self.regen_min_off_frames == 0 and self.regen_press_counter >= press_confirm_frames:
|
||||||
|
self.regen_paddle_pressed = True
|
||||||
|
self.regen_min_on_frames = min_on_frames
|
||||||
|
self.regen_press_counter = 0
|
||||||
|
switched_state = True
|
||||||
|
|
||||||
|
self.regen_paddle_timer = self.regen_press_counter
|
||||||
|
press_regen_paddle = self.regen_paddle_pressed
|
||||||
|
|
||||||
|
if self.maneuver_paddle_mode == "off":
|
||||||
|
self.regen_paddle_pressed = False
|
||||||
|
self.regen_press_counter = 0
|
||||||
|
self.regen_release_counter = 0
|
||||||
|
self.regen_min_on_frames = 0
|
||||||
|
press_regen_paddle = False
|
||||||
|
elif self.maneuver_paddle_mode == "force":
|
||||||
|
forced_press = accel < -0.02
|
||||||
|
self.regen_paddle_pressed = forced_press
|
||||||
|
press_regen_paddle = forced_press
|
||||||
|
else:
|
||||||
|
self.planner_regen_hold = False
|
||||||
|
self.regen_paddle_pressed = False
|
||||||
|
self.regen_paddle_timer = 0
|
||||||
|
self.regen_press_counter = 0
|
||||||
|
self.regen_release_counter = 0
|
||||||
|
self.regen_min_on_frames = 0
|
||||||
|
self.regen_min_off_frames = 0
|
||||||
|
|
||||||
# Regen gain ratios from bin-averaged 60–0 deceleration sweep; Calculates stronger decel from paddle
|
# Regen gain ratios from bin-averaged 60–0 deceleration sweep; Calculates stronger decel from paddle
|
||||||
speed_mps = [0.559, 1.678, 2.797, 3.916, 5.035, 6.154, 7.273, 8.392, 9.511, 10.63,
|
speed_mps = [0.559, 1.678, 2.797, 3.916, 5.035, 6.154, 7.273, 8.392, 9.511, 10.63,
|
||||||
11.749, 12.868, 13.987, 15.106, 16.225, 17.344, 18.463, 19.582, 20.701, 21.820,
|
11.749, 12.868, 13.987, 15.106, 16.225, 17.344, 18.463, 19.582, 20.701, 21.820,
|
||||||
22.939, 24.058, 25.177, 26.296]
|
22.939, 24.058, 25.177, 26.296]
|
||||||
regen_gain_ratio = [
|
regen_gain_ratio = [1.01, 1.01, 1.02, 1.05, 1.08, 1.31, 1.33,
|
||||||
1.000000, 1.057308, 1.131123, 1.220611, 1.270247, 1.300253, 1.339543, 1.361002,
|
1.34, 1.35, 1.36, 1.37, 1.38, 1.39, 1.39,
|
||||||
1.388410, 1.403253, 1.414721, 1.430949, 1.420289, 1.436787, 1.434116, 1.436805,
|
1.40, 1.40, 1.41, 1.42, 1.43, 1.43, 1.44,
|
||||||
1.417508, 1.402213, 1.395360, 1.360921, 1.342030, 1.292219, 1.270048, 1.239172
|
1.44, 1.45, 1.45]
|
||||||
]
|
|
||||||
|
|
||||||
gain = interp(car_velocity, speed_mps, regen_gain_ratio)
|
gain = interp(car_velocity, speed_mps, regen_gain_ratio)
|
||||||
pedaloffset = interp(car_velocity, [0., 3, 6, 30], [0.10, 0.175, 0.240, 0.240])
|
# Terminal-stop authority boost for paddle+pedal mode: lower gain at low speed => stronger net decel.
|
||||||
|
gain *= interp(car_velocity, [0.0, 2.0, 4.0, 5.5, 8.0, 12.0], [0.92, 0.92, 0.93, 0.94, 0.96, 1.0])
|
||||||
|
accel_gain = interp(car_velocity, [0.0, 3.0, 8.0, 20.0], [0.47, 0.52, 0.57, 0.61])
|
||||||
|
pedaloffset = interp(car_velocity, [0.0, 1.0, 3.0, 6.0, 15.0, 30.0], [0.085, 0.11, 0.17, 0.23, 0.235, 0.23])
|
||||||
|
|
||||||
# Compute raw pedal gas
|
accel_term_scale = (1.0 / max(gain, 1e-3)) if press_regen_paddle else 1.0
|
||||||
raw_pedal_gas = clip((pedaloffset + (accel / gain) * 0.6), 0.0, 1.0) if press_regen_paddle else clip((pedaloffset + accel * 0.6), 0.0, 1.0)
|
# De-sensitize small commands asymmetrically: keep decel smoother while preserving accel pickup.
|
||||||
|
if accel >= 0.0:
|
||||||
|
small_cmd_scale = interp(abs(accel), [0.0, 0.35, 0.8, 1.5, 2.5], [0.58, 0.68, 0.82, 0.93, 1.0])
|
||||||
|
else:
|
||||||
|
small_cmd_scale = interp(abs(accel), [0.0, 0.35, 0.8, 1.5, 2.5], [0.44, 0.54, 0.70, 0.89, 1.0])
|
||||||
|
accel_cmd = accel * small_cmd_scale
|
||||||
|
if (not press_regen_paddle) and accel < -2.0:
|
||||||
|
accel_cmd *= interp(abs(accel), [2.0, 2.5, 3.0], [1.0, 1.03, 1.06])
|
||||||
|
raw_pedal_gas = clip(pedaloffset + accel_cmd * accel_gain * accel_term_scale, 0.0, 1.0)
|
||||||
|
|
||||||
# --- Immediate application of raw pedal gas, no blending ---
|
# Safety cap with continuous low-speed transition (removes the near-step around ~4.5 m/s).
|
||||||
pedal_gas = raw_pedal_gas
|
pedal_gas_max = interp(car_velocity, [0.0, 1.0, 2.5, 4.5, 6.0, 8.0, 12.0], [0.20, 0.235, 0.29, 0.365, 0.52, 0.78, 1.0])
|
||||||
# Safety cap: ramp from 22% at 0 m/s to 37.25% at 10 mph (4.47 m/s), then allow full throttle
|
target_pedal_gas = clip(raw_pedal_gas, 0.0, pedal_gas_max)
|
||||||
pedal_gas_max = interp(car_velocity, [0.0, 4.47, 4.48], [0.22, 0.3725, 1.0])
|
|
||||||
pedal_gas = clip(pedal_gas, 0.0, pedal_gas_max)
|
# Blend and rate-limit command changes for smoother transients while preserving clip bounds.
|
||||||
|
if not self.pedal_active_last or switched_state:
|
||||||
|
pedal_gas = target_pedal_gas
|
||||||
|
self.pedal_active_last = True
|
||||||
|
else:
|
||||||
|
urgency = clip(abs(accel) / 2.0, 0.0, 1.0)
|
||||||
|
rate_up = interp(car_velocity, [0.0, 3.0, 8.0, 20.0], [0.007, 0.012, 0.022, 0.036]) + 0.011 * urgency
|
||||||
|
if accel > 1.2:
|
||||||
|
# Recover high-command launch response without changing low-command smoothness.
|
||||||
|
rate_up += interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [0.006, 0.005, 0.003, 0.002])
|
||||||
|
rate_down = interp(car_velocity, [0.0, 3.0, 8.0, 20.0], [0.008, 0.014, 0.026, 0.045]) + 0.015 * urgency
|
||||||
|
pedal_gas = clip(target_pedal_gas, self.pedal_steady - rate_down, self.pedal_steady + rate_up)
|
||||||
|
|
||||||
|
self.pedal_steady = pedal_gas
|
||||||
return pedal_gas, press_regen_paddle
|
return pedal_gas, press_regen_paddle
|
||||||
|
|
||||||
|
def _update_malibu_button_slot(self, cs):
|
||||||
|
button_ts = cs.steering_button_ts_nanos
|
||||||
|
if button_ts != 0 and button_ts != self.malibu_last_button_ts_nanos:
|
||||||
|
self.malibu_last_button_ts_nanos = button_ts
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _sync_malibu_phase_from_oem(self, cs):
|
||||||
|
phase_map = gmcan.malibu_phase_map_for_acc(cs.cruise_buttons)
|
||||||
|
if phase_map and cs.steering_button_checksum in phase_map:
|
||||||
|
phase = phase_map[cs.steering_button_checksum]
|
||||||
|
self.malibu_cancel_phase = phase
|
||||||
|
self.malibu_button_phase = phase
|
||||||
|
|
||||||
def update(self, CC, CS, now_nanos, frogpilot_toggles):
|
def update(self, CC, CS, now_nanos, frogpilot_toggles):
|
||||||
self.CS = CS
|
self.CS = CS
|
||||||
@@ -153,6 +245,12 @@ class CarController(CarControllerBase):
|
|||||||
actuators = CC.actuators
|
actuators = CC.actuators
|
||||||
accel = brake_accel = actuators.accel
|
accel = brake_accel = actuators.accel
|
||||||
press_regen_paddle = False
|
press_regen_paddle = False
|
||||||
|
|
||||||
|
# Longitudinal maneuvers can force paddle behavior for A/B testing.
|
||||||
|
if self.frame % 25 == 0:
|
||||||
|
mode = self.params_.get("LongitudinalManeuverPaddleMode", encoding="utf-8")
|
||||||
|
mode = (mode or "auto").strip().lower()
|
||||||
|
self.maneuver_paddle_mode = mode if mode in ("auto", "off", "force") else "auto"
|
||||||
kaofui_cars = SDGM_CAR | ASCM_INT | {
|
kaofui_cars = SDGM_CAR | ASCM_INT | {
|
||||||
CAR.CHEVROLET_VOLT,
|
CAR.CHEVROLET_VOLT,
|
||||||
CAR.CHEVROLET_VOLT_2019,
|
CAR.CHEVROLET_VOLT_2019,
|
||||||
@@ -173,14 +271,18 @@ class CarController(CarControllerBase):
|
|||||||
# Planner-driven regen hold: gate by car support and OP long active, use commanded accel thresholds
|
# Planner-driven regen hold: gate by car support and OP long active, use commanded accel thresholds
|
||||||
if (self.CP.enableGasInterceptor and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR
|
if (self.CP.enableGasInterceptor and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR
|
||||||
and self.CP.openpilotLongitudinalControl and CC.longActive):
|
and self.CP.openpilotLongitudinalControl and CC.longActive):
|
||||||
# Match original hysteresis intent: vehicle can usually stop without paddle up to ~1.0 m/s^2
|
if self.maneuver_paddle_mode == "off":
|
||||||
# Use the same thresholds as the aEgo-based hysteresis, but on commanded accel for preemption
|
|
||||||
planner_press_threshold = -0.7
|
|
||||||
planner_release_threshold = -0.3
|
|
||||||
if accel <= planner_press_threshold:
|
|
||||||
self.planner_regen_hold = True
|
|
||||||
elif accel >= planner_release_threshold:
|
|
||||||
self.planner_regen_hold = False
|
self.planner_regen_hold = False
|
||||||
|
elif self.maneuver_paddle_mode == "force":
|
||||||
|
self.planner_regen_hold = accel < -0.02
|
||||||
|
else:
|
||||||
|
# Pre-arm paddle on strong commanded decel with speed-aware hysteresis.
|
||||||
|
planner_press_threshold = interp(CS.out.vEgo, [0.0, 4.0, 12.0, 25.0], [-0.95, -0.82, -0.70, -0.62])
|
||||||
|
planner_release_threshold = interp(CS.out.vEgo, [0.0, 4.0, 12.0, 25.0], [-0.14, -0.22, -0.30, -0.36])
|
||||||
|
if accel <= planner_press_threshold:
|
||||||
|
self.planner_regen_hold = True
|
||||||
|
elif accel >= planner_release_threshold:
|
||||||
|
self.planner_regen_hold = False
|
||||||
else:
|
else:
|
||||||
self.planner_regen_hold = False
|
self.planner_regen_hold = False
|
||||||
|
|
||||||
@@ -192,129 +294,38 @@ class CarController(CarControllerBase):
|
|||||||
|
|
||||||
# Send CAN commands.
|
# Send CAN commands.
|
||||||
can_sends = []
|
can_sends = []
|
||||||
paddle_sends = []
|
|
||||||
|
|
||||||
|
malibu_oem_button_slot = False
|
||||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||||
phase_map = gmcan.malibu_phase_map_for_acc(CS.cruise_buttons)
|
malibu_oem_button_slot = self._update_malibu_button_slot(CS)
|
||||||
if phase_map and CS.steering_button_checksum in phase_map:
|
if malibu_oem_button_slot:
|
||||||
phase = (phase_map[CS.steering_button_checksum] + 1) % 4
|
self._sync_malibu_phase_from_oem(CS)
|
||||||
self.malibu_cancel_phase = phase
|
|
||||||
self.malibu_button_phase = phase
|
|
||||||
|
|
||||||
raw_regen_active = (
|
raw_regen_active = (
|
||||||
self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
|
self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
|
||||||
self.CP.openpilotLongitudinalControl and
|
self.CP.openpilotLongitudinalControl and
|
||||||
CC.longActive and
|
CC.longActive and
|
||||||
self.CP.enableGasInterceptor and
|
self.CP.enableGasInterceptor and
|
||||||
(self.regen_paddle_timer >= 10 or self.planner_regen_hold) # hysteresis or planner hint
|
self.regen_paddle_pressed
|
||||||
)
|
)
|
||||||
regen_active = raw_regen_active
|
use_panda_paddle_sched = (
|
||||||
|
self.CP.enableGasInterceptor and
|
||||||
|
self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
|
||||||
|
self.CP.openpilotLongitudinalControl and
|
||||||
|
bool(self.CP.flags & GMFlags.PEDAL_LONG.value)
|
||||||
|
)
|
||||||
|
if use_panda_paddle_sched:
|
||||||
|
if CC.enabled:
|
||||||
|
self.paddle_handoff_frames = 2
|
||||||
|
paddle_sched_feed_active = CC.enabled or (self.paddle_handoff_frames > 0)
|
||||||
|
if not CC.enabled and self.paddle_handoff_frames > 0:
|
||||||
|
self.paddle_handoff_frames -= 1
|
||||||
|
else:
|
||||||
|
self.paddle_handoff_frames = 0
|
||||||
|
paddle_sched_feed_active = False
|
||||||
|
|
||||||
# === Spoof scheduling: midpoint + overflow (~target Hz) ===
|
# Preserve existing on-send gate: only send "pressed" while above low-speed threshold.
|
||||||
# Rising-edge reset on regen start
|
paddle_spoof_pressed = raw_regen_active and (CS.out.vEgo > 2.68)
|
||||||
if raw_regen_active and not self.last_regen_active:
|
|
||||||
self.prev_steer_ts_ns = self.last_steer_ts_ns
|
|
||||||
self.last_spoof_ts_ns = 0
|
|
||||||
self.spoof_accum = 0.0
|
|
||||||
self.spoof_mid_sent = False
|
|
||||||
self.spoof_over_sent = False
|
|
||||||
|
|
||||||
if raw_regen_active:
|
|
||||||
# Interval between last two bus-0 steer sends
|
|
||||||
interval_ns = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
|
||||||
|
|
||||||
# Adaptive two‑sided gap sized to the current steer interval, but capped to a target so the window stays wide enough
|
|
||||||
gap_ns = (PADDLE_STEER_GAP_MIN_NS if interval_ns <= 0 else
|
|
||||||
max(PADDLE_STEER_GAP_MIN_NS,
|
|
||||||
min(PADDLE_STEER_GAP_MAX_NS,
|
|
||||||
min((interval_ns // 2) - PADDLE_SLOT_EARLY_NS, PADDLE_GAP_TARGET_NS))))
|
|
||||||
|
|
||||||
# New steer interval? clear per-interval flags and add credits to reach target Hz
|
|
||||||
if interval_ns != self.last_interval_ns:
|
|
||||||
self.spoof_mid_sent = False
|
|
||||||
self.spoof_over_sent = False
|
|
||||||
self.last_interval_ns = interval_ns
|
|
||||||
# Add credits once per new steer interval to reach the desired paddle rate
|
|
||||||
if interval_ns > 0:
|
|
||||||
steer_hz = 1e9 / float(interval_ns)
|
|
||||||
extra_needed = max(0.0, (PADDLE_TARGET_HZ / steer_hz) - 1.0) # e.g., 42/33 − 1 ≈ 0.2727
|
|
||||||
self.spoof_accum += extra_needed
|
|
||||||
|
|
||||||
# Midpoint spoof: one per interval
|
|
||||||
if not self.spoof_mid_sent and interval_ns > 0:
|
|
||||||
midpoint_ns = self.prev_steer_ts_ns + interval_ns // 2
|
|
||||||
# Compute spacing to last and next steer (two-sided guard)
|
|
||||||
next_steer_ts_ns = self.last_steer_ts_ns + interval_ns if interval_ns > 0 else 0
|
|
||||||
delta_after_ns = now_nanos - self.last_steer_ts_ns
|
|
||||||
delta_before_ns = (next_steer_ts_ns - now_nanos) if interval_ns > 0 else 1_000_000_000
|
|
||||||
if (CS.out.vEgo > 2.68
|
|
||||||
and now_nanos >= (midpoint_ns - PADDLE_SLOT_EARLY_NS)
|
|
||||||
and delta_after_ns >= gap_ns
|
|
||||||
and delta_before_ns >= gap_ns):
|
|
||||||
# Non-blocking 1 ms spacing for paddle frames
|
|
||||||
if now_nanos - self.last_paddle_ts_ns >= PADDLE_NONBLOCK_GAP_NS:
|
|
||||||
paddle_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, True, self.CP))
|
|
||||||
paddle_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, True))
|
|
||||||
self.last_paddle_ts_ns = now_nanos
|
|
||||||
self.last_spoof_ts_ns = now_nanos
|
|
||||||
self.spoof_mid_sent = True
|
|
||||||
|
|
||||||
# Overflow spoof: insert extra when accumulator allows
|
|
||||||
if self.spoof_accum >= OVERFLOW_THRESH and not self.spoof_over_sent and interval_ns > 0:
|
|
||||||
slot2_ns = self.prev_steer_ts_ns + (interval_ns * 2) // 3
|
|
||||||
# Two-sided spacing relative to steer
|
|
||||||
next_steer_ts_ns = self.last_steer_ts_ns + interval_ns if interval_ns > 0 else 0
|
|
||||||
delta_after_ns = now_nanos - self.last_steer_ts_ns
|
|
||||||
delta_before_ns = (next_steer_ts_ns - now_nanos) if interval_ns > 0 else 1_000_000_000
|
|
||||||
if (CS.out.vEgo > 2.68
|
|
||||||
and now_nanos >= (slot2_ns - PADDLE_SLOT_EARLY_NS)
|
|
||||||
and delta_after_ns >= gap_ns
|
|
||||||
and delta_before_ns >= gap_ns):
|
|
||||||
# Non-blocking 1 ms spacing for paddle frames
|
|
||||||
if now_nanos - self.last_paddle_ts_ns >= PADDLE_NONBLOCK_GAP_NS:
|
|
||||||
paddle_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, True, self.CP))
|
|
||||||
paddle_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, True))
|
|
||||||
self.last_paddle_ts_ns = now_nanos
|
|
||||||
self.last_spoof_ts_ns = now_nanos
|
|
||||||
self.spoof_over_sent = True
|
|
||||||
self.spoof_accum -= OVERFLOW_THRESH
|
|
||||||
# === End Spoof scheduling ===
|
|
||||||
|
|
||||||
# === Off-pulse scheduling on regen release ===
|
|
||||||
if not raw_regen_active and self.last_regen_active:
|
|
||||||
# schedule two off-slots at 1/3 and 2/3 of the last steer interval
|
|
||||||
if self.prev_steer_ts_ns and self.last_steer_ts_ns:
|
|
||||||
intv = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
|
||||||
self.off_schedule_ns = [
|
|
||||||
self.prev_steer_ts_ns + intv // 3,
|
|
||||||
self.prev_steer_ts_ns + (2 * intv) // 3
|
|
||||||
]
|
|
||||||
self.off_sent = [False, False]
|
|
||||||
|
|
||||||
if hasattr(self, "off_schedule_ns"):
|
|
||||||
for i, t_ns in enumerate(self.off_schedule_ns):
|
|
||||||
if not self.off_sent[i] and now_nanos >= (t_ns - PADDLE_SLOT_EARLY_NS):
|
|
||||||
# Two-sided spacing to steer before sending
|
|
||||||
interval_ns = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
|
||||||
gap_ns = (PADDLE_STEER_GAP_MIN_NS if interval_ns <= 0 else
|
|
||||||
max(PADDLE_STEER_GAP_MIN_NS,
|
|
||||||
min(PADDLE_STEER_GAP_MAX_NS,
|
|
||||||
min((interval_ns // 2) - PADDLE_SLOT_EARLY_NS, PADDLE_GAP_TARGET_NS))))
|
|
||||||
next_steer_ts_ns = self.last_steer_ts_ns + interval_ns if interval_ns > 0 else 0
|
|
||||||
delta_after_ns = now_nanos - self.last_steer_ts_ns
|
|
||||||
delta_before_ns = (next_steer_ts_ns - now_nanos) if interval_ns > 0 else 1_000_000_000
|
|
||||||
if (delta_after_ns >= gap_ns and delta_before_ns >= gap_ns):
|
|
||||||
# Non-blocking 1 ms spacing for paddle frames
|
|
||||||
if now_nanos - self.last_paddle_ts_ns >= PADDLE_NONBLOCK_GAP_NS:
|
|
||||||
paddle_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, False, self.CP))
|
|
||||||
paddle_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, False))
|
|
||||||
self.last_paddle_ts_ns = now_nanos
|
|
||||||
self.off_sent[i] = True
|
|
||||||
# clean up once both off pulses are sent
|
|
||||||
if hasattr(self, "off_sent") and all(self.off_sent):
|
|
||||||
del self.off_schedule_ns
|
|
||||||
del self.off_sent
|
|
||||||
# === End off-pulse scheduling ===
|
|
||||||
|
|
||||||
# Steering (Active: 50Hz, inactive: 10Hz)
|
# Steering (Active: 50Hz, inactive: 10Hz)
|
||||||
steer_step = self.params.STEER_STEP if CC.latActive else self.params.INACTIVE_STEER_STEP
|
steer_step = self.params.STEER_STEP if CC.latActive else self.params.INACTIVE_STEER_STEP
|
||||||
@@ -347,28 +358,29 @@ class CarController(CarControllerBase):
|
|||||||
if (self.CP.flags & GMFlags.CC_LONG.value) and CC.enabled and not CS.out.cruiseState.enabled: # Send 0 so Panda doesn't error
|
if (self.CP.flags & GMFlags.CC_LONG.value) and CC.enabled and not CS.out.cruiseState.enabled: # Send 0 so Panda doesn't error
|
||||||
apply_steer = 0
|
apply_steer = 0
|
||||||
|
|
||||||
# shift previous steer timestamp
|
|
||||||
self.prev_steer_ts_ns = self.last_steer_ts_ns
|
|
||||||
self.last_steer_ts_ns = now_nanos
|
self.last_steer_ts_ns = now_nanos
|
||||||
self.last_steer_frame = self.frame
|
self.last_steer_frame = self.frame
|
||||||
self.apply_steer_last = apply_steer
|
self.apply_steer_last = apply_steer
|
||||||
idx = self.lka_steering_cmd_counter % 4
|
idx = self.lka_steering_cmd_counter % 4
|
||||||
can_sends.append(gmcan.create_steering_control(self.packer_pt, CanBus.POWERTRAIN, apply_steer, idx, CC.latActive))
|
can_sends.append(gmcan.create_steering_control(self.packer_pt, CanBus.POWERTRAIN, apply_steer, idx, CC.latActive))
|
||||||
|
|
||||||
# Update regen_active state and last_regen_paddle_pressed for next loop
|
spoof_ecm_cruise_cars = {
|
||||||
self.last_regen_active = regen_active
|
CAR.CHEVROLET_BOLT_CC_2017,
|
||||||
self.last_regen_paddle_pressed = self.regen_paddle_pressed or self.planner_regen_hold
|
CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2022_2023,
|
||||||
|
CAR.CHEVROLET_MALIBU_HYBRID_CC,
|
||||||
|
}
|
||||||
|
non_acc_pedal_long = (self.CP.flags & GMFlags.PEDAL_LONG.value) and self.CP.carFingerprint in spoof_ecm_cruise_cars and self.CP.enableGasInterceptor
|
||||||
|
if non_acc_pedal_long and self.frame % 4 == 0:
|
||||||
|
spoof_enabled = True
|
||||||
|
spoof_set_speed_kph = hud_v_cruise * CV.MS_TO_KPH
|
||||||
|
can_sends.append(gmcan.create_ecm_cruise_control_command(
|
||||||
|
self.packer_pt, CanBus.POWERTRAIN, spoof_enabled, spoof_set_speed_kph))
|
||||||
|
|
||||||
if paddle_sends:
|
malibu_cancel_requested = False
|
||||||
interval_ns = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
|
||||||
flush_gap_ns = (PADDLE_STEER_GAP_MIN_NS if interval_ns <= 0 else
|
|
||||||
max(PADDLE_STEER_GAP_MIN_NS,
|
|
||||||
min(PADDLE_STEER_GAP_MAX_NS,
|
|
||||||
min((interval_ns // 2) - PADDLE_SLOT_EARLY_NS, PADDLE_GAP_TARGET_NS))))
|
|
||||||
if now_nanos - self.last_steer_ts_ns >= flush_gap_ns:
|
|
||||||
can_sends.extend(paddle_sends)
|
|
||||||
|
|
||||||
if self.CP.openpilotLongitudinalControl:
|
if self.CP.openpilotLongitudinalControl:
|
||||||
|
|
||||||
# Gas/regen, brakes, and UI commands - all at 25Hz
|
# Gas/regen, brakes, and UI commands - all at 25Hz
|
||||||
if self.frame % 4 == 0:
|
if self.frame % 4 == 0:
|
||||||
stopping = actuators.longControlState == LongCtrlState.stopping
|
stopping = actuators.longControlState == LongCtrlState.stopping
|
||||||
@@ -419,13 +431,18 @@ class CarController(CarControllerBase):
|
|||||||
|
|
||||||
gas_max = self.params.MAX_GAS
|
gas_max = self.params.MAX_GAS
|
||||||
accel_max = self.params.ACCEL_MAX
|
accel_max = self.params.ACCEL_MAX
|
||||||
|
if testing_ground.use_1:
|
||||||
|
accel_max = min(accel_max, interp(CS.out.vEgo, [0.0, 4.0, 12.0], [1.25, 1.6, self.params.ACCEL_MAX]))
|
||||||
|
|
||||||
accel = clip(actuators.accel + accel_due_to_pitch, self.params.ACCEL_MIN, accel_max)
|
accel = clip(actuators.accel + accel_due_to_pitch, self.params.ACCEL_MIN, accel_max)
|
||||||
torque = self.tireRadius * ((self.mass*accel) + (0.5*self.coeffDrag*self.frontalArea*self.airDensity*CS.out.vEgo**2))
|
torque = self.tireRadius * ((self.mass*accel) + (0.5*self.coeffDrag*self.frontalArea*self.airDensity*CS.out.vEgo**2))
|
||||||
|
|
||||||
scaled_torque = torque + self.params.ZERO_GAS
|
scaled_torque = torque + self.params.ZERO_GAS
|
||||||
apply_gas_torque = clip(scaled_torque, self.params.MAX_ACC_REGEN, gas_max)
|
apply_gas_torque = clip(scaled_torque, self.params.MAX_ACC_REGEN, gas_max)
|
||||||
BRAKE_SWITCH = int(round(interp(CS.out.vEgo, self.params.BRAKE_SWITCH_LOOKUP_BP, self.params.BRAKE_SWITCH_LOOKUP_V)))
|
BRAKE_SWITCH = int(round(interp(CS.out.vEgo, self.params.BRAKE_SWITCH_LOOKUP_BP, self.params.BRAKE_SWITCH_LOOKUP_V)))
|
||||||
|
if testing_ground.use_1:
|
||||||
|
brake_switch_bias = int(round(interp(CS.out.vEgo, [0.0, 6.0, 15.0, 30.0], [60.0, 120.0, 180.0, 220.0])))
|
||||||
|
BRAKE_SWITCH = min(self.params.ZERO_GAS, BRAKE_SWITCH + brake_switch_bias)
|
||||||
brake_accel = min((scaled_torque - BRAKE_SWITCH)/(self.tireRadius*self.mass), 0)
|
brake_accel = min((scaled_torque - BRAKE_SWITCH)/(self.tireRadius*self.mass), 0)
|
||||||
self.apply_gas = int(round(apply_gas_torque))
|
self.apply_gas = int(round(apply_gas_torque))
|
||||||
self.apply_brake = int(round(interp(brake_accel, self.params.BRAKE_LOOKUP_BP, self.params.BRAKE_LOOKUP_V)))
|
self.apply_brake = int(round(interp(brake_accel, self.params.BRAKE_LOOKUP_BP, self.params.BRAKE_LOOKUP_V)))
|
||||||
@@ -448,6 +465,10 @@ class CarController(CarControllerBase):
|
|||||||
|
|
||||||
idx = (self.frame // 4) % 4
|
idx = (self.frame // 4) % 4
|
||||||
|
|
||||||
|
if paddle_sched_feed_active:
|
||||||
|
can_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, paddle_spoof_pressed, self.CP))
|
||||||
|
can_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, paddle_spoof_pressed))
|
||||||
|
|
||||||
if self.CP.flags & GMFlags.CC_LONG.value:
|
if self.CP.flags & GMFlags.CC_LONG.value:
|
||||||
if CC.longActive and CS.out.cruiseState.enabled and CS.out.vEgo > self.CP.minEnableSpeed:
|
if CC.longActive and CS.out.cruiseState.enabled and CS.out.vEgo > self.CP.minEnableSpeed:
|
||||||
# Using extend instead of append since the message is only sent intermittently
|
# Using extend instead of append since the message is only sent intermittently
|
||||||
@@ -467,7 +488,7 @@ class CarController(CarControllerBase):
|
|||||||
friction_brake_bus = CanBus.CHASSIS
|
friction_brake_bus = CanBus.CHASSIS
|
||||||
# GM Camera exceptions
|
# GM Camera exceptions
|
||||||
# TODO: can we always check the longControlState?
|
# TODO: can we always check the longControlState?
|
||||||
if self.CP.networkLocation == NetworkLocation.fwdCamera and self.CP.carFingerprint not in CC_ONLY_CAR:
|
if self.CP.networkLocation == NetworkLocation.fwdCamera:
|
||||||
at_full_stop = at_full_stop and stopping
|
at_full_stop = at_full_stop and stopping
|
||||||
friction_brake_bus = CanBus.POWERTRAIN
|
friction_brake_bus = CanBus.POWERTRAIN
|
||||||
if self.CP.carFingerprint in SDGM_CAR:
|
if self.CP.carFingerprint in SDGM_CAR:
|
||||||
@@ -488,10 +509,11 @@ class CarController(CarControllerBase):
|
|||||||
can_sends.append(gmcan.create_friction_brake_command(self.packer_ch, friction_brake_bus, self.apply_brake,
|
can_sends.append(gmcan.create_friction_brake_command(self.packer_ch, friction_brake_bus, self.apply_brake,
|
||||||
idx, CC.enabled, near_stop, at_full_stop, self.CP))
|
idx, CC.enabled, near_stop, at_full_stop, self.CP))
|
||||||
|
|
||||||
# Send dashboard UI commands (ACC status)
|
is_bolt_acc_pedal = self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL
|
||||||
|
if self.CP.carFingerprint not in CC_ONLY_CAR or is_bolt_acc_pedal:
|
||||||
send_fcw = hud_alert == VisualAlert.fcw
|
send_fcw = hud_alert == VisualAlert.fcw
|
||||||
can_sends.append(gmcan.create_acc_dashboard_command(self.packer_pt, CanBus.POWERTRAIN, CC.enabled,
|
can_sends.append(gmcan.create_acc_dashboard_command(
|
||||||
hud_v_cruise * CV.MS_TO_KPH, hud_control, send_fcw))
|
self.packer_pt, CanBus.POWERTRAIN, CC.enabled, hud_v_cruise * CV.MS_TO_KPH, hud_control, send_fcw))
|
||||||
else:
|
else:
|
||||||
# to keep accel steady for logs when not sending gas
|
# to keep accel steady for logs when not sending gas
|
||||||
accel += self.accel_g
|
accel += self.accel_g
|
||||||
@@ -501,7 +523,10 @@ class CarController(CarControllerBase):
|
|||||||
if not self.CP.radarUnavailable:
|
if not self.CP.radarUnavailable:
|
||||||
send_adas = True
|
send_adas = True
|
||||||
if self.CP.carFingerprint in kaofui_cars:
|
if self.CP.carFingerprint in kaofui_cars:
|
||||||
send_adas = (self.CP.networkLocation != NetworkLocation.fwdCamera) and (self.CP.carFingerprint not in SDGM_CAR)
|
if self.CP.carFingerprint in ASCM_INT:
|
||||||
|
send_adas = True
|
||||||
|
else:
|
||||||
|
send_adas = (self.CP.networkLocation != NetworkLocation.fwdCamera) and (self.CP.carFingerprint not in SDGM_CAR)
|
||||||
|
|
||||||
if send_adas:
|
if send_adas:
|
||||||
tt = self.frame * DT_CTRL
|
tt = self.frame * DT_CTRL
|
||||||
@@ -532,17 +557,16 @@ class CarController(CarControllerBase):
|
|||||||
stock_cc_active = CS.out.cruiseState.enabled or CS.pcm_acc_status != AccState.OFF
|
stock_cc_active = CS.out.cruiseState.enabled or CS.pcm_acc_status != AccState.OFF
|
||||||
if self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
|
if self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
|
||||||
stock_cc_active = CS.out.cruiseState.enabled
|
stock_cc_active = CS.out.cruiseState.enabled
|
||||||
|
pedal_cancel = self.CP.flags & GMFlags.PEDAL_LONG.value
|
||||||
|
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||||
|
pedal_cancel = pedal_cancel and CC.longActive
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(self.CP.flags & GMFlags.PEDAL_LONG.value) # Always cancel stock CC when using pedal interceptor
|
pedal_cancel
|
||||||
or (self.CP.flags & GMFlags.CC_LONG.value and not CC.enabled) # Cancel stock CC if OP is not active
|
or (self.CP.flags & GMFlags.CC_LONG.value and not CC.enabled) # Cancel stock CC if OP is not active
|
||||||
) and stock_cc_active:
|
) and stock_cc_active:
|
||||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||||
# Match 33 Hz cadence (every 3 frames) and align phase to the last seen checksum.
|
malibu_cancel_requested = True
|
||||||
if self.malibu_cancel_frame % 3 == 0:
|
|
||||||
can_sends.append(gmcan.create_buttons_malibu_cancel(
|
|
||||||
CanBus.POWERTRAIN, self.malibu_cancel_phase, CS.steering_button_prefix))
|
|
||||||
self.malibu_cancel_phase = (self.malibu_cancel_phase + 1) % 4
|
|
||||||
self.malibu_cancel_frame += 1
|
|
||||||
else:
|
else:
|
||||||
if (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
if (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
||||||
self.last_button_frame = self.frame
|
self.last_button_frame = self.frame
|
||||||
@@ -555,20 +579,24 @@ class CarController(CarControllerBase):
|
|||||||
self.cancel_counter = self.cancel_counter + 1 if CC.cruiseControl.cancel else 0
|
self.cancel_counter = self.cancel_counter + 1 if CC.cruiseControl.cancel else 0
|
||||||
|
|
||||||
# Stock longitudinal, integrated at camera
|
# Stock longitudinal, integrated at camera
|
||||||
if (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||||
|
if self.cancel_counter > CAMERA_CANCEL_DELAY_FRAMES:
|
||||||
|
malibu_cancel_requested = True
|
||||||
|
elif (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
||||||
if self.cancel_counter > CAMERA_CANCEL_DELAY_FRAMES:
|
if self.cancel_counter > CAMERA_CANCEL_DELAY_FRAMES:
|
||||||
self.last_button_frame = self.frame
|
self.last_button_frame = self.frame
|
||||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
if self.CP.carFingerprint in SDGM_CAR and self.CP.carFingerprint not in (volt_like | {CAR.CHEVROLET_BLAZER, CAR.CHEVROLET_MALIBU_SDGM, CAR.CHEVROLET_TRAVERSE}):
|
||||||
if self.malibu_cancel_frame % 3 == 0:
|
|
||||||
can_sends.append(gmcan.create_buttons_malibu_cancel(
|
|
||||||
CanBus.POWERTRAIN, self.malibu_cancel_phase, CS.steering_button_prefix))
|
|
||||||
self.malibu_cancel_phase = (self.malibu_cancel_phase + 1) % 4
|
|
||||||
self.malibu_cancel_frame += 1
|
|
||||||
elif self.CP.carFingerprint in SDGM_CAR and self.CP.carFingerprint not in (volt_like | {CAR.CHEVROLET_BLAZER, CAR.CHEVROLET_MALIBU_SDGM, CAR.CHEVROLET_TRAVERSE}):
|
|
||||||
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.POWERTRAIN, CS.buttons_counter, CruiseButtons.CANCEL))
|
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.POWERTRAIN, CS.buttons_counter, CruiseButtons.CANCEL))
|
||||||
else:
|
else:
|
||||||
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.CAMERA, CS.buttons_counter, CruiseButtons.CANCEL))
|
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.CAMERA, CS.buttons_counter, CruiseButtons.CANCEL))
|
||||||
|
|
||||||
|
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||||
|
# Malibu has no reliable rolling counter in 0x1E1; lock cancel send to each observed OEM 0x1E1 slot.
|
||||||
|
if malibu_cancel_requested and malibu_oem_button_slot:
|
||||||
|
can_sends.append(gmcan.create_buttons_malibu_cancel(
|
||||||
|
CanBus.POWERTRAIN, (self.malibu_cancel_phase + 1) % 4, CS.steering_button_prefix))
|
||||||
|
self.malibu_cancel_phase = (self.malibu_cancel_phase + 1) % 4
|
||||||
|
|
||||||
if self.CP.networkLocation == NetworkLocation.fwdCamera:
|
if self.CP.networkLocation == NetworkLocation.fwdCamera:
|
||||||
# Silence "Take Steering" alert sent by camera, forward PSCMStatus with HandsOffSWlDetectionStatus=1
|
# Silence "Take Steering" alert sent by camera, forward PSCMStatus with HandsOffSWlDetectionStatus=1
|
||||||
if self.frame % 10 == 0:
|
if self.frame % 10 == 0:
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ class CarState(CarStateBase):
|
|||||||
self.buttons_counter = 0
|
self.buttons_counter = 0
|
||||||
self.steering_button_checksum = 0
|
self.steering_button_checksum = 0
|
||||||
self.steering_button_prefix = 0x01
|
self.steering_button_prefix = 0x01
|
||||||
|
self.steering_button_ts_nanos = 0
|
||||||
|
|
||||||
self.prev_distance_button = 0
|
self.prev_distance_button = 0
|
||||||
self.distance_button = 0
|
self.distance_button = 0
|
||||||
|
|
||||||
self.single_pedal_mode = False
|
self.single_pedal_mode = False
|
||||||
self.pedal_steady = 0.
|
self.pedal_steady = 0.
|
||||||
|
self.ecm_cruise_control_ts_nanos = 0
|
||||||
|
self.accelerator_pedal2_ts_nanos = 0
|
||||||
|
|
||||||
def update(self, pt_cp, cam_cp, loopback_cp, frogpilot_toggles):
|
def update(self, pt_cp, cam_cp, loopback_cp, frogpilot_toggles):
|
||||||
ret = car.CarState.new_message()
|
ret = car.CarState.new_message()
|
||||||
@@ -54,6 +57,7 @@ class CarState(CarStateBase):
|
|||||||
self.distance_button = pt_cp.vl["ASCMSteeringButton"]["DistanceButton"]
|
self.distance_button = pt_cp.vl["ASCMSteeringButton"]["DistanceButton"]
|
||||||
self.buttons_counter = pt_cp.vl["ASCMSteeringButton"]["RollingCounter"]
|
self.buttons_counter = pt_cp.vl["ASCMSteeringButton"]["RollingCounter"]
|
||||||
self.steering_button_checksum = pt_cp.vl["ASCMSteeringButton"]["SteeringButtonChecksum"]
|
self.steering_button_checksum = pt_cp.vl["ASCMSteeringButton"]["SteeringButtonChecksum"]
|
||||||
|
self.steering_button_ts_nanos = pt_cp.ts_nanos["ASCMSteeringButton"]["ACCButtons"]
|
||||||
acc_always_one = pt_cp.vl["ASCMSteeringButton"]["ACCAlwaysOne"]
|
acc_always_one = pt_cp.vl["ASCMSteeringButton"]["ACCAlwaysOne"]
|
||||||
acc_hidden_bit = pt_cp.vl["ASCMSteeringButton"].get("ACCHiddenBit", 0)
|
acc_hidden_bit = pt_cp.vl["ASCMSteeringButton"].get("ACCHiddenBit", 0)
|
||||||
self.steering_button_prefix = (int(acc_always_one) & 1) | ((int(acc_hidden_bit) & 1) << 6)
|
self.steering_button_prefix = (int(acc_always_one) & 1) | ((int(acc_hidden_bit) & 1) << 6)
|
||||||
@@ -61,6 +65,7 @@ class CarState(CarStateBase):
|
|||||||
self.cruise_buttons = cam_cp.vl["ASCMSteeringButton"]["ACCButtons"]
|
self.cruise_buttons = cam_cp.vl["ASCMSteeringButton"]["ACCButtons"]
|
||||||
self.distance_button = cam_cp.vl["ASCMSteeringButton"]["DistanceButton"]
|
self.distance_button = cam_cp.vl["ASCMSteeringButton"]["DistanceButton"]
|
||||||
self.buttons_counter = cam_cp.vl["ASCMSteeringButton"]["RollingCounter"]
|
self.buttons_counter = cam_cp.vl["ASCMSteeringButton"]["RollingCounter"]
|
||||||
|
self.steering_button_ts_nanos = cam_cp.ts_nanos["ASCMSteeringButton"]["ACCButtons"]
|
||||||
self.pscm_status = copy.copy(pt_cp.vl["PSCMStatus"])
|
self.pscm_status = copy.copy(pt_cp.vl["PSCMStatus"])
|
||||||
# This is to avoid a fault where you engage while still moving backwards after shifting to D.
|
# This is to avoid a fault where you engage while still moving backwards after shifting to D.
|
||||||
# An Equinox has been seen with an unsupported status (3), so only check if either wheel is in reverse (2)
|
# An Equinox has been seen with an unsupported status (3), so only check if either wheel is in reverse (2)
|
||||||
@@ -89,6 +94,7 @@ class CarState(CarStateBase):
|
|||||||
)
|
)
|
||||||
ret.vEgoRaw = mean([ret.wheelSpeeds.fl, ret.wheelSpeeds.fr, ret.wheelSpeeds.rl, ret.wheelSpeeds.rr])
|
ret.vEgoRaw = mean([ret.wheelSpeeds.fl, ret.wheelSpeeds.fr, ret.wheelSpeeds.rl, ret.wheelSpeeds.rr])
|
||||||
ret.vEgo, ret.aEgo = self.update_speed_kf(ret.vEgoRaw)
|
ret.vEgo, ret.aEgo = self.update_speed_kf(ret.vEgoRaw)
|
||||||
|
ret.vEgoCluster = ret.vEgo * frogpilot_toggles.cluster_offset
|
||||||
# sample rear wheel speeds, standstill=True if ECM allows engagement with brake
|
# sample rear wheel speeds, standstill=True if ECM allows engagement with brake
|
||||||
ret.standstill = ret.wheelSpeeds.rl <= STANDSTILL_THRESHOLD and ret.wheelSpeeds.rr <= STANDSTILL_THRESHOLD
|
ret.standstill = ret.wheelSpeeds.rl <= STANDSTILL_THRESHOLD and ret.wheelSpeeds.rr <= STANDSTILL_THRESHOLD
|
||||||
|
|
||||||
@@ -97,7 +103,9 @@ class CarState(CarStateBase):
|
|||||||
else:
|
else:
|
||||||
ret.gearShifter = self.parse_gear_shifter(self.shifter_values.get(pt_cp.vl["ECMPRDNL2"]["PRNDL2"], None))
|
ret.gearShifter = self.parse_gear_shifter(self.shifter_values.get(pt_cp.vl["ECMPRDNL2"]["PRNDL2"], None))
|
||||||
|
|
||||||
if self.CP.flags & GMFlags.NO_ACCELERATOR_POS_MSG.value:
|
no_accel_pos = bool(self.CP.flags & GMFlags.NO_ACCELERATOR_POS_MSG.value)
|
||||||
|
|
||||||
|
if no_accel_pos:
|
||||||
if self.CP.carFingerprint in kaofui_state_cars:
|
if self.CP.carFingerprint in kaofui_state_cars:
|
||||||
ret.brake = pt_cp.vl.get("EBCMBrakePedalPosition", {}).get("BrakePedalPosition", 0) / 0xd0
|
ret.brake = pt_cp.vl.get("EBCMBrakePedalPosition", {}).get("BrakePedalPosition", 0) / 0xd0
|
||||||
else:
|
else:
|
||||||
@@ -107,20 +115,17 @@ class CarState(CarStateBase):
|
|||||||
ret.brake = pt_cp.vl.get("ECMAcceleratorPos", {}).get("BrakePedalPos", 0)
|
ret.brake = pt_cp.vl.get("ECMAcceleratorPos", {}).get("BrakePedalPos", 0)
|
||||||
else:
|
else:
|
||||||
ret.brake = pt_cp.vl["ECMAcceleratorPos"]["BrakePedalPos"]
|
ret.brake = pt_cp.vl["ECMAcceleratorPos"]["BrakePedalPos"]
|
||||||
if self.CP.carFingerprint == CAR.CHEVROLET_BLAZER:
|
|
||||||
# Blazer can miss light taps on analog threshold; include digital brake switch.
|
if self.CP.carFingerprint in {CAR.CHEVROLET_MALIBU_CC} or (self.CP.carFingerprint == CAR.CHEVROLET_BLAZER and not no_accel_pos):
|
||||||
ret.brakePressed = (pt_cp.vl["ECMEngineStatus"]["BrakePressed"] != 0) or (ret.brake >= 0.7)
|
|
||||||
elif self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_CC:
|
|
||||||
# Malibu CC: keep strict opgm behavior using BrakePedalPos >= 8.
|
|
||||||
ret.brakePressed = ret.brake >= 8
|
ret.brakePressed = ret.brake >= 8
|
||||||
elif (self.CP.flags & GMFlags.FORCE_BRAKE_C9.value) or (self.CP.networkLocation == NetworkLocation.fwdCamera):
|
elif (self.CP.flags & GMFlags.FORCE_BRAKE_C9.value) or ((self.CP.networkLocation == NetworkLocation.fwdCamera) and (self.CP.carFingerprint != CAR.CHEVROLET_BLAZER)):
|
||||||
ret.brakePressed = pt_cp.vl["ECMEngineStatus"]["BrakePressed"] != 0
|
ret.brakePressed = pt_cp.vl["ECMEngineStatus"]["BrakePressed"] != 0
|
||||||
else:
|
else:
|
||||||
# Some Volt 2016-17 have loose brake pedal push rod retainers which causes the ECM to believe
|
# Some Volt 2016-17 have loose brake pedal push rod retainers which causes the ECM to believe
|
||||||
# that the brake is being intermittently pressed without user interaction.
|
# that the brake is being intermittently pressed without user interaction.
|
||||||
# To avoid a cruise fault we need to use a conservative brake position threshold
|
# To avoid a cruise fault we need to use a conservative brake position threshold
|
||||||
# https://static.nhtsa.gov/odi/tsbs/2017/MC-10137629-9999.pdf
|
# https://static.nhtsa.gov/odi/tsbs/2017/MC-10137629-9999.pdf
|
||||||
analog_thresh = 0.15 if (self.CP.flags & GMFlags.NO_ACCELERATOR_POS_MSG.value) else 8
|
analog_thresh = 0.10 if no_accel_pos else 8
|
||||||
ret.brakePressed = ret.brake >= analog_thresh
|
ret.brakePressed = ret.brake >= analog_thresh
|
||||||
|
|
||||||
# Regen braking is braking
|
# Regen braking is braking
|
||||||
@@ -194,6 +199,8 @@ class CarState(CarStateBase):
|
|||||||
if self.CP.pcmCruise and self.CP.carFingerprint not in ASCM_INT:
|
if self.CP.pcmCruise and self.CP.carFingerprint not in ASCM_INT:
|
||||||
ret.cruiseState.nonAdaptive = cam_cp.vl["ASCMActiveCruiseControlStatus"]["ACCCruiseState"] not in (2, 3)
|
ret.cruiseState.nonAdaptive = cam_cp.vl["ASCMActiveCruiseControlStatus"]["ACCCruiseState"] not in (2, 3)
|
||||||
if self.CP.carFingerprint in CC_ONLY_CAR:
|
if self.CP.carFingerprint in CC_ONLY_CAR:
|
||||||
|
self.ecm_cruise_control_ts_nanos = pt_cp.ts_nanos["ECMCruiseControl"]["CruiseActive"]
|
||||||
|
self.accelerator_pedal2_ts_nanos = pt_cp.ts_nanos["AcceleratorPedal2"]["CruiseState"]
|
||||||
ret.accFaulted = False
|
ret.accFaulted = False
|
||||||
ret.cruiseState.speed = pt_cp.vl["ECMCruiseControl"]["CruiseSetSpeed"] * CV.KPH_TO_MS
|
ret.cruiseState.speed = pt_cp.vl["ECMCruiseControl"]["CruiseSetSpeed"] * CV.KPH_TO_MS
|
||||||
if self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
|
if self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
|
||||||
@@ -207,6 +214,9 @@ class CarState(CarStateBase):
|
|||||||
ret.cruiseState.enabled = pt_cp.vl["ECMCruiseControl"]["CruiseActive"] != 0
|
ret.cruiseState.enabled = pt_cp.vl["ECMCruiseControl"]["CruiseActive"] != 0
|
||||||
except:
|
except:
|
||||||
ret.cruiseState.enabled = cam_cp.vl["ASCMActiveCruiseControlStatus"]["ACCCmdActive"] != 0
|
ret.cruiseState.enabled = cam_cp.vl["ASCMActiveCruiseControlStatus"]["ACCCmdActive"] != 0
|
||||||
|
else:
|
||||||
|
self.ecm_cruise_control_ts_nanos = 0
|
||||||
|
self.accelerator_pedal2_ts_nanos = 0
|
||||||
|
|
||||||
if self.CP.enableBsm:
|
if self.CP.enableBsm:
|
||||||
if not sdgm_non_volt:
|
if not sdgm_non_volt:
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ FINGERPRINTS = {
|
|||||||
170: 8, 188: 8, 189: 7, 190: 6, 193: 8, 197: 8, 201: 8, 209: 7, 211: 2, 241: 6, 298: 8, 304: 1, 308: 4, 309: 8, 311: 8, 313: 8, 320: 3, 322: 7, 328: 1, 352: 5, 353: 3, 381: 6, 384: 4, 386: 8, 388: 8, 390: 7, 407: 7, 417: 7, 419: 1, 451: 8, 452: 8, 453: 6, 454: 8, 456: 8, 463: 3, 479: 3, 481: 7, 485: 8, 489: 8, 493: 8, 495: 4, 497: 8, 499: 3, 500: 6, 501: 8, 503: 1, 508: 8, 513: 6, 528: 5, 532: 6, 546: 7, 550: 8, 554: 3, 558: 8, 560: 8, 562: 8, 563: 5, 564: 5, 565: 5, 566: 6, 567: 5, 568: 1, 573: 1, 608: 8, 609: 6, 610: 6, 611: 6, 612: 8, 613: 8, 647: 3, 707: 8, 711: 6, 717: 5, 753: 5, 761: 7, 800: 6, 810: 8, 840: 5, 842: 5, 844: 8, 866: 4, 869: 4, 872: 1, 961: 8, 967: 4, 969: 8, 977: 8, 979: 7, 985: 5, 988: 6, 989: 8, 995: 7, 1001: 8, 1005: 6, 1009: 8, 1013: 3, 1017: 8, 1019: 2, 1020: 8, 1022: 1, 1105: 6, 1187: 4, 1217: 8, 1221: 5, 1223: 3, 1225: 7, 1227: 4, 1233: 8, 1243: 3, 1249: 8, 1257: 6, 1265: 8, 1275: 3, 1280: 4, 1300: 8, 1322: 6, 1328: 4, 1904: 7, 1905: 7, 1906: 7, 1907: 7, 1912: 7, 1913: 7, 1922: 7, 1927: 7
|
170: 8, 188: 8, 189: 7, 190: 6, 193: 8, 197: 8, 201: 8, 209: 7, 211: 2, 241: 6, 298: 8, 304: 1, 308: 4, 309: 8, 311: 8, 313: 8, 320: 3, 322: 7, 328: 1, 352: 5, 353: 3, 381: 6, 384: 4, 386: 8, 388: 8, 390: 7, 407: 7, 417: 7, 419: 1, 451: 8, 452: 8, 453: 6, 454: 8, 456: 8, 463: 3, 479: 3, 481: 7, 485: 8, 489: 8, 493: 8, 495: 4, 497: 8, 499: 3, 500: 6, 501: 8, 503: 1, 508: 8, 513: 6, 528: 5, 532: 6, 546: 7, 550: 8, 554: 3, 558: 8, 560: 8, 562: 8, 563: 5, 564: 5, 565: 5, 566: 6, 567: 5, 568: 1, 573: 1, 608: 8, 609: 6, 610: 6, 611: 6, 612: 8, 613: 8, 647: 3, 707: 8, 711: 6, 717: 5, 753: 5, 761: 7, 800: 6, 810: 8, 840: 5, 842: 5, 844: 8, 866: 4, 869: 4, 872: 1, 961: 8, 967: 4, 969: 8, 977: 8, 979: 7, 985: 5, 988: 6, 989: 8, 995: 7, 1001: 8, 1005: 6, 1009: 8, 1013: 3, 1017: 8, 1019: 2, 1020: 8, 1022: 1, 1105: 6, 1187: 4, 1217: 8, 1221: 5, 1223: 3, 1225: 7, 1227: 4, 1233: 8, 1243: 3, 1249: 8, 1257: 6, 1265: 8, 1275: 3, 1280: 4, 1300: 8, 1322: 6, 1328: 4, 1904: 7, 1905: 7, 1906: 7, 1907: 7, 1912: 7, 1913: 7, 1922: 7, 1927: 7
|
||||||
}],
|
}],
|
||||||
CAR.CHEVROLET_BOLT_CC_2019_2021: [
|
CAR.CHEVROLET_BOLT_CC_2019_2021: [
|
||||||
# Chevy Bolt EV 2019-2021
|
# Chevy Bolt EV 2018-2021
|
||||||
# Bolt Premier no ACC 2018 + Pedal
|
# Bolt Premier no ACC 2018 + Pedal
|
||||||
{
|
{
|
||||||
170: 8, 188: 8, 189: 7, 190: 6, 193: 8, 197: 8, 201: 8, 209: 7, 211: 2, 241: 6, 298: 8, 304: 1, 308: 4, 309: 8, 311: 8, 313: 8, 320: 3, 322: 7, 328: 1, 352: 5, 353: 3, 381: 6, 384: 4, 386: 8, 388: 8, 390: 7, 407: 7, 417: 7, 419: 1, 451: 8, 452: 8, 453: 6, 454: 8, 456: 8, 463: 3, 479: 3, 481: 7, 485: 8, 489: 8, 493: 8, 495: 4, 497: 8, 499: 3, 500: 6, 501: 8, 503: 2, 508: 8, 513: 6, 528: 5, 532: 6, 546: 7, 550: 8, 554: 3, 558: 8, 560: 8, 562: 8, 563: 5, 564: 5, 565: 5, 566: 6, 567: 5, 568: 1, 573: 1, 577: 8, 592: 8, 608: 8, 609: 6, 610: 6, 611: 6, 612: 8, 613: 8, 647: 3, 707: 8, 711: 6, 717: 5, 753: 5, 761: 7, 800: 6, 810: 8, 840: 5, 842: 5, 844: 8, 866: 4, 869: 4, 872: 1, 961: 8, 967: 4, 969: 8, 977: 8, 979: 7, 985: 5, 988: 6, 989: 8, 995: 7, 1001: 8, 1005: 6, 1009: 8, 1013: 3, 1017: 8, 1019: 2, 1020: 8, 1022: 1, 1105: 6, 1187: 4, 1217: 8, 1221: 5, 1223: 3, 1225: 7, 1227: 4, 1233: 8, 1243: 3, 1249: 8, 1257: 6, 1265: 8, 1275: 3, 1280: 4, 1300: 8, 1322: 6, 1328: 4, 1601: 8, 1616: 8, 1904: 7, 1905: 7, 1906: 7, 1907: 7, 1912: 7, 1913: 7, 1922: 7, 1927: 7, 2020: 8, 2023: 8, 2028: 8, 2031: 8
|
170: 8, 188: 8, 189: 7, 190: 6, 193: 8, 197: 8, 201: 8, 209: 7, 211: 2, 241: 6, 298: 8, 304: 1, 308: 4, 309: 8, 311: 8, 313: 8, 320: 3, 322: 7, 328: 1, 352: 5, 353: 3, 381: 6, 384: 4, 386: 8, 388: 8, 390: 7, 407: 7, 417: 7, 419: 1, 451: 8, 452: 8, 453: 6, 454: 8, 456: 8, 463: 3, 479: 3, 481: 7, 485: 8, 489: 8, 493: 8, 495: 4, 497: 8, 499: 3, 500: 6, 501: 8, 503: 2, 508: 8, 513: 6, 528: 5, 532: 6, 546: 7, 550: 8, 554: 3, 558: 8, 560: 8, 562: 8, 563: 5, 564: 5, 565: 5, 566: 6, 567: 5, 568: 1, 573: 1, 577: 8, 592: 8, 608: 8, 609: 6, 610: 6, 611: 6, 612: 8, 613: 8, 647: 3, 707: 8, 711: 6, 717: 5, 753: 5, 761: 7, 800: 6, 810: 8, 840: 5, 842: 5, 844: 8, 866: 4, 869: 4, 872: 1, 961: 8, 967: 4, 969: 8, 977: 8, 979: 7, 985: 5, 988: 6, 989: 8, 995: 7, 1001: 8, 1005: 6, 1009: 8, 1013: 3, 1017: 8, 1019: 2, 1020: 8, 1022: 1, 1105: 6, 1187: 4, 1217: 8, 1221: 5, 1223: 3, 1225: 7, 1227: 4, 1233: 8, 1243: 3, 1249: 8, 1257: 6, 1265: 8, 1275: 3, 1280: 4, 1300: 8, 1322: 6, 1328: 4, 1601: 8, 1616: 8, 1904: 7, 1905: 7, 1906: 7, 1907: 7, 1912: 7, 1913: 7, 1922: 7, 1927: 7, 2020: 8, 2023: 8, 2028: 8, 2031: 8
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from openpilot.selfdrive.car import make_can_msg
|
|||||||
from openpilot.selfdrive.car.gm.values import CAR, CruiseButtons, CanBus
|
from openpilot.selfdrive.car.gm.values import CAR, CruiseButtons, CanBus
|
||||||
|
|
||||||
MALIBU_BUTTON_TABLE = {
|
MALIBU_BUTTON_TABLE = {
|
||||||
0: [0x2FBC, 0x25DE, 0x15EE, 0x1FCC],
|
0: [0x10FF, 0x15EE, 0x1ADD, 0x1FCC],
|
||||||
1: [0x55AE, 0x5F8C, 0x6F7C, 0x659E],
|
1: [0x55AE, 0x5F8C, 0x6F7C, 0x659E],
|
||||||
4: [0x2ACD, 0x20EF, 0x1ADD, 0x10FF],
|
4: [0x2ACD, 0x20EF, 0x1ADD, 0x10FF],
|
||||||
5: [0x50BF, 0x5A9D, 0x60AF, 0x6A8D],
|
5: [0x60AF, 0x659E, 0x6A8D, 0x6F7C],
|
||||||
}
|
}
|
||||||
|
|
||||||
MALIBU_BUTTON_MAP = {
|
MALIBU_BUTTON_MAP = {
|
||||||
@@ -78,10 +78,10 @@ def create_buttons_malibu_cancel(bus, phase, prefix=0x41):
|
|||||||
data = bytearray(7)
|
data = bytearray(7)
|
||||||
data[3] = prefix & 0xFF
|
data[3] = prefix & 0xFF
|
||||||
data[4] = 0x00
|
data[4] = 0x00
|
||||||
cancel_bytes = (0x60, 0xAF, 0x65, 0x9E, 0x6A, 0x8D, 0x6F, 0x7C)
|
cancel_seq = MALIBU_BUTTON_TABLE[5]
|
||||||
idx = ((phase + 2) % 4) * 2
|
val = cancel_seq[phase % len(cancel_seq)]
|
||||||
data[5] = cancel_bytes[idx]
|
data[5] = (val >> 8) & 0xFF
|
||||||
data[6] = cancel_bytes[idx + 1]
|
data[6] = val & 0xFF
|
||||||
return make_can_msg(0x1e1, bytes(data), bus)
|
return make_can_msg(0x1e1, bytes(data), bus)
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +185,23 @@ def create_acc_dashboard_command(packer, bus, enabled, target_speed_kph, hud_con
|
|||||||
|
|
||||||
return packer.make_can_msg("ASCMActiveCruiseControlStatus", bus, values)
|
return packer.make_can_msg("ASCMActiveCruiseControlStatus", bus, values)
|
||||||
|
|
||||||
|
def create_ecm_cruise_control_command(packer, bus, enabled, target_speed_kph):
|
||||||
|
dat = bytearray(8)
|
||||||
|
dat[0] = 0x01
|
||||||
|
# Match observed stock shape on non-ACC CC paths: byte4 is usually 0x00
|
||||||
|
# (with occasional 0x80 from stock state transitions). Keep this spoofed
|
||||||
|
# path at 0x00 to avoid plausibility mismatch on non-speed bits.
|
||||||
|
dat[4] = 0x00
|
||||||
|
|
||||||
|
set_speed_raw = 0
|
||||||
|
if enabled:
|
||||||
|
set_speed_raw = int(round(max(0., target_speed_kph) / 0.0625))
|
||||||
|
set_speed_raw = max(0, min(set_speed_raw, 0x0FFF))
|
||||||
|
|
||||||
|
dat[2] = (set_speed_raw >> 8) & 0xFF
|
||||||
|
dat[3] = set_speed_raw & 0xFF
|
||||||
|
return make_can_msg(0x3D1, bytes(dat), bus)
|
||||||
|
|
||||||
|
|
||||||
def create_adas_time_status(bus, tt, idx):
|
def create_adas_time_status(bus, tt, idx):
|
||||||
dat = [(tt >> 20) & 0xff, (tt >> 12) & 0xff, (tt >> 4) & 0xff,
|
dat = [(tt >> 20) & 0xff, (tt >> 12) & 0xff, (tt >> 4) & 0xff,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
from cereal import car, custom
|
from cereal import car, custom
|
||||||
from math import fabs, exp
|
from math import fabs, exp
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from panda import Panda
|
from panda import ALTERNATIVE_EXPERIENCE, Panda
|
||||||
|
|
||||||
from openpilot.common.conversions import Conversions as CV
|
from openpilot.common.conversions import Conversions as CV
|
||||||
|
from openpilot.common.numpy_fast import interp
|
||||||
from openpilot.selfdrive.car import create_button_events, get_safety_config
|
from openpilot.selfdrive.car import create_button_events, get_safety_config
|
||||||
from openpilot.selfdrive.car.gm.radar_interface import RADAR_HEADER_MSG
|
from openpilot.selfdrive.car.gm.radar_interface import RADAR_HEADER_MSG
|
||||||
from openpilot.selfdrive.car.gm.values import CAR, CruiseButtons, CarControllerParams, EV_CAR, CAMERA_ACC_CAR, CanBus, GMFlags, CC_ONLY_CAR, SDGM_CAR, ASCM_INT, set_red_panda_canbus
|
from openpilot.selfdrive.car.gm.values import CAR, CruiseButtons, CarControllerParams, EV_CAR, CAMERA_ACC_CAR, CanBus, GMFlags, CC_ONLY_CAR, SDGM_CAR, ASCM_INT, CC_REGEN_PADDLE_CAR, set_red_panda_canbus
|
||||||
from openpilot.selfdrive.car.interfaces import CarInterfaceBase, TorqueFromLateralAccelCallbackType, FRICTION_THRESHOLD, LateralAccelFromTorqueCallbackType, get_friction_threshold
|
from openpilot.selfdrive.car.interfaces import CarInterfaceBase, TorqueFromLateralAccelCallbackType, FRICTION_THRESHOLD, LateralAccelFromTorqueCallbackType, get_friction_threshold
|
||||||
from openpilot.selfdrive.controls.lib.drive_helpers import get_friction
|
from openpilot.selfdrive.controls.lib.drive_helpers import get_friction
|
||||||
|
|
||||||
@@ -39,6 +40,21 @@ VOLT_LIKE_CARS = {
|
|||||||
CAR.CHEVROLET_MALIBU_HYBRID_CC,
|
CAR.CHEVROLET_MALIBU_HYBRID_CC,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOLT_PEDAL_LONG_CARS = {
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2017,
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||||
|
CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL,
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2022_2023,
|
||||||
|
CAR.CHEVROLET_MALIBU_HYBRID_CC,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cancel-to-personality mapping target: gen1 Bolt pedal-long paths only.
|
||||||
|
BOLT_GEN1_CANCEL_PERSONALITY_CARS = {
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2017,
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||||
|
}
|
||||||
|
CANCEL_REMAP_DISTANCE_CARS = BOLT_GEN1_CANCEL_PERSONALITY_CARS
|
||||||
|
|
||||||
NON_LINEAR_TORQUE_PARAMS = {
|
NON_LINEAR_TORQUE_PARAMS = {
|
||||||
CAR.CHEVROLET_BOLT_ACC_2022_2023: {
|
CAR.CHEVROLET_BOLT_ACC_2022_2023: {
|
||||||
"left": [2.6531724862969748, 1.1, 0.1919764879840985, 0.0],
|
"left": [2.6531724862969748, 1.1, 0.1919764879840985, 0.0],
|
||||||
@@ -57,8 +73,8 @@ NON_LINEAR_TORQUE_PARAMS = {
|
|||||||
"right": [2.0, 1.0, 0.205, 0.0],
|
"right": [2.0, 1.0, 0.205, 0.0],
|
||||||
},
|
},
|
||||||
CAR.CHEVROLET_BOLT_CC_2017: {
|
CAR.CHEVROLET_BOLT_CC_2017: {
|
||||||
"left": [2.15, 1.0, 0.21, 0.0],
|
"left": [2.15, 1.00, 0.129, 0.0],
|
||||||
"right": [2.15, 1.0, 0.21, 0.0],
|
"right": [2.15, 1.00, 0.145, 0.0],
|
||||||
},
|
},
|
||||||
CAR.GMC_ACADIA: {
|
CAR.GMC_ACADIA: {
|
||||||
"left": [4.78003305, 1.0, 0.3122, 0.05591772],
|
"left": [4.78003305, 1.0, 0.3122, 0.05591772],
|
||||||
@@ -78,6 +94,18 @@ class CarInterface(CarInterfaceBase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_pid_accel_limits(CP, current_speed, cruise_speed):
|
def get_pid_accel_limits(CP, current_speed, cruise_speed):
|
||||||
|
if CP.enableGasInterceptor and bool(CP.flags & GMFlags.PEDAL_LONG.value):
|
||||||
|
if CP.carFingerprint in BOLT_PEDAL_LONG_CARS:
|
||||||
|
accel_min = interp(current_speed, [0.0, 1.5, 4.0, 8.0, 15.0, 30.0],
|
||||||
|
[-0.93, -1.28, -1.98, -2.58, -2.86, -2.95])
|
||||||
|
accel_max = interp(current_speed, [0.0, 1.5, 4.0, 8.0, 15.0],
|
||||||
|
[0.54, 0.74, 1.03, 1.46, CarControllerParams.ACCEL_MAX])
|
||||||
|
else:
|
||||||
|
accel_min = interp(current_speed, [0.0, 1.5, 4.0, 8.0, 15.0, 30.0],
|
||||||
|
[-0.95, -1.3, -1.85, -2.3, -2.6, -2.8])
|
||||||
|
accel_max = interp(current_speed, [0.0, 1.5, 4.0, 8.0, 15.0],
|
||||||
|
[0.60, 0.85, 1.15, 1.60, CarControllerParams.ACCEL_MAX])
|
||||||
|
return accel_min, accel_max
|
||||||
return CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX
|
return CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX
|
||||||
|
|
||||||
# Determined by iteratively plotting and minimizing error for f(angle, speed) = steer.
|
# Determined by iteratively plotting and minimizing error for f(angle, speed) = steer.
|
||||||
@@ -250,9 +278,9 @@ class CarInterface(CarInterfaceBase):
|
|||||||
ret.minEnableSpeed = -1. # engage speed is decided by pcm
|
ret.minEnableSpeed = -1. # engage speed is decided by pcm
|
||||||
ret.minSteerSpeed = 7 * CV.MPH_TO_MS
|
ret.minSteerSpeed = 7 * CV.MPH_TO_MS
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_HW_SDGM
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_HW_SDGM
|
||||||
# Use C9 brake bit on Blazer and SDGM variants that lack 0xBE (ECMAcceleratorPos),
|
# Use C9 brake bit on SDGM variants that lack 0xBE (ECMAcceleratorPos),
|
||||||
# so panda brake_pressed source matches carstate on light taps.
|
# so panda brake_pressed source matches carstate.
|
||||||
if candidate == CAR.CHEVROLET_BLAZER or ACCELERATOR_POS_MSG not in fingerprint.get(CanBus.POWERTRAIN, {}):
|
if ACCELERATOR_POS_MSG not in fingerprint.get(CanBus.POWERTRAIN, {}):
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_FORCE_BRAKE_C9
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_FORCE_BRAKE_C9
|
||||||
ret.flags |= GMFlags.FORCE_BRAKE_C9.value
|
ret.flags |= GMFlags.FORCE_BRAKE_C9.value
|
||||||
|
|
||||||
@@ -273,8 +301,9 @@ class CarInterface(CarInterfaceBase):
|
|||||||
if is_bolt_2022_2023_pedal:
|
if is_bolt_2022_2023_pedal:
|
||||||
ret.experimentalLongitudinalAvailable = False
|
ret.experimentalLongitudinalAvailable = False
|
||||||
ret.pcmCruise = False
|
ret.pcmCruise = False
|
||||||
elif candidate in ASCM_INT and has_sascm(fingerprint):
|
elif candidate in ASCM_INT:
|
||||||
ret.experimentalLongitudinalAvailable = True
|
# kaofui parity: ASCM_INT cars require SASCM for experimental long
|
||||||
|
ret.experimentalLongitudinalAvailable = candidate not in (CC_ONLY_CAR | ASCM_INT | SDGM_CAR) or has_sascm(fingerprint)
|
||||||
ret.networkLocation = NetworkLocation.fwdCamera
|
ret.networkLocation = NetworkLocation.fwdCamera
|
||||||
ret.radarUnavailable = 0x460 not in fingerprint.get(CanBus.OBSTACLE, {})
|
ret.radarUnavailable = 0x460 not in fingerprint.get(CanBus.OBSTACLE, {})
|
||||||
ret.pcmCruise = True
|
ret.pcmCruise = True
|
||||||
@@ -381,7 +410,20 @@ class CarInterface(CarInterfaceBase):
|
|||||||
ret.lateralTuning.torque.kd = 0.93
|
ret.lateralTuning.torque.kd = 0.93
|
||||||
ret.lateralTuning.torque.kfDEPRECATED = 0.02
|
ret.lateralTuning.torque.kfDEPRECATED = 0.02
|
||||||
|
|
||||||
|
if candidate in (CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||||
|
CAR.CHEVROLET_BOLT_ACC_2022_2023,
|
||||||
|
CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL,
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2022_2023):
|
||||||
|
# Apply 2019+/2022 generation-specific FF and Ki/Kd tweaks.
|
||||||
|
ret.lateralTuning.torque.ki *= 1.07
|
||||||
|
ret.lateralTuning.torque.kd *= 0.93
|
||||||
|
ret.lateralTuning.torque.kfDEPRECATED *= 0.75
|
||||||
|
|
||||||
if candidate == CAR.CHEVROLET_BOLT_CC_2017:
|
if candidate == CAR.CHEVROLET_BOLT_CC_2017:
|
||||||
|
ret.lateralTuning.torque.kp *= 1.0
|
||||||
|
ret.lateralTuning.torque.ki *= 0.9
|
||||||
|
ret.lateralTuning.torque.kd *= 0.9
|
||||||
|
ret.lateralTuning.torque.kfDEPRECATED = 0.026
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_BOLT_2017
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_BOLT_2017
|
||||||
|
|
||||||
if ret.enableGasInterceptor:
|
if ret.enableGasInterceptor:
|
||||||
@@ -398,8 +440,6 @@ class CarInterface(CarInterfaceBase):
|
|||||||
# On the Bolt, the ECM and camera independently check that you are either above 5 kph or at a stop
|
# On the Bolt, the ECM and camera independently check that you are either above 5 kph or at a stop
|
||||||
# with foot on brake to allow engagement, but this platform only has that check in the camera.
|
# with foot on brake to allow engagement, but this platform only has that check in the camera.
|
||||||
# TODO: check if this is split by EV/ICE with more platforms in the future
|
# TODO: check if this is split by EV/ICE with more platforms in the future
|
||||||
if ret.openpilotLongitudinalControl:
|
|
||||||
ret.minEnableSpeed = -1.
|
|
||||||
CarInterfaceBase.configure_torque_tune(candidate, ret.lateralTuning)
|
CarInterfaceBase.configure_torque_tune(candidate, ret.lateralTuning)
|
||||||
|
|
||||||
elif candidate in (CAR.CHEVROLET_EQUINOX, CAR.CHEVROLET_EQUINOX_CC):
|
elif candidate in (CAR.CHEVROLET_EQUINOX, CAR.CHEVROLET_EQUINOX_CC):
|
||||||
@@ -470,18 +510,27 @@ class CarInterface(CarInterfaceBase):
|
|||||||
ret.flags |= GMFlags.PEDAL_LONG.value
|
ret.flags |= GMFlags.PEDAL_LONG.value
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_PEDAL_LONG
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_PEDAL_LONG
|
||||||
# Note: Low speed, stop and go not tested. Should be fairly smooth on highway
|
# Note: Low speed, stop and go not tested. Should be fairly smooth on highway
|
||||||
if candidate in (CAR.CHEVROLET_MALIBU_CC, CAR.CHEVROLET_MALIBU_HYBRID_CC):
|
if candidate == CAR.CHEVROLET_MALIBU_CC:
|
||||||
|
ret.longitudinalTuning.kpBP = [0.0, 5.0, 35.0]
|
||||||
|
ret.longitudinalTuning.kpV = [0.06, 0.05, 0.04]
|
||||||
ret.longitudinalTuning.kiBP = [0.0, 5., 35.]
|
ret.longitudinalTuning.kiBP = [0.0, 5., 35.]
|
||||||
ret.longitudinalTuning.kiV = [0.0, 0.35, 0.5]
|
ret.longitudinalTuning.kiV = [0.0, 0.30, 0.45]
|
||||||
ret.longitudinalTuning.kfDEPRECATED = 0.15
|
ret.longitudinalTuning.kfDEPRECATED = 0.15
|
||||||
ret.stoppingDecelRate = 0.8
|
ret.stoppingDecelRate = 0.8
|
||||||
ret.minEnableSpeed = -1
|
ret.minEnableSpeed = -1
|
||||||
ret.pcmCruise = False
|
ret.pcmCruise = False
|
||||||
ret.openpilotLongitudinalControl = not frogpilot_toggles.disable_openpilot_long
|
ret.openpilotLongitudinalControl = not frogpilot_toggles.disable_openpilot_long
|
||||||
else:
|
else:
|
||||||
ret.longitudinalTuning.kiBP = [0., 3., 6., 35.]
|
ret.longitudinalTuning.kpBP = [0.0, 5.0, 15.0, 35.0]
|
||||||
ret.longitudinalTuning.kiV = [0.125, 0.175, 0.225, 0.33]
|
ret.longitudinalTuning.kpV = [0.09, 0.08, 0.06, 0.045]
|
||||||
ret.longitudinalTuning.kfDEPRECATED = 0.25
|
ret.longitudinalTuning.kiBP = [0.0, 3.0, 6.0, 35.0]
|
||||||
|
ret.longitudinalTuning.kiV = [0.09, 0.13, 0.19, 0.28]
|
||||||
|
if candidate in BOLT_PEDAL_LONG_CARS:
|
||||||
|
ret.longitudinalTuning.kpV = [0.095, 0.085, 0.065, 0.050]
|
||||||
|
ret.longitudinalTuning.kiV = [0.07, 0.10, 0.15, 0.24]
|
||||||
|
ret.longitudinalTuning.kfDEPRECATED = 0.20
|
||||||
|
else:
|
||||||
|
ret.longitudinalTuning.kfDEPRECATED = 0.25
|
||||||
ret.stoppingDecelRate = 0.8
|
ret.stoppingDecelRate = 0.8
|
||||||
else: # Pedal used for SNG, ACC for longitudinal control otherwise
|
else: # Pedal used for SNG, ACC for longitudinal control otherwise
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_HW_CAM_LONG
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_HW_CAM_LONG
|
||||||
@@ -492,9 +541,15 @@ class CarInterface(CarInterfaceBase):
|
|||||||
if ret.enableGasInterceptor and candidate == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
if ret.enableGasInterceptor and candidate == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||||
ret.flags |= GMFlags.PEDAL_LONG.value
|
ret.flags |= GMFlags.PEDAL_LONG.value
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_PEDAL_LONG
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_PEDAL_LONG
|
||||||
ret.longitudinalTuning.kiBP = [0.0, 5., 35.]
|
# Keep Malibu Hybrid pedal on the same longitudinal tune as Bolt pedal cars.
|
||||||
ret.longitudinalTuning.kiV = [0.0, 0.18, 0.25]
|
ret.longitudinalTuning.kpBP = [0.0, 5.0, 15.0, 35.0]
|
||||||
ret.longitudinalTuning.kfDEPRECATED = 0.15
|
ret.longitudinalTuning.kpV = [0.095, 0.085, 0.065, 0.050]
|
||||||
|
# Reuse Bolt pedal cancel-allow safety path so 0x1E1 cancel isn't blocked
|
||||||
|
# when stock cruise state differs from pedal-long control state.
|
||||||
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_BOLT_2022_PEDAL
|
||||||
|
ret.longitudinalTuning.kiBP = [0.0, 3.0, 6.0, 35.0]
|
||||||
|
ret.longitudinalTuning.kiV = [0.07, 0.10, 0.15, 0.24]
|
||||||
|
ret.longitudinalTuning.kfDEPRECATED = 0.20
|
||||||
ret.stoppingDecelRate = 0.8
|
ret.stoppingDecelRate = 0.8
|
||||||
ret.minEnableSpeed = -1
|
ret.minEnableSpeed = -1
|
||||||
ret.pcmCruise = False
|
ret.pcmCruise = False
|
||||||
@@ -528,30 +583,113 @@ class CarInterface(CarInterfaceBase):
|
|||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_NO_ACC
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_NO_ACC
|
||||||
|
|
||||||
# Exception for flashed cars, or cars whose camera was removed
|
# Exception for flashed cars, or cars whose camera was removed
|
||||||
if (ret.networkLocation == NetworkLocation.fwdCamera or candidate in CC_ONLY_CAR) and CAM_MSG not in fingerprint.get(CanBus.CAMERA, {}) and not candidate in (SDGM_CAR | ASCM_INT):
|
if (ret.networkLocation == NetworkLocation.fwdCamera or candidate in CC_ONLY_CAR) and CAM_MSG not in fingerprint.get(CanBus.CAMERA, {}) and candidate not in SDGM_CAR:
|
||||||
ret.flags |= GMFlags.NO_CAMERA.value
|
ret.flags |= GMFlags.NO_CAMERA.value
|
||||||
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_NO_CAMERA
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_NO_CAMERA
|
||||||
|
|
||||||
if ACCELERATOR_POS_MSG not in fingerprint.get(CanBus.POWERTRAIN, {}):
|
if ACCELERATOR_POS_MSG not in fingerprint.get(CanBus.POWERTRAIN, {}):
|
||||||
ret.flags |= GMFlags.NO_ACCELERATOR_POS_MSG.value
|
ret.flags |= GMFlags.NO_ACCELERATOR_POS_MSG.value
|
||||||
|
|
||||||
|
use_panda_3d1_sched = (
|
||||||
|
ret.openpilotLongitudinalControl and
|
||||||
|
ret.enableGasInterceptor and
|
||||||
|
bool(ret.flags & GMFlags.PEDAL_LONG.value) and
|
||||||
|
candidate in CC_ONLY_CAR and
|
||||||
|
candidate != CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL
|
||||||
|
)
|
||||||
|
if use_panda_3d1_sched:
|
||||||
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_PANDA_3D1_SCHED
|
||||||
|
|
||||||
|
use_panda_paddle_sched = (
|
||||||
|
ret.openpilotLongitudinalControl and
|
||||||
|
ret.enableGasInterceptor and
|
||||||
|
bool(ret.flags & GMFlags.PEDAL_LONG.value) and
|
||||||
|
candidate in CC_REGEN_PADDLE_CAR
|
||||||
|
)
|
||||||
|
if use_panda_paddle_sched:
|
||||||
|
gm_safety_cfg.safetyParam |= Panda.FLAG_GM_PANDA_PADDLE_SCHED
|
||||||
|
|
||||||
|
remap_cancel_to_distance = (
|
||||||
|
getattr(frogpilot_toggles, "remap_cancel_to_distance", False) and
|
||||||
|
ret.openpilotLongitudinalControl and
|
||||||
|
bool(ret.flags & GMFlags.PEDAL_LONG.value) and
|
||||||
|
candidate in CANCEL_REMAP_DISTANCE_CARS
|
||||||
|
)
|
||||||
|
malibu_cancel_passthrough = (
|
||||||
|
candidate == CAR.CHEVROLET_MALIBU_HYBRID_CC and
|
||||||
|
ret.openpilotLongitudinalControl and
|
||||||
|
bool(ret.flags & GMFlags.PEDAL_LONG.value)
|
||||||
|
)
|
||||||
|
if remap_cancel_to_distance or malibu_cancel_passthrough:
|
||||||
|
ret.alternativeExperience |= ALTERNATIVE_EXPERIENCE.GM_REMAP_CANCEL_TO_DISTANCE
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
# returns a car.CarState
|
# returns a car.CarState
|
||||||
def _update(self, c, frogpilot_toggles):
|
def _update(self, c, frogpilot_toggles):
|
||||||
ret, fp_ret = self.CS.update(self.cp, self.cp_cam, self.cp_loopback, frogpilot_toggles)
|
ret, fp_ret = self.CS.update(self.cp, self.cp_cam, self.cp_loopback, frogpilot_toggles)
|
||||||
|
|
||||||
|
remap_cancel_to_distance = bool(self.CP.alternativeExperience & ALTERNATIVE_EXPERIENCE.GM_REMAP_CANCEL_TO_DISTANCE)
|
||||||
|
malibu_cancel_passthrough = (
|
||||||
|
remap_cancel_to_distance and
|
||||||
|
self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC and
|
||||||
|
self.CP.openpilotLongitudinalControl
|
||||||
|
)
|
||||||
|
bolt_cancel_personality = (
|
||||||
|
remap_cancel_to_distance and
|
||||||
|
self.CP.carFingerprint in BOLT_GEN1_CANCEL_PERSONALITY_CARS and
|
||||||
|
self.CP.openpilotLongitudinalControl and
|
||||||
|
bool(self.CP.flags & GMFlags.PEDAL_LONG.value)
|
||||||
|
)
|
||||||
|
|
||||||
# Don't add event if transitioning from INIT, unless it's to an actual button
|
# Don't add event if transitioning from INIT, unless it's to an actual button
|
||||||
|
cruise_button_map = BUTTONS_DICT
|
||||||
|
if malibu_cancel_passthrough:
|
||||||
|
# Keep pedal-long cancel presses from creating a buttonCancel disengage event.
|
||||||
|
cruise_button_map = {k: v for k, v in BUTTONS_DICT.items() if k != CruiseButtons.CANCEL}
|
||||||
|
elif bolt_cancel_personality:
|
||||||
|
# Keep pedal-long cancel presses from creating a buttonCancel disengage event.
|
||||||
|
cruise_button_map = {k: v for k, v in BUTTONS_DICT.items() if k != CruiseButtons.CANCEL}
|
||||||
|
|
||||||
|
cruise_events = create_button_events(self.CS.cruise_buttons, self.CS.prev_cruise_buttons, cruise_button_map,
|
||||||
|
unpressed_btn=CruiseButtons.UNPRESS)
|
||||||
|
cancel_gap_events = []
|
||||||
|
if bolt_cancel_personality and self.CS.cruise_buttons != self.CS.prev_cruise_buttons:
|
||||||
|
# Gen1 Bolt pedal-long: treat CANCEL edges as a distance-style button for personality cycling.
|
||||||
|
if self.CS.prev_cruise_buttons == CruiseButtons.CANCEL:
|
||||||
|
cancel_gap_events.append(car.CarState.ButtonEvent(pressed=False, type=ButtonType.gapAdjustCruise))
|
||||||
|
if self.CS.cruise_buttons == CruiseButtons.CANCEL:
|
||||||
|
cancel_gap_events.append(car.CarState.ButtonEvent(pressed=True, type=ButtonType.gapAdjustCruise))
|
||||||
|
|
||||||
|
# Malibu pedal-long cancel can alias wheel-button bits on 0x1E1; ignore those side effects.
|
||||||
|
suppress_malibu_side_buttons = malibu_cancel_passthrough and (
|
||||||
|
self.CS.cruise_buttons in (CruiseButtons.CANCEL, CruiseButtons.MAIN) or
|
||||||
|
self.CS.prev_cruise_buttons in (CruiseButtons.CANCEL, CruiseButtons.MAIN)
|
||||||
|
)
|
||||||
|
suppress_bolt_cancel_lkas = bolt_cancel_personality and (
|
||||||
|
self.CS.cruise_buttons == CruiseButtons.CANCEL or
|
||||||
|
self.CS.prev_cruise_buttons == CruiseButtons.CANCEL
|
||||||
|
)
|
||||||
|
distance_events = [] if suppress_malibu_side_buttons else create_button_events(
|
||||||
|
self.CS.distance_button, self.CS.prev_distance_button, {1: ButtonType.gapAdjustCruise}
|
||||||
|
)
|
||||||
|
lkas_events = [] if (suppress_malibu_side_buttons or suppress_bolt_cancel_lkas) else create_button_events(
|
||||||
|
self.CS.lkas_enabled,
|
||||||
|
self.CS.lkas_previously_enabled, {1: FrogPilotButtonType.lkas}
|
||||||
|
)
|
||||||
|
|
||||||
if self.CS.cruise_buttons != CruiseButtons.UNPRESS or self.CS.prev_cruise_buttons != CruiseButtons.INIT:
|
if self.CS.cruise_buttons != CruiseButtons.UNPRESS or self.CS.prev_cruise_buttons != CruiseButtons.INIT:
|
||||||
ret.buttonEvents = [
|
ret.buttonEvents = [
|
||||||
*create_button_events(self.CS.cruise_buttons, self.CS.prev_cruise_buttons, BUTTONS_DICT,
|
*cruise_events,
|
||||||
unpressed_btn=CruiseButtons.UNPRESS),
|
*cancel_gap_events,
|
||||||
*create_button_events(self.CS.distance_button, self.CS.prev_distance_button,
|
*distance_events,
|
||||||
{1: ButtonType.gapAdjustCruise}),
|
*lkas_events,
|
||||||
*create_button_events(self.CS.lkas_enabled, self.CS.lkas_previously_enabled,
|
|
||||||
{1: FrogPilotButtonType.lkas}),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if bolt_cancel_personality and self.CS.cruise_buttons == CruiseButtons.CANCEL:
|
||||||
|
# Feed long-press logic (traffic mode, etc.) as if distance button is being held.
|
||||||
|
fp_ret.distancePressed = True
|
||||||
|
|
||||||
# The ECM allows enabling on falling edge of set, but only rising edge of resume
|
# The ECM allows enabling on falling edge of set, but only rising edge of resume
|
||||||
events = self.create_common_events(ret, extra_gears=[GearShifter.sport, GearShifter.low,
|
events = self.create_common_events(ret, extra_gears=[GearShifter.sport, GearShifter.low,
|
||||||
GearShifter.eco, GearShifter.manumatic],
|
GearShifter.eco, GearShifter.manumatic],
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ class CAR(Platforms):
|
|||||||
CHEVROLET_VOLT.specs,
|
CHEVROLET_VOLT.specs,
|
||||||
)
|
)
|
||||||
CHEVROLET_BOLT_CC_2019_2021 = GMPlatformConfig(
|
CHEVROLET_BOLT_CC_2019_2021 = GMPlatformConfig(
|
||||||
[GMCarDocs("Chevrolet Bolt EV 2019-2021 - No-ACC")],
|
[GMCarDocs("Chevrolet Bolt EV 2018-2021 - No-ACC")],
|
||||||
CHEVROLET_BOLT_ACC_2022_2023.specs,
|
CHEVROLET_BOLT_ACC_2022_2023.specs,
|
||||||
)
|
)
|
||||||
CHEVROLET_BOLT_ACC_2022_2023_PEDAL = GMPlatformConfig(
|
CHEVROLET_BOLT_ACC_2022_2023_PEDAL = GMPlatformConfig(
|
||||||
@@ -285,13 +285,12 @@ class CAR(Platforms):
|
|||||||
CHEVROLET_BOLT_CC_2022_2023 = GMPlatformConfig(
|
CHEVROLET_BOLT_CC_2022_2023 = GMPlatformConfig(
|
||||||
[
|
[
|
||||||
GMCarDocs("Chevrolet Bolt EV 2022-2023 - No-ACC"),
|
GMCarDocs("Chevrolet Bolt EV 2022-2023 - No-ACC"),
|
||||||
GMCarDocs("Chevrolet Bolt EV 2022-2023 - No-ACC"),
|
|
||||||
],
|
],
|
||||||
CHEVROLET_BOLT_ACC_2022_2023.specs,
|
CHEVROLET_BOLT_ACC_2022_2023.specs,
|
||||||
)
|
)
|
||||||
CHEVROLET_BOLT_CC_2017 = GMPlatformConfig(
|
CHEVROLET_BOLT_CC_2017 = GMPlatformConfig(
|
||||||
[GMCarDocs("Chevrolet Bolt EV 2017 - No-ACC")],
|
[GMCarDocs("Chevrolet Bolt EV 2017 - No-ACC")],
|
||||||
CHEVROLET_BOLT_ACC_2022_2023.specs,
|
GMCarSpecs(mass=1669, wheelbase=2.63779, steerRatio=16.8, centerToFrontRatio=0.4, tireStiffnessFactor=1.0),
|
||||||
)
|
)
|
||||||
CHEVROLET_EQUINOX_CC = GMPlatformConfig(
|
CHEVROLET_EQUINOX_CC = GMPlatformConfig(
|
||||||
[GMCarDocs("Chevrolet Equinox 2019-22 - No-ACC")],
|
[GMCarDocs("Chevrolet Equinox 2019-22 - No-ACC")],
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ EventName = car.CarEvent.EventName
|
|||||||
MAX_CTRL_SPEED = (V_CRUISE_MAX + 4) * CV.KPH_TO_MS
|
MAX_CTRL_SPEED = (V_CRUISE_MAX + 4) * CV.KPH_TO_MS
|
||||||
ACCEL_MAX = 2.0
|
ACCEL_MAX = 2.0
|
||||||
ACCEL_MIN = -3.5
|
ACCEL_MIN = -3.5
|
||||||
FRICTION_THRESHOLD = 0.12
|
FRICTION_THRESHOLD = 0.16
|
||||||
|
|
||||||
def get_friction_threshold(v_ego):
|
def get_friction_threshold(v_ego):
|
||||||
# Interpolate friction threshold
|
# Interpolate friction threshold
|
||||||
from openpilot.common.numpy_fast import interp
|
from openpilot.common.numpy_fast import interp
|
||||||
return interp(v_ego, [1 * CV.MPH_TO_MS, 75 * CV.MPH_TO_MS], [0.12, 0.3])
|
return interp(v_ego, [1 * CV.MPH_TO_MS, 20 * CV.MPH_TO_MS, 75 * CV.MPH_TO_MS], [0.16, 0.19, 0.27])
|
||||||
|
|
||||||
TORQUE_PARAMS_PATH = os.path.join(BASEDIR, 'selfdrive/car/torque_data/params.toml')
|
TORQUE_PARAMS_PATH = os.path.join(BASEDIR, 'selfdrive/car/torque_data/params.toml')
|
||||||
TORQUE_OVERRIDE_PATH = os.path.join(BASEDIR, 'selfdrive/car/torque_data/override.toml')
|
TORQUE_OVERRIDE_PATH = os.path.join(BASEDIR, 'selfdrive/car/torque_data/override.toml')
|
||||||
@@ -348,7 +348,8 @@ class CarInterfaceBase(ABC):
|
|||||||
|
|
||||||
# FrogPilot variables
|
# FrogPilot variables
|
||||||
fp_ret.alwaysOnLateralAllowed = self.always_on_lateral_allowed
|
fp_ret.alwaysOnLateralAllowed = self.always_on_lateral_allowed
|
||||||
fp_ret.distancePressed = bool(self.CS.distance_button)
|
# Preserve any car-interface override (e.g. remapped buttons) and include native distance state.
|
||||||
|
fp_ret.distancePressed = bool(fp_ret.distancePressed or self.CS.distance_button)
|
||||||
fp_ret.ecoGear |= ret.gearShifter == GearShifter.eco
|
fp_ret.ecoGear |= ret.gearShifter == GearShifter.eco
|
||||||
fp_ret.sportGear |= ret.gearShifter == GearShifter.sport
|
fp_ret.sportGear |= ret.gearShifter == GearShifter.sport
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ legend = ["LAT_ACCEL_FACTOR", "MAX_LAT_ACCEL_MEASURED", "FRICTION"]
|
|||||||
"CADILLAC_ESCALADE_ESV_2019" = [1.15, 1.3, 0.2]
|
"CADILLAC_ESCALADE_ESV_2019" = [1.15, 1.3, 0.2]
|
||||||
"CADILLAC_XT4" = [1.45, 1.6, 0.2]
|
"CADILLAC_XT4" = [1.45, 1.6, 0.2]
|
||||||
"CADILLAC_XT6" = [1.33, 1.9, 0.16]
|
"CADILLAC_XT6" = [1.33, 1.9, 0.16]
|
||||||
"CHEVROLET_BOLT_ACC_2022_2023" = [2.0, 2.0, 0.09]
|
"CHEVROLET_BOLT_ACC_2022_2023" = [2.0, 2.0, 0.13]
|
||||||
|
"CHEVROLET_BOLT_CC_2017" = [1.2, 2.0, 0.21]
|
||||||
|
"CHEVROLET_BOLT_CC_2019_2021" = [2.0, 2.0, 0.13]
|
||||||
"CHEVROLET_BLAZER" = [1.33, 1.33, 0.18]
|
"CHEVROLET_BLAZER" = [1.33, 1.33, 0.18]
|
||||||
"CHEVROLET_MALIBU_CC" = [1.58, 1.8422651988094612, 0.205]
|
"CHEVROLET_MALIBU_CC" = [1.58, 1.8422651988094612, 0.205]
|
||||||
"CHEVROLET_SILVERADO" = [1.9, 1.9, 0.112]
|
"CHEVROLET_SILVERADO" = [1.9, 1.9, 0.112]
|
||||||
|
|||||||
@@ -64,10 +64,8 @@ legend = ["LAT_ACCEL_FACTOR", "MAX_LAT_ACCEL_MEASURED", "FRICTION"]
|
|||||||
"CHEVROLET_VOLT_ASCM" = "CHEVROLET_VOLT"
|
"CHEVROLET_VOLT_ASCM" = "CHEVROLET_VOLT"
|
||||||
"GMC_ACADIA_ASCM" = "GMC_ACADIA"
|
"GMC_ACADIA_ASCM" = "GMC_ACADIA"
|
||||||
"CHEVROLET_VOLT_2019" = "CHEVROLET_VOLT"
|
"CHEVROLET_VOLT_2019" = "CHEVROLET_VOLT"
|
||||||
"CHEVROLET_BOLT_CC_2019_2021" = "CHEVROLET_BOLT_ACC_2022_2023"
|
|
||||||
"CHEVROLET_BOLT_ACC_2022_2023_PEDAL" = "CHEVROLET_BOLT_ACC_2022_2023"
|
"CHEVROLET_BOLT_ACC_2022_2023_PEDAL" = "CHEVROLET_BOLT_ACC_2022_2023"
|
||||||
"CHEVROLET_BOLT_CC_2022_2023" = "CHEVROLET_BOLT_ACC_2022_2023"
|
"CHEVROLET_BOLT_CC_2022_2023" = "CHEVROLET_BOLT_ACC_2022_2023"
|
||||||
"CHEVROLET_BOLT_CC_2017" = "CHEVROLET_BOLT_ACC_2022_2023"
|
|
||||||
"CHEVROLET_EQUINOX_CC" = "CHEVROLET_EQUINOX"
|
"CHEVROLET_EQUINOX_CC" = "CHEVROLET_EQUINOX"
|
||||||
"CHEVROLET_SUBURBAN" = "CHEVROLET_SILVERADO"
|
"CHEVROLET_SUBURBAN" = "CHEVROLET_SILVERADO"
|
||||||
"CHEVROLET_SUBURBAN_CC" = "CHEVROLET_SILVERADO"
|
"CHEVROLET_SUBURBAN_CC" = "CHEVROLET_SILVERADO"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
@@ -8,6 +9,7 @@ from typing import SupportsFloat
|
|||||||
import cereal.messaging as messaging
|
import cereal.messaging as messaging
|
||||||
|
|
||||||
from cereal import car, custom, log
|
from cereal import car, custom, log
|
||||||
|
from panda import ALTERNATIVE_EXPERIENCE
|
||||||
from msgq.visionipc import VisionIpcClient, VisionStreamType
|
from msgq.visionipc import VisionIpcClient, VisionStreamType
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ from openpilot.common.realtime import config_realtime_process, Priority, Ratekee
|
|||||||
from openpilot.common.swaglog import cloudlog
|
from openpilot.common.swaglog import cloudlog
|
||||||
|
|
||||||
from openpilot.selfdrive.car.car_helpers import get_car_interface, get_startup_event
|
from openpilot.selfdrive.car.car_helpers import get_car_interface, get_startup_event
|
||||||
from openpilot.selfdrive.car.gm.values import CC_ONLY_CAR, GMFlags
|
from openpilot.selfdrive.car.gm.values import CAR, CC_ONLY_CAR, GMFlags
|
||||||
from openpilot.selfdrive.controls.lib.alertmanager import AlertManager, set_offroad_alert
|
from openpilot.selfdrive.controls.lib.alertmanager import AlertManager, set_offroad_alert
|
||||||
from openpilot.selfdrive.controls.lib.drive_helpers import VCruiseHelper, clip_curvature
|
from openpilot.selfdrive.controls.lib.drive_helpers import VCruiseHelper, clip_curvature
|
||||||
from openpilot.selfdrive.controls.lib.events import Events, ET
|
from openpilot.selfdrive.controls.lib.events import Events, ET
|
||||||
@@ -63,6 +65,10 @@ CSID_MAP = {"1": EventName.roadCameraError, "2": EventName.wideRoadCameraError,
|
|||||||
ACTUATOR_FIELDS = tuple(car.CarControl.Actuators.schema.fields.keys())
|
ACTUATOR_FIELDS = tuple(car.CarControl.Actuators.schema.fields.keys())
|
||||||
ACTIVE_STATES = (State.enabled, State.softDisabling, State.overriding)
|
ACTIVE_STATES = (State.enabled, State.softDisabling, State.overriding)
|
||||||
ENABLED_STATES = (State.preEnabled, *ACTIVE_STATES)
|
ENABLED_STATES = (State.preEnabled, *ACTIVE_STATES)
|
||||||
|
BOLT_GEN1_CANCEL_PERSONALITY_CARS = {
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2017,
|
||||||
|
CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Controls:
|
class Controls:
|
||||||
@@ -199,6 +205,12 @@ class Controls:
|
|||||||
|
|
||||||
self.event_names_to_clear = set()
|
self.event_names_to_clear = set()
|
||||||
|
|
||||||
|
self.long_maneuver_popup_show = False
|
||||||
|
self.long_maneuver_popup_text1 = ""
|
||||||
|
self.long_maneuver_popup_text2 = ""
|
||||||
|
self.long_maneuver_popup_size = "small"
|
||||||
|
self.long_maneuver_popup_status = "normal"
|
||||||
|
|
||||||
self.has_menu = self.CP.carName == "gm" and not (self.CP.flags & GMFlags.NO_CAMERA.value)
|
self.has_menu = self.CP.carName == "gm" and not (self.CP.flags & GMFlags.NO_CAMERA.value)
|
||||||
|
|
||||||
self.frogpilot_AM = AlertManager()
|
self.frogpilot_AM = AlertManager()
|
||||||
@@ -766,10 +778,19 @@ class Controls:
|
|||||||
# decrement personality on distance button press
|
# decrement personality on distance button press
|
||||||
if self.CP.openpilotLongitudinalControl:
|
if self.CP.openpilotLongitudinalControl:
|
||||||
distance_pressed = params_memory.get_bool("OnroadDistanceButtonPressed")
|
distance_pressed = params_memory.get_bool("OnroadDistanceButtonPressed")
|
||||||
|
bolt_cancel_personality = (
|
||||||
|
self.CP.carName == "gm" and
|
||||||
|
bool(self.CP.alternativeExperience & ALTERNATIVE_EXPERIENCE.GM_REMAP_CANCEL_TO_DISTANCE) and
|
||||||
|
bool(self.CP.flags & GMFlags.PEDAL_LONG.value) and
|
||||||
|
self.CP.carFingerprint in BOLT_GEN1_CANCEL_PERSONALITY_CARS
|
||||||
|
)
|
||||||
|
|
||||||
if self.frogpilot_toggles.personality_profile_via_distance:
|
if self.frogpilot_toggles.personality_profile_via_distance:
|
||||||
distance_pressed |= any(not be.pressed and be.type == ButtonType.gapAdjustCruise for be in CS.buttonEvents)
|
distance_pressed |= any(not be.pressed and be.type == ButtonType.gapAdjustCruise for be in CS.buttonEvents)
|
||||||
distance_pressed &= not (self.sm['frogpilotCarState'].distanceLongPressed or self.sm['frogpilotCarState'].distanceVeryLongPressed)
|
distance_pressed &= not (self.sm['frogpilotCarState'].distanceLongPressed or self.sm['frogpilotCarState'].distanceVeryLongPressed)
|
||||||
|
elif bolt_cancel_personality:
|
||||||
|
distance_pressed |= any(not be.pressed and be.type == ButtonType.gapAdjustCruise for be in CS.buttonEvents)
|
||||||
|
distance_pressed &= not (self.sm['frogpilotCarState'].distanceLongPressed or self.sm['frogpilotCarState'].distanceVeryLongPressed)
|
||||||
if self.frogpilot_toggles.personality_profile_via_distance_long:
|
if self.frogpilot_toggles.personality_profile_via_distance_long:
|
||||||
distance_pressed |= self.sm['frogpilotCarState'].distanceLongPressed
|
distance_pressed |= self.sm['frogpilotCarState'].distanceLongPressed
|
||||||
if self.frogpilot_toggles.personality_profile_via_distance_very_long:
|
if self.frogpilot_toggles.personality_profile_via_distance_very_long:
|
||||||
@@ -910,6 +931,8 @@ class Controls:
|
|||||||
controlsState.vPid = float(self.LoC.v_pid)
|
controlsState.vPid = float(self.LoC.v_pid)
|
||||||
controlsState.vCruise = float(self.v_cruise_helper.v_cruise_kph)
|
controlsState.vCruise = float(self.v_cruise_helper.v_cruise_kph)
|
||||||
controlsState.vCruiseCluster = float(self.v_cruise_helper.v_cruise_cluster_kph)
|
controlsState.vCruiseCluster = float(self.v_cruise_helper.v_cruise_cluster_kph)
|
||||||
|
# Publish planner intent explicitly so tools can compare "model wants" vs measured accel.
|
||||||
|
controlsState.aTarget = float(self.sm['longitudinalPlan'].aTarget)
|
||||||
controlsState.upAccelCmd = float(self.LoC.pid.p)
|
controlsState.upAccelCmd = float(self.LoC.pid.p)
|
||||||
controlsState.uiAccelCmd = float(self.LoC.pid.i)
|
controlsState.uiAccelCmd = float(self.LoC.pid.i)
|
||||||
controlsState.ufAccelCmd = float(self.LoC.pid.f)
|
controlsState.ufAccelCmd = float(self.LoC.pid.f)
|
||||||
@@ -968,6 +991,8 @@ class Controls:
|
|||||||
frogpilotControlsState.alertBlinkingRate = current_frogpilot_alert.alert_rate
|
frogpilotControlsState.alertBlinkingRate = current_frogpilot_alert.alert_rate
|
||||||
frogpilotControlsState.alertType = current_frogpilot_alert.alert_type
|
frogpilotControlsState.alertType = current_frogpilot_alert.alert_type
|
||||||
frogpilotControlsState.alertSound = current_frogpilot_alert.audible_alert
|
frogpilotControlsState.alertSound = current_frogpilot_alert.audible_alert
|
||||||
|
else:
|
||||||
|
self.apply_long_maneuver_popup(frogpilotControlsState)
|
||||||
|
|
||||||
self.pm.send('frogpilotControlsState', frogpilot_dat)
|
self.pm.send('frogpilotControlsState', frogpilot_dat)
|
||||||
|
|
||||||
@@ -999,12 +1024,73 @@ class Controls:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return log.LongitudinalPersonality.standard
|
return log.LongitudinalPersonality.standard
|
||||||
|
|
||||||
|
def update_long_maneuver_popup_state(self):
|
||||||
|
raw_status = self.params.get("LongitudinalManeuverStatus", encoding="utf-8") or ""
|
||||||
|
status = {}
|
||||||
|
if raw_status:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_status)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
status = parsed
|
||||||
|
except Exception:
|
||||||
|
status = {}
|
||||||
|
|
||||||
|
ui_text_1 = str(status.get("uiText1") or "").strip()
|
||||||
|
ui_text_2 = str(status.get("uiText2") or "").strip()
|
||||||
|
ui_size = str(status.get("uiSize") or "small").strip().lower()
|
||||||
|
ui_status = str(status.get("uiStatus") or "normal").strip().lower()
|
||||||
|
mode_enabled = self.params.get_bool("LongitudinalManeuverMode")
|
||||||
|
state = str(status.get("state") or "").strip().lower()
|
||||||
|
|
||||||
|
updated_at = 0.0
|
||||||
|
try:
|
||||||
|
updated_at = float(status.get("updatedAtSec") or 0.0)
|
||||||
|
except Exception:
|
||||||
|
updated_at = 0.0
|
||||||
|
status_age = max(0.0, time.time() - updated_at) if updated_at > 0.0 else 9999.0
|
||||||
|
|
||||||
|
is_recent_finished = state == "finished" and status_age <= 20.0
|
||||||
|
ui_show = bool(status.get("uiShow", False))
|
||||||
|
show_popup = bool((mode_enabled and ui_show and (ui_text_1 or ui_text_2)) or (is_recent_finished and (ui_text_1 or ui_text_2)))
|
||||||
|
|
||||||
|
self.long_maneuver_popup_show = show_popup
|
||||||
|
self.long_maneuver_popup_text1 = ui_text_1 or "Long Maneuvers"
|
||||||
|
self.long_maneuver_popup_text2 = ui_text_2
|
||||||
|
self.long_maneuver_popup_size = ui_size if ui_size in ("small", "mid", "full") else "small"
|
||||||
|
self.long_maneuver_popup_status = ui_status if ui_status in ("normal", "userprompt", "critical", "frogpilot") else "normal"
|
||||||
|
|
||||||
|
def apply_long_maneuver_popup(self, frogpilotControlsState):
|
||||||
|
if not self.long_maneuver_popup_show:
|
||||||
|
return False
|
||||||
|
|
||||||
|
size_map = {
|
||||||
|
"small": custom.FrogPilotControlsState.AlertSize.small,
|
||||||
|
"mid": custom.FrogPilotControlsState.AlertSize.mid,
|
||||||
|
"full": custom.FrogPilotControlsState.AlertSize.full,
|
||||||
|
}
|
||||||
|
status_map = {
|
||||||
|
"normal": custom.FrogPilotControlsState.AlertStatus.normal,
|
||||||
|
"userprompt": custom.FrogPilotControlsState.AlertStatus.userPrompt,
|
||||||
|
"critical": custom.FrogPilotControlsState.AlertStatus.critical,
|
||||||
|
"frogpilot": custom.FrogPilotControlsState.AlertStatus.frogpilot,
|
||||||
|
}
|
||||||
|
|
||||||
|
frogpilotControlsState.alertText1 = self.long_maneuver_popup_text1
|
||||||
|
frogpilotControlsState.alertText2 = self.long_maneuver_popup_text2
|
||||||
|
frogpilotControlsState.alertSize = size_map.get(self.long_maneuver_popup_size, custom.FrogPilotControlsState.AlertSize.small)
|
||||||
|
frogpilotControlsState.alertStatus = status_map.get(self.long_maneuver_popup_status, custom.FrogPilotControlsState.AlertStatus.normal)
|
||||||
|
frogpilotControlsState.alertBlinkingRate = 0.
|
||||||
|
frogpilotControlsState.alertType = "longitudinalManeuverStatus"
|
||||||
|
frogpilotControlsState.alertSound = car.CarControl.HUDControl.AudibleAlert.none
|
||||||
|
return True
|
||||||
|
|
||||||
def params_thread(self, evt):
|
def params_thread(self, evt):
|
||||||
while not evt.is_set():
|
while not evt.is_set():
|
||||||
self.is_metric = self.params.get_bool("IsMetric")
|
self.is_metric = self.params.get_bool("IsMetric")
|
||||||
if not (self.frogpilot_toggles.conditional_experimental_mode or self.frogpilot_toggles.slc_fallback_experimental_mode):
|
if not (self.frogpilot_toggles.conditional_experimental_mode or self.frogpilot_toggles.slc_fallback_experimental_mode):
|
||||||
self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl
|
self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl
|
||||||
self.personality = self.read_personality_param()
|
self.personality = self.read_personality_param()
|
||||||
|
self.update_long_maneuver_popup_state()
|
||||||
if self.CP.notCar:
|
if self.CP.notCar:
|
||||||
self.joystick_mode = self.params.get_bool("JoystickDebugMode")
|
self.joystick_mode = self.params.get_bool("JoystickDebugMode")
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ class VCruiseHelper:
|
|||||||
self.button_timers = {ButtonType.decelCruise: 0, ButtonType.accelCruise: 0}
|
self.button_timers = {ButtonType.decelCruise: 0, ButtonType.accelCruise: 0}
|
||||||
self.button_change_states = {btn: {"standstill": False, "enabled": False} for btn in self.button_timers}
|
self.button_change_states = {btn: {"standstill": False, "enabled": False} for btn in self.button_timers}
|
||||||
|
|
||||||
|
def _get_short_press_delta(self, is_metric, frogpilot_toggles):
|
||||||
|
base_delta = 1. if is_metric else IMPERIAL_INCREMENT
|
||||||
|
return base_delta * frogpilot_toggles.cruise_increase
|
||||||
|
|
||||||
|
def _normalize_initialized_v_cruise(self, v_cruise_kph, is_metric, frogpilot_toggles):
|
||||||
|
if frogpilot_toggles.cruise_increase % 5 != 0:
|
||||||
|
return v_cruise_kph
|
||||||
|
|
||||||
|
v_cruise_delta = self._get_short_press_delta(is_metric, frogpilot_toggles)
|
||||||
|
return round(round(v_cruise_kph / v_cruise_delta) * v_cruise_delta, 1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def v_cruise_initialized(self):
|
def v_cruise_initialized(self):
|
||||||
return self.v_cruise_kph != V_CRUISE_UNSET
|
return self.v_cruise_kph != V_CRUISE_UNSET
|
||||||
@@ -147,17 +158,16 @@ class VCruiseHelper:
|
|||||||
# initializing is handled by the PCM
|
# initializing is handled by the PCM
|
||||||
if self.CP.pcmCruise:
|
if self.CP.pcmCruise:
|
||||||
return
|
return
|
||||||
|
engage_floor_kph = max(V_CRUISE_MIN, 7.0 * CV.MPH_TO_KPH)
|
||||||
initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if experimental_mode and not frogpilot_toggles.conditional_experimental_mode else V_CRUISE_INITIAL
|
|
||||||
|
|
||||||
# 250kph or above probably means we never had a set speed
|
# 250kph or above probably means we never had a set speed
|
||||||
if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_kph_last < 250:
|
if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_kph_last < 250:
|
||||||
self.v_cruise_kph = self.v_cruise_kph_last
|
self.v_cruise_kph = self.v_cruise_kph_last
|
||||||
else:
|
else:
|
||||||
if desired_speed_limit != 0 and frogpilot_toggles.set_speed_limit:
|
if desired_speed_limit != 0 and frogpilot_toggles.set_speed_limit:
|
||||||
self.v_cruise_kph = int(round(desired_speed_limit * CV.MS_TO_KPH))
|
self.v_cruise_kph = self._normalize_initialized_v_cruise(desired_speed_limit * CV.MS_TO_KPH, frogpilot_toggles.is_metric, frogpilot_toggles)
|
||||||
else:
|
else:
|
||||||
self.v_cruise_kph = int(round(clip(CS.vEgo * CV.MS_TO_KPH, initial, V_CRUISE_MAX)))
|
self.v_cruise_kph = int(round(clip(CS.vEgo * CV.MS_TO_KPH, engage_floor_kph, V_CRUISE_MAX)))
|
||||||
|
|
||||||
self.v_cruise_cluster_kph = self.v_cruise_kph
|
self.v_cruise_cluster_kph = self.v_cruise_kph
|
||||||
|
|
||||||
|
|||||||
@@ -39,15 +39,22 @@ LAT_ACCEL_REQUEST_BUFFER_SECONDS = 1.0
|
|||||||
VERSION = 2
|
VERSION = 2
|
||||||
DEBUG_TORQUE_TUNE = False
|
DEBUG_TORQUE_TUNE = False
|
||||||
FF_SCALE_BLEND_LAT_ACCEL = 0.05
|
FF_SCALE_BLEND_LAT_ACCEL = 0.05
|
||||||
DEADZONE_BOOST_LAT_ACCEL = 0.08
|
DEADZONE_BOOST_LAT_ACCEL = 0.15
|
||||||
UNWIND_D_DES_THRESHOLD = -1.0
|
UNWIND_D_DES_THRESHOLD = -1.0
|
||||||
UNWIND_LAT_ACCEL_NEAR_ZERO = 0.3
|
UNWIND_LAT_ACCEL_NEAR_ZERO = 0.3
|
||||||
|
|
||||||
BOLT_CARS = (
|
BOLT_2022_2023_CARS = (
|
||||||
GM_CAR.CHEVROLET_BOLT_ACC_2022_2023,
|
GM_CAR.CHEVROLET_BOLT_ACC_2022_2023,
|
||||||
|
GM_CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL,
|
||||||
GM_CAR.CHEVROLET_BOLT_CC_2022_2023,
|
GM_CAR.CHEVROLET_BOLT_CC_2022_2023,
|
||||||
|
)
|
||||||
|
BOLT_2019_2021_CARS = (
|
||||||
GM_CAR.CHEVROLET_BOLT_CC_2019_2021,
|
GM_CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||||
)
|
)
|
||||||
|
BOLT_2017_CARS = (
|
||||||
|
GM_CAR.CHEVROLET_BOLT_CC_2017,
|
||||||
|
)
|
||||||
|
BOLT_CARS = BOLT_2022_2023_CARS + BOLT_2019_2021_CARS + BOLT_2017_CARS
|
||||||
|
|
||||||
class LatControlTorque(LatControl):
|
class LatControlTorque(LatControl):
|
||||||
def __init__(self, CP, CI, dt):
|
def __init__(self, CP, CI, dt):
|
||||||
@@ -71,16 +78,21 @@ class LatControlTorque(LatControl):
|
|||||||
self.prev_desired_lateral_accel = 0.0
|
self.prev_desired_lateral_accel = 0.0
|
||||||
|
|
||||||
self.is_bolt = CP.carFingerprint in BOLT_CARS
|
self.is_bolt = CP.carFingerprint in BOLT_CARS
|
||||||
|
self.is_bolt_2022_2023 = CP.carFingerprint in BOLT_2022_2023_CARS
|
||||||
|
self.is_bolt_2019_2021 = CP.carFingerprint in BOLT_2019_2021_CARS
|
||||||
|
self.is_bolt_2017 = CP.carFingerprint in BOLT_2017_CARS
|
||||||
|
# Keep Bolt-specific FF controls isolated by generation.
|
||||||
|
self.use_bolt_ff_scaling = self.is_bolt_2022_2023 or self.is_bolt_2019_2021 or self.is_bolt_2017
|
||||||
|
self.use_bolt_ki_multiplier = self.is_bolt_2022_2023 or self.is_bolt_2019_2021 or self.is_bolt_2017
|
||||||
self.torque_ff_scale_pos = 1.0
|
self.torque_ff_scale_pos = 1.0
|
||||||
self.torque_ff_scale_neg = 1.0
|
self.torque_ff_scale_neg = 1.0
|
||||||
self.torque_deadzone_boost_neg = 0.0
|
self.torque_deadzone_boost = float(getattr(self.torque_params, "kfDEPRECATED", 0.0))
|
||||||
self.torque_ki_mult = 1.0
|
self.torque_ki_mult = 1.0
|
||||||
if self.is_bolt:
|
if self.is_bolt:
|
||||||
self.torque_ff_scale_pos = float(self.torque_params.kp)
|
self.torque_ff_scale_pos = float(self.torque_params.kp)
|
||||||
self.torque_ff_scale_neg = float(self.torque_params.ki)
|
self.torque_ff_scale_neg = float(self.torque_params.ki)
|
||||||
self.torque_ki_mult = float(self.torque_params.kd)
|
self.torque_ki_mult = float(self.torque_params.kd)
|
||||||
self.torque_deadzone_boost_neg = float(getattr(self.torque_params, "kfDEPRECATED", 0.0))
|
if self.use_bolt_ki_multiplier and self.torque_ki_mult > 0.0 and self.torque_ki_mult != 1.0:
|
||||||
if self.torque_ki_mult > 0.0 and self.torque_ki_mult != 1.0:
|
|
||||||
self.pid._k_i = [self.pid._k_i[0], [k * self.torque_ki_mult for k in self.pid._k_i[1]]]
|
self.pid._k_i = [self.pid._k_i[0], [k * self.torque_ki_mult for k in self.pid._k_i[1]]]
|
||||||
|
|
||||||
def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction):
|
def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction):
|
||||||
@@ -143,17 +155,16 @@ class LatControlTorque(LatControl):
|
|||||||
# latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll
|
# latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll
|
||||||
ff -= self.torque_params.latAccelOffset
|
ff -= self.torque_params.latAccelOffset
|
||||||
ff_scale = 1.0
|
ff_scale = 1.0
|
||||||
if self.is_bolt:
|
if self.use_bolt_ff_scaling:
|
||||||
ff_scale = np.interp(ff, [-FF_SCALE_BLEND_LAT_ACCEL, 0.0, FF_SCALE_BLEND_LAT_ACCEL],
|
ff_scale = np.interp(ff, [-FF_SCALE_BLEND_LAT_ACCEL, 0.0, FF_SCALE_BLEND_LAT_ACCEL],
|
||||||
[self.torque_ff_scale_neg, 1.0, self.torque_ff_scale_pos])
|
[self.torque_ff_scale_neg, 1.0, self.torque_ff_scale_pos])
|
||||||
ff *= ff_scale
|
ff *= ff_scale
|
||||||
ff += get_friction(error_with_lsf + JERK_GAIN * desired_lateral_jerk, lateral_accel_deadzone, get_friction_threshold(CS.vEgo), self.torque_params)
|
ff += get_friction(error_with_lsf + JERK_GAIN * desired_lateral_jerk, lateral_accel_deadzone, get_friction_threshold(CS.vEgo), self.torque_params)
|
||||||
deadzone_boost_active = False
|
deadzone_boost_active = False
|
||||||
if self.is_bolt and self.torque_deadzone_boost_neg > 0.0 and gravity_adjusted_future_lateral_accel < 0.0:
|
if self.torque_deadzone_boost > 0.0 and abs(gravity_adjusted_future_lateral_accel) < DEADZONE_BOOST_LAT_ACCEL:
|
||||||
if abs(gravity_adjusted_future_lateral_accel) < DEADZONE_BOOST_LAT_ACCEL:
|
boost_scale = np.interp(abs(gravity_adjusted_future_lateral_accel), [0.0, DEADZONE_BOOST_LAT_ACCEL], [1.0, 0.0])
|
||||||
boost_scale = np.interp(abs(gravity_adjusted_future_lateral_accel), [0.0, DEADZONE_BOOST_LAT_ACCEL], [1.0, 0.0])
|
ff += np.sign(gravity_adjusted_future_lateral_accel) * self.torque_deadzone_boost * boost_scale
|
||||||
ff -= self.torque_deadzone_boost_neg * boost_scale
|
deadzone_boost_active = True
|
||||||
deadzone_boost_active = True
|
|
||||||
|
|
||||||
if CS.vEgo < self.low_speed_reset_threshold:
|
if CS.vEgo < self.low_speed_reset_threshold:
|
||||||
self.pid.reset()
|
self.pid.reset()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, apply_dead
|
|||||||
from openpilot.selfdrive.controls.lib.pid import PIDController
|
from openpilot.selfdrive.controls.lib.pid import PIDController
|
||||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||||
from openpilot.common.filter_simple import FirstOrderFilter
|
from openpilot.common.filter_simple import FirstOrderFilter
|
||||||
from openpilot.selfdrive.car.gm.values import CarControllerParams
|
from openpilot.selfdrive.car.gm.values import CarControllerParams, GMFlags
|
||||||
|
|
||||||
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
|
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
|
||||||
|
|
||||||
@@ -102,8 +102,9 @@ class LongControl:
|
|||||||
self.v_pid = 0.0
|
self.v_pid = 0.0
|
||||||
self._mode_setup()
|
self._mode_setup()
|
||||||
self.last_output_accel = 0.0
|
self.last_output_accel = 0.0
|
||||||
|
self.last_a_target = 0.0
|
||||||
|
self.integrator_hold_frames = 0
|
||||||
|
self.is_gm_pedal_long = bool(CP.carName == "gm" and CP.enableGasInterceptor and (CP.flags & GMFlags.PEDAL_LONG.value))
|
||||||
|
|
||||||
def update_mpc_mode(self, experimental_mode):
|
def update_mpc_mode(self, experimental_mode):
|
||||||
new_mode = 'blended' if experimental_mode else 'acc'
|
new_mode = 'blended' if experimental_mode else 'acc'
|
||||||
@@ -134,6 +135,31 @@ class LongControl:
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self.pid.reset()
|
self.pid.reset()
|
||||||
|
self.last_a_target = 0.0
|
||||||
|
self.integrator_hold_frames = 0
|
||||||
|
|
||||||
|
def _get_pedal_long_freeze(self, a_target, error, v_ego, accel_limits):
|
||||||
|
if not self.is_gm_pedal_long:
|
||||||
|
self.last_a_target = a_target
|
||||||
|
self.integrator_hold_frames = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
handoff_threshold = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.35, 0.45, 0.55, 0.70])
|
||||||
|
if abs(a_target - self.last_a_target) > handoff_threshold:
|
||||||
|
hold_frames = int(round(interp(v_ego, [0.0, 4.0, 12.0, 25.0], [25.0, 20.0, 14.0, 10.0])))
|
||||||
|
self.integrator_hold_frames = max(self.integrator_hold_frames, hold_frames)
|
||||||
|
self.last_a_target = a_target
|
||||||
|
|
||||||
|
if self.integrator_hold_frames > 0:
|
||||||
|
self.integrator_hold_frames -= 1
|
||||||
|
|
||||||
|
sat_buffer = 0.03
|
||||||
|
at_neg_sat = self.last_output_accel <= (accel_limits[0] + sat_buffer)
|
||||||
|
at_pos_sat = self.last_output_accel >= (accel_limits[1] - sat_buffer)
|
||||||
|
sat_pushing_lower = at_neg_sat and error < -0.05
|
||||||
|
sat_pushing_upper = at_pos_sat and error > 0.05
|
||||||
|
|
||||||
|
return self.integrator_hold_frames > 0 or sat_pushing_lower or sat_pushing_upper
|
||||||
|
|
||||||
def update(self, active, CS, a_target, should_stop, accel_limits, frogpilot_toggles):
|
def update(self, active, CS, a_target, should_stop, accel_limits, frogpilot_toggles):
|
||||||
"""Update longitudinal control. This updates the state machine and runs a PID loop"""
|
"""Update longitudinal control. This updates the state machine and runs a PID loop"""
|
||||||
@@ -162,7 +188,9 @@ class LongControl:
|
|||||||
error = a_target - CS.aEgo
|
error = a_target - CS.aEgo
|
||||||
self.update_mpc_mode(self.experimental_mode)
|
self.update_mpc_mode(self.experimental_mode)
|
||||||
feedforward = a_target * self.feedforward_gain
|
feedforward = a_target * self.feedforward_gain
|
||||||
raw_output_accel = self.pid.update(error, speed=CS.vEgo, feedforward=feedforward)
|
freeze_integrator = self._get_pedal_long_freeze(a_target, error, CS.vEgo, accel_limits)
|
||||||
|
raw_output_accel = self.pid.update(error, speed=CS.vEgo, feedforward=feedforward,
|
||||||
|
freeze_integrator=freeze_integrator)
|
||||||
|
|
||||||
|
|
||||||
if self.transitioning and self.prev_mode == 'acc' and self.current_mode == 'blended':
|
if self.transitioning and self.prev_mode == 'acc' and self.current_mode == 'blended':
|
||||||
@@ -179,26 +207,6 @@ class LongControl:
|
|||||||
else:
|
else:
|
||||||
output_accel = raw_output_accel
|
output_accel = raw_output_accel
|
||||||
|
|
||||||
if self.long_control_state == LongCtrlState.pid:
|
|
||||||
# Smooth acceleration and deceleration with urgency-based rate limiting
|
|
||||||
base_rate = 1.0
|
|
||||||
|
|
||||||
if output_accel < self.last_output_accel: # Deceleration requested
|
|
||||||
decel_needed = self.last_output_accel - output_accel
|
|
||||||
# Use a safe default for ACCEL_MIN if not available, to prevent division by zero
|
|
||||||
max_decel = abs(CarControllerParams.ACCEL_MIN) if CarControllerParams.ACCEL_MIN != 0 else 4.0
|
|
||||||
urgency = min(1.0, decel_needed / max_decel)
|
|
||||||
|
|
||||||
# Adjust rate based on urgency (1.0 m/s^3 for low urgency, up to 4.0 m/s^3 for high urgency)
|
|
||||||
max_rate = 1.0 + 3.0 * urgency
|
|
||||||
else:
|
|
||||||
max_rate = base_rate # Acceleration is always smooth
|
|
||||||
|
|
||||||
max_accel_change = max_rate * DT_CTRL
|
|
||||||
output_accel = clip(output_accel,
|
|
||||||
self.last_output_accel - max_accel_change,
|
|
||||||
self.last_output_accel + max_accel_change)
|
|
||||||
|
|
||||||
self.last_output_accel = clip(output_accel, accel_limits[0], accel_limits[1])
|
self.last_output_accel = clip(output_accel, accel_limits[0], accel_limits[1])
|
||||||
return self.last_output_accel
|
return self.last_output_accel
|
||||||
|
|
||||||
@@ -206,6 +214,8 @@ class LongControl:
|
|||||||
"""Reset PID controller and change setpoint"""
|
"""Reset PID controller and change setpoint"""
|
||||||
self.pid.reset()
|
self.pid.reset()
|
||||||
self.v_pid = v_pid
|
self.v_pid = v_pid
|
||||||
|
self.last_a_target = 0.0
|
||||||
|
self.integrator_hold_frames = 0
|
||||||
|
|
||||||
def update_old_long(self, active, CS, long_plan, accel_limits, t_since_plan, frogpilot_toggles):
|
def update_old_long(self, active, CS, long_plan, accel_limits, t_since_plan, frogpilot_toggles):
|
||||||
"""Update longitudinal control. This updates the state machine and runs a PID loop"""
|
"""Update longitudinal control. This updates the state machine and runs a PID loop"""
|
||||||
@@ -255,10 +265,9 @@ class LongControl:
|
|||||||
# TODO too complex, needs to be simplified and tested on toyotas
|
# TODO too complex, needs to be simplified and tested on toyotas
|
||||||
prevent_overshoot = not self.CP.stoppingControl and CS.vEgo < 1.5 and v_target_1sec < 0.7 and v_target_1sec < self.v_pid
|
prevent_overshoot = not self.CP.stoppingControl and CS.vEgo < 1.5 and v_target_1sec < 0.7 and v_target_1sec < self.v_pid
|
||||||
deadzone = interp(CS.vEgo, self.CP.longitudinalTuning.deadzoneBP, self.CP.longitudinalTuning.deadzoneV)
|
deadzone = interp(CS.vEgo, self.CP.longitudinalTuning.deadzoneBP, self.CP.longitudinalTuning.deadzoneV)
|
||||||
freeze_integrator = prevent_overshoot
|
|
||||||
|
|
||||||
error = self.v_pid - CS.vEgo
|
error = self.v_pid - CS.vEgo
|
||||||
error_deadzone = apply_deadzone(error, deadzone)
|
error_deadzone = apply_deadzone(error, deadzone)
|
||||||
|
freeze_integrator = prevent_overshoot or self._get_pedal_long_freeze(a_target, error_deadzone, CS.vEgo, accel_limits)
|
||||||
feedforward = a_target * self.feedforward_gain
|
feedforward = a_target * self.feedforward_gain
|
||||||
output_accel = self.pid.update(error_deadzone, speed=CS.vEgo,
|
output_accel = self.pid.update(error_deadzone, speed=CS.vEgo,
|
||||||
feedforward=feedforward,
|
feedforward=feedforward,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ A_CRUISE_MIN = -1.0
|
|||||||
A_CRUISE_MAX_BP = [0.0, 5., 10., 15., 20., 25., 40.]
|
A_CRUISE_MAX_BP = [0.0, 5., 10., 15., 20., 25., 40.]
|
||||||
A_CRUISE_MAX_VALS = [1.125, 1.125, 1.125, 1.125, 1.25, 1.25, 1.5]
|
A_CRUISE_MAX_VALS = [1.125, 1.125, 1.125, 1.125, 1.25, 1.25, 1.5]
|
||||||
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
|
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
|
||||||
ALLOW_THROTTLE_THRESHOLD = 0.5
|
ALLOW_THROTTLE_THRESHOLD = 0.4
|
||||||
MIN_ALLOW_THROTTLE_SPEED = 2.5
|
MIN_ALLOW_THROTTLE_SPEED = 2.5
|
||||||
|
|
||||||
# Uncertainty-based filter disable thresholds
|
# Uncertainty-based filter disable thresholds
|
||||||
@@ -120,13 +120,7 @@ class LongitudinalPlanner:
|
|||||||
self.prev_lead_dist = None
|
self.prev_lead_dist = None
|
||||||
self.last_big_brake_t = 0.0
|
self.last_big_brake_t = 0.0
|
||||||
self.stable_lead = False
|
self.stable_lead = False
|
||||||
# Temporary accel nudge window
|
# Smoothed lead distance
|
||||||
self.accel_nudge_until = 0.0
|
|
||||||
|
|
||||||
# Hysteresis gate + dwell for accel re-engage and smoothed lead distance
|
|
||||||
self.accel_gate = False
|
|
||||||
self._t_arm = 0.0
|
|
||||||
self._t_disarm = 0.0
|
|
||||||
self.lead_dist_f = None
|
self.lead_dist_f = None
|
||||||
|
|
||||||
# Uncertainty slope tracking
|
# Uncertainty slope tracking
|
||||||
@@ -339,38 +333,6 @@ class LongitudinalPlanner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# now_t defined earlier
|
|
||||||
over = uncertainty > 1.0
|
|
||||||
|
|
||||||
# Asymmetric accel release with hysteresis + dwell to prevent on/off pulsing
|
|
||||||
rise_dwell_s, fall_dwell_s = 0.6, 0.4
|
|
||||||
good = (
|
|
||||||
(self.a_desired > 0.0) and
|
|
||||||
self.stable_lead and
|
|
||||||
(uncertainty <= 0.425) and
|
|
||||||
(desire_entropy < 0.41)
|
|
||||||
)
|
|
||||||
|
|
||||||
# dwell timers for robust gating
|
|
||||||
if good and not self.accel_gate:
|
|
||||||
if now_t - self._t_arm >= rise_dwell_s:
|
|
||||||
self.accel_gate = True
|
|
||||||
else:
|
|
||||||
self._t_arm = now_t
|
|
||||||
|
|
||||||
if (not good) and self.accel_gate:
|
|
||||||
if now_t - self._t_disarm >= fall_dwell_s:
|
|
||||||
self.accel_gate = False
|
|
||||||
else:
|
|
||||||
self._t_disarm = now_t
|
|
||||||
|
|
||||||
if self.accel_gate:
|
|
||||||
# Ensure some positive headroom for MPC to exit coasting
|
|
||||||
accel_limits_turns[1] = max(accel_limits_turns[1], 0.2)
|
|
||||||
# Short self-canceling nudge to unstick (applied post-MPC)
|
|
||||||
if now_t > self.accel_nudge_until:
|
|
||||||
self.accel_nudge_until = now_t + 0.45
|
|
||||||
|
|
||||||
self.mpc.set_weights(sm['frogpilotPlan'].accelerationJerk,
|
self.mpc.set_weights(sm['frogpilotPlan'].accelerationJerk,
|
||||||
sm['frogpilotPlan'].dangerJerk,
|
sm['frogpilotPlan'].dangerJerk,
|
||||||
sm['frogpilotPlan'].speedJerk,
|
sm['frogpilotPlan'].speedJerk,
|
||||||
@@ -379,7 +341,6 @@ class LongitudinalPlanner:
|
|||||||
v_ego=v_ego,
|
v_ego=v_ego,
|
||||||
lead_dist=self.lead_dist_f if self.lead_dist_f is not None else lead_dist,
|
lead_dist=self.lead_dist_f if self.lead_dist_f is not None else lead_dist,
|
||||||
uncertainty=uncertainty,
|
uncertainty=uncertainty,
|
||||||
accel_reengage=self.accel_gate,
|
|
||||||
panic_bypass=panic_bypass)
|
panic_bypass=panic_bypass)
|
||||||
self.mpc.set_accel_limits(accel_limits_turns[0], accel_limits_turns[1])
|
self.mpc.set_accel_limits(accel_limits_turns[0], accel_limits_turns[1])
|
||||||
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
|
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
|
||||||
@@ -426,10 +387,6 @@ class LongitudinalPlanner:
|
|||||||
pre_brake = min(pre_brake, 0.06)
|
pre_brake = min(pre_brake, 0.06)
|
||||||
self.a_desired = float(self.a_desired - pre_brake)
|
self.a_desired = float(self.a_desired - pre_brake)
|
||||||
|
|
||||||
# Apply tiny feed-forward nudge when released and safe
|
|
||||||
if now_t < self.accel_nudge_until and self.a_desired > -0.1:
|
|
||||||
self.a_desired = float(min(self.a_desired + 0.12, get_max_accel(v_ego)))
|
|
||||||
|
|
||||||
# Small deadzone around zero accel to kill micro-dithers
|
# Small deadzone around zero accel to kill micro-dithers
|
||||||
if -0.05 < self.a_desired < 0.05:
|
if -0.05 < self.a_desired < 0.05:
|
||||||
self.a_desired = 0.0
|
self.a_desired = 0.0
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ MAX_LAT_ACCEL_DIFF = 0.6
|
|||||||
MIN_CONFIDENCE = 0.7
|
MIN_CONFIDENCE = 0.7
|
||||||
CORR_BORDER_OFFSET = 5
|
CORR_BORDER_OFFSET = 5
|
||||||
LAG_CANDIDATE_CORR_THRESHOLD = 0.9
|
LAG_CANDIDATE_CORR_THRESHOLD = 0.9
|
||||||
|
DEFAULT_STEER_DELAY_OFFSET = 0.19
|
||||||
|
|
||||||
|
|
||||||
def masked_normalized_cross_correlation(expected_sig: np.ndarray, actual_sig: np.ndarray, mask: np.ndarray, n: int):
|
def masked_normalized_cross_correlation(expected_sig: np.ndarray, actual_sig: np.ndarray, mask: np.ndarray, n: int):
|
||||||
@@ -165,7 +166,8 @@ class LateralLagEstimator:
|
|||||||
self.window_sec = window_sec
|
self.window_sec = window_sec
|
||||||
self.okay_window_sec = okay_window_sec
|
self.okay_window_sec = okay_window_sec
|
||||||
self.min_recovery_buffer_sec = min_recovery_buffer_sec
|
self.min_recovery_buffer_sec = min_recovery_buffer_sec
|
||||||
self.initial_lag = CP.steerActuatorDelay + 0.2
|
self.lagd_disabled = CP.carName == "gm"
|
||||||
|
self.initial_lag = CP.steerActuatorDelay + DEFAULT_STEER_DELAY_OFFSET
|
||||||
self.block_size = block_size
|
self.block_size = block_size
|
||||||
self.block_count = block_count
|
self.block_count = block_count
|
||||||
self.min_valid_block_count = min_valid_block_count
|
self.min_valid_block_count = min_valid_block_count
|
||||||
@@ -209,7 +211,9 @@ class LateralLagEstimator:
|
|||||||
liveDelay = msg.liveDelay
|
liveDelay = msg.liveDelay
|
||||||
|
|
||||||
valid_mean_lag, valid_std, current_mean_lag, current_std = self.block_avg.get()
|
valid_mean_lag, valid_std, current_mean_lag, current_std = self.block_avg.get()
|
||||||
if self.block_avg.valid_blocks >= self.min_valid_block_count and not np.isnan(valid_mean_lag) and not np.isnan(valid_std):
|
if self.lagd_disabled:
|
||||||
|
liveDelay.status = log.LiveDelayData.Status.unestimated
|
||||||
|
elif self.block_avg.valid_blocks >= self.min_valid_block_count and not np.isnan(valid_mean_lag) and not np.isnan(valid_std):
|
||||||
if valid_std > MAX_LAG_STD:
|
if valid_std > MAX_LAG_STD:
|
||||||
liveDelay.status = log.LiveDelayData.Status.invalid
|
liveDelay.status = log.LiveDelayData.Status.invalid
|
||||||
else:
|
else:
|
||||||
@@ -219,21 +223,26 @@ class LateralLagEstimator:
|
|||||||
|
|
||||||
if frogpilot_toggles.use_custom_steerActuatorDelay:
|
if frogpilot_toggles.use_custom_steerActuatorDelay:
|
||||||
liveDelay.lateralDelay = frogpilot_toggles.steerActuatorDelay
|
liveDelay.lateralDelay = frogpilot_toggles.steerActuatorDelay
|
||||||
|
elif self.lagd_disabled:
|
||||||
|
liveDelay.lateralDelay = self.initial_lag
|
||||||
elif liveDelay.status == log.LiveDelayData.Status.estimated:
|
elif liveDelay.status == log.LiveDelayData.Status.estimated:
|
||||||
liveDelay.lateralDelay = valid_mean_lag
|
liveDelay.lateralDelay = valid_mean_lag
|
||||||
else:
|
else:
|
||||||
liveDelay.lateralDelay = self.initial_lag
|
liveDelay.lateralDelay = self.initial_lag
|
||||||
|
|
||||||
if not np.isnan(current_mean_lag) and not np.isnan(current_std):
|
if self.lagd_disabled:
|
||||||
|
liveDelay.lateralDelayEstimate = self.initial_lag
|
||||||
|
liveDelay.lateralDelayEstimateStd = 0.0
|
||||||
|
elif not np.isnan(current_mean_lag) and not np.isnan(current_std):
|
||||||
liveDelay.lateralDelayEstimate = current_mean_lag
|
liveDelay.lateralDelayEstimate = current_mean_lag
|
||||||
liveDelay.lateralDelayEstimateStd = current_std
|
liveDelay.lateralDelayEstimateStd = current_std
|
||||||
else:
|
else:
|
||||||
liveDelay.lateralDelayEstimate = self.initial_lag
|
liveDelay.lateralDelayEstimate = self.initial_lag
|
||||||
liveDelay.lateralDelayEstimateStd = 0.0
|
liveDelay.lateralDelayEstimateStd = 0.0
|
||||||
|
|
||||||
liveDelay.validBlocks = self.block_avg.valid_blocks
|
liveDelay.validBlocks = 0 if self.lagd_disabled else self.block_avg.valid_blocks
|
||||||
if debug:
|
if debug:
|
||||||
liveDelay.points = self.block_avg.values.flatten().tolist()
|
liveDelay.points = [] if self.lagd_disabled else self.block_avg.values.flatten().tolist()
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
@@ -291,7 +300,7 @@ class LateralLagEstimator:
|
|||||||
self.points.update(self.t, la_desired, la_actual_pose, okay)
|
self.points.update(self.t, la_desired, la_actual_pose, okay)
|
||||||
|
|
||||||
def update_estimate(self):
|
def update_estimate(self):
|
||||||
if not self.points_enough():
|
if self.lagd_disabled or not self.points_enough():
|
||||||
return
|
return
|
||||||
|
|
||||||
times, desired, actual, okay = self.points.get()
|
times, desired, actual, okay = self.points.get()
|
||||||
@@ -372,7 +381,7 @@ def main():
|
|||||||
CP = msg
|
CP = msg
|
||||||
|
|
||||||
lag_learner = LateralLagEstimator(CP, 1. / SERVICE_LIST['livePose'].frequency)
|
lag_learner = LateralLagEstimator(CP, 1. / SERVICE_LIST['livePose'].frequency)
|
||||||
if (initial_lag_params := retrieve_initial_lag(params_reader, CP)) is not None:
|
if not lag_learner.lagd_disabled and (initial_lag_params := retrieve_initial_lag(params_reader, CP)) is not None:
|
||||||
lag, valid_blocks = initial_lag_params
|
lag, valid_blocks = initial_lag_params
|
||||||
lag_learner.reset(lag, valid_blocks)
|
lag_learner.reset(lag, valid_blocks)
|
||||||
|
|
||||||
|
|||||||