Repository: sunnypilot/opendbc
Pull request #126: Toyota: SecOC Longitudinal Control
Author: login: chrispypatt
Labels: name: enhancement
description: New feature or request
Created: 2025-04-14T21:20:21Z | Merged: 2025-04-15T03:56:32Z

This PR aims to add SecOC longitudinal control to master-new. It follows a similar modularized pattern as the https://github.com/sunnypilot/opendbc/pull/28 to minimize merge conflicts with the OP master.

## Summary by Sourcery

Add SecOC longitudinal control support for Toyota vehicles, introducing a new mechanism for handling acceleration commands on SecOC-enabled vehicles

New Features:
- Implement SecOC longitudinal control for Toyota vehicles with support for alternative acceleration message transmission

Enhancements:
- Modify Toyota safety and car controller to handle SecOC-specific longitudinal control requirements
- Add support for additional CAN message (0x183) for acceleration commands on SecOC vehicles

Tests:
- Add comprehensive test cases for SecOC longitudinal control
- Implement test coverage for acceleration command handling and message transmission


Diff:
diff --git a/opendbc/car/toyota/carcontroller.py b/opendbc/car/toyota/carcontroller.py
index 78249cc4ed4..061999c45cc 100644
--- a/opendbc/car/toyota/carcontroller.py
+++ b/opendbc/car/toyota/carcontroller.py
@@ -14,6 +14,8 @@
                                         UNSUPPORTED_DSU_CAR
 from opendbc.can.packer import CANPacker
 
+from opendbc.sunnypilot.car.toyota.secoc_long import SecOCLongCarController
+
 Ecu = structs.CarParams.Ecu
 LongCtrlState = structs.CarControl.Actuators.LongControlState
 SteerControlType = structs.CarParams.SteerControlType
@@ -47,9 +49,10 @@ def get_long_tune(CP, params):
                        rate=1 / (DT_CTRL * 3))
 
 
-class CarController(CarControllerBase):
+class CarController(CarControllerBase, SecOCLongCarController):
   def __init__(self, dbc_names, CP, CP_SP):
     super().__init__(dbc_names, CP, CP_SP)
+    SecOCLongCarController.__init__(self, CP)
     self.params = CarControllerParams(self.CP)
     self.last_torque = 0
     self.last_angle = 0
@@ -88,6 +91,8 @@ def update(self, CC, CC_SP, CS, now_nanos):
     # *** control msgs ***
     can_sends = []
 
+    SecOCLongCarController.update(self, CS, can_sends, self.secoc_prev_reset_counter)
+
     # *** handle secoc reset counter increase ***
     if self.CP.flags & ToyotaFlags.SECOC.value:
       if CS.secoc_synchronization['RESET_CNT'] != self.secoc_prev_reset_counter:
@@ -238,7 +243,7 @@ def update(self, CC, CC_SP, CS, now_nanos):
         pcm_accel_cmd = float(np.clip(pcm_accel_cmd, self.params.ACCEL_MIN, self.params.ACCEL_MAX))
 
         can_sends.append(toyotacan.create_accel_command(self.packer, pcm_accel_cmd, pcm_cancel_cmd, self.permit_braking, self.standstill_req, lead,
-                                                        CS.acc_type, fcw_alert, self.distance_button))
+                                                        CS.acc_type, fcw_alert, self.distance_button, self.SECOC_LONG))
         self.accel = pcm_accel_cmd
 
     else:
@@ -247,7 +252,8 @@ def update(self, CC, CC_SP, CS, now_nanos):
         if self.CP.carFingerprint in UNSUPPORTED_DSU_CAR:
           can_sends.append(toyotacan.create_acc_cancel_command(self.packer))
         else:
-          can_sends.append(toyotacan.create_accel_command(self.packer, 0, pcm_cancel_cmd, True, False, lead, CS.acc_type, False, self.distance_button))
+          can_sends.append(toyotacan.create_accel_command(self.packer, 0, pcm_cancel_cmd, True, False, lead, CS.acc_type, False, self.distance_button,
+                                                          self.SECOC_LONG))
 
     # *** hud ui ***
     if self.CP.carFingerprint != CAR.TOYOTA_PRIUS_V:
diff --git a/opendbc/car/toyota/interface.py b/opendbc/car/toyota/interface.py
index 0874300adc7..e0491f01e02 100644
--- a/opendbc/car/toyota/interface.py
+++ b/opendbc/car/toyota/interface.py
@@ -124,12 +124,9 @@ def _get_params(ret: structs.CarParams, candidate, fingerprint, car_fw, experime
     # openpilot longitudinal behind experimental long toggle:
     #  - TSS2 radar ACC cars (disables radar)
 
-    if ret.flags & ToyotaFlags.SECOC.value:
-      ret.openpilotLongitudinalControl = False
-    else:
-      ret.openpilotLongitudinalControl = ret.enableDsu or \
-        candidate in (TSS2_CAR - RADAR_ACC_CAR) or \
-        bool(ret.flags & ToyotaFlags.DISABLE_RADAR.value)
+    ret.openpilotLongitudinalControl = ret.enableDsu or \
+      candidate in (TSS2_CAR - RADAR_ACC_CAR) or \
+      bool(ret.flags & ToyotaFlags.DISABLE_RADAR.value)
 
     ret.autoResumeSng = ret.openpilotLongitudinalControl and candidate in NO_STOP_TIMER_CAR
 
diff --git a/opendbc/car/toyota/toyotacan.py b/opendbc/car/toyota/toyotacan.py
index c4f27510d68..69f22148e37 100644
--- a/opendbc/car/toyota/toyotacan.py
+++ b/opendbc/car/toyota/toyotacan.py
@@ -1,4 +1,5 @@
 from opendbc.car.structs import CarParams
+from opendbc.sunnypilot.car.toyota.secoc_long import SecOCLong
 
 SteerControlType = CarParams.SteerControlType
 
@@ -40,7 +41,7 @@ def create_lta_steer_command_2(packer, frame):
   return packer.make_can_msg("STEERING_LTA_2", 0, values)
 
 
-def create_accel_command(packer, accel, pcm_cancel, permit_braking, standstill_req, lead, acc_type, fcw_alert, distance):
+def create_accel_command(packer, accel, pcm_cancel, permit_braking, standstill_req, lead, acc_type, fcw_alert, distance, SECOC_LONG: SecOCLong = None):
   # TODO: find the exact canceling bit that does not create a chime
   values = {
     "ACCEL_CMD": accel,
@@ -53,6 +54,9 @@ def create_accel_command(packer, accel, pcm_cancel, permit_braking, standstill_r
     "ALLOW_LONG_PRESS": 1,
     "ACC_CUT_IN": fcw_alert,  # only shown when ACC enabled
   }
+
+  SECOC_LONG.update_accel_command(packer, values)
+
   return packer.make_can_msg("ACC_CONTROL", 0, values)
 
 
diff --git a/opendbc/safety/safety/safety_toyota.h b/opendbc/safety/safety/safety_toyota.h
index ef209ab234a..e25a143fb68 100644
--- a/opendbc/safety/safety/safety_toyota.h
+++ b/opendbc/safety/safety/safety_toyota.h
@@ -22,7 +22,12 @@
   {0x128, 1, 6, false}, {0x141, 1, 4, false}, {0x160, 1, 8, false}, {0x161, 1, 7, false}, {0x470, 1, 4, false},  /* DSU bus 1 */                                                                    \
   {0x411, 0, 8, false},  /* PCS_HUD */                                                                                                                                                              \
   {0x750, 0, 8, false},  /* radar diagnostic address */                                                                                                                                             \
-  {0x343, 0, 8, true},  /* ACC */                                                                                                                                                                   \
+  {0x343, 0, 8, true},  /* ACC */
+
+#define TOYOTA_COMMON_SECOC_LONG_TX_MSGS \
+  TOYOTA_COMMON_SECOC_TX_MSGS \
+  {0x343, 0, 8, true},  /* ACC */ \
+  {0x183, 0, 8, true},  /* ACC_CONTROL_2 */  \
 
 #define TOYOTA_COMMON_RX_CHECKS(lta)                                                                          \
   {.msg = {{ 0xaa, 0, 8, .ignore_checksum = true, .ignore_counter = true, .frequency = 83U}, { 0 }, { 0 }}},  \
@@ -81,6 +86,13 @@ static bool toyota_get_quality_flag_valid(const CANPacket_t *to_push) {
   return valid;
 }
 
+static int toyota_get_longitudinal_desired_accel_tx(const CANPacket_t *to_send) {
+  int desired_accel = (GET_BYTE(to_send, 0) << 8) | GET_BYTE(to_send, 1);
+  desired_accel = to_signed(desired_accel, 16);
+
+  return desired_accel;
+}
+
 static void toyota_rx_hook(const CANPacket_t *to_push) {
   if (GET_BUS(to_push) == 0U) {
     int addr = GET_ADDR(to_push);
@@ -216,8 +228,7 @@ static bool toyota_tx_hook(const CANPacket_t *to_send) {
   if (bus == 0) {
     // ACCEL: safety check on byte 1-2
     if (addr == 0x343) {
-      int desired_accel = (GET_BYTE(to_send, 0) << 8) | GET_BYTE(to_send, 1);
-      desired_accel = to_signed(desired_accel, 16);
+      int desired_accel = toyota_get_longitudinal_desired_accel_tx(to_send);
 
       bool violation = false;
       violation |= longitudinal_accel_checks(desired_accel, TOYOTA_LONG_LIMITS);
@@ -228,6 +239,10 @@ static bool toyota_tx_hook(const CANPacket_t *to_send) {
         if (!cancel_req) {
           violation = true;
         }
+      }
+
+      // block ACC messages when openpilot is not controlling longitudinal or is a SecOC car
+      if (toyota_stock_longitudinal || toyota_secoc) {
         if (desired_accel != TOYOTA_LONG_LIMITS.inactive_accel) {
           violation = true;
         }
@@ -238,6 +253,18 @@ static bool toyota_tx_hook(const CANPacket_t *to_send) {
       }
     }
 
+    // ACCEL: safety check on byte 1-2 for SecOC car
+    if (addr == 0x183) {
+      int desired_accel = toyota_get_longitudinal_desired_accel_tx(to_send);
+
+      bool violation = false;
+      violation |= longitudinal_accel_checks(desired_accel, TOYOTA_LONG_LIMITS);
+
+      if (violation) {
+        tx = false;
+      }
+    }
+
     // AEB: block all actuation. only used when DSU is unplugged
     if (addr == 0x283) {
       // only allow the checksum, which is the last byte
@@ -352,6 +379,10 @@ static safety_config toyota_init(uint16_t param) {
     TOYOTA_COMMON_LONG_TX_MSGS
   };
 
+  static const CanMsg TOYOTA_SECOC_LONG_TX_MSGS[] = {
+    TOYOTA_COMMON_SECOC_LONG_TX_MSGS
+  };
+
   // safety param flags
   // first byte is for EPS factor, second is for flags
   const uint32_t TOYOTA_PARAM_OFFSET = 8U;
@@ -375,14 +406,18 @@ static safety_config toyota_init(uint16_t param) {
   const bool toyota_unsupported_dsu = GET_FLAG(current_safety_param_sp, TOYOTA_PARAM_SP_UNSUPPORTED_DSU);
 
   safety_config ret;
-  if (toyota_stock_longitudinal) {
-    if (toyota_secoc) {
+  if (toyota_secoc) {
+    if (toyota_stock_longitudinal) {
       SET_TX_MSGS(TOYOTA_SECOC_TX_MSGS, ret);
     } else {
-      SET_TX_MSGS(TOYOTA_TX_MSGS, ret);
+      SET_TX_MSGS(TOYOTA_SECOC_LONG_TX_MSGS, ret);
     }
   } else {
-    SET_TX_MSGS(TOYOTA_LONG_TX_MSGS, ret);
+    if (toyota_stock_longitudinal) {
+      SET_TX_MSGS(TOYOTA_TX_MSGS, ret);
+    } else {
+      SET_TX_MSGS(TOYOTA_LONG_TX_MSGS, ret);
+    }
   }
 
   if (toyota_secoc) {
@@ -442,10 +477,12 @@ static bool toyota_fwd_hook(int bus_num, int addr) {
     // block stock lkas messages and stock acc messages (if OP is doing ACC)
     // in TSS2, 0x191 is LTA which we need to block to avoid controls collision
     bool is_lkas_msg = ((addr == 0x2E4) || (addr == 0x412) || (addr == 0x191));
-    // on SecOC cars 0x131 is also LTA
-    is_lkas_msg |= toyota_secoc && (addr == 0x131);
     // in TSS2 the camera does ACC as well, so filter 0x343
     bool is_acc_msg = (addr == 0x343);
+
+    // SecOC cars use additional (not alternate) messages for lateral and longitudinal actuation
+    is_lkas_msg |= toyota_secoc && (addr == 0x131);
+    is_acc_msg |= toyota_secoc && (addr == 0x183);
     block_msg = is_lkas_msg || (is_acc_msg && !toyota_stock_longitudinal);
   }
 
diff --git a/opendbc/safety/tests/test_toyota.py b/opendbc/safety/tests/test_toyota.py
index 6cab3c468d1..050de6aad6d 100755
--- a/opendbc/safety/tests/test_toyota.py
+++ b/opendbc/safety/tests/test_toyota.py
@@ -8,12 +8,13 @@
 from opendbc.car.toyota.values import ToyotaSafetyFlags
 from opendbc.sunnypilot.car.toyota.values import ToyotaSafetyFlagsSP
 from opendbc.car.structs import CarParams
+from opendbc.safety import ALTERNATIVE_EXPERIENCE
 from opendbc.safety.tests.libsafety import libsafety_py
 import opendbc.safety.tests.common as common
 from opendbc.safety.tests.common import CANPackerPanda
 
 TOYOTA_COMMON_TX_MSGS = [[0x2E4, 0], [0x191, 0], [0x412, 0], [0x343, 0], [0x1D2, 0]]  # LKAS + LTA + ACC & PCM cancel cmds
-TOYOTA_SECOC_TX_MSGS = [[0x131, 0]] + TOYOTA_COMMON_TX_MSGS
+TOYOTA_SECOC_TX_MSGS = [[0x131, 0], [0x183, 0]] + TOYOTA_COMMON_TX_MSGS
 TOYOTA_COMMON_LONG_TX_MSGS = [[0x283, 0], [0x2E6, 0], [0x2E7, 0], [0x33E, 0], [0x344, 0], [0x365, 0], [0x366, 0], [0x4CB, 0],  # DSU bus 0
                               [0x128, 1], [0x141, 1], [0x160, 1], [0x161, 1], [0x470, 1],  # DSU bus 1
                               [0x411, 0],  # PCS_HUD
@@ -87,12 +88,12 @@ def _acc_state_msg(self, enabled):
     values = {"MAIN_ON": enabled}
     return self.packer.make_can_msg_panda(msg, 0, values)
 
-  def test_diagnostics(self, stock_longitudinal: bool = False):
+  def test_diagnostics(self, stock_longitudinal: bool = False, ecu_disabled: bool = True):
     for should_tx, msg in ((False, b"\x6D\x02\x3E\x00\x00\x00\x00\x00"),  # fwdCamera tester present
                            (False, b"\x0F\x03\xAA\xAA\x00\x00\x00\x00"),  # non-tester present
                            (True, b"\x0F\x02\x3E\x00\x00\x00\x00\x00")):
       tester_present = libsafety_py.make_CANPacket(0x750, 0, msg)
-      self.assertEqual(should_tx and not stock_longitudinal, self._tx(tester_present))
+      self.assertEqual(should_tx and ecu_disabled and not stock_longitudinal, self._tx(tester_present))
 
   def test_block_aeb(self, stock_longitudinal: bool = False):
     for controls_allowed in (True, False):
@@ -302,8 +303,8 @@ class TestToyotaStockLongitudinalBase(TestToyotaSafetyBase):
 
   LONGITUDINAL = False
 
-  def test_diagnostics(self, stock_longitudinal: bool = True):
-    super().test_diagnostics(stock_longitudinal=stock_longitudinal)
+  def test_diagnostics(self, stock_longitudinal: bool = True, ecu_disabled: bool = True):
+    super().test_diagnostics(stock_longitudinal=stock_longitudinal, ecu_disabled=ecu_disabled)
 
   def test_block_aeb(self, stock_longitudinal: bool = True):
     super().test_block_aeb(stock_longitudinal=stock_longitudinal)
@@ -347,7 +348,7 @@ def setUp(self):
     self.safety.init_tests()
 
 
-class TestToyotaSecOcSafety(TestToyotaStockLongitudinalBase):
+class TestToyotaSecOcSafetyBase(TestToyotaSafetyBase):
 
   TX_MSGS = TOYOTA_SECOC_TX_MSGS
   RELAY_MALFUNCTION_ADDRS = {0: (0x2E4,)}
@@ -357,9 +358,16 @@ def setUp(self):
     self.packer = CANPackerPanda("toyota_secoc_pt_generated")
     self.safety = libsafety_py.libsafety
     self.safety.set_safety_hooks(CarParams.SafetyModel.toyota,
-                                 self.EPS_SCALE | ToyotaSafetyFlags.STOCK_LONGITUDINAL | ToyotaSafetyFlags.SECOC)
+                                 self.EPS_SCALE | ToyotaSafetyFlags.SECOC)
     self.safety.init_tests()
 
+  # 0x283|PRE_COLLISION is not allowed on SecOC platforms
+  def test_block_aeb(self, stock_longitudinal: bool = False):
+    pass
+
+  def test_diagnostics(self, stock_longitudinal: bool = False, ecu_disabled: bool = False):
+    super().test_diagnostics(stock_longitudinal=stock_longitudinal, ecu_disabled=ecu_disabled)
+
   # This platform also has alternate brake and PCM messages, but same naming in the DBC, so same packers work
 
   def _user_gas_msg(self, gas):
@@ -381,5 +389,52 @@ def test_lta_2_steer_cmd(self):
       self.assertEqual(should_tx, self._tx(self._lta_2_msg(req, req2, angle)), f"{req=} {req2=} {angle=}")
 
 
+class TestToyotaSecOcSafetyStockLongitudinal(TestToyotaSecOcSafetyBase, TestToyotaStockLongitudinalBase):
+
+  def setUp(self):
+    self.packer = CANPackerPanda("toyota_secoc_pt_generated")
+    self.safety = libsafety_py.libsafety
+    self.safety.set_safety_hooks(CarParams.SafetyModel.toyota,
+                                 self.EPS_SCALE | ToyotaSafetyFlags.STOCK_LONGITUDINAL | ToyotaSafetyFlags.SECOC)
+    self.safety.init_tests()
+
+  def test_diagnostics(self, stock_longitudinal: bool = True, ecu_disabled: bool = False):
+    super().test_diagnostics(stock_longitudinal=stock_longitudinal, ecu_disabled=ecu_disabled)
+
+
+class TestToyotaSecOcSafety(TestToyotaSecOcSafetyBase):
+
+  RELAY_MALFUNCTION_ADDRS = {0: (0x2E4, 0x343, 0x183)}
+  FWD_BLACKLISTED_ADDRS = {2: [0x2E4, 0x191, 0x412, 0x131, 0x343, 0x183]}
+
+  def setUp(self):
+    self.packer = CANPackerPanda("toyota_secoc_pt_generated")
+    self.safety = libsafety_py.libsafety
+    self.safety.set_safety_hooks(CarParams.SafetyModel.toyota, self.EPS_SCALE | ToyotaSafetyFlags.SECOC)
+    self.safety.init_tests()
+
+  def _accel_msg_2(self, accel):
+    values = {"ACCEL_CMD": accel}
+    return self.packer.make_can_msg_panda("ACC_CONTROL_2", 0, values)
+
+  def test_accel_actuation_limits(self, stock_longitudinal=False):
+    limits = ((self.MIN_ACCEL, self.MAX_ACCEL, ALTERNATIVE_EXPERIENCE.DEFAULT),
+              (self.MIN_ACCEL, self.MAX_ACCEL, ALTERNATIVE_EXPERIENCE.RAISE_LONGITUDINAL_LIMITS_TO_ISO_MAX))
+
+    for min_accel, max_accel, alternative_experience in limits:
+      # enforce we don't skip over 0 or inactive accel
+      for accel in np.concatenate((np.arange(min_accel - 1, max_accel + 1, 0.05), [0, self.INACTIVE_ACCEL])):
+        accel = round(accel, 2)  # floats might not hit exact boundary conditions without rounding
+        for controls_allowed in [True, False]:
+          self.safety.set_controls_allowed(controls_allowed)
+          self.safety.set_alternative_experience(alternative_experience)
+          # On a SecOC vehicle, we still transmit ACC_CONTROL but the accel value moves to ACC_CONTROL_2
+          # Verify that all non-idle accel values in ACC_CONTROL are rejected, verify ACC_CONTROL_2 accel normally
+          should_tx_1 = accel == self.INACTIVE_ACCEL
+          should_tx_2 = (controls_allowed and min_accel <= accel <= max_accel) or accel == self.INACTIVE_ACCEL
+          self.assertEqual(should_tx_1, self._tx(self._accel_msg(accel)))
+          self.assertEqual(should_tx_2, self._tx(self._accel_msg_2(accel)))
+
+
 if __name__ == "__main__":
   unittest.main()
diff --git a/opendbc/sunnypilot/car/toyota/secoc_long.py b/opendbc/sunnypilot/car/toyota/secoc_long.py
new file mode 100644
index 00000000000..b496a53ff93
--- /dev/null
+++ b/opendbc/sunnypilot/car/toyota/secoc_long.py
@@ -0,0 +1,72 @@
+from opendbc.car import structs
+from opendbc.car.secoc import add_mac
+from opendbc.car.toyota.values import ToyotaFlags
+
+
+class SecOCLong:
+  def __init__(self, CP: structs.CarParams):
+    self.CP = CP
+    self.reset_counter()
+
+  def reset_counter(self):
+    self.secoc_acc_message_counter = 0
+
+  @property
+  def enabled(self):
+    return self.CP.openpilotLongitudinalControl and (self.CP.flags & ToyotaFlags.SECOC)
+
+  def update_car_state(self, car_state):
+    """
+      This method is invoked by the CarController to update the car state on the SECOC_LONG object.
+      The updated state is then used to update create the secoc signed accel command.
+      :param car_state:
+      :return:
+    """
+    self.car_state = car_state
+
+  def set_can_sends(self, can_sends):
+    """
+      This method is invoked by the CarController to set a reference to the can_sends list.
+      The can_sends list is then used to append the secoc signed accel command.
+      :param can_sends:
+      :return:
+    """
+    self.can_sends = can_sends
+
+  def update_accel_command(self, packer, values):
+    """
+      Set ACC_CONTROL's ACCEL_CMD value to 0. On SecOC, stock behavior does not send
+      acceleration on ACCEL_CMD. Instead it is send on ACC_CONTROL_2.
+      :param values: ACC_CONTROL to be sent in dictionary form before being packed
+      :return: Nothing. ACC_CONTROL is updated in place.
+    """
+    if self.enabled:
+      acc_cmd_2 = self.create_accel_2_command(packer, values["ACCEL_CMD"])
+      self.can_sends.append(acc_cmd_2)
+      values["ACCEL_CMD"] = 0
+
+  def create_accel_2_command(self, packer, accel):
+    values = {
+      "ACCEL_CMD": accel,
+    }
+    acc_cmd_2 = packer.make_can_msg("ACC_CONTROL_2", 0, values)
+    acc_cmd_2 = add_mac(self.car_state.secoc_key,
+                        int(self.car_state.secoc_synchronization['TRIP_CNT']),
+                        int(self.car_state.secoc_synchronization['RESET_CNT']),
+                        self.secoc_acc_message_counter,
+                        acc_cmd_2)
+    self.secoc_acc_message_counter += 1
+    return acc_cmd_2
+
+
+class SecOCLongCarController:
+  def __init__(self, CP: structs.CarParams):
+    self.CP = CP
+    self.SECOC_LONG = SecOCLong(CP)
+
+  def update(self, CS, can_sends, prev_reset_timer):
+    self.SECOC_LONG.update_car_state(CS)
+    self.SECOC_LONG.set_can_sends(can_sends)
+    if self.CP.flags & ToyotaFlags.SECOC:
+      if CS.secoc_synchronization['RESET_CNT'] != prev_reset_timer:
+        self.SECOC_LONG.reset_counter()
diff --git a/opendbc/sunnypilot/car/toyota/tests/__init__.py b/opendbc/sunnypilot/car/toyota/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/opendbc/sunnypilot/car/toyota/tests/test_secoc_long_base.py b/opendbc/sunnypilot/car/toyota/tests/test_secoc_long_base.py
new file mode 100644
index 00000000000..27cc52029a5
--- /dev/null
+++ b/opendbc/sunnypilot/car/toyota/tests/test_secoc_long_base.py
@@ -0,0 +1,79 @@
+import pytest
+from hypothesis import given, strategies as st, settings, HealthCheck
+
+from opendbc.car import structs
+from opendbc.car.toyota.carstate import CarState
+from opendbc.car.toyota.values import ToyotaFlags
+from opendbc.safety.tests.common import CANPackerPanda
+from opendbc.sunnypilot.car.toyota.secoc_long import SecOCLong
+
+
+@pytest.fixture
+def car_params():
+  params = structs.CarParams()
+  params.carFingerprint = "TOYOTA_RAV4_PRIME"
+  params.flags = ToyotaFlags.SECOC.value
+  params.openpilotLongitudinalControl = True
+  return params
+
+
+@pytest.fixture
+def car_params_sp():
+  params = structs.CarParams()
+  return params
+
+
+@pytest.fixture
+def car_state(car_params, car_params_sp):
+  car_state = CarState(car_params, car_params_sp)
+  car_state.secoc_key = b"22" * 16
+  car_state.secoc_synchronization = {
+    'TRIP_CNT': 1,
+    'RESET_CNT': 1
+  }
+  return car_state
+
+
+@pytest.fixture
+def secoc_long(car_params):
+  return SecOCLong(car_params)
+
+
+class TestSecOCLong:
+  packer = CANPackerPanda("toyota_nodsu_pt_generated")
+
+  @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
+  @given(st.integers(min_value=0, max_value=255))
+  def test_enabled_flag(self, car_params, value):
+    car_params.flags = value
+    secoc_long = SecOCLong(car_params)
+    assert secoc_long.enabled == (value & ToyotaFlags.SECOC.value)
+
+  def test_update_car_state(self, secoc_long, car_state):
+    secoc_long.update_car_state(car_state)
+    assert secoc_long.car_state == car_state
+
+  @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
+  @given(st.integers(min_value=0, max_value=255))
+  def test_update_accel_command(self, car_state, value):
+    secoc_long = SecOCLong(car_state.CP)
+    secoc_long.set_can_sends([])
+    secoc_long.update_car_state(car_state)
+    accel_message = {
+      "ACCEL_CMD": value
+    }
+    secoc_long.update_accel_command(self.packer, accel_message)
+    assert accel_message["ACCEL_CMD"] == 0
+
+  @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
+  @given(st.integers(min_value=0, max_value=255))
+  def test_should_not_update_accel_command(self, car_state, value):
+    car_state.CP.openpilotLongitudinalControl = False
+    secoc_long = SecOCLong(car_state.CP)
+    secoc_long.set_can_sends([])
+    secoc_long.update_car_state(car_state)
+    accel_message = {
+      "ACCEL_CMD": value
+    }
+    secoc_long.update_accel_command(self.packer, accel_message)
+    assert accel_message["ACCEL_CMD"] == value


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**opendbc/car/toyota/toyotacan.py:58**
* The parameter 'SECOC_LONG' has a default value of None, but 'update_accel_command' is called unconditionally. Consider adding a check to ensure 'SECOC_LONG' is not None before invoking its method to avoid potential runtime errors.
```
SECOC_LONG.update_accel_command(packer, values)
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-15T01:14:35Z
user: login: sunnyhaibin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T02:37:17Z
user: login: sunnyhaibin
body: LGTM. Thanks!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-15T03:56:26Z

Review comments:
diff_hunk: @@ -0,0 +1,79 @@
+import pytest
+from hypothesis import given, strategies as st, settings, HealthCheck
+
+from opendbc.car import structs
+from opendbc.car.toyota.carstate import CarState
+from opendbc.car.toyota.values import ToyotaFlags
+from opendbc.safety.tests.common import CANPackerPanda
+from opendbc.sunnypilot.car.toyota.secoc_long import SecOCLong
+
+
+@pytest.fixture
+def car_params():
+  params = structs.CarParams()
+  params.carFingerprint = "TOYOTA_RAV4_PRIME"
+  params.flags = ToyotaFlags.SECOC.value
+  params.openpilotLongitudinalControl = True
+  return params
+
+
+@pytest.fixture
+def car_params_sp():
+  params = structs.CarParams()
+  return params
+
+
+@pytest.fixture
+def car_state(car_params, car_params_sp):
+  car_state = CarState(car_params, car_params_sp)
+  car_state.secoc_key = b"22" * 16
+  car_state.secoc_synchronization = {
+    'TRIP_CNT': 1,
+    'RESET_CNT': 1
+  }
+  return car_state
+
+
+@pytest.fixture
+def secoc_long(car_params):
+  return SecOCLong(car_params)
+
+
+class TestSecOCLong:
+  packer = CANPackerPanda("toyota_nodsu_pt_generated")
+
+  @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
+  @given(st.integers(min_value=0, max_value=255))
+  def test_enabled_flag(self, car_params, value):
+    car_params.flags = value
+    secoc_long = SecOCLong(car_params)
+    assert secoc_long.enabled == (value & ToyotaFlags.SECOC.value)
+
+  def test_update_car_state(self, secoc_long, car_state):
+    secoc_long.update_car_state(car_state)
+    assert secoc_long.car_state == car_state
+
+  @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
+  @given(st.integers(min_value=0, max_value=255))
+  def test_update_accel_command(self, car_state, value):
+    secoc_long = SecOCLong(car_state.CP)
+    secoc_long.set_can_sends([])
+    secoc_long.update_car_state(car_state)
+    accel_message = {
+      "ACCEL_CMD": value
+    }
+    secoc_long.update_accel_command(self.packer, accel_message)
+    assert accel_message["ACCEL_CMD"] == 0
+
+  @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
+  @given(st.integers(min_value=0, max_value=255))
+  def test_sould_not_update_accel_command(self, car_state, value):
path: opendbc/sunnypilot/car/toyota/tests/test_secoc_long_base.py
user: login: Copilot
body: The test function name 'test_sould_not_update_accel_command' contains a typo. Consider renaming it to 'test_should_not_update_accel_command'.
```suggestion
  def test_should_not_update_accel_command(self, car_state, value):
```
created_at: 2025-04-15T01:14:34Z
author_association: NONE
diff_hunk: @@ -220,6 +224,10 @@ static bool toyota_tx_hook(const CANPacket_t *to_send) {
       desired_accel = to_signed(desired_accel, 16);
 
       bool violation = false;
+      if (toyota_secoc) {
path: opendbc/safety/safety/safety_toyota.h
user: login: sunnyhaibin
body: Move this down before `if (violation) {`
created_at: 2025-04-15T02:36:14Z
author_association: COLLABORATOR
diff_hunk: @@ -238,6 +246,13 @@ static bool toyota_tx_hook(const CANPacket_t *to_send) {
       }
     }
 
+    if (addr == 0x183) {
path: opendbc/safety/safety/safety_toyota.h
user: login: sunnyhaibin
body: ~~We should be able to combine this with `0x343`.~~ Let's keep this separated for clarity.
created_at: 2025-04-15T02:36:24Z
author_association: COLLABORATOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-14T21:20:26Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This PR introduces SecOC longitudinal control for Toyota vehicles. It includes changes to safety tests, safety hooks, car controller, car interface, and CAN message creation. A new class `SecOCLong` is introduced to handle the SecOC longitudinal control logic. The implementation follows a modularized pattern to minimize merge conflicts.

#### Updated class diagram for SecOCLong and SecOCLongCarController

```mermaid
classDiagram
  class SecOCLong {
    -CP: structs.CarParams
    -secoc_acc_message_counter: int
    +__init__(CP: structs.CarParams)
    +reset_counter()
    +enabled()
    +update_car_state(car_state)
    +set_can_sends(can_sends)
    +update_accel_command(packer, values)
    +create_accel_2_command(packer, accel)
  }
  note for SecOCLong "Handles SecOC longitudinal control logic"

  class SecOCLongCarController {
    -CP: structs.CarParams
    -SECOC_LONG: SecOCLong
    +__init__(CP: structs.CarParams)
    +update(CS, can_sends, prev_reset_timer)
  }
  note for SecOCLongCarController "Integrates SecOCLong into the CarController"

  CarController <|-- SecOCLongCarController : implements
  SecOCLongCarController *-- SecOCLong : has a
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Added SecOC longitudinal control support for Toyota vehicles. | <ul><li>Added a new test class `TestToyotaSecOcSafety` to test SecOC longitudinal control.</li><li>Modified existing tests to account for SecOC behavior.</li><li>Added `ACC_CONTROL_2` to the list of transmitted messages for SecOC vehicles.</li><li>Added logic to block AEB actuation when the DSU is unplugged.</li><li>Modified forward hook to block additional messages for SecOC cars.</li><li>Added logic to handle acceleration commands on a separate CAN message for SecOC vehicles.</li><li>Added a new class `SecOCLong` to handle SecOC longitudinal control logic.</li><li>Modified `CarController` to integrate `SecOCLong` and handle SecOC-specific longitudinal control requirements.</li><li>Updated `create_accel_command` to support SecOC message handling.</li></ul> | `opendbc/safety/tests/test_toyota.py`<br/>`opendbc/safety/safety/safety_toyota.h`<br/>`opendbc/car/toyota/carcontroller.py`<br/>`opendbc/car/toyota/interface.py`<br/>`opendbc/car/toyota/toyotacan.py`<br/>`opendbc/sunnypilot/car/toyota/tests/test_secoc_long_base.py`<br/>`opendbc/sunnypilot/car/toyota/secoc_long.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: sunnyhaibin
created_at: 2025-04-15T03:56:13Z
body: `test_models` looks good on sunnypilot. Good to merge. https://github.com/sunnypilot/sunnypilot/actions/runs/14460858611/job/40553098907?pr=819
author_association: COLLABORATOR