Compare commits

..

381 Commits

Author SHA1 Message Date
whoisdomi 799c5effeb Low Speed Twitches 2026-11-09 11:23:17 -06:00
whoisdomi 18e52b36d1 Force Stop Improvements
1. Rolling stops fixed: a latched sign survives the light clearing, and the stop target can't jump farther away.

2. Sign over a long road: a sign can latch even when the model sees far ahead, once Force Stop is already slowing.

3. Vision distance: sign distance comes from the box size, averaged with the dashboard's 27 m.

4. Sign behind a car: if you're stopped behind a car at a sign, it stops at the line once that car leaves.

5. False early sign: an early dashboard sign is ignored if stopping for it would need hard braking.
2026-11-09 11:01:43 -06:00
firestar5683 e1158eb847 Require cruise for automatic lane changes 2026-11-09 10:28:56 -06:00
firestar5683 e2a3802722 Preserve manual tuning descriptions while parked 2026-11-09 16:05:56 +00:00
firestar5683 e0f8b7a118 Preserve Panda power-save state during CAN error recovery 2026-11-09 10:03:08 -06:00
firestar5683 febb4ebdb2 Isolate Model Runtime Caches By User 2026-11-09 15:45:03 +00:00
Dom c6cdc7fa73 Keep FLM proposal regressions within the test file size gate 2026-11-09 03:48:01 -06:00
firestar5683 a9427aba54 Use consistent score units for automatic lateral proposals 2026-11-09 08:52:59 +00:00
firestar5683 13f914a54f Include short turns in automatic lateral proposals 2026-11-09 07:45:00 +00:00
firestar5683 adcf6ca32d Keep all vision worker threads on their owned cores 2026-11-09 00:55:01 -06:00
firestar5683 cf916938dd Retry transient Actionlint download failures 2026-11-09 00:31:41 -06:00
firestar5683 1272c27b8d Restore source-bound classic HKG advisory forwarding 2026-11-09 00:23:41 -06:00
firestar5683 f711833ba1 Preserve classic HKG lateral intent through stock pedal source gaps 2026-11-09 00:07:04 -06:00
Dom 68305fa238 Separate coordinate proposal tests and fix assertion spelling 2026-11-08 23:49:59 -06:00
firestar5683 fc1bdaae7d Consider all thirty lateral knobs from route symptoms 2026-11-09 05:29:58 +00:00
Dom 1b797b7359 Preserve Experimental preference during stock ACC fallback
Keep the full-time Experimental choice across temporary startup and cached UI longitudinal unavailability. Runtime authority and SafeMode checks are unchanged.

Cover stock-to-qualified restart, both native settings layouts, unsupported runtime states and explicit mode changes.
2026-11-08 21:29:22 -06:00
Dom 795799f4ab Split offroad native-output tests below source size limit 2026-11-08 21:14:49 -06:00
firestar5683 aae3767d89 Replay unchanged native limits before judging a learned tune 2026-11-09 02:24:36 +00:00
firestar5683 22308dac24 Restore selfdrived executable mode 2026-11-08 18:49:06 -06:00
firestar5683 2cbbbca5e2 Preserve MQB lateral intent through crawl and brake joins 2026-11-08 18:42:31 -06:00
firestar5683 8525214a37 Select feasible steering recommendations before validation 2026-11-09 00:19:14 +00:00
firestar5683 4f78a28b2e Keep CI fixtures aligned with actual controller configuration 2026-11-08 17:37:57 -06:00
firestar5683 f4d3b646f4 Enable openpilot explicitly in FLM source-renewal fixture 2026-11-08 16:36:44 -06:00
firestar5683 496e72ed00 Repair inherited CI contracts and build matching Panda artifacts 2026-11-08 16:08:45 -06:00
firestar5683 9be62987b5 Avoid shadowing Panda heartbeat state in MQB AOL 2026-11-08 15:55:59 -06:00
firestar5683 d39ea5382c Prevent false Ford recovery releases and steering refusals 2026-11-08 15:39:55 -06:00
firestar5683 349dde99db Fit actuator-visible steering recommendations before validation 2026-11-08 21:17:25 +00:00
firestar5683 0c9037b243 Cover first-press MQB guards in unittest safety discovery 2026-11-08 15:17:14 -06:00
firestar5683 8626158c5d Restore the rolling stopped-lead guard for MQB Alpha Long 2026-11-08 15:15:13 -06:00
firestar5683 0ae8a445bf Smooth Turn Assist transitions around straight steering requests 2026-11-08 15:11:32 -06:00
firestar5683 746e0da673 Build matching Panda firmware for first-press MQB engagement 2026-11-08 15:06:08 -06:00
firestar5683 1d5508b19d Accept the first MQB SET or RES before the host joins 2026-11-08 15:02:23 -06:00
firestar5683 1e0e06eef4 Preserve Subaru AOL intent through EPS brake event joins 2026-11-08 15:02:04 -06:00
firestar5683 c22785a22f Yield HKG direct-angle fault recovery until driver release 2026-11-08 14:53:53 -06:00
firestar5683 0c37a00b1c Preserve validated AOL metadata during source gaps 2026-11-08 14:45:53 -06:00
firestar5683 495fa11cf6 Keep HKG AOL armed when brake health precedes permission updates 2026-11-08 14:29:17 -06:00
firestar5683 27cc914f54 Track small Subaru Crosstrek angle requests at low speed 2026-11-08 14:25:59 -06:00
firestar5683 52775f1f54 Keep GM AOL armed below the cruise engagement minimum 2026-11-08 14:15:30 -06:00
Dom 9251f9b918 Place C4 startup date beside version and branch 2026-11-08 13:15:14 -06:00
firestar5683 3bdcfe7f52 Build matching Tesla and Palisade AOL firmware 2026-11-08 13:14:14 -06:00
firestar5683 9bbf79766a Preserve Tesla screen intent and Palisade Cancel behavior 2026-11-08 00:08:13 -06:00
firestar5683 a9a80d34e7 Build matching Polestar status relay firmware 2026-11-07 18:02:56 -06:00
firestar5683 3093e5b799 Preserve Polestar stock status relay 2026-11-07 17:58:51 -06:00
firestar5683 f633c05b32 Gate Ioniq 6 blind-spot warnings on queued lane changes 2026-11-07 17:33:23 -06:00
firestar5683 1d30dc3434 Align HKG default-controller regression expectations 2026-11-06 22:18:55 -06:00
firestar5683 e1e3be94fe Enable driving in learning regression fixtures 2026-11-06 21:46:34 -06:00
firestar5683 31695937b3 Fix inherited static-analysis failures 2026-11-06 21:35:36 -06:00
firestar5683 7056dcb1fa Preserve HKG angle arming through temporary refusals 2026-11-06 21:23:09 -06:00
firestar5683 68434cf39c Build matching Outback state-zero stop-start firmware 2026-11-06 19:55:36 -06:00
firestar5683 5d6e6afc3e Allow bounded Outback stop-start OFF requests from state zero 2026-11-06 19:48:56 -06:00
Dom 453aa6e0f7 Show C3 recording-drive capacity and available storage in Galaxy
Keep internal storage and the separately mounted recording drive distinct.
Gate added-drive telemetry and both views to C3, recheck filesystem identity,
and report available bytes without counting reserved space as free.

Cover absent drives, mount transitions, read failures, zero free space,
C3X/C4 exclusion, and actual Vue rendering. Refresh the inventory receipt
for JavaScript test-only protected changes; native artifacts are unchanged.
2026-11-06 19:33:47 -06:00
firestar5683 e959f52e25 Display bounded recorded steering graphs without rejecting the report 2026-11-06 19:29:42 +00:00
Dom af342611ef Merge pull request #281 from dirwin31/Starport-Log-Refusal-Bytes
Add Logging for Screen Failure Response
2026-11-06 13:20:23 -06:00
Danny c9dd2b7339 Add Logging for Screen Failure Response 2026-11-06 10:57:58 -08:00
firestar5683 be0f2cf7ba Use original metadata identities for independent route profiles 2026-11-06 18:56:19 +00:00
firestar5683 9fcecdec2a Restore installed commit date on C4 home screen 2026-11-06 12:31:03 -06:00
firestar5683 9af9f09071 Keep completed-drive windows distinct and retain measured results on failure 2026-11-06 17:39:43 +00:00
firestar5683 359cb9fda6 Add offroad alert volume previews to Galaxy 2026-11-06 11:26:19 -06:00
firestar5683 4f5cf21e9e Keep STOP vision and blind-spot inference on separate cores 2026-11-06 11:16:35 -06:00
firestar5683 dae68ce494 Explain FLM slider effects in Galaxy 2026-11-06 11:04:08 -06:00
firestar5683 52852af602 Preserve stock lateral intent during inhibited longitudinal recovery 2026-11-06 10:47:54 -06:00
firestar5683 7816b003b5 Refresh parked evidence before resuming route analysis 2026-11-06 16:16:53 +00:00
firestar5683 6e0baac46b Keep parked sessions from consuming untested steering tunes 2026-11-06 15:25:57 +00:00
Dom 9a55221ab5 Fix authenticated monitoring test fixtures and CI coverage 2026-11-06 08:38:57 -06:00
firestar5683 b4c3c5d656 Validate independent-drive Recommended profiles before automatic application 2026-11-06 14:34:42 +00:00
firestar5683 d64fb74c31 Preserve authenticated driver monitoring artifacts during builds 2026-11-06 07:46:10 -06:00
firestar5683 a2c96c47d6 Fix route-profile selection, replay verification and recommendation reporting 2026-11-06 12:45:38 +00:00
firestar5683 4e40bff081 Reject conflicting speed reads from expanded crops 2026-11-06 06:03:57 -06:00
firestar5683 97a5e15cc1 Validate offroad tune strength against an independent drive 2026-11-06 06:46:12 +00:00
firestar5683 b29a2ea4f8 Fit offroad tune strength and show completed-drive measurements 2026-11-06 05:19:36 +00:00
firestar5683 7f4e2ca589 Fix completed-drive aggregation and fresh parked result presentation 2026-11-06 03:44:15 +00:00
firestar5683 5d498ccdb9 Restore full-drive tuning recommendations and expose offroad decisions 2026-11-06 02:44:40 +00:00
Dom 7117b85680 Merge pull request #280 from zephleggett/jetlink-v0.9.0
Jetlink v0.9.0: API 4 and Jetlink's own model index
2026-11-05 20:41:37 -06:00
Dom 376d0f5df3 Qualify exact offroad candidates in cloud without temporary branches 2026-11-05 20:16:58 -06:00
firestar5683 3637b23b1c Avoid manual resume alerts for automatic Toyota launches 2026-11-05 20:10:51 -06:00
firestar5683 dc73f431a4 Preserve independent GM steering across speed and input transitions 2026-11-05 19:50:48 -06:00
firestar5683 3160182986 Use received car state for blindspot alert freshness 2026-11-05 19:44:40 -06:00
firestar5683 c0b6b82497 Restore Tesla AP1 Alpha Long cruise engagement 2026-11-05 19:40:53 -06:00
Zeph Leggett 37024b2f3d Jetlink: API 4, choices sized by Jetlink's model index
Jetlink v0.9.0 keeps its own index of comma's big models and the ONNX
each was built from, checked hourly. Active Big's choice, by the ONNX's
hash, is sized from it rather than from comma's LFS, so JetlinkPointers
is no longer read and is dropped.

JetlinkPointers leaves params_keys.h: the native params libraries need
their usual rebuild.
2026-11-05 19:37:27 -06:00
Zeph Leggett 76cb6f414c Jetlink v0.9.0 2026-11-05 19:37:27 -06:00
firestar5683 2b83873371 Add AGNOS 20.2.2 east mirror 2026-11-05 15:34:45 -06:00
firestar5683 e2a417ab1f AGNOS 20.2.2 2026-11-05 15:33:17 -06:00
firestar5683 83ecf50c3b Improve STOP recall while preserving speed detector features 2026-11-05 14:57:38 -06:00
firestar5683 790d9d2a5b Publish matching Dom precompiled artifacts 2026-11-05 14:51:20 -06:00
firestar5683 479da9e7d5 Restore automatic stopped launch for qualified Toyota SDSU profiles 2026-11-05 14:39:49 -06:00
Dom 63137021a1 Align schema and capability fixtures and StarPort types 2026-11-05 14:29:57 -06:00
firestar5683 8aa299daf8 Improve shared traffic-sign features while preserving speed reads 2026-11-05 14:27:57 -06:00
firestar5683 505495a936 Scope offroad checks to all learner and integrated controller source 2026-11-05 14:16:44 -06:00
firestar5683 fa0fd7b57e Add optional onroad weather widget 2026-11-05 14:12:15 -06:00
firestar5683 37df49d937 Check offroad learner contracts independently of unrelated pushes 2026-11-05 14:11:32 -06:00
firestar5683 a7b683d362 Preserve GM ACC startup and routing with blind-spot monitoring 2026-11-05 14:04:50 -06:00
firestar5683 1383968390 Default vehicle-specific tunes to the StarPilot controller 2026-11-05 13:58:13 -06:00
Dom 708dfa8ad5 Restore selfdrived executable mode 2026-11-05 13:50:03 -06:00
firestar5683 4b9e2ada7d Fix offroad learner report types and hosted static checks 2026-11-05 13:46:52 -06:00
prabhaavp 8b1e3066f4 Merge pull request #279 from dirwin31:MavlouPhoneIdentity
Star Port Dongle Compatibility
2026-11-05 14:42:56 -05:00
Prabhaav Pillai 55eae09ad4 Refactor handshake logic and improve comments for clarity 2026-11-05 14:41:46 -05:00
firestar5683 1301d6a566 Build Panda firmware for MQB brake-held engagement 2026-11-05 13:40:31 -06:00
firestar5683 9d3579cbbb Preserve stopped MQB brake-held engagement until brake release 2026-11-05 13:33:58 -06:00
firestar5683 34e23d945c Analyze completed drives and apply bounded next-drive steering tunes 2026-11-05 13:28:41 -06:00
firestar5683 52dcc22a1c Wrap MQB stop regression diagnostics 2026-11-05 13:23:04 -06:00
firestar5683 2e614b456b Pause MQB steering requests below the actuation speed limit 2026-11-05 13:22:11 -06:00
firestar5683 83e86fe022 Restore Subaru steering override reporting 2026-11-05 12:55:17 -06:00
firestar5683 ced757efe2 Distinguish Crosstrek wheel load from steering override 2026-11-05 12:38:13 -06:00
Danny b9d6539aa7 StarPort: Reset handshake forgets the remembered reply 2026-11-05 10:35:00 -08:00
dirwin31 64bc4e9ca2 StarPort: Setup ladder keeps what worked and skips rejected replies 2026-11-05 10:35:00 -08:00
Danny ad47e77a93 AAwireless: Account for Failed DHCP 2026-11-05 10:34:59 -08:00
Danny e96ab43d4c StarPort: Add a phone identity handshake step for unofficial dongles 2026-11-05 10:34:44 -08:00
prabhaavp 0cc9ddddfe Merge pull request #278 from dirwin31:Dom-UI-Maps-Fixes
UI/Maps: lane guidance, map polish. Hotspot and Wi-Fi fixes
2026-11-05 13:16:20 -05:00
Prabhaav Pillai aa631c8e9f backoff logic and improve widget art handling 2026-11-05 13:15:42 -05:00
firestar5683 5c9eeb707a Queue model selections for the next onroad cycle 2026-11-05 12:03:41 -06:00
firestar5683 faf81bb461 Separate stacked speed-limit panels before reading numbers 2026-11-05 11:53:09 -06:00
firestar5683 ac2b90145f Select Continental radar automatically on supported RAV4 profiles 2026-11-05 11:41:20 -06:00
Dom fb8d2c73c6 Align vision and Sierra CI fixtures with existing contracts 2026-11-05 11:05:18 -06:00
firestar5683 19943c7080 Enable vision STOP detection independently of SLC 2026-11-05 10:34:35 -06:00
firestar5683 90ee6b3686 Restore Sierra no-ACC conventional cruise layout 2026-11-05 10:03:48 -06:00
firestar5683 8d9bc972d3 Restore the default Carnival torque controller 2026-11-05 08:51:12 -06:00
firestar5683 d96d023746 Improve night stop detection and reject advisory speed signs 2026-11-05 00:51:32 -06:00
firestar5683 3b6abac814 Keep vision stop updates independent of speed-limit readings 2026-11-04 23:57:40 -06:00
firestar5683 18dc7949aa Register Ford ordering regression in complete CI inventory 2026-11-04 23:01:48 -06:00
firestar5683 6d5624f564 Order StarPilot Ford metadata before same-cycle steering 2026-11-04 22:45:29 -06:00
Dom c08b3249b6 Align Subaru engagement test with transmitted steering history 2026-11-04 19:15:52 -06:00
Dom cb707259be Refresh inventory receipt for schema qualification tests 2026-11-04 18:07:28 -06:00
Dom 49ed51a48f Qualify reviewed vision stop-sign schema addition 2026-11-04 17:53:54 -06:00
Dom e30016c008 Restore familiar torque arc colors and label 2026-11-04 17:25:53 -06:00
firestar5683 c8296b3bd8 Keep Turn Assist helping through braking turns 2026-11-04 17:17:54 -06:00
firestar5683 0571890b8e Integrate confirmed vision stop signs with Force Stop 2026-11-04 17:07:50 -06:00
firestar5683 064a3afb6d Correct steering demand display and add bounded Card timing 2026-11-04 16:49:57 -06:00
firestar5683 95bf946844 Keep Subaru VM steering handoff within native rate limits 2026-11-04 16:47:02 -06:00
Dom 761a47bc69 Provide pinned OpenCV DNN for unit vision contracts 2026-11-04 15:45:44 -06:00
firestar5683 3eb7f3fe29 Preserve Ford lateral intent through initial steering readiness 2026-11-04 15:33:07 -06:00
firestar5683 88566f1887 Add a STOP-capable joint traffic sign model 2026-11-04 14:40:28 -06:00
firestar5683 4b8ca5c379 Match StarPilot Ford demand to native pinion bounds 2026-11-04 14:20:17 -06:00
firestar5683 db1aaa67a2 Show supported years for the Elantra Hybrid refresh 2026-11-04 14:18:18 -06:00
firestar5683 a15b58fc2e Block unresolved Elantra CAN interrupt faults 2026-11-04 13:43:55 -06:00
Dom 3cb38bb9e0 Safely stage the audited legacy StarPilot return 2026-11-04 13:39:59 -06:00
firestar5683 a43e74d7f6 Preserve lateral on the first CANCEL health handoff 2026-11-04 11:28:20 -06:00
firestar5683 29bf269ccc Arm classic HKG lateral after accelerator override clears 2026-11-04 11:17:39 -06:00
Danny aab624393e Cleanup: Fix lint, trim comments, narrow error handling 2026-11-04 08:41:19 -08:00
firestar5683 73d805fafe Coordinate lane-centering nudges with planned turns 2026-11-04 10:26:26 -06:00
dirwin31 0cf1f4f3bb StarPort: rebuild aa0 after NetworkManager restarts, hold home Wi-Fi while projecting
- A NetworkManager restart left the dedicated lease on stale device paths,
  and the rest of the drive fell back to wlan0. A non-timeout aa0 failure
  now rebuilds aa0 once before falling back.
- DedicatedLease lacked `dedicated`, so SharedRadioWatch (and the ACK grace
  for home Wi-Fi changes) never ran.
- When wlan0 loses its network mid-projection, pause its autoconnect (and
  stop a join under way) until the session ends. Each retry took the shared
  radio off the car's channel and stalled video past the ACK timeout.
2026-11-04 08:07:17 -08:00
dirwin31 1bb9e9fc0e Galaxy hotspot: ship hostapd and keep NetworkManager off galaxy0
AGNOS 20.x no longer includes hostapd (agnos-builder 0cf5062), so every
start failed and the worker created and deleted galaxy0 every 15 s. On
2026-11-04 that churn crashed NetworkManager mid-drive and dropped StarPort.

- Ship Ubuntu 24.04's hostapd 2.10 and hostapd_cli (arm64, checksums in
  bin/hostapd.sha256) and run those.
- Check for hostapd before galaxy0 exists.
- Back off repeated failures from 15 s up to 5 min (DFS refusals exempt).
- Write a runtime udev rule (NM_UNMANAGED) before creating galaxy0, and wait
  for wpa_supplicant to let go of it before starting hostapd. Its release
  stopped the AP that hostapd had just started.
2026-11-04 08:07:17 -08:00
Danny 9b377b16f6 Cleanup: Trim comments, drop swallowed errors, fix glow test 2026-11-04 08:07:17 -08:00
dirwin31 f001d80378 Theme maker: allow up to 10 widget art sprites
The compact profile renders 8 sprites and the browser rejected anything over 7
as invalid metadata, leaving only the sketch outlines.
2026-11-04 08:07:17 -08:00
dirwin31 3edf46a991 UI: curve-speed glow, gas-override blink and Theme maker preview
- Steady the curve-speed glow and the gas-override blink on the on-road screen
- Stop the Theme maker preview flapping to "unavailable" and allow for Galaxy tunnel latency
- Carry the widget sprite sheet in a header so it survives the tunnel
2026-11-04 08:07:17 -08:00
dirwin31 54939bf311 Spotify: retry a saved sign-in that failed at startup
- Retry a saved sign-in when the first attempt at boot fails, for example before the clock has synced
- Let the supervisor restart a player that never logged in
- Add connect tests for the retry
2026-11-04 08:07:17 -08:00
dirwin31 11a65c1010 Maps: lane guidance, calmer rotation and steadier navigation layout
- Add lane guidance to the on-road navigation banner, fed by new route engine fields in the custom cereal schema
- Calm map rotation and steady the navigation layout, including the Galaxy layout preview
- Add route runtime, on-road map and navigation tests
2026-11-04 08:07:17 -08:00
dirwin31 1334ce24a5 StarPort: draw the default layer order when none is saved
- Use StarPort's default widget layer order in the car projection when no widgetOrder is saved, so the map no longer draws over the PiP
- Make the layout editor and the car screen agree on that order
- Cover the default order in the projection layout and widget layer tests
2026-11-04 08:07:17 -08:00
Dom 46a872b02f Synchronize sender fixture and narrow startup test callable
Hold the mock background upload until in-flight assertions finish and join it on every exit. Preserve strict completion and payload checks.

Type the dynamically populated startup fixture namespace as object and assert its main entry is callable before invocation. Preserve startup ordering assertions and refresh the canonical protected inventory receipt.
2026-11-04 09:32:22 -06:00
firestar5683 d907c22ac0 Bind each Android Auto startup fixture to its device 2026-11-04 09:12:53 -06:00
Dom 13930a89e5 Share personality alert preference with Android Auto 2026-11-04 09:10:06 -06:00
firestar5683 7c0d1d505a Prioritize driving and audio over Android Auto on every comma 2026-11-04 09:07:25 -06:00
firestar5683 ea27a69c20 Keep Android Auto transport below driving and audio on comma 4 2026-11-04 09:06:20 -06:00
firestar5683 f45fd8ed79 Keep comma 4 display work below driving and audio 2026-11-04 08:43:09 -06:00
firestar5683 1509b5e6b6 Distinguish legacy Toyota radar pre-warning from unavailability 2026-11-04 08:18:37 -06:00
firestar5683 a17e2a963d Preserve HKG lateral through CANCEL status handoffs 2026-11-04 08:01:00 -06:00
Dom c6484123d5 Keep replay provenance manifest within existing size gate
Reduce leading JSON indentation only, preserving every token, value, key and list order, reviewed reference binary and provenance pin. Keep the unchanged 120 KiB lint limit and all validation gates.

Refresh the canonical protected inventory receipt for the formatting-only file change.
2026-11-04 03:23:25 -06:00
Dom aa6207cf90 Qualify replay references and complete hosted CI coverage
Pin seven independently reviewed replay outputs to the unchanged recorded inputs and baseline provenance. Correct Subaru test helper cleanup without changing native admission rules. Declare the exact generated pandad test executable in the build-output inventory.

Run the unchanged whole-target RELEASE workload in sixteen mandatory shards, retaining all cases, assertions, skip and provenance gates, and existing deadlines. Refresh the canonical protected inventory receipt.

Native qualification completed 10,211 passed and 115 existing skips across all sixteen bins; fresh source-bound hosted CI remains required.
2026-11-04 02:23:57 -06:00
Dom 9eab5400a4 Repair hosted display selection and migrated test fixtures
Select the real headless backend for hosted AOL and RELEASE tests. Isolate SpacePilot scripted clocks from unrelated UI readers. Update camera and replay fixtures for existing native APIs, pin the reviewed schema addition, and supply declared CAN batch and throttle inputs.

Preserve production guards, acceptance assertions, selected cases, capability skips and receipt gating. Refresh the canonical protected inventory receipt.
2026-11-04 00:50:22 -06:00
Dom baf883200c Restore selfdrived executable mode after Toyota source update
Preserve the exact published Toyota source blob and restore its prior100755 executable mode. Refresh the canonical protected inventory receipt. No source semantics or test expectations change.
2026-11-03 23:25:25 -06:00
firestar5683 512742ca42 Keep cold Toyota AOL source waits distinct from lost control 2026-11-03 23:08:10 -06:00
firestar5683 1e4fe7c8a4 Damp EV6 near-center steering while preserving turn-in 2026-11-03 22:28:41 -06:00
firestar5683 9b7e8fa5fa Preserve armed HKG lateral through button counter noise 2026-11-03 22:20:04 -06:00
firestar5683 2496b40f84 Preserve armed HKG lateral through button counter noise 2026-11-03 22:02:51 -06:00
Dom 67fde8c695 Refresh audited graphics fixture identities after native binding migration
Re-audit only the GPU/map source and shared fixture fingerprints following the native binding migration. Preserve the exact capability guard, reason, selected nodes and unavailable disposition. Add a real-source fingerprint regression so mocked gate fixtures cannot hide stale source pins.
2026-11-03 21:38:16 -06:00
firestar5683 14534287c1 Detect C-HR CAN filters alongside native radar traffic 2026-11-03 20:22:32 -06:00
Dom 2929a25afe Add parked manual editing for thirty steering coordinates 2026-11-04 02:14:49 +00:00
firestar5683 465db1bc61 Preserve Ford lateral intent across separate brake status receipts 2026-11-03 19:55:37 -06:00
firestar5683 89af8a6569 Preserve Subaru intent through stop and source handoffs 2026-11-03 18:30:19 -06:00
Dom dd4214f8f5 Restore direct LAN DNS entry to Galaxy safely
Discover exact OS/DHCP/PTR identities independently of request Host. Redirect arbitrary aliases only at the direct-local inert landing document, preserving Host, Origin, session and remote boundaries. Refresh the protected-inventory receipt for the documentation-only protected change; native inputs are unchanged.
2026-11-03 17:03:01 -06:00
firestar5683 180f94dc35 Recover firmware reads interrupted by unrelated diagnostics 2026-11-03 16:02:27 -06:00
prabhaavp e997847db0 Merge pull request #275 from dirwin31/CarViewFontFix
StarPort: Missing Fonts
2026-11-03 16:44:04 -05:00
Danny 228534e0cf StarPort: Load fonts in the car screen and log full process commands 2026-11-03 13:37:46 -08:00
firestarsdog 5e02c2387a Arabesque time harden 2026-11-03 16:20:25 -05:00
Dom e6f1ad93ee Exercise native timer and interrupt health in siren harness 2026-11-03 14:43:02 -06:00
Dom 5f2caf8249 Align loader affinity fixture with captured camera dimensions 2026-11-03 14:24:38 -06:00
firestar5683 7b16c71824 Recheck live tuning against the accepted baseline 2026-11-03 13:21:03 -06:00
firestarsdog e381bf6a44 Check offline Firefox against AGNOS 2026-11-03 14:07:29 -05:00
firestarsdog c7b81a5768 More offline Arabesque stuff 2026-11-03 13:41:15 -05:00
firestar5683 7920339796 Prevent same-bus HKG advisory packet retransmission 2026-11-03 12:33:24 -06:00
Dom fc45eb6862 Style Polaris model cards by series 2026-11-03 12:06:50 -06:00
firestar5683 b302c2ef8f Avoid repeated CCNC longitudinal configuration clones 2026-11-03 11:53:35 -06:00
firestar5683 4d804768ab Keep conditional experimental mode independent of planner policy 2026-11-03 11:49:49 -06:00
firestarsdog 9b7f4c6646 Arabesque, include offline depends for install 2026-11-03 12:33:55 -05:00
firestar5683 14205db69d Reset HKG angle recovery gain and rejected command history 2026-11-03 11:33:09 -06:00
firestar5683 4cf5c9e098 Keep Subaru fixed-angle handoff within native rate limits 2026-11-03 11:25:13 -06:00
firestar5683 bf4e27fca5 Build matching Carnival independent lateral and longitudinal firmware 2026-11-03 11:12:56 -06:00
firestar5683 d42ddc3ac5 Restore Carnival ICE independent lateral and longitudinal startup 2026-11-03 11:01:08 -06:00
firestar5683 55d6fb1778 Continue learning after restoring a saved tune 2026-11-03 10:53:50 -06:00
prabhaavp 91dad8b66d Merge pull request #273 from dirwin31:StarPort-AutoSetup-20.2
StarPort: guided setup, a setup ladder, and wireless adapter fixes
2026-11-03 11:22:05 -05:00
Prabhaav Pillai 047294a1d0 Merge Dom into StarPort 2026-11-03 11:19:55 -05:00
firestar5683 a336e65dd8 Improve Tuning and Analysis controls and plot tabs 2026-11-03 10:15:16 -06:00
firestar5683 dd9608eecd Preserve Subaru lateral on fresh input after output expiry 2026-11-03 10:09:06 -06:00
Prabhaav Pillai 123e3c20a0 StarPort setup, reconnect handling, and UI consistency 2026-11-03 10:58:57 -05:00
whoisdomi 80975369ee Force Stop: add dashboard signal back 2026-11-03 09:18:24 -06:00
whoisdomi 7ec8cc721d Hold CEM for cross traffic leads 2026-11-03 09:18:24 -06:00
whoisdomi 1d2d18fa2a Force Stop Temp Hold 2026-11-03 09:18:23 -06:00
firestar5683 42a1244e1b Show retained Auto Learn study progress 2026-11-03 08:28:22 -06:00
Danny f148770788 Build 2026-11-03 05:40:06 -08:00
Danny 755d82a7e6 UI Unification 2026-11-03 05:40:06 -08:00
Danny 7d7bf43fac Account for Starport on Dev Page 2026-11-03 05:40:06 -08:00
Danny 63b05a9294 Fix test for AA wireless 2026-11-03 05:40:06 -08:00
Danny 80f707aa3d StarPort: Review fixes for the stall notice, car forget and car adoption
Reuse atomic_write and the sr-only class, drop dead code, update stale tests
2026-11-03 05:40:06 -08:00
Danny 6d99039382 Test Fixes 2026-11-03 05:40:06 -08:00
Danny cfdcd14ca8 Show car vs app in pairing 2026-11-03 05:40:06 -08:00
Danny 5be95f48df AAwireless: Stop tearing the Session down every 60s 2026-11-03 05:40:06 -08:00
Danny fb9b3516b1 Allow Disconnect from Galaxy for Starport Projecting Car 2026-11-03 05:40:06 -08:00
Danny 15b2612d7f Hold RFComm for a moment, Better Traces. 2026-11-03 05:40:06 -08:00
dirwin31 e99e552f21 StarPort: wait out the comma's own Wi-Fi switching networks, and say so on the car screen
- When the comma's Wi-Fi joins or roams while the car is on aa0, wait up to 8 s for video acks instead of 2.5 s; reconnect at once if the car takes its screen back.
- After a drop it causes, the car screen shows "Reconnected: comma Wi-Fi Joined <network>" (the network's SSID) for 8 s; Galaxy and bug reports name it too.
- Logs ack_grace, ack_grace_recovered and other_wifi_changed events.
2026-11-03 05:40:06 -08:00
dirwin31 f0c5a666c5 Galaxy Bluetooth setup: one systemd reload, after the radio has started
- Link the bridge and discovery units with --no-reload and reload once (was four reloads, 1-3 s each on a comma four).
- Wait (up to 45 s after boot) while starpilot-bluetooth-radio is still activating, so the reload can't stall btattach or bluetoothd.
- Failure handling unchanged; on a comma four this removed the ~20 s Bluetooth boots (now 16.4-16.6 s with stock AGNOS).
2026-11-03 05:40:06 -08:00
dirwin31 3b8d201839 Hide other devices once selected one 2026-11-03 05:40:06 -08:00
dirwin31 067f30dfa4 Bring the purple back to the guided setup's selections
The selected adapter or app segment is solid purple with a soft glow instead
of grey, the selected connection option gets a purple-tinted row and a filled
icon tile, the current step and the primary button glow, and a faint purple
wash sits behind the step track.
2026-11-03 05:40:05 -08:00
dirwin31 1d0525fe41 Keep the guided card open after choosing how the car connects
Choosing a USB cable or a tablet app switches the connection at once, which
counts as setup done, so the card closed under the person's finger and left
no way to change the choice. A choice made in the card now holds it open
until Done. In the guided setup the home card's button reads Change how your
car connects, so the way back is visible.
2026-11-03 05:40:05 -08:00
dirwin31 363d942ec9 Theme the guided setup with Galaxy's own colors, font and icons
Done steps and checks use the theme's success color, the current step and
selection use the primary purple, text uses Galaxy's Inter and weight tokens,
and the connection options use the outline icons the rest of the page uses
(car-front, usb-symbol, usb-c, tablet) on a single primary-tinted tile
instead of Apple's multicolour gradients.
2026-11-03 05:40:05 -08:00
dirwin31 b21952a392 Reuse the existing StarPort list, row and disclosure styles in the guided card
The guided card's lists, paired-car rows, checklist rows and handshake
disclosure now use the page's own gx-starport-list, gx-starport-row,
gx-starport-row__text and gx-starport-wiki classes, restyled once for the
card, instead of parallel guided-only copies. Radii, spacing and motion use
Galaxy's tokens. Only the step track, icon tiles, radio rows, segmented
control and checklist marks remain new, and the block is about a fifth
shorter.
2026-11-03 05:40:05 -08:00
dirwin31 2678e29cb3 Redesign the guided StarPort setup; keep the dropdowns to the classic screens
The guided card is rebuilt in an Apple-style: a three-step track (support,
car, connect) that fills as setup moves along, a large title, inset grouped
lists with coloured icon tiles for how the car connects, a segmented control
for the adapter model and the tablet app, iOS-style checklist rows for the
connection, and one full-width primary button. It uses Galaxy's theme colors
so light and dark both work, and reads cleanly down to a phone.

The classic step 2 and the home card's dropdowns now render only in the
classic setup. The guided card replaces them and hosts the page's own
pairing, cable and app instructions and the Connect button, so nothing is
lost. A finished setup can be reopened from Setup and Pairing, and Done
closes it. Paired cars the page's own list does not show yet are offered in
the card; the rest stay in the pairing step.
2026-11-03 05:40:05 -08:00
dirwin31 75a29d3e71 Put the full report in the logs bundle; turn the Bluetooth-first refusal into a step
The logs bundle, and so the Discord send, now carries report.json: the same
tags and per-session detail a Bugsink report has (setup, offered modes, reach
class, ladder, SDP, strategy) for the newest five sessions, sanitized and
size-capped. It is built best-effort: if it cannot be, the bundle is
unaffected.

The adapter setup's 'Connect your paired car in Galaxy Bluetooth first'
refusal (27 Bugsink reports) now shows a step with a link to Bluetooth, next
to the usual question while setup is unfinished and alone once it is done.
2026-11-03 05:40:05 -08:00
dirwin31 e5a981b5ad Ladder passes are per ignition; car adoption leaves the owner's audio setup alone
The comma can stay powered between drives, so the three passes belong to an
ignition (an onroad start), not to a boot; a reboot still resets them. When a
learned strategy is dropped after two handshake failures the ladder carries
on from the next rung instead of starting at the one that just failed, which
the dongle flip also avoided.

Choosing the only paired Android Auto car from housekeeping runs with nobody
at the screen, and selecting a receiver unselects that device as the
Bluetooth audio output. It now skips a car that is the selected audio device,
or when that cannot be read, leaves it to the setup screen's Use button. It
reports problems once instead of every 20 s and gives the projection role
back, keeping only the standby gateway auto-connect needs.
2026-11-03 05:40:05 -08:00
dirwin31 54eab0df8d Replay the real Bugsink failure texts through the reachability classifier; tag the reply style
The 686 pre-video failures Bugsink showed (54 distinct texts) are kept as a
fixture. The test checks the intended class for each: Host is down and page
timeouts are a sleeping car, refused and unanswered handshakes are not ready,
and anything after the link is up is not a reachability problem. Reports tag
the reply style and Wi-Fi band the working strategy used.
2026-11-03 05:40:05 -08:00
dirwin31 99a9025afe Report how the channel was found and what the ladder tried; wait longer for a slow car
Reports carry the SDP outcome (where the RFCOMM channel came from and how
many retries it took). The Discord post names the handshake options tried
and how each ended. A car that once needed 70 s to be ready now gets that
many stage timeouts to answer the Wi-Fi handshake, never fewer than the
standard wait and capped at nine.
2026-11-03 05:40:05 -08:00
dirwin31 0783710c9a Add the guided StarPort setup behind the New pairing experience switch
A card above the classic steps asks one question (how does the car connect:
built-in wireless, adapter, USB cable or app), shows only the instructions
for that answer, and lists paired cars that offer Android Auto to choose
between when there is more than one. While connecting it shows a live
checklist of the stages (Bluetooth, the car's Wi-Fi, securing the connection,
the dashboard), a list of the handshake options being tried once one has
failed or one is remembered, and a notice that the first connection can take
a few minutes or more than one drive. Every action is one the page already had.

The card is on by default; Use classic setup turns the switch off, and Try
the guided setup turns it back on. Returning drivers see the card only while
a connection is in progress. The state function is pure and tested without a
browser.
2026-11-03 05:40:05 -08:00
dirwin31 3d26454122 Let Galaxy read and change the new pairing experience switch
The setup response carries newSetup, and a new_setup control action turns the
guided setup off or on. It is handled before the StarPort-enabled check and
never calls the daemon, so it works as the way back to the classic screens
when the service is off or broken. It needs a signed-in session and park.
2026-11-03 05:40:05 -08:00
dirwin31 57900f3d73 Add the setup ladder: try handshakes in order, remember the one that worked
One module now decides how to answer a car's handshake, and it is also the
learned profile: the strategy that last worked is the one tried first.

The first pass is today's behavior. Later rungs are knobs that already
existed: the other version reply (the flip the dongle work added, now owned
here), the phone-style reply with both bands and with 2.4 GHz only, a longer
wait for a car that keeps saying not ready, and, for adapters only, the
handshake variations.

A failure advances the ladder only when the car answered the version
exchange, then went silent or refused the Wi-Fi setup, before any video was
acknowledged in the session. A sleeping car, this comma's own Bluetooth
trouble and anything after video (a car shutting off) never move it. Three
passes per boot at most, resuming at the first untried rung on the next
ignition; a learned strategy is dropped after two handshake failures; a
session that streams for a minute makes its strategy the learned one.

State is a small per-car file beside the config (setup_state.json, newest 16
cars, damaged or unreadable means a fresh start, never blocks a connection).
The status exposes the ladder for the setup screen, and reports carry what
was tried, how each rung ended and how long the car took to be ready.
2026-11-03 05:40:05 -08:00
dirwin31 2af7bdd1dd Add opt-in H.265 for a head unit that offers no H.264 mode
Allow H.265 video (Developer > StarPort, off by default) lets a car that
offers only H.265 get H.265. A car with an H.264 mode is chosen exactly as
before whatever the setting; this is replay-tested over several offers.

The hardware encoder library is ABI 4: an HEVC option selects H.265 Main
profile at high-tier level 5 and allows up to 1920x1080 (H.264 stays at
1280x720). The Python side keeps H.265 apart from the H.264 code: its own
codec-config and wire-frame functions for the two-byte NAL header, its own
access unit check, and an encoder subclass. If the car is told H.265 and the
hardware encoder cannot open, the session fails with the reason; there is no
fallback to another codec.

Measured on a parked comma with a scratch copy of the library: H.265 1080p
costs about 13 ms per frame on the NV12 path (30 ms from RGBA), mid-stream
keyframes carry VPS/SPS/PPS, and the H.264 session is unchanged. The
encoder splits every H.265 picture into two slices, which the check allows.
2026-11-03 05:40:05 -08:00
dirwin31 5dbcbb9618 Choose the car automatically when exactly one paired device offers Android Auto
A car that is paired and offers wireless Android Auto but was never chosen
(the pairing window closed first, or it was paired in Bluetooth settings)
is adopted by housekeeping when it is the only candidate. This reads the
cached BlueZ service list, so it never pages a device, and it does not run
during a pairing window or session, for adapter setups, or once a car is
chosen. With several candidates nothing is guessed; they are listed in the
status as car_candidates for the setup screen to offer.
2026-11-03 05:40:05 -08:00
dirwin31 d5f6d6ee72 Only wait on a Wi-Fi interface NetworkManager reports as unmanaged or unavailable
The first version waited on any state below disconnected, which also caught
the unknown state and broke the lease tests. Wait on states 10 and 20 only.
2026-11-03 05:40:05 -08:00
dirwin31 cad0022d3e Wait for NetworkManager to manage a shared Wi-Fi interface and tell stream losses apart
Nineteen Bugsink joins were refused with 'device is strictly unmanaged'.
On a shared interface the join now waits up to 8 s for NetworkManager to
manage it (dedicated mode already waited) and otherwise reports that the
interface is unmanaged and for how long, which the diagnosis already maps
to wifi_not_ready.

Stream losses keep the projection_interrupted category but now carry a kind
(stalled, silent, disconnected) with matching next steps, reported as
aa.loss_kind, so a car shutting off is no longer read as link loss.
2026-11-03 05:40:05 -08:00
dirwin31 dbb1fe6547 Add a StarPort section to the Developer page with the new pairing switch and an H.265 setting
The Developer page gets a heading row, StarPort, that groups the StarPort
settings: the New pairing experience switch (on unless turned off), Stay on
Wi-Fi (moved under the heading, same key, label and default) and Allow
H.265 video (off by default, daemon setting allow_hevc).

The heading is a row whose key starts with heading:, projected with
heading=true. It has no choices, is never available and cannot be changed,
and Galaxy draws it as a label. The new pairing switch is a plain file next
to the other StarPort data rather than a Params key, so it reads and changes
with the StarPort service down and needs no device rebuild; absent or
unreadable means on.
2026-11-03 05:40:05 -08:00
dirwin31 6854118aa2 Wait out an unreachable car by why it was unreachable
Most failed drives in Bugsink never reached video and stopped at Bluetooth,
but one backoff treated a sleeping radio, a service that was not up yet and
this comma's own busy stack alike.

reachability.classify() separates a car that never answered a page (Host is
down, page timeout), a car that answered but refused or reset, and a local
Bluetooth failure. A sleeping car or busy controller now waits at least as
long as before and often longer; other failures wait exactly as before. A
sleeping car no longer makes the comma forget the cached RFCOMM channel.
The class is logged on attempt_failed and reported as aa.reach_class.

The 141 'unknown' failures at the discovering stage now get a diagnosis:
car_asleep, car_not_ready or bluetooth_stack_busy, with next steps.
2026-11-03 05:40:05 -08:00
dirwin31 8e0073f110 Show the chosen setup in the StarPort log send
The Discord post now names the setup type, profile and whether it was
chosen or detected, from the newest session log that recorded one, next to
the car and head unit. The zip already carries the same data in each
session log's session_start and discovery events.
2026-11-03 05:40:05 -08:00
dirwin31 fd660ef6f7 Report the chosen setup, the car's offered video modes and the working strategy
Reports could not tell a built-in unit from a dongle, and offers and
handshakes were only visible in failure text, so there was nothing to
compare working pairings against.

session_start now records the setup type (builtin/adapter/usb/app), the
resolved profile and whether it was chosen or detected. Session reports
carry every video mode the head unit offered, whether the comma's address
reached the car in the pairing request, and, for streaming sessions, the
strategy that worked (window, reply style, Wi-Fi channel type). The drive
report gains low-cardinality tags (aa.setup_type, aa.adapter_model,
aa.profile_source, aa.phone_address_sent); long values stay in contexts.
Old logs without these fields still report. aa.schema is now 3.
2026-11-03 05:40:05 -08:00
dirwin31 745b1e94de Send the comma's Bluetooth address in the head unit pairing request
LeaseAwarePhone (used for every wireless session) had no adapter() method,
so Supervisor._adapter_address() hit AttributeError, swallowed it, and
returned "". The Bluetooth-service pairing request was therefore never sent
(bluetooth_opened always logged phone_address: "").

A 2020 myChevrolet (head unit 2.46.1.6) through an AAWireless Two+ then
accepted and acknowledged video but never showed it. A real phone sends the
pairing request about 1 s into the session; the car shows Android Auto once
its hands-free link to that address is up.

Pass adapter() through to the wrapped BluezPhone. It is a read-only property
lookup, so it needs no phone-role lease, like devices() and device().
_adapter_address() now logs adapter_address_unavailable instead of failing
silently.

Tested on the car (session-000052): phone_address sent, pairing response
received, picture displayed, touch input worked, 20-29 fps fully acked.

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

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