diff --git a/openpilot/selfdrive/car/car_events.py b/openpilot/selfdrive/car/car_events.py index 0605b2bb7f..3543007e3c 100644 --- a/openpilot/selfdrive/car/car_events.py +++ b/openpilot/selfdrive/car/car_events.py @@ -7,6 +7,7 @@ from opendbc.car.toyota.values import ToyotaFlags from openpilot.selfdrive.selfdrived.events import Events from openpilot.starpilot.car.events import event_rules, unsupported_gear +from openpilot.starpilot.car.ford.aol import stock_conventional_cruise_supported ButtonType = structs.CarState.ButtonEvent.Type GearShifter = structs.CarState.GearShifter @@ -138,7 +139,7 @@ class CarEvents: events.add(EventName.stockLkas) if CS.vEgo > MAX_CTRL_SPEED: events.add(EventName.speedTooHigh) - if CS.cruiseState.nonAdaptive: + if CS.cruiseState.nonAdaptive and not stock_conventional_cruise_supported(self.CP): events.add(EventName.wrongCruiseMode) if CS.brakeHoldActive and self.CP.openpilotLongitudinalControl: events.add(EventName.brakeHold) diff --git a/openpilot/starpilot/car/ford/aol.py b/openpilot/starpilot/car/ford/aol.py index bee7f8375f..af55e93201 100644 --- a/openpilot/starpilot/car/ford/aol.py +++ b/openpilot/starpilot/car/ford/aol.py @@ -15,6 +15,12 @@ def policy_for(cp): return AolVehiclePolicy() + +def stock_conventional_cruise_supported(cp): + """The marked stock-cruise AOL profile does not require adaptive speed control.""" + return not cp.openpilotLongitudinalControl and qualified(cp, marked_only=True) + + def native_profile_supported(model, param): return model == int(structs.CarParams.SafetyModel.ford) and profile_word(param) in AOL_WORDS diff --git a/openpilot/starpilot/tests/ford_lifecycle_helpers.py b/openpilot/starpilot/tests/ford_lifecycle_helpers.py index 26ce2888ff..7de1c893da 100644 --- a/openpilot/starpilot/tests/ford_lifecycle_helpers.py +++ b/openpilot/starpilot/tests/ford_lifecycle_helpers.py @@ -167,7 +167,7 @@ def ford_joined(identity, alpha, hands_free, monkeypatch, record_property): native.pump(1, main=0 if getattr(monkeypatch, 'ford_first_main', False) else 3, mask=0, heartbeat=False, speed=15.0) extras: list[tuple[str, int, dict[str, float]]] = [ ('SteeringPinion_Data', 0, {'StePinComp_An_Est': 0., 'StePinCompAnEst_D_Qf': 3}), - ('Cluster_Info1_FD1', 0, {'AccEnbl_B_RqDrv': 1}), + ('Cluster_Info1_FD1', 0, {'AccEnbl_B_RqDrv': int(not getattr(monkeypatch, 'ford_non_adaptive', False))}), ('BodyInfo_3_FD1', 0, {}), ('RCMStatusMessage2_FD1', 0, {'FirstRowBuckleDriver': 1}), ('INSTRUMENT_PANEL', 0, {}), @@ -219,6 +219,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag long_parser = CANParser(DBC[host.CP.carFingerprint][Bus.pt], [('ACCDATA', 50)], 0) lateral_name = 'LateralMotionControl2' if host.CP.flags & FordFlags.CANFD else 'LateralMotionControl' lateral_parser = CANParser(DBC[host.CP.carFingerprint][Bus.pt], [(lateral_name, 20)], 0) + joined.stock_cruise_active_samples = 0 joined.nonzero_after_brake = 0 joined.path_angle_after_brake = 0 joined.active_ui_frames = 0 @@ -261,7 +262,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag return None if source_gap_active[0] and service == 'aolIntentWire' else send(service, raw) local.setattr(host.pm, 'send', capture) - if scenario == 'model-loss': + if scenario in ('model-loss', 'conventional-model-loss'): model_update = sd.sm.update_msgs def drop_model(timestamp, messages): return model_update(timestamp, [m for m in messages if not (model_lost[0] and m.which() == 'modelV2')]) @@ -392,7 +393,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag assert permission & 1 == 0 or heartbeat and request & 1 and native_request & 1 extras = [ ('SteeringPinion_Data', 0, {'StePinComp_An_Est': 0., 'StePinCompAnEst_D_Qf': 3}), - ('Cluster_Info1_FD1', 0, {'AccEnbl_B_RqDrv': 1}), + ('Cluster_Info1_FD1', 0, {'AccEnbl_B_RqDrv': int(not getattr(monkeypatch, 'ford_non_adaptive', False))}), ('BodyInfo_3_FD1', 0, {}), ('RCMStatusMessage2_FD1', 0, {'FirstRowBuckleDriver': 1}), ('INSTRUMENT_PANEL', 0, {}), @@ -496,6 +497,8 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag state, _ = host.state_update() host.state_publish(state, None) assert 'aolIntentWire' in published + if getattr(monkeypatch, 'ford_non_adaptive', False): + assert state.cruiseState.nonAdaptive parser_ready = bool( state.canValid and not state.canTimeout and (state.cruiseState.available or scenario == 'route49' or warmup and main_off) and str(state.gearShifter) == 'drive' and safety.safety_config_valid() @@ -624,6 +627,8 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag joined.nonzero_after_brake = nonzero_after_brake joined.path_angle_after_brake += int(abs(decoded['LatCtlPath_An_Actl']) > 1e-7) elif address == 0x186: + if getattr(monkeypatch, 'ford_non_adaptive', False): + assert host.CP.openpilotLongitudinalControl, 'Stock cruise received a comma acceleration message' long_parser.update([(source_ns, [(address, bytes(data), frame.src - joined.offset)])]) active = bool(long_parser.vl['ACCDATA']['Cmbb_B_Enbl']) assert active == bool(command.longActive) @@ -678,6 +683,12 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag tuple(str(event.name) for event in host.sm['onroadEvents']), host.aol_card_intent.latch_clear_reason, str(axis.mode))) + if getattr(monkeypatch, 'ford_non_adaptive', False) and not host.CP.openpilotLongitudinalControl: + assert not axis.desiredLongitudinal and not command.longActive and not permission & 2 + assert safety.safety_fwd_hook(2, 0x186) == 0, 'Stock cruise acceleration forwarding was blocked' + if state.cruiseState.enabled and command.latActive and not cancel and not brake and fault is None: + assert not command.cruiseControl.cancel, 'AOL cancelled the driver-owned conventional cruise' + joined.stock_cruise_active_samples += 1 return state, axis, sd_state, command def joined_packet(frame): @@ -1137,6 +1148,24 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag assert not command.latActive and not command.longActive assert not native.safety.aol_get_permission_mask() return + if scenario == 'conventional-long-refusal': + for _ in range(40): + state, axis, sd_state, command = step(engaged=True) + assert host.CP.openpilotLongitudinalControl and state.cruiseState.nonAdaptive + assert sd_state.alertType == 'wrongCruiseMode/noEntry' + assert command.cruiseControl.cancel and not command.latActive and not command.longActive + assert not axis.desiredLateral and not axis.desiredLongitudinal + assert not safety.aol_get_permission_mask() and active_lat_frames == active_long_frames == 0 + return + if scenario == 'conventional-model-loss': + for _ in range(40): + state, axis, sd_state, command = step(engaged=True) + assert command.latActive and not command.longActive + for _ in range(450): + state, axis, sd_state, command = step(engaged=True, fault='model-loss') + assert not sd.sm.alive['modelV2'] and not controls.sm.alive['modelV2'] + assert not command.latActive and not command.longActive and not safety.aol_get_permission_mask() + return if scenario == 'configuration-parity': for _ in range(40): state, axis, sd_state, command = step(engaged=True) diff --git a/openpilot/starpilot/tests/test_ford_configuration_parity.py b/openpilot/starpilot/tests/test_ford_configuration_parity.py index 76007f5a20..012835ebb7 100644 --- a/openpilot/starpilot/tests/test_ford_configuration_parity.py +++ b/openpilot/starpilot/tests/test_ford_configuration_parity.py @@ -175,3 +175,101 @@ def test_native_rejected_ui_cannot_replace_stock_cluster(ford_joined, monkeypatc assert ford_joined.rejected_ui_packets assert all(address in (0x18A, 0x3D8) for _, address in ford_joined.rejected_ui_packets) + + +@pytest.fixture +def conventional_ford_joined(request, monkeypatch, mode, identity, alpha, hands_free): + monkeypatch.ford_non_adaptive = True + monkeypatch.ford_bluepilot = True + monkeypatch.ford_angle = mode == 'angle' + monkeypatch.ford_starpilot = mode == 'starpilot' + monkeypatch.ford_pinion = True + monkeypatch.ford_nonzero = True + monkeypatch.ford_native_first_pedal = True + monkeypatch.ford_native_10hz = True + return request.getfixturevalue('ford_joined') + + +@pytest.mark.parametrize('identity', [FORD_FACTORIES[11]]) +@pytest.mark.parametrize('alpha', [False]) +@pytest.mark.parametrize('hands_free', [False, True]) +@pytest.mark.parametrize('topology', ['automatic', 'manual-multi']) +@pytest.mark.parametrize('mode', ['curvature', 'angle', 'starpilot']) +@pytest.mark.parametrize('scenario', ['configuration-parity', 'cancel-native-cadence']) +def test_conventional_cruise_keeps_actual_aol_without_longitudinal(conventional_ford_joined, monkeypatch, scenario): + joined = conventional_ford_joined + assert not joined.host.CP.openpilotLongitudinalControl + run_actual_producer(joined, monkeypatch, scenario=scenario) + assert joined.stock_cruise_active_samples > 0 + if scenario == 'configuration-parity': + assert joined.nonzero_after_brake > 0 + + +@pytest.mark.parametrize('identity', FORD_FACTORIES) +@pytest.mark.parametrize('alpha', [False, True]) +@pytest.mark.parametrize('marked', [False, True]) +@pytest.mark.parametrize('topology', ['automatic']) +def test_conventional_cruise_event_requires_stock_marked_ford(identity, alpha, marked): + from opendbc.car import gen_empty_fingerprint, structs + from opendbc.car.ford.interface import CarInterface + from openpilot.cereal import log + from openpilot.selfdrive.car.car_events import CarEvents + fp = gen_empty_fingerprint() + fp[0].update({0x5A: 8, 0x3A6: 8, 0x3A7: 8}) + fp[2].update({0x3D6: 8, 0x186: 8}) + cp = CarInterface.get_params(identity[0], fp, [], alpha, False, False) + cp.alternativeExperience = 32 if marked else 0 + state = structs.CarState.new_message(canValid=True, gearShifter='drive', + cruiseState={'available': True, 'enabled': True, 'nonAdaptive': True}) + events = CarEvents(cp).create_common_events(state, structs.CarState.new_message()) + assert (log.OnroadEvent.EventName.wrongCruiseMode in events.names) == (alpha or not marked) + state.steerFaultPermanent = state.accFaulted = True + faults = CarEvents(cp).create_common_events(state, state) + assert log.OnroadEvent.EventName.steerUnavailable in faults.names + assert log.OnroadEvent.EventName.accFaulted in faults.names + + +@pytest.mark.parametrize('topology', ['automatic']) +@pytest.mark.parametrize('invalid', ['passive', 'dashcam', 'not-car', 'missing-panda', 'extra-owner', 'wrong-word', 'wrong-brand']) +def test_conventional_cruise_never_admits_an_unsupported_profile(invalid): + from opendbc.car import gen_empty_fingerprint + from opendbc.car.ford.interface import CarInterface + from openpilot.starpilot.car.ford.aol import stock_conventional_cruise_supported + fp = gen_empty_fingerprint() + fp[0][0x5A] = 8 + fp[2].update({0x3D6: 8, 0x186: 8}) + cp = CarInterface.get_params(FORD_FACTORIES[11][0], fp, [], False, False, False) + cp.alternativeExperience = 32 + if invalid == 'passive': + cp.passive = True + elif invalid == 'dashcam': + cp.dashcamOnly = True + elif invalid == 'not-car': + cp.notCar = True + elif invalid == 'missing-panda': + cp.safetyConfigs = [] + elif invalid == 'extra-owner': + cp.safetyConfigs = [cp.safetyConfigs[0].to_dict()] * 2 + elif invalid == 'wrong-word': + cp.safetyConfigs[0].safetyParam = 3 + else: + cp.brand = 'toyota' + assert not stock_conventional_cruise_supported(cp) + + +@pytest.mark.parametrize('identity', [FORD_FACTORIES[11]]) +@pytest.mark.parametrize('alpha', [True]) +@pytest.mark.parametrize('hands_free', [True]) +@pytest.mark.parametrize('topology', ['automatic']) +@pytest.mark.parametrize('mode', ['curvature', 'angle', 'starpilot']) +def test_conventional_cruise_keeps_alpha_long_guard(conventional_ford_joined, monkeypatch): + run_actual_producer(conventional_ford_joined, monkeypatch, scenario='conventional-long-refusal') + + +@pytest.mark.parametrize('identity', [FORD_FACTORIES[11]]) +@pytest.mark.parametrize('alpha', [False]) +@pytest.mark.parametrize('hands_free', [True]) +@pytest.mark.parametrize('topology', ['automatic']) +@pytest.mark.parametrize('mode', ['starpilot']) +def test_conventional_cruise_stops_for_lost_model(conventional_ford_joined, monkeypatch): + run_actual_producer(conventional_ford_joined, monkeypatch, scenario='conventional-model-loss') diff --git a/openpilot/starpilot/ui/onroad_state.py b/openpilot/starpilot/ui/onroad_state.py index 10ea0c8af6..4c544c512f 100644 --- a/openpilot/starpilot/ui/onroad_state.py +++ b/openpilot/starpilot/ui/onroad_state.py @@ -183,8 +183,6 @@ class OnroadState: speed_mps: float | None cruise_kph: float | None speed_limit: SpeedLimitObservation - ford_rear_fog_on: bool = False - ford_rear_fog_source_ns: int = 0 appearance: OnroadAppearance = field(default_factory=OnroadAppearance) alert: OnroadAlert = OnroadAlert() metric: bool = False @@ -248,6 +246,8 @@ class OnroadState: stop_sign_observed_ns: int = 0 weather_location: tuple[float, float] | None = None weather: WeatherReading | None = None + ford_rear_fog_on: bool = False + ford_rear_fog_source_ns: int = 0 def __post_init__(self) -> None: if self.speed_mps is not None and (not math.isfinite(self.speed_mps) or self.speed_mps < 0): @@ -267,6 +267,7 @@ class OnroadState: raise ValueError("Model confidence must be normalized") + @dataclass(frozen=True) class OnroadRequest: kind: str diff --git a/openpilot/starpilot/ui/runtime_app.py b/openpilot/starpilot/ui/runtime_app.py index df1960974e..54013cc774 100644 --- a/openpilot/starpilot/ui/runtime_app.py +++ b/openpilot/starpilot/ui/runtime_app.py @@ -1309,9 +1309,11 @@ class StarShellSession: if getattr(self, "rear_fog", None) is None: self.rear_fog = RearFogReceiver() ui = self.adapter.ui_state - enabled = snapshot.onroad.customization["layouts"][str(self.profile)]["rear_fog"]["enabled"] - fog_on, fog_stamp = self.rear_fog.sample(ui.CP, active=mode == ShellMode.ONROAD and bool(ui.started) and enabled) - snapshot = replace(snapshot, onroad=replace(snapshot.onroad, ford_rear_fog_on=fog_on, ford_rear_fog_source_ns=fog_stamp)) + cp = getattr(ui, "CP", None) + enabled = snapshot.onroad.customization.get("layouts", {}).get(str(self.profile), {}).get("rear_fog", {}).get("enabled", False) + fog_on, fog_stamp = self.rear_fog.sample(cp, active=mode == ShellMode.ONROAD and bool(getattr(ui, "started", False)) and cp is not None and enabled) + if (fog_on, fog_stamp) != (snapshot.onroad.ford_rear_fog_on, snapshot.onroad.ford_rear_fog_source_ns): + snapshot = replace(snapshot, onroad=replace(snapshot.onroad, ford_rear_fog_on=fog_on, ford_rear_fog_source_ns=fog_stamp)) if mode == ShellMode.ONROAD and self.profile == Profile.LARGE: width = getattr(self, '_onroad_page_width', 2160.0) - (SIDEBAR_WIDTH if snapshot.onroad.appearance.show_developer_sidebar else 0) fitted = fit_to_road(snapshot.onroad.customization, width) diff --git a/openpilot/starpilot/ui/tests/test_ford_rear_fog.py b/openpilot/starpilot/ui/tests/test_ford_rear_fog.py index 1814246307..776c0a59f5 100644 --- a/openpilot/starpilot/ui/tests/test_ford_rear_fog.py +++ b/openpilot/starpilot/ui/tests/test_ford_rear_fog.py @@ -232,3 +232,30 @@ def test_boot_clock_matches_native_mac_fallback(monkeypatch): monkeypatch.setattr(rear_fog.time, "clock_gettime_ns", clock) assert rear_fog.boot_time_ns() == 10_000_000_000 clock.assert_called_once_with(rear_fog.time.CLOCK_MONOTONIC) + + +@pytest.mark.parametrize('missing', ['CP', 'started', 'layout']) +def test_runtime_fog_is_off_without_vehicle_or_drive_context(missing): + from openpilot.starpilot.ui.runtime_app import StarShellSession + from openpilot.starpilot.ui.shell import ShellSnapshot, ShellMode + from openpilot.starpilot.ui.presentation import Profile + from openpilot.starpilot.ui.settings_state import Destination + from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation + state = OnroadState(False, True, 0, None, SpeedLimitObservation(), customization=default_document()) + session = StarShellSession.__new__(StarShellSession) + session.adapter = Mock() + session.adapter.ui_state = NS(sm=NS(frame=1), CP=cp(), started=True) + if missing == 'layout': + state = replace(state, customization={}) + else: + delattr(session.adapter.ui_state, missing) + session.adapter.build.return_value = ShellSnapshot(ShellMode.ONROAD, Mock(), Mock(), state) + session.selected = Destination.STAR + session.compact_y = session.compact_scroll_x = 0 + session.sidebar_expanded = True + session.profile = Profile.COMPACT + session._snapshot_cache = None + session.rear_fog = Mock() + session.rear_fog.sample.return_value = (False, 0) + assert not session.snapshot(ShellMode.ONROAD).onroad.ford_rear_fog_on + assert session.rear_fog.sample.call_args.kwargs == {'active': False} diff --git a/prebuilt.json b/prebuilt.json index 4953941380..6151e6a526 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "c910386ebeafeca30850b91d3b991c99b37b6e59381208d60a6d2662dafc2f5d", "schema_version": 1} +{"protected_inventory_sha256": "9d58d8c35076a0cc97bb85692283b41108a28ba6a8cd0d7091e7a396b20d372c", "schema_version": 1}