SLC Fixes

This commit is contained in:
firestarsdog
2026-10-21 19:18:54 -04:00
parent 0972fec4ef
commit 3d2d094c8b
15 changed files with 181 additions and 58 deletions
@@ -35,6 +35,7 @@ class CarState(CarStateBase):
self.lc_button = 0
self.cancel_button = False
self.cancel_resume_button = FordStockCruiseButton()
self.cruise_buttons = {ButtonType.accelCruise: False, ButtonType.decelCruise: False}
self.steering_angle_offset_deg = 0.0
self.lkas_available = False
self.lkas_available_ts_nanos = 0
@@ -169,6 +170,13 @@ class CarState(CarStateBase):
if self.CP.openpilotLongitudinalControl:
button_events += create_button_events(self.cancel_button, prev_cancel_button, {1: ButtonType.cancel})
for button, signals in (
(ButtonType.accelCruise, ('CcAslButtnSetIncPress', 'CcAslButtnResIncPress', 'CcAslButtnIndxIncPress')),
(ButtonType.decelCruise, ('CcAslButtnSetDecPress', 'CcAslButtnResDecPress', 'CcAslButtnIndxDecPress')),
):
pressed = any(self.buttons_stock_values[signal] for signal in signals)
button_events += create_button_events(pressed, self.cruise_buttons[button], {1: button})
self.cruise_buttons[button] = pressed
ret.buttonEvents = button_events
@@ -1,4 +1,6 @@
"""Original switch context, state event and reached controller regressions."""
import pytest
from opendbc.can import CANPacker
from opendbc.car import Bus, structs, gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
@@ -39,6 +41,27 @@ def test_actual_parser_cancel_edges_are_op_long_only_and_contextual():
assert cancel == (expected if op_long else [])
@pytest.mark.parametrize('vehicle', [CAR.FORD_ESCAPE_MK4, CAR.FORD_MUSTANG_MACH_E_MK1])
@pytest.mark.parametrize('op_long', [False, True])
@pytest.mark.parametrize('signal,button', [
('CcAslButtnSetIncPress', 'accelCruise'), ('CcAslButtnResIncPress', 'accelCruise'),
('CcAslButtnIndxIncPress', 'accelCruise'), ('CcAslButtnSetDecPress', 'decelCruise'),
('CcAslButtnResDecPress', 'decelCruise'), ('CcAslButtnIndxDecPress', 'decelCruise'),
])
def test_directional_cruise_edges_reach_software_cruise_only_with_op_long(vehicle, op_long, signal, button):
cp = params(vehicle, alpha=op_long)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
for i, (pressed, expected) in enumerate(((0, []), (1, [True]), (1, []), (0, [False]))):
parsers[Bus.pt].update([(1_000_000_000 + i * 10_000_000, [
packer.make_can_msg('Steering_Data_FD1', parsers[Bus.pt].bus, {signal: pressed}),
])])
out = state.update(parsers)
edges = [event.pressed for event in out.buttonEvents if event.type == getattr(structs.CarState.ButtonEvent.Type, button)]
assert edges == (expected if op_long else [])
def test_reached_controller_preserves_precedence_cadence_and_stock_bits():
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1, alpha=False)
controller = CarController(DBC[cp.carFingerprint], cp)
+2 -1
View File
@@ -261,7 +261,8 @@ class VCruiseHelper:
target_kph = round(target_mps * CV.MS_TO_KPH)
else:
target_kph = round(target_mps * CV.MS_TO_MPH) * CV.MPH_TO_KPH
self.v_cruise_kph = float(np.clip(target_kph, V_CRUISE_MIN, V_CRUISE_MAX))
# Match ordinary button precision so stepping away and back restores this target.
self.v_cruise_kph = float(np.clip(round(target_kph, 1), V_CRUISE_MIN, V_CRUISE_MAX))
self.v_cruise_cluster_kph = self.v_cruise_kph
def initialize_v_cruise(self, CS, experimental_mode: bool, *, resume: bool | None = None, initial_speed: object = V_CRUISE_INITIAL) -> None:
@@ -21,7 +21,8 @@ def test_real_absolute_request_changes_software_speed_without_physical_button(me
assert sender.dispatch(True, sm, params, publisher, now_ns=now, target_mps=target)
assert str(publisher.message.slcAction.kind) == 'cruiseSet'
assert consumer.apply(publisher.message, params, state, sm, helper, now_ns=now, is_metric=metric, enabled=True)
assert helper.v_cruise_kph == pytest.approx(value if metric else value * CV.MPH_TO_KPH)
assert helper.v_cruise_kph == pytest.approx(value if metric else round(value * CV.MPH_TO_KPH, 1))
assert round(helper.v_cruise_kph * (1 if metric else CV.KPH_TO_MPH)) == value
assert not list(state.buttonEvents)
assert not consumer.apply(publisher.message, params, state, sm, helper, now_ns=now + 100_000_000,
is_metric=metric, enabled=True)
@@ -528,12 +528,8 @@ vm.state.selected = "current_speed"
vm.setSpeedSources("speedSources", true)
assert.notEqual(vm.state.draft.speedSources, true) // Only from the cruise widget's own panel.
vm.state.selected = "cruise_limits"
vm.setSpeedAlign("center")
assert.equal(vm.state.draft.speedAlign, "center")
vm.setSpeedAlign("justify")
assert.equal(vm.state.draft.speedAlign, "center") // Only left, center or right.
vm.undo()
assert.equal(vm.state.draft.speedAlign ?? "left", "left")
assert.doesNotMatch(OnroadLayoutPage.template, /speedAlign|Text alignment/)
assert.equal(OnroadLayoutPage.methods.setSpeedAlign, undefined)
vm.setSpeedSources("palette", true)
assert.equal(vm.state.draft.palette === true, false) // Only the two sources settings.
// Moving or removing widgets keeps the same backdrop; scene and size changes replace it.
@@ -715,7 +711,9 @@ function feedFixture() {
const normal = feedFixture()
normal.feed.start()
assert.equal(normal.requests[0].url, "./api/ui/layout")
await normal.reply(0)
const legacyAlignment = snapshot()
legacyAlignment.document.speedAlign = "right"
await normal.reply(0, legacyAlignment)
const draft = copy(normal.feed.data.document); draft.layouts.compact.max_speed.x = 80
normal.feed.save(draft)
normal.feed.load(); normal.feed.save(draft)
@@ -724,6 +722,7 @@ assert.deepEqual(JSON.parse(normal.requests[1].options.body), { revision: "saved
assert.equal(normal.requests[1].options.credentials, "same-origin")
await normal.reply(1, { ...snapshot(), document: draft, revision: "new-revision" })
assert.equal(normal.updates.at(-1).draft.layouts.compact.max_speed.x, 80)
assert.equal(normal.updates.at(-1).draft.speedAlign, "right") // Unrelated edits preserve legacy layout fields.
assert.equal(normal.feed.data.revision, "new-revision")
for (const status of [409, 503, 400]) {
@@ -21,7 +21,7 @@ metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96,
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "car_exit"]
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
const doc = { version: 4, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "left", canvas: { width: 2880, height: 1080 }, widgets }
const doc = { version: 4, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", canvas: { width: 2880, height: 1080 }, widgets }
const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 },
revision: "aa-screen-and-layout", editable: true, valid: true,
@@ -29,6 +29,7 @@ const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
const data = editorSnapshot(raw)
assert.equal(validSnapshot(data), true)
assert.equal(validDocument(data.document, data.metadata), true)
assert.equal(data.document.speedAlign, "center") // Legacy preferences still load after removing the editor control.
assert.equal(data.document.layouts.large.car_exit.opacity, 75)
assert.equal(data.document.layouts.large.car_exit.background, false)
const resizedMonitor = copy(data.document)
@@ -236,6 +237,7 @@ assert.equal(requests[0].url, "./api/android-auto/layout")
assert.equal(requests[1].body.document.version, 4)
assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedAlign", "speedSources", "speedSourcesCard", "version", "wheelPedalFeedback", "widgets"])
assert.equal(requests[1].body.document.widgets.current_speed.x, 1170)
assert.equal(requests[1].body.document.speedAlign, "center")
assert.equal(updates.at(-1).notice, "Android Auto layout saved.")
feed.stop()
console.log("Projection editor: isolated document, strict snapshot, routing confirmation, actual Vue template, endpoint and placement-only save passed")
@@ -105,9 +105,9 @@ export function previewPoint(event, rect, profile) {
}
}
// Android Auto display switches, independent of the comma screen's preferences.
// Text alignment inside the cruise and speed-limit card, matching onroad_customization.SPEED_ALIGNMENTS.
// Legacy saved layouts retain this field even though the paired speed widget uses fixed alignment.
export const SPEED_ALIGNMENTS = ["left", "center", "right"]
// Android Auto display switches, independent of the comma screen's preferences.
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"]
export function validDocument(document, metadata) {
@@ -229,15 +229,6 @@ export const ONROAD_LAYOUT_TEMPLATE = `
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
<label v-if="selectedWidget.kind === 'clock'" class="gx-layout__clock-format"><input type="checkbox" :checked="!!state.draft.clock24Hour" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setClock24Hour"> Use 24-hour time</label>
<div v-if="selectedWidget.kind === 'cruise_limits'" class="gx-layout__favorite-display">
<span>Text alignment</span>
<div class="gx-segment" role="group" aria-label="Cruise and speed limit text alignment">
<button v-for="align in ['left', 'center', 'right']" :key="align" class="gx-btn" type="button"
:class="(state.draft.speedAlign || 'left') === align ? 'gx-btn--primary' : 'gx-btn--tonal'"
:aria-pressed="(state.draft.speedAlign || 'left') === align" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
@click="setSpeedAlign(align)">{{ align === 'left' ? 'Left' : align === 'center' ? 'Center' : 'Right' }}</button>
</div>
</div>
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits'" :row="switchRow('Show Speed-Limit Sources', state.draft.speedSources === true)" :index="0"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSources', value === 'On')" />
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits' && state.draft.speedSources === true" :row="switchRow('Show as Separate Card', state.draft.speedSourcesCard === true)" :index="1"
@@ -851,14 +851,6 @@ export const OnroadLayoutPage = {
this.recordChange(before)
this.state.notice = ""
},
setSpeedAlign(align) {
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" ||
!["left", "center", "right"].includes(align)) return
const before = clone(this.state.draft)
this.state.draft.speedAlign = align
this.recordChange(before)
this.state.notice = ""
},
setLargeUiGammaTrial(value) {
if (!this.projection || !this.editable || this.state.drag || this.state.layerDrag) return
const before = clone(this.state.draft)
+4 -1
View File
@@ -42,7 +42,8 @@ limit is qualified again.
A qualified manual change to a different set speed marks an override, for increases
and decreases. An X crosses the sign while override is active. Returning to the
applied set speed clears it, using card's receipt to account for whole-unit rounding. Adopting
a new limit also clears it. New-limit decisions continue during override.
a new limit also clears it. Adoption retains the ordinary buttons' 0.1 km/h internal
precision, including for mph targets. New-limit decisions continue during override.
The accelerator temporarily allows higher vehicle speed without changing set speed
or creating an SLC override. Adoption and confirmation can update software set
@@ -74,6 +75,8 @@ qualified planner presentation. A command receipt distinguishes application from
limit acceptance. Rejected or expired commands may retry with current evidence
while the accepted limit and expected set speed remain current, with no pending
decision or manual override. Applied commands are not repeated.
Declining or timing out a returned limit never revives an earlier adoption command;
the historical adoption remains recorded independently of the new rejection.
Transport changes append to reserved custom messages. `SlcState` appends automatic
countdown metadata at ordinals 51–52 and latest-received display metadata at 53–55.
@@ -144,6 +144,7 @@ class AdoptionProposal:
class State:
session_id: str
observation: Observation = Observation(ObservationKind.ABSENT)
# A historical adoption may match a returned proposal the driver now declines.
accepted: AcceptedLimit | None = None
pending: PendingDecision | None = None
rejected: frozenset[Candidate] = frozenset()
@@ -273,8 +274,6 @@ def _state_errors(state: State) -> list[str]:
all(_candidate_valid(c, state.session_id) for c in state.rejected))
if not rejections_valid:
errors.append("invalid rejection history")
elif state.accepted is not None and _accepted_valid(state.accepted) and state.accepted.candidate in state.rejected:
errors.append("accepted candidate is also rejected")
if state.pending is not None:
pending = state.pending
if (not isinstance(pending, PendingDecision) or not _candidate_valid(pending.candidate) or
@@ -413,7 +412,7 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
adopt_receipt = replace(adopt_receipt, status="different_presentation")
else:
# Recorded for any authority; only an owner of longitudinal control commands a target.
state, history = _accept(replace(state, rejected=state.rejected - {candidate}), candidate, now_ns)
state, history = _accept(replace(state, rejected=frozenset(c for c in state.rejected if c.speed_mps != candidate.speed_mps)), candidate, now_ns)
assert state.accepted is not None
adopt_receipt = replace(adopt_receipt, consumed=True, status="adopt")
if not authority.can_control:
+2 -1
View File
@@ -430,7 +430,8 @@ class Runtime:
def _command(self, result: comp.Result, sm, now_ns: int, old_pending,
action: acc.DriverAction | None, adopt: acc.AdoptRequest | None):
decision = result.acceptance
if (decision is None or decision.state.accepted is None or decision.state.pending is not None or decision.authority is None or
if (decision is None or decision.basis not in ('accepted', 'driver_accept', 'driver_adopt') or
decision.state.accepted is None or decision.state.pending is not None or decision.authority is None or
not decision.authority.can_control or result.coordinate is None or
result.domain is None or result.domain.context is None or result.selection is None or
result.selection.observation.candidate is None or
@@ -423,17 +423,25 @@ class TestAcceptance(unittest.TestCase):
self.assertIsNone(result.control_target_mps)
self.assertIsNone(result.history_write)
def test_accepted_or_pending_candidate_cannot_also_be_rejected(self):
accepted_conflict = replace(saved(), rejected=frozenset({HIGH}))
pending_conflict = replace(self.pending().state, rejected=frozenset({LOW}))
for state in (accepted_conflict, pending_conflict):
with self.subTest(state=state):
result = step(state, valid(HIGH), ACTIVE, Policy(False, False, True), now_ns=NS)
self.assertTrue(result.errors)
self.assertIsNone(result.control_target_mps)
self.assertIsNone(result.history_write)
self.assertIs(result.state, state)
self.assertEqual(result.state.accepted.candidate, HIGH)
def test_pending_candidate_cannot_also_be_rejected(self):
state = replace(self.pending().state, rejected=frozenset({LOW}))
result = step(state, valid(HIGH), ACTIVE, Policy(False, False, True), now_ns=NS)
self.assertTrue(result.errors)
self.assertIsNone(result.control_target_mps)
self.assertIsNone(result.history_write)
self.assertIs(result.state, state)
self.assertEqual(result.state.accepted.candidate, HIGH)
def test_rejecting_a_returned_limit_preserves_its_earlier_accepted_history(self):
other = self.pending()
returned = self.pending(candidate=HIGH, state=other.state, now_ns=NS)
declined = self.act(returned.state, ActionKind.REJECT, now_ns=2 * NS)
held = step(declined.state, valid(HIGH), ACTIVE, Policy(), now_ns=3 * NS)
self.assertFalse(held.errors)
self.assertEqual(held.basis, 'rejected')
self.assertEqual(held.state.accepted, returned.state.accepted)
self.assertIn(HIGH, held.state.rejected)
self.assertIsNone(held.state.pending)
def test_changed_limits_do_not_accumulate_obsolete_rejections(self):
state = saved()
@@ -538,6 +546,18 @@ class TestAcceptance(unittest.TestCase):
self.assertTrue(result.adoption.clear_override)
self.assertEqual(result.adoption.reconciliation_speed_mps, HIGH.speed_mps)
def test_explicit_adopt_clears_same_speed_rejection_after_provider_change(self):
rejected = self.act(self.pending().state, ActionKind.REJECT)
refreshed = replace(LOW, source='map', observation_identity=geographic('refreshed'))
shown = step(rejected.state, valid(refreshed), ACTIVE, Policy(), now_ns=2 * NS)
adopted = step(shown.state, valid(refreshed), ACTIVE, Policy(), now_ns=3 * NS,
adopt=self.adopt_request(shown.state, sequence=1))
self.assertTrue(adopted.adoption_receipt.consumed)
self.assertFalse(adopted.state.rejected)
held = step(adopted.state, valid(refreshed), ACTIVE, Policy(), now_ns=4 * NS)
self.assertFalse(held.errors)
self.assertEqual(held.basis, 'accepted')
def test_adopt_precedes_confirmation_timeout(self):
pending = self.pending()
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS, adopt=self.adopt_request(pending.state))
@@ -7,6 +7,7 @@ import pytest
from opendbc.car.dashboard_speed_limit import Status
from opendbc.car.structs import car
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.selfdrive.controls.lib import longitudinal_planner as planner
from openpilot.starpilot.speed_limits import acceptance as acc, physical_actions
@@ -95,6 +96,34 @@ def test_expired_adoption_does_not_retry_after_driver_or_authority_changes(drive
assert tick(runtime, sm, cp, 3.2, **kwargs).command is None
@pytest.mark.parametrize('failure', ['rejected', 'expired'])
@pytest.mark.parametrize('response', ['reject', 'timeout'])
def test_returned_limit_cannot_retry_an_older_adoption_after_decline(drive, failure, response):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
pending = tick(runtime, sm, cp, 0).message.slcState
first = tick(runtime, sm, cp, 1, request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
command = first.command.slcCruiseCommand
sm['slcDashboardObservation'].speedMps = 20.
receipt = command_receipt(runtime, command, 1.05, 60., 60., applied=False) if failure == 'rejected' else None
tick(runtime, sm, cp, 1.05, cruise_event=receipt)
sm['slcDashboardObservation'].speedMps = 25.
pending = tick(runtime, sm, cp, 4).message.slcState
action = Action(runtime.session_id, 2, pending.decisionId, pending.presentationId, 'reject') if response == 'reject' else None
seconds = 5 if action else 14
declined = tick(runtime, sm, cp, seconds, request=action)
assert declined.result.acceptance.basis == ('driver_reject' if action else 'timeout_reject')
assert declined.command is None
held = tick(runtime, sm, cp, seconds + .1)
assert not held.result.errors and not runtime.state.reset_required and held.command is None
assert sm['carState'].vCruise == 60.
# An explicit later adoption remains available after declining the new decision.
current = declined.message.slcState
readopted = tick(runtime, sm, cp, seconds + .2,
request=Action(runtime.session_id, 3, 0, current.presentationId, 'adopt'))
assert readopted.command is not None and readopted.command.slcCruiseCommand.targetMps == 25.
@pytest.mark.parametrize('limit,offset,bounded', [(140., 10., 145.), (10., -5., 8.)])
@pytest.mark.parametrize('metric', [False, True])
def test_adoption_and_display_use_existing_cruise_bounds(drive, limit, offset, bounded, metric):
@@ -120,30 +149,37 @@ def test_adoption_and_display_use_existing_cruise_bounds(drive, limit, offset, b
assert 8 <= helper.v_cruise_kph <= 145
def test_manual_override_compares_against_the_applied_rounded_set_speed(drive):
@pytest.mark.parametrize('metric,display', [(True, 72), (False, 55), (False, 60), (False, 65), (False, 70)])
@pytest.mark.parametrize('direction', [-1, 1])
def test_manual_override_compares_against_the_applied_rounded_set_speed(drive, metric, display, direction):
sm, cp = drive
cp.pcmCruise = False
sm['slcDashboardObservation'].speedMps = 45 * .44704
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'IsMetric': True}))
sm['slcDashboardObservation'].speedMps = 45 * CV.MPH_TO_MS if metric else display * CV.MPH_TO_MS
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'IsMetric': metric}))
tick(runtime, sm, cp, 0)
adopted = tick(runtime, sm, cp, 3)
helper = VCruiseHelper(cp)
helper.apply_slc_target(adopted.command.slcCruiseCommand.targetMps, True)
assert helper.v_cruise_kph == 72.
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 72.
tick(runtime, sm, cp, 3.05, cruise_event=command_receipt(runtime, adopted.command.slcCruiseCommand, 3.05, 60., 72.))
for event_id, (previous, chosen) in enumerate(((72., 73.), (73., 74.), (74., 73.), (73., 72.)), 2):
helper.adjust_v_cruise(car.CarState.ButtonEvent.Type.accelCruise if chosen > previous else
car.CarState.ButtonEvent.Type.decelCruise, sm['carState'], True)
assert helper.v_cruise_kph == chosen
helper.apply_slc_target(adopted.command.slcCruiseCommand.targetMps, metric)
baseline = helper.v_cruise_kph
conversion = 1 if metric else CV.KPH_TO_MPH
assert round(baseline * conversion) == display
sm['carState'].vCruise = sm['carState'].vCruiseCluster = baseline
tick(runtime, sm, cp, 3.05, cruise_event=command_receipt(runtime, adopted.command.slcCruiseCommand, 3.05, 60., baseline))
for event_id, (step, distance) in enumerate(zip((direction, direction, -direction, -direction), (1, 2, 1, 0), strict=True), 2):
previous = float(helper.v_cruise_kph)
helper.adjust_v_cruise(car.CarState.ButtonEvent.Type.accelCruise if step > 0 else
car.CarState.ButtonEvent.Type.decelCruise, sm['carState'], metric)
chosen = float(helper.v_cruise_kph)
assert round(chosen * conversion) == display + direction * distance
sm['carState'].vCruise = sm['carState'].vCruiseCluster = chosen
seconds = 3.05 + event_id * .05
stamp = START + round(seconds * 1e9)
output = tick(runtime, sm, cp, seconds, cruise_event=CruiseEvent(event_id, stamp, previous / 3.6,
chosen / 3.6, 'accel' if chosen > previous else 'decel', 'card-producer', stamp))
assert output.result.override.event_receipt.consumed
assert output.message.slcState.driverOverrideActive == (chosen != 72.)
assert output.message.slcState.driverOverrideActive == (distance != 0)
assert output.command is None
assert helper.v_cruise_kph == pytest.approx(baseline, abs=1e-6)
@pytest.mark.parametrize('fallback', [0, 2])
@@ -289,6 +325,53 @@ def test_pcm_software_set_speed_survives_native_speed_and_consumes_confirmation(
assert helper.v_cruise_kph == pytest.approx(59.)
@pytest.mark.parametrize('pending', [False, True])
@pytest.mark.parametrize('signal,direction,normal_speed', [
('CcAslButtnSetIncPress', 'accel', 61.), ('CcAslButtnSetDecPress', 'decel', 59.),
])
def test_ford_parser_buttons_adjust_set_speed_or_confirm_without_a_second_step(pending, signal, direction, normal_speed):
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR, DBC
from openpilot.selfdrive.car.cruise import CRUISE_LONG_PRESS, SlcPendingConfirmation
cp = CarInterface.get_params(CAR.FORD_ESCAPE_MK4, gen_empty_fingerprint(), [], True, False, False)
source = CarState(cp)
parsers = source.get_can_parsers(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
helper = VCruiseHelper(cp)
choice = SlcPendingConfirmation('drive', 1, 1) if pending else None
now_ns = START
def frame(pressed, decision=None):
nonlocal now_ns
parsers[Bus.pt].update([(now_ns, [
packer.make_can_msg('Steering_Data_FD1', parsers[Bus.pt].bus, {signal: pressed}),
packer.make_can_msg('EngBrakeData', parsers[Bus.pt].bus, {'CcStat_D_Actl': 4, 'Veh_V_DsplyCcSet': 60}),
])])
now_ns += 10_000_000
helper.update_v_cruise(source.update(parsers), True, True, decision, slc_enabled=True)
for _ in range(3):
frame(0)
helper.apply_slc_target(60. / 3.6, True)
frame(1, choice)
consumed = helper.slc_consumed_button
assert (consumed is not None) == pending
if pending:
assert consumed.pending == choice and consumed.button == direction
if direction == 'accel':
helper.apply_slc_target(25., True)
for _ in range(CRUISE_LONG_PRESS + 1):
frame(1)
assert helper.slc_consumed_button is None and helper.slc_cruise_change is None
frame(0)
assert helper.v_cruise_kph == (90. if pending and direction == 'accel' else 60. if pending else normal_speed)
assert (helper.slc_cruise_change is None) == pending
def test_automatic_countdown_never_consumes_plus_or_minus_and_late_accept_is_declined():
candidate = acc.Candidate('dashboard', acc.ObservationIdentity(acc.IdentityKind.SESSION_VALUE, session_id='drive'), 20.)
observation = acc.Observation(acc.ObservationKind.VALID, candidate)
@@ -14,7 +14,7 @@ from openpilot.starpilot.ui.large_speed_geometry import (WIDTH, CARD_HEIGHT, ACT
PARAM_KEY = "OnroadCustomizations"
MAX_BYTES = 16384
# Text alignment inside the cruise and speed-limit card; left is the original look.
# Legacy saved layouts retain this field even though the paired speed widget uses fixed alignment.
SPEED_ALIGNMENTS = ("left", "center", "right")
PALETTE = {"cardFill": "#000000A6", "cardBorder": "#C4CDD0B4", "text": "#FFFFFFFF"}
ROAD_COLORS = {"path": "#30FF9CFF", "pathEdge": "#00FF40FF", "laneLines": "#FFFFFFFF"}