mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 09:33:48 +08:00
Merge current changes for Toyota source cutoff fix
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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); }
|
||||
@@ -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
|
||||
|
||||
+42
-4
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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,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('&', '&') })
|
||||
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>`,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "c822f9b15f3f13ee17c2a7c0ceb3062caffb81ffebab07965943044cc00c01f2", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "4cd6e3f37cf2c7a7120caf10720061860177e7d3686cae6ec3cbcfb313c834d6", "schema_version": 1}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user