Compare commits

..

2 Commits

Author SHA1 Message Date
Prabhaav Pillai 88a3b5e2a5 Add car control sections and update projections in settings for AA support 2026-10-27 17:10:33 -04:00
Prabhaav Pillai 8ac6b224d7 Add support for host runtime and Bluetooth integration in StarPilot 2026-10-25 17:51:38 -04:00
948 changed files with 6907 additions and 48883 deletions
-8
View File
@@ -49,11 +49,3 @@ openpilot/system/loggerd/loggerd -text
openpilot/starpilot/galaxy/bin/frpc_linux_arm64 -text
openpilot/starpilot/maps/provider/mapd -text
openpilot/selfdrive/test/process_replay/refs/starpilot/*.zst -text
# Published Panda object files are AArch64 ELF, not text.
openpilot/selfdrive/pandad/main.o -text
openpilot/selfdrive/pandad/panda.o -text
openpilot/selfdrive/pandad/panda_safety.o -text
openpilot/selfdrive/pandad/panda_usb.o -text
openpilot/selfdrive/pandad/pandad.o -text
openpilot/selfdrive/pandad/spi.o -text
@@ -0,0 +1,68 @@
name: Android companion release
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-companion-release
cancel-in-progress: false
jobs:
build:
name: Test, build and sign the companion APK
runs-on: ubuntu-latest
timeout-minutes: 30
environment: android-release
defaults:
run:
working-directory: openpilot/starpilot/companion/android
steps:
- uses: actions/checkout@v7
with:
submodules: false
lfs: false
persist-credentials: false
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Install Android SDK platform and build tools
run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37" "build-tools;37.0.0"
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Restore signing key
env:
KEYSTORE_BASE64: ${{ secrets.COMPANION_KEYSTORE_BASE64 }}
run: |
umask 077
printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/companion.jks"
- name: Build and sign the APK
env:
COMPANION_KEYSTORE: ${{ runner.temp }}/companion.jks
COMPANION_KEY_PASSWORD: ${{ secrets.COMPANION_KEY_PASSWORD }}
run: ./build_apk.sh
- name: Remove signing key
if: always()
run: rm -f "$RUNNER_TEMP/companion.jks"
- name: Check the APK
working-directory: openpilot/starpilot/galaxy/bin
run: |
if unzip -l starpilot-companion.apk | grep -q 'assets/galaxy/'; then
echo 'APK still bundles Galaxy' >&2
exit 1
fi
BT="$ANDROID_HOME/build-tools/37.0.0"
"$BT/aapt2" dump badging starpilot-companion.apk | head -1
"$BT/apksigner" verify --print-certs starpilot-companion.apk | grep -E 'certificate (DN|SHA-256)'
cat starpilot-companion.apk.version
- uses: actions/upload-artifact@v4
with:
name: starpilot-companion-apk
path: |
openpilot/starpilot/galaxy/bin/starpilot-companion.apk
openpilot/starpilot/galaxy/bin/starpilot-companion.apk.version
if-no-files-found: error
retention-days: 30
+1 -6
View File
@@ -5,7 +5,7 @@ on:
branches:
- master
- Domathon
- Dom
- SecretGoodStarPilot
pull_request:
workflow_call:
inputs:
@@ -29,11 +29,6 @@ jobs:
steps:
- uses: commaai/timeout@v1
- uses: actions/checkout@v7
with:
sparse-checkout: |
/docs/
/openpilot/selfdrive/assets/icons_mici/settings/comma_icon.png
sparse-checkout-cone-mode: false
# Build
- name: Build docs
+1 -1
View File
@@ -2,7 +2,7 @@ name: iOS companion
on:
push:
branches: [Dom, secretgood-ios-integration]
branches: [SecretGoodStarPilot, secretgood-ios-integration]
paths:
- 'openpilot/starpilot/companion/ios/**'
- 'openpilot/starpilot/galaxy_bluetooth/**'
+1 -1
View File
@@ -2,7 +2,7 @@ name: safety qualification
on:
push:
branches: [master, Domathon, Dom]
branches: [master, Domathon, SecretGoodStarPilot]
pull_request:
workflow_dispatch:
+3 -13
View File
@@ -5,7 +5,7 @@ on:
branches:
- master
- Domathon
- Dom
- SecretGoodStarPilot
pull_request:
workflow_dispatch:
workflow_call:
@@ -35,7 +35,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Validate published device build receipt
if: github.event_name == 'push' && github.ref == 'refs/heads/Dom'
if: github.event_name == 'push' && github.ref == 'refs/heads/SecretGoodStarPilot'
run: python3 tools/ci/check_prebuilt_receipt.py --revision HEAD
- name: Check shipped device runtime dependencies
run: python3 -m unittest tools.laptop_device_build.tests.test_runtime_dependencies -v
@@ -207,15 +207,6 @@ jobs:
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
timeout-minutes: 5
run: tools/op.sh test opendbc_repo/opendbc/car/gm/tests --json-output gm-test-results.json
- name: Test shared GM discovery, routing, and manual overrides
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
timeout-minutes: 2
run: >-
.venv/bin/python -m pytest -q --import-mode=importlib
opendbc_repo/opendbc/car/gm/tests/test_shared_discovery.py
opendbc_repo/opendbc/car/gm/tests/test_automatic_discovery.py
opendbc_repo/opendbc/car/gm/tests/test_bolt_vin_discovery.py
--junitxml=gm-discovery-results.xml
- name: Test native independent-axis protocol
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: |
@@ -236,14 +227,13 @@ jobs:
STARPILOT_PYTHON: ${{ github.workspace }}/.venv/bin/python
run: go test -mod=readonly -race -run '^Test(MsgqNativeLayout|Shadow(GoPythonIPC|ExecutableHostGpsIPC))$' -count=1 -v
- name: Save complete unit test results
if: ${{ always() && (hashFiles('unit-test-results.json') != '' || hashFiles('gm-test-results.json') != '' || hashFiles('gm-discovery-results.xml') != '' || hashFiles('ci-results/host-test-diagnostics/**') != '') }}
if: ${{ always() && (hashFiles('unit-test-results.json') != '' || hashFiles('gm-test-results.json') != '' || hashFiles('ci-results/host-test-diagnostics/**') != '') }}
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: |
unit-test-results.json
gm-test-results.json
gm-discovery-results.xml
ci-results/host-test-diagnostics/
if-no-files-found: error
-2
View File
@@ -49,8 +49,6 @@ bin/
!openpilot/starpilot/soloist/pulseaudio/modules/*.so
*.a
st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z]
!/openpilot/starpilot/system/starport/
!/openpilot/tools/starport/
*.unchunked
*.clb
*.class
+1 -5
View File
@@ -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 `bf4dc940e98f5d3ed9b7163adb021439010d634f` 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 `d24328d849b0e5154b55f149dc18c5dfe8bd79cc` 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
@@ -33,7 +33,3 @@ Spotify's Bluetooth output binds the comma's BlueALSA A2DP source to the selecte
## Tesla Pre-AP pedal control
The Pre-AP configuration, pedal codec/feedback, VirtualDAS feedforward table, grade compensation, torque-zero estimator and vehicle-specific gains preserve firestar5683's intentional Dom work, including `7837a3c1aab`, `aed0b7fdb9e9` and pinned `b220a37f4bd119e402225a7aa16a7cd84878d0af`. Original author attribution: firestar5683 <168790843+firestar5683@users.noreply.github.com>. Startup/calibration ownership, current upstream button/PID interfaces and native command bounds are adapted to the current StarPilot architecture; the old permissive native gateway is not transplanted. Existing source/license notices remain intact.
## Ford optional pinion measurement
The default-off Use Pinion Yaw Sensor measurement option is adapted from jrneulight’s BluePilot PR #145 (https://github.com/BluePilotDev/bluepilot/pull/145), merged as `fe5b019568c8c13fb894dc6d8d779d60985051a8`. Its per-platform geometry, native pinion measurement, integrity requirements and host vehicle-model compensation are retained. StarPilot adapts the option to its existing exact safety-profile word, modern curvature checks, AOL authority and Galaxy/native settings; the upstream maintainers do not maintain this integration. Existing upstream copyright and license notices remain in THIRD_PARTY_NOTICES.md.
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11 -87
View File
@@ -1,90 +1,16 @@
# StarPilot
StarPilot is an independent fork of [openpilot](https://github.com/commaai/openpilot), with custom driving controls, vehicle integrations, raylib interfaces for comma 3 and comma four, and the Galaxy companion interface.
[![Ask DeepWiki](https://img.shields.io/badge/Ask-DeepWiki-blue?logo=readthedocs&logoColor=white)](https://deepwiki.com/StarPilotHQ/StarPilot)
[![Discord](https://img.shields.io/discord/1387432184121393333?label=Discord)](https://firestar.link/discord)
[![Last Updated](https://img.shields.io/github/last-commit/StarPilotHQ/StarPilot/StarPilot)](https://github.com/StarPilotHQ/StarPilot)
[![Wiki](https://img.shields.io/badge/Wiki-StarPilot-blue?logo=wiki)](https://wiki.firestar.link)
[![iOS App Store](https://img.shields.io/badge/iOS-App_Store-black?logo=apple&logoColor=white)](https://apps.apple.com/ca/app/starpilot-bt/id6821039119)
[![Android Beta](https://img.shields.io/badge/Android-Join_Beta-green?logo=android&logoColor=white)](https://groups.google.com/g/galaxy-starpilot-android-beta)
**Domathon is the development branch for StarPilot 7.0.** It rebuilds StarPilot on a current openpilot foundation. Feature migration and vehicle qualification are still in progress; this branch is not a completed replacement for the existing Dom release.
**StarPilot** is a custom fork of [comma.ai's openpilot](https://comma.ai/openpilot),
an open source driver assistance system.
## Explore the project
**This is the StarPilot 7.0 development branch (`SecretGoodStarPilot`). Feature migration,
vehicle qualification, and Auto Learn validation are still in progress.**
Openpilot provides
* Automated Lane Centering
* Adaptive Cruise Control
* Lane Change Assist
* Driver Monitoring *without wheel nags*
Ford-specific lateral-control and vehicle-support work includes substantial adaptations from
[BluePilot](https://github.com/BluePilotDev/bluepilot/tree/bp-7.0), principally developed by
[Alan Polk](https://github.com/alan-polk) with additional BluePilot contributors. StarPilot's
implementation has since diverged, but that does not erase its lineage. See [CREDITS.md](CREDITS.md)
for the code-level provenance and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for applicable
upstream notices and terms. BluePilot and its contributors do not maintain or endorse StarPilot;
please direct support requests for this adaptation to the StarPilot project.
Hyundai, Kia, and Genesis angle steering and related vehicle support include substantial adaptations
from [sunnypilot](https://github.com/sunnypilot/sunnypilot/tree/hkg-angle-steering-2025) and its
[opendbc angle-steering branch](https://github.com/sunnypilot/opendbc/tree/hkg-angle-steering-2025).
StarPilot's implementation has diverged significantly; the upstream contributors do not maintain
this adaptation. Detailed code lineage is recorded in [CREDITS.md](CREDITS.md).
StarPilot has a vibrant, welcoming community [discord](https://firestar.link/discord).
Stop by to chat or ask questions!
If your car isn't driving as well as you'd like, [open a ticket so we can help](https://firestar.link/logs)!
## Documentation
Please see [https://wiki.firestar.link](https://wiki.firestar.link) for hardware lists,
installation guides, and software configuration.
## Features
* **Full support for Comma C3, C3X, and C4**
* Full support for chestnut/eGPU on C3, C3X, and C4. C3/C3X may require special gps handling.
* Model switcher with all of comma's tinygrad driving models
* StarPilot custom torque controller
* StarPilot custom longitudinal planner for both radar and VoACC (visual only, radar-less) vehicles
* **Custom tuning for an expanding list of vehicles**
* Firestar Lateral Method -
Auto tuning system to adapt openpilot to *your* car's unique lateral characteristics.
* Galaxy: StarPilot's portal to configure your comma device using your phone from anywhere.
Download models, change settings, update software, diagnostics, manage dashcam video, and more.
* Navigation - Display routes and give the driving model hints on taking exits and turns.
* Always On Lateral (full time steering assist)
* Lane Centering - Enable to center between detected lane lines,
with graceful fallback to model E2E lateral outputs and safety breakout
* Force Stop 2.0 - Better stops at stop signs and traffic lights.
* Speed Limit Controller - Use dashboard, map data, and/or vision detection for speed limit signs
* Curve Speed Controller - Learns from how you take curves
* Conditional Experimental Mode (CEM)
* Conditional Chill Mode (CCM)
* Driving Profiles and Traffic Mode
* Custom themes
* Alert volume customization
* Comma Pedal Interceptor support
* Bluetooth support - Connect keyboards, controllers, steering wheel remotes, and more.
* Sentry mode - Record when motion is detected while parked.
* V-ASM - Detect vehicles in the adjacent/blind spots using the driver-facing camera.
* Additional car support - Toyota SDSU, ZSS, CAN filter; pre-AP tesla; ford; Polestar 2; and more.
* And more!
## GM-only Features
* Increased LKAS fault resiliency
* ASCM_INT and SASCM support
* Custom lateral torque controller, with special tuning for Bolts
* 50% extra torque on 2017 Chevy Bolt
* Improved lateral and longitudinal tuning
* Dashboard cruise control display speed spoofing for vehicles with pedal interceptor
* Extra steering wheel button functionality for vehicles with pedal interceptor
* Optional toggle to boot comma when remote starting your vehicle
- [Developer commands](tools/STARPILOT_DEVELOPMENT.md): host tools, replay, and device builds.
- [Driving interfaces](openpilot/starpilot/ui/README.md) and [Galaxy](openpilot/starpilot/galaxy/README.md): native and companion controls.
- [Driving models](openpilot/starpilot/models/README.md): model management, artifact compatibility, and Model Laboratory status.
- [Builds and developer tools](docs/how-to/laptop-device-build.md): local development and device builds.
- [Dependency maintenance](tools/vendor/README.md): tracked source folders and upstream sync records.
## Development
@@ -110,10 +36,8 @@ StarPilot retains upstream vehicle and Panda safety interfaces and adds regressi
Support depends on the vehicle configuration and enabled feature. An existing vehicle identifier or settings page does not by itself establish driving support. Model Laboratory currently exposes management controls, but paired driving inference remains unavailable pending integration and Chestnut validation.
## Third-Party Notices
## Upstream and attribution
* Portions of this software include modified versions of the Material Design Icons provided by Google under the Apache License 2.0. A copy of the license is included in the `LICENSE-MDI` file.
* Ford support includes software adapted from BluePilot's `bp-7.0` branch. The source repository contains both a standard MIT notice and a separate custom SUNNYPILOT LLC notice; StarPilot preserves both in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
* Hyundai, Kia, and Genesis support includes software adapted directly from sunnypilot's HKG angle-steering branch and sunnypilot/opendbc. StarPilot preserves the applicable notices in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
The current openpilot baseline is [`78dccf0b482ea1298fd1f6fa59b0554187b02ce6`](https://github.com/commaai/openpilot/commit/78dccf0b482ea1298fd1f6fa59b0554187b02ce6). Its history remains intact, followed by the StarPilot foundation and feature commits. Upstream development continues independently; the baseline changes only after an update has been integrated and checked.
> This project uses software from Haibin Wen and SUNNYPILOT LLC and is licensed under a custom license requiring permission for use.
See [LICENSE](LICENSE), [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), and [CREDITS.md](CREDITS.md) for licensing and source attribution. Component licenses remain with their source folders. StarPilot is maintained independently of comma.
+1 -1
View File
@@ -318,7 +318,7 @@ SConscript([
if arch == "comma_arm64":
SConscript(['openpilot/system/camerad/SConscript'])
SConscript(['openpilot/starpilot/system/starport/SConscript'])
SConscript(['openpilot/starpilot/system/android_auto/SConscript'])
# Build selfdrive
SConscript([
+1 -1
View File
@@ -129,7 +129,7 @@ UI framework is bundled.
## Galaxy Bluetooth companion source snapshot
The Galaxy Bluetooth phone bridge and iOS app from https://github.com/StarPilotHQ/StarPilot
The Galaxy Bluetooth phone bridge and iOS app from https://github.com/firestar5683/StarPilot
(natehuby76, PRs #224/#225) are included under `openpilot/starpilot/galaxy_bluetooth/`
and `openpilot/starpilot/companion/ios/`, with their license files (`UPSTREAM-LICENSE`, `openpilot/starpilot/companion/ios/UPSTREAM-GALAXY-LICENSE`).
The bridge runs on openpilot's Python with PyCryptodome 3.23.0 (BSD/public-domain
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
profile="${1:-c4}"
case "${profile}" in
c3) big=1 ;;
c4) big=0 ;;
*) echo "Usage: ./device [c3|c4]" >&2; exit 2 ;;
esac
# Run the real manager with host hardware/cloud disabled. The manager owns the
# UI and Galaxy processes, just as it does on a comma device.
export STARPILOT_CLOUD_DISABLED=1
export BIG="${big}"
export BLOCK="${BLOCK:+${BLOCK},}replay_owner,map_snapshot_operations"
exec "${ROOT_DIR}/dev" device
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_BIN="${HEATDEATH_PYTHON:-python3}"
if [[ -z "${HEATDEATH_PYTHON:-}" && -x /usr/local/venv/bin/python3 ]]; then
PYTHON_BIN=/usr/local/venv/bin/python3
fi
exec "$PYTHON_BIN" -B "$ROOT_DIR/tools/heatdeath/heatdeath.py" "$@"
+1 -1
View File
@@ -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.8.9'
__version__ = '0.8.7'
+1 -26
View File
@@ -152,16 +152,6 @@ class ModelSide(WorkerSide, Protocol):
frame thread at a hand-back, so it zeroes the history in place and
allocates and compiles nothing; warp.reset_queues makes one."""
def model_warp(self, small: Any, cam_w: int, cam_h: int, model_w: int, model_h: int) -> FrameWarp | None:
"""Optional (OPTIONAL): the warp the link sends frames through, warmed,
or None for openpilot's driving warp pickle at warp_path (warp.Warp). For
a fork whose runtime cannot load that pickle, typically with its small
model's own warp. Asked once on modeld's main thread before the frame
loop, after the small model's warmup; raising keeps the drive on the
small model, as a pickle that does not load does. The offroad checks
still look for the pickle (Warps.built): only modeld has a small model
to ask, so warp_path must name a file either way."""
class Openpilot(ModelSide, Protocol):
"""The whole adapter: one object implements every side."""
@@ -208,28 +198,13 @@ class JoinedModel(Protocol):
"""Let go of the link at modeld's exit."""
class FrameWarp(Protocol):
"""What an adapter's model_warp returns, warp.Warp's shape, run on modeld's
frame thread. Only one of the small and the large model runs a frame, so a
warp that shares the small model's JIT and buffers is never in use by both.
On a comma, a warp that reads its GPU output back moves it first with
warp.make_coherent, before the warp's JIT first runs."""
output: Any # the warped frame, (2, 6, model_h/2, model_w/2) uint8 as bytes: read after wait(), until the next start()
def start(self, frame: int, big_frame: int, tfm: Any, big_tfm: Any) -> None:
"""Warp the camera buffers at these addresses under their 3x3 transforms."""
def wait(self) -> None:
"""Return once `output` holds what start() warped."""
# The members of each protocol an implementation may lack: jetlink reads them
# with getattr and a fallback. conformance() passes an object without one and
# holds one with it to its signature, and adding one keeps API. Kept out of
# the protocols, where a name would be a member itself; a type checker still
# asks for these, as typing has no optional member
OPTIONAL: dict[type, tuple[str, ...]] = {
ModelSide: ('small_reset', 'model_warp'),
ModelSide: ('small_reset',),
SmallModel: ('desire_key', 'numpy_inputs', 'lat_delay', 'PLANPLUS_CONTROL'),
JoinedModel: ('get_action_from_model', 'constants', 'LAT_SMOOTH_SECONDS', 'LONG_SMOOTH_SECONDS', 'lat_delay'),
}
+2 -8
View File
@@ -334,7 +334,7 @@ class JoiningModelState:
self._demote(BEHIND)
elif self._active is not self._small and self._frame_drop_ratio > DROP_LIMIT:
# modeld is behind the large model; this frame is the small model's
self._log.warning("jetlink: modeld dropped %.2f %% of camera frames behind the large model, " +
self._log.warning("jetlink: modeld dropped %.2f %% of camera frames behind the large model, "
"the small model drives from this one", self._frame_drop_ratio * 100)
self._demote(BEHIND)
self._maybe_swap()
@@ -745,7 +745,6 @@ def join(parts, cam_w: int, cam_h: int, small) -> JoiningModelState:
link = links.Link(parts.log)
def prepare():
from jetlink.openpilot.interface import FrameWarp, conformance
from jetlink.openpilot.warp import Warp, prepare_reset
# the gadget first, so the Jetson enumerates while the warp loads. Left to
# the join thread the bind lands ~3 s later, behind the small model's
@@ -760,12 +759,7 @@ def join(parts, cam_w: int, cam_h: int, small) -> JoiningModelState:
try:
# the fork's own reset of its small model, else jetlink's from its shape
ready['reset_small'] = getattr(op, 'small_reset', lambda small: None)(small) or prepare_reset(small)
# the fork's own warp, else openpilot's driving warp pickle
warp = getattr(op, 'model_warp', lambda *args: None)(small, cam_w, cam_h, *geometry)
if warp is None:
warp = Warp(parts.warps.load(cam_w, cam_h, *geometry), face.frame_size(cam_w, cam_h))
elif problems := conformance(warp, FrameWarp):
raise RuntimeError(f"the adapter's model_warp is not a warp: {', '.join(problems)}")
warp = Warp(parts.warps.load(cam_w, cam_h, *geometry), face.frame_size(cam_w, cam_h))
except Exception:
link.close()
raise
@@ -157,9 +157,9 @@ class JetlinkModelState:
# model manager key off this flag
self.chestnut = True
# a warp.Warp or the adapter's model_warp, loaded and warmed ahead: its
# first call links the JIT, and this runs on modeld's frame thread. Frames
# go out of its output as they are, over either transport
# a warp.Warp, loaded and warmed ahead: its first call links the JIT, and
# this runs on modeld's frame thread. Frames go out of its output as they
# are, over either transport
self.warp = warp
self.input_shapes = spec.input_shapes
@@ -305,9 +305,9 @@ class JetlinkModelState:
# over the cable: how frames go, how many never came back, and what the
# kernel dropped on the way in (softnet), since the link came up
drops = getattr(self.client.t, 'net_drops', lambda: None)()
cable = (f"; frames {'as datagrams' if self.client.t.datagrams else 'on the stream'}, " +
cable = (f"; frames {'as datagrams' if self.client.t.datagrams else 'on the stream'}, "
f"{self.client.frames_lost} lost, net drops {drops}" if drops is not None else '')
self._log.warning("jetlink: frame %d warp %.1f data %.1f send %.1f wait %.1f ms%s; " +
self._log.warning("jetlink: frame %d warp %.1f data %.1f send %.1f wait %.1f ms%s; "
"server gpu %.1f queue %.1f total %.1f ms; ffs maxima prepare %.1f read_wait %.1f handoff %.1f ms%s",
self._frame_id, (frame.t1 - frame.t0) * 1e3, (frame.t2 - frame.t1) * 1e3,
(frame.sent - frame.t2) * 1e3, (t4 - waiting_from) * 1e3,
+5 -10
View File
@@ -161,7 +161,7 @@ class Warp:
out = jit.captured.ret.uop.base.buffer
self._device = out.device
if self._device.startswith('QCOM'):
make_coherent(out)
_make_coherent(out)
# the two buffers every frame passes, written in place by start()
self._tensors = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize()
for name, (shape, dtype, device) in specs.items()}
@@ -191,15 +191,10 @@ class Warp:
self._replay(self._inputs, {})
def make_coherent(out) -> None:
"""Move a QCOM buffer to KGSL memory flagged IOCOHERENT and write-back, so
the CPU reads it through its cache once the GPU is done (see Warp). Raises
if the kernel keeps either flag back (the allocation's record holds its
answer): write-back memory that is not coherent reads stale frames.
For an adapter's model_warp too. Move a buffer before the first call of
the JIT that writes it: that call links its address into the JIT, which
would keep writing the memory this frees."""
def _make_coherent(out) -> None:
"""Move a QCOM buffer to KGSL memory flagged IOCOHERENT and write-back.
Raises if the kernel keeps either flag back (the allocation's record holds
its answer): write-back memory that is not coherent reads stale frames."""
from tinygrad.device import Device
dev = Device[out.device]
mem = dev._gpu_alloc(out.nbytes, flags=COHERENT_WRITEBACK)
-5
View File
@@ -1,10 +1,5 @@
#!/usr/bin/env bash
# Device Params must not depend on the platform used to compile native libraries.
if [ -f /AGNOS ] && [ -z "${PARAMS_ROOT:-}" ]; then
export PARAMS_ROOT="/data/params"
fi
export OMP_NUM_THREADS=1
export MKL_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1
Binary file not shown.
@@ -14,96 +14,21 @@ class BluePilotLateral:
self.announced = Command()
self.announced_ns = 0
self.lka_counter = 0
self.control_counter = 0
self.control_sent_ns = 0
self.shadow_sent_ns = 0
self.control_echo_ns = 0
self.shadow_echo_ns = 0
self.control_returned_ns = 0
self.shadow_returned_ns = 0
self.control_echo_pending = False
self.shadow_echo_pending = False
self.control_return_payload = None
self.shadow_return_payload = None
self.pending_generation = 0
self.announced_generation = -1
self.sent_generation = -2
self.steering_rejection_ns = 0
self.apply_curvature_last = 0.0
def observe_can(self, can_packets, main_bus):
# Forwarded stock traffic also returns with src|0x80. Consume one matching
# return for our actual emitted payload; unrelated stock echoes cannot pace us.
# Preserve return ordering here;
# start the delay when consumed, in the controller's own clock domain.
control_address = 0x3D6 if self.canfd else 0x3D3
for timestamp, frames in can_packets:
if timestamp <= 0:
continue
for address, data, source in frames:
if len(data) != 8:
continue
if source == (main_bus | 0xC0) and address in (control_address, 0x3CA):
if timestamp > self.steering_rejection_ns:
self.steering_rejection_ns = timestamp
self.host.steering_rejected = True
continue
if source != (main_bus | 0x80):
continue
if address == control_address and data == self.control_return_payload and timestamp > self.control_returned_ns:
self.control_return_payload = None
self.control_returned_ns = timestamp
self.control_echo_pending = True
elif address == 0x3CA and data == self.shadow_return_payload and timestamp > self.shadow_returned_ns:
self.shadow_return_payload = None
self.shadow_returned_ns = timestamp
self.shadow_echo_pending = True
def step_lateral(self, CC, CS, packer, CAN, frame, now_ns):
if now_ns <= 0:
return []
if self.control_echo_pending:
self.control_echo_ns = now_ns
self.control_echo_pending = False
if ((self.control_sent_ns and now_ns - self.control_sent_ns < 50_000_000) or
(self.control_echo_ns and now_ns - self.control_echo_ns < 45_000_000) or
self.announced_generation <= self.sent_generation):
return []
self.pending = self.host.update(CC, CS)
self.pending_generation += 1
command = self.announced
# A pending permission withdrawal is immediate; an increase waits until
# the 33Hz side channel has announced the matching final actuator.
fresh = 0 < self.announced_ns <= now_ns <= self.announced_ns + 60_000_000
if not self.pending.active or not fresh:
if not self.pending.active or not 0 < self.announced_ns <= now_ns <= self.announced_ns + 60_000_000:
command = Command()
self.announced = command
if not fresh:
# Native records the neutral actuator as zero. Do not resume an old
# nonzero path after a new announcement; restart through the normal limits.
self.host.controller.reset_actuator()
self.pending = Command()
self.control_sent_ns = now_ns
self.sent_generation = self.announced_generation
message = lateral(packer, CAN.main, command, canfd=self.canfd, counter=self.control_counter)
self.control_return_payload = message[1]
self.control_counter = (self.control_counter + 1) % 16
return [message]
return [lateral(packer, CAN.main, command, canfd=self.canfd, counter=frame // 5)]
def step_lka(self, packer, CAN, now_ns):
if now_ns <= 0:
return []
if self.shadow_echo_pending:
self.shadow_echo_ns = now_ns
self.shadow_echo_pending = False
if ((self.shadow_sent_ns and now_ns - self.shadow_sent_ns < 30_000_000) or
(self.shadow_echo_ns and now_ns - self.shadow_echo_ns < 25_000_000)):
return []
self.announced = self.pending
self.announced_generation = self.pending_generation
self.announced_ns = now_ns
self.shadow_sent_ns = now_ns
message = lka(packer, CAN.main, self.announced.shadow_curvature, self.lka_counter)
self.shadow_return_payload = message[1]
self.lka_counter = (self.lka_counter + 1) % 8
return [message]
@@ -51,9 +51,8 @@ class Command:
class AngleController:
def __init__(self, *, low_gain=1.0, high_gain=1.15, trim_config=None, recovery_blips=True):
def __init__(self, *, low_gain=1.0, high_gain=1.15, trim_config=None):
self.trim_config = TrimConfig() if trim_config is None else trim_config
self.recovery_blips = recovery_blips
self.trim = LaneCenterTrim()
self.low_gain = low_gain
self.high_gain = high_gain
@@ -69,13 +68,10 @@ class AngleController:
self.blip_hold = 0.0
self.blip_count = 0
def reset_actuator(self):
def reset(self):
self.trim.reset()
self.path_last = 0.0
self.speed_last = 0.1
def reset(self):
self.reset_actuator()
self.desired_last = 0.0
self.human_timer = 0.0
self.press_last = False
@@ -111,7 +107,7 @@ class AngleController:
if i.steering_pressed:
self.press_timer += 0.05
else:
if self.recovery_blips and self.press_timer >= 0.5 and self.blip_cooldown <= 0.0 and self.blip_frames <= 0 and abs(self.path_last) < 0.1:
if self.press_timer >= 0.5 and self.blip_cooldown <= 0.0 and self.blip_frames <= 0 and abs(self.path_last) < 0.1:
self.blip_frames = 6
self.press_timer = 0.0
if self.blip_frames > 0:
@@ -131,7 +127,7 @@ class AngleController:
entering = bool(curves) and abs(interp(base, i.time_indices, curves)) > abs(i.desired_curvature)
factor = 1.0 if entering else interp(abs(i.desired_curvature), (0.005, 0.020), (1.0, 0.0))
lookup = base + bounds.VLT_EXTRA * interp(i.speed, (11.176, 24.5872), (1.0, 0.0)) * factor
predicted = interp(lookup, i.time_indices, curves) if curves else 0.0
predicted = interp(lookup, i.time_indices, curves) if curves else i.desired_curvature
saturated = i.limit_status >= 2 or self.path_last >= 0.5235 * 0.9 or self.path_last <= -0.5 * 0.9
falling = abs(i.desired_curvature) < abs(self.desired_last) - 0.010
blend = bounds.BLEND * 0.25 if not entering and (i.limit_status >= 1 or saturated or falling) else bounds.BLEND
@@ -198,7 +194,7 @@ class AngleController:
gap = i.desired_curvature - i.measured_curvature
stalled = (not i.steering_pressed and not i.lane_change and i.speed > 9.0 and abs(gap) > 0.004 and
abs(i.desired_curvature) > abs(i.measured_curvature))
if self.recovery_blips and stalled:
if stalled:
if abs(kappa - requested) > 1e-9 and self.blip_cooldown <= 0.0:
self.blip_hold += 0.05
if self.blip_hold >= 0.5 and self.blip_count < 3 and abs(path) < 0.1:
@@ -4,7 +4,6 @@ Only exact final CP words derived from the existing five classic/six CANFD
extended profiles can select angle. Combined AOL retains its original Ford profile identity.
"""
from opendbc.car import structs
from opendbc.car.ford.pinion import base_word, configured, MASK as PINION_MASK
from opendbc.car.ford.values import CAR, FordFlags
from opendbc.bluepilot_lateral.core.angle import AngleController, Inputs
from opendbc.bluepilot_lateral.core.trim import LaneGeometry
@@ -40,9 +39,7 @@ def mode_word(cp, *, selected):
expected = original | (256 if manual else 0) | (ANGLE_FLAG if selected else 0)
else:
expected = ANGLE_FLAG + (2 if fd else 0) + long if selected else original
valid = (safety.safetyModel == structs.CarParams.SafetyModel.ford and base_word(safety.safetyParam) == expected and
(safety.safetyParam == expected or configured(cp)))
return expected if valid else None
return expected if safety.safetyModel == structs.CarParams.SafetyModel.ford and safety.safetyParam == expected else None
def supported(cp):
@@ -61,8 +58,7 @@ def select(cp, raw):
if cp.alternativeExperience == 32:
cp.safetyConfigs[-1].safetyParam |= ANGLE_FLAG
else:
metadata = cp.safetyConfigs[-1].safetyParam & PINION_MASK
cp.safetyConfigs[-1].safetyParam = (ANGLE_FLAG + (2 if cp.flags & FordFlags.CANFD else 0) + int(cp.openpilotLongitudinalControl)) | metadata
cp.safetyConfigs[-1].safetyParam = ANGLE_FLAG + (2 if cp.flags & FordFlags.CANFD else 0) + int(cp.openpilotLongitudinalControl)
cp.steerActuatorDelay = 0.22
return True
@@ -90,23 +86,16 @@ class Host:
raise ValueError("BluePilot angle requires an exact admitted Ford safety word")
low, high = (0.95, 0.95) if cp.carFingerprint in TRUCKS else ((1.0, 1.05) if cp.carFingerprint in CANFD else (1.0, 1.15))
self.cp = cp
from opendbc.car.ford.pinion import Measurement
self.measurement = Measurement(cp)
self.controller = AngleController(low_gain=low, high_gain=high)
self.inputs = None
self.steering_rejected = False
self.tx_blocked = None
def update(self, CC, CS):
if self.inputs is not None:
self.inputs.update()
self.measurement.update_parameters(getattr(self.inputs, "vehicle_parameters", None))
snapshot = self.inputs.lateral_snapshot(CS.out.vEgoRaw, angle=True) if self.inputs is not None else None
snapshot = self.inputs.lateral_snapshot(CS.out.vEgoRaw) if self.inputs is not None else None
model, times, delay, _ = snapshot if snapshot is not None else (None, (), 0.15, True)
aol = self.cp.alternativeExperience == 32
# The host is constructed before AOL marks the final CarParams.
# Retained AOL keeps EPS engaged through release and tracking error;
# physical human-turn pauses and fault recovery remain independent.
self.controller.recovery_blips = not aol
ordinary = CC.enabled and CS.out.cruiseState.enabled and not CS.out.gasPressed and not CS.out.brakePressed
ready = (self.inputs is not None and self.inputs.native_permission(self.cp, max_age_ns=bounds.PANDA_AGE_NS) and
(aol and CS.out.cruiseState.available or ordinary) and CC.latActive and CS.out.canValid and
@@ -114,10 +103,17 @@ class Host:
(not aol or CS.out.gearShifter in (structs.CarState.GearShifter.drive,
structs.CarState.GearShifter.sport, structs.CarState.GearShifter.low)) and
not CS.out.steerFaultTemporary and not CS.out.steerFaultPermanent and not CS.out.vehicleSensorsInvalid)
# Only an actual steering/shadow rejection invalidates its accepted path
# and counter history. Unrelated ACC/UI rejections do not reset steering.
rejected, self.steering_rejected = self.steering_rejected, False
ready = ready and not rejected
# Rejected frames do not advance Panda's accepted counter/path history.
# One real blocked-TX increment requires an accepted neutral resynchronization,
# rather than sending an indefinitely rejected chain of active counters.
counter_reader = getattr(self.inputs, "native_tx_blocked", None)
blocked = counter_reader() if ready and callable(counter_reader) else None
if not ready:
self.tx_blocked = None
elif type(blocked) is int and 0 <= blocked <= 0xFFFFFFFF:
changed = self.tx_blocked is not None and blocked != self.tx_blocked
self.tx_blocked = blocked
ready = not changed
speed_min = speed_max = None
speed_window = getattr(CS, "bluepilot_speed_window", None)
if speed_window is not None:
@@ -128,7 +124,7 @@ class Host:
speed_max = max(high, CS.out.vEgoRaw, 0.1) + margin
meta = model.meta if model is not None else None
return self.controller.step(Inputs(
active=ready, speed=CS.out.vEgoRaw, measured_curvature=self.measurement.curvature(CS.out),
active=ready, speed=CS.out.vEgoRaw, measured_curvature=-CS.out.yawRate / max(CS.out.vEgoRaw, 0.1),
desired_curvature=CC.actuators.curvature, steering_pressed=CS.out.steeringPressed,
steering_angle=CS.out.steeringAngleDeg, yaw_predictions=tuple(model.orientationRate.z) if model is not None else (),
time_indices=tuple(times), lateral_delay=delay,
@@ -213,8 +213,8 @@ static bool ford_bp_lateral(const CANPacket_t *msg) {
(SAFETY_ABS(path) >= (relation_low - 2.0F)) && (SAFETY_ABS(path) <= (relation_high + 2.0F)) &&
(SAFETY_ABS(shadow) <= (physical_cap + FORD_BP_SHADOW_ROUNDING_MARGIN)) && (path_curvature_high <= (physical_cap + FORD_BP_SHADOW_ROUNDING_MARGIN));
if (speed_max > 10.0F) {
const int measured_min = curvature_state.meas.min - (ford_pinion_enabled ? 150 : 100);
const int measured_max = curvature_state.meas.max + (ford_pinion_enabled ? 150 : 100);
const int measured_min = curvature_state.meas.min - 100;
const int measured_max = curvature_state.meas.max + 100;
const float measured_min_float = (float)measured_min;
const float measured_max_float = (float)measured_max;
valid &= (shadow_can >= measured_min_float) && (shadow_can <= measured_max_float);
@@ -26,14 +26,8 @@
},
"starpilot_curvature_controller": {
"origin": "opendbc_repo/opendbc/car/ford/carcontroller.py",
"sha256": "9838f258b7a4eef48c8c1ed4fb40d180939b86e22ac9079a734d1eabf4c0060e",
"revision": "d2ae98eb3959c88011ed537e7cbc033d91fd3efa",
"intentional_changes": [
{
"commit": "d60f2d7ada6cf6aa0f32e881aa916809686a65a6",
"contract": "Generic CAN-FD stock-idle and same-batch mode-before-bounded-command handoff. Retains the pre-angle curvature oracle and all full-byte comparisons."
}
]
"sha256": "c131d1d5f4231004e00f46968988b5d5f379ec22ef8c44d7f67ded8464344855",
"revision": "d2ae98eb3959c88011ed537e7cbc033d91fd3efa"
},
"bpdev_trim": {
"origin": "opendbc_repo/opendbc/sunnypilot/car/ford/lane_center_trim.py",
@@ -160,17 +160,13 @@ class CarController(CarControllerBase):
else:
self.classic_lateral.set_inputs(None, (), 0.2, self.manual_turn_inputs.enabled if self.manual_turn_inputs is not None else True)
previous = self.classic_lateral.curvature_last
demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced or self.generic_canfd else FordLateralResult()
demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced else FordLateralResult()
bound = generic_canfd_bounded_command if self.generic_canfd else classic_bounded_command
lateral = bound(self.classic_lateral, demanded, previous, CS.out.vEgoRaw,
-CS.out.yawRate / max(CS.out.vEgoRaw, 0.1))
self.classic_lateral_demand = demanded
self.apply_curvature_last = lateral.curvature
if self.generic_canfd:
if self.classic_extended_announced != bool(lateral.active):
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN) if lateral.active else
fordcan.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = bool(lateral.active)
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(create_extended_canfd_msg(
self.packer, self.CAN, lateral.active, lateral.ramp_type, lateral.precision_type,
@@ -225,8 +221,7 @@ class CarController(CarControllerBase):
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, lateral_active, 0., 0., -self.apply_curvature_last, 0.))
# send lka msg at 33Hz
if (self.frame % CarControllerParams.LKA_STEP) == 0 and not any(
address == 0x3CA for address, _, _ in can_sends):
if (self.frame % CarControllerParams.LKA_STEP) == 0:
if self.CP.flags & FordFlags.LKA_STEERING:
source_age_ns = now_nanos - CS.lkas_available_ts_nanos
lka_active = (CC.latActive and CS.lkas_available and 0 <= source_age_ns <= 100_000_000 and
@@ -255,11 +250,8 @@ class CarController(CarControllerBase):
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.mache_extended_announced = True
elif self.classic_lateral is not None:
if self.generic_canfd and not self.classic_extended_announced:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
else:
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = True
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = True
else:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
@@ -57,27 +57,6 @@ class TestAngleCore(unittest.TestCase):
self.assertTrue(resumed.active)
self.assertLess(abs(resumed.path_angle), .02)
def test_retained_aol_keeps_active_after_release_and_tracking_error(self):
for stalled in (False, True):
core = AngleController(recovery_blips=False)
for tick in range(100):
inputs = Inputs(True, 15., 0., .006 if stalled else .001,
steering_pressed=not stalled and tick < 20)
result = core.step(inputs)
self.assertTrue(result.active, (stalled, tick))
self.assertLessEqual(abs(result.path_angle / (15. * limits.GAIN_MIN)), limits.LATERAL_ACCEL / 15. ** 2)
self.assertEqual(core.blip_frames, 0)
def test_retained_aol_preserves_human_turn_and_invalid_input_neutral(self):
core = AngleController(recovery_blips=False)
for _ in range(61):
result = core.step(Inputs(True, 15., 0., .001, steering_pressed=True, steering_angle=60.))
self.assertEqual(result, Command())
self.assertEqual(core.step(Inputs(True, 15., 0., math.nan)), Command())
self.assertEqual(core.step(Inputs(False, 15., 0., .001)), Command())
# Infeasible measured-curvature and actuator envelopes must still go neutral.
self.assertEqual(core.step(Inputs(True, 15., .1, .001)), Command())
def test_pinned_bp7_pure_angle_and_actual_control_bytes_with_documented_limits(self):
builders = reference.builders()
bus = SimpleNamespace(main=0)
@@ -192,7 +171,7 @@ class TestAngleTrim(unittest.TestCase):
for changes in ({"active": False}, {"lane_change": True}, {"steering_pressed": True}):
trim = LaneCenterTrim()
trim.update(0., 15., original, config, active=True, lane_change=False)
flags = {"active": True, "lane_change": False, "steering_pressed": False}
flags = dict(active=True, lane_change=False, steering_pressed=False)
flags.update(changes)
self.assertEqual(trim.update(.001, 15., original, config, **flags), .001)
self.assertEqual(trim.correction, 0.)
@@ -232,19 +211,3 @@ class TestAngleTrim(unittest.TestCase):
else:
previous = 0.
self.assertEqual(core.trim.correction, 0.)
class TestAngleInputParity(unittest.TestCase):
def test_missing_model_fallback_matches_pinned_bp7(self):
for identity in sorted(CLASSIC | CANFD):
gains = (.95, .95) if identity in TRUCKS else ((1., 1.05) if identity in CANFD else (1., 1.15))
for speed in (5., 8.):
for direction in (-1, 1):
adapted = AngleController(low_gain=gains[0], high_gain=gains[1])
upstream = reference.Angle(identity)
for _ in range(20):
inputs = Inputs(True, speed, direction * .001, direction * .001)
command, expected = adapted.step(inputs), upstream.step(inputs)
self.assertTrue(command.active)
self.assertAlmostEqual(command.path_angle, expected.path_angle, places=12)
self.assertEqual(command.precision, expected.precision_type)
@@ -1,179 +0,0 @@
"""Mach-E route 0000000d: refresh before publication; neutral restarts from zero."""
import pytest
from collections import defaultdict
from types import SimpleNamespace
from opendbc.car import structs, Bus
from opendbc.car.ford.values import CAR
from opendbc.car.ford.carcontroller import CarController
from opendbc.bluepilot_lateral.core.angle import AngleController, Inputs, Command
class CoreHost:
def __init__(self):
self.controller = AngleController(low_gain=1., high_gain=1.05, recovery_blips=False)
self.steering_rejected = False
self.steering_pressed = False
self.steering_angle = 0.
def update(self, cc, cs):
active = cc.latActive and not self.steering_rejected
self.steering_rejected = False
return self.controller.step(Inputs(active, 32.6, 0., .0012, steering_pressed=self.steering_pressed, steering_angle=self.steering_angle))
def setup(canfd=True):
cp = structs.CarParams(brand='ford', carFingerprint=CAR.FORD_MUSTANG_MACH_E_MK1 if canfd else CAR.FORD_FOCUS_MK4,
flags=3 if canfd else 2, pcmCruise=True, alternativeExperience=32,
transmissionType=structs.CarParams.TransmissionType.automatic,
steerControlType=structs.CarParams.SteerControlType.angle,
openpilotLongitudinalControl=True)
cp.safetyConfigs = [structs.CarParams.SafetyConfig(safetyModel=structs.CarParams.SafetyModel.ford, safetyParam=147 if canfd else 161)]
owner = CarController({Bus.pt:'ford_lincoln_base_pt'}, cp)
owner.bp_lat.host = CoreHost()
cc = structs.CarControl(latActive=True)
state = structs.CarState(vEgo=32.6, vEgoRaw=32.6, canValid=True,
gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
cs = SimpleNamespace(out=state.as_reader(), buttons_stock_values=defaultdict(int),
acc_tja_status_stock_values=defaultdict(int), lkas_status_stock_values=defaultdict(int))
return owner, cc.as_reader(), cs
def control_active(message):
return ((message[1][0] >> 4) if message[0] == 0x3D6 else (message[1][4] >> 2)) & 7
def prime(owner, cc, cs):
for now in range(1_000_000_000, 2_000_000_000, 10_000_000):
owner.update(cc, cs, now)
assert owner.bp_lat.pending.active
assert abs(owner.bp_lat.host.controller.path_last) > .015
def test_due_shadow_precedes_control_after_recorded_72ms_gap():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
now = max(bp.shadow_sent_ns, bp.control_sent_ns) + 72_000_000
_, messages = owner.update(cc, cs, now)
angle = [m for m in messages if m[0] in (0x3CA, 0x3D6)]
assert [m[0] for m in angle] == [0x3CA, 0x3D6]
assert control_active(angle[-1]) == 1
assert bp.announced_ns == now
def test_unrefreshable_stale_lease_neutral_clears_pending_actuator_history():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
now = max(bp.shadow_sent_ns, bp.control_sent_ns) + 72_000_000
# A newly consumed late shadow echo still enforces its pacing delay.
bp.shadow_echo_pending = True
_, messages = owner.update(cc, cs, now)
angle = [m for m in messages if m[0] in (0x3CA, 0x3D6)]
assert [m[0] for m in angle] == [0x3D6]
assert control_active(angle[0]) == 0
assert bp.pending == Command()
assert bp.host.controller.path_last == 0.
# A subsequent announcement cannot revive the old nonzero actuator.
seen = []
for t in range(now + 10_000_000, now + 300_000_000, 10_000_000):
_, messages = owner.update(cc, cs, t)
seen.extend(m for m in messages if m[0] == 0x3D6 and control_active(m))
assert seen
sig = owner.packer.dbc.name_to_msg['LateralMotionControl2'].sigs['LatCtlPath_An_Actl']
first_path = sig.get_raw_value(seen[0][1]) * sig.factor + sig.offset
assert abs(first_path) <= .005
@pytest.mark.parametrize('canfd, word', [(True, 147), (False, 161)])
def test_aol_native_accepts_due_refresh_and_stale_restart_without_relaxation(canfd, word):
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
from opendbc.safety.tests.libsafety import libsafety_py
native = TestFordAolDriverIntent()
native.setUp()
native.reset(word)
native.arm()
native.pump(30, speed=32.6, mask=1)
owner, cc, cs = setup(canfd)
active = 0
try:
for tick in range(200):
native.pump(1, speed=32.6, mask=1)
now = (1_000_000 + native.tick * 10_000) * 1000
if tick in (110, 150):
# No host update for 70ms, but native receives fresh vehicle sources.
native.pump(7, speed=32.6, mask=1)
now = (1_000_000 + native.tick * 10_000) * 1000
if tick == 150:
owner.bp_lat.shadow_echo_pending = True
_, messages = owner.update(cc, cs, now)
for address, data, bus in messages:
if address not in (0x3CA, 0x3D6, 0x3D3):
continue
assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data)), (tick, hex(address), data.hex())
active += address in (0x3D6, 0x3D3) and bool(control_active((address, data, bus)))
assert active > 20
finally:
native.tearDown()
def test_rejected_shadow_is_not_an_ack_and_withdraws_next_control():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
now = max(bp.shadow_sent_ns, bp.control_sent_ns) + 72_000_000
payload = bp.shadow_return_payload
bp.observe_can([(now, [(0x3CA, payload, 0xC0)])], 0)
assert bp.host.steering_rejected
assert not bp.shadow_echo_pending
_, messages = owner.update(cc, cs, now)
controls = [m for m in messages if m[0] == 0x3D6]
assert controls and control_active(controls[0]) == 0
assert bp.host.controller.path_last == 0.
def test_unrelated_or_out_of_order_returns_do_not_refresh_shadow_time():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
sent = bp.shadow_sent_ns
payload = bp.shadow_return_payload
bp.shadow_returned_ns = sent + 10_000_000
bp.observe_can([(sent, [(0x3CA, payload, 0x80)])], 0)
bp.observe_can([(sent + 20_000_000, [(0x3CA, bytes(8), 0x80)])], 0)
assert not bp.shadow_echo_pending
assert bp.announced_ns == sent
def test_permission_withdrawal_does_not_publish_active_even_with_due_shadow():
owner, cc, cs = setup()
prime(owner, cc, cs)
now = max(owner.bp_lat.shadow_sent_ns, owner.bp_lat.control_sent_ns) + 72_000_000
_, messages = owner.update(structs.CarControl().as_reader(), cs, now)
controls = [m for m in messages if m[0] == 0x3D6]
assert controls and control_active(controls[0]) == 0
assert owner.bp_lat.pending == Command()
def test_stale_transport_reset_preserves_continuous_manual_turn_pause():
owner, cc, cs = setup()
prime(owner, cc, cs)
host = owner.bp_lat.host
host.steering_pressed = True
host.steering_angle = 60.
for _ in range(70):
command = host.update(cc, cs)
assert not command.active
assert host.controller.human_timer >= 3.
now = max(owner.bp_lat.shadow_sent_ns, owner.bp_lat.control_sent_ns) + 72_000_000
owner.bp_lat.shadow_echo_pending = True
_, messages = owner.update(cc, cs, now)
assert not any(control_active(m) for m in messages if m[0] == 0x3D6)
assert host.controller.human_timer >= 3.
assert host.controller.press_last and host.controller.preturned
for t in range(now + 10_000_000, now + 500_000_000, 10_000_000):
_, messages = owner.update(cc, cs, t)
assert not any(control_active(m) for m in messages if m[0] == 0x3D6)
+17 -28
View File
@@ -1,24 +1,19 @@
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdint.h>
#include <string.h>
typedef struct {
PyObject *names[4];
} ExtractState;
static PyObject *get_raw_value(PyObject *self, PyObject *const *args, Py_ssize_t nargs) {
static PyObject *get_raw_value(PyObject *self, PyObject *args) {
ExtractState *state = PyModule_GetState(self);
PyObject *signal, *payload, *expected;
PyObject *msb_obj = NULL, *lsb_obj = NULL, *size_obj = NULL, *little_obj = NULL;
const unsigned char *data;
Py_ssize_t length;
long msb, lsb, size, little, first, last;
if (nargs != 3) {
PyErr_Format(PyExc_TypeError, "get_raw_value expected 3 arguments, got %zd", nargs);
return NULL;
}
signal = args[0]; payload = args[1]; expected = args[2];
if (!PyArg_UnpackTuple(args, "get_raw_value", 3, 3, &signal, &payload, &expected)) return NULL;
if (state == NULL || state->names[0] == NULL || state->names[1] == NULL ||
state->names[2] == NULL || state->names[3] == NULL) goto unsupported;
if (!PyType_Check(expected) || Py_TYPE(signal) != (PyTypeObject *)expected) goto unsupported;
@@ -59,27 +54,21 @@ static PyObject *get_raw_value(PyObject *self, PyObject *const *args, Py_ssize_t
if (size != 8 * (last - first) + msb % 8 - lsb % 8 + 1) goto unsupported;
}
if (first < 0 || first > last || last >= length) goto unsupported;
/* Contiguous signals need at most nine bytes, including an unaligned
64-bit field. memcpy permits unaligned payloads without aliasing UB. */
const unsigned count = (unsigned)(last - first + 1);
const unsigned shift = (unsigned)(lsb % 8);
uint64_t raw = 0;
memcpy(&raw, data + first, count > 8U ? 8U : count);
#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
raw = __builtin_bswap64(raw);
#elif __BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__
#error Unsupported byte order
#endif
uint64_t result;
if (little) {
result = raw >> shift;
if (count == 9U) result |= (uint64_t)data[last] << (64U - shift);
} else {
raw = __builtin_bswap64(raw);
if (count == 9U) result = (raw << (8U - shift)) | ((uint64_t)data[last] >> shift);
else result = (raw >> (8U * (8U - count))) >> shift;
uint64_t result = 0;
long remaining = size;
for (long byte = msb / 8; remaining > 0; byte += little ? -1 : 1) {
if (byte < 0 || byte >= length) goto unsupported;
const long low = lsb / 8 == byte ? lsb : byte * 8;
const long high = msb / 8 == byte ? msb : (byte + 1) * 8 - 1;
const long width = high - low + 1;
const long bit_shift = low - byte * 8;
const long result_shift = remaining - width;
if (width <= 0 || width > 8 || bit_shift < 0 || bit_shift > 7 ||
result_shift < 0 || result_shift > 63) goto unsupported;
const unsigned int fragment = (data[byte] >> bit_shift) & ((1U << width) - 1U);
result |= (uint64_t)fragment << result_shift;
remaining -= width;
}
if (size < 64) result &= (UINT64_C(1) << size) - 1U;
Py_DECREF(msb_obj);
Py_DECREF(lsb_obj);
Py_DECREF(size_obj);
@@ -97,7 +86,7 @@ unsupported:
}
static PyMethodDef methods[] = {
{"get_raw_value", (PyCFunction)(void(*)(void))get_raw_value, METH_FASTCALL, "Extract an exact complete <=64-bit CAN signal, or NotImplemented."},
{"get_raw_value", get_raw_value, METH_VARARGS, "Extract an exact complete <=64-bit CAN signal, or NotImplemented."},
{NULL, NULL, 0, NULL},
};
static int module_traverse(PyObject *self, visitproc visit, void *arg) {
Binary file not shown.
+3 -17
View File
@@ -112,18 +112,10 @@ class VLDict(dict):
class CANParser:
def __init__(self, dbc_name: str, messages: Sequence[tuple[str | int, float]], bus: int, *, signals: dict[str | int, Sequence[str]] | None = None):
def __init__(self, dbc_name: str, messages: Sequence[tuple[str | int, float]], bus: int):
self.dbc_name: str = dbc_name
self.bus: int = bus
self.dbc = DBC(dbc_name)
self._signal_selection: dict[int, frozenset[str]] = {}
for key, names in (signals or {}).items():
message = self.dbc.addr_to_msg.get(int(key)) if isinstance(key, numbers.Number) else self.dbc.name_to_msg.get(key)
if message is None or not set(names).issubset(message.sigs):
raise ValueError(f"Unknown signal selection for {key!r} in DBC {dbc_name}")
if message.address in self._signal_selection:
raise ValueError(f"Duplicate signal selection for {key!r}")
self._signal_selection[message.address] = frozenset(names)
self.vl: dict[int | str, dict[str, float]] = VLDict(self)
self.vl_all: dict[int | str, dict[str, list[float]]] = {}
@@ -156,13 +148,7 @@ class CANParser:
assert msg.address not in self.addresses
self.addresses.add(msg.address)
selected = self._signal_selection.get(msg.address)
# Projection only: counters and checksum fields still use the original
# decoder, validation, rejection and freshness path on every message.
decoded_signals = [sig for sig in msg.sigs.values() if selected is None or sig.name in selected or
sig.type != 0 or sig.calc_checksum is not None or
'COUNTER' in sig.name or 'CHECKSUM' in sig.name]
signal_names = [sig.name for sig in decoded_signals]
signal_names = list(msg.sigs.keys())
signals_dict = {s: 0.0 for s in signal_names}
dict.__setitem__(self.vl, msg.address, signals_dict)
dict.__setitem__(self.vl, msg.name, signals_dict)
@@ -175,7 +161,7 @@ class CANParser:
address=msg.address,
name=msg.name,
size=msg.size,
signals=decoded_signals,
signals=list(msg.sigs.values()),
ignore_alive=freq is not None and math.isnan(freq),
)
if freq is not None and freq > 0:
@@ -1,52 +0,0 @@
import pytest
from opendbc.can import CANPacker, CANParser
from opendbc.can.tests import TEST_DBC
@pytest.mark.parametrize('dbc,message,field', [
(TEST_DBC, 'CAN_FD_MESSAGE', 'SIGNED'),
('honda_civic_touring_2016_can_generated', 'STEERING_CONTROL', 'STEER_TORQUE'),
])
def test_selected_values_preserve_integrity_and_freshness(dbc, message, field):
full = CANParser(dbc, [(message, 100)], 0)
selected = CANParser(dbc, [(message, 100)], 0, signals={message: [field]})
packer = CANPacker(dbc)
frames = [packer.make_can_msg(message, 0, {field: 12}) for _ in range(12)]
frames += [frames[-1]] * 8 # Repeated counters must still fail.
damaged = bytearray(frames[-1][1])
damaged[0] ^= 0x80
frames += [(frames[-1][0], bytes(damaged), 0)] * 8
for index, frame in enumerate(frames):
sample = [(1_000_000_000 + index * 10_000_000, [frame])]
assert selected.update(sample) == full.update(sample)
assert selected.can_valid == full.can_valid
assert selected.bus_timeout == full.bus_timeout
for key in selected.vl[message]:
assert selected.vl[message][key] == full.vl[message][key]
assert selected.vl_all[message][key] == full.vl_all[message][key]
assert selected.ts_nanos[message][key] == full.ts_nanos[message][key]
for addr, state in selected.message_states.items():
reference = full.message_states[addr]
assert (state.counter, state.counter_fail, list(state.timestamps)) == (reference.counter, reference.counter_fail, list(reference.timestamps))
for _ in range(5):
selected.update([(9_000_000_000, [])])
full.update([(9_000_000_000, [])])
selected_valid, full_valid = selected.can_valid, full.can_valid
assert selected_valid == full_valid
assert not selected_valid and not full_valid
assert selected.bus_timeout and full.bus_timeout
for signal in full.message_states[frames[0][0]].signals:
if signal.type or signal.calc_checksum:
assert signal.name in selected.vl[message]
def test_unknown_projection_is_rejected_and_lazy_selection_is_honored():
with pytest.raises(ValueError, match='Unknown signal'):
CANParser(TEST_DBC, [], 0, signals={'CAN_FD_MESSAGE': ['not_a_signal']})
with pytest.raises(ValueError, match='Unknown signal'):
CANParser(TEST_DBC, [], 0, signals={'not_a_message': []})
parser = CANParser(TEST_DBC, [], 0, signals={'CAN_FD_MESSAGE': ['SIGNED']})
assert 'SIGNED' in parser.vl['CAN_FD_MESSAGE']
assert 'COUNTER' in parser.vl['CAN_FD_MESSAGE']
assert parser.message_states[245].signals
+11 -24
View File
@@ -10,9 +10,8 @@ from opendbc.car.fw_versions import (ObdCallback, get_fw_versions, get_fw_versio
get_present_ecus, match_fw_to_car)
from opendbc.car.mock.values import CAR as MOCK
from opendbc.car.gm.pedal_capability import automatic_bolt_candidate
from opendbc.car.gm.discovery import automatic_volt_candidate, normal_bus0_topology as gm_normal_bus0_topology
from opendbc.car.ford.discovery import (normal_bus0_selection as ford_normal_bus0_selection,
normal_bus0_queries as ford_normal_bus0_queries, normal_bus_query_brand)
from opendbc.car.gm.discovery import automatic_volt_candidate
from opendbc.car.ford.discovery import normal_bus0_selection as ford_normal_bus0_selection, normal_bus0_queries as ford_normal_bus0_queries
from opendbc.car.hyundai.discovery import normal_bus_topology
from opendbc.car.values import BRANDS
from opendbc.car.vin import get_vin, is_valid_vin, VIN_UNKNOWN
@@ -45,15 +44,13 @@ interface_names = _get_interface_names()
interfaces = load_interfaces(interface_names)
def can_fingerprint(can_recv: CanRecvCallable, *, vin: str = VIN_UNKNOWN,
camera_cruise_messages: set[bytes] | None = None) -> tuple[str | None, dict[int, dict]]:
def can_fingerprint(can_recv: CanRecvCallable) -> tuple[str | None, dict[int, dict]]:
finger = gen_empty_fingerprint()
candidate_cars = {i: all_legacy_fingerprint_cars() for i in [0, 1]} # attempt fingerprint on both bus 0 and 1
frame = 0
car_fingerprint = None
done = False
if camera_cruise_messages is None:
camera_cruise_messages = set()
camera_cruise_states = set()
while not done:
# can_recv(wait_for_one=True) may return zero or multiple packets, so we increment frame for each one we receive
@@ -62,7 +59,7 @@ def can_fingerprint(can_recv: CanRecvCallable, *, vin: str = VIN_UNKNOWN,
for can in can_packet:
# Stock camera only: host echoes and malformed DLCs are not capability evidence.
if can.src == 2 and can.address == 0x370 and len(can.dat) == 6:
camera_cruise_messages.add(bytes(can.dat))
camera_cruise_states.add(can.dat[1] & 0x7)
# The fingerprint dict is generated for all buses, this way the car interface
# can use it to detect a (valid) multipanda setup and initialize accordingly
if can.src < 128:
@@ -90,7 +87,7 @@ def can_fingerprint(can_recv: CanRecvCallable, *, vin: str = VIN_UNKNOWN,
frame += 1
car_fingerprint = automatic_volt_candidate(car_fingerprint, finger, candidate_cars)
return automatic_bolt_candidate(car_fingerprint, finger, camera_cruise_messages, vin), finger
return automatic_bolt_candidate(car_fingerprint, finger, camera_cruise_states), finger
def _gm_fw_uses_normal_bus0() -> bool:
@@ -146,18 +143,15 @@ def fingerprint(can_recv: CanRecvCallable, can_send: CanSendCallable, set_obd_mu
car_fw = get_fw_versions(can_recv, can_send, set_obd_multiplexing,
query_brand='ford', normal_bus_only=True)
ecu_rx_addrs = {(fw.responseAddress, fw.subAddress or None, fw.bus) for fw in car_fw if fw.responseAddress > 0}
elif (normal_brand := normal_bus_query_brand(
can_recv, normal_bus_topology,
gm_probe=gm_normal_bus0_topology if _gm_fw_uses_normal_bus0() else None)) is not None:
elif normal_bus_topology(can_recv):
set_obd_multiplexing(False)
warmup_remaining = 0.1 - (time.monotonic() - start_time)
if warmup_remaining > 0:
time.sleep(warmup_remaining)
vin_buses = (0,) if normal_brand in ('ford', 'gm') else (0, 1)
vin_rx_addr, vin_rx_bus, vin = get_vin(can_recv, can_send, vin_buses)
vin_rx_addr, vin_rx_bus, vin = get_vin(can_recv, can_send, (0, 1))
car_fw = get_fw_versions(can_recv, can_send, set_obd_multiplexing,
query_brand=normal_brand, normal_bus_only=True)
query_brand='hyundai', normal_bus_only=True)
ecu_rx_addrs = {(fw.responseAddress, fw.subAddress or None, fw.bus) for fw in car_fw if fw.responseAddress > 0}
else:
# enable OBD multiplexing for VIN query
@@ -188,8 +182,7 @@ def fingerprint(can_recv: CanRecvCallable, can_send: CanSendCallable, set_obd_mu
# CAN fingerprint
# drain CAN socket so we get the latest messages
can_recv()
camera_cruise_messages: set[bytes] = set()
car_fingerprint, finger = can_fingerprint(can_recv, vin=vin, camera_cruise_messages=camera_cruise_messages)
car_fingerprint, finger = can_fingerprint(can_recv)
exact_match = True
source = CarParams.FingerprintSource.can
@@ -200,11 +193,6 @@ def fingerprint(can_recv: CanRecvCallable, can_send: CanSendCallable, set_obd_mu
source = CarParams.FingerprintSource.fw
exact_match = exact_fw_match
# Firmware catalogues can use the same broad Bolt aliases as legacy CAN.
# Refine after both automatic sources, before the explicit override below.
if not fixed_fingerprint:
car_fingerprint = automatic_bolt_candidate(car_fingerprint, finger, camera_cruise_messages, vin)
if fixed_fingerprint:
car_fingerprint = fixed_fingerprint
source = CarParams.FingerprintSource.fixed
@@ -251,8 +239,7 @@ def get_car(can_recv: CanRecvCallable, can_send: CanSendCallable, set_obd_multip
candidate = "MOCK"
CarInterface = interfaces[candidate]
params_options = {'auto_pedal': False} if source == CarParams.FingerprintSource.fixed and candidate in interface_names['gm'] else {}
CP: CarParams = CarInterface.get_params(candidate, fingerprints, car_fw, alpha_long_allowed, is_release, docs=False, **params_options)
CP: CarParams = CarInterface.get_params(candidate, fingerprints, car_fw, alpha_long_allowed, is_release, docs=False)
CP.carVin = vin
CP.carFw = car_fw
CP.fingerprintSource = source
-7
View File
@@ -12,9 +12,6 @@ ANGLE = 128
ANGLE_BASE_WORDS = frozenset((18, 19, 32, 33, 66, 67))
AOL_WORDS = (BASE_WORDS | frozenset(word | ANGLE for word in ANGLE_BASE_WORDS))
AOL_WORDS |= frozenset(word | MANUAL for word in AOL_WORDS)
from opendbc.car.ford.pinion import ENABLE, SHIFT, PROFILES, base_word, configured
AOL_WORDS |= frozenset(word | ENABLE | index << SHIFT for index, words in PROFILES.items()
for word in tuple(AOL_WORDS) if (word & ~MANUAL) in words)
def aol_safety_config(cp):
@@ -36,10 +33,6 @@ def base_safety_param(cp):
if cp.transmissionType not in (structs.CarParams.TransmissionType.automatic, structs.CarParams.TransmissionType.manual):
return -1
word = int(owner.safetyParam)
normalized = base_word(word)
if normalized != word and not configured(cp):
return -1
word = normalized
if (word & MANUAL and not manual) or (manual and cp.alternativeExperience == AOL_EXPERIENCE and not word & MANUAL):
return -1
if word & ANGLE:
+14 -31
View File
@@ -16,7 +16,7 @@ from opendbc.car.ford.generic_canfd_can import create_extended_canfd_msg
from opendbc.car.ford.generic_canfd_lateral import (
GenericCanfdLateralController, qualified as generic_canfd_qualified, bounded_command as generic_canfd_bounded_command,
)
from opendbc.car.ford.mache_lateral import MachELateralController, FordLateralResult, bounded_command, path_speed_window_supported, qualified as mache_qualified
from opendbc.car.ford.mache_lateral import MachELateralController, FordLateralResult, bounded_command, qualified as mache_qualified
from opendbc.car.ford.new_port_curvature import create_controller as create_new_port_curvature, bounded_command as new_port_bounded_command
from opendbc.car.ford.values import CarControllerParams, FordFlags, FordSafetyFlags, CAR
from opendbc.car.interfaces import CarControllerBase, V_CRUISE_MAX
@@ -142,13 +142,10 @@ class CarController(CarControllerBase):
### lateral control ###
# send steer msg at 20Hz
if self.bp_lat is not None:
# Publish a due shadow before its dependent actuator, not after a stale-lease withdrawal.
can_sends += self.bp_lat.step_lka(self.packer, self.CAN, now_nanos)
lateral_sends = self.bp_lat.step_lateral(CC, CS, self.packer, self.CAN, self.frame, now_nanos)
can_sends += lateral_sends
if (self.frame % CarControllerParams.STEER_STEP) == 0 and self.bp_lat is not None:
can_sends += self.bp_lat.step_lateral(CC, CS, self.packer, self.CAN, self.frame, now_nanos)
self.apply_curvature_last = self.bp_lat.apply_curvature_last
self.mache_ui_owned |= bool(lateral_sends and self.bp_lat.announced.active and self.bp_lat.pending.active)
self.mache_ui_owned |= bool(self.bp_lat.announced.active and self.bp_lat.pending.active)
elif (self.frame % CarControllerParams.STEER_STEP) == 0 and self.CP.flags & FordFlags.LKA_STEERING:
# Preserve the stock LMC heartbeat without requesting lateral action.
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, False, 0., 0., 0., 0.,
@@ -165,8 +162,7 @@ class CarController(CarControllerBase):
previous = self.mache_lateral.curvature_last
previous_path_angle = self.mache_lateral.path_angle_last
demanded = self.mache_lateral.update(CC, CS, actuators) if self.mache_extended_announced else FordLateralResult()
lateral = bounded_command(self.mache_lateral, demanded, previous, CS.out.vEgoRaw, previous_path_angle,
getattr(CS, "bluepilot_speed_window", None) if path_speed_window_supported(self.CP) else None)
lateral = bounded_command(self.mache_lateral, demanded, previous, CS.out.vEgoRaw, previous_path_angle)
self.mache_lateral_demand = demanded
self.mache_ui_owned |= bool(lateral.active)
self.apply_curvature_last = lateral.curvature
@@ -181,20 +177,13 @@ class CarController(CarControllerBase):
else:
self.classic_lateral.set_inputs(None, (), 0.2, self.manual_turn_inputs.enabled if self.manual_turn_inputs is not None else True)
previous = self.classic_lateral.curvature_last
demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced or self.generic_canfd else FordLateralResult()
demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced else FordLateralResult()
bound = generic_canfd_bounded_command if self.generic_canfd else classic_bounded_command
lateral = bound(self.classic_lateral, demanded, previous, CS.out.vEgoRaw,
self.classic_lateral._current_curvature(CS))
-CS.out.yawRate / max(CS.out.vEgoRaw, 0.1))
self.classic_lateral_demand = demanded
self.apply_curvature_last = lateral.curvature
if self.generic_canfd:
# Stay in the stock interface while inactive. Announce a mode change in
# the same batch, before the corresponding bounded steering command.
if self.classic_extended_announced != bool(lateral.active):
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN) if lateral.active else
fordcan.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = bool(lateral.active)
self.mache_ui_owned |= bool(lateral.active)
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(create_extended_canfd_msg(
self.packer, self.CAN, lateral.active, lateral.ramp_type, lateral.precision_type,
@@ -210,7 +199,7 @@ class CarController(CarControllerBase):
previous = self.new_port_curvature.curvature_last
demanded = self.new_port_curvature.update(CC, CS, actuators)
lateral = new_port_bounded_command(self.new_port_curvature, demanded, previous, CS.out.vEgoRaw,
self.new_port_curvature._current_curvature(CS))
-CS.out.yawRate / max(CS.out.vEgoRaw, 0.1))
self.new_port_curvature_demand = demanded
self.apply_curvature_last = lateral.curvature
if self.CP.flags & FordFlags.CANFD:
@@ -265,8 +254,7 @@ class CarController(CarControllerBase):
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, lateral_active, 0., 0., -self.apply_curvature_last, 0.))
# send lka msg at 33Hz
if (self.bp_lat is not None or (self.frame % CarControllerParams.LKA_STEP) == 0) and not any(
address == 0x3CA for address, _, _ in can_sends):
if (self.frame % CarControllerParams.LKA_STEP) == 0:
if self.bp_lat is not None:
can_sends += self.bp_lat.step_lka(self.packer, self.CAN, now_nanos)
elif self.CP.flags & FordFlags.LKA_STEERING:
@@ -297,11 +285,8 @@ class CarController(CarControllerBase):
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.mache_extended_announced = True
elif self.classic_lateral is not None:
if self.generic_canfd and not self.classic_extended_announced:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
else:
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = True
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = True
else:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
@@ -353,11 +338,9 @@ class CarController(CarControllerBase):
### ui ###
# Acquire the display after the first active replacement, then retain it while
# AOL stays active, including a manual-turn steering handoff (as on Dom).
aol_owned_ui = qualified_aol(self.CP, marked_only=True) and any(
s.safetyModel == structs.CarParams.SafetyModel.ford and (s.safetyParam & ~(1 | 128 | 256)) in (18, 66)
for s in self.CP.safetyConfigs)
ui_enabled = CC.latActive and (not aol_owned_ui or self.mache_ui_owned)
if not aol_owned_ui or ui_enabled:
aol_mache_ui = self.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and qualified_aol(self.CP, marked_only=True)
ui_enabled = CC.latActive and (not aol_mache_ui or self.mache_ui_owned)
if not aol_mache_ui or ui_enabled:
send_ui = (self.main_on_last != main_on) or (self.lkas_enabled_last != ui_enabled) or (self.steer_alert_last != steer_alert)
# send lkas ui msg at 1Hz or if ui state changes
if (self.frame % CarControllerParams.LKAS_UI_STEP) == 0 or send_ui:
+1 -3
View File
@@ -6,7 +6,6 @@ from opendbc.can.dbc import DBC as CANDBC
from opendbc.car import Bus, create_button_events, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.ford.fordcan import CanBus
from opendbc.car.ford.mache_lateral import path_speed_window_supported
from opendbc.car.ford.stock_cruise import FordStockCruiseButton
from opendbc.car.ford.values import CAR, DBC, CarControllerParams, FordFlags
from opendbc.car.interfaces import CarStateBase
@@ -41,8 +40,7 @@ class CarState(CarStateBase):
self.lkas_available = False
self.lkas_available_ts_nanos = 0
self.lateral_motion_control = None
self.bluepilot_speed_samples = [0] * 6 if (CP.safetyConfigs and CP.safetyConfigs[-1].safetyParam & 128 or
path_speed_window_supported(CP)) else None
self.bluepilot_speed_samples = [0] * 6 if CP.safetyConfigs and CP.safetyConfigs[-1].safetyParam & 128 else None
self.bluepilot_speed_window = None
def get_car_gps(self):
@@ -1,6 +1,4 @@
"""Normal-bus discovery for explicitly selected Ford CAN-FD vehicles."""
import time
from opendbc.car.ford.values import CAR, FordFlags
@@ -36,53 +34,3 @@ def normal_bus0_queries():
def normal_bus0_selection(candidate):
return bool(candidate in CAR and CAR(candidate).config.flags & FordFlags.CANFD and
not CAR(candidate).config.flags & FordFlags.LKA_STEERING and normal_bus0_queries())
# Normal harness PT/chassis and stock-camera messages. This selects only a
# diagnostic query family; live VIN, ECU responses and fingerprinting still
# determine the vehicle and its control configuration.
NORMAL_BUS0_LAYOUT = {(0, 0x82): 8, (0, 0x165): 8, (0, 0x204): 8,
(0, 0x217): 8, (2, 0x3CA): 8, (2, 0x3D6): 8}
def normal_bus_query_brand(can_recv, hyundai_probe, *, gm_probe=None, clock=time.monotonic):
# Share the existing bounded passive HKG read, rather than consuming a second
# startup window or enabling the OBD mux to find out whether this is a Ford.
received = {}
gm_received = {}
gm_malformed = False
malformed = False
reads = 0
deadline = clock() + .12
def observe(**kwargs):
nonlocal malformed, gm_malformed, reads
reads += 1
packets = can_recv(**kwargs)
for packet in packets:
for frame in packet:
key = (frame.src, frame.address)
if gm_probe is not None:
bus = gm_received.setdefault(frame.src, {})
length = len(frame.dat)
gm_malformed |= frame.address in bus and bus[frame.address] != length
bus[frame.address] = length
if key in NORMAL_BUS0_LAYOUT:
length = len(frame.dat)
malformed |= length != NORMAL_BUS0_LAYOUT[key]
received[key] = length
return packets
if hyundai_probe(observe):
return 'hyundai'
# Rejecting HKG or Ford does not reject GM. Finish the shared passive window
# within its original budget even when an early frame excludes Ford.
while not (not malformed and received == NORMAL_BUS0_LAYOUT) and reads < 24 and clock() < deadline:
if malformed and (gm_probe is None or gm_malformed):
break
observe(wait_for_one=True)
if not malformed and received == NORMAL_BUS0_LAYOUT and normal_bus0_queries():
return 'ford'
if gm_probe is not None and not gm_malformed and gm_probe(gm_received):
return 'gm'
return None
@@ -21,11 +21,6 @@ class CarInterface(CarInterfaceBase):
DRIVABLE_GEARS = (structs.CarState.GearShifter.low, structs.CarState.GearShifter.manumatic)
def update(self, can_packets):
if self.CC is not None and self.CC.bp_lat is not None:
self.CC.bp_lat.observe_can(can_packets, self.CC.CAN.main)
return super().update(can_packets)
@staticmethod
def get_pid_accel_limits(CP, current_speed, cruise_speed):
# PCM doesn't allow acceleration near cruise_speed,
@@ -162,8 +162,6 @@ class FordLateralController:
def __init__(self, CP):
self.CP = CP
from opendbc.car.ford.pinion import Measurement
self.measurement = Measurement(CP)
self.model = None
self.time_indices = ()
self.lookahead = CURVATURE_LOOKAHEAD_MIN
@@ -233,8 +231,9 @@ class FordLateralController:
return MACH_E_HIGH_SPEED_LOOKAHEAD_EXTRA * float(np.interp(
v_ego, [MACH_E_HIGH_SPEED_LOOKAHEAD_START_SPEED, MACH_E_HIGH_SPEED_LOOKAHEAD_FULL_SPEED], [0.0, 1.0]))
def _current_curvature(self, CS) -> float:
return self.measurement.curvature(CS.out)
@staticmethod
def _current_curvature(CS) -> float:
return -CS.out.yawRate / max(CS.out.vEgoRaw, 0.1)
def _driver_assisting_curve(self, CS, desired: float) -> bool:
v_ego = float(CS.out.vEgoRaw)
+3 -12
View File
@@ -114,14 +114,8 @@ def qualified(CP) -> bool:
(FordSafetyFlags.LONG_CONTROL if CP.openpilotLongitudinalControl else 0)))
def path_speed_window_supported(CP) -> bool:
return (qualified(CP) and CP.alternativeExperience == 32 and
len(CP.safetyConfigs) == 1 and CP.safetyConfigs[0].safetyParam ==
int(FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED | FordSafetyFlags.LONG_CONTROL))
def bounded_command(owner: MachELateralController, demanded: FordLateralResult, previous: float, speed: float,
previous_path_angle: float = 0.0, speed_window=None) -> FordLateralResult:
previous_path_angle: float = 0.0) -> FordLateralResult:
"""Intersect original demand with modern transient limits and old absolute cap.
A discontinuous speed change may leave no legal active interval. Withdraw the
@@ -136,12 +130,9 @@ def bounded_command(owner: MachELateralController, demanded: FordLateralResult,
else:
curvature = float(np.clip(demanded.curvature, low, high))
path_angle = 0.0
# Native path assistance checks the maximum of six raw ABS speed samples.
# Do not emit an assist that only the newest (lower) speed sample permits.
path_speed = max(speed, speed_window[1]) if speed_window is not None else speed
if (3.0 <= speed and path_speed < MACH_E_PATH_ANGLE_MAX_SPEED and abs(curvature) >= 0.0195 and
if (3.0 <= speed < MACH_E_PATH_ANGLE_MAX_SPEED and abs(curvature) >= 0.0195 and
curvature * demanded.path_angle > 0.0):
headroom = max(0.0, (MAX_LATERAL_ACCEL / path_speed ** 2 - abs(curvature)) * path_speed)
headroom = max(0.0, (MAX_LATERAL_ACCEL / speed ** 2 - abs(curvature)) * speed)
path_cap = min(MACH_E_PATH_ANGLE_MAX, headroom)
path_low = max(-path_cap, previous_path_angle - MACH_E_PATH_ANGLE_STEP)
path_high = min(path_cap, previous_path_angle + MACH_E_PATH_ANGLE_STEP)
-84
View File
@@ -1,84 +0,0 @@
"""Init-only pinion measurement selection adapted from BluePilot PR #145.
See CREDITS.md and THIRD_PARTY_NOTICES.md for upstream attribution and license.
The geometry is carried in the existing exact Panda safety word, not live Params.
"""
from opendbc.car.ford.values import CAR
KEY = "FordPrefSteerAngleCurvature"
ENABLE = 0x200
SHIFT = 10
MASK = 0x3E00
GEOMETRY = {
CAR.FORD_BRONCO_SPORT_MK1: 1, CAR.FORD_ESCAPE_MK4: 2, CAR.FORD_ESCAPE_MK4_5: 3,
CAR.FORD_EXPEDITION_MK4: 4, CAR.FORD_EXPLORER_MK6: 5, CAR.FORD_FOCUS_MK4: 6,
CAR.FORD_F_150_LIGHTNING_MK1: 7, CAR.FORD_F_150_MK14: 8, CAR.FORD_MAVERICK_MK1: 9,
CAR.FORD_MONDEO_MK5: 10, CAR.FORD_MUSTANG_MACH_E_MK1: 11, CAR.FORD_RANGER_MK2: 12,
}
CLASSIC = frozenset((32, 33, 128, 129, 160, 161))
GENERIC = frozenset((66, 67, 130, 131, 194, 195))
MACH_E = frozenset((18, 19, 130, 131, 146, 147))
PROFILES = {i: CLASSIC if i in (1, 2, 5, 6, 9) else MACH_E if i == 11 else frozenset((10, 11)) if i == 10 else GENERIC
for i in range(1, 13)}
def decode(word):
"""Return (original profile, geometry), or None for malformed metadata."""
if not word & MASK:
return word, 0
index = (word >> SHIFT) & 15
base = word & ~MASK
if not word & ENABLE or index not in PROFILES or (base & ~256) not in PROFILES[index]:
return None
return base, index
def base_word(word):
result = decode(int(word))
return result[0] if result is not None else -1
def configured(cp):
if cp.brand != "ford" or not cp.safetyConfigs:
return False
result = decode(int(cp.safetyConfigs[-1].safetyParam))
return bool(result is not None and result[1] and result[1] == GEOMETRY.get(cp.carFingerprint))
def select(cp, enabled):
if not enabled or cp.brand != "ford" or cp.passive or cp.dashcamOnly or cp.notCar:
return False
from opendbc.car.ford.aol import qualified
if not qualified(cp):
return False
index = GEOMETRY.get(cp.carFingerprint)
if index is None:
return False
cp.safetyConfigs[-1].safetyParam |= ENABLE | index << SHIFT
return True
class Measurement:
"""Same init-time family as native; live model correction never changes the source."""
def __init__(self, cp):
self.enabled = configured(cp)
self.offset = self.roll = 0.0
self.model = None
if self.enabled:
from opendbc.car.vehicle_model import VehicleModel
self.model = VehicleModel(cp)
def update_parameters(self, parameters):
import math
if self.model is None or parameters is None:
return
values = (parameters.angleOffsetDeg, parameters.roll, parameters.stiffnessFactor, parameters.steerRatio)
if parameters.valid and all(math.isfinite(v) for v in values):
self.offset, self.roll = values[:2]
self.model.update_params(max(values[2], 0.1), max(values[3], 0.1))
def curvature(self, state):
if not self.enabled:
return -state.yawRate / max(state.vEgoRaw, 0.1)
import math
return -self.model.calc_curvature(math.radians(state.steeringAngleDeg - self.offset), state.vEgoRaw, self.roll)
@@ -3,7 +3,6 @@ from types import SimpleNamespace
import pytest
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.lateral_strategy import FordLateralController
from opendbc.car.ford.values import CAR
@@ -37,7 +36,7 @@ def test_typed_saved_blends_reach_owner_without_model(values, expected, monkeypa
for key, value in zip(keys, values, strict=True):
saved.put(key, value, block=True)
inputs = controller_extensions.ManualTurnInputs(saved)
owner = FordLateralController(CarInterface.get_non_essential_params(CAR.FORD_RANGER_MK2))
owner = FordLateralController(SimpleNamespace(carFingerprint=CAR.FORD_RANGER_MK2))
calls = []
setter = owner.set_blend_settings
@@ -67,7 +66,7 @@ def test_typed_saved_blends_reach_owner_without_model(values, expected, monkeypa
@pytest.mark.parametrize("desired,predicted,direction", ((-0.0005, -0.002, 1), (0.0005, 0.002, 2),
(-0.001, -0.003, 2), (0.001, 0.003, 1)))
def test_saved_blends_preserve_source_units_and_lane_direction(desired, predicted, direction):
owner = FordLateralController(CarInterface.get_non_essential_params(CAR.FORD_RANGER_MK2))
owner = FordLateralController(SimpleNamespace(carFingerprint=CAR.FORD_RANGER_MK2))
owner.set_blend_settings(0.25, 0.65, 1.1)
owner.model = SimpleNamespace(meta=SimpleNamespace(laneChangeState=2, laneChangeDirection=direction))
blend = 0.25 + min(abs(desired) / 0.001, 1.0) * 0.4
@@ -80,10 +80,7 @@ def test_generic_actual_card_publication_reaches_blend_settings(identity, monkey
controls.publish(command, lateral_log)
_, packets = ci.apply(command.as_reader(), 1_000_000_000)
assert packets
assert not ci.CC.classic_extended_announced
heartbeat = [data for address, data, _ in packets if address == 0x3CA]
assert heartbeat and all(not (data[4] & 2) for data in heartbeat)
assert all(((data[0] >> 4) & 7) == 0 for address, data, _ in packets if address == 0x3D6)
assert ci.CC.classic_extended_announced
assert not owner.human_turn_enabled
assert (owner.curvature_blend_low, owner.curvature_blend_high, owner.curvature_lane_change_factor) == (0.25, 0.65, 1.1)
finally:
@@ -1,6 +1,5 @@
"""Explorer fixed preview must not broaden the admitted Mach-E strategy."""
from types import SimpleNamespace
from opendbc.car.ford.interface import CarInterface
import pytest
@@ -8,11 +7,6 @@ from opendbc.car.ford.explorer_lateral import ExplorerLateralController
from opendbc.car.ford.mache_lateral import MachELateralController
from opendbc.car.ford.values import CAR, CarControllerParams, FordFlags
def controller_params(car, flags):
cp = CarInterface.get_non_essential_params(car)
return SimpleNamespace(brand=cp.brand, safetyConfigs=cp.safetyConfigs, carFingerprint=car, flags=flags)
def model():
return SimpleNamespace(orientationRate=SimpleNamespace(z=[i * 0.01 for i in range(33)]))
@@ -20,14 +14,14 @@ def model():
@pytest.mark.parametrize("delay", (0.20, 0.30, 0.40))
def test_explorer_fixed_lookahead_selects_original_model_sample(delay):
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
owner.set_inputs(model(), tuple(i * 0.1 for i in range(33)), delay, True)
assert owner._curvature_lookahead() == 0.20
assert owner._predicted_curvature(10.0, owner._curvature_lookahead()) == pytest.approx(0.002)
def test_mach_e_compatibility_keeps_current_provider_lookahead():
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
owner = MachELateralController(SimpleNamespace(carFingerprint=CAR.FORD_MUSTANG_MACH_E_MK1, flags=FordFlags.CANFD))
owner.set_inputs(model(), tuple(i * 0.1 for i in range(33)), 0.40, True)
assert owner._curvature_lookahead() == 0.40
assert owner._predicted_curvature(10.0, owner._curvature_lookahead()) == pytest.approx(0.004)
@@ -37,7 +31,7 @@ def test_explorer_strategy_rejects_unrelated_topology():
for car, flags in ((CAR.FORD_EXPLORER_MK6, FordFlags.CANFD),
(CAR.FORD_EDGE_MK2, FordFlags.ALT_STEER_ANGLE | FordFlags.NEW_PORT)):
with pytest.raises(ValueError):
ExplorerLateralController(controller_params(car, flags))
ExplorerLateralController(SimpleNamespace(carFingerprint=car, flags=flags))
@pytest.mark.parametrize("alpha,release", ((False, False), (True, False), (False, True), (True, True)))
@@ -65,7 +59,7 @@ def test_actual_interface_restores_explicit_longitudinal_ownership(alpha, releas
def test_classic_modern_envelope_withdraws_infeasible_speed_transition():
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
result = bounded_command(owner, FordLateralResult(curvature=.02, active=True), .02, 20., .02)
assert not result.active
assert result.curvature == owner.curvature_last == 0.
@@ -75,7 +69,7 @@ def test_classic_modern_envelope_withdraws_infeasible_speed_transition():
def test_manual_turn_withdraws_without_mach_e_latch_or_gain_compensation():
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
owner.manual_turn_detected = True
result = bounded_command(owner, FordLateralResult(active=True), .002, 10., .002)
assert not result.active
@@ -86,7 +80,7 @@ def test_manual_turn_withdraws_without_mach_e_latch_or_gain_compensation():
def test_speed_change_cap_cannot_escape_original_rate_interval():
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
# At26m/s modernjerk admits~.000265, originalnative lookup only.000180.
# Modern bank-tolerant cap~.005309 requires.000211 fromprevious.005520.
result = bounded_command(owner, FordLateralResult(curvature=.00552, active=True), .00552, 26., .00552)
@@ -137,7 +131,7 @@ def test_typed_offset_eps_and_namespace_guards():
def test_final_intersection_preserves_source_rise_and_unwind(sign):
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
rise = bounded_command(owner, FordLateralResult(curvature=sign*.0015, active=True), sign*.001, 26., sign*.001)
assert rise.curvature == pytest.approx(sign*.00108)
fall = bounded_command(owner, FordLateralResult(curvature=0., active=True), sign*.001, 26., sign*.001)
@@ -164,7 +158,7 @@ def test_classic_rate_endpoint_has_correct_decoded_sign(rate, expected):
def test_measured_recovery_with_empty_source_interval_withdraws(sign):
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
command = bounded_command(owner, FordLateralResult(curvature=sign*.008, active=True), 0., 35., sign*.008)
assert not command.active and command.curvature == 0.
assert owner.curvature_last == owner.path_angle_last == 0.
@@ -174,7 +168,7 @@ def test_measured_recovery_with_empty_source_interval_withdraws(sign):
def test_measured_window_requires_recovery_and_bounds_unwind(sign):
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner = ExplorerLateralController(SimpleNamespace(carFingerprint=CAR.FORD_EXPLORER_MK6, flags=0))
# Recovery outside the error window may proceed when source fall permits it.
recovery = bounded_command(owner, FordLateralResult(curvature=sign*.001, active=True), sign*.003, 35., 0.)
delta = CarControllerParams.CURVATURE_LIMITS.MAX_LATERAL_JERK / 35.**2 * .05
@@ -27,8 +27,7 @@ class RecordedPreview:
pass
def apply_blend_settings(self, controller):
lateral = controller.mache_lateral or controller.classic_lateral
lateral.set_blend_settings(.4, .4, .85)
controller.mache_lateral.set_blend_settings(.4, .4, .85)
def lateral_snapshot(self, speed):
return self.model, ModelConstants.T_IDXS, .2, True
@@ -37,10 +36,10 @@ class RecordedPreview:
return (), False
def run_turn(*, hands_free, angle, torque, desired, speed, yaw, identity=CAR.FORD_MUSTANG_MACH_E_MK1):
def run_turn(*, hands_free, angle, torque, desired, speed, yaw):
native = test_ford_aol.TestFordAolDriverIntent()
native.setUp()
native.reset(18 if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else 66)
native.reset(18)
native.arm()
with OpenpilotPrefix():
saved = Params()
@@ -48,9 +47,9 @@ def run_turn(*, hands_free, angle, torque, desired, speed, yaw, identity=CAR.FOR
fingerprint = gen_empty_fingerprint()
fingerprint[0].update({0x5A: 8, 0x3A6: 8, 0x3A7: 8})
fingerprint[2].update({0x3D6: 8, 0x186: 8})
cp = CarInterface.get_params(identity, fingerprint, [], False, False, False)
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint, [], False, False, False)
cp.alternativeExperience = 32
assert cp.safetyConfigs[-1].safetyParam == (18 if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else 66)
assert cp.safetyConfigs[-1].safetyParam == 18
assert cp.flags == 3
controller = CarController(DBC[cp.carFingerprint], cp)
controller.hands_free_cluster_input = FordClusterPreference(saved)
@@ -72,18 +71,6 @@ def run_turn(*, hands_free, angle, torque, desired, speed, yaw, identity=CAR.FOR
native.pump(1, mask=int(command.latActive), speed=speed, curvature=yaw / speed)
state.out = cs.as_reader()
_, frames = controller.update(command.as_reader(), state, (1_000_000 + native.tick * 10_000) * 1000)
if identity != CAR.FORD_MUSTANG_MACH_E_MK1:
lateral_frames = [(index, data) for index, (address, data, _) in enumerate(frames) if address == 0x3D6]
announcements = [(index, data) for index, (address, data, _) in enumerate(frames) if address == 0x3CA]
assert len(announcements) <= 1
for index, data in lateral_frames:
active = ((data[0] >> 4) & 7) != 0
previous_active = bool(steering and ((steering[-1][0] >> 4) & 7))
if active != previous_active:
assert announcements and announcements[0][0] < index
assert bool(announcements[0][1][4] & 2) == active
if not command.latActive:
assert all(not (data[4] & 2) for _, data in announcements)
for address, data, bus in frames:
assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
if address == 0x3D6:
@@ -110,10 +97,6 @@ def run_turn(*, hands_free, angle, torque, desired, speed, yaw, identity=CAR.FOR
cs.steeringAngleDeg = angle
cs.steeringTorque = torque
cs.rightBlinker = True
if identity != CAR.FORD_MUSTANG_MACH_E_MK1:
# Generic curvature retains its existing 45deg/3s preturned detector.
for _ in range(310):
step()
count = len(displays)
for _ in range(40):
frames = step()
@@ -172,13 +155,3 @@ def test_cluster_preference_survives_native_cleanup(tmp_path, saved, flag):
assert reopened.get(key) == saved
assert Path(reopened.get_param_path(key)).read_bytes() == (b'1' if saved else b'0')
assert FordClusterPreference(reopened).enabled == saved
@pytest.mark.parametrize('identity', [CAR.FORD_ESCAPE_MK4_5, CAR.FORD_EXPEDITION_MK4, CAR.FORD_F_150_MK14,
CAR.FORD_F_150_LIGHTNING_MK1, CAR.FORD_RANGER_MK2])
def test_generic_curvature_native_display_handoff_retains_hands_free_during_manual_turn(identity):
# Nonzero demand exercises the bounded first-active command, not just UI.
inputs = dict(identity=identity, angle=-55., torque=-2., desired=.000041086183, speed=2.55, yaw=-.006)
hands_free = run_turn(hands_free=True, **inputs)
plain = run_turn(hands_free=False, **inputs)
assert plain == hands_free
@@ -1,6 +1,5 @@
"""Original-derived Mach-E demand and driver handoff regressions."""
from types import SimpleNamespace
from opendbc.car.ford.interface import CarInterface
import pytest
from opendbc.car import Bus, structs
from opendbc.car.lateral import MAX_LATERAL_JERK
@@ -12,15 +11,10 @@ from opendbc.car.ford.tests.test_three_ports import params
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
def controller_params(car, flags):
cp = CarInterface.get_non_essential_params(car)
return SimpleNamespace(brand=cp.brand, safetyConfigs=cp.safetyConfigs, carFingerprint=car, flags=flags)
@pytest.fixture
def controller():
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
owner = MachELateralController(SimpleNamespace(flags=FordFlags.CANFD, carFingerprint=CAR.FORD_MUSTANG_MACH_E_MK1))
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.]*33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
owner.set_inputs(model, tuple(i*.1 for i in range(33)), .2, True)
@@ -379,7 +373,7 @@ def test_actual_controller_announces_before_full_physical_frame_and_resets_off()
def test_modern_transient_bound_updates_emitted_history_without_hidden_windup():
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
owner = MachELateralController(SimpleNamespace(flags=FordFlags.CANFD, carFingerprint=CAR.FORD_MUSTANG_MACH_E_MK1))
raw = FordLateralResult(curvature=.006, path_angle=.16, active=True)
command = bounded_command(owner, raw, .001, 15.)
assert command.curvature < raw.curvature
@@ -395,7 +389,7 @@ def test_modern_transient_bound_updates_emitted_history_without_hidden_windup():
@pytest.mark.parametrize("sign", (-1, 1))
def test_speed_changes_intersect_bounds_or_withdraw_and_preserve_path_headroom(sign):
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
owner = MachELateralController(SimpleNamespace(flags=FordFlags.CANFD, carFingerprint=CAR.FORD_MUSTANG_MACH_E_MK1))
previous, path = sign * .0198, sign * .055
for speed in (5., 5.1, 5.2, 5.3, 8., 15., 5.):
demand = FordLateralResult(curvature=sign*.02, path_angle=sign*.16, active=True)
@@ -1,140 +0,0 @@
import pytest
from opendbc.car.ford import mache_lateral as original
from opendbc.car import Bus
from opendbc.can import CANPacker
candidate = original
def cp():
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR
fingerprint = gen_empty_fingerprint()
fingerprint[0] = {0x5A: 8, 0x3A6: 8, 0x3A7: 8}
result = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint, [], True, False, False)
result.alternativeExperience = 32
assert result.safetyConfigs[-1].safetyParam == 19 and result.flags == 3
return result
@pytest.mark.parametrize("sign", [-1, 1])
def test_recorded_native_window_prevents_first_rejection_and_cascade(sign):
from opendbc.safety.tests.test_ford_mach_e_extended import TestFordMachEExtended
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
f = TestFordMachEExtended()
f.SAFETY_PARAM = 19
f.setUp()
a = TestFordAolDriverIntent()
a.setUp()
def setup():
a.reset(19)
a.arm()
a.pump(12, mask=3, speed=8.805556, curvature=sign * 0.02)
f._rx(f._pcm_status_msg(True))
a.safety.aol_set_host_request(3)
assert a.safety.aol_get_permission_mask() == 3
assert f.announce()
a.pump(2, mask=3, main=4, speed=8.775, curvature=sign * 0.02)
f._set_prev_desired_angle(sign * 0.02)
demand = original.FordLateralResult(active=True, curvature=sign * 0.02, path_angle=sign * 0.0035, curvature_rate=-0.001023)
setup()
old = original.bounded_command(candidate.MachELateralController(cp()), demand, sign * 0.02, 8.775)
assert not f._tx(f.command(curvature=old.curvature, angle=old.path_angle, rate=old.curvature_rate, increment_timer=False))
assert not f._tx(f.command(curvature=sign * 0.02, angle=0, rate=0, increment_timer=False))
setup()
new = candidate.bounded_command(candidate.MachELateralController(cp()), demand, sign * 0.02, 8.775, speed_window=(8.775, 8.806, 0.031))
assert new.curvature == old.curvature and new.path_angle == 0
assert f._tx(f.command(curvature=new.curvature, angle=new.path_angle, rate=new.curvature_rate, increment_timer=False))
assert f._tx(f.command(curvature=sign * 0.02, angle=0, rate=0, increment_timer=False))
a.tearDown()
@pytest.mark.parametrize("speed", [0, 2.99, 3, 5, 8.7, 8.8, 12, 30])
@pytest.mark.parametrize("active", [False, True])
def test_zero_span_window_preserves_default_command(speed, active):
d = original.FordLateralResult(active=active, curvature=0.02, path_angle=0.1)
assert candidate.bounded_command(candidate.MachELateralController(cp()), d, 0.019, speed) == candidate.bounded_command(
candidate.MachELateralController(cp()), d, 0.019, speed, speed_window=(speed, speed, 0.0)
)
@pytest.mark.parametrize("param,ae,supported", [(19, 32, True), (18, 32, False), (19, 0, False), (147, 32, False), (67, 32, False)])
def test_exact_profile(param, ae, supported):
c = cp()
c.safetyConfigs[0].safetyParam = param
c.alternativeExperience = ae
assert candidate.path_speed_window_supported(c) == supported
def test_actual_carstate_tracks_six_raw_samples():
from opendbc.car.ford.carstate import CarState
c = cp()
cs = CarState(c)
parsers = cs.get_can_parsers(c)
p = CANPacker("ford_lincoln_base_pt")
values = [8.7, 8.805556, 8.79, 8.78, 8.775, 8.77, 8.76]
for i, v in enumerate(values):
parsers[Bus.pt].update(
[[(i + 1) * 20_000_000, [p.make_can_msg("BrakeSysFeatures", 0, {"Veh_V_ActlBrk": v * 3.6, "VehVActlBrk_D_Qf": 3, "VehVActlBrk_No_Cnt": i + 1})]]]
)
cs.update(parsers)
assert cs.bluepilot_speed_window is not None
assert cs.bluepilot_speed_samples is not None
assert cs.bluepilot_speed_window[1] >= 8.8
assert len(cs.bluepilot_speed_samples) == 6
for i in range(7, 13):
parsers[Bus.pt].update(
[[(i + 1) * 20_000_000, [p.make_can_msg("BrakeSysFeatures", 0, {"Veh_V_ActlBrk": 8.7 * 3.6, "VehVActlBrk_D_Qf": 3, "VehVActlBrk_No_Cnt": i + 1})]]]
)
cs.update(parsers)
assert cs.bluepilot_speed_window is not None
assert cs.bluepilot_speed_window[1] < 8.8
@pytest.mark.parametrize("high", [8.79, 8.8, 8.81, 9.0])
def test_rising_window_previous_nonzero_path(high):
demand = original.FordLateralResult(active=True, curvature=0.02, path_angle=0.05)
out = candidate.bounded_command(candidate.MachELateralController(cp()), demand, 0.02, 8.775, 0.05, speed_window=(8.7, high, 0.1))
if high >= 8.8:
assert out.path_angle == 0
else:
assert 0 <= out.path_angle <= 0.05
assert out.curvature == 0.02
def test_controller_uses_native_speed_window():
from opendbc.can import CANParser
from opendbc.car import structs
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.values import DBC
from openpilot.common.prefix import OpenpilotPrefix
with OpenpilotPrefix():
c = cp()
controller = CarController(DBC[c.carFingerprint], c)
controller.manual_turn_inputs = None
controller.mache_extended_announced = True
assert controller.mache_lateral is not None
controller.mache_lateral.curvature_last = 0.02
controller.mache_lateral.update = lambda *_: candidate.FordLateralResult(active=True, curvature=0.02, path_angle=0.0035)
state = CarState(c)
state.update(state.get_can_parsers(c))
state.bluepilot_speed_window = (8.775, 8.806, 0.031)
cs = structs.CarState(canValid=True, vEgo=8.775, vEgoRaw=8.775, gearShifter=structs.CarState.GearShifter.drive)
cs.cruiseState.available = True
state.out = cs.as_reader()
command = structs.CarControl(latActive=True, longActive=True)
_, frames = controller.update(command.as_reader(), state, 1_000_000_000)
parser = CANParser("ford_lincoln_base_pt", [("LateralMotionControl2", 50)], 0)
parser.update([[1_000_000_000, frames]])
assert any(address == 0x3D6 for address, _, _ in frames)
assert abs(parser.vl["LateralMotionControl2"]["LatCtlPath_An_Actl"]) < 0.00001
assert abs(abs(parser.vl["LateralMotionControl2"]["LatCtlCurv_No_Actl"]) - 0.02) < 0.00001
@@ -50,11 +50,11 @@ def test_mache_idle_leaves_stock_display_and_fcw_untouched_then_owns_active_ui()
assert state.acc_tja_status_stock_values['FcwVisblWarn_B_Rq'] == 1
@pytest.mark.parametrize('identity,aol,expected', [(CAR.FORD_MUSTANG_MACH_E_MK1, False, UI), (CAR.FORD_F_150_MK14, True, set())])
def test_idle_ui_respects_native_display_ownership(identity, aol, expected):
@pytest.mark.parametrize('identity,aol', [(CAR.FORD_MUSTANG_MACH_E_MK1, False), (CAR.FORD_F_150_MK14, True)])
def test_other_display_ownership_contracts_keep_existing_idle_ui(identity, aol):
owner, state = setup(aol=aol, identity=identity)
seen = set()
for _ in range(110):
_, frames = owner.update(structs.CarControl().as_reader(), state, owner.frame * 10_000_000)
seen.update(address for address, _, _ in frames if address in UI)
assert seen == expected
assert seen == UI
@@ -1,177 +0,0 @@
"""Closed, init-only measurement selection; disabled behavior stays unchanged."""
from types import SimpleNamespace
import pytest
from opendbc.car import gen_empty_fingerprint, structs
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.pinion import GEOMETRY, ENABLE, SHIFT, MASK, decode, select, configured, Measurement
from opendbc.car.ford.aol import qualified
from opendbc.car.vehicle_model import VehicleModel, calc_slip_factor
from opendbc.can.packer import CANPacker
from opendbc.safety.tests.libsafety import libsafety_py
def params(car, long=False):
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
fp[2].update({0x3D6: 8, 0x186: 8})
return CarInterface.get_params(car, fp, [], long, False, False)
def test_closed_decoder_matches_native_exhaustively():
n = libsafety_py.libsafety
for word in range(65536):
decoded = decode(word)
assert n.get_ford_pinion_base(word) == (decoded[0] if decoded is not None else 65535)
@pytest.mark.parametrize('car', list(GEOMETRY))
@pytest.mark.parametrize('long', [False, True])
def test_default_off_and_exact_geometry_selection(car, long):
cp = params(car, long)
before = cp.to_dict()
assert not select(cp, False)
assert cp.to_dict() == before
assert select(cp, True)
assert configured(cp) and qualified(cp)
assert decode(cp.safetyConfigs[-1].safetyParam)[1] == GEOMETRY[car]
# Cross-family/identity corruption is not accepted by the host.
cp.safetyConfigs[-1].safetyParam = (cp.safetyConfigs[-1].safetyParam & ~MASK) | ENABLE | ((GEOMETRY[car] % 12 + 1) << SHIFT)
assert not configured(cp) and not qualified(cp)
@pytest.mark.parametrize('car', list(GEOMETRY))
def test_geometry_matches_actual_vehicle_model_and_native_sign(car):
cp = params(car)
index = GEOMETRY[car]
n = libsafety_py.libsafety
vm = VehicleModel(cp)
assert n.get_ford_pinion_slip(index) == pytest.approx(calc_slip_factor(vm), abs=2e-10)
assert n.get_ford_pinion_ratio(index) == pytest.approx(cp.steerRatio)
assert n.get_ford_pinion_wheelbase(index) == pytest.approx(cp.wheelbase)
assert select(cp, True)
host = Measurement(cp)
packer = CANPacker('ford_lincoln_base_pt')
for speed in (0., 5., 15., 30.):
for angle in (-100., -10., 0., 10., 100.):
n.set_alternative_experience(0)
n.set_safety_hooks(structs.CarParams.SafetyModel.ford, cp.safetyConfigs[-1].safetyParam)
assert n.get_ford_pinion_enabled()
for counter in range(6):
a, d, b = packer.make_can_msg('BrakeSysFeatures', 0, {'Veh_V_ActlBrk': speed * 3.6, 'VehVActlBrk_D_Qf': 3})
assert n.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
a, d, b = packer.make_can_msg('SteeringPinion_Data', 0, {'StePinComp_An_Est': angle, 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': counter})
assert n.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
state = SimpleNamespace(vEgoRaw=max(speed, .1), yawRate=99., steeringAngleDeg=angle)
expected = -host.curvature(state) * 50000
assert n.get_curvature_meas_min() == pytest.approx(expected, abs=1.)
assert n.get_curvature_meas_max() == pytest.approx(expected, abs=1.)
# A valid but contradictory yaw packet never changes selected pinion measurement.
a, d, b = packer.make_can_msg('Yaw_Data_FD1', 0, {'VehYaw_W_Actl': 1., 'VehYawWActl_D_Qf': 3})
assert n.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
assert n.get_curvature_meas_min() == pytest.approx(expected, abs=1.)
def test_default_measurement_is_yaw_even_with_conflicting_pinion():
cp = params(next(iter(GEOMETRY)))
m = Measurement(cp)
state = SimpleNamespace(vEgoRaw=15., yawRate=.3, steeringAngleDeg=100.)
assert m.curvature(state) == -.3 / 15.
assert not m.enabled
@pytest.mark.parametrize('index,base', [(1, 32), (3, 66), (10, 10), (11, 18)])
@pytest.mark.parametrize('long', [False, True])
@pytest.mark.parametrize('manual', [False, True])
def test_pinion_inventory_and_invalid_quality_withdraw_authority(index, base, long, manual):
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
n = TestFordAolDriverIntent()
n.setUp()
base |= int(long) | (256 if manual else 0)
n.reset(base)
count = n.safety.get_ford_pinion_rx_count()
n.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, base | ENABLE | index << SHIFT)
n.safety.set_aol_test_heartbeat(True)
assert n.safety.get_ford_pinion_rx_count() == count + 1
original_rx = n.rx
def rx(name, values):
if name == 'Yaw_Data_FD1':
original_rx('SteeringPinion_Data', {'StePinComp_An_Est': 0., 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': n.tick % 16})
original_rx(name, values)
n.rx = rx
try:
n.arm()
assert n.safety.aol_get_permission_mask() == 1
a, d, b = n.packer.make_can_msg('SteeringPinion_Data', 0,
{'StePinComp_An_Est': 100., 'StePinCompAnEst_D_Qf': 0, 'StePinAn_No_Cnt': (n.tick + 1) % 16})
assert not n.safety.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
assert n.safety.aol_get_permission_mask() == 0
assert n.safety.get_curvature_meas_max() == 0
finally:
n.tearDown()
@pytest.mark.parametrize('failure', ['missing', 'wrong_bus', 'short', 'counter'])
def test_pinion_integrity_is_required_not_substituted_by_healthy_yaw(failure):
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
n = TestFordAolDriverIntent()
n.setUp()
n.reset(66)
n.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, 66 | ENABLE | 7 << SHIFT)
n.safety.set_aol_test_heartbeat(True)
original_rx = n.rx
inject = True
def rx(name, values):
if name == 'Yaw_Data_FD1':
if inject:
original_rx('SteeringPinion_Data', {'StePinComp_An_Est': 0., 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': n.tick % 16})
elif failure != 'missing':
a, d, b = n.packer.make_can_msg('SteeringPinion_Data', 0,
{'StePinComp_An_Est': 100., 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': 2})
n.safety.safety_rx_hook(libsafety_py.make_CANPacket(a, 1 if failure == 'wrong_bus' else b, d[:7] if failure == 'short' else d))
original_rx(name, values)
n.rx = rx
try:
n.arm()
inject = False
n.pump(120, mask=1)
assert n.safety.aol_get_permission_mask() == 0
finally:
n.tearDown()
@pytest.mark.parametrize('car', [c for c in GEOMETRY if 'MONDEO' not in c])
@pytest.mark.parametrize('long', [False, True])
def test_angle_selection_preserves_pinion_and_aol_exact_profile(car, long):
from opendbc.bluepilot_lateral.hosts.starpilot import select as angle_select, qualified as angle_qualified
from opendbc.car.ford.aol import base_safety_param, AOL_WORDS
cp = params(car, long)
assert select(cp, True)
assert angle_select(cp, 1)
assert configured(cp) and angle_qualified(cp)
cp.safetyConfigs[-1].safetyParam |= base_safety_param(cp)
cp.alternativeExperience = 32
assert cp.safetyConfigs[-1].safetyParam in AOL_WORDS
assert configured(cp) and angle_qualified(cp) and qualified(cp, marked_only=True)
@pytest.mark.parametrize('word', [32 | ENABLE, 32 | (1 << SHIFT), 32 | ENABLE | (13 << SHIFT),
66 | ENABLE | (1 << SHIFT), 32 | ENABLE | (1 << SHIFT) | 0x4000,
32 | ENABLE | (1 << SHIFT) | 0x8000])
def test_invalid_pinion_native_profile_denies_all_commands(word):
n = libsafety_py.libsafety
n.set_alternative_experience(32)
n.set_safety_hooks(structs.CarParams.SafetyModel.ford, word)
n.set_controls_allowed(True)
assert not n.get_ford_pinion_enabled()
for address in (0x3D3, 0x3D6, 0x3CA, 0x186, 0x18A, 0x3D8):
assert not n.safety_tx_hook(libsafety_py.make_CANPacket(address, 0, bytes(8)))
def test_pinion_rx_capacity_guard_withdraws_authority_without_overwrite():
n = libsafety_py.libsafety
n.set_alternative_experience(32)
n.set_safety_hooks(structs.CarParams.SafetyModel.ford, 66 | ENABLE | (7 << SHIFT))
assert n.test_ford_pinion_rx_overflow()
@@ -1,49 +0,0 @@
"""Clock an equipped Bolt's replacement brake frame from the factory ACC cycle."""
class BoltAccBrakeClock:
MIN_INTERVAL_NS = 30_000_000
MAX_AGE_NS = 80_000_000
def __init__(self):
self.command = None
self.command_ns = 0
self.source_ns = 0
self.source_counter = None
self.counter = -1
self.emit_ns = 0
self.active_last = False
def prepare(self, now_ns, brake, enabled, near_stop, full_stop):
self.command = (brake, enabled, near_stop, full_stop)
self.command_ns = now_ns
def release(self, now_ns):
if not self.active_last or now_ns - self.emit_ns < self.MIN_INTERVAL_NS:
return None
# A lost clock cannot authorize braking. Retire the last active command
# once with the canonical zero release; the native ownership gates still run.
self.counter = (self.counter + 1) % 4
self.emit_ns = now_ns
self.active_last = False
return (0, self.counter, False, False, False)
def sample(self, now_ns, source_ns, counter, *, active, main_ready, source_valid):
if not main_ready:
self.command = None
self.active_last = False
return None
if (not source_valid or self.command is None or
not 0 < self.command_ns <= now_ns or now_ns - self.command_ns > self.MAX_AGE_NS or
not 0 < source_ns <= now_ns or now_ns - source_ns > self.MAX_AGE_NS or not 0 <= counter < 4):
return self.release(now_ns)
if source_ns <= self.source_ns or counter == self.source_counter:
return self.release(now_ns) if now_ns - self.emit_ns > self.MAX_AGE_NS else None
if self.emit_ns and now_ns - self.emit_ns < self.MIN_INTERVAL_NS:
return None
self.source_ns, self.source_counter = source_ns, counter
if counter == self.counter:
return None # The safe release may already have consumed this counter.
self.counter, self.emit_ns = counter, now_ns
brake, enabled, near_stop, full_stop = self.command
self.active_last = bool(active and (brake > 0 or enabled))
return (brake, counter, enabled, near_stop, full_stop) if active else (0, counter, False, False, False)
+5 -30
View File
@@ -7,7 +7,6 @@ from opendbc.car.gm.values import is_gm_auto_hold
from opendbc.car.gm.ordinary import demands as ascm_demands
from opendbc.car.gm.truck_longitudinal import TruckTuning, truck_tuning_supported
import math
from opendbc.car.gm.bolt_acc_brake_clock import BoltAccBrakeClock
import numpy as np
from opendbc.can import CANPacker
from opendbc.car import ACCELERATION_DUE_TO_GRAVITY, Bus, DT_CTRL, structs
@@ -267,7 +266,6 @@ class CarController(CarControllerBase):
self.long_pitch_input = None
self.bolt_removed_cancel_credit_used = 0
self.bolt_pedal_removed = is_bolt_pedal_removed_profile(CP) or is_bolt_pedal_removed_profile(CP, stock_only=True)
self.bolt_steering_ack_counter = is_bolt_pedal_profile(CP) or is_bolt_pedal_profile(CP, stock_only=True)
self.bolt_present_no_acc_stock = is_bolt_present_no_acc_pedal_profile(CP, stock_only=True)
self.truck_tuning = TruckTuning() if truck_tuning_supported(CP) else None
self.truck_tuning_input = None
@@ -302,8 +300,6 @@ class CarController(CarControllerBase):
self.bolt_acc_pedal_friction_release_frames = 0
self.bolt_acc_pedal_friction_low_speed_active = False
self.bolt_acc_pedal_stop_hold = False
self.bolt_acc_brake_clock = (BoltAccBrakeClock() if int(CP.safetyConfigs[0].safetyParam) == 0x1CD and
CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL and is_bolt_pedal_profile(CP) else None)
self.lka_steering_cmd_counter = 0
self.lka_icon_status_last = (False, False)
@@ -605,11 +601,7 @@ class CarController(CarControllerBase):
if CS.loopback_lka_steering_cmd_ts_nanos == 0 or out_of_sync:
steer_step = self.params.STEER_STEP
if self.bolt_steering_ack_counter:
if CS.loopback_lka_steering_cmd_updated:
self.lka_steering_cmd_counter = (CS.loopback_lka_steering_cmd_counter + 1) % 4
else:
self.lka_steering_cmd_counter += 1 if CS.loopback_lka_steering_cmd_updated else 0
self.lka_steering_cmd_counter += 1 if CS.loopback_lka_steering_cmd_updated else 0
# Avoid GM EPS faults when transmitting messages too close together: skip this transmit if we
# received the ASCMLKASteeringCmd loopback confirmation too recently
@@ -862,16 +854,10 @@ class CarController(CarControllerBase):
elif self.bolt_acc_pedal_friction_release_frames > 0:
self.bolt_acc_pedal_friction_release_frames -= 1
if friction_main_on or self.bolt_acc_pedal_friction_release_frames > 0:
if self.bolt_acc_brake_clock is not None:
self.bolt_acc_brake_clock.prepare(
now_nanos, self.apply_brake, active and friction_main_on,
active and CS.out.vEgo < self.params.NEAR_STOP_BRAKE_PHASE,
active and CS.out.standstill and actuators.longControlState == LongCtrlState.stopping)
else:
can_sends.append(gmcan.create_friction_brake_command(
self.packer_ch, CanBus.POWERTRAIN, self.apply_brake, (self.frame // 4) % 4,
active and friction_main_on, active and CS.out.vEgo < self.params.NEAR_STOP_BRAKE_PHASE,
active and CS.out.standstill and actuators.longControlState == LongCtrlState.stopping, self.CP))
can_sends.append(gmcan.create_friction_brake_command(
self.packer_ch, CanBus.POWERTRAIN, self.apply_brake, (self.frame // 4) % 4,
active and friction_main_on, active and CS.out.vEgo < self.params.NEAR_STOP_BRAKE_PHASE,
active and CS.out.standstill and actuators.longControlState == LongCtrlState.stopping, self.CP))
else:
self.apply_brake = 0
dashboard_ready = (
@@ -887,17 +873,6 @@ class CarController(CarControllerBase):
can_sends.append(gmcan.create_acc_dashboard_command(
self.packer_pt, CanBus.POWERTRAIN, CC.enabled, hud_v_cruise * CV.MS_TO_KPH,
hud_control, False, cruise_state=2, fcw_alert=fcw))
if self.bolt_acc_brake_clock is not None:
main_ns = getattr(CS, "bolt_pedal_main_ts_nanos", 0)
main_ready = (CS.out.cruiseState.available and stock_ownership_clear and
0 < main_ns <= now_nanos and now_nanos - main_ns <= 300_000_000)
scheduled = self.bolt_acc_brake_clock.sample(
now_nanos, getattr(CS, "bolt_acc_brake_clock_ns", 0), getattr(CS, "bolt_acc_brake_clock_counter", 0),
active=bool(active), main_ready=main_ready, source_valid=bool(CS.out.canValid and not CS.out.canTimeout))
if scheduled is not None:
brake, counter, enabled, near_stop, full_stop = scheduled
can_sends.append(gmcan.create_friction_brake_command(
self.packer_ch, CanBus.POWERTRAIN, brake, counter, enabled, near_stop, full_stop, self.CP))
if self.bolt_pedal_removed and self.frame % 100 == 0:
can_sends += gmcan.create_adas_keepalive(CanBus.POWERTRAIN)
# Stock ACC on the equipped variant is canceled while pedal control owns longitudinal.
-11
View File
@@ -62,7 +62,6 @@ class CarState(CarStateBase):
self.cluster_min_speed = CV.KPH_TO_MS / 2.
self.loopback_lka_steering_cmd_updated = False
self.loopback_lka_steering_cmd_counter = 0
self.loopback_lka_steering_cmd_ts_nanos = 0
self.pt_lka_steering_cmd_counter = 0
self.cam_lka_steering_cmd_counter = 0
@@ -76,8 +75,6 @@ class CarState(CarStateBase):
self.pedal_sensor_counter = None
self.pedal_packer = CANPacker(DBC[CP.carFingerprint][Bus.pt]) if CP.flags & GMFlags.PEDAL_LONG.value else None
self.stock_acc_status_ts_nanos = 0
self.bolt_acc_brake_clock_ns = 0
self.bolt_acc_brake_clock_counter = 0
self.bolt_pedal_removed_sources = ()
self.bolt_pedal_removed_stock_ts_nanos = 0
self.bolt_pedal_stock_active = False
@@ -371,7 +368,6 @@ class CarState(CarStateBase):
# Variables used for avoiding LKAS faults
self.loopback_lka_steering_cmd_updated = len(loopback_cp.vl_all["ASCMLKASteeringCmd"]["RollingCounter"]) > 0
if self.loopback_lka_steering_cmd_updated:
self.loopback_lka_steering_cmd_counter = loopback_cp.vl["ASCMLKASteeringCmd"]["RollingCounter"]
self.loopback_lka_steering_cmd_ts_nanos = loopback_cp.ts_nanos["ASCMLKASteeringCmd"]["RollingCounter"]
if (self.CP.networkLocation == NetworkLocation.fwdCamera and
not (is_conventional_cc_pedal_profile(self.CP) and self.CP.flags & GMFlags.NO_CAMERA) and
@@ -508,10 +504,6 @@ class CarState(CarStateBase):
# 1 - latched
ret.seatbeltUnlatched = pt_cp.vl["BCMDoorBeltStatus"]["LeftSeatBelt"] == 0
if int(self.CP.safetyConfigs[0].safetyParam) == 0x1CD and is_bolt_pedal_profile(self.CP):
self.bolt_acc_brake_clock_ns = cam_cp.ts_nanos["EBCMFrictionBrakeCmd"]["RollingCounter"]
self.bolt_acc_brake_clock_counter = int(cam_cp.vl["EBCMFrictionBrakeCmd"]["RollingCounter"])
ret.leftBlinker = pt_cp.vl["BCMTurnSignals"]["TurnSignals"] == 1
ret.rightBlinker = pt_cp.vl["BCMTurnSignals"]["TurnSignals"] == 2
@@ -886,9 +878,6 @@ class CarState(CarStateBase):
if hybrid is not None:
cam_messages = [] if hybrid.removed else [("ASCMLKASteeringCmd", 10), ("AEBCmd", 10)]
if int(CP.safetyConfigs[0].safetyParam) == 0x1CD and is_bolt_pedal_profile(CP):
cam_messages.append(("EBCMFrictionBrakeCmd", 25))
gps = get_car_gps_config(CP)
if gps is not None:
pt_messages += [(name, float("nan")) for name in gps.messages]
+1 -41
View File
@@ -1,7 +1,5 @@
"""Conservative GM query-family and legacy CAN alias discovery."""
"""Resolve one legacy Volt CAN alias using its observed PT-only source graph."""
from opendbc.car.gm.values import CAR, CanBus
from opendbc.car.fingerprints import all_legacy_fingerprint_cars, eliminate_incompatible_cars
from opendbc.car.can_definitions import CanData
def automatic_volt_candidate(candidate, fingerprint, candidates):
@@ -26,41 +24,3 @@ def automatic_volt_candidate(candidate, fingerprint, candidates):
# camera is not evidence of an ACC actuator owner. Read actual ECM cruise
# activity and retain button-only control; explicit ACC selections are above.
return CAR.CHEVROLET_VOLT_CC
# Frequent normal-bus frames observed on mirrored pedal installations. This is
# only diagnostic query-family evidence, never a vehicle/control fingerprint.
PEDAL_NORMAL_BUS_LAYOUT = {0xD3: 2, 0xBE: 6, 0xC9: 8, 0x1C4: 8,
0xF1: 6, 0x1E1: 7, 0x201: 6}
def pedal_normal_bus0_topology(fingerprint):
if any(messages for bus, messages in fingerprint.items() if bus not in (0, 2)):
return False
return all(fingerprint.get(bus, {}).get(address) == size
for bus in (0, 2) for address, size in PEDAL_NORMAL_BUS_LAYOUT.items())
def normal_bus0_topology(fingerprint):
"""Recognize the GM query family without choosing a vehicle or actuator owner."""
if pedal_normal_bus0_topology(fingerprint):
return True
pt = {address: size for address, size in fingerprint.get(CanBus.POWERTRAIN, {}).items()
if address < 0x800 and address not in (0x7DF, 0x7E0, 0x7E8)}
if len(pt) < 8:
return False
# Common Global A driver/steering state is shared by gateway, ASCM, SDGM,
# camera, CC and interceptor installations. Camera presence and pedal presence
# are deliberately not query-family requirements. Both ECM DLCs are in GM's
# existing legacy inventory; they do not select a parser or control profile.
if (pt.get(0xC9) in (6, 8) and pt.get(0x184) == 8 and
pt.get(0x1C4) == 8 and pt.get(0x1E1) == 7):
return True
# Older GM architectures use different state messages. Reuse the existing
# all-brand CAN inventory only when every surviving identity belongs to GM.
candidates = all_legacy_fingerprint_cars()
for address, size in pt.items():
candidates = eliminate_incompatible_cars(CanData(address, bytes(size), CanBus.POWERTRAIN), candidates)
if not candidates:
return False
return bool(candidates) and all(candidate in CAR for candidate in candidates)
+2 -3
View File
@@ -41,9 +41,8 @@ class CarInterface(CarInterfaceBase):
structs.CarState.GearShifter.eco, structs.CarState.GearShifter.manumatic)
@classmethod
def get_params(cls, candidate, fingerprint, car_fw, alpha_long, is_release, docs, *, auto_pedal=True):
selected = pedal_candidate(candidate, fingerprint) if auto_pedal else candidate
return super().get_params(selected, fingerprint, car_fw, alpha_long, is_release, docs)
def get_params(cls, candidate, fingerprint, car_fw, alpha_long, is_release, docs):
return super().get_params(pedal_candidate(candidate, fingerprint), fingerprint, car_fw, alpha_long, is_release, docs)
def apply(self, c, now_nanos=None, *, startup_keepalive=False):
if startup_keepalive:
@@ -1,6 +1,5 @@
from opendbc.car.pedal import supported_pedal_detected
from opendbc.car.gm.values import CAR, CanBus
from opendbc.car.vin import is_valid_vin
def pedal_candidate(candidate, fingerprint):
@@ -10,38 +9,14 @@ def pedal_candidate(candidate, fingerprint):
return candidate
def automatic_bolt_candidate(candidate, fingerprint, camera_cruise_messages, vin=None):
"""Refine Bolt CAN/FW aliases using VIN year and live camera capability."""
camera_cruise_states = {payload[1] & 0x7 for payload in camera_cruise_messages if len(payload) == 6}
def automatic_bolt_candidate(candidate, fingerprint, camera_cruise_states):
"""Correct the legacy Gen2 CAN alias only with observed stock ACC evidence."""
bolt_ids = (CAR.CHEVROLET_BOLT_EUV, CAR.CHEVROLET_BOLT_CC_2017,
CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023,
CAR.CHEVROLET_BOLT_ACC_2022_2023, CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL)
if candidate is not None and candidate not in bolt_ids:
if candidate not in bolt_ids:
return candidate
pt = fingerprint.get(CanBus.POWERTRAIN, {})
# Year alone is not vehicle identity. Require the Bolt EV/EUV VIN descriptor
# and matching generation's live PT layout before recovering a CAN collision.
bolt_vin = (isinstance(vin, str) and is_valid_vin(vin) and
vin.startswith('1G1F') and vin[5:8] == '6S0')
if bolt_vin and vin[9] in 'HJKLMNP':
if vin[9] in 'HJKLM':
if any(pt.get(address) != length for address, length in ((0xD3, 2), (0xBE, 6), (0x130, 1))):
return None
return CAR.CHEVROLET_BOLT_CC_2017 if vin[9] == 'H' else CAR.CHEVROLET_BOLT_CC_2018_2021
if any(pt.get(address) != length for address, length in
((0xD3, 3), (0xBE, 7), (0x130, 3), (0x1CA, 5), (0x236, 8))):
return None
pedal = supported_pedal_detected(fingerprint, CanBus.POWERTRAIN, supported=True)
if 0x201 in pt and not pedal:
return None
if camera_cruise_states and camera_cruise_states <= {2, 3}:
return CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL if pedal else CAR.CHEVROLET_BOLT_ACC_2022_2023
if camera_cruise_messages == {bytes(6)}:
return CAR.CHEVROLET_BOLT_CC_2022_2023
# Missing camera or non-adaptive ACC mode is not proof of ordinary cruise.
return None
if candidate is None:
return None
pt = fingerprint[CanBus.POWERTRAIN]
if pt.get(0xD3) != 3:
return candidate
# Original Gen2 signature, qualified against the observed route's full DLCs.
@@ -1,6 +1,5 @@
from opendbc.car import structs
from opendbc.car.gm.values import (GMFlags, camera_acc_pedal_profile, is_volt_ascm_longitudinal,
is_volt_ascm_stock, is_volt_sdgm_accepted_envelope)
from opendbc.car.gm.values import GMFlags, camera_acc_pedal_profile, is_volt_ascm_longitudinal, is_volt_sdgm_accepted_envelope
# PSCMStatus is expected at 10 Hz. Three periods bound first acquisition and missing feedback.
EPS_STATUS_TIMEOUT_NS = 300_000_000
@@ -9,7 +8,7 @@ ButtonType = structs.CarState.ButtonEvent.Type
def monitored_profile(CP: structs.CarParams) -> bool:
return ((is_volt_ascm_longitudinal(CP) or is_volt_ascm_stock(CP) or is_volt_sdgm_accepted_envelope(CP)) and
return ((is_volt_ascm_longitudinal(CP) or is_volt_sdgm_accepted_envelope(CP)) and
camera_acc_pedal_profile(CP) is None and
not CP.flags & GMFlags.PEDAL_LONG)
@@ -199,8 +199,8 @@ class TestAscmIntercept(unittest.TestCase):
cs = self.authority_frame(ci, packer, tick, status=0 if tick < 65 else 1)
self.assertFalse(cs.steerFaultTemporary, tick)
self.assertTrue(ci.CS.steering_authority.seen_active)
self.assertTrue(CarState(params(CAR.CHEVROLET_VOLT_ASCM)).steering_authority.enabled)
for cp in (params(CAR.CHEVROLET_VOLT), params(CAR.CHEVROLET_BOLT_EUV)):
for cp in (params(CAR.CHEVROLET_VOLT_ASCM), params(CAR.CHEVROLET_VOLT),
params(CAR.CHEVROLET_BOLT_EUV)):
self.assertFalse(CarState(cp).steering_authority.enabled)
def test_volt_steering_neutral_reacquisition(self):
@@ -1,314 +0,0 @@
"""Automatic pedal discovery must not move healthy normal CAN onto OBD pins."""
from dataclasses import replace
from unittest import mock
import pytest
from opendbc.car import car_helpers, fw_versions
from opendbc.car.can_definitions import CanData
from opendbc.car.ford.discovery import normal_bus_query_brand
from opendbc.car.gm.discovery import pedal_normal_bus0_topology
from opendbc.car.gm.values import CAR
from opendbc.car.hyundai.discovery import NORMAL_BUS_LAYOUT, normal_bus_topology
# Original pre-query samples from a8c036520b19999c/00000004--7e3abadb55.
# Query family only: live VIN/FW and ordinary CAN discovery still own identity.
RECORDED = ((0, 211, '2d1c'),
(2, 211, '2d1c'),
(0, 513, '047b025258c6'),
(2, 513, '047b025258c6'),
(0, 190, '0402620001fd'),
(2, 190, '0402620001fd'),
(0, 201, '8000002749001000'),
(2, 201, '8000002749001000'),
(0, 452, '1362c1ee004901b7'),
(2, 452, '1362c1ee004901b7'),
(0, 241, '1c020041fbfe'),
(2, 241, '1c020041fbfe'),
(0, 481, '00000000031cc0'),
(2, 481, '00000000031cc0'))
def recorded_frames():
return [CanData(address, bytes.fromhex(data), bus) for bus, address, data in RECORDED]
def brand(frames):
return normal_bus_query_brand(lambda **kwargs: [frames], normal_bus_topology,
gm_probe=pedal_normal_bus0_topology)
def test_recorded_pedal_layout_scopes_queries_to_gm():
assert brand(recorded_frames()) == 'gm'
@pytest.mark.parametrize('missing', range(len(RECORDED)))
def test_incomplete_layout_does_not_scope_gm(missing):
frames = recorded_frames()
del frames[missing]
assert brand(frames) is None
@pytest.mark.parametrize('bad', range(len(RECORDED)))
def test_wrong_bus_or_dlc_does_not_scope_gm(bad):
for bus_delta, size_delta in ((1, 0), (128, 0), (0, -1), (0, 1)):
frames = recorded_frames()
original = frames[bad]
frames[bad] = CanData(original.address, bytes(len(original.dat) + size_delta), original.src + bus_delta)
assert brand(frames) is None
def test_conflicting_dlc_cannot_be_hidden_by_later_valid_frame():
frames = recorded_frames()
bad = CanData(frames[0].address, bytes(len(frames[0].dat) + 1), frames[0].src)
assert brand([bad, *frames]) is None
def test_other_bus_activity_and_unrecognized_layout_keep_generic_discovery():
assert brand([*recorded_frames(), CanData(123, bytes(8), 1)]) is None
assert brand([]) is None
assert brand([CanData(123, bytes(8), 0)]) is None
def test_hyundai_path_and_existing_read_budget_are_preserved():
frames = [CanData(address, bytes(size), bus) for (bus, address), size in NORMAL_BUS_LAYOUT.items()]
assert brand(frames) == 'hyundai'
calls = []
def recv(**kwargs):
calls.append(kwargs)
return []
assert normal_bus_query_brand(recv, lambda recv: normal_bus_topology(recv, clock=lambda: 1.),
gm_probe=pedal_normal_bus0_topology) is None
assert len(calls) == 24
@pytest.mark.parametrize('registry', ['configured', 'effective'])
@pytest.mark.parametrize('change', ['bus1', 'empty'])
def test_changed_gm_query_topology_does_not_scope_automatic_queries(registry, change):
requests = fw_versions.FW_QUERY_CONFIGS['gm'].requests
if registry == 'configured':
target, name = fw_versions.FW_QUERY_CONFIGS['gm'], 'requests'
changed = [replace(r, bus=1) for r in requests] if change == 'bus1' else []
else:
target, name = fw_versions, 'REQUESTS'
changed = [(b, c, replace(r, bus=1) if b == 'gm' else r) for b, c, r in fw_versions.REQUESTS] if change == 'bus1' else [
item for item in fw_versions.REQUESTS if item[0] != 'gm']
with mock.patch.object(target, name, changed):
assert not car_helpers._gm_fw_uses_normal_bus0()
with mock.patch.object(car_helpers, 'normal_bus_query_brand', return_value=None) as probe, \
mock.patch.object(car_helpers, 'get_vin', return_value=(0, 0, '00000000000000000')), \
mock.patch.object(car_helpers, 'get_present_ecus', return_value=set()), \
mock.patch.object(car_helpers, 'get_fw_versions_ordered', return_value=[]), \
mock.patch.object(car_helpers, 'can_fingerprint', return_value=(None, {})), \
mock.patch.dict(car_helpers.os.environ, {'FINGERPRINT': '', 'SKIP_FW_QUERY': '', 'DISABLE_FW_CACHE': ''}):
car_helpers.fingerprint(lambda **kw: [], mock.Mock(), mock.Mock(), None)
assert probe.call_args.kwargs['gm_probe'] is None
# Complete observed pre-query address/DLC set; payloads are not identity evidence.
RECORDED_LAYOUT = {188: 8,
320: 3,
211: 2,
390: 7,
352: 5,
513: 6,
485: 8,
170: 8,
190: 6,
201: 8,
417: 7,
493: 8,
419: 1,
495: 4,
451: 8,
499: 3,
452: 8,
503: 1,
453: 6,
193: 8,
197: 8,
209: 7,
454: 8,
241: 6,
456: 8,
463: 3,
508: 8,
500: 6,
532: 6,
554: 3,
568: 1,
647: 3,
717: 5,
707: 8,
872: 1,
967: 4,
298: 8,
977: 8,
979: 7,
985: 5,
988: 6,
1001: 8,
1221: 5,
869: 4,
189: 7,
407: 7,
479: 3,
501: 8,
1257: 6,
546: 7,
550: 8,
558: 8,
384: 4,
489: 8,
560: 8,
562: 8,
711: 6,
309: 8,
481: 7,
1322: 6,
381: 6,
840: 5,
842: 5,
566: 6,
989: 8,
386: 8,
311: 8,
308: 4,
1249: 8,
1922: 7,
761: 7,
866: 4,
313: 8,
1300: 8,
995: 7,
528: 5,
844: 8,
1907: 7,
810: 8,
353: 3,
564: 5,
608: 8,
609: 6,
610: 6,
611: 6,
612: 8,
1017: 8,
613: 8,
1019: 2,
1328: 4,
1020: 8,
961: 8,
322: 7,
753: 5,
497: 8,
388: 8,
1105: 6,
969: 8,
800: 6,
1005: 6,
1022: 1,
1013: 3,
567: 5,
1009: 8,
2020: 8,
2028: 8,
1217: 8,
1227: 4,
1223: 3,
1233: 8,
1275: 3,
1243: 3,
1225: 7,
1913: 7,
1187: 4,
1927: 7,
1265: 8,
1906: 7,
1905: 7,
1904: 7,
328: 1,
1912: 7,
573: 1,
1280: 4,
304: 1,
565: 5,
563: 5}
VIN = "1G1RC6S50HU000001"
class RecordedIsoTpEcu:
"""Actual query/UDS framing, inert CAN peer. No real bus or firmware claim."""
def __init__(self, response_bus=0):
self.clock = 0.
self.frames = []
self.pending = {}
self.sent = []
self.response_bus = response_bus
def send(self, messages):
self.sent.extend(messages)
for message in messages:
payload = message.dat
if payload[0] >> 4 == 3:
self.frames.extend(self.pending.pop(message.address, []))
continue
if payload[0] >> 4 != 0:
continue
request = payload[1:1 + payload[0]]
if message.address in (0x7df, 0x7e0) and request == b'\x22\xf1\x90':
self.reply(0x7e0, 0x7e8, b'\x62\xf1\x90' + VIN.encode())
elif request == b'\x3e' and message.address < 0x700:
self.reply(message.address, message.address + 0x400, b'\x7e')
elif request[:1] == b'\x1a' and message.address < 0x700:
self.reply(message.address, message.address + 0x400, b'\x5a' + request[1:2] + b'FW')
def reply(self, tx, rx, value):
if len(value) < 8:
self.frames.append(CanData(rx, bytes([len(value)]) + value + bytes(7 - len(value)), self.response_bus))
else:
self.frames.append(CanData(rx, bytes([0x10 | len(value) >> 8, len(value) & 0xff]) + value[:6], self.response_bus))
self.pending[tx] = [CanData(rx, bytes([0x20 | (i + 1) % 16]) + value[start:start + 7] + bytes(max(0, 7 - len(value[start:start + 7]))), self.response_bus)
for i, start in enumerate(range(6, len(value), 7))]
def recv(self, wait_for_one=False):
self.clock += .01
values, self.frames = self.frames, []
background = [CanData(address, bytes(size), bus) for bus in (0, 2) for address, size in RECORDED_LAYOUT.items()]
return [values + background]
def test_actual_automatic_vin_firmware_and_can_fingerprint_stay_normal_bus0():
peer = RecordedIsoTpEcu()
callbacks = []
with mock.patch.dict(car_helpers.os.environ, {'FINGERPRINT': '', 'SKIP_FW_QUERY': '', 'DISABLE_FW_CACHE': ''}), \
mock.patch.object(car_helpers.time, 'monotonic', side_effect=lambda: peer.clock), \
mock.patch.object(car_helpers.time, 'sleep', side_effect=lambda seconds: setattr(peer, 'clock', peer.clock + seconds)):
result = car_helpers.fingerprint(peer.recv, peer.send, callbacks.append, None)
assert result[0] == CAR.CHEVROLET_BOLT_CC_2017
assert result[2] == VIN
assert result[3] and all(fw.brand == 'gm' and fw.bus == 0 for fw in result[3])
assert result[4] == car_helpers.CarParams.FingerprintSource.can
assert callbacks and set(callbacks) == {False}
assert peer.sent and all(frame.src == 0 for frame in peer.sent)
assert any(frame.dat[0] >> 4 == 3 for frame in peer.sent)
def test_automatic_query_scope_preserves_actual_pedal_factory_safety_configuration():
peer = RecordedIsoTpEcu()
with mock.patch.dict(car_helpers.os.environ, {'FINGERPRINT': '', 'SKIP_FW_QUERY': '', 'DISABLE_FW_CACHE': ''}), \
mock.patch.object(car_helpers.time, 'monotonic', side_effect=lambda: peer.clock), \
mock.patch.object(car_helpers.time, 'sleep', side_effect=lambda seconds: setattr(peer, 'clock', peer.clock + seconds)):
ci = car_helpers.get_car(peer.recv, peer.send, mock.Mock(), True, False)
assert ci.CP.carFingerprint == CAR.CHEVROLET_BOLT_CC_2017
assert ci.CP.openpilotLongitudinalControl
assert ci.CP.safetyConfigs[0].safetyModel == car_helpers.CarParams.SafetyModel.gm
assert ci.CP.safetyConfigs[0].safetyParam == 189
# AOL is added by card's pre-create hook, not by diagnostic query selection.
direct = car_helpers.interfaces[CAR.CHEVROLET_BOLT_CC_2017].get_params(
CAR.CHEVROLET_BOLT_CC_2017, {0: RECORDED_LAYOUT, 1: {}, 2: RECORDED_LAYOUT}, [], True, False, docs=False)
assert ci.CP.alternativeExperience == direct.alternativeExperience
assert ci.CP.flags == direct.flags
assert ci.CP.lateralTuning.to_dict() == direct.lateralTuning.to_dict()
@@ -40,7 +40,6 @@ class TestBoltAccPedalFriction(unittest.TestCase):
control.longActive = True
control.actuators.accel = -3.
state = structs.CarState()
state.canValid = True
state.vEgo = 12.
state.gearShifter = structs.CarState.GearShifter.low
state.cruiseState.available = True
@@ -53,26 +52,12 @@ class TestBoltAccPedalFriction(unittest.TestCase):
buttons_counter=0,
pscm_status=dict.fromkeys(("HandsOffSWDetectionMode", "HandsOffSWlDetectionStatus", "LKATorqueDeliveredStatus",
"LKADriverAppldTrq", "LKATorqueDelivered", "LKATotalTorqueDelivered", "RollingCounter", "PSCMStatusChecksum"), 0))
from opendbc.car.gm.steering_authority import SteeringAuthority
cs.steering_authority = SteeringAuthority(cp)
return controller, control, state, cs
def step(self, fixture, frame, now=1_000_000_000):
controller, control, state, cs = fixture
controller.frame = frame
now += (frame - 4) * 10_000_000
# Advance the declared healthy physical receipts with the 100 Hz fixture.
# Explicitly stale/absent receipts supplied by each test stay stale/absent.
previous = getattr(cs, '_healthy_fixture_stamps', {})
for name in ('pedal_sensor_ts_nanos', 'stock_acc_status_ts_nanos', 'bolt_pedal_gear_ts_nanos', 'bolt_pedal_main_ts_nanos'):
value = getattr(cs, name)
if value == previous.get(name, 950_000_000):
setattr(cs, name, now - 50_000_000)
previous[name] = now - 50_000_000
cs._healthy_fixture_stamps = previous
cs.out = state.as_reader()
cs.bolt_acc_brake_clock_ns = now
cs.bolt_acc_brake_clock_counter = (frame // 4) % 4
_, messages = controller.update(control.as_reader(), cs, now)
return messages
@@ -265,80 +250,3 @@ class TestBoltAccPedalFriction(unittest.TestCase):
payloads = {m[0]: m[1].hex() for m in messages if m[0] in (0x200, 0x1F5, 0xBD, 0x315)}
self.assertEqual(payloads, {0x200: "056f02b888ce", 0x1F5: "0c0c000500020100",
0xBD: "20000000000000", 0x315: "9000700000"})
class TestBoltAccBrakeClock(unittest.TestCase):
def test_clock_is_fresh_nonrepeating_and_never_expands_authority(self):
from opendbc.car.gm.bolt_acc_brake_clock import BoltAccBrakeClock
clock = BoltAccBrakeClock()
clock.prepare(1_000_000_000, 24, True, False, False)
def sample(now, source, counter, **changes):
flags = dict(active=True, main_ready=True, source_valid=True)
flags.update(changes)
return clock.sample(now, source, counter, **flags)
self.assertEqual(sample(1_010_000_000, 1_005_000_000, 2), (24, 2, True, False, False))
self.assertIsNone(sample(1_020_000_000, 1_005_000_000, 2))
self.assertIsNone(sample(1_020_000_000, 1_015_000_000, 2))
self.assertIsNone(sample(1_020_000_000, 1_021_000_000, 3))
self.assertIsNone(sample(1_020_000_000, 0, 3))
self.assertIsNone(sample(1_020_000_000, 1_015_000_000, 3, source_valid=False))
self.assertEqual(sample(1_050_000_000, 1_045_000_000, 3, active=False), (0, 3, False, False, False))
self.assertIsNone(sample(1_090_000_000, 1_085_000_000, 0))
clock.prepare(1_090_000_000, 24, True, False, False)
self.assertIsNone(sample(1_090_000_000, 1_000_000_000, 0))
self.assertIsNone(sample(1_090_000_000, 1_085_000_000, 0, main_ready=False))
self.assertIsNone(sample(1_090_000_000, 1_085_000_000, 0))
def test_controller_emits_on_factory_cycle_between_pedal_slots(self):
fixture = TestBoltAccPedalFriction().fixture()
controller, control, state, cs = fixture
state.canValid = True
cs.out = state.as_reader()
cs.bolt_acc_brake_clock_ns = 995_000_000
cs.bolt_acc_brake_clock_counter = 2
controller.frame = 4
_, first = controller.update(control.as_reader(), cs, 1_000_000_000)
self.assertEqual(brake_fields(brake_frames(first)[0])[2], 2)
pedal_first = [m for m in first if m[0] == 0x200]
self.assertEqual(len(pedal_first), 1)
controller.frame = 5
_, duplicate = controller.update(control.as_reader(), cs, 1_010_000_000)
self.assertFalse(brake_frames(duplicate))
controller.frame = 8
_, waiting = controller.update(control.as_reader(), cs, 1_040_000_000)
self.assertFalse(brake_frames(waiting))
cs.bolt_acc_brake_clock_ns = 1_045_000_000
cs.bolt_acc_brake_clock_counter = 3
controller.frame = 9
_, next_cycle = controller.update(control.as_reader(), cs, 1_050_000_000)
self.assertEqual(brake_fields(brake_frames(next_cycle)[0])[2], 3)
self.assertFalse(any(m[0] == 0x200 for m in next_cycle))
self.assertFalse(any(m[0] in (0x2CB, 0xBD, 0x1F5) for m in next_cycle))
def test_scope_excludes_removed_camera_and_no_acc(self):
from opendbc.car.gm.tests.test_bolt_pedal import params
for identity in (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021,
CAR.CHEVROLET_BOLT_CC_2022_2023, CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL):
cp = params(identity, pedal=True, removed=True)
controller = CarController(DBC[cp.carFingerprint], cp)
self.assertIsNone(controller.bolt_acc_brake_clock)
def test_lost_or_frozen_clock_releases_once_and_cannot_rearm_itself(self):
from opendbc.car.gm.bolt_acc_brake_clock import BoltAccBrakeClock
for failure in ('stale', 'frozen', 'invalid'):
clock = BoltAccBrakeClock()
clock.prepare(1_000_000_000, 24, True, False, False)
self.assertEqual(clock.sample(1_000_000_000, 1_000_000_000, 2,
active=True, main_ready=True, source_valid=True), (24, 2, True, False, False))
clock.prepare(1_090_000_000, 24, True, False, False)
source = 1_000_000_000 if failure == 'stale' else 1_090_000_000
self.assertEqual(clock.sample(1_090_000_000, source, 2,
active=True, main_ready=True, source_valid=failure != 'invalid'), (0, 3, False, False, False))
self.assertIsNone(clock.sample(1_100_000_000, source, 2, active=True, main_ready=True, source_valid=True))
clock.prepare(1_130_000_000, 24, True, False, False)
self.assertIsNone(clock.sample(1_130_000_000, 1_130_000_000, 3,
active=True, main_ready=True, source_valid=True))
clock.prepare(1_170_000_000, 24, True, False, False)
self.assertEqual(clock.sample(1_170_000_000, 1_170_000_000, 0,
active=True, main_ready=True, source_valid=True), (24, 0, True, False, False))
@@ -371,43 +371,25 @@ class TestBoltPedalMessages(unittest.TestCase):
cam_lka_steering_cmd_counter=0, loopback_lka_steering_cmd_updated=False,
loopback_lka_steering_cmd_ts_nanos=1_000_000_000, pt_lka_steering_cmd_counter=0,
buttons_counter=0)
state.canValid = int(cp.safetyConfigs[0].safetyParam) == 0x1CD
cs.out = state.as_reader()
cs.bolt_acc_brake_clock_ns = 995_000_000
cs.bolt_acc_brake_clock_counter = 1
def step(tick, controller=controller, control=control, state=state, cs=cs):
# Supply each 25 Hz controller slot with fresh physical receipts and
# the corresponding factory brake cycle, rather than reusing time.
now = 1_000_000_000 + (tick - 4) * 10_000_000
controller.frame = tick
cs.loopback_lka_steering_cmd_ts_nanos = now
for name in ('pedal_sensor_ts_nanos', 'stock_acc_status_ts_nanos', 'bolt_pedal_gear_ts_nanos', 'bolt_pedal_main_ts_nanos'):
setattr(cs, name, now - 50_000_000)
cs.bolt_acc_brake_clock_ns = now - 5_000_000
cs.bolt_acc_brake_clock_counter = (tick // 4) % 4
cs.out = state.as_reader()
return controller.update(control.as_reader(), cs, now)[1]
messages = step(4)
_, messages = controller.update(control.as_reader(), cs, 1_000_000_000)
expected = ([0x3D1] if candidate in NO_ACC_BOLT_CAR else []) + [0x200, 0x1F5, 0xBD]
if candidate == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
expected += [0x370, 0x315]
expected.append(0x315)
self.assertEqual([m[0] for m in messages], expected)
self.assertGreater(controller.pedal_steady, 0.)
first_fraction = controller.pedal_steady
controller.frame = 8
control.actuators.accel = 2.
messages = step(8)
_, messages = controller.update(control.as_reader(), cs, 1_000_000_000)
self.assertEqual([m[0] for m in messages], expected)
self.assertGreater(controller.pedal_steady, first_fraction)
self.assertLess(controller.pedal_steady - first_fraction, 0.06)
controller.frame = 12
cs.pedal_sensor_healthy = False
messages = step(12)
self.assertEqual([m[0] for m in messages], [address for address in expected if address != 0x370])
_, messages = controller.update(control.as_reader(), cs, 1_000_000_000)
self.assertEqual([m[0] for m in messages], expected)
payloads = {message[0]: message[1] for message in messages}
self.assertEqual(payloads[0x200][0:4], b"\x00" * 4)
self.assertEqual(payloads[0x1F5], b"\x0c\x0c\x00\x06\x00\x00\x01\x00")
@@ -419,7 +401,7 @@ class TestBoltPedalMessages(unittest.TestCase):
cs.pedal_sensor_healthy = True
state.cruiseState.enabled = True
cs.out = state.as_reader()
messages = step(16)
_, messages = controller.update(control.as_reader(), cs, 1_000_000_000)
self.assertEqual(messages[0][1][0:4], b"\x00" * 4)
self.assertIn((0x1E1, 2), [(m[0], m[2]) for m in messages])
@@ -492,8 +474,6 @@ class TestBoltPedalStartupParser(unittest.TestCase):
if 'GAS_SENSOR' not in omit:
frames.append(TestBoltPedalMessages.sensor(packer, 0, sensor_counter % 16, state=sensor_state))
frames += [packer.make_can_msg('ASCMLKASteeringCmd', 2, {}), packer.make_can_msg('AEBCmd', 2, {})]
if int(ci.CP.safetyConfigs[0].safetyParam) == 0x1CD and now % 40_000_000 < 10_000_000:
frames.append(packer.make_can_msg('EBCMFrictionBrakeCmd', 2, {'RollingCounter': (now // 40_000_000) % 4}))
if camera_stock:
frames.append(packer.make_can_msg('ASCMActiveCruiseControlStatus', 2, {'ACCCruiseState': 2}))
if bad_length is not None:
@@ -794,8 +774,6 @@ class TestBoltPedalStartupParser(unittest.TestCase):
camera = ['ASCMLKASteeringCmd', 'AEBCmd'] if camera_present else []
if cp.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
camera.append('ASCMActiveCruiseControlStatus')
if int(cp.safetyConfigs[0].safetyParam) == 0x1CD:
camera.append('EBCMFrictionBrakeCmd')
sensor_counter = 0
for index in range(250):
frames = []
@@ -803,7 +781,7 @@ class TestBoltPedalStartupParser(unittest.TestCase):
for name in names:
if (bus, name) == missing:
continue
rate = pt_messages[name] if bus == 0 else 25 if name in ('ASCMActiveCruiseControlStatus', 'EBCMFrictionBrakeCmd') else 10
rate = pt_messages[name] if bus == 0 else 25 if name == 'ASCMActiveCruiseControlStatus' else 10
if index and index * rate // 100 == (index - 1) * rate // 100:
continue
if name == 'ASCMSteeringButton':
@@ -1116,7 +1094,7 @@ class TestBoltPaddleModes(unittest.TestCase):
control.enabled = control.longActive = False
frames.append((0x1E1, button_bytes(buttons, tick % 4), 0))
frames.extend(packer.make_can_msg(name, 2, values.get(name, {})) for name in
('ASCMLKASteeringCmd', 'AEBCmd', 'ASCMActiveCruiseControlStatus', 'EBCMFrictionBrakeCmd'))
('ASCMLKASteeringCmd', 'AEBCmd', 'ASCMActiveCruiseControlStatus'))
frames.append(packer.make_can_msg('ASCMLKASteeringCmd', 128, {}))
safety.reset_recorded_can()
regen_observation_index = None
@@ -1,181 +0,0 @@
"""Recorded Bolt identity selection, capability evidence and explicit overrides."""
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from opendbc.car import car_helpers, gen_empty_fingerprint, structs
from opendbc.car.can_definitions import CanData, CanRecvCallable
from opendbc.car.gm.interface import CarInterface
from opendbc.car.gm.pedal_capability import automatic_bolt_candidate
from opendbc.car.gm.values import CAR, GMFlags
RECORDINGS = json.loads((Path(__file__).parent / 'testdata/bolt_vin_fingerprints.json').read_text())
BOLTS = (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021,
CAR.CHEVROLET_BOLT_CC_2022_2023, CAR.CHEVROLET_BOLT_ACC_2022_2023,
CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL)
def recording(index):
row = RECORDINGS[index]
fp = gen_empty_fingerprint()
fp.update({int(bus): {int(address): size for address, size in messages.items()}
for bus, messages in row['fingerprint'].items()})
# Descriptor/year retained; check digit and serial are synthetic.
vin = row['vinPrefix'] + '0' + row['year'] + '0000000'
return fp, {bytes.fromhex(raw) for raw in row['cameraPayloads']}, vin
def packets(fp, camera):
return [CanData(address, bytes(size), bus) for bus, messages in fp.items()
for address, size in messages.items() if not (bus == 2 and address == 0x370)] + [
CanData(0x370, raw, 2) for raw in camera]
def receiver(frames: list[CanData]) -> CanRecvCallable:
def recv(wait_for_one: bool = False) -> list[list[CanData]]:
return [frames]
return recv
@pytest.mark.parametrize('index', range(4))
def test_recorded_routes_select_correct_identity_through_can(index):
fp, camera, vin = recording(index)
selected, observed = car_helpers.can_fingerprint(receiver(packets(fp, camera)), vin=vin)
assert selected == RECORDINGS[index]['expected']
assert observed == fp
@pytest.mark.parametrize('year,expected', [('H', BOLTS[0]), ('J', BOLTS[1]), ('K', BOLTS[1]),
('L', BOLTS[1]), ('M', BOLTS[1])])
@pytest.mark.parametrize('alias', [None, *BOLTS, CAR.CHEVROLET_BOLT_EUV])
def test_gen1_year_corrects_all_automatic_aliases(year, expected, alias):
fp, camera, vin = recording(0)
vin = vin[:9] + year + vin[10:]
assert automatic_bolt_candidate(alias, fp, camera, vin) == expected
@pytest.mark.parametrize('year', ['N', 'P'])
@pytest.mark.parametrize('pedal', [False, True])
@pytest.mark.parametrize('acc', [False, True])
@pytest.mark.parametrize('alias', [None, *BOLTS, CAR.CHEVROLET_BOLT_EUV])
def test_gen2_capability_selects_all_three_identities(year, pedal, acc, alias):
fp, camera, vin = recording(3 if acc else 2)
vin = vin[:9] + year + vin[10:]
fp[0].pop(0x201, None)
if pedal:
fp[0][0x201] = 6
expected = BOLTS[4 if pedal else 3] if acc else BOLTS[2]
assert automatic_bolt_candidate(alias, fp, camera, vin) == expected
@pytest.mark.parametrize('camera', [set(), {b'\0\4\0\0\0\0'}, {b'\0\5\0\0\0\0'},
{b'\1\0\0\0\0\0'}, {bytes(6), b'\0\2\0\0\0\0'}])
def test_missing_nonadaptive_or_conflicting_camera_cannot_select_no_acc(camera):
fp, _, vin = recording(2)
assert automatic_bolt_candidate(BOLTS[1], fp, camera, vin) is None
@pytest.mark.parametrize('address', [0xD3, 0xBE, 0x130, 0x1CA, 0x236])
def test_missing_generation_evidence_cannot_recover_unknown_candidate(address):
fp, camera, vin = recording(3)
del fp[0][address]
assert automatic_bolt_candidate(None, fp, camera, vin) is None
@pytest.mark.parametrize('vin', ['', '0' * 17, '1G1FX6S00Z0000000', '1G1RA6S00P0000000',
'1G1FX6S00P000000I', '1G1FX6S00P'])
def test_invalid_nonbolt_or_unsupported_vin_cannot_recover_unknown_candidate(vin):
fp, camera, _ = recording(3)
assert automatic_bolt_candidate(None, fp, camera, vin) is None
def test_wrong_bus_or_dlc_is_not_pedal_evidence():
fp, camera, vin = recording(3)
fp[0].pop(0x201)
assert automatic_bolt_candidate(None, fp, camera, vin) == BOLTS[3]
fp[0][0x201] = 7
assert automatic_bolt_candidate(None, fp, camera, vin) is None
@pytest.mark.parametrize('candidate', [CAR.CHEVROLET_VOLT, CAR.CHEVROLET_VOLT_ASCM,
CAR.CHEVROLET_VOLT_2019, CAR.CHEVROLET_MALIBU, 'HYUNDAI IONIQ 6 2023'])
def test_other_identities_are_never_rewritten(candidate):
fp, camera, vin = recording(3)
assert automatic_bolt_candidate(candidate, fp, camera, vin) == candidate
def fingerprint_with_firmware(fp, camera, vin, fw_candidate, fixed=''):
cached = SimpleNamespace(brand='gm', carVin=vin, carFw=[object()])
with patch.dict(car_helpers.os.environ, {'FINGERPRINT': fixed, 'SKIP_FW_QUERY': '', 'DISABLE_FW_CACHE': ''}), \
patch.object(car_helpers, 'match_fw_to_car', return_value=(True, {fw_candidate})):
return car_helpers.fingerprint(receiver(packets(fp, camera)), lambda _: None, lambda _: None, cached)
@pytest.mark.parametrize('index', range(4))
def test_firmware_alias_cannot_undo_vin_refinement(index):
fp, camera, vin = recording(index)
result = fingerprint_with_firmware(fp, camera, vin, CAR.CHEVROLET_BOLT_EUV)
assert result[0] == RECORDINGS[index]['expected']
@pytest.mark.parametrize('manual', BOLTS)
def test_environment_override_wins_after_all_automatic_selection(manual):
fp, camera, vin = recording(3)
result = fingerprint_with_firmware(fp, camera, vin, BOLTS[1], fixed=manual)
assert result[0] == manual
assert result[4] == structs.CarParams.FingerprintSource.fixed
@pytest.mark.parametrize('manual', BOLTS)
@pytest.mark.parametrize('source', ['forced', 'environment'])
def test_get_car_preserves_exact_manual_identity_and_safety(manual, source):
fp, _, vin = recording(3)
# Avoid starting Card/controller: build actual CP, capture its final selection.
class Factory:
get_params = CarInterface.get_params
def __new__(cls, cp):
return SimpleNamespace(CP=cp)
fingerprint = (BOLTS[4] if source == 'forced' else manual, fp, vin, [],
structs.CarParams.FingerprintSource.can if source == 'forced' else structs.CarParams.FingerprintSource.fixed, True)
with patch.object(car_helpers, 'fingerprint', return_value=fingerprint), \
patch.dict(car_helpers.interfaces, {car: Factory for car in BOLTS}), \
patch.dict(car_helpers.os.environ, {'FINGERPRINT': '' if source == 'forced' else manual}):
car = car_helpers.get_car(receiver([]), lambda _: None, lambda _: None, False, False,
forced_candidate=manual if source == 'forced' else None)
expected = CarInterface.get_params(manual, fp, [], False, False, False, auto_pedal=False)
assert car.CP.carFingerprint == manual
assert [s.to_dict() for s in car.CP.safetyConfigs] == [s.to_dict() for s in expected.safetyConfigs]
assert bool(car.CP.flags & GMFlags.PEDAL_LONG) == bool(expected.flags & GMFlags.PEDAL_LONG)
@pytest.mark.parametrize('index,pedal,expected', [(0, True, BOLTS[0]), (1, True, BOLTS[1]),
(2, True, BOLTS[2]), (3, False, BOLTS[3]), (3, True, BOLTS[4])])
def test_automatic_selection_reaches_existing_safety_and_torque_profile(index, pedal, expected):
from opendbc.car.gm.values import CarControllerParams
fp, camera, vin = recording(index)
fp[0].pop(0x201, None)
if pedal:
fp[0][0x201] = 6
selected, _ = car_helpers.can_fingerprint(receiver(packets(fp, camera)), vin=vin)
assert selected == expected
cp = CarInterface.get_params(selected, fp, [], False, False, False)
existing = CarInterface.get_params(expected, fp, [], False, False, False)
assert cp.carFingerprint == expected
assert [s.to_dict() for s in cp.safetyConfigs] == [s.to_dict() for s in existing.safetyConfigs]
assert bool(cp.flags & GMFlags.PEDAL_LONG) == pedal
assert CarControllerParams(cp).STEER_MAX == (450 if index == 0 else 300)
@pytest.mark.parametrize('bus,size', [(0, 6), (128, 6), (130, 6), (2, 5), (2, 7)])
def test_wrong_camera_bus_or_dlc_cannot_select_acc(bus, size):
fp, camera, vin = recording(3)
fp[2].pop(0x370, None)
frames = packets(fp, set()) + [CanData(0x370, next(iter(camera))[:size].ljust(size, b'\0'), bus)]
selected, _ = car_helpers.can_fingerprint(receiver(frames), vin=vin)
assert selected is None
@@ -65,62 +65,6 @@ def controller_messages(cp, frame, controller=None, *, stock_acc_enabled=False,
class TestBoltVoltConfigurations(unittest.TestCase):
def test_bolt_steering_counter_tracks_real_loopback_values(self):
for identity in PEDAL_BOLT_CAR:
for alpha in (False, True):
for removed in (False, True):
with self.subTest(identity=identity, alpha=alpha, removed=removed):
cp = pedal_params(identity, setting=True, pedal=True, alpha_long=alpha, removed=removed)
if identity == CAR.CHEVROLET_BOLT_CC_2018_2021 and not removed:
self.assertEqual(cp.safetyConfigs[0].safetyParam, 0x9D)
cs = CarState(cp)
parsers = cs.get_can_parsers(cp)
packer = CANPacker(DBC[identity][Bus.pt])
controller = CarController(DBC[identity], cp)
control = structs.CarControl()
for step, (received, expected) in enumerate((([3], 0), ([0, 1], 2), ([0, 0], 1),
([1], 2), ([], 2), ([2, 3], 0), ([0], 1))):
now = 1_000_000_000 + step * 100_000_000
packets = [packer.make_can_msg("ASCMLKASteeringCmd", 128, {"RollingCounter": value}) for value in received]
for parser in parsers.values():
parser.update([(now - 20_000_000, packets)])
cs.out = cs.update(parsers).as_reader()
self.assertEqual(cs.loopback_lka_steering_cmd_updated, bool(received))
controller.frame = 20 + step * 10
_, commands = controller.update(control.as_reader(), cs, now)
steering = [message for message in commands if message[0] == 0x180 and message[2] == 0]
self.assertEqual(len(steering), 1)
data = steering[0][1]
self.assertEqual((data[0] >> 4) & 3, expected)
self.assertFalse(data[0] & 8)
def test_nonpedal_gm_preserves_legacy_loopback_counter_progression(self):
for identity in (CAR.CHEVROLET_VOLT, CAR.CHEVROLET_VOLT_CAMERA, CAR.CHEVROLET_VOLT_2019,
CAR.CHEVROLET_BOLT_EUV):
for alpha in (False, True):
with self.subTest(identity=identity, alpha=alpha):
cp = ordinary_params(identity, alpha=alpha, radar=True)
cs = CarState(cp)
parsers = cs.get_can_parsers(cp)
packer = CANPacker(DBC[identity][Bus.pt])
controller = CarController(DBC[identity], cp)
self.assertFalse(controller.bolt_steering_ack_counter)
control = structs.CarControl()
expected = 0
for step, received in enumerate(([3], [0, 1], [0, 0], [1], [], [2, 3], [0])):
now = 1_000_000_000 + step * 100_000_000
packets = [packer.make_can_msg("ASCMLKASteeringCmd", 128, {"RollingCounter": value}) for value in received]
for parser in parsers.values():
parser.update([(now - 20_000_000, packets)])
cs.out = cs.update(parsers).as_reader()
expected += bool(received)
controller.frame = 20 + step * 10
_, commands = controller.update(control.as_reader(), cs, now)
steering = [message for message in commands if message[0] == 0x180 and message[2] == 0]
self.assertEqual(len(steering), 1)
self.assertEqual((steering[0][1][0] >> 4) & 3, expected % 4)
self.assertFalse(steering[0][1][0] & 8)
def test_all_bolt_pedal_generations_withdraw_on_missing_stale_or_future_gear(self):
for candidate in PEDAL_BOLT_CAR:
for alpha in (False, True):
@@ -1,189 +0,0 @@
"""GM-wide normal wiring discovery; recorded Sierra and unchanged control owners."""
import json
from pathlib import Path
from unittest import mock
import pytest
from opendbc.car import car_helpers, gen_empty_fingerprint
from opendbc.car.can_definitions import CanData
from opendbc.car.fingerprints import _FINGERPRINTS
from opendbc.car.ford.discovery import normal_bus_query_brand
from opendbc.car.gm.discovery import normal_bus0_topology, pedal_normal_bus0_topology
from opendbc.car.gm.fingerprints import FINGERPRINTS
from opendbc.car.gm.interface import CarInterface
from opendbc.car.gm.tests.test_automatic_discovery import RecordedIsoTpEcu
from opendbc.car.gm.values import CAR
from opendbc.car.hyundai.discovery import normal_bus_topology as hyundai_probe
SIERRA = json.loads((Path(__file__).parent / 'testdata/sierra_startup_discovery.json').read_text())
GM_LAYOUTS = [(car, i, fp) for car, layouts in FINGERPRINTS.items() for i, fp in enumerate(layouts)]
OTHER_LAYOUTS = [(car, i, fp) for car, layouts in _FINGERPRINTS.items() if car not in CAR for i, fp in enumerate(layouts)]
def frames(fp):
return [CanData(a, bytes(n), b) for b, messages in fp.items() for a, n in messages.items()]
def sierra_fingerprint() -> dict[int, dict[int, int]]:
fp = gen_empty_fingerprint()
fp.update({int(b): {int(a): n for a, n in messages.items()} for b, messages in SIERRA['fingerprint'].items()})
return fp
@pytest.mark.parametrize('car,index,layout', GM_LAYOUTS, ids=[f'{c}-{i}' for c, i, _ in GM_LAYOUTS])
def test_all_registered_gm_can_layouts_use_normal_query_family(car, index, layout):
assert normal_bus0_topology({0: layout})
@pytest.mark.parametrize('car,index,layout', OTHER_LAYOUTS, ids=[f'{c}-{i}' for c, i, _ in OTHER_LAYOUTS])
def test_other_brand_layouts_do_not_select_gm_queries(car, index, layout):
assert not normal_bus0_topology({0: layout})
assert not normal_bus0_topology({0: layout, 2: layout})
def test_recorded_sierra_passive_window_is_covered_without_pedal():
batches = [[CanData(a, bytes(n), b) for a, n, b in packet] for packet in SIERRA['packets']]
observed = {}
for packet in batches:
for frame in packet:
observed.setdefault(frame.src, {})[frame.address] = len(frame.dat)
assert not pedal_normal_bus0_topology(observed) # This was the uncovered path.
assert normal_bus0_topology(observed)
stream = iter(batches)
reads = []
clock = [0.]
def recv(wait_for_one: bool = False) -> list[list[CanData]]:
reads.append(wait_for_one)
clock[0] += .01
return [next(stream, [])]
assert normal_bus_query_brand(recv, lambda read: hyundai_probe(read, clock=lambda: clock[0]),
gm_probe=normal_bus0_topology, clock=lambda: clock[0]) == 'gm'
assert len(reads) <= 13
@pytest.mark.parametrize('case', ['empty', 'sparse', 'wrong_bus', 'loopback', 'wrong_dlc'])
def test_unsupported_evidence_keeps_generic_discovery(case):
fp = {0: {0xC9: 8, 0x184: 8, 0x1C4: 8, 0x1E1: 7}}
if case == 'empty':
fp = {}
elif case in ('wrong_bus', 'loopback'):
fp = {2 if case == 'wrong_bus' else 128: sierra_fingerprint()[0]}
elif case == 'wrong_dlc':
fp = {0: dict(sierra_fingerprint()[0])}
fp[0][0x184] = 7
assert not normal_bus0_topology(fp)
def test_conflicting_dlc_does_not_fall_back_to_positive_gm_evidence():
rows = frames(sierra_fingerprint())
bad = CanData(0x184, bytes(7), 0)
def recv(wait_for_one: bool = False) -> list[list[CanData]]:
return [[bad, *rows]]
assert normal_bus_query_brand(recv, hyundai_probe, gm_probe=normal_bus0_topology, clock=lambda: 0.) is None
class SierraEcu(RecordedIsoTpEcu):
def recv(self, wait_for_one=False):
self.clock += .01
values, self.frames = self.frames, []
return [values + frames(sierra_fingerprint())]
def discover(peer, *, legacy=False, instantiate=False):
callbacks = []
with mock.patch.dict(car_helpers.os.environ, {'FINGERPRINT': '', 'SKIP_FW_QUERY': '', 'DISABLE_FW_CACHE': ''}), \
mock.patch.object(car_helpers.time, 'monotonic', side_effect=lambda: peer.clock), \
mock.patch.object(car_helpers.time, 'sleep', side_effect=lambda seconds: setattr(peer, 'clock', peer.clock + seconds)), \
mock.patch.object(car_helpers, 'gm_normal_bus0_topology', pedal_normal_bus0_topology if legacy else normal_bus0_topology):
if instantiate:
result = car_helpers.get_car(peer.recv, peer.send, callbacks.append, False, False)
else:
result = car_helpers.fingerprint(peer.recv, peer.send, callbacks.append, None)
return result, callbacks
def test_actual_sierra_vin_fw_can_and_control_profile_keep_normal_bus0():
peer = SierraEcu()
result, callbacks = discover(peer)
assert callbacks and set(callbacks) == {False}
assert peer.sent and all(frame.src == 0 for frame in peer.sent)
assert any(frame.dat[0] >> 4 == 3 for frame in peer.sent) # Actual ISO-TP flow control.
assert result[0] == SIERRA['expected'] == CAR.CHEVROLET_SILVERADO
assert result[3] and all(fw.brand == 'gm' and fw.bus == 0 for fw in result[3])
ci, callbacks = discover(SierraEcu(), instantiate=True)
direct = CarInterface.get_params(CAR.CHEVROLET_SILVERADO, sierra_fingerprint(), [], False, False, False)
assert set(callbacks) == {False}
assert ci.CP.safetyConfigs[0].safetyParam == direct.safetyConfigs[0].safetyParam == 0xC171
assert ci.CP.flags == direct.flags == 16
assert ci.CP.carFingerprint == direct.carFingerprint
assert ci.CP.pcmCruise and not ci.CP.openpilotLongitudinalControl
assert ci.CP.lateralTuning.to_dict() == direct.lateralTuning.to_dict()
def test_previous_pedal_only_route_reproduces_sierra_mux_and_can1_queries():
# Same inert CAN/ECU peer: the old routing path enables the mux and probes
# CAN1 even though the GM ECU replies and complete CAN layout are on bus0.
peer = SierraEcu()
_, callbacks = discover(peer, legacy=True)
assert True in callbacks
assert any(frame.src == 1 for frame in peer.sent)
def test_sierra_routing_fix_keeps_raw_irq_fault_blocking():
from openpilot.cereal import log
from openpilot.starpilot.aol.panda_faults import blocking_fault_list, blocking_fault_mask
cp = CarInterface.get_params(CAR.CHEVROLET_SILVERADO, sierra_fingerprint(), [], False, False, False)
cp.alternativeExperience = 32
assert blocking_fault_list([log.PandaState.FaultType.interruptRateCan2], cp)
assert blocking_fault_mask(8, cp)
def test_latest_bypassed_volt_uses_gm_queries_and_retains_automatic_cc_identity():
data = json.loads((Path(__file__).parent / 'testdata/volt_bypassed_discovery.json').read_text())
fp = gen_empty_fingerprint()
fp.update({int(b): {int(a): n for a, n in messages.items()} for b, messages in data['fingerprint'].items()})
assert not pedal_normal_bus0_topology(fp)
assert normal_bus0_topology(fp)
class VoltEcu(RecordedIsoTpEcu):
def recv(self, wait_for_one=False):
self.clock += .01
values, self.frames = self.frames, []
return [values + frames(fp)]
peer = VoltEcu()
result, callbacks = discover(peer)
assert result[0] == data['expected'] == CAR.CHEVROLET_VOLT_CC
assert callbacks and set(callbacks) == {False}
assert peer.sent and all(frame.src == 0 for frame in peer.sent)
def test_early_ford_rejection_still_collects_later_gm_frames_in_same_budget():
# Actual 2017 Bolt DLCs: 0x204 is four bytes, 0x4f1 is eight. The first
# rejects Ford and the second rejects HKG before GM core messages arrive.
layout = FINGERPRINTS[CAR.CHEVROLET_BOLT_CC_2017][0]
assert layout[0x204] == 4 and layout[0x4F1] == 8
early = [CanData(a, bytes(layout[a]), 0) for a in (0x204, 0x4F1)]
remaining = frames({0: layout})
calls = []
clock = [0.]
def recv(wait_for_one: bool = False) -> list[list[CanData]]:
clock[0] += .01
calls.append(wait_for_one)
return [early if len(calls) == 1 else remaining]
assert normal_bus_query_brand(recv, lambda read: hyundai_probe(read, clock=lambda: clock[0]),
gm_probe=normal_bus0_topology, clock=lambda: clock[0]) == 'gm'
assert 1 < len(calls) <= 13
def test_early_ford_rejection_does_not_add_reads_without_gm_probe():
calls = []
def recv(wait_for_one: bool = False) -> list[list[CanData]]:
calls.append(wait_for_one)
return [[CanData(0x204, bytes(4), 0), CanData(0x4F1, bytes(8), 0)]]
assert normal_bus_query_brand(recv, hyundai_probe, clock=lambda: 0.) is None
assert len(calls) == 1
@@ -1,168 +0,0 @@
import unittest
from opendbc.car import structs
from opendbc.car.gm.values import CAR, GMFlags
from opendbc.car.gm.steering_authority import SteeringAuthority, monitored_profile
def params():
# Stock-longitudinal ASCM profile from route5a; no private vehicle data.
cp = structs.CarParams.new_message(
brand='gm',
carFingerprint=str(CAR.CHEVROLET_VOLT_ASCM),
flags=16,
networkLocation='fwdCamera',
pcmCruise=True,
openpilotLongitudinalControl=False,
alphaLongitudinalAvailable=False,
radarUnavailable=True,
minSteerSpeed=3.1292800903320312,
alternativeExperience=32,
)
cp.safetyConfigs = [{'safetyModel': 'gm', 'safetyParam': 0x605}]
return cp
class TestStockAscmSteeringAuthority(unittest.TestCase):
def setUp(self):
self.cp = params()
self.monitor = SteeringAuthority(self.cp)
def step(self, tick, status=1, active=True, pause=None, missing=False, malformed=False):
ns = 1_000_000_000 + tick * 10_000_000
cs = structs.CarState.new_message(
canValid=True,
canTimeout=False,
vEgo=17.0,
standstill=False,
gearShifter='low',
steeringPressed=pause == 'driver',
brakePressed=pause == 'brake',
regenBraking=pause == 'paddle',
)
cs.cruiseState.available = pause != 'main'
if pause == 'stopped':
cs.standstill = True
cs.vEgo = 0.0
if pause == 'slow':
cs.vEgo = 2.0
if pause == 'cancel':
cs.buttonEvents = [structs.CarState.ButtonEvent(pressed=True, type='cancel')]
cs.steerFaultTemporary = status == 2
cs.steerFaultPermanent = status == 3
packets = [(0xC9, b'\x00' * 8, 0), (0xBE, b'\x00' * 6, 0), (0xBD, b'\x00' * 7, 0), (0x1E1, b'\x00' * 7, 0)]
if tick % 10 == 0 and not missing:
packets.append((0x184, b'\x00' if malformed else bytes([status << 3]) + b'\x00' * 7, 0))
self.monitor.observe([(ns, packets)])
self.monitor.update(cs, ns)
self.monitor.command(ns, active)
return cs
def prime(self):
for tick in range(40):
self.step(tick)
self.assertTrue(self.monitor.seen_active)
def test_exact_recorded_profile_enabled(self):
self.assertTrue(self.monitor.enabled)
def test_both_brake_sources_radar_and_bsm_variants(self):
for word in (0x205, 0x605, 0xA05, 0xE05):
for bsm in (0, 16):
with self.subTest(word=word, bsm=bsm):
cp = params()
cp.safetyConfigs[0].safetyParam = word
cp.radarUnavailable = not bool(word & 0x800)
cp.flags = bsm
self.assertTrue(monitored_profile(cp))
def test_neighbor_profiles_and_wrong_owners_refused(self):
for field, value in [
('brand', 'hyundai'),
('carFingerprint', str(CAR.CHEVROLET_BOLT_EUV)),
('networkLocation', 'gateway'),
('passive', True),
('dashcamOnly', True),
('notCar', True),
('pcmCruise', False),
('openpilotLongitudinalControl', True),
('flags', int(GMFlags.PEDAL_LONG)),
('flags', int(GMFlags.NO_CAMERA)),
('radarUnavailable', False),
]:
with self.subTest(field=field):
cp = params()
setattr(cp, field, value)
self.assertFalse(monitored_profile(cp))
for word in (5, 0x1005, 0xC163, 0xC151, 0x4087, 0x605 ^ 0x10, 0x605 ^ 0x100, 0x605 ^ 0x8000):
with self.subTest(word=word):
cp = params()
cp.safetyConfigs[0].safetyParam = word
self.assertFalse(monitored_profile(cp))
def test_unresponsive_eps_latches_and_healthy_feedback_cannot_auto_clear(self):
self.prime()
for tick in range(40, 81):
cs = self.step(tick, status=0)
self.assertTrue(self.monitor.latched)
self.assertTrue(cs.steerFaultTemporary)
for tick in range(81, 120):
cs = self.step(tick, status=1)
self.assertTrue(self.monitor.latched)
self.assertTrue(cs.steerFaultTemporary)
def test_driver_pauses_and_positive_recovery(self):
for pause in ('brake', 'paddle', 'cancel', 'main', 'driver', 'stopped', 'slow'):
with self.subTest(pause=pause):
self.monitor = SteeringAuthority(self.cp)
self.prime()
for tick in range(40, 80):
self.step(tick, status=0, active=False, pause=pause)
self.assertFalse(self.monitor.latched)
for tick in range(80, 120):
self.step(tick, status=1)
self.assertFalse(self.monitor.latched)
self.assertTrue(self.monitor.seen_active)
def test_stale_eps_feed_remains_a_fault(self):
self.prime()
for tick in range(40, 90):
cs = self.step(tick, missing=True, active=tick < 70)
self.assertTrue(self.monitor.latched)
self.assertTrue(cs.steerFaultTemporary)
def test_malformed_eps_remains_a_fault(self):
self.prime()
for tick in range(40, 80):
cs = self.step(tick, malformed=True)
self.assertTrue(self.monitor.latched)
self.assertTrue(cs.steerFaultTemporary)
def test_factory_fault_flags_preserved(self):
for status in (2, 3):
with self.subTest(status=status):
self.monitor = SteeringAuthority(self.cp)
self.prime()
for tick in range(40, 80):
cs = self.step(tick, status=status, active=False)
self.assertEqual(cs.steerFaultTemporary, status == 2)
self.assertEqual(cs.steerFaultPermanent, status == 3)
def test_main_cycle_uses_neutral_and_fresh_rearm(self):
self.prime()
for tick in range(40, 81):
self.step(tick, status=0)
self.assertTrue(self.monitor.latched)
for tick in range(81, 86):
self.step(tick, status=0, active=False, pause='main')
self.assertTrue(self.monitor.latched)
self.step(86, status=0, active=False)
self.assertFalse(self.monitor.latched)
for tick in range(87, 120):
self.step(tick, status=1)
self.assertFalse(self.monitor.latched)
self.assertTrue(self.monitor.seen_active)
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -31,11 +31,6 @@ def feed_camera(ci, packer, now, *, counter=0, speed=20., gas=False, brake=False
messages += [packer.make_can_msg("AcceleratorPedal2", 0, {"CruiseState": 2 if active else 0, "AcceleratorPedal2": 30 if gas else 0}),
packer.make_can_msg("ECMPRDNL2", 0, {"PRNDL2": 6 if low else 4}),
packer.make_can_msg("ASCMActiveCruiseControlStatus", 2, {"ACCCruiseState": 3, "ACCSpeedSetpoint": 60})]
if getattr(ci, 'CP', None) is not None and int(ci.CP.safetyConfigs[0].safetyParam) == 0x1CD:
cycle = now // 40_000_000
if cycle != getattr(ci, '_fixture_brake_cycle', None):
messages.append(packer.make_can_msg("EBCMFrictionBrakeCmd", 2, {"RollingCounter": cycle % 4}))
ci._fixture_brake_cycle = cycle
ci.update([(now - 1_000_000, messages)])
out = ci.update([(now, messages)])
return out, messages
File diff suppressed because it is too large Load Diff
@@ -1,212 +0,0 @@
{
"route": "38310abfb83b2669/00000003--d0f66b55b0",
"recordedCommit": "39392c37a688f9f8f0a57b0d352d6fad2ca76a95",
"source": "First24 original CAN batches, address/DLC/bus only; payloads intentionally omitted.",
"packets": [
[[532,6,2],[241,6,2],[532,6,0],[241,6,0],[249,8,2],[249,8,0],[460,5,2],[460,5,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[485,8,2],[485,8,0],[190,6,2],[190,6,0],[289,8,2],[289,8,0],[201,8,2],[201,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[211,2,2],[211,2,0],[560,8,2],[560,8,0],[810,8,2],[810,8,0],[209,7,2],[209,7,0],[489,8,2],[489,8,0],[562,8,2],[562,8,0],[241,6,2]],
[[241,6,0],[384,4,2],[384,4,0],[800,6,2],[800,6,0],[1033,7,2],[1033,7,0],[1034,7,2],[1034,7,0],[1296,4,2],[1296,4,0],[322,7,2],[322,7,0],[249,8,2],[249,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[761,7,2],[761,7,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[209,7,2],[209,7,0],[451,8,2],[451,8,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0],[241,6,2]],
[[455,7,2],[241,6,0],[455,7,0],[715,8,2],[715,8,0],[500,6,2],[500,6,0],[479,3,2],[479,3,0],[789,5,2],[789,5,0],[460,5,2],[460,5,0],[463,3,2],[463,3,0],[869,4,2],[869,4,0],[880,6,2],[880,6,0],[840,5,2],[840,5,0],[481,7,2],[481,7,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[842,5,2],[842,5,0],[249,8,2],[249,8,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[190,6,2],[190,6,0],[209,7,2],[209,7,0]],
[[201,8,2],[211,2,2],[201,8,0],[211,2,0],[241,6,2],[241,6,0],[717,5,2],[717,5,0],[489,8,2],[489,8,0],[844,8,2],[844,8,0],[848,4,2],[848,4,0],[497,8,2],[497,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[249,8,2],[249,8,0],[209,7,2],[209,7,0],[455,7,2],[455,7,0]],
[[241,6,2],[241,6,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[460,5,2],[460,5,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[208,8,2],[208,8,0],[451,8,2],[451,8,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0],[500,6,2],[500,6,0],[479,3,2],[479,3,0],[528,5,2],[528,5,0],[707,8,2],[707,8,0],[1001,8,2],[1001,8,0],[209,7,2],[209,7,0],[381,8,2],[381,8,0]],
[[489,8,2],[489,8,0],[241,6,2],[241,6,0],[532,6,2],[532,6,0],[249,8,2],[249,8,0],[352,5,2],[352,5,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[485,8,2],[485,8,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[211,2,2],[211,2,0],[386,8,2],[386,8,0],[481,7,2],[481,7,0],[560,8,2],[560,8,0],[209,7,2],[209,7,0],[455,7,2],[455,7,0],[562,8,2],[562,8,0]],
[[241,6,2],[241,6,0],[789,5,2],[789,5,0],[715,8,2],[715,8,0],[869,4,2],[869,4,0],[880,6,2],[880,6,0],[460,5,2],[460,5,0],[298,8,2],[298,8,0],[249,8,2],[249,8,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[761,7,2],[761,7,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[451,8,2],[451,8,0],[209,7,2],[209,7,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0]],
[[241,6,2],[241,6,0],[717,5,2],[717,5,0],[489,8,2],[489,8,0],[500,6,2],[500,6,0],[479,3,2],[479,3,0],[840,5,2],[840,5,0],[977,8,2],[977,8,0],[1011,6,2],[1011,6,0],[1020,8,2],[1020,8,0],[309,8,2],[309,8,0],[388,8,2],[388,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[842,5,2],[842,5,0],[249,8,2],[249,8,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[190,6,2],[190,6,0],[209,7,2],[209,7,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[241,6,2],[241,6,0]],
[[455,7,2],[455,7,0],[844,8,2],[844,8,0],[848,4,2],[848,4,0],[460,5,2],[460,5,0],[311,8,2],[311,8,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[481,7,2],[481,7,0],[249,8,2],[249,8,0],[209,7,2],[209,7,0],[608,8,2],[608,8,0]],
[[609,6,2],[609,6,0],[489,8,2],[489,8,0],[241,6,2],[241,6,0],[610,6,2],[610,6,0],[611,6,2],[611,6,0],[612,8,2],[612,8,0],[613,8,2],[613,8,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[313,8,2],[313,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[451,8,2],[451,8,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0],[479,3,2],[479,3,0],[500,6,2],[500,6,0],[528,5,2],[528,5,0],[707,8,2],[707,8,0],[1233,8,2],[1233,8,0],[1263,4,2],[1263,4,0],[209,7,2],[209,7,0],[455,7,2],[455,7,0]],
[[532,6,2],[532,6,0],[241,6,2],[241,6,0],[789,5,2],[789,5,0],[715,8,2],[715,8,0],[869,4,2],[869,4,0],[249,8,2],[249,8,0],[880,6,2],[880,6,0],[460,5,2],[460,5,0],[810,8,2],[810,8,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[485,8,2],[485,8,0],[190,6,2],[190,6,0],[289,8,2],[289,8,0],[201,8,2],[201,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[211,2,2],[211,2,0],[560,8,2],[560,8,0],[209,7,2],[209,7,0],[489,8,2],[489,8,0],[562,8,2],[562,8,0],[241,6,2],[241,6,0]],
[[384,4,2],[384,4,0],[717,5,2],[717,5,0],[800,6,2],[800,6,0],[1033,7,2],[1033,7,0],[1034,7,2],[1034,7,0],[1296,4,2],[1296,4,0],[322,7,2],[322,7,0],[249,8,2],[249,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[761,7,2],[761,7,0],[481,7,2],[481,7,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[451,8,2],[451,8,0],[209,7,2],[209,7,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0],[241,6,2],[241,6,0]],
[[455,7,2],[455,7,0],[500,6,2],[500,6,0],[479,3,2],[479,3,0],[840,5,2],[840,5,0],[1017,8,2],[1017,8,0],[460,5,2],[460,5,0],[463,3,2],[463,3,0],[1259,8,2],[1259,8,0],[1265,8,2],[1265,8,0],[1267,1,2],[1267,1,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[842,5,2],[842,5,0],[249,8,2],[249,8,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[190,6,2],[190,6,0],[209,7,2],[209,7,0]],
[[201,8,2],[211,2,2],[201,8,0],[211,2,0],[241,6,2],[241,6,0],[489,8,2],[489,8,0],[844,8,2],[844,8,0],[848,4,2],[848,4,0],[497,8,2],[497,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[249,8,2],[249,8,0],[209,7,2],[209,7,0],[455,7,2],[455,7,0]],
[[241,6,2],[241,6,0],[789,5,2],[789,5,0],[715,8,2],[715,8,0],[869,4,2],[869,4,0],[880,6,2],[880,6,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[460,5,2],[460,5,0],[481,7,2],[481,7,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[208,8,2],[208,8,0],[451,8,2],[451,8,0],[500,6,2],[500,6,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0],[479,3,2],[479,3,0],[528,5,2],[528,5,0],[707,8,2],[707,8,0],[1001,8,2],[1001,8,0],[209,7,2],[209,7,0],[381,8,2],[381,8,0]],
[[489,8,2],[489,8,0],[241,6,2],[241,6,0],[532,6,2],[532,6,0],[717,5,2],[717,5,0],[249,8,2],[249,8,0],[352,5,2],[352,5,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[485,8,2],[485,8,0],[190,6,2],[190,6,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[386,8,2],[386,8,0],[560,8,2],[560,8,0],[209,7,2],[209,7,0],[455,7,2]],
[[455,7,0],[562,8,2],[562,8,0],[241,6,2],[241,6,0],[460,5,2],[460,5,0],[298,8,2],[298,8,0],[249,8,2],[249,8,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[761,7,2],[761,7,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[209,7,2],[209,7,0],[451,8,2],[451,8,0]],
[[452,8,2],[452,8,0],[453,6,2],[453,6,0],[241,6,2],[241,6,0],[489,8,2],[489,8,0],[500,6,2],[500,6,0],[479,3,2],[479,3,0],[840,5,2],[840,5,0],[977,8,2],[977,8,0],[309,8,2],[309,8,0],[388,8,2],[388,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[842,5,2],[842,5,0],[481,7,2],[481,7,0],[249,8,2],[249,8,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[190,6,2],[190,6,0],[209,7,2],[209,7,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0]],
[[241,6,2],[241,6,0],[789,5,2],[789,5,0],[455,7,2],[455,7,0],[715,8,2],[715,8,0],[844,8,2],[844,8,0],[848,4,2],[848,4,0],[869,4,2],[869,4,0],[460,5,2],[460,5,0],[880,6,2],[880,6,0],[311,8,2],[311,8,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[249,8,2],[249,8,0],[209,7,2],[209,7,0],[608,8,2],[608,8,0],[609,6,2],[609,6,0],[610,6,2],[610,6,0],[489,8,2]],
[[241,6,2],[489,8,0],[241,6,0],[611,6,2],[611,6,0],[717,5,2],[717,5,0],[612,8,2],[612,8,0],[613,8,2],[613,8,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[313,8,2],[313,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[451,8,2],[451,8,0],[452,8,2],[452,8,0],[453,6,2],[453,6,0],[479,3,2],[479,3,0],[500,6,2],[500,6,0],[528,5,2],[528,5,0],[707,8,2],[707,8,0],[1271,8,2],[1271,8,0],[209,7,2],[209,7,0],[455,7,2],[455,7,0],[532,6,2],[532,6,0],[241,6,2],[241,6,0]],
[[249,8,2],[249,8,0],[460,5,2],[460,5,0],[413,8,2],[413,8,0],[481,7,2],[481,7,0],[501,8,2],[501,8,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[190,6,2],[190,6,0],[197,8,2],[197,8,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[560,8,2],[560,8,0],[810,8,2],[810,8,0],[209,7,2],[209,7,0]],
[[489,8,2],[489,8,0],[562,8,2],[562,8,0],[241,6,2],[241,6,0],[384,4,2],[384,4,0],[800,6,2],[800,6,0],[1033,7,2],[1033,7,0],[1034,7,2],[1034,7,0],[1296,4,2],[1296,4,0],[322,7,2],[322,7,0],[249,8,2],[249,8,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[761,7,2],[761,7,0],[190,6,2],[190,6,0],[201,8,2],[201,8,0],[211,2,2],[211,2,0],[209,7,2],[209,7,0],[451,8,2],[451,8,0]],
[[452,8,2],[452,8,0],[453,6,2],[453,6,0],[241,6,2],[241,6,0],[455,7,2],[455,7,0],[715,8,2],[715,8,0],[500,6,2],[500,6,0],[479,3,2],[479,3,0],[789,5,2],[789,5,0],[869,4,2],[869,4,0],[880,6,2],[880,6,0],[460,5,2],[460,5,0],[463,3,2],[463,3,0],[840,5,2],[840,5,0],[497,8,2],[497,8,0],[485,8,2],[485,8,0],[289,8,2],[289,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[842,5,2],[842,5,0],[249,8,2],[249,8,0],[413,8,2],[413,8,0],[501,8,2],[501,8,0],[190,6,2],[190,6,0],[209,7,2],[209,7,0],[201,8,2],[201,8,0]],
[[211,2,2],[211,2,0],[241,6,2],[241,6,0],[717,5,2],[717,5,0],[489,8,2],[489,8,0],[844,8,2],[844,8,0],[848,4,2],[848,4,0],[481,7,2],[481,7,0],[485,8,2],[485,8,0],[193,8,2],[193,8,0],[197,8,2],[197,8,0],[249,8,2],[249,8,0],[209,7,2],[209,7,0],[455,7,2],[455,7,0]]
],
"fingerprint": {
"0": {
"452": 8,
"453": 6,
"241": 6,
"455": 7,
"789": 5,
"715": 8,
"500": 6,
"479": 3,
"869": 4,
"880": 6,
"840": 5,
"1017": 8,
"460": 5,
"463": 3,
"1259": 8,
"485": 8,
"289": 8,
"193": 8,
"197": 8,
"842": 5,
"249": 8,
"413": 8,
"501": 8,
"190": 6,
"209": 7,
"201": 8,
"211": 2,
"717": 5,
"489": 8,
"844": 8,
"848": 4,
"481": 7,
"497": 8,
"451": 8,
"208": 8,
"528": 5,
"707": 8,
"381": 8,
"532": 6,
"352": 5,
"386": 8,
"560": 8,
"562": 8,
"298": 8,
"761": 7,
"309": 8,
"977": 8,
"1001": 8,
"388": 8,
"311": 8,
"608": 8,
"609": 6,
"610": 6,
"611": 6,
"612": 8,
"613": 8,
"313": 8,
"810": 8,
"384": 4,
"800": 6,
"1033": 7,
"1034": 7,
"1296": 4,
"322": 7,
"1011": 6,
"1020": 8,
"1217": 8,
"1261": 7,
"1221": 5,
"1233": 8,
"1263": 4,
"1265": 8,
"1267": 1,
"1271": 8,
"565": 5,
"288": 5,
"304": 3,
"320": 4,
"1249": 8,
"1300": 8,
"328": 1,
"1280": 4,
"563": 5,
"1930": 7
},
"1": {},
"2": {
"452": 8,
"453": 6,
"241": 6,
"455": 7,
"789": 5,
"715": 8,
"500": 6,
"479": 3,
"869": 4,
"880": 6,
"840": 5,
"1017": 8,
"460": 5,
"463": 3,
"1259": 8,
"485": 8,
"289": 8,
"193": 8,
"197": 8,
"842": 5,
"249": 8,
"413": 8,
"501": 8,
"190": 6,
"209": 7,
"201": 8,
"211": 2,
"717": 5,
"489": 8,
"844": 8,
"848": 4,
"481": 7,
"497": 8,
"451": 8,
"208": 8,
"528": 5,
"707": 8,
"381": 8,
"532": 6,
"352": 5,
"386": 8,
"560": 8,
"562": 8,
"298": 8,
"761": 7,
"309": 8,
"977": 8,
"1001": 8,
"388": 8,
"311": 8,
"608": 8,
"609": 6,
"610": 6,
"611": 6,
"612": 8,
"613": 8,
"313": 8,
"810": 8,
"384": 4,
"800": 6,
"1033": 7,
"1034": 7,
"1296": 4,
"322": 7,
"1011": 6,
"1020": 8,
"1217": 8,
"1261": 7,
"1221": 5,
"1233": 8,
"1263": 4,
"1265": 8,
"1267": 1,
"1271": 8,
"565": 5,
"288": 5,
"304": 3,
"320": 4,
"1249": 8,
"1300": 8,
"328": 1,
"1280": 4,
"563": 5,
"1930": 7
},
"3": {},
"4": {},
"5": {},
"6": {},
"7": {}
},
"expected": "CHEVROLET_SILVERADO"
}
@@ -1,141 +0,0 @@
{
"route": "fe40163e0981f4d1/00000001--faea507251",
"recordedCommit": "0fcc393380b6bd22bfcd53c6fc92bc4dddca723f",
"expected": "CHEVROLET_VOLT_CC",
"fingerprint": {
"0": {
"485": 8,
"193": 8,
"197": 8,
"209": 7,
"489": 8,
"844": 8,
"241": 6,
"211": 2,
"481": 7,
"189": 7,
"479": 3,
"501": 8,
"201": 8,
"493": 8,
"451": 8,
"388": 8,
"452": 8,
"453": 6,
"500": 6,
"707": 8,
"977": 8,
"562": 8,
"1001": 8,
"386": 8,
"840": 5,
"842": 5,
"560": 8,
"497": 8,
"532": 6,
"566": 5,
"761": 7,
"528": 4,
"352": 5,
"298": 8,
"309": 8,
"311": 8,
"313": 8,
"381": 6,
"608": 8,
"609": 6,
"610": 6,
"611": 6,
"612": 8,
"613": 8,
"810": 8,
"1017": 8,
"1020": 8,
"1217": 8,
"304": 1,
"1233": 8,
"1265": 8,
"1267": 1,
"320": 3,
"1221": 5,
"1249": 8,
"1300": 8,
"328": 1,
"563": 5,
"308": 4,
"1922": 7,
"565": 5,
"1280": 4
},
"1": {},
"2": {
"485": 8,
"193": 8,
"197": 8,
"209": 7,
"489": 8,
"844": 8,
"241": 6,
"211": 2,
"481": 7,
"189": 7,
"479": 3,
"501": 8,
"201": 8,
"493": 8,
"451": 8,
"388": 8,
"452": 8,
"453": 6,
"500": 6,
"707": 8,
"977": 8,
"562": 8,
"1001": 8,
"386": 8,
"840": 5,
"842": 5,
"560": 8,
"497": 8,
"532": 6,
"566": 5,
"761": 7,
"528": 4,
"352": 5,
"298": 8,
"309": 8,
"311": 8,
"313": 8,
"381": 6,
"608": 8,
"609": 6,
"610": 6,
"611": 6,
"612": 8,
"613": 8,
"810": 8,
"1017": 8,
"1020": 8,
"1217": 8,
"304": 1,
"1233": 8,
"1265": 8,
"1267": 1,
"320": 3,
"1221": 5,
"1249": 8,
"1300": 8,
"328": 1,
"563": 5,
"308": 4,
"1922": 7,
"565": 5,
"1280": 4
},
"3": {},
"4": {},
"5": {},
"6": {},
"7": {}
}
}
-15
View File
@@ -359,21 +359,6 @@ def is_volt_ascm_longitudinal(cp: CarParams) -> bool:
return False
def is_volt_ascm_stock(cp: CarParams) -> bool:
"""Exact non-pedal ASCM Volt steering owner with factory longitudinal control."""
try:
configs = cp.safetyConfigs
return (cp.brand == 'gm' and cp.carFingerprint == CAR.CHEVROLET_VOLT_ASCM and
cp.networkLocation == CarParams.NetworkLocation.fwdCamera and cp.pcmCruise and
not cp.openpilotLongitudinalControl and not cp.passive and not cp.dashcamOnly and
not cp.notCar and control_flags(cp) == 0 and len(configs) == 1 and
configs[0].safetyModel == CarParams.SafetyModel.gm and
int(configs[0].safetyParam) in (0x205, 0x605, 0xA05, 0xE05) and
bool(int(configs[0].safetyParam) & int(GMSafetyFlags.ASCM_RADAR)) is not cp.radarUnavailable)
except (AttributeError, IndexError, TypeError, ValueError):
return False
def is_volt_camera_removed(cp: CarParams, *, longitudinal=None) -> bool:
"""The exact manual Volt with PT state sources and no factory camera."""
try:
@@ -81,13 +81,7 @@ def exercise_final_cp_publication_and_disabled_startup(identity, monkeypatch, fi
discovery.setenv("DISABLE_FW_CACHE", "1")
discovery.setattr(car_helpers, "get_vin", lambda *args: (-1, -1, initial.carVin))
discovery.setattr(car_helpers, "get_present_ecus", lambda *args: set())
def can_fingerprint(can_recv, *, vin, camera_cruise_messages):
assert callable(can_recv) and vin == initial.carVin
assert camera_cruise_messages == set()
return None, fingerprint
discovery.setattr(car_helpers, "can_fingerprint", can_fingerprint)
discovery.setattr(car_helpers, "can_fingerprint", lambda *args: (None, fingerprint))
discovery.setattr(car_helpers, "get_fw_versions_ordered", lambda *args: list(firmware))
discovery.setattr(card_module, "obd_callback", lambda *args: lambda enabled: None)
else:
@@ -333,11 +333,8 @@ def test_actual_card_active_observation_requires_fresh_selfdrive_state(monkeypat
host.car_gps_publisher = SimpleNamespace(update=lambda *args: None)
host.observe_ioniq6_long_authority = lambda *args: None
host.update_vehicle_state_context = lambda *args: None
def update_cruise_speed(_state, _pending, *, approved=None):
assert approved is None
host.update_cruise_speed = update_cruise_speed
host.v_cruise_helper = SimpleNamespace(slc_consumed_button=None, toyota=None)
host.update_cruise_speed = lambda *args: None
host.v_cruise_helper = SimpleNamespace(slc_consumed_button=None)
host.startup_panda_configured = lambda: True
host.can_sock = object()
host.pm = None
@@ -27,10 +27,9 @@ def config_index(cp):
return owner
raw = int(cp.safetyConfigs[owner].safetyParam)
if cp.openpilotLongitudinalControl and not cp.pcmCruise:
word = 0x2015 if cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN else 0x15
if raw == word and cp.alternativeExperience == 0:
if raw == 0x15 and cp.alternativeExperience == 0:
return 1
if cp.carFingerprint in (CAR.KIA_EV6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN) and raw == (word | 0x800) and cp.alternativeExperience == 32:
if cp.carFingerprint in (CAR.KIA_EV6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN) and raw == 0x815 and cp.alternativeExperience == 32:
return 1
elif not cp.openpilotLongitudinalControl and cp.pcmCruise and cp.alternativeExperience == 0 and raw in (0x11, 0x811):
return 1
@@ -95,7 +95,7 @@ def qualified(cp, *, marked_only=False):
return cp.safetyConfigs[config_index(cp)].safetyParam in ((word | STOCK_AOL_MARKER,) if marked_only else (word, word | STOCK_AOL_MARKER))
LONG_AOL_WORDS = frozenset((0x0815, 0x0895, 0x2815, 0x2895))
LONG_AOL_WORDS = frozenset((0x0815, 0x0895))
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}
@@ -114,10 +114,6 @@ def qualified_long(cp, *, marked_only=False):
if word not in (0x11, 0x91):
return False
raw = int(cp.safetyConfigs[config_index(cp)].safetyParam)
if cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN:
if not raw & 0x2000:
return False
raw &= ~0x2000
return (raw == (word | 0x804) and cp.alternativeExperience == 32) or (
not marked_only and raw == (word | 4) and cp.alternativeExperience == 0
)
@@ -26,7 +26,6 @@ from opendbc.car.hyundai.ray_pedal import create_ray_pedal_command, ray_pedal_ga
from opendbc.car.hyundai.ioniq6_longitudinal import Ioniq6LongitudinalPolicy
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 independent_buttons_qualified as qualified_classic_long_buttons
from opendbc.car.hyundai.canfd_stock_aol import qualified as qualified_canfd_stock_aol
from opendbc.car.hyundai.ev6_aol import qualified as qualified_ev6_aol
from opendbc.car.hyundai.g90_longitudinal import G90LongitudinalPolicy
@@ -75,11 +74,6 @@ def angle_torque_reduction_gain(steering_torque, v_ego, lat_active, last_gain):
return round(rate_limit(target, last_gain, -0.014, 0.004) / 0.004) * 0.004
def classic_aol_dash_enabled(cp):
return bool(cp.carFingerprint != CAR.KIA_RAY_EV and (qualified_classic_stock_aol(cp, marked_only=True) or
cp.carFingerprint == CAR.HYUNDAI_ELANTRA_2021 and qualified_classic_long_buttons(cp, marked_only=True)))
def process_hud_alert(enabled, fingerprint, hud_control):
sys_warning = (hud_control.visualAlert in (VisualAlert.steerRequired, VisualAlert.ldw))
@@ -159,7 +153,7 @@ class CarController(CarControllerBase):
self.ioniq6_bsm_enabled = ioniq6_status
self.ev6_aol_status = CP.carFingerprint == CAR.KIA_EV6 and (qualified_canfd_stock_aol(CP) or qualified_ev6_aol(CP))
self.canfd_dash_icons = CanFDDashIcons() if ioniq6_status or self.torque_ev_scc_enabled or self.ev6_aol_status else None
self.classic_dash_icons = CanFDDashIcons() if classic_aol_dash_enabled(CP) else None
self.classic_dash_icons = CanFDDashIcons() if qualified_classic_stock_aol(CP, marked_only=True) and CP.carFingerprint != CAR.KIA_RAY_EV else None
self.classic_lfa_icon = None
self.ioniq6_bsm_counter = 0
self.ioniq6_bsm_last_can_ns = 0
@@ -187,7 +181,7 @@ class CarController(CarControllerBase):
return actuators.as_builder(), []
hud_control = CC.hudControl
# Card finalizes the AOL safety marker after CarInterface constructs us.
if self.classic_dash_icons is None and classic_aol_dash_enabled(self.CP):
if self.classic_dash_icons is None and self.CP.carFingerprint != CAR.KIA_RAY_EV and qualified_classic_stock_aol(self.CP, marked_only=True):
self.classic_dash_icons = CanFDDashIcons()
if self.classic_dash_icons is not None:
self.classic_lfa_icon = self.classic_dash_icons.update(self.frame, CC.enabled, CC.latActive)
@@ -11,14 +11,6 @@ _REQUIRED = (HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CCNC | HyundaiF
_IGNORED_EV_GEAR_FLAGS = HyundaiFlags.CANFD_ALT_GEARS | HyundaiFlags.CANFD_ALT_GEARS_2
def metadata_qualified(cp):
"""Common transport fields; each caller still checks its own LONG/stock mode."""
return (cp.carFingerprint in STOCK_SAFETY_PARAMS and cp.brand == "hyundai" and
not (cp.notCar or cp.passive or cp.dashcamOnly) and
int(cp.flags) & ~int(_IGNORED_EV_GEAR_FLAGS) == int(_REQUIRED) and
len(cp.safetyConfigs) == 1 and cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.hyundaiCanfd)
def qualified(cp):
return (cp.carFingerprint in STOCK_SAFETY_PARAMS and cp.brand == "hyundai" and
not (cp.notCar or cp.passive or cp.dashcamOnly or cp.openpilotLongitudinalControl) and cp.pcmCruise and
@@ -6,7 +6,7 @@ from copy import deepcopy
import time
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags, HyundaiSafetyFlags
from opendbc.car.hyundai.values import CAR, HyundaiSafetyFlags
from opendbc.car.hyundai.canfd_owner import config_index
from opendbc.car.isotp_parallel_query import IsoTpParallelQuery
@@ -34,13 +34,6 @@ def stock_copy(cp):
stock = reader.as_builder()
stock.openpilotLongitudinalControl = False
stock.pcmCruise = True
if cp.openpilotLongitudinalControl and stock.carFingerprint in (
CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025,
CAR.KIA_CARNIVAL_HEV_4TH_GEN, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN):
for config in stock.safetyConfigs:
config.safetyParam &= ~0x2000
if stock.carFingerprint in (CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN) and stock.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
config.safetyParam |= 0x2000
for config in stock.safetyConfigs:
if stock.carFingerprint == CAR.GENESIS_GV60_EV_1ST_GEN and config.safetyParam in (0x9015, 0x9095):
config.safetyParam &= ~0x9000
@@ -195,8 +188,7 @@ class HyundaiECUStartup:
expected = deepcopy(cast(PreparedParams, self.prepared_cp))
configs = expected.get('safetyConfigs', [])
owner = config_index(ci.CP)
prepared_word = 0x2015 if gv70_eligible(ci.CP) else 0x15
if owner is None or len(configs) != owner + 1 or configs[owner]['safetyParam'] != prepared_word or expected['alternativeExperience'] != 0:
if owner is None or len(configs) != owner + 1 or configs[owner]['safetyParam'] != 0x15 or expected['alternativeExperience'] != 0:
raise RuntimeError('Captured ADRV prepared LONG profile cannot be finalized')
configs[owner]['safetyParam'] |= 0x0800
expected['alternativeExperience'] = 32
@@ -1,10 +1,4 @@
"""Exact first-generation EV6 startup compatibility wrapper."""
from copy import deepcopy
import time
from opendbc.car import make_tester_present_msg
from opendbc.car.hyundai.ecu_startup import HyundaiECUStartup, Outcome
from opendbc.car.hyundai.canfd_owner import config_index
from opendbc.car.hyundai.captured_adrv_startup import CapturedADRVStartup, first_generation
from opendbc.car.hyundai.ev6_template import EV6Template
from opendbc.car.hyundai.values import CAR
@@ -21,87 +15,3 @@ def required(cp):
class EV6Startup(CapturedADRVStartup):
def __init__(self, cp, callbacks):
super().__init__(cp, callbacks, label='EV6', template_type=EV6Template)
def prepare_when_controls_ready(self, *, admission):
"""Keep factory ADRV output until the inactive control producer is ready."""
if self.outcome is not None or self.closed:
raise RuntimeError('EV6 startup preparation is single-use')
# The existing captured sender is qualified on physical A-CAN zero.
if self.template_bus != 0:
return self.prepare(admission=admission)
if admission() is not True:
return self.prepare(admission=lambda: False)
self.template = self._capture()
if self.template is None:
return HyundaiECUStartup.prepare(self, 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 configure(self, ci):
super().configure(ci)
if self.outcome is Outcome.PENDING:
if self.template is None:
raise RuntimeError('EV6 pending startup requires its captured template')
ci.CC.adrv_template = self.template
def finalize_aol_configuration(self, ci):
if self.outcome is not Outcome.PENDING:
return super().finalize_aol_configuration(ci)
if self.published or self.closed or ci is not self.ci or not self.ready:
raise RuntimeError('EV6 pending AOL finalization requires configured startup')
if self.prepared_for(ci.CP):
return
from opendbc.car.hyundai.ev6_aol import qualified
expected = deepcopy(self.prepared_cp)
index = config_index(ci.CP)
if (not qualified(ci.CP, marked_only=True) or expected is None or index is None or
len(expected['safetyConfigs']) != index + 1 or
expected['safetyConfigs'][index]['safetyParam'] != 0x15 or expected['alternativeExperience'] != 0):
raise RuntimeError('EV6 pending startup cannot relabel another safety profile')
expected['safetyConfigs'][index]['safetyParam'] |= 0x0800
expected['alternativeExperience'] = 32
if ci.CP.to_dict() != expected:
raise RuntimeError('EV6 pending AOL configuration changed unrelated CarParams')
self.prepared_cp = expected
def sources_current(self, ci, now_ns):
if self.outcome is Outcome.PENDING:
return self._sources(ci, now_ns, floor=self.floor_ns)
return super().sources_current(ci, now_ns)
def before_control(self, **context):
return self.outcome is not Outcome.PENDING and super().before_control(**context)
def commit_pending(self, ci, *, sources_current, control_current):
if (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('EV6 takeover requires current diagnostic, source and inactive control admission')
template = self._capture()
if template is None or not self.sources_current(ci, time.clock_gettime_ns(time.CLOCK_BOOTTIME)) or self.admission() is not True:
raise RuntimeError('EV6 takeover lost its current factory capture or source admission')
self.template = ci.CC.adrv_template = template
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('EV6 pending ECU disable was not sent successfully')
self.outcome = Outcome.SENT_UNCONFIRMED
self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
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('EV6 pending takeover failed; stock restoration unverified') from None
raise
@@ -5,7 +5,7 @@ from capnp.lib.capnp import _DynamicStructBuilder, _DynamicStructReader
import numpy as np
from opendbc.car import DT_CTRL, 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
class CarControllerParams:
ACCEL_MIN = -3.5
@@ -314,8 +314,6 @@ 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) is _DynamicStructBuilder and cp.schema == structs.CarParams.schema:
return metadata_qualified(cp)
stock = stock_copy(cp)
stock.alternativeExperience = 0
return stock_qualified(stock)
@@ -339,10 +337,8 @@ def lateral_request_allowed(control, state, angle_fault, *, independent=False):
The paired exact EV9 AOL profile may request lateral-only control. Its
native physical latch and current axis/session proof remain final authority.
Ordinary control retains pedal refusal. Native-qualified independent lateral
keeps its request through pedals; both retain standstill, gear, fault and CAN-health refusals.
Both modes retain pedal, standstill, gear, fault and CAN-health refusals.
"""
return ((control.enabled or (independent and control.latActive)) and state.canValid and not state.canTimeout and not state.standstill and
state.gearShifter == structs.CarState.GearShifter.drive and
not (state.steerFaultTemporary or state.steerFaultPermanent or angle_fault) and
((independent and control.latActive) or not (state.brakePressed or state.gasPressed)))
not (state.brakePressed or state.gasPressed or state.steerFaultTemporary or state.steerFaultPermanent or angle_fault))
@@ -71,8 +71,7 @@ def qualified(cp):
bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG) and not int(cp.flags) & ~int(allowed) and
bool(cp.flags & HyundaiFlags.CCNC) == bool(factory.config.flags & HyundaiFlags.CCNC) and
stock_qualified(stock) and stock.safetyConfigs[0].safetyParam in STOCK_WORDS and
cp.safetyConfigs[0].safetyParam == (stock.safetyConfigs[0].safetyParam | 4 |
(0x2000 if cp.carFingerprint in (CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN) else 0)))
cp.safetyConfigs[0].safetyParam == stock.safetyConfigs[0].safetyParam | 4)
class GenericHDA2Startup(RadarHDA2Startup):
@@ -57,12 +57,9 @@ def create_steering_messages(packer, CP, CAN, enabled, lat_active, apply_torque)
lkas_msg = "LKAS_ALT" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "LKAS"
if CP.openpilotLongitudinalControl:
ret.append(packer.make_can_msg("LFA", CAN.ECAN, values))
# Dom uses separate damping values for camera LKAS and ADAS LFA torque commands.
# Preserve the explicit Carnival/GV70 LKAS calibration in either longitudinal mode.
lkas_values = values
if (not CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING and CP.carFingerprint not in (
CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025,
CAR.KIA_CARNIVAL_HEV_4TH_GEN, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN)):
if (CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl and
not CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING):
lkas_values = {**values, "Damping_Gain": 0}
ret.append(packer.make_can_msg(lkas_msg, CAN.ACAN, lkas_values))
else:
+1 -19
View File
@@ -7,8 +7,7 @@ from opendbc.car.hyundai.non_scc_aol import NON_SCC_IDS
from opendbc.car.hyundai.values import (is_blended, is_blended_alpha, HyundaiFlags, CAR, DBC, HyundaiSafetyFlags, CANFD_ANGLE_MODEL_BITS,
CANFD_ANGLE_OBSERVED_ADAS_BIT, HYUNDAI_MRR35_RADAR_DBC, HYUNDAI_MRR30_RADAR_DBC,
HYUNDAI_GV70_RADAR_DBC, HYUNDAI_G90_RADAR_DBC, HYUNDAI_MRREVO14F_RADAR_DBC)
from opendbc.car.hyundai.radar_interface import RADAR_START_ADDR, MRR35_RADAR_START_ADDR, MRR30_RADAR_START_ADDR, radar_bus, \
stinger_2022_radar_available
from opendbc.car.hyundai.radar_interface import RADAR_START_ADDR, MRR35_RADAR_START_ADDR, MRR30_RADAR_START_ADDR, radar_bus
from opendbc.car.interfaces import CarInterfaceBase
from opendbc.car.disable_ecu import disable_ecu
from opendbc.car.hyundai.carcontroller import CarController
@@ -34,16 +33,6 @@ class CarInterface(CarInterfaceBase):
CarController = CarController
RadarInterface = RadarInterface
@staticmethod
def requires_lateral_body_dimensions(cp):
# The shared platform identifier does not establish a body/camera envelope.
return cp.carFingerprint == CAR.HYUNDAI_IONIQ_6
@staticmethod
def get_lateral_body_dimensions(cp):
from opendbc.car.hyundai.lane_body import dimensions
return dimensions(cp)
@staticmethod
def startup_required(cp):
from opendbc.car.hyundai.g90_startup import required
@@ -296,8 +285,6 @@ class CarInterface(CarInterfaceBase):
ret.radarUnavailable = fingerprint[radar_bus(ret)].get(MRR30_RADAR_START_ADDR) != 32
elif DBC[ret.carFingerprint].get(Bus.radar) == HYUNDAI_MRR35_RADAR_DBC:
ret.radarUnavailable = fingerprint[radar_bus(ret)].get(MRR35_RADAR_START_ADDR) != 24
elif candidate == CAR.KIA_STINGER_2022:
ret.radarUnavailable = not stinger_2022_radar_available(fingerprint)
else:
ret.radarUnavailable = radar_start not in fingerprint[1] or Bus.radar not in DBC[ret.carFingerprint]
from opendbc.car.hyundai.torque_ev_startup import topology_owned as torque_ev_topology
@@ -326,11 +313,6 @@ class CarInterface(CarInterfaceBase):
if ret.openpilotLongitudinalControl:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.LONG.value
if (ret.flags & HyundaiFlags.CANFD and ret.flags & HyundaiFlags.CANFD_LKA_STEER_MSG and
not ret.flags & HyundaiFlags.CANFD_ANGLE_STEERING and candidate in (
CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025,
CAR.KIA_CARNIVAL_HEV_4TH_GEN, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN)):
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CANFD_LKAS_DAMPING_100.value
if ret.flags & HyundaiFlags.HYBRID:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.HYBRID_GAS.value
elif ret.flags & HyundaiFlags.EV:
@@ -1,11 +1,9 @@
from capnp.lib.capnp import _DynamicStructBuilder
from copy import deepcopy
from enum import Enum
import threading
import time
from opendbc.car import Bus, make_tester_present_msg
from opendbc.car.can_definitions import CanRecvCallable, CanSendCallable
from opendbc.can.parser import MAX_BAD_COUNTER
from opendbc.car.hyundai.ioniq5pe_long_profile import qualified
from opendbc.car.hyundai.ccnc_ev_stock import qualified as stock_qualified
@@ -41,40 +39,12 @@ class Lease:
return self.phase is Phase.OWNED
def _plain_configuration(value):
pending = [(value, 0)]
while pending:
container, depth = pending.pop()
kind = type(container)
if kind is dict:
for key in container:
if type(key) is not str:
return False
children = container.values()
elif kind is list:
children = container
else:
return False
for child in children:
kind = type(child)
if kind is int or kind is float or kind is bool or kind is str or kind is bytes or child is None:
continue
if (kind is not dict and kind is not list) or depth >= 15:
return False
pending.append((child, depth + 1))
return True
def _configuration_bytes(cp):
return cp.as_reader().as_builder().to_bytes()
class PEPendingStartup:
@staticmethod
def profile_qualified(cp):
return qualified(cp)
def __init__(self, cp, callbacks: tuple[CanRecvCallable, CanSendCallable], *, stock_cp, clock=time.monotonic,
def __init__(self, cp, callbacks, *, stock_cp, clock=time.monotonic,
boot_clock=clock_boot_ns,
exchange=query_ioniq6_adas, event_factory=threading.Event, thread_factory=threading.Thread):
if not self.profile_qualified(cp) or not stock_qualified(stock_cp) or stock_cp.carFingerprint != cp.carFingerprint:
@@ -137,27 +107,9 @@ class PEPendingStartup:
return self.owner.cp
def prepared_for(self, cp):
if (self.closed or self.fault is not None or self.snapshot is None or
self.owner.phase not in (Phase.PENDING, Phase.OWNED, Phase.STOCK)):
return False
previous = getattr(self, '_prepared_cp_comparison', None)
try:
if (previous is not None and previous[0] is self.owner.cp and previous[1] is cp and
_plain_configuration(self.snapshot) and self.snapshot == previous[2] and
_configuration_bytes(self.owner.cp) == previous[3] and _configuration_bytes(cp) == previous[4]):
return True
except Exception:
pass
matches = self.owner.cp.to_dict() == self.snapshot == cp.to_dict()
self._prepared_cp_comparison = None
if (matches and type(self.owner.cp) is _DynamicStructBuilder and type(cp) is _DynamicStructBuilder and
_plain_configuration(self.snapshot)):
try:
self._prepared_cp_comparison = (self.owner.cp, cp, deepcopy(self.snapshot),
_configuration_bytes(self.owner.cp), _configuration_bytes(cp))
except Exception:
pass
return matches
return (not self.closed and self.fault is None and self.snapshot is not None and
self.owner.phase in (Phase.PENDING, Phase.OWNED, Phase.STOCK) and
self.owner.cp.to_dict() == self.snapshot == cp.to_dict())
def configure(self, ci):
if self.owner.published or not self.prepared_for(ci.CP):
@@ -25,8 +25,7 @@ def rear_body(status: BlindspotStatus) -> bytes:
body = bytearray(REAR_NEUTRAL_BODY)
body[3] = int(bool(left or right)) | (left << 6)
body[4] = right
# The legacy FL_INDICATOR alias overlaps the right sound-warning field.
# Mirror lamps have their own indication bits below.
body[5] = left << 1
body[6] = right << 5
body[7] = 0x80 | (max(left, right) << 3)
body[16] = left | (right << 2)
@@ -1,50 +0,0 @@
"""Narrow body-panel bounds for a corroborated US 2023 IONIQ 6.
These bounds exclude mirrors and accessories. They are used only for sampled
road-edge prediction, not vehicle identification or CAN/startup permission.
"""
CAMERA_FW = bytes.fromhex('f100434520204d46432020415420555341204c484420312e303020312e30342039393231312d4b4c30303020323231323133')
RADAR_FW = bytes.fromhex('f10043455f5f20524452202d2d2d2d2d202020202020312e303020312e30312039393131302d4b4c303030202020202020202020')
def _us_my23_vin(vin):
if not isinstance(vin, str) or len(vin) != 17 or not vin.startswith('KMH') or vin[9] != 'P' or not 'A' <= vin[6] <= 'Z':
return False
values = {str(i): i for i in range(10)}
values.update({c: i for i, c in enumerate('ABCDEFGH', 1)})
values.update({c: i for i, c in enumerate('JKLMN', 1)})
values.update({c: i for i, c in enumerate('STUVWXYZ', 2)})
values.update(P=7, R=9)
if any(c not in values for c in vin):
return False
weights = (8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2)
remainder = sum(values[c] * w for c, w in zip(vin, weights, strict=True)) % 11
return vin[8] == ('X' if remainder == 10 else str(remainder))
def dimensions(cp):
"""Read body metadata without changing CarParams or startup eligibility."""
if (getattr(cp, 'brand', None) != 'hyundai' or getattr(cp, 'carFingerprint', None) != 'HYUNDAI_IONIQ_6'
or getattr(cp, 'fuzzyFingerprint', True) is not False or not _us_my23_vin(getattr(cp, 'carVin', None))):
return None
matched = set()
try:
for fw in cp.carFw:
if fw.logging:
continue
kind = str(fw.ecu)
if kind not in ('fwdCamera', 'fwdRadar'):
continue
address, version = (0x7c4, CAMERA_FW) if kind == 'fwdCamera' else (0x7d0, RADAR_FW)
if (fw.brand != 'hyundai' or fw.address != address or fw.subAddress != 0 or fw.bus not in (0, 1)
or bytes(fw.fwVersion) != version):
return None
matched.add(kind)
except (AttributeError, TypeError, ValueError, OverflowError):
return None
if matched != {'fwdCamera', 'fwdRadar'}:
return None
# Hyundai US 2023 owner's manual, printed page 2-9: overall W/L/H.
# Mirrors are outside this body-panel envelope.
return 1.880, 4.855, 1.495
@@ -10,7 +10,7 @@ from opendbc.car.hyundai.ecu_continuity import ContinuousECUStartup, StartupKeep
from opendbc.car.hyundai.ecu_startup import stock_copy
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.radar_hda1_startup import CARS, RadarHDA1Startup
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.hyundai.values import HyundaiFlags
def required(cp):
@@ -27,8 +27,7 @@ def qualified(cp):
return (required(cp) and cp.alphaLongitudinalAvailable and cp.alternativeExperience == 0 and not cp.pcmCruise and
not int(cp.flags) & ~int(allowed) and owner is not None and
stock.safetyConfigs[owner].safetyParam in (0x10, 0x12, 0x90, 0x92) and stock_qualified(stock) and
cp.safetyConfigs[owner].safetyParam == (stock.safetyConfigs[owner].safetyParam | 4 |
(0x2000 if cp.carFingerprint == CAR.KIA_CARNIVAL_4TH_GEN else 0)))
cp.safetyConfigs[owner].safetyParam == stock.safetyConfigs[owner].safetyParam | 4)
class RadarHDA2Startup(RadarHDA1Startup):
@@ -1,9 +1,7 @@
import math
from dataclasses import dataclass
from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.interfaces import RadarInterfaceBase
from opendbc.car.hyundai.values import (CAR, DBC, HyundaiFlags, HYUNDAI_MRR35_RADAR_DBC, HYUNDAI_MRR30_RADAR_DBC,
HYUNDAI_GV70_RADAR_DBC, HYUNDAI_G90_RADAR_DBC, is_blended)
@@ -23,103 +21,6 @@ MRR35_RADAR_START_ADDR = 0x3A5
MRR35_RADAR_MSG_COUNT = 32
MRR35_RADAR_CYCLE_NS = 50_000_000 # 20 Hz
# 2022+ Kia Stinger: 10 object slots x 3 frames (0x238..0x255, 8 bytes, 50 Hz) on bus 1. No radar DBC; decoded by hand.
STINGER_2022_RADAR_BUS = 1
STINGER_2022_RADAR_START_ADDR = 0x238
STINGER_2022_RADAR_SLOT_COUNT = 10
STINGER_2022_RADAR_MSG_COUNT = 30
STINGER_2022_MAX_TRIPLET_SPAN_NS = 20_000_000
# Suppress measured near-centre stopped-lead speed undershoot without changing oncoming tracks.
STINGER_2022_STOP_UNDERSHOOT_SPEED = -0.7 # m/s
STINGER_2022_STOP_UNDERSHOOT_MAX_LATERAL = 2.0 # m
# Measured radar/wheel-speed bias; taper to zero at standstill.
STINGER_2022_EGO_SPEED_OFFSET = 0.14 # m/s
STINGER_2022_EGO_SPEED_OFFSET_RAMP = 1.0 # m/s
def stinger_2022_radar(CP) -> bool:
return CP.carFingerprint == CAR.KIA_STINGER_2022
def stinger_2022_radar_available(fingerprint) -> bool:
return all(fingerprint[STINGER_2022_RADAR_BUS].get(addr) == 8
for addr in range(STINGER_2022_RADAR_START_ADDR, STINGER_2022_RADAR_START_ADDR + STINGER_2022_RADAR_MSG_COUNT))
def _field(dat: bytes, start: int, size: int) -> int:
return (int.from_bytes(dat, "big") >> (64 - start - size)) & ((1 << size) - 1)
@dataclass(frozen=True)
class Stinger2022CandidateObject:
slot: int
object_id: int
cycle: int
type_raw: int
distance: float
lateral_raw: float
absolute_speed: float
def relative_speed(self, ego_speed: float) -> float:
if not math.isfinite(ego_speed):
raise ValueError("ego speed must be finite")
radar_ego_speed = ego_speed - STINGER_2022_EGO_SPEED_OFFSET * min(max(ego_speed, 0.0) / STINGER_2022_EGO_SPEED_OFFSET_RAMP, 1.0)
return self.settled_absolute_speed() - radar_ego_speed
def settled_absolute_speed(self) -> float:
if STINGER_2022_STOP_UNDERSHOOT_SPEED < self.absolute_speed < 0.0 and \
abs(self.lateral_raw) < STINGER_2022_STOP_UNDERSHOOT_MAX_LATERAL:
return 0.0
return self.absolute_speed
def decode_stinger_2022_triplet(slot: int, payloads: tuple[bytes, ...]) -> Stinger2022CandidateObject | None:
if not 0 <= slot < STINGER_2022_RADAR_SLOT_COUNT or len(payloads) != 3 or any(len(dat) != 8 for dat in payloads):
return None
a, b, c = payloads
cycle = _field(a, 44, 2)
object_id = _field(a, 56, 7)
if cycle != _field(b, 0, 2) or cycle != _field(c, 0, 2):
return None
if object_id != _field(b, 57, 7) or object_id != _field(c, 43, 7):
return None
distance_raw = _field(a, 0, 13)
if object_id == 0 or distance_raw == 0:
return None
return Stinger2022CandidateObject(
slot=slot, object_id=object_id, cycle=cycle, type_raw=_field(a, 13, 3),
distance=distance_raw * 0.05, lateral_raw=_field(a, 16, 12) * 0.05 - 102.4,
absolute_speed=_field(a, 32, 12) * 0.05 - 100.0,
)
class Stinger2022ObjectDecoder:
def __init__(self):
self.frames: dict[tuple[int, int], tuple[int, bytes]] = {}
def update(self, timestamp_ns: int, address: int, dat: bytes, bus: int) -> Stinger2022CandidateObject | None:
end_addr = STINGER_2022_RADAR_START_ADDR + STINGER_2022_RADAR_MSG_COUNT
if bus != STINGER_2022_RADAR_BUS or not STINGER_2022_RADAR_START_ADDR <= address < end_addr or len(dat) != 8:
return None
slot, part = divmod(address - STINGER_2022_RADAR_START_ADDR, 3)
self.frames[slot, part] = (timestamp_ns, dat)
if part != 2 or any((slot, i) not in self.frames for i in range(3)):
return None
triple = [self.frames[slot, i] for i in range(3)]
times = [t for t, _ in triple]
# Consume the complete cycle so repeated final frames cannot reuse old parts.
for i in range(3):
del self.frames[slot, i]
if max(times) != timestamp_ns or max(times) - min(times) > STINGER_2022_MAX_TRIPLET_SPAN_NS:
return None
return decode_stinger_2022_triplet(slot, tuple(dat for _, dat in triple))
def blended_track_radar(CP) -> bool:
# Exact mixed classic PT / HDA-II steering identity, independently observed
@@ -142,9 +43,6 @@ def radar_bus(CP) -> int:
def get_radar_can_parser(CP):
if stinger_2022_radar(CP):
# Only the ego speed comes from a DBC; the object frames are decoded by hand (Stinger2022ObjectDecoder).
return CANParser(DBC[CP.carFingerprint][Bus.pt], [("WHL_SPD11", 50)], 0)
if radar_dbc(CP) is None:
return None
@@ -156,15 +54,10 @@ def get_radar_can_parser(CP):
20 if mrr35 and addr == start + count - 1 else
float("nan") if mrr35 or g90 and addr >= start + RADAR_MSG_COUNT else 20 if gv70 else 50) for addr in range(start, start + count)]
mrr30 = radar_dbc(CP) == HYUNDAI_MRR30_RADAR_DBC
# MRR35 publishes many unused/unknown fields in each object packet. Decode
# the four consumed track fields, retaining parser integrity fields automatically.
selected = ({name: ('STATE', 'LONG_DIST', 'LAT_DIST', 'REL_SPEED') for name, _ in messages} if mrr35 else None)
return CANParser(radar_dbc(CP), messages, radar_bus(CP) if mrr35 or mrr30 or gv70 else 1, signals=selected)
return CANParser(radar_dbc(CP), messages, radar_bus(CP) if mrr35 or mrr30 or gv70 else 1)
def radar_message_range(CP) -> tuple[int, int]:
if stinger_2022_radar(CP):
return STINGER_2022_RADAR_START_ADDR, STINGER_2022_RADAR_MSG_COUNT
if radar_dbc(CP) == HYUNDAI_G90_RADAR_DBC:
return RADAR_START_ADDR, G90_RADAR_MSG_COUNT
if radar_dbc(CP) in (HYUNDAI_MRR30_RADAR_DBC, HYUNDAI_GV70_RADAR_DBC):
@@ -186,12 +79,7 @@ class RadarInterface(RadarInterfaceBase):
self.mrr35 = self.start_addr == MRR35_RADAR_START_ADDR
self.gv70 = radar_dbc(CP) == HYUNDAI_GV70_RADAR_DBC
self.mrr30 = self.start_addr == MRR30_RADAR_START_ADDR and not self.gv70
self.stinger_2022 = stinger_2022_radar(CP)
self.last_mrr35_trigger_ns = 0
if self.stinger_2022:
self.stinger_2022_decoder = Stinger2022ObjectDecoder()
self.stinger_2022_objects: dict[int, Stinger2022CandidateObject] = {}
self.v_ego = 0.0
self.trigger_msg = self.start_addr + (RADAR_MSG_COUNT if self.g90 else self.msg_count) - 1
self.radar_off_can = CP.radarUnavailable
@@ -210,8 +98,6 @@ class RadarInterface(RadarInterfaceBase):
vls = self.rcp.update(can_strings)
self.updated_messages.update(vls)
if self.stinger_2022:
self._update_stinger_2022_tracks(can_strings)
if self.trigger_msg not in self.updated_messages:
return None
@@ -223,60 +109,11 @@ class RadarInterface(RadarInterfaceBase):
return None
self.last_mrr35_trigger_ns = trigger_ns
rr = self._update_stinger_2022() if self.stinger_2022 else self._update(self.updated_messages)
rr = self._update(self.updated_messages)
self.updated_messages.clear()
return rr
def _update_stinger_2022_tracks(self, can_strings):
if "WHL_SPD11" in self.rcp.vl and self.rcp.ts_nanos["WHL_SPD11"]["WHL_SPD_FL"] > 0:
whl = self.rcp.vl["WHL_SPD11"]
wheel_speed = 0.25 * (whl["WHL_SPD_FL"] + whl["WHL_SPD_FR"] + whl["WHL_SPD_RL"] + whl["WHL_SPD_RR"])
self.v_ego = wheel_speed * CV.KPH_TO_MS * self.CP.wheelSpeedFactor
end_addr = self.start_addr + self.msg_count
for timestamp_ns, frames in can_strings:
for address, dat, src in frames:
if src != STINGER_2022_RADAR_BUS or not (self.start_addr <= address < end_addr) or len(dat) != 8:
continue
self.updated_messages.add(address)
obj = self.stinger_2022_decoder.update(timestamp_ns, address, bytes(dat), src)
if obj is not None:
self.stinger_2022_objects[obj.object_id] = obj
def _update_stinger_2022(self):
ret = structs.RadarData()
if not self.rcp.can_valid or self.rcp.ts_nanos["WHL_SPD11"]["WHL_SPD_FL"] == 0:
# objects decoded in an error cycle must not be published in the next one
self.stinger_2022_objects.clear()
ret.errors.canError = True
return ret
current_ids = set()
for object_id, obj in self.stinger_2022_objects.items():
current_ids.add(object_id)
pt = self.pts.get(object_id)
if pt is None:
pt = structs.RadarData.RadarPoint()
pt.trackId = self.track_id
self.track_id += 1
self.pts[object_id] = pt
pt.dRel = obj.distance
pt.yRel = -obj.lateral_raw # in car frame's y axis, left is positive
pt.vRel = obj.relative_speed(self.v_ego)
# tracks not seen this cycle are dropped
for oid in list(self.pts.keys()):
if oid not in current_ids:
del self.pts[oid]
self.stinger_2022_objects.clear()
ret.points = list(self.pts.values())
return ret
def _update(self, updated_messages):
ret = structs.RadarData()
if self.rcp is None:
@@ -1,60 +0,0 @@
"""Dom 823a5d5 camera/ADAS damping contract, checked at the CAN wire boundary."""
from itertools import product
import pytest
from opendbc.can import CANPacker
from opendbc.car import Bus, structs
from opendbc.car.hyundai.hyundaicanfd import CanBus, create_steering_messages
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
TORQUE_CARS = tuple(car for car in CAR if car.config.flags & HyundaiFlags.CANFD
and not car.config.flags & HyundaiFlags.CANFD_ANGLE_STEERING)
LKAS_DAMPING_100 = frozenset((CAR.KIA_CARNIVAL_4TH_GEN, CAR.KIA_CARNIVAL_2025,
CAR.KIA_CARNIVAL_HEV_4TH_GEN, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN))
@pytest.mark.parametrize('car', TORQUE_CARS)
@pytest.mark.parametrize('topology', ('lfa', 'lkas', 'lkas_alt'))
def test_dom_torque_wire_is_independent_of_alpha_long(car, topology):
cp = structs.CarParams()
cp.carFingerprint = car
cp.flags = int(car.config.flags & ~(HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.CANFD_LKA_STEER_MSG_ALT))
if topology != 'lfa':
cp.flags |= int(HyundaiFlags.CANFD_LKA_STEER_MSG)
if topology == 'lkas_alt':
cp.flags |= int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT)
can = CanBus(cp)
for enabled, active, torque in product((False, True), (False, True), (-137, 0, 139)):
by_mode = []
for longitudinal in (False, True):
cp.openpilotLongitudinalControl = longitudinal
packets = create_steering_messages(CANPacker(DBC[car][Bus.pt]), cp, can, enabled, active, torque)
steering_address = {'lfa': 0x12A, 'lkas': 0x50, 'lkas_alt': 0x110}[topology]
expected = [(steering_address, can.ECAN if topology == 'lfa' else can.ACAN)]
if longitudinal and topology != 'lfa':
expected.insert(0, (0x12A, can.ECAN))
assert [(addr, bus) for addr, _, bus in packets] == expected
for address, data, _ in packets:
lfa = address == 0x12A
damping = 100 if lfa or car in LKAS_DAMPING_100 else 0
# Independent frozen DBC bit positions; no parser/helper-derived oracle.
assert data[13 if lfa else 8] == damping
bits = int.from_bytes(data, 'little')
assert (bits >> 41 & 0x7FF) - 1024 == torque
assert (bits >> 52 & 3) == int(active)
assert (bits >> 38 & 7) == (2 if enabled else 1)
by_mode.append(packets[-1])
assert by_mode[0] == by_mode[1]
@pytest.mark.parametrize('car', tuple(car for car in CAR if car.config.flags & HyundaiFlags.CANFD_ANGLE_STEERING))
def test_torque_helper_keeps_existing_angle_fallback_damping(car):
cp = structs.CarParams()
cp.carFingerprint = car
cp.flags = int(car.config.flags | HyundaiFlags.CANFD_LKA_STEER_MSG)
for longitudinal in (False, True):
cp.openpilotLongitudinalControl = longitudinal
packets = create_steering_messages(CANPacker(DBC[car][Bus.pt]), cp, CanBus(cp), True, True, 0)
assert packets[-1][1][8] == 100
@@ -45,6 +45,6 @@ def test_constructor_scope_preserves_saved_button_mappings_and_release(lda, lkas
before = cp.to_dict()
settings = AolSettings(True, 0., lkas, main, (0, 0, 0), (0, 0, 0))
owner = ClassicLongCardIntent(cp, settings)
assert owner.independent_cruise_buttons == (lda and not release)
assert owner.independent_cruise_buttons == (lda and not release and lkas == 9 and main == 0)
assert cp.to_dict() == before and owner.settings == settings
assert not independent_buttons_qualified(cp)
@@ -1,105 +0,0 @@
from unittest.mock import patch
import pytest
from opendbc.can import CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.classic_long_aol import aol_word
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC
from opendbc.safety.tests import test_hyundai_classic_long_aol as native_fixture
from opendbc.safety.tests.libsafety.libsafety_py import make_CANPacket
def controller(*, marked=True, experience=32):
fp = gen_empty_fingerprint()
fp[0][0x391] = 8
fp[0][0x38d] = 8
fp[2][0x485] = 4
cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_2021, fp, [], True, False, False)
if marked:
cp.safetyConfigs[0].safetyParam = aol_word(cp)
cp.alternativeExperience = experience
assert cp.flags == 16843136 # Recorded Elantra alpha-long profile, including FCA and LDA.
ci = CarInterface(cp)
ci.update([])
return ci
def step(ci, cc, parser=None):
stamp = 10_000_000_000 + ci.CC.frame * 10_000_000
actuators, packets = ci.CC.update(cc.as_reader(), ci.CS, stamp)
if parser is not None:
parser.update((stamp, packets))
return actuators, packets
def parser():
return CANParser(DBC[CAR.HYUNDAI_ELANTRA_2021][Bus.pt], [('LKAS11', 0), ('LFAHDA_MFC', 0)], 0)
def test_elantra_long_lateral_only_dash_matches_native_permission_after_cancel():
case = native_fixture.TestHyundaiClassicLongAol(methodName='runTest')
case.setUp()
try:
ci, can = controller(), parser()
assert ci.CP.safetyConfigs[0].safetyParam == 0xC04
case.reset(0xC04)
for _ in range(6):
case.feed(0xC04)
case.feed(0xC04, button=2)
case.feed(0xC04)
assert case.request(3) == 3
case.feed(0xC04, button=4)
permission = case.request(3)
assert permission == 1 and not case.safety.get_controls_allowed()
cc = structs.CarControl(enabled=False, latActive=bool(permission & 1))
actuators, packets = step(ci, cc, can)
assert can.vl['LKAS11']['CF_Lkas_FcwOpt_USM'] == 2
assert can.vl['LFAHDA_MFC']['LFA_Icon_State'] == 2
assert actuators.torque == 0 and actuators.accel == 0
for address, data, bus in packets:
assert case.safety.safety_tx_hook(make_CANPacket(address, bus, data))
finally:
case.tearDown()
@pytest.mark.parametrize('enabled,lateral', [(False, False), (True, False), (True, True), (False, True)])
@pytest.mark.parametrize('alert', [structs.CarControl.HUDControl.VisualAlert.none, structs.CarControl.HUDControl.VisualAlert.steerRequired,
structs.CarControl.HUDControl.VisualAlert.fcw])
def test_only_icon_fields_and_lkas_checksum_change(enabled, lateral, alert):
current, original = controller(), controller()
original.CC.classic_dash_icons = None
new_can, old_can = parser(), parser()
cc = structs.CarControl(enabled=enabled, latActive=lateral, hudControl={'visualAlert': alert, 'leadVisible': True})
for _ in range(10):
new_actuators, new_packets = step(current, cc, new_can)
with patch('opendbc.car.hyundai.carcontroller.classic_aol_dash_enabled', return_value=False):
old_actuators, old_packets = step(original, cc, old_can)
assert new_actuators.to_dict() == old_actuators.to_dict()
assert len(new_packets) == len(old_packets)
for new, old in zip(new_packets, old_packets, strict=True):
assert new[0] == old[0] and new[2] == old[2]
if new[0] not in (0x340, 0x485):
assert new == old # Includes all SCC/FCA acceleration, lead, and AEB status packets.
for message, excluded in [('LKAS11', {'CF_Lkas_FcwOpt_USM', 'CF_Lkas_Chksum'}), ('LFAHDA_MFC', {'LFA_Icon_State'})]:
assert {k: v for k, v in new_can.vl[message].items() if k not in excluded} == {
k: v for k, v in old_can.vl[message].items() if k not in excluded}
def test_disengagement_blink_and_inactive_icon_restore():
ci, can = controller(), parser()
for frame in range(101):
step(ci, structs.CarControl(latActive=frame == 0), can)
icon = 2 if frame == 0 else 3 if frame < 100 else 0
assert can.vl['LKAS11']['CF_Lkas_FcwOpt_USM'] == (icon or 1)
if frame % 5 == 0:
assert can.vl['LFAHDA_MFC']['LFA_Icon_State'] == icon
@pytest.mark.parametrize('marked,experience', [(False, 0), (False, 32), (True, 0), (True, 33)])
def test_unqualified_elantra_does_not_gain_lateral_only_icon_override(marked, experience):
ci, can = controller(marked=marked, experience=experience), parser()
step(ci, structs.CarControl(latActive=True), can)
assert can.vl['LKAS11']['CF_Lkas_FcwOpt_USM'] == 1
assert can.vl['LFAHDA_MFC']['LFA_Icon_State'] == 0
@@ -1,208 +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, CanData, 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, physical_frames
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
from opendbc.car.hyundai.ioniq6_handoff import TimestampedCanPacket
from openpilot.starpilot.tests.test_ev6_startup import params
from opendbc.can.packer import CANPacker
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", "capture_lost", "cp_changed", "cruise_fault", "stock_engaged",
"disable_negative", "disable_malformed", "restore_failure", "publisher_failure"))
def test_actual_card_defers_ev6_until_inactive_producer_and_then_native_rx(scenario):
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()])
factory = CarInterface.startup_owner
def startup_factory(*args, **kwargs):
instance = factory(*args, **kwargs)
instance.clock, instance.keeper_factory, instance._query = lambda: now[0] / 1e9, Keeper, query
return instance
with OpenpilotPrefix(), patch.object(CarInterface, "startup_owner", side_effect=startup_factory), \
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), \
patch("opendbc.car.hyundai.ecu_startup.IsoTpParallelQuery", side_effect=query):
saved = Params()
for key in ("OpenpilotEnabledToggle", "AlwaysOnLateral", "AlphaLongitudinalEnabled"):
saved.put_bool(key, True, block=True)
holder = VehicleStartupOwner()
counter = [0]
lose_capture = [False]
def receive():
if lose_capture[0]:
return []
now[0] += 10_000_000
counter[0] += 1
data = bytearray(32)
data[2] = counter[0] % 256
data[4] = 7
data[:2] = hkg_can_fd_checksum(0x51, None, data).to_bytes(2, "little")
return [TimestampedCanPacket([CanData(0x51, bytes(data), 0)], now[0])]
prepared = holder.prepare(params(), CarInterface, (receive, 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()
packer = CANPacker(card.CI.can_parsers[Bus.pt].dbc.name)
# 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], physical_frames(card.CP, packer, tick))])
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
if scenario == "capture_lost":
lose_capture[0] = True
if scenario == "cp_changed":
card.CP.steerActuatorDelay += 0.01
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")
if scenario == "capture_lost":
with pytest.raises(RuntimeError, match="lost its current factory capture"):
card.controls_update(state, command, initialize_only=True)
assert not requests and not muted[0] and not card.ci_initialized
assert startup.outcome is Outcome.PENDING
return
if scenario in ("disable_negative", "disable_malformed", "restore_failure", "publisher_failure"):
with pytest.raises(RuntimeError, match="pending"):
card.controls_update(state, command, initialize_only=True)
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, initialize_only=True)
if not scenario.startswith("healthy"):
assert not requests and not muted[0] and not card.ci_initialized
assert startup.outcome is Outcome.PENDING
if scenario != "cp_changed":
assert 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, initialize_only=True)
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, initialize_only=True)
assert not emitted and not startup.handed_off
for tick in range(701, 811):
now[0] += 10_000_000
frames = physical_frames(card.CP, packer, tick)
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, initialize_only=True)
assert any(address == 0x1A0 and bus == 1 for address, _, bus in captured)
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()
@@ -52,23 +52,7 @@ class TestEV9CombinedProfile(unittest.TestCase):
cc = structs.CarControl(latActive=True)
self.assertFalse(lateral_request_allowed(cc, cs, False))
self.assertTrue(lateral_request_allowed(cc, cs, False, independent=qualified(cp, marked_only=True)))
ordinary = structs.CarControl(enabled=True, latActive=True)
for field in ('gasPressed', 'brakePressed'):
setattr(cs, field, True)
self.assertTrue(temporary_restriction(cp, cs), field) # Intent retention is not output authority.
self.assertTrue(lateral_request_allowed(cc, cs, False, independent=qualified(cp, marked_only=True)), field)
self.assertFalse(lateral_request_allowed(ordinary, cs, False), field)
self.assertFalse(lateral_request_allowed(structs.CarControl(), cs, False, independent=True), field)
self.assertFalse(lateral_request_allowed(structs.CarControl(enabled=True), cs, False, independent=True), field)
for refusal, value in (('canValid', False), ('canTimeout', True), ('steerFaultTemporary', True),
('steerFaultPermanent', True), ('standstill', True), ('gearShifter', 'reverse')):
before = getattr(cs, refusal)
setattr(cs, refusal, value)
self.assertFalse(lateral_request_allowed(cc, cs, False, independent=True), (field, refusal))
setattr(cs, refusal, before)
self.assertFalse(lateral_request_allowed(cc, cs, True, independent=True), field)
setattr(cs, field, False)
for field in ('steerFaultTemporary', 'standstill'):
for field in ('gasPressed', 'brakePressed', 'steerFaultTemporary', 'standstill'):
setattr(cs, field, True)
self.assertTrue(temporary_restriction(cp, cs), field)
self.assertFalse(lateral_request_allowed(cc, cs, False, independent=True), field)
@@ -197,105 +197,3 @@ class TestEV9LongController(unittest.TestCase):
self.assertFalse(ci.CS.out.canTimeout)
self.assertTrue(ci.CS.out.cruiseState.available)
self.assertTrue(ci.CS.out.cruiseState.enabled)
class TestEV9FreshMetadataQualification(unittest.TestCase):
@staticmethod
def original(cp):
from opendbc.car.hyundai.ev9_longitudinal import LONG_PARAM, stock_copy
from opendbc.car.hyundai.ccnc_ev_stock import qualified as stock_qualified
if (cp.carFingerprint != CAR.KIA_EV9 or not cp.openpilotLongitudinalControl or cp.pcmCruise or
cp.alternativeExperience not in (0, 32) or not cp.alphaLongitudinalAvailable or
len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyParam != LONG_PARAM):
return False
stock = stock_copy(cp)
stock.alternativeExperience = 0
return stock_qualified(stock)
def base(self):
return candidate(params(candidate=CAR.KIA_EV9), enabled=True, is_release=False)
def check(self, cp):
def result(fn):
try:
return ('value', fn(cp))
except Exception as error:
return ('error', type(error), str(error))
self.assertEqual(result(qualified), result(self.original))
def test_current_builder_fields_and_every_safety_word(self):
cp = self.base()
for experience in (0, 32):
cp.alternativeExperience = experience
for word in range(65536):
cp.safetyConfigs[0].safetyParam = word
self.assertEqual(qualified(cp), self.original(cp), (experience, word))
def test_each_live_metadata_field_and_neighbor_profile(self):
cp = self.base()
changes = {
'brand': ('', 'gm', 'hyundai'),
'carFingerprint': ('', CAR.HYUNDAI_IONIQ_5_PE, CAR.HYUNDAI_IONIQ_6, CAR.KIA_EV9),
'flags': (0, *(int(cp.flags) ^ (1 << bit) for bit in range(32))),
'alternativeExperience': (0, 32, 64, 96, 128),
'openpilotLongitudinalControl': (False, True), 'pcmCruise': (False, True),
'alphaLongitudinalAvailable': (False, True), 'notCar': (False, True),
'passive': (False, True), 'dashcamOnly': (False, True),
}
for field, values in changes.items():
for value in values:
changed = cp.copy()
setattr(changed, field, value)
with self.subTest(field=field, value=value):
self.check(changed)
for count in (0, 2):
changed = cp.copy()
changed.init('safetyConfigs', count)
self.check(changed)
for model in ('silent', 'noOutput', 'toyota', 'gm', 'hyundai', 'hyundaiCanfd'):
changed = cp.copy()
changed.safetyConfigs[0].safetyModel = model
self.check(changed)
def test_ignored_gear_metadata_and_live_mutations(self):
from opendbc.car.hyundai.values import HyundaiFlags
cp = self.base()
for bits in (0, int(HyundaiFlags.CANFD_ALT_GEARS), int(HyundaiFlags.CANFD_ALT_GEARS_2),
int(HyundaiFlags.CANFD_ALT_GEARS | HyundaiFlags.CANFD_ALT_GEARS_2)):
cp.flags = int(self.base().flags) | bits
self.assertTrue(qualified(cp))
self.check(cp)
cp.safetyConfigs[0].safetyParam ^= 1
self.assertFalse(qualified(cp))
self.check(cp)
cp.safetyConfigs[0].safetyParam ^= 1
self.assertTrue(qualified(cp))
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:
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()
class Proxy:
def __init__(self):
self.calls = []
def __getattr__(self, field):
self.calls.append(field)
return getattr(cp, field)
def to_dict(self):
self.calls.append('to_dict')
return cp.to_dict()
current, original = Proxy(), Proxy()
self.assertEqual(qualified(current), self.original(original))
self.assertEqual(current.calls, original.calls)
@@ -1,5 +1,4 @@
from contextlib import contextmanager
from copy import deepcopy
import unittest
from types import SimpleNamespace
from typing import cast
@@ -10,7 +9,7 @@ from opendbc.car import Bus, CanData, structs
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ev9_longitudinal import candidate, qualified
from opendbc.car.hyundai.ev9_pending_startup import EV9PendingStartup as PEPendingStartup
from opendbc.car.hyundai.ioniq5pe_startup import Lease, Phase
from opendbc.car.hyundai.ioniq5pe_startup import Phase
from opendbc.car.hyundai.ioniq5pe_ready import PEReadySource
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params as stock_params
@@ -591,7 +590,7 @@ class TestEV9PendingIdentity(unittest.TestCase):
stock = stock_params()
long = pe_candidate(stock, enabled=True, is_release=False)
with self.assertRaises(ValueError):
OriginalPEPendingStartup(long, (lambda wait_for_one=False, **kw: [], lambda frames: None), stock_cp=params())
OriginalPEPendingStartup(long, (lambda **kw: [], lambda frames: None), stock_cp=params())
class TestEV9UnpreparedController(unittest.TestCase):
@@ -608,120 +607,3 @@ class TestEV9UnpreparedController(unittest.TestCase):
control.actuators.accel = 1.
_, frames = ci.apply(control.as_reader(), 130_000_000)
self.assertEqual(frames, [])
class TestPreparedConfigurationComparison(unittest.TestCase):
@staticmethod
def context(car=CAR.KIA_EV9):
cp = stock_params(candidate=car)
owner = PEPendingStartup.__new__(PEPendingStartup)
owner.owner = Lease(cp.copy())
owner.owner.phase = Phase.OWNED
owner.closed = False
owner.fault = None
owner.snapshot = deepcopy(cp.to_dict())
assert owner.prepared_for(cp)
return owner, cp
@staticmethod
def leaf_paths(value, path=()):
if type(value) is dict:
for key, child in value.items():
yield from TestPreparedConfigurationComparison.leaf_paths(child, path + (key,))
elif type(value) is list:
for key, child in enumerate(value):
yield from TestPreparedConfigurationComparison.leaf_paths(child, path + (key,))
else:
yield path, value
@staticmethod
def assign(cp, path, value):
for key in path[:-1]:
cp = getattr(cp, key) if type(key) is str else cp[key]
if type(path[-1]) is str:
setattr(cp, path[-1], value)
else:
cp[path[-1]] = value
def test_every_legal_configuration_mutation_remains_freshly_rejected(self):
checked = 0
for car in (CAR.KIA_EV9, CAR.HYUNDAI_IONIQ_5_PE):
for path, value in self.leaf_paths(stock_params(candidate=car).to_dict()):
if type(value) is bool:
changed = not value
elif type(value) is int:
changed = value ^ 1
elif type(value) is float:
changed = value + .125
elif type(value) is str:
changed = value + 'changed'
elif type(value) is bytes:
changed = value + b'changed'
else:
continue
for target in ('owner', 'caller', 'snapshot'):
owner, cp = self.context(car)
if target == 'snapshot':
container = owner.snapshot
for key in path[:-1]:
container = container[key]
container[path[-1]] = changed
else:
try:
self.assign(owner.owner.cp if target == 'owner' else cp, path, changed)
except (ValueError, TypeError, AttributeError, RuntimeError):
# Invalid enum/union assignments never become a configuration.
continue
self.assertFalse(owner.prepared_for(cp), (car, path, target))
checked += 1
self.assertGreater(checked, 300)
def test_owner_fault_and_phase_are_never_cached(self):
for phase in Phase:
owner, cp = self.context()
owner.owner.phase = phase
self.assertEqual(owner.prepared_for(cp), phase in (Phase.PENDING, Phase.OWNED, Phase.STOCK))
for name, value in (('closed', True), ('fault', 'current fault'), ('snapshot', None)):
owner, cp = self.context()
setattr(owner, name, value)
self.assertFalse(owner.prepared_for(cp))
def test_equivalent_replacements_arena_changes_and_reader_fallback(self):
owner, cp = self.context()
original = cp.carFingerprint
cp.carFingerprint += 'temporary'
cp.carFingerprint = original
self.assertTrue(owner.prepared_for(cp))
cp = cp.copy()
self.assertTrue(owner.prepared_for(cp))
owner.owner.cp = owner.owner.cp.copy()
self.assertTrue(owner.prepared_for(cp))
self.assertTrue(owner.prepared_for(cp.as_reader()))
cp.mass = owner.owner.cp.mass = 0.
owner.snapshot['mass'] = -0.
self.assertTrue(owner.prepared_for(cp))
cp.mass = owner.owner.cp.mass = float('nan')
owner.snapshot['mass'] = float('nan')
self.assertFalse(owner.prepared_for(cp))
def test_custom_snapshot_callbacks_keep_original_semantics(self):
trace = []
class Meta(type):
def __eq__(self, other):
raise AssertionError('Metadata validation must not invoke metaclass equality')
def __hash__(self):
raise AssertionError('Metadata validation must not invoke metaclass hashing')
class EqualValue(metaclass=Meta):
def __eq__(self, other):
trace.append(other)
return True
owner, cp = self.context()
mass = owner.snapshot['mass']
owner.snapshot['mass'] = EqualValue()
self.assertTrue(owner.prepared_for(cp))
self.assertEqual(trace, [mass, mass])
self.assertIsNone(owner._prepared_cp_comparison)
@@ -45,9 +45,7 @@ FUEL_CASES = tuple((identity, hybrid) for identity in sorted(CARS) for hybrid in
@pytest.mark.parametrize('alt', [False, True])
def test_existing_generic_hda2_long_requires_prepublication_owner(identity, hybrid, alt):
cp = params(identity, hybrid, alt=alt)
gas = 1 if identity.config.flags & HyundaiFlags.EV else 2 if hybrid or identity == CAR.KIA_CARNIVAL_HEV_4TH_GEN else 0
calibration = 0x2000 if identity in (CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN) else 0
assert cp.openpilotLongitudinalControl and cp.safetyConfigs[0].safetyParam == (0x14 | gas | (128 if alt else 0) | calibration)
assert cp.openpilotLongitudinalControl and cp.safetyConfigs[0].safetyParam in (0x14, 0x15, 0x16, 0x94, 0x95, 0x96)
sent = []
owner = CarInterface.startup_owner(cp, (list, sent.extend), requested=True)
assert not sent
@@ -28,25 +28,14 @@ def long_params():
class TestIoniq6BlindspotStatus(unittest.TestCase):
def test_all_sixteen_reference_outputs_without_overlapping_sound_alias(self):
def test_all_sixteen_frozen_executable_outputs_and_integrity(self):
for case in CASES:
with self.subTest(case=case):
left = 1 + int(case["leftBlinker"]) if case["left"] else 0
right = 1 + int(case["rightBlinker"]) if case["right"] else 0
frames = create_ioniq6_blindspot_status(case["counter"], BlindspotStatus(left, right))
self.assertEqual([(addr, bus) for addr, _, bus in frames], [(0x1BA, 1), (0x1E5, 1)])
expected_rear = bytearray.fromhex(case["rear"])
expected_rear[5] = 0 # Retired FL_INDICATOR alias overlaps BCW_RtSndWrngSta.
expected_rear[:2] = hkg_can_fd_checksum(0x1BA, None, expected_rear).to_bytes(2, "little")
self.assertEqual([payload.hex() for _, payload, _ in frames], [expected_rear.hex(), case["front"]])
rear = frames[0][1]
sound_word = int.from_bytes(rear, "little")
self.assertEqual((sound_word >> 34) & 0x3, 0)
self.assertEqual((sound_word >> 36) & 0x3ff, 0)
self.assertEqual(rear[16] & 3, left)
self.assertEqual((rear[16] >> 2) & 0x3f, right)
self.assertEqual((rear[3] >> 6) & 3, left)
self.assertEqual(rear[4] & 3, right)
self.assertEqual([payload.hex() for _, payload, _ in frames], [case["rear"], case["front"]])
for addr, payload, _ in frames:
self.assertEqual(int.from_bytes(payload[:2], "little"), hkg_can_fd_checksum(addr, None, bytearray(payload)))
@@ -129,7 +129,7 @@ class TestIoniq6EngagementMessages(unittest.TestCase):
stock = cp.as_reader().as_builder()
stock.openpilotLongitudinalControl = False
packets = create_steering_messages(CANPacker(DBC[cp.carFingerprint][Bus.pt]), stock, CanBus(stock), True, True, 20)
self.assertEqual(packets[-1][1][8], 0)
self.assertEqual(packets[-1][1][8], 100)
angle = cp.as_reader().as_builder()
angle.flags |= int(HyundaiFlags.CANFD_ANGLE_STEERING)
packets = create_steering_messages(CANPacker(DBC[cp.carFingerprint][Bus.pt]), angle, CanBus(angle), True, True, 20)
@@ -1,6 +1,5 @@
import threading
import tempfile
import time
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -19,19 +18,6 @@ from opendbc.car.hyundai.values import CAR
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
def refresh_host_parser_receipts(card):
from collections import deque
from opendbc.can.parser import MessageState
stamp = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
parsers = {}
for bus in (1, 2):
source = MessageState(address=bus, name=f"source{bus}", size=8, signals=[],
timeout_threshold=100_000_000, timestamps=deque([stamp]))
parsers[bus] = SimpleNamespace(message_states={bus: source})
card.CI.can_parsers = parsers
class FakeIoniq6Bus:
def __init__(self, *, source=True, heartbeat_source=True, heartbeat_stays_after_disable=False,
bsm_source=True, bsm_stays_after_disable=False, missing_status=None,
@@ -575,9 +561,6 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
card.ioniq6_host_warmed = False
card.ioniq6_long_lost = False
card.ioniq6_keepalive = None
card.ioniq6_handoff_source_floor_ns = 1
card.CI = SimpleNamespace()
refresh_host_parser_receipts(card)
invalid = SimpleNamespace(canValid=False)
card.observe_ioniq6_long_authority([], invalid)
self.assertFalse(card.ioniq6_long_lost)
@@ -676,7 +659,6 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
card.controls_update(cs, cc)
card.CI.apply.assert_not_called()
cs.canValid = True
refresh_host_parser_receipts(card)
card.observe_ioniq6_long_authority([], cs)
card.controls_update(cs, cc)
card.CI.apply.assert_called_once()
@@ -870,8 +852,8 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
card_owner.is_metric = True
card_owner.experimental_mode = False
card_owner.initial_cruise_speed = 40
card_owner.v_cruise_helper = SimpleNamespace(update_v_cruise=lambda *_, slc_enabled=False, slc_approved=None: None,
initialize_v_cruise=lambda *_, **kwargs: None, slc_consumed_button=None, toyota=None,
card_owner.v_cruise_helper = SimpleNamespace(update_v_cruise=lambda *_, slc_enabled=False: None, initialize_v_cruise=lambda *_, **kwargs: None,
slc_consumed_button=None,
slc_cruise_change=None, v_cruise_kph=60.0, v_cruise_cluster_kph=60.0)
card_owner.RI = SimpleNamespace(update=lambda _: None)
control = car.CarControl(enabled=True, longActive=True)
@@ -905,12 +887,6 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
update=Mock(side_effect=lambda packets: car.CarState(canValid=True, canTimeout=False)),
apply=Mock(return_value=(structs.CarControl.Actuators(), [CanData(0x1A0, b'\0' * 8, 1)])),
init=Mock())
def parsed_update(_packets):
refresh_host_parser_receipts(card_owner)
return car.CarState(canValid=True, canTimeout=False)
card_owner.CI.update.side_effect = parsed_update
card_owner.ioniq6_panda_diagnostic_ready = Mock(return_value=True)
card_owner.ioniq6_panda_matches = Mock(side_effect=(False, True, True))
packets = iter((
@@ -1,106 +0,0 @@
"""Recorded parser grace must not complete a new I6 ECU ownership warmup."""
from collections import deque
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from opendbc.car import gen_empty_fingerprint
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ioniq6_handoff import build_ioniq6_hda2_long_candidate
from opendbc.car.hyundai.values import CAR
from openpilot.selfdrive.car.card import Car
def recorded_owner():
fp = gen_empty_fingerprint()
fp[0][0x3A5] = 24
fp[2].update({0x110: 32, 0x362: 32})
fp[1].update({0x130: 16, 0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1BA: 24, 0x1E5: 16, 0x36A: 16})
stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fp, [], False, False, False)
cp = build_ioniq6_hda2_long_candidate(stock, fp)
assert cp is not None
cp.safetyConfigs[-1].safetyParam |= 0x0800
assert cp.flags == 1059329 and cp.safetyConfigs[-1].safetyParam == 0x8895
owner = Car.__new__(Car)
owner.CP, owner.CI = cp, CarInterface(cp)
owner.ioniq6_long_selected = owner.ioniq6_long_prearmed = True
owner.ioniq6_long_lost = owner.ioniq6_host_warmed = False
owner.ioniq6_keepalive = SimpleNamespace(stop=Mock())
return owner
def test_recorded_c8_actual_parser_grace_waits_for_post_handoff_sources():
fixture = json.loads((Path(__file__).parent / "testdata/ioniq6_post_handoff_warmup.json").read_text())
owner = recorded_owner()
floor = fixture["handoffCompletedNs"]
owner.ioniq6_handoff_source_floor_ns = floor
now = [0]
history = []
with patch("time.clock_gettime_ns", side_effect=lambda clock: now[0]):
for packet in fixture["packets"]:
ns = packet["ns"]
if fixture["lastCardSourceBeforeTransactionNs"] < ns <= floor:
continue # The real UDS transaction consumed this Card cursor interval.
now[0] = ns
frames = [(a, bytes.fromhex(data), bus) for a, data, bus in packet["frames"]]
state = owner.CI.update([(ns, frames)])
if ns <= floor:
continue
parser_valid = state.canValid
owner.observe_ioniq6_long_authority([(ns, frames)], state)
history.append((ns, parser_valid, owner.ioniq6_host_warmed, owner.ioniq6_long_lost))
assert history[0][1] is True, "Recorded parser initially tolerates stale sources"
assert history[0][2] is False, "STALE_PARSER_GRACE_MUST_NOT_FINISH_HANDOFF_WARMUP"
assert any(not row[1] for row in history), "Real parser grace eventually expires"
assert history[-1][1:3] == (True, True)
assert not any(row[3] for row in history)
owner.ioniq6_keepalive.stop.assert_not_called()
# A genuinely invalid input after completed warmup retains the old refusal.
invalid = SimpleNamespace(canValid=False)
owner.observe_ioniq6_long_authority([], invalid)
assert owner.ioniq6_long_lost and not invalid.canValid
owner.ioniq6_keepalive.stop.assert_called_once()
@pytest.mark.parametrize("fault", ("no_floor", "empty_inventory", "pre_handoff", "future", "expired", "bad_counter", "missing"))
def test_warmup_proof_rejects_missing_or_invalid_source_receipts(fault):
owner = recorded_owner()
owner.ioniq6_handoff_source_floor_ns = 1_000_000_000
now = 1_100_000_000
required = [source for parser in owner.CI.can_parsers.values()
for source in parser.message_states.values() if not source.ignore_alive]
assert len(required) >= 6
for source in required:
source.timestamps = deque([now])
source = required[-1]
if fault == "no_floor":
owner.ioniq6_handoff_source_floor_ns = 0
elif fault == "empty_inventory":
owner.CI.can_parsers = {}
elif fault == "pre_handoff":
source.timestamps = deque([owner.ioniq6_handoff_source_floor_ns])
elif fault == "future":
source.timestamps = deque([now + 1])
elif fault == "expired":
source.timeout_threshold = 10_000_000
source.timestamps = deque([now - 10_000_001])
elif fault == "bad_counter":
source.counter_fail = 5
elif fault == "missing":
source.timestamps.clear()
with patch("time.clock_gettime_ns", return_value=now):
owner.observe_ioniq6_long_authority([], SimpleNamespace(canValid=True))
assert not owner.ioniq6_host_warmed and not owner.ioniq6_long_lost
@pytest.mark.parametrize("address,bus", ((0x1A0, 1), (0x1BA, 1), (0x1E5, 1), (0x100, 0)))
def test_factory_source_return_still_revokes_during_warmup(address, bus):
owner = recorded_owner()
owner.ioniq6_handoff_source_floor_ns = 1_000_000_000
state = SimpleNamespace(canValid=True)
owner.observe_ioniq6_long_authority([(1_100_000_000, [(address, bytes(32), bus)])], state)
assert owner.ioniq6_long_lost and not state.canValid
owner.ioniq6_keepalive.stop.assert_called_once()
@@ -1,56 +0,0 @@
from copy import deepcopy
from types import SimpleNamespace
import unittest
from opendbc.car.hyundai.lane_body import CAMERA_FW, RADAR_FW, dimensions
def identity():
# A generated checksum-valid metadata fixture, not a captured vehicle VIN.
vin = list('KMHAAAAA0PA000001')
values = dict(zip('0123456789ABCDEFGHJKLMNPRSTUVWXYZ', '012345678912345678123457923456789', strict=True))
remainder = sum(int(values[c]) * w for c, w in zip(vin, (8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2), strict=True)) % 11
vin[8] = 'X' if remainder == 10 else str(remainder)
firmware = [SimpleNamespace(ecu=kind, fwVersion=version, address=address, subAddress=0, bus=1, brand='hyundai', logging=False)
for kind, version, address in (('fwdCamera', CAMERA_FW, 0x7c4), ('fwdRadar', RADAR_FW, 0x7d0))]
return SimpleNamespace(brand='hyundai', carFingerprint='HYUNDAI_IONIQ_6', fuzzyFingerprint=False, carVin=''.join(vin), carFw=firmware)
class TestLaneBody(unittest.TestCase):
def test_corroborated_metadata_is_read_only_and_has_explicit_body_height(self):
cp = identity()
before = deepcopy(cp)
self.assertEqual(dimensions(cp), (1.880, 4.855, 1.495))
self.assertEqual(cp, before)
def test_absent_or_conflicting_identity_has_no_positive_bounds(self):
for field, value in (('brand', 'other'), ('carFingerprint', 'HYUNDAI_IONIQ_5'), ('fuzzyFingerprint', True),
('fuzzyFingerprint', None), ('carVin', ''), ('carVin', '0' * 17), ('carFw', [])):
cp = identity()
setattr(cp, field, value)
self.assertIsNone(dimensions(cp))
cp = identity()
for index, value in ((9, 'R'), (8, '0'), (6, '1'), (0, 'J')):
changed = deepcopy(cp)
changed.carVin = cp.carVin[:index] + value + cp.carVin[index + 1:]
self.assertIsNone(dimensions(changed))
self.assertIsNone(dimensions(object()))
def test_unrecognized_incomplete_logging_or_conflicting_firmware_is_refused(self):
for index in (0, 1):
for field, value in (('fwVersion', b'unknown'), ('brand', 'other'), ('address', 0), ('subAddress', 1), ('bus', 4), ('logging', True)):
cp = identity()
setattr(cp.carFw[index], field, value)
self.assertIsNone(dimensions(cp))
cp = identity()
cp.carFw.pop(index)
self.assertIsNone(dimensions(cp))
cp = identity()
conflict = deepcopy(cp.carFw[0])
conflict.fwVersion = b'other'
cp.carFw.append(conflict)
self.assertIsNone(dimensions(cp))
if __name__ == '__main__':
unittest.main()

Some files were not shown because too many files have changed in this diff Show More