mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-01 03:24:05 +08:00
Complete Galaxy settings and vehicle controls
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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 = ¤t_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.
@@ -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"}},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
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);
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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',
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 & 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')
|
||||
@@ -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]}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user