mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-07 04:53:47 +08:00
285 lines
15 KiB
Python
285 lines
15 KiB
Python
"""One shared saved steering-controller choice with exact source and CP guards."""
|
||
|
||
from unittest.mock import Mock
|
||
from openpilot.starpilot.galaxy.settings import ContextSource
|
||
from pathlib import Path
|
||
import tempfile
|
||
from types import SimpleNamespace
|
||
import unittest
|
||
from unittest import mock
|
||
|
||
from opendbc.car.car_helpers import interfaces
|
||
from opendbc.car.hyundai.values import CAR as HYUNDAI
|
||
from opendbc.car.toyota.values import CAR as TOYOTA
|
||
from opendbc.car.honda.values import CAR as HONDA
|
||
from openpilot.common.params import Params
|
||
from openpilot.starpilot.lateral.controller_selection import (
|
||
DOCUMENT_KEY, ControllerMode, parse_document, read_selection,
|
||
)
|
||
from openpilot.starpilot.lateral.learning_preferences import KEY as LEARNING_KEY, read_preference
|
||
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
|
||
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsRequest, row_change
|
||
from openpilot.starpilot.galaxy.settings import (
|
||
AuthorityContext, SettingsChanged, SettingsGateway, _onroad_preference, _projection,
|
||
)
|
||
|
||
|
||
|
||
def required_change(row: FeatureRow) -> FeatureSettingsRequest:
|
||
request = row_change(row)
|
||
assert request is not None
|
||
return request
|
||
|
||
|
||
class TestControllerFeature(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory()
|
||
self.addCleanup(self.temp.cleanup)
|
||
self.params = Params(self.temp.name)
|
||
self.cp = interfaces[HYUNDAI.HYUNDAI_IONIQ_6].get_non_essential_params(HYUNDAI.HYUNDAI_IONIQ_6)
|
||
self.permitted = True
|
||
self.owner = FeatureSettingsOwner(self.params, lambda group: self.permitted and group == 'preferences',
|
||
vehicle_fingerprint=lambda: str(self.cp.carFingerprint),
|
||
vehicle_params=lambda: self.cp)
|
||
|
||
def row(self, key=DOCUMENT_KEY):
|
||
state = self.owner.snapshot('torque', parked=False, system_long=False, lateral_context=False, metric=False)
|
||
return next((row for row in state.rows if row.key == key), None)
|
||
|
||
def require_row(self, key=DOCUMENT_KEY):
|
||
row = self.row(key)
|
||
assert row is not None
|
||
return row
|
||
|
||
def test_exact_choice_visible_without_manual_tuning_or_park_lock(self):
|
||
for vehicle in (HYUNDAI.HYUNDAI_IONIQ_6, HYUNDAI.GENESIS_G70_2020, TOYOTA.TOYOTA_COROLLA_TSS2):
|
||
with self.subTest(vehicle=vehicle):
|
||
self.cp = interfaces[vehicle].get_non_essential_params(vehicle)
|
||
row = self.require_row()
|
||
self.assertIsNotNone(row)
|
||
self.assertTrue(row.available)
|
||
self.assertEqual((row.label, row.value), ('Steering Controller', 'StarPilot Controller'))
|
||
self.assertEqual(row.choices, ('Stock Controller', 'StarPilot Controller'))
|
||
self.assertIn('next drive', row.reason)
|
||
self.assertNotIn('active', row.reason.lower())
|
||
self.assertEqual(_projection(row)['choices'], list(row.choices))
|
||
self.assertTrue(_onroad_preference('torque', DOCUMENT_KEY))
|
||
self.assertFalse(self.params.get_bool('AdvancedLateralTune'))
|
||
self.cp = interfaces[HYUNDAI.KIA_EV6].get_non_essential_params(HYUNDAI.KIA_EV6)
|
||
self.assertEqual(self.require_row().value, "Stock Controller")
|
||
self.cp = interfaces[HYUNDAI.HYUNDAI_IONIQ_6].get_non_essential_params(HYUNDAI.HYUNDAI_IONIQ_6)
|
||
self.cp.notCar = True
|
||
self.assertIsNone(self.row())
|
||
|
||
def test_registered_stock_torque_uses_scoped_learning_without_controller_choice(self):
|
||
self.cp = interfaces[HONDA.HONDA_FIT_4G].get_non_essential_params(HONDA.HONDA_FIT_4G)
|
||
self.assertEqual(self.cp.lateralTuning.which(), 'torque')
|
||
self.assertIsNone(self.row())
|
||
learning = self.require_row('ForceAutoTuneOff')
|
||
self.assertEqual(learning.label, 'Use StarPilot Auto Learn')
|
||
self.assertEqual(learning.value, 'On')
|
||
self.assertEqual(learning.reason, 'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.')
|
||
self.assertTrue(self.owner.apply(required_change(learning)))
|
||
self.assertFalse(read_preference(self.params, self.cp).enabled)
|
||
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
|
||
self.assertIsNone(self.params.get(DOCUMENT_KEY))
|
||
|
||
def test_stock_pid_torque_uses_same_scoped_learning_preference_only(self):
|
||
self.cp = interfaces[HONDA.HONDA_CIVIC].get_non_essential_params(HONDA.HONDA_CIVIC)
|
||
self.assertEqual(self.cp.lateralTuning.which(), 'pid')
|
||
original = self.cp.as_reader().as_builder().to_bytes()
|
||
self.assertIsNone(self.row()) # No torque-only controller/manual editor.
|
||
learning = self.require_row('ForceAutoTuneOff')
|
||
self.assertEqual((learning.label, learning.value), ('Use StarPilot Auto Learn', 'On'))
|
||
self.assertTrue(learning.available)
|
||
self.assertEqual(_projection(learning)['choices'], ['Off', 'On'])
|
||
self.assertTrue(self.owner.apply(required_change(learning)))
|
||
self.assertFalse(read_preference(self.params, self.cp).enabled)
|
||
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
|
||
self.assertIsNone(self.params.get(DOCUMENT_KEY))
|
||
self.assertEqual(self.cp.as_reader().as_builder().to_bytes(), original)
|
||
self.assertFalse(self.owner.apply(required_change(learning))) # Original expected bytes are stale.
|
||
|
||
def test_learning_capability_rejects_unknown_angle_and_passive_cp(self):
|
||
original = self.cp.as_reader().as_builder()
|
||
for failure in ('unknown', 'brand', 'angle', 'passive', 'dashcam', 'not_car', 'nonfinite'):
|
||
with self.subTest(failure=failure):
|
||
self.cp = original.as_reader().as_builder()
|
||
if failure == 'unknown':
|
||
self.cp.carFingerprint = 'UNREGISTERED_VEHICLE'
|
||
elif failure == 'brand':
|
||
self.cp.brand = 'toyota'
|
||
elif failure == 'angle':
|
||
self.cp.steerControlType = 'angle'
|
||
self.cp.lateralTuning.init('pid')
|
||
elif failure == 'nonfinite':
|
||
self.cp.lateralTuning.torque.latAccelFactor = float('nan')
|
||
else:
|
||
setattr(self.cp, {'passive': 'passive', 'dashcam': 'dashcamOnly', 'not_car': 'notCar'}[failure], True)
|
||
self.assertIsNone(self.owner.controller.learning_capability())
|
||
self.assertIsNone(self.row('ForceAutoTuneOff'))
|
||
self.assertFalse(self.owner.apply(FeatureSettingsRequest('ForceAutoTuneOff', None, 'Off')))
|
||
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
|
||
self.assertIsNone(self.params.get(LEARNING_KEY))
|
||
|
||
def test_write_preserves_other_vehicle_and_manual_preferences(self):
|
||
self.params.put_bool('AdvancedLateralTune', True, block=True)
|
||
self.params.put_bool('ForceAutoTuneOff', True, block=True)
|
||
first = self.require_row()
|
||
request = required_change(first)
|
||
self.assertEqual(request.value, 'Stock Controller')
|
||
self.assertTrue(self.owner.apply(request))
|
||
raw = Path(self.params.get_param_path(DOCUMENT_KEY)).read_bytes()
|
||
self.assertEqual(parse_document(raw)['vehicles']['HYUNDAI_IONIQ_6']['mode'], 'standard')
|
||
self.assertEqual(read_selection(self.params, self.cp).mode, ControllerMode.STANDARD)
|
||
self.cp = interfaces[TOYOTA.TOYOTA_COROLLA_TSS2].get_non_essential_params(TOYOTA.TOYOTA_COROLLA_TSS2)
|
||
other = self.require_row()
|
||
self.assertEqual(other.value, 'StarPilot Controller')
|
||
self.assertTrue(self.owner.apply(required_change(other)))
|
||
after = parse_document(Path(self.params.get_param_path(DOCUMENT_KEY)).read_bytes())['vehicles']
|
||
self.assertEqual(after['HYUNDAI_IONIQ_6']['mode'], 'standard')
|
||
self.assertEqual(after['TOYOTA_COROLLA_TSS2']['mode'], 'standard')
|
||
self.assertTrue(self.params.get_bool('AdvancedLateralTune'))
|
||
self.assertTrue(self.params.get_bool('ForceAutoTuneOff'))
|
||
|
||
def test_stale_document_cp_and_authority_rejected(self):
|
||
row = self.require_row()
|
||
request = required_change(row)
|
||
self.cp.lateralTuning.torque.latAccelFactor *= 1.05
|
||
self.assertFalse(self.owner.apply(request))
|
||
self.cp = interfaces[HYUNDAI.HYUNDAI_IONIQ_6].get_non_essential_params(HYUNDAI.HYUNDAI_IONIQ_6)
|
||
row = self.require_row()
|
||
request = required_change(row)
|
||
self.permitted = False
|
||
self.assertFalse(self.owner.apply(request))
|
||
self.permitted = True
|
||
row = self.require_row()
|
||
request = required_change(row)
|
||
self.cp = interfaces[HYUNDAI.GENESIS_G70_2020].get_non_essential_params(HYUNDAI.GENESIS_G70_2020)
|
||
self.assertFalse(self.owner.apply(request))
|
||
self.cp = interfaces[HYUNDAI.HYUNDAI_IONIQ_6].get_non_essential_params(HYUNDAI.HYUNDAI_IONIQ_6)
|
||
request = required_change(self.require_row())
|
||
Path(self.params.get_param_path(DOCUMENT_KEY)).write_bytes(b'{"version":1,"vehicles":{}}')
|
||
self.assertFalse(self.owner.apply(request))
|
||
|
||
def test_learning_choice_survives_controller_and_vehicle_changes(self):
|
||
learning = self.require_row('ForceAutoTuneOff')
|
||
self.assertTrue(learning.available)
|
||
self.assertEqual(learning.value, 'On')
|
||
self.assertEqual(learning.reason, 'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.')
|
||
self.assertTrue(self.owner.apply(required_change(learning))) # Explicit Off for Ioniq.
|
||
self.assertFalse(read_preference(self.params, self.cp).enabled)
|
||
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
|
||
for _ in range(2):
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row())))
|
||
self.assertEqual(self.require_row('ForceAutoTuneOff').value, 'Off')
|
||
first_cp = self.cp
|
||
self.cp = interfaces[TOYOTA.TOYOTA_COROLLA_TSS2].get_non_essential_params(TOYOTA.TOYOTA_COROLLA_TSS2)
|
||
self.assertEqual(self.require_row('ForceAutoTuneOff').value, 'On')
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row('ForceAutoTuneOff'))))
|
||
self.cp = first_cp
|
||
self.assertEqual(self.require_row('ForceAutoTuneOff').value, 'Off')
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row('ForceAutoTuneOff'))))
|
||
self.assertEqual(self.require_row('ForceAutoTuneOff').value, 'On')
|
||
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
|
||
|
||
def test_learning_source_and_controller_changes_revoke_requests(self):
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row())))
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row('ForceAutoTuneOff'))))
|
||
request = required_change(self.require_row())
|
||
self.params.put_bool('ForceAutoTuneOff', True, block=True)
|
||
self.assertFalse(self.owner.apply(request))
|
||
learning = required_change(self.require_row('ForceAutoTuneOff'))
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row())))
|
||
self.assertFalse(self.owner.apply(learning))
|
||
Path(self.params.get_param_path(LEARNING_KEY)).write_bytes(b'invalid')
|
||
self.assertFalse(self.require_row().available)
|
||
self.assertFalse(self.require_row('ForceAutoTuneOff').available)
|
||
|
||
def test_controller_save_never_writes_learning_preference(self):
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row('ForceAutoTuneOff'))))
|
||
saved = self.params.get(LEARNING_KEY)
|
||
from openpilot.starpilot.ui import controller_feature
|
||
commit = controller_feature.commit_exact
|
||
writes = []
|
||
|
||
def recorded(params, **kwargs):
|
||
writes.append(kwargs['key'])
|
||
return commit(params, **kwargs)
|
||
|
||
with mock.patch.object(controller_feature, 'commit_exact', side_effect=recorded):
|
||
self.assertTrue(self.owner.apply(required_change(self.require_row())))
|
||
self.assertEqual(writes, [DOCUMENT_KEY])
|
||
self.assertEqual(self.params.get(LEARNING_KEY), saved)
|
||
self.assertIsNone(self.params.get('ForceAutoTuneOff'))
|
||
|
||
def test_corrupt_or_unreadable_document_fails_closed_without_overwrite(self):
|
||
path = Path(self.params.get_param_path(DOCUMENT_KEY))
|
||
for raw in (b'not-json', b'{"version":1,"vehicles":{},"vehicles":{}}',
|
||
b'{"version":2,"vehicles":{}}', b'x' * 4097):
|
||
with self.subTest(raw=raw[:30]):
|
||
path.write_bytes(raw)
|
||
row = self.require_row()
|
||
self.assertFalse(row.available)
|
||
self.assertEqual(row.value, 'Invalid saved controller choice')
|
||
self.assertFalse(self.owner.apply(FeatureSettingsRequest(DOCUMENT_KEY, raw, 'Stock Controller',
|
||
vehicle_fingerprint=str(self.cp.carFingerprint),
|
||
capability=row.capability)))
|
||
self.assertEqual(path.read_bytes(), raw)
|
||
|
||
def test_galaxy_onroad_saved_choice_preview_confirm_and_revocation(self):
|
||
current = SimpleNamespace(parked=False, cp=self.cp, raw=b'current-cp')
|
||
context = Mock(spec=ContextSource, sample=lambda: AuthorityContext(current.parked, current.cp, current.raw))
|
||
gateway = SettingsGateway(self.params, context, clock=lambda: 10)
|
||
|
||
def preview():
|
||
page = gateway.page('torque', 'session', b'generation')
|
||
index = next(i for i, row in enumerate(page['rows']) if row['label'] == 'Steering Controller')
|
||
return gateway.preview(page['view'], index, 0, 'session', b'generation', value='Stock Controller')
|
||
|
||
intent = preview()
|
||
self.assertIn('created when the driving process starts', intent['question'])
|
||
self.assertTrue(gateway.confirm(intent['intent'], 'session', b'generation'))
|
||
self.assertEqual(read_selection(self.params, self.cp).mode, ControllerMode.STANDARD)
|
||
self.assertFalse(current.parked) # Saved preference did not require a Park state.
|
||
|
||
stale = preview()
|
||
path = Path(self.params.get_param_path(DOCUMENT_KEY))
|
||
path.write_bytes(b'{"version":1,"vehicles":{}}')
|
||
self.assertFalse(gateway.confirm(stale['intent'], 'session', b'generation'))
|
||
|
||
fresh = preview()
|
||
current.raw = b'changed-cp'
|
||
with self.assertRaises(SettingsChanged):
|
||
gateway.confirm(fresh['intent'], 'session', b'generation')
|
||
current.raw = b'current-cp'
|
||
|
||
expired = preview()
|
||
with self.assertRaises(SettingsChanged):
|
||
gateway.confirm(expired['intent'], 'session', b'other-generation')
|
||
current.cp.lateralTuning.torque.latAccelFactor *= 1.05
|
||
self.assertFalse(gateway.confirm(expired['intent'], 'session', b'generation'))
|
||
|
||
|
||
def test_native_and_galaxy_learning_help_names_actual_learning_controller(self):
|
||
import json
|
||
from openpilot.starpilot.lateral.controller_selection import replace_mode
|
||
self.cp = interfaces[TOYOTA.TOYOTA_RAV4_TSS2].get_non_essential_params(TOYOTA.TOYOTA_RAV4_TSS2)
|
||
for mode, _label in ((ControllerMode.STANDARD, "Stock Controller"),
|
||
(ControllerMode.STARPILOT, "StarPilot Controller")):
|
||
with self.subTest(mode=mode):
|
||
self.params.put(DOCUMENT_KEY, json.loads(replace_mode(None, self.cp, mode)), block=True)
|
||
state = self.owner.snapshot('torque', parked=False, system_long=False, lateral_context=False, metric=False)
|
||
learning_rows = [row for row in state.rows if row.key == 'ForceAutoTuneOff']
|
||
self.assertEqual(len(learning_rows), 1)
|
||
row = learning_rows[0]
|
||
self.assertEqual(row.label, 'Use StarPilot Auto Learn')
|
||
self.assertTrue(row.available)
|
||
self.assertEqual(row.value, 'On')
|
||
self.assertEqual(row.reason, 'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.')
|
||
from openpilot.starpilot.galaxy.setting_descriptions import description
|
||
self.assertEqual(_projection(row)['reason'], description(row))
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|