mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 06:23:43 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a699c11569 | |||
| 7d740b8a31 | |||
| a726bda691 | |||
| 72c450092d | |||
| 39649958ca | |||
| f65bb49d0a | |||
| b16bd8c7a9 |
+1
-4
@@ -14,8 +14,6 @@ openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl -text
|
||||
openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js -text
|
||||
*.apk -text
|
||||
openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 -text
|
||||
openpilot/starpilot/galaxy/bin/hostapd_linux_arm64 -text
|
||||
openpilot/starpilot/galaxy/bin/hostapd_cli_linux_arm64 -text
|
||||
openpilot/starpilot/soloist/pulseaudio/bin/pulseaudio -text
|
||||
openpilot/starpilot/soloist/pulseaudio/bin/pactl -text
|
||||
openpilot/starpilot/soloist/bluealsa/bin/bluealsa-aplay -text
|
||||
@@ -41,6 +39,7 @@ tinygrad_model_runtime/** !filter !diff !merge -text
|
||||
*.a -text
|
||||
*.so -text
|
||||
*.so.* -text
|
||||
openpilot/cereal/messaging/bridge -text
|
||||
openpilot/selfdrive/pandad/pandad -text
|
||||
openpilot/tools/replay/replay -text
|
||||
openpilot/system/camerad/camerad -text
|
||||
@@ -49,8 +48,6 @@ openpilot/system/loggerd/encoderd -text
|
||||
openpilot/system/loggerd/loggerd -text
|
||||
openpilot/starpilot/galaxy/bin/frpc_linux_arm64 -text
|
||||
openpilot/starpilot/maps/provider/mapd -text
|
||||
openpilot/tools/arabesque_firefox/offline/*.deb -text
|
||||
openpilot/tools/arabesque_firefox/offline/*.tar.xz -text
|
||||
openpilot/selfdrive/test/process_replay/refs/starpilot/*.zst -text
|
||||
|
||||
# Published Panda object files are AArch64 ELF, not text.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
name: FLM offroad contracts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [Dom]
|
||||
paths:
|
||||
- 'openpilot/starpilot/flm/**'
|
||||
- 'openpilot/starpilot/lateral/**'
|
||||
- 'openpilot/starpilot/galaxy/web/js/flm*.js'
|
||||
- 'openpilot/starpilot/galaxy/tests/test_flm*.mjs'
|
||||
- 'openpilot/selfdrive/controls/lib/latcontrol*'
|
||||
- 'openpilot/selfdrive/controls/controlsd.py'
|
||||
- 'openpilot/system/manager/process_config.py'
|
||||
- '.github/workflows/flm-offroad.yaml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Full candidate commit SHA to qualify without creating a branch
|
||||
type: string
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flm-offroad-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI: 1
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
RAYLIB_BACKEND: headless
|
||||
|
||||
jobs:
|
||||
contracts:
|
||||
name: offroad backend contracts
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_sha || github.sha }}
|
||||
- name: Verify exact source under test
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
git rev-parse HEAD
|
||||
test -z "$SOURCE_SHA" || test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Check all FLM and integrated controller source
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
ruff check openpilot/starpilot/flm openpilot/selfdrive/controls/controlsd.py openpilot/selfdrive/controls/lib/latcontrol_pid.py openpilot/selfdrive/controls/lib/latcontrol_torque.py openpilot/system/manager/process_config.py
|
||||
ty check openpilot/starpilot/flm openpilot/selfdrive/controls/controlsd.py openpilot/selfdrive/controls/lib/latcontrol_pid.py openpilot/selfdrive/controls/lib/latcontrol_torque.py openpilot/system/manager/process_config.py
|
||||
- name: Build native dependencies
|
||||
timeout-minutes: 10
|
||||
run: scons
|
||||
- name: Test offroad recommendations, storage, startup and rollback contracts
|
||||
timeout-minutes: 3
|
||||
run: >-
|
||||
tools/op.sh test
|
||||
openpilot/starpilot/flm/tests/test_offroad_backend.py
|
||||
openpilot/starpilot/flm/tests/test_offroad_coordinate_policy.py
|
||||
openpilot/starpilot/flm/tests/test_offroad_equal_native_output.py
|
||||
--json-output ci-results/flm-offroad.json
|
||||
- name: Test completed-drive and steering-result presentation
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
node openpilot/starpilot/galaxy/tests/test_flm_training_results.mjs
|
||||
node openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs
|
||||
- name: Retain offroad contract results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flm-offroad-contracts
|
||||
path: ci-results/flm-offroad.json
|
||||
if-no-files-found: warn
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
id: setup
|
||||
- name: Build native dependencies for packaging tests
|
||||
id: build
|
||||
run: scons -j2 openpilot/common/libparams_c.so msgq_repo/msgq/_ipc.so
|
||||
run: scons -j2 openpilot/common/libparams_c.so msgq_repo/msgq/ipc_pyx.so
|
||||
- name: Test dependency tools
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: python3 -m unittest discover -s tools/vendor/tests -v
|
||||
@@ -57,9 +57,6 @@ jobs:
|
||||
- name: Test CI tools
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: python3 -m unittest discover -s tools/ci/tests -v
|
||||
- name: Test authenticated monitoring release artifacts
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: python3 -m pytest -q tools/ci/tests/test_stock_monitoring_release.py
|
||||
- name: Test updater
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: python3 -m unittest discover -s openpilot/system/updated/tests -v
|
||||
@@ -201,11 +198,6 @@ jobs:
|
||||
run: |
|
||||
uv pip install --python .venv/bin/python --only-binary av av==19.0.1
|
||||
.venv/bin/python -c "import av; assert 'libx264' in av.codecs_available"
|
||||
- name: Provide pinned OpenCV DNN for vision contract tests
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
run: |
|
||||
uv pip install --python .venv/bin/python --no-deps --only-binary :all: opencv-python-headless==4.11.0.86
|
||||
.venv/bin/python -c "import cv2; assert cv2.__version__ == '4.11.0'; assert callable(cv2.dnn.readNetFromONNX); assert callable(cv2.dnn.NMSBoxes)"
|
||||
- name: Run unit tests
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
|
||||
timeout-minutes: ${{ contains(runner.name, 'nsc') && 2 || 20 }}
|
||||
@@ -233,7 +225,6 @@ jobs:
|
||||
openpilot/selfdrive/pandad/tests/test_pandad_canprotocol
|
||||
openpilot/selfdrive/pandad/tests/test_aol_protocol
|
||||
openpilot/selfdrive/pandad/tests/test_aol_wire
|
||||
openpilot/selfdrive/pandad/tests/test_panda_faults
|
||||
openpilot/selfdrive/pandad/tests/test_producer_timing
|
||||
openpilot/selfdrive/pandad/tests/test_producer_sink
|
||||
- name: Test map provider to host messaging
|
||||
@@ -351,8 +342,6 @@ jobs:
|
||||
run: scons
|
||||
- name: Gate every assigned lifecycle module serially
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
RAYLIB_BACKEND: headless
|
||||
run: python3 tools/ci/run_vehicle_tests.py --suite aol-lifecycle --shard-index "${{ matrix.shard }}" --shard-count 16 --output "ci-results/aol-lifecycle-${{ matrix.shard }}"
|
||||
- name: Save every lifecycle outcome and native build
|
||||
if: always()
|
||||
@@ -453,7 +442,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
shard: [0, 1, 2, 3]
|
||||
env:
|
||||
FUZZ_SEED: '0'
|
||||
steps:
|
||||
@@ -463,9 +452,7 @@ jobs:
|
||||
run: scons
|
||||
- name: Run every assigned RELEASE target serially
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
RAYLIB_BACKEND: headless
|
||||
run: python3 tools/ci/run_vehicle_tests.py --suite safety-release --shard-index "${{ matrix.shard }}" --shard-count 16 --output "ci-results/safety-release-${{ matrix.shard }}"
|
||||
run: python3 tools/ci/run_vehicle_tests.py --suite safety-release --shard-index "${{ matrix.shard }}" --shard-count 4 --output "ci-results/safety-release-${{ matrix.shard }}"
|
||||
- name: Save every RELEASE outcome and native build
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -488,7 +475,7 @@ jobs:
|
||||
pattern: safety-release-*
|
||||
path: ci-results/safety-release-shards
|
||||
- name: Require every RELEASE case exactly once and passed
|
||||
run: python3 tools/ci/lifecycle_shards.py ci-results/safety-release-shards --suite safety-release --count 16 --output ci-results/safety-release-complete.json
|
||||
run: python3 tools/ci/lifecycle_shards.py ci-results/safety-release-shards --suite safety-release --count 4 --output ci-results/safety-release-complete.json
|
||||
- name: Save complete RELEASE verdict
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -74,6 +74,7 @@ openpilot/selfdrive/pandad/tests/test_aol_wire
|
||||
openpilot/selfdrive/pandad/tests/test_panda_usb
|
||||
openpilot/cereal/services.h
|
||||
openpilot/cereal/gen
|
||||
openpilot/cereal/messaging/bridge
|
||||
openpilot/selfdrive/ui/translations/tmp
|
||||
openpilot/selfdrive/car/tests/cars_dump
|
||||
openpilot/system/camerad/camerad
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Jetlink external inference
|
||||
|
||||
The comma-side Jetlink client and USB transport in `jetlink_repo` are by Zeph Leggett and the [Jetlink contributors](https://github.com/zoompilot/jetlink). They are vendored from revision `9cfde98eec1d7cc714d800504944cf1a3944513d` under the MIT license, retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE). StarPilot's adapter (`openpilot/starpilot/models/jetlink_adapter.py`) builds on Jetlink's openpilot layer and owns its param names, Always On Lateral rule, model runner reset, settings and alerts. Companion applications and inference servers remain distributed by Jetlink.
|
||||
The comma-side Jetlink client and USB transport in `jetlink_repo` are by Zeph Leggett and the [Jetlink contributors](https://github.com/zoompilot/jetlink). They are vendored from revision `a863c3801da6fd925d14eb1f5b1e250f8efcc649` under the MIT license, retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE). StarPilot's adapter (`openpilot/starpilot/models/jetlink_adapter.py`) builds on Jetlink's openpilot layer and owns its param names, Always On Lateral rule, model runner reset, settings and alerts. Companion applications and inference servers remain distributed by Jetlink.
|
||||
|
||||
## MRR35 Hyundai angle and radar
|
||||
|
||||
|
||||
+15
-16
@@ -57,7 +57,7 @@ assert arch in [
|
||||
"Darwin", # macOS arm64 (x86 not supported)
|
||||
]
|
||||
|
||||
pkg_names = ['acados', 'capnproto', 'ffmpeg', 'json11', 'ncurses', 'zstd']
|
||||
pkg_names = ['acados', 'capnproto', 'ffmpeg', 'json11', 'ncurses', 'zeromq', 'zstd']
|
||||
if arch != 'Darwin':
|
||||
pkg_names.append('libusb')
|
||||
pkgs = [importlib.import_module(name) for name in pkg_names]
|
||||
@@ -172,7 +172,7 @@ env = Environment(
|
||||
COMPILATIONDB_USE_ABSPATH=True,
|
||||
REDNOSE_ROOT="#rednose_repo",
|
||||
tools=["default", "cython", "compilation_db", "rednose_filter"],
|
||||
toolpath=["#rednose_repo/site_scons/site_tools"],
|
||||
toolpath=["#msgq_repo/site_scons/site_tools", "#rednose_repo/site_scons/site_tools"],
|
||||
)
|
||||
# SCons' Darwin linker tool doesn't define the variables used to expand RPATH.
|
||||
if arch == "Darwin":
|
||||
@@ -292,7 +292,7 @@ SConscript(['opendbc_repo/opendbc/can/SConscript'])
|
||||
# Build common module
|
||||
SConscript(['openpilot/common/SConscript'])
|
||||
Import('_common')
|
||||
common = [_common, 'json11']
|
||||
common = [_common, 'json11', 'zmq']
|
||||
Export('common')
|
||||
|
||||
# Build messaging (cereal + msgq + socketmaster + their dependencies)
|
||||
@@ -329,7 +329,6 @@ SConscript([
|
||||
'openpilot/selfdrive/ui/SConscript',
|
||||
'openpilot/starpilot/audio/native/SConscript',
|
||||
'openpilot/starpilot/models/runtime_bridge/SConscript',
|
||||
'openpilot/starpilot/ui/native/SConscript',
|
||||
'openpilot/starpilot/flm/SConscript',
|
||||
])
|
||||
|
||||
@@ -348,7 +347,7 @@ if GetOption('extras') and arch != "comma_arm64":
|
||||
env.CompilationDatabase('compile_commands.json')
|
||||
|
||||
# progress output
|
||||
def get_scons_nodes(nodes):
|
||||
def count_scons_nodes(nodes):
|
||||
seen = set()
|
||||
stack = list(nodes)
|
||||
|
||||
@@ -357,28 +356,28 @@ def get_scons_nodes(nodes):
|
||||
if node in seen:
|
||||
continue
|
||||
seen.add(node)
|
||||
# Only scan directories; generated sources are not ready yet.
|
||||
stack += (node.prerequisites or []) + node.children(scan=isinstance(node, SCons.Node.FS.Dir))
|
||||
executor = node.get_executor()
|
||||
if executor is not None:
|
||||
stack += executor.get_all_prerequisites() + executor.get_all_children()
|
||||
|
||||
return seen
|
||||
return len(seen)
|
||||
|
||||
progress_interval = 5
|
||||
progress_nodes = get_scons_nodes(env.arg2nodes(BUILD_TARGETS or [Dir('.')], env.fs.Entry))
|
||||
progress_total = max(1, len(progress_nodes))
|
||||
progress_count = 0
|
||||
progress_total = max(1, count_scons_nodes(env.arg2nodes(BUILD_TARGETS or [Dir('.')], env.fs.Entry)))
|
||||
|
||||
def progress_function(node):
|
||||
if node not in progress_nodes:
|
||||
global progress_count
|
||||
if progress_count >= progress_total:
|
||||
return
|
||||
progress_nodes.remove(node)
|
||||
if len(progress_nodes) % progress_interval:
|
||||
return
|
||||
progress = round(100. * (1 - len(progress_nodes) / progress_total), 1)
|
||||
progress_count = min(progress_count + progress_interval, progress_total)
|
||||
progress = round(100. * progress_count / progress_total, 1)
|
||||
sys.stderr.write("\rBuilding: %5.1f%%" % progress if sys.stderr.isatty() else "progress: %.1f\n" % progress)
|
||||
if progress == 100. and sys.stderr.isatty():
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
Progress(progress_function)
|
||||
Progress(progress_function, interval=progress_interval)
|
||||
AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir)
|
||||
|
||||
# Release CI rebuilds tracked device outputs for the host architecture.
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
# Third-party notices
|
||||
|
||||
## Arabesque Firefox
|
||||
|
||||
The offline payload contains original Mozilla and Ubuntu archives. The pinned
|
||||
versions, upstream download URLs and hashes are listed in
|
||||
[`runtime.json`](openpilot/tools/arabesque_firefox/runtime.json). Mozilla publishes
|
||||
the [Firefox 153.6.0esr source](https://archive.mozilla.org/pub/firefox/releases/153.6.0esr/source/)
|
||||
and [Firefox notices](https://www.mozilla.org/en-US/about/legal/firefox/).
|
||||
The Ubuntu archives retain their package copyright files. The
|
||||
[Ubuntu package archive](https://ubuntu.com/project/docs/how-ubuntu-is-made/concepts/package-archive/)
|
||||
provides source packages alongside the binaries.
|
||||
|
||||
## Jetlink
|
||||
|
||||
Jetlink's comma client is copyright (c) 2026 Zeph Leggett and distributed under the MIT License. Its complete license and permission notice are retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE); source and revision are recorded in [CREDITS.md](CREDITS.md#jetlink-external-inference) and `upstream-sync.json`.
|
||||
|
||||
@@ -77,7 +77,7 @@ while [[ $# -gt 0 ]]; do
|
||||
;;
|
||||
--cereal|-cereal)
|
||||
shortcut_targets+=(openpilot/cereal/libcereal.a openpilot/cereal/libsocketmaster.a
|
||||
openpilot/cereal/services.h)
|
||||
openpilot/cereal/messaging/bridge openpilot/cereal/services.h)
|
||||
;;
|
||||
-j|--jobs)
|
||||
passthrough_args+=("$1")
|
||||
|
||||
+1
-26
@@ -5,8 +5,6 @@ openpilot ALC and openpilot LDW do not automatically drive the vehicle or reduce
|
||||
|
||||
While changing lanes, openpilot is not capable of looking next to you or checking your blind spot. Only nudge the wheel to initiate a lane change after you have confirmed it's safe to do so.
|
||||
|
||||
Unexpected steering may occur. openpilot ALC may not steer to avoid stopped or slow-moving vehicles, cross traffic, construction barriers or cones, motorcycles, children, pedestrians, animals, or other objects on the road.
|
||||
|
||||
Many factors can impact the performance of openpilot ALC and openpilot LDW, causing them to be unable to function as intended. These include, but are not limited to:
|
||||
|
||||
* Poor visibility (heavy rain, snow, fog, etc.) or weather conditions that may interfere with sensor operation.
|
||||
@@ -15,14 +13,6 @@ Many factors can impact the performance of openpilot ALC and openpilot LDW, caus
|
||||
* The device is mounted incorrectly.
|
||||
* When in sharp curves, like on-off ramps, intersections etc...; openpilot is designed to be limited in the amount of steering torque it can produce.
|
||||
* In the presence of restricted lanes or construction zones.
|
||||
* When the lane markings are unclear, worn, missing, or they cross each other.
|
||||
* Tar marks, shadows, road cracks, temporary or construction lane markings, or other road imperfections.
|
||||
* When lanes merge or split.
|
||||
* When entering a highway on-ramp or exiting an off-ramp.
|
||||
* On roads with intersections or roundabouts.
|
||||
* On slippery roads.
|
||||
* When towing a trailer.
|
||||
* When entering or exiting a tunnel.
|
||||
* When driving on highly banked roads or in presence of strong cross-wind.
|
||||
* Extremely hot or cold temperatures.
|
||||
* Bright light (due to oncoming headlights, direct sunlight, etc.).
|
||||
@@ -34,8 +24,6 @@ The list above does not represent an exhaustive list of situations that may inte
|
||||
|
||||
openpilot ACC and openpilot FCW are not systems that allow careless or inattentive driving. It is still necessary for the driver to pay close attention to the vehicle’s surroundings and to be ready to re-take control of the gas and the brake at all times.
|
||||
|
||||
openpilot ACC may not stop your vehicle in time to avoid a collision with a vehicle that quickly stops in front of you. openpilot ACC may occasionally cause your vehicle to brake when not required or when you are not expecting it.
|
||||
|
||||
Many factors can impact the performance of openpilot ACC and openpilot FCW, causing them to be unable to function as intended. These include, but are not limited to:
|
||||
|
||||
* Poor visibility (heavy rain, snow, fog, etc.) or weather conditions that may interfere with sensor operation.
|
||||
@@ -47,20 +35,9 @@ Many factors can impact the performance of openpilot ACC and openpilot FCW, caus
|
||||
* In presence of traffic signs or stop lights, which are not detected by openpilot at this time.
|
||||
* When the posted speed limit is below the user selected set speed. openpilot does not detect speed limits at this time.
|
||||
* In presence of vehicles in the same lane that are not moving.
|
||||
* When a vehicle you are following moves out of your driving path and a stationary or slow-moving vehicle or object is in front of you.
|
||||
* Narrow vehicles, such as bicycles or motorcycles.
|
||||
* Vehicles or objects only partially in the driving lane.
|
||||
* On curves, openpilot ACC may not detect a vehicle in the same lane, and may accelerate to the set speed.
|
||||
* Cross traffic.
|
||||
* Oncoming vehicles.
|
||||
* Animals or objects in the road.
|
||||
* Rare objects such as trailers, ramps, cargo, open doors, etc. protruding from vehicles.
|
||||
* When abrupt braking maneuvers are required. openpilot is designed to be limited in the amount of deceleration and acceleration that it can produce.
|
||||
* When surrounding vehicles perform close cut-ins from neighbor lanes.
|
||||
* Driving on hills, narrow, or winding roads.
|
||||
* On slippery roads.
|
||||
* When towing a trailer.
|
||||
* When entering or exiting a tunnel.
|
||||
* Extremely hot or cold temperatures.
|
||||
* Bright light (due to oncoming headlights, direct sunlight, etc.).
|
||||
* Interference from other equipment that generates radar waves.
|
||||
@@ -69,9 +46,7 @@ The list above does not represent an exhaustive list of situations that may inte
|
||||
|
||||
## Limitations of openpilot DM
|
||||
|
||||
openpilot DM displays a series of escalating warnings. Disregard of these warnings may result in openpilot being unavailable for a period of time.
|
||||
|
||||
openpilot DM should not be considered an exact measurement of the alertness of the driver. openpilot DM cannot always detect if the driver is tired or inattentive.
|
||||
openpilot DM should not be considered an exact measurement of the alertness of the driver.
|
||||
|
||||
Many factors can impact the performance of openpilot DM, causing it to be unable to function as intended. These include, but are not limited to:
|
||||
|
||||
|
||||
@@ -4,12 +4,6 @@ openpilot is an Adaptive Cruise Control (ACC) and Automated Lane Centering (ALC)
|
||||
Like other ACC and ALC systems, openpilot is a failsafe passive system and it requires the
|
||||
driver to be alert and to pay attention at all times.
|
||||
|
||||
openpilot is not an autonomous driving system. It is not a collision avoidance system or a
|
||||
substitute for safe and attentive driving. The driver is always responsible for the safe
|
||||
operation of the vehicle and must be ready to take control at any time. The driver can override
|
||||
openpilot at any time by braking, accelerating, or steering the vehicle. openpilot may
|
||||
disengage without warning. Read the [limitations](https://github.com/commaai/openpilot/blob/master/docs/LIMITATIONS.md) carefully before using openpilot.
|
||||
|
||||
To assist the driver in maintaining alertness, openpilot includes a driver monitoring feature
|
||||
that alerts when it detects driver distraction.
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ jetlink: run openpilot's large driving models on an attached Jetson.
|
||||
# so does the release check (macos/scripts/check-version.sh). The comma runs
|
||||
# jetlink from a checkout, not an install, so this is also all it has. The Swift
|
||||
# reads it from Pinned.swift: run JetlinkKit/Scripts/make_pins.py after a bump.
|
||||
__version__ = '0.9.0'
|
||||
__version__ = '0.8.12'
|
||||
|
||||
@@ -228,9 +228,8 @@ class JetlinkClient:
|
||||
"""Introduce this client. The server starts its session over on a hello,
|
||||
so this is also how a new owner of the gadget takes over one the server
|
||||
never saw end; see Session.greet in JetlinkServer. `pick` is the comma's
|
||||
own big-model pick with its age and its date by the comma's clock, {ref,
|
||||
sha256, nbytes, name, age_s, at}; the answer's `pick` is the newer of it
|
||||
and the server's (PickStore)."""
|
||||
own big-model pick and its age, {ref, sha256, nbytes, name, age_s}; the
|
||||
answer's `pick` is the newer of it and the server's (PickStore)."""
|
||||
seq = self._next_seq()
|
||||
client = {'nonce': self.nonce, 'name': self.name}
|
||||
if pick is not None:
|
||||
|
||||
@@ -40,7 +40,7 @@ from jetlink.openpilot.interface import MODES, STATES, Keys, ModelFace, Openpilo
|
||||
from jetlink.openpilot.parts import Parts, for_this_process
|
||||
from jetlink.openpilot.status import Status
|
||||
|
||||
API = 4
|
||||
API = 3
|
||||
|
||||
__all__ = ['API', 'MODES', 'STATES', 'Jetlink', 'Keys', 'ModelFace', 'Openpilot', 'OwnerConfig', 'Status', 'bind',
|
||||
'conformance', 'SHUTDOWN_TIMEOUT', 'Flags', 'SwapEvents']
|
||||
@@ -302,34 +302,13 @@ class Jetlink:
|
||||
self._shutdown_asked = time.monotonic()
|
||||
return True
|
||||
|
||||
def lists_big_models(self) -> bool:
|
||||
"""Is the model manager's big-model list jetlink's (big_catalog) rather
|
||||
than sunnypilot's? With no chestnut fitted. Hardware, not the link
|
||||
setting: the model manager drops a pick its catalog does not list."""
|
||||
def should_extend_catalog(self) -> bool:
|
||||
"""Should the model manager's big-model catalog carry the newer catalogs'
|
||||
models? With no chestnut fitted. Hardware, not the link setting: the model
|
||||
manager drops a pick its catalog does not list."""
|
||||
return not self._parts.chestnut_fitted()
|
||||
|
||||
def big_catalog(self, selector: int) -> dict | None:
|
||||
"""The model index as the model manager's big-model catalog, every bundle
|
||||
at `selector` (its REQUIRED_JSON_VERSION), fetched again
|
||||
(Models.big_catalog). None when it cannot be had, offline say, and the
|
||||
model manager keeps the list it has."""
|
||||
from jetlink.registry.index import RegistryError
|
||||
try:
|
||||
return self._parts.models.big_catalog(selector)
|
||||
except RegistryError as e:
|
||||
self._log.warning("jetlink: could not fetch the model index: %s", e)
|
||||
return None
|
||||
|
||||
def listed_ref(self, ref: str) -> str | None:
|
||||
"""The ref big_catalog() lists a pick's model under, for a pick a new list
|
||||
no longer names: a recompile is listed once, under its newest ref. None
|
||||
while the list is not jetlink's (lists_big_models)."""
|
||||
return self._parts.models.listed_ref(ref) if self.lists_big_models() else None
|
||||
|
||||
# API 3's, until the next minor release: the catalog is left as fetched.
|
||||
# Its picks still drive, since the index knows every ref it ever saw
|
||||
def should_extend_catalog(self) -> bool:
|
||||
return False
|
||||
|
||||
def extend_catalog(self, catalog: dict) -> dict:
|
||||
return catalog
|
||||
"""The big-model catalog the model manager fetched, with the models newer
|
||||
catalogs list folded in. Never raises."""
|
||||
return self._parts.models.big_catalog(catalog)
|
||||
|
||||
@@ -11,10 +11,9 @@ manager, hardwared, the UI and the model manager call these on every device,
|
||||
link on or off, and modeld on every drive: whatever jetlink does wrong turns
|
||||
the link off and is logged, and never takes one of them down. Each hook has
|
||||
the answer it gets with the link off, and gives it on any failure. They are
|
||||
not all False: attach() keeps modeld's model, big_catalog() is None so the
|
||||
model manager keeps its own list, and shutdown_step() says yes to a power-off
|
||||
that is wanted, since a broken jetlink must never keep a comma on to drain the
|
||||
car's battery. selfdrived's
|
||||
not all False: attach() keeps modeld's model, extend_catalog() the catalog,
|
||||
and shutdown_step() says yes to a power-off that is wanted, since a broken
|
||||
jetlink must never keep a comma on to drain the car's battery. selfdrived's
|
||||
swap events (swap_events()) answer no event at all.
|
||||
|
||||
The fork's guard, which builds Hooks or else Off inside a function, and its
|
||||
@@ -40,7 +39,7 @@ from jetlink.openpilot import API, Flags, Jetlink, SwapEvents, bind
|
||||
|
||||
# the APIs a fork's adapter may be written to: today's, and after a bump the
|
||||
# previous one too, for one minor release (jetlink/openpilot/__init__.py)
|
||||
COMPATIBLE = frozenset({API, 3})
|
||||
COMPATIBLE = frozenset({API})
|
||||
|
||||
# the hooks that ask jetlink with link_on() saying off. status(): the UI shows
|
||||
# the big model's name from the model library. shutdown_step(): hardwared
|
||||
@@ -48,7 +47,7 @@ COMPATIBLE = frozenset({API, 3})
|
||||
# and its wait, for a link turned off while one is pending. The model
|
||||
# manager's: the catalog is hardware and the library, not the setting
|
||||
BINDS_WITH_LINK_OFF = frozenset({'status', 'shutdown_step', 'request_shutdown', 'shutdown_pending', 'model_state',
|
||||
'lists_big_models', 'big_catalog', 'listed_ref', 'should_extend_catalog', 'extend_catalog'})
|
||||
'should_extend_catalog', 'extend_catalog'})
|
||||
|
||||
# what selfdrived is told when the swap events cannot run: none at all
|
||||
NO_EVENTS = Flags(False, False, False, False, False)
|
||||
@@ -157,17 +156,6 @@ class _Hooks:
|
||||
def model_state(self, ref: str) -> str | None:
|
||||
return self._call('model_state', None, ref)
|
||||
|
||||
def lists_big_models(self) -> bool:
|
||||
return self._call('lists_big_models', False)
|
||||
|
||||
def big_catalog(self, selector: int) -> dict | None:
|
||||
"""None with the link off or on any failure: the model manager keeps its list."""
|
||||
return self._call('big_catalog', None, selector)
|
||||
|
||||
def listed_ref(self, ref: str) -> str | None:
|
||||
return self._call('listed_ref', None, ref)
|
||||
|
||||
# API 3's, until the next minor release
|
||||
def should_extend_catalog(self) -> bool:
|
||||
return self._call('should_extend_catalog', False)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class Keys:
|
||||
offroad: str # BOOL: is the car parked (manager's IsOffroad)
|
||||
progress: str # JSON {stage, frac, msg, drops}: provisioning and join progress
|
||||
spec: str # JSON: the built model's spec and whether its engine is built
|
||||
pointers: str # JSON: catalog ref, or a hash-named pick's sha256 -> {oid, size}
|
||||
# sunnypilot's model manager: the big-model pick, JSON {ref, displayName},
|
||||
# and the big-model catalog, JSON {bundles}. Without one both are None, and
|
||||
# jetlink runs its own default big model, or the one picked on the server.
|
||||
@@ -61,8 +62,6 @@ class Keys:
|
||||
# the server is written there, so the comma's own list shows it; without
|
||||
# the key it drives all the same
|
||||
select: str | None = None
|
||||
# API 3's kept pointers, unread since the model index: until the next minor release
|
||||
pointers: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -98,6 +97,7 @@ class StatusSide(Protocol):
|
||||
"""Every reader: manager, the UI, hardwared, the model manager."""
|
||||
keys: Keys
|
||||
log: Any # logging.Logger-like (cloudlog): debug, info, warning, error, exception
|
||||
catalog_selector: int # the model manager's REQUIRED_JSON_VERSION; 0 without a model manager
|
||||
|
||||
def params_dir(self) -> Path:
|
||||
"""The params store's directory, by params.cc's rule, worked out on every
|
||||
|
||||
@@ -116,7 +116,7 @@ LAGS = (BEHIND, BAD_OUTPUT)
|
||||
RECONNECTING = {LOST: ('reconnecting', 'lost'), BEHIND: ('fell behind, reconnecting', 'behind'),
|
||||
NOT_UP: ('load failed, reconnecting', 'load_failed'),
|
||||
BAD_OUTPUT: ('bad output, reconnecting', 'bad_output')}
|
||||
# how long a model name the comma's index did not have stays the sha's
|
||||
# how long a model name the catalog had not resolved stays the sha's
|
||||
NAME_RETRY = 30.0
|
||||
|
||||
|
||||
@@ -694,9 +694,8 @@ class JoiningModelState:
|
||||
'lags': self._lags}
|
||||
|
||||
def _name_of(self, sha: str, names: dict) -> str:
|
||||
"""The index's name, kept once it is one. The sha's stand-in (a model the
|
||||
comma's index does not list yet, or a lookup that failed) is asked again
|
||||
after NAME_RETRY."""
|
||||
"""The catalog's name, kept once it is one. The sha's stand-in (a row not
|
||||
resolved yet, or a lookup that failed) is asked again after NAME_RETRY."""
|
||||
name, retry_at = names.get(sha, (None, 0.0))
|
||||
if time.monotonic() >= retry_at:
|
||||
try:
|
||||
|
||||
@@ -225,16 +225,18 @@ def connect_patiently(link: Link):
|
||||
# -- making the Jetson ready ------------------------------------------------------
|
||||
|
||||
def identity(parts, entry: dict) -> tuple[str, int]:
|
||||
"""The picked model's sha256 and byte count, from the model index, so the
|
||||
comma can name the model without holding or hashing the ONNX. A row has
|
||||
them already where the comma's copy of the index knows the model; one it
|
||||
does not fetches the index again, the one part of provisioning that may
|
||||
need the internet."""
|
||||
"""The picked model's sha256 and byte count, from the catalog model's LFS
|
||||
pointer, so the comma can name the model without holding or hashing the
|
||||
ONNX. Looked up once per model ever and kept in a param: the one part of
|
||||
provisioning that needs the internet. A model picked by its hash alone is
|
||||
only sized."""
|
||||
sha256, nbytes = entry.get('oid'), entry.get('size')
|
||||
if sha256 and nbytes:
|
||||
return sha256, int(nbytes)
|
||||
parts.progress.report('connect', 0.0, 'finding model', code='finding_model')
|
||||
return parts.models.resolve(entry.get('ref'), sha256)
|
||||
if entry.get('ref'):
|
||||
return parts.models.resolve_pointer(entry['ref'])
|
||||
return parts.models.resolve_size(sha256)
|
||||
|
||||
|
||||
# What has already been hashed in this process, keyed on the file as it was
|
||||
@@ -324,7 +326,8 @@ def stand_in(parts, hello: dict, pick: str, built) -> tuple[str, int] | None:
|
||||
if pick in have:
|
||||
return None
|
||||
if loaded:
|
||||
# its size names it to the server: the record's, else the index's
|
||||
# its size names it to the server, and only a catalog model resolved
|
||||
# here, or the record, has one
|
||||
nbytes = built.nbytes if built is not None and built.sha256 == loaded else parts.models.size_for(loaded)
|
||||
if nbytes:
|
||||
return loaded, nbytes
|
||||
@@ -354,8 +357,7 @@ def open_link(parts, link: Link, should_stop=None):
|
||||
# booting; both resolve on their own
|
||||
client = connect_patiently(link)
|
||||
try:
|
||||
sent = parts.models.hello_pick()
|
||||
hello = parts.models.heard(client.hello(timeout=10.0, pick=sent), sent)
|
||||
hello = parts.models.heard(client.hello(timeout=10.0, pick=parts.models.hello_pick()))
|
||||
parts.log.warning("jetlink: %s trt %s, engine %s, loaded %s",
|
||||
hello.get('device'), hello.get('trt_version'),
|
||||
hello.get('engine_state'), str(hello.get('loaded'))[:16])
|
||||
|
||||
@@ -7,48 +7,42 @@ See the LICENSE file in the root directory for more details.
|
||||
Which large model runs, and where its bytes are.
|
||||
|
||||
The large model is the model manager's big-model pick, the same slot a
|
||||
chestnut runs from (Keys.big_model). It names a comma commit, and what the
|
||||
Jetson runs is the ONNX that commit was built from: the model index
|
||||
(jetlink.registry.index) says which, for every big-model commit, and lists
|
||||
what can be picked. The comma keeps the last index that parsed under
|
||||
model_dir(), so it names, sizes and lists its models with no network; a pick
|
||||
the copy does not know fetches it again (resolve).
|
||||
|
||||
Without a chestnut fitted the model manager's big-model list is the index's
|
||||
(big_catalog), so what the comma lists is what the Jetson runs. With one, the
|
||||
list is sunnypilot's own, and jetlink stays out of it.
|
||||
chestnut runs from (Keys.big_model). Its bundles are tinygrad pkls for a GPU
|
||||
the Jetson does not have, but each one names the comma commit it was compiled
|
||||
from, and that commit's ONNX in comma's LFS is what the Jetson runs. The
|
||||
catalog says what exists, the slot says which one, the pointer at the commit
|
||||
says which bytes. A commit that ships a precompiled pkl instead names its
|
||||
export, and jetlink's registry follows that to comma's model repo; see
|
||||
jetlink.registry.lfs.
|
||||
|
||||
Without a model manager (Keys.big_model and Keys.catalog None) there is no
|
||||
pick and no catalog, and the large model is the one picked on the server, else
|
||||
jetlink's own default, named by its ref.
|
||||
jetlink's own default, named by its ref: its pointer resolves from the ref as
|
||||
any pick's does.
|
||||
|
||||
A fork with a picker of its own but no catalog jetlink reads (Keys.big_model
|
||||
without Keys.catalog, StarPilot's) may name its pick by the ONNX's sha256
|
||||
instead of a ref, which is all its catalog knows of a model; the index sizes
|
||||
it. With no catalog no model manager owns the slot either, so jetlink writes a
|
||||
newer pick from the server into it itself, and the fork's picker shows what
|
||||
drives.
|
||||
instead of a ref, which is all its catalog knows of a model. Its size is looked
|
||||
up from comma's LFS servers once (resolve_size). With no catalog no model
|
||||
manager owns the slot either, so jetlink writes a newer pick from the server
|
||||
into it itself, and the fork's picker shows what drives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from jetlink.registry.index import (DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF, ModelIndex, NetworkError, Pointer, RegistryError,
|
||||
is_ref, is_sha256, parse_index)
|
||||
from jetlink.registry.catalog import DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF, is_ref, is_sha256
|
||||
|
||||
POINTER_TIMEOUT = 10.0
|
||||
_REF = re.compile(r'[0-9a-f]{40}')
|
||||
# the build date a catalog name ends in, " (September 17, 2026)"; any other parenthesis stays
|
||||
_TRAILING_DATE = re.compile(r' \([A-Za-z]+ \d{1,2}, \d{4}\)$')
|
||||
# the index and the slot are a file and a JSON param, and the UI names the
|
||||
# active model every frame; the status line can lag a new pick by this long
|
||||
# the index and the slot are JSON params, and the UI names the active model
|
||||
# every frame; the status line can lag a new pick by this long
|
||||
INDEX_TTL = 2.0
|
||||
# the comma's copy of the model index, under model_dir()
|
||||
INDEX_FILE = 'index.json'
|
||||
# a pick the comma's copy does not know fetches the index again, at most this often
|
||||
INDEX_MISS_RETRY = 60.0
|
||||
# A big model's download over a car's connection: the waits between the
|
||||
# attempts of one provisioning run, one more attempt than waits. Each carries
|
||||
# on from the .part the last left, so a stall costs the wait, not the bytes
|
||||
@@ -56,11 +50,6 @@ INDEX_MISS_RETRY = 60.0
|
||||
DOWNLOAD_RETRY_DELAYS = (5.0, 15.0, 30.0, 60.0, 120.0)
|
||||
# server_pick() before this process has heard a hello
|
||||
_UNHEARD = object()
|
||||
# heard() told nothing of the hello's own pick: an older caller
|
||||
_UNTOLD = object()
|
||||
# the slot as jetlink saw it last, under model_dir(): to tell a slot the user
|
||||
# emptied (the default, chosen) from one never filled
|
||||
SLOT_STATE = 'slot.json'
|
||||
|
||||
|
||||
def _dated(pick) -> dict | None:
|
||||
@@ -79,99 +68,91 @@ def _dated(pick) -> dict | None:
|
||||
class Models:
|
||||
def __init__(self, op):
|
||||
self.op = op
|
||||
# (looked at, the file's (mtime, size), the index it held and its rows)
|
||||
self._index_cache: tuple[float, tuple | None, ModelIndex | None, list[dict]] | None = None
|
||||
self._refresh_after = 0.0
|
||||
self._index_cache: tuple[float, list[dict], dict[str, dict]] | None = None
|
||||
self._slot_cache: tuple[float, dict | None, float | None] | None = None
|
||||
# SLOT_STATE as this process last read or wrote it
|
||||
self._slot_state: dict | None = None
|
||||
# the pick of the last hello this process heard, and the owner's record
|
||||
# of the last hello any process heard
|
||||
self._heard: dict | None | object = _UNHEARD
|
||||
self._record_cache: tuple[float, dict | None] | None = None
|
||||
|
||||
# -- the model index --------------------------------------------------------
|
||||
def catalog(self) -> list[dict]:
|
||||
"""sunnypilot's big-model bundles as {name, ref}, newest first, as the model
|
||||
manager's own picker lists them. From the cached JSON rather than the parsed
|
||||
bundles: parsing builds capnp objects and writes chunk manifests, for two
|
||||
fields. Never raises."""
|
||||
try:
|
||||
key = self.op.keys.catalog
|
||||
bundles = ((self.op.get(key) if key else None) or {}).get('bundles', [])
|
||||
found = [b for b in bundles if _REF.fullmatch(str(b.get('ref')))
|
||||
and int(b.get('minimum_selector_version', 0)) == self.op.catalog_selector]
|
||||
except Exception:
|
||||
self.op.log.exception("jetlink: could not read the big-model catalog")
|
||||
return []
|
||||
found.sort(key=lambda b: int(b.get('index', 0)), reverse=True)
|
||||
return [{'name': str(b.get('display_name') or b['ref'][:10]), 'ref': b['ref']} for b in found]
|
||||
|
||||
def index_path(self) -> Path:
|
||||
return self.model_dir() / INDEX_FILE
|
||||
def pointers(self) -> dict[str, dict]:
|
||||
value = self.op.get(self.op.keys.pointers)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def index(self) -> ModelIndex | None:
|
||||
"""The comma's copy of the model index, None before the first fetch.
|
||||
Looked at once per INDEX_TTL and parsed again only when the file
|
||||
changed. Never raises."""
|
||||
return self._read_index()[0]
|
||||
def fetch_pointer(self, ref: str) -> tuple[str, int]:
|
||||
"""The oid and size of the ONNX a comma commit names, in its tree or, for a
|
||||
precompiled-pkl commit, in comma's model repo. The Jetson's registry does
|
||||
the same lookup, so the two ends agree on every model's identity."""
|
||||
from jetlink.registry.lfs import fetch_pointer as registry_fetch_pointer
|
||||
pointer = registry_fetch_pointer(ref, timeout=POINTER_TIMEOUT)
|
||||
return pointer.oid, pointer.size
|
||||
|
||||
def resolve_pointer(self, ref: str) -> tuple[str, int]:
|
||||
"""The oid and size behind a catalog model, fetched the first time and kept for good."""
|
||||
return self._resolve(ref, self.fetch_pointer)
|
||||
|
||||
def fetch_size(self, sha256: str) -> tuple[str, int]:
|
||||
"""The ONNX with this hash and its byte count, from the LFS servers a
|
||||
download asks."""
|
||||
from jetlink.registry.lfs import fetch_size as registry_fetch_size
|
||||
pointer = registry_fetch_size(sha256, self.lfs_endpoints(), timeout=POINTER_TIMEOUT)
|
||||
return pointer.oid, pointer.size
|
||||
|
||||
def resolve_size(self, sha256: str) -> tuple[str, int]:
|
||||
"""A model picked by its hash, sized the first time and kept for good with
|
||||
the catalog's pointers, under the hash."""
|
||||
return self._resolve(sha256, self.fetch_size)
|
||||
|
||||
def _resolve(self, key: str, fetch) -> tuple[str, int]:
|
||||
known = self.pointers()
|
||||
if key in known:
|
||||
return known[key]['oid'], int(known[key]['size'])
|
||||
oid, size = fetch(key)
|
||||
known[key] = {'oid': oid, 'size': size}
|
||||
self._keep_pointers(known)
|
||||
self.op.log.warning("jetlink: %s is %s, %d MB", key[:10], oid[:16], size >> 20)
|
||||
return oid, size
|
||||
|
||||
def _keep_pointers(self, known: dict[str, dict]) -> None:
|
||||
# blocking: the next lookup reads this back, and a put still in flight would be lost under it
|
||||
self.op.put(self.op.keys.pointers, known, block=True)
|
||||
self._index_cache = None
|
||||
|
||||
@staticmethod
|
||||
def _row(name: str, ref: str | None, known: dict[str, dict], sha256: str | None = None) -> dict:
|
||||
"""A model as a catalog row, by its ref's pointer, else by its hash, sized
|
||||
once resolve_size has looked it up."""
|
||||
p = known.get(ref) or known.get(sha256) or {}
|
||||
return {'name': name, 'ref': ref, 'oid': p.get('oid') or sha256, 'size': int(p['size']) if p.get('size') else None}
|
||||
|
||||
def _index(self) -> tuple[list[dict], dict[str, dict]]:
|
||||
"""The catalog's rows and the pointers behind them, read together once per INDEX_TTL."""
|
||||
now = time.monotonic()
|
||||
if self._index_cache is None or now - self._index_cache[0] >= INDEX_TTL:
|
||||
known = self.pointers()
|
||||
self._index_cache = (now, [self._row(b['name'], b['ref'], known) for b in self.catalog()], known)
|
||||
return self._index_cache[1], self._index_cache[2]
|
||||
|
||||
def model_index(self) -> list[dict]:
|
||||
"""Every model the index lists, {name, ref, oid, size}, in its order. No network."""
|
||||
return self._read_index()[1]
|
||||
|
||||
def _read_index(self) -> tuple[ModelIndex | None, list[dict]]:
|
||||
now = time.monotonic()
|
||||
cache = self._index_cache
|
||||
if cache is not None and now - cache[0] < INDEX_TTL:
|
||||
return cache[2], cache[3]
|
||||
path = self.index_path()
|
||||
try:
|
||||
st = path.stat()
|
||||
seen = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
seen = None
|
||||
if cache is not None and cache[1] == seen:
|
||||
# the same file, parsed or not: a copy that does not parse is told of once
|
||||
index = cache[2]
|
||||
else:
|
||||
index = None
|
||||
if seen is not None:
|
||||
try:
|
||||
index = parse_index(json.loads(path.read_text()))
|
||||
except (OSError, ValueError, RegistryError):
|
||||
self.op.log.exception("jetlink: the comma's model index does not parse")
|
||||
self._keep_index(now, seen, index)
|
||||
return self._index_cache[2], self._index_cache[3]
|
||||
|
||||
def _keep_index(self, now: float, seen: tuple | None, index: ModelIndex | None) -> None:
|
||||
rows = self._index_cache[3] if self._index_cache is not None and self._index_cache[2] is index else \
|
||||
[{'name': m.name, 'ref': m.ref, 'oid': m.oid, 'size': m.size} for m in index.models] if index else []
|
||||
self._index_cache = (now, seen, index, rows)
|
||||
|
||||
def refresh_index(self) -> ModelIndex:
|
||||
"""The model index, fetched and kept as the comma's copy, which is left
|
||||
alone when nothing changed so no reader parses it again. Raises a
|
||||
RegistryError for one that cannot be had or used, and keeps the copy."""
|
||||
from jetlink.comma import gadget
|
||||
from jetlink.registry.index import fetch_index
|
||||
data, index = fetch_index()
|
||||
path = self.index_path()
|
||||
if gadget.read_record(path) != data:
|
||||
gadget.write_record(path, data)
|
||||
st = path.stat()
|
||||
self._keep_index(time.monotonic(), (st.st_mtime_ns, st.st_size), index)
|
||||
return index
|
||||
|
||||
def _row(self, name: str, ref: str | None, sha256: str | None = None) -> dict:
|
||||
"""A model as a row: its ONNX by its ref, from the index, else by its hash
|
||||
and sized from the index. oid or size is None where the index does not know."""
|
||||
index = self.index()
|
||||
pointer = index.pointer(ref) if index is not None and ref else None
|
||||
if pointer is not None:
|
||||
return {'name': name, 'ref': ref, 'oid': pointer.oid, 'size': pointer.size}
|
||||
return {'name': name, 'ref': ref, 'oid': sha256, 'size': index.size(sha256) if index is not None and sha256 else None}
|
||||
|
||||
def resolve(self, ref: str | None, sha256: str | None) -> tuple[str, int]:
|
||||
"""A pick's oid and size, from the index; one the comma's copy does not
|
||||
know fetches it again, at most once per INDEX_MISS_RETRY. Raises a
|
||||
RegistryError for a model the index does not know, or no index at all."""
|
||||
if (found := self._identity(ref, sha256)) is None and time.monotonic() >= self._refresh_after:
|
||||
self._refresh_after = time.monotonic() + INDEX_MISS_RETRY
|
||||
self.refresh_index()
|
||||
found = self._identity(ref, sha256)
|
||||
if found is None:
|
||||
raise RegistryError(f"the model index does not know {(ref or sha256 or 'the model')[:16]}")
|
||||
return found
|
||||
|
||||
def _identity(self, ref: str | None, sha256: str | None) -> tuple[str, int] | None:
|
||||
row = self._row('', ref, sha256)
|
||||
return (row['oid'], int(row['size'])) if row['oid'] and row['size'] else None
|
||||
"""Every catalog model, {name, ref, oid, size}. oid and size are None until
|
||||
the model has been selected and resolved. No network."""
|
||||
return self._index()[0]
|
||||
|
||||
def selected_slot(self) -> dict | None:
|
||||
"""The big-model slot's pick as {name, ref, sha256}, from the raw param
|
||||
@@ -194,59 +175,18 @@ class Models:
|
||||
at = os.stat(Path(self.op.params_dir()) / key).st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
self._note_slot(pick)
|
||||
self._slot_cache = (now, pick, at)
|
||||
return pick
|
||||
|
||||
def _note_slot(self, pick: dict | None) -> None:
|
||||
"""Keep SLOT_STATE in step with the slot: what is in it, and when it was
|
||||
emptied. A pick taken out is the default chosen (the model manager's
|
||||
Default, here or on sunnylink), a pick like any other, which the hello
|
||||
names and a server's older pick must not undo. Read again only when the
|
||||
slot no longer matches what this process knows, so per process it costs
|
||||
a file read at a change. Never raises."""
|
||||
last = {'ref': pick['ref'], 'sha256': pick['sha256']} if pick else None
|
||||
state = self._slot_state
|
||||
if state is not None and state.get('last') == last:
|
||||
return
|
||||
path = self.model_dir() / SLOT_STATE
|
||||
try:
|
||||
state = json.loads(path.read_text())
|
||||
state = state if isinstance(state, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
state = {}
|
||||
if state and state.get('last') == last:
|
||||
self._slot_state = state
|
||||
return
|
||||
if last is None and not state:
|
||||
# empty with nothing known before it: never picked, as far as we know
|
||||
self._slot_state = {'last': None, 'cleared_at': None}
|
||||
return
|
||||
state = {'last': last, 'cleared_at': time.time() if last is None else None}
|
||||
self._slot_state = state
|
||||
from jetlink.comma import gadget
|
||||
try:
|
||||
gadget.write_record(path, state)
|
||||
except OSError:
|
||||
self.op.log.exception("jetlink: could not keep the big-model slot's state")
|
||||
|
||||
def default_chosen_at(self) -> float | None:
|
||||
"""When the slot was emptied by this clock, the default chosen; None while
|
||||
it holds a pick, or was never filled."""
|
||||
if self.selected_slot() is not None or self._slot_state is None:
|
||||
return None
|
||||
at = self._slot_state.get('cleared_at')
|
||||
return float(at) if isinstance(at, (int, float)) else None
|
||||
|
||||
def default_model(self) -> dict | None:
|
||||
"""What runs with no pick: the index's default, else the first it lists.
|
||||
Not the chestnut's default, which is the model in the tree. Before the
|
||||
comma has an index, jetlink's own by its ref, which a provisioning run
|
||||
resolves by fetching one."""
|
||||
if (index := self.index()) is None:
|
||||
return self._row(DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF)
|
||||
"""What runs with no pick: jetlink's default big model, else the newest the
|
||||
catalog lists. Not the chestnut's default, which is the model in the tree.
|
||||
A fork with no catalog at all (Keys.catalog None) gets jetlink's default by
|
||||
its ref; one whose catalog has not been fetched yet waits for it."""
|
||||
if self.op.keys.catalog is None:
|
||||
return self._row(DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF, self._index()[1])
|
||||
models = self.model_index()
|
||||
return next((m for m in models if m['ref'] == index.default), models[0] if models else None)
|
||||
return next((m for m in models if m['ref'] == DEFAULT_BIG_MODEL_REF), models[0] if models else None)
|
||||
|
||||
def default_model_name(self) -> str | None:
|
||||
"""default_model()'s name without the catalog's trailing build date, the
|
||||
@@ -257,40 +197,26 @@ class Models:
|
||||
|
||||
def selected_model(self) -> dict | None:
|
||||
"""The model picked last, here or on the server: the slot's pick, listed
|
||||
or not (the index knows every ref it ever saw), unless the server's last
|
||||
hello named a newer one. With neither, default_model()."""
|
||||
in the catalog or not (a ref is enough to find its pointer), unless the
|
||||
server's last hello named a newer one. With neither, default_model()."""
|
||||
if (theirs := self._newer_server_pick()) is not None:
|
||||
return self._server_row(theirs)
|
||||
if (own := self.selected_slot()) is not None:
|
||||
return self._own_row(own)
|
||||
return self.default_model()
|
||||
|
||||
def _own(self) -> tuple[dict | None, float | None]:
|
||||
"""Our pick as a row, and when it was made by this clock: the slot's, or
|
||||
the default when it was chosen by emptying the slot. (None, None) with
|
||||
neither."""
|
||||
if (own := self.selected_slot()) is not None:
|
||||
return self._own_row(own), self.picked_at()
|
||||
if (at := self.default_chosen_at()) is not None and (default := self.default_model()) is not None:
|
||||
return default, at
|
||||
return None, None
|
||||
|
||||
def _own_row(self, own: dict) -> dict:
|
||||
return self._row(own['name'], own['ref'], own['sha256'])
|
||||
return self._row(own['name'], own['ref'], self._index()[1], own['sha256'])
|
||||
|
||||
# -- picks on both ends -----------------------------------------------------
|
||||
# A pick is made on either end: in the comma's list (the default included,
|
||||
# chosen by emptying the slot), or on the server (its web page, an app's Use
|
||||
# Model). Each hello carries the comma's own pick, when it was made by this
|
||||
# clock and how long ago, and the server answers with the newer of that and
|
||||
# its own (PickStore in JetlinkServer), which it settles by what the comma
|
||||
# named before rather than by its clock where it can: a Jetson's clock
|
||||
# starts again behind at every power-on. The answer is kept with the rest of
|
||||
# the hello (gadget.SERVER, through the owner) along with the pick the hello
|
||||
# carried, and selected_model() takes the server's when that is still our
|
||||
# pick: the server chose knowing it. So a model picked on the server drives
|
||||
# without being picked here too, and one picked here goes to the server at
|
||||
# the next hello.
|
||||
# A pick is made on either end: in the comma's list, or on the server (its
|
||||
# web page, an app's Use Model). Each hello carries the comma's own pick and
|
||||
# how long ago it was made, and the server answers with the newer of that
|
||||
# and its own (PickStore in JetlinkServer). The answer is kept with the rest
|
||||
# of the hello (gadget.SERVER, through the owner), dated by this clock, and
|
||||
# selected_model() takes whichever pick is newer; so a model picked on the
|
||||
# server drives without being picked here too, and one picked here goes to
|
||||
# the server at the next hello.
|
||||
|
||||
def picked_at(self) -> float | None:
|
||||
"""When the slot was last written, by this clock: when the pick was made.
|
||||
@@ -298,24 +224,21 @@ class Models:
|
||||
return self._slot_cache[2] if self.selected_slot() is not None and self._slot_cache else None
|
||||
|
||||
def hello_pick(self) -> dict | None:
|
||||
"""Our own pick, for the hello: {ref, sha256, nbytes, name, age_s, at},
|
||||
its oid and size where the index knows them (no network here), `at` when it
|
||||
was made by this clock. None with nothing picked here, or no date for it:
|
||||
the server's pick, if any, stands."""
|
||||
row, at = self._own()
|
||||
if row is None or at is None or not (row['ref'] or row['oid']):
|
||||
"""Our own pick, for the hello: {ref, sha256, nbytes, name, age_s}, the
|
||||
pointer where it is resolved already (no network here). None with nothing
|
||||
picked here, or no date for it: the server's pick, if any, stands."""
|
||||
own, at = self.selected_slot(), self.picked_at()
|
||||
if own is None or at is None:
|
||||
return None
|
||||
row = self._own_row(own)
|
||||
return {'ref': row['ref'], 'sha256': row['oid'], 'nbytes': row['size'], 'name': row['name'],
|
||||
'age_s': round(max(0.0, time.time() - at), 1), 'at': round(at, 3)}
|
||||
'age_s': round(max(0.0, time.time() - at), 1)}
|
||||
|
||||
def heard(self, hello: dict, asked=_UNTOLD) -> dict:
|
||||
"""The server's hello with its `pick` dated by this clock (_dated), and
|
||||
`asked`, the hello_pick() the hello carried, kept with it: which is how
|
||||
the owner keeps it (lending.SERVER_FIELDS) and server_pick() reads it.
|
||||
This process goes by it at once, whatever the owner does with it."""
|
||||
def heard(self, hello: dict) -> dict:
|
||||
"""The server's hello with its `pick` dated by this clock (_dated), which
|
||||
is how the owner keeps it (lending.SERVER_FIELDS) and server_pick() reads
|
||||
it. This process goes by it at once, whatever the owner does with it."""
|
||||
self._heard = _dated(hello.get('pick'))
|
||||
if self._heard is not None and asked is not _UNTOLD:
|
||||
self._heard['asked'] = {k: asked.get(k) for k in ('ref', 'sha256', 'at')} if isinstance(asked, dict) else None
|
||||
return {**hello, 'pick': self._heard} if self._heard is not None else hello
|
||||
|
||||
def server_pick(self) -> dict | None:
|
||||
@@ -334,52 +257,49 @@ class Models:
|
||||
|
||||
def _newer_server_pick(self) -> dict | None:
|
||||
"""The server's last pick, when it is another model than ours and was
|
||||
picked after it, or we have none. The server settled that when the hello
|
||||
carried this pick of ours; a pick made here since that hello is newer.
|
||||
An answer kept without the pick it was asked with (an older jetlink) goes
|
||||
by its date."""
|
||||
theirs = self.server_pick()
|
||||
if theirs is None:
|
||||
picked after it, or we have none."""
|
||||
theirs, own = self.server_pick(), self.selected_slot()
|
||||
if theirs is None or (own is not None and (self._same(theirs, self._own_row(own)) or
|
||||
theirs['at'] <= (self.picked_at() or 0.0))):
|
||||
return None
|
||||
own, at = self._own()
|
||||
if own is None:
|
||||
return theirs
|
||||
if self._same(theirs, own):
|
||||
return None
|
||||
if 'asked' in theirs:
|
||||
asked = theirs['asked']
|
||||
fresh = isinstance(asked, dict) and self._same(asked, own) and isinstance(asked.get('at'), (int, float)) \
|
||||
and at is not None and abs(asked['at'] - at) < 1.0
|
||||
return theirs if fresh else None
|
||||
return None if theirs['at'] <= (at or 0.0) else theirs
|
||||
return theirs
|
||||
|
||||
@staticmethod
|
||||
def _same(pick: dict, row: dict) -> bool:
|
||||
"""One model: by the catalog ref where both have one, since two refs can
|
||||
share one export, else by the hash. The server's PickStore.sameModel."""
|
||||
if pick.get('ref') and row['ref']:
|
||||
if pick['ref'] and row['ref']:
|
||||
return pick['ref'] == row['ref']
|
||||
return bool(pick.get('sha256')) and pick['sha256'] == row['oid']
|
||||
return bool(pick['sha256']) and pick['sha256'] == row['oid']
|
||||
|
||||
def _server_row(self, pick: dict) -> dict:
|
||||
"""The server's pick as a row: named as we list it, with the server's oid
|
||||
and size, which name the bytes it has."""
|
||||
"""The server's pick as a catalog row: ours where we list it, with the
|
||||
server's oid and size, which name the bytes it has."""
|
||||
listed = next((m for m in self.model_index() if pick['ref'] and m['ref'] == pick['ref']), None)
|
||||
row = self._row(listed['name'] if listed else pick.get('name') or (pick['ref'] or pick['sha256'])[:16],
|
||||
pick['ref'], pick['sha256'])
|
||||
pick['ref'], self._index()[1], pick['sha256'])
|
||||
if pick['sha256'] and pick.get('nbytes'):
|
||||
row['oid'], row['size'] = pick['sha256'], int(pick['nbytes'])
|
||||
return row
|
||||
|
||||
def adopt(self) -> None:
|
||||
"""Make a newer pick from the server ours. With a catalog, ask the model
|
||||
manager for it (Keys.select), so the comma's own list shows what drives:
|
||||
only a model the catalog lists, which is all the model manager takes; any
|
||||
other drives all the same (selected_model). Without one, no model manager
|
||||
owns the slot, so the pick is written into it, whatever the model. Never
|
||||
raises."""
|
||||
"""Keep the server's pointer for its pick, by ref and by hash, so naming
|
||||
the model here needs no lookup; and make a newer pick from the server
|
||||
ours. With a catalog, ask the model manager for it (Keys.select), so the
|
||||
comma's own list shows what drives: only a model the catalog lists, which
|
||||
is all the model manager takes; any other drives all the same
|
||||
(selected_model). Without one, no model manager owns the slot, so the
|
||||
pick is written into it, whatever the model. Never raises."""
|
||||
try:
|
||||
if (theirs := self.server_pick()) is None or self._newer_server_pick() is None:
|
||||
if (theirs := self.server_pick()) is None:
|
||||
return
|
||||
known = self.pointers()
|
||||
if theirs['sha256'] and theirs.get('nbytes'):
|
||||
pointer = {'oid': theirs['sha256'], 'size': int(theirs['nbytes'])}
|
||||
missing = {k: pointer for k in (theirs['ref'], theirs['sha256']) if k and k not in known}
|
||||
if missing:
|
||||
self._keep_pointers({**known, **missing})
|
||||
if self._newer_server_pick() is None:
|
||||
return
|
||||
model, keys = self._server_row(theirs), self.op.keys
|
||||
if keys.catalog is None:
|
||||
@@ -392,20 +312,13 @@ class Models:
|
||||
# a download the user asked for that the model manager has not taken
|
||||
# yet is left alone: the next hello asks again
|
||||
key = keys.select
|
||||
if key and model['ref'] and self.op.get(key) is None and model['ref'] in self._catalog_refs():
|
||||
if key and model['ref'] and self.op.get(key) is None and any(m['ref'] == model['ref'] for m in self.model_index()):
|
||||
self.op.log.warning("jetlink: %s was picked on the server, picking it here too", model['name'])
|
||||
self.op.put(key, model['ref'])
|
||||
self._slot_cache = None
|
||||
except Exception:
|
||||
self.op.log.exception("jetlink: could not take the server's pick")
|
||||
|
||||
def _catalog_refs(self) -> set[str]:
|
||||
"""The refs the model manager's catalog lists: the index's without a
|
||||
chestnut fitted (big_catalog), else sunnypilot's."""
|
||||
key = self.op.keys.catalog
|
||||
bundles = ((self.op.get(key) if key else None) or {}).get('bundles')
|
||||
return {b['ref'] for b in bundles if isinstance(b, dict) and is_ref(b.get('ref'))} if isinstance(bundles, list) else set()
|
||||
|
||||
def selected_model_name(self) -> str | None:
|
||||
selected = self.selected_model()
|
||||
return selected['name'] if selected else None
|
||||
@@ -459,6 +372,8 @@ class Models:
|
||||
the run for the next one. Each attempt asks the LFS server again:
|
||||
the address it hands out expires. `retrying(error, delay)` hears of each
|
||||
wait; a run that is stopped stops waiting."""
|
||||
from jetlink.registry.catalog import NetworkError
|
||||
from jetlink.registry.lfs import Pointer
|
||||
model = self.selected_model()
|
||||
if model is None or not model['oid']:
|
||||
return None
|
||||
@@ -482,23 +397,27 @@ class Models:
|
||||
time.sleep(min(1.0, end - time.monotonic()))
|
||||
|
||||
def _fetch_once(self, pointer, dest: Path, progress, should_stop) -> Path:
|
||||
from jetlink.registry.catalog import NetworkError
|
||||
from jetlink.registry.lfs import lfs_download, lfs_hrefs
|
||||
for endpoint, href in lfs_hrefs(self.lfs_endpoints(), pointer):
|
||||
self.op.log.warning("jetlink: fetching the large model (%d MB) from %s", pointer.size >> 20, endpoint)
|
||||
return lfs_download(href, pointer, dest, progress=progress, should_stop=should_stop)
|
||||
raise NetworkError(f"no LFS server has {pointer.oid[:16]}")
|
||||
|
||||
def _row_for(self, oid: str) -> dict | None:
|
||||
"""The catalog model with this oid, among those we have resolved."""
|
||||
return next((m for m in self.model_index() if m['oid'] == oid), None)
|
||||
|
||||
def name_for(self, oid: str) -> str:
|
||||
"""The index's name for the model with this oid, or the oid's first 16
|
||||
characters when it lists none."""
|
||||
index = self.index()
|
||||
listed = index.by_oid.get(oid) if index is not None else None
|
||||
return listed.name if listed is not None else oid[:16]
|
||||
"""The catalog's name for the model with this oid, or the oid's first 16
|
||||
characters when it lists none we have resolved."""
|
||||
row = self._row_for(oid)
|
||||
return row['name'] if row is not None else oid[:16]
|
||||
|
||||
def size_for(self, oid: str) -> int | None:
|
||||
"""The byte count of the ONNX with this oid, as the index knows it."""
|
||||
index = self.index()
|
||||
return index.size(oid) if index is not None else None
|
||||
"""The byte count of the catalog model with this oid, once resolved here."""
|
||||
row = self._row_for(oid)
|
||||
return row['size'] if row is not None else None
|
||||
|
||||
def has_file(self, model: dict) -> bool:
|
||||
"""Is this model's ONNX on the comma, whole? Its size is the cheap check."""
|
||||
@@ -511,26 +430,54 @@ class Models:
|
||||
|
||||
# -- the model manager's catalog --------------------------------------------
|
||||
|
||||
def big_catalog(self, selector: int) -> dict:
|
||||
"""The index's models as a catalog in sunnypilot's shape, for the model
|
||||
manager's big-model list when no chestnut is fitted: what the comma lists
|
||||
is then what the Jetson runs, each with the index's fields, in its folder
|
||||
and order. Only `selector` is the fork's (its model manager's
|
||||
REQUIRED_JSON_VERSION, which it parses), and no artifacts: a chestnut
|
||||
has nothing to download, and an accelerator runs the ONNX. The index is
|
||||
fetched again; raises a RegistryError when it cannot be, so the model
|
||||
manager keeps the list it has."""
|
||||
models = self.refresh_index().models
|
||||
return {'bundles': [{'ref': m.ref, 'display_name': m.name, 'short_name': m.short_name or m.ref[:7], 'index': len(models) - i,
|
||||
'is_big': True, 'is_20hz': m.is_20hz, 'generation': m.generation, 'environment': m.environment,
|
||||
'runner': m.runner, 'minimum_selector_version': str(selector), 'build_time': m.build_time,
|
||||
'overrides': {'folder': m.folder}, 'models': []} for i, m in enumerate(models)]}
|
||||
def big_catalog(self, catalog: dict) -> dict:
|
||||
"""The big-model catalog with every newer one sunnypilot has published folded in.
|
||||
A Jetson runs the commit's ONNX, so a model sunnypilot only builds for its next
|
||||
runtime is still one it can run; see jetlink.registry.catalog.fetch_catalogs.
|
||||
Never raises: a probe that fails keeps what the last one found."""
|
||||
try:
|
||||
from jetlink.registry.catalog import fetch_catalogs, merge_catalogs
|
||||
fetched = fetch_catalogs()
|
||||
self.keep_pins(fetched.get('pointers', {}))
|
||||
merged = merge_catalogs([catalog, fetched], selector=self.op.catalog_selector)
|
||||
merged.pop('pointers', None) # the pins are kept above, never in the model manager's catalog
|
||||
added = len(merged.get('bundles', [])) - len(catalog.get('bundles', []))
|
||||
if added <= 0:
|
||||
return catalog
|
||||
self.op.log.warning("jetlink: %d model(s) only newer catalogs list", added)
|
||||
return merged
|
||||
except Exception:
|
||||
self.op.log.exception("jetlink: could not check for newer catalogs")
|
||||
return self._found_before(catalog)
|
||||
|
||||
def listed_ref(self, ref: str) -> str | None:
|
||||
"""The ref the index lists a model under: its own, or the newest one with
|
||||
the same ONNX (a recompile is listed once). None for a model it does not
|
||||
list. No network."""
|
||||
index = self.index()
|
||||
listed = index.listed(ref) if index is not None and is_ref(ref) else None
|
||||
return listed.ref if listed is not None else None
|
||||
def keep_pins(self, pins: dict[str, dict]) -> None:
|
||||
"""Writes the list's pins over the kept pointers (see catalog.pinned_pointers)."""
|
||||
known = self.pointers()
|
||||
changed = {ref: pin for ref, pin in pins.items() if known.get(ref) != pin}
|
||||
if not changed:
|
||||
return
|
||||
known.update(changed)
|
||||
self._keep_pointers(known)
|
||||
for ref, pin in changed.items():
|
||||
self.op.log.warning("jetlink: %s is pinned to %s", ref[:10], pin['oid'][:16])
|
||||
|
||||
def _found_before(self, catalog: dict) -> dict:
|
||||
"""The catalog with the models the last probe found folded in again, from the
|
||||
model manager's cached copy: every entry there the fetched catalog does not
|
||||
list. Not only merge_catalogs' entries with no artifacts: a newer catalog at
|
||||
the same selector version comes through with its builds (v26 lists Cinque
|
||||
Terre V3 that way). Dropped with a probe that failed, a pick only a newer
|
||||
catalog lists would be reset at the manager's next start and the owner would
|
||||
provision the default in its place; a refresh on a flaky network is enough.
|
||||
A model sunnypilot withdrew stays listed until a probe works."""
|
||||
try:
|
||||
listed = {b.get('ref') for b in catalog.get('bundles', [])}
|
||||
key = self.op.keys.catalog
|
||||
cached = ((self.op.get(key) if key else None) or {}).get('bundles', [])
|
||||
kept = [b for b in cached if isinstance(b, dict) and b.get('ref') not in listed]
|
||||
except Exception:
|
||||
return catalog
|
||||
if not kept:
|
||||
return catalog
|
||||
self.op.log.warning("jetlink: keeping %d model(s) the last probe found", len(kept))
|
||||
return {**catalog, 'bundles': [*catalog.get('bundles', []), *kept]}
|
||||
|
||||
@@ -191,8 +191,7 @@ class ProvisioningRun:
|
||||
what it costs, never speaks the protocol to learn it, and keeps the
|
||||
server's pick and whether it downloads for the comma (Models.heard)."""
|
||||
models = self.parts.models
|
||||
sent = models.hello_pick()
|
||||
hello = models.heard(self.client.hello(timeout=10.0, pick=sent), sent)
|
||||
hello = models.heard(self.client.hello(timeout=10.0, pick=models.hello_pick()))
|
||||
if self.loan is not None:
|
||||
self.loan.note_server(hello)
|
||||
return hello
|
||||
|
||||
@@ -433,11 +433,11 @@ def read(parts, mode: str) -> Status:
|
||||
|
||||
|
||||
def model_state(parts, ref: str) -> str | None:
|
||||
"""One listed model, for the picker's list: 'ready' when the Jetson has
|
||||
"""One catalog model, for the picker's list: 'ready' when the Jetson has
|
||||
built it (the spec record), 'downloaded' when the comma has its file, else
|
||||
None. Records and one stat."""
|
||||
None. Records and one stat; a model never resolved has neither."""
|
||||
model = next((m for m in parts.models.model_index() if m['ref'] == ref), None)
|
||||
if model is None:
|
||||
if model is None or not model.get('oid'):
|
||||
return None
|
||||
if parts.spec.engine_ready_for(model['oid']):
|
||||
return 'ready'
|
||||
|
||||
@@ -16,8 +16,8 @@ capnp, swaglog loads zmq, and model_face() imports modeld itself, so it is for
|
||||
modeld alone, where that module is already loaded.
|
||||
|
||||
Data are class attributes, behaviour small methods, each a fork may override;
|
||||
one override point per value: a fork sets KEYS, and the interface's keys
|
||||
reads it. It is jetlink's contract:
|
||||
one override point per value: a fork sets KEYS and CATALOG_SELECTOR, and the
|
||||
interface's keys and catalog_selector read them. Both are jetlink's contract:
|
||||
renaming or removing one, or changing what one means, is a breaking change
|
||||
and bumps API. scripts/upstream (the upstream-paths CI job) checks the paths
|
||||
and literals here against commaai/openpilot master weekly.
|
||||
@@ -57,7 +57,9 @@ class Stock:
|
||||
# the canonical names, which a fork declares in params_keys.h. No model
|
||||
# manager upstream: no big-model slot or catalog, so jetlink runs its default
|
||||
KEYS = Keys(link='JetlinkLink', offroad='IsOffroad', progress='JetlinkProgress', spec='JetlinkSpec',
|
||||
charge_phone='JetlinkChargePhone')
|
||||
pointers='JetlinkModelPointers', charge_phone='JetlinkChargePhone')
|
||||
# the model manager's catalog version; none upstream
|
||||
CATALOG_SELECTOR = 0
|
||||
# comma's chestnut, running and in its ROM (common.hardware.usb's
|
||||
# CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS, which CI holds this to): written
|
||||
# out, as the owner cannot import that package
|
||||
@@ -86,6 +88,10 @@ class Stock:
|
||||
def keys(self) -> Keys:
|
||||
return self.KEYS
|
||||
|
||||
@property
|
||||
def catalog_selector(self) -> int:
|
||||
return self.CATALOG_SELECTOR
|
||||
|
||||
@cached_property
|
||||
def log(self):
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
@@ -192,8 +192,7 @@ _MODEL = object()
|
||||
HOOK_CALLS = {'should_run': [(False, None, None)], 'status': [()], 'reason': [()], 'prepare': [(False,), (True,)],
|
||||
'attach': [(_MODEL, object(), 1928, 1208)], 'handed_over': [(_MODEL,)], 'state': [(_MODEL,)],
|
||||
'shutdown_step': [(False,), (True, 'test')], 'request_shutdown': [('test',)], 'shutdown_pending': [()],
|
||||
'model_state': [('f' * 40,)], 'lists_big_models': [()], 'big_catalog': [(21,)], 'listed_ref': [('f' * 40,)],
|
||||
'should_extend_catalog': [()], 'extend_catalog': [({'bundles': []},)],
|
||||
'model_state': [('f' * 40,)], 'should_extend_catalog': [()], 'extend_catalog': [({'bundles': []},)],
|
||||
'runtime': [()], 'swap_events': [(0.01,)]}
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ Copyright (c) 2026-, Zeph Leggett.
|
||||
This file is part of jetlink and is licensed under the MIT License.
|
||||
See the LICENSE file in the root directory for more details.
|
||||
|
||||
Which large models exist and how to get their bytes: the model index, which
|
||||
names every big model's ONNX (index), and an ONNX by its oid (lfs).
|
||||
Which large models exist and how to get their bytes: sunnypilot's catalog
|
||||
(catalog) and a model's ONNX by its git-lfs pointer (lfs).
|
||||
|
||||
The comma imports both, so this package stays stdlib only and imports nothing
|
||||
itself. The server's side of it is JetlinkRegistry in Swift, held to these
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Copyright (c) 2026-, Zeph Leggett.
|
||||
|
||||
This file is part of jetlink and is licensed under the MIT License.
|
||||
See the LICENSE file in the root directory for more details.
|
||||
|
||||
sunnypilot's big-model catalog: which large models exist and which commit each one is.
|
||||
|
||||
The catalog is the same JSON the comma's model manager caches for its chestnut
|
||||
slot, so a model picked here is a model the comma can ask for. A bundle's
|
||||
artifacts are tinygrad pkls for a GPU we do not have; the only field that
|
||||
matters is `ref`, the comma openpilot commit the bundle was compiled from,
|
||||
because that commit's ONNX is what a jetlink server runs.
|
||||
|
||||
Stdlib only, and every network call takes an `opener` so tests stay offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
CATALOG_URL_TEMPLATE = 'https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v{version}.json'
|
||||
# v26 is v25 plus Cinque Terre V3, at the same selector version. v27 onwards
|
||||
# are selector 20 and a newer tinygrad, for sunnypilot's next sync.
|
||||
CATALOG_VERSION = 26
|
||||
CATALOG_URL = CATALOG_URL_TEMPLATE.format(version=CATALOG_VERSION)
|
||||
# sunnypilot publishes a new catalog version for new models or a new runtime,
|
||||
# and keeps the old ones. The versions after the pinned one are probed up to
|
||||
# the first that is not there, so a model published later is listed without a
|
||||
# release of this package. See merge_catalogs.
|
||||
PROBE_LIMIT = 10
|
||||
# zoompilot's own big models: ones sunnypilot does not list (yet), such as
|
||||
# previews from comma's open pull requests, in the catalog's shape. Merged
|
||||
# after sunnypilot's catalogs, so their entry for the same commit wins. An
|
||||
# edit on main reaches every comma within the hour, with no release.
|
||||
EXTRA_CATALOG_URL = 'https://raw.githubusercontent.com/zoompilot/jetlink/refs/heads/main/catalog/extra_big_models.json'
|
||||
# The selector version the fork requires (REQUIRED_JSON_VERSION on the comma).
|
||||
# It is a string in the JSON; bundles at any other version describe fields we
|
||||
# would misread.
|
||||
REQUIRED_SELECTOR_VERSION = 19
|
||||
# Cinque Terre V3 Model (September 17, 2026)
|
||||
DEFAULT_BIG_MODEL_REF = 'bf3e3631b3f91d92a1020a5e0dd4298b93ff4244'
|
||||
# its name as sunnypilot's catalog lists it, without the build date: what a
|
||||
# comma with no catalog to name it from shows (jetlink.openpilot, on a fork
|
||||
# without sunnypilot's model manager)
|
||||
DEFAULT_BIG_MODEL_NAME = 'Cinque Terre V3 Model'
|
||||
CATALOG_TIMEOUT = 10.0
|
||||
|
||||
_REF = re.compile(r'[0-9a-f]{40}')
|
||||
_SHA256 = re.compile(r'[0-9a-f]{64}')
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
"""Anything the registry refuses to do."""
|
||||
|
||||
|
||||
class NetworkError(RegistryError):
|
||||
"""A server could not be reached or did not answer sensibly."""
|
||||
|
||||
|
||||
class VerifyError(RegistryError):
|
||||
"""Bytes arrived, but not the bytes that were asked for."""
|
||||
|
||||
|
||||
class NotFound(NetworkError):
|
||||
"""The server answered that there is nothing at the URL."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogModel:
|
||||
name: str
|
||||
short_name: str
|
||||
ref: str
|
||||
build_time: str
|
||||
index: int
|
||||
|
||||
|
||||
def is_ref(value: str) -> bool:
|
||||
"""A comma openpilot commit: 40 lowercase hex characters."""
|
||||
return isinstance(value, str) and _REF.fullmatch(value) is not None
|
||||
|
||||
|
||||
def is_sha256(value: str) -> bool:
|
||||
"""A model identity: 64 lowercase hex characters, which is also the LFS oid."""
|
||||
return isinstance(value, str) and _SHA256.fullmatch(value) is not None
|
||||
|
||||
|
||||
def parse_catalog(data: dict) -> list[CatalogModel]:
|
||||
"""The big-model bundles, newest first. No IO, and no bundle can raise.
|
||||
|
||||
A malformed entry in a catalog served to every comma must cost that entry and
|
||||
nothing else, so each bundle is parsed in its own try.
|
||||
"""
|
||||
found: dict[str, CatalogModel] = {}
|
||||
for bundle in _bundles(data):
|
||||
try:
|
||||
ref = bundle.get('ref')
|
||||
if not is_ref(ref) or ref in found:
|
||||
continue
|
||||
if _selector(bundle) != REQUIRED_SELECTOR_VERSION or not bundle.get('is_big'):
|
||||
continue
|
||||
found[ref] = CatalogModel(name=str(bundle.get('display_name') or ref[:10]), short_name=str(bundle.get('short_name') or ''),
|
||||
ref=ref, build_time=str(bundle.get('build_time') or ''), index=int(bundle.get('index', 0)))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
return sorted(found.values(), key=lambda m: m.index, reverse=True)
|
||||
|
||||
|
||||
def http_get(url: str, timeout: float, opener=None, limit: int = -1) -> bytes:
|
||||
"""The body at `url`, at most `limit` bytes. A 404 is NotFound, and every
|
||||
other failure a NetworkError, so a caller only catches what it treats apart."""
|
||||
opener = opener or urllib.request.urlopen
|
||||
try:
|
||||
with opener(url, timeout=timeout) as response:
|
||||
return response.read(limit)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise (NotFound if e.code == 404 else NetworkError)(f"could not fetch {url}: {e}") from e
|
||||
except (OSError, ValueError) as e:
|
||||
raise NetworkError(f"could not fetch {url}: {e}") from e
|
||||
|
||||
|
||||
def http_json(url: str, timeout: float, opener=None, kind: type = dict):
|
||||
"""http_get, parsed, and of the type asked for."""
|
||||
body = http_get(url, timeout, opener)
|
||||
try:
|
||||
data = json.loads(body.decode())
|
||||
except ValueError as e:
|
||||
raise NetworkError(f"{url} did not serve JSON: {e}") from e
|
||||
if not isinstance(data, kind):
|
||||
raise NetworkError(f"{url} did not serve a JSON {kind.__name__}")
|
||||
return data
|
||||
|
||||
|
||||
def fetch_catalog(url: str = CATALOG_URL, timeout: float = CATALOG_TIMEOUT, opener=None) -> dict:
|
||||
"""The catalog JSON. Every failure, transport or content, is a NetworkError."""
|
||||
return http_json(url, timeout, opener)
|
||||
|
||||
|
||||
def fetch_catalogs(opener=None) -> dict:
|
||||
"""The pinned catalog merged with every one sunnypilot has published since,
|
||||
whose commits the server runs too, and then zoompilot's extra models. The
|
||||
probe stops at the first version that is not there, and a missing extra list
|
||||
adds nothing. Any other failure raises: a short list would drop a model
|
||||
found only past it from whatever the caller caches, so the caller keeps its
|
||||
last complete one instead."""
|
||||
found = [fetch_catalog(CATALOG_URL, opener=opener)]
|
||||
for v in range(CATALOG_VERSION + 1, CATALOG_VERSION + 1 + PROBE_LIMIT):
|
||||
try:
|
||||
found.append(fetch_catalog(CATALOG_URL_TEMPLATE.format(version=v), opener=opener))
|
||||
except NotFound:
|
||||
break
|
||||
try:
|
||||
extra = fetch_catalog(EXTRA_CATALOG_URL, opener=opener)
|
||||
except NotFound:
|
||||
extra = {}
|
||||
merged, pins = merge_catalogs([*found, extra]), pinned_pointers(extra)
|
||||
return {**merged, 'pointers': pins} if pins else merged
|
||||
|
||||
|
||||
def pinned_pointers(data: dict) -> dict[str, dict]:
|
||||
"""The extra list's well-formed pins, {ref: {'oid', 'size'}}: the ONNX jetlink
|
||||
runs for that commit whatever a lookup said. See catalog/README.md."""
|
||||
pins = data.get('pointers')
|
||||
if not isinstance(pins, dict):
|
||||
return {}
|
||||
return {ref: {'oid': p['oid'], 'size': p['size']} for ref, p in pins.items()
|
||||
if is_ref(ref) and isinstance(p, dict) and is_sha256(p.get('oid'))
|
||||
and type(p.get('size')) is int and p['size'] > 0}
|
||||
|
||||
|
||||
def merge_catalogs(catalogs: list[dict], selector: int = REQUIRED_SELECTOR_VERSION) -> dict:
|
||||
"""One catalog in the first one's shape, listing every big model of them all.
|
||||
|
||||
A model some catalog lists at `selector` comes through as published, so a
|
||||
chestnut can still fetch its build. One listed only at another selector
|
||||
version, which is where sunnypilot puts every model once it moves runtimes,
|
||||
comes from the newest catalog that has it, retyped to `selector` and with no
|
||||
artifacts: an accelerator runs the commit's ONNX and needs nothing else from
|
||||
the entry, and a chestnut has nothing to download.
|
||||
"""
|
||||
if not catalogs:
|
||||
return {}
|
||||
kept: dict[str, dict] = {}
|
||||
others: dict[str, dict] = {}
|
||||
for data in catalogs:
|
||||
for bundle in _bundles(data):
|
||||
ref = bundle.get('ref')
|
||||
if not is_ref(ref):
|
||||
continue
|
||||
if _selector(bundle) == selector:
|
||||
kept.setdefault(ref, bundle)
|
||||
elif bundle.get('is_big'):
|
||||
others[ref] = bundle # the newest catalog's entry wins
|
||||
extra = [{**b, 'minimum_selector_version': str(selector), 'models': []} for ref, b in others.items() if ref not in kept]
|
||||
return {**catalogs[0], 'bundles': [*kept.values(), *extra]}
|
||||
|
||||
|
||||
def _bundles(data: dict) -> list[dict]:
|
||||
bundles = data.get('bundles') if isinstance(data, dict) else None
|
||||
return [b for b in bundles if isinstance(b, dict)] if isinstance(bundles, list) else []
|
||||
|
||||
|
||||
def _selector(bundle: dict) -> int | None:
|
||||
try:
|
||||
return int(bundle.get('minimum_selector_version', 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2026-, Zeph Leggett.
|
||||
|
||||
This file is part of jetlink and is licensed under the MIT License.
|
||||
See the LICENSE file in the root directory for more details.
|
||||
|
||||
Which big models a Jetson can run, and which ONNX each one is: the model
|
||||
index, zoompilot/jetlink-model-index.
|
||||
|
||||
A big model is a comma openpilot commit (its ref), and what a Jetson runs is
|
||||
the ONNX that commit was built from. Finding that ONNX takes reading comma's
|
||||
trees, its precompiled pkls' weights and its model repo, so the index repo does
|
||||
it once an hour for every commit any catalog, comma master or comma pull
|
||||
request has named, and publishes the answers:
|
||||
|
||||
default the model a Jetson runs with nothing picked
|
||||
refs every big-model commit ever seen -> {oid, size}: an old pick still resolves
|
||||
onnx every ONNX comma has published -> {size}: a pick by hash alone is sized
|
||||
models what to list, one entry per ONNX, in display order, with its folder
|
||||
and the catalog fields a list in sunnypilot's shape needs
|
||||
|
||||
The comma and the server each keep the last index that parsed, so both work
|
||||
offline with what they had, and neither looks anything up itself.
|
||||
|
||||
Stdlib only, and every network call takes an `opener` so tests stay offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
INDEX_URL = 'https://raw.githubusercontent.com/zoompilot/jetlink-model-index/main/index/v1.json'
|
||||
# the index's own version: another one describes fields we would misread
|
||||
SCHEMA = 1
|
||||
INDEX_TIMEOUT = 10.0
|
||||
# a listed model with no folder of its own
|
||||
OTHER_FOLDER = 'Other Models'
|
||||
# the runtime fields of a listed model the index has none for (an index older
|
||||
# than them): what sunnypilot's big models are built for
|
||||
RUNTIME = {'generation': '12', 'environment': 'development', 'runner': 'tinygrad'}
|
||||
# what runs with nothing picked before the comma or server has an index, which
|
||||
# names its own (ModelIndex.default): Cinque Terre V3 Model (September 17, 2026)
|
||||
DEFAULT_BIG_MODEL_REF = 'bf3e3631b3f91d92a1020a5e0dd4298b93ff4244'
|
||||
# its name without the build date: what a comma with no index to name it from
|
||||
# shows (jetlink.openpilot, on a fork without sunnypilot's model manager)
|
||||
DEFAULT_BIG_MODEL_NAME = 'Cinque Terre V3 Model'
|
||||
|
||||
_REF = re.compile(r'[0-9a-f]{40}')
|
||||
_SHA256 = re.compile(r'[0-9a-f]{64}')
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
"""Anything the registry refuses to do."""
|
||||
|
||||
|
||||
class NetworkError(RegistryError):
|
||||
"""A server could not be reached or did not answer sensibly."""
|
||||
|
||||
|
||||
class VerifyError(RegistryError):
|
||||
"""Bytes arrived, but not the bytes that were asked for."""
|
||||
|
||||
|
||||
class IndexInvalid(RegistryError):
|
||||
"""An index that cannot be used: another schema, or nothing listed."""
|
||||
|
||||
|
||||
def is_ref(value) -> bool:
|
||||
"""A comma openpilot commit: 40 lowercase hex characters."""
|
||||
return isinstance(value, str) and _REF.fullmatch(value) is not None
|
||||
|
||||
|
||||
def is_sha256(value) -> bool:
|
||||
"""A model identity: 64 lowercase hex characters, which is also the LFS oid."""
|
||||
return isinstance(value, str) and _SHA256.fullmatch(value) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pointer:
|
||||
oid: str # the ONNX SHA-256
|
||||
size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexModel:
|
||||
ref: str
|
||||
oid: str
|
||||
size: int
|
||||
name: str # as sunnypilot's catalog names it, build date and all
|
||||
short_name: str # '' when the index has none
|
||||
folder: str
|
||||
build_time: str # ISO-8601, or ''
|
||||
pr: int | None # the comma pull request a model listed only as one is
|
||||
is_20hz: bool
|
||||
generation: str # the runtime it was built for, as sunnypilot's catalog says
|
||||
environment: str
|
||||
runner: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelIndex:
|
||||
models: tuple[IndexModel, ...] # in display order
|
||||
refs: dict[str, Pointer] # every ref the index resolves, the listed ones included
|
||||
sizes: dict[str, int] # every ONNX the index knows, by oid
|
||||
default: str | None = None # the listed ref that runs with nothing picked
|
||||
# the listed models by their ONNX: one entry per ONNX, so one each
|
||||
by_oid: dict[str, IndexModel] = field(init=False, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, 'by_oid', {m.oid: m for m in reversed(self.models)})
|
||||
|
||||
def pointer(self, ref: str) -> Pointer | None:
|
||||
return self.refs.get(ref)
|
||||
|
||||
def size(self, oid: str) -> int | None:
|
||||
return self.sizes.get(oid)
|
||||
|
||||
def listed(self, ref: str) -> IndexModel | None:
|
||||
"""The listed model for a ref's ONNX: its own entry, or for a recompile of a
|
||||
listed model, the newest ref's (each ONNX is listed once)."""
|
||||
pointer = self.refs.get(ref)
|
||||
return self.by_oid.get(pointer.oid) if pointer is not None else None
|
||||
|
||||
|
||||
def _pointer(value) -> Pointer | None:
|
||||
if not isinstance(value, dict) or not is_sha256(value.get('oid')):
|
||||
return None
|
||||
size = _size(value.get('size'))
|
||||
return Pointer(value['oid'], size) if size else None
|
||||
|
||||
|
||||
def _size(value) -> int | None:
|
||||
# bool is an int; a size is not
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return value if isinstance(value, str) else ''
|
||||
|
||||
|
||||
def parse_index(data) -> ModelIndex:
|
||||
"""The index, or IndexInvalid naming why it cannot be used. No IO. An entry
|
||||
that is malformed costs that entry and nothing else; an index of another
|
||||
schema, or one listing nothing, is refused whole, so a caller keeps the
|
||||
last one that parsed rather than an empty list."""
|
||||
if not isinstance(data, dict):
|
||||
raise IndexInvalid('the index is not a JSON object')
|
||||
if data.get('schema') != SCHEMA:
|
||||
raise IndexInvalid(f"the index is schema {data.get('schema')!r}, not {SCHEMA}")
|
||||
refs = {ref: p for ref, value in _items(data.get('refs')) if is_ref(ref) and (p := _pointer(value))}
|
||||
sizes = {oid: s for oid, value in _items(data.get('onnx')) if is_sha256(oid) and isinstance(value, dict) and (s := _size(value.get('size')))}
|
||||
models: dict[str, IndexModel] = {}
|
||||
for entry in data.get('models') if isinstance(data.get('models'), list) else []:
|
||||
if not isinstance(entry, dict) or not is_ref(entry.get('ref')) or entry['ref'] in models or (p := _pointer(entry)) is None:
|
||||
continue
|
||||
ref = entry['ref']
|
||||
models[ref] = IndexModel(ref=ref, oid=p.oid, size=p.size, name=_text(entry.get('name')) or ref[:10],
|
||||
short_name=_text(entry.get('short_name')), folder=_text(entry.get('folder')) or OTHER_FOLDER,
|
||||
build_time=_text(entry.get('build_time')), pr=_size(entry.get('pr')),
|
||||
is_20hz=entry['is_20hz'] if isinstance(entry.get('is_20hz'), bool) else True,
|
||||
**{k: _text(entry.get(k)) or v for k, v in RUNTIME.items()})
|
||||
if not models:
|
||||
raise IndexInvalid('the index lists no model')
|
||||
# a listed model is what is shown and run, so its own entry names its ONNX
|
||||
for m in models.values():
|
||||
refs[m.ref] = Pointer(m.oid, m.size)
|
||||
for p in refs.values():
|
||||
sizes.setdefault(p.oid, p.size)
|
||||
default = data.get('default')
|
||||
return ModelIndex(models=tuple(models.values()), refs=refs, sizes=sizes, default=default if default in models else None)
|
||||
|
||||
|
||||
def _items(value) -> list[tuple]:
|
||||
return list(value.items()) if isinstance(value, dict) else []
|
||||
|
||||
|
||||
def fetch_index(url: str = INDEX_URL, timeout: float = INDEX_TIMEOUT, opener=None) -> tuple[dict, ModelIndex]:
|
||||
"""The index as served, and parsed: the raw JSON is what a caller keeps.
|
||||
A transport failure is a NetworkError, an index that cannot be used
|
||||
IndexInvalid; both are RegistryErrors, and either way nothing is kept."""
|
||||
opener = opener or urllib.request.urlopen
|
||||
try:
|
||||
with opener(url, timeout=timeout) as response:
|
||||
body = response.read()
|
||||
except (OSError, ValueError) as e: # HTTPError is an OSError
|
||||
raise NetworkError(f"could not fetch {url}: {e}") from e
|
||||
try:
|
||||
data = json.loads(body.decode())
|
||||
except ValueError as e:
|
||||
raise NetworkError(f"{url} did not serve JSON: {e}") from e
|
||||
return data, parse_index(data)
|
||||
@@ -4,14 +4,27 @@ Copyright (c) 2026-, Zeph Leggett.
|
||||
This file is part of jetlink and is licensed under the MIT License.
|
||||
See the LICENSE file in the root directory for more details.
|
||||
|
||||
Getting a large model's ONNX by its oid, the one the model index names
|
||||
(jetlink.registry.index).
|
||||
Getting a large model's ONNX by its git-lfs oid.
|
||||
|
||||
The bytes are on comma's LFS servers. Since openpilot #39206 comma's own LFS
|
||||
client stores objects in a HuggingFace bucket, read by oid; the older LFS
|
||||
servers, and comma's model repo for the exports behind a precompiled pkl,
|
||||
speak the LFS batch protocol, and which one has an object varies with the
|
||||
model's age, so each is asked in turn.
|
||||
comma overwrites one file per model, so the commit is the only name a big
|
||||
model's ONNX has. GitHub's raw host serves the LFS pointer for any commit it
|
||||
holds, merged or not, and that pointer carries the oid and size the comma will
|
||||
ask a jetlink server for. The bytes themselves are on comma's LFS servers,
|
||||
which is GitLab and not GitHub: each is asked in turn because which one has an
|
||||
object varies with the model's age.
|
||||
|
||||
Since openpilot #38930 a commit can ship a precompiled tinygrad
|
||||
pkl instead, with no ONNX in the tree at all; Cinque Terre V3 is one. Its
|
||||
subject names the export ("Use f78ed37d for the precompiled eGPU driving
|
||||
model") and the ONNX is in comma's HuggingFace model repo, in the folder that
|
||||
id starts. sunnypilot's model builds find it the same way. That repo speaks
|
||||
the LFS batch protocol too, so it is one more endpoint to ask.
|
||||
|
||||
Not every such commit names its export. ResAction's pull request (#39037)
|
||||
adds the ONNX in one commit and replaces it with the pkl in the
|
||||
next, subject "compiled", and a squash merge's subject only names the pull
|
||||
request. The pointer is still in a diff: the one the commit's own patch
|
||||
deletes, or the last one its pull request's patch carries.
|
||||
|
||||
The zoompilot fork downloads through this module too, asking its own
|
||||
.lfsconfig endpoint first. No openpilot imports.
|
||||
@@ -21,29 +34,49 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from jetlink.registry.index import NetworkError, Pointer, RegistryError, VerifyError
|
||||
|
||||
__all__ = ['BUCKET', 'LFS_ENDPOINTS', 'Pointer', 'lfs_download', 'lfs_hrefs', 'lfs_resolve']
|
||||
from jetlink.registry.catalog import NetworkError, NotFound, RegistryError, VerifyError, http_get, http_json, is_sha256
|
||||
|
||||
log = logging.getLogger('jetlink.registry')
|
||||
|
||||
# where comma's LFS client keeps objects since openpilot #39206. A HuggingFace
|
||||
# bucket is not an LFS server: an object is read by its oid under it (lfs_resolve)
|
||||
BUCKET = 'https://huggingface.co/buckets/commaai/openpilot-lfs'
|
||||
BIG_ONNX = 'big_driving_supercombo.onnx'
|
||||
POINTER_URL = 'https://raw.githubusercontent.com/commaai/openpilot/{ref}/openpilot/selfdrive/modeld/models/' + BIG_ONNX
|
||||
# a commit's subject without the API, whose anonymous limit a car behind CGNAT shares
|
||||
COMMIT_PATCH_URL = 'https://github.com/commaai/openpilot/commit/{ref}.patch'
|
||||
# every commit of a pull request, one patch after another, for a squash merge's subject
|
||||
PULL_PATCH_URL = 'https://github.com/commaai/openpilot/pull/{number}.patch'
|
||||
# how much of a patch is read for the pointers in its diffs; a model commit's is 3 KB
|
||||
PATCH_MAX = 256 << 10
|
||||
# the diff header of the big ONNX, wherever the tree keeps selfdrive
|
||||
_ONNX_DIFF = re.compile(r'^diff --git a/\S*selfdrive/modeld/models/' + re.escape(BIG_ONNX) + r' ')
|
||||
# a squash merge's subject ends with its pull request
|
||||
_PULL = re.compile(r'\(#([0-9]+)\)$')
|
||||
DRIVING_MODELS_REPO = 'commaai/openpilot_driving_models'
|
||||
DRIVING_MODELS_TREE_URL = f'https://huggingface.co/api/models/{DRIVING_MODELS_REPO}/tree/main'
|
||||
LFS_ENDPOINTS = (
|
||||
BUCKET,
|
||||
'https://gitlab.com/commaai/openpilot-lfs.git/info/lfs', # every object, older and PR-branch models included
|
||||
'https://huggingface.co/commaai/openpilot-lfs.git/info/lfs', # the current ones
|
||||
'https://huggingface.co/commaai/openpilot-lfs.git/info/lfs', # where comma is moving them; the current ones
|
||||
f'https://huggingface.co/{DRIVING_MODELS_REPO}.git/info/lfs', # the exports behind a precompiled pkl
|
||||
)
|
||||
# an export folder is a uuid; the subject names its first eight
|
||||
_EXPORT_ID = re.compile(r'\b[0-9a-f]{8}\b')
|
||||
# how comma's model commits name an export: the whole uuid, or its first eight
|
||||
# and the checkpoint ('8d736683/12864'). A bare eight could be any hex word
|
||||
_NAMED_EXPORT = r'\b{}[-/]'
|
||||
LFS_MEDIA_TYPE = 'application/vnd.git-lfs+json'
|
||||
POINTER_TIMEOUT = 10.0
|
||||
CONNECT_TIMEOUT = 30.0
|
||||
# A pointer is 134 bytes. Anything larger is the ONNX itself, served by a host
|
||||
# that resolved the LFS filter for us, and reading a gigabyte to find that out
|
||||
# is not on.
|
||||
POINTER_MAX = 4096
|
||||
CHUNK = 4 << 20
|
||||
FREE_SLACK = 64 << 20
|
||||
|
||||
@@ -51,30 +84,190 @@ ProgressFn = Callable[[float], None]
|
||||
StopFn = Callable[[], bool]
|
||||
|
||||
|
||||
def _bucket_href(bucket: str, pointer: Pointer, timeout: float, opener) -> str | None:
|
||||
"""A bucket's address for the object, or None when it does not have it or
|
||||
cannot be reached: its first byte is asked for, as cheap as a HEAD."""
|
||||
url = f"{bucket}/resolve/sha256/{pointer.oid}"
|
||||
try:
|
||||
with opener(urllib.request.Request(url, headers={'Range': 'bytes=0-0'}), timeout=timeout) as response:
|
||||
response.read(1)
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning("%s has no %s (%s)", bucket, pointer.oid[:16], e)
|
||||
@dataclass(frozen=True)
|
||||
class Pointer:
|
||||
oid: str # the ONNX SHA-256
|
||||
size: int
|
||||
|
||||
|
||||
def parse_pointer_text(text: str) -> Pointer | None:
|
||||
"""The oid and size in a git-lfs pointer's text, or None if it is not one."""
|
||||
if not isinstance(text, str) or len(text.encode('utf-8', 'replace')) > POINTER_MAX:
|
||||
return None
|
||||
return url
|
||||
oid = None
|
||||
size = None
|
||||
for line in text.splitlines():
|
||||
key, _, value = line.partition(' ')
|
||||
if key == 'oid':
|
||||
oid = value.removeprefix('sha256:').strip()
|
||||
elif key == 'size':
|
||||
try:
|
||||
size = int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if not is_sha256(oid) or size is None or size <= 0:
|
||||
return None
|
||||
return Pointer(oid, size)
|
||||
|
||||
|
||||
def fetch_pointer(ref: str, timeout: float = POINTER_TIMEOUT, opener=None) -> Pointer:
|
||||
"""The oid and size of the ONNX at a comma commit: in its tree, or for a
|
||||
commit that ships a precompiled pkl instead, the ONNX it was built from."""
|
||||
try:
|
||||
text = http_get(POINTER_URL.format(ref=ref), timeout, opener, POINTER_MAX).decode('utf-8', 'replace')
|
||||
except NotFound:
|
||||
return fetch_export_pointer(ref, timeout=timeout, opener=opener)
|
||||
pointer = parse_pointer_text(text)
|
||||
if pointer is None:
|
||||
raise RegistryError(f"{ref[:10]} did not serve an lfs pointer")
|
||||
return pointer
|
||||
|
||||
|
||||
def patch_subjects(text: str) -> list[str]:
|
||||
"""Every commit's subject in a patch, in order: one for a commit's, one per
|
||||
commit for a pull request's."""
|
||||
lines = text.splitlines()
|
||||
subjects = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('Subject:'):
|
||||
subject = [line.removeprefix('Subject:').strip()]
|
||||
# a long subject is folded onto indented lines; the headers end at a blank one
|
||||
for cont in lines[i + 1:]:
|
||||
if not cont[:1].isspace() or not cont.strip():
|
||||
break
|
||||
subject.append(cont.strip())
|
||||
subjects.append(re.sub(r'^\[PATCH[^\]]*\]\s*', '', ' '.join(subject)))
|
||||
return subjects
|
||||
|
||||
|
||||
def diff_pointer(text: str) -> Pointer | None:
|
||||
"""The big ONNX's pointer in a patch's diffs, the last one wins: the pointer a
|
||||
diff gives the file, or for a diff that deletes it, the pointer it had. A
|
||||
commit that swaps the ONNX for a pkl deletes the ONNX the pkl was built from."""
|
||||
found = None
|
||||
section: list[str] | None = None
|
||||
|
||||
def close():
|
||||
nonlocal found
|
||||
if section is not None:
|
||||
added = parse_pointer_text('\n'.join(x[1:] for x in section if x.startswith('+') and not x.startswith('+++')))
|
||||
removed = parse_pointer_text('\n'.join(x[1:] for x in section if x.startswith('-') and not x.startswith('---')))
|
||||
found = added or removed or found
|
||||
|
||||
for line in text.splitlines():
|
||||
# each diff, and each commit of a pull request's patch, ends the one before
|
||||
if line.startswith(('diff --git ', 'From ')):
|
||||
close()
|
||||
section = [] if _ONNX_DIFF.match(line) else None
|
||||
elif section is not None:
|
||||
section.append(line)
|
||||
close()
|
||||
return found
|
||||
|
||||
|
||||
def commit_subject(ref: str, timeout: float = POINTER_TIMEOUT, opener=None) -> str:
|
||||
"""A comma commit's subject line, from the head of its patch."""
|
||||
url = COMMIT_PATCH_URL.format(ref=ref)
|
||||
subjects = patch_subjects(http_get(url, timeout, opener, POINTER_MAX).decode('utf-8', 'replace'))
|
||||
if not subjects:
|
||||
raise RegistryError(f"{url} has no subject line")
|
||||
return subjects[0]
|
||||
|
||||
|
||||
def _tree(path: str, timeout: float, opener) -> list[dict]:
|
||||
url = DRIVING_MODELS_TREE_URL + (f"/{urllib.parse.quote(path)}?recursive=true" if path else '')
|
||||
return [e for e in http_json(url, timeout, opener, list) if isinstance(e, dict) and isinstance(e.get('path'), str)]
|
||||
|
||||
|
||||
def fetch_export_pointer(ref: str, timeout: float = POINTER_TIMEOUT, opener=None) -> Pointer:
|
||||
"""The big ONNX a precompiled-pkl commit was built from. In turn: the export
|
||||
its subject names, in comma's model repo; the pointer its own diff deletes;
|
||||
and for a squash merge, the last pointer its pull request carried, or the
|
||||
export one of that pull request's commits names.
|
||||
|
||||
The newest export named wins: a pull request's commits are in order, and
|
||||
Mountain Dew's (#39047) named three before the one it shipped. One that is
|
||||
not in the model repo yet stops the search rather than falling back to an
|
||||
older one, a different model."""
|
||||
url = COMMIT_PATCH_URL.format(ref=ref)
|
||||
patch = http_get(url, timeout, opener, PATCH_MAX).decode('utf-8', 'replace')
|
||||
subjects = patch_subjects(patch)
|
||||
if not subjects:
|
||||
raise RegistryError(f"{url} has no subject line")
|
||||
subject = subjects[0]
|
||||
ids = _newest_first(subject)
|
||||
pointer, missing = _export_pointer(ref, ids, subject, timeout, opener)
|
||||
if pointer is not None:
|
||||
return pointer
|
||||
tried = [f"its subject {_not_uploaded(missing)}" if missing else f"no folder in {DRIVING_MODELS_REPO} for {', '.join(ids)}" if ids
|
||||
else 'its subject names no export']
|
||||
if (pointer := diff_pointer(patch)) is not None:
|
||||
log.info("%s deletes %s %s", ref[:10], BIG_ONNX, pointer.oid[:16])
|
||||
return pointer
|
||||
tried.append(f"its diff has no {BIG_ONNX}")
|
||||
if (pull := _PULL.search(subject)) is not None:
|
||||
number = pull.group(1)
|
||||
pull_patch = http_get(PULL_PATCH_URL.format(number=number), timeout, opener, PATCH_MAX).decode('utf-8', 'replace')
|
||||
if (pointer := diff_pointer(pull_patch)) is not None:
|
||||
log.info("%s merged #%s, whose last %s is %s", ref[:10], number, BIG_ONNX, pointer.oid[:16])
|
||||
return pointer
|
||||
if missing is None: # an export the subject names and the repo lacks stops the search here too
|
||||
pull_subjects = ' '.join(patch_subjects(pull_patch))
|
||||
pull_ids = [i for i in _newest_first(pull_subjects) if i not in ids]
|
||||
pointer, pull_missing = _export_pointer(ref, pull_ids, pull_subjects, timeout, opener)
|
||||
if pointer is not None:
|
||||
return pointer
|
||||
tried.append(f"pull request #{number} {_not_uploaded(pull_missing)}" if pull_missing else f"nor does pull request #{number}")
|
||||
raise RegistryError(f"{ref[:10]} has no {BIG_ONNX}: {'; '.join(tried)} ({subject!r})")
|
||||
|
||||
|
||||
def _newest_first(text: str) -> list[str]:
|
||||
"""The eight-hex ids `text` names, the last mention first."""
|
||||
return list(dict.fromkeys(reversed(_EXPORT_ID.findall(text))))
|
||||
|
||||
|
||||
def _not_uploaded(export: str) -> str:
|
||||
return f"names export {export}, which is not in {DRIVING_MODELS_REPO} yet"
|
||||
|
||||
|
||||
def _export_pointer(ref: str, ids: list[str], subject: str, timeout: float, opener) -> tuple[Pointer | None, str | None]:
|
||||
"""The ONNX in the first export folder one of `ids` starts, and None; or
|
||||
None and the first id `subject` names as an export that has no folder, where
|
||||
the search stops; or None and None when no id has a folder."""
|
||||
if not ids:
|
||||
return None, None
|
||||
folders = [e['path'] for e in _tree('', timeout, opener) if e.get('type') == 'directory']
|
||||
for export in ids:
|
||||
matches = [f for f in folders if f.startswith(export)]
|
||||
if not matches:
|
||||
if re.search(_NAMED_EXPORT.format(export), subject):
|
||||
return None, export
|
||||
continue
|
||||
if len(matches) > 1:
|
||||
raise RegistryError(f"{ref[:10]}: {export} starts {len(matches)} folders in {DRIVING_MODELS_REPO}")
|
||||
files = [e for e in _tree(matches[0], timeout, opener)
|
||||
if e.get('type') == 'file' and e['path'].rsplit('/', 1)[-1] == BIG_ONNX]
|
||||
if len(files) > 1:
|
||||
# a subject that names the checkpoint, as '1a421175-.../12864' does, picks one
|
||||
files = [f for f in files if f['path'].rsplit('/', 1)[0] in subject] or files
|
||||
if len(files) != 1:
|
||||
raise RegistryError(f"{ref[:10]}: {len(files)} copies of {BIG_ONNX} under {matches[0]}")
|
||||
lfs = files[0].get('lfs') or {}
|
||||
oid, size = lfs.get('oid'), lfs.get('size')
|
||||
if not is_sha256(oid) or not isinstance(size, int) or size <= 0:
|
||||
raise RegistryError(f"{files[0]['path']} is not an lfs object")
|
||||
log.info("%s names export %s: %s", ref[:10], export, files[0]['path'])
|
||||
return Pointer(oid, size), None
|
||||
return None, None
|
||||
|
||||
|
||||
def lfs_resolve(endpoint: str, pointer: Pointer, timeout: float = CONNECT_TIMEOUT, opener=None) -> str | None:
|
||||
"""Ask one LFS server for a download href, or None if it does not have it.
|
||||
A HuggingFace bucket, comma's (BUCKET) or one a checkout's .lfsconfig
|
||||
names, is asked for the object itself.
|
||||
|
||||
A server that is down is not different from a server that lacks the object:
|
||||
either way the caller moves to the next one, so nothing raises here.
|
||||
"""
|
||||
opener = opener or urllib.request.urlopen
|
||||
if '/buckets/' in endpoint:
|
||||
return _bucket_href(endpoint, pointer, timeout, opener)
|
||||
body = json.dumps({
|
||||
'operation': 'download',
|
||||
'transfers': ['basic'],
|
||||
@@ -109,6 +302,28 @@ def lfs_hrefs(endpoints, pointer: Pointer, timeout: float = CONNECT_TIMEOUT, ope
|
||||
yield endpoint, href
|
||||
|
||||
|
||||
def fetch_size(oid: str, endpoints=LFS_ENDPOINTS, timeout: float = POINTER_TIMEOUT, opener=None) -> Pointer:
|
||||
"""The pointer for an ONNX known by its oid alone, as a fork whose catalog
|
||||
names a model by its hash knows it (StarPilot's). comma's LFS servers hand
|
||||
out a download address for any oid they hold, whatever size the batch asks
|
||||
for, and the first byte of that download says how many there are. No
|
||||
server with it is NotFound; one that would not say, a NetworkError."""
|
||||
opener = opener or urllib.request.urlopen
|
||||
for endpoint, href in lfs_hrefs(endpoints, Pointer(oid, 1), timeout=timeout, opener=opener):
|
||||
request = urllib.request.Request(href, headers={'Range': 'bytes=0-0'})
|
||||
try:
|
||||
with opener(request, timeout=timeout) as response:
|
||||
# a server that ignores the range answers 200 with the whole length
|
||||
total = (response.headers.get('Content-Range') or '').rpartition('/')[2] \
|
||||
if response.status == 206 else response.headers.get('Content-Length')
|
||||
except (OSError, ValueError) as e:
|
||||
raise NetworkError(f"could not size {oid[:16]} at {endpoint}: {e}") from e
|
||||
if not str(total).isdigit() or int(total) <= 0:
|
||||
raise NetworkError(f"{endpoint} did not say how big {oid[:16]} is")
|
||||
return Pointer(oid, int(total))
|
||||
raise NotFound(f"no LFS server has {oid[:16]}")
|
||||
|
||||
|
||||
def lfs_download(href: str, pointer: Pointer, dest: Path, progress: ProgressFn | None = None,
|
||||
should_stop: StopFn | None = None, opener=None) -> Path:
|
||||
"""Stream to a .part file, hashing as we go, and only then take the name.
|
||||
|
||||
+4
-37
@@ -57,37 +57,6 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
function update_is_ready {
|
||||
[ -f "$1" ] && [ ! -L "$1" ]
|
||||
}
|
||||
|
||||
# A validated legacy return records the OS it was staged against. Check it on
|
||||
# every boot before swapping trees, including manual reboots.
|
||||
# Keep this in sync with TRUSTED_LEGACY_RELEASES in updated.py.
|
||||
TRUSTED_LEGACY_STARPILOT_COMMIT="4a553da9ac22a14e34513c011aed04c1e93b4619"
|
||||
|
||||
function staged_os_is_compatible {
|
||||
local staged="$1" marker="$1/.git/starpilot-retain-agnos" commit
|
||||
commit="$(git -C "$staged" rev-parse --verify 'HEAD^{commit}')" || return 1
|
||||
if [ "$commit" = "$TRUSTED_LEGACY_STARPILOT_COMMIT" ]; then
|
||||
[ -f "$marker" ] && [ -s "$marker" ] && [ ! -L "$marker" ] && [ -r /VERSION ] && [ "$(cat "$marker")" = "$(< /VERSION)" ]
|
||||
else
|
||||
[ ! -e "$marker" ] && [ ! -L "$marker" ]
|
||||
fi
|
||||
}
|
||||
|
||||
function reset_update_os_environment {
|
||||
unset AGNOS_VERSION AGNOS_ACCEPTED_VERSIONS
|
||||
}
|
||||
|
||||
function maybe_activate_legacy_agnos {
|
||||
local repo="$1" verifier="$2" manifest="$3" commit
|
||||
commit="$(git -C "$repo" rev-parse --verify 'HEAD^{commit}')" || return 1
|
||||
if [ "$commit" != "$TRUSTED_LEGACY_STARPILOT_COMMIT" ] && [ ! -L "$manifest" ]; then
|
||||
"$verifier" --verify "$manifest" && sudo reboot
|
||||
fi
|
||||
}
|
||||
|
||||
function launch {
|
||||
# Remove orphaned git lock if it exists on boot
|
||||
[ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock"
|
||||
@@ -106,10 +75,8 @@ function launch {
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "${DIR} has been modified, skipping overlay update installation"
|
||||
else
|
||||
if update_is_ready "${STAGING_ROOT}/finalized/.overlay_consistent"; then
|
||||
if ! staged_os_is_compatible "${STAGING_ROOT}/finalized"; then
|
||||
echo "Staged update requires a different or unverified AGNOS version; skipping installation."
|
||||
elif [ ! -d /data/safe_staging/old_openpilot ]; then
|
||||
if [ -f "${STAGING_ROOT}/finalized/.overlay_consistent" ]; then
|
||||
if [ ! -d /data/safe_staging/old_openpilot ]; then
|
||||
echo "Valid overlay update found, installing"
|
||||
LAUNCHER_LOCATION="${BASH_SOURCE[0]}"
|
||||
|
||||
@@ -117,10 +84,10 @@ function launch {
|
||||
mv "${STAGING_ROOT}/finalized" "$DIR"
|
||||
cd "$DIR"
|
||||
# releases before the openpilot/ move can't swap AGNOS on newer AGNOS, use ours
|
||||
maybe_activate_legacy_agnos "$DIR" /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py system/hardware/tici/agnos.json
|
||||
[ ! -L system/hardware/tici/agnos.json ] && /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py --verify system/hardware/tici/agnos.json && sudo reboot
|
||||
|
||||
echo "Restarting launch script ${LAUNCHER_LOCATION}"
|
||||
reset_update_os_environment
|
||||
unset AGNOS_VERSION
|
||||
exec "${LAUNCHER_LOCATION}"
|
||||
else
|
||||
echo "openpilot backup found, not updating"
|
||||
|
||||
+1
-10
@@ -26,18 +26,9 @@ fi
|
||||
export QCOM_PRIORITY=12
|
||||
|
||||
if [ -z "$AGNOS_VERSION" ]; then
|
||||
export AGNOS_VERSION="20.2.2"
|
||||
export AGNOS_VERSION="19.8.1"
|
||||
fi
|
||||
|
||||
export AGNOS_UPDATE_POLICY="auto"
|
||||
# C3 selects its complete matching firmware chain in the AGNOS manifest loader.
|
||||
# Unknown hardware retains its installed OS.
|
||||
if [ -f /AGNOS ] || [ -e /sys/firmware/devicetree/base/model ]; then
|
||||
case "$(tr -d '\0' 2>/dev/null < /sys/firmware/devicetree/base/model)" in
|
||||
"comma tici"|"comma tizi"|"comma mici") ;;
|
||||
*) export AGNOS_UPDATE_POLICY="retain" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
export STAGING_ROOT="/data/safe_staging"
|
||||
|
||||
|
||||
@@ -20,6 +20,3 @@ libmessaging.*
|
||||
libmessaging_shared.*
|
||||
.sconsign.dblite
|
||||
.mypy_cache/
|
||||
|
||||
/msgq/_ipc.pyi
|
||||
/msgq/visionipc/_visionipc.pyi
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
include README.md pyproject.toml setup.py SConstruct SConscript
|
||||
recursive-include msgq *.py py.typed *.h *.cc
|
||||
global-exclude *.pyi *.so *.o *.os *.a *.pyc
|
||||
recursive-include site_scons *.py
|
||||
recursive-include msgq *.py *.pyx *.pxd *.h *.cc
|
||||
global-exclude *.so *.o *.os *.a *.pyc
|
||||
|
||||
+4
-33
@@ -1,29 +1,7 @@
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
import nanobind
|
||||
|
||||
Import('env', 'arch', 'common')
|
||||
|
||||
envNanobind = env.Clone(LIBS=[])
|
||||
nb_dir = Path(nanobind.source_dir()).parent
|
||||
envNanobind.Append(CPPPATH=[sysconfig.get_paths()['include'], nanobind.include_dir(), str(nb_dir / "ext/robin_map/include")],
|
||||
CCFLAGS=["-fvisibility=hidden", "-fno-strict-aliasing"])
|
||||
if arch == "Darwin":
|
||||
envNanobind.Append(LINKFLAGS=["-bundle", "-undefined", "dynamic_lookup"])
|
||||
else:
|
||||
# Python symbols resolve at import time.
|
||||
envNanobind["LINKFLAGS"] = [f for f in env["LINKFLAGS"] if f != "-Wl,--no-undefined"] + ["-pthread", "-shared"]
|
||||
|
||||
# Third-party runtime sources do not follow our shadowing warning policy.
|
||||
envNanobindRuntime = envNanobind.Clone()
|
||||
envNanobindRuntime['CCFLAGS'] = [flag for flag in envNanobindRuntime['CCFLAGS'] if not flag.startswith('-Wshadow')]
|
||||
nanobind_lib = envNanobindRuntime.SharedObject('msgq/nb_combined.os', str(nb_dir / "src/nb_combined.cpp"))
|
||||
Import('env', 'envCython', 'common')
|
||||
|
||||
|
||||
visionipc_dir = Dir('msgq/visionipc')
|
||||
python_suffix = ".so"
|
||||
|
||||
# Build msgq
|
||||
msgq_objects = env.SharedObject([
|
||||
@@ -32,7 +10,7 @@ msgq_objects = env.SharedObject([
|
||||
'msgq/msgq.cc',
|
||||
])
|
||||
msgq = env.Library('msgq', msgq_objects)
|
||||
msgq_python = envNanobind.Program(f'msgq/_ipc{python_suffix}', ['msgq/ipc_py.cc', nanobind_lib], LIBS=envNanobind["LIBS"]+[msgq]+common)
|
||||
msgq_python = envCython.Program('msgq/ipc_pyx.so', 'msgq/ipc_pyx.pyx', LIBS=envCython["LIBS"]+[msgq]+common)
|
||||
|
||||
# Build Vision IPC
|
||||
vipc_files = ['visionipc.cc', 'visionipc_server.cc', 'visionipc_client.cc']
|
||||
@@ -46,17 +24,10 @@ vipc_objects = env.SharedObject(vipc_sources)
|
||||
visionipc = env.Library('visionipc', vipc_objects)
|
||||
|
||||
|
||||
vipc_libs = envNanobind["LIBS"] + [visionipc, msgq] + common
|
||||
visionipc_python = envNanobind.Program(f'{visionipc_dir.abspath}/_visionipc{python_suffix}', [f'{visionipc_dir.abspath}/visionipc_py.cc', nanobind_lib],
|
||||
vipc_libs = envCython["LIBS"] + [visionipc, msgq] + common
|
||||
envCython.Program(f'{visionipc_dir.abspath}/visionipc_pyx.so', f'{visionipc_dir.abspath}/visionipc_pyx.pyx',
|
||||
LIBS=vipc_libs)
|
||||
|
||||
# ASan modules require a preloaded runtime before Python can import them.
|
||||
if "-fsanitize=address" not in env["LINKFLAGS"]:
|
||||
root = Dir('.').abspath
|
||||
for module in ('msgq._ipc', 'msgq.visionipc._visionipc'):
|
||||
env.Command(module.replace('.', '/') + '.pyi', [msgq_python, visionipc_python],
|
||||
f'"{sys.executable}" -m nanobind.stubgen -i "{root}" -m {module} -o "$TARGET"')
|
||||
|
||||
if GetOption('extras'):
|
||||
env.Program('msgq/test_runner', ['msgq/msgq_tests.cc'], LIBS=[msgq]+common)
|
||||
|
||||
|
||||
+15
-1
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sysconfig
|
||||
|
||||
arch = subprocess.check_output(["uname", "-m"], encoding='utf8').rstrip()
|
||||
if platform.system() == "Darwin":
|
||||
@@ -13,6 +14,7 @@ cpppath = [
|
||||
"#/",
|
||||
'#msgq/',
|
||||
'/usr/lib/include',
|
||||
sysconfig.get_paths()['include'],
|
||||
]
|
||||
|
||||
AddOption('--minimal',
|
||||
@@ -60,9 +62,21 @@ env = Environment(
|
||||
CFLAGS="-std=gnu11",
|
||||
CXXFLAGS="-std=c++1z",
|
||||
CPPPATH=cpppath,
|
||||
tools=["default"]
|
||||
CYTHONCFILESUFFIX=".cpp",
|
||||
tools=["default", "cython"]
|
||||
)
|
||||
|
||||
Export('env', 'arch', 'common')
|
||||
|
||||
envCython = env.Clone(LIBS=[])
|
||||
envCython["CCFLAGS"] += ["-Wno-#warnings", "-Wno-cpp", "-Wno-shadow", "-Wno-deprecated-declarations"]
|
||||
envCython["CCFLAGS"].remove('-Werror')
|
||||
if arch == "Darwin":
|
||||
envCython["LINKFLAGS"] = ["-bundle", "-undefined", "dynamic_lookup"]
|
||||
else:
|
||||
envCython["LINKFLAGS"] = ["-pthread", "-shared"]
|
||||
|
||||
Export('envCython')
|
||||
|
||||
|
||||
SConscript(['SConscript'])
|
||||
|
||||
@@ -18,12 +18,12 @@ test:
|
||||
ty:
|
||||
run: ty check .
|
||||
codespell:
|
||||
run: codespell {files} -L ned,stdio,master --builtin clear,rare,informal,usage,code,names,en-GB_to_en-US -S uv.lock
|
||||
run: codespell {files} -L ned,stdio,master --builtin clear,rare,informal,usage,code,names,en-GB_to_en-US -S uv.lock,*_pyx.cpp
|
||||
files: git ls-tree -r HEAD --name-only
|
||||
cppcheck:
|
||||
run: cppcheck --error-exitcode=1 --inline-suppr --language=c++ --force --quiet -j4 --check-level=exhaustive $(git ls-files '*.cc' | grep -v -E '(msgq_tests|test_runner)\.cc')
|
||||
cpplint:
|
||||
run: cpplint --recursive --quiet --counting=detailed --linelength=240 --filter=-build,-legal,-readability,-runtime,-whitespace,+build/include_subdir,+build/forward_decl,+build/include_what_you_use,+build/deprecated,+whitespace/comma,+whitespace/line_length,+whitespace/empty_if_body,+whitespace/empty_loop_body,+whitespace/empty_conditional_body,+whitespace/forcolon,+whitespace/parens,+whitespace/semicolon,+whitespace/tab,+readability/braces msgq/
|
||||
run: cpplint --exclude=msgq/ipc_pyx.cpp --exclude=msgq/visionipc/visionipc_pyx.cpp --recursive --quiet --counting=detailed --linelength=240 --filter=-build,-legal,-readability,-runtime,-whitespace,+build/include_subdir,+build/forward_decl,+build/include_what_you_use,+build/deprecated,+whitespace/comma,+whitespace/line_length,+whitespace/empty_if_body,+whitespace/empty_loop_body,+whitespace/empty_conditional_body,+whitespace/forcolon,+whitespace/parens,+whitespace/semicolon,+whitespace/tab,+readability/braces msgq/
|
||||
|
||||
# *** tests ***
|
||||
test_runner:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
ipc_pyx.cpp
|
||||
@@ -1,8 +1,8 @@
|
||||
from msgq._ipc import Poller, SubSocket, PubSocket, SocketEventHandle, toggle_fake_events, \
|
||||
from msgq.ipc_pyx import Poller, SubSocket, PubSocket, SocketEventHandle, toggle_fake_events, \
|
||||
set_fake_prefix, get_fake_prefix, delete_fake_prefix, wait_for_one_event
|
||||
from msgq._ipc import MultiplePublishersError, IpcError
|
||||
from msgq.ipc_pyx import MultiplePublishersError, IpcError
|
||||
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Union
|
||||
|
||||
__all__ = [
|
||||
"Poller",
|
||||
@@ -25,7 +25,7 @@ __all__ = [
|
||||
|
||||
NO_TRAVERSAL_LIMIT = 2**64-1
|
||||
|
||||
def fake_event_handle(endpoint: str, identifier: Optional[str] = None, override: bool = True, enable: bool = False) -> SocketEventHandle:
|
||||
def fake_event_handle(endpoint: str, identifier: Optional[Union[str, bytes]] = None, override: bool = True, enable: bool = False) -> SocketEventHandle:
|
||||
ident = identifier if identifier is not None else get_fake_prefix()
|
||||
handle = SocketEventHandle(endpoint, ident, override)
|
||||
if override:
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,61 @@
|
||||
# distutils: language = c++
|
||||
#cython: language_level=3
|
||||
|
||||
from libcpp.string cimport string
|
||||
from libcpp.vector cimport vector
|
||||
from libcpp cimport bool
|
||||
|
||||
|
||||
cdef extern from "msgq/event.h":
|
||||
cdef cppclass Event:
|
||||
@staticmethod
|
||||
int wait_for_one(vector[Event], int) except +
|
||||
|
||||
Event()
|
||||
Event(int)
|
||||
void set()
|
||||
int clear()
|
||||
void wait(int) except +
|
||||
bool peek()
|
||||
int fd()
|
||||
|
||||
cdef cppclass SocketEventHandle:
|
||||
@staticmethod
|
||||
void toggle_fake_events(bool)
|
||||
@staticmethod
|
||||
void set_fake_prefix(string)
|
||||
@staticmethod
|
||||
string fake_prefix()
|
||||
|
||||
SocketEventHandle(string, string, bool)
|
||||
bool is_enabled()
|
||||
void set_enabled(bool)
|
||||
Event recv_called()
|
||||
Event recv_ready()
|
||||
|
||||
|
||||
cdef extern from "msgq/ipc.h":
|
||||
cdef cppclass Message:
|
||||
void init(size_t)
|
||||
void init(char *, size_t)
|
||||
void close()
|
||||
size_t getSize()
|
||||
char *getData()
|
||||
|
||||
cdef cppclass SubSocket:
|
||||
SubSocket() nogil
|
||||
int connect(string, bool, size_t) nogil
|
||||
Message * receive(bool) nogil
|
||||
void setTimeout(int) nogil
|
||||
|
||||
cdef cppclass PubSocket:
|
||||
PubSocket()
|
||||
int connect(string, size_t)
|
||||
int sendMessage(Message *)
|
||||
int send(char *, size_t)
|
||||
bool all_readers_updated()
|
||||
|
||||
cdef cppclass Poller:
|
||||
Poller()
|
||||
void registerSocket(SubSocket *)
|
||||
vector[SubSocket*] poll(int) nogil
|
||||
@@ -1,113 +0,0 @@
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
#include <nanobind/stl/optional.h>
|
||||
#include <nanobind/stl/string.h>
|
||||
#include <nanobind/stl/vector.h>
|
||||
|
||||
#include "msgq/event.h"
|
||||
#include "msgq/ipc.h"
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
namespace {
|
||||
|
||||
struct IpcError : std::runtime_error {
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
struct MultiplePublishersError : IpcError {
|
||||
using IpcError::IpcError;
|
||||
};
|
||||
|
||||
void check_error(bool failed, int error, const std::string &endpoint = "") {
|
||||
if (!failed) return;
|
||||
std::string message = "Messaging failure " + (endpoint.empty() ? "" : "with " + endpoint) + ": " + std::strerror(error);
|
||||
if (error == EADDRINUSE) throw MultiplePublishersError(message);
|
||||
throw IpcError(message);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NB_MODULE(_ipc, m) {
|
||||
// Daemon threads can still hold sockets at exit.
|
||||
nb::set_leak_warnings(false);
|
||||
|
||||
nb::exception<IpcError> ipc_error(m, "IpcError");
|
||||
nb::exception<MultiplePublishersError>(m, "MultiplePublishersError", ipc_error);
|
||||
|
||||
m.def("toggle_fake_events", &SocketEventHandle::toggle_fake_events, nb::arg("enabled"));
|
||||
m.def("set_fake_prefix", &SocketEventHandle::set_fake_prefix, nb::arg("prefix"));
|
||||
m.def("get_fake_prefix", &SocketEventHandle::fake_prefix);
|
||||
m.def("delete_fake_prefix", [] { SocketEventHandle::set_fake_prefix(""); });
|
||||
m.def("wait_for_one_event", &Event::wait_for_one, nb::arg("events"), nb::arg("timeout") = -1, nb::call_guard<nb::gil_scoped_release>());
|
||||
|
||||
nb::class_<Event>(m, "Event")
|
||||
.def(nb::init<>())
|
||||
.def("set", &Event::set)
|
||||
.def("clear", &Event::clear)
|
||||
.def("wait", &Event::wait, nb::arg("timeout") = -1, nb::call_guard<nb::gil_scoped_release>())
|
||||
.def("peek", &Event::peek)
|
||||
.def_prop_ro("fd", &Event::fd)
|
||||
.def_prop_ro("ptr", [](Event &event) { return reinterpret_cast<uintptr_t>(&event); });
|
||||
|
||||
nb::class_<SocketEventHandle>(m, "SocketEventHandle")
|
||||
.def(nb::init<std::string, std::string, bool>(), nb::arg("endpoint"), nb::arg("identifier"), nb::arg("override"))
|
||||
.def_prop_rw("enabled", &SocketEventHandle::is_enabled, &SocketEventHandle::set_enabled)
|
||||
.def_prop_ro("recv_called_event", &SocketEventHandle::recv_called, nb::keep_alive<0, 1>())
|
||||
.def_prop_ro("recv_ready_event", &SocketEventHandle::recv_ready, nb::keep_alive<0, 1>());
|
||||
|
||||
nb::class_<SubSocket>(m, "SubSocket")
|
||||
.def(nb::init<>())
|
||||
.def("connect", [](SubSocket &socket, const std::string &name, bool conflate, size_t segment_size) {
|
||||
nb::gil_scoped_release release;
|
||||
int result = socket.connect(name, conflate, segment_size);
|
||||
check_error(result != 0, errno, name);
|
||||
}, nb::arg("endpoint"), nb::arg("conflate") = false, nb::arg("segment_size") = 0)
|
||||
.def("setTimeout", &SubSocket::setTimeout, nb::arg("timeout"), nb::call_guard<nb::gil_scoped_release>())
|
||||
.def("receive", [](SubSocket &socket, bool non_blocking) -> std::optional<nb::bytes> {
|
||||
std::unique_ptr<Message> message;
|
||||
{
|
||||
nb::gil_scoped_release release;
|
||||
message.reset(socket.receive(non_blocking));
|
||||
}
|
||||
if (!message) return std::nullopt;
|
||||
return nb::bytes(message->getData(), message->getSize());
|
||||
}, nb::arg("non_blocking") = false);
|
||||
|
||||
nb::class_<Poller>(m, "Poller")
|
||||
.def(nb::init<>())
|
||||
.def("registerSocket", &Poller::registerSocket, nb::arg("socket"), nb::keep_alive<1, 2>())
|
||||
.def("poll", &Poller::poll, nb::arg("timeout"), nb::rv_policy::reference, nb::call_guard<nb::gil_scoped_release>());
|
||||
|
||||
nb::class_<PubSocket>(m, "PubSocket")
|
||||
.def(nb::init<>())
|
||||
.def("connect", [](PubSocket &socket, const std::string &name, size_t segment_size) {
|
||||
int result = socket.connect(name, segment_size);
|
||||
check_error(result != 0, errno, name);
|
||||
}, nb::arg("endpoint"), nb::arg("segment_size") = 0)
|
||||
.def("send", [](PubSocket &socket, nb::bytes data) {
|
||||
int result = socket.send(const_cast<char *>(data.c_str()), data.size());
|
||||
check_error(result != static_cast<int>(data.size()), errno);
|
||||
}, nb::arg("data"))
|
||||
.def("all_readers_updated", &PubSocket::all_readers_updated)
|
||||
.def("wait_for_readers", [](PubSocket &socket, double timeout, double interval) {
|
||||
// Python's sleep releases the GIL and handles signals while waiting.
|
||||
nb::object sleep = nb::module_::import_("time").attr("sleep");
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::duration<double>(timeout);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (socket.all_readers_updated()) return;
|
||||
sleep(interval);
|
||||
}
|
||||
PyErr_SetString(PyExc_TimeoutError, "subscriber did not connect");
|
||||
throw nb::python_error();
|
||||
}, nb::arg("timeout") = 1.0, nb::arg("interval") = 0.001);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
# distutils: language = c++
|
||||
# cython: c_string_encoding=ascii, language_level=3
|
||||
|
||||
import time
|
||||
from libcpp.string cimport string
|
||||
from libcpp.vector cimport vector
|
||||
from libcpp cimport bool
|
||||
from libc cimport errno
|
||||
from libc.string cimport strerror
|
||||
from cython.operator import dereference
|
||||
|
||||
|
||||
from .ipc cimport SubSocket as cppSubSocket
|
||||
from .ipc cimport PubSocket as cppPubSocket
|
||||
from .ipc cimport Poller as cppPoller
|
||||
from .ipc cimport Message as cppMessage
|
||||
from .ipc cimport Event as cppEvent, SocketEventHandle as cppSocketEventHandle
|
||||
|
||||
|
||||
class IpcError(Exception):
|
||||
def __init__(self, endpoint=None):
|
||||
suffix = f"with {endpoint.decode('utf-8')}" if endpoint else ""
|
||||
message = f"Messaging failure {suffix}: {strerror(errno.errno).decode('utf-8')}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MultiplePublishersError(IpcError):
|
||||
pass
|
||||
|
||||
|
||||
def toggle_fake_events(bool enabled):
|
||||
cppSocketEventHandle.toggle_fake_events(enabled)
|
||||
|
||||
|
||||
def set_fake_prefix(string prefix):
|
||||
cppSocketEventHandle.set_fake_prefix(prefix)
|
||||
|
||||
|
||||
def get_fake_prefix():
|
||||
return cppSocketEventHandle.fake_prefix()
|
||||
|
||||
|
||||
def delete_fake_prefix():
|
||||
cppSocketEventHandle.set_fake_prefix(b"")
|
||||
|
||||
|
||||
def wait_for_one_event(list events, int timeout=-1):
|
||||
cdef vector[cppEvent] items
|
||||
for event in events:
|
||||
items.push_back(dereference(<cppEvent*><size_t>event.ptr))
|
||||
return cppEvent.wait_for_one(items, timeout)
|
||||
|
||||
|
||||
cdef class Event:
|
||||
cdef cppEvent event;
|
||||
|
||||
cdef setEvent(self, cppEvent event):
|
||||
self.event = event
|
||||
|
||||
def set(self):
|
||||
self.event.set()
|
||||
|
||||
def clear(self):
|
||||
return self.event.clear()
|
||||
|
||||
def wait(self, int timeout=-1):
|
||||
self.event.wait(timeout)
|
||||
|
||||
def peek(self):
|
||||
return self.event.peek()
|
||||
|
||||
@property
|
||||
def fd(self):
|
||||
return self.event.fd()
|
||||
|
||||
@property
|
||||
def ptr(self):
|
||||
return <size_t><void*>&self.event
|
||||
|
||||
|
||||
cdef class SocketEventHandle:
|
||||
cdef cppSocketEventHandle * handle;
|
||||
|
||||
def __cinit__(self, string endpoint, string identifier, bool override):
|
||||
self.handle = new cppSocketEventHandle(endpoint, identifier, override)
|
||||
|
||||
def __dealloc__(self):
|
||||
del self.handle
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return self.handle.is_enabled()
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, bool value):
|
||||
self.handle.set_enabled(value)
|
||||
|
||||
@property
|
||||
def recv_called_event(self):
|
||||
e = Event()
|
||||
e.setEvent(self.handle.recv_called())
|
||||
|
||||
return e
|
||||
|
||||
@property
|
||||
def recv_ready_event(self):
|
||||
e = Event()
|
||||
e.setEvent(self.handle.recv_ready())
|
||||
|
||||
return e
|
||||
|
||||
|
||||
cdef class Poller:
|
||||
cdef cppPoller * poller
|
||||
cdef list sub_sockets
|
||||
|
||||
def __cinit__(self):
|
||||
self.sub_sockets = []
|
||||
self.poller = new cppPoller()
|
||||
|
||||
def __dealloc__(self):
|
||||
del self.poller
|
||||
|
||||
def registerSocket(self, SubSocket socket):
|
||||
self.sub_sockets.append(socket)
|
||||
self.poller.registerSocket(socket.socket)
|
||||
|
||||
def poll(self, timeout):
|
||||
sockets = []
|
||||
cdef int t = timeout
|
||||
|
||||
with nogil:
|
||||
result = self.poller.poll(t)
|
||||
|
||||
for s in result:
|
||||
socket = SubSocket()
|
||||
socket.setPtr(s)
|
||||
sockets.append(socket)
|
||||
|
||||
return sockets
|
||||
|
||||
|
||||
cdef class SubSocket:
|
||||
cdef cppSubSocket * socket
|
||||
cdef bool is_owner
|
||||
def __cinit__(self):
|
||||
with nogil:
|
||||
self.socket = new cppSubSocket()
|
||||
|
||||
self.is_owner = True
|
||||
|
||||
def __dealloc__(self):
|
||||
if self.is_owner:
|
||||
with nogil:
|
||||
del self.socket
|
||||
|
||||
cdef setPtr(self, cppSubSocket * ptr):
|
||||
if self.is_owner:
|
||||
with nogil:
|
||||
del self.socket
|
||||
|
||||
self.is_owner = False
|
||||
self.socket = ptr
|
||||
|
||||
def connect(self, string endpoint, bool conflate=False, size_t segment_size=0):
|
||||
cdef int r
|
||||
with nogil:
|
||||
r = self.socket.connect(endpoint, conflate, segment_size)
|
||||
|
||||
if r != 0:
|
||||
if errno.errno == errno.EADDRINUSE:
|
||||
raise MultiplePublishersError(endpoint)
|
||||
else:
|
||||
raise IpcError(endpoint)
|
||||
|
||||
def setTimeout(self, int timeout):
|
||||
with nogil:
|
||||
self.socket.setTimeout(timeout)
|
||||
|
||||
def receive(self, bool non_blocking=False):
|
||||
cdef cppMessage *msg
|
||||
with nogil:
|
||||
msg = self.socket.receive(non_blocking)
|
||||
if msg == NULL:
|
||||
return None
|
||||
else:
|
||||
sz = msg.getSize()
|
||||
m = msg.getData()[:sz]
|
||||
with nogil:
|
||||
del msg
|
||||
return m
|
||||
|
||||
cdef class PubSocket:
|
||||
cdef cppPubSocket * socket
|
||||
|
||||
def __cinit__(self):
|
||||
self.socket = new cppPubSocket()
|
||||
|
||||
def __dealloc__(self):
|
||||
del self.socket
|
||||
|
||||
def connect(self, string endpoint, size_t segment_size=0):
|
||||
r = self.socket.connect(endpoint, segment_size)
|
||||
|
||||
if r != 0:
|
||||
if errno.errno == errno.EADDRINUSE:
|
||||
raise MultiplePublishersError(endpoint)
|
||||
else:
|
||||
raise IpcError(endpoint)
|
||||
|
||||
def send(self, bytes data):
|
||||
length = len(data)
|
||||
r = self.socket.send(<char*>data, length)
|
||||
|
||||
if r != length:
|
||||
if errno.errno == errno.EADDRINUSE:
|
||||
raise MultiplePublishersError
|
||||
else:
|
||||
raise IpcError
|
||||
|
||||
def all_readers_updated(self):
|
||||
return self.socket.all_readers_updated()
|
||||
|
||||
def wait_for_readers(self, double timeout=1.0, double interval=0.001):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if self.all_readers_updated():
|
||||
return
|
||||
time.sleep(interval)
|
||||
raise TimeoutError("subscriber did not connect")
|
||||
Executable
BIN
Binary file not shown.
@@ -569,98 +569,3 @@ TEST_CASE("32 active subscribers retain every message across publisher restart")
|
||||
msgq_close_queue(&pub);
|
||||
std::remove(file.c_str());
|
||||
}
|
||||
|
||||
static int64_t millis_since(std::chrono::steady_clock::time_point start)
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
|
||||
}
|
||||
|
||||
static std::thread send_later(msgq_queue_t *q, int delay_ms)
|
||||
{
|
||||
return std::thread([=]() {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
|
||||
uint64_t value = 1;
|
||||
msgq_msg_t msg;
|
||||
msgq_msg_init_data(&msg, (char *)&value, sizeof(value));
|
||||
msgq_msg_send(&msg, q);
|
||||
msgq_msg_close(&msg);
|
||||
});
|
||||
}
|
||||
|
||||
TEST_CASE("msgq_poll times out")
|
||||
{
|
||||
cleanup_test_queue();
|
||||
msgq_queue_t writer, reader;
|
||||
msgq_new_queue(&writer, "test_queue", 1024);
|
||||
msgq_new_queue(&reader, "test_queue", 1024);
|
||||
msgq_init_publisher(&writer);
|
||||
msgq_init_subscriber(&reader);
|
||||
|
||||
msgq_pollitem_t items[] = {{&reader, 0}};
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
REQUIRE(msgq_poll(items, 1, 100) == 0);
|
||||
REQUIRE(millis_since(start) >= 100);
|
||||
REQUIRE(msgq_poll(items, 1, 0) == 0);
|
||||
|
||||
send_later(&writer, 0).join();
|
||||
REQUIRE(msgq_poll(items, 1, 0) == 1);
|
||||
|
||||
msgq_close_queue(&writer);
|
||||
msgq_close_queue(&reader);
|
||||
}
|
||||
|
||||
TEST_CASE("msgq_poll wakes up on send")
|
||||
{
|
||||
cleanup_test_queue();
|
||||
msgq_queue_t writer, reader;
|
||||
msgq_new_queue(&writer, "test_queue", 1024);
|
||||
msgq_new_queue(&reader, "test_queue", 1024);
|
||||
msgq_init_publisher(&writer);
|
||||
msgq_init_subscriber(&reader);
|
||||
|
||||
msgq_pollitem_t items[] = {{&reader, 0}};
|
||||
std::thread sender = send_later(&writer, 50);
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
int n = msgq_poll(items, 1, 5000);
|
||||
int64_t elapsed = millis_since(start);
|
||||
sender.join();
|
||||
|
||||
REQUIRE(n == 1);
|
||||
REQUIRE(elapsed < 1000);
|
||||
|
||||
msgq_close_queue(&writer);
|
||||
msgq_close_queue(&reader);
|
||||
}
|
||||
|
||||
TEST_CASE("msgq_poll multiple queues")
|
||||
{
|
||||
cleanup_test_queue();
|
||||
remove("/dev/shm/msgq_test_queue2");
|
||||
remove("/tmp/msgq_test_queue2");
|
||||
msgq_queue_t writer1, writer2, reader1, reader2;
|
||||
msgq_new_queue(&writer1, "test_queue", 1024);
|
||||
msgq_new_queue(&writer2, "test_queue2", 1024);
|
||||
msgq_new_queue(&reader1, "test_queue", 1024);
|
||||
msgq_new_queue(&reader2, "test_queue2", 1024);
|
||||
msgq_init_publisher(&writer1);
|
||||
msgq_init_publisher(&writer2);
|
||||
msgq_init_subscriber(&reader1);
|
||||
msgq_init_subscriber(&reader2);
|
||||
|
||||
msgq_pollitem_t items[] = {{&reader1, 0}, {&reader2, 0}};
|
||||
std::thread sender = send_later(&writer2, 50);
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
int n = msgq_poll(items, 2, 5000);
|
||||
int64_t elapsed = millis_since(start);
|
||||
sender.join();
|
||||
|
||||
REQUIRE(n == 1);
|
||||
REQUIRE(elapsed < 1000);
|
||||
REQUIRE(items[0].revents == 0);
|
||||
REQUIRE(items[1].revents == 1);
|
||||
|
||||
msgq_close_queue(&writer1);
|
||||
msgq_close_queue(&writer2);
|
||||
msgq_close_queue(&reader1);
|
||||
msgq_close_queue(&reader2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
visionipc_pyx.cpp
|
||||
*.so
|
||||
@@ -1,4 +1,4 @@
|
||||
from msgq.visionipc._visionipc import VisionBuf, VisionIpcClient, VisionIpcServer, get_endpoint_name
|
||||
from msgq.visionipc.visionipc_pyx import VisionBuf, VisionIpcClient, VisionIpcServer, get_endpoint_name
|
||||
|
||||
__all__ = [
|
||||
"VisionBuf",
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,6 @@
|
||||
import gc
|
||||
import uuid
|
||||
import struct
|
||||
import unittest
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from msgq.visionipc import VisionIpcServer, VisionIpcClient
|
||||
|
||||
@@ -11,6 +10,7 @@ class TestVisionIpc(unittest.TestCase):
|
||||
client: Optional[VisionIpcClient]
|
||||
|
||||
def setUp(self):
|
||||
# Each test needs independent listener and publisher endpoints in parallel runs.
|
||||
self.name = f"test_{uuid.uuid4().hex}"
|
||||
self.server = None
|
||||
self.client = None
|
||||
@@ -19,139 +19,88 @@ class TestVisionIpc(unittest.TestCase):
|
||||
self.client = None
|
||||
self.server = None
|
||||
|
||||
def setup_vipc(
|
||||
self, name: str, stream_type: int, *other_stream_types: int,
|
||||
num_buffers: int = 1, width: int = 100, height: int = 100, conflate: bool = False,
|
||||
) -> tuple[VisionIpcServer, VisionIpcClient, int]:
|
||||
self.server = server = VisionIpcServer(self.name)
|
||||
for stream in (stream_type, *other_stream_types):
|
||||
server.create_buffers(stream, num_buffers, width, height)
|
||||
server.start_listener()
|
||||
def setup_vipc(self, *stream_types, num_buffers=1, width=100, height=100, conflate=False):
|
||||
self.server = VisionIpcServer(self.name)
|
||||
for stream_type in stream_types:
|
||||
self.server.create_buffers(stream_type, num_buffers, width, height)
|
||||
self.server.start_listener()
|
||||
|
||||
self.client = client = VisionIpcClient(self.name, stream_type, conflate)
|
||||
assert client.connect(True)
|
||||
buffer_len = client.buffer_len
|
||||
assert buffer_len is not None
|
||||
return server, client, buffer_len
|
||||
if len(stream_types):
|
||||
self.client = VisionIpcClient(self.name, stream_types[0], conflate)
|
||||
assert self.client.connect(True)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
return self.server, self.client
|
||||
|
||||
def test_connect(self):
|
||||
_, client, _ = self.setup_vipc("camerad", 0)
|
||||
assert client.is_connected()
|
||||
self.setup_vipc(0)
|
||||
assert self.client is not None
|
||||
assert self.client.is_connected()
|
||||
|
||||
def test_available_streams(self):
|
||||
stream_types = (0, 2)
|
||||
self.setup_vipc("camerad", *stream_types)
|
||||
self.setup_vipc(*stream_types)
|
||||
available_streams = VisionIpcClient.available_streams(self.name, True)
|
||||
assert available_streams == set(stream_types)
|
||||
|
||||
def test_buffers(self):
|
||||
width, height, num_buffers = 100, 200, 5
|
||||
_, client, buffer_len = self.setup_vipc("camerad", 0, num_buffers=num_buffers, width=width, height=height)
|
||||
assert client.width == width
|
||||
assert client.height == height
|
||||
assert buffer_len > 0
|
||||
assert client.num_buffers == num_buffers
|
||||
self.setup_vipc(0, num_buffers=num_buffers, width=width, height=height)
|
||||
assert self.client is not None
|
||||
assert self.client.width == width
|
||||
assert self.client.height == height
|
||||
assert self.client.buffer_len is not None and self.client.buffer_len > 0
|
||||
assert self.client.num_buffers == num_buffers
|
||||
|
||||
def test_send_single_buffer(self):
|
||||
server, client, buffer_len = self.setup_vipc("camerad", 0)
|
||||
buf = bytearray(buffer_len)
|
||||
self.setup_vipc(0)
|
||||
assert self.server is not None
|
||||
assert self.client is not None
|
||||
assert self.client.buffer_len is not None
|
||||
buf = bytearray(self.client.buffer_len)
|
||||
struct.pack_into("<Q", buf, 0, 1234)
|
||||
server.send(0, buf, frame_id=1337)
|
||||
self.server.send(0, buf, frame_id=1337)
|
||||
|
||||
recv_buf = client.recv()
|
||||
recv_buf = self.client.recv()
|
||||
assert recv_buf is not None
|
||||
data = recv_buf.data
|
||||
assert isinstance(data, memoryview)
|
||||
assert struct.unpack_from("<Q", data, 0)[0] == 1234
|
||||
assert len(data) == buffer_len
|
||||
assert data[8:].nbytes == buffer_len - 8
|
||||
assert client.frame_id == 1337
|
||||
assert len(data) == self.client.buffer_len
|
||||
assert data[8:].nbytes == self.client.buffer_len - 8
|
||||
assert self.client.frame_id == 1337
|
||||
assert recv_buf.frame_id == 1337
|
||||
|
||||
def test_no_conflate(self):
|
||||
server, client, buffer_len = self.setup_vipc("camerad", 0)
|
||||
buf = bytearray(buffer_len)
|
||||
server.send(0, buf, frame_id=1)
|
||||
server.send(0, buf, frame_id=2)
|
||||
self.setup_vipc(0)
|
||||
assert self.server is not None
|
||||
assert self.client is not None
|
||||
assert self.client.buffer_len is not None
|
||||
buf = bytearray(self.client.buffer_len)
|
||||
self.server.send(0, buf, frame_id=1)
|
||||
self.server.send(0, buf, frame_id=2)
|
||||
|
||||
recv_buf = client.recv()
|
||||
recv_buf = self.client.recv()
|
||||
assert recv_buf is not None
|
||||
assert client.frame_id == 1
|
||||
assert self.client.frame_id == 1
|
||||
|
||||
recv_buf = client.recv()
|
||||
recv_buf = self.client.recv()
|
||||
assert recv_buf is not None
|
||||
assert client.frame_id == 2
|
||||
assert self.client.frame_id == 2
|
||||
|
||||
def test_conflate(self):
|
||||
server, client, buffer_len = self.setup_vipc("camerad", 0, conflate=True)
|
||||
buf = bytearray(buffer_len)
|
||||
server.send(0, buf, frame_id=1)
|
||||
server.send(0, buf, frame_id=2)
|
||||
self.setup_vipc(0, conflate=True)
|
||||
assert self.server is not None
|
||||
assert self.client is not None
|
||||
assert self.client.buffer_len is not None
|
||||
buf = bytearray(self.client.buffer_len)
|
||||
self.server.send(0, buf, frame_id=1)
|
||||
self.server.send(0, buf, frame_id=2)
|
||||
|
||||
recv_buf = client.recv()
|
||||
recv_buf = self.client.recv()
|
||||
assert recv_buf is not None
|
||||
assert client.frame_id == 2
|
||||
assert self.client.frame_id == 2
|
||||
|
||||
recv_buf = client.recv(timeout_ms=5)
|
||||
recv_buf = self.client.recv(timeout_ms=5)
|
||||
assert recv_buf is None
|
||||
|
||||
def test_send_buffer_types(self):
|
||||
server, client, buffer_len = self.setup_vipc("camerad", 0)
|
||||
data = bytes(i % 256 for i in range(buffer_len))
|
||||
strided = bytearray(len(data) * 2)
|
||||
strided[::2] = data
|
||||
for payload in (data, bytearray(data), memoryview(data), memoryview(strided)[::2], memoryview(data[::-1])[::-1]):
|
||||
with self.subTest(type=type(payload)):
|
||||
server.send(0, payload, frame_id=42, timestamp_sof=123, timestamp_eof=456)
|
||||
buf = client.recv()
|
||||
assert buf is not None
|
||||
self.assertEqual(bytes(buf.data), data)
|
||||
self.assertEqual(buf.frame_id, 42)
|
||||
self.assertEqual(client.timestamp_sof, 123)
|
||||
self.assertEqual(client.timestamp_eof, 456)
|
||||
self.assertFalse(client.valid)
|
||||
|
||||
def test_send_wrong_size(self):
|
||||
server, _, _ = self.setup_vipc("camerad", 0)
|
||||
with self.assertRaises(AssertionError):
|
||||
server.send(0, b"short")
|
||||
|
||||
def test_buffer_keeps_client_alive(self):
|
||||
server, client, buffer_len = self.setup_vipc("camerad", 0)
|
||||
data = b"a" * buffer_len
|
||||
server.send(0, data, frame_id=42)
|
||||
buf = client.recv()
|
||||
assert buf is not None
|
||||
self.client = None
|
||||
del client
|
||||
gc.collect()
|
||||
self.assertEqual(buf.frame_id, 42)
|
||||
view = buf.data
|
||||
del buf
|
||||
gc.collect()
|
||||
self.assertEqual(bytes(view), data)
|
||||
self.assertFalse(view.readonly)
|
||||
view[0] = ord("b")
|
||||
self.assertEqual(view[0], ord("b"))
|
||||
|
||||
def test_unconnected_properties(self):
|
||||
client = VisionIpcClient("unconnected", 0, False)
|
||||
for value in (client.width, client.height, client.stride, client.uv_offset, client.buffer_len):
|
||||
self.assertIsNone(value)
|
||||
self.assertEqual(client.num_buffers, 0)
|
||||
self.assertFalse(client.is_connected())
|
||||
self.assertFalse(client.connect(False))
|
||||
self.assertIsNone(client.recv(timeout_ms=0))
|
||||
|
||||
def test_exact_uint64_timestamps_survive_receive_and_timeout(self):
|
||||
server, client, size = self.setup_vipc("clock", 0, width=32, height=18)
|
||||
payload = bytes(size)
|
||||
for frame, sof, eof in ((0, 0, 0), (1, 2**53 + 1, 2**53 + 19),
|
||||
(2**32 - 1, 2**63 + 17, 2**64 - 1)):
|
||||
with self.subTest(frame=frame, sof=sof, eof=eof):
|
||||
server.send(0, payload, frame_id=frame, timestamp_sof=sof, timestamp_eof=eof)
|
||||
buffer = client.recv(timeout_ms=1000)
|
||||
assert buffer is not None
|
||||
assert (client.frame_id, client.timestamp_sof, client.timestamp_eof) == (frame, sof, eof)
|
||||
assert client.recv(timeout_ms=0) is None
|
||||
assert (client.frame_id, client.timestamp_sof, client.timestamp_eof) == (frame, sof, eof)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# distutils: language = c++
|
||||
#cython: language_level=3
|
||||
|
||||
from libcpp.string cimport string
|
||||
from libcpp.vector cimport vector
|
||||
from libcpp.set cimport set
|
||||
from libc.stdint cimport uint32_t, uint64_t
|
||||
from libcpp cimport bool, int
|
||||
|
||||
cdef extern from "msgq/visionipc/visionbuf.h":
|
||||
ctypedef uint32_t VisionStreamType "VisionStreamType"
|
||||
|
||||
cdef cppclass VisionBuf:
|
||||
void * addr
|
||||
int fd
|
||||
size_t len
|
||||
size_t width
|
||||
size_t height
|
||||
size_t stride
|
||||
size_t uv_offset
|
||||
size_t idx
|
||||
void set_frame_id(uint64_t id)
|
||||
uint64_t get_frame_id()
|
||||
|
||||
cdef extern from "msgq/visionipc/visionipc.h":
|
||||
struct VisionIpcBufExtra:
|
||||
uint32_t frame_id
|
||||
uint64_t timestamp_sof
|
||||
uint64_t timestamp_eof
|
||||
bool valid
|
||||
|
||||
cdef extern from "msgq/visionipc/visionipc_server.h":
|
||||
string get_endpoint_name(string, VisionStreamType)
|
||||
|
||||
cdef cppclass VisionIpcServer:
|
||||
VisionIpcServer(string)
|
||||
void create_buffers(VisionStreamType, size_t, size_t, size_t)
|
||||
void create_buffers_with_sizes(VisionStreamType, size_t, size_t, size_t, size_t, size_t, size_t)
|
||||
VisionBuf * get_buffer(VisionStreamType)
|
||||
void send(VisionBuf *, VisionIpcBufExtra *, bool)
|
||||
void start_listener()
|
||||
|
||||
cdef extern from "msgq/visionipc/visionipc_client.h":
|
||||
cdef cppclass VisionIpcClient:
|
||||
int num_buffers
|
||||
VisionBuf buffers[1]
|
||||
VisionIpcClient(string, VisionStreamType, bool)
|
||||
VisionBuf * recv(VisionIpcBufExtra *, int) nogil
|
||||
bool connect(bool)
|
||||
bool is_connected()
|
||||
@staticmethod
|
||||
set[VisionStreamType] getAvailableStreams(string, bool)
|
||||
@@ -1,109 +0,0 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
#include <nanobind/ndarray.h>
|
||||
#include <nanobind/stl/optional.h>
|
||||
#include <nanobind/stl/set.h>
|
||||
#include <nanobind/stl/string.h>
|
||||
|
||||
#include "msgq/visionipc/visionbuf.h"
|
||||
#include "msgq/visionipc/visionipc.h"
|
||||
#include "msgq/visionipc/visionipc_client.h"
|
||||
#include "msgq/visionipc/visionipc_server.h"
|
||||
|
||||
namespace nb = nanobind;
|
||||
|
||||
namespace {
|
||||
|
||||
struct PythonVisionIpcClient : VisionIpcClient {
|
||||
using VisionIpcClient::VisionIpcClient;
|
||||
VisionIpcBufExtra extra = {};
|
||||
};
|
||||
|
||||
using Buffer = nb::ndarray<nb::memview, uint8_t, nb::ndim<1>, nb::device::cpu>;
|
||||
using InputBuffer = nb::ndarray<const uint8_t, nb::ndim<1>, nb::device::cpu>;
|
||||
|
||||
} // namespace
|
||||
|
||||
NB_MODULE(_visionipc, m) {
|
||||
// Daemon threads can still hold clients and servers at exit.
|
||||
nb::set_leak_warnings(false);
|
||||
|
||||
m.def("get_endpoint_name", &get_endpoint_name, nb::arg("name"), nb::arg("stream"));
|
||||
|
||||
nb::class_<VisionBuf>(m, "VisionBuf")
|
||||
.def_prop_ro("data", [](VisionBuf &buf) { return Buffer(buf.addr, {buf.len}); }, nb::rv_policy::reference_internal,
|
||||
nb::sig("def data(self) -> memoryview"))
|
||||
.def_ro("width", &VisionBuf::width)
|
||||
.def_ro("height", &VisionBuf::height)
|
||||
.def_ro("stride", &VisionBuf::stride)
|
||||
.def_ro("uv_offset", &VisionBuf::uv_offset)
|
||||
.def_ro("idx", &VisionBuf::idx)
|
||||
.def_ro("fd", &VisionBuf::fd)
|
||||
.def_prop_ro("frame_id", &VisionBuf::get_frame_id);
|
||||
|
||||
nb::class_<VisionIpcServer>(m, "VisionIpcServer")
|
||||
.def(nb::init<std::string>(), nb::arg("name"))
|
||||
.def("create_buffers", &VisionIpcServer::create_buffers, nb::arg("tp"), nb::arg("num_buffers"), nb::arg("width"), nb::arg("height"))
|
||||
.def("create_buffers_with_sizes", &VisionIpcServer::create_buffers_with_sizes,
|
||||
nb::arg("tp"), nb::arg("num_buffers"), nb::arg("width"), nb::arg("height"), nb::arg("size"), nb::arg("stride"), nb::arg("uv_offset"))
|
||||
.def("send", [](VisionIpcServer &server, VisionStreamType tp, InputBuffer data,
|
||||
uint32_t frame_id, uint64_t timestamp_sof, uint64_t timestamp_eof) {
|
||||
VisionBuf *buf = server.get_buffer(tp);
|
||||
if (buf->len != data.size()) {
|
||||
PyErr_SetString(PyExc_AssertionError, "buffer size does not match frame size");
|
||||
throw nb::python_error();
|
||||
}
|
||||
// The old binding accepted strided byte views as well as contiguous data.
|
||||
auto *source = data.data();
|
||||
if (data.stride(0) == 1) {
|
||||
std::memcpy(buf->addr, source, data.size());
|
||||
} else {
|
||||
auto *target = static_cast<uint8_t *>(buf->addr);
|
||||
for (size_t i = 0; i < data.size(); ++i) target[i] = source[static_cast<int64_t>(i) * data.stride(0)];
|
||||
}
|
||||
buf->set_frame_id(frame_id);
|
||||
VisionIpcBufExtra extra = {frame_id, timestamp_sof, timestamp_eof, false};
|
||||
server.send(buf, &extra, false);
|
||||
}, nb::arg("tp"), nb::arg("data").noconvert(), nb::arg("frame_id") = 0, nb::arg("timestamp_sof") = 0, nb::arg("timestamp_eof") = 0,
|
||||
nb::sig("def send(self, tp: int, data: bytes | bytearray | memoryview, frame_id: int = 0, timestamp_sof: int = 0, timestamp_eof: int = 0) -> None"))
|
||||
.def("start_listener", &VisionIpcServer::start_listener);
|
||||
|
||||
nb::class_<PythonVisionIpcClient>(m, "VisionIpcClient")
|
||||
.def(nb::init<std::string, VisionStreamType, bool>(), nb::arg("name"), nb::arg("stream"), nb::arg("conflate"))
|
||||
.def_prop_ro("width", [](PythonVisionIpcClient &client) -> std::optional<size_t> {
|
||||
return client.num_buffers ? std::make_optional(client.buffers[0].width) : std::nullopt;
|
||||
})
|
||||
.def_prop_ro("height", [](PythonVisionIpcClient &client) -> std::optional<size_t> {
|
||||
return client.num_buffers ? std::make_optional(client.buffers[0].height) : std::nullopt;
|
||||
})
|
||||
.def_prop_ro("stride", [](PythonVisionIpcClient &client) -> std::optional<size_t> {
|
||||
return client.num_buffers ? std::make_optional(client.buffers[0].stride) : std::nullopt;
|
||||
})
|
||||
.def_prop_ro("uv_offset", [](PythonVisionIpcClient &client) -> std::optional<size_t> {
|
||||
return client.num_buffers ? std::make_optional(client.buffers[0].uv_offset) : std::nullopt;
|
||||
})
|
||||
.def_prop_ro("buffer_len", [](PythonVisionIpcClient &client) -> std::optional<size_t> {
|
||||
return client.num_buffers ? std::make_optional(client.buffers[0].len) : std::nullopt;
|
||||
})
|
||||
.def_prop_ro("num_buffers", [](PythonVisionIpcClient &client) { return client.num_buffers; })
|
||||
.def_prop_ro("frame_id", [](PythonVisionIpcClient &client) { return client.extra.frame_id; })
|
||||
.def_prop_ro("timestamp_sof", [](PythonVisionIpcClient &client) { return client.extra.timestamp_sof; })
|
||||
.def_prop_ro("timestamp_eof", [](PythonVisionIpcClient &client) { return client.extra.timestamp_eof; })
|
||||
.def_prop_ro("valid", [](PythonVisionIpcClient &client) { return client.extra.valid; })
|
||||
.def("recv", [](PythonVisionIpcClient &client, int timeout_ms) {
|
||||
nb::gil_scoped_release release;
|
||||
return client.recv(&client.extra, timeout_ms);
|
||||
}, nb::arg("timeout_ms") = 100, nb::rv_policy::reference_internal,
|
||||
nb::sig("def recv(self, timeout_ms: int = 100) -> VisionBuf | None"))
|
||||
// Holds the GIL: connect remaps the buffers other threads may be reading.
|
||||
.def("connect", [](PythonVisionIpcClient &client, bool blocking) {
|
||||
return client.connect(blocking);
|
||||
}, nb::arg("blocking"))
|
||||
.def("is_connected", [](PythonVisionIpcClient &client) { return client.is_connected(); })
|
||||
.def_static("available_streams", &VisionIpcClient::getAvailableStreams, nb::arg("name"), nb::arg("block"),
|
||||
nb::call_guard<nb::gil_scoped_release>());
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# distutils: language = c++
|
||||
#cython: language_level=3
|
||||
|
||||
from .visionipc cimport VisionBuf as cppVisionBuf
|
||||
|
||||
cdef class VisionBuf:
|
||||
cdef cppVisionBuf * buf
|
||||
|
||||
@staticmethod
|
||||
cdef create(cppVisionBuf*)
|
||||
@@ -0,0 +1,163 @@
|
||||
# distutils: language = c++
|
||||
# cython: c_string_encoding=ascii, language_level=3
|
||||
|
||||
from libc.string cimport memcpy
|
||||
from libc.stdint cimport uint32_t, uint64_t
|
||||
from libcpp cimport bool
|
||||
from libcpp.string cimport string
|
||||
|
||||
from .visionipc cimport VisionIpcServer as cppVisionIpcServer
|
||||
from .visionipc cimport VisionIpcClient as cppVisionIpcClient
|
||||
from .visionipc cimport VisionBuf as cppVisionBuf
|
||||
from .visionipc cimport VisionIpcBufExtra, VisionStreamType
|
||||
from .visionipc cimport get_endpoint_name as cpp_get_endpoint_name
|
||||
|
||||
|
||||
def get_endpoint_name(string name, VisionStreamType stream):
|
||||
return cpp_get_endpoint_name(name, stream).decode('utf-8')
|
||||
|
||||
|
||||
cdef class VisionBuf:
|
||||
@staticmethod
|
||||
cdef create(cppVisionBuf * cbuf):
|
||||
buf = VisionBuf()
|
||||
buf.buf = cbuf
|
||||
return buf
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
cdef unsigned char[:] data = <unsigned char[:self.buf.len]> self.buf.addr
|
||||
return memoryview(data)
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.buf.width
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self.buf.height
|
||||
|
||||
@property
|
||||
def stride(self):
|
||||
return self.buf.stride
|
||||
|
||||
@property
|
||||
def uv_offset(self):
|
||||
return self.buf.uv_offset
|
||||
|
||||
@property
|
||||
def idx(self):
|
||||
return self.buf.idx
|
||||
|
||||
@property
|
||||
def fd(self):
|
||||
return self.buf.fd
|
||||
|
||||
@property
|
||||
def frame_id(self):
|
||||
return self.buf.get_frame_id()
|
||||
|
||||
|
||||
cdef class VisionIpcServer:
|
||||
cdef cppVisionIpcServer * server
|
||||
|
||||
def __init__(self, string name):
|
||||
self.server = new cppVisionIpcServer(name)
|
||||
|
||||
def create_buffers(self, VisionStreamType tp, size_t num_buffers, size_t width, size_t height):
|
||||
self.server.create_buffers(tp, num_buffers, width, height)
|
||||
|
||||
def create_buffers_with_sizes(self, VisionStreamType tp, size_t num_buffers, size_t width, size_t height, size_t size, size_t stride, size_t uv_offset):
|
||||
self.server.create_buffers_with_sizes(tp, num_buffers, width, height, size, stride, uv_offset)
|
||||
|
||||
def send(self, VisionStreamType tp, const unsigned char[:] data, uint32_t frame_id=0, uint64_t timestamp_sof=0, uint64_t timestamp_eof=0):
|
||||
cdef cppVisionBuf * buf = self.server.get_buffer(tp)
|
||||
|
||||
# Populate buffer
|
||||
assert buf.len == len(data)
|
||||
memcpy(buf.addr, &data[0], len(data))
|
||||
buf.set_frame_id(frame_id)
|
||||
|
||||
cdef VisionIpcBufExtra extra
|
||||
extra.frame_id = frame_id
|
||||
extra.timestamp_sof = timestamp_sof
|
||||
extra.timestamp_eof = timestamp_eof
|
||||
extra.valid = False
|
||||
|
||||
self.server.send(buf, &extra, False)
|
||||
|
||||
def start_listener(self):
|
||||
self.server.start_listener()
|
||||
|
||||
def __dealloc__(self):
|
||||
del self.server
|
||||
|
||||
|
||||
cdef class VisionIpcClient:
|
||||
cdef cppVisionIpcClient * client
|
||||
cdef VisionIpcBufExtra extra
|
||||
|
||||
def __cinit__(self, string name, VisionStreamType stream, bool conflate):
|
||||
self.client = new cppVisionIpcClient(name, stream, conflate)
|
||||
|
||||
def __dealloc__(self):
|
||||
del self.client
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.client.buffers[0].width if self.client.num_buffers else None
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self.client.buffers[0].height if self.client.num_buffers else None
|
||||
|
||||
@property
|
||||
def stride(self):
|
||||
return self.client.buffers[0].stride if self.client.num_buffers else None
|
||||
|
||||
@property
|
||||
def uv_offset(self):
|
||||
return self.client.buffers[0].uv_offset if self.client.num_buffers else None
|
||||
|
||||
@property
|
||||
def buffer_len(self):
|
||||
return self.client.buffers[0].len if self.client.num_buffers else None
|
||||
|
||||
@property
|
||||
def num_buffers(self):
|
||||
return self.client.num_buffers
|
||||
|
||||
@property
|
||||
def frame_id(self):
|
||||
return self.extra.frame_id
|
||||
|
||||
@property
|
||||
def timestamp_sof(self):
|
||||
return self.extra.timestamp_sof
|
||||
|
||||
@property
|
||||
def timestamp_eof(self):
|
||||
return self.extra.timestamp_eof
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
return self.extra.valid
|
||||
|
||||
def recv(self, int timeout_ms=100):
|
||||
cdef cppVisionBuf * buf
|
||||
# release the GIL, this can block for timeout_ms
|
||||
with nogil:
|
||||
buf = self.client.recv(&self.extra, timeout_ms)
|
||||
if not buf:
|
||||
return None
|
||||
return VisionBuf.create(buf)
|
||||
|
||||
def connect(self, bool blocking):
|
||||
return self.client.connect(blocking)
|
||||
|
||||
def is_connected(self):
|
||||
return self.client.is_connected()
|
||||
|
||||
@staticmethod
|
||||
def available_streams(string name, bool block):
|
||||
return cppVisionIpcClient.getAvailableStreams(name, block)
|
||||
Executable
BIN
Binary file not shown.
+12
-12
@@ -1,5 +1,5 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=77", "nanobind>=2.12", "scons"]
|
||||
requires = ["setuptools>=77", "Cython>=3.1", "scons"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
@@ -21,14 +21,10 @@ dependencies = []
|
||||
Repository = "https://github.com/commaai/msgq"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# for building from source with SCons
|
||||
build = [
|
||||
"nanobind>=2.12",
|
||||
"scons",
|
||||
]
|
||||
dev = [
|
||||
"msgq-ipc[build]",
|
||||
"setuptools", # for distutils
|
||||
"Cython",
|
||||
"scons",
|
||||
"ruff",
|
||||
"cppcheck",
|
||||
"cpplint",
|
||||
@@ -45,16 +41,13 @@ include = ["msgq", "msgq.*"]
|
||||
namespaces = false
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"msgq" = ["*.pyi", "py.typed", "*.h"]
|
||||
"msgq.visionipc" = ["*.pyi", "*.h"]
|
||||
"msgq" = ["*.pxd", "*.h"]
|
||||
"msgq.visionipc" = ["*.pxd", "*.h"]
|
||||
|
||||
[tool.cibuildwheel]
|
||||
build = "cp311-* cp312-* cp313-* cp314-*"
|
||||
test-command = "python -m unittest discover -s msgq"
|
||||
|
||||
[tool.cibuildwheel.macos.environment]
|
||||
MACOSX_DEPLOYMENT_TARGET = "10.13"
|
||||
|
||||
# https://beta.ruff.rs/docs/configuration/#using-pyprojecttoml
|
||||
[tool.ruff]
|
||||
lint.select = ["E", "F", "W", "PIE", "C4", "ISC", "RUF100", "A"]
|
||||
@@ -63,3 +56,10 @@ lint.flake8-implicit-str-concat.allow-multiline=false
|
||||
|
||||
line-length = 160
|
||||
target-version="py311"
|
||||
|
||||
[tool.ty.src]
|
||||
exclude = ["site_scons/"]
|
||||
|
||||
[tool.ty.rules]
|
||||
# Cython modules are compiled at build time, not available for static analysis
|
||||
unresolved-import = "ignore"
|
||||
|
||||
+3
-7
@@ -2,7 +2,6 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
|
||||
from setuptools import Extension, setup
|
||||
from setuptools.command.build_ext import build_ext
|
||||
@@ -12,19 +11,16 @@ class SConsBuildExt(build_ext):
|
||||
def build_extensions(self):
|
||||
subprocess.check_call([sys.executable, "-m", "SCons", "--minimal", "-j", str(self.parallel or os.cpu_count() or 1)])
|
||||
for ext in self.extensions:
|
||||
module = Path(*ext.name.split("."))
|
||||
source = module.with_suffix(sysconfig.get_config_var("EXT_SUFFIX"))
|
||||
source = Path(*ext.name.split(".")).with_suffix(".so")
|
||||
target = Path(self.get_ext_fullpath(ext.name))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.copy_file(str(source), str(target))
|
||||
stub = module.with_suffix(".pyi")
|
||||
self.copy_file(str(stub), str(target.parent / stub.name))
|
||||
|
||||
|
||||
setup(
|
||||
ext_modules=[
|
||||
Extension("msgq._ipc", sources=[]),
|
||||
Extension("msgq.visionipc._visionipc", sources=[]),
|
||||
Extension("msgq.ipc_pyx", sources=[]),
|
||||
Extension("msgq.visionipc.visionipc_pyx", sources=[]),
|
||||
],
|
||||
cmdclass={"build_ext": SConsBuildExt},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import re
|
||||
import SCons
|
||||
from SCons.Action import Action
|
||||
from SCons.Scanner import Scanner
|
||||
|
||||
pyx_from_import_re = re.compile(r'^from\s+(\S+)\s+cimport', re.M)
|
||||
pyx_import_re = re.compile(r'^cimport\s+(\S+)', re.M)
|
||||
cdef_import_re = re.compile(r'^cdef extern from\s+.(\S+).:', re.M)
|
||||
|
||||
|
||||
def pyx_scan(node, env, path, arg=None):
|
||||
contents = node.get_text_contents()
|
||||
|
||||
# from <module> cimport ...
|
||||
matches = pyx_from_import_re.findall(contents)
|
||||
# cimport <module>
|
||||
matches += pyx_import_re.findall(contents)
|
||||
|
||||
# Modules can be either .pxd or .pyx files
|
||||
files = [m.replace('.', '/') + '.pxd' for m in matches]
|
||||
files += [m.replace('.', '/') + '.pyx' for m in matches]
|
||||
|
||||
# cdef extern from <file>
|
||||
files += cdef_import_re.findall(contents)
|
||||
|
||||
# Handle relative imports
|
||||
cur_dir = str(node.get_dir())
|
||||
files = [cur_dir + f if f.startswith('/') else f for f in files]
|
||||
|
||||
# Filter out non-existing files (probably system imports)
|
||||
files = [f for f in files if env.File(f).exists()]
|
||||
return env.File(files)
|
||||
|
||||
|
||||
pyxscanner = Scanner(function=pyx_scan, skeys=['.pyx', '.pxd'], recursive=True)
|
||||
cythonAction = Action("$CYTHONCOM")
|
||||
|
||||
|
||||
def create_builder(env):
|
||||
try:
|
||||
cython = env['BUILDERS']['Cython']
|
||||
except KeyError:
|
||||
cython = SCons.Builder.Builder(
|
||||
action=cythonAction,
|
||||
emitter={},
|
||||
suffix=cython_suffix_emitter,
|
||||
single_source=1
|
||||
)
|
||||
env.Append(SCANNERS=pyxscanner)
|
||||
env['BUILDERS']['Cython'] = cython
|
||||
return cython
|
||||
|
||||
def cython_suffix_emitter(env, source):
|
||||
return "$CYTHONCFILESUFFIX"
|
||||
|
||||
def generate(env):
|
||||
env["CYTHON"] = "cythonize"
|
||||
env["CYTHONCOM"] = "$CYTHON $CYTHONFLAGS $SOURCE"
|
||||
env["CYTHONCFILESUFFIX"] = ".cpp"
|
||||
|
||||
c_file, _ = SCons.Tool.createCFileBuilders(env)
|
||||
|
||||
c_file.suffix['.pyx'] = cython_suffix_emitter
|
||||
c_file.add_action('.pyx', cythonAction)
|
||||
|
||||
c_file.suffix['.py'] = cython_suffix_emitter
|
||||
c_file.add_action('.py', cythonAction)
|
||||
|
||||
create_builder(env)
|
||||
|
||||
def exists(env):
|
||||
return True
|
||||
+2
-2
@@ -18,8 +18,8 @@ lefthook run test
|
||||
trap 'rm -rf "$TEST_DIR"' EXIT
|
||||
uv venv --python "$DIR/.venv/bin/python" "$TEST_DIR/.venv"
|
||||
uv pip install --python "$TEST_DIR/.venv/bin/python" "$DIR"
|
||||
cd "$("$TEST_DIR/.venv/bin/python" -c "import sysconfig; print(sysconfig.get_path('purelib'))")"
|
||||
"$TEST_DIR/.venv/bin/python" -m unittest discover -t . -s msgq
|
||||
cd "$TEST_DIR"
|
||||
"$TEST_DIR/.venv/bin/python" -m unittest msgq.tests.test_messaging
|
||||
)
|
||||
|
||||
# *** all done ***
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ body:
|
||||
attributes:
|
||||
label: Provide a route where the issue occurs
|
||||
description: Ensure the route is fully uploaded at https://useradmin.comma.ai. We cannot look into issues without routes, or at least a Dongle ID.
|
||||
placeholder: 77611a1fac303767/2020-05-11--16-37-07
|
||||
placeholder: 77611a1fac303767|2020-05-11--16-37-07
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ body:
|
||||
attributes:
|
||||
label: Provide a route where the issue occurs if applicable
|
||||
description: Ensure the route is fully uploaded at https://useradmin.comma.ai
|
||||
placeholder: 77611a1fac303767/2020-05-11--16-37-07
|
||||
placeholder: 77611a1fac303767|2020-05-11--16-37-07
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
|
||||
@@ -109,10 +109,6 @@ Start off by recording a route with lots of interesting events: enable LKAS and
|
||||
|
||||
Use the [longitudinal maneuvers](https://github.com/commaai/openpilot/tree/master/tools/longitudinal_maneuvers) report to evaluate your car's longitudinal control and tune it.
|
||||
|
||||
### Validation
|
||||
|
||||
Before submitting a port, go through the [car porting checklist](docs/CAR_PORTING_CHECKLIST.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
All opendbc development is coordinated on GitHub and [Discord](https://discord.comma.ai). Check out the `#dev-opendbc-cars` channel and `Vehicle Specific` section.
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
# Car porting checklist
|
||||
|
||||
This checklist covers common issues we find in new car ports and many of the things we check before merging them. Use it as a guide to help you write a high-quality port and get it reviewed faster.
|
||||
|
||||
Not everything will apply to your car. If something can't be tested, let us know what and why. Where it applies, check each supported variant and both stock ACC and openpilot longitudinal. Some checks need the full openpilot stack, not just opendbc. Field names like `accFaulted` are defined in [`car.capnp`](../opendbc/car/car.capnp).
|
||||
|
||||
## Vehicle and setup
|
||||
|
||||
- Vehicle details
|
||||
|
||||
- [ ] Record make, model, year, trim, market, and relevant driver assistance options in `values.py`.
|
||||
- If all variants in the US market come equipped with the necessary ADAS package, no package documentation is required.
|
||||
- If a vehicle varies in features in another market, add a new [`CarDocs`](../opendbc/car/docs_definitions.py) entry.
|
||||
- [ ] Add every observed ECU firmware version to `fingerprints.py`.
|
||||
- [ ] Identify differences from related supported cars; verify reused signals and commands on this variant.
|
||||
- Gas, gear, and brake signals often move between ICE, hybrid, and EV variants (e.g. Hyundai, Subaru, Toyota SecOC).
|
||||
|
||||
- Harness and CAN topology
|
||||
|
||||
- [ ] Confirm harness pinout, CAN bus assignments, and CAN/CAN FD configuration.
|
||||
- [ ] Identify which ECUs send, receive, and forward each relevant message.
|
||||
- This is useful to know which messages we need to forward and block in safety.
|
||||
|
||||
- Stock behavior and power lifecycle
|
||||
|
||||
- [ ] Record baseline stock LKAS, ACC, stop-and-go, and driver override behavior for reference when writing the port.
|
||||
- Stock behavior should be matched when possible, e.g. how stock ACC commands standstill and resume.
|
||||
- [ ] Verify ignition detection, startup, shutdown, and vehicle sleep with the device connected.
|
||||
|
||||
## Identification and parameters
|
||||
|
||||
- [ ] The car identifies correctly on repeated cold starts without a manually forced fingerprint or fingerprint cache.
|
||||
- [ ] [Firmware queries](../opendbc/car/fw_versions.py) use the correct ECU addresses and buses without causing any DTCs on the dashboard.
|
||||
- [ ] Exact and [fuzzy fingerprinting](../opendbc/car/fw_versions.py) distinguish variants that need different control or safety behavior.
|
||||
- [ ] Unknown or ambiguous firmware does not silently select an incompatible platform.
|
||||
- A vehicle variant we do not support that has identical firmware (such as lacking ACC or LKAS hardware) should be detected with a CAN signal in `interface.py` or `carstate.py` depending on what we can check, and set `dashcamOnly`.
|
||||
- [ ] Reliable source for wheelbase, steering ratio, and other vehicle model parameters.
|
||||
- [ ] `minSteerSpeed`, `minEnableSpeed`, `autoResumeSng`, and `steerAtStandstill` in `carParams` match observed behavior.
|
||||
|
||||
## CAN parsing and message construction
|
||||
|
||||
- Signal definitions and input validation
|
||||
|
||||
- [ ] Message addresses, lengths, buses, frequencies, endianness, signedness, scales, and units are verified.
|
||||
- [ ] Checksums and counters validate against real captures, including counter wraparound.
|
||||
- [ ] Missing, stale, malformed, or checksum-invalid messages produce the expected invalid CAN state.
|
||||
- [ ] Variable-rate or intermittent messages have an [explicit frequency](../opendbc/can/parser.py) in `get_can_parsers` so they don't cause false CAN errors.
|
||||
- For example, VW `Blinkmodi_02`, `HCA_01`, and `AWV_03` are 1 Hz when inactive and 50 Hz when active.
|
||||
- [ ] Startup defaults and invalid signal values cannot look like valid engagement conditions.
|
||||
|
||||
- Outgoing messages and forwarding
|
||||
|
||||
- [ ] Copied stock messages preserve unrelated fields and use fresh source data.
|
||||
- [ ] Transmitted messages have the correct bus, timing, counters, checksums, and request bits.
|
||||
- [ ] Forwarding and replacement rules prevent duplicate actuation messages or bus loops.
|
||||
|
||||
- Diagnostics
|
||||
|
||||
- [ ] Diagnostic sessions, [ECU disabling](../opendbc/car/disable_ecu.py), and keepalives work across startup, restart, and shutdown, if used.
|
||||
- This is for disabling an ECU, such as to gain longitudinal control. Some ports do not require this.
|
||||
|
||||
## Vehicle state: basics
|
||||
|
||||
- Seatbelt and doors
|
||||
|
||||
- [ ] Driver seatbelt: buckle and unbuckle both update `seatbeltUnlatched` correctly.
|
||||
- [ ] Doors: open and close each door individually; `doorOpen` remains true until all monitored doors close.
|
||||
|
||||
- Gears
|
||||
|
||||
- [ ] Gear selection: ideally we parse park, reverse, neutral, drive, and alternate drive modes such as sport, B (brake) gear on hybrids, etc.
|
||||
- If we cannot parse all gears, ensure we can disengage cruise on non-drive/sport gears, or anything that stock ACC would normally disengage for.
|
||||
- [ ] Unknown gear values are handled explicitly instead of defaulting to drive.
|
||||
|
||||
- Pedals and regen
|
||||
|
||||
- [ ] Brake pedal: light press, firm press, hold, and release update `brakePressed` reliably.
|
||||
- [ ] Accelerator pedal: press and release update `gasPressed`, including while cruise is active.
|
||||
- [ ] Driver pedal signals are distinguished from braking or acceleration commanded by ACC/openpilot.
|
||||
- In the past, we found some accelerator signals either activate when stock ACC accelerates, or don't activate when the user's gas was under the stock ACC request. `gasPressed` must be true whenever the user is pressing the gas pedal any amount, in any state.
|
||||
- [ ] Regen paddles or other driver regen controls update `regenBraking`, if applicable.
|
||||
|
||||
- Parking brake and auto hold
|
||||
|
||||
- [ ] Parking brake engagement and release update `parkingBrake`.
|
||||
- [ ] Auto hold engagement and release update `brakeHoldActive`, independently of the brake pedal.
|
||||
|
||||
- Speed and standstill
|
||||
|
||||
- [ ] Vehicle speed and individual wheel speeds have correct units and plausible values.
|
||||
- [ ] Speed and `standstill` use the same signals as the safety model.
|
||||
- For example, Nissan and GM use the rear wheel speeds to match safety.
|
||||
- [ ] `standstill` distinguishes a complete stop from slow creep and updates promptly on drive-off.
|
||||
- [ ] Cluster speed and cruise set speed agree with the car in both metric and imperial settings.
|
||||
|
||||
- Steering
|
||||
|
||||
- [ ] Steering angle has the correct sign, scale, center offset, and behavior through its full range.
|
||||
- [ ] Steering torque has the correct sign and scale; driver torque and EPS torque are not confused.
|
||||
- [ ] `steeringPressed` detects driver input in both directions without triggering on normal commanded steering.
|
||||
- Use [`CS.update_steering_pressed`](../opendbc/car/interfaces.py) if the signal is noisy.
|
||||
|
||||
- Blinkers and blind spots
|
||||
|
||||
- [ ] Left/right blinkers are reported correctly.
|
||||
- If we only have lamp state, use [`CS.update_blinker_from_lamp`](../opendbc/car/interfaces.py) so it doesn't oscillate with the blinker.
|
||||
- If we only have stalk position, use [`CS.update_blinker_from_stalk`](../opendbc/car/interfaces.py) so short taps are held.
|
||||
- [ ] Left/right blind spot indications are mapped correctly and clear when the stock indication clears.
|
||||
|
||||
- Faults and stock system state
|
||||
|
||||
- [ ] Temporary and permanent steering faults are logged correctly and recover only when the fault clears.
|
||||
- EPS initializing states at startup are not permanent faults (e.g. VW, BYD).
|
||||
- Expected low-speed steering lockouts should set `lowSpeedAlert` instead of a fault, usually with hysteresis.
|
||||
- [ ] `accFaulted`, `carFaultedNonCritical`, `carNotReady`, and `vehicleSensorsInvalid` are reported correctly.
|
||||
- [ ] ESP disabled/active states and stock AEB/FCW states are parsed where available.
|
||||
- [ ] Stock LKAS settings (`invalidLkasSetting`) and conventional/non-adaptive cruise modes (`cruiseState.nonAdaptive`) are detected where relevant.
|
||||
- For example, Nissan requires stock LKAS off, Mazda requires it on, and Tesla requires Autosteer off.
|
||||
|
||||
## Buttons and cruise state
|
||||
|
||||
- [ ] Set, resume, cancel, main cruise, speed up/down, gap, and LKAS buttons are mapped where present.
|
||||
- [ ] Button press and release edges each occur once; held buttons do not cause unintended repeated engagement.
|
||||
- [ ] Short presses, long presses, and simultaneous button presses are handled correctly.
|
||||
- [ ] Cruise available (main), enabled, standstill, and set speed are parsed properly and match stock behavior.
|
||||
- Cruise enabled should stay true during driver accelerator override and at standstill. Some dash signals fall in these states (e.g. Subaru).
|
||||
- `cruiseState.standstill` should only be true if the car requires a resume press to drive off.
|
||||
- [ ] Set speed changes, unit changes, and minimum/maximum set speeds behave correctly.
|
||||
|
||||
## Engagement and cancellation
|
||||
|
||||
- Engagement conditions
|
||||
|
||||
- [ ] The intended set/resume action engages once, only when the car is ready (`carNotReady`).
|
||||
- [ ] Engagement refusal and disengagement for seatbelt, doors, gear, parking brake, and faults match the intended policy.
|
||||
|
||||
- Cancellation
|
||||
|
||||
- [ ] The physical cancel button disengages openpilot and cancels stock cruise as intended.
|
||||
- [ ] If stock ACC is controlling longitudinal, verify our cancel message stops commanding acceleration.
|
||||
- [ ] Software cancellation works in each supported cruise state, including standstill and driver accelerator override.
|
||||
- [ ] Cancel wins over simultaneous set/resume requests; repeated cancel requests do not resume or re-enable cruise.
|
||||
- If the cancel button can toggle pause/resume or the main button can engage, use `blockPcmEnable` to only allow PCM enable after a real user button press (e.g. Hyundai).
|
||||
- [ ] Cruise main off disengages and prevents unintended re-engagement.
|
||||
|
||||
- Disengagement and recovery
|
||||
|
||||
- [ ] Releasing a pedal, closing a door, buckling a belt, or clearing a fault does not unexpectedly re-engage unless we are still in the soft-disable grace period.
|
||||
- [ ] Stock ACC disengagement/fault causes the expected openpilot response when using stock longitudinal control.
|
||||
- [ ] Lateral-only and longitudinal-active states set their respective actuation request bits correctly.
|
||||
- For example, `CC.enabled` should be used when keeping the car's cruise engaged during a longitudinal override while `CC.longActive` falls.
|
||||
|
||||
## Lateral control
|
||||
|
||||
- Commands, limits, and driver override
|
||||
|
||||
- [ ] Small left/right commands produce the expected steering direction.
|
||||
- [ ] Command magnitude and rate limits match the platform and safety implementation.
|
||||
- [ ] Driver override reduces or cancels openpilot steering assistance as intended, in both directions.
|
||||
- Some platforms disengage both steering and cruise on a steering override (e.g. Tesla); use `steeringDisengage` for this.
|
||||
|
||||
- Low-speed behavior
|
||||
|
||||
- [ ] Steering behaves correctly around the minimum steering speed, including crossing it in both directions.
|
||||
- [ ] Check for low-speed oscillation and flag it if present. One solution is to low-pass filter the request below a certain speed.
|
||||
|
||||
- Faults and recovery
|
||||
|
||||
- [ ] Sustained steering, high steering rates, and request-bit transitions do not cause unexpected EPS faults.
|
||||
- [ ] Temporary steering unavailability stops active requests and produces the expected user alert.
|
||||
|
||||
- Tuning and reported output
|
||||
|
||||
- [ ] Tuning is checked across representative speeds and curves for oscillation, lag, and saturation.
|
||||
- [ ] Reported `actuatorsOutput` matches the command actually sent after clipping and rate limiting.
|
||||
|
||||
## Longitudinal control
|
||||
|
||||
- Supported control modes
|
||||
|
||||
- [ ] Stock ACC works correctly with openpilot lateral control, including cancel, override, and stop-and-go.
|
||||
- There are two levels of support with openpilot-controlled longitudinal:
|
||||
- [ ] Alpha longitudinal is chosen if we cannot maintain stock AEB or other safety features, or the car does not have a radar we can parse. openpilot longitudinal is then only available on development branches.
|
||||
- [ ] openpilot longitudinal is the only mode and available in all branches if both of the above requirements are met, plus we have a good understanding of the car's longitudinal API.
|
||||
|
||||
- Acceleration, braking, and driver override
|
||||
|
||||
- [ ] Acceleration and braking command signs, scales, limits, and rate limits are verified.
|
||||
- [ ] Acceleration, coasting, and braking transitions are smooth and free of conflicting requests.
|
||||
- Some cars accept gas and brake commands separately; never request both at once.
|
||||
- [ ] Brake press disengages while moving as well as while at a standstill.
|
||||
- [ ] Accelerator override and release behave correctly, i.e. we stop requesting acceleration (inactive, or 0 m/s^2) while continuing to steer.
|
||||
|
||||
- Stopping, holding, and resuming
|
||||
|
||||
- [ ] Low-speed braking reaches a complete stop without unexpected creep or brake release.
|
||||
- [ ] Stop hold works for short and extended stops; timeout and parking brake behavior are understood.
|
||||
- [ ] Resume/drive-off releases hold in the correct sequence without unintended acceleration.
|
||||
- [ ] Resume is sent only when requested and supported.
|
||||
- [ ] Stops and starts on grades behave correctly without unexpected rollback or parking brake/gear transitions.
|
||||
- [ ] `stopAccel`, `vEgoStopping`, `vEgoStarting`, `startingState`, and `startAccel` are tuned if needed.
|
||||
|
||||
- Powertrain states and faults
|
||||
|
||||
- [ ] Behavior is checked across relevant powertrain states, such as engine stop/start.
|
||||
- [ ] ACC faults and transient not-ready states inhibit requests appropriately and recover cleanly.
|
||||
|
||||
- Tuning and stock safety features
|
||||
|
||||
- [ ] `longitudinalActuatorDelay` and tuning are evaluated across representative speeds, following, stops, and starts.
|
||||
- [ ] Stock AEB/FCW interaction and any feature loss from ECU disabling are understood and documented.
|
||||
|
||||
## Radar, alerts, and stock features
|
||||
|
||||
- Radar
|
||||
|
||||
- [ ] Radar distance, lateral position, relative velocity, signs, units, and track IDs are verified, if supported.
|
||||
- [ ] Radar track creation, updates, disappearance, invalid targets, and timeouts behave correctly.
|
||||
- [ ] `radarDelay` is set if known.
|
||||
|
||||
- HUD and alerts
|
||||
|
||||
- [ ] HUD engaged/standby state, set speed, lane lines, lead indication, and gap bars match the requested state.
|
||||
- [ ] Takeover, steering unavailable, and other required alerts appear and clear correctly.
|
||||
|
||||
- Stock feature interaction
|
||||
|
||||
- [ ] Stock LKAS and ACC do not fight openpilot commands.
|
||||
- [ ] Stock safety messages are forwarded or arbitrated as intended; validate AEB interactions with replay/bench tests.
|
||||
- [ ] Normal operation does not introduce persistent dash warnings or diagnostic trouble codes.
|
||||
- Some cars may display warning messages on the dash or force a disengage if they detect a conflict between what their camera is sending and our request through read-back signals from the PCM or EPS. If we need to spoof a message to the camera/ADAS ECU, we need to verify it does not affect stock safety features such as LKAS or AEB.
|
||||
|
||||
## Safety and failure handling
|
||||
|
||||
- Message permissions and engagement
|
||||
|
||||
- [ ] The selected safety mode permits only intended transmit addresses, buses, and message lengths.
|
||||
- [ ] Safety engagement/disengagement agrees with the car interface for cruise, buttons, pedals, and motion state.
|
||||
- [ ] Non-zero actuation or active request bits are rejected when safety's [`controls_allowed`](../opendbc/safety/safety.h) is false.
|
||||
- [ ] Any messages permitted while disengaged are limited to their intended inactive/cancel behavior.
|
||||
|
||||
- Actuation limits
|
||||
|
||||
- [ ] Steering magnitude, rate, driver torque, measured torque, and request-bit checks are covered as applicable.
|
||||
- [ ] Longitudinal limits, inactive values, and cancel/resume restrictions are covered as applicable.
|
||||
- [ ] All actuation-capable fields in transmitted messages are constrained, including fields unused by the controller.
|
||||
|
||||
- Communication failures and recovery
|
||||
|
||||
- [ ] Invalid counters/checksums, missing required input messages, and stale state disable control as intended.
|
||||
- [ ] Relay malfunction detection and forwarding restrictions cover the actual harness topology.
|
||||
|
||||
- Test coverage
|
||||
|
||||
- [ ] Safety tests cover every new safety flag variant and reject disallowed commands.
|
||||
|
||||
## Regression tests
|
||||
|
||||
- Routes and automated checks
|
||||
|
||||
- [ ] Add representative routes to the [car test routes](../opendbc/car/tests/routes.py), covering relevant variants and modes.
|
||||
- [ ] Add or update tests for fingerprinting, parsing, command construction, and discovered edge cases.
|
||||
- [ ] Run applicable [car interface tests](../opendbc/car/tests/) and [safety tests](../opendbc/safety/tests/).
|
||||
- [ ] [`test_models.py`](../opendbc/car/tests/test_models.py) passes on the new routes; it replays them to check CAN validity and that safety agrees with carstate and accepts our commands.
|
||||
- [ ] Run the repository checks in [`test.sh`](../test.sh) and fix any failures.
|
||||
|
||||
- Regression validation
|
||||
|
||||
- [ ] Recheck related existing cars affected by shared DBC, controller, interface, or safety changes.
|
||||
- [ ] Repeat startup, engagement, cancellation, and shutdown over multiple drives and ignition cycles.
|
||||
Binary file not shown.
@@ -146,7 +146,6 @@ class CANParser:
|
||||
self.can_invalid_cnt: int = CAN_INVALID_CNT
|
||||
self.last_nonempty_nanos: int = 0
|
||||
self._last_update_nanos: int = 0
|
||||
self._last_invalid_log_nanos: int | None = None
|
||||
|
||||
def _add_message(self, name_or_addr: str | int, freq: float | None = None) -> None:
|
||||
if isinstance(name_or_addr, numbers.Number):
|
||||
@@ -202,20 +201,13 @@ class CANParser:
|
||||
valid = True
|
||||
counters_valid = True
|
||||
bus_timeout = self.bus_timeout
|
||||
invalid_messages = set()
|
||||
for state in self.message_states.values():
|
||||
if state.counter_fail >= MAX_BAD_COUNTER:
|
||||
counters_valid = False
|
||||
state.rate_limited_log(self._last_update_nanos, f"counter invalid, {state.counter_fail=} {MAX_BAD_COUNTER=}")
|
||||
if not state.valid(self._last_update_nanos, bus_timeout):
|
||||
valid = False
|
||||
invalid_messages.add(state.address)
|
||||
|
||||
if invalid_messages and (self._last_invalid_log_nanos is None or
|
||||
self._last_update_nanos - self._last_invalid_log_nanos >= 10_000_000_000):
|
||||
messages = ', '.join(f"{hex(addr)} {self.message_states[addr].name}" for addr in sorted(invalid_messages))
|
||||
carlog.warning(f"CANParser: bus {self.bus} not valid (timeout or missing): {messages}")
|
||||
self._last_invalid_log_nanos = self._last_update_nanos
|
||||
state.rate_limited_log(self._last_update_nanos, "not valid (timeout or missing)")
|
||||
|
||||
# TODO: probably only want to increment this once per update() call
|
||||
self.can_invalid_cnt = 0 if valid else min(self.can_invalid_cnt + 1, CAN_INVALID_CNT)
|
||||
|
||||
@@ -578,28 +578,12 @@ class CarInterface(CarInterfaceBase):
|
||||
ret.dashcamOnly = candidate not in ORDINARY_CC_CAR
|
||||
if candidate == CAR.CHEVROLET_SILVERADO_CC:
|
||||
pt = fingerprint.get(CanBus.POWERTRAIN, {})
|
||||
cam = fingerprint.get(CanBus.CAMERA, {})
|
||||
cc_long = (all(pt.get(address) == (8 if address == 0x17D else length)
|
||||
for address, length in SILVERADO_CC_STOCK_SOURCES.items()) and
|
||||
pt.get(0xBE) == 6 and 0x201 not in pt and
|
||||
all(cam.get(address) == length for address, length in ((0x180, 4), (0x320, 6), (0x370, 6))) and
|
||||
RADAR_HEADER_MSG not in fingerprint.get(CanBus.OBSTACLE, {}) and
|
||||
CAMERA_DATA_HEADER_MSG not in fingerprint.get(CanBus.OBSTACLE, {}) and not docs)
|
||||
ret.dashcamOnly = not (cc_long or (
|
||||
ret.dashcamOnly = not (
|
||||
all(pt.get(address) == length for address, length in SILVERADO_CC_STOCK_SOURCES.items()) and
|
||||
pt.get(0xBE) in (6, 7, 8) and 0x201 not in pt and
|
||||
0x320 not in fingerprint.get(CanBus.CAMERA, {}) and
|
||||
RADAR_HEADER_MSG not in fingerprint.get(CanBus.OBSTACLE, {}) and
|
||||
CAMERA_DATA_HEADER_MSG not in fingerprint.get(CanBus.OBSTACLE, {}) and not docs))
|
||||
if cc_long:
|
||||
# Retain StarPilot's conventional-cruise button owner for this complete source layout.
|
||||
ret.openpilotLongitudinalControl, ret.pcmCruise = True, False
|
||||
ret.flags |= int(GMFlags.CC_LONG)
|
||||
ret.safetyConfigs[0].safetyParam = ORDINARY_CC_WORD
|
||||
ret.minEnableSpeed = 24 * CV.MPH_TO_MS
|
||||
ret.longitudinalActuatorDelay = 1.
|
||||
ret.longitudinalTuning.kiBP, ret.longitudinalTuning.kiV = [0.], [.1]
|
||||
ret.stopAccel = -2.
|
||||
CAMERA_DATA_HEADER_MSG not in fingerprint.get(CanBus.OBSTACLE, {}) and not docs)
|
||||
|
||||
if candidate in CAMERA_STOCK_CAR:
|
||||
ret.dashcamOnly = True # Require a recognized camera layout for manual identities.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from opendbc.car import structs
|
||||
from opendbc.car.gm.aol import qualified_gm
|
||||
from opendbc.car.gm.carcontroller import CarController
|
||||
from opendbc.car.gm.carstate import CarState
|
||||
from opendbc.car.gm.tests.test_ascm_intercept import params
|
||||
from opendbc.car.gm.tests.test_bolt_cc import native
|
||||
from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from opendbc.car.gm.values import (DBC, GMFlags, ORDINARY_ASCM_CAR, ORDINARY_SDGM_CAR,
|
||||
is_ordinary_ascm_profile, is_ordinary_sdgm_profile)
|
||||
from openpilot.starpilot.aol.tests import test_gm_startup_continuity as startup
|
||||
|
||||
|
||||
CARS = sorted(ORDINARY_ASCM_CAR | ORDINARY_SDGM_CAR)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('candidate', CARS)
|
||||
def test_bsm_preserves_exact_control_owners(candidate):
|
||||
profile = is_ordinary_sdgm_profile if candidate in ORDINARY_SDGM_CAR else is_ordinary_ascm_profile
|
||||
for alpha in (False, True):
|
||||
for release in (False, True):
|
||||
for accelerator in (False, True):
|
||||
for radar in (False, True):
|
||||
cp = params(candidate, sascm=True, accelerator=accelerator, radar=radar, alpha=alpha, release=release)
|
||||
word = cp.safetyConfigs[0].safetyParam
|
||||
cp.flags |= int(GMFlags.HAS_BSM)
|
||||
longitudinal = alpha and not release
|
||||
assert profile(cp, longitudinal=longitudinal)
|
||||
assert qualified_gm(cp)
|
||||
assert cp.safetyConfigs[0].safetyParam == word
|
||||
assert not profile(cp, longitudinal=not longitudinal)
|
||||
for bit in (1, 2, 4, 8, 32, 1 << 30):
|
||||
denied = cp.as_reader().as_builder()
|
||||
denied.flags |= bit
|
||||
assert not profile(denied, longitudinal=longitudinal)
|
||||
cp.safetyConfigs[0].safetyParam |= 4
|
||||
assert not profile(cp, longitudinal=longitudinal)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('candidate', CARS)
|
||||
def test_bsm_startup_replaces_all_acc_messages_on_correct_buses(candidate):
|
||||
cp = params(candidate, sascm=True, alpha=True)
|
||||
cp.flags |= int(GMFlags.HAS_BSM)
|
||||
controller = CarController(DBC[candidate], cp)
|
||||
assert controller.startup_acc_keepalive
|
||||
assert controller.ordinary_sdgm_long == (candidate in ORDINARY_SDGM_CAR)
|
||||
state = CarState(cp)
|
||||
state.pscm_status = dict.fromkeys(('HandsOffSWDetectionMode', 'HandsOffSWlDetectionStatus',
|
||||
'LKATorqueDeliveredStatus', 'LKADriverAppldTrq', 'LKATorqueDelivered',
|
||||
'LKATotalTorqueDelivered', 'RollingCounter', 'PSCMStatusChecksum'), 0)
|
||||
state.out = structs.CarState.new_message(canValid=True).as_reader()
|
||||
control = structs.CarControl.new_message(enabled=True, latActive=True, longActive=True)
|
||||
control.actuators.accel = 1.
|
||||
control.actuators.torque = 1.
|
||||
_, frames = controller.update(control.as_reader(), state, 1_000_000_000, startup_keepalive=True)
|
||||
safety = libsafety_py.libsafety
|
||||
safety.init_tests()
|
||||
assert safety.set_safety_hooks(cp.safetyConfigs[0].safetyModel.raw, cp.safetyConfigs[0].safetyParam) == 0
|
||||
for message in frames:
|
||||
if message[0] in (0x2CB, 0x315, 0x370):
|
||||
assert native('tx', message, 1_000_000), message
|
||||
expected_brake_bus = 2 if candidate in ORDINARY_SDGM_CAR else 0
|
||||
assert {(0x2CB, 0), (0x315, expected_brake_bus), (0x370, 0)} <= {(a, b) for a, _, b in frames}
|
||||
assert not any(a == 0x315 and b != expected_brake_bus for a, _, b in frames)
|
||||
startup.test_initializing_gm_output_is_inactive_and_profile_complete(cp)
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Conventional-cruise truck layout uses the existing guarded CC button owner."""
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import Bus, gen_empty_fingerprint, structs
|
||||
from opendbc.car.gm.interface import CarInterface
|
||||
from opendbc.car.gm.aol import qualified_gm
|
||||
from opendbc.car.gm.values import CAR, DBC, GMFlags, SILVERADO_CC_STOCK_SOURCES, is_ordinary_cc_profile
|
||||
from opendbc.car.gm.startup_preferences import prepare_disable_longitudinal
|
||||
from opendbc.car.gm.tests.test_bolt_cc import native
|
||||
from opendbc.car.gm.tests.test_cc_gateway_stock import silverado_stock_params
|
||||
from opendbc.car.gm.tests.test_ordinary_cc import qualified_frames, warm_ordinary_native
|
||||
from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.starpilot.aol.tests import test_gm as gm_fixture
|
||||
|
||||
|
||||
def fingerprint():
|
||||
fp = gen_empty_fingerprint()
|
||||
fp[0].update(SILVERADO_CC_STOCK_SOURCES)
|
||||
fp[0].update({0x17D: 8, 0xBE: 6, 0x142: 7})
|
||||
fp[2].update({0x180: 4, 0x320: 6, 0x370: 6})
|
||||
return fp
|
||||
|
||||
|
||||
def params(*, alpha=False, release=False, disabled=False):
|
||||
cp = CarInterface.get_params(CAR.CHEVROLET_SILVERADO_CC, fingerprint(), [], alpha, release, False)
|
||||
prepare_disable_longitudinal(cp, disabled)
|
||||
return cp
|
||||
|
||||
|
||||
@pytest.mark.parametrize('release', [False, True])
|
||||
@pytest.mark.parametrize('alpha', [False, True])
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
def test_sierra_layout_finalizes_existing_cc_owner_and_preserves_stock_variant(release, alpha, disabled):
|
||||
cp = params(alpha=alpha, release=release, disabled=disabled)
|
||||
assert not cp.dashcamOnly and not cp.passive
|
||||
assert cp.openpilotLongitudinalControl == (not disabled) and not cp.pcmCruise
|
||||
assert cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.gm
|
||||
assert cp.safetyConfigs[0].safetyParam == 0xC160 and cp.networkLocation == structs.CarParams.NetworkLocation.gateway
|
||||
assert cp.flags == int(GMFlags.CC_LONG | GMFlags.HAS_BSM)
|
||||
assert is_ordinary_cc_profile(cp) and qualified_gm(cp)
|
||||
assert cp.minSteerSpeed == pytest.approx(7 * .44704)
|
||||
assert cp.minEnableSpeed == pytest.approx(24 * .44704)
|
||||
old = silverado_stock_params()
|
||||
assert old.safetyConfigs[0].safetyParam == 16 and old.pcmCruise and not old.openpilotLongitudinalControl
|
||||
assert not old.dashcamOnly and not is_ordinary_cc_profile(old)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bus,address', [(0, a) for a in (*SILVERADO_CC_STOCK_SOURCES, 0xBE)] +
|
||||
[(2, 0x180), (2, 0x320), (2, 0x370)])
|
||||
def test_incomplete_sierra_layout_cannot_gain_control(bus, address):
|
||||
fp = fingerprint()
|
||||
fp[bus].pop(address)
|
||||
cp = CarInterface.get_params(CAR.CHEVROLET_SILVERADO_CC, fp, [], False, False, False)
|
||||
assert cp.dashcamOnly
|
||||
assert not qualified_gm(cp) and not is_ordinary_cc_profile(cp)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['esp_length', 'camera_length', 'radar', 'pedal'])
|
||||
def test_conflicting_layout_does_not_enter_sierra_cc_button_owner(change):
|
||||
fp = fingerprint()
|
||||
if change == 'esp_length':
|
||||
fp[0][0x17D] = 7
|
||||
elif change == 'camera_length':
|
||||
fp[2][0x320] = 8
|
||||
elif change == 'radar':
|
||||
fp[1][0x460] = 8
|
||||
else:
|
||||
fp[0][0x201] = 6
|
||||
cp = CarInterface.get_params(CAR.CHEVROLET_SILVERADO_CC, fp, [], False, False, False)
|
||||
assert not is_ordinary_cc_profile(cp)
|
||||
|
||||
|
||||
def test_sierra_name_and_actual_card_startup_no_longer_become_dashcam():
|
||||
assert any(doc.name == 'GMC Sierra 1500 No-ACC 2020-21' for doc in CAR.CHEVROLET_SILVERADO_CC.config.car_docs)
|
||||
with OpenpilotPrefix():
|
||||
card = gm_fixture.TestGmAol.card(params(), Params())
|
||||
try:
|
||||
assert not card.CP.passive and not card.CP.dashcamOnly and not card.CP.notCar
|
||||
assert is_ordinary_cc_profile(card.CP) and qualified_gm(card.CP)
|
||||
finally:
|
||||
card.vehicle_startup.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
def test_sierra_parser_sender_and_native_permissions(disabled):
|
||||
cp = params(disabled=disabled)
|
||||
assert not cp.dashcamOnly
|
||||
ci = CarInterface(cp)
|
||||
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
|
||||
safety = libsafety_py.libsafety
|
||||
safety.init_tests()
|
||||
safety.set_alternative_experience(cp.alternativeExperience)
|
||||
assert safety.set_safety_hooks(cp.safetyConfigs[0].safetyModel.raw, cp.safetyConfigs[0].safetyParam) == 0
|
||||
warm_ordinary_native(unittest.TestCase(), ci, packer, safety)
|
||||
control = structs.CarControl(enabled=True, latActive=True, longActive=not disabled)
|
||||
control.actuators.torque = .1
|
||||
control.actuators.accel = 1.
|
||||
control.hudControl.setSpeed = 35.
|
||||
buttons = []
|
||||
receipts = []
|
||||
for tick in range(150):
|
||||
now = 1_000_000_000 + tick * 10_000_000
|
||||
frames = qualified_frames(packer, tick % 4)
|
||||
frames = [(a, bytes(d) + b'\x00\x00' if a == 0x17D else d, b) for a, d, b in frames]
|
||||
frames += [packer.make_can_msg('BCMBlindSpotMonitor', 0, {}),
|
||||
packer.make_can_msg('ASCMLKASteeringCmd', 2, {'RollingCounter': tick % 4}),
|
||||
packer.make_can_msg('AEBCmd', 2, {}), packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {})]
|
||||
safety.set_timer(now // 1000)
|
||||
for frame in frames:
|
||||
assert native('rx', frame, now // 1000)
|
||||
safety.safety_tick()
|
||||
state = ci.update([(now, frames + receipts)])
|
||||
if tick > 30:
|
||||
assert state.canValid and not state.accFaulted and state.cruiseState.enabled
|
||||
assert safety.safety_config_valid()
|
||||
_, outgoing = ci.apply(control.as_reader(), now)
|
||||
receipts = [(a, d, 128) for a, d, b in outgoing if a == 0x180 and b == 0]
|
||||
for frame in outgoing:
|
||||
assert native('tx', frame, now // 1000), (tick, frame)
|
||||
if frame[0] == 0x1E1:
|
||||
buttons.append(frame)
|
||||
assert all(a not in (0x315, 0x2CB, 0x2CD, 0x200) for a, _, _ in outgoing)
|
||||
assert bool(buttons) == (not disabled)
|
||||
|
||||
|
||||
def test_sierra_main_already_on_reaches_actual_lateral_callers():
|
||||
from openpilot.starpilot.aol.intent import read_settings
|
||||
from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture
|
||||
with OpenpilotPrefix():
|
||||
case = fixture.IoniqAvailabilityTests(methodName='runTest')
|
||||
try:
|
||||
sd, _, step = case._authority_callers(False, cp=params(), genuine_events=True)
|
||||
sd.aol_settings = read_settings(sd.params)
|
||||
axis, state, command = step(0, native_request_long=False, panda_controls_allowed=False)
|
||||
assert axis.lateralActive and command.latActive
|
||||
assert not axis.longitudinalActive and not command.longActive and not state.enabled
|
||||
finally:
|
||||
case.doCleanups()
|
||||
@@ -719,8 +719,7 @@ class CAR(Platforms):
|
||||
centerToFrontRatio=0.4, tireStiffnessFactor=0.997),
|
||||
)
|
||||
CHEVROLET_SILVERADO_CC = GMPlatformConfig(
|
||||
[GMCommunityCarDocs("Chevrolet Silverado (CC)", "Cruise control; configuration-specific installation"),
|
||||
GMCommunityCarDocs("GMC Sierra 1500 No-ACC 2020-21", "Cruise control; configuration-specific installation")],
|
||||
[GMCommunityCarDocs("Chevrolet Silverado (CC)", "Cruise control; configuration-specific installation")],
|
||||
GMCarSpecs(mass=2994, wheelbase=3.75, steerRatio=16.3, tireStiffnessFactor=1.0),
|
||||
)
|
||||
CHEVROLET_SUBURBAN_CC = GMPlatformConfig(
|
||||
@@ -985,7 +984,7 @@ def is_ordinary_ascm_profile(cp, *, longitudinal):
|
||||
word = int(configs[0].safetyParam)
|
||||
return (cp.brand == "gm" and cp.carFingerprint in ORDINARY_ASCM_CAR and
|
||||
cp.networkLocation == CarParams.NetworkLocation.fwdCamera and
|
||||
not cp.passive and not cp.dashcamOnly and not cp.notCar and control_flags(cp) == 0 and
|
||||
not cp.passive and not cp.dashcamOnly and not cp.notCar and cp.flags == 0 and
|
||||
cp.openpilotLongitudinalControl == longitudinal and cp.pcmCruise != longitudinal and
|
||||
(not longitudinal or cp.alphaLongitudinalAvailable) and
|
||||
len(configs) == 1 and configs[0].safetyModel == CarParams.SafetyModel.gm and
|
||||
@@ -1009,7 +1008,7 @@ def is_ordinary_sdgm_profile(cp, *, longitudinal):
|
||||
configs = cp.safetyConfigs
|
||||
return (cp.brand == 'gm' and cp.carFingerprint in ORDINARY_SDGM_CAR and
|
||||
cp.networkLocation == CarParams.NetworkLocation.fwdCamera and
|
||||
not cp.passive and not cp.dashcamOnly and not cp.notCar and control_flags(cp) == 0 and
|
||||
not cp.passive and not cp.dashcamOnly and not cp.notCar and cp.flags == 0 and
|
||||
cp.openpilotLongitudinalControl == longitudinal and cp.pcmCruise != longitudinal and
|
||||
(not longitudinal or cp.alphaLongitudinalAvailable) and
|
||||
len(configs) == 1 and configs[0].safetyModel == CarParams.SafetyModel.gm and
|
||||
@@ -1069,7 +1068,7 @@ def is_ordinary_cc_profile(cp):
|
||||
return True
|
||||
try:
|
||||
configs = cp.safetyConfigs
|
||||
return (cp.brand == 'gm' and cp.carFingerprint in (ORDINARY_CC_CAR | {CAR.CHEVROLET_SILVERADO_CC}) and
|
||||
return (cp.brand == 'gm' and cp.carFingerprint in ORDINARY_CC_CAR and
|
||||
cp.networkLocation == CarParams.NetworkLocation.gateway and cp.radarUnavailable and not cp.pcmCruise and
|
||||
not cp.alphaLongitudinalAvailable and not cp.passive and not cp.dashcamOnly and not cp.notCar and
|
||||
control_flags(cp) == int(GMFlags.CC_LONG) and len(configs) == 1 and
|
||||
|
||||
@@ -8,9 +8,6 @@ class AngleTxRecovery:
|
||||
self.clock = time.monotonic_ns if clock is None else clock
|
||||
self.address = address
|
||||
self.bus = bus
|
||||
self.length = 24 if address == 0xCB else 32
|
||||
self.active_index = 3 if address == 0xCB else 9
|
||||
self.active_mask = 0xF if address == 0xCB else 3
|
||||
self.sent = deque(maxlen=4)
|
||||
self.last_packet_ns = 0
|
||||
self.pending_frame = -1
|
||||
@@ -19,7 +16,7 @@ class AngleTxRecovery:
|
||||
def record(self, frames, frame):
|
||||
now_ns = self.clock()
|
||||
for address, data, bus in frames:
|
||||
if address == self.address and bus == self.bus and len(data) == self.length and (data[self.active_index] >> 4 & self.active_mask) == 2:
|
||||
if address == self.address and bus == self.bus and len(data) == 32 and (data[9] >> 4 & 3) == 2:
|
||||
self.sent.append((frame, now_ns, bytes(data)))
|
||||
|
||||
def observe(self, packets, frame):
|
||||
@@ -29,7 +26,7 @@ class AngleTxRecovery:
|
||||
continue
|
||||
self.last_packet_ns = stamp
|
||||
for address, data, bus in frames:
|
||||
if address != self.address or bus != self.bus + 0xC0 or len(data) != self.length:
|
||||
if address != self.address or bus != self.bus + 0xC0 or len(data) != 32:
|
||||
continue
|
||||
# Consume an exact recent command once. The original local send clock
|
||||
# also bounds delayed echoes when controller updates have been paused.
|
||||
|
||||
@@ -96,12 +96,9 @@ def qualified(cp, *, marked_only=False):
|
||||
|
||||
|
||||
SPORTAGE_LONG_AOL_WORDS = frozenset(0x0804 | fuel | topology for fuel in (0, 2) for topology in (0, 8, 16, 144, 32, 40))
|
||||
CARNIVAL_LONG_AOL_WORD = 0x28B4
|
||||
LONG_AOL_WORDS = frozenset((CARNIVAL_LONG_AOL_WORD, 0x080D, 0x0815, 0x0895, 0x2815, 0x2895)) | SPORTAGE_LONG_AOL_WORDS
|
||||
LONG_AOL_WORDS = frozenset((0x080D, 0x0815, 0x0895, 0x2815, 0x2895)) | SPORTAGE_LONG_AOL_WORDS
|
||||
LONG_EV_CARS = frozenset((CAR.HYUNDAI_IONIQ_5, CAR.HYUNDAI_KONA_EV_2ND_GEN))
|
||||
INDEPENDENT_LONG_BUTTON_CARS = LONG_EV_CARS | {
|
||||
CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, CAR.GENESIS_GV60_EV_1ST_GEN, CAR.KIA_SPORTAGE_5TH_GEN, CAR.KIA_CARNIVAL_2025,
|
||||
}
|
||||
INDEPENDENT_LONG_BUTTON_CARS = LONG_EV_CARS | {CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, CAR.GENESIS_GV60_EV_1ST_GEN, CAR.KIA_SPORTAGE_5TH_GEN}
|
||||
|
||||
|
||||
def qualified_long(cp, *, marked_only=False):
|
||||
@@ -121,9 +118,6 @@ def qualified_long(cp, *, marked_only=False):
|
||||
elif cp.carFingerprint == CAR.KIA_SPORTAGE_5TH_GEN:
|
||||
if word is None or word | 0x804 not in SPORTAGE_LONG_AOL_WORDS:
|
||||
return False
|
||||
elif cp.carFingerprint == CAR.KIA_CARNIVAL_2025:
|
||||
if word != 0x20B0:
|
||||
return False
|
||||
elif word not in (0x11, 0x91):
|
||||
return False
|
||||
raw = int(cp.safetyConfigs[config_index(cp)].safetyParam)
|
||||
|
||||
@@ -24,7 +24,6 @@ from opendbc.car.hyundai import hyundaicanfd, hyundaican
|
||||
from opendbc.car.hyundai.hyundaicanfd import CanBus
|
||||
from opendbc.car.hyundai.ray_pedal import create_ray_pedal_command, ray_pedal_gas, ray_pedal_enabled
|
||||
from opendbc.car.hyundai.ioniq6_longitudinal import Ioniq6LongitudinalPolicy
|
||||
from opendbc.car.hyundai.ioniq6_bsm import BlindspotStatus
|
||||
from opendbc.car.hyundai.canfd_dash_icons import CanFDDashIcons
|
||||
from opendbc.car.hyundai.classic_scc_aol import qualified as qualified_classic_stock_aol
|
||||
from opendbc.car.hyundai.classic_long_aol import AOL_EXPERIENCE, qualified as qualified_classic_long_aol
|
||||
@@ -72,7 +71,7 @@ def angle_torque_reduction_gain(steering_torque, v_ego, lat_active, last_gain):
|
||||
bp4 = np.interp(v_ego, [2.0, 22.0], [400.0, 700.0])
|
||||
target = np.interp(abs(steering_torque), [bp1, bp2, bp3, bp4], [ceiling, shelf, shelf, floor])
|
||||
else:
|
||||
return 0.0
|
||||
target = 0.0
|
||||
return round(rate_limit(target, last_gain, -0.014, 0.004) / 0.004) * 0.004
|
||||
|
||||
|
||||
@@ -118,9 +117,7 @@ class CarController(CarControllerBase):
|
||||
self.angle_vm = VehicleModel(CP) if CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING else None
|
||||
self.apply_angle_last = 0.0
|
||||
self.angle_gain_last = 0.0
|
||||
self.angle_fault_driver_override = False
|
||||
self.angle_tx_recovery = (AngleTxRecovery(0xCB, self.CAN.ECAN)
|
||||
if pe_long_qualified(CP) or ev9_long_qualified(CP) else AngleTxRecovery(0x110, self.CAN.ACAN)
|
||||
self.angle_tx_recovery = (AngleTxRecovery(0x110, self.CAN.ACAN)
|
||||
if CP.carFingerprint in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9) and
|
||||
(qualified_angle_aol(CP, marked_only=True) or qualified_ccnc_ev_stock(CP)) else None)
|
||||
|
||||
@@ -299,7 +296,6 @@ class CarController(CarControllerBase):
|
||||
self.frame += 1
|
||||
if _projection is not None:
|
||||
_projection.finish(new_actuators.torque)
|
||||
can_sends.extend(CS.daw.command(now_nanos))
|
||||
return new_actuators, can_sends
|
||||
|
||||
def create_can_msgs(self, apply_steer_req, apply_torque, torque_fault, set_speed_in_units, accel, stopping,
|
||||
@@ -437,24 +433,15 @@ class CarController(CarControllerBase):
|
||||
|
||||
# steering control
|
||||
if ev9_long:
|
||||
self.angle_fault_driver_override = bool(CS.out.steeringPressed and (
|
||||
self.angle_fault_driver_override or CS.out.steerFaultTemporary or CS.out.steerFaultPermanent))
|
||||
drive = CS.out.gearShifter == structs.CarState.GearShifter.drive
|
||||
measured = CS.angle_steering_angle
|
||||
if self.angle_tx_recovery is not None and self.angle_tx_recovery.take_rejection(self.frame):
|
||||
self.apply_angle_last = float(np.clip(measured, -self.params.ANGLE_LIMITS.STEER_ANGLE_MAX,
|
||||
self.params.ANGLE_LIMITS.STEER_ANGLE_MAX))
|
||||
self.angle_gain_last = 0.0
|
||||
if self.ev9_angle_filter is not None:
|
||||
self.ev9_angle_filter.x = self.apply_angle_last
|
||||
safety_speed = max(CS.out.vEgoRaw - 1.0, 1.0)
|
||||
bound = min(get_max_angle_vm(safety_speed, self.ev9_legacy_envelope, self.params),
|
||||
get_max_angle_vm(safety_speed, self.angle_vm, self.params))
|
||||
ev9_lateral_allowed = lateral_request_allowed(CC, CS.out, CS.angle_steering_fault,
|
||||
independent=(ev9_long_aol_qualified(self.CP, marked_only=True) or
|
||||
pe_long_aol_qualified(self.CP, marked_only=True)))
|
||||
active = bool(ev9_lateral_allowed and CC.latActive and not self.angle_fault_driver_override and
|
||||
abs(measured) <= bound and abs(self.apply_angle_last) <= bound)
|
||||
active = bool(ev9_lateral_allowed and CC.latActive and abs(measured) <= bound and abs(self.apply_angle_last) <= bound)
|
||||
self.ev9_angle_filter.update_alpha(float(np.interp(CS.out.vEgo, [5.0, 10.0, 20.0], [0.2, 0.1, 0.0])))
|
||||
desired = self.ev9_angle_filter.update(float(np.clip(CC.actuators.steeringAngleDeg,
|
||||
-(self.params.ANGLE_LIMITS.STEER_ANGLE_MAX if pe_long else EV9_OP_LONG_ANGLE_TARGET_MAX),
|
||||
@@ -523,9 +510,8 @@ class CarController(CarControllerBase):
|
||||
)
|
||||
else:
|
||||
steering_status_active = CC.enabled
|
||||
if ((self.CP.carFingerprint == CAR.KIA_CARNIVAL_2025 and self.CP.safetyConfigs[-1].safetyParam == 0x28B4) or
|
||||
self.ev6_aol_status or (self.ioniq6_longitudinal is not None and
|
||||
self.CP.safetyConfigs[-1].safetyParam in (0x8815, 0x8895))):
|
||||
if self.ev6_aol_status or (self.ioniq6_longitudinal is not None and
|
||||
self.CP.safetyConfigs[-1].safetyParam in (0x8815, 0x8895)):
|
||||
# AOL may steer while ordinary cruise engagement remains off.
|
||||
steering_status_active |= CC.latActive
|
||||
can_sends.extend(hyundaicanfd.create_steering_messages(self.packer, self.CP, self.CAN,
|
||||
@@ -601,11 +587,6 @@ class CarController(CarControllerBase):
|
||||
can_now_ns = getattr(CS, "ioniq6_bsm_now_ns", 0)
|
||||
status = (sources.status(can_now_ns) if sources is not None and can_now_ns > self.ioniq6_bsm_last_can_ns else None)
|
||||
if status is not None:
|
||||
left_queued = CC.leftBlinker and not CC.rightBlinker
|
||||
right_queued = CC.rightBlinker and not CC.leftBlinker
|
||||
if (CS.out.vEgoRaw < 5 * CV.MPH_TO_MS or
|
||||
status.left == 2 and not left_queued or status.right == 2 and not right_queued):
|
||||
status = BlindspotStatus(0, 0)
|
||||
can_sends.extend(hyundaicanfd.create_ioniq6_blindspot_status(self.ioniq6_bsm_counter, status))
|
||||
self.ioniq6_bsm_counter = (self.ioniq6_bsm_counter + 1) & 0xff
|
||||
self.ioniq6_bsm_last_can_ns = can_now_ns
|
||||
|
||||
@@ -62,8 +62,6 @@ def get_non_scc_cruise_signals(flags: int, car=None) -> tuple[str, str, str, str
|
||||
class CarState(CarStateBase):
|
||||
def __init__(self, CP):
|
||||
super().__init__(CP)
|
||||
from opendbc.car.hyundai.daw import DawSource
|
||||
self.daw = DawSource(CP)
|
||||
self.forte_lkas_sources = NonSccLkasSources() if qualified_non_scc(CP) and CP.flags & HyundaiFlags.HAS_LDA_BUTTON else None
|
||||
if (qualified_classic_scc(CP) or qualified_classic_long(CP)) and CP.flags & HyundaiFlags.HAS_LDA_BUTTON:
|
||||
self.forte_lkas_sources = ClassicSccLkasSources(CP.carFingerprint)
|
||||
@@ -133,6 +131,7 @@ class CarState(CarStateBase):
|
||||
self.ccnc_1b5_ts_ns = 0
|
||||
self.dashboard_limit = LimitTracker()
|
||||
self.dashboard_stop_sign = None
|
||||
self.drive_mode = None
|
||||
self.ioniq6_bsm_sources = (Ioniq6BlindspotSources()
|
||||
if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl else None)
|
||||
self.ioniq6_bsm_now_ns = 0
|
||||
@@ -348,10 +347,9 @@ class CarState(CarStateBase):
|
||||
self.lkas11 = copy.copy(cp_cam.vl["LKAS11"])
|
||||
self.clu11 = copy.copy(cp.vl["CLU11"])
|
||||
from opendbc.car.hyundai.classic_long_aol import independent_buttons_qualified
|
||||
from opendbc.car.hyundai.blended_stock_aol import qualified_alpha
|
||||
self.ev6_aol_samples = ()
|
||||
self.ev6_aol_source_healthy = False
|
||||
if independent_buttons_qualified(self.CP) or qualified_alpha(self.CP, marked_only=True):
|
||||
if independent_buttons_qualified(self.CP):
|
||||
from opendbc.can.parser import MAX_BAD_COUNTER
|
||||
source = cp.message_states.get(cp.dbc.name_to_msg['CLU11'].address)
|
||||
self.ev6_aol_sample_stamp_ns = int(source.timestamps[-1]) if source is not None and source.timestamps else 0
|
||||
@@ -611,6 +609,10 @@ class CarState(CarStateBase):
|
||||
stamp = cp_cam.ts_nanos["ADAS_0x380"]["STOP_SIGN"]
|
||||
self.dashboard_stop_sign = (bool(cp_cam.vl["ADAS_0x380"]["STOP_SIGN"]), stamp) if stamp > 0 else None
|
||||
|
||||
if self.CP.flags & HyundaiFlags.EV:
|
||||
stamp = cp.ts_nanos["DRIVE_MODE_EV"]["DRIVE_MODE"]
|
||||
self.drive_mode = (int(cp.vl["DRIVE_MODE_EV"]["DRIVE_MODE"]), stamp) if stamp > 0 else None
|
||||
|
||||
return ret
|
||||
|
||||
def get_can_parsers_canfd(self, CP):
|
||||
@@ -625,6 +627,7 @@ class CarState(CarStateBase):
|
||||
msgs.extend((("ADAS_CMD_50_50ms", math.nan), ("BLINDSPOTS_FRONT_CORNER_2", math.nan)))
|
||||
if CP.flags & HyundaiFlags.EV:
|
||||
msgs.append(("MANUAL_SPEED_LIMIT_ASSIST", math.nan))
|
||||
msgs.append(("DRIVE_MODE_EV", math.nan))
|
||||
sign_subscription = ("FR_CMR_02_100ms", math.nan)
|
||||
if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
|
||||
msgs.append(sign_subscription)
|
||||
@@ -670,12 +673,6 @@ class CarState(CarStateBase):
|
||||
msgs.append(("CRUISE_BUTTONS_ALT", math.nan))
|
||||
if not (ev9_long_qualified(CP) or pe_long_qualified(CP)):
|
||||
(cam_msgs if CP.flags & HyundaiFlags.CANFD_CAMERA_SCC else msgs).append(("SCC_CONTROL", 50))
|
||||
elif CP.carFingerprint == CAR.KIA_CARNIVAL_2025 and CP.openpilotLongitudinalControl and CP.safetyConfigs[-1].safetyParam in (0x20B4, 0x28B4):
|
||||
msgs = [(name, frequency) for name, frequency in msgs if name != "CRUISE_BUTTONS_ALT"]
|
||||
msgs.extend((("CRUISE_BUTTONS_ALT", 50), ("ACCELERATOR_BRAKE_ALT", 100), ("TCS", 50),
|
||||
("WHEEL_SPEEDS", 100), ("MDPS", 100), (self.gear_msg_canfd, 100),
|
||||
("STEERING_SENSORS", 100), ("DOORS_SEATBELTS", 10), ("BLINKERS", 10), ("SCC_CONTROL", math.nan)))
|
||||
cam_msgs.append(("CAM_0x362", 20))
|
||||
elif torque_ev_scc_eligible(CP):
|
||||
msgs.extend(((self.accelerator_msg_canfd, 100), ("TCS", 50), ("WHEEL_SPEEDS", 100), ("MDPS", 100)))
|
||||
msgs.extend((("CRUISE_BUTTONS_ALT", math.nan), ("DOORS_SEATBELTS", math.nan),
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from opendbc.car import CanData
|
||||
from opendbc.car.hyundai.hyundaicanfd import CanBus, hkg_can_fd_checksum
|
||||
from opendbc.car.hyundai.values import CAR, HyundaiFlags, is_blended
|
||||
|
||||
|
||||
CANFD_DAW_WORDS = frozenset((0x11, 0x91, 0x811, 0x891, 0x8015, 0x8095, 0x8815, 0x8895))
|
||||
|
||||
|
||||
class DawSource:
|
||||
def __init__(self, cp):
|
||||
self.canfd = bool(cp.flags & HyundaiFlags.CANFD)
|
||||
self.enabled = (cp.brand == 'hyundai' and not cp.passive and not cp.dashcamOnly and not cp.notCar and
|
||||
not is_blended(cp) and (not self.canfd or cp.carFingerprint == CAR.HYUNDAI_IONIQ_6))
|
||||
if self.canfd:
|
||||
self.enabled = bool(self.enabled and cp.safetyConfigs and
|
||||
int(cp.safetyConfigs[-1].safetyParam) in CANFD_DAW_WORDS and CanBus(cp).ECAN % 4 == 1)
|
||||
self.address = 0x11A if self.canfd else 0x53E
|
||||
self.source_bus = CanBus(cp).ECAN if self.canfd else 2
|
||||
self.target_bus = CanBus(cp).ECAN if self.canfd else 0
|
||||
self.length = 16 if self.canfd else 6
|
||||
self.data = None
|
||||
self.stamp = 0
|
||||
|
||||
def update(self, packets):
|
||||
if not self.enabled:
|
||||
return
|
||||
for stamp, frames in packets:
|
||||
for address, data, bus in frames:
|
||||
if address != self.address or bus != self.source_bus:
|
||||
continue
|
||||
if stamp <= self.stamp or len(data) != self.length or (self.canfd and
|
||||
int.from_bytes(data[:2], 'little') != hkg_can_fd_checksum(address, None, data)):
|
||||
self.data = None
|
||||
continue
|
||||
self.data, self.stamp = bytes(data), stamp
|
||||
|
||||
def command(self, now_ns):
|
||||
if (not self.enabled or self.source_bus == self.target_bus or self.data is None or
|
||||
not 0 < self.stamp <= now_ns <= self.stamp + 100_000_000):
|
||||
return []
|
||||
data = bytearray(self.data)
|
||||
if self.canfd:
|
||||
if (data[7] >> 3) & 7 == 1:
|
||||
data[7] &= ~0x38
|
||||
data[:2] = hkg_can_fd_checksum(self.address, None, data).to_bytes(2, 'little')
|
||||
elif 1 <= data[5] & 7 <= 3:
|
||||
data[5] &= ~7
|
||||
return [CanData(self.address, bytes(data), self.target_bus)]
|
||||
@@ -314,7 +314,7 @@ def qualified(cp):
|
||||
cp.alternativeExperience not in (0, 32) or
|
||||
not cp.alphaLongitudinalAvailable or len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyParam != LONG_PARAM):
|
||||
return False
|
||||
if type(cp) in (_DynamicStructBuilder, _DynamicStructReader) and cp.schema == structs.CarParams.schema:
|
||||
if type(cp) is _DynamicStructBuilder and cp.schema == structs.CarParams.schema:
|
||||
return metadata_qualified(cp)
|
||||
stock = stock_copy(cp)
|
||||
stock.alternativeExperience = 0
|
||||
|
||||
@@ -49,11 +49,6 @@ EXISTING_OWNERS_OR_DISTINCT_RECEIVERS = frozenset((
|
||||
CAR.GENESIS_GV80,
|
||||
))
|
||||
|
||||
def carnival_long(cp):
|
||||
return (cp.carFingerprint == CAR.KIA_CARNIVAL_2025 and qualified_long(cp) and
|
||||
cp.safetyConfigs[0].safetyParam in (0x20B4, 0x28B4))
|
||||
|
||||
|
||||
STOCK_WORDS = (0x10, 0x11, 0x12, 0x90, 0x91, 0x92)
|
||||
|
||||
|
||||
@@ -70,8 +65,6 @@ def qualified(cp):
|
||||
HyundaiFlags.CANFD_ALT_GEARS | HyundaiFlags.CANFD_ALT_GEARS_2)
|
||||
if not required(cp) or cp.carFingerprint not in CARS:
|
||||
return False
|
||||
if carnival_long(cp):
|
||||
return cp.alphaLongitudinalAvailable and cp.alternativeExperience == 0
|
||||
factory = CAR(cp.carFingerprint)
|
||||
stock = stock_copy(cp)
|
||||
return (cp.alphaLongitudinalAvailable and cp.alternativeExperience == 0 and not cp.pcmCruise and
|
||||
@@ -98,13 +91,13 @@ class GenericHDA2Startup(RadarHDA2Startup):
|
||||
raise RuntimeError('Ordinary HDA-II finalization requires configured startup')
|
||||
if self.prepared_for(ci.CP):
|
||||
return
|
||||
long_aol = self.outcome in (Outcome.PENDING, Outcome.SENT_UNCONFIRMED) and qualified_long(ci.CP, marked_only=True)
|
||||
long_aol = self.outcome is Outcome.SENT_UNCONFIRMED and qualified_long(ci.CP, marked_only=True)
|
||||
if not long_aol and (self.outcome not in (Outcome.STOCK_UNTOUCHED, Outcome.STOCK_RESTORED) or
|
||||
not stock_qualified(ci.CP, marked_only=True)):
|
||||
raise RuntimeError('Ordinary HDA-II startup does not admit this AOL configuration')
|
||||
expected: Any = deepcopy(self.prepared_cp)
|
||||
configs = expected.get('safetyConfigs', [])
|
||||
allowed_words = (*tuple(word | 4 for word in STOCK_WORDS), 0x20B4) if long_aol else (*STOCK_WORDS, 0x20B0)
|
||||
allowed_words = tuple(word | 4 for word in STOCK_WORDS) if long_aol else STOCK_WORDS
|
||||
if (len(configs) != 1 or configs[0]['safetyModel'] != 'hyundaiCanfd' or
|
||||
configs[0]['safetyParam'] not in allowed_words or expected['alternativeExperience'] != 0):
|
||||
raise RuntimeError('Ordinary HDA-II prepared stock profile cannot be finalized')
|
||||
@@ -114,50 +107,3 @@ class GenericHDA2Startup(RadarHDA2Startup):
|
||||
if ci.CP.to_dict() != expected:
|
||||
raise RuntimeError('Ordinary HDA-II finalization changed unrelated CarParams')
|
||||
self.prepared_cp = expected
|
||||
|
||||
def prepare_when_controls_ready(self, *, admission):
|
||||
if not carnival_long(self.cp):
|
||||
return self.prepare(admission=admission)
|
||||
if self.outcome is not None or self.closed:
|
||||
raise RuntimeError('Carnival startup preparation is single-use')
|
||||
if admission() is not True:
|
||||
return self.prepare(admission=lambda: False)
|
||||
self.admission = admission
|
||||
self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
|
||||
self.outcome = Outcome.PENDING
|
||||
self.prepared_cp = deepcopy(self.cp.to_dict())
|
||||
return self.cp
|
||||
|
||||
def commit_pending(self, ci, *, sources_current, control_current):
|
||||
if (not carnival_long(ci.CP) or self.outcome is not Outcome.PENDING or self.closed or not self.published or
|
||||
ci is not self.ci or not self.prepared_for(ci.CP) or not sources_current or not control_current or
|
||||
self.admission is None or self.admission() is not True):
|
||||
raise RuntimeError('Carnival takeover requires current diagnostic, source and inactive control admission')
|
||||
try:
|
||||
success = self._disable(self.callbacks[0], self._send, bus=self.bus, addr=self.address,
|
||||
com_cont_req=self.communication_control_request())
|
||||
if not success or not self.disable_attempted:
|
||||
raise RuntimeError('Carnival pending ECU disable was not sent successfully')
|
||||
self.outcome = Outcome.SENT_UNCONFIRMED
|
||||
self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
|
||||
from opendbc.car import make_tester_present_msg
|
||||
self.keeper = self.keeper_factory(
|
||||
lambda: self.callbacks[1]([make_tester_present_msg(self.address, self.bus, suppress_response=True)]), clock=self.clock)
|
||||
self.keeper.start()
|
||||
except BaseException:
|
||||
if self.keeper is not None:
|
||||
self.keeper.stop()
|
||||
try:
|
||||
restored = not (self.session_attempted or self.disable_attempted) or self._restore()
|
||||
except Exception:
|
||||
restored = False
|
||||
self.outcome = Outcome.ABORT_UNCERTAIN
|
||||
if not restored:
|
||||
raise RuntimeError('Carnival pending takeover failed; stock restoration unverified') from None
|
||||
raise
|
||||
|
||||
def before_control(self, **context):
|
||||
if self.outcome is Outcome.PENDING:
|
||||
self.check()
|
||||
return False
|
||||
return super().before_control(**context)
|
||||
|
||||
@@ -285,17 +285,6 @@ def create_ioniq6_blindspot_status(counter: int, status: BlindspotStatus) -> lis
|
||||
def create_ccnc(packer, CAN, openpilot_longitudinal, enabled, hud, left_blinker, right_blinker, msg_161, msg_162, msg_1b5,
|
||||
is_metric, out, main_cruise_enabled, lfa_icon):
|
||||
if lfa_icon:
|
||||
# The host owns lateral; keep unrelated faults and obstruction warnings.
|
||||
for field in ("FAULT_LSS", "FAULT_HDA", "FAULT_DAS", "FAULT_LFA", "FAULT_DAW"):
|
||||
if msg_162.get(field) == 1:
|
||||
msg_162[field] = 0
|
||||
if msg_161.get("ALERTS_2") == 5:
|
||||
msg_161.update({"ALERTS_2": 0, "SOUNDS_2": 0})
|
||||
if msg_161.get("ALERTS_3") == 17:
|
||||
msg_161["ALERTS_3"] = 0
|
||||
if msg_161.get("ALERTS_5") in (2, 5):
|
||||
msg_161["ALERTS_5"] = 0
|
||||
msg_161["DAW_ICON"] = 0
|
||||
lane_change_speed_min = 8.9408
|
||||
any_blinker = left_blinker or right_blinker
|
||||
curvature = {i: (31 if i == -1 else 13 - abs(i + 15)) if i < 0 else 15 + i for i in range(-15, 16)}
|
||||
|
||||
@@ -396,9 +396,6 @@ class CarInterface(CarInterfaceBase):
|
||||
return ret
|
||||
|
||||
def update(self, can_packets):
|
||||
if self.CS.daw.enabled:
|
||||
can_packets = list(can_packets)
|
||||
self.CS.daw.update(can_packets)
|
||||
recovery = getattr(getattr(self, 'CC', None), 'angle_tx_recovery', None)
|
||||
if recovery is not None:
|
||||
can_packets = list(can_packets)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from math import isfinite
|
||||
from capnp.lib.capnp import _DynamicStructBuilder, _DynamicStructReader
|
||||
|
||||
from opendbc.car import structs
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
from opendbc.car.hyundai.ccnc_ev_stock import qualified as stock_qualified, metadata_qualified
|
||||
from opendbc.car.hyundai.ccnc_ev_stock import qualified as stock_qualified
|
||||
from opendbc.car.hyundai.ev9_longitudinal import copy_cp
|
||||
|
||||
STOCK_PARAM = 0x5491
|
||||
@@ -37,17 +36,13 @@ def qualified(cp):
|
||||
if (not required(cp) or cp.pcmCruise or not cp.alphaLongitudinalAvailable or
|
||||
cp.alternativeExperience not in (0, 32) or len(cp.safetyConfigs) != 1 or
|
||||
cp.safetyConfigs[0].safetyParam != LONG_PARAM or
|
||||
cp.steerControlType != structs.CarParams.SteerControlType.angle):
|
||||
return False
|
||||
deprecated = cp.deprecated
|
||||
if (not deprecated.startingState or not all(isfinite(value) for value in (deprecated.startAccel, deprecated.vEgoStarting, deprecated.vEgoStopping,
|
||||
deprecated.stoppingDecelRate, cp.longitudinalActuatorDelay)) or
|
||||
abs(deprecated.startAccel - 1.) > 1e-6 or abs(deprecated.vEgoStarting - .4) > 1e-6 or
|
||||
abs(deprecated.vEgoStopping - .3) > 1e-6 or abs(deprecated.stoppingDecelRate - .4) > 1e-6 or
|
||||
cp.steerControlType != structs.CarParams.SteerControlType.angle or not cp.deprecated.startingState or
|
||||
not all(isfinite(value) for value in (cp.deprecated.startAccel, cp.deprecated.vEgoStarting, cp.deprecated.vEgoStopping,
|
||||
cp.deprecated.stoppingDecelRate, cp.longitudinalActuatorDelay)) or
|
||||
abs(cp.deprecated.startAccel - 1.) > 1e-6 or abs(cp.deprecated.vEgoStarting - .4) > 1e-6 or
|
||||
abs(cp.deprecated.vEgoStopping - .3) > 1e-6 or abs(cp.deprecated.stoppingDecelRate - .4) > 1e-6 or
|
||||
abs(cp.longitudinalActuatorDelay - .35) > 1e-6):
|
||||
return False
|
||||
if type(cp) in (_DynamicStructBuilder, _DynamicStructReader) and cp.schema == structs.CarParams.schema:
|
||||
return metadata_qualified(cp)
|
||||
stock = copy_cp(cp)
|
||||
stock.alternativeExperience = 0
|
||||
stock.openpilotLongitudinalControl = False
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import structs
|
||||
from opendbc.car.hyundai.angle_tx_recovery import AngleTxRecovery
|
||||
from opendbc.car.hyundai.carcontroller import angle_torque_reduction_gain
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.tests.test_ev9_long_controller import packets
|
||||
from opendbc.car.hyundai.tests.test_stock_angle_aol import source_profile
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
|
||||
|
||||
@pytest.mark.parametrize('gain', (0.18, 0.532, 0.692, 0.848))
|
||||
def test_inactive_steering_does_not_retain_pre_fault_gain(gain):
|
||||
released = angle_torque_reduction_gain(0, 5., False, gain)
|
||||
assert released == 0., 'INACTIVE_STEERING_RETAINED_GAIN'
|
||||
assert angle_torque_reduction_gain(0, 5., True, released) == 0.004
|
||||
|
||||
|
||||
def profile(identity, long, aol):
|
||||
cp = source_profile(identity)
|
||||
if long:
|
||||
if identity == CAR.HYUNDAI_IONIQ_5_PE:
|
||||
from opendbc.car.hyundai.ioniq5pe_long_profile import candidate
|
||||
else:
|
||||
from opendbc.car.hyundai.ev9_longitudinal import candidate
|
||||
cp = candidate(cp, enabled=True, is_release=False)
|
||||
cp.alternativeExperience = 32 if aol else 0
|
||||
elif aol:
|
||||
cp.safetyConfigs[0].safetyParam |= 0x800 if not cp.safetyConfigs[0].safetyParam & 0x4000 else 0
|
||||
cp.alternativeExperience = 32
|
||||
return cp
|
||||
|
||||
|
||||
def controller(identity, long, aol):
|
||||
cp = profile(identity, long, aol)
|
||||
ci = CarInterface(cp)
|
||||
# Isolate command recovery after the separately tested owner handoff.
|
||||
ci.CC.pe_startup = SimpleNamespace(handed_off=True, can_emit=lambda controller: True)
|
||||
packer = CANPacker(ci.can_parsers[next(iter(ci.can_parsers))].dbc.name)
|
||||
for tick in range(1, 14):
|
||||
frames = packets(packer, tick, advance_counter=True)
|
||||
if not long and tick % 2 == 0:
|
||||
frames.append(packer.make_can_msg('SCC_CONTROL', 1, {'COUNTER': tick // 2, 'ACCMode': 1, 'MainMode_ACC': 1}))
|
||||
ci.update([(tick * 10_000_000, frames)])
|
||||
assert ci.CS.out.canValid
|
||||
return ci, packer
|
||||
|
||||
|
||||
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
|
||||
@pytest.mark.parametrize('long', (False, True))
|
||||
@pytest.mark.parametrize('aol', (False, True))
|
||||
def test_actual_controller_fault_and_full_lateral_withdrawal_restart_gain_from_zero(identity, long, aol):
|
||||
ci, _ = controller(identity, long, aol)
|
||||
cs = ci.CS
|
||||
cs.out.vEgoRaw = cs.out.vEgo = 5.
|
||||
cs.out.steeringAngleDeg = cs.angle_steering_angle = 20.
|
||||
cs.out.steeringPressed = False
|
||||
cs.out.steeringTorque = 0.
|
||||
command = structs.CarControl(enabled=True, latActive=True)
|
||||
command.actuators.steeringAngleDeg = 50.
|
||||
ci.CC.apply_angle_last = 20.
|
||||
ci.CC.angle_gain_last = .692
|
||||
cs.out.steerFaultTemporary = cs.angle_steering_fault = True
|
||||
ci.CC.frame = 1
|
||||
_, inactive = ci.apply(command.as_reader(), 200_000_000)
|
||||
assert ci.CC.angle_gain_last == 0., 'FAULT_RETAINED_PREVIOUS_GAIN'
|
||||
cs.out.steerFaultTemporary = cs.angle_steering_fault = False
|
||||
ci.CC.frame = 3
|
||||
_, output = ci.apply(command.as_reader(), 210_000_000)
|
||||
assert ci.CC.angle_gain_last == .004
|
||||
address = 0xCB if long else 0x110
|
||||
payload = next(data for addr, data, _ in output if addr == address)
|
||||
assert payload[6 if long else 12] == 1
|
||||
command.latActive = command.enabled = False
|
||||
ci.CC.frame = 7
|
||||
ci.apply(command.as_reader(), 220_000_000)
|
||||
assert ci.CC.angle_gain_last == 0.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case', ('current','accepted','wrong_bus','wrong_address','wrong_payload','short','stale','duplicate','inactive'))
|
||||
def test_long_echo_exact_source_freshness_and_one_use(case):
|
||||
now = [1_000_000_000]
|
||||
recovery = AngleTxRecovery(0xCB, 1, clock=lambda: now[0])
|
||||
raw = bytearray(24)
|
||||
raw[3] = 0x10 if case == 'inactive' else 0x20
|
||||
recovery.record([(0xCB, bytes(raw), 1)], 10)
|
||||
now[0] += 31_000_000 if case == 'stale' else 10_000_000
|
||||
if case == 'wrong_payload':
|
||||
raw[6] ^= 1
|
||||
data = bytes(raw[:23] if case == 'short' else raw)
|
||||
bus = 129 if case == 'accepted' else 192 if case == 'wrong_bus' else 193
|
||||
address = 0x110 if case == 'wrong_address' else 0xCB
|
||||
recovery.observe([(now[0], [(address, data, bus)])], 11)
|
||||
assert recovery.take_rejection(11) == (case in ('current','duplicate'))
|
||||
assert not recovery.take_rejection(11)
|
||||
recovery.observe([(now[0]+1, [(address, data, bus)])], 12)
|
||||
assert not recovery.take_rejection(12)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
|
||||
def test_long_controller_consumes_current_rejection_without_retaining_filtered_history(identity):
|
||||
now = [1_000_000_000]
|
||||
with patch('time.monotonic_ns', side_effect=lambda: now[0]):
|
||||
ci, packer = controller(identity, True, True)
|
||||
assert ci.CC.angle_tx_recovery is not None, 'LONG_REJECTION_RECOVERY_MISSING'
|
||||
cs = ci.CS
|
||||
cs.out.vEgo = cs.out.vEgoRaw = 5.
|
||||
cs.out.steeringAngleDeg = cs.angle_steering_angle = -20.
|
||||
command = structs.CarControl(enabled=True, latActive=True)
|
||||
command.actuators.steeringAngleDeg = -100.
|
||||
ci.CC.apply_angle_last = -70.
|
||||
ci.CC.ev9_angle_filter.x = -70.
|
||||
ci.CC.angle_gain_last = .692
|
||||
ci.CC.frame = 21
|
||||
_, frames = ci.apply(command.as_reader(), now[0])
|
||||
emitted = next(frame for frame in frames if frame[0] == 0xCB)
|
||||
now[0] += 10_000_000
|
||||
ci.update([(now[0], [(emitted[0], emitted[1], 193)])])
|
||||
# Current physical angle is the new native limiter baseline after rejection.
|
||||
cs.out.canValid, cs.out.canTimeout = True, False
|
||||
cs.out.steeringAngleDeg = cs.angle_steering_angle = -20.
|
||||
_, frames = ci.apply(command.as_reader(), now[0])
|
||||
assert abs(ci.CC.apply_angle_last + 20.) <= 5.
|
||||
assert ci.CC.angle_gain_last == .004
|
||||
|
||||
|
||||
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
|
||||
@pytest.mark.parametrize('long', (True,))
|
||||
@pytest.mark.parametrize('aol', (False, True))
|
||||
def test_fault_recovery_yields_until_driver_releases_steering(identity, long, aol):
|
||||
ci, _ = controller(identity, long, aol)
|
||||
cs = ci.CS
|
||||
cs.out.vEgoRaw = cs.out.vEgo = 3.
|
||||
cs.out.steeringAngleDeg = cs.angle_steering_angle = 175.
|
||||
cs.out.steeringPressed = True
|
||||
cs.out.steeringTorque = 500.
|
||||
command = structs.CarControl(enabled=not aol, latActive=True)
|
||||
command.actuators.steeringAngleDeg = 360.
|
||||
ci.CC.apply_angle_last = 175.
|
||||
ci.CC.angle_gain_last = .1
|
||||
address, gain_index = (0xCB, 6) if long else (0x110, 12)
|
||||
active_index, active_mask = (3, 15) if long else (9, 3)
|
||||
for frame in range(80):
|
||||
cs.out.steerFaultTemporary = cs.angle_steering_fault = frame % 40 < 16
|
||||
cs.out.steeringAngleDeg = cs.angle_steering_angle = 175. - frame * 3.
|
||||
ci.CC.frame = frame + 21
|
||||
_, output = ci.apply(command.as_reader(), 200_000_000 + frame * 10_000_000)
|
||||
payload = next((data for addr, data, _ in output if addr == address), None)
|
||||
if payload is not None:
|
||||
assert (payload[active_index] >> 4 & active_mask) == 1, 'FAULT_RECOVERY_FIGHTS_DRIVER'
|
||||
assert payload[gain_index] == 0
|
||||
assert ci.CC.apply_angle_last == pytest.approx(cs.out.steeringAngleDeg)
|
||||
cs.out.steerFaultTemporary = cs.angle_steering_fault = False
|
||||
cs.out.steeringPressed = False
|
||||
cs.out.steeringTorque = 0.
|
||||
ci.CC.frame += 1
|
||||
_, output = ci.apply(command.as_reader(), 1_000_000_000)
|
||||
payload = next(data for addr, data, _ in output if addr == address)
|
||||
assert (payload[active_index] >> 4 & active_mask) == 2
|
||||
assert payload[gain_index] == 1
|
||||
assert abs(ci.CC.apply_angle_last - cs.out.steeringAngleDeg) <= 5.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
|
||||
@pytest.mark.parametrize('long', (False, True))
|
||||
def test_driver_pressure_without_fault_keeps_existing_angle_blending(identity, long):
|
||||
ci, _ = controller(identity, long, True)
|
||||
cs = ci.CS
|
||||
cs.out.vEgoRaw = cs.out.vEgo = 3.
|
||||
cs.out.steeringAngleDeg = cs.angle_steering_angle = 20.
|
||||
cs.out.steeringPressed = True
|
||||
cs.out.steeringTorque = 500.
|
||||
command = structs.CarControl(enabled=False, latActive=True)
|
||||
command.actuators.steeringAngleDeg = 40.
|
||||
ci.CC.apply_angle_last = 20.
|
||||
ci.CC.frame = 21
|
||||
_, output = ci.apply(command.as_reader(), 200_000_000)
|
||||
address, active_index, active_mask = (0xCB, 3, 15) if long else (0x110, 9, 3)
|
||||
payload = next(data for addr, data, _ in output if addr == address)
|
||||
assert (payload[active_index] >> 4 & active_mask) == 2
|
||||
@@ -1,215 +0,0 @@
|
||||
"""Keep factory SCC until an inactive producer is current; never bypass native RX."""
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from opendbc.car import Bus, structs
|
||||
from opendbc.car.hyundai.ecu_startup import Outcome
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.radar_interface import RadarInterface
|
||||
from opendbc.car.hyundai.tests.test_torque_ev_startup import Keeper
|
||||
from opendbc.car.hyundai.tests.test_generic_hda2_startup_lifecycle import physical_frames as source_frames
|
||||
from opendbc.car.hyundai.tests.test_ccnc_six import params
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
from opendbc.car.hyundai.canfd_stock_aol import qualified_long
|
||||
from opendbc.car.hyundai.torque_ev_scc import eligible as ev_scc_eligible
|
||||
from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.selfdrive.car.card import Car
|
||||
from openpilot.starpilot.vehicle_startup import VehicleStartupOwner
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", (
|
||||
"healthy_suppressed", "healthy_positive", "stale_control", "active_control", "missing_source",
|
||||
"native_mode", "cruise_fault", "stock_engaged", "disable_negative", "disable_malformed", "restore_failure", "publisher_failure"))
|
||||
def test_actual_card_defers_carnival_until_initialized_and_then_native_rx(scenario):
|
||||
stock = params(CAR.KIA_CARNIVAL_2025, hda2=True, alt_buttons=True, alpha=True, adas=True, alt_lka=True)
|
||||
assert stock.safetyConfigs[0].safetyParam == 0x20B4 and qualified_long(stock) and not ev_scc_eligible(stock)
|
||||
native = libsafety_py.libsafety
|
||||
assert native.set_safety_hooks(int(structs.CarParams.SafetyModel.elm327), 1) == 0
|
||||
native.init_tests()
|
||||
now = [100_000_000_000]
|
||||
requests, emitted = [], []
|
||||
muted = [False]
|
||||
|
||||
def send(frames):
|
||||
for address, data, bus in frames:
|
||||
assert native.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
|
||||
emitted.extend(frames)
|
||||
if scenario == "publisher_failure" and any(data[:4] == b"\x03\x28\x83\x01" for _, data, _ in frames):
|
||||
muted[0] = True
|
||||
raise OSError("diagnostic publisher failed after possible ECU delivery")
|
||||
|
||||
def query(sender, receiver, bus, addresses, commands, responses):
|
||||
command = commands[0]
|
||||
requests.append((now[0], command))
|
||||
|
||||
class Query:
|
||||
def get_data(self, timeout):
|
||||
sender([(0x730, bytes([len(command)]) + command + bytes(7 - len(command)), bus)])
|
||||
if command == b"\x28\x83\x01":
|
||||
muted[0] = True
|
||||
if scenario in ("disable_negative", "restore_failure"):
|
||||
return {(0x730, None): b"\x7f\x28\x22"}
|
||||
if scenario == "disable_malformed":
|
||||
return {(0x730, None): b"\x68"}
|
||||
return {(0x730, None): b"\x68\x03"} if scenario == "healthy_positive" else {}
|
||||
if command == b"\x28\x00\x01":
|
||||
if scenario == "restore_failure":
|
||||
return {}
|
||||
muted[0] = False
|
||||
return {(0x730, None): b""}
|
||||
return Query()
|
||||
|
||||
def sleep(seconds):
|
||||
now[0] += round(seconds * 1e9)
|
||||
|
||||
def update_sm(host, command, stale=False):
|
||||
message = messaging.new_message("carControl", valid=True, logMonoTime=now[0] - (151_000_000 if stale else 0))
|
||||
message.carControl = command
|
||||
panda = messaging.new_message("pandaStates", 1, valid=True, logMonoTime=now[0])
|
||||
state = panda.pandaStates[0]
|
||||
state.safetyModel, state.safetyParam = native.get_current_safety_mode(), native.get_current_safety_param()
|
||||
state.alternativeExperience = host.CP.alternativeExperience
|
||||
state.ignitionLine = True
|
||||
state.safetyRxChecksInvalid = native.safety_test_toyota_rx_summary_invalid()
|
||||
host.sm.update_msgs(now[0] / 1e9, [message.as_reader(), panda.as_reader()])
|
||||
|
||||
with OpenpilotPrefix(), patch.dict(os.environ, {"SIMULATION": "0", "AOL_REPLAY_RUNTIME": "1"}), \
|
||||
patch("time.clock_gettime_ns", side_effect=lambda clock: now[0]), \
|
||||
patch("time.monotonic_ns", side_effect=lambda: now[0]), \
|
||||
patch("time.monotonic", side_effect=lambda: now[0] / 1e9), \
|
||||
patch("time.sleep", side_effect=sleep):
|
||||
saved = Params()
|
||||
for key in ("OpenpilotEnabledToggle", "AlwaysOnLateral", "AlphaLongitudinalEnabled"):
|
||||
saved.put_bool(key, True, block=True)
|
||||
holder = VehicleStartupOwner()
|
||||
prepared = holder.prepare(stock, CarInterface, (list, send), requested=True,
|
||||
admission=lambda: True,
|
||||
defer_until_controls=True)
|
||||
startup = holder.owner
|
||||
assert startup is not None
|
||||
startup.clock, startup.keeper_factory, startup._query = lambda: now[0] / 1e9, Keeper, query
|
||||
try:
|
||||
assert startup.outcome is Outcome.PENDING and not requests and not emitted
|
||||
assert not startup.before_control(configured=True, sources_current=True, control_current=True)
|
||||
card = Car(CI=CarInterface(prepared), RI=RadarInterface(prepared), startup_owner=startup)
|
||||
startup.admission = card.startup_diagnostic_admission
|
||||
card.params.put_bool("IsOffroad", False, block=True)
|
||||
original_cp = card.CP.to_dict()
|
||||
assert card.CP.safetyConfigs[0].safetyParam == 0x28B4 and card.CP.alternativeExperience == 32
|
||||
assert not ev_scc_eligible(card.CP)
|
||||
# Explicit synthetic delayed producer: stock has not been told to stop.
|
||||
for tick in range(1, 701):
|
||||
now[0] += 10_000_000
|
||||
state = card.CI.update([(now[0], [f for f in source_frames(card.CI, tick) if f[0] not in (0x1A0, 0x1CF, 0x160)])])
|
||||
assert state.canValid and not state.canTimeout and not state.accFaulted
|
||||
assert not requests and not muted[0] and startup.keeper is None
|
||||
command = structs.CarControl(enabled=scenario == "active_control", latActive=scenario == "active_control").as_reader()
|
||||
if scenario == "missing_source":
|
||||
parser = card.CI.can_parsers[Bus.pt]
|
||||
parser.message_states[parser.dbc.name_to_msg["TCS"].address].timestamps.clear()
|
||||
if scenario == "cruise_fault":
|
||||
state.accFaulted = True
|
||||
if scenario == "stock_engaged":
|
||||
state.cruiseState.enabled = True
|
||||
config = card.CP.safetyConfigs[-1]
|
||||
if scenario == "native_mode":
|
||||
native.set_alternative_experience(card.CP.alternativeExperience)
|
||||
assert native.set_safety_hooks(int(config.safetyModel.raw), int(config.safetyParam)) == 0
|
||||
update_sm(card, command, stale=scenario == "stale_control")
|
||||
card.controls_update(state, command, initialize_only=True)
|
||||
assert not requests and not muted[0] and not card.ci_initialized
|
||||
if scenario in ("disable_negative", "disable_malformed", "restore_failure", "publisher_failure"):
|
||||
with pytest.raises(RuntimeError, match="pending"):
|
||||
card.controls_update(state, command)
|
||||
assert startup.outcome is Outcome.ABORT_UNCERTAIN and not card.ci_initialized
|
||||
assert card.CP.to_dict() == original_cp and not startup.handed_off
|
||||
assert muted[0] == (scenario == "restore_failure")
|
||||
assert startup.restored == (scenario != "restore_failure")
|
||||
return
|
||||
card.controls_update(state, command)
|
||||
if not scenario.startswith("healthy"):
|
||||
assert not requests and not muted[0] and not card.ci_initialized
|
||||
assert startup.outcome is Outcome.PENDING and card.CP.to_dict() == original_cp
|
||||
return
|
||||
assert [command for _, command in requests] == [b"\x10\x03", b"\x28\x83\x01"]
|
||||
assert muted[0] and card.ci_initialized and startup.outcome is Outcome.SENT_UNCONFIRMED
|
||||
assert startup.keeper.started and not startup.handed_off
|
||||
# No SCC while native is diagnostic, mismatched, or awaiting RX health.
|
||||
emitted.clear()
|
||||
card.controls_update(state, command)
|
||||
assert not emitted and not startup.handed_off
|
||||
native.set_alternative_experience(card.CP.alternativeExperience)
|
||||
assert native.set_safety_hooks(int(config.safetyModel.raw), int(config.safetyParam)) == 0
|
||||
native.init_tests()
|
||||
update_sm(card, command)
|
||||
card.controls_update(state, command)
|
||||
assert not emitted and not startup.handed_off
|
||||
for tick in range(701, 811):
|
||||
now[0] += 10_000_000
|
||||
frames = [f for f in source_frames(card.CI, tick) if f[0] not in (0x1A0, 0x1CF, 0x160)]
|
||||
state = card.CI.update([(now[0], frames)])
|
||||
native.set_timer(tick * 10_000)
|
||||
for address, data, bus in frames:
|
||||
native.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, data))
|
||||
if tick % 100 == 0:
|
||||
native.safety_tick()
|
||||
assert native.safety_config_valid()
|
||||
update_sm(card, command)
|
||||
captured = []
|
||||
|
||||
def publish(frames, valid=True):
|
||||
if valid:
|
||||
send(frames)
|
||||
card.vehicle_startup.sent(frames, valid=valid)
|
||||
captured.extend(frames)
|
||||
|
||||
with patch.object(card, "publish_sendcan", side_effect=publish):
|
||||
card.controls_update(state, command)
|
||||
assert any(address == 0x1A0 and bus == 1 for address, _, bus in captured)
|
||||
decoder = card.CI.can_parsers[Bus.pt]
|
||||
decoder.update((now[0], captured))
|
||||
scc = decoder.vl['SCC_CONTROL']
|
||||
assert scc['ACCMode'] == 0 and scc['aReqRaw'] == 0 and scc['aReqValue'] == 0
|
||||
assert startup.handed_off and not startup.keeper.started
|
||||
assert not native.get_controls_allowed() and native.aol_get_permission_mask() == 0
|
||||
assert card.CP.to_dict() == original_cp
|
||||
finally:
|
||||
holder.close()
|
||||
|
||||
|
||||
def test_refused_takeover_preserves_exact_stock_aol_and_sends_no_disable():
|
||||
from opendbc.car.hyundai.ioniq6_handoff import TimestampedCanPacket
|
||||
from opendbc.car.hyundai.generic_hda2_startup import GenericHDA2Startup
|
||||
from opendbc.car.hyundai.canfd_stock_aol import qualified as stock_qualified
|
||||
|
||||
cp = params(CAR.KIA_CARNIVAL_2025, hda2=True, alt_buttons=True, alpha=True, adas=True, alt_lka=True)
|
||||
now, tick, emitted = [100_000_000_000], [0], []
|
||||
startup = None
|
||||
|
||||
def receive():
|
||||
tick[0] += 1
|
||||
now[0] += 10_000_000
|
||||
return [TimestampedCanPacket(source_frames(startup.ci, tick[0]), now[0])]
|
||||
|
||||
with patch('time.clock_gettime_ns', side_effect=lambda clock: now[0]), \
|
||||
patch('time.monotonic', side_effect=lambda: now[0] / 1e9), patch('time.sleep'):
|
||||
startup = GenericHDA2Startup(cp, (receive, emitted.extend), clock=lambda: now[0] / 1e9, keeper_factory=Keeper)
|
||||
prepared = startup.prepare_when_controls_ready(admission=lambda: False)
|
||||
assert not prepared.openpilotLongitudinalControl and prepared.pcmCruise and not emitted
|
||||
ci = CarInterface(prepared)
|
||||
try:
|
||||
startup.configure(ci)
|
||||
assert startup.ready and ci.CS.out.canValid and startup.sources_current(ci, now[0])
|
||||
ci.CP.safetyConfigs[0].safetyParam |= 0x800
|
||||
assert ci.CP.safetyConfigs[0].safetyParam == 0x28B0 and stock_qualified(ci.CP, marked_only=True)
|
||||
startup.finalize_aol_configuration(ci)
|
||||
startup.seal_publication()
|
||||
assert startup.prepared_for(ci.CP) and not emitted
|
||||
assert startup.outcome is Outcome.STOCK_UNTOUCHED and startup.keeper is None
|
||||
finally:
|
||||
startup.close()
|
||||
@@ -1,51 +0,0 @@
|
||||
import pytest
|
||||
from opendbc.car import Bus
|
||||
from opendbc.car.hyundai.tests.test_ccnc_six import params
|
||||
from opendbc.car.hyundai.values import CAR, HyundaiFlags
|
||||
from opendbc.car.hyundai.canfd_stock_aol import qualified_long
|
||||
from opendbc.car.hyundai.generic_hda2_startup import qualified
|
||||
from opendbc.car.hyundai.carstate import CarState
|
||||
|
||||
|
||||
def carnival():
|
||||
return params(CAR.KIA_CARNIVAL_2025, hda2=True, alt_buttons=True, alpha=True, adas=True, alt_lka=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('word,experience,expected', [
|
||||
(0x20B4,0,True), (0x28B4,32,True), (0x28B4,0,False), (0x20B4,32,False),
|
||||
(0x28B6,32,False), (0x28B0,32,False), (0x8B4,32,False), (0x28B5,32,False),
|
||||
])
|
||||
def test_exact_profile(word, experience, expected):
|
||||
cp = carnival()
|
||||
cp.safetyConfigs[0].safetyParam, cp.alternativeExperience = word, experience
|
||||
assert qualified_long(cp) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mutation', ['hybrid','other_car','passive','dashcam','wrong_model','angle','other_bus'])
|
||||
def test_reject_other_configuration(mutation):
|
||||
cp = carnival()
|
||||
if mutation == 'hybrid':
|
||||
cp.flags = int(cp.flags | HyundaiFlags.HYBRID)
|
||||
elif mutation == 'other_car':
|
||||
cp.carFingerprint = CAR.KIA_CARNIVAL_HEV_4TH_GEN
|
||||
elif mutation == 'passive':
|
||||
cp.passive = True
|
||||
elif mutation == 'dashcam':
|
||||
cp.dashcamOnly = True
|
||||
elif mutation == 'angle':
|
||||
cp.flags = int(cp.flags | HyundaiFlags.CANFD_ANGLE_STEERING)
|
||||
elif mutation == 'other_bus':
|
||||
cp.safetyConfigs = [dict(safetyModel='noOutput'),dict(safetyModel='hyundaiCanfd',safetyParam=0x20B4)]
|
||||
else:
|
||||
cp.safetyConfigs[0].safetyModel = 'noOutput'
|
||||
assert not qualified(cp)
|
||||
|
||||
|
||||
def test_takeover_health_binds_physical_sources_not_removed_scc():
|
||||
cp = carnival()
|
||||
parser = CarState(cp).get_can_parsers(cp)[Bus.pt]
|
||||
assert parser.message_states[0x1AA].frequency == 50
|
||||
assert 0x1CF not in parser.addresses
|
||||
assert not parser.message_states[0x1A0].frequency > 0
|
||||
for address in (0x100,0x175,0xA0,0xEA):
|
||||
assert parser.message_states[address].frequency > 0
|
||||
@@ -1,72 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from opendbc.car.hyundai import ev9_longitudinal, ioniq5pe_long_profile
|
||||
from opendbc.car.hyundai.tests.test_stock_angle_aol import source_profile
|
||||
from opendbc.car.hyundai.values import CAR, HyundaiFlags
|
||||
|
||||
|
||||
def profile(identity):
|
||||
module = ioniq5pe_long_profile if identity == CAR.HYUNDAI_IONIQ_5_PE else ev9_longitudinal
|
||||
cp = module.candidate(source_profile(identity), enabled=True, is_release=False)
|
||||
return module, cp
|
||||
|
||||
|
||||
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
|
||||
@pytest.mark.parametrize('reader', (False, True))
|
||||
def test_canonical_long_validation_does_not_clone_carparams(identity, reader):
|
||||
module, cp = profile(identity)
|
||||
cp.alternativeExperience = 32
|
||||
value = cp.as_reader() if reader else cp
|
||||
before = cp.to_dict()
|
||||
with patch.object(module, 'copy_cp', side_effect=AssertionError('CYCLIC_LONG_QUALIFICATION_CLONED_CP')):
|
||||
assert module.qualified(value)
|
||||
cp.passive = True
|
||||
assert not module.qualified(value)
|
||||
cp.passive = False
|
||||
assert module.qualified(value)
|
||||
assert cp.to_dict() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
|
||||
@pytest.mark.parametrize('mutation', ('passive','dashcam','notcar','brand','fuel','long','pcm','experience','available','word','model','slots'))
|
||||
def test_every_admission_field_is_still_checked_on_every_call(identity, mutation):
|
||||
module, cp = profile(identity)
|
||||
assert module.qualified(cp)
|
||||
if mutation == 'passive':
|
||||
cp.passive = True
|
||||
elif mutation == 'dashcam':
|
||||
cp.dashcamOnly = True
|
||||
elif mutation == 'notcar':
|
||||
cp.notCar = True
|
||||
elif mutation == 'brand':
|
||||
cp.brand = 'toyota'
|
||||
elif mutation == 'fuel':
|
||||
cp.flags = int(cp.flags | HyundaiFlags.HYBRID)
|
||||
elif mutation == 'long':
|
||||
cp.openpilotLongitudinalControl = False
|
||||
elif mutation == 'pcm':
|
||||
cp.pcmCruise = True
|
||||
elif mutation == 'experience':
|
||||
cp.alternativeExperience = 33
|
||||
elif mutation == 'available':
|
||||
cp.alphaLongitudinalAvailable = False
|
||||
elif mutation == 'word':
|
||||
cp.safetyConfigs[0].safetyParam ^= 4
|
||||
elif mutation == 'model':
|
||||
cp.safetyConfigs[0].safetyModel = 'noOutput'
|
||||
else:
|
||||
cp.safetyConfigs = [cp.safetyConfigs[0].to_dict(), cp.safetyConfigs[0].to_dict()]
|
||||
assert not module.qualified(cp)
|
||||
assert not module.qualified(cp.as_reader())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value', [('startingState',False),('startAccel',.5),('vEgoStarting',.5),('vEgoStopping',.4),
|
||||
('stoppingDecelRate',.5),('startAccel',float('nan')),('startAccel',float('inf'))])
|
||||
def test_pe_frozen_longitudinal_values_still_refuse_mutation(field, value):
|
||||
module, cp = profile(CAR.HYUNDAI_IONIQ_5_PE)
|
||||
assert module.qualified(cp)
|
||||
setattr(cp.deprecated, field, value)
|
||||
assert not module.qualified(cp)
|
||||
assert not module.qualified(cp.as_reader())
|
||||
@@ -131,7 +131,7 @@ class TestCcncSix(unittest.TestCase):
|
||||
safety.set_safety_hooks(CarParams.SafetyModel.hyundaiCanfd, cp.safetyConfigs[-1].safetyParam)
|
||||
safety.init_tests()
|
||||
bus = can.ECAN
|
||||
source_values: dict[str, float] = {"COUNTER": 17, "SET_ME_1": 1, "CRUISE_BUTTONS": 0, "BYTE6": 0x55}
|
||||
source_values = {"COUNTER": 17, "SET_ME_1": 1, "CRUISE_BUTTONS": 0, "BYTE6": 0x55}
|
||||
addr, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
|
||||
data = bytearray(raw)
|
||||
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
|
||||
@@ -163,12 +163,6 @@ class TestCcncSix(unittest.TestCase):
|
||||
active_source[0:2] = hkg_can_fd_checksum(addr, None, active_source).to_bytes(2, "little")
|
||||
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, active_source)))
|
||||
self.assertFalse(safety.safety_tx_hook(tx))
|
||||
source["COUNTER"] = source_values["COUNTER"] = 19
|
||||
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
|
||||
data = bytearray(raw)
|
||||
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
|
||||
resume = create_carnival_alt_resume(packer, cp, can, source)
|
||||
tx = libsafety_py.make_CANPacket(resume[0], resume[2], resume[1])
|
||||
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, bytes(data))))
|
||||
wrong_counter = bytearray(resume[1])
|
||||
wrong_counter[2] += 1
|
||||
@@ -183,8 +177,8 @@ class TestCcncSix(unittest.TestCase):
|
||||
safety.set_timer(0)
|
||||
self.assertTrue(safety.safety_tx_hook(tx))
|
||||
|
||||
source["COUNTER"] = 20
|
||||
source_values["COUNTER"] = 20
|
||||
source["COUNTER"] = 19
|
||||
source_values["COUNTER"] = 19
|
||||
source_values["ADAPTIVE_CRUISE_MAIN_BTN"] = 1
|
||||
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
|
||||
data = bytearray(raw)
|
||||
@@ -232,37 +226,3 @@ class TestCcncSix(unittest.TestCase):
|
||||
wrap_resume = create_carnival_alt_resume(packer, cp, can, {**source, "COUNTER": 255, "CRUISE_BUTTONS": 0})
|
||||
self.assertEqual(wrap_resume[1][2], 0)
|
||||
self.assertTrue(safety.safety_tx_hook(libsafety_py.make_CANPacket(wrap_resume[0], wrap_resume[2], wrap_resume[1])))
|
||||
|
||||
def test_ccnc_owned_warnings_preserve_critical_and_obstruction_fields(self):
|
||||
from opendbc.can import CANParser
|
||||
from opendbc.car import structs
|
||||
from opendbc.car.hyundai.hyundaicanfd import create_ccnc
|
||||
|
||||
packer = CANPacker('hyundai_canfd_generated')
|
||||
cp = params(CAR.KIA_CARNIVAL_HEV_4TH_GEN, alt_buttons=True)
|
||||
critical = ('FAULT_FSS', 'FAULT_FCA', 'FAULT_SCC', 'FAULT_ESS')
|
||||
owned = ('FAULT_LSS', 'FAULT_HDA', 'FAULT_DAS', 'FAULT_LFA', 'FAULT_DAW')
|
||||
for active in (False, True):
|
||||
for fault_value in (1, 2, 3):
|
||||
for alert in (5, 7, 8, 9, 10, 14, 21):
|
||||
with self.subTest(active=active, fault_value=fault_value, alert=alert):
|
||||
first = {name: 0 for name in packer.dbc.name_to_msg['CCNC_0x161'].sigs}
|
||||
second = {name: 0 for name in packer.dbc.name_to_msg['CCNC_0x162'].sigs}
|
||||
first.update(COUNTER=17, ALERTS_2=alert, SOUNDS_2=3, DAW_ICON=1, ALERTS_3=17, ALERTS_5=5)
|
||||
second.update({name: fault_value for name in (*critical, *owned)})
|
||||
second['COUNTER'] = 23
|
||||
hud = structs.CarControl.HUDControl()
|
||||
state = structs.CarState(vEgo=20., steeringAngleDeg=0.)
|
||||
frames = create_ccnc(packer, CanBus(cp), False, active, hud, False, False,
|
||||
first, second, None, True, state, True, active)
|
||||
parser = CANParser('hyundai_canfd_generated', [('CCNC_0x161', 0), ('CCNC_0x162', 0)], CanBus(cp).ECAN)
|
||||
parser.update((1_000_000_000, frames))
|
||||
a, b = parser.vl['CCNC_0x161'], parser.vl['CCNC_0x162']
|
||||
self.assertEqual(a['ALERTS_2'], 0 if active and alert == 5 else alert)
|
||||
self.assertEqual(a['SOUNDS_2'], 0 if active and alert == 5 else 3)
|
||||
self.assertEqual(a['DAW_ICON'], 0 if active else 1)
|
||||
self.assertEqual((a['COUNTER'], b['COUNTER']), (17, 23))
|
||||
for name in critical:
|
||||
self.assertEqual(b[name], fault_value)
|
||||
for name in owned:
|
||||
self.assertEqual(b[name], 0 if active and fault_value == 1 else fault_value)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from opendbc.car import CanData, gen_empty_fingerprint, structs
|
||||
from opendbc.car.hyundai.daw import DawSource
|
||||
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.tests.test_ioniq6_stock_parser import params as ioniq6_params
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
|
||||
BASE = 1_000_000_000
|
||||
|
||||
|
||||
def params(canfd):
|
||||
return ioniq6_params(True)[0] if canfd else CarInterface.get_params(
|
||||
CAR.HYUNDAI_SONATA_HYBRID, gen_empty_fingerprint(), [], False, False, False)
|
||||
|
||||
|
||||
def source(canfd, status):
|
||||
data = bytearray.fromhex('1234567898f8') if not canfd else bytearray(range(16))
|
||||
if canfd:
|
||||
data[7] = (data[7] & ~0x38) | (status << 3)
|
||||
data[:2] = hkg_can_fd_checksum(0x11A, None, data).to_bytes(2, 'little')
|
||||
else:
|
||||
data[5] = (data[5] & ~7) | status
|
||||
return bytes(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', range(8))
|
||||
def test_exact_current_advisory_only_transform(status):
|
||||
canfd = False
|
||||
owner = DawSource(params(canfd))
|
||||
data = source(canfd, status)
|
||||
assert not owner.command(BASE)
|
||||
owner.update([(BASE, [CanData(owner.address, data, owner.source_bus)])])
|
||||
command, = owner.command(BASE)
|
||||
expected = bytearray(data)
|
||||
if canfd:
|
||||
if status == 1:
|
||||
expected[7] &= ~0x38
|
||||
expected[:2] = hkg_can_fd_checksum(owner.address, None, expected).to_bytes(2, 'little')
|
||||
elif 1 <= status <= 3:
|
||||
expected[5] &= ~7
|
||||
assert command == CanData(owner.address, bytes(expected), owner.target_bus)
|
||||
assert owner.data == data
|
||||
assert owner.command(BASE + 100_000_000)
|
||||
assert not owner.command(BASE + 100_000_001)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('canfd,fault', [(canfd, fault) for canfd in (False, True)
|
||||
for fault in ('future', 'old', 'reversed', 'length', 'returned', 'wrong_bus', 'wrong_address', 'crc')
|
||||
if canfd or fault != 'crc'])
|
||||
def test_bad_or_missing_sources_cannot_create_replacements(canfd, fault):
|
||||
owner = DawSource(params(canfd))
|
||||
data = source(canfd, 1)
|
||||
stamp, bus, addr = BASE, owner.source_bus, owner.address
|
||||
if fault == 'length':
|
||||
data = data[:-1]
|
||||
elif fault == 'returned':
|
||||
bus += 128
|
||||
elif fault == 'wrong_bus':
|
||||
bus = 0 if canfd else 1
|
||||
elif fault == 'wrong_address':
|
||||
addr += 1
|
||||
elif fault == 'crc':
|
||||
data = bytes([data[0] ^ 1]) + data[1:]
|
||||
elif fault == 'reversed':
|
||||
owner.update([(BASE + 1, [CanData(addr, data, bus)])])
|
||||
owner.update([(stamp, [CanData(addr, data, bus)])])
|
||||
now = BASE - 1 if fault == 'future' else BASE + 100_000_001 if fault == 'old' else BASE
|
||||
assert not owner.command(now)
|
||||
|
||||
|
||||
def test_actual_parser_and_controller_keep_optional_message_out_of_driving_health():
|
||||
cp = params(False)
|
||||
before = cp.to_dict()
|
||||
first, second = CarInterface(cp.as_reader().as_builder()), CarInterface(cp.as_reader().as_builder())
|
||||
data = bytes.fromhex('000000000883')
|
||||
plain = first.update([(BASE, [])])
|
||||
observed = second.update([(BASE, [CanData(0x53E, data, 2)])])
|
||||
assert observed.to_dict() == plain.to_dict()
|
||||
control = structs.CarControl()
|
||||
baseline = first.apply(control.as_reader(), BASE)[1]
|
||||
actual = second.apply(control.as_reader(), BASE)[1]
|
||||
assert [frame for frame in actual if frame[0] != 0x53E] == baseline
|
||||
assert [frame for frame in actual if frame[0] == 0x53E] == [CanData(0x53E, bytes.fromhex('000000000880'), 0)]
|
||||
assert cp.to_dict() == before
|
||||
|
||||
|
||||
def test_unrelated_canfd_profiles_do_not_emit_new_frames():
|
||||
cp = CarInterface.get_params(CAR.KIA_EV6, gen_empty_fingerprint(), [], False, False, False)
|
||||
owner = DawSource(cp)
|
||||
assert not owner.enabled
|
||||
owner.update([(BASE, [CanData(0x11A, source(True, 1), 1)])])
|
||||
assert not owner.command(BASE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('word', (0, 1, 9, 0x15, 0x5491, 0x5495))
|
||||
def test_unsupported_ioniq6_native_profiles_cannot_emit_advisories(word):
|
||||
cp = params(True)
|
||||
cp.safetyConfigs[-1].safetyParam = word
|
||||
owner = DawSource(cp)
|
||||
assert not owner.enabled
|
||||
owner.update([(BASE, [CanData(0x11A, source(True, 1), owner.source_bus)])])
|
||||
assert not owner.command(BASE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('word', (0x11, 0x91, 0x811, 0x891, 0x8015, 0x8095, 0x8815, 0x8895))
|
||||
@pytest.mark.parametrize('status', range(8))
|
||||
def test_live_same_bus_canfd_source_is_never_retransmitted(word, status):
|
||||
cp = params(True)
|
||||
cp.safetyConfigs[-1].safetyParam = word
|
||||
owner = DawSource(cp)
|
||||
assert owner.enabled and owner.source_bus == owner.target_bus
|
||||
for tick in range(20):
|
||||
stamp = BASE + tick * 10_000_000
|
||||
data = bytearray(source(True, status))
|
||||
data[2] = tick
|
||||
data[:2] = hkg_can_fd_checksum(owner.address, None, data).to_bytes(2, 'little')
|
||||
owner.update([(stamp, [CanData(owner.address, bytes(data), owner.source_bus)])])
|
||||
assert owner.data == bytes(data)
|
||||
assert owner.command(stamp) == []
|
||||
assert owner.command(stamp + 5_000_000) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('alternate', (False, True))
|
||||
def test_actual_ioniq6_controller_keeps_live_source_off_sendcan(alternate):
|
||||
cp, _ = ioniq6_params(alternate)
|
||||
before = cp.to_dict()
|
||||
first, second = CarInterface(cp.as_reader().as_builder()), CarInterface(cp.as_reader().as_builder())
|
||||
data = source(True, 1)
|
||||
plain = first.update([(BASE, [])])
|
||||
observed = second.update([(BASE, [CanData(0x11A, data, 1)])])
|
||||
assert observed.to_dict() == plain.to_dict()
|
||||
control = structs.CarControl()
|
||||
baseline = first.apply(control.as_reader(), BASE)[1]
|
||||
actual = second.apply(control.as_reader(), BASE)[1]
|
||||
assert actual == baseline
|
||||
assert all(frame[0] != 0x11A for frame in actual)
|
||||
assert cp.to_dict() == before
|
||||
@@ -271,16 +271,16 @@ class TestEV9FreshMetadataQualification(unittest.TestCase):
|
||||
cp.safetyConfigs[0].safetyParam ^= 1
|
||||
self.assertTrue(qualified(cp))
|
||||
|
||||
def test_typed_builder_and_reader_avoid_copy_with_original_semantics(self):
|
||||
def test_valid_builder_avoids_copy_and_reader_keeps_copy_contract(self):
|
||||
from unittest.mock import patch
|
||||
from opendbc.car.hyundai import ev9_longitudinal
|
||||
cp = self.base()
|
||||
with patch.object(ev9_longitudinal, 'stock_copy', side_effect=AssertionError('unexpected whole CP copy')):
|
||||
self.assertTrue(qualified(cp))
|
||||
with structs.CarParams.from_bytes(cp.to_bytes()) as reader:
|
||||
expected = self.original(reader)
|
||||
with patch.object(ev9_longitudinal, 'stock_copy', side_effect=AssertionError('unexpected whole CP copy')):
|
||||
self.assertEqual(qualified(reader), expected)
|
||||
with patch.object(ev9_longitudinal, 'stock_copy', wraps=ev9_longitudinal.stock_copy) as copied:
|
||||
self.assertTrue(qualified(reader))
|
||||
self.assertEqual(copied.call_count, 1)
|
||||
|
||||
def test_custom_owner_keeps_original_access_and_copy_behavior(self):
|
||||
cp = self.base()
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestHyundaiFingerprint(unittest.TestCase):
|
||||
self.assertEqual(output_parser.vl["CCNC_0x162"]["COUNTER"], 9)
|
||||
self.assertEqual(output_parser.vl["CCNC_0x161"]["BACKGROUND"], 3)
|
||||
self.assertEqual(output_parser.vl["CCNC_0x162"]["SPEEDLIMIT"], 60)
|
||||
self.assertEqual(output_parser.vl["CCNC_0x162"]["FAULT_DAS"], 0)
|
||||
self.assertEqual(output_parser.vl["CCNC_0x162"]["FAULT_DAS"], 1)
|
||||
self.assertEqual(output_parser.vl["CCNC_0x162"]["LEAD_DISTANCE"], 47 if alpha_long else 25)
|
||||
for signal, expected in (("ALERTS_3", 7), ("ALERTS_5", 4), ("SOUNDS_3", 5)):
|
||||
self.assertEqual(output_parser.vl["CCNC_0x161"][signal], expected)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import Bus, CanData, gen_empty_fingerprint, structs
|
||||
from opendbc.car.common.conversions import Conversions as CV
|
||||
from opendbc.car.hyundai.carcontroller import CarController
|
||||
from opendbc.car.hyundai.hyundaicanfd import create_ioniq6_blindspot_status, hkg_can_fd_checksum
|
||||
from opendbc.car.hyundai.ioniq6_bsm import BlindspotStatus, Ioniq6BlindspotSources
|
||||
@@ -120,7 +118,6 @@ class TestIoniq6BlindspotStatus(unittest.TestCase):
|
||||
t = 10_000_000_000 # CAN BOOTTIME; controller below sees MONOTONIC.
|
||||
ci.update([(t, [packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1, {"SIDE_DETECT_STATE": 0x10}),
|
||||
packer.make_can_msg("BLINKERS", 1, {})])])
|
||||
ci.CS.out.vEgoRaw = 10.0
|
||||
control = structs.CarControl()
|
||||
_, frames = controller.update(control.as_reader(), ci.CS, t - 9_000_000_000)
|
||||
rear_front = [(addr, payload, bus) for addr, payload, bus in frames if addr in (0x1BA, 0x1E5)]
|
||||
@@ -129,7 +126,6 @@ class TestIoniq6BlindspotStatus(unittest.TestCase):
|
||||
controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + i * 10_000_000)
|
||||
ci.update([(t + 50_000_000, [packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1, {"SIDE_DETECT_STATE": 0x10}),
|
||||
packer.make_can_msg("BLINKERS", 1, {})])])
|
||||
ci.CS.out.vEgoRaw = 10.0
|
||||
_, next_frames = controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + 50_000_000)
|
||||
self.assertEqual([(addr, payload, bus) for addr, payload, bus in next_frames if addr in (0x1BA, 0x1E5)],
|
||||
create_ioniq6_blindspot_status(1, BlindspotStatus(1, 0)))
|
||||
@@ -140,133 +136,3 @@ class TestIoniq6BlindspotStatus(unittest.TestCase):
|
||||
ci.update([(t + 160_000_000, [packer.make_can_msg("WHEEL_SPEEDS", 1, {})])])
|
||||
_, stale = controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + 160_000_000)
|
||||
self.assertFalse(any(addr in (0x1BA, 0x1E5) for addr, _, _ in stale))
|
||||
|
||||
def test_controller_low_speed_indication_boundary_and_source_preservation(self):
|
||||
cp = long_params()
|
||||
ci = CarInterface(cp)
|
||||
controller = CarController(DBC[cp.carFingerprint], cp)
|
||||
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
|
||||
control = structs.CarControl()
|
||||
controller.ioniq6_bsm_counter = 250 # Exercise rollover while crossing the speed threshold.
|
||||
threshold = 5 * CV.MPH_TO_MS
|
||||
# Include repeated crossings; each fresh pair keeps the rolling counter.
|
||||
speeds = (0.0, threshold - 1e-6, threshold, threshold + 1e-6, 10.0, 0.0, threshold)
|
||||
for left, right in ((False, False), (True, False), (False, True), (True, True)):
|
||||
for left_lamp, right_lamp in ((False, False), (True, False), (False, True), (True, True)):
|
||||
for speed in speeds:
|
||||
with self.subTest(left=left, right=right, lamps=(left_lamp, right_lamp), speed=speed):
|
||||
t = 10_000_000_000 + controller.frame * 10_000_000
|
||||
ci.update([(t, [packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1,
|
||||
{"SIDE_DETECT_STATE": (0x10 if left else 0) | (0x08 if right else 0)}),
|
||||
packer.make_can_msg("BLINKERS", 1, {})])])
|
||||
# Exact float boundary without Cap'n Proto Float32 quantization.
|
||||
out = ci.CS.out
|
||||
ci.CS.out = SimpleNamespace(**{name: getattr(out, name) for name in out.schema.fields})
|
||||
ci.CS.out.vEgoRaw = speed
|
||||
sources = Ioniq6BlindspotSources()
|
||||
sources.observe(corner_source_ns=t, corner_state=(0x10 if left else 0) | (0x08 if right else 0),
|
||||
lamp_source_ns=t, left_lamp=left_lamp, right_lamp=right_lamp)
|
||||
ci.CS.ioniq6_bsm_sources = sources
|
||||
source_status = sources.status(t)
|
||||
assert source_status is not None
|
||||
counter = controller.ioniq6_bsm_counter
|
||||
_, frames = controller.update(control.as_reader(), ci.CS, t)
|
||||
status_frames = [(addr, payload, bus) for addr, payload, bus in frames if addr in (0x1BA, 0x1E5)]
|
||||
expected = (BlindspotStatus(0, 0) if speed < threshold or 2 in (source_status.left, source_status.right)
|
||||
else source_status)
|
||||
self.assertEqual(status_frames, create_ioniq6_blindspot_status(counter, expected))
|
||||
self.assertEqual(sources.status(t), source_status)
|
||||
for addr, payload, _ in status_frames:
|
||||
self.assertEqual(payload[2], counter)
|
||||
self.assertEqual(int.from_bytes(payload[:2], "little"), hkg_can_fd_checksum(addr, None, bytearray(payload)))
|
||||
# The speed filter does not change the existing paired-send cadence.
|
||||
for i in range(1, 5):
|
||||
_, skipped = controller.update(control.as_reader(), ci.CS, t + i * 10_000_000)
|
||||
self.assertFalse(any(addr in (0x1BA, 0x1E5) for addr, _, _ in skipped))
|
||||
ci.CS.out = out
|
||||
|
||||
def test_controller_requires_a_matching_queued_lane_change_for_escalation(self):
|
||||
cp = long_params()
|
||||
ci = CarInterface(cp)
|
||||
controller = CarController(DBC[cp.carFingerprint], cp)
|
||||
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
|
||||
control = structs.CarControl()
|
||||
controller.ioniq6_bsm_counter = 250
|
||||
for physical_left in (True, False):
|
||||
for request in ('matching', 'none', 'opposite', 'hazards', 'matching', 'none'):
|
||||
with self.subTest(left=physical_left, request=request):
|
||||
t = 10_000_000_000 + controller.frame * 10_000_000
|
||||
ci.update([(t, [packer.make_can_msg('BLINDSPOTS_FRONT_CORNER_2', 1,
|
||||
{'SIDE_DETECT_STATE': 0x10 if physical_left else 0x08}),
|
||||
packer.make_can_msg('BLINKERS', 1, {})])])
|
||||
ci.CS.out.vEgoRaw = 10.0
|
||||
sources = Ioniq6BlindspotSources()
|
||||
sources.observe(corner_source_ns=t, corner_state=0x10 if physical_left else 0x08,
|
||||
lamp_source_ns=t, left_lamp=physical_left, right_lamp=not physical_left)
|
||||
ci.CS.ioniq6_bsm_sources = sources
|
||||
control.leftBlinker = request == 'hazards' or request == ('matching' if physical_left else 'opposite')
|
||||
control.rightBlinker = request == 'hazards' or request == ('opposite' if physical_left else 'matching')
|
||||
counter = controller.ioniq6_bsm_counter
|
||||
_, frames = controller.update(control.as_reader(), ci.CS, t)
|
||||
actual = [(addr, payload, bus) for addr, payload, bus in frames if addr in (0x1BA, 0x1E5)]
|
||||
source_status = sources.status(t)
|
||||
assert source_status is not None
|
||||
expected = source_status if request == 'matching' else BlindspotStatus(0, 0)
|
||||
self.assertEqual(actual, create_ioniq6_blindspot_status(counter, expected), 'UNQUEUED_BLINDSPOT_WARNING')
|
||||
self.assertEqual(sources.status(t), BlindspotStatus(2, 0) if physical_left else BlindspotStatus(0, 2))
|
||||
for offset in range(1, 5):
|
||||
_, skipped = controller.update(control.as_reader(), ci.CS, t + offset * 10_000_000)
|
||||
self.assertFalse(any(addr in (0x1BA, 0x1E5) for addr, _, _ in skipped))
|
||||
|
||||
def test_actual_lane_change_request_completion_and_turn_with_held_stalk(self):
|
||||
from openpilot.cereal import log
|
||||
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
|
||||
from openpilot.starpilot.lateral.lane_change_preferences import LaneChangePolicy
|
||||
|
||||
for left in (True, False):
|
||||
cp = long_params()
|
||||
ci = CarInterface(cp)
|
||||
controller = CarController(DBC[cp.carFingerprint], cp)
|
||||
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
|
||||
state = structs.CarState(vEgo=20.0, leftBlinker=left, rightBlinker=not left)
|
||||
helper = DesireHelper(LaneChangePolicy(auto_lane_change=True))
|
||||
|
||||
def check(expected, helper, *, controller=controller, ci=ci, packer=packer, left=left):
|
||||
control = structs.CarControl()
|
||||
t = 10_000_000_000 + controller.frame * 10_000_000
|
||||
ci.update([(t, [packer.make_can_msg('BLINDSPOTS_FRONT_CORNER_2', 1,
|
||||
{'SIDE_DETECT_STATE': 0x10 if left else 0x08}),
|
||||
packer.make_can_msg('BLINKERS', 1, {'LEFT_LAMP': int(left), 'RIGHT_LAMP': int(not left)})])])
|
||||
ci.CS.out.vEgoRaw = 20.0
|
||||
queued = helper.lane_change_state != log.LaneChangeState.off
|
||||
control.leftBlinker = queued and helper.lane_change_direction == log.LaneChangeDirection.left
|
||||
control.rightBlinker = queued and helper.lane_change_direction == log.LaneChangeDirection.right
|
||||
counter = controller.ioniq6_bsm_counter
|
||||
_, frames = controller.update(control.as_reader(), ci.CS, t)
|
||||
self.assertEqual([frame for frame in frames if frame[0] in (0x1BA, 0x1E5)],
|
||||
create_ioniq6_blindspot_status(counter, expected))
|
||||
for offset in range(1, 5):
|
||||
controller.update(control.as_reader(), ci.CS, t + offset * 10_000_000)
|
||||
|
||||
# Missing automatic-lane evidence does not cancel a manual pending request.
|
||||
helper.update(state, True, 1.0, auto_evidence=False, engaged=True)
|
||||
helper.update(state, True, 1.0, auto_evidence=False, engaged=True)
|
||||
self.assertEqual(helper.auto_status, 'laneUnavailable')
|
||||
self.assertEqual(helper.lane_change_state, log.LaneChangeState.preLaneChange)
|
||||
check(BlindspotStatus(2, 0) if left else BlindspotStatus(0, 2), helper)
|
||||
state.steeringPressed = True
|
||||
state.steeringTorque = 1.0 if left else -1.0
|
||||
helper.update(state, True, 1.0, engaged=True)
|
||||
self.assertEqual(helper.lane_change_state, log.LaneChangeState.laneChangeStarting)
|
||||
check(BlindspotStatus(2, 0) if left else BlindspotStatus(0, 2), helper)
|
||||
state.steeringPressed = False
|
||||
for _ in range(12):
|
||||
helper.update(state, True, 0.0, engaged=True)
|
||||
self.assertEqual(helper.lane_change_state, log.LaneChangeState.off)
|
||||
check(BlindspotStatus(0, 0), helper)
|
||||
helper.update(state, True, 0.0, engaged=True)
|
||||
check(BlindspotStatus(0, 0), helper)
|
||||
helper = DesireHelper()
|
||||
helper.update(state, True, 1.0, navigation_turn=True)
|
||||
self.assertEqual(helper.lane_change_state, log.LaneChangeState.off)
|
||||
check(BlindspotStatus(0, 0), helper)
|
||||
|
||||
@@ -244,14 +244,14 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
|
||||
cp = CarInterface.get_params(model, fp, [], False, False, False)
|
||||
ci = CarInterface(cp)
|
||||
packer = CANPacker(DBC[model][Bus.pt])
|
||||
ci.update([(1_000_000_000, [])])
|
||||
ci.update((1_000_000_000, []))
|
||||
sources = [(parser, state) for parser in ci.can_parsers.values()
|
||||
for state in parser.message_states.values() if not state.ignore_alive and state.address != 0x2E0]
|
||||
for frame in range(120):
|
||||
packets = [packer.make_can_msg(state.name, parser.bus,
|
||||
{"COUNTER": frame % 256} if any(sig.name == "COUNTER" for sig in state.signals) else {})
|
||||
for parser, state in sources]
|
||||
state = ci.update([(1_000_000_000 + frame * 100_000_000, packets)])
|
||||
state = ci.update((1_000_000_000 + frame * 100_000_000, packets))
|
||||
self.assertTrue(state.canValid)
|
||||
self.assertFalse(cp.openpilotLongitudinalControl)
|
||||
self.assertTrue(cp.pcmCruise)
|
||||
@@ -261,7 +261,7 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
|
||||
packets = [packer.make_can_msg(msg.name, parser.bus,
|
||||
{"COUNTER": frame % 256} if any(sig.name == "COUNTER" for sig in msg.signals) else {})
|
||||
for parser, msg in sources if msg.name != "MDPS"]
|
||||
state = ci.update([(1_000_000_000 + frame * 100_000_000, packets)])
|
||||
state = ci.update((1_000_000_000 + frame * 100_000_000, packets))
|
||||
self.assertFalse(state.canValid)
|
||||
|
||||
def test_card_tags_aol_candidate_before_cp_publication_without_disabling_ecu(self):
|
||||
|
||||
@@ -8,7 +8,7 @@ from opendbc.car.hyundai.canfd_stock_aol import qualified_long
|
||||
def eligible(cp):
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
# Genesis keeps its existing longitudinal controller.
|
||||
return cp.carFingerprint not in (CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, CAR.GENESIS_GV60_EV_1ST_GEN, CAR.KIA_CARNIVAL_2025) and qualified_long(cp)
|
||||
return cp.carFingerprint not in (CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, CAR.GENESIS_GV60_EV_1ST_GEN) and qualified_long(cp)
|
||||
|
||||
|
||||
def options(available, control_state, lead):
|
||||
|
||||
@@ -96,8 +96,6 @@ class IsoTpParallelQuery:
|
||||
msg.send(self.request[0], setup_only=len(self.functional_addrs) > 0)
|
||||
|
||||
results = {}
|
||||
interrupted_reads = set()
|
||||
retried_reads = set()
|
||||
start_time = time.monotonic()
|
||||
addrs_responded = set() # track addresses that have ever sent a valid iso-tp frame for timeout logging
|
||||
response_timeouts = {tx_addr: start_time + timeout for tx_addr in self.msg_addrs}
|
||||
@@ -128,15 +126,9 @@ class IsoTpParallelQuery:
|
||||
|
||||
counter = request_counter[tx_addr]
|
||||
expected_response = self.response[counter]
|
||||
request = self.request[counter]
|
||||
if (len(request) == 3 and request[0] == uds.SERVICE_TYPE.READ_DATA_BY_IDENTIFIER and
|
||||
not dat.startswith((bytes([request[0] + 0x40]) + request[1:], b'\x7f' + request[:1]))):
|
||||
interrupted_reads.add(tx_addr)
|
||||
continue
|
||||
response_valid = dat.startswith(expected_response)
|
||||
|
||||
if response_valid:
|
||||
interrupted_reads.discard(tx_addr)
|
||||
if counter + 1 < len(self.request):
|
||||
response_timeouts[tx_addr] = time.monotonic() + timeout
|
||||
msg.send(self.request[counter + 1])
|
||||
@@ -157,11 +149,6 @@ class IsoTpParallelQuery:
|
||||
cur_time = time.monotonic()
|
||||
for tx_addr in response_timeouts:
|
||||
if cur_time - response_timeouts[tx_addr] > 0:
|
||||
if not request_done[tx_addr] and tx_addr in interrupted_reads and tx_addr not in retried_reads:
|
||||
msgs[tx_addr].send(self.request[request_counter[tx_addr]])
|
||||
response_timeouts[tx_addr] = cur_time + timeout
|
||||
retried_reads.add(tx_addr)
|
||||
continue
|
||||
if not request_done[tx_addr]:
|
||||
if request_counter[tx_addr] > 0:
|
||||
carlog.error(f"iso-tp query timeout after receiving partial response: {tx_addr}")
|
||||
|
||||
@@ -132,6 +132,8 @@ class CarController(CarControllerBase):
|
||||
self.apply_angle_last = CS.out.steeringAngleDeg
|
||||
self.ascent_angle_initialized = True
|
||||
angle_active = False
|
||||
if angle_active and not self.angle_was_active and self.CP.carFingerprint != CAR.SUBARU_ASCENT_2023:
|
||||
self.apply_angle_last = CS.out.steeringAngleDeg
|
||||
if self.CP.carFingerprint in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023, CAR.SUBARU_OUTBACK_2023):
|
||||
apply_angle = apply_std_steer_angle_limits(actuators.steeringAngleDeg, self.apply_angle_last,
|
||||
CS.out.vEgoRaw, CS.out.steeringAngleDeg, angle_active,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""StarPilot adapter for jacobw's jul-angle-based Crosstrek control.
|
||||
|
||||
Source: jacobwaller/opendbc 8acbeccdab45a47aa7af170d2d17b61f0eb5e4f0.
|
||||
Original low-speed filtering: lukasloetkolben; target-first limiting: elkoled.
|
||||
Low-speed filtering: lukasloetkolben; target-first limiting: elkoled.
|
||||
Jacob Waller integrated the implementation and corrected its VM limit type.
|
||||
Keep StarPilot's model, stricter rate, and withdrawal guards. See CREDITS.md.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from opendbc.car import rate_limit
|
||||
from opendbc.car.lateral import get_max_angle_delta_vm, get_max_angle_vm
|
||||
from opendbc.car.lateral import apply_center_deadzone, get_max_angle_delta_vm, get_max_angle_vm
|
||||
|
||||
MAX_ANGLE = 190.0
|
||||
|
||||
@@ -16,6 +16,9 @@ MAX_ANGLE = 190.0
|
||||
def limit_angle(target, previous, speed, measured, active, limits, model):
|
||||
if not active:
|
||||
return float(np.clip(measured, -MAX_ANGLE, MAX_ANGLE)), False
|
||||
if speed < 10.0:
|
||||
deadzone = float(np.interp(speed, [2.0, 10.0], [6.0, 3.0]))
|
||||
target = previous + apply_center_deadzone(target - previous, deadzone)
|
||||
speed = max(speed, 1.0)
|
||||
maximum = min(MAX_ANGLE, get_max_angle_vm(speed, model, limits))
|
||||
delta = min(get_max_angle_delta_vm(speed, model, limits), limits.ANGLE_LIMITS.MAX_ANGLE_RATE)
|
||||
|
||||
@@ -37,7 +37,6 @@ def prepare(cp, enabled):
|
||||
class StartupSources:
|
||||
def __init__(self, cp=None):
|
||||
self.buses = dict(BUSES)
|
||||
self.state_zero_supported = cp is not None and selected(cp) and cp.carFingerprint == CAR.SUBARU_OUTBACK_2023
|
||||
if cp is not None and cp.carFingerprint == CAR.SUBARU_OUTBACK_2023:
|
||||
self.buses[0x48] = 1
|
||||
self.blocked = False
|
||||
@@ -133,9 +132,7 @@ class StopStartRequest:
|
||||
return None
|
||||
# State3 is the Dom OFF acknowledgement; never toggle an already OFF system.
|
||||
engine_state = source.frames[0x174][1][4] >> 6
|
||||
# This Outback reports state0 before an OEM OFF press, then acknowledges with state3.
|
||||
# Other qualified platforms retain their existing state1/2-only contract.
|
||||
if engine_state == 0 and not source.state_zero_supported:
|
||||
if engine_state == 0:
|
||||
return None
|
||||
if engine_state == 3:
|
||||
self.done = True
|
||||
|
||||
@@ -79,21 +79,19 @@ class TestIndependentAngleEnvelope(unittest.TestCase):
|
||||
expected_angle = max(-190., min(190., angle)) if car == CAR.SUBARU_CROSSTREK_2025 else angle
|
||||
self.assertAlmostEqual(decoded['LKAS_Output'], expected_angle, places=2)
|
||||
|
||||
def test_ordinary_engagement_preserves_transmitted_history_and_other_profiles(self):
|
||||
def test_ordinary_engagement_respects_crosstrek_bound_and_preserves_other_profiles(self):
|
||||
for car in (CAR.SUBARU_CROSSTREK_2025, CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023,
|
||||
CAR.SUBARU_FORESTER_2022, CAR.SUBARU_OUTBACK_2023):
|
||||
for speed, angle in ((5., 200.), (.4, 10.)):
|
||||
with self.subTest(car=car, speed=speed, angle=angle):
|
||||
_, controller, command, state = setup_controller(car)
|
||||
command.actuators.steeringAngleDeg = -1.45
|
||||
state.out.vEgoRaw = state.out.vEgo = speed
|
||||
state.out.steeringAngleDeg = angle
|
||||
state.out.steeringRateDeg = state.out.steeringTorque = 0
|
||||
state.out.standstill = False
|
||||
for _ in range(3):
|
||||
_, decoded = step(controller, command, state)
|
||||
self.assertTrue(decoded['LKAS_Request'])
|
||||
if car == CAR.SUBARU_CROSSTREK_2025:
|
||||
# The EPS bound applies to transmitted commands, not measured-angle admission.
|
||||
# Follow the small target without jumping to the measured angle.
|
||||
self.assertAlmostEqual(decoded['LKAS_Output'], -1.45)
|
||||
expected = car != CAR.SUBARU_CROSSTREK_2025 or abs(angle) <= 190.
|
||||
self.assertEqual(bool(decoded['LKAS_Request']), expected)
|
||||
if not expected:
|
||||
self.assertEqual(abs(decoded['LKAS_Output']), 190.)
|
||||
|
||||
@@ -12,17 +12,19 @@ from opendbc.safety.tests.libsafety import libsafety_py
|
||||
from openpilot.starpilot.car.subaru.tests.caller_helpers import CompleteCan
|
||||
|
||||
|
||||
@pytest.mark.parametrize('speed', (1.0, 2.0, 6.0, 9.0))
|
||||
@pytest.mark.parametrize('speed,deadzone', ((1.0, 6.0), (2.0, 6.0), (6.0, 4.5), (9.0, 3.375)))
|
||||
@pytest.mark.parametrize('sign', (-1, 1))
|
||||
def test_real_controller_tracks_small_low_speed_changes(speed, sign):
|
||||
def test_real_controller_suppresses_small_low_speed_changes(speed, deadzone, sign):
|
||||
_, controller, command, state = setup_controller(CAR.SUBARU_CROSSTREK_2025)
|
||||
state.out.vEgoRaw = speed
|
||||
command.actuators.steeringAngleDeg = sign * 2.0
|
||||
for _ in range(20):
|
||||
_, decoded = step(controller, command, state)
|
||||
assert decoded['LKAS_Request'] == 1
|
||||
assert sign * decoded['LKAS_Output'] > 0
|
||||
assert decoded['LKAS_Output'] == pytest.approx(sign * 2.0, abs=.01)
|
||||
command.actuators.steeringAngleDeg = sign * (deadzone - .01)
|
||||
_, decoded = step(controller, command, state)
|
||||
assert decoded['LKAS_Request'] == 1
|
||||
assert decoded['LKAS_Output'] == 0
|
||||
command.actuators.steeringAngleDeg = sign * (deadzone + .01)
|
||||
_, decoded = step(controller, command, state)
|
||||
assert decoded['LKAS_Request'] == 1
|
||||
assert 0 < sign * decoded['LKAS_Output'] <= 1.0
|
||||
|
||||
|
||||
def test_target_first_limits_recover_to_moving_acceleration_bound():
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Outback state-zero startup requests retain the existing bounded safety contract."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from opendbc.can import CANPacker
|
||||
from opendbc.car import gen_empty_fingerprint, structs
|
||||
from opendbc.car.subaru.interface import CarInterface
|
||||
from opendbc.car.subaru.startup import StartupSources, StopStartRequest, checksum, prepare
|
||||
from opendbc.car.subaru.values import CAR
|
||||
from openpilot.starpilot.car.subaru import stop_start_caller
|
||||
|
||||
|
||||
class StartupHarness:
|
||||
def __init__(self, candidate=CAR.SUBARU_OUTBACK_2023, enabled=True):
|
||||
self.cp = prepare(CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False), enabled)
|
||||
self.source = StartupSources(self.cp)
|
||||
self.owner = StopStartRequest()
|
||||
self.command = structs.CarControl()
|
||||
self.state = SimpleNamespace(startup_sources=self.source,
|
||||
out=structs.CarState(canValid=True, standstill=True, gearShifter=structs.CarState.GearShifter.park))
|
||||
self.packer = CANPacker('subaru_global_2017_generated')
|
||||
self.tick = 0
|
||||
self.base_ns = 200_000_000_000_000
|
||||
self.emissions = []
|
||||
|
||||
def step(self, engine_state=0, *, manual=False, fault=False, configured=True, omit=(), corrupt=False):
|
||||
now = self.base_ns + self.tick * 50_000_000
|
||||
frames = []
|
||||
for name, address, fields in (
|
||||
('Dashlights', 0x390, {'COUNTER': self.tick % 16, 'STOP_START': int(manual)}),
|
||||
('Transmission', 0x48, {'COUNTER': self.tick % 16, 'Gear': 4}),
|
||||
('Engine_Stop_Start', 0x174, {'Counter': self.tick % 16, 'STOP_START_STATE': engine_state}),
|
||||
('ES_Distance', 0x221, {'COUNTER': self.tick % 16}),
|
||||
):
|
||||
if address not in omit:
|
||||
address, data, bus = self.packer.make_can_msg(name, self.source.buses[address], fields)
|
||||
if corrupt and address == 0x390:
|
||||
data = bytes([data[0] ^ 1]) + data[1:]
|
||||
frames.append((address, data, bus))
|
||||
self.source.observe([(now, frames)], boot_ns=now, mono_ns=now)
|
||||
|
||||
class Messages(dict):
|
||||
pass
|
||||
|
||||
sm = Messages(pandaStates=[SimpleNamespace(faults=['interruptRateCan2'] if fault else [])])
|
||||
sm.seen = sm.valid = sm.alive = {'carControl': True}
|
||||
sm.logMonoTime = {'carControl': now}
|
||||
sm.recv_time = {'carControl': now / 1e9}
|
||||
card = SimpleNamespace(CI=SimpleNamespace(CC=SimpleNamespace(stop_start=self.owner, avh_startup=None)),
|
||||
sm=sm, startup_panda_configured=lambda: configured)
|
||||
stop_start_caller.update(card, now)
|
||||
request = self.owner.update(self.command, self.state, now / 1e9, now_nanos=now)
|
||||
if request is not None:
|
||||
self.emissions.append((now, request))
|
||||
self.tick += 1
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.parametrize('candidate', [CAR.SUBARU_OUTBACK_2023, CAR.SUBARU_LEGACY_2025])
|
||||
@pytest.mark.parametrize('engine_state', [0, 1, 2, 3])
|
||||
def test_state_zero_only_on_selected_outback(candidate, engine_state):
|
||||
harness = StartupHarness(candidate)
|
||||
for _ in range(50):
|
||||
harness.step(engine_state)
|
||||
expected = engine_state in (1, 2) or (candidate == CAR.SUBARU_OUTBACK_2023 and engine_state == 0)
|
||||
assert bool(harness.emissions) == expected
|
||||
assert len(harness.emissions) <= 6
|
||||
if expected:
|
||||
stamps = [stamp for stamp, _ in harness.emissions]
|
||||
assert stamps[0] - harness.base_ns >= 1_000_000_000
|
||||
assert stamps[-1] - stamps[0] < 300_000_000
|
||||
assert all(b - a >= 50_000_000 for a, b in zip(stamps, stamps[1:], strict=False))
|
||||
for _, (address, data, bus) in harness.emissions:
|
||||
assert address == 0x390 and bus == 1 and data[6] & 0x40
|
||||
assert checksum(address, data) == data[0]
|
||||
assert data[2:6] == bytes(4) and data[7] == 0
|
||||
|
||||
|
||||
def test_state_zero_requires_selected_profile():
|
||||
for candidate in (CAR.SUBARU_OUTBACK_2023, CAR.SUBARU_LEGACY_2025):
|
||||
harness = StartupHarness(candidate, enabled=False)
|
||||
for _ in range(50):
|
||||
harness.step()
|
||||
assert not harness.emissions
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason', ['manual', 'off', 'panda_fault', 'configuration', 'corrupt', 'stale', 'gear', 'gas', 'moving',
|
||||
'enabled', 'latActive', 'longActive', 'steerFaultPermanent', 'steerFaultTemporary', 'accFaulted',
|
||||
'canTimeout', 'canInvalid'])
|
||||
def test_state_zero_abort_during_pulse_never_retries(reason):
|
||||
harness = StartupHarness()
|
||||
for _ in range(21):
|
||||
harness.step()
|
||||
assert harness.emissions
|
||||
before = len(harness.emissions)
|
||||
options = {'manual': reason == 'manual', 'fault': reason == 'panda_fault', 'configured': reason != 'configuration',
|
||||
'corrupt': reason == 'corrupt', 'engine_state': 3 if reason == 'off' else 0}
|
||||
if reason == 'gear':
|
||||
harness.state.out.gearShifter = structs.CarState.GearShifter.reverse
|
||||
elif reason == 'gas':
|
||||
harness.state.out.gasPressed = True
|
||||
elif reason == 'moving':
|
||||
harness.state.out.standstill = False
|
||||
elif reason in ('steerFaultPermanent', 'steerFaultTemporary', 'accFaulted', 'canTimeout'):
|
||||
setattr(harness.state.out, reason, True)
|
||||
elif reason == 'canInvalid':
|
||||
harness.state.out.canValid = False
|
||||
elif reason in ('enabled', 'latActive', 'longActive'):
|
||||
setattr(harness.command, reason, True)
|
||||
elif reason == 'stale':
|
||||
harness.tick += 7
|
||||
options['omit'] = (0x174,)
|
||||
assert harness.step(**options) is None
|
||||
harness.command = structs.CarControl()
|
||||
harness.state.out = structs.CarState(canValid=True, standstill=True, gearShifter=structs.CarState.GearShifter.park)
|
||||
for _ in range(50):
|
||||
assert harness.step() is None
|
||||
assert harness.owner.done and len(harness.emissions) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('options', [{'fault': True}, {'configured': False}, {'manual': True}, {'engine_state': 3}])
|
||||
def test_state_zero_cannot_start_with_fault_or_ineligible_sources(options):
|
||||
harness = StartupHarness()
|
||||
for _ in range(50):
|
||||
assert harness.step(**options) is None
|
||||
assert not harness.emissions
|
||||
@@ -6,8 +6,7 @@ from opendbc.car.interfaces import CarControllerBase
|
||||
from opendbc.car.tesla.teslacan import TeslaCAN
|
||||
from opendbc.car.tesla.preap.stock import PreAPStockCarController
|
||||
from opendbc.car.tesla.hw1 import HW1CarController
|
||||
from opendbc.car.tesla.values import CAR, CarControllerParams, TeslaSafetyFlags
|
||||
from opendbc.car.tesla.coop_steering import CooperativeSteeringController
|
||||
from opendbc.car.tesla.values import CAR, CarControllerParams
|
||||
from opendbc.car.vehicle_model import VehicleModel
|
||||
|
||||
|
||||
@@ -28,9 +27,6 @@ class CarController(CarControllerBase):
|
||||
self.hw1 = HW1CarController(dbc_names, CP)
|
||||
return
|
||||
self.apply_angle_last = 0
|
||||
self.coop_steer = CooperativeSteeringController()
|
||||
self.coop_enabled = CP.carFingerprint == CAR.TESLA_MODEL_3 and bool(CP.safetyConfigs[0].safetyParam & TeslaSafetyFlags.COOP_STEERING)
|
||||
self.steering_limit_info = {}
|
||||
self.packer = CANPacker(dbc_names[Bus.party])
|
||||
self.tesla_can = TeslaCAN(CP, self.packer)
|
||||
|
||||
@@ -50,27 +46,14 @@ class CarController(CarControllerBase):
|
||||
# Tesla EPS enforces disabling steering on heavy lateral override force.
|
||||
# When enabling in a tight curve, we wait until user reduces steering force to start steering.
|
||||
# Canceling is done on rising edge and is handled generically with CC.cruiseControl.cancel
|
||||
lat_active = CC.latActive and (not CS.out.steeringDisengage if self.coop_enabled else CS.hands_on_level < 3)
|
||||
if not lat_active:
|
||||
self.steering_limit_info = {}
|
||||
lat_active = CC.latActive and CS.hands_on_level < 3
|
||||
|
||||
if self.frame % 2 == 0:
|
||||
# Angular rate limit based on speed
|
||||
self.apply_angle_last = apply_steer_angle_limits_vm(actuators.steeringAngleDeg, self.apply_angle_last, CS.out.vEgoRaw, CS.out.steeringAngleDeg,
|
||||
lat_active, CarControllerParams, self.VM)
|
||||
|
||||
command_angle, lat_active = self.coop_steer.update(self.apply_angle_last, lat_active, self.coop_enabled, CS, self.VM)
|
||||
self.command_angle = command_angle
|
||||
if self.coop_enabled and lat_active:
|
||||
self.steering_limit_info = {
|
||||
"valid": True, "monoTime": now_nanos,
|
||||
"modelLimitErrorDeg": abs(actuators.steeringAngleDeg - self.apply_angle_last),
|
||||
"resumeLimitErrorDeg": self.coop_steer.resume_limit_error_deg,
|
||||
"cooperativeLimitErrorDeg": self.coop_steer.cooperative_limit_error_deg,
|
||||
"cooperativeOffsetDeg": self.coop_steer.cooperative_offset_deg,
|
||||
"combinedLimitErrorDeg": abs(actuators.steeringAngleDeg + self.coop_steer.cooperative_offset_deg - command_angle),
|
||||
}
|
||||
can_sends.append(self.tesla_can.create_steering_control(command_angle, lat_active))
|
||||
can_sends.append(self.tesla_can.create_steering_control(self.apply_angle_last, lat_active))
|
||||
|
||||
if self.frame % 10 == 0:
|
||||
can_sends.append(self.tesla_can.create_steering_allowed())
|
||||
@@ -91,7 +74,7 @@ class CarController(CarControllerBase):
|
||||
|
||||
# TODO: HUD control
|
||||
new_actuators = actuators.as_builder()
|
||||
new_actuators.steeringAngleDeg = getattr(self, "command_angle", self.apply_angle_last)
|
||||
new_actuators.steeringAngleDeg = self.apply_angle_last
|
||||
|
||||
self.frame += 1
|
||||
return new_actuators, can_sends
|
||||
|
||||
@@ -5,12 +5,11 @@ from opendbc.car.common.conversions import Conversions as CV
|
||||
from opendbc.car.interfaces import CarStateBase
|
||||
from opendbc.car.tesla.preap.stock import PreAPStockCarState, get_stock_parsers
|
||||
from opendbc.car.tesla.hw1 import HW1CarState, get_hw1_can_parsers
|
||||
from opendbc.car.tesla.values import CAR, DBC, CANBUS, GEAR_MAP, STEER_THRESHOLD, STEER_DISENGAGE_THRESHOLD, TeslaFlags, TeslaSafetyFlags
|
||||
from opendbc.car.tesla.values import CAR, DBC, CANBUS, GEAR_MAP, STEER_THRESHOLD, TeslaFlags
|
||||
|
||||
class TeslaScreenCANParser(CANParser):
|
||||
def __init__(self):
|
||||
# Screen gestures are optional; their silence is not a control-bus fault.
|
||||
super().__init__('tesla_model3_vehicle', [('UI_status2', float('nan'))], CANBUS.vehicle)
|
||||
super().__init__('tesla_model3_vehicle', [('UI_status2', 0)], CANBUS.vehicle)
|
||||
|
||||
def update(self, strings, sendcan=False):
|
||||
if strings and not isinstance(strings[0], list | tuple):
|
||||
@@ -32,7 +31,6 @@ class CarState(CarStateBase):
|
||||
self.hands_on_level = 0
|
||||
self.das_control = {}
|
||||
return
|
||||
self.cooperative_steering = CP.carFingerprint == CAR.TESLA_MODEL_3 and bool(CP.safetyConfigs[0].safetyParam & TeslaSafetyFlags.COOP_STEERING)
|
||||
self.can_define = CANDefine(DBC[CP.carFingerprint][Bus.party])
|
||||
self.shifter_values = self.can_define.dv["DI_systemStatus"]["DI_gear"]
|
||||
|
||||
@@ -104,8 +102,7 @@ class CarState(CarStateBase):
|
||||
# FSD disengages using union of handsOnLevel (slow overrides) and high angle rate faults (fast overrides, high speed)
|
||||
eac_error_code = self.can_define.dv["EPAS3S_sysStatus"]["EPAS3S_eacErrorCode"].get(int(epas_status["EPAS3S_eacErrorCode"]), None)
|
||||
ret.steeringDisengage = self.hands_on_level >= 3 or (eac_status == "EAC_INHIBITED" and
|
||||
eac_error_code == "EAC_ERROR_HIGH_ANGLE_RATE_SAFETY") or (
|
||||
self.cooperative_steering and abs(ret.steeringTorque) > STEER_DISENGAGE_THRESHOLD)
|
||||
eac_error_code == "EAC_ERROR_HIGH_ANGLE_RATE_SAFETY")
|
||||
|
||||
# Cruise state
|
||||
cruise_state = self.can_define.dv["DI_state"]["DI_cruiseState"].get(int(cp_party.vl["DI_state"]["DI_cruiseState"]), None)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from opendbc.car import DT_CTRL, rate_limit
|
||||
from opendbc.car.lateral import apply_steer_angle_limits_vm
|
||||
from opendbc.car.tesla.values import CarControllerParams
|
||||
from opendbc.car.vehicle_model import VehicleModel
|
||||
|
||||
|
||||
DT_LAT_CTRL = DT_CTRL * CarControllerParams.STEER_STEP
|
||||
STEER_RESUME_RATE_LIMIT_RAMP_RATE = 300.0 # deg/s^2
|
||||
|
||||
STEER_OVERRIDE_MIN_TORQUE = 0.5 # Nm
|
||||
STEER_OVERRIDE_MAX_TORQUE = 2.5 # Nm
|
||||
STEER_OVERRIDE_TORQUE_RANGE = STEER_OVERRIDE_MAX_TORQUE - STEER_OVERRIDE_MIN_TORQUE
|
||||
STEER_OVERRIDE_MAX_LAT_ACCEL = 2.0 # m/s^2
|
||||
STEER_OVERRIDE_DELTA_GAIN_LIMIT = 125.0 # deg/s/Nm
|
||||
|
||||
|
||||
def apply_bounds(signal: float, limit: float) -> float:
|
||||
return float(np.clip(signal, -limit, limit))
|
||||
|
||||
|
||||
def apply_deadzone(signal: float, deadzone: float) -> float:
|
||||
return signal - apply_bounds(signal, deadzone)
|
||||
|
||||
|
||||
def get_steer_from_lat_accel(lat_accel: float, v_ego: float, VM: VehicleModel) -> float:
|
||||
curvature = lat_accel / max(1.0, v_ego) ** 2
|
||||
return math.degrees(VM.get_steer_from_curvature(curvature, v_ego, 0.0))
|
||||
|
||||
|
||||
def get_override_torque_to_angle(v_ego: float, VM: VehicleModel) -> float:
|
||||
max_angle = CarControllerParams.ANGLE_LIMITS.STEER_ANGLE_MAX
|
||||
steer_from_lat_accel = apply_bounds(get_steer_from_lat_accel(STEER_OVERRIDE_MAX_LAT_ACCEL, v_ego, VM), max_angle)
|
||||
return steer_from_lat_accel / STEER_OVERRIDE_TORQUE_RANGE
|
||||
|
||||
|
||||
def calc_override_angle_delta_limit(torque: float) -> float:
|
||||
max_gain = CarControllerParams.ANGLE_LIMITS.MAX_ANGLE_RATE / DT_LAT_CTRL / STEER_OVERRIDE_TORQUE_RANGE
|
||||
return torque * min(STEER_OVERRIDE_DELTA_GAIN_LIMIT, max_gain) * DT_LAT_CTRL
|
||||
|
||||
|
||||
class SteerRateLimiter:
|
||||
def __init__(self):
|
||||
self.last = 0.0
|
||||
|
||||
def reset(self, angle: float) -> None:
|
||||
self.last = angle
|
||||
|
||||
def update(self, angle: float, angle_delta_limit: float) -> float:
|
||||
limited = rate_limit(angle, self.last, -angle_delta_limit, angle_delta_limit)
|
||||
self.last = limited
|
||||
return limited
|
||||
|
||||
|
||||
class CooperativeSteeringController:
|
||||
def __init__(self):
|
||||
self.apply_angle_last = 0.0
|
||||
self.coop_apply_angle_last = 0.0
|
||||
self.angle_override = 0.0
|
||||
self.resume_rate_limiter_delta = SteerRateLimiter()
|
||||
self.resume_rate_limiter = SteerRateLimiter()
|
||||
self.resume_limit_error_deg = 0.0
|
||||
self.cooperative_limit_error_deg = 0.0
|
||||
self.cooperative_offset_deg = 0.0
|
||||
|
||||
def reset_override_state(self, apply_angle: float) -> None:
|
||||
self.apply_angle_last = apply_angle
|
||||
self.angle_override = 0.0
|
||||
self.coop_apply_angle_last = apply_angle
|
||||
|
||||
def reset_resume_state(self, apply_angle: float) -> None:
|
||||
self.resume_rate_limiter_delta.reset(0.0)
|
||||
self.resume_rate_limiter.reset(apply_angle)
|
||||
|
||||
def update_override_angle(self, apply_angle_delta: float, driver_torque: float, v_ego: float, VM: VehicleModel) -> float:
|
||||
driver_torque = apply_deadzone(driver_torque, STEER_OVERRIDE_MIN_TORQUE)
|
||||
torque_to_angle = get_override_torque_to_angle(v_ego, VM)
|
||||
target_angle = driver_torque * torque_to_angle
|
||||
|
||||
holding_torque = self.angle_override / torque_to_angle if abs(v_ego) > 0.1 else 0.0
|
||||
torque_delta = driver_torque - holding_torque
|
||||
angle_delta_limit = calc_override_angle_delta_limit(abs(torque_delta))
|
||||
angle_override_delta = float(np.clip(target_angle - self.angle_override, -angle_delta_limit, angle_delta_limit))
|
||||
|
||||
# Avoid counting model-requested motion and driver-requested motion twice.
|
||||
if angle_override_delta * apply_angle_delta > 0.0:
|
||||
angle_override_delta -= apply_bounds(apply_angle_delta, abs(angle_override_delta))
|
||||
|
||||
self.angle_override += angle_override_delta
|
||||
return self.angle_override
|
||||
|
||||
def unwind_override_angle(self, saturation_error: float) -> None:
|
||||
if self.angle_override * saturation_error > 0.0:
|
||||
self.angle_override -= apply_bounds(saturation_error, abs(self.angle_override))
|
||||
|
||||
def apply_resume_rate_limit(self, lat_active: bool, apply_angle: float) -> float:
|
||||
if not lat_active:
|
||||
self.reset_resume_state(apply_angle)
|
||||
return apply_angle
|
||||
|
||||
angle_rate_delta = self.resume_rate_limiter_delta.update(
|
||||
CarControllerParams.ANGLE_LIMITS.MAX_ANGLE_RATE,
|
||||
STEER_RESUME_RATE_LIMIT_RAMP_RATE * DT_LAT_CTRL ** 2,
|
||||
)
|
||||
return self.resume_rate_limiter.update(apply_angle, angle_rate_delta)
|
||||
|
||||
def update(self, apply_angle: float, lat_active: bool, enabled: bool, CS, VM: VehicleModel) -> tuple[float, bool]:
|
||||
self.resume_limit_error_deg = 0.0
|
||||
self.cooperative_limit_error_deg = 0.0
|
||||
self.cooperative_offset_deg = 0.0
|
||||
if not enabled:
|
||||
self.reset_resume_state(apply_angle)
|
||||
self.reset_override_state(apply_angle)
|
||||
return apply_angle, lat_active
|
||||
|
||||
requested_angle = apply_angle
|
||||
apply_angle = self.apply_resume_rate_limit(lat_active, apply_angle)
|
||||
self.resume_limit_error_deg = abs(requested_angle - apply_angle)
|
||||
if not lat_active:
|
||||
self.reset_override_state(apply_angle)
|
||||
return apply_angle, False
|
||||
|
||||
apply_angle_delta = apply_angle - self.apply_angle_last
|
||||
self.apply_angle_last = apply_angle
|
||||
self.cooperative_offset_deg = self.update_override_angle(apply_angle_delta, CS.out.steeringTorque, CS.out.vEgo, VM)
|
||||
apply_angle += self.cooperative_offset_deg
|
||||
|
||||
limited_angle = apply_steer_angle_limits_vm(
|
||||
apply_angle,
|
||||
self.coop_apply_angle_last,
|
||||
CS.out.vEgoRaw,
|
||||
CS.out.steeringAngleDeg,
|
||||
True,
|
||||
CarControllerParams,
|
||||
VM,
|
||||
)
|
||||
self.coop_apply_angle_last = limited_angle
|
||||
self.cooperative_limit_error_deg = abs(apply_angle - limited_angle)
|
||||
self.unwind_override_angle(apply_angle - limited_angle)
|
||||
return limited_angle, True
|
||||
@@ -15,7 +15,6 @@ FW_VERSIONS = {
|
||||
b'TeM3_ES014p11_0.0.0 (25),ES014.19.0',
|
||||
b'TeM3_E014p10_0.0.0 (24),E014.20.2',
|
||||
b'TeM3_E014p10_0.0.0 (24),EL014.20.2',
|
||||
b'TeM3_E014p10_0.0.0 (35),E014.21.2',
|
||||
b'TeMYG4_DCS_Update_0.0.0 (13),E4014.28.1',
|
||||
b'TeMYG4_DCS_Update_0.0.0 (9),E4014.26.0',
|
||||
b'TeMYG4_Legacy3Y_0.0.0 (2),E4015.02.0',
|
||||
@@ -42,7 +41,6 @@ FW_VERSIONS = {
|
||||
b'TeM3_E014p10_0.0.0 (16),YP002.18.00',
|
||||
b'TeM3_E014p10_0.0.0 (24),Y002.21.2',
|
||||
b'TeM3_E014p10_0.0.0 (24),YP002.21.2',
|
||||
b'TeM3_E014p10_0.0.0 (35),Y002.22.2',
|
||||
b'TeM3_ES014p11_0.0.0 (16),YS002.17',
|
||||
b'TeM3_ES014p11_0.0.0 (25),YS002.19.0',
|
||||
b'TeMYG4_DCS_Update_0.0.0 (13),Y4002.27.1',
|
||||
@@ -59,7 +57,6 @@ FW_VERSIONS = {
|
||||
b'TeMYG4_SingleECU_0.0.0 (28),Y4S002.23.0',
|
||||
b'TeMYG4_SingleECU_0.0.0 (33),Y4S002.26',
|
||||
b'TeMYG4_Main_0.0.0 (87),Y4003.09.3',
|
||||
b'TeMYG4_Main_0.0.0 (92),Y4003.10.2',
|
||||
],
|
||||
},
|
||||
CAR.TESLA_MODEL_X: {
|
||||
|
||||
@@ -19,7 +19,7 @@ def get_hw1_params(ret, fingerprint, alpha_long):
|
||||
ret.radarUnavailable = fingerprint.get(1, {}).get(0x301) != 8
|
||||
ret.alphaLongitudinalAvailable = True
|
||||
ret.openpilotLongitudinalControl = alpha_long
|
||||
ret.pcmCruise = True
|
||||
ret.pcmCruise = not alpha_long
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user