Compare commits

..

241 Commits

Author SHA1 Message Date
royjr 7be3eca113 ui: preserve custom button presses 2026-08-16 21:02:03 -04:00
royjr 9e9918124d Merge branch 'hyundai-custom-button' into ccnc-port-custom-button 2026-08-16 20:45:36 -04:00
royjr 4621e9a560 Update opendbc_repo 2026-08-16 20:45:25 -04:00
royjr a8795674b8 ui: remove custom button quiet mode 2026-08-16 16:33:39 -04:00
royjr 823b447fff ui: adapt custom button cycle by device 2026-08-16 16:29:12 -04:00
royjr c36db99c2d ui: cycle custom button navigation 2026-08-16 16:27:59 -04:00
royjr 5426b6af86 ui: toggle custom button navigation targets 2026-08-16 16:26:44 -04:00
royjr bf0cdd667b ui: add custom button navigation actions 2026-08-16 16:24:42 -04:00
royjr 9af59caf9c ui: compartmentalize custom button handling 2026-08-16 16:21:05 -04:00
royjr 1a75c53ea4 ui: handle custom steering button on comma 4 2026-08-16 16:18:09 -04:00
royjr 83999585ec Merge branch 'master' into hyundai-custom-button
# Conflicts:
#	opendbc_repo
#	selfdrive/ui/feedback/feedbackd.py
#	sunnypilot/sunnylink/params_metadata.json
2026-08-16 16:02:16 -04:00
royjr 138353adb4 Update opendbc_repo 2026-08-16 15:39:52 -04:00
royjr f7eb4c2561 Update opendbc_repo 2026-08-15 01:39:27 -04:00
royjr be9255358c Update opendbc_repo 2026-08-15 01:35:33 -04:00
royjr f48ccb5d7a Merge branch 'master' into ccnc-port 2026-08-14 20:42:45 -04:00
royjr 99559d6749 Update opendbc_repo 2026-08-14 20:42:36 -04:00
royjr 4b6f0ffb46 Merge branch 'master' into ccnc-port 2026-08-11 11:56:46 -04:00
royjr 55415382ce Update opendbc_repo 2026-08-11 11:56:38 -04:00
royjr 9f30901eb6 Merge branch 'master' into ccnc-port 2026-08-10 16:33:50 -04:00
royjr d2045d24fb Update opendbc_repo 2026-08-10 16:33:37 -04:00
royjr 6488a11e4d Merge branch 'master' into ccnc-port 2026-08-02 14:27:34 -04:00
royjr 7b22b65313 Merge branch 'master' into ccnc-port 2026-07-27 15:29:04 -04:00
royjr b59a50101e Update opendbc_repo 2026-07-27 15:28:51 -04:00
royjr 3e30962bd3 Merge branch 'master' into ccnc-port 2026-07-25 03:04:23 -04:00
royjr 3274043063 Update opendbc_repo 2026-07-25 03:04:12 -04:00
royjr c624550d2e Merge branch 'master' into ccnc-port 2026-07-20 15:52:41 -04:00
royjr 6759038671 Merge branch 'master' into ccnc-port 2026-07-20 09:18:18 -04:00
royjr aa636e75c8 Merge branch 'master' into ccnc-port 2026-07-16 00:23:49 -04:00
royjr 5ff3c3bd8d Update opendbc_repo 2026-07-16 00:23:43 -04:00
royjr 1e11b52290 Update opendbc_repo 2026-07-14 00:40:02 -04:00
royjr 76e0204025 Update opendbc_repo 2026-07-14 00:37:13 -04:00
royjr cc88f2cbd6 Update opendbc_repo 2026-07-10 22:00:25 -04:00
royjr ee0ac199a4 Update opendbc_repo 2026-07-03 08:08:29 -04:00
royjr d5ed828eaa Update opendbc_repo 2026-07-01 19:30:06 -04:00
royjr 9fca585f2a Merge branch 'master' into ccnc-port 2026-06-30 22:14:33 -04:00
royjr bb1259303e Update opendbc_repo 2026-06-30 22:14:23 -04:00
royjr e135051ca8 Merge branch 'master' into ccnc-port 2026-06-13 22:38:22 -04:00
royjr 12bef55d8a Update opendbc_repo 2026-06-13 22:12:00 -04:00
royjr 2f7a45e6c8 Merge branch 'master' into ccnc-port 2026-06-08 22:01:57 -04:00
royjr 936ebfc12b Update opendbc_repo 2026-06-08 22:01:44 -04:00
royjr aa0c9dc0eb Merge branch 'master' into ccnc-port 2026-06-04 09:52:35 -04:00
royjr 7476a866e7 Update opendbc_repo 2026-06-04 09:52:06 -04:00
royjr 610d857e33 Merge branch 'master' into ccnc-port 2026-05-28 08:30:40 -04:00
royjr d2f47407d0 Update opendbc_repo 2026-05-12 21:03:18 -04:00
royjr db75ec76ea Merge branch 'master' into ccnc-port 2026-05-12 20:44:57 -04:00
royjr 24066465d7 Update opendbc_repo 2026-05-12 20:44:48 -04:00
royjr a7abbd6e25 Update opendbc_repo 2026-04-20 18:22:12 -04:00
royjr 878982447c Merge branch 'master' into ccnc-port 2026-04-19 12:06:46 -04:00
royjr 576527a36b Merge branch 'master' into ccnc-port 2026-04-17 05:42:35 -04:00
royjr ef8c35da24 Update opendbc_repo 2026-04-17 05:41:21 -04:00
royjr 85688b1040 Merge branch 'master' into ccnc-port 2026-04-16 19:48:39 -04:00
royjr 0fb2199130 Update opendbc_repo 2026-04-16 19:47:57 -04:00
royjr d48d756c1d Update opendbc_repo 2026-04-08 00:56:54 -04:00
royjr 2ed298a0c9 Merge branch 'master' into ccnc-port 2026-04-08 00:51:13 -04:00
royjr d68f038949 Update opendbc_repo 2026-04-08 00:51:11 -04:00
royjr 7231571e57 Merge branch 'master' into ccnc-port 2026-04-03 23:34:00 -04:00
royjr b37f1419d3 Update opendbc_repo 2026-04-03 23:33:19 -04:00
royjr cd85a66790 Merge branch 'master' into ccnc-port 2026-03-26 00:53:07 -04:00
royjr 305ea87daf Update opendbc_repo 2026-03-26 00:52:44 -04:00
royjr 4bbfc793e0 Merge branch 'master' into ccnc-port 2026-03-15 15:44:59 -04:00
royjr d5d983676e Update opendbc_repo 2026-03-13 16:41:05 -04:00
royjr de8a96a398 Merge branch 'master' into ccnc-port 2026-03-13 16:41:00 -04:00
royjr 0cbf45f699 Merge branch 'master' into ccnc-port 2026-03-11 23:28:59 -04:00
royjr 0d68a3a2ab Merge branch 'master' into ccnc-port 2026-03-09 19:58:32 -04:00
royjr 9e85a85059 Update opendbc_repo 2026-03-09 19:58:18 -04:00
royjr 0373c327c0 Update opendbc_repo 2026-03-02 10:10:38 -05:00
royjr efe9e5c200 Update opendbc_repo 2026-03-02 02:15:05 -05:00
royjr 8a249a45dc Update opendbc_repo 2026-03-02 02:06:16 -05:00
royjr bdbefe67f6 Update opendbc_repo 2026-03-02 01:40:31 -05:00
royjr 675bb166ad Merge branch 'master' into ccnc-port 2026-03-01 17:18:11 -05:00
royjr 1b717a7e88 Merge branch 'master' into ccnc-port 2026-03-01 13:23:30 -05:00
royjr 86f55a8ba9 Update opendbc_repo 2026-03-01 13:23:24 -05:00
royjr 629392d2f7 Update opendbc_repo 2026-02-27 16:31:59 -05:00
royjr bc414bdc8b Update opendbc_repo 2026-02-26 23:53:26 -05:00
royjr 7ca5649f2c Merge branch 'master' into ccnc-port 2026-02-26 23:52:46 -05:00
royjr 641ee8fa87 Update opendbc_repo 2026-02-26 23:52:27 -05:00
royjr 56c276158c Merge branch 'master' into ccnc-port 2026-02-24 14:12:12 -05:00
royjr c65308a8bd Update opendbc_repo 2026-02-24 14:12:01 -05:00
royjr 994e526460 Merge branch 'master' into ccnc-port 2026-02-18 12:06:05 -05:00
royjr 1defae36b7 Update opendbc_repo 2026-02-18 12:05:53 -05:00
royjr 40dc4e1591 Merge branch 'master' into hyundai-custom-button 2026-02-16 22:43:14 -05:00
royjr cce4810904 Update opendbc_repo 2026-02-16 22:43:09 -05:00
royjr 5b58f9c1f5 Update opendbc_repo 2026-02-13 23:34:16 -05:00
royjr 815ac7732b Merge branch 'master' into hyundai-custom-button 2026-02-13 23:02:12 -05:00
royjr 2321f9d8f5 Update opendbc_repo 2026-02-13 23:02:06 -05:00
royjr 8f029fd0ef Merge branch 'master' into ccnc-port 2026-02-13 23:01:41 -05:00
royjr ddb46284dc Update opendbc_repo 2026-02-13 23:01:16 -05:00
royjr 9effc754d9 Merge branch 'master' into ccnc-port 2026-02-06 01:16:06 -05:00
royjr e49ffc2a2d Update opendbc_repo 2026-02-06 01:15:59 -05:00
royjr b9020e0003 Update params_metadata.json 2026-02-04 22:07:48 -05:00
royjr ceb466578a Update params_metadata.json 2026-02-04 22:07:19 -05:00
royjr e1cfea4924 Update RELEASES.md 2026-02-04 22:04:29 -05:00
royjr dd8a2bf83a Merge branch 'master' into hyundai-custom-button 2026-02-04 21:45:41 -05:00
royjr ba36a28f2a Update opendbc_repo 2026-02-04 21:43:53 -05:00
royjr cf2010389a Revert "Update opendbc_repo"
This reverts commit 43522c4922.
2026-02-04 19:14:43 -05:00
royjr 43522c4922 Update opendbc_repo 2026-02-04 19:14:28 -05:00
royjr 2cacd0b3e5 Merge branch 'master' into ccnc-port 2026-01-24 12:50:18 -05:00
royjr c4b8859dff Update opendbc_repo 2026-01-24 12:50:11 -05:00
royjr 8fb0953205 Merge branch 'master' into ccnc-port 2026-01-11 22:17:38 -05:00
royjr 63d1c8835f Merge branch 'master' into ccnc-port 2026-01-10 13:03:48 -05:00
royjr 17a185606d Merge branch 'master' into ccnc-port 2026-01-09 16:40:32 -05:00
royjr da10131392 Merge branch 'master' into ccnc-port 2025-12-28 17:18:51 -05:00
royjr 7107c2ba14 Merge branch 'master' into ccnc-port 2025-12-23 12:13:48 -05:00
royjr 95b6e877ac Update opendbc_repo 2025-12-23 12:13:29 -05:00
royjr eb02c6570e Update opendbc_repo 2025-12-21 15:43:04 -05:00
royjr 1be8ae31c4 Merge branch 'master' into ccnc-port 2025-12-19 01:04:42 -05:00
royjr 04dcd38856 Update opendbc_repo 2025-12-19 01:04:30 -05:00
royjr 22ccf0d72f Merge branch 'master' into ccnc-port 2025-12-15 17:02:50 -05:00
royjr 3c969bb627 Merge branch 'master' into ccnc-port 2025-12-13 23:22:22 -05:00
royjr 20f8011feb Update opendbc_repo 2025-12-13 23:22:11 -05:00
royjr 9cf17e74a1 Merge branch 'master' into ccnc-port 2025-12-12 23:19:56 -05:00
royjr 2c4efdf557 Merge branch 'master' into ccnc-port 2025-12-07 13:29:48 -05:00
royjr 4cd3d3c16c Merge branch 'master' into ccnc-port 2025-12-02 12:56:21 -05:00
royjr 637f3ae9c8 Merge branch 'master' into ccnc-port 2025-12-01 14:41:03 -05:00
royjr 464ee80f71 Merge branch 'master' into ccnc-port 2025-11-26 00:27:53 -05:00
royjr 2743a04613 Merge branch 'master' into ccnc-port 2025-11-24 18:44:13 -05:00
royjr 7f9978d001 Merge branch 'master' into ccnc-port 2025-11-22 00:21:15 -05:00
royjr 4b83961c67 Merge branch 'master' into ccnc-port 2025-11-21 16:23:22 -05:00
royjr c00eaf428a Update opendbc_repo 2025-11-21 16:23:01 -05:00
royjr 0a9993e8d4 Merge branch 'master' into ccnc-port 2025-11-19 16:49:59 -05:00
royjr 0af214a985 Update opendbc_repo 2025-11-19 16:49:51 -05:00
royjr af43385e3a Merge branch 'master' into ccnc-port 2025-11-11 10:19:51 -05:00
royjr 0ab2b8c590 Update opendbc_repo 2025-11-07 19:59:50 -05:00
royjr 67ab18a0de Merge branch 'master' into ccnc-port 2025-11-07 19:23:51 -05:00
royjr e87dc15b30 Update opendbc_repo 2025-11-07 19:23:37 -05:00
royjr 192d08516c Merge branch 'master' into ccnc-port 2025-11-02 19:23:00 -05:00
royjr 3cf001c59c Update opendbc_repo 2025-11-02 19:22:49 -05:00
royjr f2ccd021da Merge branch 'master' into ccnc-port 2025-11-02 14:07:54 -05:00
royjr c9fc900f64 Update opendbc_repo 2025-11-02 14:07:44 -05:00
royjr 3c37c5ce5d Update opendbc_repo 2025-10-30 11:28:49 -04:00
royjr 7c45889e4e Merge branch 'master' into ccnc-port 2025-10-30 11:27:50 -04:00
royjr 2aabb7aee8 Merge branch 'master' into ccnc-port 2025-10-24 14:16:09 -04:00
royjr 3859e9962f Update opendbc_repo 2025-10-24 14:15:51 -04:00
royjr 810efbab72 Merge branch 'master' into ccnc-port 2025-10-18 07:33:11 -04:00
royjr ec27bec326 Update opendbc_repo 2025-10-18 07:32:33 -04:00
royjr 250d553157 Merge branch 'master' into ccnc-port 2025-10-14 21:57:32 -04:00
royjr cea54a0ca8 Update opendbc_repo 2025-10-14 21:57:26 -04:00
royjr 8e72d783bd Update opendbc_repo 2025-10-13 22:41:21 -04:00
royjr 1b0dc103dc Merge branch 'master' into ccnc-port 2025-10-11 23:51:46 -04:00
royjr 6c364d292b Update opendbc_repo 2025-10-11 23:51:31 -04:00
royjr bcdec2ce84 Merge branch 'master' into ccnc-port 2025-10-10 17:29:05 -04:00
royjr 3deaeb3759 Merge branch 'master' into ccnc-port 2025-10-10 15:02:32 -04:00
royjr c669f0984a Update opendbc_repo 2025-10-10 15:02:17 -04:00
royjr 1cb5f24439 Merge branch 'master' into hyundai-custom-button 2025-10-08 21:37:43 -04:00
royjr b6a3f21a52 Merge branch 'master' into hyundai-custom-button 2025-10-08 18:27:27 -04:00
royjr 2ecc26b623 Update opendbc_repo 2025-10-08 18:27:19 -04:00
royjr 145f75fe06 Merge branch 'master' into hyundai-custom-button 2025-10-08 17:57:50 -04:00
royjr bd0578cbf9 Update opendbc_repo 2025-10-08 17:17:28 -04:00
royjr 46dd946740 Merge branch 'master' into ccnc-port 2025-10-07 01:36:06 -04:00
royjr 9da4b3653e Update opendbc_repo 2025-10-07 01:35:58 -04:00
royjr 4e21ae7c50 Update opendbc_repo 2025-10-05 06:21:56 -04:00
royjr bb91e92237 Update opendbc_repo 2025-10-05 06:01:15 -04:00
royjr 14b4c4f85b Update opendbc_repo 2025-10-02 09:20:32 -04:00
royjr 0660b542c3 Merge branch 'master' into ccnc-port 2025-10-01 16:01:32 -04:00
royjr 2b893b90c9 Update opendbc_repo 2025-10-01 16:01:26 -04:00
royjr f5139178ed Merge branch 'master' into ccnc-port 2025-09-30 14:39:57 -04:00
royjr fb43b755f2 Update opendbc_repo 2025-09-30 14:39:50 -04:00
royjr 07f5b967d8 Merge branch 'master' into ccnc-port 2025-09-24 21:30:29 -04:00
royjr ea19c7d3bb Update opendbc_repo 2025-09-24 21:30:17 -04:00
royjr e461842cbb Merge branch 'master' into ccnc-port 2025-09-23 05:55:02 -04:00
royjr a73c9659d5 Update opendbc_repo 2025-09-23 05:54:50 -04:00
royjr cb796fbc76 Merge branch 'master' into ccnc-port 2025-09-18 20:10:43 -04:00
royjr 6bf75fc557 Update opendbc_repo 2025-09-18 20:10:37 -04:00
royjr 9a1fc28819 Merge branch 'master' into ccnc-port 2025-09-18 13:54:36 -04:00
royjr 0741d05e92 Update opendbc_repo 2025-09-18 13:54:23 -04:00
royjr 1ad008107d Merge branch 'master' into ccnc-port 2025-09-15 01:40:27 -04:00
royjr feebd9df93 Reapply "UI: Developer UI (#1233)"
This reverts commit 15e5d2efb9.
2025-09-15 01:40:21 -04:00
royjr c2e5ced3e5 Update opendbc_repo 2025-09-15 01:39:51 -04:00
royjr 15e5d2efb9 Revert "UI: Developer UI (#1233)"
This reverts commit 1bb4ca2547.
2025-09-12 02:10:29 -04:00
royjr a3929d0b54 Merge branch 'master' into ccnc-port 2025-09-12 01:40:29 -04:00
royjr 794f8f9991 Update opendbc_repo 2025-09-08 09:27:31 -04:00
royjr 68fa5e3f21 Merge branch 'master' into ccnc-port 2025-09-07 13:13:37 -04:00
royjr 86c6cc1f48 Merge branch 'master' into ccnc-port 2025-09-03 22:22:12 -04:00
royjr eb7ffbf093 Update opendbc_repo 2025-09-03 10:14:58 -04:00
royjr 40f24cc0b6 Update opendbc_repo 2025-09-03 10:08:01 -04:00
royjr 3919095752 Update opendbc_repo 2025-09-03 10:05:35 -04:00
royjr 754efac063 Merge branch 'master' into hyundai-custom-button 2025-09-03 09:57:35 -04:00
royjr 132cc156f4 Update opendbc_repo 2025-09-03 09:57:27 -04:00
royjr 74d63be1c3 Merge branch 'master' into ccnc-port 2025-09-03 09:50:55 -04:00
royjr 8894486a1a Update opendbc_repo 2025-09-03 09:50:49 -04:00
royjr 810599315d Merge branch 'master' into ccnc-port 2025-08-31 16:53:30 -04:00
royjr 6f3ab810c8 Update opendbc_repo 2025-08-31 16:53:24 -04:00
royjr 230f78b8d3 Merge branch 'master' into ccnc-port 2025-08-26 12:14:46 -04:00
royjr f1affec088 Update opendbc_repo 2025-08-26 12:14:22 -04:00
nayan 95ad932efb disable lkas per upstream & fix mixed events 2025-08-25 09:15:20 -04:00
nayan ae30f4119f Merge remote-tracking branch 'origin/master' into hyundai-custom-button
# Conflicts:
#	RELEASES.md
#	opendbc_repo
#	selfdrive/ui/feedback/feedbackd.py
2025-08-25 09:04:57 -04:00
nayan d251ae4976 pr be messing with pr 2025-08-24 16:40:48 -04:00
royjr 97d8ef242c Merge branch 'master' into ccnc-port 2025-08-24 15:12:57 -04:00
royjr a63fff9b45 Update opendbc_repo 2025-08-24 15:12:46 -04:00
nayan 33324e590b use altButton2 event 2025-08-23 22:24:39 -04:00
nayan fea10ffd22 Merge remote-tracking branch 'origin/master' into hyundai-custom-button 2025-08-23 22:00:59 -04:00
nayan f1e11e3f06 bump opendbc 2025-08-23 22:00:50 -04:00
royjr cb3893daaa Merge branch 'master' into ccnc-port 2025-08-23 10:33:24 -04:00
royjr 29f60df74b Merge branch 'master' into ccnc-port 2025-08-22 11:18:27 -04:00
royjr c6c072e1f4 Update opendbc_repo 2025-08-22 11:18:18 -04:00
royjr c562eca8a1 Merge branch 'master' into hyundai-custom-button 2025-08-22 10:30:38 -04:00
royjr d9a690dac3 Update opendbc_repo 2025-08-22 10:30:28 -04:00
nayan a0eed058d1 bump opendbc 2025-08-13 17:28:11 -04:00
nayan abc9f47a6f Merge remote-tracking branch 'origin/master' into hyundai-custom-button
# Conflicts:
#	opendbc_repo
2025-08-13 17:27:19 -04:00
royjr d101cbb83e Update opendbc_repo 2025-08-13 16:00:04 -04:00
royjr 1536d59633 Update opendbc_repo 2025-08-13 15:28:37 -04:00
royjr dc99b865ae Merge branch 'master' into ccnc-port 2025-08-13 12:31:14 -04:00
royjr e59bc027ff Merge branch 'master' into ccnc-port 2025-08-13 11:58:05 -04:00
royjr cf7e5efaca Update opendbc_repo 2025-08-13 11:57:57 -04:00
Nayan b064f6fcaf Merge branch 'master' into hyundai-custom-button 2025-08-12 14:41:16 -04:00
nayan 0c7d7df2ec keep both 2025-08-12 14:40:11 -04:00
nayan dc86f35957 refactor 2025-08-12 13:29:06 -04:00
nayan 9685b0aa9b lint 2025-08-12 12:49:17 -04:00
nayan f23cc408e3 not needed anymore 2025-08-12 12:48:49 -04:00
nayan cea00a6c14 todo 2025-08-12 12:00:53 -04:00
nayan f2949e1dc2 use custom button in feedbackd 2025-08-10 11:49:35 -04:00
royjr 5f1f34fa7f Update opendbc_repo 2025-08-10 09:31:11 -04:00
royjr 2c041f9025 Merge branch 'master' into hyundai-custom-button 2025-08-10 09:28:05 -04:00
royjr 4b44f2eb31 Merge branch 'master' into ccnc-port 2025-08-10 09:18:24 -04:00
royjr 107d2ab400 Update opendbc_repo 2025-08-10 09:18:15 -04:00
nayan 74b2d519b9 move custom button to cs_sp, add bookmark functionality 2025-08-05 15:52:46 -04:00
royjr 5432d9062c Merge branch 'master' into ccnc-port 2025-08-04 11:52:29 -04:00
royjr f533f6c843 Merge branch 'master' into ccnc-port 2025-08-02 06:53:54 -04:00
royjr 58e9ac763c Update opendbc_repo 2025-08-02 06:53:43 -04:00
royjr cb50d54169 Merge branch 'master-new' into ccnc-port 2025-07-24 20:23:46 -04:00
royjr bd5de4ed0a Merge branch 'master-new' into ccnc-port 2025-07-20 23:49:36 -04:00
royjr 0d4073fadb Merge branch 'master-new' into ccnc-port 2025-07-19 23:18:26 -04:00
royjr ebc70dcb52 Merge branch 'master-new' into ccnc-port 2025-07-19 14:37:17 -04:00
royjr 4d0426999e Update opendbc_repo 2025-07-19 14:36:59 -04:00
royjr 286da42573 Merge branch 'master-new' into ccnc-port 2025-07-16 23:48:23 -04:00
royjr 8a836710a9 Update opendbc_repo 2025-07-16 23:48:06 -04:00
royjr 5d515bcf33 Merge branch 'master-new' into ccnc-port 2025-07-07 05:35:55 -04:00
royjr 1c7f6d5133 Update opendbc_repo 2025-07-03 21:24:01 -04:00
royjr 05d57c7aeb Update opendbc_repo 2025-07-01 18:30:32 -04:00
royjr e4b0eaf352 Update opendbc_repo 2025-06-28 19:30:28 -04:00
royjr a710276472 Merge branch 'master-new' into ccnc-port 2025-06-28 19:22:59 -04:00
royjr af086db671 Merge branch 'master-new' into ccnc-port 2025-06-28 12:13:26 -04:00
royjr 0d9eb0e25e Update opendbc_repo submodule to latest commit
Advanced the opendbc_repo submodule to commit d309f7ec96e37267c94d12fc4bfe2672ad505b06. This pulls in the latest changes from the opendbc repository.
2025-06-26 14:03:56 -04:00
royjr 0616caed6d Merge branch 'master-new' into ccnc-port 2025-06-25 19:33:53 -04:00
royjr 095337b3c1 Update opendbc_repo 2025-06-25 19:33:38 -04:00
royjr 1edec2d22c Update opendbc_repo 2025-06-14 16:14:02 -04:00
royjr affabb9ee0 Update opendbc_repo 2025-06-14 15:55:07 -04:00
royjr dc27e8711c Update opendbc_repo 2025-06-14 14:54:32 -04:00
royjr cf7329a264 Merge branch 'master-new' into ccnc-port 2025-06-11 21:36:06 -04:00
royjr 5ee5ecd820 Merge branch 'master-new' into ccnc-port 2025-06-08 23:25:13 -04:00
royjr b064f730dd Update opendbc_repo 2025-06-08 17:54:43 -04:00
128 changed files with 1770 additions and 5214 deletions
-1
View File
@@ -9,7 +9,6 @@
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
openpilot/selfdrive/assets/sounds/milestone.wav -filter -diff -merge -text
openpilot/selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text
openpilot/common/hardware/comma/updater filter=lfs diff=lfs merge=lfs -text
+184 -44
View File
@@ -7,19 +7,6 @@ on:
description: 'Minimum selector version required for the models (see helpers.py or readme.md)'
required: true
type: string
target_hardware:
description: 'Hardware target to compile for (qcom or usbgpu)'
required: true
type: choice
default: 'qcom'
options:
- qcom
- usbgpu
hf_repo:
description: 'Hugging Face dataset repository'
required: false
type: string
default: 'sunnypilot/sunnypilot_models_v1'
jobs:
setup:
@@ -59,14 +46,13 @@ jobs:
id: get-json
run: |
cd docs/docs
PREFIX="driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_' || '' }}v"
latest=$(ls ${PREFIX}*.json | sed -E "s/${PREFIX}([0-9]+)\.json/\1/" | sort -n | tail -1)
latest=$(ls driving_models_v*.json | sed -E 's/.*_v([0-9]+)\.json/\1/' | sort -n | tail -1)
next=$((latest+1))
json_file="${PREFIX}${next}.json"
cp "${PREFIX}${latest}.json" "$json_file"
json_file="driving_models_v${next}.json"
cp "driving_models_v${latest}.json" "$json_file"
echo "json_file=docs/docs/$json_file" >> $GITHUB_OUTPUT
echo "json_version=$((next+0))" >> $GITHUB_OUTPUT
echo "SRC_JSON_FILE=docs/docs/${PREFIX}${latest}.json" >> $GITHUB_ENV
echo "SRC_JSON_FILE=docs/docs/driving_models_v${latest}.json" >> $GITHUB_ENV
- name: Extract tinygrad models
id: set-matrix
@@ -75,23 +61,45 @@ jobs:
jq -c '[.bundles[] | select(.runner=="tinygrad") | {ref, display_name: (.display_name | gsub(" \\([^)]*\\)"; "")), is_20hz}]' "$(basename "${SRC_JSON_FILE}")" > matrix.json
echo "model_matrix=$(cat matrix.json)" >> $GITHUB_OUTPUT
- name: Get next recompiled dir number
- name: Set up SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}
- run: |
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
- name: Clone GitLab docs repo and create new recompiled dir
id: create-recompiled-dir
env:
HF_REPO: ${{ github.event.inputs.hf_repo }}
GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts'
run: |
pip install huggingface_hub
recompiled_dir=$(python3 -c "
from huggingface_hub import HfApi
import re, sys
api = HfApi()
files = api.list_repo_files(repo_id=sys.argv[1], repo_type='dataset')
dirs = [re.search(r'models/recompiled([0-9]+)', f) for f in files]
nums = [int(m.group(1)) for m in dirs if m]
print(max(nums) + 1)
" "$HF_REPO")
git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs
cd gitlab_docs
git checkout main
git sparse-checkout set --no-cone models/
cd models
latest_dir=$(ls -d recompiled* 2>/dev/null | sed -E 's/recompiled([0-9]+)/\1/' | sort -n | tail -1)
if [[ -z "$latest_dir" ]]; then
next_dir=1
else
next_dir=$((latest_dir+1))
fi
recompiled_dir="${next_dir}"
mkdir -p "recompiled${recompiled_dir}"
touch "recompiled${recompiled_dir}/.gitkeep"
cd ../..
echo "recompiled_dir=$recompiled_dir" >> $GITHUB_OUTPUT
- name: Push empty recompiled dir to GitLab
run: |
cd gitlab_docs
git add models/recompiled${{ steps.create-recompiled-dir.outputs.recompiled_dir }}
git config --global user.name "GitHub Action"
git config --global user.email "action@github.com"
git commit -m "Add recompiled${{ steps.create-recompiled-dir.outputs.recompiled_dir }} for build-all" || echo "No changes to commit"
git push origin main
- name: Push new JSON to GitHub docs repo
run: |
cd docs
@@ -115,30 +123,25 @@ jobs:
is_20hz: ${{ matrix.model.is_20hz }}
recompiled_dir: ${{ needs.setup.outputs.recompiled_dir }}
json_version: ${{ needs.setup.outputs.json_version }}
target_hardware: ${{ github.event.inputs.target_hardware }}
hf_repo: ${{ github.event.inputs.hf_repo }}
set_min_version: ${{ github.event.inputs.set_min_version }}
tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }}
secrets: inherit
retry_failed_models:
needs: [setup, get_and_build]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.setup.result == 'success' && (needs.get_and_build.result == 'success' || needs.get_and_build.result == 'failure') }}
if: ${{ needs.setup.result != 'failure' && !cancelled() }}
outputs:
retry_matrix: ${{ steps.set-retry-matrix.outputs.retry_matrix }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: artifact-name-*
pattern: model-*
path: output
continue-on-error: true
- id: set-retry-matrix
run: |
echo '${{ needs.setup.outputs.model_matrix }}' > matrix.json
built=(); while IFS= read -r line; do [ -n "$line" ] && built+=("$line"); done < <(
find output -maxdepth 1 -name 'artifact-name-*' -printf "%f\n" 2>/dev/null | sed -E 's/^artifact-name-//' | awk '{gsub(/^ +| +$/, ""); print}'
built=(); while IFS= read -r line; do built+=("$line"); done < <(
find output -maxdepth 1 -name 'model-*' -printf "%f\n" | sed -E 's/^model-//' | sed -E 's/-[0-9]+$//' | sed -E 's/ \([^)]*\)//' | awk '{gsub(/^ +| +$/, ""); print}'
)
jq -c --argjson built "$(printf '%s\n' "${built[@]}" | jq -R . | jq -s .)" \
'map(select(.display_name as $n | ($built | index($n | gsub("^ +| +$"; "")) | not)))' matrix.json > retry_matrix.json
@@ -146,7 +149,7 @@ jobs:
retry_get_and_build:
needs: [setup, get_and_build, retry_failed_models]
if: ${{ !cancelled() && needs.retry_failed_models.result == 'success' && needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '' }}
if: ${{ needs.get_and_build.result == 'failure' || (needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '') }}
strategy:
matrix:
model: ${{ fromJson(needs.retry_failed_models.outputs.retry_matrix) }}
@@ -158,9 +161,146 @@ jobs:
is_20hz: ${{ matrix.model.is_20hz }}
recompiled_dir: ${{ needs.setup.outputs.recompiled_dir }}
json_version: ${{ needs.setup.outputs.json_version }}
target_hardware: ${{ github.event.inputs.target_hardware }}
artifact_suffix: -retry
hf_repo: ${{ github.event.inputs.hf_repo }}
set_min_version: ${{ github.event.inputs.set_min_version }}
tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }}
secrets: inherit
publish_models:
name: Publish models sequentially
needs: [setup, get_and_build, retry_failed_models, retry_get_and_build]
if: ${{ !cancelled() && (needs.get_and_build.result != 'failure' || needs.retry_get_and_build.result == 'success' || (needs.retry_failed_models.outputs.retry_matrix != '[]' && needs.retry_failed_models.outputs.retry_matrix != '')) }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
model: ${{ fromJson(needs.setup.outputs.model_matrix) }}
env:
RECOMPILED_DIR: recompiled${{ needs.setup.outputs.recompiled_dir }}
JSON_FILE: ${{ needs.setup.outputs.json_file }}
ARTIFACT_NAME_INPUT: ${{ matrix.model.display_name }}
steps:
- name: Set up SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}
- name: Add GitLab.com SSH key to known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
- name: Clone GitLab docs repo
env:
GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts'
run: |
echo "Cloning GitLab"
git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs
cd gitlab_docs
echo "checkout models/${RECOMPILED_DIR}"
git sparse-checkout set --no-cone models/${RECOMPILED_DIR}
git checkout main
cd ..
- name: Checkout docs repo
uses: actions/checkout@v4
with:
repository: sunnypilot/sunnypilot-models
ref: gh-pages
path: docs
ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }}
- name: Validate recompiled dir and JSON version
run: |
if [ ! -d "gitlab_docs/models/$RECOMPILED_DIR" ]; then
echo "Recompiled dir $RECOMPILED_DIR does not exist in GitLab repo"
exit 1
fi
if [ ! -f "$JSON_FILE" ]; then
echo "JSON file $JSON_FILE does not exist!"
exit 1
fi
- name: Download artifact name file
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ env.ARTIFACT_NAME_INPUT }}
path: artifact_name
- name: Read artifact name
id: read-artifact-name
run: |
ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt)
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
- name: Download model artifact
uses: actions/download-artifact@v4
with:
name: ${{ steps.read-artifact-name.outputs.artifact_name }}
path: output
- name: Remove onnx files bc not needed for recompiled dir since they already exist from single build
run: |
find output -type f -name '*.onnx' -delete
find output -type f -name 'big_*.pkl' -delete
find output -type f -name 'dmonitoring_model_tinygrad.pkl' -delete
- name: Copy model artifacts to gitlab
env:
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
run: |
ARTIFACT_DIR="gitlab_docs/models/${RECOMPILED_DIR}/${ARTIFACT_NAME}"
mkdir -p "$ARTIFACT_DIR"
for path in output/*; do
if [ "$(basename "$path")" = "artifact_name.txt" ]; then
continue
fi
name="$(basename "$path")"
if [ -d "$path" ]; then
mkdir -p "$ARTIFACT_DIR/$name"
cp -r "$path"/* "$ARTIFACT_DIR/$name/"
echo "Copied dir $name -> $ARTIFACT_DIR/$name"
else
cp "$path" "$ARTIFACT_DIR/"
echo "Copied file $name -> $ARTIFACT_DIR/"
fi
done
- name: Push recompiled dir to GitLab
env:
GITLAB_SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}
run: |
cd gitlab_docs
git checkout main
git pull origin main
for d in models/"$RECOMPILED_DIR"/*/; do
git sparse-checkout add "$d"
done
git add models/"$RECOMPILED_DIR"
git config --global user.name "GitHub Action"
git config --global user.email "action@github.com"
git commit -m "Update $RECOMPILED_DIR with model from build-all-tinygrad-models" || echo "No changes to commit"
git push origin main
- run: |
cd docs
git pull origin gh-pages
- name: update json
run: |
ARGS=""
[ -n "${{ inputs.set_min_version }}" ] && ARGS="$ARGS --set-min-version \"${{ inputs.set_min_version }}\""
ARGS="$ARGS --sort-by-date"
ARGS="$ARGS --tinygrad-ref \"${{ needs.setup.outputs.tinygrad_ref }}\""
eval python3 docs/json_parser.py \
--json-path "$JSON_FILE" \
--recompiled-dir "gitlab_docs/models/$RECOMPILED_DIR" \
$ARGS
- name: Push updated json to GitHub
run: |
cd docs
git config --global user.name "GitHub Action"
git config --global user.email "action@github.com"
git checkout gh-pages
git add docs/"$(basename $JSON_FILE)"
git commit -m "Update $(basename $JSON_FILE) after recompiling model" || echo "No changes to commit"
git push origin gh-pages
-501
View File
@@ -1,501 +0,0 @@
name: Build default models
on:
workflow_dispatch:
inputs:
target:
description: 'Model target to build'
required: true
type: choice
options:
- small
- big
- dm
workflow_call:
inputs:
target:
description: 'Model target to build (small, big, or dm)'
required: true
type: string
concurrency:
group: build-default-models-${{ inputs.target }}
cancel-in-progress: false
env:
HF_REPO: sunnypilot/sunnypilot_models_v1
jobs:
resolve:
runs-on: ubuntu-24.04
outputs:
model_name: ${{ steps.resolve.outputs.model_name }}
onnx_ref: ${{ steps.resolve.outputs.onnx_ref }}
onnx_path: ${{ steps.resolve.outputs.onnx_path }}
hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }}
tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- id: resolve
run: |
export PYTHONPATH=${{ github.workspace }}
if [ "${{ inputs.target }}" = "big" ]; then
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
HF_DEFAULTS_PATH="models/defaults/big"
elif [ "${{ inputs.target }}" = "dm" ]; then
ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx"
HF_DEFAULTS_PATH="models/defaults/dm"
NAME="dmonitoring_model ($(git log -1 --format=%cd --date=format:'%B %d, %Y' -- "$ONNX_PATH"))"
else
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL; print(DEFAULT_MODEL)")
ONNX_PATH="openpilot/selfdrive/modeld/models/driving_supercombo.onnx"
HF_DEFAULTS_PATH="models/defaults/small"
fi
ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH")
TINYGRAD_REF=$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)
if [ -z "$TINYGRAD_REF" ]; then
echo "::error::Failed to resolve tinygrad ref"
exit 1
fi
echo "model_name=${NAME}" >> $GITHUB_OUTPUT
echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT
echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT
echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT
echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT
build_small_model:
needs: resolve
if: ${{ inputs.target == 'small' }}
runs-on: [self-hosted, tici]
env:
SMALL_ONNX: openpilot/selfdrive/modeld/models/driving_supercombo.onnx
SMALL_PKL: openpilot/selfdrive/modeld/models/driving_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull ONNX via LFS
run: git lfs pull -I "${{ env.SMALL_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile small model with stock compiler
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)")
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
env ${TG_FLAGS} python3 \
${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \
--onnx ${{ github.workspace }}/${{ env.SMALL_ONNX }} \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
--frame-skip $FRAME_SKIP \
--output ${{ github.workspace }}/${{ env.SMALL_PKL }}
- name: Chunk small pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.SMALL_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked into {len(targets)} files')
"
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
OUTPUT_DIR="${{ github.workspace }}/small_output"
PKL_BASE="driving_tinygrad.pkl"
mkdir -p "$OUTPUT_DIR"
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
--model-dir "$MODELS_DIR" \
--output-dir "$OUTPUT_DIR" \
--custom-name "$MODEL_NAME" \
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}"
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
- name: Upload small model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }}
path: ${{ github.workspace }}/small_output/
- name: Upload artifact name file
uses: actions/upload-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.model_name }}
path: ${{ github.workspace }}/small_output/artifact_name.txt
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
build_big_model:
needs: resolve
if: ${{ inputs.target == 'big' }}
runs-on: [self-hosted, usbgpu]
env:
BIG_ONNX: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
BIG_PKL: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull big ONNX via LFS
run: git lfs pull -I "${{ env.BIG_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Wait for chestnut PCIe link
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
python3 -c "
import time
from openpilot.system.hardware.chestnut.flash import link_up
for i in range(10):
if link_up():
print(f'PCIe link up after {i+1} attempt(s)')
break
time.sleep(1)
else:
raise RuntimeError('Chestnut PCIe link not ready after 10 attempts')
"
- name: Compile big model with stock compiler
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)")
TG_FLAGS="DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2"
env ${TG_FLAGS} python3 \
${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \
--onnx ${{ github.workspace }}/${{ env.BIG_ONNX }} \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
--frame-skip $FRAME_SKIP \
--output ${{ github.workspace }}/${{ env.BIG_PKL }}
- name: Chunk big pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.BIG_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked into {len(targets)} files')
"
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
OUTPUT_DIR="${{ github.workspace }}/big_output"
PKL_BASE="big_driving_tinygrad.pkl"
mkdir -p "$OUTPUT_DIR"
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
--model-dir "$MODELS_DIR" \
--output-dir "$OUTPUT_DIR" \
--custom-name "$MODEL_NAME" \
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}"
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
- name: Upload big model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }}
path: ${{ github.workspace }}/big_output/
- name: Upload artifact name file
uses: actions/upload-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.model_name }}
path: ${{ github.workspace }}/big_output/artifact_name.txt
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
upload_defaults:
needs: [ resolve, build_small_model, build_big_model, build_dm_model ]
if: |
${{
!cancelled() &&
(inputs.target == 'big' && needs.build_big_model.result == 'success' ||
inputs.target == 'small' && needs.build_small_model.result == 'success' ||
inputs.target == 'dm' && needs.build_dm_model.result == 'success')
}}
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Pull ONNX via LFS
run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}"
- name: Install huggingface_hub
run: pip install --upgrade "huggingface_hub>=0.22.0"
- name: Download artifact name
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.model_name }}
path: artifact_name
- name: Read artifact name
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
id: artifact
run: |
ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt)
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
- name: Download model artifact
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: output
- name: Upload model to HF
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
env:
HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }}
run: |
rm -f output/artifact_name.txt
export PYTHONPATH=$(pwd)
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "$ARTIFACT_NAME" \
--model-dir output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
- name: Download DM artifact
if: ${{ inputs.target == 'dm' }}
uses: actions/download-artifact@v4
with:
name: dm-model-${{ github.run_number }}
path: dm_output
- name: Generate DM metadata and upload to HF
if: ${{ inputs.target == 'dm' }}
env:
HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }}
run: |
export PYTHONPATH=$(pwd)
python3 -c "
import json, hashlib
from pathlib import Path
from datetime import datetime, UTC
dm_dir = Path('dm_output')
manifest = list(dm_dir.glob('*.chunkmanifest'))
assert manifest, 'No chunkmanifest found'
pkl_name = manifest[0].name.removesuffix('.chunkmanifest')
num_chunks = int(manifest[0].read_text().strip())
chunks = []
for i in range(num_chunks):
chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}'
chunks.append({
'file_name': chunk.name,
'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest()
})
digest = hashlib.sha256()
for c in chunks:
with open(dm_dir / c['file_name'], 'rb') as f:
while block := f.read(1024*1024):
digest.update(block)
metadata = {
'bundles': [{
'short_name': 'DMMODEL',
'display_name': '${{ needs.resolve.outputs.model_name }}',
'ref': '${{ needs.resolve.outputs.onnx_ref }}',
'runner': 'tinygrad',
'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
'models': [{
'type': 'chunked',
'artifact': {
'file_name': pkl_name,
'download_uri': {'url': '', 'sha256': digest.hexdigest()},
'chunks': chunks
}
}]
}]
}
with open(dm_dir / 'metadata.json', 'w') as f:
json.dump(metadata, f, indent=2)
print('Generated DM metadata.json')
"
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "dm-model-${{ github.run_number }}" \
--model-dir dm_output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
build_dm_model:
needs: resolve
if: ${{ inputs.target == 'dm' }}
runs-on: [self-hosted, tici]
env:
DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx
DM_PKL: openpilot/selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull DM ONNX via LFS
run: git lfs pull -I "${{ env.DM_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile DM model
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
taskset -c 7 env ${TG_FLAGS} python3 \
${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \
${{ github.workspace }}/${{ env.DM_ONNX }} \
${{ github.workspace }}/${{ env.DM_PKL }}
- name: Chunk DM pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.DM_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked {pkl} into {len(targets)} chunks')
"
- name: Prepare DM output
run: |
mkdir -p dm_output
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/
- name: Upload DM artifact
uses: actions/upload-artifact@v4
with:
name: dm-model-${{ github.run_number }}
path: dm_output/
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
@@ -29,24 +29,11 @@ on:
required: false
type: boolean
default: true
target_hardware:
description: 'Hardware target to compile for (qcom or usbgpu)'
bypass_push:
description: 'Bypass pushing to GitLab for build-all'
required: false
type: string
default: 'qcom'
hf_repo:
description: 'Hugging Face dataset repository (e.g. sunnypilot/sunnypilot_models_v1)'
required: false
type: string
default: 'sunnypilot/sunnypilot_models_v1'
set_min_version:
description: 'Minimum selector version'
required: false
type: string
tinygrad_ref:
description: 'Tinygrad reference'
required: false
type: string
default: true
type: boolean
workflow_dispatch:
inputs:
upstream_branch:
@@ -78,8 +65,8 @@ on:
- None
- Master Models
- Release Models
- 2025 World Models
- 2026 World Models
- 2026 Deep RL Models
- Custom Merge Models
- Other
custom_model_folder:
@@ -94,22 +81,9 @@ on:
description: 'Minimum selector version'
required: false
type: string
target_hardware:
description: 'Hardware target to compile for'
required: false
type: choice
default: 'qcom'
options:
- qcom
- usbgpu
hf_repo:
description: 'Hugging Face dataset repository'
required: false
type: string
default: 'sunnypilot/sunnypilot_models_v1'
env:
RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }}
JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_v' || 'v' }}${{ inputs.json_version }}.json
JSON_FILE: docs/docs/driving_models_v${{ inputs.json_version }}.json
jobs:
build_model:
@@ -119,20 +93,38 @@ jobs:
custom_name: ${{ inputs.custom_name || inputs.upstream_branch }}
is_20hz: ${{ inputs.is_20hz }}
artifact_suffix: ${{ inputs.artifact_suffix }}
target_hardware: ${{ inputs.target_hardware }}
secrets: inherit
publish_model:
if: ${{ !cancelled() && needs.build_model.result == 'success' }}
if: ${{ !inputs.bypass_push && !cancelled() }}
concurrency:
group: hf-push-${{ inputs.recompiled_dir }}
group: gitlab-push-${{ inputs.recompiled_dir }}
cancel-in-progress: false
needs: build_model
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
steps:
- name: Set up SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}
- name: Add GitLab.com SSH key to known_hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
- name: Clone GitLab docs repo
env:
GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=~/.ssh/known_hosts'
run: |
echo "Cloning GitLab"
git clone --depth 1 --filter=tree:0 --sparse git@gitlab.com:sunnypilot/public/${{ vars.MODELS_GITLAB }} gitlab_docs
cd gitlab_docs
echo "checkout models/${RECOMPILED_DIR}"
git sparse-checkout set --no-cone models/${RECOMPILED_DIR}
git checkout main
cd ..
- name: Checkout docs repo
uses: actions/checkout@v4
with:
@@ -141,28 +133,16 @@ jobs:
path: docs
ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }}
- name: Install huggingface_hub
run: pip install --upgrade "huggingface_hub>=0.22.0"
- name: Validate hf_repo and JSON version
env:
HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }}
- name: Validate recompiled dir and JSON version
run: |
if [ ! -d "gitlab_docs/models/$RECOMPILED_DIR" ]; then
echo "Recompiled dir $RECOMPILED_DIR does not exist in GitLab repo"
exit 1
fi
if [ ! -f "$JSON_FILE" ]; then
echo "JSON file $JSON_FILE does not exist!"
exit 1
fi
python3 -c "
import sys
from huggingface_hub import HfApi
try:
api = HfApi()
api.repo_info(repo_id=sys.argv[1], repo_type='dataset')
print(f'Success: Repo {sys.argv[1]} exists.')
except Exception as e:
print('HF validation failed:', e)
sys.exit(1)
" "${{ inputs.hf_repo }}"
- name: Download artifact name file
uses: actions/download-artifact@v4
@@ -182,26 +162,49 @@ jobs:
name: ${{ steps.read-artifact-name.outputs.artifact_name }}
path: output
- name: Create models folder
- name: Remove unwanted files
run: |
find output -type f -name 'dmonitoring_model_tinygrad.pkl' -delete
find output -type f -name 'dmonitoring_model.onnx' -delete
- name: Copy model artifact(s) to GitLab recompiled dir
env:
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
run: |
mkdir -p "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}"
cp -r output/* "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/"
rm -f "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/artifact_name.txt"
ARTIFACT_DIR="gitlab_docs/models/${RECOMPILED_DIR}/${ARTIFACT_NAME}"
mkdir -p "$ARTIFACT_DIR"
for path in output/*; do
if [ "$(basename "$path")" = "artifact_name.txt" ]; then
continue
fi
name="$(basename "$path")"
if [ -d "$path" ]; then
mkdir -p "$ARTIFACT_DIR/$name"
cp -r "$path"/* "$ARTIFACT_DIR/$name/"
echo "Copied dir $name -> $ARTIFACT_DIR/$name"
else
cp "$path" "$ARTIFACT_DIR/"
echo "Copied file $name -> $ARTIFACT_DIR/"
fi
done
- name: Upload to Hugging Face
- name: Push recompiled dir to GitLab
env:
HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }}
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
GITLAB_SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}
run: |
hf upload ${{ inputs.hf_repo }} \
output/ \
"models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" \
--repo-type=dataset
cd gitlab_docs
git checkout main
git pull origin main
for d in models/"$RECOMPILED_DIR"/*/; do
git sparse-checkout add "$d"
done
git add models/"$RECOMPILED_DIR"
git config --global user.name "GitHub Action"
git config --global user.email "action@github.com"
git commit -m "Create/Update $RECOMPILED_DIR with new/updated model from build-single-tinygrad-model" || echo "No changes to commit"
git push origin main
- name: Pull gh-pages
run: |
- run: |
cd docs
git pull origin gh-pages
@@ -217,11 +220,9 @@ jobs:
fi
[ -n "${{ inputs.generation }}" ] && ARGS="$ARGS --generation \"${{ inputs.generation }}\""
[ -n "${{ inputs.version }}" ] && ARGS="$ARGS --version \"${{ inputs.version }}\""
[ -n "${{ inputs.set_min_version }}" ] && ARGS="$ARGS --set-min-version \"${{ inputs.set_min_version }}\""
[ -n "${{ inputs.tinygrad_ref }}" ] && ARGS="$ARGS --tinygrad-ref \"${{ inputs.tinygrad_ref }}\""
eval python3 docs/json_parser.py \
--json-path "$JSON_FILE" \
--recompiled-dir "local_models/$RECOMPILED_DIR" \
--recompiled-dir "gitlab_docs/models/$RECOMPILED_DIR" \
--sort-by-date \
$ARGS
@@ -1,66 +0,0 @@
name: Download HF model chunks
description: Resolve and download model chunks from HuggingFace in parallel
inputs:
hf_repo:
description: HuggingFace dataset repo
required: true
models:
description: 'JSON array of {hf_path, onnx_hash, canonical} objects'
required: true
dest_dir:
description: Destination directory for downloaded chunks
required: true
runs:
using: composite
steps:
- name: Download model chunks
shell: bash
env:
HF_REPO: ${{ inputs.hf_repo }}
MODELS_JSON: ${{ inputs.models }}
DEST_DIR: ${{ inputs.dest_dir }}
run: |
set -eo pipefail
DOWNLOAD_LIST=$(mktemp)
resolve_chunks() {
local HF_PATH="$1" ONNX_HASH="$2" CANONICAL="$3" DEST_DIR="$4"
local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json"
local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS
DEFAULTS=$(curl -fsSL "$JSON_URL")
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)')
ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact')
BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||')
NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length')
mkdir -p "$DEST_DIR"
while IFS= read -r CHUNK_NAME; do
CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true)
if [ -z "$CHUNK_IDX" ]; then
echo "::error::Failed to parse chunk index from: $CHUNK_NAME"
return 1
fi
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))")
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST"
done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name')
echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest"
}
echo "$MODELS_JSON" | jq -c '.[]' | while IFS= read -r model; do
HF_PATH=$(echo "$model" | jq -r '.hf_path')
ONNX_HASH=$(echo "$model" | jq -r '.onnx_hash')
CANONICAL=$(echo "$model" | jq -r '.canonical')
resolve_chunks "$HF_PATH" "$ONNX_HASH" "$CANONICAL" "$DEST_DIR"
done
TOTAL=$(wc -l < "$DOWNLOAD_LIST")
echo "Downloading $TOTAL chunks with 8 parallel connections..."
xargs -P8 -d'\n' -I{} bash -c '
URL="${1%% *}"
DEST="${1#* }"
echo "Downloading $(basename "$DEST")"
curl -fsSL --retry 3 --retry-delay 5 -o "$DEST" "$URL"
' _ {} < "$DOWNLOAD_LIST"
rm -f "$DOWNLOAD_LIST"
+38 -46
View File
@@ -80,7 +80,6 @@ jobs:
with:
repository: commaai/openpilot
ref: ${{ inputs.upstream_branch }}
fetch-depth: 1
submodules: recursive
path: openpilot
@@ -90,38 +89,26 @@ jobs:
with:
repository: sunnypilot/sunnypilot
ref: ${{ inputs.upstream_branch }}
fetch-depth: 1
submodules: recursive
path: openpilot
- name: Get commit date
id: commit-date
run: |
cd ${{ github.workspace }}/openpilot/openpilot
cd ${{ github.workspace }}/openpilot
commit_date=$(git log -1 --format=%cd --date=format:'%B %d, %Y')
echo "model_date=${commit_date}" >> $GITHUB_OUTPUT
cat $GITHUB_OUTPUT
- run: |
cd ${{ github.workspace }}/openpilot/openpilot
if [ "${{ inputs.target_hardware }}" != "usbgpu" ]; then
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx"
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx
else
git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx" -X ""
find selfdrive/modeld/models -name "*.onnx" ! -name "big_*.onnx" -delete
fi
if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx; then
echo "::error::the ONNX files above are still LFS pointers, not real models"
exit 1
fi
cd ${{ github.workspace }}/openpilot
git lfs pull
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
with:
name: models-${{ env.REF }}${{ inputs.artifact_suffix }}
path: ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx
if-no-files-found: error
build_model:
runs-on: [self-hosted, usbgpu]
runs-on: [self-hosted, tici]
needs: get_model
env:
MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }})
@@ -129,9 +116,24 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
submodules: recursive
- run: git lfs pull
- name: Cache SCons
uses: actions/cache@v4
with:
path: ${{env.SCONS_CACHE_DIR}}
key: scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}-model-${{ github.sha }}
# Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.)
# for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden.
restore-keys: |
scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}-model
scons-${{ runner.os }}-${{ runner.arch }}-${{ github.head_ref || github.ref_name }}
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_NEW_BRANCH }}-model
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_BRANCH }}-model
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_NEW_BRANCH }}
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.MASTER_BRANCH }}
scons-${{ runner.os }}-${{ runner.arch }}
- name: Set environment variables
id: set-env
@@ -142,7 +144,7 @@ jobs:
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
uv sync
printenv >> $GITHUB_ENV
if [[ "${{ runner.debug }}" == "1" ]]; then
cat $GITHUB_OUTPUT
@@ -164,13 +166,15 @@ jobs:
fi
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
rm -rf ${{ env.MODELS_DIR }}/*.onnx*
rm -rf ${{ env.MODELS_DIR }}/*.onnx
- name: Download model artifacts
uses: actions/download-artifact@v4
with:
name: models-${{ env.REF }}${{ inputs.artifact_suffix }}
path: ${{ env.MODELS_DIR }}
- run: |
rm -f ${{ env.MODELS_DIR }}/{dmonitoring_model,big_driving_policy,big_driving_vision,big_driving_supercombo}.onnx
- name: Build Model
run: |
@@ -184,48 +188,34 @@ jobs:
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
if [ "${{ inputs.target_hardware }}" == "usbgpu" ]; then
echo "USBGPU build"
export USBGPU=1
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2"
TG_FLAGS="DEV=AMD USBGPU=1 IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl"
else
echo "QCOM build"
TG_FLAGS="$TG_FLAGS_QCOM"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
OUTPUT_PKL="${{ env.MODELS_DIR }}/driving_tinygrad.pkl"
fi
# Generate metadata for all ONNX files
find "${{ env.MODELS_DIR }}" -maxdepth 1 -name '*.onnx' | while IFS= read -r onnx_file; do
echo "Generating metadata: $onnx_file"
env ${TG_FLAGS_QCOM} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true
env ${TG_FLAGS} python3 "${{ env.MODELS_DIR }}/../get_model_metadata.py" "$onnx_file" || true
done
# Detect model type and build compile args
VISION_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_vision.onnx" "${{ env.MODELS_DIR }}/big_driving_vision.onnx"; do
[ -f "$f" ] && VISION_ONNX="$f" && break
done
POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_policy.onnx"; do
[ -f "$f" ] && POLICY_ONNX="$f" && break
done
OFF_POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_off_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_off_policy.onnx"; do
[ -f "$f" ] && OFF_POLICY_ONNX="$f" && break
done
ON_POLICY_ONNX=""
for f in "${{ env.MODELS_DIR }}/driving_on_policy.onnx" "${{ env.MODELS_DIR }}/big_driving_on_policy.onnx"; do
[ -f "$f" ] && ON_POLICY_ONNX="$f" && break
done
VISION_ONNX="${{ env.MODELS_DIR }}/driving_vision.onnx"
POLICY_ONNX="${{ env.MODELS_DIR }}/driving_policy.onnx"
OFF_POLICY_ONNX="${{ env.MODELS_DIR }}/driving_off_policy.onnx"
ON_POLICY_ONNX="${{ env.MODELS_DIR }}/driving_on_policy.onnx"
SUPERCOMBO_ONNX=""
for f in "${{ env.MODELS_DIR }}/supercombo.onnx" "${{ env.MODELS_DIR }}/driving_supercombo.onnx" "${{ env.MODELS_DIR }}/big_supercombo.onnx" "${{ env.MODELS_DIR }}/big_driving_supercombo.onnx"; do
[ -f "$f" ] && SUPERCOMBO_ONNX="$f" && break
for f in "${{ env.MODELS_DIR }}/supercombo.onnx" "${{ env.MODELS_DIR }}/driving_supercombo.onnx"; do
if [ -f "$f" ]; then
SUPERCOMBO_ONNX="$f"
break
fi
done
MODEL_TYPE="" ONNX_ARGS="" OUTPUT_NAME=""
@@ -264,8 +254,10 @@ jobs:
# Copy the model files
rsync -avm \
--include='*.dlc' \
--include='*.pkl' \
--include='*.chunk*' \
--include='*.chunkmanifest' \
--include='*.onnx' \
--exclude='*' \
--delete-excluded \
--chown=comma:comma \
+95 -320
View File
@@ -4,8 +4,12 @@ env:
BUILD_DIR: "/data/openpilot"
OUTPUT_DIR: ${{ github.workspace }}/output
CI_DIR: ${{ github.workspace }}/release/ci
SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache
PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot"
# Branch configurations
STAGING_SOURCE_BRANCH: 'master'
# Runtime configuration
SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}"
@@ -36,11 +40,8 @@ jobs:
publish_concurrency_group: ${{ steps.strategy.outputs.publish_concurrency_group }}
is_stable_branch: ${{ steps.strategy.outputs.is_stable_branch }}
build: ${{ steps.strategy.outputs.build }}
include_big_model: ${{ steps.strategy.outputs.include_big_model }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Extract deploy strategy
id: strategy
run: |
@@ -81,9 +82,6 @@ jobs:
stable_version=$(cat openpilot/sunnypilot/common/version.h | grep SUNNYPILOT_VERSION | sed -e 's/[^0-9|.]//g');
echo "version=$([ "$is_stable_branch" = "true" ] && echo "$stable_version" || echo "$BUILD")" >> $GITHUB_OUTPUT
echo "extra_version_identifier=${environment}" >> $GITHUB_OUTPUT
include_big_model="$(echo "$CONFIG" | jq -r '.include_big_model // false')";
echo "include_big_model=$include_big_model" >> $GITHUB_OUTPUT
fi
echo "build=$BUILD" >> $GITHUB_OUTPUT
cat $GITHUB_OUTPUT
@@ -98,8 +96,6 @@ jobs:
}}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Wait for Tests
uses: ./.github/workflows/wait-for-action # Path to where you place the action
with:
@@ -113,6 +109,11 @@ jobs:
group: build-${{ github.head_ref || github.ref_name }}
cancel-in-progress: false
runs-on: [self-hosted, tici]
outputs:
new_branch: ${{ needs.prepare_strategy.outputs.new_branch }}
version: ${{ needs.prepare_strategy.outputs.version }}
extra_version_identifier: ${{ needs.prepare_strategy.outputs.extra_version_identifier }}
commit_sha: ${{ github.sha }}
if: ${{
(always() && !cancelled() && !failure()) &&
needs.prepare_strategy.result == 'success' &&
@@ -123,14 +124,31 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
submodules: recursive
ref: ${{ env.SOURCE_BRANCH }}
repository: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name || github.repository }}
- run: git lfs pull
- name: Cache SCons
uses: actions/cache@v4
with:
path: ${{env.SCONS_CACHE_DIR}}
key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}-${{ github.sha }}
# Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.)
# for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden.
restore-keys: |
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.STAGING_SOURCE_BRANCH }}
scons-${{ runner.os }}-${{ runner.arch }}
- name: Set environment variables
id: set-env
run: |
echo "new_branch=${{ needs.prepare_strategy.outputs.new_branch }}" >> $GITHUB_OUTPUT
echo "version=${{ needs.prepare_strategy.outputs.version }}" >> $GITHUB_OUTPUT
echo "extra_version_identifier=${{ needs.prepare_strategy.outputs.extra_version_identifier }}" >> $GITHUB_OUTPUT
echo "commit_sha=${{ github.sha }}" >> $GITHUB_OUTPUT
# Set up common environment
source /etc/profile;
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
@@ -139,6 +157,9 @@ jobs:
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync
printenv >> $GITHUB_ENV
if [[ "${{ runner.debug }}" == "1" ]]; then
cat $GITHUB_OUTPUT
fi
- name: Setup build environment
run: |
@@ -147,7 +168,7 @@ jobs:
echo "Starting build stage..."
echo "BUILD_DIR: ${BUILD_DIR}"
echo "CI_DIR: ${CI_DIR}"
echo "VERSION: ${{ needs.prepare_strategy.outputs.version }}"
echo "VERSION: ${{ steps.set-env.outputs.version }}"
echo "UV_PROJECT_ENVIRONMENT: ${UV_PROJECT_ENVIRONMENT}"
echo "VIRTUAL_ENV: ${VIRTUAL_ENV}"
echo "-------"
@@ -159,44 +180,61 @@ jobs:
- name: Build Main Project
run: |
export PYTHONPATH="$BUILD_DIR:$BUILD_DIR/msgq_repo:$BUILD_DIR/opendbc_repo:$BUILD_DIR/rednose_repo:$BUILD_DIR/teleoprtc_repo:$BUILD_DIR/tinygrad_repo"
./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" --
# outside the checkout, which is wiped each run. /data/scons_cache is the device's, not ours.
SCONS_CACHE="$RUNNER_WORKSPACE/scons_cache"
mkdir -p "$SCONS_CACHE"
export PYTHONPATH="$BUILD_DIR"
./tools/release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/
cd $BUILD_DIR
echo "Building locationd..."
# -j1: parallel rednose generators OOM the device
scons -j1 cache_dir="$SCONS_CACHE" --minimal \
openpilot/selfdrive/locationd openpilot/sunnypilot/selfdrive/locationd
ln -sfn msgq_repo/msgq msgq
ln -sfn opendbc_repo/opendbc opendbc
ln -sfn rednose_repo/rednose rednose
ln -sfn teleoprtc_repo/teleoprtc teleoprtc
ln -sfn tinygrad_repo/tinygrad tinygrad
sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py
echo "Building sunnypilot's modeld_v2..."
scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal openpilot/sunnypilot/modeld_v2
echo "Building sunnypilot's locationd..."
scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal openpilot/sunnypilot/selfdrive/locationd
echo "Building openpilot's locationd..."
scons -j1 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal openpilot/selfdrive/locationd
echo "Building rest of sunnypilot"
SKIP_TINYGRAD_COMPILE=1 /usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal
scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal
touch ${BUILD_DIR}/prebuilt
if [[ "${{ runner.debug }}" == "1" ]]; then
ls -la ${BUILD_DIR}
fi
- name: Strip release tree
- name: Prepare Output
run: |
cd $BUILD_DIR
find . -name '*.a' -delete
find . -name '*.o' -delete
find . -name '*.os' -delete
find . -name '*.pyc' -delete
find . -name 'moc_*' -delete
find . -name '__pycache__' -type d -exec rm -rf {} +
find . -name 'SConstruct' -delete
find . -name 'SConscript' -delete
rm -rf .sconsign.dblite Jenkinsfile tools/release/ release/
rm -f openpilot/selfdrive/modeld/models/*.onnx*
rm -f openpilot/sunnypilot/modeld*/models/*.onnx*
find openpilot/third_party/ -name '*x86*' -exec rm -r {} +
find openpilot/third_party/ -name '*Darwin*' -exec rm -r {} +
cd -
sudo rm -rf ${OUTPUT_DIR}
mkdir -p ${OUTPUT_DIR}
rsync -am${RUNNER_DEBUG:+v} \
--exclude='.sconsign.dblite' \
--exclude='*.a' \
--exclude='*.o' \
--exclude='*.os' \
--exclude='*.pyc' \
--exclude='moc_*' \
--exclude='__pycache__' \
--exclude='Jenkinsfile' \
--exclude='**/release/' \
--exclude='**/.github/' \
--exclude='**/openpilot/selfdrive/ui/replay/' \
--exclude='**/__pycache__/' \
--exclude='${{env.SCONS_CACHE_DIR}}' \
--exclude='**/.git/' \
--exclude='**/SConstruct' \
--exclude='**/SConscript' \
--exclude='**/.venv/' \
--exclude='openpilot/selfdrive/modeld/models/*.onnx*' \
--exclude='openpilot/sunnypilot/modeld*/models/*.onnx*' \
--exclude='openpilot/third_party/*x86*' \
--exclude='openpilot/third_party/*Darwin*' \
--delete-excluded \
--chown=comma:comma \
${BUILD_DIR}/ ${OUTPUT_DIR}/
- name: 'Tar.gz files'
run: |
tar czf prebuilt.tar.gz -C ${{ env.BUILD_DIR }} .
tar czf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} .
ls -la prebuilt.tar.gz
- name: 'Upload Artifact'
@@ -204,7 +242,6 @@ jobs:
with:
name: prebuilt
path: prebuilt.tar.gz
compression-level: 0
- name: Re-enable powersave
if: always()
@@ -212,212 +249,22 @@ jobs:
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
prepare_chestnut:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
if: ${{ needs.prepare_strategy.outputs.include_big_model == 'true' }}
outputs:
onnx_sha256: ${{ steps.resolve.outputs.onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/big
steps:
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
ONNX_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "ONNX hash: $ONNX_HASH"
echo "onnx_sha256=$ONNX_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ]
}
if check_defaults; then
echo "HF defaults match repo ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=big
echo "Polling HF for big model availability..."
for i in $(seq 1 90); do
sleep 30
if check_defaults; then
echo "Big model available on HF after $((i * 30))s"
exit 0
fi
echo "Poll $i/90: not yet available"
done
echo "::error::Big model not available on HF after 45 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare_small_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
outputs:
driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/small
steps:
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
DRIVING_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/driving_supercombo.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "Driving ONNX hash: $DRIVING_HASH"
echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] || return 1
}
if check_defaults; then
echo "HF defaults match repo ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=small
echo "Polling HF for model availability..."
for i in $(seq 1 60); do
sleep 30
if check_defaults; then
echo "Model available on HF after $((i * 30))s"
exit 0
fi
echo "Poll $i/60: not yet available"
done
echo "::error::Small driving model not available on HF after 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare_dm_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
outputs:
dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/dm
steps:
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
DM_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "DM ONNX hash: $DM_HASH"
echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$DM" ] && [ "$DM" != "null" ] || return 1
}
if check_defaults; then
echo "HF defaults match DM ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching DM model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=dm
echo "Polling HF for DM model availability..."
for i in $(seq 1 60); do
sleep 30
if check_defaults; then
echo "DM model available on HF after $((i * 30))s"
exit 0
fi
echo "Poll $i/60: not yet available"
done
echo "::error::DM model not available on HF after 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish:
concurrency:
# We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name.
# This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other.
# Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time.
group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}
cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
if: ${{
always() && !cancelled() &&
needs.build.result == 'success' &&
needs.prepare_strategy.result == 'success' &&
needs.prepare_small_model.result == 'success' &&
needs.prepare_dm_model.result == 'success' &&
(!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) &&
(needs.prepare_strategy.outputs.include_big_model != 'true' || needs.prepare_chestnut.result == 'success')
}}
needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ]
if: ${{ (always() && !cancelled() && !failure()) && needs.build.result == 'success' && needs.prepare_strategy.result == 'success' && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }}
needs: [ build, prepare_strategy ]
runs-on: ubuntu-24.04
environment: ${{ needs.prepare_strategy.outputs.environment }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download prebuilt artifact
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: prebuilt
@@ -427,17 +274,6 @@ jobs:
mkdir -p ${{ env.OUTPUT_DIR }}
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
- name: Download model chunks from HF
uses: ./.github/workflows/download-hf-model-chunks
with:
hf_repo: sunnypilot/sunnypilot_models_v1
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
models: |
[
{"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"},
{"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"}
]
- name: Configure Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
@@ -447,95 +283,36 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo '${{ toJSON(needs.prepare_strategy.outputs) }}'
echo '${{ toJSON(needs.build.outputs) }}'
ls -la ${{ env.OUTPUT_DIR }}
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
"${{ env.OUTPUT_DIR }}" \
"${{ needs.prepare_strategy.outputs.new_branch }}" \
"${{ needs.prepare_strategy.outputs.version }}" \
"${{ needs.build.outputs.new_branch }}" \
"${{ needs.build.outputs.version }}" \
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
"${{ needs.build.outputs.extra_version_identifier }}"
echo ""
echo "---- ️ To update the list of branches that auto deploy prebuilts -----"
echo ""
echo "1. Go to: ${{ github.server_url }}/${{ github.repository }}/settings/variables/actions/AUTO_DEPLOY_PREBUILT_BRANCHES"
echo "2. Current value: ${{ vars.AUTO_DEPLOY_PREBUILT_BRANCHES }}"
echo "3. Update as needed (JSON array with no spaces)"
- name: Tag ${{ needs.prepare_strategy.outputs.environment }}
if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }}
run: |
TAG="${{ needs.prepare_strategy.outputs.environment }}/${{ needs.prepare_strategy.outputs.version }}/${{ needs.prepare_strategy.outputs.build }}"
git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.prepare_strategy.outputs.build }}."
git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.build.outputs.build }}."
git push -f origin ${TAG}
publish_chestnut:
concurrency:
group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}-chestnut
cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
if: ${{
always() && !cancelled() &&
needs.build.result == 'success' &&
needs.prepare_strategy.result == 'success' &&
needs.prepare_small_model.result == 'success' &&
needs.prepare_dm_model.result == 'success' &&
needs.prepare_chestnut.result == 'success' &&
(!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
}}
needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ]
runs-on: ubuntu-24.04
environment: ${{ needs.prepare_strategy.outputs.environment }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download prebuilt artifact
uses: actions/download-artifact@v4
with:
name: prebuilt
- name: Untar prebuilt
run: |
mkdir -p ${{ env.OUTPUT_DIR }}
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
- name: Download model chunks from HF
uses: ./.github/workflows/download-hf-model-chunks
with:
hf_repo: sunnypilot/sunnypilot_models_v1
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
models: |
[
{"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"},
{"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"},
{"hf_path": "models/defaults/big", "onnx_hash": "${{ needs.prepare_chestnut.outputs.onnx_sha256 }}", "canonical": "big_driving_tinygrad.pkl"}
]
- name: Configure Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Publish chestnut branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut"
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
"${{ env.OUTPUT_DIR }}" \
"$CHESTNUT_BRANCH" \
"${{ needs.prepare_strategy.outputs.version }}" \
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
notify:
needs:
- prepare_strategy
- build
- publish
- publish_chestnut
- prepare_chestnut
- prepare_small_model
- prepare_dm_model
runs-on: ubuntu-24.04
if: ${{ (always() && !cancelled() && !failure())
&& needs.publish.result == 'success'
@@ -543,12 +320,11 @@ jobs:
&& (fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name] != null) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Prepare notification message
id: message
run: |
TEMPLATE='${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}'
export VERSION="${{ needs.prepare_strategy.outputs.version }}"
export branch_name="${{ env.SOURCE_BRANCH }}"
export new_branch="${{ needs.prepare_strategy.outputs.new_branch }}"
@@ -557,7 +333,6 @@ jobs:
export commit_short_sha="${commit_short_sha:0:7}"
export extra_version_identifier="${{ needs.prepare_strategy.outputs.extra_version_identifier || github.run_number }}"
export PUBLIC_REPO_URL="${{ env.PUBLIC_REPO_URL }}"
export chestnut_branch="${{ needs.prepare_chestnut.result == 'success' && format('{0}-chestnut', needs.prepare_strategy.outputs.new_branch) || '' }}"
MESSAGE=$(cat << 'EOF' | envsubst
${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}
@@ -598,7 +373,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: process.env.LABEL
name: process.env.LABELf
});
console.log(`Removed '${process.env.LABEL}' label from PR #${prNumber}`);
+1 -2
View File
@@ -25,8 +25,7 @@ env:
jobs:
preview:
if: false # tmp disable due to GH API rate limiting flakiness
#if: github.repository == 'sunnypilot/sunnypilot'
if: github.repository == 'sunnypilot/sunnypilot'
name: preview
runs-on: ubuntu-latest
timeout-minutes: 20
Vendored
-1
View File
@@ -22,7 +22,6 @@ shopt -s huponexit # kill all child processes when the shell exits
export CI=1
export PYTHONWARNINGS=error
export COMMA_CACHE=/data/tmp/comma_download_cache
#export LOGPRINT=debug # this has gotten too spammy...
export TEST_DIR=${env.TEST_DIR}
export SOURCE_DIR=${env.SOURCE_DIR}
+11 -14
View File
@@ -4,7 +4,7 @@
A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified.
# 345 Supported Cars
# 342 Supported Cars
|Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|<a href="##"><img width=2000></a>Hardware Needed<br>&nbsp;|Video|Setup Video|
|---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
@@ -34,7 +34,6 @@ A supported vehicle is one that just works when you install a comma device. All
|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Chrysler Pacifica Hybrid 2019-25">Buy Here</a></sub></details>|||
|comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None|<a href="https://youtu.be/VT-i3yRsX2s?t=2736" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|CUPRA[<sup>12</sup>](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=CUPRA Ateca 2018-23">Buy Here</a></sub></details>|||
|CUPRA[<sup>12</sup>](#footnotes)|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=CUPRA Born 2021-23">Buy Here</a></sub></details>|||
|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Dodge Durango 2020-21">Buy Here</a></sub></details>|||
|Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Ford Q3 connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ford Bronco Sport 2021-24">Buy Here</a></sub></details>|||
|Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Ford Q3 connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Ford Escape 2020-22">Buy Here</a></sub></details>|||
@@ -83,7 +82,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Honda|City (Brazil only) 2023-25|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda City (Brazil only) 2023-25">Buy Here</a></sub></details>|||
|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic 2016-18">Buy Here</a></sub></details>|<a href="https://youtu.be/-IkImTe1NYE" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Honda|Civic 2019-21|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|2 mph[<sup>4</sup>](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic 2019-21">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=4Iz1Mz5LGF8" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Honda|Civic 2022-26|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic 2022-26">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Honda|Civic 2022-24|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2017-18">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback 2019-21|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2019-21">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback 2022-24|All|openpilot available[<sup>1,5</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
@@ -159,7 +158,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Hyundai|Tucson Plug-in Hybrid 2024|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai N connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Hyundai Tucson Plug-in Hybrid 2024">Buy Here</a></sub></details>|||
|Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai E connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Hyundai Veloster 2019-20">Buy Here</a></sub></details>|||
|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Jeep Grand Cherokee 2016-18">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=eLR9o2JkuRk" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Jeep Grand Cherokee 2019-21">Buy Here</a></sub></details>|||
|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 FCA connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Jeep Grand Cherokee 2019-21">Buy Here</a></sub></details>|<a href="https://www.youtube.com/watch?v=jBe4lWnRSu4" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Kia|Carnival 2022-24|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai A connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Carnival 2022-24">Buy Here</a></sub></details>|||
|Kia|Carnival (China only) 2023|Smart Cruise Control (SCC)|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai K connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Carnival (China only) 2023">Buy Here</a></sub></details>|||
|Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Hyundai E connector<br>- 1 OBD-C cable (2 ft)<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Kia Ceed 2019-21">Buy Here</a></sub></details>|||
@@ -243,20 +242,20 @@ A supported vehicle is one that just works when you install a comma device. All
|Rivian|R1T 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Rivian B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Rivian R1T 2025">Buy Here</a></sub></details>|||
|SEAT[<sup>12</sup>](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=SEAT Ateca 2016-23">Buy Here</a></sub></details>|||
|SEAT[<sup>12</sup>](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=SEAT Leon 2014-20">Buy Here</a></sub></details>|||
|Subaru|Ascent 2019-21|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Ascent 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2020-23">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Ascent 2019-21|All[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Ascent 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Crosstrek 2020-23">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Forester 2017-18|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2017-18">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Forester 2019-21|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Impreza 2017-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2017-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Impreza 2020-22|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Forester 2019-21|All[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Forester 2019-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Impreza 2017-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2017-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Impreza 2020-22|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Impreza 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Legacy 2015-18|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Legacy 2015-18">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Legacy 2020-22|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Legacy 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Outback 2015-17|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2015-17">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Outback 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|Outback 2020-22|All[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru B connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru Outback 2020-22">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|XV 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Subaru|XV 2020-21|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2020-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Subaru|XV 2018-19|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2018-19">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|<a href="https://youtu.be/Agww7oE1k-s?t=26" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Subaru|XV 2020-21|EyeSight Driver Assistance[<sup>7</sup>](#footnotes)|openpilot available[<sup>1,8</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 Subaru A connector<br>- 1 comma four<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Subaru XV 2020-21">Buy Here</a></sub></details><details><summary>Tools</summary><sub>- 1 Pry Tool<br>- 1 Socket Wrench 8mm or 5/16" (deep)</sub></details>|||
|Škoda|Fabia 2022-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Fabia 2022-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|Škoda|Kamiq 2021-23[<sup>13,15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Kamiq 2021-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|Škoda[<sup>12</sup>](#footnotes)|Karoq 2019-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Karoq 2019-23">Buy Here</a></sub></details>|||
@@ -335,8 +334,6 @@ A supported vehicle is one that just works when you install a comma device. All
|Volkswagen[<sup>12</sup>](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf R 2015-19">Buy Here</a></sub></details>|||
|Volkswagen[<sup>12</sup>](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Golf SportsVan 2015-20">Buy Here</a></sub></details>|||
|Volkswagen[<sup>12</sup>](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Grand California 2019-24">Buy Here</a></sub></details>|<a href="https://youtu.be/4100gLeabmo" target="_blank"><img height="18px" src="assets/icon-youtube.svg" /></a>||
|Volkswagen[<sup>12</sup>](#footnotes)|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen ID.4 2021-23">Buy Here</a></sub></details>|||
|Volkswagen[<sup>12</sup>](#footnotes)|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[<sup>16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen ID.4 2024-25">Buy Here</a></sub></details>|||
|Volkswagen[<sup>12</sup>](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Jetta 2019-23">Buy Here</a></sub></details>|||
|Volkswagen[<sup>12</sup>](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Jetta GLI 2021-23">Buy Here</a></sub></details>|||
|Volkswagen|Passat 2015-22[<sup>14</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 OBD-C cable (2 ft)<br>- 1 VW J533 connector<br>- 1 comma four<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br><a href="https://comma.ai/shop/comma-3x?harness=Volkswagen Passat 2015-22">Buy Here</a></sub></details>|||
+1 -3
View File
@@ -24,9 +24,7 @@ function agnos_init {
if $AGNOS_PY --verify $MANIFEST; then
sudo reboot
fi
while true; do
$DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST
done
$DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST
fi
}
+1 -34
View File
@@ -131,7 +131,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
downloaded @2;
cached @3;
failed @4;
verifying @5;
}
struct DownloadProgress {
@@ -382,7 +381,6 @@ struct CarControlSP @0xa5cd762cd951a455 {
leadOne @2 :LeadData;
leadTwo @3 :LeadData;
intelligentCruiseButtonManagement @4 :IntelligentCruiseButtonManagement;
fordLateralPath @5 :FordLateralPath;
struct Param {
key @0 :Text;
@@ -403,14 +401,6 @@ struct CarControlSP @0xa5cd762cd951a455 {
}
}
struct FordLateralPath {
pathOffset @0 :Float32; # c0 [m]
pathAngle @1 :Float32; # c1 [rad]
curvature @2 :Float32; # c2 [1/m]
curvatureRate @3 :Float32; # c3 [1/m^2]
valid @4 :Bool;
}
struct BackupManagerSP @0xf98d843bfd7004a3 {
backupStatus @0 :Status;
restoreStatus @1 :Status;
@@ -478,30 +468,7 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
}
}
struct AssistedDrivingMilestoneState @0xcb9fd56c7057593a {
enabled @0 :Bool;
madsDistanceMeters @1 :Float64;
fullAssistDistanceMeters @2 :Float64;
event @3 :Event;
struct Event {
id @0 :UInt64;
category @1 :Category;
distanceMeters @2 :Float64;
previousDistanceMeters @3 :Float64;
unit @4 :Unit;
}
enum Category {
none @0;
mads @1;
fullAssist @2;
}
enum Unit {
imperial @0;
metric @1;
}
struct CustomReserved10 @0xcb9fd56c7057593a {
}
struct CustomReserved11 @0xc2243c65e0340384 {
+1 -1
View File
@@ -2640,7 +2640,7 @@ struct Event {
carStateSP @114 :Custom.CarStateSP;
liveMapDataSP @115 :Custom.LiveMapDataSP;
modelDataV2SP @116 :Custom.ModelDataV2SP;
assistedDrivingMilestoneState @136 :Custom.AssistedDrivingMilestoneState;
customReserved10 @136 :Custom.CustomReserved10;
customReserved11 @137 :Custom.CustomReserved11;
customReserved12 @138 :Custom.CustomReserved12;
customReserved13 @139 :Custom.CustomReserved13;
-1
View File
@@ -90,7 +90,6 @@ _services: dict[str, tuple] = {
"carParamsSP": (True, 0.02, 1),
"carControlSP": (True, 100., 10),
"carStateSP": (True, 100., 10),
"assistedDrivingMilestoneState": (True, 10., 1),
"liveMapDataSP": (True, 1., 1),
"modelDataV2SP": (True, 20., None, QueueSize.BIG),
"liveLocationKalman": (True, 20.),
+1 -1
View File
@@ -15,7 +15,7 @@ public:
static std::string get_serial() { return "cccccc"; }
static std::map<std::string, std::string> get_init_logs(bool route_log = false) {
static std::map<std::string, std::string> get_init_logs() {
return {};
}
+7 -9
View File
@@ -59,7 +59,7 @@ public:
std::ofstream("/sys/class/leds/led:switch_2/brightness") << value << "\n";
}
static std::map<std::string, std::string> get_init_logs(bool route_log = false) {
static std::map<std::string, std::string> get_init_logs() {
std::map<std::string, std::string> ret = {
{"/BUILD", util::read_file("/BUILD")},
{"lsblk", util::check_output("lsblk -o NAME,SIZE,STATE,VENDOR,MODEL,REV,SERIAL")},
@@ -73,14 +73,12 @@ public:
temp.erase(temp.find_last_not_of(std::string("\0\r\n", 3))+1);
ret["boot temp"] = temp;
// TODO: these are too slow to do on route log inits. need to do it async?
if (!route_log) {
for (std::string part : {"xbl", "abl", "aop", "devcfg", "xbl_config"}) {
for (std::string slot : {"a", "b"}) {
std::string partition = part + "_" + slot;
std::string hash = util::check_output("sha256sum /dev/disk/by-partlabel/" + partition);
ret[partition] = hash.substr(0, hash.find_first_of(" "));
}
// TODO: log something from system and boot
for (std::string part : {"xbl", "abl", "aop", "devcfg", "xbl_config"}) {
for (std::string slot : {"a", "b"}) {
std::string partition = part + "_" + slot;
std::string hash = util::check_output("sha256sum /dev/disk/by-partlabel/" + partition);
ret[partition] = hash.substr(0, hash.find_first_of(" "));
}
}
-4
View File
@@ -97,10 +97,6 @@ Params::Params(const std::string &path) {
}
Params::~Params() {
flushNonBlockingWrites();
}
void Params::flushNonBlockingWrites() {
if (future.valid()) {
future.wait();
}
-1
View File
@@ -75,7 +75,6 @@ public:
return put(key.c_str(), val ? "1" : "0", 1);
}
void putNonBlocking(const std::string &key, const std::string &val);
void flushNonBlockingWrites();
inline void putBoolNonBlocking(const std::string &key, bool val) {
putNonBlocking(key, val ? "1" : "0");
}
-5
View File
@@ -73,7 +73,6 @@ params_get = _bind("params_get", [ParamsHandle, ctypes.c_char_p, ctypes.c_bool],
params_get_bool = _bind("params_get_bool", [ParamsHandle, ctypes.c_char_p, ctypes.c_bool], ctypes.c_bool)
params_put = _bind("params_put", [ParamsHandle, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_bool], ctypes.c_int)
params_put_bool = _bind("params_put_bool", [ParamsHandle, ctypes.c_char_p, ctypes.c_bool, ctypes.c_bool], ctypes.c_int)
params_flush = _bind("params_flush", [ParamsHandle])
params_remove = _bind("params_remove", [ParamsHandle, ctypes.c_char_p], ctypes.c_int)
params_get_path = _bind("params_get_path", [ParamsHandle, ctypes.c_char_p, ctypes.c_size_t], ParamsBuffer)
params_keys_size = _bind("params_keys_size", [ParamsHandle], ctypes.c_size_t)
@@ -179,10 +178,6 @@ class Params:
def put_bool(self, key, val, block=False):
params_put_bool(self.p, self.check_key(key), val, block)
def flush(self):
"""Wait for all prior nonblocking writes from this Params instance."""
params_flush(self.p)
def remove(self, key):
params_remove(self.p, self.check_key(key))
-6
View File
@@ -133,12 +133,6 @@ int params_put_bool(ParamsHandle *handle, const char *key, bool value, bool bloc
});
}
void params_flush(ParamsHandle *handle) noexcept {
translate_exceptions([&]() {
handle->params.flushNonBlockingWrites();
});
}
int params_remove(ParamsHandle *handle, const char *key) noexcept {
return translate_exceptions(-1, [&]() {
return handle->params.remove(key);
+4 -13
View File
@@ -59,7 +59,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"IsDriverViewEnabled", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsEngaged", {PERSISTENT, BOOL}},
{"IsLdwEnabled", {PERSISTENT | BACKUP, BOOL}},
{"IsLiveStreaming", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}},
{"IsLiveStreaming", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsMetric", {PERSISTENT | BACKUP, BOOL}},
{"IsOffroad", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsRhdDetected", {PERSISTENT, BOOL}},
@@ -91,10 +91,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ExcessiveActuation", {PERSISTENT, JSON}},
{"Offroad_NeosUpdate", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_NoFirmware", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_Recalibration", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_TemperatureTooHigh", {CLEAR_ON_MANAGER_START, JSON}},
@@ -136,8 +136,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// --- sunnypilot params --- //
{"ApiCache_DriveStats", {PERSISTENT, JSON}},
{"AssistedDrivingMilestonesEnabled", {PERSISTENT | BACKUP, BOOL, "1"}},
{"AssistedDrivingMilestoneState", {PERSISTENT, JSON, "{}"}},
{"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}},
{"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}},
{"BlinkerLateralReengageDelay", {PERSISTENT | BACKUP, INT, "0"}}, // seconds
@@ -158,7 +156,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DevUIInfo", {PERSISTENT | BACKUP, INT, "0"}},
{"EnableCopyparty", {PERSISTENT | BACKUP, BOOL}},
{"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}},
{"FullAssistDrivenDistanceMeters", {PERSISTENT, FLOAT, "0.0"}},
{"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
{"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}},
{"HasAcceptedTermsSP", {PERSISTENT, STRING, "0"}},
@@ -168,9 +165,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"IsDevelopmentBranch", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsReleaseSpBranch", {CLEAR_ON_MANAGER_START, BOOL}},
{"LastGPSPositionLLK", {PERSISTENT, STRING}},
{"LastDriveAssistedDrivingSummary", {PERSISTENT, JSON, "{}"}},
{"LeadDepartAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
{"MadsDrivenDistanceMeters", {PERSISTENT, FLOAT, "0.0"}},
{"MaxTimeOffroad", {PERSISTENT | BACKUP, INT, "1800"}},
{"ModelRunnerTypeCache", {CLEAR_ON_ONROAD_TRANSITION, INT}},
{"OffroadMode", {CLEAR_ON_MANAGER_START, BOOL}},
@@ -191,6 +186,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
{"CustomButtonAction", {PERSISTENT | BACKUP, INT, "0"}},
// MADS params
{"Mads", {PERSISTENT | BACKUP, BOOL, "1"}},
@@ -200,15 +196,11 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// Model Manager params
{"ModelManager_ActiveBundle", {PERSISTENT, JSON}},
{"ModelManager_ActiveBundleUSBGPU", {PERSISTENT, JSON}},
{"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, JSON}},
{"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
{"ModelManager_DownloadRef", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}},
{"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT}},
{"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}},
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_LastSyncTime_USBGPU", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}},
{"ModelManager_ModelsCache_USBGPU", {PERSISTENT | BACKUP, JSON}},
// Neural Network Lateral Control
{"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -251,7 +243,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// mapd
{"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}},
{"Mapd_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
{"MapdVersion", {PERSISTENT, STRING}},
{"MapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT, "0.0"}},
{"NextMapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, JSON}},
-7
View File
@@ -106,13 +106,6 @@ class TestParams(OpenpilotTestCase):
assert q.get("CarParams") is None
assert q.get("CarParams", True) == b"1"
def test_flush_non_blocking_writes(self):
self.params.put("DongleId", "first")
self.params.put("DongleId", "last")
self.params.flush()
assert self.params.get("DongleId") == "last"
def test_params_all_keys(self):
keys = Params().all_keys()
-9
View File
@@ -16,15 +16,6 @@ MASTER_SP_BRANCHES = ['master']
RELEASE_BRANCHES = ['release-tizi-staging', 'release-mici-staging', 'release-tizi', 'release-mici', 'nightly']
TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev'] + RELEASE_SP_BRANCHES + TESTED_SP_BRANCHES
CHESTNUT_BRANCHES = {
"staging": "staging-chestnut",
"dev": "dev-chestnut",
"release-mici": "release-chestnut",
"release-tizi": "release-chestnut",
"release-mici-staging": "release-chestnut-staging",
"release-tizi-staging": "release-chestnut-staging",
}
SP_BRANCH_MIGRATIONS = {
("tici", "staging-c3-new"): "staging-tici",
("tici", "dev-c3-new"): "staging-tici",
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:190e196eba6feffec125ac66cf7e77620b759e346fa69b80e3a3884a5694cb15
size 3225
oid sha256:c4c38772e6080aa4b8bf5212d3619e949775468c64f3edb88a1a426d767c38d2
size 1579
Binary file not shown.
-1
View File
@@ -63,6 +63,5 @@ def convert_carControlSP(struct: capnp.lib.capnp._DynamicStructReader) -> struct
struct_dataclass.intelligentCruiseButtonManagement = structs.IntelligentCruiseButtonManagement(
**remove_deprecated(struct_dict.get('intelligentCruiseButtonManagement', {}))
)
struct_dataclass.fordLateralPath = structs.FordLateralPath(**remove_deprecated(struct_dict.get('fordLateralPath', {})))
return struct_dataclass
@@ -13,7 +13,6 @@ from openpilot.common.swaglog import cloudlog
from opendbc.car.car_helpers import interfaces
from opendbc.car.vehicle_model import VehicleModel
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_path import FordPath, FordPathController
from openpilot.selfdrive.controls.lib.latcontrol import LatControl
from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID
from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle, STEER_ANGLE_SATURATION_THRESHOLD
@@ -53,8 +52,6 @@ class Controls(ControlsExt):
self.steer_limited_by_safety = False
self.curvature = 0.0
self.desired_curvature = 0.0
self.ford_path_controller = FordPathController()
self.ford_path = FordPath()
self.pose_calibrator = PoseCalibrator()
self.calibrated_pose: Pose | None = None
@@ -158,11 +155,6 @@ class Controls(ControlsExt):
actuators.curvature = float(lateral_output)
else:
actuators.steeringAngleDeg = float(lateral_output)
if self.CP.brand == "ford":
self.ford_path = self.ford_path_controller.update(model_v2 if self.sm.valid['modelV2'] else None,
self.desired_curvature, v_ego=CS.vEgo, active=CC.latActive,
current_curvature=self.curvature)
actuators.curvature = float(self.ford_path.curvature)
# Ensure no NaNs/Infs
for p in ACTUATOR_FIELDS:
attr = getattr(actuators, p)
@@ -1,166 +0,0 @@
from dataclasses import dataclass, fields
import math
import numpy as np
DBC_OFFSET = (-5.12, 5.11)
DBC_ANGLE = (-0.5, 0.5235)
DBC_CURVATURE = (-0.02, 0.02)
DBC_CURVATURE_RATE = (-0.001024, 0.001023)
_PATH_OFFSET_DISTANCE = 7.0
_PATH_MIN_LOOKAHEAD = 7.0
_CURVATURE_RATE_HORIZONS = (3.5, 5.0, 7.0)
_CENTERING_CURVATURE_BASEBAND = (0.003, 0.006)
_TRACKING_ERROR_DEADZONE = 0.0005
_TRACKING_ERROR_LIMIT = 0.012
_PATH_RATES = (4.0, 1.0, math.inf, 0.002)
@dataclass(frozen=True)
class FordPath:
valid: bool = False
path_offset: float = 0.0
path_angle: float = 0.0
curvature: float = 0.0
curvature_rate: float = 0.0
def _finite(value: float) -> float:
return float(value) if math.isfinite(value) else 0.0
def _sample(distance: float, distances: list[float], values: list[float]) -> float:
return float(np.interp(distance, distances, values))
def _model_path(model) -> tuple[list[float], list[float], list[float]] | None:
try:
x = [float(value) for value in model.position.x]
y = [float(value) for value in model.position.y]
heading = [float(value) for value in model.orientation.z]
except (AttributeError, TypeError, ValueError):
return None
if len(x) < 2 or len(x) != len(y) or len(x) != len(heading):
return None
if not all(math.isfinite(value) for values in (x, y, heading) for value in values):
return None
distance = [0.0]
for i in range(1, len(x)):
distance.append(distance[-1] + math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]))
if distance[-1] <= 0.0:
return None
unwrapped_heading = [heading[0]]
for value in heading[1:]:
delta = (value - unwrapped_heading[-1] + math.pi) % (2.0 * math.pi) - math.pi
unwrapped_heading.append(unwrapped_heading[-1] + delta)
return distance, y, unwrapped_heading
def _curvature_rate(path: tuple[list[float], list[float], list[float]]) -> float:
distance, _, heading = path
rates = []
for requested_horizon in _CURVATURE_RATE_HORIZONS:
horizon = min(requested_horizon, distance[-1])
start = _sample(0.0, distance, heading)
midpoint = _sample(0.5 * horizon, distance, heading)
end = _sample(horizon, distance, heading)
rates.append(4.0 * (start - 2.0 * midpoint + end) / horizon ** 2)
magnitude = sum(abs(rate) for rate in rates)
if magnitude == 0.0:
return 0.0
return sorted(rates)[1] * abs(sum(rates)) / magnitude
def _curvature(path: tuple[list[float], list[float], list[float]]) -> float:
distance, _, heading = path
horizon = min(_PATH_MIN_LOOKAHEAD, distance[-1])
return (_sample(horizon, distance, heading) - _sample(0.0, distance, heading)) / horizon
def _encode_path(model, desired_curvature: float, v_ego: float, current_curvature: float | None) -> FordPath:
path = _model_path(model)
if path is None:
return FordPath()
distance, offset, heading = path
lookahead = max(_finite(v_ego), _PATH_MIN_LOOKAHEAD)
path_offset = _sample(_PATH_OFFSET_DISTANCE, distance, offset)
path_angle = _sample(lookahead, distance, heading)
model_curvature = _curvature(path)
model_curvature_rate = _curvature_rate(path)
action_curvature = _finite(desired_curvature)
requested_curvature = max((model_curvature, action_curvature), key=abs)
maneuver_residual = requested_curvature - model_curvature
path_offset += 0.5 * maneuver_residual * _PATH_OFFSET_DISTANCE ** 2
path_angle += maneuver_residual * lookahead
correction = 0.0
tracking_demand = 0.0
wheel_beyond_target = False
if current_curvature is not None:
target_curvature = action_curvature
measured_curvature = _finite(current_curvature)
tracking_error = target_curvature - measured_curvature
correction = math.copysign(max(abs(tracking_error) - _TRACKING_ERROR_DEADZONE, 0.0), tracking_error)
tracking_demand = abs(correction)
wheel_beyond_target = target_curvature * measured_curvature > 0.0 and \
abs(target_curvature) + _TRACKING_ERROR_DEADZONE < abs(measured_curvature)
correction_limit = _TRACKING_ERROR_LIMIT
if correction * target_curvature < 0.0:
correction_limit = 0.5 * abs(target_curvature)
correction = float(np.clip(correction, -correction_limit, correction_limit))
correction_offset = 0.5 * correction * _PATH_OFFSET_DISTANCE ** 2
correction_angle = correction * lookahead
if wheel_beyond_target:
path_offset = correction_offset
path_angle = correction_angle
else:
path_offset += correction_offset
path_angle += correction_angle
spatial_demand = abs(model_curvature_rate) * lookahead / 3.0
overflow_demand = max(abs(requested_curvature) - DBC_CURVATURE[1], 0.0)
maneuver_demand = max(spatial_demand, overflow_demand, tracking_demand)
maneuver_share = 1.0 if wheel_beyond_target else \
float(np.interp(maneuver_demand, _CENTERING_CURVATURE_BASEBAND, (0.0, 1.0)))
path_offset *= maneuver_share
path_angle *= maneuver_share
centering_curvature = action_curvature
return FordPath(
valid=True,
path_offset=float(np.clip(path_offset, *DBC_OFFSET)),
path_angle=float(np.clip(path_angle, *DBC_ANGLE)),
curvature=float(np.clip(centering_curvature * (1.0 - maneuver_share), *DBC_CURVATURE)),
curvature_rate=float(np.clip(model_curvature_rate * maneuver_share, *DBC_CURVATURE_RATE)),
)
class FordPathController:
"""Convert the model path directly into one vehicle-independent Ford path command."""
def __init__(self, dt: float = 0.01):
self.dt = dt
self._last_path = FordPath(valid=True)
def _limit(self, target: FordPath) -> FordPath:
values = []
for field, rate in zip(fields(FordPath)[1:], _PATH_RATES, strict=True):
previous = getattr(self._last_path, field.name)
value = getattr(target, field.name)
values.append(float(np.clip(value, previous - rate * self.dt, previous + rate * self.dt)))
self._last_path = FordPath(True, *values)
return self._last_path
def update(self, model, desired_curvature: float, *, v_ego: float = 0.0, active: bool = True,
current_curvature: float | None = None) -> FordPath:
if not active:
self._last_path = FordPath(valid=True)
return FordPath()
if model is None:
return self._limit(FordPath(valid=True))
return self._limit(_encode_path(model, desired_curvature, v_ego, current_curvature))
@@ -44,7 +44,8 @@ class LongControl:
self.CP = CP
self.CP_SP = CP_SP
self.long_control_state = LongCtrlState.off
self.pid = PIDController(0.0, (CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV),
self.pid = PIDController((CP.longitudinalTuning.kpBP, CP.longitudinalTuning.kpV),
(CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV),
rate=1 / DT_CTRL)
self.last_output_accel = 0.0
@@ -35,7 +35,6 @@ build_files = [f'{gen}/acados_solver_long.c'] + casadi_model + casadi_cost_y + c
# extra generated files used to trigger a rebuild
generated_files = [
'acados_ocp_long.json',
f'{gen}/Makefile',
f'{gen}/main_long.c',
@@ -49,8 +49,9 @@ def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt,
max_accel = min(max_accel, coast_limit)
target_accel = np.clip(v_cruise - v_ego, A_CRUISE_MIN, max_accel)
j_cruise = np.interp(v_ego, A_CRUISE_MAX_BP, J_CRUISE_VALS)
target_accel = float(np.clip(target_accel, a_cruise_prev - j_cruise * dt, a_cruise_prev + j_cruise * dt))
if not e2e:
j_cruise = np.interp(v_ego, A_CRUISE_MAX_BP, J_CRUISE_VALS)
target_accel = float(np.clip(target_accel, a_cruise_prev - j_cruise * dt, a_cruise_prev + j_cruise * dt))
return target_accel
@@ -64,9 +65,10 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
self.dt = dt
self.allow_throttle = True
self.a_desired = init_a
self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt)
self.a_cruise = init_a
self.output_a_target = init_a
self.a_cruise = 0.0
self.output_a_target = 0.0
self.output_should_stop = False
self.v_desired_trajectory = np.zeros(CONTROL_N)
@@ -103,8 +105,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
if reset_state:
self.v_desired_filter.x = v_ego
self.output_a_target = np.clip(sm['carState'].aEgo, ACCEL_MIN, ACCEL_MAX)
self.a_cruise = self.output_a_target
self.a_desired = np.clip(sm['carState'].aEgo, ACCEL_MIN, ACCEL_MAX)
# Prevent divergence, smooth in current v_ego
self.v_desired_filter.x = max(0.0, self.v_desired_filter.update(v_ego))
@@ -112,11 +113,11 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
# No change cost when user is controlling the speed, or when standstill
prev_accel_constraint = not (reset_state or sm['carState'].standstill)
# Get new v_cruise and a_target from Smart Cruise Control and Speed Limit Assist
v_cruise, self.output_a_target = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.output_a_target, v_cruise)
# Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Assist
v_cruise, self.a_desired = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.a_desired, v_cruise)
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target)
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
self.mpc.update(sm['radarState'], personality=sm['selfdriveState'].personality)
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
@@ -129,7 +130,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
cloudlog.info("FCW triggered")
# Save starting point for next iteration
a_prev = self.output_a_target
a_prev = self.a_desired
action_t = self.CP.longitudinalActuatorDelay + DT_MDL
output_a_target_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX,
@@ -154,6 +155,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
self.output_should_stop = any(should_stop for _, _, should_stop in candidates)
self.output_a_target = np.clip(output_a_target, ACCEL_MIN, ACCEL_MAX)
self.a_desired = float(self.output_a_target)
self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.output_a_target + a_prev) / 2.0
def publish(self, sm, pm):
@@ -1,290 +0,0 @@
import math
from types import SimpleNamespace
import numpy as np
from openpilot.cereal import custom
from openpilot.selfdrive.car.helpers import convert_carControlSP
from openpilot.selfdrive.controls.lib.ford_path import DBC_CURVATURE, FordPathController
def _path(curvature: float, curvature_rate: float = 0.0, speed: float = 8.0):
t = np.linspace(0.0, 3.0, 61)
distance = speed * t
heading = curvature * distance + 0.5 * curvature_rate * distance ** 2
x = np.zeros_like(distance)
y = np.zeros_like(distance)
for i in range(1, len(distance)):
ds = distance[i] - distance[i - 1]
average_heading = 0.5 * (heading[i] + heading[i - 1])
x[i] = x[i - 1] + ds * math.cos(average_heading)
y[i] = y[i - 1] + ds * math.sin(average_heading)
return SimpleNamespace(
position=SimpleNamespace(t=t.tolist(), x=x.tolist(), y=y.tolist()),
orientation=SimpleNamespace(z=heading.tolist()),
)
def _equivalent_curvature(path, distance: float = 7.0) -> float:
offset = path.path_offset + path.path_angle * distance + 0.5 * path.curvature * distance ** 2 + \
path.curvature_rate * distance ** 3 / 6.0
return 2.0 * offset / distance ** 2
def _command(model, desired_curvature: float, *, v_ego: float = 0.0, current_curvature: float | None = None):
return FordPathController(dt=1.0).update(model, desired_curvature, v_ego=v_ego, current_curvature=current_curvature)
def test_steady_arc_uses_c2_without_fast_pose_fields():
path = _command(_path(0.008), 0.008, v_ego=8.0)
assert path.valid
assert abs(path.path_offset) < 1e-9
assert abs(path.path_angle) < 1e-9
assert np.isclose(path.curvature, 0.008, atol=5e-5)
assert abs(path.curvature_rate) < 1e-5
def test_sunnypilot_path_message_round_trip():
message = custom.CarControlSP.new_message()
message.fordLateralPath.pathOffset = 0.3
message.fordLateralPath.pathAngle = -0.2
message.fordLateralPath.curvature = 0.008
message.fordLateralPath.curvatureRate = -0.0004
message.fordLateralPath.valid = True
path = convert_carControlSP(message.as_reader()).fordLateralPath
assert np.isclose(path.pathOffset, 0.3)
assert np.isclose(path.pathAngle, -0.2)
assert np.isclose(path.curvature, 0.008)
assert np.isclose(path.curvatureRate, -0.0004)
assert path.valid
def test_tight_arc_uses_signed_forward_pose_without_slow_c2():
left = _command(_path(0.04), 0.04, v_ego=8.0)
right = _command(_path(-0.04), -0.04, v_ego=8.0)
assert left.curvature == 0.0
assert right.curvature == 0.0
assert abs(left.curvature_rate) < 1e-4
assert abs(right.curvature_rate) < 1e-4
assert left.path_angle > 0.06
assert right.path_angle < -0.06
assert left.path_offset > 0.5
assert right.path_offset < -0.5
def test_c2_does_not_increase_while_tight_curve_unwinds():
curvatures = (0.04, 0.018, 0.016, 0.014, 0.012, 0.010, 0.008, 0.006, 0.0)
measured = (0.04,) + curvatures[:-1]
commands = [_command(_path(curvature), curvature, v_ego=8.0, current_curvature=actual).curvature
for curvature, actual in zip(curvatures, measured, strict=True)]
assert np.all(np.diff(commands) <= 1e-9)
def test_fresh_model_replaces_previous_path_without_hidden_state():
controller = FordPathController(dt=1.0)
initial = controller.update(_path(0.04), 0.04, v_ego=8.0)
replanned = controller.update(_path(0.0), 0.0, v_ego=8.0)
assert initial.path_offset > 0.5
assert replanned == FordPathController().update(_path(0.0), 0.0, v_ego=8.0)
def test_s_turn_reverses_fast_fields_while_c2_is_bounded():
controller = FordPathController(dt=0.05)
controller.update(_path(0.04), 0.04, v_ego=8.0)
controller.update(_path(0.04), 0.04, v_ego=8.0)
outputs = []
for frame_id in range(5):
model = _path(-0.02)
model.frameId = frame_id + 1
model.timestampEof = frame_id + 1
outputs.append(controller.update(model, -0.02, v_ego=8.0, current_curvature=0.02))
assert all(path.valid for path in outputs)
assert all(DBC_CURVATURE[0] <= path.curvature <= DBC_CURVATURE[1] for path in outputs)
assert all(path.curvature <= 0.0 for path in outputs)
assert outputs[-1].path_angle < -0.03
assert outputs[-1].path_offset < 0.0
def test_reversal_does_not_add_software_persistence_to_centering_c2():
controller = FordPathController()
assert controller.update(_path(0.002), 0.002, v_ego=8.0).curvature > 0.0
reversing = controller.update(_path(-0.02), -0.02, v_ego=8.0)
assert reversing.curvature <= 0.0
def test_requested_turn_is_not_cancelled_by_previous_path():
controller = FordPathController(dt=1.0)
previous = _path(-0.02, speed=3.0)
previous.frameId = 1
previous.timestampEof = 1
controller.update(previous, -0.02, v_ego=3.0, current_curvature=-0.01)
requested = _path(0.02, speed=3.0)
requested.frameId = 2
requested.timestampEof = 2
command = controller.update(requested, 0.02, v_ego=3.0, current_curvature=0.007)
assert command.path_offset >= 0.0
assert command.path_angle >= 0.0
assert command.curvature >= 0.0
assert _equivalent_curvature(command) >= 0.02
def test_short_low_speed_model_uses_available_path_endpoint():
command = FordPathController().update(_path(0.02, speed=1.0), 0.02, v_ego=1.0, current_curvature=0.0)
assert command.valid
assert command.path_offset > 0.0
assert command.path_angle > 0.0
def test_action_demand_exposes_forward_path_authority():
command = FordPathController().update(_path(0.002), 0.0055, v_ego=8.0, current_curvature=0.0005)
assert _equivalent_curvature(command) >= 0.004
def test_minor_curve_uses_c2_when_tracking_is_close():
command = FordPathController(dt=1.0).update(_path(0.005), 0.005, v_ego=8.0, current_curvature=0.0048)
assert abs(command.path_offset) < 1e-9
assert abs(command.path_angle) < 1e-9
assert command.curvature > 0.0049
def test_minor_changing_curve_does_not_emit_ungated_c3():
command = FordPathController(dt=1.0).update(_path(0.005, 0.0003), 0.005, v_ego=8.0, current_curvature=0.0048)
assert abs(command.path_offset) < 1e-9
assert abs(command.path_angle) < 1e-9
assert command.curvature > 0.0049
assert command.curvature_rate == 0.0
def test_c2_uses_stable_action_curvature_not_independent_model_fit():
controller = FordPathController(dt=1.0)
first = controller.update(_path(0.004), 0.002, v_ego=8.0, current_curvature=0.002)
second = controller.update(_path(0.006), 0.002, v_ego=8.0, current_curvature=0.002)
assert np.isclose(first.curvature, 0.002)
assert np.isclose(second.curvature, 0.002)
def test_action_curvature_corrects_stale_opposing_model_at_low_speed():
command = FordPathController().update(_path(-0.001, speed=1.0), 0.005, v_ego=1.0, current_curvature=0.001)
assert command.path_offset > 0.0
assert command.path_angle > 0.0
assert command.curvature >= 0.0
assert _equivalent_curvature(command) >= 0.004
def test_small_action_sign_noise_does_not_reverse_a_strong_model_path():
command = FordPathController(dt=1.0).update(_path(0.04), -0.0005, v_ego=6.0, current_curvature=0.02)
assert command.path_offset > 0.0
assert command.path_angle > 0.0
assert _equivalent_curvature(command) > 0.02
def test_measured_curvature_after_path_exit_commands_countersteer():
command = FordPathController().update(_path(0.0), 0.0, v_ego=8.0, current_curvature=0.006)
assert command.curvature == 0.0
assert command.path_offset < 0.0
assert command.path_angle < 0.0
def test_measured_curvature_countersteers_when_beyond_modeled_arc():
controller = FordPathController(dt=1.0)
command = controller.update(_path(0.004), 0.003, v_ego=8.0, current_curvature=0.012)
tracking = FordPathController(dt=1.0).update(_path(0.004), 0.003, v_ego=8.0, current_curvature=0.004)
assert command.path_offset < 0.0
assert command.path_angle < 0.0
assert command.path_angle < tracking.path_angle
assert command.curvature == 0.0
def test_model_reversal_suppresses_old_c2_and_countersteers():
reversing = FordPathController().update(_path(-0.02), -0.0005, v_ego=8.0, current_curvature=0.01)
assert reversing.curvature <= 0.0
assert reversing.path_angle < 0.0
def test_reversal_noise_band_is_continuous():
inside = FordPathController(dt=1.0).update(_path(0.02), -0.000099, v_ego=8.0, current_curvature=0.01)
outside = FordPathController(dt=1.0).update(_path(0.02), -0.000101, v_ego=8.0, current_curvature=0.01)
assert abs(outside.path_angle - inside.path_angle) < 0.005
def test_curvature_error_increases_forward_pose_command_while_behind():
behind = FordPathController(dt=1.0).update(_path(0.008), 0.008, v_ego=15.0, current_curvature=0.0)
tracking = FordPathController(dt=1.0).update(_path(0.008), 0.008, v_ego=15.0, current_curvature=0.008)
assert behind.path_offset > tracking.path_offset + 0.01
assert behind.path_angle > tracking.path_angle + 0.015
assert behind.curvature == 0.0
assert tracking.curvature > 0.007
assert np.isclose(behind.curvature_rate, tracking.curvature_rate)
def test_measured_wheel_beyond_action_countersteers_model_arc():
controller = FordPathController()
controller.update(_path(0.02), 0.02, v_ego=15.0, current_curvature=0.02)
outputs = [controller.update(_path(0.02), 0.003, v_ego=15.0, current_curvature=0.01) for _ in range(4)]
unwinding = outputs[-1]
assert unwinding.curvature == 0.0
assert unwinding.path_angle < 0.0
def test_action_c2_remains_active_for_centering():
centering = FordPathController(dt=1.0).update(_path(0.002), 0.002, v_ego=15.0, current_curvature=0.002)
assert centering.curvature > 0.001
assert abs(centering.path_offset) < 1e-9
assert abs(centering.path_angle) < 1e-9
def test_tight_turn_from_stop_builds_bounded_forward_pose_authority():
controller = FordPathController()
outputs = [controller.update(_path(0.04), 0.04, v_ego=0.0, current_curvature=0.0) for _ in range(20)]
path = outputs[-1]
assert path.curvature == 0.0
assert path.path_offset > 0.7
assert path.path_angle > 0.15
assert np.max(np.abs(np.diff([output.path_offset for output in outputs]))) <= 0.04 + 1e-9
assert np.max(np.abs(np.diff([output.path_angle for output in outputs]))) <= 0.01 + 1e-9
def test_curvature_feedback_is_bounded_for_bad_measurement():
bounded = FordPathController().update(_path(0.008), 0.008, v_ego=15.0, current_curvature=-0.02)
corrupted = FordPathController().update(_path(0.008), 0.008, v_ego=15.0, current_curvature=-1.0)
assert np.isclose(corrupted.path_angle, bounded.path_angle)
def test_invalid_model_ramps_pose_to_zero_while_remaining_in_extended_mode():
controller = FordPathController()
for _ in range(10):
active = controller.update(_path(0.04), 0.04, v_ego=12.0)
missing = controller.update(None, 0.0, v_ego=12.0)
assert active.path_offset > 0.0
assert missing.valid
assert np.isclose(active.path_offset - missing.path_offset, 0.04)
assert missing.curvature == 0.0
assert not controller.update(_path(0.0), 0.0, v_ego=12.0, active=False).valid
+37 -41
View File
@@ -73,45 +73,42 @@ compile_modeld_script = [
model_w, model_h = MEDMODEL_INPUT_SIZE
frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
if not os.getenv('SKIP_TINYGRAD_COMPILE'):
for usbgpu in [False, True] if USBGPU else [False]:
target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath
# BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU
file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags)
driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath)
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS)
# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it.
taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else ''
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py '
f'--model-size {model_w}x{model_h} '
f'--camera-resolutions {camera_res_args} '
f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} '
f'--output {target_pkl_path} --frame-skip {frame_skip}')
onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps)
chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum))
def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets):
from openpilot.system.hardware.chestnut.flash import link_up
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
print("Chestnut not ready, skipping big model build")
return
if ret := env.Execute(command):
return ret
chunk_file(pkl, chunks)
def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets):
chunk_file(pkl, chunks)
actions = Action(do_compile, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")]
node = lenv.Command(
chunk_targets,
tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file],
actions,
)
if usbgpu:
lenv.SideEffect(usbgpu_lock, node)
for usbgpu in [False, True] if USBGPU else [False]:
target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath
# BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU
file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags)
driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath)
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS)
cmd = (f'{cmd_flags} {mac_brew_string} python3 {modeld_dir}/compile_modeld.py '
f'--model-size {model_w}x{model_h} '
f'--camera-resolutions {camera_res_args} '
f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} '
f'--output {target_pkl_path} --frame-skip {frame_skip}')
onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps)
chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum))
def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets):
from openpilot.system.hardware.chestnut.flash import link_up
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
print("Chestnut not ready, skipping big model build")
return
if ret := env.Execute(command):
return ret
chunk_file(pkl, chunks)
def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets):
chunk_file(pkl, chunks)
actions = Action(do_compile, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")]
node = lenv.Command(
chunk_targets,
tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file],
actions,
)
if usbgpu:
lenv.SideEffect(usbgpu_lock, node)
# get model metadata
fn = File(f"models/dmonitoring_model").abspath
@@ -143,5 +140,4 @@ def tg_compile(flags, model_name):
Action(do_chunk, " [CHUNK] $TARGET")],
)
if not os.getenv('SKIP_TINYGRAD_COMPILE'):
tg_compile(tg_flags, 'dmonitoring_model')
tg_compile(tg_flags, 'dmonitoring_model')
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a501760a9d1d5fef0eab2b8c5d122d06124fc26dc8e0782e0aa94b82a208f0ff
size 1757355221
oid sha256:10926f2c0911821ca0e72439c1c3bf3ec11f0a08789aa14b7ee8f25379b2afa4
size 1753235978
@@ -102,6 +102,7 @@ class TestBoarddSpi(OpenpilotTestCase):
edt = 1e3 / SERVICE_LIST[service].frequency
assert edt*0.9 < np.mean(dts) < edt*1.1
assert np.max(dts) < edt*8
assert np.min(dts) < edt
assert len(dts) >= ((et-0.5)*SERVICE_LIST[service].frequency*0.8)
with subtests.test(msg="CAN traffic"):
@@ -17,8 +17,8 @@
"severity": 1,
"_comment": "Set extra field to the failed reason."
},
"Offroad_ChestnutBranch": {
"text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.",
"Offroad_NeosUpdate": {
"text": "An update to your device's operating system is downloading in the background. You will be prompted to update when it's ready to install.",
"severity": 0
},
"Offroad_UnregisteredHardware": {
+1 -2
View File
@@ -234,8 +234,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
},
EventName.bigModelFailed: {
ET.SOFT_DISABLE: soft_disable_alert("Big Model Failed"),
ET.PERMANENT: NormalPermanentAlert("Big Model Failed ", "Restart the car to retry,\nsmall model is still available", duration=20.),
ET.PERMANENT: NormalPermanentAlert("Big Model Failed ", "Restart the car to retry,\nnow driving on small model", duration=20.),
},
EventName.lateralManeuver: {
+2 -70
View File
@@ -32,14 +32,7 @@ from openpilot.sunnypilot.selfdrive.car.car_specific import CarSpecificEventsSP
from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper
from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.controller import IntelligentCruiseButtonManagement
from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker
from openpilot.sunnypilot.selfdrive.selfdrived.assisted_driving_milestones import (
AssistCategory,
AssistedDrivingMilestones,
MilestoneEvent,
MilestoneStore,
)
from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP
from openpilot.sunnypilot.system.statsd import statlog
REPLAY = "REPLAY" in os.environ
SIMULATION = "SIMULATION" in os.environ
@@ -95,8 +88,7 @@ class SelfdriveD(CruiseHelper):
self.big_model_ready_t = 0.
# Setup sockets
self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] +
['selfdriveStateSP', 'onroadEventsSP', 'assistedDrivingMilestoneState'])
self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] + ['selfdriveStateSP', 'onroadEventsSP'])
self.gps_location_service = get_gps_location_service(self.params)
self.gps_packets = [self.gps_location_service]
@@ -135,7 +127,6 @@ class SelfdriveD(CruiseHelper):
self.params.remove("ExperimentalMode")
self.CS_prev = car.CarState.new_message()
self.car_state_log_mono_time = 0
self.AM = AlertManager()
self.events = Events()
@@ -146,11 +137,6 @@ class SelfdriveD(CruiseHelper):
self.cruise_mismatch_counter = 0
self.last_steering_pressed_frame = 0
self.distance_traveled = 0
self.assisted_driving_milestones = AssistedDrivingMilestones(MilestoneStore(self.params))
self.assisted_driving_milestones_enabled = bool(self.params.get("AssistedDrivingMilestonesEnabled", return_default=True))
self.assisted_driving_milestone_drive_id = ""
self._milestone_event: MilestoneEvent | None = None
self._milestone_event_expires_ns = 0
self.last_functional_fan_frame = 0
self.events_prev = []
self.logged_comm_issue = None
@@ -351,7 +337,7 @@ class SelfdriveD(CruiseHelper):
device_motion = Pose.from_device_motion(self.sm['deviceMotion'])
self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion)
if self.calibrated_pose is not None and not self.CP.notCar:
if self.calibrated_pose is not None:
excessive_actuation = self.excessive_actuation_check.update(self.sm, CS, self.calibrated_pose)
if not self.excessive_actuation and excessive_actuation is not None:
set_offroad_alert("Offroad_ExcessiveActuation", True, extra_text=str(excessive_actuation))
@@ -411,9 +397,6 @@ class SelfdriveD(CruiseHelper):
# All events here should at least have NO_ENTRY and SOFT_DISABLE.
num_events = len(self.events)
if self.big_model_active and big_failed:
self.events.add(EventName.bigModelFailed)
not_running = {p.name for p in self.sm['managerState'].processes if not p.running and p.shouldBeRunning}
if self.sm.recv_frame['managerState'] and len(not_running):
if not_running != self.not_running_prev:
@@ -541,8 +524,6 @@ class SelfdriveD(CruiseHelper):
def data_sample(self):
_car_state = messaging.recv_one(self.car_state_sock)
CS = _car_state.carState if _car_state else self.CS_prev
if _car_state is not None:
self.car_state_log_mono_time = _car_state.logMonoTime
self.sm.update(0)
@@ -661,31 +642,6 @@ class SelfdriveD(CruiseHelper):
self.pm.send('onroadEventsSP', ce_send_sp)
self.events_sp_prev = self.events_sp.names.copy()
def publish_assisted_driving_milestones(self, now_ns: int, event: MilestoneEvent | None) -> None:
if event is not None:
self._milestone_event = event
self._milestone_event_expires_ns = now_ns + 1_000_000_000
elif now_ns >= self._milestone_event_expires_ns:
self._milestone_event = None
if event is None and self.sm.frame % 10 != 0:
return
snapshot = self.assisted_driving_milestones.snapshot()
msg = messaging.new_message("assistedDrivingMilestoneState")
msg.valid = True
state = msg.assistedDrivingMilestoneState
state.enabled = self.assisted_driving_milestones_enabled
state.madsDistanceMeters = snapshot.distances_meters[AssistCategory.MADS]
state.fullAssistDistanceMeters = snapshot.distances_meters[AssistCategory.FULL_ASSIST]
if self._milestone_event is not None:
state.event.id = self._milestone_event.event_id
state.event.category = self._milestone_event.category.value
state.event.distanceMeters = self._milestone_event.distance_meters
state.event.previousDistanceMeters = self._milestone_event.previous_distance_meters
state.event.unit = self._milestone_event.unit.value
self.pm.send("assistedDrivingMilestoneState", msg)
def step(self):
CS = self.data_sample()
self.update_events(CS)
@@ -695,28 +651,6 @@ class SelfdriveD(CruiseHelper):
self.mads.update(CS)
self.update_alerts(CS)
now_ns = time.monotonic_ns()
if not self.assisted_driving_milestone_drive_id:
self.assisted_driving_milestone_drive_id = self.params.get("CurrentRoute") or ""
self.assisted_driving_milestones.set_drive_id(self.assisted_driving_milestone_drive_id)
car_control = self.sm['carControl']
milestone_event = self.assisted_driving_milestones.update(
self.car_state_log_mono_time,
CS.vEgo,
lat_active=car_control.latActive,
long_active=car_control.longActive,
is_metric=self.is_metric,
enabled=self.assisted_driving_milestones_enabled,
)
if milestone_event is not None:
cloudlog.event("assisted_driving_milestone_reached",
event_id=milestone_event.event_id,
category=milestone_event.category.value,
distance_meters=milestone_event.distance_meters)
statlog.gauge(f"assisted_driving_milestone.{milestone_event.category.value}.meters",
milestone_event.distance_meters)
self.publish_assisted_driving_milestones(now_ns, milestone_event)
self.button_state_tracker.update(CS)
self.publish_selfdriveState(CS)
@@ -729,7 +663,6 @@ class SelfdriveD(CruiseHelper):
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl
self.personality = self.params.get("LongitudinalPersonality", return_default=True)
self.assisted_driving_milestones_enabled = bool(self.params.get("AssistedDrivingMilestonesEnabled", return_default=True))
self.mads.read_params()
time.sleep(0.1)
@@ -743,7 +676,6 @@ class SelfdriveD(CruiseHelper):
self.step()
self.rk.monitor_time()
finally:
self.assisted_driving_milestones.close()
e.set()
t.join()
@@ -7,7 +7,7 @@ import traceback
from collections import defaultdict
from tqdm import tqdm
from typing import Any
from opendbc.car.car_helpers import interface_names, interfaces
from opendbc.car.car_helpers import interface_names
from openpilot.common.git import get_commit
from openpilot.tools.lib.openpilotci import get_url
from openpilot.selfdrive.test.process_replay.compare_logs import compare_logs, format_diff
@@ -64,8 +64,7 @@ segments = [
]
# dashcamOnly makes don't need to be tested until a full port is done
excluded_interfaces = {brand for brand, platforms in interface_names.items()
if all(interfaces[platform].get_non_essential_params(platform).dashcamOnly for platform in platforms)} | {"body"}
excluded_interfaces = ["mock", "body", "psa"]
BASE_URL = "https://raw.githubusercontent.com/sunnypilot/ci-artifacts/refs/heads/process-replay/"
REF_COMMIT_FN = os.path.join(PROC_REPLAY_DIR, "ref_commit")
+11 -23
View File
@@ -67,7 +67,7 @@ PROCS = {
"openpilot.selfdrive.pandad.pandad": 0,
"openpilot.system.loggerd.uploader": 15.0,
"openpilot.system.loggerd.deleter": 1.0,
"./pandad": 40.0,
"./pandad": 19.0,
"openpilot.system.qcomgpsd.qcomgpsd": 1.0,
"openpilot.common.hardware.comma.modem": 10.0,
}
@@ -107,10 +107,6 @@ def cputime_total(ct):
class TestOnroad(OpenpilotTestCase):
COMMA_HARDWARE_TEST = True
def setUp(self):
# Hardware setup is handled once for the full onroad test in setup_class.
unittest.TestCase.setUp(self)
@classmethod
def setup_class(cls):
if "DEBUG" in os.environ:
@@ -336,34 +332,26 @@ class TestOnroad(OpenpilotTestCase):
assert np.all(eof_sof_diff < 50*1e6)
first_fid = {min(self.ts[c]['frameId']) for c in cams}
if cams[0].endswith('CameraState'):
assert len(first_fid) == 1, "Cameras don't start on same frame ID"
if cam.endswith('CameraState'):
# camerad guarantees that all cams start on frame ID 0
# (note loggerd also needs to start up fast enough to catch it)
assert min(first_fid) < 100, "Cameras start on frame ID too high"
else:
# encoderd synchronizes all camera encoders to the same starting frame
assert len(first_fid) == 1, "Camera encoders don't start on same frame ID"
assert next(iter(first_fid)) < 100, "Cameras start on frame ID too high"
# we don't do a full segment rotation, so these might not match exactly
last_fid = {max(self.ts[c]['frameId']) for c in cams}
assert max(last_fid) - min(last_fid) < 10
timestamps = {
cam: dict(zip(self.ts[cam]['frameId'], self.ts[cam]['timestampSof'], strict=True))
for cam in cams
}
common_frame_ids = set.intersection(*(set(ts) for ts in timestamps.values()))
assert common_frame_ids, "Cameras have no overlapping frame IDs"
for frame_id in sorted(common_frame_ids):
start, end = min(first_fid), min(last_fid)
for i in range(end-start):
# road and wide cameras (first two) should be synced within 2ms
ts = {cam: timestamps[cam][frame_id] / 1e6 for cam in cams[:2]}
diff = max(ts.values()) - min(ts.values())
assert diff < 2, f"Cameras not synced properly: {frame_id=}, {diff=:.1f}ms, {ts=}"
ts = {c: round(self.ts[c]['timestampSof'][i]/1e6, 1) for c in cams[:2]}
diff = (max(ts.values()) - min(ts.values()))
assert diff < 2, f"Cameras not synced properly: frame_id={start+i}, {diff=:.1f}ms, {ts=}"
# cabin camera should be staggered ~25ms from road camera
offset_ms = abs(timestamps[cams[2]][frame_id] - timestamps[cams[0]][frame_id]) / 1e6
assert 20 < offset_ms < 30, f"cabin camera stagger out of range at frame {frame_id}: {offset_ms:.1f}ms"
offset_ms = abs(self.ts[cams[2]]['timestampSof'][i] - self.ts[cams[0]]['timestampSof'][i]) / 1e6
assert 20 < offset_ms < 30, f"cabin camera stagger out of range at frame {start+i}: {offset_ms:.1f}ms"
def test_camera_encoder_matches(self, subtests):
# sanity check that the frame metadata is consistent with the encoded frames
+19
View File
@@ -3,6 +3,7 @@ from enum import IntEnum
import openpilot.cereal.messaging as messaging
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.sunnypilot.custom_button import CustomButtonAction, handle_custom_button
from openpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
from openpilot.selfdrive.ui.layouts.home import HomeLayout
from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, PanelType
@@ -27,6 +28,7 @@ class MainLayout(Widget):
super().__init__()
self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark'])
self._custom_button_sock = messaging.sub_sock('carState')
self._sidebar = Sidebar()
self._current_mode = MainState.HOME
@@ -40,6 +42,10 @@ class MainLayout(Widget):
MainState.SETTINGS: SettingsLayout(),
MainState.ONROAD: AugmentedRoadView(),
}
self._custom_button_callbacks = {
CustomButtonAction.BOOKMARK: self._on_bookmark_clicked,
CustomButtonAction.CYCLE_UI: self._cycle_ui,
}
self._sidebar_rect = rl.Rectangle(0, 0, 0, 0)
self._content_rect = rl.Rectangle(0, 0, 0, 0)
@@ -55,6 +61,7 @@ class MainLayout(Widget):
gui_app.push_widget(self._onboarding_window)
def _render(self, _):
handle_custom_button(messaging.drain_sock(self._custom_button_sock), ui_state.params, self._custom_button_callbacks)
self._handle_onroad_transition()
self._render_main_content()
@@ -114,6 +121,18 @@ class MainLayout(Widget):
def _on_settings_clicked(self):
self.open_settings(PanelType.DEVICE)
def _show_onroad(self):
self._set_current_layout(MainState.ONROAD)
self._sidebar.set_visible(False)
def _cycle_ui(self):
if self._current_mode == MainState.ONROAD and not self._sidebar.is_visible:
self._sidebar.set_visible(True)
elif self._current_mode == MainState.SETTINGS:
self._show_onroad()
else:
self._on_settings_clicked()
def _on_bookmark_clicked(self):
for service in ('bookmarkButton', 'userBookmark'):
msg = messaging.new_message(service, valid=True)
+1 -8
View File
@@ -168,16 +168,9 @@ class Sidebar(Widget, SidebarSP):
# Home/Flag button
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
button_img = self._flag_img if ui_state.started else self._home_img
button_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y)
icon_opacity = 1.0
if gui_app.sunnypilot_ui():
button_img, button_pos, icon_opacity = SidebarSP._get_home_icon(self, button_img)
tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL
if icon_opacity < 1.0:
tint = rl.Color(tint[0], tint[1], tint[2], int(255 * icon_opacity))
rl.draw_texture_ex(button_img, button_pos, 0.0, 1.0, tint)
rl.draw_texture_ex(button_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint)
# Microphone button
if self._recording_audio:
+2 -5
View File
@@ -248,11 +248,8 @@ class MiciHomeLayout(Widget):
# ***** Center-aligned bottom section icons *****
self._experimental_icon.set_visible(ui_state.experimental_mode)
if gui_app.sunnypilot_ui():
self._set_egpu_visibility()
else:
self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled)
self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled)
self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled)
self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled)
self._mic_icon.set_visible(ui_state.recording_audio)
self._body_icon.set_visible(bool(ui_state.is_body))
+26 -7
View File
@@ -1,12 +1,10 @@
import os
import pyray as rl
import openpilot.cereal.messaging as messaging
from openpilot.common.hardware import PC
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
from openpilot.selfdrive.ui.sunnypilot.custom_button import CustomButtonAction, handle_custom_button
from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow
from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout
@@ -26,6 +24,7 @@ class MiciMainLayout(Scroller):
super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark'])
self._custom_button_sock = messaging.sub_sock('carState')
self._prev_onroad = False
self._prev_standstill = False
@@ -38,6 +37,10 @@ class MiciMainLayout(Scroller):
self._settings_layout = SettingsLayout()
self._car_onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked)
self._body_onroad_layout = BodyLayout()
self._custom_button_callbacks = {
CustomButtonAction.BOOKMARK: self._on_bookmark_clicked,
CustomButtonAction.CYCLE_UI: self._cycle_ui,
}
# Initialize widget rects
for widget in (self._home_layout, self._alerts_layout, self._settings_layout,
@@ -64,8 +67,7 @@ class MiciMainLayout(Scroller):
# Start onboarding if terms or training not completed, make sure to push after self
self._onboarding_window = OnboardingWindow(lambda: gui_app.pop_widgets_to(self))
skip_onboarding_for_milestone_preview = PC and os.getenv("SP_MILESTONE_PREVIEW") == "1"
if not self._onboarding_window.completed and not skip_onboarding_for_milestone_preview:
if not self._onboarding_window.completed:
gui_app.push_widget(self._onboarding_window)
# initialize correct onroad layout
@@ -99,6 +101,8 @@ class MiciMainLayout(Scroller):
self._alerts_layout._update_state()
def _render(self, _):
handle_custom_button(messaging.drain_sock(self._custom_button_sock), ui_state.params, self._custom_button_callbacks)
if not self._setup:
if self._alerts_layout.active_alerts() > 0:
self._scroller.scroll_to(self._alerts_layout.rect.x)
@@ -123,8 +127,6 @@ class MiciMainLayout(Scroller):
self._onroad_time_delay = rl.get_time()
else:
self._scroll_to(self._home_layout)
if hasattr(self._home_layout, "request_drive_summary"):
self._home_layout.request_drive_summary()
# FIXME: these two pops can interrupt user interacting in the settings
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
@@ -156,6 +158,23 @@ class MiciMainLayout(Scroller):
msg = messaging.new_message(service, valid=True)
self._pm.send(service, msg)
def _show_layout(self, layout: Widget):
if gui_app.widget_in_stack(self._onboarding_window):
return
gui_app.pop_widgets_to(self, lambda: self._scroll_to(layout))
def _layout_visible(self, layout: Widget) -> bool:
return abs(layout.rect.x - self._rect.x) < self._rect.width / 2
def _cycle_ui(self):
if gui_app.widget_in_stack(self._settings_layout):
self._show_layout(self._onroad_layout)
elif gui_app.get_active_widget() is self and self._layout_visible(self._home_layout):
if not gui_app.widget_in_stack(self._onboarding_window):
gui_app.push_widget(self._settings_layout)
else:
self._show_layout(self._home_layout)
def _on_body_changed(self):
self._car_onroad_layout.set_visible(not ui_state.is_body)
self._body_onroad_layout.set_visible(bool(ui_state.is_body))
@@ -47,7 +47,7 @@ class SoftwareInfoLayoutMici(Widget):
self._branch_label = UnifiedLabel("branch", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False)
self._branch_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color,
font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True)
font_weight=FontWeight.ROMAN, wrap_text=False)
def _update_state(self):
desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "")
@@ -47,7 +47,6 @@ class TogglesLayoutMici(NavScroller):
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
milestone_celebrations_toggle = BigParamControl("assisted driving milestones", "AssistedDrivingMilestonesEnabled")
record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback)
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
@@ -58,7 +57,6 @@ class TogglesLayoutMici(NavScroller):
is_metric_toggle,
ldw_toggle,
always_on_dm_toggle,
milestone_celebrations_toggle,
record_front,
record_mic,
enable_openpilot,
@@ -70,7 +68,6 @@ class TogglesLayoutMici(NavScroller):
("IsMetric", is_metric_toggle),
("IsLdwEnabled", ldw_toggle),
("AlwaysOnDM", always_on_dm_toggle),
("AssistedDrivingMilestonesEnabled", milestone_celebrations_toggle),
("RecordFront", record_front),
("RecordAudio", record_mic),
("OpenpilotEnabledToggle", enable_openpilot),
@@ -20,7 +20,6 @@ AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
ALERT_MARGIN = 18
ALERT_BACKGROUND_OPACITY = 0.90
ALERT_FONT_SMALL = 66 - 50
ALERT_FONT_BIG = 88 - 40
@@ -133,7 +132,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
# 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 10:
if waiting_for_startup and time_since_onroad > 5:
return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it
@@ -280,7 +279,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
def _draw_background(self, alert: Alert) -> None:
# draw top gradient for alert text at top
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
color = rl.Color(color.r, color.g, color.b, int(255 * ALERT_BACKGROUND_OPACITY * self._alpha_filter.x))
color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
@@ -19,15 +19,10 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCamera
from openpilot.common.transformations.orientation import rot_from_euler
from enum import IntEnum
MILESTONE_CELEBRATION_ENABLED = gui_app.sunnypilot_ui()
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.hud_renderer import HudRendererSP as HudRenderer
from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus
if MILESTONE_CELEBRATION_ENABLED:
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_celebration import MilestoneCelebration
OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD
@@ -161,7 +156,6 @@ class AugmentedRoadView(CameraView):
self._alert_renderer = AlertRenderer()
self._driver_state_renderer = DriverStateRenderer()
self._confidence_ball = ConfidenceBall()
self._milestone_celebration = self._child(MilestoneCelebration()) if MILESTONE_CELEBRATION_ENABLED else None
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
@@ -229,12 +223,6 @@ class AugmentedRoadView(CameraView):
alert_to_render, not_animating_out = self._alert_renderer.will_render()
if self._milestone_celebration is not None:
if alert_to_render is not None:
self._milestone_celebration.cancel_for_alert()
else:
self._milestone_celebration.render(self._content_rect)
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and
(ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
@@ -259,6 +247,7 @@ class AugmentedRoadView(CameraView):
self._confidence_ball.render(self.rect)
self._bookmark_icon.render(self.rect)
def _switch_stream_if_needed(self, sm):
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
v_ego = sm['carState'].vEgo
@@ -366,12 +355,10 @@ class AugmentedRoadView(CameraView):
return self._cached_matrix
def show_event(self):
super().show_event()
if gui_app.sunnypilot_ui():
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME)
def hide_event(self):
super().hide_event()
if gui_app.sunnypilot_ui():
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE)
+12 -10
View File
@@ -149,15 +149,11 @@ class BigButton(Widget):
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None)
def _title_width_hint(self) -> int:
# A value moves the title to the top, where it shares space with the icon
icon_size = self._txt_icon.width if self._txt_icon and self.value else 0
def _width_hint(self) -> int:
# Single line if scrolling, so hide behind icon if exists
icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
def _subtitle_width_hint(self) -> int:
# Bottom aligned, so it sits below the icon
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _get_label_font_size(self):
if len(self.text) <= 18:
return 48
@@ -232,14 +228,14 @@ class BigButton(Widget):
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
self._label.set_color(label_color)
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._title_width_hint(),
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
label_y = label_rect.y + self._label.get_content_height(int(label_rect.width))
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y
sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height)
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
self._sub_label.render(sub_label_rect)
# ICON -------------------------------------------------------------------
@@ -316,6 +312,9 @@ class BigMultiToggle(BigToggle):
self.set_value(self._options[0])
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
cur_idx = self._options.index(self.value)
@@ -364,6 +363,9 @@ class GreyBigButton(BigButton):
def LABEL_VERTICAL_PADDING(self):
return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _get_label_font_size(self):
return 36
@@ -92,7 +92,7 @@ class AlertRenderer(Widget):
# 1. Never received selfdriveState since going onroad
waiting_for_startup = recv_frame < ui_state.started_frame
if waiting_for_startup and time_since_onroad > 10:
if waiting_for_startup and time_since_onroad > 5:
return ALERT_STARTUP_PENDING
# 2. Lost communication with selfdriveState after receiving it
@@ -192,7 +192,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
max_idx = self._get_path_length_idx(path_x_array, max_distance)
self._path.projected_points = self._map_line_to_polygon(
self._path.raw_points, self._get_path_half_width(), self._path_offset_z, max_idx, max_distance, allow_invert=False
self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False
)
self._update_experimental_gradient()
@@ -292,7 +292,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
self._blend_filter.update(int(allow_throttle))
if ui_state.rainbow_path and self._lateral_active:
if ui_state.rainbow_path:
self.rainbow_path.draw_rainbow_path(self._rect, self._path)
return
+9 -25
View File
@@ -24,8 +24,14 @@ ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate
SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds
FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES)
AMBIENT_DB = 26 # DB where MIN_VOLUME is applied
DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied
VOLUME_BASE = 20
if HARDWARE.get_device_type() == "tizi":
AMBIENT_DB = 30
VOLUME_BASE = 10
AudibleAlert = log.SelfdriveState.AudibleAlert
AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert
@@ -47,7 +53,6 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
AudibleAlert.promptDistracted: ("dm_warning.wav", None, MAX_VOLUME),
AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME),
AudibleAlert.complete: ("milestone.wav", 1, MAX_VOLUME),
AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME),
AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME),
@@ -55,14 +60,6 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
**sound_list_sp,
}
def calculate_volume_for_device(weighted_db: float, device_type: str) -> float:
ambient_db = 30 if device_type in ("mici", "tizi") else 26
volume_base = 10 if device_type in ("mici", "tizi") else 20
volume_boost = 1.5 if device_type == "mici" else 1.0
volume = ((weighted_db - ambient_db) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
return min(MAX_VOLUME, volume_boost * math.pow(volume_base, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1)))
def check_selfdrive_timeout_alert(sm):
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
@@ -77,7 +74,6 @@ class Soundd(QuietMode):
def __init__(self):
super().__init__()
self.device_type = HARDWARE.get_device_type()
self.load_sounds()
self.current_alert = AudibleAlert.none
@@ -89,7 +85,6 @@ class Soundd(QuietMode):
self.selfdrive_timeout_alert = False
self.pending_stop = False
self.last_milestone_event_id = 0
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
@@ -169,19 +164,9 @@ class Soundd(QuietMode):
self.update_alert(AudibleAlert.none)
self.selfdrive_timeout_alert = False
def update_milestone_alert(self, sm):
if not sm.updated['assistedDrivingMilestoneState']:
return
milestone_state = sm['assistedDrivingMilestoneState']
event_id = milestone_state.event.id
if not milestone_state.enabled or event_id == 0 or event_id == self.last_milestone_event_id:
return
self.last_milestone_event_id = event_id
if self.current_alert == AudibleAlert.none and not self.enabled:
self.update_alert(AudibleAlert.complete)
def calculate_volume(self, weighted_db):
return calculate_volume_for_device(weighted_db, self.device_type)
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
@retry(attempts=10, delay=3)
def get_stream(self, sd):
@@ -195,7 +180,7 @@ class Soundd(QuietMode):
import sounddevice as sd
micd.patch_sounddevice(sd)
sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure', 'assistedDrivingMilestoneState'])
sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure'])
with self.get_stream(sd) as stream:
rk = Ratekeeper(20)
@@ -213,7 +198,6 @@ class Soundd(QuietMode):
self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x))
self.get_audible_alert(sm)
self.update_milestone_alert(sm)
# Ramp up immediate warning sound over 4s
if self.current_alert == AudibleAlert.warningImmediate:
@@ -0,0 +1,19 @@
from enum import IntEnum
from opendbc.car.structs import car
class CustomButtonAction(IntEnum):
NONE = 0
BOOKMARK = 1
CYCLE_UI = 3
def handle_custom_button(messages, params, callbacks):
for msg in messages:
custom_pressed = any(be.type == car.CarState.ButtonEvent.Type.altButton2 and be.pressed
for be in msg.carState.buttonEvents)
if custom_pressed:
action = CustomButtonAction(params.get('CustomButtonAction', return_default=True))
if callback := callbacks.get(action):
callback()
@@ -10,10 +10,9 @@ import time
import pyray as rl
from openpilot.cereal import custom
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref
from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.selfdrive.ui.sunnypilot.model_info import big_model_state, bundles_for_source, carrying_model, default_model_name, queued_name
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult, Widget
@@ -23,9 +22,9 @@ from openpilot.system.ui.widgets.toggle import ON_COLOR
from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction, ScrollingButtonAction
from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp
from openpilot.system.ui.sunnypilot.widgets.download_status import download_status_item
from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item
from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
if gui_app.sunnypilot_ui():
@@ -36,11 +35,9 @@ class ModelsLayout(Widget):
def __init__(self):
super().__init__()
self.model_manager = None
self.download_status = None
self.prev_download_status = None
self.model_dialog = None
self._selection_source = None
self._downloading = False
self._verifying = False
self._last_note = None
self.last_cache_calc_time = 0
self._initialize_items()
@@ -52,24 +49,21 @@ class ModelsLayout(Widget):
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
def _initialize_items(self):
self.small_model_item = ListItemSP(
title=tr("Small Model"),
self.current_model_item = ListItemSP(
title=tr("Current Model"),
description="",
action_item=ScrollingButtonAction(tr("SELECT")),
callback=lambda: self._open_source_dialog("qcom")
action_item=NoElideButtonAction(tr("SELECT")),
callback=self._handle_current_model_clicked
)
self.big_model_item = ListItemSP(
title=tr("Big Model"),
action_item=ScrollingButtonAction(tr("SELECT")),
callback=lambda: self._open_source_dialog("usbgpu")
)
self.download_item = download_status_item(lambda: tr("Download") if self._downloading else tr("Model Status"))
self.supercombo_label = progress_item(tr("Driving Model"))
self.vision_label = progress_item(tr("Vision Model"))
self.policy_label = progress_item(tr("Policy Model"))
self.off_policy_label = progress_item(tr("Off-Policy Model"))
self.on_policy_label = progress_item(tr("On-Policy Model"))
self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "",
lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0),
ui_state.params.put("ModelManager_LastSyncTime_USBGPU", 0),
gui_app.push_widget(alert_dialog(tr("Fetching Latest Models")))))
self.clear_cache_item = ListItemSP(
@@ -79,9 +73,7 @@ class ModelsLayout(Widget):
callback=self._clear_cache
)
self.cancel_download_item = button_item(lambda: tr("Cancel Verification") if self._verifying else tr("Cancel Download"),
tr("Cancel"), "",
lambda: ui_state.params.remove("ModelManager_DownloadRef"))
self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex"))
self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000,
tr("Set the maximum speed for lane turn desires. Default is 19 mph."),
@@ -106,7 +98,8 @@ class ModelsLayout(Widget):
1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True,
lambda v: f"{v / 100:.2f} m")
self.items = [self.small_model_item, self.big_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item,
self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label,
self.policy_label, self.off_policy_label, self.on_policy_label, self.refresh_item, self.clear_cache_item,
self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset]
def _update_lagd_description(self, lagd_toggle: bool):
@@ -120,16 +113,16 @@ class ModelsLayout(Widget):
desc += f"<br>{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s"
self.lagd_toggle.set_description(desc)
def _is_downloading(self):
return (self.model_manager and self.model_manager.selectedBundle and
self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading)
@staticmethod
def calculate_cache_size():
cache_size = 0.0
if os.path.exists(CUSTOM_MODEL_PATH):
for file in os.listdir(CUSTOM_MODEL_PATH):
try:
cache_size += os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file))
except OSError:
continue
return cache_size / (1024**2)
cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2)
return cache_size
def _clear_cache(self):
def _callback(response):
@@ -142,178 +135,109 @@ class ModelsLayout(Widget):
gui_app.push_widget(dialog)
def _handle_bundle_download_progress(self):
labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label,
custom.ModelManagerSP.Model.Type.vision: self.vision_label,
custom.ModelManagerSP.Model.Type.policy: self.policy_label,
custom.ModelManagerSP.Model.Type.offPolicy: self.off_policy_label,
custom.ModelManagerSP.Model.Type.onPolicy: self.on_policy_label}
for label in labels.values():
label.set_visible(False)
self.cancel_download_item.set_visible(False)
self._downloading = False
self._verifying = False
self.download_item.set_visible(True)
if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle):
return
bundle = self.model_manager.selectedBundle if self._is_downloading() or (
self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed
) else self.model_manager.activeBundle
if not bundle:
return
self.download_status = bundle.status
status_changed = self.prev_download_status != self.download_status
self.prev_download_status = self.download_status
self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadIndex") is not None)
if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5:
self.last_cache_calc_time = current_time
self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB")
bundle = self.model_manager.selectedBundle if self.model_manager else None
progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] if bundle else []
if not progresses or bundle.status not in (custom.ModelManagerSP.DownloadStatus.downloading,
custom.ModelManagerSP.DownloadStatus.failed):
self.download_item.action_item.update(name="", segments=self._slot_segments())
return
self.cancel_download_item.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None)
if bundle.status == custom.ModelManagerSP.DownloadStatus.downloading:
if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading:
device._reset_interactive_timeout()
state = self._download_row_state(progresses, bundle.internalName)
if queued := queued_name(bundle.ref):
state["name"] += f" | {queued} {tr('queued')}"
self.download_item.action_item.update(**state)
self._downloading = self.download_item.action_item.downloading
ds = custom.ModelManagerSP.DownloadStatus
self._verifying = any(getattr(p.status, 'raw', p.status) == ds.verifying for p in progresses)
def _slot_segments(self):
"""small and big slots side by side; green marks the slot whose pick is actually
driving (runner-matched, so a failed Default big greens neither slot), an empty
slot shows its default."""
big_state = big_model_state()
carry_source, carry_internal, _ = carrying_model()
segments = []
for source, label in (("qcom", tr("small")), ("usbgpu", tr("big"))):
if segments:
segments.append(("|", rl.GRAY, None, None))
bundle = get_selected_bundle(ui_state.params, source)
name = bundle.internalName if bundle else default_model_name(source)
color = ON_COLOR if (source == carry_source and name == carry_internal) else rl.LIGHTGRAY
name = "" + name
if source == "usbgpu":
if big_state == 'failed':
color = rl.RED
elif big_state == 'loading':
color = rl.GOLD
segments.append((label, rl.GRAY, None, None))
segments.append((name, color, None, None))
return segments
for model in bundle.models:
if label := labels.get(getattr(model.type, 'raw', model.type)):
label.set_visible(True)
p = model.artifact.downloadProgress
text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY
if p.status == custom.ModelManagerSP.DownloadStatus.downloading:
text, show = f"{int(p.progress)}% - {bundle.displayName}", True
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached):
status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded")
text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR
elif p.status == custom.ModelManagerSP.DownloadStatus.failed:
text, color = f"download failed - {bundle.displayName}", rl.RED
label.action_item.update(p.progress, text, show, color)
@staticmethod
def _set_item_note(item, text):
# a description renders only while shown; hide before clearing or the
# empty description keeps its visible state
if text:
item.set_description(text)
item.show_description(True)
else:
item.show_description(False)
item.set_description("")
def _status_note(self) -> str:
"""The failover story for the Model Status row. One-way big -> small, and the
fallback is runner-matched: a Default big can only fall back to the Default
small (stock modeld), a custom big has no automatic fallback yet."""
if not ui_state.usbgpu:
return ""
big_bundle = get_selected_bundle(ui_state.params, "usbgpu")
big_name = big_bundle.internalName if big_bundle else default_model_name("usbgpu")
big_is_default = big_bundle is None
fallback_name = default_model_name("qcom")
state = big_model_state()
if state == 'failed':
if big_is_default:
return tr("Big model unavailable, {} is driving until the next drive.").format(fallback_name)
return tr("Big model unavailable until the next drive.")
if state == 'loading':
if big_is_default:
return tr("{} drives until the big model is ready.").format(fallback_name)
return tr("Getting the big model ready.")
if big_is_default:
return tr("{} will drive. If it fails during a drive, {} takes over until the next drive.").format(big_name, fallback_name)
return tr("{} will drive when the eGPU is ready.").format(big_name)
@staticmethod
def _download_row_state(progresses, name: str) -> dict:
"""Maps a bundle's artifact progress to DownloadStatusAction.update kwargs."""
# .raw: _DynamicEnum equals its int but does not hash like it
statuses = {getattr(p.status, 'raw', p.status) for p in progresses}
progress = sum(p.progress for p in progresses) / len(progresses)
ds = custom.ModelManagerSP.DownloadStatus
if ds.failed in statuses:
# close.png is authored black and a tint cannot lift it, hence close2
return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"}
if ds.verifying in statuses:
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")}
if ds.downloading in statuses:
return {"name": name, "downloading": True, "progress": progress}
if statuses <= {ds.downloaded, ds.cached}:
return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"}
# circled_slash is authored grey; tinting it again only darkens it
return {"name": name, "text_color": rl.GRAY, "icon": "icons/circled_slash.png", "icon_color": rl.WHITE}
def _show_reset_params_dialog():
def _callback(response):
if response == DialogResult.CONFIRM:
ui_state.params.remove("CalibrationParams")
ui_state.params.remove("LiveTorqueParameters")
msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?")
dialog = ConfirmDialog(msg, tr("Reset Calibration"), callback=_callback)
gui_app.push_widget(dialog)
def _on_model_selected(self, result):
if result != DialogResult.CONFIRM:
self.model_dialog = None
return
selected_ref = self.model_dialog.selection_ref
self.model_dialog = None
if selected_ref == "Default":
if self._selection_source in ACTIVE_BUNDLE_KEYS:
ui_state.params.remove(ACTIVE_BUNDLE_KEYS[self._selection_source])
return
if selected_bundle := self._resolve_selected_bundle(selected_ref):
ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref)
def _resolve_selected_bundle(self, ref):
source_bundles = {source: bundles_for_source(source) for source in ("qcom", "usbgpu")}
resolved = resolve_bundle_by_ref(ref, source_bundles)
return resolved[0] if resolved else None
ui_state.params.remove("ModelManager_ActiveBundle")
self._show_reset_params_dialog()
elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None):
ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index)
if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation:
self._show_reset_params_dialog()
self.model_dialog = None
@staticmethod
def _bundle_to_node(bundle):
return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName})
def _get_folders(self, favorites, bundles):
def _get_folders(self, favorites):
bundles = self.model_manager.availableBundles
folders = {}
for bundle in bundles:
folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle)
folders_list = []
folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': f"{DEFAULT_MODEL} (Default)", 'short_name': "Default"})])]
for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True):
folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True)
name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "")
folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles]))
if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]):
folders_list.insert(0, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles]))
folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles]))
return folders_list
def _open_source_dialog(self, source):
self._selection_source = source
def _handle_current_model_clicked(self):
favs = ui_state.params.get("ModelManager_Favs")
favorites = set(favs.split(';')) if favs else set()
folders_list = self._source_folders(favorites, source)
if not folders_list:
gui_app.push_widget(alert_dialog(tr("No models are available for this hardware yet. Connect to the internet and refresh the model list.")))
return
self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, self._slot_active_ref(source), "ModelManager_Favs",
get_folders_fn=lambda favs: self._source_folders(favs, source), on_exit=self._on_model_selected)
folders_list = self._get_folders(favorites)
active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default"
self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs",
get_folders_fn=self._get_folders, on_exit=self._on_model_selected)
gui_app.push_widget(self.model_dialog)
def _source_folders(self, favorites, source):
bundles = bundles_for_source(source)
if not bundles:
return []
folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': default_model_name(source)})])]
folders_list.extend(self._get_folders(favorites, bundles))
return folders_list
@staticmethod
def _slot_active_ref(source: str) -> str:
bundle = get_selected_bundle(ui_state.params, source)
return bundle.ref if bundle else "Default"
def _update_state(self):
advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls")
turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire")
live_delay: bool = ui_state.params.get_bool("LagdToggle")
camera_offset: bool = ui_state.active_bundle is not None
camera_offset: bool = ui_state.params.get("ModelManager_ActiveBundle") is not None
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
@@ -327,27 +251,18 @@ class ModelsLayout(Widget):
self._update_lagd_description(live_delay)
self.model_manager = ui_state.sm["modelManagerSP"]
self._handle_bundle_download_progress()
active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)"
self.current_model_item.action_item.set_value(active_name)
carry_source, _, carry_display = carrying_model()
for item, item_source in ((self.small_model_item, "qcom"), (self.big_model_item, "usbgpu")):
bundle = get_selected_bundle(ui_state.params, item_source)
name = bundle.displayName if bundle else default_model_name(item_source)
color = ON_COLOR if (item_source == carry_source and name == carry_display) else style.ITEM_TEXT_VALUE_COLOR
item.action_item.set_value(name, color)
note = self._status_note()
if note != self._last_note:
self._last_note = note
self._set_item_note(self.download_item, note)
offroad = ui_state.is_offroad()
self.small_model_item.action_item.set_enabled(offroad)
self.big_model_item.action_item.set_enabled(offroad)
self.small_model_item.set_description("" if offroad else tr("Only available when vehicle is off, or always offroad mode is on"))
if not ui_state.is_offroad():
self.current_model_item.action_item.set_enabled(False)
self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on"))
else:
self.current_model_item.action_item.set_enabled(True)
self.current_model_item.set_description("")
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()
self._last_note = None # re-expand the failover note every time the page opens
@@ -8,6 +8,7 @@ import datetime
import os
import platform
import requests
import shutil
import threading
from pathlib import Path
from time import monotonic
@@ -74,12 +75,22 @@ class OSMLayout(Widget):
def _update_map_size(self):
threading.Thread(target=self.calculate_size, daemon=True).start()
def _on_confirm_delete_maps(self):
ui_state.params.put_bool("Mapd_ClearCache", True)
def _do_delete_maps(self):
if MAP_PATH.exists():
shutil.rmtree(MAP_PATH)
for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", "OsmStateName", "OsmStateTitle"):
ui_state.params.remove(param)
self._delete_maps_btn.action_item.set_enabled(True)
self._delete_maps_btn.action_item.set_text(tr("DELETE"))
self._update_map_size()
def _on_confirm_delete_maps(self):
self._delete_maps_btn.action_item.set_enabled(False)
self._delete_maps_btn.action_item.set_text("DELETING...")
threading.Thread(target=self._do_delete_maps).start()
def _delete_maps(self):
self._show_confirm(tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all maps?"),
tr("Yes, delete all maps"), self._on_confirm_delete_maps)
@@ -4,14 +4,11 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import math
import pyray as rl
import time
from dataclasses import dataclass
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr_noop
@@ -21,9 +18,6 @@ METRIC_MARGIN = 30
METRIC_START_Y = 300
HOME_BTN = rl.Rectangle(60, 860, 180, 180)
EGPU_ICON_WIDTH = 180
EGPU_ICON_HEIGHT = 133
# Color scheme
class Colors:
@@ -59,10 +53,6 @@ class MetricData:
class SidebarSP:
def __init__(self):
self._sunnylink_status = MetricData(tr_noop("SUNNYLINK"), tr_noop("OFFLINE"), Colors.WARNING)
self._egpu_green_img = gui_app.texture("icons_mici/egpu_green.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT)
self._egpu_default_img = gui_app.texture("icons_mici/egpu.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT)
self._egpu_orange_img = gui_app.texture("icons_mici/egpu_orange.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT)
self._egpu_gray_img = gui_app.texture("icons_mici/egpu_gray.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT)
def _update_sunnylink_status(self):
if not ui_state.params.get_bool("SunnylinkEnabled"):
@@ -88,29 +78,6 @@ class SidebarSP:
self._sunnylink_status.update(tr_noop("SUNNYLINK"), status, color)
def _get_home_icon(self, default_img: rl.Texture) -> tuple[rl.Texture, rl.Vector2, float]:
default_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y)
if not ui_state.sm["deviceState"].chestnutPresent:
return default_img, default_pos, 1.0
big_model_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad
big_model_failed = ui_state.started and ui_state.big_model_failed
loading = ui_state.usbgpu_loading or (big_model_selected and ui_state.started and ui_state.usbgpu_active is None)
if loading:
icon = self._egpu_default_img
opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))
elif big_model_selected and big_model_failed:
icon, opacity = self._egpu_orange_img, 1.0
elif big_model_selected:
icon, opacity = self._egpu_green_img, 1.0
else:
icon, opacity = self._egpu_gray_img, 1.0
x = HOME_BTN.x + (HOME_BTN.width - icon.width) / 2
y = HOME_BTN.y + (HOME_BTN.height - icon.height) / 2
return icon, rl.Vector2(x, y), opacity
def _draw_metrics_w_sunnylink(self, rect: rl.Rectangle, _temp, _panda, _connect):
metrics = [_temp, _panda, _connect, self._sunnylink_status]
start_y = int(rect.y) + METRIC_START_Y
@@ -4,110 +4,12 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import math
import time
import pyray as rl
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.icon_widget import IconWidget
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
METERS_PER_MILE = 1609.344
METERS_PER_KILOMETER = 1000.0
SUMMARY_DURATION_SECONDS = 10.0
SUMMARY_WAIT_SECONDS = 3.0
def _nonnegative_float(value) -> float:
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return 0.0
from openpilot.system.ui.widgets.label import UnifiedLabel
class MiciHomeLayoutSP(MiciHomeLayout):
def __init__(self):
super().__init__()
self._openpilot_label = UnifiedLabel("sunnypilot", font_size=88, font_weight=FontWeight.AUDIOWIDE, max_width=480, wrap_text=False)
self._egpu_icon_default = IconWidget("icons_mici/egpu.png", (50, 37))
self._egpu_icon_default.set_visible(False)
self._egpu_icon_orange = IconWidget("icons_mici/egpu_orange.png", (50, 37))
self._egpu_icon_orange.set_visible(False)
gray_idx = self._status_bar_layout.widgets.index(self._egpu_icon_gray)
self._status_bar_layout.widgets.insert(gray_idx + 1, self._egpu_icon_default)
self._status_bar_layout.widgets.insert(gray_idx + 2, self._egpu_icon_orange)
initial_summary = ui_state.params.get("LastDriveAssistedDrivingSummary", return_default=True) or {}
self._last_summary_id = initial_summary.get("id", 0)
self._summary_wait_until = 0.0
self._summary_visible_until = 0.0
self._drive_summary = {}
def request_drive_summary(self) -> None:
self._summary_wait_until = time.monotonic() + SUMMARY_WAIT_SECONDS
def _render(self, _: rl.Rectangle) -> None:
super()._render(_)
now = time.monotonic()
if now < self._summary_wait_until:
summary = ui_state.params.get("LastDriveAssistedDrivingSummary", return_default=True) or {}
summary_id = summary.get("id", 0)
if summary_id and summary_id != self._last_summary_id:
self._last_summary_id = summary_id
distances = summary.get("distancesMeters", {})
enabled = ui_state.params.get_bool("AssistedDrivingMilestonesEnabled")
if enabled and any(_nonnegative_float(distances.get(category, 0.0)) > 0.0 for category in ("mads", "fullAssist")):
self._drive_summary = summary
self._summary_visible_until = now + SUMMARY_DURATION_SECONDS
self._summary_wait_until = 0.0
if now < self._summary_visible_until:
self._draw_drive_summary(_)
def _draw_drive_summary(self, rect: rl.Rectangle) -> None:
distances = self._drive_summary.get("distancesMeters", {})
metric = self._drive_summary.get("unit") == "metric"
meters_per_unit = METERS_PER_KILOMETER if metric else METERS_PER_MILE
unit = "KM" if metric else "MI"
mads = _nonnegative_float(distances.get("mads", 0.0)) / meters_per_unit
full_assist = _nonnegative_float(distances.get("fullAssist", 0.0)) / meters_per_unit
rl.draw_rectangle_rec(rect, rl.Color(0, 0, 0, 235))
gui_label(rl.Rectangle(rect.x, rect.y + 14, rect.width, 52), tr("DRIVE COMPLETE"), 42,
font_weight=FontWeight.SEMI_BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
gui_label(rl.Rectangle(rect.x + 20, rect.y + 78, rect.width / 2 - 30, 42), tr("MADS"), 28,
color=rl.Color(255, 255, 255, 184), alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
gui_label(rl.Rectangle(rect.x + rect.width / 2 + 10, rect.y + 78, rect.width / 2 - 30, 42), tr("FULL ASSIST"), 28,
color=rl.Color(255, 255, 255, 184), alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
gui_label(rl.Rectangle(rect.x + 20, rect.y + 116, rect.width / 2 - 30, 72), f"{mads:.1f} {unit}", 48,
font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
gui_label(rl.Rectangle(rect.x + rect.width / 2 + 10, rect.y + 116, rect.width / 2 - 30, 72), f"{full_assist:.1f} {unit}", 48,
font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _set_egpu_visibility(self):
chestnut = ui_state.sm["deviceState"].chestnutPresent
if not chestnut:
self._egpu_icon.set_visible(False)
self._egpu_icon_default.set_visible(False)
self._egpu_icon_orange.set_visible(False)
self._egpu_icon_gray.set_visible(False)
return
big_model_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad
big_model_failed = ui_state.started and ui_state.big_model_failed
loading = ui_state.usbgpu_loading or (big_model_selected and ui_state.started and ui_state.usbgpu_active is None)
if loading:
self._egpu_icon_default._opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))
self._egpu_icon_default.set_visible(True)
self._egpu_icon.set_visible(False)
self._egpu_icon_orange.set_visible(False)
self._egpu_icon_gray.set_visible(False)
else:
self._egpu_icon_default.set_visible(False)
self._egpu_icon.set_visible(big_model_selected and not big_model_failed)
self._egpu_icon_orange.set_visible(big_model_selected and big_model_failed)
self._egpu_icon_gray.set_visible(not big_model_selected)
@@ -7,37 +7,16 @@ See the LICENSE.md file in the root directory for more details.
import pyray as rl
from openpilot.cereal import custom
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle
from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.selfdrive.ui.sunnypilot.model_info import (active_source, big_model_state, bundles_for_source, carrying_model,
default_model_name, model_info, queued_name)
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import NavScroller
def _model_info() -> tuple[str, str, str]:
"""(active model, info header, info text) for the panel. Runner-matched: the
active line names what actually drives, and a notable big-model state takes
the info pair."""
source, active_name, other_name = model_info()
state = big_model_state()
_, _, carry_display = carrying_model()
if carry_display is None:
big = get_selected_bundle(ui_state.params, "usbgpu")
carry_display = big.displayName if big else default_model_name("usbgpu")
active_text = (carry_display or active_name).lower()
if state == 'failed':
return active_text, tr("big model"), tr("unavailable")
if state == 'loading':
return active_text, tr("big model"), tr("getting ready")
header = tr("small model") if source == "usbgpu" else tr("big model")
return active_text, header, other_name.lower()
class CurrentModelInfo(Widget):
def __init__(self):
super().__init__()
@@ -47,12 +26,12 @@ class CurrentModelInfo(Widget):
header_color = rl.Color(255, 255, 255, int(255 * 0.9))
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
max_width = int(self._rect.width - 20)
active_text, info_header, info_text = _model_info()
self.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY)
self.current_model_text = UnifiedLabel(active_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True)
default_text = f"{DEFAULT_MODEL} (Default)".lower()
self.current_model_text = UnifiedLabel(default_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True)
self.info_header = UnifiedLabel(info_header, 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY)
self.info_text = UnifiedLabel(info_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True)
self.info_header = UnifiedLabel("cache size", 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY)
self.info_text = UnifiedLabel("0 mb", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN)
def _render(self, _):
self.current_model_header.set_position(self._rect.x + 20, self._rect.y - 10)
@@ -76,13 +55,12 @@ class ModelsLayoutMici(NavScroller):
self._download_progress = "."
self._download_frame = 0
self._was_downloading = False
self._selection_source: str | None = None
self.select_model_btn = BigButton(tr("select model"))
self.select_model_btn.set_click_callback(self._show_folders)
self.cancel_download_btn = BigButton(tr("cancel download"))
self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadRef"))
self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadIndex"))
self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn]
self._scroller.add_widgets(self.main_items)
@@ -91,7 +69,8 @@ class ModelsLayoutMici(NavScroller):
def model_manager(self):
return ui_state.sm["modelManagerSP"]
def _get_grouped_bundles(self, bundles, favorites = None):
def _get_grouped_bundles(self, favorites = None):
bundles = self.model_manager.availableBundles
folders = {}
for bundle in bundles:
folder = next((override.value for override in bundle.overrides if override.key == "folder"), "")
@@ -111,70 +90,47 @@ class ModelsLayoutMici(NavScroller):
def _show_folders(self):
self.focused_widget = self.select_model_btn
hardware_btns = []
active = active_source()
for source, label in (("qcom", tr("small models")), ("usbgpu", tr("big models"))):
bundle = get_selected_bundle(ui_state.params, source)
value = (bundle.internalName if bundle else default_model_name(source)).lower()
if source == active:
value += f" ({tr('active')})"
btn = BigButton(label.lower(), value=value)
btn.set_click_callback(lambda s=source: self._select_hardware(s))
hardware_btns.append(btn)
self._push_selection_view(hardware_btns)
def _select_hardware(self, source):
self._selection_source = source
favs = ui_state.params.get("ModelManager_Favs")
favorites = set(favs.split(';')) if favs else set()
bundles = bundles_for_source(source)
if not bundles:
gui_app.push_widget(BigDialog(title=tr("No models available"),
description=tr("No models are available for this hardware yet. Connect to the internet and refresh the model list.")))
return
folders = self._get_grouped_bundles(bundles, favorites)
folders = self._get_grouped_bundles(favorites)
folder_buttons = []
default_btn = BigButton(default_model_name(source).lower())
default_btn.set_click_callback(lambda s=source: self._select_default(s))
default_btn = BigButton(f"{DEFAULT_MODEL} (Default)".lower())
default_btn.set_click_callback(self._select_default)
folder_buttons.append(default_btn)
for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True):
btn = BigButton(folder.lower())
btn.set_click_callback(lambda f=folder: self._select_folder(f))
if folder.lower() == "favorites":
folder_buttons.insert(0, btn)
else:
folder_buttons.append(btn)
if folder.lower() in ["release models", "master models", "favorites"]:
btn = BigButton(folder.lower())
btn.set_click_callback(lambda f=folder: self._select_folder(f))
if folder.lower() == "favorites":
folder_buttons.insert(0, btn)
else:
folder_buttons.append(btn)
self._push_selection_view(folder_buttons)
def _pop_to_main(self):
gui_app.pop_widgets_to(self)
self._scroller.scroll_panel.set_offset(0.0)
def _select_model(self, bundle):
ui_state.params.put("ModelManager_DownloadRef", bundle.ref)
ui_state.params.put("ModelManager_DownloadIndex", bundle.index)
self._pop_to_main()
def _select_default(self, source):
ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source])
def _select_default(self):
ui_state.params.remove("ModelManager_ActiveBundle")
self._pop_to_main()
def _select_folder(self, folder_name):
source = self._selection_source
if source is None: # folders are only reachable after picking a hardware
return
favs = ui_state.params.get("ModelManager_Favs")
favorites = set(favs.split(';')) if favs else set()
folders = self._get_grouped_bundles(bundles_for_source(source), favorites)
folders = self._get_grouped_bundles(favorites)
bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True)
btns = []
for bundle in bundles:
btn = BigButton(bundle.displayName.lower())
txt = bundle.displayName.lower()
btn = BigButton(txt)
btn.set_click_callback(lambda b=bundle: self._select_model(b))
btns.append(btn)
self._push_selection_view(btns)
@@ -206,10 +162,10 @@ class ModelsLayoutMici(NavScroller):
self._was_downloading = is_downloading
self.current_model_info.current_model_header.set_text(tr("active model"))
active_text, info_header, info_text = _model_info()
self.current_model_info.current_model_text.set_text(active_text)
self.current_model_info.info_header.set_text(info_header)
self.current_model_info.info_text.set_text(info_text)
model_text = manager.activeBundle.displayName.lower() if manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)".lower()
self.current_model_info.current_model_text.set_text(model_text)
self.current_model_info.info_header.set_text(tr("cache size"))
self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB")
if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed:
self.current_model_info.info_header.set_text(tr("error") + self._download_progress)
@@ -220,29 +176,19 @@ class ModelsLayoutMici(NavScroller):
device.set_override_interactive_timeout(5)
progress = 0.0
count = 0
verifying = False
for model in manager.selectedBundle.models:
count += 1
p = model.artifact.downloadProgress
if p.status in (custom.ModelManagerSP.DownloadStatus.downloading,
custom.ModelManagerSP.DownloadStatus.verifying):
if p.status == custom.ModelManagerSP.DownloadStatus.downloading:
progress += p.progress
verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded,
custom.ModelManagerSP.DownloadStatus.cached):
progress += 100.0
self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading"))
self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download"))
self.current_model_info.current_model_header.set_text(tr("downloading"))
self.current_model_info.current_model_header._shimmer = True
name_text = manager.selectedBundle.internalName.lower()
if queued := queued_name(manager.selectedBundle.ref):
name_text += f" | {queued.lower()} {tr('queued')}"
self.current_model_info.current_model_text.set_text(name_text)
self.current_model_info.current_model_text.set_text(f"{manager.selectedBundle.internalName.lower()}")
self.current_model_info.info_header.set_text(tr("progress") + self._download_progress)
self.current_model_info.info_header._shimmer = True
self.current_model_info.info_text.set_text(f"{progress/count:.2f}%")
elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded:
self.current_model_info.info_header.set_text(tr("downloaded"))
self.current_model_info.info_text.set_text(tr("downloaded"))
@@ -12,23 +12,13 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, Bi
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
ICON_SIZE = 70
BIG_ICON_SIZE = 110
class SunnylinkBigButton(SettingsBigButton):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._label.set_font_weight(FontWeight.AUDIOWIDE)
def _get_label_font_size(self):
# Audiowide runs wider than Inter: "sunnylink" wraps to two lines at 64
return 56
class SettingsLayoutSP(OP.SettingsLayout):
def __init__(self):
OP.SettingsLayout.__init__(self)
@@ -43,7 +33,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE)
sunnylink_panel = SunnylinkLayoutMici()
sunnylink_btn = SunnylinkBigButton(tr("sunnylink"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/sunnylink.png", 76, 44))
sunnylink_btn = SettingsBigButton(tr("sunnylink"), "", gui_app.texture("icons_mici/settings/developer/ssh.png", 55, 55))
sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel))
models_panel = ModelsLayoutMici()
@@ -66,8 +56,8 @@ class SettingsLayoutSP(OP.SettingsLayout):
items = self._scroller._items.copy()
items.insert(1, models_btn)
items.insert(5, sunnylink_btn)
items.insert(1, sunnylink_btn)
items.insert(2, models_btn)
# front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad
items.insert(0, self._enable_offroad_btn_onroad)
@@ -7,7 +7,6 @@ See the LICENSE.md file in the root directory for more details.
import pyray as rl
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.sunnypilot.onroad.blind_spot_indicators import BlindSpotIndicators
@@ -22,8 +21,6 @@ class HudRendererSP(HudRenderer):
def _render(self, rect: rl.Rectangle) -> None:
super()._render(rect)
if ui_state.usbgpu and not ui_state.usbgpu_compiled and ui_state.model_runner_tinygrad:
self._draw_model_source(rect)
self.blind_spot_indicators.render(rect)
def _has_blind_spot_detected(self) -> bool:
@@ -1,88 +0,0 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.models.fetcher import get_cached_bundles
from openpilot.sunnypilot.models.helpers import get_active_source, get_selected_bundle, resolve_bundle_by_ref
from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL, DEFAULT_MODEL
def active_source() -> str:
return get_active_source(usbgpu=ui_state.usbgpu,
usbgpu_active=ui_state.usbgpu_active, usbgpu_loading=ui_state.usbgpu_loading,
offroad=ui_state.is_offroad())
def bundles_for_source(source: str):
if source == active_source():
return ui_state.sm["modelManagerSP"].availableBundles
return get_cached_bundles(ui_state.params, source)
def default_model(source: str) -> str:
return DEFAULT_BIG_MODEL if source == 'usbgpu' else DEFAULT_MODEL
def default_model_name(source: str) -> str:
return f"{default_model(source)} (Default)"
def big_model_state() -> str | None:
"""'failed' | 'loading' | None, mirroring the sidebar's detection (#1969)."""
if ui_state.started and ui_state.usbgpu and ui_state.big_model_failed:
return 'failed'
big_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad
if ui_state.usbgpu_loading or (big_selected and ui_state.started and ui_state.usbgpu_active is None):
return 'loading'
return None
def carrying_model() -> tuple[str | None, str | None, str | None]:
"""(source, internal name, display name) of what actually drives. Runner-matched:
when a Default big cannot carry, stock modeld runs the Default small, never the
small slot's pick; a custom big has no automatic fallback yet -> (None, None, None)."""
source = active_source()
if source == "usbgpu":
bundle = get_selected_bundle(ui_state.params, "usbgpu")
if bundle:
return "usbgpu", bundle.internalName, bundle.displayName
name = default_model_name("usbgpu")
return "usbgpu", name, name
if ui_state.usbgpu:
if get_selected_bundle(ui_state.params, "usbgpu") is None:
name = default_model_name("qcom")
return "qcom", name, name
return None, None, None
bundle = get_selected_bundle(ui_state.params, "qcom")
if bundle:
return "qcom", bundle.internalName, bundle.displayName
name = default_model_name("qcom")
return "qcom", name, name
def queued_name(current_ref) -> str | None:
ref = ui_state.params.get("ModelManager_DownloadRef")
if ref and ref != current_ref:
source_bundles = {source: bundles_for_source(source) for source in ("qcom", "usbgpu")}
if resolved := resolve_bundle_by_ref(ref, source_bundles):
return resolved[0].internalName
return None
def model_info() -> tuple[str, str, str]:
"""returns (active source, active model name, other model name)
Names come from the params slots, never modelManagerSP.activeBundle the
manager republishes a tick after a chestnut change, so the stale bundle
would flash the wrong model."""
source = active_source()
other = "qcom" if source == "usbgpu" else "usbgpu"
active_bundle = get_selected_bundle(ui_state.params, source)
other_bundle = get_selected_bundle(ui_state.params, other)
active_name = active_bundle.displayName if active_bundle else default_model_name(source)
other_name = other_bundle.displayName if other_bundle else default_model_name(other)
return source, active_name, other_name
@@ -1,228 +0,0 @@
"""Render assisted-driving milestone celebrations over the on-road view."""
import math
import random
import time
from collections import deque
from dataclasses import dataclass
import pyray as rl
from openpilot.cereal import custom
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import ALERT_BACKGROUND_OPACITY
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import FONT_SIZES
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
CELEBRATION_DURATION = 4.5
PARTICLE_COUNT = 150
METERS_PER_MILE = 1609.344
METERS_PER_KILOMETER = 1000.0
CONFETTI_COLORS = (
rl.Color(255, 55, 95, 255),
rl.Color(255, 183, 3, 255),
rl.Color(48, 209, 88, 255),
rl.Color(36, 179, 255, 255),
rl.Color(112, 72, 232, 255),
rl.Color(255, 45, 196, 255),
)
@dataclass(frozen=True)
class ConfettiParticle:
x: float
y: float
width: float
height: float
speed: float
drift: float
angle: float
spin: float
phase: float
color: rl.Color
@dataclass(frozen=True)
class CelebrationMilestone:
event_id: int
full_assist: bool
distance_meters: float
previous_distance_meters: float
metric: bool
class MilestoneCelebration(Widget):
"""Pure renderer for typed assisted-driving milestone events."""
def __init__(self):
super().__init__()
self._drive_started_time = -1.0
self._celebration_started_time: float | None = None
self._current_milestone: CelebrationMilestone | None = None
self._pending_milestones: deque[CelebrationMilestone] = deque()
self._last_event_id = 0
self._particles = self._make_particles()
@staticmethod
def _make_particles() -> list[ConfettiParticle]:
rng = random.Random(20260828)
return [
ConfettiParticle(
x=rng.random(),
y=rng.uniform(-0.25, 0.95),
width=rng.uniform(10, 24),
height=rng.uniform(24, 58),
speed=rng.uniform(0.12, 0.34),
drift=rng.uniform(-0.035, 0.035),
angle=rng.uniform(0, 360),
spin=rng.uniform(-150, 150),
phase=rng.uniform(0, math.tau),
color=CONFETTI_COLORS[rng.randrange(len(CONFETTI_COLORS))],
)
for _ in range(PARTICLE_COUNT)
]
def _render(self, rect: rl.Rectangle, /) -> None:
now = time.monotonic()
if ui_state.started_time != self._drive_started_time:
self._drive_started_time = ui_state.started_time
self._celebration_started_time = None
self._current_milestone = None
self._pending_milestones.clear()
self._consume_event(suppress=False)
if self._current_milestone is None and self._pending_milestones:
self._current_milestone = self._pending_milestones.popleft()
self._celebration_started_time = now
if self._celebration_started_time is None or self._current_milestone is None:
return
elapsed = now - self._celebration_started_time
if elapsed >= CELEBRATION_DURATION:
self._celebration_started_time = None
self._current_milestone = None
return
alpha = min(1.0, elapsed / 0.2, (CELEBRATION_DURATION - elapsed) / 0.8)
self._draw_background_scrim(rect, alpha)
self._draw_confetti(rect, elapsed, alpha)
self._draw_milestone(rect, elapsed, alpha, self._current_milestone)
def cancel_for_alert(self) -> None:
self._consume_event(suppress=True)
self._celebration_started_time = None
self._current_milestone = None
self._pending_milestones.clear()
def _consume_event(self, suppress: bool) -> None:
if not ui_state.sm.updated["assistedDrivingMilestoneState"]:
return
state = ui_state.sm["assistedDrivingMilestoneState"]
event = state.event
if not state.enabled:
self._celebration_started_time = None
self._current_milestone = None
self._pending_milestones.clear()
return
if event.id == 0 or event.id == self._last_event_id:
return
self._last_event_id = event.id
if suppress:
return
self._pending_milestones.append(CelebrationMilestone(
event_id=event.id,
full_assist=event.category == custom.AssistedDrivingMilestoneState.Category.fullAssist,
distance_meters=event.distanceMeters,
previous_distance_meters=event.previousDistanceMeters,
metric=event.unit == custom.AssistedDrivingMilestoneState.Unit.metric,
))
def _draw_confetti(self, rect: rl.Rectangle, elapsed: float, alpha: float) -> None:
travel_height = rect.height * 1.45
compact = rect.height <= 300
particle_scale = rect.height / 1080.0
particles = self._particles[:100] if compact else self._particles
for particle in particles:
x = rect.x + rect.width * (particle.x + particle.drift * elapsed + 0.012 * math.sin(elapsed * 3 + particle.phase))
y = rect.y - rect.height * 0.2 + (particle.y * travel_height + particle.speed * rect.height * elapsed) % travel_height
flip = 0.2 + 0.8 * abs(math.sin(elapsed * 5 + particle.phase))
particle_rect = rl.Rectangle(x, y, particle.width * particle_scale * flip, particle.height * particle_scale)
origin = rl.Vector2(particle_rect.width / 2, particle_rect.height / 2)
color = rl.Color(particle.color.r, particle.color.g, particle.color.b, int(255 * alpha))
rl.draw_rectangle_pro(particle_rect, origin, particle.angle + particle.spin * elapsed, color)
@staticmethod
def _draw_milestone(rect: rl.Rectangle, elapsed: float, alpha: float, milestone: CelebrationMilestone) -> None:
# Match the comma four set-speed hierarchy: DISPLAY number with a MAX-sized label.
scale = rect.height / 240.0
pulse = 1.0 + 0.025 * math.sin(min(elapsed, 0.6) / 0.6 * math.pi)
number_size = int(FONT_SIZES.set_speed * scale * pulse)
milestone_size = int(FONT_SIZES.max_speed * scale * pulse)
category_size = int(22 * scale * pulse)
unit_size = category_size
display_font = gui_app.font(FontWeight.DISPLAY)
semibold_font = gui_app.font(FontWeight.SEMI_BOLD)
tween_progress = min(elapsed / 0.85, 1.0)
tween_progress = 1.0 - (1.0 - tween_progress) ** 3
meters_per_unit = METERS_PER_KILOMETER if milestone.metric else METERS_PER_MILE
previous_distance = milestone.previous_distance_meters / meters_per_unit
milestone_distance = milestone.distance_meters / meters_per_unit
displayed_distance = previous_distance + (milestone_distance - previous_distance) * tween_progress
if tween_progress >= 1.0:
number = f"{round(milestone_distance):,}"
else:
number = f"{displayed_distance:,.1f}"
unit = tr("KM") if milestone.metric else tr("MI")
category = tr("FULL ASSIST") if milestone.full_assist else tr("MADS")
milestone_label = tr("MILESTONE")
unit_bounds = measure_text_cached(semibold_font, unit, unit_size)
number_bounds = measure_text_cached(display_font, number, number_size)
max_number_width = rect.width * 0.72 - unit_bounds.x - 8 * scale
if number_bounds.x > max_number_width:
number_size = max(1, int(number_size * max_number_width / number_bounds.x))
number_bounds = measure_text_cached(display_font, number, number_size)
category_bounds = measure_text_cached(semibold_font, category, category_size)
milestone_bounds = measure_text_cached(semibold_font, milestone_label, milestone_size)
center_x = rect.x + rect.width / 2
center_y = rect.y + rect.height / 2
text_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
secondary_color = rl.Color(255, 255, 255, int(255 * 0.72 * alpha))
number_line_width = number_bounds.x + 8 * scale + unit_bounds.x
number_x = center_x - number_line_width / 2
number_y = center_y - 76 * scale
unit_y = center_y + 14 * scale
category_y = center_y - 91 * scale
milestone_y = center_y + 50 * scale
rl.draw_text_ex(semibold_font, category, rl.Vector2(center_x - category_bounds.x / 2, category_y),
category_size, 0, secondary_color)
rl.draw_text_ex(display_font, number, rl.Vector2(number_x, number_y), number_size, 0, text_color)
rl.draw_text_ex(semibold_font, unit, rl.Vector2(number_x + number_bounds.x + 8 * scale, unit_y),
unit_size, 0, secondary_color)
rl.draw_text_ex(semibold_font, milestone_label, rl.Vector2(center_x - milestone_bounds.x / 2, milestone_y),
milestone_size, 0, text_color)
@staticmethod
def _draw_background_scrim(rect: rl.Rectangle, alpha: float) -> None:
# Match the alert background: a mostly opaque black core fading to transparent.
fade_height = round(rect.height * 0.25)
solid_height = round(rect.height * 0.50)
solid_color = rl.Color(0, 0, 0, int(255 * ALERT_BACKGROUND_OPACITY * alpha))
transparent = rl.Color(0, 0, 0, 0)
x = int(rect.x)
y = int(rect.y)
width = int(rect.width)
rl.draw_rectangle_gradient_v(x, y, width, fade_height, transparent, solid_color)
rl.draw_rectangle(x, y + fade_height, width, solid_height, solid_color)
rl.draw_rectangle_gradient_v(x, y + fade_height + solid_height, width, fade_height, solid_color, transparent)
@@ -4,29 +4,11 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.sunnypilot.onroad.chevron_metrics import ChevronMetrics
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
from openpilot.selfdrive.ui.sunnypilot.ui_state import MADSState
from openpilot.system.ui.lib.application import gui_app
class ModelRendererSP:
def __init__(self):
self.rainbow_path = RainbowPath()
self.chevron_metrics = ChevronMetrics()
self._width_filter = FirstOrderFilter(0.9, 0.1, 1 / gui_app.target_fps)
@property
def _lateral_active(self) -> bool:
sm = ui_state.sm
if sm.valid["selfdriveStateSP"]:
mads = sm["selfdriveStateSP"].mads
if mads.available:
return mads.enabled and mads.state != MADSState.paused
return ui_state.status in (UIStatus.ENGAGED, UIStatus.LAT_ONLY)
def _get_path_half_width(self) -> float:
target = 0.9 if self._lateral_active else 0.40
return self._width_filter.update(target)
@@ -10,7 +10,6 @@ from openpilot.cereal import messaging, log, custom
from opendbc.car.structs import car
from openpilot.common.params import Params
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import OnroadBrightness
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_active_source
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.sunnypilot.widgets.screen_saver import ScreenSaverSP
@@ -35,8 +34,7 @@ class UIStateSP:
self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch")
self.sm_services_ext = [
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
"gpsLocation", "lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay",
"assistedDrivingMilestoneState",
"gpsLocation", "lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay"
]
self.sunnylink_state = SunnylinkState()
@@ -45,7 +43,6 @@ class UIStateSP:
self.screensaver_enabled: bool = False
self.active_bundle = None
self.model_runner_tinygrad: bool = False
self.blindspot: bool = False
self.chevron_metrics = None
self.custom_interactive_timeout: int = 0
@@ -153,10 +150,7 @@ class UIStateSP:
self.has_icbm = self.CP_SP.intelligentCruiseButtonManagementAvailable and self.params.get_bool("IntelligentCruiseButtonManagement")
self._enforce_constraints()
source = get_active_source(usbgpu=self.usbgpu, usbgpu_active=self.usbgpu_active,
usbgpu_loading=self.usbgpu_loading, offroad=self.is_offroad())
self.active_bundle = self.params.get(ACTIVE_BUNDLE_KEYS[source])
self.model_runner_tinygrad = self.active_bundle is not None and self.active_bundle.get("runner") == "tinygrad"
self.active_bundle = self.params.get("ModelManager_ActiveBundle")
self.blindspot = self.params.get_bool("BlindSpot")
self.chevron_metrics = self.params.get("ChevronInfo")
self.custom_interactive_timeout = self.params.get("InteractivityTimeout", return_default=True)
@@ -1,45 +0,0 @@
#!/usr/bin/env python3
"""Generate the assisted-driving milestone celebration chime."""
import math
import wave
from array import array
from pathlib import Path
SAMPLE_RATE = 48_000
DURATION_SECONDS = 0.82
NOTES = (
(0.00, 523.25),
(0.11, 659.25),
(0.22, 783.99),
)
def note_sample(age: float, frequency: float) -> float:
if not 0 <= age <= 0.58:
return 0.0
attack = min(age / 0.008, 1.0)
release = min((0.58 - age) / 0.15, 1.0)
envelope = attack * release * math.exp(-3.8 * age)
tone = math.sin(math.tau * frequency * age) + 0.16 * math.sin(math.tau * frequency * 2 * age)
return envelope * tone
def main() -> None:
output = Path(__file__).parents[4] / "openpilot/selfdrive/assets/sounds/milestone.wav"
samples = array('h')
for frame in range(round(SAMPLE_RATE * DURATION_SECONDS)):
t = frame / SAMPLE_RATE
value = 0.38 * sum(note_sample(t - start, frequency) for start, frequency in NOTES)
samples.append(round(max(-1.0, min(1.0, value)) * 32767))
with wave.open(str(output), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(SAMPLE_RATE)
wav.writeframes(samples.tobytes())
if __name__ == "__main__":
main()
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Publish deterministic milestone events for the local comma-four UI preview."""
import itertools
import time
from openpilot.cereal import messaging
def main() -> None:
pm = messaging.PubMaster(["assistedDrivingMilestoneState"])
milestones = itertools.cycle(((1, 0, "mads"), (2, 1, "fullAssist"), (5, 2, "mads"), (10, 5, "fullAssist")))
event_id = 0
milestone, previous_milestone, category = 0, 0, "mads"
next_event_time = time.monotonic() + 1.0
while True:
now = time.monotonic()
if now >= next_event_time:
event_id += 1
milestone, previous_milestone, category = next(milestones)
next_event_time = now + 6.0
msg = messaging.new_message("assistedDrivingMilestoneState")
state = msg.assistedDrivingMilestoneState
state.enabled = True
if event_id:
state.event.id = event_id
state.event.category = category
state.event.distanceMeters = milestone * 1609.344
state.event.previousDistanceMeters = previous_milestone * 1609.344
state.event.unit = "imperial"
pm.send("assistedDrivingMilestoneState", msg)
time.sleep(0.1)
if __name__ == "__main__":
main()
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -e
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
replay_pid=""
preview_pid=""
cleanup() {
for pid in "$preview_pid" "$replay_pid"; do
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
export PATH="$repo_root/.venv/bin:$PATH"
export SP_MILESTONE_PREVIEW=1
playback="${SP_MILESTONE_PLAYBACK:-1}"
"$repo_root/openpilot/tools/replay/replay" --demo --playback "$playback" &
replay_pid=$!
"$repo_root/.venv/bin/python" "$repo_root/openpilot/selfdrive/ui/tests/milestone_preview.py" &
preview_pid=$!
"$repo_root/.venv/bin/python" "$repo_root/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py"
@@ -0,0 +1,21 @@
from types import SimpleNamespace
from unittest.mock import Mock
from opendbc.car.structs import car
from openpilot.selfdrive.ui.sunnypilot.custom_button import CustomButtonAction, handle_custom_button
def test_custom_button_actions():
params = Mock()
press = SimpleNamespace(carState=SimpleNamespace(buttonEvents=[SimpleNamespace(
type=car.CarState.ButtonEvent.Type.altButton2,
pressed=True,
)]))
messages = [press, SimpleNamespace(carState=SimpleNamespace(buttonEvents=[])), press]
callbacks = {action: Mock() for action in CustomButtonAction if action != CustomButtonAction.NONE}
for action, callback in callbacks.items():
params.get.return_value = action
handle_custom_button(messages, params, callbacks)
assert callback.call_count == 2
+5 -56
View File
@@ -4,63 +4,12 @@ import time
from openpilot.common.test import OpenpilotTestCase
from openpilot.cereal import log, messaging
from openpilot.cereal.messaging import SubMaster, PubMaster
from openpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, Soundd, calculate_volume_for_device, check_selfdrive_timeout_alert
from openpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, check_selfdrive_timeout_alert
AudibleAlert = log.SelfdriveState.AudibleAlert
class TestSoundd(OpenpilotTestCase):
@staticmethod
def milestone_submaster(event_id=42):
class SubMasterStub:
def __init__(self):
self.updated = {'assistedDrivingMilestoneState': True}
msg = messaging.new_message('assistedDrivingMilestoneState')
msg.assistedDrivingMilestoneState.enabled = True
msg.assistedDrivingMilestoneState.event.id = event_id
self.data = {'assistedDrivingMilestoneState': msg.assistedDrivingMilestoneState}
def __getitem__(self, service):
return self.data[service]
return SubMasterStub()
def test_comma_four_volume_is_50_percent_louder_than_comma_three_x(self):
for weighted_db in (20.0, 30.0, 40.0, 50.0):
with self.subTest(weighted_db=weighted_db):
comma_three_x_volume = calculate_volume_for_device(weighted_db, "tizi")
comma_four_volume = calculate_volume_for_device(weighted_db, "mici")
assert comma_four_volume == min(1.0, comma_three_x_volume * 1.5)
def test_milestone_chime_uses_typed_milestone_event_once(self):
soundd = Soundd()
sm = self.milestone_submaster()
soundd.update_milestone_alert(sm)
assert soundd.current_alert == AudibleAlert.complete
soundd.current_alert = AudibleAlert.none
soundd.update_milestone_alert(sm)
assert soundd.current_alert == AudibleAlert.none
def test_safety_alert_consumes_milestone_without_replaying_it(self):
soundd = Soundd()
sm = self.milestone_submaster()
soundd.current_alert = AudibleAlert.warningImmediate
soundd.update_milestone_alert(sm)
soundd.current_alert = AudibleAlert.none
soundd.update_milestone_alert(sm)
assert soundd.current_alert == AudibleAlert.none
def test_quiet_mode_consumes_milestone_without_playing_it(self):
soundd = Soundd()
soundd.enabled = True
soundd.update_milestone_alert(self.milestone_submaster())
assert soundd.current_alert == AudibleAlert.none
def test_check_selfdrive_timeout_alert(self, mocker):
sm = SubMaster(['selfdriveState', 'selfdriveStateSP'])
pm = PubMaster(['selfdriveState', 'selfdriveStateSP'])
@@ -71,14 +20,14 @@ class TestSoundd(OpenpilotTestCase):
sm.update(100)
assert sm.updated['selfdriveState']
sm.recv_time['selfdriveState'] = 0
clock = mocker.patch("openpilot.selfdrive.ui.soundd.time.monotonic", return_value=SELFDRIVE_STATE_TIMEOUT)
received_at = sm.recv_time['selfdriveState']
clock = mocker.patch("openpilot.selfdrive.ui.soundd.time.monotonic", return_value=received_at + SELFDRIVE_STATE_TIMEOUT)
assert not check_selfdrive_timeout_alert(sm)
clock.return_value = SELFDRIVE_STATE_TIMEOUT + 0.1
clock.return_value = received_at + SELFDRIVE_STATE_TIMEOUT + 0.1
assert check_selfdrive_timeout_alert(sm)
clock.return_value = SELFDRIVE_STATE_TIMEOUT + 10
clock.return_value = received_at + SELFDRIVE_STATE_TIMEOUT + 10
assert not check_selfdrive_timeout_alert(sm)
def test_check_selfdrive_timeout_alert_mads_lateral_only(self):
-9
View File
@@ -112,15 +112,6 @@ class UIState(UIStateSP):
def add_on_body_changed_callbacks(self, callback: Callable[[], None]):
self._on_body_changed_callbacks.append(callback)
@property
def big_model_failed(self) -> bool:
# Mirrors the onroad HUD's four-condition check so sidebar and home icons reflect the same failure states
return (self.usbgpu_active is False or
not self.sm['deviceState'].chestnutPresent or
(self.usbgpu_active is True and self.sm.recv_frame['modelV2'] > self.started_frame and
not self.sm.alive['modelV2']) or
(self.usbgpu_active is None and self.sm.recv_frame['modelV2'] > self.started_frame))
@property
def engaged(self) -> bool:
return self.started and (self.sm["selfdriveState"].enabled or self.sm["selfdriveStateSP"].mads.enabled)
+1
View File
@@ -1,2 +1,3 @@
SConscript(['common/transformations/SConscript'])
SConscript(['modeld_v2/SConscript'])
SConscript(['selfdrive/locationd/SConscript'])
+2 -5
View File
@@ -8,10 +8,7 @@ from openpilot.common.params import Params
def get_lat_delay(params: Params, stock_lat_delay: float) -> float:
# live learning on: use what lagd publishes.
# off: use the fixed steerActuatorDelay + software delay sum that LagdToggle caches.
if params.get_bool("LagdToggle"):
return stock_lat_delay
return float(params.get("LagdValueCache", return_default=True))
return float(params.get("LagdValueCache", return_default=True))
return stock_lat_delay
-17
View File
@@ -55,19 +55,6 @@ def cleanup_old_osm_data(files_to_remove: list[str]) -> None:
shutil.rmtree(file, ignore_errors=False)
def clear_downloaded_maps() -> None:
"""Deletes downloaded OSM map data and resets params."""
path = f"{Paths.mapd_root()}/offline"
if os.path.exists(path):
shutil.rmtree(path, ignore_errors=True)
for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle",
"OsmStateName", "OsmStateTitle"):
params.remove(param)
cloudlog.info("mapd: downloaded maps cleared")
def request_refresh_osm_location_data(nations: list[str], states: list[str] | None = None) -> None:
params.put("OsmDownloadedDate", str(datetime.now().timestamp()), block=True)
params.put_bool("OsmDbUpdatesCheck", False, block=True)
@@ -144,10 +131,6 @@ def main_thread():
show_alert = bool(get_files_for_cleanup() and params.get_bool("OsmLocal"))
set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.")
if params.get("Mapd_ClearCache"):
clear_downloaded_maps()
params.remove("Mapd_ClearCache")
update_osm_db()
live_map_sp.tick()
rk.keep_time()
+84
View File
@@ -0,0 +1,84 @@
import os
import glob
from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye
from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE
from openpilot.common.hardware import HARDWARE, PC
Import('env', 'arch', 'release')
lenv = env.Clone()
tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x]
def get_camera_configs():
DEVICE_RESOLUTIONS = {
"tici": (_ar_ox_fisheye.width, _ar_ox_fisheye.height),
"tizi": (_ar_ox_fisheye.width, _ar_ox_fisheye.height),
"mici": (_os_fisheye.width, _os_fisheye.height),
}
if release or PC or 'CI' in os.environ:
return set(DEVICE_RESOLUTIONS.values())
return [DEVICE_RESOLUTIONS[HARDWARE.get_device_type()]]
CAMERA_CONFIGS = get_camera_configs()
tg_flags = {
'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0',
'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")}',
}.get(arch, 'DEV=CPU:LLVM')
image_flag = {
'larch64': 'IMAGE=2',
}.get(arch, 'IMAGE=0')
model_w, model_h = MEDMODEL_INPUT_SIZE
from openpilot.selfdrive.modeld.constants import ModelConstants
frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS)
pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + ':' + env.Dir("#").abspath + '"'
compile_modeld_script = File("compile_modeld.py").abspath
upstream_compile_script = File(Dir("#openpilot/selfdrive/modeld").File("compile_modeld.py").abspath)
script_deps = [File("compile_modeld.py"), upstream_compile_script]
def compile_combined(model_type, onnx_args, output_name):
output_pkl = File(f"models/{output_name}").abspath
cmd = (f'{pythonpath_string} {tg_flags} {image_flag} python3 {compile_modeld_script} '
f'--model-type {model_type} '
f'--model-size {model_w}x{model_h} '
f'--camera-resolutions {camera_res_args} '
f'{onnx_args} '
f'--frame-skip {frame_skip} '
f'--output {output_pkl}')
onnx_files = [f for f in onnx_args.split() if f.endswith('.onnx')]
return lenv.Command(output_pkl, tinygrad_files + script_deps + [File(f) for f in onnx_files if os.path.isfile(f)], cmd)
# Vision + Policy (stock default model)
vision_onnx = File("models/driving_vision.onnx").abspath
policy_onnx = File("models/driving_policy.onnx").abspath
if os.path.isfile(vision_onnx) and os.path.isfile(policy_onnx):
compile_combined('vision_policy',
f'--vision-onnx {vision_onnx} --policy-onnx {policy_onnx}',
'driving_combined_tinygrad.pkl')
# Vision + Off-Policy
off_policy_onnx = File("models/driving_off_policy.onnx").abspath
if os.path.isfile(vision_onnx) and os.path.isfile(off_policy_onnx):
policy_arg = f'--policy-onnx {policy_onnx}' if os.path.isfile(policy_onnx) else ''
compile_combined('vision_multi_policy',
f'--vision-onnx {vision_onnx} {policy_arg} --off-policy-onnx {off_policy_onnx}',
'driving_combined_multi_tinygrad.pkl')
# Vision + On-Policy + Off-Policy
on_policy_onnx = File("models/driving_on_policy.onnx").abspath
if os.path.isfile(vision_onnx) and os.path.isfile(on_policy_onnx) and os.path.isfile(off_policy_onnx):
compile_combined('vision_multi_policy',
f'--vision-onnx {vision_onnx} --off-policy-onnx {off_policy_onnx} --on-policy-onnx {on_policy_onnx}',
'driving_combined_tri_tinygrad.pkl')
# Supercombo
supercombo_onnx = File("models/supercombo.onnx").abspath
if os.path.isfile(supercombo_onnx):
compile_combined('supercombo',
f'--supercombo-onnx {supercombo_onnx}',
'driving_combined_supercombo_tinygrad.pkl')
+136 -160
View File
@@ -7,12 +7,11 @@ See the LICENSE.md file in the root directory for more details.
"""
import argparse
import math
import os
import tempfile
import pickle
import time
from collections import defaultdict
from functools import partial
from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob
import numpy as np
os.environ['GMMU'] = '0'
@@ -39,9 +38,6 @@ from tinygrad.engine.jit import TinyJit
from tinygrad.tensor import Tensor
MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy')
WARP_INPUTS = ['tfm', 'big_tfm']
POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs']
WARP_DEV = os.getenv('WARP_DEV')
def _detect_desire_key(shapes: dict) -> str | None:
@@ -67,21 +63,20 @@ def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tu
if desire_key:
shapes['desire'] = (input_shapes[desire_key][2],)
if is_supercombo and 'features_buffer' in input_shapes:
fb = input_shapes['features_buffer']
shapes['prev_feat'] = (fb[0], fb[2])
for key, shape in input_shapes.items():
if key not in (desire_key, 'features_buffer') and 'img' not in key:
shapes[key] = tuple(shape)
if is_supercombo and 'features_buffer' in input_shapes:
fb = input_shapes['features_buffer']
feat_dim = math.prod(fb[2:])
shapes['prev_feat'] = (fb[0], feat_dim)
sizes = [int(np.prod(size)) for size in shapes.values()]
return shapes, sizes
def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = Device.DEFAULT,
is_supercombo: bool = False) -> tuple[dict, dict]:
is_supercombo: bool = False, use_packed: bool = True) -> tuple[dict, dict]:
road_key, _ = _detect_vision_keys(input_shapes)
if not road_key:
raise ValueError("Vision road key missing from input shapes.")
@@ -97,77 +92,74 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D
desire_shape = input_shapes[desire_key]
features_buffer = input_shapes.get('features_buffer')
npy_arrays = {
'tfm': np.zeros((3, 3), dtype=np.float32),
'big_tfm': np.zeros((3, 3), dtype=np.float32)
}
if use_packed: # remove packed detection block after all models are recompiled
npy_arrays = {
'tfm': np.zeros((3, 3), dtype=np.float32),
'big_tfm': np.zeros((3, 3), dtype=np.float32)
}
shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo)
packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32)
shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo)
packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32)
split_indices = np.cumsum(sizes[:-1]) if len(sizes) > 1 else []
split_views = np.split(packed_npy_inputs, split_indices) if len(sizes) > 0 else []
for (k, s), v in zip(shapes.items(), split_views, strict=True):
npy_arrays[k] = v.reshape(s)
split_indices = np.cumsum(sizes[:-1]) if len(sizes) > 1 else []
split_views = np.split(packed_npy_inputs, split_indices) if len(sizes) > 0 else []
for (k, s), v in zip(shapes.items(), split_views, strict=True):
npy_arrays[k] = v.reshape(s)
queues = {
'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]),
dtype=np.float32), device=device).contiguous().realize(),
'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(),
}
queues = {
'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]),
dtype=np.float32), device=device).contiguous().realize(),
'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(),
}
if features_buffer:
feat_dim = math.prod(features_buffer[2:])
feat_q_len = frame_skip * features_buffer[1] if is_supercombo else frame_skip * (features_buffer[1] - 1) + 1
queues['feat_q'] = Tensor(np.zeros((feat_q_len, features_buffer[0], feat_dim),
dtype=np.float32), device=device).contiguous().realize()
if features_buffer:
queues['feat_q'] = Tensor(np.zeros((frame_skip * (features_buffer[1] - 1) + 1, features_buffer[0], features_buffer[2]),
dtype=np.float32), device=device).contiguous().realize()
queues.update({key: Tensor(value, device='NPY').realize() for key, value in npy_arrays.items() if key in ('tfm', 'big_tfm')})
queues.update({key: Tensor(value, device='NPY').realize() for key, value in npy_arrays.items() if key in ('tfm', 'big_tfm')})
else:
# TODO-SP: Remove legacy queuing fallback else block after all models are recompiled
npy_arrays = {
'desire': np.zeros(desire_shape[2], dtype=np.float32),
'tfm': np.zeros((3, 3), dtype=np.float32),
'big_tfm': np.zeros((3, 3), dtype=np.float32)
}
for key, shape in input_shapes.items():
if key not in npy_arrays and 'img' not in key and key not in ('features_buffer', desire_key):
npy_arrays[key] = np.zeros(shape, dtype=np.float32)
queues = {
'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]),
dtype=np.float32), device=device).contiguous().realize()
}
if features_buffer:
queues['feat_q'] = Tensor(np.zeros((frame_skip * (features_buffer[1] - 1) + 1, features_buffer[0], features_buffer[2]),
dtype=np.float32), device=device).contiguous().realize()
queues.update({key: Tensor(value, device='NPY').realize() for key, value in npy_arrays.items()})
return queues, npy_arrays
def make_split_input_queues(vision_input_shapes: dict, policy_input_shapes: dict,
frame_skip: int, device: str = Device.DEFAULT) -> tuple[dict, dict]:
return generate_queues_and_npy({**vision_input_shapes, **policy_input_shapes}, frame_skip, device, is_supercombo=False)
frame_skip: int, device: str = Device.DEFAULT, use_packed: bool = True) -> tuple[dict, dict]:
return generate_queues_and_npy({**vision_input_shapes, **policy_input_shapes}, frame_skip, device, is_supercombo=False, use_packed=use_packed)
def make_supercombo_input_queues(input_shapes: dict, frame_skip: int,
device: str = Device.DEFAULT) -> tuple[dict, dict]:
return generate_queues_and_npy(input_shapes, frame_skip, device, is_supercombo=True)
device: str = Device.DEFAULT, use_packed: bool = True) -> tuple[dict, dict]:
return generate_queues_and_npy(input_shapes, frame_skip, device, is_supercombo=True, use_packed=use_packed)
def make_random_images(keys, shape, device):
return {k: Tensor.randint(shape, low=0, high=256, dtype=dtypes.uint8, device=device).realize() for k in keys}
def make_warp_queues(device=Device.DEFAULT):
npy = {
'tfm': np.zeros((3, 3), dtype=np.float32),
'big_tfm': np.zeros((3, 3), dtype=np.float32),
}
queues = {k: Tensor(v, device='NPY').realize() for k, v in npy.items()}
return queues, npy
def make_warp(nv12: NV12Frame, model_w: int, model_h: int):
frame_prepare = make_frame_prepare(nv12, model_w, model_h)
WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT)
def warp(tfm, big_tfm, frame, big_frame):
tfm = tfm.to(WARP_DEV)
big_tfm = big_tfm.to(WARP_DEV)
Tensor.realize(tfm, big_tfm)
warped_frame = frame_prepare(frame, tfm).unsqueeze(0)
warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0)
return Tensor.cat(warped_frame, warped_big_frame)
return warp
def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, frame_skip: int, input_shapes: dict):
def create_jit_runner(vision_runner, policy_runners: list, nv12: NV12Frame, model_size: tuple[int, int],
features_slice: slice, frame_skip: int, input_shapes: dict, prepare_only: bool):
frame_prepare = make_frame_prepare(nv12, *model_size)
sample_skip_fn = partial(sample_skip, frame_skip=frame_skip)
sample_desire_fn = partial(sample_desire, frame_skip=frame_skip)
@@ -180,20 +172,26 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice,
is_supercombo = vision_runner is None
npy_shapes, npy_sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo)
def run_policy(warped, img_q, big_img_q, feat_q, packed_npy_inputs, **kwargs):
def runner(img_q, big_img_q, feat_q, packed_npy_inputs, frame, big_frame, tfm, big_tfm, **kwargs):
desire_q = kwargs['desire_q']
packed_npy_inputs_dev = packed_npy_inputs.to(Device.DEFAULT)
warped_dev = warped.to(Device.DEFAULT)
Tensor.realize(packed_npy_inputs_dev, warped_dev)
img = shift_and_sample(img_q, warped_dev[0:1], sample_skip_fn)
big_img = shift_and_sample(big_img_q, warped_dev[1:2], sample_skip_fn)
packed_npy_inputs_dev = packed_npy_inputs.to(Device.DEFAULT)
tfm_dev = tfm.to(Device.DEFAULT)
big_tfm_dev = big_tfm.to(Device.DEFAULT)
Tensor.realize(packed_npy_inputs_dev, tfm_dev, big_tfm_dev)
img = shift_and_sample(img_q, frame_prepare(frame, tfm_dev).unsqueeze(0), sample_skip_fn).realize()
big_img = shift_and_sample(big_img_q, frame_prepare(big_frame, big_tfm_dev).unsqueeze(0), sample_skip_fn).realize()
if prepare_only:
return img, big_img
unpacked_tensors = [tensor.reshape(shape) for tensor, shape in zip(packed_npy_inputs_dev.split(npy_sizes), npy_shapes.values(), strict=True)]
unpacked_dict = dict(zip(npy_shapes.keys(), unpacked_tensors, strict=True))
desire_dev = unpacked_dict['desire']
desire_buf = shift_and_sample(desire_q, desire_dev.reshape(1, 1, -1), sample_desire_fn)
desire_buf = shift_and_sample(desire_q, desire_dev.reshape(1, 1, -1), sample_desire_fn).realize()
inputs = {desire_key: desire_buf}
for key, tensor_val in unpacked_dict.items():
@@ -202,7 +200,7 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice,
if 'prev_feat' in unpacked_dict:
prev_feat_dev = unpacked_dict['prev_feat']
inputs['features_buffer'] = shift_and_sample(feat_q, prev_feat_dev.reshape(1, 1, -1), sample_skip_fn).reshape(input_shapes['features_buffer'])
inputs['features_buffer'] = shift_and_sample(feat_q, prev_feat_dev.reshape(1, 1, -1), sample_skip_fn).realize()
if vision_runner:
vision_out_cast = next(iter(vision_runner({road_key: img, wide_key: big_img}).values())).cast('float32').realize()
@@ -214,7 +212,7 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice,
inputs.update({road_key: img, wide_key: big_img})
if 'features_buffer' not in inputs:
inputs['features_buffer'] = sample_skip_fn(feat_q).reshape(input_shapes['features_buffer'])
inputs['features_buffer'] = sample_skip_fn(feat_q)
policy_out = next(iter(policy_runners[0](inputs).values())).cast('float32').realize()
if 'features_buffer' not in inputs and features_slice is not None:
@@ -222,52 +220,42 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice,
shift_and_sample(feat_q, new_feat, sample_skip_fn).realize()
return policy_out
return run_policy
return runner
def compile_jit(jit, make_random_inputs, input_keys, make_queues):
SEED = 42
def random_inputs_run(fn, seed, test_val=None, test_buffers=None, expect_match=True):
input_queues, npy = make_queues(Device.DEFAULT)
rng = np.random.default_rng(seed)
Tensor.manual_seed(seed)
def compile_and_warmup(nv12: NV12Frame, model_size: tuple[int, int], prepare_only: bool, frame_skip: int, vision_runner, policy_runners: list, metadata: dict):
print(f"Compiling combined JIT for {nv12.width}x{nv12.height} (prepare_only={prepare_only})...")
testing = test_val is not None or test_buffers is not None
n_runs = 1 if testing else 3
all_shapes = {key: value for meta in metadata.values() for key, value in meta['input_shapes'].items()}
for i in range(n_runs):
for v in npy.values():
v[:] = rng.standard_normal(v.shape).astype(v.dtype)
Device.default.synchronize()
random_inputs = make_random_inputs()
st = time.perf_counter()
outs = fn(**{k: input_queues[k] for k in input_keys if k in input_queues}, **random_inputs)
mt = time.perf_counter()
Device.default.synchronize()
et = time.perf_counter()
print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms")
feat_meta = metadata.get('vision') or metadata.get('model') or metadata.get('policy')
if not feat_meta:
raise ValueError("Could not find vision, model, or policy metadata.")
if i == 0:
val = [np.copy(v.numpy()) for v in (outs if isinstance(outs, tuple) else [outs])] if outs is not None else []
buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()]
features_slice = feat_meta['output_slices']['hidden_state']
WARP_DEV = 'CPU' if "USBGPU" in os.environ else Device.DEFAULT
if test_val is not None:
match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True))
assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})"
if test_buffers is not None:
match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True))
assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})"
return val, buffers
is_supercombo = vision_runner is None
run_func = create_jit_runner(vision_runner, policy_runners, nv12, model_size, features_slice, frame_skip, all_shapes, prepare_only)
run_jit = TinyJit(run_func, prune=True)
queues, npy_arrays = generate_queues_and_npy(all_shapes, frame_skip, Device.DEFAULT, is_supercombo=is_supercombo)
print('capture + replay')
test_val, test_buffers = random_inputs_run(jit, SEED)
print('pickle round trip')
with tempfile.TemporaryFile(dir=".") as f:
dump_oob(jit, f)
f.seek(0)
deserialized_jit = load_oob(f)
random_inputs_run(deserialized_jit, SEED, test_val=test_val, test_buffers=test_buffers)
return deserialized_jit
for i in range(3):
rng = np.random.default_rng(42 + i)
frame = Tensor.randint(nv12.size, low=0, high=256, dtype=dtypes.uint8, device=WARP_DEV).realize()
big_frame = Tensor.randint(nv12.size, low=0, high=256, dtype=dtypes.uint8, device=WARP_DEV).realize()
for arr in npy_arrays.values():
arr[:] = rng.standard_normal(arr.shape).astype(arr.dtype)
Device.default.synchronize()
start_time = time.perf_counter()
run_jit(**queues, frame=frame, big_frame=big_frame)
mid_time = time.perf_counter()
Device.default.synchronize()
print(f" [{i + 1}/3] enqueue {(mid_time - start_time) * 1e3:6.2f} ms -- total {(time.perf_counter() - start_time) * 1e3:6.2f} ms")
# TODO-SP: switch to dump_oob/load_oob on next full recompile of all models
return pickle.loads(pickle.dumps(run_jit)) if not prepare_only else run_jit
def _parse_size(size_str: str) -> tuple[int, int]:
@@ -275,17 +263,31 @@ def _parse_size(size_str: str) -> tuple[int, int]:
return int(width), int(height)
def read_file_chunked_to_disk(path):
def read_file_chunked_to_shm(path):
if not path:
return None
import atexit
import shutil
from openpilot.common.file_chunker import open_file_chunked
tmp_path = f'{path}.unchunked'
with open(tmp_path, 'wb') as f, open_file_chunked(path) as src:
shutil.copyfileobj(src, f)
atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path))
return tmp_path
from openpilot.common.hardware.hw import Paths
shm_path = os.path.join(Paths.shm_path(), os.path.basename(path))
atexit.register(lambda: os.path.exists(shm_path) and os.remove(shm_path))
with open(shm_path, 'wb') as dst, open_file_chunked(path) as src:
shutil.copyfileobj(src, dst)
return shm_path
def _compile_for_resolutions(camera_resolutions: list, model_size: tuple[int, int], frame_skip: int,
vision_runner, policy_runners: list, metadata: dict) -> dict:
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
return {
(cam_w, cam_h): {
name: compile_and_warmup(NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)), model_size, prepare_only,
frame_skip, vision_runner, policy_runners, metadata)
for name, prepare_only in [('warp_enqueue', True), ('run_policy', False)]
}
for cam_w, cam_h in camera_resolutions
}
def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]:
@@ -298,18 +300,7 @@ def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]:
if __name__ == "__main__":
if 'USB' in os.getenv('DEV', '') or os.getenv('USBGPU'):
from openpilot.system.hardware.chestnut.flash import link_up
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
raise RuntimeError("Chestnut not ready, skipping big model build")
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from tinygrad.nn.onnx import OnnxRunner
parser = argparse.ArgumentParser(description="Compile combined JIT pkl for sunnypilot modeld_v2")
@@ -326,14 +317,13 @@ if __name__ == "__main__":
parser.add_argument('--supercombo-onnx', help='supercombo ONNX (for supercombo)')
args = parser.parse_args()
model_w, model_h = args.model_size
output_data = {}
output_data = defaultdict(dict)
args.vision_onnx = read_file_chunked_to_disk(args.vision_onnx)
args.policy_onnx = read_file_chunked_to_disk(args.policy_onnx)
args.off_policy_onnx = read_file_chunked_to_disk(args.off_policy_onnx)
args.on_policy_onnx = read_file_chunked_to_disk(args.on_policy_onnx)
args.supercombo_onnx = read_file_chunked_to_disk(args.supercombo_onnx)
args.vision_onnx = read_file_chunked_to_shm(args.vision_onnx)
args.policy_onnx = read_file_chunked_to_shm(args.policy_onnx)
args.off_policy_onnx = read_file_chunked_to_shm(args.off_policy_onnx)
args.on_policy_onnx = read_file_chunked_to_shm(args.on_policy_onnx)
args.supercombo_onnx = read_file_chunked_to_shm(args.supercombo_onnx)
vision_runner = OnnxRunner(args.vision_onnx) if args.vision_onnx else None
@@ -358,31 +348,17 @@ if __name__ == "__main__":
vision_meta = output_data['metadata'].get('vision', {})
derived_frame_skip = args.frame_skip or derive_frame_skip(vision_meta.get('input_shapes', {}), first_policy_meta.get('input_shapes', {}))
all_shapes = {key: value for meta in output_data['metadata'].values() for key, value in meta['input_shapes'].items()}
feat_meta = output_data['metadata'].get('vision') or output_data['metadata'].get('model') or output_data['metadata'].get('policy')
assert feat_meta is not None
features_slice = feat_meta['output_slices']['hidden_state']
is_supercombo = vision_runner is None
print(f"Compiling run_policy JIT (model_size={model_w}x{model_h}, frame_skip={derived_frame_skip})...")
run_policy_func = make_run_policy(vision_runner, policy_runners, features_slice, derived_frame_skip, all_shapes)
run_policy_jit = TinyJit(run_policy_func, prune=True)
make_policy_queues = partial(generate_queues_and_npy, all_shapes, derived_frame_skip, is_supercombo=is_supercombo)
make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=WARP_DEV)
output_data['run_policy'] = compile_jit(run_policy_jit, make_random_model_inputs, POLICY_INPUTS, make_policy_queues)
for cam_w, cam_h in args.camera_resolutions:
print(f"Compiling warp JIT for {cam_w}x{cam_h}...")
nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h))
make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV)
warp = TinyJit(make_warp(nv12, model_w, model_h), prune=True)
output_data[(cam_w, cam_h)] = compile_jit(warp, make_random_warp_inputs, WARP_INPUTS, make_warp_queues)
output_data.update(_compile_for_resolutions(args.camera_resolutions, args.model_size, derived_frame_skip,
vision_runner, policy_runners, output_data['metadata']))
with open(args.output, "wb") as file:
dump_oob(output_data, file)
# TODO-SP: switch to dump_oob from openpilot/selfdrive/helpers on next full recompile of all models
pickle.dump(output_data, file)
pkl_size = os.path.getsize(args.output)
print(f"Saved combined JIT to {args.output} ({pkl_size / 1e6:.2f} MB)")
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
chunk_targets = get_chunk_targets(args.output, pkl_size)
chunk_file(args.output, chunk_targets)
print(f"Chunked into {len(chunk_targets) - 1} file(s)")
+53 -102
View File
@@ -9,13 +9,17 @@ See the LICENSE.md file in the root directory for more details.
import os
os.environ['GMMU'] = '0'
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.selfdrive.modeld.helpers import usbgpu_present, load_oob
os.environ['DEV'] = 'QCOM' if COMMA_HARDWARE else 'CPU'
USBGPU = "USBGPU" in os.environ
if USBGPU:
os.environ['DEV'] = 'AMD'
os.environ['AMD_IFACE'] = 'USB'
import pickle
import time
import numpy as np
import openpilot.cereal.messaging as messaging
from openpilot.cereal import log
from opendbc.car.structs import car
from openpilot.cereal.services import SERVICE_LIST
from setproctitle import setproctitle
from openpilot.cereal.messaging import PubMaster, SubMaster
from openpilot.cereal.visionipc import VisionStreamType
@@ -23,6 +27,7 @@ from msgq.visionipc import VisionIpcClient, VisionBuf
from opendbc.car.car_helpers import get_demo_car_params
from tinygrad.tensor import Tensor
from tinygrad.device import Device
from openpilot.common.file_chunker import open_file_chunked
from openpilot.common.swaglog import cloudlog
@@ -35,13 +40,12 @@ from openpilot.system import sentry
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, smooth_value
from openpilot.selfdrive.modeld.modeld import ChestnutState
from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState, get_curvature_from_output
from openpilot.sunnypilot.modeld_v2.constants import Plan
from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues, make_supercombo_input_queues, WARP_INPUTS, POLICY_INPUTS
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
@@ -84,14 +88,14 @@ class ModelState(ModelStateBase):
inputs: dict[str, np.ndarray]
prev_desire: np.ndarray
def __init__(self, cam_w: int, cam_h: int, usbgpu: bool = False):
def __init__(self, cam_w: int, cam_h: int):
ModelStateBase.__init__(self)
env_pkl = os.environ.get('COMBINED_MODEL_PKL')
if env_pkl and os.path.exists(env_pkl):
model_bundle = None
else:
model_bundle = get_active_bundle(usbgpu=usbgpu)
model_bundle = get_active_bundle()
self.generation = model_bundle.generation if model_bundle is not None else None
overrides = {override.key: override.value for override in model_bundle.overrides} if model_bundle else {}
@@ -99,7 +103,6 @@ class ModelState(ModelStateBase):
self.LONG_SMOOTH_SECONDS = float(overrides.get('long', ".0"))
self.MIN_LAT_CONTROL_SPEED = 0.3
self.PLANPLUS_CONTROL: float = 1.0
self.usbgpu = usbgpu
pkl_path = _find_driving_pkl(model_bundle)
assert pkl_path is not None, "No driving pkl found — all models must be compiled with compile_modeld.py"
@@ -107,20 +110,24 @@ class ModelState(ModelStateBase):
def _init_combined(self, pkl_path, cam_w, cam_h, bundle):
cloudlog.warning(f"loading combined pkl: {pkl_path}")
jits = load_oob(open_file_chunked(pkl_path))
# TODO-SP: switch to load_oob from openpilot/selfdrive/helpers on next full recompile of all models
jits = pickle.load(open_file_chunked(pkl_path))
self.WARP_DEV = 'QCOM' if COMMA_HARDWARE else 'CPU'
self.DEV = 'AMD' if self.usbgpu else self.WARP_DEV
self.DEV = Device.DEFAULT
self.WARP_DEV = 'CPU' if USBGPU else self.DEV
self.QUEUE_DEV = self.DEV
metadata = jits['metadata']
self.is_legacy_model = 'run_policy' not in jits # remove after next recompile
if self.is_legacy_model:
self.warp = jits[(cam_w, cam_h)]['warp_enqueue']
self.run_policy = jits[(cam_w, cam_h)]['run_policy']
self._run_policy = jits[(cam_w, cam_h)]['run_policy']
self._warp_enqueue = jits[(cam_w, cam_h)]['warp_enqueue']
# TODO-SP: Remove legacy use_packed detection block after all models are recompiled
captured = getattr(self._run_policy, 'captured', None)
if captured is not None:
use_packed = 'packed_npy_inputs' in getattr(captured, 'expected_names', [])
else:
self.run_policy = jits['run_policy']
self.warp = jits[(cam_w, cam_h)]
use_packed = True
if 'model' in metadata:
model_metadata = metadata['model']
@@ -129,9 +136,10 @@ class ModelState(ModelStateBase):
self._policy_slices_list = []
self._combined_model_type = 'supercombo'
self._vision_input_names = [key for key in model_metadata['input_shapes'] if 'img' in key]
from openpilot.sunnypilot.modeld_v2.compile_modeld import make_supercombo_input_queues
frame_skip = derive_frame_skip({}, model_metadata['input_shapes'])
self.input_queues, self.numpy_inputs = make_supercombo_input_queues(model_metadata['input_shapes'],
frame_skip, device=self.QUEUE_DEV)
frame_skip, device=self.QUEUE_DEV, use_packed=use_packed)
else:
vision_metadata = metadata['vision']
policy_keys = [k for k in metadata if k != 'vision']
@@ -144,12 +152,13 @@ class ModelState(ModelStateBase):
self._policy_slices_list = [metadata[k]['output_slices'] for k in policy_keys]
self.policy_output_slices = self._policy_slices_list[0]
self._has_on_policy = any('on' in k.lower() for k in policy_keys)
self._vision_input_names = [key for key in vision_metadata['input_shapes'] if 'img' in key]
first_policy_meta = metadata[policy_keys[0]]
frame_skip = derive_frame_skip(vision_metadata['input_shapes'], first_policy_meta['input_shapes'])
self.input_queues, self.numpy_inputs = make_split_input_queues(vision_metadata['input_shapes'],
first_policy_meta['input_shapes'],
frame_skip, device=self.QUEUE_DEV)
first_policy_metadata = metadata[policy_keys[0]]
vision_input_shapes = vision_metadata['input_shapes']
policy_input_shapes = first_policy_metadata['input_shapes']
self._vision_input_names = [k for k in vision_input_shapes if 'img' in k]
frame_skip = derive_frame_skip(vision_input_shapes, policy_input_shapes)
self.input_queues, self.numpy_inputs = make_split_input_queues(vision_input_shapes, policy_input_shapes,
frame_skip, device=self.QUEUE_DEV, use_packed=use_packed)
self._desire_key = next(key for key in self.numpy_inputs if key.startswith('desire'))
self._road_key = next(key for key in self._vision_input_names if 'big' not in key)
@@ -177,33 +186,10 @@ class ModelState(ModelStateBase):
self.frame_buf_params = dict.fromkeys(self._vision_input_names, nv12_info)
yuv_size = self.frame_buf_params[self._road_key][3]
frame_tensor = Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize()
big_frame_tensor = Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize()
if self.is_legacy_model: # Remove this conditional hack after recompile
self.warp(**self.input_queues, frame=frame_tensor, big_frame=big_frame_tensor)
else:
self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=frame_tensor, big_frame=big_frame_tensor)
if self.usbgpu:
self.warmup()
def warmup(self) -> None:
dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self._vision_input_names}
transforms = {k: np.eye(3, dtype=np.float32) for k in [self._road_key, self._wide_key] if k}
dummy_inputs = {}
for k, v in self.numpy_inputs.items():
if k not in ['tfm', 'big_tfm', 'prev_feat']:
dummy_inputs[k] = np.zeros(v.shape, dtype=v.dtype)
self.run(dummy_frames, transforms, dummy_inputs, prepare_only=False)
for v in self.numpy_inputs.values():
v[:] = 0
self.prev_desire[:] = 0
self.full_frames.clear()
self._blob_cache.clear()
self._warp_enqueue(
**self.input_queues,
frame=Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize(),
big_frame=Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize())
@property
@@ -241,17 +227,11 @@ class ModelState(ModelStateBase):
self.numpy_inputs['tfm'][:, :] = transforms[road_key].reshape(3, 3)
self.numpy_inputs['big_tfm'][:, :] = transforms[wide_key].reshape(3, 3)
if self.is_legacy_model: # remove after next recompile
if prepare_only:
self.warp(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key])
return None
raw_outputs = self.run_policy(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key])
else:
if prepare_only:
self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key])
return None
warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key])
raw_outputs = self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped)
if prepare_only:
self._warp_enqueue(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key])
return None
raw_outputs = self._run_policy(**self.input_queues, frame=self.full_frames[road_key], big_frame=self.full_frames[wide_key])
if self._combined_model_type == 'supercombo':
model_output = raw_outputs.numpy().flatten()
@@ -287,9 +267,10 @@ class ModelState(ModelStateBase):
buf[0, :-1] = buf[0, 1:]
buf[0, -1, :] = outputs['desired_curvature'][0, :] if not self.mlsim else 0
if self.usbgpu and not np.all(np.isfinite(outputs.get('plan', np.array([0.])))):
cloudlog.error("model output not finite, dropping frame")
return None
# TODO-SP: This is a hack to prevent GPU corruption by calculating in CPU space, it can be removed on next recompile
if 'prev_feat' not in self.numpy_inputs and 'feat_q' in self.input_queues:
feat_val = self.input_queues['feat_q'].numpy()
self.input_queues['feat_q'].assign(feat_val).realize()
return outputs
@@ -297,8 +278,8 @@ class ModelState(ModelStateBase):
lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action:
if 'action' not in model_output:
plan = model_output['plan'][0]
desired_accel = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS,
action_t=long_action_t)
desired_accel, should_stop = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS,
action_t=long_action_t)
curvature_plan = (plan + (self.PLANPLUS_CONTROL - 1.0) * model_output['planplus'][0]
if 'planplus' in model_output and self.PLANPLUS_CONTROL != 1.0 else plan)
@@ -306,8 +287,8 @@ class ModelState(ModelStateBase):
else:
desired_accel = model_output['action'][0, 1]
desired_curvature = model_output['action'][0, 0] / (max(1.0, v_ego))**2
should_stop = (v_ego < 0.3 and desired_accel < 0.1)
stop = v_ego < 0.3 and desired_accel < 0.1
desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS)
if self.generation is not None and self.generation >= 10: # smooth curvature for post FOF models
@@ -316,7 +297,7 @@ class ModelState(ModelStateBase):
else:
desired_curvature = prev_action.desiredCurvature
return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature), desiredAcceleration=float(desired_accel), shouldStop=bool(stop))
return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature),desiredAcceleration=float(desired_accel), shouldStop=bool(should_stop))
def main(demo=False):
@@ -327,14 +308,6 @@ def main(demo=False):
setproctitle(PROCESS_NAME)
config_realtime_process(7, 54)
USBGPU = usbgpu_present()
if USBGPU:
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
params = Params()
params.put_bool("UsbGpuLoading", USBGPU)
params.remove("UsbGpuActive")
# visionipc clients
while True:
available_streams = VisionIpcClient.available_streams("camerad", block=False)
@@ -359,34 +332,15 @@ def main(demo=False):
cloudlog.warning(f"connected extra cam with buffer size: {vipc_client_extra.buffer_len} ({vipc_client_extra.width} x {vipc_client_extra.height})")
cloudlog.warning("loading model")
st = time.monotonic()
model = None
if USBGPU:
import threading
def load():
nonlocal model
model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, usbgpu=True)
t = threading.Thread(target=load, daemon=True)
t.start()
t.join(60)
if model is None:
params.put_bool("UsbGpuActive", False)
raise RuntimeError("eGPU model load failed or timed out (60s)")
params.put_bool("UsbGpuActive", True)
else:
model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, usbgpu=False)
params.put_bool("UsbGpuLoading", False)
cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting")
model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height)
cloudlog.warning("models loaded, modeld starting")
# messaging
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if USBGPU else [])
pm = PubMaster(pub_socks)
pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"])
sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"])
publish_state = PublishState()
chestnut_state = ChestnutState(pm, USBGPU) if USBGPU else None
params = Params()
# setup filter to track dropped frames
frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ)
@@ -524,7 +478,6 @@ def main(demo=False):
fill_model_msg(drivingdata_send, modelv2_send, model_output, action,
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, live_calib_seen, meta_constants)
modelv2_send.modelV2.big = model.usbgpu
desire_state = modelv2_send.modelV2.meta.desireState
l_lane_change_prob = desire_state[log.Desire.laneChangeLeft]
@@ -545,8 +498,6 @@ def main(demo=False):
pm.send('modelDataV2SP', mdv2sp_send)
last_vipc_frame_id = meta_main.frame_id
if chestnut_state is not None and run_count % round(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0:
chestnut_state.send()
if __name__ == "__main__":
try:
@@ -6,6 +6,7 @@ See the LICENSE.md file in the root directory for more details.
"""
import pathlib
import pickle
import tempfile
import openpilot.sunnypilot.models.helpers as helpers
@@ -163,16 +164,14 @@ ARCHETYPES = {
def make_pkl_data(archetype):
return {
'metadata': archetype.metadata_structure,
'run_policy': _noop_jit,
(CAM_W, CAM_H): _noop_jit,
(CAM_W, CAM_H): {'run_policy': _noop_jit, 'warp_enqueue': _noop_jit},
}
def write_pkl(tmp_path, archetype):
from openpilot.selfdrive.modeld.helpers import dump_oob
pkl_path = tmp_path / 'driving_test_tinygrad.pkl'
with open(pkl_path, 'wb') as f:
dump_oob(make_pkl_data(archetype), f)
pickle.dump(make_pkl_data(archetype), f)
return pkl_path
@@ -190,8 +189,8 @@ def tmp_path():
def patch_modeld(monkeypatch):
def _patch(bundle):
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle)
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle)
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle)
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle)
return _patch
@@ -59,8 +59,8 @@ class TestFindDrivingPkl(OpenpilotTestCase):
class TestModelStateCombinedInit(OpenpilotTestCase):
def test_asserts_when_no_pkl(self, monkeypatch):
bundle = DummyBundle(models=[], is_20hz=True)
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle)
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle)
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle)
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle)
with self.assertRaisesRegex(AssertionError, "No driving pkl found"):
ModelState(cam_w=CAM_W, cam_h=CAM_H)
@@ -5,15 +5,10 @@ This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import os
import tempfile
from pathlib import Path
import numpy as np
from openpilot.common.parameterized import parameterized
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key, read_file_chunked_to_disk
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key
from openpilot.common.test import OpenpilotTestCase
@@ -165,115 +160,3 @@ class TestOutputSlicePreservation(OpenpilotTestCase):
policy_slices = {'plan': slice(0, 495), 'meta': slice(495, 550)}
assert set(vision_slices.keys()) & set(policy_slices.keys()) == set(), \
"vision and policy slices should not overlap in keys"
class TestReadFileChunkedToDisk(OpenpilotTestCase):
def test_none_passthrough(self):
assert read_file_chunked_to_disk(None) is None
def test_unchunked_source_staged_on_disk(self):
with tempfile.TemporaryDirectory() as d:
src = Path(d) / "driving_supercombo.onnx"
payload = os.urandom(1024)
src.write_bytes(payload)
out = Path(read_file_chunked_to_disk(str(src)))
assert out.parent == Path(d)
assert out.name == "driving_supercombo.onnx.unchunked"
assert out.read_bytes() == payload
def test_chunked_source_reassembled_on_disk(self):
with tempfile.TemporaryDirectory() as d:
src = Path(d) / "driving_supercombo.onnx"
payload = os.urandom(4096)
src.write_bytes(payload)
chunk_file(str(src), get_chunk_targets(str(src), len(payload)))
assert not src.exists()
out = Path(read_file_chunked_to_disk(str(src)))
assert out.parent == Path(d)
assert out.read_bytes() == payload
class Test4DFeaturesBuffer(OpenpilotTestCase):
def test_get_policy_npy_shapes_4d(self):
from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes
input_shapes = {
'desire_pulse': (1, 25, 8),
'features_buffer': (1, 24, 32, 512), # compare 4d to 3d for regression
'traffic_convention': (1, 2),
'action_t': (1, 2)
}
shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=True)
assert shapes['prev_feat'] == (1, 16384)
assert sizes == [8, 2, 2, 16384]
def test_get_policy_npy_shapes_3d(self):
from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes
input_shapes = {
'desire_pulse': (1, 25, 8),
'features_buffer': (1, 24, 512),
'traffic_convention': (1, 2),
'action_t': (1, 2)
}
shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=True)
assert shapes['prev_feat'] == (1, 512)
assert sizes == [8, 2, 2, 512]
class TestStockCompileModeldEquivalence(OpenpilotTestCase):
def test_get_policy_npy_shapes_matches_stock(self):
from openpilot.selfdrive.modeld.compile_modeld import get_policy_npy_shapes as stock_get_policy_npy_shapes
from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes as sunny_get_policy_npy_shapes
stock_input_shapes = {
'desire_pulse': (1, 25, 8),
'features_buffer': (1, 24, 512), # see below comment
'traffic_convention': (1, 2),
'action_t': (1, 2),
}
stock_shapes, stock_sizes = stock_get_policy_npy_shapes(stock_input_shapes)
sunny_shapes, sunny_sizes = sunny_get_policy_npy_shapes(stock_input_shapes, is_supercombo=True)
assert sunny_shapes == stock_shapes
assert sunny_sizes == stock_sizes
assert sunny_shapes['prev_feat'] == (1, 512)
def test_make_input_queues_full_stock_equivalence(self):
from openpilot.selfdrive.modeld.compile_modeld import make_input_queues as stock_make_input_queues
from openpilot.sunnypilot.modeld_v2.compile_modeld import make_supercombo_input_queues as sunny_make_supercombo_input_queues
input_shapes = {
'img': (1, 12, 128, 256),
'desire_pulse': (1, 25, 8),
'features_buffer': (1, 24, 512), # when https://github.com/commaai/openpilot/pull/38681 merges, update to 1,24,32,512
'traffic_convention': (1, 2),
'action_t': (1, 2),
}
frame_skip = 4
stock_queues, stock_npy = stock_make_input_queues(input_shapes, frame_skip, device='NPY')
sunny_queues, sunny_npy = sunny_make_supercombo_input_queues(input_shapes, frame_skip, device='NPY')
assert set(sunny_queues.keys()) == set(stock_queues.keys())
for key in stock_queues:
assert sunny_queues[key].shape == stock_queues[key].shape, \
f"Queue shape mismatch for {key}: sunny {sunny_queues[key].shape} != stock {stock_queues[key].shape}"
assert set(sunny_npy.keys()) == set(stock_npy.keys())
for key in stock_npy:
assert sunny_npy[key].shape == stock_npy[key].shape, \
f"Numpy array shape mismatch for {key}: sunny {sunny_npy[key].shape} != stock {stock_npy[key].shape}"
def test_make_warp_queues_stock_equivalence(self):
from openpilot.selfdrive.modeld.compile_modeld import make_warp_input_queues as stock_make_warp_queues
from openpilot.sunnypilot.modeld_v2.compile_modeld import make_warp_queues as sunny_make_warp_queues
stock_vision_shapes = {'img': (1, 12, 128, 256)} # for now?
stock_queues, stock_npy = stock_make_warp_queues(stock_vision_shapes, frame_skip=4, device='NPY')
sunny_queues, sunny_npy = sunny_make_warp_queues(device='NPY')
assert set(sunny_npy.keys()) == set(stock_npy.keys()) == {'tfm', 'big_tfm'}
for key in sunny_npy:
assert sunny_npy[key].shape == stock_npy[key].shape == (3, 3)
@@ -33,7 +33,7 @@ class TestRecoveryPower(OpenpilotTestCase):
def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0):
recorded_vel.append(plan_vel.copy())
return 0.0
return 0.0, False
def mock_curvature(output, plan, vego, lat_action_t, mlsim):
recorded_curv_plans.append(plan.copy())
+30 -26
View File
@@ -3,17 +3,8 @@ import os
import hashlib
from openpilot.common.basedir import BASEDIR
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot import get_file_hash
from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL
def get_default_model() -> str:
show_big_model = (ui_state.usbgpu
and (ui_state.usbgpu_active or ui_state.usbgpu_loading or ui_state.is_offroad()))
return DEFAULT_BIG_MODEL if show_big_model else DEFAULT_MODEL
from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL
DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "model_name.py")
MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "model_hash")
@@ -22,6 +13,7 @@ SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld",
def update_model_hash():
supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH)
combined_hash = hashlib.sha256(supercombo_hash.encode()).hexdigest()
with open(MODEL_HASH_PATH, "w") as f:
@@ -30,28 +22,40 @@ def update_model_hash():
print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}")
def update_default_model_names(default_model_name: str, default_big_model_name: str):
print("[CHANGE DEFAULT MODEL NAMES]")
with open(DEFAULT_MODEL_NAME_PATH, "w") as f:
f.write(f'DEFAULT_MODEL = "{default_model_name}"\n')
f.write(f'DEFAULT_BIG_MODEL = "{default_big_model_name}"\n')
def get_current_default_model_name():
print("[GET DEFAULT MODEL NAME]")
name = DEFAULT_MODEL
print(f'Current default model name: "{name}"')
print(f'New default small model name: "{default_model_name}"')
print(f'New default big model name: "{default_big_model_name}"')
return name
def update_default_model_name(name: str):
print("[CHANGE DEFAULT MODEL NAME]")
with open(DEFAULT_MODEL_NAME_PATH, "w") as f:
f.write(f'DEFAULT_MODEL = "{name}"\n')
print(f'New default model name: "{name}"')
print("[DONE]")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Update default model names and hash")
parser.add_argument("--new_small_model_name", type=str, help="New default small model name")
parser.add_argument("--new_big_model_name", type=str, help="New default big model name")
parser = argparse.ArgumentParser(description="Update default model name and hash")
parser.add_argument("--new_name", type=str, help="New default model name")
args = parser.parse_args()
if args.new_small_model_name is None and args.new_big_model_name is None:
new_name = input(f'Enter new default small model name (current: "{DEFAULT_MODEL}", leave empty to keep): ').strip()
new_big_model_name = input(f'Enter new default big model name (current: "{DEFAULT_BIG_MODEL}", leave empty to keep): ').strip()
else:
new_name, new_big_model_name = args.new_small_model_name, args.new_big_model_name
if not args.new_name:
print("Warning: No new default model name provided. Use --new_name to specify")
print("Default model name and hash will not be updated! (aborted)")
exit(0)
update_default_model_names(new_name or DEFAULT_MODEL, new_big_model_name or DEFAULT_BIG_MODEL)
current_name = get_current_default_model_name()
new_name = args.new_name
if current_name == new_name:
print(f'Proposed default model name: "{new_name}"')
confirm = input("Proposed default model name is the same as the current default model name. Confirm? (y/n): ").upper().strip()
if confirm != "Y":
print("Default model name and hash will not be updated! (aborted)")
exit(0)
update_default_model_name(new_name)
update_model_hash()
+24 -87
View File
@@ -13,6 +13,7 @@ from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware.hw import Paths
from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible
from openpilot.cereal import custom
@@ -102,11 +103,11 @@ class ModelParser:
class ModelCache:
"""Handles caching of model data to avoid frequent remote fetches"""
def __init__(self, params: Params, cache_timeout: int = int(3600 * 1e9), suffix: str = ""):
def __init__(self, params: Params, cache_timeout: int = int(3600 * 1e9)):
self.params = params
self.cache_timeout = cache_timeout
self._LAST_SYNC_KEY = f"ModelManager_LastSyncTime{suffix}"
self._CACHE_KEY = f"ModelManager_ModelsCache{suffix}"
self._LAST_SYNC_KEY = "ModelManager_LastSyncTime"
self._CACHE_KEY = "ModelManager_ModelsCache"
def _is_expired(self) -> bool:
"""Checks if the cache has expired"""
@@ -138,53 +139,32 @@ class ModelCache:
class ModelFetcher:
"""Handles fetching and caching of model data from remote source"""
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v21.json"
MODEL_URL_USBGPU = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_usbgpu_v22.json"
MODEL_SOURCES = {
"qcom": (MODEL_URL, ""),
"usbgpu": (MODEL_URL_USBGPU, "_USBGPU"),
}
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v18.json"
def __init__(self, params: Params):
self.params = params
self.model_cache = ModelCache(params)
self.model_parser = ModelParser()
self.model_caches = {
source: ModelCache(params, suffix=suffix)
for source, (_, suffix) in self.MODEL_SOURCES.items()
}
self._refetched: set[str] = set()
self.params.put("ModelManager_ActiveJson", {
"qcom": self.MODEL_URL,
"usbgpu": self.MODEL_URL_USBGPU,
}, block=True)
@staticmethod
def active_source(chestnut_present: bool) -> str:
return "usbgpu" if chestnut_present else "qcom"
def _fetch_and_cache_models(self, source: str) -> list[custom.ModelManagerSP.ModelBundle] | None:
def _fetch_and_cache_models(self) -> list[custom.ModelManagerSP.ModelBundle] | None:
"""Fetches fresh model data from remote and updates cache.
Returns None on transport errors. Raises on 404 and other fatal HTTP errors.
"""
model_url, _ = self.MODEL_SOURCES[source]
try:
response = requests.get(model_url, timeout=10)
response = requests.get(self.MODEL_URL, timeout=10)
# Explicitly handle 404 differently
if response.status_code == 404:
cloudlog.error(f"Models URL returned 404 Not Found: {model_url}")
raise HTTPError(f"404 Not Found: {model_url}", response=response)
cloudlog.error(f"Models URL returned 404 Not Found: {self.MODEL_URL}")
raise HTTPError(f"404 Not Found: {self.MODEL_URL}", response=response)
# Raise for any other 4xx/5xx
response.raise_for_status()
json_data = response.json()
parsed = self.model_parser.parse_models(json_data)
if parsed:
self.model_caches[source].set(json_data)
cloudlog.debug(f"Successfully updated models cache for {source}")
return parsed
self.model_cache.set(json_data)
cloudlog.debug("Successfully updated models cache")
return self.model_parser.parse_models(json_data)
except ConnectionError as e:
cloudlog.warning(f"DNS/connection error while fetching models: {e}")
@@ -197,40 +177,15 @@ class ModelFetcher:
return None
@staticmethod
def _cache_matches_source(source: str, cached_data: dict) -> bool:
bundles = cached_data.get("bundles", [])
if source == "usbgpu":
return any(bundle.get("is_big") is True for bundle in bundles)
return not any(bundle.get("is_big") is True for bundle in bundles)
def get_bundles_for_source(self, source: str) -> list[custom.ModelManagerSP.ModelBundle]:
if source not in self.MODEL_SOURCES:
cloudlog.warning(f"Unknown model source: {source}")
return []
cached_data, is_expired = self.model_caches[source].get()
def get_available_bundles(self) -> list[custom.ModelManagerSP.ModelBundle]:
"""Gets the list of available models, with smart cache handling"""
cached_data, is_expired = self.model_cache.get()
if cached_data and not is_expired:
# a source is refetched over a mismatch at most once per process: if the fresh
# manifest still mismatches, the URL is authoritative and the cache is trusted
if self._cache_matches_source(source, cached_data) or source in self._refetched:
try:
parsed = self.model_parser.parse_models(cached_data)
except Exception:
cloudlog.warning(f"Failed to parse cached models for {source}; refetching", exc_info=True)
else:
if parsed:
cloudlog.debug(f"Using valid cached models data for source {source}")
return parsed
# a source-matching cache that yields no valid bundles is stale (e.g. an old
# manifest version) - do not trust it, refetch so the source is repopulated
cloudlog.warning(f"Cached models for {source} have no valid bundles; refetching")
else:
self._refetched.add(source)
cloudlog.warning(f"Cached models for {source} not valid; refetching once")
cloudlog.debug("Using valid cached models data")
return self.model_parser.parse_models(cached_data)
fetched_bundles = self._fetch_and_cache_models(source)
fetched_bundles = self._fetch_and_cache_models()
if fetched_bundles is not None:
return fetched_bundles
@@ -238,37 +193,19 @@ class ModelFetcher:
cloudlog.warning("Failed to fetch fresh data and no cache available")
cloudlog.warning("Failed to fetch fresh data. Using expired cache as fallback")
try:
return self.model_parser.parse_models(cached_data)
except Exception:
return []
def get_cached_bundles(params: Params, source: str) -> list[custom.ModelManagerSP.ModelBundle]:
if source not in ModelFetcher.MODEL_SOURCES:
cloudlog.warning(f"Unknown model source: {source}")
return []
_, suffix = ModelFetcher.MODEL_SOURCES[source]
cached_data = params.get(f"ModelManager_ModelsCache{suffix}")
if not cached_data:
return []
try:
return ModelParser.parse_models(cached_data)
except Exception as e:
cloudlog.warning(f"Failed to parse cached models for source {source}: {e}")
return []
return self.model_parser.parse_models(cached_data)
if __name__ == "__main__":
from openpilot.selfdrive.modeld.helpers import usbgpu_present
params = Params()
model_fetcher = ModelFetcher(params)
bundles = model_fetcher.get_bundles_for_source(ModelFetcher.active_source(usbgpu_present()))
bundles = model_fetcher.get_available_bundles()
for bundle in bundles:
for model in bundle.models:
model_overrides = {override.key: override.value for override in bundle.overrides}
# Print model details
print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}")
# Print artifact details
print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}")
# Print metadata details
if model.artifact.chunks:
print(f"Contains {len(model.artifact.chunks)} chunks.")
+28 -66
View File
@@ -16,20 +16,14 @@ from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.models.constants import Meta, MetaSimPose, MetaTombRaider
from openpilot.common.hardware.hw import Paths
from openpilot.selfdrive.modeld.helpers import usbgpu_present
# SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO
REQUIRED_JSON_VERSION = 18
REQUIRED_JSON_VERSION = 16
CUSTOM_MODEL_PATH = Paths.model_root()
METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl'
ModelManager = custom.ModelManagerSP
ACTIVE_BUNDLE_KEYS = {
"qcom": "ModelManager_ActiveBundle",
"usbgpu": "ModelManager_ActiveBundleUSBGPU",
}
_LAST_VALIDATED_RAW: dict[str, dict | None] = {}
_LAST_VALIDATED_RAW = None
def _compute_hash(file_path: str) -> str | None:
@@ -92,11 +86,11 @@ def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, availa
if available_bundles is not None:
matching_bundle = None
for bundle in available_bundles:
if active_bundle.ref and bundle.ref:
if getattr(active_bundle, 'ref', None) and getattr(bundle, 'ref', None):
if active_bundle.ref == bundle.ref:
matching_bundle = bundle
break
elif active_bundle.internalName == bundle.internalName:
elif getattr(active_bundle, 'internalName', None) == getattr(bundle, 'internalName', None):
matching_bundle = bundle
break
@@ -104,79 +98,47 @@ def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, availa
return True
if active_bundle.minimumSelectorVersion != matching_bundle.minimumSelectorVersion:
return True
if active_bundle.runner != matching_bundle.runner:
return True
active_runner = getattr(active_bundle, 'runner', None)
matching_runner = getattr(matching_bundle, 'runner', None)
if active_runner is not None and matching_runner is not None:
if getattr(active_runner, 'raw', active_runner) != getattr(matching_runner, 'raw', matching_runner):
return True
if set(_bundle_artifacts(active_bundle)) != set(_bundle_artifacts(matching_bundle)):
return True
return not _bundle_is_valid_locally(active_bundle)
def _parse_active_bundle(raw_bundle) -> "custom.ModelManagerSP.ModelBundle | None":
try:
if isinstance(raw_bundle, dict) and raw_bundle and is_bundle_version_compatible(raw_bundle):
return custom.ModelManagerSP.ModelBundle(**raw_bundle)
except Exception:
pass
return None
def get_selected_bundle(params: Params | None = None, source: str = "qcom") -> "custom.ModelManagerSP.ModelBundle | None":
params = params or Params()
return _parse_active_bundle(params.get(ACTIVE_BUNDLE_KEYS[source]))
def get_active_source(usbgpu: bool | None = None, usbgpu_active: bool | None = None,
usbgpu_loading: bool | None = None, offroad: bool | None = None) -> str:
if usbgpu is None:
usbgpu = usbgpu_present()
state_valid = usbgpu_active is not None or usbgpu_loading is not None or offroad is not None
big_active = usbgpu and (not state_valid or usbgpu_active or usbgpu_loading or offroad)
return "usbgpu" if big_active else "qcom"
def get_active_bundle(params: Params | None = None, *, usbgpu: bool | None = None) -> "custom.ModelManagerSP.ModelBundle | None":
# no cross-slot fallback: an empty active slot means the hardware default, which
# only stock modeld can run - modeld_v2 requires a real bundle
params = params or Params()
return get_selected_bundle(params, get_active_source(usbgpu=usbgpu))
def resolve_bundle_by_ref(
ref: str, source_bundles: dict[str, list[custom.ModelManagerSP.ModelBundle]],
) -> "tuple[custom.ModelManagerSP.ModelBundle, str] | None":
for source, bundles in source_bundles.items():
for bundle in bundles:
if bundle.ref == ref:
return bundle, source
return None
def _validate_active_bundle(params: Params, source: str, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None) -> None:
def validate_active_bundle(params: Params, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None) -> None:
global _LAST_VALIDATED_RAW
key = ACTIVE_BUNDLE_KEYS[source]
raw_bundle = params.get(key)
raw_bundle = params.get("ModelManager_ActiveBundle")
if not raw_bundle:
return
if _LAST_VALIDATED_RAW.get(key) == raw_bundle:
if raw_bundle == _LAST_VALIDATED_RAW:
return
active_bundle = _parse_active_bundle(raw_bundle)
active_bundle = get_active_bundle(params, raw_bundle_dict=raw_bundle)
if active_bundle is None or _bundle_needs_reset(active_bundle, available_bundles):
cloudlog.warning(f"Active model bundle invalid for {source}; resetting to default")
params.remove(key)
_LAST_VALIDATED_RAW[key] = None
cloudlog.warning("Active model bundle invalid; resetting to default")
params.remove("ModelManager_ActiveBundle")
params.put("ModelRunnerTypeCache", int(custom.ModelManagerSP.Runner.stock), block=True)
_LAST_VALIDATED_RAW = None
else:
_LAST_VALIDATED_RAW[key] = raw_bundle
_LAST_VALIDATED_RAW = raw_bundle
def validate_active_bundles(params: Params, source_bundles: dict[str, list[custom.ModelManagerSP.ModelBundle]]) -> None:
# an empty list means the fetch failed, not that the catalog dropped the bundle
for source, bundles in source_bundles.items():
_validate_active_bundle(params, source, bundles or None)
get_active_model_runner(params, force_check=True)
def get_active_bundle(params: Params | None = None, raw_bundle_dict: dict | bytes | None = None) -> "custom.ModelManagerSP.ModelBundle | None":
params = params or Params()
try:
active_bundle_dict = raw_bundle_dict if raw_bundle_dict is not None else (params.get("ModelManager_ActiveBundle") or {})
if isinstance(active_bundle_dict, dict) and active_bundle_dict and is_bundle_version_compatible(active_bundle_dict):
return custom.ModelManagerSP.ModelBundle(**active_bundle_dict)
except Exception:
pass
return None
def get_active_model_runner(params: Params | None = None, force_check: bool = False) -> int:
+41 -104
View File
@@ -17,17 +17,12 @@ from openpilot.common.hardware.hw import Paths
from openpilot.cereal import messaging, custom
from openpilot.sunnypilot.models.fetcher import ModelFetcher
from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_bundle, get_selected_bundle,
resolve_bundle_by_ref, validate_active_bundles, verify_file)
from openpilot.sunnypilot.models.helpers import get_active_bundle, validate_active_bundle, verify_file
# (connect, read) seconds. read is per-request inactivity, not a total cap
DOWNLOAD_TIMEOUT = (30, 30)
class DownloadCancelled(Exception):
pass
class ModelManagerSP:
"""Manages model downloads and status reporting"""
@@ -35,25 +30,11 @@ class ModelManagerSP:
self.params = Params()
self.model_fetcher = ModelFetcher(self.params)
self.pm = messaging.PubMaster(["modelManagerSP"])
self.sm = messaging.SubMaster(["deviceState"])
self.chestnut_present = False
self.available_models: list[custom.ModelManagerSP.ModelBundle] = []
self.source_models: dict[str, list[custom.ModelManagerSP.ModelBundle]] = {}
self.selected_bundle: custom.ModelManagerSP.ModelBundle = None
self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, usbgpu=self.chestnut_present)
self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params)
self._chunk_size = 128 * 1000 # 128 KB chunks
self._download_start_times: dict[str, float] = {} # Track start time per model
self._download_ref: bytes | str | None = None
def _download_interrupted(self) -> bool:
# only removal cancels: a different ref is a queued selection that
# _release_download_ref leaves in place for the next tick
return self.params.get("ModelManager_DownloadRef") is None
def _release_download_ref(self) -> None:
if self.params.get("ModelManager_DownloadRef") == self._download_ref:
self.params.remove("ModelManager_DownloadRef")
self._download_ref = None
def _sync_artifact_progress(self, source_artifact) -> None:
"""Mirror download progress to all artifacts sharing the same filename in the selected bundle."""
@@ -95,8 +76,8 @@ class ModelManagerSP:
f.write(chunk)
bytes_downloaded += len(chunk)
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
if self.params.get("ModelManager_DownloadIndex") is None:
raise Exception("Download cancelled")
if total_size > 0:
progress = (bytes_downloaded / total_size) * 100
@@ -109,7 +90,7 @@ class ModelManagerSP:
# Clean up start time after download completes
del self._download_start_times[model.fileName]
async def _download_chunked(self, base_url: str, base_path: str, artifact, skip: frozenset[int] | set[int] = frozenset()) -> None:
async def _download_chunked(self, base_url: str, base_path: str, artifact) -> None:
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
num_chunks = len(artifact.chunks)
@@ -121,11 +102,8 @@ class ModelManagerSP:
# Shared connection saves a TCP+TLS handshake per chunk.
# Keep sequential: the link saturates on one stream and Session is not thread-safe.
completed = len(skip)
with requests.Session() as session:
for i, _ in enumerate(artifact.chunks):
if i in skip:
continue
chunk_url = get_chunk_name(base_url, i, num_chunks)
chunk_path = get_chunk_name(base_path, i, num_chunks)
chunk_downloaded = 0
@@ -136,16 +114,15 @@ class ModelManagerSP:
for data in response.iter_content(chunk_size=self._chunk_size):
f.write(data)
chunk_downloaded += len(data)
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
if self.params.get("ModelManager_DownloadIndex") is None:
raise Exception("Download cancelled")
intra = chunk_downloaded / max(chunk_size, 1)
progress = min(99.0, ((completed + intra) / num_chunks) * 100)
progress = min(99.0, ((i + intra) / num_chunks) * 100)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
artifact.downloadProgress.progress = progress
artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress)
self._sync_artifact_progress(artifact)
self._report_status()
completed += 1
with open(manifest_path, 'w') as f: # noqa: ASYNC230
f.write(str(num_chunks))
@@ -156,8 +133,6 @@ class ModelManagerSP:
async def _process_artifact(self, artifact, destination_path: str) -> None:
if not artifact.downloadUri.uri:
return None
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
url = artifact.downloadUri.uri
expected_hash = artifact.downloadUri.sha256
@@ -165,23 +140,17 @@ class ModelManagerSP:
full_path = os.path.join(destination_path, filename)
try:
# progress counts only valid chunks so a resumed download continues the
# bar from where verification left it, instead of falling back to zero
is_cached = False
valid_chunks: set[int] = set()
if len(artifact.chunks) > 0:
from openpilot.common.file_chunker import get_chunk_name
num_chunks = len(artifact.chunks)
chunks_valid = True
for i, chunk in enumerate(artifact.chunks):
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256):
valid_chunks.add(i)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying
artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100
self._sync_artifact_progress(artifact)
self._report_status()
is_cached = len(valid_chunks) == num_chunks
chunk_path = get_chunk_name(full_path, i, len(artifact.chunks))
if not await verify_file(chunk_path, chunk.sha256):
chunks_valid = False
break
if chunks_valid and len(artifact.chunks) > 0:
is_cached = True
else:
if await verify_file(full_path, expected_hash):
is_cached = True
@@ -195,7 +164,7 @@ class ModelManagerSP:
return
if len(artifact.chunks) > 0:
await self._download_chunked(url, full_path, artifact, skip=valid_chunks)
await self._download_chunked(url, full_path, artifact)
from openpilot.common.file_chunker import get_chunk_name
for i, chunk in enumerate(artifact.chunks):
chunk_path = get_chunk_name(full_path, i, len(artifact.chunks))
@@ -212,17 +181,6 @@ class ModelManagerSP:
self._sync_artifact_progress(artifact)
self._report_status()
except DownloadCancelled:
# a cancel keeps whatever is on disk: complete chunks resume the next attempt
self._download_start_times.pop(artifact.fileName, None)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
if self.selected_bundle:
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
self._report_status()
raise
except Exception as e:
cloudlog.error(f"Error downloading {filename}: {str(e)}")
for f in [full_path] + [p for p in (os.path.join(destination_path, f) for f in os.listdir(destination_path)) if filename in p]:
@@ -254,12 +212,10 @@ class ModelManagerSP:
model_manager_state.availableBundles = self.available_models
self.pm.send('modelManagerSP', msg)
async def _download_bundle(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str, source: str) -> None:
async def _download_bundle(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None:
"""Downloads all models in a bundle"""
self.selected_bundle = model_bundle
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloading
for model in self.selected_bundle.models:
model.artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
self._report_status()
os.makedirs(destination_path, exist_ok=True)
try:
@@ -276,11 +232,10 @@ class ModelManagerSP:
seen_artifacts.add(artifact.fileName)
await self._process_artifact(artifact, destination_path)
if self._download_interrupted():
raise DownloadCancelled("Download cancelled")
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded
self.params.put(ACTIVE_BUNDLE_KEYS[source], model_bundle.to_dict(), block=True)
self.active_bundle = get_active_bundle(self.params, usbgpu=self.chestnut_present)
self.active_bundle = self.selected_bundle
self.active_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded
self.params.put("ModelManager_ActiveBundle", self.active_bundle.to_dict(), block=True)
self.selected_bundle = None
except Exception:
if self.selected_bundle is not None:
@@ -290,30 +245,9 @@ class ModelManagerSP:
finally:
self._report_status()
def download(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str, source: str) -> None:
def download(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None:
"""Main entry point for downloading a model bundle"""
asyncio.run(self._download_bundle(model_bundle, destination_path, source))
def _process_download_requests(self) -> None:
# loops so a ref queued during a download starts in the same tick, without
# the bar dropping to idle for a tick between the two transfers
last_ref = None
while (ref_to_download := self.params.get("ModelManager_DownloadRef")) is not None:
if ref_to_download == last_ref: # a repeating ref falls back to the next tick instead of spinning
return
last_ref = ref_to_download
resolved = resolve_bundle_by_ref(ref_to_download, self.source_models)
if not resolved:
return
model_to_download, source = resolved
self._download_ref = ref_to_download
try:
self.download(model_to_download, Paths.model_root(), source)
except Exception as e:
cloudlog.exception(e)
finally:
self._release_download_ref()
self.selected_bundle = None
asyncio.run(self._download_bundle(model_bundle, destination_path))
def main_thread(self) -> None:
"""Main thread for model management"""
@@ -321,14 +255,19 @@ class ModelManagerSP:
while True:
try:
self.sm.update(0)
self.chestnut_present = self.sm['deviceState'].chestnutPresent
self.source_models = {source: self.model_fetcher.get_bundles_for_source(source) for source in ModelFetcher.MODEL_SOURCES}
self.available_models = self.source_models[ModelFetcher.active_source(self.chestnut_present)]
validate_active_bundles(self.params, self.source_models)
self.active_bundle = get_active_bundle(self.params, usbgpu=self.chestnut_present)
self.available_models = self.model_fetcher.get_available_bundles()
validate_active_bundle(self.params, self.available_models)
self.active_bundle = get_active_bundle(self.params)
self._process_download_requests()
if (index_to_download := self.params.get("ModelManager_DownloadIndex")) is not None:
if model_to_download := next((model for model in self.available_models if model.index == index_to_download), None):
try:
self.download(model_to_download, Paths.model_root())
except Exception as e:
cloudlog.exception(e)
finally:
self.params.remove("ModelManager_DownloadIndex")
self.selected_bundle = None
if self.params.get("ModelManager_ClearCache"):
self.clear_model_cache()
@@ -346,14 +285,12 @@ class ModelManagerSP:
Clears the model cache directory of all files except those in the active model bundle.
"""
# Get list of files used by both slots' selected bundles (either may become
# the truly active bundle depending on hardware availability)
# Get list of files used by active model bundle
active_files = []
for source in ACTIVE_BUNDLE_KEYS:
if selected_bundle := get_selected_bundle(self.params, source):
for model in selected_bundle.models:
if model.artifact.fileName:
active_files.append(model.artifact.fileName)
if self.active_bundle is not None: # When the default model is active
for model in self.active_bundle.models:
if hasattr(model, 'artifact') and model.artifact.fileName:
active_files.append(model.artifact.fileName)
# Remove all files except active ones (including their chunk files)
model_dir = Paths.model_root()
@@ -1,2 +1 @@
DEFAULT_MODEL = "CD210"
DEFAULT_BIG_MODEL = "Lebowski"
@@ -20,4 +20,4 @@ class TestDefaultModel(OpenpilotTestCase):
with open(MODEL_HASH_PATH) as f:
current_hash = f.read().strip()
assert combined_hash == current_hash, "Run openpilot/sunnypilot/models/default_model.py to update the default model name and hash"
assert combined_hash == current_hash, "Run sunnypilot/models/default_model.py to update the default model name and hash"
@@ -11,7 +11,6 @@ import http.server
import os
import tempfile
import threading
import time
import unittest
from typing import Any
from unittest import mock
@@ -24,10 +23,6 @@ from openpilot.common.test import OpenpilotTestCase
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
from openpilot.selfdrive.test.helpers import http_server_context
from openpilot.sunnypilot.models import manager as manager_module
from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles
from openpilot.sunnypilot.models import helpers
from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_source, get_selected_bundle,
resolve_bundle_by_ref, validate_active_bundles)
from openpilot.sunnypilot.models.manager import ModelManagerSP
CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000]
@@ -103,13 +98,11 @@ class ManagerDownloadTestBase(OpenpilotTestCase):
self.manager = ModelManagerSP.__new__(ModelManagerSP)
self.manager.params = mock.MagicMock()
self.manager.params.get.return_value = b'0' # not cancelled
self.manager._download_ref = b'0'
self.manager.pm = mock.MagicMock()
self.manager.pm.send.side_effect = self._record_progress
self.manager.selected_bundle = None
self.manager.active_bundle = None
self.manager.available_models = []
self.manager.chestnut_present = False
self.manager._chunk_size = 1024
self.manager._download_start_times = {}
@@ -256,166 +249,6 @@ class TestManagerDownload(ManagerDownloadTestBase):
assert self.manager._download_start_times == {}
self.run_with_server(body)
def test_download_ref_present_keeps_download_alive(self):
"""A pending download request (DownloadRef set) must not be cancelled mid-transfer."""
def body():
artifact = self.make_artifact(chunked=True)
base_path = os.path.join(self.dest, artifact.fileName)
self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None
self.manager._download_ref = b"ref"
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
assert os.path.isfile(get_manifest_path(base_path))
self.run_with_server(body)
def test_cancellation_via_download_ref(self):
"""Removing DownloadRef mid-transfer cancels the download."""
def body():
artifact = self.make_artifact(chunked=True)
base_path = os.path.join(self.dest, artifact.fileName)
checks = {"n": 0}
def get(key):
if key == "ModelManager_DownloadRef":
checks["n"] += 1
return b"ref" if checks["n"] <= 2 else None
return b"0"
self.manager.params.get.side_effect = get
self.manager._download_ref = b"ref"
with self.assertRaises(Exception) as ctx:
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
assert 'cancelled' in str(ctx.exception).lower()
assert not os.path.isfile(get_manifest_path(base_path))
self.run_with_server(body)
def test_replaced_download_ref_queues_instead_of_cancelling(self):
"""Selecting another model mid-transfer lets the running download finish."""
def body():
artifact = self.make_artifact(chunked=True)
base_path = os.path.join(self.dest, artifact.fileName)
self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None
self.manager._download_ref = b"ref"
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
assert os.path.isfile(get_manifest_path(base_path))
self.run_with_server(body)
def test_replaced_download_ref_is_kept(self):
"""A selection made during a download must survive that download's cleanup."""
self.manager.params.get.return_value = b"new-ref"
self.manager._download_ref = b"old-ref"
self.manager._release_download_ref()
self.manager.params.remove.assert_not_called()
def test_own_download_ref_is_released(self):
self.manager.params.get.return_value = b"ref"
self.manager._download_ref = b"ref"
self.manager._release_download_ref()
self.manager.params.remove.assert_called_once_with("ModelManager_DownloadRef")
def test_cached_bundle_cancel_skips_slot_write(self):
"""A cancel must stop an already-on-disk bundle before it is applied to the slot."""
def body():
artifact = self.make_artifact(chunked=True)
base_path = os.path.join(self.dest, artifact.fileName)
for i, data in enumerate(CHUNK_BODIES):
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f:
f.write(data)
self._bundle.ref = "test-ref"
params, store = self._make_params_with_store()
store["ModelManager_DownloadRef"] = None # removed -> cancelled
self.manager.params = params
self.manager._download_ref = b"ref"
with self.assertRaises(Exception) as ctx:
asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom"))
assert 'cancelled' in str(ctx.exception).lower()
assert "ModelManager_ActiveBundle" not in store
assert all(os.path.isfile(p) for p in self.chunk_paths(base_path)), "cancel must not delete cached chunks"
self.run_with_server(body)
def test_resume_skips_valid_chunks(self):
"""A chunk already on disk is kept and not re-downloaded; progress starts above its share."""
def body():
artifact = self.make_artifact(chunked=True)
base_path = os.path.join(self.dest, artifact.fileName)
with open(get_chunk_name(base_path, 0, len(CHUNK_BODIES)), 'wb') as f:
f.write(CHUNK_BODIES[0])
asyncio.run(self.manager._process_artifact(artifact, self.dest))
chunk0_suffix = get_chunk_name('', 0, len(CHUNK_BODIES))
assert not any(p.endswith(chunk0_suffix) for p in DownloadHandler.request_paths), "valid chunk was re-downloaded"
for i, expected in enumerate(CHUNK_BODIES):
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
assert f.read() == expected
assert os.path.isfile(get_manifest_path(base_path))
assert min(self.reported) >= (1 / len(CHUNK_BODIES)) * 100 - 1, "progress must not restart below the resumed share"
self.run_with_server(body)
def test_verify_reports_valid_fraction_then_cached(self):
"""A fully cached bundle publishes climbing verify progress and ends cached."""
def body():
artifact = self.make_artifact(chunked=True)
base_path = os.path.join(self.dest, artifact.fileName)
for i, data in enumerate(CHUNK_BODIES):
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f:
f.write(data)
asyncio.run(self.manager._process_artifact(artifact, self.dest))
assert DownloadHandler.request_paths == [], "cached bundle must not hit the network"
assert [round(p) for p in self.reported[:3]] == [33, 67, 100]
assert artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.cached
self.run_with_server(body)
def _make_params_with_store(self):
params = mock.MagicMock()
store = {}
def get(key, *args, **kwargs):
return store.get(key, b"0") # b"0" -> download not cancelled
def put(key, value, *args, **kwargs):
store[key] = value
params.get.side_effect = get
params.put.side_effect = put
return params, store
def test_download_writes_qcom_slot(self):
"""A download resolved to the qcom source writes the qcom active bundle slot only."""
def body():
artifact = self.make_artifact(chunked=True)
self._bundle.ref = "test-ref"
self._bundle.minimumSelectorVersion = 18
params, store = self._make_params_with_store()
self.manager.params = params
asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom"))
assert "ModelManager_ActiveBundle" in store, "qcom download must write the qcom slot"
assert "ModelManager_ActiveBundleUSBGPU" not in store, "qcom download must not touch the usbgpu slot"
assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref"
assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
chunk_names = [get_chunk_name(artifact.fileName, i, len(artifact.chunks)) for i in range(len(artifact.chunks))]
missing = [c for c in chunk_names if not os.path.isfile(os.path.join(self.dest, c))]
assert missing == [], f"chunks missing from the cache: {missing}"
self.run_with_server(body)
def test_download_writes_usbgpu_slot(self):
"""A download resolved to the usbgpu source writes the usbgpu active bundle slot only."""
def body():
self.make_artifact(chunked=True)
self._bundle.ref = "big-ref"
self._bundle.minimumSelectorVersion = 18
params, store = self._make_params_with_store()
self.manager.params = params
asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "usbgpu"))
assert "ModelManager_ActiveBundleUSBGPU" in store, "usbgpu download must write the usbgpu slot"
assert "ModelManager_ActiveBundle" not in store, "usbgpu download must not touch the qcom slot"
assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
self.run_with_server(body)
class TestManagerImports(OpenpilotTestCase):
"""Catches undeclared dependencies. aiohttp lived only in the AGNOS venv; 19.6 dropped
@@ -434,352 +267,6 @@ class TestManagerImports(OpenpilotTestCase):
assert connect > 0 and read > 0, "requests defaults to no timeout; downloads would hang forever"
class TestResolveBundleByRef(OpenpilotTestCase):
"""A ref resolves to (bundle, source) across both hardware manifests. Refs are
unique per manifest and never overlap across sources, so a ref maps to exactly
one slot. Shared by the manager's download flow and the settings UI."""
@staticmethod
def _bundle(ref: str):
bundle = custom.ModelManagerSP.ModelBundle.new_message()
bundle.ref = ref
return bundle
def test_qcom_ref_resolves_to_qcom_slot(self):
small = self._bundle("small")
assert resolve_bundle_by_ref("small", {"qcom": [small], "usbgpu": []}) == (small, "qcom")
def test_usbgpu_ref_resolves_to_usbgpu_slot(self):
big = self._bundle("big")
assert resolve_bundle_by_ref("big", {"qcom": [], "usbgpu": [big]}) == (big, "usbgpu")
def test_unknown_ref_returns_none(self):
source_bundles = {"qcom": [self._bundle("small")], "usbgpu": []}
assert resolve_bundle_by_ref("nope", source_bundles) is None
def manifest_bundle(short_name: str, ref: str, index: int = 0, is_big: bool = False) -> dict:
"""Minimal manifest bundle dict, version-compatible (no chunks to avoid disk side effects).
Big (usbgpu) bundles carry `is_big: true` in the manifest JSON."""
return {
"index": index,
"short_name": short_name,
"display_name": short_name.upper(),
"generation": 1,
"environment": "release",
"runner": "tinygrad",
"is_big": is_big,
"minimum_selector_version": "18",
"ref": ref,
"models": [{
"type": "supercombo",
"artifact": {
"file_name": f"{short_name}.pkl",
"download_uri": {"url": f"https://example.com/{short_name}.pkl", "sha256": "s"},
},
}],
}
def fresh_sync_time() -> int:
return int(time.monotonic() * 1e9)
class TestModelFetcherSources(OpenpilotTestCase):
"""Both manifests are always maintained: get_bundles_for_source exposes either
source by name, and active_source picks which one matches the attached hardware."""
def _make_params(self, qcom_manifest, usbgpu_manifest):
params = mock.MagicMock()
def get(key):
if key == "ModelManager_ModelsCache":
return qcom_manifest
if key == "ModelManager_ModelsCache_USBGPU":
return usbgpu_manifest
if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_USBGPU"):
return fresh_sync_time()
return None
params.get.side_effect = get
return params
def test_active_source_follows_chestnut_presence(self):
assert ModelFetcher.active_source(False) == "qcom"
assert ModelFetcher.active_source(True) == "usbgpu"
def test_get_bundles_for_source_returns_each_source(self):
params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]},
{"bundles": [manifest_bundle("big", "bbb", is_big=True)]})
fetcher = ModelFetcher(params)
assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["aaa"]
assert [bundle.ref for bundle in fetcher.get_bundles_for_source("usbgpu")] == ["bbb"]
def test_get_bundles_for_source_unknown(self):
assert ModelFetcher(mock.MagicMock()).get_bundles_for_source("bogus") == []
def test_get_cached_bundles_parses_source(self):
params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]},
{"bundles": [manifest_bundle("big", "bbb", is_big=True)]})
qcom_bundles = get_cached_bundles(params, "qcom")
usbgpu_bundles = get_cached_bundles(params, "usbgpu")
assert [b.ref for b in qcom_bundles] == ["aaa"]
assert [b.ref for b in usbgpu_bundles] == ["bbb"]
assert qcom_bundles[0].displayName == "SMALL"
def test_get_cached_bundles_empty_when_missing(self):
params = mock.MagicMock()
params.get.return_value = None
assert get_cached_bundles(params, "qcom") == []
assert get_cached_bundles(params, "usbgpu") == []
def test_get_cached_bundles_unknown_source(self):
assert get_cached_bundles(mock.MagicMock(), "bogus") == []
def test_active_json_has_both_urls(self):
params = mock.MagicMock()
ModelFetcher(params)
active_json_calls = [call for call in params.put.call_args_list if call.args[0] == "ModelManager_ActiveJson"]
assert active_json_calls, "expected ModelManager_ActiveJson to be written"
assert active_json_calls[-1].args[1] == {
"qcom": ModelFetcher.MODEL_URL,
"usbgpu": ModelFetcher.MODEL_URL_USBGPU,
}
class TestSourceCacheIntegrity(OpenpilotTestCase):
"""Each source's cached manifest must contain only that source's models; the
`is_big` flag in the JSON marks the big (usbgpu) models. A mismatched cache is
legacy data from before the per-source split (the active manifest was cached
under the unsuffixed key regardless of hardware) and is refetched. This
replaces the old one-time bundle migration."""
def _make_params(self, qcom_manifest, usbgpu_manifest):
params = mock.MagicMock()
def get(key):
if key == "ModelManager_ModelsCache":
return qcom_manifest
if key == "ModelManager_ModelsCache_USBGPU":
return usbgpu_manifest
if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_USBGPU"):
return fresh_sync_time()
return None
params.get.side_effect = get
return params
def _fetched(self, *bundles):
return ModelFetcher(mock.MagicMock()).model_parser.parse_models({"bundles": list(bundles)})
def test_qcom_cache_with_big_models_is_refetched(self):
"""Legacy: the unsuffixed cache holds the big manifest. is_big confirms it is
the wrong set for qcom, so a fresh fetch replaces it."""
params = self._make_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]},
{"bundles": [manifest_bundle("big2", "ccc", is_big=True)]})
fetcher = ModelFetcher(params)
fetched = self._fetched(manifest_bundle("small", "aaa"))
with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched):
bundles = fetcher.get_bundles_for_source("qcom")
assert [bundle.ref for bundle in bundles] == ["aaa"]
def test_usbgpu_cache_without_big_models_is_refetched(self):
params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]},
{"bundles": [manifest_bundle("big2", "ccc")]})
fetcher = ModelFetcher(params)
fetched = self._fetched(manifest_bundle("big", "bbb", is_big=True))
with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched):
bundles = fetcher.get_bundles_for_source("usbgpu")
assert [bundle.ref for bundle in bundles] == ["bbb"]
def test_matching_caches_are_used_without_fetch(self):
params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]},
{"bundles": [manifest_bundle("big", "bbb", is_big=True)]})
fetcher = ModelFetcher(params)
with mock.patch.object(fetcher, "_fetch_and_cache_models", side_effect=AssertionError("cache should be used")):
assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["aaa"]
assert [bundle.ref for bundle in fetcher.get_bundles_for_source("usbgpu")] == ["bbb"]
def test_stale_version_cache_is_refetched(self):
"""A source-matching cache whose bundles are all filtered by the selector version
check parses to zero valid bundles; it is stale (e.g. an old manifest) and must be
refetched instead of silently returning an empty list forever."""
stale = manifest_bundle("small", "aaa")
stale["minimum_selector_version"] = "16"
params = self._make_params({"bundles": [stale]},
{"bundles": [manifest_bundle("big", "bbb", is_big=True)]})
fetcher = ModelFetcher(params)
fetched = self._fetched(manifest_bundle("small2", "ddd"))
with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch:
bundles = fetcher.get_bundles_for_source("qcom")
fetch.assert_called_once_with("qcom")
assert [bundle.ref for bundle in bundles] == ["ddd"]
def test_mismatched_refetch_happens_once(self):
"""If the fresh manifest still fails the source check, the URL is authoritative:
trust it instead of refetching at 1 Hz forever."""
params = self._make_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]},
{"bundles": [manifest_bundle("big2", "ccc", is_big=True)]})
fetcher = ModelFetcher(params)
fetched = self._fetched(manifest_bundle("big", "bbb", is_big=True))
with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch:
first = fetcher.get_bundles_for_source("qcom")
second = fetcher.get_bundles_for_source("qcom")
fetch.assert_called_once_with("qcom")
assert [bundle.ref for bundle in first] == ["bbb"]
assert [bundle.ref for bundle in second] == ["bbb"]
def test_corrupt_cache_is_refetched(self):
"""A cache that fails to parse (e.g. truncated/foreign JSON) must trigger a
refetch instead of raising every loop and never recovering."""
corrupt = {"bundles": [{"short_name": "broken"}]} # missing required fields
params = self._make_params(corrupt, {"bundles": [manifest_bundle("big", "bbb", is_big=True)]})
fetcher = ModelFetcher(params)
fetched = self._fetched(manifest_bundle("small", "aaa"))
with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch:
bundles = fetcher.get_bundles_for_source("qcom")
fetch.assert_called_once_with("qcom")
assert [bundle.ref for bundle in bundles] == ["aaa"]
class TestActiveBundleValidation(OpenpilotTestCase):
"""Validation is per-slot: a failed fetch (empty bundle list) must not reset a slot,
and resetting one slot must not stomp the runner cache derived from the other."""
def setUp(self):
super().setUp()
helpers._LAST_VALIDATED_RAW.clear()
@staticmethod
def _raw_bundle(ref: str, runner: int | None = None) -> dict:
bundle = custom.ModelManagerSP.ModelBundle.new_message()
bundle.ref = ref
bundle.minimumSelectorVersion = 18
if runner is not None:
bundle.runner = runner
return bundle.to_dict()
def _params(self, qcom=None, usbgpu=None):
params = mock.MagicMock()
def get(key, *args, **kwargs):
return {"ModelManager_ActiveBundle": qcom, "ModelManager_ActiveBundleUSBGPU": usbgpu}.get(key)
params.get.side_effect = get
return params
def test_empty_catalog_does_not_reset_slot(self):
params = self._params(qcom=self._raw_bundle("small"))
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False):
validate_active_bundles(params, {"qcom": [], "usbgpu": []})
params.remove.assert_not_called()
def test_reset_recomputes_runner_from_surviving_slot(self):
tinygrad = int(custom.ModelManagerSP.Runner.tinygrad)
big_raw = self._raw_bundle("big", runner=tinygrad)
params = self._params(qcom=self._raw_bundle("gone"), usbgpu=big_raw)
catalog = {"qcom": [custom.ModelManagerSP.ModelBundle(**self._raw_bundle("other"))],
"usbgpu": [custom.ModelManagerSP.ModelBundle(**big_raw)]}
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True):
validate_active_bundles(params, catalog)
params.remove.assert_called_once_with("ModelManager_ActiveBundle")
runner_puts = [call for call in params.put.call_args_list if call.args[0] == "ModelRunnerTypeCache"]
assert [call.args[1] for call in runner_puts] == [tinygrad]
class TestActiveBundleSelection(OpenpilotTestCase):
"""The effective active bundle is the active source's slot: usbgpu when a GPU is
present, qcom otherwise. An empty active slot means the hardware default (stock
runner), never the other slot's pick - modeld_v2 requires a real bundle."""
@staticmethod
def _raw_bundle(ref: str) -> dict:
bundle = custom.ModelManagerSP.ModelBundle.new_message()
bundle.ref = ref
bundle.minimumSelectorVersion = 18
return bundle.to_dict()
def _params(self, qcom=None, usbgpu=None):
params = mock.MagicMock()
def get(key, *args, **kwargs):
if key == "ModelManager_ActiveBundle":
return qcom
if key == "ModelManager_ActiveBundleUSBGPU":
return usbgpu
return None
params.get.side_effect = get
return params
def test_selected_bundle_is_per_slot(self):
params = self._params(qcom=self._raw_bundle("small"), usbgpu=self._raw_bundle("big"))
assert get_selected_bundle(params, "qcom").ref == "small"
assert get_selected_bundle(params, "usbgpu").ref == "big"
def test_no_gpu_uses_qcom_slot(self):
params = self._params(qcom=self._raw_bundle("small"), usbgpu=self._raw_bundle("big"))
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False):
assert get_active_bundle(params).ref == "small"
def test_gpu_uses_usbgpu_slot(self):
params = self._params(qcom=self._raw_bundle("small"), usbgpu=self._raw_bundle("big"))
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True):
assert get_active_bundle(params).ref == "big"
def test_gpu_without_big_selection_is_hardware_default(self):
params = self._params(qcom=self._raw_bundle("small"), usbgpu=None)
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True):
assert get_active_bundle(params) is None
class TestEffectiveSource(OpenpilotTestCase):
"""One gate decides the active source. With no flags it is runtime truth (GPU
attached); display callers (mici) pass the ui_state flags, which additionally
require the big model to be loading, active, or the device offroad. The active
bundle is simply the selected bundle of that source."""
@staticmethod
def _raw_bundle(ref: str) -> dict:
bundle = custom.ModelManagerSP.ModelBundle.new_message()
bundle.ref = ref
bundle.minimumSelectorVersion = 18
return bundle.to_dict()
def test_runtime_no_gpu(self):
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False):
assert get_active_source() == "qcom"
def test_runtime_gpu_present(self):
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True):
assert get_active_source() == "usbgpu"
def test_display_offroad_gpu_present_shows_big(self):
assert get_active_source(usbgpu=True, usbgpu_active=False, usbgpu_loading=False, offroad=True) == "usbgpu"
def test_display_onroad_gpu_loading_shows_big(self):
assert get_active_source(usbgpu=True, usbgpu_active=False, usbgpu_loading=True, offroad=False) == "usbgpu"
def test_display_onroad_gpu_active_shows_big(self):
assert get_active_source(usbgpu=True, usbgpu_active=True, usbgpu_loading=False, offroad=False) == "usbgpu"
def test_display_onroad_gpu_idle_shows_small(self):
assert get_active_source(usbgpu=True, usbgpu_active=False, usbgpu_loading=False, offroad=False) == "qcom"
def test_display_active_none_is_idle(self):
assert get_active_source(usbgpu=True, usbgpu_active=None, usbgpu_loading=False, offroad=False) == "qcom"
def test_active_bundle_follows_source(self):
params = mock.MagicMock()
params.get.side_effect = lambda key: {"ModelManager_ActiveBundle": self._raw_bundle("small"),
"ModelManager_ActiveBundleUSBGPU": self._raw_bundle("big")}.get(key)
with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False):
assert get_active_bundle(params).ref == "small"
assert get_selected_bundle(params, get_active_source(usbgpu=True, usbgpu_active=False,
usbgpu_loading=False, offroad=True)).ref == "big"
@unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network')
class TestLiveModelManifest(OpenpilotTestCase):
"""Every artifact and chunk URL in the published manifest must resolve."""
@@ -4,6 +4,7 @@ from openpilot.sunnypilot.models.tinygrad_ref import get_tinygrad_ref
from openpilot.sunnypilot.models.fetcher import ModelFetcher
from openpilot.common.test import OpenpilotTestCase
def fetch_tinygrad_ref():
response = requests.get(ModelFetcher.MODEL_URL, timeout=10)
response.raise_for_status()
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:447099e93e303b29e7b3eac237bb0f27f8c5e12786991139aee2432532a75f58
size 12310
@@ -104,14 +104,6 @@ class ControlsExt(ModelStateBase):
CC_SP.intelligentCruiseButtonManagement.sendButton = icbm_src.sendButton
CC_SP.intelligentCruiseButtonManagement.vTarget = icbm_src.vTarget
ford_path = getattr(self, 'ford_path', None)
if ford_path is not None:
CC_SP.fordLateralPath.valid = ford_path.valid
CC_SP.fordLateralPath.pathOffset = ford_path.path_offset
CC_SP.fordLateralPath.pathAngle = ford_path.path_angle
CC_SP.fordLateralPath.curvature = ford_path.curvature
CC_SP.fordLateralPath.curvatureRate = ford_path.curvature_rate
return CC_SP
@staticmethod
@@ -83,14 +83,9 @@ class TestLocationdProc(OpenpilotTestCase):
self.pm.send(msg.which(), msg)
if msg.which() == "cameraOdometry":
self.pm.wait_for_readers_to_update(msg.which(), timeout=1, dt=0.005)
for _ in range(50):
val = self.params.get('LastGPSPositionLLK')
if val is not None:
break
time.sleep(0.1)
time.sleep(1) # wait for async params write
self.assertIsNotNone(val, "LastGPSPositionLLK not written within 5s")
lastGPS = json.loads(val)
lastGPS = json.loads(self.params.get('LastGPSPositionLLK'))
self.assertAlmostEqual(lastGPS['latitude'], self.lat, delta=0.001)
self.assertAlmostEqual(lastGPS['longitude'], self.lon, delta=0.001)
self.assertAlmostEqual(lastGPS['altitude'], self.alt, delta=0.001)
@@ -1,260 +0,0 @@
"""Authoritative assisted-driving distance and milestone tracking."""
import math
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from openpilot.common.params import Params
METERS_PER_MILE = 1609.344
METERS_PER_KILOMETER = 1000.0
MAX_SAMPLE_INTERVAL_SECONDS = 0.5
PERSIST_INTERVAL_NS = 10_000_000_000
STATE_VERSION = 1
STATE_PARAM = "AssistedDrivingMilestoneState"
LAST_DRIVE_SUMMARY_PARAM = "LastDriveAssistedDrivingSummary"
class AssistCategory(StrEnum):
MADS = "mads"
FULL_ASSIST = "fullAssist"
class MilestoneUnit(StrEnum):
IMPERIAL = "imperial"
METRIC = "metric"
@dataclass(frozen=True)
class MilestoneEvent:
event_id: int
category: AssistCategory
distance_meters: float
previous_distance_meters: float
unit: MilestoneUnit
@dataclass(frozen=True)
class MilestoneSnapshot:
distances_meters: dict[AssistCategory, float]
drive_start_distances_meters: dict[AssistCategory, float]
next_event_id: int
next_summary_id: int
unit: MilestoneUnit
active_drive_id: str
def assist_category(lat_active: bool, long_active: bool) -> AssistCategory | None:
if not lat_active:
return None
return AssistCategory.FULL_ASSIST if long_active else AssistCategory.MADS
def _meters_per_unit(unit: MilestoneUnit) -> float:
return METERS_PER_KILOMETER if unit == MilestoneUnit.METRIC else METERS_PER_MILE
def _next_ladder_value(value: float) -> float:
value = max(0.0, value)
magnitude = 10.0 ** math.floor(math.log10(max(1.0, value)))
for multiplier in (1.0, 2.0, 5.0):
candidate = multiplier * magnitude
if candidate > value + 1e-9:
return candidate
return 10.0 * magnitude
def _previous_ladder_value(value: float) -> float:
if value <= 1.0:
return 0.0
magnitude = 10.0 ** math.floor(math.log10(value))
normalized = value / magnitude
if normalized <= 1.0 + 1e-9:
return 5.0 * magnitude / 10.0
if normalized <= 2.0 + 1e-9:
return magnitude
return 2.0 * magnitude
def next_milestone_meters(distance_meters: float, unit: MilestoneUnit) -> float:
meters_per_unit = _meters_per_unit(unit)
return _next_ladder_value(distance_meters / meters_per_unit) * meters_per_unit
class MilestoneStore:
def __init__(self, params: Params | None = None):
self._params = params or Params()
def load(self) -> MilestoneSnapshot:
raw = self._params.get(STATE_PARAM, return_default=True)
raw = raw if isinstance(raw, dict) else {}
raw_distances = raw.get("distancesMeters", {})
raw_distances = raw_distances if isinstance(raw_distances, dict) else {}
try:
unit = MilestoneUnit(raw.get("unit", MilestoneUnit.IMPERIAL))
except ValueError:
unit = MilestoneUnit.IMPERIAL
def distance(category: AssistCategory) -> float:
try:
return max(0.0, float(raw_distances.get(category.value, 0.0)))
except (TypeError, ValueError):
return 0.0
distances = {category: distance(category) for category in AssistCategory}
raw_drive_start = raw.get("driveStartDistancesMeters", {})
raw_drive_start = raw_drive_start if isinstance(raw_drive_start, dict) else {}
def drive_start_distance(category: AssistCategory) -> float:
try:
return max(0.0, min(float(raw_drive_start.get(category.value, distances[category])), distances[category]))
except (TypeError, ValueError):
return distances[category]
try:
next_event_id = max(1, int(raw.get("nextEventId", 1)))
except (TypeError, ValueError):
next_event_id = 1
try:
next_summary_id = max(1, int(raw.get("nextSummaryId", 1)))
except (TypeError, ValueError):
next_summary_id = 1
return MilestoneSnapshot(
distances_meters=distances,
drive_start_distances_meters={category: drive_start_distance(category) for category in AssistCategory},
next_event_id=next_event_id,
next_summary_id=next_summary_id,
unit=unit,
active_drive_id=str(raw.get("activeDriveId", "")),
)
def save(self, snapshot: MilestoneSnapshot, block: bool = False) -> None:
if block:
self._params.flush()
self._params.put(STATE_PARAM, {
"version": STATE_VERSION,
"distancesMeters": {category.value: max(0.0, snapshot.distances_meters.get(category, 0.0)) for category in AssistCategory},
"driveStartDistancesMeters": {
category.value: max(0.0, snapshot.drive_start_distances_meters.get(category, 0.0)) for category in AssistCategory
},
"nextEventId": max(1, snapshot.next_event_id),
"nextSummaryId": max(1, snapshot.next_summary_id),
"unit": snapshot.unit.value,
"activeDriveId": snapshot.active_drive_id,
}, block=block)
def save_drive_summary(self, summary_id: int, distances_meters: Mapping[AssistCategory, float], unit: MilestoneUnit) -> None:
self._params.put(LAST_DRIVE_SUMMARY_PARAM, {
"version": STATE_VERSION,
"id": summary_id,
"distancesMeters": {category.value: max(0.0, distances_meters.get(category, 0.0)) for category in AssistCategory},
"unit": unit.value,
}, block=True)
class AssistedDrivingMilestones:
"""Tracks, persists, and emits milestones through one small interface."""
def __init__(self, store: MilestoneStore | None = None):
self._store = store or MilestoneStore()
snapshot = self._store.load()
self._distances_meters = snapshot.distances_meters
self._drive_start_distances_meters = snapshot.drive_start_distances_meters
self._next_event_id = snapshot.next_event_id
self._next_summary_id = snapshot.next_summary_id
self._unit = snapshot.unit
self._active_drive_id = snapshot.active_drive_id
self._next_milestone_meters = {
category: next_milestone_meters(distance, self._unit)
for category, distance in self._distances_meters.items()
}
self._last_timestamp_ns: int | None = None
self._last_persist_timestamp_ns: int | None = None
self._last_speed_mps = 0.0
self._last_category: AssistCategory | None = None
self._enabled = False
self._closed = False
def snapshot(self) -> MilestoneSnapshot:
return MilestoneSnapshot(
self._distances_meters.copy(),
self._drive_start_distances_meters.copy(),
self._next_event_id,
self._next_summary_id,
self._unit,
self._active_drive_id,
)
def set_drive_id(self, drive_id: str) -> None:
if not drive_id or drive_id == self._active_drive_id:
return
self._active_drive_id = drive_id
self._drive_start_distances_meters = self._distances_meters.copy()
self._persist()
def update(self, timestamp_ns: int, speed_mps: float, *, lat_active: bool, long_active: bool,
is_metric: bool, enabled: bool) -> MilestoneEvent | None:
self._enabled = enabled
unit = MilestoneUnit.METRIC if is_metric else MilestoneUnit.IMPERIAL
if unit != self._unit:
self._unit = unit
self._next_milestone_meters = {
category: next_milestone_meters(distance, unit)
for category, distance in self._distances_meters.items()
}
speed_mps = max(0.0, speed_mps)
category = assist_category(lat_active, long_active) if enabled else None
event = None
if self._last_timestamp_ns is not None and timestamp_ns != self._last_timestamp_ns:
dt = (timestamp_ns - self._last_timestamp_ns) / 1e9
if 0 < dt <= MAX_SAMPLE_INTERVAL_SECONDS and self._last_category is not None:
active_category = self._last_category
self._distances_meters[active_category] += (self._last_speed_mps + speed_mps) / 2.0 * dt
threshold_meters = self._next_milestone_meters[active_category]
if self._distances_meters[active_category] >= threshold_meters:
meters_per_unit = _meters_per_unit(self._unit)
threshold_units = threshold_meters / meters_per_unit
event = MilestoneEvent(
event_id=self._next_event_id,
category=active_category,
distance_meters=threshold_meters,
previous_distance_meters=_previous_ladder_value(threshold_units) * meters_per_unit,
unit=self._unit,
)
self._next_event_id += 1
self._next_milestone_meters[active_category] = next_milestone_meters(threshold_meters, self._unit)
self._persist(timestamp_ns=timestamp_ns)
self._last_timestamp_ns = timestamp_ns
self._last_speed_mps = speed_mps
self._last_category = category
if self._last_persist_timestamp_ns is None:
self._last_persist_timestamp_ns = timestamp_ns
elif timestamp_ns - self._last_persist_timestamp_ns >= PERSIST_INTERVAL_NS:
self._persist(timestamp_ns=timestamp_ns)
return event
def close(self) -> None:
if self._closed:
return
self._closed = True
drive_distances = {
category: self._distances_meters[category] - self._drive_start_distances_meters[category]
for category in AssistCategory
}
summary_id = self._next_summary_id
self._next_summary_id += 1
self._persist(block=True)
if self._enabled:
self._store.save_drive_summary(summary_id, drive_distances, self._unit)
def _persist(self, block: bool = False, timestamp_ns: int | None = None) -> None:
self._store.save(self.snapshot(), block=block)
self._last_persist_timestamp_ns = self._last_timestamp_ns if timestamp_ns is None else timestamp_ns
@@ -1,123 +0,0 @@
import unittest
from openpilot.sunnypilot.selfdrive.selfdrived.assisted_driving_milestones import (
METERS_PER_MILE,
AssistCategory,
AssistedDrivingMilestones,
MilestoneStore,
MilestoneUnit,
)
class ParamsStub:
def __init__(self, state=None):
self.values = {"AssistedDrivingMilestoneState": state or {}}
self.writes = []
def get(self, key, return_default=False):
return self.values.get(key, {} if return_default else None)
def put(self, key, value, block=False):
self.values[key] = value
self.writes.append((key, value, block))
def flush(self):
pass
class TestAssistedDrivingMilestones(unittest.TestCase):
def test_emits_and_asynchronously_persists_first_imperial_milestone(self):
params = ParamsStub({
"version": 1,
"distancesMeters": {"mads": METERS_PER_MILE - 5.0, "fullAssist": 0.0},
"nextEventId": 7,
"unit": "imperial",
})
milestones = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
self.assertIsNone(milestones.update(0, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True))
event = milestones.update(500_000_000, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
self.assertIsNotNone(event)
assert event is not None
self.assertEqual(event.event_id, 7)
self.assertEqual(event.category, AssistCategory.MADS)
self.assertEqual(event.unit, MilestoneUnit.IMPERIAL)
self.assertAlmostEqual(event.distance_meters, METERS_PER_MILE)
self.assertFalse(params.writes[-1][2])
def test_switching_units_schedules_only_a_future_milestone(self):
params = ParamsStub({
"version": 1,
"distancesMeters": {"mads": 9_500.0, "fullAssist": 0.0},
"nextEventId": 2,
"unit": "imperial",
})
milestones = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
self.assertIsNone(milestones.update(0, 1_000.0, lat_active=True, long_active=False, is_metric=True, enabled=True))
event = milestones.update(500_000_000, 1_000.0, lat_active=True, long_active=False, is_metric=True, enabled=True)
self.assertIsNotNone(event)
assert event is not None
self.assertEqual(event.unit, MilestoneUnit.METRIC)
self.assertAlmostEqual(event.distance_meters, 10_000.0)
def test_ignores_disabled_reverse_and_timestamp_gaps(self):
params = ParamsStub()
milestones = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
milestones.update(0, 20.0, lat_active=True, long_active=False, is_metric=False, enabled=False)
milestones.update(500_000_000, 20.0, lat_active=True, long_active=False, is_metric=False, enabled=False)
milestones.update(1_000_000_000, -20.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
milestones.update(2_000_000_000, 20.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
self.assertEqual(milestones.snapshot().distances_meters[AssistCategory.MADS], 0.0)
def test_close_persists_totals_and_last_drive_summary(self):
params = ParamsStub()
milestones = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
milestones.update(0, 10.0, lat_active=True, long_active=True, is_metric=False, enabled=True)
milestones.update(500_000_000, 10.0, lat_active=True, long_active=True, is_metric=False, enabled=True)
milestones.close()
summary = params.values["LastDriveAssistedDrivingSummary"]
self.assertAlmostEqual(summary["distancesMeters"]["fullAssist"], 5.0)
self.assertTrue(params.writes[-1][2])
write_count = len(params.writes)
milestones.close()
self.assertEqual(len(params.writes), write_count)
def test_process_restart_preserves_the_current_drive_start(self):
params = ParamsStub()
first_process = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
first_process.set_drive_id("route-1")
first_process.update(0, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
first_process.update(500_000_000, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
first_process.close()
second_process = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
second_process.set_drive_id("route-1")
second_process.update(1_000_000_000, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
second_process.update(1_500_000_000, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
second_process.close()
summary = params.values["LastDriveAssistedDrivingSummary"]
self.assertAlmostEqual(summary["distancesMeters"]["mads"], 10.0)
def test_disabled_feature_does_not_publish_drive_summary(self):
params = ParamsStub()
milestones = AssistedDrivingMilestones(MilestoneStore(params)) # type: ignore[arg-type]
milestones.update(0, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
milestones.update(500_000_000, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=True)
milestones.update(1_000_000_000, 10.0, lat_active=True, long_active=False, is_metric=False, enabled=False)
milestones.close()
self.assertNotIn("LastDriveAssistedDrivingSummary", params.values)
if __name__ == "__main__":
unittest.main()
@@ -28,7 +28,7 @@ from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutExce
create_connection, WebSocketConnectionClosedException)
import openpilot.cereal.messaging as messaging
from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL
from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL
from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
@@ -182,8 +182,6 @@ def getParamsMetadata() -> str:
schema["capabilities"] = generate_capabilities()
schema["capability_labels"] = CAPABILITY_LABELS
schema["default_model"] = DEFAULT_MODEL
schema["default_big_model"] = DEFAULT_BIG_MODEL
schema["usbgpu_active"] = params.get_bool("UsbGpuActive")
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
except Exception:
@@ -1383,12 +1383,6 @@
"title": "Steering Arc",
"description": "Display steering arc on the driving screen when lateral control is enabled."
},
{
"key": "AssistedDrivingMilestonesEnabled",
"widget": "toggle",
"title": "Assisted Driving Milestones",
"description": "Celebrate cumulative MADS and full-assist distance milestones while driving."
},
{
"key": "ShowTurnSignals",
"widget": "toggle",
@@ -2178,6 +2172,26 @@
"title": "Hyundai / Kia / Genesis Settings",
"description": "",
"items": [
{
"key": "CustomButtonAction",
"widget": "multiple_button",
"title": "Steering Custom Button",
"description": "Choose the openpilot action for the steering wheel custom/star button. OEM functionality is unchanged.",
"options": [
{
"value": 0,
"label": "None"
},
{
"value": 1,
"label": "Bookmark"
},
{
"value": 3,
"label": "Cycle UI"
}
]
},
{
"key": "HyundaiLongitudinalTuning",
"widget": "multiple_button",
@@ -10,6 +10,17 @@ sections:
title: Hyundai / Kia / Genesis Settings
description: ''
items:
- key: CustomButtonAction
widget: multiple_button
title: Steering Custom Button
description: Choose the openpilot action for the steering wheel custom/star button. OEM functionality is unchanged.
options:
- value: 0
label: None
- value: 1
label: Bookmark
- value: 3
label: Cycle UI
- key: HyundaiLongitudinalTuning
widget: multiple_button
title: Custom Longitudinal Tuning
@@ -20,10 +20,6 @@ sections:
widget: toggle
title: Steering Arc
description: Display steering arc on the driving screen when lateral control is enabled.
- key: AssistedDrivingMilestonesEnabled
widget: toggle
title: Assisted Driving Milestones
description: Celebrate cumulative MADS and full-assist distance milestones while driving.
- key: ShowTurnSignals
widget: toggle
title: Display Turn Signals
-1
View File
@@ -65,7 +65,6 @@ def sp_stats(end_event):
'MadsSteeringMode',
'MadsUnifiedEngagementMode',
'ModelManager_ActiveBundle',
'ModelManager_ActiveBundleUSBGPU',
'ModelManager_Favs',
'EnableSunnylinkUploader',
'SunnylinkEnabled',
@@ -84,47 +84,6 @@ def _migrate_tesla_mads_screen_button(_params):
cloudlog.exception(f"Error migrating TeslaMadsScreenButton: {e}")
def _migrate_model_bundle_slots(_params):
# Pre-split, a chestnut user's big-model selection lived in the single
# ActiveBundle. Seed both slots; validation drops whichever does not match
# its own manifest.
try:
if _params.get("ModelManager_ActiveBundleUSBGPU") is not None:
return
if (bundle := _params.get("ModelManager_ActiveBundle")) is None:
return
_params.put("ModelManager_ActiveBundleUSBGPU", bundle, block=True)
cloudlog.info("params_migration: seeded ModelManager_ActiveBundleUSBGPU from ModelManager_ActiveBundle")
except Exception as e:
cloudlog.exception(f"Error migrating model bundle slots: {e}")
def _migrate_assisted_driving_milestones(_params):
try:
state = _params.get("AssistedDrivingMilestoneState", return_default=True)
if isinstance(state, dict) and state.get("version") == 1:
return
_params.put("AssistedDrivingMilestoneState", {
"version": 1,
"distancesMeters": {
"mads": max(0.0, _params.get("MadsDrivenDistanceMeters", return_default=True) or 0.0),
"fullAssist": max(0.0, _params.get("FullAssistDrivenDistanceMeters", return_default=True) or 0.0),
},
"driveStartDistancesMeters": {
"mads": max(0.0, _params.get("MadsDrivenDistanceMeters", return_default=True) or 0.0),
"fullAssist": max(0.0, _params.get("FullAssistDrivenDistanceMeters", return_default=True) or 0.0),
},
"nextEventId": 1,
"nextSummaryId": 1,
"unit": "metric" if _params.get_bool("IsMetric") else "imperial",
"activeDriveId": "",
}, block=True)
cloudlog.info("params_migration: migrated assisted-driving milestone state")
except Exception as e:
cloudlog.exception(f"Error migrating assisted-driving milestone state: {e}")
def run_migration(_params):
# migrate OnroadScreenOffBrightness
if _params.get("OnroadScreenOffBrightnessMigrated") != ONROAD_BRIGHTNESS_MIGRATION_VERSION:
@@ -161,8 +120,3 @@ def run_migration(_params):
# seed TeslaMadsScreenButton for existing Tesla installs
_migrate_tesla_mads_screen_button(_params)
# seed the usbgpu model slot from the pre-split single slot
_migrate_model_bundle_slots(_params)
_migrate_assisted_driving_milestones(_params)

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