diff --git a/.gitattributes b/.gitattributes index 572c56264e..75b40148f5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -49,3 +49,11 @@ openpilot/system/loggerd/loggerd -text openpilot/starpilot/galaxy/bin/frpc_linux_arm64 -text openpilot/starpilot/maps/provider/mapd -text openpilot/selfdrive/test/process_replay/refs/starpilot/*.zst -text + +# Published Panda object files are AArch64 ELF, not text. +openpilot/selfdrive/pandad/main.o -text +openpilot/selfdrive/pandad/panda.o -text +openpilot/selfdrive/pandad/panda_safety.o -text +openpilot/selfdrive/pandad/panda_usb.o -text +openpilot/selfdrive/pandad/pandad.o -text +openpilot/selfdrive/pandad/spi.o -text diff --git a/.github/workflows/android-companion-release.yaml b/.github/workflows/android-companion-release.yaml deleted file mode 100644 index 218598d40e..0000000000 --- a/.github/workflows/android-companion-release.yaml +++ /dev/null @@ -1,68 +0,0 @@ -name: Android companion release - -on: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: android-companion-release - cancel-in-progress: false - -jobs: - build: - name: Test, build and sign the companion APK - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: android-release - defaults: - run: - working-directory: openpilot/starpilot/companion/android - steps: - - uses: actions/checkout@v7 - with: - submodules: false - lfs: false - persist-credentials: false - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - name: Install Android SDK platform and build tools - run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37" "build-tools;37.0.0" - - name: Unit tests - run: ./gradlew testDebugUnitTest - - name: Restore signing key - env: - KEYSTORE_BASE64: ${{ secrets.COMPANION_KEYSTORE_BASE64 }} - run: | - umask 077 - printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/companion.jks" - - name: Build and sign the APK - env: - COMPANION_KEYSTORE: ${{ runner.temp }}/companion.jks - COMPANION_KEY_PASSWORD: ${{ secrets.COMPANION_KEY_PASSWORD }} - run: ./build_apk.sh - - name: Remove signing key - if: always() - run: rm -f "$RUNNER_TEMP/companion.jks" - - name: Check the APK - working-directory: openpilot/starpilot/galaxy/bin - run: | - if unzip -l starpilot-companion.apk | grep -q 'assets/galaxy/'; then - echo 'APK still bundles Galaxy' >&2 - exit 1 - fi - BT="$ANDROID_HOME/build-tools/37.0.0" - "$BT/aapt2" dump badging starpilot-companion.apk | head -1 - "$BT/apksigner" verify --print-certs starpilot-companion.apk | grep -E 'certificate (DN|SHA-256)' - cat starpilot-companion.apk.version - - uses: actions/upload-artifact@v4 - with: - name: starpilot-companion-apk - path: | - openpilot/starpilot/galaxy/bin/starpilot-companion.apk - openpilot/starpilot/galaxy/bin/starpilot-companion.apk.version - if-no-files-found: error - retention-days: 30 diff --git a/opendbc_repo/opendbc/bluepilot_lateral/__init__.py b/opendbc_repo/opendbc/bluepilot_lateral/__init__.py index 6ae503535a..3759d631b9 100644 --- a/opendbc_repo/opendbc/bluepilot_lateral/__init__.py +++ b/opendbc_repo/opendbc/bluepilot_lateral/__init__.py @@ -14,21 +14,82 @@ class BluePilotLateral: self.announced = Command() self.announced_ns = 0 self.lka_counter = 0 + self.control_counter = 0 + self.control_sent_ns = 0 + self.shadow_sent_ns = 0 + self.control_echo_ns = 0 + self.shadow_echo_ns = 0 + self.control_returned_ns = 0 + self.shadow_returned_ns = 0 + self.control_echo_pending = False + self.shadow_echo_pending = False + self.control_return_payload = None + self.shadow_return_payload = None + self.pending_generation = 0 + self.announced_generation = -1 + self.sent_generation = -2 self.apply_curvature_last = 0.0 + def observe_can(self, can_packets, main_bus): + # Forwarded stock traffic also returns with src|0x80. Consume one matching + # return for our actual emitted payload; unrelated stock echoes cannot pace us. + # Preserve return ordering here; + # start the delay when consumed, in the controller's own clock domain. + control_address = 0x3D6 if self.canfd else 0x3D3 + for timestamp, frames in can_packets: + if timestamp <= 0: + continue + for address, data, source in frames: + if source != (main_bus | 0x80) or len(data) != 8: + continue + if address == control_address and data == self.control_return_payload and timestamp > self.control_returned_ns: + self.control_return_payload = None + self.control_returned_ns = timestamp + self.control_echo_pending = True + elif address == 0x3CA and data == self.shadow_return_payload and timestamp > self.shadow_returned_ns: + self.shadow_return_payload = None + self.shadow_returned_ns = timestamp + self.shadow_echo_pending = True + def step_lateral(self, CC, CS, packer, CAN, frame, now_ns): + if now_ns <= 0: + return [] + if self.control_echo_pending: + self.control_echo_ns = now_ns + self.control_echo_pending = False + if ((self.control_sent_ns and now_ns - self.control_sent_ns < 50_000_000) or + (self.control_echo_ns and now_ns - self.control_echo_ns < 45_000_000) or + self.announced_generation <= self.sent_generation): + return [] self.pending = self.host.update(CC, CS) + self.pending_generation += 1 command = self.announced # A pending permission withdrawal is immediate; an increase waits until # the 33Hz side channel has announced the matching final actuator. if not self.pending.active or not 0 < self.announced_ns <= now_ns <= self.announced_ns + 60_000_000: command = Command() self.announced = command - return [lateral(packer, CAN.main, command, canfd=self.canfd, counter=frame // 5)] + self.control_sent_ns = now_ns + self.sent_generation = self.announced_generation + message = lateral(packer, CAN.main, command, canfd=self.canfd, counter=self.control_counter) + self.control_return_payload = message[1] + self.control_counter = (self.control_counter + 1) % 16 + return [message] def step_lka(self, packer, CAN, now_ns): + if now_ns <= 0: + return [] + if self.shadow_echo_pending: + self.shadow_echo_ns = now_ns + self.shadow_echo_pending = False + if ((self.shadow_sent_ns and now_ns - self.shadow_sent_ns < 30_000_000) or + (self.shadow_echo_ns and now_ns - self.shadow_echo_ns < 25_000_000)): + return [] self.announced = self.pending + self.announced_generation = self.pending_generation self.announced_ns = now_ns + self.shadow_sent_ns = now_ns message = lka(packer, CAN.main, self.announced.shadow_curvature, self.lka_counter) + self.shadow_return_payload = message[1] self.lka_counter = (self.lka_counter + 1) % 8 return [message] diff --git a/opendbc_repo/opendbc/car/ford/carcontroller.py b/opendbc_repo/opendbc/car/ford/carcontroller.py index 068ea036d5..a6c4994eda 100644 --- a/opendbc_repo/opendbc/car/ford/carcontroller.py +++ b/opendbc_repo/opendbc/car/ford/carcontroller.py @@ -142,10 +142,11 @@ class CarController(CarControllerBase): ### lateral control ### # send steer msg at 20Hz - if (self.frame % CarControllerParams.STEER_STEP) == 0 and self.bp_lat is not None: - can_sends += self.bp_lat.step_lateral(CC, CS, self.packer, self.CAN, self.frame, now_nanos) + if self.bp_lat is not None: + lateral_sends = self.bp_lat.step_lateral(CC, CS, self.packer, self.CAN, self.frame, now_nanos) + can_sends += lateral_sends self.apply_curvature_last = self.bp_lat.apply_curvature_last - self.mache_ui_owned |= bool(self.bp_lat.announced.active and self.bp_lat.pending.active) + self.mache_ui_owned |= bool(lateral_sends and self.bp_lat.announced.active and self.bp_lat.pending.active) elif (self.frame % CarControllerParams.STEER_STEP) == 0 and self.CP.flags & FordFlags.LKA_STEERING: # Preserve the stock LMC heartbeat without requesting lateral action. can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, False, 0., 0., 0., 0., @@ -177,13 +178,20 @@ class CarController(CarControllerBase): else: self.classic_lateral.set_inputs(None, (), 0.2, self.manual_turn_inputs.enabled if self.manual_turn_inputs is not None else True) previous = self.classic_lateral.curvature_last - demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced else FordLateralResult() + demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced or self.generic_canfd else FordLateralResult() bound = generic_canfd_bounded_command if self.generic_canfd else classic_bounded_command lateral = bound(self.classic_lateral, demanded, previous, CS.out.vEgoRaw, -CS.out.yawRate / max(CS.out.vEgoRaw, 0.1)) self.classic_lateral_demand = demanded self.apply_curvature_last = lateral.curvature if self.generic_canfd: + # Stay in the stock interface while inactive. Announce a mode change in + # the same batch, before the corresponding bounded steering command. + if self.classic_extended_announced != bool(lateral.active): + can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN) if lateral.active else + fordcan.create_lka_msg(self.packer, self.CAN)) + self.classic_extended_announced = bool(lateral.active) + self.mache_ui_owned |= bool(lateral.active) counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10 can_sends.append(create_extended_canfd_msg( self.packer, self.CAN, lateral.active, lateral.ramp_type, lateral.precision_type, @@ -254,7 +262,8 @@ class CarController(CarControllerBase): can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, lateral_active, 0., 0., -self.apply_curvature_last, 0.)) # send lka msg at 33Hz - if (self.frame % CarControllerParams.LKA_STEP) == 0: + if (self.bp_lat is not None or (self.frame % CarControllerParams.LKA_STEP) == 0) and not any( + address == 0x3CA for address, _, _ in can_sends): if self.bp_lat is not None: can_sends += self.bp_lat.step_lka(self.packer, self.CAN, now_nanos) elif self.CP.flags & FordFlags.LKA_STEERING: @@ -285,8 +294,11 @@ class CarController(CarControllerBase): can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN)) self.mache_extended_announced = True elif self.classic_lateral is not None: - can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN)) - self.classic_extended_announced = True + if self.generic_canfd and not self.classic_extended_announced: + can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN)) + else: + can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN)) + self.classic_extended_announced = True else: can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN)) diff --git a/opendbc_repo/opendbc/car/ford/interface.py b/opendbc_repo/opendbc/car/ford/interface.py index e983765c0e..fb1827ab72 100644 --- a/opendbc_repo/opendbc/car/ford/interface.py +++ b/opendbc_repo/opendbc/car/ford/interface.py @@ -21,6 +21,11 @@ class CarInterface(CarInterfaceBase): DRIVABLE_GEARS = (structs.CarState.GearShifter.low, structs.CarState.GearShifter.manumatic) + def update(self, can_packets): + if self.CC is not None and self.CC.bp_lat is not None: + self.CC.bp_lat.observe_can(can_packets, self.CC.CAN.main) + return super().update(can_packets) + @staticmethod def get_pid_accel_limits(CP, current_speed, cruise_speed): # PCM doesn't allow acceleration near cruise_speed, diff --git a/opendbc_repo/opendbc/car/ford/tests/test_mache_display_handoff.py b/opendbc_repo/opendbc/car/ford/tests/test_mache_display_handoff.py index dddd441b6c..776aa67d37 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_mache_display_handoff.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_mache_display_handoff.py @@ -27,7 +27,8 @@ class RecordedPreview: pass def apply_blend_settings(self, controller): - controller.mache_lateral.set_blend_settings(.4, .4, .85) + lateral = controller.mache_lateral or controller.classic_lateral + lateral.set_blend_settings(.4, .4, .85) def lateral_snapshot(self, speed): return self.model, ModelConstants.T_IDXS, .2, True @@ -36,10 +37,10 @@ class RecordedPreview: return (), False -def run_turn(*, hands_free, angle, torque, desired, speed, yaw): +def run_turn(*, hands_free, angle, torque, desired, speed, yaw, identity=CAR.FORD_MUSTANG_MACH_E_MK1): native = test_ford_aol.TestFordAolDriverIntent() native.setUp() - native.reset(18) + native.reset(18 if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else 66) native.arm() with OpenpilotPrefix(): saved = Params() @@ -47,9 +48,9 @@ def run_turn(*, hands_free, angle, torque, desired, speed, yaw): fingerprint = gen_empty_fingerprint() fingerprint[0].update({0x5A: 8, 0x3A6: 8, 0x3A7: 8}) fingerprint[2].update({0x3D6: 8, 0x186: 8}) - cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint, [], False, False, False) + cp = CarInterface.get_params(identity, fingerprint, [], False, False, False) cp.alternativeExperience = 32 - assert cp.safetyConfigs[-1].safetyParam == 18 + assert cp.safetyConfigs[-1].safetyParam == (18 if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else 66) assert cp.flags == 3 controller = CarController(DBC[cp.carFingerprint], cp) controller.hands_free_cluster_input = FordClusterPreference(saved) @@ -71,6 +72,18 @@ def run_turn(*, hands_free, angle, torque, desired, speed, yaw): native.pump(1, mask=int(command.latActive), speed=speed, curvature=yaw / speed) state.out = cs.as_reader() _, frames = controller.update(command.as_reader(), state, (1_000_000 + native.tick * 10_000) * 1000) + if identity != CAR.FORD_MUSTANG_MACH_E_MK1: + lateral_frames = [(index, data) for index, (address, data, _) in enumerate(frames) if address == 0x3D6] + announcements = [(index, data) for index, (address, data, _) in enumerate(frames) if address == 0x3CA] + assert len(announcements) <= 1 + for index, data in lateral_frames: + active = ((data[0] >> 4) & 7) != 0 + previous_active = bool(steering and ((steering[-1][0] >> 4) & 7)) + if active != previous_active: + assert announcements and announcements[0][0] < index + assert bool(announcements[0][1][4] & 2) == active + if not command.latActive: + assert all(not (data[4] & 2) for _, data in announcements) for address, data, bus in frames: assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data)) if address == 0x3D6: @@ -97,6 +110,10 @@ def run_turn(*, hands_free, angle, torque, desired, speed, yaw): cs.steeringAngleDeg = angle cs.steeringTorque = torque cs.rightBlinker = True + if identity != CAR.FORD_MUSTANG_MACH_E_MK1: + # Generic curvature retains its existing 45deg/3s preturned detector. + for _ in range(310): + step() count = len(displays) for _ in range(40): frames = step() @@ -155,3 +172,13 @@ def test_cluster_preference_survives_native_cleanup(tmp_path, saved, flag): assert reopened.get(key) == saved assert Path(reopened.get_param_path(key)).read_bytes() == (b'1' if saved else b'0') assert FordClusterPreference(reopened).enabled == saved + + +@pytest.mark.parametrize('identity', [CAR.FORD_ESCAPE_MK4_5, CAR.FORD_EXPEDITION_MK4, CAR.FORD_F_150_MK14, + CAR.FORD_F_150_LIGHTNING_MK1, CAR.FORD_RANGER_MK2]) +def test_generic_curvature_native_display_handoff_retains_hands_free_during_manual_turn(identity): + # Nonzero demand exercises the bounded first-active command, not just UI. + inputs = dict(identity=identity, angle=-55., torque=-2., desired=.000041086183, speed=2.55, yaw=-.006) + hands_free = run_turn(hands_free=True, **inputs) + plain = run_turn(hands_free=False, **inputs) + assert plain == hands_free diff --git a/opendbc_repo/opendbc/car/ford/tests/test_mache_ui_owner.py b/opendbc_repo/opendbc/car/ford/tests/test_mache_ui_owner.py index e7d3d09379..9cae8e45a4 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_mache_ui_owner.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_mache_ui_owner.py @@ -50,11 +50,11 @@ def test_mache_idle_leaves_stock_display_and_fcw_untouched_then_owns_active_ui() assert state.acc_tja_status_stock_values['FcwVisblWarn_B_Rq'] == 1 -@pytest.mark.parametrize('identity,aol', [(CAR.FORD_MUSTANG_MACH_E_MK1, False), (CAR.FORD_F_150_MK14, True)]) -def test_other_display_ownership_contracts_keep_existing_idle_ui(identity, aol): +@pytest.mark.parametrize('identity,aol,expected', [(CAR.FORD_MUSTANG_MACH_E_MK1, False, UI), (CAR.FORD_F_150_MK14, True, set())]) +def test_idle_ui_respects_native_display_ownership(identity, aol, expected): owner, state = setup(aol=aol, identity=identity) seen = set() for _ in range(110): _, frames = owner.update(structs.CarControl().as_reader(), state, owner.frame * 10_000_000) seen.update(address for address, _, _ in frames if address in UI) - assert seen == UI + assert seen == expected diff --git a/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py b/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py index beba97c1e8..ccc5273120 100644 --- a/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py +++ b/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py @@ -333,8 +333,11 @@ def test_actual_card_active_observation_requires_fresh_selfdrive_state(monkeypat host.car_gps_publisher = SimpleNamespace(update=lambda *args: None) host.observe_ioniq6_long_authority = lambda *args: None host.update_vehicle_state_context = lambda *args: None - host.update_cruise_speed = lambda *args: None - host.v_cruise_helper = SimpleNamespace(slc_consumed_button=None) + + def update_cruise_speed(_state, _pending, *, approved=None): + assert approved is None + host.update_cruise_speed = update_cruise_speed + host.v_cruise_helper = SimpleNamespace(slc_consumed_button=None, toyota=None) host.startup_panda_configured = lambda: True host.can_sock = object() host.pm = None diff --git a/opendbc_repo/opendbc/safety/tests/test_ev9_route40_tx_permission.py b/opendbc_repo/opendbc/safety/tests/test_ev9_route40_tx_permission.py index 36149ec866..3f1d7d9c00 100644 --- a/opendbc_repo/opendbc/safety/tests/test_ev9_route40_tx_permission.py +++ b/opendbc_repo/opendbc/safety/tests/test_ev9_route40_tx_permission.py @@ -7,10 +7,10 @@ from opendbc.safety.tests.libsafety import libsafety_py CAPTURE = Path(__file__).parent / 'testdata/ev9_route40_active_cb.json' -def ready(param=0x5c95): +def ready(param=0x5c95, experience=32): safety = libsafety_py.libsafety safety.init_tests() - safety.set_alternative_experience(32) + safety.set_alternative_experience(experience) assert safety.set_safety_hooks(28, param) == 0 first = None active = [] @@ -30,7 +30,7 @@ def ready(param=0x5c95): # The first active packet is tested separately before any active TX. if len(active) == 1: assert safety.get_controls_allowed() - assert safety.aol_get_permission_mask() == 3 + assert safety.aol_get_permission_mask() == (3 if experience else 0) return safety, first else: safety.safety_tx_hook(packet) @@ -51,7 +51,7 @@ def test_captured_ev9_ordinary_cb_uses_advertised_combined_permission(param): @pytest.mark.parametrize('param', [0x5c95, 0x5495]) @pytest.mark.parametrize('fault', ['no_request', 'no_heartbeat', 'ordinary_disabled', 'stale_sources', 'bad_crc', 'wrong_bus']) def test_captured_ev9_combined_permission_keeps_all_refusals(fault, param): - safety, (stamp, frame) = ready(param) + safety, (stamp, frame) = ready(param, experience=0 if fault == 'ordinary_disabled' else 32) data = bytearray.fromhex(frame['d']) bus = frame['b'] if fault == 'no_request': @@ -59,6 +59,7 @@ def test_captured_ev9_combined_permission_keeps_all_refusals(fault, param): elif fault == 'no_heartbeat': safety.set_aol_test_heartbeat(False) elif fault == 'ordinary_disabled': + # With independent AOL disabled, ordinary disengagement must refuse TX. safety.set_controls_allowed(False) elif fault == 'stale_sources': safety.set_timer(stamp + 100_001) @@ -67,3 +68,23 @@ def test_captured_ev9_combined_permission_keeps_all_refusals(fault, param): elif fault == 'wrong_bus': bus = 0 assert not safety.safety_tx_hook(libsafety_py.make_CANPacket(frame['a'], bus, data)) + + +@pytest.mark.parametrize('param', [0x5c95, 0x5495]) +def test_captured_physically_armed_aol_retains_lateral_without_ordinary_controls(param): + safety, (_, frame) = ready(param) + safety.set_controls_allowed(False) + assert not safety.get_controls_allowed() + assert safety.aol_get_permission_mask() == 1 + packet = libsafety_py.make_CANPacket(frame['a'], frame['b'], bytes.fromhex(frame['d'])) + assert safety.safety_tx_hook(packet) + assert not safety.safety_tx_hook(packet), 'Duplicate counter still rejected' + + +@pytest.mark.parametrize('param', [0x5c95, 0x5495]) +def test_captured_ordinary_engagement_without_independent_aol_can_transmit(param): + safety, (_, frame) = ready(param, experience=0) + packet = libsafety_py.make_CANPacket(frame['a'], frame['b'], bytes.fromhex(frame['d'])) + assert safety.safety_tx_hook(packet) + safety.set_controls_allowed(False) + assert not safety.safety_tx_hook(packet) diff --git a/opendbc_repo/opendbc/safety/tests/test_toyota_pedal.py b/opendbc_repo/opendbc/safety/tests/test_toyota_pedal.py index dc3f1534c1..a169aa511f 100644 --- a/opendbc_repo/opendbc/safety/tests/test_toyota_pedal.py +++ b/opendbc_repo/opendbc/safety/tests/test_toyota_pedal.py @@ -228,6 +228,13 @@ class TestRav4CameraPairedPedal(TestPairedPedal): paired = self.cp.as_reader().as_builder() paired.flags &= ~int(ToyotaFlags.GAS_INTERCEPTOR) paired.safetyConfigs[0].safetyParam = ordinary.safetyConfigs[0].safetyParam + # Dom pedal defaults are intentional; all other configuration stays identical. + self.assertAlmostEqual(paired.longitudinalActuatorDelay, .2) + self.assertAlmostEqual(paired.stopAccel, -1.5) + self.assertAlmostEqual(ordinary.longitudinalActuatorDelay, .15) + self.assertAlmostEqual(ordinary.stopAccel, -2.) + paired.longitudinalActuatorDelay = ordinary.longitudinalActuatorDelay + paired.stopAccel = ordinary.stopAccel self.assertEqual(paired.to_dict(), ordinary.to_dict()) def test_known_firmware_is_candidate_specific(self): diff --git a/opendbc_repo/opendbc/safety/tests/test_toyota_rav4h_filter.py b/opendbc_repo/opendbc/safety/tests/test_toyota_rav4h_filter.py index c3fd95e406..da22de0030 100644 --- a/opendbc_repo/opendbc/safety/tests/test_toyota_rav4h_filter.py +++ b/opendbc_repo/opendbc/safety/tests/test_toyota_rav4h_filter.py @@ -40,12 +40,27 @@ class TestRav4hFilterNative(unittest.TestCase): bad.data[4] ^= 1 self.safety.safety_rx_hook(bad) self.assertEqual(self.safety.aol_get_permission_mask(), 0) - for word in (0x5049, 0x5249, 0x9048, 0x904A, 0x914A, 0x9448, 0x944A, 0x9648, 0x964A, 0xB049, 0xD049): + for word in (0x5049, 0x9048, 0x904A, 0x914A, 0x9448, 0x944A, 0x9648, 0x964A, 0xB049, 0xD049): self.init(word, 32) self.physical(cruise=True) self.assertEqual(self.safety.aol_get_permission_mask(), 0) self.assertFalse(self.safety.safety_tx_hook(self.packer.make_can_msg_safety("ACC_CONTROL", 0, {"ACCEL_CMD": .1}))) + def test_exact_factory_stock_profiles_are_distinct_from_filters(self): + # These exact words now select bare stock PCM, never adapter longitudinal. + for word in (0x5249, 0x564D, 0x5749): + self.init(word, 32) + self.assertFalse(self.safety.safety_config_valid()) + self.assertEqual(self.safety.aol_get_permission_mask(), 0) + self.assertFalse(self.safety.safety_tx_hook(self.packer.make_can_msg_safety("ACC_CONTROL", 0, {"ACCEL_CMD": .1}))) + config = self.safety.current_safety_config + addresses = {config.rx_checks[i].msg[0].addr for i in range(config.rx_checks_len)} + self.assertIn(0x365 if word in (0x564D, 0x5749) else 0x1D3, addresses) + if word == 0x5249: + self.physical(cruise=True) + self.assertEqual(self.safety.aol_get_permission_mask(), 1) + self.assertFalse(self.safety.safety_tx_hook(self.packer.make_can_msg_safety("ACC_CONTROL", 0, {"ACCEL_CMD": .1}))) + def test_explicit_legacy_advert_words_require_physical_sources(self): # The four-byte advertisement owner is distinct from the eight-byte SDSU owner. for word, permission in ((0x9449, 3), (0x9649, 1)): diff --git a/openpilot/selfdrive/controls/plannerd.py b/openpilot/selfdrive/controls/plannerd.py index 4f8baa7ad2..e35e8d3564 100755 --- a/openpilot/selfdrive/controls/plannerd.py +++ b/openpilot/selfdrive/controls/plannerd.py @@ -46,7 +46,7 @@ from openpilot.starpilot.speed_limits.runtime_settings import read_params as rea class SavedSlcLifecycle: - """Refresh saved admission; offset-only edits preserve the accepted drive limit.""" + """Refresh saved admission; offsets and display units preserve driver choices.""" def __init__(self, params, cp, environment): self.params, self.cp, self.environment = params, cp, dict(environment) @@ -66,24 +66,29 @@ class SavedSlcLifecycle: vision = admitted and feature_enabled(self.params, self.cp, 'vision', self.environment, saved_slc=saved_slc) configuration = (admitted and not settings.errors, settings, vision, vision_control_enabled(self.params, self.cp, saved_slc=saved_slc), - feature_enabled(self.params, self.cp, 'vision', {}, saved_slc=saved_slc)) + feature_enabled(self.params, self.cp, 'vision', {}, saved_slc=saved_slc), + self.params.get_bool('IsMetric')) if configuration == self.configuration: return self.runtime, False + previous_runtime = self.runtime if (self.runtime is not None and self.configuration is not None and - configuration == (self.configuration[0], replace(self.runtime.settings, offsets=settings.offsets), - *self.configuration[2:])): - self.runtime.update_offsets(settings.offsets) + configuration[:-1] == (self.configuration[0], replace(self.runtime.settings, offsets=settings.offsets), + *self.configuration[2:-1])): + self.runtime.update_offsets(settings.offsets, is_metric=configuration[5]) else: + set_speed_after_ns = self.runtime.set_speed_after_ns if self.runtime is not None else 0 if self.runtime is not None: self.runtime.reset() self.runtime = (SlcRuntime(settings, vision_enabled=vision, vision_control_qualified=configuration[3], - vision_display_qualified=configuration[4]) + vision_display_qualified=configuration[4], is_metric=configuration[5]) if configuration[0] else None) + if self.runtime is not None: + self.runtime.set_speed_after_ns = set_speed_after_ns self.configuration = configuration if pending_actions is not None: pending_actions.clear() - if pending_cruise is not None: + if pending_cruise is not None and (self.runtime is None or self.runtime is not previous_runtime): pending_cruise.clear() return self.runtime, True diff --git a/openpilot/selfdrive/controls/tests/test_cem_input_acquisition_order.py b/openpilot/selfdrive/controls/tests/test_cem_input_acquisition_order.py index 19e0127b26..543a2f8a8a 100644 --- a/openpilot/selfdrive/controls/tests/test_cem_input_acquisition_order.py +++ b/openpilot/selfdrive/controls/tests/test_cem_input_acquisition_order.py @@ -93,7 +93,7 @@ class TestCemInputAcquisitionOrder(unittest.TestCase): feature_runtime.slc_runtime_settings(params, cp, {}), feature_runtime.enabled(params, cp, 'vision', {}), feature_runtime.vision_control_enabled(params, cp), - feature_runtime.enabled(params, cp, 'vision', {})) + feature_runtime.enabled(params, cp, 'vision', {}), params.get_bool('IsMetric')) reads = [] def read(params): reads.append(True) diff --git a/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py b/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py index 304064aa0b..3b80a6a25b 100644 --- a/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py +++ b/openpilot/selfdrive/controls/tests/test_conditional_runtime_loop.py @@ -26,6 +26,113 @@ from openpilot.starpilot.conditional_mode.tests.test_projection import BOOT, MON class TestConditionalRuntimeLoop(unittest.TestCase): + def test_slc_unit_change_keeps_receipts_for_the_current_command(self): + from openpilot.selfdrive.car.cruise import slc_target_kph + from openpilot.starpilot.speed_limits import offset_document as od + from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent + from openpilot.starpilot.speed_limits.tests.test_runtime_replay import RuntimeReplayTests + fixture = RuntimeReplayTests() + fixture.setUp() + sm, cp = fixture.sm, fixture.cp + with tempfile.TemporaryDirectory() as directory: + params = Params(directory) + params.put_bool('SpeedLimitController', True, block=True) + params.put_bool('SLCConfirmation', True, block=True) + params.put('SLCPriority1', 'Dashboard', block=True) + params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (0.,) * 7)), block=True) + owner = plannerd.SavedSlcLifecycle(params, cp, {}) + runtime, _ = owner.refresh(2_000_000_000) + sm['carState'].vCruise = sm['carState'].vCruiseCluster = 60. + sm.advance(2_900_000_000) + pending = runtime.step(sm, cp, now_ns=2_900_000_000).message.slcState + sm.advance(2_950_000_000) + issued = runtime.step(sm, cp, now_ns=2_950_000_000, + request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept')) + command = issued.command.slcCruiseCommand + selected = slc_target_kph(command.targetMps, False) + receipts = deque([CruiseEvent(1, 2_990_000_000, 60 / 3.6, selected / 3.6, '', 'card-producer', + 2_990_000_000, 'commandApplied', runtime.session_id, command.decisionId, command.presentationId, command.commandId)]) + params.put_bool('IsMetric', True, block=True) + metric, _ = owner.refresh(3_000_000_000, pending_cruise=receipts) + self.assertEqual(len(receipts), 1) + sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected + sm.advance(3_000_000_000) + applied = metric.step(sm, cp, now_ns=3_000_000_000, cruise_event=receipts.popleft()) + self.assertEqual(applied.message.slcState.commandStatus, 'applied') + self.assertIsNone(applied.command) + + def test_slc_unit_changes_preserve_set_speed_and_driver_choices(self): + from openpilot.common.constants import CV + from openpilot.selfdrive.car.cruise import slc_target_kph + from openpilot.starpilot.speed_limits import offset_document as od + from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent + from openpilot.starpilot.speed_limits.tests.test_runtime_replay import RuntimeReplayTests + now = 2_000_000_000 + for confirmation, choice in ((False, 'applied'), (True, 'applied'), (False, 'override'), (True, 'decline')): + with self.subTest(confirmation=confirmation, choice=choice), tempfile.TemporaryDirectory() as directory: + fixture = RuntimeReplayTests() + fixture.setUp() + sm, cp = fixture.sm, fixture.cp + params = Params(directory) + params.put_bool('SpeedLimitController', True, block=True) + params.put_bool('SLCConfirmation', confirmation, block=True) + params.put('SLCPriority1', 'Dashboard', block=True) + params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (0.,) * 7)), block=True) + owner = plannerd.SavedSlcLifecycle(params, cp, {}) + original, _ = owner.refresh(now) + sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS + baseline = slc_target_kph(65 * CV.MPH_TO_MS, False) + sm['carState'].vCruise = sm['carState'].vCruiseCluster = baseline + aligned = original.step(sm, cp, now_ns=now) + self.assertIsNone(aligned.command) + if choice == 'override': + selected = slc_target_kph(70 * CV.MPH_TO_MS, False) + sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected + sm.advance(now + 50_000_000) + chosen = original.step(sm, cp, now_ns=now + 50_000_000, cruise_event=CruiseEvent( + 1, now + 50_000_000, baseline / 3.6, selected / 3.6, 'accel', 'card-producer', now + 50_000_000)) + self.assertTrue(chosen.message.slcState.driverOverrideActive) + elif choice == 'decline': + sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS + sm.advance(now + 50_000_000) + pending = original.step(sm, cp, now_ns=now + 50_000_000).message.slcState + sm.advance(now + 100_000_000) + original.step(sm, cp, now_ns=now + 100_000_000, + request=Action(original.session_id, 1, pending.decisionId, pending.presentationId, 'reject')) + selected = float(sm['carState'].vCruise) + params.put_bool('IsMetric', True, block=True) + metric, changed = owner.refresh(now + 1_000_000_000) + self.assertTrue(changed) + sm.advance(now + 1_000_000_000) + output = metric.step(sm, cp, now_ns=now + 1_000_000_000) + self.assertIsNone(output.command) + self.assertFalse(output.message.slcState.hasPending) + self.assertTrue(output.message.slcState.hasAccepted) + self.assertEqual(sm['carState'].vCruise, selected) + self.assertEqual(output.message.slcState.driverOverrideActive, choice == 'override') + if choice == 'decline': + self.assertEqual(output.result.acceptance.basis, 'rejected') + + def test_slc_uses_current_set_units_and_retires_actions_when_units_change(self): + cp = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + with tempfile.TemporaryDirectory() as directory: + params = Params(directory) + params.put_bool('SpeedLimitController', True, block=True) + params.put('SLCPriority1', 'Dashboard', block=True) + owner = plannerd.SavedSlcLifecycle(params, cp, {}) + imperial, _ = owner.refresh(MONO) + self.assertFalse(imperial.is_metric) + imperial.set_speed_after_ns = MONO + 1_050_000_000 + actions, cruise = deque([object()]), deque([object()]) + params.put_bool('IsMetric', True, block=True) + metric, changed = owner.refresh(MONO + 1_000_000_000, pending_actions=actions, pending_cruise=cruise) + self.assertTrue(changed) + self.assertTrue(metric.is_metric) + self.assertEqual(metric.set_speed_after_ns, MONO + 1_050_000_000) + self.assertIs(metric, imperial) + self.assertFalse(actions) + self.assertEqual(len(cruise), 1) + def test_starpilot_loop_subscriptions_follow_conditional_selection(self): class EndLoop(Exception): pass diff --git a/openpilot/selfdrive/pandad/SConscript b/openpilot/selfdrive/pandad/SConscript index 7065c076b9..16ad9f2e19 100644 --- a/openpilot/selfdrive/pandad/SConscript +++ b/openpilot/selfdrive/pandad/SConscript @@ -34,3 +34,6 @@ if arch != "Darwin": if GetOption('extras') or any(target.startswith('openpilot/selfdrive/pandad/tests/test_preap_pedal_startup') for target in COMMAND_LINE_TARGETS): env.Program('tests/test_preap_pedal_startup', ['tests/test_preap_pedal_startup.cc', env.Object('tests/preap_panda_safety.o', 'panda_safety.cc')], LIBS=[panda] + libs + ['dl']) + + if GetOption('extras') or any(target.startswith('openpilot/selfdrive/pandad/tests/test_panda_debug') for target in COMMAND_LINE_TARGETS): + env.Program('tests/test_panda_debug', ['tests/test_panda_debug.cc'], LIBS=[panda] + libs) diff --git a/openpilot/selfdrive/pandad/libpanda.a b/openpilot/selfdrive/pandad/libpanda.a index f45a467c69..e9d2230767 100644 Binary files a/openpilot/selfdrive/pandad/libpanda.a and b/openpilot/selfdrive/pandad/libpanda.a differ diff --git a/openpilot/selfdrive/pandad/main.o b/openpilot/selfdrive/pandad/main.o new file mode 100644 index 0000000000..7a7d6fd791 Binary files /dev/null and b/openpilot/selfdrive/pandad/main.o differ diff --git a/openpilot/selfdrive/pandad/panda.cc b/openpilot/selfdrive/pandad/panda.cc index 6c7b563fc3..408cf2b19d 100644 --- a/openpilot/selfdrive/pandad/panda.cc +++ b/openpilot/selfdrive/pandad/panda.cc @@ -64,9 +64,13 @@ std::string Panda::serial_read(int port_number) { std::string ret; char buffer[USBPACKET_MAX_SIZE] = {}; - while (true) { - int bytes_read = handle->control_read(0xe0, port_number, 0, (unsigned char *)buffer, USBPACKET_MAX_SIZE); - if (bytes_read <= 0) { + // Firmware exposes one debug ring; bound each drain so CAN work keeps its cadence. + (void)port_number; + constexpr size_t max_debug_bytes = 1024; + while (ret.size() < max_debug_bytes) { + const uint16_t length = std::min(USBPACKET_MAX_SIZE, max_debug_bytes - ret.size()); + int bytes_read = handle->control_read(0xb6, 0, 0, (unsigned char *)buffer, length); + if (bytes_read <= 0 || bytes_read > length) { break; } ret.append(buffer, bytes_read); diff --git a/openpilot/selfdrive/pandad/panda.o b/openpilot/selfdrive/pandad/panda.o new file mode 100644 index 0000000000..18c215943b Binary files /dev/null and b/openpilot/selfdrive/pandad/panda.o differ diff --git a/openpilot/selfdrive/pandad/panda_safety.o b/openpilot/selfdrive/pandad/panda_safety.o new file mode 100644 index 0000000000..6e662e400b Binary files /dev/null and b/openpilot/selfdrive/pandad/panda_safety.o differ diff --git a/openpilot/selfdrive/pandad/panda_usb.o b/openpilot/selfdrive/pandad/panda_usb.o new file mode 100644 index 0000000000..c65e72a8c3 Binary files /dev/null and b/openpilot/selfdrive/pandad/panda_usb.o differ diff --git a/openpilot/selfdrive/pandad/pandad b/openpilot/selfdrive/pandad/pandad index 9483a2fde5..1acd8dd5a5 100755 Binary files a/openpilot/selfdrive/pandad/pandad and b/openpilot/selfdrive/pandad/pandad differ diff --git a/openpilot/selfdrive/pandad/pandad.o b/openpilot/selfdrive/pandad/pandad.o new file mode 100644 index 0000000000..53d48ea10e Binary files /dev/null and b/openpilot/selfdrive/pandad/pandad.o differ diff --git a/openpilot/selfdrive/pandad/spi.o b/openpilot/selfdrive/pandad/spi.o new file mode 100644 index 0000000000..100bd3c127 Binary files /dev/null and b/openpilot/selfdrive/pandad/spi.o differ diff --git a/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc b/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc index 7d1d73d077..49e4c06e6c 100644 --- a/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc +++ b/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc @@ -179,7 +179,7 @@ int main() { } for (uint32_t word = 0U; word <= 65535U; ++word) { - const bool expected = (word == 0x3049U) || (word == 0x3249U) || (word == 0x3449U) || (word == 0x3649U) || (word == 0xA042U) || (word == 0xA049U) || (word == 0xA242U) || (word == 0xA249U) || (word == 0xA449U) || (word == 0xA649U) || (word == 0xA849U) || (word == 0xA949U) || (word == 0xA958U) || (word == 0xA964U) || (word == 0xAA49U) || (word == 0xAB49U) || (word == 0xAB58U) || (word == 0xAB64U) || (word == 0xB149U) || (word == 0xB158U) || (word == 0xB164U) || (word == 0xB349U) || (word == 0xB358U) || (word == 0xB364U) || (word == 0xB449U) || (word == 0xB549U) || (word == 0xB558U) || (word == 0xB564U) || (word == 0xB649U) || (word == 0xB749U) || (word == 0xB758U) || (word == 0xB764U) || (word == 0xB842U) || (word == 0xB849U) || (word == 0xBA42U) || (word == 0xBA49U) || (word == 0xE042U) || (word == 0xE049U) || (word == 0xE242U) || (word == 0xE249U) || (word == 0xE84DU) || (word == 0xE949U) || (word == 0xEA4DU) || (word == 0xEB49U) || (word == 0xF049U) || (word == 0xF04DU) || (word == 0xF149U) || (word == 0xF249U) || (word == 0xF24DU) || (word == 0xF349U) || (word == 0xF44DU) || (word == 0xF549U) || (word == 0xF64DU) || (word == 0xF749U) || (word == 0x8949U) || (word == 0x8B49U) || (word == 0x8849U) || (word == 0x8A49U) || (word == 0x9849U) || (word == 0x9A49U) || (word == 0x9842U) || (word == 0x9A42U) || (word == 0x8958U) || (word == 0x8B58U) || (word == 0x8964U) || (word == 0x8B64U) || (word == 0xC84DU) || (word == 0xCA4DU) || (word == 0xC949U) || (word == 0xCB49U) || (word == 0xB049U) || (word == 0xB249U) || (word == 0xD049U) || (word == 0xD249U) || (word == 0xC042U) || (word == 0xC242U) || (word == 0x8042U) || (word == 0x8242U) || (word == 0xC049U) || (word == 0xC249U) || (word == 73U) || (word == 585U) || (word == 856U) || (word == 4169U) || (word == 4681U) || (word == 2121U) || (word == 0x9049U) || (word == 0x9249U) || (word == 0x2449U) || (word == 0x2649U) || (word == 0x2049U) || (word == 0x2249U) || (word == 0x424DU) || (word == 0x449U) || (word == 0x649U) || (word == 0x1449U) || (word == 0x1649U) || (word == 0x8049U) || (word == 0x8249U) || (word == 0x8449U) || (word == 0x8649U) || (word == 0x9158U) || (word == 0x9358U) || (word == 0x9149U) || (word == 0x9349U) || (word == 0x9164U) || (word == 0x9364U) || (word == 0xd149U) || (word == 0xd349U) || (word == 0xd04dU) || (word == 0xd24dU) || (word == 0x9449U) || (word == 0x9549U) || (word == 0x9558U) || (word == 0x9564U) || (word == 0x9649U) || (word == 0x9749U) || (word == 0x9758U) || (word == 0x9764U) || (word == 0xD44DU) || (word == 0xD549U) || (word == 0xD64DU) || (word == 0xD749U); + const bool expected = (word == 0x0A49U) || (word == 0x1849U) || (word == 0x5249U) || (word == 0x5349U) || (word == 0x5358U) || (word == 0x5364U) || (word == 0x564DU) || (word == 0x5749U) || (word == 0x6242U) || (word == 0x6249U) || (word == 0x3049U) || (word == 0x3249U) || (word == 0x3449U) || (word == 0x3649U) || (word == 0xA042U) || (word == 0xA049U) || (word == 0xA242U) || (word == 0xA249U) || (word == 0xA449U) || (word == 0xA649U) || (word == 0xA849U) || (word == 0xA949U) || (word == 0xA958U) || (word == 0xA964U) || (word == 0xAA49U) || (word == 0xAB49U) || (word == 0xAB58U) || (word == 0xAB64U) || (word == 0xB149U) || (word == 0xB158U) || (word == 0xB164U) || (word == 0xB349U) || (word == 0xB358U) || (word == 0xB364U) || (word == 0xB449U) || (word == 0xB549U) || (word == 0xB558U) || (word == 0xB564U) || (word == 0xB649U) || (word == 0xB749U) || (word == 0xB758U) || (word == 0xB764U) || (word == 0xB842U) || (word == 0xB849U) || (word == 0xBA42U) || (word == 0xBA49U) || (word == 0xE042U) || (word == 0xE049U) || (word == 0xE242U) || (word == 0xE249U) || (word == 0xE84DU) || (word == 0xE949U) || (word == 0xEA4DU) || (word == 0xEB49U) || (word == 0xF049U) || (word == 0xF04DU) || (word == 0xF149U) || (word == 0xF249U) || (word == 0xF24DU) || (word == 0xF349U) || (word == 0xF44DU) || (word == 0xF549U) || (word == 0xF64DU) || (word == 0xF749U) || (word == 0x8949U) || (word == 0x8B49U) || (word == 0x8849U) || (word == 0x8A49U) || (word == 0x9849U) || (word == 0x9A49U) || (word == 0x9842U) || (word == 0x9A42U) || (word == 0x8958U) || (word == 0x8B58U) || (word == 0x8964U) || (word == 0x8B64U) || (word == 0xC84DU) || (word == 0xCA4DU) || (word == 0xC949U) || (word == 0xCB49U) || (word == 0xB049U) || (word == 0xB249U) || (word == 0xD049U) || (word == 0xD249U) || (word == 0xC042U) || (word == 0xC242U) || (word == 0x8042U) || (word == 0x8242U) || (word == 0xC049U) || (word == 0xC249U) || (word == 73U) || (word == 585U) || (word == 856U) || (word == 4169U) || (word == 4681U) || (word == 2121U) || (word == 0x9049U) || (word == 0x9249U) || (word == 0x2449U) || (word == 0x2649U) || (word == 0x2049U) || (word == 0x2249U) || (word == 0x424DU) || (word == 0x449U) || (word == 0x649U) || (word == 0x1449U) || (word == 0x1649U) || (word == 0x8049U) || (word == 0x8249U) || (word == 0x8449U) || (word == 0x8649U) || (word == 0x9158U) || (word == 0x9358U) || (word == 0x9149U) || (word == 0x9349U) || (word == 0x9164U) || (word == 0x9364U) || (word == 0xd149U) || (word == 0xd349U) || (word == 0xd04dU) || (word == 0xd24dU) || (word == 0x9449U) || (word == 0x9549U) || (word == 0x9558U) || (word == 0x9564U) || (word == 0x9649U) || (word == 0x9749U) || (word == 0x9758U) || (word == 0x9764U) || (word == 0xD44DU) || (word == 0xD549U) || (word == 0xD64DU) || (word == 0xD749U); auto toyota = status(); toyota.safety_mode = 2U; toyota.safety_param = static_cast(word); @@ -200,7 +200,7 @@ int main() { for (const uint16_t word : {2120U, 2122U, 2633U, 3145U, 4169U}) { auto different = sienna; different.safety_param = word; - if (word != 4169U) assert(!aol_capable(different, 2U, VEHICLE_REGISTRY)); + assert(aol_capable(different, 2U, VEHICLE_REGISTRY) == ((word == 2633U) || (word == 4169U))); } AolAxisNegotiator negotiator(VEHICLE_REGISTRY); FakeTransport transport; diff --git a/openpilot/selfdrive/pandad/tests/test_panda_debug.cc b/openpilot/selfdrive/pandad/tests/test_panda_debug.cc new file mode 100644 index 0000000000..c3225e069c --- /dev/null +++ b/openpilot/selfdrive/pandad/tests/test_panda_debug.cc @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include + +#include "common/tests/native_test.h" +#include "selfdrive/pandad/panda.h" + +class DebugHandle : public PandaCommsHandle { +public: + std::string payload; + int calls = 0; + int writes = 0; + bool endless = false; + int error = 0; + int control_write(uint8_t, uint16_t, uint16_t, unsigned int) override { ++writes; return 0; } + int control_read(uint8_t request, uint16_t p1, uint16_t p2, unsigned char *out, uint16_t length, unsigned int) override { + ++calls; + CHECK(request == 0xb6); + CHECK(p1 == 0 && p2 == 0 && length > 0 && length <= 64); + if (error) return error; + if (endless) { memset(out, 'x', length); return length; } + const size_t count = std::min(length, payload.size()); + memcpy(out, payload.data(), count); + payload.erase(0, count); + return count; + } + int bulk_write(unsigned char, unsigned char *, int, unsigned int) override { ++writes; return 0; } + int bulk_read(unsigned char, unsigned char *, int, unsigned int) override { return 0; } + void cleanup() override {} +}; +class DebugPanda : public Panda { +public: + explicit DebugPanda(std::unique_ptr handle) : Panda(std::move(handle), cereal::PandaState::PandaType::CUATRO) {} +}; +void test_debug_transport() { + auto handle = std::make_unique(); auto *io = handle.get(); + DebugPanda panda(std::move(handle)); + io->payload = std::string(83, 'a') + "Temporary fault occurred: 0x00000004\r\n"; + const std::string expected = io->payload; + CHECK(panda.serial_read() == expected); + CHECK(io->calls == 3 && io->payload.empty() && io->writes == 0); + io->calls = 0; CHECK(panda.serial_read().empty()); CHECK(io->calls == 1); + io->calls = 0; io->endless = true; + CHECK(panda.serial_read() == std::string(1024, 'x')); + CHECK(io->calls == 16 && io->writes == 0); + io->endless = false; io->error = -1; io->calls = 0; + CHECK(panda.serial_read().empty()); CHECK(io->calls == 1); + io->error = 65; io->calls = 0; + CHECK(panda.serial_read().empty()); CHECK(io->calls == 1 && io->writes == 0); +} +int main() { return run_native_test(test_debug_transport); } diff --git a/openpilot/starpilot/aol/panda_faults.py b/openpilot/starpilot/aol/panda_faults.py index cd1f4ad4f4..ce0de568c7 100644 --- a/openpilot/starpilot/aol/panda_faults.py +++ b/openpilot/starpilot/aol/panda_faults.py @@ -21,9 +21,17 @@ def _diagnostic_mask(cp): int(cp.flags) & int(HondaFlags.BOSCH) and qualified(cp, marked_only=True)) else 0 if cp.brand == 'hyundai': from opendbc.car.hyundai.values import CAR, HyundaiFlags + from opendbc.car.hyundai.classic_scc_aol import qualified + if cp.carFingerprint == CAR.HYUNDAI_ELANTRA_2021: + # Route08's stock-SCC firmware-matched profile: Dom reports this latched + # camera-side CAN1 diagnostic without withdrawing actuator authority. + # Keep the raw bit and require the ordinary native/RX/heartbeat guards. + recorded_flags = HyundaiFlags.SEND_LFA | HyundaiFlags.USE_FCA | HyundaiFlags.CHECKSUM_CRC8 | HyundaiFlags.HAS_LDA_BUTTON + return (1 << 2) if (str(cp.fingerprintSource) == 'fw' and int(cp.flags) == int(recorded_flags) and + len(cp.safetyConfigs) == 1 and int(cp.safetyConfigs[0].safetyParam) == 0xC00 and + cp.alternativeExperience == 32 and qualified(cp, marked_only=True)) else 0 if cp.carFingerprint != CAR.HYUNDAI_SONATA_HYBRID: return 0 - from opendbc.car.hyundai.classic_scc_aol import qualified recorded_flags = (HyundaiFlags.HYBRID | HyundaiFlags.SEND_LFA | HyundaiFlags.USE_FCA | HyundaiFlags.MANDO_RADAR | HyundaiFlags.CHECKSUM_CRC8 | HyundaiFlags.HAS_LDA_BUTTON) return (1 << 3) if (int(cp.flags) == int(recorded_flags) and diff --git a/openpilot/starpilot/aol/tests/test_elantra_irq_admission.py b/openpilot/starpilot/aol/tests/test_elantra_irq_admission.py new file mode 100644 index 0000000000..3f22a0cf63 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_elantra_irq_admission.py @@ -0,0 +1,115 @@ +"""Elantra route08: retain Dom diagnostic semantics without granting native authority.""" +from contextlib import ExitStack +from dataclasses import replace +from unittest import mock + +import pytest +from opendbc.car import gen_empty_fingerprint +from opendbc.car.hyundai.classic_scc_aol import aol_word, qualified +from opendbc.car.hyundai.interface import CarInterface +from opendbc.car.hyundai.values import CAR +from openpilot.cereal import log, messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.starpilot.aol.panda_faults import blocking_fault_list, blocking_fault_mask +from openpilot.starpilot.aol.runtime import current_native +from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture +from openpilot.starpilot.aol.tests.test_ordinary_source_availability import settings + + +def recorded_cp(): + fingerprint = gen_empty_fingerprint() + fingerprint[0].update({0x391: 8, 0x38d: 8}) + fingerprint[2][0x485] = 4 + cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_2021, fingerprint, [], False, False, False) + cp.fingerprintSource = 'fw' + cp.safetyConfigs[0].safetyParam = aol_word(cp) + cp.alternativeExperience = 32 + assert int(cp.flags) == 16843136 and cp.safetyConfigs[0].safetyParam == 0xC00 + assert qualified(cp, marked_only=True) + return cp + + +@pytest.mark.parametrize('full', [False, True]) +@pytest.mark.parametrize('fault', ['none', 'rx', 'relay', 'denied', 'request', 'expired-wire']) +def test_recorded_irq_through_selfdrived_and_controls_keeps_native_vetoes(full, fault): + cp = recorded_cp() + before = cp.to_dict() + case = fixture.IoniqAvailabilityTests(methodName='runTest') + with ExitStack() as cleanup: + cleanup.callback(case.doCleanups) + cleanup.enter_context(OpenpilotPrefix()) + sd, controls, drive = case._authority_callers(full, cp=cp, genuine_events=True) + new_message = messaging.new_message + def with_irq(service=None, size=None, **kwargs): + message = new_message(service, size, **kwargs) + if service == 'pandaStates': + for panda in message.pandaStates: + panda.faults = ['interruptRateCan1'] + return message + encode = fixture.encode_safety + def wire_irq(state): + state = replace(state, pandaInventory=tuple(replace(slot, faults=slot.faults | 4) for slot in state.pandaInventory)) + if fault == 'expired-wire': + state = replace(state, observedMonoTime=state.observedMonoTime - 300_000_000, + validUntilMonoTime=state.validUntilMonoTime - 300_000_000) + return encode(state) + cleanup.enter_context(mock.patch.object(messaging, 'new_message', with_irq)) + cleanup.enter_context(mock.patch.object(fixture, 'encode_safety', wire_irq)) + for tick in range(3): + stamp = 10_000_000_000 + tick * 10_000_000 + options = settings('combined' if full else 'lateral_only', stamp, tick + 1) + observed = fixture.car_state() + observed.cruiseState.enabled = full + options['observed_state'] = observed + options.update(panda_fault=fault == 'rx', relay_fault=fault == 'relay', long_allowed=full) + if fault == 'denied': + options.update(allowed=False, long_allowed=False) + if fault == 'request': + options.update(native_request_lat=False, native_request_long=False) + axis, state, command = drive(tick, **options) + if fault == 'none': + assert axis.lateralActive and command.latActive, 'RECORDED_ELANTRA_IRQ_DIAGNOSTIC_REJECTED' + assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names + native = current_native(sd.sm, sd.CP, now_ns=stamp, axis_session_id=sd.aol_session_id) + assert native is not None and native.pandaInventory[0].faults == 4 + assert list(sd.sm['pandaStates'][0].faults) == [log.PandaState.FaultType.interruptRateCan1] + assert not axis.longitudinalActive and not command.longActive + assert state.enabled == full + else: + assert not axis.lateralActive and not command.latActive + assert not axis.longitudinalActive and not command.longActive + assert cp.to_dict() == before + + +@pytest.mark.parametrize('name,value', tuple(log.PandaState.FaultType.schema.enumerants.items())) +def test_other_and_mixed_faults_still_block(name, value): + cp = recorded_cp() + expected = name != 'interruptRateCan1' + assert blocking_fault_mask(1 << value, cp) == expected + assert blocking_fault_mask((1 << value) | 4, cp) == expected + assert blocking_fault_list([value], cp) == expected + assert blocking_fault_list([log.PandaState.FaultType.interruptRateCan1, value], cp) == expected + + +@pytest.mark.parametrize('mutation', ['alpha', 'fixed', 'can', 'word', 'unmarked', 'flags', 'identity', 'extra', 'passive']) +def test_unrecorded_configurations_cannot_borrow_diagnostic(mutation): + cp = recorded_cp() + if mutation == 'alpha': + cp.openpilotLongitudinalControl = True + cp.pcmCruise = False + elif mutation in ('fixed', 'can'): + cp.fingerprintSource = mutation + elif mutation == 'word': + cp.safetyConfigs[0].safetyParam |= 4 + elif mutation == 'unmarked': + cp.alternativeExperience = 0 + elif mutation == 'flags': + cp.flags |= 1 << 31 + elif mutation == 'identity': + cp.carFingerprint = CAR.HYUNDAI_SONATA + elif mutation == 'extra': + cp.safetyConfigs = [cp.safetyConfigs[0], cp.safetyConfigs[0]] + else: + cp.passive = True + assert blocking_fault_mask(4, cp) + assert blocking_fault_list([log.PandaState.FaultType.interruptRateCan1], cp) diff --git a/openpilot/starpilot/car/subaru/tests/test_sng.py b/openpilot/starpilot/car/subaru/tests/test_sng.py index b09fac7de5..5a84bd3ce9 100644 --- a/openpilot/starpilot/car/subaru/tests/test_sng.py +++ b/openpilot/starpilot/car/subaru/tests/test_sng.py @@ -133,9 +133,11 @@ def test_real_parked_settings_row_requires_confirmation_and_fresh_authority(key, def test_sng_standstill_request_withdrawal_is_exact_and_does_not_grant_axis(): from openpilot.starpilot.car.subaru.aol import sng_standstill_lateral_inhibit cp = CarInterface.get_params(CAR.SUBARU_IMPREZA_2020, gen_empty_fingerprint(), [], False, False, False) - cp.alternativeExperience = 32 + cp.alternativeExperience = 0 stopped = structs.CarState(standstill=True) assert not sng_standstill_lateral_inhibit(cp, stopped) + cp.alternativeExperience = 32 + assert sng_standstill_lateral_inhibit(cp, stopped) prepare(cp, True) assert sng_standstill_lateral_inhibit(cp, stopped) stopped.standstill = False diff --git a/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json b/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json index de4e26e592..463406e086 100644 --- a/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json +++ b/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json @@ -1,6 +1,6 @@ { - "reviewed_schema_commit": "83a90e89b4ff7e656c0964f90832b6bce6bb1407", - "reviewed_schema_sha256": "495664ec8d6e9def2419d910399efc30dc1899930a50648d11812323a5d8cdf5", + "reviewed_schema_commit": "632fa395624275e900b072309950624d36b0184f", + "reviewed_schema_sha256": "ce728d3074ad43da2f8782cedae4665c118412f5758b011350d747ea6fbb93a0", "incumbent_fixture_sha256": "a36ab54c00b5bbe61d520873d24aadfb8f33babd70c31a07046516d5545838d9", "types": { "0xf35cc4560bbf6ec2": { @@ -18,7 +18,7 @@ }, "new_storage": { "dataWordCount": 3, - "pointerCount": 5, + "pointerCount": 6, "preferredListEncoding": "inlineComposite", "isGroup": false, "discriminantCount": 0, @@ -44,6 +44,26 @@ "ordinal": { "explicit": 37 } + }, + { + "slot": { + "offset": 5, + "type": { + "struct": { + "typeId": 10457083163342666783 + } + }, + "defaultValue": { + "schemaValueHex": "00000000040000000000000002000100100000000000000000000000000000000000000000000000" + }, + "hadExplicitDefault": false + }, + "name": "toyotaCruise", + "codeOrder": 38, + "discriminantValue": 65535, + "ordinal": { + "explicit": 38 + } } ] }, @@ -61,7 +81,7 @@ "discriminantOffset": 0 }, "new_storage": { - "dataWordCount": 17, + "dataWordCount": 18, "pointerCount": 21, "preferredListEncoding": "inlineComposite", "isGroup": false, @@ -158,6 +178,24 @@ "ordinal": { "explicit": 55 } + }, + { + "slot": { + "offset": 17, + "type": { + "uint64": null + }, + "defaultValue": { + "uint64": 0 + }, + "hadExplicitDefault": false + }, + "name": "acceptedMonoTime", + "codeOrder": 56, + "discriminantValue": 65535, + "ordinal": { + "explicit": 56 + } } ] }, diff --git a/openpilot/starpilot/galaxy/bin/starpilot-companion.apk b/openpilot/starpilot/galaxy/bin/starpilot-companion.apk deleted file mode 100644 index bbdb7be465..0000000000 Binary files a/openpilot/starpilot/galaxy/bin/starpilot-companion.apk and /dev/null differ diff --git a/openpilot/starpilot/galaxy/bin/starpilot-companion.apk.version b/openpilot/starpilot/galaxy/bin/starpilot-companion.apk.version deleted file mode 100644 index 40e713d59c..0000000000 --- a/openpilot/starpilot/galaxy/bin/starpilot-companion.apk.version +++ /dev/null @@ -1 +0,0 @@ -1.1.30 diff --git a/openpilot/starpilot/galaxy/sentry_events.py b/openpilot/starpilot/galaxy/sentry_events.py index 4fe0e0b325..ee531ef394 100644 --- a/openpilot/starpilot/galaxy/sentry_events.py +++ b/openpilot/starpilot/galaxy/sentry_events.py @@ -44,6 +44,7 @@ class SentryEvents: raise ValueError("Invalid motion event") events.append({"eventId": value["eventId"], "kind": value["kind"], "systemTimeMs": value["wallTimeNs"] // 1_000_000, + **({"simulated": True} if value.get("simulated") else {}), "images": cast(Callable[[str], list], self.store.images)(value["eventId"]) if hasattr(self.store, "images") else []}) result = {"schemaVersion": 1, "source": "local", "scanIncomplete": source["incomplete"], "capacity": source["capacity"], "events": events, diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index c8baed2459..9055ba4e61 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -13,7 +13,7 @@ import socket from pathlib import Path import threading import time -from urllib.parse import parse_qs, unquote, urlsplit +from urllib.parse import parse_qs, quote, unquote, urlsplit from openpilot.common.swaglog import cloudlog from openpilot.starpilot.galaxy.batch_routes import change_drive_state, delete_records, non_default_settings, preview_layout, preview_settings_recovery @@ -158,6 +158,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe summary_lock = threading.Lock() motion_events = sentry_events if sentry_events is not None else SentryEvents() sentry_events_lock = threading.Lock() + last_simulation = [float("-inf")] notification_owner = notifications snapshots = camera_snapshot if camera_snapshot is not None else CameraSnapshot() access = owner if owner is not None else default_owner() @@ -766,6 +767,31 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe else: if self.require_session(): self.json(200, result) + elif path in ('/api/galaxy/session', '/api/galaxy/session/qr.svg'): + record = pairing.read() + remote = getattr(self.server, 'remote_transport', False) + if not self.direct_local() and not (remote and record and gateway_cookie_valid(self.session_token(), record)): + self.json(403, {'error': 'A direct connection or matching gateway cookie is required'}) + return + if not self.require_session(): + return + if record is None: + self.json(200, {'paired': False}) + return + token = quote(f"{record['slug']}:{record['session']}", safe='') + if path.endswith('/qr.svg'): + from openpilot.common.qrcode import _Qr, _capacity + raw = ('starpilot-remote://add?key=' + token).encode() + version = next(version for version in range(1, 21) if 4 + 8 + len(raw) * 8 <= _capacity(version) * 8) + matrix = _Qr(version, raw).modules + size = len(matrix) + 8 + cells = ''.join(f'' for y, row in enumerate(matrix) for x, active in enumerate(row) if active) + svg = (f'' + + f'{cells}') + self.respond(200, svg.encode(), 'image/svg+xml', cache_control='no-store') + else: + self.json(200, {'paired': True, 'slug': record['slug'], 'url': pairing.url(record['slug']), + 'cookieName': 'galaxy_session', 'sessionToken': token}) elif path == '/api/galaxy/status': if not self.require_session(): return @@ -778,16 +804,15 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe 'tunnelClientAvailable': frpc_binary() is not None, 'legacyPassword': access.status().status == AccessStatus.LEGACY_IMPORT_AVAILABLE, 'legacyPairingAvailable': legacy_available is True}) - elif path in ('/api/galaxy/qr.svg', '/api/companion/qr.svg'): + elif path == '/api/galaxy/qr.svg': if not self.require_session(): return record = pairing.read() - if record is None and path == '/api/galaxy/qr.svg': + if record is None: self.json(404, {'error': 'Galaxy is not paired'}) return from openpilot.common.qrcode import _Qr, _capacity - from openpilot.starpilot.galaxy.hotspot import ADDRESS - raw = (f"http://{ADDRESS}:8082/app.apk" if path == '/api/companion/qr.svg' or record is None else pairing.url(record['slug'])).encode() + raw = pairing.url(record['slug']).encode() version = next(version for version in range(1, 21) if 4 + 8 + len(raw) * 8 <= _capacity(version) * 8) matrix = _Qr(version, raw).modules @@ -1688,7 +1713,24 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe self.json(401, {'error': 'Sign in to Galaxy'}) return try: - result = notification_owner.action(payload, permitted=lambda: self.settings_session() == identity) + if payload.get('action') == 'simulate': + with sentry_events_lock: + if set(payload) != {'action'}: + raise ValueError('Invalid simulation request') + if not parked(): + self.json(403, {'error': 'Park the car before simulating an event'}) + return + if clock() - last_simulation[0] < 10: + self.json(429, {'error': 'Wait ten seconds before simulating another event'}) + return + notification_owner.store.record('warning', time.monotonic_ns(), simulated=True, + permitted=lambda: self.settings_session() == identity and parked()) + last_simulation[0] = clock() + result = notification_owner.snapshot() + else: + result = notification_owner.action(payload, permitted=lambda: self.settings_session() == identity) + except LookupError: + self.json(404, {'error': 'Phone subscription not found'}) except ValueError as error: self.json(400, {'error': str(error)}) except PermissionError: diff --git a/openpilot/starpilot/galaxy/settings.py b/openpilot/starpilot/galaxy/settings.py index 02a44b5a49..981d7caaa2 100644 --- a/openpilot/starpilot/galaxy/settings.py +++ b/openpilot/starpilot/galaxy/settings.py @@ -47,6 +47,18 @@ MAX_VIEWS = 64 MAX_INTENTS = 64 TTL = 60.0 +# Android Auto lists binary controls only. +CAR_CONTROL_SECTIONS = ( + ("lateral", "Lateral (Steering)", ("aol", "lane_change", "torque", "lane")), + ("longitudinal", "Longitudinal (Speed & Following)", + ("profiles", "conditional", "curve", "slc", "aggressive", "standard", "relaxed", "traffic")), + ("wheel", "Wheel Controls", ("wheel",)), + ("visual", "Visual (Display & UI)", ("appearance", "pip")), + ("sounds", "Sounds & Alerts", ("sounds",)), + ("device", "Device & Data", ("display", "data")), + ("developer", "Developer", ("developer",)), +) + class SettingsUnavailable(Exception): pass @@ -360,7 +372,7 @@ def _session_key(token: str) -> bytes: return hashlib.sha256(token.encode("ascii")).digest() -def _projection(row: FeatureRow, page: str) -> dict: +def _projection(row: FeatureRow) -> dict: return {"label": row.label, "value": row.value, "choices": list(row.choices), "step": row.step, "minimum": row.minimum, "maximum": row.maximum, "unit": row.unit, "available": row.available, "reason": description(row), @@ -629,8 +641,41 @@ class SettingsGateway: scope = hashlib.sha256(_session_key(token) + generation + (ctx.cp_raw or b"")).digest() result = {"page": page, "title": state.title, "subtitle": state.subtitle, "parked": state.parked, "vehicleIdentified": bool(ctx.cp is not None and getattr(ctx.cp, "carFingerprint", None)), - "rows": [{**_projection(row, page), "revision": hashlib.sha256(scope + repr(row).encode()).hexdigest()} + "rows": [{**_projection(row), "revision": hashlib.sha256(scope + repr(row).encode()).hexdigest()} for row in state.rows], "view": view_id} + if page == "hub" or any(row.page for row in state.rows): + rows_by_page = {page: state} + visible: dict[str, bool] = {} + + def page_state(name: str): + if name not in rows_by_page: + rows_by_page[name] = self._state(name, ctx) + return rows_by_page[name] + + def has_car_control(name: str, visited: frozenset[str] = frozenset()) -> bool: + if name in visited or name not in PAGES: + return False + if name in visible: + return visible[name] + try: + rows = page_state(name).rows + except SettingsUnavailable: + visible[name] = False + return False + found = any((len(row.choices) == 2 and set(row.choices) == {"On", "Off"}) or + has_car_control(row.page, visited | {name}) for row in rows) + visible[name] = found + return found + if page == "hub": + result["sections"] = [] + for section_id, title, names in CAR_CONTROL_SECTIONS: + pages = [{"id": name, "title": page_state(name).title} for name in names if has_car_control(name)] + if pages: + result["sections"].append({"id": section_id, "title": title, "pages": pages}) + else: + for source, projected in zip(state.rows, result["rows"], strict=False): + if source.page: + projected["carVisible"] = has_car_control(source.page) if page == "vasm": result["editor"] = editor result["editorRow"] = next((index for index, row in enumerate(state.rows) if row.key == VASM_ANNOTATION), -1) diff --git a/openpilot/starpilot/galaxy/tests/test_borrowed_authority.py b/openpilot/starpilot/galaxy/tests/test_borrowed_authority.py index ea9c1284d1..b0f1bb30d5 100644 --- a/openpilot/starpilot/galaxy/tests/test_borrowed_authority.py +++ b/openpilot/starpilot/galaxy/tests/test_borrowed_authority.py @@ -124,6 +124,7 @@ def test_actual_ui_constructor_borrows_one_collector_for_both_runtimes(): 'openpilot.starpilot.ui.runtime_app': SimpleNamespace(StarMainLayout=lambda: layout, StarMiciMainLayout=lambda: layout), 'openpilot.starpilot.controllers.runtime': SimpleNamespace(ControllerRuntime=runtime), 'openpilot.starpilot.ui.layout_preview_runtime': SimpleNamespace(LayoutPreviewRuntime=runtime), + 'openpilot.starpilot.ui.spacepilot.native': SimpleNamespace(SpacePilotPage=type('SpacePilotPage', (), {}), poll_game_mode=Mock()), } scope: dict = { 'COMMA_HARDWARE': False, diff --git a/openpilot/starpilot/galaxy/tests/test_companion_http.py b/openpilot/starpilot/galaxy/tests/test_companion_http.py index a066286ecb..6e6d1f959a 100644 --- a/openpilot/starpilot/galaxy/tests/test_companion_http.py +++ b/openpilot/starpilot/galaxy/tests/test_companion_http.py @@ -65,7 +65,7 @@ def test_auth_and_local_mutations(companion_http): status, body, _ = request('/api/companion/status', cookie=cookie) assert status == 200 and json.loads(body)['enabled'] assert b'pass' not in body - assert request('/api/companion/qr.svg', cookie=cookie)[0] == 200 + assert request('/api/companion/qr.svg', cookie=cookie)[0] == 404 def test_apk_direct_download_without_cookie_and_head(companion_http, tmp_path): diff --git a/openpilot/starpilot/galaxy/tests/test_companion_update.mjs b/openpilot/starpilot/galaxy/tests/test_companion_update.mjs index 4bd1dc3947..8bbdf33062 100644 --- a/openpilot/starpilot/galaxy/tests/test_companion_update.mjs +++ b/openpilot/starpilot/galaxy/tests/test_companion_update.mjs @@ -57,7 +57,7 @@ try { const notice = banner(host) assert.ok(notice, 'A native app without an internet-sharing lease still gets an update notice') assert.match(text(notice), /Version 1.1.5 is available.*Installed: 1.1.4/s) - assert.ok(all(notice).some(item => item.type === 'a' && item.props.href === './app.apk' && text(item).includes('Update app'))) + assert.ok(all(notice).some(item => item.type === 'a' && item.props.href === 'https://groups.google.com/g/galaxy-starpilot-android-beta' && text(item).includes('Update app'))) assert.ok(all(host).some(item => item.type === 'div' && item.props.class === 'install-state' && text(item).includes('true')), 'The main install action is told to render as an update') const root = host.children.find(item => item.type === 'div') diff --git a/openpilot/starpilot/galaxy/tests/test_home_polish.mjs b/openpilot/starpilot/galaxy/tests/test_home_polish.mjs index c1923108af..f6a840364f 100644 --- a/openpilot/starpilot/galaxy/tests/test_home_polish.mjs +++ b/openpilot/starpilot/galaxy/tests/test_home_polish.mjs @@ -13,7 +13,7 @@ assert.ok(Home.template.includes('aria-hidden="true">Galaxy Access & Install')) assert.ok(!GalaxyPage.template.includes('Galaxy & App Install')) assert.ok(GalaxyPage.template.includes('Your remote link needs Internet and an active tunnel.')) -assert.ok(GalaxyPage.template.includes('Download Android APK')) +assert.ok(GalaxyPage.template.includes('join the Android beta on Google Groups')) assert.ok(GalaxyPage.template.includes('Android offline app version:')) assert.ok(GalaxyPage.template.includes(' { apk = value } } @@ -57,15 +57,12 @@ assert.equal(apk, undefined, 'Native updates never hand the APK to a browser') delete window.StarPilotApp const android = { ...InstallApp.data() } InstallApp.methods.installAndroid.call(android) -assert.equal(apk, './app.apk') +assert.equal(apk, ANDROID_BETA_URL) assert.match(InstallApp.template, /Install web app/) -assert.match(InstallApp.template, /Download Android APK/) +assert.match(InstallApp.template, /Join the Android beta/) +assert.doesNotMatch(InstallApp.template, /app\.apk|Download Android APK/) assert.match(InstallApp.template, /Update offline app \(Android\)/) assert.match(InstallApp.template, /android && !ios/) -location.protocol = 'https:' -InstallApp.methods.installAndroid.call(android) -assert.equal(android.help, true) -assert.match(android.message, /Android app.*locally|locally.*Android app/i) // Choosing the web app on Android always keeps web-install guidance. const webHelp = InstallApp.methods.installHelp.call(android) diff --git a/openpilot/starpilot/galaxy/tests/test_local_access.mjs b/openpilot/starpilot/galaxy/tests/test_local_access.mjs index a480b14aaf..258a825278 100644 --- a/openpilot/starpilot/galaxy/tests/test_local_access.mjs +++ b/openpilot/starpilot/galaxy/tests/test_local_access.mjs @@ -66,6 +66,6 @@ console.log('Local access: actual Vue component/setup, bounded automatically syn assert.equal(GalaxyPage.components.LocalAccess, LocalAccess) const installStep = GalaxyPage.template.slice(GalaxyPage.template.indexOf('

Install Galaxy

'), GalaxyPage.template.indexOf('', GalaxyPage.template.indexOf('

Install Galaxy

'))) assert.ok(installStep.includes('')) -assert.ok(installStep.indexOf('Download Android APK') < installStep.indexOf(' value.replaceAll('&', '&') }) console.log('Galaxy APK installation exposes authenticated local links with current mode and session handling') diff --git a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs index 9647ce208f..699ee9a740 100644 --- a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs +++ b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs @@ -10,7 +10,11 @@ print(json.dumps(dict(document=default_document(), defaults=default_document(), revision="native", editable=True, valid=True, activeProfile="large", supportedProfiles=["large"]))) `], { encoding: "utf8" })) assert.equal(validSnapshot(data), true) -for (const version of [9, 10, 12]) { +for (const version of [11, 12]) { + assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), true) + assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), true) +} +for (const version of [9, 10, 13]) { assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), false) assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), false) } @@ -57,18 +61,25 @@ assert.equal(vm.layout.nav_map.opacity, 35) assert.equal(validSnapshot({ ...data, document: vm.state.draft }), true) // Use actual native geometry for source attachment, bounds, and the transient timer. +assert.deepEqual(data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent, {inline:108, card:116, actionGap:8}) vm.state.draft = structuredClone(data.document) vm.state.selected = "cruise_limits" vm.setSpeedSources("speedSources", true) -assert.equal(vm.layout.speed_limit_actions.y, 343) +assert.equal(vm.layout.speed_limit_actions.y, 359) vm.setSpeedSources("speedSourcesCard", true) -assert.equal(vm.layout.speed_limit_actions.y, 351) +assert.equal(vm.layout.speed_limit_actions.y, 367) vm.setSpeedSources("speedSources", false) assert.equal(vm.layout.speed_limit_actions.y, 267) +const overlapping = structuredClone(data.document) +overlapping.layouts.large.cruise_limits.y = 550 +assert.equal(validDocument(overlapping, data.metadata), false) +// Keep the expanded confirmation clear of the torque arc while testing the bottom edge. +vm.layout.cruise_limits.x = 1520 +vm.layout.speed_limit_actions.x = 1544 vm.layout.cruise_limits.y = 690 vm.layout.speed_limit_actions.y = 882 vm.setSpeedSources("speedSources", true) -assert.equal(vm.layout.cruise_limits.y, 606) +assert.equal(vm.layout.cruise_limits.y, 590) assert.equal(vm.layout.speed_limit_actions.y, 882) assert.equal(validDocument(vm.state.draft, data.metadata), true) Object.assign(vm.layout.speed_limit_actions, {x:600, y:500}) diff --git a/openpilot/starpilot/galaxy/tests/test_remote_pairing.py b/openpilot/starpilot/galaxy/tests/test_remote_pairing.py index 8d27e5c6c4..fa7d6056ce 100644 --- a/openpilot/starpilot/galaxy/tests/test_remote_pairing.py +++ b/openpilot/starpilot/galaxy/tests/test_remote_pairing.py @@ -58,6 +58,31 @@ class RemotePairingTest(unittest.TestCase): connection.close() return result + def test_app_key_export_requires_direct_session_or_matching_gateway_cookie(self): + for path in ('/api/galaxy/session', '/api/galaxy/session/qr.svg'): + self.assertEqual(self.request(self.local, path)[0], 401) + cookie = self.request(self.local, '/api/auth/session')[2]['Set-Cookie'].split(';', 1)[0] + code, body, headers = self.request(self.local, '/api/galaxy/session', cookie=cookie) + self.assertEqual((code, json.loads(body)), (200, {'paired': False})) + self.assertEqual(headers['Cache-Control'], 'no-store') + slug = self.pairing.pair(hashlib.sha256(b'password123').hexdigest()) + record = self.pairing.read() + assert record is not None + token = f"{slug}%3A{record['session']}" + for path in ('/api/galaxy/session', '/api/galaxy/session/qr.svg'): + self.assertEqual(self.request(self.local, path, cookie=cookie)[0], 200) + with mock.patch('openpilot.starpilot.galaxy.server.direct_local_connection', return_value=False): + self.assertEqual(self.request(self.local, path, cookie=cookie)[0], 403) + self.assertEqual(self.request(self.remote, path, host='galaxy.firestar.link', cookie=cookie)[0], 403) + self.assertEqual(self.request(self.remote, path, host='galaxy.firestar.link', cookie='galaxy_session=wrong')[0], 403) + code, body, headers = self.request(self.remote, f'/{slug}' + path, host='galaxy.firestar.link', cookie='galaxy_session=' + token) + self.assertEqual(code, 200) + self.assertEqual(headers['Cache-Control'], 'no-store') + if not path.endswith('.svg'): + self.assertEqual(json.loads(body)['sessionToken'], token) + else: + self.assertIn(b' typeof version === 'string' && /^\d+(?:\.\d+){1,3}$/.test(version) ? version.split('.').map(Number) : [] @@ -23,7 +24,7 @@ export const GalaxyCompanion = { components: { GxNotice, BluetoothNotice }, emits: ['status'], props: { mode: String, unauthorized: Function }, - data: () => ({ status: null, error: '', native: !!window.StarPilotApp, sharing: false, autoStart: false, + data: () => ({ status: null, error: '', betaUrl: ANDROID_BETA_URL, native: !!window.StarPilotApp?.uplinkState, sharing: false, autoStart: false, bluetooth: bluetoothConnection(), nativeBytes: 0, busy: false, generation: 0, alive: false }), computed: { windowAutoStart() { return !!window.StarPilotApp?.setAutoStart } }, mounted() { this.start() }, @@ -66,11 +67,12 @@ export const GalaxyCompanion = { this.busy = true try { const enabled = this.native ? !this.sharing : !this.status.enabled + if (this.native && window.StarPilotApp.setUplinkEnabled(enabled) === false) { this.sharing = false; return } const status = await requestJson('./api/companion/settings', { request: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) } }) if (!this.alive || generation !== this.generation) return this.adopt(status) - if (this.native) { window.StarPilotApp.setUplinkEnabled(enabled); this.sharing = enabled } + if (this.native) this.sharing = enabled this.error = '' } catch (e) { if (!this.alive || generation !== this.generation) return @@ -85,15 +87,10 @@ export const GalaxyCompanion = { template: `

Android companion · Connections

Phone internet requires the Galaxy hotspot. Settings and small requests remain available. -

Share your phone’s mobile data over Galaxy Wi-Fi. Navigation and online services use your mobile plan. Log uploads, software updates and offline map downloads are blocked.

-