Merge current changes for Toyota source cutoff fix

This commit is contained in:
firestar5683
2026-10-27 09:23:03 -05:00
85 changed files with 1826 additions and 269 deletions
+8
View File
@@ -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
@@ -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
@@ -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]
+19 -7
View File
@@ -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))
@@ -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,
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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):
@@ -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)):
+12 -7
View File
@@ -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
@@ -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)
@@ -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
+3
View File
@@ -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)
Binary file not shown.
Binary file not shown.
+7 -3
View File
@@ -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<size_t>(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);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<uint16_t>(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;
@@ -0,0 +1,53 @@
#include <algorithm>
#include <cstring>
#include <memory>
#include <string>
#include <vector>
#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<size_t>(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<DebugHandle> handle) : Panda(std::move(handle), cereal::PandaState::PandaType::CUATRO) {}
};
void test_debug_transport() {
auto handle = std::make_unique<DebugHandle>(); 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); }
+9 -1
View File
@@ -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
@@ -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)
@@ -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
@@ -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
}
}
]
},
@@ -1 +0,0 @@
1.1.30
@@ -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,
+48 -6
View File
@@ -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'<path d="M{x + 4} {y + 4}h1v1h-1z"/>' for y, row in enumerate(matrix) for x, active in enumerate(row) if active)
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}">' +
f'<rect width="100%" height="100%" fill="white"/><g fill="black">{cells}</g></svg>')
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:
+47 -2
View File
@@ -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)
@@ -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,
@@ -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):
@@ -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')
@@ -13,7 +13,7 @@ assert.ok(Home.template.includes('aria-hidden="true"><svg'))
assert.ok(GalaxyPage.template.includes('>Galaxy Access & Install</h2>'))
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('<GalaxyHotspot'))
assert.ok(GalaxyPage.template.includes('<GalaxyCompanion v-if="!ios"'), 'The Android companion section is not mounted on iOS')
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict'
import { androidInstallHelp, installHelp, InstallApp, rememberInstallPrompt, isIosDevice, isAndroidDevice } from '../web/js/install-app.js'
import { ANDROID_BETA_URL, installHelp, InstallApp, rememberInstallPrompt, isIosDevice, isAndroidDevice } from '../web/js/install-app.js'
assert.match(installHelp({ secure: true, agent: 'iPhone' }), /Share.*Add to Home Screen/)
assert.match(installHelp({ secure: false, agent: 'Android' }), /web app.*https:\/\/galaxy\.firestar\.link/)
assert.match(installHelp({ secure: true, agent: 'Android' }), /Install app/)
assert.match(androidInstallHelp(), /download the Android app.*locally|locally.*download the Android app/i)
assert.equal(ANDROID_BETA_URL, 'https://groups.google.com/g/galaxy-starpilot-android-beta')
assert.match(installHelp({ secure: true, agent: 'Chrome' }), /Install app/)
const events = new Map()
let modeListener
@@ -44,7 +44,7 @@ await InstallApp.methods.installWeb.call(late)
assert.equal(prompted, 2)
InstallApp.beforeUnmount.call(late)
// Android offers the local APK; tunnel pages explain how to connect locally.
// Android sends users to the Google Groups beta instead of a direct APK download.
navigator.userAgent = 'Android'
let apk
globalThis.location = { protocol: 'http:', assign: value => { 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)
@@ -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('<h3>Install Galaxy</h3>'), GalaxyPage.template.indexOf('</li>', GalaxyPage.template.indexOf('<h3>Install Galaxy</h3>')))
assert.ok(installStep.includes('<LocalAccess v-if="!ios" :mode="mode" :on-unauthorized="unauthorized" />'))
assert.ok(installStep.indexOf('Download Android APK') < installStep.indexOf('<LocalAccess'))
assert.ok(installStep.indexOf('Android users:') < installStep.indexOf('<LocalAccess'))
compile(GalaxyPage.template, { decodeEntities: value => value.replaceAll('&amp;', '&') })
console.log('Galaxy APK installation exposes authenticated local links with current mode and session handling')
@@ -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})
@@ -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'<svg', body)
def test_gateway_session_cannot_access_routes(self):
self.assertTrue(self.owner.configure('password123', lambda: True))
slug = self.pairing.pair(hashlib.sha256(b'password123').hexdigest())
@@ -12,6 +12,43 @@ from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
class NotificationHTTPTests(unittest.TestCase):
def test_app_subscription_test_and_simulation(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
owner = NotificationOwner(root / 'notifications')
from openpilot.starpilot.sentry_mode.storage import EventStore
owner.store = EventStore(root / 'events')
parked = [True]
server = make_server(port=0, owner=GalaxyAccessOwner(root / 'access'), notifications=owner, parked=lambda: parked[0])
thread = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': .01}, daemon=True)
thread.start()
def request(payload=None, cookie=''):
connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3)
headers = {'Cookie': cookie, 'Origin': f'http://127.0.0.1:{server.server_port}', 'Content-Type': 'application/json'}
connection.request('POST' if payload is not None else 'GET', '/api/sentry/notifications' if payload is not None else '/api/auth/session',
json.dumps(payload) if payload is not None else None, headers)
reply = connection.getresponse()
result = reply.status, json.loads(reply.read()), dict(reply.getheaders())
connection.close()
return result
try:
cookie = request()[2]['Set-Cookie'].split(';', 1)[0]
code, result, _ = request({'action': 'appSubscribe', 'token': 'test-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}, cookie)
self.assertEqual(code, 200)
self.assertNotIn('test-token', json.dumps(result))
self.assertEqual(request({'action': 'test', 'target': 'app', 'id': 'missing'}, cookie)[0], 404)
self.assertEqual(request({'action': 'test', 'target': 'app', 'id': result['appSubscriptions'][0]['id']}, cookie)[0], 200)
parked[0] = False
self.assertEqual(request({'action': 'simulate'}, cookie)[0], 403)
parked[0] = True
self.assertEqual(request({'action': 'simulate'}, cookie)[0], 200)
self.assertTrue(owner.store.snapshot()['events'][0]['simulated'])
self.assertEqual(request({'action': 'simulate'}, cookie)[0], 429)
finally:
server.shutdown()
thread.join(timeout=2)
server.server_close()
def test_auth_origin_secret_redaction_and_session_change(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
@@ -96,7 +96,7 @@ def test_actual_owner_projections_have_no_uncovered_setting(tmp_path):
for row in rows:
if row.key:
assert purpose(row), (page, row.key)
assert _projection(row, page)['reason'].startswith(purpose(row))
assert _projection(row)['reason'].startswith(purpose(row))
check(DeveloperSettingsOwner(params, lambda: True).snapshot().rows, 'developer')
@@ -17,7 +17,7 @@ from opendbc.car.toyota.interface import CarInterface as ToyotaInterface
from opendbc.car.toyota.values import CAR as ToyotaCar
from openpilot.common.params import Params
from openpilot.starpilot.galaxy.settings import (
AuthorityContext, LiveContextSource, SettingsChanged, SettingsGateway,
AuthorityContext, LiveContextSource, PAGES, SettingsChanged, SettingsGateway,
)
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.server import make_server
@@ -247,10 +247,17 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertFalse(self.params.get_bool("SpeedLimitController"))
def test_all_saved_groups_are_display_only_projection(self):
self.context.value = replace(self.context.value,
cp=ToyotaInterface.get_non_essential_params(ToyotaCar.TOYOTA_COROLLA_TSS2))
hub = self.page("hub")
self.assertTrue(hub["sections"])
self.assertTrue(all(section["pages"] and all(page["id"] in PAGES for page in section["pages"])
for section in hub["sections"]))
lateral = next(section for section in hub["sections"] if section["id"] == "lateral")
self.assertEqual(next(page for page in lateral["pages"] if page["id"] == "aol")["title"], self.page("aol")["title"])
self.assertEqual([row["page"] for row in hub["rows"]],
["curve", "slc", "lane", "lane_change", "profiles", "conditional", "torque", "aol", "wheel",
"aggressive", "standard", "relaxed", "traffic"])
"vehicle", "aggressive", "standard", "relaxed", "traffic"])
for page in ("slc", "lane", "lane_change", "profiles", "conditional", "conditional/cem", "conditional/ccm",
"curve", "torque", "aol", "appearance", "display", "sounds", "pip", "sentry", "aggressive/following"):
with self.subTest(page=page):
@@ -258,6 +265,8 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertTrue(result["rows"])
for row in result["rows"]:
self.assertFalse({"source", "related_source", "dependencies", "capability", "vehicle_fingerprint"} & row.keys())
if row["page"]:
self.assertIsInstance(row["carVisible"], bool)
def test_appearance_confirmation_is_source_and_session_bound_without_cp(self):
from openpilot.starpilot.ui.appearance_preferences import read_visibility
@@ -268,7 +277,7 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertEqual([rows[label][1]["value"] for label in
("Hide Personality Alerts", "Wheel brake and acceleration colors", "C4 amber signal border", "C4 red blind-spot border")],
["Off", "Off", "Off", "On"])
# Widget visibility, the stopped timer and Rainbow Road are edited with the layout instead.
# Layout owns widgets, stopped timer and Rainbow Road.
self.assertFalse({"Stopped Timer", "Show Torque Bar", "Rainbow Road", "Hide Driver Monitoring Icon"} & rows.keys())
self.assertFalse(rows["C4 amber signal border"][1]["confirm"])
intent = self.gateway.preview(page["view"], rows["C4 amber signal border"][0], 1, self.session, self.generation)
@@ -457,7 +466,7 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertIn("accepts", confirmation["reason"])
with mock.patch.dict(os.environ, {"SLC_REPLAY_RUNTIME": "0", "SLC_VISION_DEVELOPMENT": "0"}):
# A normal finalized car offers Vision without development/replay flags.
# Finalized Vision needs no development/replay flags.
page = self.page("slc")
index = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Primary source")
intent = self.gateway.preview(page["view"], index, 1, self.session, self.generation)
@@ -483,8 +492,7 @@ class SettingsGatewayTest(unittest.TestCase):
self.gateway.confirm(intent["intent"], self.session, self.generation)
self.assertEqual(read_saved(self.params, "SLCPriority1", 128)[0], b"Dashboard")
# Source choice is display access on a native stock-ACC car, never a
# grant of longitudinal control from a remembered controller switch.
# Stock-ACC display choice never grants LONG from a saved switch.
from openpilot.starpilot.feature_runtime import slc_runtime_settings, vision_control_enabled
stock = HondaInterface.get_params(HondaCAR.HONDA_CIVIC_BOSCH, gen_empty_fingerprint(), [], False, False, False)
self.assertFalse(stock.openpilotLongitudinalControl)
@@ -1204,7 +1212,7 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertIsNone(self.params.get("LaneCenteringE2EAuthority"))
def test_uploads_missing_default_off_saved_values_and_vehicle_independence(self):
# Owner/projection contract; native key registration is qualified separately.
# Native keys are qualified separately.
root = Path(self.params.get_param_path("IsMetric")).parent
class Files:
def get_param_path(self, key):
@@ -1343,7 +1351,7 @@ class LiveContextTest(unittest.TestCase):
self.messages.updated["deviceState"] = False
self.boot += 1_600_000_000
self.assertFalse(self.source.sample().parked) # Python mono stamp ages against boot after resume.
# A queued pre-resume frame must remain blocked even if received again.
# Re-received pre-resume frames stay blocked.
self.messages.updated["deviceState"] = True
self.assertFalse(self.source.sample().parked)
self.mono += 30_000_000
@@ -1357,7 +1365,7 @@ class LiveContextTest(unittest.TestCase):
self.assertFalse(self.source.sample().parked)
def test_startup_queued_frame_and_unbounded_clock_pair(self):
# Reconstruct at current time so the already queued frame predates startup.
# Queued frames predate reconstruction.
source = LiveContextSource(self.params, self.messages, mono_clock=lambda: self.mono,
boot_clock=lambda: self.boot, evidence_wait_ms=0)
self.assertFalse(source.sample().parked)
@@ -109,6 +109,13 @@ class TestSettingsDefaults(TestCase):
self.gateway.confirm(intent['intent'], self.session, self.generation, session_valid=lambda: False)
def test_no_defaults_on_navigation_or_unavailable_feature(self):
cp = fixtures.ToyotaInterface.get_non_essential_params(fixtures.ToyotaCar.TOYOTA_COROLLA_TSS2)
for name, value in vars(self.context.value.cp).items():
if name not in ("lateralTuning", "safetyConfigs"):
setattr(cp, name, value)
self.assertEqual(cp.lateralTuning.to_dict(), self.context.value.cp.lateralTuning.to_dict())
self.assertEqual([row.to_dict() for row in cp.safetyConfigs], [row.to_dict() for row in self.context.value.cp.safetyConfigs])
self.context.value = replace(self.context.value, cp=cp)
for row in self.page('hub')['rows']:
self.assertIsNone(row['defaultValue'])
self.assertFalse(row['resetAvailable'])
@@ -1,6 +1,7 @@
import { requestJson } from './startup.js'
import { GxNotice } from './notice.js'
import { BluetoothNotice, bluetoothConnection } from './bluetooth-notice.js'
import { ANDROID_BETA_URL } from './install-app.js'
export function newerCompanionVersion(available, installed) {
const parts = version => 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: `<section class="gx-card gx-panel gx-stack"><h3>Android companion · Connections</h3>
<p v-if="native"><button class="gx-btn gx-btn--tonal" @click="connectionOptions">Bluetooth, Galaxy Wi-Fi or LAN</button></p>
<BluetoothNotice v-if="bluetooth" feature="Media transfers and phone internet">Phone internet requires the Galaxy hotspot. Settings and small requests remain available.</BluetoothNotice>
<p v-else>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.</p>
<template v-if="!native"><p>Join TheGalaxy Wi-Fi, download the Android app, then allow your browser to install unknown apps when Android asks.</p>
<a v-if="status?.appAvailable && mode === 'local'" class="gx-btn" href="./app.apk">Download Android app</a>
<p v-else class="gx-note">Connect locally to a comma with the companion APK installed to download.</p>
<img v-if="status?.appAvailable && mode === 'local'" src="./api/companion/qr.svg" class="gx-tunnel__qr" alt="Download app over Galaxy Wi-Fi" />
<template v-if="!native"><p>Join the Android beta on Google Groups to get the app, then join TheGalaxy Wi-Fi.</p>
<a class="gx-btn" :href="betaUrl" target="_blank" rel="noopener">Join the Android beta</a>
</template>
<template v-if="status && mode === 'local'"><p>Status: <strong>{{ status.state }}</strong> {{ status.reason }} · {{ ((native ? nativeBytes : status.bytes) / 1048576).toFixed(1) }} MB today</p>
<button v-if="!bluetooth" class="gx-btn gx-btn--tonal" :disabled="busy" @click="toggle">{{ (native ? sharing : status.enabled) ? 'Disable phone internet' : 'Enable phone internet' }}</button>
<label v-if="!bluetooth && native && !!windowAutoStart"><input type="checkbox" v-model="autoStart" @change="setAutoStart" /> Resume sharing after phone restart (keeps a notification while waiting for Galaxy Wi-Fi)</label>
</template><GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
</section>`,
}
+24 -5
View File
@@ -1,7 +1,7 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
import { InstallApp, isIosDevice } from "./install-app.js"
import { ANDROID_BETA_URL, InstallApp, isIosDevice } from "./install-app.js"
import { GalaxyCompanion, newerCompanionVersion } from "./companion.js"
import { GalaxyHotspot } from "./hotspot.js"
import { LocalAccess } from "./local-access.js"
@@ -15,14 +15,15 @@ export const GalaxyPage = {
components: { GxState, GxNotice, BluetoothNotice, InstallApp, GalaxyHotspot, GalaxyCompanion, LocalAccess },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
appKey: '', keyVisible: false, betaUrl: ANDROID_BETA_URL, legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
bluetooth: bluetoothConnection(), nativeVersion: window.StarPilotApp?.version?.() || '', dismissedUpdateVersion: dismissedUpdate(),
ios: isIosDevice() }),
computed: {
availableAppVersion() { return this.companionStatus?.appAvailable ? this.companionStatus.shippedVersion : '' },
installedAppVersion() { return this.nativeVersion || this.companionStatus?.version || '' },
playDistribution() { return window.StarPilotApp?.distribution?.() === 'play' },
appUpdateAvailable() {
return this.mode === 'local' && newerCompanionVersion(this.availableAppVersion, this.installedAppVersion)
return !this.playDistribution && this.mode === 'local' && newerCompanionVersion(this.availableAppVersion, this.installedAppVersion)
},
nativeInstallerAvailable() { return typeof window.StarPilotApp?.installUpdate === 'function' },
showAppUpdate() {
@@ -31,6 +32,14 @@ export const GalaxyPage = {
},
mounted() { this.load() },
methods: {
async showAppKey() {
const url = this.url
try { const data = await this.request('./api/galaxy/session'); if (this.url !== url || !this.paired) return; this.appKey = data.paired ? decodeURIComponent(data.sessionToken) : ''; this.keyVisible = !!this.appKey }
catch (error) { this.error = error.message }
},
async copyAppKey() {
try { await navigator.clipboard.writeText(this.appKey) } catch { this.error = 'Select and copy the displayed key.' }
},
installAppUpdate() { window.StarPilotApp?.installUpdate?.() },
dismissAppUpdate() {
this.dismissedUpdateVersion = this.availableAppVersion
@@ -65,6 +74,8 @@ export const GalaxyPage = {
const data = await this.request("./api/galaxy/pair", { password: this.password })
this.paired = data.paired
this.url = data.url
this.appKey = ''
this.keyVisible = false
this.password = ""
} catch (error) { this.error = error.message }
finally { this.busy = false }
@@ -77,6 +88,8 @@ export const GalaxyPage = {
await this.request("./api/galaxy/unpair", {})
this.paired = false
this.url = ""
this.appKey = ''
this.keyVisible = false
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
@@ -84,6 +97,7 @@ export const GalaxyPage = {
template: `
<div class="gx-settings gx-tunnel" :inert="busy" :aria-busy="busy || undefined"><header class="gx-settings__header gx-page-header"><div><h2>Galaxy Access & Install</h2>
<p>Pair your comma, then install Galaxy from your remote link.</p></div></header>
<p v-if="playDistribution" class="gx-note">Updates come from Google Play.</p>
<BluetoothNotice v-if="showAppUpdate && bluetooth && !ios" :feature="'Android app update ' + availableAppVersion">
Installed: {{ installedAppVersion }}.
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button>
@@ -91,7 +105,7 @@ export const GalaxyPage = {
<GxNotice v-else-if="showAppUpdate && !ios" tone="warn" title="Android app update available">
Version {{ availableAppVersion }} is available. Installed: {{ installedAppVersion }}.
<template #actions><button v-if="nativeInstallerAvailable" type="button" class="gx-btn" @click="installAppUpdate">Update app</button>
<a v-else class="gx-btn" href="./app.apk">Update app</a>
<a v-else class="gx-btn" :href="betaUrl" target="_blank" rel="noopener">Update app</a>
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button></template>
</GxNotice>
<GxState v-if="mode !== 'local'">Connect to your comma to set up remote access.</GxState>
@@ -106,6 +120,11 @@ export const GalaxyPage = {
<BluetoothNotice v-if="bluetooth" feature="Galaxy QR code">Your Galaxy link remains available below.</BluetoothNotice>
<img v-else src="./api/galaxy/qr.svg" alt="QR code for your Galaxy link" class="gx-tunnel__qr" />
<p><a :href="url" target="_blank" rel="noopener" class="gx-btn gx-btn--tonal gx-wrap">Open your Galaxy link</a></p>
<section class="gx-card"><h3>App key</h3><p>Keep this key private. It grants remote access to this comma.</p>
<button v-if="!keyVisible" class="gx-btn" @click="showAppKey">Show</button>
<template v-else><code class="gx-wrap">{{ appKey }}</code><button class="gx-btn" @click="copyAppKey">Copy</button>
<img src="./api/galaxy/session/qr.svg" class="gx-tunnel__qr" alt="Add remote access to Galaxy" /></template>
</section>
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--danger" @click="unpair">{{ busy ? 'Unpairing…' : 'Unpair' }}</button></div>
</template>
<template v-else>
@@ -123,7 +142,7 @@ export const GalaxyPage = {
<div><h3>Install Galaxy</h3><p>Launch Galaxy from your home screen or desktop.</p></div></div>
<div class="gx-aa-step__body"><InstallApp :url="url" :update-available="appUpdateAvailable" />
<p v-if="availableAppVersion && !ios">Android offline app version: <strong>{{ availableAppVersion }}</strong><span v-if="installedAppVersion"> · {{ nativeVersion ? 'Installed' : 'Connected phone' }}: {{ installedAppVersion }}</span></p>
<p v-if="!ios"><strong>Download Android APK</strong> to use Galaxy over Galaxy Wi-Fi or your local network without Internet. Download it while connected locally from this page.</p>
<p v-if="!ios"><strong>Android users:</strong> join the Android beta on Google Groups to get the app for use over Galaxy Wi-Fi or your local network without Internet.</p>
<LocalAccess v-if="!ios" :mode="mode" :on-unauthorized="unauthorized" />
<p class="gx-note">Your remote link needs Internet and an active tunnel. For offline local access, bookmark your hotspot or local address.</p>
</div>
@@ -18,22 +18,20 @@ export function installHelp({ secure = window.isSecureContext, agent = navigator
return 'This browser cannot install Galaxy automatically. Open the browser menu and choose Install app or Add to Home Screen. On a Mac in Safari, choose File → Add to Dock.'
}
export function androidInstallHelp() {
return 'Connect locally to your comma, then download the Android app from Galaxy Access & Install. Allow your browser to install unknown apps when Android asks.'
}
export const ANDROID_BETA_URL = 'https://groups.google.com/g/galaxy-starpilot-android-beta'
export const InstallApp = {
name: 'InstallApp',
props: { url: { type: String, default: '' }, updateAvailable: { type: Boolean, default: false } },
data: () => ({ prompt: null, installed: false, help: false, message: '',
data: () => ({ prompt: null, installed: false, message: '', betaUrl: ANDROID_BETA_URL,
android: isAndroidDevice(), ios: isIosDevice() }),
computed: { onPublicGalaxy() { return window.location?.hostname === 'galaxy.firestar.link' } },
computed: { playDistribution() { return window.StarPilotApp?.distribution?.() === 'play' }, onPublicGalaxy() { return window.location?.hostname === 'galaxy.firestar.link' } },
mounted() {
this.prompt = deferredPrompt
this.displayMode = window.matchMedia('(display-mode: standalone)')
this.updateInstalled = () => { this.installed = !!window.StarPilotApp || this.displayMode.matches || navigator.standalone === true }
this.onPrompt = (event) => { event.preventDefault(); deferredPrompt = event; this.prompt = event; this.help = false }
this.onInstalled = () => { this.installed = true; this.prompt = deferredPrompt = null; this.help = false }
this.onPrompt = (event) => { event.preventDefault(); deferredPrompt = event; this.prompt = event }
this.onInstalled = () => { this.installed = true; this.prompt = deferredPrompt = null }
this.updateInstalled()
window.addEventListener('beforeinstallprompt', this.onPrompt)
window.addEventListener('appinstalled', this.onInstalled)
@@ -61,8 +59,7 @@ export const InstallApp = {
window.StarPilotApp.installUpdate()
return
}
if (location.protocol === 'http:') location.assign('./app.apk')
else { this.message = androidInstallHelp(); this.help = true }
location.assign(ANDROID_BETA_URL)
},
},
template: `<div class="gx-install-app">
@@ -75,7 +72,8 @@ export const InstallApp = {
<a v-else-if="url" class="gx-btn" :href="url" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> Install web app</a>
<p v-else class="gx-note">Pair in step 1 to get your install link.</p>
<a v-if="ios" class="gx-btn" href="https://apps.apple.com/ca/app/starpilot-bt/id6821039119" target="_blank" rel="noopener"><i class="bi bi-apple" aria-hidden="true"></i> Download StarPilot BT on the App Store</a>
<button v-if="android && !ios && (!installed || updateAvailable)" type="button" class="gx-btn" @click="installAndroid"><i class="bi bi-download" aria-hidden="true"></i><span>{{ updateAvailable ? 'Update offline app (Android)' : 'Download Android APK' }}</span></button>
<p v-if="help" class="gx-note gx-install-help">{{ message }}</p>
<button v-if="android && !ios && !playDistribution && updateAvailable" type="button" class="gx-btn" @click="installAndroid"><i class="bi bi-download" aria-hidden="true"></i><span>Update offline app (Android)</span></button>
<a v-else-if="android && !ios && !playDistribution && !installed" class="gx-btn" :href="betaUrl" target="_blank" rel="noopener"><i class="bi bi-google-play" aria-hidden="true"></i><span>Join the Android beta</span></a>
<p v-if="playDistribution" class="gx-note">Updates come from Google Play.</p>
</div>`,
}
@@ -61,7 +61,7 @@ export const SentryNotifications = {
components: { GxNotice },
name: "SentryNotifications",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ channels, labels, notifications: null, notificationBusy: false, notificationError: "",
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', channels, labels, notifications: null, notificationBusy: false, notificationError: "",
drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }),
created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
@@ -69,11 +69,26 @@ export const SentryNotifications = {
document.addEventListener("visibilitychange", this.visibility)
this.start()
},
beforeUnmount() { this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) },
beforeUnmount() { clearInterval(this.testPoll); this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) },
methods: {
reportSave,
refreshPush() { try { this.pushState = JSON.parse(window.StarPilotApp?.sentryPush?.() || '{}') } catch { this.pushState = {} } },
togglePhone() { window.StarPilotApp?.setSentryPush?.(!this.pushState.enabled); this.refreshPush(); setTimeout(() => this.client.run(), 1000) },
async testPhone(id) {
if (!await this.client.run({ action: 'test', target: 'app', id })) return
clearInterval(this.testPoll)
let attempts = 0
this.testPoll = setInterval(() => { if (++attempts >= 15) { clearInterval(this.testPoll); this.testPoll = null } if (!this.client.pending) this.client.run() }, 1000)
},
removePhone(id) { return this.client.run({ action: 'appUnsubscribe', id }) },
simulate() { return this.client.run({ action: 'simulate' }) },
canSimulate() { return !!window.StarPilotApp || location.protocol === 'http:' },
async saveRelay() {
if (await this.client.run({ action: 'configure', channel: 'appPush', enabled: true, url: this.relayUrl, token: '' })) this.relayUrl = ''
},
start() {
clearInterval(this.poll)
this.refreshPush()
if (this.mode !== "local" || document.hidden) return
this.client.run()
this.poll = setInterval(() => { if (!this.notificationBusy && !this.client.pending) this.client.run() }, 5000)
@@ -120,6 +135,20 @@ export const SentryNotifications = {
<GxNotice tone="danger" v-if="notifications?.queueFull">The notification queue is full. Some events could not be queued.</GxNotice>
<template v-if="notifications">
<p class="gx-note">{{ notifications.deliverySemantics }}</p>
<section class="gx-card gx-panel gx-stack">
<h3>Galaxy app phones</h3>
<label>HTTPS push relay URL <input v-model="relayUrl" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
<button type="button" class="gx-btn gx-btn--tonal" @click="saveRelay">Save push relay</button>
<p v-if="!notifications.appPushAvailable" class="gx-note">Configure a push relay to deliver phone alerts.</p>
<button v-if="nativePush" type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="togglePhone">{{ pushState.enabled ? 'Disable phone alerts' : 'Enable phone alerts' }}</button>
<button v-if="notifications.appPushAvailable && pushState.registered && pushState.id" type="button" class="gx-btn" @click="testPhone(pushState.id)">Send test to this phone</button>
<p v-for="phone in (notifications.appSubscriptions || [])" :key="phone.id">{{ phone.label }} · {{ phone.lastResult || 'Not tested' }} · {{ phone.lastAt ? new Date(phone.lastAt * 1000).toLocaleString() : '' }}
<button type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="testPhone(phone.id)">Test</button><button type="button" class="gx-btn" @click="removePhone(phone.id)">Remove</button>
</p>
<p v-for="phone in (notifications.removedAppSubscriptions || [])" :key="phone.id">{{ phone.lastResult }}</p>
<button v-if="canSimulate()" class="gx-btn" @click="simulate">Simulate event</button>
</section>
<button class="gx-btn" @click="client.run({action: 'test', target: 'all'})">Test all channels</button>
<section v-for="name in channels" :key="name" class="gx-card gx-sentry-channel">
<div class="gx-section__header"><i class="bi" :class="{webPush: 'bi-bell', discord: 'bi-discord', webhook: 'bi-link-45deg', ntfy: 'bi-broadcast'}[name]" aria-hidden="true"></i><span class="gx-section__title">{{ labels[name] }}</span></div>
<div class="gx-panel gx-stack"><div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">Enabled</span>
+2 -1
View File
@@ -16,7 +16,8 @@ export function validSentryEvents(value) {
if (value.storageIncomplete !== undefined && typeof value.storageIncomplete !== "boolean") return false
const seen = new Set()
return value.events.every((event) => {
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images"].includes(key)) ||
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images", "simulated"].includes(key)) ||
(event.simulated !== undefined && event.simulated !== true) ||
(event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 ||
new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) ||
typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) ||
+4 -4
View File
@@ -1,13 +1,13 @@
# Sentry motion events
This is the parked motion-event part of Sentry. Galaxy can list local event
metadata at `#/cameras/events`. Camera capture, image galleries, notifications
and remote access are not implemented here. Motion detection has
metadata and captured images at `#/cameras/events`. Galaxy supports browser push,
Discord, webhooks, ntfy, and optional Firebase app push through the companion relay. Motion detection has
not yet been qualified on a physical device or vehicle.
The manager runs it only on device hardware when both
`STARPILOT_SENTRY_DEVELOPMENT=1` and a valid enabled `SentryMotionPreferences`
document are present. Ordinary startup is unchanged. The same manager-owned
the vehicle is parked and a valid enabled `SentryMotionPreferences`
document is present. The same manager-owned
`sensord` supplies acceleration onroad and, under that explicit opt-in, while
parked. Sentry never starts another sensor, camera or power service.
@@ -23,7 +23,9 @@ from openpilot.starpilot.storage import starpilot_storage_root
from openpilot.starpilot.sentry_mode.storage import EventStore
from openpilot.starpilot.sentry_mode import web_push
CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy')
CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush')
APP_PUSH_RELAY_URL = ''
APP_IDS = ('com.starpilot.galaxy', 'link.firestar.companion', 'link.galaxy.bluetooth.natehuby76')
MAX_STATE_BYTES = 1024 * 1024
MAX_JOBS = 512
MAX_AGE = 86400
@@ -52,7 +54,7 @@ def endpoint(value: object, *, allow_http: bool = False) -> str:
def fresh_state() -> dict:
return {'version': 1, 'channels': {channel: {'enabled': False, 'url': '', 'token': '', 'generation': uuid.uuid4().hex,
'since': 0} for channel in CHANNELS},
'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
def wall_seconds() -> float:
@@ -136,6 +138,10 @@ class NotificationOwner:
result[key] = value
return result
state = json.loads(raw, object_pairs_hook=unique)
if type(state) is dict and 'appSubscriptions' not in state:
state['appSubscriptions'] = []
if type(state.get('channels')) is dict:
state['channels']['appPush'] = fresh_state()['channels']['appPush']
self._validate_state(state)
self.state = state
self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode()
@@ -161,8 +167,18 @@ class NotificationOwner:
raise ValueError('Invalid notification channel')
if channel['url']:
endpoint(channel['url'], allow_http=self.allow_http)
if name != 'webPush' and channel['enabled'] and not channel['url']:
if name not in ('webPush', 'appPush') and channel['enabled'] and not channel['url']:
raise ValueError('Missing notification endpoint')
if type(state['appSubscriptions']) is not list or len(state['appSubscriptions']) > 8:
raise ValueError('Invalid app subscriptions')
for sub in state['appSubscriptions']:
if (type(sub) is not dict or set(sub) != {'id', 'token', 'appId', 'label', 'addedAt', 'since', 'lastResult', 'lastAt'} or
type(sub['token']) is not str or not 1 <= len(sub['token']) <= 4096 or any(ord(c) <= 32 for c in sub['token']) or
sub['id'] != hashlib.sha256(sub['token'].encode()).hexdigest()[:12] or sub['appId'] not in APP_IDS or
type(sub['label']) is not str or len(sub['label']) > 64 or
type(sub['lastResult']) is not str or len(sub['lastResult']) > 64 or
any(type(sub[key]) not in (int, float) or not 0 <= sub[key] < 2**63 for key in ('addedAt', 'since', 'lastAt'))):
raise ValueError('Invalid app subscription')
for sub in state['subscriptions']:
if type(sub) is not dict or set(sub) != {'id', 'subscription', 'since'} or type(sub['id']) is not str or ID.fullmatch(sub['id']) is None:
raise ValueError('Invalid browser subscription')
@@ -176,7 +192,8 @@ class NotificationOwner:
type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or
job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or
type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or
(type(job['subscriptionId']) is not str or (job['subscriptionId'] and ID.fullmatch(job['subscriptionId']) is None)) or
(type(job['subscriptionId']) is not str or (re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else
r'[0-9a-f]{32}' if job['channel'] == 'webPush' else r'', job['subscriptionId']) is None)) or
type(job['attempts']) is not int or not 0 <= job['attempts'] <= MAX_ATTEMPTS or
job['state'] not in ('queued', 'sending', 'sent', 'failed', 'cancelled') or
job['error'] not in ('', 'http', 'expired', 'timeout', 'transport', 'delivery_unknown', 'stale') or
@@ -219,6 +236,12 @@ class NotificationOwner:
'subscriptionCount': len(self.state['subscriptions']),
'subscriptions': [{'id': sub['id']} for sub in self.state['subscriptions']],
'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'}
result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')}
for sub in self.state['appSubscriptions']]
result['removedAppSubscriptions'] = [
{'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']}
for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:]
result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL)
for name, cfg in self.state['channels'].items():
relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']]
latest = max(relevant, key=lambda j: j['created'], default=None)
@@ -272,6 +295,8 @@ class NotificationOwner:
self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']]
if payload['channel'] == 'webPush':
self.state['subscriptions'] = []
if payload['channel'] == 'appPush':
self.state['appSubscriptions'] = []
elif action == 'pushKey':
if set(payload) != {'action'}:
raise ValueError('Invalid push key request')
@@ -295,6 +320,41 @@ class NotificationOwner:
if set(payload) != {'action', 'id'} or type(payload['id']) is not str or ID.fullmatch(payload['id']) is None:
raise ValueError('Choose a browser subscription')
self.state['subscriptions'] = [s for s in self.state['subscriptions'] if s['id'] != payload['id']]
elif action == 'appSubscribe':
if (set(payload) != {'action', 'token', 'appId', 'label'} or type(payload['token']) is not str or
not 1 <= len(payload['token']) <= 4096 or any(ord(c) <= 32 for c in payload['token']) or
type(payload['appId']) is not str or payload['appId'] not in APP_IDS or
type(payload['label']) is not str or len(payload['label']) > 64):
raise ValueError('Invalid app subscription')
current = self.state['appSubscriptions']
existing = next((sub for sub in current if sub['token'] == payload['token']), None)
if existing:
existing.update(appId=payload['appId'], label=payload['label'])
elif len(current) < 8:
current.append({'id': hashlib.sha256(payload['token'].encode()).hexdigest()[:12], 'token': payload['token'],
'appId': payload['appId'], 'label': payload['label'], 'addedAt': now, 'since': now,
'lastResult': '', 'lastAt': 0})
else:
raise ValueError('Remove a phone before adding another')
cfg = self.state['channels']['appPush']
cfg['enabled'] = bool(cfg['url'] or APP_PUSH_RELAY_URL)
elif action == 'appUnsubscribe':
if set(payload) not in ({'action', 'token'}, {'action', 'id'}):
raise ValueError('Choose a phone subscription')
key = 'token' if 'token' in payload else 'id'
if type(payload[key]) is not str:
raise ValueError('Choose a phone subscription')
self.state['appSubscriptions'] = [sub for sub in self.state['appSubscriptions'] if sub[key] != payload[key]]
elif action == 'test' and 'channel' not in payload:
if set(payload) - {'action', 'target', 'id'} or payload.get('target', 'all') not in ('all', 'app'):
raise ValueError('Choose a test target')
target_id = payload.get('id')
if payload.get('target') == 'app' and not any(sub['id'] == target_id for sub in self.state['appSubscriptions']):
raise LookupError('Phone subscription not found')
if sum(j['kind'] == 'test' and j['created'] > now - 60 for j in self.state['jobs'].values()) >= 32:
raise ValueError('Wait a minute before sending more tests')
self._enqueue({'eventId': uuid.uuid4().hex, 'kind': 'test', 'wallTimeNs': int(now * 1e9)},
only='appPush' if payload.get('target') == 'app' else None, target_id=target_id)
elif action == 'test':
if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS:
raise ValueError('Choose a test channel')
@@ -314,15 +374,18 @@ class NotificationOwner:
self._save()
return self.snapshot()
def _enqueue(self, event: dict, *, only: str | None = None) -> None:
def _enqueue(self, event: dict, *, only: str | None = None, target_id: str | None = None) -> None:
if event['kind'] == 'selfie':
return
now = self.clock()
for name, cfg in self.state['channels'].items():
if not cfg['enabled'] or (only is not None and only != name):
continue
targets = self.state['subscriptions'] if name == 'webPush' else [{'id': '', 'since': cfg['since']}]
targets = (self.state['subscriptions'] if name == 'webPush' else self.state['appSubscriptions']
if name == 'appPush' else [{'id': '', 'since': cfg['since']}])
for target in targets:
if target_id is not None and target['id'] != target_id:
continue
if event['kind'] != 'test' and event['wallTimeNs'] / 1e9 <= max(cfg['since'], target['since']):
continue
key = hashlib.sha256((event['eventId'] + name + cfg['generation'] + target['id']).encode()).hexdigest()
@@ -372,7 +435,9 @@ class NotificationOwner:
continue
cfg = self.state['channels'][job['channel']]
sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None)
if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] == 'webPush' and sub is None):
if job['channel'] == 'appPush':
sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None)
if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] in ('webPush', 'appPush') and sub is None):
job['state'] = 'cancelled'
continue
if now < job['created'] or now - job['created'] > MAX_AGE:
@@ -398,8 +463,18 @@ class NotificationOwner:
if current_job is None:
continue
job = current_job
if job['channel'] == 'appPush':
current_sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None)
if current_sub is not None:
current_sub['lastAt'] = self.clock()
current_sub['lastResult'] = ('Delivered to relay' if 200 <= code < 300 else 'Relay not configured' if code == 0 else
'Phone unregistered — removed' if code == 410 else 'Rate limited' if code == 429 else f'Relay error {code}')
if code == 410:
self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']]
if 200 <= code < 300:
job['state'], job['error'] = 'sent', ''
elif code == 410 and job['channel'] == 'appPush':
job['state'], job['error'], job['next'] = 'failed', 'expired', now
elif code in (404, 410) and job['channel'] == 'webPush':
job['state'], job['error'] = 'failed', 'expired'
self.state['subscriptions'] = [s for s in self.state['subscriptions']
@@ -408,9 +483,13 @@ class NotificationOwner:
job['state'], job['error'] = ('queued' if code in (408, 429) or code >= 500 else 'failed'), 'http'
except (requests.Timeout, TimeoutError):
job['state'], job['error'] = 'queued', 'timeout'
if job['channel'] == 'appPush' and sub is not None:
sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay timeout'
except Exception:
# Never log response bodies, URLs, exception text, subscription secrets or auth headers.
job['state'], job['error'] = 'queued', 'transport'
if job['channel'] == 'appPush' and sub is not None:
sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay unavailable'
if job['state'] == 'queued':
if job['attempts'] >= MAX_ATTEMPTS:
job['state'] = 'failed'
@@ -440,7 +519,17 @@ class NotificationOwner:
message = 'This is a test StarPilot Sentry notification.' if job['kind'] == 'test' else f"Parked motion detected ({job['kind']})."
event = {'eventId': job['eventId'], 'kind': job['kind'], 'systemTimeMs': int(job['created'] * 1000)}
headers = {'Idempotency-Key': key}
if name == 'webPush':
if name == 'appPush':
if subscription is None:
raise ValueError('Missing app subscription')
relay = cfg['url'] or APP_PUSH_RELAY_URL
if not relay:
return 0
response = requests.post(relay.rstrip('/') + '/v1/sentry', json={
'token': subscription['token'], 'appId': subscription['appId'], 'kind': job['kind'], 'eventId': job['eventId'],
'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label']}, headers=headers,
timeout=(3, 10), allow_redirects=False, stream=True)
elif name == 'webPush':
if subscription is None:
raise ValueError('Missing browser subscription')
body, push_headers = web_push.request(subscription, {'title': 'StarPilot Sentry Mode', 'body': message,
+7 -2
View File
@@ -46,7 +46,7 @@ def _valid(value: object, event_id: str) -> bool:
if any(type(key) is not str for key in value):
return False
fields = {key: item for key, item in value.items() if type(key) is str}
if set(fields) != FIELDS or ID.fullmatch(event_id) is None:
if set(fields) not in (FIELDS, FIELDS | {"simulated"}) or ("simulated" in fields and fields["simulated"] is not True) or ID.fullmatch(event_id) is None:
return False
version = fields["version"]
session_id = fields["sessionId"]
@@ -117,10 +117,12 @@ class EventStore:
finally:
os.close(fd)
def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None) -> RecordReceipt:
def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None, simulated: bool = False) -> RecordReceipt:
event_id = uuid.uuid4().hex
value = {"version": 1, "eventId": event_id, "sessionId": self.session_id, "kind": kind,
"monoTimeNs": mono_time_ns, "wallTimeNs": time.time_ns()}
if simulated:
value["simulated"] = True
if not _valid(value, event_id):
raise ValueError("Invalid Sentry event")
raw = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
@@ -262,6 +264,9 @@ class EventStore:
value = json.loads(raw, object_pairs_hook=_unique)
if not _valid(value, event_id):
raise ValueError("Invalid event metadata")
if value.get('simulated') and time.time_ns() - value['wallTimeNs'] > 600_000_000_000:
os.unlink(entry.name, dir_fd=directory)
continue
result["events"].append(value)
except (OSError, ValueError, TypeError, RecursionError):
result["incomplete"] = True
@@ -12,7 +12,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
import pytest
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner, NotificationUnavailable, CHANNELS
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner, NotificationUnavailable
from openpilot.starpilot.sentry_mode import web_push
@@ -120,7 +120,7 @@ def test_real_http_contracts_and_independent_channels(tmp_path):
assert paths['/push'][0]['Authorization'].startswith('vapid t=')
assert paths['/push'][0]['Content-Encoding'] == 'aes128gcm'
assert len(paths['/push'][1]) > 100 and b'This is a test' not in paths['/push'][1]
assert all(owner.snapshot()['channels'][name]['lastState'] == 'sent' for name in CHANNELS)
assert all(owner.snapshot()['channels'][name]['lastState'] == 'sent' for name in ('webPush', 'discord', 'webhook', 'ntfy'))
finally:
owner.close()
server.shutdown()
@@ -312,3 +312,74 @@ def test_missing_saved_images_send_original_text_contract(tmp_path, monkeypatch,
assert json.loads(sent['data']['event']) == {'eventId': 'a' * 32, 'kind': 'warning', 'systemTimeMs': 1000000}
finally:
owner.close()
def test_app_subscription_dispatch_redaction_and_expired_removal(tmp_path):
calls = []
clock = [100.]
owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 410)
try:
snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'})
sub_id = snapshot['appSubscriptions'][0]['id']
assert len(sub_id) == 12 and 'private-fcm-token' not in json.dumps(snapshot)
assert not snapshot['appPushAvailable']
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''})
with pytest.raises(LookupError):
owner.action({'action': 'test', 'target': 'app', 'id': 'missing'})
owner.action({'action': 'test', 'target': 'app', 'id': sub_id})
owner.tick()
assert calls[0][0] == 'appPush' and calls[0][2]['token'] == 'private-fcm-token'
assert owner.snapshot()['appSubscriptions'] == []
finally:
owner.close()
def test_app_success_dedupe_selfie_and_migration(tmp_path):
calls = []
clock = [100.]
events = []
root = tmp_path / 'notifications'
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 200)
owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'})
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''})
events.extend([{'eventId': 'a' * 32, 'kind': 'selfie', 'wallTimeNs': int(101e9)}, {'eventId': 'b' * 32, 'kind': 'warning', 'wallTimeNs': int(101e9)}])
clock[0] = 102.
owner.tick()
owner.tick()
assert len(calls) == 1
assert owner.snapshot()['appSubscriptions'][0]['lastResult'] == 'Delivered to relay'
owner.close()
reopened = NotificationOwner(root, store(events), clock=lambda: clock[0])
assert reopened.available and len(reopened.snapshot()['appSubscriptions']) == 1
reopened.close()
state = json.loads((root / 'state.json').read_text())
state.pop('appSubscriptions')
state['channels'].pop('appPush')
state['jobs'] = {}
(root / 'state.json').write_text(json.dumps(state))
migrated = NotificationOwner(root, store())
assert migrated.available and migrated.snapshot()['appSubscriptions'] == []
migrated.close()
def test_webhook_job_survives_restart_with_empty_subscription_id(tmp_path):
root = tmp_path / 'notifications'
owner = NotificationOwner(root, store())
configure(owner, 'webhook')
owner.action({'action': 'test', 'channel': 'webhook'})
owner.close()
reopened = NotificationOwner(root, store())
try:
assert reopened.available
assert reopened.snapshot()['channels']['webhook']['pending'] == 1
finally:
reopened.close()
def test_ios_bundle_id_can_subscribe(tmp_path):
owner = NotificationOwner(tmp_path / 'notifications', store(), transport=lambda *args: 200)
try:
snapshot = owner.action({'action': 'appSubscribe', 'token': 'ios-fcm-token', 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'})
assert snapshot['appSubscriptions'][0]['appId'] == 'link.galaxy.bluetooth.natehuby76'
finally:
owner.close()
@@ -140,3 +140,17 @@ class EventStoreTest(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
def test_simulated_events_expire_without_removing_real_events(tmp_path, monkeypatch):
import time
from openpilot.starpilot.sentry_mode.storage import EventStore
store = EventStore(tmp_path / 'events')
clock = [1000000000000]
monkeypatch.setattr(time, 'time_ns', lambda: clock[0])
fake = store.record('warning', 1, permitted=lambda: True, simulated=True)
real = store.record('warning', 2, permitted=lambda: True)
assert len(store.snapshot()['events']) == 2
clock[0] += 601000000000
assert [event['eventId'] for event in store.snapshot()['events']] == [real.event_id]
assert not (store.root / (fake.event_id + '.json')).exists()
@@ -15,6 +15,18 @@ numeric limit does not start another decision. A decline remains effective acros
a source gap or same-value provider change; a later different limit starts a new
window, including when an earlier numeric limit returns.
While StarPilot owns speed control, a proposed limit whose adjusted, rounded target
already matches both current SET values is recorded without another confirmation
or cruise command. The comparison uses the same mph/km/h rounding as Card, not
actual traveling speed. A presented Accept or Decline takes priority, and a decline
remains effective for that proposal. If SET reaches the target during a prompt,
the prompt closes without changing speed. An earlier command must first settle,
and SET must be observed after its receipt or expiry, before alignment is trusted.
Received-limit memory still manages proposal changes and declined choices.
An expired confirmation stays declined even if SET now matches. Changing only
display units renews pending actions while preserving accepted limits, declines, manual
overrides and the already applied SET.
With Automatic adoption on, qualified new limits are adopted immediately.
With it off, the stationary widget pulses purple twice slowly (700 ms
each), rests for one second, pulses twice quickly (300 ms each), and rests for one
@@ -341,7 +341,7 @@ def _reject(state: State, candidate: Candidate) -> State:
def step(state: State, observation: Observation, authority: Authority, policy: Policy, *, now_ns: int,
action: DriverAction | None = None, adopt: AdoptRequest | None = None) -> Decision:
action: DriverAction | None = None, adopt: AdoptRequest | None = None, set_speed_matches: bool = False) -> Decision:
"""Return a decision, never a cruise command. Invalid input produces no control target."""
state_errors = _state_errors(state)
if state_errors:
@@ -360,6 +360,8 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
errors.append("invalid adopt request")
if action is not None and adopt is not None:
errors.append("ambiguous action and adopt request")
if type(set_speed_matches) is not bool:
errors.append("invalid SET alignment")
if errors:
# Do not defer a stale prompt or credit the invalid interval on recovery.
invalid = replace(state, observation=Observation(ObservationKind.UNKNOWN), pending=None, timer_running=False, presentation=None)
@@ -468,4 +470,10 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
if pending.active_elapsed_ns >= CONFIRMATION_NS:
state = _reject(state, candidate)
return finish("timeout_reject", target=None if state.rejection_capacity_exhausted else previous_target())
# An explicit choice above wins; alignment is approval only while we own speed control.
if set_speed_matches and authority.can_control:
state, history = _accept(state, candidate, now_ns)
return finish("accepted", target=candidate.speed_mps, history=history)
return finish("pending", target=previous_target())
@@ -39,6 +39,8 @@ class Frame:
action: acc.DriverAction | None = None
adopt: acc.AdoptRequest | None = None
classified_cruise: arb.Result | None = None
is_metric: bool | None = None
set_speed_ready: bool = True
@dataclass(frozen=True)
@@ -132,7 +134,8 @@ def step(state: State, frame: Frame) -> Result:
frame.acceptance_policy.confirm_higher,
frame.acceptance_policy.fallback_previous,
frame.acceptance_policy.display_only)) or
not isinstance(frame.selected_kind, sd.DomainStatus) or not isinstance(frame.ego_pair, sd.SpeedPair)):
not isinstance(frame.selected_kind, sd.DomainStatus) or not isinstance(frame.ego_pair, sd.SpeedPair) or
(frame.is_metric is not None and type(frame.is_metric) is not bool) or type(frame.set_speed_ready) is not bool):
return Result(replace(state, lead_prior=None, reset_required=True), status="invalid_frame", errors=("invalid frame or clock",))
host = frame.host
selected_initialized = host.driver_v_cruise_kph > 0 and host.driver_v_cruise_kph != 255.0
@@ -165,8 +168,19 @@ def step(state: State, frame: Frame) -> Result:
selected = sel.select_limit(frame.observations, frame.selection_policy)
if selected.errors:
return Result(replace(base, lead_prior=None, reset_required=True), selection=selected, status="invalid_selection", errors=selected.errors)
ego = frame.ego_pair
candidate = selected.observation.candidate
matches_set = False
if (active_path and frame.set_speed_ready and candidate is not None and frame.selected_kind is sd.DomainStatus.VALID and
ego.status is sd.DomainStatus.VALID and ego.session_id == state.session_id and ego.timestamp_ns == frame.now_ns and
_number(ego.raw_mps) and _number(ego.cluster_mps)):
assert ego.raw_mps is not None and ego.cluster_mps is not None
target = sd.adjusted_limit(candidate.speed_mps, frame.offset_schedule,
minimum_target_mps=max(0.0, ego.cluster_mps - ego.raw_mps))
matches_set = target is not None and sd.set_speed_matches(
target, host.driver_v_cruise_kph, host.selected_cluster_kph, frame.is_metric)
acceptance = acc.step(base.acceptance, selected.observation, frame.authority, frame.acceptance_policy,
now_ns=frame.now_ns, action=frame.action, adopt=frame.adopt)
now_ns=frame.now_ns, action=frame.action, adopt=frame.adopt, set_speed_matches=matches_set)
base = replace(base, acceptance=acceptance.state)
if acceptance.errors:
return Result(replace(base, lead_prior=None, reset_required=True), selected, acceptance,
+30 -9
View File
@@ -113,8 +113,11 @@ class Runtime:
"""Own a unique drive session, action ledger and current-frame UI receipt."""
def __init__(self, settings: Settings, *, session_id: str | None = None, vision_enabled: bool = False,
vision_control_qualified: bool = False, vision_display_qualified: bool = False):
vision_control_qualified: bool = False, vision_display_qualified: bool = False, is_metric: bool = False):
if type(is_metric) is not bool:
raise ValueError('SLC display units must be Boolean')
self.settings = settings
self.is_metric = is_metric
self.map_source = MapSource()
self.vision_enabled = vision_enabled
# Exact saved Vision opt-in may grant control; source identity and current
@@ -139,6 +142,7 @@ class Runtime:
self._ui_receipt = None
self.command_issued_ns = 0
self.command_expiry_ns = 0
self.set_speed_after_ns = 0
self.command_expected_mps = 0.0
self.command_target_mps = 0.0
self.command_card_producer = ''
@@ -162,6 +166,7 @@ class Runtime:
self.last_ui_sequence = -1
self.last_cruise_event = -1
self.last_cruise_producer = None
# Keep the SET deadline: the previous session's command may still be in flight.
self.last_command_id = 0
self.command_status = ''
self._command_ui_sequence = 0
@@ -176,15 +181,22 @@ class Runtime:
self.toyota_projection = None
self.commanded_generation = -1
def update_offsets(self, offsets: sd.OffsetSchedule) -> None:
def update_offsets(self, offsets: sd.OffsetSchedule, *, is_metric: bool | None = None) -> None:
if is_metric is not None:
if type(is_metric) is not bool:
raise ValueError('SLC display units must be Boolean')
self.is_metric = is_metric
changed_offsets = offsets != self.settings.offsets
self.settings = replace(self.settings, offsets=offsets)
# Renew action IDs when the adjusted target changes, keeping accepted/rejected limits.
# Renew actions for new offsets/units without forgetting driver choices.
acceptance = replace(self.state.acceptance, observation=acc.Observation(acc.ObservationKind.UNKNOWN),
pending=None, presentation=None, timer_running=False)
self.state = replace(self.state, acceptance=acceptance)
self._ui_receipt = None
self.command_status = ''
self.commanded_acceptance = None
# Display units alone must not reapply an accepted SET in different rounding.
if changed_offsets:
self.command_status = ''
self.commanded_acceptance = None
def _event_current(self, event: CruiseEvent, sm, now_ns: int) -> bool:
return (bool(event.producer_session) and 0 < event.observed_ns <= now_ns and
@@ -265,10 +277,16 @@ class Runtime:
abs(event.selected_mps - self.command_target_mps) > 0.5):
return
self.command_status = 'applied'
self.set_speed_after_ns = event.observed_ns
if self.commanded_acceptance == self.state.acceptance.accepted:
self.state = replace(self.state, override=replace(self.state.override, applied_selected_mps=event.selected_mps))
else:
self.command_status = 'rejected'
self.set_speed_after_ns = event.observed_ns
def _set_speed_ready(self, sm) -> bool:
# A prior command may apply after the SET sample, even if its receipt has arrived.
return int(sm.logMonoTime.get('carState', 0)) > self.set_speed_after_ns
def _observations(self, sm, CP, now_ns: int, now_boot_ns: int | None = None) -> dict[sel.Source, acc.Observation]:
unknown = acc.Observation(acc.ObservationKind.UNKNOWN)
@@ -356,7 +374,7 @@ class Runtime:
self.settings.acceptance, self.settings.offsets,
sd.DomainStatus.VALID if car_fresh and selected_cluster is not None else sd.DomainStatus.STALE,
ego, bool(car_state.gasPressed) if car_fresh else False, host, action=action, adopt=adopt,
classified_cruise=classified)
classified_cruise=classified, is_metric=self.is_metric, set_speed_ready=self._set_speed_ready(sm))
def _action(self, request: Action | None, now_ns: int) -> tuple[acc.DriverAction | None, acc.AdoptRequest | None, int | None]:
if request is None or request.session_id != self.session_id or request.kind not in ("accept", "reject", "adopt"):
@@ -447,14 +465,16 @@ class Runtime:
if (not _number(target, positive=True) or not _number(selected_kph, positive=True) or
selected_kph >= 255 or not V_CRUISE_MIN / 3.6 <= target <= V_CRUISE_MAX / 3.6):
return None
if self.commanded_acceptance == decision.state.accepted and decision.adoption is None:
if self.command_status in ('issued', 'applied') or abs(selected_kph / 3.6 - self.command_expected_mps) > 0.1:
if sd.set_speed_matches(target, selected_kph, float(sm['carState'].vCruiseCluster), self.is_metric):
if not self._set_speed_ready(sm):
return None
if abs(target - selected_kph / 3.6) <= 0.1:
self.commanded_acceptance = decision.state.accepted
self.command_status = 'applied'
self.state = replace(self.state, override=replace(self.state.override, applied_selected_mps=selected_kph / 3.6))
return None
if self.commanded_acceptance == decision.state.accepted and decision.adoption is None:
if self.command_status in ('issued', 'applied') or abs(selected_kph / 3.6 - self.command_expected_mps) > 0.1:
return None
evidence = sm['slcDashboardObservation']
expiry = now_ns + int(2e9 / SERVICE_LIST['slcState'].frequency)
if result.selection.selected_source is sel.Source.DASHBOARD:
@@ -469,6 +489,7 @@ class Runtime:
self.command_status = 'issued'
self.command_issued_ns = now_ns
self.command_expiry_ns = expiry
self.set_speed_after_ns = max(self.set_speed_after_ns, expiry)
self.command_expected_mps = selected_kph / 3.6
self.command_target_mps = target
self.command_card_producer = str(sm['slcDashboardObservation'].producerSessionId)
@@ -1,10 +1,11 @@
"""Pure offset selection and qualified raw/cluster coordinate conversion for SLC."""
import math
import struct
from dataclasses import dataclass
from enum import Enum
from openpilot.selfdrive.car.cruise import V_CRUISE_MIN, V_CRUISE_MAX
from openpilot.selfdrive.car.cruise import V_CRUISE_MIN, V_CRUISE_MAX, slc_target_kph
from openpilot.starpilot.speed_limits import acceptance as acc
@@ -150,6 +151,20 @@ def adjusted_limit(speed_mps: float, schedule: OffsetSchedule, *, minimum_target
return min(max(value, V_CRUISE_MIN / 3.6), V_CRUISE_MAX / 3.6) if _number(value, positive=True) else None
def set_speed_matches(target_mps: float, raw_kph: float, cluster_kph: float | None, is_metric: bool | None) -> bool:
"""Compare the SET values Card would actually write, including display-unit rounding."""
if type(is_metric) is not bool or not all(_number(v, positive=True) for v in (target_mps, raw_kph, cluster_kph)):
return False
if not V_CRUISE_MIN / 3.6 <= target_mps <= V_CRUISE_MAX / 3.6:
return False
assert cluster_kph is not None
# Card receives targetMps as Float32; transport can move a half-unit rounding boundary.
target_mps = struct.unpack('f', struct.pack('f', target_mps))[0]
target_kph = slc_target_kph(target_mps, is_metric)
return (math.isclose(target_kph, raw_kph, rel_tol=0, abs_tol=0.001) and
math.isclose(target_kph, cluster_kph, rel_tol=0, abs_tol=0.001))
def resolve(decision: acc.Decision, schedule: OffsetSchedule, selected: SpeedPair, ego: SpeedPair) -> DomainResolution:
"""Bind an explicit offset and contemporaneous speed pairs to an acceptance output."""
if (not isinstance(decision, acc.Decision) or not isinstance(decision.state, acc.State) or decision.errors or
@@ -34,6 +34,14 @@ def saved(candidate: Candidate = HIGH) -> State:
class TestAcceptance(unittest.TestCase):
def test_set_alignment_requires_an_explicit_boolean(self):
for malformed in (None, 0, 1, 'yes'):
with self.subTest(value=malformed):
result = step(saved(), valid(LOW), ACTIVE, Policy(), now_ns=0, set_speed_matches=cast(Any, malformed))
self.assertTrue(result.errors)
self.assertIsNone(result.history_write)
self.assertIsNone(result.control_target_mps)
def test_sources_share_confirmation_across_disengage_and_source_loss(self):
for source, confirmation, fallback in itertools.product(('dashboard', 'map', 'vision', 'online'), (False, True), (False, True)):
policy = Policy(confirmation, confirmation, fallback)
@@ -8,7 +8,7 @@ import pytest
from opendbc.car.dashboard_speed_limit import Status
from opendbc.car.structs import car
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.selfdrive.car.cruise import VCruiseHelper, slc_target_kph
from openpilot.selfdrive.controls.lib import longitudinal_planner as planner
from openpilot.starpilot.speed_limits import acceptance as acc, physical_actions
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent, Runtime
@@ -42,6 +42,294 @@ def command_receipt(runtime, command, seconds, previous, selected, *, applied=Tr
command.decisionId, command.presentationId, command.commandId)
@pytest.mark.parametrize('confirmation', [False, True])
@pytest.mark.parametrize('metric,limit,offset', [(False, 45., 0.), (False, 45., 5.), (False, 45., -.4),
(True, 72., 0.), (True, 72., 5.), (True, 72., -.4)])
def test_matching_set_needs_neither_confirmation_nor_command(drive, confirmation, metric, limit, offset):
sm, cp = drive
conversion = CV.KPH_TO_MS if metric else CV.MPH_TO_MS
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': confirmation}),
offsets=OffsetSchedule((OffsetBand(0., None, offset * conversion),)))
runtime = Runtime(settings, is_metric=metric)
sm['slcDashboardObservation'].speedMps = limit * conversion
selected = slc_target_kph((limit + offset) * conversion, metric)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
selected = float(sm['carState'].vCruise)
for seconds in (0, .05, 11):
output = tick(runtime, sm, cp, seconds)
assert not output.result.errors and not output.message.slcState.hasPending
assert output.message.slcState.hasAccepted and output.command is None
assert output.message.slcState.commandStatus == 'applied'
assert sm['carState'].vCruise == selected
def test_half_unit_offset_uses_the_rounding_card_gets_after_float32_transport(drive):
sm, cp = drive
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}),
offsets=OffsetSchedule((OffsetBand(0., None, .5 / 3.6),)))
runtime = Runtime(settings, is_metric=True)
sm['slcDashboardObservation'].speedMps = 72 / 3.6
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 72.
pending = tick(runtime, sm, cp, 0).message.slcState
assert pending.hasPending and not pending.hasAccepted
accepted = tick(runtime, sm, cp, .05,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
assert accepted.command is not None
helper = VCruiseHelper(cp)
helper.apply_slc_target(accepted.command.slcCruiseCommand.targetMps, True)
assert helper.v_cruise_kph == 73.
@pytest.mark.parametrize('confirmation', [False, True])
def test_commute_changes_only_set_speed_and_keeps_confirmation_policy(drive, confirmation):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': confirmation}))
helper = VCruiseHelper(cp)
selected = slc_target_kph(35 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
for index, mph in enumerate((35, 45, 65, 55, 65, 35, 30)):
source = sm['slcDashboardObservation']
source.speedMps = mph * CV.MPH_TO_MS
source.episode = index + 1
source.observedMonoTime = START + index * 1_000_000_000
output = tick(runtime, sm, cp, index)
if index == 0:
assert not output.message.slcState.hasPending and output.command is None
continue
if confirmation:
pending = output.message.slcState
assert pending.hasPending and output.command is None
assert sm['carState'].vCruise == pytest.approx(selected)
output = tick(runtime, sm, cp, index + .01,
request=Action(runtime.session_id, index, pending.decisionId, pending.presentationId, 'accept'))
assert output.command is not None
command = output.command.slcCruiseCommand
helper.apply_slc_target(command.targetMps, False)
previous, selected = selected, helper.v_cruise_kph
assert round(selected * CV.KPH_TO_MPH) == mph
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
applied = tick(runtime, sm, cp, index + .02,
cruise_event=command_receipt(runtime, command, index + .02, previous, selected, event_id=index))
assert not applied.message.slcState.hasPending and applied.command is None
@pytest.mark.parametrize('source_name', ['Maps', 'Vision'])
def test_secondary_source_matching_set_skips_confirmation_and_command(drive, source_name):
from openpilot.cereal import messaging
from openpilot.starpilot.speed_limits.selection import Source
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': source_name, 'SLCConfirmation': True}),
vision_enabled=True, vision_control_qualified=True)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
source = Source.MAP if source_name == 'Maps' else Source.VISION
observations = dict.fromkeys(Source, acc.Observation(acc.ObservationKind.UNKNOWN))
observations[source] = acc.Observation(acc.ObservationKind.VALID, acc.Candidate(source.value,
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value='current-road'), 65 * CV.MPH_TO_MS))
vision = messaging.new_message('slcVisionObservation').slcVisionObservation
sm.data['slcVisionObservation'] = vision
with patch.object(runtime, '_observations', return_value=observations):
aligned = tick(runtime, sm, cp, 0)
assert aligned.message.slcState.acceptedSource == source.value
assert not aligned.message.slcState.hasPending and aligned.command is None
@pytest.mark.parametrize('selected', [65., 70.])
def test_returned_limit_only_prompts_when_it_would_change_set(drive, selected):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
first = tick(runtime, sm, cp, 0).message.slcState
tick(runtime, sm, cp, .05, request=Action(runtime.session_id, 1, 0, first.presentationId, 'adopt'))
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
assert tick(runtime, sm, cp, .1).message.slcState.hasPending
assert tick(runtime, sm, cp, 10.1).result.acceptance.basis == 'timeout_reject'
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(selected * CV.MPH_TO_MS, False)
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
returned = tick(runtime, sm, cp, 10.2)
assert returned.message.slcState.hasPending == (selected != 65.)
assert returned.command is None
@pytest.mark.parametrize('action_kind', ['accept', 'reject'])
@pytest.mark.parametrize('seconds', [.05, 10.])
def test_presented_driver_choice_wins_when_set_reaches_the_proposal(drive, action_kind, seconds):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
pending = tick(runtime, sm, cp, 0).message.slcState
selected = slc_target_kph(55 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
output = tick(runtime, sm, cp, seconds,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, action_kind))
assert output.result.acceptance.action_receipt.consumed
assert output.result.acceptance.basis == ('driver_accept' if action_kind == 'accept' else 'driver_reject')
assert output.command is None and sm['carState'].vCruise == selected
held = tick(runtime, sm, cp, seconds + .05)
assert not held.message.slcState.hasPending and held.command is None
if action_kind == 'reject':
assert held.result.acceptance.basis == 'rejected' and not held.message.slcState.hasAccepted
@pytest.mark.parametrize('seconds', [10., 10.001, 11.])
def test_set_alignment_cannot_accept_an_expired_confirmation(drive, seconds):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
pending = tick(runtime, sm, cp, 0)
assert pending.message.slcState.hasPending
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(55 * CV.MPH_TO_MS, False)
expired = tick(runtime, sm, cp, seconds)
assert expired.result.acceptance.basis == 'timeout_reject'
assert not expired.message.slcState.hasAccepted and not expired.message.slcState.hasPending
assert expired.command is None
@pytest.mark.parametrize('resolution', ['applied', 'rejected', 'expired'])
def test_alignment_waits_for_the_previous_command_and_newer_set_evidence(drive, resolution):
from openpilot.starpilot.speed_limits.selection import Source
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Maps', 'SLCConfirmation': True}))
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
previous = sm['carState'].vCruise
observations = dict.fromkeys(Source, acc.Observation(acc.ObservationKind.UNKNOWN))
def limit(mph):
observations[Source.MAP] = acc.Observation(acc.ObservationKind.VALID, acc.Candidate('map',
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value=f'road-{mph}'), mph * CV.MPH_TO_MS))
with patch.object(runtime, '_observations', return_value=observations):
limit(55)
pending = tick(runtime, sm, cp, 0).message.slcState
issued = tick(runtime, sm, cp, .05,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
command = issued.command.slcCruiseCommand
limit(65)
waiting = tick(runtime, sm, cp, .06)
assert waiting.command is None and waiting.message.slcState.commandStatus == 'issued'
assert runtime.commanded_acceptance.candidate.speed_mps == pytest.approx(55 * CV.MPH_TO_MS)
if resolution != 'expired':
selected = slc_target_kph(command.targetMps, False) if resolution == 'applied' else previous
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
received = tick(runtime, sm, cp, .07, cruise_event=command_receipt(
runtime, command, .07, previous, selected, applied=resolution == 'applied'))
assert received.command is None
settled = tick(runtime, sm, cp, .08)
else:
# An expired command could have applied after this older (still fresh) SET sample.
now = START + 151_000_000
sm.advance(now)
sm.logMonoTime['carState'] = command.expiresMonoTime
sm.logMonoTime['slcDashboardObservation'] = command.expiresMonoTime
sm['slcDashboardObservation'].carStateLogMonoTime = command.expiresMonoTime
stale_set = runtime.step(sm, cp, now_ns=now)
assert stale_set.command is None and stale_set.message.slcState.commandStatus == 'expired'
settled = tick(runtime, sm, cp, .16)
if resolution == 'applied':
assert settled.message.slcState.hasPending and settled.message.slcState.commandStatus == 'applied'
assert settled.message.slcState.acceptedSpeedLimit == pytest.approx(55 * CV.MPH_TO_MS)
else:
assert not settled.message.slcState.hasPending and settled.command is None
assert settled.message.slcState.acceptedSpeedLimit == pytest.approx(65 * CV.MPH_TO_MS)
def test_session_reset_does_not_forget_an_in_flight_speed_change(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
pending = tick(runtime, sm, cp, 0).message.slcState
issued = tick(runtime, sm, cp, .05,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
assert issued.command is not None
runtime.reset()
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
waiting = tick(runtime, sm, cp, .06)
assert not waiting.message.slcState.hasAccepted and waiting.command is None
settled = tick(runtime, sm, cp, .16)
assert settled.message.slcState.hasAccepted and not settled.message.slcState.hasPending
assert settled.command is None
@pytest.mark.parametrize('unavailable', ['off', 'lateral', 'stock', 'stale_set', 'stale_ego', 'unset', 'force_decel'])
def test_set_alignment_requires_current_system_speed_authority(drive, unavailable):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm.advance(START)
if unavailable in ('off', 'lateral', 'stock'):
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
if unavailable == 'stock':
cp.openpilotLongitudinalControl = False
sm['carState'].cruiseState.enabled = True
else:
sm['carControl'].enabled = sm['selfdriveState'].enabled = False
sm['carControl'].latActive = unavailable == 'lateral'
elif unavailable == 'stale_set':
sm.valid['carState'] = False
elif unavailable == 'stale_ego':
sm['carState'].vEgoCluster = float('nan')
elif unavailable == 'unset':
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 255.
else:
sm['controlsState'].forceDecel = True
output = runtime.step(sm, cp, now_ns=START)
assert not output.message.slcState.hasAccepted and output.command is None
def test_reaching_actual_speed_does_not_accept_a_different_set_target(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 45 * CV.MPH_TO_MS
sm['carState'].vEgo = sm['carState'].vEgoCluster = 45 * CV.MPH_TO_MS
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
output = tick(runtime, sm, cp, 0)
assert output.message.slcState.hasPending and not output.message.slcState.hasAccepted and output.command is None
def test_matching_new_limit_reconciles_an_old_manual_override_without_changing_set(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
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
first = tick(runtime, sm, cp, 0)
assert not first.message.slcState.hasPending
selected = slc_target_kph(70 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
selected = float(sm['carState'].vCruise)
changed = tick(runtime, sm, cp, .05, cruise_event=CruiseEvent(1, START + 50_000_000,
baseline / 3.6, selected / 3.6, 'accel', 'card-producer', START + 50_000_000))
assert changed.message.slcState.driverOverrideActive and changed.command is None
sm['slcDashboardObservation'].speedMps = 70 * CV.MPH_TO_MS
aligned = tick(runtime, sm, cp, .1)
assert not aligned.message.slcState.hasPending and not aligned.message.slcState.driverOverrideActive
assert aligned.command is None and sm['carState'].vCruise == selected
@pytest.mark.parametrize('direction', [-1, 1])
def test_matching_limit_keeps_wheel_buttons_available_for_normal_set_changes(drive, direction):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
selected = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
output = tick(runtime, sm, cp, 0)
pending = physical_actions.pending_confirmation(ReplaySM({'slcState': output.message.slcState}, START), START, long_active=True)
assert pending is None
helper = VCruiseHelper(cp)
helper.v_cruise_kph = selected
button = 'accelCruise' if direction > 0 else 'decelCruise'
cs = car.CarState(cruiseState={'available': True}, buttonEvents=[car.CarState.ButtonEvent(type=button, pressed=True)])
helper.update_v_cruise(cs, True, False, pending, slc_enabled=True)
assert helper.slc_consumed_button is None
cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
helper.update_v_cruise(cs, True, False, slc_enabled=True)
assert helper.v_cruise_kph == pytest.approx(round(selected + direction * round(CV.MPH_TO_KPH, 1), 1))
@pytest.mark.parametrize('failure', ['source_refresh', 'expiry'])
def test_failed_adoption_retries_with_current_evidence_and_stops_after_application(drive, failure):
sm, cp = drive
@@ -46,6 +46,7 @@ def helper(request):
assert eligible(cp)
result = VCruiseHelper(cp)
result.v_cruise_kph = result.v_cruise_cluster_kph = 50.0
assert result.toyota is not None
result.toyota.engaged = result.toyota.owns_target = True
return result
+4 -2
View File
@@ -1,6 +1,7 @@
"""Native Toyota cruise prerequisites, owned beside Card's software SET."""
import math
from typing import cast
from openpilot.selfdrive.car.cruise import ButtonType, SlcPendingConfirmation, V_CRUISE_UNSET, slc_target_kph
@@ -66,8 +67,9 @@ class ToyotaCruise:
step = self.manual_step
if step is not None:
previous_kph, previous_cluster_kph, button, long_press = step
ready = self.capacity(self.requested[1])
helper.v_cruise_kph, helper.v_cruise_cluster_kph = self.requested if ready or button == 'decel' else (previous_kph, previous_cluster_kph)
requested = cast(tuple[float, float], self.requested)
ready = self.capacity(requested[1])
helper.v_cruise_kph, helper.v_cruise_cluster_kph = requested if ready or button == 'decel' else (previous_kph, previous_cluster_kph)
helper.v_cruise_kph = min(helper.v_cruise_kph, self.native_kph)
helper.v_cruise_cluster_kph = min(helper.v_cruise_cluster_kph, self.native_cluster_kph)
if step is not None:
@@ -286,7 +286,7 @@ class CompanionTests(unittest.TestCase):
host._shared_bluetooth_owner = self.owner
host.auto = Mock()
alive = {'value': True}
host._session_alive = lambda: alive['value']
host._session_alive = Mock(side_effect=lambda: alive['value'])
host.stop = Mock(side_effect=lambda: alive.update(value=False))
host.forget_receiver = Mock(return_value=True)
self.owner.request = Mock(return_value={'devices': []}) # test double
@@ -248,3 +248,134 @@ def test_lightning_cluster_startup_and_aol_recovery(ford_joined, monkeypatch):
def test_lightning_unarmed_startup_does_not_show_armed_eps_warning(ford_joined, monkeypatch, armed):
monkeypatch.ford_startup_armed = armed
run_actual_producer(ford_joined, monkeypatch, scenario='startup-warning')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
@pytest.mark.parametrize('can_clock_offset_ns', [0, 9_000_000_000])
def test_angle_transport_batching_keeps_native_spacing(ford_joined, monkeypatch, can_clock_offset_ns):
from opendbc.car import structs
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
joined = ford_joined
native, safety, ci = joined.native, joined.safety, joined.host.CI
native.arm()
adapter = ci.CC.bp_lat
inputs = adapter.host.inputs
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed, *, angle=False: None)
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
control = structs.CarControl(enabled=True, latActive=True)
control.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
queue, echoes, rejected = [], [], []
active_count = 0
control_address = 0x3D6 if adapter.canfd else 0x3D3
last_delivery = 0
for frame in range(300):
now_us = 1_000_000 + (native.tick + 1) * 10_000
while queue and queue[0][0] <= now_us:
delivered, address, data, bus = queue.pop(0)
safety.set_timer(delivered)
accepted = safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
if not accepted:
rejected.append((frame, hex(address), data.hex()))
elif address == control_address:
active_count += int(((data[0] >> 4) & 7) == 1) if adapter.canfd else int(bool(data[4] & 4))
echoes.append((delivered * 1000 + can_clock_offset_ns, [(address, data, bus | (0x80 if accepted else 0xC0))]))
native.pump(1, mask=1, speed=15.)
# Exercise the actual interface-to-controller feedback path, including Panda
# bus offsets. Only echoes produced by real native TX acceptance count.
ci.update(echoes)
echoes.clear()
ci.CS.out = state
now_ns = now_us * 1000
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now_ns)
panda.pandaStates[0].safetyTxBlocked = len(rejected)
inputs.sm.update_msgs(now_ns / 1e9, [panda.as_reader()])
_, packets = ci.CC.update(control.as_reader(), ci.CS, now_ns)
for address, data, bus in packets:
if address not in (control_address, 0x3CA):
continue
# A delayed FIFO batch followed by an immediate one compresses a nominal
# 50/30ms send cadence below Panda's unchanged 40/20ms minima.
delivered = max(last_delivery, now_us + (25_000 if frame % 10 == 5 else 0))
last_delivery = delivered
queue.append((delivered, address, data, bus))
assert not rejected, rejected[:8]
assert active_count > 25
safety.aol_set_host_request(0)
native.pump(10, mask=0, speed=15.)
ci.CS.out = state
now_ns = (1_000_000 + native.tick * 10_000) * 1000
packets = adapter.step_lateral(control, ci.CS, native.packer, ci.CC.CAN, 310, now_ns)
assert packets and safety.aol_get_permission_mask() == 0
for address, data, bus in packets:
assert safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_stock_forwarding_returns_cannot_starve_angle_reengagement(ford_joined, monkeypatch):
from opendbc.car import structs
from opendbc.car.ford import fordcan
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
joined = ford_joined
native, safety, ci = joined.native, joined.safety, joined.host.CI
native.arm()
adapter = ci.CC.bp_lat
inputs = adapter.host.inputs
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed, *, angle=False: None)
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
command = structs.CarControl(enabled=True, latActive=True)
command.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
echoes = []
active = [0, 0]
forwarded = 0
neutral_seen = False
# Genuine camera frames are forwarded only while the real native hook permits
# it. Those forwarded TX returns share the src|0x80 namespace with host TX.
for frame in range(300):
native.pump(1, mask=1, speed=15.)
now_ns = (1_000_000 + native.tick * 10_000) * 1000
if neutral_seen and frame % 5 == 1:
stock = fordcan.create_lat_ctl2_msg(native.packer, ci.CC.CAN, 0, 0., 0., 0., 0., (frame // 5) % 16)
if safety.safety_fwd_hook(2, stock[0]) == 0:
echoes.append((now_ns, [(stock[0], stock[1], ci.CC.CAN.main | 0x80)]))
forwarded += 1
ci.update(echoes)
echoes.clear()
ci.CS.out = state
# Preserve native permission while exercising one normal host neutral pause.
# Continued forwarding must not prevent a later real active command.
command.latActive = not 80 <= frame < 130
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now_ns)
panda.pandaStates[0].safetyTxBlocked = 0
inputs.sm.update_msgs(now_ns / 1e9, [panda.as_reader()])
_, packets = ci.CC.update(command.as_reader(), ci.CS, now_ns)
for address, data, bus in packets:
assert safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus - joined.offset, data))
echoes.append((now_ns + 1, [(address, data, bus | 0x80)]))
if address == 0x3D6:
is_active = ((data[0] >> 4) & 7) == 1
if frame < 80:
active[0] += int(is_active)
elif frame < 130:
neutral_seen |= not is_active
else:
active[1] += int(is_active)
assert forwarded > 2
assert neutral_seen
assert active[0] > 3 and active[1] > 10, active
assert safety.aol_get_permission_mask() == 1
@@ -117,8 +117,11 @@ class TestGmVisionSource(unittest.TestCase):
runtime = Runtime(slc_runtime_settings(params, cp, {}), session_id='drive', vision_enabled=True,
vision_control_qualified=vision_control_enabled(params, cp))
first = runtime.step(sm, cp, now_ns=2_000_000_000, now_boot_ns=6_000_000_000)
self.assertTrue(first.message.slcState.hasPending)
self.assertEqual(first.message.slcState.automaticAdoptionPending, not confirmation)
self.assertEqual(first.message.slcState.hasPending, confirmation)
self.assertEqual(first.message.slcState.hasAccepted, not confirmation)
self.assertFalse(first.message.slcState.automaticAdoptionPending)
if not confirmation:
self.assertAlmostEqual(first.message.slcState.acceptedSpeedLimit, 55 * .44704, places=4)
self.assertFalse(first.message.slcState.hasCeiling)
evidence = sm.data['slcVisionObservation'].vision
evidence.observedMonoTime = 4_980_000_000
@@ -147,10 +150,12 @@ class TestGmVisionSource(unittest.TestCase):
evidence.frameId += 1
sm.advance(5_060_000_000)
changed = runtime.step(sm, cp, now_ns=5_060_000_000, now_boot_ns=9_060_000_000)
self.assertTrue(changed.message.slcState.hasPending)
self.assertEqual(changed.message.slcState.hasPending, confirmation)
self.assertFalse(changed.message.slcState.automaticAdoptionPending)
self.assertTrue(changed.message.slcState.hasAccepted)
self.assertFalse(changed.message.slcState.hasCeiling)
self.assertAlmostEqual(changed.message.slcState.acceptedSpeedLimit,
55 * .44704, places=4)
(55 if confirmation else next_mph) * .44704, places=4)
self.assertEqual(changed.result.acceptance.policy, runtime.settings.acceptance)
sm.data['carControl'].longActive = False
sm.advance(5_080_000_000)
@@ -15,7 +15,7 @@ GAP = 8
SOURCE_ROW_HEIGHT = 76
PADDING = 24
LIMIT_RECTS = {'large': (272, 75, 148, 165), 'compact': (330, 20, 118, 132)}
LIMIT_RECTS = {'large': (318, 75, 148, 165), 'compact': (330, 20, 118, 132)}
ACTION_BOUNDS = {
'large': ((112, ACTION_Y, 420, ACTION_Y + 80), (112, ACTION_Y + 88, 420, ACTION_Y + ACTION_HEIGHT)),
'compact': ((146, 44, 330, 102), (146, 106, 330, 164)),
+21 -9
View File
@@ -11,7 +11,7 @@ import json
import pyray as rl
from openpilot.starpilot.ui.onroad_customization import offset, rgba, widget_size
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_bounds
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds
from openpilot.starpilot.ui.onroad_widget_style import draw_control_card
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade
@@ -78,12 +78,24 @@ class UnifiedSpeedWidget:
border = rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits'))
text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits'))
if shown.mode != 'limit_only':
x = rect.x + PADDING
draw_caption(self.fonts, 'SET', x, rect.y + 19, unit=shown.unit)
size = 120
while size > 72 and self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width > 148:
size -= 1
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, x, rect.y + 51, text)
if manual_slc_confirmation(Profile.LARGE, state):
x = rect.x + PADDING
draw_caption(self.fonts, 'SET', x, rect.y + 19, unit=shown.unit, size=32)
size = 120
while size > 72 and self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width > 148:
size -= 1
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, x, rect.y + 51, text)
else:
center = rect.x + 120
draw_caption(self.fonts, 'SET', center, rect.y + 15, unit=shown.unit, centered=True)
size = 114.28
size *= min(1, 200 / max(1, self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width))
left, right = self.fonts.horizontal_ink(shown.max_text, FontRole.SET_SPEED, size)
top, bottom = self.fonts.vertical_ink(shown.max_text, FontRole.SET_SPEED, size)
# The pinned DINish dash has 15 inked rows in its 200-pixel atlas cell.
number_height = size * self.fonts.profile.font_scale * 15 / 200 if shown.max_text == '–' else bottom - top
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, center - (left + right) / 2,
rect.y + 101 - number_height / 2, text)
self.sign.render(state, defer_confirmation=True)
if shown.mode == 'limit_only' and (bounds := speed_limit_bounds(Profile.LARGE, state)) is not None:
draw_ink(self.fonts, shown.unit, FontRole.NORMAL, 24, bounds[2], bounds[3] - 32)
@@ -111,9 +123,9 @@ class CurrentSpeedHud:
_center(self.fonts, speed, FontRole.SPEED, 176, rect, content.y + 42 + dy + old_top - number_top,
rl.Color(*rgba(state.customization, 'text', 'large', 'current_speed')))
unit = 'km/h' if state.metric else 'mph'
unit_top, _ = self.fonts.vertical_ink(unit, FontRole.NORMAL, 44)
unit_top, _ = self.fonts.vertical_ink(unit, FontRole.NORMAL, 28)
red, green, blue, alpha = rgba(state.customization, 'text', 'large', 'current_speed')
_center(self.fonts, unit, FontRole.NORMAL, 44, rect, content.y + 42 + dy + old_bottom + 20 - unit_top,
_center(self.fonts, unit, FontRole.NORMAL, 28, rect, content.y + 42 + dy + old_bottom + 20 - unit_top,
rl.Color(red, green, blue, int(alpha * 200 / 255)))
+29 -15
View File
@@ -1,6 +1,7 @@
"""The latest limit, its attention animation and override mark on both screens."""
import time
from typing import cast
import pyray as rl
@@ -41,16 +42,21 @@ def draw_ink(fonts, text, role, size, x, top, color=INK):
return fonts.measure(text, role, size).width, ink_bottom - ink_top
def draw_caption(fonts, label, x, top, color=MUTED, *, unit=None):
def draw_caption(fonts, label, x, top, color=MUTED, *, unit=None, size=None, centered=False):
large = fonts.profile == Profile.LARGE
width, height = draw_ink(fonts, label, FontRole.MEDIUM, 32 if large else 24, x, top, color)
label_size = size or (28 if large else 24)
unit_size, gap = (28, 12) if large else (20, 8)
if centered:
width = fonts.measure(label, FontRole.MEDIUM, label_size).width
if unit:
width += gap + fonts.measure(unit, FontRole.NORMAL, unit_size).width
x -= width / 2
width, height = draw_ink(fonts, label, FontRole.MEDIUM, label_size, x, top, color)
if unit:
size = 28 if large else 20
unit_top, _ = fonts.vertical_ink(unit, FontRole.NORMAL, size)
unit_top, _ = fonts.vertical_ink(unit, FontRole.NORMAL, unit_size)
# Exclude the p descender when aligning mph to the caption baseline.
_, baseline = fonts.vertical_ink('mh' if unit == 'mph' else unit, FontRole.NORMAL, size)
gap = 12 if large else 8
unit_width, _ = draw_ink(fonts, unit, FontRole.NORMAL, size, x + width + gap,
_, baseline = fonts.vertical_ink('mh' if unit == 'mph' else unit, FontRole.NORMAL, unit_size)
unit_width, _ = draw_ink(fonts, unit, FontRole.NORMAL, unit_size, x + width + gap,
top + height - (baseline - unit_top), color)
width += gap + unit_width
return width, height
@@ -156,8 +162,9 @@ class SpeedLimitWidget:
number_top, number_bottom = self.fonts.vertical_ink(shown.posted_text, FontRole.BOLD, text_size(64))
text(shown.posted_text, 64, start + nw / 2, 84 - (number_bottom - number_top) / scale / 2, white, FontRole.BOLD)
if ow:
top, bottom = self.fonts.vertical_ink(shown.offset_text, FontRole.MEDIUM, text_size(32))
text(shown.offset_text, 32, start + nw + 10 + ow / 2, 84 - (bottom - top) / scale / 2, secondary)
offset_text = cast(str, shown.offset_text)
top, bottom = self.fonts.vertical_ink(offset_text, FontRole.MEDIUM, text_size(32))
text(offset_text, 32, start + nw + 10 + ow / 2, 84 - (bottom - top) / scale / 2, secondary)
caption = ink_width('New Limit', 30, FontRole.SEMI_BOLD)
cx = (width - caption - 10) / 2
accent = rl.Color(214, 188, 240, 255)
@@ -204,27 +211,34 @@ class SpeedLimitWidget:
for px in (left, right) for py in (a, b))
vienna = state.appearance.use_vienna_sign
large_steady = self.profile == Profile.LARGE and not vienna and not shown.pending
color = rl.Color(0, 0, 0, 255) if vienna else white
if vienna:
self.shapes.circle(x + 59 * scale, top + 67 * scale, self.pulse.color(rl.Color(201, 34, 49, 255), now))
else:
self.shapes.outline(x + 59 * scale, top + 66 * scale, white)
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, 18)
heading_size = 28 / self.text_scale if large_steady else 18
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size)
if large_steady and right - left > 96:
heading_size *= 96 / (right - left)
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size)
heading_width = right - left
header_y = 47 if vienna else 26
header_y = 47 if vienna else 21 if large_steady else 26
# The small arrow's visible ink is 12 x 16, with a six-pixel label gap.
header_left = 59 - (heading_width + (18 if arrow_direction else 0)) / 2
centered(heading, FontRole.SEMI_BOLD, 18, header_y - (b - a) / 2, color, header_left + heading_width / 2)
heading_color = self.pulse.color(MUTED, now) if large_steady else color
centered(heading, FontRole.SEMI_BOLD, heading_size, header_y - (b - a) / 2, heading_color, header_left + heading_width / 2)
if arrow_direction:
self.shapes.arrow('compact', round(x + (header_left + heading_width + 12) * scale),
round(top + header_y * scale), arrow_direction, color)
size = 58 if vienna else 64
role = FontRole.SET_SPEED if large_steady else FontRole.BOLD
size = 77.92 / self.text_scale if large_steady else 58 if vienna else 64
offset_size = 30 if vienna else 27
number_center = (73 if shown.offset_text else 80) if vienna else (67 if shown.offset_text else 81)
while True:
left, right, a, b = ink(shown.posted_text, FontRole.BOLD, size)
left, right, a, b = ink(shown.posted_text, role, size)
width, height = right - left, b - a
number_top = number_center - height / 2
if vienna:
@@ -240,7 +254,7 @@ class SpeedLimitWidget:
size *= .95
if vienna:
offset_size *= .95
number = centered(shown.posted_text, FontRole.BOLD, size, number_top, color)
number = centered(shown.posted_text, role, size, number_top, color)
if shown.offset_text:
if not vienna:
left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, offset_size)
@@ -563,7 +563,7 @@ class AppearanceSettingsTests(unittest.TestCase):
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), base)
card.assert_not_called()
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls
self.assertEqual(foreground_calls(fonts.draw)[0].args[3:5], (112, 94))
self.assertEqual(foreground_calls(fonts.draw)[0].args[3:5], (192, 90))
for profile in (Profile.LARGE, Profile.COMPACT):
view = onroad.OnroadView.__new__(onroad.OnroadView)
view.navigation = Mock()
@@ -62,7 +62,7 @@ class TestControllerFeature(unittest.TestCase):
self.assertEqual(row.choices, ('Stock Controller', 'StarPilot Controller'))
self.assertIn('next drive', row.reason)
self.assertNotIn('active', row.reason.lower())
self.assertEqual(_projection(row, 'torque')['choices'], list(row.choices))
self.assertEqual(_projection(row)['choices'], list(row.choices))
self.assertTrue(_onroad_preference('torque', DOCUMENT_KEY))
self.assertFalse(self.params.get_bool('AdvancedLateralTune'))
self.cp = interfaces[HYUNDAI.KIA_EV6].get_non_essential_params(HYUNDAI.KIA_EV6)
@@ -92,7 +92,7 @@ class TestControllerFeature(unittest.TestCase):
learning = self.require_row('ForceAutoTuneOff')
self.assertEqual((learning.label, learning.value), ('Use StarPilot Auto Learn', 'On'))
self.assertTrue(learning.available)
self.assertEqual(_projection(learning, 'torque')['choices'], ['Off', 'On'])
self.assertEqual(_projection(learning)['choices'], ['Off', 'On'])
self.assertTrue(self.owner.apply(required_change(learning)))
self.assertFalse(read_preference(self.params, self.cp).enabled)
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
@@ -278,7 +278,7 @@ class TestControllerFeature(unittest.TestCase):
self.assertEqual(row.value, 'On')
self.assertEqual(row.reason, 'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.')
from openpilot.starpilot.galaxy.setting_descriptions import description
self.assertEqual(_projection(row, 'torque')['reason'], description(row))
self.assertEqual(_projection(row)['reason'], description(row))
if __name__ == '__main__':
unittest.main()
@@ -114,7 +114,7 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position():
rl.init_window(128, 128, "Native speed-group placement fixture")
try:
with BitmapFonts(Profile.LARGE, default_font_directory()) as fonts:
for metric, value, show_set in product((False, True), (60, 100, 199, None), (False, True)):
for metric, value, show_set in product((False, True), (60, 99, 100, 199, None), (False, True)):
document = default_document()
document['showMaxSpeed'] = show_set
document['layouts']['large']['cruise_limits'].update(x=188, y=95)
@@ -128,26 +128,54 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position():
with patch.object(fonts, 'draw', lambda *args, calls=calls: calls.append(args)):
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), current)
calls = [call for call in calls if call[-1].a == 255]
posted = next(call for call in calls if call[0] == '120' and call[1] == FontRole.BOLD)
posted = next(call for call in calls if call[0] == '120' and call[1] == FontRole.SET_SPEED)
left, right = fonts.horizontal_ink(posted[0], posted[1], posted[2])
assert right-left <= 96*1.25
assert posted[2] <= 64*1.25*Profile.COMPACT.font_scale/Profile.LARGE.font_scale
left, right = fonts.horizontal_ink('120', FontRole.BOLD, posted[2])
assert abs(posted[3] + (left + right) / 2 - 445.75) <= .5
a, b = fonts.vertical_ink('120', FontRole.BOLD, posted[2])
assert abs(posted[3] + (left + right) / 2 - 491.75) <= .5
a, b = fonts.vertical_ink('120', FontRole.SET_SPEED, posted[2])
assert b - a == pytest.approx(60, abs=.1)
assert abs(posted[4]+(a+b)/2-(95+67*1.25)) <= .5
set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED]
set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED and call[3] < 388]
assert bool(set_calls) == show_set
if set_calls:
text, role, size, x, y, _ = set_calls[0]
assert x == 212 and fonts.measure(text, role, size).width <= 148
assert y + fonts.vertical_ink(text, role, size)[0] == 146
assert size == (120 if value in (60, None) else 87)
assert fonts.measure(text, role, size).width <= 200
left, right = fonts.horizontal_ink(text, role, size)
assert x + right + 16 <= 372
assert x + (left + right) / 2 == pytest.approx(308)
assert x + right + 20 <= 418
top, bottom = fonts.vertical_ink(text, role, size)
if value is not None:
assert bottom - top == pytest.approx(88, abs=1)
assert y + (top + bottom) / 2 == pytest.approx(196)
else:
assert y + top + size * fonts.profile.font_scale * 15 / 400 == pytest.approx(196)
unit = next(call for call in calls if call[0] == ('km/h' if metric else 'mph'))
assert unit[2] == 28
assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] < 146
assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] + 12 <= y + top
finally:
if owns_context:
rl.close_window()
def test_pending_confirmation_keeps_three_digit_set_speed_clear():
from unittest.mock import patch
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
from openpilot.starpilot.ui.tests.test_unified_speed_native import state
owns_context = not rl.is_window_ready()
if owns_context:
rl.init_window(128, 128, 'Pending confirmation spacing fixture')
try:
with BitmapFonts(Profile.LARGE, default_font_directory()) as fonts:
for metric in (False, True):
factor = 3.6 if metric else 2.2369362921
current = state(metric=metric, cruise_kph=100 / factor * 3.6)
bounds = speed_limit_bounds(Profile.LARGE, current)
assert bounds is not None
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), current)
number = next(call for call in calls if call[0] == '100' and call[1] == FontRole.SET_SPEED and call[-1].a == 255)
assert number[3] + fonts.horizontal_ink(number[0], number[1], number[2])[1] < bounds[0]
finally:
if owns_context:
rl.close_window()
@@ -420,7 +448,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
current = state(observed_ns=10_000_000_000+elapsed)
key = 'cruise_limits' if profile == Profile.LARGE else 'speed_limit'
current.customization['layouts'][profile][key].update(x=saved_x, y=y)
x = saved_x+184 if profile == Profile.LARGE else saved_x
x = saved_x+230 if profile == Profile.LARGE else saved_x
current = replace(current, speed_mps=(posted+offset+direction*2)/3.6,
appearance=replace(current.appearance, use_vienna_sign=vienna),
speed_limit=replace(current.speed_limit, source=source, speed_limit_mps=posted/3.6,
@@ -512,14 +540,21 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels():
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
widget.render(current)
assert all(float(call[3]).is_integer() and float(call[4]).is_integer() for call in calls)
assert abs(calls[0][2]/widget.text_scale-18) < 1e-9
if fonts.profile == Profile.LARGE and not vienna and not pending:
assert calls[0][2] <= 28
else:
assert abs(calls[0][2]/widget.text_scale-18) < 1e-9
drawn.append((widget, calls))
small_widget, small_calls = drawn[0]
large_widget, large_calls = drawn[1]
assert [call[0] for call in small_calls] == [call[0] for call in large_calls]
if not vienna and not pending:
assert small_calls[1][1] == FontRole.BOLD
assert large_calls[1][1] == FontRole.SET_SPEED
continue
for a, b in zip(small_calls, large_calls, strict=True):
assert abs(a[2]/small_widget.text_scale-b[2]/large_widget.text_scale) < 1e-9
assert abs((a[3]-330)-(b[3]-272)/1.25) <= 1
assert abs((a[3]-330)-(b[3]-318)/1.25) <= 1
assert abs((a[4]-20)-(b[4]-75)/1.25) <= 1
assert (a[-1].r, a[-1].g, a[-1].b, a[-1].a) == (b[-1].r, b[-1].g, b[-1].b, b[-1].a)
if pending:
@@ -100,7 +100,15 @@ class FeatureSettingsOwnerTests(unittest.TestCase):
self.owner.vehicle_params = lambda: ordinary
self.fingerprint = ordinary.carFingerprint
self.owner.configuration_vehicle = lambda: True
self.assertEqual(ordinary.safetyConfigs[0].safetyParam, 0x6242)
self.assertTrue(self._row('aol', 'AlwaysOnLateral').available)
self.assertTrue(policy_for(ordinary).runtime_supported)
unconfigured = ordinary.as_reader().as_builder()
unconfigured.safetyConfigs = []
self.owner.vehicle_params = lambda: unconfigured
self.assertFalse(self._row('aol', 'AlwaysOnLateral').available)
self.assertFalse(policy_for(unconfigured).runtime_supported)
self.assertFalse(policy_for(unconfigured).intent_supported)
fw = [structs.CarParams.CarFw(ecu=structs.CarParams.Ecu.eps, address=0x7A1, brand='toyota', bus=0,
fwVersion=b'8965B47070\x00\x00\x00\x00\x00\x00')]
actual = CarInterface.get_params(CAR.TOYOTA_PRIUS, {0: {0x2FF: 4}, 1: {}, 2: {}}, fw, False, False, False)
@@ -458,7 +458,7 @@ def test_large_draw_origins_and_wheel_input_move_together():
with patch("openpilot.starpilot.ui.onroad_large_widgets.draw_control_card") as card, patch.object(rl, "draw_line_ex"):
UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
card.assert_not_called()
assert foreground_calls(fonts.draw)[0].args[3:5] == (212, 114)
assert foreground_calls(fonts.draw)[0].args[3:5] == (292, 110)
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
assert fonts.draw.call_args_list[-2].args[3:5] == (965, 112)
wheel = SteeringWheelWidget(Path('/unused'))
@@ -544,7 +544,7 @@ def test_frozen_default_draw_geometry_and_neutral_colors():
UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
card.assert_not_called()
assert fonts.draw.call_args_list[0].args[0] == 'SET'
assert foreground_calls(fonts.draw)[0].args[3:5] == (112, 94)
assert foreground_calls(fonts.draw)[0].args[3:5] == (192, 90)
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
assert fonts.draw.call_args_list[-2].args[3:5] == (925, 72)
assert fonts.draw.call_args_list[-1].args[3:5] == pytest.approx((925, 215.2))
@@ -136,6 +136,8 @@ def test_compact_driver_camera_has_no_hidden_confirmation_targets(reverse):
appearance=replace(current.appearance, camera_view=CameraViewChoice.AUTO if reverse else CameraViewChoice.DRIVER))
assert not manual_slc_confirmation(Profile.COMPACT, current)
assert not slc_controls(Profile.COMPACT, replace(current, slc_prompt_open=True))
touch = OnroadInput(lambda request: pytest.fail('Hidden confirmation emitted an action'), Profile.COMPACT)
def unexpected_action(_request):
raise AssertionError('Hidden confirmation emitted an action')
touch = OnroadInput(unexpected_action, Profile.COMPACT)
touch.press(250, 90, current)
assert not touch.claimed
@@ -71,10 +71,11 @@ def test_live_offset_edits_keep_the_accepted_limit_and_native_ceiling(tmp_path,
session = runtime.session_id
for milliseconds, offset in ((1000, 5), (2000, -5)):
params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (0, 0, 0, offset, 0, 0, 0))), block=True)
actions, cruise = [object()], [object()]
receipt = object()
actions, cruise = [object()], [receipt]
refreshed, changed = lifecycle.refresh(NOW + milliseconds * 1_000_000, pending_actions=actions, pending_cruise=cruise)
assert changed and refreshed is runtime
assert not actions and not cruise
assert not actions and cruise == [receipt]
output, _, target = drive.step(milliseconds, received=not source_gap)
assert not output.result.errors
assert runtime.session_id == session
@@ -340,10 +341,26 @@ def test_unchanged_map_speed_does_not_accept_new_identity_or_keep_old_prompt(tmp
pending, _, _ = drive.step(150, limit=50, wayId=124)
assert pending.message.slcState.hasPending
recovered, _, target = drive.step(200)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10) and recovered.message.slcState.hasPending
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
assert not recovered.message.slcState.hasPending and recovered.command is None
assert recovered.message.slcState.acceptedSpeedLimit == pytest.approx(45 * MPH)
assert recovered.message.slcState.decisionId != pending.message.slcState.decisionId
assert recovered.message.slcState.presentationId != pending.message.slcState.presentationId
assert not drive.step(250)[0].result.errors
def test_returned_map_limit_still_requires_confirmation_when_set_is_unaligned(tmp_path):
drive = RetentionDrive(tmp_path, confirm=True)
first, _, _ = drive.step(0)
pending, _, _ = drive.step(50, limit=50, wayId=124)
returned, _, target = drive.step(100)
assert first.message.slcState.hasPending and pending.message.slcState.hasPending
assert returned.message.slcState.hasPending and not returned.message.slcState.hasAccepted
assert returned.command is None and returned.result.acceptance.history_write is None
assert returned.message.slcState.decisionId != pending.message.slcState.decisionId
assert target == pytest.approx(published_cruise_mph(70, software_target=False), rel=0, abs=1e-10)
def test_retention_respects_lower_max_secondary_source_and_reset(tmp_path):
drive = RetentionDrive(tmp_path / 'max')
drive.step(0)
@@ -175,6 +175,7 @@ def test_wheel_cues_follow_confirmation_availability(profile, available, drawing
assert [call.args[0] for call in calls] == ([True, False] if available else [])
if available:
bounds = speed_limit_bounds(profile, current)
assert bounds is not None
assert all(call.args[2] == pytest.approx(bounds[1] + 135.5 * CONFIRMATION_SCALE[profile]) for call in calls)
assert bounds[0] < calls[0].args[1] < calls[1].args[1] < bounds[2]
@@ -199,7 +200,9 @@ def test_sign_text_stays_centered_without_an_arrow(profile, vienna, reason, draw
if reason in ('equal_speed', 'unknown_speed'):
continue
left, right = font.horizontal_ink(text, role, size)
assert abs(x + (left + right) / 2 - (389 if profile == Profile.COMPACT else 345.75)) <= .5
from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, LARGE_SIGN_SCALE
center = LIMIT_RECTS[profile][0] + 59 * (1 if profile == Profile.COMPACT else LARGE_SIGN_SCALE)
assert abs(x + (left + right) / 2 - center) <= .5
@pytest.mark.parametrize('profile', list(Profile))
@@ -302,6 +305,7 @@ def test_chooser_has_separate_tonal_actions_and_immediate_pressed_feedback(profi
assert backgrounds[3] == (77, 46, 57, 255)
bounds = speed_limit_bounds(profile, current)
drawing['confirmation'].assert_called_once_with(bounds, opened=True)
assert bounds is not None
x, y, right, bottom = bounds
panel = rl.Rectangle(x, y, right - x, bottom - y)
accept, _, reject, _ = [call.args[0] for call in drawing['draw_rectangle_rounded'].call_args_list]
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "c822f9b15f3f13ee17c2a7c0ceb3062caffb81ffebab07965943044cc00c01f2", "schema_version": 1}
{"protected_inventory_sha256": "4cd6e3f37cf2c7a7120caf10720061860177e7d3686cae6ec3cbcfb313c834d6", "schema_version": 1}
+10 -4
View File
@@ -1413,10 +1413,10 @@
"basis": "Actual complete-module setup/call/teardown receipts, hosted tests40108387164 custom14; same source blob. Scheduling estimate only; all original cases and timeout retained."
},
"openpilot/starpilot/tests/test_ford_bluepilot_aol.py": {
"blob": "e523c95becf4219ff0b4ff54dd56c21ebdcc9c4f",
"cases": 71,
"seconds": 135.1532473259997,
"basis": "Hosted run40231700807 head3b3960: 71/71 cases completed with 135.153247326 setup/call/teardown seconds."
"blob": "ce0ee89600b5b608eb2c905a43c0c8ec8db5cc0b",
"cases": 133,
"seconds": 274.055069753,
"basis": "Hosted run40449589613 head12dab8c31dd362a6198b9f2bb55facdf7dd34a9f custom19 artifact12710448232: complete module setup/call/teardown durations; every case passed. Scheduling estimate only; no grouping or timeout change."
},
"openpilot/starpilot/car/toyota/tests/test_sdsu_priority_callers.py": {
"blob": "3f8196b546f7de4ec09af452552b34b71b692371",
@@ -2101,6 +2101,12 @@
"cases": 181,
"seconds": 0.17966826400288483,
"basis": "max per-case complete phase seconds across hosted runs40231700807 and40235133424"
},
"openpilot/starpilot/tests/test_ford_recorded_lifecycle.py": {
"blob": "010f3f5b83b4c455159cbc076e5b6ab4075114c8",
"cases": 10,
"seconds": 31.794074447000412,
"basis": "Hosted run40449589613 head12dab8c31dd362a6198b9f2bb55facdf7dd34a9f custom19 artifact12710448232: complete module setup/call/teardown durations; every case passed. Scheduling estimate only; no grouping or timeout change."
}
},
"reference_sha": "6eb9df96425a661afcbf1edfa27894d6e49b3706",
+28
View File
@@ -119,10 +119,38 @@
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/main.o": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/panda.o": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/panda_safety.o": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/panda_usb.o": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/pandad": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/pandad.o": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/spi.o": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": true
},
"openpilot/selfdrive/pandad/tests/test_panda_debug": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": false
},
"openpilot/selfdrive/pandad/tests/test_panda_owner": {
"recipe": "openpilot/selfdrive/pandad/SConscript",
"required_tracked": false
+61 -2
View File
@@ -421,8 +421,15 @@
},
"openpilot/starpilot/car/toyota/tests/test_legacy_stop_timer_callers.py": {
"cases": 38,
"seconds": 51.80703006199974,
"source_sha256": "bcd8f46d4e0520181d43c19975c6b9b454199f944bcb4db034ace3a7cf7b18e0"
"seconds": 85.24435488099948,
"source_sha256": "bcd8f46d4e0520181d43c19975c6b9b454199f944bcb4db034ace3a7cf7b18e0",
"reference": {
"revision": "1320712ab4de8a531714d3a8c2e4cd7d284a868d",
"run_id": 40444892065,
"artifact_id": 12709085395,
"shard": 8,
"results_sha256": "a14538044b22704179bab89d0f9ded3a4f90480c14d4ea9c76047f933d2e48c5"
}
},
"openpilot/starpilot/car/toyota/tests/test_lexus_is_caller_native.py": {
"cases": 12,
@@ -747,6 +754,58 @@
"shard": 12,
"results_sha256": "3484585f40bb30a502adf638949ca5b251252365917d6f0d0c90db962f9ee3d5"
}
},
"openpilot/starpilot/car/toyota/tests/test_dsu_bypass_callers.py": {
"cases": 132,
"seconds": 291.0783922190003,
"source_sha256": "40d47442009fcdbc29e9b4d0b35d771e8f74b02db117d246779cc83d15df5c6c",
"reference": {
"revision": "1320712ab4de8a531714d3a8c2e4cd7d284a868d",
"run_id": 40444892065,
"artifact_id": 12709085395,
"shard": 8,
"results_sha256": "a14538044b22704179bab89d0f9ded3a4f90480c14d4ea9c76047f933d2e48c5"
}
},
"openpilot/starpilot/car/toyota/tests/test_matrix_filter_callers.py": {
"cases": 16,
"seconds": 63.53352316399997,
"source_sha256": "f374bbdb0a1e2a853b58d16b375d611661963ee63dcd62917419e2473d926e74",
"reference": {
"revision": "1320712ab4de8a531714d3a8c2e4cd7d284a868d",
"run_id": 40444892065,
"artifact_id": 12709085395,
"shard": 8,
"results_sha256": "a14538044b22704179bab89d0f9ded3a4f90480c14d4ea9c76047f933d2e48c5"
}
},
"openpilot/starpilot/tests/test_ford_bluepilot_aol.py": {
"cases": 133,
"seconds": 274.055069753,
"source_sha256": "ef4d8ff607cedc41c2cf88375a707c0eecaaab623d1a6ba8c8bb3aeb13391e68",
"reference": {
"basis": "Maximum of complete same-source whole-module hosted measurements; phase time only, no grouping or deadline changes.",
"source_blob": "ce0ee89600b5b608eb2c905a43c0c8ec8db5cc0b",
"measurements": [
{
"revision": "12dab8c31dd362a6198b9f2bb55facdf7dd34a9f",
"run_id": 40449589613,
"artifact_id": 12710448232,
"shard": 19,
"suite": "custom-pytest",
"seconds": 274.055069753
},
{
"revision": "5f84b6c763b9aa185ed4e169a63b058a4cbce3d9",
"run_id": 40477003765,
"artifact_id": 12722625581,
"shard": 8,
"suite": "aol-lifecycle",
"seconds": 266.7861787670016,
"results_sha256": "ab0b19b17563bd052360b59cac490415407f5c0196fa3762219a960e3a426cc1"
}
]
}
}
},
"reference": {
@@ -97,6 +97,51 @@ class BuildIdentityTests(unittest.TestCase):
with self.assertRaises(ValueError):
helper.same_build_inputs(built, forged)
def test_pandad_exact_outputs_preserve_checkout_build_identity(self):
recipe = 'openpilot/selfdrive/pandad/SConscript'
objects = [f'openpilot/selfdrive/pandad/{name}.o' for name in ('main', 'panda', 'panda_safety', 'panda_usb', 'pandad', 'spi')]
executable = 'openpilot/selfdrive/pandad/tests/test_panda_debug'
for name in [*objects, executable]:
self.assertEqual(self.inventory.get(name), {'recipe': recipe, 'required_tracked': name != executable})
source = self.root / (executable + '.cc')
source.write_text('native test source\n')
subprocess.run([*GIT, 'rm', '--cached', executable], cwd=self.root, check=True, capture_output=True)
subprocess.run([*GIT, 'add', executable + '.cc'], cwd=self.root, check=True)
subprocess.run([*GIT, '-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'Pandad source fixture'],
cwd=self.root, check=True)
(self.root / executable).unlink()
checkout = helper.build_identity(self.root)
for name in [*objects, executable]:
(self.root / name).write_bytes(b'independent host build')
(self.root / executable).chmod(0o755)
built = helper.build_identity(self.root)
self.assertTrue(helper.same_build_inputs(checkout, built))
self.assertNotEqual(checkout, built) # In-job output mutation still fails the before/after guard.
self.assertIn(executable + '.cc', built['inputs']['tracked_inputs'])
self.assertIn(recipe, built['inputs']['recipes'])
for name in [*objects, executable]:
self.assertEqual(built['declared_outputs'][name], helper.file_record(self.root / name))
forged = copy.deepcopy(built)
del forged['declared_outputs'][name]
with self.assertRaises(ValueError):
helper.same_build_inputs(checkout, forged)
for path in (source, self.root / recipe):
original = path.read_bytes()
path.write_bytes(b'changed input')
self.assertFalse(helper.same_build_inputs(built, helper.build_identity(self.root)))
path.write_bytes(original)
neighbor = self.root / 'openpilot/selfdrive/pandad/undeclared.o'
neighbor.write_bytes(b'undeclared object')
self.assertFalse(helper.same_build_inputs(built, helper.build_identity(self.root)))
neighbor.unlink()
for name in objects:
path = self.root / name
original = path.read_bytes()
path.unlink()
with self.assertRaises(ValueError):
helper.build_identity(self.root)
path.write_bytes(original)
def test_missing_declared_artifact_is_denied(self):
(self.root / 'msgq_repo/msgq/ipc_pyx.so').unlink()
with self.assertRaises(ValueError):