This commit is contained in:
firestar5683
2026-09-14 15:41:25 -05:00
parent 65c8581db3
commit c3e4ec630f
14 changed files with 282 additions and 119 deletions
@@ -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",
@@ -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
@@ -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) {
<h4>Custom ${definition.label}</h4>
</div>
<div class="ds-personality-curve-actions">
<button type="button" class="ds-reset-btn" aria-label="Reset ${profile.label} ${definition.label} graph to Dom default" disabled="${() => !!state.values.IsOnroad || !!state.personalityMigrationRequired || !!state.personalityUpdating[updateKey]}" @click="${() => resetPersonalityCurve(profile.id, category)}">Reset</button>
<button type="button" class="ds-reset-btn" aria-label="Reset ${profile.label} ${definition.label} graph to Dom default" disabled="${() => !personalityRoadStateKnown() || !!state.personalityMigrationRequired || !!state.personalityUpdating[updateKey]}" @click="${() => resetPersonalityCurve(profile.id, category)}">Reset</button>
</div>
</div>
${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) }}"></canvas>
aria-disabled="${() => !personalityRoadStateKnown() || !!state.personalityMigrationRequired}"
@pointerdown="${event => { if (personalityRoadStateKnown() && !state.personalityMigrationRequired) beginPersonalityCurveDrag(event, profile.id, category) }}"></canvas>
<div class="ds-personality-values">
${config.curve.map((value, index) => html`
<label class="ds-personality-value">
@@ -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)}" />
<b id="personality-value-${profile.id}-${category}-${index}">${Number(value).toFixed(2)} ${definition.valueUnit}</b>
</label>
@@ -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)}
</button>
@@ -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}</button>
`)}
</div>
@@ -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)}" />
<span>${bounds.min}${bounds.max}</span>
</label>
@@ -2305,10 +2343,12 @@ function renderPersonalityProfilesPanel() {
if (!state.personalityMeta) return html`<div class="ds-personality-error" role="alert" aria-live="assertive">Driving personalities could not be loaded. Refresh the page to retry.</div>`
return html`
<div class="ds-personality-profiles" id="personality-profiles-panel">
${() => !personalityRoadStateKnown() ? html`<div class="ds-personality-migration-warning" role="status">Driving state is not confirmed. Personality editing is temporarily disabled.</div>` : ""}
${() => parseRoadFlag(state.values.IsOnroad) === true && personalityRoadStateKnown() ? html`<div class="ds-personality-migration-warning" role="note">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.</div>` : ""}
${() => state.personalityMigrationRequired ? html`
<div class="ds-personality-migration-warning" role="alert" aria-live="assertive">
<span>This profile data requires a verified migration before it can be edited.</span>
<button type="button" class="ds-reset-btn" disabled="${() => !!state.values.IsOnroad || state.personalityMigrationInProgress}" @click="${migratePersonalityProfiles}">
<button type="button" class="ds-reset-btn" disabled="${() => parseRoadFlag(state.values.IsOnroad) === true || !personalityRoadStateKnown() || state.personalityMigrationInProgress}" @click="${migratePersonalityProfiles}">
${() => state.personalityMigrationInProgress ? "Migrating..." : "Migrate profiles"}
</button>
</div>
@@ -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 = {
<p v-if="busy" role="status" class="gx-personalities__live">Saving…</p>
<p v-if="!data && !error" role="status">Loading profiles…</p>
<template v-if="data">
<p v-if="!offroad" role="note">Active driving personality can be switched on-road. Saved profile tuning is available off-road.</p>
<p v-if="!roadStateKnown" role="note">Driving state is not confirmed. Editing is temporarily disabled.</p>
<p v-else-if="isOnroad" role="note">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.</p>
<div v-if="data.migration_required" role="alert" class="gx-personalities__error">
<p>Stored profiles need migration before editing.</p>
<button type="button" class="gx-btn" :disabled="locked" @click="migrate">Migrate profiles</button>
<button type="button" class="gx-btn" :disabled="locked || isOnroad" @click="migrate">Migrate profiles</button>
</div>
<p v-if="!enabled(values.CustomPersonalities)">Enable to configure profiles. Existing defaults remain active while off.</p>
<div class="gx-personalities__grid">
@@ -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');
};
@@ -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);
@@ -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.');
};
@@ -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=[];
@@ -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={})=>{
@@ -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"]
@@ -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
@@ -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:
@@ -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("</div>", 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():
+34 -6
View File
@@ -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":