diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json
index 2ddb93a4cf..0ee696c875 100644
--- a/starpilot/common/assets/device_settings_layout.json
+++ b/starpilot/common/assets/device_settings_layout.json
@@ -1203,7 +1203,6 @@
},
{
"key": "CustomPersonalities",
- "requires_offroad": true,
"label": "Driving Personalities",
"description": "Customize braking, acceleration, and following distance for each profile.",
"picker_description": "Customizes driving personalities to match your style.",
@@ -1214,7 +1213,6 @@
},
{
"key": "TrafficPersonalityProfile",
- "requires_offroad": true,
"label": "Traffic Mode",
"description": "Customize the \"Traffic Mode\" personality profile. Designed for stop-and-go driving.",
"picker_description": "Customizes Traffic Mode for stop-and-go driving.",
@@ -1226,7 +1224,6 @@
},
{
"key": "AggressivePersonalityProfile",
- "requires_offroad": true,
"label": "Aggressive",
"description": "Customize the \"Aggressive\" personality profile. Designed for assertive driving with tighter gaps.",
"picker_description": "Customizes Aggressive Mode for assertive driving.",
@@ -1238,7 +1235,6 @@
},
{
"key": "StandardPersonalityProfile",
- "requires_offroad": true,
"label": "Standard",
"description": "Customize the \"Standard\" personality profile. Designed for balanced driving with moderate gaps.",
"picker_description": "Customizes Standard Mode for balanced driving.",
@@ -1250,7 +1246,6 @@
},
{
"key": "RelaxedPersonalityProfile",
- "requires_offroad": true,
"label": "Relaxed",
"description": "Customize the \"Relaxed\" personality profile. Designed for smoother, more comfortable driving with larger gaps.",
"picker_description": "Customizes Relaxed Mode for smoother driving.",
@@ -1262,7 +1257,6 @@
},
{
"key": "TrafficFollow",
- "requires_offroad": true,
"label": "Following Distance",
"description": "The minimum following distance to the lead vehicle. openpilot blends between this value and the \"Relaxed\" profile as speed increases. Increase for more space; decrease for tighter gaps.",
"data_type": "float",
@@ -1275,7 +1269,6 @@
},
{
"key": "TrafficJerkAcceleration",
- "requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
@@ -1288,7 +1281,6 @@
},
{
"key": "TrafficJerkDeceleration",
- "requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
@@ -1301,7 +1293,6 @@
},
{
"key": "TrafficJerkDanger",
- "requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
@@ -1314,7 +1305,6 @@
},
{
"key": "TrafficJerkSpeedDecrease",
- "requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
@@ -1327,7 +1317,6 @@
},
{
"key": "TrafficJerkSpeed",
- "requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
@@ -1340,7 +1329,6 @@
},
{
"key": "AggressiveFollow",
- "requires_offroad": true,
"label": "Following Distance",
"description": "How many seconds openpilot follows behind lead vehicles when using the \"Aggressive\" profile. Increase for more space; decrease for tighter gaps.\n\nDefault: 1.25 seconds.",
"data_type": "float",
@@ -1353,7 +1341,6 @@
},
{
"key": "AggressiveFollowHigh",
- "requires_offroad": true,
"label": "High Speed Following Distance",
"description": "Following distance for higher speeds in the \"Aggressive\" profile. openpilot smoothly blends from the base value to this value as speed rises.",
"data_type": "float",
@@ -1366,7 +1353,6 @@
},
{
"key": "AggressiveJerkAcceleration",
- "requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
@@ -1379,7 +1365,6 @@
},
{
"key": "AggressiveJerkDeceleration",
- "requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
@@ -1392,7 +1377,6 @@
},
{
"key": "AggressiveJerkDanger",
- "requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
@@ -1405,7 +1389,6 @@
},
{
"key": "AggressiveJerkSpeedDecrease",
- "requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
@@ -1418,7 +1401,6 @@
},
{
"key": "AggressiveJerkSpeed",
- "requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
@@ -1431,7 +1413,6 @@
},
{
"key": "StandardFollow",
- "requires_offroad": true,
"label": "Following Distance",
"description": "How many seconds openpilot follows behind lead vehicles when using the \"Standard\" profile. Increase for more space; decrease for tighter gaps.\n\nDefault: 1.45 seconds.",
"data_type": "float",
@@ -1444,7 +1425,6 @@
},
{
"key": "StandardFollowHigh",
- "requires_offroad": true,
"label": "High Speed Following Distance",
"description": "Following distance for higher speeds in the \"Standard\" profile. openpilot smoothly blends from the base value to this value as speed rises.",
"data_type": "float",
@@ -1457,7 +1437,6 @@
},
{
"key": "StandardJerkAcceleration",
- "requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
@@ -1470,7 +1449,6 @@
},
{
"key": "StandardJerkDeceleration",
- "requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
@@ -1483,7 +1461,6 @@
},
{
"key": "StandardJerkDanger",
- "requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
@@ -1496,7 +1473,6 @@
},
{
"key": "StandardJerkSpeedDecrease",
- "requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
@@ -1509,7 +1485,6 @@
},
{
"key": "StandardJerkSpeed",
- "requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
@@ -1522,7 +1497,6 @@
},
{
"key": "RelaxedFollow",
- "requires_offroad": true,
"label": "Following Distance",
"description": "How many seconds openpilot follows behind lead vehicles when using the \"Relaxed\" profile. Increase for more space; decrease for tighter gaps.\n\nDefault: 1.6 seconds.",
"data_type": "float",
@@ -1535,7 +1509,6 @@
},
{
"key": "RelaxedFollowHigh",
- "requires_offroad": true,
"label": "High Speed Following Distance",
"description": "Following distance for higher speeds in the \"Relaxed\" profile. openpilot smoothly blends from the base value to this value as speed rises.",
"data_type": "float",
@@ -1548,7 +1521,6 @@
},
{
"key": "RelaxedJerkAcceleration",
- "requires_offroad": true,
"label": "Acceleration Smoothness",
"description": "How smoothly openpilot accelerates. Increase for gentler starts; decrease for faster but more abrupt takeoffs.",
"data_type": "float",
@@ -1561,7 +1533,6 @@
},
{
"key": "RelaxedJerkDeceleration",
- "requires_offroad": true,
"label": "Braking Smoothness",
"description": "How smoothly openpilot brakes. Increase for gentler stops; decrease for quicker but sharper braking.",
"data_type": "float",
@@ -1574,7 +1545,6 @@
},
{
"key": "RelaxedJerkDanger",
- "requires_offroad": true,
"label": "Safety Gap Bias",
"description": "How much extra space openpilot keeps from the vehicle ahead. Increase for larger gaps and more cautious following; decrease for tighter gaps and closer following.",
"data_type": "float",
@@ -1587,7 +1557,6 @@
},
{
"key": "RelaxedJerkSpeedDecrease",
- "requires_offroad": true,
"label": "Slowdown Response",
"description": "How smoothly openpilot slows down. Increase for more gradual deceleration; decrease for faster but sharper slowdowns.",
"data_type": "float",
@@ -1600,7 +1569,6 @@
},
{
"key": "RelaxedJerkSpeed",
- "requires_offroad": true,
"label": "Speed-Up Response",
"description": "How smoothly openpilot speeds up. Increase for more gradual acceleration; decrease for quicker but more jolting acceleration.",
"data_type": "float",
diff --git a/starpilot/common/tests/test_longitudinal_personality_profiles.py b/starpilot/common/tests/test_longitudinal_personality_profiles.py
index e92b2aa83c..f81a2a28a1 100644
--- a/starpilot/common/tests/test_longitudinal_personality_profiles.py
+++ b/starpilot/common/tests/test_longitudinal_personality_profiles.py
@@ -101,7 +101,7 @@ def test_disabling_without_a_stored_document_does_not_create_one():
assert lpp.synchronise_profile_document_enabled(None, False, ev_tuning=False, truck_tuning=False) is None
-def test_every_state_affecting_personality_param_is_parked_only():
+def test_every_state_affecting_personality_param_is_included_in_bulk_restore_parked_guard():
assert lpp.PERSONALITY_PARKED_PARAM_KEYS == (
lpp.PERSONALITY_ADVANCED_PARAM_KEYS
| lpp.PERSONALITY_FOLLOW_PARAM_KEYS
diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js
index 45e1611481..8e76e51867 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js
+++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js
@@ -124,6 +124,24 @@ const PERSONALITY_ADVANCED_KEYS = {
standard: ["StandardJerkAcceleration", "StandardJerkDeceleration", "StandardJerkDanger", "StandardJerkSpeedDecrease", "StandardJerkSpeed"],
relaxed: ["RelaxedJerkAcceleration", "RelaxedJerkDeceleration", "RelaxedJerkDanger", "RelaxedJerkSpeedDecrease", "RelaxedJerkSpeed"],
}
+const PERSONALITY_EDITOR_PARAM_KEYS = new Set([
+ "CustomPersonalities",
+ ...PERSONALITY_DEFINITIONS.map(profile => personalityProfileParamKey(profile.id)),
+ ...Object.values(PERSONALITY_ADVANCED_KEYS).flat(),
+ "TrafficFollow", "AggressiveFollow", "AggressiveFollowHigh", "StandardFollow", "StandardFollowHigh",
+ "RelaxedFollow", "RelaxedFollowHigh",
+])
+function parseRoadFlag(value) {
+ if (value === true || value === 1 || value === "1" || value === "true" || value === "True") return true
+ if (value === false || value === 0 || value === "" || value === "0" || value === "false" || value === "False") return false
+ return null
+}
+
+function personalityRoadStateKnown() {
+ const onroad = parseRoadFlag(state.values.IsOnroad)
+ const offroad = parseRoadFlag(state.values.IsOffroad)
+ return onroad !== null && offroad !== null && onroad !== offroad
+}
const PANDA_FIRMWARE_TOGGLE_KEYS = new Set(["IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma"])
const FLM_ADVANCED_LATERAL_KEYS = new Set([
"AdvancedLateralTune", "ForceAutoTune", "ForceAutoTuneOff", "UseAutoSteerDelay", "SteerDelay",
@@ -896,6 +914,13 @@ async function refreshCscCalibrationValues() {
const nextValues = { ...state.values }
let changed = false
for (const [key, value] of entries) {
+ if (value === null && (key === "IsOnroad" || key === "IsOffroad")) {
+ if (nextValues[key] !== null) {
+ nextValues[key] = null
+ changed = true
+ }
+ continue
+ }
if (value === null || nextValues[key] === value) continue
nextValues[key] = value
changed = true
@@ -930,7 +955,7 @@ async function refreshUiContextValues() {
if (uiContextPollInflight || state.loadingValues) return uiContextPollInflight
uiContextPollInflight = Promise.all(
- ["IsOnroad", "IsMetric"].map(async key => {
+ ["IsOnroad", "IsOffroad", "IsMetric"].map(async key => {
const response = await fetch(`/api/params?key=${encodeURIComponent(key)}`, { cache: "no-store" })
if (!response.ok) return [key, null]
const raw = (await response.text()).trim().toLowerCase()
@@ -949,7 +974,13 @@ async function refreshUiContextValues() {
state.values = nextValues
scheduleSyncInputs()
}
- }).catch(() => {}).finally(async () => {
+ }).catch(() => {
+ const nextValues = { ...state.values, IsOnroad: null, IsOffroad: null }
+ if (state.values.IsOnroad !== null || state.values.IsOffroad !== null) {
+ state.values = nextValues
+ scheduleSyncInputs()
+ }
+ }).finally(async () => {
await fetchLongitudinalMode()
uiContextPollInflight = null
})
@@ -1579,12 +1610,15 @@ function getSettingLockReason(param) {
if (param?.key === "CustomPersonalities" && state.personalityMigrationRequired) {
return "This profile data requires a verified migration before it can be edited."
}
+ if (PERSONALITY_EDITOR_PARAM_KEYS.has(param?.key) && !personalityRoadStateKnown()) {
+ return "Driving state is not confirmed. Refresh before editing personalities."
+ }
if (param?.key === LONGITUDINAL_MODE_KEY) {
if (state.longitudinalModeUpdating) return "Updating longitudinal control mode…"
if (!state.longitudinalMode) return "Longitudinal mode state unavailable. Refresh to retry."
return state.longitudinalMode.locked ? state.longitudinalMode.reason : ""
}
- if (param?.requires_offroad && state.values.IsOnroad) {
+ if (param?.requires_offroad && (!personalityRoadStateKnown() || state.values.IsOnroad)) {
return "This setting can only be changed while parked."
}
if (param?.requires_parked && !state.values.VehicleParked && !(param.key === "ForceOffroad" && state.values.ForceOffroad)) {
@@ -1701,9 +1735,9 @@ async function recoverPersonalitySave() {
(config.preset === "custom" && config.curve.length !== data.speed_breakpoints_mph?.[category]?.length)) throw new Error("Saved profiles are malformed.")
}
}
- const onroad = [false, "", "0", "False", "false"].includes(values?.IsOnroad) ? false : [true, "1", "True", "true"].includes(values?.IsOnroad) ? true : null
- const offroad = [true, "1", "True", "true"].includes(values?.IsOffroad)
- if (onroad === null || (!onroad && !offroad)) throw new Error("Road state could not be verified.")
+ const onroad = parseRoadFlag(values?.IsOnroad)
+ const offroad = parseRoadFlag(values?.IsOffroad)
+ if (onroad === null || offroad === null || onroad === offroad) throw new Error("Road state could not be verified.")
state.values = { ...state.values, IsOnroad: onroad, IsOffroad: offroad }
state.personalityProfiles = data.profiles
state.personalityMigrationRequired = !!data.migration_required
@@ -1721,8 +1755,10 @@ async function recoverPersonalitySave() {
}
}
-async function savePersonalityCategory(profileId, category, preset, curve, successMessage) {
- if (state.values.IsOnroad) return false
+async function savePersonalityCategory(profileId, category, preset, curve, successMessage, expectedOnroad = null) {
+ if (!personalityRoadStateKnown()) return false
+ if (expectedOnroad === null) expectedOnroad = parseRoadFlag(state.values.IsOnroad)
+ if (expectedOnroad !== null && parseRoadFlag(state.values.IsOnroad) !== expectedOnroad) return false
if (!window.location.pathname.startsWith("/device_settings") || state.personalityProfilesError || state.personalityProfilesLoading) return false
if (state.personalityMigrationRequired) {
showParamSnackbar("This profile data requires a verified migration before it can be edited.", "error")
@@ -1735,7 +1771,8 @@ async function savePersonalityCategory(profileId, category, preset, curve, succe
state.personalityUpdating = { ...state.personalityUpdating, [updateKey]: true }
try {
if (uiContextPollInflight) await uiContextPollInflight
- if (generation !== personalityViewGeneration || !window.location.pathname.startsWith("/device_settings") || state.values.IsOnroad || state.personalityMigrationRequired) return false
+ if (generation !== personalityViewGeneration || !window.location.pathname.startsWith("/device_settings") || !personalityRoadStateKnown() ||
+ (expectedOnroad !== null && parseRoadFlag(state.values.IsOnroad) !== expectedOnroad) || state.personalityMigrationRequired) return false
const response = await fetch("/api/personality_profiles", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -1971,9 +2008,10 @@ function beginPersonalityCurveDrag(event, profileId, category) {
const config = state.personalityProfiles?.[profileId]?.[category]
const bounds = state.personalityMeta?.bounds?.[category]
const definition = PERSONALITY_CATEGORY_DEFINITIONS[category]
- if (state.personalityMigrationRequired || !(canvas instanceof HTMLCanvasElement) || !config || !bounds || !definition || state.personalityUpdating[personalityUpdateKey(profileId, category)]) return
+ if (!personalityRoadStateKnown() || state.personalityMigrationRequired || !(canvas instanceof HTMLCanvasElement) || !config || !bounds || !definition || state.personalityUpdating[personalityUpdateKey(profileId, category)]) return
event.preventDefault()
+ const startingOnroad = parseRoadFlag(state.values.IsOnroad)
const curve = [...config.curve]
const geometry = graphGeometry(category, curve, canvas.clientWidth || 660)
const chartRect = canvas.getBoundingClientRect()
@@ -2000,7 +2038,7 @@ function beginPersonalityCurveDrag(event, profileId, category) {
}
const finish = async pointerEvent => {
removeListeners(pointerEvent)
- const saved = await savePersonalityCategory(profileId, category, "custom", curve, `${definition.label} graph updated.`)
+ const saved = await savePersonalityCategory(profileId, category, "custom", curve, `${definition.label} graph updated.`, startingOnroad)
if (!saved && !state.personalityProfilesError && window.location.pathname.startsWith("/device_settings")) restorePersonalityCurveVisual(profileId, category, state.personalityProfiles[profileId][category].curve)
}
const cancel = pointerEvent => {
@@ -2070,7 +2108,7 @@ function renderPersonalityCurve(profile, category, config) {
Custom ${definition.label}
- resetPersonalityCurve(profile.id, category)}">Reset
+ resetPersonalityCurve(profile.id, category)}">Reset
${config.curve.some(value => value > Number(editBounds[1])) ? html`
@@ -2084,8 +2122,8 @@ function renderPersonalityCurve(profile, category, config) {
height="${geometry.height}"
role="img"
aria-label="${definition.title} by speed with the ${profile.label} reference shown faintly. Drag near a point to adjust it."
- aria-disabled="${() => !!state.values.IsOnroad || !!state.personalityMigrationRequired}"
- @pointerdown="${event => { if (!state.values.IsOnroad && !state.personalityMigrationRequired) beginPersonalityCurveDrag(event, profile.id, category) }}">
+ aria-disabled="${() => !personalityRoadStateKnown() || !!state.personalityMigrationRequired}"
+ @pointerdown="${event => { if (personalityRoadStateKnown() && !state.personalityMigrationRequired) beginPersonalityCurveDrag(event, profile.id, category) }}">
${config.curve.map((value, index) => html`
@@ -2100,7 +2138,7 @@ function renderPersonalityCurve(profile, category, config) {
aria-describedby="personality-curve-error-${profile.id}-${category}"
aria-invalid="${() => state.personalityCurveErrors[updateKey] ? "true" : "false"}"
value="${Number(value).toFixed(2)}"
- disabled="${() => !!state.values.IsOnroad || !!state.personalityMigrationRequired || !!state.personalityUpdating[updateKey]}"
+ disabled="${() => !personalityRoadStateKnown() || !!state.personalityMigrationRequired || !!state.personalityUpdating[updateKey]}"
@change="${event => adjustPersonalityCurvePoint(profile.id, category, index, event.currentTarget)}" />
${Number(value).toFixed(2)} ${definition.valueUnit}
@@ -2133,7 +2171,7 @@ function renderPersonalityCategoryField(profile, category, config) {
type="button"
class="ds-personality-option"
aria-pressed="${() => config.preset === option ? "true" : "false"}"
- disabled="${() => !!state.values.IsOnroad || !!state.personalityMigrationRequired || !!state.personalityUpdating[updateKey]}"
+ disabled="${() => !personalityRoadStateKnown() || !!state.personalityMigrationRequired || !!state.personalityUpdating[updateKey]}"
@click="${() => updatePersonalityPreset(profile.id, category, option)}">
${personalityPresetLabel(option)}
@@ -2187,7 +2225,7 @@ function personalityAdvancedOptions(key) {
}
function updatePersonalityAdvancedPreset(param, mode) {
- if (state.values.IsOnroad || state.numericUpdating[param.key]) return
+ if (!personalityRoadStateKnown() || state.numericUpdating[param.key]) return
if (mode === "custom") {
state.personalityAdvancedCustomOpen = { ...state.personalityAdvancedCustomOpen, [param.key]: true }
return
@@ -2214,7 +2252,7 @@ function renderPersonalityAdvancedValue(profile, param) {
class="ds-personality-option ds-personality-advanced-choice"
aria-label="${profile.label} ${param.label} ${label} percentage preset"
aria-pressed="${() => personalityAdvancedMode(param.key) === mode ? "true" : "false"}"
- disabled="${() => !!state.values.IsOnroad || !!state.numericUpdating[param.key]}"
+ disabled="${() => !personalityRoadStateKnown() || !!state.numericUpdating[param.key]}"
@click="${() => updatePersonalityAdvancedPreset(param, mode)}">${label}
`)}
@@ -2226,7 +2264,7 @@ function renderPersonalityAdvancedValue(profile, param) {
step="${bounds.step}"
aria-label="${profile.label} ${param.label} custom percentage"
value="${() => resolveCurrentNumericValue(param, bounds)}"
- disabled="${() => !!state.values.IsOnroad || !!state.numericUpdating[param.key]}"
+ disabled="${() => !personalityRoadStateKnown() || !!state.numericUpdating[param.key]}"
@change="${event => updateNumericParam(param, event.currentTarget.value, event.currentTarget)}" />
${bounds.min}–${bounds.max}
@@ -2305,10 +2343,12 @@ function renderPersonalityProfilesPanel() {
if (!state.personalityMeta) return html`Driving personalities could not be loaded. Refresh the page to retry.
`
return html`
+ ${() => !personalityRoadStateKnown() ? html`
Driving state is not confirmed. Personality editing is temporarily disabled.
` : ""}
+ ${() => parseRoadFlag(state.values.IsOnroad) === true && personalityRoadStateKnown() ? html`
Changes to the active profile can take effect immediately and alter acceleration, braking, or following behavior. Make adjustments only when it is safe, and stay ready to take control.
` : ""}
${() => state.personalityMigrationRequired ? html`
This profile data requires a verified migration before it can be edited.
-
+
${() => state.personalityMigrationInProgress ? "Migrating..." : "Migrate profiles"}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js
index e402a7e8c3..742f82de2a 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js
@@ -5,6 +5,12 @@ import { formatProfileSpeed, profileSpeedUnit, personalityProfileParamKey } from
const PROFILES = ["traffic", "aggressive", "standard", "relaxed"]
const CATEGORIES = { acceleration: "Acceleration", braking: "Braking", following: "Following" }
+function roadFlag(value) {
+ if (value === true || value === 1 || value === "1" || value === "true" || value === "True") return true
+ if (value === false || value === 0 || value === "" || value === "0" || value === "false" || value === "False") return false
+ return null
+}
+
export const PersonalityProfiles = {
name: "PersonalityProfiles",
props: { manageOpen: { default: null } },
@@ -15,8 +21,13 @@ export const PersonalityProfiles = {
},
computed: {
expanded: { get() { return this.manageOpen ?? this.localExpanded }, set(value) { this.localExpanded = value; this.$emit("manage") } },
- offroad() { return [false, "", "0", "False", "false"].includes(this.values.IsOnroad) && [true, "1", "True", "true"].includes(this.values.IsOffroad) },
- locked() { return !this.ready || this.busy || !this.offroad },
+ isOnroad() { return roadFlag(this.values.IsOnroad) === true },
+ roadStateKnown() {
+ const onroad = roadFlag(this.values.IsOnroad)
+ const offroad = roadFlag(this.values.IsOffroad)
+ return onroad !== null && offroad !== null && onroad !== offroad
+ },
+ locked() { return !this.ready || this.busy || !this.roadStateKnown },
editingLocked() { return this.locked || this.curvePending || !!this.data?.migration_required },
},
async mounted() {
@@ -118,14 +129,22 @@ export const PersonalityProfiles = {
this.contextRequest = api.getParams()
const values = await this.contextRequest
if (this.disposed) return
+ const previousOnroad = roadFlag(this.values.IsOnroad)
+ const nextOnroad = roadFlag(values?.IsOnroad)
if (!this.busy) this.values = values
- if (!this.offroad) { this.drag = null; this.drafts = {}; this.curveText = {} }
+ if (!this.roadStateKnown || (previousOnroad !== null && nextOnroad !== previousOnroad)) {
+ this.drag = null
+ this.drafts = {}
+ this.curveText = {}
+ }
} catch (e) { this.ready = false; this.error = "Connection lost. Reconnecting…" }
finally { this.contextPending = false; this.contextRequest = null }
},
async write(action, check = () => !this.editingLocked) {
+ if (!this.roadStateKnown) return false
+ const startingOnroad = this.isOnroad
if (this.contextPending) { try { await this.contextRequest } catch { return } }
- if (this.disposed || !check()) return
+ if (this.disposed || !this.roadStateKnown || this.isOnroad !== startingOnroad || !check()) return
this.busy = true
this.error = ""
this.notice = ""
@@ -148,7 +167,7 @@ export const PersonalityProfiles = {
} finally { this.busy = false }
if (!this.disposed) await this.load()
},
- migrate() { return this.write(() => api.migratePersonalityProfiles(), () => !this.locked) },
+ migrate() { return this.write(() => api.migratePersonalityProfiles(), () => !this.locked && !this.isOnroad) },
toggle(key, event) {
const value = event.target.checked
event.target.checked = this.enabled(this.values[key])
@@ -179,29 +198,32 @@ export const PersonalityProfiles = {
discard(profile, category) { delete this.drafts[profile + category]; delete this.curveErrors[profile + category] },
async saveCurve(profile, category, reset = false) {
if (this.editingLocked || this.disposed) return
+ const startingOnroad = this.isOnroad
const curve = reset ? [] : this.draft(profile, category)
if (!Array.isArray(curve)) return
const snapshot = [...curve]
this.curvePending = true
try {
if (this.contextPending) { try { await this.contextRequest } catch { return } }
- if (this.disposed) return
- if (await this.write(() => api.savePersonalityProfile({ profile, category, preset: "custom", curve: snapshot, ...(reset ? { reset: true } : {}), expected: this.data.profiles[profile][category] }), () => !this.locked && !this.data?.migration_required)) this.notice = ""
+ if (this.disposed || !this.roadStateKnown || this.isOnroad !== startingOnroad) return
+ if (await this.write(() => api.savePersonalityProfile({ profile, category, preset: "custom", curve: snapshot, ...(reset ? { reset: true } : {}), expected: this.data.profiles[profile][category] }), () => !this.locked && this.isOnroad === startingOnroad && !this.data?.migration_required)) this.notice = ""
} finally {
this.discard(profile, category)
this.curvePending = false
}
},
async setAdvanced(param, raw) {
+ if (!this.roadStateKnown) return
+ const startingOnroad = this.isOnroad
if (this.contextPending) { try { await this.contextRequest } catch { return } }
- if (this.disposed) return
+ if (this.disposed || !this.roadStateKnown || this.isOnroad !== startingOnroad) return
const value = Number(raw)
const { min, max, step } = this.bounds(param)
if (String(raw).trim() === "" || !Number.isFinite(value) || value < min || value > max || Math.abs((value - min) / step - Math.round((value - min) / step)) > 1e-7) {
this.advancedErrors[param.key] = `Enter ${min}–${max}% in increments of ${step}.`; return
}
delete this.advancedErrors[param.key]
- return this.write(() => api.updateParam({ key: param.key, value }), () => !this.paramLocked(param.key))
+ return this.write(() => api.updateParam({ key: param.key, value }), () => !this.paramLocked(param.key) && this.isOnroad === startingOnroad)
},
graphMax(profile, category) {
if (this.drag?.profile === profile && this.drag.category === category) return this.drag.max
@@ -277,10 +299,11 @@ export const PersonalityProfiles = {
Saving…
Loading profiles…
- Active driving personality can be switched on-road. Saved profile tuning is available off-road.
+ Driving state is not confirmed. Editing is temporarily disabled.
+ Changes to the active profile can take effect immediately and alter acceleration, braking, or following behavior. Make adjustments only when it is safe, and stay ready to take control.
Stored profiles need migration before editing.
-
Migrate profiles
+
Migrate profiles
Enable to configure profiles. Existing defaults remain active while off.
diff --git a/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs b/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs
index 93d85da717..101e650af0 100644
--- a/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs
+++ b/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs
@@ -50,7 +50,12 @@ module.exports = async ({page, data, values, writes, errors, output}) => {
await page.reload();
await page.getByRole('button',{name:'Manage',exact:true}).click();
await profile.locator('.gx-personalities__advanced > summary').click();
- for (const button of await profile.getByRole('button',{name:/reset to default/i}).all()) assert(await button.isDisabled());
+ const onroadReset = profile.getByRole('button',{name:/reset to default/i}).first();
+ assert(await onroadReset.isEnabled());
+ const writesBeforeOnroadReset = writes.length;
+ await onroadReset.click();
+ await settled();
+ assert.equal(writes.length, writesBeforeOnroadReset + 1);
assert.deepEqual(errors,[]);
- console.log('PASS: rendered reset controls, retained presets, reload persistence, per-category defaults/reference parity, narrow/wide layout and onroad lock');
+ console.log('PASS: rendered reset controls, retained presets, reload persistence, per-category defaults/reference parity, narrow/wide layout and onroad editing');
};
diff --git a/starpilot/system/the_galaxy/tests/browser/personality_lifecycle.cjs b/starpilot/system/the_galaxy/tests/browser/personality_lifecycle.cjs
index 0d9f67e42f..ed37220dd4 100644
--- a/starpilot/system/the_galaxy/tests/browser/personality_lifecycle.cjs
+++ b/starpilot/system/the_galaxy/tests/browser/personality_lifecycle.cjs
@@ -18,10 +18,10 @@ module.exports=async({page,curve,data,values,faults,counts,errors})=>{
let release=gate('params');await poll();const n=curve.locator('input').nth(2);await n.fill('1.65');assert.equal(await n.inputValue(),'1.65');assert(await n.isEnabled());
let before=counts().attempts;await n.press('Tab');assert.equal(counts().attempts,before);
assert(await n.isDisabled());release();await idle();assert.equal(counts().attempts,before+1);assert.equal(data.profiles.traffic.acceleration.curve[2],1.65);results.push('numeric pending poll commits once');
- // Off-road state changing while the change waits must prevent the PUT.
+ // Road state changing while the change waits must prevent the PUT.
release=gate('params');await poll();before=counts().attempts;await edit('1.7');values.IsOnroad='True';values.IsOffroad='';release();
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
- assert.equal(counts().attempts,before);assert.equal(await n.inputValue(),'1.65');assert(await n.isDisabled());results.push('pending change rechecks offroad');
+ assert.equal(counts().attempts,before);assert.equal(await n.inputValue(),'1.65');assert(await n.isEnabled());results.push('pending change rechecks road state');
values.IsOnroad='';values.IsOffroad='True';await poll();await idle();
// In-flight PUT blocks repeat authoring until verified readback.
release=gate('put');before=counts().attempts;await edit('1.75');await page.waitForFunction(()=>document.querySelector('#app').__vue_app__._instance.proxy.busy);
diff --git a/starpilot/system/the_galaxy/tests/browser/personality_poll.cjs b/starpilot/system/the_galaxy/tests/browser/personality_poll.cjs
index 59c9d7e448..6d08fdfdeb 100644
--- a/starpilot/system/the_galaxy/tests/browser/personality_poll.cjs
+++ b/starpilot/system/the_galaxy/tests/browser/personality_poll.cjs
@@ -27,7 +27,7 @@ module.exports = async ({page, data, values, faults, counts, errors}) => {
// A road-state change during the read must reject the queued click.
release = await gate(); attempts = counts().attempts;
await custom.click(); values.IsOnroad='True'; values.IsOffroad=''; release(); await idle();
- assert.equal(counts().attempts, attempts); assert(await custom.isDisabled());
+ assert.equal(counts().attempts, attempts); assert(await custom.isEnabled());
values.IsOnroad=''; values.IsOffroad='True';
await page.evaluate(async () => await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());
// Multiple clicks waiting for one poll cannot produce overlapping PUTs.
@@ -42,5 +42,5 @@ module.exports = async ({page, data, values, faults, counts, errors}) => {
await page.evaluate(() => document.querySelector('#app').__vue_app__.unmount());
release(); await page.waitForTimeout(100); assert.equal(counts().attempts, attempts);
assert.deepEqual(errors, []);
- console.log('PASS: stable appearance across 3 polls; deferred click saves once; road transition blocks; pending PUT blocks overlaps; unmount cancels.');
+ console.log('PASS: stable appearance across 3 polls; deferred click saves once; road transition cancels queued write; pending PUT blocks overlaps; unmount cancels.');
};
diff --git a/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs b/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs
index 3296a7386c..9d295ba3d9 100644
--- a/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs
+++ b/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs
@@ -123,11 +123,17 @@ const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').t
await page.waitForFunction(()=>document.querySelector('.gx-personalities__advanced .gx-personalities__category input')===null);
assert.equal(await row.getByRole('button',{name:'Chill',exact:true}).getAttribute('aria-pressed'),'true');
values.IsOnroad='True';values.IsOffroad='';
- await page.waitForFunction(()=>document.querySelector('.gx-personalities__options button').disabled);
+ await page.waitForFunction(()=>!document.querySelector('.gx-personalities__options button').disabled);
assert.deepEqual(errors,[]);
values.IsOnroad='';values.IsOffroad='True';
await page.waitForFunction(()=>!document.querySelector('.gx-personalities__options button').disabled);
- console.log('PASS: real API road-state encodings unlock parked controls; Custom graph opens; no Refresh; advanced inputs Custom-only; on-road tuning guard retained.');
+ values.IsOffroad=null;
+ await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());
+ await page.waitForFunction(()=>document.querySelector('.gx-personalities__options button').disabled);
+ values.IsOffroad='True';
+ await page.evaluate(async()=>await document.querySelector('#app').__vue_app__._instance.proxy.refreshContext());
+ await page.waitForFunction(()=>!document.querySelector('.gx-personalities__options button').disabled);
+ console.log('PASS: personality controls remain editable on-road; Custom graph opens; no Refresh; advanced inputs Custom-only.');
assert.deepEqual(errors,[]);
console.log('PASS: four profile cards; no horizontal overflow at 320/390/768/1280; Custom delegates initial curve to backend; no browser errors. Synthetic API only.');
const results=[];
diff --git a/starpilot/system/the_galaxy/tests/test_classic_personality_browser.cjs b/starpilot/system/the_galaxy/tests/test_classic_personality_browser.cjs
index 6b5f14bf77..950df6d5ae 100644
--- a/starpilot/system/the_galaxy/tests/test_classic_personality_browser.cjs
+++ b/starpilot/system/the_galaxy/tests/test_classic_personality_browser.cjs
@@ -27,13 +27,14 @@ window.fetch=async (input, init={}) => {
throw new Error('Unmocked request: '+method+' '+url.pathname);
};
paramsFixture.IsOnroad = ${process.env.GALAXY_DOM_ONROAD === '1'};
+paramsFixture.IsOffroad = ${process.env.GALAXY_DOM_ONROAD !== '1'};
paramsFixture.GalaxyDeveloperMode = true;
const profilesFixture = ${JSON.stringify(JSON.parse(fs.readFileSync(path.join(__dirname,'browser/fixtures/personality_profiles.json'))))};
for (const [id,profile] of Object.entries(profilesFixture.profiles)) {
for (const [category,config] of Object.entries(profile)) {config.preset='custom';config.curve=[...profilesFixture.reference_curves[id][category]];}
}
window.profileWrites=[];
-paramsFixture.IsOffroad=true;paramsFixture.CustomPersonalities=true;
+paramsFixture.CustomPersonalities=true;
for(const key of ['TrafficPersonalityProfile','AggressivePersonalityProfile','StandardPersonalityProfile','RelaxedPersonalityProfile'])paramsFixture[key]=true;
const baseFetch=window.fetch;
window.fetch=async(input,init={})=>{
diff --git a/starpilot/system/the_galaxy/tests/test_device_settings_layout.py b/starpilot/system/the_galaxy/tests/test_device_settings_layout.py
index 9b75998966..e5753b877d 100644
--- a/starpilot/system/the_galaxy/tests/test_device_settings_layout.py
+++ b/starpilot/system/the_galaxy/tests/test_device_settings_layout.py
@@ -89,6 +89,28 @@ def test_galaxy_new_ui_is_the_visible_default_choice():
assert "Galaxy (old)" in galaxy_default["description"]
+def test_driving_personality_controls_are_not_parked_only():
+ params = {
+ param["key"]: param
+ for section in _layout()
+ for param in section.get("params", [])
+ }
+ personality_keys = {
+ "CustomPersonalities",
+ *{f"{profile}PersonalityProfile" for profile in ("Traffic", "Aggressive", "Standard", "Relaxed")},
+ "TrafficFollow", "AggressiveFollow", "AggressiveFollowHigh", "StandardFollow", "StandardFollowHigh",
+ "RelaxedFollow", "RelaxedFollowHigh",
+ *{
+ f"{profile}{suffix}"
+ for profile in ("Traffic", "Aggressive", "Standard", "Relaxed")
+ for suffix in ("JerkAcceleration", "JerkDeceleration", "JerkDanger", "JerkSpeedDecrease", "JerkSpeed")
+ },
+ }
+
+ assert personality_keys <= params.keys()
+ assert all(params[key].get("requires_offroad") is not True for key in personality_keys)
+
+
def test_brake_status_toggle_is_galaxy_only():
setting = _params_by_section(_layout())["Visual (Display & UI)"]["ShowBrakeStatus"]
diff --git a/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py b/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py
index 1dd74b4f27..dd3634e9f0 100644
--- a/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py
+++ b/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py
@@ -62,11 +62,13 @@ def test_malformed_reset_never_writes(monkeypatch, extra):
assert params.writes == []
-def test_reset_obeys_offroad_and_stale_editor_guards(monkeypatch):
+def test_reset_and_stale_editor_guards_allow_onroad_editing(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": True})
payload = {"profile": "standard", "category": "following", "preset": "custom", "curve": [], "reset": True}
- assert client.put("/api/personality_profiles", json=payload).status_code == 403
- assert params.writes == []
+ response = client.put("/api/personality_profiles", json=payload)
+ assert response.status_code == 200
+ assert params.writes
+ params.writes.clear()
params.values.update(IsOnroad=False, IsOffroad=True)
payload["expected"] = {"preset": "custom", "curve": [1.0] * 10}
assert client.put("/api/personality_profiles", json=payload).status_code == 409
diff --git a/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py b/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py
index 8d32297ce7..cede605820 100644
--- a/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py
+++ b/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py
@@ -210,19 +210,25 @@ def test_saved_v2_high_curve_read_migrate_edit_and_master_round_trip(monkeypatch
@pytest.mark.parametrize("state", [{"IsOnroad": True}, {"IsOnroad": False, "IsOffroad": False}])
-def test_saved_v2_high_curve_never_bypasses_parked_write_guard(monkeypatch, state):
+def test_saved_v2_high_curve_is_editable_onroad_but_migration_needs_offroad(monkeypatch, state):
profiles = default_personality_profiles(False)
profiles["aggressive"]["acceleration"] = {"preset": "custom", "curve": [6.0] * 10}
raw = json.dumps(profile_document(profiles, enabled=True))
client, params = _client(monkeypatch, {PERSONALITY_PROFILES_PARAM: raw, **state})
assert client.get("/api/personality_profiles").status_code == 200
- assert client.put("/api/personality_profiles", json={
+ edit = client.put("/api/personality_profiles", json={
"profile": "aggressive", "category": "acceleration", "preset": "custom", "curve": [3.0] + [6.0] * 9,
- }).status_code == 403
+ })
assert client.post("/api/personality_profiles/migrate").status_code == 403
- assert client.put("/api/params", json={"key": "CustomPersonalities", "value": False}).status_code == 403
- assert params.values[PERSONALITY_PROFILES_PARAM] == raw
- assert params.writes == []
+ if state["IsOnroad"]:
+ assert edit.status_code == 200
+ assert client.put("/api/params", json={"key": "CustomPersonalities", "value": False}).status_code == 200
+ assert strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])["profiles"]["aggressive"]["acceleration"]["curve"] == [3.0] + [6.0] * 9
+ else:
+ assert edit.status_code == 403
+ assert client.put("/api/params", json={"key": "CustomPersonalities", "value": False}).status_code == 403
+ assert params.values[PERSONALITY_PROFILES_PARAM] == raw
+ assert params.writes == []
def test_get_returns_disabled_standard_defaults_and_explicit_graph_metadata(monkeypatch):
@@ -528,22 +534,27 @@ def test_api_exposes_and_enforces_requested_acceleration_and_braking_bounds(monk
{"IsOnroad": True, "IsOffroad": False},
{"IsOnroad": False, "IsOffroad": False},
])
-def test_dedicated_and_generic_profile_mutations_require_confirmed_offroad(monkeypatch, device_state):
+def test_dedicated_profile_edits_allow_confirmed_onroad_but_reject_unknown_state(monkeypatch, device_state):
original = profile_document(default_personality_profiles(False), enabled=False)
client, params = _client(monkeypatch, {**device_state, PERSONALITY_PROFILES_PARAM: original})
- before = json.loads(json.dumps(params.values))
dedicated = client.put("/api/personality_profiles", json={
- "profile": "traffic", "category": "acceleration", "preset": "eco", "curve": [1.0] * 7,
+ "profile": "traffic", "category": "acceleration", "preset": "eco", "curve": [1.0] * 10,
})
generic = client.put("/api/params", json={"key": PERSONALITY_PROFILES_PARAM, "value": {"enabled": True}})
- legacy_parent = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
- assert dedicated.status_code == 403
assert generic.status_code == 403
- assert legacy_parent.status_code == 403
- assert "parked" in legacy_parent.get_json()["error"].lower()
- assert params.values == before
+ if device_state["IsOnroad"]:
+ assert dedicated.status_code == 200
+ legacy_parent = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
+ assert legacy_parent.status_code == 200
+ assert params.get_bool("CustomPersonalities") is True
+ else:
+ assert dedicated.status_code == 403
+ legacy_parent = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
+ assert legacy_parent.status_code == 403
+ assert "state" in legacy_parent.get_json()["error"].lower()
+ assert params.values[PERSONALITY_PROFILES_PARAM] == original
def test_generic_profile_mutation_is_also_rejected_while_parked(monkeypatch):
@@ -553,6 +564,41 @@ def test_generic_profile_mutation_is_also_rejected_while_parked(monkeypatch):
assert PERSONALITY_PROFILES_PARAM not in params.values
+def test_dedicated_profile_write_rechecks_road_state_before_commit(monkeypatch):
+ client, params = _client(monkeypatch, {"IsOnroad": True})
+ original_update = the_galaxy.update_personality_profile
+
+ def transition_during_validation(*args, **kwargs):
+ result = original_update(*args, **kwargs)
+ params.values.update(IsOnroad=False, IsOffroad=False)
+ return result
+
+ monkeypatch.setattr(the_galaxy, "update_personality_profile", transition_during_validation)
+ response = client.put("/api/personality_profiles", json={
+ "profile": "standard", "category": "acceleration", "preset": "eco", "curve": [1.0] * 10,
+ })
+
+ assert response.status_code == 403
+ assert params.writes == []
+ assert "state" in response.get_json()["error"].lower()
+
+
+def test_advanced_personality_write_rechecks_road_state_before_commit(monkeypatch):
+ client, params = _client(monkeypatch, {"IsOnroad": True})
+ checks = 0
+
+ def transition_after_validation():
+ nonlocal checks
+ checks += 1
+ return checks > 1
+
+ monkeypatch.setattr(the_galaxy, "_personality_editor_write_locked", transition_after_validation)
+ response = client.put("/api/params", json={"key": "StandardJerkAcceleration", "value": 75})
+
+ assert response.status_code == 403
+ assert params.writes == []
+
+
def test_dedicated_enable_mutation_is_rejected(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False})
response = client.put("/api/personality_profiles", json={"enabled": True})
@@ -604,7 +650,7 @@ def test_master_toggle_writes_in_fail_closed_order(monkeypatch, enabled, expecte
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("road_change", [{"IsOnroad": True}, {"IsOffroad": False}])
-def test_master_toggle_rechecks_parked_state_after_waiting_for_profile_lock(monkeypatch, enabled, road_change):
+def test_master_toggle_rechecks_confirmed_road_state_after_waiting_for_profile_lock(monkeypatch, enabled, road_change):
original = profile_document(default_personality_profiles(False), enabled=not enabled)
client, params = _client(monkeypatch, {
"CustomPersonalities": not enabled, PERSONALITY_PROFILES_PARAM: original,
@@ -651,7 +697,11 @@ def test_profile_document_write_failure_never_enables_master(monkeypatch):
def test_unverified_profile_document_write_never_enables_master(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": False, "CustomPersonalities": False})
- monkeypatch.setattr(the_galaxy, "_safe_params_get_live_raw", lambda key, default=None, block=False: None)
+ monkeypatch.setattr(
+ the_galaxy,
+ "_safe_params_get_live_raw",
+ lambda key, default=None, block=False: None if key == PERSONALITY_PROFILES_PARAM else params.values.get(key, default),
+ )
response = client.put("/api/params", json={"key": "CustomPersonalities", "value": True})
@@ -683,19 +733,27 @@ def test_master_write_failure_leaves_master_false_after_verified_document_write(
assert document is not None and document["enabled"] is True
-def test_every_state_affecting_personality_write_is_rejected_onroad(monkeypatch):
+def test_every_state_affecting_personality_write_is_allowed_onroad(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": True})
- before = json.loads(json.dumps(params.values))
for key in PERSONALITY_PARKED_PARAM_KEYS:
- value = False if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS or key == "CustomPersonalities" else 50
+ value = (False if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS or key == "CustomPersonalities"
+ else 1.25 if key in PERSONALITY_FOLLOW_PARAM_KEYS else 50)
response = client.put("/api/params", json={"key": key, "value": value})
- assert response.status_code == 403, key
- assert params.values == before
+ assert response.status_code == 200, key
+ if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS or key == "CustomPersonalities":
+ assert params.get_bool(key) is value, key
+ else:
+ assert float(params.values[key]) == value, key
-def test_every_state_affecting_personality_write_is_rejected_until_offroad_is_confirmed(monkeypatch):
- client, params = _client(monkeypatch, {"IsOnroad": False, "IsOffroad": False})
+@pytest.mark.parametrize("device_state", [
+ {"IsOnroad": False, "IsOffroad": False},
+ {"IsOnroad": False, "IsOffroad": None},
+ {"IsOnroad": True, "IsOffroad": None},
+])
+def test_every_state_affecting_personality_write_is_rejected_when_road_state_is_unconfirmed(monkeypatch, device_state):
+ client, params = _client(monkeypatch, device_state)
before = json.loads(json.dumps(params.values))
for key in PERSONALITY_PARKED_PARAM_KEYS:
diff --git a/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py b/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py
index 46fc1eeda7..acc95d7673 100644
--- a/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py
+++ b/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py
@@ -124,10 +124,10 @@ def test_drag_on_expanded_saved_curve_uses_plot_scale_but_caps_only_edited_point
assert result["original"] == [6, 4] + [1] * 8
-def test_rendered_editor_has_parked_locks_units_and_all_three_profile_categories():
+def test_rendered_editor_has_confirmed_road_state_locks_units_and_all_three_profile_categories():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
- assert 'disabled="${() => !!state.values.IsOnroad' in source
- assert 'aria-disabled="${() => !!state.values.IsOnroad || !!state.personalityMigrationRequired}"' in source
+ assert 'disabled="${() => !personalityRoadStateKnown()' in source
+ assert 'aria-disabled="${() => !personalityRoadStateKnown() || !!state.personalityMigrationRequired}"' in source
assert "profileSpeedUnit" in source
assert "m/s²" in source
for category in ("acceleration", "braking", "following"):
@@ -144,7 +144,7 @@ def test_acceleration_and_braking_presets_render_from_weakest_to_strongest():
assert 'braking: ["eco", "standard", "sport", "custom"]' in source
-def test_profile_master_and_advanced_controls_declare_parked_only_metadata():
+def test_profile_master_and_advanced_controls_are_editable_onroad():
layout = json.loads(DEVICE_SETTINGS_LAYOUT_PATH.read_text(encoding="utf-8"))
params = {param["key"]: param for section in layout for param in section.get("params", [])}
keys = {
@@ -163,7 +163,7 @@ def test_profile_master_and_advanced_controls_declare_parked_only_metadata():
for suffix in ("JerkAcceleration", "JerkDeceleration", "JerkDanger", "JerkSpeedDecrease", "JerkSpeed")
},
}
- assert all(params[key].get("requires_offroad") is True for key in keys)
+ assert all(params[key].get("requires_offroad") is not True for key in keys)
def test_profile_errors_are_escaped_before_the_legacy_html_snackbar_sink():
@@ -203,8 +203,9 @@ def test_each_personality_card_maps_to_its_persisted_enable_toggle():
]
-def test_each_personality_card_exposes_an_accessible_parked_only_enable_toggle():
+def test_each_personality_card_exposes_an_accessible_enable_toggle():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
+ assert "PERSONALITY_EDITOR_PARAM_KEYS.has(param?.key) && !personalityRoadStateKnown()" in source
assert "function renderPersonalityProfileToggle" in source
toggle = source.split("function renderPersonalityProfileToggle", 1)[1].split("\n}", 1)[0]
assert "personalityProfileParamKey(profile.id)" in toggle
@@ -445,7 +446,7 @@ def test_schema_migration_state_is_visible_and_blocks_profile_writes():
assert "Migrate profiles" in source
migration_warning = source.split('class="ds-personality-migration-warning"', 1)[1].split("
", 1)[0]
assert '!state.values.IsOffroad' not in migration_warning
- assert '!!state.values.IsOnroad || state.personalityMigrationInProgress' in migration_warning
+ assert 'parseRoadFlag(state.values.IsOnroad) === true || !personalityRoadStateKnown() || state.personalityMigrationInProgress' in source
css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8")
assert ".ds-personality-migration-warning" in css
@@ -487,7 +488,7 @@ def test_device_settings_polls_driving_state_and_units_while_visible():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
assert "function ensureUiContextPolling" in source
refresh = source.split("async function refreshUiContextValues", 1)[1].split("\n}", 1)[0]
- assert '["IsOnroad", "IsMetric"]' in refresh
+ assert '["IsOnroad", "IsOffroad", "IsMetric"]' in refresh
assert '`/api/params?key=${encodeURIComponent(key)}`' in refresh
polling = source.split("function ensureUiContextPolling", 1)[1].split("\n}", 1)[0]
assert 'document.visibilityState === "visible"' in polling
@@ -616,16 +617,25 @@ def test_personality_responsive_layout_uses_available_card_width():
assert 'context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)' in source
-def test_personality_save_blocks_onroad_even_for_synthetic_events():
+def test_personality_save_allows_confirmed_onroad_and_blocks_unknown_state():
source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8")
+ road_state = "function parseRoadFlag" + source.split("function parseRoadFlag", 1)[1].split("\n}\n", 1)[0] + "\n}"
+ known = "function personalityRoadStateKnown" + source.split("function personalityRoadStateKnown", 1)[1].split("\n}\n", 1)[0] + "\n}"
save = "async function savePersonalityCategory" + source.split("async function savePersonalityCategory", 1)[1].split("\n}\n", 1)[0] + "\n}"
result = _run_node("""
- const state = {values:{IsOnroad:true}};
- const fetch = () => {throw new Error("On-road write attempted")};
- """ + save + """
- console.log(JSON.stringify(await savePersonalityCategory("standard", "acceleration", "eco", [])));
+ const state = {values:{IsOnroad:true,IsOffroad:false},personalityProfiles:{standard:{acceleration:{preset:"standard",curve:[]}}},personalityUpdating:{},personalityProfilesError:"",personalityProfilesLoading:false,personalityMigrationRequired:false};
+ const window = {location:{pathname:"/device_settings"}};
+ let writes = 0;
+ const fetch = async () => { writes++; return {ok:true,json:async()=>({profiles:{standard:{acceleration:{preset:"eco",curve:[]}}}})}; };
+ let uiContextPollInflight = null, personalityViewGeneration = 0;
+ const showParamSnackbar = () => {};
+ """ + road_state + known + save + """
+ const onroad = await savePersonalityCategory("standard", "acceleration", "eco", []);
+ state.values.IsOffroad = "unknown";
+ const uncertain = await savePersonalityCategory("standard", "acceleration", "eco", []);
+ console.log(JSON.stringify({onroad,uncertain,writes}));
""")
- assert result is False
+ assert result == {"onroad": True, "uncertain": False, "writes": 1}
def test_responsive_canvas_keeps_metric_endpoint_labels_separate_and_scales_bitmap():
diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py
index cc152913da..aff105e91d 100644
--- a/starpilot/system/the_galaxy/the_galaxy.py
+++ b/starpilot/system/the_galaxy/the_galaxy.py
@@ -3562,6 +3562,27 @@ def _safe_params_get_bool(key, default=False):
def _personality_settings_write_locked():
return _safe_params_get_bool("IsOnroad", default=True) or not _safe_params_get_bool("IsOffroad", default=False)
+def _personality_editor_write_locked():
+ def road_state(value):
+ if isinstance(value, bytes):
+ value = value.decode("utf-8", errors="replace")
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in ("1", "true"):
+ return True
+ if normalized in ("0", "false"):
+ return False
+ return None
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, int) and value in (0, 1):
+ return bool(value)
+ return None
+
+ is_onroad = road_state(_safe_params_get_live_raw("IsOnroad"))
+ is_offroad = road_state(_safe_params_get_live_raw("IsOffroad"))
+ return is_onroad is None or is_offroad is None or is_onroad == is_offroad
+
def _normalize_vasm_config(data):
if not isinstance(data, dict):
raise ValueError("Configuration must be a JSON object.")
@@ -6040,8 +6061,8 @@ def setup(app):
profiles = stored_document["profiles"] if configured else default_personality_profiles(ev_tuning, truck_tuning)
if request.method == "PUT":
- if _personality_settings_write_locked():
- return jsonify({"error": "Longitudinal personality profiles can only be changed while off-road."}), 403
+ if _personality_editor_write_locked():
+ return jsonify({"error": "Driving state is unavailable or inconsistent. Refresh before editing personalities."}), 403
if current_document is None and stored_document is not None:
return jsonify({"error": "Stored longitudinal personality profiles require a verified migration before editing."}), 409
data = request.get_json(silent=True)
@@ -6088,6 +6109,8 @@ def setup(app):
except (KeyError, TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
+ if _personality_editor_write_locked():
+ return jsonify({"error": "Driving state is unavailable or inconsistent. Refresh before editing personalities."}), 403
params.put(PERSONALITY_PROFILES_PARAM, profile_document(profiles, enabled=enabled))
configured = True
migration_required = False
@@ -6142,8 +6165,8 @@ def setup(app):
key = str(data["key"]).strip()
if key.lower() == PERSONALITY_PROFILES_PARAM.lower():
return jsonify({"error": "Longitudinal personality profiles must be changed with the Driving Personalities editor."}), 403
- if key in PERSONALITY_PARKED_PARAM_KEYS and _personality_settings_write_locked():
- return jsonify({"error": "Driving personality settings can only be changed while parked."}), 403
+ if key in PERSONALITY_PARKED_PARAM_KEYS and _personality_editor_write_locked():
+ return jsonify({"error": "Driving state is unavailable or inconsistent. Refresh before editing personalities."}), 403
if key in PERSONALITY_PROFILE_ENABLE_PARAM_KEYS and type(data["value"]) is not bool:
return jsonify({"error": f"{key} must be a JSON boolean."}), 400
if key in LONGITUDINAL_MODE_KEYS:
@@ -6235,8 +6258,8 @@ def setup(app):
return jsonify({"error": "CustomPersonalities must be a JSON boolean."}), 400
enabled = data["value"]
with _PERSONALITY_PROFILES_WRITE_LOCK:
- if _personality_settings_write_locked():
- return jsonify({"error": "Driving personality settings can only be changed while parked."}), 403
+ if _personality_editor_write_locked():
+ return jsonify({"error": "Driving state is unavailable or inconsistent. Refresh before editing personalities."}), 403
ev_tuning = _get_detected_ev_tuning()
truck_tuning = (_get_detected_truck_tuning() or params.get_bool("TruckTuning")) and not ev_tuning
raw_document = _safe_params_get_live_raw(PERSONALITY_PROFILES_PARAM)
@@ -6245,6 +6268,8 @@ def setup(app):
document = synchronise_profile_document_enabled(
raw_document, enabled, ev_tuning, truck_tuning,
)
+ if _personality_editor_write_locked():
+ return jsonify({"error": "Driving state is unavailable or inconsistent. Refresh before editing personalities."}), 403
updated = {"CustomPersonalities": enabled}
if enabled:
if document is None:
@@ -6393,6 +6418,9 @@ def setup(app):
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 409
+ if key in PERSONALITY_PARKED_PARAM_KEYS and _personality_editor_write_locked():
+ return jsonify({"error": "Driving state is unavailable or inconsistent. Refresh before editing personalities."}), 403
+
if key in {"LeadIndicator", "HideLeadMarker"}:
enabled = str_val.strip() in ("1", "true", "True")
if key == "LeadIndicator":