From 9269b9a6df4463bc1c054554035228fc7070b39c Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:24:16 +0900 Subject: [PATCH 01/50] hyundai: stock-navigation camera kinds, + skip and section average Attach the 0x4BE preview kind to the current 0x4A3 warning (matched at the warning lead of ~6.1 m per km/h) so stock CAN cameras can be handled by kind: - fixed (kind 0), signal (kind 1) and rear (value >> 9 flag) always decelerate - mobile enforcement zones (kind 3) follow AutoNaviSpeedCtrlMode: 3 decelerates, 2 or lower ignores the zone warning - VehicleNaviSkipBoxCamera / VehicleNaviSkipMobileZone: one cruise + press releases that camera's deceleration without raising the set speed - VehicleNaviSectionAvgControl / VehicleNaviSectionAvgMargin: inside a section, allow up to the set speed only while the running average stays at or below limit - margin (time banked in congestion); start/end cameras keep spot decel Kinds were matched against the stock navigation screen and labelled drives (2026-09-27). All new settings default off; mobile-zone handling changes the stock CAN behaviour when AutoNaviSpeedCtrlMode is below 3. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 25 +++- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 25 +++- opendbc_repo/opendbc/car/hyundai/carstate.py | 58 ++++++++ .../opendbc/car/hyundai/navi_speedcam.py | 133 ++++++++++++++++++ .../car/hyundai/tests/test_navi_speedcam.py | 95 +++++++++++++ .../car/hyundai/tests/test_speed_camera.py | 100 ++++++++++++- openpilot/common/params_keys.h | 4 + openpilot/selfdrive/carrot/carrot_serv.py | 14 +- openpilot/selfdrive/carrot/section_average.py | 59 ++++++++ .../server/tests/test_settings_schema.py | 23 +++ .../carrot/tests/test_section_average.py | 67 +++++++++ openpilot/selfdrive/carrot_settings.json | 82 ++++++++++- 14 files changed, 680 insertions(+), 9 deletions(-) create mode 100644 opendbc_repo/opendbc/car/hyundai/navi_speedcam.py create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py create mode 100644 openpilot/selfdrive/carrot/section_average.py create mode 100644 openpilot/selfdrive/carrot/tests/test_section_average.py diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 52bd0fe7702..5b99a497f90 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index 0a074c80cb4..947f02f7d04 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -87,6 +87,29 @@ PV5 section control does not calculate average speed or remaining distance. Rest This experimental control is off by default. First verify that the displayed event type, limit, and remaining distance match the road, and disable it immediately if they do not. +### Stock-navigation camera kinds + +The current stock warning (0x4A3) carries no camera kind. The kind of the preview received about 2 km ahead (0x4BE) is attached to the warning with the same limit at the same position. A warning starts about 6.1 m per km/h of the limit before its camera (about 366 m at 60 km/h), so only previews at that position match. A warning of unknown kind still decelerates as before. + +| Preview kind | Navigation icon | Handling | +|---|---|---| +| Fixed speed camera | Red square | Always decelerates; no skip | +| Signal-and-speed, rear signal-and-speed | Traffic-light icon | Always decelerates; no skip | +| Mobile enforcement zone | Blue circle | Decelerates when `AutoNaviSpeedCtrlMode` is `3`; ignored at `2` or lower | +| Box (presumed) | — | Decelerates; skippable with `VehicleNaviSkipBoxCamera` | + +A warning that also carries a fixed, signal, or rear camera always decelerates, even inside a mobile zone. Box detection is not yet confirmed by labelled drives. + +### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` + +When enabled, one press of cruise `+` while that kind of camera decelerates releases its deceleration. The press does not raise the set speed, and the next camera decelerates again after the warning ends. Below about 18 km/h, `+` keeps its normal resume/set-speed role. `VehicleNaviSkipMobileZone` matters only when `AutoNaviSpeedCtrlMode` is `3`, so mobile zones decelerate. Both are off by default. + +### `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin` + +When enabled, a stock-navigation section (vehicle average-speed signal present) no longer holds the section limit throughout. Up to the cruise set speed is allowed only while the average since entering the section stays at or below `limit − VehicleNaviSectionAvgMargin`. Only time banked by driving slower than that target (for example in congestion) can be spent later; without it the cap equals the existing section limit. The start camera still decelerates to the spot limit before the section, and the end camera does so after its preview about 2 km ahead. Inside the section no virtual camera distance is used when no end preview is known. + +The average uses the vehicle odometer (`vEgo`). In a recorded drive the navigation-displayed average was 2–3 km/h higher than this calculation, so a margin of at least **3 km/h (default)** is recommended. The section start is taken when the average-speed signal appears and may differ from the actual start camera. Both settings are experimental. + ### `VehicleSpeedCameraControlMode` This setting applies only to camera, average-speed-zone, and 30 km/h zone deceleration received over vehicle CAN. It does not affect camera information from Carrot Navi. diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 55576be7365..1b11668cc06 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index 887b248d319..50ed569fa57 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -89,6 +89,29 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. 이 기능은 기본값이 꺼진 실험 기능입니다. 화면의 이벤트 종류, 제한속도와 남은 거리가 실제 도로와 일치하는지 먼저 확인하고, 일치하지 않으면 즉시 끄세요. +### 순정 내비 카메라 종류별 처리 + +순정 내비 CAN의 현재 경고(0x4A3)에는 카메라 종류가 없습니다. 약 2km 앞에서 오는 예고(0x4BE)의 종류를 같은 제한속도·같은 위치의 경고에 붙여 처리합니다. 경고는 제한속도 1km/h당 약 6.1m 앞(60km/h면 약 366m)에서 시작하므로 그 위치의 예고만 연결합니다. 종류를 알 수 없는 경고는 지금처럼 감속합니다. + +| 예고 종류 | 내비 표시 | 처리 | +|---|---|---| +| 고정식 과속 | 빨간 사각 | 항상 감속, 건너뛰기 없음 | +| 신호·과속, 후면 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | +| 이동식 단속 구간 | 파란 원 | `AutoNaviSpeedCtrlMode`가 `3`이면 감속, `2` 이하면 무시 | +| 박스형(추정) | — | 감속, `VehicleNaviSkipBoxCamera`로 건너뛰기 가능 | + +같은 경고에 고정식·신호·후면 카메라가 함께 있으면 이동식 구간이어도 항상 감속합니다. 박스형 판별은 실차 라벨로 아직 확정되지 않았습니다. + +### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` + +켜면 해당 종류의 카메라로 감속하는 동안 크루즈 `+` 버튼을 한 번 눌러 그 카메라의 감속을 해제합니다. 이 누름은 설정 속도를 올리지 않고, 경고가 끝나면 다음 카메라에는 다시 감속합니다. 약 18km/h 미만에서는 `+`가 원래 역할(재개·속도 설정)을 합니다. `VehicleNaviSkipMobileZone`은 `AutoNaviSpeedCtrlMode`가 `3`이라 이동식 구간에서 감속할 때만 의미가 있습니다. 둘 다 기본값은 꺼짐입니다. + +### `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin` + +켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중 구간 제한속도로 계속 묶지 않고, 구간 진입 후 평균속도가 `제한속도 − VehicleNaviSectionAvgMargin` 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 목표 평균보다 늦게 간 시간만 이후에 쓸 수 있으며, 적립된 시간이 없으면 기존 구간 제한과 같습니다. 시점 카메라는 구간 진입 전 지점 감속을 그대로 하고, 종점 카메라는 약 2km 전 예고를 받은 뒤 지점 감속합니다. 구간 안에서는 종점 예고가 없을 때 가상거리 감속을 쓰지 않습니다. + +평균은 차량 주행거리(`vEgo`)로 계산합니다. 기록 주행에서 내비가 표시한 평균이 이 계산보다 2~3km/h 높았으므로 여유속도는 **3km/h(기본값)** 이상을 권장합니다. 구간 시작은 평균속도 신호가 켜진 시점으로 잡아 실제 시점 카메라와 차이가 있을 수 있습니다. 두 설정 모두 실험 기능입니다. + ### `VehicleSpeedCameraControlMode` 이 설정은 차량 CAN으로 받은 카메라·구간단속·30km/h 구간 감속에만 적용하며 Carrot Navi의 카메라 정보에는 영향을 주지 않습니다. diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 1fb45c4aa79..a899074437c 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -9,6 +9,7 @@ from opendbc.car.common.conversions import Conversions as CV from opendbc.car.carlog import carlog from opendbc.car.hyundai.hyundaicanfd import CanBus +from opendbc.car.hyundai.navi_speedcam import SpeedcamPolicy from opendbc.car.hyundai.steering_touch import HyundaiSteeringTouch from opendbc.car.hyundai.values import HyundaiFlags, CAR, DBC, Buttons, CarControllerParams, CAMERA_SCC_CAR, HyundaiExtFlags, \ EV_MODE_ACTIVE_VALUES, EV_MODE_STATUS_ADDR, EV_MODE_STATUS_DLC, EV_MODE_STATUS_MSG, \ @@ -35,6 +36,7 @@ VEHICLE_NAVI_CONTROLLED_ACCESS_ROAD_CLASSES = (1, 2) # Freeway, arterial/city freeway VEHICLE_NAVI_SCHOOL_ZONE_MAX_DISTANCE = 1000.0 VEHICLE_NAVI_POSITION_TIMEOUT_NS = 1_000_000_000 +SPEEDCAM_SKIP_MIN_SPEED = 5.0 # m/s; below this + keeps its resume/set-speed role VEHICLE_NAVI_ROUTE_TIMEOUT_NS = 2_000_000_000 CANFD_HDA_INFO_MSG = "CANFD_HDA_INFO_364" CANFD_NAVI_PROFILE_MSG = "CANFD_NAVI_PROFILE_093" @@ -241,6 +243,10 @@ def __init__(self, CP): self.vehicleNaviSchoolZoneControl = self.op_params.get_bool("VehicleNaviSchoolZoneControl") self.vehicleSpeedCameraParamsCounter = 0 self.vehicleNaviEvents = [] + # joongyu01/speedcam: stock-navigation camera-kind policy (mobile zones, +-button skip, section average) + self.speedcam_policy = SpeedcamPolicy() + self.speedcam_accel_swallow = False + self._read_speedcam_params() self.vehicleNaviSegmentTimestamp = 0 self.vehicleNaviProfileTimestamp = 0 self.vehicleNaviAvailable = False @@ -700,12 +706,55 @@ def _update_vehicle_speed_camera_params(self): self.vehicleNaviSchoolZoneControl = vehicle_navi_school_zone_control if not vehicle_navi_school_zone_control: self._clear_vehicle_navi_school_zone() + self._read_speedcam_params() return changed + def _read_speedcam_params(self): + # AutoNaviSpeedCtrlMode 3 (+mobile cameras) also decelerates for stock mobile zones (0x4BE kind 3). + self.speedcamMobileZoneDecel = self.op_params.get_int("AutoNaviSpeedCtrlMode") >= 3 + self.speedcamSkipBox = self.op_params.get_bool("VehicleNaviSkipBoxCamera") + self.speedcamSkipMobileZone = self.op_params.get_bool("VehicleNaviSkipMobileZone") + self.speedcamSectionAvgControl = self.op_params.get_bool("VehicleNaviSectionAvgControl") + + def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_speed, accel_rising): + """Suppress a stock camera warning for ignored mobile zones or a +-button skip.""" + if self.canfd_wrapped_navi or not self.vehicleNaviCanControl: + self.speedcam_policy.reset_warning() + return speed_limit_cam + suppress, consume = self.speedcam_policy.update( + warning_active, int(round(warning_speed)), self.totalDistance, accel_rising and ret.vEgo > SPEEDCAM_SKIP_MIN_SPEED, + self.speedcamMobileZoneDecel, self.speedcamSkipBox, self.speedcamSkipMobileZone) + if consume: + # The press that skips a camera must not also raise the cruise set speed. + self.speedcam_accel_swallow = True + return False if suppress else speed_limit_cam + + def _apply_section_average_distance(self, ret): + """With section-average control, keep spot deceleration only for a same-speed camera ahead (the end camera).""" + if self.canfd_wrapped_navi or not (self.speedcamSectionAvgControl and self.vehicleNaviCanControl and ret.vehicleNaviSectionActive): + return + section_speed = int(round(ret.vehicleNaviSpeed)) + ahead = [event["target"] - self.totalDistance for event in self.vehicleNaviEvents + if event["type"] == "camera" and event["speed"] == section_speed and event["target"] > self.totalDistance] + ret.speedLimitDistance = min(ahead) if ahead else 0.0 + + def _swallow_speedcam_accel(self, button_events): + if not self.speedcam_accel_swallow: + return button_events + kept = [] + for event in button_events: + if event.type == ButtonType.accelCruise: + if not event.pressed: + self.speedcam_accel_swallow = False + continue + kept.append(event) + return kept + def _clear_vehicle_navi_events(self): self.vehicleNaviEvents = [] self.vehicleNaviCameraTarget = None self.vehicleNaviCameraStatusEvent = None + self.speedcam_policy.clear() def _clear_vehicle_navi_route_filtered_events(self): if self.vehicleNaviCanControl < 2: @@ -959,6 +1008,9 @@ def _update_vehicle_navi_events(self, cp, ret, speed_limit_cam, cp_alt=None): if timestamp > self.vehicleNaviProfileTimestamp: self.vehicleNaviProfileTimestamp = timestamp profile = self._decode_vehicle_navi_profile(self.navi_profile_4be) + if (not self.canfd_wrapped_navi and profile["profile_type"] == 16 and + timestamp > self.vehicleNaviRouteResetTimestamp): + self.speedcam_policy.add_preview(profile["value"], profile["offset"], self.totalDistance) event = self._classify_vehicle_navi_profile(profile) if event is not None and timestamp > self.vehicleNaviRouteResetTimestamp: if event[0] == "speed_limit_zone": @@ -1382,8 +1434,13 @@ def corner_max(signal): ret.vCluRatio = (ret.vEgo / vEgoClu) if (vEgoClu > 3. and ret.vEgo > 3.) else 1.0 distance_time_changed = self._update_vehicle_speed_camera_params() + speedcam_warning_active, speedcam_warning_speed = speed_limit_cam, ret.speedLimit speed_limit_cam = self._update_vehicle_navi_events(cp, ret, speed_limit_cam, cp_alt) or speed_limit_cam + speedcam_accel_rising = self.cruise_buttons[-1] == Buttons.RES_ACCEL and prev_cruise_buttons != Buttons.RES_ACCEL + speed_limit_cam = self._apply_speedcam_policy(ret, speed_limit_cam, speedcam_warning_active, speedcam_warning_speed, + speedcam_accel_rising) self.update_speed_limit(ret, speed_limit_cam, distance_time_changed) + self._apply_section_average_distance(ret) paddle_button = self.paddle_button_prev if self.cruise_btns_msg_canfd == "CRUISE_BUTTONS": @@ -1394,6 +1451,7 @@ def corner_max(signal): ret.buttonEvents = [*create_button_events(self.cruise_buttons[-1], prev_cruise_buttons, BUTTONS_DICT), *create_button_events(paddle_button, self.paddle_button_prev, {1: ButtonType.paddleLeft, 2: ButtonType.paddleRight}), *create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise})] + ret.buttonEvents = self._swallow_speedcam_accel(ret.buttonEvents) self.paddle_button_prev = paddle_button diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py new file mode 100644 index 00000000000..b16d19629a0 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -0,0 +1,133 @@ +"""Stock-navigation speed-camera kind policy for Hyundai CAN-FD 0x4BE/0x4A3. + +0x4BE PROLONG_VALUE (profile type 16) carries a camera kind in bits 0..3, a +speed code in bits 4..8 and extra flags above bit 9. Kinds were matched against +the stock navigation screen and labelled drives (2026-09-27): + + kind 0 fixed speed camera (also section start/end cameras) + kind 1 signal-and-speed camera + kind 2 unconfirmed spot camera (likely a mobile-camera box) + kind 3 mobile enforcement zone (blue circle; enforcement anywhere in the zone) + value >> 9 == 0x321 on kind 1: rear signal-and-speed camera + +0x4A3 only says "a camera warning is active" without a kind, so this policy +attaches the kind of the nearby same-speed preview to the current warning. +""" + +KIND_FIXED = 0 +KIND_SIGNAL = 1 +KIND_BOX = 2 +KIND_MOBILE_ZONE = 3 +CAMERA_KINDS = (KIND_FIXED, KIND_SIGNAL, KIND_BOX, KIND_MOBILE_ZONE) + +CLASS_MOBILE_ZONE = 1 +CLASS_BOX = 2 +CLASS_HARD = 3 # fixed, signal, rear and unknown cameras always decelerate + +PREVIEW_SATURATED_OFFSET = 1985 # offsets near the ~2 km horizon only bound the position +# A stock warning starts about 6.1 m per km/h of the enforced speed before its camera +# (labelled drives: 30 -> 195 m, 50 -> 296-318 m, 60 -> 361-366 m, 100 -> ~615 m). +WARNING_LEAD_M_PER_KPH = 6.1 +WARNING_LEAD_TOLERANCE = 0.2 +WARNING_LEAD_MIN_TOLERANCE_M = 60.0 +MATCH_BEHIND_M = 50.0 +RECENT_PREVIEW_M = 2600.0 +PREVIEW_KEEP_M = 3000.0 + + +def decode_camera_profile(value, offset): + """Return (kind, speed_kph, flagged) for a type-16 camera preview, else None.""" + if value in (0, 0xFFFFFFFF) or not 0 < offset < 8191: + return None + low = value & 0x1FF + kind = low & 0xF + speed_code = low >> 4 + if kind not in CAMERA_KINDS or not 1 < speed_code <= 31: + return None + return kind, (speed_code - 1) * 5, (value >> 9) != 0 + + +class SpeedcamPolicy: + def __init__(self): + self.previews = [] + self.reset_warning() + + def reset_warning(self): + self.warning_active = False + self.warning_speed = 0 + self.warning_class = None + self.skipped = False + + def clear(self): + self.previews = [] + self.reset_warning() + + def add_preview(self, value, offset, total_distance): + decoded = decode_camera_profile(value, offset) + if decoded is None: + return + kind, speed, flagged = decoded + self.previews.append({ + "kind": kind, "speed": speed, "flagged": flagged, "received": total_distance, + "target": total_distance + offset, "saturated": offset >= PREVIEW_SATURATED_OFFSET, + }) + self.previews = [p for p in self.previews if total_distance - p["received"] <= PREVIEW_KEEP_M] + + def classify(self, speed, total_distance, starting=True): + """Classify the warning at total_distance from same-speed previews (None when none match). + + When a warning starts its camera sits one warning lead ahead. A saturated preview + (entered at the ~2 km horizon) matches only when its target is at that lead; + a precise preview may also be nearer, e.g. a warning that began before logging. + Upgrades during a warning use precise previews only. + """ + lead = speed * WARNING_LEAD_M_PER_KPH + tolerance = max(WARNING_LEAD_MIN_TOLERANCE_M, lead * WARNING_LEAD_TOLERANCE) + candidates = [] + for p in self.previews: + if p["speed"] != speed or total_distance - p["received"] > RECENT_PREVIEW_M: + continue + ahead = p["target"] - total_distance + if p["saturated"]: + matched = starting and abs(ahead - lead) <= tolerance + else: + matched = -MATCH_BEHIND_M <= ahead <= lead + tolerance + if matched: + candidates.append(p) + if not candidates: + return None + if any(p["flagged"] or p["kind"] in (KIND_FIXED, KIND_SIGNAL) for p in candidates): + return CLASS_HARD + if any(p["kind"] == KIND_BOX for p in candidates): + return CLASS_BOX + return CLASS_MOBILE_ZONE + + def update(self, warning_active, warning_speed, total_distance, accel_rising, + mobile_zone_decel, skip_box, skip_mobile_zone): + """Return (suppress_warning, consume_accel_button).""" + if not warning_active or warning_speed <= 0: + self.reset_warning() + return False, False + + starting = not self.warning_active or warning_speed != self.warning_speed + if starting: + self.reset_warning() + self.warning_active = True + self.warning_speed = warning_speed + + current = self.classify(warning_speed, total_distance, starting=starting) + # A warning never downgrades: a fixed camera inside a mobile zone keeps it hard. + if current is not None and (self.warning_class is None or current > self.warning_class): + self.warning_class = current + warning_class = CLASS_HARD if self.warning_class is None else self.warning_class + + if warning_class == CLASS_MOBILE_ZONE and not mobile_zone_decel: + return True, False + + skippable = ((warning_class == CLASS_BOX and skip_box) or + (warning_class == CLASS_MOBILE_ZONE and skip_mobile_zone)) + consume = False + if skippable and accel_rising and not self.skipped: + self.skipped = True + consume = True + return self.skipped and skippable, consume diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py new file mode 100644 index 00000000000..9e3e17d45af --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -0,0 +1,95 @@ +from opendbc.car.hyundai.navi_speedcam import ( + CLASS_BOX, CLASS_HARD, CLASS_MOBILE_ZONE, SpeedcamPolicy, decode_camera_profile, +) + + +def _update(policy, distance, active=True, speed=60, accel=False, mobile_decel=False, skip_box=False, skip_mobile=False): + return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile) + + +def test_decode_uses_low_nine_bits_and_keeps_rear_flag(): + assert decode_camera_profile(0xD0, 176) == (0, 60, False) + assert decode_camera_profile(0xD3, 346) == (3, 60, False) + # Labelled rear signal-and-speed cameras: kind 1 with 0x321 above bit 9. + assert decode_camera_profile(0x64291, 487) == (1, 40, True) + assert decode_camera_profile(0x64271, 791) == (1, 30, True) + assert decode_camera_profile(0x6, 118) is None # speed bump + assert decode_camera_profile(0xD7, 0) is None # kind 7 zone entry + assert decode_camera_profile(0xFFFFFFFF, 8191) is None + + +def test_mobile_zone_is_ignored_below_mode_three(): + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 361, 0.0) # kind 3 at the 60 km/h warning lead + assert _update(policy, 0.0) == (True, False) + assert policy.warning_class == CLASS_MOBILE_ZONE + assert _update(policy, 0.0, mobile_decel=True) == (False, False) + + +def test_saturated_preview_matches_only_at_warning_lead(): + # 17:55:19 drive: a mobile-zone preview at the lead and a fixed camera ~1.9 km ahead. + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 1995, -1634.0) # saturated, lands 361 m ahead + policy.add_preview(0xD0, 1996, -88.0) # saturated, 1908 m ahead + _update(policy, 0.0) + assert policy.warning_class == CLASS_MOBILE_ZONE + + +def test_fixed_camera_inside_mobile_zone_always_decelerates(): + # 18:54 drive: fixed 364 m and mobile zone 398 m ahead of the same 60 km/h warning. + policy = SpeedcamPolicy() + policy.add_preview(0xD0, 1682, -1318.0) + policy.add_preview(0xD3, 1714, -1316.0) + assert _update(policy, 0.0, accel=True, skip_mobile=True, skip_box=True) == (False, False) + assert policy.warning_class == CLASS_HARD + + +def test_precise_fixed_preview_upgrades_a_running_mobile_zone(): + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 361, 0.0) + _update(policy, 0.0) + policy.add_preview(0xD0, 200, 100.0) + assert _update(policy, 100.0) == (False, False) + assert policy.warning_class == CLASS_HARD + + +def test_unknown_warning_is_hard(): + policy = SpeedcamPolicy() + assert _update(policy, 0.0, accel=True, skip_box=True, skip_mobile=True) == (False, False) + + +def test_rear_flag_is_hard_even_with_box_kind(): + policy = SpeedcamPolicy() + policy.add_preview(0x642D2, 366, 0.0) # kind 2 @60 with the rear flag + assert _update(policy, 0.0, accel=True, skip_box=True) == (False, False) + assert policy.warning_class == CLASS_HARD + + +def test_box_skip_needs_its_toggle_and_consumes_one_press(): + policy = SpeedcamPolicy() + policy.add_preview(0xD2, 366, 0.0) + assert _update(policy, 0.0, accel=True) == (False, False) + assert policy.warning_class == CLASS_BOX + assert _update(policy, 10.0, accel=True, skip_box=True) == (True, True) + assert _update(policy, 20.0, accel=True, skip_box=True) == (True, False) + # The skip ends with the warning. + assert _update(policy, 30.0, active=False, skip_box=True) == (False, False) + policy.add_preview(0xD2, 366, 400.0) + assert _update(policy, 400.0, skip_box=True) == (False, False) + + +def test_mobile_zone_skip_applies_only_when_mode_three_decelerates(): + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 361, 0.0) + assert _update(policy, 0.0, mobile_decel=True) == (False, False) + assert _update(policy, 5.0, accel=True, mobile_decel=True, skip_mobile=True) == (True, True) + + +def test_speed_change_starts_a_new_warning(): + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 361, 0.0) + _update(policy, 0.0, mobile_decel=True, skip_mobile=True, accel=True) + assert policy.skipped + policy.add_preview(0xB1, 305, 300.0) # 50 km/h signal camera + assert _update(policy, 300.0, speed=50, mobile_decel=True, skip_mobile=True, accel=True) == (False, False) + assert not policy.skipped and policy.warning_class == CLASS_HARD diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index 1eedff896f4..9d3dd6b1d15 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -4,14 +4,17 @@ import pytest from opendbc.can import CANParser -from opendbc.car import Bus +from opendbc.car import Bus, structs from opendbc.car.hyundai.carstate import ( CANFD_HDA_INFO_MSG, CANFD_NAVI_PROFILE_MSG, CANFD_NAVI_STATUS_MSG, CarState, VEHICLE_NAVI_POSITION_TIMEOUT_NS, VEHICLE_NAVI_ROUTE_TIMEOUT_NS, VEHICLE_NAVI_SCHOOL_ZONE_MAX_DISTANCE, VEHICLE_SPEED_CAMERA_PARAM_UPDATE_FRAMES, is_canfd_navi_camera_active, ) +from opendbc.car.hyundai.navi_speedcam import SpeedcamPolicy from opendbc.car.hyundai.values import CAR, HyundaiFlags +ButtonType = structs.CarState.ButtonEvent.Type + class FakeParams: def __init__(self, value=60, vehicle_navi=False, school_zone=False): @@ -26,9 +29,13 @@ def get_int(self, key): return self.value if key == "VehicleNaviCanControl": return int(self.vehicle_navi) + if key == "AutoNaviSpeedCtrlMode": + return 2 raise KeyError(key) def get_bool(self, key): + if key in ("VehicleNaviSkipBoxCamera", "VehicleNaviSkipMobileZone", "VehicleNaviSectionAvgControl"): + return False assert key == "VehicleNaviSchoolZoneControl" return self.school_zone @@ -69,6 +76,9 @@ def _car_state(distance_time_tenths=60): state.navi_position_4b4 = None state.navi_profile_4be = None state.hda_info_4a3 = None + state.speedcam_policy = SpeedcamPolicy() + state.speedcam_accel_swallow = False + state._read_speedcam_params() return state @@ -1211,3 +1221,91 @@ def test_vehicle_navi_section_end_camera_uses_logged_1997_meter_offset(): speed_ret = SimpleNamespace(vEgo=10.0, speedLimit=ret.speedLimit, gasPressed=False) state.update_speed_limit(speed_ret, speed_limit_cam=True) assert speed_ret.speedLimitDistance == pytest.approx(1997.0 - 10.0 * 0.01) + + +def _speedcam_state(): + state = _car_state() + state.vehicleNaviCanControl = 1 + return state + + +def test_stock_mobile_zone_warning_is_ignored_below_navi_mode_three(): + state = _speedcam_state() + state.speedcam_policy.add_preview(0xD3, 361, 0.0) # kind 3 mobile zone at the 60 km/h lead + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False) + + speed_limit_cam = state._apply_speedcam_policy(ret, True, True, 60.0, False) + state.update_speed_limit(ret, speed_limit_cam) + assert not speed_limit_cam + assert ret.speedLimitDistance == 0.0 + + state.speedcamMobileZoneDecel = True # AutoNaviSpeedCtrlMode 3 + assert state._apply_speedcam_policy(ret, True, True, 60.0, False) + + +def test_box_camera_skip_consumes_one_accel_press(): + state = _speedcam_state() + state.speedcamSkipBox = True + state.speedcam_policy.add_preview(0xD2, 366, 0.0) # kind 2 box at the 60 km/h lead + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False) + + assert state._apply_speedcam_policy(ret, True, True, 60.0, False) + assert not state._apply_speedcam_policy(ret, True, True, 60.0, True) + assert state.speedcam_accel_swallow + + press = [SimpleNamespace(type=ButtonType.accelCruise, pressed=True), SimpleNamespace(type=ButtonType.gapAdjustCruise, pressed=True)] + assert [event.type for event in state._swallow_speedcam_accel(press)] == [ButtonType.gapAdjustCruise] + release = [SimpleNamespace(type=ButtonType.accelCruise, pressed=False)] + assert state._swallow_speedcam_accel(release) == [] + assert not state.speedcam_accel_swallow + assert state._swallow_speedcam_accel(press) == press + + +def test_speedcam_skip_needs_driving_speed_and_leaves_fixed_cameras(): + state = _speedcam_state() + state.speedcamSkipBox = True + state.speedcam_policy.add_preview(0xD2, 366, 0.0) + slow = SimpleNamespace(vEgo=2.0, speedLimit=60.0, gasPressed=False) + assert state._apply_speedcam_policy(slow, True, True, 60.0, True) + assert not state.speedcam_accel_swallow + + fixed = _speedcam_state() + fixed.speedcamSkipBox = fixed.speedcamSkipMobileZone = True + fixed.speedcam_policy.add_preview(0xD0, 366, 0.0) # kind 0 fixed camera + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False) + assert fixed._apply_speedcam_policy(ret, True, True, 60.0, True) + assert not fixed.speedcam_accel_swallow + + +def test_speedcam_policy_leaves_pv5_and_disabled_navi_untouched(): + state = _speedcam_state() + state.canfd_wrapped_navi = True + state.speedcam_policy.add_preview(0xD3, 361, 0.0) + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False) + assert state._apply_speedcam_policy(ret, True, True, 60.0, False) + + state = _car_state() # VehicleNaviCanControl 0 + state.speedcam_policy.add_preview(0xD3, 361, 0.0) + assert state._apply_speedcam_policy(ret, True, True, 60.0, False) + + +def test_section_average_keeps_only_the_camera_ahead_as_spot_distance(): + state = _speedcam_state() + state.speedcamSectionAvgControl = True + state.totalDistance = 1000.0 + state.vehicleNaviEvents = [{"type": "camera", "speed": 80, "kind": 0, "target": 900.0}, + {"type": "camera", "speed": 80, "kind": 0, "target": 2500.0}, + {"type": "camera", "speed": 60, "kind": 0, "target": 1200.0}] + ret = SimpleNamespace(vehicleNaviSectionActive=True, vehicleNaviSpeed=80.0, speedLimitDistance=1.0) + state._apply_section_average_distance(ret) + assert ret.speedLimitDistance == pytest.approx(1500.0) + + state.vehicleNaviEvents = [] + ret.speedLimitDistance = 1.0 + state._apply_section_average_distance(ret) + assert ret.speedLimitDistance == 0.0 # no virtual distance inside the section + + state.speedcamSectionAvgControl = False + ret.speedLimitDistance = 1.0 + state._apply_section_average_distance(ret) + assert ret.speedLimitDistance == 1.0 diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index c2886657371..1db2540c81b 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -242,6 +242,10 @@ inline static std::unordered_map keys = { {"AutoNaviSpeedCtrlMode", {PERSISTENT, INT, "2"}}, {"VehicleNaviCanControl", {PERSISTENT, INT, "0"}}, {"VehicleNaviSchoolZoneControl", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviSkipBoxCamera", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviSkipMobileZone", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviSectionAvgMargin", {PERSISTENT, INT, "3"}}, {"VehicleSpeedCameraControlMode", {PERSISTENT, INT, "1"}}, {"VehicleSpeedCameraDistanceTime", {PERSISTENT, INT, "60"}}, {"AutoRoadSpeedLimitOffset", {PERSISTENT, INT, "-1"}}, diff --git a/openpilot/selfdrive/carrot/carrot_serv.py b/openpilot/selfdrive/carrot/carrot_serv.py index 0efc5c8bcfa..ea411071bcb 100644 --- a/openpilot/selfdrive/carrot/carrot_serv.py +++ b/openpilot/selfdrive/carrot/carrot_serv.py @@ -20,6 +20,7 @@ from openpilot.common.constants import CV from openpilot.common.gps import get_gps_location_service from openpilot.selfdrive.carrot.carrot_navi_control import CarrotNaviControl, parse_carrot_navi_control +from openpilot.selfdrive.carrot.section_average import SectionAverage nav_type_mapping = { 12: ("turn", "left", 1), @@ -163,6 +164,7 @@ def __init__(self): self.xSpdDist = 0 self.xSpdType = -1 self.rear_camera_events = [] + self.section_average = SectionAverage() self.xTurnInfo = -1 self.xDistToTurn = 0 @@ -232,6 +234,8 @@ def update_params(self): self.vehicleNaviCanControl = min(3, max(0, self.params.get_int("VehicleNaviCanControl"))) self.vehicleNaviSchoolZoneControl = self.params.get_bool("VehicleNaviSchoolZoneControl") self.vehicleSpeedCameraControlMode = min(3, max(0, self.params.get_int("VehicleSpeedCameraControlMode"))) + self.vehicleNaviSectionAvgControl = self.params.get_bool("VehicleNaviSectionAvgControl") + self.vehicleNaviSectionAvgMargin = min(10, max(0, self.params.get_int("VehicleNaviSectionAvgMargin"))) self.autoNaviSpeedSafetyFactor = float(self.params.get_int("AutoNaviSpeedSafetyFactor")) * 0.01 self.autoNaviSpeedDecelRate = float(self.params.get_int("AutoNaviSpeedDecelRate")) * 0.01 self.autoNaviCountDownMode = self.params.get_int("AutoNaviCountDownMode") @@ -1426,8 +1430,16 @@ def update_navi(self, remote_ip, sm, pm, vturn_speed, coords, distances, route_s vehicle_school_zone_speed = self._vehicle_school_zone_speed(CS) if vehicle_school_zone_speed < 250: self.active_carrot = 6 - if self._vehicle_section_zone_enabled(CS): + section_zone_enabled = self._vehicle_section_zone_enabled(CS) + now = time.monotonic() + self.section_average.update(self.vehicleNaviSectionAvgControl and section_zone_enabled, + CS.vehicleNaviSpeed, now, self.totalDistance) + if section_zone_enabled: vehicle_section_zone_speed = CS.vehicleNaviSpeed * self.autoNaviSpeedSafetyFactor + if self.vehicleNaviSectionAvgControl: + # Time banked below the section average may be spent up to the cruise set speed. + allowance = self.section_average.allowance_kph(now, self.totalDistance, self.vehicleNaviSectionAvgMargin) + vehicle_section_zone_speed = max(vehicle_section_zone_speed, allowance * self.autoNaviSpeedSafetyFactor) self.active_carrot = 4 #print(f"sdi_speed: {sdi_speed}, vehicle_speed_camera_active: {vehicle_speed_camera_active}, xSpdType: {self.xSpdType}, xSpdDist: {self.xSpdDist}, active_carrot: {self.active_carrot}, v_ego_kph: {v_ego_kph}, nRoadLimitSpeed: {self.nRoadLimitSpeed}") diff --git a/openpilot/selfdrive/carrot/section_average.py b/openpilot/selfdrive/carrot/section_average.py new file mode 100644 index 00000000000..54ef25aa56d --- /dev/null +++ b/openpilot/selfdrive/carrot/section_average.py @@ -0,0 +1,59 @@ +"""Section-camera average-speed allowance for stock-navigation CAN (joongyu01/speedcam). + +Section enforcement measures the average between the start and end cameras, so +time banked below the target average (e.g. in congestion) may be spent later. +The bank is B = elapsed_time - distance / target_speed. B >= 0 means the running +average is at or below the target, whatever the remaining section length is. +The allowance spends B over a fixed horizon and returns to the normal section cap +as B runs out, so it never lowers the existing cap. +""" + +SECTION_END_DEBOUNCE_S = 3.0 +SPEND_HORIZON_M = 1000.0 +MIN_BANK_S = 0.5 +MAX_ALLOWANCE_KPH = 250.0 + + +class SectionAverage: + def __init__(self): + self.reset() + + def reset(self): + self.active = False + self.limit_kph = 0.0 + self.start_time = 0.0 + self.start_distance = 0.0 + self.inactive_since = None + + def update(self, section_active, limit_kph, now, total_distance): + if section_active and limit_kph > 0: + self.inactive_since = None + if not self.active or limit_kph != self.limit_kph: + self.active = True + self.limit_kph = limit_kph + self.start_time = now + self.start_distance = total_distance + elif self.active: + # 0x4B4 can drop for a frame; a real section end or exit lasts longer. + if self.inactive_since is None: + self.inactive_since = now + elif now - self.inactive_since >= SECTION_END_DEBOUNCE_S: + self.reset() + + def _target_ms(self, margin_kph): + return max(1.0, (self.limit_kph - margin_kph) / 3.6) + + def bank_seconds(self, now, total_distance, margin_kph): + if not self.active: + return 0.0 + return (now - self.start_time) - (total_distance - self.start_distance) / self._target_ms(margin_kph) + + def allowance_kph(self, now, total_distance, margin_kph): + """Speed (km/h, actual) that spends the bank over SPEND_HORIZON_M; 0 when there is none.""" + bank = self.bank_seconds(now, total_distance, margin_kph) + if bank <= MIN_BANK_S: + return 0.0 + remaining_time = SPEND_HORIZON_M / self._target_ms(margin_kph) - bank + if remaining_time <= SPEND_HORIZON_M / (MAX_ALLOWANCE_KPH / 3.6): + return MAX_ALLOWANCE_KPH + return min(MAX_ALLOWANCE_KPH, SPEND_HORIZON_M / remaining_time * 3.6) diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index 1111e353056..b883c3625c7 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -364,6 +364,29 @@ def test_vehicle_navi_school_zone_control_is_opt_in(settings, params): assert '{"VehicleNaviSchoolZoneControl", {PERSISTENT, BOOL, "0"}}' in params_keys +def test_speedcam_skip_and_section_average_settings_are_opt_in(settings, params): + by_name = {p["name"]: p for p in params} + toggles = ("VehicleNaviSkipBoxCamera", "VehicleNaviSkipMobileZone", "VehicleNaviSectionAvgControl") + for name in toggles: + control = by_name[name] + assert (control["min"], control["max"], control["default"]) == (0, 1, 0) + assert control["control"] == "toggle" + assert control["risk"] == "high" + margin = by_name["VehicleNaviSectionAvgMargin"] + assert (margin["min"], margin["max"], margin["default"], margin["unit"]) == (0, 10, 3, 1) + assert margin["display_unit"] == "speedKph" + + driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") + speed = next(group for group in driving["groups"] if group["id"] == "SPEED") + camera = next(group for group in speed["groups"] if group["id"] == "SPEED_CAMERA") + params_keys = PARAMS_KEYS_PATH.read_text(encoding="utf-8") + for name in (*toggles, "VehicleNaviSectionAvgMargin"): + assert name in camera["params"] + for name in toggles: + assert f'{{"{name}", {{PERSISTENT, BOOL, "0"}}}}' in params_keys + assert '{"VehicleNaviSectionAvgMargin", {PERSISTENT, INT, "3"}}' in params_keys + + def test_vehicle_navi_curve_control_settings_are_removed(settings): driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") speed = next(group for group in driving["groups"] if group["id"] == "SPEED") diff --git a/openpilot/selfdrive/carrot/tests/test_section_average.py b/openpilot/selfdrive/carrot/tests/test_section_average.py new file mode 100644 index 00000000000..27fe50e6d76 --- /dev/null +++ b/openpilot/selfdrive/carrot/tests/test_section_average.py @@ -0,0 +1,67 @@ +import pytest + +from openpilot.selfdrive.carrot.section_average import SECTION_END_DEBOUNCE_S, SectionAverage + + +def _drive(avg, speeds_kph, dt=0.05, margin=0, start_time=0.0, start_distance=0.0, limit=80): + """Integrate a speed profile; the allowance caps each step like carrot_serv does.""" + now, distance = start_time, start_distance + for requested in speeds_kph: + avg.update(True, limit, now, distance) + allowance = avg.allowance_kph(now, distance, margin) + speed = min(requested, max(limit, allowance)) + now += dt + distance += speed / 3.6 * dt + return now, distance + + +def test_no_bank_means_no_allowance(): + avg = SectionAverage() + avg.update(True, 80, 0.0, 0.0) + assert avg.allowance_kph(10.0, 80 / 3.6 * 10.0, 0) == 0.0 + + +def test_congestion_banks_time_and_allows_more_than_the_limit(): + avg = SectionAverage() + now, distance = _drive(avg, [30] * int(60 / 0.05)) # one minute at 30 km/h + assert avg.bank_seconds(now, distance, 0) == pytest.approx(60 - 500 / (80 / 3.6), rel=0.01) + assert avg.allowance_kph(now, distance, 0) > 80 + + +def test_running_average_never_exceeds_the_target_while_spending_the_bank(): + avg = SectionAverage() + now, distance = _drive(avg, [20] * int(90 / 0.05)) # congestion + for _ in range(int(600 / 0.05)): # then the driver asks for 130 + avg.update(True, 80, now, distance) + speed = min(130, max(80, avg.allowance_kph(now, distance, 0))) + if avg.allowance_kph(now, distance, 0) == 0.0: + break # bank spent: back to the normal cap + now += 0.05 + distance += speed / 3.6 * 0.05 + assert distance / now * 3.6 <= 80 + 0.5 + assert avg.bank_seconds(now, distance, 0) <= 1.0 + + +def test_margin_lowers_the_target_average(): + avg = SectionAverage() + now, distance = _drive(avg, [77] * int(60 / 0.05)) + assert avg.allowance_kph(now, distance, 0) > 0.0 # 77 < 80 banks time + assert avg.allowance_kph(now, distance, 3) == 0.0 # 77 = 80 - 3 banks nothing + + +def test_short_dropout_keeps_the_section_but_a_real_exit_resets(): + avg = SectionAverage() + avg.update(True, 80, 0.0, 0.0) + avg.update(False, 0, 10.0, 100.0) + avg.update(True, 80, 10.0 + SECTION_END_DEBOUNCE_S / 2, 110.0) + assert avg.active and avg.start_time == 0.0 + avg.update(False, 0, 20.0, 200.0) + avg.update(False, 0, 20.0 + SECTION_END_DEBOUNCE_S, 210.0) + assert not avg.active + + +def test_limit_change_restarts_the_section(): + avg = SectionAverage() + avg.update(True, 80, 0.0, 0.0) + avg.update(True, 100, 30.0, 500.0) + assert (avg.limit_kph, avg.start_time, avg.start_distance) == (100, 30.0, 500.0) diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index fc01ac801e2..39f8e2d2f27 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -223,6 +223,10 @@ "params": [ "VehicleNaviCanControl", "VehicleNaviSchoolZoneControl", + "VehicleNaviSkipBoxCamera", + "VehicleNaviSkipMobileZone", + "VehicleNaviSectionAvgControl", + "VehicleNaviSectionAvgMargin", "VehicleSpeedCameraControlMode", "VehicleSpeedCameraDistanceTime", "AutoNaviSpeedCtrlMode", @@ -3445,13 +3449,13 @@ "group": "감속제어", "name": "AutoNaviSpeedCtrlMode", "title": "네비게이션감속모드(2)", - "descr": "0: 감속안함\n1: 과속카메라\n2: 과속카메라+과속방지턱\n3: 과속카메라+과속방지턱+이동식카메라", + "descr": "0: 감속안함\n1: 과속카메라\n2: 과속카메라+과속방지턱\n3: 과속카메라+과속방지턱+이동식카메라\n순정 내비 CAN(VehicleNaviCanControl)의 이동식 단속 구간(내비 파란 원)도 이 값을 따릅니다. 3이면 감속하고, 2 이하이면 이동식 구간 경고를 무시합니다. 같은 경고에 고정식·신호·후면 카메라가 함께 있으면 항상 감속합니다.", "egroup": "SPEED", "etitle": "NaviSpeedControlMode(2)", - "edescr": "0: Not Use, 1: Speed cam, 2: cam + speed bump, 3: cam + bump + apn speed cam", + "edescr": "0: Not Use, 1: Speed cam, 2: cam + speed bump, 3: cam + bump + apn speed cam\nStock-navigation CAN mobile enforcement zones (blue circle, VehicleNaviCanControl) follow this value too: 3 decelerates and 2 or lower ignores the mobile-zone warning. A warning that also carries a fixed, signal or rear camera always decelerates.", "cgroup": "速度控制", "ctitle": "导航减速模式 (2)", - "cdescr": "0: 不使用, 1: 限速摄像头, 2: 摄像头 + 减速带, 3: 摄像头 + 减速带 + APN 限速摄像头", + "cdescr": "0: 不使用, 1: 限速摄像头, 2: 摄像头 + 减速带, 3: 摄像头 + 减速带 + APN 限速摄像头\n原车导航 CAN(VehicleNaviCanControl)的移动测速区间(导航蓝色圆圈)也遵循此值:3 时减速,2 及以下忽略移动区间警告。同一警告中若同时存在固定、闯红灯或后向摄像头,则始终减速。", "min": 0, "max": 3, "default": 2, @@ -3513,6 +3517,78 @@ "control": "toggle", "risk": "high" }, + { + "group": "감속제어", + "name": "VehicleNaviSkipBoxCamera", + "title": "차량 내비 박스 카메라 + 버튼 건너뛰기", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN이 박스형(이동식 카메라 함) 카메라로 알린 경고에서 크루즈 + 버튼을 한 번 누르면 그 카메라의 감속을 해제하고 원래 속도로 돌아갑니다. 이 누름은 설정 속도를 올리지 않습니다. 경고가 끝나면 다음 카메라에는 다시 감속합니다. 고정식·신호·후면 카메라는 건너뛸 수 없습니다. 박스 종류 판별은 실차 라벨로 아직 확정되지 않은 실험 기능입니다.", + "egroup": "SPEED", + "etitle": "Stock Navigation Box Camera + Button Skip", + "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN warning for a box-type (mobile camera housing) camera, one press of cruise + releases that camera's deceleration and returns to the previous speed. The press does not raise the set speed. The next camera decelerates again after the warning ends. Fixed, signal and rear cameras cannot be skipped. Box detection is experimental and not yet confirmed by labelled drives.", + "cgroup": "速度控制", + "ctitle": "原车导航箱式摄像头 + 键跳过", + "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 提示箱式(移动摄像头箱)摄像头时,按一次巡航 + 键即解除该摄像头的减速并恢复原速度。此按键不会提高设定速度。警告结束后,下一个摄像头仍会减速。固定、闯红灯和后向摄像头不能跳过。箱式判别为实验功能,尚未经实车标注确认。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high" + }, + { + "group": "감속제어", + "name": "VehicleNaviSkipMobileZone", + "title": "차량 내비 이동식 구간 + 버튼 건너뛰기", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 네비게이션감속모드(AutoNaviSpeedCtrlMode)가 3이라 순정 내비 CAN 이동식 단속 구간(내비 파란 원)에서 감속할 때, 크루즈 + 버튼을 한 번 누르면 그 구간의 감속을 해제하고 원래 속도로 돌아갑니다. 이 누름은 설정 속도를 올리지 않습니다. 모드가 2 이하이면 이동식 구간은 처음부터 감속하지 않으므로 이 설정은 쓰이지 않습니다. 같은 경고에 고정식·신호·후면 카메라가 함께 있으면 건너뛸 수 없습니다.", + "egroup": "SPEED", + "etitle": "Stock Navigation Mobile Zone + Button Skip", + "edescr": "Applies only when external navigation is disconnected. When AutoNaviSpeedCtrlMode is 3 and a stock-navigation CAN mobile enforcement zone (blue circle) decelerates, one press of cruise + releases that zone's deceleration and returns to the previous speed. The press does not raise the set speed. With mode 2 or lower mobile zones never decelerate, so this setting is unused. A warning that also carries a fixed, signal or rear camera cannot be skipped.", + "cgroup": "速度控制", + "ctitle": "原车导航移动测速区间 + 键跳过", + "cdescr": "仅在外部导航未连接时应用。导航减速模式(AutoNaviSpeedCtrlMode)为 3 且原车导航 CAN 移动测速区间(导航蓝色圆圈)正在减速时,按一次巡航 + 键即解除该区间的减速并恢复原速度。此按键不会提高设定速度。模式为 2 及以下时移动区间本就不减速,此设置不生效。同一警告中若同时存在固定、闯红灯或后向摄像头,则不能跳过。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high" + }, + { + "group": "감속제어", + "name": "VehicleNaviSectionAvgControl", + "title": "차량 내비 구간단속 평균속도 제어", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN 구간단속 중 구간 내내 제한속도로 묶지 않고, 구간 진입 후의 평균속도가 (제한속도 − 여유속도) 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 평균이 낮아진 만큼만 더 빨리 갈 수 있고, 적립된 여유가 없으면 기존 구간 제한과 같습니다. 시점·종점 카메라에서는 지점 감속을 그대로 합니다(종점은 약 2km 전부터 알 수 있음). 구간 시작은 차량 평균속도 신호가 켜진 시점으로 잡으며 실제 시점 카메라와 차이가 있을 수 있는 실험 기능입니다.", + "egroup": "SPEED", + "etitle": "Stock Navigation Section Average-speed Control", + "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN section camera, instead of holding the section limit throughout, allow up to the cruise set speed only while the average since entering the section stays at or below (limit - margin). Only time banked below the target (for example in congestion) can be spent; without it the cap equals the existing section limit. Start and end cameras still decelerate to the spot limit (the end is known about 2 km ahead). The section start is taken when the vehicle average-speed signal appears and may differ from the actual start camera; experimental.", + "cgroup": "速度控制", + "ctitle": "原车导航区间测速平均速度控制", + "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 区间测速期间,不再全程限制在限速,而是仅在进入区间后的平均速度保持在(限速 − 余量)以下的范围内允许达到巡航设定速度。只能使用因拥堵等积累的余量;没有余量时与原区间限速相同。起点和终点摄像头仍按点位减速(终点约提前 2km 获知)。区间起点取车辆平均速度信号出现的时刻,可能与实际起点摄像头有偏差,为实验功能。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high" + }, + { + "group": "감속제어", + "name": "VehicleNaviSectionAvgMargin", + "title": "구간단속 평균속도 여유(3km/h)", + "descr": "구간단속 평균속도 제어(VehicleNaviSectionAvgControl)가 목표로 삼는 평균속도를 제한속도보다 이만큼 낮게 잡습니다. 주행거리 오차와 구간 시작 판정 차이를 흡수합니다. 값을 높이면 더 안전하지만 더 빨리 갈 수 있는 여유가 줄어듭니다.", + "egroup": "SPEED", + "etitle": "Section Average-speed Margin(3km/h)", + "edescr": "Section average-speed control (VehicleNaviSectionAvgControl) targets an average this much below the section limit, absorbing odometer error and section-start timing. A higher value is safer but leaves less time to spend.", + "cgroup": "速度控制", + "ctitle": "区间测速平均速度余量 (3km/h)", + "cdescr": "区间测速平均速度控制(VehicleNaviSectionAvgControl)的目标平均速度比限速低此数值,用于吸收里程误差和区间起点判定偏差。数值越高越安全,但可提速的余量越少。", + "min": 0, + "max": 10, + "default": 3, + "unit": 1, + "display_unit": "speedKph", + "risk": "high" + }, { "group": "감속제어", "name": "VehicleSpeedCameraControlMode", From fe135cd1ea0fa075862a71dd649392a833350fd4 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:35:12 +0900 Subject: [PATCH 02/50] hyundai: fix button events zeroed by speedcam filter `ret.buttonEvents = self._swallow_speedcam_accel(ret.buttonEvents)` re-assigned the capnp list field from itself. pycapnp clears the field before copying, so every button event became unknown/released: steering-wheel buttons stopped working, cruiseState.available flickered (wrongCarMode) and Bluetooth remote commands were dropped while those events were present. Filter the Python list before the single assignment and add a regression test on a real capnp CarState builder. Co-Authored-By: Claude Opus 5.5 --- opendbc_repo/opendbc/car/hyundai/carstate.py | 10 ++++++---- .../car/hyundai/tests/test_speed_camera.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index a899074437c..5d30b2d05bb 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -1448,10 +1448,12 @@ def corner_max(signal): elif self.gear_msg_canfd == "GEAR": paddle_button = 1 if cp.vl["GEAR"]["LEFT_PADDLE"] == 1 else 2 if cp.vl["GEAR"]["RIGHT_PADDLE"] == 1 else 0 - ret.buttonEvents = [*create_button_events(self.cruise_buttons[-1], prev_cruise_buttons, BUTTONS_DICT), - *create_button_events(paddle_button, self.paddle_button_prev, {1: ButtonType.paddleLeft, 2: ButtonType.paddleRight}), - *create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise})] - ret.buttonEvents = self._swallow_speedcam_accel(ret.buttonEvents) + # Filter the Python list before the single capnp assignment: re-assigning a + # capnp list field from itself clears the source first and zeroes every event. + ret.buttonEvents = self._swallow_speedcam_accel([ + *create_button_events(self.cruise_buttons[-1], prev_cruise_buttons, BUTTONS_DICT), + *create_button_events(paddle_button, self.paddle_button_prev, {1: ButtonType.paddleLeft, 2: ButtonType.paddleRight}), + *create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise})]) self.paddle_button_prev = paddle_button diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index 9d3dd6b1d15..c0c360cd487 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -1,4 +1,5 @@ import math +from pathlib import Path from types import SimpleNamespace import pytest @@ -1261,6 +1262,20 @@ def test_box_camera_skip_consumes_one_accel_press(): assert state._swallow_speedcam_accel(press) == press +def test_speedcam_button_filter_keeps_capnp_button_events_intact(): + # Regression: `ret.buttonEvents = f(ret.buttonEvents)` on a capnp builder clears the + # source list first, so every button became unknown/released on the car. + state = _speedcam_state() + ret = structs.CarState() + ret.buttonEvents = state._swallow_speedcam_accel([ + structs.CarState.ButtonEvent(type=ButtonType.accelCruise, pressed=True), + structs.CarState.ButtonEvent(type=ButtonType.paddleLeft, pressed=False)]) + assert [(str(b.type), b.pressed) for b in ret.buttonEvents] == [("accelCruise", True), ("paddleLeft", False)] + + source = (Path(__file__).resolve().parents[1] / "carstate.py").read_text(encoding="utf-8") + assert "_swallow_speedcam_accel(ret.buttonEvents)" not in source + + def test_speedcam_skip_needs_driving_speed_and_leaves_fixed_cameras(): state = _speedcam_state() state.speedcamSkipBox = True From 4095894e8fc3e76b2b5036e1664bbe0a6ba4cff9 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:37:11 +0900 Subject: [PATCH 03/50] hyundai: document rear flag as kind-independent (rear speed = kind 0) A labelled rear speed camera in a 30 km/h school zone arrived as 0x64270: kind 0 with the same 0x321 flag seen on rear signal-and-speed cameras (kind 1). Record the flag as kind-independent and cover it in tests/docs. Behaviour is unchanged: any flagged preview already always decelerates. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/speed-deceleration.md | 3 ++- docs/user/ko/speed-deceleration.md | 3 ++- opendbc_repo/opendbc/car/hyundai/navi_speedcam.py | 4 +++- opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py | 2 ++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index 947f02f7d04..9e0df4975a7 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -94,7 +94,8 @@ The current stock warning (0x4A3) carries no camera kind. The kind of the previe | Preview kind | Navigation icon | Handling | |---|---|---| | Fixed speed camera | Red square | Always decelerates; no skip | -| Signal-and-speed, rear signal-and-speed | Traffic-light icon | Always decelerates; no skip | +| Signal-and-speed | Traffic-light icon | Always decelerates; no skip | +| Rear speed, rear signal-and-speed | — | Always decelerates; no skip (identified by the preview's rear flag) | | Mobile enforcement zone | Blue circle | Decelerates when `AutoNaviSpeedCtrlMode` is `3`; ignored at `2` or lower | | Box (presumed) | — | Decelerates; skippable with `VehicleNaviSkipBoxCamera` | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index 50ed569fa57..ac598433ef1 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -96,7 +96,8 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | 예고 종류 | 내비 표시 | 처리 | |---|---|---| | 고정식 과속 | 빨간 사각 | 항상 감속, 건너뛰기 없음 | -| 신호·과속, 후면 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | +| 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | +| 후면 과속, 후면 신호·과속 | — | 항상 감속, 건너뛰기 없음 (예고의 후면 표시로 구분) | | 이동식 단속 구간 | 파란 원 | `AutoNaviSpeedCtrlMode`가 `3`이면 감속, `2` 이하면 무시 | | 박스형(추정) | — | 감속, `VehicleNaviSkipBoxCamera`로 건너뛰기 가능 | diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index b16d19629a0..406e1bdab4b 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -8,7 +8,9 @@ kind 1 signal-and-speed camera kind 2 unconfirmed spot camera (likely a mobile-camera box) kind 3 mobile enforcement zone (blue circle; enforcement anywhere in the zone) - value >> 9 == 0x321 on kind 1: rear signal-and-speed camera + value >> 9 == 0x321: rear-facing flag, independent of the kind + kind 0 + 0x321 rear speed camera (TMAP 75) + kind 1 + 0x321 rear signal-and-speed camera (TMAP 76) 0x4A3 only says "a camera warning is active" without a kind, so this policy attaches the kind of the nearby same-speed preview to the current warning. diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index 9e3e17d45af..7bd52471bf8 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -13,6 +13,8 @@ def test_decode_uses_low_nine_bits_and_keeps_rear_flag(): # Labelled rear signal-and-speed cameras: kind 1 with 0x321 above bit 9. assert decode_camera_profile(0x64291, 487) == (1, 40, True) assert decode_camera_profile(0x64271, 791) == (1, 30, True) + # Labelled rear speed camera in a 30 km/h school zone: kind 0 with the same flag. + assert decode_camera_profile(0x64270, 78) == (0, 30, True) assert decode_camera_profile(0x6, 118) is None # speed bump assert decode_camera_profile(0xD7, 0) is None # kind 7 zone entry assert decode_camera_profile(0xFFFFFFFF, 8191) is None From d962410b816155ca0287b26bd8ddd401845a3370 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:55:14 +0900 Subject: [PATCH 04/50] hyundai: confirm stock-navigation kind 2 as mobile-camera box A labelled mobile-camera box at 100 km/h arrived as preview 0x152 (kind 2) and matched its warning within 1 m of the expected lead, ending at the camera like other spot cameras. Drop the "presumed/experimental" wording for box detection in the policy notes, settings descriptions and docs. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/speed-deceleration.md | 4 ++-- docs/user/ko/speed-deceleration.md | 4 ++-- opendbc_repo/opendbc/car/hyundai/navi_speedcam.py | 2 +- openpilot/selfdrive/carrot_settings.json | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index 9e0df4975a7..a4d1328fec0 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -97,9 +97,9 @@ The current stock warning (0x4A3) carries no camera kind. The kind of the previe | Signal-and-speed | Traffic-light icon | Always decelerates; no skip | | Rear speed, rear signal-and-speed | — | Always decelerates; no skip (identified by the preview's rear flag) | | Mobile enforcement zone | Blue circle | Decelerates when `AutoNaviSpeedCtrlMode` is `3`; ignored at `2` or lower | -| Box (presumed) | — | Decelerates; skippable with `VehicleNaviSkipBoxCamera` | +| Mobile-camera box | — | Decelerates; skippable with `VehicleNaviSkipBoxCamera` | -A warning that also carries a fixed, signal, or rear camera always decelerates, even inside a mobile zone. Box detection is not yet confirmed by labelled drives. +A warning that also carries a fixed, signal, or rear camera always decelerates, even inside a mobile zone. ### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index ac598433ef1..d35d5f0e333 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -99,9 +99,9 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | | 후면 과속, 후면 신호·과속 | — | 항상 감속, 건너뛰기 없음 (예고의 후면 표시로 구분) | | 이동식 단속 구간 | 파란 원 | `AutoNaviSpeedCtrlMode`가 `3`이면 감속, `2` 이하면 무시 | -| 박스형(추정) | — | 감속, `VehicleNaviSkipBoxCamera`로 건너뛰기 가능 | +| 이동식 박스 | — | 감속, `VehicleNaviSkipBoxCamera`로 건너뛰기 가능 | -같은 경고에 고정식·신호·후면 카메라가 함께 있으면 이동식 구간이어도 항상 감속합니다. 박스형 판별은 실차 라벨로 아직 확정되지 않았습니다. +같은 경고에 고정식·신호·후면 카메라가 함께 있으면 이동식 구간이어도 항상 감속합니다. ### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 406e1bdab4b..c437fd335f6 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -6,7 +6,7 @@ kind 0 fixed speed camera (also section start/end cameras) kind 1 signal-and-speed camera - kind 2 unconfirmed spot camera (likely a mobile-camera box) + kind 2 mobile-camera box (spot camera; labelled 2026-09-27, e.g. 0x152 = box @100 km/h) kind 3 mobile enforcement zone (blue circle; enforcement anywhere in the zone) value >> 9 == 0x321: rear-facing flag, independent of the kind kind 0 + 0x321 rear speed camera (TMAP 75) diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 39f8e2d2f27..2213002e2c6 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -3521,13 +3521,13 @@ "group": "감속제어", "name": "VehicleNaviSkipBoxCamera", "title": "차량 내비 박스 카메라 + 버튼 건너뛰기", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN이 박스형(이동식 카메라 함) 카메라로 알린 경고에서 크루즈 + 버튼을 한 번 누르면 그 카메라의 감속을 해제하고 원래 속도로 돌아갑니다. 이 누름은 설정 속도를 올리지 않습니다. 경고가 끝나면 다음 카메라에는 다시 감속합니다. 고정식·신호·후면 카메라는 건너뛸 수 없습니다. 박스 종류 판별은 실차 라벨로 아직 확정되지 않은 실험 기능입니다.", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN이 박스형(이동식 카메라 함) 카메라로 알린 경고에서 크루즈 + 버튼을 한 번 누르면 그 카메라의 감속을 해제하고 원래 속도로 돌아갑니다. 이 누름은 설정 속도를 올리지 않습니다. 경고가 끝나면 다음 카메라에는 다시 감속합니다. 고정식·신호·후면 카메라는 건너뛸 수 없습니다. 박스형은 순정 내비 예고 종류 2로 판별합니다(실차 라벨 확인).", "egroup": "SPEED", "etitle": "Stock Navigation Box Camera + Button Skip", - "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN warning for a box-type (mobile camera housing) camera, one press of cruise + releases that camera's deceleration and returns to the previous speed. The press does not raise the set speed. The next camera decelerates again after the warning ends. Fixed, signal and rear cameras cannot be skipped. Box detection is experimental and not yet confirmed by labelled drives.", + "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN warning for a box-type (mobile camera housing) camera, one press of cruise + releases that camera's deceleration and returns to the previous speed. The press does not raise the set speed. The next camera decelerates again after the warning ends. Fixed, signal and rear cameras cannot be skipped. Boxes are identified as stock-navigation preview kind 2 (confirmed by labelled drives).", "cgroup": "速度控制", "ctitle": "原车导航箱式摄像头 + 键跳过", - "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 提示箱式(移动摄像头箱)摄像头时,按一次巡航 + 键即解除该摄像头的减速并恢复原速度。此按键不会提高设定速度。警告结束后,下一个摄像头仍会减速。固定、闯红灯和后向摄像头不能跳过。箱式判别为实验功能,尚未经实车标注确认。", + "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 提示箱式(移动摄像头箱)摄像头时,按一次巡航 + 键即解除该摄像头的减速并恢复原速度。此按键不会提高设定速度。警告结束后,下一个摄像头仍会减速。固定、闯红灯和后向摄像头不能跳过。箱式根据原车导航预告类型 2 判别(已经实车标注确认)。", "min": 0, "max": 1, "default": 0, From 4012b38eea61a2217308dc290d16cdfe88980d77 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:35:39 +0900 Subject: [PATCH 05/50] speedcam: gas-tap skip, manual section unlock, drop margin setting - Camera skip (box / mobile zone) now also accepts a gas tap, and only while engaged; the spent input raises neither +1 nor the gas-tap +10 (card passes carstate's flag to cruise.py, which ignores that tap). - Section average control is now manual: the section stays capped at limit x AutoNaviSpeedSafetyFactor as before until one cruise + or gas tap (engaged, set speed above the cap) unlocks it. Unlocked, banked time below the limit may be spent up to the set speed; a long cruise - press locks it again and leaving the section resets it. - Move the section average bookkeeping into the Hyundai carstate (100 Hz inputs); carrot_serv returns to carrot-wip and keeps using vehicleNaviSpeed x factor as the section cap. - Remove VehicleNaviSectionAvgMargin; the target average is the limit. Verified by replaying real rlog CAN through the full CarInterface.update(): button events match the logged ones, a section replay is identical to the feature-off run until the tap, then unlocks, spends the bank and resets at the section end. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 10 +- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 10 +- opendbc_repo/opendbc/car/hyundai/carstate.py | 82 ++++++++++-- .../opendbc/car/hyundai}/section_average.py | 40 ++++-- .../car/hyundai/tests/test_section_average.py | 81 ++++++++++++ .../car/hyundai/tests/test_speed_camera.py | 121 +++++++++++++++--- openpilot/common/params_keys.h | 1 - openpilot/selfdrive/car/card.py | 6 + openpilot/selfdrive/car/cruise.py | 3 +- openpilot/selfdrive/carrot/carrot_serv.py | 14 +- .../server/tests/test_settings_schema.py | 9 +- .../carrot/tests/test_section_average.py | 67 ---------- openpilot/selfdrive/carrot_settings.json | 37 ++---- 15 files changed, 315 insertions(+), 170 deletions(-) rename {openpilot/selfdrive/carrot => opendbc_repo/opendbc/car/hyundai}/section_average.py (56%) create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py delete mode 100644 openpilot/selfdrive/carrot/tests/test_section_average.py diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 5b99a497f90..89f7513eb1f 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index a4d1328fec0..dca11f1af76 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -103,13 +103,13 @@ A warning that also carries a fixed, signal, or rear camera always decelerates, ### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` -When enabled, one press of cruise `+` while that kind of camera decelerates releases its deceleration. The press does not raise the set speed, and the next camera decelerates again after the warning ends. Below about 18 km/h, `+` keeps its normal resume/set-speed role. `VehicleNaviSkipMobileZone` matters only when `AutoNaviSpeedCtrlMode` is `3`, so mobile zones decelerate. Both are off by default. +When enabled, while engaged and that kind of camera decelerates, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) releases its deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the next camera decelerates again after the warning ends. While disengaged or below about 18 km/h, `+` and the gas tap keep their normal resume/engage/set-speed roles. `VehicleNaviSkipMobileZone` matters only when `AutoNaviSpeedCtrlMode` is `3`, so mobile zones decelerate. Both are off by default. -### `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin` +### `VehicleNaviSectionAvgControl` -When enabled, a stock-navigation section (vehicle average-speed signal present) no longer holds the section limit throughout. Up to the cruise set speed is allowed only while the average since entering the section stays at or below `limit − VehicleNaviSectionAvgMargin`. Only time banked by driving slower than that target (for example in congestion) can be spent later; without it the cap equals the existing section limit. The start camera still decelerates to the spot limit before the section, and the end camera does so after its preview about 2 km ahead. Inside the section no virtual camera distance is used when no end preview is known. +When enabled, a stock-navigation section (vehicle average-speed signal present) still stays capped at the section limit × `AutoNaviSpeedSafetyFactor` (for example 117 in a 110 section at 107%). While engaged and with the cruise set speed above that cap, one press of cruise `+` or a gas tap unlocks the section. Up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (for example in congestion) can be spent; without it, or once it is spent, the cap applies. The unlocking input does not raise the set speed (neither `+1` nor the gas-tap `+10`). A long press of cruise `-` (the −10 step) locks the section again, and the −10 still applies. Leaving the section resets it. -The average uses the vehicle odometer (`vEgo`). In a recorded drive the navigation-displayed average was 2–3 km/h higher than this calculation, so a margin of at least **3 km/h (default)** is recommended. The section start is taken when the average-speed signal appears and may differ from the actual start camera. Both settings are experimental. +Once unlocked, no virtual camera distance is used inside the section, and the end camera decelerates to the spot limit after its preview about 2 km ahead. The average uses the vehicle odometer (`vEgo`) with no margin. In a recorded drive the navigation-displayed average was 2–3 km/h higher than this calculation, so adjust the cruise set speed yourself if needed. The section start is taken when the average-speed signal appears; experimental. ### `VehicleSpeedCameraControlMode` diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 1b11668cc06..3e41c7e5421 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index d35d5f0e333..4c7813707a4 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -105,13 +105,13 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. ### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` -켜면 해당 종류의 카메라로 감속하는 동안 크루즈 `+` 버튼을 한 번 눌러 그 카메라의 감속을 해제합니다. 이 누름은 설정 속도를 올리지 않고, 경고가 끝나면 다음 카메라에는 다시 감속합니다. 약 18km/h 미만에서는 `+`가 원래 역할(재개·속도 설정)을 합니다. `VehicleNaviSkipMobileZone`은 `AutoNaviSpeedCtrlMode`가 `3`이라 이동식 구간에서 감속할 때만 의미가 있습니다. 둘 다 기본값은 꺼짐입니다. +켜면 openpilot 작동 중 해당 종류의 카메라로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 카메라의 감속을 해제합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 경고가 끝나면 다음 카메라에는 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만에서는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. `VehicleNaviSkipMobileZone`은 `AutoNaviSpeedCtrlMode`가 `3`이라 이동식 구간에서 감속할 때만 의미가 있습니다. 둘 다 기본값은 꺼짐입니다. -### `VehicleNaviSectionAvgControl`, `VehicleNaviSectionAvgMargin` +### `VehicleNaviSectionAvgControl` -켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중 구간 제한속도로 계속 묶지 않고, 구간 진입 후 평균속도가 `제한속도 − VehicleNaviSectionAvgMargin` 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 목표 평균보다 늦게 간 시간만 이후에 쓸 수 있으며, 적립된 시간이 없으면 기존 구간 제한과 같습니다. 시점 카메라는 구간 진입 전 지점 감속을 그대로 하고, 종점 카메라는 약 2km 전 예고를 받은 뒤 지점 감속합니다. 구간 안에서는 종점 예고가 없을 때 가상거리 감속을 쓰지 않습니다. +켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중에도 평소에는 구간 제한속도 × `AutoNaviSpeedSafetyFactor`로 고정합니다(예: 110 구간, 107% → 117). openpilot 작동 중이고 크루즈 설정 속도가 이 값보다 높을 때 크루즈 `+`를 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풉니다. 그 뒤로는 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없거나 다 쓰면 고정값과 같습니다. 해제에 쓴 입력은 설정 속도를 올리지 않습니다(`+1`, 악셀톡 `+10` 모두 반영 안 함). 크루즈 `-`를 길게 누르면(−10 동작) 다시 고정하며, 이때 −10은 그대로 반영됩니다. 구간이 끝나면 초기화됩니다. -평균은 차량 주행거리(`vEgo`)로 계산합니다. 기록 주행에서 내비가 표시한 평균이 이 계산보다 2~3km/h 높았으므로 여유속도는 **3km/h(기본값)** 이상을 권장합니다. 구간 시작은 평균속도 신호가 켜진 시점으로 잡아 실제 시점 카메라와 차이가 있을 수 있습니다. 두 설정 모두 실험 기능입니다. +해제 후에는 구간 안에서 가상거리 감속을 쓰지 않고, 종점 카메라는 약 2km 전 예고를 받은 뒤 지점 감속합니다. 평균은 차량 주행거리(`vEgo`)로 계산하며 여유를 두지 않습니다. 기록 주행에서 내비가 표시한 평균이 이 계산보다 2~3km/h 높았으므로 필요하면 크루즈 설정 속도로 직접 조절하세요. 구간 시작은 평균속도 신호가 켜진 시점으로 잡는 실험 기능입니다. ### `VehicleSpeedCameraControlMode` diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 5d30b2d05bb..a8f46df4819 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -10,6 +10,7 @@ from opendbc.car.carlog import carlog from opendbc.car.hyundai.hyundaicanfd import CanBus from opendbc.car.hyundai.navi_speedcam import SpeedcamPolicy +from opendbc.car.hyundai.section_average import SectionAverage from opendbc.car.hyundai.steering_touch import HyundaiSteeringTouch from opendbc.car.hyundai.values import HyundaiFlags, CAR, DBC, Buttons, CarControllerParams, CAMERA_SCC_CAR, HyundaiExtFlags, \ EV_MODE_ACTIVE_VALUES, EV_MODE_STATUS_ADDR, EV_MODE_STATUS_DLC, EV_MODE_STATUS_MSG, \ @@ -37,6 +38,7 @@ VEHICLE_NAVI_SCHOOL_ZONE_MAX_DISTANCE = 1000.0 VEHICLE_NAVI_POSITION_TIMEOUT_NS = 1_000_000_000 SPEEDCAM_SKIP_MIN_SPEED = 5.0 # m/s; below this + keeps its resume/set-speed role +SPEEDCAM_GAS_TOK_FRAMES = 40 # matches cruise.py _gas_tok_timer (0.4 s at 100 Hz) VEHICLE_NAVI_ROUTE_TIMEOUT_NS = 2_000_000_000 CANFD_HDA_INFO_MSG = "CANFD_HDA_INFO_364" CANFD_NAVI_PROFILE_MSG = "CANFD_NAVI_PROFILE_093" @@ -246,6 +248,14 @@ def __init__(self, CP): # joongyu01/speedcam: stock-navigation camera-kind policy (mobile zones, +-button skip, section average) self.speedcam_policy = SpeedcamPolicy() self.speedcam_accel_swallow = False + self.speedcam_engaged = False # set by card from carControl.enabled + self.speedcam_gas_count = 0 + self.speedcam_gas_tok_consumed = False # read by card, tells cruise.py to ignore this gas tap + self.speedcam_input_consumed = False + self.speedcam_v_cruise = 0.0 # set by card from the cruise set speed + self.speedcam_decel_count = 0 + self.speedcam_time = 0.0 + self.section_average = SectionAverage() self._read_speedcam_params() self.vehicleNaviSegmentTimestamp = 0 self.vehicleNaviProfileTimestamp = 0 @@ -715,25 +725,74 @@ def _read_speedcam_params(self): self.speedcamSkipBox = self.op_params.get_bool("VehicleNaviSkipBoxCamera") self.speedcamSkipMobileZone = self.op_params.get_bool("VehicleNaviSkipMobileZone") self.speedcamSectionAvgControl = self.op_params.get_bool("VehicleNaviSectionAvgControl") + # The existing section cap is limit x AutoNaviSpeedSafetyFactor (carrot_serv); unlock only above it. + self.speedcamSafetyFactor = self.op_params.get_int("AutoNaviSpeedSafetyFactor") * 0.01 + self.speedcamLongPressFrames = self.op_params.get_int("CruiseButtonLongDelay") # cruise.py long press + + def _update_speedcam_decel_long_press(self): + """True once when cruise - has been held past cruise.py's long-press threshold (the -10 step).""" + if self.cruise_buttons[-1] != Buttons.SET_DECEL: + self.speedcam_decel_count = 0 + return False + self.speedcam_decel_count += 1 + return self.speedcam_decel_count == self.speedcamLongPressFrames + 1 - def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_speed, accel_rising): - """Suppress a stock camera warning for ignored mobile zones or a +-button skip.""" + def _update_speedcam_gas_tok(self, gas_pressed): + """Mirror cruise.py's gas tap: pressed for fewer than SPEEDCAM_GAS_TOK_FRAMES, reported on release.""" + if gas_pressed: + self.speedcam_gas_count += 1 + return False + gas_tok = 0 < self.speedcam_gas_count < SPEEDCAM_GAS_TOK_FRAMES + self.speedcam_gas_count = 0 + return gas_tok + + def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_speed, accel_rising, gas_tok=False): + """Suppress a stock camera warning for ignored mobile zones or a +-button / gas-tap skip.""" + self.speedcam_gas_tok_consumed = False + self.speedcam_input_consumed = False if self.canfd_wrapped_navi or not self.vehicleNaviCanControl: self.speedcam_policy.reset_warning() return speed_limit_cam + # Only while engaged: otherwise + and a gas tap keep their resume/engage roles. + can_skip = self.speedcam_engaged and ret.vEgo > SPEEDCAM_SKIP_MIN_SPEED suppress, consume = self.speedcam_policy.update( - warning_active, int(round(warning_speed)), self.totalDistance, accel_rising and ret.vEgo > SPEEDCAM_SKIP_MIN_SPEED, + warning_active, int(round(warning_speed)), self.totalDistance, can_skip and (accel_rising or gas_tok), self.speedcamMobileZoneDecel, self.speedcamSkipBox, self.speedcamSkipMobileZone) if consume: - # The press that skips a camera must not also raise the cruise set speed. - self.speedcam_accel_swallow = True + self._consume_speedcam_input(accel_rising, gas_tok) return False if suppress else speed_limit_cam - def _apply_section_average_distance(self, ret): - """With section-average control, keep spot deceleration only for a same-speed camera ahead (the end camera).""" - if self.canfd_wrapped_navi or not (self.speedcamSectionAvgControl and self.vehicleNaviCanControl and ret.vehicleNaviSectionActive): + def _consume_speedcam_input(self, accel_rising, gas_tok): + # The input spent on a camera skip or section unlock must not also raise the set speed (+1 / gas tap +10). + self.speedcam_input_consumed = True + self.speedcam_accel_swallow = accel_rising + self.speedcam_gas_tok_consumed = gas_tok and not accel_rising + + def _apply_section_average(self, ret, accel_rising, gas_tok, decel_long_press): + """Section cap stays at limit x factor until + / gas tap unlocks it; then spend banked time up to the set speed.""" + self.speedcam_time += DT_CTRL + active = (not self.canfd_wrapped_navi and self.speedcamSectionAvgControl and self.vehicleNaviCanControl and + ret.vehicleNaviSectionActive and ret.vehicleNaviSpeed > 0) + section_speed = int(round(ret.vehicleNaviSpeed)) if active else 0 + section = self.section_average + section.update(active, section_speed, self.speedcam_time, self.totalDistance) + if not active: return - section_speed = int(round(ret.vehicleNaviSpeed)) + + cap_kph = section_speed * self.speedcamSafetyFactor + if (not section.unlocked and not self.speedcam_input_consumed and (accel_rising or gas_tok) and self.speedcam_engaged and + ret.vEgo > SPEEDCAM_SKIP_MIN_SPEED and self.speedcam_v_cruise > cap_kph): + section.unlock() + self._consume_speedcam_input(accel_rising, gas_tok) + elif section.unlocked and decel_long_press: + section.lock() + if not section.unlocked: + return # existing section cap and camera distances, unchanged + + allowance = section.allowance_kph(self.speedcam_time, self.totalDistance) + if allowance > section_speed: + ret.vehicleNaviSpeed = allowance # carrot_serv caps the section at vehicleNaviSpeed x factor + # Keep spot deceleration only for a same-speed camera ahead (the end camera); no virtual distance. ahead = [event["target"] - self.totalDistance for event in self.vehicleNaviEvents if event["type"] == "camera" and event["speed"] == section_speed and event["target"] > self.totalDistance] ret.speedLimitDistance = min(ahead) if ahead else 0.0 @@ -1437,10 +1496,11 @@ def corner_max(signal): speedcam_warning_active, speedcam_warning_speed = speed_limit_cam, ret.speedLimit speed_limit_cam = self._update_vehicle_navi_events(cp, ret, speed_limit_cam, cp_alt) or speed_limit_cam speedcam_accel_rising = self.cruise_buttons[-1] == Buttons.RES_ACCEL and prev_cruise_buttons != Buttons.RES_ACCEL + speedcam_gas_tok = self._update_speedcam_gas_tok(ret.gasPressed) speed_limit_cam = self._apply_speedcam_policy(ret, speed_limit_cam, speedcam_warning_active, speedcam_warning_speed, - speedcam_accel_rising) + speedcam_accel_rising, speedcam_gas_tok) self.update_speed_limit(ret, speed_limit_cam, distance_time_changed) - self._apply_section_average_distance(ret) + self._apply_section_average(ret, speedcam_accel_rising, speedcam_gas_tok, self._update_speedcam_decel_long_press()) paddle_button = self.paddle_button_prev if self.cruise_btns_msg_canfd == "CRUISE_BUTTONS": diff --git a/openpilot/selfdrive/carrot/section_average.py b/opendbc_repo/opendbc/car/hyundai/section_average.py similarity index 56% rename from openpilot/selfdrive/carrot/section_average.py rename to opendbc_repo/opendbc/car/hyundai/section_average.py index 54ef25aa56d..d65d27feeb6 100644 --- a/openpilot/selfdrive/carrot/section_average.py +++ b/opendbc_repo/opendbc/car/hyundai/section_average.py @@ -1,11 +1,12 @@ """Section-camera average-speed allowance for stock-navigation CAN (joongyu01/speedcam). -Section enforcement measures the average between the start and end cameras, so -time banked below the target average (e.g. in congestion) may be spent later. -The bank is B = elapsed_time - distance / target_speed. B >= 0 means the running -average is at or below the target, whatever the remaining section length is. -The allowance spends B over a fixed horizon and returns to the normal section cap -as B runs out, so it never lowers the existing cap. +A section stays capped at its limit (the existing behaviour) until the driver +unlocks it with one cruise + press or gas tap. Once unlocked, time banked below +the limit (e.g. in congestion) may be spent up to the cruise set speed: +B = elapsed_time - distance / limit, and B >= 0 keeps the running average at or +below the limit whatever the remaining length is. The allowance spends B over a +fixed horizon and falls back to the limit cap as B runs out. A long press of +cruise - locks the section again; leaving the section resets it. """ SECTION_END_DEBOUNCE_S = 3.0 @@ -20,6 +21,7 @@ def __init__(self): def reset(self): self.active = False + self.unlocked = False self.limit_kph = 0.0 self.start_time = 0.0 self.start_distance = 0.0 @@ -29,6 +31,7 @@ def update(self, section_active, limit_kph, now, total_distance): if section_active and limit_kph > 0: self.inactive_since = None if not self.active or limit_kph != self.limit_kph: + self.reset() self.active = True self.limit_kph = limit_kph self.start_time = now @@ -40,20 +43,29 @@ def update(self, section_active, limit_kph, now, total_distance): elif now - self.inactive_since >= SECTION_END_DEBOUNCE_S: self.reset() - def _target_ms(self, margin_kph): - return max(1.0, (self.limit_kph - margin_kph) / 3.6) + def unlock(self): + if self.active: + self.unlocked = True + + def lock(self): + self.unlocked = False - def bank_seconds(self, now, total_distance, margin_kph): + def _limit_ms(self): + return max(1.0, self.limit_kph / 3.6) + + def bank_seconds(self, now, total_distance): if not self.active: return 0.0 - return (now - self.start_time) - (total_distance - self.start_distance) / self._target_ms(margin_kph) + return (now - self.start_time) - (total_distance - self.start_distance) / self._limit_ms() - def allowance_kph(self, now, total_distance, margin_kph): - """Speed (km/h, actual) that spends the bank over SPEND_HORIZON_M; 0 when there is none.""" - bank = self.bank_seconds(now, total_distance, margin_kph) + def allowance_kph(self, now, total_distance): + """Speed (km/h, actual) that spends the bank over SPEND_HORIZON_M; 0 when locked or without bank.""" + if not self.unlocked: + return 0.0 + bank = self.bank_seconds(now, total_distance) if bank <= MIN_BANK_S: return 0.0 - remaining_time = SPEND_HORIZON_M / self._target_ms(margin_kph) - bank + remaining_time = SPEND_HORIZON_M / self._limit_ms() - bank if remaining_time <= SPEND_HORIZON_M / (MAX_ALLOWANCE_KPH / 3.6): return MAX_ALLOWANCE_KPH return min(MAX_ALLOWANCE_KPH, SPEND_HORIZON_M / remaining_time * 3.6) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py b/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py new file mode 100644 index 00000000000..6ad4df36663 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py @@ -0,0 +1,81 @@ +import pytest + +from opendbc.car.hyundai.section_average import SECTION_END_DEBOUNCE_S, SectionAverage + + +def _drive(avg, speeds_kph, dt=0.05, start_time=0.0, start_distance=0.0, limit=80): + """Integrate a speed profile; the allowance caps each step like the section cap does.""" + now, distance = start_time, start_distance + for requested in speeds_kph: + avg.update(True, limit, now, distance) + allowance = avg.allowance_kph(now, distance) + speed = min(requested, max(limit, allowance)) + now += dt + distance += speed / 3.6 * dt + return now, distance + + +def _unlocked(limit=80): + avg = SectionAverage() + avg.update(True, limit, 0.0, 0.0) + avg.unlock() + return avg + + +def test_locked_section_never_allows_more_than_the_limit(): + avg = SectionAverage() + now, distance = _drive(avg, [30] * int(60 / 0.05)) + assert avg.bank_seconds(now, distance) > 30 + assert avg.allowance_kph(now, distance) == 0.0 + + +def test_no_bank_means_no_allowance(): + avg = _unlocked() + assert avg.allowance_kph(10.0, 80 / 3.6 * 10.0) == 0.0 + + +def test_congestion_banks_time_and_allows_more_than_the_limit(): + avg = _unlocked() + now, distance = _drive(avg, [30] * int(60 / 0.05)) # one minute at 30 km/h + assert avg.bank_seconds(now, distance) == pytest.approx(60 - 500 / (80 / 3.6), rel=0.01) + assert avg.allowance_kph(now, distance) > 80 + + +def test_running_average_never_exceeds_the_limit_while_spending_the_bank(): + avg = _unlocked() + now, distance = _drive(avg, [20] * int(90 / 0.05)) # congestion + for _ in range(int(600 / 0.05)): # then the driver asks for 130 + avg.update(True, 80, now, distance) + allowance = avg.allowance_kph(now, distance) + if allowance == 0.0: + break # bank spent: back to the normal cap + now += 0.05 + distance += min(130, max(80, allowance)) / 3.6 * 0.05 + assert distance / now * 3.6 <= 80 + 0.5 + assert avg.bank_seconds(now, distance) <= 1.0 + + +def test_lock_and_section_change_restore_the_cap(): + avg = _unlocked() + now, distance = _drive(avg, [30] * int(60 / 0.05)) + avg.lock() + assert avg.allowance_kph(now, distance) == 0.0 + avg.unlock() + avg.update(True, 100, now, distance) # new limit: a new section, locked again + assert not avg.unlocked and (avg.limit_kph, avg.start_time) == (100, now) + + +def test_short_dropout_keeps_the_section_but_a_real_exit_resets(): + avg = _unlocked() + avg.update(False, 0, 10.0, 100.0) + avg.update(True, 80, 10.0 + SECTION_END_DEBOUNCE_S / 2, 110.0) + assert avg.active and avg.unlocked and avg.start_time == 0.0 + avg.update(False, 0, 20.0, 200.0) + avg.update(False, 0, 20.0 + SECTION_END_DEBOUNCE_S, 210.0) + assert not avg.active and not avg.unlocked + + +def test_unlock_needs_an_active_section(): + avg = SectionAverage() + avg.unlock() + assert not avg.unlocked diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index c0c360cd487..ad3db63a23d 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -1,4 +1,5 @@ import math +from collections import deque from pathlib import Path from types import SimpleNamespace @@ -12,7 +13,8 @@ VEHICLE_SPEED_CAMERA_PARAM_UPDATE_FRAMES, is_canfd_navi_camera_active, ) from opendbc.car.hyundai.navi_speedcam import SpeedcamPolicy -from opendbc.car.hyundai.values import CAR, HyundaiFlags +from opendbc.car.hyundai.section_average import SectionAverage +from opendbc.car.hyundai.values import CAR, Buttons, HyundaiFlags ButtonType = structs.CarState.ButtonEvent.Type @@ -32,6 +34,10 @@ def get_int(self, key): return int(self.vehicle_navi) if key == "AutoNaviSpeedCtrlMode": return 2 + if key == "AutoNaviSpeedSafetyFactor": + return 107 + if key == "CruiseButtonLongDelay": + return 40 raise KeyError(key) def get_bool(self, key): @@ -79,6 +85,14 @@ def _car_state(distance_time_tenths=60): state.hda_info_4a3 = None state.speedcam_policy = SpeedcamPolicy() state.speedcam_accel_swallow = False + state.speedcam_engaged = False + state.speedcam_gas_count = 0 + state.speedcam_gas_tok_consumed = False + state.speedcam_input_consumed = False + state.speedcam_v_cruise = 0.0 + state.speedcam_decel_count = 0 + state.speedcam_time = 0.0 + state.section_average = SectionAverage() state._read_speedcam_params() return state @@ -1227,9 +1241,40 @@ def test_vehicle_navi_section_end_camera_uses_logged_1997_meter_offset(): def _speedcam_state(): state = _car_state() state.vehicleNaviCanControl = 1 + state.speedcam_engaged = True return state +def test_speedcam_gas_tap_matches_cruise_timer(): + state = _speedcam_state() + assert not any(state._update_speedcam_gas_tok(True) for _ in range(39)) + assert state._update_speedcam_gas_tok(False) # 39 pressed frames: a tap + assert not any(state._update_speedcam_gas_tok(True) for _ in range(40)) + assert not state._update_speedcam_gas_tok(False) # 40 frames: a hold + assert not state._update_speedcam_gas_tok(False) + + +def test_gas_tap_skips_box_and_is_withheld_from_cruise(): + state = _speedcam_state() + state.speedcamSkipBox = True + state.speedcam_policy.add_preview(0xD2, 366, 0.0) + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False) + assert not state._apply_speedcam_policy(ret, True, True, 60.0, False, True) + assert state.speedcam_gas_tok_consumed and not state.speedcam_accel_swallow + assert not state._apply_speedcam_policy(ret, True, True, 60.0, False, False) + assert not state.speedcam_gas_tok_consumed # only the tap frame is reported + + +def test_skip_inputs_keep_their_roles_while_disengaged(): + state = _speedcam_state() + state.speedcam_engaged = False + state.speedcamSkipBox = True + state.speedcam_policy.add_preview(0xD2, 366, 0.0) + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False) + assert state._apply_speedcam_policy(ret, True, True, 60.0, True, True) + assert not state.speedcam_accel_swallow and not state.speedcam_gas_tok_consumed + + def test_stock_mobile_zone_warning_is_ignored_below_navi_mode_three(): state = _speedcam_state() state.speedcam_policy.add_preview(0xD3, 361, 0.0) # kind 3 mobile zone at the 60 km/h lead @@ -1304,23 +1349,65 @@ def test_speedcam_policy_leaves_pv5_and_disabled_navi_untouched(): assert state._apply_speedcam_policy(ret, True, True, 60.0, False) -def test_section_average_keeps_only_the_camera_ahead_as_spot_distance(): +def _section_state(): state = _speedcam_state() state.speedcamSectionAvgControl = True - state.totalDistance = 1000.0 - state.vehicleNaviEvents = [{"type": "camera", "speed": 80, "kind": 0, "target": 900.0}, - {"type": "camera", "speed": 80, "kind": 0, "target": 2500.0}, - {"type": "camera", "speed": 60, "kind": 0, "target": 1200.0}] - ret = SimpleNamespace(vehicleNaviSectionActive=True, vehicleNaviSpeed=80.0, speedLimitDistance=1.0) - state._apply_section_average_distance(ret) - assert ret.speedLimitDistance == pytest.approx(1500.0) + state.speedcam_v_cruise = 130.0 + state.cruise_buttons = deque([0]) + return state - state.vehicleNaviEvents = [] - ret.speedLimitDistance = 1.0 - state._apply_section_average_distance(ret) - assert ret.speedLimitDistance == 0.0 # no virtual distance inside the section - state.speedcamSectionAvgControl = False - ret.speedLimitDistance = 1.0 - state._apply_section_average_distance(ret) - assert ret.speedLimitDistance == 1.0 +def _section_ret(v_ego=16.0): + return SimpleNamespace(vEgo=v_ego, vehicleNaviSectionActive=True, vehicleNaviSpeed=80.0, speedLimitDistance=1.0) + + +def test_section_stays_capped_until_unlocked(): + state = _section_state() + ret = _section_ret() + state._apply_section_average(ret, False, False, False) + assert not state.section_average.unlocked + assert (ret.vehicleNaviSpeed, ret.speedLimitDistance) == (80.0, 1.0) # existing cap and virtual distance + + state.speedcam_v_cruise = 85.0 # set speed not above 80 x 1.07 + state._apply_section_average(ret, True, False, False) + assert not state.section_average.unlocked and not state.speedcam_accel_swallow + + +def test_section_unlock_by_plus_or_gas_tap_spends_banked_time(): + state = _section_state() + state.vehicleNaviEvents = [{"type": "camera", "speed": 80, "kind": 0, "target": 900.0}, + {"type": "camera", "speed": 80, "kind": 0, "target": 2500.0}] + state.totalDistance = 1000.0 + ret = _section_ret() + state._apply_section_average(ret, True, False, False) + assert state.section_average.unlocked and state.speedcam_accel_swallow + assert ret.vehicleNaviSpeed == 80.0 # no banked time yet + assert ret.speedLimitDistance == pytest.approx(1500.0) # only the end camera ahead, no virtual distance + + state.speedcam_time += 60.0 # a slow minute: banked time + ret = _section_ret() + state._apply_section_average(ret, False, False, False) + assert ret.vehicleNaviSpeed > 80.0 + + gas = _section_state() + gas._apply_section_average(_section_ret(), False, True, False) + assert gas.section_average.unlocked and gas.speedcam_gas_tok_consumed + + +def test_section_long_minus_locks_again_and_camera_skip_wins_the_input(): + state = _section_state() + state._apply_section_average(_section_ret(), True, False, False) + assert state.section_average.unlocked + state.speedcamLongPressFrames = 40 + state.cruise_buttons = deque([Buttons.SET_DECEL]) + assert not any(state._update_speedcam_decel_long_press() for _ in range(40)) + assert state._update_speedcam_decel_long_press() # 41st held frame = cruise.py long press + ret = _section_ret() + state._apply_section_average(ret, False, False, True) + assert not state.section_average.unlocked + assert (ret.vehicleNaviSpeed, ret.speedLimitDistance) == (80.0, 1.0) + + skipped = _section_state() + skipped.speedcam_input_consumed = True # this press already skipped a camera + skipped._apply_section_average(_section_ret(), True, False, False) + assert not skipped.section_average.unlocked diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 1db2540c81b..4263cd48055 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -245,7 +245,6 @@ inline static std::unordered_map keys = { {"VehicleNaviSkipBoxCamera", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviSkipMobileZone", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, - {"VehicleNaviSectionAvgMargin", {PERSISTENT, INT, "3"}}, {"VehicleSpeedCameraControlMode", {PERSISTENT, INT, "1"}}, {"VehicleSpeedCameraDistanceTime", {PERSISTENT, INT, "60"}}, {"AutoRoadSpeedLimitOffset", {PERSISTENT, INT, "-1"}}, diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index e7a0dea0700..ddfd62c141a 100644 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -204,6 +204,10 @@ def state_update(self) -> car.CarState: decode_done_ns = time.monotonic_ns() # Update carState from CAN + # joongyu01/speedcam: camera skips and section unlock (+ / gas tap) apply only while engaged, + # and a section unlocks only when the set speed is above its cap. + self.CI.CS.speedcam_engaged = self.sm['carControl'].enabled + self.CI.CS.speedcam_v_cruise = self.v_cruise_helper.v_cruise_kph CS = self.CI.update(can_list) if self.CP.brand == 'mock': CS = self.mock_carstate.update(CS) @@ -242,6 +246,8 @@ def state_update(self) -> car.CarState: #self.t2 = time.monotonic() #self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric) + # A gas tap spent on a camera skip must not also raise the set speed by +10. + self.v_cruise_helper.speedcam_gas_tok_consumed = getattr(self.CI.CS, "speedcam_gas_tok_consumed", False) self.v_cruise_helper.update_v_cruise(CS, self.sm, self.is_metric) #self.t3 = time.monotonic() if self.sm['carControl'].enabled and not self.CC_prev.enabled: diff --git a/openpilot/selfdrive/car/cruise.py b/openpilot/selfdrive/car/cruise.py index 2f25288a50d..b44d3581e78 100644 --- a/openpilot/selfdrive/car/cruise.py +++ b/openpilot/selfdrive/car/cruise.py @@ -192,6 +192,7 @@ def __init__(self, CP): self._gas_pressed_value = 0 self._gas_tok_timer = int(0.4 / 0.01) # 0.4 sec self._gas_tok = False + self.speedcam_gas_tok_consumed = False # set by card when carstate spent this tap on a camera skip self._brake_pressed_count = 0 self._soft_hold_count = 0 self._soft_hold_active = 0 @@ -961,7 +962,7 @@ def _prepare_brake_gas(self, CS, CC): self.carrot_cruise_active = False self._cruise_control(-1, 0, "Cruise off (gas pressed)") else: - self._gas_tok = True if 0 < self._gas_pressed_count < self._gas_tok_timer else False + self._gas_tok = True if 0 < self._gas_pressed_count < self._gas_tok_timer and not self.speedcam_gas_tok_consumed else False self._gas_pressed_count = min(-1, self._gas_pressed_count - 1) if self._gas_pressed_count < -1: self._gas_pressed_count_last = 0 diff --git a/openpilot/selfdrive/carrot/carrot_serv.py b/openpilot/selfdrive/carrot/carrot_serv.py index ea411071bcb..0efc5c8bcfa 100644 --- a/openpilot/selfdrive/carrot/carrot_serv.py +++ b/openpilot/selfdrive/carrot/carrot_serv.py @@ -20,7 +20,6 @@ from openpilot.common.constants import CV from openpilot.common.gps import get_gps_location_service from openpilot.selfdrive.carrot.carrot_navi_control import CarrotNaviControl, parse_carrot_navi_control -from openpilot.selfdrive.carrot.section_average import SectionAverage nav_type_mapping = { 12: ("turn", "left", 1), @@ -164,7 +163,6 @@ def __init__(self): self.xSpdDist = 0 self.xSpdType = -1 self.rear_camera_events = [] - self.section_average = SectionAverage() self.xTurnInfo = -1 self.xDistToTurn = 0 @@ -234,8 +232,6 @@ def update_params(self): self.vehicleNaviCanControl = min(3, max(0, self.params.get_int("VehicleNaviCanControl"))) self.vehicleNaviSchoolZoneControl = self.params.get_bool("VehicleNaviSchoolZoneControl") self.vehicleSpeedCameraControlMode = min(3, max(0, self.params.get_int("VehicleSpeedCameraControlMode"))) - self.vehicleNaviSectionAvgControl = self.params.get_bool("VehicleNaviSectionAvgControl") - self.vehicleNaviSectionAvgMargin = min(10, max(0, self.params.get_int("VehicleNaviSectionAvgMargin"))) self.autoNaviSpeedSafetyFactor = float(self.params.get_int("AutoNaviSpeedSafetyFactor")) * 0.01 self.autoNaviSpeedDecelRate = float(self.params.get_int("AutoNaviSpeedDecelRate")) * 0.01 self.autoNaviCountDownMode = self.params.get_int("AutoNaviCountDownMode") @@ -1430,16 +1426,8 @@ def update_navi(self, remote_ip, sm, pm, vturn_speed, coords, distances, route_s vehicle_school_zone_speed = self._vehicle_school_zone_speed(CS) if vehicle_school_zone_speed < 250: self.active_carrot = 6 - section_zone_enabled = self._vehicle_section_zone_enabled(CS) - now = time.monotonic() - self.section_average.update(self.vehicleNaviSectionAvgControl and section_zone_enabled, - CS.vehicleNaviSpeed, now, self.totalDistance) - if section_zone_enabled: + if self._vehicle_section_zone_enabled(CS): vehicle_section_zone_speed = CS.vehicleNaviSpeed * self.autoNaviSpeedSafetyFactor - if self.vehicleNaviSectionAvgControl: - # Time banked below the section average may be spent up to the cruise set speed. - allowance = self.section_average.allowance_kph(now, self.totalDistance, self.vehicleNaviSectionAvgMargin) - vehicle_section_zone_speed = max(vehicle_section_zone_speed, allowance * self.autoNaviSpeedSafetyFactor) self.active_carrot = 4 #print(f"sdi_speed: {sdi_speed}, vehicle_speed_camera_active: {vehicle_speed_camera_active}, xSpdType: {self.xSpdType}, xSpdDist: {self.xSpdDist}, active_carrot: {self.active_carrot}, v_ego_kph: {v_ego_kph}, nRoadLimitSpeed: {self.nRoadLimitSpeed}") diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index b883c3625c7..fc04e2d290b 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -372,19 +372,16 @@ def test_speedcam_skip_and_section_average_settings_are_opt_in(settings, params) assert (control["min"], control["max"], control["default"]) == (0, 1, 0) assert control["control"] == "toggle" assert control["risk"] == "high" - margin = by_name["VehicleNaviSectionAvgMargin"] - assert (margin["min"], margin["max"], margin["default"], margin["unit"]) == (0, 10, 3, 1) - assert margin["display_unit"] == "speedKph" driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") speed = next(group for group in driving["groups"] if group["id"] == "SPEED") camera = next(group for group in speed["groups"] if group["id"] == "SPEED_CAMERA") params_keys = PARAMS_KEYS_PATH.read_text(encoding="utf-8") - for name in (*toggles, "VehicleNaviSectionAvgMargin"): - assert name in camera["params"] for name in toggles: + assert name in camera["params"] assert f'{{"{name}", {{PERSISTENT, BOOL, "0"}}}}' in params_keys - assert '{"VehicleNaviSectionAvgMargin", {PERSISTENT, INT, "3"}}' in params_keys + assert "VehicleNaviSectionAvgMargin" not in by_name + assert "VehicleNaviSectionAvgMargin" not in params_keys def test_vehicle_navi_curve_control_settings_are_removed(settings): diff --git a/openpilot/selfdrive/carrot/tests/test_section_average.py b/openpilot/selfdrive/carrot/tests/test_section_average.py deleted file mode 100644 index 27fe50e6d76..00000000000 --- a/openpilot/selfdrive/carrot/tests/test_section_average.py +++ /dev/null @@ -1,67 +0,0 @@ -import pytest - -from openpilot.selfdrive.carrot.section_average import SECTION_END_DEBOUNCE_S, SectionAverage - - -def _drive(avg, speeds_kph, dt=0.05, margin=0, start_time=0.0, start_distance=0.0, limit=80): - """Integrate a speed profile; the allowance caps each step like carrot_serv does.""" - now, distance = start_time, start_distance - for requested in speeds_kph: - avg.update(True, limit, now, distance) - allowance = avg.allowance_kph(now, distance, margin) - speed = min(requested, max(limit, allowance)) - now += dt - distance += speed / 3.6 * dt - return now, distance - - -def test_no_bank_means_no_allowance(): - avg = SectionAverage() - avg.update(True, 80, 0.0, 0.0) - assert avg.allowance_kph(10.0, 80 / 3.6 * 10.0, 0) == 0.0 - - -def test_congestion_banks_time_and_allows_more_than_the_limit(): - avg = SectionAverage() - now, distance = _drive(avg, [30] * int(60 / 0.05)) # one minute at 30 km/h - assert avg.bank_seconds(now, distance, 0) == pytest.approx(60 - 500 / (80 / 3.6), rel=0.01) - assert avg.allowance_kph(now, distance, 0) > 80 - - -def test_running_average_never_exceeds_the_target_while_spending_the_bank(): - avg = SectionAverage() - now, distance = _drive(avg, [20] * int(90 / 0.05)) # congestion - for _ in range(int(600 / 0.05)): # then the driver asks for 130 - avg.update(True, 80, now, distance) - speed = min(130, max(80, avg.allowance_kph(now, distance, 0))) - if avg.allowance_kph(now, distance, 0) == 0.0: - break # bank spent: back to the normal cap - now += 0.05 - distance += speed / 3.6 * 0.05 - assert distance / now * 3.6 <= 80 + 0.5 - assert avg.bank_seconds(now, distance, 0) <= 1.0 - - -def test_margin_lowers_the_target_average(): - avg = SectionAverage() - now, distance = _drive(avg, [77] * int(60 / 0.05)) - assert avg.allowance_kph(now, distance, 0) > 0.0 # 77 < 80 banks time - assert avg.allowance_kph(now, distance, 3) == 0.0 # 77 = 80 - 3 banks nothing - - -def test_short_dropout_keeps_the_section_but_a_real_exit_resets(): - avg = SectionAverage() - avg.update(True, 80, 0.0, 0.0) - avg.update(False, 0, 10.0, 100.0) - avg.update(True, 80, 10.0 + SECTION_END_DEBOUNCE_S / 2, 110.0) - assert avg.active and avg.start_time == 0.0 - avg.update(False, 0, 20.0, 200.0) - avg.update(False, 0, 20.0 + SECTION_END_DEBOUNCE_S, 210.0) - assert not avg.active - - -def test_limit_change_restarts_the_section(): - avg = SectionAverage() - avg.update(True, 80, 0.0, 0.0) - avg.update(True, 100, 30.0, 500.0) - assert (avg.limit_kph, avg.start_time, avg.start_distance) == (100, 30.0, 500.0) diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 2213002e2c6..d1675bd7e4d 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -226,7 +226,6 @@ "VehicleNaviSkipBoxCamera", "VehicleNaviSkipMobileZone", "VehicleNaviSectionAvgControl", - "VehicleNaviSectionAvgMargin", "VehicleSpeedCameraControlMode", "VehicleSpeedCameraDistanceTime", "AutoNaviSpeedCtrlMode", @@ -3521,13 +3520,13 @@ "group": "감속제어", "name": "VehicleNaviSkipBoxCamera", "title": "차량 내비 박스 카메라 + 버튼 건너뛰기", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN이 박스형(이동식 카메라 함) 카메라로 알린 경고에서 크루즈 + 버튼을 한 번 누르면 그 카메라의 감속을 해제하고 원래 속도로 돌아갑니다. 이 누름은 설정 속도를 올리지 않습니다. 경고가 끝나면 다음 카메라에는 다시 감속합니다. 고정식·신호·후면 카메라는 건너뛸 수 없습니다. 박스형은 순정 내비 예고 종류 2로 판별합니다(실차 라벨 확인).", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN이 박스형(이동식 카메라 함) 카메라로 알린 경고에서 openpilot 작동 중에 크루즈 + 버튼을 한 번 누르거나 악셀톡(가속 페달을 짧게 밟았다 뗌)을 하면 그 카메라의 감속을 해제하고 원래 속도로 돌아갑니다. 이 입력은 설정 속도를 올리지 않습니다(+1, 악셀톡 +10 모두 반영 안 함). 경고가 끝나면 다음 카메라에는 다시 감속합니다. 고정식·신호·후면 카메라는 건너뛸 수 없습니다. 박스형은 순정 내비 예고 종류 2로 판별합니다(실차 라벨 확인).", "egroup": "SPEED", "etitle": "Stock Navigation Box Camera + Button Skip", - "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN warning for a box-type (mobile camera housing) camera, one press of cruise + releases that camera's deceleration and returns to the previous speed. The press does not raise the set speed. The next camera decelerates again after the warning ends. Fixed, signal and rear cameras cannot be skipped. Boxes are identified as stock-navigation preview kind 2 (confirmed by labelled drives).", + "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN warning for a box-type (mobile camera housing) camera, while engaged, one press of cruise + or a gas tap (brief accelerator press) releases that camera's deceleration and returns to the previous speed. The input does not raise the set speed (neither +1 nor the gas-tap +10). The next camera decelerates again after the warning ends. Fixed, signal and rear cameras cannot be skipped. Boxes are identified as stock-navigation preview kind 2 (confirmed by labelled drives).", "cgroup": "速度控制", "ctitle": "原车导航箱式摄像头 + 键跳过", - "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 提示箱式(移动摄像头箱)摄像头时,按一次巡航 + 键即解除该摄像头的减速并恢复原速度。此按键不会提高设定速度。警告结束后,下一个摄像头仍会减速。固定、闯红灯和后向摄像头不能跳过。箱式根据原车导航预告类型 2 判别(已经实车标注确认)。", + "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 提示箱式(移动摄像头箱)摄像头时,在 openpilot 启用时按一次巡航 + 键或轻踩油门(短按后松开)即解除该摄像头的减速并恢复原速度。此输入不会提高设定速度(+1 和轻踩油门 +10 均不生效)。警告结束后,下一个摄像头仍会减速。固定、闯红灯和后向摄像头不能跳过。箱式根据原车导航预告类型 2 判别(已经实车标注确认)。", "min": 0, "max": 1, "default": 0, @@ -3539,13 +3538,13 @@ "group": "감속제어", "name": "VehicleNaviSkipMobileZone", "title": "차량 내비 이동식 구간 + 버튼 건너뛰기", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 네비게이션감속모드(AutoNaviSpeedCtrlMode)가 3이라 순정 내비 CAN 이동식 단속 구간(내비 파란 원)에서 감속할 때, 크루즈 + 버튼을 한 번 누르면 그 구간의 감속을 해제하고 원래 속도로 돌아갑니다. 이 누름은 설정 속도를 올리지 않습니다. 모드가 2 이하이면 이동식 구간은 처음부터 감속하지 않으므로 이 설정은 쓰이지 않습니다. 같은 경고에 고정식·신호·후면 카메라가 함께 있으면 건너뛸 수 없습니다.", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 네비게이션감속모드(AutoNaviSpeedCtrlMode)가 3이라 순정 내비 CAN 이동식 단속 구간(내비 파란 원)에서 감속할 때, openpilot 작동 중에 크루즈 + 버튼을 한 번 누르거나 악셀톡을 하면 그 구간의 감속을 해제하고 원래 속도로 돌아갑니다. 이 입력은 설정 속도를 올리지 않습니다. 모드가 2 이하이면 이동식 구간은 처음부터 감속하지 않으므로 이 설정은 쓰이지 않습니다. 같은 경고에 고정식·신호·후면 카메라가 함께 있으면 건너뛸 수 없습니다.", "egroup": "SPEED", "etitle": "Stock Navigation Mobile Zone + Button Skip", - "edescr": "Applies only when external navigation is disconnected. When AutoNaviSpeedCtrlMode is 3 and a stock-navigation CAN mobile enforcement zone (blue circle) decelerates, one press of cruise + releases that zone's deceleration and returns to the previous speed. The press does not raise the set speed. With mode 2 or lower mobile zones never decelerate, so this setting is unused. A warning that also carries a fixed, signal or rear camera cannot be skipped.", + "edescr": "Applies only when external navigation is disconnected. When AutoNaviSpeedCtrlMode is 3 and a stock-navigation CAN mobile enforcement zone (blue circle) decelerates, while engaged, one press of cruise + or a gas tap releases that zone's deceleration and returns to the previous speed. The input does not raise the set speed. With mode 2 or lower mobile zones never decelerate, so this setting is unused. A warning that also carries a fixed, signal or rear camera cannot be skipped.", "cgroup": "速度控制", "ctitle": "原车导航移动测速区间 + 键跳过", - "cdescr": "仅在外部导航未连接时应用。导航减速模式(AutoNaviSpeedCtrlMode)为 3 且原车导航 CAN 移动测速区间(导航蓝色圆圈)正在减速时,按一次巡航 + 键即解除该区间的减速并恢复原速度。此按键不会提高设定速度。模式为 2 及以下时移动区间本就不减速,此设置不生效。同一警告中若同时存在固定、闯红灯或后向摄像头,则不能跳过。", + "cdescr": "仅在外部导航未连接时应用。导航减速模式(AutoNaviSpeedCtrlMode)为 3 且原车导航 CAN 移动测速区间(导航蓝色圆圈)正在减速时,在 openpilot 启用时按一次巡航 + 键或轻踩油门即解除该区间的减速并恢复原速度。此输入不会提高设定速度。模式为 2 及以下时移动区间本就不减速,此设置不生效。同一警告中若同时存在固定、闯红灯或后向摄像头,则不能跳过。", "min": 0, "max": 1, "default": 0, @@ -3557,13 +3556,13 @@ "group": "감속제어", "name": "VehicleNaviSectionAvgControl", "title": "차량 내비 구간단속 평균속도 제어", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN 구간단속 중 구간 내내 제한속도로 묶지 않고, 구간 진입 후의 평균속도가 (제한속도 − 여유속도) 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 평균이 낮아진 만큼만 더 빨리 갈 수 있고, 적립된 여유가 없으면 기존 구간 제한과 같습니다. 시점·종점 카메라에서는 지점 감속을 그대로 합니다(종점은 약 2km 전부터 알 수 있음). 구간 시작은 차량 평균속도 신호가 켜진 시점으로 잡으며 실제 시점 카메라와 차이가 있을 수 있는 실험 기능입니다.", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN 구간단속 중에는 평소처럼 구간 제한속도(× 안전계수)로 고정합니다. openpilot 작동 중이고 크루즈 설정 속도가 그보다 높을 때 크루즈 + 버튼을 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풀고, 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없으면 기존 제한과 같습니다. 이 입력은 설정 속도를 올리지 않습니다. 크루즈 - 버튼을 길게 누르면 다시 고정합니다. 구간이 끝나면 초기화됩니다. 종점 카메라는 약 2km 전 예고 후 지점 감속합니다. 구간 시작은 평균속도 신호가 켜진 시점으로 잡는 실험 기능입니다.", "egroup": "SPEED", "etitle": "Stock Navigation Section Average-speed Control", - "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN section camera, instead of holding the section limit throughout, allow up to the cruise set speed only while the average since entering the section stays at or below (limit - margin). Only time banked below the target (for example in congestion) can be spent; without it the cap equals the existing section limit. Start and end cameras still decelerate to the spot limit (the end is known about 2 km ahead). The section start is taken when the vehicle average-speed signal appears and may differ from the actual start camera; experimental.", + "edescr": "Applies only when external navigation is disconnected. A stock-navigation CAN section stays capped at its limit (x safety factor) as before. While engaged and with the cruise set speed above that cap, one press of cruise + or a gas tap unlocks the section: up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (e.g. congestion) can be spent; without it the cap is unchanged. The input does not raise the set speed. A long press of cruise - locks the section again, and leaving the section resets it. The end camera still decelerates to the spot limit after its preview about 2 km ahead. The section start is taken when the average-speed signal appears; experimental.", "cgroup": "速度控制", "ctitle": "原车导航区间测速平均速度控制", - "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 区间测速期间,不再全程限制在限速,而是仅在进入区间后的平均速度保持在(限速 − 余量)以下的范围内允许达到巡航设定速度。只能使用因拥堵等积累的余量;没有余量时与原区间限速相同。起点和终点摄像头仍按点位减速(终点约提前 2km 获知)。区间起点取车辆平均速度信号出现的时刻,可能与实际起点摄像头有偏差,为实验功能。", + "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 区间测速期间照常固定在区间限速(× 安全系数)。openpilot 启用且巡航设定速度高于该上限时,按一次巡航 + 键或轻踩油门即解除该区间的固定,仅在进入区间后的平均速度保持在区间限速以下的范围内允许达到设定速度。只能使用因拥堵等积累的余量;没有余量时与原限制相同。此输入不会提高设定速度。长按巡航 - 键重新固定,离开区间时复位。终点摄像头约提前 2km 预告后按点位减速。区间起点取平均速度信号出现的时刻,为实验功能。", "min": 0, "max": 1, "default": 0, @@ -3571,24 +3570,6 @@ "control": "toggle", "risk": "high" }, - { - "group": "감속제어", - "name": "VehicleNaviSectionAvgMargin", - "title": "구간단속 평균속도 여유(3km/h)", - "descr": "구간단속 평균속도 제어(VehicleNaviSectionAvgControl)가 목표로 삼는 평균속도를 제한속도보다 이만큼 낮게 잡습니다. 주행거리 오차와 구간 시작 판정 차이를 흡수합니다. 값을 높이면 더 안전하지만 더 빨리 갈 수 있는 여유가 줄어듭니다.", - "egroup": "SPEED", - "etitle": "Section Average-speed Margin(3km/h)", - "edescr": "Section average-speed control (VehicleNaviSectionAvgControl) targets an average this much below the section limit, absorbing odometer error and section-start timing. A higher value is safer but leaves less time to spend.", - "cgroup": "速度控制", - "ctitle": "区间测速平均速度余量 (3km/h)", - "cdescr": "区间测速平均速度控制(VehicleNaviSectionAvgControl)的目标平均速度比限速低此数值,用于吸收里程误差和区间起点判定偏差。数值越高越安全,但可提速的余量越少。", - "min": 0, - "max": 10, - "default": 3, - "unit": 1, - "display_unit": "speedKph", - "risk": "high" - }, { "group": "감속제어", "name": "VehicleSpeedCameraControlMode", From 81f428e0fa1b1fca759f26f19b404303e3b50cd0 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:18:19 +0900 Subject: [PATCH 06/50] speedcam: long cruise - relocks a section without the -10 step While a section is unlocked, cruise - is now held back from cruise.py the same way the consumed + and gas tap are. A long press only relocks the section; cruise.py never sees it, so the set speed no longer drops by the long-press -10 step (and its repeats while held). A short press is handed to cruise.py at release as a same-frame press + release pair, which cruise.py already treats as one short press (the remote-button path), so the -1 step is unchanged. Locked sections and non-section driving are untouched. Tests: hyundai test_navi_speedcam / test_section_average / test_speed_camera 150 passed (3 new: long press never reaches cruise, short press forwarded at release incl. capnp assignment, pass-through while locked); test_steering_touch + test_settings_schema 104 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/speed-deceleration.md | 2 +- docs/user/ko/speed-deceleration.md | 2 +- opendbc_repo/opendbc/car/hyundai/carstate.py | 32 ++++++++++- .../car/hyundai/tests/test_speed_camera.py | 56 +++++++++++++++++++ openpilot/selfdrive/carrot_settings.json | 6 +- 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index dca11f1af76..4ff12729eca 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -107,7 +107,7 @@ When enabled, while engaged and that kind of camera decelerates, one press of cr ### `VehicleNaviSectionAvgControl` -When enabled, a stock-navigation section (vehicle average-speed signal present) still stays capped at the section limit × `AutoNaviSpeedSafetyFactor` (for example 117 in a 110 section at 107%). While engaged and with the cruise set speed above that cap, one press of cruise `+` or a gas tap unlocks the section. Up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (for example in congestion) can be spent; without it, or once it is spent, the cap applies. The unlocking input does not raise the set speed (neither `+1` nor the gas-tap `+10`). A long press of cruise `-` (the −10 step) locks the section again, and the −10 still applies. Leaving the section resets it. +When enabled, a stock-navigation section (vehicle average-speed signal present) still stays capped at the section limit × `AutoNaviSpeedSafetyFactor` (for example 117 in a 110 section at 107%). While engaged and with the cruise set speed above that cap, one press of cruise `+` or a gas tap unlocks the section. Up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (for example in congestion) can be spent; without it, or once it is spent, the cap applies. The unlocking input does not raise the set speed (neither `+1` nor the gas-tap `+10`). A long press of cruise `-` locks the section again without changing the set speed (while unlocked, a long `-` does not do its −10 step; a short press still lowers the set speed as usual). Leaving the section resets it. Once unlocked, no virtual camera distance is used inside the section, and the end camera decelerates to the spot limit after its preview about 2 km ahead. The average uses the vehicle odometer (`vEgo`) with no margin. In a recorded drive the navigation-displayed average was 2–3 km/h higher than this calculation, so adjust the cruise set speed yourself if needed. The section start is taken when the average-speed signal appears; experimental. diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index 4c7813707a4..c6fc9dac396 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -109,7 +109,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. ### `VehicleNaviSectionAvgControl` -켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중에도 평소에는 구간 제한속도 × `AutoNaviSpeedSafetyFactor`로 고정합니다(예: 110 구간, 107% → 117). openpilot 작동 중이고 크루즈 설정 속도가 이 값보다 높을 때 크루즈 `+`를 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풉니다. 그 뒤로는 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없거나 다 쓰면 고정값과 같습니다. 해제에 쓴 입력은 설정 속도를 올리지 않습니다(`+1`, 악셀톡 `+10` 모두 반영 안 함). 크루즈 `-`를 길게 누르면(−10 동작) 다시 고정하며, 이때 −10은 그대로 반영됩니다. 구간이 끝나면 초기화됩니다. +켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중에도 평소에는 구간 제한속도 × `AutoNaviSpeedSafetyFactor`로 고정합니다(예: 110 구간, 107% → 117). openpilot 작동 중이고 크루즈 설정 속도가 이 값보다 높을 때 크루즈 `+`를 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풉니다. 그 뒤로는 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없거나 다 쓰면 고정값과 같습니다. 해제에 쓴 입력은 설정 속도를 올리지 않습니다(`+1`, 악셀톡 `+10` 모두 반영 안 함). 크루즈 `-`를 길게 누르면 설정 속도는 그대로 두고 다시 고정합니다(해제된 동안의 `-` 길게는 −10 동작을 하지 않으며, 짧게 누르면 평소처럼 설정 속도를 내립니다). 구간이 끝나면 초기화됩니다. 해제 후에는 구간 안에서 가상거리 감속을 쓰지 않고, 종점 카메라는 약 2km 전 예고를 받은 뒤 지점 감속합니다. 평균은 차량 주행거리(`vEgo`)로 계산하며 여유를 두지 않습니다. 기록 주행에서 내비가 표시한 평균이 이 계산보다 2~3km/h 높았으므로 필요하면 크루즈 설정 속도로 직접 조절하세요. 구간 시작은 평균속도 신호가 켜진 시점으로 잡는 실험 기능입니다. diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index a8f46df4819..df05be585cb 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -254,6 +254,8 @@ def __init__(self, CP): self.speedcam_input_consumed = False self.speedcam_v_cruise = 0.0 # set by card from the cruise set speed self.speedcam_decel_count = 0 + self.speedcam_decel_hold = False # cruise - held back from cruise.py while a section is unlocked + self.speedcam_decel_long_fired = False # that held press reached the long press (section relocked) self.speedcam_time = 0.0 self.section_average = SectionAverage() self._read_speedcam_params() @@ -809,6 +811,29 @@ def _swallow_speedcam_accel(self, button_events): kept.append(event) return kept + def _hold_speedcam_decel(self, button_events, decel_long_press): + """While a section is unlocked, cruise - goes to carstate only: a long press relocks the section + without cruise.py's -10 step, and a short press is handed to cruise.py at release (where it acts).""" + if decel_long_press and self.speedcam_decel_hold: + self.speedcam_decel_long_fired = True + kept = [] + for event in button_events: + if event.type != ButtonType.decelCruise: + kept.append(event) + elif event.pressed: + if self.section_average.unlocked: + self.speedcam_decel_hold = True + self.speedcam_decel_long_fired = False + else: + kept.append(event) + elif self.speedcam_decel_hold: + if not self.speedcam_decel_long_fired: + kept += [structs.CarState.ButtonEvent(pressed=True, type=ButtonType.decelCruise), event] + self.speedcam_decel_hold = False + else: + kept.append(event) + return kept + def _clear_vehicle_navi_events(self): self.vehicleNaviEvents = [] self.vehicleNaviCameraTarget = None @@ -1500,7 +1525,8 @@ def corner_max(signal): speed_limit_cam = self._apply_speedcam_policy(ret, speed_limit_cam, speedcam_warning_active, speedcam_warning_speed, speedcam_accel_rising, speedcam_gas_tok) self.update_speed_limit(ret, speed_limit_cam, distance_time_changed) - self._apply_section_average(ret, speedcam_accel_rising, speedcam_gas_tok, self._update_speedcam_decel_long_press()) + speedcam_decel_long_press = self._update_speedcam_decel_long_press() + self._apply_section_average(ret, speedcam_accel_rising, speedcam_gas_tok, speedcam_decel_long_press) paddle_button = self.paddle_button_prev if self.cruise_btns_msg_canfd == "CRUISE_BUTTONS": @@ -1510,10 +1536,10 @@ def corner_max(signal): # Filter the Python list before the single capnp assignment: re-assigning a # capnp list field from itself clears the source first and zeroes every event. - ret.buttonEvents = self._swallow_speedcam_accel([ + ret.buttonEvents = self._hold_speedcam_decel(self._swallow_speedcam_accel([ *create_button_events(self.cruise_buttons[-1], prev_cruise_buttons, BUTTONS_DICT), *create_button_events(paddle_button, self.paddle_button_prev, {1: ButtonType.paddleLeft, 2: ButtonType.paddleRight}), - *create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise})]) + *create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise})]), speedcam_decel_long_press) self.paddle_button_prev = paddle_button diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index ad3db63a23d..ed5b5d149b9 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -91,6 +91,8 @@ def _car_state(distance_time_tenths=60): state.speedcam_input_consumed = False state.speedcam_v_cruise = 0.0 state.speedcam_decel_count = 0 + state.speedcam_decel_hold = False + state.speedcam_decel_long_fired = False state.speedcam_time = 0.0 state.section_average = SectionAverage() state._read_speedcam_params() @@ -1411,3 +1413,57 @@ def test_section_long_minus_locks_again_and_camera_skip_wins_the_input(): skipped.speedcam_input_consumed = True # this press already skipped a camera skipped._apply_section_average(_section_ret(), True, False, False) assert not skipped.section_average.unlocked + + +def _decel(pressed): + return structs.CarState.ButtonEvent(type=ButtonType.decelCruise, pressed=pressed) + + +def _decel_types(events): + return [(str(event.type), event.pressed) for event in events if event.type == ButtonType.decelCruise] + + +def test_section_long_minus_relock_never_reaches_cruise(): + # A long cruise - that relocks an unlocked section must not also step the set speed down by 10. + state = _section_state() + state._apply_section_average(_section_ret(), True, False, False) + assert state.section_average.unlocked + state.speedcamLongPressFrames = 40 + state.cruise_buttons = deque([Buttons.SET_DECEL]) + paddle = structs.CarState.ButtonEvent(type=ButtonType.paddleLeft, pressed=True) + + assert _decel_types(state._hold_speedcam_decel([_decel(True), paddle], False)) == [] + forwarded = [] + for _ in range(80): # hold past the long press and one repeat + long_press = state._update_speedcam_decel_long_press() + state._apply_section_average(_section_ret(), False, False, long_press) + forwarded += state._hold_speedcam_decel([], long_press) + assert not state.section_average.unlocked # relocked + assert forwarded == [] + assert state._hold_speedcam_decel([_decel(False)], False) == [] # release swallowed too + assert not state.speedcam_decel_hold + + +def test_section_short_minus_is_handed_to_cruise_at_release(): + state = _section_state() + state._apply_section_average(_section_ret(), True, False, False) + state.speedcamLongPressFrames = 40 + state.cruise_buttons = deque([Buttons.SET_DECEL]) + + assert state._hold_speedcam_decel([_decel(True)], False) == [] + for _ in range(10): + assert state._hold_speedcam_decel([], state._update_speedcam_decel_long_press()) == [] + ret = structs.CarState() + ret.buttonEvents = state._hold_speedcam_decel([_decel(False)], False) + # cruise.py handles a same-frame press + release as one short press (as for remote buttons) + assert _decel_types(ret.buttonEvents) == [("decelCruise", True), ("decelCruise", False)] + assert state.section_average.unlocked and not state.speedcam_decel_hold + + +def test_minus_passes_through_while_section_locked(): + state = _section_state() + assert not state.section_average.unlocked + press, release = [_decel(True)], [_decel(False)] + assert state._hold_speedcam_decel(press, False) == press + assert state._hold_speedcam_decel([], True) == [] + assert state._hold_speedcam_decel(release, False) == release diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index d1675bd7e4d..71cb3fe3b98 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -3556,13 +3556,13 @@ "group": "감속제어", "name": "VehicleNaviSectionAvgControl", "title": "차량 내비 구간단속 평균속도 제어", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN 구간단속 중에는 평소처럼 구간 제한속도(× 안전계수)로 고정합니다. openpilot 작동 중이고 크루즈 설정 속도가 그보다 높을 때 크루즈 + 버튼을 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풀고, 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없으면 기존 제한과 같습니다. 이 입력은 설정 속도를 올리지 않습니다. 크루즈 - 버튼을 길게 누르면 다시 고정합니다. 구간이 끝나면 초기화됩니다. 종점 카메라는 약 2km 전 예고 후 지점 감속합니다. 구간 시작은 평균속도 신호가 켜진 시점으로 잡는 실험 기능입니다.", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN 구간단속 중에는 평소처럼 구간 제한속도(× 안전계수)로 고정합니다. openpilot 작동 중이고 크루즈 설정 속도가 그보다 높을 때 크루즈 + 버튼을 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풀고, 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없으면 기존 제한과 같습니다. 이 입력은 설정 속도를 올리지 않습니다. 크루즈 - 버튼을 길게 누르면 설정 속도는 그대로 두고 다시 고정합니다(짧게 누르면 평소처럼 설정 속도를 내립니다). 구간이 끝나면 초기화됩니다. 종점 카메라는 약 2km 전 예고 후 지점 감속합니다. 구간 시작은 평균속도 신호가 켜진 시점으로 잡는 실험 기능입니다.", "egroup": "SPEED", "etitle": "Stock Navigation Section Average-speed Control", - "edescr": "Applies only when external navigation is disconnected. A stock-navigation CAN section stays capped at its limit (x safety factor) as before. While engaged and with the cruise set speed above that cap, one press of cruise + or a gas tap unlocks the section: up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (e.g. congestion) can be spent; without it the cap is unchanged. The input does not raise the set speed. A long press of cruise - locks the section again, and leaving the section resets it. The end camera still decelerates to the spot limit after its preview about 2 km ahead. The section start is taken when the average-speed signal appears; experimental.", + "edescr": "Applies only when external navigation is disconnected. A stock-navigation CAN section stays capped at its limit (x safety factor) as before. While engaged and with the cruise set speed above that cap, one press of cruise + or a gas tap unlocks the section: up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (e.g. congestion) can be spent; without it the cap is unchanged. The input does not raise the set speed. A long press of cruise - locks the section again without changing the set speed (a short press still lowers it as usual), and leaving the section resets it. The end camera still decelerates to the spot limit after its preview about 2 km ahead. The section start is taken when the average-speed signal appears; experimental.", "cgroup": "速度控制", "ctitle": "原车导航区间测速平均速度控制", - "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 区间测速期间照常固定在区间限速(× 安全系数)。openpilot 启用且巡航设定速度高于该上限时,按一次巡航 + 键或轻踩油门即解除该区间的固定,仅在进入区间后的平均速度保持在区间限速以下的范围内允许达到设定速度。只能使用因拥堵等积累的余量;没有余量时与原限制相同。此输入不会提高设定速度。长按巡航 - 键重新固定,离开区间时复位。终点摄像头约提前 2km 预告后按点位减速。区间起点取平均速度信号出现的时刻,为实验功能。", + "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 区间测速期间照常固定在区间限速(× 安全系数)。openpilot 启用且巡航设定速度高于该上限时,按一次巡航 + 键或轻踩油门即解除该区间的固定,仅在进入区间后的平均速度保持在区间限速以下的范围内允许达到设定速度。只能使用因拥堵等积累的余量;没有余量时与原限制相同。此输入不会提高设定速度。长按巡航 - 键重新固定且不改变设定速度(短按仍照常降低设定速度),离开区间时复位。终点摄像头约提前 2km 预告后按点位减速。区间起点取平均速度信号出现的时刻,为实验功能。", "min": 0, "max": 1, "default": 0, From cacf6ff541ed253d0e71abffec26398abc770459 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:21:51 +0900 Subject: [PATCH 07/50] speedcam: a mobile zone must sit one lead ahead of the warning start A mobile-zone class suppresses deceleration (AutoNaviSpeedCtrlMode <= 2) or makes it skippable, so it must be the zone that started the warning. Before, any same-speed kind 3 preview within -50 m .. lead+tolerance of the current position could classify it; a warning without its own preview (signal cameras send none) that began as unknown/hard could therefore be turned into a zone later in the warning and lose its deceleration. Now an unflagged kind 3 preview counts only when its target is within 60 m of warning start + 6.1 m x limit, whenever it arrived. Fixed, signal, box and rear previews keep the existing window. Data (37 own drives, 1,099 km, 209 warnings, 2026-09-30): all 44 zone warnings have their kind 3 target at start + lead -12..+17 m, including two whose preview arrived 7-9 m after the warning began. Replaying the policy over those drives gives identical classes before and after. Tests: 153 passed (3 new: late zone preview at the start lead, unrelated zone cannot release a running warning, off-lead zone ignored at start). Co-Authored-By: Claude Opus 5.5 --- .../opendbc/car/hyundai/navi_speedcam.py | 18 ++++++++++-- .../car/hyundai/tests/test_navi_speedcam.py | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index c437fd335f6..8b4c1bf61e2 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -33,6 +33,10 @@ WARNING_LEAD_TOLERANCE = 0.2 WARNING_LEAD_MIN_TOLERANCE_M = 60.0 MATCH_BEHIND_M = 50.0 +# A mobile zone suppresses deceleration, so it must be the zone that started this warning: +# its target sits one lead ahead of the warning start (44/44 zone warnings in 37 drives +# within -12..+17 m, 2026-09-30). A kind 3 preview elsewhere never turns a warning into a zone. +ZONE_LEAD_TOLERANCE_M = 60.0 RECENT_PREVIEW_M = 2600.0 PREVIEW_KEEP_M = 3000.0 @@ -57,6 +61,7 @@ def __init__(self): def reset_warning(self): self.warning_active = False self.warning_speed = 0 + self.warning_start_distance = None self.warning_class = None self.skipped = False @@ -75,20 +80,26 @@ def add_preview(self, value, offset, total_distance): }) self.previews = [p for p in self.previews if total_distance - p["received"] <= PREVIEW_KEEP_M] - def classify(self, speed, total_distance, starting=True): + def classify(self, speed, total_distance, starting=True, start_distance=None): """Classify the warning at total_distance from same-speed previews (None when none match). When a warning starts its camera sits one warning lead ahead. A saturated preview (entered at the ~2 km horizon) matches only when its target is at that lead; a precise preview may also be nearer, e.g. a warning that began before logging. - Upgrades during a warning use precise previews only. + Upgrades during a warning use precise previews only. A mobile zone (unflagged kind 3) + counts only when its target is one lead ahead of the warning start, whenever it arrived. """ lead = speed * WARNING_LEAD_M_PER_KPH tolerance = max(WARNING_LEAD_MIN_TOLERANCE_M, lead * WARNING_LEAD_TOLERANCE) + if start_distance is None: + start_distance = total_distance candidates = [] for p in self.previews: if p["speed"] != speed or total_distance - p["received"] > RECENT_PREVIEW_M: continue + if (p["kind"] == KIND_MOBILE_ZONE and not p["flagged"] and + abs(p["target"] - start_distance - lead) > ZONE_LEAD_TOLERANCE_M): + continue ahead = p["target"] - total_distance if p["saturated"]: matched = starting and abs(ahead - lead) <= tolerance @@ -116,8 +127,9 @@ def update(self, warning_active, warning_speed, total_distance, accel_rising, self.reset_warning() self.warning_active = True self.warning_speed = warning_speed + self.warning_start_distance = total_distance - current = self.classify(warning_speed, total_distance, starting=starting) + current = self.classify(warning_speed, total_distance, starting=starting, start_distance=self.warning_start_distance) # A warning never downgrades: a fixed camera inside a mobile zone keeps it hard. if current is not None and (self.warning_class is None or current > self.warning_class): self.warning_class = current diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index 7bd52471bf8..5b1dea969d1 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -95,3 +95,32 @@ def test_speed_change_starts_a_new_warning(): policy.add_preview(0xB1, 305, 300.0) # 50 km/h signal camera assert _update(policy, 300.0, speed=50, mobile_decel=True, skip_mobile=True, accel=True) == (False, False) assert not policy.skipped and policy.warning_class == CLASS_HARD + + +def test_late_mobile_zone_preview_at_the_start_lead_still_counts(): + # 00000462 / 00000473 (2026-09): the zone preview arrived 7-9 m after the warning began, + # its target still one lead (366 m @60) ahead of the warning start. + policy = SpeedcamPolicy() + assert _update(policy, 0.0) == (False, False) # no match yet: hard + policy.add_preview(0xD3, 347, 8.0) # target 355 m = start + lead - 11 + assert _update(policy, 8.0) == (True, False) + assert policy.warning_class == CLASS_MOBILE_ZONE + + +def test_unrelated_mobile_zone_cannot_release_a_running_warning(): + # A warning with no preview of its own (e.g. a signal camera) must stay hard even when + # a same-speed mobile-zone preview shows up nearby later in the warning. + policy = SpeedcamPolicy() + assert _update(policy, 0.0) == (False, False) + policy.add_preview(0xD3, 150, 100.0) # target 250 m: 116 m off the start lead + assert _update(policy, 100.0) == (False, False) + assert policy.warning_class is None # still unknown, treated as hard + policy.add_preview(0xD3, 150, 300.0) # target 450 m, also nowhere near the lead + assert _update(policy, 300.0) == (False, False) + + +def test_mobile_zone_far_from_the_start_lead_is_ignored_even_at_warning_start(): + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 200, 0.0) # precise, 200 m ahead: inside the old window + assert _update(policy, 0.0) == (False, False) + assert policy.warning_class is None From eca3454503c534fff35300ddd08ecf7e8026f30e Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:22:21 +0900 Subject: [PATCH 08/50] speedcam: merge repeated navigation previews The stock navigation repeats each 0x4BE camera preview (374 frames for 122 announcements on 2026-09-12). add_preview appended every repeat and only pruned by distance travelled, so copies piled up while stopped and the per-frame classification loop grew with them. A repeat with the same kind, speed and flag within 20 m of an existing target (the carstate event-merge distance) now refreshes that entry instead; a precise position replaces a horizon-bound (saturated) one and is never replaced by a later saturated repeat. Classification is unchanged: the bulk replay over 37 drives gives the same classes. Tests: 155 passed (2 new: repeats while stopped stay one entry, precise repeat replaces a saturated position). Co-Authored-By: Claude Opus 5.5 --- .../opendbc/car/hyundai/navi_speedcam.py | 15 +++++++++++-- .../car/hyundai/tests/test_navi_speedcam.py | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 8b4c1bf61e2..8e0572ee482 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -38,6 +38,7 @@ # within -12..+17 m, 2026-09-30). A kind 3 preview elsewhere never turns a warning into a zone. ZONE_LEAD_TOLERANCE_M = 60.0 RECENT_PREVIEW_M = 2600.0 +PREVIEW_MERGE_M = 20.0 # the stock navigation repeats each preview (~3x); same as carstate event merging PREVIEW_KEEP_M = 3000.0 @@ -74,11 +75,21 @@ def add_preview(self, value, offset, total_distance): if decoded is None: return kind, speed, flagged = decoded + target = total_distance + offset + saturated = offset >= PREVIEW_SATURATED_OFFSET + self.previews = [p for p in self.previews if total_distance - p["received"] <= PREVIEW_KEEP_M] + for p in self.previews: + if (p["kind"], p["speed"], p["flagged"]) == (kind, speed, flagged) and abs(p["target"] - target) < PREVIEW_MERGE_M: + # A repeat refreshes the entry; a precise position wins over a horizon-bound one. + p["received"] = total_distance + if not saturated or p["saturated"]: + p["target"] = target + p["saturated"] = p["saturated"] and saturated + return self.previews.append({ "kind": kind, "speed": speed, "flagged": flagged, "received": total_distance, - "target": total_distance + offset, "saturated": offset >= PREVIEW_SATURATED_OFFSET, + "target": target, "saturated": saturated, }) - self.previews = [p for p in self.previews if total_distance - p["received"] <= PREVIEW_KEEP_M] def classify(self, speed, total_distance, starting=True, start_distance=None): """Classify the warning at total_distance from same-speed previews (None when none match). diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index 5b1dea969d1..e462df742fe 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -124,3 +124,25 @@ def test_mobile_zone_far_from_the_start_lead_is_ignored_even_at_warning_start(): policy.add_preview(0xD3, 200, 0.0) # precise, 200 m ahead: inside the old window assert _update(policy, 0.0) == (False, False) assert policy.warning_class is None + + +def test_repeated_previews_merge_and_do_not_pile_up_while_stopped(): + policy = SpeedcamPolicy() + for _ in range(50): # stationary: repeats at the same odometer + policy.add_preview(0xD3, 361, 0.0) + assert len(policy.previews) == 1 + policy.add_preview(0xD3, 351, 10.0) # same camera 10 m later: same target + policy.add_preview(0xD0, 361, 10.0) # a different kind at that spot stays separate + assert len(policy.previews) == 2 + assert policy.previews[0]["received"] == 10.0 + + +def test_precise_repeat_replaces_a_horizon_bound_position(): + policy = SpeedcamPolicy() + policy.add_preview(0xD3, 1995, 0.0) # saturated: target only bounded + policy.add_preview(0xD3, 1980, 10.0) # precise repeat, 5 m further + assert len(policy.previews) == 1 + p = policy.previews[0] + assert (p["target"], p["saturated"]) == (1990.0, False) + policy.add_preview(0xD3, 1990, 12.0) # a later saturated repeat keeps the precise target + assert (p["target"], p["saturated"]) == (1990.0, False) From 8e90e7914caf5251fd1ffd3964e349b580c30585 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:27:00 +0900 Subject: [PATCH 09/50] speedcam: drive the section allowance in true speed The section allowance was computed from the vEgo odometer and handed to carrot_serv as vehicleNaviSpeed. carrot_serv multiplies it by AutoNaviSpeedSafetyFactor and treats it as a cluster speed, and the planner converts cluster to vEgo with vCluRatio, so the car drove about factor x vCluRatio = 1.07 x 0.968 = +3.6 % above the allowance while spending banked time. - Invert that chain: vehicleNaviSpeed = allowance / DISTANCE_SCALE / vCluRatio / factor, so the driven vEgo speed is the allowance. - Scale the vEgo odometer to true distance in the bank (DISTANCE_SCALE 1.015). GPS / vEgo on this car is 1.0054-1.0074 in five drives (08-07..09-2x, p90 1.012); the 0x4B4 display average runs 3.3 % above vEgo because it follows the cluster speed (1/vCluRatio 1.029-1.035), so it is not used as the reference. - Without banked time the cap stays the locked one (limit x factor), as before; the docs now say this can exceed the limit by the factor. Tests: 157 passed. New end-to-end test banks time in congestion, unlocks and follows the target through carrot_serv/planner scaling with true speed = vEgo x 1.0074; the true running average stays <= limit while the bank is spent. It fails on the previous logic (80.10 > 80.1 km/h). Co-Authored-By: Claude Opus 5.5 --- docs/user/en/speed-deceleration.md | 2 +- docs/user/ko/speed-deceleration.md | 2 +- opendbc_repo/opendbc/car/hyundai/carstate.py | 10 +++- .../opendbc/car/hyundai/section_average.py | 9 ++- .../car/hyundai/tests/test_section_average.py | 8 +-- .../car/hyundai/tests/test_speed_camera.py | 57 ++++++++++++++++++- 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index 4ff12729eca..a3c2b0be8bf 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -107,7 +107,7 @@ When enabled, while engaged and that kind of camera decelerates, one press of cr ### `VehicleNaviSectionAvgControl` -When enabled, a stock-navigation section (vehicle average-speed signal present) still stays capped at the section limit × `AutoNaviSpeedSafetyFactor` (for example 117 in a 110 section at 107%). While engaged and with the cruise set speed above that cap, one press of cruise `+` or a gas tap unlocks the section. Up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (for example in congestion) can be spent; without it, or once it is spent, the cap applies. The unlocking input does not raise the set speed (neither `+1` nor the gas-tap `+10`). A long press of cruise `-` locks the section again without changing the set speed (while unlocked, a long `-` does not do its −10 step; a short press still lowers the set speed as usual). Leaving the section resets it. +When enabled, a stock-navigation section (vehicle average-speed signal present) still stays capped at the section limit × `AutoNaviSpeedSafetyFactor` (for example 117 in a 110 section at 107%). While engaged and with the cruise set speed above that cap, one press of cruise `+` or a gas tap unlocks the section. Up to the set speed is then allowed only while the average since entering the section stays at or below the section limit. Only time banked by driving slower (for example in congestion) can be spent; without it, or once it is spent, the cap applies. The bank uses the vehicle-speed (vEgo) odometer scaled to true distance (×1.015; vEgo reads about 0.6 % below GPS), and the allowance is passed back through the safety factor and the cluster/actual speed ratio so the car actually drives at it. Without banked time the cap is the same section limit × `AutoNaviSpeedSafetyFactor` as when locked, so if that is above the limit in true speed (for example 107 %), the average can exceed the limit by that much. The unlocking input does not raise the set speed (neither `+1` nor the gas-tap `+10`). A long press of cruise `-` locks the section again without changing the set speed (while unlocked, a long `-` does not do its −10 step; a short press still lowers the set speed as usual). Leaving the section resets it. Once unlocked, no virtual camera distance is used inside the section, and the end camera decelerates to the spot limit after its preview about 2 km ahead. The average uses the vehicle odometer (`vEgo`) with no margin. In a recorded drive the navigation-displayed average was 2–3 km/h higher than this calculation, so adjust the cruise set speed yourself if needed. The section start is taken when the average-speed signal appears; experimental. diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index c6fc9dac396..3dbeb4b0606 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -109,7 +109,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. ### `VehicleNaviSectionAvgControl` -켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중에도 평소에는 구간 제한속도 × `AutoNaviSpeedSafetyFactor`로 고정합니다(예: 110 구간, 107% → 117). openpilot 작동 중이고 크루즈 설정 속도가 이 값보다 높을 때 크루즈 `+`를 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풉니다. 그 뒤로는 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없거나 다 쓰면 고정값과 같습니다. 해제에 쓴 입력은 설정 속도를 올리지 않습니다(`+1`, 악셀톡 `+10` 모두 반영 안 함). 크루즈 `-`를 길게 누르면 설정 속도는 그대로 두고 다시 고정합니다(해제된 동안의 `-` 길게는 −10 동작을 하지 않으며, 짧게 누르면 평소처럼 설정 속도를 내립니다). 구간이 끝나면 초기화됩니다. +켜면 순정 내비 구간단속(차량 평균속도 신호가 켜진 구간) 중에도 평소에는 구간 제한속도 × `AutoNaviSpeedSafetyFactor`로 고정합니다(예: 110 구간, 107% → 117). openpilot 작동 중이고 크루즈 설정 속도가 이 값보다 높을 때 크루즈 `+`를 한 번 누르거나 악셀톡을 하면 그 구간의 고정을 풉니다. 그 뒤로는 구간 진입 후 평균속도가 구간 제한속도 이하로 유지되는 범위에서만 설정 속도까지 허용합니다. 정체 등으로 늦게 간 시간만 쓸 수 있고, 적립이 없거나 다 쓰면 고정값과 같습니다. 적립은 차량 속도(vEgo)로 잰 거리를 실제 거리로 보정(×1.015, 실측 GPS 대비 vEgo가 약 0.6% 낮음)해 계산하고, 허용속도는 안전계수와 계기/실제 속도 비를 되돌려 실제 주행 속도가 허용속도가 되게 넘깁니다. 적립이 없을 때의 상한은 잠금 상태와 같은 구간 제한속도 × `AutoNaviSpeedSafetyFactor`라서, 이 값이 실제 속도로 제한보다 높으면(예: 107%) 그만큼 평균이 제한을 넘을 수 있습니다. 해제에 쓴 입력은 설정 속도를 올리지 않습니다(`+1`, 악셀톡 `+10` 모두 반영 안 함). 크루즈 `-`를 길게 누르면 설정 속도는 그대로 두고 다시 고정합니다(해제된 동안의 `-` 길게는 −10 동작을 하지 않으며, 짧게 누르면 평소처럼 설정 속도를 내립니다). 구간이 끝나면 초기화됩니다. 해제 후에는 구간 안에서 가상거리 감속을 쓰지 않고, 종점 카메라는 약 2km 전 예고를 받은 뒤 지점 감속합니다. 평균은 차량 주행거리(`vEgo`)로 계산하며 여유를 두지 않습니다. 기록 주행에서 내비가 표시한 평균이 이 계산보다 2~3km/h 높았으므로 필요하면 크루즈 설정 속도로 직접 조절하세요. 구간 시작은 평균속도 신호가 켜진 시점으로 잡는 실험 기능입니다. diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index df05be585cb..2086ef89d18 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -10,7 +10,7 @@ from opendbc.car.carlog import carlog from opendbc.car.hyundai.hyundaicanfd import CanBus from opendbc.car.hyundai.navi_speedcam import SpeedcamPolicy -from opendbc.car.hyundai.section_average import SectionAverage +from opendbc.car.hyundai.section_average import DISTANCE_SCALE as SECTION_DISTANCE_SCALE, SectionAverage from opendbc.car.hyundai.steering_touch import HyundaiSteeringTouch from opendbc.car.hyundai.values import HyundaiFlags, CAR, DBC, Buttons, CarControllerParams, CAMERA_SCC_CAR, HyundaiExtFlags, \ EV_MODE_ACTIVE_VALUES, EV_MODE_STATUS_ADDR, EV_MODE_STATUS_DLC, EV_MODE_STATUS_MSG, \ @@ -791,9 +791,13 @@ def _apply_section_average(self, ret, accel_rising, gas_tok, decel_long_press): if not section.unlocked: return # existing section cap and camera distances, unchanged + # allowance is a true speed; carrot_serv caps at vehicleNaviSpeed x factor as a cluster speed and + # the planner drives cluster x vCluRatio in vEgo units, so invert that chain (else ~+3.6 % over). allowance = section.allowance_kph(self.speedcam_time, self.totalDistance) - if allowance > section_speed: - ret.vehicleNaviSpeed = allowance # carrot_serv caps the section at vehicleNaviSpeed x factor + clu_ratio = ret.vCluRatio if ret.vCluRatio > 0.5 else 1.0 + navi_speed = allowance / SECTION_DISTANCE_SCALE / clu_ratio / max(self.speedcamSafetyFactor, 0.5) + if navi_speed > section_speed: + ret.vehicleNaviSpeed = navi_speed # Keep spot deceleration only for a same-speed camera ahead (the end camera); no virtual distance. ahead = [event["target"] - self.totalDistance for event in self.vehicleNaviEvents if event["type"] == "camera" and event["speed"] == section_speed and event["target"] > self.totalDistance] diff --git a/opendbc_repo/opendbc/car/hyundai/section_average.py b/opendbc_repo/opendbc/car/hyundai/section_average.py index d65d27feeb6..a90703728a1 100644 --- a/opendbc_repo/opendbc/car/hyundai/section_average.py +++ b/opendbc_repo/opendbc/car/hyundai/section_average.py @@ -7,12 +7,17 @@ below the limit whatever the remaining length is. The allowance spends B over a fixed horizon and falls back to the limit cap as B runs out. A long press of cruise - locks the section again; leaving the section resets it. + +Distances are carstate's vEgo odometer. vEgo reads about 0.6 % below GPS speed on +this car (1.0054-1.0074 in five drives, p90 1.012; 2026-09-30), so the bank scales +them by DISTANCE_SCALE and the allowance is an estimate of true (camera) speed. """ SECTION_END_DEBOUNCE_S = 3.0 SPEND_HORIZON_M = 1000.0 MIN_BANK_S = 0.5 MAX_ALLOWANCE_KPH = 250.0 +DISTANCE_SCALE = 1.015 # vEgo odometer -> true distance, with margin over the measured 1.006 class SectionAverage: @@ -56,10 +61,10 @@ def _limit_ms(self): def bank_seconds(self, now, total_distance): if not self.active: return 0.0 - return (now - self.start_time) - (total_distance - self.start_distance) / self._limit_ms() + return (now - self.start_time) - (total_distance - self.start_distance) * DISTANCE_SCALE / self._limit_ms() def allowance_kph(self, now, total_distance): - """Speed (km/h, actual) that spends the bank over SPEND_HORIZON_M; 0 when locked or without bank.""" + """True speed (km/h) that spends the bank over SPEND_HORIZON_M; 0 when locked or without bank.""" if not self.unlocked: return 0.0 bank = self.bank_seconds(now, total_distance) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py b/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py index 6ad4df36663..e1f3e4f10c2 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_section_average.py @@ -1,6 +1,6 @@ import pytest -from opendbc.car.hyundai.section_average import SECTION_END_DEBOUNCE_S, SectionAverage +from opendbc.car.hyundai.section_average import DISTANCE_SCALE, SECTION_END_DEBOUNCE_S, SectionAverage def _drive(avg, speeds_kph, dt=0.05, start_time=0.0, start_distance=0.0, limit=80): @@ -37,7 +37,7 @@ def test_no_bank_means_no_allowance(): def test_congestion_banks_time_and_allows_more_than_the_limit(): avg = _unlocked() now, distance = _drive(avg, [30] * int(60 / 0.05)) # one minute at 30 km/h - assert avg.bank_seconds(now, distance) == pytest.approx(60 - 500 / (80 / 3.6), rel=0.01) + assert avg.bank_seconds(now, distance) == pytest.approx(60 - 500 * DISTANCE_SCALE / (80 / 3.6), rel=0.01) assert avg.allowance_kph(now, distance) > 80 @@ -50,8 +50,8 @@ def test_running_average_never_exceeds_the_limit_while_spending_the_bank(): if allowance == 0.0: break # bank spent: back to the normal cap now += 0.05 - distance += min(130, max(80, allowance)) / 3.6 * 0.05 - assert distance / now * 3.6 <= 80 + 0.5 + distance += min(130, max(80, allowance)) / DISTANCE_SCALE / 3.6 * 0.05 # vEgo odometer + assert distance * DISTANCE_SCALE / now * 3.6 <= 80 + 0.5 # true average assert avg.bank_seconds(now, distance) <= 1.0 diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index ed5b5d149b9..fd916e2633c 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -6,7 +6,7 @@ import pytest from opendbc.can import CANParser -from opendbc.car import Bus, structs +from opendbc.car import Bus, DT_CTRL, structs from opendbc.car.hyundai.carstate import ( CANFD_HDA_INFO_MSG, CANFD_NAVI_PROFILE_MSG, CANFD_NAVI_STATUS_MSG, CarState, VEHICLE_NAVI_POSITION_TIMEOUT_NS, VEHICLE_NAVI_ROUTE_TIMEOUT_NS, VEHICLE_NAVI_SCHOOL_ZONE_MAX_DISTANCE, @@ -1359,8 +1359,13 @@ def _section_state(): return state +CLU_RATIO = 0.968 # vEgo / cluster speed on this car (1/1.033) +TRUE_OVER_VEGO = 1.0074 # GPS / vEgo, largest drive median (2026-09-30) + + def _section_ret(v_ego=16.0): - return SimpleNamespace(vEgo=v_ego, vehicleNaviSectionActive=True, vehicleNaviSpeed=80.0, speedLimitDistance=1.0) + return SimpleNamespace(vEgo=v_ego, vehicleNaviSectionActive=True, vehicleNaviSpeed=80.0, speedLimitDistance=1.0, + vCluRatio=CLU_RATIO) def test_section_stays_capped_until_unlocked(): @@ -1467,3 +1472,51 @@ def test_minus_passes_through_while_section_locked(): assert state._hold_speedcam_decel(press, False) == press assert state._hold_speedcam_decel([], True) == [] assert state._hold_speedcam_decel(release, False) == release + + +def test_section_allowance_is_inverted_through_factor_and_cluster_ratio(): + state = _section_state() + state._apply_section_average(_section_ret(), True, False, False) # unlock + state.speedcam_time += 60.0 # bank a minute + ret = _section_ret() + state._apply_section_average(ret, False, False, False) + allowance = state.section_average.allowance_kph(state.speedcam_time, state.totalDistance) + assert ret.vehicleNaviSpeed == pytest.approx(allowance / 1.015 / CLU_RATIO / 1.07) + # What the car then drives (carrot_serv x factor, planner x vCluRatio) is the allowance in vEgo units. + assert ret.vehicleNaviSpeed * 1.07 * CLU_RATIO == pytest.approx(allowance / 1.015) + + +def test_section_true_average_stays_at_or_below_the_limit_end_to_end(): + # Congestion banks time; the driver unlocks with the set speed at 130 and the car follows the + # section target the way carrot_serv (x factor, cluster speed) and the planner (x vCluRatio) do. + # Its true speed is vEgo x TRUE_OVER_VEGO. While banked time is being spent the true running + # average must stay at or below the limit. + state = _section_state() + state.speedcam_v_cruise = 130.0 + dt = DT_CTRL * 10 + true_distance = 0.0 + + def step(v_ego, unlock=False): + nonlocal true_distance + ret = _section_ret(v_ego) + state.speedcam_time += dt - DT_CTRL # _apply_section_average adds DT_CTRL itself + state._apply_section_average(ret, unlock, False, False) + state.totalDistance += v_ego * dt + true_distance += v_ego * TRUE_OVER_VEGO * dt + return ret + + for _ in range(int(120 / dt)): + step(40 / 3.6 / TRUE_OVER_VEGO) # two minutes at a true 40 km/h + ret = step(40 / 3.6 / TRUE_OVER_VEGO, unlock=True) + assert state.section_average.unlocked + + spent_steps = 0 + while state.section_average.allowance_kph(state.speedcam_time, state.totalDistance) > 0.0: + v_ego = min(130.0, ret.vehicleNaviSpeed * 1.07) * CLU_RATIO / 3.6 + ret = step(v_ego) + elapsed = state.speedcam_time - state.section_average.start_time + assert true_distance / elapsed * 3.6 <= 80.0 + 0.1 + spent_steps += 1 + assert spent_steps < int(1200 / dt) + assert spent_steps * dt > 30 # the bank was actually used above the limit + From f79974b7ffbdf33e5aa6fcff1a2b728d30cbda88 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:40:28 +0900 Subject: [PATCH 10/50] speedcam: one deceleration cancel request, now also for speed bumps Replace the separate VehicleNaviSkipBoxCamera / VehicleNaviSkipMobileZone toggles with a single "deceleration cancel request": - VehicleNaviDecelCancel (master, default off) - VehicleNaviDecelCancelBox, VehicleNaviDecelCancelMobileZone, VehicleNaviDecelCancelBump (per-kind, default on), shown in the master's detail screen via detail_parent like the ShareData ONNX settings. Turning on the master enables all kinds. The old keys only ever existed on this branch, which no device has run. Speed bumps get the same cancel as box cameras and mobile zones: while engaged above 5 m/s and the nearest stock-navigation bump is actually slowing the car (carrot_serv's bump profile from AutoNaviSpeedBumpSpeed / Time / DecelRate is below the set speed and the bump is beyond AutoNaviSpeedBumpEndDistance), cruise + or a gas tap marks that bump cancelled and speedBumpDistance moves on to the next bump. The input is swallowed from cruise.py as for cameras; a camera skip in the same frame wins. The cancel survives a repeated announcement of the same bump. Tests: 162 passed (5 new bump/master tests); settings schema test rewritten for the master/child keys and the removed ones, 104 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 18 ++-- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 18 ++-- opendbc_repo/opendbc/car/hyundai/carstate.py | 40 ++++++++- .../car/hyundai/tests/test_speed_camera.py | 82 ++++++++++++++++++- openpilot/common/params_keys.h | 6 +- .../server/tests/test_settings_schema.py | 23 ++++-- openpilot/selfdrive/carrot_settings.json | 80 ++++++++++++++---- 9 files changed, 228 insertions(+), 43 deletions(-) diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 89f7513eb1f..e15770bc4ff 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index a3c2b0be8bf..d0c7fda69bf 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -97,13 +97,21 @@ The current stock warning (0x4A3) carries no camera kind. The kind of the previe | Signal-and-speed | Traffic-light icon | Always decelerates; no skip | | Rear speed, rear signal-and-speed | — | Always decelerates; no skip (identified by the preview's rear flag) | | Mobile enforcement zone | Blue circle | Decelerates when `AutoNaviSpeedCtrlMode` is `3`; ignored at `2` or lower | -| Mobile-camera box | — | Decelerates; skippable with `VehicleNaviSkipBoxCamera` | +| Mobile-camera box | — | Decelerates; can be cancelled with the deceleration cancel request | A warning that also carries a fixed, signal, or rear camera always decelerates, even inside a mobile zone. -### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` +### `VehicleNaviDecelCancel` (deceleration cancel request) -When enabled, while engaged and that kind of camera decelerates, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) releases its deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the next camera decelerates again after the warning ends. While disengaged or below about 18 km/h, `+` and the gas tap keep their normal resume/engage/set-speed roles. `VehicleNaviSkipMobileZone` matters only when `AutoNaviSpeedCtrlMode` is `3`, so mobile zones decelerate. Both are off by default. +When enabled, while engaged and one of the kinds enabled in its details is decelerating, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the cancel applies to that one camera or bump; the next one decelerates again. While disengaged, below about 18 km/h, or when that kind is not actually decelerating, `+` and the gas tap keep their normal resume/engage/set-speed roles. Fixed, signal and rear cameras and section enforcement cannot be cancelled. Off by default. + +Per-kind toggles (in the setting's detail screen, on by default so turning on the master enables all): + +| Key | Applies to | +|---|---| +| `VehicleNaviDecelCancelBox` | Mobile-camera box | +| `VehicleNaviDecelCancelMobileZone` | Mobile enforcement zone; matters only when `AutoNaviSpeedCtrlMode` is `3`, so zones decelerate | +| `VehicleNaviDecelCancelBump` | Stock-navigation speed bump. The input is used only while the bump target (from the speed-bump settings) is below the set speed, i.e. actually decelerating, and cancels the nearest bump only | ### `VehicleNaviSectionAvgControl` @@ -236,7 +244,7 @@ For `-1`, a non-negative offset selects limit + offset; a negative offset select ## 3. Speed bumps -Speed-bump control uses `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, and `AutoNaviSpeedBumpEndDistance`. It requires `AutoNaviSpeedCtrlMode >= 2`, a bump event and distance, and a road category that the code does not treat as highway. A stock-navigation CAN bump must also satisfy the route condition selected by `VehicleNaviCanControl`. +Speed-bump control uses `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, and `AutoNaviSpeedBumpEndDistance`. It requires `AutoNaviSpeedCtrlMode >= 2`, a bump event and distance, and a road category that the code does not treat as highway. A stock-navigation CAN bump must also satisfy the route condition selected by `VehicleNaviCanControl`. With the deceleration cancel request on (`VehicleNaviDecelCancel` + `VehicleNaviDecelCancelBump`), `+` or a gas tap during a bump deceleration cancels the nearest bump. ### `AutoNaviSpeedBumpSpeed` diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 3e41c7e5421..7a5aa97c438 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index 3dbeb4b0606..f26e5eb109f 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone`, `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -99,13 +99,21 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | | 후면 과속, 후면 신호·과속 | — | 항상 감속, 건너뛰기 없음 (예고의 후면 표시로 구분) | | 이동식 단속 구간 | 파란 원 | `AutoNaviSpeedCtrlMode`가 `3`이면 감속, `2` 이하면 무시 | -| 이동식 박스 | — | 감속, `VehicleNaviSkipBoxCamera`로 건너뛰기 가능 | +| 이동식 박스 | — | 감속, 감속 중지 요청으로 중지 가능 | 같은 경고에 고정식·신호·후면 카메라가 함께 있으면 이동식 구간이어도 항상 감속합니다. -### `VehicleNaviSkipBoxCamera`, `VehicleNaviSkipMobileZone` +### `VehicleNaviDecelCancel` (감속 중지 요청) -켜면 openpilot 작동 중 해당 종류의 카메라로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 카메라의 감속을 해제합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 경고가 끝나면 다음 카메라에는 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만에서는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. `VehicleNaviSkipMobileZone`은 `AutoNaviSpeedCtrlMode`가 `3`이라 이동식 구간에서 감속할 때만 의미가 있습니다. 둘 다 기본값은 꺼짐입니다. +켜면 openpilot 작동 중 아래 세부 항목에서 켠 종류로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 중지는 그 카메라·방지턱 하나에만 적용되어 다음 것은 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만, 또는 해당 종류가 실제로 감속 중이 아닐 때는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. 고정식·신호·후면 카메라와 구간단속은 중지할 수 없습니다. 기본값은 꺼짐입니다. + +세부 항목(설정 상세 화면, 기본값 켜짐 — 상위 항목을 켜면 모두 동작): + +| 키 | 대상 | +|---|---| +| `VehicleNaviDecelCancelBox` | 이동식 박스 카메라 | +| `VehicleNaviDecelCancelMobileZone` | 이동식 단속 구간. `AutoNaviSpeedCtrlMode`가 `3`이라 감속할 때만 의미 | +| `VehicleNaviDecelCancelBump` | 순정 내비 과속방지턱. 방지턱 감속 목표(방지턱 설정으로 계산)가 설정 속도보다 낮아 실제로 감속 중일 때만 입력을 쓰고, 가장 가까운 방지턱 하나만 중지 | ### `VehicleNaviSectionAvgControl` @@ -278,7 +286,7 @@ Kia PV5는 순정 내비의 같은 단속 상태·제한속도가 계속 수신 ## 3. 과속방지턱 -관련 설정은 `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance`입니다. 방지턱 감속은 `AutoNaviSpeedCtrlMode >= 2`이고, 내비가 방지턱 종류와 거리를 제공하며, 코드가 고속도로가 아닌 도로 범주로 판단할 때 사용합니다. 차량 순정 내비 CAN 방지턱은 `VehicleNaviCanControl`의 경로 조건도 만족해야 합니다. +관련 설정은 `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance`입니다. 방지턱 감속은 `AutoNaviSpeedCtrlMode >= 2`이고, 내비가 방지턱 종류와 거리를 제공하며, 코드가 고속도로가 아닌 도로 범주로 판단할 때 사용합니다. 차량 순정 내비 CAN 방지턱은 `VehicleNaviCanControl`의 경로 조건도 만족해야 합니다. 감속 중지 요청(`VehicleNaviDecelCancel` + `VehicleNaviDecelCancelBump`)을 켜면 방지턱으로 감속하는 동안 `+`나 악셀톡으로 가장 가까운 방지턱의 감속을 중지할 수 있습니다. ### `AutoNaviSpeedBumpSpeed` diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 2086ef89d18..5a00958492f 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -724,8 +724,17 @@ def _update_vehicle_speed_camera_params(self): def _read_speedcam_params(self): # AutoNaviSpeedCtrlMode 3 (+mobile cameras) also decelerates for stock mobile zones (0x4BE kind 3). self.speedcamMobileZoneDecel = self.op_params.get_int("AutoNaviSpeedCtrlMode") >= 3 - self.speedcamSkipBox = self.op_params.get_bool("VehicleNaviSkipBoxCamera") - self.speedcamSkipMobileZone = self.op_params.get_bool("VehicleNaviSkipMobileZone") + # Deceleration cancel request (+ / gas tap): one master toggle with a toggle per kind in its details. + cancel = self.op_params.get_bool("VehicleNaviDecelCancel") + self.speedcamSkipBox = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBox") + self.speedcamSkipMobileZone = cancel and self.op_params.get_bool("VehicleNaviDecelCancelMobileZone") + self.speedcamCancelBump = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBump") + # carrot_serv's stock-navigation bump source, mirrored to tell when a bump is actually decelerating. + self.speedcamBumpDecel = self.op_params.get_int("AutoNaviSpeedCtrlMode") >= 2 + self.speedcamBumpSpeed = float(self.op_params.get_int("AutoNaviSpeedBumpSpeed")) + self.speedcamBumpTime = float(self.op_params.get_int("AutoNaviSpeedBumpTime")) + self.speedcamBumpEndDistance = min(5000, max(0, self.op_params.get_int("AutoNaviSpeedBumpEndDistance"))) * 0.01 + self.speedcamDecelRate = self.op_params.get_int("AutoNaviSpeedDecelRate") * 0.01 self.speedcamSectionAvgControl = self.op_params.get_bool("VehicleNaviSectionAvgControl") # The existing section cap is limit x AutoNaviSpeedSafetyFactor (carrot_serv); unlock only above it. self.speedcamSafetyFactor = self.op_params.get_int("AutoNaviSpeedSafetyFactor") * 0.01 @@ -764,6 +773,32 @@ def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_s self._consume_speedcam_input(accel_rising, gas_tok) return False if suppress else speed_limit_cam + def _speedcam_bump_target_kph(self, distance): + """carrot_serv.calculate_current_speed for the bump source (cluster km/h).""" + safe = self.speedcamBumpSpeed / 3.6 + decel_dist = distance - safe * self.speedcamBumpTime + if decel_dist <= 0: + return self.speedcamBumpSpeed + return max(self.speedcamBumpSpeed, min(250.0, math.sqrt(safe ** 2 + 2 * self.speedcamDecelRate * decel_dist) * 3.6)) + + def _apply_speedcam_bump_cancel(self, ret, accel_rising, gas_tok): + """Deceleration cancel request for stock-navigation speed bumps: while the nearest bump is + actually slowing the car (its target below the set speed), + or a gas tap cancels that bump.""" + if self.canfd_wrapped_navi or not self.vehicleNaviCanControl: + return + live = [event for event in self.vehicleNaviEvents + if event["type"] == "bump" and event["target"] > self.totalDistance and not event.get("cancelled")] + ret.speedBumpDistance = live[0]["target"] - self.totalDistance if live else 0.0 + if (not live or not self.speedcamCancelBump or not self.speedcamBumpDecel or self.speedcam_input_consumed or + not (accel_rising or gas_tok) or not self.speedcam_engaged or ret.vEgo <= SPEEDCAM_SKIP_MIN_SPEED): + return + distance = ret.speedBumpDistance + if distance <= self.speedcamBumpEndDistance or self._speedcam_bump_target_kph(distance) >= self.speedcam_v_cruise: + return # carrot_serv is not slowing for this bump: + and the gas tap keep their roles + live[0]["cancelled"] = True + ret.speedBumpDistance = live[1]["target"] - self.totalDistance if len(live) > 1 else 0.0 + self._consume_speedcam_input(accel_rising, gas_tok) + def _consume_speedcam_input(self, accel_rising, gas_tok): # The input spent on a camera skip or section unlock must not also raise the set speed (+1 / gas tap +10). self.speedcam_input_consumed = True @@ -1528,6 +1563,7 @@ def corner_max(signal): speedcam_gas_tok = self._update_speedcam_gas_tok(ret.gasPressed) speed_limit_cam = self._apply_speedcam_policy(ret, speed_limit_cam, speedcam_warning_active, speedcam_warning_speed, speedcam_accel_rising, speedcam_gas_tok) + self._apply_speedcam_bump_cancel(ret, speedcam_accel_rising, speedcam_gas_tok) self.update_speed_limit(ret, speed_limit_cam, distance_time_changed) speedcam_decel_long_press = self._update_speedcam_decel_long_press() self._apply_section_average(ret, speedcam_accel_rising, speedcam_gas_tok, speedcam_decel_long_press) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index fd916e2633c..816df8b15cd 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -38,11 +38,16 @@ def get_int(self, key): return 107 if key == "CruiseButtonLongDelay": return 40 + if key in ("AutoNaviSpeedBumpSpeed", "AutoNaviSpeedBumpTime", "AutoNaviSpeedBumpEndDistance", "AutoNaviSpeedDecelRate"): + return {"AutoNaviSpeedBumpSpeed": 35, "AutoNaviSpeedBumpTime": 1, + "AutoNaviSpeedBumpEndDistance": 200, "AutoNaviSpeedDecelRate": 120}[key] raise KeyError(key) def get_bool(self, key): - if key in ("VehicleNaviSkipBoxCamera", "VehicleNaviSkipMobileZone", "VehicleNaviSectionAvgControl"): + if key in ("VehicleNaviDecelCancel", "VehicleNaviSectionAvgControl"): return False + if key in ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump"): + return True assert key == "VehicleNaviSchoolZoneControl" return self.school_zone @@ -1520,3 +1525,78 @@ def step(v_ego, unlock=False): assert spent_steps < int(1200 / dt) assert spent_steps * dt > 30 # the bank was actually used above the limit + +def _bump_state(cancel=True): + state = _speedcam_state() + state.speedcamCancelBump = cancel + state.speedcam_v_cruise = 60.0 + state.vehicleNaviEvents = [{"type": "bump", "speed": 0, "kind": 6, "target": 80.0}, + {"type": "bump", "speed": 0, "kind": 6, "target": 300.0}] + return state + + +def _bump_ret(): + return SimpleNamespace(vEgo=16.0, speedBumpDistance=80.0) + + +def test_bump_decel_cancel_releases_only_the_decelerating_bump(): + state = _bump_state() + assert state._speedcam_bump_target_kph(80.0) < 60.0 # 35 km/h bump 80 m ahead: slowing below the set 60 + ret = _bump_ret() + state._apply_speedcam_bump_cancel(ret, True, False) + assert ret.speedBumpDistance == pytest.approx(300.0) # the next bump still decelerates + assert state.speedcam_input_consumed and state.speedcam_accel_swallow + assert state.vehicleNaviEvents[0]["cancelled"] + + ret = _bump_ret() # following frames keep the cancel + state.speedcam_input_consumed = state.speedcam_accel_swallow = False + state._apply_speedcam_bump_cancel(ret, False, False) + assert ret.speedBumpDistance == pytest.approx(300.0) + + +def test_bump_cancel_needs_actual_deceleration_and_its_toggles(): + far = _bump_state() + far.vehicleNaviEvents = [{"type": "bump", "speed": 0, "kind": 6, "target": 400.0}] + assert far._speedcam_bump_target_kph(400.0) > 60.0 # not slowing yet + ret = SimpleNamespace(vEgo=16.0, speedBumpDistance=400.0) + far._apply_speedcam_bump_cancel(ret, True, False) + assert ret.speedBumpDistance == pytest.approx(400.0) and not far.speedcam_input_consumed + + off = _bump_state(cancel=False) # master or bump toggle off + ret = _bump_ret() + off._apply_speedcam_bump_cancel(ret, True, True) + assert ret.speedBumpDistance == pytest.approx(80.0) and not off.speedcam_input_consumed + + disengaged = _bump_state() + disengaged.speedcam_engaged = False + ret = _bump_ret() + disengaged._apply_speedcam_bump_cancel(ret, False, True) + assert ret.speedBumpDistance == pytest.approx(80.0) and not disengaged.speedcam_gas_tok_consumed + + +def test_bump_gas_tap_cancel_and_camera_skip_win_the_input(): + state = _bump_state() + ret = _bump_ret() + state._apply_speedcam_bump_cancel(ret, False, True) + assert ret.speedBumpDistance == pytest.approx(300.0) and state.speedcam_gas_tok_consumed + + taken = _bump_state() + taken.speedcam_input_consumed = True # this press already skipped a camera + ret = _bump_ret() + taken._apply_speedcam_bump_cancel(ret, True, False) + assert ret.speedBumpDistance == pytest.approx(80.0) + + +def test_bump_cancel_survives_a_repeated_announcement(): + state = _bump_state() + state._apply_speedcam_bump_cancel(_bump_ret(), True, False) + state._add_vehicle_navi_event("bump", 0, 6, 85.0) # same bump announced again, 5 m further + assert len(state.vehicleNaviEvents) == 2 and state.vehicleNaviEvents[0]["cancelled"] + + +def test_decel_cancel_master_gates_every_kind(): + state = _car_state() + assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump) + state.op_params.get_bool = lambda key: True + state._read_speedcam_params() + assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 4263cd48055..0b4940298bc 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -242,8 +242,10 @@ inline static std::unordered_map keys = { {"AutoNaviSpeedCtrlMode", {PERSISTENT, INT, "2"}}, {"VehicleNaviCanControl", {PERSISTENT, INT, "0"}}, {"VehicleNaviSchoolZoneControl", {PERSISTENT, BOOL, "0"}}, - {"VehicleNaviSkipBoxCamera", {PERSISTENT, BOOL, "0"}}, - {"VehicleNaviSkipMobileZone", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviDecelCancel", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviDecelCancelBox", {PERSISTENT, BOOL, "1"}}, + {"VehicleNaviDecelCancelMobileZone", {PERSISTENT, BOOL, "1"}}, + {"VehicleNaviDecelCancelBump", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, {"VehicleSpeedCameraControlMode", {PERSISTENT, INT, "1"}}, {"VehicleSpeedCameraDistanceTime", {PERSISTENT, INT, "60"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index fc04e2d290b..9e4296e12c0 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -364,24 +364,31 @@ def test_vehicle_navi_school_zone_control_is_opt_in(settings, params): assert '{"VehicleNaviSchoolZoneControl", {PERSISTENT, BOOL, "0"}}' in params_keys -def test_speedcam_skip_and_section_average_settings_are_opt_in(settings, params): +def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, params): by_name = {p["name"]: p for p in params} - toggles = ("VehicleNaviSkipBoxCamera", "VehicleNaviSkipMobileZone", "VehicleNaviSectionAvgControl") - for name in toggles: + master, children = "VehicleNaviDecelCancel", ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", + "VehicleNaviDecelCancelBump") + defaults = {master: 0, "VehicleNaviSectionAvgControl": 0, **{name: 1 for name in children}} + for name, default in defaults.items(): control = by_name[name] - assert (control["min"], control["max"], control["default"]) == (0, 1, 0) + assert (control["min"], control["max"], control["default"]) == (0, 1, default) assert control["control"] == "toggle" assert control["risk"] == "high" + # Per-kind toggles live in the master's detail screen; only the master is opt-in. + assert not by_name[master].get("detail_parent") + assert all(by_name[name]["detail_parent"] == master and by_name[name]["group"] == by_name[master]["group"] + for name in children) driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") speed = next(group for group in driving["groups"] if group["id"] == "SPEED") camera = next(group for group in speed["groups"] if group["id"] == "SPEED_CAMERA") params_keys = PARAMS_KEYS_PATH.read_text(encoding="utf-8") - for name in toggles: + for name, default in defaults.items(): assert name in camera["params"] - assert f'{{"{name}", {{PERSISTENT, BOOL, "0"}}}}' in params_keys - assert "VehicleNaviSectionAvgMargin" not in by_name - assert "VehicleNaviSectionAvgMargin" not in params_keys + assert f'{{"{name}", {{PERSISTENT, BOOL, "{default}"}}}}' in params_keys + for removed in ("VehicleNaviSkipBoxCamera", "VehicleNaviSkipMobileZone", "VehicleNaviSectionAvgMargin"): + assert removed not in by_name + assert removed not in params_keys def test_vehicle_navi_curve_control_settings_are_removed(settings): diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 71cb3fe3b98..e95926fa85f 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -223,8 +223,10 @@ "params": [ "VehicleNaviCanControl", "VehicleNaviSchoolZoneControl", - "VehicleNaviSkipBoxCamera", - "VehicleNaviSkipMobileZone", + "VehicleNaviDecelCancel", + "VehicleNaviDecelCancelBox", + "VehicleNaviDecelCancelMobileZone", + "VehicleNaviDecelCancelBump", "VehicleNaviSectionAvgControl", "VehicleSpeedCameraControlMode", "VehicleSpeedCameraDistanceTime", @@ -3518,15 +3520,15 @@ }, { "group": "감속제어", - "name": "VehicleNaviSkipBoxCamera", - "title": "차량 내비 박스 카메라 + 버튼 건너뛰기", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 순정 내비 CAN이 박스형(이동식 카메라 함) 카메라로 알린 경고에서 openpilot 작동 중에 크루즈 + 버튼을 한 번 누르거나 악셀톡(가속 페달을 짧게 밟았다 뗌)을 하면 그 카메라의 감속을 해제하고 원래 속도로 돌아갑니다. 이 입력은 설정 속도를 올리지 않습니다(+1, 악셀톡 +10 모두 반영 안 함). 경고가 끝나면 다음 카메라에는 다시 감속합니다. 고정식·신호·후면 카메라는 건너뛸 수 없습니다. 박스형은 순정 내비 예고 종류 2로 판별합니다(실차 라벨 확인).", + "name": "VehicleNaviDecelCancel", + "title": "차량 내비 감속 중지 요청", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다(순정 내비 CAN). 켜면 openpilot 작동 중(약 18km/h 이상) 아래에서 켠 종류로 감속하는 동안 크루즈 + 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않습니다(+1, 악셀톡 +10 모두 반영 안 함). 중지는 그 카메라·방지턱 하나에만 적용되고 다음 것은 다시 감속합니다. 고정식·신호·후면 카메라와 구간단속은 중지할 수 없습니다. 세부 항목(박스형 카메라, 이동식 구간, 과속방지턱)에서 종류별로 켜고 끕니다.", "egroup": "SPEED", - "etitle": "Stock Navigation Box Camera + Button Skip", - "edescr": "Applies only when external navigation is disconnected. During a stock-navigation CAN warning for a box-type (mobile camera housing) camera, while engaged, one press of cruise + or a gas tap (brief accelerator press) releases that camera's deceleration and returns to the previous speed. The input does not raise the set speed (neither +1 nor the gas-tap +10). The next camera decelerates again after the warning ends. Fixed, signal and rear cameras cannot be skipped. Boxes are identified as stock-navigation preview kind 2 (confirmed by labelled drives).", + "etitle": "Stock Navigation Deceleration Cancel Request", + "edescr": "Applies only when external navigation is disconnected (stock-navigation CAN). When enabled, while engaged (above about 18 km/h) and one of the kinds enabled below is decelerating, one press of cruise + or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither +1 nor the gas-tap +10). The cancel applies to that one camera or bump; the next one decelerates again. Fixed, signal and rear cameras and section enforcement cannot be cancelled. Turn each kind (box camera, mobile zone, speed bump) on or off in the details.", "cgroup": "速度控制", - "ctitle": "原车导航箱式摄像头 + 键跳过", - "cdescr": "仅在外部导航未连接时应用。原车导航 CAN 提示箱式(移动摄像头箱)摄像头时,在 openpilot 启用时按一次巡航 + 键或轻踩油门(短按后松开)即解除该摄像头的减速并恢复原速度。此输入不会提高设定速度(+1 和轻踩油门 +10 均不生效)。警告结束后,下一个摄像头仍会减速。固定、闯红灯和后向摄像头不能跳过。箱式根据原车导航预告类型 2 判别(已经实车标注确认)。", + "ctitle": "原车导航减速中止请求", + "cdescr": "仅在外部导航未连接时应用(原车导航 CAN)。启用后,在 openpilot 启用(约 18km/h 以上)且下方已启用的类型正在减速时,按一次巡航 + 键或轻踩油门(踩下不足 0.4 秒后松开)即中止该处减速。此输入不会提高设定速度(+1 和轻踩油门 +10 均不生效)。中止只适用于该摄像头或减速带,下一个仍会减速。固定、闯红灯、后向摄像头和区间测速不能中止。在详细项中按类型(箱式摄像头、移动区间、减速带)分别开关。", "min": 0, "max": 1, "default": 0, @@ -3536,21 +3538,63 @@ }, { "group": "감속제어", - "name": "VehicleNaviSkipMobileZone", - "title": "차량 내비 이동식 구간 + 버튼 건너뛰기", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다. 네비게이션감속모드(AutoNaviSpeedCtrlMode)가 3이라 순정 내비 CAN 이동식 단속 구간(내비 파란 원)에서 감속할 때, openpilot 작동 중에 크루즈 + 버튼을 한 번 누르거나 악셀톡을 하면 그 구간의 감속을 해제하고 원래 속도로 돌아갑니다. 이 입력은 설정 속도를 올리지 않습니다. 모드가 2 이하이면 이동식 구간은 처음부터 감속하지 않으므로 이 설정은 쓰이지 않습니다. 같은 경고에 고정식·신호·후면 카메라가 함께 있으면 건너뛸 수 없습니다.", + "name": "VehicleNaviDecelCancelBox", + "title": "감속 중지: 박스형 카메라", + "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비가 박스형(이동식 카메라 함, 예고 종류 2)으로 알린 카메라의 감속을 + 버튼·악셀톡으로 중지할 수 있게 합니다.", "egroup": "SPEED", - "etitle": "Stock Navigation Mobile Zone + Button Skip", - "edescr": "Applies only when external navigation is disconnected. When AutoNaviSpeedCtrlMode is 3 and a stock-navigation CAN mobile enforcement zone (blue circle) decelerates, while engaged, one press of cruise + or a gas tap releases that zone's deceleration and returns to the previous speed. The input does not raise the set speed. With mode 2 or lower mobile zones never decelerate, so this setting is unused. A warning that also carries a fixed, signal or rear camera cannot be skipped.", + "etitle": "Deceleration Cancel: Box Camera", + "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a box-type (mobile camera housing, preview kind 2) camera.", "cgroup": "速度控制", - "ctitle": "原车导航移动测速区间 + 键跳过", - "cdescr": "仅在外部导航未连接时应用。导航减速模式(AutoNaviSpeedCtrlMode)为 3 且原车导航 CAN 移动测速区间(导航蓝色圆圈)正在减速时,在 openpilot 启用时按一次巡航 + 键或轻踩油门即解除该区间的减速并恢复原速度。此输入不会提高设定速度。模式为 2 及以下时移动区间本就不减速,此设置不生效。同一警告中若同时存在固定、闯红灯或后向摄像头,则不能跳过。", + "ctitle": "减速中止:箱式摄像头", + "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止箱式(移动摄像头箱,预告类型 2)摄像头的减速。", "min": 0, "max": 1, - "default": 0, + "default": 1, "unit": 1, "control": "toggle", - "risk": "high" + "risk": "high", + "detail_parent": "VehicleNaviDecelCancel", + "detail_section": "camera" + }, + { + "group": "감속제어", + "name": "VehicleNaviDecelCancelMobileZone", + "title": "감속 중지: 이동식 단속 구간", + "descr": "감속 중지 요청이 켜져 있을 때, 이동식 단속 구간(내비 파란 원)의 감속을 + 버튼·악셀톡으로 중지할 수 있게 합니다. 네비게이션감속모드(AutoNaviSpeedCtrlMode)가 3일 때만 이동식 구간에서 감속하므로, 모드 2 이하에서는 쓰이지 않습니다.", + "egroup": "SPEED", + "etitle": "Deceleration Cancel: Mobile Zone", + "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a mobile enforcement zone (blue circle). Mobile zones decelerate only when AutoNaviSpeedCtrlMode is 3, so this is unused at mode 2 or lower.", + "cgroup": "速度控制", + "ctitle": "减速中止:移动测速区间", + "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止移动测速区间(导航蓝色圆圈)的减速。导航减速模式(AutoNaviSpeedCtrlMode)为 3 时移动区间才会减速,模式 2 及以下不生效。", + "min": 0, + "max": 1, + "default": 1, + "unit": 1, + "control": "toggle", + "risk": "high", + "detail_parent": "VehicleNaviDecelCancel", + "detail_section": "camera" + }, + { + "group": "감속제어", + "name": "VehicleNaviDecelCancelBump", + "title": "감속 중지: 과속방지턱", + "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비 과속방지턱 감속을 + 버튼·악셀톡으로 중지할 수 있게 합니다. 방지턱 감속 목표(AutoNaviSpeedBumpSpeed·AutoNaviSpeedBumpTime·AutoNaviSpeedDecelRate로 계산)가 설정 속도보다 낮아 실제로 감속 중일 때만 입력을 씁니다. 중지는 가장 가까운 방지턱 하나에만 적용됩니다.", + "egroup": "SPEED", + "etitle": "Deceleration Cancel: Speed Bump", + "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel a stock-navigation speed-bump deceleration. The input is used only while the bump target (from AutoNaviSpeedBumpSpeed, AutoNaviSpeedBumpTime and AutoNaviSpeedDecelRate) is below the set speed, i.e. actually decelerating. The cancel applies to the nearest bump only.", + "cgroup": "速度控制", + "ctitle": "减速中止:减速带", + "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止原车导航减速带减速。仅在减速带目标速度(由 AutoNaviSpeedBumpSpeed、AutoNaviSpeedBumpTime、AutoNaviSpeedDecelRate 计算)低于设定速度、即实际减速时才使用该输入。中止只适用于最近的一个减速带。", + "min": 0, + "max": 1, + "default": 1, + "unit": 1, + "control": "toggle", + "risk": "high", + "detail_parent": "VehicleNaviDecelCancel", + "detail_section": "bump" }, { "group": "감속제어", From ff4da401cb8b74d1ded1df2a4157ee7965485ac4 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:30:10 +0900 Subject: [PATCH 11/50] speedcam: optional deceleration cancel for fixed speed cameras Add VehicleNaviDecelCancelFixed under the deceleration cancel request so + or a gas tap can also cancel a fixed speed camera warning, with the same one-press-per-warning logic as box cameras and mobile zones. - navi_speedcam: unflagged kind 0 gets its own class CLASS_FIXED (below CLASS_HARD), skippable only with skip_fixed. Signal, rear-flagged and unmatched warnings stay CLASS_HARD and never skip; a warning still never downgrades. - carstate: read the toggle (gated by the master) and pass it to the policy, except while a section is active: section start/end cameras are kind 0 too, and + must stay the section unlock there. - The new toggle defaults off even with the master on, since these are real enforcement cameras. Tests: 165 passed (3 new: fixed skip needs its toggle; signal/rear/ unknown never skip; no fixed skip inside a section); settings schema 104 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 7 ++-- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 7 ++-- opendbc_repo/opendbc/car/hyundai/carstate.py | 5 ++- .../opendbc/car/hyundai/navi_speedcam.py | 14 +++++--- .../car/hyundai/tests/test_navi_speedcam.py | 35 ++++++++++++++++--- .../car/hyundai/tests/test_speed_camera.py | 23 ++++++++++-- openpilot/common/params_keys.h | 1 + .../server/tests/test_settings_schema.py | 5 +-- openpilot/selfdrive/carrot_settings.json | 27 ++++++++++++-- 11 files changed, 101 insertions(+), 27 deletions(-) diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index e15770bc4ff..b399ef59114 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index d0c7fda69bf..374b97d1bcf 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -93,7 +93,7 @@ The current stock warning (0x4A3) carries no camera kind. The kind of the previe | Preview kind | Navigation icon | Handling | |---|---|---| -| Fixed speed camera | Red square | Always decelerates; no skip | +| Fixed speed camera | Red square | Decelerates; can be cancelled when the fixed-camera kind of the deceleration cancel request is on | | Signal-and-speed | Traffic-light icon | Always decelerates; no skip | | Rear speed, rear signal-and-speed | — | Always decelerates; no skip (identified by the preview's rear flag) | | Mobile enforcement zone | Blue circle | Decelerates when `AutoNaviSpeedCtrlMode` is `3`; ignored at `2` or lower | @@ -103,7 +103,7 @@ A warning that also carries a fixed, signal, or rear camera always decelerates, ### `VehicleNaviDecelCancel` (deceleration cancel request) -When enabled, while engaged and one of the kinds enabled in its details is decelerating, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the cancel applies to that one camera or bump; the next one decelerates again. While disengaged, below about 18 km/h, or when that kind is not actually decelerating, `+` and the gas tap keep their normal resume/engage/set-speed roles. Fixed, signal and rear cameras and section enforcement cannot be cancelled. Off by default. +When enabled, while engaged and one of the kinds enabled in its details is decelerating, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the cancel applies to that one camera or bump; the next one decelerates again. While disengaged, below about 18 km/h, or when that kind is not actually decelerating, `+` and the gas tap keep their normal resume/engage/set-speed roles. Signal and rear cameras, warnings without a preview and section enforcement cannot be cancelled; fixed cameras only when the fixed-camera kind is on. Off by default. Per-kind toggles (in the setting's detail screen, on by default so turning on the master enables all): @@ -111,6 +111,7 @@ Per-kind toggles (in the setting's detail screen, on by default so turning on th |---|---| | `VehicleNaviDecelCancelBox` | Mobile-camera box | | `VehicleNaviDecelCancelMobileZone` | Mobile enforcement zone; matters only when `AutoNaviSpeedCtrlMode` is `3`, so zones decelerate | +| `VehicleNaviDecelCancelFixed` | Fixed speed camera (preview kind 0, no rear flag). Real enforcement cameras, so **off by default** — it must be turned on separately even with the master on. Not applied inside a section, where `+` is the section unlock | | `VehicleNaviDecelCancelBump` | Stock-navigation speed bump. The input is used only while the bump target (from the speed-bump settings) is below the set speed, i.e. actually decelerating, and cancels the nearest bump only | ### `VehicleNaviSectionAvgControl` diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 7a5aa97c438..c0135012d36 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index f26e5eb109f..71aad9c3ee5 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -95,7 +95,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | 예고 종류 | 내비 표시 | 처리 | |---|---|---| -| 고정식 과속 | 빨간 사각 | 항상 감속, 건너뛰기 없음 | +| 고정식 과속 | 빨간 사각 | 감속, 감속 중지 요청의 고정식 항목을 켜면 중지 가능 | | 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | | 후면 과속, 후면 신호·과속 | — | 항상 감속, 건너뛰기 없음 (예고의 후면 표시로 구분) | | 이동식 단속 구간 | 파란 원 | `AutoNaviSpeedCtrlMode`가 `3`이면 감속, `2` 이하면 무시 | @@ -105,7 +105,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. ### `VehicleNaviDecelCancel` (감속 중지 요청) -켜면 openpilot 작동 중 아래 세부 항목에서 켠 종류로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 중지는 그 카메라·방지턱 하나에만 적용되어 다음 것은 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만, 또는 해당 종류가 실제로 감속 중이 아닐 때는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. 고정식·신호·후면 카메라와 구간단속은 중지할 수 없습니다. 기본값은 꺼짐입니다. +켜면 openpilot 작동 중 아래 세부 항목에서 켠 종류로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 중지는 그 카메라·방지턱 하나에만 적용되어 다음 것은 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만, 또는 해당 종류가 실제로 감속 중이 아닐 때는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. 신호·후면 카메라, 예고가 없는 경고, 구간단속은 중지할 수 없고, 고정식 카메라는 고정식 세부 항목을 켰을 때만 중지합니다. 기본값은 꺼짐입니다. 세부 항목(설정 상세 화면, 기본값 켜짐 — 상위 항목을 켜면 모두 동작): @@ -113,6 +113,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. |---|---| | `VehicleNaviDecelCancelBox` | 이동식 박스 카메라 | | `VehicleNaviDecelCancelMobileZone` | 이동식 단속 구간. `AutoNaviSpeedCtrlMode`가 `3`이라 감속할 때만 의미 | +| `VehicleNaviDecelCancelFixed` | 고정식 과속카메라(예고 종류 0, 후면 표시 없음). 실제 단속 카메라라서 **기본값 꺼짐** — 상위를 켜도 이 항목은 따로 켜야 동작. 구간단속 중에는 `+`가 구간 해제에 쓰이므로 적용하지 않음 | | `VehicleNaviDecelCancelBump` | 순정 내비 과속방지턱. 방지턱 감속 목표(방지턱 설정으로 계산)가 설정 속도보다 낮아 실제로 감속 중일 때만 입력을 쓰고, 가장 가까운 방지턱 하나만 중지 | ### `VehicleNaviSectionAvgControl` diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 5a00958492f..22b7e4f14ca 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -729,6 +729,7 @@ def _read_speedcam_params(self): self.speedcamSkipBox = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBox") self.speedcamSkipMobileZone = cancel and self.op_params.get_bool("VehicleNaviDecelCancelMobileZone") self.speedcamCancelBump = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBump") + self.speedcamSkipFixed = cancel and self.op_params.get_bool("VehicleNaviDecelCancelFixed") # carrot_serv's stock-navigation bump source, mirrored to tell when a bump is actually decelerating. self.speedcamBumpDecel = self.op_params.get_int("AutoNaviSpeedCtrlMode") >= 2 self.speedcamBumpSpeed = float(self.op_params.get_int("AutoNaviSpeedBumpSpeed")) @@ -768,7 +769,9 @@ def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_s can_skip = self.speedcam_engaged and ret.vEgo > SPEEDCAM_SKIP_MIN_SPEED suppress, consume = self.speedcam_policy.update( warning_active, int(round(warning_speed)), self.totalDistance, can_skip and (accel_rising or gas_tok), - self.speedcamMobileZoneDecel, self.speedcamSkipBox, self.speedcamSkipMobileZone) + self.speedcamMobileZoneDecel, self.speedcamSkipBox, self.speedcamSkipMobileZone, + # inside a section its cameras are the section's: + stays the section unlock + self.speedcamSkipFixed and not getattr(ret, "vehicleNaviSectionActive", False)) if consume: self._consume_speedcam_input(accel_rising, gas_tok) return False if suppress else speed_limit_cam diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 8e0572ee482..4d6d0ce6379 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -24,7 +24,8 @@ CLASS_MOBILE_ZONE = 1 CLASS_BOX = 2 -CLASS_HARD = 3 # fixed, signal, rear and unknown cameras always decelerate +CLASS_FIXED = 3 # unflagged fixed speed camera: skippable only with its own toggle +CLASS_HARD = 4 # signal, rear and unknown cameras always decelerate PREVIEW_SATURATED_OFFSET = 1985 # offsets near the ~2 km horizon only bound the position # A stock warning starts about 6.1 m per km/h of the enforced speed before its camera @@ -120,14 +121,16 @@ def classify(self, speed, total_distance, starting=True, start_distance=None): candidates.append(p) if not candidates: return None - if any(p["flagged"] or p["kind"] in (KIND_FIXED, KIND_SIGNAL) for p in candidates): + if any(p["flagged"] or p["kind"] == KIND_SIGNAL for p in candidates): return CLASS_HARD + if any(p["kind"] == KIND_FIXED for p in candidates): + return CLASS_FIXED if any(p["kind"] == KIND_BOX for p in candidates): return CLASS_BOX return CLASS_MOBILE_ZONE def update(self, warning_active, warning_speed, total_distance, accel_rising, - mobile_zone_decel, skip_box, skip_mobile_zone): + mobile_zone_decel, skip_box, skip_mobile_zone, skip_fixed=False): """Return (suppress_warning, consume_accel_button).""" if not warning_active or warning_speed <= 0: self.reset_warning() @@ -141,7 +144,7 @@ def update(self, warning_active, warning_speed, total_distance, accel_rising, self.warning_start_distance = total_distance current = self.classify(warning_speed, total_distance, starting=starting, start_distance=self.warning_start_distance) - # A warning never downgrades: a fixed camera inside a mobile zone keeps it hard. + # A warning never downgrades: a fixed camera inside a mobile zone keeps it fixed. if current is not None and (self.warning_class is None or current > self.warning_class): self.warning_class = current warning_class = CLASS_HARD if self.warning_class is None else self.warning_class @@ -150,7 +153,8 @@ def update(self, warning_active, warning_speed, total_distance, accel_rising, return True, False skippable = ((warning_class == CLASS_BOX and skip_box) or - (warning_class == CLASS_MOBILE_ZONE and skip_mobile_zone)) + (warning_class == CLASS_MOBILE_ZONE and skip_mobile_zone) or + (warning_class == CLASS_FIXED and skip_fixed)) consume = False if skippable and accel_rising and not self.skipped: self.skipped = True diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index e462df742fe..b0b50e6cb70 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -1,10 +1,11 @@ from opendbc.car.hyundai.navi_speedcam import ( - CLASS_BOX, CLASS_HARD, CLASS_MOBILE_ZONE, SpeedcamPolicy, decode_camera_profile, + CLASS_BOX, CLASS_FIXED, CLASS_HARD, CLASS_MOBILE_ZONE, SpeedcamPolicy, decode_camera_profile, ) -def _update(policy, distance, active=True, speed=60, accel=False, mobile_decel=False, skip_box=False, skip_mobile=False): - return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile) +def _update(policy, distance, active=True, speed=60, accel=False, mobile_decel=False, skip_box=False, skip_mobile=False, + skip_fixed=False): + return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile, skip_fixed) def test_decode_uses_low_nine_bits_and_keeps_rear_flag(): @@ -43,7 +44,7 @@ def test_fixed_camera_inside_mobile_zone_always_decelerates(): policy.add_preview(0xD0, 1682, -1318.0) policy.add_preview(0xD3, 1714, -1316.0) assert _update(policy, 0.0, accel=True, skip_mobile=True, skip_box=True) == (False, False) - assert policy.warning_class == CLASS_HARD + assert policy.warning_class == CLASS_FIXED def test_precise_fixed_preview_upgrades_a_running_mobile_zone(): @@ -52,7 +53,7 @@ def test_precise_fixed_preview_upgrades_a_running_mobile_zone(): _update(policy, 0.0) policy.add_preview(0xD0, 200, 100.0) assert _update(policy, 100.0) == (False, False) - assert policy.warning_class == CLASS_HARD + assert policy.warning_class == CLASS_FIXED def test_unknown_warning_is_hard(): @@ -146,3 +147,27 @@ def test_precise_repeat_replaces_a_horizon_bound_position(): assert (p["target"], p["saturated"]) == (1990.0, False) policy.add_preview(0xD3, 1990, 12.0) # a later saturated repeat keeps the precise target assert (p["target"], p["saturated"]) == (1990.0, False) + + +def test_fixed_camera_skip_needs_its_own_toggle(): + policy = SpeedcamPolicy() + policy.add_preview(0xD0, 366, 0.0) # kind 0 fixed @60 at the warning lead + assert _update(policy, 0.0, accel=True, skip_box=True, skip_mobile=True) == (False, False) + assert policy.warning_class == CLASS_FIXED + assert _update(policy, 10.0, accel=True, skip_fixed=True) == (True, True) + assert _update(policy, 20.0, accel=True, skip_fixed=True) == (True, False) + + +def test_signal_rear_and_unknown_never_skip_with_the_fixed_toggle(): + signal = SpeedcamPolicy() + signal.add_preview(0xD1, 366, 0.0) # kind 1 signal-and-speed + signal.add_preview(0xD0, 366, 0.0) # with a fixed camera at the same spot + assert _update(signal, 0.0, accel=True, skip_fixed=True) == (False, False) + assert signal.warning_class == CLASS_HARD + + rear = SpeedcamPolicy() + rear.add_preview(0x642D0, 366, 0.0) # kind 0 + rear flag + assert _update(rear, 0.0, accel=True, skip_fixed=True) == (False, False) + + unknown = SpeedcamPolicy() # no preview at all + assert _update(unknown, 0.0, accel=True, skip_fixed=True) == (False, False) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index 816df8b15cd..7c38a3c57ac 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -46,7 +46,8 @@ def get_int(self, key): def get_bool(self, key): if key in ("VehicleNaviDecelCancel", "VehicleNaviSectionAvgControl"): return False - if key in ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump"): + if key in ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", + "VehicleNaviDecelCancelFixed"): return True assert key == "VehicleNaviSchoolZoneControl" return self.school_zone @@ -1596,7 +1597,23 @@ def test_bump_cancel_survives_a_repeated_announcement(): def test_decel_cancel_master_gates_every_kind(): state = _car_state() - assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump) + assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump or state.speedcamSkipFixed) state.op_params.get_bool = lambda key: True state._read_speedcam_params() - assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump + assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump and state.speedcamSkipFixed + + +def test_fixed_camera_skip_outside_a_section_only(): + state = _speedcam_state() + state.speedcamSkipFixed = True + state.speedcam_policy.add_preview(0xD0, 366, 0.0) # kind 0 fixed @60 + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False, vehicleNaviSectionActive=False) + assert not state._apply_speedcam_policy(ret, True, True, 60.0, True) + assert state.speedcam_accel_swallow + + section = _speedcam_state() + section.speedcamSkipFixed = True + section.speedcam_policy.add_preview(0xD0, 366, 0.0) # a section's start/end camera is kind 0 too + ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False, vehicleNaviSectionActive=True) + assert section._apply_speedcam_policy(ret, True, True, 60.0, True) + assert not section.speedcam_accel_swallow # + stays the section unlock diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 0b4940298bc..3966234183b 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -246,6 +246,7 @@ inline static std::unordered_map keys = { {"VehicleNaviDecelCancelBox", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelMobileZone", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelBump", {PERSISTENT, BOOL, "1"}}, + {"VehicleNaviDecelCancelFixed", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, {"VehicleSpeedCameraControlMode", {PERSISTENT, INT, "1"}}, {"VehicleSpeedCameraDistanceTime", {PERSISTENT, INT, "60"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index 9e4296e12c0..ba800f530b1 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -368,7 +368,8 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, by_name = {p["name"]: p for p in params} master, children = "VehicleNaviDecelCancel", ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump") - defaults = {master: 0, "VehicleNaviSectionAvgControl": 0, **{name: 1 for name in children}} + defaults = {master: 0, "VehicleNaviSectionAvgControl": 0, **{name: 1 for name in children}, + "VehicleNaviDecelCancelFixed": 0} # real enforcement cameras: opt-in even with the master on for name, default in defaults.items(): control = by_name[name] assert (control["min"], control["max"], control["default"]) == (0, 1, default) @@ -377,7 +378,7 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, # Per-kind toggles live in the master's detail screen; only the master is opt-in. assert not by_name[master].get("detail_parent") assert all(by_name[name]["detail_parent"] == master and by_name[name]["group"] == by_name[master]["group"] - for name in children) + for name in (*children, "VehicleNaviDecelCancelFixed")) driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") speed = next(group for group in driving["groups"] if group["id"] == "SPEED") diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index e95926fa85f..44c8525b3d2 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -227,6 +227,7 @@ "VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", + "VehicleNaviDecelCancelFixed", "VehicleNaviSectionAvgControl", "VehicleSpeedCameraControlMode", "VehicleSpeedCameraDistanceTime", @@ -3522,13 +3523,13 @@ "group": "감속제어", "name": "VehicleNaviDecelCancel", "title": "차량 내비 감속 중지 요청", - "descr": "외부 내비가 연결되지 않았을 때만 적용합니다(순정 내비 CAN). 켜면 openpilot 작동 중(약 18km/h 이상) 아래에서 켠 종류로 감속하는 동안 크루즈 + 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않습니다(+1, 악셀톡 +10 모두 반영 안 함). 중지는 그 카메라·방지턱 하나에만 적용되고 다음 것은 다시 감속합니다. 고정식·신호·후면 카메라와 구간단속은 중지할 수 없습니다. 세부 항목(박스형 카메라, 이동식 구간, 과속방지턱)에서 종류별로 켜고 끕니다.", + "descr": "외부 내비가 연결되지 않았을 때만 적용합니다(순정 내비 CAN). 켜면 openpilot 작동 중(약 18km/h 이상) 아래에서 켠 종류로 감속하는 동안 크루즈 + 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않습니다(+1, 악셀톡 +10 모두 반영 안 함). 중지는 그 카메라·방지턱 하나에만 적용되고 다음 것은 다시 감속합니다. 신호·후면 카메라와 구간단속은 중지할 수 없고, 고정식 카메라는 세부 항목을 따로 켰을 때만 중지합니다. 세부 항목(박스형 카메라, 이동식 구간, 과속방지턱, 고정식 카메라)에서 종류별로 켜고 끕니다.", "egroup": "SPEED", "etitle": "Stock Navigation Deceleration Cancel Request", - "edescr": "Applies only when external navigation is disconnected (stock-navigation CAN). When enabled, while engaged (above about 18 km/h) and one of the kinds enabled below is decelerating, one press of cruise + or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither +1 nor the gas-tap +10). The cancel applies to that one camera or bump; the next one decelerates again. Fixed, signal and rear cameras and section enforcement cannot be cancelled. Turn each kind (box camera, mobile zone, speed bump) on or off in the details.", + "edescr": "Applies only when external navigation is disconnected (stock-navigation CAN). When enabled, while engaged (above about 18 km/h) and one of the kinds enabled below is decelerating, one press of cruise + or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither +1 nor the gas-tap +10). The cancel applies to that one camera or bump; the next one decelerates again. Signal and rear cameras and section enforcement cannot be cancelled; fixed cameras only when their own detail toggle is on. Turn each kind (box camera, mobile zone, speed bump, fixed camera) on or off in the details.", "cgroup": "速度控制", "ctitle": "原车导航减速中止请求", - "cdescr": "仅在外部导航未连接时应用(原车导航 CAN)。启用后,在 openpilot 启用(约 18km/h 以上)且下方已启用的类型正在减速时,按一次巡航 + 键或轻踩油门(踩下不足 0.4 秒后松开)即中止该处减速。此输入不会提高设定速度(+1 和轻踩油门 +10 均不生效)。中止只适用于该摄像头或减速带,下一个仍会减速。固定、闯红灯、后向摄像头和区间测速不能中止。在详细项中按类型(箱式摄像头、移动区间、减速带)分别开关。", + "cdescr": "仅在外部导航未连接时应用(原车导航 CAN)。启用后,在 openpilot 启用(约 18km/h 以上)且下方已启用的类型正在减速时,按一次巡航 + 键或轻踩油门(踩下不足 0.4 秒后松开)即中止该处减速。此输入不会提高设定速度(+1 和轻踩油门 +10 均不生效)。中止只适用于该摄像头或减速带,下一个仍会减速。闯红灯、后向摄像头和区间测速不能中止;固定摄像头仅在其详细项单独开启时可中止。在详细项中按类型(箱式摄像头、移动区间、减速带、固定摄像头)分别开关。", "min": 0, "max": 1, "default": 0, @@ -3596,6 +3597,26 @@ "detail_parent": "VehicleNaviDecelCancel", "detail_section": "bump" }, + { + "group": "감속제어", + "name": "VehicleNaviDecelCancelFixed", + "title": "감속 중지: 고정식 과속카메라", + "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비가 고정식 과속카메라(예고 종류 0)로 알린 경고의 감속을 + 버튼·악셀톡으로 중지할 수 있게 합니다. 실제 단속 카메라이므로 다른 세부 항목과 달리 기본값은 꺼짐입니다. 신호·후면 카메라, 예고가 없는 경고, 구간단속 중의 카메라는 이 설정과 관계없이 중지할 수 없습니다.", + "egroup": "SPEED", + "etitle": "Deceleration Cancel: Fixed Speed Camera", + "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a fixed speed camera (preview kind 0). Because these are real enforcement cameras, this is off by default unlike the other kinds. Signal and rear cameras, warnings without a preview and cameras inside a section can never be cancelled.", + "cgroup": "速度控制", + "ctitle": "减速中止:固定测速摄像头", + "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止固定测速摄像头(预告类型 0)的减速。由于是实际执法摄像头,与其他类型不同默认关闭。闯红灯、后向摄像头、无预告的警告以及区间测速中的摄像头无论此设置如何都不能中止。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high", + "detail_parent": "VehicleNaviDecelCancel", + "detail_section": "camera" + }, { "group": "감속제어", "name": "VehicleNaviSectionAvgControl", From 6cfeeb999c7babf9ed75901c57a6cecd25a565e4 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:45:50 +0900 Subject: [PATCH 12/50] hyundai: hold the turn signal for the whole lane change, release when across ADRV_0x1ea LEFT/RIGHT_BLINK_HOLD was set from the model's desireState[laneChange] > 0.9. That probability falls about 2 s before laneChangeStarting ends, so the lamp stopped mid-change: on the 2026-09-19 drive (22 changes) the hold covered 49-65 % of a normal change, the lamp went out before the body was in the new lane in 6/13 measured changes and once before the car centre crossed the line. The green "changing" cluster lane, driven by the confirmed desire, stayed on 98-100 %. Hold the bit for the confirmed desire (laneChangeLeft/Right = starting + finishing, same source as the green lane) and release it once the model lane lines show the body across: the target-side line came within 1.0 m of the car centre and then moved 1.5 m further out (the relabel is spread over 2-3 model frames), and the crossed line, now on the other side, is 1.0 m away. Without a visible crossing the hold lasts to the end of the desire (2/22 on that drive). Replay over the 22 changes: hold released at 3.6 s median (was 3.0 s, desire ends ~6.0 s), i.e. the lamp now stops about one flash after the body enters the new lane instead of before it. Tests: test_blink_hold.py (no model message / short lists, crossing over several frames, right change, no visible crossing, re-arm next change). Co-Authored-By: Claude Opus 5.5 --- .../opendbc/car/hyundai/hyundaicanfd.py | 45 +++++++++++++++++-- .../car/hyundai/tests/test_blink_hold.py | 44 ++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index ca069cfce5c..064bcab9722 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -762,6 +762,42 @@ def _get_desire_and_lane_changing(md): if ds[4] > 0.9: lane_changing = 4 return desire, lane_changing +BLINK_HOLD_CROSS_NEAR_M = 1.0 # the target-side line came this close to the car centre ... +BLINK_HOLD_CROSS_JUMP_M = 1.5 # ... then moved this far away again: the model relabelled it, the centre is across +BLINK_HOLD_BODY_CLEAR_M = 1.0 # crossed line this far from the centre: the body (~0.95 m half width) is across + + +def _blink_hold_side(md, desire, state): + """Turn-signal hold for ADRV_0x1ea: 3 (left), 4 (right) or 0. + + Held for the whole confirmed lane change (desire 3/4 = starting + finishing, same as the green + cluster lane) instead of the model's desireState > 0.9, which falls ~2 s before the change ends and + let the lamp stop mid-change (2026-09-19 drive: hold covered 49-65 % of the change). Released as soon + as the model's lane lines show the body across the crossed line, so the lamp does not keep flashing + ~2 s after the car is already in the new lane. Without a detectable crossing the hold lasts to the end + of the desire. + """ + if desire not in (3, 4): + state.update(crossed=False, released=False, y_min=None) + return 0 + if state.get("released"): + return 0 + lines = getattr(md, "laneLines", None) if md is not None else None + if lines is not None and len(lines) >= 3 and len(lines[1].y) and len(lines[2].y): + target, other = (1, 2) if desire == 3 else (2, 1) + y_target, y_other = float(lines[target].y[0]), float(lines[other].y[0]) + # The relabel is spread over 2-3 model frames (e.g. -0.28 -> -1.36 -> -3.46 m), so compare with the + # closest approach instead of the previous frame. + y_min = abs(y_target) if state.get("y_min") is None else min(state["y_min"], abs(y_target)) + state["y_min"] = y_min + if not state.get("crossed") and y_min < BLINK_HOLD_CROSS_NEAR_M and abs(y_target) - y_min > BLINK_HOLD_CROSS_JUMP_M: + state["crossed"] = True + if state.get("crossed") and abs(y_other) >= BLINK_HOLD_BODY_CLEAR_M: + state["released"] = True + return 0 + return desire + + def _apply_lane_desire(values, desire): #values['LANE_CHANGING'] = 0 @@ -906,7 +942,7 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if not hasattr(create_ccnc_messages, '_lane_line_check') or frame % 100 == 0: create_ccnc_messages._lane_line_check = Params().get_int("LaneLineCheck") lane_line_check = create_ccnc_messages._lane_line_check - desire, lane_changing = _get_desire_and_lane_changing(md) + desire, _ = _get_desire_and_lane_changing(md) if CP.flags & HyundaiFlags.CAMERA_SCC.value: HDA_CntrlModSta = 0 @@ -1074,9 +1110,10 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if CS.adrv_0x1ea is not None: values = copy.copy(CS.adrv_0x1ea) rx_counter = values.pop("COUNTER", None) - # blinker hold - values['LEFT_BLINK_HOLD'] = 1 if lane_changing == 3 else 0 - values['RIGHT_BLINK_HOLD'] = 1 if lane_changing == 4 else 0 + # blinker hold: the confirmed lane change until the body is across (see _blink_hold_side) + hold = _blink_hold_side(md, desire, create_ccnc_messages.__dict__.setdefault("_blink_hold", {})) + values['LEFT_BLINK_HOLD'] = 1 if hold == 3 else 0 + values['RIGHT_BLINK_HOLD'] = 1 if hold == 4 else 0 _apply_cluster_lane_lines(values, CS, lat_active, desire) _normalize_cluster_corner_objects(values) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py new file mode 100644 index 00000000000..fb4136782a9 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -0,0 +1,44 @@ +from types import SimpleNamespace as NS + +from opendbc.car.hyundai.hyundaicanfd import _blink_hold_side + + +def _md(y_left, y_right): + return NS(laneLines=[NS(y=[-5.0]), NS(y=[y_left]), NS(y=[y_right]), NS(y=[5.0])]) + + +def test_blink_hold_follows_the_confirmed_change_without_lane_lines(): + state = {} + assert _blink_hold_side(None, 3, state) == 3 # no model message: hold for the desire + assert _blink_hold_side(NS(laneLines=[]), 4, state) == 4 # missing / short lane-line lists + assert _blink_hold_side(NS(laneLines=[NS(y=[]), NS(y=[]), NS(y=[])]), 4, state) == 4 + assert _blink_hold_side(None, 0, state) == 0 + + +def test_blink_hold_releases_once_the_body_is_across(): + # Left change, 2026-09-19 #9: the left line closes in, the model relabels it over two frames, + # then the crossed line (now on the right) moves away from the car centre. + state = {} + frames = [(-1.75, 1.75), (-1.2, 2.3), (-0.6, 2.8), (-0.28, 3.04), (-1.36, 2.21), (-3.46, 0.12), + (-3.11, 0.32), (-2.62, 0.76)] + assert all(_blink_hold_side(_md(*f), 3, state) == 3 for f in frames) + assert state["crossed"] and not state.get("released") + assert _blink_hold_side(_md(-2.37, 1.04), 3, state) == 0 # crossed line 1 m away: body across + assert _blink_hold_side(_md(-1.9, 1.6), 3, state) == 0 # stays released for this change + assert _blink_hold_side(_md(-1.9, 1.6), 0, state) == 0 # desire ends: reset + assert _blink_hold_side(_md(-1.8, 1.8), 4, state) == 4 # the next change holds again + + +def test_blink_hold_right_change_uses_the_right_line(): + state = {} + for y_left, y_right in [(-1.9, 1.66), (-2.5, 0.98), (-3.06, 0.17), (-3.09, 0.27), (-1.64, 1.66), (-0.21, 3.08)]: + assert _blink_hold_side(_md(y_left, y_right), 4, state) == 4 + assert _blink_hold_side(_md(-1.01, 2.43), 4, state) == 0 + + +def test_blink_hold_without_a_visible_crossing_lasts_to_the_end_of_the_desire(): + # 2026-09-19 #11: the model lines follow the car and never come within 1 m of the centre. + state = {} + for y_left, y_right in [(-2.9, 1.5), (-2.4, 1.1), (-2.2, 0.8), (-1.5, 1.3), (-1.2, 1.9), (-1.7, 1.8)]: + assert _blink_hold_side(_md(y_left, y_right), 3, state) == 3 + assert _blink_hold_side(_md(-1.7, 1.8), 0, state) == 0 From d8ba6e75a3d1a6184f4649ad810542efc7a228b0 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:52:42 +0900 Subject: [PATCH 13/50] hyundai: release the lane-change turn signal at 60 % across The hold now ends once the car centre has moved 60 % of a lane width into the new lane: after the model relabels the crossed line, that line (now on the other side) is 0.1 lane width (~0.35 m) from the centre. Lane width is taken from the two model lines around the car. Previously the release waited for the crossed line to be 1.0 m away (~80 % across). Co-Authored-By: Claude Opus 5.5 --- opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py | 13 +++++++------ .../opendbc/car/hyundai/tests/test_blink_hold.py | 9 +++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index 064bcab9722..233adbc8ad2 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -764,7 +764,7 @@ def _get_desire_and_lane_changing(md): BLINK_HOLD_CROSS_NEAR_M = 1.0 # the target-side line came this close to the car centre ... BLINK_HOLD_CROSS_JUMP_M = 1.5 # ... then moved this far away again: the model relabelled it, the centre is across -BLINK_HOLD_BODY_CLEAR_M = 1.0 # crossed line this far from the centre: the body (~0.95 m half width) is across +BLINK_HOLD_RELEASE_PROGRESS = 0.6 # release once the car centre has moved this fraction of a lane width into the new lane def _blink_hold_side(md, desire, state): @@ -772,10 +772,10 @@ def _blink_hold_side(md, desire, state): Held for the whole confirmed lane change (desire 3/4 = starting + finishing, same as the green cluster lane) instead of the model's desireState > 0.9, which falls ~2 s before the change ends and - let the lamp stop mid-change (2026-09-19 drive: hold covered 49-65 % of the change). Released as soon - as the model's lane lines show the body across the crossed line, so the lamp does not keep flashing - ~2 s after the car is already in the new lane. Without a detectable crossing the hold lasts to the end - of the desire. + let the lamp stop mid-change (2026-09-19 drive: hold covered 49-65 % of the change). Released once the + model's lane lines show the car centre 60 % of a lane width over (the crossed line, now on the other + side, 0.1 lane width away), so the lamp does not keep flashing ~2 s after the car is already in the new + lane. Without a detectable crossing the hold lasts to the end of the desire. """ if desire not in (3, 4): state.update(crossed=False, released=False, y_min=None) @@ -792,7 +792,8 @@ def _blink_hold_side(md, desire, state): state["y_min"] = y_min if not state.get("crossed") and y_min < BLINK_HOLD_CROSS_NEAR_M and abs(y_target) - y_min > BLINK_HOLD_CROSS_JUMP_M: state["crossed"] = True - if state.get("crossed") and abs(y_other) >= BLINK_HOLD_BODY_CLEAR_M: + lane_width = abs(y_target) + abs(y_other) + if state.get("crossed") and abs(y_other) >= (BLINK_HOLD_RELEASE_PROGRESS - 0.5) * lane_width: state["released"] = True return 0 return desire diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py index fb4136782a9..4767a984450 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -20,10 +20,10 @@ def test_blink_hold_releases_once_the_body_is_across(): # then the crossed line (now on the right) moves away from the car centre. state = {} frames = [(-1.75, 1.75), (-1.2, 2.3), (-0.6, 2.8), (-0.28, 3.04), (-1.36, 2.21), (-3.46, 0.12), - (-3.11, 0.32), (-2.62, 0.76)] + (-3.11, 0.32)] assert all(_blink_hold_side(_md(*f), 3, state) == 3 for f in frames) - assert state["crossed"] and not state.get("released") - assert _blink_hold_side(_md(-2.37, 1.04), 3, state) == 0 # crossed line 1 m away: body across + assert state["crossed"] and not state.get("released") # centre across, 0.32 m < 0.1 lane width + assert _blink_hold_side(_md(-2.62, 0.76), 3, state) == 0 # 0.76 m past the line: over 60 % assert _blink_hold_side(_md(-1.9, 1.6), 3, state) == 0 # stays released for this change assert _blink_hold_side(_md(-1.9, 1.6), 0, state) == 0 # desire ends: reset assert _blink_hold_side(_md(-1.8, 1.8), 4, state) == 4 # the next change holds again @@ -33,7 +33,8 @@ def test_blink_hold_right_change_uses_the_right_line(): state = {} for y_left, y_right in [(-1.9, 1.66), (-2.5, 0.98), (-3.06, 0.17), (-3.09, 0.27), (-1.64, 1.66), (-0.21, 3.08)]: assert _blink_hold_side(_md(y_left, y_right), 4, state) == 4 - assert _blink_hold_side(_md(-1.01, 2.43), 4, state) == 0 + assert _blink_hold_side(_md(-0.33, 3.0), 4, state) == 4 # 0.33 m < 0.1 x 3.33 m: under 60 % + assert _blink_hold_side(_md(-0.4, 2.9), 4, state) == 0 # 0.4 m >= 0.33 m: 60 % over def test_blink_hold_without_a_visible_crossing_lasts_to_the_end_of_the_desire(): From 90cee30d8c20dfdc038bd059312f434efb9cfa31 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:48:04 +0900 Subject: [PATCH 14/50] speedcam: experimental deceleration cancel for unknown warnings Add VehicleNaviDecelCancelUnknown ([experimental], default off) under the deceleration cancel request: + or a gas tap may also cancel a warning that no camera preview was matched to (preview not received or not understood), with the same one-press-per-warning logic. Field report 2026-10-04 (another user on this branch): a 60 km/h warning with no matching camera preview (only a kind 7 speed-zone preview, the kind 3 zone targets 100 m outside the zone lead window) stayed hard; the driver could not cancel it and + only stacked the set speed. Guards: never at 30 km/h or below (school/senior zones warn there, often without a preview), never inside a section, and a later signal, rear or fixed match switches back to that kind's rule (signal/rear decelerate). Off by default and separate from the master because the kind is unknown: over our 37 drives about 15 of 209 warnings are unmatched point warnings above 30 km/h (likely signal/fixed cameras without a preview) that this toggle would make cancellable. Tests: 168 passed (3 new: toggle + 30 km/h guard, signal match ends the skip, classified warnings untouched); settings schema 104 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 3 +- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 3 +- opendbc_repo/opendbc/car/hyundai/carstate.py | 4 ++- .../opendbc/car/hyundai/navi_speedcam.py | 8 +++-- .../car/hyundai/tests/test_navi_speedcam.py | 29 +++++++++++++++++-- .../car/hyundai/tests/test_speed_camera.py | 6 ++-- openpilot/common/params_keys.h | 1 + .../server/tests/test_settings_schema.py | 5 ++-- openpilot/selfdrive/carrot_settings.json | 21 ++++++++++++++ 11 files changed, 71 insertions(+), 13 deletions(-) diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 1445fffe5e9..e223f8c6094 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index 374b97d1bcf..23b2383c8df 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -112,6 +112,7 @@ Per-kind toggles (in the setting's detail screen, on by default so turning on th | `VehicleNaviDecelCancelBox` | Mobile-camera box | | `VehicleNaviDecelCancelMobileZone` | Mobile enforcement zone; matters only when `AutoNaviSpeedCtrlMode` is `3`, so zones decelerate | | `VehicleNaviDecelCancelFixed` | Fixed speed camera (preview kind 0, no rear flag). Real enforcement cameras, so **off by default** — it must be turned on separately even with the master on. Not applied inside a section, where `+` is the section unlock | +| `VehicleNaviDecelCancelUnknown` | **[Experimental]** Warnings whose camera kind preview was not received or not understood. It can include signal or speed cameras that send no preview, so **off by default** and must be turned on separately from the master. Not applied at 30 km/h or below (school and senior zones) or inside a section; if a signal, rear or fixed preview is matched after the cancel, that rule applies | | `VehicleNaviDecelCancelBump` | Stock-navigation speed bump. The input is used only while the bump target (from the speed-bump settings) is below the set speed, i.e. actually decelerating, and cancels the nearest bump only | ### `VehicleNaviSectionAvgControl` diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index b7e2eb5fcdb..a118919e9f2 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index 71aad9c3ee5..a5aacf62b4a 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -114,6 +114,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | `VehicleNaviDecelCancelBox` | 이동식 박스 카메라 | | `VehicleNaviDecelCancelMobileZone` | 이동식 단속 구간. `AutoNaviSpeedCtrlMode`가 `3`이라 감속할 때만 의미 | | `VehicleNaviDecelCancelFixed` | 고정식 과속카메라(예고 종류 0, 후면 표시 없음). 실제 단속 카메라라서 **기본값 꺼짐** — 상위를 켜도 이 항목은 따로 켜야 동작. 구간단속 중에는 `+`가 구간 해제에 쓰이므로 적용하지 않음 | +| `VehicleNaviDecelCancelUnknown` | **[실험]** 카메라 종류 예고를 받지 못했거나 해석하지 못한 경고(CAN 미수신·미처리). 예고 없이 오는 신호·과속 카메라도 포함될 수 있어 **기본값 꺼짐**, 상위와 별도로 켜야 동작. 30km/h 이하(어린이·노인보호구역)와 구간단속 중에는 적용하지 않고, 중지 뒤 신호·후면·고정식 예고가 확인되면 그 규칙을 따름 | | `VehicleNaviDecelCancelBump` | 순정 내비 과속방지턱. 방지턱 감속 목표(방지턱 설정으로 계산)가 설정 속도보다 낮아 실제로 감속 중일 때만 입력을 쓰고, 가장 가까운 방지턱 하나만 중지 | ### `VehicleNaviSectionAvgControl` diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 22b7e4f14ca..083506f4f8b 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -730,6 +730,7 @@ def _read_speedcam_params(self): self.speedcamSkipMobileZone = cancel and self.op_params.get_bool("VehicleNaviDecelCancelMobileZone") self.speedcamCancelBump = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBump") self.speedcamSkipFixed = cancel and self.op_params.get_bool("VehicleNaviDecelCancelFixed") + self.speedcamSkipUnknown = cancel and self.op_params.get_bool("VehicleNaviDecelCancelUnknown") # carrot_serv's stock-navigation bump source, mirrored to tell when a bump is actually decelerating. self.speedcamBumpDecel = self.op_params.get_int("AutoNaviSpeedCtrlMode") >= 2 self.speedcamBumpSpeed = float(self.op_params.get_int("AutoNaviSpeedBumpSpeed")) @@ -771,7 +772,8 @@ def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_s warning_active, int(round(warning_speed)), self.totalDistance, can_skip and (accel_rising or gas_tok), self.speedcamMobileZoneDecel, self.speedcamSkipBox, self.speedcamSkipMobileZone, # inside a section its cameras are the section's: + stays the section unlock - self.speedcamSkipFixed and not getattr(ret, "vehicleNaviSectionActive", False)) + self.speedcamSkipFixed and not getattr(ret, "vehicleNaviSectionActive", False), + self.speedcamSkipUnknown and not getattr(ret, "vehicleNaviSectionActive", False)) if consume: self._consume_speedcam_input(accel_rising, gas_tok) return False if suppress else speed_limit_cam diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 4d6d0ce6379..5476586117e 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -26,6 +26,9 @@ CLASS_BOX = 2 CLASS_FIXED = 3 # unflagged fixed speed camera: skippable only with its own toggle CLASS_HARD = 4 # signal, rear and unknown cameras always decelerate +# [experimental] an unknown warning (no camera preview matched) may be skipped with its own +# toggle, but never at 30 km/h or below: school and senior zones warn there, often without a preview. +PROTECTED_ZONE_KPH = 30 PREVIEW_SATURATED_OFFSET = 1985 # offsets near the ~2 km horizon only bound the position # A stock warning starts about 6.1 m per km/h of the enforced speed before its camera @@ -130,7 +133,7 @@ def classify(self, speed, total_distance, starting=True, start_distance=None): return CLASS_MOBILE_ZONE def update(self, warning_active, warning_speed, total_distance, accel_rising, - mobile_zone_decel, skip_box, skip_mobile_zone, skip_fixed=False): + mobile_zone_decel, skip_box, skip_mobile_zone, skip_fixed=False, skip_unknown=False): """Return (suppress_warning, consume_accel_button).""" if not warning_active or warning_speed <= 0: self.reset_warning() @@ -154,7 +157,8 @@ def update(self, warning_active, warning_speed, total_distance, accel_rising, skippable = ((warning_class == CLASS_BOX and skip_box) or (warning_class == CLASS_MOBILE_ZONE and skip_mobile_zone) or - (warning_class == CLASS_FIXED and skip_fixed)) + (warning_class == CLASS_FIXED and skip_fixed) or + (self.warning_class is None and skip_unknown and warning_speed > PROTECTED_ZONE_KPH)) consume = False if skippable and accel_rising and not self.skipped: self.skipped = True diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index b0b50e6cb70..cae98ba1a04 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -4,8 +4,8 @@ def _update(policy, distance, active=True, speed=60, accel=False, mobile_decel=False, skip_box=False, skip_mobile=False, - skip_fixed=False): - return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile, skip_fixed) + skip_fixed=False, skip_unknown=False): + return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile, skip_fixed, skip_unknown) def test_decode_uses_low_nine_bits_and_keeps_rear_flag(): @@ -171,3 +171,28 @@ def test_signal_rear_and_unknown_never_skip_with_the_fixed_toggle(): unknown = SpeedcamPolicy() # no preview at all assert _update(unknown, 0.0, accel=True, skip_fixed=True) == (False, False) + + +def test_unknown_warning_skip_is_experimental_and_spares_protected_zones(): + # 2026-10-04 field report: a 60 km/h warning with no camera preview stayed hard and uncancellable. + policy = SpeedcamPolicy() + assert _update(policy, 0.0, accel=True, skip_fixed=True) == (False, False) # toggle off: hard + assert _update(policy, 10.0, accel=True, skip_unknown=True) == (True, True) # toggle on: one press skips + assert _update(policy, 20.0, accel=True, skip_unknown=True) == (True, False) + + school = SpeedcamPolicy() # 30 km/h zones never skip + assert _update(school, 0.0, speed=30, accel=True, skip_unknown=True) == (False, False) + + +def test_unknown_skip_ends_when_a_signal_camera_is_matched(): + policy = SpeedcamPolicy() + assert _update(policy, 0.0, accel=True, skip_unknown=True) == (True, True) + policy.add_preview(0xD1, 200, 100.0) # signal camera turns up + assert _update(policy, 100.0, skip_unknown=True) == (False, False) # decelerates again + assert policy.warning_class == CLASS_HARD + + +def test_unknown_skip_does_not_touch_classified_warnings(): + policy = SpeedcamPolicy() + policy.add_preview(0xD0, 366, 0.0) # fixed camera at the lead + assert _update(policy, 0.0, accel=True, skip_unknown=True) == (False, False) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index 7c38a3c57ac..1510362ba6c 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -47,7 +47,7 @@ def get_bool(self, key): if key in ("VehicleNaviDecelCancel", "VehicleNaviSectionAvgControl"): return False if key in ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", - "VehicleNaviDecelCancelFixed"): + "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown"): return True assert key == "VehicleNaviSchoolZoneControl" return self.school_zone @@ -1597,10 +1597,12 @@ def test_bump_cancel_survives_a_repeated_announcement(): def test_decel_cancel_master_gates_every_kind(): state = _car_state() - assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump or state.speedcamSkipFixed) + assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump or state.speedcamSkipFixed + or state.speedcamSkipUnknown) state.op_params.get_bool = lambda key: True state._read_speedcam_params() assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump and state.speedcamSkipFixed + assert state.speedcamSkipUnknown def test_fixed_camera_skip_outside_a_section_only(): diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 3966234183b..02c88984b7a 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -247,6 +247,7 @@ inline static std::unordered_map keys = { {"VehicleNaviDecelCancelMobileZone", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelBump", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelFixed", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviDecelCancelUnknown", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, {"VehicleSpeedCameraControlMode", {PERSISTENT, INT, "1"}}, {"VehicleSpeedCameraDistanceTime", {PERSISTENT, INT, "60"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index ba800f530b1..bac3efb336f 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -369,7 +369,8 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, master, children = "VehicleNaviDecelCancel", ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump") defaults = {master: 0, "VehicleNaviSectionAvgControl": 0, **{name: 1 for name in children}, - "VehicleNaviDecelCancelFixed": 0} # real enforcement cameras: opt-in even with the master on + "VehicleNaviDecelCancelFixed": 0, # real enforcement cameras: opt-in even with the master on + "VehicleNaviDecelCancelUnknown": 0} # experimental: kind unknown, opt-in for name, default in defaults.items(): control = by_name[name] assert (control["min"], control["max"], control["default"]) == (0, 1, default) @@ -378,7 +379,7 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, # Per-kind toggles live in the master's detail screen; only the master is opt-in. assert not by_name[master].get("detail_parent") assert all(by_name[name]["detail_parent"] == master and by_name[name]["group"] == by_name[master]["group"] - for name in (*children, "VehicleNaviDecelCancelFixed")) + for name in (*children, "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown")) driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") speed = next(group for group in driving["groups"] if group["id"] == "SPEED") diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 3222d6f583a..d0a1ac064ab 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -228,6 +228,7 @@ "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", "VehicleNaviDecelCancelFixed", + "VehicleNaviDecelCancelUnknown", "VehicleNaviSectionAvgControl", "VehicleSpeedCameraControlMode", "VehicleSpeedCameraDistanceTime", @@ -3617,6 +3618,26 @@ "detail_parent": "VehicleNaviDecelCancel", "detail_section": "camera" }, + { + "group": "감속제어", + "name": "VehicleNaviDecelCancelUnknown", + "title": "감속 중지: 알 수 없는 경고 [실험]", + "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비 단속 경고 중 카메라 종류 예고를 받지 못했거나 해석하지 못한 경고(CAN 미수신·미처리)의 감속을 + 버튼·악셀톡으로 중지할 수 있게 하는 실험 기능입니다. 종류를 모르는 경고라 예고 없이 오는 신호·과속 카메라도 포함될 수 있어 기본값은 꺼짐이며, 상위를 켜도 이 항목은 따로 켜야 동작합니다. 30km/h 이하 경고(어린이·노인보호구역 등)와 구간단속 중에는 적용하지 않습니다. 중지한 뒤 신호·후면·고정식 예고가 확인되면 그 종류의 규칙을 따릅니다.", + "egroup": "SPEED", + "etitle": "Deceleration Cancel: Unknown Warning [Experimental]", + "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a stock-navigation enforcement warning whose camera kind preview was not received or not understood (experimental). Because the kind is unknown it can include signal or speed cameras that send no preview, so this is off by default and must be turned on separately even with the master on. Not applied to warnings at 30 km/h or below (school and senior zones) or inside a section. If a signal, rear or fixed preview is matched after the cancel, that kind's rule applies.", + "cgroup": "速度控制", + "ctitle": "减速中止:未知警告 [实验]", + "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止原车导航测速警告中未收到或无法解析摄像头类型预告(CAN 未接收/未处理)的警告的减速(实验功能)。由于类型未知,可能包含无预告的闯红灯或测速摄像头,因此默认关闭,即使开启上级也需单独开启。30km/h 及以下的警告(儿童·老人保护区等)和区间测速中不适用。中止后若匹配到闯红灯、后向或固定预告,则遵循该类型的规则。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high", + "detail_parent": "VehicleNaviDecelCancel", + "detail_section": "camera" + }, { "group": "감속제어", "name": "VehicleNaviSectionAvgControl", From 084ee9adeabaa95b409d463051bcd053562c31e4 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:52:15 +0900 Subject: [PATCH 15/50] speedcam: rename the experimental unknown-warning cancel in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title the VehicleNaviDecelCancelUnknown toggle by what it does: skip speed cameras whose kind was not detected ("과속카메라 종류 미탐지 건 건너뛰기 [실험]", en/zh to match). Key and behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- openpilot/selfdrive/carrot_settings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index d0a1ac064ab..332c86ffaa2 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -3621,13 +3621,13 @@ { "group": "감속제어", "name": "VehicleNaviDecelCancelUnknown", - "title": "감속 중지: 알 수 없는 경고 [실험]", + "title": "과속카메라 종류 미탐지 건 건너뛰기 [실험]", "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비 단속 경고 중 카메라 종류 예고를 받지 못했거나 해석하지 못한 경고(CAN 미수신·미처리)의 감속을 + 버튼·악셀톡으로 중지할 수 있게 하는 실험 기능입니다. 종류를 모르는 경고라 예고 없이 오는 신호·과속 카메라도 포함될 수 있어 기본값은 꺼짐이며, 상위를 켜도 이 항목은 따로 켜야 동작합니다. 30km/h 이하 경고(어린이·노인보호구역 등)와 구간단속 중에는 적용하지 않습니다. 중지한 뒤 신호·후면·고정식 예고가 확인되면 그 종류의 규칙을 따릅니다.", "egroup": "SPEED", - "etitle": "Deceleration Cancel: Unknown Warning [Experimental]", + "etitle": "Skip Speed Cameras of Undetected Kind [Experimental]", "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a stock-navigation enforcement warning whose camera kind preview was not received or not understood (experimental). Because the kind is unknown it can include signal or speed cameras that send no preview, so this is off by default and must be turned on separately even with the master on. Not applied to warnings at 30 km/h or below (school and senior zones) or inside a section. If a signal, rear or fixed preview is matched after the cancel, that kind's rule applies.", "cgroup": "速度控制", - "ctitle": "减速中止:未知警告 [实验]", + "ctitle": "跳过未识别类型的测速摄像头 [实验]", "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止原车导航测速警告中未收到或无法解析摄像头类型预告(CAN 未接收/未处理)的警告的减速(实验功能)。由于类型未知,可能包含无预告的闯红灯或测速摄像头,因此默认关闭,即使开启上级也需单独开启。30km/h 及以下的警告(儿童·老人保护区等)和区间测速中不适用。中止后若匹配到闯红灯、后向或固定预告,则遵循该类型的规则。", "min": 0, "max": 1, From 09d170f9c60be4d5321286fab1576b3952c9011f Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:54:12 +0900 Subject: [PATCH 16/50] speedcam: match the undetected-kind cancel title to its siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefix the VehicleNaviDecelCancelUnknown title like the other per-kind toggles: "감속 중지: 과속카메라 종류 미탐지 건 [실험]" (en/zh to match). Key and behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- openpilot/selfdrive/carrot_settings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 332c86ffaa2..af4f40a7e56 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -3621,13 +3621,13 @@ { "group": "감속제어", "name": "VehicleNaviDecelCancelUnknown", - "title": "과속카메라 종류 미탐지 건 건너뛰기 [실험]", + "title": "감속 중지: 과속카메라 종류 미탐지 건 [실험]", "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비 단속 경고 중 카메라 종류 예고를 받지 못했거나 해석하지 못한 경고(CAN 미수신·미처리)의 감속을 + 버튼·악셀톡으로 중지할 수 있게 하는 실험 기능입니다. 종류를 모르는 경고라 예고 없이 오는 신호·과속 카메라도 포함될 수 있어 기본값은 꺼짐이며, 상위를 켜도 이 항목은 따로 켜야 동작합니다. 30km/h 이하 경고(어린이·노인보호구역 등)와 구간단속 중에는 적용하지 않습니다. 중지한 뒤 신호·후면·고정식 예고가 확인되면 그 종류의 규칙을 따릅니다.", "egroup": "SPEED", - "etitle": "Skip Speed Cameras of Undetected Kind [Experimental]", + "etitle": "Deceleration Cancel: Undetected Camera Kind [Experimental]", "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a stock-navigation enforcement warning whose camera kind preview was not received or not understood (experimental). Because the kind is unknown it can include signal or speed cameras that send no preview, so this is off by default and must be turned on separately even with the master on. Not applied to warnings at 30 km/h or below (school and senior zones) or inside a section. If a signal, rear or fixed preview is matched after the cancel, that kind's rule applies.", "cgroup": "速度控制", - "ctitle": "跳过未识别类型的测速摄像头 [实验]", + "ctitle": "减速中止:未识别类型的测速摄像头 [实验]", "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止原车导航测速警告中未收到或无法解析摄像头类型预告(CAN 未接收/未处理)的警告的减速(实验功能)。由于类型未知,可能包含无预告的闯红灯或测速摄像头,因此默认关闭,即使开启上级也需单独开启。30km/h 及以下的警告(儿童·老人保护区等)和区间测速中不适用。中止后若匹配到闯红灯、后向或固定预告,则遵循该类型的规则。", "min": 0, "max": 1, From 7512215f568e0c0ff35004540c6b6fe1195d64a6 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:45:51 +0900 Subject: [PATCH 17/50] hyundai: count turn-signal lever presses into carState carState.leftBlinker/rightBlinker on CAN-FD Hyundai is the lamp (BLINKERS LEFT/RIGHT_LAMP_ALT, 0.4 s on/off, bridged 0.5 s), so a lever press while the lamp is already flashing is invisible. BLINKER_STALKS (0x3C1, PT bus, ~5 Hz) carries the lever itself: a 0.04-0.2 s pulse per press (2026-09-19 drive: 37 presses, matching the driver's actions). BLINKERS LEFT_STALK (0x413 bit 8) is not the lever but the ~4 s flash session of a one-touch press. Add carState.leftBlinkerStalkCount / rightBlinkerStalkCount (UInt8, wrapping, @95/@96): +1 on each rising edge. A counter cannot be missed by consumers that sample carState at 20 Hz (modeld's DesireHelper), unlike a one-frame button event. Read BLINKER_STALKS on the PT bus only: the alt bus carries an unrelated 24-byte frame with the same 0x3C1 id. Tests: test_blinker_stalks.py (press vs hold, both sides, UInt8 wrap, car without the message). Co-Authored-By: Claude Opus 5.5 --- opendbc_repo/opendbc/car/car.capnp | 2 + opendbc_repo/opendbc/car/hyundai/carstate.py | 20 ++++++++++ .../car/hyundai/tests/test_blinker_stalks.py | 37 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py diff --git a/opendbc_repo/opendbc/car/car.capnp b/opendbc_repo/opendbc/car/car.capnp index f715209c9cd..3e5ae00647b 100644 --- a/opendbc_repo/opendbc/car/car.capnp +++ b/opendbc_repo/opendbc/car/car.capnp @@ -273,6 +273,8 @@ struct CarState { vehicleNaviAvailable @92 :Bool; # stock-navigation 0x4BE has been observed during this drive radarInput @93 :RadarInput; steeringTouch @94 :SteeringTouch; + leftBlinkerStalkCount @95 :UInt8; # +1 (wrapping) on every left turn-signal lever press; the lamp-based leftBlinker cannot show a press while the lamp is already flashing + rightBlinkerStalkCount @96 :UInt8; # +1 (wrapping) on every right turn-signal lever press # Optional original vehicle receive signal. Separate from torque/override. struct SteeringTouch { diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 083506f4f8b..78a9d6835ac 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -219,6 +219,9 @@ def __init__(self, CP): self.accelerator = None self.blinkers = None self.blinkers_alt = None + self.blinker_stalks = None + self.left_stalk_prev = self.right_stalk_prev = False + self.left_blinker_stalk_count = self.right_blinker_stalk_count = 0 self.doors_seatbelts = None self.cruise_buttons_alt2 = None @@ -476,6 +479,9 @@ def add_and_cache(parser, name: str, attr: str, ignore_counter: bool = False): add_and_cache(self.cp, "ACCELERATOR", "accelerator", ignore_counter = True) add_and_cache(self.cp, "BLINKERS", "blinkers") add_and_cache(self.cp, "BLINKERS_ALT", "blinkers_alt") + # Turn-signal lever pulses (0.04-0.2 s per press, ~5 Hz) on the PT bus only: the alt bus carries an + # unrelated 24-byte frame with the same 0x3C1 id (Ioniq 5 PE, 2026-09-19 drive). + add_and_cache(self.cp, "BLINKER_STALKS", "blinker_stalks", ignore_counter=True) add_and_cache(self.cp, "DOORS_SEATBELTS", "doors_seatbelts") elif self.controls_ready_count == 126: add_and_cache(self.cp, "CRUISE_BUTTONS_ALT2", "cruise_buttons_alt2", ignore_counter = True) @@ -855,6 +861,18 @@ def _swallow_speedcam_accel(self, button_events): kept.append(event) return kept + def _update_blinker_stalks(self, ret): + """Count turn-signal lever presses (BLINKER_STALKS rising edges) into carState.*BlinkerStalkCount.""" + if self.blinker_stalks is not None: + left_stalk, right_stalk = bool(self.blinker_stalks["LEFT_BLINKER"]), bool(self.blinker_stalks["RIGHT_BLINKER"]) + if left_stalk and not self.left_stalk_prev: + self.left_blinker_stalk_count = (self.left_blinker_stalk_count + 1) % 256 + if right_stalk and not self.right_stalk_prev: + self.right_blinker_stalk_count = (self.right_blinker_stalk_count + 1) % 256 + self.left_stalk_prev, self.right_stalk_prev = left_stalk, right_stalk + ret.leftBlinkerStalkCount = self.left_blinker_stalk_count + ret.rightBlinkerStalkCount = self.right_blinker_stalk_count + def _hold_speedcam_decel(self, button_events, decel_long_press): """While a section is unlocked, cruise - goes to carstate only: a long press relocks the section without cruise.py's -10 step, and a short press is handed to cruise.py at release (where it acts).""" @@ -1372,6 +1390,8 @@ def update_canfd(self, can_parsers) -> structs.CarState: ret.steerFaultTemporary = cp.vl["MDPS"]["LKA_FAULT"] != 0 or cp.vl["MDPS"]["LFA2_FAULT"] != 0 #ret.steerFaultTemporary = False + self._update_blinker_stalks(ret) + blinkers_info = self.blinkers if self.blinkers is not None else self.blinkers_alt if self.blinkers_alt is not None else None if blinkers_info is not None: left_blinker_lamp = blinkers_info["LEFT_LAMP"] or blinkers_info["LEFT_LAMP_ALT"] diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py new file mode 100644 index 00000000000..3d6ada1a061 --- /dev/null +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py @@ -0,0 +1,37 @@ +from opendbc.car import structs +from opendbc.car.hyundai.carstate import CarState + + +def _stalk_state(): + cs = CarState.__new__(CarState) + cs.blinker_stalks = None + cs.left_stalk_prev = cs.right_stalk_prev = False + cs.left_blinker_stalk_count = cs.right_blinker_stalk_count = 0 + return cs + + +def _counts(cs, left, right): + cs.blinker_stalks = {"LEFT_BLINKER": left, "RIGHT_BLINKER": right} + ret = structs.CarState() + cs._update_blinker_stalks(ret) + return ret.leftBlinkerStalkCount, ret.rightBlinkerStalkCount + + +def test_stalk_counter_counts_presses_not_hold_time(): + cs = _stalk_state() + assert _counts(cs, 0, 0) == (0, 0) + assert _counts(cs, 1, 0) == (1, 0) # press + assert _counts(cs, 1, 0) == (1, 0) # still held: same press + assert _counts(cs, 0, 0) == (1, 0) + assert _counts(cs, 1, 0) == (2, 0) # second press + assert _counts(cs, 0, 1) == (2, 1) + + +def test_stalk_counter_wraps_and_survives_a_missing_message(): + cs = _stalk_state() + cs.left_blinker_stalk_count = 255 + assert _counts(cs, 1, 0) == (0, 0) # UInt8 wrap + cs.blinker_stalks = None # BLINKER_STALKS not on this car + ret = structs.CarState() + cs._update_blinker_stalks(ret) + assert (ret.leftBlinkerStalkCount, ret.rightBlinkerStalkCount) == (0, 0) From 18dbb7edb5442b4c8f4aa13bd0f193d20b307eed Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:45:52 +0900 Subject: [PATCH 18/50] desire: a turn-signal lever press asks for the next lane change New lane-change input was ignored for the ~6 s of laneChangeStarting + laneChangeFinishing (the green cluster lane). A lever press made then starts a 4 s flash, so the lamp-based blinker was still on when the change ended and the next one fell into preLaneChange with next_lane_change, waiting for steering torque. Drivers had to wait for the green lane to go off and press again, losing the gap. With carState.*BlinkerStalkCount: - presses while crossing (laneChangeStarting) are ignored, so re-lighting the lamp mid-change never asks for a second lane; - a press in laneChangeFinishing after STALK_QUEUE_GUARD_S (0.3 s) is a request: when the change ends the next one waits in preLaneChange without the torque requirement, still behind every availability check (lane geometry, line type/colour, side objects, BSD) and one lane at a time; - a press while waiting for torque releases that wait; - a press while the lamp is still flashing starts a request from off. Cars without the counters keep the lamp behaviour. A/B replay of the 2026-09-19 drive (61 segments, 72,681 model frames, logged inputs, A without / B with the logged lever presses): the only difference is one change where a finishing-phase press cleared the torque flag (the driver also applied torque, same start frame); the mid-change press at 1842.86 s is ignored as intended. Tests: test_desire_helper_stalk.py (7); disabling the lever input fails the 3 behaviour tests, the 4 unchanged-behaviour tests still pass. Co-Authored-By: Claude Opus 5.5 --- .../selfdrive/controls/lib/desire_helper.py | 45 ++++++- .../tests/test_desire_helper_stalk.py | 113 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index d49e0c6972f..4e9b8cc7df4 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -12,6 +12,10 @@ from openpilot.selfdrive.controls.lib.desire_lib.side_state import SideState from openpilot.selfdrive.controls.lib.desire_lib.maneuver_classifier import classify_maneuver_type +# A lever press this early in laneChangeFinishing is still the driver re-lighting the lamp of the change +# that just crossed, not a request for the next one. +STALK_QUEUE_GUARD_S = 0.3 + class DesireHelper: def __init__(self): @@ -51,6 +55,11 @@ def __init__(self): self.auto_lane_change_enable = False self.next_lane_change = False + # turn-signal lever presses (carState.*BlinkerStalkCount): an explicit request for the next lane change + self.stalk_counts = None + self.queued_lane_change = BLINKER_NONE + self.finishing_timer = 0.0 + # keep pulse self.keep_pulse_timer = 0.0 @@ -103,6 +112,24 @@ def _update_driver_blinker(self, carstate): enabled = False return st, changed, enabled + def _update_stalk(self, carstate) -> int: + """Side of a turn-signal lever press since the last update (BLINKER_LEFT/RIGHT), else BLINKER_NONE. + + The lamp-based blinker cannot show a press while the lamp is already flashing (one press flashes + for ~4 s); the lever counters can, and a counter never misses a press between 20 Hz updates. + """ + counts = (getattr(carstate, "leftBlinkerStalkCount", None), getattr(carstate, "rightBlinkerStalkCount", None)) + if counts[0] is None or counts[1] is None: + return BLINKER_NONE + last, self.stalk_counts = self.stalk_counts, counts + if last is None: + return BLINKER_NONE + if counts[0] != last[0]: + return BLINKER_LEFT + if counts[1] != last[1]: + return BLINKER_RIGHT + return BLINKER_NONE + def _update_atc_blinker(self, carrotMan, driver_blinker_state, remote=None): atc_type = carrotMan.atcType atc_blinker_state = BLINKER_NONE @@ -242,6 +269,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa # blinkers driver_st, driver_changed, driver_enabled = self._update_driver_blinker(carstate) + stalk_press = self._update_stalk(carstate) remote = self.bluetooth_commands.read(allowed=(lateral_active and carstate.canValid and not below_lane_change_speed and not trailer_maneuver_blocked)) atc_st, atc_enabled = self._update_atc_blinker(carrotMan, driver_st, remote) @@ -349,7 +377,8 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.turn_direction = TurnDirection.none if self.lane_change_state == LaneChangeState.off: - driver_desire_started = driver_enabled and driver_changed + # A lever press while the lamp is still flashing has no lamp edge but is still a fresh request. + driver_desire_started = driver_enabled and (driver_changed or stalk_press == blinker_state) if desire_enabled and (not self.prev_desire_enabled or driver_desire_started) and \ not below_lane_change_speed and side is not None: self.lane_change_state = LaneChangeState.preLaneChange @@ -367,6 +396,9 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.lane_change_direction = LaneChangeDirection.none else: self.lane_change_direction = LaneChangeDirection.left if blinker_state == BLINKER_LEFT else LaneChangeDirection.right + # An explicit lever press asks for this change: no steering-torque confirmation needed. + if self.next_lane_change and driver_enabled and stalk_press == blinker_state: + self.next_lane_change = False # torque direction cond torque_cond = (carstate.steeringTorque > 0) if blinker_state == BLINKER_LEFT else (carstate.steeringTorque < 0) @@ -428,19 +460,28 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.lane_change_state = LaneChangeState.laneChangeStarting elif self.lane_change_state == LaneChangeState.laneChangeStarting: + # Lever presses while crossing are ignored (re-lighting the lamp must not ask for a second lane). self.lane_change_ll_prob = max(self.lane_change_ll_prob - 2 * DT_MDL, 0.0) if lane_change_prob < 0.02 and self.lane_change_ll_prob < 0.01: self.lane_change_state = LaneChangeState.laneChangeFinishing + self.finishing_timer = 0.0 + self.queued_lane_change = BLINKER_NONE elif self.lane_change_state == LaneChangeState.laneChangeFinishing: + self.finishing_timer += DT_MDL + if stalk_press != BLINKER_NONE and self.finishing_timer >= STALK_QUEUE_GUARD_S: + self.queued_lane_change = stalk_press self.lane_change_ll_prob = min(self.lane_change_ll_prob + DT_MDL, 1.0) if self.lane_change_ll_prob > 0.99: self.lane_change_direction = LaneChangeDirection.none if desire_enabled: self.lane_change_state = LaneChangeState.preLaneChange - self.next_lane_change = True + # Holding the blinker through the change still needs torque for the next one; a lever press + # made during finishing (same side as the lamp) is an explicit request and does not. + self.next_lane_change = not (driver_enabled and self.queued_lane_change == blinker_state) else: self.lane_change_state = LaneChangeState.off + self.queued_lane_change = BLINKER_NONE # timer if self.lane_change_state in (LaneChangeState.off, LaneChangeState.preLaneChange): diff --git a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py new file mode 100644 index 00000000000..46b359bec7a --- /dev/null +++ b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py @@ -0,0 +1,113 @@ +from types import SimpleNamespace + +from openpilot.cereal import log +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, STALK_QUEUE_GUARD_S + +LCS = log.LaneChangeState + + +class TestDesireHelperStalkRequest: + """Turn-signal lever presses (carState.*BlinkerStalkCount) as explicit next-lane-change requests.""" + + def setup_method(self): + self.helper = DesireHelper() + self.helper._update_params_periodic = lambda: None + self.helper._process_sides = lambda car, model, radar: None + self.helper._check_desire_state = lambda model, car, maneuver: None + self.helper.laneChangeNeedTorque = 0 + self.helper.laneChangeBsd = 0 + self.helper.laneLineCheck = 0 + for side in (self.helper.left, self.helper.right): + side.lane_available = True + side.edge_available = False + side.dist_to_edge_far = 2.0 + side.lane_change_available_geom = True + side.lane_change_available = True + side.side_object_detected = False + side.bsd_hold_counter = 0 + side.lane_line_info_mod = 0 + side.lane_line_info_edge_detect = False + side.lane_change_available_released = False + side.lane_available_trigger = False + side.lane_appeared = False + side.lane_exist_count.counter = 10 + self.carrot_man = SimpleNamespace(atcType="", carrotCmdIndex=0, carrotCmd="", carrotArg="") + self.left_count = 0 + + def update(self, left_blinker=True, lane_change_prob=0.5, press=False, counters=True): + if press: + self.left_count = (self.left_count + 1) % 256 + car = SimpleNamespace(canValid=True, leftBlinker=left_blinker, rightBlinker=False, vEgo=25.0, aEgo=0.0, + trailerConnected=False, steeringTorque=0.0, steeringPressed=False) + if counters: + car.leftBlinkerStalkCount, car.rightBlinkerStalkCount = self.left_count, 0 + self.helper.update(car, SimpleNamespace(), True, lane_change_prob, self.carrot_man, SimpleNamespace()) + return self.helper.lane_change_state + + def start_and_cross(self, counters=True): + """off -> pre -> starting with the left blinker, then hold starting until the model says done.""" + self.update(left_blinker=False, counters=counters) + assert self.update(press=counters, counters=counters) == LCS.preLaneChange + assert self.update(counters=counters) == LCS.laneChangeStarting + for _ in range(5): + self.update(counters=counters) + + def finish(self, press_at_s=None, counters=True): + """laneChangeStarting -> finishing -> end; optionally press the lever press_at_s into finishing.""" + for _ in range(int(0.6 / DT_MDL)): # ll_prob decays at 2/s; model prob already low + if self.update(lane_change_prob=0.0, counters=counters) == LCS.laneChangeFinishing: + break + assert self.helper.lane_change_state == LCS.laneChangeFinishing + t = DT_MDL # the transition frame counts as the first + while self.helper.lane_change_state == LCS.laneChangeFinishing: + press = press_at_s is not None and abs(t - press_at_s) < DT_MDL / 2 + self.update(lane_change_prob=0.0, press=press, counters=counters) + t += DT_MDL + return self.helper.lane_change_state + + def test_blinker_held_through_the_change_still_needs_torque(self): + self.start_and_cross() + assert self.finish() == LCS.preLaneChange + assert self.helper.next_lane_change + assert self.update() == LCS.preLaneChange # no torque: no second lane change + + def test_press_while_crossing_is_ignored(self): + self.start_and_cross() + self.update(press=True) # re-lighting the lamp mid-change + assert self.helper.lane_change_state == LCS.laneChangeStarting + assert self.finish() == LCS.preLaneChange + assert self.helper.next_lane_change + assert self.update() == LCS.preLaneChange + + def test_press_during_finishing_queues_the_next_change_without_torque(self): + self.start_and_cross() + assert self.finish(press_at_s=STALK_QUEUE_GUARD_S + 0.2) == LCS.preLaneChange + assert not self.helper.next_lane_change + assert self.update() == LCS.laneChangeStarting # one lane at a time: starts after the first ended + + def test_press_right_after_the_crossing_is_still_the_same_change(self): + self.start_and_cross() + assert self.finish(press_at_s=STALK_QUEUE_GUARD_S / 2) == LCS.preLaneChange + assert self.helper.next_lane_change + + def test_press_while_waiting_for_torque_releases_the_wait(self): + self.start_and_cross() + self.finish() + assert self.helper.next_lane_change + self.update(press=True) + assert not self.helper.next_lane_change + assert self.update() == LCS.laneChangeStarting + + def test_press_while_the_lamp_still_flashes_starts_a_request_from_off(self): + self.update(left_blinker=True) # lamp already flashing: no lamp rising edge left + self.helper.lane_change_state = LCS.off + self.helper.prev_desire_enabled = True + assert self.update() == LCS.off + assert self.update(press=True) == LCS.preLaneChange + + def test_cars_without_lever_counters_keep_the_lamp_behaviour(self): + self.start_and_cross(counters=False) + assert self.finish(counters=False) == LCS.preLaneChange + assert self.helper.next_lane_change + assert self.update(counters=False) == LCS.preLaneChange From 0b12f7a37e8577d9c1c9f08d0e4b7bc2a4fd0f6b Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:06:00 +0900 Subject: [PATCH 19/50] desire: latched turn-signal lever means a turn below 50 km/h (BlinkerLatchedTurn) The turn / lane-change choice (classify_maneuver_type) guesses from speed, lane lines and the model, and often guesses wrong. The lever already says it: BLINKER_STALKS (0x3C1) keeps LEFT/RIGHT_BLINKER set while latched, and the undocumented bits 36/38 (now LEFT/RIGHT_BLINKER_TAP) are set only in the one-touch detent (2026-10-04 lever test, route 0000048b: 12/12 inputs; a right latch passed through the detent for ~0.1 s). - carState.blinkerLever @97: 0 released, 1 one-touch detent, 2 latched. - BlinkerLatchedTurn (default off, Hyundai CAN-FD only): at or below 50 km/h with the driver's blinker, latched = turn, otherwise lane change. No lane change starts while the lever is still in the one-touch detent, so a latch on its way through cannot start one. Above 50 km/h the existing classifier is unchanged. Tests: lever position (one-touch / latched / passing the detent); off keeps the classifier, latched = turn, detent blocks the start, one-touch starts after the lever returns, latched above 50 km/h stays a lane change. Hyundai/desire/settings/DBC suites: same 22 pre-existing failures before and after, +13 passed. Co-Authored-By: Claude Opus 5.5 --- opendbc_repo/opendbc/car/car.capnp | 1 + opendbc_repo/opendbc/car/hyundai/carstate.py | 5 +- .../car/hyundai/tests/test_blinker_stalks.py | 19 ++++++- .../dbc/generator/hyundai/hyundai_canfd.dbc | 2 + .../opendbc/dbc/hyundai_canfd_generated.dbc | 2 + openpilot/common/params_keys.h | 1 + openpilot/selfdrive/carrot_settings.json | 19 +++++++ .../selfdrive/controls/lib/desire_helper.py | 14 +++++- .../tests/test_desire_helper_stalk.py | 49 +++++++++++++++++-- 9 files changed, 106 insertions(+), 6 deletions(-) diff --git a/opendbc_repo/opendbc/car/car.capnp b/opendbc_repo/opendbc/car/car.capnp index 3e5ae00647b..cbde7885234 100644 --- a/opendbc_repo/opendbc/car/car.capnp +++ b/opendbc_repo/opendbc/car/car.capnp @@ -275,6 +275,7 @@ struct CarState { steeringTouch @94 :SteeringTouch; leftBlinkerStalkCount @95 :UInt8; # +1 (wrapping) on every left turn-signal lever press; the lamp-based leftBlinker cannot show a press while the lamp is already flashing rightBlinkerStalkCount @96 :UInt8; # +1 (wrapping) on every right turn-signal lever press + blinkerLever @97 :UInt8; # turn-signal lever position: 0 released, 1 one-touch (half) detent, 2 latched # Optional original vehicle receive signal. Separate from torque/override. struct SteeringTouch { diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 78a9d6835ac..c57bc1d3fb2 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -862,7 +862,7 @@ def _swallow_speedcam_accel(self, button_events): return kept def _update_blinker_stalks(self, ret): - """Count turn-signal lever presses (BLINKER_STALKS rising edges) into carState.*BlinkerStalkCount.""" + """Turn-signal lever (BLINKER_STALKS): presses (rising edges) into carState.*BlinkerStalkCount, position into blinkerLever.""" if self.blinker_stalks is not None: left_stalk, right_stalk = bool(self.blinker_stalks["LEFT_BLINKER"]), bool(self.blinker_stalks["RIGHT_BLINKER"]) if left_stalk and not self.left_stalk_prev: @@ -870,6 +870,9 @@ def _update_blinker_stalks(self, ret): if right_stalk and not self.right_stalk_prev: self.right_blinker_stalk_count = (self.right_blinker_stalk_count + 1) % 256 self.left_stalk_prev, self.right_stalk_prev = left_stalk, right_stalk + # *_TAP is set only in the one-touch detent; a latch passes through it for ~0.1 s (2026-10-04 lever test) + tap = self.blinker_stalks["LEFT_BLINKER_TAP"] or self.blinker_stalks["RIGHT_BLINKER_TAP"] + ret.blinkerLever = 1 if tap else 2 if (left_stalk or right_stalk) else 0 ret.leftBlinkerStalkCount = self.left_blinker_stalk_count ret.rightBlinkerStalkCount = self.right_blinker_stalk_count diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py index 3d6ada1a061..1358ba44005 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blinker_stalks.py @@ -11,7 +11,7 @@ def _stalk_state(): def _counts(cs, left, right): - cs.blinker_stalks = {"LEFT_BLINKER": left, "RIGHT_BLINKER": right} + cs.blinker_stalks = {"LEFT_BLINKER": left, "RIGHT_BLINKER": right, "LEFT_BLINKER_TAP": 0, "RIGHT_BLINKER_TAP": 0} ret = structs.CarState() cs._update_blinker_stalks(ret) return ret.leftBlinkerStalkCount, ret.rightBlinkerStalkCount @@ -35,3 +35,20 @@ def test_stalk_counter_wraps_and_survives_a_missing_message(): ret = structs.CarState() cs._update_blinker_stalks(ret) assert (ret.leftBlinkerStalkCount, ret.rightBlinkerStalkCount) == (0, 0) + + +def test_lever_position_one_touch_vs_latched(): + # 2026-10-04 lever test (route 0000048b): one-touch sets *_BLINKER and *_TAP together; a latch keeps + # *_BLINKER only, after passing the one-touch detent for ~0.1 s on the right side. + cs = _stalk_state() + def lever(left, right, left_tap, right_tap): + cs.blinker_stalks = {"LEFT_BLINKER": left, "RIGHT_BLINKER": right, "LEFT_BLINKER_TAP": left_tap, "RIGHT_BLINKER_TAP": right_tap} + ret = structs.CarState() + cs._update_blinker_stalks(ret) + return ret.blinkerLever + assert lever(0, 0, 0, 0) == 0 + assert lever(1, 0, 1, 0) == 1 # left one-touch + assert lever(0, 0, 0, 0) == 0 + assert lever(0, 1, 0, 1) == 1 # right latch, passing the detent + assert lever(0, 1, 0, 0) == 2 # latched + assert lever(1, 0, 0, 0) == 2 diff --git a/opendbc_repo/opendbc/dbc/generator/hyundai/hyundai_canfd.dbc b/opendbc_repo/opendbc/dbc/generator/hyundai/hyundai_canfd.dbc index ca65d7d728c..6ec20053364 100644 --- a/opendbc_repo/opendbc/dbc/generator/hyundai/hyundai_canfd.dbc +++ b/opendbc_repo/opendbc/dbc/generator/hyundai/hyundai_canfd.dbc @@ -863,6 +863,8 @@ BO_ 961 BLINKER_STALKS: 8 XXX SG_ HIGHBEAM_BACKWARD : 26|1@0+ (1,0) [0|1] "" XXX SG_ LEFT_BLINKER : 30|1@0+ (1,0) [0|1] "" XXX SG_ RIGHT_BLINKER : 32|1@0+ (1,0) [0|1] "" XXX + SG_ LEFT_BLINKER_TAP : 36|1@0+ (1,0) [0|1] "" XXX + SG_ RIGHT_BLINKER_TAP : 38|1@0+ (1,0) [0|1] "" XXX BO_ 995 BLINKERS_ALT: 16 XXX SG_ LEFT_BLINKER : 74|1@0+ (1,0) [0|1] "" XXX diff --git a/opendbc_repo/opendbc/dbc/hyundai_canfd_generated.dbc b/opendbc_repo/opendbc/dbc/hyundai_canfd_generated.dbc index c1e00f81b26..741bb1ee9f9 100644 --- a/opendbc_repo/opendbc/dbc/hyundai_canfd_generated.dbc +++ b/opendbc_repo/opendbc/dbc/hyundai_canfd_generated.dbc @@ -866,6 +866,8 @@ BO_ 961 BLINKER_STALKS: 8 XXX SG_ HIGHBEAM_BACKWARD : 26|1@0+ (1,0) [0|1] "" XXX SG_ LEFT_BLINKER : 30|1@0+ (1,0) [0|1] "" XXX SG_ RIGHT_BLINKER : 32|1@0+ (1,0) [0|1] "" XXX + SG_ LEFT_BLINKER_TAP : 36|1@0+ (1,0) [0|1] "" XXX + SG_ RIGHT_BLINKER_TAP : 38|1@0+ (1,0) [0|1] "" XXX BO_ 995 BLINKERS_ALT: 16 XXX SG_ LEFT_BLINKER : 74|1@0+ (1,0) [0|1] "" XXX diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 02c88984b7a..94a6534e1b6 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -355,6 +355,7 @@ inline static std::unordered_map keys = { {"LaneChangeDelay", {PERSISTENT, INT, "0"}}, {"LaneChangeBsd", {PERSISTENT, INT, "0"}}, {"LaneLineCheck", {PERSISTENT, INT, "0"}}, + {"BlinkerLatchedTurn", {PERSISTENT, BOOL, "0"}}, {"MaxAngleFrames", {PERSISTENT, INT, "89"}}, {"LatMpcPathCost", {PERSISTENT, INT, "200"}}, diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index af4f40a7e56..2fef9b0a57b 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -148,6 +148,7 @@ "LaneChangeDelay", "LaneChangeBsd", "LaneLineCheck", + "BlinkerLatchedTurn", "AutoTurnControl", "AutoTurnControlSpeedTurn", "AutoTurnControlTurnEnd", @@ -2466,6 +2467,24 @@ "default": 0, "unit": 1 }, + { + "group": "조향일반", + "name": "BlinkerLatchedTurn", + "title": "깜빡이 레버로 회전/차선변경 구분", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 50km/h 이하에서 레버를 끝까지 걸면(고정식) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정합니다. 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정식도 걸리기 전 약 0.1초 그 위치를 지나감). 50km/h 초과에서는 기존 자동 판단을 씁니다.", + "egroup": "LAT", + "etitle": "Turn-signal lever selects turn / lane change", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). At or below 50 km/h a latched lever means a left/right turn and a one-touch (half) press means a lane change. No lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above 50 km/h the existing automatic guess is used.", + "cgroup": "转向", + "ctitle": "转向灯拨杆区分转弯/变道", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。50km/h 以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道。拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过 50km/h 时使用原有自动判断。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high" + }, { "group": "조향일반", "name": "LaneChangeDelay", diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index 4e9b8cc7df4..954b593c1fc 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -15,6 +15,8 @@ # A lever press this early in laneChangeFinishing is still the driver re-lighting the lamp of the change # that just crossed, not a request for the next one. STALK_QUEUE_GUARD_S = 0.3 +# BlinkerLatchedTurn: up to this speed the lever decides the manoeuvre (latched = turn, one-touch = lane change). +LEVER_TURN_SPEED_MAX = 50 / 3.6 class DesireHelper: @@ -68,6 +70,7 @@ def __init__(self): self.laneChangeBsd = 0 self.laneLineCheck = 0 self.laneChangeDelay = 0.0 + self.blinkerLatchedTurn = False # misc self.prev_desire_enabled = False @@ -86,6 +89,7 @@ def _update_params_periodic(self): self.laneChangeBsd = self.params.get_int("LaneChangeBsd") self.laneLineCheck = self.params.get_int("LaneLineCheck") self.laneChangeDelay = self.params.get_float("LaneChangeDelay") * 0.1 + self.blinkerLatchedTurn = self.params.get_bool("BlinkerLatchedTurn") def _check_desire_state(self, modeldata, carstate, maneuver_type): desire_state = modeldata.meta.desireState @@ -350,6 +354,14 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa else: new_type = "none" + # BlinkerLatchedTurn: the lever replaces the speed/lane-line guess. A latch passes through the one-touch + # detent (~0.1 s), so a lane change must not start while the lever is still there (lever_undecided). + lever = getattr(carstate, "blinkerLever", 0) + lever_mode = self.blinkerLatchedTurn and driver_enabled and side is not None and v_ego <= LEVER_TURN_SPEED_MAX + lever_undecided = lever_mode and lever == 1 + if lever_mode: + new_type = "turn" if lever == 2 else "lane_change" + if trailer_maneuver_blocked and new_type in ("lane_change", "turn"): new_type = "none" @@ -436,7 +448,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa not atc_lane_change_retry_line_blocked start_gate = (side.lane_change_available_geom and self.lane_change_delay == 0) or \ side.lane_line_info_edge_detect or solid_line_blocked or block_released_auto or atc_line_release - if start_gate: + if start_gate and not lever_undecided: if solid_line_blocked: if atc_line_release or (torque_applied and not (bsd_active and block_lanechange_bsd)): self.lane_change_state = LaneChangeState.laneChangeStarting diff --git a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py index 46b359bec7a..56d248b1445 100644 --- a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py +++ b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py @@ -3,6 +3,7 @@ from openpilot.cereal import log from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, STALK_QUEUE_GUARD_S +from openpilot.selfdrive.controls.lib.desire_lib.constants import TurnDirection LCS = log.LaneChangeState @@ -35,11 +36,11 @@ def setup_method(self): self.carrot_man = SimpleNamespace(atcType="", carrotCmdIndex=0, carrotCmd="", carrotArg="") self.left_count = 0 - def update(self, left_blinker=True, lane_change_prob=0.5, press=False, counters=True): + def update(self, left_blinker=True, lane_change_prob=0.5, press=False, counters=True, lever=0, v_kph=90.0): if press: self.left_count = (self.left_count + 1) % 256 - car = SimpleNamespace(canValid=True, leftBlinker=left_blinker, rightBlinker=False, vEgo=25.0, aEgo=0.0, - trailerConnected=False, steeringTorque=0.0, steeringPressed=False) + car = SimpleNamespace(canValid=True, leftBlinker=left_blinker, rightBlinker=False, vEgo=v_kph / 3.6, aEgo=0.0, + trailerConnected=False, steeringTorque=0.0, steeringPressed=False, blinkerLever=lever) if counters: car.leftBlinkerStalkCount, car.rightBlinkerStalkCount = self.left_count, 0 self.helper.update(car, SimpleNamespace(), True, lane_change_prob, self.carrot_man, SimpleNamespace()) @@ -111,3 +112,45 @@ def test_cars_without_lever_counters_keep_the_lamp_behaviour(self): assert self.finish(counters=False) == LCS.preLaneChange assert self.helper.next_lane_change assert self.update(counters=False) == LCS.preLaneChange + + +class TestBlinkerLatchedTurn: + """BlinkerLatchedTurn: at or below 50 km/h a latched lever is a turn, a one-touch press a lane change.""" + setup_method = TestDesireHelperStalkRequest.setup_method + update = TestDesireHelperStalkRequest.update + + def lever_on(self, lever, v_kph=45.0, steps=1): + self.update(left_blinker=False, v_kph=v_kph) + for i in range(steps): + state = self.update(press=(i == 0), lever=lever, v_kph=v_kph) + return state + + def test_off_keeps_the_classifier(self): + self.lever_on(2, steps=3) + assert self.helper.maneuver_type == "lane_change" # 45 km/h, lanes visible: the classifier says lane change + + def test_latched_lever_is_a_turn(self): + self.helper.blinkerLatchedTurn = True + self.lever_on(2, steps=3) + assert self.helper.maneuver_type == "turn" + assert self.helper.lane_change_state == LCS.off + assert self.helper.turn_direction == TurnDirection.turnLeft + + def test_latch_passing_the_one_touch_detent_does_not_start_a_lane_change(self): + self.helper.blinkerLatchedTurn = True + assert self.lever_on(1, steps=3) == LCS.preLaneChange # still in the detent: wait + self.update(lever=2, v_kph=45.0) + assert self.helper.maneuver_type == "turn" + assert self.helper.lane_change_state == LCS.off + + def test_one_touch_is_a_lane_change_after_the_lever_returns(self): + self.helper.blinkerLatchedTurn = True + assert self.lever_on(1, steps=2) == LCS.preLaneChange + assert self.update(lever=0, v_kph=45.0) == LCS.laneChangeStarting + assert self.helper.maneuver_type == "lane_change" + + def test_latched_lever_above_50_kph_is_the_classifier(self): + self.helper.blinkerLatchedTurn = True + self.lever_on(2, v_kph=60.0, steps=3) + assert self.helper.maneuver_type == "lane_change" + assert self.helper.lane_change_state == LCS.laneChangeStarting From 963375f67d5d873712b31221c71d42d6e1c3c68a Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:37:55 +0900 Subject: [PATCH 20/50] desire: lever turn speed as a setting, one-touch request waits 5 flashes BlinkerLatchedTurn becomes a speed (km/h, 0 = off, step 5, max 80) instead of a fixed 50 km/h toggle. At or below it the lever decides as before (latched = turn, one-touch = lane change, nothing starts in the one-touch detent); above it every lever input, latched included, is a lane change, so the classifier's navigation-turn guess no longer turns a latched lever at 80 km/h into a turn. LaneChangeLeverWait (default off): a one-touch press asks for one lane change for 5 flashes from the press (0.8 s per flash, 2026-10-04 lever test), plus LaneChangeDelay, which the FSM waits out first. While that change waits in preLaneChange - blocked by whatever the FSM already checks (BSD, side objects, lines) - DesireHelper asks for the lamp through modelV2.meta.laneChangeBlinkerHold (@20) and ADRV_0x1ea BLINK_HOLD keeps it flashing, so a car set to fewer one-touch flashes still flashes 5 times. If the change has not started by then the request is dropped and the driver blinker is ignored until the lamp goes dark, so neither the last flashes nor our own hold can start it later. A new press restarts the count; a latched lever keeps waiting with no timeout. Both rules live in desire_lib/lever.py; DesireHelper only calls them. Settings: Driving > Steering > Lane change. Docs ko/en lane-change.md. Tests: desire stalk/lever 19 passed (6 new wait tests incl. a LaneChangeDelay longer than the wait, 2 new speed tests), hyundai 176 passed (blink hold before the change), settings schema 46 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 8 ++ docs/user/en/settings.md | 2 +- docs/user/ko/lane-change.md | 8 ++ docs/user/ko/settings.md | 2 +- .../opendbc/car/hyundai/hyundaicanfd.py | 5 +- .../car/hyundai/tests/test_blink_hold.py | 9 ++ openpilot/cereal/log.capnp | 1 + openpilot/common/params_keys.h | 3 +- openpilot/selfdrive/carrot_settings.json | 34 +++++-- .../selfdrive/controls/lib/desire_helper.py | 33 ++++--- .../controls/lib/desire_lib/lever.py | 74 ++++++++++++++ .../tests/test_desire_helper_stalk.py | 99 +++++++++++++++++-- openpilot/selfdrive/modeld/modeld.py | 1 + 13 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 openpilot/selfdrive/controls/lib/desire_lib/lever.py diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index ed2b0fce958..47da64683b5 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -96,6 +96,14 @@ OpenCV is included with the update and installed automatically during normal dev The camera or vehicle can misclassify markings, and value `2` does not make crossing a solid line safe or legal. The driver must verify road markings and applicable traffic rules. +### Turn-signal lever (Hyundai CAN-FD) + +Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. + +- A press in the last phase of a lane change (the second half of the green lane display) asks for the next change. When the change ends, the next one waits without the steering-torque requirement and still passes the existing lane-line and BSD checks. Presses while crossing are ignored. +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. +- `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. + ## What these settings do not change - `LaneChangeDelay` delays **entry**; it does not make an active lane change slower. diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index e223f8c6094..08954de320d 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -154,7 +154,7 @@ Volkswagen's separate `SET` button sets current speed and `RES` restores the pre | ONNX Lane and BSD | `ShareData`, `OnnxLaneThreshold`, `OnnxLaneIntervalMs`, `OnnxBsdThreshold`, `OnnxBsdSmoothingMs`, `OnnxBsdIntervalMs` | On-device lane-type and gated camera-BSD detection and tuning | | Centering | `PathOffset`, `CameraYawTrimDeg` | Path position and camera-yaw trim | | Steering feel | `SteerActuatorDelay`, `LatSmoothSec`, `LatSuspendAngleDeg`, `CustomSR`, `SteerRatioRate` | Timing, smoothing, suspension angle, and steering ratio | -| [Lane change](lane-change.md) and automatic turn | `LaneChangeNeedTorque`, `LaneChangeDelay`, `LaneChangeBsd`, `LaneLineCheck`, `AutoTurnControl`, `AutoTurnControlSpeedTurn`, `AutoTurnControlTurnEnd`, `AutoTurnMapChange` | Lane-change entry conditions and ATC behavior | +| [Lane change](lane-change.md) and automatic turn | `LaneChangeNeedTorque`, `LaneChangeDelay`, `LaneChangeBsd`, `LaneLineCheck`, `BlinkerLatchedTurn`, `LaneChangeLeverWait`, `AutoTurnControl`, `AutoTurnControlSpeedTurn`, `AutoTurnControlTurnEnd`, `AutoTurnMapChange` | Lane-change entry conditions and ATC behavior | | Lane mode | `LatMpcPathCost`, `LatMpcMotionCost`, `LatMpcAccelCost`, `LatMpcJerkCost`, `LatMpcSteeringRateCost`, `LatMpcInputOffset`, `UseLaneLineSpeed`, `UseLaneLineCurveSpeed`, `AdjustLaneOffset` | Lane-mode MPC weights and lane-line conditions | | Advanced torque | `LateralTorqueCustom`, `LateralTorqueAccelFactor`, `LateralTorqueFriction`, `LateralTorqueKpV`, `LateralTorqueKiV`, `LateralTorqueKf`, `LateralTorqueKd` | Custom torque-control gains | | Steering limits | `CustomSteerMax`, `CustomSteerDeltaUp`, `CustomSteerDeltaDown`, `CustomSteerDeltaUpLC`, `CustomSteerDeltaDownLC` | Maximum torque and torque-rate limits | diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index c73e95901dc..edf38b4bead 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -96,6 +96,14 @@ mici의 VISION에는 좌우 차선 종류와 최근 처리 시간이 나옵니 카메라나 차량이 차선을 잘못 분류할 수 있고, 설정 `2`는 실선 통과를 안전하거나 합법적으로 만들어 주지 않습니다. 도로 표시와 교통 법규를 운전자가 직접 확인해야 합니다. +### 깜빡이 레버 (현대 CAN-FD) + +현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. + +- 차로 변경이 끝나 가는 단계(초록 차선 표시의 후반)에서 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 끝나면 조향토크 없이 다음 차로 변경을 준비하며, 차선·BSD 등 기존 판단은 그대로 거칩니다. 차선을 넘는 중에 누른 것은 무시합니다. +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. +- `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. + ## 바뀌지 않는 것과 혼동하기 쉬운 설정 - `LaneChangeDelay`는 **진입 시점**만 늦춥니다. 진행 중인 차로 변경을 천천히 만들지 않습니다. diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index a118919e9f2..346f4f5bea9 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -154,7 +154,7 @@ Carrot Web 설정 화면에서는 다음 기능을 사용할 수 있습니다. | ONNX 차선·BSD | `ShareData`, `OnnxLaneThreshold`, `OnnxLaneIntervalMs`, `OnnxBsdThreshold`, `OnnxBsdSmoothingMs`, `OnnxBsdIntervalMs` | 장치의 차선 종류와 조건부 카메라 BSD 인식·세부 조정 | | 중앙 보정 | `PathOffset`, `CameraYawTrimDeg` | 레인모드 경로의 좌우 위치와 카메라 YAW 미세 보정 | | 조향감 | `SteerActuatorDelay`, `LatSmoothSec`, `LatSuspendAngleDeg`, `CustomSR`, `SteerRatioRate` | 조향 시점, 평활화, 일시중지 각도와 조향비 | -| [차로 변경](lane-change.md)·자동 턴 | `LaneChangeNeedTorque`, `LaneChangeDelay`, `LaneChangeBsd`, `LaneLineCheck`, `AutoTurnControl`, `AutoTurnControlSpeedTurn`, `AutoTurnControlTurnEnd`, `AutoTurnMapChange` | 차로 변경 진입 조건과 ATC 동작 | +| [차로 변경](lane-change.md)·자동 턴 | `LaneChangeNeedTorque`, `LaneChangeDelay`, `LaneChangeBsd`, `LaneLineCheck`, `BlinkerLatchedTurn`, `LaneChangeLeverWait`, `AutoTurnControl`, `AutoTurnControlSpeedTurn`, `AutoTurnControlTurnEnd`, `AutoTurnMapChange` | 차로 변경 진입 조건과 ATC 동작 | | 레인모드 | `LatMpcPathCost`, `LatMpcMotionCost`, `LatMpcAccelCost`, `LatMpcJerkCost`, `LatMpcSteeringRateCost`, `LatMpcInputOffset`, `UseLaneLineSpeed`, `UseLaneLineCurveSpeed`, `AdjustLaneOffset` | 레인모드 MPC 가중치와 차선 사용 조건 | | 고급 토크·토크 계수 | `LateralTorqueCustom`, `LateralTorqueAccelFactor`, `LateralTorqueFriction`, `LateralTorqueKpV`, `LateralTorqueKiV`, `LateralTorqueKf`, `LateralTorqueKd` | 커스텀 토크 제어 계수 | | 고급 토크·조향 제한 | `CustomSteerMax`, `CustomSteerDeltaUp`, `CustomSteerDeltaDown`, `CustomSteerDeltaUpLC`, `CustomSteerDeltaDownLC` | 최대 조향 토크와 토크 변화율 제한 | diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index 233adbc8ad2..36687725323 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -779,7 +779,10 @@ def _blink_hold_side(md, desire, state): """ if desire not in (3, 4): state.update(crossed=False, released=False, y_min=None) - return 0 + # before the change: DesireHelper may hold the lamp while a lever-requested change waits (LaneChangeLeverWait) + meta = getattr(md, "meta", None) if md is not None else None + pre_hold = str(getattr(meta, "laneChangeBlinkerHold", "none")) + return 3 if pre_hold == "left" else 4 if pre_hold == "right" else 0 if state.get("released"): return 0 lines = getattr(md, "laneLines", None) if md is not None else None diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py index 4767a984450..001b82f29ac 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -43,3 +43,12 @@ def test_blink_hold_without_a_visible_crossing_lasts_to_the_end_of_the_desire(): for y_left, y_right in [(-2.9, 1.5), (-2.4, 1.1), (-2.2, 0.8), (-1.5, 1.3), (-1.2, 1.9), (-1.7, 1.8)]: assert _blink_hold_side(_md(y_left, y_right), 3, state) == 3 assert _blink_hold_side(_md(-1.7, 1.8), 0, state) == 0 + + +def test_blink_hold_before_the_change_follows_the_lever_wait(): + # LaneChangeLeverWait: DesireHelper asks for the lamp while a lever-requested change waits (desire not 3/4 yet). + state = {} + assert _blink_hold_side(NS(laneLines=[], meta=NS(laneChangeBlinkerHold="left")), 0, state) == 3 + assert _blink_hold_side(NS(laneLines=[], meta=NS(laneChangeBlinkerHold="right")), 0, state) == 4 + assert _blink_hold_side(NS(laneLines=[], meta=NS(laneChangeBlinkerHold="none")), 0, state) == 0 + assert _blink_hold_side(NS(laneLines=[]), 0, state) == 0 # older schema without the field diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 41cb107543f..e3b0e8c5590 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -1097,6 +1097,7 @@ struct ModelDataV2 { modelTurnSpeed @17 :Float32; laneChangeAvailableLeft @18 :Bool; laneChangeAvailableRight @19 :Bool; + laneChangeBlinkerHold @20 :LaneChangeDirection; # carrot: hold this turn signal while a lever-requested change waits deprecated :group { brakeDisengageProb @2 :Float32; diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 94a6534e1b6..ca18468bcf0 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -355,7 +355,8 @@ inline static std::unordered_map keys = { {"LaneChangeDelay", {PERSISTENT, INT, "0"}}, {"LaneChangeBsd", {PERSISTENT, INT, "0"}}, {"LaneLineCheck", {PERSISTENT, INT, "0"}}, - {"BlinkerLatchedTurn", {PERSISTENT, BOOL, "0"}}, + {"BlinkerLatchedTurn", {PERSISTENT, INT, "0"}}, + {"LaneChangeLeverWait", {PERSISTENT, BOOL, "0"}}, {"MaxAngleFrames", {PERSISTENT, INT, "89"}}, {"LatMpcPathCost", {PERSISTENT, INT, "200"}}, diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 2fef9b0a57b..a4e4225ae8b 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -149,6 +149,7 @@ "LaneChangeBsd", "LaneLineCheck", "BlinkerLatchedTurn", + "LaneChangeLeverWait", "AutoTurnControl", "AutoTurnControlSpeedTurn", "AutoTurnControlTurnEnd", @@ -2470,20 +2471,37 @@ { "group": "조향일반", "name": "BlinkerLatchedTurn", - "title": "깜빡이 레버로 회전/차선변경 구분", - "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 50km/h 이하에서 레버를 끝까지 걸면(고정식) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정합니다. 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정식도 걸리기 전 약 0.1초 그 위치를 지나감). 50km/h 초과에서는 기존 자동 판단을 씁니다.", + "title": "깜빡이 레버로 회전/차선변경 구분 (km/h, 0=끔)", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다.", "egroup": "LAT", - "etitle": "Turn-signal lever selects turn / lane change", - "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). At or below 50 km/h a latched lever means a left/right turn and a one-touch (half) press means a lane change. No lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above 50 km/h the existing automatic guess is used.", + "etitle": "Turn-signal lever selects turn / lane change (km/h, 0=off)", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change.", "cgroup": "转向", - "ctitle": "转向灯拨杆区分转弯/变道", - "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。50km/h 以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道。拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过 50km/h 时使用原有自动判断。", + "ctitle": "转向灯拨杆区分转弯/变道 (km/h, 0=关)", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。", + "min": 0, + "max": 80, + "default": 0, + "unit": 5, + "display_unit": "speedKph", + "risk": "high" + }, + { + "group": "조향일반", + "name": "LaneChangeLeverWait", + "title": "원터치 차선변경 요청 5회 대기", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 레버를 원터치로 눌렀는데 차선변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단), 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 차선변경하고, 안 풀리면 요청을 포기하며 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다.", + "egroup": "LAT", + "etitle": "One-touch lane change request waits 5 flashes", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). When a one-touch lever press is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the lane change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before.", + "cgroup": "转向", + "ctitle": "单触变道请求等待 5 次闪烁", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。单触拨杆后若变道未立即获批(BSD、侧方车辆、车道线等原有判断),从拨杆起保持转向灯并等待 5 次闪烁(约 4 秒)。期间解除则变道,否则放弃请求,剩余闪烁不会再触发。锁定拨杆时照旧持续等待。", "min": 0, "max": 1, "default": 0, "unit": 1, - "control": "toggle", - "risk": "high" + "control": "toggle" }, { "group": "조향일반", diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index 954b593c1fc..c9cd76ff293 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -1,4 +1,5 @@ from openpilot.cereal import log +from openpilot.common.constants import CV from openpilot.common.realtime import DT_MDL from openpilot.common.params import Params from openpilot.selfdrive.carrot.bluetooth.model import CommandReader @@ -11,12 +12,11 @@ ) from openpilot.selfdrive.controls.lib.desire_lib.side_state import SideState from openpilot.selfdrive.controls.lib.desire_lib.maneuver_classifier import classify_maneuver_type +from openpilot.selfdrive.controls.lib.desire_lib.lever import LeverRequestWait, lever_maneuver # A lever press this early in laneChangeFinishing is still the driver re-lighting the lamp of the change # that just crossed, not a request for the next one. STALK_QUEUE_GUARD_S = 0.3 -# BlinkerLatchedTurn: up to this speed the lever decides the manoeuvre (latched = turn, one-touch = lane change). -LEVER_TURN_SPEED_MAX = 50 / 3.6 class DesireHelper: @@ -61,6 +61,8 @@ def __init__(self): self.stalk_counts = None self.queued_lane_change = BLINKER_NONE self.finishing_timer = 0.0 + self.lever_wait = LeverRequestWait() + self.blinker_hold_direction = LaneChangeDirection.none # modelV2.meta.laneChangeBlinkerHold # keep pulse self.keep_pulse_timer = 0.0 @@ -70,7 +72,8 @@ def __init__(self): self.laneChangeBsd = 0 self.laneLineCheck = 0 self.laneChangeDelay = 0.0 - self.blinkerLatchedTurn = False + self.blinkerLatchedTurn = 0.0 # m/s, 0 = off + self.laneChangeLeverWait = False # misc self.prev_desire_enabled = False @@ -89,7 +92,8 @@ def _update_params_periodic(self): self.laneChangeBsd = self.params.get_int("LaneChangeBsd") self.laneLineCheck = self.params.get_int("LaneLineCheck") self.laneChangeDelay = self.params.get_float("LaneChangeDelay") * 0.1 - self.blinkerLatchedTurn = self.params.get_bool("BlinkerLatchedTurn") + self.blinkerLatchedTurn = self.params.get_int("BlinkerLatchedTurn") * CV.KPH_TO_MS + self.laneChangeLeverWait = self.params.get_bool("LaneChangeLeverWait") def _check_desire_state(self, modeldata, carstate, maneuver_type): desire_state = modeldata.meta.desireState @@ -274,6 +278,10 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa # blinkers driver_st, driver_changed, driver_enabled = self._update_driver_blinker(carstate) stalk_press = self._update_stalk(carstate) + lever = getattr(carstate, "blinkerLever", 0) + if self.lever_wait.update(self.laneChangeLeverWait, stalk_press, lever, driver_st, self.lane_change_state, + self.laneChangeDelay): + driver_enabled = driver_changed = False # one-touch request timed out: ignore the lamp until it goes dark remote = self.bluetooth_commands.read(allowed=(lateral_active and carstate.canValid and not below_lane_change_speed and not trailer_maneuver_blocked)) atc_st, atc_enabled = self._update_atc_blinker(carrotMan, driver_st, remote) @@ -354,13 +362,12 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa else: new_type = "none" - # BlinkerLatchedTurn: the lever replaces the speed/lane-line guess. A latch passes through the one-touch - # detent (~0.1 s), so a lane change must not start while the lever is still there (lever_undecided). - lever = getattr(carstate, "blinkerLever", 0) - lever_mode = self.blinkerLatchedTurn and driver_enabled and side is not None and v_ego <= LEVER_TURN_SPEED_MAX - lever_undecided = lever_mode and lever == 1 - if lever_mode: - new_type = "turn" if lever == 2 else "lane_change" + # BlinkerLatchedTurn: the driver's lever replaces the speed/lane-line guess (see lever_maneuver). + lever_type, lever_undecided = (None, False) + if driver_enabled and side is not None: + lever_type, lever_undecided = lever_maneuver(lever, v_ego, self.blinkerLatchedTurn) + if lever_type is not None: + new_type = lever_type if trailer_maneuver_blocked and new_type in ("lane_change", "turn"): new_type = "none" @@ -517,6 +524,10 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.lane_change_state = LaneChangeState.off self.blinker_ignore = True + hold = self.lever_wait.update_hold(self.lane_change_state, blinker_state) + self.blinker_hold_direction = {BLINKER_LEFT: LaneChangeDirection.left, + BLINKER_RIGHT: LaneChangeDirection.right}.get(hold, LaneChangeDirection.none) + # final desire if self.turn_direction != TurnDirection.none: self.desire = TURN_DESIRES[self.turn_direction] diff --git a/openpilot/selfdrive/controls/lib/desire_lib/lever.py b/openpilot/selfdrive/controls/lib/desire_lib/lever.py new file mode 100644 index 00000000000..e06bf7ca7ed --- /dev/null +++ b/openpilot/selfdrive/controls/lib/desire_lib/lever.py @@ -0,0 +1,74 @@ +"""Turn-signal lever rules for DesireHelper (Hyundai CAN-FD: carState.blinkerLever, *BlinkerStalkCount). + +Both rules are opt-in and live here so the lane-change FSM only asks two questions: +- lever_maneuver(): BlinkerLatchedTurn - the lever decides turn / lane change. +- LeverRequestWait: LaneChangeLeverWait - a one-touch press asks for one lane change for a fixed number of flashes. +""" +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.controls.lib.desire_lib.constants import BLINKER_NONE, LaneChangeState + +LEVER_RELEASED, LEVER_ONE_TOUCH, LEVER_LATCHED = 0, 1, 2 + +TURN_SIGNAL_FLASH_S = 0.8 # one lamp on/off cycle (2026-10-04 lever test, BLINKERS LAMP_ALT edges 0.79-0.81 s) +LEVER_REQUEST_FLASHES = 5 +LEVER_REQUEST_WAIT_S = LEVER_REQUEST_FLASHES * TURN_SIGNAL_FLASH_S + + +def lever_maneuver(lever: int, v_ego: float, turn_speed_max: float): + """(maneuver, undecided) chosen by the driver's lever, or (None, False) when the lever does not decide. + + turn_speed_max (m/s, 0 = off). At or below it a latched lever is a turn and anything else a lane change; a lever + still in the one-touch detent is undecided, because a latch passes through it for ~0.1 s. Above it every lever + input is a lane change. + """ + if turn_speed_max <= 0: + return None, False + if v_ego > turn_speed_max: + return "lane_change", False + if lever == LEVER_LATCHED: + return "turn", False + return "lane_change", lever == LEVER_ONE_TOUCH + + +class LeverRequestWait: + """A one-touch lever press asks for one lane change for LEVER_REQUEST_FLASHES flashes from the press. + + While that change waits in preLaneChange (blocked by whatever the FSM checks: BSD, side objects, lines) the lamp + is held on the requested side, so a car set to fewer one-touch flashes still flashes that long. If the change has + not started LEVER_REQUEST_WAIT_S (plus LaneChangeDelay, which the FSM waits out first) after the press, the + request is dropped and the driver blinker is ignored until + the lamp goes dark, so neither the lamp's last flashes nor a later release can start it. A latched lever is the + driver holding the request himself: no timeout. + """ + + def __init__(self): + self.side = BLINKER_NONE + self.timer = 0.0 + self.gave_up = False + self.hold_side = BLINKER_NONE + + def update(self, enabled: bool, stalk_press: int, lever: int, lamp_side: int, lane_change_state, + start_delay: float = 0.0) -> bool: + """Before the FSM (lane_change_state is last frame's). True while the driver blinker must be ignored.""" + if not enabled: + self.__init__() + return False + if stalk_press != BLINKER_NONE: + self.side, self.timer, self.gave_up = stalk_press, 0.0, False + elif self.side != BLINKER_NONE: + self.timer += DT_MDL + + if lever == LEVER_LATCHED or lane_change_state == LaneChangeState.laneChangeStarting: + self.side = BLINKER_NONE + elif self.side != BLINKER_NONE and self.timer >= LEVER_REQUEST_WAIT_S + start_delay: + self.side, self.gave_up = BLINKER_NONE, True + + if self.gave_up and lamp_side == BLINKER_NONE: + self.gave_up = False + return self.gave_up + + def update_hold(self, lane_change_state, blinker_state: int) -> int: + """After the FSM: the side whose lamp to hold (BLINKER_NONE when not waiting for the requested change).""" + waiting = lane_change_state == LaneChangeState.preLaneChange and blinker_state == self.side + self.hold_side = self.side if waiting else BLINKER_NONE + return self.hold_side diff --git a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py index 56d248b1445..e8bedf6b2c4 100644 --- a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py +++ b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py @@ -3,9 +3,11 @@ from openpilot.cereal import log from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, STALK_QUEUE_GUARD_S +from openpilot.selfdrive.controls.lib.desire_lib.lever import LEVER_REQUEST_WAIT_S from openpilot.selfdrive.controls.lib.desire_lib.constants import TurnDirection LCS = log.LaneChangeState +LCD = log.LaneChangeDirection class TestDesireHelperStalkRequest: @@ -115,7 +117,8 @@ def test_cars_without_lever_counters_keep_the_lamp_behaviour(self): class TestBlinkerLatchedTurn: - """BlinkerLatchedTurn: at or below 50 km/h a latched lever is a turn, a one-touch press a lane change.""" + """BlinkerLatchedTurn (here 50 km/h): at or below it a latched lever is a turn, a one-touch press a lane change; + above it every lever input is a lane change.""" setup_method = TestDesireHelperStalkRequest.setup_method update = TestDesireHelperStalkRequest.update @@ -130,27 +133,109 @@ def test_off_keeps_the_classifier(self): assert self.helper.maneuver_type == "lane_change" # 45 km/h, lanes visible: the classifier says lane change def test_latched_lever_is_a_turn(self): - self.helper.blinkerLatchedTurn = True + self.helper.blinkerLatchedTurn = 50 / 3.6 self.lever_on(2, steps=3) assert self.helper.maneuver_type == "turn" assert self.helper.lane_change_state == LCS.off assert self.helper.turn_direction == TurnDirection.turnLeft def test_latch_passing_the_one_touch_detent_does_not_start_a_lane_change(self): - self.helper.blinkerLatchedTurn = True + self.helper.blinkerLatchedTurn = 50 / 3.6 assert self.lever_on(1, steps=3) == LCS.preLaneChange # still in the detent: wait self.update(lever=2, v_kph=45.0) assert self.helper.maneuver_type == "turn" assert self.helper.lane_change_state == LCS.off def test_one_touch_is_a_lane_change_after_the_lever_returns(self): - self.helper.blinkerLatchedTurn = True + self.helper.blinkerLatchedTurn = 50 / 3.6 assert self.lever_on(1, steps=2) == LCS.preLaneChange assert self.update(lever=0, v_kph=45.0) == LCS.laneChangeStarting assert self.helper.maneuver_type == "lane_change" - def test_latched_lever_above_50_kph_is_the_classifier(self): - self.helper.blinkerLatchedTurn = True - self.lever_on(2, v_kph=60.0, steps=3) + def test_latched_lever_above_the_set_speed_is_a_lane_change(self): + # At 80 km/h the classifier calls a navigation turn with a far road edge a turn; the lever setting overrides it. + self.carrot_man.atcType = "turn left" + self.helper.left.dist_to_edge_far = 5.0 + self.lever_on(2, v_kph=80.0, steps=3) + assert self.helper.maneuver_type == "turn" # off: the classifier's guess + self.setup_method() + self.carrot_man.atcType = "turn left" + self.helper.left.dist_to_edge_far = 5.0 + self.helper.blinkerLatchedTurn = 50 / 3.6 + self.lever_on(2, v_kph=80.0, steps=3) assert self.helper.maneuver_type == "lane_change" assert self.helper.lane_change_state == LCS.laneChangeStarting + + def test_latched_lever_at_the_set_speed_is_a_turn(self): + self.helper.blinkerLatchedTurn = 50 / 3.6 + self.lever_on(2, v_kph=50.0, steps=3) + assert self.helper.maneuver_type == "turn" + + +class TestLaneChangeLeverWait: + """LaneChangeLeverWait: a blocked one-touch request holds the lamp for 5 flashes from the press, then gives up.""" + update = TestDesireHelperStalkRequest.update + + def setup_method(self): + TestDesireHelperStalkRequest.setup_method(self) + self.helper.laneChangeLeverWait = True + self.block(True) + + def block(self, blocked): + side = self.helper.left # BSD on the left: the FSM waits for torque + side.bsd_hold_counter = 40 if blocked else 0 + side.lane_change_available = not blocked + + def press(self, lever=0): + self.update(left_blinker=False) + assert self.update(press=True, lever=lever) == LCS.preLaneChange + + def wait(self, seconds, lever=0): + for _ in range(round(seconds / DT_MDL)): + self.update(lever=lever) + return self.helper.lane_change_state + + def test_blocked_request_holds_the_lamp_then_gives_up(self): + self.press() + assert self.helper.blinker_hold_direction == LCD.left + assert self.wait(LEVER_REQUEST_WAIT_S - 0.2) == LCS.preLaneChange + assert self.helper.blinker_hold_direction == LCD.left + assert self.wait(0.3) == LCS.off # 5 flashes after the press: dropped + assert self.helper.blinker_hold_direction == LCD.none + self.block(False) + assert self.wait(1.0) == LCS.off # lamp still flashing: no restart from it + self.update(left_blinker=False) # lamp dark: the next input is a new request + assert self.update() == LCS.preLaneChange + + def test_clearing_within_the_wait_starts_the_change(self): + self.press() + self.wait(2.0) + self.block(False) + assert self.update() == LCS.laneChangeStarting + self.update() + assert self.helper.blinker_hold_direction == LCD.none # the change itself holds the lamp from here + + def test_a_new_press_restarts_the_count(self): + self.press() + self.wait(3.0) + self.update(press=True) + assert self.wait(LEVER_REQUEST_WAIT_S - 0.5) == LCS.preLaneChange + assert self.wait(0.6) == LCS.off + + def test_latched_lever_keeps_waiting(self): + self.press(lever=2) + assert self.wait(LEVER_REQUEST_WAIT_S + 1.0, lever=2) == LCS.preLaneChange + assert self.helper.blinker_hold_direction == LCD.none + + def test_lane_change_delay_extends_the_wait(self): + self.helper.laneChangeDelay = 5.0 # longer than the 5 flashes: must not give up first + self.block(False) + self.press() + assert self.wait(LEVER_REQUEST_WAIT_S + 0.5) == LCS.preLaneChange + assert self.wait(1.0) == LCS.laneChangeStarting + + def test_off_keeps_waiting_on_the_lamp(self): + self.helper.laneChangeLeverWait = False + self.press() + assert self.wait(LEVER_REQUEST_WAIT_S + 1.0) == LCS.preLaneChange + assert self.helper.blinker_hold_direction == LCD.none diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 3f2badc14d4..da067200117 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -509,6 +509,7 @@ def load_usbgpu_model(): modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction modelv2_send.modelV2.meta.desireLog = DH.desireLog #carrot + modelv2_send.modelV2.meta.laneChangeBlinkerHold = DH.blinker_hold_direction drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction From 05a2653097b8822cd5ee81be808ef58340a063db Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:56:51 +0900 Subject: [PATCH 21/50] desire: a press after the model sees the line crossed asks for the next lane On the 2026-10-05 drive (route 00000492) all four next-lane presses were ignored: each came 0.1-1.1 s after the car centre crossed the line (model lane lines relabel at +3.5-3.8 s), but laneChangeStarting lasts ~5 s because the model keeps the lane-change probability up ~1.5 s past the crossing, and presses in laneChangeStarting were all treated as re-lighting the lamp of the current change. - LaneCrossing (desire_lib/lever.py): the change-side line came within 1 m of the centre and then jumped 1.5 m further out - the same rule as the turn-signal release. A press after it, in laneChangeStarting or laneChangeFinishing, queues the next change; a press before it is still ignored, so it never crosses two lanes at once. Without lane lines the old finishing-phase rule (0.3 s into finishing) remains. - The lamp of a press made mid-change stopped ~2.5 s after it on that drive, i.e. about when the next change became due. A queued request now holds the lamp (modelV2.meta.laneChangeBlinkerHold, which hyundaicanfd now prefers over the current change's 60 % release) until the queued change starts; blocked in preLaneChange it is dropped after the same 5 flashes as a one-touch press, or when the lamp side changes. Open-loop replay of route 00000492 with this code: 4/4 next-lane presses queued (was 0/4), first-change presses unchanged. Tests: desire 24 passed (crossing before/after, late press, lamp held until the queued start, dropped after 5 flashes), hyundai 177 passed (request overrides the release), settings schema 46 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- .../opendbc/car/hyundai/hyundaicanfd.py | 18 +++-- .../car/hyundai/tests/test_blink_hold.py | 9 +++ openpilot/cereal/log.capnp | 2 +- .../selfdrive/controls/lib/desire_helper.py | 39 +++++++++-- .../controls/lib/desire_lib/lever.py | 29 ++++++++ .../tests/test_desire_helper_stalk.py | 67 ++++++++++++++++++- 8 files changed, 152 insertions(+), 16 deletions(-) diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index 47da64683b5..821e1286838 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -100,7 +100,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. -- A press in the last phase of a lane change (the second half of the green lane display) asks for the next change. When the change ends, the next one waits without the steering-torque requirement and still passes the existing lane-line and BSD checks. Presses while crossing are ignored. +- A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). - `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index edf38b4bead..7e6bc6f815b 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -100,7 +100,7 @@ mici의 VISION에는 좌우 차선 종류와 최근 처리 시간이 나옵니 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. -- 차로 변경이 끝나 가는 단계(초록 차선 표시의 후반)에서 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 끝나면 조향토크 없이 다음 차로 변경을 준비하며, 차선·BSD 등 기존 판단은 그대로 거칩니다. 차선을 넘는 중에 누른 것은 무시합니다. +- 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. - `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index 36687725323..22203655abc 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -770,6 +770,19 @@ def _get_desire_and_lane_changing(md): def _blink_hold_side(md, desire, state): """Turn-signal hold for ADRV_0x1ea: 3 (left), 4 (right) or 0. + DesireHelper's request (modelV2.meta.laneChangeBlinkerHold) wins: it holds the lamp while a lever-requested + change waits (LaneChangeLeverWait) and while a next change asked for during this one is queued. Otherwise the + current change decides (_blink_hold_change). + """ + change_hold = _blink_hold_change(md, desire, state) + meta = getattr(md, "meta", None) if md is not None else None + requested = str(getattr(meta, "laneChangeBlinkerHold", "none")) + return 3 if requested == "left" else 4 if requested == "right" else change_hold + + +def _blink_hold_change(md, desire, state): + """Hold for the current lane change: 3 (left), 4 (right) or 0. + Held for the whole confirmed lane change (desire 3/4 = starting + finishing, same as the green cluster lane) instead of the model's desireState > 0.9, which falls ~2 s before the change ends and let the lamp stop mid-change (2026-09-19 drive: hold covered 49-65 % of the change). Released once the @@ -779,10 +792,7 @@ def _blink_hold_side(md, desire, state): """ if desire not in (3, 4): state.update(crossed=False, released=False, y_min=None) - # before the change: DesireHelper may hold the lamp while a lever-requested change waits (LaneChangeLeverWait) - meta = getattr(md, "meta", None) if md is not None else None - pre_hold = str(getattr(meta, "laneChangeBlinkerHold", "none")) - return 3 if pre_hold == "left" else 4 if pre_hold == "right" else 0 + return 0 if state.get("released"): return 0 lines = getattr(md, "laneLines", None) if md is not None else None diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py index 001b82f29ac..230b370635a 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -52,3 +52,12 @@ def test_blink_hold_before_the_change_follows_the_lever_wait(): assert _blink_hold_side(NS(laneLines=[], meta=NS(laneChangeBlinkerHold="right")), 0, state) == 4 assert _blink_hold_side(NS(laneLines=[], meta=NS(laneChangeBlinkerHold="none")), 0, state) == 0 assert _blink_hold_side(NS(laneLines=[]), 0, state) == 0 # older schema without the field + + +def test_blink_hold_request_overrides_the_release_of_the_current_change(): + # a next change queued while this one crosses keeps the lamp lit past the 60 % release + state = {} + frames = [(-1.75, 1.75), (-0.6, 2.8), (-0.28, 3.04), (-3.46, 0.12), (-2.62, 0.76)] + assert [_blink_hold_side(_md(*f), 3, state) for f in frames][-1] == 0 # released at 60 % + md = NS(laneLines=_md(-2.4, 1.0).laneLines, meta=NS(laneChangeBlinkerHold="left")) + assert _blink_hold_side(md, 3, state) == 3 diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index e3b0e8c5590..16c503532bb 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -1097,7 +1097,7 @@ struct ModelDataV2 { modelTurnSpeed @17 :Float32; laneChangeAvailableLeft @18 :Bool; laneChangeAvailableRight @19 :Bool; - laneChangeBlinkerHold @20 :LaneChangeDirection; # carrot: hold this turn signal while a lever-requested change waits + laneChangeBlinkerHold @20 :LaneChangeDirection; # carrot: hold this turn signal while a lever-requested change waits or is queued deprecated :group { brakeDisengageProb @2 :Float32; diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index c9cd76ff293..2f99e128417 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -12,10 +12,10 @@ ) from openpilot.selfdrive.controls.lib.desire_lib.side_state import SideState from openpilot.selfdrive.controls.lib.desire_lib.maneuver_classifier import classify_maneuver_type -from openpilot.selfdrive.controls.lib.desire_lib.lever import LeverRequestWait, lever_maneuver +from openpilot.selfdrive.controls.lib.desire_lib.lever import LEVER_REQUEST_WAIT_S, LaneCrossing, LeverRequestWait, lever_maneuver -# A lever press this early in laneChangeFinishing is still the driver re-lighting the lamp of the change -# that just crossed, not a request for the next one. +# Without a model-detected crossing, a lever press this early in laneChangeFinishing is still the driver +# re-lighting the lamp of the change that just crossed, not a request for the next one. STALK_QUEUE_GUARD_S = 0.3 @@ -61,7 +61,9 @@ def __init__(self): self.stalk_counts = None self.queued_lane_change = BLINKER_NONE self.finishing_timer = 0.0 + self.queued_wait = 0.0 self.lever_wait = LeverRequestWait() + self.crossing = LaneCrossing() self.blinker_hold_direction = LaneChangeDirection.none # modelV2.meta.laneChangeBlinkerHold # keep pulse @@ -261,6 +263,8 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa below_lane_change_speed = v_ego < LANE_CHANGE_SPEED_MIN trailer_maneuver_blocked = carstate.trailerConnected + prev_lane_change_state = self.lane_change_state + # per-side compute (좌/우 모두) self._process_sides(carstate, modeldata, radarState) if trailer_maneuver_blocked: @@ -479,16 +483,22 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.lane_change_state = LaneChangeState.laneChangeStarting elif self.lane_change_state == LaneChangeState.laneChangeStarting: - # Lever presses while crossing are ignored (re-lighting the lamp must not ask for a second lane). + # A press before the car centre crossed the line re-lights this change's lamp and is ignored (never a second + # lane at once); one after it asks for the next change. The model keeps the lane-change probability up + # ~1.5 s past the crossing, so waiting for laneChangeFinishing missed them (2026-10-05 drive: 4/4 next-lane + # presses came 0.1-1.1 s after the crossing, all still in laneChangeStarting). + since_crossing = self.crossing.update(modeldata, self.lane_change_direction == LaneChangeDirection.left) + if stalk_press != BLINKER_NONE and since_crossing >= 0.0: + self.queued_lane_change = stalk_press self.lane_change_ll_prob = max(self.lane_change_ll_prob - 2 * DT_MDL, 0.0) if lane_change_prob < 0.02 and self.lane_change_ll_prob < 0.01: self.lane_change_state = LaneChangeState.laneChangeFinishing self.finishing_timer = 0.0 - self.queued_lane_change = BLINKER_NONE elif self.lane_change_state == LaneChangeState.laneChangeFinishing: self.finishing_timer += DT_MDL - if stalk_press != BLINKER_NONE and self.finishing_timer >= STALK_QUEUE_GUARD_S: + crossed = self.crossing.since >= 0.0 # the crossing was seen during laneChangeStarting + if stalk_press != BLINKER_NONE and (crossed or self.finishing_timer >= STALK_QUEUE_GUARD_S): self.queued_lane_change = stalk_press self.lane_change_ll_prob = min(self.lane_change_ll_prob + DT_MDL, 1.0) if self.lane_change_ll_prob > 0.99: @@ -498,9 +508,9 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa # Holding the blinker through the change still needs torque for the next one; a lever press # made during finishing (same side as the lamp) is an explicit request and does not. self.next_lane_change = not (driver_enabled and self.queued_lane_change == blinker_state) + self.queued_wait = 0.0 else: self.lane_change_state = LaneChangeState.off - self.queued_lane_change = BLINKER_NONE # timer if self.lane_change_state in (LaneChangeState.off, LaneChangeState.preLaneChange): @@ -524,7 +534,22 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.lane_change_state = LaneChangeState.off self.blinker_ignore = True + if self.lane_change_state in (LaneChangeState.off, LaneChangeState.preLaneChange): + self.crossing = LaneCrossing() + # A queued request lives until the next change starts; waiting in preLaneChange (blocked) it gets the same + # 5 flashes as a one-touch press, and it ends if the lamp side changes or everything goes off. + if self.lane_change_state == LaneChangeState.preLaneChange and self.queued_lane_change != BLINKER_NONE: + self.queued_wait += DT_MDL + if self.lane_change_state == LaneChangeState.off or (self.lane_change_state == LaneChangeState.laneChangeStarting and + prev_lane_change_state == LaneChangeState.preLaneChange) or (self.lane_change_state == LaneChangeState.preLaneChange and + (self.queued_wait >= LEVER_REQUEST_WAIT_S or self.queued_lane_change != blinker_state)): + self.queued_lane_change = BLINKER_NONE + hold = self.lever_wait.update_hold(self.lane_change_state, blinker_state) + if self.queued_lane_change != BLINKER_NONE: + # The lamp of a press made during a change may stop before that change ends (2026-10-05: ~2.5 s after the + # press), which would drop the request just as it becomes due: keep it lit until the queued change starts. + hold = self.queued_lane_change self.blinker_hold_direction = {BLINKER_LEFT: LaneChangeDirection.left, BLINKER_RIGHT: LaneChangeDirection.right}.get(hold, LaneChangeDirection.none) diff --git a/openpilot/selfdrive/controls/lib/desire_lib/lever.py b/openpilot/selfdrive/controls/lib/desire_lib/lever.py index e06bf7ca7ed..a9bf47fbaac 100644 --- a/openpilot/selfdrive/controls/lib/desire_lib/lever.py +++ b/openpilot/selfdrive/controls/lib/desire_lib/lever.py @@ -3,6 +3,7 @@ Both rules are opt-in and live here so the lane-change FSM only asks two questions: - lever_maneuver(): BlinkerLatchedTurn - the lever decides turn / lane change. - LeverRequestWait: LaneChangeLeverWait - a one-touch press asks for one lane change for a fixed number of flashes. +- LaneCrossing: when the car centre crossed the line, so a press after it can ask for the next lane change. """ from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.controls.lib.desire_lib.constants import BLINKER_NONE, LaneChangeState @@ -72,3 +73,31 @@ def update_hold(self, lane_change_state, blinker_state: int) -> int: waiting = lane_change_state == LaneChangeState.preLaneChange and blinker_state == self.side self.hold_side = self.side if waiting else BLINKER_NONE return self.hold_side + + +class LaneCrossing: + """Seconds since the car centre crossed the line toward the change side (-1 before), from the model lane lines. + + The change-side line comes within NEAR_M of the centre, then the model relabels it and it jumps JUMP_M further + out; the relabel is spread over 2-3 frames, so compare with the closest approach rather than the previous frame. + Same rule as the turn-signal release in hyundaicanfd._blink_hold_side. Without lane lines it never fires. + """ + NEAR_M = 1.0 + JUMP_M = 1.5 + + def __init__(self): + self.y_min = None + self.since = -1.0 + + def update(self, model, left: bool) -> float: + if self.since >= 0.0: + self.since += DT_MDL + return self.since + lines = getattr(model, "laneLines", None) + if lines is None or len(lines) < 3 or not len(lines[1].y) or not len(lines[2].y): + return self.since + y = abs(float(lines[1 if left else 2].y[0])) + self.y_min = y if self.y_min is None else min(self.y_min, y) + if self.y_min < self.NEAR_M and y - self.y_min > self.JUMP_M: + self.since = 0.0 + return self.since diff --git a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py index e8bedf6b2c4..c32b41b9ae3 100644 --- a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py +++ b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py @@ -38,14 +38,15 @@ def setup_method(self): self.carrot_man = SimpleNamespace(atcType="", carrotCmdIndex=0, carrotCmd="", carrotArg="") self.left_count = 0 - def update(self, left_blinker=True, lane_change_prob=0.5, press=False, counters=True, lever=0, v_kph=90.0): + def update(self, left_blinker=True, lane_change_prob=0.5, press=False, counters=True, lever=0, v_kph=90.0, lines=None): if press: self.left_count = (self.left_count + 1) % 256 car = SimpleNamespace(canValid=True, leftBlinker=left_blinker, rightBlinker=False, vEgo=v_kph / 3.6, aEgo=0.0, trailerConnected=False, steeringTorque=0.0, steeringPressed=False, blinkerLever=lever) if counters: car.leftBlinkerStalkCount, car.rightBlinkerStalkCount = self.left_count, 0 - self.helper.update(car, SimpleNamespace(), True, lane_change_prob, self.carrot_man, SimpleNamespace()) + model = SimpleNamespace(laneLines=[SimpleNamespace(y=[y]) for y in (-5.0, *lines, 5.0)]) if lines else SimpleNamespace() + self.helper.update(car, model, True, lane_change_prob, self.carrot_man, SimpleNamespace()) return self.helper.lane_change_state def start_and_cross(self, counters=True): @@ -89,6 +90,68 @@ def test_press_during_finishing_queues_the_next_change_without_torque(self): assert not self.helper.next_lane_change assert self.update() == LCS.laneChangeStarting # one lane at a time: starts after the first ended + # left change, 2026-10-05 drive: the left line closes in, the model relabels it, then the car settles in the new lane + CROSSING = [(-1.3, 1.9), (-0.9, 2.2), (-0.5, 2.7), (-0.34, 2.84), (-0.41, 2.83), (-2.5, 0.47), (-2.94, 0.16)] + + def cross(self, press_after_s=None, press_before=False): + """Still in laneChangeStarting: the car centre crosses the left line; optionally press before or after it.""" + for i, lines in enumerate(self.CROSSING): + self.update(lines=lines, press=press_before and i == 1) + t = DT_MDL + for _ in range(int(1.2 / DT_MDL)): # the model keeps the change probability up after it + press = press_after_s is not None and abs(t - press_after_s) < DT_MDL / 2 + self.update(lines=(-2.6, 0.6), press=press) + t += DT_MDL + assert self.helper.lane_change_state == LCS.laneChangeStarting + + def test_press_after_the_crossing_queues_the_next_change(self): + self.start_and_cross() + self.cross(press_after_s=0.1) # 2026-10-05 #1: pressed 0.1 s after the relabel + assert self.helper.queued_lane_change == 1 + assert self.helper.blinker_hold_direction == LCD.left # keep the lamp lit until the queued change starts + assert self.finish() == LCS.preLaneChange + assert not self.helper.next_lane_change + assert self.update() == LCS.laneChangeStarting + + def test_queued_request_keeps_the_lamp_until_the_next_change_starts(self): + self.start_and_cross() + self.cross(press_after_s=0.5) + side = self.helper.left + side.bsd_hold_counter, side.lane_change_available = 40, False # BSD when the next change becomes due + assert self.finish() == LCS.preLaneChange + assert self.helper.blinker_hold_direction == LCD.left + for _ in range(int(2.0 / DT_MDL)): + self.update() + assert self.helper.blinker_hold_direction == LCD.left # still waiting, lamp held + side.bsd_hold_counter, side.lane_change_available = 0, True + assert self.update() == LCS.laneChangeStarting + assert self.helper.queued_lane_change == 0 # the queued change started + self.update() + assert self.helper.blinker_hold_direction == LCD.none + + def test_blocked_queued_request_is_dropped_after_five_flashes(self): + self.start_and_cross() + self.cross(press_after_s=0.5) + side = self.helper.left + side.bsd_hold_counter, side.lane_change_available = 40, False + self.finish() + for _ in range(int((LEVER_REQUEST_WAIT_S + 0.2) / DT_MDL)): + self.update() + assert self.helper.queued_lane_change == 0 + assert self.helper.blinker_hold_direction == LCD.none + + def test_press_before_the_crossing_is_ignored(self): + self.start_and_cross() + self.cross(press_before=True) + assert self.helper.queued_lane_change == 0 + assert self.finish() == LCS.preLaneChange + assert self.helper.next_lane_change + + def test_press_late_in_the_change_after_the_crossing_still_queues(self): + self.start_and_cross() + self.cross(press_after_s=1.1) + assert self.helper.queued_lane_change == 1 + def test_press_right_after_the_crossing_is_still_the_same_change(self): self.start_and_cross() assert self.finish(press_at_s=STALK_QUEUE_GUARD_S / 2) == LCS.preLaneChange From b0ddfaed9781c416fab5c9f9eff09b20b8bca54a Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:15:02 +0900 Subject: [PATCH 22/50] hyundai: cluster message while a latched lever turns the car With BlinkerLatchedTurn, a latched lever at or below the set speed makes a turn desire. Show that on the cluster so the driver knows the lever was taken as a turn: ADRV_0x1ea AUTOLANECHANGE_MSG = 1 ("Check surrounding conditions"; the cluster has no turn message) while the turn desire is on. DesireHelper sets lever_turn only when the lever decided the turn (not a classifier or navigation turn, not a lane change) and the turn desire is active; modeld publishes it as modelV2.meta.leverTurn (@21); hyundaicanfd writes the message after the lane graphics. No new setting: it follows BlinkerLatchedTurn. Tests: desire 25 passed (flag only for a lever-made turn, cleared with the turn), hyundai 178 passed (message only when flagged, older schema). Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- .../opendbc/car/hyundai/hyundaicanfd.py | 12 +++++++++++ .../car/hyundai/tests/test_blink_hold.py | 13 +++++++++++- openpilot/cereal/log.capnp | 1 + openpilot/selfdrive/carrot_settings.json | 6 +++--- .../selfdrive/controls/lib/desire_helper.py | 4 ++++ .../tests/test_desire_helper_stalk.py | 21 +++++++++++++++++++ openpilot/selfdrive/modeld/modeld.py | 1 + 9 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index 821e1286838..3df1e1e95f4 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -101,7 +101,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. - A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). -- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While a latched lever is turning the car, the cluster shows 'Check surrounding conditions' in the lane-change area (the cluster has no turn message). - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. ## What these settings do not change diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index 7e6bc6f815b..2f2f213565c 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -101,7 +101,7 @@ mici의 VISION에는 좌우 차선 종류와 최근 처리 시간이 나옵니 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. - 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. -- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 레버 고정으로 회전 중일 때는 계기판 차로 변경 칸에 '주변 상황을 확인하세요' 문구가 뜹니다(계기판에 회전 전용 문구가 없음). - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. ## 바뀌지 않는 것과 혼동하기 쉬운 설정 diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index 22203655abc..8d74541dd3a 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -870,6 +870,17 @@ def _apply_cluster_lane_lines(values, CS, lat_active, desire): _apply_lane_desire(values, desire) +# ADRV_0x1ea AUTOLANECHANGE_MSG 1 "Check surrounding conditions" (the cluster has no "turn" message) +LEVER_TURN_CLUSTER_MSG = 1 + + +def _apply_lever_turn_msg(values, md): + """BlinkerLatchedTurn: while a latched lever makes the turn desire, say so in the cluster's lane-change slot.""" + meta = getattr(md, "meta", None) if md is not None else None + if getattr(meta, "leverTurn", False): + values["AUTOLANECHANGE_MSG"] = LEVER_TURN_CLUSTER_MSG + + def _normalize_cluster_corner_objects(values, *, ccnc=False): # EV5 can report 0x162 corner geometry with DETECT=0 while 0x1ea marks it visible. # Use nonzero corner distance to show a gray car, including those hidden types. @@ -1130,6 +1141,7 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values['RIGHT_BLINK_HOLD'] = 1 if hold == 4 else 0 _apply_cluster_lane_lines(values, CS, lat_active, desire) + _apply_lever_turn_msg(values, md) _normalize_cluster_corner_objects(values) ret.append(packer.make_can_msg("ADRV_0x1ea", CAN.ECAN, values, rx_counter = rx_counter)) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py index 230b370635a..e2770d86680 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -1,6 +1,6 @@ from types import SimpleNamespace as NS -from opendbc.car.hyundai.hyundaicanfd import _blink_hold_side +from opendbc.car.hyundai.hyundaicanfd import _apply_lever_turn_msg, _blink_hold_side def _md(y_left, y_right): @@ -61,3 +61,14 @@ def test_blink_hold_request_overrides_the_release_of_the_current_change(): assert [_blink_hold_side(_md(*f), 3, state) for f in frames][-1] == 0 # released at 60 % md = NS(laneLines=_md(-2.4, 1.0).laneLines, meta=NS(laneChangeBlinkerHold="left")) assert _blink_hold_side(md, 3, state) == 3 + + +def test_lever_turn_shows_the_cluster_message_only_when_flagged(): + values = {"AUTOLANECHANGE_MSG": 0} + _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=False))) + assert values["AUTOLANECHANGE_MSG"] == 0 + _apply_lever_turn_msg(values, None) + _apply_lever_turn_msg(values, NS(meta=NS())) # older schema + assert values["AUTOLANECHANGE_MSG"] == 0 + _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=True))) + assert values["AUTOLANECHANGE_MSG"] == 1 diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 16c503532bb..e463f1eeddf 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -1098,6 +1098,7 @@ struct ModelDataV2 { laneChangeAvailableLeft @18 :Bool; laneChangeAvailableRight @19 :Bool; laneChangeBlinkerHold @20 :LaneChangeDirection; # carrot: hold this turn signal while a lever-requested change waits or is queued + leverTurn @21 :Bool; # carrot: the turn desire comes from a latched lever (BlinkerLatchedTurn) deprecated :group { brakeDisengageProb @2 :Float32; diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index a4e4225ae8b..ac81d859d53 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -2472,13 +2472,13 @@ "group": "조향일반", "name": "BlinkerLatchedTurn", "title": "깜빡이 레버로 회전/차선변경 구분 (km/h, 0=끔)", - "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다.", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다. 레버 고정으로 회전 중일 때는 계기판 차로 변경 칸에 '주변 상황을 확인하세요' 문구를 띄웁니다.", "egroup": "LAT", "etitle": "Turn-signal lever selects turn / lane change (km/h, 0=off)", - "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change.", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change. While a latched lever is turning the car, the cluster shows 'Check surrounding conditions' in the lane-change area.", "cgroup": "转向", "ctitle": "转向灯拨杆区分转弯/变道 (km/h, 0=关)", - "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。锁定拨杆转弯时,仪表盘变道区域显示“请确认周围情况”。", "min": 0, "max": 80, "default": 0, diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index 2f99e128417..ad602242dd0 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -65,6 +65,7 @@ def __init__(self): self.lever_wait = LeverRequestWait() self.crossing = LaneCrossing() self.blinker_hold_direction = LaneChangeDirection.none # modelV2.meta.laneChangeBlinkerHold + self.lever_turn = False # modelV2.meta.leverTurn: the turn desire comes from a latched lever # keep pulse self.keep_pulse_timer = 0.0 @@ -264,6 +265,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa trailer_maneuver_blocked = carstate.trailerConnected prev_lane_change_state = self.lane_change_state + lever_decided_turn = False # per-side compute (좌/우 모두) self._process_sides(carstate, modeldata, radarState) @@ -372,6 +374,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa lever_type, lever_undecided = lever_maneuver(lever, v_ego, self.blinkerLatchedTurn) if lever_type is not None: new_type = lever_type + lever_decided_turn = lever_type == "turn" if trailer_maneuver_blocked and new_type in ("lane_change", "turn"): new_type = "none" @@ -559,6 +562,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa self.lane_change_direction = self.turn_direction else: self.desire = DESIRES[self.lane_change_direction][self.lane_change_state] + self.lever_turn = lever_decided_turn and self.turn_direction != TurnDirection.none # keep pulse if self.lane_change_state in (LaneChangeState.off, LaneChangeState.laneChangeStarting): diff --git a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py index c32b41b9ae3..748566e5133 100644 --- a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py +++ b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py @@ -233,6 +233,27 @@ def test_latched_lever_at_the_set_speed_is_a_turn(self): self.helper.blinkerLatchedTurn = 50 / 3.6 self.lever_on(2, v_kph=50.0, steps=3) assert self.helper.maneuver_type == "turn" + assert self.helper.lever_turn # cluster message: the lever made this turn + + def test_lever_turn_flag_only_for_a_lever_made_turn(self): + # off: a navigation turn guessed by the classifier is a turn, but not the lever's + self.carrot_man.atcType = "turn left" + self.helper.left.dist_to_edge_far = 5.0 + self.lever_on(2, v_kph=80.0, steps=3) + assert self.helper.maneuver_type == "turn" and not self.helper.lever_turn + # on, one-touch at 45 km/h: a lane change, no message + self.setup_method() + self.helper.blinkerLatchedTurn = 50 / 3.6 + self.lever_on(1, steps=2) + self.update(lever=0, v_kph=45.0) + assert self.helper.maneuver_type == "lane_change" and not self.helper.lever_turn + # the lever released: the message goes with the turn + self.setup_method() + self.helper.blinkerLatchedTurn = 50 / 3.6 + self.lever_on(2, steps=3) + assert self.helper.lever_turn + self.update(left_blinker=False, lever=0, v_kph=45.0) + assert not self.helper.lever_turn class TestLaneChangeLeverWait: diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index da067200117..7f862cb07bd 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -510,6 +510,7 @@ def load_usbgpu_model(): modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction modelv2_send.modelV2.meta.desireLog = DH.desireLog #carrot modelv2_send.modelV2.meta.laneChangeBlinkerHold = DH.blinker_hold_direction + modelv2_send.modelV2.meta.leverTurn = DH.lever_turn drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction From 2fd6c158fbc32a31b6fbda01ff533b0c85be6874 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:25:39 +0900 Subject: [PATCH 23/50] hyundai: show the lever-turn notice as a 0x161 alert ADRV_0x1ea AUTOLANECHANGE_MSG did not show on the cluster: on the 2026-10-05 drive (route 00000494) all three latched-lever turns sent it as 1 (with HDA_MODE2 1, LANE_CHANGING 1/2) and nothing appeared. The stock ADRV only sends it next to lane-change-assist state changes (HDA_MODE2 4 or 0), so the cluster likely shows it only in that context. Use ADRV_0x161 ALERTS_3 = 18 (CHECK_SURROUNDINGS) instead, the alert slot the cluster already shows for carrot, and only when no other ALERTS_3 is up. No sound is added. Same trigger: modelV2.meta.leverTurn. Tests: hyundai 178 passed, settings schema 46 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py | 14 ++++++++------ .../opendbc/car/hyundai/tests/test_blink_hold.py | 13 ++++++++----- openpilot/selfdrive/carrot_settings.json | 6 +++--- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index 3df1e1e95f4..ade791e3baa 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -101,7 +101,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. - A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). -- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While a latched lever is turning the car, the cluster shows 'Check surrounding conditions' in the lane-change area (the cluster has no turn message). +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While a latched lever is turning the car, the cluster shows a 'Check surroundings' alert (the cluster has no turn message). - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. ## What these settings do not change diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index 2f2f213565c..3c41e953063 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -101,7 +101,7 @@ mici의 VISION에는 좌우 차선 종류와 최근 처리 시간이 나옵니 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. - 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. -- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 레버 고정으로 회전 중일 때는 계기판 차로 변경 칸에 '주변 상황을 확인하세요' 문구가 뜹니다(계기판에 회전 전용 문구가 없음). +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 레버 고정으로 회전 중일 때는 계기판에 '주변을 확인하세요' 알림이 뜹니다(계기판에 회전 전용 문구가 없음). - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. ## 바뀌지 않는 것과 혼동하기 쉬운 설정 diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index 8d74541dd3a..c74b420a063 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -870,15 +870,16 @@ def _apply_cluster_lane_lines(values, CS, lat_active, desire): _apply_lane_desire(values, desire) -# ADRV_0x1ea AUTOLANECHANGE_MSG 1 "Check surrounding conditions" (the cluster has no "turn" message) -LEVER_TURN_CLUSTER_MSG = 1 +# ADRV_0x161 ALERTS_3 18 "CHECK_SURROUNDINGS" (the cluster has no "turn" message). ADRV_0x1ea AUTOLANECHANGE_MSG +# was tried first and the cluster did not show it (2026-10-05 route 00000494: sent 1 with HDA_MODE2 1, nothing shown). +LEVER_TURN_ALERT = 18 def _apply_lever_turn_msg(values, md): - """BlinkerLatchedTurn: while a latched lever makes the turn desire, say so in the cluster's lane-change slot.""" + """BlinkerLatchedTurn: while a latched lever makes the turn desire, show a cluster alert (unless one is up).""" meta = getattr(md, "meta", None) if md is not None else None - if getattr(meta, "leverTurn", False): - values["AUTOLANECHANGE_MSG"] = LEVER_TURN_CLUSTER_MSG + if getattr(meta, "leverTurn", False) and values.get("ALERTS_3", 0) == 0: + values["ALERTS_3"] = LEVER_TURN_ALERT def _normalize_cluster_corner_objects(values, *, ccnc=False): @@ -1076,6 +1077,8 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if values["ALERTS_5"] in [11] and CS.softHoldActive == 0: values["ALERTS_5"] = 0 + _apply_lever_turn_msg(values, md) + # curvature 표시(0x161쪽 기존 로직 유지) _suppress_trailer_mode_warning(values, CS) @@ -1141,7 +1144,6 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values['RIGHT_BLINK_HOLD'] = 1 if hold == 4 else 0 _apply_cluster_lane_lines(values, CS, lat_active, desire) - _apply_lever_turn_msg(values, md) _normalize_cluster_corner_objects(values) ret.append(packer.make_can_msg("ADRV_0x1ea", CAN.ECAN, values, rx_counter = rx_counter)) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py index e2770d86680..006e75e749a 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -63,12 +63,15 @@ def test_blink_hold_request_overrides_the_release_of_the_current_change(): assert _blink_hold_side(md, 3, state) == 3 -def test_lever_turn_shows_the_cluster_message_only_when_flagged(): - values = {"AUTOLANECHANGE_MSG": 0} +def test_lever_turn_shows_the_cluster_alert_only_when_flagged(): + values = {"ALERTS_3": 0} _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=False))) - assert values["AUTOLANECHANGE_MSG"] == 0 + assert values["ALERTS_3"] == 0 _apply_lever_turn_msg(values, None) _apply_lever_turn_msg(values, NS(meta=NS())) # older schema - assert values["AUTOLANECHANGE_MSG"] == 0 + assert values["ALERTS_3"] == 0 _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=True))) - assert values["AUTOLANECHANGE_MSG"] == 1 + assert values["ALERTS_3"] == 18 # CHECK_SURROUNDINGS + values = {"ALERTS_3": 2} # another alert is up: keep it + _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=True))) + assert values["ALERTS_3"] == 2 diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index ac81d859d53..8db5f4d5311 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -2472,13 +2472,13 @@ "group": "조향일반", "name": "BlinkerLatchedTurn", "title": "깜빡이 레버로 회전/차선변경 구분 (km/h, 0=끔)", - "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다. 레버 고정으로 회전 중일 때는 계기판 차로 변경 칸에 '주변 상황을 확인하세요' 문구를 띄웁니다.", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다. 레버 고정으로 회전 중일 때는 계기판에 '주변을 확인하세요' 알림을 띄웁니다.", "egroup": "LAT", "etitle": "Turn-signal lever selects turn / lane change (km/h, 0=off)", - "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change. While a latched lever is turning the car, the cluster shows 'Check surrounding conditions' in the lane-change area.", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change. While a latched lever is turning the car, the cluster shows a 'Check surroundings' alert.", "cgroup": "转向", "ctitle": "转向灯拨杆区分转弯/变道 (km/h, 0=关)", - "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。锁定拨杆转弯时,仪表盘变道区域显示“请确认周围情况”。", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。锁定拨杆转弯时,仪表盘显示“请确认周围情况”提示。", "min": 0, "max": 80, "default": 0, From b2406cf99bb701452a4f868c835a5f55305cdcdb Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:56:04 +0900 Subject: [PATCH 24/50] speedcam: experimental early-warning correction for camera matching Add VehicleNaviDecelCancelEarlyWarning ([experimental], default off, only with the deceleration cancel request on). When on, the far end of every warning-lead window (saturated and precise previews, and the mobile-zone start window) grows to 1.35 x the 6.1 m/km/h lead; the near end and the default behaviour are unchanged. Field logs 2026-10-05 (another user, Gangwon, 60 km/h): a box camera, a fixed camera and a mobile zone all started their warning 462-466 m before the camera (7.6-7.7 m/km/h vs 6.1), so none matched and all three were treated as an undetected kind (no cancel). Replaying those logs: off -> unknown x3, on -> BOX, FIXED, ZONE. Replaying our 37 drives (209 warnings): no warning changes class with the option on. Risk, stated in the setting: a nearby mobile-zone preview can attach to the wrong warning in the wider window; with the mobile-zone cancel on or AutoNaviSpeedCtrlMode <= 2 (zones ignored) a real camera's deceleration may then be skipped. Users are told to verify on the road. Tests: 170 passed (2 new: early box match on/off; zone covered, late start still unmatched); settings schema 104 passed. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 3 ++- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 3 ++- opendbc_repo/opendbc/car/hyundai/carstate.py | 1 + .../opendbc/car/hyundai/navi_speedcam.py | 16 +++++++++--- .../car/hyundai/tests/test_navi_speedcam.py | 25 +++++++++++++++++++ .../car/hyundai/tests/test_speed_camera.py | 5 ++-- openpilot/common/params_keys.h | 1 + .../server/tests/test_settings_schema.py | 6 +++-- openpilot/selfdrive/carrot_settings.json | 21 ++++++++++++++++ 11 files changed, 74 insertions(+), 11 deletions(-) diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 08954de320d..a4e5f709620 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index 23b2383c8df..ad98333382b 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -113,6 +113,7 @@ Per-kind toggles (in the setting's detail screen, on by default so turning on th | `VehicleNaviDecelCancelMobileZone` | Mobile enforcement zone; matters only when `AutoNaviSpeedCtrlMode` is `3`, so zones decelerate | | `VehicleNaviDecelCancelFixed` | Fixed speed camera (preview kind 0, no rear flag). Real enforcement cameras, so **off by default** — it must be turned on separately even with the master on. Not applied inside a section, where `+` is the section unlock | | `VehicleNaviDecelCancelUnknown` | **[Experimental]** Warnings whose camera kind preview was not received or not understood. It can include signal or speed cameras that send no preview, so **off by default** and must be turned on separately from the master. Not applied at 30 km/h or below (school and senior zones) or inside a section; if a signal, rear or fixed preview is matched after the cancel, that rule applies | +| `VehicleNaviDecelCancelEarlyWarning` | **[Experimental]** For roads/cars where the warning starts earlier than 6.1 m x the limit. Grows the far end of the matching window up to 1.35x so box, fixed and mobile-zone previews are found. **Risk**: a mobile-zone preview attached to the wrong warning can, with low probability, skip a real enforcement camera when the mobile-zone cancel is on or `AutoNaviSpeedCtrlMode` is 2 or lower; always verify. Off by default | | `VehicleNaviDecelCancelBump` | Stock-navigation speed bump. The input is used only while the bump target (from the speed-bump settings) is below the set speed, i.e. actually decelerating, and cancels the nearest bump only | ### `VehicleNaviSectionAvgControl` diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 346f4f5bea9..7d345a3fb27 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index a5aacf62b4a..0965807dbd7 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -115,6 +115,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | `VehicleNaviDecelCancelMobileZone` | 이동식 단속 구간. `AutoNaviSpeedCtrlMode`가 `3`이라 감속할 때만 의미 | | `VehicleNaviDecelCancelFixed` | 고정식 과속카메라(예고 종류 0, 후면 표시 없음). 실제 단속 카메라라서 **기본값 꺼짐** — 상위를 켜도 이 항목은 따로 켜야 동작. 구간단속 중에는 `+`가 구간 해제에 쓰이므로 적용하지 않음 | | `VehicleNaviDecelCancelUnknown` | **[실험]** 카메라 종류 예고를 받지 못했거나 해석하지 못한 경고(CAN 미수신·미처리). 예고 없이 오는 신호·과속 카메라도 포함될 수 있어 **기본값 꺼짐**, 상위와 별도로 켜야 동작. 30km/h 이하(어린이·노인보호구역)와 구간단속 중에는 적용하지 않고, 중지 뒤 신호·후면·고정식 예고가 확인되면 그 규칙을 따름 | +| `VehicleNaviDecelCancelEarlyWarning` | **[실험]** 경고가 평소(카메라 앞 6.1m×제한속도)보다 일찍 켜지는 지역·차량용. 짝 찾기 범위의 앞쪽 끝을 최대 1.35배로 넓혀 박스형·고정식·이동식 구간을 찾음. **위험**: 이동식 구간 예고가 엉뚱한 경고에 붙으면, 이동식 구간 감속 중지가 켜져 있거나 `AutoNaviSpeedCtrlMode` 2 이하일 때 낮은 확률로 실제 단속 카메라 감속을 건너뛸 수 있으니 반드시 확인. 기본값 꺼짐 | | `VehicleNaviDecelCancelBump` | 순정 내비 과속방지턱. 방지턱 감속 목표(방지턱 설정으로 계산)가 설정 속도보다 낮아 실제로 감속 중일 때만 입력을 쓰고, 가장 가까운 방지턱 하나만 중지 | ### `VehicleNaviSectionAvgControl` diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index c57bc1d3fb2..6b6306b7f6e 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -737,6 +737,7 @@ def _read_speedcam_params(self): self.speedcamCancelBump = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBump") self.speedcamSkipFixed = cancel and self.op_params.get_bool("VehicleNaviDecelCancelFixed") self.speedcamSkipUnknown = cancel and self.op_params.get_bool("VehicleNaviDecelCancelUnknown") + self.speedcam_policy.early_lead = cancel and self.op_params.get_bool("VehicleNaviDecelCancelEarlyWarning") # carrot_serv's stock-navigation bump source, mirrored to tell when a bump is actually decelerating. self.speedcamBumpDecel = self.op_params.get_int("AutoNaviSpeedCtrlMode") >= 2 self.speedcamBumpSpeed = float(self.op_params.get_int("AutoNaviSpeedBumpSpeed")) diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 5476586117e..14aeb24fda5 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -41,6 +41,10 @@ # its target sits one lead ahead of the warning start (44/44 zone warnings in 37 drives # within -12..+17 m, 2026-09-30). A kind 3 preview elsewhere never turns a warning into a zone. ZONE_LEAD_TOLERANCE_M = 60.0 +# [experimental] Some cars/roads start the warning earlier: 2026-10-05 field logs (Gangwon, 60 km/h) +# had a box, a fixed camera and a mobile zone all at 7.6-7.7 m/km/h. With early_lead the far end of +# every lead window grows to EARLY_LEAD_FACTOR x lead; the near end is unchanged. +EARLY_LEAD_FACTOR = 1.35 RECENT_PREVIEW_M = 2600.0 PREVIEW_MERGE_M = 20.0 # the stock navigation repeats each preview (~3x); same as carstate event merging PREVIEW_KEEP_M = 3000.0 @@ -61,6 +65,7 @@ def decode_camera_profile(value, offset): class SpeedcamPolicy: def __init__(self): self.previews = [] + self.early_lead = False self.reset_warning() def reset_warning(self): @@ -108,18 +113,23 @@ def classify(self, speed, total_distance, starting=True, start_distance=None): tolerance = max(WARNING_LEAD_MIN_TOLERANCE_M, lead * WARNING_LEAD_TOLERANCE) if start_distance is None: start_distance = total_distance + far = lead + tolerance + zone_far = lead + ZONE_LEAD_TOLERANCE_M + if self.early_lead: + far = max(far, lead * EARLY_LEAD_FACTOR) + zone_far = max(zone_far, lead * EARLY_LEAD_FACTOR) candidates = [] for p in self.previews: if p["speed"] != speed or total_distance - p["received"] > RECENT_PREVIEW_M: continue if (p["kind"] == KIND_MOBILE_ZONE and not p["flagged"] and - abs(p["target"] - start_distance - lead) > ZONE_LEAD_TOLERANCE_M): + not lead - ZONE_LEAD_TOLERANCE_M <= p["target"] - start_distance <= zone_far): continue ahead = p["target"] - total_distance if p["saturated"]: - matched = starting and abs(ahead - lead) <= tolerance + matched = starting and lead - tolerance <= ahead <= far else: - matched = -MATCH_BEHIND_M <= ahead <= lead + tolerance + matched = -MATCH_BEHIND_M <= ahead <= far if matched: candidates.append(p) if not candidates: diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index cae98ba1a04..caf60b0b4b6 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -196,3 +196,28 @@ def test_unknown_skip_does_not_touch_classified_warnings(): policy = SpeedcamPolicy() policy.add_preview(0xD0, 366, 0.0) # fixed camera at the lead assert _update(policy, 0.0, accel=True, skip_unknown=True) == (False, False) + + +def test_early_lead_window_matches_warnings_that_start_early(): + # 2026-10-05 field logs (Gangwon, 60 km/h): the warning started 462 m before a box camera + # (lead 366 m). Off: unknown (hard). On: the saturated preview at 1.26x lead is the box. + for early, expected in ((False, None), (True, CLASS_BOX)): + policy = SpeedcamPolicy() + policy.early_lead = early + policy.add_preview(0xD2, 1999, -1537.0) # saturated, lands 462 m ahead of the warning start + _update(policy, 0.0) + assert policy.warning_class == expected + + +def test_early_lead_also_covers_a_mobile_zone_but_never_a_late_start(): + zone = SpeedcamPolicy() + zone.early_lead = True + zone.add_preview(0xD3, 1463, -997.0) # kind 3 target 466 m ahead of the start + _update(zone, 0.0) + assert zone.warning_class == CLASS_MOBILE_ZONE + + late = SpeedcamPolicy() + late.early_lead = True + late.add_preview(0xD2, 1999, -1749.0) # 250 m ahead: below the unchanged near end + _update(late, 0.0) + assert late.warning_class is None diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index 1510362ba6c..cb28ed2e913 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -47,7 +47,8 @@ def get_bool(self, key): if key in ("VehicleNaviDecelCancel", "VehicleNaviSectionAvgControl"): return False if key in ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", - "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown"): + "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown", + "VehicleNaviDecelCancelEarlyWarning"): return True assert key == "VehicleNaviSchoolZoneControl" return self.school_zone @@ -1602,7 +1603,7 @@ def test_decel_cancel_master_gates_every_kind(): state.op_params.get_bool = lambda key: True state._read_speedcam_params() assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump and state.speedcamSkipFixed - assert state.speedcamSkipUnknown + assert state.speedcamSkipUnknown and state.speedcam_policy.early_lead def test_fixed_camera_skip_outside_a_section_only(): diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index ca18468bcf0..3b3504772a6 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -248,6 +248,7 @@ inline static std::unordered_map keys = { {"VehicleNaviDecelCancelBump", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelFixed", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviDecelCancelUnknown", {PERSISTENT, BOOL, "0"}}, + {"VehicleNaviDecelCancelEarlyWarning", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, {"VehicleSpeedCameraControlMode", {PERSISTENT, INT, "1"}}, {"VehicleSpeedCameraDistanceTime", {PERSISTENT, INT, "60"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index bac3efb336f..a3e563b3665 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -370,7 +370,8 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, "VehicleNaviDecelCancelBump") defaults = {master: 0, "VehicleNaviSectionAvgControl": 0, **{name: 1 for name in children}, "VehicleNaviDecelCancelFixed": 0, # real enforcement cameras: opt-in even with the master on - "VehicleNaviDecelCancelUnknown": 0} # experimental: kind unknown, opt-in + "VehicleNaviDecelCancelUnknown": 0, # experimental: kind unknown, opt-in + "VehicleNaviDecelCancelEarlyWarning": 0} # experimental: wider lead window, opt-in for name, default in defaults.items(): control = by_name[name] assert (control["min"], control["max"], control["default"]) == (0, 1, default) @@ -379,7 +380,8 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, # Per-kind toggles live in the master's detail screen; only the master is opt-in. assert not by_name[master].get("detail_parent") assert all(by_name[name]["detail_parent"] == master and by_name[name]["group"] == by_name[master]["group"] - for name in (*children, "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown")) + for name in (*children, "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown", + "VehicleNaviDecelCancelEarlyWarning")) driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") speed = next(group for group in driving["groups"] if group["id"] == "SPEED") diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 8db5f4d5311..ea6ad543370 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -231,6 +231,7 @@ "VehicleNaviDecelCancelBump", "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown", + "VehicleNaviDecelCancelEarlyWarning", "VehicleNaviSectionAvgControl", "VehicleSpeedCameraControlMode", "VehicleSpeedCameraDistanceTime", @@ -3675,6 +3676,26 @@ "detail_parent": "VehicleNaviDecelCancel", "detail_section": "camera" }, + { + "group": "감속제어", + "name": "VehicleNaviDecelCancelEarlyWarning", + "title": "감속 중지: 경고 조기 시작 보정 [실험]", + "descr": "감속 중지 요청이 켜져 있을 때만 동작하는 실험 기능입니다. 순정 내비 단속 경고는 보통 카메라 앞 약 6.1m×제한속도(60km/h면 약 366m)에서 켜지는데, 일부 지역·차량에서는 이보다 일찍(60km/h에서 약 460m) 켜져 박스형·고정식·이동식 구간 예고와 짝을 찾지 못하고 '종류 미탐지'로 처리됩니다. 켜면 짝 찾기 범위의 앞쪽 끝을 최대 1.35배로 넓혀 이런 경고도 종류를 찾습니다. ⚠ 위험: 범위가 넓어진 만큼 근처의 이동식 카메라(이동식 단속 구간) 예고가 엉뚱한 경고에 붙을 수 있습니다. 이때 '감속 중지: 이동식 단속 구간'이 켜져 있거나 네비게이션감속모드(AutoNaviSpeedCtrlMode)가 2 이하(이동식 구간 무시)이면, 확률은 낮지만 실제 단속 카메라의 감속을 건너뛸 수 있습니다. 켜면 반드시 주행에서 동작을 확인하세요.", + "egroup": "SPEED", + "etitle": "Deceleration Cancel: Early Warning Correction [Experimental]", + "edescr": "Experimental; works only with the deceleration cancel request on. A stock-navigation enforcement warning usually starts about 6.1 m x the limit (km/h) before its camera (about 366 m at 60 km/h), but on some roads/cars it starts earlier (about 460 m at 60 km/h), so box, fixed and mobile-zone previews are not matched and the warning is treated as an undetected kind. When on, the far end of the matching window grows up to 1.35x so these warnings find their kind. WARNING: the wider window can attach a nearby mobile-camera (mobile enforcement zone) preview to the wrong warning. If 'Deceleration Cancel: Mobile Zone' is on or AutoNaviSpeedCtrlMode is 2 or lower (mobile zones ignored), a real enforcement camera's deceleration may, with low probability, be skipped. Always verify the behaviour on the road after enabling.", + "cgroup": "速度控制", + "ctitle": "减速中止:提前警告修正 [实验]", + "cdescr": "实验功能,仅在减速中止请求开启时有效。原车导航测速警告通常在摄像头前约 6.1m×限速(60km/h 约 366m)开始,但在部分地区或车辆上会更早开始(60km/h 约 460m),导致箱式、固定、移动区间预告无法匹配而被视为未识别类型。开启后将匹配窗口的远端最多扩大至 1.35 倍,使这类警告也能识别类型。⚠ 风险:窗口扩大后,附近的移动摄像头(移动测速区间)预告可能被错误匹配到其他警告。此时若开启了“减速中止:移动测速区间”或导航减速模式(AutoNaviSpeedCtrlMode)为 2 及以下(忽略移动区间),则有较低概率跳过实际测速摄像头的减速。开启后务必在实际驾驶中确认其行为。", + "min": 0, + "max": 1, + "default": 0, + "unit": 1, + "control": "toggle", + "risk": "high", + "detail_parent": "VehicleNaviDecelCancel", + "detail_section": "camera" + }, { "group": "감속제어", "name": "VehicleNaviSectionAvgControl", From 67591b8dbdc35fda3f7ded091a47468da8ddbe44 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:53:20 +0900 Subject: [PATCH 25/50] radar: mode 3 does not adopt an SCC body that radar places in another lane EnableRadarTracks 3 adopts the nearest OEM SCC object whenever front-radar/vision matching fails, with its lateral position overwritten to 0. On our car that turned an adjacent-lane car into leadOne and braked hard twice: 2026-09-24 00000459 seg62: adjacent queue, 31 -> 0.9 km/h, aTarget -3.11 2026-10-06 00000499 seg13: car left behind by a right lane change, 54 -> 5 km/h, -3.14 In both, the front and corner tracks of that same body (range/speed match through _scc_physical_support) were 2.3-3.8 m beside the path. Before adopting the SCC body, find its front and corner tracks. Refuse it only when they put it beside the path: both path offset and raw lateral >= 2.0 m (close-range radar read 1.3-2.0 m for a car straight ahead), at least one >= 2.5 m, and front and corner agree on the body (or both >= 2.5 m). Keep SCC when vision sees a lead at the same range near the path, when no track matches (SCC may be the only sensor on it), and during a lane change for a body below 18 km/h (a stopped car can still be in the lane being left). A refusal holds 1 s through support dropouts. Modes other than 3, leadTwo SCC backup and the central vision fallback are unchanged. Validation (33 routes, 59,388 forced-SCC frames): 7.6 % refused, none with same-range in-lane vision; video-checked refusals were adjacent or parked cars. Replaying the modified controller on the logs: forced-SCC leadOne frames 129 -> 8 (09-24) and 193 -> 12 (10-06); unchanged for a stopped truck in the lane being left (51), a stopped car while changing lanes (79) and an in-lane car with 1.6 m radar lateral (11). Tests: new test_radar_forced_scc_gate 9 passed (3 fail with the gate disabled); radar suite 701 with the same 4 pyray-only failures as before; settings schema 104. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/radar.md | 4 +- docs/user/ko/radar.md | 4 +- .../carrot/radar_motion/controller.py | 85 ++++++++++++ .../tests/test_radar_forced_scc_gate.py | 125 ++++++++++++++++++ openpilot/selfdrive/carrot_settings.json | 4 +- 5 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 openpilot/selfdrive/carrot/tests/test_radar_forced_scc_gate.py diff --git a/docs/user/en/radar.md b/docs/user/en/radar.md index 8c824f25799..63ad4ccefe2 100644 --- a/docs/user/en/radar.md +++ b/docs/user/en/radar.md @@ -22,7 +22,7 @@ A radar fitted to the vehicle does not guarantee that carrotpilot can read the r | `0` | Always use SCC range and relative speed; use vision if SCC is absent | Default; same SCC-only policy as `-1` | | `1` | Match raw front-radar tracks to vision without SCC; use vision if matching fails | Requires vehicle-specific activation and message support | | `2` | Match front-radar tracks and low-speed SCC to vision; use vision if matching fails | Test only on an identical validated configuration | -| `3` | Match front radar to vision first; if it fails, always use SCC, then vision if SCC is absent | Experimental; false detections are possible | +| `3` | Match front radar to vision first; if it fails, use SCC (not when front/corner radar place that body beside the path), then vision if SCC is absent | Experimental; false detections are possible | On non-CAN FD Hyundai/Kia vehicles, a positive value attempts to enable radar tracks during startup and stores the result in `EnableRadarTracksResult`. Confirm both the activation result and actual incoming tracks; physical radar presence alone is not enough. @@ -57,7 +57,7 @@ Corner-radar objects are created only when the vehicle code recognizes a support | `0` | Keep the existing `radard` lead and cut-in processing (default) | | `1` | Run the independent dPath RadarD, calculate front-first radar/vision leadOne first, then use only physical dPath CUT-INs as leadTwo | -SCC is used only when `EnableRadarTracks` allows it. Mode `-2` is vision-only; modes `-1` and `0` always use a valid SCC object, then vision if SCC is absent. Mode `1` matches front radar to vision; mode `2` also permits SCC with `vLead < 5.0 m/s`, using only range and speed for SCC association. Mode `3` always uses SCC if front matching fails. The final vision fallback in modes `1`, `2`, and `3` requires probability at least `0.40` and `|dPath| <= 1.0 m`. SCC-only modes `-1` and `0` use the same probability threshold without the additional lateral/path gate. +SCC is used only when `EnableRadarTracks` allows it. Mode `-2` is vision-only; modes `-1` and `0` always use a valid SCC object, then vision if SCC is absent. Mode `1` matches front radar to vision; mode `2` also permits SCC with `vLead < 5.0 m/s`, using only range and speed for SCC association. Mode `3` uses SCC if front matching fails, except when the front and corner tracks matching its range and speed all sit beside the path (path and raw lateral at least 2.0 m, one at least 2.5 m from the path): that adjacent-lane body is refused, with a 1 s hold. Same-range central vision, an in-path track, or a body below 18 km/h during a lane change keeps SCC. The final vision fallback in modes `1`, `2`, and `3` requires probability at least `0.40` and `|dPath| <= 1.0 m`. SCC-only modes `-1` and `0` use the same probability threshold without the additional lateral/path gate. For an already selected moving front vehicle, the range guard uses the model’s distance uncertainty instead of discarding radar solely because it is more than 8 m farther away. Both radar and vision must indicate motion above 4 m/s and remain central to the model path. The same front must stay measured and physically continuous, with no gap longer than 0.15 seconds. Its allowed farther-than-vision difference is `max(8 m, min(15 m, 15% of vision distance, 1.5 × xStd))`. Fresh IDs, missing matches, invalid paths, position or speed jumps, and precise nearer vision do not inherit that allowance. This adds no confirmation delay and does not prevent a better matching nearer front vehicle from becoming L1. A retained lead uses radar distance and speed; it does not average them with the visual estimates. diff --git a/docs/user/ko/radar.md b/docs/user/ko/radar.md index 61ed0fbde6b..52625530d22 100644 --- a/docs/user/ko/radar.md +++ b/docs/user/ko/radar.md @@ -22,7 +22,7 @@ | `0` | SCC 종거리·상대속도를 항상 사용하고, SCC가 없으면 비전 사용 | 기본값; `-1`과 같은 SCC 전용 정책 | | `1` | SCC 없이 전방 레이더 트랙을 비전과 매칭하고, 매칭 실패 시 비전 사용 | 차량별 레이더 활성화와 메시지 지원 필요 | | `2` | 전방 레이더 트랙과 저속 SCC를 비전과 매칭하고, 매칭 실패 시 비전 사용 | 검증된 동일 구성에서만 시험 | -| `3` | 전방 레이더를 비전과 먼저 매칭하고, 실패하면 SCC를 항상 사용하며 SCC도 없으면 비전 사용 | 오검출 가능성이 있는 실험 기능 | +| `3` | 전방 레이더를 비전과 먼저 매칭하고, 실패하면 SCC를 사용하며(같은 차체를 전방·코너 레이더가 경로 밖에서 보면 제외) SCC도 없으면 비전 사용 | 오검출 가능성이 있는 실험 기능 | 비-CAN FD 현대·기아 차량에서 양수 값을 사용하면 시작 과정에서 레이더 트랙 활성화를 시도하고 결과를 `EnableRadarTracksResult`에 저장합니다. 물리적인 레이더가 있어도 활성화 결과와 실제 트랙 수신을 함께 확인해야 합니다. @@ -57,7 +57,7 @@ SCC의 횡위치는 0으로 고정되거나 신뢰하기 어려울 수 있으므 | `0` | 기존 `radard` 선행차·끼어들기 처리 유지(기본값) | | `1` | 독립 dPath RadarD가 프런트 우선의 레이더·비전 leadOne을 먼저 계산하고, 물리 dPath CUT-IN만 leadTwo로 사용 | -SCC는 `EnableRadarTracks`가 허용하는 모드에서만 사용합니다. `-2`는 비전 전용 시험, `-1`·`0`은 SCC 전용이며 유효한 SCC를 항상 사용하고 없으면 비전으로 전환합니다. `1`은 프런트 레이더, `2`는 프런트 레이더와 `vLead < 5.0m/s`인 SCC를 비전과 매칭합니다. SCC 매칭에는 종거리·속도만 사용합니다. `3`은 프런트 매칭 실패 시 SCC를 항상 사용합니다. `1`·`2` 및 SCC가 없는 `3`의 최종 비전 폴백은 확률 `0.40` 이상, `|dPath| ≤ 1.0m`를 요구합니다. SCC 전용 `-1`·`0`의 비전 폴백은 같은 확률 기준을 사용하되 별도 횡위치·경로 제한을 적용하지 않습니다. +SCC는 `EnableRadarTracks`가 허용하는 모드에서만 사용합니다. `-2`는 비전 전용 시험, `-1`·`0`은 SCC 전용이며 유효한 SCC를 항상 사용하고 없으면 비전으로 전환합니다. `1`은 프런트 레이더, `2`는 프런트 레이더와 `vLead < 5.0m/s`인 SCC를 비전과 매칭합니다. SCC 매칭에는 종거리·속도만 사용합니다. `3`은 프런트 매칭 실패 시 SCC를 사용합니다. 단, 거리·속도가 같은 전방·코너 레이더 트랙이 모두 경로 밖(경로·원시 횡위치 2.0m 이상, 하나는 경로 2.5m 이상)이면 옆 차로 차로 보고 채택하지 않으며 1초 유지합니다. 같은 거리 중앙 비전, 경로 안 트랙, 차로변경 중 18km/h 미만 물체는 기존대로 채택합니다. `1`·`2` 및 SCC가 없는 `3`의 최종 비전 폴백은 확률 `0.40` 이상, `|dPath| ≤ 1.0m`를 요구합니다. SCC 전용 `-1`·`0`의 비전 폴백은 같은 확률 기준을 사용하되 별도 횡위치·경로 제한을 적용하지 않습니다. 이미 L1로 선택한 이동 프런트 차량은 비전보다 8m 넘게 멀다는 이유만으로 버리지 않고, 모델의 거리 불확실도를 함께 반영합니다. 레이더와 비전 모두 속도가 4m/s를 넘고 모델 경로 중앙에 있어야 하며, 같은 프런트가 0.15초를 넘는 공백 없이 실측되고 위치·속도가 연속되어야 합니다. 이때 비전보다 멀어도 허용하는 차이는 `max(8m, min(15m, 비전 거리의 15%, 1.5 × xStd))`입니다. 새 ID, 매칭 소실, 유효하지 않은 경로, 위치·속도 급변, 오차가 작은 가까운 비전에는 이 유지 허용치를 물려주지 않습니다. 별도의 확인 지연을 추가하지 않으며, 더 가까운 프런트가 새로 잘 매칭되면 그 차량이 L1이 됩니다. 유지한 L1의 거리·속도는 레이더 값을 사용하고 비전 값과 평균내지 않습니다. diff --git a/openpilot/selfdrive/carrot/radar_motion/controller.py b/openpilot/selfdrive/carrot/radar_motion/controller.py index e6f9c2685e2..6b9a5884c9a 100644 --- a/openpilot/selfdrive/carrot/radar_motion/controller.py +++ b/openpilot/selfdrive/carrot/radar_motion/controller.py @@ -103,12 +103,70 @@ CROSS_SENSOR_CLOSE_CUTIN_MAX_DREL_M = 12.0 CROSS_SENSOR_CLOSE_CUTIN_MAX_ABS_DPATH_M = 3.0 CROSS_SENSOR_CLOSE_CUTIN_MIN_VLEAD_MPS = 0.5 +# EnableRadarTracks 3 adopts the OEM SCC object with its lateral position zeroed. When the +# front/corner radar tracks of that same body put it clearly beside the path, it is an +# adjacent-lane car (2026-09-24 queue, 2026-10-06 lane change phantoms): do not adopt it. +# Close-range radar lateral can read 1.3-2.0 m for a car straight ahead, so "near" is 2.0 m. +FORCED_SCC_NEAR_PATH_M = 2.0 +FORCED_SCC_BESIDE_PATH_M = 2.5 +FORCED_SCC_VISION_MIN_PROB = 0.5 +# During a lane change the old lane can still hold a stopped car the path has already left. +FORCED_SCC_LANE_CHANGE_KEEP_VLEAD_MPS = 5.0 +FORCED_SCC_REJECT_HOLD_S = 1.0 +LANE_CHANGE_ACTIVE_STATES = frozenset(("preLaneChange", "laneChangeStarting", "laneChangeFinishing")) def _is_corner(point: RadarPointSnapshot) -> bool: return point.source.startswith("corner") +def _lane_change_active(model: Any) -> bool: + meta = getattr(model, "meta", None) + return str(getattr(meta, "laneChangeState", "off")) in LANE_CHANGE_ACTIVE_STATES + + +def _forced_scc_lateral_evidence( + scc: RadarPointSnapshot, + points: tuple[RadarPointSnapshot, ...], + path: tuple[tuple[float, float], ...], + vision: VisionLead | None, + model: Any, +) -> tuple[bool, bool]: + """(beside_path, in_lane) for the SCC body that mode 3 would adopt unconditionally.""" + if _lane_change_active(model) and scc.v_lead < FORCED_SCC_LANE_CHANGE_KEEP_VLEAD_MPS: + return False, True + if ( + vision is not None + and vision.probability >= FORCED_SCC_VISION_MIN_PROB + and abs(vision.d_rel - scc.d_rel) <= max( + SCC_PHYSICAL_MATCH_MIN_DREL_M, scc.d_rel * SCC_PHYSICAL_MATCH_DREL_FRACTION, + ) + and abs(vision.y_rel) <= FORCED_SCC_NEAR_PATH_M + ): + return False, True + supports = [ + support for support in ( + _scc_physical_support( + scc, (point for point in points if point.source == "frontRadar"), path, require_path=False, + ), + _scc_physical_support( + scc, (point for point in points if _is_corner(point)), path, require_path=False, + ), + ) + if support is not None + ] + if not supports: + return False, False # no physical match: SCC may be the only sensor on this car + d_paths = [abs(project_to_model_path(path, point.d_rel, point.y_rel).d_path) for point in supports] + # A noisy model path alone must not push a car out: the raw lateral has to agree. + if min(d_paths) < FORCED_SCC_NEAR_PATH_M or min(abs(point.y_rel) for point in supports) < FORCED_SCC_NEAR_PATH_M: + return False, True + # Front and corner on different bodies: reject only if both are clearly beside the path. + agree = len(supports) < 2 or abs(supports[0].y_rel - supports[1].y_rel) <= SCC_CORNER_MATCH_MAX_YREL_DELTA_M + beside = max(d_paths) >= FORCED_SCC_BESIDE_PATH_M and (agree or min(d_paths) >= FORCED_SCC_BESIDE_PATH_M) + return beside, False + + def _model_path(model: Any) -> tuple[tuple[float, float], ...]: position = getattr(model, "position", None) if position is None: @@ -467,6 +525,7 @@ def __init__( ) -> None: self.primary_matcher = VisionRadarMatcher() self.scc_primary_fallback_matcher = VisionRadarMatcher() + self.forced_scc_rejected_s: float | None = None # last frame mode 3 refused a beside-path SCC body self.enable_radar_tracks = int(enable_radar_tracks) self.front_radar_measurement_delay_s = max( 0.0, float(front_radar_measurement_delay_s), @@ -635,6 +694,28 @@ def _reject_farther_radar_match( >= CORROBORATED_STATIONARY_VISION_RANGE_MISMATCH_HOLD_S ) + def _reject_forced_scc( + self, + scc: RadarPointSnapshot, + points: tuple[RadarPointSnapshot, ...], + path: tuple[tuple[float, float], ...], + vision: VisionLead | None, + model: Any, + time_s: float, + ) -> bool: + """Mode 3: refuse a beside-path SCC body; hold the refusal briefly through support dropouts.""" + beside, in_lane = _forced_scc_lateral_evidence(scc, points, path, vision, model) + if in_lane: + self.forced_scc_rejected_s = None + return False + if beside: + self.forced_scc_rejected_s = time_s + return True + return ( + self.forced_scc_rejected_s is not None + and 0.0 <= time_s - self.forced_scc_rejected_s <= FORCED_SCC_REJECT_HOLD_S + ) + def _points_at_model_time( self, radar_points: Any, @@ -860,6 +941,10 @@ def update( # longitudinal object if matching fails. Vision is the final fallback # only when no SCC object exists. primary_match = unconditional_scc_match(points) + if primary_match is not None and self._reject_forced_scc(primary_match.point, points, path, vision, model, time_s): + primary_match = None + elif primary_match is None: + self.forced_scc_rejected_s = None if primary_match is not None: self.scc_primary_fallback_matcher.reset() elif self.enable_radar_tracks == 2: diff --git a/openpilot/selfdrive/carrot/tests/test_radar_forced_scc_gate.py b/openpilot/selfdrive/carrot/tests/test_radar_forced_scc_gate.py new file mode 100644 index 00000000000..f555fc42725 --- /dev/null +++ b/openpilot/selfdrive/carrot/tests/test_radar_forced_scc_gate.py @@ -0,0 +1,125 @@ +"""EnableRadarTracks 3: the unconditional SCC fallback refuses a body that front/corner radar put beside the path. + +Geometry follows field logs: 2026-09-24 adjacent-lane queue (front y -2.4, corner y -2.9), +2026-10-06 car left behind by a lane change, 2026-09-18 stopped truck in the lane being left, +2026-09-18 in-lane car whose close-range radar lateral read 1.3-2.0 m. +""" +from dataclasses import dataclass +from types import SimpleNamespace + +import pytest + +from openpilot.selfdrive.carrot.radar_motion.controller import ( + FORCED_SCC_REJECT_HOLD_S, + DPathRadarController, +) + + +@dataclass(frozen=True) +class Point: + track_id: int + d_rel: float + y_rel: float + v_rel: float = 0.0 + a_rel: float = 0.0 + measured: bool = True + source: str = "frontRadar" + v_lead: float | None = None + a_lead: float | None = None + yv_rel: float = 0.0 + j_lead: float = 0.0 + trackState: int = 0 + + +def model(lead_d: float, lead_y: float, lead_v: float, probability: float, + lane_change_state: str = "off") -> SimpleNamespace: + return SimpleNamespace( + position=SimpleNamespace(x=(0.0, 100.0), y=(0.0, 0.0)), + leadsV3=(SimpleNamespace( + prob=probability, x=(lead_d + 1.52,), y=(-lead_y,), v=(lead_v,), + xStd=(2.0,), yStd=(0.6,), vStd=(1.5,), + ),), + meta=SimpleNamespace(laneChangeState=lane_change_state), + ) + + +def body(d_rel: float, front_y: float | None, corner_y: float | None, v_rel: float) -> tuple[Point, ...]: + points = [Point(0, d_rel, 0.0, v_rel=v_rel, source="scc")] + if front_y is not None: + points.append(Point(38, d_rel - 0.3, front_y, v_rel=v_rel)) + if corner_y is not None: + points.append(Point(6762, d_rel + 0.1, corner_y, v_rel=v_rel, source="corner235")) + return tuple(points) + + +def is_forced_scc(lead: dict | None) -> bool: + return lead is not None and lead["radar"] and lead["radarTrackId"] == 0 + + +def test_beside_path_queue_is_not_adopted() -> None: + output = DPathRadarController(enable_radar_tracks=3).update( + time_s=1.0, v_ego=8.6, radar_points=body(16.5, -2.4, -2.9, v_rel=-1.3), + model=model(60.0, 0.1, 9.0, probability=0.15), + ) + assert not is_forced_scc(output.lead_one) + + +def test_car_left_behind_by_lane_change_is_not_adopted() -> None: + # Moving car in the old lane: the slow-object lane change exception does not apply. + output = DPathRadarController(enable_radar_tracks=3).update( + time_s=1.0, v_ego=14.6, radar_points=body(12.0, 3.4, 3.4, v_rel=-3.0), + model=model(50.0, -7.0, 14.0, probability=0.1, lane_change_state="laneChangeStarting"), + ) + assert not is_forced_scc(output.lead_one) + + +def test_stopped_car_in_lane_being_left_is_kept() -> None: + output = DPathRadarController(enable_radar_tracks=3).update( + time_s=1.0, v_ego=16.4, radar_points=body(31.0, -2.5, -3.0, v_rel=-16.8), + model=model(60.0, 0.2, 16.0, probability=0.7, lane_change_state="laneChangeStarting"), + ) + assert is_forced_scc(output.lead_one) + + +def test_close_in_lane_car_with_wide_radar_lateral_is_kept() -> None: + output = DPathRadarController(enable_radar_tracks=3).update( + time_s=1.0, v_ego=10.5, radar_points=body(7.5, 1.6, 1.9, v_rel=-2.0), + model=model(24.0, 0.5, 9.0, probability=0.98), + ) + assert is_forced_scc(output.lead_one) + + +def test_vision_at_same_distance_keeps_scc() -> None: + output = DPathRadarController(enable_radar_tracks=3).update( + time_s=1.0, v_ego=12.0, radar_points=body(20.0, -2.6, -2.9, v_rel=-2.0), + model=model(20.0, 0.0, 10.0, probability=0.9), + ) + assert output.lead_one is not None and output.lead_one["radar"] + + +def test_scc_without_physical_match_is_still_adopted() -> None: + output = DPathRadarController(enable_radar_tracks=3).update( + time_s=1.0, v_ego=10.0, radar_points=body(30.0, None, None, v_rel=-2.0), + model=model(60.0, 0.0, 9.0, probability=0.1), + ) + assert is_forced_scc(output.lead_one) + + +def test_refusal_holds_through_support_dropout_then_expires() -> None: + controller = DPathRadarController(enable_radar_tracks=3) + far_vision = model(60.0, 0.1, 9.0, probability=0.15) + first = controller.update(time_s=1.0, v_ego=8.6, radar_points=body(16.5, -2.4, -2.9, v_rel=-1.3), model=far_vision) + assert not is_forced_scc(first.lead_one) + dropout = controller.update(time_s=1.5, v_ego=8.6, radar_points=body(16.0, None, None, v_rel=-1.3), model=far_vision) + assert not is_forced_scc(dropout.lead_one) + expired = controller.update(time_s=1.5 + FORCED_SCC_REJECT_HOLD_S + 0.2, v_ego=8.6, + radar_points=body(15.0, None, None, v_rel=-1.3), model=far_vision) + assert is_forced_scc(expired.lead_one) + + +@pytest.mark.parametrize("mode", (1, 2)) +def test_other_modes_never_take_the_unconditional_path(mode: int) -> None: + controller = DPathRadarController(enable_radar_tracks=mode) + controller.update(time_s=1.0, v_ego=8.6, radar_points=body(16.5, -2.4, -2.9, v_rel=-1.3), + model=model(60.0, 0.1, 9.0, probability=0.15)) + assert controller.forced_scc_rejected_s is None diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index b665706983f..ffe944c76ae 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -2806,10 +2806,10 @@ "group": "레이더설정", "name": "EnableRadarTracks", "title": "레이더트랙,SCC레이더설정(0)", - "descr": "-2: VOACC 비전 only 시험\n-1: 비전매치 없이 SCC 항상 사용, SCC 없으면 비전\n0: SCC 종거리·상대속도 항상 사용, SCC 없으면 비전\n1: SCC 없이 프런트 레이더 비전매치, 실패하면 비전\n2: 프런트 레이더 + 저속 SCC 비전매치, 실패하면 비전\n3: 프런트 레이더 비전매치 실패 시 SCC 항상 사용, SCC 없으면 비전\n※ 새 비전은 prob 0.40 이상, 모든 SCC는 횡좌표 미사용\n※ 레거시 Mando 32/64슬롯 자동 지원", + "descr": "-2: VOACC 비전 only 시험\n-1: 비전매치 없이 SCC 항상 사용, SCC 없으면 비전\n0: SCC 종거리·상대속도 항상 사용, SCC 없으면 비전\n1: SCC 없이 프런트 레이더 비전매치, 실패하면 비전\n2: 프런트 레이더 + 저속 SCC 비전매치, 실패하면 비전\n3: 프런트 레이더 비전매치 실패 시 SCC 사용(같은 차를 전방·코너 레이더가 옆 차로에서 보면 제외), SCC 없으면 비전\n※ 새 비전은 prob 0.40 이상, 모든 SCC는 횡좌표 미사용\n※ 레거시 Mando 32/64슬롯 자동 지원", "egroup": "RADAR", "etitle": "Enable radar tracks(0)", - "edescr": "-2: VOACC vision-only test\n-1: Always use SCC without vision matching; vision if absent\n0: Always use SCC range and relative speed; vision if absent\n1: Match front radar without SCC; vision if it fails\n2: Match front radar + low-speed SCC; vision if it fails\n3: If front-radar vision matching fails, always use SCC; vision if absent\nNew vision requires prob >= 0.40; all SCC association ignores lateral position\nLegacy Mando 32/64-slot variants are detected automatically", + "edescr": "-2: VOACC vision-only test\n-1: Always use SCC without vision matching; vision if absent\n0: Always use SCC range and relative speed; vision if absent\n1: Match front radar without SCC; vision if it fails\n2: Match front radar + low-speed SCC; vision if it fails\n3: If front-radar vision matching fails, use SCC (not when front/corner radar place that body in another lane); vision if absent\nNew vision requires prob >= 0.40; all SCC association ignores lateral position\nLegacy Mando 32/64-slot variants are detected automatically", "cgroup": "雷达设置", "ctitle": "开启雷达轨迹 (0)", "cdescr": "-2: VOACC 纯视觉测试\n-1: 无视觉匹配始终使用 SCC;无 SCC 时使用视觉\n0: 始终使用 SCC 纵向距离和相对速度;无 SCC 时使用视觉\n1: 仅前雷达与视觉匹配;失败时使用视觉\n2: 前雷达 + 低速 SCC 与视觉匹配;失败时使用视觉\n3: 前雷达视觉匹配失败后始终使用 SCC;无 SCC 时使用视觉\n新视觉目标要求 prob >= 0.40;所有 SCC 匹配均忽略横向位置\n自动支持传统 Mando 32/64 槽位版本", From fdc11e22ee4a15ab39b86eb422a3d7276cf739c2 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:57:30 +0900 Subject: [PATCH 26/50] speedcam: remove the fixed-camera deceleration cancel option Drop VehicleNaviDecelCancelFixed (added in ff4da401c). Fixed speed cameras are real enforcement cameras, so they go back to always decelerating: unflagged kind 0 previews classify as CLASS_HARD again, together with signal, rear and unmatched warnings, and no toggle can skip them. CLASS_FIXED and the policy's skip_fixed argument are removed; carstate no longer reads the key. The box, mobile-zone, bump, unknown and early-warning options are unchanged. A device that still stores the key just ignores it. Tests: hyundai 370 passed (fixed camera never skips with every other toggle on; signal/rear/unknown still never skip), settings schema 104, desire 25. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 2 +- docs/user/en/speed-deceleration.md | 7 ++--- docs/user/ko/settings.md | 2 +- docs/user/ko/speed-deceleration.md | 7 ++--- opendbc_repo/opendbc/car/hyundai/carstate.py | 2 -- .../opendbc/car/hyundai/navi_speedcam.py | 12 +++----- .../car/hyundai/tests/test_navi_speedcam.py | 29 +++++++++---------- .../car/hyundai/tests/test_speed_camera.py | 21 +++++--------- openpilot/common/params_keys.h | 1 - .../server/tests/test_settings_schema.py | 3 +- openpilot/selfdrive/carrot_settings.json | 21 -------------- 11 files changed, 34 insertions(+), 73 deletions(-) diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index 6b0e961c3d1..7e8aadf0191 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -187,7 +187,7 @@ Force-sensor trends do not establish loss of hand contact or driver consent to h | Section | Parameters | Purpose | |---|---|---| -| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | +| [Speed cameras](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | Event types, rear-camera post-pass hold, stock camera distance matching and virtual distance, PV5 warning-confirmed camera retention/cancellation and section speed caps, deceleration start, and target speed | | [Road speed limit](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | Desired-speed adjustment from the road limit | | [Speed bumps](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | Completion time, crossing speed, and early-release distance | | [Curves and turns](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | Curve slowing from curvature and distance, prompt recovery after confirmed easing, and route-turn speed | diff --git a/docs/user/en/speed-deceleration.md b/docs/user/en/speed-deceleration.md index ad98333382b..89b134336b9 100644 --- a/docs/user/en/speed-deceleration.md +++ b/docs/user/en/speed-deceleration.md @@ -51,7 +51,7 @@ Record the value currently shown on the device before changing anything. ## 1. Speed cameras -The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. +The related settings are `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, and `VehicleSpeedCameraDistanceTime`. ### `AutoNaviSpeedCtrlMode` @@ -93,7 +93,7 @@ The current stock warning (0x4A3) carries no camera kind. The kind of the previe | Preview kind | Navigation icon | Handling | |---|---|---| -| Fixed speed camera | Red square | Decelerates; can be cancelled when the fixed-camera kind of the deceleration cancel request is on | +| Fixed speed camera | Red square | Always decelerates; no skip | | Signal-and-speed | Traffic-light icon | Always decelerates; no skip | | Rear speed, rear signal-and-speed | — | Always decelerates; no skip (identified by the preview's rear flag) | | Mobile enforcement zone | Blue circle | Decelerates when `AutoNaviSpeedCtrlMode` is `3`; ignored at `2` or lower | @@ -103,7 +103,7 @@ A warning that also carries a fixed, signal, or rear camera always decelerates, ### `VehicleNaviDecelCancel` (deceleration cancel request) -When enabled, while engaged and one of the kinds enabled in its details is decelerating, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the cancel applies to that one camera or bump; the next one decelerates again. While disengaged, below about 18 km/h, or when that kind is not actually decelerating, `+` and the gas tap keep their normal resume/engage/set-speed roles. Signal and rear cameras, warnings without a preview and section enforcement cannot be cancelled; fixed cameras only when the fixed-camera kind is on. Off by default. +When enabled, while engaged and one of the kinds enabled in its details is decelerating, one press of cruise `+` or a gas tap (accelerator pressed for under 0.4 s) cancels that deceleration. The input does not raise the set speed (neither `+1` nor the gas-tap `+10`), and the cancel applies to that one camera or bump; the next one decelerates again. While disengaged, below about 18 km/h, or when that kind is not actually decelerating, `+` and the gas tap keep their normal resume/engage/set-speed roles. Fixed, signal and rear cameras, warnings without a preview and section enforcement cannot be cancelled. Off by default. Per-kind toggles (in the setting's detail screen, on by default so turning on the master enables all): @@ -111,7 +111,6 @@ Per-kind toggles (in the setting's detail screen, on by default so turning on th |---|---| | `VehicleNaviDecelCancelBox` | Mobile-camera box | | `VehicleNaviDecelCancelMobileZone` | Mobile enforcement zone; matters only when `AutoNaviSpeedCtrlMode` is `3`, so zones decelerate | -| `VehicleNaviDecelCancelFixed` | Fixed speed camera (preview kind 0, no rear flag). Real enforcement cameras, so **off by default** — it must be turned on separately even with the master on. Not applied inside a section, where `+` is the section unlock | | `VehicleNaviDecelCancelUnknown` | **[Experimental]** Warnings whose camera kind preview was not received or not understood. It can include signal or speed cameras that send no preview, so **off by default** and must be turned on separately from the master. Not applied at 30 km/h or below (school and senior zones) or inside a section; if a signal, rear or fixed preview is matched after the cancel, that rule applies | | `VehicleNaviDecelCancelEarlyWarning` | **[Experimental]** For roads/cars where the warning starts earlier than 6.1 m x the limit. Grows the far end of the matching window up to 1.35x so box, fixed and mobile-zone previews are found. **Risk**: a mobile-zone preview attached to the wrong warning can, with low probability, skip a real enforcement camera when the mobile-zone cancel is on or `AutoNaviSpeedCtrlMode` is 2 or lower; always verify. Off by default | | `VehicleNaviDecelCancelBump` | Stock-navigation speed bump. The input is used only while the bump target (from the speed-bump settings) is below the set speed, i.e. actually decelerating, and cancels the nearest bump only | diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 3c6e5abec84..8530aa34805 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -189,7 +189,7 @@ VW MEB(ID.4 포함)에도 수동 조향비와 학습 비율이 적용됩니다. | 세부 구역 | 파라미터 | 용도 | |---|---|---| -| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | +| [과속카메라](speed-deceleration.md#speed-camera) | `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime` | 안전운전 이벤트의 대상, 후면단속 통과 후 유지, 순정 카메라 거리 연결·가상거리, PV5 단속 상태에 따른 카메라 유지·해제와 구간단속 제한속도 유지, 감속 시점과 목표 속도 | | [도로 제한속도](speed-deceleration.md#road-speed-limit) | `AutoRoadSpeedLimitOffset`, `AutoRoadSpeedAdjust`, `AutoSpeedUptoRoadSpeedLimit` | 도로 제한속도에 맞춘 목표 속도 조절 | | [과속방지턱](speed-deceleration.md#speed-bump) | `AutoNaviSpeedBumpTime`, `AutoNaviSpeedBumpSpeed`, `AutoNaviSpeedBumpEndDistance` | 방지턱 감속 완료 시점, 통과 속도와 조기 종료 거리 | | [커브·턴](speed-deceleration.md#curve-turn) | `AutoCurveSpeedFactor`, `AutoCurveSpeedLowerLimit`, `TurnSpeedControlMode`, `MapTurnSpeedFactor`, `ApplyModelSpeed` | 곡률·남은 거리 기반 감속, 회전 완화 확인 후 빠른 복구와 경로 턴 속도 | diff --git a/docs/user/ko/speed-deceleration.md b/docs/user/ko/speed-deceleration.md index 0965807dbd7..d3a7da69a06 100644 --- a/docs/user/ko/speed-deceleration.md +++ b/docs/user/ko/speed-deceleration.md @@ -53,7 +53,7 @@ Carrot Web 기본값 복원에 쓰이는 `carrot_settings.json`과 Params 최초 ## 1. 과속카메라 -관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelFixed`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. +관련 설정은 `AutoNaviSpeedCtrlMode`, `AutoNaviSpeedCtrlEnd`, `AutoNaviRearCameraHoldDistance`, `AutoNaviSpeedDecelRate`, `AutoNaviSpeedSafetyFactor`, `AutoNaviCountDownMode`, `VehicleNaviCanControl`, `VehicleNaviSchoolZoneControl`, `VehicleNaviDecelCancel`(`VehicleNaviDecelCancelBox`, `VehicleNaviDecelCancelMobileZone`, `VehicleNaviDecelCancelBump`, `VehicleNaviDecelCancelUnknown`, `VehicleNaviDecelCancelEarlyWarning`), `VehicleNaviSectionAvgControl`, `VehicleSpeedCameraControlMode`, `VehicleSpeedCameraDistanceTime`입니다. ### `AutoNaviSpeedCtrlMode` @@ -95,7 +95,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. | 예고 종류 | 내비 표시 | 처리 | |---|---|---| -| 고정식 과속 | 빨간 사각 | 감속, 감속 중지 요청의 고정식 항목을 켜면 중지 가능 | +| 고정식 과속 | 빨간 사각 | 항상 감속, 건너뛰기 없음 | | 신호·과속 | 신호등 아이콘 | 항상 감속, 건너뛰기 없음 | | 후면 과속, 후면 신호·과속 | — | 항상 감속, 건너뛰기 없음 (예고의 후면 표시로 구분) | | 이동식 단속 구간 | 파란 원 | `AutoNaviSpeedCtrlMode`가 `3`이면 감속, `2` 이하면 무시 | @@ -105,7 +105,7 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. ### `VehicleNaviDecelCancel` (감속 중지 요청) -켜면 openpilot 작동 중 아래 세부 항목에서 켠 종류로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 중지는 그 카메라·방지턱 하나에만 적용되어 다음 것은 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만, 또는 해당 종류가 실제로 감속 중이 아닐 때는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. 신호·후면 카메라, 예고가 없는 경고, 구간단속은 중지할 수 없고, 고정식 카메라는 고정식 세부 항목을 켰을 때만 중지합니다. 기본값은 꺼짐입니다. +켜면 openpilot 작동 중 아래 세부 항목에서 켠 종류로 감속하는 동안 크루즈 `+` 버튼을 한 번 누르거나 악셀톡(가속 페달을 0.4초 미만으로 밟았다 뗌)을 해서 그 지점의 감속을 중지합니다. 이 입력은 설정 속도를 올리지 않고(`+1`, 악셀톡 `+10` 모두 반영 안 함), 중지는 그 카메라·방지턱 하나에만 적용되어 다음 것은 다시 감속합니다. 미작동 상태이거나 약 18km/h 미만, 또는 해당 종류가 실제로 감속 중이 아닐 때는 `+`와 악셀톡이 원래 역할(재개·작동·속도 설정)을 합니다. 고정식·신호·후면 카메라, 예고가 없는 경고, 구간단속은 중지할 수 없습니다. 기본값은 꺼짐입니다. 세부 항목(설정 상세 화면, 기본값 켜짐 — 상위 항목을 켜면 모두 동작): @@ -113,7 +113,6 @@ PV5 구간단속은 평균속도나 남은거리를 계산하지 않습니다. |---|---| | `VehicleNaviDecelCancelBox` | 이동식 박스 카메라 | | `VehicleNaviDecelCancelMobileZone` | 이동식 단속 구간. `AutoNaviSpeedCtrlMode`가 `3`이라 감속할 때만 의미 | -| `VehicleNaviDecelCancelFixed` | 고정식 과속카메라(예고 종류 0, 후면 표시 없음). 실제 단속 카메라라서 **기본값 꺼짐** — 상위를 켜도 이 항목은 따로 켜야 동작. 구간단속 중에는 `+`가 구간 해제에 쓰이므로 적용하지 않음 | | `VehicleNaviDecelCancelUnknown` | **[실험]** 카메라 종류 예고를 받지 못했거나 해석하지 못한 경고(CAN 미수신·미처리). 예고 없이 오는 신호·과속 카메라도 포함될 수 있어 **기본값 꺼짐**, 상위와 별도로 켜야 동작. 30km/h 이하(어린이·노인보호구역)와 구간단속 중에는 적용하지 않고, 중지 뒤 신호·후면·고정식 예고가 확인되면 그 규칙을 따름 | | `VehicleNaviDecelCancelEarlyWarning` | **[실험]** 경고가 평소(카메라 앞 6.1m×제한속도)보다 일찍 켜지는 지역·차량용. 짝 찾기 범위의 앞쪽 끝을 최대 1.35배로 넓혀 박스형·고정식·이동식 구간을 찾음. **위험**: 이동식 구간 예고가 엉뚱한 경고에 붙으면, 이동식 구간 감속 중지가 켜져 있거나 `AutoNaviSpeedCtrlMode` 2 이하일 때 낮은 확률로 실제 단속 카메라 감속을 건너뛸 수 있으니 반드시 확인. 기본값 꺼짐 | | `VehicleNaviDecelCancelBump` | 순정 내비 과속방지턱. 방지턱 감속 목표(방지턱 설정으로 계산)가 설정 속도보다 낮아 실제로 감속 중일 때만 입력을 쓰고, 가장 가까운 방지턱 하나만 중지 | diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index 6b6306b7f6e..ef58e81bab2 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -735,7 +735,6 @@ def _read_speedcam_params(self): self.speedcamSkipBox = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBox") self.speedcamSkipMobileZone = cancel and self.op_params.get_bool("VehicleNaviDecelCancelMobileZone") self.speedcamCancelBump = cancel and self.op_params.get_bool("VehicleNaviDecelCancelBump") - self.speedcamSkipFixed = cancel and self.op_params.get_bool("VehicleNaviDecelCancelFixed") self.speedcamSkipUnknown = cancel and self.op_params.get_bool("VehicleNaviDecelCancelUnknown") self.speedcam_policy.early_lead = cancel and self.op_params.get_bool("VehicleNaviDecelCancelEarlyWarning") # carrot_serv's stock-navigation bump source, mirrored to tell when a bump is actually decelerating. @@ -779,7 +778,6 @@ def _apply_speedcam_policy(self, ret, speed_limit_cam, warning_active, warning_s warning_active, int(round(warning_speed)), self.totalDistance, can_skip and (accel_rising or gas_tok), self.speedcamMobileZoneDecel, self.speedcamSkipBox, self.speedcamSkipMobileZone, # inside a section its cameras are the section's: + stays the section unlock - self.speedcamSkipFixed and not getattr(ret, "vehicleNaviSectionActive", False), self.speedcamSkipUnknown and not getattr(ret, "vehicleNaviSectionActive", False)) if consume: self._consume_speedcam_input(accel_rising, gas_tok) diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 14aeb24fda5..88995a8c1b3 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -24,8 +24,7 @@ CLASS_MOBILE_ZONE = 1 CLASS_BOX = 2 -CLASS_FIXED = 3 # unflagged fixed speed camera: skippable only with its own toggle -CLASS_HARD = 4 # signal, rear and unknown cameras always decelerate +CLASS_HARD = 3 # fixed, signal, rear and unknown cameras always decelerate # [experimental] an unknown warning (no camera preview matched) may be skipped with its own # toggle, but never at 30 km/h or below: school and senior zones warn there, often without a preview. PROTECTED_ZONE_KPH = 30 @@ -134,16 +133,14 @@ def classify(self, speed, total_distance, starting=True, start_distance=None): candidates.append(p) if not candidates: return None - if any(p["flagged"] or p["kind"] == KIND_SIGNAL for p in candidates): + if any(p["flagged"] or p["kind"] in (KIND_FIXED, KIND_SIGNAL) for p in candidates): return CLASS_HARD - if any(p["kind"] == KIND_FIXED for p in candidates): - return CLASS_FIXED if any(p["kind"] == KIND_BOX for p in candidates): return CLASS_BOX return CLASS_MOBILE_ZONE def update(self, warning_active, warning_speed, total_distance, accel_rising, - mobile_zone_decel, skip_box, skip_mobile_zone, skip_fixed=False, skip_unknown=False): + mobile_zone_decel, skip_box, skip_mobile_zone, skip_unknown=False): """Return (suppress_warning, consume_accel_button).""" if not warning_active or warning_speed <= 0: self.reset_warning() @@ -157,7 +154,7 @@ def update(self, warning_active, warning_speed, total_distance, accel_rising, self.warning_start_distance = total_distance current = self.classify(warning_speed, total_distance, starting=starting, start_distance=self.warning_start_distance) - # A warning never downgrades: a fixed camera inside a mobile zone keeps it fixed. + # A warning never downgrades: a fixed camera inside a mobile zone keeps it hard. if current is not None and (self.warning_class is None or current > self.warning_class): self.warning_class = current warning_class = CLASS_HARD if self.warning_class is None else self.warning_class @@ -167,7 +164,6 @@ def update(self, warning_active, warning_speed, total_distance, accel_rising, skippable = ((warning_class == CLASS_BOX and skip_box) or (warning_class == CLASS_MOBILE_ZONE and skip_mobile_zone) or - (warning_class == CLASS_FIXED and skip_fixed) or (self.warning_class is None and skip_unknown and warning_speed > PROTECTED_ZONE_KPH)) consume = False if skippable and accel_rising and not self.skipped: diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index caf60b0b4b6..7cc4544355c 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -1,11 +1,11 @@ from opendbc.car.hyundai.navi_speedcam import ( - CLASS_BOX, CLASS_FIXED, CLASS_HARD, CLASS_MOBILE_ZONE, SpeedcamPolicy, decode_camera_profile, + CLASS_BOX, CLASS_HARD, CLASS_MOBILE_ZONE, SpeedcamPolicy, decode_camera_profile, ) def _update(policy, distance, active=True, speed=60, accel=False, mobile_decel=False, skip_box=False, skip_mobile=False, - skip_fixed=False, skip_unknown=False): - return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile, skip_fixed, skip_unknown) + skip_unknown=False): + return policy.update(active, speed, distance, accel, mobile_decel, skip_box, skip_mobile, skip_unknown) def test_decode_uses_low_nine_bits_and_keeps_rear_flag(): @@ -44,7 +44,7 @@ def test_fixed_camera_inside_mobile_zone_always_decelerates(): policy.add_preview(0xD0, 1682, -1318.0) policy.add_preview(0xD3, 1714, -1316.0) assert _update(policy, 0.0, accel=True, skip_mobile=True, skip_box=True) == (False, False) - assert policy.warning_class == CLASS_FIXED + assert policy.warning_class == CLASS_HARD def test_precise_fixed_preview_upgrades_a_running_mobile_zone(): @@ -53,7 +53,7 @@ def test_precise_fixed_preview_upgrades_a_running_mobile_zone(): _update(policy, 0.0) policy.add_preview(0xD0, 200, 100.0) assert _update(policy, 100.0) == (False, False) - assert policy.warning_class == CLASS_FIXED + assert policy.warning_class == CLASS_HARD def test_unknown_warning_is_hard(): @@ -149,34 +149,33 @@ def test_precise_repeat_replaces_a_horizon_bound_position(): assert (p["target"], p["saturated"]) == (1990.0, False) -def test_fixed_camera_skip_needs_its_own_toggle(): +def test_fixed_camera_never_skips(): policy = SpeedcamPolicy() policy.add_preview(0xD0, 366, 0.0) # kind 0 fixed @60 at the warning lead - assert _update(policy, 0.0, accel=True, skip_box=True, skip_mobile=True) == (False, False) - assert policy.warning_class == CLASS_FIXED - assert _update(policy, 10.0, accel=True, skip_fixed=True) == (True, True) - assert _update(policy, 20.0, accel=True, skip_fixed=True) == (True, False) + assert _update(policy, 0.0, accel=True, skip_box=True, skip_mobile=True, skip_unknown=True) == (False, False) + assert policy.warning_class == CLASS_HARD + assert _update(policy, 10.0, accel=True, skip_box=True, skip_mobile=True, skip_unknown=True) == (False, False) -def test_signal_rear_and_unknown_never_skip_with_the_fixed_toggle(): +def test_signal_rear_and_unknown_never_skip(): signal = SpeedcamPolicy() signal.add_preview(0xD1, 366, 0.0) # kind 1 signal-and-speed signal.add_preview(0xD0, 366, 0.0) # with a fixed camera at the same spot - assert _update(signal, 0.0, accel=True, skip_fixed=True) == (False, False) + assert _update(signal, 0.0, accel=True, skip_box=True, skip_mobile=True) == (False, False) assert signal.warning_class == CLASS_HARD rear = SpeedcamPolicy() rear.add_preview(0x642D0, 366, 0.0) # kind 0 + rear flag - assert _update(rear, 0.0, accel=True, skip_fixed=True) == (False, False) + assert _update(rear, 0.0, accel=True, skip_box=True, skip_mobile=True) == (False, False) unknown = SpeedcamPolicy() # no preview at all - assert _update(unknown, 0.0, accel=True, skip_fixed=True) == (False, False) + assert _update(unknown, 0.0, accel=True, skip_box=True, skip_mobile=True) == (False, False) def test_unknown_warning_skip_is_experimental_and_spares_protected_zones(): # 2026-10-04 field report: a 60 km/h warning with no camera preview stayed hard and uncancellable. policy = SpeedcamPolicy() - assert _update(policy, 0.0, accel=True, skip_fixed=True) == (False, False) # toggle off: hard + assert _update(policy, 0.0, accel=True) == (False, False) # toggle off: hard assert _update(policy, 10.0, accel=True, skip_unknown=True) == (True, True) # toggle on: one press skips assert _update(policy, 20.0, accel=True, skip_unknown=True) == (True, False) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py index cb28ed2e913..cb8a08b345f 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_speed_camera.py @@ -47,7 +47,7 @@ def get_bool(self, key): if key in ("VehicleNaviDecelCancel", "VehicleNaviSectionAvgControl"): return False if key in ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", - "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown", + "VehicleNaviDecelCancelUnknown", "VehicleNaviDecelCancelEarlyWarning"): return True assert key == "VehicleNaviSchoolZoneControl" @@ -1598,25 +1598,18 @@ def test_bump_cancel_survives_a_repeated_announcement(): def test_decel_cancel_master_gates_every_kind(): state = _car_state() - assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump or state.speedcamSkipFixed + assert not (state.speedcamSkipBox or state.speedcamSkipMobileZone or state.speedcamCancelBump or state.speedcamSkipUnknown) state.op_params.get_bool = lambda key: True state._read_speedcam_params() - assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump and state.speedcamSkipFixed + assert state.speedcamSkipBox and state.speedcamSkipMobileZone and state.speedcamCancelBump assert state.speedcamSkipUnknown and state.speedcam_policy.early_lead -def test_fixed_camera_skip_outside_a_section_only(): +def test_fixed_camera_always_decelerates(): state = _speedcam_state() - state.speedcamSkipFixed = True + state.speedcamSkipBox = state.speedcamSkipMobileZone = state.speedcamSkipUnknown = True state.speedcam_policy.add_preview(0xD0, 366, 0.0) # kind 0 fixed @60 ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False, vehicleNaviSectionActive=False) - assert not state._apply_speedcam_policy(ret, True, True, 60.0, True) - assert state.speedcam_accel_swallow - - section = _speedcam_state() - section.speedcamSkipFixed = True - section.speedcam_policy.add_preview(0xD0, 366, 0.0) # a section's start/end camera is kind 0 too - ret = SimpleNamespace(vEgo=16.0, speedLimit=60.0, gasPressed=False, vehicleNaviSectionActive=True) - assert section._apply_speedcam_policy(ret, True, True, 60.0, True) - assert not section.speedcam_accel_swallow # + stays the section unlock + assert state._apply_speedcam_policy(ret, True, True, 60.0, True) + assert not state.speedcam_accel_swallow # + keeps raising the set speed diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 9a43b27537f..1a93e1b224c 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -249,7 +249,6 @@ inline static std::unordered_map keys = { {"VehicleNaviDecelCancelBox", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelMobileZone", {PERSISTENT, BOOL, "1"}}, {"VehicleNaviDecelCancelBump", {PERSISTENT, BOOL, "1"}}, - {"VehicleNaviDecelCancelFixed", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviDecelCancelUnknown", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviDecelCancelEarlyWarning", {PERSISTENT, BOOL, "0"}}, {"VehicleNaviSectionAvgControl", {PERSISTENT, BOOL, "0"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index a3e563b3665..619db6ecf0e 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -369,7 +369,6 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, master, children = "VehicleNaviDecelCancel", ("VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump") defaults = {master: 0, "VehicleNaviSectionAvgControl": 0, **{name: 1 for name in children}, - "VehicleNaviDecelCancelFixed": 0, # real enforcement cameras: opt-in even with the master on "VehicleNaviDecelCancelUnknown": 0, # experimental: kind unknown, opt-in "VehicleNaviDecelCancelEarlyWarning": 0} # experimental: wider lead window, opt-in for name, default in defaults.items(): @@ -380,7 +379,7 @@ def test_speedcam_decel_cancel_and_section_average_settings_are_opt_in(settings, # Per-kind toggles live in the master's detail screen; only the master is opt-in. assert not by_name[master].get("detail_parent") assert all(by_name[name]["detail_parent"] == master and by_name[name]["group"] == by_name[master]["group"] - for name in (*children, "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown", + for name in (*children, "VehicleNaviDecelCancelUnknown", "VehicleNaviDecelCancelEarlyWarning")) driving = next(category for category in settings["menu"] if category["id"] == "DRIVING") diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index ffe944c76ae..c7e87428bc8 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -229,7 +229,6 @@ "VehicleNaviDecelCancelBox", "VehicleNaviDecelCancelMobileZone", "VehicleNaviDecelCancelBump", - "VehicleNaviDecelCancelFixed", "VehicleNaviDecelCancelUnknown", "VehicleNaviDecelCancelEarlyWarning", "VehicleNaviSectionAvgControl", @@ -3636,26 +3635,6 @@ "detail_parent": "VehicleNaviDecelCancel", "detail_section": "bump" }, - { - "group": "감속제어", - "name": "VehicleNaviDecelCancelFixed", - "title": "감속 중지: 고정식 과속카메라", - "descr": "감속 중지 요청이 켜져 있을 때, 순정 내비가 고정식 과속카메라(예고 종류 0)로 알린 경고의 감속을 + 버튼·악셀톡으로 중지할 수 있게 합니다. 실제 단속 카메라이므로 다른 세부 항목과 달리 기본값은 꺼짐입니다. 신호·후면 카메라, 예고가 없는 경고, 구간단속 중의 카메라는 이 설정과 관계없이 중지할 수 없습니다.", - "egroup": "SPEED", - "etitle": "Deceleration Cancel: Fixed Speed Camera", - "edescr": "With the deceleration cancel request on, lets cruise + or a gas tap cancel the deceleration for a fixed speed camera (preview kind 0). Because these are real enforcement cameras, this is off by default unlike the other kinds. Signal and rear cameras, warnings without a preview and cameras inside a section can never be cancelled.", - "cgroup": "速度控制", - "ctitle": "减速中止:固定测速摄像头", - "cdescr": "减速中止请求开启时,允许用巡航 + 键或轻踩油门中止固定测速摄像头(预告类型 0)的减速。由于是实际执法摄像头,与其他类型不同默认关闭。闯红灯、后向摄像头、无预告的警告以及区间测速中的摄像头无论此设置如何都不能中止。", - "min": 0, - "max": 1, - "default": 0, - "unit": 1, - "control": "toggle", - "risk": "high", - "detail_parent": "VehicleNaviDecelCancel", - "detail_section": "camera" - }, { "group": "감속제어", "name": "VehicleNaviDecelCancelUnknown", From 1db98b7a2c168c294f3e2b39d836662708043095 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:44:57 +0900 Subject: [PATCH 27/50] hyundai: flash the cluster red while the turn model drives While the model desire is turnLeft/turnRight, send ADRV_0x161 BACKGROUND 6 (FLASHING RED), the same display carrot already uses for a held paddle, so the driver sees a turn in progress whatever started it (latched lever, automatic turn or navigation). This replaces the lever-only ALERTS_3 18 ("Check surroundings") alert from 2fd6c158f, which is removed together with its helper. modelV2.meta.leverTurn is still published (log only); the cluster no longer reads it. Tests: hyundai tests unchanged apart from the replaced test (same 56 environment-only failures before and after: Params stub, missing hypothesis); settings schema 104, desire_helper stalk 25. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- .../opendbc/car/hyundai/hyundaicanfd.py | 25 ++++++------------- .../car/hyundai/tests/test_blink_hold.py | 21 ++++++---------- openpilot/selfdrive/carrot_settings.json | 6 ++--- 5 files changed, 21 insertions(+), 35 deletions(-) diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index 980176ec5f2..2daed1c9b44 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -103,7 +103,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. - A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). -- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While a latched lever is turning the car, the cluster shows a 'Check surroundings' alert (the cluster has no turn message). +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. ## What these settings do not change diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index 17d236fdf2d..eb40eadeadf 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -103,7 +103,7 @@ BSD 분류기는 지정한 감지영역의 종횡비를 유지한 채 정사각 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. - 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. -- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 레버 고정으로 회전 중일 때는 계기판에 '주변을 확인하세요' 알림이 뜹니다(계기판에 회전 전용 문구가 없음). +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. ## 바뀌지 않는 것과 혼동하기 쉬운 설정 diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index 90f91e9af61..f434a3b5b09 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -858,8 +858,13 @@ def _hide_replaced_adas_service_warning(values): values["FAULT_DAS"] = 0 -def _select_cluster_background(cruise_enabled, lat_active, paddle_pressed, paddle_mode): - if paddle_mode > 0 and paddle_pressed: +# log.Desire turnLeft / turnRight: the turn model is driving the car. +TURN_DESIRES = (1, 2) + + +def _select_cluster_background(cruise_enabled, lat_active, paddle_pressed, paddle_mode, turning=False): + # 6 = FLASHING RED: a held paddle (PaddleMode) and, whatever started it, the turn model + if turning or (paddle_mode > 0 and paddle_pressed): return 6 return 1 if cruise_enabled else 3 if lat_active else 7 @@ -875,18 +880,6 @@ def _apply_cluster_lane_lines(values, CS, lat_active, desire): _apply_lane_desire(values, desire) -# ADRV_0x161 ALERTS_3 18 "CHECK_SURROUNDINGS" (the cluster has no "turn" message). ADRV_0x1ea AUTOLANECHANGE_MSG -# was tried first and the cluster did not show it (2026-10-05 route 00000494: sent 1 with HDA_MODE2 1, nothing shown). -LEVER_TURN_ALERT = 18 - - -def _apply_lever_turn_msg(values, md): - """BlinkerLatchedTurn: while a latched lever makes the turn desire, show a cluster alert (unless one is up).""" - meta = getattr(md, "meta", None) if md is not None else None - if getattr(meta, "leverTurn", False) and values.get("ALERTS_3", 0) == 0: - values["ALERTS_3"] = LEVER_TURN_ALERT - - def _normalize_cluster_corner_objects(values, *, ccnc=False): # EV5 can report 0x162 corner geometry with DETECT=0 while 0x1ea marks it visible. # Use nonzero corner distance to show a gray car, including those hidden types. @@ -1052,7 +1045,7 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["TARGET_DISTANCE"] = lead_distance values["BACKGROUND"] = _select_cluster_background( - cruise_enabled, lat_active, CS.paddle_button_prev > 0, paddle_mode, + cruise_enabled, lat_active, CS.paddle_button_prev > 0, paddle_mode, turning=desire in TURN_DESIRES, ) values["CENTERLINE"] = 1 if HDA_CntrlModSta > 0 else 0 values["CAR_CIRCLE"] = 2 if hdp_active else 1 if cruise_enabled else 0 @@ -1082,8 +1075,6 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, if values["ALERTS_5"] in [11] and CS.softHoldActive == 0: values["ALERTS_5"] = 0 - _apply_lever_turn_msg(values, md) - # curvature 표시(0x161쪽 기존 로직 유지) _suppress_trailer_mode_warning(values, CS) diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py index 006e75e749a..afdd897eed8 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_blink_hold.py @@ -1,6 +1,6 @@ from types import SimpleNamespace as NS -from opendbc.car.hyundai.hyundaicanfd import _apply_lever_turn_msg, _blink_hold_side +from opendbc.car.hyundai.hyundaicanfd import _blink_hold_side, _select_cluster_background def _md(y_left, y_right): @@ -63,15 +63,10 @@ def test_blink_hold_request_overrides_the_release_of_the_current_change(): assert _blink_hold_side(md, 3, state) == 3 -def test_lever_turn_shows_the_cluster_alert_only_when_flagged(): - values = {"ALERTS_3": 0} - _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=False))) - assert values["ALERTS_3"] == 0 - _apply_lever_turn_msg(values, None) - _apply_lever_turn_msg(values, NS(meta=NS())) # older schema - assert values["ALERTS_3"] == 0 - _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=True))) - assert values["ALERTS_3"] == 18 # CHECK_SURROUNDINGS - values = {"ALERTS_3": 2} # another alert is up: keep it - _apply_lever_turn_msg(values, NS(meta=NS(leverTurn=True))) - assert values["ALERTS_3"] == 2 +def test_turn_model_flashes_the_cluster_red_like_a_held_paddle(): + assert _select_cluster_background(True, True, False, 0, turning=True) == 6 # FLASHING RED + assert _select_cluster_background(False, True, False, 0, turning=True) == 6 # lateral only too + assert _select_cluster_background(True, True, True, 2) == 6 # held paddle, unchanged + assert _select_cluster_background(True, True, False, 2) == 1 # cruise: blue + assert _select_cluster_background(False, True, False, 0) == 3 # lateral only: orange + assert _select_cluster_background(False, False, False, 0) == 7 # gray diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 649bc2f5173..a30d84e3aa2 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -2413,13 +2413,13 @@ "group": "조향일반", "name": "BlinkerLatchedTurn", "title": "깜빡이 레버로 회전/차선변경 구분 (km/h, 0=끔)", - "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다. 레버 고정으로 회전 중일 때는 계기판에 '주변을 확인하세요' 알림을 띄웁니다.", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다.", "egroup": "LAT", "etitle": "Turn-signal lever selects turn / lane change (km/h, 0=off)", - "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change. While a latched lever is turning the car, the cluster shows a 'Check surroundings' alert.", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change.", "cgroup": "转向", "ctitle": "转向灯拨杆区分转弯/变道 (km/h, 0=关)", - "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。锁定拨杆转弯时,仪表盘显示“请确认周围情况”提示。", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。", "min": 0, "max": 80, "default": 0, From 71162d3d7584cc6d337f1be70b3630439a8dbefd Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:40:53 +0900 Subject: [PATCH 28/50] selfdrived: say "Turn" and "Straight" around the turn model When the turn desire (turnLeft/turnRight) has held for 0.2 s, play a "Turn" voice prompt; 0.5 s after it ends, play "Straight". The debounce keeps one-frame desire flickers and short gaps inside one turn silent. Prompts are PERMANENT voice-only alerts gated on carControl.latActive, because 49 of 53 lever turns in route 000004a2 (10-09) ran with lateral only, where WARNING alerts are dropped. New AudibleAlert turnModel @38 / turnModelEnd @39 and EventName turnModel @130 / turnModelEnd @131. English TTS clips live in sounds_eng, which every language falls back to. Replaying the 10-09 drive: 53 raw turn onsets -> 51 Turn/Straight pairs. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- opendbc_repo/opendbc/car/car.capnp | 2 + openpilot/cereal/log.capnp | 2 + .../assets/sounds_eng/audio_straight.wav | Bin 0 -> 20052 bytes .../assets/sounds_eng/audio_turn_model.wav | Bin 0 -> 14922 bytes openpilot/selfdrive/controls/beep.py | 3 +- openpilot/selfdrive/selfdrived/events.py | 9 ++++ openpilot/selfdrive/selfdrived/selfdrived.py | 9 ++++ .../selfdrived/tests/test_turn_prompt.py | 51 ++++++++++++++++++ openpilot/selfdrive/selfdrived/turn_prompt.py | 47 ++++++++++++++++ openpilot/selfdrive/ui/soundd.py | 2 + 12 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 openpilot/selfdrive/assets/sounds_eng/audio_straight.wav create mode 100644 openpilot/selfdrive/assets/sounds_eng/audio_turn_model.wav create mode 100644 openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py create mode 100644 openpilot/selfdrive/selfdrived/turn_prompt.py diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index 2daed1c9b44..f56e932b74a 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -103,7 +103,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. - A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). -- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. With lateral control active, a "Turn" voice prompt plays once the turn has held for 0.2 s and "Straight" plays 0.5 s after it ends (also when only lateral is engaged). - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. ## What these settings do not change diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index eb40eadeadf..5e60ac13749 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -103,7 +103,7 @@ BSD 분류기는 지정한 감지영역의 종횡비를 유지한 채 정사각 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. - 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. -- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). 조향이 켜져 있으면 회전이 0.2초 이어질 때 "Turn", 끝나고 0.5초 지나면 "Straight" 음성이 한 번씩 나옵니다(인게이지 없이 조향만 켜진 상태 포함). - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. ## 바뀌지 않는 것과 혼동하기 쉬운 설정 diff --git a/opendbc_repo/opendbc/car/car.capnp b/opendbc_repo/opendbc/car/car.capnp index 23f9065b85c..ace4d5d06f0 100644 --- a/opendbc_repo/opendbc/car/car.capnp +++ b/opendbc_repo/opendbc/car/car.capnp @@ -574,6 +574,8 @@ struct CarControl { radarCutin @35; radarStationaryLead @36; systemReady @37; + turnModel @38; # carrot: the turn model (turn desire) takes over + turnModelEnd @39; # carrot: the turn desire ended, straight driving again } } diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index cdc540cefbc..22a2fd9c739 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -164,6 +164,8 @@ struct OnroadEvent @0xc4fa6047f024e718 { systemReady @127; impactDetected @128; impactDashcamReboot @129; + turnModel @130; + turnModelEnd @131; soundsUnavailableDEPRECATED @47; } diff --git a/openpilot/selfdrive/assets/sounds_eng/audio_straight.wav b/openpilot/selfdrive/assets/sounds_eng/audio_straight.wav new file mode 100644 index 0000000000000000000000000000000000000000..3bcb7cebbf133d82c4ad713bce7b9a837d78ee5e GIT binary patch literal 20052 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¨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 literal 0 HcmV?d00001 diff --git a/openpilot/selfdrive/controls/beep.py b/openpilot/selfdrive/controls/beep.py index aedca8236c9..3e284f3c0bc 100755 --- a/openpilot/selfdrive/controls/beep.py +++ b/openpilot/selfdrive/controls/beep.py @@ -97,7 +97,8 @@ def update_alert(self, new_alert): self.dispatch_beep(self.warning) elif new_alert in [AudibleAlert.longEngaged, AudibleAlert.longDisengaged, AudibleAlert.trafficSignGreen, AudibleAlert.trafficSignChanged, AudibleAlert.trafficError, AudibleAlert.bsdWarning, AudibleAlert.laneChange]: self.dispatch_beep(self.ding) - elif new_alert in [AudibleAlert.stopStop, AudibleAlert.stopping, AudibleAlert.autoHold, AudibleAlert.engage2, AudibleAlert.disengage2, AudibleAlert.speedDown, AudibleAlert.audioTurn, AudibleAlert.reverseGear]: + elif new_alert in [AudibleAlert.stopStop, AudibleAlert.stopping, AudibleAlert.autoHold, AudibleAlert.engage2, AudibleAlert.disengage2, AudibleAlert.speedDown, AudibleAlert.audioTurn, AudibleAlert.reverseGear, + AudibleAlert.turnModel, AudibleAlert.turnModelEnd]: self.dispatch_beep(self.dong) elif new_alert in [AudibleAlert.audio1, AudibleAlert.audio2, AudibleAlert.audio3, AudibleAlert.audio4, AudibleAlert.audio5, AudibleAlert.audio6, AudibleAlert.audio7, AudibleAlert.audio8, AudibleAlert.audio9, AudibleAlert.audio10]: diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index ab63d144e32..173fb088cd9 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -1130,6 +1130,15 @@ def car_parser_result(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMast EventName.audioTurn: { ET.WARNING: EngagementAlert(AudibleAlert.audioTurn), }, + # Voice only, and also while only lateral is active (most lever turns happen without longitudinal). + EventName.turnModel: { + ET.PERMANENT: Alert("", "", AlertStatus.normal, AlertSize.none, Priority.LOW, + VisualAlert.none, AudibleAlert.turnModel, .2), + }, + EventName.turnModelEnd: { + ET.PERMANENT: Alert("", "", AlertStatus.normal, AlertSize.none, Priority.LOW, + VisualAlert.none, AudibleAlert.turnModelEnd, .2), + }, EventName.trafficSignGreen: { ET.WARNING: EngagementAlert(AudibleAlert.trafficSignGreen), #ET.WARNING: Alert( diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 06ab6220d8e..60c3bba26f6 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -22,6 +22,7 @@ from openpilot.selfdrive.selfdrived.events import Events, ET, EmptyAlert from openpilot.selfdrive.selfdrived.helpers import ExcessiveActuationCheck from openpilot.selfdrive.selfdrived.state import StateMachine +from openpilot.selfdrive.selfdrived.turn_prompt import TURN_DESIRES, TurnPrompt, TurnPromptTracker from openpilot.selfdrive.selfdrived.impact_detector import ImpactDetector from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert from openpilot.selfdrive.controls.lib.cutin_alert import ( @@ -152,6 +153,7 @@ def __init__(self, CP=None): self.rk = Ratekeeper(100, print_delay_threshold=None) self.atc_type_last = "" + self.turn_prompt = TurnPromptTracker() # some comma three with NVMe experience NVMe dropouts mid-drive that @@ -366,6 +368,13 @@ def update_events(self, CS): self.events.add(EventName.audioTurn) self.atc_type_last = atc_type + turn_prompt = self.turn_prompt.update(self.sm['modelV2'].meta.desire.raw in TURN_DESIRES, + self.sm['carControl'].latActive, self.sm.frame * DT_CTRL) + if turn_prompt == TurnPrompt.TURN: + self.events.add(EventName.turnModel) + elif turn_prompt == TurnPrompt.STRAIGHT: + self.events.add(EventName.turnModelEnd) + # Handle lane change if self.sm['modelV2'].meta.laneChangeState == LaneChangeState.preLaneChange: direction = self.sm['modelV2'].meta.laneChangeDirection diff --git a/openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py b/openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py new file mode 100644 index 00000000000..13915773685 --- /dev/null +++ b/openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py @@ -0,0 +1,51 @@ +from openpilot.selfdrive.selfdrived.turn_prompt import TURN_ENTER_S, TURN_RELEASE_S, TurnPrompt, TurnPromptTracker + +DT = 0.01 + + +def _run(tracker, turning, lat_active, t0, duration): + prompts = [] + t = t0 + while t < t0 + duration - 1e-9: + p = tracker.update(turning, lat_active, t) + if p is not None: + prompts.append((round(t, 2), p)) + t += DT + return prompts, t + + +def test_turn_then_straight_once(): + tr = TurnPromptTracker() + p1, t = _run(tr, True, True, 0.0, 3.0) + p2, _ = _run(tr, False, True, t, 2.0) + assert [x[1] for x in p1] == [TurnPrompt.TURN] + assert abs(p1[0][0] - TURN_ENTER_S) < 0.02 + assert [x[1] for x in p2] == [TurnPrompt.STRAIGHT] + assert abs(p2[0][0] - (t + TURN_RELEASE_S)) < 0.02 + + +def test_desire_flicker_says_nothing(): + tr = TurnPromptTracker() + p1, t = _run(tr, True, True, 0.0, TURN_ENTER_S / 2) + p2, _ = _run(tr, False, True, t, 2.0) + assert p1 == [] and p2 == [] + + +def test_short_gap_inside_one_turn_keeps_turning(): + tr = TurnPromptTracker() + p1, t = _run(tr, True, True, 0.0, 1.0) + p2, t = _run(tr, False, True, t, TURN_RELEASE_S / 2) + p3, t = _run(tr, True, True, t, 1.0) + p4, _ = _run(tr, False, True, t, 1.0) + assert [x[1] for x in p1 + p2 + p3 + p4] == [TurnPrompt.TURN, TurnPrompt.STRAIGHT] + + +def test_no_prompt_without_lateral_and_losing_lateral_ends_silently(): + tr = TurnPromptTracker() + p0, t = _run(tr, True, False, 0.0, 2.0) + assert p0 == [] + p1, t = _run(tr, True, True, t, 1.0) + p2, t = _run(tr, True, False, t, 0.5) + p3, _ = _run(tr, False, True, t, 2.0) + assert [x[1] for x in p1] == [TurnPrompt.TURN] + assert p2 == [] and p3 == [] diff --git a/openpilot/selfdrive/selfdrived/turn_prompt.py b/openpilot/selfdrive/selfdrived/turn_prompt.py new file mode 100644 index 00000000000..60e87c698e2 --- /dev/null +++ b/openpilot/selfdrive/selfdrived/turn_prompt.py @@ -0,0 +1,47 @@ +from enum import Enum + +# log.Desire turnLeft / turnRight: the turn model is driving the car. +TURN_DESIRES = (1, 2) + +TURN_ENTER_S = 0.2 # a one-frame desire flicker must not say "Turn" +TURN_RELEASE_S = 0.5 # short gaps inside one turn must not say "Straight", "Turn" + + +class TurnPrompt(Enum): + TURN = "turn" + STRAIGHT = "straight" + + +class TurnPromptTracker: + """Say "Turn" once when the turn desire has held for TURN_ENTER_S and "Straight" once when it has been gone + for TURN_RELEASE_S. Only while lateral control is active; losing it ends the turn silently.""" + + def __init__(self): + self.announced = False + self.on_since: float | None = None + self.off_since: float | None = None + + def update(self, turning: bool, lat_active: bool, now: float) -> TurnPrompt | None: + if not lat_active: + self.__init__() + return None + + if turning: + self.off_since = None + if self.on_since is None: + self.on_since = now + if not self.announced and now - self.on_since >= TURN_ENTER_S: + self.announced = True + return TurnPrompt.TURN + return None + + self.on_since = None + if not self.announced: + return None + if self.off_since is None: + self.off_since = now + if now - self.off_since >= TURN_RELEASE_S: + self.announced = False + self.off_since = None + return TurnPrompt.STRAIGHT + return None diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index ea73f0c0712..9d5749faa87 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -63,6 +63,8 @@ AudibleAlert.disengage2: ("audio_disengage.wav", None, MAX_VOLUME), AudibleAlert.speedDown: ("audio_speed_down.wav", None, MAX_VOLUME), AudibleAlert.audioTurn: ("audio_turn.wav", None, MAX_VOLUME), + AudibleAlert.turnModel: ("audio_turn_model.wav", 1, MAX_VOLUME), + AudibleAlert.turnModelEnd: ("audio_straight.wav", 1, MAX_VOLUME), AudibleAlert.reverseGear: ("reverse_gear.wav", 1, float(Params().get_int("SoundVolumeAdjustEngage"))/100.), AudibleAlert.audio1: ("audio_1.wav", None, MAX_VOLUME), AudibleAlert.audio2: ("audio_2.wav", None, MAX_VOLUME), From 64f4d27e4a6f9a073558ebd126f6d69961dadaa3 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:26:39 +0900 Subject: [PATCH 29/50] speedcam: a horizon-bound fixed camera upgrades a running mobile-zone warning One continuous stock warning can cover a mobile zone and then a fixed camera of the same speed. The zone classified the warning at its start; the fixed camera preview had arrived at the ~2 km horizon (saturated) and saturated previews only matched at a warning start, so the whole warning stayed a zone and was ignored up to the fixed camera. Route 000004b0 (2026-10-11 23:36:31, 100 km/h): zone +622 m, section start camera +1498 m, the driver braked 100 m before the camera at 111 km/h. During a running warning a saturated fixed/signal/rear preview within the lead window now upgrades to hard, but only once the box/zone that started the warning is behind, so the next camera's own preview cannot take over a box warning. Replay: 10-11 drives, 43 warnings -> only 23:36:31 changes (zone suppressed until 798 m before the camera instead of throughout). camera_bulk_20260930 (31 routes, 209 warnings) -> 7 changes, all upgrades: 3 more zone + fixed-at-the-end cases (+620 / +1126..1148 m), 4 unknown -> hard (unchanged behaviour). Co-Authored-By: Claude Opus 5.5 --- .../opendbc/car/hyundai/navi_speedcam.py | 11 +++++++++ .../car/hyundai/tests/test_navi_speedcam.py | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py index 88995a8c1b3..b6d83375011 100644 --- a/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/navi_speedcam.py @@ -118,6 +118,7 @@ def classify(self, speed, total_distance, starting=True, start_distance=None): far = max(far, lead * EARLY_LEAD_FACTOR) zone_far = max(zone_far, lead * EARLY_LEAD_FACTOR) candidates = [] + late_hard = [] for p in self.previews: if p["speed"] != speed or total_distance - p["received"] > RECENT_PREVIEW_M: continue @@ -127,10 +128,20 @@ def classify(self, speed, total_distance, starting=True, start_distance=None): ahead = p["target"] - total_distance if p["saturated"]: matched = starting and lead - tolerance <= ahead <= far + # A horizon-bound fixed/signal camera may still upgrade a running warning (the safe direction): + # one continuous warning can cover a mobile zone and then a section-start camera (2026-10-11 23:37). + if (not starting and not matched and (p["flagged"] or p["kind"] in (KIND_FIXED, KIND_SIGNAL)) and + -MATCH_BEHIND_M <= ahead <= far): + late_hard.append(p) else: matched = -MATCH_BEHIND_M <= ahead <= far if matched: candidates.append(p) + # ... but only once the box/zone that started it (one lead ahead of the start) is behind, so the + # next camera's preview cannot take over a warning that is still about its own box. + if late_hard and not any(p["speed"] == speed and not p["flagged"] and p["kind"] in (KIND_BOX, KIND_MOBILE_ZONE) and + total_distance < p["target"] <= start_distance + far for p in self.previews): + candidates += late_hard if not candidates: return None if any(p["flagged"] or p["kind"] in (KIND_FIXED, KIND_SIGNAL) for p in candidates): diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py index 7cc4544355c..ab2ee802c2b 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_navi_speedcam.py @@ -38,6 +38,30 @@ def test_saturated_preview_matches_only_at_warning_lead(): assert policy.warning_class == CLASS_MOBILE_ZONE +def test_horizon_bound_fixed_camera_upgrades_a_running_zone_after_the_zone(): + # 2026-10-11 23:36:31: one continuous 100 km/h warning covered a mobile zone (+622 m) and then a + # section-start fixed camera (+1498 m); both previews arrived at the ~2 km horizon (saturated). + policy = SpeedcamPolicy() + policy.add_preview(0x153, 1988, -1366.0) # zone @100, target +622 + policy.add_preview(0x150, 1990, -492.0) # fixed @100, target +1498 + assert _update(policy, 0.0, speed=100) == (True, False) # zone, ignored below mode 3 + assert _update(policy, 600.0, speed=100) == (True, False) # still in the zone + assert _update(policy, 700.0, speed=100) == (True, False) # zone passed, camera 798 m > lead window + assert _update(policy, 780.0, speed=100) == (False, False) # camera within the lead window: decelerate + assert policy.warning_class == CLASS_HARD + + +def test_next_cameras_horizon_preview_does_not_upgrade_a_box_still_ahead(): + # 2026-10-11 23:56:56: a 60 km/h box warning (+364 m) and the next signal camera (+756 m, own warning). + policy = SpeedcamPolicy() + policy.add_preview(0xD2, 1999, -1635.0) # box @60, target +364 + policy.add_preview(0xD1, 1997, -1241.0) # signal @60, target +756 + _update(policy, 0.0) + assert policy.warning_class == CLASS_BOX + _update(policy, 330.0) # signal within the lead window, box 34 m ahead + assert policy.warning_class == CLASS_BOX + + def test_fixed_camera_inside_mobile_zone_always_decelerates(): # 18:54 drive: fixed 364 m and mobile zone 398 m ahead of the same 60 km/h warning. policy = SpeedcamPolicy() From 2bec4fd9dd5ea6e688e94577f7e54a2c4801aaa4 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:17:24 +0900 Subject: [PATCH 30/50] sounds: neural "Turn" / "Straight" voice for the turn model Replace the Windows SAPI (Zira) clips, which sounded robotic next to the other prompts, with piper en_US-lessac-medium (offline neural TTS, 22.05 kHz kept; soundd resamples). 22 ms lead / 40 ms tail like the existing prompts, loudness set to their median voiced RMS (2955; audio_turn/turn2/stopping/lane_change/engage). Picked by the user from four candidates. Co-Authored-By: Claude Opus 5.5 --- .../assets/sounds_eng/audio_straight.wav | Bin 20052 -> 18636 bytes .../assets/sounds_eng/audio_turn_model.wav | Bin 14922 -> 16458 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/openpilot/selfdrive/assets/sounds_eng/audio_straight.wav b/openpilot/selfdrive/assets/sounds_eng/audio_straight.wav index 3bcb7cebbf133d82c4ad713bce7b9a837d78ee5e..4161a537a2493aa26bf36e624fefb26398de0f85 100644 GIT binary patch literal 18636 zcmbt+1$Y$6^Jw?XtV=eoBq2Bi4-SXByTcvs?ta)I!QJif;C46%!47u_arb0>$GTrF zdHL?{|9q^q33ip-A!dW8g)f+^bh)i-lQqC1`2|^m(&}rM~k4p zL~5rF+5yc+6;T4zzeyjUI=CG=L+{g9(7Q~h(4VOSbwVsaSQ{On`D8VXMCa%d`kpSP z^P$D=P~H(0&=_h&ebE#&5&5I4=mAPbi{a`${RHi_LMPD@^ob_ZwgBr&`i^d=k@OAi zhRi>n7Ey_|MCtS~eM+0sQ}ib4h3-S$<}kJs0PAyFK%-DF+KkMoDVjkq({X46s)-)K zXz$Z@s65K2R@$0=A*pm8I!~+7$+Q(}jJF|5A^~e3sEsa$_gkV!JRc395$GoRi!P@* z^f}<|4O)S8s0jL%=m}(@4@dy@g)vt{S+pK)Nyno5D2+~}ZD}g01EYVA9;4al4*CW6 zM$1VARp1FY9W_8>0GcS&A5}n2P&t6|IcW&^>5X2|Wq`wqxCS=U@pJ(tgeMm$hkOA~ zZO|Zen^vJ}^asiYtcC(UQ(?4M&?9sMa5E6MMXSj=dIoTN1}Tw4enR|kGi0Fq&>s9A zokGh{F1aj=M1^C~XnKa0M-OOcq{3$aLk&?I+KU=!FA{^MvIlWKDND2I0OW!ZS!s8g zOls0_^c3dLi+&^~s-gL4HFJqhlfTex{0e_ZyNHsUfw{U))}wc5H=2NVQbevJ7p_64 zk}>!?^PZ;5t5F>G15BM@nqzNyC^?2>X^wmV9YWQ~bezquCQdP(dgFZ5fK9_QK$a82-<^{?o-^rh3K6=j_#+B&}`Jg-?A;QS8UmWJb|SbQ@_(-lB!<8Jt4`&<0vdT1DD2V}R1TV|Y$F&}5XxbVoXAKYh=R z#kc85I#j+dpFtDY>r9aRT5N$@pt>lI38Ot_5q}`F#a)bzb&+*Qf*Fpbo-~_yFobNC zJrs6s3F(SXFdcyU?$R%4Fq1>8Ln8&>5)iMr73c6T1~nm z-(|DVO?egWkA_MIC}keeRA~*~tXzklxduyr;aV)KIE7}3pO`v|G}i&XoVrjsUgGFT zZaul~Dog^KXRW`WEFV0beI(4 zV|1BZlZ-^S*$K>CIa(e~6R3uLjqeKW<<6=R%0W_jVFP-LYD*b7SN(+XC6{QfP*I$# zNK>3fcbP|Mx;TwE@B;LWen#oI9<3l;kxg786{K#=Npx6x$JSMvgwOVHbVad-+r=HA ztDO~Go5^ABxk`c8x$4PdxRcCwRD>F%RP>6u%q~Ew!UH*o9Uxo9Ty8Y8UaHOZV4VDY z(jF6j9ZJ#$VAhpKZ{Q?;8%a|hrPlLtYX(so=y%~!XL&8+v ziU;y?@`z^0^+{jZEXS}N*iZ5sG?Xin8Gf+Lu!Jc~_t53+Ol{&6l%5T5$4sT9KKL{Ba_l zMMC5x@_`(uT4p~kj~_GL@oG6%YDB*=&DjIwh`bKJr&cK*8@b1HDoSMb(nxuz{9Jy4 z^@_Jl7u*Qllk#PjX@>gCqe*)@2c&l$6owb$ji?`fiMP{pa-66ZZ_*`P8Kx51O4gvm z=n_4L)-X%)CVGOLkV2$~qz@X22eXk(0F5D@)FR)NJL65<8}z4yLALD?2FMebr)U7v zK_SyJa#Oi0lZm^dmmvR= z=?uv#x8o+@xnvJF4FB%hN|cHP=oi`qSEPTEt@IIXfR};pYs1(GE869{audEc3F17M zI?Myk2ltkvk%`?){a~gql3k=BK8+`!6n2^NDC3RJqot&$98a&%o3bACSTU-OH-P3G zgdUM6BpSFbR-Q;(&^mGu=%hJh7@B|{$@A!PJP55sV^CFk8>Ht%ng>#AAl*os%7f5T zZZXaV>W@O@*bSIulJ;*QQ0QJQ6aAll==F=hQI;P|jxy{_d zr{po@Ic-Ts%H2tKbOvt$z1#yGKx^@OWczOvP1?_qXwMS8K*5a??Bq8%6+7l z@^iq!7b#jUqE%Rpf@5WRmaHIG$UV7}bVsUBW}#8sC}kC{Iv$5E;{Ya>ZX%-qtF1^E z&}ZeTlCgk>s0+Ooqc5mBy($?%>rI#6lEvsaZG;aq`#~=?Wcn~$Q6+jrij_W72IZ4< znnn@mXA#$7Zla&ia$E%Oo{*oBhw^x|9c1PW+6Uhx+sO{V*%91>9fy+S*JL~aO$;`} z9oiT#!E3M+M%M}SvPe2nAG$m&rZo4XM1m7#YwEREY0UE8)<5vW^6zU&&Ir zEcq@kpf!Le)mXSYdy)j8k=-N&wZ?I1Cs;+BNJA1$XQLFf z1FV4C^aeVH;^ZLG2v5Zq&}m!~W`8_Vpev*XiIwjW9caO$KxtD+Mfnn`i(>F%u9!_k zb8$ze9gUT@f<5yaSXceAmidVG&|l~(dJIj)nKX!gMBDKkw1l!?LnM-3!>Bulg-8ZkR9mbJo!R4fwpWUmm?q06Fd)WOoUka zkbEOnc_OI{7KR#o;>L6~*-WR?&$Jnn$)w{*CKlw(F|w5UgT?ucE<;6l9n+Ml%G|;C zQ7zPkN^&1El^Ur;B9V)xlP9zotf!x0l$&W)+8fGm%c9g-)U2Wy7?+|f|S|6Vx2vz{PkvGm~k8FVi*Tm7FIJBmTr9^P~-GiG!H(%zE4v z7a(smjm{u8X_e$7Z<61U0vP!bMq<(!7k=#Ka+xQgjhpluSt&1*>PWtFD2>34nX`<8smC^9qnTiQ zh#n4}vL0E@OS9*o0qB8mn(?xuWCYASJ3S81j6OcKD-@IiZk?$)CxG>#0D zUy7_~5n76`#mQ2bT$QY%2T^6<&=t4?eg@XpDLR(!gVoM-q{oFgiP^;la%(xlEmFKu z#3}wz3{)g>ce!MaQ_NH}RASW`)f-hC)pF%g#dxj`I}vc(jEQ8ffmApOtAX)g+vdt& zrTgL(A&9@?WE^X353J{`jch8r%RbPVwb$)l8as2FfWk+_s&1Chp zc$?c6zb_h5^uBO>p|Y@l!S4JK`ThmTg_q1-?aO&TvYuJ4jMWtDvh+<2iw$QC7Yz># zfyPuL@vxctdRF&*ZJK71JQ{cyjpy~PbWb%k)svKyxba|B#{iYiBojz9NX*5gD=Cos z$(N=25*5`_6KT5?B%hM~NC#4%Xi2erS$0UX#LE0Hj+xe-#X*G)^7VNabK`Qw+_$;I za*J|M?vvcU`5A>1EXN%~M1j6yH!9ny+h|118SQSJ-e5LHct(5Q_if}K=YP`wx!+RX zPu^cW-+5#kD(ipM-crXXqq!KSE$HGuXl?q8EFl%hGI@>ET`VI^=4bH=!7PX(FCmgk zdV|$nl_W{~0B?s}YaJbItu4=sHx_j&EK`t~w=TD0&e5#M%vBk6GW%pd%5xR%v~A*} zNhaf|EKsR58f^z%uKuy{tSQB7o^QJU;lPnWoq`eqI|nulSmam5XP#%Gakjol(?WHS zo5*mWH8bb|I)Ex+^*0?X`DfA|@vP8Ls4nyuRtk@Wp<;Wfwd_y!l5|oVR)UvEd$~xE zox%1AmIpt$3IjrS{XmG8{H8@;G9`UYop^`UVDs2B((U5tbMB zOW2f9T^XO?o&kUQI88$hFEo{vix?kzNxCLp63a^mq;mkzb1_Hwop0%??#y+3cE0B8 zN>fQ=v<-BFflXu#j0)EQdDN1YCDo_H>Xw?NXd0*7wU=f$WrMlf6!6SI-~3l;wRvh`A!Fctuviz zz_dpnrB|*Ow!_8!^Xp|#O7D`gDRIO14PTFZ&iULe?qu?X?0)7f{tRxUj@556t~bsx zZt&RPmE+ency8F-a_1}lTDf246BRm?eHl^|aK-n47xJ)co2mr%1&Wqu3asm$50#zASV1) z`ID7LRMSRARJ~H+d6+4vi*HlU0fteU6h#9z7+0aba;{K`&v$-uoU^yI?XnCpH!%0J zT(w!9Tf|;8g}JWisOq6oD2-fGrV}27UHA{?H+CyGPhsI2Gsk7#*~1c9;Gf+;ZBNqP z_?B^7zFzzM`s3)2TRzk8SJJN+)_2vx{+b)c98bHK!n={rul^lF&Xk>6@j;b$)ec3@ zt@fc(Sj3~?4t`-?%Z!_}gOppD?X-hDM~vh9xGat<_6@dy)?D+U;r=z;scEMRHaSP(*4ix7OP>jTL;TkUt|+rBVs#~F)!4}B>gGsPb!qw8 zW#arK&pyUa+A69TR*hTI6>>lEfh*JA%v#rcv*<FqhbMTrQsrW^fZh4d$X&jPe@KpXdeGAPW5f-=OrJe zzdQ7<$)|JiRk9{p&(N2eAkRm>PyOo!SOWS6*9(7Ev3q3On#Nk+Y8pF|!gJG3UP$8}}%8-=&SKDCo;XX}+&UXyrP8!?zW(H&iE(oj>*fwZr=#mOCk)3Owt&>&jRiwJoPhsi40S2rJ<5HP* zIFUw?&5}*v_~wqMmexi4@^|Jn%kNd#(9+yBfUZ^irm<;LG_8PR&#~vZ1*&)22xAq` zQC=5J^Yz)vJGdG7Cf??sIA7Q|TBjBJ=FiEhoz^$$L43vUwy%{xkNfcb?X)+>w;MnF z`Mpn$)iq2R@6pD0q5lnkFaO&9C@4ID+#FHZCGUKWG51maZ1W4JgM=tfsRK1S)vw%W)|=a@e5GyS(bj8{ z_en3cDOLACX~7b?BHxwzi}iVbN2FzM!Oz(}(pn|izEA(w_UorF<-YX%y!F$cA7elE z`_eqoH@AgL<|Lz+Uz4E0Ar;CTEb}gGNcnPAV{0;XJJ+dMqe|sTWv>M}yq_DMYo4k6 zlt)>fo)9a!432zTjy2x=s<3W;jl9PB3yWsjmI-^2L19r|Qbs6luuZwCs&V?to{Y~C zpU&RxO)d0TU4dJQ+n@{7Nt(!a_%Qp-;=_3zGM&lq<2S_Z_*U!N>~Gq*CUN9j($`O) zdwzC&yOHWyER$Zkc<)Vt_d{~a_=ipm-5Y+lLf6RZwI|kb)qGQRQhDFdYyLT=MD3r- zK#t(ubg^`qZ|K_QJm+|B+iN~kurD_v7i=c;D`zxGXAP=Xst3wbisyJtXN~QB z+WBwtyW+Lk@K8NU@szF2HfA5Q$Jp7}S03)%Z(fkE&K{gTGG$QGr^E?Ks^kvI+ml8n z+=%P`_1>ppALoC`Os-b6l^oac-uZsMe!TY!ulYV#0*{p4TlGQh@loD&-cKogaE))hi`dKd(`xi##oAUw-CtFrH!=<+1-yTG3lCmTU{lXz_ zfN6`*Vqc5bT4R*q zP9BjoD6w$@`Cjq+sWJYF4e* zuKZ7B=KIh0uHZS*V~L@^Zi#w3S0CBLFjphHpS7LYSp2r=b#V>rKt~vVM2HoZ3gyK1 zat^wyc%qFqDokTME`T(-s2QNSq>a@#F+MUN-ECzo4v;_b@15nrcFZ*A6>iQ;&KiG^vtT z{Zx&5)q*M%g|-Yz_VeyQ<`b7@v^p^vK4cdP9js}VNz?cKd*7b zm>=dxW=rYa($1xFX=0jphMbX-;hWwgWo2T+_^9vW;}@m~c}S3ypFF;LwKj$66SemZ z6@5O2e5{Zisj7u*Xe-?;Gska(_b_kKyQ@!}*A$O0+A<38*9j|}mmK#T$T7^;$U52@ zZCho(Vn1jb zd5Z05IAt2~*RD{-D4HqOE0hX7yAWCB3Sz9Y$eLYb%8$yqofVMvCi7P2xy;zi9huhjS*gYp zUCOzXZ>e9?Mx`xFb)@$$ILc2`V55iE4bKf8V~m1FPhT3+ru>^Kp^?+79E`98&Gp^r zo#g$yJL^+;GN%_irNn!Ux1 z1S?^;s)DAKuB9=_G{!5{>yYO(<4bKV^)ISubvtcOEmfJgMzo8t&N17z!8*ix)H=*M z#w--n&GpSXm+>&em9ZFqSMRQLr zsV{0z>-XwMY4<44;sSZB6fADwH@JE_GweUvN81Kj;)@0pyvj|_u97_=dr{7gyiWy# z3I`Un$|t#_bIaw&6jm>8V}4bfTyQY=W>#uuasE2KgR-m#k)V``-s`{GWb$1OP4GG#zWf8js_K0!` zLww~k(n=BWl^iFnapnrgYEZ*6CTTq~y2>%oe5tdz>}0Q}9*# zRaS_ZPRV}GKG2cpcnkT7sr`zTc%mN zS-V({n^&1zo4*%3i+(NaSdfwzkvlC9TQ`u6YNAio&ft2|r6@*K!!s?gW|$)^CnU=+ z+O*m@$IC15M44e_G=bN=$9lx;H|jcTWlgY7r#q`ow3AO>&x0XO_sI80rk)5^Qwwf)EE#B5VE3rMY=i3+A z6KoxAcI#wYlzoJ~ot@g|+g96_+uB=+IneyQh%H)C&?EO@?l9|W+D+}!9#kE{ZAlTO ziv32H-=dIvWo8DI^Lb$0V|04|6yyk*9K6v#-h032V~?4Jr@Di>99@z2ooXxF7Of}M zWq)y!>%QZ#(8=pY8o@Eo{T>dtEm93R9lDi>JxqVt@G-(?I=2H_@=ouuZ>B zyG0$Uj?`W^{OK{&cw0MC^-=LraZO=k17VNCE)S9`N|S{Q*L&wHSpD|46<813j@iH3 zqwLpgGORn_JB~OTxJ1`eUMVE=fAQ&jJ)y0zoqyz7<9g<*&pTYhoh$4cZS8Cxw%yhy z)&|yH7IU$!@Nj-+F3x+C`ypp#!8_+vW}K>{q8|C(HB{)sJl9?Jz8=sdXi~sy@8`xW zLltjra6wqdu;iefeoK9xdmr!`Zptv8_n2ui8c(a+uq#k8-Acxb4%cKpSJcXDgc^?W z*5;PF)+nyVpyDoP`wlzDt*e3kO++i^}<@9m7#3|e(!*lO8eiQtBydM}k>3SRc`W*=U z68=ZXDgQaXzxgik<$SWds`;e)=K8!avAR0SnH*)+%op%@s+cHtD~jeHT0G33tzVt7 z!Z*<*EfUYVVBW0t97n};Y*2LKx-cR5JFd^^lw(!jG>!ES^{aJTG;h^enhDw?pL~wl%g|%qz{u%yH&D z=3k2&7mY3KT$osxTG*(tLSaHdor3iRLVieIY=Pc67#~;G0_K?SmqfObT0gASXj_2-`c*r{4V)veY^TR^3p;e^*?!^4*D3fEO4JsORovuXMK12 z4e-0{_tIbOZ}#F14Rm#N!TOH66Y9^(ol1jpCOd+h=dG@C;yG!b)I`j2C4-;)h&9f# z!gkTwN$?Rr@u!{l963&n&`0`4_Efnim6-0mZA>BzeqJo{c&tx>oll;X9;h?Zx%ok(C z=3o;sKdUyYepgjj ztx$$3$0@a{Ps*l>DAoX;KW}yhdw{8gd5FXDAp>Cd+$l>Wn^cjDh3mY)*A&AgFKM3W z6gCPnKc4@I|IUYsKGG-gs&JO?#-HV@3yp+|!ZP8zuvJ*Xmv zoq3MQj^&QlK%LE9*PM$S>+JXKYaI=oF974&PMhOhpgnAF7qwaN{qgw;qcOk98IF?Ts@{^*y~kO`aD_T|Is_ z+|(`9ebiqy#OP0JFRQDmDk;Bk<=OcR!!~7`Gd>VY5ii~l&0-dO10+!#B>D;uTo+t5 zAPmk|ZZ5}20g_&7FBMD6$qaN8=R11^yr!nId86^wF> z;t{u$fhzyxH^6cQDmcF@9aA43&p_>SSQ%4x+i(bZRJqdOUoBm ziDShX;zdyd{dJa9(kUppC^E3iwh@l8Qe7w$Kqf~@)g>ROe^1;aP7ou&zW6LW5l#uS zgeu_ceZlVo4b-3S2=;V4Ud_LAeRlD#3Vdt66+f6i$Uo&@^Kbe0AThjzHo~vKf$gL( zvP{YV?_89Jlip}HlgAF?!a0U3%T48?6-!lfG#9lSwcRuu)h#rSw9WKdgVOL)AFa>S znRNTLaoQugvihyMNbR5MkE(8}H_B$pdy0_?!g+EF89PMv5$XwX2hCt-J`C9GU71M%A!_l}qu7s`VE;<77T@xUZ@c_h+ z6_EtktIQ{N$Yj9(BY7LdcU_fl$tUG0a(UQw%9UJ_zg!u1I`U!n@SJo)ijj6o6JS1S zOJxB2sp4&MpEyx$DEf$kkRyB(VublZZ=t3TDEJ6oLJ*uqK9m0fvtZ+^2vI_Fp{vkI zmH44of^#|2^aKN+=!!T8tQOq)C3R53s@p-%* zAHxsvA9x1Fus_}i)kmWtvaugT--OaLWDm$?J#~=dq#1c4kCnU1Ghw##<%@C!SmjKV zmPzj=HSE%V2hP|awU!vkUz#s{fcpiKS-dY=#R^h2DO?JKz2&x&hjdT8DLTcL(kQ8; z6a~M(OV?q}t^;<5$WK9XxnvXRM1CW0NJY8^qEQyWF4bJL1@_VJ;~~sr#>{YR7({7p zWC^=~`^qUGvhFswg*(C}alwkp3LAHj>j0ea7rUPA$yR1<%pN9!IRsIZxe(Vd3!+X5 z{RsQjLqS?5l0s66PK5~8t#m2$b`x;d7UD1}LF~p!z}tL$4%cViGELag>_GM>wj!%! zd8RRYhph^nww8@$dNbpgIHncbA7Hx9gfNpJf}|q0;~UHw<^f)Z9@ALb45C^UxCs4* zlBfVtAC<{VvX2(gakMq532W!8fWM*g9(k+G$|>RzK>-@CF<|bjh(r&e1OL|b)YX#z z%7+S^FrDAz;+-z%JXd$Vn2!hP{n~ZbmFM~ycK0?3^~6JBtawL!B6a|5?j*~BQk$Wz z$QNJ6Q<*{RG;WKcuX2WRfHGG3m&&ZZu8}lR+TGem+O^ug+ND~Dw!JP-s|8<9k@}4K zC$N4VDcgW#NatK^S#}b{Fztl6oG26x^stKzA}qND9NAaK@@E9Kd8Z@H!1Up@d47qv-W(gXCpfp|mA&u&rxF=sNwm)wO|D;}cQqUkk= z%)LnW!tp1>(R74;{aI)v?7H9skNGUnp{meZ7z5y->46%uc@!Ar>K?c!>XF94a#GRgIq2f z!|rCgvo{zs4#B;^f8L!|huE1Q`T{8JfE)>M8|04iZ}Jg&v0P2&C8OLKM)XlufZXl| zv(b#yA^pfHf??zhLE^WjKfzT^i2vOS7$VdRSo({;g{Yn^h%z1r*j*2?+8rSRc`RW4 z7Rc+z=sWrbGqN2x=OxNSap)REaBM~kK^OQzBz7p^+!s9s{#p)P)(>K%n*p9fsRd;F zXNZCP0uj6)K*FCT*Wo^i+yhzjfD{uK#1tMP=SUiH!1L2&C&bk}fvdA1?bbm|*#(G9 z7zt4dzmXmAWFr|teuikS`D6f;-6U}^hf7IkG8UrZ-oSg0;muFv2E;)ghA0&W5eXp~ z9^`B=qhzd5_X0kQ58y3$9v*_* zfo0+Z%smGj{sD1_TOhV;4Thg z_Z=`l4B{ZZ+XMN)5e^jJ3@E=5M8LI$8EOMF&v=bPOEj11vmeAkdBWiZS6=Xj9!6pSjy3@n zO1|!=K9EHa0I~b-cFMrJ<)N+0@U4n!fMlozn5qYoEDB=X8^Uirz*#*gs|(nv1_c3lHFfp^oau z=nU|#H^3DDZG}O!f2AML)P&w@K(B6C>H#eEK>s%W0ZmCs{U1=(hZ9FaL;g`xaxTe4ao2G_aZ{H&Q^ehZ z4SprKw!^i%9rwG!k2hV=i~UFIb|`Vcod{3u|Fg{b1EX%7O7PU(hP!3@10HuAqPrLO zQHpQ($lafk`;w<6B_;SMdE#!%ohS37sUI`thQH*!zuRJ9_S`dwfKS}DnIE`yUzL<| zKhEyA*#C+3(sN06j{C{KKkj^;zd!C)+@I2O$=!eYb<@*-t>vbyzsuNvT$i?p0pik6 z$yiG7O3&_0qyM{)8)NP_O3!YrxIgZ8{+0Rmh@3u?E`uBb5l^Zg5?UJ#Uj`(jpxO*?5Lw8w8PTN1<{hNwPA^2bWF0KFH z&r)fvf1gVsDk=SE+kapGvrY+(xqsb~;-63c-LCuY-*f-JKL77Z{@wE5u>5!LxSzRe zmhyG!Tkf|0Pht6YzoosEwp4om|8oBSZ=-}l{$Kk3@85A#-G3hUKi~T&{QtcEXB{{0 zODN>urT@D-24egbKl*lSml7EMzH)08x1CX(J7Bx6Ofm*dwKy%>Bd- zrMt}%eOCfmiA*WUAuY)kEs<3veV4pVK_BzbVyR{00R8Rwp>y5ZoCn?P*70tAjlkmY z0}I3-{1-alh{j;ggrP1to4LcJLGD8uX$Pz8gJ4q-uoI_)RlkMqhpf-1um)cZ<%HIO z$bg4r8yODXqncKS6J&EHV+T&6 zBZ(1Kl8@;~SSccMoTZ@~v6 zK=#yAGEVZ7hJqh_I8LL>VCAwAa@KswIq4}a#J_^Q)(uxdEg;`>2A+&FAyWkW54aU} z+qq-#8+@F4K^E9?SVQ=s74k^(4vT0bnASaolKtb*@E3HbuM zAliN};C?D?1-ABW$Yi`l)`E?Fiki?qT9J04ondTu-5HQ{9OQsx$q{rvF3TLIRpkma z2{%MC>4aM|74Ue#{x^CDY}h(zKC>NPg^U-5QD+`=OeU0WzGq#k`^`B@6N5B3Y~4OhRY?#FYk; zbm<5^gxfGfF`_F;F)~B0(>StI{(_7wizkrVWH}0e?6(FehmNL0z@mK*GqIU=rJ1m9 zEejN|AAFLl=?1c!cEO|3VDgN#g0<=@RE@^VtEiqi3VEM@$%9}wX9OEX&xxUQ0^5h_ zLc2iC+cX&0h5V@^sgi8PliA+1w={x8fiJB$sV0Ae)jWfSAjt5NljM_-fqD?lCHu)) z$jU*u68a*qB^z)zyoq4&k}d!kEmR8`O)iL6Zwpz)KcQR1B_9F0{mq?q2Qxa2GLXX* zL4xTE6oMJZFL_0Vkr>(+@|1c(Hrh;b882cCh?jR0Pgsr2pod`nc8VTF3-Anxxv5NJ z&_n1Ep=TsbP9%uA&dj0B$X{q2wi6xsM5kf_ZGn~92%!J=v>F^Nh9oN#|AgkCLlzS9ptFNIGfP1z>rjI|q23ZeXAk+E; zNYAq<8h=J3=v%^5Eo4TPM+uPUH;;ONFR~N53%vioAA+>a zBmT4(_>jIphI)6%+205f9rn8E5>iA410R@yGY-=%SUtZ2k4-x0heM#3#)6+C9QNBP zV-9@={yGm?__yI-0^YlE2D&d7GKA*QGT>*ajIPlGs6FJ6M?&m;XW+lSps|iYu2D-k z??6Lf=6?fT(1G{+9%!jlnt-<87*rpymPnU_E?xmt{R%SpHUk}vqAy7vWPXkV?pqIh zx&`t$8_`!J2zccQVDUT5QF-wFZiSia1sR3wK#wH?CSrg({UDESG5Ai-0=J0tSLkaF z3db3Mx2NE*S_!oL1@z`kkQL?dU+5N9fut{l{{@giUkw6GE&=~qBxK&EQ~eLCK#QuQ zP2fkW0T8wV-YpMW@DBVxz(Vj^wT1Wz3EEo?ew{?x6m$Qh(b>PQo!2RDrHjM)sbbBWn!aTG9dOHd_-xvN70Wvv3+wX__GvM*jf~^n> zzNhu{18_X#^@I246udU#ld;z1K24h$Q zobwIZD**2L48J1K{8x}IvEY$(fOPZ*2!03sI1jD{f)~Y2mp(8KD^SrTx)9oF3)n?4 z`~KijZVfPvf;qI%D&X}IAxCl|S_+tW2bi1%|A)~Zc1i^BpL7H)RRpL%LZ4ru#oA~n za70t^lI`!9JV>^6bSF@v2$au3F9_y;9%L(Z z2ky-PDQSV(nEbqvxY8$V+ZtA43Ij&wtN8X=YiKc5%_ODK)w%X zaxGxF18}wqcJ)S!uTXs80lL)X~i^1Fa2xw>#P}B*awAuhoXP8et zdIMDX5yq584S>5aG1@9 zK!uk8zE5DO{R01{(h=aHaBZO{0fIQ_Z8((o0}OitJ=_MH^$d)Q1;~ejZ_w=@^#*>8 zhT|Sko*pDxP2eU!fX)h5)D7q@1+Z8R=BPFBcw4|9f_}0JWci zXZa~es#hQ(jnGOsVB8D*iS8>eI6~mc?Q!%6zC(bQuRnaYS%4EGJPU^z&;nPu{l{;C z?{h)MsDQ3Qe#l%a+@}Eklc8VJ4FR(wfqX#^SfNK_b)f+Iy!WfI7 zjZ_$W0kq)$-y8RT<+xWhR(Rs}fI6XE2beYjM{)4ny@t|3Yb8EsKcG$(%rAv{W?1dG zpStU~0EXfp9#adHIss-Lew{y7L)cvfJ}MZe0%*HrW$9k!xl6Rry84H_F7X+>#2wHq`dq(!wqYIW~7BzGVogyNV`8DGW{!bl+L z+G$9qg1E->$B0ro(TGm`30HP}rs02%mz@Zuk@t#_@E>O~6eR}9Qt>1^v5{C3O8kg7 zaVti`aA@S6dZn_HI1+-EGSLzxKDbJ%1X3UO-%(qXOI0GlB!OH|lT}qxfP7T}Qq>?a#EU#v>(FC;l8uzZcQ2e3strk3jmUhGsP?Hp)KN7I zV_2!Ksu!U5fjXjMi3?QaC#Tgxm8qJO*EknI7OQCVb5%_vM^$OGazUNPnJQ#9+T2g7 zlLn+D*{J%F$)q;=J*l3lCF+#=rWUFh82LN(xAKg(EGbR)sb9%1w3dU;Qo~g>(w*!i z`7pA9WFt8S>W8Q=DuhlU>oJPs80nvAtqyslmXRRZUUeY9lZvDx-3HqFk=81bTvv-! zD7mBFs?%zx8bxx^GNh?`iMvm$+~hKPx=IeK`RW7c*iE`(HeyL6nMNjHE}d#8$w8t> zM{-&oCEsx0EK*G^z?=-g2)C%gWH=2aYh*)mf-F-X$e*et=ChwFM!B3WH_@4@G^kxB z6IBe&Qnx_EXf=owAxl(M^&5KMqZ+H~sx_TVX2>X76ZCDPMD-_iFoSjIa#8rY9Mjm4}24P*lkumhGidH4a6SYdU zB-60MF|@jxsq14%DAojfCx)fX8JF8YnUP)9 zcs5LQCJ$)18bju(&ZIRtCdaW9wUPVLKgnZUA4!^$gK8Cdtky^u`9hwO52}qSPB*FL zn7KYGne@c@WcfRIr#9(N%E<^dj1~e-OR%f%VV)D{Eg7p)=ybYSy^=3zaZ*y%VVjjV z%_LRS0(wv6Bq`JsN3h50t9ImuJgpv)g{rkG&A!Wnav~k1!r4FagFL_%@!BMay%!(J zPR#Z?@`rd&ny_WUi|(QQWOEusPRJv4D0vFW@l3QNf06rg9Gxn6$UU@*BI>L>N5{}z zg3u1?lxRS&lP0RHnn;r9eN{{*(j&x_oYs)JDbxf2}O5Nmb`?WL#zY6Ry0DfaU(;Ow(%A6ZHal|`q^nq($xB+^w^x=y_XAN_(o zm6IGso0a5f;=?jz3w4y_Pzxa$hp6#b_s4V#y)0U?e(C_J%g&4WbdMS&f2TqYkly4H z$)}pB8KC~a?5^AwkveJ|Ye75n z)ASx0D^1)r2J-QU=%bxcE}5VU?BsrAtExxNkb821N}vUWi)<$j(O(;-s$q4P$qn?e zOdykKK1ibs=#u<|L$-WXL+JpuM!6yHVrhRlS~+p=9P)+)%0}#yTC9>)mO4ql$oY^S zG2o-F#GjqB99j(dQ_g2EtYmgV7FP}Q&DI`v9y~Rg4d*rWw^*lT8s*j5adloTV6{a! zyQS*NP;IizsXoz@&^vR~6=<{-&=3P@QF%lZ(L!Vgd5Dfw)5%sb2QqUSudEqzrpn76 z@P=Bbsw{`GL2@rCBB!c(n74J{nrrN>xF`$jJ^3bjmCP2J7AFgkF0`pQO0vj8`2cI) z9xL@8?;)7~3u+2!tg5I*v?@6&(@0I}RG;a35zYQnU1X%biu({>tt9`*VpJdTlAVDZ zIY$o*K_5d3pC_B-Qd$Rmeu&%@+i1F|supOL^_l!e!^JYz9{Z^a8_27}XvpB>*?i&> z$FY0csoFFWvhtkTpe}P5DQ+@abtB#BaRgbHIz#b%*|r6ijiLOCQVqZArqCS5`7 zstEM-MtV&ps)y=VmB8}KTC^R}MO*Ex>;P{3X5G-jX)V5*+0|xwpZZ7#-48j}O8ZZa zRg$>mZ1O9$)M{~rRia-+Pr6El)7Ppu`Iqh&@uUvLu9p``BDiBN83?JzAV<%uG4dZ$ zU*#mT)EU*04i>wJMLvr>bTdhpH3wyF?3O)9QF>l#bP=RhX%>ialm=&XVP}P+^Hsb`))rVB>1k?N zk@^tvMs{U4tmW(xIVXCs&Z@JjKoeCR)sucCljPsDojfC*bhaqRW|N&_Gb;mYq$e@e zIWkm@BXwjU5)Hj&(M93{?MnuT|FkQzj#@+CLEb$Rm1qORMgrY`e?v@IDg%aUQFlZvK6>Lbm@9`e0p0c|43%e7R>3D6oYt2WEP9JkdbTK8CY z+LO1_Z%Zm}vZ`d4s7{VR`b5yXa;5s4mgk$;8)mv^Yt2~{->3!3Eh3j;m)>d|&BKc_ zo&G5zwIQO5e5DQH$HWZi$i|TLXT%~^gtb%8=>xT$7cty&rfjQ?w1_^9_O%4_gN~T5 zh08QqmUU1Qo;*z!)6e2p`jBjpQ`lrRP;{mVaxt-3d#eFyp;Z!L zv@onFrj6r+=Y0s>tjIrj{UA>bSMS``#mbV^29tmBmuWYa` za)CE{tHbn@{8Q>$J~bCRwUDwPIj6zKW~3L5X4T~m;br_G%E-FHe4+fjkVFzAJS)sSgE_Mt*U`>hwp+F?M2_q1#$-SmXlzIEdl4x6N{mz zJ4k=FNVFyqv)DJSiPp4#d8|V+y&SQZK+jV@2TdC zMWU9Q+;rvkYAU9w-FkCZW&Jpf;RP8JzmeIjn(PH@^n$8KQz85M zi7Mm`^v5mrSyrMsbU$kZNm2#He&Ym>p|@#1Uci3dY%ki_Gu*n?jq{&b5qd1N z@?1Jx__C6ux!lbDX3=~kJuEx(7=0=Koz-XEtYtdIn&+hbNj0qXc1We-vL{^(S=iO8 zWsE21+@JNY{1>&-e#IS0&u9VeVcKUl(pmw{Rv8lArN+uKUL*NlcZA=Pth}nQ_xy|^ zw)@oSnqa$X#>gYaO6#ktqfh39%+_0plkz1vYcf{%8@Tu(Tj`#t?UMKSDBB5hAv>h) za^JK+wyvAk9p2_C)lb{tUZkxSuZ6cU6mmI2D-L;C$Xd_3+JAGMROj_0<_&e6^nzX3 zLPi+n$wSM<-14l zDgF6yyPulsUS;b<=knLGowl1?F)QglYOPtv_^ftA3XdXF*evUlc2A3PZD#4l2kWl+ zRcMXdix>TxLmZg=>t~(^flO=yM$!o^_M~y|Kwdt)DfxWs7(TvKnmzbk1fL0y_$=Anr*?E>)!Vz{jgsP{RmOX)nN6k`TjAAor!8bU#!|*As~oy;7otrN z(3uU*bbz+i%bOJ;>qikK0#r9TiI<=~v=bsr-eQHcFT9-kn{~qLl1P>7_4cl5>Wm|o zd$9Gzc8p4XKxnot?3mMARq#3?jm%5NLR*0Q5N#|^sRScI)+1}I`>eLTopUk`H?BK_ z*cRI`vz>L&R)AGA2a-5=8e4@%zS(^6m{ai;~@9a;lvw8(BG;1ea#3UJ|{;;0v z4Y8LG+D&mG>k-@N&8*^CExnfOLGH42n=#ex;+7*>^s%#531uVM!Kyi*3Ndu{A$g%Kd~Ok=UQv{ z{ol>~T3xM+^+v0}*N`hZ5hs{0PoU3iPt6Dxtv>R%wpQ?1pOAmm0M?0r)_T$v&OeRW z#xY*OoInmaF1qTw5BWTj|75k)ira-%-TmFx*?8dWX}-)OEx*G$&!2_ub>y! zcUt90f=r?_^$4>wy~nni?buHJ1FvEUZJ^H00y2x0rqlR$RnOkfebc<-!_-daa_VLa zw9{g|>7pU57F{Dh@?2V3-4Dok@}5 zZIt#|?;Pv?Y%8zLw^s2y`a8D5I-`1NGu3kutcvLy>09?rwZRrm{&0^Y9c^pnUD#4C zR+{frXN-@$Jb$TOMcgBVHl}_eP)R+ElrqyvfGt#6RuKJ0>M0+315vrBh}(516UA?| zzg}28gcsXY9hB|JEBY5X#@DE3Mn84Yd_)RrDfDkqh40jgu?}3wrYr?|{-ijpZ6Xc1 zH*L?n)FN0AaoQoVLR4dA=}{@2n#eVB z1UU{nP!kbEXM^Zsl_{#gx;)1QE6LB3lXN->Rm0%Jua$??64;lu_&U{8Ye-*%j_%AG z@vtrO8)7nlkm87Qof9Rt>=_fy_HISMe)Hqd@o>nvEX7UgG z-PZ73gW=ydPz~vN(n}3dx5-7)0M^qQNG+wF%N1lf-KT`ii>Om8_zlxwvp1y-(WV%Q z$RT^GBs6I^%<2L8S^Y=e$~)>88bI#K#o*WitQ$OwHmVJ^$OX)W9lCsjCq^q@%3nw` z8bcPy>xhn=g@-r~v9Is&8DgMQ{!@h!MQlonB4W7;woy@11M$n{@V4h;6mja1%1$?s z1YFggG^8_#KfLXg>_32@XAQ}Ec}prpSa#B1NFbsiYe;KaP2I;lU!)u8Kve^?Uy3ou}UNkzyFN*m&Aot`bw!Ttr3_)iXJk?4jN1bJ?1&Ku>ND62dsacGX}tttOK7hiI1|z(|)%8`a+rH z9K&lF-))<;ZSDc?KJ1eAm{nF$B0p@?gEEAari7zYOU`F!(R=s zg5A%VLkplK5%J8VjmQ=`Qnll|*cN@IEG9e4n`$@dN8;hVW%0Lk7fa{2#WQl6_GF9Y zAnd)#Rw%xtZ#R{j!*bVa%`G=Kdk))rM4}M%KpRH}9H)slN&aYZs!CMpQ zU3yd7$wEab*B|C(_S7-X7%$4WKAY8zTG~dzMY6ocsCHlG6<1VGbyHTQpH(jHHm$;? zs4K2uCVrzrTgLmDr&TRl$@VWj4@o}S>_(U9St3(*RIk`0c3o`Y1+0rK!`6nh7g^RS z5~>X)=i&KGvl@zv>`!AT39~-J0j=yP-~qAQ{RkX`M-bafnx2a<;Ho@3NOtpTEbZB!YWnN+XKO0zn0yy_&*%Bk8dGGBE@^t7M; zg;(L-$aeNnJAtToK9x;2L5_ow>WHi^p&w);Sy;9aUsMU2U+c&yIYid*D<Ywl)PB(#tEJX9K0zhXp)8M3l zZZ)2*H)3cobnPFa7YQK$(c3JZ_^3GH4X=0)g`KW~RXtUW`f578#(l*;GKa3AoCYJh zUYJ+l@8})v5q%{0@*^Ti-XMeM26;^=c|a}^^W=Ma3bCCWe6htuI%&=ZLo1Y(Q{+-{ zSDYa8S$B3-edEH~&+CyD+7{Md6&9;RAH?nks?jo7RzoE7r5dFALDzNXhoNPsAeU4~ z4n}6@jVuAldX%1IQM89zFQ3aWT3SoiN@;%d7HNa%>kS&9?a=<#R@2>xO^v1*^fL9L z{xpO-=r!n*e-W29wLV_qj&ZiidU=vmIKVextltZt13vZq#{}07n-dls>J=2|SI2vkBf)!v_dUmRd${qMj#lqMTZ+}w zQsyZ0p}U2NfUBgtoSEIaWNo%Wt=DFhb=R89 zi-=9)h`1(RLMA_w8<8zYAqskBHH~2Zu$-q`(OR^scH8S_pl{TvT*YGA=8elaHL734x5(-_*5&OV`!fHtT=T=b_%*SOX5-l{ zJ!kU9cYjIz+9YX9N?@8_+QyXoN!PwV{eCW~N6PuMCYd#yvt5(j!Dfhg z&^^k1*1gE|<#}W&GMZkZE$J}Cx-KF6FkCxh{AT}dpJFd%d#Lx*)AZ}cGus=xX@6(y zZUpO}Ji3sUW@Xqm@P(7D^ok4WnQcwpt_7yX{+efh^sdO}QR{L%h$&mJOo3dvyN3<* zeof~ILo#}Z*%0*`nz<8^esCPL7^5>14Uf{o+ zXM@TaMY+p)%{88fsP$sJ>w0Rxul}EEf2{QR&iAdUwM|gs*KoVt8v!NRZmEz9D91_cFac*&Vi6eJyLo`e~>1>W073 zQuo)!(3)f-a)m499_f;w<#0Jc=B4|+GDG|4{Ziymi5bQ67D&xrG2(sX{9LsP#1^?6 z$8xUqJ;UFm9Z6o0mg1T#bnea=|Lx$1Qg4U68~ZsrbxM}M`ik@M6{T?TyL|T^rdrZ?nRw zMF!?OACix(N}2rS)%Pcvb9uZRvAK6-K8n7eR>Y{Z@O)FV1ZhiKqp?Si; z<#H7Ai@#iOanx-6cG{t@uaZ7xEkl%d3(uccH?iq^?bEMG?VN=bXFiCY6)-zFzhv3m zhj;~2gKdG$<8&R#IFxoeJ>7Xkt)dP6!D(c)HG z0VB!irUxPyGM9h0GOQ0Gjy_;Vs2?KGtK})NSq9UZY%m*U>l$=0SK-1#iruuY6w$eT!?KJ2~K-n3<}l{Fb#^{6~(9iJ5!9-A&ZL70l==>gXA^) z+Avpig#u;bn-wyng;yol>Ey*}+xZo}qVZDR&)EKT)aQmNovnTPXh%)QZoP&qY=&5- z3}Z#L73lG(yL`sHw1OGIS?`>0%@ty?NV5)ECE?|+rz4DMUcJ4V+OF%%wX4*Z#?$3 z*Vyn;;a77bC|7J;fp(F_?K@o?(igj<*%EyondEModiP7wuYJ--iZj|~TaxWD+r zpG9j}@Fn!wT1Uy8Pt(%U>STP+Z0^>DPEX zY+TT)AS0Y=O)?K#!-X4G;Ra;0#=*ONNP4rKte19M@1TdW&U7Xs=B8+71csH)-zxq~ z(VqD~N3{uE7u}-J-z9q$y_++e?{4#aS`}xOl5D)xv+jJm^s#+n#ndos5G`XYwawKA z%O)a`WawS(6}-G`4^?f~%=FOo0I}qh- zr}Z;x>nyMimiYT@jVw2XNP`WcQbqpR0XK$k-<` z*(B{Y+e148HE>IFIac~~@=DOI@$y+$(knX0SPQK2?xn7l&gjhgS#5bN+pkZzrP(8F zA6Yp~vt`=W*@P|Ln5Vp~oYwFBu~nWA1`?t?9}8VJ0$x=s?YMDEuK?}TLVv2?){5)P z^+WWN_)EUF6^Mu|7#N=zTQYmwh*i-t)=~09$+!7G1Px&oL}4|B{i^*d>t%lW=JRFB z*Jdf_U605+t*AB~8QOXB1l#WL^V{TY7Aa1rO-cwzmIEKbaTw0=waEe<$7AUNSUmX4|DiBhSTFTiJoSK*){j2 z?+rdr|JEp_Wkx9fT`zB=?3Roo2ed<89sIKgDBDG=f7T0UO|zV}+dAT2kufW^Y(|W` z7j)c9<~B|m$F)&fHshP^gx3N`ime7c!@pUtc>~c`#EA>mCo=*5>p;;&-O&!&<{PIF ze;vbiYq#{~#y7T7j^_{f0z~+ah+=#z*Nj~erQ^C3o}Bk;_9i)B=QHE;mOfS_U*v52 z9<0M>@Fs>6p5aSt^o%TxB0sKi3=3Z2PWA*y#S zt6;qnLrd+7I~nptAFbE0ePavMNz0ygF>&VSn#oJk%DBeUsXB5;{34xe&*2^B-z4Ck zEgRq9{*%Xx5!O`q(JWi~vDBUE>#{zWwaHfPfM(J=w2x8PJH~I7-#$l8Z4A&zMOYc& zE^hK)+!LH<-KLdpp0Hwo1Nm3YlHKTNW52DhF+|^?#W62P+Y98QoGU8J`Qnb5!|Z6C z5LQZyhmzXV zP43e9W--?cvd?R;_iEqozB}|N>!vwR_7yJoHdl|#{z>n??n)`1*~b}fRTWLZrLWb$ zMo~urpSRw}?Y)fOjXQd@-a>aG*LchP?)EdAn-zJMs4b4b#xNj(r)Wt=i0zVIUtg(} zWVy7*`gs~{<$k7p@xnIV0FFvPOm3;j}ulrv0AK zBQEQi({lfAj&mkE*Gfa*s(;jr+Md|9*~-~Y8yAfn_8aidAMrapL>5Gp#E|36<*v2- z42e_E1SAw}@_(s6>#eoWg&t&#K^Ax+CU zmQ;9mo{8`jON-KceA9ZuXU4;WVCXfjl4pJ{xEqdfe!Va z5#2d9ICs;qDiM=oevQ9eBC6=aJoO?^L^O_U5p>48w)a}wNwZ<{lW#t0k*+XziS%yY z;*)kdorsCe2YRERR)FQEJ4hM!-RNyAs=TtsWai@;qMWO9Myo78*J)V7U(D`erR+>E zvpL8MFSQ+V+_a|{+l{$;I^^JeJs%r_xSFr}B;Htgcxm}ohRKWKx~xsFvyQ-3HP`Cc zzU!ySZZ$-Uv*pkliQ-mgvcoqudU4*>QC1S3KS*Q)RFvCy@t|HERye6H& z-RTw6SXN#4E3={b$=wis{zesvc=mtx>W-GSqWT*)O?zUm?X?E+oM$SSY~h9Z1sNbB z+#AjRVEI1f=YY%UK$_FM>;)M@18kdZ#pn|m&93Va+H~lm3+j-!BRWUkSrM200>kp< zt5hUU;XC=`bKZ`q6!|1_PVhp%mVVSu+<8+cXEbuZanE&*NgepLK=M>ij!2H*#>t$@ab*?|M z<*@y$Pq0<9KcFoIP`|9G>Mx#%Syp5BdOn#{l~t_}Fhm;@Ue#_F=E+DWZ0VwGd` zb6Rhyi(2f3PwB||x$;L94d@xXB~Nsb9ECUKYm&WOxH~)~YFpUnfX+Tu^!(=R%wJtz z=5E)etdVI&lJcjHaoc2oyegl`#o|6WVz2H!7g@xAVXf_Be`qsFA}_<|Sl+Jm%q6ZS z*01I%*AbW7HN$eyqsVNO0#>WJy^_6|mY{Wjyk5t8lA-i1T}C#mWKqo8V_lN7r44zI z*1#Z!$u;UdT}9uM`*c5B%bKt)tbkU6RYrzCnT+$cXVY`94fFAv5ZX0w(?XL9rN(TH z`X}^rWZCRZ!Yliqa4ghwAWJ<^ak0mmV(xI|ca}G+kkyE176OuVmsZ90)wbXE-uO?G zz-eZXIckJlD=L^%vue7UTch0Fonh|b=2>@ul^fXTL9zxpi0DFVItjX_o%WQx(W(P| z)LN}%qsbfV9{;3niZ)iP%%)24-9SJTB-=%M@<`K}4@rkbl%Fi6xs7UUjF==FYJT38 z!UyN76#3k@P{@${xrtf>`$UrWf!?}Wp5FD(ff#P7%jj*S(l|<+{~z-v^{xe zrqdd%8u2w*{akzLJ$sBTr~bfrp`WE=G+*0c?J^Le8c)pX?^-N7h*DYQGQMOLcPCmE zWClNGb`~pG6J(Bm*M7H+Ho_2DO4erUQ}k%9jmpKlNng!}8s-^SB`L^DQN_B*uc|UE zf=!Tn#X!w%&!ry_HC2M`y3Iv8ix;YkcUI)r=>7o#UbjNNt}DC z{d4FL|9yT_yr+|Js|NX`Jr<#vf2Q_GZ|ANA`+dFhpN!I?x$#Bs!e+zIudBV%^XUO} z9<8VMC39SBT(@PMdh9Oa)Lds>!RC5iQp|xRr~v_(rc&5is|Pmb|!Y8@?46X`|Ztu)d}JEwgHrkApt>QB|qu`1jZ zZu*7!Izv9>>=%0|pHKAlknE8Ma_7uhAoSk=hkumglNP{+0u%94Ty>sE+mcxT=;nPU zab|Zvl#R5jS~yE3o0#1wsQa>c^cZWT{iBpS(A`c%lLl7ZjENb~-OqR)%W?*|`dO`j zmaixhgvK)T{MrckUT*dQUPKA@53KRmOw;SA?$#7uANj33>Jl%-bEy5aHgQNl^_>pT zY64^VK`BO!Ei6%pi44A|_S z>T@4{4ZKyOrnSo1${k}a(-kaGsqp9qp|3#n4Xz1Eg@|`wJpDtVRg=&b*ZTeXL zkecmX|75;%UNLisVd7W!*36#nFuH_&~&>j5Oz&O{{`upt&8g<%_eVYY=ZLUh)KFGkkeN zAn`V7x6~cEmF7bZrx6Qc`;bGNPt%bhnnu5|RN0S5sXnwIjR!(FJJ83i*jtvU{FN{G z>JjZo{|3e<(w^miJD`W7x?@gIaMYpb{!w+pzJ+{@*pg#N_NHNf1l0>F7t|>r)_1a_ zsom6@0He@V4W=!~B3{5Of_%j>wOr;GE%^+w1vLu@U(0;5xEw40LIm1Z{wAtg!_0=f zjj*g=%¨70mBhANfL&Q)r^P+y*q~VjxwQ0q3(5*|H(Xbk?J#kR@~ib=ebX@3sj(t9%OC z6YX^a)`r~-PYI0(*%KTcF0&nosu0>MID5#>AQ~{;zm|WNW1^9wPe7i25({S^Wl<{v zhyY4#;*xdEDg=w50`*p%tV6s9@~@TUWmynBmLYb_8)BrmB9|e56JdGD&T6Mf;ckrW zuxNp~JTAuZil~IJqr&5FVD^uZVu&c6RrisTyFrH_cRva_>9IJ!45--~H~lrSS%?lB)m8_cffi-2zk)yxD#_Ge^6vfp^_F2nie6^pk`xqZ= zrmuIPZ-C3+JMd$`&>+9?=!k~F^8-o*J41E^mkr7uu-B)jcPHP%zCG-}=q`Pr{+x2; zDFo*Kq-ZH(t+G~8P;$*h+y|^AtAe@3ebqhHJ>G1>^IMl(7v0aS6swrk$hu|qMb74| zXab2;Pki!VVGy@@8LBvL!} zm~I6Rw{VimDz;3^7e)0Y- z{04gq`y_i^dq3kETR`_COZ7_C5Ie2ae6rjrzPW#K)iS$7a@IC0xC*(Jm~Z$2Ym6(@ zm1a_r55H!(>swFxO0kotnWqsSoulGKXb`b%3C`+j|>-pf(S|FmB%pYcAO{agW+Lkfl5_0R3+ z6XXtA5|quqyI*&|-M)u?H~J*mGmKjHm$uegJNgSdPIVO``^wuqif_WMG|b2DMLbaK zH7A%Me6`iw`pR>QY1TSx3Sy9@_zj*Nc0b_*MSYpfNAT%lg!qS7gf6Zu7V;g?ApeL# z;-Gvbe*re(f%*qnfp%Dn%BaZNikw(C+Lh*jd`dueY#HLzL$!a5gpU!30r-&#Ja9pxR(y#u@(+v^xRjE%;1U~}5hQ9x-lQnh7EzQsDp z=kmkm0c3FwnupD{W`w!W{mR`HGU>QE&RlAZ<8637ewU~6&wPon$z_P(RhC0#0hu6Y z$uV-6G=O_)E|<&hY8EO7Wj^#sKyF}hWMQhhi)iN`_VtyJ8h{hvcb#O#@|Lr*JWpkW$%y)eCn(h_kHPCL@$JwSCoAl!PVo3fHKn1l0%4De;Ymo7&XZzo&MwrNZB zlEyM)qEW|qY!tLFwcoPMwME%0*nRENw#yb}duZG;iW>#=51LcU2Rpw#GOzncd0=+- z$uvQb)Ba1GnaDvt7vZuia3xdZG-M(}Q~{uO z?8+B4MWaD=ZB+(dz!-4GEMUz?!H2&E6w-g#wF%&nHaO-X6Z{Q%%eLV8Wh_L?uSILY zT5ByxZ?30m7Z5*as+Rzb=e6D15^aR$t4(69Sr_EsMj#e@7J9lfDnF;HoT%D~giX>7 z7C|*x1|GML+^#7KBI4mIqww1x=%l}}{`-OAawxa-!rE6-9f6K&2HNJKYI86!#`94@ zvrw%CLVG@bxdyz_LBt+jBQu-mZXWTx#{UK2D+tfrLm+B&eUsh#DCf|WN zrfe!mG3AZ_iy@aCk27^pVK)FaTu;oYhpMGGwt%jqs9!k>eEucq_Pcn$fr`Y}I3B6z zsGUp%x+oGFBR48EE2E;Z6B!M!cOEF;M~+rq?$2J_>pqzd$KVH|l-mm7(;y-*pfpsSpyNJ#>UD^b0NSMU~e zc;@C6=H>6k4yUKF_=4J~*<8U%o|f>2K!3RxZYqjgVpaTK^9 z0aO+Nj_ns5RY5~FWHoAmqwC1}OE=>-pcprxRCtY9RQJ(E)6SKMy~6 zW}5wIH45t&hJK>3hPiRXfCdk(g)z^b_oDy%Rsa+hz}Z-=c5cjncDy2Sr{~^ajMlSS zp7HBHX46Bdr&kXRo)z{S89%7B6e`VvdlH1hzE{*9C(oJW?VCpMj`2-m7w;g})#Ld!yAgShEI@`sY+2 z_S*+YZw0!p;O<+Hs3xRRPOQ-b=$Mv}Y$mAe0@?cuzSqRgtcaEf_G(*l6MQfgG+k6> z@O}-N^b%^2tD#ch7gQn<(wuyU%%!Nb-iqHP@J#(6saufy&>WYbhkelAZBXu|;$#x! z_%&G&t;WC;zJTi2_H-XjKxN@AJk?+ee8ePJ_=2jZ%#xJedhx206Owc28z zC}+G-M@`BSw98zf8kmD>Z_USeh&aM3-5VI(Jz6F94?#7i(v3(Us{gVlkOh1KtRz&W z)8+IesSm$!73xTzqU!i5sjlwvDH2txc#6V3`icI|3y^p8iW&%J@+QaF9qXOws@GHx zMG|XGcJe!Foi><7sJ~Qw)`p-efb?R8Q7ditx*?fIL#|p zlRRubyzh-{ zTRjpd$w;+Ui$m5I0fB`GEJP4ZwFLQIlMSW+O9ztUfJ$fKhEKt6}eO zwS#2HZg>{K-?FS`kdeFyoe#WhEl*t`tc_8??gL3=<^W!?K58=O(463rD&X8vz?a<6 zij$6f1uW#=su=dzRoJ12@QjCLq%ss}3hHb-f!pV)8d{>vA>!CDIuuqxK46*;%YQwz z^2TD6R)=+m*S39T_ghcEdO^3qPn;Yl7(Ks4}x7942=~Hej0&_+uYEot%2dKjQHm? zpq~9umb8Ns&Zs9Z3Z0wSZLAOz(y_q;T8M~DbQ7& zm(wBrZ-BCGYP_0+IYqs~4{UBYDrMft7eMvS1kD}`+{DvTZj-^_m;2z=QSfan;MdL} z{_2gMo~X9ah}*z37vK$q0WUHLmi%t)`abBf0#+p+wJ#e%#R)uDWcH8omL|#2Zi~nR z^l%G$@GbA3sf?+83JBxj(VEbn2o`Z8dG2$`~hA^RVm`% zC$Lh{WIG}>L($eK(0m5po-2>Fmxgf!!q$#~mGC$0i<$5d8^9_Gg{9I0<79ur`RreI&>1`RvabXZJ@uxFmZFTMfG$d1*qaD7pH<^e4Mu+BX8c`BYd zaRt`bTfDzV&jf7{^pl7=_1JBmK4S6fH;mY0DOxylP91>>ps0i%(i z=@xkWE6%@$#|1z(tVi<09`r@e*|1acLS}g^Ujr0)Yrv$9CK+q?ws9V+qi*tZ(2>mcZkbbR}111I6T3ui5~=feC2;qX}P!I+m^ zpf?ytINJC42qE~iHF;JSmU=BG8V%Ho-i2ya0mV3{Tz%_Oa)@XVtC$_2Qw`W6%HeMNO=-{^M@X9Sm0yQ0Qs> zr(cRMnCC1|?m3qRiaeeTMcbKZ!Q=aTyaxTp9d7jCnH|qF$Wn2wggoIG<47Jgh{>t;!>eHoDr*>@!`J!$Aj$lHg zgmgGbh(vtJ=n>OLMB_LAeMv+Jm+%OQ*oc|9MY4D&iiMp7V?R+iL8j(H3BkGAS1Q*AMH(g77MK{ibpOjB}^Bw#&sY|<(9b&ooL*xq| z(hFDpESzF4`HpnQKRv^}!bk@)nykhBB~pIpNKzttktIYX%f&Gl#m0w zwrEag&{pCuyT#8CmF}k*+^n}_U(#w)m^j5AiYK&`e9xV1yEsZZlL@3YeMII62R|%U zl1y@l1d<}m?!K5nzNVAOpM11%lN>TbZ0Ct$4LwCtc^IB_8Yu+_`tiTHOk2=Z;u8NH zPd0{pL$bv=T)$Hs5DvmgE3uy2#B6d$&R33-0qhyMV0f&o6J~vyzJdQn0wqd&iw0yp z?L+SJqx>AdC#p&>q%(96T}DrcRs2t0kNfa3WC@+Y8|t9AJX1Qw2e1~rKRGY`AoZmt zsfFYu&1ew4%ir_5v=bS}hBF`1R{De z2kkQ!1DU=osz@uzb=|{nl9v29UntJ8bbf)%QNZiaC8E9PCJm8_Ngy2~Jy!gb<>D3pogSq3$b4zD^js_u>!mN`zJ&1> zw7J|`iV@v;ZOq~hsl$w-ixeO&7gI&B^ooAYpR>-?Urr?HJehQquhSNYKON-FgN+S)US6U>xaywZ^ zp7JKVA6W|R=q1{TYs|%llO*y~`~e*}E;>uIsU)_sk&KB?KEBY8m@#$^>{K%5}iah2HxqL_$+!9G&rm`7N?}%@>Dul zq_GaHB0n$Mkg?=P8YYbsRL|5p^4_F9?M<$*WjbSKGK?nBt)wz{=*!szo*}kLZ{@bM z7F(f(v8kk`JXh{Ri-A>@fL~5hNxCFYmo$D-|DYe`Kao6on+_&z#1`=j38FID$XoIu z{2f#HJh6dr@eM!3`tcZX0{Ca5^D&beq=i@^+S4{tIvqqC(>tWU@aL=f3+Tu*~_#(25en&^qpUDu>gZJc{#9eY0 z=r~&J5UHe>bXJO!LTRFy#jmqhd?@)IGX0mdURpq^3R#>Ho1j%kgom%-+jtrOR{Vz; zq$bicYNZc>r(+4FHK>{PA}7Tx(Opy&RbdB?lQQ7yVNqGk1zsEyOc;rs+N6#22bdd4 zjO7o-0~#wepaWr5YLW(|7U@sUh{=2k=X?avi5;uDb!*_;bo zSVf%YZq|+6W;WQY{dk`1(2p_12`+kAfOrH8bCq1C3t_Lik*2VYBSd}J%`eD9a*)&| zcVU-*A`R%Dut0Bd??CdSIK$)kQ|?DrkjA7Mc>5)7Mw^h0u;jOa?)Ndj2e6&vM67to z38dx)B)blDbB5T#9c(h&$uQN zuVORVU*+~x)T3vmSb4c*plgIAT>KcH%e&)f4|%n6K#`R8@=Ixgv;Z@kM0U}E@*X8Y zxvaz(QVfB{W`??ow=zO`CC5uW$QJlgNn`>IlnSM9<+oBBI#O`fpUnX-4dM&=UOt@9 zWmF%le&H!`A9HtgS9P~_4|0!oZ*tN{X+dmJ*-XACH5uT z#~2W8F8_%)fX7ss_K@1hkL0FGl5*4V$oRx`)O^I^WvgsA*{!y&*6J9)SQ5=^%ylgR z);?BhU1iBOhnZ`bY8yKn&L{&EhumJSFPUH=3i&yEn`HC3j1Xm3&oFzvQ!$*Cl&Odpaa%sWZft?^4|F zU3=Z<)YHsHlayhW9ItfWF@7C<{d|7(-tGOhcPH;u?@qpF{TBGI^sncC-tV06b8nOV zvH5f3w}xAWwZvk^_6Ot`>tz`tErp2dwbmO zF76SofU-x9P=}Xeo}55nZmyhT@}>Rf0D1mC($R(=Ud<2e%CNd zFYlFBf73YSkTi?_1Icbi3+Pa(fEGiRE{aC*4HOopt6CdQ+lm5($U*7+)-M(yJTHan}VRcNjW{UM`i8H{E$&UV@!ruR$|Vsf=Z>4?qO_& z^tCcKEU&zMVyF)cUq6;(Ly?v>f|z+LUb`el)*Q3^IH4|y^VUtHOg7J z)UWt);hn!**`M=-0XNxzXt8ipDZ3^kMwbftL3Vpw1HHP=KxdWv=_r(>QY(Ep@NV_nqwiWISIgW}@M~F&XlK&B`uQgW zv<~p`@8Z|juf2a!z`@}8;UlB(#y*N&7=1f@LGUHN&32Qyv*D`pyZ6Z&HDLV9NyW14NcVeDzBEpH<}{GR?9d&cVlWygtN_QbQ!nOSrz zFFQLV+mSsecS+u(yf?YU*_O=6)X4X#iTST1-uS=!J>_-IAV(&P~sgewo}G?53-?U>j}*GOSfD^i{5?yIF!3YO-q z&)SsrIJDDjgP;Sw=sx;4tuF19wo6;-4dG-- z`Xs%fuIN+sP~i68a*(m3>7wbFDcM-ja7>y=W{Y3MAXwZGlFJM9O&;Pj6`jlV&W_A# zofVet&Tf=*GW%)f)U-h#n!n9X*!e0u;q!Mr)7^zfv~ZKlw@c{W$mvm&A~u9J3l0hD z7qlRFe&~dV-qCMjddAF&tP#3A;EeY@OBcf%_}kUV4a9e|=qox&S_6&UDGsp^y}Gto zt%ykG0y{+iF_`ts(RW+@l*%$`Grr@l?YLZkb|Bt+GUI1ri zE@ZaI3{IbxT=e#JLWh?*FJ2^^e{ag!T6RkA=QAL*IBIds*{H$cULmD{I|BCwrG^ZP z@Q>La7a4Dh&5HOr__^;%tE&7++VT6WIe*T-6Jtm)4W$MWgy_?(HBcvdqC8JLsy0nj zmOnM#HZQS$YCC4_ZO&6J)3xFXqDxJzqFv=UrLufSe4?H!U6YQ!Iz$w-ihfcF$I0Kb>^{w1EMO%XT~3kO^F;3<`Ysm_;B!=(1j5z zqC3Z?$8?Mmp-%(iy+@cw$uETs9<|JS^P7k=hXLJc^ISbrPtvYx3$(7Z{LRe)p>J+wamU6n({3TWbZck3JaxB3{J&77-ZwIH*q0(%^Ant)l$n z+Ez%6*JDV;!N40{MaHUhI6I@A*7oW5SQh^WS)3olKz?7Jq%Bi#dBQzu?)9EewPaRE z0+cEy-BM)x(jI8rY~EqWl%9}fq6hLB$+WRtRjxq4U@hIFOV;PD%+ynRq;yINOwCBG zpSB_OWb(lzIZ=OA_R^KGIB9AoE3uNgUQfMyZDZNvk zCm;K;C#g8G*6S{>HoeMyvn_d4?k?ATrJLWNh?Q|mD%j$`jjj~FBDi;8P@o*VE$m)Y z^SBG~=i)w%eiADAU$SP)|KY2(X=;|*Mo(s~g%Bp-Y$tAJDcXCrts19JQpafFtf$DP zHGXPL=6dlF3uGCoWzRRj@7lpY+kGQ7QYAk0-B8 z{_Vr(?+x!dz8R6wHQ~;ixgVNmk95?aSzc#CYsGG;P`kpnu`?qVg`NzW8dw)L_f&)_ zCL)f*a@fNyfi1jl8pqK0Y@psr4?@fmiHyll!X~Qlj%*g}{d4V(X4bFif3yCgE^Q=F zGVC_>v~;pIvDUZjGMzWvlmn$)NZ=bXhEAeO$ygq#JuZ7x{9~ReYfswxlmp2lllvxz zB@h2_KWY41>zhppw%0@7{+3dhJHaJnkIye*_hRJuT5)}&PlgW*sTTBWV7K7uVNIh3 z#ommw#5Iea7dANHu)VplHZ5ac>5cV2^doFNw<6Nq#U;Lwb!7qUZ~cOPUM~X9EaET4 zJsPR(G(Iyov0ku#Y4x^LHw73Xkin=yE788R8$5|8yhzhrElXP!oXj?4tVpe#vN?HT z^5|s!L+X1|(w@ZSuOB7^y)h(7>AMTU)yc*ue#gV(W7FeK#9oZ@4*x6oec-OZw!yQ* zf}*@)$Hh&L?H4sAw4wh}+h>MhWH76!*U(*hAz~s)JmLYczEdEJzQDp%{b#n9?-Rch z2W=%kRwf$@Ow}zHqZFf{H+?jJW<3Xx-N|of) z58=u2$?HEfdN1DYd{gQ5u-EB{b&{K9pC~;cF56lJUy7O$_aV+1^J`?Iu<60^L3e^0 zhi;GfEBZyOGd4H+ZFrNQ%icZAsx(5ZV|CaiwwkvP3q?HgEA81xJx_b5MeE!2ItCJ&wCU{*4ogCY-;`oYEe1quN z@WfzGU}oUUU}Jdus7W!GV_4LZ@EJjaeJqxqa;P|@uhC{|OuMdcW}(Pd6tZ({GrP>5 z;qwBb9Xs-X`^BGRxAa13Y?@=?)+4rUwxd>Iwwt^SE958AEh$o7Do>JkN(V_hcF)gNYdTJMX%L_z(nf@->hdPRm2lpy^yUj{VOi6 zw4uW7m_89PA(aEe0zw1t2m6HIk8BrR9QA#~(2(7J4(n!RyfEowV2_3U*;P@CUZm6MX>wZJMvT4{b8e2DaS6F79aA0{UFH(&BztwQs$PriZEQO% z7fiW^6N(8@B2gMCwUq1fD(OpjbPYTk9M=mCxz92drdCQWdY|}y!-w0+fvG(8i?saI zH7UbCJbd?UV!t>26V2~mrVlTCrS>wl4G4%jQlV|-29>VG8KSC%b_f)HMSd*c!D$21 zH)IUW%*m9qc4X=qz8PxT_Ozwx6SF+|KRHkFVdi6gSHoV!9E`sg*D$(6*q*?-eomjd zzFqte1T7DH9w|qc+m1i|ciP_>CFv3$#XhhUv0C~=sc$@B3^qEHG4glF@lE4r*nM`M z4^n~247^qV>!{fxNDT% zcEmeujxfCEm$on2Q*@wUdETy^+T^Xw{V69e`|IpzL{>Gk*Jb~h(=D$`!HL3JMGp#T;j8?bd8OGi zGL0G5%-~$Lc#FDNZg1}r_+7YHbXnB5;p2kh{a$!q@~-Xs!vAjYR}n*F>cl<5^BfMk z@3Y(5-uP5{j0mBxv{H#N?lVp^1{!x7l9Y?`cxeLi|C3RT+D!J+>vA{a1?a^!o7Fza zw%KyZI2$?s`=S%MNw3Hs3`wRImd2JrCP@h*r}P1;&9lzE-EH$c^wd_5c&fX7%OuCP zk`u*siz^p*C|+EAuDE~k+@ev1Q3c`o3-fB_?a4cp|EwUWD5!XP@o&XR*s4YCi>ek5 zDVU$1l3P6|Gb=p1ZC+f-F;9#%+WOw_^Uw;B6(U(^VBpU__q-~4TYakpsKG5FLSl}@ z=0|@OJ~r@zcU$Wn;~=FI5Ij?vVKkan83!2@#VM&&qD$~(n~}SCMDCD2;7Cu!-#Fj2 z%v{IP%rew`)_6ymCXbYNC58_) zea2}itx{aSsHo6dG^MCoacGINWK`+rr4>r8rBZ29$%K-W;uFQG#r;d3l+-QVQ`*&G zb@p+-aoj2$Q4(HkD+(-ZQLsM$RbEiuPq{I9?F*KaGRk{kQMc08j8&;cKIs$Am^lg(n2&`*gIww8WV!m|{&OCTbaC8E@Wf+@^5&11d7S ze~824mhdH0Nd%oOeXT4qwlUYV)V8cOk1>@Qb}HND>GD41cVh!fU)vJP^*8UEe#-mLwK?ig)Ar`Z_u|)16*r2b~#?RgSBUe$G~9 zja=bw;Xdwp?pf$L0Va9`+-5Bx5D2=K92e` z@=)0OKri33c0b!z>t^dIE46jBZMV)dcQg1&*}{y>dUfP{wvwlGpEOCjM90!blCSdK z@WSY43N$r0bvErrmhFZz4mpj-mg%-s+gRIVYh%k&Q)9z>=<<2^&9~_-nnJpXfvm6g zsb`EU>cIQ*RRo&PvJ&Jt&tGp=k-nZh1Dh5nLMjMR<1j^zgZ%5kbBD zx_Do+&$a(%XBa!|zt}2UJ*MvsljS$Gg#1eK$vo;WO_nZ8E#+{fiJ^`0rO|2p(>UFj zV~96=rliTOl_3U)ah`d;<))>&CBan3xL&C)ca<#CU@1(#Enk+8OQ*A}(potaeQ}n~y zpK7#v)-&DH!lQVexYxTYx<|X(mr2g_(%{mQCCf`DmCP>LU2?tTLCLO?7A3jG_kacU zN*0!!D^W|DmX3q&oGCq78sb>t9P9o?pFmd{=9+t2N7-!lIQs!xUE5{bMXzMvT7f5m zb3<52T*#83rU5s7Z+V~hI_=fP+vPpTr=Cv_?_cd(t%J;qjWrE}6|)imPv8$FPdTWB zE4$^Ma$Q-Gk4bf;E3`i~(WfMtd_jZZvo4ei|xm_uKuo}u8A(qwZv1R-Q+!J zM|r<;*f84|X4-0sGuteyt*h*ZkXil3ud08MUu(bNzWaRQebT+tyyJaV`=tALe75+U z_MYh#XMb)zXgO+jnzooGn}(U{BAYedc-xSxl*uKMBz2-!QM(w7u8CKq8XZc%fz}P9 zy%9fA>Oto9Fs&sWk}@Talq~I*Vx+0`BC1RqL<6DnC;SZG%=^MeAJ1OuUG-PmBCWgD zMVqSqrUmJJkR$zC@1a-Kje3gqJ2D(eT4jBP{)_%Ze~HNMf_?}!qY=2SJASuL-=WXd zTk0<8;W$mwHmKq1HP1{>V^6+&hr78u)wSML%XPnOSeaCI+quiRz&YMI-MQI$%~{}# zC~I3bvFt<{D;w(i%@yUI>OSjEbQicCK(*?gt)2nuW-XRo;69`RCDI*fgFI0gZkT4g zZfa=x)k^Ke>#SEp?=9YcdS^h|B7H{sT=((!?ddzlx4W;!caM+7XSDb4UY~fav6tE= z+Kjdf)}hu^OIyoA^G~M7#$1D@geha?eCe1pOKL4~dWUXD1*{<|m4{IWnvJT~X;`dh zsO==6X1N-*%?qL}B8Bb1>dL%?-C%1_9f@Zj^evDs4b)KQ8C@LcDG`seX zx?TMeoeM8PjqV<^=dpXUyPvy~o4Zn739gr}L|2B(=`y&3-8J0Z+|%3#-EZ6>o?f1{ zp8L>&YHC+?jJirarT(S5)b`-zCjB_eM0GKoO42{lUU`Dj!_d>X&~(S_u@Ku6+gy8~ z*N~-v?p#2xEEv%O;v6i{! zm!<}$b;f4~*-#zyyf1ww<v=W}tgvrkE`HB8&Dn zpTT2Mh26+{A_Fhka*gzEmHqH`PN>X}YWJ(7w{@YfOEl z?olVIZPY+D$8*cG$FsmQ+SAw5&C}7--qYFB)6*Z{XL#0oP9gi~_Ec4esGHP#Dg|Gb zg41rTralOIa7lOQ_1P?Tk5%Ot`C{}jZKj8${qjPkg`wDR!MMgW!93W~)@roic7%U;`l!`938!8+Ayux_!0Shkw|%xg`= zG}-vn5NG&Cxgh%^dJC3*p!Z+xNh!>2%<@fkCejZi&WxPA`Yj@Zx zR5dCw7pSlk+3|sTH@&0YUT+TF4%H312bR;M`{+UVW{3Qi0xRu$9Hg;1e)WYuOaBp- zOs5{h+Oa{viiK=DvJtOYAuD60ET3hvckChig?-N^vChx~O@FNK*T2Q}W!eqoM2Bll zv;Zwjy`t_==c~ij&(zjxHPu&jds00}#3G{2@n{||HBzmqwg-LZfg|@Isnv1i9%MNp zFo(_h?|Lz2&3T_d|!oEpmu%lxE4FD%5brFw5A^6l12AXF%8rwj;I( zRIoDaRlRz94fE;`sjcD_;$`tFwEu2jX0Ks?VEf8O;ltFlUWF%p8ga$XrpBgg#=6FB zK-lKWCfOm4ld|X(VCgd8<|#2mR6_OYE?>hta~n@$ciA=2;%Byt&11b;MW*WiK;pjB zhv-f82;EEf*DFFM+W;5ZV|3RCKsIOS%VD2S=#L=h67yq$us;S!y@5rtR%{H=IxvkYt^bX1#<7w{sHg1>6`Rd zdMK#9h@AkZ?YskDf}FPj+WIT{>aL^zWUSN$e$6vwn!(3-**MwM&}_8)ZJA>=*ruT( z7-}D6UuEBE-)diJpKTv&?_+OmuW0w!u7ctw+aYT!>r=~6OO|2=OgiSJNVE1 z4u8dSxQ>nr#@)P-=kikYKHv{{9)H9S@oAWY8QoStvTs;ZW<|y69PTs_x{`^!`&_M? zRt;GV2ax`6^`d$}-3WRA8pz#6ZK*a?>j7oH0M@Kf_o}}_!r!UIs+ZPS8?PPKlC>E9 zbA2(aYq}oA20+7}F)MG%NAa!vFH}qW05eO`Gn0;t)NrWa@2>Kg6`+}Vn|;QPhbatZi84C_)WD~5c)`7>Q`X% zCqS~JVfEj@TD=4YE1*S7p!Q1r05mpF_eW>P2;lk+;6xWiRm+a+|Br)3O*n64QZY3h17T9p*#iMbW>Ri=*v^shpD)i z9o3i9@O;NW>MLrBmH{hwMLn+WQrD}Ck*^=A4p#d^2fDy3{0!(a0};bT(4MI=@D~R| zTMlaXwIZz&?8Z(=eIT;b^Vw;Z3hnC$eS63)qN`XhUP10>pwBdd&ZQ3egEUb7L@80u z82T7L7{4~J#-gWc{GJRb8!q2g|ufJqGGO zRJ9s%6Px2h|ZiHt%z+Ny4{2R*;@O1Ro&Jn+%!dDAjMaM`Q zx|HV7r?eTe#lvN@az^QDcw}g0JZ=nupIp&=!`#_&(c)$8Ze3_SYQ1H>Z@q3kY29sI zXI*TaZXF5qFqF27b>1AX_=E@8!S1uDnG*o(;W^!5B87^AbG!yN@FQ{b1Ko zN14XkaXUI7uOP2J38+>s1Eu=k`Z4gn1*+dqw4dN}E`?__9#QcCU{ZZ8L{rt5>UHSF z9rd}It4gS}MS}hfwNA)!PJ)g8S$hH9ZGsB=ZrGB)VVQhbb2bI&p8~XO$;U%ta(PW? z=`M78=ZGM5*6k!I;KR4{Cz?U)qa!##K7ekZf0VBcMTT*RcDtH>H#IUJK|jqz%XN!x ziMGZAeS)oat77$7@-6Qyzgf0gMp>#^GR(WptN2nLNxYs}~&8XLGLC0ks?hCt+&Yr*${{)0z4%z9TU>ZLKn#09QaRbFjfGs zR>I%d%y;tL;J_AeY$Ui4$zAL%djOks5FXYz)}BQnj(h|^a}jJpE7+qrJsf_O4qSYu zUD6I{TS4iS(2+HW4)%giXSFNv{~w~;BL^N>HTVZp^zFdaB*;v4Sl?-E8|39BELKfE z8uw1%{^+HhAU2_gGZCGF&B(W)$!p?IyU<0b2NcoD(jaLgVk9DWl7EmL@;D`5nPbom zlZ^L_#8ksH)O6TnGS5N|wWejbWsYUBWuE05%V(C#7H)oTzGuE*K4so(o?&iaeruX$ zvYSpCI~y|$8w`yM3Cbd+68O;v7}ZU>PMgxd(3e{eU6MD%JkbIe@i%060(vy<@J#No zGeGLY@QYTVGq@wP!^n#Cx3I_;A*p-8m3{a;uiw-k!ee@c@e-d;!J}MV=;7c`TiBZJ zcz1)04`~t08%Zh<3mvG-z`aIy(WcW%$5aE{l!JR<$?)sHK+wY zqc6PP4d`{uVR7*LhM}W%89$6XT^LYu4OT+DL-nW{dhmZjjA)`&X?MB+U4;_rZ4;zT z(s?OIsw*#)Z-F0CN>^p2@*-Q<;gIyv*U|NVC

oW-pzJ08Ax)ROr9-q8O(s8(FM(Fc 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 literal 14922 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 From 59897a3bd1c5942ae03270a702b82a806b493956 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:00:19 +0900 Subject: [PATCH 31/50] selfdrived: remove the "Turn" / "Straight" voice prompts Reverts 71162d3d7 and 2bec4fd9d at the user's request (the carrot-wip review of PR #547 deferred them for the extra 100 Hz tracker, schema and sound; the cluster turn indication stays). Drops AudibleAlert turnModel @38 / turnModelEnd @39, EventName turnModel @130 / turnModelEnd @131, turn_prompt.py and both clips. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- opendbc_repo/opendbc/car/car.capnp | 2 - openpilot/cereal/log.capnp | 2 - .../assets/sounds_eng/audio_straight.wav | Bin 18636 -> 0 bytes .../assets/sounds_eng/audio_turn_model.wav | Bin 16458 -> 0 bytes openpilot/selfdrive/controls/beep.py | 3 +- openpilot/selfdrive/selfdrived/events.py | 9 ---- openpilot/selfdrive/selfdrived/selfdrived.py | 9 ---- .../selfdrived/tests/test_turn_prompt.py | 51 ------------------ openpilot/selfdrive/selfdrived/turn_prompt.py | 47 ---------------- openpilot/selfdrive/ui/soundd.py | 2 - 12 files changed, 3 insertions(+), 126 deletions(-) delete mode 100644 openpilot/selfdrive/assets/sounds_eng/audio_straight.wav delete mode 100644 openpilot/selfdrive/assets/sounds_eng/audio_turn_model.wav delete mode 100644 openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py delete mode 100644 openpilot/selfdrive/selfdrived/turn_prompt.py diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index f56e932b74a..2daed1c9b44 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -103,7 +103,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. - A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). -- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. With lateral control active, a "Turn" voice prompt plays once the turn has held for 0.2 s and "Straight" plays 0.5 s after it ends (also when only lateral is engaged). +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. ## What these settings do not change diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index 5e60ac13749..eb40eadeadf 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -103,7 +103,7 @@ BSD 분류기는 지정한 감지영역의 종횡비를 유지한 채 정사각 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. - 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. -- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). 조향이 켜져 있으면 회전이 0.2초 이어질 때 "Turn", 끝나고 0.5초 지나면 "Straight" 음성이 한 번씩 나옵니다(인게이지 없이 조향만 켜진 상태 포함). +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. ## 바뀌지 않는 것과 혼동하기 쉬운 설정 diff --git a/opendbc_repo/opendbc/car/car.capnp b/opendbc_repo/opendbc/car/car.capnp index ace4d5d06f0..23f9065b85c 100644 --- a/opendbc_repo/opendbc/car/car.capnp +++ b/opendbc_repo/opendbc/car/car.capnp @@ -574,8 +574,6 @@ struct CarControl { radarCutin @35; radarStationaryLead @36; systemReady @37; - turnModel @38; # carrot: the turn model (turn desire) takes over - turnModelEnd @39; # carrot: the turn desire ended, straight driving again } } diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 78a96307ec4..72726cd85dd 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -164,8 +164,6 @@ struct OnroadEvent @0xc4fa6047f024e718 { systemReady @127; impactDetected @128; impactDashcamReboot @129; - turnModel @130; - turnModelEnd @131; soundsUnavailableDEPRECATED @47; } diff --git a/openpilot/selfdrive/assets/sounds_eng/audio_straight.wav b/openpilot/selfdrive/assets/sounds_eng/audio_straight.wav deleted file mode 100644 index 4161a537a2493aa26bf36e624fefb26398de0f85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18636 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

oW-pzJ08Ax)ROr9-q8O(s8(FM(Fc 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 diff --git a/openpilot/selfdrive/controls/beep.py b/openpilot/selfdrive/controls/beep.py index 3e284f3c0bc..aedca8236c9 100755 --- a/openpilot/selfdrive/controls/beep.py +++ b/openpilot/selfdrive/controls/beep.py @@ -97,8 +97,7 @@ def update_alert(self, new_alert): self.dispatch_beep(self.warning) elif new_alert in [AudibleAlert.longEngaged, AudibleAlert.longDisengaged, AudibleAlert.trafficSignGreen, AudibleAlert.trafficSignChanged, AudibleAlert.trafficError, AudibleAlert.bsdWarning, AudibleAlert.laneChange]: self.dispatch_beep(self.ding) - elif new_alert in [AudibleAlert.stopStop, AudibleAlert.stopping, AudibleAlert.autoHold, AudibleAlert.engage2, AudibleAlert.disengage2, AudibleAlert.speedDown, AudibleAlert.audioTurn, AudibleAlert.reverseGear, - AudibleAlert.turnModel, AudibleAlert.turnModelEnd]: + elif new_alert in [AudibleAlert.stopStop, AudibleAlert.stopping, AudibleAlert.autoHold, AudibleAlert.engage2, AudibleAlert.disengage2, AudibleAlert.speedDown, AudibleAlert.audioTurn, AudibleAlert.reverseGear]: self.dispatch_beep(self.dong) elif new_alert in [AudibleAlert.audio1, AudibleAlert.audio2, AudibleAlert.audio3, AudibleAlert.audio4, AudibleAlert.audio5, AudibleAlert.audio6, AudibleAlert.audio7, AudibleAlert.audio8, AudibleAlert.audio9, AudibleAlert.audio10]: diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index 173fb088cd9..ab63d144e32 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -1130,15 +1130,6 @@ def car_parser_result(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMast EventName.audioTurn: { ET.WARNING: EngagementAlert(AudibleAlert.audioTurn), }, - # Voice only, and also while only lateral is active (most lever turns happen without longitudinal). - EventName.turnModel: { - ET.PERMANENT: Alert("", "", AlertStatus.normal, AlertSize.none, Priority.LOW, - VisualAlert.none, AudibleAlert.turnModel, .2), - }, - EventName.turnModelEnd: { - ET.PERMANENT: Alert("", "", AlertStatus.normal, AlertSize.none, Priority.LOW, - VisualAlert.none, AudibleAlert.turnModelEnd, .2), - }, EventName.trafficSignGreen: { ET.WARNING: EngagementAlert(AudibleAlert.trafficSignGreen), #ET.WARNING: Alert( diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 523f0ab1b63..044d4b33516 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -22,7 +22,6 @@ from openpilot.selfdrive.selfdrived.events import Events, ET, EmptyAlert from openpilot.selfdrive.selfdrived.helpers import ExcessiveActuationCheck from openpilot.selfdrive.selfdrived.state import StateMachine -from openpilot.selfdrive.selfdrived.turn_prompt import TURN_DESIRES, TurnPrompt, TurnPromptTracker from openpilot.selfdrive.selfdrived.impact_detector import ImpactDetector from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert from openpilot.selfdrive.controls.lib.cutin_alert import ( @@ -155,7 +154,6 @@ def __init__(self, CP=None): self.rk = Ratekeeper(100, print_delay_threshold=None) self.atc_type_last = "" - self.turn_prompt = TurnPromptTracker() # some comma three with NVMe experience NVMe dropouts mid-drive that @@ -370,13 +368,6 @@ def update_events(self, CS): self.events.add(EventName.audioTurn) self.atc_type_last = atc_type - turn_prompt = self.turn_prompt.update(self.sm['modelV2'].meta.desire.raw in TURN_DESIRES, - self.sm['carControl'].latActive, self.sm.frame * DT_CTRL) - if turn_prompt == TurnPrompt.TURN: - self.events.add(EventName.turnModel) - elif turn_prompt == TurnPrompt.STRAIGHT: - self.events.add(EventName.turnModelEnd) - # Handle lane change if self.sm['modelV2'].meta.laneChangeState == LaneChangeState.preLaneChange: direction = self.sm['modelV2'].meta.laneChangeDirection diff --git a/openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py b/openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py deleted file mode 100644 index 13915773685..00000000000 --- a/openpilot/selfdrive/selfdrived/tests/test_turn_prompt.py +++ /dev/null @@ -1,51 +0,0 @@ -from openpilot.selfdrive.selfdrived.turn_prompt import TURN_ENTER_S, TURN_RELEASE_S, TurnPrompt, TurnPromptTracker - -DT = 0.01 - - -def _run(tracker, turning, lat_active, t0, duration): - prompts = [] - t = t0 - while t < t0 + duration - 1e-9: - p = tracker.update(turning, lat_active, t) - if p is not None: - prompts.append((round(t, 2), p)) - t += DT - return prompts, t - - -def test_turn_then_straight_once(): - tr = TurnPromptTracker() - p1, t = _run(tr, True, True, 0.0, 3.0) - p2, _ = _run(tr, False, True, t, 2.0) - assert [x[1] for x in p1] == [TurnPrompt.TURN] - assert abs(p1[0][0] - TURN_ENTER_S) < 0.02 - assert [x[1] for x in p2] == [TurnPrompt.STRAIGHT] - assert abs(p2[0][0] - (t + TURN_RELEASE_S)) < 0.02 - - -def test_desire_flicker_says_nothing(): - tr = TurnPromptTracker() - p1, t = _run(tr, True, True, 0.0, TURN_ENTER_S / 2) - p2, _ = _run(tr, False, True, t, 2.0) - assert p1 == [] and p2 == [] - - -def test_short_gap_inside_one_turn_keeps_turning(): - tr = TurnPromptTracker() - p1, t = _run(tr, True, True, 0.0, 1.0) - p2, t = _run(tr, False, True, t, TURN_RELEASE_S / 2) - p3, t = _run(tr, True, True, t, 1.0) - p4, _ = _run(tr, False, True, t, 1.0) - assert [x[1] for x in p1 + p2 + p3 + p4] == [TurnPrompt.TURN, TurnPrompt.STRAIGHT] - - -def test_no_prompt_without_lateral_and_losing_lateral_ends_silently(): - tr = TurnPromptTracker() - p0, t = _run(tr, True, False, 0.0, 2.0) - assert p0 == [] - p1, t = _run(tr, True, True, t, 1.0) - p2, t = _run(tr, True, False, t, 0.5) - p3, _ = _run(tr, False, True, t, 2.0) - assert [x[1] for x in p1] == [TurnPrompt.TURN] - assert p2 == [] and p3 == [] diff --git a/openpilot/selfdrive/selfdrived/turn_prompt.py b/openpilot/selfdrive/selfdrived/turn_prompt.py deleted file mode 100644 index 60e87c698e2..00000000000 --- a/openpilot/selfdrive/selfdrived/turn_prompt.py +++ /dev/null @@ -1,47 +0,0 @@ -from enum import Enum - -# log.Desire turnLeft / turnRight: the turn model is driving the car. -TURN_DESIRES = (1, 2) - -TURN_ENTER_S = 0.2 # a one-frame desire flicker must not say "Turn" -TURN_RELEASE_S = 0.5 # short gaps inside one turn must not say "Straight", "Turn" - - -class TurnPrompt(Enum): - TURN = "turn" - STRAIGHT = "straight" - - -class TurnPromptTracker: - """Say "Turn" once when the turn desire has held for TURN_ENTER_S and "Straight" once when it has been gone - for TURN_RELEASE_S. Only while lateral control is active; losing it ends the turn silently.""" - - def __init__(self): - self.announced = False - self.on_since: float | None = None - self.off_since: float | None = None - - def update(self, turning: bool, lat_active: bool, now: float) -> TurnPrompt | None: - if not lat_active: - self.__init__() - return None - - if turning: - self.off_since = None - if self.on_since is None: - self.on_since = now - if not self.announced and now - self.on_since >= TURN_ENTER_S: - self.announced = True - return TurnPrompt.TURN - return None - - self.on_since = None - if not self.announced: - return None - if self.off_since is None: - self.off_since = now - if now - self.off_since >= TURN_RELEASE_S: - self.announced = False - self.off_since = None - return TurnPrompt.STRAIGHT - return None diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index 9d5749faa87..ea73f0c0712 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -63,8 +63,6 @@ AudibleAlert.disengage2: ("audio_disengage.wav", None, MAX_VOLUME), AudibleAlert.speedDown: ("audio_speed_down.wav", None, MAX_VOLUME), AudibleAlert.audioTurn: ("audio_turn.wav", None, MAX_VOLUME), - AudibleAlert.turnModel: ("audio_turn_model.wav", 1, MAX_VOLUME), - AudibleAlert.turnModelEnd: ("audio_straight.wav", 1, MAX_VOLUME), AudibleAlert.reverseGear: ("reverse_gear.wav", 1, float(Params().get_int("SoundVolumeAdjustEngage"))/100.), AudibleAlert.audio1: ("audio_1.wav", None, MAX_VOLUME), AudibleAlert.audio2: ("audio_2.wav", None, MAX_VOLUME), From 67de82cd003a78a5160c67e65372408f5fa1dadf Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:08:52 +0900 Subject: [PATCH 32/50] desire: a lever turn needs one-touch, two lamp lightings, then the latch Below BlinkerLatchedTurn a plain latch was a turn, so latching the lever to hold the signal for a further lane change turned the car instead. A turn now needs a deliberate gesture: one-touch, the lamp lit -> dark -> lit (~0.8 s), then the lever latched (LeverForcedTurn); a plain latch or a one-touch is a lane change. The speed limit still follows BlinkerLatchedTurn; above it every lever input is a lane change. carState.leftBlinker/rightBlinker hold the lamp for 0.5 s and stay on through a whole flash, so the raw lamp goes out as carState.blinkerLamp @103 (0x413 *_LAMP_ALT; 2026-10-11 log: 0.4 s lit / 0.4 s dark). A latch passes the one-touch detent for ~0.1 s and never collects two lightings. Replay of 442 segments (10-09, 10-11) with the gesture alone: 5 activations, all latches during consecutive lane changes, 4 of them above 50 km/h, which the speed limit now keeps lane changes. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/lane-change.md | 2 +- docs/user/ko/lane-change.md | 2 +- opendbc_repo/opendbc/car/car.capnp | 1 + opendbc_repo/opendbc/car/hyundai/carstate.py | 1 + openpilot/selfdrive/carrot_settings.json | 6 +-- .../selfdrive/controls/lib/desire_helper.py | 6 ++- .../controls/lib/desire_lib/lever.py | 54 +++++++++++++++++-- .../tests/test_desire_helper_stalk.py | 20 +++++-- .../controls/tests/test_lever_forced_turn.py | 44 +++++++++++++++ 9 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 openpilot/selfdrive/controls/tests/test_lever_forced_turn.py diff --git a/docs/user/en/lane-change.md b/docs/user/en/lane-change.md index 2daed1c9b44..cc9c97da708 100644 --- a/docs/user/en/lane-change.md +++ b/docs/user/en/lane-change.md @@ -103,7 +103,7 @@ The camera or vehicle can misclassify markings, and value `2` does not make cros Hyundai CAN-FD cars also read the lever itself (`0x3C1`), separate from the lamp: every press is counted and the lever position (released, one-touch, latched) is known, so a press made while the lamp is already flashing is seen. - A press after the car centre has crossed the line during a lane change (from the model lane lines) asks for the next change; a press before the crossing is ignored, so it never crosses two lanes at once. Without a detected crossing only a press in the finishing phase counts. The queued request keeps the turn signal on until the next change starts, which needs no steering torque but still passes the existing lane-line and BSD checks; if it stays blocked it is dropped after 5 flashes (about 4 s). -- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a latched lever is a left/right turn and a one-touch press is a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. +- `BlinkerLatchedTurn` (km/h, `0` = off): at or below the set speed a one-touch press, then latching the lever after the lamp has lit, gone dark and lit again (about 0.8 s), is a left/right turn; a plain latch or a one-touch press is a lane change (a latch can hold the signal for the next lane); no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed a latched lever is a lane change only. While the turn model is active, however it was started (lever or automatic turn), the cluster background flashes red, the same display as a held paddle. - `LaneChangeLeverWait` (default off): when a one-touch lane change is not approved at once (BSD, side vehicle, lane lines: the existing checks), the turn signal is held and the request waits for 5 flashes (about 4 s) from the press. If it clears in time the change starts; otherwise the request is dropped and the remaining flashes do not start it. A latched lever keeps waiting as before. ## What these settings do not change diff --git a/docs/user/ko/lane-change.md b/docs/user/ko/lane-change.md index eb40eadeadf..f3ed304b492 100644 --- a/docs/user/ko/lane-change.md +++ b/docs/user/ko/lane-change.md @@ -103,7 +103,7 @@ BSD 분류기는 지정한 감지영역의 종횡비를 유지한 채 정사각 현대 CAN-FD 차량은 램프와 별도로 레버 신호(`0x3C1`)를 읽어 레버를 누른 횟수와 위치(놓음·원터치·고정)를 압니다. 그래서 이미 깜빡이는 중에 다시 누른 것도 구분합니다. - 차로 변경 중 차 중심이 선을 넘은 뒤(모델 차선으로 판단) 레버를 다시 누르면 다음 차로 변경 요청으로 받습니다. 선을 넘기 전에 누른 것은 무시합니다(한 번에 두 차로를 넘지 않음). 모델이 선을 못 찾으면 마무리 단계에서 누른 것만 받습니다. 받아 둔 요청은 다음 차로 변경이 시작될 때까지 방향지시등을 유지하고, 조향토크 없이 시작하되 차선·BSD 등 기존 판단은 그대로 거칩니다. 막혀 있으면 깜빡이 5회(약 4초) 뒤 포기합니다. -- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 원터치는 차로 변경입니다. 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). +- `BlinkerLatchedTurn` (km/h, `0`=끔): 설정 속도 이하에서는 원터치로 깜빡이를 켜고 램프가 켜짐-꺼짐-켜짐(약 0.8초)을 지난 뒤 레버를 고정하면 좌/우회전, 그냥 고정하거나 원터치만 하면 차로 변경입니다(고정으로 다음 차로 깜빡이 유지 가능). 레버가 원터치 위치에 있는 동안은 차로 변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차로 변경만 합니다. 회전 모델이 동작하는 동안은 레버·자동 회전 등 시작 방법과 관계없이 계기판 배경이 빨간색으로 점멸합니다(패들을 누르고 있을 때와 같은 표시). - `LaneChangeLeverWait` (기본 꺼짐): 원터치로 누른 차로 변경이 바로 승인되지 않으면(BSD·옆 차량·차선 등 기존 판단) 누른 시점부터 깜빡이 5회(약 4초) 동안 깜빡이를 유지하며 기다립니다. 그 안에 풀리면 시작하고, 아니면 요청을 버리고 남은 깜빡임으로는 다시 시작하지 않습니다. 레버를 고정한 경우는 기존대로 계속 기다립니다. ## 바뀌지 않는 것과 혼동하기 쉬운 설정 diff --git a/opendbc_repo/opendbc/car/car.capnp b/opendbc_repo/opendbc/car/car.capnp index 23f9065b85c..5fe777ca05a 100644 --- a/opendbc_repo/opendbc/car/car.capnp +++ b/opendbc_repo/opendbc/car/car.capnp @@ -281,6 +281,7 @@ struct CarState { leftBlinkerStalkCount @100 :UInt8; # +1 (wrapping) on every left turn-signal lever press; the lamp-based leftBlinker cannot show a press while the lamp is already flashing rightBlinkerStalkCount @101 :UInt8; # +1 (wrapping) on every right turn-signal lever press blinkerLever @102 :UInt8; # turn-signal lever position: 0 released, 1 one-touch (half) detent, 2 latched + blinkerLamp @103 :UInt8; # turn-signal lamp right now (no hold): 0 dark, 1 left lit, 2 right lit # Optional original vehicle receive signal. Separate from torque/override. struct SteeringTouch { diff --git a/opendbc_repo/opendbc/car/hyundai/carstate.py b/opendbc_repo/opendbc/car/hyundai/carstate.py index ef58e81bab2..d9f1de7f82e 100644 --- a/opendbc_repo/opendbc/car/hyundai/carstate.py +++ b/opendbc_repo/opendbc/car/hyundai/carstate.py @@ -1399,6 +1399,7 @@ def update_canfd(self, can_parsers) -> structs.CarState: left_blinker_lamp = blinkers_info["LEFT_LAMP"] or blinkers_info["LEFT_LAMP_ALT"] right_blinker_lamp = blinkers_info["RIGHT_LAMP"] or blinkers_info["RIGHT_LAMP_ALT"] ret.leftBlinker, ret.rightBlinker = self.update_blinker_from_lamp(50, left_blinker_lamp, right_blinker_lamp) + ret.blinkerLamp = 1 if left_blinker_lamp else 2 if right_blinker_lamp else 0 if self.CP.enableBsm: if self.cp_bsm is None: diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index c05f7e190bb..ec9ad45e936 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -2417,13 +2417,13 @@ "group": "조향일반", "name": "BlinkerLatchedTurn", "title": "깜빡이 레버로 회전/차선변경 구분 (km/h, 0=끔)", - "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 레버를 끝까지 걸면(고정) 좌/우회전, 반만 밀면(원터치) 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다.", + "descr": "현대 CAN-FD(0x3C1 레버 신호) 전용. 0이면 끔. 설정 속도 이하에서는 반만 밀어(원터치) 깜빡이가 켜짐-꺼짐-켜짐을 본 뒤 끝까지 걸면(고정) 좌/우회전, 그냥 고정하거나 원터치만 하면 차선변경으로 정하고, 레버가 원터치 위치에 있는 동안은 차선변경을 시작하지 않습니다(고정도 걸리기 전 약 0.1초 그 위치를 지나감). 설정 속도를 넘으면 레버를 고정해도 차선변경만 합니다.", "egroup": "LAT", "etitle": "Turn-signal lever selects turn / lane change (km/h, 0=off)", - "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a latched lever means a left/right turn and a one-touch (half) press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change.", + "edescr": "Hyundai CAN-FD only (0x3C1 lever signal). 0 = off. At or below the set speed a one-touch (half) press, then latching the lever after the lamp has lit, gone dark and lit again, means a left/right turn; a plain latch or a one-touch press means a lane change; no lane change starts while the lever is still in the one-touch position (a latch passes through it for about 0.1 s). Above the set speed any lever input, latched included, is a lane change.", "cgroup": "转向", "ctitle": "转向灯拨杆区分转弯/变道 (km/h, 0=关)", - "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,拨杆拨到底(锁定)为左/右转弯,半拨(单触)为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。", + "cdescr": "仅限现代 CAN-FD(0x3C1 拨杆信号)。0 为关闭。设定速度及以下时,先半拨(单触),转向灯亮-灭-亮之后再拨到底(锁定)为左/右转弯;直接锁定或仅单触为变道;拨杆仍在单触位置时不开始变道(锁定前也会经过该位置约 0.1 秒)。超过设定速度时,即使锁定拨杆也只变道。", "min": 0, "max": 80, "default": 0, diff --git a/openpilot/selfdrive/controls/lib/desire_helper.py b/openpilot/selfdrive/controls/lib/desire_helper.py index ad602242dd0..9c865340cb9 100644 --- a/openpilot/selfdrive/controls/lib/desire_helper.py +++ b/openpilot/selfdrive/controls/lib/desire_helper.py @@ -12,7 +12,7 @@ ) from openpilot.selfdrive.controls.lib.desire_lib.side_state import SideState from openpilot.selfdrive.controls.lib.desire_lib.maneuver_classifier import classify_maneuver_type -from openpilot.selfdrive.controls.lib.desire_lib.lever import LEVER_REQUEST_WAIT_S, LaneCrossing, LeverRequestWait, lever_maneuver +from openpilot.selfdrive.controls.lib.desire_lib.lever import LEVER_REQUEST_WAIT_S, LaneCrossing, LeverForcedTurn, LeverRequestWait, lever_maneuver # Without a model-detected crossing, a lever press this early in laneChangeFinishing is still the driver # re-lighting the lamp of the change that just crossed, not a request for the next one. @@ -63,6 +63,7 @@ def __init__(self): self.finishing_timer = 0.0 self.queued_wait = 0.0 self.lever_wait = LeverRequestWait() + self.lever_forced_turn = LeverForcedTurn() self.crossing = LaneCrossing() self.blinker_hold_direction = LaneChangeDirection.none # modelV2.meta.laneChangeBlinkerHold self.lever_turn = False # modelV2.meta.leverTurn: the turn desire comes from a latched lever @@ -285,6 +286,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa driver_st, driver_changed, driver_enabled = self._update_driver_blinker(carstate) stalk_press = self._update_stalk(carstate) lever = getattr(carstate, "blinkerLever", 0) + forced_turn = self.lever_forced_turn.update(lever, getattr(carstate, "blinkerLamp", 0)) if self.lever_wait.update(self.laneChangeLeverWait, stalk_press, lever, driver_st, self.lane_change_state, self.laneChangeDelay): driver_enabled = driver_changed = False # one-touch request timed out: ignore the lamp until it goes dark @@ -371,7 +373,7 @@ def update(self, carstate, modeldata, lateral_active, lane_change_prob, carrotMa # BlinkerLatchedTurn: the driver's lever replaces the speed/lane-line guess (see lever_maneuver). lever_type, lever_undecided = (None, False) if driver_enabled and side is not None: - lever_type, lever_undecided = lever_maneuver(lever, v_ego, self.blinkerLatchedTurn) + lever_type, lever_undecided = lever_maneuver(lever, v_ego, self.blinkerLatchedTurn, forced_turn) if lever_type is not None: new_type = lever_type lever_decided_turn = lever_type == "turn" diff --git a/openpilot/selfdrive/controls/lib/desire_lib/lever.py b/openpilot/selfdrive/controls/lib/desire_lib/lever.py index a9bf47fbaac..18c1843366d 100644 --- a/openpilot/selfdrive/controls/lib/desire_lib/lever.py +++ b/openpilot/selfdrive/controls/lib/desire_lib/lever.py @@ -15,22 +15,66 @@ LEVER_REQUEST_WAIT_S = LEVER_REQUEST_FLASHES * TURN_SIGNAL_FLASH_S -def lever_maneuver(lever: int, v_ego: float, turn_speed_max: float): +def lever_maneuver(lever: int, v_ego: float, turn_speed_max: float, forced_turn: bool = False): """(maneuver, undecided) chosen by the driver's lever, or (None, False) when the lever does not decide. - turn_speed_max (m/s, 0 = off). At or below it a latched lever is a turn and anything else a lane change; a lever - still in the one-touch detent is undecided, because a latch passes through it for ~0.1 s. Above it every lever - input is a lane change. + turn_speed_max (m/s, 0 = off). At or below it only the forced-turn gesture (LeverForcedTurn: one-touch, two lamp + lightings, then latched) is a turn; a plain latch, like a one-touch, is a lane change (e.g. holding the signal for + the next lane). A lever still in the one-touch detent is undecided, because a latch passes through it for ~0.1 s. + Above it every lever input is a lane change. """ if turn_speed_max <= 0: return None, False if v_ego > turn_speed_max: return "lane_change", False - if lever == LEVER_LATCHED: + if forced_turn: return "turn", False return "lane_change", lever == LEVER_ONE_TOUCH +LAMP_DARK_RESET_S = 1.0 # dark longer than one off phase (0.4 s): the signal has stopped + + +class LeverForcedTurn: + """One-touch lever, lamp lit -> dark -> lit, then latched: the turn gesture below BlinkerLatchedTurn (lever_maneuver). + + The two lamp lightings take about one flash (0.8 s), so a plain latch, which passes the one-touch detent for + ~0.1 s, never qualifies. The turn lasts while the lever stays latched. lamp: carState.blinkerLamp (no hold). + """ + + def __init__(self): + self.armed = False + self.side = BLINKER_NONE + self.lightings = 0 + self.lit = False + self.dark_s = 0.0 + self.active = False + + def update(self, lever: int, lamp: int) -> bool: + if lever == LEVER_LATCHED: + self.active = self.active or (self.armed and self.lightings >= 2) + self.armed = False + return self.active + self.active = False + if lever == LEVER_ONE_TOUCH and not self.armed: + self.armed, self.side, self.lightings, self.lit, self.dark_s = True, lamp, int(lamp != BLINKER_NONE), lamp != BLINKER_NONE, 0.0 + return False + if not self.armed: + return False + if lamp != BLINKER_NONE: + if lamp != self.side: + self.side, self.lightings = lamp, 0 + if not self.lit: + self.lightings += 1 + self.dark_s = 0.0 + else: + self.dark_s += DT_MDL + if self.dark_s > LAMP_DARK_RESET_S: + self.armed = False + self.lit = lamp != BLINKER_NONE + return False + + class LeverRequestWait: """A one-touch lever press asks for one lane change for LEVER_REQUEST_FLASHES flashes from the press. diff --git a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py index 748566e5133..b92d06bac1d 100644 --- a/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py +++ b/openpilot/selfdrive/controls/tests/test_desire_helper_stalk.py @@ -180,8 +180,8 @@ def test_cars_without_lever_counters_keep_the_lamp_behaviour(self): class TestBlinkerLatchedTurn: - """BlinkerLatchedTurn (here 50 km/h): at or below it a latched lever is a turn, a one-touch press a lane change; - above it every lever input is a lane change.""" + """BlinkerLatchedTurn (here 50 km/h): at or below it the turn gesture (one-touch, two lamp lightings, then latched) + is a turn and any other lever input a lane change; above it every lever input is a lane change.""" setup_method = TestDesireHelperStalkRequest.setup_method update = TestDesireHelperStalkRequest.update @@ -191,12 +191,21 @@ def lever_on(self, lever, v_kph=45.0, steps=1): state = self.update(press=(i == 0), lever=lever, v_kph=v_kph) return state + def gesture(self): + # LeverForcedTurn itself is covered by test_lever_forced_turn.py; here a latch counts as the finished gesture. + self.helper.lever_forced_turn.update = lambda lever, lamp: lever == 2 + def test_off_keeps_the_classifier(self): self.lever_on(2, steps=3) assert self.helper.maneuver_type == "lane_change" # 45 km/h, lanes visible: the classifier says lane change - def test_latched_lever_is_a_turn(self): + def test_plain_latch_is_a_lane_change_the_gesture_a_turn(self): + self.helper.blinkerLatchedTurn = 50 / 3.6 + self.lever_on(2, steps=3) + assert self.helper.maneuver_type == "lane_change" # e.g. holding the signal for the next lane + self.setup_method() self.helper.blinkerLatchedTurn = 50 / 3.6 + self.gesture() self.lever_on(2, steps=3) assert self.helper.maneuver_type == "turn" assert self.helper.lane_change_state == LCS.off @@ -204,6 +213,7 @@ def test_latched_lever_is_a_turn(self): def test_latch_passing_the_one_touch_detent_does_not_start_a_lane_change(self): self.helper.blinkerLatchedTurn = 50 / 3.6 + self.gesture() assert self.lever_on(1, steps=3) == LCS.preLaneChange # still in the detent: wait self.update(lever=2, v_kph=45.0) assert self.helper.maneuver_type == "turn" @@ -229,8 +239,9 @@ def test_latched_lever_above_the_set_speed_is_a_lane_change(self): assert self.helper.maneuver_type == "lane_change" assert self.helper.lane_change_state == LCS.laneChangeStarting - def test_latched_lever_at_the_set_speed_is_a_turn(self): + def test_gesture_at_the_set_speed_is_a_turn(self): self.helper.blinkerLatchedTurn = 50 / 3.6 + self.gesture() self.lever_on(2, v_kph=50.0, steps=3) assert self.helper.maneuver_type == "turn" assert self.helper.lever_turn # cluster message: the lever made this turn @@ -250,6 +261,7 @@ def test_lever_turn_flag_only_for_a_lever_made_turn(self): # the lever released: the message goes with the turn self.setup_method() self.helper.blinkerLatchedTurn = 50 / 3.6 + self.gesture() self.lever_on(2, steps=3) assert self.helper.lever_turn self.update(left_blinker=False, lever=0, v_kph=45.0) diff --git a/openpilot/selfdrive/controls/tests/test_lever_forced_turn.py b/openpilot/selfdrive/controls/tests/test_lever_forced_turn.py new file mode 100644 index 00000000000..cc6f732789f --- /dev/null +++ b/openpilot/selfdrive/controls/tests/test_lever_forced_turn.py @@ -0,0 +1,44 @@ +from openpilot.common.realtime import DT_MDL +from openpilot.selfdrive.controls.lib.desire_lib.lever import LEVER_LATCHED, LEVER_ONE_TOUCH, LEVER_RELEASED, LeverForcedTurn + +LIT_S, DARK_S = 0.4, 0.4 # measured lamp phases (0x413 *_LAMP_ALT, 2026-10-11) + + +def _run(t, lever, lamp_side, seconds): + """Feed `seconds` of the flashing lamp (one clock from the first call; lamp_side 0 = dark); returns the last result.""" + out = False + for _ in range(round(seconds / DT_MDL)): + k = t.__dict__.setdefault("_k", 0) + lit = (k * DT_MDL) % (LIT_S + DARK_S) < LIT_S + out = t.update(lever, lamp_side if lamp_side and lit else 0) + t._k = k + 1 + return out + + +def test_one_touch_two_lightings_then_latch_forces_a_turn(): + t = LeverForcedTurn() + _run(t, LEVER_ONE_TOUCH, 2, 0.15) # tap: lit + _run(t, LEVER_RELEASED, 2, 0.75) # released, still flashing: dark, lit again + assert _run(t, LEVER_LATCHED, 2, 0.5) # latched afterwards -> forced turn + assert not _run(t, LEVER_RELEASED, 0, 0.1) # releasing the latch ends it + + +def test_plain_latch_through_the_detent_is_not_forced(): + t = LeverForcedTurn() + _run(t, LEVER_ONE_TOUCH, 1, 0.1) # a latch passes the one-touch detent for ~0.1 s + assert not _run(t, LEVER_LATCHED, 1, 2.0) + + +def test_latch_after_only_one_lighting_is_not_forced(): + t = LeverForcedTurn() + _run(t, LEVER_ONE_TOUCH, 1, 0.15) + _run(t, LEVER_RELEASED, 1, 0.5) # lit, then dark, but not lit again yet + assert not _run(t, LEVER_LATCHED, 1, 1.0) + + +def test_gesture_expires_when_the_lamp_stops(): + t = LeverForcedTurn() + _run(t, LEVER_ONE_TOUCH, 2, 0.15) + _run(t, LEVER_RELEASED, 2, 0.75) + _run(t, LEVER_RELEASED, 0, 1.2) # flashing over + assert not _run(t, LEVER_LATCHED, 2, 1.0) From f6b8945f701f99a4b4b799ad8da60f8162e188c1 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:59:11 +0900 Subject: [PATCH 33/50] Revert "Merge carrot-wip 8e7eec049 into joongyu01/carrot-wip-dev" Back to 6491beedc to check whether the screen got too bright after this update. Revert this revert before merging carrot-wip again, or the reverted changes will not come back. This reverts commit ea286a0b4, reversing changes made to 6491beedc. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mdm-model.yaml | 6 +- .github/workflows/tests.yaml | 2 - AGENTS.md | 78 -------- docs/egpu_persistent_rejection_20261015.md | 73 +------- docs/hud_retry_memory_20261015.md | 163 ----------------- docs/md_v3_20261015.md | 68 ------- docs/s3_release_20261015.md | 67 ------- docs/tesla_pr551_review_20261015.md | 78 -------- docs/user/en/settings.md | 5 +- docs/user/ko/settings.md | 5 +- .../opendbc/car/tesla/carcontroller.py | 9 +- .../opendbc/car/tesla/coop_steering.py | 20 +-- .../tesla/tests/test_controller_feedback.py | 103 ----------- .../car/tesla/tests/test_coop_steering.py | 90 ---------- .../opendbc/safety/safety/safety_tesla.h | 7 +- .../opendbc/safety/tests/test_tesla.py | 58 ------ .../carrot/cluster/cluster_profile.py | 14 +- .../carrot/cluster/cluster_wifi_display.py | 9 +- openpilot/selfdrive/carrot/cluster/main.py | 7 +- openpilot/selfdrive/carrot/cluster_autorun.py | 59 ++---- .../carrot/tests/test_cluster_gc_lifecycle.py | 56 ------ .../tests/test_cluster_retry_logging.py | 66 ------- .../carrot/tests/test_cluster_wifi.py | 59 ------ .../carrot/web/generated/asset-manifest.json | 2 +- .../carrot/web/js/generated/tools.js | 2 +- .../web/src/features/tools/egpu_model.js | 1 - .../controls/lib/latcontrol_angle.py | 2 +- .../controls/tests/test_angle_saturation.py | 22 --- openpilot/selfdrive/modeld/big_model.py | 14 +- openpilot/selfdrive/modeld/egpu_events.py | 18 -- openpilot/selfdrive/modeld/egpu_recovery.py | 32 +--- openpilot/selfdrive/modeld/egpu_usb_wait.py | 4 +- openpilot/selfdrive/modeld/modeld.py | 4 - .../selfdrive/modeld/precompiled_model.py | 26 +-- .../modeld/tests/fixtures/md_v3_metadata.json | 170 ------------------ .../selfdrive/modeld/tests/test_big_model.py | 14 +- .../modeld/tests/test_egpu_events.py | 20 --- .../tests/test_egpu_failure_containment.py | 8 +- .../modeld/tests/test_egpu_recovery.py | 40 +---- .../modeld/tests/test_precompiled_artifact.py | 3 +- .../modeld/tests/test_precompiled_model.py | 38 ++-- .../tests/test_precompiled_rejection_retry.py | 6 +- .../tests/test_panda_firmware_version.py | 5 +- tools/carrot_route_vault/tests/test_viewer.py | 42 ----- tools/carrot_route_vault/viewer.py | 40 ----- tools/esp32s3_display/INSTALL_KO.md | 144 --------------- tools/esp32s3_display/README.md | 27 +-- tools/esp32s3_display/firmware/CMakeLists.txt | 1 - .../firmware/main/usb_bridge.c | 62 ++----- tools/esp32s3_display/installer/index.html | 26 --- tools/esp32s3_display/installer/installer.js | 71 -------- tools/esp32s3_display/installer/manifest.mjs | 21 --- .../installer/tests/install_flow.test.mjs | 55 ------ .../installer/tests/manifest.test.mjs | 21 --- tools/esp32s3_display/package_release.py | 88 --------- tools/esp32s3_display/provision.py | 15 +- tools/modeld/build_md_v3_package.py | 63 ------- 57 files changed, 110 insertions(+), 2099 deletions(-) delete mode 100644 docs/hud_retry_memory_20261015.md delete mode 100644 docs/md_v3_20261015.md delete mode 100644 docs/s3_release_20261015.md delete mode 100644 docs/tesla_pr551_review_20261015.md delete mode 100644 opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py delete mode 100644 openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py delete mode 100644 openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py delete mode 100644 openpilot/selfdrive/controls/tests/test_angle_saturation.py delete mode 100644 openpilot/selfdrive/modeld/egpu_events.py delete mode 100644 openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json delete mode 100644 openpilot/selfdrive/modeld/tests/test_egpu_events.py delete mode 100644 tools/esp32s3_display/INSTALL_KO.md delete mode 100644 tools/esp32s3_display/installer/index.html delete mode 100644 tools/esp32s3_display/installer/installer.js delete mode 100644 tools/esp32s3_display/installer/manifest.mjs delete mode 100644 tools/esp32s3_display/installer/tests/install_flow.test.mjs delete mode 100644 tools/esp32s3_display/installer/tests/manifest.test.mjs delete mode 100644 tools/esp32s3_display/package_release.py delete mode 100644 tools/modeld/build_md_v3_package.py diff --git a/.github/workflows/mdm-model.yaml b/.github/workflows/mdm-model.yaml index f75a66cd850..610f90cff70 100644 --- a/.github/workflows/mdm-model.yaml +++ b/.github/workflows/mdm-model.yaml @@ -1,7 +1,7 @@ name: eGPU model compatibility on: push: - branches: [carrot-wip] + branches: [carrot-wip, carrot-signal-shadow] workflow_dispatch: permissions: contents: read @@ -33,8 +33,8 @@ jobs: openpilot/system/tests/test_model_build_cache.py - name: Download the pinned isolated runtime run: | - curl --fail --retry 3 -o /tmp/mdm-runtime.tar.gz https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/precompiled-runtime.tar.gz - echo 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac /tmp/mdm-runtime.tar.gz' | sha256sum -c - + curl --fail --retry 3 -o /tmp/mdm-runtime.tar.gz https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/precompiled-runtime.tar.gz + echo '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2 /tmp/mdm-runtime.tar.gz' | sha256sum -c - mkdir /tmp/mdm-runtime tar -xzf /tmp/mdm-runtime.tar.gz -C /tmp/mdm-runtime - name: Execute pinned native CPU warp and persistent-buffer roundtrip diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d257afe86af..b30e2ae5b9a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -40,8 +40,6 @@ jobs: openpilot/selfdrive/modeld/tests/test_precompiled_model.py \ openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py \ openpilot/selfdrive/modeld/tests/test_egpu_worker_progress.py \ - openpilot/selfdrive/modeld/tests/test_egpu_events.py \ - openpilot/selfdrive/modeld/tests/test_egpu_recovery.py \ openpilot/selfdrive/modeld/tests/test_helpers.py::test_usbgpu_compiled_path_waits_for_selected_model_instead_of_previous \ openpilot/system/tests/test_model_build_cache.py \ openpilot/system/tests/test_launch_order.py diff --git a/AGENTS.md b/AGENTS.md index dcae7a1c650..fc4b15d8bff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,83 +1,5 @@ # Repository memory -- On 2026-10-15, user requested review/integration of Tesla PR551 c61d998ff8. - Reproduced inactive phase-lead CAN/feedback and saturation defects; Tesla's - encoded zero375 also regranted controls through the shared accel helper. - Port the nine PR files unchanged: measured inactive angle, final feedback, - cooperative torque blending only above25km/h with normal low-speed steering, - output-limit-aware saturation and Tesla-specific longitudinal permission. - Both maintained branches:199 focused tests/128 subtests pass. Three inherited - generic disabled-angle safety cases still fail identically before/after; - do not claim full safety suite success. Native C uses a desktop packet/forward - ABI bridge, not MCU/vehicle validation. Publish single-parent on both branches; - no vehicle installation. See docs/tesla_pr551_review_20261015.md. - -- On 2026-10-15, user requested historical attribution of memory >80%. Read925 - retained local logs (916 own-device with memory) plus on-device qlogs. Earliest - observed >80% is Oct10/108e, but Oct11/1095 stays73--77% with HUD+signals; - not one proven regression date. Current DM warmup/plateau and logger sawtooth - match earlier runs. GC retry fix reduces Python retention, but native HUD ION - buffers persist after process death (89.6MB), and new PID accumulates again. - Do not call memory normal/fixed or recommend process cycling as its fix. - Expected S3 transport log cleanup published both branches; fresh-manager - reboot verifies concise timeout lines (HUD-only restart kept cached code). - Reboot clears old native buffers but four retries retain6.6MB again; current - 81%/685MiB available is not leak clearance. Preboot model/DM1200 frames/60s, - postboot1199 valid consecutive each at19.974Hz, no missing IDs/fallback; - do not claim a strict >=20Hz postboot pass. S3 still times out. See - docs/hud_retry_memory_20261015.md; raw evidence private. - -- On 2026-10-15, user's C4 Git Pull was blocked by two untracked manually - installed eGPU helpers identical to incoming blobs; back up only those files, - preserve unrelated build/model artifacts. Old93160a93 HUD repeatedly lost S3 - before renderer ready. Prior qlogs reach99%; same HUD PID grows168.62->528.55 - MiB RSS, +181.89MiB PSS across segments5->27. Actual failed JPEG pipelines - remain frozen after close/collect; unfreeze releases them. Pair HUD session - freeze with finally unfreeze/threshold restore, retaining active-frame policy. - 27 focused tests and native isolated12-cycle reclamation pass. This fixes a - demonstrated retention path, not every system/native allocation or reboot - cause. User authorizes parked system stop/update; verify fresh P/zero/inactive. - Publish both maintained branches, single-parent. See docs/hud_retry_memory_20261015.md. - -- On 2026-10-15, user requested public S3 firmware/install guide after confirming - the panel wakes and turns off. S3 1.1.0 confirms sync/brightness-off, retries - failed shutdown and reasserts idle-off; C4 offroad dimming includes registered - S3. N16R8 / powered hub / TURZX 1cbe:0092 only. Browser installer and NAS ZIP - preserve NVS; never distribute device flash backups or pairing keys. UART - --auto requires a unique bridge and verifies identity. Native three-phase - 150-frame final test passes with preserved pairing; browser-native flashing - is distinct from UART validation. Publish both maintained branches, single-parent. - See docs/s3_release_20261015.md and tools/esp32s3_display/INSTALL_KO.md. - -- On 2026-10-15, user requested MD v3 PR39087 on carrot-wip ONLY unless already - installed. Prior selection was8d736683; new checkpoint1365126a/12864, AMDd4c7a3fc, - pinned tinygrad02e69816/runtimec7c58622. PR changes only the big PKL; no matching - ONNX, internal/DM and Jetson remain unchanged. Preserve retry/fallback policies; - source-verified new runtime retains USB no-op sleep and +/-1 link-error meanings, - so extend only the exact hash allowlist.411 focused tests/13 Web tests pass, - 8 Windows skips; Linux CI403 plus3 native-runtime tests and HTTPS empty-cache - install/offline reuse pass on8864280c9c. - No vehicle install or native20Hz/driving claim. See docs/md_v3_20261015.md. - -- On 2026-10-15, user requested explicit tmux error/retry visibility after - repeated eGPU investigations. Add bounded stderr [eGPU] lifecycle events with - PID/monotonic time, saved failure cause/classification, attempt/limit/backoff, - waiting reason, probe-ready/handover, actual first live publication and stop - reason. Preserve all retry/validation/model policies; never write diagnostic - text to the worker stdout protocol.179 focused tests pass; no vehicle action. - See docs/egpu_persistent_rejection_20261015.md. - -- On 2026-10-15, identified the persistent Ioniq 5 PE block from actual NAS - diagnostics: pinned90970e01 reports AMD failed with +1 during input upload. - Its usb_reap latches LIBUSB_TRANSFER_ERROR=1; old classifier recognized only-1. - Both branches now classify exactly +/-1 only for that verified runtime, with - previous-boot/hash/artifact/smoke validation for old rejections.175 tests and - the exact recorded traceback probe pass. The failing734634 predates Oct15 - timing changes. Device wall clock was stale; do not assert recorded21:05 as - incident time. Physical USB root cause remains unknown; no vehicle install. - This supersedes the earlier unknown reporter cause below; numerical migration - is a separate correction. See docs/egpu_persistent_rejection_20261015.md. - - On 2026-10-15, a Mountain Dew report remained rejected after reboot. Current wip lacked nav's previous-boot numerical-rejection migration; four positive replay cases fail before the port and pass afterward. Port only that migration, diff --git a/docs/egpu_persistent_rejection_20261015.md b/docs/egpu_persistent_rejection_20261015.md index 79ee000d819..6630b25a4a7 100644 --- a/docs/egpu_persistent_rejection_20261015.md +++ b/docs/egpu_persistent_rejection_20261015.md @@ -2,32 +2,10 @@ ## Findings -The reported Ioniq 5 PE's private NAS evidence now establishes the cause of the -persistent block. Ten diagnostics were copied and SHA256-verified. October 14 -22:09/22:12 captures show eGPU active. October 15 09:42 captures show an inference -failure from code 734634c983; subsequent 09:48/09:51/10:05 captures on 5a33dc40ed -retain the identical rejected failure, with no successful new load. - -The terminal error is `RuntimeError: AMD failed with 1` during input upload in -`GenericModelRuntime.run`. The selected runtime directory is 90970e01f2ba70d5. -Inspection of the SHA256-verified runtime archive -90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2 -shows `tinygrad/runtime/support/usb.py:457` maps AMD.error_state to the USB link -error word; `usb_reap` at lines 312-317 latches the asynchronous transfer status. -The same archive's `autogen/libusb.py:204` defines status 1 as -LIBUSB_TRANSFER_ERROR. The existing classifier accepted only -1, so this verified -USB error was incorrectly treated as an artifact failure and persisted forever. -The exact recorded traceback fails the old classifier and passes the correction. - -The initial captured device wall clock was stale: the October 15 09:42 log has -an embedded October 14 21:05 clock. Do not use that stored time as the actual -incident time. Within that snapshot, the error precedes the later logged USB -unplug; the unplug is not proof of the initial cause. Cable, power, bridge and -host causes are not distinguished by this evidence. The failing code predates -the October 15 response-deadline/wait-yield changes. - -The numerical-rejection issue below was found independently during the initial -code review; it is not the cause of this reporter's USB +1 block. +The supplied Mountain Dew card explicitly reports `rejected`, not a fresh USB +or timeout diagnosis. The reporter says it worked yesterday and restarting or +rebooting did not help. Their device identity, exact commit and saved failure +have not been supplied, so the first failure cause remains unknown. Comparison of current remote branches found a concrete recovery gap: `carrot-wip` 5a33dc40ed lacks `retry_output_rejection`, which carrot-nav already @@ -42,17 +20,12 @@ only writes a waiting status and requests a reboot; it neither repairs the failure nor clears the marker. This can repeatedly show the same card. The October 15 response-deadline change classifies its TimeoutError as transient; -that path does not itself write a persistent rejection. This does not establish which physical or runtime condition caused USB status 1. The prior web +that path does not itself write a persistent rejection. This does not rule out +other recent changes or identify this user's original failure. The prior web map-preview change does not modify the eGPU loader or failure policy. ## Changes and limits -- Both branches now recognize exactly -1 and +1 for the verified runtime hash. - Other runtimes/codes and quoted/nonterminal errors remain rejected. The existing - new-boot migration revalidates old +1 rejections. New +1 faults stop the failed - worker without permanently rejecting the artifact; existing bounded recovery, - internal-model fallback and handover checks remain in force. No runtime binary, - inference deadline, GPU model or USB error suppression is changed. - Port only nav's numerical-rejection migration into wip. Keep the separate branch models, runtimes and per-frame output policies; do not port nav's single-frame rollback implementation with this repair. @@ -83,37 +56,3 @@ road validation. No user's vehicle was restarted, installed or unblocked. After updating wip, a recognized earlier-boot numerical rejection can be retried on boot. A remaining block needs the downloaded diagnostic report; repeatedly rebooting or deleting every rejected marker is not a diagnosis or repair. - -## Reporter-specific follow-up verification - -175 focused tests pass for USB rejection classification, file revalidation and -bounded background recovery. The exact privately saved traceback also passes a -matching-runtime/later-boot migration probe, rejects a same-boot attempt and -preserves the original failure record. This proves recovery eligibility, not -physical USB stability or successful driving. No NAS raw logs are committed. - -## Tmux recovery lifecycle visibility - -The user requested explicit error and retry output so future analysis does not -need to infer the recovery path from a generic card. All added events go to -stderr with `[eGPU]`, process ID and monotonic time; they never enter the worker -stdout control protocol. Failures include phase, model/runtime identity, -terminal error, saved diagnostic path and persistent/transient classification. -The full earlier traceback remains in the existing diagnostic report. - -Recovery prints fallback, scheduled delay and attempt/limit, actual attempt -start, failure/cancellation, probe READY, handover, and terminal stop reason. -Waiting explains absent USB, internal health or backoff. Unchanged waits are -not repeated; changed waiting reasons are limited to one per five seconds. -The modeld first-output publication path separately prints live_output_resumed: -a successful synthetic probe or handover is not claimed as a live result. -Boot-time artifact rejection/migration also prints the retained cause and next -step. Startup without a recovery manager explicitly reports no retry. - -No retry limits, validation gates, inference timeouts, model selection or USB -runtime are changed. A closed diagnostic stream is swallowed, not propagated -into inference.179 focused tests pass on nav before publication; final wip uses -the same tests. Tests check three-attempt exhaustion, delayed handover, absent -USB log volume, old-block cause output and failure of the logging stream. -Syntax checks pass. Targeted lint passes; modeld's existing ISC002/E501/F841 -findings are outside these changes. No physical vehicle/reboot test or install. diff --git a/docs/hud_retry_memory_20261015.md b/docs/hud_retry_memory_20261015.md deleted file mode 100644 index a884f2ea5c4..00000000000 --- a/docs/hud_retry_memory_20261015.md +++ /dev/null @@ -1,163 +0,0 @@ -# HUD retry memory retention, 2026-10-15 - -The user's C4 was still on `93160a93`, 13 commits behind the published NAV -branch. Carrot Web's actual failed Git Pull jobs named two manually installed, -untracked eGPU helpers. Both matched their incoming Git blobs exactly. They -were backed up under `/data/local/tmp/git-pull-repair-20261015` before repair; -unrelated untracked build/model files must not be cleaned. - -The failed pull also reset tracked trial changes. Its running HUD logged -`S3 display is closed` followed by `asynchronous USB JPEG pipeline failed`. -The published renderer-ready Wi-Fi connection fix was absent from this checkout. - -## Memory evidence - -Private previous-session qlogs show memory usage reaching 99%. Between segment -5 and 27, the same HUD PID 208998 grew from 168.62 to 528.55 MiB RSS, with a -181.89 MiB PSS increase. Loggerd RSS rose 25.55 MiB but PSS only 3.19 MiB over -those selected endpoints. RSS contains shared pages and cannot be summed to -explain all system consumption. Available RAM was as low as 65 MiB at a sampled -segment-20 endpoint. These data establish HUD growth as a major contributor, -not a complete attribution of all memory or a proven cause of reboot. - -After reconnection, a new boot initially reported 84% with approximately 533 MiB -available. P, zero speed and disabled controls were verified. Pstore later -confirmed a previous-boot kernel BUG in `dump_throttled_rt_tasks`, with the -interrupted stack in `shrink_slab`/`try_to_free_pages`/`filemap_fault`. The task -named by RT throttling was radarcan. No OOM-killer entry was found; this does not -prove HUD caused the panic or establish a radarcan allocation leak. -The existing radar arena fix was already in the old vehicle commit. - -## Reproduced retention and correction - -`freeze_gc_after_init()` permanently excluded the HUD session's cyclic objects -from collection. Autorun reuses the process after failure. A JPEG worker retains -its failure traceback, which references its frozen pipeline and frame data. -Ordinary `gc.collect()` cannot reclaim this frozen cycle. - -Using the actual AsyncJpegUsbPipeline with a disconnected transport, four old -sessions remained alive after close plus collection. Unfreeze plus collection -released all four. The fix pairs each successful freeze with an unfreeze and -restores prior GC thresholds at the very start of `run_demo`'s finally block, -including errors and normal exit. Active-frame freeze/threshold behavior stays -unchanged; no new collection is inserted in the rendering loop. Cleanup failures -cannot skip the unfreeze. The next initialization collection can reclaim the -previous session after its stack unwinds. - -Validation: 27 focused desktop Wi-Fi/lifecycle tests pass. The new test repeats -12 actual JPEG-worker failure sessions and asserts all weak references are -released and thresholds restored. The same 12-cycle test passes in an isolated -C4 Python process using the candidate helper, without changing driving services. -Changed main source adds no lint findings; existing unrelated findings remain. -This is a reproduced Python retention fix, not proof that every native graphics -allocation is bounded. - -## Installed verification and remaining problems - -Actual Carrot Web Git Pull job `849abc7c4050` completed successfully after a -fresh parked/inactive guard and a bounded stop of manager-owned processes. -Vehicle advanced from `93160a93` to NAV `0cf297c3`, then rebooted. Web status -reported zero ahead/behind and a clean tracked tree. Model and DM each published -1,200 consecutive valid frames in 60 seconds, approximately 20 Hz, with eGPU -active. A subsequent three-minute sample measured 82--86% system usage. HUD PSS -repeatedly returned to approximately 110--126 MiB after failures rather than -retaining every previous Python session. This short test is not a long-term -memory clearance or proof that the system is back to its earlier baseline. - -S3 still authenticates but times out awaiting frame responses. Known S3 -transport errors now report one bounded line with the underlying exception and -the existing five-second retry. Unexpected encoder/programming failures keep -their traceback. Authentication, transport timeouts, queue bounds and retry -intervals are unchanged. Eight diagnostic regression cases plus the existing -27 Wi-Fi/lifecycle tests pass. - -Further native inspection found a separate remaining allocation path: the -HUD PID's ION orphan-labelled buffers rose from 216 objects / 59,719,680 bytes -to 222 / 61,378,560 bytes over a retry interval. The label alone does not prove -an unreachable allocation; repeated growth and actual release must be checked. -This is outside ordinary Python RSS/PSS accounting. Current system usage still -reaches 87%; historical comparison and native-buffer cleanup remain under -investigation. Do not report the overall memory problem or S3 timeout as solved. - -Raw qlogs, process snapshots, tmux excerpts and diagnostic scripts remain private -in `.analysis/scratch/2026-10-15-egpu-s3/` and are not included in firmware releases. - -## Historical comparison requested by the user - -Read 925 distinct locally retained compressed logs; 916 contain memory samples -from the user's device identity (908 also identify Ioniq 5 PE, eight omit -carParams). Combine them with surviving on-device qlogs and deduplicate matching -route/segment observations. This is retained evidence, not every historical -drive. Many old on-device files are truncated; keep their parse errors and do -not infer an entire minute from a fragment. Dates below come from initData and -the corresponding archived collection, not from an inferred panic timestamp. - -The earliest **observed** >80% sample is October 10 route 108e segment 4 on -`cb45d99b61`. It is not the date of a proven code regression: the same revision's -earlier 108c run stayed around 74--76%, and a later October 11 run remained -73--77% with both HUD and signal processes running. - -Representative complete-session observations, excluding segments 0--4: - -| Date / route suffix | Observed segments | Memory range | Median of segment medians | -| --- | --- | --- | --- | -| Oct 10 / 108e | 5--25 | 79--81% | 80% | -| Oct 11 / 1095 | 5--70 | 73--77% | 75% | -| Oct 12 / 109f | 5--10 | 81--83% | 82% | -| Oct 14 / 10bc | 5--72 | 73--83% | 78% | -| Oct 15 / 10ce | 22--115 available | 82--87% | 86% | -| Oct 15 / 10d3, before fix | 5--27 | 88--99% | 95% | -| Oct 15 / 10d4, before fix | 5--20 | 83--94% | 87% | -| Oct 15 / 10d5, GC fix | 5--22 captured | 83--89% | 86% | - -September 25--27 retained samples were generally in the 60s, but HUD PSS was -only approximately 11 MiB (waiting, not rendering), with different model/backend -configuration. They are not a like-for-like target for the current active HUD. -October 11's 1095 is a stronger demonstration that active HUD and signal support -can coexist below 80%; it does not isolate which later configuration accounts -for every additional byte. - -In long earlier sessions, DM PSS rises during the first approximately 20 minutes -then flattens near 156 MiB. Loggerd repeatedly moves between approximately -86--113 MiB rather than accumulating without return. Current values near -151 MiB DM and 87--113 MiB loggerd fit those historical patterns; a pair of -short endpoints alone should not have been interpreted as proof of a leak. -UI remains near 230 MiB, and the fixed radar is near 9 MiB. The previous -runaway HUD is the clearly demonstrated large same-process change today. - -An additional live native check is not normal: HUD PID 53363 accumulated -324 ION orphan-labelled objects / 89,579,520 bytes. They remained after that -process was killed and a new manager-owned HUD PID appeared. The new PID then -accumulated 60 objects / 16,588,800 bytes. Thus process recycling alone is **not** -a demonstrated fix for these buffers. The installed raylib wheel reports -`comma-deps-raylib 6.0.0.1.post98`; do not assume the older checked-in build.sh -source pin describes that binary. Native buffer ownership/cleanup is unresolved. - -After the logging update's actual Web Git Pull, the existing manager continued -forking the already imported autorun code. A HUD-only restart therefore did not -activate its new exception reporter, despite the correct on-disk HEAD. Do not -claim the new log output verified until the manager is freshly loaded. - -Another live 60-second check during this investigation has 1,200 consecutive -valid model and 1,200 DM frames, 20.004/20.000 Hz respectively, unchanged model/DM -PIDs and eGPU active. S3 is disconnected in that final snapshot. Model timing -is currently healthy; retained native buffers and repeated S3 failure mean the -overall state is **not** certified normal or restored to its former memory level. - -### Final activation check - -After preserving the preceding evidence, Web job `4732670e92a9` installed -`cfbc50fb9a`. Another 554 fresh parked/inactive checks preceded an authorized -reboot. The fresh manager now emits the actual expected-failure line -`S3 Wi-Fi disconnected (TimeoutError: timed out); retrying in 5s`, without the -three chained tracebacks. Both maintained branches contain the reporter fix; -its 35 focused checks passed on each branch. - -At uptime 253 seconds, memory was 81%, MemAvailable 701,008 KiB (684.6 MiB). -The old PID's retained allocations disappeared across reboot, but the new HUD -PID already had 24 orphan-labelled objects / 6,635,520 bytes after four failed -sessions. This confirms recurrence; reboot is recovery, not a native-leak fix. -The postboot 60-second model/DM observation had 1,199 consecutive valid frames -each, 19.974 Hz, with no missing frame IDs or eGPU fallback. Report that measured -rate exactly rather than claiming a strictly >=20 Hz postboot pass. S3 still -times out, so healthy end-to-end display operation is not established. diff --git a/docs/md_v3_20261015.md b/docs/md_v3_20261015.md deleted file mode 100644 index 8def5fcd300..00000000000 --- a/docs/md_v3_20261015.md +++ /dev/null @@ -1,68 +0,0 @@ -# Mountain Dew v3 on carrot-wip - -The user requested PR [39087](https://github.com/commaai/openpilot/pull/39087) -on carrot-wip only, unless already selected. The previous selection was retrained -Mountain Dew 8d736683 (AMD 256f2178), so this is a new model. carrot-nav is excluded. - -## Official artifact and runtime - -The merged PR has one changed file: big_driving_tinygrad.pkl. Its head is -e86b58dc7b9fb499dd84ac6dbe514a03fa2bc428 and merge is -1e219d7785cfb7b7343c7f2366347fc86e7ce5f2. - -- Checkpoint: f22e06e5-3b3f-48d2-a6b9-2f32dc20e707/56320/1365126a-e71e-4778-be00-8ab92dc82c09/12864. -- PKL: 799,941,965 bytes, SHA256 d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422. -- PR tinygrad submodule: 02e698160eed1751738212144a7ea2cfb160b3f1. -- Source archive SHA256: 2824b933fbf48df672cbb18c8462bd7f9d637a065f4c510ae3d07dedc01ffcb9. -- Deterministic isolated runtime: 2,740,280 bytes, SHA256 c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac. -- [Download manifest](https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/manifest.json). - -The complete official Hugging Face LFS download matches the committed pointer. -Metadata was inspected with inert pickle globals. Input/output specifications, -shapes, output slices and recurrent-state mappings match the previous model; -only the checkpoint metadata differs. A new fixture exercises parser compatibility. -The package builder preserves the historical model builder and reproduces the new -package from hash-checked source files. NAS publication verifies temporary copies -and publishes the manifest last, in a new directory. - -## Compatibility and recovery - -The PR does not itself modify tinygrad, but its baseline pins a newer runtime than -our previous d3f09c9 package. Keep that upstream-matched runtime isolated from the -internal driving model and DM. Existing byte-addressed input adapters and persistent -buffer loading remain applicable. - -Source inspection of 02e69816 confirms USBIface.sleep is still a no-op, -AMDDevice.sleep_timeout_ms remains 200, usb_reap still polls with zero timeout and -copies the asynchronous status through usb_fail, and usb_setup binds AMD.error_state -to the USB link error word. Therefore add only this exact runtime hash to the existing -1 ms spin / 200 us sleep adaptation and exact +/-1 transport-error classification. -Unknown runtimes and other codes remain blocked. Retry limits, reboot/identity and -artifact/smoke checks, response deadlines, worker disposal, fallback, validity and -control policies are unchanged. Existing diagnostic events are retained. One existing -fallback-handler test needed its log_event stub updated for the earlier diagnostic change. - -## Scope and verification - -The small internal model and driver-monitoring models are unchanged. The official -[ONNX catalog](https://huggingface.co/commaai/openpilot_driving_models/tree/main) -on October 15 has 1a421175, 870a4823, 8d736683 and f78ed37d, but no 1365126a export. -Thus this update selects only the AMD eGPU big model; Jetson remains unchanged. -Web identifies this model as Mountain Dew v3. - -- 411 focused Windows tests pass; 8 platform-dependent skips. -- 13 Web model/status tests pass; generated bundle and hash rebuilt. -- Two native pinned-runtime packed-buffer/state-alias tests pass on Windows. - The native warp test requires clang, unavailable on this workstation. -- [Linux CI 38689958099](https://github.com/ajouatom/openpilot/actions/runs/38689958099) - passes on implementation commit 8864280c9c: 403 contract/delivery/warp-policy tests, - all 3 exact-runtime native buffer/warp/persistent-roundtrip tests, and Web checks. -- Full NAS HTTPS empty-cache installation passes, including complete model/runtime - hashes and offline cache reuse. Download never creates a GPU validation receipt. - -No vehicle was installed or restarted. These checks do not establish physical AMD -inference, 20 Hz performance, driving quality, or USB root-cause resolution for v3. -Private download and install evidence is under .analysis/scratch/2026-10-15-md-v3/. - -Docs-Not-Needed: Selected model artifact and runtime compatibility update; -no setting or user-guide workflow change. diff --git a/docs/s3_release_20261015.md b/docs/s3_release_20261015.md deleted file mode 100644 index a29974df410..00000000000 --- a/docs/s3_release_20261015.md +++ /dev/null @@ -1,67 +0,0 @@ -# S3 1.1.0 release and shutdown correction — 2026-10-15 - -The user's TURZX panel stayed on its manufacturer splash while C4 was offroad. -UART still answered: this was not evidence of a completely dead S3. Resetting the -S3 and receiving successful sync (10) and brightness-zero (14) replies restored -shutdown; the user confirmed the screen went off. The original missed command's -physical cause is not established. - -The firmware marked itself dark even when local shutdown failed, suppressing -further attempts. It now synchronizes, waits 200 ms, checks the brightness reply, -retries failed shutdowns at 1/2/4/8/16 s, and reasserts idle-off every 30 s. Old -session commands cannot change current power state. Setup remains visible; a new -image wakes the display. This is backlight shutdown, not power removal. - -C4 close synchronizes and checks shutdown; offroad periodic dimming now includes -the registered S3 even when no local USB display exists. Direct USB behavior is -preserved. The firmware watchdog also works when C4/network disappears. - -## Distribution - -- [NAS installer/guide](https://upload.shind0.synology.me/models/carrot-s3/index.html) -- [Firmware ZIP](https://upload.shind0.synology.me/models/carrot-s3/carrot-s3-1.1.0.zip) -- [Korean installation guide](../tools/esp32s3_display/INSTALL_KO.md) -- Supported hardware: ESP32-S3 N16R8 with 8 MB octal PSRAM, powered compatible hub, - TURZX 9.2-inch 1cbe:0092, 1920×462. No generic USB-C/HDMI monitor support. -- Installer uses pinned Espressif esptool-js 0.5.6 with licenses; downloads are - SHA-256 checked before writing and flash contents are MD5 checked afterward. -- Existing-install mode verifies partitions and writes only 0x10000 application. - Initial-install mode writes bootloader/partition/app separately; neither erases - NVS. Before/after NVS digest checks protect registration. There is no whole-flash - image, private pairing, Wi-Fi password or device NVS in the distribution. -- `--auto` selects one supported USB UART without probing other devices, then - verifies S3 identity. Cable insertion alone is not automatic registration. -- Public NAS route uses an explicit file allowlist and rejects symlink components; - model and recording access policies are unchanged. - -## Validation and limits - -ESP-IDF 5.4.0 builds the final 874048-byte app. It was UART-flashed to the user's -N16R8; saved device identity and pairing key matched before/after. Each of normal -close, abrupt TCP loss and reconnect sent 50 acknowledged images over 5 s, with -zero USB failures. A longer visual repeat sent 150 images per phase over 15 s; -the user confirmed “켜졌다가 꺼짐.” The final stale-session guard was then rebuilt, -flashed and passed another three-phase 150-image check. - -This validates the bench hardware and displayed wake/off behavior, not every USB -hub or vehicle shutdown scenario. Artificial USB ACK failure/backoff and panel-only -power-cycle fault injection are not physical test claims. The C4 host changes have -focused regression coverage; they do not alter models, driving control or eGPU. -Web Serial page rendering and cancellation are checked in Chrome; actual flash -verification above uses esptool UART. No claim of a completed browser-native flash -is implied by the page check. See release metadata for binary/source hashes. - -Focused checks: 26 Wi-Fi/provision/close/offroad tests; 38 autorun/performance tests -with one Linux-only skip; 12 NAS viewer tests with eight Windows symlink-permission -skips; five JS installer/manifest tests. The latter reject corrupted downloads, -wrong chip/partition, NVS mismatch and writes outside allowed firmware sectors. - -C4 shell validation found UART dependencies under the existing `pydeps` directory, -not the bare system/venv search path. Provisioning adds that bundled directory -when present; the guide uses `python3`, also available in a plain SSH shell. -The three matching-source S3 host files were backed up and installed on C4 after -56 fresh offroad/ignition-OFF/control-inactive checks. Only `carrot_cluster` was -restarted; model/DM were already stopped offroad. The installed native offroad -function discovered the registered S3 and successfully sent brightness zero. -Provisioning imports also pass with the ordinary `/usr/bin/python3` and no -inherited `PYTHONPATH`/`VIRTUAL_ENV`. diff --git a/docs/tesla_pr551_review_20261015.md b/docs/tesla_pr551_review_20261015.md deleted file mode 100644 index 629fe03c055..00000000000 --- a/docs/tesla_pr551_review_20261015.md +++ /dev/null @@ -1,78 +0,0 @@ -# Tesla PR #551 review and integration - -Reviewed https://github.com/ajouatom/openpilot/pull/551 at -`c61d998ff8fdda9cbb8a1a6d5b5d0b198085b9c4` against carrot-nav -`e9b33329e4` and carrot-wip `d329752ab0`. The affected production files -were identical on both maintained branches. The nine PR files are integrated -without additional production changes, preserving the contributor's code and -tests. Publication uses one-parent commits on both branches. - -## Findings - -- Tesla zero acceleration is encoded as 375. The existing shared - `longitudinal_accel_checks` sets `controls_allowed` when its raw input is - nonzero, including this inactive request. The Tesla-specific replacement - checks both bounds and requires longitudinal permission for nonzero physical - acceleration without mutating permission. Other brands' policies are unchanged. - Stock AEB, cancel-only operation and the existing reverse check remain in place. -- Inactive cooperative steering previously returned the phase-lead estimate, - which can differ from the EPS measurement and exceed the angle envelope. - It now sends the measurement clamped to 360 degrees while preserving the - separate phase-lead resume state. -- Controller actuator feedback now follows the final cooperative steering - request and holds that value between 50 Hz steering transmissions. It remains - a requested angle, not measured actuator motion; CAN quantization is 0.1 degree. -- Cooperative torque blending runs only above 25 km/h. At and below that speed, - ordinary lateral control remains active, the final request receives normal - limits referenced to the previous final output, and cooperative state resets. - The boundary uses CarState Float32 precision. Crossing it does not disable - normal lateral control. -- The common angle controller now passes the actual output-limit flag to the - saturation timer, matching the timer's existing contract. This affects all - angle-controlled cars; it suppresses saturation accumulation while the output - is being limited, without changing angle commands or curvature limiting. -- The firmware source-version regression includes the Tesla safety header, so - this safety change participates in the existing firmware rebuild mechanism. - -## Local verification - -Windows Python 3.12, actual Cap'n Proto schemas, DBC encoder/parser, vehicle -model and production controller code were used. The desktop runner substitutes -only default-false Params reads and the 0.01 s control-period import; it does not -replace the control algorithms. No real Params or vehicle is accessed. - -1. Before the production patch, the 54 focused cooperative/feedback/saturation - cases gave **29 failures and 25 passes**. This reproduces the reported - inactive-angle, feedback and saturation defects as well as the new speed - boundary behavior. -2. After the patch, the complete Tesla car test directory, angle saturation - tests and firmware-version tests gave **102 passes and 23 passing subtests**. -3. Baseline and patched production C safety headers were separately compiled - with Zig 0.13, optimization and warnings-as-errors. A temporary desktop bridge - allocates the real native CANPacket structure and adapts the old test - forwarding signature to the current packet API. The production safety - predicates are not replaced. This avoids the existing Python/native packet - ABI mismatch; the missing `parameterized` test dependency was also supplied. -4. Full patched Tesla safety execution gave **97 passes, 105 passing subtests, - 38 skips and 3 failures**. All three failures are inherited - `test_angle_cmd_when_disabled` cases (stock, longitudinal and auto-speed). - They also fail on the baseline, whose additional acceleration-limit tests - and 62 permission subcases fail. The full baseline reports 67 failures - including those subcases, 95 passes and 43 passing subtests. -5. Excluding only that pre-existing common test gives **97 passes, 105 passing - subtests, 37 skips and 4 deselections** (three concrete cases plus the abstract - base). This includes acceleration permission loss, both acceleration bounds, - stock AEB blocking/passthrough, reverse prevention, inactive measured angles, - speed-wheel policy and wrong-safety-mode checks. No new failure remains. - -The focused passing total is **199 tests and 128 subtests**; this is not a claim -that the unfiltered safety suite passes. Raw logs and temporary Windows bridge -are retained privately under `.analysis/scratch/2026-10-15-pr551/`. - -No vehicle installation, firmware flashing, physical EPS/MCU timing or road -validation was performed. The PR author's F4/H7 build result was not independently -repeated here; the native C check above is host execution, not an MCU build. - -Docs-Not-Needed: This is a controller/safety bug-fix port with a fixed cooperative -speed boundary; no user-visible setting is added, removed or redefined, and no -user-guide or Wiki edits were requested. diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index edee00a156e..d09ae230e1e 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -300,10 +300,7 @@ Display contains 34 settings. External-HUD settings control the layout and outpu `ClusterHud` (External HUD Display) switches a **direct USB HUD or a registered ESP32-S3 Wi-Fi HUD** on or off. A HUD connected to Jetson starts automatically even when this value is `0`, and normally turns its display off with ignition. Use the existing always-on `ClusterHudDebug` modes to keep it visible offroad. Brightness and layout settings also apply to the Jetson HUD. -S3 Wi-Fi output requires a TURZX 9.2-inch display, a powered hub, and C4/S3 on the same 2.4 GHz network. Follow the [initial registration instructions](../../../tools/esp32s3_display/README.md) to register once over USB or import the registration file from the phone setup page. A registered S3 reconnects after reboot or an IP change. This path defaults to JPEG quality 50 and a 15 FPS target regardless of `ClusterHudEncoder`, allowing up to 10 FPS while the eGPU is active (direct USB keeps its 5 FPS cap). Actual speed depends on image content and wireless conditions; C4 vehicle measurements remain separate. Remove the registration file and restart HUD to restore direct USB output. - -The [NAS installer and setup guide](https://upload.shind0.synology.me/models/carrot-s3/index.html) provides the N16R8 board checklist, wiring, browser firmware installation, phone/C4 registration and firmware ZIP. S3 1.1.0 confirms and retries shutdown; updating preserves existing registration. - +S3 Wi-Fi output requires a TURZX 9.2-inch display, a powered hub, and C4/S3 on the same 2.4 GHz network. Follow the [initial registration instructions](../../../tools/esp32s3_display/README.md) to register once over USB or import the registration file from the phone setup page. A registered S3 reconnects after reboot or an IP change. This path defaults to JPEG quality 50 and a 15 FPS target regardless of `ClusterHudEncoder`, preserving the existing 5 FPS cap while the eGPU is active. Actual speed depends on image content and wireless conditions; C4 vehicle measurements remain separate. Remove the registration file and restart HUD to restore direct USB output. The Jetson HUD shows the highest internal sensor temperature below `jetSON`, or in the upper-right corner in full-screen navigation/graph modes. If temperature data has not refreshed for three seconds, it shows `--°C`. A top-strip temperature warning starts 5°C below each sensor's configured thermal limit; reaching that limit shows an overheating warning. For example, a device configured to throttle at 99°C warns from 94°C. Check the fan and ventilation when warned. Vehicle driving alerts take precedence, and Jetson temperature remains separate from device temperature/memory statistics. This feature requires updated Jetson HUD software. diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 1016c27257e..f72eb1562c7 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -299,10 +299,7 @@ Bosch MRRevo14F 수신을 지원하는 버전에서 Bosch MRRevo14F 후보 차 `ClusterHud`(외부 HUD 활성화)는 **본체 USB에 직접 연결한 HUD 또는 등록된 ESP32-S3 Wi-Fi HUD**를 켜고 끄는 설정입니다. Jetson에 연결한 HUD는 이 값이 `0`이어도 자동 실행되며, 기본적으로 시동 OFF 시 화면을 끕니다. 시동 OFF에도 표시하려면 기존 `ClusterHudDebug`의 항상 켜기를 사용합니다. 밝기·화면 구성 설정은 Jetson HUD에도 적용됩니다. -S3 Wi-Fi 출력은 TURZX 9.2인치와 전원 공급 허브를 사용하며, C4와 S3가 같은 2.4GHz 네트워크에 연결되어야 합니다. [초기 등록 안내](../../../tools/esp32s3_display/README.md)에 따라 USB 자동 등록 또는 폰 설정 페이지의 등록 파일 가져오기를 한 번 진행하세요. 등록된 S3는 재부팅과 IP 변경 후 자동으로 다시 연결됩니다. 이 경로는 `ClusterHudEncoder` 선택과 무관하게 JPEG 품질 50·목표 15 FPS를 기본으로 사용하며, eGPU 활성 시 최대 10 FPS를 허용합니다(직접 USB는 기존 5 FPS). 실제 속도는 화면 내용과 무선 상태에 따라 달라지며, C4 차량 실측은 별도입니다. 등록 파일을 제거하고 HUD를 재시작하면 직접 USB 출력으로 돌아갑니다. - -S3 N16R8 보드 선택·배선·펌웨어 설치·폰/C4 등록은 [NAS 설치 설명서](https://upload.shind0.synology.me/models/carrot-s3/index.html)에서 진행할 수 있습니다. 펌웨어 ZIP도 같은 페이지에서 제공합니다. S3 1.1.0은 소등 응답 확인과 재시도를 추가했으며, 기존 등록을 보존하여 업데이트합니다. - +S3 Wi-Fi 출력은 TURZX 9.2인치와 전원 공급 허브를 사용하며, C4와 S3가 같은 2.4GHz 네트워크에 연결되어야 합니다. [초기 등록 안내](../../../tools/esp32s3_display/README.md)에 따라 USB 자동 등록 또는 폰 설정 페이지의 등록 파일 가져오기를 한 번 진행하세요. 등록된 S3는 재부팅과 IP 변경 후 자동으로 다시 연결됩니다. 이 경로는 `ClusterHudEncoder` 선택과 무관하게 JPEG 품질 50·목표 15 FPS를 기본으로 사용하며, 기존 eGPU 활성 시 5 FPS 제한을 유지합니다. 실제 속도는 화면 내용과 무선 상태에 따라 달라지며, C4 차량 실측은 별도입니다. 등록 파일을 제거하고 HUD를 재시작하면 직접 USB 출력으로 돌아갑니다. Jetson HUD에는 `jetSON` 아래에 Jetson 내부 센서의 최고 온도가 표시됩니다. 전체 내비게이션·그래프 화면에서는 우측 상단에 표시하며, 온도 정보가 3초 이상 갱신되지 않으면 `--°C`로 바뀝니다. 각 센서의 장치 설정 온도 한계보다 5°C 낮은 지점부터 상단에 온도 높음 경고, 한계 도달 시 과열 경고를 표시합니다. 예를 들어 성능 제한 온도가 99°C인 장치는 94°C부터 사전 경고합니다. 경고가 나타나면 팬과 통풍 상태를 확인하세요. 차량 운전 경고가 우선 표시되며, 이 온도 표시는 본체 온도·메모리 통계와 구분됩니다. 이 기능은 업데이트된 Jetson HUD 소프트웨어가 필요합니다. diff --git a/opendbc_repo/opendbc/car/tesla/carcontroller.py b/opendbc_repo/opendbc/car/tesla/carcontroller.py index 204128685c5..59eedb7b128 100644 --- a/opendbc_repo/opendbc/car/tesla/carcontroller.py +++ b/opendbc_repo/opendbc/car/tesla/carcontroller.py @@ -20,7 +20,6 @@ class CarController(CarControllerBase): def __init__(self, dbc_names, CP): super().__init__(dbc_names, CP) self.apply_angle_last = 0 - self.apply_angle_out = 0 self.packer = CANPacker(dbc_names[Bus.party]) self.tesla_can = TeslaCAN(CP, self.packer) self.coop_steering = True @@ -47,12 +46,10 @@ def update(self, CC, CS, now_nanos): if self.coop_steering: coop_result = self.coop_steer.update(self.apply_angle_last, lat_active, CS, self.VM) - self.apply_angle_out = coop_result.steeringAngleDeg - can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_out, coop_result.lat_active, + can_sends.append(self.tesla_can.create_steering_control(coop_result.steeringAngleDeg, coop_result.lat_active, (self.frame // CarControllerParams.STEER_STEP) % 16)) else: - self.apply_angle_out = self.apply_angle_last - can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_out, lat_active, + can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_last, lat_active, (self.frame // CarControllerParams.STEER_STEP) % 16)) if self.frame % 10 == 0: @@ -77,7 +74,7 @@ def update(self, CC, CS, now_nanos): # TODO: HUD control new_actuators = actuators.as_builder() - new_actuators.steeringAngleDeg = self.apply_angle_out + new_actuators.steeringAngleDeg = self.apply_angle_last self.frame += 1 return new_actuators, can_sends \ No newline at end of file diff --git a/opendbc_repo/opendbc/car/tesla/coop_steering.py b/opendbc_repo/opendbc/car/tesla/coop_steering.py index 0ba484c9b61..40164901826 100644 --- a/opendbc_repo/opendbc/car/tesla/coop_steering.py +++ b/opendbc_repo/opendbc/car/tesla/coop_steering.py @@ -4,14 +4,11 @@ from dataclasses import replace from opendbc.car import structs, rate_limit, DT_CTRL, apply_steer_angle_limits_vm -from opendbc.car.common.conversions import Conversions as CV from opendbc.car.vehicle_model import VehicleModel from opendbc.car.tesla.values import CarControllerParams DT_LAT_CTRL = DT_CTRL * CarControllerParams.STEER_STEP -# Match CarState's Float32 speed representation so exactly 25 km/h remains disabled. -COOP_STEERING_MIN_SPEED = float(np.float32(25.0 * CV.KPH_TO_MS)) class CoopSteeringCarControllerParams(CarControllerParams): @@ -78,7 +75,6 @@ def update(self, angle: float, angle_delta_lim: float) -> float: class CoopSteeringCarController: def __init__(self): self.apply_angle_last = 0 - self.apply_angle_out_last = 0 self.coop_apply_angle_sat_last = 0 self.angle_override = 0 self.resume_rate_limiter_delta = SteerRateLimiter() @@ -130,16 +126,6 @@ def resume_steer_desired_rate_limit(self, lat_active: bool, apply_angle: float, return apply_angle_lim def update(self, apply_angle, lat_active, CS: structs.CarState, VM: VehicleModel) -> CoopSteeringData: - if lat_active and not CS.out.vEgo > COOP_STEERING_MIN_SPEED: - # Keep normal steering limits referenced to the last final request when leaving cooperative control. - apply_angle = apply_steer_angle_limits_vm(apply_angle, self.apply_angle_out_last, CS.out.vEgoRaw, - CS.out.steeringAngleDeg, lat_active, CarControllerParams, VM) - self.reset_override_state(apply_angle) - self.resume_rate_limiter_delta.reset(0.0) - self.resume_rate_limiter.reset(apply_angle) - self.apply_angle_out_last = apply_angle - return CoopSteeringData(apply_angle, lat_active, 1) - # estimate real steering angle by adding rate to the tesla filtered angle steeringAngleDegPhaseLead = CS.out.steeringAngleDeg + CS.out.steeringRateDeg / STEERING_DEG_PHASE_LEAD_COEFF @@ -148,10 +134,7 @@ def update(self, apply_angle, lat_active, CS: structs.CarState, VM: VehicleModel if not lat_active: self.reset_override_state(apply_angle) - # Inactive CAN must follow the EPS measurement, not the phase-lead estimate. - inactive_angle = apply_bounds(CS.out.steeringAngleDeg, CarControllerParams.ANGLE_LIMITS.STEER_ANGLE_MAX) - self.apply_angle_out_last = inactive_angle - return CoopSteeringData(inactive_angle, lat_active, 1) + return CoopSteeringData(apply_angle, lat_active, 1) apply_angle_step = apply_angle - self.apply_angle_last self.apply_angle_last = apply_angle @@ -163,5 +146,4 @@ def update(self, apply_angle, lat_active, CS: structs.CarState, VM: VehicleModel self.coop_apply_angle_sat_last = apply_steer_angle_limits_vm(apply_angle, self.coop_apply_angle_sat_last, CS.out.vEgoRaw, CS.out.steeringAngleDeg, lat_active, CoopSteeringCarControllerParams, VM) self.angle_override = self.unwind_on_saturation(self.angle_override, apply_angle - self.coop_apply_angle_sat_last) - self.apply_angle_out_last = self.coop_apply_angle_sat_last return CoopSteeringData(self.coop_apply_angle_sat_last, lat_active, 1) diff --git a/opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py b/opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py deleted file mode 100644 index a040063dc45..00000000000 --- a/opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py +++ /dev/null @@ -1,103 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from opendbc.can import CANParser -from opendbc.car import Bus, structs, apply_steer_angle_limits_vm -from opendbc.car.tesla.carcontroller import CarController -from opendbc.car.tesla.interface import CarInterface -from opendbc.car.tesla.values import CAR, CarControllerParams - - -@pytest.mark.parametrize("speed_kph", [0.0, 24.9, 25.0, 25.1, 36.0]) -@pytest.mark.parametrize("cooperative", [False, True]) -@pytest.mark.parametrize("active,override", [(False, False), (True, False), (True, True)]) -def test_feedback_matches_final_steering_request_and_holds_between_transmissions(speed_kph, cooperative, active, override): - cp = CarInterface.get_non_essential_params(CAR.TESLA_MODEL_3) - controller = CarController({Bus.party: "tesla_model3_party"}, cp) - controller.coop_steering = cooperative - state = SimpleNamespace( - out=structs.CarState(vEgo=speed_kph / 3.6, vEgoRaw=speed_kph / 3.6, - steeringAngleDeg=12.3, steeringRateDeg=80.0, steeringTorque=1.0), - steering_disengage=override, tesla_manual_speed_adjustment_counter=0, - tesla_speed_auto_resume_gesture_counter=0, tesla_speed_limit_target_nanos=0, - das_accCancel=False, cruise_override=False, das_control={"DAS_controlCounter": 0}, - ) - command = structs.CarControl(latActive=active) - command.actuators.steeringAngleDeg = 30.0 - parser = CANParser("tesla_model3_party", [("DAS_steeringControl", 50)], 0) - - output, messages = controller.update(command.as_reader(), state, 0) - parser.update([(0, messages)]) - request = parser.vl["DAS_steeringControl"] - assert output.steeringAngleDeg == pytest.approx(-request["DAS_steeringAngleRequest"], abs=0.051) - assert bool(request["DAS_steeringControlType"]) == (active and not override) - if cooperative and (not active or override): - assert output.steeringAngleDeg == pytest.approx(state.out.steeringAngleDeg) - if cooperative and active and not override and speed_kph <= 25.0: - assert controller.coop_steer.angle_override == 0.0 - assert output.steeringAngleDeg == pytest.approx(controller.apply_angle_last) - - command.actuators.steeringAngleDeg = -30.0 - next_output, next_messages = controller.update(command.as_reader(), state, 10_000_000) - assert all(message[0] != 0x488 for message in next_messages) - assert next_output.steeringAngleDeg == output.steeringAngleDeg - - -@pytest.mark.parametrize("torque", [-1.5, 1.5]) -def test_speed_transitions_preserve_active_steering_and_final_request_limits(torque): - cp = CarInterface.get_non_essential_params(CAR.TESLA_MODEL_3) - controller = CarController({Bus.party: "tesla_model3_party"}, cp) - state = SimpleNamespace( - out=structs.CarState(steeringTorque=torque), - steering_disengage=False, tesla_manual_speed_adjustment_counter=0, - tesla_speed_auto_resume_gesture_counter=0, tesla_speed_limit_target_nanos=0, - das_accCancel=False, cruise_override=False, das_control={"DAS_controlCounter": 0}, - ) - command = structs.CarControl(latActive=True) - command.actuators.steeringAngleDeg = 30.0 - previous_output = 0.0 - parser = CANParser("tesla_model3_party", [("DAS_steeringControl", 50)], 0) - - for speed_kph in (30.0, 25.1, 25.0, 24.9, 10.0, 0.0, 24.9, 25.0, 25.1, 30.0): - state.out.vEgo = state.out.vEgoRaw = speed_kph / 3.6 - for _ in range(40): - state.out.steeringAngleDeg = previous_output - output, messages = controller.update(command.as_reader(), state, controller.frame * 10_000_000) - if any(message[0] == 0x488 for message in messages): - parser.update([(0, messages)]) - request = parser.vl["DAS_steeringControl"] - assert request["DAS_steeringControlType"] == 1 - assert output.steeringAngleDeg == pytest.approx(-request["DAS_steeringAngleRequest"], abs=0.051) - limited = apply_steer_angle_limits_vm(output.steeringAngleDeg, previous_output, state.out.vEgoRaw, - state.out.steeringAngleDeg, True, CarControllerParams, controller.VM) - assert output.steeringAngleDeg == pytest.approx(limited, abs=1e-5) - if speed_kph <= 25.0: - assert controller.coop_steer.angle_override == 0.0 - else: - assert output.steeringAngleDeg == previous_output - previous_output = output.steeringAngleDeg - - -def test_low_speed_engagement_uses_last_inactive_request_not_phase_lead(): - cp = CarInterface.get_non_essential_params(CAR.TESLA_MODEL_3) - controller = CarController({Bus.party: "tesla_model3_party"}, cp) - state = SimpleNamespace( - out=structs.CarState(vEgo=25.0 / 3.6, vEgoRaw=25.0 / 3.6, - steeringAngleDeg=12.3, steeringRateDeg=80.0, steeringTorque=1.0), - steering_disengage=False, tesla_manual_speed_adjustment_counter=0, - tesla_speed_auto_resume_gesture_counter=0, tesla_speed_limit_target_nanos=0, - das_accCancel=False, cruise_override=False, das_control={"DAS_controlCounter": 0}, - ) - command = structs.CarControl() - command.actuators.steeringAngleDeg = 30.0 - inactive, _ = controller.update(command.as_reader(), state, 0) - assert controller.coop_steer.coop_apply_angle_sat_last > inactive.steeringAngleDeg - - command.latActive = True - controller.update(command.as_reader(), state, 10_000_000) - output, _ = controller.update(command.as_reader(), state, 20_000_000) - expected = apply_steer_angle_limits_vm(command.actuators.steeringAngleDeg, inactive.steeringAngleDeg, - state.out.vEgoRaw, state.out.steeringAngleDeg, True, CarControllerParams, controller.VM) - assert output.steeringAngleDeg == pytest.approx(expected) - assert controller.coop_steer.angle_override == 0.0 diff --git a/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py b/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py index a885d5e74c0..62b943e7f66 100644 --- a/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py +++ b/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py @@ -4,10 +4,8 @@ from opendbc.car.tesla.coop_steering import ( CoopSteeringCarController, - STEERING_DEG_PHASE_LEAD_COEFF, STEER_OVERRIDE_MIN_TORQUE, ) -from opendbc.car.tesla.values import CarControllerParams class LinearVehicleModel: @@ -63,91 +61,3 @@ def test_update_resets_override_when_lateral_control_disables(monkeypatch): assert result.steeringAngleDeg == 0.0 assert controller.angle_override == 0.0 - - -@pytest.mark.parametrize("angle,rate", [(12.3, 80.0), (-15.6, -120.0), (420.0, 32.0), (-420.0, -32.0)]) -def test_inactive_request_tracks_measurement_but_preserves_resume_state(angle, rate): - controller = CoopSteeringCarController() - state = fake_state() - state.out.steeringAngleDeg = angle - state.out.steeringRateDeg = rate - controller.angle_override = 3.0 - - result = controller.update(20.0, False, state, LinearVehicleModel()) - - assert not result.lat_active - assert result.steeringAngleDeg == pytest.approx(max(-360.0, min(360.0, angle))) - phase_lead = angle + rate / STEERING_DEG_PHASE_LEAD_COEFF - assert controller.apply_angle_last == phase_lead - assert controller.coop_apply_angle_sat_last == phase_lead - assert controller.resume_rate_limiter._last == phase_lead - assert controller.resume_rate_limiter_delta._last == 0.0 - assert controller.angle_override == 0.0 - assert controller.apply_angle_out_last == result.steeringAngleDeg - - -@pytest.mark.parametrize("speed_kph", [0.0, 10.0, 24.999, 25.0, 25.001, 36.0]) -def test_cooperative_steering_requires_speed_strictly_above_25_kph(monkeypatch, speed_kph): - monkeypatch.setattr("opendbc.car.tesla.coop_steering.apply_steer_angle_limits_vm", lambda angle, *_args: angle) - controller = CoopSteeringCarController() - state = fake_state(speed=speed_kph / 3.6, torque=1.5) - state.out.steeringRateDeg = 80.0 - result = controller.update(5.0, True, state, LinearVehicleModel()) - - assert result.lat_active - if speed_kph <= 25.0: - assert result.steeringAngleDeg == 5.0 - assert controller.angle_override == 0.0 - assert controller.resume_rate_limiter._last == 5.0 - assert controller.resume_rate_limiter_delta._last == 0.0 - else: - assert controller.angle_override > 0.0 - assert result.steeringAngleDeg != 5.0 - - -def test_crossing_speed_threshold_clears_override_and_restarts_from_last_output(monkeypatch): - monkeypatch.setattr("opendbc.car.tesla.coop_steering.apply_steer_angle_limits_vm", lambda angle, *_args: angle) - controller = CoopSteeringCarController() - state = fake_state(speed=30.0 / 3.6, torque=1.5) - for _ in range(20): - controller.update(5.0, True, state, LinearVehicleModel()) - assert controller.angle_override > 0.0 - - state.out.vEgo = state.out.vEgoRaw = 25.0 / 3.6 - low_result = controller.update(2.0, True, state, LinearVehicleModel()) - assert low_result.steeringAngleDeg == 2.0 - assert low_result.lat_active - assert controller.angle_override == 0.0 - assert controller.apply_angle_last == controller.coop_apply_angle_sat_last == 2.0 - - state.out.vEgo = state.out.vEgoRaw = 30.0 / 3.6 - state.out.steeringTorque = 0.0 - resumed = controller.update(10.0, True, state, LinearVehicleModel()) - fresh = CoopSteeringCarController() - state.out.vEgo = state.out.vEgoRaw = 25.0 / 3.6 - fresh.update(2.0, True, state, LinearVehicleModel()) - state.out.vEgo = state.out.vEgoRaw = 30.0 / 3.6 - expected = fresh.update(10.0, True, state, LinearVehicleModel()) - assert resumed == expected - assert 2.0 < resumed.steeringAngleDeg < 10.0 - assert controller.angle_override == 0.0 - - -def test_low_speed_uses_normal_limits_from_last_final_output(monkeypatch): - calls = [] - - def limit(angle, previous, speed, measured, active, limits, model): - calls.append((angle, previous, speed, measured, active, limits, model)) - return previous + 0.2 - - monkeypatch.setattr("opendbc.car.tesla.coop_steering.apply_steer_angle_limits_vm", limit) - controller = CoopSteeringCarController() - controller.apply_angle_out_last = 10.0 - controller.angle_override = 3.0 - state = fake_state(speed=25.0 / 3.6, torque=1.5) - model = LinearVehicleModel() - result = controller.update(20.0, True, state, model) - assert calls == [(20.0, 10.0, state.out.vEgoRaw, 0.0, True, CarControllerParams, model)] - assert result.steeringAngleDeg == 10.2 - assert result.lat_active - assert controller.angle_override == 0.0 diff --git a/opendbc_repo/opendbc/safety/safety/safety_tesla.h b/opendbc_repo/opendbc/safety/safety/safety_tesla.h index 4a6b61351a2..a082934fad8 100644 --- a/opendbc_repo/opendbc/safety/safety/safety_tesla.h +++ b/opendbc_repo/opendbc/safety/safety/safety_tesla.h @@ -220,11 +220,8 @@ static bool tesla_tx_hook(const CANPacket_t *to_send) { } // Don't allow any acceleration limits above the safety limits - violation |= (raw_accel_max > TESLA_LONG_LIMITS.max_accel) || (raw_accel_max < TESLA_LONG_LIMITS.min_accel); - violation |= (raw_accel_min > TESLA_LONG_LIMITS.max_accel) || (raw_accel_min < TESLA_LONG_LIMITS.min_accel); - // Tesla's zero acceleration is encoded as 375, not 0. TX must never grant controls. - violation |= !get_longitudinal_allowed() && ((raw_accel_max != TESLA_LONG_LIMITS.inactive_accel) || - (raw_accel_min != TESLA_LONG_LIMITS.inactive_accel)); + violation |= longitudinal_accel_checks(raw_accel_max, TESLA_LONG_LIMITS); + violation |= longitudinal_accel_checks(raw_accel_min, TESLA_LONG_LIMITS); } else { // does allowing cancel here disrupt stock AEB? TODO: find out and add safety or remove comment // Can only send cancel longitudinal messages when not controlling longitudinal diff --git a/opendbc_repo/opendbc/safety/tests/test_tesla.py b/opendbc_repo/opendbc/safety/tests/test_tesla.py index 05b8044487e..38123d5c78e 100755 --- a/opendbc_repo/opendbc/safety/tests/test_tesla.py +++ b/opendbc_repo/opendbc/safety/tests/test_tesla.py @@ -103,15 +103,6 @@ def test_vehicle_speed_measurements(self): self._common_measurement_test(self._speed_msg, 0, 285 / 3.6, 1, self.safety.get_vehicle_speed_min, self.safety.get_vehicle_speed_max) - def test_inactive_angle_requires_clamped_measurement(self): - self.safety.set_controls_allowed(False) - for measured in (-420.0, -360.0, -12.3, 12.3, 360.0, 420.0): - with self.subTest(measured=measured): - self._reset_angle_measurement(measured) - inactive = max(-self.STEER_ANGLE_MAX, min(self.STEER_ANGLE_MAX, measured)) - self.assertTrue(self._tx(self._angle_cmd_msg(inactive, False))) - self.assertFalse(self._tx(self._angle_cmd_msg(inactive + 1.0, False))) - class TestTeslaStockSafety(TestTeslaSafetyBase): @@ -149,55 +140,6 @@ def test_no_aeb(self): for aeb_event in range(4): self.assertEqual(self._tx(self._long_control_msg(10, aeb_event=aeb_event)), aeb_event == 0) - def test_acceleration_tx_never_grants_controls(self): - for flags in (1, 3, 5, 7): - for accel in (0.0, 1.0, -1.0, 2.1, -3.6): - with self.subTest(flags=flags, accel=accel): - self.safety.set_safety_hooks(CarParams.SafetyModel.tesla, flags) - self.safety.init_tests() - self.safety.set_controls_allowed(False) - self.assertEqual(self._tx(self._accel_msg(accel)), accel == 0.0) - self.assertFalse(self.safety.get_controls_allowed()) - - def test_permission_loss_blocks_actuation_without_tx_reengagement(self): - for cause in ("cruise", "brake", "gas"): - with self.subTest(cause=cause): - self._reset_safety_hooks() - self.safety.init_tests() - self.assertTrue(self._rx(self._pcm_status_msg(True))) - self.assertTrue(self.safety.get_controls_allowed()) - self.assertTrue(self._tx(self._accel_msg(1.0))) - if cause == "cruise": - self.assertTrue(self._rx(self._pcm_status_msg(False))) - elif cause == "brake": - self.assertTrue(self._rx(self._user_brake_msg(True))) - else: - self.assertTrue(self._rx(self._user_gas_msg(10))) - self.assertFalse(self._tx(self._accel_msg(1.0))) - self.assertTrue(self._tx(self._accel_msg(0.0))) - if cause != "gas": - self.assertFalse(self.safety.get_controls_allowed()) - - def test_each_acceleration_bound_requires_permission_and_valid_range(self): - for enabled in (False, True): - self.safety.set_controls_allowed(enabled) - for minimum, maximum in ((0, 0), (-1, 0), (0, 1), (-3.48, 2), (-3.6, 0), (0, 2.1), (2.1, 0), (0, -3.6)): - with self.subTest(enabled=enabled, minimum=minimum, maximum=maximum): - valid_range = self.MIN_ACCEL <= minimum <= self.MAX_ACCEL and self.MIN_ACCEL <= maximum <= self.MAX_ACCEL - inactive = minimum == maximum == 0 - self.assertEqual(self._tx(self._long_control_msg(10, accel_limits=(minimum, maximum))), - valid_range and (enabled or inactive)) - self.assertEqual(self.safety.get_controls_allowed(), enabled) - - def test_stock_aeb_still_blocks_host_commands(self): - self.safety.set_controls_allowed(True) - self.assertTrue(self._tx(self._accel_msg(1.0))) - self.assertTrue(self._rx(self._long_control_msg(10, aeb_event=1, bus=2))) - self.assertFalse(self._tx(self._accel_msg(1.0))) - self.assertFalse(self._tx(self._accel_msg(0.0))) - self.assertTrue(self._rx(self._long_control_msg(10, aeb_event=0, bus=2))) - self.assertTrue(self._tx(self._accel_msg(1.0))) - def test_stock_aeb_passthrough(self): no_aeb_msg = self._long_control_msg(10, aeb_event=0) no_aeb_msg_cam = self._long_control_msg(10, aeb_event=0, bus=2) diff --git a/openpilot/selfdrive/carrot/cluster/cluster_profile.py b/openpilot/selfdrive/carrot/cluster/cluster_profile.py index aa5f41cecfe..f6d28689a6a 100644 --- a/openpilot/selfdrive/carrot/cluster/cluster_profile.py +++ b/openpilot/selfdrive/carrot/cluster/cluster_profile.py @@ -205,12 +205,11 @@ def __call__(self, phase: str, info: dict[str, Any]) -> None: self.profile.add(f"gc.gen{generation}", (time.perf_counter() - start_time) * 1000.0) -def freeze_gc_after_init(profile: ProfileReporter) -> tuple[int, int, int] | None: +def freeze_gc_after_init(profile: ProfileReporter) -> None: freeze = getattr(gc, "freeze", None) if freeze is None: return - previous_thresholds = gc.get_threshold() profile_stage = time.perf_counter() gc.collect(2) profile.add_elapsed("gc.freeze_init.collect", profile_stage) @@ -219,14 +218,3 @@ def freeze_gc_after_init(profile: ProfileReporter) -> tuple[int, int, int] | Non freeze() profile.add_elapsed("gc.freeze_init.freeze", profile_stage) gc.set_threshold(*_post_freeze_gc_thresholds()) - return previous_thresholds - - -def restore_gc_after_run(previous_thresholds: tuple[int, int, int] | None) -> None: - if previous_thresholds is None: - return - # A failed JPEG worker keeps a traceback cycle through its frozen pipeline. - # Autorun retries in this process: let the next init collection reclaim the - # previous session, while preserving freeze during the active frame loop. - gc.unfreeze() - gc.set_threshold(*previous_thresholds) diff --git a/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py b/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py index 0498daec3ed..c086781e458 100644 --- a/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py +++ b/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py @@ -1,6 +1,5 @@ """C4 renders the existing HUD; S3 forwards the resulting TURZX USB packets.""" from collections import deque -import time from cluster_usb_display import TuringUsbDisplay, _set_cluster_hud_connected from cluster_wifi_protocol import Connection, load_config @@ -29,9 +28,7 @@ def open(self) -> None: self.landscape_width, self.landscape_height = 1920, 462 self._disconnected = False self._send_command(10, 'sync') - time.sleep(0.2) - self._send_command(14, 'brightness', {8: int(self.brightness / 100 * 102)}, - expect_response=self.brightness == 0, log=False) + self._send_command(14, 'brightness', {8: int(self.brightness / 100 * 102)}, expect_response=False, log=False) _set_cluster_hud_connected(True) except Exception as exc: self.close() @@ -83,9 +80,7 @@ def close(self) -> None: if self.connection is not None: try: if not self._disconnected: - self._send_command(10, 'sync-off', log=False) - time.sleep(0.2) - self._send_command(14, 'brightness-off', {8: 0}, log=False) + self._send_command(14, 'brightness-off', {8: 0}, expect_response=False, log=False) except (RuntimeError, OSError): pass finally: diff --git a/openpilot/selfdrive/carrot/cluster/main.py b/openpilot/selfdrive/carrot/cluster/main.py index 01c6ad4cc49..b432a9bd9b5 100644 --- a/openpilot/selfdrive/carrot/cluster/main.py +++ b/openpilot/selfdrive/carrot/cluster/main.py @@ -68,7 +68,7 @@ from cluster_live import OpenpilotLiveSource from cluster_models import RouteOverlay, SimulatorInput from cluster_navi_overlay import merge_navi_overlay_state -from cluster_profile import GcProfileHook, ProfileReporter, freeze_gc_after_init, restore_gc_after_run +from cluster_profile import GcProfileHook, ProfileReporter, freeze_gc_after_init from cluster_renderer import ClusterUiRenderer from cluster_route_replay import ( ROUTE_CORNER_SOURCE_CHOICES, @@ -1020,7 +1020,6 @@ def switch_route_source( ) return True - previous_gc_thresholds = None try: if hud_output_gate_param_reader is not None and not hud_output_gate_param_reader.allowed(): print( @@ -1131,7 +1130,7 @@ def switch_route_source( flush=True, ) if gc_freeze_init: - previous_gc_thresholds = freeze_gc_after_init(profile) + freeze_gc_after_init(profile) while True: if stop_requested: break @@ -1910,8 +1909,6 @@ def switch_route_source( report_frames = 0 last_report_time = now finally: - # Restore even if a later native-resource close raises an exception. - restore_gc_after_run(previous_gc_thresholds) if usb_display is not None: _set_cluster_hud_connected(False) scheduler.update(False, force=True, child_pid=h264_pipeline.encoder_pid if h264_pipeline is not None else None) diff --git a/openpilot/selfdrive/carrot/cluster_autorun.py b/openpilot/selfdrive/carrot/cluster_autorun.py index d70b7946fb3..87be88497cd 100644 --- a/openpilot/selfdrive/carrot/cluster_autorun.py +++ b/openpilot/selfdrive/carrot/cluster_autorun.py @@ -81,39 +81,6 @@ def _is_usb_disconnect_error(exc: BaseException) -> bool: return False -def _s3_transport_failure_reason(exc: BaseException) -> str | None: - """Only shorten a transport failure inside the known S3 wrappers.""" - current: BaseException | None = exc - seen: set[int] = set() - s3_failure = False - transport_error: OSError | None = None - while current is not None and id(current) not in seen: - seen.add(id(current)) - if str(current) in ( - "S3 display disconnected; reconnect required", - "S3 display connection failed; waiting for rediscovery", - ): - s3_failure = True - if isinstance(current, OSError): - transport_error = current - current = current.__cause__ or current.__context__ - if not s3_failure or transport_error is None: - return None - detail = " ".join(str(transport_error).split())[:160] - return f"{type(transport_error).__name__}: {detail}" - - -def _report_cluster_failure(exc: BaseException) -> None: - reason = _s3_transport_failure_reason(exc) - if reason is not None: - print(f"[cluster_autorun] S3 Wi-Fi disconnected ({reason}); retrying in {RETRY_INTERVAL_S:.0f}s", flush=True) - elif _is_usb_disconnect_error(exc): - print(f"[cluster_autorun] USB display disconnected; retrying in {RETRY_INTERVAL_S:.0f}s", flush=True) - else: - print(f"[cluster_autorun] cluster HUD failed: {exc}; retrying in {RETRY_INTERVAL_S:.0f}s", flush=True) - traceback.print_exc() - - def _read_hud_mode(params: Params) -> int: try: return int(params.get_int(HUD_PARAM)) @@ -443,19 +410,11 @@ def _turn_off_supported_usb_device(expected_product_id: int, reason: str) -> boo _configure_autorun_locale() from cluster_usb_display import TuringUsbDisplay, find_supported_usb_product, product_label - from cluster_wifi_display import S3WifiDisplay - from cluster_wifi_protocol import load_config - try: - config = load_config() if expected_product_id == 0x0092 else None - except (ValueError, RuntimeError, OSError): + if find_supported_usb_product(expected_product_id) is None: return False - if config is not None: - display = S3WifiDisplay(config, brightness=0, display_fps=0, expected_product_id=expected_product_id) - else: - if find_supported_usb_product(expected_product_id) is None: - return False - display = TuringUsbDisplay(brightness=0, display_fps=0, expected_product_id=expected_product_id) + + display = TuringUsbDisplay(brightness=0, display_fps=0, expected_product_id=expected_product_id) try: display.open() print( @@ -663,7 +622,17 @@ def main() -> None: flush=True, ) except Exception as exc: - _report_cluster_failure(exc) + if _is_usb_disconnect_error(exc): + print( + f"[cluster_autorun] USB display disconnected; retrying in {RETRY_INTERVAL_S:.0f}s", + flush=True, + ) + else: + print( + f"[cluster_autorun] cluster HUD failed: {exc}; retrying in {RETRY_INTERVAL_S:.0f}s", + flush=True, + ) + traceback.print_exc() time.sleep(RETRY_INTERVAL_S) diff --git a/openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py b/openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py deleted file mode 100644 index 1221fa84a1a..00000000000 --- a/openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Run GC-global lifecycle checks outside the pytest process.""" -from pathlib import Path -import subprocess -import sys - - -ROOT = Path(__file__).resolve().parents[4] - - -def test_failed_jpeg_sessions_release_frozen_traceback_cycles(): - code = r''' -import gc -import sys -import weakref -from pathlib import Path -sys.path.insert(0, str(Path.cwd() / "openpilot/selfdrive/carrot/cluster")) -from cluster_profile import ProfileReporter, freeze_gc_after_init, restore_gc_after_run -from cluster_usb_pipeline import AsyncJpegUsbPipeline - -class DisconnectedDisplay: - def clear_profile_samples(self): pass - def encode_jpeg(self, rgba, width, height): return b"jpeg" - def send_jpeg(self, jpeg): raise ConnectionError("S3 display is closed") - def profile_samples(self): return () - -original = gc.get_threshold() -refs = [] -def session(): - pipeline = AsyncJpegUsbPipeline(DisconnectedDisplay()) - pipeline.start() - previous = freeze_gc_after_init(ProfileReporter(False, 1)) - try: - pipeline.submit_rgba(bytes(1920 * 462 * 4), 1920, 462) - pipeline._thread.join(2) - assert not pipeline._thread.is_alive() - try: - pipeline.check_error() - except RuntimeError as exc: - assert isinstance(exc.__cause__, ConnectionError) - else: - raise AssertionError("Expected disconnected JPEG failure") - finally: - restore_gc_after_run(previous) - pipeline.close() - return weakref.ref(pipeline) - -for _ in range(12): - refs.append(session()) - gc.collect() - assert not any(ref() is not None for ref in refs), "Failed HUD session retained" - assert gc.get_threshold() == original, (gc.get_threshold(), original) -restore_gc_after_run(None) -assert gc.get_threshold() == original -''' - result = subprocess.run([sys.executable, "-c", code], cwd=ROOT, capture_output=True, text=True, timeout=30) - assert result.returncode == 0, result.stdout + result.stderr diff --git a/openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py b/openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py deleted file mode 100644 index af429b76dc6..00000000000 --- a/openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Exercise retry reporting without loading the native Params extension.""" -import ast -from pathlib import Path -import traceback - -import pytest - - -def report(exc): - source = Path(__file__).resolve().parents[1] / 'cluster_autorun.py' - tree = ast.parse(source.read_text(encoding='utf8')) - names = {'_is_usb_disconnect_error', '_s3_transport_failure_reason', '_report_cluster_failure'} - nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names - or isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'USB_DISCONNECT_TEXT' for t in n.targets)] - scope = {'traceback': traceback, 'RETRY_INTERVAL_S': 5} - exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), scope) - scope['_report_cluster_failure'](exc) - - -@pytest.mark.parametrize('cause', [TimeoutError('timed out'), ConnectionResetError('peer reset'), ConnectionError('S3 display is closed')]) -def test_s3_transport_failure_is_one_line_with_cause_and_retry(capsys, cause): - transport = RuntimeError('S3 display disconnected; reconnect required') - transport.__cause__ = cause - pipeline = RuntimeError('asynchronous USB JPEG pipeline failed') - pipeline.__cause__ = transport - report(pipeline) - captured = capsys.readouterr() - assert captured.out.splitlines() == [f'[cluster_autorun] S3 Wi-Fi disconnected ({type(cause).__name__}: {cause}); retrying in 5s'] - assert captured.err == '' - - -@pytest.mark.parametrize('cause', [ValueError('bad encoder input'), OSError('unrelated encoder IO failure')]) -def test_unexpected_pipeline_failure_keeps_traceback(capsys, cause): - try: - raise RuntimeError('asynchronous USB JPEG pipeline failed') from cause - except RuntimeError as exc: - report(exc) - captured = capsys.readouterr() - assert 'cluster HUD failed' in captured.out - assert 'Traceback' in captured.err and str(cause) in captured.err - - -def test_s3_wrapper_does_not_hide_programming_error(capsys): - try: - raise RuntimeError('S3 display connection failed; waiting for rediscovery') from ValueError('invalid payload') - except RuntimeError as exc: - report(exc) - assert 'ValueError: invalid payload' in capsys.readouterr().err - - -def test_usb_hot_unplug_remains_concise(capsys): - report(RuntimeError('USB display disconnected')) - captured = capsys.readouterr() - assert captured.out == '[cluster_autorun] USB display disconnected; retrying in 5s\n' - assert captured.err == '' - - -def test_cyclic_chain_and_multiline_detail_are_bounded(capsys): - transport = RuntimeError('S3 display connection failed; waiting for rediscovery') - error = TimeoutError('timed out\n' + 'x' * 1000) - transport.__cause__ = error - error.__context__ = transport - report(transport) - captured = capsys.readouterr() - assert len(captured.out.splitlines()) == 1 and len(captured.out) < 260 - assert captured.err == '' diff --git a/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py b/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py index 9398c13dc70..10664232977 100644 --- a/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py +++ b/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py @@ -222,67 +222,8 @@ def test_provisioning_validates_bytes_and_does_not_reset_uart(): assert 'port.dtr = port.rts = False' in source -def test_automatic_uart_selection_refuses_ambiguity_without_opening_ports(monkeypatch): - path = Path(__file__).resolve().parents[4] / 'tools/esp32s3_display/provision.py' - spec = importlib.util.spec_from_file_location('s3_auto_provision', path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - ports = [] - monkeypatch.setattr('serial.tools.list_ports.comports', lambda: ports) - with pytest.raises(RuntimeError, match='UART candidates: none'): - module.automatic_port() - ports.extend([SimpleNamespace(vid=0x10c4, device='COM14'), SimpleNamespace(vid=0x9999, device='COM15')]) - assert module.automatic_port() == 'COM14' - ports.append(SimpleNamespace(vid=0x1a86, device='COM16')) - with pytest.raises(RuntimeError, match='COM14, COM16'): - module.automatic_port() - - @pytest.mark.parametrize('size', [0, 511, protocol.MAX_PAYLOAD + 1]) def test_payload_bounds_before_socket_write(size): connection = protocol.Connection.__new__(protocol.Connection) with pytest.raises(ValueError): connection.send(bytes(size)) - - -@pytest.mark.parametrize('fail', [False, True]) -def test_close_syncs_and_confirms_off_but_releases_transport_on_failure(monkeypatch, fail): - display = S3WifiDisplay(CONFIG) - events = [] - display.connection = SimpleNamespace(close=lambda: events.append('closed')) - def command(number, name, fields=None, **kwargs): - events.append((number, fields)) - assert kwargs.get('expect_response', True) - if fail and number == 14: - raise RuntimeError('USB response lost') - monkeypatch.setattr(display, '_send_command', command) - monkeypatch.setattr('cluster_wifi_display.time.sleep', lambda delay: events.append(delay)) - display.close() - assert events == [(10, None), .2, (14, {8: 0}), 'closed'] - assert display.connection is None and not display.pending - - -@pytest.mark.parametrize('registered', [False, True]) -def test_offroad_dims_registered_s3_without_local_usb(tmp_path, monkeypatch, registered): - path = tmp_path / 'pair.json' - monkeypatch.setenv('CARROT_S3_CONFIG', str(path)) - if registered: - protocol.save_config(CONFIG, path) - events = [] - def scan(pid): - events.append('scan') - return None - class Display: - def __init__(self, config, **kwargs): - assert config == CONFIG and kwargs['brightness'] == 0 - def open(self): events.append('open-off') - def close(self): events.append('close') - monkeypatch.setattr('cluster_usb_display.find_supported_usb_product', scan) - monkeypatch.setattr('cluster_wifi_display.S3WifiDisplay', Display) - source = CLUSTER.parent / 'cluster_autorun.py' - node = next(n for n in ast.parse(source.read_text(encoding='utf8')).body - if isinstance(n, ast.FunctionDef) and n.name == '_turn_off_supported_usb_device') - scope = {'_configure_autorun_locale': lambda: None} - exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), scope) - assert scope[node.name](0x0092, 'test') is registered - assert events == (['open-off', 'close'] if registered else ['scan']) diff --git a/openpilot/selfdrive/carrot/web/generated/asset-manifest.json b/openpilot/selfdrive/carrot/web/generated/asset-manifest.json index 28a90e6851c..02aab84d8c0 100644 --- a/openpilot/selfdrive/carrot/web/generated/asset-manifest.json +++ b/openpilot/selfdrive/carrot/web/generated/asset-manifest.json @@ -34,7 +34,7 @@ "kind": "bundle", "source": "src/entries/tools.js", "path": "js/generated/tools.js", - "hash": "326d4534c6fe5d78463a8d66f7adf55fd9e528f52789ed5488c7c89985bab871" + "hash": "2d26f4eacf9daea3f0dd3599913920e72ef059c5d4e35c569e96f97055002a7a" }, { "id": "logs.runtime", diff --git a/openpilot/selfdrive/carrot/web/js/generated/tools.js b/openpilot/selfdrive/carrot/web/js/generated/tools.js index 9539247679c..c2893f5c56c 100644 --- a/openpilot/selfdrive/carrot/web/js/generated/tools.js +++ b/openpilot/selfdrive/carrot/web/js/generated/tools.js @@ -171,7 +171,7 @@ ${wn(o)}`.trim()}}let l=Array.isArray(a.commits)?a.commits:[],d=Math.max(0,ne(a. ${y("tools_raw_output","Raw output")} ${T}`:p=b||T||hn(o)}return`> ${bn(o)} -${p}`}function pi(o,a){let l=String(o?.status||""),d=String(o?.action||"").trim(),p=vn(o),b=p?Sn(o,p):null,T=ui(o),A=Number(o?.updated_at||o?.created_at||0),G=!!o?.payload?.notice,O=`job-${o?.id||a}`,V=p?`${O}:${p.after||p.before||""}`:"",E=hn(o);return l==="running"?E=String(o?.message||"").trim()||si(d)||E:l==="failed"?E=li(d)||E:G?E=kt(T):p?E=U(b?.card||p.card_summary||p.display||"")||kt(T):E=Dt(o)||ri(d)||E,{id:O,ackId:V,source:l==="running"?"current":"history",text:T,title:bn(o),summary:E,updateCard:b?{label:b.label||"",messages:Array.isArray(b.messages)?b.messages:[],stats:b.stats||null}:null,status:l,highlight:!!(p?.updated&&l==="done"&&!L.has(V)),updateKind:p?"git_pull":"",timestamp:A,timeLabel:ii(A)}}function Tn(o,a,l){let d=U(o);return{id:`${a}-${l}-${Se(d)}`,source:a,text:d,title:ni(d),summary:kt(d),timestamp:0,timeLabel:""}}function pt(o={}){let a=U(o.currentText),l=U(o.historyText),d=Array.isArray(o.jobs)?o.jobs.filter(Boolean):[],p=[];return d.slice(0,i).forEach((b,T)=>p.push(pi(b,T))),a&&p.push(Tn(a,"current",0)),l&&l.split(/\n{2,}/).map(b=>b.trim()).filter(Boolean).slice(-i).forEach((b,T)=>p.push(Tn(b,"history",T))),{entries:p,hasHistory:!!l||d.some(b=>String(b?.status||"")!=="running"),state:{jobs:d,historyText:l,currentText:a}}}function Cn(o,a){let l=document.createElement("button");return l.type="button",l.className="tools-console-log__clearBtn",l.textContent=y("tools_notifications_clear","Clear"),l.disabled=!o.hasHistory,l.addEventListener("click",d=>{d.stopPropagation(),f="",a.options.onClear?.()}),l}function An(o){let a=document.createElement("span");a.className="tools-console-log__header",a.dataset.toolsNotificationMode=o.mode;let l=document.createElement("span");if(l.className="tools-console-log__headerTitle",l.textContent=y("tools_notifications","Notifications"),a.appendChild(l),o.mode===n.LANDSCAPE){let d=document.createElement("span");d.className="tools-console-log__headerActions",d.appendChild(Cn(o.model,o)),a.appendChild(d)}return a}function gi(){let o=document.createElement("span");return o.className="tools-console-log__empty",o.textContent=y("tools_notifications_empty","No notifications"),o}function Pt(){return!!t.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches}function mi(o){let a=String(o||"").trim().split(",")[0].trim();if(!a)return 0;let l=Number.parseFloat(a);return Number.isFinite(l)?a.endsWith("ms")?l:l*1e3:0}function En(o){if(Pt())return 0;try{let a=t.getComputedStyle?.(o),l=mi(a?.getPropertyValue("--tools-detail-duration"));return Math.max(180,l||320)+40}catch{return 360}}function Bt(){F&&(t.clearTimeout(F),F=null,z="",j=0,te=null)}function fi(o){return!!(F&&z&&Date.now()bi(a.id,o),{passive:!0}))}function gt(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`)}function xn(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.transition="none",a.style.animation="none",a.style.opacity="1",a.style.transform="translateY(0)",gt(o),a.getBoundingClientRect())}function yi(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`,a.style.opacity="1",a.style.transform="translateY(0)",a.getBoundingClientRect(),o.classList.add("is-collapsing"),o.classList.remove("is-expanded"),o.setAttribute("aria-expanded","false"),t.requestAnimationFrame(()=>{a.style.maxHeight="0px",a.style.opacity="0",a.style.transform="translateY(-6px)"}))}function oe(o){return o?(o.dataset.toolsNotificationMode||Te())===n.PORTRAIT?o:o?.querySelector?.(".tools-console-log__body")||o||null:null}function re(o,a){return!o||!a?null:Array.from(o.querySelectorAll("[data-notification-id]")).find(l=>l.dataset.notificationId===a)||null}function wi(o,a){return JSON.stringify({mode:a,active:f,hasHistory:o.hasHistory,entries:o.entries.map(l=>({id:l.id,source:l.source,title:l.title,summary:l.summary,updateCard:l.updateCard||null,text:l.text,status:l.status||"",highlight:!!l.highlight,updateKind:l.updateKind||"",hasTime:Number(l.timestamp||0)>0}))})}function vi(o){return String(o?.status||"")==="running"}function Si(o){return!!o&&!vi(o)}function Ln(o){let a=oe(o);return a?Array.from(a.querySelectorAll("[data-notification-id]")):[]}function Ti(o,a){let l=Ln(o);return l.length===a.entries.length&&l.every((d,p)=>d.dataset.notificationId===a.entries[p]?.id)}function Ot(o,a){o&&o.textContent!==a&&(o.textContent=a)}function qe(o,a,l){let d=document.createElement("span");return d.className=a,d.textContent=l,o.appendChild(d),d}function Nn(o,a){if(!o)return;if(o.replaceChildren(),o.classList.toggle("tools-console-log__cardBody--update",!!a.updateCard),!a.updateCard){o.textContent=a.summary;return}let l=String(a.updateCard.label||"").trim();l&&qe(o,"tools-console-log__updateLabel",l);let d=Array.isArray(a.updateCard.messages)?a.updateCard.messages.filter(Boolean):[];if(d.length){let b=document.createElement("span");b.className="tools-console-log__updateMessages",d.slice(0,3).forEach(T=>qe(b,"tools-console-log__updateMessage",T)),o.appendChild(b)}let p=a.updateCard.stats&&typeof a.updateCard.stats=="object"?a.updateCard.stats:null;if(p){let b=document.createElement("span");b.className="tools-console-log__updateStats",p.commits&&qe(b,"tools-console-log__updateStat",p.commits),p.files&&qe(b,"tools-console-log__updateStat",p.files),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--add",`+${Math.max(0,ne(p.insertions))}`),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--delete",`-${Math.max(0,ne(p.deletions))}`),o.appendChild(b)}else!d.length&&l&&(o.textContent=l)}function Ci(o,a,l){let d=f===a.id;o.classList.toggle("tools-console-log__current",a.source==="current"),o.classList.toggle("tools-console-log__history",a.source==="history"),o.classList.toggle("is-expanded",d),o.classList.toggle("is-git-update",a.updateKind==="git_pull"),o.classList.toggle("is-unread-update",!!a.highlight),o.dataset.toolsNotificationMode=l.mode,o.setAttribute("aria-expanded",d?"true":"false"),Ot(o.querySelector(".tools-console-log__cardTitle"),a.title);let p=o.querySelector(".tools-console-log__cardHead"),b=o.querySelector(".tools-console-log__cardTime");a.timeLabel?(!b&&p&&(b=document.createElement("span"),b.className="tools-console-log__cardTime",p.appendChild(b)),Ot(b,a.timeLabel)):b&&b.remove(),Nn(o.querySelector(".tools-console-log__cardBody"),a),Ot(o.querySelector(".tools-console-log__detail"),a.text)}function Ai(o,a,l){Ln(o).forEach((d,p)=>Ci(d,a.entries[p],l)),o.querySelectorAll(".tools-console-log__clearBtn").forEach(d=>{d.disabled=!a.hasHistory})}function Mt(o,a){if(!o)return;let l=new Map(a.map(d=>[d.id,d.timeLabel||""]));o.querySelectorAll("[data-notification-id]").forEach(d=>{let p=l.get(d.dataset.notificationId)||"",b=d.querySelector(".tools-console-log__cardTime");b&&b.textContent!==p&&(b.textContent=p)})}function Ei(o){f&&(o.entries.some(a=>a.id===f)||(f=""))}function $n(o){if(!o?.entries?.length)return null;let a=null,l=0;return o.entries.forEach(d=>{let p=Number(d.timestamp||0);Number.isFinite(p)&&p>0&&p>=l&&(a=d,l=p)}),a||o.entries.slice().reverse().find(d=>d.source==="current")||o.entries[o.entries.length-1]}function Ii(o,a,l=!0,d={}){let p=oe(o),T=re(p,a)?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:!1,instant:d.instant===!0,smoothOnce:d.smoothOnce===!0,stableDetail:d.stableDetail===!0,mode:o?.dataset?.toolsNotificationMode||Te(),scrollTop:p?.scrollTop||0,cardTop:T?T.top:null}}function kn(o,a,l={}){if(!a)return"";let d=l.expand!==!1;return Bt(),f=d?a:"",_=Ii(o,a,d,l),v.delete(a),a}function xi(o=C,a={}){let l=pt(N),d=$n(l);if(!d)return"";let p=kn(o,d.id,a);return o&&Ve(o,l.state,M,{force:!0,preserveScroll:!1}),p}function Li(o,a=f){let l=oe(o);if(!l)return null;let d=re(l,a),p=o?.dataset?.toolsNotificationMode||Te();return{anchorId:a,mode:p,scrollTop:l.scrollTop||0,cardTop:d?d.getBoundingClientRect().top:null}}function Ni(o,a,l,d){let p=oe(o),b=re(p,a),T=o?.dataset?.toolsNotificationMode||Te(),A=b?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:d,mode:T,scrollTop:p?.scrollTop||0,cardTop:A?A.top:null}}function $i(o,a){let l=oe(o);if(!l||!a)return;let d=a.scrollTop;if(a.mode!==n.LANDSCAPE&&a.anchorId&&a.cardTop!==null){let p=re(l,a.anchorId);p&&(d+=p.getBoundingClientRect().top-a.cardTop)}l.scrollTop=Math.max(0,d)}function Ut(o,a){let l=Math.max(0,(o?.scrollHeight||0)-(o?.clientHeight||0));return Math.min(Math.max(0,a),l)}function ki(o,a){let l=oe(o),d=re(l,a?.id);if(!l||!a)return;if(!d||a.cardTop===null){l.scrollTop=Ut(l,a.scrollTop||0);return}let p=d.getBoundingClientRect().top-a.cardTop;if(Math.abs(p)<1)return;let b=(a.scrollTop||0)+p;l.scrollTop=Ut(l,b)}function ke(o,a,l="settled",d={}){let p=oe(o),b=re(p,a?.id);if(!p||!b)return;if(a.keyboard)try{b.focus({preventScroll:!0})}catch{}let T=p.getBoundingClientRect(),A=b.getBoundingClientRect(),G=b.querySelector(".tools-console-log__cardHead")?.getBoundingClientRect?.()||A,O=o.dataset.toolsNotificationMode||Te(),V=O===n.PORTRAIT?18:12,E=O===n.PORTRAIT?92:20,X=Math.max(T.height-V-E,1),Q=T.top+V,ge=T.bottom-E,Ce=Math.max(0,Number(d.expandedHeightDelta)||0),se=Ce>0?{top:A.top,bottom:A.bottom+Ce,height:A.height+Ce}:A,I=l==="opening"?G:se,le=0;if(!a.smoothOnce&&!a.stableDetail&&!a.instant&&(se.top>=Q&&se.bottom<=ge||G.top>=Q&&G.bottom<=ge&&se.height>=X)||l==="opening"&&I.top>=Q&&I.bottom<=ge||(a?.expanded&&l!=="opening"&&se.height>=X?le=se.top-Q:I.topge&&(le=I.bottom-ge),Math.abs(le)<2))return;let Je=Ut(p,p.scrollTop+le),Ae=d.behavior||(Pt()?"auto":"smooth");try{p.scrollTo({top:Je,behavior:Ae})}catch{p.scrollTop=Je}}function Ri(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");if(!a)return 0;let l=a.getBoundingClientRect();return Math.max(0,a.scrollHeight-l.height)}function Di(o,a){if(!a?.id)return;h+=1;let l=h;S&&(t.clearTimeout(S),S=null);let d=Te();t.requestAnimationFrame(()=>{if(l!==h)return;if(a.instant){ke(o,a,"settled",{behavior:"auto"});return}if(a.smoothOnce){ke(o,a,"settled");return}if(!a.stableDetail){a.keyboard&&ke(o,a,"opening");return}if(a.expanded&&!Pt()){let b=oe(o),T=re(b,a.id),A=Ri(T);ke(o,a,"concurrent",{expandedHeightDelta:A});let G=d===n.PORTRAIT?380:360;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},G);return}ke(o,a,"opening");let p=a.expanded?d===n.PORTRAIT?380:360:d===n.PORTRAIT?180:160;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},p)})}function Pi(o){let a=document.createElement("span");a.className="tools-console-log__detailWrap";let l=document.createElement("span");l.className="tools-console-log__detailInner";let d=document.createElement("span");d.className="tools-console-log__detailTitle",d.textContent=y("tools_notification_detail","Detail log"),l.appendChild(d);let p=document.createElement("span");return p.className="tools-console-log__detail",p.textContent=o.text,hi(p,o),l.appendChild(p),a.appendChild(l),a}function Bi(o,a){let l=f===o.id,d=document.createElement("div");d.className=`tools-console-log__card tools-console-log__${o.source}`,d.classList.toggle("is-expanded",l),d.classList.toggle("is-git-update",o.updateKind==="git_pull"),d.classList.toggle("is-unread-update",!!o.highlight),d.dataset.notificationId=o.id,d.dataset.toolsNotificationMode=a.mode,d.setAttribute("role","button"),d.setAttribute("tabindex","0"),d.setAttribute("aria-expanded",l?"true":"false");let p=document.createElement("span");p.className="tools-console-log__cardTitle",p.textContent=o.title;let b=document.createElement("span");if(b.className="tools-console-log__cardHead",b.appendChild(p),o.timeLabel){let E=document.createElement("span");E.className="tools-console-log__cardTime",E.textContent=o.timeLabel,b.appendChild(E)}d.appendChild(b);let T=document.createElement("span");T.className="tools-console-log__cardBody",Nn(T,o),d.appendChild(T),d.appendChild(Pi(o));let A=0,G=0,O=!1,V=(E,X=!1)=>{if(O){O=!1,E.preventDefault();return}let Q=!l;if(_=Ni(a.out,o.id,Q,X),Qo(o,d),!Q){Bt(),f="",v.delete(o.id),z=o.id,te=a.out,yi(d),j=Date.now()+En(d),F=t.setTimeout(()=>{F=null,z="",j=0,te=null,Ve(a.out,a.model.state,a.options,{force:!0})},En(d));return}Bt(),f=Q?o.id:"",Ve(a.out,a.model.state,a.options)};return d.addEventListener("pointerdown",E=>{A=E.clientX,G=E.clientY,O=!1},{passive:!0}),d.addEventListener("pointermove",E=>{Math.hypot(E.clientX-A,E.clientY-G)>8&&(O=!0)},{passive:!0}),d.addEventListener("click",E=>{V(E,E.detail===0)}),d.addEventListener("keydown",E=>{E.key!=="Enter"&&E.key!==" "||(E.preventDefault(),V(E,!0))}),d}function Oi(o,a){let l=document.createElement("span");return l.className="tools-console-log__footer",l.dataset.toolsNotificationMode=a.mode,l.appendChild(Cn(o,a)),l}function Mi(o,a){let l=document.createDocumentFragment();return o.entries.length?(o.entries.forEach(d=>l.appendChild(Bi(d,a))),l):(l.appendChild(gi()),l)}function Rn(o){let a=document.createElement("span");return a.className="tools-console-log__body",a.appendChild(Mi(o.model,o)),a}function Ui(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a}function Gi(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a.appendChild(Oi(o.model,o)),a}function Ve(o,a={},l={},d={}){if(!o)return;ti();let p=_,b=d.preserveScroll===!1||p?null:Li(o),T=Te(),A=pt(a);if(N=A.state,M=l,ci(A.state),pn(o,T),!d.force&&fi(o)){Mt(o,A.entries),Qe(A.entries);return}Ei(A);let O=l.autoFocusLatest===!0&&!d.skipAutoFocusLatest?$n(A):null;Si(O)&&O.id!==$&&($=O.id,kn(o,O.id,{expand:!0,smoothOnce:!0,stableDetail:!0}),p=_,b=null);let V=wi(A,T),E={out:o,mode:T,model:A,options:l};if(!p&&V===w&&o.childElementCount>0){Mt(o,A.entries),Qe(A.entries);return}if(!p&&o.childElementCount>0&&Ti(o,A)){Ai(o,A,E),w=V,Qe(A.entries),f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)});return}if(o.replaceChildren(T===n.PORTRAIT?Gi(E):Ui(E)),w=V,p){if(ki(o,p),p.instant||p.stableDetail){let X=oe(o);xn(re(X,p.id))}}else if($i(o,b),f){let X=oe(o);xn(re(X,f))}Qe(A.entries),p?(_=null,p.expanded&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,p.id);gt(Q),_i(o,p.id)}),Di(o,p)):f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)})}function qi(o,a={}){_n(o,a,{boundKey:"toolsNotificationGestureBound",getPanel:gn})}function Hi(o,a={}){_n(o,a,{boundKey:"toolsNotificationPanelDragBound",getPanel:()=>o,ignoreTarget:Xo})}function Dn(o=N){return pt(o).entries.filter(a=>!!a.highlight).length}t.CarrotToolsNotifications={bindPanelDrag:Hi,bindStatusGesture:qi,unreadCount:Dn,render:Ve,resetDetail(){f="",$="",v.clear()},focusLatest(o={}){return xi(o.out||C,o)},syncMode(o=C){o&&pn(o)}}})(window);var rn=null,st=null,At=!1,sn={recovering:"eGPU reconnecting",recovering_detail:"The internal model remains in use while eGPU recovery is attempted, up to three times.",recovery_ready:"eGPU READY",recovery_ready_detail:"Test calculations passed. The internal model remains in use until the vehicle stops or you intervene with steering.",recovery_stopped:"eGPU recovery stopped",recovery_stopped_detail:"The internal model remains in use. Once parked, restart the comma. Recovery stopped because a check failed or the retry limit was reached.",title:"eGPU big model",checking:"Checking model",downloading:"Downloading",verifying:"Verifying download",ready:"Ready to compile",waiting_for_ignition:"Waiting for ignition",compiling:"Compiling eGPU model",compiled:"Ready to use",error:"eGPU setup failed",runtime_error:"eGPU unavailable",recovery_restart:"Once parked, open User / System below and press Reboot once. If this happens again, send a screenshot of this card for support.",waiting_for_network:"Waiting for network / automatic retry",installing:"Installing eGPU package",installed:"Installed · next start",active:"eGPU running",waiting_for_network_detail:"Keep an internet connection available. Installation retries automatically every 30 seconds.",installing_detail:"Preparing the verified model and runtime. The current driving model continues running.",installed_detail:"Installation is complete. The eGPU will be tried at the next ignition session; no restart is needed while driving.",active_detail:"The eGPU is currently running the driving model.",failure_dns:"Cannot resolve the download server address. Check the internet connection; retry is automatic.",failure_clock:"Certificate not yet valid. Waiting for the device clock/network to recover; retry is automatic.",failure_network:"Download connection interrupted. Retrying automatically.",failure_server:"The download server is temporarily unavailable. Retrying automatically.",failure_certificate:"Server certificate verification failed. The download was stopped; verification remains enabled.",failure_download:"The server refused the download or the requested file is unavailable.",failure_storage:"Not enough free storage to install the eGPU package.",failure_install:"The eGPU package could not be installed or verified. The saved diagnostic report contains the cause.",failure_rejected:"A previous failure blocked this model. Compiling again does not resolve a runtime failure. If it remains after a reboot, download the diagnostics and send them for support.",diagnostics:"Download diagnostics",failure_runtime:"The eGPU could not start or continue running. The internal model is selected.",failure_pcie:"The eGPU connection could not start. The internal model is selected.",failure_timeout:"The eGPU took too long to respond. The internal model is selected.",failure_usb:"Communication with the eGPU was interrupted. The internal model is selected.",checking_detail:"Checking the model catalog. You can keep using openpilot.",downloading_detail:"Download may continue while driving. Do not restart or power off until it finishes.",verifying_detail:"Checking the complete file. This can take a moment.",ready_detail:"Park, turn ignition on, then restart once to compile.",waiting_for_ignition_detail:"Keep the car parked and ignition on, then restart to compile.",compiling_detail:"Keep the car parked and ignition on. The screen will update when compilation finishes.",compiled_detail:"The eGPU big model is compiled and will be selected automatically.",error_detail:"The eGPU is not ready. Send a screenshot of this card for support.",restart:"Restart & compile",restart_confirm:"Park the car and turn ignition on. Restart now to compile the eGPU big model?",restart_requested:"Restart requested. Compilation will begin during boot.",host_ok:"Connected",host_warning:"High temperature",host_error:"Host error / connection lost",host_unknown:"Waiting for current host health",host_ip:"Host IP",host_no_ip:"Unavailable",host_not_connected:"Host not connected",host_unavailable:"Host connection unavailable",host_health_unavailable:"Host health unavailable"};function Y(e){return typeof getUIText=="function"?getUIText(`egpu_model_${e}`,sn[e]||e):sn[e]||e}function xo(e){let t=Math.max(0,Number(e)||0);return t<1024*1024?`${(t/1024).toFixed(0)} KB`:`${(t/(1024*1024)).toFixed(t>=1024*1024*1024?0:1)} MB`}function Oa(e){let t=Math.max(0,Math.floor(Date.now()/1e3-(Number(e)||Date.now()/1e3))),n=Math.floor(t/60);return`${String(n).padStart(2,"0")}:${String(t%60).padStart(2,"0")}`}function Ma(e){let t=String(e?.display_name||"").trim();if(t)return t;let n=String(e?.model_id||"").toLowerCase();return n.includes("pr39087-md-v3")?"Mountain Dew v3":n.includes("pr39047")||n.includes("mdm-v1")||n.includes("6f4c0faf-256f2178")||n.includes("4bfb5340-e20cde17")?"Mountain Dew":n.includes("pr38932")||n.includes("cinque-v3")?"Cinque v3":n.includes("pr38823")||n.includes("cinque-v2")?"Cinque v2":n.includes("pr38771")||n.includes("cinque-terre")?"Cinque Terre":n.includes("bmrlanpv6")?"BMRLNAP v6":n.includes("pr38739")||n.includes("tgc")?"TGC":n.includes("pr38726")||n.includes("time-to-go")?"Time to Go":""}function Ua(e,t=Y("title")){let n=Ma(e);return n?`${n} · eGPU`:t}function ln(e=st){let t=document.getElementById("egpuModelCard");if(!t)return;if(st=e,!e?.available&&!e?.jetlink){t.hidden=!0;return}let n=String(e.state||"checking"),i=Number(e.progress),r=["checking","downloading","verifying","compiling","waiting_for_network","installing"].includes(n),c=document.getElementById("egpuModelState"),s=document.getElementById("egpuModelDetail"),g=document.getElementById("egpuModelRecovery");g.hidden=!0,g.textContent="";let m=document.getElementById("egpuModelProgress"),f=document.getElementById("egpuModelProgressBar"),C=document.getElementById("egpuModelAmount"),N=document.getElementById("btnEgpuCompileRestart"),M=document.getElementById("egpuModelDiagnostics");M&&(M.hidden=!e.available||n!=="error"||!!e.active,M.textContent=Y("diagnostics"));let u=document.getElementById("jetlinkHealth");u||(u=document.createElement("div"),u.id="jetlinkHealth",u.className="egpu-model-card__detail",s.after(u));let _=e.jetlink;if(u.hidden=!_,_){let z=["ok","warning","error","unknown"].includes(_.severity)?_.severity:"unknown";u.textContent=`${_.label} · ${Y(`host_${z}`)} · ${Y("host_ip")}: ${(_.addresses||[]).join(", ")||Y("host_no_ip")}`+(Number.isFinite(_.temp_c)?` · ${_.temp_c.toFixed(1)} °C`:"")+(_.reason?` · ${{"Host not connected":Y("host_not_connected"),"Host connection unavailable":Y("host_unavailable"),"Host health unavailable":Y("host_health_unavailable")}[_.reason]||_.reason}`:""),u.style.color=z==="error"?"#ff7474":z==="warning"?"#ffd166":""}if(!e.available){t.hidden=!1,t.dataset.state=_.severity==="error"?"error":"compiled",t.classList.remove("is-running"),document.getElementById("egpuModelTitle").textContent=`${_.label} · Cinque v2`,c.textContent=Y(`host_${_.severity||"unknown"}`),s.textContent="",m.hidden=!0,C.textContent="",N.hidden=!0;return}t.hidden=!1,t.dataset.state=n,t.classList.toggle("is-running",r),document.getElementById("egpuModelTitle").textContent=Ua(e);let h=e.active&&["compiled","installed"].includes(n),S=n==="error"&&["runtime","pcie","timeout","usb"].includes(e.error_code);c.textContent=Y(h?"active":S?"runtime_error":n);let P=`failure_${e.error_code}`;s.textContent=e.error_code?Y(Object.hasOwn(sn,P)?P:"error_detail"):Y(h?"active_detail":`${n}_detail`),g.hidden=!S,g.textContent=S?Y("recovery_restart"):"";let F=n==="downloading"&&Number.isFinite(i);m.hidden=!F,f.style.width=`${Math.min(100,Math.max(0,i||0))}%`,C.textContent=F?`${xo(e.downloaded_bytes)} / ${xo(e.total_bytes)} · ${i.toFixed(1)}%`:n==="compiling"?Oa(e.started_at):"",N.textContent=Y(At?"checking":"restart"),N.hidden=!["ready","waiting_for_ignition","error"].includes(n)||e.compiled||e.error_code==="rejected",N.disabled=At||!e.can_restart}async function Et(){try{ln(await getJson("/api/egpu/model"))}catch{st?.jetlink&&ln({...st,jetlink:{...st.jetlink,severity:"unknown",addresses:[],temp_c:null,reason:"",fresh:!1}})}finally{window.clearTimeout(rn),rn=window.setTimeout(Et,2e3)}}async function Ga(){if(!await appConfirm(Y("restart_confirm"),{title:Y("restart")}))return;let e=document.getElementById("btnEgpuCompileRestart");At=!0,e&&(e.disabled=!0,e.textContent=Y("checking"));try{await postJson("/api/egpu/model/compile-restart",{}),typeof showAppToast=="function"&&showAppToast(Y("restart_requested"));return}catch(t){At=!1,typeof showAppToast=="function"&&showAppToast(`${Y("error")}: ${t?.message||t}`,{tone:"error",duration:5e3}),typeof showError=="function"&&showError("eGPU model",t),await Et()}}function qa(){let e=document.getElementById("btnEgpuCompileRestart");e&&e.dataset.bound!=="1"&&(e.dataset.bound="1",e.addEventListener("click",Ga)),rn||Et()}var Ha=Object.freeze({init:qa,refresh:Et,render:ln});globalThis.CarrotEgpuModel=Ha;function It(e){let[t,n="single"]=e.split("@");return[t,n]}function No(e,t){return t==="single"?e:`${e}@${t}`}function $o(e){return[...new Set(Object.keys(e).map(t=>It(t)[0]))]}function ko(e,t){let n=!1;for(let i of t){if(i.reason!=="test")continue;let r=It(i.button)[0];r in e||(e[r]="none",n=!0),i.button in e||(e[i.button]="none",n=!0)}return n}var Lo=["accelCruise","decelCruise","gapAdjustCruise","lfaButton","cancel"],Ro=["none",...Lo,...Lo.map(e=>`${e}Long`),"laneLeft","laneRight","paddleDecel","carrotCruise"];var Wa={ko:{title:"블루투스 리모컨",close:"닫기",scan:"장치 검색 (30초)",on:"블루투스 켜기",off:"블루투스 끄기",help:"정차하고 크루즈를 해제한 상태에서 설정하세요. 매핑을 켜면 해당 장치의 원래 터치·키보드·볼륨 입력을 차단합니다. 차로변경은 기존 속도·측후방·조향 조건을 따릅니다. 휴대폰도 페어링할 수 있지만 통화·오디오·네트워크 기능은 제공하지 않습니다.",empty:"장치를 페어링 모드로 놓고 검색하세요. 기존 휴대폰에 연결되어 있다면 그 연결을 해제하세요.",pair:"페어링",connect:"연결",disconnect:"연결 해제",forget:"등록 해제",paired:"페어링됨",connected:"연결됨",profile:"입력 종류",generic:"일반 키보드 / HID",enabled:"캐럿 매핑 사용 (원래 HID 입력 차단)",save:"매핑 저장",saved:"저장했습니다",test:"키 등록 / 시험 시작 (120초)",stop:"시험 종료",waiting:"시험 준비 중…",testing:"시험 중: 차량 명령은 보내지 않습니다. 버튼을 누르면 아래에 표시됩니다. 새 키는 자동으로 목록에 추가됩니다.",idle:"입력 대기",blocked:"정차·크루즈 해제 상태 또는 입력 서비스가 확인되지 않아 설정할 수 없습니다.",grabbed:"HID 입력 독점 중",inactive:"입력 독점 대기 (연결·매핑 사용 여부 확인)",learnHelp:"먼저 입력 종류와 매핑을 저장한 뒤 시험을 시작하세요. 일반 HID는 시험 중 버튼을 눌렀다 떼어 등록합니다. 변경한 기능은 다시 저장해야 적용됩니다. Yiser-J6의 길게 누르기 지원 여부는 리모컨이 보내는 실제 입력에 따라 다릅니다.",none:"사용 안 함",accelCruise:"크루즈 + (accelCruise)",decelCruise:"크루즈 − (decelCruise)",laneLeft:"왼쪽 차로변경",laneRight:"오른쪽 차로변경",paddleDecel:"패들 감속",gapAdjustCruise:"크루즈 갭",up:"위",down:"아래",left:"왼쪽",right:"오른쪽",center:"중앙",1:"1",2:"2",cancel:"취소",confirm:"확인",pin:"PIN / 패스키",prompt:"페어링 확인",display:"상대 장치에 표시된 번호를 입력하거나 아래 번호를 확인하세요.",scanning:"검색 중",ready:"준비",unavailable:"블루투스 어댑터를 사용할 수 없습니다",noService:"입력 서비스가 실행되지 않았습니다",unsaved:"변경 내용을 저장하세요",sent:"차량 명령 전달",testEvent:"시험 입력",ignored:"명령 미전달",battery:"배터리",single:"짧게 누르기",double:"더블클릭",long:"길게 누르기",carrotCruise:"CarrotCruise (가속 제한 진입)",gestures:"더블클릭은 두 번의 뗌 간격이 350ms 이내일 때 인식합니다. 더블클릭을 지정한 키는 짧게 누르기도 350ms 기다립니다. ‘길게 누르기’에 기능을 지정하면 누른 채 700ms 후 실행합니다. 크루즈 +/−(10단위 동작 포함)는 이후 500ms 간격으로 반복하고, 나머지 기능은 한 번만 실행합니다. 버튼을 떼거나 연결이 끊기면 반복을 멈춥니다. 페달·핸들 버튼 조작, 크루즈 해제 또는 10초 경과 후에는 떼었다 다시 눌러야 합니다. 누름 시간을 보내지 않는 리모컨은 길게 누르기를 구분할 수 없습니다. 시험 중에는 모든 키에서 세 동작을 구분합니다.",multi:"여러 장치를 함께 사용할 수 있으며 장치별로 매핑을 저장합니다 (최대 16개 등록, 실제 동시 연결 수는 기기에 따라 다름). 입력은 순서대로 처리하며 400ms 지난 명령은 버립니다. 키 등록 시험은 선택한 한 장치에만 적용됩니다. 다른 활성 장치는 계속 동작합니다.",carrotHelp:"CarrotCruise는 기존 가속 제한 모드에 진입합니다. 다시 눌러도 해제되지 않으며 크루즈 +로 해제합니다. 실제 가속 제한은 해당 차종의 기존 지원과 CarrotCruiseDecel 설정을 따릅니다.",mappingOn:"매핑 켜짐",mappingOff:"매핑 꺼짐",receiving:"입력 수신 중",accelCruiseLong:"크루즈 + 길게 (10단위 조절)",decelCruiseLong:"크루즈 − 길게 (10단위 조절)",gapAdjustCruiseLong:"갭 길게 (주행 모드 순환)",lfaButton:"LFA 버튼",lfaButtonLong:"LFA 길게 (차선 모드 전환)",cancelCruise:"크루즈 취소",cancelLong:"취소 길게 (크루즈·조향 해제)",buttonHelp:"크루즈 +/−는 크루즈가 꺼져 있으면 기존 활성화 조건을 거쳐 켜기를 요청합니다. 버튼의 ‘길게’ 동작도 기능 목록에서 선택할 수 있습니다. 예: 리모컨 짧게 누르기 → 크루즈 + 길게. 이 경우 추가로 기다리지 않고 핸들 버튼의 롱 동작을 한 번 실행합니다. LFA와 갭의 동작은 기존 설정을 따릅니다."},en:{title:"Bluetooth remotes",close:"Close",scan:"Scan devices (30s)",on:"Enable Bluetooth",off:"Disable Bluetooth",help:"Configure while stationary with cruise disengaged. Enabling a mapping captures this device's original touch, keyboard and volume input. Lane changes retain existing speed, blind-spot and steering conditions. Phones can pair; calls, audio and networking are not provided.",empty:"Put your device in pairing mode and scan. Disconnect it from its previous phone if needed.",pair:"Pair",connect:"Connect",disconnect:"Disconnect",forget:"Forget",paired:"Paired",connected:"Connected",profile:"Input profile",generic:"Generic keyboard / HID",enabled:"Use Carrot mapping (capture original HID input)",save:"Save mapping",saved:"Saved",test:"Learn / test keys (120s)",stop:"End test",waiting:"Preparing test…",testing:"Test mode: no vehicle commands. Press and release a button to see it below. New keys are added to the list automatically.",idle:"Waiting for input",blocked:"Setup requires a fresh stationary, disengaged state and the input service.",grabbed:"Exclusive HID capture active",inactive:"Waiting for HID capture (check connection and mapping)",learnHelp:"Save the input profile and mapping before testing. Press and release each generic HID button during the test to learn it. Save again to apply edited actions. Long-press support on Yiser-J6 depends on the events reported by the remote.",none:"No action",accelCruise:"Cruise + (accelCruise)",decelCruise:"Cruise − (decelCruise)",laneLeft:"Lane change left",laneRight:"Lane change right",paddleDecel:"Paddle deceleration",gapAdjustCruise:"Cruise gap",up:"Up",down:"Down",left:"Left",right:"Right",center:"Center",1:"1",2:"2",cancel:"Cancel",confirm:"Confirm",pin:"PIN / passkey",prompt:"Pairing confirmation",display:"Enter the code shown on the other device, or verify the code below.",scanning:"Scanning",ready:"Ready",unavailable:"Bluetooth adapter unavailable",noService:"Input service is not running",unsaved:"Save your changes",sent:"Vehicle command sent",testEvent:"Test input",ignored:"Command not sent",battery:"Battery",single:"Short press",double:"Double click",long:"Long press",carrotCruise:"CarrotCruise (enter acceleration limit)",gestures:"Double clicks require releases within 350ms. Assigning a double click delays the short press by 350ms. An assigned Long press fires after holding for 700ms. Cruise +/− (including 10-step actions) then repeat every 500ms; other actions fire once. Release or disconnection stops repetition. After a pedal or steering-wheel button input, cruise disengagement, or 10 seconds, release and press again. Remotes that do not report hold duration cannot distinguish long presses. Test mode recognizes all three gestures on every key.",multi:"Use multiple devices with separate mappings (up to 16 saved; simultaneous connections depend on hardware). Commands are processed in order and expire after 400ms. Learning tests only the selected device; other enabled devices continue operating.",carrotHelp:"CarrotCruise enters the existing acceleration-limiting mode. Repeating it keeps the mode on; Cruise + exits it. Actual limiting follows existing vehicle support and CarrotCruiseDecel.",mappingOn:"Mapping on",mappingOff:"Mapping off",receiving:"Receiving input",accelCruiseLong:"Cruise + long (10-step adjustment)",decelCruiseLong:"Cruise − long (10-step adjustment)",gapAdjustCruiseLong:"Gap long (cycle driving mode)",lfaButton:"LFA button",lfaButtonLong:"LFA long (toggle lane-line mode)",cancelCruise:"Cancel cruise",cancelLong:"Cancel long (cruise and lateral off)",buttonHelp:"Cruise +/− request engagement through existing checks when cruise is off. Native long-button actions are separate choices: for example, a short remote press can run Cruise + long once, with no additional hold delay. LFA and gap actions follow existing settings."}},Po={up:"accelCruise",down:"decelCruise",left:"laneLeft",right:"laneRight",center:"paddleDecel",1:"gapAdjustCruise",2:"none"},Fa=Ro,ce,B,ae,ee,Do,cn,xt=!1,dn,ve=!1,x=e=>Wa[typeof LANG<"u"&&LANG==="ko"?"ko":"en"][e]||e,Bo=e=>x(e==="cancel"?"cancelCruise":e),Oo=e=>{let[t,n]=It(e);return`${x(t)} · ${x(n)}`},Ue=new Set,D=(e,t,n)=>{let i=document.createElement(e);return t!=null&&(i.textContent=t),n&&(i.className=n),i},Z=e=>ce.querySelector(`[data-bt="${e}"]`),we=(e,t="div")=>{let n=D(t);return n.dataset.bt=e,n};function de(e,t,n=!0){let i=D("button",x(e),"btn");return i.type="button",n&&(i.dataset.mutation="true"),i.onclick=n?()=>Ka(t):t,i}async function pe(e,t){let n=await fetch(`/api/bluetooth${e?`/${e}`:""}`,e?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t||{})}:{cache:"no-store"});if(!n.ok)throw new Error(await n.text());return n.json()}async function Ka(e){if(!xt){xt=!0,Ke(),Z("error").textContent="";try{await e(),await Uo()}catch(t){Z("error").textContent=t.message}finally{xt=!1,Ke()}}}function Ke(){ce?.querySelectorAll('[data-mutation="true"]').forEach(e=>{e.disabled=xt||!B?.runtime?.stationary})}function ja(e){if(ve){Z("error").textContent=x("unsaved");return}ae=e.address;let t=B.config.devices[ae];ee=t?structuredClone(t):{name:e.name,profile:/yiser-j6/i.test(e.name)?"yiser-j6":"generic",enabled:!1,mapping:{}},!t&&ee.profile==="yiser-j6"&&(ee.mapping={...Po}),ve=!1,Ue=new Set((B.runtime.recent_events||[]).map(n=>n.id)),un()}function un(){let e=Z("editor");if(e.replaceChildren(),!ee)return;e.append(D("h3",`${ee.name||ae} · ${ae}`));let t=D("label",x("profile")),n=D("select");for(let[g,m]of[["yiser-j6","Yiser-J6"],["generic",x("generic")]]){let f=D("option",m);f.value=g,n.append(f)}n.value=ee.profile,n.dataset.mutation="true",n.onchange=()=>{ee.profile=n.value,ee.mapping=n.value==="yiser-j6"?{...Po}:{},ve=!0,un()},t.append(n),e.append(t);let i=D("label"),r=D("input");r.type="checkbox",r.checked=ee.enabled,r.dataset.mutation="true",r.onchange=()=>{ee.enabled=r.checked,ve=!0},i.append(r,document.createTextNode(x("enabled"))),e.append(i);let c=we("mapping");e.append(c),Mo();let s=D("div",null,"bt-actions");s.append(de("save",async()=>{await pe("device-config",{address:ae,device:ee}),ve=!1,Z("notice").textContent=x("saved")}),de("test",async()=>{if(ve||!B.config.devices[ae])throw new Error(x("unsaved"));Ue=new Set((B.runtime.recent_events||[]).map(g=>g.id)),await pe("learn",{address:ae,enabled:!0}),Z("notice").textContent=x("waiting")}),de("stop",async()=>{await pe("learn",{address:ae,enabled:!1})})),e.append(s,D("p",x("learnHelp")),D("p",x("gestures")),D("p",x("buttonHelp")),D("p",x("carrotHelp")),we("notice"),we("capture"),we("event","output")),Ke()}function Mo(){let e=Z("mapping");e.replaceChildren();for(let t of $o(ee.mapping)){let n=D("fieldset",null,"bt-mapping-row");n.append(D("legend",x(t)));for(let i of["single","double","long"]){let r=No(t,i),c=D("label",x(i)),s=D("select");s.dataset.mutation="true",s.setAttribute("aria-label",Oo(r));for(let g of Fa){let m=D("option",Bo(g));m.value=g,s.append(m)}s.value=ee.mapping[r]||"none",s.onchange=()=>{ee.mapping[r]=s.value,ve=!0},c.append(s),n.append(c)}e.append(n)}Ke()}function Qa(){let e=[...B.devices].sort((i,r)=>Number(r.paired)-Number(i.paired)||i.address.localeCompare(r.address)),t=JSON.stringify(e.map(({address:i,name:r,paired:c,connected:s,battery:g})=>({address:i,name:r,paired:c,connected:s,battery:g,enabled:B.config.devices[i]?.enabled,grabbed:B.runtime.grabbed?.includes(i)})));if(t===cn)return;cn=t;let n=Z("devices");n.replaceChildren(),B.devices.length||n.append(D("p",x("empty")));for(let i of e){let r=D("div",null,"bt-device"),c=D("strong",i.name||i.address);r.append(c,D("div",`${i.address} · ${i.connected?x("connected"):i.paired?x("paired"):""}${i.battery!=null?` · ${x("battery")} ${i.battery}%`:""}`)),B.config.devices[i.address]&&r.append(D("div",`${x(B.config.devices[i.address].enabled?"mappingOn":"mappingOff")}${B.runtime.grabbed?.includes(i.address)?` · ${x("receiving")}`:""}`));let s=D("div",null,"bt-actions");i.paired?s.append(de(i.connected?"disconnect":"connect",()=>pe(i.connected?"disconnect":"connect",{address:i.address})),de("forget",async()=>{await pe("forget",{address:i.address}),ae===i.address&&(ae=ee=null,un())}),de("profile",()=>ja(i),!1)):s.append(de("pair",()=>pe("pair",{address:i.address}))),r.append(s),n.append(r)}}function za(){let e=B.prompt;if((e?.id||null)===dn)return;dn=e?.id||null;let t=Z("prompt");if(t.replaceChildren(),!e)return;t.append(D("h3",x("prompt")),D("p",x("display")),D("strong",e.value));let n;["RequestPinCode","RequestPasskey"].includes(e.kind)&&(n=D("input"),n.placeholder=x("pin"),n.setAttribute("aria-label",x("pin")),n.maxLength=e.kind==="RequestPasskey"?6:16,t.append(n)),e.kind.startsWith("Display")||t.append(de("confirm",()=>pe("answer",{id:e.id,value:n?n.value:!0}))),t.append(de("cancel",()=>pe("cancel")))}async function Uo(){if(!ce?.open)return;B=await pe(),Z("status").textContent=B.runtime.stationary?B.available?B.adapters.some(t=>t.discovering)?x("scanning"):x("ready"):x("unavailable"):x("blocked"),Z("radio").textContent=x(B.radioEnabled?"off":"on"),Z("service").textContent=[B.error,!B.runtime.alive&&x("noService"),...Object.values(B.runtime.errors||{}),B.pair?.error].filter(Boolean).join(" · "),Qa(),za();let e=B.runtime.learning;if(ee){Z("capture").textContent=(B.runtime.grabbed||[]).includes(ae)?x("grabbed"):x("inactive"),Z("notice").textContent=e?.address===ae?`${x("testing")} (${Math.max(0,Math.ceil(e.until-B.runtime.time))}s)`:ve?x("unsaved"):"";let t=B.runtime.recent_events||(B.runtime.last_event?[B.runtime.last_event]:[]),n=t.filter(i=>i.address===ae&&!Ue.has(i.id));for(let i of n)Ue.add(i.id),Z("event").textContent=`${Oo(i.button)} → ${Bo(i.action)} · ${x(i.reason==="test"?"testEvent":i.emitted?"sent":"ignored")}`;e?.address===ae&&ko(ee.mapping,n)&&(ve=!0,Mo()),Ue.size>256&&(Ue=new Set(t.map(i=>i.id)))}Ke()}function Va(){if(ce?.open)return;ce?.remove(),B=ae=ee=dn=cn=null,ve=!1,Ue=new Set,ce=D("dialog",null,"carrot-bt-dialog"),ce.setAttribute("aria-label",x("title"));let e=D("div",null,"bt-actions");e.append(D("h2",x("title")),de("close",()=>ce.close(),!1));let t=D("div",null,"bt-actions"),n=de("on",()=>pe("radio",{enabled:!B.radioEnabled}));n.dataset.bt="radio",t.append(n,de("scan",()=>pe("scan")),de("cancel",()=>pe("cancel"))),ce.append(e,D("p",x("help")),D("p",x("multi")),we("status"),we("service"),we("error"),t,we("prompt"),we("devices"),we("editor")),Z("error").setAttribute("role","alert"),Z("status").setAttribute("role","status"),ce.addEventListener("close",()=>{clearTimeout(Do)}),document.body.append(ce),ce.showModal(),Ke();let i=!1,r=async()=>{try{await Uo(),i&&(Z("error").textContent=""),i=!1}catch(c){i=!0,Z("error").textContent=c.message}finally{ce.open&&(Do=setTimeout(r,1e3))}};r()}globalThis.CarrotBluetooth={init(){let e=document.getElementById("btnToolsBluetooth");e&&(e.textContent=x("title"),e.onclick=Va)}};var Ge=null,je=null,dt=null,ct=0,lt=null;function k(e,t,n=null){return typeof getUIText=="function"?getUIText(e,t,n):t}function Lt(e){return String(e??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function $t(e){typeof appDialog>"u"||!appDialog||appDialog.classList.toggle("app-dialog--tools-qr",!!e)}function Go(e,t,n=null,i=null){let r=k(e,t,n);return typeof showAppToast=="function"&&showAppToast(r,i||void 0),r}function Nt(){je&&(cancelAnimationFrame(je),je=null),dt&&(clearTimeout(dt),dt=null),ct=0,Ge&&(Ge.getTracks().forEach(e=>e.stop()),Ge=null)}function $e(e,t){e&&(e.dataset.scanState=t||"idle")}function Ja(e,t){let n=Math.min(e,t)*.74;return{left:(e-n)/2,top:(t-n)/2,right:(e+n)/2,bottom:(t+n)/2,width:n,height:n}}function Ya(e){let t=[e?.location?.topLeftCorner,e?.location?.topRightCorner,e?.location?.bottomRightCorner,e?.location?.bottomLeftCorner].filter(m=>Number.isFinite(Number(m?.x))&&Number.isFinite(Number(m?.y)));if(t.length<4)return null;let n=t.map(m=>Number(m.x)),i=t.map(m=>Number(m.y)),r=Math.min(...n),c=Math.max(...n),s=Math.min(...i),g=Math.max(...i);return{left:r,top:s,right:c,bottom:g,width:c-r,height:g-s,centerX:(r+c)/2,centerY:(s+g)/2}}function Xa(e,t,n){let i=Ja(t,n),r=Ya(e);if(!r||r.width<=0||r.height<=0)return!1;let c=i.width*.18,s=i.height*.18,g=r.centerX>=i.left&&r.centerX<=i.right&&r.centerY>=i.top&&r.centerY<=i.bottom,m=r.left>=i.left-c&&r.right<=i.right+c&&r.top>=i.top-s&&r.bottom<=i.bottom+s,f=r.width>=i.width*.34&&r.height>=i.height*.34&&r.width<=i.width*1.18&&r.height<=i.height*1.18;return g&&m&&f}function Ho(e){if(typeof qrcode!="function")throw new Error("QR library not loaded");let t=qrcode(0,"L"),n=String(e||"");return t.addData(e,n.startsWith("CQR3:")||n.startsWith("CQR4:")?"Alphanumeric":"Byte"),t.make(),t}function Za(e){return Ho(e).createSvgTag(2,4)}function er(e){let t=Ho(e),n=t.getModuleCount(),i=4,r=4,c=(n+r*2)*i,s=document.createElement("canvas");s.width=c,s.height=c;let g=s.getContext("2d");g.fillStyle="#ffffff",g.fillRect(0,0,c,c),g.fillStyle="#000000";for(let m=0;m|]+/g,"-").replace(/\s+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,64)||"carrot"}function qo(e=new Date){let t=String(e.getFullYear()),n=String(e.getMonth()+1).padStart(2,"0"),i=String(e.getDate()).padStart(2,"0");return`${t}${n}${i}`}async function nr(){try{let e=await bulkGet(["GitBranch"]);return`${tr(e.GitBranch)}-${qo()}.png`}catch{return`carrot-${qo()}.png`}}async function Wo(){if(lt)return lt;lt=(async()=>{let e=await getJson("/api/params_qr_dependency");if(console.info("[carrot][qr-dependency]",e),e.installed)return e;Go("qr_configuring","Please wait...");let t=await postJson("/api/params_qr_dependency/ensure",{});if(console.info("[carrot][qr-dependency]",t),!t.ok||!t.installed)throw new Error(t.error||k("qr_config_failed","QR feature could not be configured."));return Go("qr_config_done","QR feature is ready."),t})();try{return await lt}finally{lt=null}}function or(e,t){let n=document.createElement("a");n.href=e,n.download=t,document.body.appendChild(n),n.click(),n.remove()}function ir(e){let t=document.createElement("canvas"),n=Math.min(1,1600/Math.max(e.naturalWidth||e.width,e.naturalHeight||e.height,1));t.width=Math.max(1,Math.floor((e.naturalWidth||e.width)*n)),t.height=Math.max(1,Math.floor((e.naturalHeight||e.height)*n));let i=t.getContext("2d",{willReadFrequently:!0});i.drawImage(e,0,0,t.width,t.height);let r=i.getImageData(0,0,t.width,t.height);return(typeof jsQR=="function"?jsQR(r.data,t.width,t.height):null)?.data||""}function ar(e,t,n){if(!e)return;let i=new FileReader;i.onload=()=>{let r=new Image;r.onload=()=>{let c=ir(r);c?t(c):n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.onerror=()=>{n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.src=String(i.result||"")},i.readAsDataURL(e)}function rr(e={}){return typeof renderSettingsDiffSummary=="function"?renderSettingsDiffSummary(e):""}function sr(e){return typeof renderSettingsDiffHtml=="function"?renderSettingsDiffHtml(e,{currentLabel:k("qr_restore_current_value","Current"),nextLabel:k("qr_restore_backup_value","Restore"),changedLabel:k("qr_restore_changed","Changed")}):rr(e?.summary)}async function lr(){try{await Wo();let e=await getJson("/api/params_qr_backup"),t=e.format||String(e.payload||"").split(/[.:]/,1)[0]||"unknown";console.info("[carrot][qr-backup]",{format:t,version:e.version,count:e.count,payloadChars:e.payload_chars,rawBytes:e.json_bytes,compressedBytes:e.compressed_bytes,ecc:e.ecc});let n=Za(e.payload),i=er(e.payload),r=await nr(),c=k("qr_backup_title","QR Backup"),s=` +${p}`}function pi(o,a){let l=String(o?.status||""),d=String(o?.action||"").trim(),p=vn(o),b=p?Sn(o,p):null,T=ui(o),A=Number(o?.updated_at||o?.created_at||0),G=!!o?.payload?.notice,O=`job-${o?.id||a}`,V=p?`${O}:${p.after||p.before||""}`:"",E=hn(o);return l==="running"?E=String(o?.message||"").trim()||si(d)||E:l==="failed"?E=li(d)||E:G?E=kt(T):p?E=U(b?.card||p.card_summary||p.display||"")||kt(T):E=Dt(o)||ri(d)||E,{id:O,ackId:V,source:l==="running"?"current":"history",text:T,title:bn(o),summary:E,updateCard:b?{label:b.label||"",messages:Array.isArray(b.messages)?b.messages:[],stats:b.stats||null}:null,status:l,highlight:!!(p?.updated&&l==="done"&&!L.has(V)),updateKind:p?"git_pull":"",timestamp:A,timeLabel:ii(A)}}function Tn(o,a,l){let d=U(o);return{id:`${a}-${l}-${Se(d)}`,source:a,text:d,title:ni(d),summary:kt(d),timestamp:0,timeLabel:""}}function pt(o={}){let a=U(o.currentText),l=U(o.historyText),d=Array.isArray(o.jobs)?o.jobs.filter(Boolean):[],p=[];return d.slice(0,i).forEach((b,T)=>p.push(pi(b,T))),a&&p.push(Tn(a,"current",0)),l&&l.split(/\n{2,}/).map(b=>b.trim()).filter(Boolean).slice(-i).forEach((b,T)=>p.push(Tn(b,"history",T))),{entries:p,hasHistory:!!l||d.some(b=>String(b?.status||"")!=="running"),state:{jobs:d,historyText:l,currentText:a}}}function Cn(o,a){let l=document.createElement("button");return l.type="button",l.className="tools-console-log__clearBtn",l.textContent=y("tools_notifications_clear","Clear"),l.disabled=!o.hasHistory,l.addEventListener("click",d=>{d.stopPropagation(),f="",a.options.onClear?.()}),l}function An(o){let a=document.createElement("span");a.className="tools-console-log__header",a.dataset.toolsNotificationMode=o.mode;let l=document.createElement("span");if(l.className="tools-console-log__headerTitle",l.textContent=y("tools_notifications","Notifications"),a.appendChild(l),o.mode===n.LANDSCAPE){let d=document.createElement("span");d.className="tools-console-log__headerActions",d.appendChild(Cn(o.model,o)),a.appendChild(d)}return a}function gi(){let o=document.createElement("span");return o.className="tools-console-log__empty",o.textContent=y("tools_notifications_empty","No notifications"),o}function Pt(){return!!t.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches}function mi(o){let a=String(o||"").trim().split(",")[0].trim();if(!a)return 0;let l=Number.parseFloat(a);return Number.isFinite(l)?a.endsWith("ms")?l:l*1e3:0}function En(o){if(Pt())return 0;try{let a=t.getComputedStyle?.(o),l=mi(a?.getPropertyValue("--tools-detail-duration"));return Math.max(180,l||320)+40}catch{return 360}}function Bt(){F&&(t.clearTimeout(F),F=null,z="",j=0,te=null)}function fi(o){return!!(F&&z&&Date.now()bi(a.id,o),{passive:!0}))}function gt(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`)}function xn(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.transition="none",a.style.animation="none",a.style.opacity="1",a.style.transform="translateY(0)",gt(o),a.getBoundingClientRect())}function yi(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`,a.style.opacity="1",a.style.transform="translateY(0)",a.getBoundingClientRect(),o.classList.add("is-collapsing"),o.classList.remove("is-expanded"),o.setAttribute("aria-expanded","false"),t.requestAnimationFrame(()=>{a.style.maxHeight="0px",a.style.opacity="0",a.style.transform="translateY(-6px)"}))}function oe(o){return o?(o.dataset.toolsNotificationMode||Te())===n.PORTRAIT?o:o?.querySelector?.(".tools-console-log__body")||o||null:null}function re(o,a){return!o||!a?null:Array.from(o.querySelectorAll("[data-notification-id]")).find(l=>l.dataset.notificationId===a)||null}function wi(o,a){return JSON.stringify({mode:a,active:f,hasHistory:o.hasHistory,entries:o.entries.map(l=>({id:l.id,source:l.source,title:l.title,summary:l.summary,updateCard:l.updateCard||null,text:l.text,status:l.status||"",highlight:!!l.highlight,updateKind:l.updateKind||"",hasTime:Number(l.timestamp||0)>0}))})}function vi(o){return String(o?.status||"")==="running"}function Si(o){return!!o&&!vi(o)}function Ln(o){let a=oe(o);return a?Array.from(a.querySelectorAll("[data-notification-id]")):[]}function Ti(o,a){let l=Ln(o);return l.length===a.entries.length&&l.every((d,p)=>d.dataset.notificationId===a.entries[p]?.id)}function Ot(o,a){o&&o.textContent!==a&&(o.textContent=a)}function qe(o,a,l){let d=document.createElement("span");return d.className=a,d.textContent=l,o.appendChild(d),d}function Nn(o,a){if(!o)return;if(o.replaceChildren(),o.classList.toggle("tools-console-log__cardBody--update",!!a.updateCard),!a.updateCard){o.textContent=a.summary;return}let l=String(a.updateCard.label||"").trim();l&&qe(o,"tools-console-log__updateLabel",l);let d=Array.isArray(a.updateCard.messages)?a.updateCard.messages.filter(Boolean):[];if(d.length){let b=document.createElement("span");b.className="tools-console-log__updateMessages",d.slice(0,3).forEach(T=>qe(b,"tools-console-log__updateMessage",T)),o.appendChild(b)}let p=a.updateCard.stats&&typeof a.updateCard.stats=="object"?a.updateCard.stats:null;if(p){let b=document.createElement("span");b.className="tools-console-log__updateStats",p.commits&&qe(b,"tools-console-log__updateStat",p.commits),p.files&&qe(b,"tools-console-log__updateStat",p.files),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--add",`+${Math.max(0,ne(p.insertions))}`),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--delete",`-${Math.max(0,ne(p.deletions))}`),o.appendChild(b)}else!d.length&&l&&(o.textContent=l)}function Ci(o,a,l){let d=f===a.id;o.classList.toggle("tools-console-log__current",a.source==="current"),o.classList.toggle("tools-console-log__history",a.source==="history"),o.classList.toggle("is-expanded",d),o.classList.toggle("is-git-update",a.updateKind==="git_pull"),o.classList.toggle("is-unread-update",!!a.highlight),o.dataset.toolsNotificationMode=l.mode,o.setAttribute("aria-expanded",d?"true":"false"),Ot(o.querySelector(".tools-console-log__cardTitle"),a.title);let p=o.querySelector(".tools-console-log__cardHead"),b=o.querySelector(".tools-console-log__cardTime");a.timeLabel?(!b&&p&&(b=document.createElement("span"),b.className="tools-console-log__cardTime",p.appendChild(b)),Ot(b,a.timeLabel)):b&&b.remove(),Nn(o.querySelector(".tools-console-log__cardBody"),a),Ot(o.querySelector(".tools-console-log__detail"),a.text)}function Ai(o,a,l){Ln(o).forEach((d,p)=>Ci(d,a.entries[p],l)),o.querySelectorAll(".tools-console-log__clearBtn").forEach(d=>{d.disabled=!a.hasHistory})}function Mt(o,a){if(!o)return;let l=new Map(a.map(d=>[d.id,d.timeLabel||""]));o.querySelectorAll("[data-notification-id]").forEach(d=>{let p=l.get(d.dataset.notificationId)||"",b=d.querySelector(".tools-console-log__cardTime");b&&b.textContent!==p&&(b.textContent=p)})}function Ei(o){f&&(o.entries.some(a=>a.id===f)||(f=""))}function $n(o){if(!o?.entries?.length)return null;let a=null,l=0;return o.entries.forEach(d=>{let p=Number(d.timestamp||0);Number.isFinite(p)&&p>0&&p>=l&&(a=d,l=p)}),a||o.entries.slice().reverse().find(d=>d.source==="current")||o.entries[o.entries.length-1]}function Ii(o,a,l=!0,d={}){let p=oe(o),T=re(p,a)?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:!1,instant:d.instant===!0,smoothOnce:d.smoothOnce===!0,stableDetail:d.stableDetail===!0,mode:o?.dataset?.toolsNotificationMode||Te(),scrollTop:p?.scrollTop||0,cardTop:T?T.top:null}}function kn(o,a,l={}){if(!a)return"";let d=l.expand!==!1;return Bt(),f=d?a:"",_=Ii(o,a,d,l),v.delete(a),a}function xi(o=C,a={}){let l=pt(N),d=$n(l);if(!d)return"";let p=kn(o,d.id,a);return o&&Ve(o,l.state,M,{force:!0,preserveScroll:!1}),p}function Li(o,a=f){let l=oe(o);if(!l)return null;let d=re(l,a),p=o?.dataset?.toolsNotificationMode||Te();return{anchorId:a,mode:p,scrollTop:l.scrollTop||0,cardTop:d?d.getBoundingClientRect().top:null}}function Ni(o,a,l,d){let p=oe(o),b=re(p,a),T=o?.dataset?.toolsNotificationMode||Te(),A=b?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:d,mode:T,scrollTop:p?.scrollTop||0,cardTop:A?A.top:null}}function $i(o,a){let l=oe(o);if(!l||!a)return;let d=a.scrollTop;if(a.mode!==n.LANDSCAPE&&a.anchorId&&a.cardTop!==null){let p=re(l,a.anchorId);p&&(d+=p.getBoundingClientRect().top-a.cardTop)}l.scrollTop=Math.max(0,d)}function Ut(o,a){let l=Math.max(0,(o?.scrollHeight||0)-(o?.clientHeight||0));return Math.min(Math.max(0,a),l)}function ki(o,a){let l=oe(o),d=re(l,a?.id);if(!l||!a)return;if(!d||a.cardTop===null){l.scrollTop=Ut(l,a.scrollTop||0);return}let p=d.getBoundingClientRect().top-a.cardTop;if(Math.abs(p)<1)return;let b=(a.scrollTop||0)+p;l.scrollTop=Ut(l,b)}function ke(o,a,l="settled",d={}){let p=oe(o),b=re(p,a?.id);if(!p||!b)return;if(a.keyboard)try{b.focus({preventScroll:!0})}catch{}let T=p.getBoundingClientRect(),A=b.getBoundingClientRect(),G=b.querySelector(".tools-console-log__cardHead")?.getBoundingClientRect?.()||A,O=o.dataset.toolsNotificationMode||Te(),V=O===n.PORTRAIT?18:12,E=O===n.PORTRAIT?92:20,X=Math.max(T.height-V-E,1),Q=T.top+V,ge=T.bottom-E,Ce=Math.max(0,Number(d.expandedHeightDelta)||0),se=Ce>0?{top:A.top,bottom:A.bottom+Ce,height:A.height+Ce}:A,I=l==="opening"?G:se,le=0;if(!a.smoothOnce&&!a.stableDetail&&!a.instant&&(se.top>=Q&&se.bottom<=ge||G.top>=Q&&G.bottom<=ge&&se.height>=X)||l==="opening"&&I.top>=Q&&I.bottom<=ge||(a?.expanded&&l!=="opening"&&se.height>=X?le=se.top-Q:I.topge&&(le=I.bottom-ge),Math.abs(le)<2))return;let Je=Ut(p,p.scrollTop+le),Ae=d.behavior||(Pt()?"auto":"smooth");try{p.scrollTo({top:Je,behavior:Ae})}catch{p.scrollTop=Je}}function Ri(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");if(!a)return 0;let l=a.getBoundingClientRect();return Math.max(0,a.scrollHeight-l.height)}function Di(o,a){if(!a?.id)return;h+=1;let l=h;S&&(t.clearTimeout(S),S=null);let d=Te();t.requestAnimationFrame(()=>{if(l!==h)return;if(a.instant){ke(o,a,"settled",{behavior:"auto"});return}if(a.smoothOnce){ke(o,a,"settled");return}if(!a.stableDetail){a.keyboard&&ke(o,a,"opening");return}if(a.expanded&&!Pt()){let b=oe(o),T=re(b,a.id),A=Ri(T);ke(o,a,"concurrent",{expandedHeightDelta:A});let G=d===n.PORTRAIT?380:360;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},G);return}ke(o,a,"opening");let p=a.expanded?d===n.PORTRAIT?380:360:d===n.PORTRAIT?180:160;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},p)})}function Pi(o){let a=document.createElement("span");a.className="tools-console-log__detailWrap";let l=document.createElement("span");l.className="tools-console-log__detailInner";let d=document.createElement("span");d.className="tools-console-log__detailTitle",d.textContent=y("tools_notification_detail","Detail log"),l.appendChild(d);let p=document.createElement("span");return p.className="tools-console-log__detail",p.textContent=o.text,hi(p,o),l.appendChild(p),a.appendChild(l),a}function Bi(o,a){let l=f===o.id,d=document.createElement("div");d.className=`tools-console-log__card tools-console-log__${o.source}`,d.classList.toggle("is-expanded",l),d.classList.toggle("is-git-update",o.updateKind==="git_pull"),d.classList.toggle("is-unread-update",!!o.highlight),d.dataset.notificationId=o.id,d.dataset.toolsNotificationMode=a.mode,d.setAttribute("role","button"),d.setAttribute("tabindex","0"),d.setAttribute("aria-expanded",l?"true":"false");let p=document.createElement("span");p.className="tools-console-log__cardTitle",p.textContent=o.title;let b=document.createElement("span");if(b.className="tools-console-log__cardHead",b.appendChild(p),o.timeLabel){let E=document.createElement("span");E.className="tools-console-log__cardTime",E.textContent=o.timeLabel,b.appendChild(E)}d.appendChild(b);let T=document.createElement("span");T.className="tools-console-log__cardBody",Nn(T,o),d.appendChild(T),d.appendChild(Pi(o));let A=0,G=0,O=!1,V=(E,X=!1)=>{if(O){O=!1,E.preventDefault();return}let Q=!l;if(_=Ni(a.out,o.id,Q,X),Qo(o,d),!Q){Bt(),f="",v.delete(o.id),z=o.id,te=a.out,yi(d),j=Date.now()+En(d),F=t.setTimeout(()=>{F=null,z="",j=0,te=null,Ve(a.out,a.model.state,a.options,{force:!0})},En(d));return}Bt(),f=Q?o.id:"",Ve(a.out,a.model.state,a.options)};return d.addEventListener("pointerdown",E=>{A=E.clientX,G=E.clientY,O=!1},{passive:!0}),d.addEventListener("pointermove",E=>{Math.hypot(E.clientX-A,E.clientY-G)>8&&(O=!0)},{passive:!0}),d.addEventListener("click",E=>{V(E,E.detail===0)}),d.addEventListener("keydown",E=>{E.key!=="Enter"&&E.key!==" "||(E.preventDefault(),V(E,!0))}),d}function Oi(o,a){let l=document.createElement("span");return l.className="tools-console-log__footer",l.dataset.toolsNotificationMode=a.mode,l.appendChild(Cn(o,a)),l}function Mi(o,a){let l=document.createDocumentFragment();return o.entries.length?(o.entries.forEach(d=>l.appendChild(Bi(d,a))),l):(l.appendChild(gi()),l)}function Rn(o){let a=document.createElement("span");return a.className="tools-console-log__body",a.appendChild(Mi(o.model,o)),a}function Ui(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a}function Gi(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a.appendChild(Oi(o.model,o)),a}function Ve(o,a={},l={},d={}){if(!o)return;ti();let p=_,b=d.preserveScroll===!1||p?null:Li(o),T=Te(),A=pt(a);if(N=A.state,M=l,ci(A.state),pn(o,T),!d.force&&fi(o)){Mt(o,A.entries),Qe(A.entries);return}Ei(A);let O=l.autoFocusLatest===!0&&!d.skipAutoFocusLatest?$n(A):null;Si(O)&&O.id!==$&&($=O.id,kn(o,O.id,{expand:!0,smoothOnce:!0,stableDetail:!0}),p=_,b=null);let V=wi(A,T),E={out:o,mode:T,model:A,options:l};if(!p&&V===w&&o.childElementCount>0){Mt(o,A.entries),Qe(A.entries);return}if(!p&&o.childElementCount>0&&Ti(o,A)){Ai(o,A,E),w=V,Qe(A.entries),f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)});return}if(o.replaceChildren(T===n.PORTRAIT?Gi(E):Ui(E)),w=V,p){if(ki(o,p),p.instant||p.stableDetail){let X=oe(o);xn(re(X,p.id))}}else if($i(o,b),f){let X=oe(o);xn(re(X,f))}Qe(A.entries),p?(_=null,p.expanded&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,p.id);gt(Q),_i(o,p.id)}),Di(o,p)):f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)})}function qi(o,a={}){_n(o,a,{boundKey:"toolsNotificationGestureBound",getPanel:gn})}function Hi(o,a={}){_n(o,a,{boundKey:"toolsNotificationPanelDragBound",getPanel:()=>o,ignoreTarget:Xo})}function Dn(o=N){return pt(o).entries.filter(a=>!!a.highlight).length}t.CarrotToolsNotifications={bindPanelDrag:Hi,bindStatusGesture:qi,unreadCount:Dn,render:Ve,resetDetail(){f="",$="",v.clear()},focusLatest(o={}){return xi(o.out||C,o)},syncMode(o=C){o&&pn(o)}}})(window);var rn=null,st=null,At=!1,sn={recovering:"eGPU reconnecting",recovering_detail:"The internal model remains in use while eGPU recovery is attempted, up to three times.",recovery_ready:"eGPU READY",recovery_ready_detail:"Test calculations passed. The internal model remains in use until the vehicle stops or you intervene with steering.",recovery_stopped:"eGPU recovery stopped",recovery_stopped_detail:"The internal model remains in use. Once parked, restart the comma. Recovery stopped because a check failed or the retry limit was reached.",title:"eGPU big model",checking:"Checking model",downloading:"Downloading",verifying:"Verifying download",ready:"Ready to compile",waiting_for_ignition:"Waiting for ignition",compiling:"Compiling eGPU model",compiled:"Ready to use",error:"eGPU setup failed",runtime_error:"eGPU unavailable",recovery_restart:"Once parked, open User / System below and press Reboot once. If this happens again, send a screenshot of this card for support.",waiting_for_network:"Waiting for network / automatic retry",installing:"Installing eGPU package",installed:"Installed · next start",active:"eGPU running",waiting_for_network_detail:"Keep an internet connection available. Installation retries automatically every 30 seconds.",installing_detail:"Preparing the verified model and runtime. The current driving model continues running.",installed_detail:"Installation is complete. The eGPU will be tried at the next ignition session; no restart is needed while driving.",active_detail:"The eGPU is currently running the driving model.",failure_dns:"Cannot resolve the download server address. Check the internet connection; retry is automatic.",failure_clock:"Certificate not yet valid. Waiting for the device clock/network to recover; retry is automatic.",failure_network:"Download connection interrupted. Retrying automatically.",failure_server:"The download server is temporarily unavailable. Retrying automatically.",failure_certificate:"Server certificate verification failed. The download was stopped; verification remains enabled.",failure_download:"The server refused the download or the requested file is unavailable.",failure_storage:"Not enough free storage to install the eGPU package.",failure_install:"The eGPU package could not be installed or verified. The saved diagnostic report contains the cause.",failure_rejected:"A previous failure blocked this model. Compiling again does not resolve a runtime failure. If it remains after a reboot, download the diagnostics and send them for support.",diagnostics:"Download diagnostics",failure_runtime:"The eGPU could not start or continue running. The internal model is selected.",failure_pcie:"The eGPU connection could not start. The internal model is selected.",failure_timeout:"The eGPU took too long to respond. The internal model is selected.",failure_usb:"Communication with the eGPU was interrupted. The internal model is selected.",checking_detail:"Checking the model catalog. You can keep using openpilot.",downloading_detail:"Download may continue while driving. Do not restart or power off until it finishes.",verifying_detail:"Checking the complete file. This can take a moment.",ready_detail:"Park, turn ignition on, then restart once to compile.",waiting_for_ignition_detail:"Keep the car parked and ignition on, then restart to compile.",compiling_detail:"Keep the car parked and ignition on. The screen will update when compilation finishes.",compiled_detail:"The eGPU big model is compiled and will be selected automatically.",error_detail:"The eGPU is not ready. Send a screenshot of this card for support.",restart:"Restart & compile",restart_confirm:"Park the car and turn ignition on. Restart now to compile the eGPU big model?",restart_requested:"Restart requested. Compilation will begin during boot.",host_ok:"Connected",host_warning:"High temperature",host_error:"Host error / connection lost",host_unknown:"Waiting for current host health",host_ip:"Host IP",host_no_ip:"Unavailable",host_not_connected:"Host not connected",host_unavailable:"Host connection unavailable",host_health_unavailable:"Host health unavailable"};function Y(e){return typeof getUIText=="function"?getUIText(`egpu_model_${e}`,sn[e]||e):sn[e]||e}function xo(e){let t=Math.max(0,Number(e)||0);return t<1024*1024?`${(t/1024).toFixed(0)} KB`:`${(t/(1024*1024)).toFixed(t>=1024*1024*1024?0:1)} MB`}function Oa(e){let t=Math.max(0,Math.floor(Date.now()/1e3-(Number(e)||Date.now()/1e3))),n=Math.floor(t/60);return`${String(n).padStart(2,"0")}:${String(t%60).padStart(2,"0")}`}function Ma(e){let t=String(e?.display_name||"").trim();if(t)return t;let n=String(e?.model_id||"").toLowerCase();return n.includes("pr39047")||n.includes("mdm-v1")||n.includes("6f4c0faf-256f2178")||n.includes("4bfb5340-e20cde17")?"Mountain Dew":n.includes("pr38932")||n.includes("cinque-v3")?"Cinque v3":n.includes("pr38823")||n.includes("cinque-v2")?"Cinque v2":n.includes("pr38771")||n.includes("cinque-terre")?"Cinque Terre":n.includes("bmrlanpv6")?"BMRLNAP v6":n.includes("pr38739")||n.includes("tgc")?"TGC":n.includes("pr38726")||n.includes("time-to-go")?"Time to Go":""}function Ua(e,t=Y("title")){let n=Ma(e);return n?`${n} · eGPU`:t}function ln(e=st){let t=document.getElementById("egpuModelCard");if(!t)return;if(st=e,!e?.available&&!e?.jetlink){t.hidden=!0;return}let n=String(e.state||"checking"),i=Number(e.progress),r=["checking","downloading","verifying","compiling","waiting_for_network","installing"].includes(n),c=document.getElementById("egpuModelState"),s=document.getElementById("egpuModelDetail"),g=document.getElementById("egpuModelRecovery");g.hidden=!0,g.textContent="";let m=document.getElementById("egpuModelProgress"),f=document.getElementById("egpuModelProgressBar"),C=document.getElementById("egpuModelAmount"),N=document.getElementById("btnEgpuCompileRestart"),M=document.getElementById("egpuModelDiagnostics");M&&(M.hidden=!e.available||n!=="error"||!!e.active,M.textContent=Y("diagnostics"));let u=document.getElementById("jetlinkHealth");u||(u=document.createElement("div"),u.id="jetlinkHealth",u.className="egpu-model-card__detail",s.after(u));let _=e.jetlink;if(u.hidden=!_,_){let z=["ok","warning","error","unknown"].includes(_.severity)?_.severity:"unknown";u.textContent=`${_.label} · ${Y(`host_${z}`)} · ${Y("host_ip")}: ${(_.addresses||[]).join(", ")||Y("host_no_ip")}`+(Number.isFinite(_.temp_c)?` · ${_.temp_c.toFixed(1)} °C`:"")+(_.reason?` · ${{"Host not connected":Y("host_not_connected"),"Host connection unavailable":Y("host_unavailable"),"Host health unavailable":Y("host_health_unavailable")}[_.reason]||_.reason}`:""),u.style.color=z==="error"?"#ff7474":z==="warning"?"#ffd166":""}if(!e.available){t.hidden=!1,t.dataset.state=_.severity==="error"?"error":"compiled",t.classList.remove("is-running"),document.getElementById("egpuModelTitle").textContent=`${_.label} · Cinque v2`,c.textContent=Y(`host_${_.severity||"unknown"}`),s.textContent="",m.hidden=!0,C.textContent="",N.hidden=!0;return}t.hidden=!1,t.dataset.state=n,t.classList.toggle("is-running",r),document.getElementById("egpuModelTitle").textContent=Ua(e);let h=e.active&&["compiled","installed"].includes(n),S=n==="error"&&["runtime","pcie","timeout","usb"].includes(e.error_code);c.textContent=Y(h?"active":S?"runtime_error":n);let P=`failure_${e.error_code}`;s.textContent=e.error_code?Y(Object.hasOwn(sn,P)?P:"error_detail"):Y(h?"active_detail":`${n}_detail`),g.hidden=!S,g.textContent=S?Y("recovery_restart"):"";let F=n==="downloading"&&Number.isFinite(i);m.hidden=!F,f.style.width=`${Math.min(100,Math.max(0,i||0))}%`,C.textContent=F?`${xo(e.downloaded_bytes)} / ${xo(e.total_bytes)} · ${i.toFixed(1)}%`:n==="compiling"?Oa(e.started_at):"",N.textContent=Y(At?"checking":"restart"),N.hidden=!["ready","waiting_for_ignition","error"].includes(n)||e.compiled||e.error_code==="rejected",N.disabled=At||!e.can_restart}async function Et(){try{ln(await getJson("/api/egpu/model"))}catch{st?.jetlink&&ln({...st,jetlink:{...st.jetlink,severity:"unknown",addresses:[],temp_c:null,reason:"",fresh:!1}})}finally{window.clearTimeout(rn),rn=window.setTimeout(Et,2e3)}}async function Ga(){if(!await appConfirm(Y("restart_confirm"),{title:Y("restart")}))return;let e=document.getElementById("btnEgpuCompileRestart");At=!0,e&&(e.disabled=!0,e.textContent=Y("checking"));try{await postJson("/api/egpu/model/compile-restart",{}),typeof showAppToast=="function"&&showAppToast(Y("restart_requested"));return}catch(t){At=!1,typeof showAppToast=="function"&&showAppToast(`${Y("error")}: ${t?.message||t}`,{tone:"error",duration:5e3}),typeof showError=="function"&&showError("eGPU model",t),await Et()}}function qa(){let e=document.getElementById("btnEgpuCompileRestart");e&&e.dataset.bound!=="1"&&(e.dataset.bound="1",e.addEventListener("click",Ga)),rn||Et()}var Ha=Object.freeze({init:qa,refresh:Et,render:ln});globalThis.CarrotEgpuModel=Ha;function It(e){let[t,n="single"]=e.split("@");return[t,n]}function No(e,t){return t==="single"?e:`${e}@${t}`}function $o(e){return[...new Set(Object.keys(e).map(t=>It(t)[0]))]}function ko(e,t){let n=!1;for(let i of t){if(i.reason!=="test")continue;let r=It(i.button)[0];r in e||(e[r]="none",n=!0),i.button in e||(e[i.button]="none",n=!0)}return n}var Lo=["accelCruise","decelCruise","gapAdjustCruise","lfaButton","cancel"],Ro=["none",...Lo,...Lo.map(e=>`${e}Long`),"laneLeft","laneRight","paddleDecel","carrotCruise"];var Wa={ko:{title:"블루투스 리모컨",close:"닫기",scan:"장치 검색 (30초)",on:"블루투스 켜기",off:"블루투스 끄기",help:"정차하고 크루즈를 해제한 상태에서 설정하세요. 매핑을 켜면 해당 장치의 원래 터치·키보드·볼륨 입력을 차단합니다. 차로변경은 기존 속도·측후방·조향 조건을 따릅니다. 휴대폰도 페어링할 수 있지만 통화·오디오·네트워크 기능은 제공하지 않습니다.",empty:"장치를 페어링 모드로 놓고 검색하세요. 기존 휴대폰에 연결되어 있다면 그 연결을 해제하세요.",pair:"페어링",connect:"연결",disconnect:"연결 해제",forget:"등록 해제",paired:"페어링됨",connected:"연결됨",profile:"입력 종류",generic:"일반 키보드 / HID",enabled:"캐럿 매핑 사용 (원래 HID 입력 차단)",save:"매핑 저장",saved:"저장했습니다",test:"키 등록 / 시험 시작 (120초)",stop:"시험 종료",waiting:"시험 준비 중…",testing:"시험 중: 차량 명령은 보내지 않습니다. 버튼을 누르면 아래에 표시됩니다. 새 키는 자동으로 목록에 추가됩니다.",idle:"입력 대기",blocked:"정차·크루즈 해제 상태 또는 입력 서비스가 확인되지 않아 설정할 수 없습니다.",grabbed:"HID 입력 독점 중",inactive:"입력 독점 대기 (연결·매핑 사용 여부 확인)",learnHelp:"먼저 입력 종류와 매핑을 저장한 뒤 시험을 시작하세요. 일반 HID는 시험 중 버튼을 눌렀다 떼어 등록합니다. 변경한 기능은 다시 저장해야 적용됩니다. Yiser-J6의 길게 누르기 지원 여부는 리모컨이 보내는 실제 입력에 따라 다릅니다.",none:"사용 안 함",accelCruise:"크루즈 + (accelCruise)",decelCruise:"크루즈 − (decelCruise)",laneLeft:"왼쪽 차로변경",laneRight:"오른쪽 차로변경",paddleDecel:"패들 감속",gapAdjustCruise:"크루즈 갭",up:"위",down:"아래",left:"왼쪽",right:"오른쪽",center:"중앙",1:"1",2:"2",cancel:"취소",confirm:"확인",pin:"PIN / 패스키",prompt:"페어링 확인",display:"상대 장치에 표시된 번호를 입력하거나 아래 번호를 확인하세요.",scanning:"검색 중",ready:"준비",unavailable:"블루투스 어댑터를 사용할 수 없습니다",noService:"입력 서비스가 실행되지 않았습니다",unsaved:"변경 내용을 저장하세요",sent:"차량 명령 전달",testEvent:"시험 입력",ignored:"명령 미전달",battery:"배터리",single:"짧게 누르기",double:"더블클릭",long:"길게 누르기",carrotCruise:"CarrotCruise (가속 제한 진입)",gestures:"더블클릭은 두 번의 뗌 간격이 350ms 이내일 때 인식합니다. 더블클릭을 지정한 키는 짧게 누르기도 350ms 기다립니다. ‘길게 누르기’에 기능을 지정하면 누른 채 700ms 후 실행합니다. 크루즈 +/−(10단위 동작 포함)는 이후 500ms 간격으로 반복하고, 나머지 기능은 한 번만 실행합니다. 버튼을 떼거나 연결이 끊기면 반복을 멈춥니다. 페달·핸들 버튼 조작, 크루즈 해제 또는 10초 경과 후에는 떼었다 다시 눌러야 합니다. 누름 시간을 보내지 않는 리모컨은 길게 누르기를 구분할 수 없습니다. 시험 중에는 모든 키에서 세 동작을 구분합니다.",multi:"여러 장치를 함께 사용할 수 있으며 장치별로 매핑을 저장합니다 (최대 16개 등록, 실제 동시 연결 수는 기기에 따라 다름). 입력은 순서대로 처리하며 400ms 지난 명령은 버립니다. 키 등록 시험은 선택한 한 장치에만 적용됩니다. 다른 활성 장치는 계속 동작합니다.",carrotHelp:"CarrotCruise는 기존 가속 제한 모드에 진입합니다. 다시 눌러도 해제되지 않으며 크루즈 +로 해제합니다. 실제 가속 제한은 해당 차종의 기존 지원과 CarrotCruiseDecel 설정을 따릅니다.",mappingOn:"매핑 켜짐",mappingOff:"매핑 꺼짐",receiving:"입력 수신 중",accelCruiseLong:"크루즈 + 길게 (10단위 조절)",decelCruiseLong:"크루즈 − 길게 (10단위 조절)",gapAdjustCruiseLong:"갭 길게 (주행 모드 순환)",lfaButton:"LFA 버튼",lfaButtonLong:"LFA 길게 (차선 모드 전환)",cancelCruise:"크루즈 취소",cancelLong:"취소 길게 (크루즈·조향 해제)",buttonHelp:"크루즈 +/−는 크루즈가 꺼져 있으면 기존 활성화 조건을 거쳐 켜기를 요청합니다. 버튼의 ‘길게’ 동작도 기능 목록에서 선택할 수 있습니다. 예: 리모컨 짧게 누르기 → 크루즈 + 길게. 이 경우 추가로 기다리지 않고 핸들 버튼의 롱 동작을 한 번 실행합니다. LFA와 갭의 동작은 기존 설정을 따릅니다."},en:{title:"Bluetooth remotes",close:"Close",scan:"Scan devices (30s)",on:"Enable Bluetooth",off:"Disable Bluetooth",help:"Configure while stationary with cruise disengaged. Enabling a mapping captures this device's original touch, keyboard and volume input. Lane changes retain existing speed, blind-spot and steering conditions. Phones can pair; calls, audio and networking are not provided.",empty:"Put your device in pairing mode and scan. Disconnect it from its previous phone if needed.",pair:"Pair",connect:"Connect",disconnect:"Disconnect",forget:"Forget",paired:"Paired",connected:"Connected",profile:"Input profile",generic:"Generic keyboard / HID",enabled:"Use Carrot mapping (capture original HID input)",save:"Save mapping",saved:"Saved",test:"Learn / test keys (120s)",stop:"End test",waiting:"Preparing test…",testing:"Test mode: no vehicle commands. Press and release a button to see it below. New keys are added to the list automatically.",idle:"Waiting for input",blocked:"Setup requires a fresh stationary, disengaged state and the input service.",grabbed:"Exclusive HID capture active",inactive:"Waiting for HID capture (check connection and mapping)",learnHelp:"Save the input profile and mapping before testing. Press and release each generic HID button during the test to learn it. Save again to apply edited actions. Long-press support on Yiser-J6 depends on the events reported by the remote.",none:"No action",accelCruise:"Cruise + (accelCruise)",decelCruise:"Cruise − (decelCruise)",laneLeft:"Lane change left",laneRight:"Lane change right",paddleDecel:"Paddle deceleration",gapAdjustCruise:"Cruise gap",up:"Up",down:"Down",left:"Left",right:"Right",center:"Center",1:"1",2:"2",cancel:"Cancel",confirm:"Confirm",pin:"PIN / passkey",prompt:"Pairing confirmation",display:"Enter the code shown on the other device, or verify the code below.",scanning:"Scanning",ready:"Ready",unavailable:"Bluetooth adapter unavailable",noService:"Input service is not running",unsaved:"Save your changes",sent:"Vehicle command sent",testEvent:"Test input",ignored:"Command not sent",battery:"Battery",single:"Short press",double:"Double click",long:"Long press",carrotCruise:"CarrotCruise (enter acceleration limit)",gestures:"Double clicks require releases within 350ms. Assigning a double click delays the short press by 350ms. An assigned Long press fires after holding for 700ms. Cruise +/− (including 10-step actions) then repeat every 500ms; other actions fire once. Release or disconnection stops repetition. After a pedal or steering-wheel button input, cruise disengagement, or 10 seconds, release and press again. Remotes that do not report hold duration cannot distinguish long presses. Test mode recognizes all three gestures on every key.",multi:"Use multiple devices with separate mappings (up to 16 saved; simultaneous connections depend on hardware). Commands are processed in order and expire after 400ms. Learning tests only the selected device; other enabled devices continue operating.",carrotHelp:"CarrotCruise enters the existing acceleration-limiting mode. Repeating it keeps the mode on; Cruise + exits it. Actual limiting follows existing vehicle support and CarrotCruiseDecel.",mappingOn:"Mapping on",mappingOff:"Mapping off",receiving:"Receiving input",accelCruiseLong:"Cruise + long (10-step adjustment)",decelCruiseLong:"Cruise − long (10-step adjustment)",gapAdjustCruiseLong:"Gap long (cycle driving mode)",lfaButton:"LFA button",lfaButtonLong:"LFA long (toggle lane-line mode)",cancelCruise:"Cancel cruise",cancelLong:"Cancel long (cruise and lateral off)",buttonHelp:"Cruise +/− request engagement through existing checks when cruise is off. Native long-button actions are separate choices: for example, a short remote press can run Cruise + long once, with no additional hold delay. LFA and gap actions follow existing settings."}},Po={up:"accelCruise",down:"decelCruise",left:"laneLeft",right:"laneRight",center:"paddleDecel",1:"gapAdjustCruise",2:"none"},Fa=Ro,ce,B,ae,ee,Do,cn,xt=!1,dn,ve=!1,x=e=>Wa[typeof LANG<"u"&&LANG==="ko"?"ko":"en"][e]||e,Bo=e=>x(e==="cancel"?"cancelCruise":e),Oo=e=>{let[t,n]=It(e);return`${x(t)} · ${x(n)}`},Ue=new Set,D=(e,t,n)=>{let i=document.createElement(e);return t!=null&&(i.textContent=t),n&&(i.className=n),i},Z=e=>ce.querySelector(`[data-bt="${e}"]`),we=(e,t="div")=>{let n=D(t);return n.dataset.bt=e,n};function de(e,t,n=!0){let i=D("button",x(e),"btn");return i.type="button",n&&(i.dataset.mutation="true"),i.onclick=n?()=>Ka(t):t,i}async function pe(e,t){let n=await fetch(`/api/bluetooth${e?`/${e}`:""}`,e?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t||{})}:{cache:"no-store"});if(!n.ok)throw new Error(await n.text());return n.json()}async function Ka(e){if(!xt){xt=!0,Ke(),Z("error").textContent="";try{await e(),await Uo()}catch(t){Z("error").textContent=t.message}finally{xt=!1,Ke()}}}function Ke(){ce?.querySelectorAll('[data-mutation="true"]').forEach(e=>{e.disabled=xt||!B?.runtime?.stationary})}function ja(e){if(ve){Z("error").textContent=x("unsaved");return}ae=e.address;let t=B.config.devices[ae];ee=t?structuredClone(t):{name:e.name,profile:/yiser-j6/i.test(e.name)?"yiser-j6":"generic",enabled:!1,mapping:{}},!t&&ee.profile==="yiser-j6"&&(ee.mapping={...Po}),ve=!1,Ue=new Set((B.runtime.recent_events||[]).map(n=>n.id)),un()}function un(){let e=Z("editor");if(e.replaceChildren(),!ee)return;e.append(D("h3",`${ee.name||ae} · ${ae}`));let t=D("label",x("profile")),n=D("select");for(let[g,m]of[["yiser-j6","Yiser-J6"],["generic",x("generic")]]){let f=D("option",m);f.value=g,n.append(f)}n.value=ee.profile,n.dataset.mutation="true",n.onchange=()=>{ee.profile=n.value,ee.mapping=n.value==="yiser-j6"?{...Po}:{},ve=!0,un()},t.append(n),e.append(t);let i=D("label"),r=D("input");r.type="checkbox",r.checked=ee.enabled,r.dataset.mutation="true",r.onchange=()=>{ee.enabled=r.checked,ve=!0},i.append(r,document.createTextNode(x("enabled"))),e.append(i);let c=we("mapping");e.append(c),Mo();let s=D("div",null,"bt-actions");s.append(de("save",async()=>{await pe("device-config",{address:ae,device:ee}),ve=!1,Z("notice").textContent=x("saved")}),de("test",async()=>{if(ve||!B.config.devices[ae])throw new Error(x("unsaved"));Ue=new Set((B.runtime.recent_events||[]).map(g=>g.id)),await pe("learn",{address:ae,enabled:!0}),Z("notice").textContent=x("waiting")}),de("stop",async()=>{await pe("learn",{address:ae,enabled:!1})})),e.append(s,D("p",x("learnHelp")),D("p",x("gestures")),D("p",x("buttonHelp")),D("p",x("carrotHelp")),we("notice"),we("capture"),we("event","output")),Ke()}function Mo(){let e=Z("mapping");e.replaceChildren();for(let t of $o(ee.mapping)){let n=D("fieldset",null,"bt-mapping-row");n.append(D("legend",x(t)));for(let i of["single","double","long"]){let r=No(t,i),c=D("label",x(i)),s=D("select");s.dataset.mutation="true",s.setAttribute("aria-label",Oo(r));for(let g of Fa){let m=D("option",Bo(g));m.value=g,s.append(m)}s.value=ee.mapping[r]||"none",s.onchange=()=>{ee.mapping[r]=s.value,ve=!0},c.append(s),n.append(c)}e.append(n)}Ke()}function Qa(){let e=[...B.devices].sort((i,r)=>Number(r.paired)-Number(i.paired)||i.address.localeCompare(r.address)),t=JSON.stringify(e.map(({address:i,name:r,paired:c,connected:s,battery:g})=>({address:i,name:r,paired:c,connected:s,battery:g,enabled:B.config.devices[i]?.enabled,grabbed:B.runtime.grabbed?.includes(i)})));if(t===cn)return;cn=t;let n=Z("devices");n.replaceChildren(),B.devices.length||n.append(D("p",x("empty")));for(let i of e){let r=D("div",null,"bt-device"),c=D("strong",i.name||i.address);r.append(c,D("div",`${i.address} · ${i.connected?x("connected"):i.paired?x("paired"):""}${i.battery!=null?` · ${x("battery")} ${i.battery}%`:""}`)),B.config.devices[i.address]&&r.append(D("div",`${x(B.config.devices[i.address].enabled?"mappingOn":"mappingOff")}${B.runtime.grabbed?.includes(i.address)?` · ${x("receiving")}`:""}`));let s=D("div",null,"bt-actions");i.paired?s.append(de(i.connected?"disconnect":"connect",()=>pe(i.connected?"disconnect":"connect",{address:i.address})),de("forget",async()=>{await pe("forget",{address:i.address}),ae===i.address&&(ae=ee=null,un())}),de("profile",()=>ja(i),!1)):s.append(de("pair",()=>pe("pair",{address:i.address}))),r.append(s),n.append(r)}}function za(){let e=B.prompt;if((e?.id||null)===dn)return;dn=e?.id||null;let t=Z("prompt");if(t.replaceChildren(),!e)return;t.append(D("h3",x("prompt")),D("p",x("display")),D("strong",e.value));let n;["RequestPinCode","RequestPasskey"].includes(e.kind)&&(n=D("input"),n.placeholder=x("pin"),n.setAttribute("aria-label",x("pin")),n.maxLength=e.kind==="RequestPasskey"?6:16,t.append(n)),e.kind.startsWith("Display")||t.append(de("confirm",()=>pe("answer",{id:e.id,value:n?n.value:!0}))),t.append(de("cancel",()=>pe("cancel")))}async function Uo(){if(!ce?.open)return;B=await pe(),Z("status").textContent=B.runtime.stationary?B.available?B.adapters.some(t=>t.discovering)?x("scanning"):x("ready"):x("unavailable"):x("blocked"),Z("radio").textContent=x(B.radioEnabled?"off":"on"),Z("service").textContent=[B.error,!B.runtime.alive&&x("noService"),...Object.values(B.runtime.errors||{}),B.pair?.error].filter(Boolean).join(" · "),Qa(),za();let e=B.runtime.learning;if(ee){Z("capture").textContent=(B.runtime.grabbed||[]).includes(ae)?x("grabbed"):x("inactive"),Z("notice").textContent=e?.address===ae?`${x("testing")} (${Math.max(0,Math.ceil(e.until-B.runtime.time))}s)`:ve?x("unsaved"):"";let t=B.runtime.recent_events||(B.runtime.last_event?[B.runtime.last_event]:[]),n=t.filter(i=>i.address===ae&&!Ue.has(i.id));for(let i of n)Ue.add(i.id),Z("event").textContent=`${Oo(i.button)} → ${Bo(i.action)} · ${x(i.reason==="test"?"testEvent":i.emitted?"sent":"ignored")}`;e?.address===ae&&ko(ee.mapping,n)&&(ve=!0,Mo()),Ue.size>256&&(Ue=new Set(t.map(i=>i.id)))}Ke()}function Va(){if(ce?.open)return;ce?.remove(),B=ae=ee=dn=cn=null,ve=!1,Ue=new Set,ce=D("dialog",null,"carrot-bt-dialog"),ce.setAttribute("aria-label",x("title"));let e=D("div",null,"bt-actions");e.append(D("h2",x("title")),de("close",()=>ce.close(),!1));let t=D("div",null,"bt-actions"),n=de("on",()=>pe("radio",{enabled:!B.radioEnabled}));n.dataset.bt="radio",t.append(n,de("scan",()=>pe("scan")),de("cancel",()=>pe("cancel"))),ce.append(e,D("p",x("help")),D("p",x("multi")),we("status"),we("service"),we("error"),t,we("prompt"),we("devices"),we("editor")),Z("error").setAttribute("role","alert"),Z("status").setAttribute("role","status"),ce.addEventListener("close",()=>{clearTimeout(Do)}),document.body.append(ce),ce.showModal(),Ke();let i=!1,r=async()=>{try{await Uo(),i&&(Z("error").textContent=""),i=!1}catch(c){i=!0,Z("error").textContent=c.message}finally{ce.open&&(Do=setTimeout(r,1e3))}};r()}globalThis.CarrotBluetooth={init(){let e=document.getElementById("btnToolsBluetooth");e&&(e.textContent=x("title"),e.onclick=Va)}};var Ge=null,je=null,dt=null,ct=0,lt=null;function k(e,t,n=null){return typeof getUIText=="function"?getUIText(e,t,n):t}function Lt(e){return String(e??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function $t(e){typeof appDialog>"u"||!appDialog||appDialog.classList.toggle("app-dialog--tools-qr",!!e)}function Go(e,t,n=null,i=null){let r=k(e,t,n);return typeof showAppToast=="function"&&showAppToast(r,i||void 0),r}function Nt(){je&&(cancelAnimationFrame(je),je=null),dt&&(clearTimeout(dt),dt=null),ct=0,Ge&&(Ge.getTracks().forEach(e=>e.stop()),Ge=null)}function $e(e,t){e&&(e.dataset.scanState=t||"idle")}function Ja(e,t){let n=Math.min(e,t)*.74;return{left:(e-n)/2,top:(t-n)/2,right:(e+n)/2,bottom:(t+n)/2,width:n,height:n}}function Ya(e){let t=[e?.location?.topLeftCorner,e?.location?.topRightCorner,e?.location?.bottomRightCorner,e?.location?.bottomLeftCorner].filter(m=>Number.isFinite(Number(m?.x))&&Number.isFinite(Number(m?.y)));if(t.length<4)return null;let n=t.map(m=>Number(m.x)),i=t.map(m=>Number(m.y)),r=Math.min(...n),c=Math.max(...n),s=Math.min(...i),g=Math.max(...i);return{left:r,top:s,right:c,bottom:g,width:c-r,height:g-s,centerX:(r+c)/2,centerY:(s+g)/2}}function Xa(e,t,n){let i=Ja(t,n),r=Ya(e);if(!r||r.width<=0||r.height<=0)return!1;let c=i.width*.18,s=i.height*.18,g=r.centerX>=i.left&&r.centerX<=i.right&&r.centerY>=i.top&&r.centerY<=i.bottom,m=r.left>=i.left-c&&r.right<=i.right+c&&r.top>=i.top-s&&r.bottom<=i.bottom+s,f=r.width>=i.width*.34&&r.height>=i.height*.34&&r.width<=i.width*1.18&&r.height<=i.height*1.18;return g&&m&&f}function Ho(e){if(typeof qrcode!="function")throw new Error("QR library not loaded");let t=qrcode(0,"L"),n=String(e||"");return t.addData(e,n.startsWith("CQR3:")||n.startsWith("CQR4:")?"Alphanumeric":"Byte"),t.make(),t}function Za(e){return Ho(e).createSvgTag(2,4)}function er(e){let t=Ho(e),n=t.getModuleCount(),i=4,r=4,c=(n+r*2)*i,s=document.createElement("canvas");s.width=c,s.height=c;let g=s.getContext("2d");g.fillStyle="#ffffff",g.fillRect(0,0,c,c),g.fillStyle="#000000";for(let m=0;m|]+/g,"-").replace(/\s+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,64)||"carrot"}function qo(e=new Date){let t=String(e.getFullYear()),n=String(e.getMonth()+1).padStart(2,"0"),i=String(e.getDate()).padStart(2,"0");return`${t}${n}${i}`}async function nr(){try{let e=await bulkGet(["GitBranch"]);return`${tr(e.GitBranch)}-${qo()}.png`}catch{return`carrot-${qo()}.png`}}async function Wo(){if(lt)return lt;lt=(async()=>{let e=await getJson("/api/params_qr_dependency");if(console.info("[carrot][qr-dependency]",e),e.installed)return e;Go("qr_configuring","Please wait...");let t=await postJson("/api/params_qr_dependency/ensure",{});if(console.info("[carrot][qr-dependency]",t),!t.ok||!t.installed)throw new Error(t.error||k("qr_config_failed","QR feature could not be configured."));return Go("qr_config_done","QR feature is ready."),t})();try{return await lt}finally{lt=null}}function or(e,t){let n=document.createElement("a");n.href=e,n.download=t,document.body.appendChild(n),n.click(),n.remove()}function ir(e){let t=document.createElement("canvas"),n=Math.min(1,1600/Math.max(e.naturalWidth||e.width,e.naturalHeight||e.height,1));t.width=Math.max(1,Math.floor((e.naturalWidth||e.width)*n)),t.height=Math.max(1,Math.floor((e.naturalHeight||e.height)*n));let i=t.getContext("2d",{willReadFrequently:!0});i.drawImage(e,0,0,t.width,t.height);let r=i.getImageData(0,0,t.width,t.height);return(typeof jsQR=="function"?jsQR(r.data,t.width,t.height):null)?.data||""}function ar(e,t,n){if(!e)return;let i=new FileReader;i.onload=()=>{let r=new Image;r.onload=()=>{let c=ir(r);c?t(c):n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.onerror=()=>{n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.src=String(i.result||"")},i.readAsDataURL(e)}function rr(e={}){return typeof renderSettingsDiffSummary=="function"?renderSettingsDiffSummary(e):""}function sr(e){return typeof renderSettingsDiffHtml=="function"?renderSettingsDiffHtml(e,{currentLabel:k("qr_restore_current_value","Current"),nextLabel:k("qr_restore_backup_value","Restore"),changedLabel:k("qr_restore_changed","Changed")}):rr(e?.summary)}async function lr(){try{await Wo();let e=await getJson("/api/params_qr_backup"),t=e.format||String(e.payload||"").split(/[.:]/,1)[0]||"unknown";console.info("[carrot][qr-backup]",{format:t,version:e.version,count:e.count,payloadChars:e.payload_chars,rawBytes:e.json_bytes,compressedBytes:e.compressed_bytes,ecc:e.ecc});let n=Za(e.payload),i=er(e.payload),r=await nr(),c=k("qr_backup_title","QR Backup"),s=`

${n}
diff --git a/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js b/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js index 61b1d442efb..9c13013bc6b 100644 --- a/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js +++ b/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js @@ -88,7 +88,6 @@ function modelDisplayName(status) { if (explicitName) return explicitName; const modelId = String(status?.model_id || "").toLowerCase(); - if (modelId.includes("pr39087-md-v3")) return "Mountain Dew v3"; if (modelId.includes("pr39047") || modelId.includes("mdm-v1") || modelId.includes("6f4c0faf-256f2178") || modelId.includes("4bfb5340-e20cde17")) return "Mountain Dew"; if (modelId.includes("pr38932") || modelId.includes("cinque-v3")) return "Cinque v3"; if (modelId.includes("pr38823") || modelId.includes("cinque-v2")) return "Cinque v2"; diff --git a/openpilot/selfdrive/controls/lib/latcontrol_angle.py b/openpilot/selfdrive/controls/lib/latcontrol_angle.py index fc9bbfcb9aa..ec73c4e7bdf 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_angle.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_angle.py @@ -24,7 +24,7 @@ def update(self, active, CS, VM, params, steer_limited_by_controls, desired_curv angle_steers_des += params.angleOffsetDeg angle_control_saturated = abs(angle_steers_des - CS.steeringAngleDeg) > STEER_ANGLE_SATURATION_THRESHOLD - angle_log.saturated = bool(self._check_saturation(angle_control_saturated, CS, steer_limited_by_controls, curvature_limited)) + angle_log.saturated = bool(self._check_saturation(angle_control_saturated, CS, False, curvature_limited)) angle_log.steeringAngleDeg = float(CS.steeringAngleDeg) angle_log.steeringAngleDesiredDeg = float(angle_steers_des) if not CS.steeringPressed else float(CS.steeringAngleDeg) return 0, float(angle_steers_des), angle_log diff --git a/openpilot/selfdrive/controls/tests/test_angle_saturation.py b/openpilot/selfdrive/controls/tests/test_angle_saturation.py deleted file mode 100644 index fdea7c9e922..00000000000 --- a/openpilot/selfdrive/controls/tests/test_angle_saturation.py +++ /dev/null @@ -1,22 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle - - -@pytest.mark.parametrize("limited", [False, True]) -@pytest.mark.parametrize("curvature_limited", [False, True]) -def test_saturation_respects_controller_output_limit(limited, curvature_limited): - controller = LatControlAngle(SimpleNamespace(steerLimitTimer=0.4), None) - state = SimpleNamespace(vEgo=15.0, steeringAngleDeg=0.0, steeringPressed=False) - model = SimpleNamespace(get_steer_from_curvature=lambda *_args: 0.2) - params = SimpleNamespace(roll=0.0, angleOffsetDeg=0.0) - for _ in range(50): - _, _, status = controller.update(True, state, model, params, limited, 0.01, None, curvature_limited) - assert status.saturated == (not limited) - - for _ in range(50): - _, _, status = controller.update(False, state, model, params, False, 0.01, None, False) - assert not status.saturated - assert controller.sat_count == 0.0 diff --git a/openpilot/selfdrive/modeld/big_model.py b/openpilot/selfdrive/modeld/big_model.py index c476afed9b4..c5d07dd3c68 100644 --- a/openpilot/selfdrive/modeld/big_model.py +++ b/openpilot/selfdrive/modeld/big_model.py @@ -32,13 +32,13 @@ from openpilot.selfdrive.modeld.big_model_status import BigModelStatusReporter -DEFAULT_MANIFEST_URL = "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/manifest.json" -MD_V3_MODEL = { - "model_id": "comma-pr39087-md-v3-e86b58dc-d4c7a3fc", +DEFAULT_MANIFEST_URL = "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/manifest.json" +MDM_RETRAINED_MODEL = { + "model_id": "comma-pr39047-mdm-retrained-6f4c0faf-256f2178", "filename": "big_driving_tinygrad.pkl", - "size": 799_941_965, - "sha256": "d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422", - "url": "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/big_driving_tinygrad.pkl", + "size": 799_942_179, + "sha256": "256f2178366479a211933701c03518621ed3970289e6a8a43e5a2bcb15e55b5f", + "url": "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/big_driving_tinygrad.pkl", } MAX_MANIFEST_SIZE = 64 * 1024 MAX_MODEL_SIZE = 4 * 1024 * 1024 * 1024 @@ -153,7 +153,7 @@ def fetch_manifest(manifest_url: str = DEFAULT_MANIFEST_URL, timeout: float = 15 # Keep the environment/CLI override path below so a different manifest can # still be tested explicitly without changing this branch. if manifest_url == DEFAULT_MANIFEST_URL: - return BigModelManifest.from_dict(MD_V3_MODEL, manifest_url) + return BigModelManifest.from_dict(MDM_RETRAINED_MODEL, manifest_url) req = Request(manifest_url, headers={"Accept": "application/json", "User-Agent": "carrot-modeld/1"}) with urlopen(req, timeout=timeout) as response: diff --git a/openpilot/selfdrive/modeld/egpu_events.py b/openpilot/selfdrive/modeld/egpu_events.py deleted file mode 100644 index b88dd7cb697..00000000000 --- a/openpilot/selfdrive/modeld/egpu_events.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Low-volume eGPU lifecycle diagnostics for tmux; never the worker protocol.""" -import json -import os -import sys -import time - - -def error_summary(error) -> str: - lines = str(error).strip().splitlines() - return (lines[-1] if lines else type(error).__name__)[-600:] - - -def log_event(event: str, **fields) -> None: - try: - print('[eGPU] ' + json.dumps({'event': event, 'pid': os.getpid(), 'monotonic_s': round(time.monotonic(), 3), **fields}, - ensure_ascii=True), file=sys.stderr, flush=True) - except Exception: - pass # A closed diagnostic stream must not change model/fallback behavior. diff --git a/openpilot/selfdrive/modeld/egpu_recovery.py b/openpilot/selfdrive/modeld/egpu_recovery.py index c0e3940341d..cf00e8b1194 100644 --- a/openpilot/selfdrive/modeld/egpu_recovery.py +++ b/openpilot/selfdrive/modeld/egpu_recovery.py @@ -12,7 +12,6 @@ from types import SimpleNamespace from openpilot.common.egpu_recovery_status import BUDGET, STATUS, write_status -from openpilot.selfdrive.modeld.egpu_events import error_summary, log_event from openpilot.selfdrive.modeld.model_join import may_join from openpilot.selfdrive.modeld.precompiled_model import gpu_bootloader_timeout, gpu_transport_failure, gpu_usb_link_failure, gpu_worker_busy @@ -165,7 +164,6 @@ def __init__(self, path, width, height, *, clock=time.monotonic, prepare=None, self.next_status = 0. self.attempts = 0 self.exhausted = False - self.last_wait_reason, self.next_wait_log = None, 0. try: value = json.loads(budget_path.read_text()) self.attempts, self.exhausted = int(value['attempts']), bool(value['stopped']) @@ -179,18 +177,7 @@ def __init__(self, path, width, height, *, clock=time.monotonic, prepare=None, def _save_budget(self): write_status({'attempts': self.attempts, 'stopped': self.exhausted}, self.budget_path) - def _event(self, event, **fields): - log_event(event, model=self.path.parent.name, **{'attempt': self.attempts, 'limit': len(DELAYS), **fields}) - - def _scheduled(self): - self._event('retry_scheduled', attempt=self.attempts + 1, - delay_s=DELAYS[self.attempts], requires='USB present and healthy internal output for 3s', - active_model='internal') - def failed(self, error): - self._event('fallback', error=error_summary(error), active_model='internal', - retryable=retryable(error, self.manifest), - artifact_blocked=(self.path.parent / 'rejected').exists()) self.healthy_since = self.last_publication = None if self.exhausted or self.attempts >= len(DELAYS) or not retryable(error, self.manifest): self.stop('not_retryable' if not retryable(error, self.manifest) else 'attempts_exhausted') @@ -199,7 +186,6 @@ def failed(self, error): else: self.state, self.reason = 'waiting', '' self.next_attempt = self.clock() + DELAYS[self.attempts] - self._scheduled() def stop(self, reason): if self.state == 'stopped': @@ -208,7 +194,6 @@ def stop(self, reason): if self.task is not None: self.task.cancel.set() self._save_budget() - self._event('retry_stopped', reason=reason, active_model='internal', retry=False) def healthy(self): return self.last_publication is not None and 0 <= self.clock() - self.last_publication <= MAX_PUBLICATION_GAP @@ -230,32 +215,25 @@ def tick(self, sm, present): if self.task is not None: if now - self.task.started_at >= TASK_SECONDS: self.stop('cleanup_timeout') - if not present and not self.task.cancel.is_set(): - self._event('retry_cancelled', reason='USB absent', active_model='internal') + if not present: self.task.cancel.set() if self.task.done.is_set(): error, self.task = self.task.error, None - self._event('retry_failed', error=error_summary(error) if error is not None else 'worker ended without handover', - active_model='internal') if self.state != 'stopped': if self.attempts >= len(DELAYS) or (error is not None and not retryable_preparation(error, self.manifest)): self.stop('attempts_exhausted' if self.attempts >= len(DELAYS) else 'probe_failed') else: self.state = 'waiting' self.next_attempt = now + DELAYS[self.attempts] - self._scheduled() elif self.task.cancel.is_set(): self.state = 'stopped' if self.exhausted else 'recovering' elif self.task.ready_at is not None: - if self.state != 'ready': - self._event('retry_ready', result='probe passed; waiting for handover permission', active_model='internal') self.state = 'ready' valid = {k: sm.valid[k] and sm.alive[k] for k in ('carState', 'selfdriveState', 'carControl')} if present and may_join(now, sm, valid, sm.recv_time): model = self.task.take() if model is not None: self.task, self.state = None, 'idle' - self._event('retry_selected', result='prepared worker handed to modeld') self.publish(force=True) return model if (self.state == 'waiting' and self.task is None and present and self.healthy() and @@ -264,16 +242,8 @@ def tick(self, sm, present): self.attempts += 1 self._save_budget() self.state = 'recovering' - self._event('retry_started', active_model='internal') self.task = self.task_factory(self.prepare, self.healthy) self.task.started_at = now - if self.state == 'waiting': - reason = ('USB absent' if not present else 'internal output not yet healthy for 3s' - if not self.healthy() or self.healthy_since is None or now - self.healthy_since < HEALTH_SECONDS - else 'backoff delay') - if reason != self.last_wait_reason and now >= self.next_wait_log: - self._event('retry_waiting', reason=reason, retry_in_s=max(0, round(self.next_attempt - now))) - self.last_wait_reason, self.next_wait_log = reason, now + 5. self.publish() return None diff --git a/openpilot/selfdrive/modeld/egpu_usb_wait.py b/openpilot/selfdrive/modeld/egpu_usb_wait.py index db3f4c9efff..c68d4fb092e 100644 --- a/openpilot/selfdrive/modeld/egpu_usb_wait.py +++ b/openpilot/selfdrive/modeld/egpu_usb_wait.py @@ -7,8 +7,6 @@ import time RUNTIME_SHA = '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2' -MD_V3_RUNTIME_SHA = 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac' -VERIFIED_USB_RUNTIMES = frozenset((RUNTIME_SHA, MD_V3_RUNTIME_SHA)) SPIN_MS = 1 SLEEP_SECONDS = .0002 @@ -18,7 +16,7 @@ def usb_sleep(self, timeout): def configure_usb_wait(manifest): - if manifest.get('runtime', {}).get('sha256') not in VERIFIED_USB_RUNTIMES: + if manifest.get('runtime', {}).get('sha256') != RUNTIME_SHA: return False from tinygrad.runtime.ops_amd import AMDDevice, USBIface # This interpreter explicitly selects USB+AMD. Do not modify an installed diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index bcd58797b67..5c9bf66a074 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -11,7 +11,6 @@ from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.modeld.egpu_events import log_event from openpilot.common.runtime_diagnostics import RuntimeDiagnostics from openpilot.common.params import Params from openpilot.common.stopping_params import get_stopping_speed @@ -518,8 +517,6 @@ def load_usbgpu_model(): except Exception: cloudlog.exception('eGPU recovery could not be armed') egpu_recovery = None - else: - log_event('retry_stopped', reason='background recovery unavailable', retry=False, active_model='internal') run_count = 0 # The failed USB wait can outlive this camera buffer by a second. Receive # a fresh synchronized pair before running/publishing the internal model. @@ -596,7 +593,6 @@ def load_usbgpu_model(): params.put_bool("UsbGpuLoading", False) usbgpu_startup_pending = False cloudlog.warning("eGPU first model output published; startup complete") - log_event('live_output_resumed', frame_id=meta_main.frame_id, active_model='eGPU') last_vipc_frame_id = meta_main.frame_id if egpu_recovery is not None and model is small_model: try: diff --git a/openpilot/selfdrive/modeld/precompiled_model.py b/openpilot/selfdrive/modeld/precompiled_model.py index 374d1dbf975..32f917f6fcb 100644 --- a/openpilot/selfdrive/modeld/precompiled_model.py +++ b/openpilot/selfdrive/modeld/precompiled_model.py @@ -16,8 +16,6 @@ from openpilot.selfdrive.modeld.big_model import active_manifest, model_cache_dir, model_path from openpilot.selfdrive.modeld.precompiled_artifact import SERIALIZATIONS -from openpilot.selfdrive.modeld.egpu_events import error_summary, log_event -from openpilot.selfdrive.modeld.egpu_usb_wait import VERIFIED_USB_RUNTIMES PROTOCOL = 1 MAX_CATALOG = 64 * 1024 @@ -153,13 +151,13 @@ def gpu_transport_failure(detail: str) -> bool: def gpu_usb_link_failure(detail: str, manifest: dict) -> bool: """Decode the verified USB runtime's latched link error, not arbitrary AMD faults.""" - # Both verified d3f09c9/02e69816 runtimes bind AMD.error_state to the USB link's error word. -1 is - # libusb I/O failure or a short transfer; +1 is LIBUSB_TRANSFER_ERROR from - # usb_reap copying the asynchronous transfer status, not a model prediction. + # This runtime binds AMD.error_state to the USB link's error word. -1 is + # libusb I/O failure or a short transfer, not an invalid model prediction. # Other runtime versions/codes need their own source verification first. runtime = manifest.get('runtime') - return (isinstance(runtime, dict) and runtime.get('sha256') in VERIFIED_USB_RUNTIMES and - re.search(r'^(?:RuntimeError: )?AMD(?::[0-9]+)? failed with -?1\Z', detail.rstrip(), re.MULTILINE) is not None) + return (isinstance(runtime, dict) and runtime.get('sha256') == + '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2' and + re.search(r'^(?:RuntimeError: )?AMD(?::[0-9]+)? failed with -1\Z', detail.rstrip(), re.MULTILINE) is not None) def retry_usb_rejection(root: Path, pickle_sha256: str) -> bool: @@ -230,16 +228,9 @@ def ensure_precompiled(model=None, cache_dir: Path | None = None, progress=None) # Artifact integrity/contract errors still require a different valid artifact. if (root / 'rejected').exists() and (root / 'rejected').read_text() == value['pickle']['sha256']: if not (retry_usb_rejection(root, value['pickle']['sha256']) or retry_output_rejection(root, value['pickle']['sha256'])): - from openpilot.selfdrive.modeld.egpu_worker_progress import saved_failure - failure = saved_failure(cache_dir or model_cache_dir(), model.sha256) - log_event('artifact_blocked', model=model.sha256, retry=False, - reason='no eligible previous-boot failure; inspect saved diagnostics', - error=error_summary(failure.get('error', 'diagnostics unavailable')), - diagnostic=str(root / 'last_failure.json')) return None (root / 'boot_validation.json').unlink(missing_ok=True) - log_event('artifact_retry', model=model.sha256, reason='recoverable previous-boot failure', - next='verify files and require a new startup smoke test') + print('Retrying model after an earlier-boot recoverable GPU failure; revalidating artifacts.') root.mkdir(parents=True, exist_ok=True) target = root / 'model.pkl' if value['format'] == 'comma-generic-onnx' and not target.exists(): @@ -302,9 +293,4 @@ def record_failure(path: Path, error: BaseException | str, phase: str) -> bool: os.replace(temporary, target) if not transient: reject(path) - log_event('failure', model=value['pickle']['sha256'], - runtime=value['runtime'].get('sha256') if isinstance(value.get('runtime'), dict) else None, - boot_id=failure['boot_id'], phase=phase, error=error_summary(error), - classification='transient_device' if transient else 'artifact_blocked', - diagnostic=str(target)) return not transient diff --git a/openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json b/openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json deleted file mode 100644 index 1c129608ea9..00000000000 --- a/openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "metadata": { - "metadata": { - "output_slices": "gASVkwEAAAAAAAB9lCiMCmxhbmVfbGluZXOUjAhidWlsdGluc5SMBXNsaWNllJOUSwBNEAJOh5RS\nlIwPbGFuZV9saW5lc19wcm9ilGgETRACTRgCToeUUpSMCnJvYWRfZWRnZXOUaARNGAJNIANOh5RS\nlIwEbWV0YZRoBE0gA01XA06HlFKUjAtkZXNpcmVfcHJlZJRoBE1XA013A06HlFKUjARwb3NllGgE\nTXcDTYMDToeUUpSMFndpZGVfZnJvbV9kZXZpY2VfZXVsZXKUaARNgwNNiQNOh5RSlIwOcm9hZF90\ncmFuc2Zvcm2UaARNiQNNlQNOh5RSlIwEcGxhbpRoBE2VA01zB06HlFKUjARsZWFklGgETXMHTQMI\nToeUUpSMCWxlYWRfcHJvYpRoBE0DCE0GCE6HlFKUjAxkZXNpcmVfc3RhdGWUaARNBghNDghOh5RS\nlIwGYWN0aW9ulGgETQ4ITRIIToeUUpSMDGhpZGRlbl9zdGF0ZZRoBE0SCE0SSE6HlFKUjANwYWSU\naARNEkhNFEhOh5RSlHUu\n", - "model_checkpoint": "f22e06e5-3b3f-48d2-a6b9-2f32dc20e707/56320/1365126a-e71e-4778-be00-8ab92dc82c09/12864" - }, - "input_shapes": { - "new_img": [ - 2, - 6, - 128, - 256 - ], - "desire": [ - 8 - ], - "traffic_convention": [ - 1, - 2 - ], - "action_t": [ - 1, - 2 - ], - "state_img_q": [ - 2, - 5, - 6, - 128, - 256 - ], - "state_desire_q": [ - 132, - 1, - 8 - ], - "state_feat_q": [ - 128, - 1, - 16384 - ] - }, - "output_shapes": { - "outputs": [ - 1, - 18452 - ], - "next_state_img_q": [ - 2, - 5, - 6, - 128, - 256 - ], - "next_state_desire_q": [ - 132, - 1, - 8 - ], - "next_state_feat_q": [ - 128, - 1, - 16384 - ] - } - }, - "input_specs": { - "new_img": [ - [ - 2, - 6, - 128, - 256 - ], - "uint8", - "AMD" - ], - "desire": [ - [ - 8 - ], - "float32", - "AMD" - ], - "traffic_convention": [ - [ - 1, - 2 - ], - "float32", - "AMD" - ], - "action_t": [ - [ - 1, - 2 - ], - "float32", - "AMD" - ], - "state_img_q": [ - [ - 2, - 5, - 6, - 128, - 256 - ], - "uint8", - "AMD" - ], - "state_desire_q": [ - [ - 132, - 1, - 8 - ], - "float32", - "AMD" - ], - "state_feat_q": [ - [ - 128, - 1, - 16384 - ], - "float32", - "AMD" - ] - }, - "output_specs": { - "outputs": [ - [ - 1, - 18452 - ], - "float32", - "AMD" - ], - "next_state_img_q": [ - [ - 2, - 5, - 6, - 128, - 256 - ], - "uint8", - "AMD" - ], - "next_state_desire_q": [ - [ - 132, - 1, - 8 - ], - "float32", - "AMD" - ], - "next_state_feat_q": [ - [ - 128, - 1, - 16384 - ], - "float32", - "AMD" - ] - } -} diff --git a/openpilot/selfdrive/modeld/tests/test_big_model.py b/openpilot/selfdrive/modeld/tests/test_big_model.py index ce7bd7e635b..f8709b5ee86 100644 --- a/openpilot/selfdrive/modeld/tests/test_big_model.py +++ b/openpilot/selfdrive/modeld/tests/test_big_model.py @@ -60,16 +60,16 @@ def test_manifest_resolves_relative_https_url(): assert manifest.cache_filename.endswith(".onnx") -def test_default_manifest_is_pinned_to_md_v3(monkeypatch): +def test_default_manifest_is_pinned_to_retrained_mountain_dew(monkeypatch): monkeypatch.setattr(big_model, "urlopen", lambda *_args, **_kwargs: pytest.fail("default manifest must be built in")) manifest = big_model.fetch_manifest() - assert big_model.DEFAULT_MANIFEST_URL == "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/manifest.json" - assert manifest.model_id == "comma-pr39087-md-v3-e86b58dc-d4c7a3fc" - assert manifest.size == 799_941_965 - assert manifest.sha256 == "d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422" - assert manifest.url == "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/big_driving_tinygrad.pkl" + assert big_model.DEFAULT_MANIFEST_URL == "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/manifest.json" + assert manifest.model_id == "comma-pr39047-mdm-retrained-6f4c0faf-256f2178" + assert manifest.size == 799_942_179 + assert manifest.sha256 == "256f2178366479a211933701c03518621ed3970289e6a8a43e5a2bcb15e55b5f" + assert manifest.url == "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/big_driving_tinygrad.pkl" assert manifest.precompiled_only - assert manifest.cache_filename == "big_driving_tinygrad-d4c7a3fcfc958138.pkl" + assert manifest.cache_filename == "big_driving_tinygrad-256f2178366479a2.pkl" def test_precompiled_only_model_never_uses_a_local_onnx_build(tmp_path, monkeypatch): diff --git a/openpilot/selfdrive/modeld/tests/test_egpu_events.py b/openpilot/selfdrive/modeld/tests/test_egpu_events.py deleted file mode 100644 index 62aeb19821b..00000000000 --- a/openpilot/selfdrive/modeld/tests/test_egpu_events.py +++ /dev/null @@ -1,20 +0,0 @@ -import json - -from openpilot.selfdrive.modeld.egpu_events import error_summary, log_event - - -def test_error_is_bounded_and_event_is_one_stderr_line(capsys): - assert error_summary(RuntimeError('Traceback\nRuntimeError: AMD failed with 1\n')) == 'RuntimeError: AMD failed with 1' - assert len(error_summary(ValueError('x' * 10000))) == 600 - log_event('failure', error='one\ntwo', retry=False) - captured = capsys.readouterr() - assert not captured.out and len(captured.err.splitlines()) == 1 - value = json.loads(captured.err.removeprefix('[eGPU] ')) - assert value['error'] == 'one\ntwo' and value['retry'] is False - - -def test_closed_diagnostic_stream_cannot_disable_model(monkeypatch): - def broken_print(*args, **kwargs): - raise BrokenPipeError('closed tmux stream') - monkeypatch.setattr('builtins.print', broken_print) - log_event('retry_started', attempt=1) diff --git a/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py b/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py index 288f96ee27d..cb91b002d44 100644 --- a/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py +++ b/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py @@ -84,16 +84,14 @@ def test_fallback_handler_discards_failed_camera_before_internal_inference(): internal = SimpleNamespace(reset=lambda: reset.append(True), run=lambda *a: pytest.fail('stale frame inference')) params = SimpleNamespace(get_bool=lambda _: True, put_bool=lambda *a: None) scope = {'params': params, 'small_model': internal, 'cloudlog': SimpleNamespace(exception=lambda *a: None), - 'queue_usbgpu_error_tmux': lambda *a: None, 'log_event': lambda *a, **kw: None, 'internal_needs_reset': True, + 'queue_usbgpu_error_tmux': lambda *a: None, 'internal_needs_reset': True, 'egpu_recovery': None, 'meta_main': SimpleNamespace(frame_id=41), 'published': []} exec(compile(module, '', 'exec'), scope) assert scope['published'] == [2] and scope['last_vipc_frame_id'] == 41 assert scope['model'] is internal and reset == [True] -@pytest.mark.parametrize('runtime_sha', ['90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2', - 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) -def test_usb_wait_only_adapts_verified_runtime_and_yields(monkeypatch, runtime_sha): +def test_usb_wait_only_adapts_verified_runtime_and_yields(monkeypatch): import sys from openpilot.selfdrive.modeld import egpu_usb_wait as wait class AMD: @@ -107,7 +105,7 @@ def sleep(self, timeout): pass assert USB.sleep is old_sleep and AMD.sleep_timeout_ms == 200 sleeps = [] monkeypatch.setattr(wait.time, 'sleep', sleeps.append) - assert wait.configure_usb_wait({'runtime': {'sha256': runtime_sha}}) + assert wait.configure_usb_wait({'runtime': {'sha256': wait.RUNTIME_SHA}}) USB().sleep(AMD.sleep_timeout_ms) assert sleeps == [.0002] and AMD.sleep_timeout_ms == 1 assert AMD.wait_timeout_ms == 30000 diff --git a/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py b/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py index 3e72134d32e..e2af783acb3 100644 --- a/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py +++ b/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py @@ -118,7 +118,7 @@ def test_ready_rejects_bad_vehicle_state_even_when_stopped(manager, bad): assert value.tick(messages, True) is None -def test_exactly_three_attempts_cleanup_serialized_and_budget_survives_modeld_restart(manager, capsys): +def test_exactly_three_attempts_cleanup_serialized_and_budget_survives_modeld_restart(manager): value, clock = manager value.failed(USB_ERROR) for attempt in range(1, 4): @@ -131,45 +131,12 @@ def test_exactly_three_attempts_cleanup_serialized_and_budget_survives_modeld_re task.done.set() value.tick(Messages(clock.now), True) assert value.state == 'stopped' - events = [json.loads(line.removeprefix('[eGPU] ')) for line in capsys.readouterr().err.splitlines()] - assert [e['attempt'] for e in events if e['event'] == 'retry_started'] == [1, 2, 3] - assert [e['delay_s'] for e in events if e['event'] == 'retry_scheduled'] == list(recovery.DELAYS) - assert sum(e['event'] == 'retry_failed' for e in events) == 3 - assert events[-1]['event'] == 'retry_stopped' and events[-1]['reason'] == 'attempts_exhausted' - assert not any(e['event'] == 'retry_selected' for e in events) restarted = recovery.EgpuRecovery(value.path, 1344, 760, clock=clock, status_path=value.status_path, budget_path=value.budget_path) restarted.failed(USB_ERROR) assert restarted.exhausted and restarted.attempts == 3 -def test_retry_logs_distinguish_probe_ready_handover_and_actual_live_output(manager, capsys): - value, clock = manager - value.failed(USB_ERROR) - advance(manager, 11) - value.task.ready_at = clock.now - for _ in range(20): - value.tick(Messages(clock.now), True) - assert value.tick(Messages(clock.now, moving=False), True) is not None - captured = capsys.readouterr() - events = [json.loads(line.removeprefix('[eGPU] ')) for line in captured.err.splitlines()] - assert not captured.out - assert events[0]['error'] == str(USB_ERROR) and events[0]['active_model'] == 'internal' - assert sum(e['event'] == 'retry_ready' for e in events) == 1 - assert events[-1]['event'] == 'retry_selected' - assert not any(e['event'] == 'live_output_resumed' for e in events) - - -def test_usb_absence_explained_once_without_frame_rate_log_spam(manager, capsys): - value, _ = manager - value.failed(USB_ERROR) - advance(manager, 60, present=False) - events = [json.loads(line.removeprefix('[eGPU] ')) for line in capsys.readouterr().err.splitlines()] - waits = [e for e in events if e['event'] == 'retry_waiting'] - assert len(waits) == 1 and waits[0]['reason'] == 'USB absent' - assert not any(e['event'] == 'retry_started' for e in events) - - @pytest.mark.parametrize('error', [ValueError('non-finite model output'), ValueError('precompiled PKL checksum mismatch'), RuntimeError('AMD failed with 1'), RuntimeError('precompiled checkpoint mismatch'), TimeoutError('some other operation'), RuntimeError('precompiled eGPU worker exited (-9)')]) @@ -180,9 +147,8 @@ def test_numerical_integrity_and_unknown_failures_are_not_retried(manager, error assert value.exhausted and value.attempts == 0 -@pytest.mark.parametrize('code', [-1, 1]) -def test_usb_runtime_identity_required_and_worker_timeout_is_supported(code): - error = RuntimeError(f'AMD failed with {code}') +def test_usb_runtime_identity_required_and_worker_timeout_is_supported(): + error = RuntimeError('AMD failed with -1') assert not recovery.retryable(error, {}) manifest = {'runtime': {'sha256': '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2'}} assert recovery.retryable(error, manifest) diff --git a/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py b/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py index b6a6832778c..848182f124d 100644 --- a/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py +++ b/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py @@ -14,8 +14,7 @@ @pytest.mark.parametrize('fixture,checkpoint_id', [('mdm2_metadata.json', '870a4823-d2ec-4b9a-8015-b947e9901fc7'), - ('mdm_retrained_metadata.json', '8d736683-fed4-4e48-96fd-eae5d9d5bc56'), - ('md_v3_metadata.json', '1365126a-e71e-4778-be00-8ab92dc82c09')]) + ('mdm_retrained_metadata.json', '8d736683-fed4-4e48-96fd-eae5d9d5bc56')]) @pytest.mark.parametrize('previous_fixture', ['cinque_v3_metadata.json', 'mdm_v1_metadata.json']) def test_mdm_contract_matches_parser_and_preserves_history(previous_fixture, fixture, checkpoint_id): fixtures = Path(__file__).parent / 'fixtures' diff --git a/openpilot/selfdrive/modeld/tests/test_precompiled_model.py b/openpilot/selfdrive/modeld/tests/test_precompiled_model.py index e90c920e5cf..f6efdc5f854 100644 --- a/openpilot/selfdrive/modeld/tests/test_precompiled_model.py +++ b/openpilot/selfdrive/modeld/tests/test_precompiled_model.py @@ -228,38 +228,33 @@ def test_transport_match_does_not_hide_model_errors(error): @pytest.mark.parametrize('phase', ['load', 'boot_validation', 'inference']) -@pytest.mark.parametrize('code', [-1, 1]) -def test_pinned_usb_link_error_preserves_failure_without_blacklisting(tmp_path, phase, code): - error = USB_LINK_FAILURE.replace("with -1", f"with {code}") +def test_pinned_usb_link_error_preserves_failure_without_blacklisting(tmp_path, phase): path = tmp_path / 'model.pkl' manifest = {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': USB_RUNTIME_SHA}} (tmp_path / 'installed.json').write_text(json.dumps(manifest)) (tmp_path / 'boot_validation.json').write_text('{"key":"old"}') - assert not pm.record_failure(path, RuntimeError(error), phase) + assert not pm.record_failure(path, RuntimeError(USB_LINK_FAILURE), phase) assert not (tmp_path / 'rejected').exists() assert not (tmp_path / 'boot_validation.json').exists() failure = json.loads((tmp_path / 'last_failure.json').read_text()) - assert failure['error'] == error and not failure['rejected'] and failure['phase'] == phase + assert failure['error'] == USB_LINK_FAILURE and not failure['rejected'] and failure['phase'] == phase @pytest.mark.parametrize('error', ['AMD failed with -1', 'RuntimeError: AMD failed with -1', - 'RuntimeError: AMD:0 failed with -1', 'RuntimeError: AMD failed with 1', - 'RuntimeError: AMD:0 failed with 1', USB_LINK_FAILURE]) -@pytest.mark.parametrize('runtime_sha', [USB_RUNTIME_SHA, 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) -def test_usb_link_error_matches_only_verified_runtime(error, runtime_sha): - assert pm.gpu_usb_link_failure(error, {'runtime': {'sha256': runtime_sha}}) + 'RuntimeError: AMD:0 failed with -1', USB_LINK_FAILURE]) +def test_usb_link_error_matches_only_verified_runtime(error): + assert pm.gpu_usb_link_failure(error, {'runtime': {'sha256': USB_RUNTIME_SHA}}) for manifest in ({}, {'runtime': None}, {'runtime': []}, {'runtime': {'sha256': 'b' * 64}}): assert not pm.gpu_usb_link_failure(error, manifest) -@pytest.mark.parametrize('error', ['RuntimeError: AMD failed with -2', 'RuntimeError: AMD failed with 2', +@pytest.mark.parametrize('error', ['RuntimeError: AMD failed with -2', 'RuntimeError: AMD failed with 1', 'ValueError: AMD failed with -1', 'RuntimeError: AMD failed with -10', 'RuntimeError: AMD failed with -1\nValueError: checksum mismatch', 'RuntimeError: quoted AMD failed with -1', 'RuntimeError: NV failed with -1']) -@pytest.mark.parametrize('runtime_sha', [USB_RUNTIME_SHA, 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) -def test_ambiguous_or_other_gpu_errors_still_reject(tmp_path, error, runtime_sha): +def test_ambiguous_or_other_gpu_errors_still_reject(tmp_path, error): (tmp_path / 'installed.json').write_text(json.dumps( - {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': runtime_sha}})) + {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': USB_RUNTIME_SHA}})) assert pm.record_failure(tmp_path / 'model.pkl', RuntimeError(error), 'inference') assert (tmp_path / 'rejected').exists() @@ -268,16 +263,13 @@ def test_ambiguous_or_other_gpu_errors_still_reject(tmp_path, error, runtime_sha ({'boot_id': ''}, False), ({'phase': 'unknown'}, False), ({'pickle_sha256': 'b' * 64}, False), ({'rejected': False}, False)]) @pytest.mark.parametrize('phase', ['load', 'boot_validation', 'inference']) -@pytest.mark.parametrize('code', [-1, 1]) -@pytest.mark.parametrize('runtime_sha', [USB_RUNTIME_SHA, 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) -def test_old_usb_link_block_needs_new_boot_and_matching_artifact(tmp_path, monkeypatch, change, allowed, phase, code, runtime_sha): - error = USB_LINK_FAILURE.replace("with -1", f"with {code}") +def test_old_usb_link_block_needs_new_boot_and_matching_artifact(tmp_path, monkeypatch, change, allowed, phase): from openpilot.selfdrive.modeld import egpu_worker_progress monkeypatch.setattr(egpu_worker_progress, 'boot_identity', lambda: 'this-boot') (tmp_path / 'installed.json').write_text(json.dumps( - {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': runtime_sha}})) + {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': USB_RUNTIME_SHA}})) failure = {'rejected': True, 'pickle_sha256': 'a' * 64, 'phase': phase, - 'boot_id': 'previous-boot', 'error': error} | change + 'boot_id': 'previous-boot', 'error': USB_LINK_FAILURE} | change (tmp_path / 'last_failure.json').write_text(json.dumps(failure)) assert pm.retry_usb_rejection(tmp_path, 'a' * 64) == allowed assert json.loads((tmp_path / 'last_failure.json').read_text()) == failure @@ -287,9 +279,7 @@ def test_old_usb_link_block_needs_new_boot_and_matching_artifact(tmp_path, monke assert not pm.retry_usb_rejection(tmp_path, 'a' * 64) -@pytest.mark.parametrize('code', [-1, 1]) -def test_previous_usb_link_block_revalidates_files_and_smoke_receipt(tmp_path, monkeypatch, code): - error = USB_LINK_FAILURE.replace("with -1", f"with {code}") +def test_previous_usb_link_block_revalidates_files_and_smoke_receipt(tmp_path, monkeypatch): from openpilot.selfdrive.modeld import egpu_worker_progress value, data = catalog() model = SimpleNamespace(sha256='a' * 64, url='https://nas.example/models/v2/model.onnx') @@ -300,7 +290,7 @@ def fetch(request, **kwargs): monkeypatch.setattr(egpu_worker_progress, 'boot_identity', lambda: 'this-boot') path = pm.ensure_precompiled(model, tmp_path) failure = {'rejected': True, 'pickle_sha256': value['pickle']['sha256'], 'phase': 'inference', - 'boot_id': 'previous-boot', 'error': error} + 'boot_id': 'previous-boot', 'error': USB_LINK_FAILURE} (path.parent / 'last_failure.json').write_text(json.dumps(failure)) # The recorded old manifest supplies the verified runtime identity; the test # catalog then delivers a tiny replacement rather than a real GPU runtime. diff --git a/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py b/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py index 83b927cff19..c333193c490 100644 --- a/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py +++ b/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py @@ -19,7 +19,7 @@ ('ValueError: checkpoint mismatch', 'inference', True, False), ('NonFiniteModelOutput: invalid\nValueError: checksum mismatch', 'inference', True, False), ]) -def test_reboot_retry_rehashes_artifacts_and_requires_new_smoke_test(tmp_path, monkeypatch, capsys, error, phase, old_boot, allowed): +def test_reboot_retry_rehashes_artifacts_and_requires_new_smoke_test(tmp_path, monkeypatch, error, phase, old_boot, allowed): value, data = catalog() model = SimpleNamespace(sha256='a' * 64, url='https://nas.example/models/v2/model.onnx') def fetch(request, **kwargs): @@ -37,10 +37,6 @@ def fetch(request, **kwargs): path.write_bytes(b'corrupt') (path.parent / 'runtime.tar.gz').write_bytes(b'corrupt') result = pm.ensure_precompiled(model, tmp_path) - events = [json.loads(line.removeprefix('[eGPU] ')) for line in capsys.readouterr().err.splitlines()] - assert events[-1]['event'] == ('artifact_retry' if allowed else 'artifact_blocked') - if not allowed: - assert events[-1]['retry'] is False and events[-1]['error'] == error.splitlines()[-1] assert (result == path) == allowed assert (path.parent / 'rejected').exists() == (not allowed) assert json.loads((path.parent / 'last_failure.json').read_text()) == failure diff --git a/openpilot/system/tests/test_panda_firmware_version.py b/openpilot/system/tests/test_panda_firmware_version.py index 2751367a22b..ef59ff1d06e 100644 --- a/openpilot/system/tests/test_panda_firmware_version.py +++ b/openpilot/system/tests/test_panda_firmware_version.py @@ -15,7 +15,7 @@ def source_tree(tmp_path): files = ('panda/SConscript', 'panda/SConstruct', 'panda/firmware_version.py', 'panda/certs/debug.pub', 'panda/certs/release.pub', 'panda/board/main.c', 'panda/board/bootstub.c', 'panda/board/stm32h7/flash.ld', 'panda/crypto/sign.py', - 'opendbc/safety/safety.h', 'opendbc/safety/safety/safety_hyundai.h', 'opendbc/safety/safety/safety_tesla.h') + 'opendbc/safety/safety.h', 'opendbc/safety/safety/safety_hyundai.h') for name in files: path = tmp_path / name path.parent.mkdir(parents=True, exist_ok=True) @@ -38,8 +38,7 @@ def test_unrelated_changes_do_not_change_firmware(source_tree, name): @pytest.mark.parametrize('name', ['panda/board/main.c', 'panda/board/stm32h7/flash.ld', 'panda/SConscript', 'panda/crypto/sign.py', - 'panda/certs/debug.pub', 'opendbc/safety/safety/safety_hyundai.h', - 'opendbc/safety/safety/safety_tesla.h']) + 'panda/certs/debug.pub', 'opendbc/safety/safety/safety_hyundai.h']) def test_actual_firmware_inputs_change_version_without_git_commit(source_tree, name): before = source_version(source_tree) (source_tree / name).write_text('changed input') diff --git a/tools/carrot_route_vault/tests/test_viewer.py b/tools/carrot_route_vault/tests/test_viewer.py index d684bdf129b..765e78a2a5e 100644 --- a/tools/carrot_route_vault/tests/test_viewer.py +++ b/tools/carrot_route_vault/tests/test_viewer.py @@ -100,48 +100,6 @@ async def run(): asyncio.run(run()) -def test_s3_installer_serves_only_release_files_and_supports_resume(tmp_path): - async def run(): - root = tmp_path / 'uploads/models/carrot-s3' - for name in [*viewer_module.S3_DOWNLOAD_TYPES, 'private.json', 'nvs.bin', 'vendor/secret.txt']: - path = root/name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b'0123456789') - async with TestClient(TestServer(create_app(viewer_config(tmp_path), start_cleanup=False))) as client: - for name, content_type in viewer_module.S3_DOWNLOAD_TYPES.items(): - response = await client.get('/models/carrot-s3/'+name) - assert response.status == 200 and await response.read() == b'0123456789' - assert response.headers['Content-Type'] == content_type - assert response.headers['X-Content-Type-Options'] == 'nosniff' - for name in ['private.json', 'nvs.bin', 'vendor/secret.txt', '', '../private.json']: - assert (await client.get('/models/carrot-s3/'+name)).status == 404 - response = await client.get('/models/carrot-s3/carrot-s3.bin', headers={'Range':'bytes=4-'}) - assert response.status == 206 and await response.read() == b'456789' - assert (await client.head('/models/carrot-s3/carrot-s3.bin')).headers['Content-Length'] == '10' - asyncio.run(run()) - - -@pytest.mark.parametrize('component', ['models', 'carrot-s3', 'vendor', 'esptool-js-bundle.js']) -def test_s3_installer_rejects_symlinks(tmp_path, component): - async def run(): - root = tmp_path/'uploads' - parts = ['models', 'carrot-s3', 'vendor', 'esptool-js-bundle.js'] - index = parts.index(component) - parent = root.joinpath(*parts[:index]) - parent.mkdir(parents=True) - outside = tmp_path/'private' - target = outside.joinpath(*parts[index+1:]) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(b'private') - try: - (parent/component).symlink_to(outside, target_is_directory=index != 3) - except OSError: - pytest.skip('Creating symlinks requires Windows permission') - async with TestClient(TestServer(create_app(viewer_config(tmp_path), start_cleanup=False))) as client: - assert (await client.get('/models/carrot-s3/vendor/esptool-js-bundle.js')).status == 404 - asyncio.run(run()) - - @pytest.mark.parametrize('component', ['downloads', 'jetson', 'v0.2.0-preview', 'carrot-jetson.img.zst']) def test_jetson_download_rejects_symlink_at_each_component(tmp_path, component): async def run(): diff --git a/tools/carrot_route_vault/viewer.py b/tools/carrot_route_vault/viewer.py index 87216839446..c2b8a0829be 100644 --- a/tools/carrot_route_vault/viewer.py +++ b/tools/carrot_route_vault/viewer.py @@ -69,24 +69,6 @@ "precompiled-runtime.tar.gz": "application/gzip", } -# Only the public S3 release artifacts; never expose arbitrary model/NVS files. -S3_DOWNLOAD_TYPES = { - "index.html": "text/html; charset=utf-8", - "installer.js": "text/javascript", - "manifest.mjs": "text/javascript", - "release.json": "application/json", - "SHA256SUMS.txt": "text/plain", - "INSTALL_KO.md": "text/plain; charset=utf-8", - "README.md": "text/plain; charset=utf-8", - "bootloader.bin": "application/octet-stream", - "partition-table.bin": "application/octet-stream", - "carrot-s3.bin": "application/octet-stream", - "carrot-s3-1.1.0.zip": "application/zip", - "vendor/esptool-js-bundle.js": "text/javascript", - "vendor/esptool-js-LICENSE.txt": "text/plain", - "vendor/pako-LICENSE.txt": "text/plain", -} - JETSON_RELEASE_RE = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,63})?$") JETSON_DOWNLOAD_TYPES = { "carrot-jetson.img.zst": "application/zstd", @@ -896,27 +878,6 @@ async def model_file(self, request: web.Request) -> web.StreamResponse: ) return web.FileResponse(path, headers={"Content-Type": MODEL_CONTENT_TYPES[filename]}) - async def s3_download(self, request: web.Request) -> web.StreamResponse: - name = request.match_info.get("path", "") - if name not in S3_DOWNLOAD_TYPES: - raise web.HTTPNotFound(text="S3 release file not found") - root = self.storage_root.resolve() - path = root - try: - for part in ("models", "carrot-s3", *name.split("/")): - path = path / part - if path.is_symlink(): - raise web.HTTPNotFound(text="S3 release file not found") - if not path.is_file() or root not in path.resolve().parents: - raise web.HTTPNotFound(text="S3 release file not found") - except OSError as exc: - raise web.HTTPNotFound(text="S3 release file not found") from exc - headers = {"Content-Type": S3_DOWNLOAD_TYPES[name], "Cache-Control": "no-cache", - "X-Content-Type-Options": "nosniff", "Permissions-Policy": "serial=(self)"} - if name.endswith((".zip", ".bin")): - headers["Content-Disposition"] = f'attachment; filename="{name}"' - return web.FileResponse(path, headers=headers) - async def jetson_download(self, request: web.Request) -> web.StreamResponse: filename = request.match_info.get("filename", "") path = await asyncio.to_thread(self._jetson_download_path, request.match_info.get("release"), filename) @@ -1216,7 +1177,6 @@ def register(self, app: web.Application) -> None: app.router.add_get(f"{prefix}/devices/{{device}}/routes_segments", self.api_share_device_routes) app.router.add_get(f"{prefix}/devices/{{device}}/routes/preserved", self.api_share_device_routes) - app.router.add_get("/models/carrot-s3/{path:.*}", self.s3_download) app.router.add_get("/models/{family}/{filename}", self.model_file) app.router.add_get("/downloads/jetson/{release}/{filename}", self.jetson_download) app.router.add_get("/routes/{directory}/{selection}", self.public_page) diff --git a/tools/esp32s3_display/INSTALL_KO.md b/tools/esp32s3_display/INSTALL_KO.md deleted file mode 100644 index 74f6eed27e8..00000000000 --- a/tools/esp32s3_display/INSTALL_KO.md +++ /dev/null @@ -1,144 +0,0 @@ -# CARROT S3 Wi-Fi HUD 설치 설명서 - -**일반 사용자는 [NAS 설치 페이지](https://upload.shind0.synology.me/models/carrot-s3/index.html)를 여세요.** -PC Chrome/Edge에서 보드를 선택하고 설치할 수 있습니다. 개발 환경은 필요 없습니다. -[펌웨어·설명서 ZIP](https://upload.shind0.synology.me/models/carrot-s3/carrot-s3-1.1.0.zip) · -[검증값](https://upload.shind0.synology.me/models/carrot-s3/SHA256SUMS.txt) - -## 준비물과 지원 범위 - -| 준비물 | 확인할 내용 | -| --- | --- | -| ESP32-S3 N16R8 | Flash 16MB, Octal PSRAM 8MB, UART/USB 두 USB-C 포트가 있는 DevKitC-1 형태 | -| 안테나 | 보드 끝에 안테나가 붙은 제품 권장. 외장 안테나형은 별도 안테나 필요하며 이번 검증 대상 아님 | -| USB 화면 | TURZX 9.2인치 1920×462, USB ID `1cbe:0092` | -| 전원형 USB 허브 | 본체 연결용 케이블 + USB 데이터 포트 + 외부 전원 입력. 모든 허브의 호환성을 보장하지 않음 | -| 전원·케이블 | 화면/허브 요구 전력을 충족하는 USB 전원, 데이터 지원 USB 케이블 | - -ESP32, S2, C3, N8R2, PSRAM 없는 보드는 이 펌웨어 대상이 아닙니다. -HDMI 또는 USB-C DP Alt Mode 모니터로 영상 신호를 내보내는 장치가 아닙니다. - -## 연결 - -```text -PC 또는 C4 ── S3 UART 포트 (BOOT 버튼 쪽): 설치·최초 등록 - S3 USB 포트 (RESET 버튼 쪽) - │ - 허브 본체 연결용 케이블 - │ - 전원형 허브 ── USB 화면 (데이터 포트) - │ - USB 전원 입력 -``` - -보드의 좌우보다 `UART` / `USB` 인쇄를 확인하세요. 시험 보드는 USB 포트에서 -화면용 전원을 공급하지 못해 전원형 허브가 필요했습니다. 전원을 받은 화면이 -제조사 로고를 보여도 USB 데이터 통신이 된다는 뜻은 아닙니다. -등록 전 대기 화면은 소등될 수 있으며, 설정 모드를 열면 설정 안내가 나옵니다. - -## 1. 펌웨어 설치 또는 업데이트 - -1. S3 **UART** 포트를 데이터 케이블로 PC에 연결합니다. -2. PC Chrome/Edge에서 NAS 설치 페이지를 엽니다. -3. 새 보드는 **처음 설치**, 기존 CARROT 보드는 **업데이트**를 선택합니다. -4. 보드 확인란을 선택하고 설치 버튼을 눌러 **CP210x / USB Serial** 포트를 고릅니다. -5. 완료 메시지까지 기다립니다. S3가 재시작합니다. - -업데이트는 기존 파티션을 검사한 뒤 앱만 교체합니다. 처음 설치도 전체 지우기를 -하지 않습니다. 설치 전후 등록 저장 영역을 비교해 Wi-Fi·등록 정보 보존을 확인합니다. -처음 설치는 보드의 기존 프로그램을 CARROT으로 교체합니다. - -포트가 없으면 데이터 케이블/UART 포트를 확인하고, 필요 시 -[Silicon Labs CP210x 드라이버](https://www.silabs.com/developer-tools/usb-to-uart-bridge-vcp-drivers)를 설치합니다. -다른 UART 칩 보드는 해당 칩의 드라이버가 필요합니다. 자동 연결에 실패하면 -BOOT를 누른 채 RESET을 한 번 누르고 두 버튼을 뗀 뒤 재시도하세요. -다른 시리얼 모니터는 닫아 두세요. 실패 시 같은 설치를 다시 실행할 수 있습니다. - -## 2-A. C4 USB로 등록 (권장) - -C4에 최신 carrot-nav/carrot-wip가 설치돼 있어야 합니다. C4를 사용할 2.4GHz -Wi-Fi/폰 핫스팟에 연결한 뒤 S3의 UART 포트를 C4에 연결하세요. -C4 웹 터미널 또는 SSH 터미널에서 실행합니다. 시동을 꺼도 등록할 수 있습니다. - -```sh -cd /data/openpilot -python3 tools/esp32s3_display/provision.py --auto -``` - -현재 Wi-Fi 이름과 암호를 S3에 저장하고 C4 등록까지 마칩니다. 지원 UART가 한 개일 -때만 자동 선택하며 S3 응답을 확인한 뒤 저장합니다. 여러 포트가 있으면 실제 S3를 -확인해 `--auto` 대신 `--port /dev/ttyUSB0`처럼 지정하세요. -Wi-Fi 암호를 읽을 권한이 없으면 `sudo -E`로 실행하거나 `--ssid`를 사용합니다. -**케이블을 꽂는 것만으로 등록되지는 않습니다.** - -## 2-B. 폰으로 설정 - -1. 첫 설치 또는 부팅 후 **BOOT 3초**로 설정 화면을 엽니다. 전원을 넣을 때 BOOT를 - 누르고 있으면 다운로드 모드가 되므로 부팅이 끝난 뒤 누르세요. -2. 폰에서 Wi-Fi **CARROT-HUD**, 암호 **12345678**에 접속합니다. -3. **http://192.168.4.1**을 엽니다. 시간 초과 시 “인터넷 없이 연결 유지”를 선택하고 - 모바일 데이터를 잠깐 끕니다. `setup closed`면 BOOT 3초로 다시 엽니다. -4. 차에서 사용할 2.4GHz Wi-Fi 이름·암호를 입력하고 **Wi-Fi 저장**을 누릅니다. - 설정은 약 15초 뒤 닫힙니다. 미저장 설정 창은 10분 후 닫힙니다. -5. 첫 C4 등록이면 **처음 C4 연결: 등록 파일 받기**를 누릅니다. 매번 다른 이름의 - JSON이 다운로드됩니다. Wi-Fi만 바꾸는 경우에는 다운로드가 필요 없습니다. -6. JSON을 WinSCP/SFTP 등으로 C4의 `/data/carrot-s3.json`에 복사하고 아래를 실행합니다. - 파일 전송이 어렵다면 USB 등록(2-A)을 선택하세요. - -```sh -cd /data/openpilot -python3 tools/esp32s3_display/provision.py --import-pairing /data/carrot-s3.json -``` - -등록 파일은 해당 화면에 접근할 수 있는 키입니다. 공개하거나 다른 사람에게 보내지 -마세요. 핫스팟 폰이 Wi-Fi 접속을 동시에 못 하면 다른 폰으로 설정 후 핫스팟을 켭니다. -아이폰 핫스팟은 **호환성 최대화**, 안드로이드는 **2.4GHz**를 선택하세요. - -## 3. 사용 - -1. C4 설정에서 **외부 HUD (ClusterHud) → 9.2인치**를 선택합니다. -2. C4와 S3를 같은 Wi-Fi/핫스팟에 연결합니다. 기기 간 통신을 차단하는 게스트망은 - 사용할 수 없습니다. 차량에서는 PC·USB 테더링이 필요 없습니다. -3. 시동 ON에서 표시하고 OFF에서 소등합니다. 대기 중 화면 시험은 - `ClusterHudDebug`를 사용하고 시험 후 **0**으로 되돌립니다. - -밝기·방향·미러·카메라는 기존 HUD 설정을 사용합니다. Wi-Fi는 기본 최대 15fps, -eGPU 사용 중 최대 10fps입니다. 실제 속도는 화면 내용과 무선 환경에 따라 달라지며 -광학 패널 FPS 보장이 아닙니다. 직접 USB 연결의 기존 5fps 정책은 별도입니다. - -## 다음에 바꿀 때 - -- **SSID/암호:** BOOT 3초 → 폰에서 저장, 또는 새 Wi-Fi에 연결된 C4에서 USB 등록 명령 재실행. - 같은 보드의 등록 키는 유지되어 다시 JSON을 옮길 필요가 없습니다. -- **펌웨어:** NAS 설치 페이지의 업데이트. 일반적인 C4 화면 변경마다 다시 설치할 필요는 - 없지만 S3 자체 버그 수정은 재설치가 필요할 수 있습니다. OTA는 없습니다. -- **직접 USB로 복귀:** C4 등록 파일 `/data/carrot/cluster_wifi.json`을 별도 위치로 옮기고 - HUD를 재시작합니다. 나중에 복원하려면 등록 파일을 보관하세요. - -## 화면이 안 나오거나 꺼지지 않을 때 - -- 제조사 로고만 보이면 허브 본체 케이블이 S3의 **USB**에 연결됐는지 확인합니다. - 전원이 들어오는 것과 데이터 연결은 다릅니다. 다른 허브로 비교할 수 있습니다. -- 설정 Wi-Fi는 보이는데 설정 화면이 안 보이면 화면/허브 연결부터 확인합니다. -- 대기 중 꺼짐은 정상입니다. 시동 ON에서도 안 보이면 C4 등록과 동일 네트워크를 확인합니다. -- 지연이 크면 안테나를 금속·허브에서 떨어뜨리고 다른 2.4GHz 핫스팟과 비교합니다. -- 시동 OFF인데 계속 켜지면 Debug 0, C4 최신 코드, S3 1.1.0 이상을 확인합니다. - 1.1.0은 소등 명령 응답을 확인하고 실패 시 재시도합니다. 통신이 끊겨도 마지막 - 정상 영상 약 2초 후 자체 소등하며 USB 타임아웃이 추가될 수 있습니다. 대기 중 - 30초마다 소등을 재확인합니다. 설정 안내 중에는 켜져 있습니다. - -소등은 백라이트 끄기이며 USB 전원 차단은 아닙니다. - -## 고급 사용자: 파일로 설치 - -ZIP에는 `bootloader.bin`, `partition-table.bin`, `carrot-s3.bin`, `release.json`, -`SHA256SUMS.txt`, 설명서, 검증된 빌드 설정이 있습니다. esptool 4.11 사용 예: - -```sh -python -m esptool --chip esp32s3 --port COM14 --baud 460800 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 carrot-s3.bin -``` - -`COM14`는 예시이며 실제 포트를 선택합니다. 기존 CARROT 앱만 업데이트할 때는 -마지막 `0x10000 carrot-s3.bin`만 기록합니다. **erase_flash는 실행하지 마세요.** -0x9000–0xEFFF의 NVS에는 개인 Wi-Fi/등록 정보가 있습니다. 배포 ZIP에는 이 영역이나 -개인 설정이 포함되지 않습니다. ESP-IDF 5.4.0 소스 빌드는 [README](README.md)를 참고하세요. diff --git a/tools/esp32s3_display/README.md b/tools/esp32s3_display/README.md index 2e768a17154..0b88f887cfc 100644 --- a/tools/esp32s3_display/README.md +++ b/tools/esp32s3_display/README.md @@ -14,13 +14,6 @@ the tested board. This is USB data forwarding, not DisplayPort/USB-C video outpu ## One-time firmware installation -For ordinary users, use the [NAS browser installer and Korean setup guide](https://upload.shind0.synology.me/models/carrot-s3/index.html). -It includes the N16R8 board checklist, wiring diagram, initial setup and a firmware -ZIP download. Chrome/Edge on a PC can install without Python or ESP-IDF. The update -option validates the existing partition table, writes only the app, and verifies -that NVS is unchanged. Version 1.1.0 fixes confirmed-off/retry handling. -See also [INSTALL_KO.md](INSTALL_KO.md). The commands below are for source builds. - Use ESP-IDF **5.4.0**, target esp32s3, from `firmware/`: ```sh @@ -40,23 +33,21 @@ Connect C4 to the intended **2.4 GHz** hotspot first, then connect S3's UART USB connector to C4. Run from the openpilot repository: ```sh -python3 tools/esp32s3_display/provision.py --auto +python tools/esp32s3_display/provision.py --port /dev/ttyUSB0 ``` The utility reads the active NetworkManager Wi-Fi profile and password, verifies the UART bridge identity, writes credentials to S3 NVS, and saves the device ID and pairing key in `/data/carrot/cluster_wifi.json`. It does not save the Wi-Fi password in that file or print it. Reading the active profile's secrets may require root. -Auto selects only when exactly one supported USB UART is present, then verifies -the S3 identity before writing. Otherwise select the actual UART with `--port`; -the utility never probes or resets other serial devices. +Select the actual UART port; the utility never probes or resets other serial devices. This works while C4 is offroad. Plugging in the cable alone does not run registration: run the command again after changing C4's active Wi-Fi to copy the new credentials. For manual setup on a PC or a C4 without readable NetworkManager credentials: ```sh -python3 tools/esp32s3_display/provision.py --port COM14 --ssid "My hotspot" --output carrot-s3.json +python tools/esp32s3_display/provision.py --port COM14 --ssid "My hotspot" --output carrot-s3.json ``` The password is prompted without echo. Import that registration file on C4 with @@ -82,7 +73,7 @@ the command below. Never commit or share a registration file. 5. Transfer the downloaded registration file to C4 and import it once: ```sh -python3 tools/esp32s3_display/provision.py --import-pairing /path/to/carrot-s3.json +python tools/esp32s3_display/provision.py --import-pairing /path/to/carrot-s3.json ``` Both registration methods preserve the existing pairing key when changing SSID. @@ -115,8 +106,7 @@ code, not a new S3 firmware. The Wi-Fi path uses JPEG quality 50 and 15 FPS by default, independently of `ClusterHudEncoder`. Optional integer `fps` (1–20) and `quality` (20–90) fields in the registration JSON change that policy on the next HUD start. The existing -Wi-Fi eGPU activity cap is 10 FPS; the wired USB cap remains 5 FPS. Lower configured -rates still apply. Brightness, layout, camera, mirror and +5 FPS eGPU activity cap remains in force. Brightness, layout, camera, mirror and orientation settings still apply through the existing renderer. H.264 is not enabled or validated for this bridge, although the transport carries opaque packets. Remove the registration file and restart HUD to return to direct USB behavior. @@ -143,13 +133,6 @@ stored credentials. The phone registration file authorizes access to that displa ## Validation -Version 1.1.0 initializes the panel before local shutdown, waits 200 ms, and only -marks it dark after the brightness command succeeds. Failed local shutdowns retry -after 1/2/4/8/16 seconds; idle panels are reasserted off every 30 seconds to recover -a powered-panel reboot. This is backlight off, not removal of USB power. Setup -screens remain visible. C4 also includes registered S3 displays in offroad dimming -and confirms shutdown on connection close. First image wakes the panel again. - The October 15 native C4 test corrected a missing registration, an AP channel latency problem, and the graphics-startup idle disconnect. The existing camera HUD then rendered at typically 9–13 Hz, with model and driver monitoring at 20 Hz in diff --git a/tools/esp32s3_display/firmware/CMakeLists.txt b/tools/esp32s3_display/firmware/CMakeLists.txt index 6981a354693..85783064541 100644 --- a/tools/esp32s3_display/firmware/CMakeLists.txt +++ b/tools/esp32s3_display/firmware/CMakeLists.txt @@ -1,4 +1,3 @@ cmake_minimum_required(VERSION 3.16) -set(PROJECT_VER "1.1.0") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(carrot_s3_display) diff --git a/tools/esp32s3_display/firmware/main/usb_bridge.c b/tools/esp32s3_display/firmware/main/usb_bridge.c index 89d14413c73..9d4919dd67c 100644 --- a/tools/esp32s3_display/firmware/main/usb_bridge.c +++ b/tools/esp32s3_display/firmware/main/usb_bridge.c @@ -21,8 +21,6 @@ static usb_host_client_handle_t client; static usb_device_handle_t opened[128]; static int64_t last_frame; static bool dark = true; -static int64_t next_off; -static unsigned off_failures; static bool setup_shown; static uint8_t brightness = 80; static uint8_t response_data[512]; @@ -112,39 +110,20 @@ static bool send_payload(const uint8_t *data, size_t size) { return true; } -static bool command_response(uint8_t command) { - for (int i=0; i<4; i++) { - if (!read_response(claimed_device)) return false; - if (response_command==command) return true; - } - return false; -} - -static bool local_brightness(uint8_t value) { +static void local_brightness(uint8_t value) { uint8_t packet[512]; display_command(packet, 14, value); - return send_payload(packet, sizeof(packet)) && command_response(14); -} - -static void turn_off(void) { - uint8_t sync[512]; display_command(sync, 10, 0); - bool ok=send_payload(sync, sizeof(sync)) && command_response(10); - // The panel needs time after initialization, including its factory splash. - if (ok) vTaskDelay(pdMS_TO_TICKS(200)); - ok=ok && local_brightness(0); - dark=ok; - if (ok) off_failures=0; - else if (off_failures<5) off_failures++; - unsigned delay_s=ok ? 30 : (1U << (off_failures-1)); - next_off=esp_timer_get_time()+delay_s*1000000LL; - ESP_LOGI(TAG, "DISPLAY_OFF confirmed=%d failures=%u retry_s=%u", ok, off_failures, delay_s); + if (send_payload(packet, sizeof(packet))) { + transfer_timeout_ms=50; read_response(claimed_device); transfer_timeout_ms=1000; + } } static void claim_display(usb_device_handle_t dev) { if (claimed_device) return; if (usb_host_interface_claim(client, dev, 0, 0) != ESP_OK) return; - claimed_device=dev; setup_shown=false; dark=false; last_frame=0; - next_off=0; off_failures=0; - turn_off(); + claimed_device=dev; setup_shown=false; dark=true; last_frame=0; + uint8_t sync[512]; display_command(sync, 10, 0); + if (send_payload(sync, sizeof(sync))) read_response(dev); + local_brightness(0); } static void event_cb(const usb_host_client_event_msg_t *event, void *arg) { @@ -205,7 +184,7 @@ static void describe_device(uint8_t address) { } static void usb_task(void *arg) { - ESP_LOGI(TAG, "Carrot S3 display bridge 1.1.0; no OTA; external powered USB hub required"); + ESP_LOGI(TAG, "Carrot S3 display bridge v1; no OTA; external powered USB hub required"); ESP_LOGI(TAG, "External hub support=%d", CONFIG_USB_HOST_HUBS_SUPPORTED); event_queue = xQueueCreate(16, sizeof(usb_host_client_event_msg_t)); assert(event_queue); @@ -252,42 +231,39 @@ static void usb_task(void *arg) { } } if (!setup && setup_shown) { - setup_shown=false; dark=false; last_frame=0; next_off=0; + if (claimed_device) local_brightness(0); + setup_shown=false; dark=true; } int slot; if (xQueueReceive(network_ready, &slot, 0) == pdTRUE) { network_frame_t *frame=&network_frames[slot]; streaming=true; int64_t start=esp_timer_get_time(); - bool current=claimed_device && network_current(frame) && !setup; - bool ok=current; + bool ok=claimed_device && network_current(frame) && !setup; uint8_t plain[504]; display_decode(frame->data, plain); uint8_t command=plain[0]; uint32_t stamp; memcpy(&stamp, plain+4, 4); bool image=command==101 || command==102; response_length=0; - if (ok && image && (dark || esp_timer_get_time()-last_frame>2000000)) ok=local_brightness(brightness); + if (ok && image && dark) local_brightness(brightness); if (ok) ok=send_payload(frame->data, frame->length); if (ok) { - bool require_reply=frame->expect_reply || (command==14 && plain[8]==0); - transfer_timeout_ms=require_reply ? 1000 : 50; + transfer_timeout_ms=frame->expect_reply ? 1000 : 50; bool received=read_response(claimed_device); transfer_timeout_ms=1000; // Discard late optional command replies, never mistake them for a frame ACK. for (int i=0; received && response_command!=command && i<3; i++) received=read_response(claimed_device); - if (require_reply) ok=received && response_command==command && (!image || response_stamp==stamp); + if (frame->expect_reply) ok=received && response_command==command && (!image || response_stamp==stamp); if (!received) response_length=0; } - if (ok && image) { last_frame=esp_timer_get_time(); dark=false; next_off=0; off_failures=0; } - if (current && command==14 && plain[8]==0) { - dark=ok; last_frame=0; next_off=ok ? esp_timer_get_time()+30000000 : 0; - } else if (ok && command==14) { brightness=plain[8]; dark=false; } + if (ok && image) { last_frame=esp_timer_get_time(); dark=false; } + if (ok && command==14) { brightness=plain[8]; dark=brightness==0; } if (ok) good_frames++; else bad_frames++; network_complete(slot, ok ? 0 : 1, esp_timer_get_time()-start, response_data, response_length); streaming=false; } - if (claimed_device && !setup && esp_timer_get_time()-last_frame>2000000 && esp_timer_get_time()>=next_off) { - turn_off(); + if (claimed_device && !dark && !setup && esp_timer_get_time()-last_frame>2000000) { + local_brightness(0); dark=true; } int64_t now = esp_timer_get_time(); if (now >= report_at) { diff --git a/tools/esp32s3_display/installer/index.html b/tools/esp32s3_display/installer/index.html deleted file mode 100644 index 92e3048df56..00000000000 --- a/tools/esp32s3_display/installer/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - -CARROT S3 · 설치와 설정 - -
CARROT · WI-FI HUD

USB 화면을
Wi-Fi로 연결하세요.

C4가 화면을 만들고, ESP32-S3가 USB 디스플레이로 전달합니다.
이 페이지에서 펌웨어 설치부터 폰 설정, C4 등록까지 진행할 수 있습니다.

펌웨어 1.1.0 · N16R8 전용 · 2026-10-15

펌웨어·설명서 ZIP 받기 파일 검증값
-

1. 준비할 제품

제품확인할 내용
ESP32-S3 N16R8 보드Flash 16MB / Octal PSRAM 8MB, USB-C 두 개, UART·USB 표시가 있는 DevKitC-1 형태. 안테나가 붙어 있는 보드를 권장합니다.
TURZX 9.2인치 USB 화면1920×462, USB ID 1cbe:0092. 이 조합으로 시험했습니다.
외부 전원 USB 허브본체 연결용 케이블과 데이터 포트, 별도의 전원 입력이 필요합니다. 허브 호환성은 제품마다 다릅니다.
케이블·전원데이터 지원 USB 케이블, 화면과 허브의 요구 전력을 충족하는 USB 전원 장치.

ESP32, S2, C3, N8R2 또는 PSRAM이 없는 보드는 이 파일의 설치 대상이 아닙니다. USB-C 영상 입력(DP Alt Mode) 모니터나 HDMI 화면은 지원하지 않습니다. 외장 안테나형 보드는 별도 안테나가 필요하며 이번 검증 대상이 아닙니다.

-

2. 두 USB 포트를 구분해 연결

-PC / C4ESP32-S3UART ← → USBBOOT 쪽 / RESET 쪽전원형 USB 허브USB 화면USB 전원설치·최초 USB 등록 시 연결허브의 본체 연결용 케이블 → S3 USB -

좌우 위치보다 보드에 인쇄된 UART / USB 글자를 확인하세요. UART는 PC에, USB는 허브에 연결합니다. 화면은 허브의 데이터 포트, 충전기는 허브의 전원 입력에 연결합니다.

시험한 보드는 S3의 USB 포트에서 화면용 전원이 나오지 않아 외부 전원 허브가 필요했습니다. 전원만 연결되면 제조사 로고가 보일 수 있습니다. CARROT 영상이 나오는 것은 별도 등록을 마친 뒤입니다. 정상 설치 후 대기 중에는 화면이 꺼지는 것이 정상입니다.

-

3. PC에서 펌웨어 설치

  1. PC의 Chrome 또는 Edge로 이 HTTPS 페이지를 엽니다. 폰 브라우저에서 설치하지 마세요.
  2. S3의 UART 포트를 PC에 연결합니다. 다른 시리얼 모니터·설치 프로그램을 닫습니다.
  3. 아래에서 설치 방법을 선택하고 버튼을 누릅니다. 포트 목록에서 CP210x / USB Serial에 해당하는 S3를 선택하세요.
  4. 완료 메시지를 기다립니다. 설치 후에는 USB 연결이 자동으로 해제되고 S3가 재시작합니다.
- -

-

설치 도구 준비 중…

설치 진행 기록
-

전체 지우기를 하지 않습니다. 업데이트는 기존 파티션을 확인한 뒤 앱만 교체하고, 등록 저장 영역이 그대로인지 검증합니다. 처음 설치는 해당 보드의 기존 프로그램을 CARROT으로 교체합니다. 다른 펌웨어에서 이전하는 경우는 새 보드와 동일한 절차를 적용하세요.

-
포트가 없거나 연결에 실패하면

충전 전용 케이블을 데이터 케이블로 교체하고 UART 포트를 확인합니다. Windows 장치 관리자에서 CP210x 드라이버가 없으면 Silicon Labs 공식 드라이버를 설치하세요. CH340 등 다른 UART 칩인 보드는 그 칩의 드라이버가 필요합니다.

자동 연결이 계속 실패하면 BOOT를 누른 채 RESET을 한 번 눌렀다 떼고, BOOT도 뗀 뒤 다시 설치합니다. 완료 후 RESET을 한 번 누르세요. 업데이트 중 실패하면 케이블을 확인하고 같은 설치를 다시 실행할 수 있습니다.

-

4. Wi-Fi와 C4 등록 — 두 가지 중 선택

C4에는 최신 carrot-nav 또는 carrot-wip가 필요합니다. 시동을 끈 상태에서도 등록할 수 있습니다. C4와 S3는 서로 통신할 수 있는 같은 네트워크에 있어야 합니다. 폰 핫스팟은 2.4GHz, 아이폰은 호환성 최대화를 사용하세요.

-

A. C4에 USB로 한 번 연결 — 권장

  1. C4를 사용할 Wi-Fi/폰 핫스팟에 먼저 연결합니다.
  2. S3의 UART 포트를 C4의 USB에 연결합니다. 화면용 허브는 S3의 USB 쪽에 둡니다.
  3. C4 웹 화면의 터미널 또는 SSH 터미널에서 아래 명령을 실행합니다.
cd /data/openpilot
-python3 tools/esp32s3_display/provision.py --auto

한 개의 지원 UART가 연결돼 있으면 자동 선택합니다. 현재 Wi-Fi 이름·암호를 S3에 저장하고 C4 등록도 함께 마칩니다. 여러 포트가 있으면 명령에 표시되는 목록에서 S3를 확인한 뒤 --auto 대신 --port /dev/ttyUSB0처럼 지정하세요. 케이블을 꽂는 것만으로 등록되지는 않습니다.

-

B. 폰에서 Wi-Fi 설정

  1. 처음 설치한 보드는 설정 화면이 나옵니다. 이미 설정한 보드는 부팅이 끝난 뒤 BOOT를 3초간 눌러 설정을 엽니다. 전원을 넣을 때부터 누르면 다운로드 모드로 들어갑니다.
  2. 폰을 CARROT-HUD Wi-Fi에 연결합니다. 비밀번호는 12345678입니다.
  3. 주소창에 http://192.168.4.1을 입력합니다. 시간 초과가 나면 “인터넷 없이 연결 유지”를 선택하고 모바일 데이터를 잠깐 끕니다.
  4. 차에서 사용할 Wi-Fi 이름·비밀번호를 입력하고 Wi-Fi 저장을 누릅니다. 설정 창은 저장 약 15초 후 닫힙니다.
  5. 처음 C4에 등록하는 경우 처음 C4 연결: 등록 파일 받기를 누릅니다. 파일명은 매번 달라집니다. 이미 C4에 등록한 보드의 Wi-Fi만 바꾼다면 다운로드할 필요가 없습니다.
  6. 받은 JSON을 WinSCP/SFTP 등으로 C4의 /data/carrot-s3.json에 복사하고 아래 명령으로 등록합니다. 파일 전송이 익숙하지 않으면 위 A 방법을 사용하세요.
cd /data/openpilot
-python3 tools/esp32s3_display/provision.py --import-pairing /data/carrot-s3.json

등록 JSON에는 해당 화면을 연결할 수 있는 키가 들어 있습니다. 다른 사람에게 보내거나 공개 게시판에 올리지 마세요. 폰이 핫스팟과 Wi-Fi 접속을 동시에 할 수 없으면 다른 폰으로 설정한 후 원래 폰의 핫스팟을 다시 켜세요.

-

5. 화면 사용과 다음 설정 변경

  1. C4 설정에서 외부 HUD (ClusterHud) → 9.2인치를 선택합니다.
  2. C4와 S3를 등록한 같은 Wi-Fi/핫스팟에 연결합니다. 차량에서 PC나 USB 테더링은 필요하지 않습니다.
  3. 기본적으로 시동을 켜면 HUD가 나오고 시동을 끄면 소등됩니다. 정차 중 시험은 ClusterHudDebug를 사용하고 시험 후 0으로 되돌리세요.

화면 밝기·방향·미러·카메라 등은 기존 HUD 설정을 사용합니다. Wi-Fi 화면은 기본 최대 15fps, eGPU 사용 시 최대 10fps이며 실제 속도는 화면 내용과 통신 상태에 따라 달라집니다. 화면을 바꾸는 일반 C4 업데이트마다 S3를 다시 설치할 필요는 없습니다.

SSID를 바꾸려면 부팅 후 BOOT 3초 → 폰 설정에서 저장하거나, 새 Wi-Fi에 연결된 C4에 UART를 연결하고 --auto 명령을 다시 실행합니다. 기존 C4 등록 키는 유지됩니다. 펌웨어 버그 수정은 이 페이지의 업데이트로 설치합니다. OTA 업데이트 기능은 없습니다.

-

문제가 있을 때

증상확인 순서
제조사 기본 화면만 보임S3 USB 포트·허브 본체 케이블·데이터 포트를 확인하세요. 허브 종류에 따라 USB 통신이 안 될 수 있습니다. 전원이 들어온 것만으로 통신 성공은 아닙니다.
설정 화면도 안 보임BOOT 3초 뒤 CARROT-HUD가 보이는지 확인합니다. Wi-Fi가 보이면 S3는 동작 중입니다. 화면/허브 연결을 점검하세요.
setup closed설정 시간이 끝났습니다. BOOT를 3초 눌러 다시 열고 CARROT-HUD로 재접속합니다.
등록했는데 화면이 꺼져 있음시동 OFF·Debug 0이면 정상입니다. 시동 ON에서도 나오지 않으면 C4 등록, 동일 네트워크, 핫스팟 기기 간 통신 허용을 확인하세요.
끊김·지연안테나 끝을 금속/허브에서 떨어뜨리고 2.4GHz 신호를 확인합니다. 다른 핫스팟과 비교합니다. 공유기 채널·혼잡도에 따라 달라지며 폰의 정상 접속만으로 S3 링크를 판단할 수는 없습니다.
시동을 꺼도 화면이 켜짐Debug를 0으로 설정하고 C4와 S3를 모두 업데이트합니다. 펌웨어 1.1.0은 종료 명령 실패를 재시도하며 통신이 끊겨도 자체 소등합니다. 설정 안내 중에는 화면이 켜져 있습니다.
- - diff --git a/tools/esp32s3_display/installer/installer.js b/tools/esp32s3_display/installer/installer.js deleted file mode 100644 index ff16b869660..00000000000 --- a/tools/esp32s3_display/installer/installer.js +++ /dev/null @@ -1,71 +0,0 @@ -import {ESPLoader, Transport} from './vendor/esptool-js-bundle.js'; -import {validateManifest, verifyBytes, binaryString} from './manifest.mjs'; - -const $ = id => document.getElementById(id); -const status = text => { $('status').textContent = text; }; -const log = text => { $('log').textContent = ($('log').textContent + text + '\n').slice(-16000); }; -let busy = false; -const supported = isSecureContext && 'serial' in navigator; -function enabled() { - $('flash').disabled = busy || !supported || !$('board').checked; - $('mode').disabled = $('board').disabled = busy; -} -$('board').addEventListener('change', enabled); -status(supported ? 'UART 포트를 연결하고 보드 확인란을 선택하세요.' : 'PC의 Chrome 또는 Edge에서 HTTPS 설치 페이지를 열어 주세요.'); -enabled(); - -$('flash').addEventListener('click', async () => { - let transport, loader; - const mode = $('mode').value; - busy = true; enabled(); $('log').textContent = ''; $('progress').value = 0; - try { - const port = await navigator.serial.requestPort(); - status('파일을 검증하고 있습니다.'); - const response = await fetch('./release.json', {cache:'no-store'}); - if (!response.ok) throw new Error('펌웨어 정보를 받지 못했습니다.'); - const manifest = validateManifest(await response.json()); - const parts = await Promise.all(manifest.parts.map(async part => { - const response = await fetch(part.path); - if (!response.ok) throw new Error(`${part.path} 다운로드 실패`); - const bytes = new Uint8Array(await response.arrayBuffer()); - await verifyBytes(bytes, part); - return {...part, bytes}; - })); - transport = new Transport(port, false); - loader = new ESPLoader({transport, baudrate:460800, - terminal:{clean(){}, write:log, writeLine:log}}); - status('S3에 연결 중입니다…'); - await loader.main(); - if (loader.chip.CHIP_NAME !== 'ESP32-S3' || await loader.getFlashSize() !== 16384) { - throw new Error('ESP32-S3 / Flash 16MB 보드가 아닙니다. 설치를 중단했습니다.'); - } - const partition = parts.find(part => part.offset === 0x8000); - if (mode === 'update') { - const bytes = await loader.readFlash(0x8000, partition.size); - try { await verifyBytes(bytes, partition); } - catch { throw new Error('기존 CARROT 파티션이 아닙니다. 새 보드라면 처음 설치를 선택하세요.'); } - } - const nvsBefore = await loader.flashMd5sum(0x9000, 0x6000); - const selected = mode === 'update' ? parts.filter(part => part.offset === 0x10000) : parts; - for (let index=0; index part.md5, - reportProgress:(_file, written, total) => { $('progress').value=(index+written/total)/selected.length*100; }}); - } - if (nvsBefore !== await loader.flashMd5sum(0x9000,0x6000)) { - throw new Error('설치 후 등록 저장 영역 검증이 일치하지 않습니다. 진행 기록을 확인하세요.'); - } - await loader.after('hard_reset'); - status('설치 완료! 등록 정보 보존을 확인했습니다. 아래 4번 Wi-Fi/C4 설정을 진행하세요.'); - $('progress').value=100; - } catch(error) { - status(error.name==='NotFoundError' ? '보드 선택을 취소했습니다.' : `설치 중단: ${error.message}`); - log(error.message); - } finally { - if (transport) { try { await transport.disconnect(); } catch(error) { log(error.message); } } - busy=false; enabled(); - } -}); diff --git a/tools/esp32s3_display/installer/manifest.mjs b/tools/esp32s3_display/installer/manifest.mjs deleted file mode 100644 index be8b5011f62..00000000000 --- a/tools/esp32s3_display/installer/manifest.mjs +++ /dev/null @@ -1,21 +0,0 @@ -export function validateManifest(value) { - const slots = new Map([['bootloader.bin',[0,0x8000]],['partition-table.bin',[0x8000,0x1000]],['carrot-s3.bin',[0x10000,0x300000]]]); - if (value?.chip !== 'ESP32-S3' || value?.board !== 'N16R8' || value?.parts?.length !== 3) throw new Error('지원하지 않는 펌웨어 정보'); - for (const part of value.parts) { - const slot=slots.get(part.path); - if (!slot || part.offset!==slot[0] || !Number.isInteger(part.size) || part.size<=0 || part.size>slot[1] || part.size%4 || - !/^[0-9a-f]{64}$/.test(part.sha256) || !/^[0-9a-f]{32}$/.test(part.md5)) throw new Error('잘못된 펌웨어 파일 또는 주소'); - slots.delete(part.path); - } - return value; -} -export async function verifyBytes(bytes, part) { - if (bytes.length !== part.size) throw new Error('펌웨어 크기 불일치'); - const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), x=>x.toString(16).padStart(2,'0')).join(''); - if (hash !== part.sha256) throw new Error('펌웨어 SHA-256 불일치'); -} -export function binaryString(bytes) { - let value=''; - for (let offset=0; offset({path,offset,size:4,sha256:createHash('sha256').update(bytes).digest('hex'),md5:createHash('md5').update(bytes).digest('hex')}))}; - let nvsReads=0; - class Transport {async disconnect(){events.push('disconnect');}} - class ESPLoader { - constructor(){this.chip={CHIP_NAME:fault==='chip'?'ESP32':'ESP32-S3'};} - async main(){events.push('connect');} - async getFlashSize(){return 16384;} - async readFlash(address,size){assert.equal(address,0x8000);assert.equal(size,4);return fault==='partition'?new Uint8Array(4):bytes;} - async flashMd5sum(address,size){assert.equal(address,0x9000);assert.equal(size,0x6000);return fault==='nvs'&&nvsReads++?'changed':'same';} - async writeFlash(options){ - assert.equal(options.eraseAll,false);assert.equal(options.flashMode,'keep'); - const part=options.fileArray[0];assert.notEqual(part.address,0x9000); - assert.equal(options.calculateMD5Hash(part.data),createHash('md5').update(Buffer.from(part.data,'binary')).digest('hex')); - events.push(part.address);options.reportProgress(0,4,4); - } - async after(mode){assert.equal(mode,'hard_reset');events.push('reset');} - } - const context={document:{getElementById:id=>nodes.get(id)},isSecureContext:true,navigator:{serial:{async requestPort(){return {};}}}, - Transport,ESPLoader,validateManifest,verifyBytes,binaryString,crypto:webcrypto,Uint8Array, - fetch:async path=>({ok:true,json:async()=>manifest,arrayBuffer:async()=>fault==='download'?new Uint8Array(4).buffer:bytes.buffer})}; - vm.runInNewContext(code,context); - await nodes.get('flash').click(); - return {events,status:nodes.get('status').textContent,enabled:!nodes.get('flash').disabled}; -} -test('update writes app only; initial install writes disjoint partitions',async()=>{ - for(const mode of ['update','install']){ - const result=await run({mode}); - assert.deepEqual(result.events,['connect',...(mode==='update'?[0x10000]:[0,0x8000,0x10000]),'reset','disconnect']); - assert.match(result.status,/설치 완료/);assert.equal(result.enabled,true); - } -}); -test('wrong chip, bad partition and corrupt downloads cannot reach flash',async()=>{ - for(const fault of ['chip','partition','download']){ - const result=await run({fault});assert.equal(result.events.some(x=>typeof x==='number'),false); - assert.match(result.status,/설치 중단/);assert.equal(result.enabled,true); - } -}); -test('NVS mismatch reports failure and releases the port',async()=>{ - const result=await run({fault:'nvs'});assert.match(result.status,/저장 영역 검증/); - assert.equal(result.events.at(-1),'disconnect');assert.equal(result.events.includes('reset'),false); -}); diff --git a/tools/esp32s3_display/installer/tests/manifest.test.mjs b/tools/esp32s3_display/installer/tests/manifest.test.mjs deleted file mode 100644 index 61e37c7509b..00000000000 --- a/tools/esp32s3_display/installer/tests/manifest.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {createHash} from 'node:crypto'; -import {validateManifest, verifyBytes, binaryString} from '../manifest.mjs'; -const bytes=new Uint8Array([0,128,255,42]); -const file=path=>({path,size:4,sha256:createHash('sha256').update(bytes).digest('hex'),md5:'a'.repeat(32)}); -function manifest(){return {chip:'ESP32-S3',board:'N16R8',parts:[ - {...file('bootloader.bin'),offset:0},{...file('partition-table.bin'),offset:0x8000},{...file('carrot-s3.bin'),offset:0x10000}]};} -test('release partitions cannot erase NVS or overflow app',()=>{ - assert.equal(validateManifest(manifest()).parts.length,3); - for(const [index,changes] of [[0,{offset:0x9000}],[1,{size:0x2000}],[2,{size:0x300004}],[2,{path:'../private.bin'}]]){ - const value=manifest();Object.assign(value.parts[index],changes);assert.throws(()=>validateManifest(value)); - } - const value=manifest();value.parts[1]=value.parts[0];assert.throws(()=>validateManifest(value)); -}); -test('download corruption stops before flashing',async()=>{ - await verifyBytes(bytes,file('app')); - await assert.rejects(()=>verifyBytes(new Uint8Array([0,128,255,43]),file('app'))); - await assert.rejects(()=>verifyBytes(bytes.subarray(1),file('app'))); - assert.deepEqual([...binaryString(bytes)].map(x=>x.charCodeAt(0)),[0,128,255,42]); -}); diff --git a/tools/esp32s3_display/package_release.py b/tools/esp32s3_display/package_release.py deleted file mode 100644 index 206d949d8cc..00000000000 --- a/tools/esp32s3_display/package_release.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -"""Package an ESP-IDF build without ever reading a device's private NVS.""" -import argparse -import hashlib -import json -from pathlib import Path -import shutil -import struct -import subprocess -import zipfile - -HERE = Path(__file__).resolve().parent -VERSION = '1.1.0' -VENDOR_HASHES = { - 'esptool-js-bundle.js': '475e0e98fd051dc11bf34d8f83410764da11f3a0c40ecd33fd9647dc46b098b8', - 'esptool-js-LICENSE.txt': '1c25f29242785d63e9adb0be7fdc137551dfbbc0756b622fcd26d9cd7de3a4f3', - 'pako-LICENSE.txt': 'a04665b3b2de56c66730c1f720f528175739e4104f79073614aa611da1e85539', -} - - -def digest(data, algorithm='sha256'): - return hashlib.new(algorithm, data).hexdigest() - - -def package(build: Path, output: Path, vendor: Path, sdkconfig: Path): - output.mkdir(parents=True, exist_ok=True) - sources = [('bootloader/bootloader.bin', 'bootloader.bin', 0, 0x8000), - ('partition_table/partition-table.bin', 'partition-table.bin', 0x8000, 0x1000)] - app = next((p for p in [build/'carrot_s3_display.bin', build/'s3_usb_probe.bin'] if p.is_file()), None) - if app is None: - raise ValueError('Built application not found') - sources.append((app.name, 'carrot-s3.bin', 0x10000, 0x300000)) - parts = [] - for source, name, offset, maximum in sources: - data = (build/source).read_bytes() - if not 0 < len(data) <= maximum or len(data) % 4: - raise ValueError(f'Invalid binary size: {name}') - if offset == 0: - if data[0] != 0xe9 or data[2:4] != bytes([2, 0x4f]): - raise ValueError('Bootloader must be preconfigured DIO / 16 MB / 80 MHz') - elif offset == 0x8000: - rows = [struct.unpack(' dict: port.close() -def automatic_port() -> str: - from serial.tools import list_ports - # Enumerate USB metadata only; never open/reset an unrelated port to probe it. - ports = [p.device for p in list_ports.comports() if p.vid in (0x10c4, 0x1a86, 0x0403)] - if len(ports) != 1: - found = ', '.join(ports) if ports else 'none' - raise RuntimeError(f'Connect one S3 UART port, or use --port explicitly. UART candidates: {found}') - return ports[0] - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group(required=True) source.add_argument('--port', help='UART USB port, e.g. /dev/ttyUSB0 or COM14') - source.add_argument('--auto', action='store_true', help='Select the only CP210x/CH34x/FTDI UART; verify S3 identity before writing') source.add_argument('--import-pairing', type=Path, help='Registration JSON downloaded from the phone setup page') parser.add_argument('--ssid', help='Manual Wi-Fi name; password is prompted without echo') parser.add_argument('--output', type=Path, help='Default: /data/carrot/cluster_wifi.json') @@ -102,7 +89,7 @@ def main() -> None: parser.error('Registration file does not exist') else: ssid, password = (args.ssid, getpass.getpass('Wi-Fi password: ')) if args.ssid else current_wifi() - config = register(automatic_port() if args.auto else args.port, ssid, password) + config = register(args.port, ssid, password) save_config(config, args.output) print('S3 registered. Enable the 9.2-inch external HUD; C4 and S3 must use the same 2.4 GHz network.') diff --git a/tools/modeld/build_md_v3_package.py b/tools/modeld/build_md_v3_package.py deleted file mode 100644 index e91e0e54fff..00000000000 --- a/tools/modeld/build_md_v3_package.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Build the pinned MD v3 Mountain Dew NAS package from verified upstream downloads.""" -import argparse -import gzip -import hashlib -import io -import json -from pathlib import Path -import shutil -import tarfile - -MODEL_SHA = 'd4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422' -MODEL_SIZE = 799941965 -SOURCE = 'e86b58dc7b9fb499dd84ac6dbe514a03fa2bc428' -TINYGRAD = '02e698160eed1751738212144a7ea2cfb160b3f1' -SOURCE_TAR_SHA = '2824b933fbf48df672cbb18c8462bd7f9d637a065f4c510ae3d07dedc01ffcb9' -CHECKPOINT = 'f22e06e5-3b3f-48d2-a6b9-2f32dc20e707/56320/1365126a-e71e-4778-be00-8ab92dc82c09/12864' - - -def digest(path): - with path.open('rb') as stream: - return hashlib.file_digest(stream, 'sha256').hexdigest() - - -def build(model, source_tar, output): - if model.stat().st_size != MODEL_SIZE or digest(model) != MODEL_SHA: - raise ValueError('MDM model identity mismatch') - if digest(source_tar) != SOURCE_TAR_SHA: - raise ValueError('pinned tinygrad source archive mismatch') - output.mkdir(parents=True, exist_ok=True) - runtime = output / 'precompiled-runtime.tar.gz' - with source_tar.open('rb') as source, tarfile.open(fileobj=source, mode='r:gz') as upstream: - with runtime.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0) as compressed: - with tarfile.open(fileobj=compressed, mode='w') as archive: - for member in sorted(upstream.getmembers(), key=lambda m: m.name): - name = member.name.partition('/')[2] - if not member.isfile() or not (name.startswith(('tinygrad/', 'examples/openpilot/')) or name == 'LICENSE'): - continue - if '..' in Path(name).parts: - raise ValueError('invalid source archive path') - data = upstream.extractfile(member).read() - info = tarfile.TarInfo(name) - info.size, info.mode, info.mtime = len(data), 0o644, 0 - archive.addfile(info, io.BytesIO(data)) - manifest = {'model_id': 'comma-pr39087-md-v3-e86b58dc-d4c7a3fc', 'filename': 'big_driving_tinygrad.pkl', - 'size': MODEL_SIZE, 'sha256': MODEL_SHA, 'url': 'big_driving_tinygrad.pkl'} - catalog = {'protocol': 1, 'format': 'comma-generic-onnx', 'serialization': 'persistent-buffer-v1', - 'gpu_arch': 'gfx1200', 'frame_skip': 4, 'camera_resolutions': [[1928, 1208], [1344, 760]], - 'model_sha256': MODEL_SHA, 'model_checkpoint': CHECKPOINT, 'source_commit': SOURCE, 'tinygrad_commit': TINYGRAD, - 'pickle': {'url': manifest['url'], 'size': MODEL_SIZE, 'sha256': MODEL_SHA}, - 'runtime': {'url': runtime.name, 'size': runtime.stat().st_size, 'sha256': digest(runtime)}} - for name, value in [('manifest.json', manifest), ('precompiled.json', catalog)]: - (output / name).write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8') - shutil.copyfile(model, output / manifest['filename']) - print(json.dumps(catalog, indent=2)) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('model', type=Path) - parser.add_argument('source_tar', type=Path) - parser.add_argument('output', type=Path) - args = parser.parse_args() - build(args.model, args.source_tar, args.output) From 69b9286c118bd55daf862a041d3d997081b6dab4 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:26:01 +0900 Subject: [PATCH 34/50] Reapply "Merge carrot-wip 8e7eec049 into joongyu01/carrot-wip-dev" Back to the latest carrot-wip at the user's request. This reverts f6b8945f7. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mdm-model.yaml | 6 +- .github/workflows/tests.yaml | 2 + AGENTS.md | 78 ++++++++ docs/egpu_persistent_rejection_20261015.md | 73 +++++++- docs/hud_retry_memory_20261015.md | 163 +++++++++++++++++ docs/md_v3_20261015.md | 68 +++++++ docs/s3_release_20261015.md | 67 +++++++ docs/tesla_pr551_review_20261015.md | 78 ++++++++ docs/user/en/settings.md | 5 +- docs/user/ko/settings.md | 5 +- .../opendbc/car/tesla/carcontroller.py | 9 +- .../opendbc/car/tesla/coop_steering.py | 20 ++- .../tesla/tests/test_controller_feedback.py | 103 +++++++++++ .../car/tesla/tests/test_coop_steering.py | 90 ++++++++++ .../opendbc/safety/safety/safety_tesla.h | 7 +- .../opendbc/safety/tests/test_tesla.py | 58 ++++++ .../carrot/cluster/cluster_profile.py | 14 +- .../carrot/cluster/cluster_wifi_display.py | 9 +- openpilot/selfdrive/carrot/cluster/main.py | 7 +- openpilot/selfdrive/carrot/cluster_autorun.py | 59 ++++-- .../carrot/tests/test_cluster_gc_lifecycle.py | 56 ++++++ .../tests/test_cluster_retry_logging.py | 66 +++++++ .../carrot/tests/test_cluster_wifi.py | 59 ++++++ .../carrot/web/generated/asset-manifest.json | 2 +- .../carrot/web/js/generated/tools.js | 2 +- .../web/src/features/tools/egpu_model.js | 1 + .../controls/lib/latcontrol_angle.py | 2 +- .../controls/tests/test_angle_saturation.py | 22 +++ openpilot/selfdrive/modeld/big_model.py | 14 +- openpilot/selfdrive/modeld/egpu_events.py | 18 ++ openpilot/selfdrive/modeld/egpu_recovery.py | 32 +++- openpilot/selfdrive/modeld/egpu_usb_wait.py | 4 +- openpilot/selfdrive/modeld/modeld.py | 4 + .../selfdrive/modeld/precompiled_model.py | 26 ++- .../modeld/tests/fixtures/md_v3_metadata.json | 170 ++++++++++++++++++ .../selfdrive/modeld/tests/test_big_model.py | 14 +- .../modeld/tests/test_egpu_events.py | 20 +++ .../tests/test_egpu_failure_containment.py | 8 +- .../modeld/tests/test_egpu_recovery.py | 40 ++++- .../modeld/tests/test_precompiled_artifact.py | 3 +- .../modeld/tests/test_precompiled_model.py | 38 ++-- .../tests/test_precompiled_rejection_retry.py | 6 +- .../tests/test_panda_firmware_version.py | 5 +- tools/carrot_route_vault/tests/test_viewer.py | 42 +++++ tools/carrot_route_vault/viewer.py | 40 +++++ tools/esp32s3_display/INSTALL_KO.md | 144 +++++++++++++++ tools/esp32s3_display/README.md | 27 ++- tools/esp32s3_display/firmware/CMakeLists.txt | 1 + .../firmware/main/usb_bridge.c | 62 +++++-- tools/esp32s3_display/installer/index.html | 26 +++ tools/esp32s3_display/installer/installer.js | 71 ++++++++ tools/esp32s3_display/installer/manifest.mjs | 21 +++ .../installer/tests/install_flow.test.mjs | 55 ++++++ .../installer/tests/manifest.test.mjs | 21 +++ tools/esp32s3_display/package_release.py | 88 +++++++++ tools/esp32s3_display/provision.py | 15 +- tools/modeld/build_md_v3_package.py | 63 +++++++ 57 files changed, 2099 insertions(+), 110 deletions(-) create mode 100644 docs/hud_retry_memory_20261015.md create mode 100644 docs/md_v3_20261015.md create mode 100644 docs/s3_release_20261015.md create mode 100644 docs/tesla_pr551_review_20261015.md create mode 100644 opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py create mode 100644 openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py create mode 100644 openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py create mode 100644 openpilot/selfdrive/controls/tests/test_angle_saturation.py create mode 100644 openpilot/selfdrive/modeld/egpu_events.py create mode 100644 openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json create mode 100644 openpilot/selfdrive/modeld/tests/test_egpu_events.py create mode 100644 tools/esp32s3_display/INSTALL_KO.md create mode 100644 tools/esp32s3_display/installer/index.html create mode 100644 tools/esp32s3_display/installer/installer.js create mode 100644 tools/esp32s3_display/installer/manifest.mjs create mode 100644 tools/esp32s3_display/installer/tests/install_flow.test.mjs create mode 100644 tools/esp32s3_display/installer/tests/manifest.test.mjs create mode 100644 tools/esp32s3_display/package_release.py create mode 100644 tools/modeld/build_md_v3_package.py diff --git a/.github/workflows/mdm-model.yaml b/.github/workflows/mdm-model.yaml index 610f90cff70..f75a66cd850 100644 --- a/.github/workflows/mdm-model.yaml +++ b/.github/workflows/mdm-model.yaml @@ -1,7 +1,7 @@ name: eGPU model compatibility on: push: - branches: [carrot-wip, carrot-signal-shadow] + branches: [carrot-wip] workflow_dispatch: permissions: contents: read @@ -33,8 +33,8 @@ jobs: openpilot/system/tests/test_model_build_cache.py - name: Download the pinned isolated runtime run: | - curl --fail --retry 3 -o /tmp/mdm-runtime.tar.gz https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/precompiled-runtime.tar.gz - echo '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2 /tmp/mdm-runtime.tar.gz' | sha256sum -c - + curl --fail --retry 3 -o /tmp/mdm-runtime.tar.gz https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/precompiled-runtime.tar.gz + echo 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac /tmp/mdm-runtime.tar.gz' | sha256sum -c - mkdir /tmp/mdm-runtime tar -xzf /tmp/mdm-runtime.tar.gz -C /tmp/mdm-runtime - name: Execute pinned native CPU warp and persistent-buffer roundtrip diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b30e2ae5b9a..d257afe86af 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -40,6 +40,8 @@ jobs: openpilot/selfdrive/modeld/tests/test_precompiled_model.py \ openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py \ openpilot/selfdrive/modeld/tests/test_egpu_worker_progress.py \ + openpilot/selfdrive/modeld/tests/test_egpu_events.py \ + openpilot/selfdrive/modeld/tests/test_egpu_recovery.py \ openpilot/selfdrive/modeld/tests/test_helpers.py::test_usbgpu_compiled_path_waits_for_selected_model_instead_of_previous \ openpilot/system/tests/test_model_build_cache.py \ openpilot/system/tests/test_launch_order.py diff --git a/AGENTS.md b/AGENTS.md index fc4b15d8bff..dcae7a1c650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,83 @@ # Repository memory +- On 2026-10-15, user requested review/integration of Tesla PR551 c61d998ff8. + Reproduced inactive phase-lead CAN/feedback and saturation defects; Tesla's + encoded zero375 also regranted controls through the shared accel helper. + Port the nine PR files unchanged: measured inactive angle, final feedback, + cooperative torque blending only above25km/h with normal low-speed steering, + output-limit-aware saturation and Tesla-specific longitudinal permission. + Both maintained branches:199 focused tests/128 subtests pass. Three inherited + generic disabled-angle safety cases still fail identically before/after; + do not claim full safety suite success. Native C uses a desktop packet/forward + ABI bridge, not MCU/vehicle validation. Publish single-parent on both branches; + no vehicle installation. See docs/tesla_pr551_review_20261015.md. + +- On 2026-10-15, user requested historical attribution of memory >80%. Read925 + retained local logs (916 own-device with memory) plus on-device qlogs. Earliest + observed >80% is Oct10/108e, but Oct11/1095 stays73--77% with HUD+signals; + not one proven regression date. Current DM warmup/plateau and logger sawtooth + match earlier runs. GC retry fix reduces Python retention, but native HUD ION + buffers persist after process death (89.6MB), and new PID accumulates again. + Do not call memory normal/fixed or recommend process cycling as its fix. + Expected S3 transport log cleanup published both branches; fresh-manager + reboot verifies concise timeout lines (HUD-only restart kept cached code). + Reboot clears old native buffers but four retries retain6.6MB again; current + 81%/685MiB available is not leak clearance. Preboot model/DM1200 frames/60s, + postboot1199 valid consecutive each at19.974Hz, no missing IDs/fallback; + do not claim a strict >=20Hz postboot pass. S3 still times out. See + docs/hud_retry_memory_20261015.md; raw evidence private. + +- On 2026-10-15, user's C4 Git Pull was blocked by two untracked manually + installed eGPU helpers identical to incoming blobs; back up only those files, + preserve unrelated build/model artifacts. Old93160a93 HUD repeatedly lost S3 + before renderer ready. Prior qlogs reach99%; same HUD PID grows168.62->528.55 + MiB RSS, +181.89MiB PSS across segments5->27. Actual failed JPEG pipelines + remain frozen after close/collect; unfreeze releases them. Pair HUD session + freeze with finally unfreeze/threshold restore, retaining active-frame policy. + 27 focused tests and native isolated12-cycle reclamation pass. This fixes a + demonstrated retention path, not every system/native allocation or reboot + cause. User authorizes parked system stop/update; verify fresh P/zero/inactive. + Publish both maintained branches, single-parent. See docs/hud_retry_memory_20261015.md. + +- On 2026-10-15, user requested public S3 firmware/install guide after confirming + the panel wakes and turns off. S3 1.1.0 confirms sync/brightness-off, retries + failed shutdown and reasserts idle-off; C4 offroad dimming includes registered + S3. N16R8 / powered hub / TURZX 1cbe:0092 only. Browser installer and NAS ZIP + preserve NVS; never distribute device flash backups or pairing keys. UART + --auto requires a unique bridge and verifies identity. Native three-phase + 150-frame final test passes with preserved pairing; browser-native flashing + is distinct from UART validation. Publish both maintained branches, single-parent. + See docs/s3_release_20261015.md and tools/esp32s3_display/INSTALL_KO.md. + +- On 2026-10-15, user requested MD v3 PR39087 on carrot-wip ONLY unless already + installed. Prior selection was8d736683; new checkpoint1365126a/12864, AMDd4c7a3fc, + pinned tinygrad02e69816/runtimec7c58622. PR changes only the big PKL; no matching + ONNX, internal/DM and Jetson remain unchanged. Preserve retry/fallback policies; + source-verified new runtime retains USB no-op sleep and +/-1 link-error meanings, + so extend only the exact hash allowlist.411 focused tests/13 Web tests pass, + 8 Windows skips; Linux CI403 plus3 native-runtime tests and HTTPS empty-cache + install/offline reuse pass on8864280c9c. + No vehicle install or native20Hz/driving claim. See docs/md_v3_20261015.md. + +- On 2026-10-15, user requested explicit tmux error/retry visibility after + repeated eGPU investigations. Add bounded stderr [eGPU] lifecycle events with + PID/monotonic time, saved failure cause/classification, attempt/limit/backoff, + waiting reason, probe-ready/handover, actual first live publication and stop + reason. Preserve all retry/validation/model policies; never write diagnostic + text to the worker stdout protocol.179 focused tests pass; no vehicle action. + See docs/egpu_persistent_rejection_20261015.md. + +- On 2026-10-15, identified the persistent Ioniq 5 PE block from actual NAS + diagnostics: pinned90970e01 reports AMD failed with +1 during input upload. + Its usb_reap latches LIBUSB_TRANSFER_ERROR=1; old classifier recognized only-1. + Both branches now classify exactly +/-1 only for that verified runtime, with + previous-boot/hash/artifact/smoke validation for old rejections.175 tests and + the exact recorded traceback probe pass. The failing734634 predates Oct15 + timing changes. Device wall clock was stale; do not assert recorded21:05 as + incident time. Physical USB root cause remains unknown; no vehicle install. + This supersedes the earlier unknown reporter cause below; numerical migration + is a separate correction. See docs/egpu_persistent_rejection_20261015.md. + - On 2026-10-15, a Mountain Dew report remained rejected after reboot. Current wip lacked nav's previous-boot numerical-rejection migration; four positive replay cases fail before the port and pass afterward. Port only that migration, diff --git a/docs/egpu_persistent_rejection_20261015.md b/docs/egpu_persistent_rejection_20261015.md index 6630b25a4a7..79ee000d819 100644 --- a/docs/egpu_persistent_rejection_20261015.md +++ b/docs/egpu_persistent_rejection_20261015.md @@ -2,10 +2,32 @@ ## Findings -The supplied Mountain Dew card explicitly reports `rejected`, not a fresh USB -or timeout diagnosis. The reporter says it worked yesterday and restarting or -rebooting did not help. Their device identity, exact commit and saved failure -have not been supplied, so the first failure cause remains unknown. +The reported Ioniq 5 PE's private NAS evidence now establishes the cause of the +persistent block. Ten diagnostics were copied and SHA256-verified. October 14 +22:09/22:12 captures show eGPU active. October 15 09:42 captures show an inference +failure from code 734634c983; subsequent 09:48/09:51/10:05 captures on 5a33dc40ed +retain the identical rejected failure, with no successful new load. + +The terminal error is `RuntimeError: AMD failed with 1` during input upload in +`GenericModelRuntime.run`. The selected runtime directory is 90970e01f2ba70d5. +Inspection of the SHA256-verified runtime archive +90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2 +shows `tinygrad/runtime/support/usb.py:457` maps AMD.error_state to the USB link +error word; `usb_reap` at lines 312-317 latches the asynchronous transfer status. +The same archive's `autogen/libusb.py:204` defines status 1 as +LIBUSB_TRANSFER_ERROR. The existing classifier accepted only -1, so this verified +USB error was incorrectly treated as an artifact failure and persisted forever. +The exact recorded traceback fails the old classifier and passes the correction. + +The initial captured device wall clock was stale: the October 15 09:42 log has +an embedded October 14 21:05 clock. Do not use that stored time as the actual +incident time. Within that snapshot, the error precedes the later logged USB +unplug; the unplug is not proof of the initial cause. Cable, power, bridge and +host causes are not distinguished by this evidence. The failing code predates +the October 15 response-deadline/wait-yield changes. + +The numerical-rejection issue below was found independently during the initial +code review; it is not the cause of this reporter's USB +1 block. Comparison of current remote branches found a concrete recovery gap: `carrot-wip` 5a33dc40ed lacks `retry_output_rejection`, which carrot-nav already @@ -20,12 +42,17 @@ only writes a waiting status and requests a reboot; it neither repairs the failure nor clears the marker. This can repeatedly show the same card. The October 15 response-deadline change classifies its TimeoutError as transient; -that path does not itself write a persistent rejection. This does not rule out -other recent changes or identify this user's original failure. The prior web +that path does not itself write a persistent rejection. This does not establish which physical or runtime condition caused USB status 1. The prior web map-preview change does not modify the eGPU loader or failure policy. ## Changes and limits +- Both branches now recognize exactly -1 and +1 for the verified runtime hash. + Other runtimes/codes and quoted/nonterminal errors remain rejected. The existing + new-boot migration revalidates old +1 rejections. New +1 faults stop the failed + worker without permanently rejecting the artifact; existing bounded recovery, + internal-model fallback and handover checks remain in force. No runtime binary, + inference deadline, GPU model or USB error suppression is changed. - Port only nav's numerical-rejection migration into wip. Keep the separate branch models, runtimes and per-frame output policies; do not port nav's single-frame rollback implementation with this repair. @@ -56,3 +83,37 @@ road validation. No user's vehicle was restarted, installed or unblocked. After updating wip, a recognized earlier-boot numerical rejection can be retried on boot. A remaining block needs the downloaded diagnostic report; repeatedly rebooting or deleting every rejected marker is not a diagnosis or repair. + +## Reporter-specific follow-up verification + +175 focused tests pass for USB rejection classification, file revalidation and +bounded background recovery. The exact privately saved traceback also passes a +matching-runtime/later-boot migration probe, rejects a same-boot attempt and +preserves the original failure record. This proves recovery eligibility, not +physical USB stability or successful driving. No NAS raw logs are committed. + +## Tmux recovery lifecycle visibility + +The user requested explicit error and retry output so future analysis does not +need to infer the recovery path from a generic card. All added events go to +stderr with `[eGPU]`, process ID and monotonic time; they never enter the worker +stdout control protocol. Failures include phase, model/runtime identity, +terminal error, saved diagnostic path and persistent/transient classification. +The full earlier traceback remains in the existing diagnostic report. + +Recovery prints fallback, scheduled delay and attempt/limit, actual attempt +start, failure/cancellation, probe READY, handover, and terminal stop reason. +Waiting explains absent USB, internal health or backoff. Unchanged waits are +not repeated; changed waiting reasons are limited to one per five seconds. +The modeld first-output publication path separately prints live_output_resumed: +a successful synthetic probe or handover is not claimed as a live result. +Boot-time artifact rejection/migration also prints the retained cause and next +step. Startup without a recovery manager explicitly reports no retry. + +No retry limits, validation gates, inference timeouts, model selection or USB +runtime are changed. A closed diagnostic stream is swallowed, not propagated +into inference.179 focused tests pass on nav before publication; final wip uses +the same tests. Tests check three-attempt exhaustion, delayed handover, absent +USB log volume, old-block cause output and failure of the logging stream. +Syntax checks pass. Targeted lint passes; modeld's existing ISC002/E501/F841 +findings are outside these changes. No physical vehicle/reboot test or install. diff --git a/docs/hud_retry_memory_20261015.md b/docs/hud_retry_memory_20261015.md new file mode 100644 index 00000000000..a884f2ea5c4 --- /dev/null +++ b/docs/hud_retry_memory_20261015.md @@ -0,0 +1,163 @@ +# HUD retry memory retention, 2026-10-15 + +The user's C4 was still on `93160a93`, 13 commits behind the published NAV +branch. Carrot Web's actual failed Git Pull jobs named two manually installed, +untracked eGPU helpers. Both matched their incoming Git blobs exactly. They +were backed up under `/data/local/tmp/git-pull-repair-20261015` before repair; +unrelated untracked build/model files must not be cleaned. + +The failed pull also reset tracked trial changes. Its running HUD logged +`S3 display is closed` followed by `asynchronous USB JPEG pipeline failed`. +The published renderer-ready Wi-Fi connection fix was absent from this checkout. + +## Memory evidence + +Private previous-session qlogs show memory usage reaching 99%. Between segment +5 and 27, the same HUD PID 208998 grew from 168.62 to 528.55 MiB RSS, with a +181.89 MiB PSS increase. Loggerd RSS rose 25.55 MiB but PSS only 3.19 MiB over +those selected endpoints. RSS contains shared pages and cannot be summed to +explain all system consumption. Available RAM was as low as 65 MiB at a sampled +segment-20 endpoint. These data establish HUD growth as a major contributor, +not a complete attribution of all memory or a proven cause of reboot. + +After reconnection, a new boot initially reported 84% with approximately 533 MiB +available. P, zero speed and disabled controls were verified. Pstore later +confirmed a previous-boot kernel BUG in `dump_throttled_rt_tasks`, with the +interrupted stack in `shrink_slab`/`try_to_free_pages`/`filemap_fault`. The task +named by RT throttling was radarcan. No OOM-killer entry was found; this does not +prove HUD caused the panic or establish a radarcan allocation leak. +The existing radar arena fix was already in the old vehicle commit. + +## Reproduced retention and correction + +`freeze_gc_after_init()` permanently excluded the HUD session's cyclic objects +from collection. Autorun reuses the process after failure. A JPEG worker retains +its failure traceback, which references its frozen pipeline and frame data. +Ordinary `gc.collect()` cannot reclaim this frozen cycle. + +Using the actual AsyncJpegUsbPipeline with a disconnected transport, four old +sessions remained alive after close plus collection. Unfreeze plus collection +released all four. The fix pairs each successful freeze with an unfreeze and +restores prior GC thresholds at the very start of `run_demo`'s finally block, +including errors and normal exit. Active-frame freeze/threshold behavior stays +unchanged; no new collection is inserted in the rendering loop. Cleanup failures +cannot skip the unfreeze. The next initialization collection can reclaim the +previous session after its stack unwinds. + +Validation: 27 focused desktop Wi-Fi/lifecycle tests pass. The new test repeats +12 actual JPEG-worker failure sessions and asserts all weak references are +released and thresholds restored. The same 12-cycle test passes in an isolated +C4 Python process using the candidate helper, without changing driving services. +Changed main source adds no lint findings; existing unrelated findings remain. +This is a reproduced Python retention fix, not proof that every native graphics +allocation is bounded. + +## Installed verification and remaining problems + +Actual Carrot Web Git Pull job `849abc7c4050` completed successfully after a +fresh parked/inactive guard and a bounded stop of manager-owned processes. +Vehicle advanced from `93160a93` to NAV `0cf297c3`, then rebooted. Web status +reported zero ahead/behind and a clean tracked tree. Model and DM each published +1,200 consecutive valid frames in 60 seconds, approximately 20 Hz, with eGPU +active. A subsequent three-minute sample measured 82--86% system usage. HUD PSS +repeatedly returned to approximately 110--126 MiB after failures rather than +retaining every previous Python session. This short test is not a long-term +memory clearance or proof that the system is back to its earlier baseline. + +S3 still authenticates but times out awaiting frame responses. Known S3 +transport errors now report one bounded line with the underlying exception and +the existing five-second retry. Unexpected encoder/programming failures keep +their traceback. Authentication, transport timeouts, queue bounds and retry +intervals are unchanged. Eight diagnostic regression cases plus the existing +27 Wi-Fi/lifecycle tests pass. + +Further native inspection found a separate remaining allocation path: the +HUD PID's ION orphan-labelled buffers rose from 216 objects / 59,719,680 bytes +to 222 / 61,378,560 bytes over a retry interval. The label alone does not prove +an unreachable allocation; repeated growth and actual release must be checked. +This is outside ordinary Python RSS/PSS accounting. Current system usage still +reaches 87%; historical comparison and native-buffer cleanup remain under +investigation. Do not report the overall memory problem or S3 timeout as solved. + +Raw qlogs, process snapshots, tmux excerpts and diagnostic scripts remain private +in `.analysis/scratch/2026-10-15-egpu-s3/` and are not included in firmware releases. + +## Historical comparison requested by the user + +Read 925 distinct locally retained compressed logs; 916 contain memory samples +from the user's device identity (908 also identify Ioniq 5 PE, eight omit +carParams). Combine them with surviving on-device qlogs and deduplicate matching +route/segment observations. This is retained evidence, not every historical +drive. Many old on-device files are truncated; keep their parse errors and do +not infer an entire minute from a fragment. Dates below come from initData and +the corresponding archived collection, not from an inferred panic timestamp. + +The earliest **observed** >80% sample is October 10 route 108e segment 4 on +`cb45d99b61`. It is not the date of a proven code regression: the same revision's +earlier 108c run stayed around 74--76%, and a later October 11 run remained +73--77% with both HUD and signal processes running. + +Representative complete-session observations, excluding segments 0--4: + +| Date / route suffix | Observed segments | Memory range | Median of segment medians | +| --- | --- | --- | --- | +| Oct 10 / 108e | 5--25 | 79--81% | 80% | +| Oct 11 / 1095 | 5--70 | 73--77% | 75% | +| Oct 12 / 109f | 5--10 | 81--83% | 82% | +| Oct 14 / 10bc | 5--72 | 73--83% | 78% | +| Oct 15 / 10ce | 22--115 available | 82--87% | 86% | +| Oct 15 / 10d3, before fix | 5--27 | 88--99% | 95% | +| Oct 15 / 10d4, before fix | 5--20 | 83--94% | 87% | +| Oct 15 / 10d5, GC fix | 5--22 captured | 83--89% | 86% | + +September 25--27 retained samples were generally in the 60s, but HUD PSS was +only approximately 11 MiB (waiting, not rendering), with different model/backend +configuration. They are not a like-for-like target for the current active HUD. +October 11's 1095 is a stronger demonstration that active HUD and signal support +can coexist below 80%; it does not isolate which later configuration accounts +for every additional byte. + +In long earlier sessions, DM PSS rises during the first approximately 20 minutes +then flattens near 156 MiB. Loggerd repeatedly moves between approximately +86--113 MiB rather than accumulating without return. Current values near +151 MiB DM and 87--113 MiB loggerd fit those historical patterns; a pair of +short endpoints alone should not have been interpreted as proof of a leak. +UI remains near 230 MiB, and the fixed radar is near 9 MiB. The previous +runaway HUD is the clearly demonstrated large same-process change today. + +An additional live native check is not normal: HUD PID 53363 accumulated +324 ION orphan-labelled objects / 89,579,520 bytes. They remained after that +process was killed and a new manager-owned HUD PID appeared. The new PID then +accumulated 60 objects / 16,588,800 bytes. Thus process recycling alone is **not** +a demonstrated fix for these buffers. The installed raylib wheel reports +`comma-deps-raylib 6.0.0.1.post98`; do not assume the older checked-in build.sh +source pin describes that binary. Native buffer ownership/cleanup is unresolved. + +After the logging update's actual Web Git Pull, the existing manager continued +forking the already imported autorun code. A HUD-only restart therefore did not +activate its new exception reporter, despite the correct on-disk HEAD. Do not +claim the new log output verified until the manager is freshly loaded. + +Another live 60-second check during this investigation has 1,200 consecutive +valid model and 1,200 DM frames, 20.004/20.000 Hz respectively, unchanged model/DM +PIDs and eGPU active. S3 is disconnected in that final snapshot. Model timing +is currently healthy; retained native buffers and repeated S3 failure mean the +overall state is **not** certified normal or restored to its former memory level. + +### Final activation check + +After preserving the preceding evidence, Web job `4732670e92a9` installed +`cfbc50fb9a`. Another 554 fresh parked/inactive checks preceded an authorized +reboot. The fresh manager now emits the actual expected-failure line +`S3 Wi-Fi disconnected (TimeoutError: timed out); retrying in 5s`, without the +three chained tracebacks. Both maintained branches contain the reporter fix; +its 35 focused checks passed on each branch. + +At uptime 253 seconds, memory was 81%, MemAvailable 701,008 KiB (684.6 MiB). +The old PID's retained allocations disappeared across reboot, but the new HUD +PID already had 24 orphan-labelled objects / 6,635,520 bytes after four failed +sessions. This confirms recurrence; reboot is recovery, not a native-leak fix. +The postboot 60-second model/DM observation had 1,199 consecutive valid frames +each, 19.974 Hz, with no missing frame IDs or eGPU fallback. Report that measured +rate exactly rather than claiming a strictly >=20 Hz postboot pass. S3 still +times out, so healthy end-to-end display operation is not established. diff --git a/docs/md_v3_20261015.md b/docs/md_v3_20261015.md new file mode 100644 index 00000000000..8def5fcd300 --- /dev/null +++ b/docs/md_v3_20261015.md @@ -0,0 +1,68 @@ +# Mountain Dew v3 on carrot-wip + +The user requested PR [39087](https://github.com/commaai/openpilot/pull/39087) +on carrot-wip only, unless already selected. The previous selection was retrained +Mountain Dew 8d736683 (AMD 256f2178), so this is a new model. carrot-nav is excluded. + +## Official artifact and runtime + +The merged PR has one changed file: big_driving_tinygrad.pkl. Its head is +e86b58dc7b9fb499dd84ac6dbe514a03fa2bc428 and merge is +1e219d7785cfb7b7343c7f2366347fc86e7ce5f2. + +- Checkpoint: f22e06e5-3b3f-48d2-a6b9-2f32dc20e707/56320/1365126a-e71e-4778-be00-8ab92dc82c09/12864. +- PKL: 799,941,965 bytes, SHA256 d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422. +- PR tinygrad submodule: 02e698160eed1751738212144a7ea2cfb160b3f1. +- Source archive SHA256: 2824b933fbf48df672cbb18c8462bd7f9d637a065f4c510ae3d07dedc01ffcb9. +- Deterministic isolated runtime: 2,740,280 bytes, SHA256 c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac. +- [Download manifest](https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/manifest.json). + +The complete official Hugging Face LFS download matches the committed pointer. +Metadata was inspected with inert pickle globals. Input/output specifications, +shapes, output slices and recurrent-state mappings match the previous model; +only the checkpoint metadata differs. A new fixture exercises parser compatibility. +The package builder preserves the historical model builder and reproduces the new +package from hash-checked source files. NAS publication verifies temporary copies +and publishes the manifest last, in a new directory. + +## Compatibility and recovery + +The PR does not itself modify tinygrad, but its baseline pins a newer runtime than +our previous d3f09c9 package. Keep that upstream-matched runtime isolated from the +internal driving model and DM. Existing byte-addressed input adapters and persistent +buffer loading remain applicable. + +Source inspection of 02e69816 confirms USBIface.sleep is still a no-op, +AMDDevice.sleep_timeout_ms remains 200, usb_reap still polls with zero timeout and +copies the asynchronous status through usb_fail, and usb_setup binds AMD.error_state +to the USB link error word. Therefore add only this exact runtime hash to the existing +1 ms spin / 200 us sleep adaptation and exact +/-1 transport-error classification. +Unknown runtimes and other codes remain blocked. Retry limits, reboot/identity and +artifact/smoke checks, response deadlines, worker disposal, fallback, validity and +control policies are unchanged. Existing diagnostic events are retained. One existing +fallback-handler test needed its log_event stub updated for the earlier diagnostic change. + +## Scope and verification + +The small internal model and driver-monitoring models are unchanged. The official +[ONNX catalog](https://huggingface.co/commaai/openpilot_driving_models/tree/main) +on October 15 has 1a421175, 870a4823, 8d736683 and f78ed37d, but no 1365126a export. +Thus this update selects only the AMD eGPU big model; Jetson remains unchanged. +Web identifies this model as Mountain Dew v3. + +- 411 focused Windows tests pass; 8 platform-dependent skips. +- 13 Web model/status tests pass; generated bundle and hash rebuilt. +- Two native pinned-runtime packed-buffer/state-alias tests pass on Windows. + The native warp test requires clang, unavailable on this workstation. +- [Linux CI 38689958099](https://github.com/ajouatom/openpilot/actions/runs/38689958099) + passes on implementation commit 8864280c9c: 403 contract/delivery/warp-policy tests, + all 3 exact-runtime native buffer/warp/persistent-roundtrip tests, and Web checks. +- Full NAS HTTPS empty-cache installation passes, including complete model/runtime + hashes and offline cache reuse. Download never creates a GPU validation receipt. + +No vehicle was installed or restarted. These checks do not establish physical AMD +inference, 20 Hz performance, driving quality, or USB root-cause resolution for v3. +Private download and install evidence is under .analysis/scratch/2026-10-15-md-v3/. + +Docs-Not-Needed: Selected model artifact and runtime compatibility update; +no setting or user-guide workflow change. diff --git a/docs/s3_release_20261015.md b/docs/s3_release_20261015.md new file mode 100644 index 00000000000..a29974df410 --- /dev/null +++ b/docs/s3_release_20261015.md @@ -0,0 +1,67 @@ +# S3 1.1.0 release and shutdown correction — 2026-10-15 + +The user's TURZX panel stayed on its manufacturer splash while C4 was offroad. +UART still answered: this was not evidence of a completely dead S3. Resetting the +S3 and receiving successful sync (10) and brightness-zero (14) replies restored +shutdown; the user confirmed the screen went off. The original missed command's +physical cause is not established. + +The firmware marked itself dark even when local shutdown failed, suppressing +further attempts. It now synchronizes, waits 200 ms, checks the brightness reply, +retries failed shutdowns at 1/2/4/8/16 s, and reasserts idle-off every 30 s. Old +session commands cannot change current power state. Setup remains visible; a new +image wakes the display. This is backlight shutdown, not power removal. + +C4 close synchronizes and checks shutdown; offroad periodic dimming now includes +the registered S3 even when no local USB display exists. Direct USB behavior is +preserved. The firmware watchdog also works when C4/network disappears. + +## Distribution + +- [NAS installer/guide](https://upload.shind0.synology.me/models/carrot-s3/index.html) +- [Firmware ZIP](https://upload.shind0.synology.me/models/carrot-s3/carrot-s3-1.1.0.zip) +- [Korean installation guide](../tools/esp32s3_display/INSTALL_KO.md) +- Supported hardware: ESP32-S3 N16R8 with 8 MB octal PSRAM, powered compatible hub, + TURZX 9.2-inch 1cbe:0092, 1920×462. No generic USB-C/HDMI monitor support. +- Installer uses pinned Espressif esptool-js 0.5.6 with licenses; downloads are + SHA-256 checked before writing and flash contents are MD5 checked afterward. +- Existing-install mode verifies partitions and writes only 0x10000 application. + Initial-install mode writes bootloader/partition/app separately; neither erases + NVS. Before/after NVS digest checks protect registration. There is no whole-flash + image, private pairing, Wi-Fi password or device NVS in the distribution. +- `--auto` selects one supported USB UART without probing other devices, then + verifies S3 identity. Cable insertion alone is not automatic registration. +- Public NAS route uses an explicit file allowlist and rejects symlink components; + model and recording access policies are unchanged. + +## Validation and limits + +ESP-IDF 5.4.0 builds the final 874048-byte app. It was UART-flashed to the user's +N16R8; saved device identity and pairing key matched before/after. Each of normal +close, abrupt TCP loss and reconnect sent 50 acknowledged images over 5 s, with +zero USB failures. A longer visual repeat sent 150 images per phase over 15 s; +the user confirmed “켜졌다가 꺼짐.” The final stale-session guard was then rebuilt, +flashed and passed another three-phase 150-image check. + +This validates the bench hardware and displayed wake/off behavior, not every USB +hub or vehicle shutdown scenario. Artificial USB ACK failure/backoff and panel-only +power-cycle fault injection are not physical test claims. The C4 host changes have +focused regression coverage; they do not alter models, driving control or eGPU. +Web Serial page rendering and cancellation are checked in Chrome; actual flash +verification above uses esptool UART. No claim of a completed browser-native flash +is implied by the page check. See release metadata for binary/source hashes. + +Focused checks: 26 Wi-Fi/provision/close/offroad tests; 38 autorun/performance tests +with one Linux-only skip; 12 NAS viewer tests with eight Windows symlink-permission +skips; five JS installer/manifest tests. The latter reject corrupted downloads, +wrong chip/partition, NVS mismatch and writes outside allowed firmware sectors. + +C4 shell validation found UART dependencies under the existing `pydeps` directory, +not the bare system/venv search path. Provisioning adds that bundled directory +when present; the guide uses `python3`, also available in a plain SSH shell. +The three matching-source S3 host files were backed up and installed on C4 after +56 fresh offroad/ignition-OFF/control-inactive checks. Only `carrot_cluster` was +restarted; model/DM were already stopped offroad. The installed native offroad +function discovered the registered S3 and successfully sent brightness zero. +Provisioning imports also pass with the ordinary `/usr/bin/python3` and no +inherited `PYTHONPATH`/`VIRTUAL_ENV`. diff --git a/docs/tesla_pr551_review_20261015.md b/docs/tesla_pr551_review_20261015.md new file mode 100644 index 00000000000..629fe03c055 --- /dev/null +++ b/docs/tesla_pr551_review_20261015.md @@ -0,0 +1,78 @@ +# Tesla PR #551 review and integration + +Reviewed https://github.com/ajouatom/openpilot/pull/551 at +`c61d998ff8fdda9cbb8a1a6d5b5d0b198085b9c4` against carrot-nav +`e9b33329e4` and carrot-wip `d329752ab0`. The affected production files +were identical on both maintained branches. The nine PR files are integrated +without additional production changes, preserving the contributor's code and +tests. Publication uses one-parent commits on both branches. + +## Findings + +- Tesla zero acceleration is encoded as 375. The existing shared + `longitudinal_accel_checks` sets `controls_allowed` when its raw input is + nonzero, including this inactive request. The Tesla-specific replacement + checks both bounds and requires longitudinal permission for nonzero physical + acceleration without mutating permission. Other brands' policies are unchanged. + Stock AEB, cancel-only operation and the existing reverse check remain in place. +- Inactive cooperative steering previously returned the phase-lead estimate, + which can differ from the EPS measurement and exceed the angle envelope. + It now sends the measurement clamped to 360 degrees while preserving the + separate phase-lead resume state. +- Controller actuator feedback now follows the final cooperative steering + request and holds that value between 50 Hz steering transmissions. It remains + a requested angle, not measured actuator motion; CAN quantization is 0.1 degree. +- Cooperative torque blending runs only above 25 km/h. At and below that speed, + ordinary lateral control remains active, the final request receives normal + limits referenced to the previous final output, and cooperative state resets. + The boundary uses CarState Float32 precision. Crossing it does not disable + normal lateral control. +- The common angle controller now passes the actual output-limit flag to the + saturation timer, matching the timer's existing contract. This affects all + angle-controlled cars; it suppresses saturation accumulation while the output + is being limited, without changing angle commands or curvature limiting. +- The firmware source-version regression includes the Tesla safety header, so + this safety change participates in the existing firmware rebuild mechanism. + +## Local verification + +Windows Python 3.12, actual Cap'n Proto schemas, DBC encoder/parser, vehicle +model and production controller code were used. The desktop runner substitutes +only default-false Params reads and the 0.01 s control-period import; it does not +replace the control algorithms. No real Params or vehicle is accessed. + +1. Before the production patch, the 54 focused cooperative/feedback/saturation + cases gave **29 failures and 25 passes**. This reproduces the reported + inactive-angle, feedback and saturation defects as well as the new speed + boundary behavior. +2. After the patch, the complete Tesla car test directory, angle saturation + tests and firmware-version tests gave **102 passes and 23 passing subtests**. +3. Baseline and patched production C safety headers were separately compiled + with Zig 0.13, optimization and warnings-as-errors. A temporary desktop bridge + allocates the real native CANPacket structure and adapts the old test + forwarding signature to the current packet API. The production safety + predicates are not replaced. This avoids the existing Python/native packet + ABI mismatch; the missing `parameterized` test dependency was also supplied. +4. Full patched Tesla safety execution gave **97 passes, 105 passing subtests, + 38 skips and 3 failures**. All three failures are inherited + `test_angle_cmd_when_disabled` cases (stock, longitudinal and auto-speed). + They also fail on the baseline, whose additional acceleration-limit tests + and 62 permission subcases fail. The full baseline reports 67 failures + including those subcases, 95 passes and 43 passing subtests. +5. Excluding only that pre-existing common test gives **97 passes, 105 passing + subtests, 37 skips and 4 deselections** (three concrete cases plus the abstract + base). This includes acceleration permission loss, both acceleration bounds, + stock AEB blocking/passthrough, reverse prevention, inactive measured angles, + speed-wheel policy and wrong-safety-mode checks. No new failure remains. + +The focused passing total is **199 tests and 128 subtests**; this is not a claim +that the unfiltered safety suite passes. Raw logs and temporary Windows bridge +are retained privately under `.analysis/scratch/2026-10-15-pr551/`. + +No vehicle installation, firmware flashing, physical EPS/MCU timing or road +validation was performed. The PR author's F4/H7 build result was not independently +repeated here; the native C check above is host execution, not an MCU build. + +Docs-Not-Needed: This is a controller/safety bug-fix port with a fixed cooperative +speed boundary; no user-visible setting is added, removed or redefined, and no +user-guide or Wiki edits were requested. diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index d09ae230e1e..edee00a156e 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -300,7 +300,10 @@ Display contains 34 settings. External-HUD settings control the layout and outpu `ClusterHud` (External HUD Display) switches a **direct USB HUD or a registered ESP32-S3 Wi-Fi HUD** on or off. A HUD connected to Jetson starts automatically even when this value is `0`, and normally turns its display off with ignition. Use the existing always-on `ClusterHudDebug` modes to keep it visible offroad. Brightness and layout settings also apply to the Jetson HUD. -S3 Wi-Fi output requires a TURZX 9.2-inch display, a powered hub, and C4/S3 on the same 2.4 GHz network. Follow the [initial registration instructions](../../../tools/esp32s3_display/README.md) to register once over USB or import the registration file from the phone setup page. A registered S3 reconnects after reboot or an IP change. This path defaults to JPEG quality 50 and a 15 FPS target regardless of `ClusterHudEncoder`, preserving the existing 5 FPS cap while the eGPU is active. Actual speed depends on image content and wireless conditions; C4 vehicle measurements remain separate. Remove the registration file and restart HUD to restore direct USB output. +S3 Wi-Fi output requires a TURZX 9.2-inch display, a powered hub, and C4/S3 on the same 2.4 GHz network. Follow the [initial registration instructions](../../../tools/esp32s3_display/README.md) to register once over USB or import the registration file from the phone setup page. A registered S3 reconnects after reboot or an IP change. This path defaults to JPEG quality 50 and a 15 FPS target regardless of `ClusterHudEncoder`, allowing up to 10 FPS while the eGPU is active (direct USB keeps its 5 FPS cap). Actual speed depends on image content and wireless conditions; C4 vehicle measurements remain separate. Remove the registration file and restart HUD to restore direct USB output. + +The [NAS installer and setup guide](https://upload.shind0.synology.me/models/carrot-s3/index.html) provides the N16R8 board checklist, wiring, browser firmware installation, phone/C4 registration and firmware ZIP. S3 1.1.0 confirms and retries shutdown; updating preserves existing registration. + The Jetson HUD shows the highest internal sensor temperature below `jetSON`, or in the upper-right corner in full-screen navigation/graph modes. If temperature data has not refreshed for three seconds, it shows `--°C`. A top-strip temperature warning starts 5°C below each sensor's configured thermal limit; reaching that limit shows an overheating warning. For example, a device configured to throttle at 99°C warns from 94°C. Check the fan and ventilation when warned. Vehicle driving alerts take precedence, and Jetson temperature remains separate from device temperature/memory statistics. This feature requires updated Jetson HUD software. diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index f72eb1562c7..1016c27257e 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -299,7 +299,10 @@ Bosch MRRevo14F 수신을 지원하는 버전에서 Bosch MRRevo14F 후보 차 `ClusterHud`(외부 HUD 활성화)는 **본체 USB에 직접 연결한 HUD 또는 등록된 ESP32-S3 Wi-Fi HUD**를 켜고 끄는 설정입니다. Jetson에 연결한 HUD는 이 값이 `0`이어도 자동 실행되며, 기본적으로 시동 OFF 시 화면을 끕니다. 시동 OFF에도 표시하려면 기존 `ClusterHudDebug`의 항상 켜기를 사용합니다. 밝기·화면 구성 설정은 Jetson HUD에도 적용됩니다. -S3 Wi-Fi 출력은 TURZX 9.2인치와 전원 공급 허브를 사용하며, C4와 S3가 같은 2.4GHz 네트워크에 연결되어야 합니다. [초기 등록 안내](../../../tools/esp32s3_display/README.md)에 따라 USB 자동 등록 또는 폰 설정 페이지의 등록 파일 가져오기를 한 번 진행하세요. 등록된 S3는 재부팅과 IP 변경 후 자동으로 다시 연결됩니다. 이 경로는 `ClusterHudEncoder` 선택과 무관하게 JPEG 품질 50·목표 15 FPS를 기본으로 사용하며, 기존 eGPU 활성 시 5 FPS 제한을 유지합니다. 실제 속도는 화면 내용과 무선 상태에 따라 달라지며, C4 차량 실측은 별도입니다. 등록 파일을 제거하고 HUD를 재시작하면 직접 USB 출력으로 돌아갑니다. +S3 Wi-Fi 출력은 TURZX 9.2인치와 전원 공급 허브를 사용하며, C4와 S3가 같은 2.4GHz 네트워크에 연결되어야 합니다. [초기 등록 안내](../../../tools/esp32s3_display/README.md)에 따라 USB 자동 등록 또는 폰 설정 페이지의 등록 파일 가져오기를 한 번 진행하세요. 등록된 S3는 재부팅과 IP 변경 후 자동으로 다시 연결됩니다. 이 경로는 `ClusterHudEncoder` 선택과 무관하게 JPEG 품질 50·목표 15 FPS를 기본으로 사용하며, eGPU 활성 시 최대 10 FPS를 허용합니다(직접 USB는 기존 5 FPS). 실제 속도는 화면 내용과 무선 상태에 따라 달라지며, C4 차량 실측은 별도입니다. 등록 파일을 제거하고 HUD를 재시작하면 직접 USB 출력으로 돌아갑니다. + +S3 N16R8 보드 선택·배선·펌웨어 설치·폰/C4 등록은 [NAS 설치 설명서](https://upload.shind0.synology.me/models/carrot-s3/index.html)에서 진행할 수 있습니다. 펌웨어 ZIP도 같은 페이지에서 제공합니다. S3 1.1.0은 소등 응답 확인과 재시도를 추가했으며, 기존 등록을 보존하여 업데이트합니다. + Jetson HUD에는 `jetSON` 아래에 Jetson 내부 센서의 최고 온도가 표시됩니다. 전체 내비게이션·그래프 화면에서는 우측 상단에 표시하며, 온도 정보가 3초 이상 갱신되지 않으면 `--°C`로 바뀝니다. 각 센서의 장치 설정 온도 한계보다 5°C 낮은 지점부터 상단에 온도 높음 경고, 한계 도달 시 과열 경고를 표시합니다. 예를 들어 성능 제한 온도가 99°C인 장치는 94°C부터 사전 경고합니다. 경고가 나타나면 팬과 통풍 상태를 확인하세요. 차량 운전 경고가 우선 표시되며, 이 온도 표시는 본체 온도·메모리 통계와 구분됩니다. 이 기능은 업데이트된 Jetson HUD 소프트웨어가 필요합니다. diff --git a/opendbc_repo/opendbc/car/tesla/carcontroller.py b/opendbc_repo/opendbc/car/tesla/carcontroller.py index 59eedb7b128..204128685c5 100644 --- a/opendbc_repo/opendbc/car/tesla/carcontroller.py +++ b/opendbc_repo/opendbc/car/tesla/carcontroller.py @@ -20,6 +20,7 @@ class CarController(CarControllerBase): def __init__(self, dbc_names, CP): super().__init__(dbc_names, CP) self.apply_angle_last = 0 + self.apply_angle_out = 0 self.packer = CANPacker(dbc_names[Bus.party]) self.tesla_can = TeslaCAN(CP, self.packer) self.coop_steering = True @@ -46,10 +47,12 @@ def update(self, CC, CS, now_nanos): if self.coop_steering: coop_result = self.coop_steer.update(self.apply_angle_last, lat_active, CS, self.VM) - can_sends.append(self.tesla_can.create_steering_control(coop_result.steeringAngleDeg, coop_result.lat_active, + self.apply_angle_out = coop_result.steeringAngleDeg + can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_out, coop_result.lat_active, (self.frame // CarControllerParams.STEER_STEP) % 16)) else: - can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_last, lat_active, + self.apply_angle_out = self.apply_angle_last + can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_out, lat_active, (self.frame // CarControllerParams.STEER_STEP) % 16)) if self.frame % 10 == 0: @@ -74,7 +77,7 @@ def update(self, CC, CS, now_nanos): # TODO: HUD control new_actuators = actuators.as_builder() - new_actuators.steeringAngleDeg = self.apply_angle_last + new_actuators.steeringAngleDeg = self.apply_angle_out self.frame += 1 return new_actuators, can_sends \ No newline at end of file diff --git a/opendbc_repo/opendbc/car/tesla/coop_steering.py b/opendbc_repo/opendbc/car/tesla/coop_steering.py index 40164901826..0ba484c9b61 100644 --- a/opendbc_repo/opendbc/car/tesla/coop_steering.py +++ b/opendbc_repo/opendbc/car/tesla/coop_steering.py @@ -4,11 +4,14 @@ from dataclasses import replace from opendbc.car import structs, rate_limit, DT_CTRL, apply_steer_angle_limits_vm +from opendbc.car.common.conversions import Conversions as CV from opendbc.car.vehicle_model import VehicleModel from opendbc.car.tesla.values import CarControllerParams DT_LAT_CTRL = DT_CTRL * CarControllerParams.STEER_STEP +# Match CarState's Float32 speed representation so exactly 25 km/h remains disabled. +COOP_STEERING_MIN_SPEED = float(np.float32(25.0 * CV.KPH_TO_MS)) class CoopSteeringCarControllerParams(CarControllerParams): @@ -75,6 +78,7 @@ def update(self, angle: float, angle_delta_lim: float) -> float: class CoopSteeringCarController: def __init__(self): self.apply_angle_last = 0 + self.apply_angle_out_last = 0 self.coop_apply_angle_sat_last = 0 self.angle_override = 0 self.resume_rate_limiter_delta = SteerRateLimiter() @@ -126,6 +130,16 @@ def resume_steer_desired_rate_limit(self, lat_active: bool, apply_angle: float, return apply_angle_lim def update(self, apply_angle, lat_active, CS: structs.CarState, VM: VehicleModel) -> CoopSteeringData: + if lat_active and not CS.out.vEgo > COOP_STEERING_MIN_SPEED: + # Keep normal steering limits referenced to the last final request when leaving cooperative control. + apply_angle = apply_steer_angle_limits_vm(apply_angle, self.apply_angle_out_last, CS.out.vEgoRaw, + CS.out.steeringAngleDeg, lat_active, CarControllerParams, VM) + self.reset_override_state(apply_angle) + self.resume_rate_limiter_delta.reset(0.0) + self.resume_rate_limiter.reset(apply_angle) + self.apply_angle_out_last = apply_angle + return CoopSteeringData(apply_angle, lat_active, 1) + # estimate real steering angle by adding rate to the tesla filtered angle steeringAngleDegPhaseLead = CS.out.steeringAngleDeg + CS.out.steeringRateDeg / STEERING_DEG_PHASE_LEAD_COEFF @@ -134,7 +148,10 @@ def update(self, apply_angle, lat_active, CS: structs.CarState, VM: VehicleModel if not lat_active: self.reset_override_state(apply_angle) - return CoopSteeringData(apply_angle, lat_active, 1) + # Inactive CAN must follow the EPS measurement, not the phase-lead estimate. + inactive_angle = apply_bounds(CS.out.steeringAngleDeg, CarControllerParams.ANGLE_LIMITS.STEER_ANGLE_MAX) + self.apply_angle_out_last = inactive_angle + return CoopSteeringData(inactive_angle, lat_active, 1) apply_angle_step = apply_angle - self.apply_angle_last self.apply_angle_last = apply_angle @@ -146,4 +163,5 @@ def update(self, apply_angle, lat_active, CS: structs.CarState, VM: VehicleModel self.coop_apply_angle_sat_last = apply_steer_angle_limits_vm(apply_angle, self.coop_apply_angle_sat_last, CS.out.vEgoRaw, CS.out.steeringAngleDeg, lat_active, CoopSteeringCarControllerParams, VM) self.angle_override = self.unwind_on_saturation(self.angle_override, apply_angle - self.coop_apply_angle_sat_last) + self.apply_angle_out_last = self.coop_apply_angle_sat_last return CoopSteeringData(self.coop_apply_angle_sat_last, lat_active, 1) diff --git a/opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py b/opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py new file mode 100644 index 00000000000..a040063dc45 --- /dev/null +++ b/opendbc_repo/opendbc/car/tesla/tests/test_controller_feedback.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace + +import pytest + +from opendbc.can import CANParser +from opendbc.car import Bus, structs, apply_steer_angle_limits_vm +from opendbc.car.tesla.carcontroller import CarController +from opendbc.car.tesla.interface import CarInterface +from opendbc.car.tesla.values import CAR, CarControllerParams + + +@pytest.mark.parametrize("speed_kph", [0.0, 24.9, 25.0, 25.1, 36.0]) +@pytest.mark.parametrize("cooperative", [False, True]) +@pytest.mark.parametrize("active,override", [(False, False), (True, False), (True, True)]) +def test_feedback_matches_final_steering_request_and_holds_between_transmissions(speed_kph, cooperative, active, override): + cp = CarInterface.get_non_essential_params(CAR.TESLA_MODEL_3) + controller = CarController({Bus.party: "tesla_model3_party"}, cp) + controller.coop_steering = cooperative + state = SimpleNamespace( + out=structs.CarState(vEgo=speed_kph / 3.6, vEgoRaw=speed_kph / 3.6, + steeringAngleDeg=12.3, steeringRateDeg=80.0, steeringTorque=1.0), + steering_disengage=override, tesla_manual_speed_adjustment_counter=0, + tesla_speed_auto_resume_gesture_counter=0, tesla_speed_limit_target_nanos=0, + das_accCancel=False, cruise_override=False, das_control={"DAS_controlCounter": 0}, + ) + command = structs.CarControl(latActive=active) + command.actuators.steeringAngleDeg = 30.0 + parser = CANParser("tesla_model3_party", [("DAS_steeringControl", 50)], 0) + + output, messages = controller.update(command.as_reader(), state, 0) + parser.update([(0, messages)]) + request = parser.vl["DAS_steeringControl"] + assert output.steeringAngleDeg == pytest.approx(-request["DAS_steeringAngleRequest"], abs=0.051) + assert bool(request["DAS_steeringControlType"]) == (active and not override) + if cooperative and (not active or override): + assert output.steeringAngleDeg == pytest.approx(state.out.steeringAngleDeg) + if cooperative and active and not override and speed_kph <= 25.0: + assert controller.coop_steer.angle_override == 0.0 + assert output.steeringAngleDeg == pytest.approx(controller.apply_angle_last) + + command.actuators.steeringAngleDeg = -30.0 + next_output, next_messages = controller.update(command.as_reader(), state, 10_000_000) + assert all(message[0] != 0x488 for message in next_messages) + assert next_output.steeringAngleDeg == output.steeringAngleDeg + + +@pytest.mark.parametrize("torque", [-1.5, 1.5]) +def test_speed_transitions_preserve_active_steering_and_final_request_limits(torque): + cp = CarInterface.get_non_essential_params(CAR.TESLA_MODEL_3) + controller = CarController({Bus.party: "tesla_model3_party"}, cp) + state = SimpleNamespace( + out=structs.CarState(steeringTorque=torque), + steering_disengage=False, tesla_manual_speed_adjustment_counter=0, + tesla_speed_auto_resume_gesture_counter=0, tesla_speed_limit_target_nanos=0, + das_accCancel=False, cruise_override=False, das_control={"DAS_controlCounter": 0}, + ) + command = structs.CarControl(latActive=True) + command.actuators.steeringAngleDeg = 30.0 + previous_output = 0.0 + parser = CANParser("tesla_model3_party", [("DAS_steeringControl", 50)], 0) + + for speed_kph in (30.0, 25.1, 25.0, 24.9, 10.0, 0.0, 24.9, 25.0, 25.1, 30.0): + state.out.vEgo = state.out.vEgoRaw = speed_kph / 3.6 + for _ in range(40): + state.out.steeringAngleDeg = previous_output + output, messages = controller.update(command.as_reader(), state, controller.frame * 10_000_000) + if any(message[0] == 0x488 for message in messages): + parser.update([(0, messages)]) + request = parser.vl["DAS_steeringControl"] + assert request["DAS_steeringControlType"] == 1 + assert output.steeringAngleDeg == pytest.approx(-request["DAS_steeringAngleRequest"], abs=0.051) + limited = apply_steer_angle_limits_vm(output.steeringAngleDeg, previous_output, state.out.vEgoRaw, + state.out.steeringAngleDeg, True, CarControllerParams, controller.VM) + assert output.steeringAngleDeg == pytest.approx(limited, abs=1e-5) + if speed_kph <= 25.0: + assert controller.coop_steer.angle_override == 0.0 + else: + assert output.steeringAngleDeg == previous_output + previous_output = output.steeringAngleDeg + + +def test_low_speed_engagement_uses_last_inactive_request_not_phase_lead(): + cp = CarInterface.get_non_essential_params(CAR.TESLA_MODEL_3) + controller = CarController({Bus.party: "tesla_model3_party"}, cp) + state = SimpleNamespace( + out=structs.CarState(vEgo=25.0 / 3.6, vEgoRaw=25.0 / 3.6, + steeringAngleDeg=12.3, steeringRateDeg=80.0, steeringTorque=1.0), + steering_disengage=False, tesla_manual_speed_adjustment_counter=0, + tesla_speed_auto_resume_gesture_counter=0, tesla_speed_limit_target_nanos=0, + das_accCancel=False, cruise_override=False, das_control={"DAS_controlCounter": 0}, + ) + command = structs.CarControl() + command.actuators.steeringAngleDeg = 30.0 + inactive, _ = controller.update(command.as_reader(), state, 0) + assert controller.coop_steer.coop_apply_angle_sat_last > inactive.steeringAngleDeg + + command.latActive = True + controller.update(command.as_reader(), state, 10_000_000) + output, _ = controller.update(command.as_reader(), state, 20_000_000) + expected = apply_steer_angle_limits_vm(command.actuators.steeringAngleDeg, inactive.steeringAngleDeg, + state.out.vEgoRaw, state.out.steeringAngleDeg, True, CarControllerParams, controller.VM) + assert output.steeringAngleDeg == pytest.approx(expected) + assert controller.coop_steer.angle_override == 0.0 diff --git a/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py b/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py index 62b943e7f66..a885d5e74c0 100644 --- a/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py +++ b/opendbc_repo/opendbc/car/tesla/tests/test_coop_steering.py @@ -4,8 +4,10 @@ from opendbc.car.tesla.coop_steering import ( CoopSteeringCarController, + STEERING_DEG_PHASE_LEAD_COEFF, STEER_OVERRIDE_MIN_TORQUE, ) +from opendbc.car.tesla.values import CarControllerParams class LinearVehicleModel: @@ -61,3 +63,91 @@ def test_update_resets_override_when_lateral_control_disables(monkeypatch): assert result.steeringAngleDeg == 0.0 assert controller.angle_override == 0.0 + + +@pytest.mark.parametrize("angle,rate", [(12.3, 80.0), (-15.6, -120.0), (420.0, 32.0), (-420.0, -32.0)]) +def test_inactive_request_tracks_measurement_but_preserves_resume_state(angle, rate): + controller = CoopSteeringCarController() + state = fake_state() + state.out.steeringAngleDeg = angle + state.out.steeringRateDeg = rate + controller.angle_override = 3.0 + + result = controller.update(20.0, False, state, LinearVehicleModel()) + + assert not result.lat_active + assert result.steeringAngleDeg == pytest.approx(max(-360.0, min(360.0, angle))) + phase_lead = angle + rate / STEERING_DEG_PHASE_LEAD_COEFF + assert controller.apply_angle_last == phase_lead + assert controller.coop_apply_angle_sat_last == phase_lead + assert controller.resume_rate_limiter._last == phase_lead + assert controller.resume_rate_limiter_delta._last == 0.0 + assert controller.angle_override == 0.0 + assert controller.apply_angle_out_last == result.steeringAngleDeg + + +@pytest.mark.parametrize("speed_kph", [0.0, 10.0, 24.999, 25.0, 25.001, 36.0]) +def test_cooperative_steering_requires_speed_strictly_above_25_kph(monkeypatch, speed_kph): + monkeypatch.setattr("opendbc.car.tesla.coop_steering.apply_steer_angle_limits_vm", lambda angle, *_args: angle) + controller = CoopSteeringCarController() + state = fake_state(speed=speed_kph / 3.6, torque=1.5) + state.out.steeringRateDeg = 80.0 + result = controller.update(5.0, True, state, LinearVehicleModel()) + + assert result.lat_active + if speed_kph <= 25.0: + assert result.steeringAngleDeg == 5.0 + assert controller.angle_override == 0.0 + assert controller.resume_rate_limiter._last == 5.0 + assert controller.resume_rate_limiter_delta._last == 0.0 + else: + assert controller.angle_override > 0.0 + assert result.steeringAngleDeg != 5.0 + + +def test_crossing_speed_threshold_clears_override_and_restarts_from_last_output(monkeypatch): + monkeypatch.setattr("opendbc.car.tesla.coop_steering.apply_steer_angle_limits_vm", lambda angle, *_args: angle) + controller = CoopSteeringCarController() + state = fake_state(speed=30.0 / 3.6, torque=1.5) + for _ in range(20): + controller.update(5.0, True, state, LinearVehicleModel()) + assert controller.angle_override > 0.0 + + state.out.vEgo = state.out.vEgoRaw = 25.0 / 3.6 + low_result = controller.update(2.0, True, state, LinearVehicleModel()) + assert low_result.steeringAngleDeg == 2.0 + assert low_result.lat_active + assert controller.angle_override == 0.0 + assert controller.apply_angle_last == controller.coop_apply_angle_sat_last == 2.0 + + state.out.vEgo = state.out.vEgoRaw = 30.0 / 3.6 + state.out.steeringTorque = 0.0 + resumed = controller.update(10.0, True, state, LinearVehicleModel()) + fresh = CoopSteeringCarController() + state.out.vEgo = state.out.vEgoRaw = 25.0 / 3.6 + fresh.update(2.0, True, state, LinearVehicleModel()) + state.out.vEgo = state.out.vEgoRaw = 30.0 / 3.6 + expected = fresh.update(10.0, True, state, LinearVehicleModel()) + assert resumed == expected + assert 2.0 < resumed.steeringAngleDeg < 10.0 + assert controller.angle_override == 0.0 + + +def test_low_speed_uses_normal_limits_from_last_final_output(monkeypatch): + calls = [] + + def limit(angle, previous, speed, measured, active, limits, model): + calls.append((angle, previous, speed, measured, active, limits, model)) + return previous + 0.2 + + monkeypatch.setattr("opendbc.car.tesla.coop_steering.apply_steer_angle_limits_vm", limit) + controller = CoopSteeringCarController() + controller.apply_angle_out_last = 10.0 + controller.angle_override = 3.0 + state = fake_state(speed=25.0 / 3.6, torque=1.5) + model = LinearVehicleModel() + result = controller.update(20.0, True, state, model) + assert calls == [(20.0, 10.0, state.out.vEgoRaw, 0.0, True, CarControllerParams, model)] + assert result.steeringAngleDeg == 10.2 + assert result.lat_active + assert controller.angle_override == 0.0 diff --git a/opendbc_repo/opendbc/safety/safety/safety_tesla.h b/opendbc_repo/opendbc/safety/safety/safety_tesla.h index a082934fad8..4a6b61351a2 100644 --- a/opendbc_repo/opendbc/safety/safety/safety_tesla.h +++ b/opendbc_repo/opendbc/safety/safety/safety_tesla.h @@ -220,8 +220,11 @@ static bool tesla_tx_hook(const CANPacket_t *to_send) { } // Don't allow any acceleration limits above the safety limits - violation |= longitudinal_accel_checks(raw_accel_max, TESLA_LONG_LIMITS); - violation |= longitudinal_accel_checks(raw_accel_min, TESLA_LONG_LIMITS); + violation |= (raw_accel_max > TESLA_LONG_LIMITS.max_accel) || (raw_accel_max < TESLA_LONG_LIMITS.min_accel); + violation |= (raw_accel_min > TESLA_LONG_LIMITS.max_accel) || (raw_accel_min < TESLA_LONG_LIMITS.min_accel); + // Tesla's zero acceleration is encoded as 375, not 0. TX must never grant controls. + violation |= !get_longitudinal_allowed() && ((raw_accel_max != TESLA_LONG_LIMITS.inactive_accel) || + (raw_accel_min != TESLA_LONG_LIMITS.inactive_accel)); } else { // does allowing cancel here disrupt stock AEB? TODO: find out and add safety or remove comment // Can only send cancel longitudinal messages when not controlling longitudinal diff --git a/opendbc_repo/opendbc/safety/tests/test_tesla.py b/opendbc_repo/opendbc/safety/tests/test_tesla.py index 38123d5c78e..05b8044487e 100755 --- a/opendbc_repo/opendbc/safety/tests/test_tesla.py +++ b/opendbc_repo/opendbc/safety/tests/test_tesla.py @@ -103,6 +103,15 @@ def test_vehicle_speed_measurements(self): self._common_measurement_test(self._speed_msg, 0, 285 / 3.6, 1, self.safety.get_vehicle_speed_min, self.safety.get_vehicle_speed_max) + def test_inactive_angle_requires_clamped_measurement(self): + self.safety.set_controls_allowed(False) + for measured in (-420.0, -360.0, -12.3, 12.3, 360.0, 420.0): + with self.subTest(measured=measured): + self._reset_angle_measurement(measured) + inactive = max(-self.STEER_ANGLE_MAX, min(self.STEER_ANGLE_MAX, measured)) + self.assertTrue(self._tx(self._angle_cmd_msg(inactive, False))) + self.assertFalse(self._tx(self._angle_cmd_msg(inactive + 1.0, False))) + class TestTeslaStockSafety(TestTeslaSafetyBase): @@ -140,6 +149,55 @@ def test_no_aeb(self): for aeb_event in range(4): self.assertEqual(self._tx(self._long_control_msg(10, aeb_event=aeb_event)), aeb_event == 0) + def test_acceleration_tx_never_grants_controls(self): + for flags in (1, 3, 5, 7): + for accel in (0.0, 1.0, -1.0, 2.1, -3.6): + with self.subTest(flags=flags, accel=accel): + self.safety.set_safety_hooks(CarParams.SafetyModel.tesla, flags) + self.safety.init_tests() + self.safety.set_controls_allowed(False) + self.assertEqual(self._tx(self._accel_msg(accel)), accel == 0.0) + self.assertFalse(self.safety.get_controls_allowed()) + + def test_permission_loss_blocks_actuation_without_tx_reengagement(self): + for cause in ("cruise", "brake", "gas"): + with self.subTest(cause=cause): + self._reset_safety_hooks() + self.safety.init_tests() + self.assertTrue(self._rx(self._pcm_status_msg(True))) + self.assertTrue(self.safety.get_controls_allowed()) + self.assertTrue(self._tx(self._accel_msg(1.0))) + if cause == "cruise": + self.assertTrue(self._rx(self._pcm_status_msg(False))) + elif cause == "brake": + self.assertTrue(self._rx(self._user_brake_msg(True))) + else: + self.assertTrue(self._rx(self._user_gas_msg(10))) + self.assertFalse(self._tx(self._accel_msg(1.0))) + self.assertTrue(self._tx(self._accel_msg(0.0))) + if cause != "gas": + self.assertFalse(self.safety.get_controls_allowed()) + + def test_each_acceleration_bound_requires_permission_and_valid_range(self): + for enabled in (False, True): + self.safety.set_controls_allowed(enabled) + for minimum, maximum in ((0, 0), (-1, 0), (0, 1), (-3.48, 2), (-3.6, 0), (0, 2.1), (2.1, 0), (0, -3.6)): + with self.subTest(enabled=enabled, minimum=minimum, maximum=maximum): + valid_range = self.MIN_ACCEL <= minimum <= self.MAX_ACCEL and self.MIN_ACCEL <= maximum <= self.MAX_ACCEL + inactive = minimum == maximum == 0 + self.assertEqual(self._tx(self._long_control_msg(10, accel_limits=(minimum, maximum))), + valid_range and (enabled or inactive)) + self.assertEqual(self.safety.get_controls_allowed(), enabled) + + def test_stock_aeb_still_blocks_host_commands(self): + self.safety.set_controls_allowed(True) + self.assertTrue(self._tx(self._accel_msg(1.0))) + self.assertTrue(self._rx(self._long_control_msg(10, aeb_event=1, bus=2))) + self.assertFalse(self._tx(self._accel_msg(1.0))) + self.assertFalse(self._tx(self._accel_msg(0.0))) + self.assertTrue(self._rx(self._long_control_msg(10, aeb_event=0, bus=2))) + self.assertTrue(self._tx(self._accel_msg(1.0))) + def test_stock_aeb_passthrough(self): no_aeb_msg = self._long_control_msg(10, aeb_event=0) no_aeb_msg_cam = self._long_control_msg(10, aeb_event=0, bus=2) diff --git a/openpilot/selfdrive/carrot/cluster/cluster_profile.py b/openpilot/selfdrive/carrot/cluster/cluster_profile.py index f6d28689a6a..aa5f41cecfe 100644 --- a/openpilot/selfdrive/carrot/cluster/cluster_profile.py +++ b/openpilot/selfdrive/carrot/cluster/cluster_profile.py @@ -205,11 +205,12 @@ def __call__(self, phase: str, info: dict[str, Any]) -> None: self.profile.add(f"gc.gen{generation}", (time.perf_counter() - start_time) * 1000.0) -def freeze_gc_after_init(profile: ProfileReporter) -> None: +def freeze_gc_after_init(profile: ProfileReporter) -> tuple[int, int, int] | None: freeze = getattr(gc, "freeze", None) if freeze is None: return + previous_thresholds = gc.get_threshold() profile_stage = time.perf_counter() gc.collect(2) profile.add_elapsed("gc.freeze_init.collect", profile_stage) @@ -218,3 +219,14 @@ def freeze_gc_after_init(profile: ProfileReporter) -> None: freeze() profile.add_elapsed("gc.freeze_init.freeze", profile_stage) gc.set_threshold(*_post_freeze_gc_thresholds()) + return previous_thresholds + + +def restore_gc_after_run(previous_thresholds: tuple[int, int, int] | None) -> None: + if previous_thresholds is None: + return + # A failed JPEG worker keeps a traceback cycle through its frozen pipeline. + # Autorun retries in this process: let the next init collection reclaim the + # previous session, while preserving freeze during the active frame loop. + gc.unfreeze() + gc.set_threshold(*previous_thresholds) diff --git a/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py b/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py index c086781e458..0498daec3ed 100644 --- a/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py +++ b/openpilot/selfdrive/carrot/cluster/cluster_wifi_display.py @@ -1,5 +1,6 @@ """C4 renders the existing HUD; S3 forwards the resulting TURZX USB packets.""" from collections import deque +import time from cluster_usb_display import TuringUsbDisplay, _set_cluster_hud_connected from cluster_wifi_protocol import Connection, load_config @@ -28,7 +29,9 @@ def open(self) -> None: self.landscape_width, self.landscape_height = 1920, 462 self._disconnected = False self._send_command(10, 'sync') - self._send_command(14, 'brightness', {8: int(self.brightness / 100 * 102)}, expect_response=False, log=False) + time.sleep(0.2) + self._send_command(14, 'brightness', {8: int(self.brightness / 100 * 102)}, + expect_response=self.brightness == 0, log=False) _set_cluster_hud_connected(True) except Exception as exc: self.close() @@ -80,7 +83,9 @@ def close(self) -> None: if self.connection is not None: try: if not self._disconnected: - self._send_command(14, 'brightness-off', {8: 0}, expect_response=False, log=False) + self._send_command(10, 'sync-off', log=False) + time.sleep(0.2) + self._send_command(14, 'brightness-off', {8: 0}, log=False) except (RuntimeError, OSError): pass finally: diff --git a/openpilot/selfdrive/carrot/cluster/main.py b/openpilot/selfdrive/carrot/cluster/main.py index b432a9bd9b5..01c6ad4cc49 100644 --- a/openpilot/selfdrive/carrot/cluster/main.py +++ b/openpilot/selfdrive/carrot/cluster/main.py @@ -68,7 +68,7 @@ from cluster_live import OpenpilotLiveSource from cluster_models import RouteOverlay, SimulatorInput from cluster_navi_overlay import merge_navi_overlay_state -from cluster_profile import GcProfileHook, ProfileReporter, freeze_gc_after_init +from cluster_profile import GcProfileHook, ProfileReporter, freeze_gc_after_init, restore_gc_after_run from cluster_renderer import ClusterUiRenderer from cluster_route_replay import ( ROUTE_CORNER_SOURCE_CHOICES, @@ -1020,6 +1020,7 @@ def switch_route_source( ) return True + previous_gc_thresholds = None try: if hud_output_gate_param_reader is not None and not hud_output_gate_param_reader.allowed(): print( @@ -1130,7 +1131,7 @@ def switch_route_source( flush=True, ) if gc_freeze_init: - freeze_gc_after_init(profile) + previous_gc_thresholds = freeze_gc_after_init(profile) while True: if stop_requested: break @@ -1909,6 +1910,8 @@ def switch_route_source( report_frames = 0 last_report_time = now finally: + # Restore even if a later native-resource close raises an exception. + restore_gc_after_run(previous_gc_thresholds) if usb_display is not None: _set_cluster_hud_connected(False) scheduler.update(False, force=True, child_pid=h264_pipeline.encoder_pid if h264_pipeline is not None else None) diff --git a/openpilot/selfdrive/carrot/cluster_autorun.py b/openpilot/selfdrive/carrot/cluster_autorun.py index 87be88497cd..d70b7946fb3 100644 --- a/openpilot/selfdrive/carrot/cluster_autorun.py +++ b/openpilot/selfdrive/carrot/cluster_autorun.py @@ -81,6 +81,39 @@ def _is_usb_disconnect_error(exc: BaseException) -> bool: return False +def _s3_transport_failure_reason(exc: BaseException) -> str | None: + """Only shorten a transport failure inside the known S3 wrappers.""" + current: BaseException | None = exc + seen: set[int] = set() + s3_failure = False + transport_error: OSError | None = None + while current is not None and id(current) not in seen: + seen.add(id(current)) + if str(current) in ( + "S3 display disconnected; reconnect required", + "S3 display connection failed; waiting for rediscovery", + ): + s3_failure = True + if isinstance(current, OSError): + transport_error = current + current = current.__cause__ or current.__context__ + if not s3_failure or transport_error is None: + return None + detail = " ".join(str(transport_error).split())[:160] + return f"{type(transport_error).__name__}: {detail}" + + +def _report_cluster_failure(exc: BaseException) -> None: + reason = _s3_transport_failure_reason(exc) + if reason is not None: + print(f"[cluster_autorun] S3 Wi-Fi disconnected ({reason}); retrying in {RETRY_INTERVAL_S:.0f}s", flush=True) + elif _is_usb_disconnect_error(exc): + print(f"[cluster_autorun] USB display disconnected; retrying in {RETRY_INTERVAL_S:.0f}s", flush=True) + else: + print(f"[cluster_autorun] cluster HUD failed: {exc}; retrying in {RETRY_INTERVAL_S:.0f}s", flush=True) + traceback.print_exc() + + def _read_hud_mode(params: Params) -> int: try: return int(params.get_int(HUD_PARAM)) @@ -410,11 +443,19 @@ def _turn_off_supported_usb_device(expected_product_id: int, reason: str) -> boo _configure_autorun_locale() from cluster_usb_display import TuringUsbDisplay, find_supported_usb_product, product_label + from cluster_wifi_display import S3WifiDisplay + from cluster_wifi_protocol import load_config - if find_supported_usb_product(expected_product_id) is None: + try: + config = load_config() if expected_product_id == 0x0092 else None + except (ValueError, RuntimeError, OSError): return False - - display = TuringUsbDisplay(brightness=0, display_fps=0, expected_product_id=expected_product_id) + if config is not None: + display = S3WifiDisplay(config, brightness=0, display_fps=0, expected_product_id=expected_product_id) + else: + if find_supported_usb_product(expected_product_id) is None: + return False + display = TuringUsbDisplay(brightness=0, display_fps=0, expected_product_id=expected_product_id) try: display.open() print( @@ -622,17 +663,7 @@ def main() -> None: flush=True, ) except Exception as exc: - if _is_usb_disconnect_error(exc): - print( - f"[cluster_autorun] USB display disconnected; retrying in {RETRY_INTERVAL_S:.0f}s", - flush=True, - ) - else: - print( - f"[cluster_autorun] cluster HUD failed: {exc}; retrying in {RETRY_INTERVAL_S:.0f}s", - flush=True, - ) - traceback.print_exc() + _report_cluster_failure(exc) time.sleep(RETRY_INTERVAL_S) diff --git a/openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py b/openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py new file mode 100644 index 00000000000..1221fa84a1a --- /dev/null +++ b/openpilot/selfdrive/carrot/tests/test_cluster_gc_lifecycle.py @@ -0,0 +1,56 @@ +"""Run GC-global lifecycle checks outside the pytest process.""" +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[4] + + +def test_failed_jpeg_sessions_release_frozen_traceback_cycles(): + code = r''' +import gc +import sys +import weakref +from pathlib import Path +sys.path.insert(0, str(Path.cwd() / "openpilot/selfdrive/carrot/cluster")) +from cluster_profile import ProfileReporter, freeze_gc_after_init, restore_gc_after_run +from cluster_usb_pipeline import AsyncJpegUsbPipeline + +class DisconnectedDisplay: + def clear_profile_samples(self): pass + def encode_jpeg(self, rgba, width, height): return b"jpeg" + def send_jpeg(self, jpeg): raise ConnectionError("S3 display is closed") + def profile_samples(self): return () + +original = gc.get_threshold() +refs = [] +def session(): + pipeline = AsyncJpegUsbPipeline(DisconnectedDisplay()) + pipeline.start() + previous = freeze_gc_after_init(ProfileReporter(False, 1)) + try: + pipeline.submit_rgba(bytes(1920 * 462 * 4), 1920, 462) + pipeline._thread.join(2) + assert not pipeline._thread.is_alive() + try: + pipeline.check_error() + except RuntimeError as exc: + assert isinstance(exc.__cause__, ConnectionError) + else: + raise AssertionError("Expected disconnected JPEG failure") + finally: + restore_gc_after_run(previous) + pipeline.close() + return weakref.ref(pipeline) + +for _ in range(12): + refs.append(session()) + gc.collect() + assert not any(ref() is not None for ref in refs), "Failed HUD session retained" + assert gc.get_threshold() == original, (gc.get_threshold(), original) +restore_gc_after_run(None) +assert gc.get_threshold() == original +''' + result = subprocess.run([sys.executable, "-c", code], cwd=ROOT, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py b/openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py new file mode 100644 index 00000000000..af429b76dc6 --- /dev/null +++ b/openpilot/selfdrive/carrot/tests/test_cluster_retry_logging.py @@ -0,0 +1,66 @@ +"""Exercise retry reporting without loading the native Params extension.""" +import ast +from pathlib import Path +import traceback + +import pytest + + +def report(exc): + source = Path(__file__).resolve().parents[1] / 'cluster_autorun.py' + tree = ast.parse(source.read_text(encoding='utf8')) + names = {'_is_usb_disconnect_error', '_s3_transport_failure_reason', '_report_cluster_failure'} + nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names + or isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'USB_DISCONNECT_TEXT' for t in n.targets)] + scope = {'traceback': traceback, 'RETRY_INTERVAL_S': 5} + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), scope) + scope['_report_cluster_failure'](exc) + + +@pytest.mark.parametrize('cause', [TimeoutError('timed out'), ConnectionResetError('peer reset'), ConnectionError('S3 display is closed')]) +def test_s3_transport_failure_is_one_line_with_cause_and_retry(capsys, cause): + transport = RuntimeError('S3 display disconnected; reconnect required') + transport.__cause__ = cause + pipeline = RuntimeError('asynchronous USB JPEG pipeline failed') + pipeline.__cause__ = transport + report(pipeline) + captured = capsys.readouterr() + assert captured.out.splitlines() == [f'[cluster_autorun] S3 Wi-Fi disconnected ({type(cause).__name__}: {cause}); retrying in 5s'] + assert captured.err == '' + + +@pytest.mark.parametrize('cause', [ValueError('bad encoder input'), OSError('unrelated encoder IO failure')]) +def test_unexpected_pipeline_failure_keeps_traceback(capsys, cause): + try: + raise RuntimeError('asynchronous USB JPEG pipeline failed') from cause + except RuntimeError as exc: + report(exc) + captured = capsys.readouterr() + assert 'cluster HUD failed' in captured.out + assert 'Traceback' in captured.err and str(cause) in captured.err + + +def test_s3_wrapper_does_not_hide_programming_error(capsys): + try: + raise RuntimeError('S3 display connection failed; waiting for rediscovery') from ValueError('invalid payload') + except RuntimeError as exc: + report(exc) + assert 'ValueError: invalid payload' in capsys.readouterr().err + + +def test_usb_hot_unplug_remains_concise(capsys): + report(RuntimeError('USB display disconnected')) + captured = capsys.readouterr() + assert captured.out == '[cluster_autorun] USB display disconnected; retrying in 5s\n' + assert captured.err == '' + + +def test_cyclic_chain_and_multiline_detail_are_bounded(capsys): + transport = RuntimeError('S3 display connection failed; waiting for rediscovery') + error = TimeoutError('timed out\n' + 'x' * 1000) + transport.__cause__ = error + error.__context__ = transport + report(transport) + captured = capsys.readouterr() + assert len(captured.out.splitlines()) == 1 and len(captured.out) < 260 + assert captured.err == '' diff --git a/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py b/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py index 10664232977..9398c13dc70 100644 --- a/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py +++ b/openpilot/selfdrive/carrot/tests/test_cluster_wifi.py @@ -222,8 +222,67 @@ def test_provisioning_validates_bytes_and_does_not_reset_uart(): assert 'port.dtr = port.rts = False' in source +def test_automatic_uart_selection_refuses_ambiguity_without_opening_ports(monkeypatch): + path = Path(__file__).resolve().parents[4] / 'tools/esp32s3_display/provision.py' + spec = importlib.util.spec_from_file_location('s3_auto_provision', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + ports = [] + monkeypatch.setattr('serial.tools.list_ports.comports', lambda: ports) + with pytest.raises(RuntimeError, match='UART candidates: none'): + module.automatic_port() + ports.extend([SimpleNamespace(vid=0x10c4, device='COM14'), SimpleNamespace(vid=0x9999, device='COM15')]) + assert module.automatic_port() == 'COM14' + ports.append(SimpleNamespace(vid=0x1a86, device='COM16')) + with pytest.raises(RuntimeError, match='COM14, COM16'): + module.automatic_port() + + @pytest.mark.parametrize('size', [0, 511, protocol.MAX_PAYLOAD + 1]) def test_payload_bounds_before_socket_write(size): connection = protocol.Connection.__new__(protocol.Connection) with pytest.raises(ValueError): connection.send(bytes(size)) + + +@pytest.mark.parametrize('fail', [False, True]) +def test_close_syncs_and_confirms_off_but_releases_transport_on_failure(monkeypatch, fail): + display = S3WifiDisplay(CONFIG) + events = [] + display.connection = SimpleNamespace(close=lambda: events.append('closed')) + def command(number, name, fields=None, **kwargs): + events.append((number, fields)) + assert kwargs.get('expect_response', True) + if fail and number == 14: + raise RuntimeError('USB response lost') + monkeypatch.setattr(display, '_send_command', command) + monkeypatch.setattr('cluster_wifi_display.time.sleep', lambda delay: events.append(delay)) + display.close() + assert events == [(10, None), .2, (14, {8: 0}), 'closed'] + assert display.connection is None and not display.pending + + +@pytest.mark.parametrize('registered', [False, True]) +def test_offroad_dims_registered_s3_without_local_usb(tmp_path, monkeypatch, registered): + path = tmp_path / 'pair.json' + monkeypatch.setenv('CARROT_S3_CONFIG', str(path)) + if registered: + protocol.save_config(CONFIG, path) + events = [] + def scan(pid): + events.append('scan') + return None + class Display: + def __init__(self, config, **kwargs): + assert config == CONFIG and kwargs['brightness'] == 0 + def open(self): events.append('open-off') + def close(self): events.append('close') + monkeypatch.setattr('cluster_usb_display.find_supported_usb_product', scan) + monkeypatch.setattr('cluster_wifi_display.S3WifiDisplay', Display) + source = CLUSTER.parent / 'cluster_autorun.py' + node = next(n for n in ast.parse(source.read_text(encoding='utf8')).body + if isinstance(n, ast.FunctionDef) and n.name == '_turn_off_supported_usb_device') + scope = {'_configure_autorun_locale': lambda: None} + exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), scope) + assert scope[node.name](0x0092, 'test') is registered + assert events == (['open-off', 'close'] if registered else ['scan']) diff --git a/openpilot/selfdrive/carrot/web/generated/asset-manifest.json b/openpilot/selfdrive/carrot/web/generated/asset-manifest.json index 02aab84d8c0..28a90e6851c 100644 --- a/openpilot/selfdrive/carrot/web/generated/asset-manifest.json +++ b/openpilot/selfdrive/carrot/web/generated/asset-manifest.json @@ -34,7 +34,7 @@ "kind": "bundle", "source": "src/entries/tools.js", "path": "js/generated/tools.js", - "hash": "2d26f4eacf9daea3f0dd3599913920e72ef059c5d4e35c569e96f97055002a7a" + "hash": "326d4534c6fe5d78463a8d66f7adf55fd9e528f52789ed5488c7c89985bab871" }, { "id": "logs.runtime", diff --git a/openpilot/selfdrive/carrot/web/js/generated/tools.js b/openpilot/selfdrive/carrot/web/js/generated/tools.js index c2893f5c56c..9539247679c 100644 --- a/openpilot/selfdrive/carrot/web/js/generated/tools.js +++ b/openpilot/selfdrive/carrot/web/js/generated/tools.js @@ -171,7 +171,7 @@ ${wn(o)}`.trim()}}let l=Array.isArray(a.commits)?a.commits:[],d=Math.max(0,ne(a. ${y("tools_raw_output","Raw output")} ${T}`:p=b||T||hn(o)}return`> ${bn(o)} -${p}`}function pi(o,a){let l=String(o?.status||""),d=String(o?.action||"").trim(),p=vn(o),b=p?Sn(o,p):null,T=ui(o),A=Number(o?.updated_at||o?.created_at||0),G=!!o?.payload?.notice,O=`job-${o?.id||a}`,V=p?`${O}:${p.after||p.before||""}`:"",E=hn(o);return l==="running"?E=String(o?.message||"").trim()||si(d)||E:l==="failed"?E=li(d)||E:G?E=kt(T):p?E=U(b?.card||p.card_summary||p.display||"")||kt(T):E=Dt(o)||ri(d)||E,{id:O,ackId:V,source:l==="running"?"current":"history",text:T,title:bn(o),summary:E,updateCard:b?{label:b.label||"",messages:Array.isArray(b.messages)?b.messages:[],stats:b.stats||null}:null,status:l,highlight:!!(p?.updated&&l==="done"&&!L.has(V)),updateKind:p?"git_pull":"",timestamp:A,timeLabel:ii(A)}}function Tn(o,a,l){let d=U(o);return{id:`${a}-${l}-${Se(d)}`,source:a,text:d,title:ni(d),summary:kt(d),timestamp:0,timeLabel:""}}function pt(o={}){let a=U(o.currentText),l=U(o.historyText),d=Array.isArray(o.jobs)?o.jobs.filter(Boolean):[],p=[];return d.slice(0,i).forEach((b,T)=>p.push(pi(b,T))),a&&p.push(Tn(a,"current",0)),l&&l.split(/\n{2,}/).map(b=>b.trim()).filter(Boolean).slice(-i).forEach((b,T)=>p.push(Tn(b,"history",T))),{entries:p,hasHistory:!!l||d.some(b=>String(b?.status||"")!=="running"),state:{jobs:d,historyText:l,currentText:a}}}function Cn(o,a){let l=document.createElement("button");return l.type="button",l.className="tools-console-log__clearBtn",l.textContent=y("tools_notifications_clear","Clear"),l.disabled=!o.hasHistory,l.addEventListener("click",d=>{d.stopPropagation(),f="",a.options.onClear?.()}),l}function An(o){let a=document.createElement("span");a.className="tools-console-log__header",a.dataset.toolsNotificationMode=o.mode;let l=document.createElement("span");if(l.className="tools-console-log__headerTitle",l.textContent=y("tools_notifications","Notifications"),a.appendChild(l),o.mode===n.LANDSCAPE){let d=document.createElement("span");d.className="tools-console-log__headerActions",d.appendChild(Cn(o.model,o)),a.appendChild(d)}return a}function gi(){let o=document.createElement("span");return o.className="tools-console-log__empty",o.textContent=y("tools_notifications_empty","No notifications"),o}function Pt(){return!!t.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches}function mi(o){let a=String(o||"").trim().split(",")[0].trim();if(!a)return 0;let l=Number.parseFloat(a);return Number.isFinite(l)?a.endsWith("ms")?l:l*1e3:0}function En(o){if(Pt())return 0;try{let a=t.getComputedStyle?.(o),l=mi(a?.getPropertyValue("--tools-detail-duration"));return Math.max(180,l||320)+40}catch{return 360}}function Bt(){F&&(t.clearTimeout(F),F=null,z="",j=0,te=null)}function fi(o){return!!(F&&z&&Date.now()bi(a.id,o),{passive:!0}))}function gt(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`)}function xn(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.transition="none",a.style.animation="none",a.style.opacity="1",a.style.transform="translateY(0)",gt(o),a.getBoundingClientRect())}function yi(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`,a.style.opacity="1",a.style.transform="translateY(0)",a.getBoundingClientRect(),o.classList.add("is-collapsing"),o.classList.remove("is-expanded"),o.setAttribute("aria-expanded","false"),t.requestAnimationFrame(()=>{a.style.maxHeight="0px",a.style.opacity="0",a.style.transform="translateY(-6px)"}))}function oe(o){return o?(o.dataset.toolsNotificationMode||Te())===n.PORTRAIT?o:o?.querySelector?.(".tools-console-log__body")||o||null:null}function re(o,a){return!o||!a?null:Array.from(o.querySelectorAll("[data-notification-id]")).find(l=>l.dataset.notificationId===a)||null}function wi(o,a){return JSON.stringify({mode:a,active:f,hasHistory:o.hasHistory,entries:o.entries.map(l=>({id:l.id,source:l.source,title:l.title,summary:l.summary,updateCard:l.updateCard||null,text:l.text,status:l.status||"",highlight:!!l.highlight,updateKind:l.updateKind||"",hasTime:Number(l.timestamp||0)>0}))})}function vi(o){return String(o?.status||"")==="running"}function Si(o){return!!o&&!vi(o)}function Ln(o){let a=oe(o);return a?Array.from(a.querySelectorAll("[data-notification-id]")):[]}function Ti(o,a){let l=Ln(o);return l.length===a.entries.length&&l.every((d,p)=>d.dataset.notificationId===a.entries[p]?.id)}function Ot(o,a){o&&o.textContent!==a&&(o.textContent=a)}function qe(o,a,l){let d=document.createElement("span");return d.className=a,d.textContent=l,o.appendChild(d),d}function Nn(o,a){if(!o)return;if(o.replaceChildren(),o.classList.toggle("tools-console-log__cardBody--update",!!a.updateCard),!a.updateCard){o.textContent=a.summary;return}let l=String(a.updateCard.label||"").trim();l&&qe(o,"tools-console-log__updateLabel",l);let d=Array.isArray(a.updateCard.messages)?a.updateCard.messages.filter(Boolean):[];if(d.length){let b=document.createElement("span");b.className="tools-console-log__updateMessages",d.slice(0,3).forEach(T=>qe(b,"tools-console-log__updateMessage",T)),o.appendChild(b)}let p=a.updateCard.stats&&typeof a.updateCard.stats=="object"?a.updateCard.stats:null;if(p){let b=document.createElement("span");b.className="tools-console-log__updateStats",p.commits&&qe(b,"tools-console-log__updateStat",p.commits),p.files&&qe(b,"tools-console-log__updateStat",p.files),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--add",`+${Math.max(0,ne(p.insertions))}`),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--delete",`-${Math.max(0,ne(p.deletions))}`),o.appendChild(b)}else!d.length&&l&&(o.textContent=l)}function Ci(o,a,l){let d=f===a.id;o.classList.toggle("tools-console-log__current",a.source==="current"),o.classList.toggle("tools-console-log__history",a.source==="history"),o.classList.toggle("is-expanded",d),o.classList.toggle("is-git-update",a.updateKind==="git_pull"),o.classList.toggle("is-unread-update",!!a.highlight),o.dataset.toolsNotificationMode=l.mode,o.setAttribute("aria-expanded",d?"true":"false"),Ot(o.querySelector(".tools-console-log__cardTitle"),a.title);let p=o.querySelector(".tools-console-log__cardHead"),b=o.querySelector(".tools-console-log__cardTime");a.timeLabel?(!b&&p&&(b=document.createElement("span"),b.className="tools-console-log__cardTime",p.appendChild(b)),Ot(b,a.timeLabel)):b&&b.remove(),Nn(o.querySelector(".tools-console-log__cardBody"),a),Ot(o.querySelector(".tools-console-log__detail"),a.text)}function Ai(o,a,l){Ln(o).forEach((d,p)=>Ci(d,a.entries[p],l)),o.querySelectorAll(".tools-console-log__clearBtn").forEach(d=>{d.disabled=!a.hasHistory})}function Mt(o,a){if(!o)return;let l=new Map(a.map(d=>[d.id,d.timeLabel||""]));o.querySelectorAll("[data-notification-id]").forEach(d=>{let p=l.get(d.dataset.notificationId)||"",b=d.querySelector(".tools-console-log__cardTime");b&&b.textContent!==p&&(b.textContent=p)})}function Ei(o){f&&(o.entries.some(a=>a.id===f)||(f=""))}function $n(o){if(!o?.entries?.length)return null;let a=null,l=0;return o.entries.forEach(d=>{let p=Number(d.timestamp||0);Number.isFinite(p)&&p>0&&p>=l&&(a=d,l=p)}),a||o.entries.slice().reverse().find(d=>d.source==="current")||o.entries[o.entries.length-1]}function Ii(o,a,l=!0,d={}){let p=oe(o),T=re(p,a)?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:!1,instant:d.instant===!0,smoothOnce:d.smoothOnce===!0,stableDetail:d.stableDetail===!0,mode:o?.dataset?.toolsNotificationMode||Te(),scrollTop:p?.scrollTop||0,cardTop:T?T.top:null}}function kn(o,a,l={}){if(!a)return"";let d=l.expand!==!1;return Bt(),f=d?a:"",_=Ii(o,a,d,l),v.delete(a),a}function xi(o=C,a={}){let l=pt(N),d=$n(l);if(!d)return"";let p=kn(o,d.id,a);return o&&Ve(o,l.state,M,{force:!0,preserveScroll:!1}),p}function Li(o,a=f){let l=oe(o);if(!l)return null;let d=re(l,a),p=o?.dataset?.toolsNotificationMode||Te();return{anchorId:a,mode:p,scrollTop:l.scrollTop||0,cardTop:d?d.getBoundingClientRect().top:null}}function Ni(o,a,l,d){let p=oe(o),b=re(p,a),T=o?.dataset?.toolsNotificationMode||Te(),A=b?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:d,mode:T,scrollTop:p?.scrollTop||0,cardTop:A?A.top:null}}function $i(o,a){let l=oe(o);if(!l||!a)return;let d=a.scrollTop;if(a.mode!==n.LANDSCAPE&&a.anchorId&&a.cardTop!==null){let p=re(l,a.anchorId);p&&(d+=p.getBoundingClientRect().top-a.cardTop)}l.scrollTop=Math.max(0,d)}function Ut(o,a){let l=Math.max(0,(o?.scrollHeight||0)-(o?.clientHeight||0));return Math.min(Math.max(0,a),l)}function ki(o,a){let l=oe(o),d=re(l,a?.id);if(!l||!a)return;if(!d||a.cardTop===null){l.scrollTop=Ut(l,a.scrollTop||0);return}let p=d.getBoundingClientRect().top-a.cardTop;if(Math.abs(p)<1)return;let b=(a.scrollTop||0)+p;l.scrollTop=Ut(l,b)}function ke(o,a,l="settled",d={}){let p=oe(o),b=re(p,a?.id);if(!p||!b)return;if(a.keyboard)try{b.focus({preventScroll:!0})}catch{}let T=p.getBoundingClientRect(),A=b.getBoundingClientRect(),G=b.querySelector(".tools-console-log__cardHead")?.getBoundingClientRect?.()||A,O=o.dataset.toolsNotificationMode||Te(),V=O===n.PORTRAIT?18:12,E=O===n.PORTRAIT?92:20,X=Math.max(T.height-V-E,1),Q=T.top+V,ge=T.bottom-E,Ce=Math.max(0,Number(d.expandedHeightDelta)||0),se=Ce>0?{top:A.top,bottom:A.bottom+Ce,height:A.height+Ce}:A,I=l==="opening"?G:se,le=0;if(!a.smoothOnce&&!a.stableDetail&&!a.instant&&(se.top>=Q&&se.bottom<=ge||G.top>=Q&&G.bottom<=ge&&se.height>=X)||l==="opening"&&I.top>=Q&&I.bottom<=ge||(a?.expanded&&l!=="opening"&&se.height>=X?le=se.top-Q:I.topge&&(le=I.bottom-ge),Math.abs(le)<2))return;let Je=Ut(p,p.scrollTop+le),Ae=d.behavior||(Pt()?"auto":"smooth");try{p.scrollTo({top:Je,behavior:Ae})}catch{p.scrollTop=Je}}function Ri(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");if(!a)return 0;let l=a.getBoundingClientRect();return Math.max(0,a.scrollHeight-l.height)}function Di(o,a){if(!a?.id)return;h+=1;let l=h;S&&(t.clearTimeout(S),S=null);let d=Te();t.requestAnimationFrame(()=>{if(l!==h)return;if(a.instant){ke(o,a,"settled",{behavior:"auto"});return}if(a.smoothOnce){ke(o,a,"settled");return}if(!a.stableDetail){a.keyboard&&ke(o,a,"opening");return}if(a.expanded&&!Pt()){let b=oe(o),T=re(b,a.id),A=Ri(T);ke(o,a,"concurrent",{expandedHeightDelta:A});let G=d===n.PORTRAIT?380:360;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},G);return}ke(o,a,"opening");let p=a.expanded?d===n.PORTRAIT?380:360:d===n.PORTRAIT?180:160;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},p)})}function Pi(o){let a=document.createElement("span");a.className="tools-console-log__detailWrap";let l=document.createElement("span");l.className="tools-console-log__detailInner";let d=document.createElement("span");d.className="tools-console-log__detailTitle",d.textContent=y("tools_notification_detail","Detail log"),l.appendChild(d);let p=document.createElement("span");return p.className="tools-console-log__detail",p.textContent=o.text,hi(p,o),l.appendChild(p),a.appendChild(l),a}function Bi(o,a){let l=f===o.id,d=document.createElement("div");d.className=`tools-console-log__card tools-console-log__${o.source}`,d.classList.toggle("is-expanded",l),d.classList.toggle("is-git-update",o.updateKind==="git_pull"),d.classList.toggle("is-unread-update",!!o.highlight),d.dataset.notificationId=o.id,d.dataset.toolsNotificationMode=a.mode,d.setAttribute("role","button"),d.setAttribute("tabindex","0"),d.setAttribute("aria-expanded",l?"true":"false");let p=document.createElement("span");p.className="tools-console-log__cardTitle",p.textContent=o.title;let b=document.createElement("span");if(b.className="tools-console-log__cardHead",b.appendChild(p),o.timeLabel){let E=document.createElement("span");E.className="tools-console-log__cardTime",E.textContent=o.timeLabel,b.appendChild(E)}d.appendChild(b);let T=document.createElement("span");T.className="tools-console-log__cardBody",Nn(T,o),d.appendChild(T),d.appendChild(Pi(o));let A=0,G=0,O=!1,V=(E,X=!1)=>{if(O){O=!1,E.preventDefault();return}let Q=!l;if(_=Ni(a.out,o.id,Q,X),Qo(o,d),!Q){Bt(),f="",v.delete(o.id),z=o.id,te=a.out,yi(d),j=Date.now()+En(d),F=t.setTimeout(()=>{F=null,z="",j=0,te=null,Ve(a.out,a.model.state,a.options,{force:!0})},En(d));return}Bt(),f=Q?o.id:"",Ve(a.out,a.model.state,a.options)};return d.addEventListener("pointerdown",E=>{A=E.clientX,G=E.clientY,O=!1},{passive:!0}),d.addEventListener("pointermove",E=>{Math.hypot(E.clientX-A,E.clientY-G)>8&&(O=!0)},{passive:!0}),d.addEventListener("click",E=>{V(E,E.detail===0)}),d.addEventListener("keydown",E=>{E.key!=="Enter"&&E.key!==" "||(E.preventDefault(),V(E,!0))}),d}function Oi(o,a){let l=document.createElement("span");return l.className="tools-console-log__footer",l.dataset.toolsNotificationMode=a.mode,l.appendChild(Cn(o,a)),l}function Mi(o,a){let l=document.createDocumentFragment();return o.entries.length?(o.entries.forEach(d=>l.appendChild(Bi(d,a))),l):(l.appendChild(gi()),l)}function Rn(o){let a=document.createElement("span");return a.className="tools-console-log__body",a.appendChild(Mi(o.model,o)),a}function Ui(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a}function Gi(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a.appendChild(Oi(o.model,o)),a}function Ve(o,a={},l={},d={}){if(!o)return;ti();let p=_,b=d.preserveScroll===!1||p?null:Li(o),T=Te(),A=pt(a);if(N=A.state,M=l,ci(A.state),pn(o,T),!d.force&&fi(o)){Mt(o,A.entries),Qe(A.entries);return}Ei(A);let O=l.autoFocusLatest===!0&&!d.skipAutoFocusLatest?$n(A):null;Si(O)&&O.id!==$&&($=O.id,kn(o,O.id,{expand:!0,smoothOnce:!0,stableDetail:!0}),p=_,b=null);let V=wi(A,T),E={out:o,mode:T,model:A,options:l};if(!p&&V===w&&o.childElementCount>0){Mt(o,A.entries),Qe(A.entries);return}if(!p&&o.childElementCount>0&&Ti(o,A)){Ai(o,A,E),w=V,Qe(A.entries),f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)});return}if(o.replaceChildren(T===n.PORTRAIT?Gi(E):Ui(E)),w=V,p){if(ki(o,p),p.instant||p.stableDetail){let X=oe(o);xn(re(X,p.id))}}else if($i(o,b),f){let X=oe(o);xn(re(X,f))}Qe(A.entries),p?(_=null,p.expanded&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,p.id);gt(Q),_i(o,p.id)}),Di(o,p)):f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)})}function qi(o,a={}){_n(o,a,{boundKey:"toolsNotificationGestureBound",getPanel:gn})}function Hi(o,a={}){_n(o,a,{boundKey:"toolsNotificationPanelDragBound",getPanel:()=>o,ignoreTarget:Xo})}function Dn(o=N){return pt(o).entries.filter(a=>!!a.highlight).length}t.CarrotToolsNotifications={bindPanelDrag:Hi,bindStatusGesture:qi,unreadCount:Dn,render:Ve,resetDetail(){f="",$="",v.clear()},focusLatest(o={}){return xi(o.out||C,o)},syncMode(o=C){o&&pn(o)}}})(window);var rn=null,st=null,At=!1,sn={recovering:"eGPU reconnecting",recovering_detail:"The internal model remains in use while eGPU recovery is attempted, up to three times.",recovery_ready:"eGPU READY",recovery_ready_detail:"Test calculations passed. The internal model remains in use until the vehicle stops or you intervene with steering.",recovery_stopped:"eGPU recovery stopped",recovery_stopped_detail:"The internal model remains in use. Once parked, restart the comma. Recovery stopped because a check failed or the retry limit was reached.",title:"eGPU big model",checking:"Checking model",downloading:"Downloading",verifying:"Verifying download",ready:"Ready to compile",waiting_for_ignition:"Waiting for ignition",compiling:"Compiling eGPU model",compiled:"Ready to use",error:"eGPU setup failed",runtime_error:"eGPU unavailable",recovery_restart:"Once parked, open User / System below and press Reboot once. If this happens again, send a screenshot of this card for support.",waiting_for_network:"Waiting for network / automatic retry",installing:"Installing eGPU package",installed:"Installed · next start",active:"eGPU running",waiting_for_network_detail:"Keep an internet connection available. Installation retries automatically every 30 seconds.",installing_detail:"Preparing the verified model and runtime. The current driving model continues running.",installed_detail:"Installation is complete. The eGPU will be tried at the next ignition session; no restart is needed while driving.",active_detail:"The eGPU is currently running the driving model.",failure_dns:"Cannot resolve the download server address. Check the internet connection; retry is automatic.",failure_clock:"Certificate not yet valid. Waiting for the device clock/network to recover; retry is automatic.",failure_network:"Download connection interrupted. Retrying automatically.",failure_server:"The download server is temporarily unavailable. Retrying automatically.",failure_certificate:"Server certificate verification failed. The download was stopped; verification remains enabled.",failure_download:"The server refused the download or the requested file is unavailable.",failure_storage:"Not enough free storage to install the eGPU package.",failure_install:"The eGPU package could not be installed or verified. The saved diagnostic report contains the cause.",failure_rejected:"A previous failure blocked this model. Compiling again does not resolve a runtime failure. If it remains after a reboot, download the diagnostics and send them for support.",diagnostics:"Download diagnostics",failure_runtime:"The eGPU could not start or continue running. The internal model is selected.",failure_pcie:"The eGPU connection could not start. The internal model is selected.",failure_timeout:"The eGPU took too long to respond. The internal model is selected.",failure_usb:"Communication with the eGPU was interrupted. The internal model is selected.",checking_detail:"Checking the model catalog. You can keep using openpilot.",downloading_detail:"Download may continue while driving. Do not restart or power off until it finishes.",verifying_detail:"Checking the complete file. This can take a moment.",ready_detail:"Park, turn ignition on, then restart once to compile.",waiting_for_ignition_detail:"Keep the car parked and ignition on, then restart to compile.",compiling_detail:"Keep the car parked and ignition on. The screen will update when compilation finishes.",compiled_detail:"The eGPU big model is compiled and will be selected automatically.",error_detail:"The eGPU is not ready. Send a screenshot of this card for support.",restart:"Restart & compile",restart_confirm:"Park the car and turn ignition on. Restart now to compile the eGPU big model?",restart_requested:"Restart requested. Compilation will begin during boot.",host_ok:"Connected",host_warning:"High temperature",host_error:"Host error / connection lost",host_unknown:"Waiting for current host health",host_ip:"Host IP",host_no_ip:"Unavailable",host_not_connected:"Host not connected",host_unavailable:"Host connection unavailable",host_health_unavailable:"Host health unavailable"};function Y(e){return typeof getUIText=="function"?getUIText(`egpu_model_${e}`,sn[e]||e):sn[e]||e}function xo(e){let t=Math.max(0,Number(e)||0);return t<1024*1024?`${(t/1024).toFixed(0)} KB`:`${(t/(1024*1024)).toFixed(t>=1024*1024*1024?0:1)} MB`}function Oa(e){let t=Math.max(0,Math.floor(Date.now()/1e3-(Number(e)||Date.now()/1e3))),n=Math.floor(t/60);return`${String(n).padStart(2,"0")}:${String(t%60).padStart(2,"0")}`}function Ma(e){let t=String(e?.display_name||"").trim();if(t)return t;let n=String(e?.model_id||"").toLowerCase();return n.includes("pr39047")||n.includes("mdm-v1")||n.includes("6f4c0faf-256f2178")||n.includes("4bfb5340-e20cde17")?"Mountain Dew":n.includes("pr38932")||n.includes("cinque-v3")?"Cinque v3":n.includes("pr38823")||n.includes("cinque-v2")?"Cinque v2":n.includes("pr38771")||n.includes("cinque-terre")?"Cinque Terre":n.includes("bmrlanpv6")?"BMRLNAP v6":n.includes("pr38739")||n.includes("tgc")?"TGC":n.includes("pr38726")||n.includes("time-to-go")?"Time to Go":""}function Ua(e,t=Y("title")){let n=Ma(e);return n?`${n} · eGPU`:t}function ln(e=st){let t=document.getElementById("egpuModelCard");if(!t)return;if(st=e,!e?.available&&!e?.jetlink){t.hidden=!0;return}let n=String(e.state||"checking"),i=Number(e.progress),r=["checking","downloading","verifying","compiling","waiting_for_network","installing"].includes(n),c=document.getElementById("egpuModelState"),s=document.getElementById("egpuModelDetail"),g=document.getElementById("egpuModelRecovery");g.hidden=!0,g.textContent="";let m=document.getElementById("egpuModelProgress"),f=document.getElementById("egpuModelProgressBar"),C=document.getElementById("egpuModelAmount"),N=document.getElementById("btnEgpuCompileRestart"),M=document.getElementById("egpuModelDiagnostics");M&&(M.hidden=!e.available||n!=="error"||!!e.active,M.textContent=Y("diagnostics"));let u=document.getElementById("jetlinkHealth");u||(u=document.createElement("div"),u.id="jetlinkHealth",u.className="egpu-model-card__detail",s.after(u));let _=e.jetlink;if(u.hidden=!_,_){let z=["ok","warning","error","unknown"].includes(_.severity)?_.severity:"unknown";u.textContent=`${_.label} · ${Y(`host_${z}`)} · ${Y("host_ip")}: ${(_.addresses||[]).join(", ")||Y("host_no_ip")}`+(Number.isFinite(_.temp_c)?` · ${_.temp_c.toFixed(1)} °C`:"")+(_.reason?` · ${{"Host not connected":Y("host_not_connected"),"Host connection unavailable":Y("host_unavailable"),"Host health unavailable":Y("host_health_unavailable")}[_.reason]||_.reason}`:""),u.style.color=z==="error"?"#ff7474":z==="warning"?"#ffd166":""}if(!e.available){t.hidden=!1,t.dataset.state=_.severity==="error"?"error":"compiled",t.classList.remove("is-running"),document.getElementById("egpuModelTitle").textContent=`${_.label} · Cinque v2`,c.textContent=Y(`host_${_.severity||"unknown"}`),s.textContent="",m.hidden=!0,C.textContent="",N.hidden=!0;return}t.hidden=!1,t.dataset.state=n,t.classList.toggle("is-running",r),document.getElementById("egpuModelTitle").textContent=Ua(e);let h=e.active&&["compiled","installed"].includes(n),S=n==="error"&&["runtime","pcie","timeout","usb"].includes(e.error_code);c.textContent=Y(h?"active":S?"runtime_error":n);let P=`failure_${e.error_code}`;s.textContent=e.error_code?Y(Object.hasOwn(sn,P)?P:"error_detail"):Y(h?"active_detail":`${n}_detail`),g.hidden=!S,g.textContent=S?Y("recovery_restart"):"";let F=n==="downloading"&&Number.isFinite(i);m.hidden=!F,f.style.width=`${Math.min(100,Math.max(0,i||0))}%`,C.textContent=F?`${xo(e.downloaded_bytes)} / ${xo(e.total_bytes)} · ${i.toFixed(1)}%`:n==="compiling"?Oa(e.started_at):"",N.textContent=Y(At?"checking":"restart"),N.hidden=!["ready","waiting_for_ignition","error"].includes(n)||e.compiled||e.error_code==="rejected",N.disabled=At||!e.can_restart}async function Et(){try{ln(await getJson("/api/egpu/model"))}catch{st?.jetlink&&ln({...st,jetlink:{...st.jetlink,severity:"unknown",addresses:[],temp_c:null,reason:"",fresh:!1}})}finally{window.clearTimeout(rn),rn=window.setTimeout(Et,2e3)}}async function Ga(){if(!await appConfirm(Y("restart_confirm"),{title:Y("restart")}))return;let e=document.getElementById("btnEgpuCompileRestart");At=!0,e&&(e.disabled=!0,e.textContent=Y("checking"));try{await postJson("/api/egpu/model/compile-restart",{}),typeof showAppToast=="function"&&showAppToast(Y("restart_requested"));return}catch(t){At=!1,typeof showAppToast=="function"&&showAppToast(`${Y("error")}: ${t?.message||t}`,{tone:"error",duration:5e3}),typeof showError=="function"&&showError("eGPU model",t),await Et()}}function qa(){let e=document.getElementById("btnEgpuCompileRestart");e&&e.dataset.bound!=="1"&&(e.dataset.bound="1",e.addEventListener("click",Ga)),rn||Et()}var Ha=Object.freeze({init:qa,refresh:Et,render:ln});globalThis.CarrotEgpuModel=Ha;function It(e){let[t,n="single"]=e.split("@");return[t,n]}function No(e,t){return t==="single"?e:`${e}@${t}`}function $o(e){return[...new Set(Object.keys(e).map(t=>It(t)[0]))]}function ko(e,t){let n=!1;for(let i of t){if(i.reason!=="test")continue;let r=It(i.button)[0];r in e||(e[r]="none",n=!0),i.button in e||(e[i.button]="none",n=!0)}return n}var Lo=["accelCruise","decelCruise","gapAdjustCruise","lfaButton","cancel"],Ro=["none",...Lo,...Lo.map(e=>`${e}Long`),"laneLeft","laneRight","paddleDecel","carrotCruise"];var Wa={ko:{title:"블루투스 리모컨",close:"닫기",scan:"장치 검색 (30초)",on:"블루투스 켜기",off:"블루투스 끄기",help:"정차하고 크루즈를 해제한 상태에서 설정하세요. 매핑을 켜면 해당 장치의 원래 터치·키보드·볼륨 입력을 차단합니다. 차로변경은 기존 속도·측후방·조향 조건을 따릅니다. 휴대폰도 페어링할 수 있지만 통화·오디오·네트워크 기능은 제공하지 않습니다.",empty:"장치를 페어링 모드로 놓고 검색하세요. 기존 휴대폰에 연결되어 있다면 그 연결을 해제하세요.",pair:"페어링",connect:"연결",disconnect:"연결 해제",forget:"등록 해제",paired:"페어링됨",connected:"연결됨",profile:"입력 종류",generic:"일반 키보드 / HID",enabled:"캐럿 매핑 사용 (원래 HID 입력 차단)",save:"매핑 저장",saved:"저장했습니다",test:"키 등록 / 시험 시작 (120초)",stop:"시험 종료",waiting:"시험 준비 중…",testing:"시험 중: 차량 명령은 보내지 않습니다. 버튼을 누르면 아래에 표시됩니다. 새 키는 자동으로 목록에 추가됩니다.",idle:"입력 대기",blocked:"정차·크루즈 해제 상태 또는 입력 서비스가 확인되지 않아 설정할 수 없습니다.",grabbed:"HID 입력 독점 중",inactive:"입력 독점 대기 (연결·매핑 사용 여부 확인)",learnHelp:"먼저 입력 종류와 매핑을 저장한 뒤 시험을 시작하세요. 일반 HID는 시험 중 버튼을 눌렀다 떼어 등록합니다. 변경한 기능은 다시 저장해야 적용됩니다. Yiser-J6의 길게 누르기 지원 여부는 리모컨이 보내는 실제 입력에 따라 다릅니다.",none:"사용 안 함",accelCruise:"크루즈 + (accelCruise)",decelCruise:"크루즈 − (decelCruise)",laneLeft:"왼쪽 차로변경",laneRight:"오른쪽 차로변경",paddleDecel:"패들 감속",gapAdjustCruise:"크루즈 갭",up:"위",down:"아래",left:"왼쪽",right:"오른쪽",center:"중앙",1:"1",2:"2",cancel:"취소",confirm:"확인",pin:"PIN / 패스키",prompt:"페어링 확인",display:"상대 장치에 표시된 번호를 입력하거나 아래 번호를 확인하세요.",scanning:"검색 중",ready:"준비",unavailable:"블루투스 어댑터를 사용할 수 없습니다",noService:"입력 서비스가 실행되지 않았습니다",unsaved:"변경 내용을 저장하세요",sent:"차량 명령 전달",testEvent:"시험 입력",ignored:"명령 미전달",battery:"배터리",single:"짧게 누르기",double:"더블클릭",long:"길게 누르기",carrotCruise:"CarrotCruise (가속 제한 진입)",gestures:"더블클릭은 두 번의 뗌 간격이 350ms 이내일 때 인식합니다. 더블클릭을 지정한 키는 짧게 누르기도 350ms 기다립니다. ‘길게 누르기’에 기능을 지정하면 누른 채 700ms 후 실행합니다. 크루즈 +/−(10단위 동작 포함)는 이후 500ms 간격으로 반복하고, 나머지 기능은 한 번만 실행합니다. 버튼을 떼거나 연결이 끊기면 반복을 멈춥니다. 페달·핸들 버튼 조작, 크루즈 해제 또는 10초 경과 후에는 떼었다 다시 눌러야 합니다. 누름 시간을 보내지 않는 리모컨은 길게 누르기를 구분할 수 없습니다. 시험 중에는 모든 키에서 세 동작을 구분합니다.",multi:"여러 장치를 함께 사용할 수 있으며 장치별로 매핑을 저장합니다 (최대 16개 등록, 실제 동시 연결 수는 기기에 따라 다름). 입력은 순서대로 처리하며 400ms 지난 명령은 버립니다. 키 등록 시험은 선택한 한 장치에만 적용됩니다. 다른 활성 장치는 계속 동작합니다.",carrotHelp:"CarrotCruise는 기존 가속 제한 모드에 진입합니다. 다시 눌러도 해제되지 않으며 크루즈 +로 해제합니다. 실제 가속 제한은 해당 차종의 기존 지원과 CarrotCruiseDecel 설정을 따릅니다.",mappingOn:"매핑 켜짐",mappingOff:"매핑 꺼짐",receiving:"입력 수신 중",accelCruiseLong:"크루즈 + 길게 (10단위 조절)",decelCruiseLong:"크루즈 − 길게 (10단위 조절)",gapAdjustCruiseLong:"갭 길게 (주행 모드 순환)",lfaButton:"LFA 버튼",lfaButtonLong:"LFA 길게 (차선 모드 전환)",cancelCruise:"크루즈 취소",cancelLong:"취소 길게 (크루즈·조향 해제)",buttonHelp:"크루즈 +/−는 크루즈가 꺼져 있으면 기존 활성화 조건을 거쳐 켜기를 요청합니다. 버튼의 ‘길게’ 동작도 기능 목록에서 선택할 수 있습니다. 예: 리모컨 짧게 누르기 → 크루즈 + 길게. 이 경우 추가로 기다리지 않고 핸들 버튼의 롱 동작을 한 번 실행합니다. LFA와 갭의 동작은 기존 설정을 따릅니다."},en:{title:"Bluetooth remotes",close:"Close",scan:"Scan devices (30s)",on:"Enable Bluetooth",off:"Disable Bluetooth",help:"Configure while stationary with cruise disengaged. Enabling a mapping captures this device's original touch, keyboard and volume input. Lane changes retain existing speed, blind-spot and steering conditions. Phones can pair; calls, audio and networking are not provided.",empty:"Put your device in pairing mode and scan. Disconnect it from its previous phone if needed.",pair:"Pair",connect:"Connect",disconnect:"Disconnect",forget:"Forget",paired:"Paired",connected:"Connected",profile:"Input profile",generic:"Generic keyboard / HID",enabled:"Use Carrot mapping (capture original HID input)",save:"Save mapping",saved:"Saved",test:"Learn / test keys (120s)",stop:"End test",waiting:"Preparing test…",testing:"Test mode: no vehicle commands. Press and release a button to see it below. New keys are added to the list automatically.",idle:"Waiting for input",blocked:"Setup requires a fresh stationary, disengaged state and the input service.",grabbed:"Exclusive HID capture active",inactive:"Waiting for HID capture (check connection and mapping)",learnHelp:"Save the input profile and mapping before testing. Press and release each generic HID button during the test to learn it. Save again to apply edited actions. Long-press support on Yiser-J6 depends on the events reported by the remote.",none:"No action",accelCruise:"Cruise + (accelCruise)",decelCruise:"Cruise − (decelCruise)",laneLeft:"Lane change left",laneRight:"Lane change right",paddleDecel:"Paddle deceleration",gapAdjustCruise:"Cruise gap",up:"Up",down:"Down",left:"Left",right:"Right",center:"Center",1:"1",2:"2",cancel:"Cancel",confirm:"Confirm",pin:"PIN / passkey",prompt:"Pairing confirmation",display:"Enter the code shown on the other device, or verify the code below.",scanning:"Scanning",ready:"Ready",unavailable:"Bluetooth adapter unavailable",noService:"Input service is not running",unsaved:"Save your changes",sent:"Vehicle command sent",testEvent:"Test input",ignored:"Command not sent",battery:"Battery",single:"Short press",double:"Double click",long:"Long press",carrotCruise:"CarrotCruise (enter acceleration limit)",gestures:"Double clicks require releases within 350ms. Assigning a double click delays the short press by 350ms. An assigned Long press fires after holding for 700ms. Cruise +/− (including 10-step actions) then repeat every 500ms; other actions fire once. Release or disconnection stops repetition. After a pedal or steering-wheel button input, cruise disengagement, or 10 seconds, release and press again. Remotes that do not report hold duration cannot distinguish long presses. Test mode recognizes all three gestures on every key.",multi:"Use multiple devices with separate mappings (up to 16 saved; simultaneous connections depend on hardware). Commands are processed in order and expire after 400ms. Learning tests only the selected device; other enabled devices continue operating.",carrotHelp:"CarrotCruise enters the existing acceleration-limiting mode. Repeating it keeps the mode on; Cruise + exits it. Actual limiting follows existing vehicle support and CarrotCruiseDecel.",mappingOn:"Mapping on",mappingOff:"Mapping off",receiving:"Receiving input",accelCruiseLong:"Cruise + long (10-step adjustment)",decelCruiseLong:"Cruise − long (10-step adjustment)",gapAdjustCruiseLong:"Gap long (cycle driving mode)",lfaButton:"LFA button",lfaButtonLong:"LFA long (toggle lane-line mode)",cancelCruise:"Cancel cruise",cancelLong:"Cancel long (cruise and lateral off)",buttonHelp:"Cruise +/− request engagement through existing checks when cruise is off. Native long-button actions are separate choices: for example, a short remote press can run Cruise + long once, with no additional hold delay. LFA and gap actions follow existing settings."}},Po={up:"accelCruise",down:"decelCruise",left:"laneLeft",right:"laneRight",center:"paddleDecel",1:"gapAdjustCruise",2:"none"},Fa=Ro,ce,B,ae,ee,Do,cn,xt=!1,dn,ve=!1,x=e=>Wa[typeof LANG<"u"&&LANG==="ko"?"ko":"en"][e]||e,Bo=e=>x(e==="cancel"?"cancelCruise":e),Oo=e=>{let[t,n]=It(e);return`${x(t)} · ${x(n)}`},Ue=new Set,D=(e,t,n)=>{let i=document.createElement(e);return t!=null&&(i.textContent=t),n&&(i.className=n),i},Z=e=>ce.querySelector(`[data-bt="${e}"]`),we=(e,t="div")=>{let n=D(t);return n.dataset.bt=e,n};function de(e,t,n=!0){let i=D("button",x(e),"btn");return i.type="button",n&&(i.dataset.mutation="true"),i.onclick=n?()=>Ka(t):t,i}async function pe(e,t){let n=await fetch(`/api/bluetooth${e?`/${e}`:""}`,e?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t||{})}:{cache:"no-store"});if(!n.ok)throw new Error(await n.text());return n.json()}async function Ka(e){if(!xt){xt=!0,Ke(),Z("error").textContent="";try{await e(),await Uo()}catch(t){Z("error").textContent=t.message}finally{xt=!1,Ke()}}}function Ke(){ce?.querySelectorAll('[data-mutation="true"]').forEach(e=>{e.disabled=xt||!B?.runtime?.stationary})}function ja(e){if(ve){Z("error").textContent=x("unsaved");return}ae=e.address;let t=B.config.devices[ae];ee=t?structuredClone(t):{name:e.name,profile:/yiser-j6/i.test(e.name)?"yiser-j6":"generic",enabled:!1,mapping:{}},!t&&ee.profile==="yiser-j6"&&(ee.mapping={...Po}),ve=!1,Ue=new Set((B.runtime.recent_events||[]).map(n=>n.id)),un()}function un(){let e=Z("editor");if(e.replaceChildren(),!ee)return;e.append(D("h3",`${ee.name||ae} · ${ae}`));let t=D("label",x("profile")),n=D("select");for(let[g,m]of[["yiser-j6","Yiser-J6"],["generic",x("generic")]]){let f=D("option",m);f.value=g,n.append(f)}n.value=ee.profile,n.dataset.mutation="true",n.onchange=()=>{ee.profile=n.value,ee.mapping=n.value==="yiser-j6"?{...Po}:{},ve=!0,un()},t.append(n),e.append(t);let i=D("label"),r=D("input");r.type="checkbox",r.checked=ee.enabled,r.dataset.mutation="true",r.onchange=()=>{ee.enabled=r.checked,ve=!0},i.append(r,document.createTextNode(x("enabled"))),e.append(i);let c=we("mapping");e.append(c),Mo();let s=D("div",null,"bt-actions");s.append(de("save",async()=>{await pe("device-config",{address:ae,device:ee}),ve=!1,Z("notice").textContent=x("saved")}),de("test",async()=>{if(ve||!B.config.devices[ae])throw new Error(x("unsaved"));Ue=new Set((B.runtime.recent_events||[]).map(g=>g.id)),await pe("learn",{address:ae,enabled:!0}),Z("notice").textContent=x("waiting")}),de("stop",async()=>{await pe("learn",{address:ae,enabled:!1})})),e.append(s,D("p",x("learnHelp")),D("p",x("gestures")),D("p",x("buttonHelp")),D("p",x("carrotHelp")),we("notice"),we("capture"),we("event","output")),Ke()}function Mo(){let e=Z("mapping");e.replaceChildren();for(let t of $o(ee.mapping)){let n=D("fieldset",null,"bt-mapping-row");n.append(D("legend",x(t)));for(let i of["single","double","long"]){let r=No(t,i),c=D("label",x(i)),s=D("select");s.dataset.mutation="true",s.setAttribute("aria-label",Oo(r));for(let g of Fa){let m=D("option",Bo(g));m.value=g,s.append(m)}s.value=ee.mapping[r]||"none",s.onchange=()=>{ee.mapping[r]=s.value,ve=!0},c.append(s),n.append(c)}e.append(n)}Ke()}function Qa(){let e=[...B.devices].sort((i,r)=>Number(r.paired)-Number(i.paired)||i.address.localeCompare(r.address)),t=JSON.stringify(e.map(({address:i,name:r,paired:c,connected:s,battery:g})=>({address:i,name:r,paired:c,connected:s,battery:g,enabled:B.config.devices[i]?.enabled,grabbed:B.runtime.grabbed?.includes(i)})));if(t===cn)return;cn=t;let n=Z("devices");n.replaceChildren(),B.devices.length||n.append(D("p",x("empty")));for(let i of e){let r=D("div",null,"bt-device"),c=D("strong",i.name||i.address);r.append(c,D("div",`${i.address} · ${i.connected?x("connected"):i.paired?x("paired"):""}${i.battery!=null?` · ${x("battery")} ${i.battery}%`:""}`)),B.config.devices[i.address]&&r.append(D("div",`${x(B.config.devices[i.address].enabled?"mappingOn":"mappingOff")}${B.runtime.grabbed?.includes(i.address)?` · ${x("receiving")}`:""}`));let s=D("div",null,"bt-actions");i.paired?s.append(de(i.connected?"disconnect":"connect",()=>pe(i.connected?"disconnect":"connect",{address:i.address})),de("forget",async()=>{await pe("forget",{address:i.address}),ae===i.address&&(ae=ee=null,un())}),de("profile",()=>ja(i),!1)):s.append(de("pair",()=>pe("pair",{address:i.address}))),r.append(s),n.append(r)}}function za(){let e=B.prompt;if((e?.id||null)===dn)return;dn=e?.id||null;let t=Z("prompt");if(t.replaceChildren(),!e)return;t.append(D("h3",x("prompt")),D("p",x("display")),D("strong",e.value));let n;["RequestPinCode","RequestPasskey"].includes(e.kind)&&(n=D("input"),n.placeholder=x("pin"),n.setAttribute("aria-label",x("pin")),n.maxLength=e.kind==="RequestPasskey"?6:16,t.append(n)),e.kind.startsWith("Display")||t.append(de("confirm",()=>pe("answer",{id:e.id,value:n?n.value:!0}))),t.append(de("cancel",()=>pe("cancel")))}async function Uo(){if(!ce?.open)return;B=await pe(),Z("status").textContent=B.runtime.stationary?B.available?B.adapters.some(t=>t.discovering)?x("scanning"):x("ready"):x("unavailable"):x("blocked"),Z("radio").textContent=x(B.radioEnabled?"off":"on"),Z("service").textContent=[B.error,!B.runtime.alive&&x("noService"),...Object.values(B.runtime.errors||{}),B.pair?.error].filter(Boolean).join(" · "),Qa(),za();let e=B.runtime.learning;if(ee){Z("capture").textContent=(B.runtime.grabbed||[]).includes(ae)?x("grabbed"):x("inactive"),Z("notice").textContent=e?.address===ae?`${x("testing")} (${Math.max(0,Math.ceil(e.until-B.runtime.time))}s)`:ve?x("unsaved"):"";let t=B.runtime.recent_events||(B.runtime.last_event?[B.runtime.last_event]:[]),n=t.filter(i=>i.address===ae&&!Ue.has(i.id));for(let i of n)Ue.add(i.id),Z("event").textContent=`${Oo(i.button)} → ${Bo(i.action)} · ${x(i.reason==="test"?"testEvent":i.emitted?"sent":"ignored")}`;e?.address===ae&&ko(ee.mapping,n)&&(ve=!0,Mo()),Ue.size>256&&(Ue=new Set(t.map(i=>i.id)))}Ke()}function Va(){if(ce?.open)return;ce?.remove(),B=ae=ee=dn=cn=null,ve=!1,Ue=new Set,ce=D("dialog",null,"carrot-bt-dialog"),ce.setAttribute("aria-label",x("title"));let e=D("div",null,"bt-actions");e.append(D("h2",x("title")),de("close",()=>ce.close(),!1));let t=D("div",null,"bt-actions"),n=de("on",()=>pe("radio",{enabled:!B.radioEnabled}));n.dataset.bt="radio",t.append(n,de("scan",()=>pe("scan")),de("cancel",()=>pe("cancel"))),ce.append(e,D("p",x("help")),D("p",x("multi")),we("status"),we("service"),we("error"),t,we("prompt"),we("devices"),we("editor")),Z("error").setAttribute("role","alert"),Z("status").setAttribute("role","status"),ce.addEventListener("close",()=>{clearTimeout(Do)}),document.body.append(ce),ce.showModal(),Ke();let i=!1,r=async()=>{try{await Uo(),i&&(Z("error").textContent=""),i=!1}catch(c){i=!0,Z("error").textContent=c.message}finally{ce.open&&(Do=setTimeout(r,1e3))}};r()}globalThis.CarrotBluetooth={init(){let e=document.getElementById("btnToolsBluetooth");e&&(e.textContent=x("title"),e.onclick=Va)}};var Ge=null,je=null,dt=null,ct=0,lt=null;function k(e,t,n=null){return typeof getUIText=="function"?getUIText(e,t,n):t}function Lt(e){return String(e??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function $t(e){typeof appDialog>"u"||!appDialog||appDialog.classList.toggle("app-dialog--tools-qr",!!e)}function Go(e,t,n=null,i=null){let r=k(e,t,n);return typeof showAppToast=="function"&&showAppToast(r,i||void 0),r}function Nt(){je&&(cancelAnimationFrame(je),je=null),dt&&(clearTimeout(dt),dt=null),ct=0,Ge&&(Ge.getTracks().forEach(e=>e.stop()),Ge=null)}function $e(e,t){e&&(e.dataset.scanState=t||"idle")}function Ja(e,t){let n=Math.min(e,t)*.74;return{left:(e-n)/2,top:(t-n)/2,right:(e+n)/2,bottom:(t+n)/2,width:n,height:n}}function Ya(e){let t=[e?.location?.topLeftCorner,e?.location?.topRightCorner,e?.location?.bottomRightCorner,e?.location?.bottomLeftCorner].filter(m=>Number.isFinite(Number(m?.x))&&Number.isFinite(Number(m?.y)));if(t.length<4)return null;let n=t.map(m=>Number(m.x)),i=t.map(m=>Number(m.y)),r=Math.min(...n),c=Math.max(...n),s=Math.min(...i),g=Math.max(...i);return{left:r,top:s,right:c,bottom:g,width:c-r,height:g-s,centerX:(r+c)/2,centerY:(s+g)/2}}function Xa(e,t,n){let i=Ja(t,n),r=Ya(e);if(!r||r.width<=0||r.height<=0)return!1;let c=i.width*.18,s=i.height*.18,g=r.centerX>=i.left&&r.centerX<=i.right&&r.centerY>=i.top&&r.centerY<=i.bottom,m=r.left>=i.left-c&&r.right<=i.right+c&&r.top>=i.top-s&&r.bottom<=i.bottom+s,f=r.width>=i.width*.34&&r.height>=i.height*.34&&r.width<=i.width*1.18&&r.height<=i.height*1.18;return g&&m&&f}function Ho(e){if(typeof qrcode!="function")throw new Error("QR library not loaded");let t=qrcode(0,"L"),n=String(e||"");return t.addData(e,n.startsWith("CQR3:")||n.startsWith("CQR4:")?"Alphanumeric":"Byte"),t.make(),t}function Za(e){return Ho(e).createSvgTag(2,4)}function er(e){let t=Ho(e),n=t.getModuleCount(),i=4,r=4,c=(n+r*2)*i,s=document.createElement("canvas");s.width=c,s.height=c;let g=s.getContext("2d");g.fillStyle="#ffffff",g.fillRect(0,0,c,c),g.fillStyle="#000000";for(let m=0;m|]+/g,"-").replace(/\s+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,64)||"carrot"}function qo(e=new Date){let t=String(e.getFullYear()),n=String(e.getMonth()+1).padStart(2,"0"),i=String(e.getDate()).padStart(2,"0");return`${t}${n}${i}`}async function nr(){try{let e=await bulkGet(["GitBranch"]);return`${tr(e.GitBranch)}-${qo()}.png`}catch{return`carrot-${qo()}.png`}}async function Wo(){if(lt)return lt;lt=(async()=>{let e=await getJson("/api/params_qr_dependency");if(console.info("[carrot][qr-dependency]",e),e.installed)return e;Go("qr_configuring","Please wait...");let t=await postJson("/api/params_qr_dependency/ensure",{});if(console.info("[carrot][qr-dependency]",t),!t.ok||!t.installed)throw new Error(t.error||k("qr_config_failed","QR feature could not be configured."));return Go("qr_config_done","QR feature is ready."),t})();try{return await lt}finally{lt=null}}function or(e,t){let n=document.createElement("a");n.href=e,n.download=t,document.body.appendChild(n),n.click(),n.remove()}function ir(e){let t=document.createElement("canvas"),n=Math.min(1,1600/Math.max(e.naturalWidth||e.width,e.naturalHeight||e.height,1));t.width=Math.max(1,Math.floor((e.naturalWidth||e.width)*n)),t.height=Math.max(1,Math.floor((e.naturalHeight||e.height)*n));let i=t.getContext("2d",{willReadFrequently:!0});i.drawImage(e,0,0,t.width,t.height);let r=i.getImageData(0,0,t.width,t.height);return(typeof jsQR=="function"?jsQR(r.data,t.width,t.height):null)?.data||""}function ar(e,t,n){if(!e)return;let i=new FileReader;i.onload=()=>{let r=new Image;r.onload=()=>{let c=ir(r);c?t(c):n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.onerror=()=>{n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.src=String(i.result||"")},i.readAsDataURL(e)}function rr(e={}){return typeof renderSettingsDiffSummary=="function"?renderSettingsDiffSummary(e):""}function sr(e){return typeof renderSettingsDiffHtml=="function"?renderSettingsDiffHtml(e,{currentLabel:k("qr_restore_current_value","Current"),nextLabel:k("qr_restore_backup_value","Restore"),changedLabel:k("qr_restore_changed","Changed")}):rr(e?.summary)}async function lr(){try{await Wo();let e=await getJson("/api/params_qr_backup"),t=e.format||String(e.payload||"").split(/[.:]/,1)[0]||"unknown";console.info("[carrot][qr-backup]",{format:t,version:e.version,count:e.count,payloadChars:e.payload_chars,rawBytes:e.json_bytes,compressedBytes:e.compressed_bytes,ecc:e.ecc});let n=Za(e.payload),i=er(e.payload),r=await nr(),c=k("qr_backup_title","QR Backup"),s=` +${p}`}function pi(o,a){let l=String(o?.status||""),d=String(o?.action||"").trim(),p=vn(o),b=p?Sn(o,p):null,T=ui(o),A=Number(o?.updated_at||o?.created_at||0),G=!!o?.payload?.notice,O=`job-${o?.id||a}`,V=p?`${O}:${p.after||p.before||""}`:"",E=hn(o);return l==="running"?E=String(o?.message||"").trim()||si(d)||E:l==="failed"?E=li(d)||E:G?E=kt(T):p?E=U(b?.card||p.card_summary||p.display||"")||kt(T):E=Dt(o)||ri(d)||E,{id:O,ackId:V,source:l==="running"?"current":"history",text:T,title:bn(o),summary:E,updateCard:b?{label:b.label||"",messages:Array.isArray(b.messages)?b.messages:[],stats:b.stats||null}:null,status:l,highlight:!!(p?.updated&&l==="done"&&!L.has(V)),updateKind:p?"git_pull":"",timestamp:A,timeLabel:ii(A)}}function Tn(o,a,l){let d=U(o);return{id:`${a}-${l}-${Se(d)}`,source:a,text:d,title:ni(d),summary:kt(d),timestamp:0,timeLabel:""}}function pt(o={}){let a=U(o.currentText),l=U(o.historyText),d=Array.isArray(o.jobs)?o.jobs.filter(Boolean):[],p=[];return d.slice(0,i).forEach((b,T)=>p.push(pi(b,T))),a&&p.push(Tn(a,"current",0)),l&&l.split(/\n{2,}/).map(b=>b.trim()).filter(Boolean).slice(-i).forEach((b,T)=>p.push(Tn(b,"history",T))),{entries:p,hasHistory:!!l||d.some(b=>String(b?.status||"")!=="running"),state:{jobs:d,historyText:l,currentText:a}}}function Cn(o,a){let l=document.createElement("button");return l.type="button",l.className="tools-console-log__clearBtn",l.textContent=y("tools_notifications_clear","Clear"),l.disabled=!o.hasHistory,l.addEventListener("click",d=>{d.stopPropagation(),f="",a.options.onClear?.()}),l}function An(o){let a=document.createElement("span");a.className="tools-console-log__header",a.dataset.toolsNotificationMode=o.mode;let l=document.createElement("span");if(l.className="tools-console-log__headerTitle",l.textContent=y("tools_notifications","Notifications"),a.appendChild(l),o.mode===n.LANDSCAPE){let d=document.createElement("span");d.className="tools-console-log__headerActions",d.appendChild(Cn(o.model,o)),a.appendChild(d)}return a}function gi(){let o=document.createElement("span");return o.className="tools-console-log__empty",o.textContent=y("tools_notifications_empty","No notifications"),o}function Pt(){return!!t.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches}function mi(o){let a=String(o||"").trim().split(",")[0].trim();if(!a)return 0;let l=Number.parseFloat(a);return Number.isFinite(l)?a.endsWith("ms")?l:l*1e3:0}function En(o){if(Pt())return 0;try{let a=t.getComputedStyle?.(o),l=mi(a?.getPropertyValue("--tools-detail-duration"));return Math.max(180,l||320)+40}catch{return 360}}function Bt(){F&&(t.clearTimeout(F),F=null,z="",j=0,te=null)}function fi(o){return!!(F&&z&&Date.now()bi(a.id,o),{passive:!0}))}function gt(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`)}function xn(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.transition="none",a.style.animation="none",a.style.opacity="1",a.style.transform="translateY(0)",gt(o),a.getBoundingClientRect())}function yi(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");a&&(a.style.maxHeight=`${a.scrollHeight}px`,a.style.opacity="1",a.style.transform="translateY(0)",a.getBoundingClientRect(),o.classList.add("is-collapsing"),o.classList.remove("is-expanded"),o.setAttribute("aria-expanded","false"),t.requestAnimationFrame(()=>{a.style.maxHeight="0px",a.style.opacity="0",a.style.transform="translateY(-6px)"}))}function oe(o){return o?(o.dataset.toolsNotificationMode||Te())===n.PORTRAIT?o:o?.querySelector?.(".tools-console-log__body")||o||null:null}function re(o,a){return!o||!a?null:Array.from(o.querySelectorAll("[data-notification-id]")).find(l=>l.dataset.notificationId===a)||null}function wi(o,a){return JSON.stringify({mode:a,active:f,hasHistory:o.hasHistory,entries:o.entries.map(l=>({id:l.id,source:l.source,title:l.title,summary:l.summary,updateCard:l.updateCard||null,text:l.text,status:l.status||"",highlight:!!l.highlight,updateKind:l.updateKind||"",hasTime:Number(l.timestamp||0)>0}))})}function vi(o){return String(o?.status||"")==="running"}function Si(o){return!!o&&!vi(o)}function Ln(o){let a=oe(o);return a?Array.from(a.querySelectorAll("[data-notification-id]")):[]}function Ti(o,a){let l=Ln(o);return l.length===a.entries.length&&l.every((d,p)=>d.dataset.notificationId===a.entries[p]?.id)}function Ot(o,a){o&&o.textContent!==a&&(o.textContent=a)}function qe(o,a,l){let d=document.createElement("span");return d.className=a,d.textContent=l,o.appendChild(d),d}function Nn(o,a){if(!o)return;if(o.replaceChildren(),o.classList.toggle("tools-console-log__cardBody--update",!!a.updateCard),!a.updateCard){o.textContent=a.summary;return}let l=String(a.updateCard.label||"").trim();l&&qe(o,"tools-console-log__updateLabel",l);let d=Array.isArray(a.updateCard.messages)?a.updateCard.messages.filter(Boolean):[];if(d.length){let b=document.createElement("span");b.className="tools-console-log__updateMessages",d.slice(0,3).forEach(T=>qe(b,"tools-console-log__updateMessage",T)),o.appendChild(b)}let p=a.updateCard.stats&&typeof a.updateCard.stats=="object"?a.updateCard.stats:null;if(p){let b=document.createElement("span");b.className="tools-console-log__updateStats",p.commits&&qe(b,"tools-console-log__updateStat",p.commits),p.files&&qe(b,"tools-console-log__updateStat",p.files),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--add",`+${Math.max(0,ne(p.insertions))}`),qe(b,"tools-console-log__updateStat tools-console-log__updateStat--delete",`-${Math.max(0,ne(p.deletions))}`),o.appendChild(b)}else!d.length&&l&&(o.textContent=l)}function Ci(o,a,l){let d=f===a.id;o.classList.toggle("tools-console-log__current",a.source==="current"),o.classList.toggle("tools-console-log__history",a.source==="history"),o.classList.toggle("is-expanded",d),o.classList.toggle("is-git-update",a.updateKind==="git_pull"),o.classList.toggle("is-unread-update",!!a.highlight),o.dataset.toolsNotificationMode=l.mode,o.setAttribute("aria-expanded",d?"true":"false"),Ot(o.querySelector(".tools-console-log__cardTitle"),a.title);let p=o.querySelector(".tools-console-log__cardHead"),b=o.querySelector(".tools-console-log__cardTime");a.timeLabel?(!b&&p&&(b=document.createElement("span"),b.className="tools-console-log__cardTime",p.appendChild(b)),Ot(b,a.timeLabel)):b&&b.remove(),Nn(o.querySelector(".tools-console-log__cardBody"),a),Ot(o.querySelector(".tools-console-log__detail"),a.text)}function Ai(o,a,l){Ln(o).forEach((d,p)=>Ci(d,a.entries[p],l)),o.querySelectorAll(".tools-console-log__clearBtn").forEach(d=>{d.disabled=!a.hasHistory})}function Mt(o,a){if(!o)return;let l=new Map(a.map(d=>[d.id,d.timeLabel||""]));o.querySelectorAll("[data-notification-id]").forEach(d=>{let p=l.get(d.dataset.notificationId)||"",b=d.querySelector(".tools-console-log__cardTime");b&&b.textContent!==p&&(b.textContent=p)})}function Ei(o){f&&(o.entries.some(a=>a.id===f)||(f=""))}function $n(o){if(!o?.entries?.length)return null;let a=null,l=0;return o.entries.forEach(d=>{let p=Number(d.timestamp||0);Number.isFinite(p)&&p>0&&p>=l&&(a=d,l=p)}),a||o.entries.slice().reverse().find(d=>d.source==="current")||o.entries[o.entries.length-1]}function Ii(o,a,l=!0,d={}){let p=oe(o),T=re(p,a)?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:!1,instant:d.instant===!0,smoothOnce:d.smoothOnce===!0,stableDetail:d.stableDetail===!0,mode:o?.dataset?.toolsNotificationMode||Te(),scrollTop:p?.scrollTop||0,cardTop:T?T.top:null}}function kn(o,a,l={}){if(!a)return"";let d=l.expand!==!1;return Bt(),f=d?a:"",_=Ii(o,a,d,l),v.delete(a),a}function xi(o=C,a={}){let l=pt(N),d=$n(l);if(!d)return"";let p=kn(o,d.id,a);return o&&Ve(o,l.state,M,{force:!0,preserveScroll:!1}),p}function Li(o,a=f){let l=oe(o);if(!l)return null;let d=re(l,a),p=o?.dataset?.toolsNotificationMode||Te();return{anchorId:a,mode:p,scrollTop:l.scrollTop||0,cardTop:d?d.getBoundingClientRect().top:null}}function Ni(o,a,l,d){let p=oe(o),b=re(p,a),T=o?.dataset?.toolsNotificationMode||Te(),A=b?.getBoundingClientRect?.();return{id:a,expanded:l,keyboard:d,mode:T,scrollTop:p?.scrollTop||0,cardTop:A?A.top:null}}function $i(o,a){let l=oe(o);if(!l||!a)return;let d=a.scrollTop;if(a.mode!==n.LANDSCAPE&&a.anchorId&&a.cardTop!==null){let p=re(l,a.anchorId);p&&(d+=p.getBoundingClientRect().top-a.cardTop)}l.scrollTop=Math.max(0,d)}function Ut(o,a){let l=Math.max(0,(o?.scrollHeight||0)-(o?.clientHeight||0));return Math.min(Math.max(0,a),l)}function ki(o,a){let l=oe(o),d=re(l,a?.id);if(!l||!a)return;if(!d||a.cardTop===null){l.scrollTop=Ut(l,a.scrollTop||0);return}let p=d.getBoundingClientRect().top-a.cardTop;if(Math.abs(p)<1)return;let b=(a.scrollTop||0)+p;l.scrollTop=Ut(l,b)}function ke(o,a,l="settled",d={}){let p=oe(o),b=re(p,a?.id);if(!p||!b)return;if(a.keyboard)try{b.focus({preventScroll:!0})}catch{}let T=p.getBoundingClientRect(),A=b.getBoundingClientRect(),G=b.querySelector(".tools-console-log__cardHead")?.getBoundingClientRect?.()||A,O=o.dataset.toolsNotificationMode||Te(),V=O===n.PORTRAIT?18:12,E=O===n.PORTRAIT?92:20,X=Math.max(T.height-V-E,1),Q=T.top+V,ge=T.bottom-E,Ce=Math.max(0,Number(d.expandedHeightDelta)||0),se=Ce>0?{top:A.top,bottom:A.bottom+Ce,height:A.height+Ce}:A,I=l==="opening"?G:se,le=0;if(!a.smoothOnce&&!a.stableDetail&&!a.instant&&(se.top>=Q&&se.bottom<=ge||G.top>=Q&&G.bottom<=ge&&se.height>=X)||l==="opening"&&I.top>=Q&&I.bottom<=ge||(a?.expanded&&l!=="opening"&&se.height>=X?le=se.top-Q:I.topge&&(le=I.bottom-ge),Math.abs(le)<2))return;let Je=Ut(p,p.scrollTop+le),Ae=d.behavior||(Pt()?"auto":"smooth");try{p.scrollTo({top:Je,behavior:Ae})}catch{p.scrollTop=Je}}function Ri(o){let a=o?.querySelector?.(".tools-console-log__detailWrap");if(!a)return 0;let l=a.getBoundingClientRect();return Math.max(0,a.scrollHeight-l.height)}function Di(o,a){if(!a?.id)return;h+=1;let l=h;S&&(t.clearTimeout(S),S=null);let d=Te();t.requestAnimationFrame(()=>{if(l!==h)return;if(a.instant){ke(o,a,"settled",{behavior:"auto"});return}if(a.smoothOnce){ke(o,a,"settled");return}if(!a.stableDetail){a.keyboard&&ke(o,a,"opening");return}if(a.expanded&&!Pt()){let b=oe(o),T=re(b,a.id),A=Ri(T);ke(o,a,"concurrent",{expandedHeightDelta:A});let G=d===n.PORTRAIT?380:360;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},G);return}ke(o,a,"opening");let p=a.expanded?d===n.PORTRAIT?380:360:d===n.PORTRAIT?180:160;S=t.setTimeout(()=>{S=null,l===h&&ke(o,a,"settled")},p)})}function Pi(o){let a=document.createElement("span");a.className="tools-console-log__detailWrap";let l=document.createElement("span");l.className="tools-console-log__detailInner";let d=document.createElement("span");d.className="tools-console-log__detailTitle",d.textContent=y("tools_notification_detail","Detail log"),l.appendChild(d);let p=document.createElement("span");return p.className="tools-console-log__detail",p.textContent=o.text,hi(p,o),l.appendChild(p),a.appendChild(l),a}function Bi(o,a){let l=f===o.id,d=document.createElement("div");d.className=`tools-console-log__card tools-console-log__${o.source}`,d.classList.toggle("is-expanded",l),d.classList.toggle("is-git-update",o.updateKind==="git_pull"),d.classList.toggle("is-unread-update",!!o.highlight),d.dataset.notificationId=o.id,d.dataset.toolsNotificationMode=a.mode,d.setAttribute("role","button"),d.setAttribute("tabindex","0"),d.setAttribute("aria-expanded",l?"true":"false");let p=document.createElement("span");p.className="tools-console-log__cardTitle",p.textContent=o.title;let b=document.createElement("span");if(b.className="tools-console-log__cardHead",b.appendChild(p),o.timeLabel){let E=document.createElement("span");E.className="tools-console-log__cardTime",E.textContent=o.timeLabel,b.appendChild(E)}d.appendChild(b);let T=document.createElement("span");T.className="tools-console-log__cardBody",Nn(T,o),d.appendChild(T),d.appendChild(Pi(o));let A=0,G=0,O=!1,V=(E,X=!1)=>{if(O){O=!1,E.preventDefault();return}let Q=!l;if(_=Ni(a.out,o.id,Q,X),Qo(o,d),!Q){Bt(),f="",v.delete(o.id),z=o.id,te=a.out,yi(d),j=Date.now()+En(d),F=t.setTimeout(()=>{F=null,z="",j=0,te=null,Ve(a.out,a.model.state,a.options,{force:!0})},En(d));return}Bt(),f=Q?o.id:"",Ve(a.out,a.model.state,a.options)};return d.addEventListener("pointerdown",E=>{A=E.clientX,G=E.clientY,O=!1},{passive:!0}),d.addEventListener("pointermove",E=>{Math.hypot(E.clientX-A,E.clientY-G)>8&&(O=!0)},{passive:!0}),d.addEventListener("click",E=>{V(E,E.detail===0)}),d.addEventListener("keydown",E=>{E.key!=="Enter"&&E.key!==" "||(E.preventDefault(),V(E,!0))}),d}function Oi(o,a){let l=document.createElement("span");return l.className="tools-console-log__footer",l.dataset.toolsNotificationMode=a.mode,l.appendChild(Cn(o,a)),l}function Mi(o,a){let l=document.createDocumentFragment();return o.entries.length?(o.entries.forEach(d=>l.appendChild(Bi(d,a))),l):(l.appendChild(gi()),l)}function Rn(o){let a=document.createElement("span");return a.className="tools-console-log__body",a.appendChild(Mi(o.model,o)),a}function Ui(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a}function Gi(o){let a=document.createDocumentFragment();return a.appendChild(An(o)),a.appendChild(Rn(o)),a.appendChild(Oi(o.model,o)),a}function Ve(o,a={},l={},d={}){if(!o)return;ti();let p=_,b=d.preserveScroll===!1||p?null:Li(o),T=Te(),A=pt(a);if(N=A.state,M=l,ci(A.state),pn(o,T),!d.force&&fi(o)){Mt(o,A.entries),Qe(A.entries);return}Ei(A);let O=l.autoFocusLatest===!0&&!d.skipAutoFocusLatest?$n(A):null;Si(O)&&O.id!==$&&($=O.id,kn(o,O.id,{expand:!0,smoothOnce:!0,stableDetail:!0}),p=_,b=null);let V=wi(A,T),E={out:o,mode:T,model:A,options:l};if(!p&&V===w&&o.childElementCount>0){Mt(o,A.entries),Qe(A.entries);return}if(!p&&o.childElementCount>0&&Ti(o,A)){Ai(o,A,E),w=V,Qe(A.entries),f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)});return}if(o.replaceChildren(T===n.PORTRAIT?Gi(E):Ui(E)),w=V,p){if(ki(o,p),p.instant||p.stableDetail){let X=oe(o);xn(re(X,p.id))}}else if($i(o,b),f){let X=oe(o);xn(re(X,f))}Qe(A.entries),p?(_=null,p.expanded&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,p.id);gt(Q),_i(o,p.id)}),Di(o,p)):f&&t.requestAnimationFrame(()=>{let X=oe(o),Q=re(X,f);gt(Q),In(o,f)})}function qi(o,a={}){_n(o,a,{boundKey:"toolsNotificationGestureBound",getPanel:gn})}function Hi(o,a={}){_n(o,a,{boundKey:"toolsNotificationPanelDragBound",getPanel:()=>o,ignoreTarget:Xo})}function Dn(o=N){return pt(o).entries.filter(a=>!!a.highlight).length}t.CarrotToolsNotifications={bindPanelDrag:Hi,bindStatusGesture:qi,unreadCount:Dn,render:Ve,resetDetail(){f="",$="",v.clear()},focusLatest(o={}){return xi(o.out||C,o)},syncMode(o=C){o&&pn(o)}}})(window);var rn=null,st=null,At=!1,sn={recovering:"eGPU reconnecting",recovering_detail:"The internal model remains in use while eGPU recovery is attempted, up to three times.",recovery_ready:"eGPU READY",recovery_ready_detail:"Test calculations passed. The internal model remains in use until the vehicle stops or you intervene with steering.",recovery_stopped:"eGPU recovery stopped",recovery_stopped_detail:"The internal model remains in use. Once parked, restart the comma. Recovery stopped because a check failed or the retry limit was reached.",title:"eGPU big model",checking:"Checking model",downloading:"Downloading",verifying:"Verifying download",ready:"Ready to compile",waiting_for_ignition:"Waiting for ignition",compiling:"Compiling eGPU model",compiled:"Ready to use",error:"eGPU setup failed",runtime_error:"eGPU unavailable",recovery_restart:"Once parked, open User / System below and press Reboot once. If this happens again, send a screenshot of this card for support.",waiting_for_network:"Waiting for network / automatic retry",installing:"Installing eGPU package",installed:"Installed · next start",active:"eGPU running",waiting_for_network_detail:"Keep an internet connection available. Installation retries automatically every 30 seconds.",installing_detail:"Preparing the verified model and runtime. The current driving model continues running.",installed_detail:"Installation is complete. The eGPU will be tried at the next ignition session; no restart is needed while driving.",active_detail:"The eGPU is currently running the driving model.",failure_dns:"Cannot resolve the download server address. Check the internet connection; retry is automatic.",failure_clock:"Certificate not yet valid. Waiting for the device clock/network to recover; retry is automatic.",failure_network:"Download connection interrupted. Retrying automatically.",failure_server:"The download server is temporarily unavailable. Retrying automatically.",failure_certificate:"Server certificate verification failed. The download was stopped; verification remains enabled.",failure_download:"The server refused the download or the requested file is unavailable.",failure_storage:"Not enough free storage to install the eGPU package.",failure_install:"The eGPU package could not be installed or verified. The saved diagnostic report contains the cause.",failure_rejected:"A previous failure blocked this model. Compiling again does not resolve a runtime failure. If it remains after a reboot, download the diagnostics and send them for support.",diagnostics:"Download diagnostics",failure_runtime:"The eGPU could not start or continue running. The internal model is selected.",failure_pcie:"The eGPU connection could not start. The internal model is selected.",failure_timeout:"The eGPU took too long to respond. The internal model is selected.",failure_usb:"Communication with the eGPU was interrupted. The internal model is selected.",checking_detail:"Checking the model catalog. You can keep using openpilot.",downloading_detail:"Download may continue while driving. Do not restart or power off until it finishes.",verifying_detail:"Checking the complete file. This can take a moment.",ready_detail:"Park, turn ignition on, then restart once to compile.",waiting_for_ignition_detail:"Keep the car parked and ignition on, then restart to compile.",compiling_detail:"Keep the car parked and ignition on. The screen will update when compilation finishes.",compiled_detail:"The eGPU big model is compiled and will be selected automatically.",error_detail:"The eGPU is not ready. Send a screenshot of this card for support.",restart:"Restart & compile",restart_confirm:"Park the car and turn ignition on. Restart now to compile the eGPU big model?",restart_requested:"Restart requested. Compilation will begin during boot.",host_ok:"Connected",host_warning:"High temperature",host_error:"Host error / connection lost",host_unknown:"Waiting for current host health",host_ip:"Host IP",host_no_ip:"Unavailable",host_not_connected:"Host not connected",host_unavailable:"Host connection unavailable",host_health_unavailable:"Host health unavailable"};function Y(e){return typeof getUIText=="function"?getUIText(`egpu_model_${e}`,sn[e]||e):sn[e]||e}function xo(e){let t=Math.max(0,Number(e)||0);return t<1024*1024?`${(t/1024).toFixed(0)} KB`:`${(t/(1024*1024)).toFixed(t>=1024*1024*1024?0:1)} MB`}function Oa(e){let t=Math.max(0,Math.floor(Date.now()/1e3-(Number(e)||Date.now()/1e3))),n=Math.floor(t/60);return`${String(n).padStart(2,"0")}:${String(t%60).padStart(2,"0")}`}function Ma(e){let t=String(e?.display_name||"").trim();if(t)return t;let n=String(e?.model_id||"").toLowerCase();return n.includes("pr39087-md-v3")?"Mountain Dew v3":n.includes("pr39047")||n.includes("mdm-v1")||n.includes("6f4c0faf-256f2178")||n.includes("4bfb5340-e20cde17")?"Mountain Dew":n.includes("pr38932")||n.includes("cinque-v3")?"Cinque v3":n.includes("pr38823")||n.includes("cinque-v2")?"Cinque v2":n.includes("pr38771")||n.includes("cinque-terre")?"Cinque Terre":n.includes("bmrlanpv6")?"BMRLNAP v6":n.includes("pr38739")||n.includes("tgc")?"TGC":n.includes("pr38726")||n.includes("time-to-go")?"Time to Go":""}function Ua(e,t=Y("title")){let n=Ma(e);return n?`${n} · eGPU`:t}function ln(e=st){let t=document.getElementById("egpuModelCard");if(!t)return;if(st=e,!e?.available&&!e?.jetlink){t.hidden=!0;return}let n=String(e.state||"checking"),i=Number(e.progress),r=["checking","downloading","verifying","compiling","waiting_for_network","installing"].includes(n),c=document.getElementById("egpuModelState"),s=document.getElementById("egpuModelDetail"),g=document.getElementById("egpuModelRecovery");g.hidden=!0,g.textContent="";let m=document.getElementById("egpuModelProgress"),f=document.getElementById("egpuModelProgressBar"),C=document.getElementById("egpuModelAmount"),N=document.getElementById("btnEgpuCompileRestart"),M=document.getElementById("egpuModelDiagnostics");M&&(M.hidden=!e.available||n!=="error"||!!e.active,M.textContent=Y("diagnostics"));let u=document.getElementById("jetlinkHealth");u||(u=document.createElement("div"),u.id="jetlinkHealth",u.className="egpu-model-card__detail",s.after(u));let _=e.jetlink;if(u.hidden=!_,_){let z=["ok","warning","error","unknown"].includes(_.severity)?_.severity:"unknown";u.textContent=`${_.label} · ${Y(`host_${z}`)} · ${Y("host_ip")}: ${(_.addresses||[]).join(", ")||Y("host_no_ip")}`+(Number.isFinite(_.temp_c)?` · ${_.temp_c.toFixed(1)} °C`:"")+(_.reason?` · ${{"Host not connected":Y("host_not_connected"),"Host connection unavailable":Y("host_unavailable"),"Host health unavailable":Y("host_health_unavailable")}[_.reason]||_.reason}`:""),u.style.color=z==="error"?"#ff7474":z==="warning"?"#ffd166":""}if(!e.available){t.hidden=!1,t.dataset.state=_.severity==="error"?"error":"compiled",t.classList.remove("is-running"),document.getElementById("egpuModelTitle").textContent=`${_.label} · Cinque v2`,c.textContent=Y(`host_${_.severity||"unknown"}`),s.textContent="",m.hidden=!0,C.textContent="",N.hidden=!0;return}t.hidden=!1,t.dataset.state=n,t.classList.toggle("is-running",r),document.getElementById("egpuModelTitle").textContent=Ua(e);let h=e.active&&["compiled","installed"].includes(n),S=n==="error"&&["runtime","pcie","timeout","usb"].includes(e.error_code);c.textContent=Y(h?"active":S?"runtime_error":n);let P=`failure_${e.error_code}`;s.textContent=e.error_code?Y(Object.hasOwn(sn,P)?P:"error_detail"):Y(h?"active_detail":`${n}_detail`),g.hidden=!S,g.textContent=S?Y("recovery_restart"):"";let F=n==="downloading"&&Number.isFinite(i);m.hidden=!F,f.style.width=`${Math.min(100,Math.max(0,i||0))}%`,C.textContent=F?`${xo(e.downloaded_bytes)} / ${xo(e.total_bytes)} · ${i.toFixed(1)}%`:n==="compiling"?Oa(e.started_at):"",N.textContent=Y(At?"checking":"restart"),N.hidden=!["ready","waiting_for_ignition","error"].includes(n)||e.compiled||e.error_code==="rejected",N.disabled=At||!e.can_restart}async function Et(){try{ln(await getJson("/api/egpu/model"))}catch{st?.jetlink&&ln({...st,jetlink:{...st.jetlink,severity:"unknown",addresses:[],temp_c:null,reason:"",fresh:!1}})}finally{window.clearTimeout(rn),rn=window.setTimeout(Et,2e3)}}async function Ga(){if(!await appConfirm(Y("restart_confirm"),{title:Y("restart")}))return;let e=document.getElementById("btnEgpuCompileRestart");At=!0,e&&(e.disabled=!0,e.textContent=Y("checking"));try{await postJson("/api/egpu/model/compile-restart",{}),typeof showAppToast=="function"&&showAppToast(Y("restart_requested"));return}catch(t){At=!1,typeof showAppToast=="function"&&showAppToast(`${Y("error")}: ${t?.message||t}`,{tone:"error",duration:5e3}),typeof showError=="function"&&showError("eGPU model",t),await Et()}}function qa(){let e=document.getElementById("btnEgpuCompileRestart");e&&e.dataset.bound!=="1"&&(e.dataset.bound="1",e.addEventListener("click",Ga)),rn||Et()}var Ha=Object.freeze({init:qa,refresh:Et,render:ln});globalThis.CarrotEgpuModel=Ha;function It(e){let[t,n="single"]=e.split("@");return[t,n]}function No(e,t){return t==="single"?e:`${e}@${t}`}function $o(e){return[...new Set(Object.keys(e).map(t=>It(t)[0]))]}function ko(e,t){let n=!1;for(let i of t){if(i.reason!=="test")continue;let r=It(i.button)[0];r in e||(e[r]="none",n=!0),i.button in e||(e[i.button]="none",n=!0)}return n}var Lo=["accelCruise","decelCruise","gapAdjustCruise","lfaButton","cancel"],Ro=["none",...Lo,...Lo.map(e=>`${e}Long`),"laneLeft","laneRight","paddleDecel","carrotCruise"];var Wa={ko:{title:"블루투스 리모컨",close:"닫기",scan:"장치 검색 (30초)",on:"블루투스 켜기",off:"블루투스 끄기",help:"정차하고 크루즈를 해제한 상태에서 설정하세요. 매핑을 켜면 해당 장치의 원래 터치·키보드·볼륨 입력을 차단합니다. 차로변경은 기존 속도·측후방·조향 조건을 따릅니다. 휴대폰도 페어링할 수 있지만 통화·오디오·네트워크 기능은 제공하지 않습니다.",empty:"장치를 페어링 모드로 놓고 검색하세요. 기존 휴대폰에 연결되어 있다면 그 연결을 해제하세요.",pair:"페어링",connect:"연결",disconnect:"연결 해제",forget:"등록 해제",paired:"페어링됨",connected:"연결됨",profile:"입력 종류",generic:"일반 키보드 / HID",enabled:"캐럿 매핑 사용 (원래 HID 입력 차단)",save:"매핑 저장",saved:"저장했습니다",test:"키 등록 / 시험 시작 (120초)",stop:"시험 종료",waiting:"시험 준비 중…",testing:"시험 중: 차량 명령은 보내지 않습니다. 버튼을 누르면 아래에 표시됩니다. 새 키는 자동으로 목록에 추가됩니다.",idle:"입력 대기",blocked:"정차·크루즈 해제 상태 또는 입력 서비스가 확인되지 않아 설정할 수 없습니다.",grabbed:"HID 입력 독점 중",inactive:"입력 독점 대기 (연결·매핑 사용 여부 확인)",learnHelp:"먼저 입력 종류와 매핑을 저장한 뒤 시험을 시작하세요. 일반 HID는 시험 중 버튼을 눌렀다 떼어 등록합니다. 변경한 기능은 다시 저장해야 적용됩니다. Yiser-J6의 길게 누르기 지원 여부는 리모컨이 보내는 실제 입력에 따라 다릅니다.",none:"사용 안 함",accelCruise:"크루즈 + (accelCruise)",decelCruise:"크루즈 − (decelCruise)",laneLeft:"왼쪽 차로변경",laneRight:"오른쪽 차로변경",paddleDecel:"패들 감속",gapAdjustCruise:"크루즈 갭",up:"위",down:"아래",left:"왼쪽",right:"오른쪽",center:"중앙",1:"1",2:"2",cancel:"취소",confirm:"확인",pin:"PIN / 패스키",prompt:"페어링 확인",display:"상대 장치에 표시된 번호를 입력하거나 아래 번호를 확인하세요.",scanning:"검색 중",ready:"준비",unavailable:"블루투스 어댑터를 사용할 수 없습니다",noService:"입력 서비스가 실행되지 않았습니다",unsaved:"변경 내용을 저장하세요",sent:"차량 명령 전달",testEvent:"시험 입력",ignored:"명령 미전달",battery:"배터리",single:"짧게 누르기",double:"더블클릭",long:"길게 누르기",carrotCruise:"CarrotCruise (가속 제한 진입)",gestures:"더블클릭은 두 번의 뗌 간격이 350ms 이내일 때 인식합니다. 더블클릭을 지정한 키는 짧게 누르기도 350ms 기다립니다. ‘길게 누르기’에 기능을 지정하면 누른 채 700ms 후 실행합니다. 크루즈 +/−(10단위 동작 포함)는 이후 500ms 간격으로 반복하고, 나머지 기능은 한 번만 실행합니다. 버튼을 떼거나 연결이 끊기면 반복을 멈춥니다. 페달·핸들 버튼 조작, 크루즈 해제 또는 10초 경과 후에는 떼었다 다시 눌러야 합니다. 누름 시간을 보내지 않는 리모컨은 길게 누르기를 구분할 수 없습니다. 시험 중에는 모든 키에서 세 동작을 구분합니다.",multi:"여러 장치를 함께 사용할 수 있으며 장치별로 매핑을 저장합니다 (최대 16개 등록, 실제 동시 연결 수는 기기에 따라 다름). 입력은 순서대로 처리하며 400ms 지난 명령은 버립니다. 키 등록 시험은 선택한 한 장치에만 적용됩니다. 다른 활성 장치는 계속 동작합니다.",carrotHelp:"CarrotCruise는 기존 가속 제한 모드에 진입합니다. 다시 눌러도 해제되지 않으며 크루즈 +로 해제합니다. 실제 가속 제한은 해당 차종의 기존 지원과 CarrotCruiseDecel 설정을 따릅니다.",mappingOn:"매핑 켜짐",mappingOff:"매핑 꺼짐",receiving:"입력 수신 중",accelCruiseLong:"크루즈 + 길게 (10단위 조절)",decelCruiseLong:"크루즈 − 길게 (10단위 조절)",gapAdjustCruiseLong:"갭 길게 (주행 모드 순환)",lfaButton:"LFA 버튼",lfaButtonLong:"LFA 길게 (차선 모드 전환)",cancelCruise:"크루즈 취소",cancelLong:"취소 길게 (크루즈·조향 해제)",buttonHelp:"크루즈 +/−는 크루즈가 꺼져 있으면 기존 활성화 조건을 거쳐 켜기를 요청합니다. 버튼의 ‘길게’ 동작도 기능 목록에서 선택할 수 있습니다. 예: 리모컨 짧게 누르기 → 크루즈 + 길게. 이 경우 추가로 기다리지 않고 핸들 버튼의 롱 동작을 한 번 실행합니다. LFA와 갭의 동작은 기존 설정을 따릅니다."},en:{title:"Bluetooth remotes",close:"Close",scan:"Scan devices (30s)",on:"Enable Bluetooth",off:"Disable Bluetooth",help:"Configure while stationary with cruise disengaged. Enabling a mapping captures this device's original touch, keyboard and volume input. Lane changes retain existing speed, blind-spot and steering conditions. Phones can pair; calls, audio and networking are not provided.",empty:"Put your device in pairing mode and scan. Disconnect it from its previous phone if needed.",pair:"Pair",connect:"Connect",disconnect:"Disconnect",forget:"Forget",paired:"Paired",connected:"Connected",profile:"Input profile",generic:"Generic keyboard / HID",enabled:"Use Carrot mapping (capture original HID input)",save:"Save mapping",saved:"Saved",test:"Learn / test keys (120s)",stop:"End test",waiting:"Preparing test…",testing:"Test mode: no vehicle commands. Press and release a button to see it below. New keys are added to the list automatically.",idle:"Waiting for input",blocked:"Setup requires a fresh stationary, disengaged state and the input service.",grabbed:"Exclusive HID capture active",inactive:"Waiting for HID capture (check connection and mapping)",learnHelp:"Save the input profile and mapping before testing. Press and release each generic HID button during the test to learn it. Save again to apply edited actions. Long-press support on Yiser-J6 depends on the events reported by the remote.",none:"No action",accelCruise:"Cruise + (accelCruise)",decelCruise:"Cruise − (decelCruise)",laneLeft:"Lane change left",laneRight:"Lane change right",paddleDecel:"Paddle deceleration",gapAdjustCruise:"Cruise gap",up:"Up",down:"Down",left:"Left",right:"Right",center:"Center",1:"1",2:"2",cancel:"Cancel",confirm:"Confirm",pin:"PIN / passkey",prompt:"Pairing confirmation",display:"Enter the code shown on the other device, or verify the code below.",scanning:"Scanning",ready:"Ready",unavailable:"Bluetooth adapter unavailable",noService:"Input service is not running",unsaved:"Save your changes",sent:"Vehicle command sent",testEvent:"Test input",ignored:"Command not sent",battery:"Battery",single:"Short press",double:"Double click",long:"Long press",carrotCruise:"CarrotCruise (enter acceleration limit)",gestures:"Double clicks require releases within 350ms. Assigning a double click delays the short press by 350ms. An assigned Long press fires after holding for 700ms. Cruise +/− (including 10-step actions) then repeat every 500ms; other actions fire once. Release or disconnection stops repetition. After a pedal or steering-wheel button input, cruise disengagement, or 10 seconds, release and press again. Remotes that do not report hold duration cannot distinguish long presses. Test mode recognizes all three gestures on every key.",multi:"Use multiple devices with separate mappings (up to 16 saved; simultaneous connections depend on hardware). Commands are processed in order and expire after 400ms. Learning tests only the selected device; other enabled devices continue operating.",carrotHelp:"CarrotCruise enters the existing acceleration-limiting mode. Repeating it keeps the mode on; Cruise + exits it. Actual limiting follows existing vehicle support and CarrotCruiseDecel.",mappingOn:"Mapping on",mappingOff:"Mapping off",receiving:"Receiving input",accelCruiseLong:"Cruise + long (10-step adjustment)",decelCruiseLong:"Cruise − long (10-step adjustment)",gapAdjustCruiseLong:"Gap long (cycle driving mode)",lfaButton:"LFA button",lfaButtonLong:"LFA long (toggle lane-line mode)",cancelCruise:"Cancel cruise",cancelLong:"Cancel long (cruise and lateral off)",buttonHelp:"Cruise +/− request engagement through existing checks when cruise is off. Native long-button actions are separate choices: for example, a short remote press can run Cruise + long once, with no additional hold delay. LFA and gap actions follow existing settings."}},Po={up:"accelCruise",down:"decelCruise",left:"laneLeft",right:"laneRight",center:"paddleDecel",1:"gapAdjustCruise",2:"none"},Fa=Ro,ce,B,ae,ee,Do,cn,xt=!1,dn,ve=!1,x=e=>Wa[typeof LANG<"u"&&LANG==="ko"?"ko":"en"][e]||e,Bo=e=>x(e==="cancel"?"cancelCruise":e),Oo=e=>{let[t,n]=It(e);return`${x(t)} · ${x(n)}`},Ue=new Set,D=(e,t,n)=>{let i=document.createElement(e);return t!=null&&(i.textContent=t),n&&(i.className=n),i},Z=e=>ce.querySelector(`[data-bt="${e}"]`),we=(e,t="div")=>{let n=D(t);return n.dataset.bt=e,n};function de(e,t,n=!0){let i=D("button",x(e),"btn");return i.type="button",n&&(i.dataset.mutation="true"),i.onclick=n?()=>Ka(t):t,i}async function pe(e,t){let n=await fetch(`/api/bluetooth${e?`/${e}`:""}`,e?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t||{})}:{cache:"no-store"});if(!n.ok)throw new Error(await n.text());return n.json()}async function Ka(e){if(!xt){xt=!0,Ke(),Z("error").textContent="";try{await e(),await Uo()}catch(t){Z("error").textContent=t.message}finally{xt=!1,Ke()}}}function Ke(){ce?.querySelectorAll('[data-mutation="true"]').forEach(e=>{e.disabled=xt||!B?.runtime?.stationary})}function ja(e){if(ve){Z("error").textContent=x("unsaved");return}ae=e.address;let t=B.config.devices[ae];ee=t?structuredClone(t):{name:e.name,profile:/yiser-j6/i.test(e.name)?"yiser-j6":"generic",enabled:!1,mapping:{}},!t&&ee.profile==="yiser-j6"&&(ee.mapping={...Po}),ve=!1,Ue=new Set((B.runtime.recent_events||[]).map(n=>n.id)),un()}function un(){let e=Z("editor");if(e.replaceChildren(),!ee)return;e.append(D("h3",`${ee.name||ae} · ${ae}`));let t=D("label",x("profile")),n=D("select");for(let[g,m]of[["yiser-j6","Yiser-J6"],["generic",x("generic")]]){let f=D("option",m);f.value=g,n.append(f)}n.value=ee.profile,n.dataset.mutation="true",n.onchange=()=>{ee.profile=n.value,ee.mapping=n.value==="yiser-j6"?{...Po}:{},ve=!0,un()},t.append(n),e.append(t);let i=D("label"),r=D("input");r.type="checkbox",r.checked=ee.enabled,r.dataset.mutation="true",r.onchange=()=>{ee.enabled=r.checked,ve=!0},i.append(r,document.createTextNode(x("enabled"))),e.append(i);let c=we("mapping");e.append(c),Mo();let s=D("div",null,"bt-actions");s.append(de("save",async()=>{await pe("device-config",{address:ae,device:ee}),ve=!1,Z("notice").textContent=x("saved")}),de("test",async()=>{if(ve||!B.config.devices[ae])throw new Error(x("unsaved"));Ue=new Set((B.runtime.recent_events||[]).map(g=>g.id)),await pe("learn",{address:ae,enabled:!0}),Z("notice").textContent=x("waiting")}),de("stop",async()=>{await pe("learn",{address:ae,enabled:!1})})),e.append(s,D("p",x("learnHelp")),D("p",x("gestures")),D("p",x("buttonHelp")),D("p",x("carrotHelp")),we("notice"),we("capture"),we("event","output")),Ke()}function Mo(){let e=Z("mapping");e.replaceChildren();for(let t of $o(ee.mapping)){let n=D("fieldset",null,"bt-mapping-row");n.append(D("legend",x(t)));for(let i of["single","double","long"]){let r=No(t,i),c=D("label",x(i)),s=D("select");s.dataset.mutation="true",s.setAttribute("aria-label",Oo(r));for(let g of Fa){let m=D("option",Bo(g));m.value=g,s.append(m)}s.value=ee.mapping[r]||"none",s.onchange=()=>{ee.mapping[r]=s.value,ve=!0},c.append(s),n.append(c)}e.append(n)}Ke()}function Qa(){let e=[...B.devices].sort((i,r)=>Number(r.paired)-Number(i.paired)||i.address.localeCompare(r.address)),t=JSON.stringify(e.map(({address:i,name:r,paired:c,connected:s,battery:g})=>({address:i,name:r,paired:c,connected:s,battery:g,enabled:B.config.devices[i]?.enabled,grabbed:B.runtime.grabbed?.includes(i)})));if(t===cn)return;cn=t;let n=Z("devices");n.replaceChildren(),B.devices.length||n.append(D("p",x("empty")));for(let i of e){let r=D("div",null,"bt-device"),c=D("strong",i.name||i.address);r.append(c,D("div",`${i.address} · ${i.connected?x("connected"):i.paired?x("paired"):""}${i.battery!=null?` · ${x("battery")} ${i.battery}%`:""}`)),B.config.devices[i.address]&&r.append(D("div",`${x(B.config.devices[i.address].enabled?"mappingOn":"mappingOff")}${B.runtime.grabbed?.includes(i.address)?` · ${x("receiving")}`:""}`));let s=D("div",null,"bt-actions");i.paired?s.append(de(i.connected?"disconnect":"connect",()=>pe(i.connected?"disconnect":"connect",{address:i.address})),de("forget",async()=>{await pe("forget",{address:i.address}),ae===i.address&&(ae=ee=null,un())}),de("profile",()=>ja(i),!1)):s.append(de("pair",()=>pe("pair",{address:i.address}))),r.append(s),n.append(r)}}function za(){let e=B.prompt;if((e?.id||null)===dn)return;dn=e?.id||null;let t=Z("prompt");if(t.replaceChildren(),!e)return;t.append(D("h3",x("prompt")),D("p",x("display")),D("strong",e.value));let n;["RequestPinCode","RequestPasskey"].includes(e.kind)&&(n=D("input"),n.placeholder=x("pin"),n.setAttribute("aria-label",x("pin")),n.maxLength=e.kind==="RequestPasskey"?6:16,t.append(n)),e.kind.startsWith("Display")||t.append(de("confirm",()=>pe("answer",{id:e.id,value:n?n.value:!0}))),t.append(de("cancel",()=>pe("cancel")))}async function Uo(){if(!ce?.open)return;B=await pe(),Z("status").textContent=B.runtime.stationary?B.available?B.adapters.some(t=>t.discovering)?x("scanning"):x("ready"):x("unavailable"):x("blocked"),Z("radio").textContent=x(B.radioEnabled?"off":"on"),Z("service").textContent=[B.error,!B.runtime.alive&&x("noService"),...Object.values(B.runtime.errors||{}),B.pair?.error].filter(Boolean).join(" · "),Qa(),za();let e=B.runtime.learning;if(ee){Z("capture").textContent=(B.runtime.grabbed||[]).includes(ae)?x("grabbed"):x("inactive"),Z("notice").textContent=e?.address===ae?`${x("testing")} (${Math.max(0,Math.ceil(e.until-B.runtime.time))}s)`:ve?x("unsaved"):"";let t=B.runtime.recent_events||(B.runtime.last_event?[B.runtime.last_event]:[]),n=t.filter(i=>i.address===ae&&!Ue.has(i.id));for(let i of n)Ue.add(i.id),Z("event").textContent=`${Oo(i.button)} → ${Bo(i.action)} · ${x(i.reason==="test"?"testEvent":i.emitted?"sent":"ignored")}`;e?.address===ae&&ko(ee.mapping,n)&&(ve=!0,Mo()),Ue.size>256&&(Ue=new Set(t.map(i=>i.id)))}Ke()}function Va(){if(ce?.open)return;ce?.remove(),B=ae=ee=dn=cn=null,ve=!1,Ue=new Set,ce=D("dialog",null,"carrot-bt-dialog"),ce.setAttribute("aria-label",x("title"));let e=D("div",null,"bt-actions");e.append(D("h2",x("title")),de("close",()=>ce.close(),!1));let t=D("div",null,"bt-actions"),n=de("on",()=>pe("radio",{enabled:!B.radioEnabled}));n.dataset.bt="radio",t.append(n,de("scan",()=>pe("scan")),de("cancel",()=>pe("cancel"))),ce.append(e,D("p",x("help")),D("p",x("multi")),we("status"),we("service"),we("error"),t,we("prompt"),we("devices"),we("editor")),Z("error").setAttribute("role","alert"),Z("status").setAttribute("role","status"),ce.addEventListener("close",()=>{clearTimeout(Do)}),document.body.append(ce),ce.showModal(),Ke();let i=!1,r=async()=>{try{await Uo(),i&&(Z("error").textContent=""),i=!1}catch(c){i=!0,Z("error").textContent=c.message}finally{ce.open&&(Do=setTimeout(r,1e3))}};r()}globalThis.CarrotBluetooth={init(){let e=document.getElementById("btnToolsBluetooth");e&&(e.textContent=x("title"),e.onclick=Va)}};var Ge=null,je=null,dt=null,ct=0,lt=null;function k(e,t,n=null){return typeof getUIText=="function"?getUIText(e,t,n):t}function Lt(e){return String(e??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function $t(e){typeof appDialog>"u"||!appDialog||appDialog.classList.toggle("app-dialog--tools-qr",!!e)}function Go(e,t,n=null,i=null){let r=k(e,t,n);return typeof showAppToast=="function"&&showAppToast(r,i||void 0),r}function Nt(){je&&(cancelAnimationFrame(je),je=null),dt&&(clearTimeout(dt),dt=null),ct=0,Ge&&(Ge.getTracks().forEach(e=>e.stop()),Ge=null)}function $e(e,t){e&&(e.dataset.scanState=t||"idle")}function Ja(e,t){let n=Math.min(e,t)*.74;return{left:(e-n)/2,top:(t-n)/2,right:(e+n)/2,bottom:(t+n)/2,width:n,height:n}}function Ya(e){let t=[e?.location?.topLeftCorner,e?.location?.topRightCorner,e?.location?.bottomRightCorner,e?.location?.bottomLeftCorner].filter(m=>Number.isFinite(Number(m?.x))&&Number.isFinite(Number(m?.y)));if(t.length<4)return null;let n=t.map(m=>Number(m.x)),i=t.map(m=>Number(m.y)),r=Math.min(...n),c=Math.max(...n),s=Math.min(...i),g=Math.max(...i);return{left:r,top:s,right:c,bottom:g,width:c-r,height:g-s,centerX:(r+c)/2,centerY:(s+g)/2}}function Xa(e,t,n){let i=Ja(t,n),r=Ya(e);if(!r||r.width<=0||r.height<=0)return!1;let c=i.width*.18,s=i.height*.18,g=r.centerX>=i.left&&r.centerX<=i.right&&r.centerY>=i.top&&r.centerY<=i.bottom,m=r.left>=i.left-c&&r.right<=i.right+c&&r.top>=i.top-s&&r.bottom<=i.bottom+s,f=r.width>=i.width*.34&&r.height>=i.height*.34&&r.width<=i.width*1.18&&r.height<=i.height*1.18;return g&&m&&f}function Ho(e){if(typeof qrcode!="function")throw new Error("QR library not loaded");let t=qrcode(0,"L"),n=String(e||"");return t.addData(e,n.startsWith("CQR3:")||n.startsWith("CQR4:")?"Alphanumeric":"Byte"),t.make(),t}function Za(e){return Ho(e).createSvgTag(2,4)}function er(e){let t=Ho(e),n=t.getModuleCount(),i=4,r=4,c=(n+r*2)*i,s=document.createElement("canvas");s.width=c,s.height=c;let g=s.getContext("2d");g.fillStyle="#ffffff",g.fillRect(0,0,c,c),g.fillStyle="#000000";for(let m=0;m|]+/g,"-").replace(/\s+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,64)||"carrot"}function qo(e=new Date){let t=String(e.getFullYear()),n=String(e.getMonth()+1).padStart(2,"0"),i=String(e.getDate()).padStart(2,"0");return`${t}${n}${i}`}async function nr(){try{let e=await bulkGet(["GitBranch"]);return`${tr(e.GitBranch)}-${qo()}.png`}catch{return`carrot-${qo()}.png`}}async function Wo(){if(lt)return lt;lt=(async()=>{let e=await getJson("/api/params_qr_dependency");if(console.info("[carrot][qr-dependency]",e),e.installed)return e;Go("qr_configuring","Please wait...");let t=await postJson("/api/params_qr_dependency/ensure",{});if(console.info("[carrot][qr-dependency]",t),!t.ok||!t.installed)throw new Error(t.error||k("qr_config_failed","QR feature could not be configured."));return Go("qr_config_done","QR feature is ready."),t})();try{return await lt}finally{lt=null}}function or(e,t){let n=document.createElement("a");n.href=e,n.download=t,document.body.appendChild(n),n.click(),n.remove()}function ir(e){let t=document.createElement("canvas"),n=Math.min(1,1600/Math.max(e.naturalWidth||e.width,e.naturalHeight||e.height,1));t.width=Math.max(1,Math.floor((e.naturalWidth||e.width)*n)),t.height=Math.max(1,Math.floor((e.naturalHeight||e.height)*n));let i=t.getContext("2d",{willReadFrequently:!0});i.drawImage(e,0,0,t.width,t.height);let r=i.getImageData(0,0,t.width,t.height);return(typeof jsQR=="function"?jsQR(r.data,t.width,t.height):null)?.data||""}function ar(e,t,n){if(!e)return;let i=new FileReader;i.onload=()=>{let r=new Image;r.onload=()=>{let c=ir(r);c?t(c):n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.onerror=()=>{n.textContent=k("qr_restore_decode_failed","QR code was not found.")},r.src=String(i.result||"")},i.readAsDataURL(e)}function rr(e={}){return typeof renderSettingsDiffSummary=="function"?renderSettingsDiffSummary(e):""}function sr(e){return typeof renderSettingsDiffHtml=="function"?renderSettingsDiffHtml(e,{currentLabel:k("qr_restore_current_value","Current"),nextLabel:k("qr_restore_backup_value","Restore"),changedLabel:k("qr_restore_changed","Changed")}):rr(e?.summary)}async function lr(){try{await Wo();let e=await getJson("/api/params_qr_backup"),t=e.format||String(e.payload||"").split(/[.:]/,1)[0]||"unknown";console.info("[carrot][qr-backup]",{format:t,version:e.version,count:e.count,payloadChars:e.payload_chars,rawBytes:e.json_bytes,compressedBytes:e.compressed_bytes,ecc:e.ecc});let n=Za(e.payload),i=er(e.payload),r=await nr(),c=k("qr_backup_title","QR Backup"),s=`
${n}
diff --git a/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js b/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js index 9c13013bc6b..61b1d442efb 100644 --- a/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js +++ b/openpilot/selfdrive/carrot/web/src/features/tools/egpu_model.js @@ -88,6 +88,7 @@ function modelDisplayName(status) { if (explicitName) return explicitName; const modelId = String(status?.model_id || "").toLowerCase(); + if (modelId.includes("pr39087-md-v3")) return "Mountain Dew v3"; if (modelId.includes("pr39047") || modelId.includes("mdm-v1") || modelId.includes("6f4c0faf-256f2178") || modelId.includes("4bfb5340-e20cde17")) return "Mountain Dew"; if (modelId.includes("pr38932") || modelId.includes("cinque-v3")) return "Cinque v3"; if (modelId.includes("pr38823") || modelId.includes("cinque-v2")) return "Cinque v2"; diff --git a/openpilot/selfdrive/controls/lib/latcontrol_angle.py b/openpilot/selfdrive/controls/lib/latcontrol_angle.py index ec73c4e7bdf..fc9bbfcb9aa 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_angle.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_angle.py @@ -24,7 +24,7 @@ def update(self, active, CS, VM, params, steer_limited_by_controls, desired_curv angle_steers_des += params.angleOffsetDeg angle_control_saturated = abs(angle_steers_des - CS.steeringAngleDeg) > STEER_ANGLE_SATURATION_THRESHOLD - angle_log.saturated = bool(self._check_saturation(angle_control_saturated, CS, False, curvature_limited)) + angle_log.saturated = bool(self._check_saturation(angle_control_saturated, CS, steer_limited_by_controls, curvature_limited)) angle_log.steeringAngleDeg = float(CS.steeringAngleDeg) angle_log.steeringAngleDesiredDeg = float(angle_steers_des) if not CS.steeringPressed else float(CS.steeringAngleDeg) return 0, float(angle_steers_des), angle_log diff --git a/openpilot/selfdrive/controls/tests/test_angle_saturation.py b/openpilot/selfdrive/controls/tests/test_angle_saturation.py new file mode 100644 index 00000000000..fdea7c9e922 --- /dev/null +++ b/openpilot/selfdrive/controls/tests/test_angle_saturation.py @@ -0,0 +1,22 @@ +from types import SimpleNamespace + +import pytest + +from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle + + +@pytest.mark.parametrize("limited", [False, True]) +@pytest.mark.parametrize("curvature_limited", [False, True]) +def test_saturation_respects_controller_output_limit(limited, curvature_limited): + controller = LatControlAngle(SimpleNamespace(steerLimitTimer=0.4), None) + state = SimpleNamespace(vEgo=15.0, steeringAngleDeg=0.0, steeringPressed=False) + model = SimpleNamespace(get_steer_from_curvature=lambda *_args: 0.2) + params = SimpleNamespace(roll=0.0, angleOffsetDeg=0.0) + for _ in range(50): + _, _, status = controller.update(True, state, model, params, limited, 0.01, None, curvature_limited) + assert status.saturated == (not limited) + + for _ in range(50): + _, _, status = controller.update(False, state, model, params, False, 0.01, None, False) + assert not status.saturated + assert controller.sat_count == 0.0 diff --git a/openpilot/selfdrive/modeld/big_model.py b/openpilot/selfdrive/modeld/big_model.py index c5d07dd3c68..c476afed9b4 100644 --- a/openpilot/selfdrive/modeld/big_model.py +++ b/openpilot/selfdrive/modeld/big_model.py @@ -32,13 +32,13 @@ from openpilot.selfdrive.modeld.big_model_status import BigModelStatusReporter -DEFAULT_MANIFEST_URL = "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/manifest.json" -MDM_RETRAINED_MODEL = { - "model_id": "comma-pr39047-mdm-retrained-6f4c0faf-256f2178", +DEFAULT_MANIFEST_URL = "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/manifest.json" +MD_V3_MODEL = { + "model_id": "comma-pr39087-md-v3-e86b58dc-d4c7a3fc", "filename": "big_driving_tinygrad.pkl", - "size": 799_942_179, - "sha256": "256f2178366479a211933701c03518621ed3970289e6a8a43e5a2bcb15e55b5f", - "url": "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/big_driving_tinygrad.pkl", + "size": 799_941_965, + "sha256": "d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422", + "url": "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/big_driving_tinygrad.pkl", } MAX_MANIFEST_SIZE = 64 * 1024 MAX_MODEL_SIZE = 4 * 1024 * 1024 * 1024 @@ -153,7 +153,7 @@ def fetch_manifest(manifest_url: str = DEFAULT_MANIFEST_URL, timeout: float = 15 # Keep the environment/CLI override path below so a different manifest can # still be tested explicitly without changing this branch. if manifest_url == DEFAULT_MANIFEST_URL: - return BigModelManifest.from_dict(MDM_RETRAINED_MODEL, manifest_url) + return BigModelManifest.from_dict(MD_V3_MODEL, manifest_url) req = Request(manifest_url, headers={"Accept": "application/json", "User-Agent": "carrot-modeld/1"}) with urlopen(req, timeout=timeout) as response: diff --git a/openpilot/selfdrive/modeld/egpu_events.py b/openpilot/selfdrive/modeld/egpu_events.py new file mode 100644 index 00000000000..b88dd7cb697 --- /dev/null +++ b/openpilot/selfdrive/modeld/egpu_events.py @@ -0,0 +1,18 @@ +"""Low-volume eGPU lifecycle diagnostics for tmux; never the worker protocol.""" +import json +import os +import sys +import time + + +def error_summary(error) -> str: + lines = str(error).strip().splitlines() + return (lines[-1] if lines else type(error).__name__)[-600:] + + +def log_event(event: str, **fields) -> None: + try: + print('[eGPU] ' + json.dumps({'event': event, 'pid': os.getpid(), 'monotonic_s': round(time.monotonic(), 3), **fields}, + ensure_ascii=True), file=sys.stderr, flush=True) + except Exception: + pass # A closed diagnostic stream must not change model/fallback behavior. diff --git a/openpilot/selfdrive/modeld/egpu_recovery.py b/openpilot/selfdrive/modeld/egpu_recovery.py index cf00e8b1194..c0e3940341d 100644 --- a/openpilot/selfdrive/modeld/egpu_recovery.py +++ b/openpilot/selfdrive/modeld/egpu_recovery.py @@ -12,6 +12,7 @@ from types import SimpleNamespace from openpilot.common.egpu_recovery_status import BUDGET, STATUS, write_status +from openpilot.selfdrive.modeld.egpu_events import error_summary, log_event from openpilot.selfdrive.modeld.model_join import may_join from openpilot.selfdrive.modeld.precompiled_model import gpu_bootloader_timeout, gpu_transport_failure, gpu_usb_link_failure, gpu_worker_busy @@ -164,6 +165,7 @@ def __init__(self, path, width, height, *, clock=time.monotonic, prepare=None, self.next_status = 0. self.attempts = 0 self.exhausted = False + self.last_wait_reason, self.next_wait_log = None, 0. try: value = json.loads(budget_path.read_text()) self.attempts, self.exhausted = int(value['attempts']), bool(value['stopped']) @@ -177,7 +179,18 @@ def __init__(self, path, width, height, *, clock=time.monotonic, prepare=None, def _save_budget(self): write_status({'attempts': self.attempts, 'stopped': self.exhausted}, self.budget_path) + def _event(self, event, **fields): + log_event(event, model=self.path.parent.name, **{'attempt': self.attempts, 'limit': len(DELAYS), **fields}) + + def _scheduled(self): + self._event('retry_scheduled', attempt=self.attempts + 1, + delay_s=DELAYS[self.attempts], requires='USB present and healthy internal output for 3s', + active_model='internal') + def failed(self, error): + self._event('fallback', error=error_summary(error), active_model='internal', + retryable=retryable(error, self.manifest), + artifact_blocked=(self.path.parent / 'rejected').exists()) self.healthy_since = self.last_publication = None if self.exhausted or self.attempts >= len(DELAYS) or not retryable(error, self.manifest): self.stop('not_retryable' if not retryable(error, self.manifest) else 'attempts_exhausted') @@ -186,6 +199,7 @@ def failed(self, error): else: self.state, self.reason = 'waiting', '' self.next_attempt = self.clock() + DELAYS[self.attempts] + self._scheduled() def stop(self, reason): if self.state == 'stopped': @@ -194,6 +208,7 @@ def stop(self, reason): if self.task is not None: self.task.cancel.set() self._save_budget() + self._event('retry_stopped', reason=reason, active_model='internal', retry=False) def healthy(self): return self.last_publication is not None and 0 <= self.clock() - self.last_publication <= MAX_PUBLICATION_GAP @@ -215,25 +230,32 @@ def tick(self, sm, present): if self.task is not None: if now - self.task.started_at >= TASK_SECONDS: self.stop('cleanup_timeout') - if not present: + if not present and not self.task.cancel.is_set(): + self._event('retry_cancelled', reason='USB absent', active_model='internal') self.task.cancel.set() if self.task.done.is_set(): error, self.task = self.task.error, None + self._event('retry_failed', error=error_summary(error) if error is not None else 'worker ended without handover', + active_model='internal') if self.state != 'stopped': if self.attempts >= len(DELAYS) or (error is not None and not retryable_preparation(error, self.manifest)): self.stop('attempts_exhausted' if self.attempts >= len(DELAYS) else 'probe_failed') else: self.state = 'waiting' self.next_attempt = now + DELAYS[self.attempts] + self._scheduled() elif self.task.cancel.is_set(): self.state = 'stopped' if self.exhausted else 'recovering' elif self.task.ready_at is not None: + if self.state != 'ready': + self._event('retry_ready', result='probe passed; waiting for handover permission', active_model='internal') self.state = 'ready' valid = {k: sm.valid[k] and sm.alive[k] for k in ('carState', 'selfdriveState', 'carControl')} if present and may_join(now, sm, valid, sm.recv_time): model = self.task.take() if model is not None: self.task, self.state = None, 'idle' + self._event('retry_selected', result='prepared worker handed to modeld') self.publish(force=True) return model if (self.state == 'waiting' and self.task is None and present and self.healthy() and @@ -242,8 +264,16 @@ def tick(self, sm, present): self.attempts += 1 self._save_budget() self.state = 'recovering' + self._event('retry_started', active_model='internal') self.task = self.task_factory(self.prepare, self.healthy) self.task.started_at = now + if self.state == 'waiting': + reason = ('USB absent' if not present else 'internal output not yet healthy for 3s' + if not self.healthy() or self.healthy_since is None or now - self.healthy_since < HEALTH_SECONDS + else 'backoff delay') + if reason != self.last_wait_reason and now >= self.next_wait_log: + self._event('retry_waiting', reason=reason, retry_in_s=max(0, round(self.next_attempt - now))) + self.last_wait_reason, self.next_wait_log = reason, now + 5. self.publish() return None diff --git a/openpilot/selfdrive/modeld/egpu_usb_wait.py b/openpilot/selfdrive/modeld/egpu_usb_wait.py index c68d4fb092e..db3f4c9efff 100644 --- a/openpilot/selfdrive/modeld/egpu_usb_wait.py +++ b/openpilot/selfdrive/modeld/egpu_usb_wait.py @@ -7,6 +7,8 @@ import time RUNTIME_SHA = '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2' +MD_V3_RUNTIME_SHA = 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac' +VERIFIED_USB_RUNTIMES = frozenset((RUNTIME_SHA, MD_V3_RUNTIME_SHA)) SPIN_MS = 1 SLEEP_SECONDS = .0002 @@ -16,7 +18,7 @@ def usb_sleep(self, timeout): def configure_usb_wait(manifest): - if manifest.get('runtime', {}).get('sha256') != RUNTIME_SHA: + if manifest.get('runtime', {}).get('sha256') not in VERIFIED_USB_RUNTIMES: return False from tinygrad.runtime.ops_amd import AMDDevice, USBIface # This interpreter explicitly selects USB+AMD. Do not modify an installed diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 5c9bf66a074..bcd58797b67 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -11,6 +11,7 @@ from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.modeld.egpu_events import log_event from openpilot.common.runtime_diagnostics import RuntimeDiagnostics from openpilot.common.params import Params from openpilot.common.stopping_params import get_stopping_speed @@ -517,6 +518,8 @@ def load_usbgpu_model(): except Exception: cloudlog.exception('eGPU recovery could not be armed') egpu_recovery = None + else: + log_event('retry_stopped', reason='background recovery unavailable', retry=False, active_model='internal') run_count = 0 # The failed USB wait can outlive this camera buffer by a second. Receive # a fresh synchronized pair before running/publishing the internal model. @@ -593,6 +596,7 @@ def load_usbgpu_model(): params.put_bool("UsbGpuLoading", False) usbgpu_startup_pending = False cloudlog.warning("eGPU first model output published; startup complete") + log_event('live_output_resumed', frame_id=meta_main.frame_id, active_model='eGPU') last_vipc_frame_id = meta_main.frame_id if egpu_recovery is not None and model is small_model: try: diff --git a/openpilot/selfdrive/modeld/precompiled_model.py b/openpilot/selfdrive/modeld/precompiled_model.py index 32f917f6fcb..374d1dbf975 100644 --- a/openpilot/selfdrive/modeld/precompiled_model.py +++ b/openpilot/selfdrive/modeld/precompiled_model.py @@ -16,6 +16,8 @@ from openpilot.selfdrive.modeld.big_model import active_manifest, model_cache_dir, model_path from openpilot.selfdrive.modeld.precompiled_artifact import SERIALIZATIONS +from openpilot.selfdrive.modeld.egpu_events import error_summary, log_event +from openpilot.selfdrive.modeld.egpu_usb_wait import VERIFIED_USB_RUNTIMES PROTOCOL = 1 MAX_CATALOG = 64 * 1024 @@ -151,13 +153,13 @@ def gpu_transport_failure(detail: str) -> bool: def gpu_usb_link_failure(detail: str, manifest: dict) -> bool: """Decode the verified USB runtime's latched link error, not arbitrary AMD faults.""" - # This runtime binds AMD.error_state to the USB link's error word. -1 is - # libusb I/O failure or a short transfer, not an invalid model prediction. + # Both verified d3f09c9/02e69816 runtimes bind AMD.error_state to the USB link's error word. -1 is + # libusb I/O failure or a short transfer; +1 is LIBUSB_TRANSFER_ERROR from + # usb_reap copying the asynchronous transfer status, not a model prediction. # Other runtime versions/codes need their own source verification first. runtime = manifest.get('runtime') - return (isinstance(runtime, dict) and runtime.get('sha256') == - '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2' and - re.search(r'^(?:RuntimeError: )?AMD(?::[0-9]+)? failed with -1\Z', detail.rstrip(), re.MULTILINE) is not None) + return (isinstance(runtime, dict) and runtime.get('sha256') in VERIFIED_USB_RUNTIMES and + re.search(r'^(?:RuntimeError: )?AMD(?::[0-9]+)? failed with -?1\Z', detail.rstrip(), re.MULTILINE) is not None) def retry_usb_rejection(root: Path, pickle_sha256: str) -> bool: @@ -228,9 +230,16 @@ def ensure_precompiled(model=None, cache_dir: Path | None = None, progress=None) # Artifact integrity/contract errors still require a different valid artifact. if (root / 'rejected').exists() and (root / 'rejected').read_text() == value['pickle']['sha256']: if not (retry_usb_rejection(root, value['pickle']['sha256']) or retry_output_rejection(root, value['pickle']['sha256'])): + from openpilot.selfdrive.modeld.egpu_worker_progress import saved_failure + failure = saved_failure(cache_dir or model_cache_dir(), model.sha256) + log_event('artifact_blocked', model=model.sha256, retry=False, + reason='no eligible previous-boot failure; inspect saved diagnostics', + error=error_summary(failure.get('error', 'diagnostics unavailable')), + diagnostic=str(root / 'last_failure.json')) return None (root / 'boot_validation.json').unlink(missing_ok=True) - print('Retrying model after an earlier-boot recoverable GPU failure; revalidating artifacts.') + log_event('artifact_retry', model=model.sha256, reason='recoverable previous-boot failure', + next='verify files and require a new startup smoke test') root.mkdir(parents=True, exist_ok=True) target = root / 'model.pkl' if value['format'] == 'comma-generic-onnx' and not target.exists(): @@ -293,4 +302,9 @@ def record_failure(path: Path, error: BaseException | str, phase: str) -> bool: os.replace(temporary, target) if not transient: reject(path) + log_event('failure', model=value['pickle']['sha256'], + runtime=value['runtime'].get('sha256') if isinstance(value.get('runtime'), dict) else None, + boot_id=failure['boot_id'], phase=phase, error=error_summary(error), + classification='transient_device' if transient else 'artifact_blocked', + diagnostic=str(target)) return not transient diff --git a/openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json b/openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json new file mode 100644 index 00000000000..1c129608ea9 --- /dev/null +++ b/openpilot/selfdrive/modeld/tests/fixtures/md_v3_metadata.json @@ -0,0 +1,170 @@ +{ + "metadata": { + "metadata": { + "output_slices": "gASVkwEAAAAAAAB9lCiMCmxhbmVfbGluZXOUjAhidWlsdGluc5SMBXNsaWNllJOUSwBNEAJOh5RS\nlIwPbGFuZV9saW5lc19wcm9ilGgETRACTRgCToeUUpSMCnJvYWRfZWRnZXOUaARNGAJNIANOh5RS\nlIwEbWV0YZRoBE0gA01XA06HlFKUjAtkZXNpcmVfcHJlZJRoBE1XA013A06HlFKUjARwb3NllGgE\nTXcDTYMDToeUUpSMFndpZGVfZnJvbV9kZXZpY2VfZXVsZXKUaARNgwNNiQNOh5RSlIwOcm9hZF90\ncmFuc2Zvcm2UaARNiQNNlQNOh5RSlIwEcGxhbpRoBE2VA01zB06HlFKUjARsZWFklGgETXMHTQMI\nToeUUpSMCWxlYWRfcHJvYpRoBE0DCE0GCE6HlFKUjAxkZXNpcmVfc3RhdGWUaARNBghNDghOh5RS\nlIwGYWN0aW9ulGgETQ4ITRIIToeUUpSMDGhpZGRlbl9zdGF0ZZRoBE0SCE0SSE6HlFKUjANwYWSU\naARNEkhNFEhOh5RSlHUu\n", + "model_checkpoint": "f22e06e5-3b3f-48d2-a6b9-2f32dc20e707/56320/1365126a-e71e-4778-be00-8ab92dc82c09/12864" + }, + "input_shapes": { + "new_img": [ + 2, + 6, + 128, + 256 + ], + "desire": [ + 8 + ], + "traffic_convention": [ + 1, + 2 + ], + "action_t": [ + 1, + 2 + ], + "state_img_q": [ + 2, + 5, + 6, + 128, + 256 + ], + "state_desire_q": [ + 132, + 1, + 8 + ], + "state_feat_q": [ + 128, + 1, + 16384 + ] + }, + "output_shapes": { + "outputs": [ + 1, + 18452 + ], + "next_state_img_q": [ + 2, + 5, + 6, + 128, + 256 + ], + "next_state_desire_q": [ + 132, + 1, + 8 + ], + "next_state_feat_q": [ + 128, + 1, + 16384 + ] + } + }, + "input_specs": { + "new_img": [ + [ + 2, + 6, + 128, + 256 + ], + "uint8", + "AMD" + ], + "desire": [ + [ + 8 + ], + "float32", + "AMD" + ], + "traffic_convention": [ + [ + 1, + 2 + ], + "float32", + "AMD" + ], + "action_t": [ + [ + 1, + 2 + ], + "float32", + "AMD" + ], + "state_img_q": [ + [ + 2, + 5, + 6, + 128, + 256 + ], + "uint8", + "AMD" + ], + "state_desire_q": [ + [ + 132, + 1, + 8 + ], + "float32", + "AMD" + ], + "state_feat_q": [ + [ + 128, + 1, + 16384 + ], + "float32", + "AMD" + ] + }, + "output_specs": { + "outputs": [ + [ + 1, + 18452 + ], + "float32", + "AMD" + ], + "next_state_img_q": [ + [ + 2, + 5, + 6, + 128, + 256 + ], + "uint8", + "AMD" + ], + "next_state_desire_q": [ + [ + 132, + 1, + 8 + ], + "float32", + "AMD" + ], + "next_state_feat_q": [ + [ + 128, + 1, + 16384 + ], + "float32", + "AMD" + ] + } +} diff --git a/openpilot/selfdrive/modeld/tests/test_big_model.py b/openpilot/selfdrive/modeld/tests/test_big_model.py index f8709b5ee86..ce7bd7e635b 100644 --- a/openpilot/selfdrive/modeld/tests/test_big_model.py +++ b/openpilot/selfdrive/modeld/tests/test_big_model.py @@ -60,16 +60,16 @@ def test_manifest_resolves_relative_https_url(): assert manifest.cache_filename.endswith(".onnx") -def test_default_manifest_is_pinned_to_retrained_mountain_dew(monkeypatch): +def test_default_manifest_is_pinned_to_md_v3(monkeypatch): monkeypatch.setattr(big_model, "urlopen", lambda *_args, **_kwargs: pytest.fail("default manifest must be built in")) manifest = big_model.fetch_manifest() - assert big_model.DEFAULT_MANIFEST_URL == "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/manifest.json" - assert manifest.model_id == "comma-pr39047-mdm-retrained-6f4c0faf-256f2178" - assert manifest.size == 799_942_179 - assert manifest.sha256 == "256f2178366479a211933701c03518621ed3970289e6a8a43e5a2bcb15e55b5f" - assert manifest.url == "https://upload.shind0.synology.me/models/comma4-big-mdm-8d736683/big_driving_tinygrad.pkl" + assert big_model.DEFAULT_MANIFEST_URL == "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/manifest.json" + assert manifest.model_id == "comma-pr39087-md-v3-e86b58dc-d4c7a3fc" + assert manifest.size == 799_941_965 + assert manifest.sha256 == "d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422" + assert manifest.url == "https://upload.shind0.synology.me/models/comma4-big-md-v3-1365126a/big_driving_tinygrad.pkl" assert manifest.precompiled_only - assert manifest.cache_filename == "big_driving_tinygrad-256f2178366479a2.pkl" + assert manifest.cache_filename == "big_driving_tinygrad-d4c7a3fcfc958138.pkl" def test_precompiled_only_model_never_uses_a_local_onnx_build(tmp_path, monkeypatch): diff --git a/openpilot/selfdrive/modeld/tests/test_egpu_events.py b/openpilot/selfdrive/modeld/tests/test_egpu_events.py new file mode 100644 index 00000000000..62aeb19821b --- /dev/null +++ b/openpilot/selfdrive/modeld/tests/test_egpu_events.py @@ -0,0 +1,20 @@ +import json + +from openpilot.selfdrive.modeld.egpu_events import error_summary, log_event + + +def test_error_is_bounded_and_event_is_one_stderr_line(capsys): + assert error_summary(RuntimeError('Traceback\nRuntimeError: AMD failed with 1\n')) == 'RuntimeError: AMD failed with 1' + assert len(error_summary(ValueError('x' * 10000))) == 600 + log_event('failure', error='one\ntwo', retry=False) + captured = capsys.readouterr() + assert not captured.out and len(captured.err.splitlines()) == 1 + value = json.loads(captured.err.removeprefix('[eGPU] ')) + assert value['error'] == 'one\ntwo' and value['retry'] is False + + +def test_closed_diagnostic_stream_cannot_disable_model(monkeypatch): + def broken_print(*args, **kwargs): + raise BrokenPipeError('closed tmux stream') + monkeypatch.setattr('builtins.print', broken_print) + log_event('retry_started', attempt=1) diff --git a/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py b/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py index cb91b002d44..288f96ee27d 100644 --- a/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py +++ b/openpilot/selfdrive/modeld/tests/test_egpu_failure_containment.py @@ -84,14 +84,16 @@ def test_fallback_handler_discards_failed_camera_before_internal_inference(): internal = SimpleNamespace(reset=lambda: reset.append(True), run=lambda *a: pytest.fail('stale frame inference')) params = SimpleNamespace(get_bool=lambda _: True, put_bool=lambda *a: None) scope = {'params': params, 'small_model': internal, 'cloudlog': SimpleNamespace(exception=lambda *a: None), - 'queue_usbgpu_error_tmux': lambda *a: None, 'internal_needs_reset': True, + 'queue_usbgpu_error_tmux': lambda *a: None, 'log_event': lambda *a, **kw: None, 'internal_needs_reset': True, 'egpu_recovery': None, 'meta_main': SimpleNamespace(frame_id=41), 'published': []} exec(compile(module, '', 'exec'), scope) assert scope['published'] == [2] and scope['last_vipc_frame_id'] == 41 assert scope['model'] is internal and reset == [True] -def test_usb_wait_only_adapts_verified_runtime_and_yields(monkeypatch): +@pytest.mark.parametrize('runtime_sha', ['90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2', + 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) +def test_usb_wait_only_adapts_verified_runtime_and_yields(monkeypatch, runtime_sha): import sys from openpilot.selfdrive.modeld import egpu_usb_wait as wait class AMD: @@ -105,7 +107,7 @@ def sleep(self, timeout): pass assert USB.sleep is old_sleep and AMD.sleep_timeout_ms == 200 sleeps = [] monkeypatch.setattr(wait.time, 'sleep', sleeps.append) - assert wait.configure_usb_wait({'runtime': {'sha256': wait.RUNTIME_SHA}}) + assert wait.configure_usb_wait({'runtime': {'sha256': runtime_sha}}) USB().sleep(AMD.sleep_timeout_ms) assert sleeps == [.0002] and AMD.sleep_timeout_ms == 1 assert AMD.wait_timeout_ms == 30000 diff --git a/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py b/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py index e2af783acb3..3e72134d32e 100644 --- a/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py +++ b/openpilot/selfdrive/modeld/tests/test_egpu_recovery.py @@ -118,7 +118,7 @@ def test_ready_rejects_bad_vehicle_state_even_when_stopped(manager, bad): assert value.tick(messages, True) is None -def test_exactly_three_attempts_cleanup_serialized_and_budget_survives_modeld_restart(manager): +def test_exactly_three_attempts_cleanup_serialized_and_budget_survives_modeld_restart(manager, capsys): value, clock = manager value.failed(USB_ERROR) for attempt in range(1, 4): @@ -131,12 +131,45 @@ def test_exactly_three_attempts_cleanup_serialized_and_budget_survives_modeld_re task.done.set() value.tick(Messages(clock.now), True) assert value.state == 'stopped' + events = [json.loads(line.removeprefix('[eGPU] ')) for line in capsys.readouterr().err.splitlines()] + assert [e['attempt'] for e in events if e['event'] == 'retry_started'] == [1, 2, 3] + assert [e['delay_s'] for e in events if e['event'] == 'retry_scheduled'] == list(recovery.DELAYS) + assert sum(e['event'] == 'retry_failed' for e in events) == 3 + assert events[-1]['event'] == 'retry_stopped' and events[-1]['reason'] == 'attempts_exhausted' + assert not any(e['event'] == 'retry_selected' for e in events) restarted = recovery.EgpuRecovery(value.path, 1344, 760, clock=clock, status_path=value.status_path, budget_path=value.budget_path) restarted.failed(USB_ERROR) assert restarted.exhausted and restarted.attempts == 3 +def test_retry_logs_distinguish_probe_ready_handover_and_actual_live_output(manager, capsys): + value, clock = manager + value.failed(USB_ERROR) + advance(manager, 11) + value.task.ready_at = clock.now + for _ in range(20): + value.tick(Messages(clock.now), True) + assert value.tick(Messages(clock.now, moving=False), True) is not None + captured = capsys.readouterr() + events = [json.loads(line.removeprefix('[eGPU] ')) for line in captured.err.splitlines()] + assert not captured.out + assert events[0]['error'] == str(USB_ERROR) and events[0]['active_model'] == 'internal' + assert sum(e['event'] == 'retry_ready' for e in events) == 1 + assert events[-1]['event'] == 'retry_selected' + assert not any(e['event'] == 'live_output_resumed' for e in events) + + +def test_usb_absence_explained_once_without_frame_rate_log_spam(manager, capsys): + value, _ = manager + value.failed(USB_ERROR) + advance(manager, 60, present=False) + events = [json.loads(line.removeprefix('[eGPU] ')) for line in capsys.readouterr().err.splitlines()] + waits = [e for e in events if e['event'] == 'retry_waiting'] + assert len(waits) == 1 and waits[0]['reason'] == 'USB absent' + assert not any(e['event'] == 'retry_started' for e in events) + + @pytest.mark.parametrize('error', [ValueError('non-finite model output'), ValueError('precompiled PKL checksum mismatch'), RuntimeError('AMD failed with 1'), RuntimeError('precompiled checkpoint mismatch'), TimeoutError('some other operation'), RuntimeError('precompiled eGPU worker exited (-9)')]) @@ -147,8 +180,9 @@ def test_numerical_integrity_and_unknown_failures_are_not_retried(manager, error assert value.exhausted and value.attempts == 0 -def test_usb_runtime_identity_required_and_worker_timeout_is_supported(): - error = RuntimeError('AMD failed with -1') +@pytest.mark.parametrize('code', [-1, 1]) +def test_usb_runtime_identity_required_and_worker_timeout_is_supported(code): + error = RuntimeError(f'AMD failed with {code}') assert not recovery.retryable(error, {}) manifest = {'runtime': {'sha256': '90970e01f2ba70d553294c0f9d5c6fe1678827f750705cab679a7b19b7bcb0b2'}} assert recovery.retryable(error, manifest) diff --git a/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py b/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py index 848182f124d..b6a6832778c 100644 --- a/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py +++ b/openpilot/selfdrive/modeld/tests/test_precompiled_artifact.py @@ -14,7 +14,8 @@ @pytest.mark.parametrize('fixture,checkpoint_id', [('mdm2_metadata.json', '870a4823-d2ec-4b9a-8015-b947e9901fc7'), - ('mdm_retrained_metadata.json', '8d736683-fed4-4e48-96fd-eae5d9d5bc56')]) + ('mdm_retrained_metadata.json', '8d736683-fed4-4e48-96fd-eae5d9d5bc56'), + ('md_v3_metadata.json', '1365126a-e71e-4778-be00-8ab92dc82c09')]) @pytest.mark.parametrize('previous_fixture', ['cinque_v3_metadata.json', 'mdm_v1_metadata.json']) def test_mdm_contract_matches_parser_and_preserves_history(previous_fixture, fixture, checkpoint_id): fixtures = Path(__file__).parent / 'fixtures' diff --git a/openpilot/selfdrive/modeld/tests/test_precompiled_model.py b/openpilot/selfdrive/modeld/tests/test_precompiled_model.py index f6efdc5f854..e90c920e5cf 100644 --- a/openpilot/selfdrive/modeld/tests/test_precompiled_model.py +++ b/openpilot/selfdrive/modeld/tests/test_precompiled_model.py @@ -228,33 +228,38 @@ def test_transport_match_does_not_hide_model_errors(error): @pytest.mark.parametrize('phase', ['load', 'boot_validation', 'inference']) -def test_pinned_usb_link_error_preserves_failure_without_blacklisting(tmp_path, phase): +@pytest.mark.parametrize('code', [-1, 1]) +def test_pinned_usb_link_error_preserves_failure_without_blacklisting(tmp_path, phase, code): + error = USB_LINK_FAILURE.replace("with -1", f"with {code}") path = tmp_path / 'model.pkl' manifest = {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': USB_RUNTIME_SHA}} (tmp_path / 'installed.json').write_text(json.dumps(manifest)) (tmp_path / 'boot_validation.json').write_text('{"key":"old"}') - assert not pm.record_failure(path, RuntimeError(USB_LINK_FAILURE), phase) + assert not pm.record_failure(path, RuntimeError(error), phase) assert not (tmp_path / 'rejected').exists() assert not (tmp_path / 'boot_validation.json').exists() failure = json.loads((tmp_path / 'last_failure.json').read_text()) - assert failure['error'] == USB_LINK_FAILURE and not failure['rejected'] and failure['phase'] == phase + assert failure['error'] == error and not failure['rejected'] and failure['phase'] == phase @pytest.mark.parametrize('error', ['AMD failed with -1', 'RuntimeError: AMD failed with -1', - 'RuntimeError: AMD:0 failed with -1', USB_LINK_FAILURE]) -def test_usb_link_error_matches_only_verified_runtime(error): - assert pm.gpu_usb_link_failure(error, {'runtime': {'sha256': USB_RUNTIME_SHA}}) + 'RuntimeError: AMD:0 failed with -1', 'RuntimeError: AMD failed with 1', + 'RuntimeError: AMD:0 failed with 1', USB_LINK_FAILURE]) +@pytest.mark.parametrize('runtime_sha', [USB_RUNTIME_SHA, 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) +def test_usb_link_error_matches_only_verified_runtime(error, runtime_sha): + assert pm.gpu_usb_link_failure(error, {'runtime': {'sha256': runtime_sha}}) for manifest in ({}, {'runtime': None}, {'runtime': []}, {'runtime': {'sha256': 'b' * 64}}): assert not pm.gpu_usb_link_failure(error, manifest) -@pytest.mark.parametrize('error', ['RuntimeError: AMD failed with -2', 'RuntimeError: AMD failed with 1', +@pytest.mark.parametrize('error', ['RuntimeError: AMD failed with -2', 'RuntimeError: AMD failed with 2', 'ValueError: AMD failed with -1', 'RuntimeError: AMD failed with -10', 'RuntimeError: AMD failed with -1\nValueError: checksum mismatch', 'RuntimeError: quoted AMD failed with -1', 'RuntimeError: NV failed with -1']) -def test_ambiguous_or_other_gpu_errors_still_reject(tmp_path, error): +@pytest.mark.parametrize('runtime_sha', [USB_RUNTIME_SHA, 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) +def test_ambiguous_or_other_gpu_errors_still_reject(tmp_path, error, runtime_sha): (tmp_path / 'installed.json').write_text(json.dumps( - {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': USB_RUNTIME_SHA}})) + {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': runtime_sha}})) assert pm.record_failure(tmp_path / 'model.pkl', RuntimeError(error), 'inference') assert (tmp_path / 'rejected').exists() @@ -263,13 +268,16 @@ def test_ambiguous_or_other_gpu_errors_still_reject(tmp_path, error): ({'boot_id': ''}, False), ({'phase': 'unknown'}, False), ({'pickle_sha256': 'b' * 64}, False), ({'rejected': False}, False)]) @pytest.mark.parametrize('phase', ['load', 'boot_validation', 'inference']) -def test_old_usb_link_block_needs_new_boot_and_matching_artifact(tmp_path, monkeypatch, change, allowed, phase): +@pytest.mark.parametrize('code', [-1, 1]) +@pytest.mark.parametrize('runtime_sha', [USB_RUNTIME_SHA, 'c7c58622652231359ecea1a4253b63e157d002242bf596ede1b853f941f937ac']) +def test_old_usb_link_block_needs_new_boot_and_matching_artifact(tmp_path, monkeypatch, change, allowed, phase, code, runtime_sha): + error = USB_LINK_FAILURE.replace("with -1", f"with {code}") from openpilot.selfdrive.modeld import egpu_worker_progress monkeypatch.setattr(egpu_worker_progress, 'boot_identity', lambda: 'this-boot') (tmp_path / 'installed.json').write_text(json.dumps( - {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': USB_RUNTIME_SHA}})) + {'pickle': {'sha256': 'a' * 64}, 'runtime': {'sha256': runtime_sha}})) failure = {'rejected': True, 'pickle_sha256': 'a' * 64, 'phase': phase, - 'boot_id': 'previous-boot', 'error': USB_LINK_FAILURE} | change + 'boot_id': 'previous-boot', 'error': error} | change (tmp_path / 'last_failure.json').write_text(json.dumps(failure)) assert pm.retry_usb_rejection(tmp_path, 'a' * 64) == allowed assert json.loads((tmp_path / 'last_failure.json').read_text()) == failure @@ -279,7 +287,9 @@ def test_old_usb_link_block_needs_new_boot_and_matching_artifact(tmp_path, monke assert not pm.retry_usb_rejection(tmp_path, 'a' * 64) -def test_previous_usb_link_block_revalidates_files_and_smoke_receipt(tmp_path, monkeypatch): +@pytest.mark.parametrize('code', [-1, 1]) +def test_previous_usb_link_block_revalidates_files_and_smoke_receipt(tmp_path, monkeypatch, code): + error = USB_LINK_FAILURE.replace("with -1", f"with {code}") from openpilot.selfdrive.modeld import egpu_worker_progress value, data = catalog() model = SimpleNamespace(sha256='a' * 64, url='https://nas.example/models/v2/model.onnx') @@ -290,7 +300,7 @@ def fetch(request, **kwargs): monkeypatch.setattr(egpu_worker_progress, 'boot_identity', lambda: 'this-boot') path = pm.ensure_precompiled(model, tmp_path) failure = {'rejected': True, 'pickle_sha256': value['pickle']['sha256'], 'phase': 'inference', - 'boot_id': 'previous-boot', 'error': USB_LINK_FAILURE} + 'boot_id': 'previous-boot', 'error': error} (path.parent / 'last_failure.json').write_text(json.dumps(failure)) # The recorded old manifest supplies the verified runtime identity; the test # catalog then delivers a tiny replacement rather than a real GPU runtime. diff --git a/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py b/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py index c333193c490..83b927cff19 100644 --- a/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py +++ b/openpilot/selfdrive/modeld/tests/test_precompiled_rejection_retry.py @@ -19,7 +19,7 @@ ('ValueError: checkpoint mismatch', 'inference', True, False), ('NonFiniteModelOutput: invalid\nValueError: checksum mismatch', 'inference', True, False), ]) -def test_reboot_retry_rehashes_artifacts_and_requires_new_smoke_test(tmp_path, monkeypatch, error, phase, old_boot, allowed): +def test_reboot_retry_rehashes_artifacts_and_requires_new_smoke_test(tmp_path, monkeypatch, capsys, error, phase, old_boot, allowed): value, data = catalog() model = SimpleNamespace(sha256='a' * 64, url='https://nas.example/models/v2/model.onnx') def fetch(request, **kwargs): @@ -37,6 +37,10 @@ def fetch(request, **kwargs): path.write_bytes(b'corrupt') (path.parent / 'runtime.tar.gz').write_bytes(b'corrupt') result = pm.ensure_precompiled(model, tmp_path) + events = [json.loads(line.removeprefix('[eGPU] ')) for line in capsys.readouterr().err.splitlines()] + assert events[-1]['event'] == ('artifact_retry' if allowed else 'artifact_blocked') + if not allowed: + assert events[-1]['retry'] is False and events[-1]['error'] == error.splitlines()[-1] assert (result == path) == allowed assert (path.parent / 'rejected').exists() == (not allowed) assert json.loads((path.parent / 'last_failure.json').read_text()) == failure diff --git a/openpilot/system/tests/test_panda_firmware_version.py b/openpilot/system/tests/test_panda_firmware_version.py index ef59ff1d06e..2751367a22b 100644 --- a/openpilot/system/tests/test_panda_firmware_version.py +++ b/openpilot/system/tests/test_panda_firmware_version.py @@ -15,7 +15,7 @@ def source_tree(tmp_path): files = ('panda/SConscript', 'panda/SConstruct', 'panda/firmware_version.py', 'panda/certs/debug.pub', 'panda/certs/release.pub', 'panda/board/main.c', 'panda/board/bootstub.c', 'panda/board/stm32h7/flash.ld', 'panda/crypto/sign.py', - 'opendbc/safety/safety.h', 'opendbc/safety/safety/safety_hyundai.h') + 'opendbc/safety/safety.h', 'opendbc/safety/safety/safety_hyundai.h', 'opendbc/safety/safety/safety_tesla.h') for name in files: path = tmp_path / name path.parent.mkdir(parents=True, exist_ok=True) @@ -38,7 +38,8 @@ def test_unrelated_changes_do_not_change_firmware(source_tree, name): @pytest.mark.parametrize('name', ['panda/board/main.c', 'panda/board/stm32h7/flash.ld', 'panda/SConscript', 'panda/crypto/sign.py', - 'panda/certs/debug.pub', 'opendbc/safety/safety/safety_hyundai.h']) + 'panda/certs/debug.pub', 'opendbc/safety/safety/safety_hyundai.h', + 'opendbc/safety/safety/safety_tesla.h']) def test_actual_firmware_inputs_change_version_without_git_commit(source_tree, name): before = source_version(source_tree) (source_tree / name).write_text('changed input') diff --git a/tools/carrot_route_vault/tests/test_viewer.py b/tools/carrot_route_vault/tests/test_viewer.py index 765e78a2a5e..d684bdf129b 100644 --- a/tools/carrot_route_vault/tests/test_viewer.py +++ b/tools/carrot_route_vault/tests/test_viewer.py @@ -100,6 +100,48 @@ async def run(): asyncio.run(run()) +def test_s3_installer_serves_only_release_files_and_supports_resume(tmp_path): + async def run(): + root = tmp_path / 'uploads/models/carrot-s3' + for name in [*viewer_module.S3_DOWNLOAD_TYPES, 'private.json', 'nvs.bin', 'vendor/secret.txt']: + path = root/name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'0123456789') + async with TestClient(TestServer(create_app(viewer_config(tmp_path), start_cleanup=False))) as client: + for name, content_type in viewer_module.S3_DOWNLOAD_TYPES.items(): + response = await client.get('/models/carrot-s3/'+name) + assert response.status == 200 and await response.read() == b'0123456789' + assert response.headers['Content-Type'] == content_type + assert response.headers['X-Content-Type-Options'] == 'nosniff' + for name in ['private.json', 'nvs.bin', 'vendor/secret.txt', '', '../private.json']: + assert (await client.get('/models/carrot-s3/'+name)).status == 404 + response = await client.get('/models/carrot-s3/carrot-s3.bin', headers={'Range':'bytes=4-'}) + assert response.status == 206 and await response.read() == b'456789' + assert (await client.head('/models/carrot-s3/carrot-s3.bin')).headers['Content-Length'] == '10' + asyncio.run(run()) + + +@pytest.mark.parametrize('component', ['models', 'carrot-s3', 'vendor', 'esptool-js-bundle.js']) +def test_s3_installer_rejects_symlinks(tmp_path, component): + async def run(): + root = tmp_path/'uploads' + parts = ['models', 'carrot-s3', 'vendor', 'esptool-js-bundle.js'] + index = parts.index(component) + parent = root.joinpath(*parts[:index]) + parent.mkdir(parents=True) + outside = tmp_path/'private' + target = outside.joinpath(*parts[index+1:]) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b'private') + try: + (parent/component).symlink_to(outside, target_is_directory=index != 3) + except OSError: + pytest.skip('Creating symlinks requires Windows permission') + async with TestClient(TestServer(create_app(viewer_config(tmp_path), start_cleanup=False))) as client: + assert (await client.get('/models/carrot-s3/vendor/esptool-js-bundle.js')).status == 404 + asyncio.run(run()) + + @pytest.mark.parametrize('component', ['downloads', 'jetson', 'v0.2.0-preview', 'carrot-jetson.img.zst']) def test_jetson_download_rejects_symlink_at_each_component(tmp_path, component): async def run(): diff --git a/tools/carrot_route_vault/viewer.py b/tools/carrot_route_vault/viewer.py index c2b8a0829be..87216839446 100644 --- a/tools/carrot_route_vault/viewer.py +++ b/tools/carrot_route_vault/viewer.py @@ -69,6 +69,24 @@ "precompiled-runtime.tar.gz": "application/gzip", } +# Only the public S3 release artifacts; never expose arbitrary model/NVS files. +S3_DOWNLOAD_TYPES = { + "index.html": "text/html; charset=utf-8", + "installer.js": "text/javascript", + "manifest.mjs": "text/javascript", + "release.json": "application/json", + "SHA256SUMS.txt": "text/plain", + "INSTALL_KO.md": "text/plain; charset=utf-8", + "README.md": "text/plain; charset=utf-8", + "bootloader.bin": "application/octet-stream", + "partition-table.bin": "application/octet-stream", + "carrot-s3.bin": "application/octet-stream", + "carrot-s3-1.1.0.zip": "application/zip", + "vendor/esptool-js-bundle.js": "text/javascript", + "vendor/esptool-js-LICENSE.txt": "text/plain", + "vendor/pako-LICENSE.txt": "text/plain", +} + JETSON_RELEASE_RE = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,63})?$") JETSON_DOWNLOAD_TYPES = { "carrot-jetson.img.zst": "application/zstd", @@ -878,6 +896,27 @@ async def model_file(self, request: web.Request) -> web.StreamResponse: ) return web.FileResponse(path, headers={"Content-Type": MODEL_CONTENT_TYPES[filename]}) + async def s3_download(self, request: web.Request) -> web.StreamResponse: + name = request.match_info.get("path", "") + if name not in S3_DOWNLOAD_TYPES: + raise web.HTTPNotFound(text="S3 release file not found") + root = self.storage_root.resolve() + path = root + try: + for part in ("models", "carrot-s3", *name.split("/")): + path = path / part + if path.is_symlink(): + raise web.HTTPNotFound(text="S3 release file not found") + if not path.is_file() or root not in path.resolve().parents: + raise web.HTTPNotFound(text="S3 release file not found") + except OSError as exc: + raise web.HTTPNotFound(text="S3 release file not found") from exc + headers = {"Content-Type": S3_DOWNLOAD_TYPES[name], "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", "Permissions-Policy": "serial=(self)"} + if name.endswith((".zip", ".bin")): + headers["Content-Disposition"] = f'attachment; filename="{name}"' + return web.FileResponse(path, headers=headers) + async def jetson_download(self, request: web.Request) -> web.StreamResponse: filename = request.match_info.get("filename", "") path = await asyncio.to_thread(self._jetson_download_path, request.match_info.get("release"), filename) @@ -1177,6 +1216,7 @@ def register(self, app: web.Application) -> None: app.router.add_get(f"{prefix}/devices/{{device}}/routes_segments", self.api_share_device_routes) app.router.add_get(f"{prefix}/devices/{{device}}/routes/preserved", self.api_share_device_routes) + app.router.add_get("/models/carrot-s3/{path:.*}", self.s3_download) app.router.add_get("/models/{family}/{filename}", self.model_file) app.router.add_get("/downloads/jetson/{release}/{filename}", self.jetson_download) app.router.add_get("/routes/{directory}/{selection}", self.public_page) diff --git a/tools/esp32s3_display/INSTALL_KO.md b/tools/esp32s3_display/INSTALL_KO.md new file mode 100644 index 00000000000..74f6eed27e8 --- /dev/null +++ b/tools/esp32s3_display/INSTALL_KO.md @@ -0,0 +1,144 @@ +# CARROT S3 Wi-Fi HUD 설치 설명서 + +**일반 사용자는 [NAS 설치 페이지](https://upload.shind0.synology.me/models/carrot-s3/index.html)를 여세요.** +PC Chrome/Edge에서 보드를 선택하고 설치할 수 있습니다. 개발 환경은 필요 없습니다. +[펌웨어·설명서 ZIP](https://upload.shind0.synology.me/models/carrot-s3/carrot-s3-1.1.0.zip) · +[검증값](https://upload.shind0.synology.me/models/carrot-s3/SHA256SUMS.txt) + +## 준비물과 지원 범위 + +| 준비물 | 확인할 내용 | +| --- | --- | +| ESP32-S3 N16R8 | Flash 16MB, Octal PSRAM 8MB, UART/USB 두 USB-C 포트가 있는 DevKitC-1 형태 | +| 안테나 | 보드 끝에 안테나가 붙은 제품 권장. 외장 안테나형은 별도 안테나 필요하며 이번 검증 대상 아님 | +| USB 화면 | TURZX 9.2인치 1920×462, USB ID `1cbe:0092` | +| 전원형 USB 허브 | 본체 연결용 케이블 + USB 데이터 포트 + 외부 전원 입력. 모든 허브의 호환성을 보장하지 않음 | +| 전원·케이블 | 화면/허브 요구 전력을 충족하는 USB 전원, 데이터 지원 USB 케이블 | + +ESP32, S2, C3, N8R2, PSRAM 없는 보드는 이 펌웨어 대상이 아닙니다. +HDMI 또는 USB-C DP Alt Mode 모니터로 영상 신호를 내보내는 장치가 아닙니다. + +## 연결 + +```text +PC 또는 C4 ── S3 UART 포트 (BOOT 버튼 쪽): 설치·최초 등록 + S3 USB 포트 (RESET 버튼 쪽) + │ + 허브 본체 연결용 케이블 + │ + 전원형 허브 ── USB 화면 (데이터 포트) + │ + USB 전원 입력 +``` + +보드의 좌우보다 `UART` / `USB` 인쇄를 확인하세요. 시험 보드는 USB 포트에서 +화면용 전원을 공급하지 못해 전원형 허브가 필요했습니다. 전원을 받은 화면이 +제조사 로고를 보여도 USB 데이터 통신이 된다는 뜻은 아닙니다. +등록 전 대기 화면은 소등될 수 있으며, 설정 모드를 열면 설정 안내가 나옵니다. + +## 1. 펌웨어 설치 또는 업데이트 + +1. S3 **UART** 포트를 데이터 케이블로 PC에 연결합니다. +2. PC Chrome/Edge에서 NAS 설치 페이지를 엽니다. +3. 새 보드는 **처음 설치**, 기존 CARROT 보드는 **업데이트**를 선택합니다. +4. 보드 확인란을 선택하고 설치 버튼을 눌러 **CP210x / USB Serial** 포트를 고릅니다. +5. 완료 메시지까지 기다립니다. S3가 재시작합니다. + +업데이트는 기존 파티션을 검사한 뒤 앱만 교체합니다. 처음 설치도 전체 지우기를 +하지 않습니다. 설치 전후 등록 저장 영역을 비교해 Wi-Fi·등록 정보 보존을 확인합니다. +처음 설치는 보드의 기존 프로그램을 CARROT으로 교체합니다. + +포트가 없으면 데이터 케이블/UART 포트를 확인하고, 필요 시 +[Silicon Labs CP210x 드라이버](https://www.silabs.com/developer-tools/usb-to-uart-bridge-vcp-drivers)를 설치합니다. +다른 UART 칩 보드는 해당 칩의 드라이버가 필요합니다. 자동 연결에 실패하면 +BOOT를 누른 채 RESET을 한 번 누르고 두 버튼을 뗀 뒤 재시도하세요. +다른 시리얼 모니터는 닫아 두세요. 실패 시 같은 설치를 다시 실행할 수 있습니다. + +## 2-A. C4 USB로 등록 (권장) + +C4에 최신 carrot-nav/carrot-wip가 설치돼 있어야 합니다. C4를 사용할 2.4GHz +Wi-Fi/폰 핫스팟에 연결한 뒤 S3의 UART 포트를 C4에 연결하세요. +C4 웹 터미널 또는 SSH 터미널에서 실행합니다. 시동을 꺼도 등록할 수 있습니다. + +```sh +cd /data/openpilot +python3 tools/esp32s3_display/provision.py --auto +``` + +현재 Wi-Fi 이름과 암호를 S3에 저장하고 C4 등록까지 마칩니다. 지원 UART가 한 개일 +때만 자동 선택하며 S3 응답을 확인한 뒤 저장합니다. 여러 포트가 있으면 실제 S3를 +확인해 `--auto` 대신 `--port /dev/ttyUSB0`처럼 지정하세요. +Wi-Fi 암호를 읽을 권한이 없으면 `sudo -E`로 실행하거나 `--ssid`를 사용합니다. +**케이블을 꽂는 것만으로 등록되지는 않습니다.** + +## 2-B. 폰으로 설정 + +1. 첫 설치 또는 부팅 후 **BOOT 3초**로 설정 화면을 엽니다. 전원을 넣을 때 BOOT를 + 누르고 있으면 다운로드 모드가 되므로 부팅이 끝난 뒤 누르세요. +2. 폰에서 Wi-Fi **CARROT-HUD**, 암호 **12345678**에 접속합니다. +3. **http://192.168.4.1**을 엽니다. 시간 초과 시 “인터넷 없이 연결 유지”를 선택하고 + 모바일 데이터를 잠깐 끕니다. `setup closed`면 BOOT 3초로 다시 엽니다. +4. 차에서 사용할 2.4GHz Wi-Fi 이름·암호를 입력하고 **Wi-Fi 저장**을 누릅니다. + 설정은 약 15초 뒤 닫힙니다. 미저장 설정 창은 10분 후 닫힙니다. +5. 첫 C4 등록이면 **처음 C4 연결: 등록 파일 받기**를 누릅니다. 매번 다른 이름의 + JSON이 다운로드됩니다. Wi-Fi만 바꾸는 경우에는 다운로드가 필요 없습니다. +6. JSON을 WinSCP/SFTP 등으로 C4의 `/data/carrot-s3.json`에 복사하고 아래를 실행합니다. + 파일 전송이 어렵다면 USB 등록(2-A)을 선택하세요. + +```sh +cd /data/openpilot +python3 tools/esp32s3_display/provision.py --import-pairing /data/carrot-s3.json +``` + +등록 파일은 해당 화면에 접근할 수 있는 키입니다. 공개하거나 다른 사람에게 보내지 +마세요. 핫스팟 폰이 Wi-Fi 접속을 동시에 못 하면 다른 폰으로 설정 후 핫스팟을 켭니다. +아이폰 핫스팟은 **호환성 최대화**, 안드로이드는 **2.4GHz**를 선택하세요. + +## 3. 사용 + +1. C4 설정에서 **외부 HUD (ClusterHud) → 9.2인치**를 선택합니다. +2. C4와 S3를 같은 Wi-Fi/핫스팟에 연결합니다. 기기 간 통신을 차단하는 게스트망은 + 사용할 수 없습니다. 차량에서는 PC·USB 테더링이 필요 없습니다. +3. 시동 ON에서 표시하고 OFF에서 소등합니다. 대기 중 화면 시험은 + `ClusterHudDebug`를 사용하고 시험 후 **0**으로 되돌립니다. + +밝기·방향·미러·카메라는 기존 HUD 설정을 사용합니다. Wi-Fi는 기본 최대 15fps, +eGPU 사용 중 최대 10fps입니다. 실제 속도는 화면 내용과 무선 환경에 따라 달라지며 +광학 패널 FPS 보장이 아닙니다. 직접 USB 연결의 기존 5fps 정책은 별도입니다. + +## 다음에 바꿀 때 + +- **SSID/암호:** BOOT 3초 → 폰에서 저장, 또는 새 Wi-Fi에 연결된 C4에서 USB 등록 명령 재실행. + 같은 보드의 등록 키는 유지되어 다시 JSON을 옮길 필요가 없습니다. +- **펌웨어:** NAS 설치 페이지의 업데이트. 일반적인 C4 화면 변경마다 다시 설치할 필요는 + 없지만 S3 자체 버그 수정은 재설치가 필요할 수 있습니다. OTA는 없습니다. +- **직접 USB로 복귀:** C4 등록 파일 `/data/carrot/cluster_wifi.json`을 별도 위치로 옮기고 + HUD를 재시작합니다. 나중에 복원하려면 등록 파일을 보관하세요. + +## 화면이 안 나오거나 꺼지지 않을 때 + +- 제조사 로고만 보이면 허브 본체 케이블이 S3의 **USB**에 연결됐는지 확인합니다. + 전원이 들어오는 것과 데이터 연결은 다릅니다. 다른 허브로 비교할 수 있습니다. +- 설정 Wi-Fi는 보이는데 설정 화면이 안 보이면 화면/허브 연결부터 확인합니다. +- 대기 중 꺼짐은 정상입니다. 시동 ON에서도 안 보이면 C4 등록과 동일 네트워크를 확인합니다. +- 지연이 크면 안테나를 금속·허브에서 떨어뜨리고 다른 2.4GHz 핫스팟과 비교합니다. +- 시동 OFF인데 계속 켜지면 Debug 0, C4 최신 코드, S3 1.1.0 이상을 확인합니다. + 1.1.0은 소등 명령 응답을 확인하고 실패 시 재시도합니다. 통신이 끊겨도 마지막 + 정상 영상 약 2초 후 자체 소등하며 USB 타임아웃이 추가될 수 있습니다. 대기 중 + 30초마다 소등을 재확인합니다. 설정 안내 중에는 켜져 있습니다. + +소등은 백라이트 끄기이며 USB 전원 차단은 아닙니다. + +## 고급 사용자: 파일로 설치 + +ZIP에는 `bootloader.bin`, `partition-table.bin`, `carrot-s3.bin`, `release.json`, +`SHA256SUMS.txt`, 설명서, 검증된 빌드 설정이 있습니다. esptool 4.11 사용 예: + +```sh +python -m esptool --chip esp32s3 --port COM14 --baud 460800 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 carrot-s3.bin +``` + +`COM14`는 예시이며 실제 포트를 선택합니다. 기존 CARROT 앱만 업데이트할 때는 +마지막 `0x10000 carrot-s3.bin`만 기록합니다. **erase_flash는 실행하지 마세요.** +0x9000–0xEFFF의 NVS에는 개인 Wi-Fi/등록 정보가 있습니다. 배포 ZIP에는 이 영역이나 +개인 설정이 포함되지 않습니다. ESP-IDF 5.4.0 소스 빌드는 [README](README.md)를 참고하세요. diff --git a/tools/esp32s3_display/README.md b/tools/esp32s3_display/README.md index 0b88f887cfc..2e768a17154 100644 --- a/tools/esp32s3_display/README.md +++ b/tools/esp32s3_display/README.md @@ -14,6 +14,13 @@ the tested board. This is USB data forwarding, not DisplayPort/USB-C video outpu ## One-time firmware installation +For ordinary users, use the [NAS browser installer and Korean setup guide](https://upload.shind0.synology.me/models/carrot-s3/index.html). +It includes the N16R8 board checklist, wiring diagram, initial setup and a firmware +ZIP download. Chrome/Edge on a PC can install without Python or ESP-IDF. The update +option validates the existing partition table, writes only the app, and verifies +that NVS is unchanged. Version 1.1.0 fixes confirmed-off/retry handling. +See also [INSTALL_KO.md](INSTALL_KO.md). The commands below are for source builds. + Use ESP-IDF **5.4.0**, target esp32s3, from `firmware/`: ```sh @@ -33,21 +40,23 @@ Connect C4 to the intended **2.4 GHz** hotspot first, then connect S3's UART USB connector to C4. Run from the openpilot repository: ```sh -python tools/esp32s3_display/provision.py --port /dev/ttyUSB0 +python3 tools/esp32s3_display/provision.py --auto ``` The utility reads the active NetworkManager Wi-Fi profile and password, verifies the UART bridge identity, writes credentials to S3 NVS, and saves the device ID and pairing key in `/data/carrot/cluster_wifi.json`. It does not save the Wi-Fi password in that file or print it. Reading the active profile's secrets may require root. -Select the actual UART port; the utility never probes or resets other serial devices. +Auto selects only when exactly one supported USB UART is present, then verifies +the S3 identity before writing. Otherwise select the actual UART with `--port`; +the utility never probes or resets other serial devices. This works while C4 is offroad. Plugging in the cable alone does not run registration: run the command again after changing C4's active Wi-Fi to copy the new credentials. For manual setup on a PC or a C4 without readable NetworkManager credentials: ```sh -python tools/esp32s3_display/provision.py --port COM14 --ssid "My hotspot" --output carrot-s3.json +python3 tools/esp32s3_display/provision.py --port COM14 --ssid "My hotspot" --output carrot-s3.json ``` The password is prompted without echo. Import that registration file on C4 with @@ -73,7 +82,7 @@ the command below. Never commit or share a registration file. 5. Transfer the downloaded registration file to C4 and import it once: ```sh -python tools/esp32s3_display/provision.py --import-pairing /path/to/carrot-s3.json +python3 tools/esp32s3_display/provision.py --import-pairing /path/to/carrot-s3.json ``` Both registration methods preserve the existing pairing key when changing SSID. @@ -106,7 +115,8 @@ code, not a new S3 firmware. The Wi-Fi path uses JPEG quality 50 and 15 FPS by default, independently of `ClusterHudEncoder`. Optional integer `fps` (1–20) and `quality` (20–90) fields in the registration JSON change that policy on the next HUD start. The existing -5 FPS eGPU activity cap remains in force. Brightness, layout, camera, mirror and +Wi-Fi eGPU activity cap is 10 FPS; the wired USB cap remains 5 FPS. Lower configured +rates still apply. Brightness, layout, camera, mirror and orientation settings still apply through the existing renderer. H.264 is not enabled or validated for this bridge, although the transport carries opaque packets. Remove the registration file and restart HUD to return to direct USB behavior. @@ -133,6 +143,13 @@ stored credentials. The phone registration file authorizes access to that displa ## Validation +Version 1.1.0 initializes the panel before local shutdown, waits 200 ms, and only +marks it dark after the brightness command succeeds. Failed local shutdowns retry +after 1/2/4/8/16 seconds; idle panels are reasserted off every 30 seconds to recover +a powered-panel reboot. This is backlight off, not removal of USB power. Setup +screens remain visible. C4 also includes registered S3 displays in offroad dimming +and confirms shutdown on connection close. First image wakes the panel again. + The October 15 native C4 test corrected a missing registration, an AP channel latency problem, and the graphics-startup idle disconnect. The existing camera HUD then rendered at typically 9–13 Hz, with model and driver monitoring at 20 Hz in diff --git a/tools/esp32s3_display/firmware/CMakeLists.txt b/tools/esp32s3_display/firmware/CMakeLists.txt index 85783064541..6981a354693 100644 --- a/tools/esp32s3_display/firmware/CMakeLists.txt +++ b/tools/esp32s3_display/firmware/CMakeLists.txt @@ -1,3 +1,4 @@ cmake_minimum_required(VERSION 3.16) +set(PROJECT_VER "1.1.0") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(carrot_s3_display) diff --git a/tools/esp32s3_display/firmware/main/usb_bridge.c b/tools/esp32s3_display/firmware/main/usb_bridge.c index 9d4919dd67c..89d14413c73 100644 --- a/tools/esp32s3_display/firmware/main/usb_bridge.c +++ b/tools/esp32s3_display/firmware/main/usb_bridge.c @@ -21,6 +21,8 @@ static usb_host_client_handle_t client; static usb_device_handle_t opened[128]; static int64_t last_frame; static bool dark = true; +static int64_t next_off; +static unsigned off_failures; static bool setup_shown; static uint8_t brightness = 80; static uint8_t response_data[512]; @@ -110,20 +112,39 @@ static bool send_payload(const uint8_t *data, size_t size) { return true; } -static void local_brightness(uint8_t value) { - uint8_t packet[512]; display_command(packet, 14, value); - if (send_payload(packet, sizeof(packet))) { - transfer_timeout_ms=50; read_response(claimed_device); transfer_timeout_ms=1000; +static bool command_response(uint8_t command) { + for (int i=0; i<4; i++) { + if (!read_response(claimed_device)) return false; + if (response_command==command) return true; } + return false; +} + +static bool local_brightness(uint8_t value) { + uint8_t packet[512]; display_command(packet, 14, value); + return send_payload(packet, sizeof(packet)) && command_response(14); +} + +static void turn_off(void) { + uint8_t sync[512]; display_command(sync, 10, 0); + bool ok=send_payload(sync, sizeof(sync)) && command_response(10); + // The panel needs time after initialization, including its factory splash. + if (ok) vTaskDelay(pdMS_TO_TICKS(200)); + ok=ok && local_brightness(0); + dark=ok; + if (ok) off_failures=0; + else if (off_failures<5) off_failures++; + unsigned delay_s=ok ? 30 : (1U << (off_failures-1)); + next_off=esp_timer_get_time()+delay_s*1000000LL; + ESP_LOGI(TAG, "DISPLAY_OFF confirmed=%d failures=%u retry_s=%u", ok, off_failures, delay_s); } static void claim_display(usb_device_handle_t dev) { if (claimed_device) return; if (usb_host_interface_claim(client, dev, 0, 0) != ESP_OK) return; - claimed_device=dev; setup_shown=false; dark=true; last_frame=0; - uint8_t sync[512]; display_command(sync, 10, 0); - if (send_payload(sync, sizeof(sync))) read_response(dev); - local_brightness(0); + claimed_device=dev; setup_shown=false; dark=false; last_frame=0; + next_off=0; off_failures=0; + turn_off(); } static void event_cb(const usb_host_client_event_msg_t *event, void *arg) { @@ -184,7 +205,7 @@ static void describe_device(uint8_t address) { } static void usb_task(void *arg) { - ESP_LOGI(TAG, "Carrot S3 display bridge v1; no OTA; external powered USB hub required"); + ESP_LOGI(TAG, "Carrot S3 display bridge 1.1.0; no OTA; external powered USB hub required"); ESP_LOGI(TAG, "External hub support=%d", CONFIG_USB_HOST_HUBS_SUPPORTED); event_queue = xQueueCreate(16, sizeof(usb_host_client_event_msg_t)); assert(event_queue); @@ -231,39 +252,42 @@ static void usb_task(void *arg) { } } if (!setup && setup_shown) { - if (claimed_device) local_brightness(0); - setup_shown=false; dark=true; + setup_shown=false; dark=false; last_frame=0; next_off=0; } int slot; if (xQueueReceive(network_ready, &slot, 0) == pdTRUE) { network_frame_t *frame=&network_frames[slot]; streaming=true; int64_t start=esp_timer_get_time(); - bool ok=claimed_device && network_current(frame) && !setup; + bool current=claimed_device && network_current(frame) && !setup; + bool ok=current; uint8_t plain[504]; display_decode(frame->data, plain); uint8_t command=plain[0]; uint32_t stamp; memcpy(&stamp, plain+4, 4); bool image=command==101 || command==102; response_length=0; - if (ok && image && dark) local_brightness(brightness); + if (ok && image && (dark || esp_timer_get_time()-last_frame>2000000)) ok=local_brightness(brightness); if (ok) ok=send_payload(frame->data, frame->length); if (ok) { - transfer_timeout_ms=frame->expect_reply ? 1000 : 50; + bool require_reply=frame->expect_reply || (command==14 && plain[8]==0); + transfer_timeout_ms=require_reply ? 1000 : 50; bool received=read_response(claimed_device); transfer_timeout_ms=1000; // Discard late optional command replies, never mistake them for a frame ACK. for (int i=0; received && response_command!=command && i<3; i++) received=read_response(claimed_device); - if (frame->expect_reply) ok=received && response_command==command && (!image || response_stamp==stamp); + if (require_reply) ok=received && response_command==command && (!image || response_stamp==stamp); if (!received) response_length=0; } - if (ok && image) { last_frame=esp_timer_get_time(); dark=false; } - if (ok && command==14) { brightness=plain[8]; dark=brightness==0; } + if (ok && image) { last_frame=esp_timer_get_time(); dark=false; next_off=0; off_failures=0; } + if (current && command==14 && plain[8]==0) { + dark=ok; last_frame=0; next_off=ok ? esp_timer_get_time()+30000000 : 0; + } else if (ok && command==14) { brightness=plain[8]; dark=false; } if (ok) good_frames++; else bad_frames++; network_complete(slot, ok ? 0 : 1, esp_timer_get_time()-start, response_data, response_length); streaming=false; } - if (claimed_device && !dark && !setup && esp_timer_get_time()-last_frame>2000000) { - local_brightness(0); dark=true; + if (claimed_device && !setup && esp_timer_get_time()-last_frame>2000000 && esp_timer_get_time()>=next_off) { + turn_off(); } int64_t now = esp_timer_get_time(); if (now >= report_at) { diff --git a/tools/esp32s3_display/installer/index.html b/tools/esp32s3_display/installer/index.html new file mode 100644 index 00000000000..92e3048df56 --- /dev/null +++ b/tools/esp32s3_display/installer/index.html @@ -0,0 +1,26 @@ + + +CARROT S3 · 설치와 설정 + +
CARROT · WI-FI HUD

USB 화면을
Wi-Fi로 연결하세요.

C4가 화면을 만들고, ESP32-S3가 USB 디스플레이로 전달합니다.
이 페이지에서 펌웨어 설치부터 폰 설정, C4 등록까지 진행할 수 있습니다.

펌웨어 1.1.0 · N16R8 전용 · 2026-10-15

펌웨어·설명서 ZIP 받기 파일 검증값
+

1. 준비할 제품

제품확인할 내용
ESP32-S3 N16R8 보드Flash 16MB / Octal PSRAM 8MB, USB-C 두 개, UART·USB 표시가 있는 DevKitC-1 형태. 안테나가 붙어 있는 보드를 권장합니다.
TURZX 9.2인치 USB 화면1920×462, USB ID 1cbe:0092. 이 조합으로 시험했습니다.
외부 전원 USB 허브본체 연결용 케이블과 데이터 포트, 별도의 전원 입력이 필요합니다. 허브 호환성은 제품마다 다릅니다.
케이블·전원데이터 지원 USB 케이블, 화면과 허브의 요구 전력을 충족하는 USB 전원 장치.

ESP32, S2, C3, N8R2 또는 PSRAM이 없는 보드는 이 파일의 설치 대상이 아닙니다. USB-C 영상 입력(DP Alt Mode) 모니터나 HDMI 화면은 지원하지 않습니다. 외장 안테나형 보드는 별도 안테나가 필요하며 이번 검증 대상이 아닙니다.

+

2. 두 USB 포트를 구분해 연결

+PC / C4ESP32-S3UART ← → USBBOOT 쪽 / RESET 쪽전원형 USB 허브USB 화면USB 전원설치·최초 USB 등록 시 연결허브의 본체 연결용 케이블 → S3 USB +

좌우 위치보다 보드에 인쇄된 UART / USB 글자를 확인하세요. UART는 PC에, USB는 허브에 연결합니다. 화면은 허브의 데이터 포트, 충전기는 허브의 전원 입력에 연결합니다.

시험한 보드는 S3의 USB 포트에서 화면용 전원이 나오지 않아 외부 전원 허브가 필요했습니다. 전원만 연결되면 제조사 로고가 보일 수 있습니다. CARROT 영상이 나오는 것은 별도 등록을 마친 뒤입니다. 정상 설치 후 대기 중에는 화면이 꺼지는 것이 정상입니다.

+

3. PC에서 펌웨어 설치

  1. PC의 Chrome 또는 Edge로 이 HTTPS 페이지를 엽니다. 폰 브라우저에서 설치하지 마세요.
  2. S3의 UART 포트를 PC에 연결합니다. 다른 시리얼 모니터·설치 프로그램을 닫습니다.
  3. 아래에서 설치 방법을 선택하고 버튼을 누릅니다. 포트 목록에서 CP210x / USB Serial에 해당하는 S3를 선택하세요.
  4. 완료 메시지를 기다립니다. 설치 후에는 USB 연결이 자동으로 해제되고 S3가 재시작합니다.
+ +

+

설치 도구 준비 중…

설치 진행 기록
+

전체 지우기를 하지 않습니다. 업데이트는 기존 파티션을 확인한 뒤 앱만 교체하고, 등록 저장 영역이 그대로인지 검증합니다. 처음 설치는 해당 보드의 기존 프로그램을 CARROT으로 교체합니다. 다른 펌웨어에서 이전하는 경우는 새 보드와 동일한 절차를 적용하세요.

+
포트가 없거나 연결에 실패하면

충전 전용 케이블을 데이터 케이블로 교체하고 UART 포트를 확인합니다. Windows 장치 관리자에서 CP210x 드라이버가 없으면 Silicon Labs 공식 드라이버를 설치하세요. CH340 등 다른 UART 칩인 보드는 그 칩의 드라이버가 필요합니다.

자동 연결이 계속 실패하면 BOOT를 누른 채 RESET을 한 번 눌렀다 떼고, BOOT도 뗀 뒤 다시 설치합니다. 완료 후 RESET을 한 번 누르세요. 업데이트 중 실패하면 케이블을 확인하고 같은 설치를 다시 실행할 수 있습니다.

+

4. Wi-Fi와 C4 등록 — 두 가지 중 선택

C4에는 최신 carrot-nav 또는 carrot-wip가 필요합니다. 시동을 끈 상태에서도 등록할 수 있습니다. C4와 S3는 서로 통신할 수 있는 같은 네트워크에 있어야 합니다. 폰 핫스팟은 2.4GHz, 아이폰은 호환성 최대화를 사용하세요.

+

A. C4에 USB로 한 번 연결 — 권장

  1. C4를 사용할 Wi-Fi/폰 핫스팟에 먼저 연결합니다.
  2. S3의 UART 포트를 C4의 USB에 연결합니다. 화면용 허브는 S3의 USB 쪽에 둡니다.
  3. C4 웹 화면의 터미널 또는 SSH 터미널에서 아래 명령을 실행합니다.
cd /data/openpilot
+python3 tools/esp32s3_display/provision.py --auto

한 개의 지원 UART가 연결돼 있으면 자동 선택합니다. 현재 Wi-Fi 이름·암호를 S3에 저장하고 C4 등록도 함께 마칩니다. 여러 포트가 있으면 명령에 표시되는 목록에서 S3를 확인한 뒤 --auto 대신 --port /dev/ttyUSB0처럼 지정하세요. 케이블을 꽂는 것만으로 등록되지는 않습니다.

+

B. 폰에서 Wi-Fi 설정

  1. 처음 설치한 보드는 설정 화면이 나옵니다. 이미 설정한 보드는 부팅이 끝난 뒤 BOOT를 3초간 눌러 설정을 엽니다. 전원을 넣을 때부터 누르면 다운로드 모드로 들어갑니다.
  2. 폰을 CARROT-HUD Wi-Fi에 연결합니다. 비밀번호는 12345678입니다.
  3. 주소창에 http://192.168.4.1을 입력합니다. 시간 초과가 나면 “인터넷 없이 연결 유지”를 선택하고 모바일 데이터를 잠깐 끕니다.
  4. 차에서 사용할 Wi-Fi 이름·비밀번호를 입력하고 Wi-Fi 저장을 누릅니다. 설정 창은 저장 약 15초 후 닫힙니다.
  5. 처음 C4에 등록하는 경우 처음 C4 연결: 등록 파일 받기를 누릅니다. 파일명은 매번 달라집니다. 이미 C4에 등록한 보드의 Wi-Fi만 바꾼다면 다운로드할 필요가 없습니다.
  6. 받은 JSON을 WinSCP/SFTP 등으로 C4의 /data/carrot-s3.json에 복사하고 아래 명령으로 등록합니다. 파일 전송이 익숙하지 않으면 위 A 방법을 사용하세요.
cd /data/openpilot
+python3 tools/esp32s3_display/provision.py --import-pairing /data/carrot-s3.json

등록 JSON에는 해당 화면을 연결할 수 있는 키가 들어 있습니다. 다른 사람에게 보내거나 공개 게시판에 올리지 마세요. 폰이 핫스팟과 Wi-Fi 접속을 동시에 할 수 없으면 다른 폰으로 설정한 후 원래 폰의 핫스팟을 다시 켜세요.

+

5. 화면 사용과 다음 설정 변경

  1. C4 설정에서 외부 HUD (ClusterHud) → 9.2인치를 선택합니다.
  2. C4와 S3를 등록한 같은 Wi-Fi/핫스팟에 연결합니다. 차량에서 PC나 USB 테더링은 필요하지 않습니다.
  3. 기본적으로 시동을 켜면 HUD가 나오고 시동을 끄면 소등됩니다. 정차 중 시험은 ClusterHudDebug를 사용하고 시험 후 0으로 되돌리세요.

화면 밝기·방향·미러·카메라 등은 기존 HUD 설정을 사용합니다. Wi-Fi 화면은 기본 최대 15fps, eGPU 사용 시 최대 10fps이며 실제 속도는 화면 내용과 통신 상태에 따라 달라집니다. 화면을 바꾸는 일반 C4 업데이트마다 S3를 다시 설치할 필요는 없습니다.

SSID를 바꾸려면 부팅 후 BOOT 3초 → 폰 설정에서 저장하거나, 새 Wi-Fi에 연결된 C4에 UART를 연결하고 --auto 명령을 다시 실행합니다. 기존 C4 등록 키는 유지됩니다. 펌웨어 버그 수정은 이 페이지의 업데이트로 설치합니다. OTA 업데이트 기능은 없습니다.

+

문제가 있을 때

증상확인 순서
제조사 기본 화면만 보임S3 USB 포트·허브 본체 케이블·데이터 포트를 확인하세요. 허브 종류에 따라 USB 통신이 안 될 수 있습니다. 전원이 들어온 것만으로 통신 성공은 아닙니다.
설정 화면도 안 보임BOOT 3초 뒤 CARROT-HUD가 보이는지 확인합니다. Wi-Fi가 보이면 S3는 동작 중입니다. 화면/허브 연결을 점검하세요.
setup closed설정 시간이 끝났습니다. BOOT를 3초 눌러 다시 열고 CARROT-HUD로 재접속합니다.
등록했는데 화면이 꺼져 있음시동 OFF·Debug 0이면 정상입니다. 시동 ON에서도 나오지 않으면 C4 등록, 동일 네트워크, 핫스팟 기기 간 통신 허용을 확인하세요.
끊김·지연안테나 끝을 금속/허브에서 떨어뜨리고 2.4GHz 신호를 확인합니다. 다른 핫스팟과 비교합니다. 공유기 채널·혼잡도에 따라 달라지며 폰의 정상 접속만으로 S3 링크를 판단할 수는 없습니다.
시동을 꺼도 화면이 켜짐Debug를 0으로 설정하고 C4와 S3를 모두 업데이트합니다. 펌웨어 1.1.0은 종료 명령 실패를 재시도하며 통신이 끊겨도 자체 소등합니다. 설정 안내 중에는 화면이 켜져 있습니다.
+ + diff --git a/tools/esp32s3_display/installer/installer.js b/tools/esp32s3_display/installer/installer.js new file mode 100644 index 00000000000..ff16b869660 --- /dev/null +++ b/tools/esp32s3_display/installer/installer.js @@ -0,0 +1,71 @@ +import {ESPLoader, Transport} from './vendor/esptool-js-bundle.js'; +import {validateManifest, verifyBytes, binaryString} from './manifest.mjs'; + +const $ = id => document.getElementById(id); +const status = text => { $('status').textContent = text; }; +const log = text => { $('log').textContent = ($('log').textContent + text + '\n').slice(-16000); }; +let busy = false; +const supported = isSecureContext && 'serial' in navigator; +function enabled() { + $('flash').disabled = busy || !supported || !$('board').checked; + $('mode').disabled = $('board').disabled = busy; +} +$('board').addEventListener('change', enabled); +status(supported ? 'UART 포트를 연결하고 보드 확인란을 선택하세요.' : 'PC의 Chrome 또는 Edge에서 HTTPS 설치 페이지를 열어 주세요.'); +enabled(); + +$('flash').addEventListener('click', async () => { + let transport, loader; + const mode = $('mode').value; + busy = true; enabled(); $('log').textContent = ''; $('progress').value = 0; + try { + const port = await navigator.serial.requestPort(); + status('파일을 검증하고 있습니다.'); + const response = await fetch('./release.json', {cache:'no-store'}); + if (!response.ok) throw new Error('펌웨어 정보를 받지 못했습니다.'); + const manifest = validateManifest(await response.json()); + const parts = await Promise.all(manifest.parts.map(async part => { + const response = await fetch(part.path); + if (!response.ok) throw new Error(`${part.path} 다운로드 실패`); + const bytes = new Uint8Array(await response.arrayBuffer()); + await verifyBytes(bytes, part); + return {...part, bytes}; + })); + transport = new Transport(port, false); + loader = new ESPLoader({transport, baudrate:460800, + terminal:{clean(){}, write:log, writeLine:log}}); + status('S3에 연결 중입니다…'); + await loader.main(); + if (loader.chip.CHIP_NAME !== 'ESP32-S3' || await loader.getFlashSize() !== 16384) { + throw new Error('ESP32-S3 / Flash 16MB 보드가 아닙니다. 설치를 중단했습니다.'); + } + const partition = parts.find(part => part.offset === 0x8000); + if (mode === 'update') { + const bytes = await loader.readFlash(0x8000, partition.size); + try { await verifyBytes(bytes, partition); } + catch { throw new Error('기존 CARROT 파티션이 아닙니다. 새 보드라면 처음 설치를 선택하세요.'); } + } + const nvsBefore = await loader.flashMd5sum(0x9000, 0x6000); + const selected = mode === 'update' ? parts.filter(part => part.offset === 0x10000) : parts; + for (let index=0; index part.md5, + reportProgress:(_file, written, total) => { $('progress').value=(index+written/total)/selected.length*100; }}); + } + if (nvsBefore !== await loader.flashMd5sum(0x9000,0x6000)) { + throw new Error('설치 후 등록 저장 영역 검증이 일치하지 않습니다. 진행 기록을 확인하세요.'); + } + await loader.after('hard_reset'); + status('설치 완료! 등록 정보 보존을 확인했습니다. 아래 4번 Wi-Fi/C4 설정을 진행하세요.'); + $('progress').value=100; + } catch(error) { + status(error.name==='NotFoundError' ? '보드 선택을 취소했습니다.' : `설치 중단: ${error.message}`); + log(error.message); + } finally { + if (transport) { try { await transport.disconnect(); } catch(error) { log(error.message); } } + busy=false; enabled(); + } +}); diff --git a/tools/esp32s3_display/installer/manifest.mjs b/tools/esp32s3_display/installer/manifest.mjs new file mode 100644 index 00000000000..be8b5011f62 --- /dev/null +++ b/tools/esp32s3_display/installer/manifest.mjs @@ -0,0 +1,21 @@ +export function validateManifest(value) { + const slots = new Map([['bootloader.bin',[0,0x8000]],['partition-table.bin',[0x8000,0x1000]],['carrot-s3.bin',[0x10000,0x300000]]]); + if (value?.chip !== 'ESP32-S3' || value?.board !== 'N16R8' || value?.parts?.length !== 3) throw new Error('지원하지 않는 펌웨어 정보'); + for (const part of value.parts) { + const slot=slots.get(part.path); + if (!slot || part.offset!==slot[0] || !Number.isInteger(part.size) || part.size<=0 || part.size>slot[1] || part.size%4 || + !/^[0-9a-f]{64}$/.test(part.sha256) || !/^[0-9a-f]{32}$/.test(part.md5)) throw new Error('잘못된 펌웨어 파일 또는 주소'); + slots.delete(part.path); + } + return value; +} +export async function verifyBytes(bytes, part) { + if (bytes.length !== part.size) throw new Error('펌웨어 크기 불일치'); + const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), x=>x.toString(16).padStart(2,'0')).join(''); + if (hash !== part.sha256) throw new Error('펌웨어 SHA-256 불일치'); +} +export function binaryString(bytes) { + let value=''; + for (let offset=0; offset({path,offset,size:4,sha256:createHash('sha256').update(bytes).digest('hex'),md5:createHash('md5').update(bytes).digest('hex')}))}; + let nvsReads=0; + class Transport {async disconnect(){events.push('disconnect');}} + class ESPLoader { + constructor(){this.chip={CHIP_NAME:fault==='chip'?'ESP32':'ESP32-S3'};} + async main(){events.push('connect');} + async getFlashSize(){return 16384;} + async readFlash(address,size){assert.equal(address,0x8000);assert.equal(size,4);return fault==='partition'?new Uint8Array(4):bytes;} + async flashMd5sum(address,size){assert.equal(address,0x9000);assert.equal(size,0x6000);return fault==='nvs'&&nvsReads++?'changed':'same';} + async writeFlash(options){ + assert.equal(options.eraseAll,false);assert.equal(options.flashMode,'keep'); + const part=options.fileArray[0];assert.notEqual(part.address,0x9000); + assert.equal(options.calculateMD5Hash(part.data),createHash('md5').update(Buffer.from(part.data,'binary')).digest('hex')); + events.push(part.address);options.reportProgress(0,4,4); + } + async after(mode){assert.equal(mode,'hard_reset');events.push('reset');} + } + const context={document:{getElementById:id=>nodes.get(id)},isSecureContext:true,navigator:{serial:{async requestPort(){return {};}}}, + Transport,ESPLoader,validateManifest,verifyBytes,binaryString,crypto:webcrypto,Uint8Array, + fetch:async path=>({ok:true,json:async()=>manifest,arrayBuffer:async()=>fault==='download'?new Uint8Array(4).buffer:bytes.buffer})}; + vm.runInNewContext(code,context); + await nodes.get('flash').click(); + return {events,status:nodes.get('status').textContent,enabled:!nodes.get('flash').disabled}; +} +test('update writes app only; initial install writes disjoint partitions',async()=>{ + for(const mode of ['update','install']){ + const result=await run({mode}); + assert.deepEqual(result.events,['connect',...(mode==='update'?[0x10000]:[0,0x8000,0x10000]),'reset','disconnect']); + assert.match(result.status,/설치 완료/);assert.equal(result.enabled,true); + } +}); +test('wrong chip, bad partition and corrupt downloads cannot reach flash',async()=>{ + for(const fault of ['chip','partition','download']){ + const result=await run({fault});assert.equal(result.events.some(x=>typeof x==='number'),false); + assert.match(result.status,/설치 중단/);assert.equal(result.enabled,true); + } +}); +test('NVS mismatch reports failure and releases the port',async()=>{ + const result=await run({fault:'nvs'});assert.match(result.status,/저장 영역 검증/); + assert.equal(result.events.at(-1),'disconnect');assert.equal(result.events.includes('reset'),false); +}); diff --git a/tools/esp32s3_display/installer/tests/manifest.test.mjs b/tools/esp32s3_display/installer/tests/manifest.test.mjs new file mode 100644 index 00000000000..61e37c7509b --- /dev/null +++ b/tools/esp32s3_display/installer/tests/manifest.test.mjs @@ -0,0 +1,21 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {validateManifest, verifyBytes, binaryString} from '../manifest.mjs'; +const bytes=new Uint8Array([0,128,255,42]); +const file=path=>({path,size:4,sha256:createHash('sha256').update(bytes).digest('hex'),md5:'a'.repeat(32)}); +function manifest(){return {chip:'ESP32-S3',board:'N16R8',parts:[ + {...file('bootloader.bin'),offset:0},{...file('partition-table.bin'),offset:0x8000},{...file('carrot-s3.bin'),offset:0x10000}]};} +test('release partitions cannot erase NVS or overflow app',()=>{ + assert.equal(validateManifest(manifest()).parts.length,3); + for(const [index,changes] of [[0,{offset:0x9000}],[1,{size:0x2000}],[2,{size:0x300004}],[2,{path:'../private.bin'}]]){ + const value=manifest();Object.assign(value.parts[index],changes);assert.throws(()=>validateManifest(value)); + } + const value=manifest();value.parts[1]=value.parts[0];assert.throws(()=>validateManifest(value)); +}); +test('download corruption stops before flashing',async()=>{ + await verifyBytes(bytes,file('app')); + await assert.rejects(()=>verifyBytes(new Uint8Array([0,128,255,43]),file('app'))); + await assert.rejects(()=>verifyBytes(bytes.subarray(1),file('app'))); + assert.deepEqual([...binaryString(bytes)].map(x=>x.charCodeAt(0)),[0,128,255,42]); +}); diff --git a/tools/esp32s3_display/package_release.py b/tools/esp32s3_display/package_release.py new file mode 100644 index 00000000000..206d949d8cc --- /dev/null +++ b/tools/esp32s3_display/package_release.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Package an ESP-IDF build without ever reading a device's private NVS.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import struct +import subprocess +import zipfile + +HERE = Path(__file__).resolve().parent +VERSION = '1.1.0' +VENDOR_HASHES = { + 'esptool-js-bundle.js': '475e0e98fd051dc11bf34d8f83410764da11f3a0c40ecd33fd9647dc46b098b8', + 'esptool-js-LICENSE.txt': '1c25f29242785d63e9adb0be7fdc137551dfbbc0756b622fcd26d9cd7de3a4f3', + 'pako-LICENSE.txt': 'a04665b3b2de56c66730c1f720f528175739e4104f79073614aa611da1e85539', +} + + +def digest(data, algorithm='sha256'): + return hashlib.new(algorithm, data).hexdigest() + + +def package(build: Path, output: Path, vendor: Path, sdkconfig: Path): + output.mkdir(parents=True, exist_ok=True) + sources = [('bootloader/bootloader.bin', 'bootloader.bin', 0, 0x8000), + ('partition_table/partition-table.bin', 'partition-table.bin', 0x8000, 0x1000)] + app = next((p for p in [build/'carrot_s3_display.bin', build/'s3_usb_probe.bin'] if p.is_file()), None) + if app is None: + raise ValueError('Built application not found') + sources.append((app.name, 'carrot-s3.bin', 0x10000, 0x300000)) + parts = [] + for source, name, offset, maximum in sources: + data = (build/source).read_bytes() + if not 0 < len(data) <= maximum or len(data) % 4: + raise ValueError(f'Invalid binary size: {name}') + if offset == 0: + if data[0] != 0xe9 or data[2:4] != bytes([2, 0x4f]): + raise ValueError('Bootloader must be preconfigured DIO / 16 MB / 80 MHz') + elif offset == 0x8000: + rows = [struct.unpack(' dict: port.close() +def automatic_port() -> str: + from serial.tools import list_ports + # Enumerate USB metadata only; never open/reset an unrelated port to probe it. + ports = [p.device for p in list_ports.comports() if p.vid in (0x10c4, 0x1a86, 0x0403)] + if len(ports) != 1: + found = ', '.join(ports) if ports else 'none' + raise RuntimeError(f'Connect one S3 UART port, or use --port explicitly. UART candidates: {found}') + return ports[0] + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group(required=True) source.add_argument('--port', help='UART USB port, e.g. /dev/ttyUSB0 or COM14') + source.add_argument('--auto', action='store_true', help='Select the only CP210x/CH34x/FTDI UART; verify S3 identity before writing') source.add_argument('--import-pairing', type=Path, help='Registration JSON downloaded from the phone setup page') parser.add_argument('--ssid', help='Manual Wi-Fi name; password is prompted without echo') parser.add_argument('--output', type=Path, help='Default: /data/carrot/cluster_wifi.json') @@ -89,7 +102,7 @@ def main() -> None: parser.error('Registration file does not exist') else: ssid, password = (args.ssid, getpass.getpass('Wi-Fi password: ')) if args.ssid else current_wifi() - config = register(args.port, ssid, password) + config = register(automatic_port() if args.auto else args.port, ssid, password) save_config(config, args.output) print('S3 registered. Enable the 9.2-inch external HUD; C4 and S3 must use the same 2.4 GHz network.') diff --git a/tools/modeld/build_md_v3_package.py b/tools/modeld/build_md_v3_package.py new file mode 100644 index 00000000000..e91e0e54fff --- /dev/null +++ b/tools/modeld/build_md_v3_package.py @@ -0,0 +1,63 @@ +"""Build the pinned MD v3 Mountain Dew NAS package from verified upstream downloads.""" +import argparse +import gzip +import hashlib +import io +import json +from pathlib import Path +import shutil +import tarfile + +MODEL_SHA = 'd4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422' +MODEL_SIZE = 799941965 +SOURCE = 'e86b58dc7b9fb499dd84ac6dbe514a03fa2bc428' +TINYGRAD = '02e698160eed1751738212144a7ea2cfb160b3f1' +SOURCE_TAR_SHA = '2824b933fbf48df672cbb18c8462bd7f9d637a065f4c510ae3d07dedc01ffcb9' +CHECKPOINT = 'f22e06e5-3b3f-48d2-a6b9-2f32dc20e707/56320/1365126a-e71e-4778-be00-8ab92dc82c09/12864' + + +def digest(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def build(model, source_tar, output): + if model.stat().st_size != MODEL_SIZE or digest(model) != MODEL_SHA: + raise ValueError('MDM model identity mismatch') + if digest(source_tar) != SOURCE_TAR_SHA: + raise ValueError('pinned tinygrad source archive mismatch') + output.mkdir(parents=True, exist_ok=True) + runtime = output / 'precompiled-runtime.tar.gz' + with source_tar.open('rb') as source, tarfile.open(fileobj=source, mode='r:gz') as upstream: + with runtime.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w') as archive: + for member in sorted(upstream.getmembers(), key=lambda m: m.name): + name = member.name.partition('/')[2] + if not member.isfile() or not (name.startswith(('tinygrad/', 'examples/openpilot/')) or name == 'LICENSE'): + continue + if '..' in Path(name).parts: + raise ValueError('invalid source archive path') + data = upstream.extractfile(member).read() + info = tarfile.TarInfo(name) + info.size, info.mode, info.mtime = len(data), 0o644, 0 + archive.addfile(info, io.BytesIO(data)) + manifest = {'model_id': 'comma-pr39087-md-v3-e86b58dc-d4c7a3fc', 'filename': 'big_driving_tinygrad.pkl', + 'size': MODEL_SIZE, 'sha256': MODEL_SHA, 'url': 'big_driving_tinygrad.pkl'} + catalog = {'protocol': 1, 'format': 'comma-generic-onnx', 'serialization': 'persistent-buffer-v1', + 'gpu_arch': 'gfx1200', 'frame_skip': 4, 'camera_resolutions': [[1928, 1208], [1344, 760]], + 'model_sha256': MODEL_SHA, 'model_checkpoint': CHECKPOINT, 'source_commit': SOURCE, 'tinygrad_commit': TINYGRAD, + 'pickle': {'url': manifest['url'], 'size': MODEL_SIZE, 'sha256': MODEL_SHA}, + 'runtime': {'url': runtime.name, 'size': runtime.stat().st_size, 'sha256': digest(runtime)}} + for name, value in [('manifest.json', manifest), ('precompiled.json', catalog)]: + (output / name).write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8') + shutil.copyfile(model, output / manifest['filename']) + print(json.dumps(catalog, indent=2)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('model', type=Path) + parser.add_argument('source_tar', type=Path) + parser.add_argument('output', type=Path) + args = parser.parse_args() + build(args.model, args.source_tar, args.output) From aa64dd28cff14b7de955317ba61b93c7dd82e04c Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:49:51 +0900 Subject: [PATCH 35/50] ui(C4): always show the wide road camera The lead box and path are projections of radarState/model positions, so they follow the shown camera through the wide calibration transform; no second detection runs. Previously C4 showed wide only in Experimental Mode below 5 m/s, which this car never uses. Co-Authored-By: Claude Opus 5.5 --- .../ui/mici/onroad/augmented_road_view.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 982137dd635..e6fa8e0f7d0 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -39,8 +39,6 @@ class BookmarkState(IntEnum): DRAGGING = 1 TRIGGERED = 2 -WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph) -ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph) CAM_Y_OFFSET = 20 @@ -352,17 +350,8 @@ def _road_view_mode(self): return max(0, min(ui_state.show_model_view, 3)) def _switch_stream_if_needed(self, sm): - if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: - v_ego = sm['carState'].vEgo - if v_ego < WIDE_CAM_MAX_SPEED: - target = WIDE_CAM - elif v_ego > ROAD_CAM_MIN_SPEED: - target = ROAD_CAM - else: - # Hysteresis zone - keep current stream - target = self.stream_type - else: - target = ROAD_CAM + # joongyu01: C4 always shows the wide camera; overlays (lead box, path) follow via the wide calibration transform. + target = WIDE_CAM if WIDE_CAM in self.available_streams else ROAD_CAM if self.stream_type != target: self.switch_stream(target) From dd60046eb1ebdeb256c6e6afffde40056a894319 Mon Sep 17 00:00:00 2001 From: Joongyu <268551435+joongyu01@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:45:43 +0900 Subject: [PATCH 36/50] ui(C4): ScreenOn setting (default on); turning it off cuts the screen and backlight Gates Device wakefulness, so the panel stays off onroad and offroad until the setting is turned back on (re-read every 5 s, e.g. from Carrot Web). An unset key reads the default and keeps the screen on. mici only; C3/C3X are unaffected. Sounds and controls keep running. Co-Authored-By: Claude Opus 5.5 --- docs/user/en/settings.md | 4 ++-- docs/user/ko/settings.md | 4 ++-- openpilot/common/params_keys.h | 1 + .../server/tests/test_settings_schema.py | 1 + openpilot/selfdrive/carrot_settings.json | 18 ++++++++++++++++++ openpilot/selfdrive/ui/ui_state.py | 7 +++++-- .../docs/wiki_settings/tests/test_ci_check.py | 2 +- .../docs/wiki_settings/tests/test_generate.py | 8 ++++---- 8 files changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/user/en/settings.md b/docs/user/en/settings.md index edee00a156e..07c6318ddae 100644 --- a/docs/user/en/settings.md +++ b/docs/user/en/settings.md @@ -296,7 +296,7 @@ In modes `EnableRadarTracks=1`–`3`, a confirmed departing front lead can recei ## Display -Display contains 34 settings. External-HUD settings control the layout and output of separate display hardware. +Display contains 35 settings. External-HUD settings control the layout and output of separate display hardware. `ClusterHud` (External HUD Display) switches a **direct USB HUD or a registered ESP32-S3 Wi-Fi HUD** on or off. A HUD connected to Jetson starts automatically even when this value is `0`, and normally turns its display off with ignition. Use the existing always-on `ClusterHudDebug` modes to keep it visible offroad. Brightness and layout settings also apply to the Jetson HUD. @@ -313,7 +313,7 @@ With `CarrotVisionEnabled` on, the external HUD and web camera view can be used |---|---|---| | Information | `ShowDebugUI`, `ShowTpms`, `ShowDateTime`, `ShowPathEnd`, `ShowDeviceState`, `ShowLaneInfo`, `ShowRadarInfo`, `ShowRouteInfo`, `ShowPlotMode` | Debug, tire, time, lane, radar, and route information | | Path | `ShowPathMode`, `ShowPathColor`, `ShowPathColorCruiseOff`, `ShowPathModeLane`, `ShowPathColorLane` | Path shape and color by driving state | -| Brightness/on-road view | `ShowCustomBrightness`, `ShowModelView`, `ShowCameraWithCluster` | Brightness, camera/model composition, and the on-device camera while the external HUD is connected | +| Brightness/on-road view | `ShowCustomBrightness`, `ScreenOn`, `ShowModelView`, `ShowCameraWithCluster` | Brightness, C4 screen on/off, camera/model composition, and the on-device camera while the external HUD is connected | | External HUD | `ClusterHud`, `ClusterHudBrightness`, `ClusterHudOrientation`, and related `ClusterHud*` settings | Supported TURZX HUD layout, live brightness, screen rotation, camera, radar, and encoder options | For RAV4 2022 with the ARS display stream, `ShowRadarInfo=3` on the standard device diff --git a/docs/user/ko/settings.md b/docs/user/ko/settings.md index 1016c27257e..95ea0b4ea3a 100644 --- a/docs/user/ko/settings.md +++ b/docs/user/ko/settings.md @@ -295,7 +295,7 @@ Bosch MRRevo14F 수신을 지원하는 버전에서 Bosch MRRevo14F 후보 차 ## 화면 표시 -화면 표시에는 34개 항목이 있습니다. 외부 HUD 항목은 별도 하드웨어의 화면 구성과 출력 방식을 조정합니다. +화면 표시에는 35개 항목이 있습니다. 외부 HUD 항목은 별도 하드웨어의 화면 구성과 출력 방식을 조정합니다. `ClusterHud`(외부 HUD 활성화)는 **본체 USB에 직접 연결한 HUD 또는 등록된 ESP32-S3 Wi-Fi HUD**를 켜고 끄는 설정입니다. Jetson에 연결한 HUD는 이 값이 `0`이어도 자동 실행되며, 기본적으로 시동 OFF 시 화면을 끕니다. 시동 OFF에도 표시하려면 기존 `ClusterHudDebug`의 항상 켜기를 사용합니다. 밝기·화면 구성 설정은 Jetson HUD에도 적용됩니다. @@ -312,7 +312,7 @@ Jetson HUD에는 `jetSON` 아래에 Jetson 내부 센서의 최고 온도가 표 |---|---|---| | 정보 표시 | `ShowDebugUI`, `ShowTpms`, `ShowDateTime`, `ShowPathEnd`, `ShowDeviceState`, `ShowLaneInfo`, `ShowRadarInfo`, `ShowRouteInfo`, `ShowPlotMode` | 주행 화면의 디버그, 타이어, 시간, 차선, 레이더와 경로 정보 | | 경로 표시 | `ShowPathMode`, `ShowPathColor`, `ShowPathColorCruiseOff`, `ShowPathModeLane`, `ShowPathColorLane` | 레인리스·레인모드·크루즈 OFF 상태의 경로 모양과 색상 | -| 밝기·주행화면 | `ShowCustomBrightness`, `ShowModelView`, `ShowCameraWithCluster` | 주행 중 밝기, 카메라·모델 표시 조합과 외부 HUD 연결 중 본체 카메라 표시 | +| 밝기·주행화면 | `ShowCustomBrightness`, `ScreenOn`, `ShowModelView`, `ShowCameraWithCluster` | 주행 중 밝기, C4 화면 켜기·끄기, 카메라·모델 표시 조합과 외부 HUD 연결 중 본체 카메라 표시 | | 외부 HUD·기본 | `ClusterHud`, `ClusterHudBrightness`, `ClusterHudOrientation`, `ClusterHudMirror`, `ClusterHudTheme`, `ClusterNaviMapTheme`, `ClusterNaviMapType`, `ClusterNaviMapFps` | TURZX 외부 HUD, 밝기, 화면 회전, 미러링과 지도 테마 | | 외부 HUD·화면·카메라 | `ClusterHudEncoder`, `ClusterHudScreenMode`, `ClusterHudPanelLayout`, `ClusterHudCameraViewMode` | 인코더와 화면·카메라·좌우 패널 구성 | | 외부 HUD·레이더 표시 | `ClusterHudRadarInfo`, `ClusterHudRadarDisplay`, `ClusterHudRadarSourceColor` | 외부 HUD의 레이더 정보와 색상 | diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 3c93d25d3ad..db798814889 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -192,6 +192,7 @@ inline static std::unordered_map keys = { {"TimezoneSource", {PERSISTENT, STRING, ""}}, // 타임존 출처 우선순위 추적 ("app" > "wifi" > "gps") {"CarrotTireTrajectory", {PERSISTENT, INT, "0"}}, {"ShowCustomBrightness", {PERSISTENT, INT, "100"}}, + {"ScreenOn", {PERSISTENT, BOOL, "1"}}, {"ShowModelView", {PERSISTENT, INT, "0"}}, {"ShowCameraWithCluster", {PERSISTENT, INT, "0"}}, {"ClusterHud", {PERSISTENT, INT, "0"}}, diff --git a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py index 5ab512ff4e4..7caf8ec7978 100644 --- a/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py +++ b/openpilot/selfdrive/carrot/server/tests/test_settings_schema.py @@ -484,6 +484,7 @@ def test_cluster_camera_preference_is_in_brightness_and_view(settings, params): brightness = next(group for group in display["groups"] if group["id"] == "DISP_BRIGHT") assert brightness["params"] == [ "ShowCustomBrightness", + "ScreenOn", "ShowModelView", "ShowCameraWithCluster", ] diff --git a/openpilot/selfdrive/carrot_settings.json b/openpilot/selfdrive/carrot_settings.json index 4fe8daaf032..8c6b659bc77 100644 --- a/openpilot/selfdrive/carrot_settings.json +++ b/openpilot/selfdrive/carrot_settings.json @@ -509,6 +509,7 @@ "zh": "亮度与视图", "params": [ "ShowCustomBrightness", + "ScreenOn", "ShowModelView", "ShowCameraWithCluster" ] @@ -3896,6 +3897,23 @@ "unit": 5, "display_unit": "percent" }, + { + "group": "화면", + "name": "ScreenOn", + "title": "화면 켜기(C4)", + "descr": "기본 켜짐. 끄면 C4 화면과 백라이트를 완전히 끕니다(오프로드·주행 모두). 경고음과 주행 기능은 그대로 동작합니다.\n다시 켜면 약 5초 안에 화면이 켜집니다. 화면이 꺼진 동안에는 Carrot Web에서 켜세요. C3/C3X는 영향 없음.", + "egroup": "DISPLAY", + "etitle": "Screen On (C4)", + "edescr": "On by default. Turning it off switches the C4 screen and backlight fully off (offroad and onroad). Alert sounds and driving functions keep working.\nTurning it back on brings the screen back within about 5 seconds. While the screen is off, change it from Carrot Web. No effect on C3/C3X.", + "cgroup": "显示", + "ctitle": "开启屏幕(C4)", + "cdescr": "默认开启。关闭后完全关闭 C4 屏幕和背光(停车和行车时均如此)。提示音和驾驶功能照常工作。\n重新开启后约 5 秒内屏幕点亮。屏幕关闭期间请在 Carrot Web 中开启。对 C3/C3X 无影响。", + "min": 0, + "max": 1, + "default": 1, + "unit": 1, + "control": "toggle" + }, { "group": "화면", "name": "ShowModelView", diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index b7fade7d46b..647355e4169 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -17,7 +17,8 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC -BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 +IS_MICI = HARDWARE.get_device_type() == "mici" +BACKLIGHT_OFFROAD = 65 if IS_MICI else 50 class UIStatus(Enum): @@ -109,6 +110,7 @@ def _initialize(self): # Brightness and UI options self.show_brightness_ratio: float = 1.0 + self.screen_off: bool = False self.show_model_view: int = 0 self.share_data: bool = False self.show_camera_with_cluster: bool = False @@ -225,6 +227,7 @@ def update_params(self) -> None: self.show_date_time = self.params.get_int("ShowDateTime") self.show_radar_info = self.params.get_int("ShowRadarInfo") self.show_brightness_ratio: float = self.params.get_int("ShowCustomBrightness") / 100.0 + self.screen_off = IS_MICI and self.params.get("ScreenOn", return_default=True) is False # unset key keeps the screen on self.show_model_view = self.params.get_int("ShowModelView") self.share_data = self.params.get_bool("ShareData") self.show_camera_with_cluster = self.params.get_int("ShowCameraWithCluster") == 1 @@ -350,7 +353,7 @@ def _update_wakefulness(self): callback() self._prev_timed_out = interaction_timeout - self._set_awake(ui_state.ignition or not interaction_timeout or PC) + self._set_awake((ui_state.ignition or not interaction_timeout or PC) and not ui_state.screen_off) def _set_awake(self, on: bool): if on != self._awake: diff --git a/tools/docs/wiki_settings/tests/test_ci_check.py b/tools/docs/wiki_settings/tests/test_ci_check.py index 1ba2fa27a03..b50b1fda2d0 100644 --- a/tools/docs/wiki_settings/tests/test_ci_check.py +++ b/tools/docs/wiki_settings/tests/test_ci_check.py @@ -47,7 +47,7 @@ def test_read_only_check_writes_reports_without_touching_wiki(self): self.assertTrue((output / "summary.md").is_file()) self.assertTrue((output / "pages.diff").is_file()) stored = json.loads((output / "summary.json").read_text(encoding="utf-8")) - self.assertEqual(stored["result"]["settings"], 195) + self.assertEqual(stored["result"]["settings"], 196) self.assertEqual(stored["validationIssues"], []) self.assertIn("Settings-Catalog.md", (output / "pages.diff").read_text(encoding="utf-8")) diff --git a/tools/docs/wiki_settings/tests/test_generate.py b/tools/docs/wiki_settings/tests/test_generate.py index ca9d76e7172..f0607c27ced 100644 --- a/tools/docs/wiki_settings/tests/test_generate.py +++ b/tools/docs/wiki_settings/tests/test_generate.py @@ -85,7 +85,7 @@ def test_current_catalog_generates_all_settings_and_valid_markdown(self): catalog_commit=COMMIT, generated_at=STAMP, ) - self.assertEqual(len(result.generated_settings), 195) + self.assertEqual(len(result.generated_settings), 196) self.assertIn("NNFF", result.index["settings"]) self.assertNotIn("SteerHandoverMode", result.index["settings"]) self.assertIn("SignalAssistMode", result.index["settings"]) @@ -98,15 +98,15 @@ def test_current_catalog_generates_all_settings_and_valid_markdown(self): for retired in ("ClusterHudPriority", "ClusterHudCoreMode", "ClusterHudLiveFps", "CanfdStopRetry", "DisableDM", "CarrotYouTubeLive", "CarrotYouTubeQuality", "CarrotYouTubeTimestamp"): self.assertNotIn(retired, result.index["settings"]) - self.assertEqual(result.index["review"], {"current": 0, "needs_review": 195}) + self.assertEqual(result.index["review"], {"current": 0, "needs_review": 196}) self.assertEqual(result.index["locales"], ["ko", "en", "zh"]) - self.assertEqual(len(result.pages), (195 * 3) + 2) + self.assertEqual(len(result.pages), (196 * 3) + 2) setting_pages = { name: text for name, text in result.pages.items() if GENERATOR.GENERATED_PAGE_RE.fullmatch(name) } - self.assertEqual(len(setting_pages), 195 * 3) + self.assertEqual(len(setting_pages), 196 * 3) self.assertTrue(all(text.count("