mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-11 02:33:51 +08:00
Galaxy: unify longitudinal control modes
Co-authored-by: AngusBell97 <124716116+AngusBell97@users.noreply.github.com>
This commit is contained in:
committed by
firestar5683
parent
a19beda327
commit
c2921c1a8f
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user