Galaxy: unify longitudinal control modes

Co-authored-by: AngusBell97 <124716116+AngusBell97@users.noreply.github.com>
This commit is contained in:
AngusBell97
2026-09-09 16:26:07 -05:00
committed by firestar5683
parent a19beda327
commit c2921c1a8f
27 changed files with 1789 additions and 48 deletions
@@ -0,0 +1,117 @@
"""Exact class and planner branch, fake clocks/scene/Params; no native runtime."""
import ast
from pathlib import Path
from types import SimpleNamespace as NS
import pytest
ROOT = Path(__file__).resolve().parents[3]
def make_modes():
now = [100.0]
statuses = {'OFF': 0, 'LEAD': 1, 'SPEED': 2, 'USER_EXPERIMENTAL': 99, 'USER_OVERRIDDEN': 99}
memory = {}
params = NS(get_bool=lambda _: False)
planner = NS(params=params, params_memory=NS(put_int=lambda k,v: memory.update({k:v})))
ns = {'time': NS(monotonic=lambda: now[0]), 'CV': NS(MPH_TO_MS=0.44704), 'CCStatus': statuses, 'CEStatus': statuses,
'restore_persisted_cc_state': lambda *_: memory.get('manual', 0), 'restore_persisted_ce_state': lambda *_: memory.get('manual', 0),
'is_manual_cc_status': lambda s: s == 99, 'is_manual_ce_status': lambda s: s == 99,
'FirstOrderFilter': lambda *args: NS(x=0), 'DT_MDL': .05}
for file, name in [('conditional_chill_mode.py','ConditionalChillMode'), ('conditional_experimental_mode.py','ConditionalExperimentalMode')]:
path = ROOT/'starpilot/controls/lib'/file
cls = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.ClassDef) and n.name == name)
exec(compile(ast.Module(body=[cls], type_ignores=[]), str(path), 'exec'), ns)
cem = ns['ConditionalExperimentalMode'](planner)
ccm = ns['ConditionalChillMode'](planner, cem)
planner.starpilot_cem, planner.starpilot_ccm = cem, ccm
ccm._refresh_detector = lambda *_: None
ccm._get_chill_status = lambda *_: (1, False)
ccm._has_hard_veto = lambda *a, **k: False
cem.update_conditions = lambda *_: None
cem.check_conditions = lambda *_: False
cem.stop_sign_and_light = lambda *_: None
return planner, now, memory
def branch(planner, mode):
path = ROOT/'starpilot/controls/starpilot_planner.py'
node = next(n for n in ast.walk(ast.parse(path.read_text())) if isinstance(n, ast.If) and ast.unparse(n.test).startswith('conditional_tracking_active and'))
exec(compile(ast.Module(body=[node],type_ignores=[]), str(path), 'exec'),
{'self': planner, 'conditional_tracking_active': True, 'starpilot_toggles': NS(conditional_experimental_mode=mode=='cem', conditional_chill_mode=mode=='ccm'), 'v_ego':20, 'v_cruise':30, 'sm':{}, 'PLANNER_TIME':10})
@pytest.mark.parametrize('absence', [.1, 100])
@pytest.mark.parametrize('other', ['fixed', 'cem'])
def test_ccm_reentry_requires_new_confirmation(absence, other):
p, now, _ = make_modes()
p.starpilot_ccm.update(20,30,{},NS())
original_update = p.starpilot_cem.update
p.starpilot_cem.update = lambda *_: None
branch(p, other)
p.starpilot_cem.update = original_update
now[0] += absence
p.starpilot_ccm.update(20,30,{},NS())
assert p.starpilot_ccm.experimental_mode
assert p.starpilot_ccm._candidate_since == now[0]
now[0] += 1.01
p.starpilot_ccm.update(20,30,{},NS())
assert not p.starpilot_ccm.experimental_mode
@pytest.mark.parametrize('other', ['fixed', 'ccm'])
def test_cem_reentry_does_not_inherit_mode_hold(other):
p, now, _ = make_modes()
cem = p.starpilot_cem
cem.prev_experimental_mode = True
cem.mode_hold_until = now[0] + .5
cem.slow_lead_mode_hold_until = now[0] + 1.5
original_update = p.starpilot_ccm.update
p.starpilot_ccm.update = lambda *_: None
branch(p, other)
p.starpilot_ccm.update = original_update
now[0] += .1
cem.update(20, {'carState': NS(standstill=False)}, NS(conditional_lead=False,conditional_open_road=False))
assert not cem.experimental_mode
@pytest.mark.parametrize('other', ['fixed', 'cem'])
def test_ccm_manual_override_survives_deactivation(other):
p, _, memory = make_modes()
memory['manual'] = 99
p.starpilot_cem.update = lambda *_: None
branch(p, other)
p.starpilot_ccm.update(20,30,{},NS())
assert p.starpilot_ccm.experimental_mode
assert memory['manual'] == 99
def test_cem_deactivation_retains_shared_hazard_detector_state():
p, _, _ = make_modes()
cem = p.starpilot_cem
cem.stop_light_detected = True
cem.stop_light_filter.x = .9
cem.standstill_stop_reason = 'sign'
branch(p, 'fixed')
assert cem.stop_light_detected and cem.stop_light_filter.x == .9
assert cem.standstill_stop_reason == 'sign'
@pytest.mark.parametrize('condition', ['none', 'veto', 'safe'])
def test_ccm_reentry_still_honors_current_scene_and_safety(condition):
p, now, _ = make_modes()
ccm = p.starpilot_ccm
ccm.update(20,30,{},NS())
branch(p, 'fixed')
now[0] += 100
if condition == 'none':
ccm._get_chill_status = lambda *_: (0, False)
elif condition == 'veto':
ccm._has_hard_veto = lambda *a, **k: True
else:
p.params.get_bool = lambda key: key == 'SafeMode'
ccm.update(20,30,{},NS())
assert ccm.experimental_mode == (condition != 'safe')
assert ccm._candidate_since == 0
def test_cem_manual_override_survives_inactive_interval():
p, _, memory = make_modes()
memory['manual'] = 99
branch(p, 'fixed')
p.starpilot_cem.update(20, {'carState': NS(standstill=False)}, NS())
assert p.starpilot_cem.experimental_mode
assert memory['manual'] == 99
+12 -8
View File
@@ -35,6 +35,7 @@ from openpilot.system.hardware import HARDWARE
from openpilot.starpilot.common.starpilot_utilities import contains_event_type
from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles
from openpilot.starpilot.common.lateral_only_experimental import experimental_mode_available
from openpilot.starpilot.common.longitudinal_mode import request_mode_refresh
from openpilot.starpilot.common.vision_bsm import get_fresh_vasm_state
from openpilot.starpilot.system.wheel_controls import (
CONTROLLER_ACTION_COUNTERS,
@@ -845,10 +846,10 @@ class SelfdriveD:
self.starpilot_events.add_from_msg(self.sm['starpilotPlan'].starpilotEvents)
if self.starpilot_toggles.conditional_experimental_mode or getattr(self.starpilot_toggles, "conditional_chill_mode", False):
self.experimental_mode = self.sm['starpilotPlan'].experimentalMode
else:
self.experimental_mode |= self.sm['starpilotPlan'].experimentalMode
self.experimental_mode = (not self.safe_mode and experimental_mode_available(self.CP) and (
self.sm['starpilotPlan'].experimentalMode if not REPLAY or self.starpilot_toggles.conditional_experimental_mode
or getattr(self.starpilot_toggles, "conditional_chill_mode", False)
else self.experimental_mode or self.sm['starpilotPlan'].experimentalMode))
def data_sample(self):
_car_state = messaging.recv_one(self.car_state_sock)
@@ -995,10 +996,13 @@ class SelfdriveD:
self.is_metric = self.params.get_bool("IsMetric")
self.is_ldw_enabled = self.params.get_bool("IsLdwEnabled")
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
if self.safe_mode:
self.experimental_mode = False
elif not self.starpilot_toggles.conditional_experimental_mode:
self.experimental_mode = self.params.get_bool("ExperimentalMode") and experimental_mode_available(self.CP)
if REPLAY:
if self.safe_mode:
self.experimental_mode = False
elif not self.starpilot_toggles.conditional_experimental_mode:
self.experimental_mode = self.params.get_bool("ExperimentalMode") and experimental_mode_available(self.CP)
else:
request_mode_refresh(self.params, self.params_memory, self.starpilot_toggles)
self.personality = log.LongitudinalPersonality.relaxed if self.safe_mode else self.params.get("LongitudinalPersonality", return_default=True)
time.sleep(0.1)
+39
View File
@@ -0,0 +1,39 @@
"""Coherent mode Params reads for participating Python writers/readers.
The sidecar lock is outside the Params key directory (no registry addition).
Never unlink/replace it while processes are running. All locks are nonblocking:
background refreshers retry and keep the last complete toggle object on failure.
Legacy Dom writers are deliberately not excluded; their settled values remain
visible, but advisory locking cannot make their multi-key writes atomic.
"""
from contextlib import contextmanager
import fcntl
import os
from pathlib import Path
MODE_KEYS = ("ExperimentalMode", "ConditionalChill", "ConditionalExperimental")
@contextmanager
def mode_lock(params, *, exclusive=False):
directory = Path(params.get_param_path()).parent
fd = os.open(directory / ".longitudinal_mode.lock", os.O_CREAT | os.O_RDWR | os.O_CLOEXEC, 0o660)
try:
fcntl.flock(fd, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def read_mode_values(params):
with mode_lock(params):
return {key: params.get_bool(key) for key in MODE_KEYS}
def request_mode_refresh(params, params_memory, toggles):
try:
values = read_mode_values(params)
except OSError:
return
if values != getattr(toggles, "longitudinal_mode_values", None):
params_memory.put_bool("StarPilotTogglesUpdated", True)
+13 -2
View File
@@ -31,6 +31,7 @@ from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.starpilot.common.model_versions import is_tinygrad_model_version
from openpilot.starpilot.common.lateral_delay import full_lateral_delay
from openpilot.starpilot.common.lateral_only_experimental import lateral_only_experimental_available
from openpilot.starpilot.common.longitudinal_mode import read_mode_values
from openpilot.starpilot.common.accel_profile import (
ACCELERATION_PROFILES,
A_CRUISE_MAX_BP_CUSTOM,
@@ -623,6 +624,14 @@ class StarPilotVariables:
def update(self, holiday_theme="stock", started=False, clear_update_flag=True):
toggle = self.starpilot_toggles
try:
mode_values = read_mode_values(self.params)
except OSError:
self.params_memory.put_bool("StarPilotTogglesUpdated", True)
if hasattr(toggle, "longitudinal_mode_values"):
return
mode_values = {"ExperimentalMode": False, "ConditionalChill": False, "ConditionalExperimental": False}
clear_update_flag = False
# CarParams uses this value to select the matching Panda safety configuration.
toggle.tesla_cooperative_steering = self.params.get_bool("TeslaCoopSteering")
toggle.rivian_angle_control = self.params.get_bool("RivianAngleControl")
@@ -873,8 +882,10 @@ class StarPilotVariables:
self.migrate_prius_cluster_offset(str(toggle.car_model))
toggle.cluster_offset = self.get_value("ClusterOffset", cast=float, condition=toggle.car_make == "toyota")
toggle.conditional_experimental_mode = toggle.openpilot_longitudinal and self.get_value("ConditionalExperimental")
toggle.conditional_chill_mode = toggle.openpilot_longitudinal and not toggle.conditional_experimental_mode and self.get_value("ConditionalChill")
toggle.longitudinal_mode_values = mode_values
toggle.experimental_mode = toggle.experimental_mode_available and not toggle.safe_mode and mode_values["ExperimentalMode"]
toggle.conditional_experimental_mode = toggle.openpilot_longitudinal and not toggle.safe_mode and mode_values["ConditionalExperimental"]
toggle.conditional_chill_mode = toggle.openpilot_longitudinal and not toggle.safe_mode and not toggle.conditional_experimental_mode and mode_values["ConditionalChill"]
toggle.conditional_curves = self.get_value("CECurves", condition=toggle.conditional_experimental_mode)
toggle.conditional_curves_lead = self.get_value("CECurvesLead", condition=toggle.conditional_curves)
toggle.conditional_lead = self.get_value("CELead", condition=toggle.conditional_experimental_mode)
@@ -138,6 +138,11 @@ class ConditionalChillMode:
self._write_status(self.status_value if not self.experimental_mode else CCStatus["OFF"])
def deactivate(self):
self._reset_timers()
self.experimental_mode = True
self._prev_cc_status = None
def _reset_timers(self):
self._active_auto_status = CCStatus["OFF"]
self._candidate_since = 0.0
@@ -127,6 +127,17 @@ class ConditionalExperimentalMode:
self.prev_open_road_triggered = False
self.open_road_lead_hold_until = 0.0
def deactivate(self):
self.experimental_mode = False
self.prev_experimental_mode = False
self.mode_hold_until = 0.0
self.mode_false_since = 0.0
self.slow_lead_mode_hold_until = 0.0
self.open_road_triggered = False
self.prev_open_road_triggered = False
self.open_road_lead_hold_until = 0.0
self._prev_ce_status = None
def update(self, v_ego, sm, starpilot_toggles, v_cruise=None):
now = time.monotonic()
standstill = bool(sm["carState"].standstill)
+5 -5
View File
@@ -227,13 +227,13 @@ class StarPilotPlanner:
if conditional_tracking_active and bool(getattr(starpilot_toggles, "conditional_experimental_mode", False)):
# Keep CEM's filters warm in AOL so engagement can inherit the current scene.
self.starpilot_cem.update(v_ego, sm, starpilot_toggles, v_cruise)
self.starpilot_ccm.experimental_mode = True
self.starpilot_ccm.deactivate()
elif conditional_tracking_active and bool(getattr(starpilot_toggles, "conditional_chill_mode", False)):
self.starpilot_ccm.update(v_ego, v_cruise, sm, starpilot_toggles)
self.starpilot_cem.experimental_mode = False
self.starpilot_cem.deactivate()
else:
self.starpilot_ccm.experimental_mode = True
self.starpilot_cem.experimental_mode = False
self.starpilot_ccm.deactivate()
self.starpilot_cem.deactivate()
self.starpilot_cem.curve_detected = False
self.starpilot_cem.stop_sign_and_light(v_ego, sm, PLANNER_TIME - 2)
@@ -341,7 +341,7 @@ class StarPilotPlanner:
starpilotPlan.pulseGlideCoasting = self.starpilot_acceleration.pulse_glide_coasting
starpilotPlan.trackingLead = self.tracking_lead
conditional_experimental_mode = False
conditional_experimental_mode = bool(getattr(starpilot_toggles, "experimental_mode", False))
if starpilot_toggles.conditional_experimental_mode:
conditional_experimental_mode = self.starpilot_cem.experimental_mode
elif starpilot_toggles.conditional_chill_mode:
+1
View File
@@ -256,6 +256,7 @@ def update_toggles_in_background(result, starpilot_variables, started, theme_man
result["update"] = (updated_variables, updated_toggles)
except Exception:
result["failed"] = True
starpilot_variables.params_memory.put_bool("StarPilotTogglesUpdated", True)
raise
@@ -8,6 +8,8 @@ import {
} from "/assets/components/tools/personality_profiles.mjs"
import { formatNumericParamValue, resolveVehicleUnitParam, vehicleSpeedUnit } from "/assets/mobile/js/params.js"
import { LONGITUDINAL_MODE_KEY, longitudinalModeLayout, validLongitudinalSnapshot } from "/assets/components/tools/longitudinal_mode.mjs"
const endpointOptionsCache = {}
const endpointOptionsInflight = {}
const COLOR_UI_DEFAULTS = {
@@ -125,6 +127,9 @@ const FLM_ADVANCED_LATERAL_KEYS = new Set([
// Module-level state (persists across route changes)
const state = reactive({
longitudinalMode: null,
longitudinalModeUpdating: false,
longitudinalModeRequestId: 0,
layout: [],
allKeys: [],
paramMetaByKey: {},
@@ -192,6 +197,7 @@ function matchesSettingValueCondition(param) {
}
function isSettingVisible(section, param) {
if (param.longitudinal_mode && param.longitudinal_mode !== state.longitudinalMode?.mode) return false
if (PROFILE_HIDDEN_LAYOUT_KEYS.has(param.key) || HIDDEN_SETTING_KEYS.has(param.key) ||
!isVehicleSettingVisible(section, param) || !matchesSettingValueCondition(param)) return false
if (param.requires_capability && !state.values[param.requires_capability]) return false
@@ -239,6 +245,7 @@ function isParamEnabledForChildren(paramOrKey) {
if (isGroupParam(param)) return true
const key = isKey ? paramOrKey : (param && param.key)
if (key === LONGITUDINAL_MODE_KEY) return ["conditional_experimental", "conditional_chill"].includes(state.longitudinalMode?.mode)
return !!(key && state.values[key])
}
@@ -439,6 +446,62 @@ function syncInputs() {
}
}
function applyLongitudinalMode(data) {
if (!validLongitudinalSnapshot(data)) throw new Error("Longitudinal mode state is unavailable. Refresh to retry.")
if (JSON.stringify(state.longitudinalMode) === JSON.stringify(data) && state.values[LONGITUDINAL_MODE_KEY] === data.mode &&
Object.entries(data.values).every(([key, value]) => state.values[key] === value)) return
state.longitudinalMode = data
state.values = { ...state.values, ...data.values, [LONGITUDINAL_MODE_KEY]: data.mode }
scheduleSyncInputs()
}
async function fetchLongitudinalMode(force = false) {
if (state.longitudinalModeUpdating && !force) return
const requestId = ++state.longitudinalModeRequestId
try {
const response = await fetch("/api/longitudinal_mode", { cache: "no-store" })
if (!response.ok) throw new Error("Longitudinal mode unavailable")
const data = await response.json()
if (requestId === state.longitudinalModeRequestId && (!state.longitudinalModeUpdating || force)) applyLongitudinalMode(data)
} catch (_error) {
if (requestId !== state.longitudinalModeRequestId || (state.longitudinalModeUpdating && !force)) return
state.longitudinalMode = null
state.values = { ...state.values, [LONGITUDINAL_MODE_KEY]: "" }
scheduleSyncInputs()
}
}
async function updateLongitudinalMode(targetOverride = null) {
const el = document.getElementById(`ds-${LONGITUDINAL_MODE_KEY}`)
if ((!el && !targetOverride) || getSettingLockReason({ key: LONGITUDINAL_MODE_KEY })) {
scheduleSyncInputs()
return
}
const target = targetOverride || el.value
const current = state.longitudinalMode
if (target === current.mode) return
const acknowledged = target === "experimental"
// Invalidate pre-write reads, even if they finish after forced reconciliation.
++state.longitudinalModeRequestId
state.longitudinalModeUpdating = true
try {
const response = await fetch("/api/longitudinal_mode", {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: target, expected: current.values, acknowledged }),
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || "Longitudinal mode update failed")
applyLongitudinalMode(data)
showParamSnackbar("Longitudinal control mode updated.")
} catch (error) {
showParamSnackbar(error.message || "Longitudinal mode update failed", "error")
} finally {
await fetchLongitudinalMode(true)
state.longitudinalModeUpdating = false
scheduleSyncInputs()
}
}
async function fetchDefaultValues() {
try {
const defaultsRes = await fetch("/api/params/defaults")
@@ -549,13 +612,14 @@ async function fetchLayoutAndParams() {
const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1", { cache: "no-store" })
const rawLayoutData = await layoutRes.json()
const layoutData = rawLayoutData
let layoutData = rawLayoutData
.map(section => ({
...section,
params: (section.params || []).filter(param => param.key !== "Model"),
}))
.filter(section => section.params.length > 0)
layoutData = longitudinalModeLayout(layoutData)
state.layout = layoutData
const keys = []
@@ -589,6 +653,7 @@ async function fetchLayoutAndParams() {
state.defaultValues = {}
}
await fetchLongitudinalMode()
await fetchFavoriteSlots()
state.loadingValues = false
@@ -876,7 +941,8 @@ async function refreshUiContextValues() {
state.values = nextValues
scheduleSyncInputs()
}
}).catch(() => {}).finally(() => {
}).catch(() => {}).finally(async () => {
await fetchLongitudinalMode()
uiContextPollInflight = null
})
@@ -957,6 +1023,18 @@ function updateFavoriteFilter(index, event) {
}
async function updateFavoriteValue(key, checked, sourceEl = null) {
if (["ExperimentalMode", "ConditionalExperimental", "ConditionalChill"].includes(key)) {
await fetchLongitudinalMode()
if (state.longitudinalMode) {
const candidate = { ...state.longitudinalMode.values, [key]: checked }
if (checked && key !== "ExperimentalMode") candidate[key === "ConditionalExperimental" ? "ConditionalChill" : "ConditionalExperimental"] = false
const target = candidate.ConditionalExperimental ? "conditional_experimental" : candidate.ConditionalChill ? "conditional_chill" : candidate.ExperimentalMode ? "experimental" : "chill"
await updateLongitudinalMode(target)
}
if (sourceEl) sourceEl.checked = !!state.values[key]
scheduleSyncInputs()
return
}
if (!confirmPandaFirmwareToggle(key, checked)) {
if (sourceEl) sourceEl.checked = !!state.values[key]
scheduleSyncInputs()
@@ -1326,6 +1404,10 @@ async function runSettingAction(param) {
}
async function updateParam(key, elType) {
if (key === LONGITUDINAL_MODE_KEY) {
await updateLongitudinalMode()
return
}
if (String(key).toLowerCase() === "starpilotfavoriteslots") {
await saveFavoriteSlots(state.favoriteSlots)
return
@@ -1492,6 +1574,11 @@ function getSettingLockReason(param) {
if (param?.key === "CustomPersonalities" && state.personalityMigrationRequired) {
return "This profile data requires a verified migration before it can be edited."
}
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) {
return "This setting can only be changed while parked."
}
@@ -2596,7 +2683,16 @@ function renderSettingRow(p) {
</div>
` : ""}
${() => p.is_parent_toggle && (p.key === "CustomPersonalities" || isParamEnabledForChildren(p)) ? html`
${() => p.key === LONGITUDINAL_MODE_KEY && isParamEnabledForChildren(p) ? html`
<button type="button" class="ds-manage-btn"
aria-controls="ds-LongitudinalControlMode-children"
aria-expanded="${() => state.expanded[p.key] ? "true" : "false"}"
@click="${() => toggleManage(p.key)}">
${state.expanded[p.key] ? "Close" : "Manage"}
<i class="bi bi-chevron-${state.expanded[p.key] ? "up" : "down"}" aria-hidden="true"></i>
</button>
` : ""}
${() => p.key !== LONGITUDINAL_MODE_KEY && p.is_parent_toggle && (p.key === "CustomPersonalities" || isParamEnabledForChildren(p)) ? html`
<button type="button" class="ds-manage-btn"
aria-controls="${p.key === "CustomPersonalities" ? "personality-profiles-panel" : `ds-${p.key}-children`}"
aria-expanded="${() => state.expanded[p.key] ? "true" : "false"}"
@@ -2639,7 +2735,8 @@ function renderSettingTree(paramsList, parentKey = null) {
if (!hasChildParams(paramsList, param.key)) continue
if (!isParamEnabledForChildren(param) || !state.expanded[param.key]) continue
rendered.push(html`<div id="ds-${param.key}-children" class="ds-setting-children">${() => renderSettingTree(paramsList, param.key)}</div>`)
const childrenId = param.key === LONGITUDINAL_MODE_KEY ? "ds-LongitudinalControlMode-children" : `ds-${param.key}-children`
rendered.push(html`<div id="${childrenId}" class="ds-setting-children">${() => renderSettingTree(paramsList, param.key)}</div>`)
}
return rendered
@@ -2693,10 +2790,6 @@ export function DeviceSettings({ params }) {
<div class="ds-wrapper">
<h2>Toggles</h2>
<div class="ds-unit-note">
<i class="bi bi-speedometer2"></i>
<span>Vehicle-unit speed settings use <strong>${() => vehicleSpeedUnit(state.values)}</strong> and follow the comma's <em>Use Metric System</em> toggle. Each control shows its adjustment step.</span>
</div>
<div class="ds-search-row">
<input
@@ -0,0 +1,43 @@
export const LONGITUDINAL_MODE_KEY = "LongitudinalControlMode"
export const LONGITUDINAL_MODES = [
{ value: "chill", label: "Chill" },
{ value: "experimental", label: "Experimental" },
{ value: "conditional_experimental", label: "Conditional Experimental" },
{ value: "conditional_chill", label: "Conditional Chill" },
]
export function longitudinalModeLayout(layout) {
return layout.map(section => {
const params = section.params || []
if (!params.some(p => p.key === "ConditionalExperimental")) return section
const byKey = new Map(params.map(p => [p.key, p]))
function owner(p) {
const seen = new Set()
while (p?.parent_key && !seen.has(p.parent_key)) {
if (p.parent_key === "ConditionalExperimental") return "conditional_experimental"
if (p.parent_key === "ConditionalChill") return "conditional_chill"
seen.add(p.parent_key)
p = byKey.get(p.parent_key)
}
return null
}
return { ...section, params: params.flatMap(p => {
if (p.key === "ConditionalChill" || p.key === "ExperimentalMode") return []
if (p.key === "ConditionalExperimental") return [{
key: LONGITUDINAL_MODE_KEY, label: "Longitudinal control mode", data_type: "string",
ui_type: "dropdown", settings_tier: "simple", options: LONGITUDINAL_MODES, is_parent_toggle: true,
description: "Chill: conventional speed control. Experimental: model-controlled gas and brakes. Conditional Experimental: Chill, switching to Experimental under your chosen conditions. Conditional Chill: Experimental, switching to Chill for simple cruising.",
}]
const mode = owner(p)
return [{ ...p, ...(mode ? { longitudinal_mode: mode, settings_tier: "simple" } : {}),
...(["ConditionalExperimental", "ConditionalChill"].includes(p.parent_key) ? { parent_key: LONGITUDINAL_MODE_KEY } : {}) }]
}) }
})
}
export function validLongitudinalSnapshot(data) {
return !!data && LONGITUDINAL_MODES.some(mode => mode.value === data.mode) &&
typeof data.locked === "boolean" && typeof data.reason === "string" &&
typeof data.experimental_confirmed === "boolean" &&
["ExperimentalMode", "ConditionalExperimental", "ConditionalChill"].every(key => typeof data.values?.[key] === "boolean")
}
@@ -800,6 +800,14 @@ body.is-scrolling .gx-tile {
.gx-row--favorites { align-items: stretch; flex-direction: column; }
.gx-row--favorites .gx-row__info { flex: none; }
.gx-row--stack { align-items: stretch; flex-direction: column; }
/* Keep the native keyboard/touch picker, but wrap its verified label instead
of clipping Conditional Experimental at enlarged phone UI scales. */
.gx-mode-select { position: relative; width: 100%; }
.gx-mode-select__label { display: flex; align-items: center; justify-content: space-between; gap: 12px; white-space: normal; }
.gx-mode-select__label span { min-width: 0; overflow-wrap: anywhere; }
.gx-mode-select__label i { flex-shrink: 0; }
.gx-mode-select select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.gx-mode-select:focus-within { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: 16px; }
.gx-row--stack .gx-row__info { flex: none; }
.gx-row--stack .gx-field,
.gx-row--stack .gx-slider-row { width: 100%; }
@@ -14,6 +14,7 @@ export const GalaxyToggleCard = {
value: { default: undefined },
values: { type: Object, default: () => ({}) },
locked: { type: Boolean, default: false },
lockMessage: { type: String, default: "This setting can only be changed while parked." },
manageable: { type: Boolean, default: false },
manageOpen: { type: Boolean, default: false },
},
@@ -72,6 +73,7 @@ export const GalaxyToggleCard = {
labelOf(el) { return el?.options?.[el.selectedIndex]?.textContent || "" },
rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) },
async commit(nextValue) {
if (this.locked || this.updating) return
const prev = this.value
const label = this.lastLabel || ""
this.$emit("change", { key: this.param.key, value: nextValue })
@@ -179,7 +181,7 @@ export const GalaxyToggleCard = {
<span v-if="displayParam.settings_tier === 'advanced'" class="gx-chip gx-chip--advanced">Advanced</span>
</span>
<span v-if="displayParam.description" class="gx-row__desc">{{ displayParam.description }}</span>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> This setting can only be changed while parked.</div>
<div v-if="locked" class="gx-row__desc"><strong>Locked:</strong> {{ lockMessage }}</div>
</div>
<label v-if="isSwitch" class="gx-switch">
@@ -0,0 +1,85 @@
import { LONGITUDINAL_MODE_KEY, LONGITUDINAL_MODES, validLongitudinalSnapshot } from "/assets/components/tools/longitudinal_mode.mjs"
import { SettingTree } from "./SettingTree.js"
import { isSettingVisible } from "../params.js"
export const LongitudinalMode = {
name: "LongitudinalMode",
components: { SettingTree },
props: { section: { type: Object, required: true }, values: { type: Object, required: true } },
emits: ["change"],
data() { return { snapshot: null, pending: false, reading: false, expanded: {}, open: false, error: "", generation: 0, timer: null, disposed: false, modes: LONGITUDINAL_MODES } },
computed: {
mode() { return this.snapshot?.mode || "" },
label() { return this.modes.find(m => m.value === this.mode)?.label || "Unavailable" },
reason() { return this.snapshot?.locked ? this.snapshot.reason : this.snapshot ? "" : "Speed control state unavailable." },
locked() { return this.pending || !this.snapshot || this.snapshot.locked },
conditional() { return this.mode === "conditional_experimental" || this.mode === "conditional_chill" },
description() { return this.section.params.find(p => p.key === LONGITUDINAL_MODE_KEY)?.description || "" },
children() { return this.section.params.filter(p => p.longitudinal_mode === this.mode && isSettingVisible(this.section, p, this.values)) },
},
methods: {
async request(init) {
const response = await fetch("/api/longitudinal_mode", { cache: "no-store", ...init })
const data = await response.json()
if (!response.ok || !validLongitudinalSnapshot(data)) throw new Error(data.error || "Speed control state unavailable.")
return data
},
async refresh() {
if (this.pending || this.reading || this.disposed) return
const generation = this.generation
this.reading = true
try {
const snapshot = await this.request()
if (!this.disposed && generation === this.generation) this.snapshot = snapshot
} catch (_) {
if (!this.disposed && generation === this.generation) this.snapshot = null
} finally { this.reading = false }
},
async select(event) {
const target = event.target.value
event.target.value = this.mode
if (this.locked || target === this.mode || !this.modes.some(m => m.value === target)) return
++this.generation // A pre-write GET may finish after this write; never publish it.
this.pending = true
this.error = ""
try {
const snapshot = await this.request({ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: target, expected: this.snapshot.values, acknowledged: true }) })
if (!this.disposed) this.snapshot = snapshot
} catch (error) {
this.error = error.message
// A failed HTTP response can follow a successful storage write. Reconcile,
// rather than guessing that the old selection is still authoritative.
try { this.snapshot = await this.request() } catch (_) { this.snapshot = null }
} finally { this.pending = false }
},
childLock() { return this.pending ? "Speed control update in progress." : this.reason },
manage(key) { this.expanded = { ...this.expanded, [key]: !this.expanded[key] } },
},
mounted() { this.refresh(); this.timer = setInterval(() => this.refresh(), 1500) },
beforeUnmount() { this.disposed = true; ++this.generation; clearInterval(this.timer) },
template: `
<div class="gx-tree-node gx-longitudinal-mode">
<div class="gx-row gx-row--stack" :class="{ disabled: locked }">
<div class="gx-row__info">
<label class="gx-row__label" for="gx-longitudinal-mode">Longitudinal control mode</label>
<span id="gx-longitudinal-description" class="gx-row__desc">{{ description }}</span>
<span v-if="reason" class="gx-row__desc" role="status">{{ reason }}</span>
<span v-if="error" class="gx-row__desc" role="alert">{{ error }}</span>
</div>
<div class="gx-mode-select">
<div class="gx-field gx-mode-select__label" aria-hidden="true"><span>{{ label }}</span><i class="bi bi-chevron-down"></i></div>
<select id="gx-longitudinal-mode" :value="mode" :disabled="locked" aria-describedby="gx-longitudinal-description" @change="select">
<option v-if="!snapshot" value="">Unavailable</option>
<option v-for="m in modes" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
</div>
<button v-if="conditional" type="button" class="gx-manage-btn" :aria-expanded="open" aria-controls="gx-longitudinal-children" @click="open = !open">
{{ open ? 'Close' : 'Manage' }}<i class="bi" aria-hidden="true" :class="open ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</button>
<div v-if="conditional && open" id="gx-longitudinal-children" class="gx-tree-children">
<SettingTree :params="children" parent-key="LongitudinalControlMode" :depth="1" :values="values" :expanded="expanded" :lock-reason="childLock" @change="$emit('change', $event)" @manage="manage" />
</div>
</div>
`,
}
@@ -32,7 +32,7 @@ export const SettingTree = {
<template v-for="p in children" :key="p.key">
<PersonalityProfiles v-if="p.key === 'CustomPersonalities'" :manage-open="isExpanded(p)" @manage="$emit('manage', p.key)" @change="$emit('change', $event)" />
<div v-else class="gx-tree-node" :class="{ 'gx-tree-node--child': depth > 0 }" :style="'--gx-depth:' + depth">
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
<GalaxyToggleCard :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''" :lock-message="lockReason(p)"
:manageable="manageable(p)" :manage-open="manageOpen(p)"
@change="$emit('change', $event)" @manage="$emit('manage', $event)" />
</div>
@@ -1,3 +1,5 @@
import { longitudinalModeLayout, LONGITUDINAL_MODE_KEY } from "/assets/components/tools/longitudinal_mode.mjs"
import { LongitudinalMode } from "../components/LongitudinalMode.js"
import { api, showSnackbar } from "../api.js"
import { navigate, store } from "../store.js"
import {
@@ -10,8 +12,6 @@ import { GalaxyToggleCard } from "../components/GalaxyToggleCard.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { DevModeBanner } from "../components/DevModeBanner.js"
// Match classic Galaxy's retired longitudinal controls in this replacement view.
// These are presentation exclusions only; registry values and backend policy stay intact.
const LEGACY_PERSONALITY_KEYS = new Set([
"AccelerationProfile", "AggressiveFollow", "AggressiveFollowHigh", "CustomAccelProfile",
"CustomAccelProfile0MPH", "CustomAccelProfile11MPH", "CustomAccelProfile22MPH", "CustomAccelProfile34MPH",
@@ -22,7 +22,7 @@ const LEGACY_PERSONALITY_KEYS = new Set([
export const Settings = {
name: "Settings",
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner },
components: { SettingTree, PersonalityProfiles, GalaxyToggleCard, GalaxySection, DevModeBanner, LongitudinalMode },
data() {
return {
layout: [],
@@ -68,12 +68,15 @@ export const Settings = {
},
},
methods: {
isModeParam(p) { return p.key === LONGITUDINAL_MODE_KEY || !!p.longitudinal_mode },
modeSection(s) { return this.layout.find(section => section.name === s.name && section.params.some(p => p.key === LONGITUDINAL_MODE_KEY)) },
ordinaryParams(s) { return s.params.filter(p => !this.isModeParam(p)) },
async load() {
try {
const [layout, values, defaults] = await Promise.all([
api.getLayout(), api.getParams(), api.getDefaults(),
])
this.layout = layout
this.layout = longitudinalModeLayout(layout)
this.values = values || {}
this.defaults = defaults || {}
if (!this.activeSectionSlug && this.sections.length) {
@@ -149,9 +152,10 @@ export const Settings = {
</div>
<template v-for="section in searchResults" :key="section.slug">
<GalaxySection :title="section.name + ' (' + section.matches.length + ')'" :icon="section.icon || 'bi-search'" :default-open="false">
<LongitudinalMode v-if="section.matches.some(isModeParam)" :section="modeSection(section)" :values="values" @change="onParamChange" />
<template v-for="p in section.matches" :key="p.key">
<PersonalityProfiles v-if="p.key === 'CustomPersonalities'" :manage-open="!!expanded[p.key]" @manage="toggleManage(p.key)" @change="onParamChange" />
<GalaxyToggleCard v-else :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
<GalaxyToggleCard v-else-if="!isModeParam(p)" :param="p" :value="values[p.key]" :values="values" :locked="lockReason(p) !== ''"
@change="onParamChange" />
</template>
</GalaxySection>
@@ -172,7 +176,8 @@ export const Settings = {
<i class="bi" :class="activeSection.icon"></i>
<span class="gx-section__title">{{ activeSection.name }}</span>
</div>
<SettingTree :params="activeSection.params" :parent-key="null" :values="values"
<LongitudinalMode v-if="modeSection(activeSection)" :section="modeSection(activeSection)" :values="values" @change="onParamChange" />
<SettingTree :params="ordinaryParams(activeSection)" :parent-key="null" :values="values"
:expanded="expanded" :lock-reason="lockReason" @change="onParamChange" @manage="toggleManage" />
<div v-if="!activeSection.params.length" class="gx-empty">No settings in this section.</div>
</div>
@@ -0,0 +1,112 @@
"""Adapter over existing mode Params, with coherent participating runtime reads.
Enable a conditional target before clearing its competitor; for fixed modes,
set the fallback before disabling conditional flags. Every stored write boundary
selects either the old or requested mode, including when a write fails. The
shared sidecar lock keeps participating readers on their complete snapshot.
Legacy nonparticipating writers remain advisory-lock exceptions, not
transactions. API success confirms storage, not plan activation.
"""
from contextlib import contextmanager
from threading import RLock
from openpilot.starpilot.common.longitudinal_mode import MODE_KEYS, mode_lock
MODES = {"chill": None, "experimental": "ExperimentalMode",
"conditional_experimental": "ConditionalExperimental", "conditional_chill": "ConditionalChill"}
WRITE_LOCK = RLock()
class ModeError(ValueError):
def __init__(self, message, status=409):
super().__init__(message)
self.status = status
def selected_mode(values):
if values["ConditionalExperimental"]:
return "conditional_experimental"
if values["ConditionalChill"]:
return "conditional_chill"
return "experimental" if values["ExperimentalMode"] else "chill"
def lock_reason(params, capable):
def boolean(key):
value = params.get(key)
if value in (True, "1", b"1", "True", b"True"):
return True
if value in (False, "0", b"0", "False", b"False"):
return False
return None
offroad, onroad = boolean("IsOffroad"), boolean("IsOnroad")
if offroad is None or onroad is None or offroad == onroad:
return "Longitudinal control mode requires a known, consistent road state."
if boolean("SafeMode") is not False:
return "Longitudinal control mode is locked by Safe Mode or unavailable safety state."
if capable is not True:
return "openpilot longitudinal control is unavailable for the detected vehicle."
return ""
def snapshot(params, capable):
try:
with mode_lock(params):
return _snapshot(params, capable)
except OSError as error:
raise ModeError("Longitudinal mode is busy or unavailable. Refresh before retrying.", 503) from error
def _snapshot(params, capable):
values = {key: params.get_bool(key) for key in MODE_KEYS}
reason = lock_reason(params, capable)
return {"mode": selected_mode(values), "values": values, "locked": bool(reason), "reason": reason,
"experimental_confirmed": params.get_bool("ExperimentalModeConfirmed")}
@contextmanager
def _write_lock(params):
try:
with mode_lock(params, exclusive=True):
yield
except OSError as error:
raise ModeError("Longitudinal mode is busy or unavailable. Refresh before retrying.", 503) from error
def set_mode(params, target, expected, capability, acknowledged=False):
if not isinstance(target, str) or target not in MODES:
raise ModeError("Unknown longitudinal control mode.", 400)
if not isinstance(expected, dict) or any(type(expected.get(key)) is not bool for key in MODE_KEYS):
raise ModeError("An exact previous mode snapshot is required.", 400)
with WRITE_LOCK, _write_lock(params):
try:
current = _snapshot(params, capability())
if current["locked"]:
raise ModeError(current["reason"], 403)
if current["values"] != {key: expected[key] for key in MODE_KEYS}:
raise ModeError("Longitudinal mode changed elsewhere. Refresh before retrying.")
if current["mode"] == target:
return current # Preserve dormant flags, defaults and manual override state.
if target == "experimental" and not current["experimental_confirmed"] and acknowledged is not True:
raise ModeError("Experimental Mode requires explicit acknowledgement before enabling.")
if target == "conditional_experimental":
writes = [("ConditionalExperimental", True), ("ConditionalChill", False), ("ExperimentalMode", False)]
elif target == "conditional_chill":
writes = [("ConditionalChill", True), ("ConditionalExperimental", False), ("ExperimentalMode", False)]
else:
writes = [("ExperimentalMode", target == "experimental"), ("ConditionalChill", False), ("ConditionalExperimental", False)]
for key, value in writes:
reason = lock_reason(params, capability())
if reason:
raise ModeError(reason, 403)
params.put_bool(key, value)
if params.get_bool(key) is not value:
raise ModeError("Mode write could not be verified; refresh before retrying.", 500)
result = _snapshot(params, capability())
desired = {key: key == MODES[target] for key in MODE_KEYS}
if result["values"] != desired or result["locked"]:
raise ModeError("Mode changed during the update; refresh before retrying.")
return result
except ModeError:
raise
except Exception as error:
raise ModeError("Longitudinal mode update failed; refresh to inspect the stored state.", 500) from error
@@ -0,0 +1,148 @@
// Real Vue Settings renderer + shipped CSS; synthetic API, no device access.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const repo = path.resolve(__dirname, '../../../..')
const assets = path.join(repo, 'starpilot/system/the_galaxy/assets')
const layout = JSON.parse(fs.readFileSync(path.join(repo,'starpilot/common/assets/device_settings_layout.json')))
const out = process.env.MODE_EVIDENCE || '/opt/data/workspace/speed-control-big-dipper-evidence'
fs.mkdirSync(out,{recursive:true})
const fixture = `
import {createApp} from 'vue';
import {Settings} from '/assets/mobile/js/views/Settings.js';
import {store} from '/assets/mobile/js/store.js';
window.store=store; store.route='/settings/longitudinal-speed-following';
const layout=${JSON.stringify(layout)};
const values=Object.fromEntries(layout.flatMap(s=>s.params||[]).map(p=>[p.key,p.data_type==='bool'?false:(p.default??p.min??0)]));
Object.assign(values,{IsOnroad:'True',IsOffroad:'',SafeMode:false,HasRadar:true,GalaxyDeveloperMode:false});
window.writes=[]; window.paramWrites=[]; window.failWrite=false; window.failRead=false; window.holdWrite=false; window.holdRead=false;
window.state={mode:'conditional_experimental',values:{ExperimentalMode:true,ConditionalExperimental:true,ConditionalChill:false},locked:false,reason:'',experimental_confirmed:false};
window.externalMode=mode=>{window.state={...window.state,mode,values:{ExperimentalMode:mode==='experimental',ConditionalExperimental:mode==='conditional_experimental',ConditionalChill:mode==='conditional_chill'}}};
window.fetch=async(input,init={})=>{
const url=new URL(input,location.href); const json=(data,status=200)=>new Response(JSON.stringify(data),{status});
if(url.pathname==='/api/longitudinal_mode') {
if(init.method==='PUT') {
const body=JSON.parse(init.body); window.writes.push(body);
if(window.holdWrite) await new Promise(r=>window.releaseWrite=r);
if(window.failWrite) return json({error:'Injected write failure'},500);
if(JSON.stringify(body.expected)!==JSON.stringify(window.state.values)) return json({error:'Changed elsewhere'},409);
window.externalMode(body.mode);
} else if(window.holdRead) {const captured=structuredClone(window.state); await new Promise(r=>window.releaseRead=r); return json(captured)}
if(window.failRead) return json({},503);
return json(window.state);
}
if(url.pathname.endsWith('device_settings_layout.json')) return json(layout);
if(url.pathname==='/api/params/all') return json(values);
if(url.pathname==='/api/params/defaults') return json({});
if(url.pathname==='/api/params' && init.method==='PUT') {const body=JSON.parse(init.body);window.paramWrites.push(body);return json({updated:{[body.key]:body.value}})}
throw new Error('Unmocked request '+url.pathname);
};
window.app=createApp(Settings); window.vm=window.app.mount('#app');
`
;(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROMIUM_EXECUTABLE,args:['--no-sandbox']})
const reports=[]
try {
for(const cfg of [{width:1440,height:1000,scale:1,touch:false},{width:1100,height:900,scale:1.25,touch:false},{width:390,height:844,scale:1,touch:true},{width:360,height:800,scale:1.5,touch:true},{width:768,height:1024,scale:2,touch:true}]) {
const page=await browser.newPage({viewport:{width:cfg.width,height:cfg.height},hasTouch:cfg.touch,deviceScaleFactor:cfg.scale})
const errors=[];page.on('pageerror',e=>errors.push(e.message))
await page.route('**/*',async route=>{
const url=new URL(route.request().url());assert.equal(url.hostname,'offline.invalid')
if(url.pathname==='/') return route.fulfill({contentType:'text/html',body:`<html data-theme="dark"><head><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css"><link rel="stylesheet" href="/assets/mobile/css/material.css"><link rel="stylesheet" href="/assets/mobile/css/home.css"><script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script></head><body><main id="app" style="max-width:1100px;margin:auto;padding:16px"></main><script type="module" src="/setup.js"></script></body></html>`})
if(url.pathname==='/setup.js') return route.fulfill({contentType:'text/javascript',body:fixture})
const file=path.join(assets,url.pathname.replace(/^\/assets\//,''));if(fs.existsSync(file)&&fs.statSync(file).isFile()) return route.fulfill({path:file})
return route.fulfill({status:404,body:'not found'})
})
await page.goto('http://offline.invalid/')
await page.evaluate(scale=>{document.documentElement.style.zoom=String(scale)},cfg.scale)
const select=page.locator('#gx-longitudinal-mode'), manage=page.locator('[aria-controls="gx-longitudinal-children"]')
const waitMode=mode=>page.waitForFunction(mode=>{const e=document.querySelector('#gx-longitudinal-mode');return e?.value===mode&&!e.disabled},mode)
await waitMode('conditional_experimental')
assert.equal(await page.locator('.gx-mode-select__label span').innerText(),'Conditional Experimental')
assert.equal(await page.locator('.gx-mode-select__label span').evaluate(e=>e.scrollWidth<=e.clientWidth+1),true)
await select.focus()
assert.equal(await page.locator('.gx-mode-select').evaluate(e=>getComputedStyle(e).outlineStyle),'solid')
assert.deepEqual(await select.locator('option').allTextContents(),['Chill','Experimental','Conditional Experimental','Conditional Chill'])
assert.equal(await page.evaluate(()=>writes.length),0)
assert.equal(await manage.getAttribute('aria-expanded'),'false')
assert.equal(await page.locator('#gx-longitudinal-children').count(),0)
assert.ok((await page.locator('#gx-longitudinal-description').innerText()).includes('model-controlled gas and brakes'))
await (cfg.touch?manage.tap():manage.click())
const children=page.locator('#gx-longitudinal-children')
assert.ok((await children.innerText()).includes('Persist Experimental State'))
assert.ok(!(await children.innerText()).includes('Persist Chill State'))
// Every direct classic child appears with identical description and native control.
for(const owner of ['ConditionalExperimental','ConditionalChill']) {
const target=owner==='ConditionalExperimental'?'conditional_experimental':'conditional_chill'
if(target!=='conditional_experimental') {await select.selectOption(target);await waitMode(target)}
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-${target}.png`),fullPage:true})
for(const p of layout.flatMap(s=>s.params||[]).filter(p=>p.parent_key===owner)) {
assert.ok((await children.innerText()).includes(p.label),p.key)
const card=children.locator('.gx-row').filter({has:page.locator('.gx-row__label',{hasText:p.label})}).first()
assert.ok(await card.count(),p.key)
if(p.description) assert.equal(await card.locator('.gx-row__desc').first().textContent(),p.description)
}
if(owner==='ConditionalExperimental') {
const lead=children.locator('.gx-tree-node').filter({has:page.locator('.gx-row__label',{hasText:'Lead Detected Ahead'})}).first()
await lead.locator('input[type=checkbox]').check()
await lead.locator('.gx-manage-btn').click()
for(const p of layout.flatMap(s=>s.params||[]).filter(p=>p.parent_key==='CELead')) {
const row=children.locator('.gx-row').filter({has:page.locator('.gx-row__label',{hasText:p.label})})
assert.equal(await row.isVisible(),true,p.key)
if(p.description) assert.equal(await row.locator('.gx-row__desc').innerText(),p.description)
await row.locator('input[type=checkbox]').check()
}
}
}
const slider=children.locator('input[type=range]').first()
await slider.focus();await slider.press('ArrowRight');await slider.press('Tab')
assert.ok(await page.evaluate(()=>paramWrites.length>0))
for(const target of ['chill','experimental']) {
await select.selectOption(target);await waitMode(target);assert.equal(await manage.count(),0);assert.equal(await children.count(),0)
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-${target}.png`),fullPage:true})
}
assert.equal(await page.evaluate(()=>writes.at(-1).acknowledged),true)
await page.evaluate(()=>{holdWrite=true});await select.selectOption('conditional_chill')
await page.waitForFunction(()=>!!window.releaseWrite)
assert.equal(await select.inputValue(),'experimental');assert.equal(await select.isDisabled(),true)
await page.evaluate(()=>{holdWrite=false;releaseWrite()});await waitMode('conditional_chill')
await page.evaluate(()=>{failWrite=true});await select.selectOption('chill');await waitMode('conditional_chill')
assert.ok((await page.locator('[role=alert]').innerText()).includes('Injected'))
await page.evaluate(()=>{failWrite=false;externalMode('conditional_experimental')});await waitMode('conditional_experimental')
// Routine polling never dims an available control; stale pre-write GET is ignored.
await page.evaluate(()=>{holdRead=true});await page.waitForFunction(()=>!!window.releaseRead)
assert.equal(await select.isEnabled(),true)
await select.selectOption('conditional_chill');await waitMode('conditional_chill')
await page.evaluate(()=>{holdRead=false;releaseRead()});await page.waitForTimeout(100)
assert.equal(await select.inputValue(),'conditional_chill')
for(const reason of ['Locked by Safe Mode.','openpilot longitudinal unavailable.']) {
await page.evaluate(reason=>{state={...state,locked:true,reason}},reason)
await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').disabled)
assert.ok((await page.locator('.gx-longitudinal-mode').innerText()).includes(reason))
assert.equal(await children.locator('input:not(:disabled),select:not(:disabled)').count(),0)
await page.evaluate(()=>{state={...state,locked:false,reason:''}});await waitMode('conditional_chill')
}
// CSS zoom covers enlarged UI/text separately from DPR.
await page.evaluate(scale=>{document.documentElement.style.zoom=String(scale)},cfg.scale)
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>document.documentElement.clientWidth+1)
assert.equal(overflow,false,'horizontal overflow '+JSON.stringify(cfg))
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-dark.png`),fullPage:true})
await page.evaluate(()=>document.documentElement.setAttribute('data-theme','light'))
await page.screenshot({path:path.join(out,`${cfg.width}-${cfg.scale}-light.png`),fullPage:true})
await page.evaluate(()=>{failRead=true});await page.waitForFunction(()=>document.querySelector('#gx-longitudinal-mode').value==='')
assert.equal(await select.isDisabled(),true)
await page.evaluate(()=>{failRead=false});await waitMode('conditional_chill')
// Search must use the same guarded selector, never generic Params for virtual key.
await page.evaluate(()=>{store.search='Longitudinal control mode'})
await page.locator('.gx-section__header').last().click()
await waitMode('conditional_chill');await select.selectOption('chill');await waitMode('chill')
assert.equal(await page.evaluate(()=>paramWrites.some(p=>p.key==='LongitudinalControlMode')),false)
assert.deepEqual(errors,[])
reports.push({...cfg,passed:true,writes:await page.evaluate(()=>writes.length)})
fs.writeFileSync(path.join(out,'results.json'),JSON.stringify(reports,null,2))
await page.close()
}
console.log('PASS Big Dipper Settings: '+JSON.stringify(reports))
} finally {await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1})
@@ -0,0 +1,91 @@
// Exact classic functions with deterministic synthetic network ordering; no Params/device access.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const source = fs.readFileSync(path.join(__dirname, '../assets/components/tools/device_settings.js'), 'utf8');
const start = source.indexOf('function applyLongitudinalMode(data)');
const end = source.indexOf('async function fetchDefaultValues()', start);
assert(start > 0 && end > start);
const snapshot = mode => ({ mode, values: { ExperimentalMode: mode === 'experimental', ConditionalExperimental: mode === 'conditional_experimental', ConditionalChill: mode === 'conditional_chill' }, locked: false, reason: '', experimental_confirmed: true });
const reply = (data, ok = true) => ({ ok, json: async () => data });
function harness() {
const old = snapshot('experimental');
const requests = [];
const state = { longitudinalMode: old, longitudinalModeUpdating: false, longitudinalModeRequestId: 0, values: { ...old.values, LongitudinalControlMode: old.mode } };
const env = { state, LONGITUDINAL_MODE_KEY: 'LongitudinalControlMode', validLongitudinalSnapshot: () => true,
scheduleSyncInputs: () => {}, document: { getElementById: () => null },
getSettingLockReason: () => state.longitudinalModeUpdating ? 'Updating' : '', showParamSnackbar: () => {},
fetch: (_url, options) => new Promise((resolve, reject) => requests.push({ resolve, reject, options })) };
vm.createContext(env);
vm.runInContext(source.slice(start, end), env);
return { env, state, requests, old };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
function check(state, mode, pending) {
assert.equal(state.longitudinalMode?.mode ?? null, mode);
assert.equal(state.values.LongitudinalControlMode, mode ?? '');
assert.equal(state.longitudinalModeUpdating, pending);
if (mode) for (const [key, value] of Object.entries(snapshot(mode).values)) assert.equal(state.values[key], value);
}
for (const outcome of ['success', 'http failure', 'network failure']) {
for (const phase of ['write pending', 'reconcile pending', 'completed']) {
for (const writeOK of [true, false]) {
test(`obsolete GET ${outcome}, ${phase}, PUT ${writeOK ? 'succeeds' : 'fails'}`, async () => {
const { env, state, requests, old } = harness();
const read = env.fetchLongitudinalMode();
const write = env.updateLongitudinalMode('chill');
check(state, 'experimental', true);
await env.fetchLongitudinalMode();
assert.equal(requests.length, 2, 'ordinary polling stays suppressed during write');
assert.equal(requests[1].options.method, 'PUT');
assert.deepEqual(JSON.parse(requests[1].options.body).expected, old.values);
if (phase !== 'write pending') {
requests[1].resolve(reply(writeOK ? snapshot('chill') : { error: 'uncertain write' }, writeOK));
await flush();
assert.equal(requests.length, 3, 'always reconcile, including failed PUT');
if (phase === 'completed') { requests[2].resolve(reply(snapshot('chill'))); await write; }
}
if (outcome === 'network failure') requests[0].reject(new Error('offline'));
else requests[0].resolve(reply(old, outcome === 'success'));
await read;
check(state, phase === 'completed' || (phase === 'reconcile pending' && writeOK) ? 'chill' : 'experimental', phase !== 'completed');
if (phase === 'write pending') {
requests[1].resolve(reply(writeOK ? snapshot('chill') : { error: 'uncertain write' }, writeOK));
await flush();
}
if (phase !== 'completed') { requests[2].resolve(reply(snapshot('chill'))); await write; }
check(state, 'chill', false);
const poll = env.fetchLongitudinalMode();
requests[3].resolve(reply(snapshot('conditional_chill')));
await poll;
check(state, 'conditional_chill', false);
});
}
}
test(`obsolete forced read ${outcome} cannot supersede newer read`, async () => {
const { env, state, requests, old } = harness();
const first = env.fetchLongitudinalMode(true);
const second = env.fetchLongitudinalMode(true);
requests[1].resolve(reply(snapshot('conditional_experimental')));
await second;
if (outcome === 'network failure') requests[0].reject(new Error('offline'));
else requests[0].resolve(reply(old, outcome === 'success'));
await first;
check(state, 'conditional_experimental', false);
});
}
test('current reconciliation failure still clears selection and unlocks polling', async () => {
const { env, state, requests } = harness();
const write = env.updateLongitudinalMode('chill');
requests[0].resolve(reply(snapshot('chill')));
await flush();
requests[1].reject(new Error('offline'));
await write;
check(state, null, false);
const poll = env.fetchLongitudinalMode();
requests[2].resolve(reply(snapshot('chill')));
await poll;
check(state, 'chill', false);
});
@@ -0,0 +1,264 @@
"""Isolated real AST seams + real OS advisory locks, never native/device Params."""
import ast
import copy
import multiprocessing
from itertools import product
from pathlib import Path
from types import SimpleNamespace as NS
import pytest
from test_longitudinal_mode import Params, mode
from openpilot.starpilot.common.longitudinal_mode import mode_lock, read_mode_values, request_mode_refresh
ROOT = Path(__file__).resolve().parents[4]
def nodes_in(path):
return ast.parse((ROOT / path).read_text())
def execute(nodes, env):
exec(compile(ast.Module(body=nodes, type_ignores=[]), '<runtime AST>', 'exec'), env)
def load_toggles(params, *, capable=True, safe=False):
tree = nodes_in('starpilot/common/starpilot_variables.py')
assignments = sorted((node for node in ast.walk(tree) if isinstance(node, ast.Assign)), key=lambda node: node.lineno)
start = next(node.lineno for node in assignments if isinstance(node.targets[0], ast.Attribute) and node.targets[0].attr == 'longitudinal_mode_values')
end = next(node.lineno for node in assignments if isinstance(node.targets[0], ast.Attribute) and node.targets[0].attr == 'conditional_chill_launch_assist')
toggle = NS(openpilot_longitudinal=capable, experimental_mode_available=capable, safe_mode=safe)
get_value = lambda key, condition=True, **kwargs: params.get(key) if condition else False
execute([node for node in assignments if start <= node.lineno <= end],
dict(toggle=toggle, self=NS(params=params, get_value=get_value), mode_values=read_mode_values(params), speed_conversion=1))
return toggle
def plan_result(toggles, cem=True, ccm=True, slc=False):
tree = nodes_in('starpilot/controls/starpilot_planner.py')
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == 'publish')
start = next(i for i, node in enumerate(method.body) if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Name) and node.targets[0].id == 'conditional_experimental_mode')
plan = NS()
execute(method.body[start:start + 3], dict(starpilot_toggles=toggles, starpilotPlan=plan,
self=NS(starpilot_cem=NS(experimental_mode=cem), starpilot_ccm=NS(experimental_mode=ccm),
starpilot_vcruise=NS(slc=NS(experimental_mode=slc)))))
return plan.experimentalMode
def selfdrive_result(plan, *, previous=False, cached=None, safe=False, capable=True, replay=False):
tree = nodes_in('selfdrive/selfdrived/selfdrived.py')
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == 'update_events')
nodes = [node for node in method.body if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Attribute) and node.targets[0].attr == 'experimental_mode']
assert len(nodes) == 1
state = NS(experimental_mode=previous, starpilot_toggles=cached or NS(conditional_experimental_mode=False, conditional_chill_mode=False), safe_mode=safe, CP=object(),
sm={'starpilotPlan': NS(experimentalMode=plan)})
execute(nodes, dict(self=state, experimental_mode_available=lambda cp: capable, REPLAY=replay))
return state.experimental_mode
@pytest.mark.parametrize('bits', list(product([False, True], repeat=3)))
@pytest.mark.parametrize('target', mode.MODES)
def test_onroad_transitions_never_publish_clear_all_intermediates(bits, target):
params = Params(dict(zip(mode.MODE_KEYS, bits)) | {'IsOffroad': False, 'IsOnroad': True, 'CECurves': True, 'CCMLaunchAssist': True})
published = load_toggles(params)
old_values = published.longitudinal_mode_values.copy()
observed = []
put = params.put_bool
def interleaved_write(key, value):
nonlocal published
put(key, value)
# This is the real loader selection seam attempted at each write boundary.
with pytest.raises(BlockingIOError):
load_toggles(params)
assert published.longitudinal_mode_values == old_values
observed.append(selfdrive_result(plan_result(published)))
params.put_bool = interleaved_write
result = mode.set_mode(params, target, old_values, lambda: True)
assert observed == [selfdrive_result(plan_result(published))] * len(params.writes)
published = load_toggles(params)
assert published.longitudinal_mode_values == result['values']
assert published.conditional_curves is published.conditional_experimental_mode
assert published.conditional_chill_launch_assist is published.conditional_chill_mode
assert selfdrive_result(plan_result(published)) is (target != 'chill')
def child_read(path, pipe):
params = NS(get_param_path=lambda: path, get_bool=lambda key: True)
try:
read_mode_values(params)
pipe.send('read')
except BlockingIOError:
pipe.send('busy')
finally:
pipe.close()
def test_lock_is_cross_process_not_only_a_python_mutex():
params = Params()
ctx = multiprocessing.get_context('fork')
receive, send = ctx.Pipe(duplex=False)
with mode_lock(params, exclusive=True):
child = ctx.Process(target=child_read, args=(params.get_param_path(), send))
child.start()
assert receive.poll(3), 'nonblocking read hung'
assert receive.recv() == 'busy'
child.join(3)
assert child.exitcode == 0
assert read_mode_values(params)['ConditionalExperimental'] is True
send.close()
receive.close()
def test_reader_excludes_participating_writer_between_key_reads():
params = Params()
before = {key: params.get_bool(key) for key in mode.MODE_KEYS}
get = params.get_bool
attempts = []
def read(key):
if key in mode.MODE_KEYS:
with pytest.raises(mode.ModeError) as error:
mode.set_mode(params, 'chill', before, lambda: True)
assert error.value.status == 503
attempts.append(key)
return get(key)
params.get_bool = read
assert read_mode_values(params) == before
assert attempts == list(mode.MODE_KEYS)
assert not params.writes
@pytest.mark.parametrize('safe,capable', [(True, True), (False, False)])
def test_effective_plan_cannot_bypass_safety_or_vehicle_capability(safe, capable):
assert selfdrive_result(True, safe=safe, capable=capable) is False
params = Params({'ConditionalExperimental': False, 'ExperimentalMode': True})
assert load_toggles(params, safe=safe, capable=capable).experimental_mode is False
params.values.update(ConditionalExperimental=True, ConditionalChill=True)
loaded = load_toggles(params, safe=safe, capable=capable)
assert not loaded.conditional_experimental_mode and not loaded.conditional_chill_mode
@pytest.mark.parametrize('previous,plan,cached_cem,cached_ccm', list(product([False, True], repeat=4)))
def test_current_plan_wins_over_all_stale_cached_flags(previous, plan, cached_cem, cached_ccm):
cached = NS(conditional_experimental_mode=cached_cem, conditional_chill_mode=cached_ccm)
assert selfdrive_result(plan, previous=previous, cached=cached) is plan
def test_conditional_defaults_and_slc_override_preserved():
params = Params({'ExperimentalMode': True})
assert plan_result(load_toggles(params), cem=False) is False # CEM masks dormant EXP
assert plan_result(load_toggles(params), cem=False, slc=True) is True
params.values.update(ConditionalExperimental=False, ConditionalChill=True)
assert plan_result(load_toggles(params), ccm=False) is False
assert plan_result(load_toggles(params), ccm=True) is True
params.values.update(ConditionalChill=False)
assert plan_result(load_toggles(params), cem=False, ccm=False) is True
def test_poll_retries_until_published_and_honors_unnotified_external_writes():
params, memory = Params(), Params()
published = load_toggles(params)
request_mode_refresh(params, memory, published)
assert not memory.writes
# Dom/nonparticipating writer, without calling the Galaxy notification helper.
params.values.update(ConditionalExperimental=False, ExperimentalMode=True)
request_mode_refresh(params, memory, published)
assert memory.writes[-1] == ('StarPilotTogglesUpdated', True)
in_flight = load_toggles(params)
params.values.update(ExperimentalMode=False, ConditionalChill=True)
memory.values['StarPilotTogglesUpdated'] = False # Worker consumes first signal.
request_mode_refresh(params, memory, published)
assert memory.get_bool('StarPilotTogglesUpdated')
# Even publication of the older in-flight update must not suppress retry.
memory.values['StarPilotTogglesUpdated'] = False
request_mode_refresh(params, memory, in_flight)
assert memory.get_bool('StarPilotTogglesUpdated')
published = load_toggles(params)
memory.writes.clear()
request_mode_refresh(params, memory, published)
assert not memory.writes
with mode_lock(params, exclusive=True):
request_mode_refresh(params, memory, published)
assert not memory.writes
def test_background_failure_preserves_entire_old_object_and_requeues():
params, memory = Params({'ExperimentalMode': True}), Params()
published = load_toggles(params)
variables = NS(starpilot_toggles=published, params_memory=memory)
node = next(node for node in nodes_in('starpilot/starpilot_process.py').body
if isinstance(node, ast.FunctionDef) and node.name == 'update_toggles_in_background')
def reload(updated, *args, **kwargs):
updated.starpilot_toggles.experimental_mode = False
load_toggles(params)
env = dict(copy=copy, update_toggles=reload)
execute([node], env)
result = {}
with mode_lock(params, exclusive=True), pytest.raises(BlockingIOError):
env['update_toggles_in_background'](result, variables, True, None, None, True, params, published)
assert result == {'failed': True}
assert variables.starpilot_toggles is published
assert published.experimental_mode is True
assert published.conditional_experimental_mode
assert memory.get_bool('StarPilotTogglesUpdated')
@pytest.mark.parametrize('fail_at', [1, 2, 3])
def test_failed_write_releases_lock_without_enabling_or_rolling_back(fail_at):
params = Params({'IsOffroad': False, 'IsOnroad': True}, fail_at=fail_at)
before = read_mode_values(params)
with pytest.raises(mode.ModeError):
mode.set_mode(params, 'experimental', before, lambda: True)
assert len(params.writes) == fail_at
assert mode.selected_mode(params.values) in {mode.selected_mode(before), 'experimental'}
assert read_mode_values(params) == {key: params.values[key] for key in mode.MODE_KEYS}
def test_lock_unavailable_fails_closed_without_writes(tmp_path):
params = Params({'IsOffroad': False, 'IsOnroad': True})
params.get_param_path = lambda: str(tmp_path / 'missing' / 'd')
with pytest.raises(mode.ModeError) as error:
mode.set_mode(params, 'experimental', params.values, lambda: True)
assert error.value.status == 503
assert not params.writes
def test_params_thread_no_longer_overwrites_experimental_from_live_params():
method = next(node for node in ast.walk(nodes_in('selfdrive/selfdrived/selfdrived.py'))
if isinstance(node, ast.FunctionDef) and node.name == 'params_thread')
replay = next(node for node in ast.walk(method) if isinstance(node, ast.If) and isinstance(node.test, ast.Name) and node.test.id == 'REPLAY')
assert not any(isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store)
and node.attr == 'experimental_mode' for branch in replay.orelse for node in ast.walk(branch))
assert any(isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'request_mode_refresh' for node in ast.walk(method))
@pytest.mark.parametrize('guard,capable', [({'SafeMode': True}, True), ({'SafeMode': None}, True),
({'IsOffroad': None}, True), ({'IsOnroad': None}, True), ({'IsOffroad': True}, True), ({}, False)])
def test_onroad_safety_state_and_capability_fail_closed(guard, capable):
params = Params({'IsOffroad': False, 'IsOnroad': True, **guard})
with pytest.raises(mode.ModeError) as error:
mode.set_mode(params, 'experimental', params.values, lambda: capable)
assert error.value.status == 403
assert not params.writes
def test_onroad_api_accepts_exact_snapshot_but_rejects_stale_and_busy():
from test_longitudinal_mode_api import client_for
params = Params({'IsOffroad': False, 'IsOnroad': True})
client, signals = client_for(params)
before = client.get('/api/longitudinal_mode').json
assert before['locked'] is False
response = client.put('/api/longitudinal_mode', json={'mode': 'conditional_chill', 'expected': before['values']})
assert response.status_code == 200
assert response.json['mode'] == 'conditional_chill'
writes = params.writes.copy()
response = client.put('/api/longitudinal_mode', json={'mode': 'experimental', 'expected': before['values']})
assert response.status_code == 409
assert params.writes == writes
with mode_lock(params, exclusive=True):
assert client.get('/api/longitudinal_mode').status_code == 503
assert client.get('/api/longitudinal_mode').json['mode'] == 'conditional_chill'
@@ -0,0 +1,114 @@
"""Host-only tests: no device Params, controller, or service imports."""
import importlib.util
from itertools import product
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
MODULE = Path(__file__).resolve().parents[1] / "longitudinal_mode.py"
spec = importlib.util.spec_from_file_location("longitudinal_mode", MODULE)
mode = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mode)
class Params:
def __init__(self, values=None, fail_at=None):
self.directory = TemporaryDirectory(prefix="starpilot-mode-test-")
self.values = {"IsOffroad": True, "IsOnroad": False, "SafeMode": False,
"ExperimentalModeConfirmed": True, "ExperimentalMode": False, "ConditionalExperimental": True, "ConditionalChill": False, **(values or {})}
self.writes = []
self.fail_at = fail_at
def get_param_path(self):
return str(Path(self.directory.name) / "d")
def get(self, key):
return self.values.get(key)
def get_bool(self, key):
return self.values.get(key, False)
def put_bool(self, key, value):
self.writes.append((key, value))
if len(self.writes) == self.fail_at:
raise OSError("injected write failure")
self.values[key] = value
@pytest.mark.parametrize("cem,ccm,experimental", list(product([False, True], repeat=3)))
def test_read_precedence_without_writes(cem, ccm, experimental):
params = Params(dict(zip(mode.MODE_KEYS, [experimental, ccm, cem])))
result = mode.snapshot(params, True)
assert result["mode"] == ("conditional_experimental" if cem else "conditional_chill" if ccm else "experimental" if experimental else "chill")
assert not params.writes
@pytest.mark.parametrize("target", ["chill", "experimental", "conditional_experimental", "conditional_chill"])
@pytest.mark.parametrize("cem,ccm,experimental", list(product([False, True], repeat=3)))
def test_all_transitions(target, cem, ccm, experimental):
params = Params(dict(zip(mode.MODE_KEYS, [experimental, ccm, cem])))
before = params.values.copy()
result = mode.set_mode(params, target, before, lambda: True)
assert result["mode"] == target
if target == mode.selected_mode(before):
assert not params.writes # A no-op never normalizes dormant flags.
else:
intermediate = before.copy()
for key, value in params.writes:
intermediate[key] = value
assert mode.selected_mode(intermediate) in {mode.selected_mode(before), target}
assert sum(params.values[key] for key in mode.MODE_KEYS) == (target != "chill")
@pytest.mark.parametrize("values,capable", [({"IsOffroad": False}, True), ({"IsOffroad": None}, True),
({"IsOnroad": True}, True), ({"IsOnroad": None}, True), ({"SafeMode": True}, True),
({"SafeMode": None}, True), ({}, False)])
def test_guards_fail_closed(values, capable):
params = Params(values)
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), lambda: capable)
assert not params.writes
@pytest.mark.parametrize("target", [None, True, 1, [], {}, "Experimental", ""])
def test_invalid_modes_never_write(target):
params = Params()
with pytest.raises(mode.ModeError):
mode.set_mode(params, target, params.values.copy(), lambda: True)
assert not params.writes
def test_stale_or_missing_expected_state_never_writes():
params = Params()
for expected in [None, {}, {key: False for key in mode.MODE_KEYS}]:
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", expected, lambda: True)
assert not params.writes
@pytest.mark.parametrize("fail_at", [1, 2, 3])
def test_failed_write_keeps_old_or_requested_mode(fail_at):
params = Params(fail_at=fail_at)
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), lambda: True)
assert mode.selected_mode(params.values) in {"conditional_experimental", "experimental"}
assert len(params.writes) == fail_at # No unsafe rollback or later enabling.
def test_readback_failure_stops_without_further_writes():
params = Params()
params.put_bool = lambda key, value: params.writes.append((key, value))
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), lambda: True)
assert params.writes == [("ExperimentalMode", True)]
assert mode.selected_mode(params.values) == "conditional_experimental"
def test_guard_rechecked_before_every_write():
params = Params()
def capable():
return len(params.writes) < 2
with pytest.raises(mode.ModeError):
mode.set_mode(params, "experimental", params.values.copy(), capable)
assert params.writes == [("ExperimentalMode", True), ("ConditionalChill", False)]
@@ -0,0 +1,134 @@
"""Exercise the actual Flask handlers in isolation, without native/device imports."""
import ast
from pathlib import Path
from types import SimpleNamespace
import pytest
from flask import Flask, jsonify, request
from test_longitudinal_mode import Params, mode
SOURCE = Path(__file__).resolve().parents[1] / "the_galaxy.py"
def client_for(params, capable=True):
tree = ast.parse(SOURCE.read_text())
setup = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "setup")
routes = [node for node in setup.body if isinstance(node, ast.FunctionDef) and node.name in {"longitudinal_mode", "get_param"}]
signals = []
app = Flask(__name__)
env = dict(app=app, request=request, jsonify=jsonify, params=params,
LONGITUDINAL_MODE_LOCK=mode.WRITE_LOCK, LONGITUDINAL_MODE_KEYS=mode.MODE_KEYS,
ModeError=mode.ModeError, set_longitudinal_mode=mode.set_mode,
longitudinal_mode_snapshot=mode.snapshot, _get_longitudinal_mode_capable=lambda: capable,
update_starpilot_toggles=lambda: signals.append(True),
PERSONALITY_PROFILES_PARAM="LongitudinalPersonalityProfiles",
PERSONALITY_PARKED_PARAM_KEYS=set(), PERSONALITY_PROFILE_ENABLE_PARAM_KEYS=set(),
FAVORITE_SLOTS_PARAM="StarPilotFavoriteSlots")
exec(compile(ast.Module(body=routes, type_ignores=[]), str(SOURCE), "exec"), env)
return app.test_client(), signals
def test_live_snapshot_precedence_no_load_writes():
params = Params({"ConditionalExperimental": True, "ConditionalChill": False, "ExperimentalMode": True})
client, signals = client_for(params)
response = client.get("/api/longitudinal_mode")
assert response.status_code == 200
assert response.json["mode"] == "conditional_experimental"
assert response.json["values"]["ExperimentalMode"] is True
assert not params.writes and not signals
def test_put_readback_and_failed_partial_write_signal():
params = Params(fail_at=3)
client, signals = client_for(params)
response = client.put("/api/longitudinal_mode", json={"mode": "experimental", "expected": params.values.copy()})
assert response.status_code == 500
assert signals == [True]
assert len(params.writes) == 3
assert client.get("/api/longitudinal_mode").json["values"] == {key: params.values[key] for key in mode.MODE_KEYS}
@pytest.mark.parametrize("body", [None, [], {}, {"mode": "bad"}, {"mode": "experimental", "expected": {}}])
def test_bad_request_rejected(body):
params = Params()
client, _ = client_for(params)
assert client.put("/api/longitudinal_mode", json=body).status_code == 400
assert not params.writes
@pytest.mark.parametrize("guard,capable", [({"IsOnroad": True}, True), ({"SafeMode": True}, True), ({}, False)])
def test_api_guards_and_legacy_favorites(guard, capable):
params = Params(guard)
client, _ = client_for(params, capable)
assert client.put("/api/longitudinal_mode", json={"mode": "conditional_chill", "expected": params.values.copy()}).status_code == 403
assert client.put("/api/params", json={"key": "ConditionalChill", "value": True}).status_code == 403
assert not params.writes
def test_legacy_favorite_uses_guarded_adapter():
params = Params()
client, signals = client_for(params)
response = client.put("/api/params", json={"key": "ConditionalChill", "value": True})
assert response.status_code == 200
assert response.json["updated"] == {"ConditionalChill": True, "ConditionalExperimental": False, "ExperimentalMode": False}
assert signals == [True]
assert client.put("/api/params", json={"key": "ExperimentalMode", "value": "true"}).status_code == 400
def test_experimental_requires_acknowledgement_does_not_mark_confirmed():
params = Params({"ExperimentalModeConfirmed": False})
client, _ = client_for(params)
body = {"mode": "experimental", "expected": params.values.copy()}
assert client.put("/api/longitudinal_mode", json=body).status_code == 409
assert not params.writes
assert client.put("/api/longitudinal_mode", json={**body, "acknowledged": "true"}).status_code == 409
assert client.put("/api/longitudinal_mode", json={**body, "acknowledged": True}).status_code == 200
assert params.values["ExperimentalModeConfirmed"] is False
assert all(key != "ExperimentalModeConfirmed" for key, _ in params.writes)
@pytest.mark.parametrize("cp,values,expected", [
(None, {}, False),
(SimpleNamespace(alphaLongitudinalAvailable=False, openpilotLongitudinalControl=False), {}, False),
(SimpleNamespace(alphaLongitudinalAvailable=False, openpilotLongitudinalControl=True), {}, True),
(SimpleNamespace(alphaLongitudinalAvailable=True, openpilotLongitudinalControl=True), {"AlphaLongitudinalEnabled": False}, False),
(SimpleNamespace(alphaLongitudinalAvailable=True, openpilotLongitudinalControl=True), {"AlphaLongitudinalEnabled": True}, True),
(SimpleNamespace(alphaLongitudinalAvailable=False, openpilotLongitudinalControl=True), {"DisableOpenpilotLongitudinal": True}, False),
])
def test_capability_pending_vehicle_guards(cp, values, expected):
from contextlib import nullcontext
node = next(node for node in ast.parse(SOURCE.read_text()).body if isinstance(node, ast.FunctionDef) and node.name == "_get_longitudinal_mode_capable")
env = {"_safe_params_get_bool": lambda key, default=False: values.get(key, False),
"_safe_params_get_live_raw": lambda key: b"cp" if cp else None,
"car": SimpleNamespace(CarParams=SimpleNamespace(from_bytes=lambda raw: nullcontext(cp)))}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(SOURCE), "exec"), env)
assert env[node.name]() is expected
def test_real_params_compat_missing_and_failed_reads_fail_closed():
tree = ast.parse(SOURCE.read_text())
compat = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "ParamsCompat")
env = {}
exec(compile(ast.Module(body=[compat], type_ignores=[]), str(SOURCE), "exec"), env)
params = Params()
wrapped = env["ParamsCompat"](params)
# Fake get lacks the optional block keyword, exercising the real fallback.
assert mode.snapshot(wrapped, True)["locked"] is False
params.values.pop("IsOffroad")
assert mode.snapshot(wrapped, True)["locked"] is True
def failed_read(*args, **kwargs):
raise OSError("unreadable Params")
params.get = failed_read
assert mode.snapshot(wrapped, True)["locked"] is True
def test_manual_and_persisted_overrides_and_child_values_untouched():
overrides = {"PersistExperimentalState": True, "PersistedCEStatus": 2,
"PersistChillState": True, "PersistedCCStatus": 3, "CESpeed": 27, "CCMSpeed": 43}
params = Params(overrides)
client, _ = client_for(params)
response = client.put("/api/longitudinal_mode", json={"mode": "conditional_chill", "expected": params.values.copy()})
assert response.status_code == 200
assert {key: params.values[key] for key in overrides} == overrides
assert all(key in mode.MODE_KEYS for key, _ in params.writes)
@@ -0,0 +1,123 @@
// Local-only real DOM smoke. Requires Playwright + its Chromium; no live API.
// PLAYWRIGHT_MODULE can point at an existing isolated Playwright installation.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const repo = path.resolve(__dirname, '../../../..')
const assets = path.join(repo, 'starpilot/system/the_galaxy/assets')
// All API responses below are synthetic. Layout/assets come from this checkout.
const layoutFixture = JSON.parse(fs.readFileSync(path.join(repo, 'starpilot/common/assets/device_settings_layout.json'), 'utf8'))
const fixture = `
const layoutFixture = ${JSON.stringify(layoutFixture)};
const paramsFixture = Object.fromEntries(layoutFixture.flatMap(section => section.params || []).map(param =>
[param.key, param.data_type === 'bool' ? false : (param.default ?? param.min ?? 0)]));
const jsonResponse = value => new Response(JSON.stringify(value), {status:200});
window.showSnackbar=()=>{};
window.fetch=async (input, init={}) => {
const url=new URL(input,location.href);
const method=init.method || 'GET';
if(method !== 'GET') throw new Error('Unexpected fixture write: '+url.pathname);
if(url.pathname==='/assets/components/tools/device_settings_layout.json') return jsonResponse(layoutFixture);
if(url.pathname==='/api/params/defaults' || url.pathname==='/api/flm/workspace') return jsonResponse({});
if(url.pathname==='/api/params/all') return jsonResponse(paramsFixture);
if(url.pathname==='/api/favorites/slots') return jsonResponse({options:[],slots:[null,null,null],values:{}});
if(url.pathname==='/api/favorites/values') return jsonResponse({values:{}});
if(url.pathname==='/api/params') return new Response(String(paramsFixture[url.searchParams.get('key')] ?? false));
throw new Error('Unmocked request: '+method+' '+url.pathname);
};
paramsFixture.IsOnroad = ${process.env.GALAXY_DOM_ONROAD === '1'};
paramsFixture.GalaxyDeveloperMode = false;
let modeState = {mode:'conditional_experimental', values:{ExperimentalMode:true,ConditionalExperimental:true,ConditionalChill:false},locked:false,reason:'',experimental_confirmed:false};
window.modeWrites=[]; window.failModeWrite=false; window.failModeRead=false;
window.holdModeWrite=false;
const fixtureFetch=window.fetch;
window.fetch=async (input, init={}) => {
const url=new URL(input,location.href);
if(url.pathname!='/api/longitudinal_mode') return fixtureFetch(input,init);
if(init.method==='PUT') {
const body=JSON.parse(init.body); window.modeWrites.push(body);
if(window.holdModeWrite) await new Promise(resolve => {window.releaseModeWrite=resolve});
if(window.failModeWrite) return new Response(JSON.stringify({error:'Injected failure'}),{status:500});
modeState={...modeState,mode:body.mode,values:{ExperimentalMode:body.mode==='experimental',ConditionalExperimental:body.mode==='conditional_experimental',ConditionalChill:body.mode==='conditional_chill'}};
}
if(window.failModeRead) return new Response('{}',{status:503});
return new Response(JSON.stringify(modeState),{status:200});
};
window.lockMode=()=>{modeState={...modeState,locked:true,reason:'Safe Mode locked'};};
import {DeviceSettings} from '/assets/components/tools/device_settings.js';
DeviceSettings({params:{section:'longitudinal-speed-following'}})(document.querySelector('#app'));
`
;(async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE, args: ['--no-sandbox'] })
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } })
const errors = []
const dialogs = []
page.on('dialog', dialog => { dialogs.push(dialog.message()); dialog.dismiss() })
page.on('pageerror', error => errors.push(error.message))
await page.route('**/*', async route => {
const url = new URL(route.request().url())
if (url.hostname !== 'offline.invalid') throw new Error('External access blocked')
if (url.pathname === '/device_settings') return route.fulfill({contentType:'text/html',body:'<html><head><link rel="stylesheet" href="/assets/components/main.css"><link rel="stylesheet" href="/assets/components/settings.css"><link rel="stylesheet" href="/assets/components/tools/device_settings.css"></head><body><main id="app"></main><script type="module" src="/setup.js"></script></body></html>'})
if (url.pathname === '/setup.js') return route.fulfill({contentType:'text/javascript',body:fixture})
if (url.pathname.startsWith('/assets/')) {
const file = path.join(assets, url.pathname.slice('/assets/'.length))
if (fs.existsSync(file) && fs.statSync(file).isFile()) return route.fulfill({path:file})
}
return route.fulfill({status:404,body:'not found'})
})
await page.goto('http://offline.invalid/device_settings')
await page.getByRole('button', {name:'Longitudinal (Speed & Following)',exact:true}).click()
const select = page.locator('#ds-LongitudinalControlMode')
await select.waitFor()
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === 'conditional_experimental')
assert.equal(await select.isEnabled(), true)
assert.deepEqual(await select.locator('option').allTextContents(), ['Chill','Experimental','Conditional Experimental','Conditional Chill'])
assert.equal(await page.locator('#ds-ConditionalExperimental, #ds-ConditionalChill').count(), 0)
assert.equal(await page.locator('#ds-manual-CESpeed').count(), 0)
const manage = page.locator('[aria-controls="ds-LongitudinalControlMode-children"]')
assert.equal(await manage.getAttribute('aria-expanded'), 'false')
if(process.env.GALAXY_DOM_SCREENSHOT) await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT.replace('.png','-collapsed.png'),fullPage:true})
await manage.click()
assert.equal(await page.locator('#ds-manual-CESpeed').isVisible(), true)
await manage.click()
assert.equal(await page.locator('#ds-manual-CESpeed').count(), 0)
assert.equal(await select.isVisible(), true)
await manage.click()
assert.equal(await page.locator('#ds-manual-CCMSpeed').count(), 0)
assert.equal(await page.evaluate(() => window.modeWrites.length), 0)
for (const target of ['conditional_chill','chill']) {
await select.selectOption(target)
await page.waitForFunction(target => document.querySelector('#ds-LongitudinalControlMode')?.value === target && !document.querySelector('#ds-LongitudinalControlMode')?.disabled, target)
assert.equal(await page.locator('#ds-manual-CESpeed').count(), 0)
assert.equal(await manage.count(), target === 'chill' ? 0 : 1)
assert.equal(await page.locator('#ds-manual-CCMSpeed').count(), target === 'conditional_chill' ? 1 : 0)
}
const beforeExperimental = await page.evaluate(() => window.modeWrites.length)
await page.evaluate(() => { window.holdModeWrite=true })
await select.selectOption('experimental')
await page.waitForFunction(() => !!window.releaseModeWrite && document.querySelector('#ds-LongitudinalControlMode')?.disabled)
await page.evaluate(() => { window.holdModeWrite=false; window.releaseModeWrite() })
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === 'experimental' && !document.querySelector('#ds-LongitudinalControlMode')?.disabled)
assert.equal(await page.evaluate(() => window.modeWrites.at(-1).acknowledged), true)
assert.equal(await page.evaluate(() => window.modeWrites.length), beforeExperimental + 1)
assert.deepEqual(dialogs, [])
assert.equal(await manage.count(), 0)
assert.equal(await page.locator('#ds-manual-CESpeed, #ds-manual-CCMSpeed').count(), 0)
await page.evaluate(() => { window.failModeWrite = true })
await select.selectOption('conditional_chill')
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === 'experimental' && !document.querySelector('#ds-LongitudinalControlMode')?.disabled)
await page.evaluate(() => { window.failModeWrite = false })
await select.selectOption('conditional_experimental')
await page.waitForFunction(() => document.querySelector('#ds-manual-CESpeed'))
await page.screenshot({path:process.env.GALAXY_DOM_SCREENSHOT || '/tmp/galaxy-longitudinal-mode.png',fullPage:true})
await page.evaluate(() => window.lockMode())
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.disabled)
await page.evaluate(() => { window.failModeRead=true })
await page.waitForFunction(() => document.querySelector('#ds-LongitudinalControlMode')?.value === '')
assert.equal(await select.isDisabled(), true)
assert.deepEqual(errors, [])
console.log('PASS: real DOM initial CEM precedence/no writes; collapsed Manage disclosure/visible dropdown; all four choices; mode-specific children; no experimental dialog; request acknowledgement; failed-write readback; Safe Mode and missing-state locks; zero page errors')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode=1 })
@@ -0,0 +1,157 @@
"""Host-only evidence for residual nonparticipating readers/writers.
Execute the actual runtime selector functions/assignments via AST, without
importing native Params or starting services. Legacy UI readers and external
Dom writers still don't share the adapter lock. They cannot gain atomicity from
write ordering. Driving handoff coverage is in test_coherent_mode_handoff.py.
"""
import ast
from itertools import combinations_with_replacement, permutations, product
from pathlib import Path
from types import SimpleNamespace
import pytest
from test_longitudinal_mode import Params, mode
from openpilot.starpilot.common.longitudinal_mode import read_mode_values
ROOT = Path(__file__).resolve().parents[4]
def runtime_requested():
path = ROOT / 'starpilot/common/experimental_state.py'
tree = ast.parse(path.read_text())
# Future annotations make Params only a type annotation. Do not import it.
tree.body = [node for node in tree.body if not (
isinstance(node, ast.ImportFrom) and node.module == 'openpilot.common.params')]
namespace = {}
exec(compile(tree, str(path), 'exec'), namespace)
return namespace['requested_experimental_mode']
REQUESTED = runtime_requested()
def runtime_toggles(params):
path = ROOT / 'starpilot/common/starpilot_variables.py'
tree = ast.parse(path.read_text())
names = {'conditional_experimental_mode', 'conditional_chill_mode'}
nodes = [node for node in ast.walk(tree) if isinstance(node, ast.Assign)
and len(node.targets) == 1 and isinstance(node.targets[0], ast.Attribute)
and isinstance(node.targets[0].value, ast.Name)
and node.targets[0].value.id == 'toggle' and node.targets[0].attr in names]
assert len(nodes) == 2
nodes.sort(key=lambda node: node.lineno)
toggle = SimpleNamespace(openpilot_longitudinal=True, safe_mode=False)
selection = ast.Module(body=[], type_ignores=[])
first = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign)
and isinstance(node.targets[0], ast.Name) and node.targets[0].id == 'mode_values')
selection.body.extend([first, *nodes])
exec(compile(selection, str(path), 'exec'),
{'toggle': toggle, 'self': SimpleNamespace(params=params), 'read_mode_values': read_mode_values})
return toggle
class InterleavedParams(Params):
def __init__(self, values, writes, schedule, manual=True):
super().__init__(values)
self.pending = list(writes)
self.schedule = iter(schedule)
self.applied = 0
self.trace = []
self.manual = manual
def get_bool(self, key):
if key in mode.MODE_KEYS:
until = next(self.schedule, self.applied)
while self.applied < until:
name, enabled = self.pending[self.applied]
self.values[name] = enabled
self.applied += 1
self.trace.append((key, self.values[key]))
return super().get_bool(key)
def get_int(self, key, default=0):
# Both conditional modes request EXP for this manual override fixture.
return {'PersistedCEStatus': 2, 'PersistedCCStatus': 1}.get(key, default) if self.manual else default
def observed_branch(params):
for key, enabled in params.trace:
if enabled:
return next(name for name, value in mode.MODES.items() if value == key)
return 'chill'
def outcomes(values, writes):
result = set()
# Reader visits at most three mode keys. Include every relative placement
# of the ordered writer operations before/between those reads.
for schedule in combinations_with_replacement(range(len(writes) + 1), 3):
params = InterleavedParams(values, writes, schedule)
REQUESTED(params)
result.add(observed_branch(params))
return result
@pytest.mark.parametrize('bits', list(product([False, True], repeat=3)))
@pytest.mark.parametrize('target', list(mode.MODES))
def test_adapter_cannot_protect_nonparticipating_ui_readers(bits, target):
params = Params(dict(zip(mode.MODE_KEYS, bits)))
before = params.values.copy()
old = mode.selected_mode(before)
result = mode.set_mode(params, target, before, lambda: True)
seen = outcomes(before, params.writes)
assert result['mode'] == target
if old == target:
assert not params.writes
assert seen == {old}
else:
assert old in seen and target in seen
# Nonparticipating legacy readers may still observe ordinary fallback;
# the exact torn-read counterexample below is retained, not concealed.
assert seen <= {old, target, 'chill', 'experimental'}
@pytest.mark.parametrize('writes', list(permutations([
('ConditionalExperimental', True), ('ConditionalChill', False)])))
def test_no_order_of_required_ccm_to_cem_writes_fixes_runtime_reader(writes):
values = {'ConditionalExperimental': False, 'ConditionalChill': True, 'ExperimentalMode': False}
assert outcomes(values, writes) == {'conditional_experimental', 'conditional_chill', 'chill'}
def test_dom_target_first_is_coherent_at_write_boundaries_but_not_between_reads():
values = {'ConditionalExperimental': False, 'ConditionalChill': True, 'ExperimentalMode': False}
writes = [('ConditionalExperimental', True), ('ConditionalChill', False)]
params = Params(values)
stored = [mode.selected_mode(params.values)]
for key, value in writes:
params.put_bool(key, value)
stored.append(mode.selected_mode(params.values))
assert stored == ['conditional_chill', 'conditional_experimental', 'conditional_experimental']
# CEM read before both writes, CCM and EXP after both writes: a branch which
# never existed in storage. Manual EXP in both endpoints becomes false.
params = InterleavedParams(values, writes, [0, 2, 2])
assert REQUESTED(params) is False
assert observed_branch(params) == 'chill'
for endpoint in [values, params.values]:
assert REQUESTED(InterleavedParams(endpoint, [], [])) is True
def test_nonparticipating_writer_can_still_tear_a_shared_reader():
params = InterleavedParams(
{'ConditionalExperimental': True, 'ConditionalChill': False, 'ExperimentalMode': False},
[('ConditionalChill', True), ('ConditionalExperimental', False)], [0, 0, 2])
toggles = runtime_toggles(params)
assert not toggles.conditional_experimental_mode
assert not toggles.conditional_chill_mode
assert mode.selected_mode(params.values) == 'conditional_chill'
def test_upstream_manual_and_default_semantics_are_not_aliases():
cem = {'ConditionalExperimental': True, 'ConditionalChill': False, 'ExperimentalMode': True}
ccm = {'ConditionalExperimental': False, 'ConditionalChill': True, 'ExperimentalMode': False}
assert REQUESTED(InterleavedParams(cem, [], [], manual=False)) is False
assert REQUESTED(InterleavedParams(ccm, [], [], manual=False)) is True
assert REQUESTED(InterleavedParams(cem, [], [], manual=True)) is True
assert REQUESTED(InterleavedParams({**ccm, 'SafeMode': True}, [], [], manual=True)) is False
@@ -0,0 +1,95 @@
"""Concrete regressions from the on-road handoff review; fake Params only."""
import ast
import copy
from itertools import product
from types import SimpleNamespace as NS
import pytest
from test_longitudinal_mode import Params, mode
from test_coherent_mode_handoff import nodes_in, execute, selfdrive_result, mode_lock, read_mode_values
@pytest.mark.parametrize('bits', list(product([False, True], repeat=3)))
@pytest.mark.parametrize('target', list(mode.MODES))
def test_failed_write_at_every_boundary_keeps_old_or_requested_mode(bits, target):
before = dict(zip(mode.MODE_KEYS, bits))
old = mode.selected_mode(before)
if old == target:
return # No-op normalisation is covered separately.
for fail_at, after_write in product([1, 2, 3], [False, True]):
params = Params(before | {'IsOffroad': False, 'IsOnroad': True})
put = params.put_bool
stored = []
def failing(key, value):
if len(params.writes) + 1 == fail_at and not after_write:
params.writes.append((key, value))
raise OSError('before write')
put(key, value)
stored.append(mode.selected_mode(params.values))
if len(params.writes) == fail_at:
raise OSError('after write')
params.put_bool = failing
with pytest.raises(mode.ModeError):
mode.set_mode(params, target, before, lambda: True)
assert len(params.writes) == fail_at
assert set(stored) <= {old, target}
assert mode.selected_mode(read_mode_values(params)) in {old, target}
def update_prefix():
method = copy.deepcopy(next(node for node in ast.walk(nodes_in('starpilot/common/starpilot_variables.py'))
if isinstance(node, ast.FunctionDef) and node.name == 'update'))
# Execute the real pre-mutation branch, including its early return.
assert isinstance(method.body[0], ast.Assign) and isinstance(method.body[1], ast.Try)
method.body = method.body[:2] + ast.parse('return mode_values, clear_update_flag').body
ast.fix_missing_locations(method)
env = {'read_mode_values': read_mode_values}
execute([method], env)
return env['update']
@pytest.mark.parametrize('existing', [False, True])
@pytest.mark.parametrize('failure', ['busy', 'unavailable'])
def test_startup_and_sync_refresh_dont_crash_or_mutate_old_snapshot(existing, failure, tmp_path):
params, memory = Params(), Params()
old = NS(longitudinal_mode_values={key: True for key in mode.MODE_KEYS}, sentinel={'untouched': True}) if existing else NS()
before = copy.deepcopy(vars(old))
variables = NS(params=params, params_memory=memory, starpilot_toggles=old)
run = update_prefix()
if failure == 'busy':
with mode_lock(params, exclusive=True):
result = run(variables)
else:
params.get_param_path = lambda: str(tmp_path/'missing'/'d')
result = run(variables)
assert vars(old) == before
assert memory.get_bool('StarPilotTogglesUpdated')
if existing:
assert result is None # Return before the first shared-object mutation.
else:
values, clear_update_flag = result
assert values == {key: False for key in mode.MODE_KEYS}
assert clear_update_flag is False # Complete startup in Chill, retry later.
@pytest.mark.parametrize('previous,plan,conditional', list(product([False, True], repeat=3)))
def test_old_replay_without_complete_plan_retains_dom_behaviour(previous, plan, conditional):
cached = NS(conditional_experimental_mode=conditional, conditional_chill_mode=False)
assert selfdrive_result(plan, previous=previous, cached=cached, replay=True) is (plan if conditional else previous or plan)
assert selfdrive_result(plan, previous=previous, cached=cached, replay=True, safe=True) is False
def test_live_params_thread_never_uses_replay_fallback():
method = next(node for node in ast.walk(nodes_in('selfdrive/selfdrived/selfdrived.py'))
if isinstance(node, ast.FunctionDef) and node.name == 'params_thread')
branch = next(node for node in ast.walk(method) if isinstance(node, ast.If) and isinstance(node.test, ast.Name) and node.test.id == 'REPLAY')
calls = []
state = NS(params=Params({'ExperimentalMode': True}), params_memory=Params(), safe_mode=False,
experimental_mode=False, starpilot_toggles=NS(conditional_experimental_mode=False), CP=object())
env = dict(self=state, REPLAY=False, request_mode_refresh=lambda *args: calls.append('refresh'), experimental_mode_available=lambda cp: True)
execute([branch], env)
assert state.experimental_mode is False and calls == ['refresh']
env['REPLAY'] = True
execute([branch], env)
assert state.experimental_mode is True and calls == ['refresh']
@@ -0,0 +1,33 @@
"""Mode writes stop immediately when safety/road-state eligibility changes."""
import pytest
from test_longitudinal_mode import Params, mode
@pytest.mark.parametrize('after_write', [1, 2, 3])
@pytest.mark.parametrize('key,value', [('IsOnroad', True), ('IsOffroad', False), ('SafeMode', True)])
def test_guard_transition_during_write_stops_further_writes(after_write, key, value):
params = Params()
original_put = params.put_bool
def put(name, enabled):
original_put(name, enabled)
if len(params.writes) == after_write:
params.values[key] = value
params.put_bool = put
with pytest.raises(mode.ModeError):
mode.set_mode(params, 'experimental', params.values.copy(), lambda: True, acknowledged=True)
assert len(params.writes) == after_write
assert mode.selected_mode(params.values) in {'conditional_experimental', 'experimental'}
def test_multi_key_write_boundaries_never_expose_intermediate_chill():
# Target first: partial failure cannot select an unrelated stored mode.
params = Params({'ConditionalExperimental': True, 'ConditionalChill': False, 'ExperimentalMode': False})
observed = []
original_put = params.put_bool
def put(name, enabled):
original_put(name, enabled)
observed.append(mode.selected_mode({key: params.get_bool(key) for key in mode.MODE_KEYS}))
params.put_bool = put
mode.set_mode(params, 'conditional_chill', params.values.copy(), lambda: True)
assert observed == ['conditional_experimental', 'conditional_chill', 'conditional_chill']
@@ -100,7 +100,9 @@ def test_ui_restores_hierarchical_sub_toggle_rendering():
assert "hasChildParams" in params
assert "SettingTree" in settings
assert '<SettingTree :params="activeSection.params"' in settings
assert '<SettingTree :params="ordinaryParams(activeSection)"' in settings
assert '<LongitudinalMode v-if="modeSection(activeSection)"' in settings
assert 's.params.filter(p => !this.isModeParam(p))' in settings
# SettingTree recursively reveals children; subpanels are collapsed by default
# (classic Galaxy behavior) and expand only when the user taps Manage/Close.
+60 -16
View File
@@ -114,6 +114,10 @@ from openpilot.starpilot.common.maps_download_progress import (
selection_key,
)
from openpilot.starpilot.common.experimental_state import sync_persist_chill_state, sync_persist_experimental_state
from openpilot.starpilot.system.the_galaxy.longitudinal_mode import (
MODE_KEYS as LONGITUDINAL_MODE_KEYS, ModeError, WRITE_LOCK as LONGITUDINAL_MODE_LOCK,
set_mode as set_longitudinal_mode, snapshot as longitudinal_mode_snapshot,
)
from openpilot.starpilot.common.favorite_slots import (
FAVORITE_SLOTS_PARAM,
SETTINGS_CATALOG_PATH,
@@ -4296,6 +4300,23 @@ def _get_vehicle_parked():
except Exception:
return False
def _get_longitudinal_mode_capable():
# Do not authorize from a default or a stale toggle snapshot. Pending disable
# also blocks selection until the driving stack has regenerated CarParams.
if _safe_params_get_bool("DisableOpenpilotLongitudinal", default=True):
return False
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
if not cp_bytes:
return False
try:
with car.CarParams.from_bytes(cp_bytes) as cp:
if cp.alphaLongitudinalAvailable and not _safe_params_get_bool("AlphaLongitudinalEnabled", default=False):
return False
return bool(cp.openpilotLongitudinalControl)
except Exception:
return False
def _get_alpha_longitudinal_available():
cp_bytes = _safe_params_get_live_raw("CarParamsPersistent")
if not cp_bytes:
@@ -5941,6 +5962,24 @@ def setup(app):
"following": list(FOLLOWING_SPEEDS_MPH),
},
}), 200
@app.route("/api/longitudinal_mode", methods=["GET", "PUT"])
def longitudinal_mode():
with LONGITUDINAL_MODE_LOCK:
try:
if request.method == "GET":
return jsonify(longitudinal_mode_snapshot(params, _get_longitudinal_mode_capable())), 200
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object."}), 400
try:
result = set_longitudinal_mode(params, data.get("mode"), data.get("expected"), _get_longitudinal_mode_capable, data.get("acknowledged") is True)
finally:
update_starpilot_toggles()
return jsonify(result), 200
except ModeError as error:
return jsonify({"error": str(error)}), error.status
except Exception:
return jsonify({"error": "Longitudinal mode state is unavailable. Refresh before retrying."}), 503
@app.route("/api/params", methods=["GET", "PUT"])
def get_param():
@@ -5956,6 +5995,27 @@ def setup(app):
return jsonify({"error": "Driving personality settings can only be changed while parked."}), 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:
if type(data["value"]) is not bool:
return jsonify({"error": "Mode settings require a JSON boolean."}), 400
with LONGITUDINAL_MODE_LOCK:
try:
before = longitudinal_mode_snapshot(params, _get_longitudinal_mode_capable())
candidate = {**before["values"], key: data["value"]}
if data["value"] and key in {"ConditionalExperimental", "ConditionalChill"}:
candidate["ConditionalChill" if key == "ConditionalExperimental" else "ConditionalExperimental"] = False
target = ("conditional_experimental" if candidate["ConditionalExperimental"] else
"conditional_chill" if candidate["ConditionalChill"] else
"experimental" if candidate["ExperimentalMode"] else "chill")
try:
result = set_longitudinal_mode(params, target, before["values"], _get_longitudinal_mode_capable, data.get("acknowledged") is True)
finally:
update_starpilot_toggles()
return jsonify({"updated": result["values"], "message": "Longitudinal control mode updated."}), 200
except ModeError as error:
return jsonify({"error": str(error)}), error.status
except Exception:
return jsonify({"error": "Longitudinal mode state is unavailable."}), 503
if key.lower() == FAVORITE_SLOTS_PARAM.lower():
key = FAVORITE_SLOTS_PARAM
raw_slots = data["value"]
@@ -6228,22 +6288,6 @@ def setup(app):
"updated": updated,
}), 200
if key in {"ConditionalExperimental", "ConditionalChill"}:
enabled = str_val.strip() in ("1", "true", "True")
params.put_bool(key, enabled)
updated = {key: enabled}
if enabled:
other_key = "ConditionalChill" if key == "ConditionalExperimental" else "ConditionalExperimental"
params.put_bool(other_key, False)
updated[other_key] = False
update_starpilot_toggles()
return jsonify({
"message": f"Parameter '{key}' updated successfully.",
"updated": updated,
}), 200
if key == "CustomAccelProfile":
enabled = str_val.strip() in ("1", "true", "True")
params.put_bool(key, enabled)