Compare commits

..

8 Commits

Author SHA1 Message Date
Kumar 15b7f3babd Merge branch 'master' into smooth-final-stop 2026-10-18 12:59:17 -07:00
rav4kumar 3e8e5f430c hard code? 2026-09-15 21:58:17 -07:00
Kumar 88418aa418 Update longcontrol.py 2026-09-15 21:58:10 -07:00
rav4kumar ef938df670 lint 2026-09-15 15:14:45 -07:00
rav4kumar 4b3e51a490 lower 2026-09-15 15:01:31 -07:00
Kumar a3a00e7393 Reduce stopped deceleration rate from 2.0 to 0.5 2026-09-15 15:01:31 -07:00
rav4kumar 5462c13b5a refactor: improve stopping acceleration logic in LongControlSP 2026-09-15 15:01:30 -07:00
rav4kumar 5065418df8 refine stopping acceleration calculation in longcontrol.py
Update longcontrol.py

smooth
2026-09-15 15:01:30 -07:00
37 changed files with 717 additions and 822 deletions
+3 -4
View File
@@ -115,7 +115,7 @@ jobs:
env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_onnx.py \
${{ github.workspace }}/${{ env.SMALL_ONNX }} \
${{ github.workspace }}/${{ env.SMALL_PKL }} \
--out-of-band --benchmark-runs 1
--device-input "*" --out-of-band --benchmark-runs 1
- name: Prepare output
env:
@@ -440,10 +440,9 @@ jobs:
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
taskset -c 7 env ${TG_FLAGS} python3 \
${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile_onnx.py \
${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \
${{ github.workspace }}/${{ env.DM_ONNX }} \
${{ github.workspace }}/${{ env.DM_PKL }} \
--out-of-band --benchmark-runs 1
${{ github.workspace }}/${{ env.DM_PKL }}
- name: Compile DM warp
run: |
+1 -79
View File
@@ -33,7 +33,7 @@ jobs:
- name: LFS Fetch
run: |
git lfs fetch || echo "::warning::some objects are missing on commaai/openpilot-lfs; mirroring the rest"
git lfs fetch
- name: Set up Git
run: |
@@ -68,7 +68,6 @@ jobs:
git config lfs.activitytimeout 300
git config lfs.transfer.maxretries 5
git config lfs.concurrenttransfers 4
git config lfs.allowincompletepush true
- name: Push LFS
id: sync-and-commit
@@ -82,80 +81,3 @@ jobs:
done
echo "All push attempts failed"
exit 1
- name: Checkout sunnypilot
uses: actions/checkout@v4
with:
repository: 'sunnypilot/sunnypilot'
path: sunnypilot
fetch-depth: 0
filter: blob:limit=1k
lfs: false
- name: Reference objects on sunnypilot-lfs
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
set -euo pipefail
GIT_LFS_SKIP_SMUDGE=1 git clone --quiet https://huggingface.co/sunnypilot/sunnypilot-lfs hf-lfs
mkdir -p hf-lfs/all
cat > scan_pointers.py <<'EOF'
import re, subprocess, sys, threading
repo = sys.argv[1]
chk = subprocess.run(["git", "-C", repo, "cat-file", "--batch-all-objects",
"--batch-check=%(objectname) %(objecttype) %(objectsize)"],
capture_output=True, text=True, check=True).stdout
small = [f[0] for f in (l.split() for l in chk.splitlines()) if f[1] == "blob" and int(f[2]) < 1024]
p = subprocess.Popen(["git", "-C", repo, "cat-file", "--batch=%(objectsize)"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
def feed():
for sha in small:
p.stdin.write(sha.encode() + b"\n")
p.stdin.close()
threading.Thread(target=feed, daemon=True).start()
rx = re.compile(rb"^version https://git-lfs\.github\.com/spec/v1\noid sha256:([0-9a-f]{64})\nsize ([0-9]+)\n")
seen = {}
for _ in small:
n = int(p.stdout.readline()); body = p.stdout.read(n); p.stdout.read(1)
m = rx.match(body)
if m:
seen.setdefault(m.group(1).decode(), m.group(2).decode())
for oid, size in sorted(seen.items()):
print(oid, size)
EOF
for repo in . sunnypilot; do
python3 scan_pointers.py "$repo"
done | sort -u | while read -r oid size; do
[ -e "hf-lfs/all/$oid" ] && continue
printf 'version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %s\n' "$oid" "$size" > "hf-lfs/all/$oid"
echo "$oid"
done > new_oids.txt
echo "new pointer files: $(wc -l < new_oids.txt)"
dropped=0
while mapfile -t -n 100 chunk && ((${#chunk[@]})); do
body=$(for oid in "${chunk[@]}"; do
size=$(awk '/^size /{print $2}' "hf-lfs/all/$oid")
printf '{"oid":"%s","size":%s}\n' "$oid" "$size"
done | jq -s '{operation:"download",transfers:["basic"],objects:.}')
for attempt in 1 2 3 4 5; do
resp=$(curl -sS -u "user:${HF_TOKEN}" -w '\n%{http_code}' \
-H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' \
--data "$body" "${LFS_URL}/objects/batch") && [ "$(tail -n1 <<<"$resp")" = 200 ] && break
echo "batch check attempt $attempt failed ($(tail -n1 <<<"$resp")), retrying in 60s"; sleep 60
done
[ "$(tail -n1 <<<"$resp")" = 200 ] || { echo "batch check failed"; exit 1; }
for oid in $(sed '$d' <<<"$resp" | jq -r '.objects[] | select(.error) | .oid'); do
rm -f "hf-lfs/all/$oid"; dropped=$((dropped+1))
done
done < new_oids.txt
echo "dropped (not on HF): $dropped"
cd hf-lfs
git add -A all
if git diff --cached --quiet; then
echo "nothing new to reference"
exit 0
fi
git commit -q -m "Reference $(git diff --cached --name-only | wc -l | tr -d ' ') new LFS objects"
git push origin HEAD:main
@@ -594,14 +594,8 @@ jobs:
- name: Publish chestnut branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut"
cp .lfsconfig "${{ env.OUTPUT_DIR }}/"
echo 'openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > "${{ env.OUTPUT_DIR }}/.gitattributes"
git lfs install
git config --global credential.helper store
echo "https://user:${HF_TOKEN}@huggingface.co" > ~/.git-credentials
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
-1
View File
@@ -4,7 +4,6 @@
[submodule "opendbc"]
path = opendbc_repo
url = https://github.com/sunnypilot/opendbc.git
branch = toyotabsmhack
[submodule "msgq"]
path = msgq_repo
url = https://github.com/sunnypilot/msgq.git
-3
View File
@@ -138,9 +138,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
status @0 :DownloadStatus;
progress @1 :Float32;
eta @2 :UInt32;
speed @3 :Float32;
canceling @4 :Bool;
resuming @5 :Bool;
}
struct Chunk {
-1
View File
@@ -246,7 +246,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"TeslaMadsScreenButton", {PERSISTENT | BACKUP, INT, "0"}},
{"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaEnhancedBsm", {PERSISTENT | BACKUP, BOOL, "0"}},
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -4,6 +4,7 @@ from openpilot.common.realtime import DT_CTRL
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.common.pid import PIDController
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.sunnypilot.selfdrive.controls.lib.longcontrol import LongControlSP
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
@@ -39,7 +40,8 @@ def long_control_state_trans(CP_SP, active, long_control_state,
return long_control_state
class LongControl:
class LongControl(LongControlSP):
def __init__(self, CP, CP_SP):
self.CP = CP
self.CP_SP = CP_SP
@@ -68,7 +70,7 @@ class LongControl:
if output_accel > self.CP.stopAccel:
output_accel = min(output_accel, 0.0)
# TODO: can we just go straight to stopAccel?
output_accel -= 1.0 * DT_CTRL # m/s^2/s while trying to stop
output_accel -= self.stopping_decel_rate(CS.vEgo) * DT_CTRL
self.reset()
else: # LongCtrlState.pid
@@ -1,6 +1,10 @@
from types import SimpleNamespace
from typing import cast
from openpilot.common.test import OpenpilotTestCase
from openpilot.cereal import custom
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState, long_control_state_trans
from openpilot.sunnypilot.selfdrive.controls.lib.longcontrol import LongControlSP
class TestLongControlStateTransition(OpenpilotTestCase):
@@ -42,3 +46,21 @@ class TestLongControlStateTransition(OpenpilotTestCase):
next_state = long_control_state_trans(CP_SP, active, current_state,
should_stop=False, brake_pressed=False, cruise_standstill=False)
assert next_state == LongCtrlState.pid
class TestStoppingHold:
def test_weak_brake_does_not_hold_stopping(self):
control = SimpleNamespace(last_output_accel=-0.109)
car_state = SimpleNamespace(vEgo=0.277, aEgo=-0.406)
assert not LongControlSP.should_hold_stopping(cast(LongControlSP, control), car_state, -0.072)
def test_sufficient_brake_holds_stopping(self):
control = SimpleNamespace(last_output_accel=-0.543)
car_state = SimpleNamespace(vEgo=0.209, aEgo=-0.534)
assert LongControlSP.should_hold_stopping(cast(LongControlSP, control), car_state, -0.469)
def test_stopping_decel_rate_is_smooth_while_rolling(self):
assert LongControlSP.stopping_decel_rate(0.1) == 0.3
def test_stopping_decel_rate_builds_holding_brake_at_stop(self):
assert LongControlSP.stopping_decel_rate(0.0) == 2.0
+3 -4
View File
@@ -48,9 +48,8 @@ def compile_model(onnx_path, pkl_path):
Action(cmd, " [ONNX] $TARGET"),
)
if not os.getenv('SKIP_TINYGRAD_COMPILE'):
compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl')
compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl')
compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl')
compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl')
model_w, model_h = MEDMODEL_INPUT_SIZE
for cam_w, cam_h in camera_configs:
@@ -63,7 +62,7 @@ for cam_w, cam_h in camera_configs:
lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd)
dm_w, dm_h = DM_INPUT_SIZE
for cam_w, cam_h in [] if os.getenv('SKIP_TINYGRAD_COMPILE') else camera_configs:
for cam_w, cam_h in camera_configs:
dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h)
cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" '
-1
View File
@@ -7,7 +7,6 @@ from functools import cached_property
import os
os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom
os.environ.setdefault('AM_POWER_LIMIT', '100')
os.environ.setdefault('AM_PERF_LEVEL', 'high')
from tinygrad.device import Buffer, Device
from tinygrad.dtype import DType, dtypes
from tinygrad.engine.realize import lower_and_compile
@@ -8,6 +8,7 @@ from enum import IntEnum
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise_sub_layouts.speed_limit_settings import SpeedLimitSettingsLayout
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.mapd import MAPD_AVAILABLE
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp, simple_button_item_sp
from openpilot.system.ui.widgets import Widget
@@ -29,6 +30,7 @@ ACC_ENABLED_DESCRIPTION = tr_noop("Enable custom Short & Long press increments f
ACC_NOLONG_DESCRIPTION = tr_noop("This feature can only be used with sunnypilot longitudinal control enabled.")
ACC_PCMCRUISE_DISABLED_DESCRIPTION = tr_noop("This feature is not supported on this platform due to vehicle limitations.")
ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.")
MAP_UNAVAILABLE_DESCRIPTION = tr_noop("Map data is temporarily unavailable.")
class CruiseLayout(Widget):
@@ -54,7 +56,8 @@ class CruiseLayout(Widget):
self.scc_m_toggle = toggle_item_sp(
title=tr("Smart Cruise Control - Map"),
description=tr("Use map data to estimate the appropriate speed to drive through turns ahead."),
description=(f"<b>{tr(MAP_UNAVAILABLE_DESCRIPTION)}</b>\n\n" if not MAPD_AVAILABLE else "") +
tr("Use map data to estimate the appropriate speed to drive through turns ahead."),
param="SmartCruiseControlMap")
self.custom_acc_toggle = toggle_item_sp(
@@ -139,7 +142,7 @@ class CruiseLayout(Widget):
if has_long or has_icbm:
self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad())
self.scc_v_toggle.action_item.set_enabled(True)
self.scc_m_toggle.action_item.set_enabled(True)
self.scc_m_toggle.action_item.set_enabled(MAPD_AVAILABLE)
else:
ui_state.params.remove("CustomAccIncrementsEnabled")
ui_state.params.remove("SmartCruiseControlVision")
@@ -8,6 +8,8 @@ from collections.abc import Callable
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.mapd import MAPD_AVAILABLE
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp
from openpilot.system.ui.widgets.network import NavButton
@@ -43,6 +45,8 @@ class SpeedLimitPolicyLayout(Widget):
param="SpeedLimitPolicy",
button_width=250,
)
if not MAPD_AVAILABLE:
self._speed_limit_policy.action_item.set_enabled_buttons({Policy.car_state_only})
items = [
self._speed_limit_policy
@@ -51,6 +55,8 @@ class SpeedLimitPolicyLayout(Widget):
@staticmethod
def _get_policy_description():
if not MAPD_AVAILABLE:
return f"<b>{tr('Map data is temporarily unavailable. Using Speed Limit data only from Car.')}</b>"
return get_highlighted_description(ui_state.params, "SpeedLimitPolicy", SPEED_LIMIT_POLICY_DESCRIPTIONS)
def _render(self, rect):
@@ -235,8 +235,6 @@ class ModelsLayout(Widget):
progress = sum(p.progress for p in progresses) / len(progresses)
ds = custom.ModelManagerSP.DownloadStatus
if any(p.canceling for p in progresses):
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("canceling...")}
if ds.failed in statuses:
# close.png is authored black and a tint cannot lift it, hence close2
return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"}
@@ -4,8 +4,6 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from opendbc.car.toyota.values import ToyotaFlags
from opendbc.sunnypilot.car.toyota.values import ToyotaFlagsSP
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app
@@ -17,10 +15,6 @@ from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.")
SNG_HACK_UNAVAILABLE = tr_noop("sunnypilot Longitudinal Control must be available and enabled for your vehicle to use this feature.")
BSM_UNAVAILABLE = tr_noop("Enhanced Blind Spot Monitoring requires a TSS2 Toyota/Lexus without SecOC.")
BSM_STOCK_LONG_UNAVAILABLE = tr_noop("Enhanced Blind Spot Monitoring is not available while Enforce Factory Longitudinal Control is enabled.")
BSM_REQUIRED = tr_noop("This vehicle does not report blind spot status over the standard CAN signals. " +
"Enhanced Blind Spot Monitoring is required for blind spot detection.")
DESCRIPTIONS = {
'enforce_stock_longitudinal': tr_noop(
@@ -29,10 +23,6 @@ DESCRIPTIONS = {
'stop_and_go_hack': tr_noop(
'sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. ' +
'This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.'
),
'enhanced_bsm': tr_noop(
'sunnypilot will poll the blind spot monitor diagnostics directly from the radar. Use this on vehicles whose blind spot monitors ' +
'do not report blind spot status over the standard CAN signals. This is an alpha feature. Use at your own risk.'
)
}
@@ -57,18 +47,9 @@ class ToyotaSettings(BrandSettings):
enabled=lambda: not ui_state.engaged,
)
self.enhanced_bsm = toggle_item_sp(
lambda: tr("Enhanced Blind Spot Monitoring (Alpha)"),
description=lambda: tr(DESCRIPTIONS["enhanced_bsm"]),
initial_state=ui_state.params.get_bool("ToyotaEnhancedBsm"),
callback=self._on_enable_enhanced_bsm,
enabled=lambda: not ui_state.engaged,
)
self.items = [
self.enforce_stock_longitudinal,
self.stop_and_go_hack,
self.enhanced_bsm,
]
def _on_enable_enforce_stock_longitudinal(self, state: bool):
@@ -113,25 +94,6 @@ class ToyotaSettings(BrandSettings):
ui_state.params.put_bool("ToyotaStopAndGoHack", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def _on_enable_enhanced_bsm(self, state: bool):
if state:
def confirm_callback(result: int):
if result == DialogResult.CONFIRM:
ui_state.params.put_bool("ToyotaEnhancedBsm", True)
ui_state.params.put_bool("OnroadCycleRequested", True)
else:
self.enhanced_bsm.action_item.set_state(False)
content = (f"<h1>{self.enhanced_bsm.title}</h1><br>" +
f"<p>{self.enhanced_bsm.description}</p>")
dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback)
gui_app.push_widget(dlg)
else:
ui_state.params.put_bool("ToyotaEnhancedBsm", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def update_settings(self):
if ui_state.CP is not None:
longitudinal = ui_state.CP.openpilotLongitudinalControl
@@ -151,36 +113,9 @@ class ToyotaSettings(BrandSettings):
self.stop_and_go_hack.set_description(new_desc)
if show_desc:
self.stop_and_go_hack.show_description(True)
# Enhanced BSM needs the radar diagnostic address, which is only in the TX allowlist for
# TSS2 cars without SecOC that are not enforcing factory longitudinal control
bsm_supported = bool(ui_state.CP.flags & ToyotaFlags.TSS2) and not bool(ui_state.CP.flags & ToyotaFlags.SECOC)
bsm_available = bsm_supported and not enforce_stock
self.enhanced_bsm.action_item.set_enabled(bsm_available and not ui_state.engaged)
bsm_desc = tr(DESCRIPTIONS["enhanced_bsm"])
if not bsm_supported:
self.enhanced_bsm.action_item.set_state(False)
bsm_desc = "<b>" + tr(BSM_UNAVAILABLE) + "</b>\n\n" + bsm_desc
elif enforce_stock:
self.enhanced_bsm.action_item.set_state(False)
bsm_desc = "<b>" + tr(BSM_STOCK_LONG_UNAVAILABLE) + "</b>\n\n" + bsm_desc
elif ui_state.CP_SP is not None and ui_state.CP_SP.flags & ToyotaFlagsSP.SP_NEED_DEBUG_BSM:
bsm_desc = "<b>" + tr(BSM_REQUIRED) + "</b>\n\n" + bsm_desc
if self.enhanced_bsm.description != bsm_desc:
self.enhanced_bsm.set_description(bsm_desc)
if not bsm_available:
self.enhanced_bsm.show_description(True)
else:
self.stop_and_go_hack.action_item.set_enabled(False)
new_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["stop_and_go_hack"])
if self.stop_and_go_hack.description != new_desc:
self.stop_and_go_hack.set_description(new_desc)
self.stop_and_go_hack.show_description(True)
self.enhanced_bsm.action_item.set_enabled(False)
bsm_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["enhanced_bsm"])
if self.enhanced_bsm.description != bsm_desc:
self.enhanced_bsm.set_description(bsm_desc)
self.enhanced_bsm.show_description(True)
@@ -251,12 +251,11 @@ class ModelsLayoutMici(NavScroller):
self.current_model_info.info_text.set_text(tr("download failed"))
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading:
self.cancel_download_btn.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None)
self.cancel_download_btn.set_visible(True)
device.set_override_interactive_timeout(5)
progress = 0.0
count = 0
verifying = False
canceling = False
for model in manager.selectedBundle.models:
count += 1
p = model.artifact.downloadProgress
@@ -264,13 +263,11 @@ class ModelsLayoutMici(NavScroller):
custom.ModelManagerSP.DownloadStatus.verifying):
progress += p.progress
verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying
canceling = canceling or p.canceling
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded,
custom.ModelManagerSP.DownloadStatus.cached):
progress += 100.0
header = tr("canceling...") if canceling else tr("verifying") if verifying else tr("downloading")
self.current_model_info.current_model_header.set_text(header)
self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading"))
self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download"))
self.current_model_info.current_model_header._shimmer = True
name_text = manager.selectedBundle.internalName.lower()
+4
View File
@@ -3,3 +3,7 @@ from openpilot.common.basedir import BASEDIR
MAPD_BIN_DIR = os.path.join(BASEDIR, 'openpilot', 'third_party/mapd_pfeiferj')
MAPD_PATH = os.path.join(MAPD_BIN_DIR, 'mapd')
# mapd still runs (OSM downloads) but has no position source since ubloxd owns LastGPSPosition;
# map-based features stay off until mapd is rebuilt
MAPD_AVAILABLE = False
@@ -4,7 +4,6 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import json
import math
import platform
@@ -27,21 +26,6 @@ class OsmMapData(BaseMapData):
self.last_bearing = math.degrees(location.calibratedOrientationNED.value[2])
self.last_position = Coordinate(location.positionGeodetic.value[0], location.positionGeodetic.value[1])
if self.last_position is None:
return
params = {
"latitude": self.last_position.latitude,
"longitude": self.last_position.longitude,
}
if self.last_bearing is not None:
params['bearing'] = self.last_bearing
# mapd reads its position from the memory params as JSON, so write it for the mapd binary.
# LastGPSPosition is registered as BYTES (ubloxd owns the persistent copy for pigeond),
# so the JSON payload has to be encoded here.
self.mem_params.put("LastGPSPosition", json.dumps(params).encode(), block=True)
def get_current_speed_limit(self) -> float:
return float(self.mem_params.get("MapSpeedLimit") or 0.0)
@@ -121,10 +121,6 @@ def update_osm_db() -> None:
if not mem_params.get("OSMDownloadBounds"):
mem_params.put("OSMDownloadBounds", "", block=True)
# mapd reads LastGPSPosition from the memory params as JSON, and it is registered as BYTES upstream
if not mem_params.get("LastGPSPosition"):
mem_params.put("LastGPSPosition", b"{}", block=True)
def main_thread():
update_installed_version(VERSION, params)
@@ -1,70 +0,0 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import json
import math
import platform
import openpilot.cereal.messaging as messaging
from openpilot.cereal import log
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData
class TestOsmMapData(OpenpilotTestCase):
"""The mapd binary reads its position from the memory params as JSON, so mapd_manager has to feed it."""
LATITUDE = 32.875134
LONGITUDE = -117.209777
ALTITUDE = 42.0
BEARING = 90.0
def setup_method(self):
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else Params()
self.mem_params.remove("LastGPSPosition")
self.map_data = OsmMapData()
self.pm = messaging.PubMaster(['liveLocationKalman'])
def location_message(self, status: log.LiveLocationKalman.Status, position_valid: bool):
msg = messaging.new_message('liveLocationKalman', valid=True)
location = msg.liveLocationKalman
location.status = status
location.positionGeodetic = {'value': [self.LATITUDE, self.LONGITUDE, self.ALTITUDE], 'valid': position_valid}
location.calibratedOrientationNED = {'value': [0.0, 0.0, math.radians(self.BEARING)], 'valid': True}
return msg
def send_location(self, status: log.LiveLocationKalman.Status, position_valid: bool) -> None:
# the first exchange synchronizes the PUB/SUB connection, the second one is actually received
self.pm.send('liveLocationKalman', self.location_message(status, position_valid))
self.map_data.sm.update(1000)
self.pm.send('liveLocationKalman', self.location_message(status, position_valid))
self.map_data.sm.update(1000)
def read_position(self) -> dict:
value = self.mem_params.get("LastGPSPosition")
self.assertIsNotNone(value, "LastGPSPosition was not written for mapd")
return json.loads(value)
def test_writes_position_for_mapd(self):
self.send_location(log.LiveLocationKalman.Status.valid, True)
self.map_data.tick()
position = self.read_position()
self.assertAlmostEqual(position['latitude'], self.LATITUDE, delta=1e-6)
self.assertAlmostEqual(position['longitude'], self.LONGITUDE, delta=1e-6)
self.assertAlmostEqual(position['bearing'], self.BEARING, delta=1e-6)
def test_invalid_localizer_keeps_last_known_position(self):
self.send_location(log.LiveLocationKalman.Status.valid, True)
self.map_data.tick()
self.send_location(log.LiveLocationKalman.Status.uninitialized, False)
self.map_data.tick()
position = self.read_position()
self.assertAlmostEqual(position['latitude'], self.LATITUDE, delta=1e-6)
self.assertAlmostEqual(position['longitude'], self.LONGITUDE, delta=1e-6)
+48 -146
View File
@@ -7,10 +7,7 @@ See the LICENSE.md file in the root directory for more details.
import asyncio
import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from openpilot.common.params import Params
@@ -25,99 +22,12 @@ from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_
# (connect, read) seconds. read is per-request inactivity, not a total cap
DOWNLOAD_TIMEOUT = (30, 30)
# HuggingFace throttles each connection to ~1-2 MB/s but not the connection count; 12 saturates the device link
MAX_CONCURRENT_PIECES = 12
PIECE_SIZE = 8 * 1024 * 1024
PIECE_ATTEMPTS = 3
PIECE_BACKOFF = 1.0 # seconds, growing linearly: waits 1 s then 2 s between a piece's three attempts
TRANSIENT_HTTP_STATUS = frozenset({408, 429, 500, 502, 503, 504})
REPORT_INTERVAL = 0.5 # seconds between progress publications
# byte ranges count encoded bytes but iter_content yields decoded ones, so ask for an unencoded body
NO_COMPRESSION = {"Accept-Encoding": "identity"}
class DownloadCancelled(Exception):
pass
def _is_transient(e: BaseException) -> bool:
if isinstance(e, requests.HTTPError):
return e.response is not None and e.response.status_code in TRANSIENT_HTTP_STATUS
return isinstance(e, (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError))
class _Progress:
def __init__(self):
self._bytes = 0
self._lock = threading.Lock()
def add_bytes(self, n: int) -> None:
with self._lock:
self._bytes += n
def snapshot(self) -> int:
with self._lock:
return self._bytes
def _piece_ranges(total: int, piece_size: int) -> list[tuple[int, int]]:
return [(start, min(start + piece_size, total)) for start in range(0, total, piece_size)]
def _prepare_target(path: str, total: int) -> None:
with open(path, "wb") as f:
f.truncate(total)
def _content_range_total(response: requests.Response) -> int | None:
if response.status_code != 206:
return None
match = re.fullmatch(r"bytes \d+-\d+/(\d+)", response.headers.get("Content-Range", ""))
return int(match.group(1)) if match else None
def _probe_size(url: str) -> tuple[int, bool]:
with requests.get(url, headers={**NO_COMPRESSION, "Range": "bytes=0-0"}, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
response.raise_for_status()
total = _content_range_total(response)
if total is not None:
return total, True
return int(response.headers.get("Content-Length", 0)), False
def _fetch_piece(url: str, path: str, index: int, start: int, end: int | None, progress: _Progress,
cancel: threading.Event, block_size: int) -> None:
headers = {**NO_COMPRESSION, "Range": f"bytes={start}-{end - 1}"} if end is not None else NO_COMPRESSION
label = f"{os.path.basename(path)} piece {index}"
for attempt in range(PIECE_ATTEMPTS):
written = 0
try:
with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
response.raise_for_status()
if end is not None and response.status_code != 206:
raise ValueError(f"server ignored byte range for {label}")
with open(path, "r+b") as f:
f.seek(start)
for block in response.iter_content(chunk_size=block_size):
if cancel.is_set():
raise DownloadCancelled("Download cancelled")
f.write(block)
written += len(block)
progress.add_bytes(len(block))
if end is None:
f.truncate() # unknown length: the body defines the file size
if end is not None and written != end - start:
raise requests.exceptions.ConnectionError(f"short read: {written} of {end - start} bytes")
return
except requests.RequestException as e:
progress.add_bytes(-written) # the piece restarts from scratch
if not _is_transient(e) or attempt == PIECE_ATTEMPTS - 1:
raise
cloudlog.warning(f"retrying {label} after {type(e).__name__}: {e}")
if cancel.wait(PIECE_BACKOFF * (1 + attempt)):
raise DownloadCancelled("Download cancelled") from None
class ModelManagerSP:
"""Manages model downloads and status reporting"""
@@ -131,7 +41,7 @@ class ModelManagerSP:
self.source_models: dict[str, list[custom.ModelManagerSP.ModelBundle]] = {}
self.selected_bundle: custom.ModelManagerSP.ModelBundle = None
self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, chestnut=self.chestnut_present)
self._block_size = 128 * 1000 # 128 KB network read blocks
self._chunk_size = 128 * 1000 # 128 KB chunks
self._download_start_times: dict[str, float] = {} # Track start time per model
self._download_ref: bytes | str | None = None
@@ -146,6 +56,7 @@ class ModelManagerSP:
self._download_ref = None
def _sync_artifact_progress(self, source_artifact) -> None:
"""Mirror download progress to all artifacts sharing the same filename in the selected bundle."""
if not self.selected_bundle:
return
for model in self.selected_bundle.models:
@@ -156,6 +67,7 @@ class ModelManagerSP:
artifact.downloadProgress.eta = source_artifact.downloadProgress.eta
def _calculate_eta(self, filename: str, progress: float) -> int:
"""Calculate ETA based on elapsed time and current progress"""
if filename not in self._download_start_times or progress <= 0:
return 60 # Default ETA for new downloads
@@ -163,58 +75,39 @@ class ModelManagerSP:
if elapsed_time <= 0:
return 60
# If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100
total_estimated_time = (elapsed_time / progress) * 100
eta = total_estimated_time - elapsed_time
return max(1, int(eta)) # Return at least 1 second if download is ongoing
def _set_progress(self, artifact, status, progress: float, eta: int = 0) -> None:
artifact.downloadProgress.status = status
artifact.downloadProgress.progress = progress
artifact.downloadProgress.eta = eta
self._sync_artifact_progress(artifact)
self._report_status()
async def _download_file(self, url: str, path: str, model) -> None:
"""Downloads a file with progress tracking"""
self._download_start_times[model.fileName] = time.monotonic()
def _publish_progress(self, artifact, done_bytes: int, total: int) -> None:
# held at 99 until the file passes its hash check
progress = min(99.0, done_bytes / total * 100) if total > 0 else 0.0
eta = self._calculate_eta(artifact.fileName, progress)
self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta)
with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: # noqa: ASYNC210
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
bytes_downloaded = 0
async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int) -> None:
pending = set(tasks)
while pending:
done, pending = await asyncio.wait(pending, timeout=REPORT_INTERVAL)
for task in done:
task.result() # re-raises a worker failure
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
self._publish_progress(artifact, progress.snapshot(), total)
with open(path, 'wb') as f: # noqa: ASYNC230
for chunk in response.iter_content(chunk_size=self._chunk_size): # type: bytes
f.write(chunk)
bytes_downloaded += len(chunk)
async def _download_file(self, url: str, path: str, artifact) -> None:
self._download_start_times[artifact.fileName] = time.monotonic()
loop = asyncio.get_running_loop()
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
total, ranged = await loop.run_in_executor(None, _probe_size, url)
pieces: list[tuple[int, int | None]] = list(_piece_ranges(total, PIECE_SIZE)) if ranged else [(0, None)]
await loop.run_in_executor(None, _prepare_target, path, total)
progress = _Progress()
cancel = threading.Event()
if total_size > 0:
progress = (bytes_downloaded / total_size) * 100
model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
model.downloadProgress.progress = progress
model.downloadProgress.eta = self._calculate_eta(model.fileName, progress)
self._sync_artifact_progress(model)
self._report_status()
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_PIECES) as pool:
tasks = [loop.run_in_executor(pool, _fetch_piece, url, path, i, start, end, progress, cancel, self._block_size)
for i, (start, end) in enumerate(pieces)]
try:
await self._report_until_done(tasks, artifact, progress, total)
except BaseException as e:
cancel.set()
if isinstance(e, DownloadCancelled):
artifact.downloadProgress.canceling = True
self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, artifact.downloadProgress.progress)
for task in tasks:
task.cancel() # drops queued pieces; running ones see the event at their next block
await asyncio.gather(*tasks, return_exceptions=True)
raise
del self._download_start_times[artifact.fileName]
# Clean up start time after download completes
del self._download_start_times[model.fileName]
async def _process_artifact(self, artifact, destination_path: str) -> None:
if not artifact.downloadUri.uri:
@@ -226,30 +119,33 @@ class ModelManagerSP:
expected_hash = artifact.downloadUri.sha256
filename = artifact.fileName
full_path = os.path.join(destination_path, filename)
status = custom.ModelManagerSP.DownloadStatus
artifact.downloadProgress.canceling = False
try:
self._set_progress(artifact, status.verifying, 0)
if await verify_file(full_path, expected_hash):
self._set_progress(artifact, status.cached, 100)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self._report_status()
return
self._set_progress(artifact, status.downloading, 0)
await self._download_file(url, full_path, artifact)
self._set_progress(artifact, status.verifying, 99)
if not await verify_file(full_path, expected_hash):
raise ValueError(f"Hash validation failed for {filename}")
self._set_progress(artifact, status.downloaded, 100)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self._report_status()
except DownloadCancelled:
self._download_start_times.pop(artifact.fileName, None)
artifact.downloadProgress.status = status.failed
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
if self.selected_bundle:
self.selected_bundle.status = status.failed
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
self._report_status()
raise
@@ -257,11 +153,11 @@ class ModelManagerSP:
cloudlog.error(f"Error downloading {filename}: {str(e)}")
if os.path.isfile(full_path): # noqa: ASYNC240
os.remove(full_path)
artifact.downloadProgress.status = status.failed
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
if self.selected_bundle:
self.selected_bundle.status = status.failed
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
self._report_status()
self._download_start_times.pop(artifact.fileName, None)
raise
@@ -376,7 +272,12 @@ class ModelManagerSP:
rk.keep_time()
def clear_model_cache(self) -> None:
""" Clears the model cache directory of all files except those in the active model bundle."""
"""
Clears the model cache directory of all files except those in the active model bundle.
"""
# Get list of files used by both slots' selected bundles (either may become
# the truly active bundle depending on hardware availability)
active_files = []
for source in ACTIVE_BUNDLE_KEYS:
if selected_bundle := get_selected_bundle(self.params, source):
@@ -384,6 +285,7 @@ class ModelManagerSP:
if model.artifact.fileName:
active_files.append(model.artifact.fileName)
# Remove all files except active ones
model_dir = Paths.model_root()
try:
for filename in os.listdir(model_dir):
File diff suppressed because it is too large Load Diff
@@ -126,7 +126,6 @@ def initialize_params(params) -> list[dict[str, Any]]:
keys.extend([
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
"ToyotaEnhancedBsm",
])
return [{k: params.get(k, return_default=True)} for k in keys]
@@ -0,0 +1,25 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
STOPPING_DISTANCE = 1.5
STOPPED_SPEED = 0.02
STOPPING_TIME = 2.5
MIN_STOPPING_HOLD_ACCEL = -0.2
STOPPING_DECEL_RATE = 0.3
STOPPED_DECEL_RATE = 0.05
class LongControlSP:
def should_hold_stopping(self, CS, a_target: float) -> bool:
return (self.last_output_accel <= MIN_STOPPING_HOLD_ACCEL and a_target >= self.last_output_accel and CS.vEgo > STOPPED_SPEED and CS.aEgo < 0.0
and CS.vEgo <= -CS.aEgo * STOPPING_TIME and CS.vEgo ** 2 <= -2.0 * CS.aEgo * STOPPING_DISTANCE)
@staticmethod
def stopping_decel_rate(v_ego: float) -> float:
return float(np.interp(v_ego, [0.0, 0.5, 1.5], [STOPPED_DECEL_RATE, 0.15, STOPPING_DECEL_RATE]))
@@ -9,6 +9,7 @@ from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.mapd import MAPD_AVAILABLE
from openpilot.sunnypilot.navd.helpers import Coordinate
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V
@@ -75,7 +76,7 @@ class SmartCruiseControlMap:
def __init__(self):
self.params = Params()
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
self.enabled = self.params.get_bool("SmartCruiseControlMap")
self.enabled = MAPD_AVAILABLE and self.params.get_bool("SmartCruiseControlMap")
self.long_enabled = False
self.long_override = False
self.is_enabled = False
@@ -101,7 +102,7 @@ class SmartCruiseControlMap:
def update_params(self):
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
self.enabled = self.params.get_bool("SmartCruiseControlMap")
self.enabled = MAPD_AVAILABLE and self.params.get_bool("SmartCruiseControlMap")
def update_calculations(self, sm: messaging.SubMaster) -> None:
gps = sm[self.gps_location_service]
@@ -8,7 +8,7 @@ import json
import math
import platform
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from openpilot.cereal import custom
from openpilot.common.params import Params
@@ -23,6 +23,8 @@ MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState
class TestSmartCruiseControlMap(OpenpilotTestCase):
def setup_method(self):
self.mapd_patch = patch("openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller.MAPD_AVAILABLE", True)
self.mapd_patch.start()
self.params = Params()
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
self.reset_params()
@@ -30,6 +32,9 @@ class TestSmartCruiseControlMap(OpenpilotTestCase):
self.sm = MagicMock()
self.sm.__getitem__.return_value = SimpleNamespace(latitude=0.0, longitude=0.0)
def teardown_method(self):
self.mapd_patch.stop()
def reset_params(self):
self.params.put_bool("SmartCruiseControlMap", True, block=True)
@@ -50,6 +55,14 @@ class TestSmartCruiseControlMap(OpenpilotTestCase):
assert self.scc_m.state == VisionState.disabled
assert not self.scc_m.is_active
def test_mapd_unavailable(self):
self.mapd_patch.stop()
self.scc_m = SmartCruiseControlMap()
for _ in range(int(10. / DT_MDL)):
self.scc_m.update(self.sm, True, False, 0., 0., 0.)
assert self.scc_m.state == VisionState.disabled
self.mapd_patch.start()
def test_disabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_m.update(self.sm, False, False, 0., 0., 0.)
@@ -13,6 +13,7 @@ from openpilot.common.gps import get_gps_location_service
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD, get_sanitize_int_param
from openpilot.sunnypilot.mapd import MAPD_AVAILABLE
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy, OffsetType
@@ -48,6 +49,8 @@ class SpeedLimitResolver:
Policy.max().value,
self.params
)
if not MAPD_AVAILABLE:
self.policy = Policy.car_state_only
self._policy_to_sources_map = {
Policy.car_state_only: [SpeedLimitSource.car],
Policy.map_data_only: [SpeedLimitSource.map],
@@ -91,7 +94,7 @@ class SpeedLimitResolver:
def update_params(self):
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
self.policy = self.params.get("SpeedLimitPolicy", return_default=True)
self.policy = self.params.get("SpeedLimitPolicy", return_default=True) if MAPD_AVAILABLE else Policy.car_state_only
self.is_metric = self.params.get_bool("IsMetric")
self.offset_type = self.params.get("SpeedLimitOffsetType", return_default=True)
self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True)
+4 -13
View File
@@ -10,12 +10,12 @@ from openpilot.cereal import custom, messaging
from opendbc.car.structs import car
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR
from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags
from opendbc.car.toyota.values import CAR as TOYOTA_CAR, ToyotaFlags
from opendbc.sunnypilot.car.interfaces import has_bsm
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware import HARDWARE
from openpilot.sunnypilot.mapd import MAPD_AVAILABLE
# Wire-protocol version for the capabilities payload. Bump on breaking changes
@@ -44,7 +44,7 @@ CAPABILITY_FIELDS = (
"device_type",
"subaru_has_sng",
"hyundai_alpha_long_available",
"toyota_enhanced_bsm_available",
"mapd_available",
)
CAPABILITY_LABELS: dict[str, str] = {
@@ -67,7 +67,7 @@ CAPABILITY_LABELS: dict[str, str] = {
"device_type": "Device type",
"subaru_has_sng": "Subaru Stop-and-Go available",
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
"toyota_enhanced_bsm_available": "Toyota Enhanced BSM available",
"mapd_available": "Map data available",
}
# Explicit defaults for non-boolean capability fields
@@ -114,16 +114,6 @@ def _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
caps["has_stop_and_go"] = caps["subaru_has_sng"]
elif brand == "toyota":
if bundle_platform:
try:
flags = TOYOTA_CAR[bundle_platform].config.flags
caps["toyota_enhanced_bsm_available"] = bool(flags & ToyotaFlags.TSS2) and not bool(flags & ToyotaFlags.SECOC)
except KeyError:
cloudlog.exception(f"capabilities: unknown toyota platform {bundle_platform!r}")
elif CP is not None:
caps["toyota_enhanced_bsm_available"] = bool(CP.flags & ToyotaFlags.TSS2) and not bool(CP.flags & ToyotaFlags.SECOC)
def generate_capabilities(params: Params | None = None) -> dict:
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
@@ -145,6 +135,7 @@ def generate_capabilities(params: Params | None = None) -> dict:
caps["is_sp_release"] = params.get_bool("IsReleaseSpBranch")
caps["is_development"] = params.get_bool("IsDevelopmentBranch")
caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal")
caps["mapd_available"] = MAPD_AVAILABLE
bundle = params.get("CarPlatformBundle")
bundle_brand = _bundle_field(bundle, "brand")
@@ -156,7 +156,7 @@ The tables below describe the **compiled** `settings_ui.json` schema — what th
**Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why.
**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`, `toyota_enhanced_bsm_available`
**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`, `mapd_available`
---
+37 -26
View File
@@ -857,19 +857,47 @@
},
{
"value": 1,
"label": "Map Data Only"
"label": "Map Data Only",
"enablement": [
{
"type": "capability",
"field": "mapd_available",
"equals": true
}
]
},
{
"value": 2,
"label": "Car State Priority"
"label": "Car State Priority",
"enablement": [
{
"type": "capability",
"field": "mapd_available",
"equals": true
}
]
},
{
"value": 3,
"label": "Map Data Priority"
"label": "Map Data Priority",
"enablement": [
{
"type": "capability",
"field": "mapd_available",
"equals": true
}
]
},
{
"value": 4,
"label": "Combined"
"label": "Combined",
"enablement": [
{
"type": "capability",
"field": "mapd_available",
"equals": true
}
]
}
]
},
@@ -1001,6 +1029,11 @@
}
],
"enablement": [
{
"type": "capability",
"field": "mapd_available",
"equals": true
},
{
"type": "any",
"conditions": [
@@ -2324,28 +2357,6 @@
"equals": false
}
]
},
{
"key": "ToyotaEnhancedBsm",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Enhanced Blind Spot Monitoring (Alpha)",
"description": "sunnypilot will poll the blind spot monitor diagnostics directly from the radar. Use this on vehicles whose blind spot monitors do not report blind spot status over the standard CAN signals. This is an alpha feature. Use at your own risk.",
"enablement": [
{
"type": "not_engaged"
},
{
"type": "capability",
"field": "toyota_enhanced_bsm_available",
"equals": true
},
{
"type": "capability",
"field": "stock_longitudinal",
"equals": false
}
]
}
]
}
@@ -167,12 +167,28 @@ sections:
label: Car State Only
- value: 1
label: Map Data Only
enablement:
- type: capability
field: mapd_available
equals: true
- value: 2
label: Car State Priority
enablement:
- type: capability
field: mapd_available
equals: true
- value: 3
label: Map Data Priority
enablement:
- type: capability
field: mapd_available
equals: true
- value: 4
label: Combined
enablement:
- type: capability
field: mapd_available
equals: true
- key: SpeedLimitOffsetType
widget: multiple_button
title: Speed Limit Offset Type
@@ -246,6 +262,9 @@ sections:
field: has_icbm
equals: true
enablement:
- type: capability
field: mapd_available
equals: true
- type: any
conditions:
- type: capability
@@ -101,18 +101,3 @@ sections:
- type: param
key: ToyotaEnforceStockLongitudinal
equals: false
- key: ToyotaEnhancedBsm
widget: toggle
needs_onroad_cycle: true
title: Enhanced Blind Spot Monitoring (Alpha)
description: sunnypilot will poll the blind spot monitor diagnostics directly from the radar. Use this on vehicles whose
blind spot monitors do not report blind spot status over the standard CAN signals. This is an alpha feature. Use at
your own risk.
enablement:
- $ref: '#/macros/not_engaged'
- type: capability
field: toyota_enhanced_bsm_available
equals: true
- type: capability
field: stock_longitudinal
equals: false
@@ -69,18 +69,12 @@ class TestOpaquePerBrandFlags(OpenpilotTestCase):
def test_hyundai_alpha_long_available_field_present(self):
assert "hyundai_alpha_long_available" in CAPABILITY_FIELDS
def test_toyota_enhanced_bsm_available_field_present(self):
assert "toyota_enhanced_bsm_available" in CAPABILITY_FIELDS
def test_subaru_has_sng_default_false(self, caps):
assert caps["subaru_has_sng"] is False
def test_hyundai_alpha_long_available_default_false(self, caps):
assert caps["hyundai_alpha_long_available"] is False
def test_toyota_enhanced_bsm_available_default_false(self, caps):
assert caps["toyota_enhanced_bsm_available"] is False
class TestCapabilitiesShape(OpenpilotTestCase):
def test_all_fields_present(self, caps):
@@ -207,7 +207,6 @@ class TestNotEngagedReplacement(OpenpilotTestCase):
"AlphaLongitudinalEnabled",
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
"ToyotaEnhancedBsm",
], names=["key"])
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
"""These items should use not_engaged, not offroad_only."""
@@ -286,7 +286,6 @@ class TestKnownVehicleSettings(OpenpilotTestCase):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
assert "ToyotaEnforceStockLongitudinal" in keys
assert "ToyotaStopAndGoHack" in keys
assert "ToyotaEnhancedBsm" in keys
def test_tesla_has_coop_steering(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
@@ -57,7 +57,7 @@ class DownloadStatusAction(ItemAction):
self.icon: str | None = None
self.icon_color: rl.Color | None = None
self._font = gui_app.font(FontWeight.NORMAL)
# raw progress arrives in steps, one per progress report from the manager
# raw progress arrives in steps, one per 128KB chunk the manager publishes
self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps)
# integrated per frame; (t * speed) % span jumps whenever the fill width changes
self._sweep = 0.0