mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-01 21:53:42 +08:00
60kW ?! k
This commit is contained in:
@@ -676,6 +676,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"SubaruSNG", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}},
|
||||
{"SubaruSNGManualParkingBrake", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
|
||||
{"SubaruStopStartOff", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
|
||||
{"SubaruAvhOnAtStartup", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
|
||||
{"TacoTune", {PERSISTENT, BOOL, "0", "0", 2}},
|
||||
{"TeslaCoopSteering", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}},
|
||||
{"TestAlert", {CLEAR_ON_MANAGER_START, STRING, "", ""}},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$DIR/scripts/model_release.py" "$@"
|
||||
@@ -128,6 +128,20 @@ def get_test_toggles() -> SimpleNamespace:
|
||||
|
||||
|
||||
class TestHyundaiFingerprint:
|
||||
def test_carnival_hev_low_speed_torque_rate_limits(self):
|
||||
CP = CarInterface.get_params(CAR.KIA_CARNIVAL_HEV_4TH_GEN, gen_empty_fingerprint(), [],
|
||||
False, False, False, None)
|
||||
carnival_2025_cp = CarInterface.get_params(CAR.KIA_CARNIVAL_2025, gen_empty_fingerprint(), [],
|
||||
False, False, False, None)
|
||||
|
||||
low_speed = CarControllerParams(CP, 10.0)
|
||||
high_speed = CarControllerParams(CP, 20.0)
|
||||
carnival_2025_low_speed = CarControllerParams(carnival_2025_cp, 10.0)
|
||||
|
||||
assert (low_speed.STEER_DELTA_UP, low_speed.STEER_DELTA_DOWN) == (2, 3)
|
||||
assert (high_speed.STEER_DELTA_UP, high_speed.STEER_DELTA_DOWN) == (2, 3)
|
||||
assert (carnival_2025_low_speed.STEER_DELTA_UP, carnival_2025_low_speed.STEER_DELTA_DOWN) == (10, 8)
|
||||
|
||||
@pytest.mark.parametrize("candidate", (CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN))
|
||||
def test_carnival_uses_clean_canfd_lfa_status(self, candidate):
|
||||
assert not preserve_stock_canfd_lfa_status(candidate)
|
||||
|
||||
@@ -45,8 +45,12 @@ class CarControllerParams:
|
||||
self.STEER_DRIVER_MULTIPLIER = 2
|
||||
self.STEER_THRESHOLD = 100
|
||||
if vEgoRaw < 15.0: # below ~34 mph - more aggressive for tight turns
|
||||
self.STEER_DELTA_UP = 10
|
||||
self.STEER_DELTA_DOWN = 8
|
||||
if CP.carFingerprint == CAR.KIA_CARNIVAL_HEV_4TH_GEN:
|
||||
self.STEER_DELTA_UP = 2
|
||||
self.STEER_DELTA_DOWN = 3
|
||||
else:
|
||||
self.STEER_DELTA_UP = 10
|
||||
self.STEER_DELTA_DOWN = 8
|
||||
else:
|
||||
self.STEER_DELTA_UP = 2
|
||||
self.STEER_DELTA_DOWN = 3
|
||||
|
||||
@@ -4,7 +4,7 @@ from opendbc.car import Bus, DT_CTRL, make_tester_present_msg, structs
|
||||
from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_std_steer_angle_limits, apply_steer_angle_limits_vm, common_fault_avoidance
|
||||
from opendbc.car.interfaces import CarControllerBase
|
||||
from opendbc.car.subaru import subarucan
|
||||
from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags
|
||||
from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags
|
||||
from opendbc.car.vehicle_model import VehicleModel
|
||||
|
||||
# FIXME: These limits aren't exact. The real limit is more than likely over a larger time period and
|
||||
@@ -37,6 +37,10 @@ _STOP_START_STARTUP_DELAY_FRAMES = 100
|
||||
_STOP_START_STARTUP_DEADLINE_FRAMES = 1000
|
||||
_STOP_START_PULSE_FRAMES = 30
|
||||
_STOP_START_PULSE_PERIOD_FRAMES = 5
|
||||
_AVH_STARTUP_DELAY_FRAMES = _STOP_START_STARTUP_DELAY_FRAMES
|
||||
_AVH_STARTUP_DEADLINE_FRAMES = _STOP_START_STARTUP_DEADLINE_FRAMES
|
||||
_AVH_PULSE_FRAMES = _STOP_START_PULSE_FRAMES
|
||||
_AVH_PULSE_PERIOD_FRAMES = _STOP_START_PULSE_PERIOD_FRAMES
|
||||
|
||||
|
||||
def get_safety_CP():
|
||||
@@ -87,6 +91,10 @@ class CarController(CarControllerBase):
|
||||
self.stop_start_initial_state = None
|
||||
self.stop_start_counter = 0
|
||||
self.stop_start_acknowledged = False
|
||||
self.avh_attempted = False
|
||||
self.avh_request_started = False
|
||||
self.avh_request_frame = 0
|
||||
self.avh_counter = 0
|
||||
|
||||
def _stop_start_off_request(self, CC, CS, starpilot_toggles):
|
||||
"""Send one bounded Subaru Stop/Start OFF request after ignition.
|
||||
@@ -143,6 +151,55 @@ class CarController(CarControllerBase):
|
||||
self.stop_start_counter = (self.stop_start_counter + 1) % 0x10
|
||||
return msg
|
||||
|
||||
def _avh_on_request(self, CC, CS, starpilot_toggles):
|
||||
"""Send one bounded Subaru AVH ON request after ignition.
|
||||
|
||||
The AVH button frame was identified on the 2025 Legacy only. Keep this
|
||||
independent from Stop/Start so the existing Outback request is unchanged.
|
||||
"""
|
||||
if self.CP.carFingerprint not in SUBARU_AVH_CARS or \
|
||||
not getattr(starpilot_toggles, "subaru_avh_on", False) or self.avh_attempted:
|
||||
return None
|
||||
|
||||
if self.frame > _AVH_STARTUP_DEADLINE_FRAMES or getattr(CC, "enabled", False):
|
||||
self.avh_attempted = True
|
||||
return None
|
||||
|
||||
if self.frame < _AVH_STARTUP_DELAY_FRAMES or not getattr(getattr(CS, "out", None), "canValid", True):
|
||||
return None
|
||||
|
||||
out = CS.out
|
||||
if not getattr(out, "standstill", False) or out.gearShifter not in (
|
||||
structs.CarState.GearShifter.park,
|
||||
structs.CarState.GearShifter.neutral,
|
||||
):
|
||||
return None
|
||||
|
||||
avh_msg = getattr(CS, "avh_msg", None)
|
||||
avh_dat = getattr(CS, "avh_dat", None)
|
||||
if not avh_msg or not avh_dat:
|
||||
return None
|
||||
|
||||
if not self.avh_request_started:
|
||||
self.avh_request_started = True
|
||||
self.avh_request_frame = self.frame
|
||||
self.avh_counter = (int(avh_msg.get("COUNTER", 0)) + 1) % 0x10
|
||||
|
||||
elapsed = self.frame - self.avh_request_frame
|
||||
if elapsed >= _AVH_PULSE_FRAMES:
|
||||
self.avh_attempted = True
|
||||
return None
|
||||
|
||||
if elapsed % _AVH_PULSE_PERIOD_FRAMES != 0:
|
||||
return None
|
||||
|
||||
msg = subarucan.create_avh_control(
|
||||
self.packer, avh_msg, raw_dat=avh_dat,
|
||||
counter=self.avh_counter, bus=CanBus.alt_for_cp(self.CP),
|
||||
)
|
||||
self.avh_counter = (self.avh_counter + 1) % 0x10
|
||||
return msg
|
||||
|
||||
def _reset_legacy_2025_handoff(self):
|
||||
self.driver_override = False
|
||||
self.angle_override_confirm_frames = 0
|
||||
@@ -416,6 +473,10 @@ class CarController(CarControllerBase):
|
||||
if stop_start_msg is not None:
|
||||
can_sends.append(stop_start_msg)
|
||||
|
||||
avh_msg = self._avh_on_request(CC, CS, starpilot_toggles)
|
||||
if avh_msg is not None:
|
||||
can_sends.append(avh_msg)
|
||||
|
||||
# *** steering ***
|
||||
if (self.frame % self.p.STEER_STEP) == 0:
|
||||
if self.CP.flags & SubaruFlags.LKAS_ANGLE:
|
||||
|
||||
@@ -4,7 +4,7 @@ from opendbc.can import CANDefine, CANParser
|
||||
from opendbc.car import Bus, structs
|
||||
from opendbc.car.common.conversions import Conversions as CV
|
||||
from opendbc.car.interfaces import CarStateBase
|
||||
from opendbc.car.subaru.values import DBC, CanBus, SUBARU_STOP_START_CARS, SubaruFlags
|
||||
from opendbc.car.subaru.values import DBC, CanBus, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags
|
||||
from opendbc.car import CanSignalRateCalculator
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ class CarState(CarStateBase):
|
||||
self.dashlights_msg = {}
|
||||
self.dashlights_dat = b""
|
||||
self.stop_start_state = 0
|
||||
self.avh_msg = {}
|
||||
self.avh_dat = b""
|
||||
|
||||
def update(self, can_parsers, starpilot_toggles) -> structs.CarState:
|
||||
cp = can_parsers[Bus.pt]
|
||||
@@ -33,6 +35,11 @@ class CarState(CarStateBase):
|
||||
self.dashlights_dat = stop_start_cp.vl_raw["Dashlights"]
|
||||
self.stop_start_state = stop_start_cp.vl["Engine_Stop_Start"]["STOP_START_STATE"]
|
||||
|
||||
if self.CP.carFingerprint in SUBARU_AVH_CARS:
|
||||
avh_cp = cp_alt if self.CP.flags & SubaruFlags.GLOBAL_GEN2 else cp
|
||||
self.avh_msg = copy.copy(avh_cp.vl["AVH"])
|
||||
self.avh_dat = avh_cp.vl_raw["AVH"]
|
||||
|
||||
throttle_msg = cp.vl["Throttle"] if not (self.CP.flags & SubaruFlags.HYBRID) else cp_alt.vl["Throttle_Hybrid"]
|
||||
ret.gasPressed = throttle_msg["Throttle_Pedal"] > 1e-5
|
||||
if self.CP.flags & SubaruFlags.PREGLOBAL:
|
||||
@@ -156,10 +163,11 @@ class CarState(CarStateBase):
|
||||
|
||||
@staticmethod
|
||||
def get_can_parsers(CP):
|
||||
avh_messages = [("AVH", 0)] if CP.carFingerprint in SUBARU_AVH_CARS else []
|
||||
parsers = {
|
||||
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main_for_cp(CP)),
|
||||
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.camera),
|
||||
Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.alt_for_cp(CP))
|
||||
Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], avh_messages, CanBus.alt_for_cp(CP))
|
||||
}
|
||||
if CP.flags & SubaruFlags.D_PLATFORM:
|
||||
parsers[Bus.main] = CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main)
|
||||
|
||||
@@ -3,7 +3,7 @@ from opendbc.car.disable_ecu import disable_ecu
|
||||
from opendbc.car.interfaces import CarInterfaceBase
|
||||
from opendbc.car.subaru.carcontroller import CarController
|
||||
from opendbc.car.subaru.carstate import CarState
|
||||
from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags
|
||||
from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags
|
||||
|
||||
|
||||
class CarInterface(CarInterfaceBase):
|
||||
@@ -42,6 +42,8 @@ class CarInterface(CarInterfaceBase):
|
||||
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value
|
||||
if candidate in SUBARU_STOP_START_CARS:
|
||||
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value
|
||||
if candidate in SUBARU_AVH_CARS:
|
||||
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.AVH_BUTTON.value
|
||||
if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023):
|
||||
ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value
|
||||
|
||||
|
||||
@@ -208,6 +208,31 @@ def create_stop_start_control(packer, dashlights_msg, raw_dat=None, counter=None
|
||||
return packer.make_can_msg("Dashlights", bus, values)
|
||||
|
||||
|
||||
def create_avh_control(packer, avh_msg, raw_dat=None, counter=None, bus=CanBus.alt):
|
||||
"""Create the supported Subaru Legacy AVH ON request.
|
||||
|
||||
AVH is carried in the live 0x32b frame. Preserve the other bytes and update
|
||||
only the rolling counter, AVH bit, and Subaru additive checksum.
|
||||
"""
|
||||
if raw_dat:
|
||||
dat = bytearray(raw_dat)
|
||||
if len(dat) != 8:
|
||||
raise ValueError(f"AVH frame must be 8 bytes, got {len(dat)}")
|
||||
if counter is None:
|
||||
counter = (int(avh_msg.get("COUNTER", 0)) + 1) % 0x10
|
||||
dat[1] = (dat[1] & 0xF0) | (counter % 0x10)
|
||||
dat[5] |= 0x20 # AVH, big-endian bit 45
|
||||
dat[0] = ((0x32B & 0xFF) + ((0x32B >> 8) & 0xFF) + sum(dat[1:])) & 0xFF
|
||||
return 0x32B, bytes(dat), bus
|
||||
|
||||
values = dict(avh_msg)
|
||||
if counter is None:
|
||||
counter = (int(values.get("COUNTER", 0)) + 1) % 0x10
|
||||
values["COUNTER"] = counter % 0x10
|
||||
values["AVH"] = 1
|
||||
return packer.make_can_msg("AVH", bus, values)
|
||||
|
||||
|
||||
def create_es_brake(packer, frame, es_brake_msg, long_enabled, long_active, brake_value, bus=CanBus.main):
|
||||
values = {s: es_brake_msg[s] for s in [
|
||||
"CHECKSUM",
|
||||
|
||||
@@ -194,6 +194,7 @@ def test_outback_2023_uses_d_platform_bus_layout():
|
||||
assert CP.flags & SubaruFlags.D_PLATFORM
|
||||
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM
|
||||
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON
|
||||
assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.AVH_BUTTON)
|
||||
assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS)
|
||||
assert CanBus.main_for_cp(CP) == CanBus.alt
|
||||
assert CanBus.angle_for_cp(CP) == CanBus.main
|
||||
@@ -224,6 +225,21 @@ def test_stop_start_inputs_are_captured_for_supported_models(platform):
|
||||
assert car_state.stop_start_state == 3
|
||||
|
||||
|
||||
def test_avh_inputs_are_captured_for_legacy_2025():
|
||||
CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025)
|
||||
car_state = CarState(CP, None)
|
||||
parsers = car_state.get_can_parsers(CP)
|
||||
raw_avh = bytes.fromhex("230f1c4208800000")
|
||||
parsers[Bus.alt].vl["AVH"]["COUNTER"] = 15
|
||||
parsers[Bus.alt].vl["AVH"]["AVH"] = 0
|
||||
parsers[Bus.alt].vl_raw["AVH"] = raw_avh
|
||||
|
||||
car_state.update(parsers, SimpleNamespace(subaru_sng=False))
|
||||
|
||||
assert car_state.avh_msg["COUNTER"] == 15
|
||||
assert car_state.avh_dat == raw_avh
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform, expected_bus, start_frame", [
|
||||
(CAR.SUBARU_OUTBACK_2023, CanBus.alt, 101),
|
||||
(CAR.SUBARU_LEGACY_2025, CanBus.alt, 401),
|
||||
@@ -276,6 +292,51 @@ def test_stop_start_request_is_bounded_and_uses_live_dashlights(platform, expect
|
||||
assert controller.stop_start_acknowledged
|
||||
|
||||
|
||||
def test_avh_request_sets_observed_bit_and_is_bounded():
|
||||
CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025)
|
||||
controller = CarController({}, CP)
|
||||
controller.frame = 101
|
||||
|
||||
class TestActuators:
|
||||
steeringAngleDeg = 0.0
|
||||
|
||||
def as_builder(self):
|
||||
return SimpleNamespace(steeringAngleDeg=self.steeringAngleDeg)
|
||||
|
||||
CC = SimpleNamespace(
|
||||
enabled=False,
|
||||
latActive=False,
|
||||
longActive=False,
|
||||
actuators=TestActuators(),
|
||||
hudControl=SimpleNamespace(leadVisible=False),
|
||||
cruiseControl=SimpleNamespace(cancel=False),
|
||||
)
|
||||
CS = SimpleNamespace(
|
||||
canValid=True,
|
||||
avh_msg={"COUNTER": 15, "AVH": 0},
|
||||
avh_dat=bytes.fromhex("230f1c4208800000"),
|
||||
out=SimpleNamespace(
|
||||
standstill=True,
|
||||
gearShifter=structs.CarState.GearShifter.park,
|
||||
),
|
||||
)
|
||||
toggles = SimpleNamespace(subaru_stop_start_off=False, subaru_avh_on=True, subaru_sng=False)
|
||||
|
||||
_, can_sends = controller.update(CC, CS, 0, toggles)
|
||||
avh_msgs = [msg for msg in can_sends if msg[0] == 0x32b]
|
||||
assert avh_msgs == [(0x32b, bytes.fromhex("34001c4208a00000"), CanBus.alt)]
|
||||
|
||||
parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("AVH", 0)], CanBus.alt)
|
||||
parser.update([(CanBus.alt, avh_msgs)])
|
||||
assert parser.vl["AVH"]["AVH"] == 1
|
||||
assert parser.vl["AVH"]["COUNTER"] == 0
|
||||
|
||||
controller.frame = 131
|
||||
_, can_sends = controller.update(CC, CS, 0, toggles)
|
||||
assert not any(msg[0] == 0x32b for msg in can_sends)
|
||||
assert controller.avh_attempted
|
||||
|
||||
|
||||
def test_legacy_2025_uses_gen2_angle_bus_layout():
|
||||
CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025)
|
||||
parsers = CarState.get_can_parsers(CP)
|
||||
@@ -287,6 +348,7 @@ def test_legacy_2025_uses_gen2_angle_bus_layout():
|
||||
assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM_CAMERA)
|
||||
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS
|
||||
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON
|
||||
assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.AVH_BUTTON
|
||||
assert CanBus.main_for_cp(CP) == CanBus.main
|
||||
assert CanBus.angle_for_cp(CP) == CanBus.main
|
||||
assert parsers[Bus.pt].bus == CanBus.main
|
||||
|
||||
@@ -89,6 +89,7 @@ class SubaruSafetyFlags(IntFlag):
|
||||
D_PLATFORM_CAMERA = 64
|
||||
FIXED_ANGLE_LIMITS = 128
|
||||
STOP_START_BUTTON = 256
|
||||
AVH_BUTTON = 512
|
||||
LEGACY_2025_ANGLE_LIMITS = FIXED_ANGLE_LIMITS
|
||||
|
||||
|
||||
@@ -275,6 +276,10 @@ SUBARU_STOP_START_CARS = (
|
||||
CAR.SUBARU_LEGACY_2025,
|
||||
)
|
||||
|
||||
SUBARU_AVH_CARS = (
|
||||
CAR.SUBARU_LEGACY_2025,
|
||||
)
|
||||
|
||||
|
||||
SUBARU_VERSION_REQUEST = bytes([uds.SERVICE_TYPE.READ_DATA_BY_IDENTIFIER]) + \
|
||||
p16(uds.DATA_IDENTIFIER_TYPE.APPLICATION_DATA_IDENTIFICATION)
|
||||
|
||||
@@ -10,6 +10,8 @@ TransmissionType = structs.CarParams.TransmissionType
|
||||
# main-bus SPEED (0x60) is raw counts in the DBC; measured against GPS ground speed.
|
||||
# Must match VOLVO_SPEED_TO_MS in opendbc/safety/modes/volvo.h.
|
||||
SPEED_TO_MS = 0.003977
|
||||
STEERING_PRESSED_THRESHOLD = 2
|
||||
STEERING_DISENGAGE_THRESHOLD = 5
|
||||
|
||||
|
||||
class CarState(CarStateBase):
|
||||
@@ -75,7 +77,9 @@ class CarState(CarStateBase):
|
||||
|
||||
# Driver steering torque feedback (used for driver override detection)
|
||||
ret.steeringTorque = -cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT'] # Car right turn is negative, openpilot right turn is positive
|
||||
ret.steeringPressed = abs(cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT']) > 2
|
||||
driver_input = abs(cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT'])
|
||||
ret.steeringPressed = driver_input > STEERING_PRESSED_THRESHOLD
|
||||
ret.steeringDisengage = driver_input > STEERING_DISENGAGE_THRESHOLD
|
||||
|
||||
# EPS status - placeholder until actual signal is found
|
||||
self.eps_active = True # Assume EPS is active for now
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
CM_ "IMPORT _subaru_global.dbc";
|
||||
|
||||
BO_ 811 AVH: 8 XXX
|
||||
SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
|
||||
SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
|
||||
SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX
|
||||
|
||||
BO_ 72 Transmission: 8 XXX
|
||||
SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
|
||||
SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
|
||||
|
||||
@@ -307,6 +307,11 @@ VAL_ 544 AEB_Status 12 "AEB related" 8 "AEB actuation" 4 "AEB related" 0 "No AEB
|
||||
|
||||
CM_ "subaru_global_2017.dbc starts here";
|
||||
|
||||
BO_ 811 AVH: 8 XXX
|
||||
SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
|
||||
SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
|
||||
SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX
|
||||
|
||||
BO_ 72 Transmission: 8 XXX
|
||||
SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX
|
||||
SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
#define MSG_SUBARU_ES_STATIC_1 0x22aU
|
||||
#define MSG_SUBARU_ES_STATIC_2 0x325U
|
||||
#define MSG_SUBARU_Dashlights 0x390U
|
||||
#define MSG_SUBARU_AVH 0x32bU
|
||||
|
||||
#define SUBARU_MAIN_BUS 0U
|
||||
#define SUBARU_ALT_BUS 1U
|
||||
@@ -65,6 +66,13 @@
|
||||
#define SUBARU_STOP_START_TX_MSGS(bus) \
|
||||
{MSG_SUBARU_Dashlights, bus, 8, .check_relay = false}, \
|
||||
|
||||
#define SUBARU_AVH_TX_MSGS(bus) \
|
||||
{MSG_SUBARU_AVH, bus, 8, .check_relay = false}, \
|
||||
|
||||
#define SUBARU_STOP_START_AVH_TX_MSGS(bus) \
|
||||
SUBARU_STOP_START_TX_MSGS(bus) \
|
||||
SUBARU_AVH_TX_MSGS(bus)
|
||||
|
||||
#define SUBARU_COMMON_LONG_TX_MSGS(alt_bus) \
|
||||
{MSG_SUBARU_ES_Distance, alt_bus, 8, .check_relay = true}, \
|
||||
{MSG_SUBARU_ES_Brake, alt_bus, 8, .check_relay = true}, \
|
||||
@@ -113,6 +121,7 @@ static bool subaru_lkas_angle = false;
|
||||
static bool subaru_d_platform = false;
|
||||
static bool subaru_fixed_angle_limits = false;
|
||||
static bool subaru_stop_start_button = false;
|
||||
static bool subaru_avh_button = false;
|
||||
|
||||
static uint32_t subaru_get_checksum(const CANPacket_t *msg) {
|
||||
return (uint8_t)msg->data[0];
|
||||
@@ -297,6 +306,13 @@ static bool subaru_tx_hook(const CANPacket_t *msg) {
|
||||
violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg);
|
||||
}
|
||||
|
||||
if (msg->addr == MSG_SUBARU_AVH) {
|
||||
violation |= !subaru_avh_button;
|
||||
violation |= msg->bus != (subaru_gen2 ? SUBARU_ALT_BUS : SUBARU_MAIN_BUS);
|
||||
violation |= !GET_BIT(msg, 45U);
|
||||
violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg);
|
||||
}
|
||||
|
||||
if (violation){
|
||||
tx = false;
|
||||
}
|
||||
@@ -347,6 +363,12 @@ static safety_config subaru_init(uint16_t param) {
|
||||
SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS)
|
||||
};
|
||||
|
||||
static const CanMsg SUBARU_GEN2_LKAS_ANGLE_STOP_START_AVH_TX_MSGS[] = {
|
||||
SUBARU_BASE_TX_MSGS(SUBARU_ALT_BUS, MSG_SUBARU_ES_LKAS_ANGLE)
|
||||
SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
|
||||
SUBARU_STOP_START_AVH_TX_MSGS(SUBARU_ALT_BUS)
|
||||
};
|
||||
|
||||
static const CanMsg SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS[] = {
|
||||
SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS)
|
||||
SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
|
||||
@@ -358,6 +380,12 @@ static safety_config subaru_init(uint16_t param) {
|
||||
SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS)
|
||||
};
|
||||
|
||||
static const CanMsg SUBARU_D_PLATFORM_ANGLE_STOP_START_AVH_MAIN_TX_MSGS[] = {
|
||||
SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS)
|
||||
SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
|
||||
SUBARU_STOP_START_AVH_TX_MSGS(SUBARU_ALT_BUS)
|
||||
};
|
||||
|
||||
static const CanMsg SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS[] = {
|
||||
SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_CAM_BUS)
|
||||
SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS)
|
||||
@@ -405,6 +433,9 @@ static safety_config subaru_init(uint16_t param) {
|
||||
const uint16_t SUBARU_PARAM_STOP_START_BUTTON = 256;
|
||||
subaru_stop_start_button = GET_FLAG(param, SUBARU_PARAM_STOP_START_BUTTON);
|
||||
|
||||
const uint16_t SUBARU_PARAM_AVH_BUTTON = 512;
|
||||
subaru_avh_button = GET_FLAG(param, SUBARU_PARAM_AVH_BUTTON);
|
||||
|
||||
#ifdef ALLOW_DEBUG
|
||||
const uint16_t SUBARU_PARAM_LONGITUDINAL = 2;
|
||||
subaru_longitudinal = GET_FLAG(param, SUBARU_PARAM_LONGITUDINAL);
|
||||
@@ -412,10 +443,12 @@ static safety_config subaru_init(uint16_t param) {
|
||||
|
||||
safety_config ret;
|
||||
if (subaru_lkas_angle) {
|
||||
ret = subaru_d_platform ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS) : \
|
||||
ret = subaru_d_platform ? (subaru_stop_start_button ? (subaru_avh_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_AVH_MAIN_TX_MSGS) : \
|
||||
BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS)) : \
|
||||
(subaru_d_platform_camera ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS) : \
|
||||
BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS))) : \
|
||||
subaru_gen2 ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_TX_MSGS) : \
|
||||
subaru_gen2 ? (subaru_stop_start_button ? (subaru_avh_button ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_AVH_TX_MSGS) : \
|
||||
BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_TX_MSGS)) : \
|
||||
BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_TX_MSGS)) : \
|
||||
BUILD_SAFETY_CFG(subaru_lkas_angle_rx_checks, SUBARU_LKAS_ANGLE_TX_MSGS);
|
||||
} else if (subaru_gen2) {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
#define VOLVO_ANGLE_DEG_TO_CAN 17.869907f
|
||||
#define VOLVO_MAX_ANGLE_CAN 9650
|
||||
#define VOLVO_RELAY_ANGLE_TOLERANCE 54 // approximately 3 degrees
|
||||
#define VOLVO_DRIVER_OVERRIDE 2
|
||||
#define VOLVO_DRIVER_OVERRIDE 5
|
||||
|
||||
|
||||
// CAN bus definitions for Volvo
|
||||
|
||||
@@ -37,6 +37,7 @@ class SubaruMsg(enum.IntEnum):
|
||||
ES_STATIC_1 = 0x22a
|
||||
ES_STATIC_2 = 0x325
|
||||
Dashlights = 0x390
|
||||
AVH = 0x32b
|
||||
|
||||
|
||||
SUBARU_MAIN_BUS = 0
|
||||
@@ -385,6 +386,20 @@ class TestSubaruGen2FixedAngleStopStartSafety(TestSubaruGen2FixedAngleSafety):
|
||||
self.assertFalse(self._tx(self._stop_start_msg(False)))
|
||||
|
||||
|
||||
class TestSubaruGen2FixedAngleStopStartAvhSafety(TestSubaruGen2FixedAngleStopStartSafety):
|
||||
FLAGS = TestSubaruGen2FixedAngleStopStartSafety.FLAGS | SubaruSafetyFlags.AVH_BUTTON
|
||||
TX_MSGS = TestSubaruGen2FixedAngleStopStartSafety.TX_MSGS + [[SubaruMsg.AVH, SUBARU_ALT_BUS]]
|
||||
|
||||
def _avh_msg(self, pressed):
|
||||
return self.packer.make_can_msg_safety(
|
||||
"AVH", SUBARU_ALT_BUS, {"COUNTER": 0, "AVH": pressed},
|
||||
)
|
||||
|
||||
def test_avh_tx_requires_pressed_bit(self):
|
||||
self.assertTrue(self._tx(self._avh_msg(True)))
|
||||
self.assertFalse(self._tx(self._avh_msg(False)))
|
||||
|
||||
|
||||
class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, TestSubaruAngleSafetyBase):
|
||||
FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM
|
||||
ALT_MAIN_BUS = SUBARU_ALT_BUS
|
||||
|
||||
@@ -201,10 +201,19 @@ class TestVolvoSafetyBase(common.CarSafetyTest):
|
||||
self.assertFalse(self._tx(invalid))
|
||||
|
||||
def test_driver_override_disengages_controls(self):
|
||||
def driver_input_msg(value):
|
||||
return self.mid_packer.make_can_msg_safety(
|
||||
"DRIVER_INPUT", VOLVO_PARTY_BUS, {"STEERING_DRIVER_INPUT": value})
|
||||
|
||||
for value in (2, 3, 5):
|
||||
self._rx(driver_input_msg(0))
|
||||
self.safety.set_controls_allowed(True)
|
||||
self._rx(driver_input_msg(value))
|
||||
self.assertTrue(self.safety.get_controls_allowed(), f"unexpected disengage at {value=}")
|
||||
|
||||
self._rx(driver_input_msg(0))
|
||||
self.safety.set_controls_allowed(True)
|
||||
msg = self.mid_packer.make_can_msg_safety(
|
||||
"DRIVER_INPUT", VOLVO_PARTY_BUS, {"STEERING_DRIVER_INPUT": 6})
|
||||
self._rx(msg)
|
||||
self._rx(driver_input_msg(6))
|
||||
self.assertFalse(self.safety.get_controls_allowed())
|
||||
|
||||
# ---- Volvo-specific consistency tests ----
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Model Release Tool
|
||||
|
||||
`./scripts/model_release.py` automates the single-supercombo release path:
|
||||
|
||||
1. Paste the model bot message.
|
||||
2. The tool parses the branch, ONNX path, model name, release date, model ID, and commit SHAs.
|
||||
3. It scans every listed upstream commit for `tinygrad`, `modeld`, or Chestnut runtime changes.
|
||||
4. It downloads the Git LFS object without loading it into shell variables.
|
||||
5. It asks for the comma IP, transfers the ONNX, and runs the device compiler.
|
||||
6. It verifies the returned PKL or multipart checksum.
|
||||
7. It uploads `models/<id>/`, `onnx/<id>/`, and `manifests/` to Hugging Face.
|
||||
8. It pushes GitHub one artifact part per commit and the manifest last.
|
||||
|
||||
Run interactively:
|
||||
|
||||
```sh
|
||||
./scripts/model_release.py
|
||||
```
|
||||
|
||||
Paste the complete bot message, press `Ctrl-D`, then enter the comma IP. The
|
||||
default runtime behavior version is `v16`; the `Model vN` line is treated as
|
||||
the model iteration/name, not the runtime behavior version. Override an
|
||||
ambiguous ID with `--model-id`, or the runtime contract with
|
||||
`--behavior-version v15`.
|
||||
|
||||
For the normal model bot flow, a commit SHA alone is also accepted:
|
||||
|
||||
```sh
|
||||
./modelgrab f877d7a0ccc3cce943c76e285214c020cd65c899
|
||||
```
|
||||
|
||||
The tool resolves the commit's changed driving ONNX, associated pull-request
|
||||
branch/title, GPU status, and commit date through GitHub. If GitHub cannot
|
||||
associate a human model title with the SHA, it derives the name from the ONNX
|
||||
filename; use `--model-id` when an exact local naming convention is required.
|
||||
|
||||
Useful non-interactive form:
|
||||
|
||||
```sh
|
||||
./scripts/model_release.py \
|
||||
--text-file release-message.txt \
|
||||
--ip 192.168.3.110
|
||||
```
|
||||
|
||||
The tool refuses `192.168.3.109`. It also stops before downloading or compiling
|
||||
if any supplied commit changes runtime code. Review the warning first, then
|
||||
rerun with `--allow-runtime-changes` only after Firestar approves it.
|
||||
|
||||
The default resources checkout is `~/StarPilot-Resources` on branch `Models`.
|
||||
It must be clean and synchronized before starting. The default Hugging Face
|
||||
bucket is `firestar4430/StarPilot-Resources`; authenticate with `hf auth login`
|
||||
before use. Use `--dry-run` to test parsing and the runtime scan without
|
||||
touching the device or either resource store.
|
||||
|
||||
Failed runs leave the source, compiled parts, logs, and result JSON in the
|
||||
workspace so the cause can be inspected. A rerun requires `--force` when the
|
||||
same source path already exists.
|
||||
Executable
+786
@@ -0,0 +1,786 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Release one upstream model through the device compiler and resource stores.
|
||||
|
||||
The command is intentionally fail-closed when the supplied upstream commits
|
||||
touch tinygrad or modeld runtime code. A model source update is safe to build
|
||||
only after that runtime change has been reviewed separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OPENPILOT_REPO = "commaai/openpilot"
|
||||
RESOURCES_REPO = os.environ.get("STARPILOT_RESOURCES_REPO", "firestar5683/StarPilot-Resources")
|
||||
HF_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "firestar4430/StarPilot-Resources")
|
||||
RESOURCE_BRANCH = "Models"
|
||||
MANIFEST_VERSION = "v24"
|
||||
DEFAULT_BEHAVIOR_VERSION = "v16"
|
||||
DEVICE_ROOT = "/data/openpilot"
|
||||
REPOSITORY_FILE_LIMIT = 100_000_000
|
||||
LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
||||
CHUNK_SUFFIX_RE = re.compile(r"\.p\d{2}$")
|
||||
SHA_RE = re.compile(r"(?<![0-9a-f])([0-9a-f]{40})(?![0-9a-f])", re.IGNORECASE)
|
||||
DATE_RE = re.compile(r"([A-Za-z]+\s+\d{1,2},\s+\d{4})")
|
||||
MODEL_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||
|
||||
RUNTIME_PATH_PREFIXES = (
|
||||
"tinygrad/",
|
||||
"tinygrad_repo/",
|
||||
"openpilot/tinygrad/",
|
||||
"openpilot/tinygrad_repo/",
|
||||
"selfdrive/modeld/",
|
||||
"openpilot/selfdrive/modeld/",
|
||||
"system/hardware/chestnut/",
|
||||
"openpilot/system/hardware/chestnut/",
|
||||
)
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
model_id: str
|
||||
display_name: str
|
||||
release_date: str
|
||||
branch: str
|
||||
source_ref: str
|
||||
source_path: str
|
||||
input_format: str
|
||||
behavior_version: str
|
||||
uses_external_gpu: bool
|
||||
commits: list[str]
|
||||
model_iteration: str
|
||||
|
||||
|
||||
def default_workspace() -> Path:
|
||||
t5 = Path("/Volumes/T5")
|
||||
if t5.is_dir():
|
||||
return t5 / "StarPilot-Model-Releases"
|
||||
return Path.home() / "Desktop" / "StarPilot-Model-Releases"
|
||||
|
||||
|
||||
def run(command: list[str], *, cwd: Path | None = None, capture: bool = False) -> subprocess.CompletedProcess:
|
||||
print("$ " + " ".join(shlex.quote(part) for part in command))
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
text=capture,
|
||||
capture_output=capture,
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def http_request(url: str, *, method: str = "GET", payload: bytes | None = None, headers: dict[str, str] | None = None):
|
||||
request_headers = {"User-Agent": "StarPilot-model-release/1.0", **(headers or {})}
|
||||
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
if token and "api.github.com" in url:
|
||||
request_headers["Authorization"] = f"Bearer {token}"
|
||||
request = urllib.request.Request(url, data=payload, headers=request_headers, method=method)
|
||||
try:
|
||||
return urllib.request.urlopen(request, timeout=30)
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read(500).decode("utf-8", errors="replace")
|
||||
raise ReleaseError(f"HTTP {error.code} from {url}: {detail}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise ReleaseError(f"Unable to reach {url}: {error.reason}") from error
|
||||
|
||||
|
||||
def get_json_value(url: str) -> Any:
|
||||
with http_request(url, headers={"Accept": "application/vnd.github+json"}) as response:
|
||||
try:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ReleaseError(f"Invalid JSON response from {url}") from error
|
||||
|
||||
|
||||
def get_json(url: str) -> dict:
|
||||
payload = get_json_value(url)
|
||||
if not isinstance(payload, dict):
|
||||
raise ReleaseError(f"Unexpected JSON response from {url}")
|
||||
return payload
|
||||
|
||||
|
||||
def parse_lfs_pointer(data: bytes) -> tuple[str, int] | None:
|
||||
if not data.startswith(LFS_POINTER_PREFIX):
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for line in data.decode("ascii", errors="strict").splitlines():
|
||||
if " " in line:
|
||||
key, value = line.split(" ", 1)
|
||||
fields[key] = value
|
||||
oid = fields.get("oid", "").removeprefix("sha256:")
|
||||
size = fields.get("size", "")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", oid) or not size.isdigit():
|
||||
raise ReleaseError("Malformed Git LFS pointer")
|
||||
return oid, int(size)
|
||||
|
||||
|
||||
def stream_response(response, destination: Path, prefix: bytes = b"") -> tuple[int, str]:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with destination.open("wb") as output:
|
||||
if prefix:
|
||||
output.write(prefix)
|
||||
digest.update(prefix)
|
||||
size += len(prefix)
|
||||
for chunk in iter(lambda: response.read(1024 * 1024), b""):
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
return size, digest.hexdigest()
|
||||
|
||||
|
||||
def download_lfs_object(oid: str, expected_size: int, ref: str, destination: Path) -> tuple[int, str]:
|
||||
batch_url = f"https://github.com/{OPENPILOT_REPO}.git/info/lfs/objects/batch"
|
||||
payload = json.dumps({
|
||||
"operation": "download",
|
||||
"transfers": ["basic"],
|
||||
"objects": [{"oid": oid, "size": expected_size}],
|
||||
"ref": {"name": ref},
|
||||
}).encode("utf-8")
|
||||
with http_request(
|
||||
batch_url,
|
||||
method="POST",
|
||||
payload=payload,
|
||||
headers={"Accept": "application/vnd.git-lfs+json", "Content-Type": "application/vnd.git-lfs+json"},
|
||||
) as response:
|
||||
batch = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
objects = batch.get("objects", []) if isinstance(batch, dict) else []
|
||||
if not objects or "error" in objects[0]:
|
||||
raise ReleaseError(f"Git LFS download was not available for {oid}")
|
||||
action = objects[0].get("actions", {}).get("download")
|
||||
if not action or not action.get("href"):
|
||||
raise ReleaseError(f"Git LFS returned no download action for {oid}")
|
||||
headers = {str(key): str(value) for key, value in action.get("header", {}).items()}
|
||||
with http_request(action["href"], headers=headers) as response:
|
||||
size, digest = stream_response(response, destination)
|
||||
if size != expected_size or digest != oid:
|
||||
raise ReleaseError(f"LFS object verification failed: size {size}/{expected_size}, sha256 {digest}/{oid}")
|
||||
return size, digest
|
||||
|
||||
|
||||
def download_source(ref: str, git_path: str, destination: Path, force: bool) -> dict:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.exists() and not force:
|
||||
raise ReleaseError(f"Source already exists: {destination}; use --force to fetch the requested commit")
|
||||
|
||||
encoded_ref = urllib.parse.quote(ref, safe="/")
|
||||
encoded_path = "/".join(urllib.parse.quote(part, safe="") for part in git_path.split("/"))
|
||||
raw_url = f"https://raw.githubusercontent.com/{OPENPILOT_REPO}/{encoded_ref}/{encoded_path}"
|
||||
temporary = destination.with_name(destination.name + ".part")
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
with http_request(raw_url) as response:
|
||||
prefix = response.read(512)
|
||||
pointer = parse_lfs_pointer(prefix)
|
||||
if pointer is None:
|
||||
size, digest = stream_response(response, temporary, prefix)
|
||||
else:
|
||||
temporary.unlink(missing_ok=True)
|
||||
size, digest = download_lfs_object(pointer[0], pointer[1], ref, temporary)
|
||||
|
||||
temporary.replace(destination)
|
||||
print(f"Downloaded source: {size} bytes, sha256 {digest}")
|
||||
return {"path": str(destination), "size": size, "sha256": digest, "url": raw_url, "ref": ref, "git_path": git_path}
|
||||
|
||||
|
||||
def clean_markdown(value: str) -> str:
|
||||
value = re.sub(r"\[([^]]+)\]\([^)]*\)", r"\1", value)
|
||||
value = re.sub(r"[*_`]+", "", value)
|
||||
return " ".join(value.split()).strip()
|
||||
|
||||
|
||||
def slug_model_id(name: str) -> str:
|
||||
tokens = re.findall(r"[a-z0-9]+", name.lower())
|
||||
return "".join(tokens)
|
||||
|
||||
|
||||
def parse_release_date(text: str) -> str | None:
|
||||
match = DATE_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
for fmt in ("%B %d, %Y", "%b %d, %Y"):
|
||||
try:
|
||||
return dt.datetime.strptime(match.group(1), fmt).date().isoformat()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def commit_local_date(payload: dict) -> str:
|
||||
commit_data = payload.get("commit", {})
|
||||
for author_key in ("committer", "author"):
|
||||
timestamp = commit_data.get(author_key, {}).get("date") if isinstance(commit_data, dict) else None
|
||||
if not timestamp:
|
||||
continue
|
||||
try:
|
||||
return dt.datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")).astimezone().date().isoformat()
|
||||
except ValueError:
|
||||
continue
|
||||
return dt.date.today().isoformat()
|
||||
|
||||
|
||||
def source_name_from_path(source_path: str) -> str:
|
||||
stem = Path(source_path).stem
|
||||
stem = re.sub(r"^(?:big_)?driving_supercombo$", "", stem, flags=re.IGNORECASE)
|
||||
stem = re.sub(r"_driving_(?:supercombo|vision|policy)$", "", stem, flags=re.IGNORECASE)
|
||||
stem = stem.strip("_-")
|
||||
return stem.replace("_", " ").replace("-", " ").title() or "Model"
|
||||
|
||||
|
||||
def select_model_source(files: list[Any], commit: str) -> str:
|
||||
paths = [
|
||||
str(item.get("filename", ""))
|
||||
for item in files
|
||||
if isinstance(item, dict) and str(item.get("filename", "")).lower().endswith(".onnx")
|
||||
]
|
||||
preferred = [path for path in paths if Path(path).name.lower().endswith("driving_supercombo.onnx")]
|
||||
candidates = preferred or paths
|
||||
if len(candidates) != 1:
|
||||
if not candidates:
|
||||
raise ReleaseError(f"Commit {commit} does not change a driving ONNX file")
|
||||
raise ReleaseError(f"Commit {commit} changes multiple ONNX files; provide the full bot message to disambiguate")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def resolve_commit_release(commit: str, model_id_override: str | None, behavior_version: str) -> ReleaseInfo:
|
||||
commit_payload = get_json(f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}")
|
||||
files = commit_payload.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
raise ReleaseError(f"GitHub returned no file list for commit {commit}")
|
||||
source_path = select_model_source(files, commit)
|
||||
|
||||
branch = ""
|
||||
display_name = ""
|
||||
pulls = get_json_value(f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}/pulls")
|
||||
if isinstance(pulls, list):
|
||||
for pull in pulls:
|
||||
if not isinstance(pull, dict):
|
||||
continue
|
||||
head = pull.get("head", {})
|
||||
if isinstance(head, dict) and str(head.get("sha", "")).lower() == commit:
|
||||
branch = str(head.get("ref") or "")
|
||||
display_name = clean_markdown(str(pull.get("title") or ""))
|
||||
break
|
||||
|
||||
if not branch:
|
||||
branches = get_json_value(
|
||||
f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}/branches-where-head"
|
||||
)
|
||||
if isinstance(branches, list):
|
||||
names = [str(item.get("name")) for item in branches if isinstance(item, dict) and item.get("name")]
|
||||
branch = next((name for name in names if name.lower() != "master"), names[0] if names else "")
|
||||
branch = branch or commit
|
||||
|
||||
if not display_name or display_name.lower() in {"big", "model", "update model"}:
|
||||
display_name = source_name_from_path(source_path)
|
||||
model_id = model_id_override or slug_model_id(display_name)
|
||||
if not MODEL_ID_RE.fullmatch(model_id):
|
||||
raise ReleaseError(f"Invalid model ID {model_id!r}; use lowercase letters, digits, '-' or '_'")
|
||||
uses_external_gpu = Path(source_path).name.lower().startswith("big_")
|
||||
model_iteration_match = re.search(r"\b(v\d+)\b", display_name, flags=re.IGNORECASE)
|
||||
return ReleaseInfo(
|
||||
model_id=model_id,
|
||||
display_name=display_name,
|
||||
release_date=commit_local_date(commit_payload),
|
||||
branch=branch,
|
||||
source_ref=commit,
|
||||
source_path=source_path,
|
||||
input_format="supercombo" if "supercombo" in Path(source_path).name else "split",
|
||||
behavior_version=behavior_version,
|
||||
uses_external_gpu=uses_external_gpu,
|
||||
commits=[commit],
|
||||
model_iteration=model_iteration_match.group(1).lower() if model_iteration_match else "",
|
||||
)
|
||||
|
||||
|
||||
def parse_pasted_release(text: str, model_id_override: str | None, behavior_version: str) -> ReleaseInfo:
|
||||
cleaned_text = text.replace("\\u00a0", " ")
|
||||
commits = list(dict.fromkeys(match.lower() for match in SHA_RE.findall(cleaned_text)))
|
||||
if re.fullmatch(r"\s*[0-9a-f]{40}\s*", cleaned_text, flags=re.IGNORECASE):
|
||||
return resolve_commit_release(commits[0], model_id_override, behavior_version)
|
||||
|
||||
source_match = re.search(
|
||||
r"https?://github\.com/commaai/openpilot/(?:blob|raw)/([^/\s]+)/([^\s)\]]+)",
|
||||
cleaned_text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if source_match:
|
||||
branch = urllib.parse.unquote(source_match.group(1))
|
||||
source_path = urllib.parse.unquote(source_match.group(2)).rstrip(".,")
|
||||
else:
|
||||
branch_match = re.search(r"\[([^]]+)\]\s*\(Branch\s+v?\d+\)", cleaned_text, flags=re.IGNORECASE)
|
||||
branch = clean_markdown(branch_match.group(1)) if branch_match else ""
|
||||
family_match = re.search(r"\[([a-z0-9_-]+)\]\s*\(Branch", cleaned_text, flags=re.IGNORECASE)
|
||||
family = family_match.group(1).lower() if family_match else ""
|
||||
source_path = f"openpilot/selfdrive/modeld/models/{family}_driving_supercombo.onnx" if family else ""
|
||||
|
||||
if not branch:
|
||||
raise ReleaseError("Could not parse the openpilot branch from the pasted release block")
|
||||
if not source_path or not source_path.endswith(".onnx"):
|
||||
raise ReleaseError("Could not parse the upstream ONNX path from the pasted release block")
|
||||
|
||||
name = ""
|
||||
release_date = None
|
||||
for line in cleaned_text.splitlines():
|
||||
plain = clean_markdown(line)
|
||||
date_match = DATE_RE.search(plain)
|
||||
if date_match:
|
||||
candidate = plain[:date_match.start()].strip(" :-(\t")
|
||||
candidate = re.sub(r"^(?:model name|name)\s*[:=-]?\s*", "", candidate, flags=re.IGNORECASE)
|
||||
if candidate and "next model version" not in candidate.lower():
|
||||
name = candidate
|
||||
release_date = parse_release_date(plain)
|
||||
break
|
||||
if not name:
|
||||
family_match = re.search(r"\[([a-z0-9_-]+)\]\s*\(Branch", cleaned_text, flags=re.IGNORECASE)
|
||||
name = family_match.group(1).replace("-", " ").title() if family_match else Path(source_path).stem
|
||||
release_date = dt.date.today().isoformat()
|
||||
release_date = release_date or dt.date.today().isoformat()
|
||||
|
||||
iteration_match = re.search(r"\b(v\d+)\b", name, flags=re.IGNORECASE)
|
||||
model_iteration = iteration_match.group(1).lower() if iteration_match else ""
|
||||
model_id = model_id_override or slug_model_id(name)
|
||||
if not MODEL_ID_RE.fullmatch(model_id):
|
||||
raise ReleaseError(f"Invalid model ID {model_id!r}; use lowercase letters, digits, '-' or '_'")
|
||||
|
||||
uses_external_gpu = bool(
|
||||
re.search(r"\[big\]|\bbig[_ -]model\b", cleaned_text, flags=re.IGNORECASE)
|
||||
or Path(source_path).name.startswith("big_")
|
||||
)
|
||||
source_ref = commits[0] if commits else branch
|
||||
input_format = "supercombo" if "supercombo" in Path(source_path).name else "split"
|
||||
return ReleaseInfo(
|
||||
model_id=model_id,
|
||||
display_name=name,
|
||||
release_date=release_date,
|
||||
branch=branch,
|
||||
source_ref=source_ref,
|
||||
source_path=source_path,
|
||||
input_format=input_format,
|
||||
behavior_version=behavior_version,
|
||||
uses_external_gpu=uses_external_gpu,
|
||||
commits=commits,
|
||||
model_iteration=model_iteration,
|
||||
)
|
||||
|
||||
|
||||
def resolve_branch_commit(branch: str) -> str:
|
||||
url = f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{urllib.parse.quote(branch, safe='')}"
|
||||
payload = get_json(url)
|
||||
sha = str(payload.get("sha") or "")
|
||||
if not SHA_RE.fullmatch(sha):
|
||||
raise ReleaseError(f"Could not resolve branch head for {branch}")
|
||||
return sha
|
||||
|
||||
|
||||
def runtime_file(path: str) -> bool:
|
||||
normalized = path.lstrip("./")
|
||||
if normalized.lower().endswith(".onnx"):
|
||||
return False
|
||||
return normalized.startswith(RUNTIME_PATH_PREFIXES)
|
||||
|
||||
|
||||
def scan_runtime_changes(info: ReleaseInfo) -> list[dict]:
|
||||
commits = info.commits or [resolve_branch_commit(info.branch)]
|
||||
findings: list[dict] = []
|
||||
for commit in commits:
|
||||
url = f"https://api.github.com/repos/{OPENPILOT_REPO}/commits/{commit}"
|
||||
payload = get_json(url)
|
||||
files = payload.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
raise ReleaseError(f"GitHub returned no file list for commit {commit}")
|
||||
changed = [str(item.get("filename", "")) for item in files if isinstance(item, dict)]
|
||||
runtime_paths = [path for path in changed if runtime_file(path)]
|
||||
if runtime_paths:
|
||||
findings.append({"commit": commit, "message": str(payload.get("commit", {}).get("message", "")).splitlines()[0], "paths": runtime_paths})
|
||||
return findings
|
||||
|
||||
|
||||
def print_summary(info: ReleaseInfo) -> None:
|
||||
print("\nRelease summary")
|
||||
print(f" model ID: {info.model_id}")
|
||||
print(f" display name: {info.display_name}")
|
||||
print(f" release date: {info.release_date}")
|
||||
print(f" behavior: {info.behavior_version}")
|
||||
print(f" source ref: {info.source_ref}")
|
||||
print(f" source path: {info.source_path}")
|
||||
print(f" input format: {info.input_format}")
|
||||
print(f" external GPU: {info.uses_external_gpu}")
|
||||
|
||||
|
||||
def print_runtime_warning(findings: list[dict]) -> None:
|
||||
print("\n" + "!" * 88)
|
||||
print("STOP: UPSTREAM TINYGRAD/MODELD RUNTIME CHANGES DETECTED")
|
||||
print("Ask Firestar to review these changes before compiling this model.")
|
||||
for finding in findings:
|
||||
print(f" {finding['commit'][:12]} {finding['message']}")
|
||||
for path in finding["paths"]:
|
||||
print(f" - {path}")
|
||||
print("The release tool will not compile until --allow-runtime-changes is supplied.")
|
||||
print("!" * 88 + "\n")
|
||||
|
||||
|
||||
def choose_device_ip(args: argparse.Namespace) -> str:
|
||||
if args.ip:
|
||||
value = args.ip.strip()
|
||||
elif sys.stdin.isatty():
|
||||
value = input("Comma IP [192.168.3.110]: ").strip() or "192.168.3.110"
|
||||
else:
|
||||
raise ReleaseError("Pass --ip when the release text is piped on stdin")
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError as error:
|
||||
raise ReleaseError(f"Invalid comma IP: {value}") from error
|
||||
if str(address).split(".")[-1] == "109":
|
||||
raise ReleaseError("Refusing to use .109. This workflow is restricted to the requested device, not 192.168.3.109.")
|
||||
return str(address)
|
||||
|
||||
|
||||
def ssh_base(ip: str) -> list[str]:
|
||||
return ["ssh", "-o", "ConnectTimeout=10", "-o", "ConnectionAttempts=1", "-o", "ServerAliveInterval=30", f"comma@{ip}"]
|
||||
|
||||
|
||||
def scp_base(ip: str) -> list[str]:
|
||||
return ["scp", "-p", "-o", "ConnectTimeout=10", "-o", "ConnectionAttempts=1"]
|
||||
|
||||
|
||||
def remote_compile(info: ReleaseInfo, source: Path, ip: str, workspace: Path, keep_device_files: bool) -> dict:
|
||||
if info.input_format != "supercombo":
|
||||
raise ReleaseError("The release tool currently requires a single supercombo ONNX source")
|
||||
|
||||
input_dir = f"{DEVICE_ROOT}/uncompiledmodels"
|
||||
output_dir = f"{DEVICE_ROOT}/compiledmodels"
|
||||
remote_source = f"{input_dir}/{info.model_id}_driving_supercombo.onnx"
|
||||
artifact_prefix = f"{info.model_id}_driving_tinygrad.pkl"
|
||||
safe_id = shlex.quote(info.model_id)
|
||||
cleanup_command = f"rm -f {shlex.quote(remote_source)} {shlex.quote(output_dir)}/{artifact_prefix}*"
|
||||
run(ssh_base(ip) + [f"mkdir -p {shlex.quote(input_dir)} {shlex.quote(output_dir)} && {cleanup_command}"])
|
||||
run(scp_base(ip) + [str(source), f"comma@{ip}:{remote_source}"])
|
||||
|
||||
command = [
|
||||
f"cd {shlex.quote(DEVICE_ROOT)} && ./models",
|
||||
f"--model {safe_id}",
|
||||
f"--input-dir {shlex.quote(input_dir)}",
|
||||
f"--output-dir {shlex.quote(output_dir)}",
|
||||
f"--input-format supercombo",
|
||||
f"--version {shlex.quote(info.behavior_version)}",
|
||||
]
|
||||
if info.uses_external_gpu:
|
||||
command.append("--gpu")
|
||||
remote_command = " ".join(command)
|
||||
log_path = workspace / "logs" / f"{info.model_id}.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"\nCompiling on comma@{ip}. Output is also logged to {log_path}")
|
||||
with log_path.open("w", encoding="utf-8") as log:
|
||||
process = subprocess.Popen(
|
||||
ssh_base(ip) + [remote_command],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
print(f"[device] {line}", end="")
|
||||
log.write(line)
|
||||
return_code = process.wait()
|
||||
if return_code != 0:
|
||||
raise ReleaseError(f"Device compilation failed; see {log_path}")
|
||||
|
||||
list_command = f"for f in {shlex.quote(output_dir)}/{artifact_prefix}*; do [ -f \"$f\" ] && basename \"$f\"; done"
|
||||
listed = run(ssh_base(ip) + [list_command], capture=True).stdout.splitlines()
|
||||
remote_files = sorted(name for name in listed if name.startswith(artifact_prefix))
|
||||
if not remote_files:
|
||||
raise ReleaseError(f"Device compiler produced no {artifact_prefix} output")
|
||||
|
||||
artifact_dir = workspace / "compiled" / info.model_id
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
for stale in artifact_dir.iterdir():
|
||||
if stale.is_file():
|
||||
stale.unlink()
|
||||
for filename in remote_files:
|
||||
run(scp_base(ip) + [f"comma@{ip}:{output_dir}/{filename}", str(artifact_dir / filename)])
|
||||
|
||||
parts = sorted(artifact_dir.glob(f"{artifact_prefix}.p[0-9][0-9]"))
|
||||
full_artifact = artifact_dir / artifact_prefix
|
||||
checksum_path = artifact_dir / f"{artifact_prefix}.sha256"
|
||||
if parts:
|
||||
if full_artifact.exists() or not checksum_path.is_file():
|
||||
raise ReleaseError("Device returned invalid multipart output")
|
||||
expected = checksum_path.read_text(encoding="utf-8").split()[0].lower()
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
for part in parts:
|
||||
with part.open("rb") as source_part:
|
||||
for chunk in iter(lambda: source_part.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
actual = digest.hexdigest()
|
||||
if actual != expected:
|
||||
raise ReleaseError(f"Multipart checksum mismatch: {actual} != {expected}")
|
||||
artifact_files = [*parts, checksum_path]
|
||||
elif full_artifact.is_file():
|
||||
size = full_artifact.stat().st_size
|
||||
actual = sha256_file(full_artifact)
|
||||
artifact_files = [full_artifact]
|
||||
expected = actual
|
||||
else:
|
||||
raise ReleaseError("Device returned no usable artifact")
|
||||
|
||||
if not keep_device_files:
|
||||
run(ssh_base(ip) + [cleanup_command])
|
||||
result = {
|
||||
"id": info.model_id,
|
||||
"status": "compiled",
|
||||
"size": size,
|
||||
"sha256": expected,
|
||||
"multipart": bool(parts),
|
||||
"files": [path.name for path in artifact_files],
|
||||
"path": str(artifact_dir),
|
||||
}
|
||||
(workspace / "results").mkdir(parents=True, exist_ok=True)
|
||||
(workspace / "results" / f"{info.model_id}.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
return result
|
||||
|
||||
|
||||
def manifest_entry(info: ReleaseInfo, result: dict) -> dict:
|
||||
display_name = info.display_name
|
||||
if "👀" not in display_name and "📡" not in display_name:
|
||||
display_name += " 👀📡"
|
||||
return {
|
||||
"id": info.model_id,
|
||||
"name": display_name,
|
||||
"version": info.behavior_version,
|
||||
"series": "OP Series",
|
||||
"released": info.release_date,
|
||||
"community_favorite": False,
|
||||
"artifact_format": "tinygrad_single_v1",
|
||||
"artifact_size": result["size"],
|
||||
"artifact_sha256": result["sha256"],
|
||||
"uses_external_gpu": info.uses_external_gpu,
|
||||
}
|
||||
|
||||
|
||||
def update_manifest(repo: Path, info: ReleaseInfo, result: dict, manifest_version: str) -> Path:
|
||||
path = repo / f"model_names_{manifest_version}.json"
|
||||
if not path.is_file():
|
||||
raise ReleaseError(f"Manifest not found: {path}")
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
models = payload.get("models", payload) if isinstance(payload, dict) else payload
|
||||
if not isinstance(models, list):
|
||||
raise ReleaseError(f"Unsupported manifest shape: {path}")
|
||||
entry = manifest_entry(info, result)
|
||||
replaced = False
|
||||
updated = []
|
||||
for model in models:
|
||||
if isinstance(model, dict) and model.get("id") == info.model_id:
|
||||
updated.append(entry)
|
||||
replaced = True
|
||||
else:
|
||||
updated.append(model)
|
||||
if not replaced:
|
||||
updated.append(entry)
|
||||
output = {**payload, "models": updated} if isinstance(payload, dict) else {"models": updated}
|
||||
path.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def find_hf() -> str:
|
||||
candidates = [shutil.which("hf"), str(Path.home() / ".local/bin/hf")]
|
||||
for candidate in candidates:
|
||||
if candidate and Path(candidate).is_file():
|
||||
return candidate
|
||||
raise ReleaseError("Hugging Face CLI not found; install/authenticate `hf` first")
|
||||
|
||||
|
||||
def hf_copy(source: Path, bucket: str, remote_path: str) -> None:
|
||||
hf = find_hf()
|
||||
destination = f"hf://buckets/{bucket}/{remote_path}"
|
||||
run([hf, "buckets", "cp", str(source), destination, "--format", "quiet"])
|
||||
|
||||
|
||||
def upload_huggingface(info: ReleaseInfo, result: dict, workspace: Path, bucket: str, manifest: Path, upload_onnx: bool, source: Path) -> None:
|
||||
artifact_dir = Path(result["path"])
|
||||
for filename in result["files"]:
|
||||
hf_copy(artifact_dir / filename, bucket, f"models/{info.model_id}/{filename}")
|
||||
if upload_onnx:
|
||||
hf_copy(source, bucket, f"onnx/{info.model_id}/{source.name}")
|
||||
hf_copy(manifest, bucket, f"manifests/{manifest.name}")
|
||||
print(f"Hugging Face upload complete: {bucket}/models/{info.model_id}/")
|
||||
|
||||
|
||||
def git_output(repo: Path, args: list[str]) -> str:
|
||||
return run(["git", "-C", str(repo), *args], capture=True).stdout.strip()
|
||||
|
||||
|
||||
def check_resources_repo(repo: Path, branch: str) -> None:
|
||||
if not (repo / ".git").exists():
|
||||
raise ReleaseError(f"GitHub resources checkout not found: {repo}")
|
||||
status = git_output(repo, ["status", "--porcelain"])
|
||||
if status:
|
||||
raise ReleaseError(f"Resources checkout is dirty; refusing to modify it:\n{status}")
|
||||
current_branch = git_output(repo, ["branch", "--show-current"])
|
||||
if current_branch != branch:
|
||||
raise ReleaseError(f"Resources checkout is on {current_branch!r}, expected {branch!r}")
|
||||
remote_head = git_output(repo, ["rev-parse", f"origin/{branch}"])
|
||||
local_head = git_output(repo, ["rev-parse", "HEAD"])
|
||||
if remote_head != local_head:
|
||||
raise ReleaseError("Resources checkout has unpushed or missing remote commits; sync it before releasing")
|
||||
|
||||
|
||||
def push_github(info: ReleaseInfo, result: dict, resources_repo: Path, manifest: Path, branch: str, force: bool) -> None:
|
||||
artifact_dir = Path(result["path"])
|
||||
artifact_names = list(result["files"])
|
||||
destination_paths = []
|
||||
stale_relative: list[str] = []
|
||||
for filename in artifact_names:
|
||||
destination = resources_repo / filename
|
||||
if destination.exists() and not force:
|
||||
raise ReleaseError(f"Artifact already exists in GitHub checkout: {destination}; use --force to replace it")
|
||||
shutil.copy2(artifact_dir / filename, destination)
|
||||
destination_paths.append(destination)
|
||||
|
||||
prefix = f"{info.model_id}_driving_tinygrad.pkl"
|
||||
if force:
|
||||
allowed = {path.name for path in destination_paths}
|
||||
for stale in resources_repo.glob(f"{prefix}*"):
|
||||
if stale.name not in allowed and stale.is_file():
|
||||
stale.unlink()
|
||||
stale_relative.append(str(stale.relative_to(resources_repo)))
|
||||
|
||||
for index, destination in enumerate(destination_paths):
|
||||
relative = destination.relative_to(resources_repo)
|
||||
paths_to_stage = [str(relative)]
|
||||
if index == 0:
|
||||
paths_to_stage.extend(stale_relative)
|
||||
run(["git", "-C", str(resources_repo), "add", "-A", "--", *paths_to_stage])
|
||||
run(["git", "-C", str(resources_repo), "commit", "-m", f"Add {info.model_id} artifact {destination.name}", "--", *paths_to_stage])
|
||||
run(["git", "-C", str(resources_repo), "push", "origin", f"HEAD:{branch}"])
|
||||
|
||||
run(["git", "-C", str(resources_repo), "add", "--", str(manifest.relative_to(resources_repo))])
|
||||
if git_output(resources_repo, ["diff", "--cached", "--name-only"]):
|
||||
run(["git", "-C", str(resources_repo), "commit", "-m", f"Add {info.display_name} to {manifest.name}", "--", str(manifest.relative_to(resources_repo))])
|
||||
run(["git", "-C", str(resources_repo), "push", "origin", f"HEAD:{branch}"])
|
||||
print(f"GitHub upload complete: {RESOURCES_REPO}/{branch}")
|
||||
|
||||
|
||||
def read_release_text(args: argparse.Namespace) -> str:
|
||||
if args.commit:
|
||||
return args.commit
|
||||
if args.text_file:
|
||||
return args.text_file.read_text(encoding="utf-8")
|
||||
if args.text:
|
||||
return args.text
|
||||
if not sys.stdin.isatty():
|
||||
return sys.stdin.read()
|
||||
print("Paste the model bot message. Press Ctrl-D when finished.")
|
||||
return sys.stdin.read()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Download, device-compile, verify, and publish one upstream model.")
|
||||
parser.add_argument("commit", nargs="?", help="A single openpilot commit SHA; metadata is resolved from GitHub.")
|
||||
parser.add_argument("--text", help="Release text, otherwise paste it into stdin.")
|
||||
parser.add_argument("--text-file", type=Path, help="Read the pasted release text from a file.")
|
||||
parser.add_argument("--model-id", help="Override the ID parsed from the model name.")
|
||||
parser.add_argument("--behavior-version", default=DEFAULT_BEHAVIOR_VERSION, help="Runtime behavior version (default: v16).")
|
||||
parser.add_argument("--ip", help="Comma IP; prompted interactively when omitted.")
|
||||
parser.add_argument("--workspace", type=Path, default=default_workspace())
|
||||
parser.add_argument("--resources-repo", type=Path, default=Path.home() / "StarPilot-Resources")
|
||||
parser.add_argument("--resources-branch", default=RESOURCE_BRANCH)
|
||||
parser.add_argument("--manifest-version", default=MANIFEST_VERSION)
|
||||
parser.add_argument("--hf-bucket", default=HF_BUCKET)
|
||||
gpu = parser.add_mutually_exclusive_group()
|
||||
gpu.add_argument("--gpu", dest="gpu", action="store_true", help="Force external-GPU compilation.")
|
||||
gpu.add_argument("--no-gpu", dest="gpu", action="store_false", help="Disable external-GPU compilation.")
|
||||
parser.set_defaults(gpu=None)
|
||||
parser.add_argument("--allow-runtime-changes", action="store_true", help="Continue only after reviewing the runtime-change warning.")
|
||||
parser.add_argument("--no-onnx-upload", action="store_true", help="Do not archive the source ONNX in Hugging Face.")
|
||||
parser.add_argument("--keep-device-files", action="store_true", help="Leave the staged source and compiled output on the comma.")
|
||||
parser.add_argument("--force", action="store_true", help="Replace an existing source/artifact/model ID.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Parse and scan only; do not download, compile, or publish.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
if not re.fullmatch(r"v\d+", args.behavior_version.strip(), flags=re.IGNORECASE):
|
||||
raise ReleaseError("--behavior-version must look like v16")
|
||||
text = read_release_text(args)
|
||||
if not text.strip():
|
||||
raise ReleaseError("No release text was supplied")
|
||||
info = parse_pasted_release(text, args.model_id, args.behavior_version.strip().lower())
|
||||
if args.gpu is not None:
|
||||
info.uses_external_gpu = args.gpu
|
||||
print_summary(info)
|
||||
|
||||
findings = scan_runtime_changes(info)
|
||||
if findings:
|
||||
print_runtime_warning(findings)
|
||||
if not args.allow_runtime_changes:
|
||||
return 2
|
||||
else:
|
||||
print("Runtime scan: no tinygrad/modeld runtime files changed in the supplied commits.")
|
||||
|
||||
if args.dry_run:
|
||||
print("Dry run complete; no device or repository changes made.")
|
||||
return 0
|
||||
|
||||
ip = choose_device_ip(args)
|
||||
workspace = args.workspace / info.model_id
|
||||
for relative in ("onnx", "compiled", "logs", "results"):
|
||||
(workspace / relative).mkdir(parents=True, exist_ok=True)
|
||||
source = workspace / "onnx" / f"{info.model_id}_driving_supercombo.onnx"
|
||||
source_result = download_source(info.source_ref, info.source_path, source, args.force)
|
||||
(workspace / "release.txt").write_text(text, encoding="utf-8")
|
||||
(workspace / "source.json").write_text(json.dumps({**source_result, "model": info.__dict__}, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
result = remote_compile(info, source, ip, workspace, args.keep_device_files)
|
||||
resources_repo = args.resources_repo.expanduser().resolve()
|
||||
check_resources_repo(resources_repo, args.resources_branch)
|
||||
manifest = update_manifest(resources_repo, info, result, args.manifest_version)
|
||||
upload_huggingface(info, result, workspace, args.hf_bucket, manifest, not args.no_onnx_upload, source)
|
||||
push_github(info, result, resources_repo, manifest, args.resources_branch, args.force)
|
||||
print("\nRelease complete.")
|
||||
print(f" local artifact: {result['path']}")
|
||||
print(f" Hugging Face: {args.hf_bucket}/models/{info.model_id}/")
|
||||
print(f" GitHub: {RESOURCES_REPO}/{args.resources_branch}")
|
||||
return 0
|
||||
except (ReleaseError, subprocess.CalledProcessError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from scripts import model_release
|
||||
from scripts.model_release import parse_lfs_pointer, parse_pasted_release, runtime_file, update_manifest
|
||||
|
||||
|
||||
RELEASE_TEXT = """
|
||||
**BRANCH UPDATED — BIG**
|
||||
[remove-avgpool](https://github.com/commaai/openpilot/pull/38681) #38681 [big] (Branch v5)
|
||||
**Changed Files**
|
||||
[big_driving_supercombo](https://github.com/commaai/openpilot/blob/remove-avgpool/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx)
|
||||
**Recent Commits [f877]**
|
||||
[f877d7a0ccc3cce943c76e285214c020cd65c899](https://github.com/commaai/openpilot/commit/f877d7a0ccc3cce943c76e285214c020cd65c899)
|
||||
[faster compile](https://github.com/commaai/openpilot/commit/83a461eb4f0cb5737132c24b7a5ad5de46fc0fbb)
|
||||
**Next Model Version [big]**
|
||||
Model v4
|
||||
|
||||
Bmrlnap v4 (August 30, 2026) f877
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_release_block():
|
||||
info = parse_pasted_release(RELEASE_TEXT, None, "v16")
|
||||
assert info.model_id == "bmrlnapv4"
|
||||
assert info.display_name == "Bmrlnap v4"
|
||||
assert info.release_date == "2026-08-30"
|
||||
assert info.branch == "remove-avgpool"
|
||||
assert info.source_ref == "f877d7a0ccc3cce943c76e285214c020cd65c899"
|
||||
assert info.source_path.endswith("big_driving_supercombo.onnx")
|
||||
assert info.input_format == "supercombo"
|
||||
assert info.uses_external_gpu
|
||||
assert info.commits == [
|
||||
"f877d7a0ccc3cce943c76e285214c020cd65c899",
|
||||
"83a461eb4f0cb5737132c24b7a5ad5de46fc0fbb",
|
||||
]
|
||||
|
||||
|
||||
def test_lfs_pointer_parser():
|
||||
pointer = (
|
||||
b"version https://git-lfs.github.com/spec/v1\n"
|
||||
b"oid sha256:" + b"a" * 64 + b"\n"
|
||||
b"size 123\n"
|
||||
)
|
||||
assert parse_lfs_pointer(pointer) == ("a" * 64, 123)
|
||||
|
||||
|
||||
def test_sha_only_resolves_commit_metadata(monkeypatch):
|
||||
commit = "f877d7a0ccc3cce943c76e285214c020cd65c899"
|
||||
payloads = {
|
||||
f"https://api.github.com/repos/commaai/openpilot/commits/{commit}": {
|
||||
"commit": {"committer": {"date": "2026-08-31T00:41:06Z"}},
|
||||
"files": [{"filename": "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"}],
|
||||
},
|
||||
f"https://api.github.com/repos/commaai/openpilot/commits/{commit}/pulls": [
|
||||
{"title": "BMRLNAP", "head": {"sha": commit, "ref": "remove-avgpool"}},
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(model_release, "get_json_value", lambda url: payloads[url])
|
||||
info = parse_pasted_release(commit, None, "v16")
|
||||
|
||||
assert info.model_id == "bmrlnap"
|
||||
assert info.display_name == "BMRLNAP"
|
||||
assert info.release_date == "2026-08-30"
|
||||
assert info.branch == "remove-avgpool"
|
||||
assert info.source_ref == commit
|
||||
assert info.source_path.endswith("big_driving_supercombo.onnx")
|
||||
assert info.uses_external_gpu
|
||||
|
||||
|
||||
def test_runtime_scan_excludes_model_weights_but_flags_runtime_code():
|
||||
assert not runtime_file("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx")
|
||||
assert runtime_file("openpilot/selfdrive/modeld/compile_modeld.py")
|
||||
assert runtime_file("tinygrad/engine/jit.py")
|
||||
assert not runtime_file("README.md")
|
||||
|
||||
|
||||
def test_update_manifest_replaces_one_entry(tmp_path: Path):
|
||||
manifest = tmp_path / "model_names_v24.json"
|
||||
manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n")
|
||||
info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16")
|
||||
path = update_manifest(
|
||||
tmp_path,
|
||||
info,
|
||||
{"size": 123, "sha256": "a" * 64},
|
||||
"v24",
|
||||
)
|
||||
payload = json.loads(path.read_text())
|
||||
assert len(payload["models"]) == 2
|
||||
entry = payload["models"][1]
|
||||
assert entry["id"] == "bmrlnapv4"
|
||||
assert entry["artifact_size"] == 123
|
||||
assert entry["uses_external_gpu"]
|
||||
@@ -3413,6 +3413,16 @@
|
||||
"galaxy_only": true,
|
||||
"settings_tier": "simple"
|
||||
},
|
||||
{
|
||||
"key": "SubaruAvhOnAtStartup",
|
||||
"label": "AVH On at Startup",
|
||||
"description": "For supported Subaru Legacy 2025 vehicles, send one momentary Auto Vehicle Hold request after ignition while stationary and in Park or Neutral.",
|
||||
"picker_description": "Requests Auto Vehicle Hold ON once after ignition on the supported Legacy.",
|
||||
"data_type": "bool",
|
||||
"ui_type": "toggle",
|
||||
"galaxy_only": true,
|
||||
"settings_tier": "simple"
|
||||
},
|
||||
{
|
||||
"key": "ClusterOffset",
|
||||
"label": "Dashboard Speed Offset",
|
||||
|
||||
@@ -192,6 +192,7 @@ SAFE_MODE_MANAGED_KEYS = (
|
||||
"SubaruSNG",
|
||||
"SubaruSNGManualParkingBrake",
|
||||
"SubaruStopStartOff",
|
||||
"SubaruAvhOnAtStartup",
|
||||
"VoltSNG",
|
||||
"JeepBrakeHold",
|
||||
"GMAutoHold",
|
||||
@@ -210,6 +211,7 @@ SAFE_MODE_FIXED_VALUES = {
|
||||
"LongitudinalPersonality": int(log.LongitudinalPersonality.relaxed),
|
||||
"UseAutoSteerDelay": True,
|
||||
"SubaruStopStartOff": False,
|
||||
"SubaruAvhOnAtStartup": False,
|
||||
}
|
||||
|
||||
SAFE_MODE_STOCK_PARAM_MAP = {
|
||||
|
||||
@@ -20,7 +20,7 @@ from opendbc.car.gm.values import CAR as GM_CAR, EV_CAR as GM_EV_CAR, GMFlags
|
||||
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, EV_CAR as HYUNDAI_EV_CAR, HyundaiFlags, HyundaiStarPilotSafetyFlags
|
||||
from opendbc.car.interfaces import TORQUE_SUBSTITUTE_PATH, CarInterfaceBase, GearShifter
|
||||
from opendbc.car.mock.values import CAR as MOCK
|
||||
from opendbc.car.subaru.values import SUBARU_STOP_START_CARS, SubaruFlags
|
||||
from opendbc.car.subaru.values import SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags
|
||||
from opendbc.car.tesla.values import CAR as TESLA_CAR
|
||||
from opendbc.car.toyota.values import CAR as TOYOTA_CAR, ToyotaStarPilotFlags
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
@@ -1490,6 +1490,9 @@ class StarPilotVariables:
|
||||
toggle.subaru_stop_start_off = self.get_value(
|
||||
"SubaruStopStartOff", condition=toggle.car_model in SUBARU_STOP_START_CARS,
|
||||
)
|
||||
toggle.subaru_avh_on = self.get_value(
|
||||
"SubaruAvhOnAtStartup", condition=toggle.car_model in SUBARU_AVH_CARS,
|
||||
)
|
||||
|
||||
toggle.jeep_brake_hold = self.get_value(
|
||||
"JeepBrakeHold",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
|
||||
import { hideSidebar } from "/assets/js/utils.js"
|
||||
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-c4-hint-1"
|
||||
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-4"
|
||||
import { Bluetooth } from "/assets/components/tools/bluetooth.js?v=bluetooth-5"
|
||||
import { WheelControls } from "/assets/components/tools/wheel_controls.js?v=controllers-2"
|
||||
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
|
||||
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { galaxyPath } from "/assets/js/utils.js"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
@@ -20,6 +21,27 @@ const state = reactive({
|
||||
let initialized = false
|
||||
let lastPromptId = ""
|
||||
let audioTestTimer = null
|
||||
let pollTimer = null
|
||||
let refreshRequested = false
|
||||
let refreshPromise = null
|
||||
|
||||
function bluetoothPageActive() {
|
||||
const currentPath = window.location.pathname.replace(/\/+$/, "")
|
||||
const bluetoothPath = galaxyPath("/bluetooth").replace(/\/+$/, "")
|
||||
return currentPath === bluetoothPath
|
||||
}
|
||||
|
||||
function pollDelay() {
|
||||
return state.busy || state.discovering || state.pairingAddress ? 500 : 2000
|
||||
}
|
||||
|
||||
function schedulePoll(delay = pollDelay()) {
|
||||
if (pollTimer !== null) clearTimeout(pollTimer)
|
||||
pollTimer = setTimeout(async () => {
|
||||
if (bluetoothPageActive()) await refresh()
|
||||
schedulePoll()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function startAudioTestCountdown(address, delayMs, requestStartedAt) {
|
||||
if (audioTestTimer !== null) clearInterval(audioTestTimer)
|
||||
@@ -47,9 +69,11 @@ function startAudioTestCountdown(address, delayMs, requestStartedAt) {
|
||||
async function request(operation, body = {}) {
|
||||
const requestStartedAt = performance.now()
|
||||
state.busy = operation
|
||||
schedulePoll(250)
|
||||
try {
|
||||
const response = await fetch(`/api/bluetooth/${operation}`, {
|
||||
const response = await fetch(galaxyPath(`/api/bluetooth/${operation}`), {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -64,6 +88,7 @@ async function request(operation, body = {}) {
|
||||
state.error = error?.message || "Bluetooth operation failed"
|
||||
} finally {
|
||||
state.busy = ""
|
||||
schedulePoll(250)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,36 +112,51 @@ async function handlePrompt(prompt) {
|
||||
await request("pairing_response", { prompt_id: prompt.id, accepted, value })
|
||||
}
|
||||
|
||||
async function refreshOnce() {
|
||||
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
|
||||
const response = await fetch(statusUrl, { cache: "no-store" })
|
||||
const payload = await response.json()
|
||||
state.available = !!payload.available
|
||||
state.enabled = !!payload.enabled
|
||||
state.powered = !!payload.powered
|
||||
state.discovering = !!payload.discovering
|
||||
state.offroad = !!payload.offroad
|
||||
state.selectedAudio = String(payload.selected_audio || "")
|
||||
state.pairingAddress = String(payload.pairing_address || "")
|
||||
state.devices = Array.isArray(payload.devices) ? payload.devices : []
|
||||
state.prompt = payload.prompt || null
|
||||
state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable")
|
||||
handlePrompt(state.prompt)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshRequested = true
|
||||
if (refreshPromise !== null) return refreshPromise
|
||||
|
||||
refreshPromise = (async () => {
|
||||
while (refreshRequested) {
|
||||
refreshRequested = false
|
||||
try {
|
||||
await refreshOnce()
|
||||
} catch (error) {
|
||||
state.available = false
|
||||
state.error = error?.message || "Bluetooth service unavailable"
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/bluetooth/status", { cache: "no-store" })
|
||||
const payload = await response.json()
|
||||
state.available = !!payload.available
|
||||
state.enabled = !!payload.enabled
|
||||
state.powered = !!payload.powered
|
||||
state.discovering = !!payload.discovering
|
||||
state.offroad = !!payload.offroad
|
||||
state.selectedAudio = String(payload.selected_audio || "")
|
||||
state.pairingAddress = String(payload.pairing_address || "")
|
||||
state.devices = Array.isArray(payload.devices) ? payload.devices : []
|
||||
state.prompt = payload.prompt || null
|
||||
state.error = payload.error || (response.ok ? "" : "Bluetooth service unavailable")
|
||||
handlePrompt(state.prompt)
|
||||
} catch (error) {
|
||||
state.available = false
|
||||
state.error = error?.message || "Bluetooth service unavailable"
|
||||
await refreshPromise
|
||||
} finally {
|
||||
state.loading = false
|
||||
refreshPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
refresh()
|
||||
setInterval(() => {
|
||||
if (window.location.pathname === "/bluetooth") refresh()
|
||||
}, 2000)
|
||||
if (!initialized) initialized = true
|
||||
schedulePoll(0)
|
||||
}
|
||||
|
||||
function normalizedAddress(device) {
|
||||
|
||||
@@ -40,6 +40,7 @@ const VEHICLE_SETTING_MAKES = {
|
||||
SubaruSNG: ["Subaru"],
|
||||
SubaruSNGManualParkingBrake: ["Subaru"],
|
||||
SubaruStopStartOff: ["Subaru"],
|
||||
SubaruAvhOnAtStartup: ["Subaru"],
|
||||
ClusterOffset: ["Lexus", "Toyota"],
|
||||
SNGHack: ["Lexus", "Toyota"],
|
||||
ToyotaAutoHold: ["Lexus", "Toyota"],
|
||||
|
||||
@@ -42,6 +42,11 @@ def test_bluetooth_actions_use_reactive_disabled_bindings():
|
||||
assert "bluetoothForgetButton" in source
|
||||
assert "bi-trash3" in source
|
||||
assert "state.pairingAddress" in source
|
||||
assert 'galaxyPath("/bluetooth")' in source
|
||||
assert 'window.location.pathname === "/bluetooth"' not in source
|
||||
assert "schedulePoll(250)" in source
|
||||
assert "while (refreshRequested)" in source
|
||||
assert 'cache: "no-store"' in source
|
||||
|
||||
|
||||
def test_controller_test_mode_has_explicit_start_and_stop():
|
||||
|
||||
@@ -142,6 +142,7 @@ def test_bluetooth_status_api(monkeypatch):
|
||||
response = client.get("/api/bluetooth/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Cache-Control"] == "no-store, no-cache, must-revalidate, max-age=0"
|
||||
assert response.get_json() == {
|
||||
"available": True,
|
||||
"devices": [],
|
||||
|
||||
@@ -4939,6 +4939,10 @@ def setup(app):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
if request.path == "/api/bluetooth/status" or request.path.startswith("/api/bluetooth/"):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
@app.errorhandler(404)
|
||||
|
||||
Reference in New Issue
Block a user