Complete Galaxy settings and vehicle controls

This commit is contained in:
firestar5683
2026-10-13 23:07:13 -05:00
parent a019b9788f
commit 72ef147f07
197 changed files with 7699 additions and 1935 deletions
+28
View File
@@ -0,0 +1,28 @@
name: simulator driving (manual)
on:
workflow_dispatch:
permissions:
contents: read
env:
CI: 1
PYTHONPATH: ${{ github.workspace }}
jobs:
simulator_driving:
name: simulator driving
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons
- name: Driving test
timeout-minutes: 2
env:
# MetaDrive renders offscreen through panda3d's EGL pipe on llvmpipe
EGL_PLATFORM: surfaceless
run: python openpilot/tools/sim/tests/test_metadrive_bridge.py
-21
View File
@@ -427,27 +427,6 @@ jobs:
max_attempts: 3
command: cd ${{ github.workspace }}/ci-artifacts && git push origin process-replay --force
simulator_driving:
name: simulator driving
runs-on: ${{
(github.repository == 'commaai/openpilot') &&
((github.event_name != 'pull_request') ||
(github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))
&& fromJSON('["namespace-profile-amd64-8x16"]')
|| fromJSON('["ubuntu-24.04"]') }}
if: false # FIXME: Started to timeout recently
steps:
- uses: actions/checkout@v7
- run: ./tools/op.sh setup
- name: Build openpilot
run: scons
- name: Driving test
timeout-minutes: 2
env:
# MetaDrive renders offscreen through panda3d's EGL pipe on llvmpipe
EGL_PLATFORM: surfaceless
run: python openpilot/tools/sim/tests/test_metadrive_bridge.py
create_ui_report:
name: Create UI Report
runs-on: ${{
+15 -73
View File
@@ -1,78 +1,20 @@
<p align="center">
<img src="macos/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png" width="128" height="128" alt="Jetlink icon">
</p>
# Jetlink runtime for StarPilot
<h1 align="center">Jetlink</h1>
This subtree supplies the Jetlink remote-model client used by **StarPilot**.
For installing, connecting, selecting, and running it with StarPilot, follow
[the StarPilot JetLink guide](../openpilot/starpilot/models/JETLINK.md).
<p align="center">
Run openpilot's large driving models on a computer connected to your comma.
</p>
The comma runs StarPilot; the connected host runs the upstream Jetlink application.
Host apps, installers, development references, and original history are maintained
in [ZoomPilot / Jetlink](https://github.com/zoompilot/jetlink/tree/c17cd5c).
This runtime subtree does not contain the upstream host applications or docs;
their source attribution does not require installing ZoomPilot on the comma.
**Jetlink is experimental.** It requires zoompilot's
[`develop` branch](https://github.com/zoompilot/zoompilot/tree/develop).
If the link drops or lags while engaged, the comma says **TAKE CONTROL** and
stays engaged on the small model. Be ready to take over.
Jetlink is experimental. Use separate power and USB 3, set up parked, and verify
StarPilot's active model status. See the local guide for recovery and limitations.
## Quick start
## Attribution
You need a **comma 3X or comma 4**, a **USB 3 data cable**, and **separate power
for the comma and computer**. Set up offroad with both devices online.
Choose your computer. Each guide covers installation and connecting the comma.
| Computer | Setup guide |
| --- | --- |
| Jetson Orin Nano Super | [Set up a Jetson](docs/jetson.md) |
| Apple silicon Mac | [Set up a Mac](docs/macos-app.md) |
| Linux PC with an NVIDIA GPU | [Set up a Linux PC](docs/linux-pc.md) |
| iPhone or iPad with USB-C | [Install with TestFlight](docs/iphone-app.md) |
| Android phone with USB 3 | [Try the experimental Android app](docs/android-app.md) |
## Demos
### Jetson
<a href="docs/images/install-demo.mp4"><img src="docs/images/install-demo.webp" width="100%" alt="Jetlink installer on a Jetson"></a>
<details>
<summary>Watch the web page</summary>
<a href="docs/images/webui-demo.mp4"><img src="docs/images/webui-demo.webp" width="100%" alt="The Jetson's web page on a phone and a computer, with simulated drive data: sign in, frame timing against the 50 ms budget, a model downloaded and prepared (sped up), the power setting applied, and keep awake"></a>
</details>
### Mac
<a href="docs/images/mac-demo.mp4"><img src="docs/images/mac-demo.webp" width="100%" alt="Jetlink for Mac: Use Model downloads and prepares a model, the comma connects over USB, and Status shows each frame against the 50 ms budget"></a>
### iPhone and iPad (experimental)
Simulator demo.
<a href="docs/images/iphone-demo.mp4"><img src="docs/images/iphone-demo.webp" width="100%" alt="Jetlink for iPhone, recorded in the iOS Simulator: Get downloads and prepares a model, the comma connects over USB, and Status shows each frame against the 50 ms budget, with timings modeled on an iPhone 17 Pro measurement"></a>
<a href="https://testflight.apple.com/join/DAsYk5sP"><img src="docs/images/testflight-badge.svg" alt="Available on TestFlight" height="40"></a>
### Android (experimental)
Emulator demo.
<a href="docs/images/android-demo.mp4"><img src="docs/images/android-demo.webp" width="100%" alt="Jetlink for Android, recorded in the Android emulator: Get downloads a model, the comma connects over USB, and Status shows each frame against the 50 ms budget. The download is sped up, the emulator's CPU preparation cut, and the model's time modeled on an estimate for a Snapdragon 8 Gen 3"></a>
<a id="comma-setup-all-platforms"></a>
<a id="jetson-or-linux-pc"></a>
<a id="linux-pc"></a>
<a id="what-to-expect-when-driving"></a>
<a id="if-something-is-wrong"></a>
<a id="more"></a>
## Help
[Setup guides](docs/README.md#set-up) ·
[Daily use](docs/using-jetlink.md) ·
[Troubleshooting](docs/troubleshooting.md) ·
[Operating limits](docs/status.md)
## License
[MIT](LICENSE).
Jetlink was developed by Zeph Leggett / ZoomPilot. Copyright (c) 2026 Zeph Leggett.
The original [MIT license](LICENSE) is preserved without modification.
[Upstream README and credits](https://github.com/zoompilot/jetlink/blob/c17cd5c/README.md).
+13 -11
View File
@@ -262,6 +262,7 @@ class CarController(CarControllerBase):
def __init__(self, dbc_names, CP):
super().__init__(dbc_names, CP)
self.long_pitch = True
self.long_pitch_input = None
self.bolt_removed_cancel_credit_used = 0
self.bolt_pedal_removed = is_bolt_pedal_removed_profile(CP) or is_bolt_pedal_removed_profile(CP, stock_only=True)
self.bolt_present_no_acc_stock = is_bolt_present_no_acc_pedal_profile(CP, stock_only=True)
@@ -439,6 +440,7 @@ class CarController(CarControllerBase):
return self.regen_paddle_pressed
def update(self, CC, CS, now_nanos, *, startup_keepalive=False):
long_pitch = self.long_pitch
if ((self.camera_pedal_profile is not None and self.camera_pedal_profile.volt or self.volt_cc_pedal_profile is not None)
and self.frame % 25 == 0):
self.longitudinal_maneuver_mode = bool(self.longitudinal_maneuver_input is not None and
@@ -625,7 +627,7 @@ class CarController(CarControllerBase):
pedal = self.hybrid_command.update(
actuators.accel, CC.enabled and CC.longActive, CS.out,
stopping=actuators.longControlState == LongCtrlState.stopping, resume=CC.cruiseControl.resume,
orientation=CC.orientationNED if self.long_pitch else None, acc_tune=self.gm_acc_tune)
orientation=CC.orientationNED if long_pitch else None, acc_tune=self.gm_acc_tune)
else:
self.hybrid_command.withdraw()
pedal = 0.
@@ -678,7 +680,7 @@ class CarController(CarControllerBase):
ready = runtime_allowed and physical and source_current and sensor_current
pedal = 0.
if ready:
gas, _ = volt_demands(actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None,
gas, _ = volt_demands(actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None,
.75, self.CP.mass, self.CP.wheelbase, -4., 2., 400)
pedal = self.volt_cc_pedal_command.update(
actuators.accel, CC.enabled and CC.longActive, CS.out,
@@ -715,7 +717,7 @@ class CarController(CarControllerBase):
if ready:
pedal, self.apply_gas, self.apply_brake = self.silverado_pedal_command.update(
actuators.accel, CC.longActive, CS.out, stopping=actuators.longControlState == LongCtrlState.stopping,
resume=CC.cruiseControl.resume, orientation=CC.orientationNED if self.long_pitch else None)
resume=CC.cruiseControl.resume, orientation=CC.orientationNED if long_pitch else None)
else:
if not sources_ready:
self.silverado_pedal_command.prime_recovery()
@@ -735,7 +737,7 @@ class CarController(CarControllerBase):
if ready:
pedal, self.apply_gas, self.apply_brake = self.conventional_pedal_command.update(
actuators.accel, CC.longActive and CC.enabled, CS.out, stopping=actuators.longControlState == LongCtrlState.stopping,
resume=CC.cruiseControl.resume, orientation=CC.orientationNED if self.long_pitch else None)
resume=CC.cruiseControl.resume, orientation=CC.orientationNED if long_pitch else None)
else:
if not sources_ready:
self.conventional_pedal_command.prime_recovery()
@@ -803,7 +805,7 @@ class CarController(CarControllerBase):
0 < gear_stamp <= now_nanos and now_nanos - gear_stamp <= 100_000_000 and
(CS.out.standstill or math.isfinite(CS.out.vEgo) and CS.out.vEgo < cutoff) and
actuators.longControlState != LongCtrlState.stopping and
bolt_pedal_launch_gas(actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None,
bolt_pedal_launch_gas(actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None,
self.CP, acc_tune=self.gm_acc_tune)):
pedal = 18. / 255.
can_sends.append(gmcan.create_pedal_command(self.packer_pt, pedal, (self.frame // 4) % 16))
@@ -878,10 +880,10 @@ class CarController(CarControllerBase):
else:
if self.CP.carFingerprint == CAR.CHEVROLET_SUBURBAN:
self.apply_gas, self.apply_brake = suburban_gateway_demands(
actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None, self.CP, acc_tune=self.gm_acc_tune)
actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None, self.CP, acc_tune=self.gm_acc_tune)
elif self.ordinary_camera_long:
self.apply_gas, self.apply_brake = ascm_demands(
actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None, self.CP,
actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None, self.CP,
min_gas=-540, max_gas=2698, inactive_gas=-500, brake_threshold=0.0, acc_tune=self.gm_acc_tune,
truck_tuning=self.truck_tuning, lead_visible=CC.hudControl.leadVisible,
set_speed_error=max(CC.hudControl.setSpeed - CS.out.vEgo, 0.0), stopping=stopping)
@@ -892,14 +894,14 @@ class CarController(CarControllerBase):
stop_speed=0.35 if self.CP.carFingerprint == CAR.CHEVROLET_BLAZER else 0.25, acc_tune=self.gm_acc_tune)
elif self.ordinary_ascm_long:
self.apply_gas, self.apply_brake = ascm_demands(
actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None, self.CP, acc_tune=self.gm_acc_tune)
actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None, self.CP, acc_tune=self.gm_acc_tune)
elif self.bolt_euv_long:
self.apply_gas, self.apply_brake = bolt_euv_demands(
actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None,
actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None,
self.CP.mass, self.CP.wheelbase, self.params.MAX_BRAKE, acc_tune=self.gm_acc_tune)
elif self.volt_gateway_long or self.volt_ascm_long or self.volt_camera_long or self.volt_sdgm_long:
self.apply_gas, self.apply_brake = volt_demands(
actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None,
actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None,
VOLT_CAMERA_GRADE_STOP_SPEED if self.volt_ascm_long or self.volt_camera_long or self.volt_sdgm_long else VOLT_GATEWAY_GRADE_STOP_SPEED,
self.CP.mass, self.CP.wheelbase, self.params.ACCEL_MIN, self.params.ACCEL_MAX, self.params.MAX_BRAKE,
min_gas=-540 if self.volt_camera_long or self.volt_sdgm_long else -650,
@@ -910,7 +912,7 @@ class CarController(CarControllerBase):
if self.gm_acc_tune else actuators.accel)
if self.CP.carFingerprint in VOLT_GRADE_CAR:
gas_demand, brake_demand = volt_grade_demands(
actuators.accel, CS.out.vEgo, CC.orientationNED if self.long_pitch else None,
actuators.accel, CS.out.vEgo, CC.orientationNED if long_pitch else None,
VOLT_GATEWAY_GRADE_STOP_SPEED if self.CP.networkLocation == NetworkLocation.gateway else VOLT_CAMERA_GRADE_STOP_SPEED,
self.params.ACCEL_MIN, self.params.ACCEL_MAX)
self.apply_gas = float(np.interp(gas_demand, self.params.GAS_LOOKUP_BP, self.params.GAS_LOOKUP_V))
@@ -9,6 +9,7 @@ from opendbc.car.secoc import add_mac, build_sync_mac
from opendbc.car.interfaces import CarControllerBase
from opendbc.car.toyota import toyotacan
from opendbc.car.toyota import prius_longitudinal
from opendbc.car.toyota.door_locks import DoorLocks
from opendbc.car.toyota.values import CAR, CarControllerParams, ToyotaFlags, TOYOTA_AUTO_HOLD_CARS, uses_toyota_auto_hold_aeb
from opendbc.can import CANPacker
from opendbc.safety import ALTERNATIVE_EXPERIENCE
@@ -69,6 +70,7 @@ def get_long_tune(CP, params):
class CarController(CarControllerBase):
def __init__(self, dbc_names, CP):
super().__init__(dbc_names, CP)
self.door_locks = DoorLocks()
self.reverse_cruise_input = None
self.prius_filter_input = None
self.params = CarControllerParams(self.CP)
@@ -386,6 +388,11 @@ class CarController(CarControllerBase):
if self.frame % 20 == 0 and self.CP.flags & ToyotaFlags.DISABLE_RADAR.value:
can_sends.append(make_tester_present_msg(0x750, 0, 0xF))
door_command = self.door_locks.update(CS.out)
if door_command is not None:
from opendbc.car import CanData
can_sends.append(CanData(0x750, door_command, 0))
new_actuators = actuators.as_builder()
new_actuators.torque = apply_torque / self.params.STEER_MAX
new_actuators.torqueOutputCan = apply_torque
@@ -0,0 +1,35 @@
"""Stationary Toyota door commands on actual observed gear transitions.
Command payloads from Dom StarPilot, credited there to AlexandreSato.
This owner never changes Panda safety mode or sends diagnostics directly.
"""
import math
from opendbc.car.structs import CarState
# Credit: AlexandreSato (original Dom Toyota controller).
LOCK_CMD = b"\x40\x05\x30\x11\x00\x80\x00\x00"
UNLOCK_CMD = b"\x40\x05\x30\x11\x00\x40\x00\x00"
Gear = CarState.GearShifter
class DoorLocks:
def __init__(self):
self.lock = self.unlock = False
self.previous_gear = None
def update(self, state):
gear = state.gearShifter
if not state.canValid or state.canTimeout or gear not in (Gear.park, Gear.drive, Gear.reverse, Gear.neutral):
self.previous_gear = None
return None
previous, self.previous_gear = self.previous_gear, gear
if (previous is None or previous == gear or not state.standstill or not math.isfinite(state.vEgoRaw) or
abs(state.vEgoRaw) > .01 or state.doorOpen or state.gasPressed or state.vehicleSensorsInvalid):
return None
if previous == Gear.park and gear != Gear.park and self.lock:
return LOCK_CMD
if previous != Gear.park and gear == Gear.park and self.unlock:
return UNLOCK_CMD
return None
@@ -0,0 +1,62 @@
"""Actual factory Toyota controller emits only one stationary transition command."""
import pytest
from opendbc.car import structs
from opendbc.car.toyota.door_locks import LOCK_CMD, UNLOCK_CMD
from opendbc.car.toyota.tests.test_auto_hold import setup_hold, step_hold
from opendbc.car.toyota.values import CAR
Gear = structs.CarState.GearShifter
def body_commands(controller, command, state):
return [frame for frame in step_hold(controller, command, state) if frame[0] == 0x750 and frame[1] in (LOCK_CMD, UNLOCK_CMD)]
@pytest.mark.parametrize('car', [CAR.TOYOTA_PRIUS, CAR.TOYOTA_COROLLA_TSS2, CAR.TOYOTA_RAV4_TSS2])
def test_actual_controller_park_drive_park_one_shot_with_no_control_enable(car):
_, controller, command, state = setup_hold(car, enabled=False)
assert not command.enabled and not command.latActive and not command.longActive
controller.door_locks.lock = controller.door_locks.unlock = True
state.out.gearShifter = Gear.park
assert body_commands(controller, command, state) == []
state.out.gearShifter = Gear.drive
assert body_commands(controller, command, state) == [(0x750, LOCK_CMD, 0)]
assert body_commands(controller, command, state) == []
state.out.gearShifter = Gear.park
assert body_commands(controller, command, state) == [(0x750, UNLOCK_CMD, 0)]
assert body_commands(controller, command, state) == []
@pytest.mark.parametrize('fault', ['moving', 'door', 'gas', 'invalid_can', 'timeout', 'invalid_sensors'])
def test_actual_controller_rejects_physical_or_source_fault_at_transition(fault):
_, controller, command, state = setup_hold(enabled=False)
controller.door_locks.lock = controller.door_locks.unlock = True
state.out.gearShifter = Gear.park
assert body_commands(controller, command, state) == []
state.out.gearShifter = Gear.drive
if fault == 'moving':
state.out.standstill, state.out.vEgoRaw = False, 1.0
elif fault == 'door':
state.out.doorOpen = True
elif fault == 'gas':
state.out.gasPressed = True
elif fault == 'invalid_can':
state.out.canValid = False
elif fault == 'timeout':
state.out.canTimeout = True
else:
state.out.vehicleSensorsInvalid = True
assert body_commands(controller, command, state) == []
def test_defaults_off_and_starting_in_drive_never_create_transition():
_, controller, command, state = setup_hold(enabled=False)
state.out.gearShifter = Gear.park
assert body_commands(controller, command, state) == []
state.out.gearShifter = Gear.drive
assert body_commands(controller, command, state) == []
_, controller, command, state = setup_hold(enabled=False)
controller.door_locks.lock = True
assert body_commands(controller, command, state) == []
+25 -12
View File
@@ -6,6 +6,7 @@
// Stock longitudinal
#define TOYOTA_BASE_TX_MSGS \
{0x191, 0, 8, .check_relay = true}, {0x412, 0, 8, .check_relay = true}, {0x1D2, 0, 8, .check_relay = false}, /* LKAS + LTA + PCM cancel cmd */ \
{0x750, 0, 8, .check_relay = false}, /* literal stationary body commands, restricted below */ \
#define TOYOTA_COMMON_TX_MSGS \
TOYOTA_BASE_TX_MSGS \
@@ -27,8 +28,6 @@
{0x470, 1, 4, .check_relay = false}, \
/* PCS_HUD */ \
{0x411, 0, 8, .check_relay = false}, \
/* radar diagnostic address */ \
{0x750, 0, 8, .check_relay = false}, \
/* ACC */ \
{0x343, 0, 8, .check_relay = true}, \
@@ -72,6 +71,7 @@ static bool toyota_secoc = false;
static bool toyota_alt_brake = false;
static bool toyota_stock_longitudinal = false;
static bool toyota_lta = false;
static bool toyota_radar_diagnostics_allowed = false;
static int toyota_dbc_eps_torque_factor = 100; // conversion factor for STEER_TORQUE_EPS in %: see dbc file
static bool toyota_hold_main_on = false;
@@ -88,9 +88,7 @@ static bool toyota_hold_main_seen = false;
static bool toyota_hold_speed_seen = false;
static bool toyota_hold_gas_seen = false;
static bool toyota_hold_ready(int permission) {
const int hold_mask = ALT_EXP_TOYOTA_AUTO_HOLD | ALT_EXP_TOYOTA_AEB_HOLD;
const uint32_t now = microsecond_timer_get();
static bool toyota_rx_ready(uint32_t now) {
bool rx_valid = true;
for (int i = 0; i < current_safety_config.rx_checks_len; i++) {
const RxStatus *status = &current_safety_config.rx_checks[i].status;
@@ -98,7 +96,13 @@ static bool toyota_hold_ready(int permission) {
(status->wrong_counters < MAX_WRONG_COUNTERS) && !status->lagging &&
(safety_get_ts_elapsed(now, status->last_timestamp) <= 1000000U);
}
return rx_valid && ((alternative_experience & hold_mask) == permission) && !toyota_stock_longitudinal && !toyota_secoc &&
return rx_valid;
}
static bool toyota_hold_ready(int permission) {
const int hold_mask = ALT_EXP_TOYOTA_AUTO_HOLD | ALT_EXP_TOYOTA_AEB_HOLD;
const uint32_t now = microsecond_timer_get();
return toyota_rx_ready(now) && ((alternative_experience & hold_mask) == permission) && !toyota_stock_longitudinal && !toyota_secoc &&
toyota_hold_config_valid && !safety_rx_checks_invalid && !relay_malfunction && !vehicle_moving && !gas_pressed && toyota_hold_main_on &&
toyota_hold_main_seen && toyota_hold_speed_seen && toyota_hold_gas_seen &&
(safety_get_ts_elapsed(now, toyota_hold_main_ts) <= 1000000U) &&
@@ -175,7 +179,8 @@ static void toyota_rx_hook(const CANPacket_t *msg) {
toyota_hold_speed_seen = true;
toyota_hold_speed_ts = microsecond_timer_get();
}
if ((msg_matches(msg, 0x1D2U, 0U)) && (GET_LEN(msg) == 8U)) {
if ((((msg_matches(msg, 0x1D2U, 0U)) && !toyota_secoc) ||
((msg_matches(msg, 0x116U, 0U)) && toyota_secoc)) && (GET_LEN(msg) == 8U)) {
toyota_hold_gas_seen = true;
toyota_hold_gas_ts = microsecond_timer_get();
}
@@ -441,12 +446,19 @@ static bool toyota_tx_hook(const CANPacket_t *msg) {
}
}
// UDS: Only tester present ("\x0F\x02\x3E\x00\x00\x00\x00\x00") allowed on diagnostics address
if (msg->addr == 0x750U) {
// this address is sub-addressed. only allow tester present to radar (0xF)
bool invalid_uds_msg = GET_BYTES_64_LE(msg, 0, 8) != 0x00000000003E020FULL;
if (invalid_uds_msg) {
tx = false;
uint64_t payload = GET_BYTES_64_LE(msg, 0, 8);
bool body_command = (payload == 0x0000800011300540ULL) || (payload == 0x0000400011300540ULL);
if (body_command) {
// Never admit arbitrary UDS, an unobserved/stale stop, or a moving car.
const uint32_t now = microsecond_timer_get();
tx &= toyota_rx_ready(now) && toyota_hold_speed_seen && toyota_hold_gas_seen &&
!vehicle_moving && !gas_pressed && !safety_rx_checks_invalid && !relay_malfunction &&
(safety_get_ts_elapsed(now, toyota_hold_speed_ts) <= 100000U) &&
(safety_get_ts_elapsed(now, toyota_hold_gas_ts) <= 100000U);
} else {
// Preserve the original radar-only tester-present allowance and configuration scope.
tx &= toyota_radar_diagnostics_allowed && (payload == 0x00000000003E020FULL);
}
}
@@ -502,6 +514,7 @@ static safety_config toyota_init(uint16_t param) {
toyota_alt_brake = GET_FLAG(param, TOYOTA_PARAM_ALT_BRAKE);
toyota_stock_longitudinal = GET_FLAG(param, TOYOTA_PARAM_STOCK_LONGITUDINAL);
toyota_radar_diagnostics_allowed = !toyota_stock_longitudinal && !toyota_secoc && (param != 0x1049U);
toyota_lta = GET_FLAG(param, TOYOTA_PARAM_LTA);
toyota_dbc_eps_torque_factor = param & TOYOTA_EPS_FACTOR;
@@ -357,3 +357,16 @@ bool safety_test_hyundai_angle_invalid_capacity(unsigned int count) {
const bool bounded = hyundai_canfd_angle_aol_capacity(&fixture);
return !bounded && (fixture.rx_checks_len == 0) && (fixture.tx_msgs_len == 0);
}
// Toyota's actual required-RxStatus guard, exercised without a periodic safety tick.
bool safety_test_toyota_rx_summary_invalid(void) {
return safety_rx_checks_invalid;
}
bool safety_test_toyota_invalid_counter_status(void) {
if ((current_safety_mode != SAFETY_TOYOTA) || (current_safety_config.rx_checks_len <= 0)) {
return false;
}
current_safety_config.rx_checks[0].status.wrong_counters = MAX_WRONG_COUNTERS;
return !safety_rx_checks_invalid;
}
@@ -9,12 +9,11 @@ from opendbc.car.structs import CarParams
from opendbc.safety.tests.libsafety import libsafety_py
import opendbc.safety.tests.common as common
TOYOTA_COMMON_TX_MSGS = [[0x2E4, 0], [0x191, 0], [0x412, 0], [0x343, 0], [0x1D2, 0]] # LKAS + LTA + ACC & PCM cancel cmds
TOYOTA_COMMON_TX_MSGS = [[0x2E4, 0], [0x191, 0], [0x412, 0], [0x343, 0], [0x1D2, 0], [0x750, 0]] # LKAS + LTA + ACC & PCM cancel cmds
TOYOTA_SECOC_TX_MSGS = [[0x131, 0], [0x183, 0]] + TOYOTA_COMMON_TX_MSGS
TOYOTA_COMMON_LONG_TX_MSGS = [[0x283, 0], [0x2E6, 0], [0x2E7, 0], [0x33E, 0], [0x344, 0], [0x365, 0], [0x366, 0], [0x4CB, 0], # DSU bus 0
[0x128, 1], [0x141, 1], [0x160, 1], [0x161, 1], [0x470, 1], # DSU bus 1
[0x411, 0], # PCS_HUD
[0x750, 0]] # radar diagnostic address
[0x411, 0]] # PCS_HUD
class TestToyotaSafetyBase(common.CarSafetyTest, common.LongitudinalAccelSafetyTest):
@@ -77,6 +76,81 @@ class TestToyotaSafetyBase(common.CarSafetyTest, common.LongitudinalAccelSafetyT
values = {"CRUISE_ACTIVE": enable}
return self.packer.make_can_msg_safety("PCM_CRUISE", 0, values)
def _body_required_rx(self, *, include_gas=True):
self.assertTrue(self._rx(self._speed_msg(0)))
self.assertTrue(self._rx(self._torque_meas_msg(0)))
self.assertTrue(self._rx(self._user_brake_msg(0)))
if self.SAFETY_PARAM & ToyotaSafetyFlags.SECOC:
self.assertTrue(self._rx(self._pcm_status_msg(False)))
if include_gas:
self.assertTrue(self._rx(self._user_gas_msg(0)))
def _body_command(self, data=b"\x40\x05\x30\x11\x00\x80\x00\x00", bus=0):
return libsafety_py.make_CANPacket(0x750, bus, data)
def test_literal_body_commands_require_current_stationary_speed(self):
for data in (b"\x40\x05\x30\x11\x00\x80\x00\x00", b"\x40\x05\x30\x11\x00\x40\x00\x00"):
self.safety.set_safety_hooks(self.SAFETY_MODEL, self.SAFETY_PARAM)
self.safety.set_timer(1_000_000)
self.assertFalse(self._tx(self._body_command(data)))
self._body_required_rx()
self.assertTrue(self._tx(self._body_command(data)))
self.assertTrue(self._rx(self._user_gas_msg(1)))
self.assertFalse(self._tx(self._body_command(data)))
self.assertTrue(self._rx(self._user_gas_msg(0)))
self.assertTrue(self._tx(self._body_command(data)))
for bus in (1, 2):
self.assertFalse(self._tx(self._body_command(data, bus)))
self.assertFalse(self._tx(self._body_command(data[:-1])))
for index in range(8):
changed = bytearray(data)
changed[index] ^= 1
self.assertFalse(self._tx(self._body_command(bytes(changed))))
self.safety.set_timer(1_100_001)
self.assertFalse(self._tx(self._body_command(data)))
self._body_required_rx()
self.assertTrue(self._rx(self._speed_msg(1)))
self.assertFalse(self._tx(self._body_command(data)))
self.assertTrue(self._rx(self._speed_msg(0)))
self.safety.set_relay_malfunction(True)
self.assertFalse(self._tx(self._body_command(data)))
self.safety.set_relay_malfunction(False)
def test_body_commands_require_observed_fresh_selected_gas(self):
self.safety.set_timer(1_000_000)
self._body_required_rx(include_gas=False)
self.assertFalse(self._tx(self._body_command()))
self.assertTrue(self._rx(self._user_gas_msg(0)))
self.assertTrue(self._tx(self._body_command()))
self.safety.set_timer(1_100_001)
self._body_required_rx(include_gas=False)
self.assertFalse(self._tx(self._body_command()))
if self.SAFETY_PARAM & ToyotaSafetyFlags.SECOC:
# A fresh non-SecOC PCM frame cannot refresh this configuration's gas witness.
data = bytearray([16] + [0] * 7)
data[-1] = ((0x1D2 & 255) + (0x1D2 >> 8) + len(data) + sum(data[:-1])) & 255
self.assertTrue(self._rx(libsafety_py.make_CANPacket(0x1D2, 0, bytes(data))))
self.assertFalse(self._tx(self._body_command()))
self.assertTrue(self._rx(self._user_gas_msg(0)))
self.assertTrue(self._tx(self._body_command()))
def test_body_commands_reject_new_rx_fault_before_periodic_tick(self):
for fault in ('quality', 'checksum', 'counter_status'):
self.safety.set_safety_hooks(self.SAFETY_MODEL, self.SAFETY_PARAM)
self.safety.set_timer(1_000_000)
self._body_required_rx()
self.assertTrue(self._tx(self._body_command()))
self.assertFalse(self.safety.safety_test_toyota_rx_summary_invalid())
if fault == 'quality':
self.assertFalse(self._rx(self._speed_msg(0, quality_flag=False)))
elif fault == 'checksum':
self.assertFalse(self._rx(libsafety_py.make_CANPacket(0x260, 0, bytes(8))))
else:
# Toyota ignores parsed counters: explicitly inject the actual status guard boundary.
self.assertTrue(self.safety.safety_test_toyota_invalid_counter_status())
self.assertFalse(self.safety.safety_test_toyota_rx_summary_invalid())
self.assertFalse(self._tx(self._body_command()))
def test_diagnostics(self, stock_longitudinal: bool = False, ecu_disabled: bool = True):
for should_tx, msg in ((False, b"\x6D\x02\x3E\x00\x00\x00\x00\x00"), # fwdCamera tester present
(False, b"\x0F\x03\xAA\xAA\x00\x00\x00\x00"), # non-tester present
Binary file not shown.
Binary file not shown.
+3
View File
@@ -282,6 +282,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"RecordFront", {PERSISTENT, BOOL}},
{"RecordFrontLock", {PERSISTENT, BOOL}}, // for the internal fleet
{"SecOCKey", {PERSISTENT | DONT_LOG, STRING}},
{"SecOCKeys", {PERSISTENT | DONT_LOG, STRING}},
{"LockDoors", {PERSISTENT, BOOL, "0"}},
{"UnlockDoors", {PERSISTENT, BOOL, "0"}},
{"ShowDebugInfo", {PERSISTENT, BOOL}},
{"ShowBrakeStatus", {PERSISTENT, BOOL, "0"}},
{"HideDMIcon", {PERSISTENT, BOOL, "0"}},
+23 -3
View File
@@ -223,6 +223,15 @@ class Car:
openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle")
startup_preferences = VehicleStartupPreferences.read(self.params, enabled=openpilot_enabled_toggle)
def initialize_aol_preference(cp):
from openpilot.starpilot.aol.defaults import enroll_missing_preference
enroll_missing_preference(self.params, cp)
if not self.aol_replay and feature_requested(self.params, 'aol'):
self.aol_replay = True
subscribed_services.extend(('aolSafetyWire', 'managerState', 'aolAxisState'))
self.sm = messaging.SubMaster(subscribed_services)
self.pm.sock['aolIntentWire'] = messaging.pub_sock('aolIntentWire')
if CI is None:
# wait for one pandaState and one CAN packet
print("Waiting for CAN messages...")
@@ -240,6 +249,7 @@ class Car:
cached_params = _cached_params
def pre_create_hook(cp, candidate, fingerprints, car_fw):
initialize_aol_preference(cp)
if candidate == HYUNDAI_CAR.HYUNDAI_IONIQ_6:
# This decision must precede CarInterface parser/controller construction
# and the first CarParams publication below. The exact opt-in is
@@ -255,6 +265,8 @@ class Car:
cloudlog.event('ioniq6_long_preflight', reason=preflight.reason)
if preflight.source_period is None:
long_cp = None
if long_cp is not None:
initialize_aol_preference(long_cp)
if long_cp is not None and self.aol_replay and independent_axis_requested(read_settings(self.params)):
# Bit 11 is AOL only inside the exact Ioniq 6 bit-15 namespace.
# This immutable choice precedes the ECU transaction and CP publish.
@@ -314,15 +326,17 @@ class Car:
try:
with open("/cache/params/SecOCKey") as f:
user_key = f.readline().strip()
if len(user_key) == 32:
from openpilot.starpilot.car.toyota.security_key import decode_key
if decode_key(user_key) is not None:
self.params.put("SecOCKey", user_key, block=True)
except Exception:
pass
from openpilot.starpilot.car.toyota.security_key import decode_key
secoc_key = self.params.get("SecOCKey")
if secoc_key is not None:
saved_secoc_key = bytes.fromhex(secoc_key.strip())
if len(saved_secoc_key) == 16:
saved_secoc_key = decode_key(secoc_key)
if saved_secoc_key is not None:
self.CP.secOcKeyAvailable = True
self.CI.CS.secoc_key = saved_secoc_key
if controller_available:
@@ -339,6 +353,7 @@ class Car:
subscribed_services.extend(('aolSafetyWire', 'managerState', 'aolAxisState'))
self.sm = messaging.SubMaster(subscribed_services)
self.pm.sock['aolIntentWire'] = messaging.pub_sock('aolIntentWire')
initialize_aol_preference(self.CP)
self.aol_settings = read_settings(self.params) if self.aol_replay else None
self.aol_qualified = bool(self.aol_settings is not None and
(aol_policy.full_axis_runtime_required or aol_policy.physical_stalk_owner or independent_axis_requested(self.aol_settings,
@@ -827,6 +842,8 @@ class Car:
events = [event for event in events if not (event.name == log.OnroadEvent.EventName.locationdTemporaryError and
event.softDisable and not event.immediateDisable)]
return intent_disarming_fault(self.CP, events, CS,
startup_calibration_pending=bool(getattr(self.aol_card_intent, 'startup_calibration_pending', False) and
self.aol_card_intent.settings.enabled and self.aol_card_intent.settings.main_action != 9),
temporary_ui_process_failure=self.aol_process_fault_context.temporary_ui_failure(event_ns, now_ns),
temporary_selfdrive_lagging=(qualified_gm(self.CP) or aol_policy_for(self.CP).intent_supported),
temporary_source_pause=(self.aol_transport_feedback.qualified or self.aol_transport_feedback.awaiting))
@@ -1489,6 +1506,9 @@ class Car:
if now >= next_cruise_read:
self.slc_requested_configuration = self.read_slc_configuration()
self.v_cruise_helper.intervals = read_cruise_intervals(self.params, pcm_cruise=self.CP.pcmCruise)
pitch_input = getattr(self.CI.CC, 'long_pitch_input', None)
if pitch_input is not None:
self.CI.CC.long_pitch = pitch_input.update(time.monotonic_ns())
next_cruise_read = now + 1.0
if timing is not None:
timing.worker_end()
+12 -2
View File
@@ -34,7 +34,7 @@ from openpilot.starpilot.lateral.lane_centering import (
LaneCenteringController, LaneCenteringInput, LaneCenteringRequest, LaneCenteringResult,
)
from openpilot.starpilot.lateral.lane_feedback import feedback_message
from openpilot.starpilot.lateral.lane_change_preferences import read_saved as read_lane_change, effective as lane_change_policy
from openpilot.starpilot.lateral.lane_change_preferences import LiveLaneChangePolicy
from openpilot.starpilot.lateral.lane_change_smoothing import LaneChangeSmoother, limit_rate
from openpilot.starpilot.lateral.lane_runtime import LaneCenteringHost, runtime_supported as lane_runtime_supported
from openpilot.starpilot.lateral.torque_runtime import TorqueHost, runtime_enabled as torque_runtime_enabled
@@ -86,7 +86,9 @@ class Controls:
self.model_turn_assist = ModelTurnAssist()
self.lane_centering_controller = LaneCenteringController()
self.lateral_pause = LateralPause(read_lateral_pause(self.params), self.params)
self.lane_change_policy = lane_change_policy(read_lane_change(self.params))
self.lane_change_preferences = LiveLaneChangePolicy(self.params,
now_ns=0 if os.getenv('REPLAY') == '1' else time.monotonic_ns())
self.lane_change_policy = self.lane_change_preferences.applied
self.lane_change_smoother = LaneChangeSmoother()
self.lane_centering_host = LaneCenteringHost(self.params) if lane_runtime_supported(self.CP) else None
self.torque_host = (TorqueHost(self.params, self.CP, allow_learning=(self.torque_learning_allowed
@@ -136,6 +138,12 @@ class Controls:
device_motion = Pose.from_device_motion(self.sm['deviceMotion'])
self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion)
def refresh_lane_change_policy(self, carstate, model_v2, now_ns):
self.lane_change_policy = self.lane_change_preferences.sample(now_ns,
signaling=bool(carstate.leftBlinker or carstate.rightBlinker),
maneuver_active=model_v2.meta.laneChangeState != LaneChangeState.off)
return self.lane_change_policy
def state_control(self, *, lane_centering: LaneCenteringRequest | None = None):
CS = self.sm['carState']
if self.lateral_gain_owner is not None:
@@ -308,6 +316,8 @@ class Controls:
if result.candidate_curvature is not None:
new_desired_curvature = result.candidate_curvature
previous_curvature = self.desired_curvature
lane_policy_now_ns = int(self.sm.logMonoTime['carState']) if os.getenv('REPLAY') == '1' else time.monotonic_ns()
self.refresh_lane_change_policy(CS, model_v2, lane_policy_now_ns)
factor = self.lane_change_smoother.factor(
active=bool(CC.latActive and CS.canValid and not CS.canTimeout and self.sm.all_checks(['modelV2']) and
self.lane_change_policy.enabled), lane_change_state=model_v2.meta.laneChangeState,
+1 -1
View File
@@ -396,7 +396,7 @@ def starpilot_main():
subscribed_services.append('deviceState')
if conditional_host is not None:
subscribed_services.append('starpilotRadarState')
optional_slc = ['slcDashboardObservation', 'slcVisionObservation'] if slc_available else []
optional_slc = ['slcDashboardObservation', 'slcVisionObservation', 'mapdOut'] if slc_available else []
subscribed_services.extend(optional_slc)
subscribed_services.append('starpilotNavigation')
optional_conditional = ['starpilotNavigation'] + (['starpilotRadarState'] if conditional_host is not None else []) + optional_slc
@@ -45,326 +45,326 @@ const static double MAHA_THRESH_31 = 3.8414588206941227;
* *
* This file is part of 'ekf' *
******************************************************************************/
void err_fun(double *nom_x, double *delta_x, double *out_8385818388315926896) {
out_8385818388315926896[0] = delta_x[0] + nom_x[0];
out_8385818388315926896[1] = delta_x[1] + nom_x[1];
out_8385818388315926896[2] = delta_x[2] + nom_x[2];
out_8385818388315926896[3] = delta_x[3] + nom_x[3];
out_8385818388315926896[4] = delta_x[4] + nom_x[4];
out_8385818388315926896[5] = delta_x[5] + nom_x[5];
out_8385818388315926896[6] = delta_x[6] + nom_x[6];
out_8385818388315926896[7] = delta_x[7] + nom_x[7];
out_8385818388315926896[8] = delta_x[8] + nom_x[8];
void err_fun(double *nom_x, double *delta_x, double *out_8401062644651369313) {
out_8401062644651369313[0] = delta_x[0] + nom_x[0];
out_8401062644651369313[1] = delta_x[1] + nom_x[1];
out_8401062644651369313[2] = delta_x[2] + nom_x[2];
out_8401062644651369313[3] = delta_x[3] + nom_x[3];
out_8401062644651369313[4] = delta_x[4] + nom_x[4];
out_8401062644651369313[5] = delta_x[5] + nom_x[5];
out_8401062644651369313[6] = delta_x[6] + nom_x[6];
out_8401062644651369313[7] = delta_x[7] + nom_x[7];
out_8401062644651369313[8] = delta_x[8] + nom_x[8];
}
void inv_err_fun(double *nom_x, double *true_x, double *out_3457977650484525251) {
out_3457977650484525251[0] = -nom_x[0] + true_x[0];
out_3457977650484525251[1] = -nom_x[1] + true_x[1];
out_3457977650484525251[2] = -nom_x[2] + true_x[2];
out_3457977650484525251[3] = -nom_x[3] + true_x[3];
out_3457977650484525251[4] = -nom_x[4] + true_x[4];
out_3457977650484525251[5] = -nom_x[5] + true_x[5];
out_3457977650484525251[6] = -nom_x[6] + true_x[6];
out_3457977650484525251[7] = -nom_x[7] + true_x[7];
out_3457977650484525251[8] = -nom_x[8] + true_x[8];
void inv_err_fun(double *nom_x, double *true_x, double *out_1597386426371446775) {
out_1597386426371446775[0] = -nom_x[0] + true_x[0];
out_1597386426371446775[1] = -nom_x[1] + true_x[1];
out_1597386426371446775[2] = -nom_x[2] + true_x[2];
out_1597386426371446775[3] = -nom_x[3] + true_x[3];
out_1597386426371446775[4] = -nom_x[4] + true_x[4];
out_1597386426371446775[5] = -nom_x[5] + true_x[5];
out_1597386426371446775[6] = -nom_x[6] + true_x[6];
out_1597386426371446775[7] = -nom_x[7] + true_x[7];
out_1597386426371446775[8] = -nom_x[8] + true_x[8];
}
void H_mod_fun(double *state, double *out_1868311171103980123) {
out_1868311171103980123[0] = 1.0;
out_1868311171103980123[1] = 0.0;
out_1868311171103980123[2] = 0.0;
out_1868311171103980123[3] = 0.0;
out_1868311171103980123[4] = 0.0;
out_1868311171103980123[5] = 0.0;
out_1868311171103980123[6] = 0.0;
out_1868311171103980123[7] = 0.0;
out_1868311171103980123[8] = 0.0;
out_1868311171103980123[9] = 0.0;
out_1868311171103980123[10] = 1.0;
out_1868311171103980123[11] = 0.0;
out_1868311171103980123[12] = 0.0;
out_1868311171103980123[13] = 0.0;
out_1868311171103980123[14] = 0.0;
out_1868311171103980123[15] = 0.0;
out_1868311171103980123[16] = 0.0;
out_1868311171103980123[17] = 0.0;
out_1868311171103980123[18] = 0.0;
out_1868311171103980123[19] = 0.0;
out_1868311171103980123[20] = 1.0;
out_1868311171103980123[21] = 0.0;
out_1868311171103980123[22] = 0.0;
out_1868311171103980123[23] = 0.0;
out_1868311171103980123[24] = 0.0;
out_1868311171103980123[25] = 0.0;
out_1868311171103980123[26] = 0.0;
out_1868311171103980123[27] = 0.0;
out_1868311171103980123[28] = 0.0;
out_1868311171103980123[29] = 0.0;
out_1868311171103980123[30] = 1.0;
out_1868311171103980123[31] = 0.0;
out_1868311171103980123[32] = 0.0;
out_1868311171103980123[33] = 0.0;
out_1868311171103980123[34] = 0.0;
out_1868311171103980123[35] = 0.0;
out_1868311171103980123[36] = 0.0;
out_1868311171103980123[37] = 0.0;
out_1868311171103980123[38] = 0.0;
out_1868311171103980123[39] = 0.0;
out_1868311171103980123[40] = 1.0;
out_1868311171103980123[41] = 0.0;
out_1868311171103980123[42] = 0.0;
out_1868311171103980123[43] = 0.0;
out_1868311171103980123[44] = 0.0;
out_1868311171103980123[45] = 0.0;
out_1868311171103980123[46] = 0.0;
out_1868311171103980123[47] = 0.0;
out_1868311171103980123[48] = 0.0;
out_1868311171103980123[49] = 0.0;
out_1868311171103980123[50] = 1.0;
out_1868311171103980123[51] = 0.0;
out_1868311171103980123[52] = 0.0;
out_1868311171103980123[53] = 0.0;
out_1868311171103980123[54] = 0.0;
out_1868311171103980123[55] = 0.0;
out_1868311171103980123[56] = 0.0;
out_1868311171103980123[57] = 0.0;
out_1868311171103980123[58] = 0.0;
out_1868311171103980123[59] = 0.0;
out_1868311171103980123[60] = 1.0;
out_1868311171103980123[61] = 0.0;
out_1868311171103980123[62] = 0.0;
out_1868311171103980123[63] = 0.0;
out_1868311171103980123[64] = 0.0;
out_1868311171103980123[65] = 0.0;
out_1868311171103980123[66] = 0.0;
out_1868311171103980123[67] = 0.0;
out_1868311171103980123[68] = 0.0;
out_1868311171103980123[69] = 0.0;
out_1868311171103980123[70] = 1.0;
out_1868311171103980123[71] = 0.0;
out_1868311171103980123[72] = 0.0;
out_1868311171103980123[73] = 0.0;
out_1868311171103980123[74] = 0.0;
out_1868311171103980123[75] = 0.0;
out_1868311171103980123[76] = 0.0;
out_1868311171103980123[77] = 0.0;
out_1868311171103980123[78] = 0.0;
out_1868311171103980123[79] = 0.0;
out_1868311171103980123[80] = 1.0;
void H_mod_fun(double *state, double *out_6673047517466386074) {
out_6673047517466386074[0] = 1.0;
out_6673047517466386074[1] = 0.0;
out_6673047517466386074[2] = 0.0;
out_6673047517466386074[3] = 0.0;
out_6673047517466386074[4] = 0.0;
out_6673047517466386074[5] = 0.0;
out_6673047517466386074[6] = 0.0;
out_6673047517466386074[7] = 0.0;
out_6673047517466386074[8] = 0.0;
out_6673047517466386074[9] = 0.0;
out_6673047517466386074[10] = 1.0;
out_6673047517466386074[11] = 0.0;
out_6673047517466386074[12] = 0.0;
out_6673047517466386074[13] = 0.0;
out_6673047517466386074[14] = 0.0;
out_6673047517466386074[15] = 0.0;
out_6673047517466386074[16] = 0.0;
out_6673047517466386074[17] = 0.0;
out_6673047517466386074[18] = 0.0;
out_6673047517466386074[19] = 0.0;
out_6673047517466386074[20] = 1.0;
out_6673047517466386074[21] = 0.0;
out_6673047517466386074[22] = 0.0;
out_6673047517466386074[23] = 0.0;
out_6673047517466386074[24] = 0.0;
out_6673047517466386074[25] = 0.0;
out_6673047517466386074[26] = 0.0;
out_6673047517466386074[27] = 0.0;
out_6673047517466386074[28] = 0.0;
out_6673047517466386074[29] = 0.0;
out_6673047517466386074[30] = 1.0;
out_6673047517466386074[31] = 0.0;
out_6673047517466386074[32] = 0.0;
out_6673047517466386074[33] = 0.0;
out_6673047517466386074[34] = 0.0;
out_6673047517466386074[35] = 0.0;
out_6673047517466386074[36] = 0.0;
out_6673047517466386074[37] = 0.0;
out_6673047517466386074[38] = 0.0;
out_6673047517466386074[39] = 0.0;
out_6673047517466386074[40] = 1.0;
out_6673047517466386074[41] = 0.0;
out_6673047517466386074[42] = 0.0;
out_6673047517466386074[43] = 0.0;
out_6673047517466386074[44] = 0.0;
out_6673047517466386074[45] = 0.0;
out_6673047517466386074[46] = 0.0;
out_6673047517466386074[47] = 0.0;
out_6673047517466386074[48] = 0.0;
out_6673047517466386074[49] = 0.0;
out_6673047517466386074[50] = 1.0;
out_6673047517466386074[51] = 0.0;
out_6673047517466386074[52] = 0.0;
out_6673047517466386074[53] = 0.0;
out_6673047517466386074[54] = 0.0;
out_6673047517466386074[55] = 0.0;
out_6673047517466386074[56] = 0.0;
out_6673047517466386074[57] = 0.0;
out_6673047517466386074[58] = 0.0;
out_6673047517466386074[59] = 0.0;
out_6673047517466386074[60] = 1.0;
out_6673047517466386074[61] = 0.0;
out_6673047517466386074[62] = 0.0;
out_6673047517466386074[63] = 0.0;
out_6673047517466386074[64] = 0.0;
out_6673047517466386074[65] = 0.0;
out_6673047517466386074[66] = 0.0;
out_6673047517466386074[67] = 0.0;
out_6673047517466386074[68] = 0.0;
out_6673047517466386074[69] = 0.0;
out_6673047517466386074[70] = 1.0;
out_6673047517466386074[71] = 0.0;
out_6673047517466386074[72] = 0.0;
out_6673047517466386074[73] = 0.0;
out_6673047517466386074[74] = 0.0;
out_6673047517466386074[75] = 0.0;
out_6673047517466386074[76] = 0.0;
out_6673047517466386074[77] = 0.0;
out_6673047517466386074[78] = 0.0;
out_6673047517466386074[79] = 0.0;
out_6673047517466386074[80] = 1.0;
}
void f_fun(double *state, double dt, double *out_1499231844596104733) {
out_1499231844596104733[0] = state[0];
out_1499231844596104733[1] = state[1];
out_1499231844596104733[2] = state[2];
out_1499231844596104733[3] = state[3];
out_1499231844596104733[4] = state[4];
out_1499231844596104733[5] = dt*((-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]))*state[6] - 9.8100000000000005*state[8] + stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*state[1]) + (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*state[4])) + state[5];
out_1499231844596104733[6] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*state[4])) + state[6];
out_1499231844596104733[7] = state[7];
out_1499231844596104733[8] = state[8];
void f_fun(double *state, double dt, double *out_9084958235864333770) {
out_9084958235864333770[0] = state[0];
out_9084958235864333770[1] = state[1];
out_9084958235864333770[2] = state[2];
out_9084958235864333770[3] = state[3];
out_9084958235864333770[4] = state[4];
out_9084958235864333770[5] = dt*((-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]))*state[6] - 9.8100000000000005*state[8] + stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*state[1]) + (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*state[4])) + state[5];
out_9084958235864333770[6] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*state[4])) + state[6];
out_9084958235864333770[7] = state[7];
out_9084958235864333770[8] = state[8];
}
void F_fun(double *state, double dt, double *out_7736119846340090296) {
out_7736119846340090296[0] = 1;
out_7736119846340090296[1] = 0;
out_7736119846340090296[2] = 0;
out_7736119846340090296[3] = 0;
out_7736119846340090296[4] = 0;
out_7736119846340090296[5] = 0;
out_7736119846340090296[6] = 0;
out_7736119846340090296[7] = 0;
out_7736119846340090296[8] = 0;
out_7736119846340090296[9] = 0;
out_7736119846340090296[10] = 1;
out_7736119846340090296[11] = 0;
out_7736119846340090296[12] = 0;
out_7736119846340090296[13] = 0;
out_7736119846340090296[14] = 0;
out_7736119846340090296[15] = 0;
out_7736119846340090296[16] = 0;
out_7736119846340090296[17] = 0;
out_7736119846340090296[18] = 0;
out_7736119846340090296[19] = 0;
out_7736119846340090296[20] = 1;
out_7736119846340090296[21] = 0;
out_7736119846340090296[22] = 0;
out_7736119846340090296[23] = 0;
out_7736119846340090296[24] = 0;
out_7736119846340090296[25] = 0;
out_7736119846340090296[26] = 0;
out_7736119846340090296[27] = 0;
out_7736119846340090296[28] = 0;
out_7736119846340090296[29] = 0;
out_7736119846340090296[30] = 1;
out_7736119846340090296[31] = 0;
out_7736119846340090296[32] = 0;
out_7736119846340090296[33] = 0;
out_7736119846340090296[34] = 0;
out_7736119846340090296[35] = 0;
out_7736119846340090296[36] = 0;
out_7736119846340090296[37] = 0;
out_7736119846340090296[38] = 0;
out_7736119846340090296[39] = 0;
out_7736119846340090296[40] = 1;
out_7736119846340090296[41] = 0;
out_7736119846340090296[42] = 0;
out_7736119846340090296[43] = 0;
out_7736119846340090296[44] = 0;
out_7736119846340090296[45] = dt*(stiffness_front*(-state[2] - state[3] + state[7])/(mass*state[1]) + (-stiffness_front - stiffness_rear)*state[5]/(mass*state[4]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[6]/(mass*state[4]));
out_7736119846340090296[46] = -dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*pow(state[1], 2));
out_7736119846340090296[47] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_7736119846340090296[48] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_7736119846340090296[49] = dt*((-1 - (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*pow(state[4], 2)))*state[6] - (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*pow(state[4], 2)));
out_7736119846340090296[50] = dt*(-stiffness_front*state[0] - stiffness_rear*state[0])/(mass*state[4]) + 1;
out_7736119846340090296[51] = dt*(-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]));
out_7736119846340090296[52] = dt*stiffness_front*state[0]/(mass*state[1]);
out_7736119846340090296[53] = -9.8100000000000005*dt;
out_7736119846340090296[54] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front - pow(center_to_rear, 2)*stiffness_rear)*state[6]/(rotational_inertia*state[4]));
out_7736119846340090296[55] = -center_to_front*dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*pow(state[1], 2));
out_7736119846340090296[56] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_7736119846340090296[57] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_7736119846340090296[58] = dt*(-(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*pow(state[4], 2)) - (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*pow(state[4], 2)));
out_7736119846340090296[59] = dt*(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(rotational_inertia*state[4]);
out_7736119846340090296[60] = dt*(-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])/(rotational_inertia*state[4]) + 1;
out_7736119846340090296[61] = center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_7736119846340090296[62] = 0;
out_7736119846340090296[63] = 0;
out_7736119846340090296[64] = 0;
out_7736119846340090296[65] = 0;
out_7736119846340090296[66] = 0;
out_7736119846340090296[67] = 0;
out_7736119846340090296[68] = 0;
out_7736119846340090296[69] = 0;
out_7736119846340090296[70] = 1;
out_7736119846340090296[71] = 0;
out_7736119846340090296[72] = 0;
out_7736119846340090296[73] = 0;
out_7736119846340090296[74] = 0;
out_7736119846340090296[75] = 0;
out_7736119846340090296[76] = 0;
out_7736119846340090296[77] = 0;
out_7736119846340090296[78] = 0;
out_7736119846340090296[79] = 0;
out_7736119846340090296[80] = 1;
void F_fun(double *state, double dt, double *out_7170567397506903651) {
out_7170567397506903651[0] = 1;
out_7170567397506903651[1] = 0;
out_7170567397506903651[2] = 0;
out_7170567397506903651[3] = 0;
out_7170567397506903651[4] = 0;
out_7170567397506903651[5] = 0;
out_7170567397506903651[6] = 0;
out_7170567397506903651[7] = 0;
out_7170567397506903651[8] = 0;
out_7170567397506903651[9] = 0;
out_7170567397506903651[10] = 1;
out_7170567397506903651[11] = 0;
out_7170567397506903651[12] = 0;
out_7170567397506903651[13] = 0;
out_7170567397506903651[14] = 0;
out_7170567397506903651[15] = 0;
out_7170567397506903651[16] = 0;
out_7170567397506903651[17] = 0;
out_7170567397506903651[18] = 0;
out_7170567397506903651[19] = 0;
out_7170567397506903651[20] = 1;
out_7170567397506903651[21] = 0;
out_7170567397506903651[22] = 0;
out_7170567397506903651[23] = 0;
out_7170567397506903651[24] = 0;
out_7170567397506903651[25] = 0;
out_7170567397506903651[26] = 0;
out_7170567397506903651[27] = 0;
out_7170567397506903651[28] = 0;
out_7170567397506903651[29] = 0;
out_7170567397506903651[30] = 1;
out_7170567397506903651[31] = 0;
out_7170567397506903651[32] = 0;
out_7170567397506903651[33] = 0;
out_7170567397506903651[34] = 0;
out_7170567397506903651[35] = 0;
out_7170567397506903651[36] = 0;
out_7170567397506903651[37] = 0;
out_7170567397506903651[38] = 0;
out_7170567397506903651[39] = 0;
out_7170567397506903651[40] = 1;
out_7170567397506903651[41] = 0;
out_7170567397506903651[42] = 0;
out_7170567397506903651[43] = 0;
out_7170567397506903651[44] = 0;
out_7170567397506903651[45] = dt*(stiffness_front*(-state[2] - state[3] + state[7])/(mass*state[1]) + (-stiffness_front - stiffness_rear)*state[5]/(mass*state[4]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[6]/(mass*state[4]));
out_7170567397506903651[46] = -dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*pow(state[1], 2));
out_7170567397506903651[47] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_7170567397506903651[48] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_7170567397506903651[49] = dt*((-1 - (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*pow(state[4], 2)))*state[6] - (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*pow(state[4], 2)));
out_7170567397506903651[50] = dt*(-stiffness_front*state[0] - stiffness_rear*state[0])/(mass*state[4]) + 1;
out_7170567397506903651[51] = dt*(-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]));
out_7170567397506903651[52] = dt*stiffness_front*state[0]/(mass*state[1]);
out_7170567397506903651[53] = -9.8100000000000005*dt;
out_7170567397506903651[54] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front - pow(center_to_rear, 2)*stiffness_rear)*state[6]/(rotational_inertia*state[4]));
out_7170567397506903651[55] = -center_to_front*dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*pow(state[1], 2));
out_7170567397506903651[56] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_7170567397506903651[57] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_7170567397506903651[58] = dt*(-(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*pow(state[4], 2)) - (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*pow(state[4], 2)));
out_7170567397506903651[59] = dt*(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(rotational_inertia*state[4]);
out_7170567397506903651[60] = dt*(-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])/(rotational_inertia*state[4]) + 1;
out_7170567397506903651[61] = center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_7170567397506903651[62] = 0;
out_7170567397506903651[63] = 0;
out_7170567397506903651[64] = 0;
out_7170567397506903651[65] = 0;
out_7170567397506903651[66] = 0;
out_7170567397506903651[67] = 0;
out_7170567397506903651[68] = 0;
out_7170567397506903651[69] = 0;
out_7170567397506903651[70] = 1;
out_7170567397506903651[71] = 0;
out_7170567397506903651[72] = 0;
out_7170567397506903651[73] = 0;
out_7170567397506903651[74] = 0;
out_7170567397506903651[75] = 0;
out_7170567397506903651[76] = 0;
out_7170567397506903651[77] = 0;
out_7170567397506903651[78] = 0;
out_7170567397506903651[79] = 0;
out_7170567397506903651[80] = 1;
}
void h_25(double *state, double *unused, double *out_5130465691248679820) {
out_5130465691248679820[0] = state[6];
void h_25(double *state, double *unused, double *out_2817491058203070073) {
out_2817491058203070073[0] = state[6];
}
void H_25(double *state, double *unused, double *out_993002426764977143) {
out_993002426764977143[0] = 0;
out_993002426764977143[1] = 0;
out_993002426764977143[2] = 0;
out_993002426764977143[3] = 0;
out_993002426764977143[4] = 0;
out_993002426764977143[5] = 0;
out_993002426764977143[6] = 1;
out_993002426764977143[7] = 0;
out_993002426764977143[8] = 0;
void H_25(double *state, double *unused, double *out_2580508330330928305) {
out_2580508330330928305[0] = 0;
out_2580508330330928305[1] = 0;
out_2580508330330928305[2] = 0;
out_2580508330330928305[3] = 0;
out_2580508330330928305[4] = 0;
out_2580508330330928305[5] = 0;
out_2580508330330928305[6] = 1;
out_2580508330330928305[7] = 0;
out_2580508330330928305[8] = 0;
}
void h_24(double *state, double *unused, double *out_3830047811733214319) {
out_3830047811733214319[0] = state[4];
out_3830047811733214319[1] = state[5];
void h_24(double *state, double *unused, double *out_1256144265869621667) {
out_1256144265869621667[0] = state[4];
out_1256144265869621667[1] = state[5];
}
void H_24(double *state, double *unused, double *out_5739511753731486177) {
out_5739511753731486177[0] = 0;
out_5739511753731486177[1] = 0;
out_5739511753731486177[2] = 0;
out_5739511753731486177[3] = 0;
out_5739511753731486177[4] = 1;
out_5739511753731486177[5] = 0;
out_5739511753731486177[6] = 0;
out_5739511753731486177[7] = 0;
out_5739511753731486177[8] = 0;
out_5739511753731486177[9] = 0;
out_5739511753731486177[10] = 0;
out_5739511753731486177[11] = 0;
out_5739511753731486177[12] = 0;
out_5739511753731486177[13] = 0;
out_5739511753731486177[14] = 1;
out_5739511753731486177[15] = 0;
out_5739511753731486177[16] = 0;
out_5739511753731486177[17] = 0;
void H_24(double *state, double *unused, double *out_2239813174325059958) {
out_2239813174325059958[0] = 0;
out_2239813174325059958[1] = 0;
out_2239813174325059958[2] = 0;
out_2239813174325059958[3] = 0;
out_2239813174325059958[4] = 1;
out_2239813174325059958[5] = 0;
out_2239813174325059958[6] = 0;
out_2239813174325059958[7] = 0;
out_2239813174325059958[8] = 0;
out_2239813174325059958[9] = 0;
out_2239813174325059958[10] = 0;
out_2239813174325059958[11] = 0;
out_2239813174325059958[12] = 0;
out_2239813174325059958[13] = 0;
out_2239813174325059958[14] = 1;
out_2239813174325059958[15] = 0;
out_2239813174325059958[16] = 0;
out_2239813174325059958[17] = 0;
}
void h_30(double *state, double *unused, double *out_4178561097777020614) {
out_4178561097777020614[0] = state[4];
void h_30(double *state, double *unused, double *out_3770336868273225090) {
out_3770336868273225090[0] = state[4];
}
void H_30(double *state, double *unused, double *out_1525330531742271484) {
out_1525330531742271484[0] = 0;
out_1525330531742271484[1] = 0;
out_1525330531742271484[2] = 0;
out_1525330531742271484[3] = 0;
out_1525330531742271484[4] = 1;
out_1525330531742271484[5] = 0;
out_1525330531742271484[6] = 0;
out_1525330531742271484[7] = 0;
out_1525330531742271484[8] = 0;
void H_30(double *state, double *unused, double *out_2451169383187688235) {
out_2451169383187688235[0] = 0;
out_2451169383187688235[1] = 0;
out_2451169383187688235[2] = 0;
out_2451169383187688235[3] = 0;
out_2451169383187688235[4] = 1;
out_2451169383187688235[5] = 0;
out_2451169383187688235[6] = 0;
out_2451169383187688235[7] = 0;
out_2451169383187688235[8] = 0;
}
void h_26(double *state, double *unused, double *out_4041436014126362414) {
out_4041436014126362414[0] = state[7];
void h_26(double *state, double *unused, double *out_5250206544167251352) {
out_5250206544167251352[0] = state[7];
}
void H_26(double *state, double *unused, double *out_2311523542995823458) {
out_2311523542995823458[0] = 0;
out_2311523542995823458[1] = 0;
out_2311523542995823458[2] = 0;
out_2311523542995823458[3] = 0;
out_2311523542995823458[4] = 0;
out_2311523542995823458[5] = 0;
out_2311523542995823458[6] = 0;
out_2311523542995823458[7] = 1;
out_2311523542995823458[8] = 0;
void H_26(double *state, double *unused, double *out_1160994988543127919) {
out_1160994988543127919[0] = 0;
out_1160994988543127919[1] = 0;
out_1160994988543127919[2] = 0;
out_1160994988543127919[3] = 0;
out_1160994988543127919[4] = 0;
out_1160994988543127919[5] = 0;
out_1160994988543127919[6] = 0;
out_1160994988543127919[7] = 1;
out_1160994988543127919[8] = 0;
}
void h_27(double *state, double *unused, double *out_128792423587834175) {
out_128792423587834175[0] = state[3];
void h_27(double *state, double *unused, double *out_4831854115108264269) {
out_4831854115108264269[0] = state[3];
}
void H_27(double *state, double *unused, double *out_3748924602926214701) {
out_3748924602926214701[0] = 0;
out_3748924602926214701[1] = 0;
out_3748924602926214701[2] = 0;
out_3748924602926214701[3] = 1;
out_3748924602926214701[4] = 0;
out_3748924602926214701[5] = 0;
out_3748924602926214701[6] = 0;
out_3748924602926214701[7] = 0;
out_3748924602926214701[8] = 0;
void H_27(double *state, double *unused, double *out_276406071387263324) {
out_276406071387263324[0] = 0;
out_276406071387263324[1] = 0;
out_276406071387263324[2] = 0;
out_276406071387263324[3] = 1;
out_276406071387263324[4] = 0;
out_276406071387263324[5] = 0;
out_276406071387263324[6] = 0;
out_276406071387263324[7] = 0;
out_276406071387263324[8] = 0;
}
void h_29(double *state, double *unused, double *out_1579423816216717342) {
out_1579423816216717342[0] = state[1];
void h_29(double *state, double *unused, double *out_733550816828892405) {
out_733550816828892405[0] = state[1];
}
void H_29(double *state, double *unused, double *out_2035561876056663668) {
out_2035561876056663668[0] = 0;
out_2035561876056663668[1] = 1;
out_2035561876056663668[2] = 0;
out_2035561876056663668[3] = 0;
out_2035561876056663668[4] = 0;
out_2035561876056663668[5] = 0;
out_2035561876056663668[6] = 0;
out_2035561876056663668[7] = 0;
out_2035561876056663668[8] = 0;
void H_29(double *state, double *unused, double *out_2961400727502080419) {
out_2961400727502080419[0] = 0;
out_2961400727502080419[1] = 1;
out_2961400727502080419[2] = 0;
out_2961400727502080419[3] = 0;
out_2961400727502080419[4] = 0;
out_2961400727502080419[5] = 0;
out_2961400727502080419[6] = 0;
out_2961400727502080419[7] = 0;
out_2961400727502080419[8] = 0;
}
void h_28(double *state, double *unused, double *out_7563935858016534988) {
out_7563935858016534988[0] = state[0];
void h_28(double *state, double *unused, double *out_4150115056898576496) {
out_4150115056898576496[0] = state[0];
}
void H_28(double *state, double *unused, double *out_3046837141012866906) {
out_3046837141012866906[0] = 1;
out_3046837141012866906[1] = 0;
out_3046837141012866906[2] = 0;
out_3046837141012866906[3] = 0;
out_3046837141012866906[4] = 0;
out_3046837141012866906[5] = 0;
out_3046837141012866906[6] = 0;
out_3046837141012866906[7] = 0;
out_3046837141012866906[8] = 0;
void H_28(double *state, double *unused, double *out_4925030999067406670) {
out_4925030999067406670[0] = 1;
out_4925030999067406670[1] = 0;
out_4925030999067406670[2] = 0;
out_4925030999067406670[3] = 0;
out_4925030999067406670[4] = 0;
out_4925030999067406670[5] = 0;
out_4925030999067406670[6] = 0;
out_4925030999067406670[7] = 0;
out_4925030999067406670[8] = 0;
}
void h_31(double *state, double *unused, double *out_8001670333771886589) {
out_8001670333771886589[0] = state[8];
void h_31(double *state, double *unused, double *out_6454940586457092423) {
out_6454940586457092423[0] = state[8];
}
void H_31(double *state, double *unused, double *out_1685315440762471982) {
out_1685315440762471982[0] = 0;
out_1685315440762471982[1] = 0;
out_1685315440762471982[2] = 0;
out_1685315440762471982[3] = 0;
out_1685315440762471982[4] = 0;
out_1685315440762471982[5] = 0;
out_1685315440762471982[6] = 0;
out_1685315440762471982[7] = 0;
out_1685315440762471982[8] = 1;
void H_31(double *state, double *unused, double *out_2611154292207888733) {
out_2611154292207888733[0] = 0;
out_2611154292207888733[1] = 0;
out_2611154292207888733[2] = 0;
out_2611154292207888733[3] = 0;
out_2611154292207888733[4] = 0;
out_2611154292207888733[5] = 0;
out_2611154292207888733[6] = 0;
out_2611154292207888733[7] = 0;
out_2611154292207888733[8] = 1;
}
#include <eigen3/Eigen/Dense>
#include <iostream>
@@ -518,68 +518,68 @@ void car_update_28(double *in_x, double *in_P, double *in_z, double *in_R, doubl
void car_update_31(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea) {
update<1, 3, 0>(in_x, in_P, h_31, H_31, NULL, in_z, in_R, in_ea, MAHA_THRESH_31);
}
void car_err_fun(double *nom_x, double *delta_x, double *out_8385818388315926896) {
err_fun(nom_x, delta_x, out_8385818388315926896);
void car_err_fun(double *nom_x, double *delta_x, double *out_8401062644651369313) {
err_fun(nom_x, delta_x, out_8401062644651369313);
}
void car_inv_err_fun(double *nom_x, double *true_x, double *out_3457977650484525251) {
inv_err_fun(nom_x, true_x, out_3457977650484525251);
void car_inv_err_fun(double *nom_x, double *true_x, double *out_1597386426371446775) {
inv_err_fun(nom_x, true_x, out_1597386426371446775);
}
void car_H_mod_fun(double *state, double *out_1868311171103980123) {
H_mod_fun(state, out_1868311171103980123);
void car_H_mod_fun(double *state, double *out_6673047517466386074) {
H_mod_fun(state, out_6673047517466386074);
}
void car_f_fun(double *state, double dt, double *out_1499231844596104733) {
f_fun(state, dt, out_1499231844596104733);
void car_f_fun(double *state, double dt, double *out_9084958235864333770) {
f_fun(state, dt, out_9084958235864333770);
}
void car_F_fun(double *state, double dt, double *out_7736119846340090296) {
F_fun(state, dt, out_7736119846340090296);
void car_F_fun(double *state, double dt, double *out_7170567397506903651) {
F_fun(state, dt, out_7170567397506903651);
}
void car_h_25(double *state, double *unused, double *out_5130465691248679820) {
h_25(state, unused, out_5130465691248679820);
void car_h_25(double *state, double *unused, double *out_2817491058203070073) {
h_25(state, unused, out_2817491058203070073);
}
void car_H_25(double *state, double *unused, double *out_993002426764977143) {
H_25(state, unused, out_993002426764977143);
void car_H_25(double *state, double *unused, double *out_2580508330330928305) {
H_25(state, unused, out_2580508330330928305);
}
void car_h_24(double *state, double *unused, double *out_3830047811733214319) {
h_24(state, unused, out_3830047811733214319);
void car_h_24(double *state, double *unused, double *out_1256144265869621667) {
h_24(state, unused, out_1256144265869621667);
}
void car_H_24(double *state, double *unused, double *out_5739511753731486177) {
H_24(state, unused, out_5739511753731486177);
void car_H_24(double *state, double *unused, double *out_2239813174325059958) {
H_24(state, unused, out_2239813174325059958);
}
void car_h_30(double *state, double *unused, double *out_4178561097777020614) {
h_30(state, unused, out_4178561097777020614);
void car_h_30(double *state, double *unused, double *out_3770336868273225090) {
h_30(state, unused, out_3770336868273225090);
}
void car_H_30(double *state, double *unused, double *out_1525330531742271484) {
H_30(state, unused, out_1525330531742271484);
void car_H_30(double *state, double *unused, double *out_2451169383187688235) {
H_30(state, unused, out_2451169383187688235);
}
void car_h_26(double *state, double *unused, double *out_4041436014126362414) {
h_26(state, unused, out_4041436014126362414);
void car_h_26(double *state, double *unused, double *out_5250206544167251352) {
h_26(state, unused, out_5250206544167251352);
}
void car_H_26(double *state, double *unused, double *out_2311523542995823458) {
H_26(state, unused, out_2311523542995823458);
void car_H_26(double *state, double *unused, double *out_1160994988543127919) {
H_26(state, unused, out_1160994988543127919);
}
void car_h_27(double *state, double *unused, double *out_128792423587834175) {
h_27(state, unused, out_128792423587834175);
void car_h_27(double *state, double *unused, double *out_4831854115108264269) {
h_27(state, unused, out_4831854115108264269);
}
void car_H_27(double *state, double *unused, double *out_3748924602926214701) {
H_27(state, unused, out_3748924602926214701);
void car_H_27(double *state, double *unused, double *out_276406071387263324) {
H_27(state, unused, out_276406071387263324);
}
void car_h_29(double *state, double *unused, double *out_1579423816216717342) {
h_29(state, unused, out_1579423816216717342);
void car_h_29(double *state, double *unused, double *out_733550816828892405) {
h_29(state, unused, out_733550816828892405);
}
void car_H_29(double *state, double *unused, double *out_2035561876056663668) {
H_29(state, unused, out_2035561876056663668);
void car_H_29(double *state, double *unused, double *out_2961400727502080419) {
H_29(state, unused, out_2961400727502080419);
}
void car_h_28(double *state, double *unused, double *out_7563935858016534988) {
h_28(state, unused, out_7563935858016534988);
void car_h_28(double *state, double *unused, double *out_4150115056898576496) {
h_28(state, unused, out_4150115056898576496);
}
void car_H_28(double *state, double *unused, double *out_3046837141012866906) {
H_28(state, unused, out_3046837141012866906);
void car_H_28(double *state, double *unused, double *out_4925030999067406670) {
H_28(state, unused, out_4925030999067406670);
}
void car_h_31(double *state, double *unused, double *out_8001670333771886589) {
h_31(state, unused, out_8001670333771886589);
void car_h_31(double *state, double *unused, double *out_6454940586457092423) {
h_31(state, unused, out_6454940586457092423);
}
void car_H_31(double *state, double *unused, double *out_1685315440762471982) {
H_31(state, unused, out_1685315440762471982);
void car_H_31(double *state, double *unused, double *out_2611154292207888733) {
H_31(state, unused, out_2611154292207888733);
}
void car_predict(double *in_x, double *in_P, double *in_Q, double dt) {
predict(in_x, in_P, in_Q, dt);
@@ -9,27 +9,27 @@ void car_update_27(double *in_x, double *in_P, double *in_z, double *in_R, doubl
void car_update_29(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void car_update_28(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void car_update_31(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void car_err_fun(double *nom_x, double *delta_x, double *out_8385818388315926896);
void car_inv_err_fun(double *nom_x, double *true_x, double *out_3457977650484525251);
void car_H_mod_fun(double *state, double *out_1868311171103980123);
void car_f_fun(double *state, double dt, double *out_1499231844596104733);
void car_F_fun(double *state, double dt, double *out_7736119846340090296);
void car_h_25(double *state, double *unused, double *out_5130465691248679820);
void car_H_25(double *state, double *unused, double *out_993002426764977143);
void car_h_24(double *state, double *unused, double *out_3830047811733214319);
void car_H_24(double *state, double *unused, double *out_5739511753731486177);
void car_h_30(double *state, double *unused, double *out_4178561097777020614);
void car_H_30(double *state, double *unused, double *out_1525330531742271484);
void car_h_26(double *state, double *unused, double *out_4041436014126362414);
void car_H_26(double *state, double *unused, double *out_2311523542995823458);
void car_h_27(double *state, double *unused, double *out_128792423587834175);
void car_H_27(double *state, double *unused, double *out_3748924602926214701);
void car_h_29(double *state, double *unused, double *out_1579423816216717342);
void car_H_29(double *state, double *unused, double *out_2035561876056663668);
void car_h_28(double *state, double *unused, double *out_7563935858016534988);
void car_H_28(double *state, double *unused, double *out_3046837141012866906);
void car_h_31(double *state, double *unused, double *out_8001670333771886589);
void car_H_31(double *state, double *unused, double *out_1685315440762471982);
void car_err_fun(double *nom_x, double *delta_x, double *out_8401062644651369313);
void car_inv_err_fun(double *nom_x, double *true_x, double *out_1597386426371446775);
void car_H_mod_fun(double *state, double *out_6673047517466386074);
void car_f_fun(double *state, double dt, double *out_9084958235864333770);
void car_F_fun(double *state, double dt, double *out_7170567397506903651);
void car_h_25(double *state, double *unused, double *out_2817491058203070073);
void car_H_25(double *state, double *unused, double *out_2580508330330928305);
void car_h_24(double *state, double *unused, double *out_1256144265869621667);
void car_H_24(double *state, double *unused, double *out_2239813174325059958);
void car_h_30(double *state, double *unused, double *out_3770336868273225090);
void car_H_30(double *state, double *unused, double *out_2451169383187688235);
void car_h_26(double *state, double *unused, double *out_5250206544167251352);
void car_H_26(double *state, double *unused, double *out_1160994988543127919);
void car_h_27(double *state, double *unused, double *out_4831854115108264269);
void car_H_27(double *state, double *unused, double *out_276406071387263324);
void car_h_29(double *state, double *unused, double *out_733550816828892405);
void car_H_29(double *state, double *unused, double *out_2961400727502080419);
void car_h_28(double *state, double *unused, double *out_4150115056898576496);
void car_H_28(double *state, double *unused, double *out_4925030999067406670);
void car_h_31(double *state, double *unused, double *out_6454940586457092423);
void car_H_31(double *state, double *unused, double *out_2611154292207888733);
void car_predict(double *in_x, double *in_P, double *in_Q, double dt);
void car_set_mass(double x);
void car_set_rotational_inertia(double x);
File diff suppressed because it is too large Load Diff
@@ -5,18 +5,18 @@ void pose_update_4(double *in_x, double *in_P, double *in_z, double *in_R, doubl
void pose_update_10(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void pose_update_13(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void pose_update_14(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void pose_err_fun(double *nom_x, double *delta_x, double *out_8587424029422482613);
void pose_inv_err_fun(double *nom_x, double *true_x, double *out_8786163150320395038);
void pose_H_mod_fun(double *state, double *out_2404492370394702667);
void pose_f_fun(double *state, double dt, double *out_442464745730678105);
void pose_F_fun(double *state, double dt, double *out_68862599699564370);
void pose_h_4(double *state, double *unused, double *out_6968799398651119641);
void pose_H_4(double *state, double *unused, double *out_6333703861688286471);
void pose_h_10(double *state, double *unused, double *out_5068008641526453532);
void pose_H_10(double *state, double *unused, double *out_3796470789070264043);
void pose_h_13(double *state, double *unused, double *out_1215417763549020361);
void pose_H_13(double *state, double *unused, double *out_8900766386688932344);
void pose_h_14(double *state, double *unused, double *out_1254666721235298923);
void pose_H_14(double *state, double *unused, double *out_8149799355681780616);
void pose_err_fun(double *nom_x, double *delta_x, double *out_7162235413577390203);
void pose_inv_err_fun(double *nom_x, double *true_x, double *out_7495913663529407299);
void pose_H_mod_fun(double *state, double *out_170725082104688702);
void pose_f_fun(double *state, double dt, double *out_3543895396835097983);
void pose_F_fun(double *state, double dt, double *out_585703334321150959);
void pose_h_4(double *state, double *unused, double *out_4198313265922725605);
void pose_H_4(double *state, double *unused, double *out_924886084159401309);
void pose_h_10(double *state, double *unused, double *out_331605980695215593);
void pose_H_10(double *state, double *unused, double *out_5486414686034992016);
void pose_h_13(double *state, double *unused, double *out_6500207722279651616);
void pose_H_13(double *state, double *unused, double *out_4137159909491734110);
void pose_h_14(double *state, double *unused, double *out_4036957254120554746);
void pose_H_14(double *state, double *unused, double *out_4888126940498885838);
void pose_predict(double *in_x, double *in_P, double *in_Q, double dt);
}
+12 -2
View File
@@ -35,7 +35,7 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.common.transformations.model import get_warp_matrix
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.starpilot.lateral.lane_change_preferences import effective as effective_lane_change, read_saved as read_lane_change
from openpilot.starpilot.lateral.lane_change_preferences import LiveLaneChangePolicy
from openpilot.starpilot.lateral.auto_lane_change import ClockEpochGuard, auto_evidence, paired_clocks_ns, session_policy, steering_capable
from openpilot.starpilot.lateral.lane_change_status_wire import (
Direction as StatusDirection, LaneChangeStatus, Phase as StatusPhase, encode_optional as encode_lane_status,
@@ -249,6 +249,14 @@ class ModelState:
self.prev_desire[:] = 0
def refresh_lane_change_policy(preferences, desire_helper, carstate, now_ns, vehicle_capable):
policy = session_policy(preferences.sample(now_ns,
signaling=bool(carstate.leftBlinker or carstate.rightBlinker),
maneuver_active=desire_helper.lane_change_state != log.LaneChangeState.off), vehicle_capable)
desire_helper.policy = policy
return policy
def main(demo=False):
cloudlog.warning("modeld init")
@@ -408,7 +416,8 @@ def main(demo=False):
long_delay = CP.longitudinalActuatorDelay + LONG_SMOOTH_SECONDS
prev_action = log.ModelDataV2.Action()
saved_lane_policy = effective_lane_change(read_lane_change(params))
lane_preferences = LiveLaneChangePolicy(params, now_ns=time.monotonic_ns())
saved_lane_policy = lane_preferences.applied
auto_vehicle_capable = steering_capable(CP)
runtime_lane_policy = session_policy(saved_lane_policy, auto_vehicle_capable)
initial_mono_ns, initial_boot_ns, initial_skew_ns = paired_clocks_ns()
@@ -565,6 +574,7 @@ def main(demo=False):
car_state = sm['carState']
direction = -1 if car_state.leftBlinker and not car_state.rightBlinker else (1 if car_state.rightBlinker and not car_state.leftBlinker else 0)
mono_now_ns, boot_now_ns, clock_skew_ns = paired_clocks_ns()
runtime_lane_policy = refresh_lane_change_policy(lane_preferences, DH, car_state, mono_now_ns, auto_vehicle_capable)
auto_ready = (runtime_lane_policy.auto_lane_change and direction != 0 and
lane_clock_guard.ready(sm, mono_now_ns, boot_now_ns, clock_skew_ns) and
auto_evidence(sm, modelv2_send.modelV2, direction, runtime_lane_policy.minimum_lane_width_m,
Binary file not shown.
+25 -7
View File
@@ -1607,14 +1607,22 @@ class SelfdriveD:
def _decide_aol_axes(self, CS, intent, native, *, now_ns=0, ordinary_lag=False):
from openpilot.starpilot.car.gm.aol import physical_cancel_lateral_allowed
from openpilot.starpilot.aol.dm import armed_lateral_current
retained_dm_lateral = bool(intent is not None and
getattr(self, 'aol_dm_retained_owner', None) == (intent.producerSessionId, self.aol_session_id) and
self.sm.all_checks(['driverMonitoringState']) and
armed_lateral_current(self.CP, CS, intent, native, source_ns=self.aol_car_state_log_ns,
now_ns=now_ns, axis_session_id=self.aol_session_id))
lateral_events = [name for name in self.events.names if not (
name == EventName.selfdrivedLagging and ordinary_lag or
name == EventName.tooDistracted and retained_dm_lateral)]
return decide_axes(
standard_lateral=self.active, standard_longitudinal=bool(self.enabled and self.CP.openpilotLongitudinalControl and
not any(sample.seatbeltUnlatched for sample, _ in getattr(self, 'car_state_samples', ()))),
intent=intent, native=native, car_state=CS, initialized=self.initialized,
model_ready=bool(self.sm.all_checks(['modelV2', 'extrinsicsCalibration']) and
self.sm['extrinsicsCalibration'].calStatus == log.ExtrinsicsCalibration.Status.calibrated),
no_entry=aol_no_entry(([name for name in self.events.names if name != EventName.selfdrivedLagging]
if ordinary_lag else self.events.names), CS, paddle_only_cancel=self.nostalgia_paddle_cancel,
no_entry=aol_no_entry(lateral_events, CS, paddle_only_cancel=self.nostalgia_paddle_cancel,
allow_lateral_cancel=bool(intent is not None and intent.allowedLatch and intent.optionalSetRelease
and ev6_aol_qualified(self.CP, marked_only=True)) or
physical_cancel_lateral_allowed(self.CP, CS, intent, native,
@@ -1624,8 +1632,9 @@ class SelfdriveD:
not self.CP.flags & GMFlags.PEDAL_LONG)),
immediate_disable=self.events.contains(ET.IMMEDIATE_DISABLE),
dm_lockout=bool(not self.sm.all_checks(['driverMonitoringState']) or
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout or
self.sm['driverMonitoringState'].alertLevel == AlertLevel.three),
not retained_dm_lateral and (self.sm['driverMonitoringState'].lockout or
self.sm['driverMonitoringState'].alwaysOnLockout or
self.sm['driverMonitoringState'].alertLevel == AlertLevel.three)),
pause_brake_mps=self.aol_settings.pause_brake_mps if self.aol_settings is not None else 0.0,
lateral_inhibit=self.aol_dm_lateral_inhibit or any(
sample.steerFaultTemporary or sample.brakePressed and not sample.standstill and
@@ -1767,8 +1776,15 @@ class SelfdriveD:
intent = self.reconciled_intent(intent, previous_intent, reconciled_pairs, reconciled_epoch, now_ns)
self.mark_loop_phase('intent_admission_return')
self.aol_last_intent = intent
if self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or self.sm['driverMonitoringState'].lockout:
self.aol_dm_lateral_inhibit = True
if (intent is None or not intent.allowedLatch or previous_intent is None or
intent.producerSessionId != previous_intent.producerSessionId):
self.aol_dm_retained_owner = None
elif self.aol_axis_decision.lateral_active and previous_intent.allowedLatch:
self.aol_dm_retained_owner = (intent.producerSessionId, self.aol_session_id)
if (self.sm['driverMonitoringState'].alertLevel == AlertLevel.three or
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout):
self.aol_availability_long_inhibit = True
self.aol_dm_lateral_inhibit = False
elif intent is not None and not intent.allowedLatch:
self.aol_dm_lateral_inhibit = False
previous = self.aol_axis_decision
@@ -1860,7 +1876,7 @@ class SelfdriveD:
self.aol_authority_lost = False
# Temporary EPS withdraws steering commands without revoking the armed
# latch. Keep the event and ordinary soft-disable behavior unchanged.
from openpilot.starpilot.car.gm.aol import seatbelt_lateral_soft_event_allowed
from openpilot.starpilot.aol.seatbelt import seatbelt_lateral_soft_event_allowed
aol_hard_soft_fault = any(event.softDisable and not (
event.name == EventName.steerTempUnavailable and CS.steerFaultTemporary and not CS.steerFaultPermanent or
seatbelt_lateral_soft_event_allowed(self.CP, CS, intent, native, event,
@@ -1884,6 +1900,8 @@ class SelfdriveD:
self.state_machine.current_alert_types.append(ET.IMMEDIATE_DISABLE)
if self.aol_replay:
if (not availability and source_current and self.enabled and self.events.contains(ET.ENABLE) and
self.sm['driverMonitoringState'].alertLevel != AlertLevel.three and
not self.sm['driverMonitoringState'].lockout and not self.sm['driverMonitoringState'].alwaysOnLockout and
not self.events.contains(ET.NO_ENTRY) and not self.events.contains(ET.IMMEDIATE_DISABLE) and
not self.events.contains(ET.SOFT_DISABLE)):
self.aol_availability_long_inhibit = False
+28
View File
@@ -0,0 +1,28 @@
"""Capability-aware preference enrollment, separate from runtime authority."""
from pathlib import Path
from openpilot.starpilot.aol.vehicle import policy_for
from openpilot.starpilot.state_migration import _atomic_write, _params_lock, _read_value
def default_enabled(cp) -> bool:
if cp is None or getattr(cp, 'notCar', True) or getattr(cp, 'passive', True) or getattr(cp, 'dashcamOnly', True):
return False
policy = policy_for(cp)
return bool(policy.settings_supported and policy.intent_supported)
def enroll_missing_preference(params, cp) -> bool:
if not default_enabled(cp):
return False
try:
namespace = Path(params.get_param_path()).absolute()
with _params_lock(namespace):
try:
_read_value(namespace / 'AlwaysOnLateral')
except FileNotFoundError:
_atomic_write(namespace / 'AlwaysOnLateral', b'1')
return True
except (OSError, ValueError):
return False
return False
+24
View File
@@ -0,0 +1,24 @@
def armed_lateral_current(cp, cs, intent, native, *, source_ns, now_ns, axis_session_id) -> bool:
"""Retain only a current deliberately armed native-permitted lateral owner."""
from opendbc.car.structs import car
from openpilot.starpilot.aol.vehicle import policy_for
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp
try:
return bool(policy_for(cp).intent_supported and
cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and
not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and
cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low,
car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and
intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and
intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
now_ns - source_ns <= AXIS_MAX_AGE_NS and
intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and
native is not None and native.compatible and int(native.protocolVersion) == 1 and
axis_session_id and native.axisSessionId == axis_session_id and
native.requestedLateral and native.lateralAllowed and native.pandaInventory and
native_inventory_matches_cp(native, cp) and
0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and
native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS)
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
return False
+26
View File
@@ -0,0 +1,26 @@
def seatbelt_lateral_soft_event_allowed(cp, cs, intent, native, event, *, source_ns, now_ns, axis_session_id) -> bool:
"""A fresh native-acknowledged armed LAT owner treats the belt as a LONG lock."""
from opendbc.car.structs import car
from openpilot.starpilot.aol.vehicle import policy_for
from openpilot.cereal import log
from openpilot.starpilot.aol.runtime import AXIS_MAX_AGE_NS, SAFETY_MAX_AGE_NS, native_inventory_matches_cp
try:
return bool(policy_for(cp).intent_supported and event.name == log.OnroadEvent.EventName.seatbeltNotLatched and event.softDisable and
event.noEntry and not event.immediateDisable and not event.userDisable and
cs.seatbeltUnlatched and cs.canValid and not cs.canTimeout and not cs.accFaulted and not cs.doorOpen and
not cs.steerFaultTemporary and not cs.steerFaultPermanent and cs.cruiseState.available and
cs.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low,
car.CarState.GearShifter.sport, car.CarState.GearShifter.brake) and
intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and
intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
0 < source_ns <= intent.observedMonoTime <= now_ns <= intent.validUntilMonoTime and
now_ns - source_ns <= AXIS_MAX_AGE_NS and
intent.validUntilMonoTime - intent.observedMonoTime <= SAFETY_MAX_AGE_NS and
native is not None and native.compatible and int(native.protocolVersion) == 1 and
axis_session_id and native.axisSessionId == axis_session_id and
native.requestedLateral and native.lateralAllowed and native.pandaInventory and
native_inventory_matches_cp(native, cp) and
0 < native.observedMonoTime <= now_ns <= native.validUntilMonoTime and
native.validUntilMonoTime - native.observedMonoTime <= SAFETY_MAX_AGE_NS)
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
return False
@@ -262,6 +262,7 @@ def test_real_settings_worker_continues_after_diagnostic_sink_failure(monkeypatc
reads = []
daemon.params = SimpleNamespace(get_bool=lambda key: reads.append(key) or True)
daemon.CP = SimpleNamespace(openpilotLongitudinalControl=True, pcmCruise=False)
daemon.CI = SimpleNamespace(CP=daemon.CP, CC=None)
daemon.aol_card_intent = None
daemon.v_cruise_helper = SimpleNamespace(intervals=None)
daemon.read_slc_configuration = lambda: 'settings'
@@ -210,6 +210,7 @@ def test_actual_params_worker_emits_after_recorder_releases_frame(monkeypatch):
daemon.loop_timing = timing
daemon.params = SimpleNamespace(get_bool=lambda key: False)
daemon.CP = SimpleNamespace(openpilotLongitudinalControl=False, pcmCruise=False)
daemon.CI = SimpleNamespace(CP=daemon.CP, CC=None)
daemon.aol_card_intent = None
daemon.v_cruise_helper = SimpleNamespace(intervals=None)
daemon.read_slc_configuration = lambda: 'configuration'
@@ -294,6 +295,7 @@ def test_actual_params_worker_preserves_inherited_scheduling(monkeypatch):
daemon: Any = module.Car.__new__(module.Car)
daemon.loop_timing = None
daemon.CP = SimpleNamespace(openpilotLongitudinalControl=True, pcmCruise=False)
daemon.CI = SimpleNamespace(CP=daemon.CP, CC=None)
cp_before = dict(vars(daemon.CP))
daemon.aol_card_intent = None
daemon.v_cruise_helper = SimpleNamespace(intervals=None)
@@ -488,6 +490,7 @@ def test_actual_params_worker_formats_output_snapshot_before_settings_reads(monk
return False
daemon.params = SimpleNamespace(get_bool=read_bool)
daemon.CP = SimpleNamespace(openpilotLongitudinalControl=False, pcmCruise=False)
daemon.CI = SimpleNamespace(CP=daemon.CP, CC=None)
daemon.aol_card_intent = None
daemon.v_cruise_helper = SimpleNamespace(intervals=None)
daemon.read_slc_configuration = lambda: 'configuration'
@@ -0,0 +1,119 @@
import tempfile
from pathlib import Path
from unittest import TestCase
from opendbc.car.honda.interface import CarInterface
from opendbc.car.honda.values import CAR
from openpilot.common.params import Params
from openpilot.starpilot.aol.defaults import default_enabled, enroll_missing_preference
from openpilot.starpilot.aol.intent import read_settings
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
class TestAolInitialDefault(TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.params = Params(self.temp.name)
self.cp = CarInterface.get_params(CAR.HONDA_CIVIC_BOSCH, {0: {}, 1: {}, 2: {}}, [], True, False, False)
def test_actual_supported_policy_and_canonical_owner_share_default(self):
self.assertFalse(int(self.cp.safetyConfigs[0].safetyParam) & 0x20)
self.assertTrue(default_enabled(self.cp))
owner = FeatureSettingsOwner(self.params, lambda group: True, vehicle_fingerprint=lambda: self.cp.carFingerprint,
vehicle_params=lambda: self.cp)
row = next(row for row in owner.snapshot('aol', parked=True, system_long=True, lateral_context=True, metric=False).rows if row.key == 'AlwaysOnLateral')
self.assertEqual((row.value, row.default_value), ('On', 'On'))
self.assertTrue(enroll_missing_preference(self.params, self.cp))
self.assertTrue(read_settings(self.params).enabled)
self.assertFalse(enroll_missing_preference(self.params, self.cp))
def test_explicit_off_on_and_invalid_migration_bytes_always_win(self):
for raw in (b'0', b'1', b'invalid'):
with self.subTest(raw=raw):
Path(self.params.get_param_path('AlwaysOnLateral')).write_bytes(raw)
self.assertFalse(enroll_missing_preference(self.params, self.cp))
self.assertEqual(Path(self.params.get_param_path('AlwaysOnLateral')).read_bytes(), raw)
def test_runtime_unavailability_never_rewrites_existing_choice(self):
self.params.put_bool('AlwaysOnLateral', False, block=True)
self.cp.passive = True
self.assertFalse(default_enabled(self.cp))
self.assertFalse(enroll_missing_preference(self.params, self.cp))
self.assertFalse(self.params.get_bool('AlwaysOnLateral'))
def test_irregular_preference_is_preserved_without_startup_failure(self):
target = Path(self.params.get_param_path('AlwaysOnLateral'))
target.mkdir()
self.assertFalse(enroll_missing_preference(self.params, self.cp))
self.assertTrue(target.is_dir())
def test_real_invalid_lock_is_preserved_without_startup_failure(self):
namespace = Path(self.params.get_param_path()).absolute()
lock = namespace.parent / '.lock'
lock.unlink(missing_ok=True)
lock.symlink_to(namespace / 'nonexistent')
self.assertFalse(enroll_missing_preference(self.params, self.cp))
self.assertTrue(lock.is_symlink())
self.assertFalse((namespace / 'AlwaysOnLateral').exists())
def test_real_missing_namespace_write_failure_does_not_abort_startup(self):
namespace = Path(self.params.get_param_path()).absolute()
if namespace.is_symlink():
namespace.unlink()
else:
import shutil
shutil.rmtree(namespace)
self.assertFalse(enroll_missing_preference(self.params, self.cp))
def test_symlink_preference_read_failure_preserves_external_explicit_choice(self):
outside = Path(self.temp.name) / 'saved-off'
outside.write_bytes(b'0')
target = Path(self.params.get_param_path('AlwaysOnLateral'))
target.symlink_to(outside)
self.assertFalse(enroll_missing_preference(self.params, self.cp))
self.assertTrue(target.is_symlink())
self.assertEqual(outside.read_bytes(), b'0')
class TestActualFreshCardStartup(TestCase):
def construct(self, preference=None):
from unittest.mock import patch
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.car.card import Car
prefix = OpenpilotPrefix()
prefix.__enter__()
self.addCleanup(prefix.__exit__, None, None, None)
params = Params()
params.put_bool('OpenpilotEnabledToggle', True, block=True)
params.put_bool('IsReleaseBranch', False, block=True)
if preference is not None:
Path(params.get_param_path('AlwaysOnLateral')).write_bytes(preference)
cp = CarInterface.get_params(CAR.HONDA_CIVIC_BOSCH, {0: {}, 1: {}, 2: {}}, [], True, False, False)
self.assertFalse(int(cp.safetyConfigs[0].safetyParam) & 0x20)
ci = CarInterface(cp)
with patch.dict('os.environ', {'AOL_REPLAY_RUNTIME': '0'}):
host = Car(CI=ci, RI=ci.RadarInterface(cp))
return host, params
def test_missing_preference_bootstraps_actual_same_startup_transport_and_cp(self):
host, params = self.construct()
self.assertTrue(params.get_bool('AlwaysOnLateral'))
self.assertEqual(Path(params.get_param_path('AlwaysOnLateral')).read_bytes(), b'1')
self.assertTrue(host.aol_replay)
self.assertTrue(host.aol_settings.enabled)
self.assertIsNotNone(host.aol_card_intent)
self.assertIn('aolSafetyWire', host.sm.services)
self.assertIn('aolIntentWire', host.pm.sock)
self.assertTrue(int(host.CP.safetyConfigs[0].safetyParam) & 0x20)
from opendbc.car.structs import car
with car.CarParams.from_bytes(params.get('CarParams')) as published:
self.assertTrue(int(published.safetyConfigs[0].safetyParam) & 0x20)
def test_explicit_disabled_preference_keeps_actual_startup_disabled(self):
host, params = self.construct(b'0')
self.assertFalse(params.get_bool('AlwaysOnLateral'))
self.assertEqual(Path(params.get_param_path('AlwaysOnLateral')).read_bytes(), b'0')
self.assertFalse(host.aol_replay)
self.assertIsNone(host.aol_settings)
self.assertFalse(int(host.CP.safetyConfigs[0].safetyParam) & 0x20)
@@ -0,0 +1,121 @@
from contextlib import ExitStack
import os
from unittest import mock
import pytest
from openpilot.cereal import log
from openpilot.selfdrive.selfdrived.events import EVENTS, ET, EventName
from openpilot.starpilot.aol.tests.stock_lateral_test_helpers import callers
from openpilot.starpilot.aol.tests.test_ioniq_availability import car_state
@pytest.mark.parametrize('family', ['gm', 'toyota'])
@pytest.mark.parametrize('full', [False, True])
@pytest.mark.parametrize('vision', [False, True])
def test_actual_armed_dm_escalation_retains_lateral_and_drops_long(family, full, vision):
with ExitStack() as cleanup:
sd, _, drive, card, receive = callers(cleanup, family, not full)
values = {'level': 'three', 'lockout': False}
original = sd.update_events
def dm_events(state):
dm = sd.sm['driverMonitoringState'].as_builder()
dm.alertLevel = values['level']
dm.activePolicy = 'vision' if vision else 'wheeltouch'
dm.lockout = values['lockout']
sd.sm.data['driverMonitoringState'] = dm.as_reader()
return original(state)
cleanup.enter_context(mock.patch.object(sd, 'update_events', side_effect=dm_events))
for tick, level, locked in ((2, 'one', False), (3, 'two', False), (4, 'three', False),
(5, 'three', True), (6, 'none', False)):
values.update(level=level, lockout=locked)
axis, _, command = drive(tick, native_request_long=full if tick < 4 else False)
assert axis.lateralActive and command.latActive
assert receive(10_000_000_000 + tick * 10_000_000)
assert card.aol_card_intent.allowed_latch
if tick >= 4:
assert not axis.desiredLongitudinal and not command.longActive and not sd.enabled
if level == 'three':
event = EventName.driverDistracted3 if vision else EventName.driverUnresponsive3
assert event in sd.events.names
alert = EVENTS[event][ET.PERMANENT]
assert alert.audible_alert == log.SelfdriveState.AudibleAlert.warningImmediate
assert alert.alert_status == log.SelfdriveState.AlertStatus.critical
assert not sd.enabled and not command.longActive
@pytest.mark.parametrize('fault', ['unarmed', 'native_denied', 'permanent', 'stale_source'])
def test_dm_does_not_grant_lateral_to_otherwise_ineligible_callers(fault):
with ExitStack() as cleanup:
sd, _, drive, _, _ = callers(cleanup, 'toyota', True)
original = sd.update_events
def red_events(state):
dm = sd.sm['driverMonitoringState'].as_builder()
dm.alertLevel = 'three'
sd.sm.data['driverMonitoringState'] = dm.as_reader()
return original(state)
cleanup.enter_context(mock.patch.object(sd, 'update_events', side_effect=red_events))
state = car_state()
if fault == 'permanent':
state.steerFaultPermanent = True
options = {'observed_state': state, 'native_request_long': False}
if fault == 'unarmed':
options['latched'] = False
elif fault == 'native_denied':
options['allowed'] = False
elif fault == 'stale_source':
options['state_stamp'] = 10_000_000_000
axis, _, command = drive(5, **options)
assert not axis.lateralActive and not command.latActive and not command.longActive
@pytest.mark.parametrize('always_only', [False, True])
def test_dm_lockout_alone_keeps_prior_lateral_but_requires_new_long_engagement(always_only):
with ExitStack() as cleanup:
sd, _, drive, _, _ = callers(cleanup, 'gm', False)
original = sd.update_events
locked = [True]
def events(state):
dm = sd.sm['driverMonitoringState'].as_builder()
dm.alertLevel = 'none'
dm.lockout = locked[0] and not always_only
dm.alwaysOnLockout = locked[0] and always_only
sd.sm.data['driverMonitoringState'] = dm.as_reader()
return original(state)
cleanup.enter_context(mock.patch.object(sd, 'update_events', side_effect=events))
axis, _, command = drive(2)
assert axis.lateralActive and command.latActive
assert not sd.enabled and not axis.desiredLongitudinal and not command.longActive
assert EventName.tooDistracted in sd.events.names
locked[0] = False
axis, _, command = drive(3, native_request_long=False)
assert axis.lateralActive and not sd.enabled and not command.longActive
def test_first_red_admission_cannot_start_previously_unaccepted_lateral():
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.starpilot.aol.tests.stock_lateral_test_helpers import honda_family_case
with ExitStack() as cleanup:
cleanup.enter_context(OpenpilotPrefix())
cleanup.enter_context(mock.patch.dict(os.environ, {'SIMULATION': '0', 'AOL_REPLAY_RUNTIME': '1'}))
family = honda_family_case('honda', True)
cleanup.callback(family.doCleanups)
sd, _, drive = family._authority_callers(False, cp=family.family_cp.as_builder(), genuine_events=True)
original = sd.update_events
def events(state):
dm = sd.sm['driverMonitoringState'].as_builder()
dm.alertLevel = 'three'
sd.sm.data['driverMonitoringState'] = dm.as_reader()
return original(state)
cleanup.enter_context(mock.patch.object(sd, 'update_events', side_effect=events))
axis, _, command = drive(0, latched=True, native_request_long=False)
assert not axis.lateralActive and not command.latActive and not command.longActive
assert not getattr(sd, 'aol_dm_retained_owner', None)
@@ -282,6 +282,7 @@ class TestEv6ConfiguredDesire(unittest.TestCase):
params.put('MainCruiseButtonControl', 0, block=True)
card = Car.__new__(Car)
card.params, card.CP = params, ev6_params()
card.CI = SimpleNamespace(CP=card.CP, CC=None)
card.slc_transport_available = False
card.v_cruise_helper = VCruiseHelper(card.CP)
card.aol_card_intent = Ev6CardIntent(read_settings(params))
@@ -161,7 +161,11 @@ def test_fresh_lag_does_not_waive_fault_dm_or_native_gates(fault):
mock.patch.object(sd.rk.avg_dt, 'get_average', return_value=0.02):
axis, _, command = drive(5, state_stamp=stamp, observed_state=observed, **options)
if fault in ('dm_three', 'dm_lockout'):
assert delivered == [stamp] and sd.aol_dm_lateral_inhibit
assert delivered == [stamp] and sd.aol_availability_long_inhibit
assert not sd.aol_dm_lateral_inhibit and not sd.enabled
assert sd.aol_dm_retained_owner == (sd.aol_last_intent.producerSessionId, sd.aol_session_id)
expected = log.OnroadEvent.EventName.driverDistracted3 if fault == 'dm_three' else log.OnroadEvent.EventName.tooDistracted
assert expected in sd.events.names
assert not axis.lateralActive and not axis.longitudinalActive
assert not command.latActive and not command.longActive
assert not sd.events.contains(fixture.ET.ENABLE)
+2
View File
@@ -424,6 +424,7 @@ class TestGmAol(unittest.TestCase):
def test_actual_card_startup_default_off_and_main_cycle_fault_recovery(self):
with OpenpilotPrefix(), patch.dict(os.environ, {'SIMULATION': '1', 'AOL_REPLAY_RUNTIME': '0'}):
settings = Params()
settings.put_bool('AlwaysOnLateral', False, block=True) # Explicit Off survives new-install On enrollment.
cp = factory_params(alpha=False)
ordinary = self.card(cp, settings)
self.assertIsNone(ordinary.aol_card_intent)
@@ -621,6 +622,7 @@ class TestGmAol(unittest.TestCase):
with self.subTest(identity=identity, held=held, disabled=disabled), OpenpilotPrefix(), \
patch.dict(os.environ, {'SIMULATION': '1', 'REPLAY': '1', 'AOL_REPLAY_RUNTIME': '0'}):
settings = Params()
settings.put_bool('AlwaysOnLateral', False, block=True) # Test the preserved explicit-Off startup branch.
settings.put_bool('GMAutoHold', held, block=True)
settings.put_bool('DisableOpenpilotLongitudinal', disabled, block=True)
factory = ordinary_params(identity, radar=True)
@@ -0,0 +1,348 @@
"""GM cold MAIN replay through actual factory/parser/Card/Controls/native code.
No vehicle I/O. Native masks are calculated by libsafety; CAN validity is
calculated by real parsers. Clock patching supplies a recorded test timeline.
"""
import os
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from opendbc.can import CANPacker
from opendbc.car import Bus, structs
from opendbc.car.gm.aol import qualified_gm
from opendbc.car.gm.interface import CarInterface
from opendbc.car.gm.tests.test_bolt_cc import feed as packets_for_bolt, native, params as bolt_params
from opendbc.car.gm.tests.test_bolt_factory_acc import factory_params
from opendbc.car.gm.tests.test_volt_camera_control import camera_params, feed_camera
from opendbc.car.gm.tests.test_volt_sdgm_control import sdgm_params
from opendbc.car.gm.tests.test_cc_gateway_stock import silverado_stock_params, pt_frames
from opendbc.car.gm.values import CAR, DBC
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.selfdrive.selfdrived.events import Events, EventName, ET
from openpilot.starpilot.aol.intent import AolProcessFaultContext, read_settings
from openpilot.starpilot.aol.runtime import decide_axes, ordinary_lateral_requested
from openpilot.starpilot.aol.tests import test_gm as gm_fixtures
from openpilot.starpilot.aol.tests.test_gm import configurations
from openpilot.starpilot.aol.vehicle import create_intent, policy_for
from openpilot.starpilot.aol.wire import IntentState, SafetyState, encode_safety
from openpilot.starpilot.lateral.tests.test_lane_runtime import feed as feed_controls
def event_message(events, now):
value = messaging.new_message('onroadEvents', 0, valid=True, logMonoTime=now)
value.onroadEvents = events.to_msg()
return value
def parsed_packets(cp, packer, now, tick, speed, main):
# Packet-only fixture, followed by ONE actual parser update. Every engine
# MAIN value in the resulting batch belongs to the declared physical level.
empty = SimpleNamespace(update=lambda _: None)
if cp.carFingerprint == CAR.CHEVROLET_SILVERADO_CC:
packets = pt_frames(packer, cruise=False, main=main, counter=tick % 4)
elif cp.carFingerprint in (CAR.CHEVROLET_VOLT_CAMERA, CAR.CHEVROLET_VOLT_2019):
_, packets = feed_camera(empty, packer, now, counter=tick % 4, speed=speed, active=False)
else:
_, packets = packets_for_bolt(empty, packer, now, counter=tick % 4, speed=speed, active=False)
packets += [packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {'ACCCruiseState': 0})]
if cp.carFingerprint != CAR.CHEVROLET_SILVERADO_CC:
address = packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {})[0]
packets = [row for row in packets if not (row[0] == address and row[2] == 2)]
packets.append(packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {'ACCCruiseState': 0}))
replaced = ('ECMEngineStatus', 'EBCMWheelSpdFront', 'EBCMWheelSpdRear')
addresses = {packer.make_can_msg(name, 0, {})[0] for name in replaced}
packets = [row for row in packets if not (row[2] == 0 and row[0] in addresses)]
packets += [packer.make_can_msg('ECMEngineStatus', 0, {'CruiseMainOn': int(main)}),
packer.make_can_msg('EBCMWheelSpdFront', 0, {'FLWheelSpd': speed * 3.6, 'FRWheelSpd': speed * 3.6}),
packer.make_can_msg('EBCMWheelSpdRear', 0, {'RLWheelSpd': speed * 3.6, 'RRWheelSpd': speed * 3.6,
'RLWheelDir': 1, 'RRWheelDir': 1})]
return packets
def cases():
yield factory_params(alpha=False)
yield factory_params(alpha=True)
yield bolt_params(CAR.CHEVROLET_BOLT_CC_2018_2021)
yield silverado_stock_params()
yield camera_params(alpha=False)
yield camera_params(alpha=True)
yield sdgm_params(alpha=False)
yield sdgm_params(alpha=True)
@pytest.mark.parametrize('index', range(8))
@pytest.mark.parametrize('availability', [None, 'commIssue', 'commIssueAvgFreq'])
def test_cold_parser_main_readiness_then_actual_commands_without_main_edge(index, availability):
cp = list(cases())[index]
with OpenpilotPrefix(), patch.dict(os.environ, {'SIMULATION': '1', 'REPLAY': '1', 'AOL_REPLAY_RUNTIME': '0'}):
params = Params()
params.put_bool('AlwaysOnLateral', True, block=True)
selected = gm_fixtures.TestGmAol.card(cp, params)
selected.aol_process_fault_context = AolProcessFaultContext()
selected.ci_initialized = True # Skip ECU I/O; no native permission is fabricated.
sent = []
selected.publish_sendcan = lambda rows, **kwargs: sent.append(list(rows))
controls = Controls()
ci = selected.CI
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
safety = libsafety_py.libsafety
safety.init_tests()
safety.set_alternative_experience(32)
assert safety.set_safety_hooks(int(structs.CarParams.SafetyModel.gm), selected.CP.safetyConfigs[0].safetyParam) == 0
minimum = max(selected.CP.minSteerSpeed, .3)
above = max(4., minimum + 2.)
emitted_active = False
try:
for tick in range(48):
now = 10_000_000_000 + tick * 10_000_000
initializing = tick < 3
incomplete = 3 <= tick < 8
main = tick != 47
speed = 0. if tick < 10 else minimum / 2. if tick < 20 else above
packets = parsed_packets(cp, packer, now, tick, speed, main)
# Actual parser lazy subscriptions may need a preceding genuine frame.
if tick == 0:
ci.update([(now - 1_000_000, packets)])
cs = ci.update([(now, packets)])
assert cs.canValid and not cs.canTimeout
assert cs.cruiseState.available == main
assert not cs.cruiseState.enabled # MAIN is not engaged stock ACC.
for packet in packets:
assert native('rx', packet, now // 1000)
safety.safety_tick()
safety.set_aol_test_heartbeat(True)
events = Events()
if initializing:
events.add(EventName.selfdriveInitializing)
if incomplete:
events.add(EventName.calibrationIncomplete)
if availability:
events.add(getattr(EventName, availability))
event = event_message(events, now)
selected.sm.update_msgs(now / 1e9, [event.as_reader()])
disarm = selected.aol_disarming_fault(cs, now, now)
assert not disarm
selected.aol_card_intent.update(cs, fault_active=disarm, now_ns=now)
selected.observe_aol_calibration(cs, now, False)
allowed, pause_lat, pause_long = selected.aol_card_intent.output(cs)
assert allowed == (main and not incomplete)
assert not selected.aol_card_intent._main_cycle_required
intent = IntentState(selected.slc_producer_session, tick + 1, now, now, now + 30_000_000,
allowed, pause_lat, pause_long, True, allowed)
desired = decide_axes(standard_lateral=False, standard_longitudinal=False, intent=intent,
native=None, car_state=cs, initialized=not initializing, model_ready=not incomplete,
no_entry=events.contains(ET.NO_ENTRY), immediate_disable=events.contains(ET.IMMEDIATE_DISABLE),
dm_lockout=False, pause_brake_mps=0.)
safety.aol_set_host_request(int(desired.desired_lateral))
mask = safety.aol_get_permission_mask()
receipt_state = SafetyState(1, True, now, now + 200_000_000,
int(structs.CarParams.SafetyModel.gm), selected.CP.safetyConfigs[0].safetyParam,
bool(mask & 1), bool(mask & 2), desired.desired_lateral, False, 'panda', 'gm-startup')
decision = decide_axes(standard_lateral=False, standard_longitudinal=False, intent=intent,
native=receipt_state, car_state=cs, initialized=not initializing, model_ready=not incomplete,
no_entry=events.contains(ET.NO_ENTRY), immediate_disable=events.contains(ET.IMMEDIATE_DISABLE),
dm_lockout=False, pause_brake_mps=0.)
if tick == 43:
receipt_state = replace(receipt_state, observedMonoTime=now - 200_000_001, validUntilMonoTime=now - 1)
elif tick == 44:
receipt_state = replace(receipt_state, axisSessionId='replaced-session')
elif tick == 46:
receipt_state = replace(receipt_state, lateralAllowed=False)
receipt = messaging.new_message('aolSafetyWire', 0, valid=tick != 45, logMonoTime=receipt_state.observedMonoTime)
receipt.aolSafetyWire = encode_safety(receipt_state)
axis = messaging.new_message('aolAxisState', valid=True, logMonoTime=now)
axis.aolAxisState.qualified = True
axis.aolAxisState.nativeAcknowledged = decision.native_acknowledged
axis.aolAxisState.desiredLateral = decision.desired_lateral
axis.aolAxisState.lateralActive = decision.lateral_active
axis.aolAxisState.sessionId = 'gm-startup'
axis.aolAxisState.observedMonoTime, axis.aolAxisState.validUntilMonoTime = now, now + 30_000_000
state = messaging.new_message('carState', valid=True, logMonoTime=now)
state.carState = cs
# Build the existing full input fixture, then substitute the actual
# physical/state/axis events BEFORE one genuine SubMaster receive.
# Two receives of carState/onroadEvents at the same clock tick would
# give the real frequency tracker a zero inter-arrival duration.
batches = []
with patch.object(controls.sm, 'update_msgs', side_effect=lambda stamp, rows, batches=batches: batches.append((stamp, rows))):
feed_controls(controls, now, tick, active=False, enabled=False)
assert len(batches) == 1 and batches[0][0] == now / 1e9
samples = [sample for sample in batches[0][1] if sample.which() not in
('carState', 'onroadEvents', 'aolAxisState', 'aolSafetyWire')]
samples += [axis.as_reader(), receipt.as_reader(), state.as_reader(), event.as_reader()]
assert len({sample.which() for sample in samples}) == len(samples)
controls.sm.update_msgs(now / 1e9, samples)
command, _ = controls.state_control()
expected = bool(main and not incomplete and cs.vEgo > minimum and tick < 43)
assert command.latActive == expected
assert not command.longActive
assert ordinary_lateral_requested(command.latActive, cs, selected.CP) == expected
emitted_active |= expected
command.actuators.torque = .02 if command.latActive else 0.
current = messaging.new_message('carControl', valid=True, logMonoTime=now)
current.carControl = command
selected.sm.update_msgs(now / 1e9, [current.as_reader()])
selected.can_log_mono_time = now + 2
with patch('openpilot.selfdrive.car.card.time.monotonic_ns', return_value=now + 2), \
patch('openpilot.selfdrive.car.card.time.monotonic', return_value=(now + 2) / 1e9):
selected.controls_update(cs, current.carControl.as_reader())
for message in sent.pop():
assert native('tx', message, (now + 2) // 1000), (tick, message)
assert emitted_active
assert params.get_bool('AlwaysOnLateral')
finally:
safety.aol_set_host_request(0)
selected.vehicle_startup.close()
def test_all_actual_gm_configuration_policies_cold_main_then_critical_recovery():
with OpenpilotPrefix():
params = Params()
params.put_bool('AlwaysOnLateral', True, block=True)
settings = read_settings(params)
for cp in configurations():
assert qualified_gm(cp)
policy = policy_for(cp)
assert policy.intent_supported
owner = create_intent(cp, settings, policy)
cs = structs.CarState(canValid=True, gearShifter='drive', vEgo=20.)
cs.cruiseState.available = True
initial = Events()
initial.add(EventName.selfdriveInitializing)
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=initial.to_msg())
assert owner.startup_calibration_pending
events = Events()
events.add(EventName.calibrationIncomplete)
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=events.to_msg())
assert not owner.output(cs)[0] and not owner._calibration_rearm_required
for _ in range(10):
owner.update(cs, fault_active=None)
owner.observe_calibration(cs, events=None) # onroadEvents publishes sparsely.
assert not owner.output(cs)[0] and not owner._calibration_rearm_required
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
assert owner.output(cs)[0] and not owner.startup_calibration_pending
# Same event after actual ready observation retains existing rearm law.
owner.update(cs, fault_active=True)
owner.observe_calibration(cs, events=events.to_msg())
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
assert not owner.output(cs)[0] and owner._main_cycle_required
cs.cruiseState.available = False
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
cs.cruiseState.available = True
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
assert owner.output(cs)[0]
before = cp.to_dict()
denied = cp.as_reader().as_builder()
denied.passive = True
assert create_intent(denied, settings, policy_for(denied)) is None
assert cp.to_dict() == before
@pytest.mark.parametrize('fault', ['calibrationInvalid', 'calibrationRecalibrating', 'controlsMismatch', 'processNotRunning'])
def test_cold_mixed_or_critical_fault_never_uses_readiness_exception(fault):
from openpilot.starpilot.car.gm.aol import intent_disarming_fault
cp = factory_params(alpha=False)
cs = structs.CarState(canValid=True, gearShifter='drive')
cs.cruiseState.available = True
events = Events()
events.add(EventName.calibrationIncomplete)
events.add(getattr(EventName, fault))
assert intent_disarming_fault(cp, events.to_msg(), cs, startup_calibration_pending=True)
def test_explicit_off_and_managed_toggle_do_not_gain_startup_authority():
with OpenpilotPrefix():
params = Params()
params.put_bool('AlwaysOnLateral', False, block=True)
cp = factory_params(alpha=False)
policy = policy_for(cp)
owner = create_intent(cp, read_settings(params), policy)
cs = structs.CarState(canValid=True, gearShifter='drive')
cs.cruiseState.available = True
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
assert not owner.output(cs)[0] and not params.get_bool('AlwaysOnLateral')
params.put_bool('AlwaysOnLateral', True, block=True)
params.put('MainCruiseButtonControl', 9, block=True)
owner = create_intent(cp, read_settings(params), policy)
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
assert not owner.output(cs)[0]
cs.cruiseState.available = False
cs.buttonEvents = [structs.CarState.ButtonEvent(type='mainCruise', pressed=True)]
owner.update(cs, fault_active=False)
assert not owner.output(cs)[0] # Even a configured toggle cannot bypass MAIN OFF.
@pytest.mark.parametrize('index', range(8))
def test_cached_main_without_current_required_can_cannot_arm(index):
cp = list(cases())[index]
with OpenpilotPrefix():
params = Params()
params.put_bool('AlwaysOnLateral', True, block=True)
owner = create_intent(cp, read_settings(params), policy_for(cp))
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
original_main_stamp = 0
# A lazy source keeps its canonical initial timeout until rate is learned.
# Derive actual held ticks from that real parser policy, never shorten it.
stop_tick = 5
tick = 0
while tick < stop_tick:
now = 10_000_000_000 + tick * 10_000_000
packets = parsed_packets(cp, packer, now, tick, 20., True)
if tick == 0:
ci.update([(now - 1_000_000, packets)])
if tick >= 5:
main_address = packer.make_can_msg('ECMEngineStatus', 0, {})[0]
packets = [packet for packet in packets if not (packet[0] == main_address and packet[2] == 0)]
cs = ci.update([(now, packets)])
main_stamp = ci.can_parsers[Bus.pt].ts_nanos['ECMEngineStatus']['CruiseMainOn']
if tick == 4:
original_main_stamp = main_stamp
engine_address = packer.make_can_msg('ECMEngineStatus', 0, {})[0]
timeout = ci.can_parsers[Bus.pt].message_states[engine_address].timeout_threshold
from opendbc.can.parser import CAN_INVALID_CNT
stop_tick = 5 + int(timeout // 10_000_000) + CAN_INVALID_CNT + 2
elif tick >= 5:
assert main_stamp == original_main_stamp
owner.update(cs, fault_active=False, now_ns=now)
if tick < 5:
assert owner.output(cs)[0]
tick += 1
assert now - original_main_stamp > timeout
assert cs.cruiseState.available # Cached physical bit alone is insufficient.
assert not cs.canValid and not owner.output(cs)[0]
def test_cold_native_transport_reset_is_not_a_readiness_recovery():
with OpenpilotPrefix():
params = Params()
params.put_bool('AlwaysOnLateral', True, block=True)
cp = factory_params(alpha=False)
owner = create_intent(cp, read_settings(params), policy_for(cp))
cs = structs.CarState(canValid=True, gearShifter='drive')
cs.cruiseState.available = True
events = Events()
events.add(EventName.calibrationIncomplete)
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=events.to_msg())
assert not owner.output(cs)[0]
owner.update(cs, fault_active=True) # Existing Card transport-reset result.
owner.observe_calibration(cs, events=events.to_msg())
owner.update(cs, fault_active=False)
owner.observe_calibration(cs, events=[])
assert not owner.output(cs)[0] and owner._main_cycle_required
assert not owner.startup_calibration_pending
@@ -2021,6 +2021,7 @@ class UiProcessIntentTests(unittest.TestCase):
card = Car.__new__(Car)
card.CP = stock(True)
card.aol_card_intent = None # Real Card initializes this when no optional owner is selected.
card.aol_process_fault_context = AolProcessFaultContext()
class SM:
seen = {'managerState': True}
@@ -0,0 +1,171 @@
from contextlib import ExitStack
from dataclasses import replace
import pytest
from openpilot.cereal import log
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.selfdrived.events import ET, EventName
from openpilot.starpilot.aol.runtime import current_intent, current_native
from openpilot.starpilot.aol.seatbelt import seatbelt_lateral_soft_event_allowed
from openpilot.starpilot.aol.tests.test_ioniq_availability import IoniqAvailabilityTests, car_state
from openpilot.starpilot.aol.tests.stock_lateral_test_helpers import callers
@pytest.mark.parametrize('full_op', [False, True])
def test_actual_ioniq_belt_long_lock_preserves_armed_lateral(full_op):
case = IoniqAvailabilityTests(methodName='runTest')
with OpenpilotPrefix(), ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
sd, controls, drive, card, receive = case._ioniq_availability_callers(full_op)
for tick, unlatched in ((2, True), (3, True), (4, False)):
state = car_state()
state.seatbeltUnlatched = unlatched
axis, _, command = drive(tick, observed_state=state, native_request_long=False, long_allowed=False)
assert axis.faultReason == 'none'
assert axis.lateralActive and command.latActive
assert not axis.longitudinalActive and not command.longActive
if unlatched:
assert sd.events.contains(ET.NO_ENTRY) and sd.events.contains(ET.SOFT_DISABLE)
assert EventName.seatbeltNotLatched in sd.events.names
# Deliver the exact published current command, rather than the prior SM command.
published = controls.pm.messages['carControl']
sd.sm.update_msgs((10_000_000_000 + tick * 10_000_000) / 1e9, [published])
assert receive(10_000_000_000 + tick * 10_000_000, state=state,
axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
assert card.aol_card_intent.allowed_latch
assert not card.sm['carControl'].longActive
# Buckling is not an ordinary LONG enable gesture.
assert not command.longActive
@pytest.mark.parametrize('fault', [None, 'native_denied', 'wrong_session', 'stale_source', 'unarmed',
'belt_mismatch', 'mixed_user', 'no_entry_missing', 'door', 'permanent'])
def test_shared_belt_classification_keeps_actual_native_and_source_guards(fault):
case = IoniqAvailabilityTests(methodName='runTest')
with OpenpilotPrefix(), ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
sd, _, drive, _, _ = case._ioniq_availability_callers(False)
state = car_state()
state.seatbeltUnlatched = True
state.cruiseState.available = True
drive(2, observed_state=state, native_request_long=False)
now = source = 10_020_000_000
intent = current_intent(sd.sm, car_state_ns=source, now_ns=now, companion=sd.aol_intent_companion)
native = current_native(sd.sm, now_ns=now, CP=sd.CP, axis_session_id=sd.aol_session_id)
assert intent is not None and native is not None
event = next(event for event in sd.events.to_msg() if event.name == EventName.seatbeltNotLatched)
if fault == 'native_denied':
native = replace(native, lateralAllowed=False)
elif fault == 'wrong_session':
native = replace(native, axisSessionId='other')
elif fault == 'stale_source':
now += 30_000_001
elif fault == 'unarmed':
intent = replace(intent, allowedLatch=False)
elif fault == 'belt_mismatch':
state.seatbeltUnlatched = False
elif fault == 'mixed_user':
event.userDisable = True
elif fault == 'no_entry_missing':
event.noEntry = False
elif fault == 'door':
state.doorOpen = True
elif fault == 'permanent':
state.steerFaultPermanent = True
assert seatbelt_lateral_soft_event_allowed(sd.CP, state, intent, native, event,
source_ns=source, now_ns=now, axis_session_id=sd.aol_session_id) == (fault is None)
@pytest.mark.parametrize('family', ['gm', 'toyota'])
@pytest.mark.parametrize('full_op', [False, True])
def test_actual_family_belt_removal_preserves_lateral_and_ordinary_long_timer(family, full_op):
with ExitStack() as cleanup:
sd, controls, drive, card, receive = callers(cleanup, family, not full_op)
baseline = controls.pm.messages['carControl'].carControl
assert baseline.latActive and baseline.longActive == full_op
state = car_state()
state.seatbeltUnlatched = True
# Preserve the prior native combined grant for the first withdrawal decision.
axis, _, command = drive(2, observed_state=state, native_request_long=full_op)
assert axis.lateralActive and command.latActive
assert not axis.desiredLongitudinal and not axis.longitudinalActive and not command.longActive
assert axis.faultReason == 'none'
assert receive(10_020_000_000, state)
assert card.aol_card_intent.allowed_latch
assert EventName.seatbeltNotLatched in sd.events.names
assert sd.events.contains(ET.NO_ENTRY) and sd.events.contains(ET.SOFT_DISABLE)
if full_op:
assert sd.enabled and sd.state_machine.state == log.SelfdriveState.OpenpilotState.softDisabling
assert sd.state_machine.soft_disable_timer == 300
# Exercise the unchanged ordinary owner with its actual current event set.
for _ in range(300):
sd.state_machine.update(sd.events)
assert sd.state_machine.state == log.SelfdriveState.OpenpilotState.disabled
state.seatbeltUnlatched = False
axis, _, command = drive(3, observed_state=state, native_request_long=False, long_allowed=False)
assert axis.lateralActive and command.latActive
assert not command.longActive
assert not sd.enabled and not axis.desiredLongitudinal
assert EventName.seatbeltNotLatched not in sd.events.names
def test_actual_ioniq_first_unbuckled_admission_needs_no_buckled_baseline():
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import RadarInterface
from openpilot.selfdrive.car.card import Car
from openpilot.selfdrive.car.tests.test_hyundai_aol import candidate
from opendbc.car.structs import car
case = IoniqAvailabilityTests(methodName='runTest')
with OpenpilotPrefix(), ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
_, cp = candidate(True)
# Match Card pre_create_hook finalization before the supplied CI constructor.
cp.safetyConfigs[-1].safetyParam |= 0x0800
sd, _, drive = case._authority_callers(False, cp=cp, genuine_events=True)
card_cp = cp.as_reader().as_builder()
card = Car(CI=CarInterface(card_cp), RI=RadarInterface(card_cp))
state = car_state()
state.cruiseState.available = True
state.seatbeltUnlatched = True
assert not card.aol_card_intent.allowed_latch
card.aol_card_intent.update(state, now_ns=9_990_000_000)
state.buttonEvents = [car.CarState.ButtonEvent(type=car.CarState.ButtonEvent.Type.lkas, pressed=True)]
card.aol_card_intent.update(state, now_ns=10_000_000_000)
assert card.aol_card_intent.allowed_latch
state.buttonEvents = []
axis, _, command = drive(0, observed_state=state, latched=card.aol_card_intent.allowed_latch,
native_request_long=False)
assert axis.faultReason == 'none' and axis.lateralActive and command.latActive
assert not sd.enabled and not axis.desiredLongitudinal and not command.longActive
assert EventName.seatbeltNotLatched in sd.events.names
@pytest.mark.parametrize('fault', ['wrong_inventory', 'unsupported', 'mixed_hard'])
def test_actual_family_belt_does_not_mask_foreign_or_hard_authority(fault):
with ExitStack() as cleanup:
sd, _, drive, _, _ = callers(cleanup, 'toyota', True)
state = car_state()
state.cruiseState.available = True
state.seatbeltUnlatched = True
if fault == 'mixed_hard':
state.steerFaultPermanent = True
axis, _, command = drive(2, observed_state=state, native_request_long=False)
if fault == 'mixed_hard':
assert not axis.lateralActive and not command.latActive and not command.longActive
assert axis.faultReason == 'critical'
return
now = 10_020_000_000
intent = current_intent(sd.sm, car_state_ns=now, now_ns=now, companion=sd.aol_intent_companion)
native = current_native(sd.sm, sd.CP, now_ns=now, axis_session_id=sd.aol_session_id)
assert intent is not None and native is not None and native.pandaInventory
event = next(e for e in sd.events.to_msg() if e.name == EventName.seatbeltNotLatched)
cp = sd.CP
if fault == 'wrong_inventory':
native = replace(native, pandaInventory=(replace(native.pandaInventory[0], safetyParam=0),))
else:
cp = cp.as_builder()
cp.brand = 'unsupported'
assert not seatbelt_lateral_soft_event_allowed(cp, state, intent, native, event,
source_ns=now, now_ns=now, axis_session_id=sd.aol_session_id)
@@ -10,7 +10,7 @@ from openpilot.starpilot.aol.tests.test_input_deadline_continuity import intent
@pytest.mark.parametrize('fault', ['alert_three', 'lockout'])
def test_source_zero_withdrawal_respects_dm_inhibit_after_current_dm_clears(fault):
def test_source_zero_withdrawal_preserves_armed_dm_lateral_but_never_commands_without_source(fault):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
@@ -46,8 +46,9 @@ def test_source_zero_withdrawal_respects_dm_inhibit_after_current_dm_clears(faul
axis, _, command = drive(5)
assert delivered == [10_050_000_000]
assert sd.sm.logMonoTime['driverMonitoringState'] == 10_050_000_000
assert sd.aol_dm_lateral_inhibit
assert not axis.lateralActive and not command.latActive
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
assert axis.lateralActive and command.latActive
assert not axis.longitudinalActive and not command.longActive and not sd.enabled
if fault == 'alert_three':
assert sd.sm['driverMonitoringState'].alertLevel == log.DriverMonitoringState.AlertLevel.three
else:
@@ -55,8 +56,9 @@ def test_source_zero_withdrawal_respects_dm_inhibit_after_current_dm_clears(faul
current_fault[0] = False
for tick in range(6, 11):
axis, _, command = drive(tick)
assert sd.aol_dm_lateral_inhibit
assert not axis.lateralActive and not command.latActive
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
assert axis.lateralActive and command.latActive
assert not axis.longitudinalActive and not command.longActive and not sd.enabled
assert delivered == [10_050_000_000, 10_100_000_000]
assert sd.sm['driverMonitoringState'].alertLevel == log.DriverMonitoringState.AlertLevel.none
assert not sd.sm['driverMonitoringState'].lockout
@@ -66,9 +68,17 @@ def test_source_zero_withdrawal_respects_dm_inhibit_after_current_dm_clears(faul
assert accepted.allowedLatch and accepted.lateralArmed and card.aol_card_intent.allowed_latch
axis, _, command = drive(14, timeout=True, state_stamp=accepted_source,
intent_message=intent(accepted_source, 11))
assert sd.aol_ordinary_source_pause
assert sd.aol_dm_lateral_inhibit
# DM already disabled ordinary LONG; its source-pause path cannot own this LAT-only gap.
assert not sd.aol_ordinary_source_pause
assert sd.aol_availability_pause.active
assert axis.faultReason == 'availabilityPause'
assert not sd.aol_authority_lost
assert sd.aol_publication_retained_intent is not None
assert sd.aol_publication_retained_intent.carStateLogMonoTime == accepted_source
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
assert axis.sourceCarStateMonoTime == 0 and not sd.conditional_car_state_valid
assert not axis.lateralActive and not axis.longitudinalActive
assert not command.latActive and not command.longActive
assert not axis.desiredLateral and not axis.nativeAcknowledged
assert axis.desiredLateral and axis.nativeAcknowledged
assert accepted.allowedLatch and accepted.lateralArmed
assert not sd.enabled and not sd.events.contains(fixture.ET.ENABLE)
@@ -202,7 +202,7 @@ def test_axis_first_critical_join_never_masks_missing_or_hard_occurrence(failure
@pytest.mark.parametrize('dm', ['alert_three', 'lockout'])
def test_dm_inhibits_actual_lateral_output_during_and_after_lag(dm):
def test_dm_preserves_armed_lateral_after_lag_but_long_remains_disabled(dm):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
@@ -244,19 +244,20 @@ def test_dm_inhibits_actual_lateral_output_during_and_after_lag(dm):
assert sd.sm['driverMonitoringState'].lockout
assert not axis.lateralActive and not axis.longitudinalActive
assert not command.latActive and not command.longActive
assert sd.aol_dm_lateral_inhibit
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
expected = log.OnroadEvent.EventName.driverDistracted3 if dm == 'alert_three' else log.OnroadEvent.EventName.tooDistracted
assert expected in sd.events.names
assert receive(10_050_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
assert card.aol_card_intent.allowed_latch
dm_current[0] = False
# DM remains current until its next cadence sample; neither clearing lag
# nor a new positive native ACK removes the independent lateral inhibit.
# nor a new positive native ACK reenables ordinary LONG.
for tick in range(6, 11):
axis, state, command = drive(tick, native_request_long=False)
assert sd.aol_dm_lateral_inhibit
assert not axis.lateralActive and not axis.longitudinalActive
assert not command.latActive and not command.longActive and not state.enabled
assert sd.aol_availability_long_inhibit and not sd.aol_dm_lateral_inhibit
assert axis.lateralActive and not axis.longitudinalActive
assert command.latActive and not command.longActive and not state.enabled
assert not sd.events.contains(fixture.ET.ENABLE)
assert delivered == [10_050_000_000, 10_100_000_000]
assert sd.sm.logMonoTime['driverMonitoringState'] == 10_100_000_000
assert sd.sm['driverMonitoringState'].alertLevel == log.DriverMonitoringState.AlertLevel.none
-7
View File
@@ -15,13 +15,6 @@
"size": 376387,
"sha256": "2fff703ee69452789ec7884e6447c146a0f45c6fa39c70455fd9db88e15ebb54"
},
{
"id": "stalin",
"name": "Stalin",
"path": "theme/Themes/stalin/sounds.zip",
"size": 1443422,
"sha256": "52f07cf7456a3eed2435a6a5ec46b6a0a99fa9714b0ddb3a1d46bf940117ee59"
},
{
"id": "tesla",
"name": "Tesla",
+4 -2
View File
@@ -14,7 +14,7 @@ import urllib.request
import uuid
import zipfile
from openpilot.starpilot.audio.sound_pack import LEGACY_NAMES, MAX_WAV_BYTES, PACK_ROOT, pack_directory, read_wav
from openpilot.starpilot.audio.sound_pack import REMOVED_PACKS, cleanup_removed_packs, LEGACY_NAMES, MAX_WAV_BYTES, PACK_ROOT, pack_directory, read_wav
ORIGIN = "https://huggingface.co/buckets/StarPilot-Driving/StarPilot-Resources/resolve/"
@@ -63,7 +63,7 @@ class SoundDownloads:
packs = {}
for pack in catalog["packs"]:
slug = pack["id"]
if (not isinstance(slug, str) or not SLUG.fullmatch(slug) or slug == "stock" or slug in packs or
if (not isinstance(slug, str) or not SLUG.fullmatch(slug) or slug == "stock" or slug in REMOVED_PACKS or slug in packs or
pack["path"] != f"theme/Themes/{slug}/sounds.zip" or
not isinstance(pack["name"], str) or not 0 < len(pack["name"]) <= 128 or
type(pack["size"]) is not int or not 0 < pack["size"] <= MAX_ARCHIVE_BYTES or
@@ -71,6 +71,7 @@ class SoundDownloads:
raise ValueError("invalid catalog entry")
packs[slug] = pack
self._packs = packs
cleanup_removed_packs(self.root, parked=self._safe_parked)
except (OSError, ValueError, KeyError, TypeError) as exc:
raise SoundDownloadError("sound catalog unavailable", 503) from exc
@@ -91,6 +92,7 @@ class SoundDownloads:
}
def action(self, command: str, data: dict) -> dict:
cleanup_removed_packs(self.root, parked=self._safe_parked)
if not isinstance(data, dict):
raise SoundDownloadError("invalid request")
with self._lock:
+138 -1
View File
@@ -2,6 +2,8 @@
from pathlib import Path
import re
import os
import stat
import wave
import numpy as np
@@ -13,7 +15,7 @@ BUILTIN_FILES = {
"refuse.wav": "warning_1.wav", "warning.wav": "warning_1.wav", "pre_alert.wav": "warning_1.wav",
"dm_warning.wav": "warning_2.wav", "critical.wav": "warning_2.wav", "dm_critical.wav": "warning_3.wav",
}
REMOVED_PACKS = frozenset(("frog", "frogpilot"))
REMOVED_PACKS = frozenset(("frog", "frogpilot", "stalin"))
MAX_WAV_BYTES = 48000 * 2 * 30 + 4096
LEGACY_NAMES = {
"warning.wav": "prompt.wav", "dm_warning.wav": "prompt_distracted.wav",
@@ -75,6 +77,141 @@ def migrate_removed_selection(params, root: Path = PACK_ROOT) -> bool:
authorized=lambda: True, temp_prefix=".sound-pack-migration-").verified
def cleanup_removed_packs(root: Path = PACK_ROOT, *, parked) -> tuple[str, ...]:
"""Bounded parked migration over captured directory descriptors.
Enumerate at most 256 root entries and 64 pack entries, depth 4 / 96 MiB.
Keep captured descriptors, never follow links, and check entry identities
before mutations. External renames between check and syscall remain a POSIX
race; symlink targets are never traversed.
Concurrent growth/revocation leaves remaining obsolete files for a later retry.
"""
def safe():
try:
return bool(parked())
except Exception:
return False
def inode(value):
return value.st_dev, value.st_ino
def revision(value):
return inode(value), value.st_mtime_ns, value.st_ctime_ns
removed, opened = [], []
try:
if not safe() or root.is_symlink():
return ()
root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
opened.append(root_fd)
root_identity = inode(os.fstat(root_fd))
names = []
with os.scandir(root_fd) as entries:
for entry in entries:
if len(names) == 256:
return ()
names.append(entry.name)
for name in names:
if name.casefold() not in REMOVED_PACKS or not safe():
continue
expected = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
def guard(name=name, expected=expected):
return (safe() and inode(root.lstat()) == root_identity and
inode(os.stat(name, dir_fd=root_fd, follow_symlinks=False)) == inode(expected))
if stat.S_ISLNK(expected.st_mode):
if guard():
os.unlink(name, dir_fd=root_fd)
removed.append(name)
continue
if not stat.S_ISDIR(expected.st_mode):
continue
pack_fds, budget = [], [0, 0]
try:
pack_fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd)
pack_fds.append(pack_fd)
if inode(os.fstat(pack_fd)) != inode(expected):
continue
def plan(directory_fd, depth, *, budget=budget, pack_fds=pack_fds, guard=guard):
if depth > 4 or not guard():
raise ValueError('cleanup depth or authority')
node = [directory_fd, revision(os.fstat(directory_fd)), []]
with os.scandir(directory_fd) as entries:
for entry in entries:
budget[0] += 1
if budget[0] > 64:
raise ValueError('cleanup entry bound')
saved = os.stat(entry.name, dir_fd=directory_fd, follow_symlinks=False)
child = None
if stat.S_ISDIR(saved.st_mode):
child_fd = os.open(entry.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd)
pack_fds.append(child_fd)
if inode(os.fstat(child_fd)) != inode(saved):
raise ValueError('cleanup inode changed')
child = plan(child_fd, depth + 1)
elif not (stat.S_ISREG(saved.st_mode) or stat.S_ISLNK(saved.st_mode)):
raise ValueError('cleanup unexpected entry')
else:
budget[1] += saved.st_size
if budget[1] > 96 * 1024 * 1024:
raise ValueError('cleanup byte bound')
node[2].append((entry.name, saved, child))
if revision(os.fstat(directory_fd)) != node[1]:
raise ValueError('cleanup directory changed')
return node
tree = plan(pack_fd, 1)
def same_entries(directory_fd, expected_names):
observed = set()
with os.scandir(directory_fd) as entries:
for entry in entries:
if len(observed) == 64:
return False
observed.add(entry.name)
return observed == expected_names
def remove(node, *, guard=guard):
directory_fd, _, children = node
for index, (entry_name, saved, child) in enumerate(children):
expected_names = {item[0] for item in children[index:]}
if (not guard() or not same_entries(directory_fd, expected_names) or
revision(os.fstat(directory_fd)) != node[1]):
raise ValueError('cleanup authority or growth')
current = os.stat(entry_name, dir_fd=directory_fd, follow_symlinks=False)
if revision(current) != revision(saved):
raise ValueError('cleanup replacement entry')
if child is not None:
remove(child)
if not guard() or inode(os.stat(entry_name, dir_fd=directory_fd, follow_symlinks=False)) != inode(saved):
raise ValueError('cleanup replacement directory')
os.rmdir(entry_name, dir_fd=directory_fd)
else:
os.unlink(entry_name, dir_fd=directory_fd)
node[1] = revision(os.fstat(directory_fd))
if (not guard() or not same_entries(directory_fd, set()) or
revision(os.fstat(directory_fd)) != node[1]):
raise ValueError('cleanup authority or growth')
remove(tree)
if guard():
os.rmdir(name, dir_fd=root_fd)
removed.append(name)
except (OSError, ValueError):
pass
finally:
for descriptor in reversed(pack_fds):
os.close(descriptor)
except (OSError, TypeError, ValueError):
pass
finally:
for descriptor in reversed(opened):
os.close(descriptor)
return tuple(removed)
def read_wav(path: Path) -> np.ndarray:
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_WAV_BYTES:
raise ValueError("invalid WAV file")
@@ -28,7 +28,7 @@ def test_default_builtin_assets_flow_through_soundd_callback(tmp_path):
assert BUILTIN_FILES[sound_list[AudibleAlert.warningImmediate][0]] == 'warning_3.wav'
@pytest.mark.parametrize('name', ['frog', 'Frog', 'frogpilot'])
@pytest.mark.parametrize('name', ['frog', 'Frog', 'frogpilot', 'stalin', 'STALIN'])
def test_removed_choice_migrates_without_loading_old_pack(tmp_path, name):
params = Params(str(tmp_path / 'params'))
params.put('SoundPack', name, block=True)
@@ -51,7 +51,7 @@ def test_explicit_user_choice_is_preserved(tmp_path, name):
def test_removed_and_builtin_packs_cannot_be_shadowed(tmp_path):
for name in ('starpilot', 'stock', 'frog', 'Frog', 'custom'):
for name in ('starpilot', 'stock', 'frog', 'Frog', 'stalin', 'STALIN', 'custom'):
(tmp_path / name / 'sounds').mkdir(parents=True, exist_ok=True)
assert installed_packs(tmp_path) == ('starpilot', 'stock', 'custom')
@@ -62,9 +62,9 @@ def test_builtin_labels_keep_persisted_sound_selection_stable(tmp_path):
params = Params(str(tmp_path / 'params'))
owner = SoundsOwner(params, lambda: True, tmp_path / 'packs')
row = owner.snapshot().rows[-1]
assert row.value == 'StarPilot (Built-in)'
assert row.choices == ('StarPilot (Built-in)', 'Stock (openpilot)')
assert owner.apply(FeatureSettingsRequest(key='SoundPack', expected=None, value='Stock (openpilot)'))
assert row.value == 'StarPilot Sounds'
assert row.choices == ('StarPilot Sounds', 'Stock Sounds')
assert owner.apply(FeatureSettingsRequest(key='SoundPack', expected=None, value='Stock Sounds'))
assert params.get('SoundPack') == 'stock'
assert owner.apply(FeatureSettingsRequest(key='SoundPack', expected=b'stock', value='StarPilot (Built-in)'))
assert owner.apply(FeatureSettingsRequest(key='SoundPack', expected=b'stock', value='StarPilot Sounds'))
assert params.get('SoundPack') == 'starpilot'
@@ -100,7 +100,7 @@ class SoundPackTests(unittest.TestCase):
parked = [True]
owner = SoundsOwner(self.params, lambda: parked[0], self.root)
row = owner.snapshot().rows[-1]
self.assertEqual(row.choices, ("StarPilot (Built-in)", "Stock (openpilot)", "custom"))
self.assertEqual(row.choices, ("StarPilot Sounds", "Stock Sounds", "custom"))
request = FeatureSettingsRequest("SoundPack", row.source, "custom")
self.assertTrue(owner.apply(request))
self.assertFalse(owner.apply(request))
@@ -0,0 +1,167 @@
'Stock alert mapping, playback, selection migration and bounded cleanup.'
from pathlib import Path
from unittest.mock import patch
import numpy as np
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.selfdrive.ui.soundd import Soundd, AudibleAlert, CRITICAL_MAX, sound_list
from openpilot.starpilot.audio.sound_pack import read_wav, installed_packs, read_selection, migrate_removed_selection
EXPECTED = {
AudibleAlert.engage: ('engage.wav', 1, 1.0),
AudibleAlert.disengage: ('disengage.wav', 1, 1.0),
AudibleAlert.refuse: ('refuse.wav', 1, 1.0),
AudibleAlert.prompt: ('warning.wav', 1, 1.0),
AudibleAlert.promptRepeat: ('warning.wav', None, 1.0),
AudibleAlert.promptDistracted: ('dm_warning.wav', None, 1.0),
AudibleAlert.preAlert: ('pre_alert.wav', 1, 1.0),
AudibleAlert.warningSoft: ('critical.wav', None, 1.0),
AudibleAlert.warningImmediate: ('dm_critical.wav', None, 1.0),
CRITICAL_MAX: ('dm_critical_max.wav', None, 1.0),
}
def test_stock_every_event_current_upstream_mapping_and_callback(tmp_path):
params = Params(str(tmp_path / 'params'))
params.put('SoundPack', 'stock', block=True)
sound = Soundd(params, pack_root=tmp_path / 'packs')
assert sound_list == EXPECTED
root = Path(BASEDIR) / 'openpilot/selfdrive/assets/sounds'
for alert, (filename, _loops, _gain) in EXPECTED.items():
expected = read_wav(root / filename)
np.testing.assert_array_equal(sound.loaded_sounds[alert], expected)
sound.current_alert, sound.current_sound, sound.current_sound_frame, sound.current_volume = (alert, alert, 0, 0.5)
output = np.zeros((len(expected), 1), dtype=np.float32)
with patch('pathlib.Path.open', side_effect=AssertionError('callback disk access')):
sound.callback(output, len(expected), None, None)
np.testing.assert_array_equal(output[:, 0], expected * 0.5)
assert params.get('SoundPack') == 'stock'
def test_removed_installed_selection_migrates_default_and_cannot_shadow(tmp_path):
params = Params(str(tmp_path / 'params'))
root = tmp_path / 'packs'
for name in ('stalin', 'STALIN'):
(root / name / 'sounds').mkdir(parents=True)
params.put('SoundPack', 'STALIN', block=True)
assert installed_packs(root) == ('starpilot', 'stock')
sound = Soundd(params, pack_root=root)
assert params.get('SoundPack') == 'starpilot'
assert read_selection(params)[1] == 'starpilot'
assert not migrate_removed_selection(params, root)
assert len(sound.pack_loader.builtin_sounds) == 5
def test_parked_cleanup_preserves_custom_renamed_symlink_and_missing(tmp_path):
from openpilot.starpilot.audio.sound_pack import cleanup_removed_packs
root = tmp_path / 'packs'
root.mkdir()
obsolete = root / 'stalin'
(obsolete / 'sounds').mkdir(parents=True)
(obsolete / 'sounds' / 'legacy.wav').write_bytes(b'obsolete')
custom = root / 'custom'
(custom / 'sounds').mkdir(parents=True)
(custom / 'sounds' / 'keep.wav').write_bytes(b'keep')
renamed = root / 'retained_user_pack'
renamed.mkdir()
(renamed / 'keep').write_bytes(b'keep')
(root / 'STALIN').symlink_to(custom, target_is_directory=True)
assert cleanup_removed_packs(root, parked=lambda: False) == ()
assert obsolete.exists()
assert set(cleanup_removed_packs(root, parked=lambda: True)) == {'stalin', 'STALIN'}
assert not obsolete.exists()
assert (custom / 'sounds' / 'keep.wav').read_bytes() == b'keep'
assert (renamed / 'keep').read_bytes() == b'keep'
assert not (root / 'STALIN').exists()
assert cleanup_removed_packs(root, parked=lambda: True) == ()
assert cleanup_removed_packs(tmp_path / 'missing', parked=lambda: True) == ()
def test_cleanup_parked_callback_failure_and_revocation(tmp_path):
from openpilot.starpilot.audio.sound_pack import cleanup_removed_packs
root = tmp_path / 'packs'
pack = root / 'stalin' / 'sounds'
pack.mkdir(parents=True)
(pack / 'keep.wav').write_bytes(b'old')
def failed():
raise RuntimeError('parked source unavailable')
assert cleanup_removed_packs(root, parked=failed) == ()
assert (pack / 'keep.wav').exists()
calls = [0]
def revoked():
calls[0] += 1
return calls[0] < 5
assert cleanup_removed_packs(root, parked=revoked) == ()
assert (pack / 'keep.wav').exists()
def test_cleanup_oversized_depth_and_growth_are_not_walked_or_deleted(tmp_path):
from openpilot.starpilot.audio.sound_pack import cleanup_removed_packs
root = tmp_path / 'packs'
pack = root / 'stalin'
pack.mkdir(parents=True)
for i in range(65):
(pack / f'{i}.wav').write_bytes(b'old')
assert cleanup_removed_packs(root, parked=lambda: True) == ()
assert len(tuple(pack.iterdir())) == 65
for path in pack.iterdir():
path.unlink()
deep = pack / 'a' / 'b' / 'c' / 'd'
deep.mkdir(parents=True)
(deep / 'old').write_bytes(b'old')
assert cleanup_removed_packs(root, parked=lambda: True) == ()
assert (deep / 'old').exists()
def test_cleanup_swap_before_delete_preserves_replacement_and_renamed_pack(tmp_path):
from openpilot.starpilot.audio.sound_pack import cleanup_removed_packs
root = tmp_path / 'packs'
pack = root / 'stalin'
pack.mkdir(parents=True)
(pack / 'old.wav').write_bytes(b'old')
custom = root / 'custom'
custom.mkdir()
(custom / 'keep').write_bytes(b'keep')
calls = [0]
def replace_pack():
calls[0] += 1
if calls[0] == 4:
pack.rename(root / 'renamed_old')
custom.rename(pack)
return True
assert cleanup_removed_packs(root, parked=replace_pack) == ()
assert (root / 'renamed_old' / 'old.wav').read_bytes() == b'old'
assert (pack / 'keep').read_bytes() == b'keep'
def test_cleanup_growth_before_delete_is_not_added_to_deletion_plan(tmp_path):
from openpilot.starpilot.audio.sound_pack import cleanup_removed_packs
root = tmp_path / 'packs'
pack = root / 'stalin'
pack.mkdir(parents=True)
(pack / 'old.wav').write_bytes(b'old')
calls = [0]
def growth():
calls[0] += 1
if calls[0] == 4:
(pack / 'new_user_file').write_bytes(b'keep')
return True
assert cleanup_removed_packs(root, parked=growth) == ()
assert (pack / 'old.wav').exists()
assert (pack / 'new_user_file').read_bytes() == b'keep'
def test_existing_download_owner_survives_parked_callback_failure(tmp_path):
import pytest
from openpilot.starpilot.audio.downloads import SoundDownloads, SoundDownloadError
def failed():
raise RuntimeError('parked source unavailable')
owner = SoundDownloads(failed, root=tmp_path / 'packs')
assert owner.snapshot()['parked'] is False
with pytest.raises(SoundDownloadError):
owner.action('install', {'id': 'duck'})
owner.close()
+52 -8
View File
@@ -6,7 +6,7 @@ from opendbc.car.structs import CarParams
from opendbc.car.gm.values import CAR, control_flags, camera_acc_pedal_profile, is_volt_ascm_longitudinal
from opendbc.car.gm.aol import GM_AOL_ALTERNATIVE_EXPERIENCE, GM_AOL_WORDS, qualified_gm
from openpilot.starpilot.aol.policy import AolVehiclePolicy
from openpilot.starpilot.aol.intent import AolCardIntent
from openpilot.starpilot.aol.intent import AOL_TOGGLE, AolCardIntent
def policy_for(cp) -> AolVehiclePolicy:
@@ -30,10 +30,13 @@ def native_accepts_cp(cp, model: int, param: int) -> bool:
class GmAolCardIntent(AolCardIntent):
"""Main-following intent with explicit main-cycle recovery after fatal faults."""
def __init__(self, settings):
def __init__(self, settings, cp=None):
super().__init__(settings, lkas_button_supported=False)
self.cp = cp
self.requires_fault_observation = True
self._main_cycle_required = False
self.startup_calibration_pending = True
self._startup_incomplete = False
def update(self, CS, *, consumed_buttons=frozenset(), fault_active=None,
now_ns=0, native_rejection_ns=0, standard_enabled=False):
@@ -47,10 +50,43 @@ class GmAolCardIntent(AolCardIntent):
standard_enabled=standard_enabled)
if self._main_cycle_required:
self.allowed_latch = False
self.startup_calibration_pending = False
def observe_calibration(self, CS, *, events=None, standard_enabled=False):
from openpilot.cereal import log
# Cold calibration readiness is not a previously revoked driver gesture.
# The original calibration inhibit still withdraws all optional output.
prior_incomplete = self._startup_incomplete
cold_safe = True
if events is not None:
from openpilot.starpilot.aol.intent import disarming_fault
remaining = [event for event in _intent_fault_events(self.cp, events) if
event.name != log.OnroadEvent.EventName.calibrationIncomplete or event.immediateDisable]
cold_safe = not disarming_fault(remaining, CS,
temporary_selfdrive_lagging=qualified_gm(self.cp))
self._startup_incomplete = bool(cold_safe and
any(event.name == log.OnroadEvent.EventName.calibrationIncomplete for event in events))
cold_readiness = bool(self.startup_calibration_pending and cold_safe and
(prior_incomplete or self._startup_incomplete) and
self.settings.enabled and self.settings.main_action != AOL_TOGGLE and
not CS.steerFaultPermanent and not self._main_cycle_required)
if cold_readiness:
self._calibration_rearm_required = False
super().observe_calibration(CS, events=events, standard_enabled=standard_enabled)
if cold_readiness:
self._calibration_rearm_required = False
if (events is not None and CS.canValid and not CS.canTimeout and not self._startup_incomplete and
not any(event.name == log.OnroadEvent.EventName.selfdriveInitializing for event in events)):
self.startup_calibration_pending = False
def output(self, CS):
allowed, pause_lateral, pause_longitudinal = super().output(CS)
return (bool(allowed and not CS.canTimeout and CS.cruiseState.available),
pause_lateral, pause_longitudinal)
def create_intent(cp, settings):
return GmAolCardIntent(settings) if qualified_gm(cp) else None
return GmAolCardIntent(settings, cp) if qualified_gm(cp) else None
def configuration_settings_policy(cp):
@@ -79,18 +115,26 @@ def configuration_settings_policy(cp):
return None
def intent_disarming_fault(cp, events, state, **context):
"""An ASCM Volt ACC fault withdraws axes without erasing main-armed intent."""
def _intent_fault_events(cp, events):
from openpilot.cereal import log
from openpilot.starpilot.aol.intent import disarming_fault
if qualified_gm(cp):
availability = (log.OnroadEvent.EventName.commIssue, log.OnroadEvent.EventName.commIssueAvgFreq)
# Availability withdraws current axes; only critical faults erase main-following intent.
events = [event for event in events if not
(event.name in availability and event.softDisable and not event.immediateDisable)]
if qualified_gm(cp) and camera_acc_pedal_profile(cp) is None and is_volt_ascm_longitudinal(cp):
events = [event for event in events if event.name != log.OnroadEvent.EventName.accFaulted]
return disarming_fault(events, state, **context)
return events
def intent_disarming_fault(cp, events, state, **context):
"""An ASCM Volt ACC fault withdraws axes without erasing main-armed intent."""
from openpilot.cereal import log
from openpilot.starpilot.aol.intent import disarming_fault
cold_calibration = context.pop('startup_calibration_pending', False)
if qualified_gm(cp) and cold_calibration and not state.steerFaultPermanent:
events = [event for event in events if not (
event.name == log.OnroadEvent.EventName.calibrationIncomplete and not event.immediateDisable)]
return disarming_fault(_intent_fault_events(cp, events), state, **context)
def physical_longitudinal_withdrawal_source(cp, cs, parser, observation, now_ns) -> int:
@@ -84,3 +84,27 @@ class TruckTuningPreference:
except (OSError, RuntimeError, TypeError, ValueError):
self.enabled = False
return self.enabled
class GradeCompensationPreference:
"""Worker-side live input selection; no CP/safety/tune/limiter mutation."""
def __init__(self, params):
self.params = params
self.read_ns = 0
self.enabled = True
def update(self, now_ns):
if type(now_ns) is not int or now_ns <= 0:
return self.enabled
if self.read_ns and 0 <= now_ns - self.read_ns < 1_000_000_000:
return self.enabled
self.read_ns = now_ns
try:
choice, readable = read_saved(self.params, 'LongPitch', 8)
safe, safe_readable = read_saved(self.params, 'SafeMode', 8)
master, master_readable = read_saved(self.params, 'OpenpilotEnabledToggle', 8)
self.enabled = not (readable and choice == b'0' and master_readable and master == b'1' and
safe_readable and safe in (None, b'0'))
except (OSError, RuntimeError, TypeError, ValueError):
self.enabled = True
return self.enabled
@@ -0,0 +1,17 @@
"""Toyota SecOC key decoding; never include credentials in diagnostics."""
import re
def decode_key(value):
if isinstance(value, bytes):
try:
value = value.decode('ascii')
except UnicodeDecodeError:
return None
if type(value) is not str:
return None
value = value.strip()
if re.fullmatch(r'[0-9a-fA-F]{32}', value) is None:
return None
return bytes.fromhex(value)
@@ -139,6 +139,15 @@ class ResumePlanInputs:
def configure_controller(CI, params):
cp = CI.CP
controller = CI.CC
if controller is not None and cp.brand == 'gm':
from opendbc.car.gm.values import (is_bolt_euv_longitudinal, is_volt_longitudinal,
camera_acc_pedal_profile, volt_cc_pedal_profile)
from opendbc.car.gm.suburban import stopping_decel_rate as suburban_stopping_decel_rate
pedal = camera_acc_pedal_profile(cp) or volt_cc_pedal_profile(cp)
if (is_bolt_euv_longitudinal(cp) or is_volt_longitudinal(cp) or suburban_stopping_decel_rate(cp) is not None or
pedal is not None and pedal.longitudinal):
from openpilot.starpilot.car.gm.tune_preferences import GradeCompensationPreference
controller.long_pitch_input = GradeCompensationPreference(params)
from opendbc.car.gm.truck_longitudinal import truck_tuning_supported
if controller is not None and truck_tuning_supported(cp):
from openpilot.starpilot.car.gm.tune_preferences import TruckTuningPreference
+19 -5
View File
@@ -60,6 +60,7 @@ class Result:
training: bool
progress: float
dirty_revision: int | None
learning_reason: str = ''
class Runtime:
@@ -329,25 +330,38 @@ class Runtime:
frame.ego_mps > CRUISING_SPEED and frame.tracking_lead is False and not frame.blinker and
now - self.last_lead_ns >= LEAD_CLEAR_NS and
now >= self.quiet_until_ns and finite_number(measured_accel) and
in_curve and not controlling)
not controlling)
learned = False
if trainable:
if not self.last_training_ns:
self.last_training_ns = now
if now - self.last_training_ns >= TRAINING_SETTLE_NS:
if now - self.last_training_ns >= TRAINING_SETTLE_NS and in_curve:
self.curve.observe(abs(frame.measured_curvature), measured_accel)
learned = True
else:
self.last_training_ns = 0
learning_reason = ('learning_disabled' if not self.enabled else
'invalid_learning_document' if not self.document_valid else
'learning_requires_system_longitudinal' if not frame.system_longitudinal else
'learning_requires_driver_speed' if not manual else
'learning_requires_cruising_speed' if frame.ego_mps <= CRUISING_SPEED else
'learning_lead_unknown' if frame.tracking_lead is None else
'learning_following_lead' if frame.tracking_lead else
'learning_lead_clear_wait' if now - self.last_lead_ns < LEAD_CLEAR_NS else
'learning_indicator_active' if frame.blinker else
'learning_feedback_quiet_wait' if now < self.quiet_until_ns else
'learning_controller_active' if controlling else
'learning_settling' if now - self.last_training_ns < TRAINING_SETTLE_NS else
'learning' if learned else 'learning_waiting_for_curve')
self.was_long_active = frame.long_active
self.was_gas = frame.gas_pressed
self.was_brake = frame.brake_pressed
reason = ('available' if controlling else 'not_binding' if allowed else
'invalid_learning_document' if not self.document_valid else 'not_eligible')
return self._result(candidate, ceiling, envelope.binding_distance_m, reason, learned, now)
return self._result(candidate, ceiling, envelope.binding_distance_m, reason, learned, now, learning_reason)
def _result(self, candidate: float | None, ceiling: float | None, distance: float,
reason: str, training: bool, now_ns: int) -> Result:
reason: str, training: bool, now_ns: int, learning_reason: str | None = None) -> Result:
revision = (self.curve.revision if self.document_valid and self.curve.dirty and
(not training or now_ns - self.last_persist_ns >= PERSIST_INTERVAL_NS) else None)
return Result(candidate, ceiling, distance, reason, training, self.curve.progress, revision)
return Result(candidate, ceiling, distance, reason, training, self.curve.progress, revision, learning_reason or reason)
+2 -1
View File
@@ -53,7 +53,8 @@ class StatusPublisher:
'applied': bool(planner.last_curve_ceiling_applied), 'controlling': bool(runtime.was_controlling),
'training': bool(result.training), 'calibrationProgress': float(result.progress),
'comfortAccel': float(runtime.curve.average_comfort), 'bindingDistance': float(result.binding_distance_m),
'reason': result.reason, 'plannerStatus': planner.last_curve_ceiling_status,
'reason': result.reason if result.ceiling_mps is not None else result.learning_reason,
'plannerStatus': planner.last_curve_ceiling_status,
'persistenceStatus': persistence_status, 'glow': bool(runtime.glow), 'curveOnly': curve_only,
'hasRoadCurvature': road_curvature is not None, 'roadCurvature': float(road_curvature or 0.0),
}
@@ -0,0 +1,206 @@
"""Bounded real planner/host/Params learning path; no vehicle or live IPC."""
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from opendbc.car.honda.interface import CarInterface
from opendbc.car.honda.values import CAR
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
from openpilot.selfdrive.controls.plannerd import update_curve_frame
from openpilot.selfdrive.controls.tests.test_curve_runtime_loop import bus, NOW
from openpilot.starpilot.curve_speed.actions import apply_learning, learning_snapshot
from openpilot.starpilot.curve_speed.host import CurveHost
from openpilot.starpilot.curve_speed.learning import CURVATURES, NUDGE_WEIGHT
from openpilot.starpilot.curve_speed.preferences import DOCUMENT_KEY, LEGACY_KEY, MASTER_KEY, PreferenceHost
from openpilot.starpilot.curve_speed.status import StatusPublisher, observation
def vehicle():
cp = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
cp.pcmCruise = False
return cp
def inputs(tick, *, curve=False, manual=True, exclusion=None):
stamp = NOW + tick * 50_000_000
sm = bus(stamp, lead_present=exclusion == 'lead')
for name in ('modelV2', 'carState', 'carControl', 'controlsState'):
sm.messages[name] = sm.messages[name].as_builder()
sm['modelV2'].orientationRate.z = [.1 if curve else 0.] * 33
sm['carControl'].longActive = not manual
sm['controlsState'].longControlState = LongCtrlState.off if manual else LongCtrlState.pid
sm['controlsState'].curvature = .004 if curve else 0.
sm['carState'].leftBlinker = exclusion == 'indicator'
if exclusion == 'stale':
sm.logMonoTime['modelV2'] = stamp - 1_000_000_000
if exclusion == 'unknown_lead':
sm.valid['radarState'] = False
return sm, stamp
def drive(planner, host, cp, start, stop, **kwargs):
result = None
for tick in range(start, stop):
sm, stamp = inputs(tick, **kwargs)
result = update_curve_frame(planner, sm, cp, stamp, host=host)
if kwargs.get('manual', True):
assert not planner.last_curve_ceiling_applied
assert result.ceiling_mps is None
return result, sm, stamp
def test_actual_planner_settles_on_straights_then_learns_short_curve():
cp = vehicle()
host = CurveHost(enabled=True, replay=True)
planner = LongitudinalPlanner(cp, init_v=25.)
result, _, _ = drive(planner, host, cp, 1, 81)
assert not result.training and host.runtime.curve.revision == 0
straight_reason = getattr(result, 'learning_reason', None)
result, sm, stamp = drive(planner, host, cp, 81, 101, curve=True)
assert result.training and result.progress > .1
assert host.runtime.curve.revision == 20
assert straight_reason == 'learning_waiting_for_curve'
assert sum(row['count'] for row in host.runtime.curve.document()['buckets'].values()) == 20
packet = StatusPublisher().attach(None, host, result, planner, now_ns=stamp, model_ns=stamp,
persistence_status='writing')
status = observation(messaging.log_from_bytes(packet.to_bytes()).slcState, stamp)
assert status is not None and status.training and status.progress == pytest.approx(result.progress)
assert status.reason == 'learning' and not status.applied
@pytest.mark.parametrize('exclusion', ['lead', 'unknown_lead', 'stale', 'indicator', 'automatic', 'disabled'])
def test_unqualified_straight_time_never_settles_learning(exclusion):
cp = vehicle()
host = CurveHost(enabled=exclusion != 'disabled', replay=True)
planner = LongitudinalPlanner(cp, init_v=25.)
drive(planner, host, cp, 1, 81, manual=exclusion != 'automatic', exclusion=exclusion)
host.runtime.enabled = True
result, _, _ = drive(planner, host, cp, 81, 101, curve=True)
assert host.runtime.curve.revision == 0 and not result.training
assert result.learning_reason in ('learning_lead_clear_wait', 'learning_settling', 'learning_feedback_quiet_wait')
def test_actual_curve_longitudinal_drop_is_feedback_not_passive_settling():
cp = vehicle()
host = CurveHost(enabled=True, replay=True)
planner = LongitudinalPlanner(cp, init_v=25.)
result, _, _ = drive(planner, host, cp, 1, 81, manual=False, curve=True)
assert host.runtime.was_controlling and planner.last_curve_ceiling_applied
assert host.runtime.curve.revision == 0 and not result.training
# The real driver transition out of a selected in-curve controller is
# qualified feedback. One weighted nudge does not establish passive settling.
result, _, _ = drive(planner, host, cp, 81, 101, curve=True)
assert host.runtime.curve.revision == 1 and not result.training
assert sum(row['count'] for row in host.runtime.curve.document()['buckets'].values()) == NUDGE_WEIGHT
assert result.learning_reason == 'learning_feedback_quiet_wait'
def test_saved_learning_roundtrip_galaxy_progress_automatic_use_and_reset(tmp_path):
params = Params(str(tmp_path))
params.put_bool(MASTER_KEY, True, block=True)
legacy = b'{"0.001":{"average":2.2,"count":1}}'
Path(params.get_param_path(LEGACY_KEY)).write_bytes(legacy)
cp = vehicle()
owner = PreferenceHost(params)
try:
host = owner.make_host(replay=True)
owner.refresh(host, NOW)
planner = LongitudinalPlanner(cp, init_v=25.)
drive(planner, host, cp, 1, 81)
result, _, stamp = drive(planner, host, cp, 81, 101, curve=True)
result, _, stamp = drive(planner, host, cp, 101, 102)
assert result.dirty_revision is not None
owner.persist(host, result, stamp)
assert owner.worker is not None
owner.worker.join(timeout=2.)
assert not owner.worker.is_alive()
owner.persist(host, SimpleNamespace(dirty_revision=None), stamp + 50_000_000)
assert owner.status == 'saved' and not host.runtime.curve.dirty
saved = learning_snapshot(params)
# This is the SAME canonical progress source consumed by the Galaxy row.
assert saved.progress == pytest.approx(host.runtime.curve.progress) and saved.progress > .1
document = json.loads(Path(params.get_param_path(DOCUMENT_KEY)).read_bytes())
assert document == host.runtime.curve.document()
assert Path(params.get_param_path(LEGACY_KEY)).read_bytes() == legacy
finally:
owner.close()
restored_owner = PreferenceHost(params)
try:
restored = restored_owner.make_host(replay=True)
restored_owner.refresh(restored, NOW + 10_000_000_000)
assert restored.runtime.enabled
assert restored.runtime.curve.document() == document # No adopt action.
default = CurveHost(enabled=True, replay=True)
restored_planner = LongitudinalPlanner(cp, init_v=25.)
default_planner = LongitudinalPlanner(cp, init_v=25.)
# Run the unchanged approach filter through its ego+2m/s initialization;
# a selected future ceiling need not yet control a slower current vehicle.
for tick in range(220, 250):
sm, stamp = inputs(tick, curve=True, manual=False)
actual = update_curve_frame(restored_planner, sm, cp, stamp, host=restored)
baseline = update_curve_frame(default_planner, sm, cp, stamp, host=default)
assert actual.candidate_mps > baseline.candidate_mps
assert actual.ceiling_mps is not None
assert restored_planner.last_curve_ceiling_applied
assert restored.runtime.was_controlling
finally:
restored_owner.close()
saved = learning_snapshot(params)
assert apply_learning(params, 'curve_reset', saved.sources, authorized=lambda: True).committed
assert learning_snapshot(params).progress == 0.
assert params.get_bool(MASTER_KEY)
assert Path(params.get_param_path(LEGACY_KEY)).read_bytes() == legacy
reset_owner = PreferenceHost(params)
try:
reset = reset_owner.make_host(replay=True)
reset_owner.refresh(reset, NOW + 15_000_000_000)
assert reset.runtime.curve.document()['buckets'] == {} # Legacy cannot revive.
reset_planner = LongitudinalPlanner(cp, init_v=25.)
pristine = CurveHost(enabled=True, replay=True)
pristine_planner = LongitudinalPlanner(cp, init_v=25.)
for tick in range(320, 350):
sm, stamp = inputs(tick, curve=True, manual=False)
actual = update_curve_frame(reset_planner, sm, cp, stamp, host=reset)
baseline = update_curve_frame(pristine_planner, sm, cp, stamp, host=pristine)
assert actual.candidate_mps == baseline.candidate_mps
assert reset_planner.last_curve_ceiling_applied
finally:
reset_owner.close()
def test_actual_host_24_by_200_progress_is_coverage_and_saturates():
cp = vehicle()
host = CurveHost(enabled=True, replay=True)
# Settle via the actual host/lead projection, without recording straights.
for tick in range(1, 81):
sm, stamp = inputs(tick)
host.sample(sm, cp, stamp)
tick = 81
for bucket, curvature in enumerate(CURVATURES):
for _ in range(200):
sm, stamp = inputs(tick, curve=True)
# 55m/s makes even the lowest bucket exceed the unchanged1.3m/s²
# observation gate. Manual speed means no ceiling/control authority.
sm['carState'].vEgo = 55.
sm['carState'].vCruise = 198.
sm['controlsState'].curvature = curvature
sm['modelV2'].velocity.x = [55.] * 33
sm['modelV2'].orientationRate.z = [55. * curvature] * 33
result = host.sample(sm, cp, stamp)
assert result.training and result.ceiling_mps is None
tick += 1
assert result.progress == pytest.approx(100. * (bucket + 1) / 24)
document = host.runtime.curve.document()['buckets']
assert len(document) == 24 and all(row['count'] == 200 for row in document.values())
assert host.runtime.curve.revision == 4800 and result.progress == 100.
sm, stamp = inputs(tick, curve=True)
result = host.sample(sm, cp, stamp)
assert result.training and result.progress == 100.
+6 -5
View File
@@ -21,12 +21,14 @@ class DriveStateControl:
now = self.clock()
if self.cached is None or not 0 <= now - self.cached[0] < 0.5:
state = self.owner.snapshot()
self.cached = now, state, self.physical.allowed()
status = getattr(self.physical, 'status', None)
admission = status() if callable(status) else {'allowed': self.physical.allowed(), 'reason': None}
self.cached = now, state, bool(admission['allowed']), admission['reason']
state = self.cached[1]
actual = self.effective()
return {'mode': state.mode.value, 'revision': state.revision, 'available': state.available,
'effective': 'onroad' if actual is True else 'offroad' if actual is False else None,
'overrideAllowed': self.cached[2]}
'overrideAllowed': self.cached[2], 'reason': self.cached[3]}
def change(self, mode, revision, authorized):
result = self.owner.request(mode, expected_revision=revision, authorized=authorized,
@@ -38,9 +40,8 @@ class DriveStateControl:
@staticmethod
def next_mode(status):
mode = Mode(status['mode'])
if mode != Mode.AUTO and not status['overrideAllowed']:
return Mode.AUTO
return CYCLE[(CYCLE.index(mode) + 1) % len(CYCLE)]
desired = CYCLE[(CYCLE.index(mode) + 1) % len(CYCLE)]
return Mode.ONROAD if desired == Mode.OFFROAD and not status['overrideAllowed'] else desired
def cycle(self, status, authorized):
return self.change(self.next_mode(status).value, status['revision'], authorized)
+40 -11
View File
@@ -20,9 +20,14 @@ class PhysicalSource:
self.closed = False
def allowed(self) -> bool:
return self.status()['allowed']
def status(self):
def result(allowed, reason=None):
return {'allowed': bool(allowed), 'reason': reason}
with self.lock:
if self.closed:
return False
return result(False, 'Vehicle status source is closed')
try:
if self.messages is None:
from openpilot.cereal import messaging
@@ -31,14 +36,14 @@ class PhysicalSource:
snapshot = getattr(self.messages, "snapshot", None)
sm = snapshot() if callable(snapshot) else self.messages
if sm is None:
return False
return result(False, 'Current vehicle status is unavailable')
self.floor = max(self.floor, getattr(sm, "after_mono_ns", self.floor))
if self.owns_messages:
sm.update(0)
before, boot, now = self.mono(), self.boot(), self.mono()
if not 0 <= now - before <= RESUME_SKEW_NS:
self.floor, self.offset = now, None
return False
return result(False, 'Device clock changed; fresh vehicle status is required')
offset = boot - (before + now) // 2
if self.offset is not None and abs(offset - self.offset) > RESUME_SKEW_NS:
self.floor = now
@@ -56,20 +61,44 @@ class PhysicalSource:
return stamp > 0 and 0 <= age <= ttl and 0 <= now - receipt <= ttl
if not fresh('pandaStates'):
return False
return result(False, 'Current Panda status is missing, invalid or older than 300 ms')
pandas = sm['pandaStates']
if len(pandas) == 0 or any(str(p.pandaType) == 'unknown' for p in pandas):
return False
return result(False, 'No recognized Panda is reporting vehicle status')
ignition = any(p.ignitionLine or p.ignitionCan for p in pandas)
if not ignition:
return all(str(p.safetyModel) == 'noOutput' and not p.controlsAllowed for p in pandas)
if not fresh('carState', True) or not fresh('selfdriveState', True):
return False
safe = all(str(p.safetyModel) == 'noOutput' and not p.controlsAllowed for p in pandas)
return result(safe, None if safe else 'Panda must confirm noOutput safety and controls disabled')
if not fresh('carState', True):
return result(False, 'Current speed and gear are unavailable; turn off the vehicle ignition to confirm parked state')
if not fresh('selfdriveState', True):
return result(False, 'Driving services cannot confirm disengagement; turn off the vehicle ignition to confirm parked state')
car, selfdrive = sm['carState'], sm['selfdriveState']
return (car.canValid and not car.canTimeout and car.standstill and str(car.gearShifter) == 'park' and
not selfdrive.enabled and not selfdrive.active)
if not car.canValid or car.canTimeout:
return result(False, 'Vehicle CAN data is invalid or timed out')
if not car.standstill or str(car.gearShifter) != 'park':
return result(False, 'Park the vehicle before forcing Offroad or updating')
if selfdrive.enabled or selfdrive.active:
return result(False, 'Disengage driving controls before forcing Offroad or updating')
return result(True)
except (OSError, RuntimeError, AttributeError, KeyError, TypeError, ValueError, OverflowError):
return False
return result(False, 'Vehicle status could not be read')
def update_status(self, manager_offroad):
if manager_offroad is not True:
return {'allowed': False, 'reason': 'Switch the device Offroad before updating'}
physical = self.status()
if not physical['allowed']:
return physical
actual = self.effective()
if actual is None:
return {'allowed': False, 'reason': 'Current device offroad status is missing or older than 1.5 seconds'}
if actual:
return {'allowed': False, 'reason': 'Manager is still running driving services; wait for the Offroad transition'}
return {'allowed': True, 'reason': None}
def update_ready(self, manager_offroad):
return self.update_status(manager_offroad)['allowed']
def effective(self):
with self.lock:
+4 -4
View File
@@ -139,19 +139,19 @@ class DriveStateOwner:
raise Rejected('Drive state changed; refresh and try again')
if not authorized():
raise Rejected('Drive state changes are unavailable')
if desired == Mode.OFFROAD and not override_allowed():
raise Rejected('Park the vehicle and disengage before forcing Offroad')
if desired == current.mode:
return current
if desired == Mode.ONROAD and not override_allowed():
raise Rejected('Park and disengage before changing drive state')
# These callbacks recheck the authenticated caller and physical source,
# rather than relying on the state that produced the button.
if not authorized() or (desired == Mode.ONROAD and not override_allowed()) or not self.alive(session):
if not authorized() or (desired == Mode.OFFROAD and not override_allowed()) or not self.alive(session):
raise Rejected('Drive state changes are unavailable')
observed_session, observed = self._read()
if observed_session != session or observed != current:
raise Rejected('Drive state changed; refresh and try again')
record = self._record(session, desired)
if not authorized() or (desired == Mode.ONROAD and not override_allowed()):
if not authorized() or (desired == Mode.OFFROAD and not override_allowed()):
raise Rejected('Drive state changes are unavailable')
self.params.put(REQUEST_KEY, record, block=True)
return request_state(record, session)
@@ -11,6 +11,8 @@ import types
import pytest
from openpilot.starpilot.drive_state.owner import Mode, Rejected
from openpilot.starpilot.drive_state.evidence import PhysicalSource
from openpilot.starpilot.drive_state.tests.test_evidence import Messages
from openpilot.starpilot.drive_state.tests.test_transport import fixture as fixture
@@ -52,9 +54,19 @@ def test_force_offroad_uses_profile_dialog_and_submits_once(fixture, profile):
assert fixture.owner.snapshot().mode == Mode.AUTO
native._drive_change('offroad', revision)
dialog = gui.push_widget.call_args.args[0]
writes = len(fixture.owner.params.writes)
dialog.confirm()
assert fixture.owner.snapshot().mode == Mode.OFFROAD
assert len(fixture.owner.params.writes) == writes + 1
dialog.confirm()
assert len(fixture.owner.params.writes) == writes + 1
assert fixture.owner.snapshot().mode == Mode.AUTO
now = [1_000_000_000]
physical = PhysicalSource(Messages(), mono=lambda: now[0], boot=lambda: now[0] + 10_000_000_000)
now[0] = 2_100_000_000
try:
assert physical.allowed()
fixture.control.physical = physical
writes = len(fixture.owner.params.writes)
dialog.confirm()
assert fixture.owner.snapshot().mode == Mode.OFFROAD
assert len(fixture.owner.params.writes) == writes + 1
dialog.confirm()
assert len(fixture.owner.params.writes) == writes + 1
finally:
physical.close()
@@ -0,0 +1,144 @@
from types import SimpleNamespace as NS
from unittest.mock import Mock
import pytest
from openpilot.starpilot.drive_state.control import DriveStateControl
from openpilot.starpilot.drive_state.owner import DriveStateOwner, Mode, Rejected
from openpilot.starpilot.drive_state.tests.test_owner import Params, BOOT
from openpilot.starpilot.drive_state.evidence import PhysicalSource
from openpilot.starpilot.drive_state.tests.test_evidence import Messages
@pytest.fixture
def fixture():
now = [1_000_000_000, 10_000_000_000]
messages = Messages()
source = PhysicalSource(messages, mono=lambda: now[0], boot=lambda: sum(now))
now[0] = 2_100_000_000
try:
yield NS(now=now, sm=messages, source=source)
finally:
source.close()
def test_force_onroad_never_uses_park_admission(tmp_path):
params = Params()
owner = DriveStateOwner(params, tmp_path / 'owner', alive=lambda _: True)
owner.initialize(pid=1, birth=2, boot=BOOT)
physical = Mock(side_effect=AssertionError('Onroad does not require Park'))
state = owner.request('onroad', expected_revision=owner.snapshot().revision,
authorized=lambda: True, override_allowed=physical)
assert state.mode == Mode.ONROAD
physical.assert_not_called()
def test_force_offroad_rechecks_actual_park_before_commit(tmp_path):
params = Params()
owner = DriveStateOwner(params, tmp_path / 'owner', alive=lambda _: True)
owner.initialize(pid=1, birth=2, boot=BOOT)
baseline = list(params.writes)
with pytest.raises(Rejected, match='Park'):
owner.request('offroad', expected_revision=owner.snapshot().revision,
authorized=lambda: True, override_allowed=lambda: False)
assert params.writes == baseline
values = iter((True, False))
with pytest.raises(Rejected):
owner.request('offroad', expected_revision=owner.snapshot().revision,
authorized=lambda: True, override_allowed=lambda: next(values))
assert params.writes == baseline
def test_requested_mode_never_claims_actual_update_authority(tmp_path):
params = Params()
owner = DriveStateOwner(params, tmp_path / 'owner', alive=lambda _: True)
owner.initialize(pid=1, birth=2, boot=BOOT)
physical = NS(allowed=lambda: False)
control = DriveStateControl(owner, physical, effective=lambda: False)
state = control.snapshot()
assert not state['overrideAllowed']
assert control.next_mode(state) == Mode.ONROAD
control.change('onroad', state['revision'], lambda: True)
assert control.snapshot()['mode'] == 'onroad'
assert control.snapshot()['effective'] == 'offroad'
def test_normal_garage_flow_current_nooutput_and_manager_offroad(fixture):
f = fixture
f.sm.values['deviceState'] = NS(started=False)
for table, value in ((f.sm.seen, True), (f.sm.alive, True), (f.sm.valid, True),
(f.sm.logMonoTime, 2_000_000_000), (f.sm.recv_time, 2.0)):
table['deviceState'] = value
assert f.source.allowed()
assert f.source.update_ready(True)
assert not f.source.update_ready(False)
f.sm.values['deviceState'].started = True
assert not f.source.update_ready(True)
f.sm.values['deviceState'].started = False
f.sm.values['pandaStates'][0].controlsAllowed = True
assert not f.source.update_ready(True)
def test_forced_offroad_does_not_borrow_old_park_while_ignition_on(fixture):
f = fixture
f.sm.values['pandaStates'][0].ignitionLine = True
f.sm.values['deviceState'] = NS(started=False)
f.sm.logMonoTime['carState'] = 1_000_000_000
status = f.source.update_status(True)
assert not status['allowed']
assert 'ignition' in status['reason']
f.sm.values['pandaStates'][0].ignitionLine = False
for table, value in ((f.sm.seen, True), (f.sm.alive, True), (f.sm.valid, True),
(f.sm.logMonoTime, 2_000_000_000), (f.sm.recv_time, 2.0)):
table['deviceState'] = value
assert f.source.update_ready(True)
@pytest.mark.parametrize('fault', ['moving', 'drive_gear', 'can_invalid', 'enabled', 'active'])
def test_live_park_guards_remain_authoritative(fixture, fault):
f = fixture
f.sm.values['pandaStates'][0].ignitionLine = True
if fault == 'moving':
f.sm.values['carState'].standstill = False
elif fault == 'drive_gear':
f.sm.values['carState'].gearShifter = 'drive'
elif fault == 'can_invalid':
f.sm.values['carState'].canValid = False
else:
setattr(f.sm.values['selfdriveState'], fault, True)
assert not f.source.allowed()
def test_authenticated_remote_force_drive_modes_use_shared_owner():
from contextlib import ExitStack
from openpilot.starpilot.drive_state.tests.test_http import ForceHttpTest
case = ForceHttpTest(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
case.setUp()
case.allowed = False
result = case.drive_action('onroad', remote=True)
assert result[0] == 200 and result[1]['mode'] == 'onroad'
assert result[1]['effective'] == 'offroad'
assert case.drive_action('offroad', remote=True)[0] == 409
assert case.drive.snapshot().mode == Mode.ONROAD
case.allowed = True
result = case.drive_action('offroad', remote=True)
assert result[0] == 200 and result[1]['mode'] == 'offroad'
def test_update_snapshot_keeps_one_admission_and_matching_reason():
from contextlib import ExitStack
from openpilot.starpilot.galaxy.tests.test_software_operations import SoftwareOperationsTest
case = SoftwareOperationsTest(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
case.setUp()
context = NS(update_status=Mock(side_effect=[{'allowed': False, 'reason': 'Current Panda status is stale'},
{'allowed': True, 'reason': None}]), close=Mock())
case.owner.context = context
case.owner.parked = lambda: context.update_status(True)['allowed']
view = case.owner.snapshot(include_history=False)
assert not view['parked'] and not view['canFastUpdate'] and not view['canInstall']
assert view['reason'] == 'Current Panda status is stale'
context.update_status.assert_called_once_with(True)
@@ -44,11 +44,13 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.assertEqual(self.drive_action('offroad', remote=True)[0], 200)
self.assertEqual(self.drive_action('auto', revision=result[1]['revision'])[0], 409)
def test_physical_loss_blocks_override_but_auto_and_same_request_remain_recoverable(self):
def test_physical_loss_blocks_offroad_but_onroad_and_auto_remain_recoverable(self):
self.assertEqual(self.drive_action('offroad')[0], 200)
self.allowed = False
self.assertEqual(self.drive_action('onroad')[0], 409)
self.assertEqual(self.drive.snapshot().mode, Mode.OFFROAD)
self.assertEqual(self.drive_action('onroad')[0], 200)
self.assertEqual(self.drive.snapshot().mode, Mode.ONROAD)
self.assertEqual(self.drive_action('offroad')[0], 409)
self.assertEqual(self.drive.snapshot().mode, Mode.ONROAD)
self.assertEqual(self.drive_action('auto', remote=True)[0], 200)
self.assertEqual(self.drive.snapshot().mode, Mode.AUTO)
@@ -67,7 +69,7 @@ class ForceHttpTest(helpers.NavigationHttpTest):
return True
self.physical.allowed = revoke
self.assertEqual(self.drive_action('onroad', remote=True)[0], 409)
self.assertEqual(self.drive_action('offroad', remote=True)[0], 409)
self.assertEqual(self.params.writes, before)
def test_authenticated_force_offroad_from_live_onroad_state(self):
@@ -96,10 +98,15 @@ class ForceHttpTest(helpers.NavigationHttpTest):
self.assertEqual(status[1]['effective'], 'onroad')
self.assertEqual(status[1]['overrideAllowed'], can_valid)
result = self.drive_action('offroad', remote=True)
self.assertEqual(result[0], 200)
self.assertEqual(result[1]['mode'], 'offroad')
self.assertEqual(result[0], 200 if can_valid else 409)
if can_valid:
self.assertEqual(result[1]['mode'], 'offroad')
else:
self.assertEqual(self.drive.snapshot().mode, Mode.AUTO)
self.assertIn('Park', result[1]['error'])
# hardwared consumes this same owner request through the actual resolver.
messages.values['deviceState'].started = should_start(self.drive.snapshot().mode,
{'ignition': True}, {'ready': True}, already_started=True)
self.assertFalse(messages.values['deviceState'].started)
self.assertEqual(self.request('/api/drive-state/status', remote=True, cookie=self.remote_cookie)[1]['effective'], 'offroad')
self.assertEqual(messages.values['deviceState'].started, not can_valid)
self.assertEqual(self.request('/api/drive-state/status', remote=True, cookie=self.remote_cookie)[1]['effective'],
'offroad' if can_valid else 'onroad')
@@ -56,8 +56,8 @@ def test_shared_owner_idempotence_atomic_mode_and_manager_reset(fixture):
def test_only_deliberate_override_requires_physical_admission(fixture):
f = fixture
with pytest.raises(Rejected):
request(f, 'onroad', override_allowed=lambda: False)
request(f, 'offroad')
request(f, 'offroad', override_allowed=lambda: False)
request(f, 'onroad')
assert request(f, 'auto', override_allowed=lambda: False).mode == Mode.AUTO
assert not any(key in f.params.values for key in ('ForceOffroad', 'ForceOnroad'))
@@ -75,7 +75,7 @@ def test_revocation_before_commit_never_writes_request(fixture, change):
return calls[0] < 2
with pytest.raises(Rejected):
request(f, 'onroad', authorized=checked if change == 'permission' else lambda: True,
request(f, 'offroad', authorized=checked if change == 'permission' else lambda: True,
override_allowed=checked if change in ('physical', 'manager') else lambda: True)
assert len(f.params.writes) == writes
@@ -278,11 +278,11 @@ def test_empty_real_native_registry_resolves_auto_and_unsupported_key_is_explici
assert DriveStateOwner(Unsupported(), tmp_path / 'unsupported').snapshot() == State()
def test_force_offroad_withdraws_authority_without_healthy_can(fixture):
def test_force_onroad_requires_no_park_proof_but_offroad_does(fixture):
f = fixture
assert request(f, 'offroad', override_allowed=lambda: False).mode == Mode.OFFROAD
assert request(f, 'onroad', override_allowed=lambda: False).mode == Mode.ONROAD
with pytest.raises(Rejected):
request(f, 'onroad', override_allowed=lambda: False)
request(f, 'offroad', override_allowed=lambda: False)
with pytest.raises(Rejected):
request(f, 'auto', authorized=lambda: False, override_allowed=lambda: False)
assert f.owner.snapshot().mode == Mode.OFFROAD
assert f.owner.snapshot().mode == Mode.ONROAD
@@ -46,8 +46,10 @@ def test_small_cycle_and_expired_physical_source_explicit_auto_recovery(fixture)
f.physical.allowed.return_value = False
f.now[0] += 0.5
status = f.control.snapshot()
assert status['mode'] == 'offroad' and f.control.next_mode(status) == Mode.AUTO
assert status['mode'] == 'offroad' and f.control.next_mode(status) == Mode.ONROAD
f.control.cycle(status, lambda: True)
assert f.control.snapshot()['mode'] == 'onroad'
f.control.cycle(f.control.snapshot(), lambda: True)
assert f.control.snapshot()['mode'] == 'auto'
@@ -56,7 +58,7 @@ def test_displayed_admission_is_not_reused_for_action_and_auth_revocation(fixtur
status = f.control.snapshot()
f.physical.allowed.return_value = False
with pytest.raises(Rejected):
f.control.change('onroad', status['revision'], lambda: True)
f.control.change('offroad', status['revision'], lambda: True)
assert f.owner.snapshot().mode == Mode.AUTO
f.physical.allowed.return_value = True
with pytest.raises(Rejected):
@@ -184,8 +186,10 @@ def test_actual_manager_process_predicate_stops_park_producers_and_auto_recovers
messages.logMonoTime['pandaStates'] = now[0] + 10_000_000_000
messages.recv_time['pandaStates'] = now[0] / 1e9
status = control.snapshot()
assert not status['overrideAllowed'] and control.next_mode(status) == Mode.AUTO
assert not status['overrideAllowed'] and control.next_mode(status) == Mode.ONROAD
control.cycle(status, lambda: True)
assert fixture.owner.snapshot().mode == Mode.ONROAD
control.change('auto', fixture.owner.snapshot().revision, lambda: True)
assert fixture.owner.snapshot().mode == Mode.AUTO
@@ -229,7 +233,7 @@ def test_concurrent_old_snapshot_cannot_repopulate_cache_after_acknowledged_chan
assert f.control.snapshot()['mode'] == 'onroad'
def test_native_offroad_confirmation_preserves_revision_without_requiring_healthy_can(fixture):
def test_native_offroad_confirmation_preserves_revision_and_rechecks_park(fixture):
from unittest.mock import patch
from openpilot.starpilot.ui.runtime_app import StarShellSession
from openpilot.starpilot.ui.shell import ShellMode
@@ -266,4 +270,8 @@ def test_native_offroad_confirmation_preserves_revision_without_requiring_health
native._drive_change('offroad', revision)
f.physical.allowed.return_value = False
push.call_args.args[0]._callback(DialogResult.CONFIRM)
assert f.owner.snapshot().mode == Mode.AUTO
f.physical.allowed.return_value = True
native._drive_change('offroad', revision)
push.call_args.args[0]._callback(DialogResult.CONFIRM)
assert f.owner.snapshot().mode == Mode.OFFROAD
+122 -9
View File
@@ -1,4 +1,4 @@
"""Bounded, read-only access to reports written by the current tombstoned service."""
"""Bounded access to reports written by the current tombstoned service."""
from __future__ import annotations
@@ -6,10 +6,13 @@ import base64
import binascii
import json
import os
import re
from pathlib import Path
import stat
from openpilot.common.hardware.hw import Paths
from openpilot.system.crash_storage import acquire_lock, open_directory
from openpilot.starpilot.stored_artifacts import DeletionLimit, remove_entry, usage
MAX_SCAN = 1000
@@ -38,20 +41,30 @@ class CrashReports:
def __init__(self, directory: Path | None = None):
self.directory = directory if directory is not None else Path(Paths.log_root()) / "crash"
def _root(self, *, missing_ok: bool = False) -> tuple[int, os.stat_result] | None:
def _root(self, *, missing_ok: bool = False, exclusive: bool = False) -> tuple[int, os.stat_result, int] | None:
try:
fd = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
fd = open_directory(self.directory)
except FileNotFoundError:
if missing_ok:
return None
raise CrashMissing from None
except OSError:
raise CrashUnavailable from None
lock = None
try:
info = os.fstat(fd)
self._check_root(info)
return fd, info
lock = acquire_lock(fd, exclusive=exclusive)
self._check_root(info)
return fd, info, lock
except OSError:
if lock is not None:
os.close(lock)
os.close(fd)
raise CrashUnavailable from None
except Exception:
if lock is not None:
os.close(lock)
os.close(fd)
raise
@@ -78,7 +91,7 @@ class CrashReports:
except (ValueError, UnicodeError, binascii.Error, RecursionError):
raise CrashMissing from None
if (not isinstance(value, list) or len(value) != 8 or not isinstance(value[0], str) or
value[0] in ("", ".", "..") or "/" in value[0] or "\\" in value[0] or "\0" in value[0] or
value[0] in ("", ".", "..", ".lock") or "/" in value[0] or "\\" in value[0] or "\0" in value[0] or
len(value[0]) > 255 or any(type(part) is not int for part in value[1:]) or
any(part < 0 for part in value[1:6])):
raise CrashMissing
@@ -87,13 +100,15 @@ class CrashReports:
def list(self) -> dict:
opened = self._root(missing_ok=True)
if opened is None:
return {"schemaVersion": 1, "reports": [], "scanIncomplete": False, "listLimited": False}
fd, root = opened
return {"schemaVersion": 1, "reports": [], "scanIncomplete": False, "listLimited": False, "reportCount": 0, "storageBytes": 0, "storageIncomplete": False}
fd, root, lock = opened
try:
reports: list[tuple[int, str, dict]] = []
scanned = 0
with os.scandir(fd) as entries:
for entry in entries:
if entry.name == '.lock':
continue
scanned += 1
if scanned > MAX_SCAN:
break
@@ -106,20 +121,23 @@ class CrashReports:
reports.append((info.st_mtime_ns, entry.name,
{"id": self._token(entry.name, root, info), "name": entry.name,
"size": info.st_size, "modifiedAt": info.st_mtime_ns / 1e9}))
stored_bytes, storage_incomplete = usage(fd)
self._check_root(root)
reports.sort(key=lambda report: (-report[0], report[1]))
return {"schemaVersion": 1, "reports": [report[2] for report in reports[:MAX_LIST]],
"scanIncomplete": scanned > MAX_SCAN, "listLimited": len(reports) > MAX_LIST}
"scanIncomplete": scanned > MAX_SCAN, "listLimited": len(reports) > MAX_LIST,
"reportCount": len(reports), "storageBytes": stored_bytes, "storageIncomplete": storage_incomplete}
except OSError:
raise CrashUnavailable from None
finally:
os.close(lock)
os.close(fd)
def preview(self, token: str) -> dict:
name, root_id, selected = self._decode(token)
opened = self._root()
assert opened is not None
root_fd, root = opened
root_fd, root, lock = opened
try:
if (root.st_dev, root.st_ino) != root_id:
raise CrashChanged
@@ -157,4 +175,99 @@ class CrashReports:
finally:
os.close(fd)
finally:
os.close(lock)
os.close(root_fd)
def _latest_summary(self, directory, name, selected):
# save_exception writes a timestamped log and a shared latest error.txt.
pattern = r'[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.log'
if re.fullmatch(pattern, name) is None:
return None
with os.scandir(directory) as entries:
for count, entry in enumerate(entries):
if count >= MAX_SCAN:
return None
if entry.name != name and re.fullmatch(pattern, entry.name):
try:
info = entry.stat(follow_symlinks=False)
except FileNotFoundError:
continue
if stat.S_ISREG(info.st_mode) and info.st_mtime_ns >= selected.st_mtime_ns:
return None
try:
log_fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
summary_fd = os.open('error.txt', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
except OSError:
if 'log_fd' in locals():
os.close(log_fd)
return None
try:
before = os.fstat(summary_fd)
if not stat.S_ISREG(before.st_mode) or not selected.st_mtime_ns <= before.st_mtime_ns <= selected.st_mtime_ns + 2_000_000_000:
return None
log_info = os.fstat(log_fd)
if _identity(log_info) != _identity(selected):
return None
os.lseek(log_fd, max(0, selected.st_size - MAX_PREVIEW), os.SEEK_SET)
tail = os.read(log_fd, MAX_PREVIEW)
if selected.st_size > MAX_PREVIEW and tail.count(b'\n') < 11:
return None
summary = os.read(summary_fd, MAX_PREVIEW + 1)
expected = '\n'.join(tail.decode('utf-8', 'replace').splitlines()[-10:]).encode()
if summary != expected or _identity(os.fstat(summary_fd)) != _identity(before) or _identity(os.fstat(log_fd)) != _identity(selected):
return None
return _identity(before)
finally:
os.close(log_fd)
os.close(summary_fd)
def delete(self, token=None, *, permitted=lambda: True):
selection = self._decode(token) if token is not None else None
if not permitted():
raise PermissionError('Galaxy session expired')
opened = self._root(missing_ok=True, exclusive=True)
more = False
if opened is not None:
directory, root, lock = opened
try:
if not permitted():
raise PermissionError('Galaxy session expired')
budget = [4096]
if selection is None:
with os.scandir(directory) as entries:
for entry in entries:
if entry.name == '.lock':
continue
try:
remove_entry(directory, entry.name, budget)
except DeletionLimit:
more = True
break
else:
name, root_id, identity = selection
if root_id != (root.st_dev, root.st_ino):
raise CrashChanged
try:
current = os.stat(name, dir_fd=directory, follow_symlinks=False)
except FileNotFoundError:
current = None
if current is not None:
if not stat.S_ISREG(current.st_mode) or _identity(current) != identity:
raise CrashChanged
summary = self._latest_summary(directory, name, current)
remove_entry(directory, name, budget)
if summary is not None:
try:
latest = os.stat('error.txt', dir_fd=directory, follow_symlinks=False)
except FileNotFoundError:
latest = None
if latest is not None and _identity(latest) == summary:
remove_entry(directory, 'error.txt', budget)
self._check_root(root)
os.fsync(directory)
except (OSError, DeletionLimit):
raise CrashUnavailable from None
finally:
os.close(lock)
os.close(directory)
return {**self.list(), 'more': more}
@@ -0,0 +1,119 @@
"""Recovery through canonical feature rows and their existing guarded owners."""
from dataclasses import dataclass, replace
from decimal import Decimal, InvalidOperation
import secrets
import threading
from openpilot.starpilot.galaxy.settings import PAGES, TTL, SettingsChanged, _session_key
from openpilot.starpilot.ui.feature_settings_state import row_default
def equivalent(current, default):
if current == default:
return True
try:
left, right = Decimal(current), Decimal(default)
return left.is_finite() and right.is_finite() and left == right
except (InvalidOperation, TypeError, ValueError):
return False
@dataclass(frozen=True)
class RecoveryView:
session: bytes
generation: bytes
cp_raw: bytes | None
expires: float
rows: tuple
class DefaultsRecovery:
def __init__(self, gateway):
self.gateway = gateway
self.lock = threading.Lock()
self.views = {}
self.intents = {}
def snapshot(self, token, generation):
cp_raw = self.gateway.context.sample().cp_raw
seen, choices, rows = set(), [], []
for page in sorted(PAGES):
ctx = self.gateway.context.sample()
if ctx.cp_raw != cp_raw:
raise SettingsChanged('Vehicle changed')
state = self.gateway._state(page, ctx)
for row in state.rows:
request = row_default(replace(row, available=True))
if request is None or request.key in seen or equivalent(row.value, row.default_value) or (row.source is None and row.related_source is None):
continue
seen.add(request.key)
index = len(choices)
choices.append((page, row))
rows.append({'id': index, 'page': page, 'label': row.label, 'current': row.value,
'default': row.default_value, 'modified': True, 'available': row.available, 'reason': row.reason,
'resetAvailable': ctx.parked and row_default(row, allow_inactive=True) is not None})
if len(choices) > 128:
raise SettingsChanged('Too many settings to reset together')
view = secrets.token_urlsafe(24)
with self.lock:
now = self.gateway.clock()
self.views = {k: v for k, v in self.views.items() if v.expires > now}
if len(self.views) >= 16:
self.views.pop(next(iter(self.views)))
self.views[view] = RecoveryView(_session_key(token), generation, cp_raw, now + TTL, tuple(choices))
return {'view': view, 'rows': rows, 'count': len(rows), 'resettableCount': sum(row['resetAvailable'] for row in rows),
'scope': 'Saved settings available for this vehicle. Runtime and hardware state are excluded.'}
def preview(self, view, selected, token, generation):
with self.lock:
saved = self.views.get(view)
if (saved is None or saved.expires <= self.gateway.clock() or saved.session != _session_key(token) or
saved.generation != generation or not isinstance(selected, list) or not selected or
any(type(i) is not int or not 0 <= i < len(saved.rows) for i in selected) or len(selected) != len(set(selected))):
raise SettingsChanged('Settings recovery selection expired')
rows = tuple(sorted((saved.rows[i] for i in selected), key=lambda item: item[1].default_value == "Off"))
if any(row_default(row, allow_inactive=True) is None for _, row in rows):
raise SettingsChanged('Selected settings cannot be reset for this vehicle')
intent = secrets.token_urlsafe(24)
self.intents[intent] = (saved, rows)
if len(self.intents) > 16:
self.intents.pop(next(iter(self.intents)))
return {'intent': intent, 'question': f'Reset {len(rows)} selected settings to their current StarPilot defaults?',
'labels': [row.label for _, row in rows], 'count': len(rows)}
def confirm(self, intent, token, generation, *, session_valid=lambda: True):
with self.lock:
value = self.intents.pop(intent, None)
if value is None:
raise SettingsChanged('Settings recovery confirmation expired')
saved, rows = value
ctx = self.gateway.context.sample()
if (saved.expires <= self.gateway.clock() or saved.session != _session_key(token) or saved.generation != generation or
ctx.cp_raw != saved.cp_raw or not ctx.parked or not session_valid()):
raise SettingsChanged('Settings recovery requires the same vehicle and offroad mode')
for page, row in rows:
if row not in self.gateway._state(page, ctx).rows or row_default(row, allow_inactive=True) is None:
raise SettingsChanged('Saved settings or defaults changed')
applied = []
for page, original in rows:
state = self.gateway.page(page, token, generation)
with self.gateway.lock:
fresh = self.gateway.views[state['view']].rows
candidates = [(i, row) for i, row in enumerate(fresh) if row_default(row, allow_inactive=True) is not None and
row_default(row, allow_inactive=True).key == row_default(original, allow_inactive=True).key]
if len(candidates) != 1:
return {'saved': False, 'applied': applied, 'reason': 'Settings became unavailable', 'refresh': True}
index, row = candidates[0]
if equivalent(row.value, row.default_value):
continue
if row.value != original.value or row.default_value != original.default_value:
return {'saved': False, 'applied': applied, 'reason': 'Saved settings changed during reset', 'refresh': True}
try:
pending = self.gateway.preview(state['view'], index, 0, token, generation, reset_default=True, recovery_default=True)
saved = self.gateway.confirm(pending['intent'], token, generation, session_valid=session_valid)
except (SettingsChanged, OSError, ValueError):
return {'saved': False, 'applied': applied, 'reason': 'Saved settings changed during reset', 'refresh': True}
if not saved:
return {'saved': False, 'applied': applied, 'reason': 'Setting could not be reset', 'refresh': True}
applied.append(original.label)
return {'saved': True, 'applied': applied, 'refresh': True}
+1 -1
View File
@@ -82,7 +82,7 @@ class _LocalHTTPServer(ThreadingHTTPServer):
media = getattr(self, 'recording_media_source', None)
if media is not None:
cleanup.callback(media.close)
for name in ('map_source', 'settings_source', 'vehicle_selection_source', 'model_source', 'plots_source',
for name in ('map_source', 'settings_source', 'vehicle_selection_source', 'toyota_tools_source', 'model_source', 'plots_source',
'flm_source', 'bluetooth_authority', 'bluetooth_source', 'model_manager_source', 'model_authority', 'layout_authority', 'favorites_source',
'sound_authority', 'sound_source', 'software_operations_source', 'drive_stats_authority', 'drive_stats_source',
'pairing_authority', 'evidence_source', 'device_state_source', 'navigation_source', 'drive_physical_source', 'notification_source',
+2 -1
View File
@@ -12,6 +12,7 @@ from openpilot.starpilot.ui.onroad_customization import (
MAX_BYTES, PARAM_KEY, customization_metadata, decode_document, default_document, validate_document,
)
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.galaxy.setting_descriptions import description
# Display preferences edited with the layout but saved in their own params. Groups order the editor.
DISPLAY_GROUPS = {
@@ -60,7 +61,7 @@ class OnroadLayoutOwner:
"rows": [{"key": row.key, "label": row.label, "group": DISPLAY_GROUPS[row.key], "choices": list(row.choices),
# Lead info follows the draft's Lead indicator, not the saved one.
"available": row.available or row.key == "LeadInfo" and parked and lead_readable and bool(row.choices),
"reason": row.reason, "requires": "HideLeadMarker" if row.key == "LeadInfo" else ""} for row in rows],
"reason": description(row), "requires": "HideLeadMarker" if row.key == "LeadInfo" else ""} for row in rows],
"values": {row.key: row.value for row in rows},
"defaults": {row.key: row.default_value for row in rows if row.default_value is not None},
}
+14 -2
View File
@@ -1,4 +1,4 @@
"""Bounded, read-only projection of local Sentry motion metadata."""
"""Bounded projection of local Sentry motion metadata."""
import json
import re
@@ -20,6 +20,10 @@ class EventSnapshotSource(Protocol):
def snapshot(self) -> dict: ...
class EventDeletionSource(Protocol):
def delete(self, event_id: str | None = None, *, permitted: Callable[[], bool]) -> dict: ...
class SentryEvents:
def __init__(self, store: EventSnapshotSource | None = None):
self.store = store if store is not None else EventStore()
@@ -42,7 +46,8 @@ class SentryEvents:
"systemTimeMs": value["wallTimeNs"] // 1_000_000,
"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}
"capacity": source["capacity"], "events": events,
"storageBytes": source.get("storageBytes", 0), "storageIncomplete": source.get("storageIncomplete", False)}
if len(json.dumps(result, separators=(",", ":")).encode()) > MAX_RESPONSE_BYTES:
raise ValueError("Motion response exceeds limit")
return result
@@ -54,3 +59,10 @@ class SentryEvents:
return self.store.image(event_id, camera)
except (OSError, StorageUnavailable, AttributeError) as error:
raise SentryEventsUnavailable from error
def delete(self, event_id=None, *, permitted=lambda: True):
try:
result = cast(EventDeletionSource, self.store).delete(event_id, permitted=permitted)
return {**self.snapshot(), **result}
except (StorageUnavailable, OSError, AttributeError) as error:
raise SentryEventsUnavailable from error
+140 -7
View File
@@ -65,7 +65,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
sounds=None, software_operations=None, drive_stats=None, layout_preview_socket=None, controllers_socket=None,
remote_pairing=None, parked=None, camera_snapshot=None, clock=time.monotonic, android_auto_setup=None,
android_auto_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None,
local_access=None, tmux_live=None, android_auto_logs=None, hotspot=None):
local_access=None, tmux_live=None, android_auto_logs=None, hotspot=None, toyota_tools=None):
cloud = cloud_provider
hotspot_source = hotspot
hotspot_source_lock = threading.Lock()
@@ -90,6 +90,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
console_source = tmux_live if tmux_live is not None else TmuxLive()
monitor_source = monitor if monitor is not None else SystemMonitor()
reports = crashes if crashes is not None else CrashReports()
crash_reports_lock = threading.Lock()
aa_logs_source = android_auto_logs
def aa_logs():
@@ -161,7 +162,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
settings_lock = threading.Lock()
models_lock = threading.Lock()
settings_gateway = settings
recovery_gateway = None
vehicle_gateway = vehicle_selection
toyota_source = toyota_tools
model_source = models
model_manager_source = model_manager
plot_source = plots
@@ -398,6 +401,26 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
server.settings_source = settings_gateway
return settings_gateway
def settings_recovery():
nonlocal recovery_gateway
gateway = feature_settings()
with settings_lock:
if recovery_gateway is None:
from openpilot.starpilot.galaxy.defaults_recovery import DefaultsRecovery
recovery_gateway = DefaultsRecovery(gateway)
return recovery_gateway
def toyota_owner():
nonlocal toyota_source
with settings_lock:
if toyota_source is None:
from openpilot.common.params import Params
from openpilot.starpilot.galaxy.toyota_tools import ToyotaTools
params = Params()
toyota_source = ToyotaTools(params, context_source(params))
server.toyota_tools_source = toyota_source
return toyota_source
def vehicle_choices():
nonlocal vehicle_gateway
with settings_lock:
@@ -903,6 +926,19 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.json(200, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
elif path == '/api/toyota-tools':
if not self.require_session():
return
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
try:
result = toyota_owner().page(*identity)
except (OSError, RuntimeError, ValueError):
self.json(503, {'error': 'Toyota settings are unavailable'})
else:
self.json(200, result) if self.settings_session() == identity else self.json(401, {'error': 'Sign in to Galaxy'})
elif path == '/api/vehicle-selection':
if not self.require_session():
return
@@ -1217,6 +1253,24 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.json(200, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
elif path == '/api/settings/non-defaults':
if not self.require_session():
return
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
try:
result = settings_recovery().snapshot(*identity)
except SettingsChanged:
self.json(409, {'error': 'Saved settings changed; refresh the page'})
except (SettingsUnavailable, OSError, ValueError, RuntimeError):
self.json(503, {'error': 'Saved settings are unavailable'})
else:
if self.settings_session() == identity:
self.json(200, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
elif path.startswith('/api/settings/pages/'):
if not self.require_session():
return
@@ -1311,6 +1365,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.connection.settimeout(old_timeout)
return
if path not in ('/api/connect/provider', '/api/auth/login', '/api/auth/logout', '/api/settings/preview', '/api/settings/confirm',
'/api/settings/recovery/preview', '/api/settings/recovery/confirm',
'/api/settings/reset-default', '/api/settings/restore', '/api/recordings/action',
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot',
'/api/android-auto/layout', '/api/android-auto/enable', '/api/android-auto/control', '/api/android-auto/pairing',
@@ -1323,15 +1378,15 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
'/api/models/cancel', '/api/models/delete', '/api/models/refresh_manifest', '/api/models/jetlink',
'/api/models/laboratory', '/api/models/laboratory/download', '/api/models/laboratory/delete',
'/api/sounds/download', '/api/sounds/cancel', '/api/software/action', '/api/drives/ignore',
'/api/recordings/delete-videos', '/api/sentry/notifications',
'/api/recordings/delete-videos', '/api/sentry/notifications', '/api/sentry/events/delete', '/api/crash-reports/delete',
'/api/navigation/search', '/api/navigation/action', '/api/navigation/offline', '/api/drive-state/action',
'/api/vehicle-selection/preview', '/api/vehicle-selection/confirm'):
'/api/toyota-tools/action', '/api/vehicle-selection/preview', '/api/vehicle-selection/confirm'):
self.json(405, {'error': 'Method unavailable'})
return
if (path.startswith(('/api/recordings/', '/api/connect/', '/api/cameras/', '/api/settings/', '/api/maps/', '/api/flm/', '/api/bluetooth/',
'/api/android-auto/', '/api/controllers/', '/api/vehicle-selection/',
'/api/android-auto/', '/api/controllers/', '/api/vehicle-selection/', '/api/toyota-tools/',
'/api/models/', '/api/ui/', '/api/favorites/', '/api/sounds/', '/api/software/',
'/api/drives/', '/api/navigation/', '/api/drive-state/', '/api/sentry/')) and
'/api/drives/', '/api/navigation/', '/api/drive-state/', '/api/sentry/', '/api/crash-reports/')) and
not self.require_session()):
return
if self.headers.get('Transfer-Encoding') is not None or \
@@ -1693,6 +1748,46 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
self.json(200, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
elif path in ('/api/sentry/events/delete', '/api/crash-reports/delete'):
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
sentry = path == '/api/sentry/events/delete'
key = 'eventId' if sentry else 'id'
try:
if type(payload) is not dict:
raise ValueError('Invalid deletion selection')
if set(payload) == {'all', 'confirmed'} and payload['all'] is True and payload['confirmed'] is True:
selected = None
elif set(payload) == {key} and type(payload[key]) is str and payload[key]:
selected = payload[key]
else:
raise ValueError('Confirm deleting all stored records')
def permitted():
return self.settings_session() == identity
with sentry_events_lock if sentry else crash_reports_lock:
if not permitted():
raise PermissionError('Galaxy session expired')
if sentry and notification_owner is not None:
result = notification_owner.delete_events(lambda: motion_events.delete(selected, permitted=permitted), selected)
else:
result = (motion_events if sentry else reports).delete(selected, permitted=permitted)
except (ValueError, CrashMissing):
self.json(400, {'error': 'Invalid deletion selection or confirmation'})
except PermissionError:
self.json(401, {'error': 'Sign in to Galaxy'})
except CrashChanged:
self.json(409, {'error': 'Report changed; refresh before deleting'})
except (OSError, CrashUnavailable, SentryEventsUnavailable, NotificationUnavailable):
self.json(503, {'error': 'Stored records could not be fully deleted; refresh and retry'})
else:
if permitted():
self.json(200, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
elif path == '/api/recordings/delete-videos':
identity = self.settings_session()
if identity is None:
@@ -1817,7 +1912,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if (type(payload) is not dict or set(payload) != {'document', 'profile', 'scene'} or
if (type(payload) is not dict or set(payload) not in ({'document', 'profile', 'scene'}, {'document', 'profile', 'scene', 'art'}) or
('art' in payload and payload['art'] is not True) or
type(payload['profile']) is not str or payload['profile'] not in (PREVIEW_PROFILES | {'projection'}) or
type(payload['scene']) is not str or payload['scene'] not in PREVIEW_SCENES):
self.json(400, {'error': 'Invalid layout preview request'})
@@ -1841,7 +1937,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
if not layout_owner().parked():
self.json(403, {'error': 'Turn off the vehicle to preview this layout'})
return
image = request_preview({'document': document, 'profile': payload['profile'], 'scene': payload['scene']},
image = request_preview({'document': document, 'profile': payload['profile'], 'scene': payload['scene'],
**({'art': True} if payload.get('art') is True else {})},
layout_preview_socket)
if self.settings_session() != identity:
self.json(401, {'error': 'Sign in to Galaxy'})
@@ -2127,6 +2224,24 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
aa_source_registry().revoke_source(source)
self.json(200, {'ok': True})
return
if path == '/api/toyota-tools/action':
from openpilot.starpilot.galaxy.toyota_tools import ToyotaToolsChanged
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
try:
with effect_lock:
result = toyota_owner().action(payload, *identity, session_valid=lambda: self.settings_session() == identity)
except ValueError:
self.json(400, {'error': 'Invalid Toyota request'})
except ToyotaToolsChanged:
self.json(409, {'error': 'Toyota settings changed; refresh saved values'})
except (OSError, RuntimeError):
self.json(503, {'error': 'Toyota settings are unavailable'})
else:
self.json(200, result) if self.settings_session() == identity else self.json(401, {'error': 'Sign in to Galaxy'})
return
if path.startswith('/api/vehicle-selection/'):
identity = self.settings_session()
if identity is None:
@@ -2345,6 +2460,22 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
with effect_lock:
result = ToggleBackup(feature_settings(), layout_owner()).restore(
payload, identity, authorized=lambda: self.settings_session() == identity)
elif path == '/api/settings/recovery/preview':
if (not isinstance(payload, dict) or set(payload) != {'view', 'selected'} or
not isinstance(payload['view'], str) or len(payload['view']) > 64 or
not isinstance(payload['selected'], list) or not 1 <= len(payload['selected']) <= 128 or
any(type(index) is not int for index in payload['selected'])):
raise ValueError('Invalid settings recovery selection')
result = settings_recovery().preview(payload['view'], payload['selected'], *identity)
elif path == '/api/settings/recovery/confirm':
if (not isinstance(payload, dict) or set(payload) != {'intent', 'confirmed'} or
not isinstance(payload['intent'], str) or len(payload['intent']) > 64 or payload['confirmed'] is not True):
raise ValueError('Invalid settings recovery confirmation')
with effect_lock:
if self.settings_session() != identity:
raise SettingsChanged('Session changed')
result = settings_recovery().confirm(payload['intent'], *identity,
session_valid=lambda: self.settings_session() == identity)
elif path == '/api/settings/reset-default':
if not isinstance(payload, dict) or set(payload) != {'view', 'row'} or \
not isinstance(payload['view'], str) or len(payload['view']) > 64 or type(payload['row']) is not int:
@@ -2486,6 +2617,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
server.plots_source = plot_source
if settings_gateway is not None:
server.settings_source = settings_gateway
if toyota_source is not None:
server.toyota_tools_source = toyota_source
if bluetooth_source is not None:
server.bluetooth_source = bluetooth_source
if aa_setup_source is not None:
@@ -0,0 +1,263 @@
"""Descriptions of existing settings; no preferences, defaults or runtime state.
Rows and their availability still come from the canonical feature owners.
This presentation catalog supplies the purpose before any genuine prerequisite.
"""
from openpilot.starpilot.ui.long_profile_feature import CATEGORY_HELP, VALUE_HELP
DESCRIPTIONS = {
'lane_change:enabled': 'Enables lane-change assistance after you signal. With Automatic Lane Changes off, give the wheel a nudge to begin.',
'lane_change:pace': 'Sets how quickly steering moves into the adjacent lane. Higher values change lanes more quickly; lower values make steering gentler.',
'lane_change:speed': 'Sets the lowest speed at which lane-change assistance can begin.',
'lane_change:one': 'Allows one assisted lane change until you release the turn signal.',
'lane_change:auto': 'Starts a lane change after the signal delay without a steering nudge, when the adjacent lane and blind spot are clear.',
'lane_change:delay': 'Sets the waiting time after signaling before an automatic lane change can begin.',
'lane_change:width': 'Sets the minimum detected width of the adjacent lane for an automatic lane change.',
'lane_change:close_gap': 'Temporarily reduces following time during a lane change when the lane and lead gap are clear.',
'lane_change:close_gap_seconds': 'Sets following time while Close lane-change gap is active. Higher values leave more space.',
'lane_change:reset': 'Restores the default lane-change assistance, signal timing, pace and gap settings.',
'LaneCentering': 'Uses detected lane lines to help keep the vehicle centered in its lane.',
'LaneCenteringPauseOnSignal': 'Pauses lane-centering correction while a turn signal is on.',
'LaneCenterOffset': 'Moves the lane-centering target left or right of the detected lane center.',
'LaneCenteringE2EAuthority': 'Lets a confident driving-model path reduce lane-centering correction when the model path differs from the lane center.',
'LaneCenteringStrength': 'Sets the strength of lane-centering correction. 100% is normal; higher values strengthen it.',
'PauseLateralSpeed': 'Pauses steering assistance below this speed. Zero disables the speed-based pause.',
'PauseLateralOnSignal': 'Limits the low-speed steering pause to times when a turn signal is on.',
'LateralResumeDelay': 'Waits before resuming steering after signaling ends, if speed fell below half the steering-pause threshold.',
'AlwaysOnLateral': 'Keeps steering assistance available independently of cruise control on supported vehicles.',
'AolBrakePauseSpeedMps': 'Pauses Always On Lateral during braking below this speed. Zero disables this brake-speed pause.',
'NostalgiaMode': 'Lets the Ioniq 6 left paddle pause acceleration and braking while Always On Lateral keeps steering.',
'TurnAssist': 'Uses steering-angle feedback to assist rolling low-speed turns above the vehicle minimum steering speed.',
'LateralControllerSelection': 'Chooses the stock steering controller or the vehicle-specific StarPilot controller.',
'ForceAutoTuneOff': 'Controls whether compatible, qualified automatic steering learning is used for this vehicle.',
'torque_prepare_firestar': 'Prepares steering settings for a tuning-request recording. Turning it off restores the previous settings.',
'SteerLatAccel': 'Sets the legacy lateral-acceleration-to-torque conversion. Review the vehicle-specific tuning profile before applying it.',
'SteerFriction': 'Sets legacy steering friction compensation. Review the vehicle-specific tuning profile before applying it.',
'UseStarPilotLongitudinalPlanner': 'Selects the StarPilot acceleration, braking and following planner. Off selects the upstream planner.',
'CustomCruise': 'Sets how much a short cruise-button press changes the set speed.',
'CustomCruiseLong': 'Sets how much holding a cruise button changes the set speed.',
'ReverseCruise': 'Swaps the Toyota short-press and held-press cruise-speed increments.',
'ForceStops': 'Commits to stops detected by the driving model. Press the accelerator to continue.',
'ForceStopDistanceOffset': 'Moves the planned stopping point. Positive values stop farther forward; negative values stop earlier.',
'LeadApproachBuffer': 'Gradually adds following distance when approaching a slower or braking lead vehicle.',
'FasterLeadTakeoff': 'Responds sooner when a stopped or slow lead vehicle starts moving.',
'CustomPersonalities': 'Uses your following-time and jerk settings for the selected driving personality.',
'profile:global_acceleration': 'Chooses the acceleration curve used by personalities set to Selected Profile.',
'profile:global_braking': 'Chooses the braking response used by personalities set to Selected Profile.',
'reset_profiles': 'Restores the default acceleration, braking and following curves and presets.',
'CurveSpeedController': 'Adjusts cruise speed for curves ahead using available learned cornering preferences.',
'CurveSpeedControllerNoLead': 'Pauses curve-speed adjustments while following a lead vehicle.',
'ShowCSCStatus': 'Shows when Curve Speed Controller is adjusting speed on the driving screen.',
'curve_reset': 'Deletes saved cornering learning so Curve Speed Controller can learn your preferences again.',
'SpeedLimitController': 'Adjusts the set speed to accepted speed limits using your source priority and offsets.',
'ShowSpeedLimits': 'Shows recognized speed-limit signs on the driving screen.',
'SLCConfirmation': 'Asks before applying a newly detected speed limit to the set speed.',
'SLCConfirmationLower': 'Asks before applying a lower speed limit when Require confirmation is on.',
'SLCConfirmationHigher': 'Asks before applying a higher speed limit when Require confirmation is on.',
'SLCPriority1': 'Chooses the first source checked for a fresh speed limit.',
'SLCPriority2': 'Chooses the source used when the primary source has no fresh speed limit.',
'SLCFallback': 'Keeps the previously accepted speed limit when the selected sources temporarily have no usable limit.',
'slc_adopt': 'Keeps the current speed-limit offset schedule fixed when display units change.',
'slc_reset': 'Resets saved speed-limit offsets while retaining valid configured speed ranges.',
'AlwaysAllowUploads': 'Allows queued full logs and videos to upload over metered networks. This uses mobile data.',
'ToyotaAutoHold': ('Holds the brakes after you press the brake for over one second at a complete stop ' +
'with cruise main on and cruise disengaged. Press the accelerator to release.'),
'GMPedalLongitudinal': 'Uses a detected compatible pedal interceptor for StarPilot acceleration control.',
'DisableOpenpilotLongitudinal': 'Turns off StarPilot acceleration and braking control for the supported Bolt configuration.',
'VoltSNG': 'Assists automatic resume from a cruise-controlled stop when fresh lead and stopping information allow it.',
'GMAutoHold': 'Holds the brakes at a stop with cruise main on. Press the accelerator or the supported regen paddle to release.',
'VoltOnePedalMode': 'Slows and holds a Volt when you lift off the accelerator in Low or single-pedal mode, with cruise main on and cruise disengaged.',
'LongPitch': 'Compensates acceleration and braking for road grade.',
'GMLongitudinalTune': 'Selects the supported vehicle speed-control response tune.',
'TruckTuning': 'Softens small acceleration requests, reduces grade correction and smooths light braking on supported GM trucks.',
'EVTuning': 'Selects EV acceleration curves for inherited acceleration presets.',
'FordLateralMode': 'Chooses the native Ford steering controller or the supported BluePilot angle controller.',
'TeslaAOLScreenTap': 'Lets a three-finger tap on a supported Tesla screen toggle Always On Lateral.',
'TeslaAOLDisengageOnBrake': 'Disarms screen-tap Always On Lateral when you press the brake.',
'RemoteStartBootsComma': 'Wakes comma during GM remote start.',
'HKGRemoteStartBootsComma': 'Wakes comma during Hyundai, Kia or Genesis remote climate operation.',
'IgnoreIgnitionLine': 'Uses GM CAN ignition instead of the harness ignition signal.',
'TeslaWakeOnCAN': 'Wakes comma when the Tesla wakes up, before you press the brake.',
'RadarRecoveryAlert': 'Play a chime and show a brief message when a radar fault clears. You must re-engage manually.',
'CameraView': 'Chooses the camera view shown on the driving screen.',
'DriverCamera': 'Shows the driver camera while reversing.',
'StockConfidenceBallWidget': 'Uses StarPilot driving widgets in place of the stock confidence ball.',
'HidePersonalityAlerts': 'Hides the alert shown when the driving personality changes.',
'ShowBrakeStatus': 'Colors the steering wheel to show braking and acceleration.',
'HideLeadMarker': 'Shows the lead-vehicle indicator on the driving screen.',
'LeadInfo': 'Shows the lead vehicle distance or speed beside its indicator.',
'SignalMetrics': 'Shows an amber border for turn signals on comma 4.',
'BlindSpotMetrics': 'Shows a red border for blind-spot warnings on comma 4.',
'StarPilotDisplayPreferencesEnabled': 'Uses your configured screen brightness and display timeouts.',
'ScreenBrightness': 'Sets screen brightness while parked. Auto follows the normal brightness policy.',
'ScreenBrightnessOnroad': 'Sets screen brightness while driving. Auto follows the normal brightness policy.',
'ScreenTimeout': 'Sets how long the parked screen remains on after interaction.',
'ScreenTimeoutOnroad': 'Sets the wait after interaction before returning to the driving view or turning off the screen if configured.',
'TurnOffCommaScreen': 'Pauses comma UI rendering to reduce CPU and GPU use. Tap once to wake the screen.',
'SoundPack': 'Chooses the sounds used for driving alerts and chimes.',
'WarningImmediateVolume': 'Sets the starting volume of immediate warnings. These warnings still ramp to full volume.',
'WarningSoftVolume': 'Sets the starting volume of soft warnings. Warning volume keeps its required minimum.',
'RefuseVolume': 'Sets the volume of the engagement-refused sound.',
'PromptDistractedVolume': 'Sets the volume of distracted-driver prompts.',
'EngageVolume': 'Sets the engagement-chime volume.',
'DisengageVolume': 'Sets the disengagement-alert volume.',
'PromptVolume': 'Sets the volume of general prompts.',
'PIPPreviewEnabled': 'Shows the configured blind-spot camera preview when fresh camera frames are available.',
'PIPPreviewShowOnBlinker': 'Shows the blind-spot camera preview while a turn signal is on.',
'PIPPreviewShowOnBSM': 'Shows the blind-spot camera preview during a blind-spot warning.',
'PIPPreviewInvert': 'Mirrors the blind-spot camera crop horizontally.',
'pip:mask:size': 'Sets the size of each camera-image crop shown in the preview.',
'pip:mask:editor': 'Edits the source-image crop for the vehicle left and right camera views.',
'pip:reset': 'Restores the default source-image camera crops.',
'vasm:enabled': 'Enables visual spot-monitor warnings for the configured camera regions when qualified camera observations are available.',
'vasm:confidence': 'Sets the model confidence needed for a visual spot-monitor warning.',
'vasm:smooth': 'Sets how slowly visual spot-monitor warnings change.',
'vasm:annotation': 'Selects the camera-image regions corresponding to the vehicle windows for visual spot monitoring.',
'vasm:reset': 'Turns off visual spot monitoring and clears its configured camera regions.',
'sentry:enabled': 'Monitors parked motion and stores detected events when fresh parked sensor evidence is available.',
'sentry:sensitivity': 'Sets how large an acceleration change must be to count as parked motion. Lower values detect smaller changes.',
'sentry:warning': 'Sets how long detected motion must persist before a warning is recorded.',
'sentry:reset': 'Turns off parked motion monitoring and restores its sensitivity and warning persistence defaults.',
'conditional:mode': 'Chooses Chill, Experimental or automatic switching based on the configured driving conditions.',
'conditional:reset': 'Restores the default conditional driving-mode settings.',
'conditional:manual_reset': 'Returns both remembered manual driving-mode choices to Automatic.',
}
# These are initialization explanations, not a second defaults table.
INITIALIZATION = {
'LateralControllerSelection': ('Applies when driving processes next start: the controller class, vehicle policy and ' +
'PID/filter state are constructed together.'),
'UseStarPilotLongitudinalPlanner': 'Applies when the planner next starts: each choice runs a different planner process with its own subscriptions and state.',
'TurnAssist': ('Applies when driving processes next start: the selected torque controller creates its ' +
'low-speed feedback controller and filter state at initialization.'),
'ToyotaAutoHold': 'Requires the next vehicle startup to configure the brake-hold permission in CarParams and Panda safety.',
'DisableOpenpilotLongitudinal': 'Requires the next vehicle startup to rebuild cruise ownership and Panda safety configuration.',
'GMAutoHold': 'First enable requires the next vehicle startup to configure the brake-hold safety permission. An already admitted hold can be disabled live.',
'VoltOnePedalMode': ('First enable requires the next vehicle startup to configure the braking safety ' +
'permission. An already admitted mode can be disabled live.'),
'VoltSNG': 'Applies when the vehicle controller next starts: its fresh resume-plan input is created for the selected stop-and-go mode.',
'FordLateralMode': 'Requires the next vehicle startup to select the steering controller, admitted CAN mode and Panda safety word.',
'TeslaAOLScreenTap': 'Requires the next vehicle startup to configure the screen-button CAN source and matching safety permission.',
'TeslaAOLDisengageOnBrake': 'Requires the next vehicle startup to configure the screen-button brake/disarm safety permission.',
'RemoteStartBootsComma': 'Requires a device restart to load the matching Panda ignition firmware.',
'HKGRemoteStartBootsComma': 'Requires a device restart to load the matching Panda ignition firmware.',
'IgnoreIgnitionLine': 'Requires a device restart to load the matching Panda ignition firmware.',
'TeslaWakeOnCAN': 'Requires a device restart to load the matching Panda ignition firmware.',
}
CONDITIONS = {
'speed_mps': 'Sets the speed threshold for automatic mode switching without a tracked lead vehicle.',
'speed_with_lead_mps': 'Sets the speed threshold for automatic mode switching while tracking a lead vehicle.',
'signal_speed_mps': 'Requests Experimental below this speed while signaling, subject to the configured adjacent-lane check.',
'open_road': 'Requests Experimental on an open road without a tracked lead vehicle.',
'curves': 'Requests Experimental for curves without a tracked lead vehicle.',
'curves_with_lead': 'Requests Experimental for curves while tracking a lead vehicle.',
'lead': 'Uses a tracked lead vehicle as a condition for automatic mode switching.',
'slower_lead': 'Requests Experimental when approaching a slower lead vehicle.',
'stopped_lead': 'Requests Experimental for a detected stopped lead vehicle.',
'stop_lights': 'Requests Experimental for detected traffic lights and stop scenes.',
'model_stop_s': 'Sets how far ahead in time to look for a stop predicted by the driving model.',
'signal_lane_detection': ('Distinguishes a turn from a lane change using the space beside the car. A signal ' +
'requests Experimental only when no adjacent lane is detected.'),
'signal_lane_width_m': 'Sets the minimum adjacent width that counts as a lane when distinguishing a turn from a lane change.',
'set_speed_margin_mps': 'Sets the margin below cruise speed required before Conditional Chill selects Chill.',
'launch_assist': 'Temporarily selects Chill when a tracked lead moves away from a stop and the launch conditions are met.',
'persist_manual': 'Remembers your manual driving-mode choice across drives. Changing this setting resets that choice to Automatic.',
}
def purpose(row):
key = row.key
if key in DESCRIPTIONS:
return DESCRIPTIONS[key]
if key.startswith(('wheel:', 'conditional:button:')):
return f'Assigns the action performed by {row.label.lower()}.'
if key.startswith('conditional:'):
section, field = key.split(':')[1:]
if field in ('speed_mps', 'speed_with_lead_mps'):
direction, mode = ('below', 'Experimental') if section == 'cem' else ('at or above', 'Chill')
lead = 'while following a lead' if field == 'speed_with_lead_mps' else 'without a tracked lead'
return f'Requests {mode} {direction} this speed {lead}, when the remaining driving conditions qualify.'
if field == 'lead':
return ('Requests Experimental when a qualified slower lead is detected.' if section == 'cem' else
'Allows Chill while following a qualified stable lead above the lead-speed threshold.')
return CONDITIONS.get(key.rsplit(':', 1)[-1])
if key.startswith('profile:'):
parts = key.split(':')
category = parts[2] if len(parts) >= 3 else None
if category in CATEGORY_HELP:
if len(parts) == 3:
return CATEGORY_HELP[category]
return {'following': 'Sets following time at this speed. Higher values leave more space.',
'acceleration': 'Sets the acceleration limit at this speed. Higher values allow stronger acceleration.',
'braking': 'Sets braking response at this speed. Higher values allow stronger braking.'}[category]
if key.startswith(('Aggressive', 'Standard', 'Relaxed', 'Traffic')):
if key == 'TrafficFollow':
return 'Sets following time at low speeds in Traffic mode, blending toward Relaxed as speed rises.'
suffix = next((name for name in VALUE_HELP if key.endswith(name)), None)
value = VALUE_HELP.get(suffix)
return value + ' Blends toward Relaxed as speed rises in Traffic mode.' if value and key.startswith('Traffic') else value
if key in tuple('Offset'+str(index) for index in range(1, 8)):
return 'Adds or subtracts this offset from the accepted speed limit within the displayed speed range.'
if key.startswith('long_repair:'):
return 'Restores the default value of this invalid longitudinal setting.'
if key.startswith('long_reset:'):
return 'Restores the default following and jerk settings for this driving personality.'
if key.startswith('display:auto:'):
return 'Returns this brightness setting to the automatic display policy.'
if key.startswith('sounds:auto:'):
return 'Returns this alert volume to the ambient-sound policy.'
if key.startswith('pip:format:'):
return 'Selects camera crop dimensions matching the source camera image.'
if key.startswith('pip:mask:'):
return 'Moves the camera-image crop center along the indicated axis.'
if key.startswith('torque:'):
field = key.split(':')[1]
return {'factor': 'Sets the lateral-acceleration-to-torque conversion for compatible steering controllers.',
'friction': 'Sets steering friction compensation around changes in steering direction.',
'gain': 'Sets how strongly the steering controller responds to tracking error.',
'ratio': 'Sets the steering-wheel ratio used by the vehicle model.',
'full_delay': 'Sets the full steering delay used to anticipate the vehicle response.',
'automatic_delay': 'Chooses a learned or vehicle/custom steering delay.',
'learning': 'Chooses whether compatible steering geometry and torque learning are used or overridden.',
'geometry_reset': 'Restores the vehicle steering ratio and full delay.'}.get(field)
if key in ('torque_adopt', 'torque_rebase', 'torque_gain_rebase', 'torque_reset', 'torque_reset_profile'):
return {'torque_adopt': 'Keeps the existing steering adjustments in a vehicle-specific tuning profile.',
'torque_rebase': 'Reviews the saved steering adjustments against the current vehicle tune.',
'torque_gain_rebase': 'Reviews the saved steering response against the selected controller.',
'torque_reset': 'Restores the default steering tuning profiles.',
'torque_reset_profile': 'Restores the vehicle tuning for this model.'}[key]
return None
def description(row):
"""Keep actual error/prerequisite information after the setting's purpose."""
base = purpose(row)
if base is None:
return row.reason
suffix = INITIALIZATION.get(row.key, '')
if row.key.startswith('lane_change:') and row.key not in ('lane_change:close_gap', 'lane_change:close_gap_seconds', 'lane_change:reset'):
suffix = 'Changes apply when the current signal and maneuver end; turning assistance off cancels it after the preference refresh.'
if row.key.startswith('torque:') and row.key.split(':')[1] in ('factor', 'friction', 'gain'):
suffix = ('First activation of a manual tuning owner requires a driving-process restart. An ' +
'already active owner refreshes saved adjustments during the drive.')
if row.key == 'ForceAutoTuneOff':
suffix = ('The native torque learner and controller eligibility are initialized when driving ' +
'processes start; compatible live learning consumers also recheck the preference.')
if row.key == 'AlwaysOnLateral' and 'Applies after the next startup' in row.reason:
suffix = ('The pending vehicle configuration needs the next startup to admit its steering and ' +
'native button permissions. An already admitted configuration refreshes the steering preference during the drive.')
if row.key.startswith('wheel:'):
suffix = ('Axis-pause and AOL assignments require the next vehicle startup to configure the ' +
'native button permissions. Other actions refresh during the drive.')
if row.key == 'GMLongitudinalTune':
suffix = 'ACC Tune refreshes during the drive. Volt Tune initializes its stop/start response when driving processes start.'
warning = row.reason.strip().removeprefix('Saved for this vehicle. ')
related = any(word in warning.lower() for word in
('temporarily ignored', 'temporarily ignores', 'fixed torque conversion', 'inactive while', 'custom display is off',
'require', 'needs', 'uses its vehicle tune', 'unavailable', 'paused'))
actionable = warning and (related or (not row.available or row.repair_value) and any(word in warning.lower() for word in
('invalid', 'unreadable', 'cannot be read', 'unavailable', 'not supported', 'not available', 'turn on', 'repair', 'not detected', 'paused', 'required')))
if actionable and warning not in base and warning not in suffix:
suffix = (suffix + ' ' + warning).strip()
return ' '.join(value for value in (base, suffix) if value)
+26 -18
View File
@@ -1,5 +1,7 @@
"""Authenticated local projection of the shared saved-feature owner."""
from openpilot.starpilot.ui.lane_change_feature import confirm_question as lane_change_question
from collections.abc import Callable
from dataclasses import dataclass, replace
import hashlib
@@ -12,6 +14,7 @@ import time
from typing import Protocol, cast
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
from openpilot.starpilot.galaxy.setting_descriptions import description
from openpilot.starpilot.ui.controller_feature import SETUP_ACTION, SETUP_QUESTION
from openpilot.starpilot.ui.vehicle_bool import VEHICLE_BOOL_KEYS, confirmation_question as vehicle_question
from openpilot.starpilot.ui.conditional_feature import confirmation_question as conditional_question
@@ -275,8 +278,7 @@ class LiveContextSource:
except (OSError, RuntimeError, AttributeError, KeyError, TypeError, ValueError):
return False
def sample(self) -> AuthorityContext:
parked = self.configuration_allowed()
def runtime_vehicle(self):
cp_raw, readable = read_saved(self.params, "CarParamsPersistent", self.MAX_CP_BYTES)
cp = None
if readable and cp_raw is not None:
@@ -289,6 +291,12 @@ class LiveContextSource:
cp = messaging.log_from_bytes(inspected.payload, car.CarParams)
except (OSError, RuntimeError, TypeError, ValueError, OverflowError):
cp = None
return cp, cp_raw if readable else None
def sample(self) -> AuthorityContext:
parked = self.configuration_allowed()
cp, cp_raw = self.runtime_vehicle()
readable = True
editing_saved = configurable_long = configuration_vehicle = False
if parked and self.offroad():
from openpilot.starpilot.galaxy.vehicle_configuration import settings_configuration
@@ -319,6 +327,7 @@ class _Intent:
cp_raw: bytes | None
expires: float
reset_row: FeatureRow | None = None
recovery_default: bool = False
def _session_key(token: str) -> bytes:
@@ -328,7 +337,7 @@ def _session_key(token: str) -> bytes:
def _projection(row: FeatureRow, page: str) -> 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": row.reason,
"available": row.available, "reason": description(row),
"page": row.page if row.page in PAGES else "", "action": bool(row.key and row.available and not row.page),
"confirm": bool(row.key in CONFIRM_ACTIONS or is_long_confirm_action(row.key) or
row.key in (PIP_RESET, PIP_EDITOR, VASM_RESET, VASM_ANNOTATION) or row.key.startswith(PIP_FORMAT_PREFIX)),
@@ -338,12 +347,12 @@ def _projection(row: FeatureRow, page: str) -> dict:
def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
key = request.key
if key == "UseStarPilotLongitudinalPlanner":
return f"Save {row.label} as {request.value}? This applies on the next drive; supported longitudinal control stays active."
return f"Select {request.value} for {row.label.lower()}? The selected planner starts with the next driving process."
if key in ("CustomCruise", "CustomCruiseLong"):
if row.capability and row.capability[0] == "toyota":
return f"Save {row.label.lower()} as {request.value}? Toyota cruise preferences refresh while this compatible drive is active."
value = f"{request.value} {row.unit}" if row.unit else request.value
return f"Save {row.label.lower()} as {value}? This applies on the next drive when StarPilot controls cruise speed."
return f"Set {row.label.lower()} to {value}? Cruise-button increments refresh during the drive."
if key in SOUND_SPECS:
value = request.value + ("%" if request.value != "Auto" and not request.value.endswith("%") else "")
return f"Save {row.label.lower()} as {value}? The normal alert sound still decides when it plays."
@@ -396,7 +405,7 @@ def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
if key == "torque_rebase":
return f"Reapply saved torque values ({row.value}) with the current vehicle tune?"
if key == "LateralControllerSelection":
return f"Save {request.value} for the next drive? Manual torque adjustments are separate."
return f"Select {request.value}? The controller and its feedback state are created when the driving process starts."
if key == SETUP_ACTION:
return SETUP_QUESTION
if key.startswith("torque_"):
@@ -405,15 +414,11 @@ def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
from openpilot.starpilot.ui.long_profile_feature import long_confirm_question
return long_confirm_question(row)
if key.startswith("lane_change:"):
if key == "lane_change:reset":
return "Restore stock driver-nudged lane-change settings for the next drive?"
unit = request.display_unit or row.unit
value = f"{request.value} {unit}" if unit else request.value
return f"Save {row.label.lower()} as {value} for the next drive? Blindspot checks remain required."
return lane_change_question(request)
if key in VEHICLE_BOOL_KEYS:
return vehicle_question(request)
if key == "ToyotaAutoHold":
return f"Save automatic brake hold as {request.value} for the next startup?"
return f"Set automatic brake hold to {request.value}? The next startup configures the required brake-hold safety permissions."
value = f"{request.value} {row.unit}".rstrip()
return f"Set {row.label.lower()} to {value}?"
@@ -593,7 +598,7 @@ class SettingsGateway:
return result
def preview(self, view_id: str, index: int, direction: int, token: str, generation: bytes,
*, draft: dict | None = None, value: str | int | float | None = None, reset_default: bool = False) -> dict:
*, draft: dict | None = None, value: str | int | float | None = None, reset_default: bool = False, recovery_default: bool = False) -> dict:
with self.lock:
self._clean()
view = self.views.get(view_id)
@@ -607,7 +612,9 @@ class SettingsGateway:
if state.rows != view.rows:
raise SettingsChanged("Saved settings changed")
row = state.rows[index]
if not row.available or not (ctx.parked or _onroad_preference(view.page, row.key)):
if ((not row.available and not (reset_default and recovery_default and ctx.parked and
row_default(row, allow_inactive=True) is not None)) or
not (ctx.parked or _onroad_preference(view.page, row.key))):
raise SettingsChanged("Settings are unavailable")
if draft is not None and not ((view.page == "vasm" and row.key == VASM_ANNOTATION) or
(view.page == "pip" and row.key == PIP_EDITOR)):
@@ -617,7 +624,7 @@ class SettingsGateway:
if reset_default:
if direction != 0 or value is not None or draft is not None:
raise SettingsChanged("Invalid default reset")
request = row_default(row)
request = row_default(row, allow_inactive=recovery_default)
if request is None:
raise SettingsChanged("Default is unavailable")
elif value is not None:
@@ -683,7 +690,7 @@ class SettingsGateway:
self.intents.pop(next(iter(self.intents)))
intent_id = secrets.token_urlsafe(24)
self.intents[intent_id] = _Intent(_session_key(token), generation, view.page, request, ctx.cp_raw, self.clock() + TTL,
row if reset_default else None)
row if reset_default else None, recovery_default)
question = _question(row, request)
if reset_default:
question = f"Reset {row.label} to its default? " + question
@@ -702,7 +709,7 @@ class SettingsGateway:
raise SettingsChanged("Vehicle changed or this operation requires offroad mode")
if intent.reset_row is not None:
fresh_rows = self._state(intent.page, ctx).rows
if intent.reset_row not in fresh_rows or row_default(intent.reset_row) != intent.request:
if intent.reset_row not in fresh_rows or row_default(intent.reset_row, allow_inactive=intent.recovery_default) != intent.request:
raise SettingsChanged("Default or saved settings changed")
if intent.page == "pip":
return self._pip_owner(ctx, live=True, session_valid=session_valid).apply(intent.request)
@@ -716,4 +723,5 @@ class SettingsGateway:
return self._sentry_owner(ctx, live=True, session_valid=session_valid).apply(intent.request)
if intent.page == "vasm":
return self._vasm_owner(ctx, live=True, session_valid=session_valid).apply(intent.request)
return self._owner(ctx, live=True, session_valid=session_valid).apply(intent.request)
owner = self._owner(ctx, live=True, session_valid=session_valid)
return owner.apply(intent.request, reset_default=True) if intent.recovery_default else owner.apply(intent.request)
@@ -144,9 +144,10 @@ class SoftwareOperations:
self.params = params if params is not None else Params()
self.status = status if status is not None else SoftwareStatus(self.params)
if parked is None:
from openpilot.starpilot.galaxy.settings import LiveContextSource
self.context = LiveContextSource(self.params)
self.parked = self.context.parked
from openpilot.starpilot.drive_state.evidence import PhysicalSource
self.context = PhysicalSource()
physical = self.context
self.parked = lambda: physical.update_ready(self._flag('IsOffroad') is True)
else:
self.context = None
self.parked = parked
@@ -300,7 +301,14 @@ class SoftwareOperations:
fresh = self.refresh is None or self.refresh() is True
except (OSError, RuntimeError, ValueError, TypeError):
fresh = False
parked = fresh and self._parked()
if not fresh:
admission = {'allowed': False, 'reason': 'Current vehicle status refresh failed'}
elif self.context is not None:
admission = self.context.update_status(self._flag('IsOffroad') is True)
else:
admission = {'allowed': self._parked(),
'reason': 'Switch the device Offroad and confirm the vehicle is parked before updating'}
parked = bool(admission['allowed'])
branches = self._branches()
updater_idle = status["updater"]["state"] == "idle"
pending = self.request is not None and self.request["state"] == "pending"
@@ -312,8 +320,7 @@ class SoftwareOperations:
selected = target is not None and target in branches and status["updater"]["targetBranch"] == target
reason = None
if not parked:
reason = ("Waiting for current offroad status before allowing updates" if not fresh or self._flag("IsOffroad") is True
else "Park the vehicle to manage updates")
reason = admission['reason']
elif disabled:
reason = "Updates are disabled"
elif reboot:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,100 @@
import os
import threading
import pytest
from openpilot.common.hardware.hw import Paths
from openpilot.system import crash_storage, sentry
from openpilot.starpilot.galaxy import crash_reports
def test_writer_unit_excludes_delete_until_summary_is_complete(tmp_path, monkeypatch):
monkeypatch.setattr(Paths, 'log_root', lambda: str(tmp_path))
sentry.save_exception('old trace')
reader = crash_reports.CrashReports(tmp_path / 'crash')
old = next(report for report in reader.list()['reports'] if report['name'].endswith('.log'))
paused, release, attempted = threading.Event(), threading.Event(), threading.Event()
errors, inspected = [], []
original_write, original_lock = sentry.write_text, crash_reports.acquire_lock
original_summary = reader._latest_summary
def write(directory, name, text):
if name == 'error.txt' and text == 'new trace':
paused.set()
assert release.wait(2)
return original_write(directory, name, text)
def lock(directory, **kwargs):
attempted.set()
return original_lock(directory, **kwargs)
def summary(*args):
inspected.append(True)
return original_summary(*args)
def run(call):
try:
call()
except BaseException as error:
errors.append(error)
monkeypatch.setattr(sentry, 'write_text', write)
monkeypatch.setattr(crash_reports, 'acquire_lock', lock)
monkeypatch.setattr(reader, '_latest_summary', summary)
writing = threading.Thread(target=run, args=(lambda: sentry.save_exception('new trace'),))
deleting = threading.Thread(target=run, args=(lambda: reader.delete(old['id']),))
writing.start()
try:
assert paused.wait(1)
deleting.start()
assert attempted.wait(1)
assert inspected == []
assert (reader.directory / 'error.txt').read_text() == 'old trace'
finally:
release.set()
writing.join(2)
if deleting.ident is not None:
deleting.join(2)
assert not writing.is_alive() and not deleting.is_alive() and errors == []
assert (reader.directory / 'error.txt').read_text() == 'new trace'
assert not (reader.directory / old['name']).exists()
assert reader.delete(old['id'])['reportCount'] == 2
assert (reader.directory / 'error.txt').read_text() == 'new trace'
lock_inode = (reader.directory / '.lock').stat().st_ino
assert reader.delete()['reportCount'] == 0
assert list(reader.directory.iterdir()) == [reader.directory / '.lock']
assert (reader.directory / '.lock').stat().st_ino == lock_inode
def test_lock_wait_is_bounded_and_copy_collection_remains_functional(tmp_path):
directory = crash_storage.open_directory(tmp_path / 'crash', create=True)
lock = crash_storage.acquire_lock(directory)
try:
with pytest.raises(BlockingIOError):
crash_storage.acquire_lock(directory, timeout=0)
finally:
os.close(lock)
os.close(directory)
source = tmp_path / 'tombstone'
source.write_bytes(b'actual tombstone bytes')
with crash_storage.writer(tmp_path / 'crash') as directory:
crash_storage.copy_report(directory, 'tombstone-report', source)
reader = crash_reports.CrashReports(tmp_path / 'crash')
assert reader.list()['reportCount'] == 1
assert (reader.directory / 'tombstone-report').read_bytes() == source.read_bytes()
assert reader.delete()['storageBytes'] == 0
def test_reserved_lock_token_cannot_preview_or_remove_retained_inode(tmp_path):
with crash_storage.writer(tmp_path / 'crash') as directory:
crash_storage.write_text(directory, 'report', 'saved report')
reader = crash_reports.CrashReports(tmp_path / 'crash')
lock = reader.directory / '.lock'
original = lock.stat()
token = reader._token('.lock', reader.directory.stat(), original)
with pytest.raises(crash_reports.CrashMissing):
reader.preview(token)
with pytest.raises(crash_reports.CrashMissing):
reader.delete(token)
assert lock.stat().st_ino == original.st_ino
assert reader.list()['reportCount'] == 1
@@ -0,0 +1,126 @@
from dataclasses import replace
from unittest import TestCase
from openpilot.starpilot.galaxy.defaults_recovery import DefaultsRecovery
from openpilot.starpilot.galaxy.settings import SettingsChanged
from openpilot.starpilot.galaxy.tests import test_settings as fixtures
class TestCanonicalRecovery(TestCase):
def setUp(self):
fixtures.SettingsGatewayTest.setUp(self)
from opendbc.car.car_helpers import interfaces
from opendbc.car.toyota.values import CAR
cp = interfaces[CAR.TOYOTA_COROLLA_TSS2].get_non_essential_params(CAR.TOYOTA_COROLLA_TSS2)
cp.openpilotLongitudinalControl = True
cp.pcmCruise = False
self.context.value = replace(self.context.value, cp=cp)
def recovery(self):
return DefaultsRecovery(self.gateway)
def test_real_owner_defaults_and_selected_reset_preserve_runtime_state(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.params.put_bool('PedalsOnUI', True, block=True)
self.params.put_bool('StockConfidenceBallWidget', True, block=True)
self.params.put_bool('IsOffroad', True, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
selected = [row['id'] for row in view['rows'] if row['label'] == 'Wheel brake and acceleration colors']
self.assertEqual(len(selected), 1)
row = next(row for row in view['rows'] if row['id'] == selected[0])
self.assertEqual(row['default'], 'Off')
self.assertNotIn('IsOffroad', [row['label'] for row in view['rows']])
pending = recovery.preview(view['view'], selected, self.session, self.generation)
self.assertTrue(self.params.get_bool('ShowBrakeStatus'))
result = recovery.confirm(pending['intent'], self.session, self.generation)
self.assertTrue(result['saved'])
self.assertFalse(self.params.get_bool('ShowBrakeStatus'))
self.assertFalse(self.params.get_bool('PedalsOnUI'))
self.assertTrue(self.params.get_bool('StockConfidenceBallWidget'))
self.assertTrue(self.params.get_bool('IsOffroad'))
refreshed = recovery.snapshot(self.session, self.generation)
self.assertNotIn(row['label'], [r['label'] for r in refreshed['rows']])
def test_confirmation_rechecks_real_saved_sources_before_any_reset(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
selected = [row['id'] for row in view['rows'] if row['label'] == 'Wheel brake and acceleration colors']
pending = recovery.preview(view['view'], selected, self.session, self.generation)
self.params.put_bool('PedalsOnUI', True, block=True)
with self.assertRaises(SettingsChanged):
recovery.confirm(pending['intent'], self.session, self.generation)
self.assertTrue(self.params.get_bool('ShowBrakeStatus'))
def test_selected_and_all_reset_use_the_same_owner_rows(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.params.put_bool('StockConfidenceBallWidget', True, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
pending = recovery.preview(view['view'], [r['id'] for r in view['rows']], self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertEqual(recovery.snapshot(self.session, self.generation)['count'], 0)
def test_disabled_master_children_remain_visible_and_reset_without_enabling_master(self):
self.params.put_bool('SLCConfirmation', False, block=True)
self.params.put_bool('SLCConfirmationLower', True, block=True)
self.params.put_bool('SLCConfirmationHigher', True, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
children = [row for row in view['rows'] if row['label'] in ('Confirm lower limits', 'Confirm higher limits')]
self.assertEqual(len(children), 2)
self.assertTrue(all(not row['available'] for row in children))
pending = recovery.preview(view['view'], [row['id'] for row in children], self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertFalse(self.params.get_bool('SLCConfirmation'))
self.assertFalse(self.params.get_bool('SLCConfirmationLower'))
self.assertFalse(self.params.get_bool('SLCConfirmationHigher'))
def test_reset_all_master_and_children_does_not_leave_hidden_customization(self):
for key in ('SLCConfirmation', 'SLCConfirmationLower', 'SLCConfirmationHigher'):
self.params.put_bool(key, True, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
selected = [row['id'] for row in view['rows'] if row['label'] in
('Require confirmation', 'Confirm lower limits', 'Confirm higher limits')]
self.assertEqual(len(selected), 3)
pending = recovery.preview(view['view'], selected, self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
for key in ('SLCConfirmation', 'SLCConfirmationLower', 'SLCConfirmationHigher'):
self.assertFalse(self.params.get_bool(key))
def test_unsupported_rows_remain_visible_but_cannot_block_available_reset_all(self):
from dataclasses import replace
self.params.put_bool('SLCConfirmation', True, block=True)
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.context.value = replace(self.context.value, cp=None, cp_raw=None)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
unsupported = next(row for row in view['rows'] if row['label'] == 'Require confirmation')
self.assertFalse(unsupported['resetAvailable'])
with self.assertRaises(SettingsChanged):
recovery.preview(view['view'], [unsupported['id']], self.session, self.generation)
selected = [row['id'] for row in view['rows'] if row['resetAvailable']]
self.assertTrue(selected)
pending = recovery.preview(view['view'], selected, self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertTrue(self.params.get_bool('SLCConfirmation'))
self.assertFalse(self.params.get_bool('ShowBrakeStatus'))
def test_other_canonical_inactive_children_reset_without_turning_their_masters_on(self):
self.params.put_bool('LaneCentering', False, block=True)
self.params.put_bool('LaneCenteringPauseOnSignal', False, block=True)
self.params.put_bool('ForceStops', False, block=True)
self.params.put('ForceStopDistanceOffset', 5, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
selected = [row['id'] for row in view['rows'] if row['label'] in ('Pause on signal', 'Stop Distance Adjustment')]
self.assertEqual(len(selected), 2)
self.assertTrue(all(row['resetAvailable'] for row in view['rows'] if row['id'] in selected))
pending = recovery.preview(view['view'], selected, self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertFalse(self.params.get_bool('LaneCentering'))
self.assertTrue(self.params.get_bool('LaneCenteringPauseOnSignal'))
self.assertFalse(self.params.get_bool('ForceStops'))
self.assertEqual(self.params.get('ForceStopDistanceOffset'), 0)
@@ -0,0 +1,100 @@
from dataclasses import replace
from unittest import TestCase
from openpilot.starpilot.galaxy.tests import test_settings as fixtures
class TestRecoveryHttp(TestCase):
def setUp(self):
fixtures.SettingsHttpTest.setUp(self)
from opendbc.car.car_helpers import interfaces
from opendbc.car.toyota.values import CAR
cp = interfaces[CAR.TOYOTA_COROLLA_TSS2].get_non_essential_params(CAR.TOYOTA_COROLLA_TSS2)
cp.openpilotLongitudinalControl = True
cp.pcmCruise = False
self.context.value = replace(self.context.value, cp=cp)
stop = fixtures.SettingsHttpTest.stop
request = fixtures.SettingsHttpTest.request
login = fixtures.SettingsHttpTest.login
def selection(self, cookie, labels):
status, value, _ = self.request('/api/settings/non-defaults', cookie=cookie)
self.assertEqual(status, 200)
selected = [row['id'] for row in value['rows'] if row['label'] in labels]
self.assertEqual(len(selected), len(labels))
return {'view': value['view'], 'selected': selected}
def test_authenticated_individual_reset_requires_real_confirmation_and_refresh(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.assertEqual(self.request('/api/settings/non-defaults')[0], 401)
cookie = self.login()
payload = self.selection(cookie, ['Wheel brake and acceleration colors'])
self.assertEqual(self.request('/api/settings/recovery/preview', payload=payload)[0], 401)
self.assertEqual(self.request('/api/settings/recovery/preview', cookie=cookie, payload=payload,
origin='https://elsewhere.example')[0], 403)
status, pending, _ = self.request('/api/settings/recovery/preview', cookie=cookie, payload=payload)
self.assertEqual(status, 200)
self.assertTrue(self.params.get_bool('ShowBrakeStatus'))
body = {'intent': pending['intent'], 'confirmed': True}
self.assertEqual(self.request('/api/settings/recovery/confirm', cookie=cookie, payload=body)[0], 200)
self.assertFalse(self.params.get_bool('ShowBrakeStatus'))
self.assertEqual(self.request('/api/settings/recovery/confirm', cookie=cookie, payload=body)[0], 409)
rows = self.request('/api/settings/non-defaults', cookie=cookie)[1]['rows']
self.assertNotIn('Wheel brake and acceleration colors', [row['label'] for row in rows])
def test_disabled_children_are_visible_and_all_reset_uses_canonical_defaults(self):
for key in ('SLCConfirmation', 'SLCConfirmationLower', 'SLCConfirmationHigher'):
self.params.put_bool(key, True, block=True)
cookie = self.login()
payload = self.selection(cookie, ['Require confirmation', 'Confirm lower limits', 'Confirm higher limits'])
status, pending, _ = self.request('/api/settings/recovery/preview', cookie=cookie, payload=payload)
self.assertEqual(status, 200)
result = self.request('/api/settings/recovery/confirm', cookie=cookie,
payload={'intent': pending['intent'], 'confirmed': True})
self.assertEqual(result[0], 200)
self.assertTrue(result[1]['saved'])
for key in ('SLCConfirmation', 'SLCConfirmationLower', 'SLCConfirmationHigher'):
self.assertFalse(self.params.get_bool(key))
def test_wrong_selection_and_unconfirmed_requests_are_rejected(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
cookie = self.login()
payload = self.selection(cookie, ['Wheel brake and acceleration colors'])
for invalid in (payload | {'selected': [True]}, payload | {'selected': [{}]}, payload | {'value': 'Off'}):
self.assertEqual(self.request('/api/settings/recovery/preview', cookie=cookie, payload=invalid)[0], 400)
pending = self.request('/api/settings/recovery/preview', cookie=cookie, payload=payload)[1]
self.assertEqual(self.request('/api/settings/recovery/confirm', cookie=cookie,
payload={'intent': pending['intent'], 'confirmed': False})[0], 400)
self.assertTrue(self.params.get_bool('ShowBrakeStatus'))
def test_vehicle_or_saved_source_change_revokes_batch_before_any_write(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
cookie = self.login()
payload = self.selection(cookie, ['Wheel brake and acceleration colors'])
pending = self.request('/api/settings/recovery/preview', cookie=cookie, payload=payload)[1]
self.context.value = replace(self.context.value, cp_raw=b'changed')
self.assertEqual(self.request('/api/settings/recovery/confirm', cookie=cookie,
payload={'intent': pending['intent'], 'confirmed': True})[0], 409)
self.assertTrue(self.params.get_bool('ShowBrakeStatus'))
def test_unsupported_rows_are_reported_and_available_reset_all_does_not_touch_them(self):
self.params.put_bool('SLCConfirmation', True, block=True)
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.context.value = replace(self.context.value, cp=None, cp_raw=None)
cookie = self.login()
status, view, _ = self.request('/api/settings/non-defaults', cookie=cookie)
self.assertEqual(status, 200)
unsupported = next(row for row in view['rows'] if row['label'] == 'Require confirmation')
self.assertFalse(unsupported['resetAvailable'])
self.assertEqual(self.request('/api/settings/recovery/preview', cookie=cookie,
payload={'view': view['view'], 'selected': [unsupported['id']]})[0], 409)
selected = [row['id'] for row in view['rows'] if row['resetAvailable']]
pending = self.request('/api/settings/recovery/preview', cookie=cookie,
payload={'view': view['view'], 'selected': selected})[1]
result = self.request('/api/settings/recovery/confirm', cookie=cookie,
payload={'intent': pending['intent'], 'confirmed': True})
self.assertEqual(result[0], 200)
self.assertTrue(result[1]['saved'])
self.assertFalse(self.params.get_bool('ShowBrakeStatus'))
self.assertTrue(self.params.get_bool('SLCConfirmation'))
@@ -34,6 +34,8 @@ globalThis.window={confirm:()=>false}
const before=requests.length
await onroad.change('offroad');assert.equal(requests.length,before)
window.confirm=()=>true
await onroad.change('offroad');assert.equal(requests.length,before)
onroad.state.overrideAllowed=true
const stop=onroad.change('offroad')
assert.equal(requests.length,before+1)
assert.deepEqual(JSON.parse(requests.at(-1).options.body),{mode:'offroad',revision:'a'.repeat(32)})
@@ -0,0 +1,182 @@
"""Actual lane consumers and GM emitted demands; root runs under native custody."""
from dataclasses import replace
import inspect
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from opendbc.car.structs import car
from openpilot.cereal import log
from openpilot.starpilot.lateral.lane_change_preferences import KEY, LaneChangePolicy, LiveLaneChangePolicy, to_value
from openpilot.starpilot.lateral.lane_change_smoothing import LaneChangeSmoother, limit_rate
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
class PathParams:
def __init__(self, directory):
self.directory = Path(directory)
def get_param_path(self, key):
return str(self.directory / key)
def put(self, key, raw):
(self.directory / key).write_bytes(raw)
def save(params, policy):
params.put(KEY, json.dumps(to_value(policy)).encode())
def test_actual_modeld_and_controls_consume_same_live_document(tmp_path):
from openpilot.selfdrive.modeld.modeld import refresh_lane_change_policy, main
from openpilot.selfdrive.controls.controlsd import Controls
params = PathParams(tmp_path)
initial = LaneChangePolicy(duration_s=6.)
save(params, initial)
dh = DesireHelper()
model_cache = LiveLaneChangePolicy(params, now_ns=1_000_000_000)
controls = SimpleNamespace(lane_change_preferences=LiveLaneChangePolicy(params, now_ns=1_000_000_000))
cs = car.CarState.new_message(vEgo=20., leftBlinker=True)
model = SimpleNamespace(meta=SimpleNamespace(laneChangeState=log.LaneChangeState.off))
def step(now, *, nudge=False, evidence=False):
cs.steeringPressed, cs.steeringTorque = nudge, 1. if nudge else 0.
refresh_lane_change_policy(model_cache, dh, cs, now, True)
dh.update(cs, True, 1., auto_evidence=evidence, engaged=True)
model.meta.laneChangeState = dh.lane_change_state
return Controls.refresh_lane_change_policy(controls, cs, model, now)
step(1_010_000_000)
assert dh.lane_change_state == log.LaneChangeState.preLaneChange
cs.leftBlindspot = True
step(1_020_000_000, nudge=True)
assert dh.lane_change_state == log.LaneChangeState.preLaneChange
cs.leftBlindspot = False
original = step(1_030_000_000, nudge=True)
assert dh.lane_change_state == log.LaneChangeState.laneChangeStarting
changed = replace(initial, duration_s=8., minimum_speed_mps=25., auto_lane_change=True, auto_delay_s=0.)
save(params, changed)
assert step(2_040_000_000, evidence=True) == original
assert dh.policy == original # No pace/minimum/auto change in a committed maneuver.
smoother = LaneChangeSmoother()
factor = smoother.factor(active=True, lane_change_state=dh.lane_change_state, speed=cs.vEgo,
minimum_speed=original.minimum_speed_mps, duration=original.duration_s,
previous=0., desired=.01)
shaped = limit_rate(cs.vEgo, 0., .01, factor)
output, _ = clip_curvature(cs.vEgo, 0., shaped, 0.)
assert 0 < output <= 5. / cs.vEgo ** 2 * .01
save(params, replace(changed, enabled=False))
assert not step(3_050_000_000).enabled
assert dh.lane_change_state == log.LaneChangeState.off
save(params, changed)
assert not step(4_060_000_000, evidence=True).enabled # Held signal cannot reauthorize.
cs.leftBlinker = False
adopted = step(4_070_000_000)
assert adopted.duration_s == 8. and dh.policy.minimum_speed_mps == 25.
cs.vEgo = 30.
cs.leftBlinker = True
step(4_080_000_000, evidence=True)
step(4_090_000_000, evidence=True)
assert dh.lane_change_state == log.LaneChangeState.laneChangeStarting
# The actual driving loops call these helpers before DesireHelper/smoother consumption.
assert 'refresh_lane_change_policy(lane_preferences, DH, car_state' in inspect.getsource(main)
state_control = inspect.getsource(Controls.state_control)
assert state_control.index('self.refresh_lane_change_policy(') < state_control.index('self.lane_change_smoother.factor(')
def test_invalid_document_clock_rollback_and_bounded_reads(tmp_path):
from openpilot.starpilot.lateral import lane_change_preferences as preferences
params = PathParams(tmp_path)
save(params, LaneChangePolicy(auto_lane_change=True))
with patch.object(preferences, 'read_saved', wraps=preferences.read_saved) as read:
cache = LiveLaneChangePolicy(params, now_ns=1_000_000_000)
for tick in range(99):
assert cache.sample(1_000_000_000 + tick * 10_000_000).enabled
assert read.call_count == 1
params.put(KEY, b'{"enabled": true}')
assert not cache.sample(2_000_000_000).enabled
assert not cache.sample(1_999_000_000).enabled
assert not cache.sample(0).enabled
save(params, LaneChangePolicy())
assert not cache.sample(3_000_000_000, signaling=True).enabled
assert cache.sample(3_010_000_000).enabled
def test_grade_worker_live_preference_reaches_actual_gm_can(tmp_path):
from opendbc.car.gm.carcontroller import CarController
from opendbc.car.gm.tests.test_ascm_intercept import params as vehicle_params
from opendbc.car.gm.tests import test_volt_grade as fixture
from opendbc.car.gm.values import CAR
from openpilot.starpilot.car.gm.tune_preferences import GradeCompensationPreference
params = PathParams(tmp_path)
params.put('OpenpilotEnabledToggle', b'1')
preference = GradeCompensationPreference(params)
cp = vehicle_params(CAR.CHEVROLET_BOLT_EUV, alpha=True, radar=False, sascm=False)
assert cp.openpilotLongitudinalControl
safety = (int(cp.flags), int(cp.alternativeExperience), tuple((s.safetyModel.raw, s.safetyParam) for s in cp.safetyConfigs))
def emit(enabled):
def create(*args):
controller = CarController(*args)
controller.long_pitch = enabled
return controller
with patch.object(fixture, 'CarController', side_effect=create):
actual, messages = fixture.command(cp, accel=-.5, speed=12., orientation=[0., -.03, 0.], frame=4)
return (actual.apply_gas, actual.apply_brake), [m for m in messages if m[0] in (0x2cb, 0x315)]
on = emit(preference.update(1_000_000_000))
params.put('LongPitch', b'0')
assert preference.update(1_500_000_000) # No reads between worker refreshes.
off = emit(preference.update(2_000_000_000))
assert on[1] and off[1] # Prove both branches emitted actual gas/brake CAN.
assert on != off
params.put('LongPitch', b'1')
assert emit(preference.update(3_000_000_000)) == on
params.put('LongPitch', b'invalid')
assert emit(preference.update(4_000_000_000)) == on
assert safety == (int(cp.flags), int(cp.alternativeExperience), tuple((s.safetyModel.raw, s.safetyParam) for s in cp.safetyConfigs))
from openpilot.selfdrive.car.card import Car
assert 'pitch_input.update(time.monotonic_ns())' in inspect.getsource(Car.params_thread)
update = inspect.getsource(CarController.update)
assert 'long_pitch = self.long_pitch' in update
assert 'get_param_path' not in update and 'GradeCompensationPreference' not in update
def test_actual_card_worker_publishes_grade_without_controller_params_reads(tmp_path):
import threading
from opendbc.car.gm.carcontroller import CarController
from opendbc.car.gm.tests.test_ascm_intercept import params as vehicle_params
from opendbc.car.gm.values import CAR, DBC
from openpilot.selfdrive.car import card
from openpilot.starpilot.car.gm.tune_preferences import GradeCompensationPreference
saved = PathParams(tmp_path)
saved.put('OpenpilotEnabledToggle', b'1')
saved.put('LongPitch', b'0')
cp = vehicle_params(CAR.CHEVROLET_BOLT_EUV)
cc = CarController(DBC[cp.carFingerprint], cp)
cc.long_pitch_input = GradeCompensationPreference(saved)
worker = card.Car.__new__(card.Car)
worker.CP, worker.CI = cp, SimpleNamespace(CC=cc)
worker.params = SimpleNamespace(get_bool=lambda key: False)
worker.aol_card_intent = None
worker.v_cruise_helper = SimpleNamespace(intervals=None)
event = threading.Event()
def publish(now_ns):
event.clear()
with patch.object(card.Car, 'read_slc_configuration', return_value=None), \
patch.object(card, 'read_cruise_intervals', return_value='unchanged-cruise-intervals'), \
patch.object(card.time, 'monotonic', return_value=now_ns / 1e9), \
patch.object(card.time, 'monotonic_ns', return_value=now_ns), \
patch.object(card.time, 'sleep', side_effect=lambda seconds: event.set()):
card.Car.params_thread(worker, event)
assert worker.v_cruise_helper.intervals == 'unchanged-cruise-intervals'
publish(1_000_000_000)
assert not cc.long_pitch
saved.put('LongPitch', b'1')
publish(2_000_000_000)
assert cc.long_pitch
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { pathToFileURL } from 'node:url'
import path from 'node:path'
const root = process.env.OFFLINE_MAPS_ROOT || '/work'
const { validOperation, formatBytes, MapOperationsPanel } = await import(pathToFileURL(path.join(root, 'openpilot/starpilot/galaxy/web/js/map-operations.js')))
const generation = 'a'.repeat(64)
const status = { schemaVersion: 1, ownerSession: 'owner', operationId: 'owner:1', state: 'completed', regionToken: 'us_state.Oklahoma',
bounds: [35, -98, 35.25, -97.75], completedGroups: 1, totalGroups: 1, transferredBytes: 256, transferBudgetBytes: 1024,
preparedGeneration: generation, selectedGeneration: generation, errorCode: null, selectedForNextShadowStart: true,
selectedSnapshot: { generation, storedBytes: 1024, tileCount: 1, bounds: [35, -98, 35.25, -97.75] } }
assert.equal(validOperation(status), true)
for (const info of [undefined, null, { ...status.selectedSnapshot, storedBytes: 0 },
{ ...status.selectedSnapshot, tileCount: 0 }, { ...status.selectedSnapshot, generation: 'b'.repeat(64) },
{ ...status.selectedSnapshot, bounds: null }, { ...status.selectedSnapshot, storedBytes: Number.NaN }]) {
assert.equal(validOperation({ ...status, selectedSnapshot: info }), false)
}
assert.equal(formatBytes(512), '512 B')
assert.equal(formatBytes(1024), '1.0 KiB')
assert.equal(formatBytes(2 * 1024 ** 2), '2.0 MiB')
assert.equal(formatBytes(2 * 1024 ** 3), '2.0 GiB')
assert.match(MapOperationsPanel.template, /Offline map database/)
assert.match(MapOperationsPanel.template, /selectedSnapshot.storedBytes/)
assert.match(MapOperationsPanel.template, /selectedSnapshot.tileCount/)
assert.doesNotMatch(MapOperationsPanel.template, /Download complete\. This map will be used/)
console.log('PASS: nonempty package identity required for completed projection; actual size and units rendered')
@@ -131,12 +131,16 @@ function editor() {
$refs: { preview: { ...target, getBoundingClientRect: () => ({ left: 100, top: 100, width: 1080, height: 540 }) } } }
for (const [key, getter] of Object.entries(OnroadLayoutPage.computed)) Object.defineProperty(vm, key, { get: () => getter.call(vm) })
for (const [key, method] of Object.entries(OnroadLayoutPage.methods)) vm[key] = method.bind(vm)
// This editor fixture exercises interaction; native request/race behavior has its own actual feed tests.
vm.artFeed.start = () => { vm.artFeed.active = true }
vm.artFeed.update = () => {}
vm.artFeed.stop = () => { vm.artFeed.active = false }
vm.state.data = snapshot(); vm.state.data.supportedProfiles = ['large', 'compact']; vm.state.draft = copy(vm.state.data.document); vm.state.status = "ready"; vm.state.selected = "current_speed"
const publish = vm.feed.publish
vm.feed = { load: () => calls.push("load"), save: (document) => calls.push(copy(document)) }
vm.previewFeed.start = () => previewCalls.push("start")
vm.previewFeed.start = () => { vm.previewFeed.active = true; previewCalls.push("start") }
vm.previewFeed.update = (...args) => previewCalls.push(["update", ...args])
vm.previewFeed.stop = () => previewCalls.push("stop")
vm.previewFeed.stop = () => { vm.previewFeed.active = false; previewCalls.push("stop") }
const event = (x, y, pointerId = 1) => ({ clientX: x, clientY: y, pointerId, button: 0, currentTarget: { setPointerCapture() { throw new Error("Capture must belong to the stable preview") } }, preventDefault() {} })
return { vm, event, emitted, calls, previewCalls, captures, publish }
}
@@ -297,8 +301,9 @@ const draftBeforePreview = copy(vm.state.draft)
vm.mode = "local"
vm.showDevicePreview()
assert.equal(vm.state.devicePreviewOpen, true)
assert.equal(vm.state.previewMode, "device")
assert.deepEqual(previewCalls.map((call) => Array.isArray(call) ? call[0] : call), ["start", "update"])
assert.deepEqual(previewCalls[1].slice(1), [draftBeforePreview, "large", "engaged"])
assert.deepEqual(previewCalls[1].slice(1), [draftBeforePreview, "large", "engaged", false])
vm.selectProfile("compact")
assert.equal(previewCalls.at(-1)[2], "compact")
vm.state.scene = "braking"
@@ -306,6 +311,7 @@ assert.equal(previewCalls.at(-1)[3], "braking")
vm.selectProfile("large")
vm.hideDevicePreview()
assert.equal(vm.state.devicePreviewOpen, false)
assert.equal(vm.previewFeed.active, false)
assert.equal(previewCalls.at(-1), "stop")
assert.deepEqual(copy(vm.state.draft), draftBeforePreview)
const previewCount = previewCalls.length
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { deleteRecords, storedSize } from '../web/js/record-deletion.js'
const empty = { schemaVersion: 1, reports: [], reportCount: 0, storageBytes: 0, more: false }
function fixture(replies = [empty]) {
const states = [], requests = []
const feed = { active: true, generation: 0, request: null, deleting: false,
poller: { stop() {}, start() {} }, later: () => 1, cancel: () => {},
publish: state => states.push(state), stop() { this.active = false; this.generation++; this.request?.abort() }, unauthorized() {},
fetcher: async (url, options) => { requests.push({ url, options }); return { ok: true, status: 200, json: async () => replies.shift() } } }
const accept = data => states.push(data)
const valid = data => data?.schemaVersion === 1 && Array.isArray(data.reports)
return { feed, requests, states, options: { accept, valid, subject: 'crash reports' } }
}
const cancel = fixture()
assert.equal(await deleteRecords(cancel.feed, '/delete', { all: true, confirmed: true }, { ...cancel.options, confirm: () => false }), false)
assert.equal(cancel.requests.length, 0)
const individual = fixture()
assert.equal(await deleteRecords(individual.feed, '/delete', { id: 'selected' }, individual.options), true)
assert.equal(individual.requests[0].options.method, 'POST')
assert.deepEqual(JSON.parse(individual.requests[0].options.body), { id: 'selected' })
assert.equal(individual.states.find(state => state.storageBytes === 0).reportCount, 0)
assert.equal(individual.states[0].preview, null)
assert.equal(individual.feed.deleting, false)
const batches = fixture([{ ...empty, more: true, storageBytes: 5 }, empty])
let message
assert.equal(await deleteRecords(batches.feed, '/delete', { all: true, confirmed: true }, {
...batches.options, confirm: value => { message = value; return true },
}), true)
assert.match(message, /Delete all crash reports.*cannot be undone/)
assert.equal(batches.requests.length, 2)
assert.deepEqual(batches.states.filter(state => state.storageBytes !== undefined).map(state => state.storageBytes), [5, 0])
const stale = fixture()
let reply
stale.feed.fetcher = () => new Promise(resolve => { reply = resolve })
const pending = deleteRecords(stale.feed, '/delete', { id: 'selected' }, stale.options)
stale.feed.stop()
reply({ ok: true, status: 200, json: async () => empty })
assert.equal(await pending, false)
assert.equal(stale.states.some(state => state.reports), false)
assert.equal(storedSize(7), '7 B')
assert.equal(storedSize(2048), '2.0 KB')
for (const file of ['sentry.js', 'logs.js']) {
const source = readFileSync(new URL('../web/js/' + file, import.meta.url), 'utf8')
assert.match(source, /Delete All/)
assert.match(source, />Delete<\/button>/)
assert.match(source, /storageBytes/)
assert.match(source, /:disabled="(?:deleting|crashes.deleting)"/)
assert.doesNotMatch(source, /v-html/)
}
console.log('record deletion confirmation, refresh, lifecycle and UI hooks passed')
const { SentryEventsFeed } = await import('../web/js/sentry.js')
const { CrashReportsFeed } = await import('../web/js/crash-reports.js')
for (const kind of ['sentry', 'crash']) {
const eventId = 'a'.repeat(32)
let inventory = kind === 'sentry'
? { schemaVersion: 1, source: 'local', scanIncomplete: false, capacity: 512, storageBytes: 10, storageIncomplete: false,
events: [{ eventId, kind: 'warning', systemTimeMs: 1700000000000 }] }
: { schemaVersion: 1, reports: [{ id: 'opaque-id', name: 'report', size: 10, modifiedAt: 1 }], reportCount: 1,
storageBytes: 10, storageIncomplete: false, scanIncomplete: false, listLimited: false }
const updates = [], calls = []
const Feed = kind === 'sentry' ? SentryEventsFeed : CrashReportsFeed
const feed = new Feed({ publish: update => updates.push(update), later: () => 1, cancel: () => {}, cancelTimer: () => {},
fetcher: async (url, options) => {
calls.push({ url, options })
if (options.method === 'POST') inventory = { ...inventory, events: [], reports: [], reportCount: 0, storageBytes: 0, more: false }
return { ok: true, status: 200, json: async () => inventory }
} })
await feed.start()
assert.equal(await feed.remove(kind === 'sentry' ? eventId : 'opaque-id'), true)
assert.equal(calls.at(-1).url, kind === 'sentry' ? './api/sentry/events/delete' : './api/crash-reports/delete')
assert.equal(kind === 'sentry' ? feed.data.events.length : feed.reports.length, 0)
assert.equal(kind === 'sentry' ? feed.data.storageBytes : updates.find(update => update.storageBytes === 0).storageBytes, 0)
feed.stop()
assert.equal(feed.deleting, false)
}
@@ -0,0 +1,69 @@
"""Authenticated real HTTP cleanup and destructive confirmation."""
import http.client
import json
import threading
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.crash_reports import CrashReports
from openpilot.starpilot.galaxy.sentry_events import SentryEvents
from openpilot.starpilot.galaxy.server import make_server
from openpilot.starpilot.sentry_mode.storage import EventStore
def test_record_deletion_auth_confirmation_refresh_and_idempotence(tmp_path):
store = EventStore(tmp_path / 'events')
receipt = store.record('warning', 1, permitted=lambda: True, images={'wide': b'\xff\xd8abc\xff\xd9'})
crash = tmp_path / 'crash'
crash.mkdir()
(crash / 'native-crash').write_bytes(b'broken\xffreport')
reports = CrashReports(crash)
token = reports.list()['reports'][0]['id']
access = GalaxyAccessOwner(tmp_path / 'access')
access.configure('password123', lambda: True)
server = make_server(port=0, owner=access, sentry_events=SentryEvents(store), crashes=reports, parked=lambda: True)
worker = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': .01}, daemon=True)
worker.start()
def request(path, payload, cookie=None, origin=None):
headers = {'Forwarded': 'for=203.0.113.8', 'Content-Type': 'application/json',
'Origin': origin or f'http://127.0.0.1:{server.server_port}'}
if cookie:
headers['Cookie'] = cookie
connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3)
try:
connection.request('POST', path, json.dumps(payload), headers)
response = connection.getresponse()
return response.status, json.loads(response.read()), dict(response.getheaders())
finally:
connection.close()
try:
for route in ('/api/sentry/events/delete', '/api/crash-reports/delete'):
assert request(route, {'all': True, 'confirmed': True})[0] == 401
assert (store.root / (receipt.event_id + '.json')).exists() and (crash / 'native-crash').exists()
status, _, headers = request('/api/auth/login', {'password': 'password123'})
assert status == 200
cookie = headers['Set-Cookie'].split(';', 1)[0]
for route in ('/api/sentry/events/delete', '/api/crash-reports/delete'):
assert request(route, {'all': True}, cookie)[0] == 400
assert request(route, {'all': True, 'confirmed': False}, cookie)[0] == 400
assert request(route, {'all': True, 'confirmed': True}, cookie, 'https://outside.invalid')[0] == 403
assert request('/api/sentry/events/delete', {'eventId': '../crash'}, cookie)[0] == 400
status, data, _ = request('/api/sentry/events/delete', {'eventId': receipt.event_id}, cookie)
assert status == 200 and data['events'] == [] and data['storageBytes'] == 0 and data['more'] is False
assert request('/api/sentry/events/delete', {'eventId': receipt.event_id}, cookie)[0] == 200
status, data, _ = request('/api/crash-reports/delete', {'id': token}, cookie)
assert status == 200 and data['reports'] == [] and data['reportCount'] == 0 and data['storageBytes'] == 0
assert request('/api/crash-reports/delete', {'id': token}, cookie)[0] == 200
fresh = store.record('alarm', 2, permitted=lambda: True)
(crash / 'new-crash').write_text('new collection')
for route in ('/api/sentry/events/delete', '/api/crash-reports/delete'):
status, data, _ = request(route, {'all': True, 'confirmed': True}, cookie)
assert status == 200 and data['storageBytes'] == 0 and data['more'] is False
assert not (store.root / (fresh.event_id + '.json')).exists() and list(crash.iterdir()) == [crash / '.lock']
assert request('/api/auth/logout', {}, cookie)[0] == 200
assert request('/api/crash-reports/delete', {'all': True, 'confirmed': True}, cookie)[0] == 401
finally:
server.shutdown()
server.server_close()
worker.join(2)
@@ -0,0 +1,113 @@
"""Purpose coverage for all source-expanded setting/action keys, without defaults."""
from dataclasses import replace
import json
from pathlib import Path
from openpilot.starpilot.galaxy.setting_descriptions import DESCRIPTIONS, INITIALIZATION, description, purpose
from openpilot.starpilot.ui.feature_settings_state import FeatureRow
from openpilot.starpilot.ui.long_profile_feature import CATEGORY_HELP, VALUE_HELP
from openpilot.starpilot.longitudinal.profile_preferences import SCALARS
from openpilot.starpilot.controllers.wheel_actions import KEYS as WHEEL_KEYS
from openpilot.starpilot.conditional_mode.actions import BOOLEAN_FIELDS, NUMBER_FIELDS
def inventory():
return set(json.loads((Path(__file__).parent / 'description-key-inventory.json').read_text()))
def test_all_expanded_description_keys_have_a_purpose_before_timing_or_warning():
keys = inventory()
assert set(DESCRIPTIONS) <= keys
for key in keys:
row = FeatureRow(key, 'Test action', 'On', reason='Saved for the next drive')
text = purpose(row)
assert text, key
first = description(row).split('.')[0].lower()
assert not first.startswith(('saved', 'warning', 'requires', 'applies')), key
assert not any(word in first for word in ('next drive', 'migration', 'does not', 'restart')), key
assert row.reason == 'Saved for the next drive' # Presentation does not mutate the canonical owner.
for key in INITIALIZATION:
assert INITIALIZATION[key] in description(FeatureRow(key, 'Setting', 'On'))
def test_generated_inventory_matches_actual_canonical_field_and_button_sources():
keys = inventory()
for scalars in SCALARS.values():
assert set(scalars) <= keys
assert {'long_repair:' + key for key in scalars} <= keys
assert {'wheel:' + key for key in WHEEL_KEYS} <= keys
for section in ('cem', 'ccm'):
assert {'conditional:' + section + ':' + field for field in
BOOLEAN_FIELDS[section] | NUMBER_FIELDS[section] | {'persist_manual'}} <= keys
for name in ('traffic', 'aggressive', 'standard', 'relaxed'):
for category in CATEGORY_HELP:
assert 'profile:' + name + ':' + category in keys
assert {'profile:' + name + ':' + category + ':' + str(index) for index in range(10)} <= keys
for suffix in VALUE_HELP:
assert all(name + suffix in keys for name in ('Aggressive', 'Standard', 'Relaxed'))
def test_real_warning_kept_after_purpose_and_false_blanket_next_drive_removed():
row = FeatureRow('LongPitch', 'Grade Compensation', 'Invalid', available=False,
reason='Invalid saved value; repair required')
assert description(row).startswith('Compensates acceleration and braking for road grade.')
assert description(row).endswith(row.reason)
assert 'next drive' not in description(replace(row, available=True, reason='Saved for the next drive'))
inactive = FeatureRow('TrafficFollow', 'Following', '1.2', reason='Saved values are inactive while Custom Driving Profiles is Off')
assert inactive.reason in description(inactive)
pending = FeatureRow('AlwaysOnLateral', 'Always On Lateral', 'On', reason='Applies after the next startup')
assert 'pending vehicle configuration' in description(pending)
assert 'pending vehicle configuration' not in description(replace(pending, reason='Keep steering assistance available'))
assert 'below' in purpose(FeatureRow('conditional:cem:speed_mps', 'Speed', '20'))
assert 'at or above' in purpose(FeatureRow('conditional:ccm:speed_mps', 'Speed', '20'))
confirmation = FeatureRow('SLCPriority1', 'Primary source', 'Vision',
reason='Use this source first; camera signs require your confirmation before changing cruise speed')
assert description(confirmation).startswith(purpose(confirmation))
assert 'require your confirmation' in description(confirmation)
ownership = FeatureRow('ForceAutoTuneOff', 'Automatic Steering Learning', 'On',
reason='Saved for this vehicle. StarPilot Controller uses its vehicle tune; automatic learning is currently unavailable.')
assert 'StarPilot Controller uses its vehicle tune' in description(ownership)
assert 'Saved for this vehicle' not in description(ownership)
maps = FeatureRow('SLCPriority2', 'Secondary source', 'Maps',
reason='Maps needs a verified provider and an installed offline map package')
assert maps.reason in description(maps)
def test_actual_owner_projections_have_no_uncovered_setting(tmp_path):
from opendbc.car.car_helpers import interfaces
from opendbc.car.gm.values import CAR as GM
from opendbc.car.hyundai.values import CAR as HYUNDAI
from opendbc.car.toyota.values import CAR as TOYOTA
from opendbc.car.ford.values import CAR as FORD
from openpilot.common.params import Params
from openpilot.starpilot.galaxy.settings import PAGES, _projection
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
from openpilot.starpilot.ui.appearance_owner import AppearanceOwner
from openpilot.starpilot.ui.display_owner import DisplayOwner
from openpilot.starpilot.ui.sounds_owner import SoundsOwner
from openpilot.starpilot.ui.pip_owner import PiPOwner
from openpilot.starpilot.ui.vasm_owner import VASMOwner
from openpilot.starpilot.ui.sentry_owner import SentryOwner
from openpilot.starpilot.ui.presentation import Profile
params = Params(str(tmp_path))
def check(rows, page):
for row in rows:
if row.key:
assert purpose(row), (page, row.key)
assert _projection(row, page)['reason'].startswith(purpose(row))
for candidate in (GM.CHEVROLET_BOLT_EUV, GM.CHEVROLET_VOLT, HYUNDAI.HYUNDAI_IONIQ_6,
TOYOTA.TOYOTA_RAV4_TSS2_2023, FORD.FORD_ESCAPE_MK4):
cp = interfaces[candidate].get_non_essential_params(candidate)
owner = FeatureSettingsOwner(params, lambda group: True, vehicle_fingerprint=lambda cp=cp: cp.carFingerprint,
vehicle_params=lambda cp=cp: cp, show_cruise_intervals=True, galaxy=lambda: True)
for page in PAGES - {'appearance', 'display', 'sounds', 'pip', 'sentry', 'vasm'}:
state = owner.snapshot(page, parked=True, system_long=True, lateral_context=True, metric=False)
check(state.rows, page)
for profile in (Profile.COMPACT, Profile.LARGE):
check(AppearanceOwner(params, lambda: True).snapshot(profile).rows, 'appearance')
check(DisplayOwner(params, lambda: True).snapshot(profile).rows, 'display')
for page, owner in (('sounds', SoundsOwner(params, lambda: True)), ('pip', PiPOwner(params, lambda: True, lambda: False)),
('vasm', VASMOwner(params, lambda: True)), ('sentry', SentryOwner(params, lambda: True))):
check(owner.snapshot().rows, page)
@@ -132,15 +132,15 @@ assert.equal(expired.unauthorized, 1)
assert.equal(expired.states.at(-1).status, "idle")
assert.match(SettingsPage.template, /v-if="state.pending"/)
assert.match(SettingsPage.template, /Turn the vehicle off to change these settings/)
assert.match(SettingsPage.template, /:disabled="!!state\.error \|\| !row\.available"/)
assert.match(SettingsPage.template, /Local settings are unavailable in preview/)
assert.match(SettingsPage.template, /state.data\?\.subtitle/)
// Driving Screen Widgets opens the layout editor on its Display tab; target switches go through the router.
assert.match(SettingsPage.template, /:initial-panel="state\.layoutPanel" @target="openLayoutTarget"/)
assert.doesNotMatch(SettingsPage.template, /Use Colors &amp; Layout to arrange widgets and change colors/)
assert.match(SettingsPage.template, /Close gap adjusts following distance during a lane change when StarPilot controls acceleration and braking\. It is Off by default/)
assert.match(SettingsPage.template, /Braking response works without saved personality curves\. A selected personality braking preset or Traffic takes priority\./)
assert.match(SettingsPage.template, /Traffic follow and jerk blend toward saved Relaxed values at higher speeds/)
assert.match(SettingsPage.template, /Configure lane-change assistance, signal timing, steering pace and following gap\./)
assert.match(SettingsPage.template, /Adjust acceleration, braking and following for each driving personality\./)
assert.match(SettingsPage.template, /Tune low-speed following and jerk, blending toward Relaxed at higher speeds\./)
const app = (await import('node:fs')).readFileSync(new URL('../web/js/app.js', import.meta.url), 'utf8')
assert.match(app, /:initial-panel="route\.path === '\/appearance' \? 'display' : 'widgets'"/)
const filtered = SettingsPage.computed.visibleRows.call({ state: { query: "lane", data: { rows: [
@@ -245,7 +245,7 @@ class SettingsGatewayTest(unittest.TestCase):
def test_all_saved_groups_are_display_only_projection(self):
hub = self.page("hub")
self.assertEqual([row["page"] for row in hub["rows"]],
["slc", "lane", "lane_change", "profiles", "conditional", "curve", "torque", "aol", "wheel",
["curve", "slc", "lane", "lane_change", "profiles", "conditional", "torque", "aol", "wheel",
"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"):
@@ -326,13 +326,13 @@ class SettingsGatewayTest(unittest.TestCase):
saved = Path(self.params.get_param_path("SoundPack"))
self.assertEqual(saved.read_bytes(), b"custom_pack")
intent = preview("Stock (openpilot)")
intent = preview("Stock Sounds")
saved.write_bytes(b"custom_pack ")
self.assertFalse(self.gateway.confirm(intent["intent"], self.session, self.generation))
self.assertEqual(saved.read_bytes(), b"custom_pack ")
saved.write_bytes(b"custom_pack")
intent = preview("Stock (openpilot)")
intent = preview("Stock Sounds")
self.context.value = AuthorityContext(False, None, None)
self.assertTrue(self.gateway.confirm(intent["intent"], self.session, self.generation))
self.assertEqual(saved.read_bytes(), b"stock")
@@ -741,7 +741,8 @@ class SettingsGatewayTest(unittest.TestCase):
page = self.page("lane_change")
auto = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Automatic Lane Changes")
intent = self.gateway.preview(page["view"], auto, 1, self.session, self.generation)
self.assertIn("next drive", intent["question"].lower())
self.assertIn("after the current signal and maneuver end", intent["question"].lower())
self.assertNotIn("next drive", intent["question"].lower())
self.assertIn("blindspot checks remain required", intent["question"].lower())
self.assertNotIn("development", intent["question"].lower())
self.assertTrue(self.gateway.confirm(intent["intent"], self.session, self.generation))
@@ -764,12 +765,14 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertEqual((page["rows"][gap]["minimum"], page["rows"][gap]["maximum"], page["rows"][gap]["step"]),
(0.75, 1.0, 0.05))
intent = self.gateway.preview(page["view"], close, 1, self.session, self.generation)
self.assertIn("as On for the next drive", intent["question"])
self.assertIn("to On", intent["question"])
self.assertIn("Changes refresh during this drive", intent["question"])
self.assertTrue(self.gateway.confirm(intent["intent"], self.session, self.generation))
self.assertTrue(read_lane_change(self.params).policy.close_gap)
page = self.page("lane_change")
intent = self.gateway.preview(page["view"], gap, 1, self.session, self.generation)
self.assertIn("as 0.8 s for the next drive", intent["question"])
self.assertIn("to 0.8 seconds", intent["question"])
self.assertIn("Changes refresh during this drive", intent["question"])
self.assertTrue(self.gateway.confirm(intent["intent"], self.session, self.generation))
self.assertEqual(read_lane_change(self.params).policy.close_gap_seconds, 0.8)
self.assertEqual(self.page("lane_change")["rows"][gap]["value"], "0.8")
@@ -0,0 +1,43 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { SettingsRecoveryPage } from "../web/js/settings-recovery.js"
const initial = { view: "view-1", rows: [{ id: 0, page: "appearance", label: "Wheel colors", current: "On", default: "Off", resetAvailable: true }] }
const calls = []
let saved = false
const oldFetch = globalThis.fetch
globalThis.fetch = async (path, options) => {
calls.push({ path, options, body: options.body ? JSON.parse(options.body) : null })
const value = path.endsWith("/preview") ? { intent: "confirmation-1", question: "Reset selected settings?", labels: ["Wheel colors"] }
: path.endsWith("/confirm") ? (saved = true, { saved: true, applied: ["Wheel colors"], refresh: true })
: (saved ? { view: "view-2", rows: [] } : initial)
return { ok: true, status: 200, json: async () => value }
}
try {
const state = { ...SettingsRecoveryPage.data(), mode: "local", unauthorized: () => assert.fail("unexpected logout") }
for (const [name, method] of Object.entries(SettingsRecoveryPage.methods)) state[name] = method.bind(state)
await state.refresh()
assert.equal(state.snapshot.rows[0].default, "Off")
await state.review([0])
assert.equal(saved, false)
assert.equal(state.pending.intent, "confirmation-1")
assert.deepEqual(calls.at(-1).body, { view: "view-1", selected: [0] })
await state.confirm()
assert.equal(saved, true)
assert.equal(state.snapshot.rows.length, 0)
assert.equal(state.pending, null)
assert.deepEqual(calls.find(call => call.path.endsWith("/confirm")).body, { intent: "confirmation-1", confirmed: true })
assert.match(SettingsRecoveryPage.template, /Reset Selected/)
assert.match(SettingsRecoveryPage.template, /Reset All Non-Default Settings/)
assert.match(SettingsRecoveryPage.template, /review\(\[row.id\]\)/)
assert.match(SettingsRecoveryPage.template, /Current:/)
assert.match(SettingsRecoveryPage.template, /!row.resetAvailable/)
assert.match(SettingsRecoveryPage.template, /filter\(row => row.resetAvailable\)/)
assert.match(SettingsRecoveryPage.template, /Some settings are unavailable for this vehicle/)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
const tools = readFileSync(new URL("../web/js/diagnostic-tools.js", import.meta.url), "utf8")
assert.match(app, /route.path === '\/settings\/recovery'/)
assert.match(tools, /Show Non-Default Values/)
assert.match(tools, /Reset to Default/)
assert.match(tools, /navigate', '\/settings\/recovery'/)
} finally { globalThis.fetch = oldFetch }
@@ -92,7 +92,7 @@ class SoundDownloadsHttpTest(unittest.TestCase):
status, page = request('/api/settings/pages/sounds')
self.assertEqual(status, 200)
row = next(item for item in page['rows'] if item['label'] == 'Sound Pack')
self.assertEqual(row['value'], 'Stock (openpilot)')
self.assertEqual(row['value'], 'Stock Sounds')
self.assertIn('test', row['choices'])
self.assertEqual(Path(params.get_param_path('SoundPack')).read_bytes(), b'stock')
self.assertEqual(request('/api/sounds/download', {'pack': 'test'})[0], 409)
@@ -0,0 +1,219 @@
"""Real storage cleanup keeps collection and unrelated files intact."""
import json
import os
import pytest
from openpilot.starpilot.galaxy.crash_reports import CrashChanged, CrashMissing, CrashReports, CrashUnavailable
from openpilot.starpilot.galaxy.sentry_events import SentryEvents
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
from openpilot.starpilot.sentry_mode.storage import EventStore, StorageUnavailable
def test_event_metadata_images_temporary_and_idempotence(tmp_path):
store = EventStore(tmp_path / 'events')
receipt = store.record('alarm', 1, permitted=lambda: True, images={'wide': b'\xff\xd8abc\xff\xd9', 'cabin': b'\xff\xd8xyz\xff\xd9'})
other = store.record('warning', 2, permitted=lambda: True)
(store.root / ('.' + receipt.event_id + '.tmp')).write_text('interrupted')
os.chmod(store.root / ('.' + receipt.event_id + '.tmp'), 0o600)
before = SentryEvents(store).snapshot()['storageBytes']
result = SentryEvents(store).delete(receipt.event_id)
assert not (store.root / receipt.event_id).exists()
assert not (store.root / (receipt.event_id + '.json')).exists()
assert not (store.root / ('.' + receipt.event_id + '.tmp')).exists()
assert [event['eventId'] for event in result['events']] == [other.event_id]
assert 0 < result['storageBytes'] < before
assert SentryEvents(store).delete(receipt.event_id)['events'] == result['events']
assert (store.root / '.lock').exists()
fresh = store.record('selfie', 3, permitted=lambda: True)
assert fresh.event_id in {event['eventId'] for event in EventStore(store.root).snapshot()['events']}
def test_event_corrupt_orphans_and_external_symlinks(tmp_path):
store = EventStore(tmp_path / 'events')
receipt = store.record('warning', 1, permitted=lambda: True)
(store.root / (receipt.event_id + '.json')).write_bytes(b'{broken')
outside = tmp_path / 'outside'
outside.write_text('keep')
(store.root / receipt.event_id).symlink_to(outside)
orphan = store.root / ('b' * 32)
orphan.mkdir(mode=0o700)
(orphan / 'wide.jpg').write_bytes(b'corrupt')
result = SentryEvents(store).delete()
assert result['events'] == [] and result['storageBytes'] == 0 and not result['more']
assert outside.read_text() == 'keep'
assert list(store.root.iterdir()) == [store.root / '.lock']
assert SentryEvents(EventStore(store.root)).snapshot()['events'] == []
for invalid in ('../outside', 'a' * 31, 'A' * 32, 'a' * 32 + '/x'):
with pytest.raises(ValueError):
store.delete(invalid)
assert outside.exists()
redirect = tmp_path / 'redirect'
redirect.symlink_to(tmp_path, target_is_directory=True)
with pytest.raises(StorageUnavailable):
EventStore(redirect).delete()
def test_bounded_delete_all_continues_and_missing_root(tmp_path):
store = EventStore(tmp_path / 'events')
assert SentryEvents(store).delete()['events'] == []
store.record('alarm', 1, permitted=lambda: True)
for index in range(4100):
(store.root / f'corrupt-{index}').touch(mode=0o600)
assert store.delete()['more']
assert not store.delete()['more']
assert store.snapshot()['storageBytes'] == 0
assert store.record('warning', 2, permitted=lambda: True).durable
with pytest.raises(PermissionError):
store.delete(permitted=lambda: False)
def test_crash_real_flat_reports_corrupt_logs_and_collection_after_delete(tmp_path):
directory = tmp_path / 'crash'
directory.mkdir()
report = directory / '2026-10-14.log'
report.write_bytes(b'corrupt\xfftrace')
(directory / 'error.txt').write_text('latest summary')
reader = CrashReports(directory)
original = reader.list()
token = next(item['id'] for item in original['reports'] if item['name'] == report.name)
result = reader.delete(token)
assert not report.exists()
assert result['reportCount'] == 1 and result['storageBytes'] == len('latest summary')
assert reader.delete(token)['reportCount'] == 1
assert not reader.delete()['reports']
assert reader.list()['storageBytes'] == 0
(directory / 'new-native-crash').write_text('new collection')
assert CrashReports(directory).list()['reportCount'] == 1
def test_crash_changed_tokens_and_no_symlink_escape(tmp_path):
directory = tmp_path / 'crash'
directory.mkdir()
report = directory / 'report'
report.write_text('old')
reader = CrashReports(directory)
token = reader.list()['reports'][0]['id']
report.write_text('new-longer')
with pytest.raises(CrashChanged):
reader.delete(token)
assert report.read_text() == 'new-longer'
outside = tmp_path / 'external'
outside.write_text('keep')
(directory / 'link').symlink_to(outside)
assert reader.delete()['reports'] == []
assert outside.read_text() == 'keep'
for token in ('../external', 'bad', 'x' * 5000):
with pytest.raises(CrashMissing):
reader.delete(token)
absent = CrashReports(tmp_path / 'missing')
assert absent.delete()['reports'] == []
linked = tmp_path / 'linked-root'
linked.symlink_to(tmp_path, target_is_directory=True)
with pytest.raises(CrashUnavailable):
CrashReports(linked).delete()
def test_notification_jobs_removed_durably_without_preferences_loss(tmp_path):
now = [9]
store = EventStore(tmp_path / 'events')
receipt = store.record('alarm', 1, permitted=lambda: True)
owner = NotificationOwner(tmp_path / 'notifications', store=store, clock=lambda: now[0])
try:
owner.action({'action': 'configure', 'channel': 'ntfy', 'enabled': True, 'url': 'https://ntfy.sh/test-topic', 'token': ''})
now[0] = 10
owner._enqueue({'eventId': receipt.event_id, 'kind': 'alarm', 'wallTimeNs': 10_000_000_000})
owner._save()
original_channels = json.loads(json.dumps(owner.state['channels']))
assert owner.state['jobs']
owner.delete_events(lambda: store.delete(receipt.event_id), receipt.event_id)
assert owner.state['jobs'] == {} and owner.state['channels'] == original_channels
finally:
owner.close()
reopened = NotificationOwner(tmp_path / 'notifications', store=store, clock=lambda: now[0])
try:
assert reopened.state['jobs'] == {} and reopened.state['channels'] == original_channels
fresh = store.record('warning', 2, permitted=lambda: True)
reopened._enqueue({'eventId': fresh.event_id, 'kind': 'warning', 'wallTimeNs': 10_000_000_000})
assert {job['eventId'] for job in reopened.state['jobs'].values()} == {fresh.event_id}
finally:
reopened.close()
def test_notification_inflight_delete_does_not_resurrect_jobs(tmp_path):
now = [9]
store = EventStore(tmp_path / 'events')
receipt = store.record('alarm', 1, permitted=lambda: True)
sent = []
owner: NotificationOwner
def transport(_channel, _config, _subscription, job, _key, _pem):
sent.append(job['eventId'])
owner.delete_events(lambda: store.delete())
return 200
owner = NotificationOwner(tmp_path / 'notifications', store=store, clock=lambda: now[0], transport=transport)
try:
owner.action({'action': 'configure', 'channel': 'ntfy', 'enabled': True, 'url': 'https://ntfy.sh/test-topic', 'token': ''})
now[0] = 10
owner._enqueue({'eventId': receipt.event_id, 'kind': 'alarm', 'wallTimeNs': 10_000_000_000})
owner.tick()
assert sent == [receipt.event_id]
assert owner.state['jobs'] == {} and owner.available
assert json.loads((owner.root / 'state.json').read_text())['jobs'] == {}
finally:
owner.close()
def test_actual_python_crash_collection_summary_and_future_collection(tmp_path, monkeypatch):
from openpilot.common.hardware.hw import Paths
from openpilot.system.sentry import save_exception
monkeypatch.setattr(Paths, 'log_root', lambda: str(tmp_path))
save_exception('old trace\nold end')
reader = CrashReports(tmp_path / 'crash')
old = next(item for item in reader.list()['reports'] if item['name'].endswith('.log'))
save_exception('new trace\nnew end')
current = next(item for item in reader.list()['reports'] if item['name'].endswith('.log') and item['id'] != old['id'])
reader.delete(old['id'])
assert (reader.directory / 'error.txt').read_text() == 'new trace\nnew end'
assert reader.delete(current['id'])['reports'] == []
assert reader.list()['storageBytes'] == 0
save_exception('future trace')
assert reader.list()['reportCount'] == 2
assert (reader.directory / 'error.txt').read_text() == 'future trace'
def test_notification_failed_prune_keeps_event_and_persisted_jobs(tmp_path, monkeypatch):
now = [9]
store = EventStore(tmp_path / 'events')
receipt = store.record('alarm', 1, permitted=lambda: True)
sent = []
owner = NotificationOwner(tmp_path / 'notifications', store=store, clock=lambda: now[0], transport=lambda *args: sent.append(args))
try:
owner.action({'action': 'configure', 'channel': 'ntfy', 'enabled': True, 'url': 'https://ntfy.sh/test-topic', 'token': ''})
now[0] = 10
owner._enqueue({'eventId': receipt.event_id, 'kind': 'alarm', 'wallTimeNs': 10_000_000_000})
owner._save()
jobs = json.loads(json.dumps(owner.state['jobs']))
assert jobs
def fail_save():
raise OSError('injected persistence failure')
monkeypatch.setattr(owner, '_save', fail_save)
removed = []
with pytest.raises(OSError):
owner.delete_events(lambda: removed.append(store.delete(receipt.event_id)), receipt.event_id)
assert removed == [] and sent == []
assert owner.state['jobs'] == jobs
assert (store.root / (receipt.event_id + '.json')).exists()
finally:
owner.close()
reopened = NotificationOwner(tmp_path / 'notifications', store=store, clock=lambda: now[0])
try:
assert reopened.state['jobs'] == jobs
def remove():
assert json.loads((reopened.root / 'state.json').read_text())['jobs'] == {}
return store.delete(receipt.event_id)
reopened.delete_events(remove, receipt.event_id)
assert reopened.state['jobs'] == {}
finally:
reopened.close()
@@ -0,0 +1,111 @@
"""Toyota tools use the registered factory context and exact guarded saved sources."""
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from opendbc.car.toyota.interface import CarInterface
from opendbc.car.toyota.values import CAR
from openpilot.starpilot.car.toyota.security_key import decode_key
from openpilot.starpilot.galaxy.toyota_tools import ToyotaTools, ToyotaToolsChanged
class SavedParams:
def __init__(self, root):
self.root = root / 'd'
self.root.mkdir()
def get_param_path(self, key):
return str(self.root / key)
@pytest.fixture
def tools(tmp_path):
params = SavedParams(tmp_path)
cp = CarInterface.get_non_essential_params(CAR.TOYOTA_RAV4_PRIME)
assert cp.secOcRequired and cp.brand == 'toyota'
current = SimpleNamespace(cp=cp, cp_raw=cp.to_bytes(), parked=True)
context = SimpleNamespace(sample=lambda: current, close=lambda: None)
return ToyotaTools(params, context, clock=lambda: 10), params, current
def action(owner, action_name, **fields):
page = owner.page('session', b'generation')
return owner.action(dict(view=page['view'], action=action_name, confirmed=True, **fields), 'session', b'generation')
@pytest.mark.parametrize('raw', [None, '', 'x' * 32, 'a' * 31, 'a' * 33, 'aa aa' * 6, b'\xff' * 32, 123])
def test_malformed_secoc_key_is_unavailable_without_decode_exception(raw):
assert decode_key(raw) is None
def test_valid_key_uses_exact_sixteen_bytes():
assert decode_key('AB' * 16) == bytes([171] * 16)
assert decode_key(('ab' * 16).encode()) == bytes([171] * 16)
def test_key_library_use_delete_never_discloses_value(tools):
owner, params, _ = tools
assert action(owner, 'save_key', name='My Toyota', value='ab' * 16)['saved']
page = owner.page('session', b'generation')
assert page['keys'] == [{'name': 'My Toyota', 'active': False}]
assert 'ab' * 16 not in json.dumps(page)
assert action(owner, 'use_key', name='My Toyota')['nextStartup']
assert Path(params.get_param_path('SecOCKey')).read_bytes() == b'ab' * 16
assert owner.page('session', b'generation')['keys'][0]['active']
assert action(owner, 'delete_key', name='My Toyota')['saved']
assert owner.page('session', b'generation')['keys'] == []
assert Path(params.get_param_path('SecOCKey')).read_bytes() == b'ab' * 16
@pytest.mark.parametrize('revoke', ['session', 'generation', 'parked', 'context', 'source', 'vehicle'])
def test_changed_authority_or_exact_source_cannot_write(tools, revoke):
owner, params, current = tools
page = owner.page('session', b'generation')
if revoke == 'parked':
current.parked = False
elif revoke == 'context':
current.cp_raw = b'changed'
elif revoke == 'source':
Path(params.get_param_path('LockDoors')).write_bytes(b'1')
elif revoke == 'vehicle':
current.cp.brand = 'honda'
with pytest.raises(ToyotaToolsChanged):
owner.action({'view': page['view'], 'action': 'save_key', 'name': 'test', 'value': 'ab' * 16, 'confirmed': True},
'other' if revoke == 'session' else 'session', b'other' if revoke == 'generation' else b'generation')
assert not Path(params.get_param_path('SecOCKeys')).exists()
def test_non_toyota_hidden_and_non_secoc_key_actions_denied(tools):
owner, _, current = tools
current.cp.brand = 'hyundai'
assert not owner.page('session', b'generation')['visible']
current.cp = CarInterface.get_non_essential_params(CAR.TOYOTA_COROLLA_TSS2)
current.cp_raw = current.cp.to_bytes()
page = owner.page('session', b'generation')
assert page['visible'] and not page['security']
with pytest.raises(ValueError):
owner.action({'view': page['view'], 'action': 'save_key', 'name': 'test', 'value': 'ab' * 16, 'confirmed': True},
'session', b'generation')
def test_door_preferences_default_off_and_verified_at_next_start(tools):
owner, params, _ = tools
page = owner.page('session', b'generation')
assert not page['lock'] and not page['unlock']
assert action(owner, 'lock', value=True)['nextStartup']
assert Path(params.get_param_path('LockDoors')).read_bytes() == b'1'
assert action(owner, 'unlock', value=True)['saved']
assert Path(params.get_param_path('UnlockDoors')).read_bytes() == b'1'
def test_malformed_library_never_becomes_empty_or_discloses_credentials(tools):
owner, params, _ = tools
invalid = b'["private-invalid-value"]'
Path(params.get_param_path('SecOCKeys')).write_bytes(invalid)
page = owner.page('session', b'generation')
assert not page['readable'] and page['view'] is None and page['keys'] == []
assert invalid.decode() not in json.dumps(page)
assert Path(params.get_param_path('SecOCKeys')).read_bytes() == invalid
@@ -0,0 +1,58 @@
"""Toyota credentials require the existing authenticated, origin-checked Galaxy session."""
import http.client
import json
import threading
from opendbc.car.toyota.interface import CarInterface
from opendbc.car.toyota.values import CAR
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.server import make_server
from openpilot.starpilot.galaxy.tests.test_toyota_tools import SavedParams
from openpilot.starpilot.galaxy.toyota_tools import ToyotaTools
from types import SimpleNamespace
def test_http_session_origin_and_secret_free_readback(tmp_path):
params = SavedParams(tmp_path)
cp = CarInterface.get_non_essential_params(CAR.TOYOTA_RAV4_PRIME)
cp_raw = cp.to_bytes()
context = SimpleNamespace(sample=lambda: SimpleNamespace(cp=cp, cp_raw=cp_raw, parked=True), close=lambda: None)
tools = ToyotaTools(params, context)
access = GalaxyAccessOwner(tmp_path / 'access')
assert access.configure('password123', lambda: True)
server = make_server(port=0, owner=access, parked=lambda: True, toyota_tools=tools)
worker = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': .01}, daemon=True)
worker.start()
def request(path, payload=None, cookie='', origin=True):
connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3)
headers = {'Cookie': cookie}
if payload is not None:
headers['Content-Type'] = 'application/json'
headers['Origin'] = f'http://127.0.0.1:{server.server_port}' if origin else 'https://external.invalid'
try:
connection.request('POST' if payload is not None else 'GET', path, json.dumps(payload) if payload is not None else None, headers)
response = connection.getresponse()
return response.status, json.loads(response.read()), dict(response.getheaders())
finally:
connection.close()
try:
assert request('/api/toyota-tools')[0] == 401
code, _, headers = request('/api/auth/login', {'password': 'password123'})
assert code == 200
cookie = headers['Set-Cookie'].split(';', 1)[0]
code, page, _ = request('/api/toyota-tools', cookie=cookie)
assert code == 200 and page['visible'] and page['security']
payload = {'view': page['view'], 'action': 'save_key', 'confirmed': True, 'name': 'My Toyota', 'value': 'ab' * 16}
assert request('/api/toyota-tools/action', payload)[0] == 401
assert request('/api/toyota-tools/action', payload, cookie, origin=False)[0] == 403
assert request('/api/toyota-tools/action', payload, cookie)[0] == 200
code, page, _ = request('/api/toyota-tools', cookie=cookie)
assert code == 200 and page['keys'] == [{'name': 'My Toyota', 'active': False}]
assert 'ab' * 16 not in json.dumps(page)
request('/api/auth/logout', {}, cookie)
assert request('/api/toyota-tools/action', {'view': page['view'], 'action': 'use_key', 'confirmed': True, 'name': 'My Toyota'}, cookie)[0] == 401
finally:
server.shutdown()
worker.join(timeout=2)
server.server_close()
@@ -118,7 +118,11 @@ await staleParked.reply(0, { ...page, parked: false })
assert.equal(staleParked.feed.data, null)
assert.equal(staleParked.timers.size, 0)
assert.match(VehicleControlsPage.template, /Last identified vehicle/)
assert.match(VehicleControlsPage.template, /Configured selection mode/)
assert.match(VehicleControlsPage.template, /Manually Selected/)
assert.match(VehicleControlsPage.template, /Last auto-detected vehicle/)
assert.match(VehicleControlsPage.template, /This runtime used a manual selection, not automatic detection/)
assert.match(VehicleControlsPage.template, /Switch to Auto Detection/)
assert.match(VehicleControlsPage.template, /next start/)
assert.match(VehicleControlsPage.template, /state\.pending\.question/)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
@@ -72,6 +72,7 @@ class VehicleSelectionHttpTest(unittest.TestCase):
self.assertTrue(page['parked'] and page['valid'])
self.assertIsNone(page['selected'])
self.assertEqual(page['selectedLabel'], 'Auto detection')
self.assertEqual(page['selectionMode'], 'auto')
self.assertIsNone(page['reported'])
self.assertNotIn('MOCK', [item['platform'] for item in page['choices']])
self.assertEqual(page['choices'], [{'platform': choice.platform, 'make': choice.make, 'label': choice.label}
@@ -91,10 +92,26 @@ class VehicleSelectionHttpTest(unittest.TestCase):
self.assertEqual(readback['selected'], selected['platform'])
self.assertEqual(readback['reported']['platform'], selected['platform'])
self.assertNotEqual(readback['selectedLabel'], 'Auto detection')
self.assertEqual(readback['selectionMode'], 'manual')
self.assertEqual(self.request('/api/auth/logout', {}, cookie)[0], 200)
self.assertEqual(self.request('/api/vehicle-selection', cookie=cookie)[0], 401)
self.assertEqual(self.request('/api/vehicle-selection/confirm', {'intent': preview['intent'], 'confirmed': True}, cookie)[0], 401)
def test_runtime_report_is_not_replaced_by_manually_selected_editor_context(self):
cookie = self.login()
first, second = self.gateway.catalog[:2]
self.context.reported = SimpleNamespace(carFingerprint=str(second.platform), notCar=False)
self.context.runtime_vehicle = lambda: (SimpleNamespace(carFingerprint=str(first.platform), notCar=False,
fingerprintSource='fw'), b'actual-runtime-cp')
page = self.request('/api/vehicle-selection', cookie=cookie)[1]
self.assertEqual(page['reported']['platform'], str(first.platform))
self.assertEqual(page['reported']['origin'], 'detected')
self.context.runtime_vehicle = lambda: (SimpleNamespace(carFingerprint=str(first.platform), notCar=False,
fingerprintSource='fixed'), b'manual-runtime-cp')
page = self.request('/api/vehicle-selection', cookie=cookie)[1]
self.assertEqual(page['reported']['origin'], 'manual')
self.assertEqual(page['selectionMode'], 'auto') # Next start can differ from the last running selection.
def test_invalid_saved_source_explicit_auto_repair_and_parked_revocation(self):
cookie = self.login()
Path(self.params.get_param_path(KEY)).write_bytes(b'bad-document')
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import {deflateSync} from 'node:zlib'
import {LayoutPreviewFeed} from '../web/js/layout-preview.js'
import {widgetArtMetadata} from '../web/js/widget-art.js'
import {LayoutWidgetPreview, OnroadLayoutPage} from '../web/js/onroad-layout.js'
function chunk(kind, bytes) {
const body=Buffer.concat([Buffer.from(kind),bytes]);let crc=0xffffffff
for(const byte of body){crc^=byte;for(let i=0;i<8;i++)crc=(crc>>>1)^(crc&1?0xedb88320:0)}
const size=Buffer.alloc(4),sum=Buffer.alloc(4);size.writeUInt32BE(bytes.length);sum.writeUInt32BE((crc^0xffffffff)>>>0)
return Buffer.concat([size,body,sum])
}
const meta={version:1,width:2,height:1,sprites:{conditional_mode:{x:0,y:0,width:2,height:1}}}
function image(metadata=meta){
const header=Buffer.alloc(13);header.writeUInt32BE(2);header.writeUInt32BE(1,4);header[8]=8;header[9]=6
return new Blob([Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',header),
chunk('tEXt',Buffer.from('starpilot-widget-art\0'+JSON.stringify(metadata))),
chunk('IDAT',deflateSync(Buffer.from([0,255,255,255,255,0,0,0,0]))),chunk('IEND',Buffer.alloc(0))])],{type:'image/png'})
}
assert.deepEqual(await widgetArtMetadata(image()),meta)
for(const bad of [{...meta,width:3},{...meta,sprites:{conditional_mode:{x:1,y:0,width:2,height:1}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:NaN,height:1}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetX:1}}}])await assert.rejects(widgetArtMetadata(image(bad)))
await assert.rejects(widgetArtMetadata(new Blob(['not PNG'])))
const updates=[],requests=[],timers=new Map(),revoked=[];let next=0,url=0
const feed=new LayoutPreviewFeed({art:true,publish:value=>updates.push(value),
fetcher:(address,options)=>new Promise(resolve=>requests.push({address,options,resolve})),
later:fn=>{timers.set(++next,fn);return next},cancelTimer:id=>timers.delete(id),
urls:{createObjectURL:()=>`blob:${++url}`,revokeObjectURL:value=>revoked.push(value)}})
const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve()}
const tick=()=>{const [id,fn]=[...timers].at(-1);timers.delete(id);fn()}
const answer=async(index,status=200)=>{requests[index].resolve({status,ok:status===200,
headers:{get:()=> 'image/png'},blob:async()=>image()});await flush()}
const document={version:6,layouts:{compact:{}},palette:{text:'#FFFFFFFF'}}
feed.start();feed.update(document,'compact','engaged');tick()
assert.equal(requests[0].address,'./api/ui/layout/preview');assert.equal(requests[0].options.credentials,'same-origin')
assert.equal(JSON.parse(requests[0].options.body).art,true)
await answer(0);assert.deepEqual(updates.at(-1).sprites,meta)
feed.update(document,'compact','engaged',true);assert.equal(feed.currentUrl,'blob:1','Drag retains mounted native art')
assert.equal(feed.pending,null,'Drag queues no native render');assert.equal(feed.timer,null,'Drag schedules no request timer')
assert.equal(requests.length,1,'Entering drag sends no native request')
feed.update(document,'compact','cem_stop_light');assert.equal(feed.currentUrl,null,'Scene change clears stale CEM art')
tick();feed.update(document,'large','cem_curve');assert.equal(requests[1].options.signal.aborted,true)
tick();await answer(1);assert.equal(requests.length,3);await answer(2);assert.equal(updates.at(-1).url,'blob:2')
feed.update(document,'large','braking');tick();await answer(3,503);assert.equal(feed.currentUrl,null)
assert.equal(updates.at(-1).status,'unavailable');feed.stop();assert(revoked.includes('blob:1')&&revoked.includes('blob:2'))
assert.equal(OnroadLayoutPage.methods.runtimeWidget.call({profile:{runtimePreviewWidgets:['conditional_mode']}},'conditional_mode'),true)
assert.equal(OnroadLayoutPage.methods.runtimeWidget.call({profile:{}},'conditional_mode'),false)
assert.match(LayoutWidgetPreview.template,/data-preview-source="runtime"/)
assert.match(LayoutWidgetPreview.template,/data-preview-source="placeholder"/)
assert.doesNotMatch(LayoutWidgetPreview.template,/scene === 'experimental' \? 'E' : 'C'/)
assert.doesNotMatch(LayoutWidgetPreview.template,/M38 78 Q96 107 154 78/)
console.log('Canonical widget art: metadata bounds, authenticated request, race, drag retention, neutral error, selection passed')
+149
View File
@@ -0,0 +1,149 @@
"""Parked Toyota preferences and private SecOC credential management."""
from dataclasses import dataclass
import hashlib
import json
import re
import secrets
import threading
import time
from openpilot.starpilot.car.toyota.security_key import decode_key
from openpilot.starpilot.saved_document import commit_exact
from openpilot.starpilot.saved_source import read_saved
KEYS = (('SecOCKeys', 16384), ('SecOCKey', 128), ('LockDoors', 128), ('UnlockDoors', 128))
class ToyotaToolsChanged(Exception):
pass
def capability(cp):
try:
from opendbc.car.toyota.values import CAR
if cp is None or cp.brand != 'toyota' or str(cp.carFingerprint) not in tuple(str(value) for value in CAR) or cp.notCar or cp.dashcamOnly:
return None
return str(cp.carFingerprint), bool(cp.secOcRequired)
except (AttributeError, ValueError, TypeError):
return None
def key_library(raw):
if raw is None:
return []
try:
values = json.loads(raw)
if type(values) is not list or len(values) > 16:
return None
names = set()
for row in values:
if (type(row) is not dict or set(row) != {'name', 'value'} or type(row['name']) is not str or
re.fullmatch(r'[A-Za-z0-9 _.-]{1,48}', row['name']) is None or row['name'] in names or
decode_key(row['value']) is None):
return None
names.add(row['name'])
return values
except (ValueError, TypeError, UnicodeDecodeError):
return None
@dataclass(frozen=True)
class View:
identity: tuple
context: bytes
capability: tuple
sources: tuple
expires: float
class ToyotaTools:
def __init__(self, params, context, *, clock=time.monotonic):
self.params, self.context, self.clock = params, context, clock
self.lock = threading.Lock()
self.views = {}
def close(self):
self.context.close()
def sources(self):
return tuple(read_saved(self.params, key, size) for key, size in KEYS)
@staticmethod
def identity(token, generation):
return hashlib.sha256(token.encode('ascii')).digest(), generation
def page(self, token, generation):
ctx = self.context.sample()
cap = capability(ctx.cp)
result = {'version': 1, 'visible': cap is not None, 'parked': bool(ctx.parked), 'security': False,
'view': None, 'keys': [], 'activePresent': False, 'readable': False, 'lock': False, 'unlock': False}
if cap is None or ctx.cp_raw is None:
return result
sources = self.sources()
library = key_library(sources[0][0])
readable = all(item[1] for item in sources) and library is not None
readable &= all(sources[index][0] in (None, b'0', b'1') for index in (2, 3))
result.update(security=cap[1], readable=bool(readable), activePresent=decode_key(sources[1][0]) is not None,
lock=sources[2][0] == b'1', unlock=sources[3][0] == b'1')
if library is not None:
result['keys'] = [{'name': row['name'], 'active': decode_key(row['value']) == decode_key(sources[1][0])}
for row in library]
if readable:
with self.lock:
self.views = {key: value for key, value in self.views.items() if value.expires > self.clock()}
if len(self.views) >= 64:
self.views.pop(next(iter(self.views)))
token_id = secrets.token_urlsafe(24)
self.views[token_id] = View(self.identity(token, generation), ctx.cp_raw, cap, sources, self.clock() + 60)
result['view'] = token_id
return result
def action(self, payload, token, generation, *, session_valid=lambda: True):
if type(payload) is not dict or type(payload.get('view')) is not str or payload.get('confirmed') is not True:
raise ValueError('Invalid Toyota request')
with self.lock:
view = self.views.pop(payload['view'], None)
def authorized():
ctx = self.context.sample()
return bool(view is not None and self.clock() < view.expires and session_valid() and ctx.parked and
view.identity == self.identity(token, generation) and ctx.cp_raw == view.context and
capability(ctx.cp) == view.capability and self.sources() == view.sources)
if not authorized():
raise ToyotaToolsChanged('Toyota settings changed; refresh the page')
action = payload.get('action')
library = key_library(view.sources[0][0])
if action in ('lock', 'unlock'):
if set(payload) != {'view', 'action', 'value', 'confirmed'} or type(payload['value']) is not bool:
raise ValueError('Invalid door preference')
index = 2 if action == 'lock' else 3
raw = b'1' if payload['value'] else b'0'
elif action in ('save_key', 'use_key', 'delete_key') and view.capability[1]:
fields = {'view', 'action', 'name', 'confirmed'} | ({'value'} if action == 'save_key' else set())
name = payload.get('name')
if set(payload) != fields or type(name) is not str or re.fullmatch(r'[A-Za-z0-9 _.-]{1,48}', name) is None:
raise ValueError('Invalid security key request')
row = next((row for row in library if row['name'] == name), None)
index = 0
if action == 'use_key':
if row is None:
raise ValueError('Security key is unavailable')
index, raw = 1, decode_key(row['value']).hex().encode()
else:
updated = [row for row in library if row['name'] != name]
if action == 'save_key':
value = decode_key(payload.get('value'))
if value is None or len(updated) >= 16:
raise ValueError('Enter exactly 32 hexadecimal characters')
updated.append({'name': name, 'value': value.hex()})
elif row is None:
raise ValueError('Security key is unavailable')
raw = json.dumps(updated, separators=(',', ':')).encode()
else:
raise ValueError('Unsupported Toyota action')
key, limit = KEYS[index]
result = commit_exact(self.params, key=key, max_bytes=limit, raw=raw, expected=view.sources[index][0],
authorized=authorized, temp_prefix='.toyota-preference-')
if not result.verified:
raise ToyotaToolsChanged('Save could not be confirmed; refresh saved values')
return {'saved': True, 'nextStartup': True}
@@ -69,11 +69,14 @@ class VehicleSelectionGateway:
parked = self.context.parked()
reported = None
try:
cp = self.context.sample().cp
runtime_vehicle = getattr(self.context, 'runtime_vehicle', None)
cp = runtime_vehicle()[0] if callable(runtime_vehicle) else self.context.sample().cp
platform = str(cp.carFingerprint) if cp is not None else ''
if not cp.notCar and platform in self.by_platform:
choice = self.by_platform[platform]
reported = {'platform': platform, 'make': str(choice.make), 'label': str(choice.label)}
origin = str(getattr(cp, 'fingerprintSource', 'unknown'))
reported = {'platform': platform, 'make': str(choice.make), 'label': str(choice.label),
'origin': 'manual' if origin == 'fixed' else 'detected' if origin in ('can', 'fw') else 'unknown'}
except (AttributeError, TypeError, ValueError, OSError, RuntimeError):
pass
view = None
@@ -89,6 +92,8 @@ class VehicleSelectionGateway:
'selected': str(saved.platform) if selected is not None else None,
'selectedLabel': str(selected.label) if selected is not None else 'Auto detection' if saved.valid else
'Needs review' if saved.readable else 'Unavailable',
'selectionMode': 'manual' if selected is not None else 'auto' if saved.valid else
'invalid' if saved.readable else 'unavailable',
'reported': reported, 'view': view,
'choices': [{'platform': str(choice.platform), 'make': str(choice.make), 'label': str(choice.label)}
for choice in self.catalog]}
+3 -1
View File
@@ -16,6 +16,7 @@ const NavigationPage = page(() => import("./navigation.js").then((module) => mod
const ModelsPage = page(() => import("./models.js").then((module) => module.ModelsPage))
const LaboratoryPage = page(() => import("./model-laboratory.js").then((module) => module.LaboratoryPage))
import { SettingsPage } from "./settings.js"
import { SettingsRecoveryPage } from "./settings-recovery.js"
import { ToggleSearch } from "./toggle-search.js"
const PlotsPage = page(() => import("./plots.js").then((module) => module.PlotsPage))
const FlmPage = page(() => import("./flm.js").then((module) => module.FlmPage))
@@ -78,7 +79,7 @@ const auth = new LocalAuth({ publish: (update) => {
createApp({
components: { BottomNav, GalaxyLoading, Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile },
components: { BottomNav, GalaxyLoading, Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, SettingsRecoveryPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile },
data: () => ({ state, authState, authForm, route, NAV, routeDirection: 1, updateRestart, UPDATE_RESTART_MESSAGE, nextLayoutLeavePresentation: "inline" }),
computed: {
visibleTools() { return state.tools.filter(tool => tool.visibility !== "authenticated" ||
@@ -220,6 +221,7 @@ createApp({
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="route.path === '/developer/connect'" :mode="state.monitorMode" :unauthorized="sessionExpired" initial-section="developer" />
<GalaxyPage v-else-if="route.path === '/galaxy'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<OnroadLayoutPage ref="activeLayout" v-else-if="['/theme_maker', '/theme_maker/android_auto', '/appearance'].includes(route.path)" :key="route.path" :projection="route.path === '/theme_maker/android_auto'" :initial-panel="route.path === '/appearance' ? 'display' : 'widgets'" :mode="state.monitorMode" :unauthorized="sessionExpired" @target="go($event === 'projection' ? '/theme_maker/android_auto' : '/theme_maker')" @close="routeBack" />
<SettingsRecoveryPage v-else-if="route.path === '/settings/recovery'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<Logs v-else-if="route.path === '/logs' || route.path.startsWith('/logs/') || ['/troubleshoot', '/manage_tmux'].includes(route.path)" :path="route.path === '/troubleshoot' ? '/logs/troubleshoot' : route.path === '/manage_tmux' ? '/logs/tmux' : route.path" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<SoftwarePage v-else-if="route.path === '/system'" :mode="state.monitorMode" :unauthorized="sessionExpired" :restarting="baseline => updateRestart.start(baseline)" />
<NavigationPage v-else-if="['/navigation', '/navigation/maps', '/navigation/setup'].includes(route.path)" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go"
@@ -1,6 +1,15 @@
import { PollTimer, connectionError } from "./polling.js"
import { requestJson } from "./startup.js"
// Read-only local crash reports. State is cleared on navigation and sign-out.
import { deleteRecords } from "./record-deletion.js"
export function validCrashReports(data) {
return data?.schemaVersion === 1 && Array.isArray(data.reports) && data.reports.length <= 200 &&
typeof data.scanIncomplete === "boolean" && typeof data.listLimited === "boolean" &&
data.reports.every(item => typeof item.id === "string" && item.id && typeof item.name === "string" && item.name &&
typeof item.size === "number" && Number.isFinite(item.size) && item.size >= 0 &&
typeof item.modifiedAt === "number" && Number.isFinite(item.modifiedAt))
}
// Local stored crash reports. State is cleared on navigation and sign-out.
export class CrashReportsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher })
@@ -14,7 +23,8 @@ export class CrashReportsFeed {
clear() {
this.reports = []
this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" })
this.deleting = false
this.publish({ deleting: false, storageBytes: 0, reportCount: 0, reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" })
}
stop() {
@@ -54,16 +64,9 @@ export class CrashReportsFeed {
try {
const data = await this.requestJson("./api/crash-reports", generation, request)
if (!data) return
if (data.schemaVersion !== 1 || !Array.isArray(data.reports) || data.reports.length > 200 ||
typeof data.scanIncomplete !== "boolean" || typeof data.listLimited !== "boolean" ||
data.reports.some((item) => typeof item.id !== "string" || !item.id ||
typeof item.name !== "string" || !item.name ||
typeof item.size !== "number" || !Number.isFinite(item.size) || item.size < 0 ||
typeof item.modifiedAt !== "number" || !Number.isFinite(item.modifiedAt))) {
throw new Error("Invalid crash report list")
}
if (!validCrashReports(data)) throw new Error("Invalid crash report list")
this.reports = data.reports
this.publish({ reports: data.reports, scanIncomplete: data.scanIncomplete, listLimited: data.listLimited, status: "ready", error: "" })
this.publish({ reports: data.reports, scanIncomplete: data.scanIncomplete, listLimited: data.listLimited, reportCount: data.reportCount, storageBytes: data.storageBytes, storageIncomplete: data.storageIncomplete, status: "ready", error: "" })
} catch (error) {
if (this.active && generation === this.generation) this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "unavailable", error: connectionError(error), selected: null, preview: null, previewStatus: "idle" })
} finally {
@@ -71,6 +74,14 @@ export class CrashReportsFeed {
}
}
remove(id, confirm) {
return deleteRecords(this, "./api/crash-reports/delete", id == null ? { all: true, confirmed: true } : { id }, {
confirm, subject: "crash reports", valid: data => validCrashReports(data) &&
Number.isSafeInteger(data.storageBytes) && data.storageBytes >= 0 && Number.isSafeInteger(data.reportCount) && data.reportCount >= 0 && typeof data.storageIncomplete === "boolean",
accept: data => { this.reports = data.reports; this.publish({ ...data, status: "ready", error: "" }) },
})
}
async open(report) {
if (!this.active || !this.reports.some((item) => item.id === report?.id)) return
const generation = ++this.generation
@@ -99,7 +99,7 @@ export const TroubleshootPage = {
<p>Read-only device, vehicle and settings snapshot. Copy it when reporting a problem.</p></div></header>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
<GxNotice v-if="state.notice && !state.error" tone="info">{{ state.notice }}</GxNotice>
<div class="gx-settings__controls gx-actions"><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/monitor')">System Monitor</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/crashes')">Crash Reports</button><GxIconButton label="Copy report" icon="bi-clipboard" :disabled="!state.data" @click="copyText(report(state.data))" /></div>
<div class="gx-settings__controls gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local'" @click="$emit('navigate', '/settings/recovery')">Show Non-Default Values</button><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local'" @click="$emit('navigate', '/settings/recovery')">Reset to Default</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/monitor')">System Monitor</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/crashes')">Crash Reports</button><GxIconButton label="Copy report" icon="bi-clipboard" :disabled="!state.data" @click="copyText(report(state.data))" /></div>
<GxState v-if="state.loading && !state.error" loading>Loading report…</GxState>
<template v-if="state.data"><section class="gx-card gx-panel"><h3>Device Snapshot</h3><p>State: {{ state.data.device.state || 'Unavailable' }}</p><p>Vehicle: {{ state.data.vehicle.available ? state.data.vehicle.fingerprint : 'Unavailable' }}</p><p>Brand: {{ state.data.vehicle.brand || 'Unavailable' }} · Longitudinal: {{ state.data.vehicle.longitudinal ? 'Available' : 'Unavailable' }} · Steering: {{ state.data.vehicle.steering || 'Unavailable' }}</p><dl class="gx-details"><template v-for="row in state.data.snapshot" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section>
<section v-for="section in state.data.sections" :key="section.title" class="gx-card gx-panel"><h3>{{ section.title }}</h3><dl class="gx-details"><template v-for="row in section.rows" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section><p class="gx-note">{{ state.data.note }}</p></template></section>`,
@@ -35,7 +35,7 @@ export const DriveStatePanel = {
},
async change(mode) {
if (this.busy || !this.state?.available) return
if (mode === "onroad" && !this.state.overrideAllowed) { this.error = "Park and disengage, then retry. The device can stay on-road."; return }
if (mode === "offroad" && !this.state.overrideAllowed) { this.error = "Park the vehicle and disengage before forcing Offroad."; return }
if (mode === "offroad" && this.state.effective === "onroad" &&
!window.confirm("Switch to Offroad and stop driving services? Disengage and stop safely first. Stay stopped until you return to Auto.")) return
const revision = this.state.revision
@@ -61,11 +61,11 @@ export const DriveStatePanel = {
<GxNotice tone="danger" v-if="state && !state.available">The drive-state manager is unavailable. Reconnect to check again.</GxNotice>
<GxNotice v-if="pending" tone="info">Waiting for the device to switch {{ state.mode }}…</GxNotice>
<p>Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
<p>Auto follows the car. Force Offroad stops driving services even when the device is on-road. Disengage and stop safely before forcing Offroad; Force Onroad requires Park and valid vehicle data. Return to Auto restores normal operation.</p>
<p v-if="state?.available && !state.overrideAllowed" role="status">Force Onroad needs fresh parked, disengaged vehicle data. Force Offroad remains available to stop driving services.</p>
<p>Auto follows the car. Force Onroad starts driving services at any time. Force Offroad stops them when the vehicle is parked and disengaged. Return to Auto restores normal operation.</p>
<p v-if="state?.available && !state.overrideAllowed" role="status">{{ state.reason || "Park the vehicle and disengage to use Force Offroad." }}</p>
<div class="gx-force-drive__actions gx-actions"><button v-for="mode in ['offroad', 'onroad', 'auto']" :key="mode" type="button"
class="gx-btn" :class="state?.mode === mode ? '' : 'gx-btn--tonal'"
:disabled="!state?.available || (mode === 'onroad' && !state.overrideAllowed)"
:disabled="!state?.available || (mode === 'offroad' && !state.overrideAllowed)"
:aria-pressed="state?.mode === mode" @click="change(mode)">{{ mode === 'auto' ? 'Return to Auto' : mode === 'onroad' ? 'Onroad' : 'Offroad' }}</button></div>
</section>`,
}
@@ -1,3 +1,4 @@
import { widgetArtMetadata } from "./widget-art.js"
export const PREVIEW_SCENES = [
{ id: "engaged", label: "Engaged" },
{ id: "aol", label: "Lateral only" },
@@ -22,8 +23,8 @@ const SCENES = PREVIEW_SCENES.map(({ id }) => id)
export class LayoutPreviewFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id),
urls = URL }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls })
urls = URL, art = false }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls, art })
this.active = false
this.version = 0
this.timer = null
@@ -47,7 +48,7 @@ export class LayoutPreviewFeed {
clearImage() {
if (this.currentUrl) this.urls.revokeObjectURL(this.currentUrl)
this.currentUrl = null
this.publish({ url: null })
this.publish({ url: null, ...(this.art ? {sprites: null} : {}) })
}
start() { this.stop(); this.active = true }
@@ -63,6 +64,7 @@ export class LayoutPreviewFeed {
this.pending = null
this.request?.abort()
if (dragging) { this.publish({ status: "editing", error: "" }); return }
if (this.art) this.clearImage()
this.pending = { document: JSON.parse(JSON.stringify(document)), profile, scene, version: this.version }
this.publish({ status: "updating" })
this.timer = this.later(() => { this.timer = null; this.dispatch() }, 200)
@@ -90,7 +92,7 @@ export class LayoutPreviewFeed {
await Promise.race([aborted, (async () => {
const response = await this.fetcher("./api/ui/layout/preview", { method: "POST", credentials: "same-origin",
cache: "no-store", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene }), signal: request.signal })
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene, ...(this.art ? {art: true} : {}) }), signal: request.signal })
if (!this.active || item.version !== this.version || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 403) { failure = "Turn off the vehicle to preview this layout."; throw new Error() }
@@ -100,14 +102,17 @@ export class LayoutPreviewFeed {
const blob = await response.blob()
if (!this.active || item.version !== this.version || request.signal.aborted) return
if (!blob.size || blob.size > 4 * 1024 * 1024) throw new Error("Preview is unavailable. Reconnecting automatically…")
const sprites = this.art ? await widgetArtMetadata(blob) : null
if (!this.active || item.version !== this.version || request.signal.aborted) return
const previousUrl = this.currentUrl
this.currentUrl = this.urls.createObjectURL(blob)
this.publish({ status: "ready", error: "", url: this.currentUrl })
this.publish({ status: "ready", error: "", url: this.currentUrl, ...(this.art ? {sprites} : {}) })
if (previousUrl) this.urls.revokeObjectURL(previousUrl)
})()])
} catch {
if (this.active && item.version === this.version) {
this.inputKey = null
if (this.art) this.clearImage()
this.publish({ status: "unavailable", error: failure })
this.timer = this.later(() => { this.timer = null; this.retry() }, 5000)
}
+5 -3
View File
@@ -4,6 +4,7 @@ import { TroubleshootPage, TmuxPage } from "./diagnostic-tools.js"
import { navigate } from "./router.js"
import { SystemMonitor } from "./system-monitor.js"
import { CrashReportsFeed } from "./crash-reports.js"
import { storedSize } from "./record-deletion.js"
import { AndroidAutoLogsFeed, BUNDLE_URL, fileUrl, outcomeLabel } from "./android-auto-logs.js"
import { MenuTile } from "./menu-tile.js"
@@ -11,7 +12,7 @@ export const Logs = {
name: "Logs",
components: { GxState, GxNotice, SystemMonitor, TroubleshootPage, TmuxPage, MenuTile },
props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" }, search: "",
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle", deleting: false, storageBytes: 0, reportCount: 0 }, search: "",
aaLogs: { sessions: [], others: [], status: "idle", error: "" } }),
computed: {
visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) },
@@ -34,6 +35,7 @@ export const Logs = {
},
},
methods: {
storedSize,
go: navigate,
openTroubleshoot() { navigate("/logs/troubleshoot") },
openTmux() { navigate("/logs/tmux") },
@@ -72,14 +74,14 @@ export const Logs = {
<h2>Crash Reports</h2>
<p class="gx-note">Local reports saved when a process stops unexpectedly.</p>
<GxNotice tone="danger" v-if="crashes.error">{{ crashes.error }}</GxNotice>
<div class="gx-crash-controls"><input class="gx-field" type="search" v-model="search" placeholder="Search report names" aria-label="Search crash reports"></div>
<div class="gx-crash-controls"><button type="button" class="gx-btn" :disabled="crashes.deleting" @click="crashFeed.remove(null)">Delete All</button><span>{{ crashes.scanIncomplete ? 'At least ' : '' }}{{ crashes.reportCount }} reports · {{ crashes.storageIncomplete ? 'At least ' : '' }}{{ storedSize(crashes.storageBytes) }} stored</span><input class="gx-field" type="search" v-model="search" placeholder="Search report names" aria-label="Search crash reports"></div>
<p v-if="crashes.scanIncomplete" class="gx-note">Directory scan is incomplete; newer reports may be omitted.</p>
<p v-else-if="crashes.listLimited" class="gx-note">Showing the 200 newest reports; older reports are omitted.</p>
<GxState v-if="crashes.status === 'loading'" loading>Loading crash reports…</GxState>
<GxNotice tone="danger" v-else-if="crashes.status === 'unavailable' && !crashes.error">Crash reports are unavailable.</GxNotice>
<GxState v-else-if="crashes.status === 'ready' && !visibleReports.length">{{ search ? 'No matching reports.' : 'No crash reports.' }}</GxState>
<div v-if="crashes.status === 'ready'" class="gx-crash-list">
<button v-for="report in visibleReports" :key="report.id" type="button" class="gx-card gx-crash-row gx-panel" @click="crashFeed.open(report)"><strong>{{ report.name }}</strong><span>{{ reportDate(report.modifiedAt) }} · {{ reportSize(report.size) }}</span></button>
<article v-for="report in visibleReports" :key="report.id" class="gx-card gx-crash-row gx-panel"><button type="button" class="gx-btn" @click="crashFeed.open(report)"><strong>{{ report.name }}</strong><span>{{ reportDate(report.modifiedAt) }} · {{ reportSize(report.size) }}</span></button><button type="button" class="gx-btn" :disabled="crashes.deleting" @click="crashFeed.remove(report.id)">Delete</button></article>
</div>
<GxState v-if="crashes.previewStatus === 'loading'" loading>Loading report…</GxState>
<section v-else-if="crashes.preview" class="gx-card gx-crash-preview gx-panel"><div class="gx-crash-preview__head"><strong>{{ crashes.preview.name }}</strong><button type="button" class="gx-btn gx-btn--tonal" @click="copyPreview">Copy visible text</button></div><p v-if="crashes.preview.truncated" class="gx-note">Preview truncated to the first 256 KiB.</p><pre>{{ crashes.preview.text }}</pre></section>
@@ -61,7 +61,7 @@ export const LongitudinalCurvesPage = {
template: `
<section class="gx-long-curves" aria-label="Longitudinal curves" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Longitudinal Curves</h2>
<p>Review acceleration, braking, and following curves for the next drive.</p></div></header>
<p>Adjust acceleration, braking, and following curves for each driving personality.</p></div></header>
<GxState v-if="mode !== 'local'">Local saved curves are unavailable in preview.</GxState>
<template v-else>
<div class="gx-long-curves__tabs gx-tabs" role="group" aria-label="Personality profile">

Some files were not shown because too many files have changed in this diff Show More