Compare commits

..

7 Commits

Author SHA1 Message Date
whoisdomi 2a04e4bd31 Summer cooling curve 2026-09-06 19:33:37 -05:00
whoisdomi 6f22a50edc big model firmware blob fetch 2026-09-06 16:25:05 -05:00
whoisdomi c72c4c2073 Big Model race condition 2026-09-06 14:22:07 -05:00
whoisdomi d52bf831ae Keep logging 60sec after Offroad 2026-09-06 11:00:33 -05:00
firestarsdog e16e85d131 Revert "Test fix - revert if nukes galaxy lol"
This reverts commit f51059956c.
2026-09-05 21:40:26 -05:00
whoisdomi 547b935e16 Match C4 cooling curve 2026-09-05 21:24:10 -05:00
whoisdomi f1bcffd279 CSC rewrite 2026-09-05 21:23:22 -05:00
32 changed files with 1608 additions and 1563 deletions
@@ -1168,6 +1168,7 @@ def test_starpilot_planner_updates_cem_with_current_frame_state(monkeypatch):
try: try:
monkeypatch.setattr(starpilot_planner_module, "calculate_road_curvature", lambda model, v_ego: (0.01, 1.0)) monkeypatch.setattr(starpilot_planner_module, "calculate_road_curvature", lambda model, v_ego: (0.01, 1.0))
monkeypatch.setattr(starpilot_planner_module, "extract_curve_profile", lambda model: ([], []))
monkeypatch.setattr(planner.starpilot_acceleration, "update", lambda *args, **kwargs: None) monkeypatch.setattr(planner.starpilot_acceleration, "update", lambda *args, **kwargs: None)
monkeypatch.setattr(planner.starpilot_events, "update", lambda *args, **kwargs: None) monkeypatch.setattr(planner.starpilot_events, "update", lambda *args, **kwargs: None)
monkeypatch.setattr(planner.starpilot_vcruise, "update", lambda *args, **kwargs: 0.0) monkeypatch.setattr(planner.starpilot_vcruise, "update", lambda *args, **kwargs: 0.0)
@@ -0,0 +1,506 @@
import numpy as np
import pytest
from types import SimpleNamespace
from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.common.starpilot_variables import DEFAULT_LATERAL_ACCELERATION
from openpilot.starpilot.controls.lib.curve_speed_controller import (
CSC_APPROACH_DECEL,
CSC_COMFORT_MARGIN,
CSC_COUNT_CAP,
CSC_EGO_HEADROOM,
CSC_FARFIELD_GAIN,
CSC_LAT_ACCEL_MAX,
CSC_MIN_SPEED,
MAX_CURVATURE,
PRIOR_CURVATURE_BP,
PRIOR_LAT_ACCEL_V,
CSC_NUDGE,
CSC_NUDGE_WEIGHT,
CSC_OVERRIDE_WATCH_TIME,
CSC_TARGET_UP_RATE,
CSC_TRAINING_SETTLE_TIME,
CurveSpeedController,
weighted_isotonic,
)
class FakeParams:
def __init__(self, values=None):
self.values = dict(values or {})
def get(self, *args, **kwargs):
key = args[0] if args else None
return self.values.get(key)
def put_nonblocking(self, key, value):
self.values[key] = value
def make_controller(curve_profile=None, curvature_data=None, weather_id=0, reduce_lat=0.0, road_curvature=0.02, driving_in_curve=False):
if curve_profile is None:
curve_profile = (np.zeros(33), np.linspace(0.0, 300.0, 33))
planner = SimpleNamespace(
params=FakeParams({"CurvatureData": curvature_data} if curvature_data is not None else None),
curve_profile=curve_profile,
starpilot_weather=SimpleNamespace(weather_id=weather_id, reduce_lateral_acceleration=reduce_lat),
road_curvature=road_curvature,
driving_in_curve=driving_in_curve,
tracking_lead=False,
lateral_acceleration=0.0,
)
controller = CurveSpeedController(SimpleNamespace(starpilot_planner=planner))
return planner, controller
def make_sm(*, gas=False, brake=False, long_active=True, blinker=False, accel_pressed=False):
return {
"carControl": SimpleNamespace(longActive=long_active),
"carState": SimpleNamespace(gasPressed=gas, brakePressed=brake, leftBlinker=blinker, rightBlinker=False),
"starpilotCarState": SimpleNamespace(accelPressed=accel_pressed),
"onroadEvents": [],
}
def single_apex_profile(curvature, distance):
distances = np.linspace(0.0, max(distance * 1.5, 1.0), 33)
curvatures = np.zeros(33)
index = int(np.argmin(np.abs(distances - distance)))
distances[index] = distance
curvatures[index] = curvature
return curvatures, distances
def converge(controller, v_ego, v_cruise, frames=600):
for _ in range(frames):
controller.update_target(v_ego, v_cruise)
return controller.target
def envelope_speed(controller, curvature, distance):
curve_speed = max(float(np.sqrt(controller.lat_accel_for_curvature(curvature) / curvature)), CSC_MIN_SPEED)
return float(np.sqrt(curve_speed**2 + 2.0 * CSC_APPROACH_DECEL * distance))
def test_straight_road_target_is_cruise_speed():
_, controller = make_controller()
controller.update_target(30.0, 30.0)
assert controller.target == pytest.approx(30.0)
def test_distant_apex_does_not_constrain_until_braking_is_due():
# derived from the shipped decel so retuning it doesn't silently invalidate the case
_, probe = make_controller()
curve_speed = max(float(np.sqrt(probe.lat_accel_for_curvature(0.02) / 0.02)), CSC_MIN_SPEED)
beyond_braking = 1.3 * (30.0**2 - curve_speed**2) / (2 * CSC_APPROACH_DECEL)
_, controller = make_controller(curve_profile=single_apex_profile(0.02, beyond_braking))
target = converge(controller, 30.0, 30.0)
assert target == pytest.approx(30.0)
def test_apex_in_braking_range_constrains_to_kinematic_envelope():
_, controller = make_controller(curve_profile=single_apex_profile(0.02, 150.0))
target = converge(controller, 30.0, 30.0)
assert target == pytest.approx(envelope_speed(controller, 0.02, 150.0), abs=0.1)
assert target < 30.0
def test_exit_recovery_rises_immediately_without_freeze():
planner, controller = make_controller(curve_profile=single_apex_profile(0.03, 20.0))
low_target = converge(controller, 15.0, 30.0)
assert low_target < 20.0
planner.curve_profile = (np.zeros(33), np.linspace(0.0, 300.0, 33))
controller.update_target(15.0, 30.0)
assert controller.target > low_target # rises on the very next frame, no freeze
assert controller.target - low_target == pytest.approx(CSC_TARGET_UP_RATE * DT_MDL)
# and it clears the car by the headroom within the time the up-rate needs
frames = int((15.0 + CSC_EGO_HEADROOM - controller.target) / (CSC_TARGET_UP_RATE * DT_MDL)) + 1
for _ in range(frames):
controller.update_target(15.0, 30.0)
assert controller.target >= 15.0 + CSC_EGO_HEADROOM
recovered = converge(controller, 15.0, 30.0)
assert recovered == pytest.approx(30.0)
def test_upward_jitter_in_the_envelope_is_rate_limited():
# a sweeper the envelope only grazes: raw_target flicks between a mild cap and the
# set speed. The target must not chase the jumps, or the glow strobes.
planner, controller = make_controller(curve_profile=single_apex_profile(0.002, 40.0))
steady = converge(controller, 30.0, 32.0)
assert steady < 32.0
flat = (np.zeros(33), np.linspace(0.0, 300.0, 33))
grazing = planner.curve_profile
peak = steady
for i in range(40):
planner.curve_profile = flat if i % 2 else grazing
controller.update_target(30.0, 32.0)
assert controller.target - peak <= CSC_TARGET_UP_RATE * DT_MDL + 1e-6
peak = controller.target
def test_firm_distant_curvature_is_corrected_for_the_model_under_read():
# the model reads ~0.81x actual at range, so a firm distant bend binds later than it should
distance = 90.0
_, plain = make_controller(curve_profile=single_apex_profile(0.0045, distance))
_, probe = make_controller()
corrected = probe._correct_far_field(*single_apex_profile(0.0045, distance))
assert corrected.max() == pytest.approx(0.0045 * CSC_FARFIELD_GAIN)
assert converge(plain, 30.0, 30.0) < envelope_speed(plain, 0.0045, distance) + 1e-6
def test_weak_or_near_readings_are_left_alone():
_, probe = make_controller()
# too weak to carry usable magnitude at range
weak = probe._correct_far_field(*single_apex_profile(0.002, 90.0))
assert weak.max() == pytest.approx(0.002)
# firm, but close enough that the model is already accurate
near = probe._correct_far_field(*single_apex_profile(0.0045, 10.0))
assert near.max() == pytest.approx(0.0045)
def test_far_field_correction_brings_the_slowdown_forward():
profile = single_apex_profile(0.0045, 120.0)
_, controller = make_controller(curve_profile=profile)
corrected = converge(controller, 30.0, 30.0)
raw_curvatures, distances = profile
uncorrected = float(np.sqrt(
max(np.sqrt(controller.lat_accel_for_curvature(0.0045) / 0.0045), CSC_MIN_SPEED) ** 2
+ 2.0 * CSC_APPROACH_DECEL * 120.0))
assert corrected < uncorrected # binds sooner than the model's own reading would
def test_fresh_activation_seeds_at_envelope_not_cruise():
_, controller = make_controller(curve_profile=(np.full(33, 0.05), np.linspace(0.0, 60.0, 33)))
controller.update_target(6.0, 30.0)
assert controller.target < 15.0
def test_target_never_trails_accelerating_car_when_unconstrained():
planner, controller = make_controller(curve_profile=single_apex_profile(0.03, 20.0))
converge(controller, 15.0, 30.0)
planner.curve_profile = (np.zeros(33), np.linspace(0.0, 300.0, 33))
v_ego = 15.0
caught_up = None
for frame in range(200):
v_ego = min(v_ego + 2.0 * DT_MDL, 30.0)
controller.update_target(v_ego, 30.0)
# the target climbs faster than the car can, so once it is ahead it stays ahead
if controller.target >= v_ego:
caught_up = caught_up if caught_up is not None else frame
assert caught_up is None or controller.target >= min(30.0, v_ego) - 1e-6
assert caught_up is not None and caught_up * DT_MDL < 2.0
assert controller.target == pytest.approx(30.0)
def test_target_does_not_ratchet_down_with_ego_speed():
_, controller = make_controller(curve_profile=single_apex_profile(0.02, 150.0))
target = converge(controller, 30.0, 30.0)
assert target > CSC_MIN_SPEED # a real curve speed, not floored
controller.update_target(14.0, 30.0)
assert controller.target == pytest.approx(target, abs=0.2)
def test_sharp_curve_target_floors_at_min_speed():
_, controller = make_controller(curve_profile=(np.full(33, 0.1), np.linspace(0.0, 100.0, 33)))
target = converge(controller, 15.0, 30.0)
assert target == pytest.approx(CSC_MIN_SPEED, abs=0.05)
def test_weather_reduces_curve_speed():
_, dry = make_controller(curve_profile=single_apex_profile(0.01, 0.0))
_, wet = make_controller(curve_profile=single_apex_profile(0.01, 0.0), weather_id=1, reduce_lat=0.2)
dry_target = converge(dry, 20.0, 30.0)
wet_target = converge(wet, 20.0, 30.0)
assert wet_target < dry_target
assert wet_target == pytest.approx(dry_target * np.sqrt(0.8), abs=0.1)
def test_prior_gives_higher_lat_accel_for_sharper_curves():
_, controller = make_controller()
assert controller.learned_lat_accel(0.001) == pytest.approx(1.5, abs=0.05)
assert controller.learned_lat_accel(MAX_CURVATURE) > controller.learned_lat_accel(0.001)
assert controller.learned_lat_accel(MAX_CURVATURE) == pytest.approx(
float(np.interp(MAX_CURVATURE, PRIOR_CURVATURE_BP, PRIOR_LAT_ACCEL_V)), abs=0.05)
assert controller.lateral_acceleration == pytest.approx(DEFAULT_LATERAL_ACCELERATION)
def test_comfort_margin_matches_the_learned_habit():
# margin is fixed at 1.0 -- CSC targets exactly the driver's own learned comfort
_, controller = make_controller()
assert CSC_COMFORT_MARGIN == pytest.approx(1.0)
assert controller.lat_accel_for_curvature(0.01) == pytest.approx(controller.learned_lat_accel(0.01))
def test_binding_distance_reports_the_constraining_point():
_, controller = make_controller(curve_profile=single_apex_profile(0.02, 150.0))
converge(controller, 30.0, 30.0)
assert controller.binding_distance == pytest.approx(150.0, abs=1.0)
def test_binding_distance_is_zero_when_unconstrained():
_, controller = make_controller()
converge(controller, 30.0, 30.0)
assert controller.binding_distance == 0.0
def test_heavily_sampled_bucket_dominates_prior():
_, controller = make_controller(curvature_data={"0.05": {"average": 3.0, "count": 100000}})
assert controller.learned_lat_accel(0.05) == pytest.approx(3.0, abs=0.05)
assert controller.learned_lat_accel(0.08) >= controller.learned_lat_accel(0.05)
def test_learned_curve_stays_monotonic_despite_low_outlier_bucket():
_, controller = make_controller(curvature_data={"0.05": {"average": 0.5, "count": 100000}})
assert controller.learned_lat_accel(0.05) >= controller.learned_lat_accel(0.03)
def test_dense_bucket_is_not_overridden_by_sparse_neighbour():
# real device data: a running maximum ratcheted the 80-sample bucket up to the 20-sample neighbour
_, dense_low = make_controller(curvature_data={
"0.003": {"average": 1.95, "count": 20},
"0.005": {"average": 1.38, "count": 80},
})
_, dense_high = make_controller(curvature_data={
"0.003": {"average": 1.95, "count": 80},
"0.005": {"average": 1.38, "count": 20},
})
assert dense_low.learned_lat_accel(0.005) < 1.95 # not ratcheted to the sparse neighbour
assert dense_low.learned_lat_accel(0.005) >= dense_low.learned_lat_accel(0.003)
# whichever side is better sampled should pull the fit: swapping the counts must raise it
assert dense_high.learned_lat_accel(0.005) > dense_low.learned_lat_accel(0.005)
def test_weighted_isotonic_pools_violators_by_weight():
fitted = weighted_isotonic(np.array([1.0, 3.0, 1.2]), np.array([1.0, 1.0, 1000.0]))
assert np.all(np.diff(fitted) >= -1e-9)
assert fitted[-1] == pytest.approx(1.2, abs=0.02)
def test_weighted_isotonic_leaves_sorted_input_untouched():
values = np.array([1.0, 1.5, 2.0, 2.5])
fitted = weighted_isotonic(values, np.ones(4))
assert fitted == pytest.approx(values)
def test_legacy_off_grid_curvature_data_merges_into_buckets():
_, controller = make_controller(curvature_data={
"0.0203": {"average": 2.5, "count": 10},
"0.02": {"average": 2.0, "count": 10},
})
assert controller.curvature_data["0.02"]["count"] == 20
assert controller.curvature_data["0.02"]["average"] == pytest.approx(2.25)
def test_training_update_step_is_capped_by_ema_count():
planner, controller = make_controller(curvature_data={"0.02": {"average": 2.0, "count": 10000}}, driving_in_curve=True)
planner.lateral_acceleration = 3.0
controller.training_timer = CSC_TRAINING_SETTLE_TIME
controller.log_data(10.0, make_sm(long_active=False))
data = controller.curvature_data["0.02"]
assert data["count"] == 10001
assert data["average"] == pytest.approx((2.0 * CSC_COUNT_CAP + 3.0) / (CSC_COUNT_CAP + 1))
def test_no_passive_training_right_after_csc_limited_speed():
planner, controller = make_controller(curve_profile=single_apex_profile(0.03, 20.0), driving_in_curve=True)
planner.lateral_acceleration = 3.0
converge(controller, 15.0, 30.0)
assert controller.training_quiet_timer > 0.0
controller.training_timer = CSC_TRAINING_SETTLE_TIME
controller.log_data(10.0, make_sm(long_active=False))
assert "0.02" not in controller.curvature_data
assert not controller.enable_training
controller.training_quiet_timer = 0.0
controller.training_timer = CSC_TRAINING_SETTLE_TIME
controller.log_data(10.0, make_sm(long_active=False))
assert controller.curvature_data["0.02"]["count"] == 1
def test_training_settles_within_a_couple_of_seconds():
# a real drive rarely holds every eligibility condition for a whole model horizon,
# so the settle time has to be short enough that ordinary curves still teach it
planner, controller = make_controller(driving_in_curve=True)
planner.lateral_acceleration = 2.4
sm = make_sm(long_active=False)
for _ in range(int(CSC_TRAINING_SETTLE_TIME / DT_MDL) - 2):
controller.log_data(10.0, sm)
assert "0.02" not in controller.curvature_data
for _ in range(3):
controller.log_data(10.0, sm)
assert controller.curvature_data["0.02"]["count"] >= 1
def test_brief_ineligibility_does_not_restart_the_settle_timer():
planner, controller = make_controller(driving_in_curve=True)
planner.lateral_acceleration = 2.4
sm = make_sm(long_active=False)
for _ in range(int(CSC_TRAINING_SETTLE_TIME / DT_MDL) + 1):
controller.log_data(10.0, sm)
trained = controller.curvature_data["0.02"]["count"]
# a lead flickers into the tracker for two frames, then leaves
planner.tracking_lead = True
controller.log_data(10.0, sm)
controller.log_data(10.0, sm)
planner.tracking_lead = False
controller.log_data(10.0, sm)
assert controller.curvature_data["0.02"]["count"] == trained + 1
def test_sustained_ineligibility_still_drains_the_settle_timer():
planner, controller = make_controller(driving_in_curve=True)
planner.lateral_acceleration = 2.4
engaged = make_sm(long_active=True)
manual = make_sm(long_active=False)
for _ in range(int(CSC_TRAINING_SETTLE_TIME / DT_MDL) + 1):
controller.log_data(10.0, manual)
for _ in range(int(2 * CSC_TRAINING_SETTLE_TIME / DT_MDL)):
controller.log_data(10.0, engaged)
assert controller.training_timer == pytest.approx(0.0)
controller.log_data(10.0, manual)
assert not controller.enable_training
def settle_override(controller, sm=None, frames=None):
"""Run the post-override watch out so the pseudo-sample is committed."""
sm = sm if sm is not None else make_sm()
for _ in range(frames if frames is not None else int(CSC_OVERRIDE_WATCH_TIME / DT_MDL) + 1):
controller.handle_override(20.0, False, sm)
def test_gas_override_nudges_bucket_up_once_per_episode():
_, controller = make_controller()
prior = controller.learned_lat_accel(0.02)
controller.target = 10.0
controller.handle_override(20.0, True, make_sm(gas=True))
controller.handle_override(20.0, True, make_sm(gas=True))
assert "0.02" not in controller.curvature_data # still watching what the driver holds
settle_override(controller)
assert controller.curvature_data["0.02"]["count"] == CSC_NUDGE_WEIGHT
assert controller.curvature_data["0.02"]["average"] > prior
controller.handle_override(20.0, False, make_sm())
controller.target = 10.0
controller.handle_override(20.0, True, make_sm(gas=True))
settle_override(controller)
assert controller.curvature_data["0.02"]["count"] == 2 * CSC_NUDGE_WEIGHT
def test_override_learns_the_cornering_the_driver_actually_held():
# the whole point: a fixed step needs several rejections to close a real disagreement,
# so record what they demonstrated instead
planner, observed = make_controller(driving_in_curve=True)
observed.target = 10.0
observed.handle_override(20.0, True, make_sm(gas=True))
planner.lateral_acceleration = 2.9 # they hold the curve much harder than CSC wanted
settle_override(observed, make_sm(gas=True))
_, stepped = make_controller(driving_in_curve=True)
stepped._apply_nudge(CSC_NUDGE) # what the old fixed-step path would have recorded
assert observed.curvature_data["0.02"]["average"] == pytest.approx(2.9)
assert observed.curvature_data["0.02"]["average"] > stepped.curvature_data["0.02"]["average"]
assert observed.learned_lat_accel(0.02) > stepped.learned_lat_accel(0.02)
def test_override_on_a_straight_still_registers_the_fixed_step():
planner, controller = make_controller()
prior = controller.learned_lat_accel(0.02)
controller.target = 10.0
controller.handle_override(20.0, True, make_sm(gas=True))
planner.lateral_acceleration = 0.0 # never reached a corner
settle_override(controller)
assert controller.curvature_data["0.02"]["average"] == pytest.approx(prior + CSC_NUDGE)
def test_res_button_nudges_bucket_up_even_at_target_speed():
_, controller = make_controller()
prior = controller.learned_lat_accel(0.02)
controller.target = 20.0 # car tracking the target, so the gas-press condition would not fire
controller.handle_override(20.0, True, make_sm(), accel_button=True)
settle_override(controller)
assert controller.curvature_data["0.02"]["count"] == CSC_NUDGE_WEIGHT
assert controller.curvature_data["0.02"]["average"] > prior
def test_brake_override_nudges_bucket_down():
_, controller = make_controller(driving_in_curve=True)
prior = controller.learned_lat_accel(0.02)
controller.handle_override(20.0, True, make_sm(brake=True))
assert controller.curvature_data["0.02"]["count"] == CSC_NUDGE_WEIGHT
assert controller.curvature_data["0.02"]["average"] < prior
def test_calibrated_lateral_acceleration_param_is_written_on_flush():
planner, controller = make_controller(curvature_data={"0.02": {"average": 2.8, "count": 5000}})
assert "CalibratedLateralAcceleration" not in planner.params.values
controller.flush_data()
assert planner.params.values["CalibratedLateralAcceleration"] > DEFAULT_LATERAL_ACCELERATION
assert controller.lateral_acceleration == planner.params.values["CalibratedLateralAcceleration"]
def test_stale_param_from_a_previous_build_is_republished_without_training():
# a stale value must not survive a restart just because this drive never trained
planner, controller = make_controller(curvature_data={"0.02": {"average": 2.8, "count": 5000}})
planner.params.values["CalibratedLateralAcceleration"] = 3.71
controller.log_data(0.0, make_sm()) # standstill: ineligible -> flush path
assert planner.params.values["CalibratedLateralAcceleration"] <= CSC_LAT_ACCEL_MAX
@@ -4,7 +4,8 @@ import pytest
from openpilot.common.constants import CV from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.controls.lib.curve_speed_controller import CSC_MAX_DECEL_RATE, CurveSpeedController, MIN_TRAINING_TIME from openpilot.starpilot.common.starpilot_variables import PLANNER_TIME
from openpilot.starpilot.controls.lib.curve_speed_controller import CSC_GLOW_HOLD_TIME, CSC_GLOW_ON_DELTA
from openpilot.starpilot.controls.lib.starpilot_vcruise import ( from openpilot.starpilot.controls.lib.starpilot_vcruise import (
FORCE_STOP_CAP_SLACK_M, FORCE_STOP_CAP_SLACK_M,
FORCE_STOP_TURN_VETO_STOP_SEEN_HOLD_TIME, FORCE_STOP_TURN_VETO_STOP_SEEN_HOLD_TIME,
@@ -53,6 +54,7 @@ def make_vcruise(*, red_light=False, raw_model_stopped=False, forcing_stop=False
raw_model_stopped=raw_model_stopped, raw_model_stopped=raw_model_stopped,
road_curvature=road_curvature, road_curvature=road_curvature,
road_curvature_detected=False, road_curvature_detected=False,
lateral_acceleration=0.0,
) )
vcruise = StarPilotVCruise(planner) vcruise = StarPilotVCruise(planner)
vcruise.forcing_stop = forcing_stop vcruise.forcing_stop = forcing_stop
@@ -82,12 +84,12 @@ def make_sm(*, standstill=True, min_steer_speed=0.0, car_fingerprint=""):
} }
def update_vcruise(vcruise, sm, toggles, *, now, v_ego=0.0, controls_enabled=True): def update_vcruise(vcruise, sm, toggles, *, now, v_ego=0.0, v_cruise=20.0, controls_enabled=True):
return vcruise.update( return vcruise.update(
controls_enabled=controls_enabled, controls_enabled=controls_enabled,
now=now, now=now,
time_validated=True, time_validated=True,
v_cruise=20.0, v_cruise=v_cruise,
v_ego=v_ego, v_ego=v_ego,
sm=sm, sm=sm,
starpilot_toggles=toggles, starpilot_toggles=toggles,
@@ -145,30 +147,56 @@ def test_santa_fe_force_stop_tune_only_applies_to_that_car():
assert get_force_stop_low_speed_hold(other) is None assert get_force_stop_low_speed_hold(other) is None
def test_curve_speed_controller_holds_target_through_brief_detector_dropout(): def test_curve_speed_controller_blinker_releases_the_cap_but_keeps_the_plan():
planner, vcruise = make_vcruise() planner, vcruise = make_vcruise()
sm = make_sm(standstill=False) sm = make_sm(standstill=False)
toggles = make_toggles() toggles = make_toggles()
toggles.curve_speed_controller = True toggles.curve_speed_controller = True
def set_curve_target(_v_ego): calls = []
vcruise.csc.target_set = True
def set_curve_target(_v_ego, _v_cruise):
calls.append(_v_ego)
vcruise.csc.target = 14.0 vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target vcruise.csc.update_target = set_curve_target
planner.road_curvature_detected = True
result = update_vcruise(vcruise, sm, toggles, now=10.0, v_ego=20.0) result = update_vcruise(vcruise, sm, toggles, now=10.0, v_ego=20.0)
assert result == pytest.approx(14.0) assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed assert vcruise.csc_controlling_speed
planner.road_curvature_detected = False # the cap lifts so CSC can't fight the lane change, but the envelope keeps planning
# so the curve doesn't have to be re-discovered from the set speed afterwards
sm["carState"].leftBlinker = True
result = update_vcruise(vcruise, sm, toggles, now=10.25, v_ego=20.0) result = update_vcruise(vcruise, sm, toggles, now=10.25, v_ego=20.0)
assert result == pytest.approx(20.0)
assert not vcruise.csc_controlling_speed
assert len(calls) == 2 # still planning, so nothing has to be rediscovered
# blinker off: the plan is already current, so the cap comes straight back
sm["carState"].leftBlinker = False
result = update_vcruise(vcruise, sm, toggles, now=10.5, v_ego=20.0)
assert result == pytest.approx(14.0) assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed assert vcruise.csc_controlling_speed
result = update_vcruise(vcruise, sm, toggles, now=10.8, v_ego=20.0)
assert result == pytest.approx(20.0) def test_curve_speed_controller_reseeds_after_a_real_dropout():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target
update_vcruise(vcruise, sm, toggles, now=11.0, v_ego=20.0)
assert vcruise.csc_controlling_speed
# disengaging is a real dropout, not a momentary veto -- that still resets
sm["carControl"].longActive = False
update_vcruise(vcruise, sm, toggles, now=11.05, v_ego=20.0)
assert not vcruise.csc_controlling_speed assert not vcruise.csc_controlling_speed
assert vcruise.csc.seed_pending
def test_curve_speed_controller_releases_immediately_when_disabled(): def test_curve_speed_controller_releases_immediately_when_disabled():
@@ -177,53 +205,19 @@ def test_curve_speed_controller_releases_immediately_when_disabled():
toggles = make_toggles() toggles = make_toggles()
toggles.curve_speed_controller = True toggles.curve_speed_controller = True
def set_curve_target(_v_ego): def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target_set = True
vcruise.csc.target = 14.0 vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target vcruise.csc.update_target = set_curve_target
planner.road_curvature_detected = True
update_vcruise(vcruise, sm, toggles, now=20.0, v_ego=20.0) update_vcruise(vcruise, sm, toggles, now=20.0, v_ego=20.0)
assert vcruise.csc_controlling_speed assert vcruise.csc_controlling_speed
planner.road_curvature_detected = False
toggles.curve_speed_controller = False toggles.curve_speed_controller = False
result = update_vcruise(vcruise, sm, toggles, now=20.1, v_ego=20.0) result = update_vcruise(vcruise, sm, toggles, now=20.1, v_ego=20.0)
assert result == pytest.approx(20.0) assert result == pytest.approx(20.0)
assert not vcruise.csc_controlling_speed assert not vcruise.csc_controlling_speed
def test_curve_speed_controller_does_not_compete_with_force_stop():
planner, vcruise = make_vcruise(red_light=True, road_curvature=0.001)
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
planner.road_curvature_detected = True
vcruise.csc.target_set = True
vcruise.csc.target = 12.0
update_vcruise(vcruise, sm, toggles, now=25.0, v_ego=20.0)
assert not vcruise.csc_controlling_speed
assert not vcruise.csc.target_set
def test_curve_speed_controller_learns_through_a_signaled_curve():
planner, vcruise = make_vcruise(road_curvature=0.02)
sm = make_sm(standstill=False)
sm["carControl"].longActive = False
sm["carState"].leftBlinker = True
planner.driving_in_curve = True
planner.lateral_acceleration = 2.4
vcruise.csc.training_timer = MIN_TRAINING_TIME
vcruise.csc.log_data(20.0, sm)
assert vcruise.csc.enable_training
assert vcruise.csc.curvature_data["0.02"]["count"] == 1
def test_curve_speed_controller_can_be_limited_to_driving_without_a_lead(): def test_curve_speed_controller_can_be_limited_to_driving_without_a_lead():
planner, vcruise = make_vcruise() planner, vcruise = make_vcruise()
sm = make_sm(standstill=False) sm = make_sm(standstill=False)
@@ -231,12 +225,10 @@ def test_curve_speed_controller_can_be_limited_to_driving_without_a_lead():
toggles.curve_speed_controller = True toggles.curve_speed_controller = True
toggles.csc_no_lead = True toggles.csc_no_lead = True
def set_curve_target(_v_ego): def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target_set = True
vcruise.csc.target = 14.0 vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target vcruise.csc.update_target = set_curve_target
planner.road_curvature_detected = True
result = update_vcruise(vcruise, sm, toggles, now=30.0, v_ego=20.0) result = update_vcruise(vcruise, sm, toggles, now=30.0, v_ego=20.0)
assert result == pytest.approx(14.0) assert result == pytest.approx(14.0)
@@ -254,10 +246,8 @@ def test_curve_speed_controller_stays_enabled_with_a_lead_by_default():
toggles = make_toggles() toggles = make_toggles()
toggles.curve_speed_controller = True toggles.curve_speed_controller = True
planner.starpilot_following.following_lead = True planner.starpilot_following.following_lead = True
planner.road_curvature_detected = True
def set_curve_target(_v_ego): def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target_set = True
vcruise.csc.target = 14.0 vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target vcruise.csc.update_target = set_curve_target
@@ -281,7 +271,7 @@ def test_curve_speed_controller_learns_when_speed_is_manually_controlled(long_ac
planner.driving_in_curve = True planner.driving_in_curve = True
planner.road_curvature_detected = True planner.road_curvature_detected = True
planner.lateral_acceleration = 2.4 planner.lateral_acceleration = 2.4
vcruise.csc.training_timer = MIN_TRAINING_TIME vcruise.csc.training_timer = PLANNER_TIME
update_vcruise(vcruise, sm, toggles, now=50.0, v_ego=20.0) update_vcruise(vcruise, sm, toggles, now=50.0, v_ego=20.0)
@@ -299,7 +289,7 @@ def test_curve_speed_controller_learns_when_longitudinal_override_event_is_activ
planner.driving_in_curve = True planner.driving_in_curve = True
planner.road_curvature_detected = True planner.road_curvature_detected = True
planner.lateral_acceleration = 2.4 planner.lateral_acceleration = 2.4
vcruise.csc.training_timer = MIN_TRAINING_TIME vcruise.csc.training_timer = PLANNER_TIME
update_vcruise(vcruise, sm, toggles, now=50.0, v_ego=20.0) update_vcruise(vcruise, sm, toggles, now=50.0, v_ego=20.0)
@@ -313,7 +303,7 @@ def test_curve_speed_controller_persists_data_after_leaving_curve():
sm["carControl"].longActive = False sm["carControl"].longActive = False
planner.driving_in_curve = True planner.driving_in_curve = True
planner.lateral_acceleration = 2.4 planner.lateral_acceleration = 2.4
vcruise.csc.training_timer = MIN_TRAINING_TIME vcruise.csc.training_timer = PLANNER_TIME
vcruise.csc.log_data(20.0, sm) vcruise.csc.log_data(20.0, sm)
assert not any(key == "CurvatureData" for key, _ in planner.params.writes) assert not any(key == "CurvatureData" for key, _ in planner.params.writes)
@@ -324,54 +314,276 @@ def test_curve_speed_controller_persists_data_after_leaving_curve():
assert any(key == "CurvatureData" for key, _ in planner.params.writes) assert any(key == "CurvatureData" for key, _ in planner.params.writes)
def test_curve_speed_controller_publishes_live_values_to_memory_params(): def test_csc_res_press_cancels_for_episode_and_rearms():
planner, vcruise = make_vcruise(road_curvature=0.02) planner, vcruise = make_vcruise()
sm = make_sm(standstill=False) sm = make_sm(standstill=False)
sm["carControl"].longActive = False toggles = make_toggles()
toggles.curve_speed_controller = True
curve_target = {"v": 14.0}
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = curve_target["v"]
vcruise.csc.update_target = set_curve_target
result = update_vcruise(vcruise, sm, toggles, now=60.0, v_ego=20.0)
assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed
sm["starpilotCarState"].accelPressed = True
result = update_vcruise(vcruise, sm, toggles, now=60.05, v_ego=20.0)
assert result == pytest.approx(20.0)
assert not vcruise.csc_controlling_speed
assert vcruise.csc_override
# latches for the rest of the episode, not just while pressed
sm["starpilotCarState"].accelPressed = False
result = update_vcruise(vcruise, sm, toggles, now=60.1, v_ego=20.0)
assert result == pytest.approx(20.0)
assert vcruise.csc_override
# curve ends -> re-arms
curve_target["v"] = 20.0
update_vcruise(vcruise, sm, toggles, now=60.15, v_ego=20.0)
assert not vcruise.csc_override
curve_target["v"] = 14.0
result = update_vcruise(vcruise, sm, toggles, now=60.2, v_ego=20.0)
assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed
def test_csc_res_press_does_not_latch_when_csc_was_not_active():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target
# press before CSC ever limited: suspends it while held, but must not latch a cancel
sm["starpilotCarState"].accelPressed = True
result = update_vcruise(vcruise, sm, toggles, now=70.0, v_ego=20.0)
assert result == pytest.approx(20.0)
assert not vcruise.csc_override
sm["starpilotCarState"].accelPressed = False
result = update_vcruise(vcruise, sm, toggles, now=70.05, v_ego=20.0)
assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed
def test_csc_res_press_defers_to_slc_confirmation():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target
update_vcruise(vcruise, sm, toggles, now=80.0, v_ego=20.0)
assert vcruise.csc_controlling_speed
# confirming a speed limit must not also cancel the curve slowdown
vcruise.slc.speed_limit_changed_timer = 1.0
vcruise.slc.unconfirmed_speed_limit = 25.0
sm["starpilotCarState"].accelPressed = True
update_vcruise(vcruise, sm, toggles, now=80.05, v_ego=20.0)
assert not vcruise.csc_override
sm["starpilotCarState"].accelPressed = False
result = update_vcruise(vcruise, sm, toggles, now=80.1, v_ego=20.0)
assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed
def test_curve_speed_controller_glow_ignores_a_trivial_graze():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
# a long gentle bend where the envelope only shaves a little: the target hovers either
# side of the threshold for the whole curve, so a low bar strobes the glow
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 20.0 - (CSC_GLOW_ON_DELTA / 2.0)
vcruise.csc.update_target = set_curve_target
result = update_vcruise(vcruise, sm, toggles, now=160.0, v_ego=20.0)
assert result < 20.0 # the cap is still applied
assert not vcruise.csc_controlling_speed # it just isn't worth announcing
def test_curve_speed_controller_glow_holds_through_a_brief_release():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
curve_target = {"v": 14.0}
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = curve_target["v"]
vcruise.csc.update_target = set_curve_target
update_vcruise(vcruise, sm, toggles, now=130.0, v_ego=20.0)
assert vcruise.csc_controlling_speed
# one curve routinely lets go and re-engages; the glow must ride through it
curve_target["v"] = 20.0
now = 130.0
for _ in range(int((CSC_GLOW_HOLD_TIME - 0.2) / DT_MDL)):
now += DT_MDL
update_vcruise(vcruise, sm, toggles, now=now, v_ego=20.0)
assert vcruise.csc_controlling_speed
curve_target["v"] = 14.0
now += DT_MDL
update_vcruise(vcruise, sm, toggles, now=now, v_ego=20.0)
assert vcruise.csc_controlling_speed
def test_curve_speed_controller_glow_clears_once_the_release_sticks():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
curve_target = {"v": 14.0}
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = curve_target["v"]
vcruise.csc.update_target = set_curve_target
update_vcruise(vcruise, sm, toggles, now=140.0, v_ego=20.0)
assert vcruise.csc_controlling_speed
curve_target["v"] = 20.0
now = 140.0
for _ in range(int(CSC_GLOW_HOLD_TIME / DT_MDL) + 1):
now += DT_MDL
update_vcruise(vcruise, sm, toggles, now=now, v_ego=20.0)
assert not vcruise.csc_controlling_speed
def test_curve_speed_controller_keeps_the_cap_when_signalling_mid_curve():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 14.0
vcruise.csc.update_target = set_curve_target
result = update_vcruise(vcruise, sm, toggles, now=150.0, v_ego=20.0)
assert result == pytest.approx(14.0)
# a lane change taken inside a curve must not hand the speed back
planner.driving_in_curve = True planner.driving_in_curve = True
planner.lateral_acceleration = 2.4 sm["carState"].leftBlinker = True
vcruise.csc.training_timer = MIN_TRAINING_TIME result = update_vcruise(vcruise, sm, toggles, now=150.05, v_ego=20.0)
assert result == pytest.approx(14.0)
assert vcruise.csc_controlling_speed
vcruise.csc.log_data(20.0, sm) # on a straight it still yields, so CSC can't fight the manoeuvre
planner.driving_in_curve = False
assert any(key == "CalibratedLateralAcceleration" for key, _ in planner.params_memory.writes) result = update_vcruise(vcruise, sm, toggles, now=150.1, v_ego=20.0)
assert any(key == "CalibrationProgress" for key, _ in planner.params_memory.writes) assert result == pytest.approx(20.0)
assert planner.params_memory.values["CalibrationProgress"] > 0.0 assert not vcruise.csc_controlling_speed
def test_curve_speed_controller_ramps_toward_curve_speed_at_bounded_rate(): def test_curve_speed_controller_glow_lights_when_the_car_arrives_at_the_cap_from_below():
planner = SimpleNamespace( planner, vcruise = make_vcruise()
params=FakeParams(), sm = make_sm(standstill=False)
road_curvature=0.004, toggles = make_toggles()
time_to_curve=2.0, toggles.curve_speed_controller = True
starpilot_weather=SimpleNamespace(weather_id=0, reduce_lateral_acceleration=0.0),
)
controller = CurveSpeedController(SimpleNamespace(starpilot_planner=planner))
controller.lateral_acceleration = 2.0
controller.target_set = True
controller.target = 30.0
controller.update_target(30.0) # accelerating out of a slow zone into a curve: the target is never under v_ego, but it
# is still the only thing stopping the car from reaching the set speed
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 22.0
assert controller.target == pytest.approx(30.0 - CSC_MAX_DECEL_RATE * DT_MDL) vcruise.csc.update_target = set_curve_target
assert controller.target > (controller.lateral_acceleration / planner.road_curvature) ** 0.5
update_vcruise(vcruise, sm, toggles, now=120.0, v_ego=15.0, v_cruise=32.0)
assert not vcruise.csc_controlling_speed # still climbing, CSC isn't holding it yet
result = update_vcruise(vcruise, sm, toggles, now=120.05, v_ego=22.0, v_cruise=32.0)
assert result == pytest.approx(22.0)
assert vcruise.csc_controlling_speed # arrived at the cap, and it binds
def test_curve_speed_controller_does_not_slow_for_curve_speed_above_ego(): def test_curve_speed_controller_glow_stays_off_while_the_target_is_above_v_ego():
planner = SimpleNamespace( planner, vcruise = make_vcruise()
params=FakeParams(), sm = make_sm(standstill=False)
road_curvature=0.001, toggles = make_toggles()
time_to_curve=2.0, toggles.curve_speed_controller = True
starpilot_weather=SimpleNamespace(weather_id=0, reduce_lateral_acceleration=0.0),
)
controller = CurveSpeedController(SimpleNamespace(starpilot_planner=planner))
controller.lateral_acceleration = 2.0
controller.target_set = True
controller.target = 28.0
controller.update_target(30.0) # a highway sweeper trims the target well under the set speed but never under v_ego,
# so the car keeps accelerating and the driver feels nothing
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 26.0
vcruise.csc.update_target = set_curve_target
result = update_vcruise(vcruise, sm, toggles, now=90.0, v_ego=20.0, v_cruise=30.0)
assert result == pytest.approx(26.0)
assert not vcruise.csc_controlling_speed
def test_curve_speed_controller_glow_holds_through_the_recovery_ramp():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
curve_target = {"v": 14.0}
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = curve_target["v"]
vcruise.csc.update_target = set_curve_target
update_vcruise(vcruise, sm, toggles, now=100.0, v_ego=20.0)
assert vcruise.csc_controlling_speed
# past the apex the target climbs back above v_ego while the car is still cornering
curve_target["v"] = 18.0
update_vcruise(vcruise, sm, toggles, now=100.05, v_ego=15.0)
assert vcruise.csc_controlling_speed
# fully released, but the glow only clears once the release has stuck
curve_target["v"] = 20.0
now = 100.1
update_vcruise(vcruise, sm, toggles, now=now, v_ego=17.0)
assert vcruise.csc_controlling_speed
for _ in range(int(CSC_GLOW_HOLD_TIME / DT_MDL) + 1):
now += DT_MDL
update_vcruise(vcruise, sm, toggles, now=now, v_ego=17.0)
assert not vcruise.csc_controlling_speed
def test_curve_speed_controller_hysteresis_keeps_glow_off_for_marginal_targets():
planner, vcruise = make_vcruise()
sm = make_sm(standstill=False)
toggles = make_toggles()
toggles.curve_speed_controller = True
def set_curve_target(_v_ego, _v_cruise):
vcruise.csc.target = 19.7
vcruise.csc.update_target = set_curve_target
result = update_vcruise(vcruise, sm, toggles, now=50.0, v_ego=20.0)
assert result == pytest.approx(19.7)
assert not vcruise.csc_controlling_speed
assert controller.target == pytest.approx(30.0)
def test_active_slc_control_target_applies_offset_and_cluster_diff(): def test_active_slc_control_target_applies_offset_and_cluster_diff():
+30 -1
View File
@@ -27,13 +27,42 @@ def _patch_tinygrad_fetch_fw():
if original_fetch_fw is None: if original_fetch_fw is None:
return return
# tinygrad's own disk cache lives under $HOME/.cache, which is not guaranteed
# to survive a reboot on-device. Persist a copy under /data so a firmware blob
# only ever needs network once, ever, instead of on every cold boot.
persistent_cache_dir = pathlib.Path("/data/tinygrad_fw_cache")
def fetch_fw(path, name, sha256): def fetch_fw(path, name, sha256):
firmware_path = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") firmware_path = pathlib.Path(f"/lib/firmware/{path}/{name}.zst")
if firmware_path.is_file(): if firmware_path.is_file():
blob = zstandard.ZstdDecompressor().stream_reader(firmware_path.read_bytes()).read() blob = zstandard.ZstdDecompressor().stream_reader(firmware_path.read_bytes()).read()
if hashlib.sha256(blob).hexdigest() == sha256: if hashlib.sha256(blob).hexdigest() == sha256:
return blob return blob
return original_fetch_fw(path, name, sha256)
cached_path = persistent_cache_dir / path / f"{name}.{sha256}"
if cached_path.is_file():
blob = cached_path.read_bytes()
if hashlib.sha256(blob).hexdigest() == sha256:
return blob
last_error = None
for attempt in range(3):
if attempt:
time.sleep(5)
try:
blob = original_fetch_fw(path, name, sha256)
break
except Exception as error:
last_error = error
else:
raise last_error
try:
cached_path.parent.mkdir(parents=True, exist_ok=True)
cached_path.write_bytes(blob)
except OSError:
pass
return blob
helpers.fetch_fw = fetch_fw helpers.fetch_fw = fetch_fw
+6 -1
View File
@@ -20,11 +20,16 @@ def _chestnut_portli() -> Path | None:
def wait_usbgpu_link(timeout: float = 30.0) -> None: def wait_usbgpu_link(timeout: float = 30.0) -> None:
start_time = time.monotonic()
portli = _chestnut_portli() portli = _chestnut_portli()
while portli is None and time.monotonic() - start_time < timeout:
time.sleep(0.5)
portli = _chestnut_portli()
if portli is None: if portli is None:
cloudlog.error("usbgpu device never enumerated")
return return
start_time = time.monotonic()
while time.monotonic() - start_time < timeout: while time.monotonic() - start_time < timeout:
start_errors = read_int(portli, 0) start_errors = read_int(portli, 0)
time.sleep(STABLE_SECONDS) time.sleep(STABLE_SECONDS)
@@ -59,6 +59,8 @@ def _csc_state():
plan = sm["starpilotPlan"] plan = sm["starpilotPlan"]
params = ui_state.ui_params params = ui_state.ui_params
# A pending speed limit flashes the speed limit sign, not the border -- it has no reason
# to blank this, and doing so hid real curve slowdowns for the whole confirmation window.
if not params.get_bool("ShowCSCStatus"): if not params.get_bool("ShowCSCStatus"):
return None return None
+17
View File
@@ -138,6 +138,23 @@ def calculate_road_curvature(modelData, v_ego):
return float(predicted_lateral_acc / max(v_ego, 1)**2), max(time_to_curve, 1) return float(predicted_lateral_acc / max(v_ego, 1)**2), max(time_to_curve, 1)
PROFILE_MIN_SPEED = 3.0 # m/s — model points planned near standstill have unusable curvature
PROFILE_MAX_CURVATURE = 0.1
def extract_curve_profile(modelData):
orientation_rate = np.abs(np.array(modelData.orientationRate.z))
velocity = np.array(modelData.velocity.x)
distances = np.array(modelData.position.x)
# k = psi_dot / v per point, against the model's own planned speed so its
# slowdowns don't inflate the curvature
curvatures = orientation_rate / np.clip(velocity, PROFILE_MIN_SPEED, None)
curvatures = np.where(velocity < PROFILE_MIN_SPEED, 0.0, np.minimum(curvatures, PROFILE_MAX_CURVATURE))
return curvatures, distances
def clean_model_name(name): def clean_model_name(name):
return name.replace("(Default)", "").strip() return name.replace("(Default)", "").strip()
+307 -67
View File
@@ -2,19 +2,97 @@
import numpy as np import numpy as np
from openpilot.common.constants import CV from openpilot.common.constants import CV
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.realtime import DT_MDL from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.common.starpilot_variables import CITY_SPEED_LIMIT, CRUISING_SPEED, DEFAULT_LATERAL_ACCELERATION, PLANNER_TIME from openpilot.starpilot.common.starpilot_variables import (
CITY_SPEED_LIMIT,
CRUISING_SPEED,
DEFAULT_LATERAL_ACCELERATION,
PLANNER_TIME,
)
CALIBRATION_PROGRESS_THRESHOLD = 10 / DT_MDL CALIBRATION_PROGRESS_THRESHOLD = 10 / DT_MDL
MIN_TRAINING_TIME = 5.0
CSC_MIN_SPEED = CITY_SPEED_LIMIT * CV.MPH_TO_MS CSC_MIN_SPEED = CITY_SPEED_LIMIT * CV.MPH_TO_MS
CSC_MAX_DECEL_RATE = 1.5
MAX_CURVATURE = 0.1 # braking distance is (v^2 - v_curve^2) / (2 * this), so lower starts the slowdown
MIN_CURVATURE = 0.001 # sooner and spreads it further.
PERCENTILE = 90 CSC_APPROACH_DECEL = 0.3
ROUNDING_PRECISION = 5 CSC_TARGET_UP_RATE = 3.0
STEP = 0.001 CSC_TARGET_DOWN_RATE = 2.5
CSC_TARGET_FILTER_RC = 0.4
CSC_EGO_HEADROOM = 2.0 # target never trails below v_ego, so CSC can't drag re-acceleration
CSC_RELEASE_DEBOUNCE = 0.25 # s the envelope must stay clear before that floor applies
CSC_ACTIVE_ON_DELTA = 0.5
CSC_ACTIVE_OFF_DELTA = 0.25
CSC_GLOW_ON_DELTA = 1.0 # ~2.2 mph; separate from CSC_ACTIVE_ON_DELTA (training) so a trivial graze doesn't light the glow
CSC_GLOW_HOLD_TIME = 3.0 # s the cap must stay released before the glow clears, so it doesn't flicker on/off across one curve
CSC_COUNT_CAP = 600 # EMA floor: samples beyond this stop shrinking the update step
CSC_PRIOR_COUNT = 100 # bucket count at which learned data and the prior have equal weight
CSC_LAT_ACCEL_MIN = 1.2
CSC_LAT_ACCEL_MAX = 3.2
CSC_NUDGE = 0.15
CSC_NUDGE_WEIGHT = 20 # counts a single override pseudo-sample is worth
CSC_OVERRIDE_WATCH_TIME = 6.0 # s to keep watching what the driver holds after they reject a cut
CSC_TRAINING_QUIET_TIME = 5.0 # blocks passive samples after CSC limited speed, so it can't learn its own cap
CSC_TRAINING_SETTLE_TIME = 2.0 # driver-owned seconds before a sample counts, so it isn't openpilot's leftover speed
CSC_COMFORT_MARGIN = 1.0 # 1.0 = matches the driver's own learned cornering, no extra cushion
# The model under-reads curvature at range: measured 0.81x actual beyond ~75 m. That holds
# only where the reading is already firm -- weak distant readings carry no usable magnitude
# (0.40x median with a 14:1 spread), so scaling those would amplify noise, not signal.
CSC_FARFIELD_MIN_CURVATURE = 0.004 # ~R 250 m; at this strength range readings were 85%+ reliable
CSC_FARFIELD_MIN_DISTANCE = 30.0 # inside this the model is already accurate
CSC_FARFIELD_GAIN = 1.23 # 1 / 0.81
# Buckets are spaced geometrically, not linearly: comfort is a speed and v = sqrt(a/k), so equal
# steps in k give wildly uneven speed resolution. Regridding is safe -- _normalize_curvature_data
# re-buckets stored keys on load.
MIN_CURVATURE = 0.0005 # R 2000 m — gentler than this never constrains anything
MAX_CURVATURE = 0.02 # R 50 m — already well below the CSC_MIN_SPEED floor
CURVATURE_BUCKETS = 24 # keeps every bucket under ~7 mph wide without over-thinning the data
ROUNDING_PRECISION = 6
CURVATURE_GRID = MIN_CURVATURE * np.power(MAX_CURVATURE / MIN_CURVATURE,
np.arange(CURVATURE_BUCKETS) / (CURVATURE_BUCKETS - 1))
LOG_CURVATURE_GRID = np.log(CURVATURE_GRID)
# Drivers accept more lateral acceleration in sharp slow corners than in highway sweepers.
PRIOR_CURVATURE_BP = [0.001, 0.003, 0.01, 0.03, 0.1]
PRIOR_LAT_ACCEL_V = [1.5, 1.8, 2.2, 2.6, 2.9]
def weighted_isotonic(values, weights):
"""Weighted non-decreasing fit (pool adjacent violators).
Keeps comfort from falling as curves tighten, without letting a sparse bucket
overrule a well-sampled neighbour the way a running maximum would.
"""
block_values: list[float] = []
block_weights: list[float] = []
block_sizes: list[int] = []
for value, weight in zip(values, weights, strict=True):
block_values.append(float(value))
block_weights.append(float(weight))
block_sizes.append(1)
while len(block_values) > 1 and block_values[-2] > block_values[-1]:
merged_weight = block_weights[-2] + block_weights[-1]
merged_value = ((block_values[-2] * block_weights[-2]) + (block_values[-1] * block_weights[-1])) / merged_weight
block_values.pop()
block_weights.pop()
merged_size = block_sizes.pop()
block_values[-1] = merged_value
block_weights[-1] = merged_weight
block_sizes[-1] += merged_size
fitted = np.empty(len(values))
index = 0
for value, size in zip(block_values, block_sizes, strict=True):
fitted[index:index + size] = value
index += size
return fitted
def is_user_overriding_longitudinal(sm): def is_user_overriding_longitudinal(sm):
@@ -43,26 +121,45 @@ class CurveSpeedController:
self.starpilot_planner = StarPilotVCruise.starpilot_planner self.starpilot_planner = StarPilotVCruise.starpilot_planner
self.enable_training = False self.enable_training = False
self.target_set = False self.nudge_applied = False
self.override_watch_key = None
self.override_watch_peak = 0.0
self.override_watch_timer = 0.0
self.training_timer = 0.0 self.training_timer = 0.0
self.persistence_timer = 0.0 self.persistence_timer = 0.0
self.training_quiet_timer = 0.0
self.data_dirty = False self.data_dirty = False
self.target = 0.0
self.binding_distance = 0.0
self.release_timer = 0.0
self.target_filter = FirstOrderFilter(0.0, CSC_TARGET_FILTER_RC, DT_MDL, initialized=False)
self.seed_pending = True
self._long_active_prev = False
curvature_data = self.starpilot_planner.params.get("CurvatureData") curvature_data = self.starpilot_planner.params.get("CurvatureData")
self.curvature_data = self._normalize_curvature_data(curvature_data) self.curvature_data = self._normalize_curvature_data(curvature_data)
self.required_curvatures = [str(round(road_curvature, ROUNDING_PRECISION)) for road_curvature in np.arange(MIN_CURVATURE, MAX_CURVATURE + STEP, STEP)] # built through the bucketer so the keys are byte-identical to what training writes
self.required_curvatures = [self._bucket_curvature(curvature) for curvature in CURVATURE_GRID]
self.update_lateral_acceleration() self.rebuild_lat_accel_curve()
self._publish_calibration_progress(persist=True) # publish on the first flush even if this drive never trains, or the readout
# keeps showing whatever a previous build left behind
self.data_dirty = True
# the Settings screen reads the memory param for a live readout -- seed it now, or it
# shows nothing until the first disk flush
self._publish_live_values()
@staticmethod @staticmethod
def _bucket_curvature(road_curvature): def _bucket_curvature(road_curvature):
clipped_curvature = float(np.clip(road_curvature, MIN_CURVATURE, MAX_CURVATURE)) clipped_curvature = float(np.clip(abs(road_curvature), MIN_CURVATURE, MAX_CURVATURE))
bucket_index = round((clipped_curvature - MIN_CURVATURE) / STEP) # nearest in log space, so a bucket is a constant speed step rather than a constant radius one
bucketed_curvature = MIN_CURVATURE + (bucket_index * STEP) bucket_index = int(np.argmin(np.abs(LOG_CURVATURE_GRID - np.log(clipped_curvature))))
return str(round(bucketed_curvature, ROUNDING_PRECISION)) return str(round(float(CURVATURE_GRID[bucket_index]), ROUNDING_PRECISION))
@classmethod @classmethod
def _normalize_curvature_data(cls, curvature_data): def _normalize_curvature_data(cls, curvature_data):
@@ -100,17 +197,6 @@ class CurveSpeedController:
return normalized return normalized
def _persist_data(self):
if not self.data_dirty:
return
progress = self._calibration_progress()
self.starpilot_planner.params.put_nonblocking("CalibrationProgress", progress)
self.starpilot_planner.params.put_nonblocking("CurvatureData", self.curvature_data)
self._put_memory_param("CalibrationProgress", progress)
self.data_dirty = False
self.persistence_timer = 0.0
def _calibration_progress(self): def _calibration_progress(self):
progress = 0.0 progress = 0.0
for key in self.required_curvatures: for key in self.required_curvatures:
@@ -118,31 +204,48 @@ class CurveSpeedController:
progress += min(self.curvature_data[key]["count"] / CALIBRATION_PROGRESS_THRESHOLD, 1.0) progress += min(self.curvature_data[key]["count"] / CALIBRATION_PROGRESS_THRESHOLD, 1.0)
return (progress / len(self.required_curvatures)) * 100 return (progress / len(self.required_curvatures)) * 100
def _publish_calibration_progress(self, persist=False): def _publish_live_values(self, progress=None):
progress = self._calibration_progress() # memory-only and cheap, so this can run every frame training touches the data --
if persist: # it's what the on-device Settings screen reads for a live readout between disk flushes
self.starpilot_planner.params.put_nonblocking("CalibrationProgress", progress)
self._put_memory_param("CalibrationProgress", progress)
def _put_memory_param(self, key, value):
params_memory = getattr(self.starpilot_planner, "params_memory", None) params_memory = getattr(self.starpilot_planner, "params_memory", None)
if params_memory is not None: if params_memory is None:
params_memory.put_nonblocking(key, value) return
if progress is None:
progress = self._calibration_progress()
params_memory.put_nonblocking("CalibratedLateralAcceleration", self.lateral_acceleration)
params_memory.put_nonblocking("CalibrationProgress", progress)
def _persist_data(self):
if not self.data_dirty:
return
progress = self._calibration_progress()
self.starpilot_planner.params.put_nonblocking("CalibratedLateralAcceleration", self.lateral_acceleration)
self.starpilot_planner.params.put_nonblocking("CalibrationProgress", progress)
self.starpilot_planner.params.put_nonblocking("CurvatureData", self.curvature_data)
self._publish_live_values(progress)
self.data_dirty = False
self.persistence_timer = 0.0
def flush_data(self): def flush_data(self):
self._persist_data() self._persist_data()
def log_data(self, v_ego, sm): def log_data(self, v_ego, sm):
self.training_quiet_timer = max(self.training_quiet_timer - DT_MDL, 0.0)
eligible = ( eligible = (
v_ego > CRUISING_SPEED and v_ego > CRUISING_SPEED and
not self.starpilot_planner.tracking_lead and not self.starpilot_planner.tracking_lead and
is_manual_speed_control(sm) is_manual_speed_control(sm) and
self.training_quiet_timer <= 0.0
) )
self.enable_training = False self.enable_training = False
if not eligible: if not eligible:
self.flush_data() self.flush_data()
self.training_timer = 0.0 # decay instead of resetting: a lead flickering in and out of the tracker used to
# cost the full re-arm, which left almost nothing to learn from on a real drive
self.training_timer = max(self.training_timer - DT_MDL, 0.0)
self.persistence_timer = 0.0 self.persistence_timer = 0.0
return return
@@ -151,8 +254,9 @@ class CurveSpeedController:
self.persistence_timer += DT_MDL self.persistence_timer += DT_MDL
in_curve = ( in_curve = (
self.training_timer >= MIN_TRAINING_TIME and self.training_timer >= CSC_TRAINING_SETTLE_TIME and
self.starpilot_planner.driving_in_curve self.starpilot_planner.driving_in_curve and
not (sm["carState"].leftBlinker or sm["carState"].rightBlinker)
) )
if in_curve: if in_curve:
lateral_acceleration = abs(self.starpilot_planner.lateral_acceleration) lateral_acceleration = abs(self.starpilot_planner.lateral_acceleration)
@@ -160,11 +264,11 @@ class CurveSpeedController:
if road_curvature in self.curvature_data: if road_curvature in self.curvature_data:
data = self.curvature_data[road_curvature] data = self.curvature_data[road_curvature]
average = data["average"] # capped so an established bucket still tracks a change in driving style
count = data["count"] effective_count = min(data["count"], CSC_COUNT_CAP)
self.curvature_data[road_curvature] = { self.curvature_data[road_curvature] = {
"average": ((average * count) + lateral_acceleration) / (count + 1), "average": ((data["average"] * effective_count) + lateral_acceleration) / (effective_count + 1),
"count": count + 1 "count": data["count"] + 1
} }
else: else:
self.curvature_data[road_curvature] = { self.curvature_data[road_curvature] = {
@@ -173,8 +277,8 @@ class CurveSpeedController:
} }
self.data_dirty = True self.data_dirty = True
self.update_lateral_acceleration() self.rebuild_lat_accel_curve()
self._publish_calibration_progress() self._publish_live_values()
self.enable_training = True self.enable_training = True
if self.persistence_timer >= PLANNER_TIME: if self.persistence_timer >= PLANNER_TIME:
@@ -182,30 +286,166 @@ class CurveSpeedController:
elif self.data_dirty: elif self.data_dirty:
self.flush_data() self.flush_data()
def update_lateral_acceleration(self): def handle_override(self, v_ego, was_controlling, sm, accel_button=False):
if self.curvature_data: long_active = bool(sm["carControl"].longActive)
all_samples = [data["average"] for data in self.curvature_data.values()] long_dropped = self._long_active_prev and not long_active
self.lateral_acceleration = float(np.percentile(all_samples, PERCENTILE)) self._long_active_prev = long_active
self._update_override_watch(sm)
if not was_controlling:
self.nudge_applied = False
return
if self.nudge_applied:
return
if accel_button or (sm["carState"].gasPressed and self.target < v_ego - 0.5):
# Watch what the driver actually holds instead of stepping by a fixed amount -- CSC is
# suspended while overridden, so their cornering now measures their real comfort.
self.override_watch_key = self._bucket_curvature(abs(self.starpilot_planner.road_curvature))
self.override_watch_peak = abs(self.starpilot_planner.lateral_acceleration)
self.override_watch_timer = CSC_OVERRIDE_WATCH_TIME
self.nudge_applied = True
elif (getattr(sm["carState"], "brakePressed", False) or long_dropped) and self.starpilot_planner.driving_in_curve:
self._apply_nudge(-CSC_NUDGE)
def _update_override_watch(self, sm):
if self.override_watch_key is None:
return
lateral_acceleration = abs(self.starpilot_planner.lateral_acceleration)
if lateral_acceleration > self.override_watch_peak:
# credit the bucket the peak actually happened in, not the one at the button press
self.override_watch_peak = lateral_acceleration
self.override_watch_key = self._bucket_curvature(abs(self.starpilot_planner.road_curvature))
self.override_watch_timer -= DT_MDL
if self.override_watch_timer > 0.0 and (is_user_overriding_longitudinal(sm) or
self.starpilot_planner.driving_in_curve):
return
key = self.override_watch_key
self.override_watch_key = None
# floored at the old fixed step, so a rejection that never reaches a corner still counts
# and this path can only ever raise the bucket
self._record_pseudo_sample(key, max(self.override_watch_peak,
self.learned_lat_accel(float(key)) + CSC_NUDGE))
def _apply_nudge(self, offset):
key = self._bucket_curvature(abs(self.starpilot_planner.road_curvature))
# relative to the learned value, not the margined one, or repeated overrides walk the bucket down
self._record_pseudo_sample(key, self.learned_lat_accel(float(key)) + offset)
self.nudge_applied = True
def _record_pseudo_sample(self, key, sample):
sample = float(np.clip(sample, CSC_LAT_ACCEL_MIN, CSC_LAT_ACCEL_MAX))
data = self.curvature_data.get(key, {"average": sample, "count": 0})
effective_count = min(data["count"], CSC_COUNT_CAP)
total = effective_count + CSC_NUDGE_WEIGHT
self.curvature_data[key] = {
"average": ((data["average"] * effective_count) + (sample * CSC_NUDGE_WEIGHT)) / total,
"count": data["count"] + CSC_NUDGE_WEIGHT,
}
self.rebuild_lat_accel_curve()
self.data_dirty = True
self.flush_data()
def rebuild_lat_accel_curve(self):
grid_k = np.array([float(key) for key in self.required_curvatures])
prior = np.interp(grid_k, PRIOR_CURVATURE_BP, PRIOR_LAT_ACCEL_V)
blended = prior.copy()
counts = np.zeros(len(grid_k))
for i, key in enumerate(self.required_curvatures):
data = self.curvature_data.get(key)
if data:
confidence = data["count"] / (data["count"] + CSC_PRIOR_COUNT)
blended[i] = confidence * data["average"] + (1.0 - confidence) * prior[i]
counts[i] = data["count"]
blended = np.clip(blended, CSC_LAT_ACCEL_MIN, CSC_LAT_ACCEL_MAX)
blended = weighted_isotonic(blended, counts + CSC_PRIOR_COUNT)
self._curve_k = grid_k
self._curve_a = blended
if counts.sum() > 0:
self.lateral_acceleration = float(np.average(blended, weights=counts))
else: else:
self.lateral_acceleration = DEFAULT_LATERAL_ACCELERATION self.lateral_acceleration = DEFAULT_LATERAL_ACCELERATION
self.starpilot_planner.params.put_nonblocking("CalibratedLateralAcceleration", self.lateral_acceleration) def learned_lat_accel(self, curvature):
self._put_memory_param("CalibratedLateralAcceleration", self.lateral_acceleration) """Comfort level learned for this curvature, before any control margin."""
return float(np.interp(abs(curvature), self._curve_k, self._curve_a))
def update_target(self, v_ego): def lat_accel_for_curvature(self, curvature):
lateral_acceleration = self.lateral_acceleration lat_accel = np.interp(np.abs(curvature), self._curve_k, self._curve_a) * CSC_COMFORT_MARGIN
if self.starpilot_planner.starpilot_weather.weather_id != 0:
lateral_acceleration -= self.lateral_acceleration * self.starpilot_planner.starpilot_weather.reduce_lateral_acceleration
if self.target_set: weather = self.starpilot_planner.starpilot_weather
csc_speed = (lateral_acceleration / abs(self.starpilot_planner.road_curvature))**0.5 if weather.weather_id != 0:
csc_speed = max(float(csc_speed), CSC_MIN_SPEED) lat_accel = lat_accel * (1.0 - weather.reduce_lateral_acceleration)
if csc_speed >= v_ego:
self.target = v_ego return lat_accel
else:
time_to_curve = max(float(self.starpilot_planner.time_to_curve), DT_MDL) @staticmethod
decel_rate = float(np.clip((v_ego - csc_speed) / time_to_curve, 0.0, CSC_MAX_DECEL_RATE)) def _correct_far_field(curvatures, distances):
self.target = float(np.clip(self.target - decel_rate * DT_MDL, csc_speed, v_ego)) """Undo the model's known under-read of distant curvature, where the reading is firm."""
firm = (curvatures >= CSC_FARFIELD_MIN_CURVATURE) & (distances >= CSC_FARFIELD_MIN_DISTANCE)
return np.minimum(np.where(firm, curvatures * CSC_FARFIELD_GAIN, curvatures), MAX_CURVATURE)
def reset(self, v_cruise):
self.target = float(v_cruise)
self.release_timer = 0.0
self.target_filter.x = float(v_cruise)
self.target_filter.initialized = True
self.seed_pending = True
def update_target(self, v_ego, v_cruise):
if not self.target_filter.initialized:
self.reset(v_cruise)
curvatures, distances = self.starpilot_planner.curve_profile
if len(curvatures) == 0:
raw_target = float(v_cruise)
self.binding_distance = 0.0
else: else:
self.target_set = True curvatures = self._correct_far_field(curvatures, distances)
self.target = v_ego lat_accel = self.lat_accel_for_curvature(curvatures)
point_speeds = np.sqrt(lat_accel / np.maximum(curvatures, 1e-4))
point_speeds = np.maximum(point_speeds, CSC_MIN_SPEED)
allowed_speeds = np.sqrt(point_speeds**2 + 2.0 * CSC_APPROACH_DECEL * np.maximum(distances, 0.0))
binding_index = int(np.argmin(allowed_speeds))
raw_target = min(float(allowed_speeds[binding_index]), float(v_cruise))
self.binding_distance = float(distances[binding_index]) if raw_target < v_cruise else 0.0
# a fresh activation starts at the envelope, or it spends seconds ramping down
# toward a curve it already sees (engaging or launching into a turn)
if self.seed_pending:
seed = min(float(v_cruise), max(raw_target, v_ego + CSC_EGO_HEADROOM))
self.target = seed
self.target_filter.x = seed
self.seed_pending = False
if raw_target >= v_ego:
self.release_timer += DT_MDL
else:
self.release_timer = 0.0
# The headroom aim goes through the rate limiter with everything else; applying it
# after the clamp let every upward jitter in raw_target reach the target unsmoothed.
filtered = self.target_filter.update(raw_target)
self.target = float(np.clip(max(filtered, min(raw_target, v_ego + CSC_EGO_HEADROOM)),
self.target - CSC_TARGET_DOWN_RATE * DT_MDL,
self.target + CSC_TARGET_UP_RATE * DT_MDL))
# Once the envelope really has released, the target must not sit under the car or it
# drags re-acceleration. Debounced, because a single jittery frame doing this yanks a
# legitimate cut back up to v_ego and strobes the glow on sweepers.
if self.release_timer >= CSC_RELEASE_DEBOUNCE:
self.target = max(self.target, min(raw_target, v_ego))
if self.target < v_cruise - CSC_ACTIVE_ON_DELTA:
self.training_quiet_timer = CSC_TRAINING_QUIET_TIME
+61 -21
View File
@@ -6,7 +6,13 @@ from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.common.starpilot_variables import CITY_SPEED_LIMIT, CRUISING_SPEED from openpilot.starpilot.common.starpilot_variables import CITY_SPEED_LIMIT, CRUISING_SPEED
from openpilot.starpilot.controls.lib.curve_speed_controller import CurveSpeedController, is_manual_speed_control from openpilot.starpilot.controls.lib.curve_speed_controller import (
CSC_ACTIVE_OFF_DELTA,
CSC_GLOW_HOLD_TIME,
CSC_GLOW_ON_DELTA,
CurveSpeedController,
is_manual_speed_control,
)
from openpilot.starpilot.controls.lib.speed_limit_controller import SpeedLimitController from openpilot.starpilot.controls.lib.speed_limit_controller import SpeedLimitController
from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import ( from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import (
get_force_stop_distance_bias, get_force_stop_distance_bias,
@@ -16,7 +22,6 @@ from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import (
) )
CSC_MIN_SPEED = CITY_SPEED_LIMIT * CV.MPH_TO_MS CSC_MIN_SPEED = CITY_SPEED_LIMIT * CV.MPH_TO_MS
CSC_CURVE_RELEASE_HOLD_TIME = 0.75
OVERRIDE_FORCE_STOP_TIMER = 10 OVERRIDE_FORCE_STOP_TIMER = 10
STANDSTILL_FORCE_STOP_CLEAR_TIME = 0.75 STANDSTILL_FORCE_STOP_CLEAR_TIME = 0.75
# Open-loop — green is undetectable at standstill, so this only needs to cover the # Open-loop — green is undetectable at standstill, so this only needs to cover the
@@ -202,8 +207,9 @@ class StarPilotVCruise:
self._nav_instruction_state = {} self._nav_instruction_state = {}
self._applied_slc_control_target = 0.0 self._applied_slc_control_target = 0.0
self.csc_controlling_speed = False self.csc_controlling_speed = False
self.csc_glow_release_timer = 0.0
self.csc_override = False
self.csc_target = 0.0 self.csc_target = 0.0
self.csc_curve_last_seen_at = None
def _update_nav_instruction_state(self): def _update_nav_instruction_state(self):
raw = self.starpilot_planner.params_memory.get("NavInstructionState") or {} raw = self.starpilot_planner.params_memory.get("NavInstructionState") or {}
@@ -571,28 +577,62 @@ class StarPilotVCruise:
starpilot_toggles.curve_speed_controller and starpilot_toggles.curve_speed_controller and
(not getattr(starpilot_toggles, "csc_no_lead", False) or not following_lead) (not getattr(starpilot_toggles, "csc_no_lead", False) or not following_lead)
) )
csc_curve_detected = csc_available and self.starpilot_planner.road_curvature_detected # The blinker veto is for lane changes/turns, not for an already-real curve -- releasing it
if csc_curve_detected: # there let the car accelerate into the bend, then claw the speed back once the blinker cleared.
self.csc.update_target(v_ego) csc_blinker_on = ((sm["carState"].leftBlinker or sm["carState"].rightBlinker) and
not self.starpilot_planner.driving_in_curve)
csc_was_controlling = self.csc_controlling_speed
# a pending SLC confirmation owns the accel button
slc_confirmation_pending = self.slc.speed_limit_changed_timer > DT_MDL and self.slc.unconfirmed_speed_limit >= 1
csc_accel_button = bool(sm["starpilotCarState"].accelPressed) and not slc_confirmation_pending
self.csc_controlling_speed = True # Latched outside the availability branch: the press itself suspends CSC this frame, so
self.csc_target = self.csc.target # latching inside it would never see the press, and the slowdown would return on release.
self.csc_curve_last_seen_at = now if csc_was_controlling and csc_accel_button:
else: self.csc_override = True
csc_release_hold = bool( if not (long_control_active and starpilot_toggles.curve_speed_controller):
csc_available and self.csc_override = False
self.csc_controlling_speed and
self.csc_curve_last_seen_at is not None and
self._elapsed_seconds(now, self.csc_curve_last_seen_at) < CSC_CURVE_RELEASE_HOLD_TIME
)
if not csc_release_hold:
self.csc.log_data(v_ego, sm)
if csc_available and not csc_blinker_on:
self.csc.update_target(v_ego, v_cruise)
if self.csc_override and self.csc.target > v_cruise - CSC_ACTIVE_OFF_DELTA:
self.csc_override = False
if self.csc_override:
self.csc_controlling_speed = False self.csc_controlling_speed = False
self.csc.target_set = False self.csc_glow_release_timer = 0.0
self.csc_curve_last_seen_at = None
self.csc_target = v_cruise self.csc_target = v_cruise
else:
self.csc_target = self.csc.target
# A low target alone means nothing until the car has actually reached it (slowed down
# to it, or accelerated up into it). Release still waits for the set speed, so the glow
# spans the hold and the recovery, not just the braking.
if self.csc_target < v_cruise - CSC_GLOW_ON_DELTA and v_ego >= self.csc_target - CSC_ACTIVE_OFF_DELTA:
self.csc_controlling_speed = True
self.csc_glow_release_timer = 0.0
elif self.csc_target > v_cruise - CSC_ACTIVE_OFF_DELTA:
# hold through a brief release: one curve routinely lets go and re-engages
self.csc_glow_release_timer += DT_MDL
if self.csc_glow_release_timer >= CSC_GLOW_HOLD_TIME:
self.csc_controlling_speed = False
else:
self.csc_glow_release_timer = 0.0
elif csc_available:
# Release the cap so CSC can't fight the lane change, but keep planning -- resetting here
# threw the braking plan away and re-planned from the set speed with the curve closer.
self.csc.update_target(v_ego, v_cruise)
self.csc_controlling_speed = False
self.csc_glow_release_timer = 0.0
self.csc_target = v_cruise
else:
self.csc.reset(v_cruise)
self.csc_controlling_speed = False
self.csc_glow_release_timer = 0.0
self.csc_target = v_cruise
self.csc.handle_override(v_ego, csc_was_controlling, sm, accel_button=csc_accel_button)
self.csc.log_data(v_ego, sm)
# Pfeiferj's Speed Limit Controller # Pfeiferj's Speed Limit Controller
self.slc.starpilot_toggles = starpilot_toggles self.slc.starpilot_toggles = starpilot_toggles
+5 -1
View File
@@ -20,7 +20,7 @@ from openpilot.selfdrive.controls.lib.lead_behavior import (
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import A_CHANGE_COST, DANGER_ZONE_COST, J_EGO_COST, STOP_DISTANCE from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import A_CHANGE_COST, DANGER_ZONE_COST, J_EGO_COST, STOP_DISTANCE
from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import get_lead_follow_jerk_scale from openpilot.selfdrive.controls.lib.longitudinal_vehicle_tunes import get_lead_follow_jerk_scale
from openpilot.starpilot.common.starpilot_utilities import calculate_lane_width, calculate_road_curvature from openpilot.starpilot.common.starpilot_utilities import calculate_lane_width, calculate_road_curvature, extract_curve_profile
from openpilot.starpilot.common.starpilot_variables import CRUISING_SPEED, MINIMUM_LATERAL_ACCELERATION, PLANNER_TIME, THRESHOLD from openpilot.starpilot.common.starpilot_variables import CRUISING_SPEED, MINIMUM_LATERAL_ACCELERATION, PLANNER_TIME, THRESHOLD
from openpilot.starpilot.controls.lib.conditional_chill_mode import ConditionalChillMode from openpilot.starpilot.controls.lib.conditional_chill_mode import ConditionalChillMode
from openpilot.starpilot.controls.lib.conditional_experimental_mode import ConditionalExperimentalMode from openpilot.starpilot.controls.lib.conditional_experimental_mode import ConditionalExperimentalMode
@@ -214,6 +214,7 @@ class StarPilotPlanner:
self.model_stopped = self.raw_model_stopped or self.starpilot_vcruise.forcing_stop self.model_stopped = self.raw_model_stopped or self.starpilot_vcruise.forcing_stop
self.road_curvature, self.time_to_curve = calculate_road_curvature(sm["modelV2"], v_ego) self.road_curvature, self.time_to_curve = calculate_road_curvature(sm["modelV2"], v_ego)
self.curve_profile = extract_curve_profile(sm["modelV2"])
self.road_curvature_detected = (1 / abs(self.road_curvature))**0.5 < v_ego > CRUISING_SPEED and not (sm["carState"].leftBlinker or sm["carState"].rightBlinker) self.road_curvature_detected = (1 / abs(self.road_curvature))**0.5 < v_ego > CRUISING_SPEED and not (sm["carState"].leftBlinker or sm["carState"].rightBlinker)
@@ -328,6 +329,9 @@ class StarPilotPlanner:
starpilotPlan.cscControllingSpeed = self.starpilot_vcruise.csc_controlling_speed starpilotPlan.cscControllingSpeed = self.starpilot_vcruise.csc_controlling_speed
starpilotPlan.cscSpeed = float(self.starpilot_vcruise.csc_target) starpilotPlan.cscSpeed = float(self.starpilot_vcruise.csc_target)
starpilotPlan.cscTraining = self.starpilot_vcruise.csc.enable_training starpilotPlan.cscTraining = self.starpilot_vcruise.csc.enable_training
starpilotPlan.cscOverridden = self.starpilot_vcruise.csc_override
starpilotPlan.cscLearnedLatAccel = float(self.starpilot_vcruise.csc.learned_lat_accel(self.road_curvature))
starpilotPlan.cscBindingDistance = float(self.starpilot_vcruise.csc.binding_distance)
starpilotPlan.desiredFollowDistance = int(self.starpilot_following.desired_follow_distance) starpilotPlan.desiredFollowDistance = int(self.starpilot_following.desired_follow_distance)
starpilotPlan.disableThrottle = ( starpilotPlan.disableThrottle = (
+2 -3
View File
@@ -235,7 +235,7 @@ class BlueZClient:
continue continue
props = interfaces[DEVICE_IFACE] props = interfaces[DEVICE_IFACE]
uuids = [str(value).lower() for value in props.get("UUIDs", [])] uuids = [str(value).lower() for value in props.get("UUIDs", [])]
audio, controller, serial = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", ""))) audio, controller = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
device = { device = {
"path": path, "path": path,
"address": str(props.get("Address", "")), "address": str(props.get("Address", "")),
@@ -248,10 +248,9 @@ class BlueZClient:
"uuids": uuids, "uuids": uuids,
"audio": audio, "audio": audio,
"controller": controller, "controller": controller,
"serial": serial,
} }
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"], if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
device["blocked"], audio, controller, serial, include_discovering): device["blocked"], audio, controller, include_discovering):
devices.append(device) devices.append(device)
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower())) return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
+2 -86
View File
@@ -9,13 +9,11 @@ from typing import Any
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient from openpilot.starpilot.system.bluetooth.bluez import BlueZClient
from openpilot.starpilot.system.bluetooth.elm327 import ELM327Session
from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio
OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response", OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
"elm_open", "elm_command", "elm_read_dtcs"}
SCAN_DURATION = 20.0 SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0 AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0 AUDIO_TEST_HOLD_TIME = 3.0
@@ -26,15 +24,13 @@ MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0
class BluetoothController: class BluetoothController:
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None, def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None,
params_memory: Params | None = None, sleep=time.sleep, elm_factory=ELM327Session): params_memory: Params | None = None, sleep=time.sleep):
self.params = params or Params() self.params = params or Params()
self.params_memory = params_memory or Params(memory=True) self.params_memory = params_memory or Params(memory=True)
self._bluez_factory = bluez_factory self._bluez_factory = bluez_factory
self._elm_factory = elm_factory
self._radio = radio or BluetoothRadio() self._radio = radio or BluetoothRadio()
self._lock = threading.RLock() self._lock = threading.RLock()
self._bluez: BlueZClient | None = None self._bluez: BlueZClient | None = None
self._elm: ELM327Session | None = None
self._pairing_address = "" self._pairing_address = ""
self._pairing_error = "" self._pairing_error = ""
self._last_reconnect = 0.0 self._last_reconnect = 0.0
@@ -50,7 +46,6 @@ class BluetoothController:
self.params.remove("BluetoothAudioTestActive") self.params.remove("BluetoothAudioTestActive")
self.params_memory.remove("TestAlert") self.params_memory.remove("TestAlert")
with self._lock: with self._lock:
self._close_elm()
if self._bluez is not None: if self._bluez is not None:
self._bluez.close() self._bluez.close()
self._bluez = None self._bluez = None
@@ -85,7 +80,6 @@ class BluetoothController:
def _reset_client(self) -> None: def _reset_client(self) -> None:
with self._lock: with self._lock:
self._close_elm()
if self._bluez is not None: if self._bluez is not None:
try: try:
self._bluez.close() self._bluez.close()
@@ -93,32 +87,6 @@ class BluetoothController:
pass pass
self._bluez = None self._bluez = None
def _close_elm(self) -> None:
with self._lock:
session = self._elm
self._elm = None
if session is None:
return
try:
session.close()
except Exception:
cloudlog.warning("ELM327 session close failed")
def _invalidate_elm(self, session: ELM327Session) -> None:
with self._lock:
if self._elm is not session:
return
self._close_elm()
def _active_elm(self, address: str) -> ELM327Session:
with self._lock:
session = self._elm
if session is None:
raise RuntimeError("ELM327 session is not open")
if str(session.address).upper() != address.upper():
raise RuntimeError("ELM327 session is open for another device")
return session
def _offroad(self) -> bool: def _offroad(self) -> bool:
return self.params.get_bool("IsOffroad") return self.params.get_bool("IsOffroad")
@@ -215,7 +183,6 @@ class BluetoothController:
pass pass
raise raise
else: else:
self._close_elm()
try: try:
client = self._bluez client = self._bluez
if client is not None: if client is not None:
@@ -264,9 +231,6 @@ class BluetoothController:
self._manual_disconnect_until.pop(normalized_address, None) self._manual_disconnect_until.pop(normalized_address, None)
raise raise
elif command == "forget": elif command == "forget":
with self._lock:
if self._elm is not None and str(self._elm.address).upper() == address.upper():
self._close_elm()
self._client().remove(address) self._client().remove(address)
self._reconnect_backoff.pop(address.upper(), None) self._reconnect_backoff.pop(address.upper(), None)
self._manual_disconnect_until.pop(address.upper(), None) self._manual_disconnect_until.pop(address.upper(), None)
@@ -294,50 +258,6 @@ class BluetoothController:
self._audio_test_deadline = deadline self._audio_test_deadline = deadline
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start() threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start()
return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))} return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))}
elif command == "elm_open":
if not address:
raise RuntimeError("Bluetooth device address is required")
if not self.params.get_bool("BluetoothEnabled"):
raise RuntimeError("Bluetooth is disabled")
with self._lock:
if self._elm is not None and str(self._elm.address).upper() == address.upper():
return {"adapter": self._elm.adapter_name}
device = self._client().device_for_address(address)
if not device.get("paired"):
raise RuntimeError("Pair the Bluetooth device before opening ELM327")
if not device.get("serial"):
raise RuntimeError("Bluetooth device does not advertise Serial Port Profile")
self._close_elm()
session = self._elm_factory(address)
try:
adapter = str(session.open())
except Exception:
try:
session.close()
except Exception:
pass
raise
session.adapter_name = adapter
self._elm = session
return {"adapter": adapter}
elif command == "elm_close":
with self._lock:
if self._elm is not None and str(self._elm.address).upper() == address.upper():
self._close_elm()
elif command == "elm_command":
session = self._active_elm(address)
try:
return {"response": session.command(str(request.get("value", "")))}
except Exception:
self._invalidate_elm(session)
raise
elif command == "elm_read_dtcs":
session = self._active_elm(address)
try:
return session.read_dtcs()
except Exception:
self._invalidate_elm(session)
raise
elif command == "pairing_response": elif command == "pairing_response":
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))): if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))):
raise RuntimeError("Pairing request is no longer active") raise RuntimeError("Pairing request is no longer active")
@@ -356,14 +276,10 @@ class BluetoothController:
while True: while True:
time.sleep(2) time.sleep(2)
if not self.params.get_bool("BluetoothEnabled"): if not self.params.get_bool("BluetoothEnabled"):
self._close_elm()
continue continue
try: try:
status = self.status() status = self.status()
if self._elm is not None and not status["offroad"]:
self._close_elm()
if not status["available"] or not status["powered"]: if not status["available"] or not status["powered"]:
self._close_elm()
continue continue
now = time.monotonic() now = time.monotonic()
self._maintain_scan(status, now) self._maintain_scan(status, now)
-166
View File
@@ -1,166 +0,0 @@
from __future__ import annotations
import re
import socket
import threading
DEFAULT_CHANNEL = 1
OPEN_TIMEOUT = 10.0
DEFAULT_COMMAND_TIMEOUT = 10.0
DTC_COMMAND_TIMEOUT = 25.0
MAX_COMMAND_LENGTH = 256
MAX_RESPONSE_SIZE = 64 * 1024
RECV_SIZE = 4096
class DTCParseError(ValueError):
def __init__(self, message: str, raw: str):
super().__init__(message)
self.raw = raw
def decode_dtc(first: int, second: int) -> str:
prefixes = "PCBU"
prefix = prefixes[(first >> 6) & 0x03]
return f"{prefix}{(first >> 4) & 0x03:X}{first & 0x0F:X}{second >> 4:X}{second & 0x0F:X}"
_HEX_BYTE = re.compile(r"(?i)(?<![0-9a-f])([0-9a-f]{2})(?![0-9a-f])")
def parse_dtcs(raw: str) -> list[str]:
codes = []
seen = set()
for line in raw.splitlines():
values = [int(match, 16) for match in _HEX_BYTE.findall(line)]
try:
response_index = values.index(0x43)
except ValueError:
continue
payload = values[response_index + 1:]
if len(payload) % 2:
count = payload[0]
expected_length = count * 2
if len(payload) - 1 < expected_length:
raise DTCParseError(f"Mode 03 response claims {expected_length} DTC bytes, received {len(payload) - 1}", raw)
payload = payload[1:1 + expected_length]
for index in range(0, len(payload) - 1, 2):
first, second = payload[index:index + 2]
if first == 0 and second == 0:
continue
code = decode_dtc(first, second)
if code not in seen:
seen.add(code)
codes.append(code)
return codes
class ELM327Session:
def __init__(self, address: str, channel: int = DEFAULT_CHANNEL):
self.address = address
self.channel = channel
self.socket: socket.socket | None = None
self.lock = threading.RLock()
self.adapter_name = ""
def _close_unlocked(self) -> None:
client_socket = self.socket
self.socket = None
self.adapter_name = ""
if client_socket is not None:
try:
client_socket.close()
except Exception:
pass
def close(self) -> None:
with self.lock:
self._close_unlocked()
def _receive_until_prompt_unlocked(self, timeout: float) -> bytes:
if self.socket is None:
raise RuntimeError("ELM327 session is not open")
self.socket.settimeout(timeout)
response = bytearray()
while True:
chunk = self.socket.recv(RECV_SIZE)
if not chunk:
raise RuntimeError("ELM327 connection closed")
response.extend(chunk)
if len(response) > MAX_RESPONSE_SIZE:
raise RuntimeError("ELM327 response exceeded 64 KiB")
if b">" in response:
return bytes(response)
@staticmethod
def _clean_response(raw: bytes, command: str) -> str:
response = raw.split(b">", 1)[0].decode("ascii", errors="replace")
response = response.replace("\r\n", "\n").replace("\r", "\n")
lines = response.split("\n")
while lines and not lines[0].strip():
lines.pop(0)
if lines and lines[0].strip() == command:
lines.pop(0)
return "\n".join(lines).strip()
def _exchange_unlocked(self, command: str, timeout: float) -> str:
if self.socket is None:
raise RuntimeError("ELM327 session is not open")
try:
self.socket.settimeout(timeout)
self.socket.sendall(command.encode("ascii") + b"\r")
return self._clean_response(self._receive_until_prompt_unlocked(timeout), command)
except Exception as error:
self._close_unlocked()
raise RuntimeError(f"ELM327 transport failed: {error}") from error
def open(self) -> str:
with self.lock:
if self.socket is not None:
return self.adapter_name
try:
self.socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
self.socket.settimeout(OPEN_TIMEOUT)
self.socket.connect((self.address, self.channel))
adapter_name = self._exchange_unlocked("ATI", OPEN_TIMEOUT)
if not adapter_name or adapter_name.strip().upper() in {"?", "ERROR", "COMMAND UNKNOWN", "UNKNOWN COMMAND", "NO DATA"}:
raise RuntimeError("ELM327 adapter rejected ATI")
self.adapter_name = adapter_name
for setup_command in ("ATE0", "ATL0", "ATH0"):
self._exchange_unlocked(setup_command, OPEN_TIMEOUT)
return self.adapter_name
except Exception as error:
self._close_unlocked()
if isinstance(error, RuntimeError) and str(error).startswith("ELM327 open failed:"):
raise
raise RuntimeError(f"ELM327 open failed: {error}") from error
def command(self, command: str, timeout: float = DEFAULT_COMMAND_TIMEOUT) -> str:
if not isinstance(command, str):
raise ValueError("ELM327 command must be text")
if "\r" in command or "\n" in command:
raise ValueError("ELM327 command cannot contain carriage returns or newlines")
command = command.strip()
if not command:
raise ValueError("ELM327 command cannot be empty")
if len(command) > MAX_COMMAND_LENGTH:
raise ValueError("ELM327 command is too long")
try:
command.encode("ascii")
except UnicodeEncodeError as error:
raise ValueError("ELM327 command must contain ASCII characters") from error
if timeout <= 0:
raise ValueError("ELM327 command timeout must be positive")
with self.lock:
return self._exchange_unlocked(command, timeout)
def read_dtcs(self) -> dict[str, str | list[str]]:
with self.lock:
for setup_command in ("ATE0", "ATL0", "ATH0", "ATSP0"):
self.command(setup_command)
raw = self.command("03", timeout=DTC_COMMAND_TIMEOUT)
return {"codes": parse_dtcs(raw), "raw": raw}
+4 -24
View File
@@ -16,7 +16,6 @@ BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb" A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb" HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb" HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
COMMAND_TIMEOUTS = { COMMAND_TIMEOUTS = {
"set_power": 90.0, "set_power": 90.0,
"start_scan": 20.0, "start_scan": 20.0,
@@ -25,10 +24,6 @@ COMMAND_TIMEOUTS = {
"disconnect": 20.0, "disconnect": 20.0,
"forget": 20.0, "forget": 20.0,
"test_audio": 10.0, "test_audio": 10.0,
"elm_open": 15.0,
"elm_close": 5.0,
"elm_command": 20.0,
"elm_read_dtcs": 30.0,
} }
TRUE_VALUES = {"1", "true", "yes", "on"} TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -45,7 +40,6 @@ class BluetoothDevice:
uuids: tuple[str, ...] = () uuids: tuple[str, ...] = ()
audio: bool = False audio: bool = False
controller: bool = False controller: bool = False
serial: bool = False
@classmethod @classmethod
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice": def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice":
@@ -60,7 +54,6 @@ class BluetoothDevice:
uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())), uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
audio=bool(value.get("audio", False)), audio=bool(value.get("audio", False)),
controller=bool(value.get("controller", False)), controller=bool(value.get("controller", False)),
serial=bool(value.get("serial", False)),
) )
@@ -93,22 +86,21 @@ class BluetoothStatus:
) )
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool, bool]: def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]:
normalized = {str(uuid).lower() for uuid in uuids} normalized = {str(uuid).lower() for uuid in uuids}
major_class = (int(bluetooth_class) >> 8) & 0x1F major_class = (int(bluetooth_class) >> 8) & 0x1F
audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"} audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"}
controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"} controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"}
serial = SPP_UUID in normalized return audio, controller
return audio, controller, serial
def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool, def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool,
audio: bool, controller: bool, serial: bool = False, discovering: bool = False) -> bool: audio: bool, controller: bool, discovering: bool = False) -> bool:
known = paired or trusted or connected known = paired or trusted or connected
normalized_address = "".join(character for character in address.upper() if character.isalnum()) normalized_address = "".join(character for character in address.upper() if character.isalnum())
normalized_name = "".join(character for character in name.upper() if character.isalnum()) normalized_name = "".join(character for character in name.upper() if character.isalnum())
named = bool(name) and name != "Unknown device" and normalized_name != normalized_address named = bool(name) and name != "Unknown device" and normalized_name != normalized_address
return known or (named and not blocked and (audio or controller or serial)) return known or (named and not blocked and (audio or controller))
class _DesktopFakeBluetooth: class _DesktopFakeBluetooth:
@@ -312,17 +304,5 @@ class BluetoothClient:
result = self.call("test_audio", address=address) result = self.call("test_audio", address=address)
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0) return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0)
def elm_open(self, address: str) -> dict[str, Any]:
return self.call("elm_open", address=address)
def elm_close(self, address: str) -> dict[str, Any]:
return self.call("elm_close", address=address)
def elm_command(self, address: str, value: str) -> dict[str, Any]:
return self.call("elm_command", address=address, value=value)
def elm_read_dtcs(self, address: str) -> dict[str, Any]:
return self.call("elm_read_dtcs", address=address)
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None: def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value) self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
@@ -6,11 +6,10 @@ import numpy as np
import pytest import pytest
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
import openpilot.starpilot.system.bluetooth.daemon as bluetooth_daemon
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus, from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus,
SPP_UUID, device_capabilities, show_pairing_device) device_capabilities, show_pairing_device)
from openpilot.system import hardware from openpilot.system import hardware
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
@@ -65,7 +64,6 @@ class FakeBlueZ:
"connected": False, "connected": False,
"audio": True, "audio": True,
"controller": False, "controller": False,
"serial": False,
} }
def close(self): def close(self):
@@ -165,38 +163,9 @@ class FakeProcess:
self.stopped = True self.stopped = True
class FakeELM:
instances = []
def __init__(self, address):
self.address = address
self.adapter_name = "Fake ELM327"
self.closed = False
self.commands = []
self.opened = False
FakeELM.instances.append(self)
def open(self):
self.opened = True
return self.adapter_name
def close(self):
self.closed = True
def command(self, value):
self.commands.append(value)
if value == "fail":
raise RuntimeError("transport failed")
return f"response for {value}"
def read_dtcs(self):
return {"codes": ["P0133"], "raw": "43 01 33"}
def test_protocol_round_trip_and_capabilities(): def test_protocol_round_trip_and_capabilities():
audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID]) audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID])
assert audio and controller assert audio and controller
assert not serial
status = BluetoothStatus.from_dict({ status = BluetoothStatus.from_dict({
"available": True, "available": True,
"enabled": True, "enabled": True,
@@ -205,155 +174,12 @@ def test_protocol_round_trip_and_capabilities():
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),) assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
def test_serial_capability_round_trips_and_is_discoverable():
audio, controller, serial = device_capabilities([SPP_UUID.upper()])
assert not audio and not controller and serial
status = BluetoothStatus.from_dict({
"devices": [{"address": "00:11:22:33:44:55", "name": "OBDII", "serial": True}],
})
assert status.devices[0].serial
assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False,
audio=False, controller=False, serial=True)
def test_serial_device_is_not_auto_reconnected_but_audio_device_is(monkeypatch):
class StopMaintenance(Exception):
pass
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ()
client.powered = True
client.device.update(paired=True, trusted=True, connected=False, audio=False, controller=False, serial=True)
sleeps = 0
def sleep(_delay):
nonlocal sleeps
sleeps += 1
if sleeps > 1:
raise StopMaintenance
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
controller = BluetoothController(params, lambda: client, FakeRadio(), sleep=sleep, elm_factory=FakeELM)
controller._bluez = client
controller._last_reconnect = -100.0
with pytest.raises(StopMaintenance):
controller.maintain_connections()
assert client.actions == []
client.device.update(audio=True, serial=False)
sleeps = 0
controller._last_reconnect = -100.0
with pytest.raises(StopMaintenance):
controller.maintain_connections()
assert client.actions == [("connect", client.device["address"])]
def make_elm_controller(elm_factory=FakeELM):
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
client = FakeBlueZ()
client.device.update(paired=True, trusted=True, serial=True)
controller = BluetoothController(params, lambda: client, FakeRadio(), elm_factory=elm_factory)
return controller, client, params
def test_elm_is_lazy_and_requires_a_paired_serial_device():
FakeELM.instances = []
controller, client, params = make_elm_controller()
assert FakeELM.instances == []
controller.status()
assert FakeELM.instances == []
result = controller.handle({"command": "elm_open", "address": client.device["address"]})
assert result == {"adapter": "Fake ELM327"}
assert len(FakeELM.instances) == 1
params.values["IsOffroad"] = False
with pytest.raises(RuntimeError, match="offroad"):
controller.handle({"command": "elm_command", "address": client.device["address"], "value": "ATI"})
params.values["IsOffroad"] = True
client.device["paired"] = False
controller.handle({"command": "elm_close", "address": client.device["address"]})
with pytest.raises(RuntimeError, match="Pair"):
controller.handle({"command": "elm_open", "address": client.device["address"]})
client.device.update(paired=True, serial=False)
with pytest.raises(RuntimeError, match="Serial Port Profile"):
controller.handle({"command": "elm_open", "address": client.device["address"]})
def test_elm_commands_use_one_session_and_close_on_transport_failure():
FakeELM.instances = []
controller, client, _ = make_elm_controller()
address = client.device["address"]
controller.handle({"command": "elm_open", "address": address})
assert controller.handle({"command": "elm_open", "address": address}) == {"adapter": "Fake ELM327"}
assert controller.handle({"command": "elm_command", "address": address, "value": "ATI"}) == {"response": "response for ATI"}
assert controller.handle({"command": "elm_read_dtcs", "address": address}) == {"codes": ["P0133"], "raw": "43 01 33"}
with pytest.raises(RuntimeError, match="transport"):
controller.handle({"command": "elm_command", "address": address, "value": "fail"})
assert controller._elm is None
assert FakeELM.instances[0].closed
def test_elm_open_replaces_a_different_session_and_close_is_allowed_onroad():
FakeELM.instances = []
controller, client, params = make_elm_controller()
first = client.device["address"]
second = "AA:BB:CC:DD:EE:FF"
controller.handle({"command": "elm_open", "address": first})
controller.handle({"command": "elm_open", "address": second})
assert len(FakeELM.instances) == 2
assert FakeELM.instances[0].closed
assert not FakeELM.instances[1].closed
params.values["IsOffroad"] = False
controller.handle({"command": "elm_close", "address": second})
assert FakeELM.instances[1].closed and controller._elm is None
def test_elm_cleanup_happens_before_poweroff_forget_shutdown_and_onroad(monkeypatch):
class StopMaintenance(Exception):
pass
for cleanup in ("power", "forget", "shutdown", "onroad"):
FakeELM.instances = []
controller, client, params = make_elm_controller()
address = client.device["address"]
controller.handle({"command": "elm_open", "address": address})
session = FakeELM.instances[0]
if cleanup == "power":
controller.handle({"command": "set_power", "enabled": False})
elif cleanup == "forget":
controller.handle({"command": "forget", "address": address})
elif cleanup == "shutdown":
controller.close()
else:
params.values["IsOffroad"] = False
sleeps = 0
def sleep(_delay):
nonlocal sleeps
sleeps += 1
if sleeps > 1:
raise StopMaintenance
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
controller._sleep = sleep
with pytest.raises(StopMaintenance):
controller.maintain_connections()
assert session.closed and controller._elm is None
def test_pairing_list_filters_anonymous_and_irrelevant_advertisements(): def test_pairing_list_filters_anonymous_and_irrelevant_advertisements():
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False) assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False) assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True) assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, discovering=True) assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, True)
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, discovering=True) assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, True)
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False) assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
@@ -1,227 +0,0 @@
from collections import deque
import threading
import pytest
from openpilot.starpilot.system.bluetooth import elm327
ADDRESS = "00:11:22:33:44:55"
class FakeSocket:
def __init__(self, responses):
self.responses = deque(deque(response) for response in responses)
self.pending = deque()
self.sent = []
self.connected_to = None
self.timeouts = []
self.closed = False
self.close_calls = 0
self.recv_error = None
self.send_error = None
def settimeout(self, timeout):
self.timeouts.append(timeout)
def connect(self, address):
self.connected_to = address
def sendall(self, value):
if self.send_error is not None:
raise self.send_error
self.sent.append(value)
self.pending = self.responses.popleft() if self.responses else deque()
def recv(self, _size):
if self.recv_error is not None:
raise self.recv_error
return self.pending.popleft() if self.pending else b""
def close(self):
self.close_calls += 1
self.closed = True
def startup_responses(identity=b"ELM327 v1.5"):
return [
[b"ATI\r\n", identity + b"\r\n>"],
[b"ATE0\r\nOK\r\n>"],
[b"ATL0\r\nOK\r\n>"],
[b"ATH0\r\nOK\r\n>"],
]
def make_session(monkeypatch, responses):
fake = FakeSocket(responses)
monkeypatch.setattr(elm327.socket, "AF_BLUETOOTH", 31, raising=False)
monkeypatch.setattr(elm327.socket, "BTPROTO_RFCOMM", 3, raising=False)
monkeypatch.setattr(elm327.socket, "socket", lambda *args: fake)
return elm327.ELM327Session(ADDRESS), fake
def test_open_uses_rfccomm_channel_one_and_validates_ati(monkeypatch):
session, fake = make_session(monkeypatch, startup_responses())
assert session.open() == "ELM327 v1.5"
assert fake.connected_to == (ADDRESS, 1)
assert fake.sent == [b"ATI\r", b"ATE0\r", b"ATL0\r", b"ATH0\r"]
assert session.adapter_name == "ELM327 v1.5"
def test_open_rejects_empty_or_obviously_rejected_ati(monkeypatch):
for identity in (b"", b"?", b"ERROR"):
session, fake = make_session(monkeypatch, startup_responses(identity))
with pytest.raises(RuntimeError, match="ATI"):
session.open()
assert session.socket is None and fake.closed
def test_command_removes_exact_echo_and_reads_split_prompt_response(monkeypatch):
responses = startup_responses() + [[b"ATR", b"V\r\n12.4V\r", b"\n>"]]
session, fake = make_session(monkeypatch, responses)
session.open()
assert session.command(" ATRV ") == "12.4V"
assert fake.sent[-1] == b"ATRV\r"
def test_malformed_response_bytes_decode_with_replacement(monkeypatch):
responses = startup_responses() + [[b"ATI\r\n\xffOK\r\n>"]]
session, _ = make_session(monkeypatch, responses)
session.open()
assert session.command("ATI") == "OK"
@pytest.mark.parametrize("command", ["", " ", "ATI\r", "ATI\n", "AT\r\nI", "A" * (elm327.MAX_COMMAND_LENGTH + 1)])
def test_command_rejects_invalid_input(monkeypatch, command):
session, _ = make_session(monkeypatch, [])
with pytest.raises(ValueError):
session.command(command)
def test_command_rejects_non_ascii_input(monkeypatch):
session, _ = make_session(monkeypatch, [])
with pytest.raises(ValueError, match="ASCII"):
session.command("ATé")
def test_response_size_limit_closes_session(monkeypatch):
responses = startup_responses() + [[b"x" * (elm327.MAX_RESPONSE_SIZE + 1)]]
session, fake = make_session(monkeypatch, responses)
session.open()
with pytest.raises(RuntimeError, match="64 KiB"):
session.command("ATI")
assert session.socket is None and fake.closed
@pytest.mark.parametrize("error", [TimeoutError("timed out"), OSError("disconnected")])
def test_timeout_or_eof_closes_session(monkeypatch, error):
responses = startup_responses() + [[]]
session, fake = make_session(monkeypatch, responses)
session.open()
fake.recv_error = error if isinstance(error, TimeoutError) else None
if isinstance(error, TimeoutError):
with pytest.raises(RuntimeError, match="transport"):
session.command("ATI")
else:
with pytest.raises(RuntimeError, match="connection closed"):
session.command("ATI")
assert session.socket is None and fake.closed
def test_send_failure_closes_session(monkeypatch):
responses = startup_responses() + [[b"OK>"]]
session, fake = make_session(monkeypatch, responses)
session.open()
fake.send_error = OSError("send failed")
with pytest.raises(RuntimeError, match="transport"):
session.command("ATI")
assert session.socket is None and fake.closed
def test_close_is_idempotent(monkeypatch):
session, fake = make_session(monkeypatch, startup_responses())
session.open()
session.close()
session.close()
assert fake.close_calls == 1
assert session.socket is None
def test_simultaneous_commands_are_serialized(monkeypatch):
class SerializedSocket(FakeSocket):
def __init__(self, responses):
super().__init__(responses)
self.command_started = threading.Event()
self.release_command = threading.Event()
self._command_sends = 0
def sendall(self, value):
super().sendall(value)
self._command_sends += 1
if self._command_sends == 5:
self.command_started.set()
assert self.release_command.wait(timeout=1.0)
fake = SerializedSocket(startup_responses() + [[b"VALUE1>"], [b"VALUE2>"]])
monkeypatch.setattr(elm327.socket, "AF_BLUETOOTH", 31, raising=False)
monkeypatch.setattr(elm327.socket, "BTPROTO_RFCOMM", 3, raising=False)
monkeypatch.setattr(elm327.socket, "socket", lambda *args: fake)
session = elm327.ELM327Session(ADDRESS)
session.open()
results = []
first = threading.Thread(target=lambda: results.append(session.command("ONE")))
second = threading.Thread(target=lambda: results.append(session.command("TWO")))
first.start()
assert fake.command_started.wait(timeout=1.0)
second.start()
assert len(fake.sent) == 5
fake.release_command.set()
first.join(timeout=1.0)
second.join(timeout=1.0)
assert sorted(results) == ["VALUE1", "VALUE2"]
assert len(fake.sent) == 6
def test_read_dtcs_runs_known_setup_and_returns_mode_three_result(monkeypatch):
responses = startup_responses() + [
[b"OK>"], [b"OK>"], [b"OK>"], [b"OK>"],
[b"43 01 33 04 20 00 00>"],
]
session, fake = make_session(monkeypatch, responses)
session.open()
assert session.read_dtcs() == {"codes": ["P0133", "P0420"], "raw": "43 01 33 04 20 00 00"}
assert fake.sent[-5:] == [b"ATE0\r", b"ATL0\r", b"ATH0\r", b"ATSP0\r", b"03\r"]
def test_non_can_dtc_is_decoded_and_padding_ignored():
assert elm327.parse_dtcs("43 01 33 00 00 00 00") == ["P0133"]
def test_can_dtc_count_byte_is_skipped():
assert elm327.parse_dtcs("43 02 01 33 04 20") == ["P0133", "P0420"]
def test_no_data_returns_no_codes():
assert elm327.parse_dtcs("NO DATA") == []
def test_multiple_ecu_lines_are_ordered_and_deduplicated():
raw = "43 01 33 00 00\n43 04 20 00 00\n43 01 33 00 00"
assert elm327.parse_dtcs(raw) == ["P0133", "P0420"]
def test_malformed_can_count_raises_with_raw_response():
raw = "43 02 01 33"
with pytest.raises(elm327.DTCParseError) as error:
elm327.parse_dtcs(raw)
assert error.value.raw == raw
@@ -24,7 +24,7 @@ function rememberEvent(eventId) {
async function pollSentryEvent() { async function pollSentryEvent() {
try { try {
const response = await fetch(galaxyPath("/api/sentry/status"), { cache: "no-store" }) const response = await fetch("/api/sentry/status", { cache: "no-store" })
if (!response.ok) return if (!response.ok) return
const payload = await response.json() const payload = await response.json()
const event = payload?.lastEvent const event = payload?.lastEvent
@@ -81,13 +81,6 @@ async function readJsonResponse(response) {
try { try {
return JSON.parse(body) return JSON.parse(body)
} catch { } catch {
const contentType = response.headers?.get("content-type") || ""
if (contentType.includes("text/html") || body.trim().startsWith("<")) {
if (response.status === 200) {
throw new Error("Galaxy returned an HTML page instead of JSON data. Check your session or connection.")
}
throw new Error(`Galaxy returned an HTML error page (${response.status}). Check the device connection or Galaxy tunnel.`)
}
throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`)
} }
} }
@@ -398,116 +398,6 @@
animation-delay: 0.28s; animation-delay: 0.28s;
} }
.bluetoothElmPanel {
background: var(--secondary-bg);
border: 1px solid rgba(169, 140, 229, 0.45);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
padding: 18px;
}
.bluetoothElmHeader,
.bluetoothElmActions,
.bluetoothElmCommand > div {
align-items: center;
display: flex;
gap: 10px;
}
.bluetoothElmHeader {
justify-content: space-between;
gap: 18px;
}
.bluetoothElmHeader h3,
.bluetoothElmHeader p,
.bluetoothElmCodes p,
.bluetoothElmResponse pre,
.bluetoothElmCommand label {
margin: 0;
}
.bluetoothElmHeader p,
.bluetoothElmCodes p {
color: var(--text-muted);
margin-top: 4px;
}
.bluetoothElmHeader strong {
color: #cbb2fa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bluetoothElmActions {
margin-top: 16px;
}
.bluetoothElmActions button,
.bluetoothElmCommand button {
background: linear-gradient(135deg, #765bb6, #9474ce);
border: 0;
border-radius: var(--border-radius-md);
color: #fff;
cursor: pointer;
font-weight: 700;
padding: 10px 14px;
}
.bluetoothElmActions .bluetoothSecondaryButton {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
color: var(--text-color);
}
.bluetoothElmActions button:disabled,
.bluetoothElmCommand button:disabled,
.bluetoothElmCommand input:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.bluetoothElmCodes,
.bluetoothElmCommand,
.bluetoothElmResponse {
margin-top: 16px;
}
.bluetoothElmCommand label {
color: var(--text-muted);
display: block;
font-size: 0.86rem;
margin-bottom: 6px;
}
.bluetoothElmCommand > div {
align-items: stretch;
}
.bluetoothElmCommand input {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
color: var(--text-color);
flex: 1;
font: inherit;
min-width: 0;
padding: 10px 12px;
}
.bluetoothElmResponse pre {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-md);
color: var(--text-color);
margin-top: 6px;
max-height: 220px;
overflow: auto;
padding: 12px;
white-space: pre-wrap;
}
@keyframes bluetoothSpin { @keyframes bluetoothSpin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@@ -20,11 +20,6 @@ const state = reactive({
prompt: null, prompt: null,
audioTestAddress: "", audioTestAddress: "",
audioTestLabel: "", audioTestLabel: "",
elmAddress: "",
elmName: "",
elmAdapter: "",
elmResponse: "",
elmCodes: null,
error: "", error: "",
}) })
@@ -51,9 +46,7 @@ function schedulePoll(delay = pollDelay()) {
pollTimer = setTimeout(async () => { pollTimer = setTimeout(async () => {
pollTimer = null pollTimer = null
try { try {
if (state.elmAddress && (document.visibilityState === "hidden" || !bluetoothPageActive())) { if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
closeElm()
} else if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
await refresh() await refresh()
} }
} finally { } finally {
@@ -104,10 +97,8 @@ async function request(operation, body = {}) {
} }
state.error = "" state.error = ""
await refresh() await refresh()
return payload
} catch (error) { } catch (error) {
state.error = error?.message || "Bluetooth operation failed" state.error = error?.message || "Bluetooth operation failed"
return null
} finally { } finally {
state.busy = "" state.busy = ""
if (operation === "power") state.powerTarget = null if (operation === "power") state.powerTarget = null
@@ -115,54 +106,6 @@ async function request(operation, body = {}) {
} }
} }
function clearElmState() {
state.elmAddress = ""
state.elmName = ""
state.elmAdapter = ""
state.elmResponse = ""
state.elmCodes = null
}
function closeElm() {
const address = state.elmAddress
if (!address) return
clearElmState()
request("elm_close", { address })
}
async function openElm(address) {
const device = state.devices.find((item) => normalizedAddress(item) === String(address || "").toUpperCase())
const payload = await request("elm_open", { address })
if (!payload || !device) return
state.elmAddress = address
state.elmName = device.name || address
state.elmAdapter = String(payload.adapter || "")
state.elmResponse = ""
state.elmCodes = null
}
async function readElmCodes() {
const address = state.elmAddress
if (!address) return
const payload = await request("elm_read_dtcs", { address })
if (!payload || state.elmAddress !== address) return
state.elmCodes = Array.isArray(payload.codes) ? payload.codes.map(String) : []
state.elmResponse = String(payload.raw || "")
}
async function sendElmCommand() {
const address = state.elmAddress
const input = document.getElementById("bluetoothElmCommand")
const command = input?.value.trim() || ""
if (!address || !command) {
state.error = "Enter an ELM327 command."
return
}
const payload = await request("elm_command", { address, value: command })
if (!payload || state.elmAddress !== address) return
state.elmResponse = `${command}\n${String(payload.response || "")}`.trim()
}
async function refreshOnce() { async function refreshOnce() {
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}` const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
const response = await fetch(statusUrl, { cache: "no-store" }) const response = await fetch(statusUrl, { cache: "no-store" })
@@ -183,10 +126,6 @@ async function refreshOnce() {
devices, devices,
}) })
state.devices = devices state.devices = devices
if (state.elmAddress && (!state.enabled || !state.offroad ||
!devices.some((device) => normalizedAddress(device) === state.elmAddress.toUpperCase() && device.paired))) {
closeElm()
}
if (state.deviceSignature !== deviceSignature) { if (state.deviceSignature !== deviceSignature) {
state.deviceSignature = deviceSignature state.deviceSignature = deviceSignature
state.revision++ state.revision++
@@ -284,11 +223,7 @@ function initialize() {
window.addEventListener("focus", refresh) window.addEventListener("focus", refresh)
window.addEventListener("pageshow", refresh) window.addEventListener("pageshow", refresh)
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" || !bluetoothPageActive()) { if (document.visibilityState !== "hidden" && bluetoothPageActive()) refresh()
closeElm()
} else {
refresh()
}
}) })
refresh() refresh()
schedulePoll(0) schedulePoll(0)
@@ -313,7 +248,6 @@ function deviceCapabilities(device) {
const capabilities = [] const capabilities = []
if (device.audio) capabilities.push("Audio") if (device.audio) capabilities.push("Audio")
if (device.controller) capabilities.push("Controller") if (device.controller) capabilities.push("Controller")
if (device.serial) capabilities.push("Serial")
return capabilities.join(" · ") || "Bluetooth device" return capabilities.join(" · ") || "Bluetooth device"
} }
@@ -419,14 +353,7 @@ function renderDeviceActions(device) {
actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" + actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" +
renderDisabledAttribute(!state.offroad || !!state.busy || pairing) + ">" + (pairing ? "Pairing…" : "Pair") + "</button>") renderDisabledAttribute(!state.offroad || !!state.busy || pairing) + ">" + (pairing ? "Pairing…" : "Pair") + "</button>")
} }
if (device.paired && device.serial) { if (device.paired || device.connected) {
actions.push("<button data-bluetooth-operation=\"elm_open\" data-address=\"" + address + "\"" +
renderDisabledAttribute(!state.offroad || !!state.busy) + ">ELM327</button>")
actions.push("<button class=\"bluetoothIconButton bluetoothForgetButton\" data-bluetooth-operation=\"forget\" data-address=\"" +
address + "\" data-device-name=\"" + name + "\" title=\"Forget device\" aria-label=\"Forget " + name + "\"" +
renderDisabledAttribute(!state.offroad || !!state.busy) + "><i class=\"bi bi-trash3\" aria-hidden=\"true\"></i></button>")
}
if ((device.paired || device.connected) && !device.serial) {
const operation = device.connected ? "disconnect" : "connect" const operation = device.connected ? "disconnect" : "connect"
actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" + actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" +
renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>") renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>")
@@ -480,48 +407,7 @@ function handleDeviceListClick(event) {
const operation = button.dataset.bluetoothOperation const operation = button.dataset.bluetoothOperation
const address = button.dataset.address || "" const address = button.dataset.address || ""
if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return
if (operation === "elm_open") { request(operation, { address })
openElm(address)
} else {
request(operation, { address })
}
}
function renderElmPanel() {
if (!state.elmAddress) return ""
return html`
<section class="bluetoothElmPanel">
<div class="bluetoothElmHeader">
<div>
<h3>ELM327</h3>
<p>${() => state.elmName || state.elmAddress}</p>
</div>
<strong>${() => state.elmAdapter || "Connecting…"}</strong>
</div>
<div class="bluetoothElmActions">
<button disabled="${() => !state.offroad || !!state.busy}" @click="${readElmCodes}">Read Codes</button>
<button class="bluetoothSecondaryButton" disabled="${() => !!state.busy}" @click="${closeElm}">Close</button>
</div>
${() => state.elmCodes !== null ? html`
<div class="bluetoothElmCodes">
<strong>Stored Codes</strong>
<p>${() => state.elmCodes.length ? state.elmCodes.join(" · ") : "No stored codes reported."}</p>
</div>
` : ""}
<div class="bluetoothElmCommand">
<label for="bluetoothElmCommand">Command</label>
<div>
<input id="bluetoothElmCommand" type="text" autocomplete="off" placeholder="ATI"
disabled="${() => !state.offroad || !!state.busy}" />
<button disabled="${() => !state.offroad || !!state.busy}" @click="${sendElmCommand}">Send</button>
</div>
</div>
<div class="bluetoothElmResponse">
<strong>Response</strong>
<pre>${() => state.elmResponse || "—"}</pre>
</div>
</section>
`
} }
export function Bluetooth() { export function Bluetooth() {
@@ -554,7 +440,6 @@ export function Bluetooth() {
<span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span> <span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span>
</div> </div>
` : ""} ` : ""}
${() => renderElmPanel()}
<div class="bluetoothToolbar"> <div class="bluetoothToolbar">
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}" <button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
@@ -384,6 +384,7 @@
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
} }
/* read-only: no border, since there is nothing here to click or edit */
.ds-row-readout { .ds-row-readout {
background-color: transparent; background-color: transparent;
border: none; border: none;
@@ -484,11 +484,11 @@ function formatSliderValue(val, stepStr, precisionInt, key) {
function formatReadoutValue(p) { function formatReadoutValue(p) {
const raw = state.values[p.key] const raw = state.values[p.key]
const value = parseFloat(raw) const v = parseFloat(raw)
if (raw === undefined || raw === null || Number.isNaN(value)) return "--" if (raw === undefined || raw === null || Number.isNaN(v)) return "--"
const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2 const precision = p.precision !== undefined && p.precision !== null ? Number(p.precision) : 2
const formatted = Number(value.toFixed(Math.max(0, precision))).toString() const formatted = Number(v.toFixed(Math.max(0, precision))).toString()
return p.unit ? `${formatted}${p.unit}` : formatted return p.unit ? `${formatted}${p.unit}` : formatted
} }
@@ -108,14 +108,14 @@ async function sendTestEvent() {
state.testBusy = true state.testBusy = true
try { try {
const response = await fetch(galaxyPath("/api/sentry/test"), { method: "POST" }) const response = await fetch(galaxyPath("/api/sentry/test"), { method: "POST" })
const payload = await readJsonResponse(response) const payload = await response.json()
if (!response.ok) { if (!response.ok) {
showSnackbar(payload.error || "Sentry test failed.") showSnackbar(payload.error || "Sentry test failed.")
return return
} }
showSnackbar("Test capture started. The images will appear here shortly.") showSnackbar("Test capture started. The images will appear here shortly.")
} catch (error) { } catch (error) {
showSnackbar(error.message || "Network error — is the device reachable?") showSnackbar("Network error — is the device reachable?")
} finally { } finally {
state.testBusy = false state.testBusy = false
} }
@@ -128,13 +128,6 @@ async function readJsonResponse(response) {
try { try {
return JSON.parse(body) return JSON.parse(body)
} catch { } catch {
const contentType = response.headers?.get("content-type") || ""
if (contentType.includes("text/html") || body.trim().startsWith("<")) {
if (response.status === 200) {
throw new Error("Galaxy returned an HTML page instead of JSON data. Check your session or connection.")
}
throw new Error(`Galaxy returned an HTML error page (${response.status}). Check the device connection or Galaxy tunnel.`)
}
throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`)
} }
} }
@@ -91,7 +91,6 @@ export function isGalaxyTunnel() {
export function galaxyPath(path) { export function galaxyPath(path) {
const suffix = path.startsWith("/") ? path : `/${path}` const suffix = path.startsWith("/") ? path : `/${path}`
if (!isGalaxyTunnel()) return suffix if (!isGalaxyTunnel()) return suffix
if (suffix === "/api" || suffix.startsWith("/api/")) return suffix
const firstPathSegment = window.location.pathname.split("/").filter(Boolean)[0] || "" const firstPathSegment = window.location.pathname.split("/").filter(Boolean)[0] || ""
const slug = /^[A-Za-z0-9]{16}$/.test(firstPathSegment) ? `/${firstPathSegment}` : "" const slug = /^[A-Za-z0-9]{16}$/.test(firstPathSegment) ? `/${firstPathSegment}` : ""
@@ -132,15 +132,7 @@ class FakeBluetoothClient:
def call(self, command, **payload): def call(self, command, **payload):
self.calls.append((command, payload)) self.calls.append((command, payload))
if command == "test_audio": return {"audio_test_delay_ms": 3000} if command == "test_audio" else {}
return {"audio_test_delay_ms": 3000}
if command == "elm_open":
return {"adapter": "Fake ELM327"}
if command == "elm_command":
return {"response": "OK"}
if command == "elm_read_dtcs":
return {"codes": ["P0133"], "raw": "43 01 33"}
return {}
def test_bluetooth_status_api(monkeypatch): def test_bluetooth_status_api(monkeypatch):
@@ -211,26 +203,6 @@ def test_bluetooth_api_dispatches_operations(monkeypatch):
] ]
def test_bluetooth_api_dispatches_elm_payload_and_allows_close_onroad(monkeypatch):
FakeBluetoothClient.calls = []
client, fake_params = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
address = "00:11:22:33:44:55"
assert client.post("/api/bluetooth/elm_open", json={"address": address}).get_json()["adapter"] == "Fake ELM327"
assert client.post("/api/bluetooth/elm_command", json={"address": address, "value": "ATI"}).get_json()["response"] == "OK"
assert client.post("/api/bluetooth/elm_read_dtcs", json={"address": address}).get_json()["codes"] == ["P0133"]
fake_params.values["IsOffroad"] = False
assert client.post("/api/bluetooth/elm_close", json={"address": address}).status_code == 200
assert FakeBluetoothClient.calls == [
("elm_open", {"address": address}),
("elm_command", {"address": address, "value": "ATI"}),
("elm_read_dtcs", {"address": address}),
("elm_close", {"address": address}),
]
def test_wheel_controls_status_includes_favorite_slots(monkeypatch): def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici") client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []}) monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []})
@@ -1,140 +0,0 @@
import json
import time
from pathlib import Path
import pytest
from test_dashboard_stats import FakeParams, MODULE_DIR, _install_server_import_stubs
def _load_server_module():
import importlib.util
import sys
_install_server_import_stubs()
spec = importlib.util.spec_from_file_location("sentry_routing_server", MODULE_DIR / "the_galaxy.py")
module = importlib.util.module_from_spec(spec)
sys.modules["sentry_routing_server"] = module
spec.loader.exec_module(module)
return module
the_galaxy = _load_server_module()
@pytest.fixture
def client(monkeypatch, tmp_path):
assert the_galaxy._import_galaxy_web_symbols()
monkeypatch.setattr(the_galaxy, "params", FakeParams())
monkeypatch.setattr(the_galaxy, "_get_galaxy_dir", lambda: tmp_path)
app = the_galaxy.Flask(
f"test_galaxy_{time.monotonic_ns()}",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
the_galaxy.setup(app)
return app.test_client()
def test_slug_middleware_strips_16_char_slug(client):
# Slug-prefixed API call to sentry push config
response = client.get("/df70390ca648d7c3/api/sentry/push/config")
assert response.status_code == 200
assert "application/json" in response.headers.get("Content-Type", "")
data = response.get_json()
assert data["enabled"] is True
assert len(data["publicKey"]) > 20
# Direct unslugged API call
response_direct = client.get("/api/sentry/push/config")
assert response_direct.status_code == 200
assert response_direct.get_json()["publicKey"] == data["publicKey"]
def test_slug_middleware_service_worker_and_headers(client):
with client.get("/df70390ca648d7c3/service-worker.js") as response:
assert response.status_code == 200
assert response.headers.get("Service-Worker-Allowed") == "/"
assert "no-store" in response.headers.get("Cache-Control", "")
with client.get("/service-worker.js") as response_direct:
assert response_direct.status_code == 200
assert response_direct.headers.get("Service-Worker-Allowed") == "/"
def test_404_api_returns_json_not_html(client):
# Non-existent API route without slug
res1 = client.get("/api/nonexistent")
assert res1.status_code == 404
assert "application/json" in res1.headers.get("Content-Type", "")
assert res1.get_json() == {"error": "Not found"}
# Non-existent API route with slug
res2 = client.get("/df70390ca648d7c3/api/nonexistent")
assert res2.status_code == 404
assert "application/json" in res2.headers.get("Content-Type", "")
assert res2.get_json() == {"error": "Not found"}
# POST to non-existent route returns 404 JSON
res3 = client.post("/random_post_route")
assert res3.status_code == 404
assert "application/json" in res3.headers.get("Content-Type", "")
def test_404_assets_returns_not_found_text(client):
res = client.get("/assets/nonexistent_image.png")
assert res.status_code == 404
assert res.get_data(as_text=True) == "Not found"
def test_404_spa_client_routes_return_html(client):
# SPA route without slug returns index.html
res1 = client.get("/sentry")
assert res1.status_code == 200
assert "text/html" in res1.headers.get("Content-Type", "")
# SPA route with slug returns index.html
res2 = client.get("/df70390ca648d7c3/sentry")
assert res2.status_code == 200
assert "text/html" in res2.headers.get("Content-Type", "")
def test_sentry_push_subscribe_lifecycle(client):
subscription_payload = {
"endpoint": "https://fcm.googleapis.com/fcm/send/test-endpoint-id",
"expirationTime": None,
"keys": {
"p256dh": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-Skv60QVu3vW5PFGhmqazETUFAmeLbvDWP00n-5wViBRio5B-dQ31-10",
"auth": "5KkU95j6j8gBsmVdYqC8pA",
},
}
res = client.post(
"/api/sentry/push/subscribe",
data=json.dumps(subscription_payload),
content_type="application/json",
)
assert res.status_code == 200
assert res.get_json()["subscribed"] is True
assert res.get_json()["subscriptionCount"] == 1
# Check config shows count 1
res_cfg = client.get("/api/sentry/push/config")
assert res_cfg.get_json()["subscriptionCount"] == 1
def test_sentry_vapid_corrupt_file_self_healing(tmp_path, monkeypatch):
monkeypatch.setattr(the_galaxy, "_get_galaxy_dir", lambda: tmp_path)
key_path, _ = the_galaxy._sentry_push_paths()
key_path.parent.mkdir(parents=True, exist_ok=True)
# Write 0-byte corrupted file
key_path.write_bytes(b"")
assert key_path.stat().st_size == 0
# Should self-heal and generate valid key
vapid = the_galaxy._get_sentry_vapid()
assert vapid is not None
assert key_path.stat().st_size > 0
pub_key = the_galaxy._sentry_vapid_public_key(vapid)
assert len(pub_key) > 20
+24 -80
View File
@@ -6,7 +6,6 @@ import importlib
import math import math
import numbers import numbers
import os import os
import platform
import sys import sys
import sysconfig import sysconfig
import tarfile import tarfile
@@ -187,9 +186,7 @@ def _galaxy_runtime_dependency_paths() -> tuple[str, ...]:
"/usr/local/venv/lib/python3.12/site-packages", "/usr/local/venv/lib/python3.12/site-packages",
] ]
is_arm = platform.machine().lower() in ("aarch64", "arm64") for venv_name in (".venv", ".venv-linux-arm64"):
venv_names = (".venv-linux-arm64", ".venv") if is_arm else (".venv",)
for venv_name in venv_names:
venv_path = repo_root / venv_name / "lib" venv_path = repo_root / venv_name / "lib"
if venv_path.is_dir(): if venv_path.is_dir():
candidates.extend(str(path) for path in venv_path.glob("python*/site-packages")) candidates.extend(str(path) for path in venv_path.glob("python*/site-packages"))
@@ -199,14 +196,10 @@ def _galaxy_runtime_dependency_paths() -> tuple[str, ...]:
REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party" REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
GALAXY_RUNTIME_DEPENDENCY_PATHS = _galaxy_runtime_dependency_paths() GALAXY_RUNTIME_DEPENDENCY_PATHS = _galaxy_runtime_dependency_paths()
for deps_path in GALAXY_DEPS_PATHS: for deps_path in GALAXY_DEPS_PATHS + GALAXY_RUNTIME_DEPENDENCY_PATHS:
if os.path.isdir(deps_path) and deps_path not in sys.path: if os.path.isdir(deps_path) and deps_path not in sys.path:
sys.path.insert(0, deps_path) sys.path.insert(0, deps_path)
for deps_path in GALAXY_RUNTIME_DEPENDENCY_PATHS:
if os.path.isdir(deps_path) and deps_path not in sys.path:
sys.path.append(deps_path)
if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path: if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path:
sys.path.insert(0, str(REPO_THIRD_PARTY_PATH)) sys.path.insert(0, str(REPO_THIRD_PARTY_PATH))
@@ -956,23 +949,16 @@ def _get_sentry_vapid():
except ModuleNotFoundError as error: except ModuleNotFoundError as error:
raise RuntimeError("pywebpush is not installed") from error raise RuntimeError("pywebpush is not installed") from error
with _SENTRY_PUSH_LOCK: private_key_path, _ = _sentry_push_paths()
private_key_path, _ = _sentry_push_paths() private_key_path.parent.mkdir(parents=True, exist_ok=True)
private_key_path.parent.mkdir(parents=True, exist_ok=True) if private_key_path.is_file():
if private_key_path.is_file(): return Vapid.from_file(str(private_key_path))
try:
if private_key_path.stat().st_size > 0:
return Vapid.from_file(str(private_key_path))
except Exception as error:
cloudlog.warning("Galaxy: Existing Sentry VAPID private key was invalid, regenerating: %s", error)
vapid = Vapid() vapid = Vapid()
vapid.generate_keys() vapid.generate_keys()
temporary_path = private_key_path.with_suffix(".tmp") vapid.save_key(str(private_key_path))
vapid.save_key(str(temporary_path)) private_key_path.chmod(0o600)
temporary_path.chmod(0o600) return vapid
temporary_path.replace(private_key_path)
return vapid
def _sentry_vapid_public_key(vapid) -> str: def _sentry_vapid_public_key(vapid) -> str:
@@ -4973,30 +4959,7 @@ def _set_lateral_maneuver_mode(enabled):
return _save_lateral_maneuver_status(status) return _save_lateral_maneuver_status(status)
_SLUG_PREFIX_RE = re.compile(r"^/([A-Za-z0-9]{16})(/.*)?$")
class GalaxySlugMiddleware:
"""WSGI middleware to normalize reverse-proxy requests prefixed with a 16-character tunnel slug."""
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
path_info = environ.get("PATH_INFO", "")
match = _SLUG_PREFIX_RE.match(path_info)
if match:
environ["HTTP_X_GALAXY_SLUG"] = match.group(1)
remainder = match.group(2)
environ["PATH_INFO"] = remainder if remainder else "/"
return self.wsgi_app(environ, start_response)
def setup(app): def setup(app):
if not isinstance(app.wsgi_app, GalaxySlugMiddleware):
app.wsgi_app = GalaxySlugMiddleware(app.wsgi_app)
model_status_debug = { model_status_debug = {
"last_signature": None, "last_signature": None,
"last_log_time": 0.0, "last_log_time": 0.0,
@@ -5042,19 +5005,6 @@ def setup(app):
@app.errorhandler(404) @app.errorhandler(404)
def not_found(_): def not_found(_):
is_api = (
request.path == "/api"
or request.path.startswith("/api/")
or "/api/" in request.path
or request.is_json
or (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html)
)
if is_api or request.method not in ("GET", "HEAD"):
return jsonify({"error": "Not found"}), 404
if request.path.startswith(("/assets/", "/screen_recordings/", "/thumbnails/", "/video/")):
return "Not found", 404
response = make_response(render_template("index.html")) response = make_response(render_template("index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
@@ -5121,16 +5071,11 @@ def setup(app):
"select_audio": "select_audio", "select_audio": "select_audio",
"test_audio": "test_audio", "test_audio": "test_audio",
"pairing_response": "pairing_response", "pairing_response": "pairing_response",
"elm_open": "elm_open",
"elm_close": "elm_close",
"elm_command": "elm_command",
"elm_read_dtcs": "elm_read_dtcs",
} }
command = commands.get(operation) command = commands.get(operation)
if command is None: if command is None:
return jsonify({"error": "Unknown Bluetooth operation."}), 404 return jsonify({"error": "Unknown Bluetooth operation."}), 404
offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response", offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
"elm_open", "elm_command", "elm_read_dtcs"}
if operation in offroad_only and not params.get_bool("IsOffroad"): if operation in offroad_only and not params.get_bool("IsOffroad"):
return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409 return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409
@@ -5148,8 +5093,6 @@ def setup(app):
payload["address"] = str(data.get("address", "")) payload["address"] = str(data.get("address", ""))
if not payload["address"] and command != "select_audio": if not payload["address"] and command != "select_audio":
return jsonify({"error": "Bluetooth device address is required."}), 400 return jsonify({"error": "Bluetooth device address is required."}), 400
if command == "elm_command":
payload["value"] = str(data.get("value", ""))
try: try:
client = BluetoothClient(timeout=10.0) client = BluetoothClient(timeout=10.0)
if command == "set_power": if command == "set_power":
@@ -6181,6 +6124,16 @@ def setup(app):
result["VehicleParked"] = _get_vehicle_parked() result["VehicleParked"] = _get_vehicle_parked()
result["AlphaLongitudinalAvailable"] = _get_alpha_longitudinal_available() result["AlphaLongitudinalAvailable"] = _get_alpha_longitudinal_available()
result["HasRivianAngleHarness"] = _get_has_rivian_angle_harness() result["HasRivianAngleHarness"] = _get_has_rivian_angle_harness()
# read-only: excluded from allowed_keys (and so from the write paths) but still
# worth surfacing as a display-only readout
try:
result["CalibratedLateralAcceleration"] = _get_current_param_value("CalibratedLateralAcceleration", float, defaults_lookup)
except Exception:
result["CalibratedLateralAcceleration"] = None
try:
result["CalibrationProgress"] = _get_current_param_value("CalibrationProgress", float, defaults_lookup)
except Exception:
result["CalibrationProgress"] = None
for key in ("CalibratedLateralAcceleration", "CalibrationProgress"): for key in ("CalibratedLateralAcceleration", "CalibrationProgress"):
try: try:
@@ -8243,19 +8196,14 @@ def setup(app):
def sentry_service_worker(): def sentry_service_worker():
response = send_from_directory(app.static_folder, "service-worker.js", mimetype="application/javascript") response = send_from_directory(app.static_folder, "service-worker.js", mimetype="application/javascript")
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Service-Worker-Allowed"] = "/"
return response return response
@app.route("/api/sentry/push/config", methods=["GET"]) @app.route("/api/sentry/push/config", methods=["GET"])
def sentry_push_config(): def sentry_push_config():
try: try:
public_key = _sentry_vapid_public_key(_get_sentry_vapid()) public_key = _sentry_vapid_public_key(_get_sentry_vapid())
except (RuntimeError, ModuleNotFoundError) as error: except Exception:
cloudlog.warning("Galaxy: Sentry Web Push dependencies unavailable: %s", error)
return jsonify({"enabled": False, "error": "Web Push dependencies are unavailable."}), 503 return jsonify({"enabled": False, "error": "Web Push dependencies are unavailable."}), 503
except Exception as error:
cloudlog.exception("Galaxy: Failed to initialize Sentry Web Push: %s", error)
return jsonify({"enabled": False, "error": f"Push notification service error: {error}"}), 500
return jsonify({ return jsonify({
"enabled": True, "enabled": True,
@@ -8271,12 +8219,8 @@ def setup(app):
try: try:
_get_sentry_vapid() _get_sentry_vapid()
except (RuntimeError, ModuleNotFoundError) as error: except Exception:
cloudlog.warning("Galaxy: Sentry Web Push dependencies unavailable: %s", error)
return jsonify({"error": "Web Push dependencies are unavailable."}), 503 return jsonify({"error": "Web Push dependencies are unavailable."}), 503
except Exception as error:
cloudlog.exception("Galaxy: Failed to initialize Sentry Web Push for subscription: %s", error)
return jsonify({"error": f"Push notification service error: {error}"}), 500
with _SENTRY_PUSH_LOCK: with _SENTRY_PUSH_LOCK:
subscriptions = _load_sentry_push_subscriptions() subscriptions = _load_sentry_push_subscriptions()
+4 -11
View File
@@ -86,16 +86,9 @@ class Vapid01(object):
:type private_key: bytes :type private_key: bytes
""" """
try: # not sure why, but load_pem_private_key fails to deserialize
key = serialization.load_pem_private_key( return cls.from_der(
private_key, b''.join(private_key.splitlines()[1:-1]))
password=None,
backend=default_backend()
)
return cls(key)
except Exception:
lines = [line.strip() for line in private_key.splitlines() if line.strip() and not line.strip().startswith(b"-----")]
return cls.from_der(b''.join(lines))
@classmethod @classmethod
def from_der(cls, private_key): def from_der(cls, private_key):
@@ -204,7 +197,7 @@ class Vapid01(object):
def generate_keys(self): def generate_keys(self):
"""Generate a valid ECDSA Key Pair.""" """Generate a valid ECDSA Key Pair."""
self.private_key = ec.generate_private_key(ec.SECP256R1(), self.private_key = ec.generate_private_key(ec.SECP256R1,
default_backend()) default_backend())
def private_pem(self): def private_pem(self):
+8 -5
View File
@@ -7,9 +7,12 @@ from openpilot.common.swaglog import cloudlog
from openpilot.common.pid import PIDController from openpilot.common.pid import PIDController
from openpilot.system.hardware import HARDWARE from openpilot.system.hardware import HARDWARE
# raise fan setpoint on tici/tizi to reduce noise # comma 3/3X (tici/tizi) run a more aggressive, cooler-targeting curve than comma 4 (mici)
# after raising LMH threshold in AGNOS 18.1 to prevent CPU throttling IS_MICI = HARDWARE.get_device_type() == "mici"
OFFSET = 0 if HARDWARE.get_device_type() == "mici" else 5 OFFSET = 0 if IS_MICI else -5
K_P = 0 if IS_MICI else 1.0
FF_LOW = 60.0 if IS_MICI else 55.0
FF_HIGH = 100.0 if IS_MICI else 80.0
class BaseFanController(ABC): class BaseFanController(ABC):
@abstractmethod @abstractmethod
@@ -23,7 +26,7 @@ class TiciFanController(BaseFanController):
cloudlog.info("Setting up TICI fan handler") cloudlog.info("Setting up TICI fan handler")
self.last_ignition = False self.last_ignition = False
self.controller = PIDController(k_p=0, k_i=4e-3, rate=(1 / DT_HW)) self.controller = PIDController(k_p=K_P, k_i=4e-3, rate=(1 / DT_HW))
def update(self, cur_temp: float, ignition: bool) -> int: def update(self, cur_temp: float, ignition: bool) -> int:
self.controller.pos_limit = 100 if ignition else 30 self.controller.pos_limit = 100 if ignition else 30
@@ -35,7 +38,7 @@ class TiciFanController(BaseFanController):
error = cur_temp - (75 + OFFSET) error = cur_temp - (75 + OFFSET)
fan_pwr_out = int(self.controller.update( fan_pwr_out = int(self.controller.update(
error=error, error=error,
feedforward=np.interp(cur_temp, [60.0 + OFFSET, 100.0 + OFFSET], [0, 100]) feedforward=np.interp(cur_temp, [FF_LOW, FF_HIGH], [0, 100])
)) ))
self.last_ignition = ignition self.last_ignition = ignition
+14 -1
View File
@@ -2,6 +2,7 @@ import os
import operator import operator
import platform import platform
import sys import sys
import time
from types import SimpleNamespace from types import SimpleNamespace
@@ -24,9 +25,21 @@ def notcar(started: bool, params: Params, CP: car.CarParams, starpilot_toggles:
def iscar(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: def iscar(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
return started and not CP.notCar return started and not CP.notCar
FORCE_OFFROAD_LOGGING_GRACE_S = 60 # keep loggerd alive this long after Force Offroad, to capture the onroad->offroad transition in the same route
_force_offroad_since: float | None = None
def logging(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool: def logging(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
global _force_offroad_since
run = (not CP.notCar) or not params.get_bool("DisableLogging") run = (not CP.notCar) or not params.get_bool("DisableLogging")
return started and run
if started or not starpilot_toggles.force_offroad:
_force_offroad_since = None
return started and run
# offroad specifically because of the Force Offroad toggle: keep logging for a grace period
if _force_offroad_since is None:
_force_offroad_since = time.monotonic()
return run and (time.monotonic() - _force_offroad_since) < FORCE_OFFROAD_LOGGING_GRACE_S
def ublox_available() -> bool: def ublox_available() -> bool:
return os.path.exists('/dev/ttyHS0') and not os.path.exists('/persist/comma/use-quectel-gps') return os.path.exists('/dev/ttyHS0') and not os.path.exists('/persist/comma/use-quectel-gps')
-38
View File
@@ -98,32 +98,6 @@ class BluetoothManager:
self._operations.pop(normalized_address, None) self._operations.pop(normalized_address, None)
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
def _run_result(self, fn, *args, operation: str = "", address: str = "", callback=None) -> None:
normalized_address = address.upper()
if normalized_address:
with self._lock:
self._operations[normalized_address] = operation
def worker():
result = None
error = None
try:
result = fn(*args)
except Exception as exception:
error = str(exception)
finally:
if normalized_address:
with self._lock:
if self._operations.get(normalized_address) == operation:
self._operations.pop(normalized_address, None)
if callback is not None:
callback(result, error)
elif error:
with self._lock:
self._operation_error = error
threading.Thread(target=worker, daemon=True).start()
def set_power(self, enabled: bool) -> None: def set_power(self, enabled: bool) -> None:
with self._lock: with self._lock:
if self._power_pending: if self._power_pending:
@@ -174,17 +148,5 @@ class BluetoothManager:
self._audio_test_deadline = 0.0 self._audio_test_deadline = 0.0
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
def elm_open(self, address: str, callback=None) -> None:
self._run_result(self._client.elm_open, address, operation="elm_open", address=address, callback=callback)
def elm_close(self, address: str, callback=None) -> None:
self._run_result(self._client.elm_close, address, operation="elm_close", address=address, callback=callback)
def elm_command(self, address: str, value: str, callback=None) -> None:
self._run_result(self._client.elm_command, address, value, operation="elm_command", address=address, callback=callback)
def elm_read_dtcs(self, address: str, callback=None) -> None:
self._run_result(self._client.elm_read_dtcs, address, operation="elm_read_dtcs", address=address, callback=callback)
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None: def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self._run(self._client.respond, prompt_id, accepted, value) self._run(self._client.respond, prompt_id, accepted, value)
+1 -143
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import partial from functools import partial
import threading
import pyray as rl import pyray as rl
@@ -15,7 +14,7 @@ from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import gui_label, gui_text_box from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.toggle import Toggle from openpilot.system.ui.widgets.toggle import Toggle
@@ -46,15 +45,11 @@ def device_status_text(device: BluetoothDevice, operation: str, selected_audio:
capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio")) capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio"))
if device.controller: if device.controller:
capabilities.append(tr("controller")) capabilities.append(tr("controller"))
if device.serial:
capabilities.append(tr("serial"))
capability_text = " / ".join(capabilities) capability_text = " / ".join(capabilities)
if device.connected: if device.connected:
return tr("Connected") + (f" / {capability_text}" if capability_text else "") return tr("Connected") + (f" / {capability_text}" if capability_text else "")
if device.paired: if device.paired:
if device.serial:
return tr("Paired - tap to use ELM327") + (f" / {capability_text}" if capability_text else "")
return tr("Paired - tap to connect") return tr("Paired - tap to connect")
return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "") return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "")
@@ -63,8 +58,6 @@ def device_action_allowed(device: BluetoothDevice, operation: str, offroad: bool
"""Mirror the daemon's operation policy before a row can receive a tap.""" """Mirror the daemon's operation policy before a row can receive a tap."""
if operation: if operation:
return False return False
if device.serial and not offroad:
return False
if not offroad and not device.paired: if not offroad and not device.paired:
return False return False
return True return True
@@ -182,139 +175,6 @@ class BluetoothAudioTestDialog(Widget):
self._done_button.render(button_rect) self._done_button.render(button_rect)
class ELM327Dialog(Widget):
"""Ephemeral, offroad-only ELM327 controls opened from the Bluetooth panel."""
def __init__(self, manager: BluetoothManager, device: BluetoothDevice):
super().__init__()
self._manager = manager
self._address = device.address
self._name = device.name
self._state_lock = threading.Lock()
self._closed = False
self._connecting = True
self._adapter = ""
self._response = ""
self._codes: list[str] | None = None
self._error = ""
self._keyboard = Keyboard(max_text_size=256, min_text_size=1, password_mode=False)
self._read_button = Button(tr("Read Codes"), self._read_codes, button_style=ButtonStyle.PRIMARY, font_size=42)
self._command_button = Button(tr("Send Command"), self._send_command, button_style=ButtonStyle.NORMAL, font_size=42)
self._done_button = Button(tr("Done"), gui_app.pop_widget, button_style=ButtonStyle.NORMAL, font_size=42)
def show_event(self):
super().show_event()
self._manager.elm_open(self._address, callback=self._on_open_result)
def hide_event(self):
with self._state_lock:
self._closed = True
self._manager.elm_close(self._address)
super().hide_event()
def _on_open_result(self, result, error):
should_close = False
with self._state_lock:
self._connecting = False
if error:
self._error = error
else:
self._adapter = str((result or {}).get("adapter", ""))
self._response = ""
self._codes = None
should_close = self._closed
if should_close:
self._manager.elm_close(self._address)
def _on_command_result(self, command: str, result, error):
with self._state_lock:
if error:
self._error = error
else:
response = str((result or {}).get("response", ""))
self._response = f"{command}\n{response}".strip()
def _on_read_result(self, result, error):
with self._state_lock:
if error:
self._error = error
else:
self._codes = [str(code) for code in (result or {}).get("codes", [])]
self._response = str((result or {}).get("raw", ""))
def _send_command(self):
with self._state_lock:
if self._connecting or self._error or self._closed:
return
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Send ELM327 command"), tr("Raw commands are available offroad only."))
self._keyboard.set_callback(self._on_command_entered)
gui_app.push_widget(self._keyboard)
def _on_command_entered(self, result: DialogResult):
command = self._keyboard.text.strip()
self._keyboard.clear()
if result != DialogResult.CONFIRM or not command:
return
with self._state_lock:
self._response = f"{command}\nSending..."
self._error = ""
self._manager.elm_command(self._address, command, callback=partial(self._on_command_result, command))
def _read_codes(self):
with self._state_lock:
self._codes = None
self._error = ""
self._manager.elm_read_dtcs(self._address, callback=self._on_read_result)
def _update_state(self):
with self._state_lock:
ready = bool(self._adapter) and not self._connecting and not self._error and not self._closed
status = self._manager.status
operation = self._manager.operation_for(self._address)
enabled = ready and status.offroad and not operation
self._read_button.set_enabled(enabled)
self._command_button.set_enabled(enabled)
self._done_button.set_enabled(True)
def _render(self, rect: rl.Rectangle):
dialog_rect = rl.Rectangle(rect.x + 90, rect.y + 60, rect.width - 180, rect.height - 120)
rl.draw_rectangle_rounded(dialog_rect, 0.03, 20, DIALOG_BACKGROUND)
with self._state_lock:
connecting = self._connecting
adapter = self._adapter
response = self._response
codes = self._codes
error = self._error
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 35, dialog_rect.width - 90, 70), tr("ELM327"),
font_size=62, font_weight=FontWeight.BOLD)
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 105, dialog_rect.width - 90, 52), self._name,
font_size=42, color=TEXT_SECONDARY)
identity = tr("Connecting...") if connecting else adapter
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 157, dialog_rect.width - 90, 52), identity,
font_size=42, color=TEXT_CONNECTED if adapter else TEXT_SECONDARY)
button_y = dialog_rect.y + 225
button_width = (dialog_rect.width - 135) / 3
self._read_button.render(rl.Rectangle(dialog_rect.x + 45, button_y, button_width, 90))
self._command_button.render(rl.Rectangle(dialog_rect.x + 60 + button_width, button_y, button_width, 90))
self._done_button.render(rl.Rectangle(dialog_rect.x + 75 + button_width * 2, button_y, button_width, 90))
if error:
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 330, dialog_rect.width - 90, 80), error,
font_size=38, color=rl.Color(255, 150, 150, 255))
if codes is not None:
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 410, 300, 45), tr("Stored Codes"),
font_size=38, font_weight=FontWeight.BOLD)
code_text = "\n".join(codes) if codes else tr("No stored codes reported.")
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 455, dialog_rect.width - 90, 95), code_text,
font_size=38, color=TEXT_SECONDARY)
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 560, 300, 45), tr("Response"),
font_size=38, font_weight=FontWeight.BOLD)
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 605, dialog_rect.width - 90, dialog_rect.height - 650),
response or "", font_size=36, color=TEXT_SECONDARY)
class BluetoothManagerUI(Widget): class BluetoothManagerUI(Widget):
"""Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon.""" """Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon."""
def __init__(self, manager: BluetoothManager): def __init__(self, manager: BluetoothManager):
@@ -404,8 +264,6 @@ class BluetoothManagerUI(Widget):
return return
if not device.paired: if not device.paired:
self._manager.pair(device.address) self._manager.pair(device.address)
elif device.serial:
gui_app.push_widget(ELM327Dialog(self._manager, device))
elif not device.connected: elif not device.connected:
self._manager.connect(device.address) self._manager.connect(device.address)
else: else:
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Curve Speed Controller field report: does it cut the lateral-accel tail, how
often does it engage, and how often do drivers reject it.
Usage:
./analyze_csc.py <route-or-segment> # e.g. a1b2c3d4e5f6g7h8|2026-08-14--10-30-00
./analyze_csc.py <rlog-path> [<rlog-path> ...]
./analyze_csc.py <route> --json report.json
"""
from __future__ import annotations
import argparse
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
DT = 0.05 # modelV2/starpilotPlan cadence
MS_TO_MPH = 2.23694
M_TO_MILES = 1.0 / 1609.34
HIGHWAY_SPEED = 60.0 / MS_TO_MPH # above this, engagement is the over-slowing regression risk
CURVE_LAT_ACCEL = 1.3 # MINIMUM_LATERAL_ACCELERATION
EPISODE_GAP_S = 1.0
V_CRUISE_UNSET = 255
@dataclass
class Frame:
t: float = 0.0
v_ego: float = 0.0
a_ego: float = 0.0
curvature: float = 0.0
gas: bool = False
brake: bool = False
accel_pressed: bool = False
long_active: bool = False
blinker: bool = False # CSC gating input: a blinker suspends it entirely
csc_active: bool = False
csc_overridden: bool = False
csc_training: bool = False
csc_speed: float = 0.0
v_cruise: float = 0.0 # applied cruise speed, already reduced by CSC
set_speed: float = 0.0 # what the driver dialled in, so cuts are measurable
learned_lat_accel: float = 0.0
binding_distance: float = 0.0
@property
def lat_accel(self) -> float:
return self.v_ego ** 2 * abs(self.curvature)
@dataclass
class Episode:
start: float
end: float
peak_cut: float = 0.0
peak_lat_accel: float = 0.0
min_a_ego: float = 0.0
entry_speed: float = 0.0
binding_distance: float = 0.0
cancelled: bool = False
gas: bool = False
brake: bool = False
@property
def duration(self) -> float:
return self.end - self.start
def read_events(identifier: str):
"""A downloaded rlog reads directly; anything else goes through LogReader."""
path = Path(identifier)
if path.is_file():
from cereal import log as capnp_log
data = path.read_bytes()
if data[:4] == b"\x28\xb5\x2f\xfd":
import zstandard
data = zstandard.ZstdDecompressor().decompress(data, max_output_size=2 << 30)
return capnp_log.Event.read_multiple_bytes(data)
from openpilot.tools.lib.logreader import LogReader, ReadMode # needs the device stack
return LogReader(identifier, default_mode=ReadMode.AUTO, sort_by_time=True)
def read_frames(identifier: str) -> list[Frame]:
"""Join carState/controlsState/starpilotPlan onto the plan's cadence."""
frames: list[Frame] = []
latest = Frame()
t0 = None
have_plan = False
for msg in read_events(identifier):
which = msg.which()
if which == "carState":
cs = msg.carState
latest.v_ego = float(cs.vEgo)
latest.a_ego = float(cs.aEgo)
latest.gas = bool(cs.gasPressed)
latest.brake = bool(cs.brakePressed)
latest.blinker = bool(cs.leftBlinker or cs.rightBlinker)
set_kph = float(cs.vCruise)
latest.set_speed = set_kph / 3.6 if 0 < set_kph < V_CRUISE_UNSET else 0.0
elif which == "carControl":
latest.long_active = bool(msg.carControl.longActive)
elif which == "controlsState":
latest.curvature = float(msg.controlsState.curvature)
elif which == "starpilotCarState":
latest.accel_pressed = bool(getattr(msg.starpilotCarState, "accelPressed", False))
elif which == "starpilotPlan":
plan = msg.starpilotPlan
have_plan = True
if t0 is None:
t0 = msg.logMonoTime / 1e9
latest.t = msg.logMonoTime / 1e9 - t0
latest.csc_active = bool(plan.cscControllingSpeed)
latest.csc_training = bool(plan.cscTraining)
latest.csc_speed = float(plan.cscSpeed)
latest.v_cruise = float(plan.vCruise)
# absent in older logs
latest.csc_overridden = bool(getattr(plan, "cscOverridden", False))
latest.learned_lat_accel = float(getattr(plan, "cscLearnedLatAccel", 0.0))
latest.binding_distance = float(getattr(plan, "cscBindingDistance", 0.0))
frames.append(Frame(**vars(latest)))
if not have_plan:
raise SystemExit(f"no starpilotPlan messages in {identifier} — is this a StarPilot route?")
return frames
def build_episodes(frames: list[Frame]) -> list[Episode]:
episodes: list[Episode] = []
current: Episode | None = None
last_active_t = -math.inf
for f in frames:
if f.csc_active:
if current is None or (f.t - last_active_t) > EPISODE_GAP_S:
current = Episode(start=f.t, end=f.t, entry_speed=f.v_ego,
binding_distance=f.binding_distance, min_a_ego=f.a_ego)
episodes.append(current)
current.end = f.t
if f.set_speed > 0:
current.peak_cut = max(current.peak_cut, f.set_speed - f.csc_speed)
current.peak_lat_accel = max(current.peak_lat_accel, f.lat_accel)
current.min_a_ego = min(current.min_a_ego, f.a_ego)
current.gas |= f.gas
current.brake |= f.brake
last_active_t = f.t
elif current is not None and (f.t - last_active_t) <= EPISODE_GAP_S:
# an override releases CSC on the same frame it registers, so the rejection
# always lands just past the end of the episode it rejected
current.cancelled |= f.csc_overridden or f.accel_pressed
current.gas |= f.gas
current.brake |= f.brake
return episodes
def curve_lat_accel_peaks(frames: list[Frame]) -> list[float]:
"""Peak lateral acceleration of each distinct curve, engaged driving only."""
peaks: list[float] = []
peak = 0.0
in_curve = False
for f in frames:
if not f.long_active:
continue
if f.lat_accel >= CURVE_LAT_ACCEL:
in_curve = True
peak = max(peak, f.lat_accel)
elif in_curve:
peaks.append(peak)
peak = 0.0
in_curve = False
if in_curve:
peaks.append(peak)
return peaks
def summarize(frames: list[Frame], episodes: list[Episode]) -> dict:
driving = [f for f in frames if f.v_ego > 5.0]
engaged = [f for f in driving if f.long_active]
active = [f for f in engaged if f.csc_active]
distance_mi = sum(f.v_ego * DT for f in driving) * M_TO_MILES
peaks = curve_lat_accel_peaks(frames)
highway = [e for e in episodes if e.entry_speed >= HIGHWAY_SPEED]
def pct(n, d):
return 100.0 * n / d if d else 0.0
return {
"route": {
"duration_min": len(frames) * DT / 60.0,
"distance_mi": distance_mi,
"engaged_pct": pct(len(engaged), len(driving)),
"mean_speed_mph": float(np.mean([f.v_ego for f in driving]) * MS_TO_MPH) if driving else 0.0,
},
"engagement": {
"active_pct_of_engaged": pct(len(active), len(engaged)),
"episodes": len(episodes),
"episodes_per_mile": len(episodes) / distance_mi if distance_mi > 0.1 else 0.0,
"median_duration_s": float(np.median([e.duration for e in episodes])) if episodes else 0.0,
"max_duration_s": max((e.duration for e in episodes), default=0.0),
"median_cut_mph": float(np.median([e.peak_cut for e in episodes]) * MS_TO_MPH) if episodes else 0.0,
"max_cut_mph": max((e.peak_cut for e in episodes), default=0.0) * MS_TO_MPH,
"median_anticipation_m": float(np.median([e.binding_distance for e in episodes])) if episodes else 0.0,
},
"outcome_lat_accel": {
"curves_seen": len(peaks),
"median": float(np.median(peaks)) if peaks else 0.0,
"p90": float(np.percentile(peaks, 90)) if peaks else 0.0,
"p99": float(np.percentile(peaks, 99)) if peaks else 0.0,
"max": max(peaks, default=0.0),
"over_3_0_pct": pct(sum(1 for p in peaks if p > 3.0), len(peaks)),
},
"acceptance": {
"cancelled_episodes": sum(1 for e in episodes if e.cancelled),
"cancel_rate_pct": pct(sum(1 for e in episodes if e.cancelled), len(episodes)),
"gas_during_episode_pct": pct(sum(1 for e in episodes if e.gas), len(episodes)),
"brake_during_episode_pct": pct(sum(1 for e in episodes if e.brake), len(episodes)),
},
"comfort": {
"median_min_a_ego": float(np.median([e.min_a_ego for e in episodes])) if episodes else 0.0,
"hardest_decel": min((e.min_a_ego for e in episodes), default=0.0),
},
"highway_watch": {
"episodes_above_60mph": len(highway),
"max_cut_mph": max((e.peak_cut for e in highway), default=0.0) * MS_TO_MPH,
},
"learning": {
"training_pct_of_driving": pct(sum(1 for f in driving if f.csc_training), len(driving)),
"learned_lat_accel_min": min((f.learned_lat_accel for f in active), default=0.0),
"learned_lat_accel_max": max((f.learned_lat_accel for f in active), default=0.0),
},
}
def print_report(name: str, s: dict) -> None:
r, e, o, a, c, h, l = (s["route"], s["engagement"], s["outcome_lat_accel"],
s["acceptance"], s["comfort"], s["highway_watch"], s["learning"])
print(f"\n=== {name}")
print(f" {r['duration_min']:.1f} min, {r['distance_mi']:.1f} mi, "
f"{r['mean_speed_mph']:.0f} mph avg, engaged {r['engaged_pct']:.0f}% of driving")
print("\n DOES IT WORK -- peak lateral accel per curve (engaged)")
print(f" {o['curves_seen']} curves median {o['median']:.2f} p90 {o['p90']:.2f} "
f"p99 {o['p99']:.2f} max {o['max']:.2f} m/s^2")
print(f" curves over 3.0 m/s^2: {o['over_3_0_pct']:.1f}% <-- this tail should shrink vs a CSC-off route")
print("\n DO USERS ACCEPT IT")
print(f" cancel rate (RES+) {a['cancel_rate_pct']:.0f}% gas {a['gas_during_episode_pct']:.0f}% "
f"brake {a['brake_during_episode_pct']:.0f}% of {e['episodes']} episodes")
print(" cancels/gas high => too slow; brake high => too fast")
print("\n ENGAGEMENT")
print(f" {e['active_pct_of_engaged']:.1f}% of engaged time, {e['episodes_per_mile']:.2f} episodes/mi, "
f"median {e['median_duration_s']:.1f}s (max {e['max_duration_s']:.1f}s)")
print(f" speed cut median {e['median_cut_mph']:.1f} mph, max {e['max_cut_mph']:.1f} mph")
print(f" braking begins {e['median_anticipation_m']:.0f} m ahead (median)")
print("\n COMFORT / REGRESSION WATCH")
print(f" decel median {c['median_min_a_ego']:.2f}, hardest {c['hardest_decel']:.2f} m/s^2")
print(f" highway (>60 mph) episodes: {h['episodes_above_60mph']}, max cut {h['max_cut_mph']:.1f} mph"
f" <-- over-slowing complaints start here")
print("\n LEARNING")
print(f" training {l['training_pct_of_driving']:.1f}% of driving; "
f"learned comfort in use {l['learned_lat_accel_min']:.2f}-{l['learned_lat_accel_max']:.2f} m/s^2")
def main() -> None:
parser = argparse.ArgumentParser(description="Curve Speed Controller field report.")
parser.add_argument("routes", nargs="+", help="route/segment identifier(s) or rlog path(s)")
parser.add_argument("--json", type=Path, help="also write the raw numbers here")
args = parser.parse_args()
reports = {}
for identifier in args.routes:
name = Path(identifier).name if Path(identifier).exists() else identifier
frames = read_frames(identifier)
episodes = build_episodes(frames)
summary = summarize(frames, episodes)
reports[name] = summary
print_report(name, summary)
if args.json:
args.json.write_text(json.dumps(reports, indent=2))
print(f"\nwrote {args.json}")
if __name__ == "__main__":
main()