diff --git a/common/params_keys.h b/common/params_keys.h index 8b8e5528c..4c5bb81d8 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -665,6 +665,7 @@ inline static std::unordered_map keys = { {"SwitchbackModeEnabled", {CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0", "0"}}, {"SubaruSNG", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"SubaruSNGManualParkingBrake", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, + {"SubaruStopStartOff", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"TacoTune", {PERSISTENT, BOOL, "0", "0", 2}}, {"TeslaCoopSteering", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"TestAlert", {CLEAR_ON_MANAGER_START, STRING, "", ""}}, diff --git a/opendbc_repo/opendbc/car/hyundai/carcontroller.py b/opendbc_repo/opendbc/car/hyundai/carcontroller.py index eab034279..9fcaf74e7 100644 --- a/opendbc_repo/opendbc/car/hyundai/carcontroller.py +++ b/opendbc_repo/opendbc/car/hyundai/carcontroller.py @@ -180,6 +180,13 @@ def should_use_ev6_gt_line_stop_direct_tracking(ev6_gt_line: bool, stopping: boo return bool(ev6_gt_line and stopping and v_ego > EV6_GT_LINE_STOP_BRAKE_CAP_MAX_SPEED and accel_cmd < actual_accel) +def apply_carnival_steering_override(car_fingerprint, steering_pressed: bool, + apply_steer_req: bool, apply_torque: int) -> tuple[bool, int]: + if car_fingerprint == CAR.KIA_CARNIVAL_2025 and steering_pressed: + return False, 0 + return apply_steer_req, apply_torque + + def update_ev9_longitudinal_tuning(state: EV9LongitudinalTuningState, enabled: bool, stopping: bool, v_ego: float) -> EV9LongitudinalTuningState: if not enabled: @@ -613,6 +620,10 @@ class CarController(CarControllerBase): if not CC.latActive: apply_torque = 0 + apply_steer_req, apply_torque = apply_carnival_steering_override( + self.CP.carFingerprint, CS.out.steeringPressed, apply_steer_req, apply_torque, + ) + # Hold torque with induced temporary fault when cutting the actuation bit # FIXME: we don't use this with CAN FD? torque_fault = CC.latActive and not apply_steer_req diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py index 824876b5b..d7386d03e 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py @@ -20,6 +20,7 @@ from opendbc.car.hyundai.carcontroller import CarController, Ioniq6LongitudinalT should_track_stop_accel_directly_for_car, \ preserve_stock_canfd_lfa_status, \ preserve_stock_canfd_lkas_status, \ + apply_carnival_steering_override, \ suppress_redundant_gv70_brake_cancel from opendbc.car.hyundai.carstate import CarState, decode_canfd_camera_lead, decode_ioniq_6_blindspot_radar_state, \ get_canfd_cruise_available @@ -208,6 +209,12 @@ class TestHyundaiFingerprint: assert parser.vl["LKAS_ALT"]["TORQUE_REQUEST"] == 123 assert parser.vl["LKAS_ALT"]["STEER_REQ"] == 1 + def test_carnival_steering_override_is_scoped_to_2025_platform(self): + assert apply_carnival_steering_override(CAR.KIA_CARNIVAL_2025, True, True, 123) == (False, 0) + assert apply_carnival_steering_override(CAR.KIA_CARNIVAL_2025, False, True, 123) == (True, 123) + assert apply_carnival_steering_override(CAR.KIA_CARNIVAL_HEV_4TH_GEN, True, True, 123) == (True, 123) + assert apply_carnival_steering_override(CAR.HYUNDAI_IONIQ_6, True, True, 123) == (True, 123) + def test_canfd_torque_bsm_parser_registers_rear_blindspots(self): CP = CarParams.new_message() CP.carFingerprint = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN diff --git a/opendbc_repo/opendbc/car/subaru/carcontroller.py b/opendbc_repo/opendbc/car/subaru/carcontroller.py index 027de5cce..88c2131ce 100644 --- a/opendbc_repo/opendbc/car/subaru/carcontroller.py +++ b/opendbc_repo/opendbc/car/subaru/carcontroller.py @@ -30,6 +30,10 @@ _ANGLE_RECLAIM_FRAMES = 36 _ANGLE_RECLAIM_EXPONENT = 2.5 _ANGLE_MADS_MIN_SPEED = 0.44704 _ANGLE_MADS_MAX_STEER_ANGLE = 120.0 +_STOP_START_STARTUP_DELAY_FRAMES = 100 +_STOP_START_STARTUP_DEADLINE_FRAMES = 300 +_STOP_START_PULSE_FRAMES = 30 +_STOP_START_PULSE_PERIOD_FRAMES = 5 def get_safety_CP(): @@ -73,6 +77,66 @@ class CarController(CarControllerBase): self.prev_close_distance = 0 self.epb_resume_frames_remaining = -1 self.last_standstill_frame = 0 + self.stop_start_attempted = False + self.stop_start_request_started = False + self.stop_start_request_frame = 0 + self.stop_start_initial_state = None + self.stop_start_counter = 0 + self.stop_start_acknowledged = False + + def _stop_start_off_request(self, CC, CS, starpilot_toggles): + """Send one bounded Outback Stop/Start OFF request after ignition. + + This is intentionally opt-in and limited to a stationary vehicle in + Park/Neutral. A single ignition session gets at most one attempt. + """ + if self.CP.carFingerprint != CAR.SUBARU_OUTBACK_2023 or \ + not getattr(starpilot_toggles, "subaru_stop_start_off", False) or self.stop_start_attempted: + return None + + if self.frame > _STOP_START_STARTUP_DEADLINE_FRAMES or getattr(CC, "enabled", False): + self.stop_start_attempted = True + return None + + if self.frame < _STOP_START_STARTUP_DELAY_FRAMES or not getattr(getattr(CS, "out", None), "canValid", True): + return None + + out = CS.out + if not getattr(out, "standstill", False) or out.gearShifter not in ( + structs.CarState.GearShifter.park, + structs.CarState.GearShifter.neutral, + ): + return None + + dashlights_msg = getattr(CS, "dashlights_msg", None) + if not dashlights_msg: + return None + + if not self.stop_start_request_started: + self.stop_start_request_started = True + self.stop_start_request_frame = self.frame + self.stop_start_initial_state = getattr(CS, "stop_start_state", None) + self.stop_start_counter = (int(dashlights_msg.get("COUNTER", 0)) + 1) % 0x10 + + current_state = getattr(CS, "stop_start_state", None) + if self.stop_start_initial_state is not None and self.stop_start_initial_state != 3 and current_state == 3: + self.stop_start_attempted = True + self.stop_start_acknowledged = True + return None + + elapsed = self.frame - self.stop_start_request_frame + if elapsed >= _STOP_START_PULSE_FRAMES: + self.stop_start_attempted = True + return None + + if elapsed % _STOP_START_PULSE_PERIOD_FRAMES != 0: + return None + + msg = subarucan.create_stop_start_control( + self.packer, dashlights_msg, counter=self.stop_start_counter, bus=self.main_bus, + ) + self.stop_start_counter = (self.stop_start_counter + 1) % 0x10 + return msg def _reset_legacy_2025_handoff(self): self.legacy_2025_handoff_active = False @@ -87,7 +151,7 @@ class CarController(CarControllerBase): self._reset_legacy_2025_handoff() return False - if CS.out.steeringPressed: + if getattr(CS.out, "steeringPressed", False): self.legacy_2025_handoff_active = True self.legacy_2025_override_hold_frames = _LEGACY_2025_OVERRIDE_HOLD_FRAMES self.legacy_2025_reengage_settle_frames = 0 @@ -96,7 +160,7 @@ class CarController(CarControllerBase): return True if not self.legacy_2025_handoff_active and not self.legacy_2025_lkas_active and \ - abs(CS.out.steeringRateDeg) > _LEGACY_2025_REENGAGE_MAX_STEER_RATE: + abs(getattr(CS.out, "steeringRateDeg", 0.0)) > _LEGACY_2025_REENGAGE_MAX_STEER_RATE: self.legacy_2025_handoff_active = True self.legacy_2025_reengage_reference_angle = CS.out.steeringAngleDeg @@ -109,7 +173,7 @@ class CarController(CarControllerBase): self.legacy_2025_reengage_reference_angle = CS.out.steeringAngleDeg return True - wheel_stable = abs(CS.out.steeringRateDeg) <= _LEGACY_2025_REENGAGE_MAX_STEER_RATE and \ + wheel_stable = abs(getattr(CS.out, "steeringRateDeg", 0.0)) <= _LEGACY_2025_REENGAGE_MAX_STEER_RATE and \ abs(CS.out.steeringAngleDeg - self.legacy_2025_reengage_reference_angle) <= _LEGACY_2025_REENGAGE_MAX_ANGLE_DELTA if wheel_stable: self.legacy_2025_reengage_settle_frames += 1 @@ -150,7 +214,7 @@ class CarController(CarControllerBase): self._reset_angle_handoff() return False - if CS.out.steeringPressed: + if getattr(CS.out, "steeringPressed", False): self.angle_handoff_active = True self.angle_override_hold_frames = _ANGLE_OVERRIDE_HOLD_FRAMES self.angle_reengage_settle_frames = 0 @@ -159,7 +223,7 @@ class CarController(CarControllerBase): return True if not self.angle_handoff_active and not self.angle_lkas_active and \ - abs(CS.out.steeringRateDeg) > _ANGLE_REENGAGE_MAX_STEER_RATE: + abs(getattr(CS.out, "steeringRateDeg", 0.0)) > _ANGLE_REENGAGE_MAX_STEER_RATE: self.angle_handoff_active = True self.angle_reengage_reference_angle = CS.out.steeringAngleDeg @@ -172,7 +236,7 @@ class CarController(CarControllerBase): self.angle_reengage_reference_angle = CS.out.steeringAngleDeg return True - wheel_stable = abs(CS.out.steeringRateDeg) <= _ANGLE_REENGAGE_MAX_STEER_RATE and \ + wheel_stable = abs(getattr(CS.out, "steeringRateDeg", 0.0)) <= _ANGLE_REENGAGE_MAX_STEER_RATE and \ abs(CS.out.steeringAngleDeg - self.angle_reengage_reference_angle) <= _ANGLE_REENGAGE_MAX_ANGLE_DELTA if wheel_stable: self.angle_reengage_settle_frames += 1 @@ -267,9 +331,19 @@ class CarController(CarControllerBase): elif abs_torque < self.p.STEER_OVERRIDE_TORQUE_LOW: self.driver_override = False - lat_active = CC.latActive and not self.driver_override + mads_only = CC.latActive and not getattr(CC, "enabled", False) + mads_only_ok = CS.out.vEgoRaw > _ANGLE_MADS_MIN_SPEED and \ + abs(CS.out.steeringAngleDeg) < _ANGLE_MADS_MAX_STEER_ANGLE + lkas_available = CC.latActive and (not mads_only or mads_only_ok) and \ + getattr(CS.out, "gearShifter", structs.CarState.GearShifter.drive) == structs.CarState.GearShifter.drive and \ + not getattr(CS.out, "standstill", False) + manual_handoff = self._angle_manual_handoff(CS, lkas_available) + lat_active = lkas_available and not self.driver_override and not manual_handoff + if lat_active and not self.angle_lkas_active: + self.apply_steer_last = CS.out.steeringAngleDeg + steer_target = self._angle_reclaim_target(CC.actuators.steeringAngleDeg) if lat_active else CC.actuators.steeringAngleDeg apply_steer = apply_steer_angle_limits_vm( - CC.actuators.steeringAngleDeg, + steer_target, self.apply_steer_last, CS.out.vEgoRaw, CS.out.steeringAngleDeg, @@ -282,6 +356,7 @@ class CarController(CarControllerBase): apply_steer = CS.out.steeringAngleDeg self.apply_steer_last = apply_steer + self.angle_lkas_active = lat_active return subarucan.create_steering_control_angle(self.packer, apply_steer, lat_active, self.angle_bus) def lateral_torque(self, CC, CS): @@ -316,6 +391,10 @@ class CarController(CarControllerBase): can_sends = [] + stop_start_msg = self._stop_start_off_request(CC, CS, starpilot_toggles) + if stop_start_msg is not None: + can_sends.append(stop_start_msg) + # *** steering *** if (self.frame % self.p.STEER_STEP) == 0: if self.CP.flags & SubaruFlags.LKAS_ANGLE: diff --git a/opendbc_repo/opendbc/car/subaru/carstate.py b/opendbc_repo/opendbc/car/subaru/carstate.py index 928eb7dcb..c29f0b8c4 100644 --- a/opendbc_repo/opendbc/car/subaru/carstate.py +++ b/opendbc_repo/opendbc/car/subaru/carstate.py @@ -4,7 +4,7 @@ from opendbc.can import CANDefine, CANParser from opendbc.car import Bus, structs from opendbc.car.common.conversions import Conversions as CV from opendbc.car.interfaces import CarStateBase -from opendbc.car.subaru.values import DBC, CanBus, SubaruFlags +from opendbc.car.subaru.values import CAR, DBC, CanBus, SubaruFlags from opendbc.car import CanSignalRateCalculator @@ -15,6 +15,8 @@ class CarState(CarStateBase): self.shifter_values = can_define.dv["Transmission"]["Gear"] self.angle_rate_calulator = CanSignalRateCalculator(50) + self.dashlights_msg = {} + self.stop_start_state = 0 def update(self, can_parsers, starpilot_toggles) -> structs.CarState: cp = can_parsers[Bus.pt] @@ -24,6 +26,10 @@ class CarState(CarStateBase): cp_angle = cp_main if self.CP.flags & SubaruFlags.D_PLATFORM else cp ret = structs.CarState() + if self.CP.carFingerprint == CAR.SUBARU_OUTBACK_2023: + self.dashlights_msg = copy.copy(cp.vl["Dashlights"]) + self.stop_start_state = cp.vl["Engine_Stop_Start"]["STOP_START_STATE"] + throttle_msg = cp.vl["Throttle"] if not (self.CP.flags & SubaruFlags.HYBRID) else cp_alt.vl["Throttle_Hybrid"] ret.gasPressed = throttle_msg["Throttle_Pedal"] > 1e-5 if self.CP.flags & SubaruFlags.PREGLOBAL: diff --git a/opendbc_repo/opendbc/car/subaru/interface.py b/opendbc_repo/opendbc/car/subaru/interface.py index b3548cc3d..01d52fead 100644 --- a/opendbc_repo/opendbc/car/subaru/interface.py +++ b/opendbc_repo/opendbc/car/subaru/interface.py @@ -40,6 +40,8 @@ class CarInterface(CarInterfaceBase): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM.value if ret.flags & SubaruFlags.D_PLATFORM_CAMERA: ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value + if candidate == CAR.SUBARU_OUTBACK_2023: + ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value diff --git a/opendbc_repo/opendbc/car/subaru/subarucan.py b/opendbc_repo/opendbc/car/subaru/subarucan.py index f52a1a190..39c352f75 100644 --- a/opendbc_repo/opendbc/car/subaru/subarucan.py +++ b/opendbc_repo/opendbc/car/subaru/subarucan.py @@ -182,6 +182,20 @@ def create_es_dashstatus(packer, frame, dashstatus_msg, enabled, long_enabled, l return packer.make_can_msg("ES_DashStatus", bus, values) +def create_stop_start_control(packer, dashlights_msg, counter=None, bus=CanBus.alt): + """Create the Outback 2023-24 momentary Stop/Start button request. + + Dashlights is a stock periodic message, so preserve the live frame and only + change the event bit. CANPacker calculates the Subaru checksum for us. + """ + values = dict(dashlights_msg) + if counter is None: + counter = (int(values.get("COUNTER", 0)) + 1) % 0x10 + values["COUNTER"] = counter % 0x10 + values["STOP_START"] = 1 + return packer.make_can_msg("Dashlights", bus, values) + + def create_es_brake(packer, frame, es_brake_msg, long_enabled, long_active, brake_value, bus=CanBus.main): values = {s: es_brake_msg[s] for s in [ "CHECKSUM", diff --git a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py index 3a3f29ee1..802d30bb6 100644 --- a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py +++ b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py @@ -193,6 +193,7 @@ def test_outback_2023_uses_d_platform_bus_layout(): assert CP.flags & SubaruFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM + assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS) assert CanBus.main_for_cp(CP) == CanBus.alt assert CanBus.angle_for_cp(CP) == CanBus.main @@ -205,6 +206,52 @@ def test_outback_2023_uses_d_platform_bus_layout(): assert CP.lateralSmoothSeconds == pytest.approx(0.4) +def test_stop_start_request_is_bounded_and_uses_live_dashlights(): + CP = CarInterface.get_non_essential_params(CAR.SUBARU_OUTBACK_2023) + controller = CarController({}, CP) + controller.frame = 101 + + class TestActuators: + steeringAngleDeg = 0.0 + + def as_builder(self): + return SimpleNamespace(steeringAngleDeg=self.steeringAngleDeg) + + CC = SimpleNamespace( + enabled=False, + latActive=False, + longActive=False, + actuators=TestActuators(), + hudControl=SimpleNamespace(leadVisible=False), + cruiseControl=SimpleNamespace(cancel=False), + ) + CS = SimpleNamespace( + canValid=True, + dashlights_msg={"COUNTER": 6, "STOP_START": 0}, + stop_start_state=0, + out=SimpleNamespace( + standstill=True, + gearShifter=structs.CarState.GearShifter.park, + ), + ) + toggles = SimpleNamespace(subaru_stop_start_off=True, subaru_sng=False) + + _, can_sends = controller.update(CC, CS, 0, toggles) + stop_start_msgs = [msg for msg in can_sends if msg[0] == 0x390] + assert len(stop_start_msgs) == 1 + assert stop_start_msgs[0][2] == CanBus.alt + parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("Dashlights", 0)], CanBus.alt) + parser.update([(1, [stop_start_msgs[0]])]) + assert parser.vl["Dashlights"]["STOP_START"] == 1 + assert parser.vl["Dashlights"]["COUNTER"] == 7 + + controller.frame = 103 + CS.stop_start_state = 3 + _, can_sends = controller.update(CC, CS, 0, toggles) + assert not any(msg[0] == 0x390 for msg in can_sends) + assert controller.stop_start_acknowledged + + def test_legacy_2025_uses_gen2_angle_bus_layout(): CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025) parsers = CarState.get_can_parsers(CP) @@ -448,6 +495,44 @@ def test_angle_controller_tracks_driver_override(): assert not controller.driver_override +def test_angle_controller_blocks_low_speed_mads_engagement(): + CP = CarInterface.get_non_essential_params(CAR.SUBARU_CROSSTREK_2025) + controller = CarController({}, CP) + CC = SimpleNamespace( + enabled=False, + latActive=True, + actuators=SimpleNamespace(steeringAngleDeg=15.0), + ) + CS = SimpleNamespace(out=SimpleNamespace( + vEgoRaw=0.3, + steeringAngleDeg=80.0, + steeringTorque=0.0, + gearShifter=structs.CarState.GearShifter.drive, + standstill=False, + )) + parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("ES_LKAS_ANGLE", 0)], CanBus.main) + + msg = controller.lateral_angle(CC, CS) + parser.update([(1, [msg])]) + + assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 0 + assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Output"] == pytest.approx(CS.out.steeringAngleDeg) + + CS.out.vEgoRaw = 1.0 + CS.out.steeringAngleDeg = 130.0 + msg = controller.lateral_angle(CC, CS) + parser.update([(2, [msg])]) + + assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 0 + assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Output"] == pytest.approx(CS.out.steeringAngleDeg) + + CS.out.steeringAngleDeg = 0.0 + msg = controller.lateral_angle(CC, CS) + parser.update([(3, [msg])]) + + assert parser.vl["ES_LKAS_ANGLE"]["LKAS_Request"] == 1 + + def test_ascent_angle_controller_uses_fixed_angle_rate_limits(): CP = CarInterface.get_non_essential_params(CAR.SUBARU_ASCENT_2023) controller = CarController({}, CP) diff --git a/opendbc_repo/opendbc/car/subaru/values.py b/opendbc_repo/opendbc/car/subaru/values.py index 9b0b25398..e1b101506 100644 --- a/opendbc_repo/opendbc/car/subaru/values.py +++ b/opendbc_repo/opendbc/car/subaru/values.py @@ -88,6 +88,7 @@ class SubaruSafetyFlags(IntFlag): D_PLATFORM = 32 D_PLATFORM_CAMERA = 64 FIXED_ANGLE_LIMITS = 128 + STOP_START_BUTTON = 256 LEGACY_2025_ANGLE_LIMITS = FIXED_ANGLE_LIMITS diff --git a/opendbc_repo/opendbc/car/toyota/carcontroller.py b/opendbc_repo/opendbc/car/toyota/carcontroller.py index e5b39222e..df7548977 100644 --- a/opendbc_repo/opendbc/car/toyota/carcontroller.py +++ b/opendbc_repo/opendbc/car/toyota/carcontroller.py @@ -42,6 +42,7 @@ TOYOTA_NO_LEAD_CRUISE_SIGN_FLIP_MIN_SET_SPEED_ERROR = 0.35 # m/s # EPS faults if you apply torque while the steering rate is above 100 deg/s for too long MAX_STEER_RATE = 100 # deg/s MAX_STEER_RATE_FRAMES = 18 # tx control frames needed before torque can be cut +TOYOTA_HIGHLANDER_TSS2_MAX_STEER_RATE_FRAMES = 8 # EPS allows user torque above threshold for 50 frames before permanently faulting MAX_USER_TORQUE = 500 @@ -72,6 +73,11 @@ def should_bypass_toyota_long_pid(CP, starpilot_toggles=None) -> bool: ) or highlander_sdsu) +def get_steer_rate_limit_frames(car_fingerprint) -> int: + return (TOYOTA_HIGHLANDER_TSS2_MAX_STEER_RATE_FRAMES + if car_fingerprint == CAR.TOYOTA_HIGHLANDER_TSS2 else MAX_STEER_RATE_FRAMES) + + def get_long_tune(CP, params): kiBP = [2., 5.] kiV = [0.5, 0.25] @@ -223,6 +229,7 @@ class CarController(CarControllerBase): self.standstill_req = False self.permit_braking = True self.steer_rate_counter = 0 + self.steer_rate_limit_frames = get_steer_rate_limit_frames(self.CP.carFingerprint) self.distance_button = 0 # *** start long control state *** @@ -345,8 +352,10 @@ class CarController(CarControllerBase): apply_torque = apply_meas_steer_torque_limits(new_torque, self.last_torque, CS.out.steeringTorqueEps, self.params) # >100 degree/sec steering fault prevention - self.steer_rate_counter, apply_steer_req = common_fault_avoidance(abs(CS.out.steeringRateDeg) >= MAX_STEER_RATE, lat_active, - self.steer_rate_counter, MAX_STEER_RATE_FRAMES) + self.steer_rate_counter, apply_steer_req = common_fault_avoidance( + abs(CS.out.steeringRateDeg) >= MAX_STEER_RATE, lat_active, + self.steer_rate_counter, self.steer_rate_limit_frames, + ) if not lat_active: apply_torque = 0 diff --git a/opendbc_repo/opendbc/car/toyota/tests/test_toyota.py b/opendbc_repo/opendbc/car/toyota/tests/test_toyota.py index bea85747a..fef7f7c71 100644 --- a/opendbc_repo/opendbc/car/toyota/tests/test_toyota.py +++ b/opendbc_repo/opendbc/car/toyota/tests/test_toyota.py @@ -10,6 +10,7 @@ from opendbc.car.fw_versions import build_fw_dict, match_fw_to_car from opendbc.car.toyota import toyotacan from opendbc.car.toyota.carcontroller import CarController, get_camry_hybrid_feedforward, get_long_tune, get_prius_feedforward, \ get_prius_positive_feedforward_scale, \ + get_steer_rate_limit_frames, \ limit_interceptor_pcm_accel, \ limit_interceptor_stopping_accel, limit_no_lead_cruise_sign_flip, \ limit_prius_stopping_accel, should_bypass_toyota_long_pid, update_permit_braking @@ -706,6 +707,10 @@ class TestToyotaFingerprint: class TestToyotaCarController: + def test_highlander_tss2_uses_early_steer_rate_fault_guard(self): + assert get_steer_rate_limit_frames(CAR.TOYOTA_HIGHLANDER_TSS2) == 8 + assert get_steer_rate_limit_frames(CAR.TOYOTA_RAV4_TSS2) == 18 + @staticmethod def _make_controller(*, standstill_req=False, last_standstill=False): controller = CarController.__new__(CarController) diff --git a/opendbc_repo/opendbc/safety/modes/subaru.h b/opendbc_repo/opendbc/safety/modes/subaru.h index 699334e75..4e3e1037a 100644 --- a/opendbc_repo/opendbc/safety/modes/subaru.h +++ b/opendbc_repo/opendbc/safety/modes/subaru.h @@ -41,6 +41,7 @@ #define MSG_SUBARU_ES_HighBeamAssist 0x121U #define MSG_SUBARU_ES_STATIC_1 0x22aU #define MSG_SUBARU_ES_STATIC_2 0x325U +#define MSG_SUBARU_Dashlights 0x390U #define SUBARU_MAIN_BUS 0U #define SUBARU_ALT_BUS 1U @@ -61,6 +62,9 @@ {MSG_SUBARU_ES_LKAS_State, bus, 8, .check_relay = true}, \ {MSG_SUBARU_ES_Infotainment, bus, 8, .check_relay = true}, \ +#define SUBARU_STOP_START_TX_MSGS(bus) \ + {MSG_SUBARU_Dashlights, bus, 8, .check_relay = false}, \ + #define SUBARU_COMMON_LONG_TX_MSGS(alt_bus) \ {MSG_SUBARU_ES_Distance, alt_bus, 8, .check_relay = true}, \ {MSG_SUBARU_ES_Brake, alt_bus, 8, .check_relay = true}, \ @@ -108,6 +112,7 @@ static bool subaru_stop_and_go = false; static bool subaru_lkas_angle = false; static bool subaru_d_platform = false; static bool subaru_fixed_angle_limits = false; +static bool subaru_stop_start_button = false; static uint32_t subaru_get_checksum(const CANPacket_t *msg) { return (uint8_t)msg->data[0]; @@ -285,6 +290,13 @@ static bool subaru_tx_hook(const CANPacket_t *msg) { violation |= !(is_tester_present || is_button_rdbi); } + if (msg->addr == MSG_SUBARU_Dashlights) { + violation |= !subaru_stop_start_button; + violation |= msg->bus != SUBARU_ALT_BUS; + violation |= !GET_BIT(msg, 54U); + violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg); + } + if (violation){ tx = false; } @@ -334,6 +346,12 @@ static safety_config subaru_init(uint16_t param) { SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) }; + static const CanMsg SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS[] = { + SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS) + SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) + SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS) + }; + static const CanMsg SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS[] = { SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_CAM_BUS) SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) @@ -378,6 +396,9 @@ static safety_config subaru_init(uint16_t param) { const uint16_t SUBARU_PARAM_FIXED_ANGLE_LIMITS = 128; subaru_fixed_angle_limits = GET_FLAG(param, SUBARU_PARAM_FIXED_ANGLE_LIMITS); + const uint16_t SUBARU_PARAM_STOP_START_BUTTON = 256; + subaru_stop_start_button = GET_FLAG(param, SUBARU_PARAM_STOP_START_BUTTON); + #ifdef ALLOW_DEBUG const uint16_t SUBARU_PARAM_LONGITUDINAL = 2; subaru_longitudinal = GET_FLAG(param, SUBARU_PARAM_LONGITUDINAL); @@ -385,8 +406,9 @@ static safety_config subaru_init(uint16_t param) { safety_config ret; if (subaru_lkas_angle) { - ret = subaru_d_platform ? (subaru_d_platform_camera ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS) : \ - BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS)) : \ + ret = subaru_d_platform ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS) : \ + (subaru_d_platform_camera ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS) : \ + BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS))) : \ subaru_gen2 ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_TX_MSGS) : \ BUILD_SAFETY_CFG(subaru_lkas_angle_rx_checks, SUBARU_LKAS_ANGLE_TX_MSGS); } else if (subaru_gen2) { diff --git a/opendbc_repo/opendbc/safety/tests/common.py b/opendbc_repo/opendbc/safety/tests/common.py index da4f36bfb..40eb80bd9 100644 --- a/opendbc_repo/opendbc/safety/tests/common.py +++ b/opendbc_repo/opendbc/safety/tests/common.py @@ -1075,13 +1075,14 @@ class SafetyTest(SafetyTestBase): continue if attr.startswith('TestSubaruGen') and current_test.startswith('TestSubaruGen'): continue - if 'TestSubaruDPlatformAngleSafety' in {attr, current_test} and \ - 'Angle' in attr and 'Angle' in current_test: + if attr.startswith('TestSubaruDPlatform') and current_test.startswith('TestSubaruDPlatform'): continue - if 'TestSubaruDPlatformAngleSafety' in {attr, current_test}: + if attr.startswith('TestSubaruDPlatform'): # D-platform uses the same main-bus HUD messages as the other # Subaru modes, so those modes cannot be distinguished by ID. - tx = list(filter(lambda m: not (m[1] == 0 and m[0] in [0x321, 0x322, 0x323]), tx)) + tx = list(filter(lambda m: not (m[1] == 0 and m[0] in [0x124, 0x321, 0x322, 0x323]), tx)) + if current_test.startswith('TestSubaruDPlatform') and attr.startswith('TestSubaruGen'): + tx = list(filter(lambda m: not (m[1] == 0 and m[0] in [0x124, 0x321, 0x322, 0x323]), tx)) if attr.startswith('TestSubaruPreglobal') and current_test.startswith('TestSubaruPreglobal'): continue if {attr, current_test}.issubset({'TestVolkswagenPqSafety', 'TestVolkswagenPqStockSafety', 'TestVolkswagenPqLongSafety'}): diff --git a/opendbc_repo/opendbc/safety/tests/test_subaru.py b/opendbc_repo/opendbc/safety/tests/test_subaru.py index 41e2624bb..28d5a9d8b 100755 --- a/opendbc_repo/opendbc/safety/tests/test_subaru.py +++ b/opendbc_repo/opendbc/safety/tests/test_subaru.py @@ -36,6 +36,7 @@ class SubaruMsg(enum.IntEnum): ES_HighBeamAssist = 0x121 ES_STATIC_1 = 0x22a ES_STATIC_2 = 0x325 + Dashlights = 0x390 SUBARU_MAIN_BUS = 0 @@ -401,6 +402,21 @@ class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, Test return self.packer.make_can_msg_safety("Steering_2", SUBARU_MAIN_BUS, {"Steering_Angle": angle}) +class TestSubaruDPlatformStopStartSafety(TestSubaruDPlatformAngleSafety): + FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | \ + SubaruSafetyFlags.STOP_START_BUTTON + TX_MSGS = TestSubaruDPlatformAngleSafety.TX_MSGS + [[SubaruMsg.Dashlights, SUBARU_ALT_BUS]] + + def _stop_start_msg(self, pressed): + return self.packer.make_can_msg_safety( + "Dashlights", SUBARU_ALT_BUS, {"COUNTER": 0, "STOP_START": pressed}, + ) + + def test_stop_start_tx_requires_pressed_bit(self): + self.assertTrue(self._tx(self._stop_start_msg(True))) + self.assertFalse(self._tx(self._stop_start_msg(False))) + + class TestSubaruDPlatformCameraAngleSafety(TestSubaruDPlatformAngleSafety): FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM | SubaruSafetyFlags.D_PLATFORM_CAMERA TX_MSGS = [[SubaruMsg.ES_LKAS_ANGLE, SUBARU_CAM_BUS], diff --git a/panda/board/obj/gitversion.h b/panda/board/obj/gitversion.h index 4476bc2a1..c23494695 100644 --- a/panda/board/obj/gitversion.h +++ b/panda/board/obj/gitversion.h @@ -1,2 +1,2 @@ extern const uint8_t gitversion[19]; -const uint8_t gitversion[19] = "DEV-f8d47139-DEBUG"; +const uint8_t gitversion[19] = "DEV-5882bd23-DEBUG"; diff --git a/panda/board/obj/version b/panda/board/obj/version index 4939093d6..79e70540b 100644 --- a/panda/board/obj/version +++ b/panda/board/obj/version @@ -1 +1 @@ -DEV-f8d47139-DEBUG \ No newline at end of file +DEV-5882bd23-DEBUG \ No newline at end of file diff --git a/scripts/model_compiler.py b/scripts/model_compiler.py index c91f4859a..f3db6079d 100644 --- a/scripts/model_compiler.py +++ b/scripts/model_compiler.py @@ -16,6 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from openpilot.starpilot.common.model_versions import UNIFIED_ARTIFACT_FORMAT + DEFAULT_INPUT_ROOT = Path("/data/openpilot/uncompiledmodels") DEFAULT_OUTPUT_ROOT = Path("/data/openpilot/compiledmodels") COMPILE_SCRIPT = REPO_ROOT / "tinygrad_repo/examples/openpilot/compile3.py" @@ -389,15 +391,33 @@ def multipart_output_paths(artifact: Path, output_dir: Path | None = None) -> li ] -def install_local_artifact(artifact: Path, model_key: str, version: str) -> None: +def _update_local_artifact_metadata(model_key: str, external_gpu: bool) -> None: + metadata_path = MODELS_PATH / ".model_artifacts.json" + try: + payload = json.loads(metadata_path.read_text()) if metadata_path.is_file() else {} + if not isinstance(payload, dict): + payload = {} + metadata = payload.get(model_key) + if not isinstance(metadata, dict): + metadata = {} + metadata["artifact_format"] = UNIFIED_ARTIFACT_FORMAT + metadata["uses_external_gpu"] = bool(external_gpu) + payload[model_key] = metadata + metadata_path.write_text(json.dumps(payload)) + except Exception as error: + print(f" WARN: could not update {metadata_path.name}: {error}") + + +def install_local_artifact(artifact: Path, model_key: str, version: str, external_gpu: bool = False) -> None: """Copy a freshly compiled local- model into the runtime dir modeld loads from, - and ensure its .json sidecar carries the correct version. + and ensure its .json sidecar and artifact cache carry the correct metadata. The sidecar version is NOT cosmetic: without it _discover_local_models() records an empty version, which downstream parses on the wrong contract (a v15 model then drives like v11). Since we know the build version here, we write it so the local - install is correct by default. Local models must be a single is_file() in - /data/models to show in the picker. No-ops off-device (no /data/models). + install is correct by default. The GPU flag is equally important: it selects + the out-of-band loader and AMD queue at runtime. Local models must be a single + is_file() in /data/models to show in the picker. No-ops off-device. """ if not MODELS_PATH.is_dir(): print(f" skipped auto-install: {MODELS_PATH} not present (not on device?)") @@ -415,20 +435,19 @@ def install_local_artifact(artifact: Path, model_key: str, version: str) -> None info = loaded except Exception as error: print(f" WARN: existing sidecar {sidecar.name} is malformed, rewriting: {error}") - if not version: - if not str(info.get("version") or "").strip(): - print(f" WARN: could not determine version -- set it by hand in {sidecar.name} " - "or the model may drive on the wrong version contract") - return - # keep any user-set name/series; only guarantee a correct, non-empty version - if str(info.get("version") or "").strip() == version and sidecar.is_file(): - print(f" sidecar ok: {sidecar.name} (version {version})") - return - info.setdefault("name", model_key[len("local-"):].replace("_", " ").replace("-", " ").strip()) - info.setdefault("series", "Local") - info["version"] = version - sidecar.write_text(json.dumps(info, indent=2) + "\n") - print(f" wrote sidecar {sidecar.name} (version {version})") + if version: + info.setdefault("name", model_key[len("local-"):].replace("_", " ").replace("-", " ").strip()) + info.setdefault("series", "Local") + info["version"] = version + elif not str(info.get("version") or "").strip(): + print(f" WARN: could not determine version -- set it by hand in {sidecar.name} " + "or the model may drive on the wrong version contract") + + info["uses_external_gpu"] = bool(external_gpu) + if version or sidecar.is_file() or external_gpu: + sidecar.write_text(json.dumps(info, indent=2) + "\n") + print(f" wrote sidecar {sidecar.name} (gpu={bool(external_gpu)})") + _update_local_artifact_metadata(model_key, external_gpu) def split_oversized_artifact( @@ -665,7 +684,7 @@ def main() -> int: print(f" --no-split: kept one {size_mb:.1f} MB file; over 100 MB, so split it " "before committing to a repo (re-run without --no-split, or --split-artifact)") if is_local and not args.no_install: - install_local_artifact(output, model_key, version) + install_local_artifact(output, model_key, version, args.external_gpu) else: multipart_outputs = split_oversized_artifact(output) if multipart_outputs: diff --git a/selfdrive/car/card.py b/selfdrive/car/card.py index 55386a71e..8f4d6841e 100644 --- a/selfdrive/car/card.py +++ b/selfdrive/car/card.py @@ -85,7 +85,8 @@ class Car: def __init__(self, CI=None, RI=None) -> None: self.can_sock = messaging.sub_sock('can', timeout=20) self.sm = messaging.SubMaster(['pandaStates', 'carControl', 'onroadEvents', 'radarState', 'longitudinalPlan']) - self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks', 'gpsLocationExternal']) + self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks']) + self.gps_pm = None self.can_rcv_cum_timeout_counter = 0 self._last_car_gps_timestamp_nanos = 0 @@ -138,7 +139,10 @@ class Car: self.CI, self.CP, self.FPCP = CI, CI.CP, CI.FPCP self.RI = RI - self.params.put_bool("CarGpsAvailable", bool(getattr(self.CI.CS, 'car_gps_supported', False))) + car_gps_supported = bool(getattr(self.CI.CS, 'car_gps_supported', False)) + self.params.put_bool("CarGpsAvailable", car_gps_supported) + if car_gps_supported: + self.gps_pm = messaging.PubMaster(['gpsLocationExternal']) interface_alternative_experience = self.CP.alternativeExperience self.CP.alternativeExperience = interface_alternative_experience @@ -364,7 +368,8 @@ class Car: gps.speedAccuracy = car_gps['speedAccuracy'] gps.hasFix = car_gps['hasFix'] gps.satelliteCount = car_gps['satelliteCount'] - self.pm.send('gpsLocationExternal', gps_send) + assert self.gps_pm is not None + self.gps_pm.send('gpsLocationExternal', gps_send) self._last_car_gps_publish_monotonic = now # carParams - logged every 50 seconds (> 1 per segment) diff --git a/selfdrive/controls/lib/lane_centering.py b/selfdrive/controls/lib/lane_centering.py index 6dd4fac05..aac1d78df 100644 --- a/selfdrive/controls/lib/lane_centering.py +++ b/selfdrive/controls/lib/lane_centering.py @@ -14,6 +14,7 @@ _MAX_OFFSET = 0.3 _MIN_CENTER_TO_LINE = 1.1 _MAX_RAW_CORRECTION = 0.004 _MAX_GAIN = 0.30 +_VISUAL_CORRECTION_EPSILON = 1e-6 _SMOOTH_TAU = 0.4 _SIGNAL_RELEASE_TAU = 0.20 _CONFIDENCE_RELEASE_TAU = 0.20 @@ -183,8 +184,9 @@ def get_lane_centering_visual_direction(model_v2, v_ego: float, offset: float, e ) if not valid or not np.isfinite(correction): return 0 - if correction == 0.0: - if applied_correction is None or not np.isfinite(applied_correction) or applied_correction == 0.0: - return 0 - correction = applied_correction + if applied_correction is not None and np.isfinite(applied_correction) and \ + abs(applied_correction) > _VISUAL_CORRECTION_EPSILON: + correction = float(applied_correction) + if abs(correction) <= _VISUAL_CORRECTION_EPSILON: + return 0 return 1 if correction > 0.0 else -1 diff --git a/selfdrive/controls/lib/latcontrol_angle.py b/selfdrive/controls/lib/latcontrol_angle.py index 02c9ffeab..8ab7f84c5 100644 --- a/selfdrive/controls/lib/latcontrol_angle.py +++ b/selfdrive/controls/lib/latcontrol_angle.py @@ -6,6 +6,8 @@ from openpilot.selfdrive.controls.lib.latcontrol import LatControl # TODO This is speed dependent STEER_ANGLE_SATURATION_THRESHOLD = 2.5 # Degrees +FORD_ANGLE_CATCH_UP_HORIZON = 1.25 # Seconds +FORD_ANGLE_RATE_FILTER_TIME_CONSTANT = 0.15 # Seconds _ASCENT_ANGLE_TRACKING_GAIN = 0.25 _ASCENT_ANGLE_TRACKING_MAX_CORRECTION = 8.0 @@ -23,12 +25,32 @@ def _ascent_angle_tracking_target(target_angle: float, steering_angle: float, return target_angle + correction +def _ford_angle_tracking_saturated(angle_error: float, steering_rate: float) -> bool: + """Only call a Ford angle request saturated when the EPS is not on track to catch it.""" + catching_up = angle_error * steering_rate > 0.0 + catching_up &= abs(angle_error) <= abs(steering_rate) * FORD_ANGLE_CATCH_UP_HORIZON + return abs(angle_error) > STEER_ANGLE_SATURATION_THRESHOLD and not catching_up + + class LatControlAngle(LatControl): def __init__(self, CP, CI, dt): super().__init__(CP, CI, dt) self.sat_check_min_speed = 5. self.use_steer_limited_by_safety = CP.brand in ("tesla", "hyundai") self.is_ascent = CP.carFingerprint == SUBARU_CAR.SUBARU_ASCENT_2023 + self.is_ford = CP.brand == "ford" + self.measured_angle_last = None + self.measured_angle_rate = 0.0 + + def _update_measured_angle_rate(self, steering_angle: float, reset: bool) -> float: + if reset or self.measured_angle_last is None: + self.measured_angle_rate = 0.0 + else: + raw_rate = (steering_angle - self.measured_angle_last) / self.dt + alpha = self.dt / (FORD_ANGLE_RATE_FILTER_TIME_CONSTANT + self.dt) + self.measured_angle_rate += alpha * (raw_rate - self.measured_angle_rate) + self.measured_angle_last = steering_angle + return self.measured_angle_rate def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay, calibrated_pose, model_data, starpilot_toggles): angle_log = log.ControlsState.LateralAngleState.new_message() @@ -49,13 +71,21 @@ class LatControlAngle(LatControl): bool(getattr(CS, "steeringPressed", False)), ) + ford_angle_mode = self.is_ford and getattr(starpilot_toggles, "ford_lateral_mode", -1) == 2 + measured_angle_rate = self._update_measured_angle_rate( + float(CS.steeringAngleDeg), not active or bool(CS.steeringPressed) or not ford_angle_mode) + if self.use_steer_limited_by_safety: # these cars' carcontrollers calculate max lateral accel and jerk, so we can rely on carOutput for saturation angle_control_saturated = steer_limited_by_safety else: # for cars which use a method of limiting torque such as a torque signal (Nissan and Toyota) # or relying on EPS (Ford Q3), carOutput does not capture maxing out torque # TODO: this can be improved - angle_control_saturated = abs(angle_steers_des - CS.steeringAngleDeg) > STEER_ANGLE_SATURATION_THRESHOLD + angle_error = angle_steers_des - CS.steeringAngleDeg + if ford_angle_mode: + angle_control_saturated = _ford_angle_tracking_saturated(angle_error, measured_angle_rate) + else: + angle_control_saturated = abs(angle_error) > STEER_ANGLE_SATURATION_THRESHOLD angle_log.saturated = bool(self._check_saturation(angle_control_saturated, CS, False, curvature_limited)) angle_log.steeringAngleDeg = float(CS.steeringAngleDeg) angle_log.steeringAngleDesiredDeg = angle_steers_des diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py index aefa0568e..ad6c3629d 100644 --- a/selfdrive/controls/lib/latcontrol_torque.py +++ b/selfdrive/controls/lib/latcontrol_torque.py @@ -108,6 +108,7 @@ class LatControlTorque(LatControl): self.is_genesis_gv70 = CP.carFingerprint in GENESIS_GV70_CARS self.is_palisade = CP.carFingerprint in PALISADE_CARS self.is_prius = CP.carFingerprint in PRIUS_CARS + self.is_standard_prius = CP.carFingerprint == TOYOTA_CAR.TOYOTA_PRIUS self.is_camry = CP.carFingerprint in CAMRY_CARS self.is_rav4_tss2 = CP.carFingerprint in RAV4_TSS2_CARS self.is_rav4_prime = CP.carFingerprint in RAV4_PRIME_CARS @@ -610,7 +611,9 @@ class LatControlTorque(LatControl): output_torque *= get_toyota_corolla_tss2_center_output_scale(setpoint, CS.vEgo) elif prius_active: output_torque *= prius_center_taper - output_torque *= get_prius_high_speed_output_taper_scale(setpoint, CS.vEgo) + prius_taper_max = (PRIUS_STANDARD_HIGH_SPEED_OUTPUT_TAPER_MAX if self.is_standard_prius + else PRIUS_HIGH_SPEED_OUTPUT_TAPER_MAX) + output_torque *= get_prius_high_speed_output_taper_scale(setpoint, CS.vEgo, prius_taper_max) elif volt_standard_test_active: output_torque *= volt_standard_center_taper elif volt_plexy_test_active: diff --git a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py index 1f94d0270..badda996f 100644 --- a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py +++ b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py @@ -247,7 +247,7 @@ GENESIS_G70_FRICTION_JERK_DEADZONE_LAT = 0.30 GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5 -GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.14 +GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.16 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10 GENESIS_G70_CENTER_OUTPUT_TAPER_SPEED = 18.0 @@ -277,7 +277,7 @@ GENESIS_G70_CURVE_UNWIND_LAT = 0.25 GENESIS_G70_CURVE_UNWIND_LAT_WIDTH = 0.12 GENESIS_G70_CURVE_UNWIND_JERK = 0.08 GENESIS_G70_CURVE_UNWIND_JERK_WIDTH = 0.08 -GENESIS_G70_UNWIND_FF_REDUCTION_MAX = 0.28 +GENESIS_G70_UNWIND_FF_REDUCTION_MAX = 0.32 GENESIS_G70_UNWIND_FF_OVERSHOOT = 0.12 GENESIS_G70_UNWIND_FF_OVERSHOOT_WIDTH = 0.12 GENESIS_G70_UNWIND_FF_JERK = 0.10 @@ -1035,6 +1035,7 @@ PRIUS_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.07 PRIUS_FRICTION_JERK_DEADZONE_SPEED = 18.0 PRIUS_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 2.2 PRIUS_HIGH_SPEED_OUTPUT_TAPER_MAX = 0.06 +PRIUS_STANDARD_HIGH_SPEED_OUTPUT_TAPER_MAX = 0.10 PRIUS_HIGH_SPEED_OUTPUT_TAPER_LAT = 0.30 PRIUS_HIGH_SPEED_OUTPUT_TAPER_LAT_WIDTH = 0.35 PRIUS_HIGH_SPEED_OUTPUT_TAPER_SPEED = 22.0 @@ -1493,12 +1494,13 @@ def get_prius_friction_jerk_deadzone(v_ego: float, desired_lateral_accel: float) return PRIUS_FRICTION_JERK_DEADZONE_MAX * speed_weight * center_weight -def get_prius_high_speed_output_taper_scale(desired_lateral_accel: float, v_ego: float) -> float: +def get_prius_high_speed_output_taper_scale(desired_lateral_accel: float, v_ego: float, + taper_max: float = PRIUS_HIGH_SPEED_OUTPUT_TAPER_MAX) -> float: speed_weight = _prius_sigmoid((v_ego - PRIUS_HIGH_SPEED_OUTPUT_TAPER_SPEED) / PRIUS_HIGH_SPEED_OUTPUT_TAPER_SPEED_WIDTH) curve_weight = _prius_sigmoid((abs(desired_lateral_accel) - PRIUS_HIGH_SPEED_OUTPUT_TAPER_LAT) / PRIUS_HIGH_SPEED_OUTPUT_TAPER_LAT_WIDTH) - return 1.0 - PRIUS_HIGH_SPEED_OUTPUT_TAPER_MAX * speed_weight * curve_weight + return 1.0 - taper_max * speed_weight * curve_weight def get_camry_friction_threshold(v_ego: float, desired_lateral_accel: float = 0.0, diff --git a/selfdrive/controls/lib/lead_behavior.py b/selfdrive/controls/lib/lead_behavior.py index f345f7d45..91aa2f587 100644 --- a/selfdrive/controls/lib/lead_behavior.py +++ b/selfdrive/controls/lib/lead_behavior.py @@ -113,7 +113,9 @@ def is_radarless_matched_follow_window(v_ego: float, lead_distance: float, v_lea def get_tracked_lead_catchup_bias(v_ego: float, lead_distance: float, desired_gap: float, closing_speed: float, - v_cruise: float | None = None, y_rel: float | None = None) -> float: + v_cruise: float | None = None, y_rel: float | None = None, + min_headway_margin: float = TRACKED_LEAD_CATCHUP_BIAS_MIN_HEADWAY_MARGIN, + full_headway_margin: float = TRACKED_LEAD_CATCHUP_BIAS_FULL_HEADWAY_MARGIN) -> float: gap_error = lead_distance - desired_gap actual_hw = lead_distance / max(v_ego, 1e-3) desired_hw = desired_gap / max(v_ego, 1e-3) @@ -137,8 +139,8 @@ def get_tracked_lead_catchup_bias(v_ego: float, lead_distance: float, desired_ga fade_end_margin = max(TRACKED_LEAD_CATCHUP_BIAS_MIN_FADE_END_MARGIN, TRACKED_LEAD_CATCHUP_BIAS_ABSOLUTE_FADE_END - desired_hw) entry_factor = _smoothstep(headway_margin, - TRACKED_LEAD_CATCHUP_BIAS_MIN_HEADWAY_MARGIN, - TRACKED_LEAD_CATCHUP_BIAS_FULL_HEADWAY_MARGIN) + min_headway_margin, + full_headway_margin) exit_factor = 1.0 - _smoothstep(headway_margin, fade_start_margin, fade_end_margin) closing_fade_end = max(2.5, 0.12 * v_ego) diff --git a/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py b/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py index e7d9f7ece..f8b8a29bb 100755 --- a/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py +++ b/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @@ -924,7 +924,7 @@ class LongitudinalMpc: personality=log.LongitudinalPersonality.standard, tracking_lead=True, optional_far_lead_comfort=True, smooth_duplicate_vision=False, stop_x=None, silverado_early_follow=False, modelV2=None, - lead_obstacle_bias=(0.0, 0.0)): + lead_obstacle_bias=(0.0, 0.0), tracked_lead_catchup_headway_margins=None): v_ego = self.x0[1] lead_one = radarstate.leadOne lead_two = radarstate.leadTwo @@ -972,6 +972,12 @@ class LongitudinalMpc: if optional_far_lead_comfort and tracking_lead and lead_one.status: desired_gap = desired_follow_distance(v_ego, lead_one.vLead, t_follow) closing_speed = max(0.0, v_ego - lead_one.vLead) + catchup_kwargs = {} + if tracked_lead_catchup_headway_margins is not None: + catchup_kwargs = { + "min_headway_margin": tracked_lead_catchup_headway_margins[0], + "full_headway_margin": tracked_lead_catchup_headway_margins[1], + } cruise_obstacle += get_tracked_lead_catchup_bias( v_ego, lead_one.dRel, @@ -979,6 +985,7 @@ class LongitudinalMpc: closing_speed, v_cruise=v_cruise, y_rel=float(getattr(lead_one, "yRel", 0.0)), + **catchup_kwargs, ) if optional_far_lead_comfort: cruise_obstacle += self.get_identical_radar_duplicate_cruise_bias(lead_one, lead_two, v_ego, t_follow) diff --git a/selfdrive/controls/lib/longitudinal_planner.py b/selfdrive/controls/lib/longitudinal_planner.py index 7fd1b6301..b9dedda37 100755 --- a/selfdrive/controls/lib/longitudinal_planner.py +++ b/selfdrive/controls/lib/longitudinal_planner.py @@ -37,8 +37,11 @@ from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import ( get_honda_crv_5g_stopped_lead_obstacle_bias, get_honda_crv_5g_low_speed_stopped_lead_cap, allow_honda_crv_5g_vision_gap_settle, + get_honda_crv_5g_early_radar_follow_cap, get_standstill_gap_settle_max_extra_gap, get_standstill_stopped_lead_guard_distance_margin, + get_standstill_stopped_lead_guard_max_lead_speed, + get_tracked_lead_catchup_headway_margins, ) from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET @@ -560,6 +563,7 @@ class LongitudinalPlanner: self.output_should_stop = False self.far_follow_brake_slew_rate, self.far_follow_release_slew_rate = get_far_follow_output_slew_rates(CP) self.untracked_slow_lead_decel_scale = get_untracked_slow_lead_decel_scale(CP) + self.tracked_lead_catchup_headway_margins = get_tracked_lead_catchup_headway_margins(CP) self.far_follow_output_slew_active = False self.model_launch_armed = False self.model_launch_stop_seen = False @@ -1638,13 +1642,16 @@ class LongitudinalPlanner: lead_speed = max(float(getattr(lead, "vLead", 0.0)), 0.0) lead_delta = lead_speed - float(v_ego) + max_lead_speed = get_standstill_stopped_lead_guard_max_lead_speed( + self.CP, STANDSTILL_STOPPED_LEAD_GUARD_MAX_LEAD_SPEED, + ) max_distance = max( STANDSTILL_STOPPED_LEAD_GUARD_MIN_DISTANCE, float(stop_distance) + get_standstill_stopped_lead_guard_distance_margin(self.CP), ) if ( float(getattr(lead, "dRel", float("inf"))) > max_distance or - lead_speed > STANDSTILL_STOPPED_LEAD_GUARD_MAX_LEAD_SPEED or + lead_speed > max_lead_speed or lead_delta > STANDSTILL_STOPPED_LEAD_GUARD_MAX_LEAD_DELTA ): return None @@ -1652,7 +1659,7 @@ class LongitudinalPlanner: distance_factor = float(np.clip((max_distance - float(lead.dRel)) / max(max_distance - STANDSTILL_STOPPED_LEAD_GUARD_MIN_DISTANCE, 0.1), 0.0, 1.0)) - speed_factor = float(np.clip(lead_speed / max(STANDSTILL_STOPPED_LEAD_GUARD_MAX_LEAD_SPEED, 0.1), 0.0, 1.0)) + speed_factor = float(np.clip(lead_speed / max(max_lead_speed, 0.1), 0.0, 1.0)) delta_factor = float(np.clip((STANDSTILL_STOPPED_LEAD_GUARD_MAX_LEAD_DELTA - lead_delta) / max(STANDSTILL_STOPPED_LEAD_GUARD_MAX_LEAD_DELTA, 0.1), 0.0, 1.0)) @@ -2261,7 +2268,8 @@ class LongitudinalPlanner: stop_x=force_stop_x, silverado_early_follow=early_truck_follow, modelV2=sm['modelV2'], - lead_obstacle_bias=stopped_lead_obstacle_bias) + lead_obstacle_bias=stopped_lead_obstacle_bias, + tracked_lead_catchup_headway_margins=self.tracked_lead_catchup_headway_margins) self.a_desired_trajectory_full = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution) self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution) @@ -2351,6 +2359,22 @@ class LongitudinalPlanner: if approach_lift_caps: raw_approach_lift_cap = min(approach_lift_caps) + if not experimental_mode: + early_radar_caps = [ + cap for cap in ( + get_honda_crv_5g_early_radar_follow_cap( + self.CP, self.lead_one, v_ego, vision_cap_accel_min, + ), + get_honda_crv_5g_early_radar_follow_cap( + self.CP, self.lead_two, v_ego, vision_cap_accel_min, + ), + ) if cap is not None + ] + if early_radar_caps: + early_radar_cap = min(early_radar_caps) + self.a_desired = min(self.a_desired, early_radar_cap) + output_a_target = min(output_a_target, early_radar_cap) + pretracking_vision_caps = [] for lead in (self.lead_one, self.lead_two): if lead.status and not bool(getattr(lead, "radar", False)): diff --git a/selfdrive/controls/lib/longitudinal_vehicle_tunes.py b/selfdrive/controls/lib/longitudinal_vehicle_tunes.py index cb2fe7ef8..fe6466e81 100644 --- a/selfdrive/controls/lib/longitudinal_vehicle_tunes.py +++ b/selfdrive/controls/lib/longitudinal_vehicle_tunes.py @@ -14,6 +14,10 @@ GM_SILVERADO_EARLY_FOLLOW_MAX_LATERAL_OFFSET = 1.2 DEFAULT_FOLLOW_PREBRAKE_MIN_HEADWAY = 1.25 GM_SILVERADO_FOLLOW_PREBRAKE_MIN_HEADWAY = 1.25 FORD_LIGHTNING_FOLLOW_PREBRAKE_MIN_HEADWAY = 1.0 +FORD_LIGHTNING_TRACKED_LEAD_CATCHUP_MIN_HEADWAY_MARGIN = 0.20 +FORD_LIGHTNING_TRACKED_LEAD_CATCHUP_FULL_HEADWAY_MARGIN = 0.45 +FORD_LIGHTNING_STANDSTILL_GUARD_DISTANCE_MARGIN = 5.0 +FORD_LIGHTNING_STANDSTILL_GUARD_MAX_LEAD_SPEED = 0.60 TOYOTA_SIENNA_POST_DEPARTURE_RESTOP_MAX_EGO_SPEED = 2.0 TOYOTA_SIENNA_POST_DEPARTURE_RESTOP_MAX_LEAD_SPEED = 0.45 TOYOTA_SIENNA_POST_DEPARTURE_RESTOP_MAX_LEAD_DELTA = 0.35 @@ -67,6 +71,14 @@ HONDA_CRV_5G_LOW_SPEED_STOP_MAX_DECEL = 0.45 HONDA_CRV_5G_LOW_SPEED_STOP_MIN_DECEL = 0.12 HONDA_CRV_5G_GAP_SETTLE_MAX_EXTRA_GAP = 7.0 HONDA_CRV_5G_GUARD_DISTANCE_MARGIN = 1.5 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MIN_EGO_SPEED = 8.0 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_EGO_SPEED = 22.0 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_LEAD_SPEED = 8.0 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MIN_CLOSING_SPEED = 7.0 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_TTC = 8.0 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_DISTANCE_TIME = 5.0 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MIN_MODEL_PROB = 0.80 +HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_LATERAL_OFFSET = 1.0 TOYOTA_CAMRY_TSS2_FORCE_STOP_HANDOFF_M = 4.5 # The Camry's force-stop path otherwise consumes the model endpoint before the # normal MPC stop-distance margin can be applied. Keep it within the forward @@ -113,6 +125,50 @@ def is_honda_crv_5g(CP): ) +def is_honda_crv_5g_early_radar_follow_lead(CP, lead, v_ego): + """Admit a credible, rapidly closing CR-V radar lead before model tracking catches up.""" + if ( + not is_honda_crv_5g(CP) or + lead is None or not bool(getattr(lead, "status", False)) or + not bool(getattr(lead, "radar", False)) or + not HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MIN_EGO_SPEED <= float(v_ego) <= \ + HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_EGO_SPEED or + float(getattr(lead, "modelProb", 0.0)) < HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MIN_MODEL_PROB or + abs(float(getattr(lead, "yRel", 0.0))) > HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_LATERAL_OFFSET + ): + return False + + lead_speed = max(float(getattr(lead, "vLead", 0.0)), 0.0) + closing_speed = float(v_ego) - lead_speed + distance = float(getattr(lead, "dRel", float("inf"))) + if ( + lead_speed > HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_LEAD_SPEED or + closing_speed < HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MIN_CLOSING_SPEED or + distance <= 0.0 or + distance > HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_DISTANCE_TIME * float(v_ego) + ): + return False + + return distance / max(closing_speed, 0.1) <= HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_TTC + + +def get_honda_crv_5g_early_radar_follow_cap(CP, lead, v_ego, accel_min): + """Start a mild CR-V radar response before the normal lead handoff.""" + if not is_honda_crv_5g_early_radar_follow_lead(CP, lead, v_ego): + return None + + lead_speed = max(float(getattr(lead, "vLead", 0.0)), 0.0) + closing_speed = float(v_ego) - lead_speed + distance = float(getattr(lead, "dRel", float("inf"))) + ttc = distance / max(closing_speed, 0.1) + urgency = float(np.clip( + (HONDA_CRV_5G_RADAR_EARLY_FOLLOW_MAX_TTC - ttc) / 4.0, + 0.0, 1.0, + )) + cap = 0.25 + 0.25 * urgency + return max(float(accel_min), -cap) + + def get_honda_crv_5g_stopped_lead_obstacle_bias(CP, lead, v_ego): """Bring the CR-V's vision stopped-lead target in without changing stops.""" if ( @@ -189,9 +245,33 @@ def get_standstill_gap_settle_max_extra_gap(CP): def get_standstill_stopped_lead_guard_distance_margin(CP): if is_honda_crv_5g(CP): return HONDA_CRV_5G_GUARD_DISTANCE_MARGIN + if is_ford_f150_lightning(CP): + return FORD_LIGHTNING_STANDSTILL_GUARD_DISTANCE_MARGIN return 3.0 +def get_standstill_stopped_lead_guard_max_lead_speed(CP, default): + if is_ford_f150_lightning(CP): + return FORD_LIGHTNING_STANDSTILL_GUARD_MAX_LEAD_SPEED + return float(default) + + +def get_tracked_lead_catchup_headway_margins(CP): + if is_ford_f150_lightning(CP): + return ( + FORD_LIGHTNING_TRACKED_LEAD_CATCHUP_MIN_HEADWAY_MARGIN, + FORD_LIGHTNING_TRACKED_LEAD_CATCHUP_FULL_HEADWAY_MARGIN, + ) + return None + + +def is_ford_f150_lightning(CP): + return ( + getattr(CP, "brand", "") == "ford" and + str(getattr(CP, "carFingerprint", "")) == "FORD_F_150_LIGHTNING_MK1" + ) + + def is_toyota_rav4_tss2_post_departure_tune(CP): """Identify RAV4 TSS2 variants that need normal catch-up caps after departure.""" return ( diff --git a/selfdrive/controls/tests/test_lane_centering.py b/selfdrive/controls/tests/test_lane_centering.py index beb89646b..dde592301 100644 --- a/selfdrive/controls/tests/test_lane_centering.py +++ b/selfdrive/controls/tests/test_lane_centering.py @@ -191,3 +191,8 @@ def test_visual_direction_requires_both_primary_lane_lines(): def test_visual_direction_uses_filtered_correction_in_deadband(): model = _model() assert get_lane_centering_visual_direction(model, _V_EGO, 0.0, 0.0, True, True, applied_correction=0.001) == 1 + + +def test_visual_direction_follows_applied_correction(): + model = _model(left=-1.5, right=2.1) + assert get_lane_centering_visual_direction(model, _V_EGO, 0.0, 0.0, True, True, applied_correction=-0.001) == -1 diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py index f7af3fcb8..75ea766a5 100644 --- a/selfdrive/controls/tests/test_latcontrol.py +++ b/selfdrive/controls/tests/test_latcontrol.py @@ -1,3 +1,4 @@ +import math import pytest from parameterized import parameterized from types import SimpleNamespace @@ -17,7 +18,11 @@ from opendbc.car.hyundai.values import CAR as HYUNDAI from opendbc.car.subaru.values import CAR as SUBARU from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL -from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle, _ascent_angle_tracking_target +from openpilot.selfdrive.controls.lib.latcontrol_angle import ( + LatControlAngle, + _ascent_angle_tracking_target, + _ford_angle_tracking_saturated, +) from openpilot.selfdrive.controls.lib.latcontrol_pid import ( LatControlPID, get_civic_bosch_modified_pid_output_alpha, @@ -193,6 +198,41 @@ class TestLatControl: assert _ascent_angle_tracking_target(10.0, 0.0, 4.0, False) == pytest.approx(10.0) assert _ascent_angle_tracking_target(10.0, 0.0, 20.0, True) == pytest.approx(10.0) + def test_ford_angle_tracking_does_not_report_a_responsive_eps_as_saturated(self): + assert not _ford_angle_tracking_saturated(12.0, 12.0) + assert not _ford_angle_tracking_saturated(-12.0, -12.0) + assert _ford_angle_tracking_saturated(16.0, 12.0) + assert _ford_angle_tracking_saturated(12.0, -12.0) + + def test_ford_angle_tracking_still_reports_a_stalled_eps(self): + assert _ford_angle_tracking_saturated(3.0, 0.0) + assert not _ford_angle_tracking_saturated(2.5, 0.0) + + def test_ford_angle_handoff_saturation_waits_for_eps_response(self): + CP = SimpleNamespace( + steerLimitTimer=1.0, + brand="ford", + carFingerprint="FORD_MUSTANG_MACH_E_MK1", + ) + controller = LatControlAngle(CP, None, DT_CTRL) + target = [12.0] + VM = SimpleNamespace(get_steer_from_curvature=lambda *_args: math.radians(target[0])) + CS = car.CarState.new_message(vEgo=10.0, steeringPressed=False) + params = log.LiveParametersData.new_message(angleOffsetDeg=0.0, roll=0.0) + toggles = SimpleNamespace(ford_lateral_mode=2) + + for frame in range(round(2.0 / DT_CTRL)): + CS.steeringAngleDeg = frame * 12.0 * DT_CTRL + target[0] = CS.steeringAngleDeg + 12.0 + _, _, angle_log = controller.update( + True, CS, VM, params, False, 0.0, False, 0.0, None, None, toggles) + assert not angle_log.saturated + + for _ in range(round(2.0 / DT_CTRL)): + _, _, angle_log = controller.update( + True, CS, VM, params, False, 0.0, False, 0.0, None, None, toggles) + assert angle_log.saturated + def test_torque_log_exposes_friction_controller_state(self): controller, VM, CS, params, starpilot_toggles = self._build_torque_controller(GM.CHEVROLET_BOLT_ACC_2022_2023) diff --git a/selfdrive/controls/tests/test_lead_behavior.py b/selfdrive/controls/tests/test_lead_behavior.py index 088a2d0c2..4070fbb2d 100644 --- a/selfdrive/controls/tests/test_lead_behavior.py +++ b/selfdrive/controls/tests/test_lead_behavior.py @@ -17,6 +17,30 @@ def test_tracked_lead_catchup_bias_ignores_near_desired_gap(): assert bias == 0.0 +def test_tracked_lead_catchup_bias_corrects_lightning_aggressive_follow_bookmark(): + default_bias = get_tracked_lead_catchup_bias( + 31.75, + 46.2, + 34.0, + 0.0, + v_cruise=37.55, + y_rel=0.1, + ) + bias = get_tracked_lead_catchup_bias( + 31.75, + 46.2, + 34.0, + 0.0, + v_cruise=37.55, + y_rel=0.1, + min_headway_margin=0.20, + full_headway_margin=0.45, + ) + + assert default_bias == 0.0 + assert bias > 2.0 + + def test_tracked_lead_catchup_bias_ignores_very_far_gap(): bias = get_tracked_lead_catchup_bias(31.4, 110.0, 38.0, 0.1) assert bias == 0.0 diff --git a/selfdrive/controls/tests/test_longitudinal_planner.py b/selfdrive/controls/tests/test_longitudinal_planner.py index 830edfdfd..619b4cac3 100644 --- a/selfdrive/controls/tests/test_longitudinal_planner.py +++ b/selfdrive/controls/tests/test_longitudinal_planner.py @@ -10,6 +10,8 @@ from cereal import log from opendbc.car.honda.interface import CarInterface from opendbc.car.honda.values import CAR from opendbc.car.gm.values import CAR as GM_CAR, GMFlags +from opendbc.car.ford.interface import CarInterface as FordCarInterface +from opendbc.car.ford.values import CAR as FORD_CAR from opendbc.car.toyota.interface import CarInterface as ToyotaCarInterface from opendbc.car.toyota.values import CAR as TOYOTA_CAR import openpilot.selfdrive.controls.lib.longitudinal_planner as longitudinal_planner_module @@ -31,7 +33,12 @@ from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import ( get_honda_crv_5g_stopped_lead_obstacle_bias, get_honda_crv_5g_low_speed_stopped_lead_cap, allow_honda_crv_5g_vision_gap_settle, + is_honda_crv_5g_early_radar_follow_lead, + get_honda_crv_5g_early_radar_follow_cap, get_standstill_gap_settle_max_extra_gap, + get_standstill_stopped_lead_guard_distance_margin, + get_standstill_stopped_lead_guard_max_lead_speed, + get_tracked_lead_catchup_headway_margins, get_toyota_prius_stopped_lead_obstacle_bias, get_toyota_rav4_tss2_lead_departure_tune, get_toyota_rav4_tss2_early_lead_cap, @@ -139,6 +146,43 @@ def test_honda_crv_5g_stopped_lead_tune_is_vehicle_specific(): assert get_standstill_gap_settle_max_extra_gap(crv) > get_standstill_gap_settle_max_extra_gap(civic) +def test_honda_crv_5g_early_radar_follow_admits_high_closing_centered_lead(): + crv = CarInterface.get_non_essential_params(CAR.HONDA_CRV_5G) + lead = make_lead( + status=True, d_rel=77.0, v_lead=2.8, radar=True, model_prob=0.87, y_rel=0.56, + ) + + assert is_honda_crv_5g_early_radar_follow_lead(crv, lead, v_ego=16.2) + assert get_honda_crv_5g_early_radar_follow_cap( + crv, lead, v_ego=16.2, accel_min=-3.5, + ) == pytest.approx(-0.39, abs=0.01) + + +@pytest.mark.parametrize("kwargs", [ + {"radar": False}, + {"model_prob": 0.79}, + {"y_rel": 1.01}, + {"v_lead": 8.1}, + {"d_rel": 90.0}, +]) +def test_honda_crv_5g_early_radar_follow_rejects_unreliable_or_nonurgent_lead(kwargs): + crv = CarInterface.get_non_essential_params(CAR.HONDA_CRV_5G) + lead_kwargs = { + "d_rel": 77.0, "v_lead": 2.8, "radar": True, "model_prob": 0.87, "y_rel": 0.56, + } + lead_kwargs.update(kwargs) + lead = make_lead(status=True, **lead_kwargs) + + assert not is_honda_crv_5g_early_radar_follow_lead(crv, lead, v_ego=16.2) + + +def test_honda_crv_5g_early_radar_follow_is_vehicle_specific(): + civic = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + lead = make_lead(status=True, d_rel=77.0, v_lead=2.8, radar=True, model_prob=0.87, y_rel=0.56) + + assert not is_honda_crv_5g_early_radar_follow_lead(civic, lead, v_ego=16.2) + + @pytest.mark.parametrize("model_version", ["v11", "v12", "v13", "v14", "v15"]) def test_honda_crv_5g_vision_lead_gap_settle_is_bounded(model_version): CP = CarInterface.get_non_essential_params(CAR.HONDA_CRV_5G) @@ -679,6 +723,18 @@ def test_prebrake_floor_is_vehicle_specific(): assert get_follow_prebrake_min_headway(honda, 1.0) == pytest.approx(1.25) +def test_lightning_stopped_lead_guard_tune_is_vehicle_specific(): + lightning = FordCarInterface.get_non_essential_params(FORD_CAR.FORD_F_150_LIGHTNING_MK1) + civic = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + + assert get_standstill_stopped_lead_guard_distance_margin(lightning) == pytest.approx(5.0) + assert get_standstill_stopped_lead_guard_distance_margin(civic) == pytest.approx(3.0) + assert get_standstill_stopped_lead_guard_max_lead_speed(lightning, 0.45) == pytest.approx(0.60) + assert get_standstill_stopped_lead_guard_max_lead_speed(civic, 0.45) == pytest.approx(0.45) + assert get_tracked_lead_catchup_headway_margins(lightning) == pytest.approx((0.20, 0.45)) + assert get_tracked_lead_catchup_headway_margins(civic) is None + + def test_silverado_vision_follow_hold_survives_nonurgent_far_lead_crossover(): v_ego = 32.0 t_follow = 1.0 @@ -2332,6 +2388,37 @@ def test_standstill_stopped_lead_guard_blocks_false_release_at_longer_gap(model_ assert planner.output_a_target <= 0.0 +def test_lightning_stopped_lead_guard_holds_ambiguous_creep_then_releases(): + CP = FordCarInterface.get_non_essential_params(FORD_CAR.FORD_F_150_LIGHTNING_MK1) + planner = LongitudinalPlanner(CP, init_v=0.17) + sm = make_sm( + 0.17, + desired_accel=0.17, + min_accel=-0.5, + experimental_mode=False, + tracking_lead=True, + lead_one=make_lead(status=True, d_rel=9.5, v_lead=0.46, a_lead=0.02, radar=True, model_prob=1.0), + ) + sm["starpilotPlan"].vCruise = 10.0 + sm["modelV2"].action.shouldStop = False + + planner.update(sm, make_toggles("v15")) + + assert planner.output_should_stop + assert planner.output_a_target <= 0.0 + + sm["carState"].vEgo = 0.0 + sm["carState"].vEgoCluster = 0.0 + sm["carState"].standstill = True + sm["radarState"].leadOne.dRel = 9.9 + sm["radarState"].leadOne.vLead = 0.71 + sm["radarState"].leadOne.vLeadK = 0.71 + sm["radarState"].leadOne.aLeadK = 0.22 + planner.update(sm, make_toggles("v15")) + + assert not planner.output_should_stop + + @pytest.mark.parametrize("model_version", ["v11", "v12", "v13", "v14", "v15"]) def test_standstill_stopped_lead_guard_does_not_block_radar_depart_at_longer_gap(model_version): CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index 89586aae5..2eedcef0c 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -77,9 +77,6 @@ def _should_publish_model_output(model_output, vipc_dropped_frames: int, externa MIN_LAT_CONTROL_SPEED = 0.3 BIG_MODEL_LOAD_WAIT_TIMEOUT_MS = 30000 BIG_MODEL_RUN_WAIT_TIMEOUT_MS = 3000 -EXTERNAL_GPU_POWER_READY_MV = 13000 -EXTERNAL_GPU_POWER_STABLE_SECONDS = 3.0 -EXTERNAL_GPU_POWER_LOG_INTERVAL_SECONDS = 10.0 LAT_SMOOTH_BP = [2.0, 8.0] @@ -92,40 +89,6 @@ def _set_hcq_wait_timeout(timeout_ms: int) -> None: getenv.cache_clear() -def _external_gpu_power_ready(panda_states, now: float, stable_since: float | None) -> tuple[bool, float | None, int | None]: - voltages = [ - int(state.voltage) for state in panda_states - if state.pandaType != log.PandaState.PandaType.unknown and int(state.voltage) > 0 - ] - voltage = max(voltages, default=None) - if voltage is None or voltage < EXTERNAL_GPU_POWER_READY_MV: - return False, None, voltage - - stable_since = now if stable_since is None else stable_since - return now - stable_since >= EXTERNAL_GPU_POWER_STABLE_SECONDS, stable_since, voltage - - -def wait_for_external_gpu_power_ready() -> None: - """Wait until the vehicle's 12 V rail is in its post-start charging state.""" - sm = SubMaster(["pandaStates"]) - stable_since = None - last_log = 0.0 - - while True: - sm.update(1000) - now = time.monotonic() - ready, stable_since, voltage = _external_gpu_power_ready(sm["pandaStates"], now, stable_since) - if ready: - cloudlog.warning(f"vehicle power stable at {voltage / 1000:.2f} V; starting external GPU load") - return - - if now - last_log >= EXTERNAL_GPU_POWER_LOG_INTERVAL_SECONDS: - detail = "unavailable" if voltage is None else f"{voltage / 1000:.2f} V" - cloudlog.warning(f"external GPU load deferred: vehicle power is {detail}; waiting for " + - f"{EXTERNAL_GPU_POWER_READY_MV / 1000:.1f} V to remain stable") - last_log = now - - def get_lateral_smooth_seconds(v_ego: float, maximum: float = 0.0) -> float: return float(np.interp(v_ego, LAT_SMOOTH_BP, [maximum, 0.0])) @@ -666,14 +629,10 @@ def _load_model_state(cam_w: int, cam_h: int, selected_model: str, external_gpu_ return ModelState(cam_w, cam_h, False) -def _load_external_gpu_model(cam_w: int, cam_h: int, selected_model: str, - demo: bool = False) -> ModelState | None: +def _load_external_gpu_model(cam_w: int, cam_h: int, selected_model: str) -> ModelState | None: """Load and warm the USB-GPU model without running another tinygrad model concurrently.""" candidate = None try: - if not demo: - wait_for_external_gpu_power_ready() - _set_hcq_wait_timeout(BIG_MODEL_LOAD_WAIT_TIMEOUT_MS) wait_usbgpu_link() candidate = ModelState( @@ -748,7 +707,6 @@ def main(demo=False): vipc_client_main.width, vipc_client_main.height, selected_model, - demo, ) small_model = ModelState( diff --git a/selfdrive/modeld/tests/test_usbgpu_helpers.py b/selfdrive/modeld/tests/test_usbgpu_helpers.py index 0e643941c..11f36a425 100644 --- a/selfdrive/modeld/tests/test_usbgpu_helpers.py +++ b/selfdrive/modeld/tests/test_usbgpu_helpers.py @@ -69,46 +69,6 @@ def test_native_amd_signal_keeps_existing_short_wait_behavior(): assert sleeps == [200] -def test_external_gpu_power_must_be_stable_after_vehicle_start(): - panda_type = modeld.log.PandaState.PandaType.tres - - def panda_state(voltage): - return SimpleNamespace(pandaType=panda_type, voltage=voltage) - - ready, stable_since, voltage = modeld._external_gpu_power_ready([panda_state(12800)], 10.0, None) - assert not ready - assert stable_since is None - assert voltage == 12800 - - ready, stable_since, voltage = modeld._external_gpu_power_ready([panda_state(14100)], 11.0, stable_since) - assert not ready - assert stable_since == 11.0 - assert voltage == 14100 - - ready, stable_since, _ = modeld._external_gpu_power_ready([panda_state(14100)], 13.9, stable_since) - assert not ready - assert stable_since == 11.0 - - ready, stable_since, _ = modeld._external_gpu_power_ready([panda_state(11900)], 14.0, stable_since) - assert not ready - assert stable_since is None - - ready, stable_since, _ = modeld._external_gpu_power_ready([panda_state(14100)], 15.0, stable_since) - assert not ready - ready, stable_since, _ = modeld._external_gpu_power_ready([panda_state(14100)], 18.0, stable_since) - assert ready - assert stable_since == 15.0 - - -def test_external_gpu_power_ignores_unknown_pandas(): - panda_states = [ - SimpleNamespace(pandaType=modeld.log.PandaState.PandaType.unknown, voltage=15000), - SimpleNamespace(pandaType=modeld.log.PandaState.PandaType.tres, voltage=0), - ] - - assert modeld._external_gpu_power_ready(panda_states, 10.0, None) == (False, None, None) - - def test_external_gpu_wait_timeout_updates_tinygrad_cache(monkeypatch): from tinygrad.helpers import getenv @@ -178,7 +138,6 @@ def test_external_gpu_load_finishes_before_native_model_can_start(monkeypatch): def warmup(self): calls.append("warmup") - monkeypatch.setattr(modeld, "wait_for_external_gpu_power_ready", lambda: calls.append("power")) monkeypatch.setattr(modeld, "wait_usbgpu_link", lambda: calls.append("link")) monkeypatch.setattr(modeld, "_set_hcq_wait_timeout", lambda timeout: calls.append(("timeout", timeout))) monkeypatch.setattr(modeld, "_close_tinygrad_disk_cache_connection", lambda: calls.append("close_cache")) @@ -193,7 +152,6 @@ def test_external_gpu_load_finishes_before_native_model_can_start(monkeypatch): assert isinstance(loaded, FakeModelState) assert calls == [ - "power", ("timeout", modeld.BIG_MODEL_LOAD_WAIT_TIMEOUT_MS), "link", ("model", 1928, 1208, True, "big-model", False), diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py index 9c6d657a5..619443ca0 100644 --- a/selfdrive/ui/mici/onroad/model_renderer.py +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -365,12 +365,13 @@ class ModelRenderer(Widget): def _lane_centering_direction(self) -> int: toggles = ui_state.starpilot_toggles sm = ui_state.sm - if not sm.valid.get("modelV2", False) or not sm.valid.get("carState", False): + if (sm.recv_frame.get("modelV2", 0) < ui_state.started_frame or + sm.recv_frame.get("carState", 0) < ui_state.started_frame): return 0 car_state = sm["carState"] applied_correction = None - if sm.valid.get("controlsState", False): + if sm.recv_frame.get("controlsState", 0) >= ui_state.started_frame: try: applied_correction = sm["controlsState"].desiredCurvature - sm["modelV2"].action.desiredCurvature except (AttributeError, TypeError, ValueError): diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index 9c8169a93..92f34720e 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -373,12 +373,13 @@ class ModelRenderer(Widget): def _lane_centering_direction(self) -> int: toggles = ui_state.starpilot_toggles sm = ui_state.sm - if not sm.valid.get("modelV2", False) or not sm.valid.get("carState", False): + if (sm.recv_frame.get("modelV2", 0) < ui_state.started_frame or + sm.recv_frame.get("carState", 0) < ui_state.started_frame): return 0 car_state = sm["carState"] applied_correction = None - if sm.valid.get("controlsState", False): + if sm.recv_frame.get("controlsState", 0) >= ui_state.started_frame: try: applied_correction = sm["controlsState"].desiredCurvature - sm["modelV2"].action.desiredCurvature except (AttributeError, TypeError, ValueError): diff --git a/selfdrive/ui/onroad/starpilot/pulse_glide.py b/selfdrive/ui/onroad/starpilot/pulse_glide.py index e23e1b08f..01c54f6b5 100644 --- a/selfdrive/ui/onroad/starpilot/pulse_glide.py +++ b/selfdrive/ui/onroad/starpilot/pulse_glide.py @@ -11,7 +11,12 @@ DEEP_GLIDE_BORDER_COLOR = rl.Color(24, 72, 150, 255) def get_pulse_glide_border_color(sm, default_color: rl.Color) -> rl.Color: """Use a deep-blue outer border only while developer P&G is gliding.""" - if not sm.valid.get("starpilotCarState", False) or not sm.valid.get("starpilotPlan", False): + if ( + not sm.valid.get("starpilotCarState", False) or + not sm.valid.get("starpilotPlan", False) or + not sm.valid.get("carControl", False) or + not bool(getattr(sm["carControl"], "longActive", False)) + ): return default_color car_state = sm["starpilotCarState"] diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py index e386c5661..dd1e31aa7 100644 --- a/starpilot/assets/model_manager.py +++ b/starpilot/assets/model_manager.py @@ -571,6 +571,7 @@ class ModelManager: "released": str(info.get("released") or "2100-01-01").strip(), "community_favorite": False, "artifact_format": UNIFIED_ARTIFACT_FORMAT, + "uses_external_gpu": bool(info.get("uses_external_gpu", False)), } return list(discovered.values()) diff --git a/starpilot/assets/tests/test_model_pipeline.py b/starpilot/assets/tests/test_model_pipeline.py index 429fff041..ae70ef119 100644 --- a/starpilot/assets/tests/test_model_pipeline.py +++ b/starpilot/assets/tests/test_model_pipeline.py @@ -64,6 +64,32 @@ def test_external_gpu_requirement_is_cached_from_manifest(tmp_path, monkeypatch) assert not model_manager.model_uses_external_gpu("missing") +def test_local_gpu_compile_persists_runtime_metadata(tmp_path, monkeypatch): + models_path = tmp_path / "models" + compiled_path = tmp_path / "compiled" / "local-large_driving_tinygrad.pkl" + models_path.mkdir() + compiled_path.parent.mkdir() + compiled_path.write_bytes(b"artifact") + monkeypatch.setattr(model_compiler, "MODELS_PATH", models_path) + + model_compiler.install_local_artifact(compiled_path, "local-large", "v16", external_gpu=True) + + sidecar = json.loads((models_path / "local-large.json").read_text()) + metadata = json.loads((models_path / model_manager.ARTIFACT_METADATA_CACHE).read_text()) + assert sidecar["uses_external_gpu"] is True + assert metadata["local-large"]["uses_external_gpu"] is True + + monkeypatch.setattr(model_manager, "MODELS_PATH", models_path) + manager = object.__new__(ModelManager) + assert manager._discover_local_models()[0]["uses_external_gpu"] is True + + model_compiler.install_local_artifact(compiled_path, "local-large", "v16", external_gpu=False) + sidecar = json.loads((models_path / "local-large.json").read_text()) + metadata = json.loads((models_path / model_manager.ARTIFACT_METADATA_CACHE).read_text()) + assert sidecar["uses_external_gpu"] is False + assert metadata["local-large"]["uses_external_gpu"] is False + + def test_external_gpu_compilation_is_opt_in(tmp_path, monkeypatch): invocations = [] monkeypatch.setattr(model_compiler, "build_compile_env", lambda **_: { diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 4f70400bd..61d28f3ec 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -499,7 +499,7 @@ "ui_type": "numeric", "min": 0.0, "max": 1.0, - "step": 0.05, + "step": 0.01, "precision": 2, "parent_key": "FordLateralMode", "visible_when_key": "FordLateralMode", @@ -520,7 +520,7 @@ "ui_type": "numeric", "min": 0.5, "max": 1.5, - "step": 0.05, + "step": 0.01, "precision": 2, "parent_key": "FordLateralMode", "visible_when_key": "FordLateralMode", @@ -541,7 +541,7 @@ "ui_type": "numeric", "min": 0.5, "max": 1.5, - "step": 0.05, + "step": 0.01, "precision": 2, "parent_key": "FordLateralMode", "visible_when_key": "FordLateralMode", @@ -562,7 +562,7 @@ "ui_type": "numeric", "min": 0.25, "max": 1.25, - "step": 0.05, + "step": 0.01, "precision": 2, "parent_key": "FordLateralMode", "visible_when_key": "FordLateralMode", @@ -583,7 +583,7 @@ "ui_type": "numeric", "min": 0.5, "max": 1.5, - "step": 0.05, + "step": 0.01, "precision": 2, "parent_key": "FordLateralMode", "visible_when_key": "FordLateralMode", @@ -3379,6 +3379,16 @@ "ui_type": "toggle", "settings_tier": "simple" }, + { + "key": "SubaruStopStartOff", + "label": "Stop/Start Off at Startup", + "description": "For Subaru Outback 2023-24, send one momentary Stop/Start request after ignition while the vehicle is stationary and in Park or Neutral.", + "picker_description": "Requests Stop/Start OFF once after ignition on supported Outbacks.", + "data_type": "bool", + "ui_type": "toggle", + "galaxy_only": true, + "settings_tier": "simple" + }, { "key": "ClusterOffset", "label": "Dashboard Speed Offset", diff --git a/starpilot/common/safe_mode.py b/starpilot/common/safe_mode.py index 71e393e35..ab9680b53 100644 --- a/starpilot/common/safe_mode.py +++ b/starpilot/common/safe_mode.py @@ -191,6 +191,7 @@ SAFE_MODE_MANAGED_KEYS = ( "ToyotaAutoHold", "SubaruSNG", "SubaruSNGManualParkingBrake", + "SubaruStopStartOff", "VoltSNG", "JeepBrakeHold", "GMAutoHold", @@ -208,6 +209,7 @@ SAFE_MODE_FIXED_VALUES = { "ExperimentalMode": False, "LongitudinalPersonality": int(log.LongitudinalPersonality.relaxed), "UseAutoSteerDelay": True, + "SubaruStopStartOff": False, } SAFE_MODE_STOCK_PARAM_MAP = { diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index 41be05894..eea603ee6 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -20,7 +20,7 @@ from opendbc.car.gm.values import CAR as GM_CAR, EV_CAR as GM_EV_CAR, GMFlags from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, EV_CAR as HYUNDAI_EV_CAR, HyundaiFlags, HyundaiStarPilotSafetyFlags from opendbc.car.interfaces import TORQUE_SUBSTITUTE_PATH, CarInterfaceBase, GearShifter from opendbc.car.mock.values import CAR as MOCK -from opendbc.car.subaru.values import SubaruFlags +from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags from opendbc.car.tesla.values import CAR as TESLA_CAR from opendbc.car.toyota.values import CAR as TOYOTA_CAR, ToyotaStarPilotFlags from openpilot.common.basedir import BASEDIR @@ -621,6 +621,14 @@ class StarPilotVariables: alpha_longitudinal = CP.alphaLongitudinalAvailable toggle.car_make = CP.brand + toggle.ford_lateral_mode = self.get_value( + "FordLateralMode", + cast=int, + condition=toggle.car_make == "ford", + default=1, + min=0, + max=2, + ) migrate_ford_lkas_button_default(toggle.car_make, self.params) toggle.car_model = CP.carFingerprint toggle.disable_openpilot_long = self.get_value("DisableOpenpilotLongitudinal", condition=not alpha_longitudinal) @@ -1479,6 +1487,9 @@ class StarPilotVariables: toggle.subaru_sng = self.get_value("SubaruSNG", condition=toggle.car_make == "subaru" and not (CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID | SubaruFlags.LKAS_ANGLE))) toggle.subaru_sng_manual_parking_brake = self.get_value("SubaruSNGManualParkingBrake", condition=toggle.subaru_sng) + toggle.subaru_stop_start_off = self.get_value( + "SubaruStopStartOff", condition=toggle.car_model == SUBARU_CAR.SUBARU_OUTBACK_2023, + ) toggle.jeep_brake_hold = self.get_value( "JeepBrakeHold", diff --git a/starpilot/controls/starpilot_card.py b/starpilot/controls/starpilot_card.py index 04b857f4e..d647f24f9 100644 --- a/starpilot/controls/starpilot_card.py +++ b/starpilot/controls/starpilot_card.py @@ -86,7 +86,7 @@ class StarPilotCard: elif getattr(starpilot_toggles, f"force_coast_via_{key}"): self.force_coast = not self.force_coast elif getattr(starpilot_toggles, f"pulse_and_glide_via_{key}"): - if getattr(sm["carControl"], "longActive", False): + if getattr(sm["carControl"], "longActive", False) or self.pulse_and_glide: self.pulse_and_glide = not self.pulse_and_glide return True elif getattr(starpilot_toggles, f"pause_lateral_via_{key}"): @@ -137,9 +137,12 @@ class StarPilotCard: self.switchback_mode_enabled = self.params_memory.get_bool("SwitchbackModeEnabled") self._handle_favorite_traffic_mode_action(sm) - pulse_glide_cancel_override = bool(getattr(sm["carControl"], "longActive", False)) and any( - getattr(starpilot_toggles, f"pulse_and_glide_via_cancel{suffix}", False) - for suffix in ("", "_long", "_very_long") + pulse_glide_cancel_override = ( + (bool(getattr(sm["carControl"], "longActive", False)) or self.pulse_and_glide) and + any( + getattr(starpilot_toggles, f"pulse_and_glide_via_cancel{suffix}", False) + for suffix in ("", "_long", "_very_long") + ) ) cancel_pressed = bool(getattr(starpilotCarState, "cancelPressed", False)) if pulse_glide_cancel_override: @@ -155,8 +158,9 @@ class StarPilotCard: self._button_type_raw(be) == int(ButtonType.lkas) and be.pressed for be in carState.buttonEvents ) - pulse_glide_lkas_override = bool(getattr(sm["carControl"], "longActive", False)) and getattr( - starpilot_toggles, "pulse_and_glide_via_lkas", False + pulse_glide_lkas_override = ( + (bool(getattr(sm["carControl"], "longActive", False)) or self.pulse_and_glide) and + getattr(starpilot_toggles, "pulse_and_glide_via_lkas", False) ) if pulse_glide_lkas_override: carState.buttonEvents = [ @@ -368,8 +372,6 @@ class StarPilotCard: if not getattr(starpilot_toggles, "pulse_and_glide_available", False): self.pulse_and_glide = False - self.pulse_and_glide &= bool(getattr(sm["carControl"], "longActive", False)) - self.pulse_and_glide &= not (carState.brakePressed or carState.gasPressed) self.force_coast &= not (carState.brakePressed or carState.gasPressed) starpilotCarState.accelPressed = self.accel_pressed diff --git a/starpilot/controls/tests/test_starpilot_card.py b/starpilot/controls/tests/test_starpilot_card.py index b8ed4714f..2beda9663 100644 --- a/starpilot/controls/tests/test_starpilot_card.py +++ b/starpilot/controls/tests/test_starpilot_card.py @@ -123,7 +123,38 @@ def test_pulse_and_glide_requires_developer_access_and_active_longitudinal(monke car_state = make_car_state(gas_pressed=True) result = card.update(car_state, starpilot_car_state, sm, toggles) + assert result.pulseAndGlide is True + + +def test_pulse_and_glide_survives_temporary_disengagement(monkeypatch, tmp_path): + monkeypatch.setattr(spc, "Params", FakeParams) + monkeypatch.setattr(spc, "ERROR_LOGS_PATH", tmp_path) + + card = spc.StarPilotCard(SimpleNamespace(brand="gm"), SimpleNamespace(alternativeExperience=0)) + sm = make_sm() + sm["carControl"].longActive = True + toggles = make_toggles( + pulse_and_glide_available=True, + pulse_and_glide_via_lkas=True, + ) + starpilot_car_state = SimpleNamespace(distancePressed=False) + + card.handle_button_event("lkas", sm, toggles) + assert card.pulse_and_glide is True + + sm["carControl"].longActive = False + result = card.update(make_car_state(brake_pressed=True), starpilot_car_state, sm, toggles) + assert result.pulseAndGlide is True + + sm["carControl"].longActive = True + result = card.update(make_car_state(), starpilot_car_state, sm, toggles) + assert result.pulseAndGlide is True + + sm["carControl"].longActive = False + off_press = make_car_state(button_events=[SimpleNamespace(type=spc.ButtonType.lkas, pressed=True)]) + result = card.update(off_press, starpilot_car_state, sm, toggles) assert result.pulseAndGlide is False + assert off_press.buttonEvents == [] def test_pulse_and_glide_consumes_native_cancel_when_mapped(monkeypatch, tmp_path): diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index 20b875bd0..63752f24f 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -39,6 +39,7 @@ const VEHICLE_SETTING_MAKES = { JeepBrakeHold: ["Jeep"], SubaruSNG: ["Subaru"], SubaruSNGManualParkingBrake: ["Subaru"], + SubaruStopStartOff: ["Subaru"], ClusterOffset: ["Lexus", "Toyota"], SNGHack: ["Lexus", "Toyota"], ToyotaAutoHold: ["Lexus", "Toyota"], diff --git a/starpilot/system/the_galaxy/tests/test_device_settings_layout.py b/starpilot/system/the_galaxy/tests/test_device_settings_layout.py index 969dda9e0..e26458b5a 100644 --- a/starpilot/system/the_galaxy/tests/test_device_settings_layout.py +++ b/starpilot/system/the_galaxy/tests/test_device_settings_layout.py @@ -255,6 +255,22 @@ def test_honda_pid_scale_controls_use_galaxy_fine_granularity(): assert setting["settings_tier"] == "advanced" +def test_ford_angle_controls_use_galaxy_fine_granularity(): + lateral = _params_by_section(_layout())["Lateral (Steering)"] + + for key in ( + "FordAngleBlend", + "FordAngleLowSpeedFactor", + "FordAngleHighSpeedFactor", + "FordAngleHighSpeedDamping", + "FordAngleLaneChangeFactor", + ): + setting = lateral[key] + assert setting["step"] == 0.01 + assert setting["precision"] == 2 + assert setting["galaxy_only"] + + def test_hidden_feature_defaults_remain_enabled(): assert _declared_default("GalaxyDeveloperMode") == "0" assert _declared_default("NavDesiresAllowed") == "1" diff --git a/system/athena/athenad.py b/system/athena/athenad.py index 3043a62a1..04982c07d 100755 --- a/system/athena/athenad.py +++ b/system/athena/athenad.py @@ -5,21 +5,25 @@ import base64 import hashlib import io import json +import math import os import queue import random +import re import select import socket +import subprocess import sys import tempfile import threading import time +from contextlib import suppress from dataclasses import asdict, dataclass, replace from datetime import datetime from functools import partial, total_ordering from queue import Queue from typing import cast -from collections.abc import Callable +from collections.abc import Callable, Iterable import requests from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK @@ -31,15 +35,18 @@ import cereal.messaging as messaging from cereal import car, log from cereal.services import SERVICE_LIST from openpilot.common.api import Api, get_key_pair +from openpilot.common.basedir import BASEDIR from openpilot.common.utils import CallbackReader, get_upload_stream from openpilot.common.params import Params from openpilot.common.realtime import set_core_affinity from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.loggerd.config import CAMERA_FPS, SEGMENT_LENGTH from openpilot.system.loggerd.xattr_cache import getxattr, setxattr from openpilot.common.swaglog import cloudlog from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.system.version import get_build_metadata from openpilot.system.hardware.hw import Paths +from openpilot.tools.lib.helpers import RE from openpilot.starpilot.common.starpilot_utilities import use_konik_server @@ -58,6 +65,7 @@ MAX_AGE = 31 * 24 * 3600 # seconds WS_FRAME_SIZE = 4096 DEVICE_STATE_UPDATE_INTERVAL = 1.0 # in seconds DEFAULT_UPLOAD_PRIORITY = 99 # higher number = lower priority +CLIP_CHUNK_SIZE = 512 * 1024 # https://bytesolutions.com/dscp-tos-cos-precedence-conversion-chart, # https://en.wikipedia.org/wiki/Differentiated_services @@ -398,6 +406,217 @@ def listDataDirectory(prefix='') -> list[str]: return scan_dir(Paths.log_root(), prefix) +class VideoClips: + @dataclass + class Clip: + route: str + camera: str + source_start_time: float + source_end_time: float + bitrate: int + speedup: int + filename: str + requested_at: float + + def __init__(self): + self.clip_path = os.path.join(Paths.log_root(), "clips") + self.lock = threading.Condition() + self.clips: dict[str, VideoClips.Clip] = {} + self.transcode_proc: tuple[str, subprocess.Popen] | None = None + threading.Thread(target=self._worker, name="video_clip", daemon=True).start() + + def _encode(self, clip: Clip, inputs: Iterable[str], output_path: str, start_time: float, duration: float) -> None: + inputs = list(inputs) + metadata = json.dumps(asdict(clip), separators=(',', ':')) + if PC: + command = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", + "-r", str(CAMERA_FPS * clip.speedup), "-f", "concat", "-safe", "0", "-protocol_whitelist", "file,pipe", "-c:v", "hevc", + "-i", "pipe:0", "-ss", str(start_time / clip.speedup), "-t", str(duration / clip.speedup), + "-map", "0:v:0", "-an", "-r", str(CAMERA_FPS), "-c:v", "libx264", "-preset", "veryfast", + "-b:v", f"{clip.bitrate}M", "-pix_fmt", "yuv420p", "-movflags", "+faststart+use_metadata_tags", + "-metadata", f"ai.comma.clip.settings={metadata}", output_path, + ] + else: + command = [os.path.join(BASEDIR, "openpilot/system/loggerd/encoderd"), "--clip", output_path, + str(start_time), str(duration), "--bitrate", str(clip.bitrate * 1_000_000), + "--speedup", str(clip.speedup), "--metadata", metadata, "--", *inputs] + + with self.lock: + if self.clips.get(clip.filename) is not clip: + return + process = subprocess.Popen(command, stdin=subprocess.PIPE if PC else subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True) + self.transcode_proc = (clip.filename, process) + try: + if PC: + if process.stdin is None: + raise RuntimeError("ffmpeg stdin is unavailable") + process.stdin.write("ffconcat version 1.0\n") + for path in inputs: + escaped_path = path.replace("'", "'\\''") + process.stdin.write(f"file 'file:{escaped_path}'\noption framerate {CAMERA_FPS}\nduration {SEGMENT_LENGTH}\n") + process.stdin.close() + process.wait() + if process.returncode != 0: + raise RuntimeError(f"clip encoder exited with code {process.returncode}") + finally: + with suppress(OSError): + if process.stdin is not None: + process.stdin.close() + if process.poll() is None: + process.terminate() + process.wait() + with self.lock: + if self.transcode_proc is not None and self.transcode_proc[0] == clip.filename: + self.transcode_proc = None + + def _worker(self) -> None: + while True: + with self.lock: + while not self.clips: + self.lock.wait() + clip = next(iter(self.clips.values())) + temporary_path = "" + try: + with self.lock: + if self.clips.get(clip.filename) is not clip: + continue + first_segment = math.floor(clip.source_start_time / SEGMENT_LENGTH) + inputs = ( + os.path.join(Paths.log_root(), f"{clip.route}--{segment}", clip.camera) + for segment in range(first_segment, math.ceil(clip.source_end_time / SEGMENT_LENGTH)) + ) + os.makedirs(self.clip_path, exist_ok=True) + temporary_path = os.path.join(self.clip_path, f".{clip.filename}") + output_path = os.path.join(self.clip_path, clip.filename) + self._encode(clip, inputs, temporary_path, clip.source_start_time - first_segment * SEGMENT_LENGTH, + clip.source_end_time - clip.source_start_time) + with self.lock: + if self.clips.get(clip.filename) is clip: + os.replace(temporary_path, output_path) + del self.clips[clip.filename] + except Exception: + with self.lock: + failed = self.clips.get(clip.filename) is clip + if failed: + del self.clips[clip.filename] + if failed: + cloudlog.exception("athena.video_clip.failed") + finally: + with suppress(OSError): + if temporary_path: + os.unlink(temporary_path) + + def _on_disk(self) -> dict[str, dict]: + clips = {} + try: + entries = os.scandir(self.clip_path) + except FileNotFoundError: + return clips + with entries: + for entry in entries: + if entry.name.startswith(".") or not entry.is_file(): + continue + probe = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format_tags=ai.comma.clip.settings", + "-of", "json", entry.path], capture_output=True, text=True) + if probe.returncode != 0: + continue + try: + metadata = json.loads(json.loads(probe.stdout)["format"]["tags"]["ai.comma.clip.settings"]) + size = entry.stat().st_size + except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError): + continue + if not isinstance(metadata, dict) or not isinstance(metadata.get("requested_at"), (int, float)): + continue + clips[entry.name] = {**metadata, "filename": entry.name, "status": "ready", + "fn": os.path.relpath(entry.path, Paths.log_root()), "size": size} + return clips + + def _available_ranges(self, route: str) -> dict: + cameras: dict[str, list[int]] = {} + try: + with os.scandir(Paths.log_root()) as entries: + for entry in entries: + entry_route, _, segment = entry.name.rpartition("--") + if entry_route != route or not segment.isdigit() or not entry.is_dir(): + continue + with os.scandir(entry.path) as files: + for camera in files: + if camera.is_file() and camera.name.endswith("camera.hevc"): + cameras.setdefault(camera.name, []).append(int(segment)) + except OSError: + return {} + + available = {} + for camera, camera_segments in cameras.items(): + ranges: list[list[int]] = [] + for segment in sorted(camera_segments): + if ranges and ranges[-1][1] == segment * SEGMENT_LENGTH: + ranges[-1][1] += SEGMENT_LENGTH + else: + ranges.append([segment * SEGMENT_LENGTH, (segment + 1) * SEGMENT_LENGTH]) + available[camera] = {"available_ranges": ranges} + return available + + def createClip(self, route: str, source_start_time: float, source_end_time: float, clip: dict): + if not PC and not Params().get_bool("IsOffroad"): + raise RuntimeError("video clips can only be created while offroad") + route_match = re.fullmatch(RE.ROUTE_NAME, route) + assert route_match is not None, "invalid route" + route_name = route_match.group("log_id") + camera = clip["camera"] + filename = clip["filename"] + assert camera == os.path.basename(camera) and camera.endswith("camera.hevc"), "invalid camera filename" + assert filename == os.path.basename(filename), "invalid filename" + with self.lock: + self.clips[filename] = self.Clip(route_name, camera, source_start_time, source_end_time, clip["bitrate"], clip["speedup"], + filename, datetime.now().timestamp()) + self.lock.notify() + + def getClipState(self, route: str | None = None) -> dict: + route_match = re.search(RE.ROUTE_NAME, route or "") + with self.lock: + transcode_filename = self.transcode_proc[0] if self.transcode_proc is not None else None + active_clips = {clip.filename: {**asdict(clip), "status": "encoding" if clip.filename == transcode_filename else "queued"} + for clip in self.clips.values()} + clips = self._on_disk() + clips.update(active_clips) + state = {"clips": sorted(clips.values(), key=lambda clip: clip["requested_at"], reverse=True)} + if route_match is not None: + route_name = route_match.group("log_id") + state.update({"route": route_name, "cameras": self._available_ranges(route_name)}) + return state + + def deleteClip(self, filename: str) -> None: + assert filename == os.path.basename(filename), "invalid filename" + with self.lock: + self.clips.pop(filename, None) + output_path = os.path.join(self.clip_path, filename) + if self.transcode_proc is not None and self.transcode_proc[0] == filename: + self.transcode_proc[1].terminate() + if os.path.exists(output_path): + os.unlink(output_path) + + def getClipChunk(self, filename: str, offset: int) -> dict: + assert filename == os.path.basename(filename) and not filename.startswith("."), "invalid filename" + assert isinstance(offset, int) and offset >= 0, "invalid offset" + path = os.path.join(self.clip_path, filename) + size = os.path.getsize(path) + assert offset <= size, "offset past end of file" + with open(path, "rb") as f: + f.seek(offset) + data = f.read(CLIP_CHUNK_SIZE) + return {"data": base64.b64encode(data).decode(), "offset": offset, "size": size} + + +video_clips = VideoClips() +dispatcher.add_method(video_clips.createClip) +dispatcher.add_method(video_clips.getClipState) +dispatcher.add_method(video_clips.deleteClip) +dispatcher.add_method(video_clips.getClipChunk) + + @dispatcher.add_method def uploadFileToUrl(fn: str, url: str, headers: dict[str, str]) -> UploadFilesToUrlResponse: # this is because mypy doesn't understand that the decorator doesn't change the return type diff --git a/system/athena/tests/test_athenad.py b/system/athena/tests/test_athenad.py index 504d6fc56..f1df63b69 100644 --- a/system/athena/tests/test_athenad.py +++ b/system/athena/tests/test_athenad.py @@ -104,12 +104,23 @@ class TestAthenadMethods: f.write(data) return fn + @staticmethod + def _video_clips(clip): + clips = object.__new__(athenad.VideoClips) + clips.lock = threading.Condition() + clips.clips = {clip.filename: clip} + clips.transcode_proc = None + return clips + # *** test cases *** def test_echo(self): assert dispatcher["echo"]("bob") == "bob" + def test_video_clip_methods_registered(self): + assert {"createClip", "getClipState", "deleteClip", "getClipChunk"}.issubset(dispatcher) + def test_get_message(self): with pytest.raises(TimeoutError) as _: dispatcher["getMessage"]("controlsState") @@ -174,6 +185,44 @@ class TestAthenadMethods: assert resp, 'list empty!' assert len(resp) == len(expected) + def test_video_clip_hardware_encoder(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 10, 130, 2, 4, "clip.mp4", 123) + clips = self._video_clips(clip) + process = mocker.Mock(stdin=None, returncode=0) + process.poll.return_value = 0 + popen = mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", False) + + clips._encode(clip, ["segment0", "segment1"], "output.mp4", 10, 120) + + metadata = json.dumps(asdict(clip), separators=(',', ':')) + assert popen.call_args.args[0] == [ + os.path.join(athenad.BASEDIR, "openpilot/system/loggerd/encoderd"), "--clip", "output.mp4", "10", "120", + "--bitrate", "2000000", "--speedup", "4", "--metadata", metadata, "--", "segment0", "segment1", + ] + assert popen.call_args.kwargs["stdin"] == athenad.subprocess.DEVNULL + assert clips.transcode_proc is None + + def test_video_clip_software_fallback(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 10, 30, 3, 2, "clip.mp4", 123) + clips = self._video_clips(clip) + stdin = mocker.Mock() + process = mocker.Mock(stdin=stdin, returncode=0) + process.poll.return_value = 0 + popen = mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", True) + + clips._encode(clip, ["segment'0", "segment1"], "output.mp4", 10, 20) + + command = popen.call_args.args[0] + assert ["-r", "40"] == command[command.index("-r"):command.index("-r") + 2] + assert ["-ss", "5.0"] == command[command.index("-ss"):command.index("-ss") + 2] + assert ["-t", "10.0"] == command[command.index("-t"):command.index("-t") + 2] + assert ["-b:v", "3M"] == command[command.index("-b:v"):command.index("-b:v") + 2] + writes = [call.args[0] for call in stdin.write.call_args_list] + assert "file 'file:segment'\\''0'\n" in writes[1] + assert writes[-1].startswith("file 'file:segment1'") + def test_strip_extension(self): # any requested log file with an invalid extension won't return as existing fn = self._create_file('qlog.bz2') diff --git a/system/loggerd/SConscript b/system/loggerd/SConscript index 457008a4d..91eab7de9 100644 --- a/system/loggerd/SConscript +++ b/system/loggerd/SConscript @@ -5,7 +5,9 @@ libs = [common, messaging, visionipc, 'pthread', 'z', 'm', 'zstd'] src = ['logger.cc', 'zstd_writer.cc', 'video_writer.cc', 'encoder/encoder.cc', 'encoder/v4l_encoder.cc', 'encoder/jpeg_encoder.cc'] -if arch != "larch64": +if arch == "larch64": + src += ['clip_encoder.cc', 'encoder/v4l_decoder.cc'] +else: src += ['encoder/ffmpeg_encoder.cc'] libs += ['yuv'] diff --git a/system/loggerd/clip_encoder.cc b/system/loggerd/clip_encoder.cc new file mode 100644 index 000000000..2c8eb0d45 --- /dev/null +++ b/system/loggerd/clip_encoder.cc @@ -0,0 +1,270 @@ +#include "system/loggerd/clip_encoder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +#include "common/swaglog.h" +#include "system/loggerd/encoder/v4l_decoder.h" +#include "system/loggerd/encoder/v4l_encoder.h" +#include "system/loggerd/loggerd.h" +#include "system/loggerd/video_writer.h" + +namespace { + +constexpr double SEGMENT_DURATION = 60.0; +constexpr int CLIP_FPS = 20; +constexpr double PARALLEL_CLIP_MIN_DURATION = 2 * SEGMENT_DURATION; + +const EncoderInfo clip_encoder_info = { + .publish_name = "livestreamRoadEncodeData", + .record = false, + .fps = CLIP_FPS, + .get_settings = [](int) { return EncoderSettings::StreamEncoderSettings(); }, + INIT_ENCODE_FUNCTIONS(LivestreamRoadEncode), +}; + +bool open_input(const std::string &path, AVFormatContext **ctx, int *stream_index) { + if (avformat_open_input(ctx, path.c_str(), nullptr, nullptr) < 0 || + avformat_find_stream_info(*ctx, nullptr) < 0 || + (*stream_index = av_find_best_stream(*ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0)) < 0) { + LOGE("failed to open clip input %s", path.c_str()); + avformat_close_input(ctx); + return false; + } + return true; +} + +void remove_file(const std::string &path) { + std::error_code error; + std::filesystem::remove(path, error); +} + +int encode_clip_worker(const std::vector &inputs, int width, int height, + double start_time, double duration, int bitrate, int speedup, + int64_t frame_offset, int64_t *encoded_frames, + V4LEncoder::PacketCallback packet_callback) try { + EncoderInfo encoder_info = clip_encoder_info; + encoder_info.get_settings = [bitrate](int) { + return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, + .bitrate = bitrate, .gop_size = 5}; + }; + V4LDecoder decoder; + V4LEncoder::Options options = { + .packet_callback = std::move(packet_callback), + .input_format = V4L2_PIX_FMT_NV12_UBWC, + .input_done_callback = [&decoder](VisionBuf *buf) { decoder.releaseFrame(buf); }, + .max_performance = true, + }; + V4LEncoder encoder(encoder_info, width, height, std::move(options)); + encoder.encoder_open(); + if (!decoder.init(V4LDecoder::DEVICE, width, height, V4L2_PIX_FMT_HEVC, true, V4L2_PIX_FMT_NV12_UBWC)) return 1; + + const int64_t first_frame = std::floor(start_time * CLIP_FPS); + const int64_t end_frame = std::ceil((start_time + duration) * CLIP_FPS); + const int64_t source_frames = end_frame - first_frame; + const int64_t first_output_frame = (speedup - frame_offset % speedup) % speedup; + const int64_t expected_output_frames = first_output_frame < source_frames ? + 1 + (source_frames - first_output_frame - 1) / speedup : 0; + int64_t input_frame = 0; + int64_t output_frame = 0; + int64_t received_frames = 0; + bool failed = false; + auto pump_decoder = [&](int timeout_ms) { + V4LDecodedFrame frame; + if (!decoder.pump(frame, timeout_ms)) return false; + if (!frame.buf) return true; + ++received_frames; + const int64_t source_frame = (int64_t)frame.token - 1; + if (source_frame < first_frame) { + decoder.releaseFrame(frame.buf); + return true; + } + if ((frame_offset + source_frame - first_frame) % speedup != 0) { + decoder.releaseFrame(frame.buf); + return true; + } + + VisionIpcBufExtra extra = {}; + extra.frame_id = output_frame; + extra.timestamp_sof = output_frame * 1000000000ULL / CLIP_FPS; + extra.timestamp_eof = extra.timestamp_sof; + if (encoder.encode_frame(frame.buf, &extra) < 0) { + decoder.releaseFrame(frame.buf); + return false; + } + + ++output_frame; + return true; + }; + + for (const std::string &input : inputs) { + AVFormatContext *ctx = nullptr; + int stream_index = -1; + if (!open_input(input, &ctx, &stream_index)) { failed = true; break; } + AVPacket packet = {}; + while (input_frame < end_frame && av_read_frame(ctx, &packet) >= 0) { + if (packet.stream_index != stream_index) { + av_packet_unref(&packet); + continue; + } + if (packet.size <= 0 || (size_t)packet.size > decoder.maxPacketSize()) { + LOGE("decoder packet too large: %d > %zu", packet.size, decoder.maxPacketSize()); + av_packet_unref(&packet); + failed = true; + break; + } + + while (!decoder.queuePacket(&packet, input_frame + 1)) { + if (!pump_decoder(-1)) { + failed = true; + break; + } + } + av_packet_unref(&packet); + if (failed) break; + + ++input_frame; + } + av_packet_unref(&packet); + avformat_close_input(&ctx); + if (failed || input_frame >= end_frame) break; + } + + if (!failed) decoder.sendEOS(); + for (int empty_polls = 0; !failed && received_frames < input_frame;) { + const int64_t before = received_frames; + failed = !pump_decoder(1000); + empty_polls = received_frames == before ? empty_polls + 1 : 0; + if (empty_polls == 5) failed = true; + } + + encoder.encoder_close(); + if (failed || input_frame < end_frame || output_frame != expected_output_frames) { + LOGE("clip failed: input=%lld/%lld decoded=%lld encoded=%lld/%lld", + (long long)input_frame, (long long)end_frame, (long long)received_frames, + (long long)output_frame, (long long)expected_output_frames); + return 1; + } + *encoded_frames = output_frame; + return 0; +} catch (const std::exception &e) { + LOGE("clip worker failed: %s", e.what()); + return 1; +} + +struct SpoolPacket { + uint32_t size; + int64_t timestamp; + bool keyframe; +}; + +} // namespace + +int encode_clip(const std::vector &inputs, const std::string &output, + double start_time, double duration, int bitrate, int speedup, + const std::string &metadata) { + if (inputs.empty() || !std::isfinite(start_time) || !std::isfinite(duration) || + start_time < 0 || duration <= 0 || bitrate <= 0 || speedup <= 0) { + return 1; + } + + const double available_duration = inputs.size() * SEGMENT_DURATION; + if (start_time >= available_duration || duration > available_duration - start_time) return 1; + const size_t skipped_segments = start_time / SEGMENT_DURATION; + const std::vector clip_inputs(inputs.begin() + skipped_segments, inputs.end()); + const double local_start = start_time - skipped_segments * SEGMENT_DURATION; + + AVFormatContext *ctx = nullptr; + int stream = -1; + if (!open_input(clip_inputs.front(), &ctx, &stream)) return 1; + AVCodecParameters *codec = ctx->streams[stream]->codecpar; + const int width = codec->width, height = codec->height; + const bool valid_codec = codec->codec_id == AV_CODEC_ID_HEVC && width > 0 && height > 0; + avformat_close_input(&ctx); + if (!valid_codec) return 1; + + std::filesystem::path output_path(output); + const std::string output_dir = output_path.has_parent_path() ? output_path.parent_path() : "."; + VideoWriter writer(output_dir.c_str(), output_path.filename().c_str(), true, + width, height, CLIP_FPS, cereal::EncodeIndex::Type::QCAMERA_H264); + if (!metadata.empty()) writer.set_metadata("ai.comma.clip.settings", metadata.c_str()); + V4LEncoder::PacketCallback write_packet = [&writer](uint8_t *data, size_t size, int64_t timestamp, + bool config, bool keyframe) { + writer.write(data, size, timestamp, config, keyframe); + }; + + if (clip_inputs.size() < 2 || duration < PARALLEL_CLIP_MIN_DURATION) { + int64_t encoded_frames = 0; + const bool success = encode_clip_worker(clip_inputs, width, height, local_start, duration, + bitrate, speedup, 0, &encoded_frames, write_packet) == 0; + if (!success) remove_file(output); + return success ? 0 : 1; + } + + const size_t split = std::clamp(std::llround((local_start + duration / 2) / SEGMENT_DURATION), + 1, clip_inputs.size() - 1); + const double split_time = split * SEGMENT_DURATION; + const std::array, 2> shard_inputs = { + std::vector(clip_inputs.begin(), clip_inputs.begin() + split), + std::vector(clip_inputs.begin() + split, clip_inputs.end()), + }; + const std::array shard_starts = {local_start, 0}; + const std::array shard_durations = { + split_time - local_start, local_start + duration - split_time, + }; + const std::string spool_path = output + ".encoderd-" + std::to_string(getpid()) + ".tmp"; + FILE *spool = fopen(spool_path.c_str(), "w+b"); + if (!spool) { + remove_file(output); + return 1; + } + remove_file(spool_path); + bool spool_ok = true; + V4LEncoder::PacketCallback spool_packet = [&](uint8_t *data, size_t size, int64_t timestamp, + bool config, bool keyframe) { + if (config) return; + const SpoolPacket packet = {(uint32_t)size, timestamp, keyframe}; + spool_ok &= fwrite(&packet, sizeof(packet), 1, spool) == 1 && fwrite(data, 1, size, spool) == size; + }; + std::array results = {1, 1}; + std::array encoded_frames = {}; + const std::array frame_offsets = { + 0, (int64_t)std::llround(split_time * CLIP_FPS) - (int64_t)std::floor(local_start * CLIP_FPS), + }; + std::array workers; + + for (size_t i = 0; i < workers.size(); ++i) { + workers[i] = std::thread([&, i]() { + results[i] = encode_clip_worker(shard_inputs[i], width, height, shard_starts[i], shard_durations[i], + bitrate, speedup, frame_offsets[i], &encoded_frames[i], + i == 0 ? write_packet : spool_packet); + }); + } + for (std::thread &worker : workers) worker.join(); + + rewind(spool); + SpoolPacket packet; + std::vector data; + const int64_t timestamp_offset = encoded_frames[0] * 1000000 / CLIP_FPS; + while (spool_ok && fread(&packet, sizeof(packet), 1, spool) == 1) { + data.resize(packet.size); + spool_ok = fread(data.data(), 1, data.size(), spool) == data.size(); + if (spool_ok) writer.write(data.data(), data.size(), packet.timestamp + timestamp_offset, false, packet.keyframe); + } + fclose(spool); + bool success = results[0] == 0 && results[1] == 0 && spool_ok; + if (!success) remove_file(output); + return success ? 0 : 1; +} diff --git a/system/loggerd/clip_encoder.h b/system/loggerd/clip_encoder.h new file mode 100644 index 000000000..5bd01b0c6 --- /dev/null +++ b/system/loggerd/clip_encoder.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +// inputs are consecutive 60-second loggerd HEVC segments; start_time is +// relative to the beginning of the first input. +int encode_clip(const std::vector &inputs, const std::string &output, + double start_time, double duration, int bitrate = 5'000'000, + int speedup = 1, const std::string &metadata = {}); diff --git a/system/loggerd/encoder/v4l_decoder.cc b/system/loggerd/encoder/v4l_decoder.cc new file mode 100644 index 000000000..b9847bebc --- /dev/null +++ b/system/loggerd/encoder/v4l_decoder.cc @@ -0,0 +1,390 @@ +#include "system/loggerd/encoder/v4l_decoder.h" + +#include +#include +#include +#include "third_party/linux/include/v4l2-controls.h" +#include +#include +#include + + +#include "common/swaglog.h" +#include "common/util.h" + +constexpr int OFFLINE_CORE_PLACEMENT_RATE = 80 << 16; + +// echo "0xFFFF" > /sys/kernel/debug/msm_vidc/debug_level + +static void copyBuffer(VisionBuf *src_buf, VisionBuf *dst_buf) { + // Copy Y plane + memcpy(dst_buf->y, src_buf->y, src_buf->height * src_buf->stride); + // Copy UV plane + memcpy(dst_buf->uv, src_buf->uv, src_buf->height / 2 * src_buf->stride); +} + +static void request_buffers(int fd, v4l2_buf_type buf_type, unsigned int count) { + struct v4l2_requestbuffers reqbuf = { + .count = count, + .type = buf_type, + .memory = V4L2_MEMORY_USERPTR + }; + util::safe_ioctl(fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); +} + +V4LDecoder::~V4LDecoder() { + if (fd > 0) { + close(fd); + } +} + +bool V4LDecoder::init(const char* dev, size_t width, size_t height, uint64_t codec, + bool direct_mode, uint32_t capture_fourcc) { + LOG("Initializing msm_vidc device %s", dev); + this->w = width; + this->h = height; + this->direct = direct_mode; + this->capture_format = capture_fourcc; + this->fd = open(dev, O_RDWR | O_NONBLOCK, 0); + if (fd < 0) { + LOGE("failed to open video device %s", dev); + return false; + } + subscribeEvents(); + v4l2_buf_type out_type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; + setPlaneFormat(out_type, codec); // Also allocates the output buffers + setFPS(FPS); + if (direct) { + struct v4l2_control ctrls[] = { + { .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, .value = OFFLINE_CORE_PLACEMENT_RATE }, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, .value = V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE }, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL offline decode failed"); + } + } + request_buffers(fd, out_type, OUTPUT_BUFFER_COUNT); + util::safe_ioctl(fd, VIDIOC_STREAMON, &out_type, "VIDIOC_STREAMON OUTPUT failed"); + restartCapture(); + pfd = {fd, POLLIN | POLLOUT | POLLWRNORM | POLLRDNORM | POLLPRI, 0}; + + this->initialized = true; + return true; +} + +VisionBuf* V4LDecoder::decodeFrame(AVPacket *pkt, VisionBuf *buf) { + assert(initialized && !direct && pkt != nullptr && buf != nullptr); + bool queued = false; + while (true) { + if (!queued) queued = queuePacket(pkt, 0); + V4LDecodedFrame frame; + if (!pump(frame, -1)) return nullptr; + if (!frame.buf) continue; + + VisionBuf *decoded = frame.buf; + copyBuffer(decoded, buf); + releaseFrame(decoded); + return buf; + } +} + +void V4LDecoder::releaseFrame(VisionBuf *buf) { + assert(buf >= cap_bufs && buf < cap_bufs + CAPTURE_BUFFER_COUNT); + queueCaptureBuffer(buf - cap_bufs); +} + +bool V4LDecoder::queuePacket(const AVPacket *pkt, uint64_t token) { + int buf_index = getBufferUnlocked(); + return buf_index >= 0 && sendPacket(buf_index, pkt, token); +} + +bool V4LDecoder::pump(V4LDecodedFrame &frame, int timeout_ms) { + frame = {}; + int rc; + while (true) { + rc = poll(&pfd, 1, timeout_ms); + if (rc < 0) { + if (errno == EINTR) continue; + LOGE("poll() error: %d", errno); + return false; + } + break; + } + + if (rc == 0) return true; + + int result; + + while ((result = handleEvent()) > 0) {} + if (result < 0) return false; + + while ((result = handleOutput()) > 0) {} + if (result < 0) return false; + + result = handleCapture(&frame); + return result >= 0; +} + +int V4LDecoder::handleCapture(V4LDecodedFrame *frame) { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1] = {0}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.m.planes = planes; + buf.length = 1; + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQBUF, &buf)); + if (err < 0 && errno == EAGAIN) return 0; + if (err < 0) { + LOGE("VIDIOC_DQBUF CAPTURE failed: %d", errno); + return -1; + } + + const bool has_payload = buf.m.planes[0].bytesused != 0; + const bool eos = (buf.flags & V4L2_QCOM_BUF_FLAG_EOS) != 0; + + frame->buf = nullptr; + if (!reconfigure_pending && has_payload) { + frame->buf = &cap_bufs[buf.index]; + frame->token = (uint64_t)buf.timestamp.tv_sec * 1000000ULL + buf.timestamp.tv_usec; + } else if (!reconfigure_pending && !eos) { + queueCaptureBuffer(buf.index); + } + + return 1; +} + +bool V4LDecoder::subscribeEvents() { + for (uint32_t event : subscriptions) { + struct v4l2_event_subscription sub = { .type = event}; + util::safe_ioctl(fd, VIDIOC_SUBSCRIBE_EVENT, &sub, "VIDIOC_SUBSCRIBE_EVENT failed"); + } + return true; +} + +bool V4LDecoder::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { + struct v4l2_format fmt = {.type = type}; + struct v4l2_pix_format_mplane *pix = &fmt.fmt.pix_mp; + *pix = { + .width = (__u32)this->w, + .height = (__u32)this->h, + .pixelformat = fourcc + }; + util::safe_ioctl(fd, VIDIOC_S_FMT, &fmt, "VIDIOC_S_FMT failed"); + if (type == V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE) { + this->out_buf_size = pix->plane_fmt[0].sizeimage; + for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { + this->out_bufs[i].allocate(this->out_buf_size); + this->out_buf_flag[i] = false; + } + LOGD("Set output buffer size to %d, count %d, addr %p", this->out_buf_size, OUTPUT_BUFFER_COUNT, this->out_bufs[0].addr); + } else if (type == V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE) { + request_buffers(this->fd, type, CAPTURE_BUFFER_COUNT); + util::safe_ioctl(fd, VIDIOC_G_FMT, &fmt, "VIDIOC_G_FMT failed"); + const __u32 y_size = pix->plane_fmt[0].sizeimage; + const __u32 y_stride = pix->plane_fmt[0].bytesperline; + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; i++) { + size_t uv_offset = (size_t)y_stride * pix->height; + size_t required = uv_offset + (y_stride * pix->height / 2); // enough for Y + UV. For linear NV12, UV plane starts at y_stride * height. + size_t alloc_size = std::max(y_size, required); + this->cap_bufs[i].allocate(alloc_size); + this->cap_bufs[i].init_yuv(pix->width, pix->height, y_stride, uv_offset); + } + LOGD("Set capture buffer size to %d, count %d, addr %p, extradata size %d", + pix->plane_fmt[0].sizeimage, CAPTURE_BUFFER_COUNT, this->cap_bufs[0].addr, pix->plane_fmt[1].sizeimage); + } + return true; +} + +bool V4LDecoder::setFPS(uint32_t fps) { + struct v4l2_streamparm streamparam = { + .type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, + }; + streamparam.parm.output.timeperframe = {1, fps}; + util::safe_ioctl(fd, VIDIOC_S_PARM, &streamparam, "VIDIOC_S_PARM failed"); + return true; +} + +bool V4LDecoder::restartCapture() { + // stop if already initialized + enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; + if (this->initialized) { + LOGD("Restarting capture, flushing buffers..."); + util::safe_ioctl(this->fd, VIDIOC_STREAMOFF, &type, "VIDIOC_STREAMOFF CAPTURE failed"); + struct v4l2_requestbuffers reqbuf = {.type = type, .memory = V4L2_MEMORY_USERPTR}; + util::safe_ioctl(this->fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { + this->cap_bufs[i].free(); + cap_bufs[i].~VisionBuf(); + new (&cap_bufs[i]) VisionBuf(); + } + } + // setup, start and queue capture buffers + setDBP(); + setPlaneFormat(type, capture_format); + if (direct) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, + .value = OFFLINE_CORE_PLACEMENT_RATE, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL placement decode failed"); + } + util::safe_ioctl(this->fd, VIDIOC_STREAMON, &type, "VIDIOC_STREAMON CAPTURE failed"); + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { + queueCaptureBuffer(i); + } + if (direct) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, + .value = INT_MAX, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL turbo decode failed"); + } + + return true; +} + +bool V4LDecoder::queueCaptureBuffer(int i) { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1] = {0}; + + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.index = i; + buf.m.planes = planes; + buf.length = 1; + // decoded frame plane + planes[0].m.userptr = (unsigned long)this->cap_bufs[i].addr; // no security + planes[0].length = this->cap_bufs[i].len; + planes[0].reserved[0] = this->cap_bufs[i].fd; // ION fd + planes[0].reserved[1] = 0; + planes[0].bytesused = this->cap_bufs[i].len; + planes[0].data_offset = 0; + util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); + return true; +} + +bool V4LDecoder::queueOutputBuffer(int i, size_t size, uint64_t token) { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1] = {0}; + + buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.index = i; + buf.flags = V4L2_BUF_FLAG_TIMESTAMP_COPY; + buf.timestamp.tv_sec = token / 1000000ULL; + buf.timestamp.tv_usec = token % 1000000ULL; + buf.m.planes = planes; + buf.length = 1; + // decoded frame plane + planes[0].m.userptr = (unsigned long)this->out_bufs[i].addr; + planes[0].length = this->out_buf_size; + planes[0].reserved[0] = this->out_bufs[i].fd; // ION fd + planes[0].reserved[1] = 0; + planes[0].bytesused = size; + planes[0].data_offset = 0; + assert(this->out_buf_size % 4096 == 0); // ditto for size + + util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); + this->out_buf_flag[i] = true; // mark as queued + return true; +} + +bool V4LDecoder::setDBP() { + struct v4l2_ext_control control[2] = {0}; + struct v4l2_ext_controls controls = {0}; + control[0].id = V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE; + control[0].value = 1; // V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_SECONDARY + control[1].id = V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT; + control[1].value = 0; // V4L2_MPEG_VIDC_VIDEO_DPB_COLOR_FMT_NONE + controls.count = 2; + controls.ctrl_class = V4L2_CTRL_CLASS_MPEG; + controls.controls = control; + util::safe_ioctl(fd, VIDIOC_S_EXT_CTRLS, &controls, "VIDIOC_S_EXT_CTRLS failed"); + return true; +} + +bool V4LDecoder::sendPacket(int buf_index, const AVPacket *pkt, uint64_t token) { + assert(buf_index >= 0 && buf_index < OUTPUT_BUFFER_COUNT); + assert(pkt != nullptr && pkt->data != nullptr && pkt->size > 0); + assert((size_t)pkt->size <= (size_t)this->out_buf_size); + // Prepare output buffer + uint8_t * data = (uint8_t *)this->out_bufs[buf_index].addr; + memcpy(data, pkt->data, pkt->size); + queueOutputBuffer(buf_index, pkt->size, token); + return true; +} + +int V4LDecoder::getBufferUnlocked() { + for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { + if (!out_buf_flag[i]) { + return i; + } + } + return -1; +} + + +int V4LDecoder::handleOutput() { + struct v4l2_buffer buf = {0}; + struct v4l2_plane planes[1]; + buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; + buf.memory = V4L2_MEMORY_USERPTR; + buf.m.planes = planes; + buf.length = 1; + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQBUF, &buf)); + if (err < 0 && errno == EAGAIN) return 0; + if (err < 0) { + LOGE("VIDIOC_DQBUF OUTPUT failed: %d", errno); + return -1; + } + this->out_buf_flag[buf.index] = false; // mark as not queued + return 1; +} + +int V4LDecoder::handleEvent() { + // dequeue event + struct v4l2_event event = {0}; + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQEVENT, &event)); + if (err < 0 && (errno == EAGAIN || errno == ENOENT)) return 0; + if (err < 0) { + LOGE("VIDIOC_DQEVENT failed: %d", errno); + return -1; + } + switch (event.type) { + case V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT: { + unsigned int *ptr = (unsigned int *)event.u.data; + unsigned int height = ptr[0]; + unsigned int width = ptr[1]; + this->w = width; + this->h = height; + LOGD("Port Reconfig received insufficient, new size %ux%u, flushing capture bufs...", width, height); // This is normal + struct v4l2_decoder_cmd dec; + dec.flags = V4L2_QCOM_CMD_FLUSH_CAPTURE; + dec.cmd = V4L2_QCOM_CMD_FLUSH; + util::safe_ioctl(this->fd, VIDIOC_DECODER_CMD, &dec, "VIDIOC_DECODER_CMD FLUSH_CAPTURE failed"); + this->reconfigure_pending = true; + LOGD("Waiting for flush done event to reconfigure capture queue"); + break; + } + + case V4L2_EVENT_MSM_VIDC_FLUSH_DONE: { + unsigned int *ptr = (unsigned int *)event.u.data; + unsigned int flags = ptr[0]; + if (flags & V4L2_QCOM_CMD_FLUSH_CAPTURE) { + if (this->reconfigure_pending) { + this->restartCapture(); + this->reconfigure_pending = false; + } + } + break; + } + default: + break; + } + return 1; +} + +void V4LDecoder::sendEOS() { + struct v4l2_decoder_cmd command = { .cmd = V4L2_DEC_CMD_STOP }; + util::safe_ioctl(fd, VIDIOC_DECODER_CMD, &command, "VIDIOC_DECODER_CMD STOP failed"); +} diff --git a/system/loggerd/encoder/v4l_decoder.h b/system/loggerd/encoder/v4l_decoder.h new file mode 100644 index 000000000..c7a96803e --- /dev/null +++ b/system/loggerd/encoder/v4l_decoder.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +#include "msgq/visionipc/visionbuf.h" + +extern "C" { + #include + #include +} + +#define V4L2_EVENT_MSM_VIDC_START (V4L2_EVENT_PRIVATE_START + 0x00001000) +#define V4L2_EVENT_MSM_VIDC_FLUSH_DONE (V4L2_EVENT_MSM_VIDC_START + 1) +#define V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT (V4L2_EVENT_MSM_VIDC_START + 3) +#ifndef V4L2_CID_MPEG_MSM_VIDC_BASE +#define V4L2_CID_MPEG_MSM_VIDC_BASE 0x00992000 +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT +#define V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT (V4L2_CID_MPEG_MSM_VIDC_BASE + 44) +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE +#define V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE (V4L2_CID_MPEG_MSM_VIDC_BASE + 22) +#endif +#ifndef V4L2_PIX_FMT_NV12_UBWC +#define V4L2_PIX_FMT_NV12_UBWC v4l2_fourcc('Q', '1', '2', '8') +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY +#define V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY (V4L2_CID_MPEG_MSM_VIDC_BASE + 52) +#define V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE 0 +#define V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_DISABLE 1 +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE +#define V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE (V4L2_CID_MPEG_MSM_VIDC_BASE + 53) +#endif +#define V4L2_QCOM_CMD_FLUSH_CAPTURE (1 << 1) +#define V4L2_QCOM_CMD_FLUSH (4) +#ifndef V4L2_QCOM_BUF_FLAG_EOS +#define V4L2_QCOM_BUF_FLAG_EOS 0x02000000 +#endif + +#define OUTPUT_BUFFER_COUNT 8 +#define CAPTURE_BUFFER_COUNT 16 +#define FPS 20 + +struct V4LDecodedFrame { + VisionBuf *buf = nullptr; + uint64_t token = 0; +}; + +class V4LDecoder { +public: + static constexpr const char *DEVICE = "/dev/video32"; + + V4LDecoder() = default; + ~V4LDecoder(); + + bool init(const char* dev, size_t width, size_t height, uint64_t codec, + bool direct_mode = false, uint32_t capture_fourcc = V4L2_PIX_FMT_NV12); + VisionBuf* decodeFrame(AVPacket* pkt, VisionBuf* buf); + // queuePacket() and pump() are single-threaded. releaseFrame() may be called + // from a consumer thread after a direct capture surface is no longer needed. + bool queuePacket(const AVPacket *pkt, uint64_t token); + bool pump(V4LDecodedFrame &frame, int timeout_ms); + void releaseFrame(VisionBuf *buf); + void sendEOS(); + size_t maxPacketSize() const { return out_buf_size; } + + AVFormatContext* avctx = nullptr; + int fd = 0; + +private: + bool initialized = false; + bool reconfigure_pending = false; + bool direct = false; + uint32_t capture_format = V4L2_PIX_FMT_NV12; + + VisionBuf out_bufs[OUTPUT_BUFFER_COUNT]; // Distinct dma-buf per in-flight packet + VisionBuf cap_bufs[CAPTURE_BUFFER_COUNT]; // Capture (output) buffers + + size_t w = 0, h = 0; + int out_buf_size = 0; + + bool out_buf_flag[OUTPUT_BUFFER_COUNT] = {false}; + + const int subscriptions[2] = { + V4L2_EVENT_MSM_VIDC_FLUSH_DONE, + V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT + }; + + struct pollfd pfd = {}; + + bool subscribeEvents(); + bool setPlaneFormat(v4l2_buf_type type, uint32_t fourcc); + bool setFPS(uint32_t fps); + bool restartCapture(); + bool queueCaptureBuffer(int i); + bool queueOutputBuffer(int i, size_t size, uint64_t token); + bool setDBP(); + bool sendPacket(int buf_index, const AVPacket* pkt, uint64_t token); + int getBufferUnlocked(); + int handleCapture(V4LDecodedFrame *frame); + int handleOutput(); + int handleEvent(); +}; diff --git a/system/loggerd/encoder/v4l_encoder.cc b/system/loggerd/encoder/v4l_encoder.cc index 99666391c..acab8e515 100644 --- a/system/loggerd/encoder/v4l_encoder.cc +++ b/system/loggerd/encoder/v4l_encoder.cc @@ -2,6 +2,7 @@ #include #include #include +#include #include "system/loggerd/encoder/v4l_encoder.h" #include "common/util.h" @@ -119,12 +120,17 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } else if (flags & V4L2_QCOM_BUF_FLAG_CODECCONFIG) { // save header header = kj::heapArray(buf, bytesused); + if (e->packet_callback) e->packet_callback(header.begin(), header.size(), ts, true, false); } else { VisionIpcBufExtra extra = e->extras.pop(); assert(extra.timestamp_eof/1000 == ts); // stay in sync frame_id = extra.frame_id; ++idx; - e->publisher_publish(e->segment_num, idx, extra, flags, header, kj::arrayPtr(buf, bytesused)); + if (e->packet_callback) { + e->packet_callback(buf, bytesused, ts, false, flags & V4L2_BUF_FLAG_KEYFRAME); + } else { + e->publisher_publish(e->segment_num, idx, extra, flags, header, kj::arrayPtr(buf, bytesused)); + } } if (env_debug_encoder) { @@ -139,13 +145,19 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { if (pfd.revents & POLLOUT) { unsigned int index; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, &index); + VisionBuf *input_buf = e->input_bufs[index].exchange(nullptr); + if (input_buf && e->input_done_callback) e->input_done_callback(input_buf); e->free_buf_in.push(index); } } } V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height) - : VideoEncoder(encoder_info, in_width, in_height) { + : V4LEncoder(encoder_info, in_width, in_height, Options{}) {} + +V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height, Options options) + : VideoEncoder(encoder_info, in_width, in_height), packet_callback(std::move(options.packet_callback)), + input_done_callback(std::move(options.input_done_callback)) { fd = HANDLE_EINTR(open("/dev/v4l/by-path/platform-aa00000.qcom_vidc-video-index1", O_RDWR|O_NONBLOCK)); assert(fd >= 0); @@ -194,7 +206,7 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei .pix_mp = { .width = (unsigned int)in_width, .height = (unsigned int)in_height, - .pixelformat = V4L2_PIX_FMT_NV12, + .pixelformat = options.input_format, .field = V4L2_FIELD_ANY, .colorspace = V4L2_COLORSPACE_470_SYSTEM_BG, } @@ -221,6 +233,13 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); } } + if (options.max_performance) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, + .value = V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL offline encode failed"); + } if (is_h265) { struct v4l2_control ctrls[] = { @@ -282,6 +301,7 @@ int V4LEncoder::encode_frame(VisionBuf* buf, VisionIpcBufExtra *extra) { // reserve buffer int buffer_in = free_buf_in.pop(); + input_bufs[buffer_in].store(buf); // push buffer extras.push(*extra); diff --git a/system/loggerd/encoder/v4l_encoder.h b/system/loggerd/encoder/v4l_encoder.h index b1a84e654..0dfb3c27f 100644 --- a/system/loggerd/encoder/v4l_encoder.h +++ b/system/loggerd/encoder/v4l_encoder.h @@ -1,14 +1,27 @@ #pragma once +#include +#include + #include "common/queue.h" #include "system/loggerd/encoder/encoder.h" -#define BUF_IN_COUNT 7 +#define BUF_IN_COUNT 9 #define BUF_OUT_COUNT 6 class V4LEncoder : public VideoEncoder { public: + using PacketCallback = std::function; + using InputDoneCallback = std::function; + struct Options { + PacketCallback packet_callback; + uint32_t input_format = V4L2_PIX_FMT_NV12; + InputDoneCallback input_done_callback; + bool max_performance = false; + }; + V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height); + V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height, Options options); ~V4LEncoder(); int encode_frame(VisionBuf* buf, VisionIpcBufExtra *extra); void encoder_open(); @@ -25,10 +38,13 @@ private: int current_bitrate = -1; SafeQueue extras; + PacketCallback packet_callback; + InputDoneCallback input_done_callback; static void dequeue_handler(V4LEncoder *e); std::thread dequeue_handler_thread; VisionBuf buf_out[BUF_OUT_COUNT]; + std::atomic input_bufs[BUF_IN_COUNT] = {}; SafeQueue free_buf_in; }; diff --git a/system/loggerd/encoderd.cc b/system/loggerd/encoderd.cc index 5ca794dd5..5b044580f 100644 --- a/system/loggerd/encoderd.cc +++ b/system/loggerd/encoderd.cc @@ -1,5 +1,12 @@ #include +#ifdef __TICI__ +#include +#include +#endif +#ifdef __TICI__ +#include "system/loggerd/clip_encoder.h" +#endif #include "system/loggerd/loggerd.h" #include "system/loggerd/encoder/jpeg_encoder.h" @@ -170,6 +177,37 @@ void encoderd_thread(const LogCameraInfo (&cameras)[N]) { } int main(int argc, char* argv[]) { +#ifdef __TICI__ + if (argc > 1 && std::string(argv[1]) == "--clip") { + if (argc < 6) { + fprintf(stderr, "usage: encoderd --clip OUTPUT START DURATION [--bitrate BPS] [--speedup N] " + "[--metadata JSON] SEGMENT [SEGMENT ...]\n"); + return 2; + } + try { + int bitrate = 5'000'000; + int speedup = 1; + std::string metadata; + int input_arg = 5; + while (input_arg < argc && std::string(argv[input_arg]).rfind("--", 0) == 0) { + const std::string option = argv[input_arg++]; + if (option == "--") break; + if (input_arg == argc) throw std::invalid_argument("missing clip option value"); + if (option == "--bitrate") bitrate = std::stoi(argv[input_arg++]); + else if (option == "--speedup") speedup = std::stoi(argv[input_arg++]); + else if (option == "--metadata") metadata = argv[input_arg++]; + else throw std::invalid_argument("unknown clip option: " + option); + } + if (input_arg == argc) throw std::invalid_argument("missing clip input"); + std::vector inputs(argv + input_arg, argv + argc); + return encode_clip(inputs, argv[2], std::stod(argv[3]), std::stod(argv[4]), + bitrate, speedup, metadata); + } catch (const std::exception &e) { + fprintf(stderr, "clip encoding failed: %s\n", e.what()); + return 1; + } + } +#endif if (!Hardware::PC()) { int ret; ret = util::set_realtime_priority(52); diff --git a/system/loggerd/video_writer.cc b/system/loggerd/video_writer.cc index 5ed439257..f37cbac21 100644 --- a/system/loggerd/video_writer.cc +++ b/system/loggerd/video_writer.cc @@ -50,6 +50,11 @@ VideoWriter::VideoWriter(const char *path, const char *filename, bool remuxing, } } +void VideoWriter::set_metadata(const char *key, const char *value) { + assert(remuxing && !header_written); + av_dict_set(&ofmt_ctx->metadata, key, value, 0); +} + void VideoWriter::initialize_audio(int sample_rate) { assert(this->ofmt_ctx->oformat->audio_codec != AV_CODEC_ID_NONE); // check output format supports audio streams const AVCodec *audio_avcodec = avcodec_find_encoder(AV_CODEC_ID_AAC); @@ -110,7 +115,10 @@ void VideoWriter::write(uint8_t *data, int len, long long timestamp, bool codecc int err = avcodec_parameters_from_context(out_stream->codecpar, codec_ctx); assert(err >= 0); // if there is an audio stream, it must be initialized before this point - err = avformat_write_header(ofmt_ctx, NULL); + AVDictionary *options = nullptr; + if (ofmt_ctx->metadata) av_dict_set(&options, "movflags", "+faststart+use_metadata_tags", 0); + err = avformat_write_header(ofmt_ctx, &options); + av_dict_free(&options); assert(err >= 0); header_written = true; } else { diff --git a/system/loggerd/video_writer.h b/system/loggerd/video_writer.h index e973c5d81..a726aee2a 100644 --- a/system/loggerd/video_writer.h +++ b/system/loggerd/video_writer.h @@ -13,6 +13,7 @@ extern "C" { class VideoWriter { public: VideoWriter(const char *path, const char *filename, bool remuxing, int width, int height, int fps, cereal::EncodeIndex::Type codec); + void set_metadata(const char *key, const char *value); void write(uint8_t *data, int len, long long timestamp, bool codecconfig, bool keyframe); void write_audio(uint8_t *data, int len, long long timestamp, int sample_rate); diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 4ea1879d3..907eaf73a 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -32,20 +32,23 @@ def ublox_available() -> bool: return os.path.exists('/dev/ttyHS0') and not os.path.exists('/persist/comma/use-quectel-gps') -def update_car_gps_param(params: Params, CP: car.CarParams) -> bool: - available = car_gps_available(CP) +def update_car_gps_param(params: Params) -> bool | None: + car_params = params.get("CarParams") + if car_params is None: + return None + + with car.CarParams.from_bytes(car_params) as CP: + available = car_gps_available(CP) if available != params.get_bool("CarGpsAvailable"): params.put_bool("CarGpsAvailable", available) return available def ublox(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: - car_gps = update_car_gps_param(params, CP) + car_gps = update_car_gps_param(params) use_ublox = ublox_available() if use_ublox != params.get_bool("UbloxAvailable"): params.put_bool("UbloxAvailable", use_ublox) - # The Mach-E's CAN GPS is the preferred external source for now. Do not let - # the no-fix comma GNSS publisher race it on gpsLocationExternal. - return started and use_ublox and not car_gps + return started and use_ublox and car_gps is False def joystick(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: return started and params.get_bool("JoystickDebugMode") @@ -63,7 +66,7 @@ def not_long_maneuver(started: bool, params: Params, CP: car.CarParams, starpilo return started and not params.get_bool("LongitudinalManeuverMode") def qcomgps(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: - update_car_gps_param(params, CP) + update_car_gps_param(params) return started and not ublox_available() def always_run(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: diff --git a/system/manager/test/test_process_config.py b/system/manager/test/test_process_config.py index 617706cb2..ec6f6106e 100644 --- a/system/manager/test/test_process_config.py +++ b/system/manager/test/test_process_config.py @@ -3,7 +3,8 @@ from types import SimpleNamespace import pytest from cereal import car -from openpilot.system.manager.process_config import allow_uploads, camera_run, managed_processes, sentry_mode +from opendbc.car.ford.values import CAR as FORD_CAR +from openpilot.system.manager.process_config import allow_uploads, camera_run, managed_processes, sentry_mode, ublox class FakeParams: @@ -71,3 +72,43 @@ class SentryParams: @pytest.mark.parametrize("started,enabled,expected", [(True, True, False), (False, True, True), (False, False, False)]) def test_sentry_process_is_offroad_only(started, enabled, expected): assert sentry_mode(started, SentryParams(enabled), car.CarParams.new_message(), SimpleNamespace()) is expected + + +class GpsParams: + def __init__(self, CP=None): + self.values = {} + if CP is not None: + self.values["CarParams"] = CP.to_bytes() + + def get(self, key: str): + return self.values.get(key) + + def get_bool(self, key: str) -> bool: + return bool(self.values.get(key, False)) + + def put_bool(self, key: str, value: bool): + self.values[key] = value + + +def test_ublox_waits_for_current_carparams(monkeypatch): + monkeypatch.setattr("openpilot.system.manager.process_config.ublox_available", lambda: True) + params = GpsParams() + + assert not ublox(True, params, car.CarParams.new_message(), SimpleNamespace()) + assert params.get_bool("UbloxAvailable") + + +@pytest.mark.parametrize("car_gps,expected", [(False, True), (True, False)]) +def test_ublox_has_single_external_gps_publisher(monkeypatch, car_gps, expected): + monkeypatch.setattr("openpilot.system.manager.process_config.ublox_available", lambda: True) + CP = car.CarParams.new_message() + if car_gps: + CP.brand = "ford" + CP.carFingerprint = FORD_CAR.FORD_MUSTANG_MACH_E_MK1 + else: + CP.brand = "mock" + CP.carFingerprint = "mock" + params = GpsParams(CP) + + assert ublox(True, params, car.CarParams.new_message(), SimpleNamespace()) is expected + assert params.get_bool("CarGpsAvailable") is car_gps diff --git a/tools/StarPilot/feasibleparams.txt b/tools/StarPilot/feasibleparams.txt index e80425930..b380cac4c 100644 --- a/tools/StarPilot/feasibleparams.txt +++ b/tools/StarPilot/feasibleparams.txt @@ -353,6 +353,7 @@ StoppingDecelRate StoppingDecelRateStock SubaruSNG SubaruSNGManualParkingBrake +SubaruStopStartOff SwitchbackModeCooldown SwitchbackModeEnabled TacoTune