Compare commits

..

195 Commits

Author SHA1 Message Date
Michael Tawata ea53286972 readme overhaul 2026-03-24 23:42:43 -07:00
firestar5683 d2a6a60ea0 Handle MapsSelected downgrade from StarPilot-Development 2026-03-22 15:12:11 -05:00
firestar5683 ecec3afbd6 tinygrad bump 2026-03-21 13:59:45 -05:00
firestar5683 5a67663069 Fix Galaxy 2026-03-20 17:52:16 -05:00
firestar5683 54b307378b Long Maneuvers Resume 2026-03-18 21:10:09 -05:00
firestar5683 9b4c0a7151 Fix Lead departure issue 2026-03-18 19:36:08 -05:00
firestarsdog 299c42040a Fix UI Respring 2026-03-15 22:18:46 -04:00
firestar5683 2d50c43309 Backwards Compatible 2026-03-15 00:58:38 -05:00
firestar5683 5965d2389e offset 2026-03-11 18:10:54 -05:00
firestar5683 43eeca188f button 2026-03-11 17:59:50 -05:00
firestar5683 7986f23b1c SLC Bug 2026-03-11 17:56:34 -05:00
firestar5683 d4ce22deaf Update plots.js 2026-03-11 17:51:52 -05:00
firestar5683 782cef4c8b no blend 2026-03-11 17:07:28 -05:00
firestar5683 3ffe607452 v3 2026-03-11 16:56:46 -05:00
firestar5683 fc79fe1248 LongPlanV2 2026-03-11 15:18:14 -05:00
firestar5683 79e28d6f2e back to zero 2026-03-11 02:43:22 -05:00
firestar5683 335e7cf74e patches 2026-03-11 02:06:02 -05:00
firestar5683 978d57a674 Planner 2026-03-11 02:06:02 -05:00
firestarsdog 33c0f0ef3a GLXY Client Changes 2026-03-10 23:20:46 -05:00
firestar5683 c540998591 long press 2026-03-10 17:37:04 -05:00
firestar5683 445e89e14c BUTTON 2026-03-10 17:23:31 -05:00
firestar5683 46e5eaa232 Remove experimentalgmtune 2026-03-10 13:57:49 -05:00
firestar5683 d4d426f94e malibu 2026-03-10 13:55:20 -05:00
firestar5683 9364ffd6a8 malibu 2026-03-10 13:31:14 -05:00
firestar5683 fcf8c3feb2 Malibu Exception 2026-03-10 01:20:59 -05:00
firestar5683 2881deb510 Updates 2026-03-10 00:55:50 -05:00
firestar5683 082418b012 BUTTONS YEEEAAAA 2026-03-10 00:54:56 -05:00
firestar5683 f8eab92141 stop distance 2026-03-09 18:42:17 -05:00
firestar5683 208630f5ca personalities 2026-03-09 14:01:27 -05:00
firestar5683 0e0909cbd7 Goat scream 2026-03-09 13:52:40 -05:00
firestar5683 4320525df6 disable lagd 2026-03-09 13:11:05 -05:00
firestar5683 813e3183fc Troubleshoot 2026-03-09 13:11:05 -05:00
firestar5683 b4cf18cdc2 High Speed Follow 2026-03-09 13:11:05 -05:00
firestar5683 c8f18d1497 allow uploads 2026-03-08 21:09:13 -05:00
firestar5683 5ca3f503ee Pond Updates 2026-03-08 21:06:31 -05:00
firestar5683 283e9b1d78 Refine GM pedal/paddle tuning and EV accel profile behavior 2026-03-08 21:06:31 -05:00
firestar5683 404922e80d Add Pond long maneuvers page and live status wiring 2026-03-08 21:06:31 -05:00
firestar5683 77f314a170 Backport longitudinal maneuvers tooling and runtime hooks 2026-03-08 21:06:31 -05:00
firestar5683 b0426254be Revert "offset"
This reverts commit 3bd7152f0a.
2026-03-08 01:44:13 -06:00
firestar5683 0d911b2c31 Update Lateral 2026-03-07 19:52:37 -06:00
firestar5683 bd7f47eb20 rename sidebar and tmux remote 2026-03-07 13:29:37 -06:00
firestar5683 f14ac063b3 Revert "General2"
This reverts commit cc700d9b81.
2026-03-07 00:06:54 -06:00
firestar5683 b169d93464 general 2026-03-07 00:06:54 -06:00
firestar5683 14a3cb7025 steppers 2026-03-06 23:47:40 -06:00
firestar5683 9e6ef37d96 fix stale labels 2026-03-06 23:25:57 -06:00
firestar5683 aa36b2133e offset 2026-03-06 23:25:56 -06:00
firestar5683 8eb6d5b447 Revert "Testing Ground: add Gen1 Bolt lateral tune (slot 2)"
This reverts commit a43027c1ad.
2026-03-06 13:02:00 -06:00
firestar5683 f2b9cc51a0 Testing Ground: add Gen1 Bolt lateral tune (slot 2) 2026-03-06 11:32:40 -06:00
firestar5683 f2d754e96d Testing Ground: add GM Long Tune (slot 1) 2026-03-06 11:32:40 -06:00
firestar5683 cca4a277f1 Testing Ground: framework and UI scaffolding (unused slots) 2026-03-06 11:32:40 -06:00
firestar5683 cd7cb862fd plots 2026-03-05 23:32:43 -06:00
firestar5683 e8009ad9b9 Updater 2026-03-05 01:25:47 -06:00
firestar5683 cc700d9b81 General2 2026-03-04 23:14:57 -06:00
firestar5683 c24dd3552c Galaxy Optimization 2026-03-04 23:12:12 -06:00
firestarsdog 2eced5b72c Revert "General"
This reverts commit 77ffd98a1a.
2026-03-04 18:50:27 -05:00
firestar5683 77ffd98a1a General 2026-03-04 11:18:23 -06:00
firestar5683 d62d7dd9fc Moar Toggles 2026-03-03 22:46:49 -06:00
firestarsdog c84acea993 Galaxy Pair Locally 2026-03-03 22:45:38 -05:00
firestar5683 876a45122f Model Manager/Galaxy 2026-03-03 21:44:07 -06:00
firestarsdog f538eddb87 Pin to Pass 2026-03-03 11:37:52 -06:00
firestarsdog 7c51e8aac1 Galaxy 2026-03-03 00:10:16 -06:00
firestar5683 4ab8b0e874 2017 Tuning 2026-03-02 22:41:47 -06:00
firestar5683 b6f9f8180e Try old regen tuning 2026-03-02 16:39:38 -06:00
firestar5683 d07852b2bc low speed 2026-03-02 16:38:53 -06:00
firestar5683 a70746918b paddle refactor 2026-03-02 00:40:15 -06:00
firestar5683 0a6e03b7ca 2017 2026-03-02 00:40:15 -06:00
firestar5683 f0f95d5d10 Update parse_model_outputs.py 2026-02-27 15:04:29 -06:00
firestar5683 cf735574a6 Test handoff 2026-02-27 13:59:28 -06:00
firestarsdog d466d36429 Nothing to see 2026-02-24 20:05:23 -05:00
firestar5683 33cb78cfa9 Lat Tuning 2026-02-24 17:42:10 -06:00
firestar5683 e2a15a0985 Update tinygrad_modeld.py 2026-02-24 17:33:52 -06:00
firestar5683 984fd6a6cb Update latcontrol_torque.py 2026-02-23 21:30:52 -06:00
firestar5683 7ef35817fc SC 2026-02-23 13:23:55 -06:00
firestar5683 26e0b2406e 7mph engage 2026-02-22 21:10:30 -06:00
firestarsdog fed12740f2 Spoof CruiseSpeed Non-Acc 2026-02-22 21:10:02 -06:00
firestar5683 030cf80148 Update carcontroller.py 2026-02-20 02:11:08 -06:00
firestar5683 d082aa8a3c blaze 2026-02-19 13:19:35 -06:00
firestar5683 fdcab8d127 blaze 2026-02-19 11:13:07 -06:00
firestar5683 266fda2c91 CEM Transition 2026-02-18 21:32:01 -06:00
firestar5683 97a2b1522e oops 2026-02-18 15:35:56 -06:00
firestar5683 d59ab46e91 Cleanup fingerprint 2026-02-18 15:15:06 -06:00
firestar5683 948cdf21d0 fix general 2026-02-18 14:49:48 -06:00
firestar5683 f10391d9ce Revert "no sascm"
This reverts commit 1a77897c3a.
2026-02-18 14:30:09 -06:00
firestar5683 444edbe71b Update interface.py 2026-02-18 10:51:47 -06:00
firestar5683 bb41c76c62 Revert "GMC Sierra Print"
This reverts commit a7f5f3b21e.
2026-02-18 10:48:42 -06:00
firestar5683 1978e5fda1 GMC Sierra Print 2026-02-18 08:38:36 -06:00
firestar5683 85906ceb77 JC's follow changes 2026-02-18 08:03:05 -06:00
firestar5683 259ac19499 Honda 2026-02-17 21:02:15 -06:00
firestar5683 1a77897c3a no sascm
Revert "no sascm"

This reverts commit 147e752007c6a4f80eec551934bc1cf86510e6dc.

Update interface.py
2026-02-17 20:49:33 -06:00
firestar5683 b5d39c4bc1 Truck Tuning 2026-02-17 19:57:35 -06:00
firestar5683 d4d78c9435 Update frogpilot_acceleration.py 2026-02-17 12:28:29 -06:00
firestar5683 2f29d7ee04 EV Tuning 2026-02-16 23:43:46 -06:00
firestar5683 338a07973b Truck Tuning 2026-02-16 23:39:45 -06:00
firestar5683 f4da6a3e3b silverado 2026-02-16 17:28:35 -06:00
firestar5683 5fbaebc91c brake mismatches 2026-02-16 13:25:23 -06:00
firestarsdog 7dbdb33ed1 Gen2ACC Distance Button Sync for CC_ONLY_CAR 2026-02-15 23:33:15 -06:00
firestar5683 a1cd4954b1 cancel acc bolt with pedal 2026-02-15 23:15:48 -06:00
firestar5683 2bbffc2a38 cruisestatus acc pedal 2026-02-14 22:44:33 -06:00
firestar5683 4d7aee4935 pedal 2026-02-14 15:58:32 -06:00
firestarsdog 41b055d140 Update values.py 2026-02-14 16:24:10 -05:00
firestarsdog bec0c04334 Evil +? 2026-02-14 13:02:22 -05:00
firestar5683 a8499d20fc Separate Non-ACC vs ACC + Pedal 2026-02-13 23:48:41 -06:00
firestar5683 6d0c17f43a Holiday 2026-02-13 22:45:30 -06:00
firestar5683 6dec58237b Fingerprinting 2026-02-13 22:35:10 -06:00
firestarsdog 6a47be1631 Hondadays 2026-02-13 21:38:01 -05:00
firestarsdog 8ea080b4cb Honda attempt from bugsink 2026-02-13 15:35:17 -05:00
firestarsdog a51503f2e4 BugSink Catch - human_lane_changes 2026-02-13 11:16:12 -05:00
firestar5683 07c722553d The Great Merge 2026-02-13 01:52:05 -06:00
firestar5683 14b1793c79 User adjustable offsets 2026-02-07 23:32:26 -06:00
firestar5683 055c223ded Integrator Smooth On Handoff 2026-02-06 15:46:56 -06:00
firestar5683 4d406112f7 Lights 2026-02-05 22:39:42 -06:00
firestar5683 e03f28bba0 New Models 2026-02-05 15:05:18 -06:00
firestar5683 f0cc963c0e Revert "merry christmas"
This reverts commit 8a9d0e5e6a.
2026-02-05 13:33:40 -06:00
firestar5683 cd39ce0305 Increase Fault Resilience 2026-02-05 13:15:39 -06:00
firestarsdog 749b5e06fb Stats 2026-02-02 01:04:19 -05:00
firestarsdog 23121cca0e Stats 2026-02-01 22:46:13 -06:00
firestar5683 6f0502e862 friction threshold 2026-01-29 11:28:30 -06:00
firestar5683 04c59fb18e Lat Updates 2026-01-28 23:51:36 -06:00
firestar5683 844dce2030 Update interface.py 2026-01-20 22:08:07 -06:00
firestar5683 aa77db1fc9 Update override.toml 2026-01-19 21:18:53 -06:00
firestar5683 bee9b2b046 Revert "Mac Update"
This reverts commit 1b5d776464.
2026-01-19 11:43:31 -06:00
firestarsdog 469cd5e528 Add SASCM to vehicle settings detection/stats 2026-01-19 10:32:21 -06:00
firestar5683 23f52644ae Update interface.py 2026-01-18 23:45:59 -06:00
firestar5683 e76b82603b Update interface.py 2026-01-18 23:44:42 -06:00
firestar5683 1b5d776464 Mac Update 2026-01-18 22:22:06 -06:00
firestarsdog 61f4e8b142 Stats 2026-01-18 22:17:42 -06:00
firestar5683 f0fefb6e13 Torque Controller Update 2026-01-18 21:58:43 -06:00
firestar5683 25a7090c3d Update redneck 2026-01-15 22:54:15 -06:00
firestar5683 b0de4d64b4 Split Tune 2026-01-15 19:17:55 -06:00
firestar5683 77ae5b176a fix redneck v2 2026-01-14 13:58:22 -06:00
firestar5683 89de28682c Remove lat smooth seconds 2026-01-14 13:49:35 -06:00
firestar5683 a1d4dcb9a0 frogpilot migration 2026-01-12 22:10:40 -06:00
firestar5683 c8deae75b7 More defaults 2026-01-12 22:04:19 -06:00
firestar5683 8653fb41f2 Update defaults 2026-01-12 22:00:04 -06:00
firestar5683 c064197fde Big Mac 2026-01-11 16:58:25 -06:00
firestar5683 7f878ec1dc Try Higher Friction 2026-01-10 14:04:32 -06:00
firestar5683 140bdd59fb Autotune Off 2026-01-09 22:40:45 -06:00
firestar5683 6f728f935a Revert "Pedal Braking Limits"
This reverts commit 568b72095f.
2025-12-31 20:55:13 -06:00
firestar5683 568b72095f Pedal Braking Limits 2025-12-30 20:17:59 -06:00
firestar5683 0aaf4d1336 merry christmas 2025-12-24 22:04:58 -06:00
firestar5683 bb3f0a3354 ds2 2025-12-24 21:28:40 -06:00
firestar5683 f2aba355b9 Try friction adjustment 2025-12-16 14:38:29 -06:00
firestar5683 e6513efb9b Update frogpilot_tracking.py 2025-12-15 00:55:51 -06:00
firestar5683 872a2c58aa minsteer speed 2025-12-14 14:46:27 -06:00
firestar5683 1630560715 Update Percentages 2025-12-13 19:13:44 -06:00
firestar5683 4cfd893837 Trailer Load Gas Tuning 2025-12-12 10:50:11 -06:00
firestar5683 0e93ceef00 Live Friction 2025-12-11 16:54:11 -06:00
firestar5683 d626f0fe49 Zero error 2025-12-11 15:12:29 -06:00
firestar5683 8dae3d375c Updates
Update latcontrol_torque.py

interp friction threshold
2025-12-09 19:58:46 -06:00
firestar5683 721d39ef1f Change NoEntryAlert message to 'Reverse Gear' 2025-12-06 16:44:30 -06:00
firestar5683 a97c5742b7 Update events.py 2025-12-05 20:13:49 -06:00
firestar5683 488f821983 LattyBoi2.0 2025-12-03 23:30:35 -06:00
firestar5683 190dc98180 Latty Boi
Revert "Latty Boi"

This reverts commit af687e501cc4bdcda7840453d06595d7ea674148.

Reapply "Latty Boi"

This reverts commit ff5566d4439f5e4997fe83f4f21d0be62b75d75d.
2025-12-02 20:29:48 -06:00
firestar5683 0b459d9f0b Recovery Power 2025-12-02 17:18:43 -06:00
firestar5683 3cd594bbe4 New planplus 2025-12-02 12:09:05 -06:00
Woohyun Rho f31082ce2b Update 2025-11-28 17:10:45 -06:00
firestar5683 b51d7ae35d Update interface.py 2025-11-18 20:48:48 -06:00
firestar5683 884fcb8315 New Lateral Changes 2025-11-18 20:48:21 -06:00
firestar5683 77597e60b2 Update 2025-11-15 14:44:07 -06:00
firestar5683 f2fbc8f30b Accel Tests 2025-11-14 20:32:47 -06:00
firestar5683 fffea614a4 Paddle Planner 2025-11-14 01:26:06 -06:00
firestar5683 e7e593cdb2 Revert "Upstream Lateral"
This reverts commit 97f8736605.
2025-11-10 23:07:02 -06:00
firestar5683 97f8736605 Upstream Lateral
Revert "Upstream Lateral"

This reverts commit 20f7d6631bb152860781b46533bb0e96223be132.

Reapply "Upstream Lateral"

This reverts commit 2a7a563e219a1b688529d341919fc377fed5038e.

Update latcontrol_torque.py

more lateral

Update ui
2025-11-07 00:13:34 -06:00
firestar5683 48efc5b97a Update manager.py 2025-10-27 00:58:28 -05:00
firestar5683 4cca6553e4 Update 2025-10-25 15:35:55 -05:00
firestar5683 6eda52b562 Medium Fanta 2025-10-22 22:18:08 -05:00
firestar5683 de9cbc5de6 Update interfaces.py 2025-10-20 13:49:08 -05:00
firestar5683 fee241dc0b no nnff 2025-10-19 17:59:36 -05:00
firestar5683 2094be9a7a Scene Complexity 2025-10-18 14:24:48 -05:00
firestar5683 6ce476025d Update carcontroller.py 2025-10-17 19:10:41 -05:00
firestar5683 dd6c62dbf6 Update interface.py 2025-10-17 18:13:24 -05:00
firestar5683 2ab19dbd4c CEM 2025-10-17 17:39:06 -05:00
firestar5683 3241fbdd8c lite 2025-10-17 17:13:37 -05:00
firestar5683 1f0350e66a Redneck 2.0 2025-10-17 16:55:56 -05:00
firestar5683 ce46776439 Modify torque tuning parameters in interfaces.py
Adjusted torque tuning parameters for improved performance.
2025-10-17 16:53:54 -05:00
firestar5683 afb209a1a8 Hurts Donut 2025-10-16 23:57:08 -05:00
firestar5683 b4e29d4ed6 Smoothy Boi 2025-10-16 23:35:26 -05:00
firestar5683 dada0f2ae6 New change 2025-10-15 22:59:04 -05:00
firestar5683 4532e7f5d1 lat3 2025-10-15 22:20:35 -05:00
firestar5683 78de442ee1 New Lateral Changes 2025-10-10 20:59:23 -05:00
firestar5683 953b343e06 Fix Standard 2025-10-10 19:24:24 -05:00
firestar5683 5b26be2e3c error? 2025-10-08 23:13:32 -05:00
firestar5683 4abe50405c Update interface.py 2025-10-08 23:07:37 -05:00
firestar5683 f2e705413c Fix New Devices 2025-10-08 22:19:43 -05:00
firestar5683 da56f26105 No positive P-response for long control if user-selected parameter set 2025-10-08 07:42:32 -05:00
firestar5683 ab39266180 Automatic updates 2025-10-04 16:04:35 -05:00
firestar5683 dac85dfd3a Steer Alerts 2025-10-04 01:49:22 -05:00
firestar5683 7e539cde38 Update frogpilot_acceleration.py 2025-10-03 22:42:08 -05:00
firestar5683 16a79fbc3d SteerAlerts
Revert "SteerAlerts"

This reverts commit cbaba399c5b9caac5a8faf3917f0929d747a0acc.

Update controlsd.py
2025-10-03 22:14:25 -05:00
firestar5683 9cdf0120cf Donut DM 2025-10-03 20:50:00 -05:00
firestar5683 aa0f0aaac8 Update gm_global_a_powertrain_generated.dbc 2025-10-03 16:41:22 -05:00
firestar5683 aefaafb616 Update 2025-10-03 00:43:40 -05:00
firestar5683 388f63168f firehose 2025-09-30 20:45:47 -05:00
firestar5683 7fe40f6e7f Update 2025-09-30 14:30:04 -05:00
firestar5683 eb2218e733 Dom 2025-09-30 09:25:14 -05:00
1247 changed files with 326655 additions and 457754 deletions
+74 -252
View File
@@ -1,252 +1,74 @@
<div align="center" style="text-align: center;">
<h1>openpilot</h1>
<p>
<b>openpilot is an operating system for robotics.</b>
<br>
Currently, it upgrades the driver assistance system in 300+ supported cars.
</p>
<h3>
<a href="https://docs.comma.ai">Docs</a>
<span> · </span>
<a href="https://docs.comma.ai/contributing/roadmap/">Roadmap</a>
<span> · </span>
<a href="https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md">Contribute</a>
<span> · </span>
<a href="https://discord.comma.ai">Community</a>
<span> · </span>
<a href="https://comma.ai/shop">Try it on a comma 3X</a>
</h3>
Quick start: `bash <(curl -fsSL openpilot.comma.ai)`
[![openpilot tests](https://github.com/commaai/openpilot/actions/workflows/selfdrive_tests.yaml/badge.svg)](https://github.com/commaai/openpilot/actions/workflows/selfdrive_tests.yaml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![X Follow](https://img.shields.io/twitter/follow/comma_ai)](https://x.com/comma_ai)
[![Discord](https://img.shields.io/discord/469524606043160576)](https://discord.comma.ai)
</div>
<table>
<tr>
<td><a href="https://youtu.be/NmBfgOanCyk" title="Video By Greer Viau"><img src="https://github.com/commaai/openpilot/assets/8762862/2f7112ae-f748-4f39-b617-fabd689c3772"></a></td>
<td><a href="https://youtu.be/VHKyqZ7t8Gw" title="Video By Logan LeGrand"><img src="https://github.com/commaai/openpilot/assets/8762862/92351544-2833-40d7-9e0b-7ef7ae37ec4c"></a></td>
<td><a href="https://youtu.be/SUIZYzxtMQs" title="A drive to Taco Bell"><img src="https://github.com/commaai/openpilot/assets/8762862/05ceefc5-2628-439c-a9b2-89ce77dc6f63"></a></td>
</tr>
</table>
Using openpilot in a car
------
To use openpilot in a car, you need four things:
1. **Supported Device:** a comma 3/3X, available at [comma.ai/shop](https://comma.ai/shop/comma-3x).
2. **Software:** The setup procedure for the comma 3/3X allows users to enter a URL for custom software. Use the URL `openpilot.comma.ai` to install the release version.
3. **Supported Car:** Ensure that you have one of [the 275+ supported cars](docs/CARS.md).
4. **Car Harness:** You will also need a [car harness](https://comma.ai/shop/car-harness) to connect your comma 3/3X to your car.
We have detailed instructions for [how to install the harness and device in a car](https://comma.ai/setup). Note that it's possible to run openpilot on [other hardware](https://blog.comma.ai/self-driving-car-for-free/), although it's not plug-and-play.
------
<div align="center" style="text-align: center;">
<h1>FrogPilot 🐸</h1>
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/FrogAi/FrogPilot)
[![Discord](https://img.shields.io/discord/1137853399715549214?label=Discord)](https://discord.frogpilot.download)
[![Last Updated](https://img.shields.io/badge/Last%20Updated-August%209th%2C%202025-brightgreen)](https://github.com/FrogAi/FrogPilot/releases/latest)
[![Wiki](https://img.shields.io/badge/Wiki-FrogPilot-blue?logo=wiki)](https://frogpilot.wiki.gg/)
</div>
------
**FrogPilot** is a custom frog-themed fork of openpilot that embraces a collaborative, community-driven approach to push the project forward. It delivers bleeding-edge features and experimental improvements far ahead of official releases. As an unofficial and highly experimental version of openpilot, **FrogPilot** should always be used with caution!
openpilot vs **FrogPilot**
------
#### Community
| Feature | openpilot | **FrogPilot** |
|---------|:---------:|:---------:|
| A Welcoming Community | ❌ | ✅ |
| Erich / Primary Moderators / 🦇 | ✅ | ❌ |
#### Core Features
| Feature | openpilot | **FrogPilot** |
|---------|:---------:|:---------:|
| Always On Lateral (Steering) | ❌ | ✅ |
| Blind Spot Integration | ✅ | ✅ |
| Conditional Experimental Mode | ❌ | ✅ |
| Custom Themes | ❌ | ✅ |
| Driver Monitoring | ✅ | ✅ |
| Driving Model Selector | ❌ | ✅ |
| Holiday Themes | ❌ | ✅ |
| Speed Limit Support | ❌ | ✅ |
| Weather Detection | ❌ | ✅ |
#### Device & Hardware
| Feature | openpilot | **FrogPilot** |
|---------|:---------:|:---------:|
| Advanced Volume Controller | ❌ | ✅ |
| Automatic Version Backups | ❌ | ✅ |
| C3 Support | ❌ | ✅ |
| comma Pedal Support | ❌ | ✅ |
| High Quality Recordings | ❌ | ✅ |
| SDSU Support | ❌ | ✅ |
| ZSS Support | ❌ | ✅ |
#### Gas/Brake
| Feature | openpilot | **FrogPilot** |
|---------|:---------:|:---------:|
| Adaptive Cruise Control (ACC) | ✅ | ✅ |
| Advanced Live Tuning | ❌ | ✅ |
| Custom Following Distances | ❌ | ✅ |
| Faster Human-Like Acceleration | ❌ | ✅ |
| Human-Like Speed Control in Curves | ❌ | ✅ |
| Smoother Human-Like Braking | ❌ | ✅ |
#### Steering
| Feature | openpilot | **FrogPilot** |
|---------|:---------:|:---------:|
| Advanced Live Tuning | ❌ | ✅ |
| Automatic Lane Changes | ❌ | ✅ |
| Increased Steering Torque* | ❌ | ✅ |
| Lane Centering (LKAS) | ✅ | ✅ |
| Lane Change Assist | ✅ | ✅ |
*Select vehicles only
And much much more!
🌟 Highlight Features
------
### 🚗 Always On Lateral (AOL)
With **"Always On Lateral"**, lane-centering stays active whenever cruise control is on, even when you press the accelerator or brake. This means steering assist won't cut out during manual speed adjustments giving you continuous support through curves, traffic, or mountain roads!
---
### 🧠 Conditional Experimental Mode (CEM)
**["Experimental Mode"](https://blog.comma.ai/090release/#experimental-mode)** lets openpilot drive at the speed it thinks a human would to allow slowing for curves, stopping at stoplights/stop signs, and adapting to traffic. This makes it powerful in complex scenarios, but it's still, well, "experimental" and less predictable than **"Chill Mode"**. But **"Conditional Experimental Mode"** gives you the best of both worlds by automatically switching between **"Chill Mode"** for steady cruising and **"Experimental Mode"** for more advanced situations to help fully automate your driving experience!
**"Conditional Experimental Mode"** switches into **"Experimental Mode"** when conditions like these are met:
- Approaching curves and turns
- Detecting slower or stopped lead vehicles
- Driving below a set speed
- Predicting an upcoming stop (e.g. stoplight or stop sign)
Once conditions clear it returns to **"Chill Mode"** for stability and predictability.
**Note: Stay attentive as "Experimental Mode" is an alpha feature and mistakes are expected!**
---
### 🎭 Driving Personalities
With **"Driving Personalities"**, you choose how the vehicle behaves with four adjustable profiles:
- **Traffic:** Catered towards stop-and-go traffic by minimizing gaps and delays
- **Aggressive:** Aimed to provide tighter following distances and quicker reactions
- **Standard:** Useful for a balanced, all-purpose driving
- **Relaxed:** A smoother driving experience with larger following distance gaps
Each profile can be fine-tuned to change the desired following distance, acceleration, and braking style letting you shape **FrogPilot**'s behavior to match your own driving preferences! Profiles can be switched instantly using the following distance button on the steering wheel, while **"Traffic Mode"** can be enabled by simply holding down the following distance button.
---
### 📏 Speed Limit Controller (SLC)
With **"Speed Limit Controller"**, **FrogPilot** automatically adapts to the road's posted speed using information from downloaded **["OpenStreetMap"](https://www.openstreetmap.org)** maps, online **["Mapbox"](https://www.mapbox.com)** data, and the vehicle's dashboard (if supported).
Offsets let you fine-tune how closely **FrogPilot** follows posted limits across different speed ranges allowing you to cruise slightly above or below for a more natural driving experience. If no speed limit is available, you can choose whether **FrogPilot** drives at the set speed, falls back to the last known speed limit, or uses **"Experimental Mode"** to estimate one with the driving model.
Maps can be downloaded directly in settings and updated automatically on a schedule ensuring your device always has the latest speed limits!
**Note: Speed limits are only as accurate as the available speed limit data. Always stay attentive and adjust your speed when necessary!**
---
### 🎨 Themes
With **"Themes"**, you can personalize **FrogPilot**'s driving screen to make it uniquely yours! Choose from:
- **Color Schemes**
- **Icon Packs**
- **Sound Packs**
- **Turn Signal Animations**
- **Steering Wheel Icons**
Enjoy pre-existing **FrogPilot** and seasonal holiday themes, or you can create your own with the **"Theme Maker"** and even share them with the community! For extra fun, enable features like the Mario Kartstyle **"Rainbow Path"** or **"Random Events"** that add playful visual effects while you drive!
---
And lots more! From safety enhancements to personalization options, **FrogPilot** continues to evolve with features that put you in control. Check it out today for yourself!
---
🔧 Branches
------
| Branch | Install&nbsp;URL | Description | Recommended&nbsp;For |
|----------------------------|---------------------------|--------------------------------------------------------|--------------------------|
| FrogPilot | frogpilot.download | The main release branch. | Everyone |
| FrogPilot&#8209;Staging | staging.frogpilot.download| Beta branch with upcoming features. Expect bugs! | Early&nbsp;Adopters |
| FrogPilot&#8209;Testing | testing.frogpilot.download| Alpha branch with bleeding-edge features. Breaks often!| Advanced&nbsp;Testers |
| FrogPilot&#8209;Development| No :) | Active development branch. Don't use! | **FrogPilot**&nbsp;Developers|
| MAKE&#8209;PRS&#8209;HERE | No :) | Workspace for pull requests. Don't use! | Contributors |
🧰 How to Install
------
The easiest way to install **FrogPilot** is by entering this URL on the installation screen:
```
frogpilot.download
```
**DO NOT** install the **FrogPilot-Development** branch. I'm constantly breaking things on there, so unless you don't want to use **FrogPilot**, **NEVER** install it!
![](https://i.imgur.com/LTCqRqB.png)
🐞 Bug Reports / Feature Requests
------
If you run into bugs, issues, or have ideas for new features, please post about it on the **[FrogPilot Discord](https://discord.gg/frogpilot)**! Feedback helps improve **FrogPilot** and create a better experience for everyone!
To report a bug, please post it in [**#bug-reports**](https://discord.com/channels/1137853399715549214/1162100167110053888).
To request a feature, please post it in [**#feature-requests**](https://discord.com/channels/1137853399715549214/1160318669839147259).
Please include as much detail as possible! Photos, videos, log files, or anything that can help explain the issue or idea are very helpful!
I'll do my best to respond promptly, but not every request can be addressed right away. Your feedback is always appreciated and helps make **FrogPilot** the best it can be!
📋 Credits
------
* [Aidenir](https://github.com/Aidenir)
* [AlexandreSato](https://github.com/AlexandreSato)
* [cfranyota](https://github.com/cfranyota)
* [cydia2020](https://github.com/cydia2020)
* [dragonpilot-community](https://github.com/dragonpilot-community)
* [ErichMoraga](https://github.com/ErichMoraga)
* [garrettpall](https://github.com/garrettpall)
* [jakethesnake420](https://github.com/jakethesnake420)
* [jyoung8607](https://github.com/jyoung8607)
* [mike8643](https://github.com/mike8643)
* [neokii](https://github.com/neokii)
* [OPGM](https://github.com/opgm)
* [OPKR](https://github.com/openpilotkr)
* [pfeiferj](https://github.com/pfeiferj)
* [realfast](https://github.com/realfast)
* [syncword](https://github.com/syncword)
* [twilsonco](https://github.com/twilsonco)
Star History
------
[![Star History Chart](https://api.star-history.com/svg?repos=FrogAi/FrogPilot&type=Date)](https://www.star-history.com/#FrogAi/FrogPilot&Date)
# StarPilot
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/firestar5683/StarPilot)
[![Discord](https://img.shields.io/discord/1137853399715549214?label=Discord)](https://firestar.link/discord)
[![Last Updated](https://img.shields.io/github/last-commit/firestar5683/StarPilot/StarPilot)](https://github.com/firestar5683/StarPilot)
[![Wiki](https://img.shields.io/badge/Wiki-StarPilot-blue?logo=wiki)](https://wiki.firestar.link)
**StarPilot** is a custom fork of [comma.ai's openpilot](https://comma.ai/openpilot),
an open source driver assistance system.
Openpilot provides
* Automated Lane Centering
* Adaptive Cruise Control
* Lane Change Assist
* Driver Monitoring *without wheel nags*
StarPilot adds support for many GM vehicles along with improved tuning,
especially for radar-less (camera only) vehicles.
StarPilot is built off of [FrogPilot](https://github.com/FrogAi/FrogPilot)
and supports the major features FrogPilot offers.
StarPilot has a vibrant, welcoming community [discord](https://firestar.link/discord).
Stop by to chat or ask questions!
## Documentation
Please see [https://wiki.firestar.link](https://wiki.firestar.link) for hardware lists,
installation guides, and software configuration.
## Features
* Full support for Comma C3, C3X, and C4
* C4 is currently in release testing. Join our fleet of C4 testers!
* Model switcher with all of comma's tinygrad driving models
* Special longitudinal planner tuning for VoACC (visual only, radar-less) vehicles
* Galaxy: StarPilot's portal to configure your comma device using your phone from anywhere.
Download models, change settings, update software, visualize live model outputs for tuning.
* Always On Lateral (full time steering assist)*
* Learning Speed Limit Controller*
* Conditional Experimental Mode (CEM)*
* Driving Profiles*
* Custom themes*
* Alert Volume Controller*
* Comma Pedal Interceptor support*
* Toyota SDSU support*
* ZSS support*
* High quality dashcam recordings*
* Enhanced tuning for CEM (dynamic experimental mode switching)
\* [Inherited from FrogPilot](https://github.com/FrogAi/FrogPilot#openpilot-vs-frogpilot)
## GM-only Features
* Increased LKAS fault resiliency
* ASCM_INT and SASCM support
* Custom lateral torque controller, with special tuning for Bolts
* 50% extra torque on 2017 Chevy Bolt
* Improved lateral and longitudinal tuning
* Dashboard cruise control display speed spoofing for vehicles with pedal interceptor
* Extra steering wheel button functionality for vehicles with pedal interceptor
* Optional toggle to boot comma when remote starting your vehicle
## Developer Features
* Native and cross compilation for Windows, Mac, and Ubuntu
* Custom AGNOS to support C3, C3X, and C4
* To run UI on PC:
* `./c3` for large UI
* `./c4` for small UI
* `./build` to produce cross compiled binaries for comma devices.
Uses your comma's sysroot/toolchain
* Toggle: "Use Precompiled Binaries" to allow switching between fast boot / editable builds
* Custom long maneuver tests, specifically designed for regen-only vehicles
+12 -1
View File
@@ -164,6 +164,9 @@ std::unordered_map<std::string, uint32_t> keys = {
{"LiveDelay", PERSISTENT},
{"LiveParameters", PERSISTENT},
{"LiveTorqueParameters", PERSISTENT | DONT_LOG},
{"LongitudinalManeuverMode", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
{"LongitudinalManeuverPaddleMode", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
{"LongitudinalManeuverStatus", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
{"LongitudinalPersonality", PERSISTENT},
{"NavDestination", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
{"NavDestinationWaypoints", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION},
@@ -235,6 +238,7 @@ std::unordered_map<std::string, uint32_t> keys = {
{"AlwaysOnLateral", PERSISTENT},
{"AlwaysOnLateralLKAS", PERSISTENT},
{"AlwaysOnLateralMain", PERSISTENT},
{"AlwaysAllowUploads", PERSISTENT},
{"AMapKey1", PERSISTENT | DONT_LOG},
{"AMapKey2", PERSISTENT | DONT_LOG},
{"ApiCache_DriveStats", PERSISTENT},
@@ -247,6 +251,7 @@ std::unordered_map<std::string, uint32_t> keys = {
{"BlacklistedModels", PERSISTENT},
{"BlindSpotMetrics", PERSISTENT},
{"BlindSpotPath", PERSISTENT},
{"BootLogo", PERSISTENT},
{"BorderMetrics", PERSISTENT},
{"CalibratedLateralAcceleration", PERSISTENT},
{"CalibrationProgress", PERSISTENT},
@@ -272,6 +277,7 @@ std::unordered_map<std::string, uint32_t> keys = {
{"CEStatus", CLEAR_ON_OFFROAD_TRANSITION},
{"CEStoppedLead", PERSISTENT},
{"ClusterOffset", PERSISTENT},
{"BootLogoToDownload", CLEAR_ON_MANAGER_START},
{"ColorToDownload", CLEAR_ON_MANAGER_START},
{"Compass", PERSISTENT},
{"ConditionalExperimental", PERSISTENT},
@@ -310,6 +316,7 @@ std::unordered_map<std::string, uint32_t> keys = {
{"DisengageVolume", PERSISTENT},
{"DoToggleReset", PERSISTENT},
{"DoToggleResetStock", PERSISTENT},
{"DownloadableBootLogos", PERSISTENT},
{"DownloadableColors", PERSISTENT},
{"DownloadableDistanceIcons", PERSISTENT},
{"DownloadableIcons", PERSISTENT},
@@ -321,8 +328,8 @@ std::unordered_map<std::string, uint32_t> keys = {
{"DynamicPathWidth", PERSISTENT},
{"DynamicPedalsOnUI", PERSISTENT},
{"EngageVolume", PERSISTENT},
{"ExperimentalGMTune", PERSISTENT},
{"EVTuning", PERSISTENT},
{"TruckTuning", PERSISTENT},
{"Fahrenheit", PERSISTENT},
{"FavoriteDestinations", PERSISTENT | DONT_LOG},
{"FlashPanda", CLEAR_ON_MANAGER_START},
@@ -350,6 +357,7 @@ std::unordered_map<std::string, uint32_t> keys = {
{"GasRegenCmd", PERSISTENT},
{"GMPedalLongitudinal", PERSISTENT},
{"GoatScream", PERSISTENT},
{"GoatScreamCriticalAlerts", PERSISTENT},
{"GreenLightAlert", PERSISTENT},
{"HideAlerts", PERSISTENT},
{"HideLeadMarker", PERSISTENT},
@@ -469,6 +477,9 @@ std::unordered_map<std::string, uint32_t> keys = {
{"RoadEdgesWidth", PERSISTENT},
{"RoadName", CLEAR_ON_MANAGER_START},
{"RoadNameUI", PERSISTENT},
{"RedPanda", PERSISTENT},
{"RemapCancelToDistance", PERSISTENT},
{"RemoteStartBootsComma", PERSISTENT},
{"RotatingWheel", PERSISTENT},
{"ScreenBrightness", PERSISTENT},
{"ScreenBrightnessOnroad", PERSISTENT},
Binary file not shown.
+28 -11
View File
@@ -29,13 +29,15 @@ def check_github_rate_limit():
print(f"Error checking GitHub rate limit: {error}")
return False
def download_file(cancel_param, destination, progress_param, url, download_param, params_memory):
def download_file(cancel_param, destination, progress_param, url, download_param, params_memory, allow_unknown_size=False, suppress_errors=False):
try:
destination.parent.mkdir(parents=True, exist_ok=True)
total_size = get_remote_file_size(url)
if total_size == 0:
total_size = get_remote_file_size(url, suppress_errors=suppress_errors or allow_unknown_size)
if total_size == 0 and not allow_unknown_size:
if not url.endswith(".gif"):
if suppress_errors:
return
handle_error(None, "Download invalid...", "Download invalid...", download_param, progress_param, params_memory)
return
@@ -56,24 +58,30 @@ def download_file(cancel_param, destination, progress_param, url, download_param
temp_file.write(chunk)
downloaded_size += len(chunk)
progress = (downloaded_size / total_size) * 100
if progress != 100:
params_memory.put(progress_param, f"{progress:.0f}%")
else:
if total_size > 0:
progress = (downloaded_size / total_size) * 100
if progress != 100:
params_memory.put(progress_param, f"{progress:.0f}%")
else:
params_memory.put(progress_param, "Verifying authenticity...")
elif downloaded_size > 0:
params_memory.put(progress_param, "Verifying authenticity...")
temp_file_path.rename(destination)
except Exception as error:
if suppress_errors:
return
handle_request_error(error, destination, download_param, progress_param, params_memory)
def get_remote_file_size(url):
def get_remote_file_size(url, suppress_errors=False):
try:
response = requests.head(url, headers={"Accept-Encoding": "identity"}, timeout=10)
response.raise_for_status()
return int(response.headers.get("Content-Length", 0))
except Exception as error:
handle_request_error(error, None, None, None, None)
if not suppress_errors:
handle_request_error(error, None, None, None, None)
return 0
def get_repository_url():
@@ -104,8 +112,17 @@ def handle_request_error(error, destination, download_param, progress_param, par
error_message = error_map.get(type(error), "Unexpected error")
handle_error(destination, f"Failed: {error_message}", error, download_param, progress_param, params_memory)
def verify_download(file_path, url):
remote_file_size = get_remote_file_size(url)
def verify_download(file_path, url, allow_unknown_size=False):
remote_file_size = get_remote_file_size(url, suppress_errors=allow_unknown_size)
if remote_file_size == 0 and allow_unknown_size:
if not file_path.is_file():
print(f"File not found: {file_path}")
return False
if file_path.stat().st_size == 0:
print(f"File is empty: {file_path}")
return False
return True
if remote_file_size == 0:
print(f"Error fetching remote size for {file_path}")
+1 -1
View File
@@ -13,7 +13,7 @@ from openpilot.frogpilot.assets.download_functions import GITLAB_URL, download_f
from openpilot.frogpilot.common.frogpilot_utilities import delete_file
from openpilot.frogpilot.common.frogpilot_variables import DEFAULT_MODEL, MODELS_PATH, params, params_default, params_memory
VERSION = "v20"
VERSION = "v21"
CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress"
@@ -0,0 +1 @@
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
@@ -0,0 +1 @@
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
@@ -0,0 +1 @@
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
@@ -0,0 +1 @@
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
@@ -0,0 +1 @@
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
@@ -0,0 +1 @@
{"input_std":[[9.342214],[1.5915664],[0.60113484],[0.048193663],[1.5680411],[1.57577],[1.5836853],[1.5711677],[1.5445132],[1.5007596],[1.4529978],[0.047915205],[0.04800539],[0.04808892],[0.04822398],[0.04817677],[0.047881734],[0.047405947]],"model_test_loss":0.019342588260769844,"input_size":18,"current_date_and_time":"2023-08-05_06-09-11","input_mean":[[22.757933],[-0.016342578],[-0.001405228],[-0.014619173],[-0.018091483],[-0.018382493],[-0.019270267],[-0.018759886],[-0.019559544],[-0.017848592],[-0.020014366],[-0.014564899],[-0.01457757],[-0.014600966],[-0.014757987],[-0.014915743],[-0.015121007],[-0.015359475]],"input_vars":["v_ego","lateral_accel","lateral_jerk","roll","lateral_accel_m03","lateral_accel_m02","lateral_accel_m01","lateral_accel_p03","lateral_accel_p06","lateral_accel_p10","lateral_accel_p15","roll_m03","roll_m02","roll_m01","roll_p03","roll_p06","roll_p10","roll_p15"],"output_size":1,"layers":[{"dense_1_b":[[-0.045577038],[-2.8160777],[-0.21418032],[2.9552083],[-0.06852827],[0.029141279],[-0.053249933]],"dense_1_W":[[0.0010664682,1.3813498,-8.153277,-0.009101015,-0.1484779,1.0008405,-1.5930991,-1.195274,0.6136762,0.3560462,-0.30077773,0.93531257,0.14550218,-0.40074933,-0.21716364,0.13834935,-0.0658936,-0.49427196],[-0.7338253,-0.037501235,-0.49922377,-1.0526338,-0.46141604,-1.3649396,0.7309814,-0.91667104,0.044683233,-0.18628967,-0.20878822,-0.2678598,0.48446324,0.51164204,0.09547851,0.41491362,-0.4524314,0.16594785],[-0.0032175342,3.5451593,-0.11421584,-0.2511033,0.27893174,0.6384996,0.80790603,1.3835542,1.8000495,2.249461,1.4026878,0.7851167,-0.35052946,0.041820426,-0.39790183,0.45703772,-0.30459648,0.18287674],[0.72289664,-0.77138704,-0.5025135,-0.49323097,-0.39893976,-0.8779149,0.6848096,-0.58729875,-0.16643623,-0.14427555,-0.15013328,-0.11467142,-0.27280045,0.48508415,0.52080667,-0.0029569597,-0.28170735,0.06367212],[0.00014026981,-2.0353239,0.0060915067,0.4794941,1.1882906,-1.512865,0.80642134,-0.26044324,-0.56213886,-0.365122,0.55078083,-0.34897435,-0.32031298,0.46106994,0.37792024,-0.14116105,0.15597256,-0.32811671],[0.0011494387,1.9060265,8.3941965,-0.3078165,-0.9530427,0.6589645,-1.2487054,-0.541133,0.13451256,0.2805466,-0.33187166,0.9900705,0.2821258,-0.51270896,-0.5130031,-0.28222615,0.042734932,0.27233443],[-0.0014444552,-1.0933362,0.003359957,-0.15052961,0.37165833,1.7087307,-1.4971043,0.673802,-0.108465396,-0.088505454,0.21561684,0.6252258,-0.15019403,-0.34727478,0.08958758,-0.1723621,-0.25557828,0.29778987]],"activation":"σ"},{"dense_2_W":[[0.20810607,-0.13481373,-0.15003344,-0.6067625,-0.31937903,-0.37380016,0.4666911],[0.71834767,0.15669753,-0.11389502,0.27131546,-0.060118295,0.039282244,0.52961355],[-0.27200606,0.43380752,-0.106262706,-0.49363258,0.35659012,-0.01706296,-1.0807354],[-0.79583454,-0.66428274,-0.42157334,-0.52979547,-0.2803004,-0.57637554,0.084149174],[0.1230394,-0.57023287,0.28639448,-0.506744,-0.9920808,0.7510901,0.8584937],[-0.41256845,0.35423175,0.13009231,0.5198333,0.7576573,-0.7053181,-0.3390053],[-0.15090485,0.33375353,-0.64164793,0.57386595,0.25700107,-0.15245672,-0.49020413],[-0.50120676,0.5210849,-0.3282951,0.4276323,1.0257447,-1.0398436,-0.8911315],[0.11402861,-0.41491523,0.247507,-0.39776865,-0.6089287,0.20646147,0.6692102],[0.09411492,0.31819808,0.41330767,0.024243973,0.2314893,0.08529045,0.14911193],[0.5179265,0.20473345,-0.36762783,-0.009045922,-0.19436747,-0.548099,-0.08656279],[-0.69121784,-0.14201449,0.3887621,0.10787631,1.0371574,-0.64747447,-0.73437464],[-0.07860244,-0.597764,0.019551078,0.010199122,-0.7493642,0.66619915,0.121666186]],"activation":"σ","dense_2_b":[[-0.04259814],[-0.053309314],[-0.28045595],[-0.23801634],[0.00944943],[-0.109921046],[-0.037457183],[0.24627604],[-0.0069056284],[-0.04725389],[-0.03361769],[0.044101644],[-0.0009850285]]},{"dense_3_W":[[-0.19631335,-0.47722518,-0.019853225,-0.29363275,-0.625923,0.35145283,0.62401354,0.14337404,-0.25707108,-0.51310915,0.2565006,0.61742216,-0.07952821],[0.5868974,-0.36786363,-0.0177682,-0.45121866,0.68608487,-0.55178356,-0.35236016,-0.7226455,-0.015394288,-0.10650332,0.5391039,0.17791761,0.34196886],[0.16821232,-0.35580373,0.26906335,0.41736925,-0.6979869,0.41801625,0.34707317,0.7784011,-0.10002936,0.32485273,-0.3644285,0.49092358,-0.43872768]],"activation":"identity","dense_3_b":[[-0.019481273],[0.031026587],[-0.03595322]]},{"dense_4_W":[[-0.72340566,0.21244061,-0.67248434]],"dense_4_b":[[0.026229527]],"activation":"identity"}]}
+140 -50
View File
@@ -37,6 +37,7 @@ HOLIDAY_SLUGS = {
}
THEME_COMPONENT_PARAMS = {
"boot_logos": "BootLogoToDownload",
"colors": "ColorToDownload",
"distance_icons": "DistanceIconToDownload",
"icons": "IconToDownload",
@@ -93,8 +94,16 @@ class ThemeManager:
steering_wheel_save_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(steering_wheel_image_path, steering_wheel_save_path)
default_boot_logo_path = Path(__file__).parent / "other_images/frogpilot_boot_logo.png"
boot_logo_save_path = THEME_SAVE_PATH / "bootlogos/starpilot.png"
boot_logo_save_path.parent.mkdir(parents=True, exist_ok=True)
if not boot_logo_save_path.exists():
shutil.copy2(default_boot_logo_path, boot_logo_save_path)
def download_theme(self, theme_component, theme_name, asset_param, frogpilot_toggles):
self.downloading_theme = True
allow_unknown_size = theme_component in {"boot_logos", "steering_wheels"}
name_candidates = [theme_name]
repo_url = get_repository_url()
if not repo_url:
@@ -102,7 +111,12 @@ class ThemeManager:
self.downloading_theme = False
return
if theme_component == "distance_icons":
if theme_component == "boot_logos":
download_link = f"{repo_url}/Themes/bootlogo"
download_path = THEME_SAVE_PATH / "bootlogos" / theme_name
extensions = [".png", ".jpg", ".jpeg"]
name_candidates = list(dict.fromkeys([theme_name, theme_name.replace("_", "-"), theme_name.replace("-", "_")]))
elif theme_component == "distance_icons":
download_link = f"{repo_url}/Distance-Icons/{theme_name}"
download_path = THEME_SAVE_PATH / "theme_packs" / theme_name / theme_component
extensions = [".zip"]
@@ -117,37 +131,40 @@ class ThemeManager:
for extension in extensions:
theme_path = download_path.with_suffix(extension)
theme_url = download_link + extension
theme_urls = [f"{download_link}/{candidate}{extension}" for candidate in name_candidates]
if theme_component != "boot_logos":
theme_urls = [download_link + extension]
delete_file(theme_path)
print(f"Downloading theme from GitHub: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory)
if params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
for theme_url in theme_urls:
delete_file(theme_path)
handle_error(None, "Download cancelled...", "Download cancelled...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
self.downloading_theme = False
return
print(f"Downloading theme from GitHub: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory, allow_unknown_size=allow_unknown_size, suppress_errors=allow_unknown_size)
if verify_download(theme_path, theme_url):
print(f"Theme {theme_name} downloaded and verified successfully from GitHub!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if params_memory.get_bool(CANCEL_DOWNLOAD_PARAM):
delete_file(theme_path)
handle_error(None, "Download cancelled...", "Download cancelled...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
if extension == ".zip":
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
self.downloading_theme = False
return
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
params_memory.remove(asset_param)
if verify_download(theme_path, theme_url, allow_unknown_size=allow_unknown_size):
print(f"Theme {theme_name} downloaded and verified successfully from GitHub!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
self.downloading_theme = False
if extension == ".zip":
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
self.update_themes(frogpilot_toggles)
return
elif self.handle_verification_failure(extension, theme_component, theme_name, asset_param, theme_path, download_path, frogpilot_toggles):
return
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
params_memory.remove(asset_param)
self.downloading_theme = False
self.update_themes(frogpilot_toggles)
return
elif self.handle_verification_failure(extension, theme_component, theme_name, asset_param, theme_path, download_path, frogpilot_toggles):
return
handle_error(download_path, "Download failed...", "Download failed...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
self.downloading_theme = False
@@ -158,7 +175,7 @@ class ThemeManager:
repo_encoded = quote_plus(RESOURCES_REPO)
assets = {"themes": {}, "wheels": []}
assets = {"boot_logos": [], "themes": {}, "wheels": []}
try:
def list_files(branch):
if is_github:
@@ -234,6 +251,20 @@ class ThemeManager:
theme_name, sub_path = item["path"].split("/", 1)
theme_path = sub_path.lower()
if theme_name.lower() == "bootlogo":
if Path(sub_path).suffix.lower() not in (".png", ".jpg", ".jpeg"):
continue
assets["boot_logos"].append(sub_path)
logo_name = Path(sub_path).stem
local_files = list((THEME_SAVE_PATH / "bootlogos").glob(f"{logo_name}.*"))
if local_files and expected_size > 0:
local_size = self.theme_sizes.get("boot_logos", {}).get(logo_name)
if local_size != expected_size:
print(f"boot logo {logo_name} is outdated, redownloading...")
self.download_theme("boot_logos", logo_name, THEME_COMPONENT_PARAMS["boot_logos"], frogpilot_toggles)
continue
for key in ("colors", "icons", "signals", "sounds"):
if key in theme_path:
assets["themes"].setdefault(theme_name, set()).add(key)
@@ -246,6 +277,7 @@ class ThemeManager:
self.download_theme(key, theme_name, THEME_COMPONENT_PARAMS[key], frogpilot_toggles)
break
assets["boot_logos"].sort()
assets["themes"] = {key: sorted(list(value)) for key, value in assets["themes"].items()}
assets["wheels"].sort()
return assets
@@ -265,7 +297,7 @@ class ThemeManager:
parts = base.replace("_", "-").split("-")
capitalized_parts = [part.capitalize() for part in parts if part]
if len(capitalized_parts) > 1 and component != "steering_wheels":
if len(capitalized_parts) > 1 and component not in {"boot_logos", "steering_wheels"}:
display = f"{capitalized_parts[0]} ({' '.join(capitalized_parts[1:])})"
else:
display = " ".join(capitalized_parts)
@@ -321,37 +353,44 @@ class ThemeManager:
}
def handle_verification_failure(self, extension, theme_component, theme_name, asset_param, theme_path, download_path, frogpilot_toggles):
if theme_component == "distance_icons":
allow_unknown_size = theme_component in {"boot_logos", "steering_wheels"}
if theme_component == "boot_logos":
download_link = f"{GITLAB_URL}/Themes/bootlogo"
name_candidates = list(dict.fromkeys([theme_name, theme_name.replace("_", "-"), theme_name.replace("-", "_")]))
elif theme_component == "distance_icons":
download_link = f"{GITLAB_URL}/Distance-Icons/{theme_name}"
name_candidates = [theme_name]
elif theme_component == "steering_wheels":
download_link = f"{GITLAB_URL}/Steering-Wheels/{theme_name}"
name_candidates = [theme_name]
else:
download_link = f"{GITLAB_URL}/Themes/{theme_name}/{theme_component}"
name_candidates = [theme_name]
delete_file(theme_path)
for candidate in name_candidates:
delete_file(theme_path)
theme_url = download_link + extension
print(f"Downloading theme from GitLab: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory)
theme_url = f"{download_link}/{candidate}{extension}" if theme_component == "boot_logos" else download_link + extension
print(f"Downloading theme from GitLab: {theme_name}")
download_file(CANCEL_DOWNLOAD_PARAM, theme_path, DOWNLOAD_PROGRESS_PARAM, theme_url, asset_param, params_memory, allow_unknown_size=allow_unknown_size, suppress_errors=allow_unknown_size)
if verify_download(theme_path, theme_url):
print(f"Theme {theme_name} downloaded and verified successfully from GitLab!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if verify_download(theme_path, theme_url, allow_unknown_size=allow_unknown_size):
print(f"Theme {theme_name} downloaded and verified successfully from GitLab!")
self.update_theme_size(theme_component, theme_name, theme_path.stat().st_size)
if extension == ".zip":
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
if extension == ".zip":
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Unpacking theme...")
extract_zip(theme_path, download_path)
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
params_memory.remove(asset_param)
params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloaded!")
params_memory.remove(asset_param)
self.downloading_theme = False
self.downloading_theme = False
self.update_themes(frogpilot_toggles)
return True
self.update_themes(frogpilot_toggles)
return True
handle_error(None, "Download failed...", "Download failed...", asset_param, DOWNLOAD_PROGRESS_PARAM, params_memory)
self.downloading_theme = False
# Let the caller continue trying alternate extensions before surfacing failure.
return False
@staticmethod
@@ -415,6 +454,8 @@ class ThemeManager:
return random.choice(candidates) if candidates else "stock"
def update_active_theme(self, time_validated, frogpilot_toggles, boot_run=False, randomize_theme=False):
boot_logo = getattr(frogpilot_toggles, "boot_logo", "stock")
if time_validated and frogpilot_toggles.holiday_themes:
self.holiday_theme = self.update_holiday()
else:
@@ -422,6 +463,7 @@ class ThemeManager:
if self.holiday_theme != "stock":
asset_mappings = {
"boot_logo": ("boot_logo", boot_logo),
"color_scheme": ("colors", self.holiday_theme),
"distance_icons": ("distance_icons", self.holiday_theme),
"icon_pack": ("icons", self.holiday_theme),
@@ -434,6 +476,7 @@ class ThemeManager:
selected_theme = self.randomize_theme_asset(available_themes)
asset_mappings = {
"boot_logo": ("boot_logo", boot_logo),
"color_scheme": ("colors", selected_theme.replace("-animated", "")),
"distance_icons": ("distance_icons", self.randomize_distance_icons(available_themes, selected_theme.replace("-animated", ""))),
"icon_pack": ("icons", selected_theme),
@@ -444,6 +487,7 @@ class ThemeManager:
elif not frogpilot_toggles.random_themes:
asset_mappings = {
"boot_logo": ("boot_logo", boot_logo),
"color_scheme": ("colors", frogpilot_toggles.color_scheme),
"distance_icons": ("distance_icons", frogpilot_toggles.distance_icons),
"icon_pack": ("icons", frogpilot_toggles.icon_pack),
@@ -458,7 +502,9 @@ class ThemeManager:
for asset, (asset_type, current_value) in asset_mappings.items():
print(f"Updating {asset}: {asset_type} with value {current_value}")
if asset_type == "wheel_image":
if asset_type == "boot_logo":
self.update_boot_logo(current_value)
elif asset_type == "wheel_image":
self.update_wheel_image(current_value, boot_run=boot_run)
else:
self.update_theme_asset(asset_type, current_value, boot_run=boot_run)
@@ -499,9 +545,16 @@ class ThemeManager:
save_location.symlink_to(asset_location, target_is_directory=True)
print(f"Linked {save_location} to {asset_location}")
def update_theme_params(self, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels):
def update_theme_params(self, downloadable_boot_logos, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels):
def update_param(key, assets, subfolder):
if subfolder == "steering_wheels":
if subfolder == "boot_logos":
themes_path = THEME_SAVE_PATH / "bootlogos"
existing_assets = {item.stem.lower() for item in themes_path.glob("*") if item.is_file()}
pending_assets = [asset for asset in assets if asset.lower() not in existing_assets]
params.put(key, ",".join(sorted(set(pending_assets))))
print(f"{key} updated successfully")
return
elif subfolder == "steering_wheels":
themes_path = THEME_SAVE_PATH / subfolder
existing_assets = {self.format_name(item.name, "steering_wheels") for item in themes_path.glob("*") if item.is_file()}
else:
@@ -511,6 +564,7 @@ class ThemeManager:
params.put(key, ",".join(sorted(set(assets) - existing_assets)))
print(f"{key} updated successfully")
update_param("DownloadableBootLogos", downloadable_boot_logos, "boot_logos")
update_param("DownloadableColors", downloadable_colors, "colors")
update_param("DownloadableDistanceIcons", downloadable_distance_icons, "distance_icons")
update_param("DownloadableIcons", downloadable_icons, "icons")
@@ -529,12 +583,20 @@ class ThemeManager:
theme_name = self.format_name(theme_dir.name, "theme_packs")
downloaded_themes[theme_name] = sorted(components)
(THEME_SAVE_PATH / "bootlogos").mkdir(parents=True, exist_ok=True)
downloaded_boot_logos = []
for boot_logo_file in (THEME_SAVE_PATH / "bootlogos").iterdir():
if boot_logo_file.is_file():
downloaded_boot_logos.append(self.format_name(boot_logo_file.name, "boot_logos"))
downloaded_wheels = []
for wheel_file in (THEME_SAVE_PATH / "steering_wheels").iterdir():
if wheel_file.is_file():
downloaded_wheels.append(self.format_name(wheel_file.name, "steering_wheels"))
params.put("ThemesDownloaded", json.dumps({
"boot_logos": sorted(downloaded_boot_logos),
"themes": {key: downloaded_themes[key] for key in sorted(downloaded_themes)},
"steering_wheels": sorted(downloaded_wheels)
}))
@@ -542,7 +604,9 @@ class ThemeManager:
print("ThemesDownloaded updated successfully")
def update_theme_size(self, theme_component, theme_name, file_size):
if theme_component == "steering_wheels":
if theme_component == "boot_logos":
key = "boot_logos"
elif theme_component == "steering_wheels":
key = "wheels"
else:
key = "themes"
@@ -550,7 +614,7 @@ class ThemeManager:
if key not in self.theme_sizes:
self.theme_sizes[key] = {}
if key == "wheels":
if key in {"boot_logos", "wheels"}:
self.theme_sizes[key][theme_name] = file_size
else:
if theme_name not in self.theme_sizes[key]:
@@ -572,6 +636,7 @@ class ThemeManager:
if not assets:
return
downloadable_boot_logos = []
downloadable_colors = []
downloadable_distance_icons = []
downloadable_icons = []
@@ -593,8 +658,10 @@ class ThemeManager:
if "sounds" in available_assets:
downloadable_sounds.append(theme_name)
downloadable_boot_logos = [Path(boot_logo).stem for boot_logo in assets["boot_logos"]]
downloadable_wheels = [self.format_name(wheel, "steering_wheels") for wheel in assets["wheels"]]
print(f"Downloadable Boot Logos: {downloadable_boot_logos}")
print(f"Downloadable Colors: {downloadable_colors}")
print(f"Downloadable Icons: {downloadable_icons}")
print(f"Downloadable Signals: {downloadable_signals}")
@@ -605,7 +672,21 @@ class ThemeManager:
if boot_run:
self.validate_themes(downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles)
self.update_theme_params(downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels)
self.update_theme_params(downloadable_boot_logos, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels)
@staticmethod
def update_boot_logo(image):
boot_logo_location = Path(__file__).parent / "other_images/frogpilot_boot_logo.png"
image_name = image.replace(" ", "_").lower()
source_file = next((file for file in (THEME_SAVE_PATH / "bootlogos").glob("*") if file.is_file() and file.stem.lower() == image_name), boot_logo_location)
# Avoid SameFileError when the selected/fallback source is already the active boot logo.
if source_file.resolve() == boot_logo_location.resolve():
print(f"Boot logo unchanged: {boot_logo_location}")
return
shutil.copy2(source_file, boot_logo_location)
print(f"Copied {source_file} to {boot_logo_location}")
def update_wheel_image(self, image, boot_run=False, random_event=False):
wheel_save_location = ACTIVE_THEME_PATH / "steering_wheel"
@@ -641,6 +722,15 @@ class ThemeManager:
def validate_themes(self, downloadable_colors, downloadable_distance_icons, downloadable_icons, downloadable_signals, downloadable_sounds, downloadable_wheels, frogpilot_toggles):
downloaded_data = json.loads(params.get("ThemesDownloaded") or "{}")
boot_logos_path = THEME_SAVE_PATH / "bootlogos"
for display_name in downloaded_data.get("boot_logos", []):
file_stem = display_name.replace(" ", "_").lower()
matching_files = list(boot_logos_path.glob(f"{file_stem}.*"))
if not matching_files:
print(f"Missing boot logo '{display_name}'. Downloading...")
self.download_theme("boot_logos", file_stem, THEME_COMPONENT_PARAMS["boot_logos"], frogpilot_toggles)
self.update_active_theme(True, frogpilot_toggles)
for display_name, components in downloaded_data.get("themes", {}).items():
raw_name = display_name.lower().replace(" ", "_").replace("(", "").replace(")", "")
theme_folder_name = raw_name.replace("_animated", "-animated")
+57 -5
View File
@@ -2,6 +2,7 @@
import json
import math
import numpy as np
import os
import requests
import shutil
import subprocess
@@ -22,7 +23,7 @@ from opendbc.can.parser import CANParser
from openpilot.common.realtime import DT_DMON, DT_HW
from openpilot.selfdrive.car.toyota.carcontroller import LOCK_CMD
from openpilot.system.hardware import HARDWARE
from panda import Panda
from panda import Panda, FW_PATH
from openpilot.frogpilot.common.frogpilot_variables import EARTH_RADIUS, KONIK_PATH, MAPD_PATH, MAPS_PATH, params, params_cache, params_memory
@@ -151,11 +152,21 @@ def extract_zip(zip_file, extract_path):
print(f"Extraction completed: {zip_file} has been removed")
def flash_panda():
remote_start = params.get_bool("RemoteStartBootsComma")
for serial in Panda.list():
try:
panda = Panda(serial)
flash_fn = None
if remote_start:
app_fn = panda.get_mcu_type().config.app_fn
remote_fn = "panda_h7_remote.bin.signed" if app_fn == "panda_h7.bin.signed" else "panda_remote.bin.signed"
candidate = os.path.join(FW_PATH, remote_fn)
if os.path.isfile(candidate):
flash_fn = candidate
else:
print(f"Remote-start panda firmware missing: {candidate}. Falling back to default firmware.")
panda.reset(enter_bootstub=True)
panda.flash()
panda.flash(fn=flash_fn)
panda.close()
except Exception as exception:
print(f"Error flashing Panda {serial}: {exception}")
@@ -233,16 +244,57 @@ def update_json_file(path, data):
with open(path, "w") as file:
json.dump(data, file, indent=2, sort_keys=True)
def parse_maps_selected(raw_maps_selected):
if raw_maps_selected is None:
return {}
raw_maps_selected = raw_maps_selected.strip()
if not raw_maps_selected:
return {}
try:
maps_selected = json.loads(raw_maps_selected)
if isinstance(maps_selected, dict):
nations = sorted({str(item) for item in maps_selected.get("nations", []) if item})
states = sorted({str(item) for item in maps_selected.get("states", []) if item})
return {"nations": nations, "states": states}
return {}
except json.JSONDecodeError:
pass
nations = set()
states = set()
for item in raw_maps_selected.split(","):
item = item.strip()
if item.startswith("nation."):
nation = item.removeprefix("nation.").strip()
if nation:
nations.add(nation)
elif item.startswith("us_state."):
state = item.removeprefix("us_state.").strip()
if state:
states.add(state)
if nations or states:
return {"nations": sorted(nations), "states": sorted(states)}
return {}
def update_maps(now):
while not MAPD_PATH.exists():
time.sleep(60)
maps_selected = json.loads(params.get("MapsSelected", encoding="utf-8") or "{}")
if isinstance(maps_selected, int):
raw_maps_selected = params.get("MapsSelected", encoding="utf-8")
maps_selected = parse_maps_selected(raw_maps_selected)
if raw_maps_selected and not maps_selected:
params.remove("MapsSelected")
params_cache.remove("MapsSelected")
return
if maps_selected:
normalized_maps_selected = json.dumps(maps_selected, separators=(",", ":"))
if raw_maps_selected != normalized_maps_selected:
params.put("MapsSelected", normalized_maps_selected)
params_cache.put("MapsSelected", normalized_maps_selected)
if not (maps_selected.get("nations") or maps_selected.get("states")):
return
+61 -15
View File
@@ -62,6 +62,7 @@ THEME_SAVE_PATH = Path("/data/themes")
ERROR_LOGS_PATH = Path("/data/error_logs")
SCREEN_RECORDINGS_PATH = Path("/data/media/screen_recordings")
VIDEO_CACHE_PATH = Path("/data/video_cache")
MODEL_MIGRATION_FLAG_PATH = Path("/data/params/d/ModelMigratedBd2ToSc")
BACKUP_PATH = Path("/cache/on_backup")
@@ -75,8 +76,8 @@ MAPD_PATH = Path("/data/media/0/osm/mapd")
MAPS_PATH = Path("/data/media/0/osm/offline")
NNFF_MODELS_PATH = Path(BASEDIR) / "frogpilot/assets/nnff_models"
DEFAULT_MODEL = "bd2"
DEFAULT_MODEL_NAME = "Firehose (Default) 👀📡"
DEFAULT_MODEL = "sc"
DEFAULT_MODEL_NAME = "South Carolina (Default) 👀📡"
DEFAULT_MODEL_VERSION = "v11"
BUTTON_FUNCTIONS = {
@@ -133,6 +134,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("AdvancedLateralTune", "1", 2, "0"),
("AdvancedLongitudinalTune", "0", 3, "0"),
("EVTuning", "", 3, "0"),
("TruckTuning", "0", 3, "0"),
("AggressiveFollow", "1.25", 2, "1.25"),
("AggressiveFollowHigh", "1.25", 2, "1.25"),
("AggressiveJerkAcceleration", "50", 3, "50"),
@@ -146,6 +148,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("AlwaysOnLateral", "1", 0, "0"),
("AlwaysOnLateralLKAS", "1", 0, "0"),
("AlwaysOnLateralMain", "1", 0, "0"),
("AlwaysAllowUploads", "0", 2, "0"),
("AMapKey1", "", 0, ""),
("AMapKey2", "", 0, ""),
("AutomaticallyDownloadModels", "1", 1, "0"),
@@ -159,6 +162,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("BlacklistedModels", "", 2, ""),
("BlindSpotMetrics", "1", 3, "0"),
("BlindSpotPath", "1", 1, "0"),
("BootLogo", "starpilot", 0, "stock"),
("BorderMetrics", "1", 3, "0"),
("CalibratedLateralAcceleration", str(DEFAULT_LATERAL_ACCELERATION), 2, str(DEFAULT_LATERAL_ACCELERATION)),
("CalibrationProgress", "0", 3, "0"),
@@ -170,7 +174,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("CECurves", "0", 1, "0"),
("CECurvesLead", "0", 1, "0"),
("CELead", "0", 1, "0"),
("CEModelStopTime", str(PLANNER_TIME - 2), 2, "0"),
("CEModelStopTime", "7", 2, "0"),
("CENavigation", "0", 2, "0"),
("CENavigationIntersections", "1", 2, "0"),
("CENavigationLead", "1", 2, "0"),
@@ -219,7 +223,6 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("DynamicPathWidth", "0", 2, "0"),
("DynamicPedalsOnUI", "1", 2, "0"),
("EngageVolume", "101", 2, "101"),
("ExperimentalGMTune", "0", 2, "0"),
("ExperimentalLongitudinalEnabled", "0", 0, "0"),
("ExperimentalModeConfirmed", "0", 0, "0"),
("Fahrenheit", "0", 3, "0"),
@@ -237,9 +240,13 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("FullMap", "0", 2, "0"),
("GasRegenCmd", "1", 2, "0"),
("GMPedalLongitudinal", "1", 2, "1"),
("RemapCancelToDistance", "0", 2, "0"),
("RedPanda", "0", 3, "0"),
("RemoteStartBootsComma", "0", 3, "0"),
("GithubSshKeys", "", 0, ""),
("GithubUsername", "", 0, ""),
("GoatScream", "0", 1, "0"),
("GoatScreamCriticalAlerts", "0", 1, "0"),
("GreenLightAlert", "0", 0, "0"),
("GsmApn", "", 0, ""),
("GsmRoaming", "1", 0, "0"),
@@ -452,7 +459,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("WarningSoftVolume", "101", 2, "101"),
("WheelIcon", "frog", 0, "stock"),
("WheelSpeed", "0", 2, "0"),
("StopDistance", "6", 3, "6"),
("StopDistance", "6.0", 3, "6.0"),
("RecoveryPower", "1.0", 2, "1.0")
]
@@ -530,6 +537,18 @@ class FrogPilotVariables:
tuning_level = params.get_int("TuningLevel") if params.get_bool("TuningLevelConfirmed") else 3
# One-time migration: move legacy bd2 selections to the new default sc model.
if not MODEL_MIGRATION_FLAG_PATH.is_file():
if params.get("Model", encoding="utf-8") == "bd2":
params.put("Model", DEFAULT_MODEL)
params.put("ModelVersion", DEFAULT_MODEL_VERSION)
params_cache.put("Model", DEFAULT_MODEL)
params_cache.put("ModelVersion", DEFAULT_MODEL_VERSION)
try:
MODEL_MIGRATION_FLAG_PATH.touch(exist_ok=True)
except OSError:
pass
toggle.is_metric = params.get_bool("IsMetric")
distance_conversion = 1 if toggle.is_metric else CV.FOOT_TO_METER
small_distance_conversion = 1 if toggle.is_metric else CV.INCH_TO_CM
@@ -571,6 +590,7 @@ class FrogPilotVariables:
toggle.has_cc_long = toggle.car_make == "gm" and bool(CP.flags & GMFlags.CC_LONG.value)
has_nnff = nnff_supported(toggle.car_model)
toggle.has_pedal = CP.enableGasInterceptor
params_default.put("IncreasedStoppedDistance", "4" if toggle.has_pedal else "0")
has_radar = not CP.radarUnavailable
toggle.has_sdsu = toggle.car_make == "toyota" and bool(CP.flags & ToyotaFlags.SMART_DSU.value)
toggle.has_sascm = toggle.car_make == "gm" and bool(CP.flags & GMFlags.SASCM.value)
@@ -620,19 +640,34 @@ class FrogPilotVariables:
toggle.steer_offset = np.clip(params.get_float("SteerOffset"), -0.2, 0.2) if advanced_lateral_tuning and tuning_level >= level["SteerOffset"] and toggle.car_make == "gm" else 0.0
toggle.use_custom_friction = bool(round(toggle.friction, 2) != round(friction, 2)) and is_torque_car and not toggle.force_auto_tune or toggle.force_auto_tune_off
toggle.steerKp = [[0], [np.clip(params.get_float("SteerKP"), steerKp * 0.5, steerKp * 1.5) if advanced_lateral_tuning and is_torque_car and tuning_level >= level["SteerKP"] else steerKp]]
toggle.latAccelFactor = np.clip(params.get_float("SteerLatAccel"), latAccelFactor * 0.75, latAccelFactor * 1.25) if advanced_lateral_tuning and tuning_level >= level["SteerLatAccel"] else latAccelFactor
toggle.latAccelFactor = np.clip(params.get_float("SteerLatAccel"), latAccelFactor * 0.5, latAccelFactor * 1.25) if advanced_lateral_tuning and tuning_level >= level["SteerLatAccel"] else latAccelFactor
toggle.use_custom_latAccelFactor = bool(round(toggle.latAccelFactor, 2) != round(latAccelFactor, 2)) and is_torque_car and not toggle.force_auto_tune or toggle.force_auto_tune_off
toggle.steerRatio = np.clip(params.get_float("SteerRatio"), steerRatio * 0.5, steerRatio * 1.5) if advanced_lateral_tuning and tuning_level >= level["SteerRatio"] else steerRatio
toggle.steerRatio = np.clip(params.get_float("SteerRatio"), steerRatio * 0.25, steerRatio * 1.5) if advanced_lateral_tuning and tuning_level >= level["SteerRatio"] else steerRatio
toggle.use_custom_steerRatio = bool(round(toggle.steerRatio, 2) != round(steerRatio, 2)) and not toggle.force_auto_tune or toggle.force_auto_tune_off
advanced_longitudinal_tuning = params.get_bool("AdvancedLongitudinalTune") if tuning_level >= level["AdvancedLongitudinalTune"] else default.get_bool("AdvancedLongitudinalTune")
ev_vehicle = toggle.car_make == "gm" and toggle.car_model != "CHEVROLET_VOLT" and CP.carFingerprint in GM_EV_CAR or toggle.car_make == "hyundai" and CP.carFingerprint in HYUNDAI_EV_CAR
gm_ev_vehicle = toggle.car_make == "gm" and CP.carFingerprint in GM_EV_CAR
gm_ev_vehicle &= not (toggle.car_model.startswith("CHEVROLET_VOLT") and not toggle.car_model.endswith("_CC"))
gm_ev_vehicle &= toggle.car_model != "CHEVROLET_MALIBU_HYBRID_CC"
ev_vehicle = gm_ev_vehicle or (toggle.car_make == "hyundai" and CP.carFingerprint in HYUNDAI_EV_CAR)
ev_vehicle |= CP.transmissionType == TransmissionType.direct
if params.get("EVTuning") == b"":
params.put_bool("EVTuning", ev_vehicle)
toggle.ev_tuning = params.get_bool("EVTuning") if advanced_longitudinal_tuning and tuning_level >= level["EVTuning"] else ev_vehicle
if params.get("TruckTuning") == b"":
params.put_bool("TruckTuning", False)
ev_tuning_param = params.get_bool("EVTuning")
truck_tuning_param = params.get_bool("TruckTuning")
# Enforce exclusivity between EV and Truck tuning.
if truck_tuning_param and ev_tuning_param:
ev_tuning_param = False
params.put_bool("EVTuning", False)
toggle.ev_tuning = ev_tuning_param if advanced_longitudinal_tuning and tuning_level >= level["EVTuning"] else ev_vehicle
toggle.truck_tuning = truck_tuning_param if advanced_longitudinal_tuning and tuning_level >= level["TruckTuning"] else False
toggle.longitudinalActuatorDelay = np.clip(params.get_float("LongitudinalActuatorDelay"), 0, 1) if advanced_longitudinal_tuning and tuning_level >= level["LongitudinalActuatorDelay"] else longitudinalActuatorDelay
toggle.startAccel = np.clip(params.get_float("StartAccel"), 0, 4) if advanced_longitudinal_tuning and tuning_level >= level["StartAccel"] else startAccel
toggle.stopAccel = np.clip(params.get_float("StopAccel"), -4, 0) if advanced_longitudinal_tuning and tuning_level >= level["StopAccel"] else stopAccel
@@ -663,7 +698,7 @@ class FrogPilotVariables:
toggle.car_model = params.get("CarModel", encoding="utf-8") or toggle.car_model
toggle.cluster_offset = params.get_float("ClusterOffset") if toggle.car_make == "toyota" and tuning_level >= level["ClusterOffset"] else default.get_float("ClusterOffset")
toggle.cluster_offset = params.get_float("ClusterOffset") if toggle.car_make in {"gm", "toyota"} and tuning_level >= level["ClusterOffset"] else default.get_float("ClusterOffset")
toggle.conditional_experimental_mode = toggle.openpilot_longitudinal and (params.get_bool("ConditionalExperimental") if tuning_level >= level["ConditionalExperimental"] else default.get_bool("ConditionalExperimental"))
toggle.conditional_curves = toggle.conditional_experimental_mode and (params.get_bool("CECurves") if tuning_level >= level["CECurves"] else default.get_bool("CECurves"))
@@ -687,6 +722,7 @@ class FrogPilotVariables:
toggle.custom_alerts = params.get_bool("CustomAlerts") if tuning_level >= level["CustomAlerts"] else default.get_bool("CustomAlerts")
toggle.goat_scream_alert = toggle.custom_alerts and (params.get_bool("GoatScream") if tuning_level >= level["GoatScream"] else default.get_bool("GoatScream"))
toggle.goat_scream_critical_alerts = toggle.custom_alerts and (params.get_bool("GoatScreamCriticalAlerts") if tuning_level >= level["GoatScreamCriticalAlerts"] else default.get_bool("GoatScreamCriticalAlerts"))
toggle.green_light_alert = toggle.custom_alerts and (params.get_bool("GreenLightAlert") if tuning_level >= level["GreenLightAlert"] else default.get_bool("GreenLightAlert"))
toggle.lead_departing_alert = toggle.custom_alerts and (params.get_bool("LeadDepartingAlert") if tuning_level >= level["LeadDepartingAlert"] else default.get_bool("LeadDepartingAlert"))
toggle.loud_blindspot_alert = has_bsm and toggle.custom_alerts and (params.get_bool("LoudBlindspotAlert") if tuning_level >= level["LoudBlindspotAlert"] else default.get_bool("LoudBlindspotAlert"))
@@ -772,6 +808,7 @@ class FrogPilotVariables:
toggle.device_shutdown_time = (device_shutdown_setting - 3) * 3600 if device_shutdown_setting >= 4 else device_shutdown_setting * (60 * 15)
toggle.increase_thermal_limits = device_management and (params.get_bool("IncreaseThermalLimits") if tuning_level >= level["IncreaseThermalLimits"] else default.get_bool("IncreaseThermalLimits"))
toggle.low_voltage_shutdown = np.clip(params.get_float("LowVoltageShutdown"), VBATT_PAUSE_CHARGING, 12.5) if device_management and tuning_level >= level["LowVoltageShutdown"] else default.get_float("LowVoltageShutdown")
toggle.always_allow_uploads = device_management and (params.get_bool("AlwaysAllowUploads") if tuning_level >= level["AlwaysAllowUploads"] else default.get_bool("AlwaysAllowUploads"))
toggle.no_logging = device_management and (params.get_bool("NoLogging") if tuning_level >= level["NoLogging"] else default.get_bool("NoLogging")) and not self.vetting_branch or toggle.force_onroad
toggle.no_uploads = device_management and (params.get_bool("NoUploads") if tuning_level >= level["NoUploads"] else default.get_bool("NoUploads")) and not self.vetting_branch or toggle.use_higher_bitrate
toggle.no_onroad_uploads = toggle.no_uploads and (params.get_bool("DisableOnroadUploads") if tuning_level >= level["DisableOnroadUploads"] else default.get_bool("DisableOnroadUploads")) and not toggle.use_higher_bitrate
@@ -803,10 +840,11 @@ class FrogPilotVariables:
toggle.personality_profile_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["PERSONALITY_PROFILE"]
toggle.traffic_mode_via_distance_very_long = toggle.openpilot_longitudinal and distance_button_control_very_long == BUTTON_FUNCTIONS["TRAFFIC_MODE"]
toggle.experimental_gm_tune = toggle.openpilot_longitudinal and toggle.car_make == "gm" and (params.get_bool("ExperimentalGMTune") if tuning_level >= level["ExperimentalGMTune"] else default.get_bool("ExperimentalGMTune"))
toggle.stoppingDecelRate = 0.3 if toggle.experimental_gm_tune else toggle.stoppingDecelRate
toggle.vEgoStarting = 0.15 if toggle.experimental_gm_tune else toggle.vEgoStarting
toggle.vEgoStopping = 0.15 if toggle.experimental_gm_tune else toggle.vEgoStopping
toggle.red_panda = toggle.car_make == "gm" and (params.get_bool("RedPanda") if tuning_level >= level["RedPanda"] else default.get_bool("RedPanda"))
toggle.remote_start_boots_comma = toggle.car_make == "gm" and (params.get_bool("RemoteStartBootsComma") if tuning_level >= level["RemoteStartBootsComma"] else default.get_bool("RemoteStartBootsComma"))
toggle.remap_cancel_to_distance = toggle.car_make == "gm" and toggle.openpilot_longitudinal and (
params.get_bool("RemapCancelToDistance") if tuning_level >= level["RemapCancelToDistance"] else default.get_bool("RemapCancelToDistance")
)
toggle.force_fingerprint = (params.get_bool("ForceFingerprint") if tuning_level >= level["ForceFingerprint"] else default.get_bool("ForceFingerprint")) and toggle.car_model is not None
@@ -917,6 +955,7 @@ class FrogPilotVariables:
toggle.old_long_api |= toggle.openpilot_longitudinal and toggle.car_make == "hyundai" and not (params.get_bool("NewLongAPI") if tuning_level >= level["NewLongAPI"] else default.get_bool("NewLongAPI"))
personalize_openpilot = params.get_bool("PersonalizeOpenpilot") if tuning_level >= level["PersonalizeOpenpilot"] else default.get_bool("PersonalizeOpenpilot")
toggle.boot_logo = params.get("BootLogo", encoding="utf-8") or "starpilot"
toggle.color_scheme = toggle.current_holiday_theme if toggle.current_holiday_theme != "stock" else params.get("CustomColors", encoding="utf-8") if personalize_openpilot else "stock"
toggle.distance_icons = toggle.current_holiday_theme if toggle.current_holiday_theme != "stock" else params.get("CustomDistanceIcons", encoding="utf-8") if personalize_openpilot else "stock"
toggle.icon_pack = toggle.current_holiday_theme if toggle.current_holiday_theme != "stock" else params.get("CustomIcons", encoding="utf-8") if personalize_openpilot else "stock"
@@ -1006,7 +1045,14 @@ class FrogPilotVariables:
toggle.lock_doors = toyota_doors and (params.get_bool("LockDoors") if tuning_level >= level["LockDoors"] else default.get_bool("LockDoors"))
toggle.unlock_doors = toyota_doors and (params.get_bool("UnlockDoors") if tuning_level >= level["UnlockDoors"] else default.get_bool("UnlockDoors"))
toggle.volt_sng = toggle.car_model == "CHEVROLET_VOLT" and (params.get_bool("VoltSNG") if tuning_level >= level["VoltSNG"] else default.get_bool("VoltSNG"))
volt_models = {
"CHEVROLET_VOLT",
"CHEVROLET_VOLT_2019",
"CHEVROLET_VOLT_ASCM",
"CHEVROLET_VOLT_CAMERA",
}
toggle.volt_sng = toggle.car_model in volt_models and (params.get_bool("VoltSNG") if tuning_level >= level["VoltSNG"] else default.get_bool("VoltSNG"))
toggle.gm_pedal_longitudinal = params.get_bool("GMPedalLongitudinal") if tuning_level >= level["GMPedalLongitudinal"] else default.get_bool("GMPedalLongitudinal")
+224
View File
@@ -0,0 +1,224 @@
import json
import threading
import time
from pathlib import Path
from openpilot.system.hardware import PC
TESTING_GROUNDS_SCHEMA_VERSION = 1
TESTING_GROUND_1 = "1"
TESTING_GROUND_2 = "2"
TESTING_GROUND_3 = "3"
TESTING_GROUND_4 = "4"
TESTING_GROUND_5 = "5"
TESTING_GROUND_6 = "6"
TESTING_GROUND_7 = "7"
TESTING_GROUND_IDS = (
TESTING_GROUND_1,
TESTING_GROUND_2,
TESTING_GROUND_3,
TESTING_GROUND_4,
TESTING_GROUND_5,
TESTING_GROUND_6,
TESTING_GROUND_7,
)
TESTING_GROUNDS_STATE_PATH = Path("/tmp/the_pond_testing_grounds_slots.json") if PC else Path("/data/testing_grounds/slots.json")
# Edit slot names/descriptions once here. The Pond and runtime checks share this table.
# Slots named "Unused" are hidden from the dropdown.
# Adding cLabel/dLabel/etc. automatically adds more mode buttons for that slot in Testing Ground.
TESTING_GROUNDS_SLOT_DEFINITIONS = (
{
"id": TESTING_GROUND_1,
"name": "GM Long Tune",
"description": "BoltLongTune A/B sandbox for GM ACC longitudinal testing.",
"aLabel": "A - Installed tune",
"bLabel": "B - BoltLongTune test",
},
{
"id": TESTING_GROUND_2,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_3,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_4,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_5,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_6,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
{
"id": TESTING_GROUND_7,
"name": "Unused",
"description": "",
"aLabel": "A",
"bLabel": "B",
},
)
_DEFAULT_ACTIVE_SLOT = TESTING_GROUND_1
DEFAULT_TESTING_GROUND_VARIANT = "A"
TESTING_GROUND_TEST_VARIANT = "B"
_CACHE_LOCK = threading.Lock()
_CACHE_LAST_REFRESH = 0.0
_CACHE_LAST_MTIME_NS = -1
_CACHE_ACTIVE_SLOT = _DEFAULT_ACTIVE_SLOT
_CACHE_ACTIVE_VARIANT = DEFAULT_TESTING_GROUND_VARIANT
def _extract_variant_labels(slot_definition):
labels = {}
for key, value in dict(slot_definition).items():
if not isinstance(key, str) or not key.endswith("Label"):
continue
variant = key[:-5].upper()
if len(variant) != 1 or not variant.isalpha():
continue
label = str(value or "").strip()
if label:
labels[variant] = label
if DEFAULT_TESTING_GROUND_VARIANT not in labels:
labels[DEFAULT_TESTING_GROUND_VARIANT] = DEFAULT_TESTING_GROUND_VARIANT
return dict(sorted(labels.items()))
TESTING_GROUND_VARIANT_LABELS = {
str(slot.get("id") or "").strip(): _extract_variant_labels(slot)
for slot in TESTING_GROUNDS_SLOT_DEFINITIONS
}
TESTING_GROUND_VARIANTS = tuple(
sorted({
variant
for labels in TESTING_GROUND_VARIANT_LABELS.values()
for variant in labels.keys()
})
)
def _normalize_variant(value, slot_id=None):
normalized_slot_id = str(slot_id or "").strip()
allowed_variants = set(TESTING_GROUND_VARIANT_LABELS.get(normalized_slot_id, {}).keys())
if not allowed_variants:
allowed_variants = set(TESTING_GROUND_VARIANTS) or {DEFAULT_TESTING_GROUND_VARIANT}
variant = str(value or "").strip().upper()
return variant if variant in allowed_variants else DEFAULT_TESTING_GROUND_VARIANT
def get_testing_ground_selection(refresh_interval_s=0.5):
global _CACHE_LAST_REFRESH, _CACHE_LAST_MTIME_NS, _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
now = time.monotonic()
with _CACHE_LOCK:
if (now - _CACHE_LAST_REFRESH) < refresh_interval_s:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
_CACHE_LAST_REFRESH = now
try:
stat_result = TESTING_GROUNDS_STATE_PATH.stat()
except FileNotFoundError:
_CACHE_LAST_MTIME_NS = -1
_CACHE_ACTIVE_SLOT = _DEFAULT_ACTIVE_SLOT
_CACHE_ACTIVE_VARIANT = DEFAULT_TESTING_GROUND_VARIANT
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
except OSError:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
if stat_result.st_mtime_ns == _CACHE_LAST_MTIME_NS:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
try:
payload = json.loads(TESTING_GROUNDS_STATE_PATH.read_text(encoding="utf-8"))
except Exception:
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
if not isinstance(payload, dict):
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
slot_id = str(payload.get("activeSlot") or _DEFAULT_ACTIVE_SLOT).strip()
if slot_id not in TESTING_GROUND_IDS:
slot_id = _DEFAULT_ACTIVE_SLOT
_CACHE_ACTIVE_SLOT = slot_id
_CACHE_ACTIVE_VARIANT = _normalize_variant(payload.get("activeVariant"), slot_id)
_CACHE_LAST_MTIME_NS = stat_result.st_mtime_ns
return _CACHE_ACTIVE_SLOT, _CACHE_ACTIVE_VARIANT
def is_testing_ground_active(slot_id, variant=TESTING_GROUND_TEST_VARIANT, refresh_interval_s=0.5):
active_slot, active_variant = get_testing_ground_selection(refresh_interval_s=refresh_interval_s)
normalized_slot_id = str(slot_id or "").strip()
target_variant = _normalize_variant(variant, normalized_slot_id)
return normalized_slot_id == active_slot and active_variant == target_variant
class _TestingGroundFlag:
__slots__ = ("slot_id", "variant")
def __init__(self, slot_id, variant=TESTING_GROUND_TEST_VARIANT):
self.slot_id = str(slot_id or "").strip()
self.variant = _normalize_variant(variant, self.slot_id)
def __bool__(self):
return is_testing_ground_active(self.slot_id, self.variant)
def __repr__(self):
state = "active" if bool(self) else "inactive"
return f"<TestingGroundFlag slot={self.slot_id} variant={self.variant} {state}>"
use_testing_ground_1 = _TestingGroundFlag(TESTING_GROUND_1)
use_testing_ground_2 = _TestingGroundFlag(TESTING_GROUND_2)
use_testing_ground_3 = _TestingGroundFlag(TESTING_GROUND_3)
use_testing_ground_4 = _TestingGroundFlag(TESTING_GROUND_4)
use_testing_ground_5 = _TestingGroundFlag(TESTING_GROUND_5)
use_testing_ground_6 = _TestingGroundFlag(TESTING_GROUND_6)
use_testing_ground_7 = _TestingGroundFlag(TESTING_GROUND_7)
class _TestingGroundNamespace:
__slots__ = ()
id_1 = TESTING_GROUND_1
id_2 = TESTING_GROUND_2
id_3 = TESTING_GROUND_3
id_4 = TESTING_GROUND_4
id_5 = TESTING_GROUND_5
id_6 = TESTING_GROUND_6
id_7 = TESTING_GROUND_7
use_1 = use_testing_ground_1
use_2 = use_testing_ground_2
use_3 = use_testing_ground_3
use_4 = use_testing_ground_4
use_5 = use_testing_ground_5
use_6 = use_testing_ground_6
use_7 = use_testing_ground_7
def use(self, slot_id, variant=TESTING_GROUND_TEST_VARIANT):
return is_testing_ground_active(slot_id, variant)
def selection(self):
return get_testing_ground_selection()
testing_ground = _TestingGroundNamespace()
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
import time
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.realtime import DT_MDL
from openpilot.common.numpy_fast import interp
@@ -32,6 +34,10 @@ class ConditionalExperimentalMode:
LIGHT_BOOST_LOW = 1.15
LIGHT_BOOST_HIGH = 1.2
# Small latch to avoid frame-to-frame mode chatter.
CEM_TRANSITION_GUARD_TIME = 0.50
CEM_TRANSITION_BUFFER_TIME = 0.25
@staticmethod
def get_speed_based_param(speed_mph, param_array):
"""Get parameter value based on current speed using smooth interpolation between breakpoints [0, 35, 55, 70]"""
@@ -49,8 +55,12 @@ class ConditionalExperimentalMode:
self.experimental_mode = False
self.stop_light_detected = False
self.prev_experimental_mode = False # For hysteresis
self.mode_hold_until = 0.0
self.mode_false_since = 0.0
def update(self, v_ego, sm, frogpilot_toggles):
now = time.monotonic()
if frogpilot_toggles.experimental_mode_via_press:
self.status_value = params_memory.get_int("CEStatus")
else:
@@ -58,28 +68,23 @@ class ConditionalExperimentalMode:
if self.status_value not in {1, 2} and not sm["carState"].standstill:
self.update_conditions(v_ego, sm, frogpilot_toggles)
new_experimental_mode = self.check_conditions(v_ego, sm, frogpilot_toggles)
# Add hysteresis to prevent rapid toggling
if new_experimental_mode and not self.prev_experimental_mode:
# Require weaker conditions to turn on
hysteresis_factor = 0.9
elif not new_experimental_mode and self.prev_experimental_mode:
# Require stronger conditions to turn off
hysteresis_factor = 1.2
else:
hysteresis_factor = 1.0
triggered = self.check_conditions(v_ego, sm, frogpilot_toggles)
if triggered:
self.mode_hold_until = now + self.CEM_TRANSITION_GUARD_TIME
self.mode_false_since = 0.0
elif self.mode_false_since == 0.0:
self.mode_false_since = now
# Apply hysteresis to key conditions
if hasattr(self, 'slow_lead_detected'):
self.slow_lead_detected = self.slow_lead_detected if hysteresis_factor == 1.0 else (self.slow_lead_filter.x >= scale_threshold(v_ego) * hysteresis_factor)
if hasattr(self, 'curve_detected'):
self.curve_detected = self.curve_detected if hysteresis_factor == 1.0 else (self.curvature_filter.x >= THRESHOLD * hysteresis_factor)
hold_active = now < self.mode_hold_until
transition_buffer_active = self.mode_false_since != 0.0 and (now - self.mode_false_since) < self.CEM_TRANSITION_BUFFER_TIME
self.experimental_mode = self.check_conditions(v_ego, sm, frogpilot_toggles)
self.experimental_mode = triggered or hold_active or transition_buffer_active
self.prev_experimental_mode = self.experimental_mode
params_memory.put_int("CEStatus", self.status_value if self.experimental_mode else 0)
else:
self.mode_hold_until = 0.0
self.mode_false_since = 0.0
self.experimental_mode = self.status_value == 2 or sm["carState"].standstill and self.experimental_mode and self.frogpilot_planner.model_stopped
self.stop_light_detected &= self.status_value not in {1, 2}
self.stop_light_filter.x = 0
@@ -47,26 +47,53 @@ A_CRUISE_MIN_SPORT = A_CRUISE_MIN * 2
# MPH = [0.0, 11, 22, 34, 45, 56, 89]
A_CRUISE_MAX_BP_CUSTOM = [0.0, 5., 10., 15., 20., 25., 40.]
A_CRUISE_MAX_VALS_ECO_EV = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
A_CRUISE_MAX_VALS_SPORT_EV = [1.25, 1.25, 1.25, 1.25, 1.5, 1.5, 2.0]
A_CRUISE_MAX_VALS_ECO_EV = [1.15, 1.15, 1.15, 1.15, 1.30, 1.30, 1.72]
A_CRUISE_MAX_VALS_STANDARD_EV = [1.25, 1.25, 1.25, 1.25, 1.45, 1.50, 2.00]
A_CRUISE_MAX_VALS_SPORT_EV = [1.35, 1.35, 1.35, 1.35, 1.60, 1.60, 2.10]
A_CRUISE_MAX_VALS_SPORT_PLUS_EV = [1.55, 1.55, 1.55, 1.55, 1.84, 1.84, 2.42]
A_CRUISE_MAX_VALS_ECO_GAS = [2.0, 1.5, 1.0, 0.8, 0.6, 0.4, 0.2]
A_CRUISE_MAX_VALS_SPORT_GAS = [3.0, 2.5, 2.0, 1.5, 1.0, 0.8, 0.6]
A_CRUISE_MAX_VALS_ECO_TRUCK = [3.00, 1.05, 0.60, 0.50, 0.50, 0.45, 0.35]
A_CRUISE_MAX_VALS_STANDARD_TRUCK = [6.00, 1.10, 0.70, 0.60, 0.55, 0.45, 0.35]
A_CRUISE_MAX_VALS_SPORT_TRUCK = [6.00, 1.15, 0.75, 0.70, 0.60, 0.50, 0.40]
A_CRUISE_MAX_VALS_SPORT_PLUS_TRUCK = [6.00, 1.30, 0.90, 0.80, 0.70, 0.60, 0.45]
def get_max_accel_eco(v_ego, ev_tuning=True):
cruise_vals = A_CRUISE_MAX_VALS_ECO_EV if ev_tuning else A_CRUISE_MAX_VALS_ECO_GAS
def get_max_accel_eco(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
cruise_vals = A_CRUISE_MAX_VALS_ECO_EV
elif truck_tuning:
cruise_vals = A_CRUISE_MAX_VALS_ECO_TRUCK
else:
cruise_vals = A_CRUISE_MAX_VALS_ECO_GAS
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, cruise_vals))
def get_max_accel_sport(v_ego, ev_tuning=True):
cruise_vals = A_CRUISE_MAX_VALS_SPORT_EV if ev_tuning else A_CRUISE_MAX_VALS_SPORT_GAS
def get_max_accel_sport(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
cruise_vals = A_CRUISE_MAX_VALS_SPORT_EV
elif truck_tuning:
cruise_vals = A_CRUISE_MAX_VALS_SPORT_TRUCK
else:
cruise_vals = A_CRUISE_MAX_VALS_SPORT_GAS
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, cruise_vals))
def get_max_accel_standard(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_STANDARD_EV))
if truck_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_STANDARD_TRUCK))
return get_max_accel(v_ego)
def get_max_accel_low_speeds(max_accel, v_cruise):
return float(akima_interp(v_cruise, [0., CITY_SPEED_LIMIT / 2, CITY_SPEED_LIMIT], [max_accel / 4, max_accel / 2, max_accel]))
def get_max_accel_ramp_off(max_accel, v_cruise, v_ego):
return float(akima_interp(v_cruise - v_ego, [0., 1., 5., 10.], [0., 0.5, 1.0, max_accel]))
def get_max_allowed_accel(v_ego):
def get_max_allowed_accel(v_ego, ev_tuning=True, truck_tuning=False):
if ev_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_SPORT_PLUS_EV))
if truck_tuning:
return float(akima_interp(v_ego, A_CRUISE_MAX_BP_CUSTOM, A_CRUISE_MAX_VALS_SPORT_PLUS_TRUCK))
return float(akima_interp(v_ego, [0., 5., 20.], [4.0, 4.0, 2.0])) # ISO 15622:2018
class FrogPilotAcceleration:
@@ -80,26 +107,27 @@ class FrogPilotAcceleration:
eco_gear = sm["frogpilotCarState"].ecoGear
sport_gear = sm["frogpilotCarState"].sportGear
ev_tuning = frogpilot_toggles.ev_tuning
truck_tuning = frogpilot_toggles.truck_tuning
if sm["frogpilotCarState"].trafficModeEnabled:
self.max_accel = get_max_accel(v_ego)
self.max_accel = get_max_accel_standard(v_ego, ev_tuning, truck_tuning)
elif frogpilot_toggles.map_acceleration and (eco_gear or sport_gear):
if eco_gear:
self.max_accel = get_max_accel_eco(v_ego, ev_tuning)
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
else:
if frogpilot_toggles.acceleration_profile == 2:
self.max_accel = get_max_accel_sport(v_ego, ev_tuning)
self.max_accel = get_max_accel_sport(v_ego, ev_tuning, truck_tuning)
else:
self.max_accel = get_max_allowed_accel(v_ego)
self.max_accel = get_max_allowed_accel(v_ego, ev_tuning, truck_tuning)
else:
if frogpilot_toggles.acceleration_profile == 1:
self.max_accel = get_max_accel_eco(v_ego, ev_tuning)
self.max_accel = get_max_accel_eco(v_ego, ev_tuning, truck_tuning)
elif frogpilot_toggles.acceleration_profile == 2:
self.max_accel = get_max_accel_sport(v_ego, ev_tuning)
self.max_accel = get_max_accel_sport(v_ego, ev_tuning, truck_tuning)
elif frogpilot_toggles.acceleration_profile == 3:
self.max_accel = get_max_allowed_accel(v_ego)
self.max_accel = get_max_allowed_accel(v_ego, ev_tuning, truck_tuning)
else:
self.max_accel = get_max_accel(v_ego)
self.max_accel = get_max_accel_standard(v_ego, ev_tuning, truck_tuning)
if frogpilot_toggles.human_acceleration:
self.max_accel = min(get_max_accel_low_speeds(self.max_accel, self.frogpilot_planner.v_cruise), self.max_accel)
+20 -4
View File
@@ -7,7 +7,8 @@ from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import COMFO
from openpilot.frogpilot.common.frogpilot_variables import CITY_SPEED_LIMIT
TRAFFIC_MODE_BP = [0., CITY_SPEED_LIMIT]
PERSONALITY_BP = [20. * CV.KPH_TO_MS, 90. * CV.KPH_TO_MS]
PERSONALITY_BP = [45. * CV.MPH_TO_MS, 70. * CV.MPH_TO_MS]
HIGHWAY_DISABLE_THROTTLE_MIN_SPEED = 45. * CV.MPH_TO_MS
class FrogPilotFollowing:
def __init__(self, FrogPilotPlanner):
@@ -73,9 +74,24 @@ class FrogPilotFollowing:
self.following_lead = self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.dRel < (self.t_follow * 2) * v_ego
self.disable_throttle = self.frogpilot_planner.tracking_lead and not self.following_lead
self.disable_throttle &= self.frogpilot_planner.lead_one.dRel + 6.0 < (self.t_follow * 2 * 2) * v_ego
self.disable_throttle &= self.frogpilot_planner.lead_one.vLead < v_ego * 0.75
self.disable_throttle = False
if self.frogpilot_planner.tracking_lead and self.frogpilot_planner.lead_one.status:
lead_distance = self.frogpilot_planner.lead_one.dRel
v_lead = self.frogpilot_planner.lead_one.vLead
closing_speed = max(0.0, v_ego - v_lead)
desired_gap = float(desired_follow_distance(v_ego, v_lead, self.t_follow))
ttc = lead_distance / max(closing_speed, 1e-3) if closing_speed > 0.1 else 1e6
# Keep a mild coasting behavior only for far/low-risk slower leads.
coast_window_open = lead_distance > desired_gap + max(4.0, 0.2 * v_ego)
coast_window_far = lead_distance < desired_gap + max(25.0, 1.2 * v_ego)
gentle_closing = closing_speed < max(2.0, 0.12 * v_ego)
# Highway-only coast suppression: keep low-speed lead departures responsive.
self.disable_throttle = (not self.following_lead and v_ego > HIGHWAY_DISABLE_THROTTLE_MIN_SPEED and coast_window_open and
coast_window_far and gentle_closing)
# Never coast when we are entering a potentially late-braking scenario.
self.disable_throttle &= ttc > 6.0 and lead_distance > desired_gap + 6.0
if sm["controlsState"].enabled and self.frogpilot_planner.tracking_lead:
self.update_follow_values(self.frogpilot_planner.lead_one.dRel, v_ego, self.frogpilot_planner.lead_one.vLead, frogpilot_toggles)
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
import hmac
import json
import platform
import shutil
import signal
import subprocess
import tarfile
import time
import threading
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from openpilot.common.params import Params
GALAXY_DIR = Path("/data/galaxy")
FRPC_VERSION = "0.67.0"
FRPC_LOG = GALAXY_DIR / "frpc.log"
AUTH_PORT = 8083
process = None
auth_server = None
VALID_PATHS = {"/glxylogin": "glxyauth", "/glxyverify": "glxysession"}
class AuthHandler(BaseHTTPRequestHandler):
"""Validates login hashes and verifies session tokens."""
dongle_id = ""
def do_POST(self):
target = VALID_PATHS.get(self.path)
if not target:
self.send_response(404)
self.end_headers()
return
file_path = GALAXY_DIR / target
if not file_path.exists():
self.send_response(404)
self.end_headers()
return
try:
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8").strip()
if hmac.compare_digest(body, file_path.read_text().strip()):
self.send_response(200)
if self.path == "/glxylogin":
self.send_header("Content-Type", "application/json")
self.end_headers()
token = (GALAXY_DIR / "glxysession").read_text().strip()
self.wfile.write(json.dumps({"dongle_id": self.dongle_id, "token": token}).encode())
else:
self.end_headers()
return
except Exception:
pass
self.send_response(403)
self.end_headers()
def log_message(self, format, *args):
pass
def start_auth_server():
global auth_server
if auth_server is not None:
return
auth_server = HTTPServer(("127.0.0.1", AUTH_PORT), AuthHandler)
thread = threading.Thread(target=auth_server.serve_forever, daemon=True)
thread.start()
print(f"Galaxy: Auth server listening on 127.0.0.1:{AUTH_PORT}")
def cleanup_frpc(*_):
global process
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process = None
def get_arch_url():
arch = platform.machine()
if arch in ("aarch64", "arm64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", f"frp_{FRPC_VERSION}_linux_arm64"
elif arch in ("x86_64", "amd64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_amd64.tar.gz", f"frp_{FRPC_VERSION}_linux_amd64"
return None, None
def setup_frpc():
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
frpc_bin = GALAXY_DIR / "frpc"
if not frpc_bin.exists():
print("Galaxy: Downloading frpc...")
url, folder_name = get_arch_url()
if not url:
print("Galaxy: Unsupported architecture")
return False
tar_path = GALAXY_DIR / "frp.tar.gz"
try:
urllib.request.urlretrieve(url, tar_path)
with tarfile.open(tar_path, "r:gz") as tar:
tar.extractall(path=GALAXY_DIR, filter='data')
# Move binary
extracted_bin = GALAXY_DIR / folder_name / "frpc"
extracted_bin.rename(frpc_bin)
frpc_bin.chmod(0o755)
# Cleanup
tar_path.unlink()
shutil.rmtree(GALAXY_DIR / folder_name)
print("Galaxy: frpc downloaded and installed.")
except Exception as e:
print(f"Galaxy: Failed to install frpc: {e}")
return False
return True
def main():
global process
params = Params()
signal.signal(signal.SIGTERM, cleanup_frpc)
signal.signal(signal.SIGINT, cleanup_frpc)
dongle_id = params.get("DongleId", encoding='utf8')
while not dongle_id:
print("Galaxy: Waiting for DongleId...")
time.sleep(5)
dongle_id = params.get("DongleId", encoding='utf8')
print(f"Galaxy: DongleId: {dongle_id}")
AuthHandler.dongle_id = dongle_id
print("Galaxy: Starting manager loop...")
last_slug = None
while True:
glxyauth_file = GALAXY_DIR / "glxyauth"
galaxy_password_hash = glxyauth_file.read_text().strip() if glxyauth_file.exists() else None
slug_file = GALAXY_DIR / "glxyslug"
slug = slug_file.read_text().strip() if slug_file.exists() else None
is_paired = galaxy_password_hash and len(galaxy_password_hash) == 64 and slug
if is_paired:
if process is None or process.poll() is not None or slug != last_slug:
cleanup_frpc()
if process is not None and slug == last_slug:
print(f"Galaxy: frpc exited with code {process.returncode}. Restarting...")
print("Galaxy: Password set. Preparing frpc tunnel...")
if not setup_frpc():
print("Galaxy: FRPC setup failed. Retrying later...")
time.sleep(10)
continue
start_auth_server()
frpc_toml = GALAXY_DIR / "frpc.toml"
config = f"""\
serverAddr = "galaxy.firestar.link"
serverPort = 7000
[transport]
tls.enable = true
poolCount = 2
[[proxies]]
name = "{slug}_pond"
type = "http"
localIP = "127.0.0.1"
localPort = 8082
customDomains = ["{slug}.devices.local"]
transport.useCompression = true
[[proxies]]
name = "{slug}_auth"
type = "http"
localIP = "127.0.0.1"
localPort = {AUTH_PORT}
customDomains = ["auth-{slug}.devices.local"]
"""
frpc_toml.write_text(config)
print(f"Galaxy: Starting frpc tunnel (slug: {slug[:4]}...)...")
log_file = open(FRPC_LOG, 'a')
process = subprocess.Popen(
[str(GALAXY_DIR / "frpc"), "-c", str(frpc_toml)],
stdout=log_file,
stderr=log_file
)
last_slug = slug
else:
if process is not None and process.poll() is None:
print("Galaxy: Password cleared. Stopping frpc tunnel...")
cleanup_frpc()
last_slug = None
time.sleep(3)
if __name__ == "__main__":
main()
@@ -6,7 +6,7 @@
}
.disk .progress {
background: linear-gradient(to right, green 0%, yellow 80%, orange 90%, red 100%);
background: linear-gradient(to right, #5ec8c8 0%, #8b6cc5 60%, #e05577 85%, #c04466 100%);
border-radius: var(--border-radius-md);
height: var(--padding-base);
overflow: hidden;
@@ -15,7 +15,6 @@
.diskUsage,
.drivingStats,
.firehoseStats,
.softwareInfo {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
@@ -31,12 +30,17 @@
}
.diskUsage p,
.firehoseStats p,
.softwareGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.softwareGrid p {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.drivingStat {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -65,7 +69,6 @@
.diskUsage:hover,
.drivingStats:hover,
.firehoseStats:hover,
.softwareInfo:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
@@ -1,148 +1,152 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core";
import { html } from "/assets/vendor/arrow-core.js";
function DiskUsage(disk) {
const used = parseFloat(disk.usedPercentage) || 0;
const rightRadius = used >= 100 ? "0" : "var(--border-radius-md)";
const HOME_STATE = {
status: "loading", // loading | ready | error
data: null,
unit: "miles",
error: "",
initialized: false,
};
return html`
<div class="disk">
<p>${disk.used} used of ${disk.size}</p>
<div class="progress">
<div
class="bar"
style="
border-bottom-right-radius: ${rightRadius};
border-top-right-radius: ${rightRadius};
width: ${100 - used}%;
"
></div>
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
promise.then((value) => {
clearTimeout(timerId);
resolve(value);
}).catch((err) => {
clearTimeout(timerId);
reject(err);
});
});
}
function formatInt(value) {
const n = Number(value || 0);
return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 0 }) : "0";
}
function renderDiskUsageSection(state) {
const { data } = state;
const shell = document.getElementById("home_shell");
if (!shell) return;
if (state.status === "error") {
shell.innerHTML = `<p class="error">Failed to load data: ${state.error}</p>`;
return;
}
if (state.status !== "ready" || !data) {
shell.innerHTML = "<p>Loading...</p>";
return;
}
const driveStats = data.driveStats || {};
const softwareInfo = data.softwareInfo || {};
const diskUsage = Array.isArray(data.diskUsage) ? data.diskUsage : [];
const diskError = Array.isArray(data.diskError) ? data.diskError : [];
const statBlock = (title, stats = {}) => `
<div class="drivingStat">
<h2>${title}</h2>
<div><p>${formatInt(stats.drives)}</p><p>drives</p></div>
<div><p>${formatInt(stats.distance)}</p><p>${stats.unit || state.unit}</p></div>
<div><p>${formatInt(stats.hours)}</p><p>hours</p></div>
</div>
`;
const diskBlock = (disk = {}) => {
const usedPct = Number.parseFloat(disk.usedPercentage) || 0;
const rightRadius = usedPct >= 100 ? "0" : "var(--border-radius-md)";
return `
<div class="disk">
<p>${disk.used || "0 GB"} used of ${disk.size || "0 GB"}</p>
<div class="progress">
<div
class="bar"
style="
border-bottom-right-radius: ${rightRadius};
border-top-right-radius: ${rightRadius};
width: ${Math.max(0, 100 - usedPct)}%;
"
></div>
</div>
</div>
`;
};
const softwareFields = [
["Branch Name", softwareInfo.branchName],
["Build", softwareInfo.buildEnvironment],
["Commit Hash", softwareInfo.commitHash],
["Fork Maintainer", softwareInfo.forkMaintainer],
["Update Available", softwareInfo.updateAvailable],
["Version Date", softwareInfo.versionDate],
];
const softwareMarkup = softwareFields
.map(([label, value]) => `<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`)
.join("");
const diskMarkup = diskError.length
? `<p>${diskError.join("<br>")}</p>`
: (diskUsage.length ? diskUsage.map(diskBlock).join("") : diskBlock({}));
shell.innerHTML = `
<div>
<h1>Galaxy</h1>
<div class="drivingStats">
${statBlock("All Time", driveStats.all)}
${statBlock("Past Week", driveStats.week)}
${statBlock("FrogPilot", driveStats.frogpilot)}
</div>
<h2>Disk Usage</h2>
<div class="diskUsage">
${diskMarkup}
</div>
<h2>Software Info</h2>
<div class="softwareInfo">
<div class="softwareGrid">${softwareMarkup}</div>
</div>
</div>
`;
}
function DriveStat(title, stats = {}, defaultUnit) {
const format = (n) =>
n?.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}) ?? "0";
async function initializeHome() {
try {
const [statsResponse, unitResponse] = await Promise.all([
withTimeout(fetch("/api/stats"), 5000, "stats request"),
withTimeout(fetch("/api/params?key=IsMetric"), 5000, "metric request"),
]);
return html`
<div class="drivingStat">
<h2>${title}</h2>
<div><p>${format(stats.drives)}</p><p>drives</p></div>
<div><p>${format(stats.distance)}</p><p>${stats.unit ?? defaultUnit}</p></div>
<div><p>${format(stats.hours)}</p><p>hours</p></div>
</div>
`;
}
if (!statsResponse.ok) throw new Error(`stats API error: ${statsResponse.status}`);
if (!unitResponse.ok) throw new Error(`params API error: ${unitResponse.status}`);
function renderSoftwareInfo(info = {}) {
const fields = [
["Branch Name", info.branchName],
["Build", info.buildEnvironment],
["Commit Hash", info.commitHash],
["Fork Maintainer", info.forkMaintainer],
["Update Available", info.updateAvailable],
["Version Date", info.versionDate],
];
const statsJson = await withTimeout(statsResponse.json(), 5000, "stats JSON parse");
const isMetricText = (await withTimeout(unitResponse.text(), 5000, "metric read")).trim();
return fields.map(
([label, value]) =>
html`<p><strong>${label}:</strong> ${value ?? "Unknown"}</p>`
);
}
function renderDiskUsageSection({ diskError, diskUsage }) {
if (diskError) {
return html`<p>${diskError.join("<br>")}</p>`;
HOME_STATE.data = statsJson;
HOME_STATE.unit = isMetricText === "1" ? "kilometers" : "miles";
HOME_STATE.status = "ready";
} catch (err) {
HOME_STATE.status = "error";
HOME_STATE.error = err?.message || String(err);
}
if (diskUsage?.length) {
return diskUsage.map(DiskUsage);
}
return DiskUsage({ size: "0 GB", used: "0 GB", usedPercentage: "0" });
renderDiskUsageSection(HOME_STATE);
}
export function Home() {
const state = reactive({
data: null,
unit: "miles",
isLoading: true,
error: null,
});
async function initialize() {
try {
const [statsResponse, unitResponse] = await Promise.all([
fetch("/api/stats"),
fetch("/api/params?key=IsMetric"),
]);
if (!statsResponse.ok) throw new Error(`API error: ${statsResponse.statusText}`);
if (!unitResponse.ok) throw new Error(`API error: ${unitResponse.statusText}`);
const statsJson = await statsResponse.json();
const isMetricText = (await unitResponse.text()).trim();
const isMetric = isMetricText === "1";
state.data = statsJson;
state.unit = isMetric ? "kilometers" : "miles";
localStorage.setItem("isMetric", isMetricText);
} catch (err) {
console.error("Failed to initialize component:", err);
state.error = err.message;
} finally {
state.isLoading = false;
setTimeout(() => {
renderDiskUsageSection(HOME_STATE);
if (!HOME_STATE.initialized) {
HOME_STATE.initialized = true;
initializeHome();
}
}
}, 0);
initialize();
return html`
<div>
${() => {
if (state.isLoading) {
return html`<p>Loading...</p>`;
}
if (state.error) {
return html`<p class="error">Failed to load data: ${state.error}</p>`;
}
if (state.data) {
const { driveStats, firehoseStats, softwareInfo } = state.data;
return html`
<h1>The Pond</h1>
<div class="drivingStats">
${DriveStat("All Time", driveStats?.all, state.unit)}
${DriveStat("Past Week", driveStats?.week, state.unit)}
${DriveStat("FrogPilot", driveStats?.frogpilot, state.unit)}
</div>
<h2>Disk Usage</h2>
<div class="diskUsage">
${renderDiskUsageSection(state.data)}
</div>
<h2>Firehose Segments</h2>
<div class="firehoseStats">
<p>
<strong>${(firehoseStats?.segments ?? 0).toLocaleString("en-US")}</strong>
segments in training data.
</p>
</div>
<h2>Software Info</h2>
<div class="softwareInfo">
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
</div>
`;
}
return html`<p>No data available.</p>`;
}}
</div>
`;
return html`<div id="home_shell"><p>Loading...</p></div>`;
}
@@ -20,47 +20,49 @@
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
/* Colors */
--accent-bg: #673ab7;
--accent-hover-bg: #512da8;
--card-bg: #234423;
/* Colors — Galaxy palette (cosmic purple · teal · rose · amber) */
--accent-bg: #8b6cc5;
--accent-hover-bg: #7558b0;
--card-bg: #121224;
--color-black: #000000;
--color-confirm: #1a73e8;
--color-confirm-hover: #0758ad;
--color-gray-100: #f5f5f5;
--color-gray-200: #e0e0e0;
--color-gray-300: #c2c2c2;
--color-gray-400: #a4a4a4;
--color-gray-500: #8f8f8f;
--color-gray-600: #737373;
--color-gray-700: #595959;
--color-gray-800: #333333;
--color-gray-900: #1a1a1a;
--color-confirm: #8b6cc5;
--color-confirm-hover: #7558b0;
--color-gray-100: #f0f0f8;
--color-gray-200: #d8d8e4;
--color-gray-300: #b8b8cc;
--color-gray-400: #9898b0;
--color-gray-500: #7e7e98;
--color-gray-600: #636380;
--color-gray-700: #4a4a64;
--color-gray-800: #2e2e44;
--color-gray-900: #1a1a30;
--color-white: #ffffff;
--danger-bg: #b71c1c;
--danger-fg: #ef1313;
--danger-hover-bg: #d32f2f;
--input-bg: #2f5432;
--main-bg: #0b1b0b;
--main-fg: #178643;
--secondary-bg: #264026;
--selected-camera-bg: #1f2f1f;
--sidebar-active-bg: #64c87826;
--sidebar-bg: #264026;
--sidebar-border-color: #1e2e1e;
--danger-bg: #e05577;
--danger-fg: #e05577;
--danger-hover-bg: #c04466;
--glow-primary: 0 0 0 2px var(--main-fg), 0 0 10px rgba(139, 108, 197, 0.35);
--input-bg: #161630;
--main-bg: #06060f;
--main-fg: #8b6cc5;
--secondary-bg: #0e0e1a;
--selected-camera-bg: #0b0b18;
--sidebar-active-bg: rgba(139, 108, 197, 0.15);
--sidebar-bg: #0a0a16;
--sidebar-border-color: #1e1e3e;
--sidebar-fg: #ffffff;
--sidebar-title-fg: #193446;
--success-bg: #039226;
--success-fg: #00a100;
--success-hover-bg: #01be2d;
--text-color: #ffffff;
--text-muted: #a0a0a0;
--sidebar-title-fg: #8b6cc5;
--success-bg: #5ec8c8;
--success-fg: #5ec8c8;
--success-hover-bg: #4ab3b3;
--switch-inactive-bg: #1e1e3e;
--text-color: #e8e8f0;
--text-muted: #8080a8;
--text-on-primary: var(--sidebar-fg);
--text-on-surface: var(--text-color);
--thumb-color: #178643;
--track-color: #1a3a1a;
--warning-bg: #ff9800;
--warning-hover-bg: #f57c00;
--thumb-color: #8b6cc5;
--track-color: #14142e;
--warning-bg: #d4a060;
--warning-hover-bg: #b8884a;
/* Effects */
--disabled-opacity: 0.5;
@@ -71,7 +73,7 @@
--hover-scale-lg: scale(1.1);
/* Fonts */
--font-body: "Open Sans", sans-serif;
--font-body: "Inter", "Open Sans", sans-serif;
--font-mono: "Courier New", Courier, monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.85rem;
@@ -96,9 +98,9 @@
--width-xxxxl: 1200px;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
--shadow-md: 0 4px 12px rgba(0,0,0,0.2);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
/* Spacing */
--gap-xxs: 0.125rem;
@@ -172,12 +174,14 @@ input[type="checkbox"],
input[type="radio"],
.manage-keys-link,
.route_card,
.sidebar .menu_section > li > ul > li,
.sidebar .menu_section>li>ul>li,
.clickable {
cursor: pointer;
}
body { padding-bottom: 0 !important; }
body {
padding-bottom: 0 !important;
}
/* ――― Layout containers ――― */
.content {
@@ -199,9 +203,17 @@ h3 {
}
/* ――― Helpers & states ――― */
.hidden { display: none; }
html { cursor: default; }
.no_scroll { overflow: hidden; }
.hidden {
display: none;
}
html {
cursor: default;
}
.no_scroll {
overflow: hidden;
}
.not_implemented {
cursor: not-allowed;
@@ -251,7 +263,9 @@ textarea {
cursor: text;
}
label[for] { cursor: default; }
label[for] {
cursor: default;
}
/* ――― Snackbar component ――― */
.snackbar {
@@ -297,6 +311,7 @@ a {
bottom: 0;
opacity: 0;
}
to {
bottom: var(--snackbar-offset, 30px);
opacity: 1;
@@ -304,13 +319,20 @@ a {
}
@keyframes fadeout {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* ――― Breakpoint overrides ――― */
@media only screen and (max-width: var(--breakpoint-md)) {
.content { margin-left: 0; }
.content {
margin-left: 0;
}
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
@@ -320,6 +342,7 @@ a {
padding-left: 1rem;
padding-right: 1rem;
}
#snackbar_wrapper {
left: 50%;
transform: translateX(-50%);
@@ -333,6 +356,7 @@ a {
bottom: 0;
opacity: 0;
}
to {
bottom: var(--snackbar-offset, 30px);
opacity: 1;
@@ -340,18 +364,62 @@ a {
}
@-webkit-keyframes fadeout {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* ——— Tunnel notice banner ——— */
.tunnel-notice {
align-items: center;
background: linear-gradient(135deg, var(--card-bg), var(--secondary-bg));
border: var(--border-width-thin) var(--border-style-base) var(--sidebar-border-color);
border-left: 3px solid var(--main-fg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--text-color);
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin: var(--margin-xl) auto;
max-width: var(--width-xl);
padding: var(--padding-xl) var(--padding-xxl);
text-align: center;
}
.tunnel-notice-icon {
font-size: 2.5rem;
opacity: 0.8;
}
.tunnel-notice-title {
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin: 0;
}
.tunnel-notice-body {
color: var(--text-muted);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
margin: 0;
}
::-webkit-scrollbar {
height: 8px;
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--track-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--thumb-color);
border-radius: var(--border-radius-sm);
}
}
@@ -1,4 +1,4 @@
import { html } from "https://esm.sh/@arrow-js/core";
import { html } from "/assets/vendor/arrow-core.js";
export function Modal({
title,
@@ -109,7 +109,7 @@
.favorites-toggle-button:hover {
background-color: var(--main-fg);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
color: var(--text-color);
font-weight: var(--font-weight-bold);
transform: var(--hover-scale-sm);
@@ -352,7 +352,7 @@
.navigation-summary-widget button.directions:hover {
background-color: var(--success-hover-bg);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
color: var(--text-color);
transform: var(--hover-scale-sm);
}
@@ -466,12 +466,12 @@
}
.search-provider-toggle button.active {
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.search-provider-toggle button:hover {
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
@@ -533,4 +533,4 @@
min-width: 0;
width: calc(100% - var(--padding-xl));
}
}
}
@@ -1,4 +1,4 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core";
import { html, reactive } from "/assets/vendor/arrow-core.js";
import {
addRouteToMap,
formatMetersToHuman,
@@ -101,6 +101,25 @@ async function setSpecial(favorite, type, state, loadFavoritesAlphabetically) {
}
}
let mapboxLoadPromise = null;
function loadMapboxGL() {
if (mapboxLoadPromise) return mapboxLoadPromise;
mapboxLoadPromise = new Promise((resolve, reject) => {
const link = document.createElement("link");
link.href = "https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css";
link.rel = "stylesheet";
document.head.appendChild(link);
const script = document.createElement("script");
script.src = "https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js";
script.onload = resolve;
script.onerror = () => reject(new Error("Failed to load Mapbox GL"));
document.head.appendChild(script);
});
return mapboxLoadPromise;
}
export function NavDestination() {
let map;
let destinationMarker;
@@ -262,13 +281,18 @@ export function NavDestination() {
};
try {
state.destination = JSON.parse(data.destination);
} catch {}
} catch { }
try {
const prev = JSON.parse(data.previousDestinations);
state.previousDestinations = prev.map(d => ({ name: d.place_name }));
state.suggestions = JSON.stringify(state.previousDestinations);
} catch {}
setupMap();
} catch { }
try {
await setupMap();
} catch {
showSnackbar("Failed to load map resources…");
return;
}
loadFavoritesAlphabetically();
}
@@ -524,12 +548,18 @@ export function NavDestination() {
}
}
const setupMap = async () => {
const setupMap = async (retries = 0) => {
if (!state.mapboxPublic || state.initialized) return;
if (typeof mapboxgl === "undefined") {
await loadMapboxGL();
}
const container = document.getElementById("map");
if (!container) {
requestAnimationFrame(setupMap);
return;
if (retries >= 50) return;
await new Promise(r => requestAnimationFrame(r));
return setupMap(retries + 1);
}
state.initialized = true;
mapboxgl.accessToken = state.mapboxPublic;
@@ -585,9 +615,9 @@ export function NavDestination() {
return html`
<div class="navigation-container">
${() => {
if (state.missingKeys === null) return "";
return state.missingKeys
? html`
if (state.missingKeys === null) return "";
return state.missingKeys
? html`
<section class="keys-required-wrapper">
<div class="keys-required-widget">
<div class="keys-required-title">Mapbox Keys Required</div>
@@ -596,7 +626,7 @@ export function NavDestination() {
</div>
</section>
`
: html`
: html`
<div class="map-wrapper">
<div class="search-wrapper">
<div class="search-controls">
@@ -611,47 +641,47 @@ export function NavDestination() {
</div>
<div id="infobox">
${() => {
if (state.loadingRoute) {
return html`<div class="navigation-summary-widget loading-status"><span class="spinner"></span> Calculating route...</div>`;
} else if (state.selectedRoute) {
return NavigationDestination({
...state.selectedRoute,
isFavorited: isRouteFavorited(state.selectedRoute, state.favoriteRoutes),
isConfirmed: () => areRoutesEqual(state.selectedRoute, state.confirmedRoute),
map,
isMetric: state.isMetric,
cancelNavigationFn: () => {
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = state.previousDestinations;
if (destinationMarker) destinationMarker.remove();
},
onConfirm: () => {
state.confirmedRoute = JSON.parse(JSON.stringify(state.selectedRoute));
state.confirmedRouteRefresh = Math.random();
},
loadFavorites: loadFavoritesAlphabetically,
removeFavorite: confirmRemoveFavorite,
searchFieldState,
favoriteRoutes: state.favoriteRoutes
}, state.confirmedRouteRefresh);
} else if (JSON.parse(state.suggestions).length > 0) {
return SearchSuggestions({
suggestions: JSON.parse(state.suggestions),
selectSuggestion,
removeFavorite: confirmRemoveFavorite,
renameFavorite: confirmRenameFavorite,
setHome: setHome,
setWork: setWork
});
}
}}
if (state.loadingRoute) {
return html`<div class="navigation-summary-widget loading-status"><span class="spinner"></span> Calculating route...</div>`;
} else if (state.selectedRoute) {
return NavigationDestination({
...state.selectedRoute,
isFavorited: isRouteFavorited(state.selectedRoute, state.favoriteRoutes),
isConfirmed: () => areRoutesEqual(state.selectedRoute, state.confirmedRoute),
map,
isMetric: state.isMetric,
cancelNavigationFn: () => {
state.selectedRoute = null;
state.confirmedRoute = null;
state.suggestions = state.previousDestinations;
if (destinationMarker) destinationMarker.remove();
},
onConfirm: () => {
state.confirmedRoute = JSON.parse(JSON.stringify(state.selectedRoute));
state.confirmedRouteRefresh = Math.random();
},
loadFavorites: loadFavoritesAlphabetically,
removeFavorite: confirmRemoveFavorite,
searchFieldState,
favoriteRoutes: state.favoriteRoutes
}, state.confirmedRouteRefresh);
} else if (JSON.parse(state.suggestions).length > 0) {
return SearchSuggestions({
suggestions: JSON.parse(state.suggestions),
selectSuggestion,
removeFavorite: confirmRemoveFavorite,
renameFavorite: confirmRenameFavorite,
setHome: setHome,
setWork: setWork
});
}
}}
</div>
</div>
<div id="map"></div>
</div>
`;
}}
}}
</div>
${() => (state.showRemoveFavoriteModal ? Modal({
title: "Remove Favorite",
@@ -815,9 +845,9 @@ function NavigationDestination({
</div>
<div class="buttonCluster">
${() =>
isConfirmed()
? html`<button class="cancel" @click="${cancelNavigation}"><i class="bi bi-x-lg"></i> Cancel Navigation</button>`
: html`<button class="directions" @click="${confirmDestination}"><i class="bi bi-sign-turn-right"></i> Start Navigation</button>`}
isConfirmed()
? html`<button class="cancel" @click="${cancelNavigation}"><i class="bi bi-x-lg"></i> Cancel Navigation</button>`
: html`<button class="directions" @click="${confirmDestination}"><i class="bi bi-sign-turn-right"></i> Start Navigation</button>`}
<button class="favorite" @click="${toggleFavorite}">${isFavorited ? "💔 Unfavorite" : "❤️ Favorite"}</button>
</div>
</div>
@@ -47,7 +47,7 @@
transition: opacity 0.5s ease;
}
.navkeys-group > .navkeys-row:last-of-type {
.navkeys-group>.navkeys-row:last-of-type {
margin-bottom: 0;
}
@@ -79,7 +79,7 @@
.navkeys-input:hover,
.navkeys-input:focus {
border-color: var(--thumb-color);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
@@ -153,4 +153,4 @@
.navkeys-input {
font-size: var(--font-size-sm);
}
}
}
@@ -1,4 +1,4 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js";
export function NavKeys() {
@@ -1,4 +1,5 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
import { Modal } from "/assets/components/modal.js";
@@ -12,8 +13,18 @@ const state = reactive({
total: 0,
showDeleteAllModal: false,
isDeletingAll: false,
truncated: false,
})
const MAX_RENDERED_ROUTES = 250
const ROUTE_FLUSH_INTERVAL_MS = 120
let routesAbortController = null
let routesRequestToken = 0
let pendingRoutes = []
let flushTimerId = null
let seenRouteNames = new Set()
function formatRouteDate(dateString) {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
@@ -31,10 +42,73 @@ function formatRouteDate(dateString) {
return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}`
}
function resetRouteStreamState() {
pendingRoutes = []
if (flushTimerId !== null) {
clearTimeout(flushTimerId)
flushTimerId = null
}
seenRouteNames = new Set()
}
function flushPendingRoutes() {
if (pendingRoutes.length === 0) return
const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0)
if (availableSlots <= 0) {
pendingRoutes = []
state.truncated = true
return
}
const toAppend = pendingRoutes.slice(0, availableSlots)
pendingRoutes = []
if (toAppend.length > 0) {
state.routes = [...state.routes, ...toAppend]
}
if (state.routes.length >= MAX_RENDERED_ROUTES) {
state.truncated = true
}
}
function enqueueRoutes(rawRoutes) {
if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return
const nextRoutes = []
for (const route of rawRoutes) {
const name = String(route?.name || "")
if (!name || seenRouteNames.has(name)) continue
seenRouteNames.add(name)
nextRoutes.push({
...route,
timestamp: formatRouteDate(route.timestamp),
})
}
if (nextRoutes.length === 0) return
pendingRoutes.push(...nextRoutes)
if (flushTimerId === null) {
flushTimerId = setTimeout(() => {
flushTimerId = null
flushPendingRoutes()
}, ROUTE_FLUSH_INTERVAL_MS)
}
}
async function fetchRoutes() {
const requestToken = ++routesRequestToken
if (routesAbortController) {
routesAbortController.abort()
}
const controller = new AbortController()
routesAbortController = controller
try {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`);
const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error();
const reader = response.body.getReader();
@@ -45,24 +119,24 @@ async function fetchRoutes() {
const { value, done } = await reader.read();
if (done) break;
if (requestToken !== routesRequestToken) return
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
const lines = buffer.split(/\r?\n\r?\n/);
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
if (line.startsWith("data:")) {
try {
const data = JSON.parse(line.substring(6));
const payload = line.substring(5).trim()
if (!payload) continue
const data = JSON.parse(payload);
if (data.progress !== undefined && data.total !== undefined) {
state.progress = data.progress;
state.total = data.total;
}
if (data.routes) {
const routes = data.routes.map(route => ({
...route,
timestamp: formatRouteDate(route.timestamp),
}));
state.routes.push(...routes);
enqueueRoutes(data.routes)
}
} catch (e) {
console.error("Failed to parse JSON:", e);
@@ -70,21 +144,35 @@ async function fetchRoutes() {
}
}
}
} catch (_) {
state.error = "Couldn't load routes. Please try again later..."
flushPendingRoutes()
} catch (error) {
if (error?.name !== "AbortError") {
state.error = "Couldn't load routes. Please try again later..."
}
} finally {
state.loading = false
if (requestToken === routesRequestToken) {
flushPendingRoutes()
state.loading = false
if (routesAbortController === controller) {
routesAbortController = null
}
}
}
}
fetchRoutes()
function refresh() {
state.loading = true
state.error = null
state.routes = []
state.progress = 0
state.total = 0
state.truncated = false
resetRouteStreamState()
fetchRoutes()
}
refresh()
let overlay = null
function openDialog(htmlStr) {
@@ -124,27 +212,27 @@ async function deleteRoute(route) {
}
async function resetRouteName(route, dlg) {
const res = await fetch(`/api/routes/reset_name`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: route.name })
});
if (res.ok) {
const { timestamp } = await res.json();
closeDialog(dlg);
const routeInList = state.routes.find(r => r.name === route.name);
if (routeInList) {
routeInList.timestamp = formatRouteDate(timestamp);
}
route.timestamp = formatRouteDate(timestamp);
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = formatRouteDate(timestamp);
}
showSnackbar("Route name reset!");
} else {
showSnackbar("Resetting name failed...", "error");
const res = await fetch(`/api/routes/reset_name`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: route.name })
});
if (res.ok) {
const { timestamp } = await res.json();
closeDialog(dlg);
const routeInList = state.routes.find(r => r.name === route.name);
if (routeInList) {
routeInList.timestamp = formatRouteDate(timestamp);
}
route.timestamp = formatRouteDate(timestamp);
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = formatRouteDate(timestamp);
}
showSnackbar("Route name reset!");
} else {
showSnackbar("Resetting name failed...", "error");
}
}
async function renameRoute(route) {
@@ -317,6 +405,16 @@ async function deleteAllRoutes() {
}
export function RouteRecordings() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Dashcam Routes Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Loading dashcam routes requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute);
return html`
@@ -332,75 +430,36 @@ export function RouteRecordings() {
</button>
${() => {
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
if (routesToShow.length === 0) {
if (state.loading && state.total > 0) {
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
}
if (state.loading && !state.isDeletingAll) {
return html`<p class="screen-recordings-message">Loading...</p>`;
}
if (state.isDeletingAll) {
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
}
if (state.showPreservedOnly) {
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
}
if (state.error) {
return html`<p class="screen-recordings-message">${state.error}</p>`;
}
return html`<p class="screen-recordings-message">No routes found...</p>`;
}
if (routesToShow.length === 0) {
if (state.loading && state.total > 0) {
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
}
if (state.loading && !state.isDeletingAll) {
return html`<p class="screen-recordings-message">Loading...</p>`;
}
if (state.isDeletingAll) {
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
}
if (state.showPreservedOnly) {
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
}
if (state.error) {
return html`<p class="screen-recordings-message">${state.error}</p>`;
}
return html`<p class="screen-recordings-message">No routes found...</p>`;
}
return html`
return html`
<div class="screen-recordings-grid">
${routesToShow.map(
route => html`
route => html`
<div
class="recording-card"
@mouseenter="${e => {
if (state.selectedRoute) return;
const card = e.currentTarget;
const gif = card.querySelector(".recording-preview-gif");
const png = card.querySelector(".recording-preview-png");
if (card.dataset.gifLoaded) {
png.style.display = "none";
gif.style.display = "block";
return;
}
card.dataset.loadingGif = "true";
const preloader = new Image();
preloader.onload = () => {
if (card.dataset.loadingGif === "true") {
gif.src = preloader.src;
png.style.display = "none";
gif.style.display = "block";
card.dataset.gifLoaded = true;
}
delete card.dataset.loadingGif;
};
preloader.onerror = () => {
console.error("Failed to load preview GIF:", preloader.src);
delete card.dataset.loadingGif;
};
preloader.src = gif.dataset.src;
}}"
@mouseleave="${e => {
const card = e.currentTarget;
card.querySelector(".recording-preview-png").style.display = "block";
card.querySelector(".recording-preview-gif").style.display = "none";
if (card.dataset.loadingGif === "true") {
delete card.dataset.loadingGif;
}
}}"
@click="${() => {
state.selectedRoute = route;
}}"
state.selectedRoute = route;
}}"
>
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
@@ -410,23 +469,22 @@ export function RouteRecordings() {
src="${route.png}"
class="recording-preview recording-preview-png"
style="display:block;"
>
<img
data-src="${route.gif}"
class="recording-preview recording-preview-gif"
style="display:none;"
loading="lazy"
>
</div>
<p class="recording-filename">${route.timestamp}</p>
</div>
`
)}
)}
</div>
`;
}}
}}
${() => state.truncated ? html`
<p class="screen-recordings-message">Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.</p>
` : ""}
${() => {
if (state.routes.length > 0) {
return html`
if (state.routes.length > 0) {
return html`
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
@@ -435,17 +493,17 @@ export function RouteRecordings() {
${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
</button>
`;
}
return "";
}}
}
return "";
}}
</div>
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all routes? This action cannot be undone...",
onConfirm: deleteAllRoutes,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
title: "Confirm Delete All",
message: "Are you sure you want to delete all routes? This action cannot be undone...",
onConfirm: deleteAllRoutes,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
</div>
`;
}
@@ -1,4 +1,5 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js";
const state = reactive({
@@ -126,7 +127,6 @@ async function renameFile(rec) {
if (recordingToUpdate) {
recordingToUpdate.filename = newFilename
recordingToUpdate.is_custom_name = true
recordingToUpdate.gif = `/screen_recordings/${val}.gif`
recordingToUpdate.png = `/screen_recordings/${val}.png`
}
@@ -142,8 +142,8 @@ async function renameFile(rec) {
}
function confirmDeleteFile(rec) {
state.recordingToDelete = rec;
state.showDeleteModal = true;
state.recordingToDelete = rec;
state.showDeleteModal = true;
}
async function deleteFile() {
@@ -152,11 +152,11 @@ async function deleteFile() {
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
if (res.ok) {
closeOverlay();
refresh();
showSnackbar("Recording deleted!");
closeOverlay();
refresh();
showSnackbar("Recording deleted!");
} else {
showSnackbar("Delete failed...", "error");
showSnackbar("Delete failed...", "error");
}
state.showDeleteModal = false;
@@ -221,6 +221,16 @@ async function deleteAllRecordings() {
}
export function ScreenRecordings() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Screen Recordings Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Loading screen recordings requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (state.selectedRecording && !overlay) openOverlay(state.selectedRecording)
return html`
@@ -229,77 +239,37 @@ export function ScreenRecordings() {
<div class="screen-recordings-title">Screen Recordings</div>
${() => {
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
if (state.progress > 0 && state.progress < state.total) {
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
}
if (state.recordings.length === 0 && !state.loading) {
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
}
return ""
}}
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
if (state.progress > 0 && state.progress < state.total) {
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
}
if (state.recordings.length === 0 && !state.loading) {
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
}
return ""
}}
<div class="screen-recordings-grid">
${() => state.recordings.map(rec => {
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
return html`
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
return html`
<div
class="recording-card"
@mouseenter="${e => {
if (state.selectedRecording) return;
const card = e.currentTarget;
const gif = card.querySelector(".recording-preview-gif");
const png = card.querySelector(".recording-preview-png");
if (card.dataset.gifLoaded) {
png.style.display = "none";
gif.style.display = "block";
return;
}
card.dataset.loadingGif = "true";
const preloader = new Image();
preloader.onload = () => {
if (card.dataset.loadingGif === "true") {
gif.src = preloader.src;
png.style.display = "none";
gif.style.display = "block";
card.dataset.gifLoaded = true;
}
delete card.dataset.loadingGif;
};
preloader.onerror = () => {
console.error("Failed to load preview GIF:", preloader.src);
delete card.dataset.loadingGif;
};
preloader.src = gif.dataset.src;
}}"
@mouseleave="${e => {
const card = e.currentTarget;
card.querySelector(".recording-preview-png").style.display = "block";
card.querySelector(".recording-preview-gif").style.display = "none";
if (card.dataset.loadingGif === "true") {
delete card.dataset.loadingGif;
}
}}"
@click="${() => { state.selectedRecording = rec }}"
>
<div class="recording-preview-container">
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;">
<img data-src="${rec.gif}" class="recording-preview recording-preview-gif" style="display:none;">
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;" loading="lazy">
</div>
<p class="recording-filename">${displayName}</p>
</div>
`
})}
})}
</div>
${() => {
if (state.recordings.length > 0) {
return html`
if (state.recordings.length > 0) {
return html`
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
@@ -307,24 +277,24 @@ export function ScreenRecordings() {
Delete All Recordings
</button>
`
}
return ""
}}
}
return ""
}}
</div>
${() => state.showDeleteModal ? Modal({
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
onConfirm: deleteFile,
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
confirmText: "Delete"
}) : ""}
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
onConfirm: deleteFile,
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
confirmText: "Delete"
}) : ""}
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
onConfirm: deleteAllRecordings,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
title: "Confirm Delete All",
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
onConfirm: deleteAllRecordings,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
</div>
`
}
@@ -1,9 +1,12 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
import { hideSidebar } from "/assets/js/utils.js"
import { DoorControl } from "/assets/components/tools/doors.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js"
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
@@ -11,27 +14,54 @@ import { SettingsView } from "/assets/components/settings.js"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t"
import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TestingGround } from "/assets/components/tools/testing_ground.js"
import { Troubleshoot } from "/assets/components/tools/troubleshoot.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
import { TSKManager } from "/assets/components/tools/tsk_manager.js"
import { UpdateManager } from "/assets/components/tools/update_manager.js"
let router, routerState
function toRouterHref(href) {
try {
const url = new URL(href, window.location.origin)
return `${url.pathname}${url.search}${url.hash}`
} catch {
return href
}
}
function createRoute(id, path, component) {
return {
id,
path,
loader: () => {},
loader: () => { },
element: component,
}
}
function SafeHome() {
return html`
<div>
<h1>Galaxy</h1>
<p>Safe mode is active while UI rendering is being repaired.</p>
<p>
<a href="/galaxy">Galaxy Pairing</a> |
<a href="/device_settings">Toggles</a> |
<a href="/manage_updates">Software</a>
</p>
</div>
`
}
function Root() {
let routes = [
createRoute("doors", "/lock_or_unlock_doors", DoorControl),
createRoute("device_settings", "/device_settings/:section?", DeviceSettings),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing),
createRoute("navdestination", "/set_navigation_destination", NavDestination),
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
createRoute("root", "/", Home),
@@ -39,11 +69,16 @@ function Root() {
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("tailscale", "/manage_tailscale", TailscaleControl),
createRoute("model_manager", "/manage_models", ModelManager),
createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers),
createRoute("plots", "/plots", LivePlots),
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("testing_ground", "/testing_ground", TestingGround),
createRoute("troubleshoot", "/troubleshoot", Troubleshoot),
createRoute("tmux", "/manage_tmux", TmuxLog),
createRoute("toggles", "/manage_toggles", ToggleControl),
createRoute("tsk_manager", "/tsk_manager", TSKManager),
createRoute("updates", "/manage_updates", UpdateManager),
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
]
router = createRouter({
@@ -51,6 +86,11 @@ function Root() {
history: createBrowserHistory(),
}).initialize()
window.__thePondNavigate = (href) => {
router.navigate(toRouterHref(href))
window.scrollTo(0, 0)
}
routerState = reactive({
activePath: "/",
activePathFull: "/",
@@ -58,17 +98,21 @@ function Root() {
navigation: { state: "loading" },
errors: [],
params: {},
activeMatch: null,
})
router.subscribe(({ initialized, navigation, matches, errors }) => {
const [match] = matches
const match = Array.isArray(matches) && matches.length > 0
? matches[matches.length - 1]
: null
Object.assign(routerState, {
initialized,
activePath: match.route.path,
activePathFull: match.pathname,
activePath: match?.route?.path || "",
activePathFull: match?.pathname || "",
navigation,
params: match.params,
params: match?.params || {},
errors,
activeMatch: match,
})
})
@@ -76,17 +120,21 @@ function Root() {
${() => Sidebar(routerState.activePathFull)}
<div class="content">
${() => {
if (!routerState.initialized || routerState.navigation.state === "loading") {
return html`<div>Loading...</div>`
}
if (!routerState.initialized) {
return Home({ params: routerState.params })
}
if (routerState.errors?.root?.status === 404) {
return html`<h1>Not Found</h1>`
}
if (routerState.errors?.root?.status === 404) {
return html`<h1>Not Found</h1>`
}
const match = routes.find(r => r.path === routerState.activePath)
return match.element({ params: routerState.params })
}}
const routeElement = routerState.activeMatch?.route?.element
if (typeof routeElement !== "function") {
console.warn("[router] no route element for path:", routerState.activePathFull)
return Home({ params: routerState.params })
}
return routeElement({ params: routerState.params })
}}
</div>
`
}
@@ -97,7 +145,7 @@ export function Link(href, children, onClick, classes = "") {
href="${() => href}"
@click="${(e) => {
e.preventDefault()
router.navigate(e.currentTarget.href)
router.navigate(toRouterHref(e.currentTarget.href))
hideSidebar()
onClick?.()
}}"
@@ -105,8 +153,28 @@ export function Link(href, children, onClick, classes = "") {
}
export function Navigate(href) {
router.navigate(href)
router.navigate(toRouterHref(href))
window.scrollTo(0, 0)
}
Root()(document.getElementById("app"))
function mountRouterWhenReady() {
const mountNode = document.getElementById("app")
if (!mountNode) {
// Some embedded browsers execute module scripts before <body> is fully available.
setTimeout(mountRouterWhenReady, 0)
return
}
if (!window.__thePondRouterMounted) {
window.__thePondRouterMounted = true
Root()(mountNode)
} else {
console.warn("[router] duplicate mount prevented")
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountRouterWhenReady, { once: true })
} else {
mountRouterWhenReady()
}
@@ -30,7 +30,7 @@
position: relative;
}
.dropdown > select {
.dropdown>select {
appearance: none;
background-color: var(--sidebar-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
@@ -74,19 +74,19 @@ input.searchfield:focus {
outline: none;
}
input:checked + .slider {
input:checked+.slider {
background-color: var(--success-bg);
}
input:checked + .slider:before {
input:checked+.slider:before {
transform: translateX(26px);
}
input:checked + .slider.loading:before {
input:checked+.slider.loading:before {
animation: rotationChecked 1s linear infinite;
}
input:focus + .slider {
input:focus+.slider {
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
}
@@ -133,11 +133,11 @@ input:focus + .slider {
z-index: 1;
}
.options > input {
.options>input {
display: none;
}
.options > input:checked + label {
.options>input:checked+label {
background-color: var(--success-bg);
color: var(--text-color);
font-weight: bold;
@@ -220,14 +220,13 @@ input:focus + .slider {
grid-column: span 2;
}
.setting.subsetting_link {
}
.setting.subsetting_link {}
.setting.subtoggle {
margin: 0 0 0 var(--padding-xl);
}
.setting.subtoggle + .setting {
.setting.subtoggle+.setting {
margin-top: var(--border-radius-xl);
}
@@ -300,7 +299,7 @@ input.searchfield {
animation: rotation 1s linear infinite;
background-color: none !important;
border: var(--border-width-thick) solid var(--sidebar-fg);
border-bottom-color: #46439b;
border-bottom-color: var(--main-fg);
box-sizing: border-box;
}
@@ -337,6 +336,7 @@ i.switch {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
@@ -346,7 +346,8 @@ i.switch {
0% {
transform: translateX(26px) rotate(0deg);
}
100% {
transform: translateX(26px) rotate(360deg);
}
}
}
@@ -1,4 +1,4 @@
import { html } from "https://esm.sh/@arrow-js/core"
import { html } from "/assets/vendor/arrow-core.js"
import { upperFirst } from "/assets/js/utils.js"
import { Navigate } from "/assets/components/router.js"
@@ -60,7 +60,10 @@
}
.sidebar {
background-color: var(--sidebar-bg);
background:
radial-gradient(ellipse at 30% 80%, rgba(139, 108, 197, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 70% 20%, rgba(94, 200, 200, 0.04) 0%, transparent 50%),
var(--sidebar-bg);
border-right: var(--border-width-thin) solid var(--sidebar-border-color);
display: flex;
flex-direction: column;
@@ -102,7 +105,7 @@
padding: 0;
}
.sidebar .menu_section > li > a span {
.sidebar .menu_section>li>a span {
color: var(--text-color);
display: inline-block;
font-size: var(--font-size-lg);
@@ -111,13 +114,13 @@
padding-left: var(--padding-sm);
}
.sidebar .menu_section > li > ul {
.sidebar .menu_section>li>ul {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar .menu_section > li > ul > li {
.sidebar .menu_section>li>ul>li {
border-radius: var(--border-radius-sm);
margin-bottom: var(--padding-sm);
overflow: hidden;
@@ -125,23 +128,23 @@
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.sidebar .menu_section > li > ul > li > a.menu-item-link {
.sidebar .menu_section>li>ul>li>a.menu-item-link {
border-radius: inherit;
}
.sidebar .menu_section > li > ul > li.active,
.sidebar .menu_section > li > ul > li:hover {
.sidebar .menu_section>li>ul>li.active,
.sidebar .menu_section>li>ul>li:hover {
background-color: var(--sidebar-active-bg);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.sidebar .menu_section > li > ul > li.active > a {
.sidebar .menu_section>li>ul>li.active>a {
color: var(--color-white);
font-weight: var(--font-weight-bold);
}
.sidebar .menu_section > li > ul > li:hover > a {
.sidebar .menu_section>li>ul>li:hover>a {
color: var(--color-white);
font-weight: var(--font-weight-demi-bold);
}
@@ -203,9 +206,13 @@
}
.sidebar_header p {
color: var(--success-hover-bg);
background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
letter-spacing: 0.05em;
}
.sidebar_widget {
@@ -234,7 +241,7 @@
transition: var(--transition-fast);
}
.sidebar .menu_section > li > a {
.sidebar .menu_section>li>a {
font-size: var(--padding-sm);
}
@@ -262,7 +269,7 @@
transition: var(--transition-fast);
}
.sidebar .menu_section > li > a {
.sidebar .menu_section>li>a {
font-size: var(--padding-sm);
}
@@ -277,4 +284,4 @@
#menu_button {
display: none;
}
}
}
@@ -1,8 +1,7 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core";
import { Link } from "/assets/components/router.js";
import { upperFirst, hideSidebar } from "/assets/js/utils.js";
import { html } from "/assets/vendor/arrow-core.js";
import { hideSidebar, upperFirst } from "/assets/js/utils.js";
const MenuItems = {
const MENU_ITEMS = {
home: [
{ name: "Home", link: "/", icon: "bi-house-fill" },
],
@@ -14,138 +13,126 @@ const MenuItems = {
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
],
tailscale: [
{ name: "Tailscale", link: "/manage_tailscale", icon: "bi-wifi" },
],
tools: [
{ name: "Toggles", link: "/device_settings", icon: "bi-toggle-on" },
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" },
{ name: "Troubleshoot", link: "/troubleshoot", icon: "bi-tools" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
{ name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
{ name: "Toyota Security Keys", link: "/tsk_manager", icon: "bi-key-fill" },
{ name: "Backup and Restore", link: "/manage_toggles", icon: "bi-arrow-repeat" },
{ name: "Software", link: "/manage_updates", icon: "bi-arrow-up-circle" },
{ name: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" },
],
};
const state = reactive({
doorsVisible: false,
isDoorsFetched: false,
isTSKFetched: false,
tskVisible: false,
function matchesPath(currentPath, link) {
if (link === "/") return currentPath === "/";
return currentPath === link || currentPath.startsWith(`${link}/`);
}
activeRoute: ""
});
function buildSectionMarkup(section, links, currentPath) {
const linksMarkup = links.map((link) => {
const active = matchesPath(currentPath, link.link) ? "active" : "";
return `
<li class="${active}">
<a class="menu-item-link" href="${link.link}">
<i class="bi ${link.icon}"></i>
<span>${upperFirst(link.name)}</span>
</a>
</li>
`;
}).join("");
export function Sidebar() {
const currentPath = window.location.pathname;
const activeItem = Object.values(MenuItems).flat().find(item => item.link === currentPath);
state.activeRoute = activeItem?.name ?? "";
return `
<div class="sidebar_widget">
<ul class="menu_section">
<li>
<span class="section-title">${upperFirst(section)}</span>
<ul id="${section}">
${linksMarkup}
</ul>
</li>
</ul>
</div>
`;
}
if (!state.isDoorsFetched) {
state.isDoorsFetched = true;
(async () => {
try {
const response = await fetch("/api/doors_available");
const data = await response.json();
state.doorsVisible = data.result;
} catch (e) {
console.error("Failed to fetch door availability:", e);
}
})();
}
function bindSidebarHandlers() {
const menuButton = document.getElementById("menu_button");
const underlay = document.getElementById("sidebarUnderlay");
if (!state.isTSKFetched) {
state.isTSKFetched = true;
(async () => {
try {
const response = await fetch("/api/tsk_available");
const data = await response.json();
state.tskVisible = data.result;
} catch (e) {
console.error("Failed to fetch TSK availability:", e);
}
})();
}
if (!menuButton || !underlay) return;
function navigate(link) {
state.activeRoute = link.name;
window.scrollTo(0, 0);
hideSidebar();
document.querySelectorAll('.sidebar li').forEach(el => {
el.classList.remove('active');
if (!window.__thePondSidebarMenuBound) {
window.__thePondSidebarMenuBound = true;
menuButton.addEventListener("click", () => {
const sidebar = document.getElementById("sidebar");
const currentUnderlay = document.getElementById("sidebarUnderlay");
if (!sidebar || !currentUnderlay) return;
sidebar.classList.toggle("visible");
currentUnderlay.classList.toggle("hidden");
});
const linkElement = document.querySelector(`.sidebar li a[href="${link.link}"]`);
if (linkElement) {
linkElement.parentElement.classList.add('active');
}
}
return html`
underlay.onclick = hideSidebar;
document.querySelectorAll("#sidebar a.menu-item-link").forEach((anchor) => {
if (anchor.dataset.boundClick === "1") return;
anchor.dataset.boundClick = "1";
anchor.addEventListener("click", (event) => {
if (event.defaultPrevented) return;
if (event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
const href = anchor.getAttribute("href") || "/";
const navigate = window.__thePondNavigate;
if (typeof navigate === "function") {
navigate(href);
} else {
window.location.assign(href);
}
hideSidebar();
window.scrollTo(0, 0);
});
});
}
function renderSidebarIntoShell(currentPath) {
const shell = document.getElementById("sidebar_shell");
if (!shell) return;
const activePath = currentPath || window.location.pathname;
const sectionsMarkup = Object.entries(MENU_ITEMS)
.map(([section, links]) => buildSectionMarkup(section, links, activePath))
.join("");
shell.innerHTML = `
<div id="sidebarUnderlay" class="hidden"></div>
<div id="sidebar" class="sidebar">
<div>
<div class="title">
<img class="logo" src="/assets/images/main_logo.png" alt="FrogPilot logo" />
<img class="logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<div class="title_text sidebar_header">
<p>The Pond</p>
<a href="https://github.com/Aidenir">by&nbsp;Aidenir</a>
<p>Galaxy</p>
</div>
</div>
<hr />
${() => Object.entries(MenuItems).map(([section, links]) => html`
<div class="sidebar_widget">
<ul class="menu_section">
<li>
<span class="section-title">${upperFirst(section)}</span>
<ul id="${section}">
${links.map(link => {
if (link.name === "Lock/Unlock Doors" && !state.doorsVisible) {
return "";
}
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
return "";
}
const isActive = state.activeRoute === link.name;
const classList = [isActive && "active"].filter(Boolean).join(" ");
const content = html`
<div class="menu-item-link">
<i class="bi ${link.icon}"></i>
<span>${upperFirst(link.name)}</span>
</div>
`;
return html`
<li class="${classList}">
${Link(link.link, content, () => navigate(link))}
</li>
`;
})}
</ul>
</li>
</ul>
</div>
`)}
${sectionsMarkup}
</div>
</div>`;
</div>
`;
bindSidebarHandlers();
}
function setupMenuButton() {
const button = document.getElementById("menu_button");
const sidebar = document.getElementById("sidebar");
const underlay = document.getElementById("sidebarUnderlay");
button.addEventListener("click", () => {
sidebar.classList.toggle("visible");
underlay.classList.toggle("hidden");
});
underlay.addEventListener("click", hideSidebar);
export function Sidebar(currentPath) {
setTimeout(() => renderSidebarIntoShell(currentPath), 0);
return html`<div id="sidebar_shell"></div>`;
}
document.addEventListener("DOMContentLoaded", setupMenuButton, false);
@@ -1,4 +1,4 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js";
export function TailscaleControl() {
@@ -52,13 +52,14 @@ export function TailscaleControl() {
checkInstallStatus()
return html`
<div class="tailscale-wrapper">
<div class="toggle-control-widget" style="margin-top: 1.5rem">
<section class="tailscale-widget">
<div class="tailscale-title">
<div class="toggle-control-title">
${() => state.installed ? 'Uninstall Tailscale' : 'Install Tailscale'}
</div>
<p class="tailscale-text">
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!<br><br>
<strong style="color: #ff9494;">Note: Not recommended. Using Galaxy Tunnel is the preferred remote connection method.</strong>
</p>
<div class="tailscale-button-wrapper">
<button
@@ -67,11 +68,11 @@ export function TailscaleControl() {
disabled="${() => state.status === 'installing' || state.status === 'uninstalling'}"
>
${() => {
if (state.status === 'installing') return 'Installing...'
if (state.status === 'uninstalling') return 'Uninstalling...'
if (state.installed) return 'Uninstall'
return 'Install'
}}
if (state.status === 'installing') return 'Installing...'
if (state.status === 'uninstalling') return 'Uninstalling...'
if (state.installed) return 'Uninstall'
return 'Install'
}}
</button>
<a class="tailscale-link" href="https://tailscale.com/download" target="_blank">
Download Tailscale on your other devices
@@ -79,12 +80,12 @@ export function TailscaleControl() {
</div>
</section>
${() => state.showUninstallModal ? Modal({
title: "Confirm Uninstall",
message: "Are you sure you want to uninstall Tailscale?",
onConfirm: handleAction,
onCancel: () => { state.showUninstallModal = false; },
confirmText: "Uninstall"
}) : ""}
title: "Confirm Uninstall",
message: "Are you sure you want to uninstall Tailscale?",
onConfirm: handleAction,
onCancel: () => { state.showUninstallModal = false; },
confirmText: "Uninstall"
}) : ""}
</div>
`
}
@@ -0,0 +1,498 @@
/* ――― Device Settings Page ――― */
.ds-wrapper {
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
/* ――― Section Tabs ――― */
.ds-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: var(--margin-base);
}
.ds-tab {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: 0.75rem;
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
font-family: var(--font-body);
font-size: 0.88rem;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.ds-tab i {
font-size: 0.9rem;
}
.ds-tab:hover {
border-color: var(--main-fg);
color: var(--text-color);
}
.ds-tab.active {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
/* ――― Search / Filter ――― */
.ds-search-row {
align-items: center;
display: flex;
gap: 0.5rem;
margin-bottom: var(--margin-lg);
}
.ds-search {
background-color: var(--input-bg);
border: var(--border-style-input);
border-radius: var(--border-radius-lg);
box-sizing: border-box;
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-base);
margin-bottom: 0;
outline: none;
padding: var(--padding-sm) var(--padding-base);
transition: box-shadow var(--transition-fast);
width: 100%;
flex: 1;
}
.ds-search:focus {
box-shadow: 0 0 0 2px var(--main-fg);
}
.ds-search-clear {
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
cursor: pointer;
font-family: var(--font-body);
font-size: var(--font-size-sm);
min-width: 4.75rem;
padding: 0.6rem 0.8rem;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.ds-search-clear:hover {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
/* ――― Section Cards ――― */
.ds-section {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--margin-lg);
overflow: hidden;
transition: box-shadow var(--transition-fast);
}
.ds-section:hover {
box-shadow: var(--shadow-md);
}
.ds-section-header {
align-items: center;
background-color: var(--input-bg);
display: flex;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
user-select: none;
}
.ds-static-header {
cursor: default;
}
.ds-section-header i {
color: var(--main-fg);
font-size: var(--font-size-lg);
margin-right: var(--margin-sm);
min-width: 1.5rem;
text-align: center;
}
.ds-section-title {
color: var(--text-color);
flex: 1;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
}
.ds-section-chevron {
color: var(--text-muted);
font-size: var(--font-size-sm);
transition: transform var(--transition-fast);
}
.ds-section.collapsed .ds-section-chevron {
transform: rotate(-90deg);
}
.ds-section-body {
max-height: 5000px;
overflow: hidden;
transition: max-height 0.3s ease-in-out;
}
.ds-section.collapsed .ds-section-body {
max-height: 0;
}
/* ――― Individual Toggle Rows ――― */
.ds-row {
align-items: center;
border-top: 1px solid var(--sidebar-border-color);
display: flex;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
transition: background-color var(--transition-fast);
}
.ds-row:hover {
background-color: var(--sidebar-active-bg);
}
.ds-row-label {
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
margin-right: var(--margin-base);
}
.ds-row-desc {
color: var(--text-muted);
font-size: var(--font-size-xs);
margin-top: 0.25rem;
line-height: 1.3;
white-space: pre-line;
}
/* ――― Child Row Modifier (Sub-menus) ――― */
.ds-child-modifier {
border-left: 2px solid var(--color-gray-200);
margin-left: 1rem;
padding-left: 1rem;
}
.ds-manage-btn {
display: inline-flex;
align-items: center;
background-color: var(--color-gray-200);
border: 1px solid var(--color-gray-300);
border-radius: 1rem;
color: var(--main-fg);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
margin-top: 0.6rem;
padding: 0.25rem 0.75rem;
transition: all var(--transition-fast);
user-select: none;
width: fit-content;
}
.ds-manage-btn:hover {
background-color: var(--color-gray-300);
}
.ds-manage-btn i {
margin-left: 0.3rem;
}
/* ――― Toggle Switch ――― */
.ds-toggle {
appearance: none;
background-color: var(--track-color);
border: none;
border-radius: 1rem;
flex-shrink: 0;
height: 1.5rem;
outline: none;
position: relative;
transition: background-color var(--transition-fast);
width: 2.75rem;
}
.ds-toggle::after {
background-color: var(--color-gray-300);
border-radius: 50%;
content: "";
height: 1.1rem;
left: 0.2rem;
position: absolute;
top: 0.2rem;
transition: transform var(--transition-fast), background-color var(--transition-fast);
width: 1.1rem;
}
.ds-toggle:checked {
background-color: var(--main-fg);
}
.ds-select {
appearance: none;
background-color: var(--color-gray-950);
border: 1px solid var(--track-color);
border-radius: 0.5rem;
color: var(--color-gray-200);
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
padding: 0.5rem 2rem 0.5rem 1rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
max-width: 250px;
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/200.svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1em;
}
.ds-select:hover {
border-color: var(--color-gray-500);
}
.ds-select:focus {
border-color: var(--main-fg);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
.ds-toggle:checked::after {
background-color: var(--color-white);
transform: translateX(1.25rem);
}
.ds-toggle:disabled {
opacity: var(--disabled-opacity);
}
/* ――― Loading State ――― */
.ds-loading {
color: var(--text-muted);
padding: var(--padding-xl);
text-align: center;
}
.ds-status-bar {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: var(--font-size-xs);
gap: var(--gap-sm);
justify-content: space-between;
margin-bottom: var(--margin-sm);
}
/* ――― Empty Filter State ――― */
.ds-empty {
color: var(--text-muted);
font-style: italic;
padding: var(--padding-lg);
text-align: center;
}
/* ――― Custom Range Slider ――― */
.ds-row-numeric {
align-items: flex-start;
flex-direction: column;
}
.ds-row-info {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: var(--margin-sm);
width: 100%;
}
.ds-row-value {
background-color: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-sm);
padding: 0.2rem 0.6rem;
}
.ds-stepper-container {
width: 100%;
}
@media (min-width: 768px) {
.ds-stepper-container {
max-width: 350px;
}
}
.ds-stepper {
align-items: center;
background: var(--input-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
display: grid;
gap: var(--gap-sm);
grid-template-columns: 56px 1fr 56px;
padding: 0.45rem;
}
.ds-stepper-meta {
align-items: center;
color: var(--text-muted);
display: flex;
flex-direction: column;
font-size: var(--font-size-xs);
gap: 0.1rem;
justify-content: center;
min-height: 2rem;
text-align: center;
}
.ds-default-value {
color: var(--text-color);
font-weight: var(--font-weight-semibold);
}
.ds-step-value {
color: var(--text-muted);
}
.ds-manual-row {
align-items: center;
display: flex;
gap: 0.35rem;
margin-top: 0.15rem;
width: 100%;
}
.ds-manual-input {
background: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-sm);
color: var(--text-color);
flex: 1;
font-family: var(--font-body);
font-size: var(--font-size-xs);
min-width: 0;
padding: 0.22rem 0.35rem;
}
.ds-manual-input:focus {
border-color: var(--main-fg);
outline: none;
}
.ds-apply-btn {
background: transparent;
border: var(--border-style-main);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-xs);
padding: 0.2rem 0.45rem;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
}
.ds-apply-btn:hover:not(:disabled) {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
.ds-apply-btn:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.ds-reset-btn {
background: transparent;
border: var(--border-style-main);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size-xs);
margin-top: 0.15rem;
padding: 0.2rem 0.45rem;
transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.ds-reset-btn:hover:not(:disabled) {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
}
.ds-reset-btn:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.ds-stepper-btn {
align-items: center;
background: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
cursor: pointer;
display: inline-flex;
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
height: 2.1rem;
justify-content: center;
line-height: 1;
transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.ds-stepper-btn:hover:not(:disabled) {
background: var(--main-fg);
border-color: var(--main-fg);
color: var(--color-black);
transform: translateY(-1px);
}
.ds-stepper-btn:disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.ds-wrapper {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
.ds-search-row {
align-items: stretch;
flex-direction: column;
}
.ds-search-clear {
width: 100%;
}
.ds-tabs {
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.ds-tabs::-webkit-scrollbar {
display: none;
}
}
@@ -0,0 +1,825 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const endpointOptionsCache = {}
const endpointOptionsInflight = {}
// Plain variables — scheduling/routing flags that must NOT be reactive
let syncScheduled = false
let lastParams = null
// Module-level state (persists across route changes)
const state = reactive({
layout: [],
allKeys: [],
paramMetaByKey: {},
values: {},
defaultValues: {},
loadingLayout: true,
loadingValues: true,
filter: "",
expanded: {},
fetched: false,
activeSectionSlug: "",
numericUpdating: {},
})
function slugifySectionName(name) {
return String(name || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
function getSectionsWithSlug() {
return state.layout.map(section => ({
...section,
slug: slugifySectionName(section.name),
}))
}
function toSelectValue(value) {
return value === null || value === undefined ? "" : String(value)
}
function resolveEndpointTemplate(template) {
if (!template) return ""
return String(template).replace(/\{([A-Za-z0-9_]+)\}/g, (_, key) => {
return encodeURIComponent(toSelectValue(state.values[key]))
})
}
function scheduleSyncInputs() {
if (syncScheduled) return
syncScheduled = true
requestAnimationFrame(() => {
syncScheduled = false
syncInputs()
})
}
function applySelectOptions(el, options) {
el.innerHTML = ""
for (const opt of options || []) {
const o = document.createElement("option")
o.value = String(opt.value)
o.textContent = opt.label
el.appendChild(o)
}
}
async function hydrateEndpointOptions(el, key, endpoint) {
if (endpointOptionsCache[endpoint]) {
applySelectOptions(el, endpointOptionsCache[endpoint])
el.dataset.hydrated = "1"
el.value = toSelectValue(state.values[key])
return
}
if (!endpointOptionsInflight[endpoint]) {
endpointOptionsInflight[endpoint] = fetch(endpoint)
.then(r => r.json())
.then(options => {
endpointOptionsCache[endpoint] = options
return options
})
.catch(() => null)
.finally(() => {
delete endpointOptionsInflight[endpoint]
})
}
const options = await endpointOptionsInflight[endpoint]
if (!options || !el.isConnected) return
applySelectOptions(el, options)
el.dataset.hydrated = "1"
el.value = toSelectValue(state.values[key])
}
function syncInputs() {
// Sync checkboxes — set DOM property directly (attribute alone is unreliable)
for (const el of document.querySelectorAll("input[type='checkbox'].ds-toggle[id^='ds-']")) {
el.checked = !!state.values[el.id.slice(3)]
}
// Sync selects — hydrate options + set value
for (const el of document.querySelectorAll("select.ds-select[id^='ds-']")) {
const key = el.id.slice(3)
const endpointTemplate = el.getAttribute("data-endpoint")
const endpoint = resolveEndpointTemplate(endpointTemplate)
const inlineOptions = state.paramMetaByKey[key]?.options
if (endpoint) {
if (!el.dataset.hydrated || el.dataset.endpoint !== endpoint) {
el.dataset.endpoint = endpoint
hydrateEndpointOptions(el, key, endpoint)
} else {
el.value = toSelectValue(state.values[key])
}
continue
}
if (Array.isArray(inlineOptions) && inlineOptions.length > 0) {
if (!el.dataset.hydrated) {
applySelectOptions(el, inlineOptions)
el.dataset.hydrated = "1"
}
el.value = toSelectValue(state.values[key])
}
}
}
async function fetchDefaultValues() {
try {
const defaultsRes = await fetch("/api/params/defaults")
if (!defaultsRes.ok) return false
const defaultsData = await defaultsRes.json()
state.defaultValues = defaultsData || {}
return true
} catch (e) {
return false
}
}
async function fetchLayoutAndParams() {
state.loadingLayout = true
state.loadingValues = true
try {
const layoutRes = await fetch("/assets/components/tools/device_settings_layout.json")
const rawLayoutData = await layoutRes.json()
const layoutData = rawLayoutData
.map(section => ({
...section,
params: (section.params || []).filter(param => param.key !== "Model"),
}))
.filter(section => section.params.length > 0)
state.layout = layoutData
const keys = []
const paramMetaByKey = {}
for (const section of layoutData) {
for (const p of section.params) {
keys.push(p.key)
paramMetaByKey[p.key] = p
}
}
state.allKeys = keys
state.paramMetaByKey = paramMetaByKey
} catch (e) {
console.error("Failed to fetch UI layout:", e)
}
state.loadingLayout = false
// Pull params once at page load; local state handles subsequent edits.
try {
const valuesRes = await fetch("/api/params/all")
const data = await valuesRes.json()
state.values = data
if (!(await fetchDefaultValues())) {
state.defaultValues = {}
}
} catch (e) {
console.error("Failed to fetch param values:", e)
state.defaultValues = {}
}
state.loadingValues = false
// Resolve slug now that layout is available (uses stored route params)
resolveActiveSectionSlug(lastParams)
scheduleSyncInputs()
}
function formatSliderValue(val, stepStr, precisionInt, key) {
if (val === null || val === undefined) return "--"
const v = parseFloat(val)
if (Number.isNaN(v)) return val
const volumeKeys = [
"DisengageVolume", "EngageVolume", "PromptVolume",
"PromptDistractedVolume", "RefuseVolume",
"WarningImmediateVolume", "WarningSoftVolume",
]
if (key && volumeKeys.includes(key)) {
if (v === 0) return "Muted"
if (v === 101) return "Auto"
return `${v}%`
}
if (precisionInt !== undefined && precisionInt !== null) {
return Number(v.toFixed(precisionInt)).toString()
}
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
const dec = stepStr.split(".")[1].length
return Number(v.toFixed(dec)).toString()
}
function formatNumericForInput(value, precision) {
const n = Number(value)
if (!Number.isFinite(n)) return ""
return Number(n.toFixed(precision)).toString()
}
function formatStepValue(step, precision) {
const n = Number(step)
if (!Number.isFinite(n)) return "1"
return Number(n.toFixed(Math.max(0, precision))).toString()
}
function numericBounds(param) {
const defaultBounds = {
min: param.min !== undefined ? param.min : (param.data_type === "float" ? 0.0 : 0),
max: param.max !== undefined ? param.max : (param.data_type === "float" ? 100.0 : 100),
step: param.step !== undefined ? param.step : (param.data_type === "float" ? 0.01 : 1),
}
const toFinite = (value) => {
const n = Number(value)
return Number.isFinite(n) ? n : null
}
if (param.key === "ScreenBrightness") {
return { min: 1, max: 101, step: 1 }
}
if (param.key === "ScreenBrightnessOnroad") {
return { min: 0, max: 101, step: 1 }
}
// Personality jerk params are stored as percentage-style integers (25..200).
// Layout metadata currently uses normalized 0.5..3.0 ranges, which breaks
// the +/- stepper and clamps values like 50 down to 3.
if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) {
return { min: 25, max: 200, step: 1 }
}
if (param.key === "SteerKP") {
const base = toFinite(state.values.SteerKPStock) || toFinite(state.values.SteerKP) || 0.6
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
if (param.key === "SteerLatAccel") {
const base = toFinite(state.values.SteerLatAccelStock) || toFinite(state.values.SteerLatAccel) || 2.0
return { min: +(base * 0.5).toFixed(2), max: +(base * 1.25).toFixed(2), step: 0.01 }
}
if (param.key === "SteerRatio") {
const base = toFinite(state.values.SteerRatioStock) || toFinite(state.values.SteerRatio) || 15.0
return { min: +(base * 0.25).toFixed(2), max: +(base * 1.5).toFixed(2), step: 0.01 }
}
return defaultBounds
}
function coerceValueByType(rawValue, dataType) {
if (dataType === "int") {
const n = Number.parseInt(rawValue, 10)
return Number.isFinite(n) ? n : rawValue
}
if (dataType === "float") {
const n = Number.parseFloat(rawValue)
return Number.isFinite(n) ? n : rawValue
}
return rawValue
}
function stepPrecision(step, explicitPrecision) {
if (explicitPrecision !== undefined && explicitPrecision !== null && explicitPrecision !== "") {
const parsed = Number.parseInt(explicitPrecision, 10)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
const stepStr = String(step ?? "")
if (!stepStr.includes(".")) return 0
return stepStr.split(".")[1].length
}
function clampNumeric(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function snapNumericToBoundsAndStep(rawValue, bounds, precision) {
const min = Number(bounds.min)
const max = Number(bounds.max)
const step = Number(bounds.step)
const value = Number(rawValue)
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(value)) return null
const clamped = clampNumeric(value, min, max)
if (!Number.isFinite(step) || step <= 0) {
return clampNumeric(Number(clamped.toFixed(precision)), min, max)
}
const snapped = min + Math.round((clamped - min) / step) * step
return clampNumeric(Number(snapped.toFixed(precision)), min, max)
}
function resolveCurrentNumericValue(param, bounds) {
const raw = state.values[param.key]
const precision = stepPrecision(bounds.step, param.precision)
const snapped = snapNumericToBoundsAndStep(raw, bounds, precision)
if (snapped !== null) return snapped
const fallback = Number(bounds.min)
return Number.isFinite(fallback) ? fallback : 0
}
function resolveDefaultNumericValue(param, bounds) {
const precision = stepPrecision(bounds.step, param.precision)
const stockKey = `${param.key}Stock`
// Prefer live vehicle stock values when available.
const liveStock = snapNumericToBoundsAndStep(state.values?.[stockKey], bounds, precision)
if (liveStock !== null) return liveStock
// Fallback to default table stock value if present.
const defaultStock = snapNumericToBoundsAndStep(state.defaultValues?.[stockKey], bounds, precision)
if (defaultStock !== null) return defaultStock
// Final fallback: generic param default.
return snapNumericToBoundsAndStep(state.defaultValues?.[param.key], bounds, precision)
}
function isNumericUpdating(key) {
return !!state.numericUpdating[key]
}
function showParamSnackbar(message, level, timeout = 2200) {
showSnackbar(message, level, timeout, {
key: "device-settings-param-update",
replace: true,
})
}
function syncNumericDisplay(param, rawValue) {
const displayEl = document.getElementById(`ds-display-${param.key}`)
if (!displayEl) return
const bounds = numericBounds(param)
displayEl.textContent = formatSliderValue(
rawValue,
String(bounds.step),
param.precision,
param.key,
)
}
async function updateNumericParam(param, numericValue, options = {}) {
const key = param.key
const current = state.values[key]
const successMessage = options.successMessage
state.numericUpdating = { ...state.numericUpdating, [key]: true }
state.values = { ...state.values, [key]: numericValue }
syncNumericDisplay(param, numericValue)
try {
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value: coerceValueByType(numericValue, param.data_type) }),
})
const data = await res.json()
if (res.ok) {
const updated = (data.updated && typeof data.updated === "object") ? data.updated : {}
const resolvedValue = Object.prototype.hasOwnProperty.call(updated, key) ? updated[key] : numericValue
state.values = { ...state.values, [key]: resolvedValue, ...updated }
state.numericUpdating = { ...state.numericUpdating, [key]: false }
syncNumericDisplay(param, resolvedValue)
showParamSnackbar(successMessage || data.message || `Parameter '${key}' updated.`)
scheduleSyncInputs()
} else {
state.values = { ...state.values, [key]: current }
state.numericUpdating = { ...state.numericUpdating, [key]: false }
syncNumericDisplay(param, current)
showParamSnackbar(data.error || "Failed to update parameter", "error")
}
} catch (e) {
state.values = { ...state.values, [key]: current }
state.numericUpdating = { ...state.numericUpdating, [key]: false }
syncNumericDisplay(param, current)
showParamSnackbar("Network error — is the device reachable?", "error")
}
}
function stepNumericParam(param, direction) {
const bounds = numericBounds(param)
const min = Number(bounds.min)
const max = Number(bounds.max)
const step = Number(bounds.step)
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(step) || step <= 0) return
if (isNumericUpdating(param.key)) return
const current = resolveCurrentNumericValue(param, bounds)
const precision = stepPrecision(step, param.precision)
const epsilon = Math.pow(10, -(precision + 2))
const next = snapNumericToBoundsAndStep(current + (direction * step), bounds, precision)
if (next === null) return
if (Math.abs(next - current) <= epsilon) return
updateNumericParam(param, next)
}
function applyManualNumericParam(param) {
if (isNumericUpdating(param.key)) return
const inputEl = document.getElementById(`ds-manual-${param.key}`)
if (!inputEl) return
const raw = String(inputEl.value ?? "").trim()
if (!raw) {
showParamSnackbar("Enter a value first.", "error")
return
}
const parsed = Number.parseFloat(raw)
if (!Number.isFinite(parsed)) {
showParamSnackbar("Enter a valid number.", "error")
return
}
const bounds = numericBounds(param)
const precision = stepPrecision(bounds.step, param.precision)
const snapped = snapNumericToBoundsAndStep(parsed, bounds, precision)
if (snapped === null) {
showParamSnackbar("Value is out of range.", "error")
return
}
inputEl.value = formatNumericForInput(snapped, precision)
const current = resolveCurrentNumericValue(param, bounds)
const epsilon = Math.pow(10, -(precision + 2))
if (Math.abs(snapped - current) <= epsilon) return
updateNumericParam(param, snapped)
}
async function resetNumericParam(param) {
const bounds = numericBounds(param)
let defaultValue = resolveDefaultNumericValue(param, bounds)
if (defaultValue === null) {
const loaded = await fetchDefaultValues()
if (!loaded) {
showParamSnackbar("Couldn't load defaults. Try refreshing the page.", "error")
return
}
defaultValue = resolveDefaultNumericValue(param, bounds)
}
if (defaultValue === null) {
showParamSnackbar("No default value available for this setting.", "error")
return
}
if (isNumericUpdating(param.key)) return
const current = resolveCurrentNumericValue(param, bounds)
const precision = stepPrecision(bounds.step, param.precision)
const epsilon = Math.pow(10, -(precision + 2))
if (Math.abs(defaultValue - current) <= epsilon) return
updateNumericParam(param, defaultValue, {
successMessage: `Parameter '${param.key}' reset to default.`,
})
}
async function updateParam(key, elType) {
const current = state.values[key]
const el = document.getElementById(`ds-${key}`)
if (!el) return
const param = state.paramMetaByKey[key] || {}
let formattedVal
if (elType === "checkbox") {
formattedVal = !!el.checked
} else if (elType === "dropdown") {
formattedVal = coerceValueByType(el.value, param.data_type)
} else {
formattedVal = coerceValueByType(el.value, param.data_type)
}
try {
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value: formattedVal }),
})
const data = await res.json()
if (res.ok) {
const updated = (data.updated && typeof data.updated === "object") ? data.updated : {}
state.values = { ...state.values, [key]: formattedVal, ...updated }
showParamSnackbar(data.message || `Parameter '${key}' updated.`)
scheduleSyncInputs()
} else {
revertInput(key, current, elType)
showParamSnackbar(data.error || "Failed to update parameter", "error")
}
} catch (e) {
revertInput(key, current, elType)
showParamSnackbar("Network error — is the device reachable?", "error")
}
}
function revertInput(key, current, elType) {
const el = document.getElementById(`ds-${key}`)
if (!el) return
if (elType === "checkbox") {
el.checked = !!current
return
}
if (elType === "dropdown") {
el.value = toSelectValue(current)
return
}
el.value = current
}
function toggleManage(key) {
state.expanded = { ...state.expanded, [key]: !state.expanded[key] }
scheduleSyncInputs()
}
function matchesFilter(p) {
if (!state.filter) return true
const q = state.filter.toLowerCase()
return p.label.toLowerCase().includes(q) || p.key.toLowerCase().includes(q)
}
function clearSearchFilter() {
if (!state.filter) return
state.filter = ""
scheduleSyncInputs()
}
function handleSectionTabClick(sectionSlug, event) {
if (!sectionSlug || sectionSlug === state.activeSectionSlug) return
// Preserve horizontal tab strip position on mobile when switching sections.
const tabsEl = event?.currentTarget?.closest(".ds-tabs")
const preservedScrollLeft = tabsEl ? tabsEl.scrollLeft : null
state.activeSectionSlug = sectionSlug
if (preservedScrollLeft !== null) {
requestAnimationFrame(() => {
const nextTabsEl = document.getElementById("ds-tabs")
if (nextTabsEl) nextTabsEl.scrollLeft = preservedScrollLeft
})
}
}
function renderSettingRow(p) {
if (p.parent_key && !state.filter) {
if (!state.values[p.parent_key]) return ""
if (!state.expanded[p.parent_key]) return ""
}
const isNumeric = p.ui_type === "numeric"
const isChild = p.parent_key ? "ds-child-modifier" : ""
return html`
<div class="ds-row ${isNumeric ? "ds-row-numeric" : ""} ${isChild}">
<div class="ds-row-info">
<div class="ds-row-text">
<span class="ds-row-label">${p.label}</span>
${p.description ? html`<div class="ds-row-desc">${p.description}</div>` : ""}
${() => p.is_parent_toggle && state.values[p.key] ? html`
<div class="ds-manage-btn" @click="${() => toggleManage(p.key)}">
${state.expanded[p.key] ? "Close" : "Manage"}
<i class="bi bi-chevron-${state.expanded[p.key] ? "up" : "down"}"></i>
</div>
` : ""}
</div>
${isNumeric ? html`<span class="ds-row-value" id="ds-display-${p.key}">${() => {
const currentValue = state.values[p.key]
const bounds = numericBounds(p)
return currentValue !== undefined ? formatSliderValue(currentValue, String(bounds.step), p.precision, p.key) : ".."
}}</span>` : ""}
</div>
${isNumeric ? html`
<div class="ds-stepper-container">
${(() => {
const bounds = numericBounds(p)
const currentNumeric = resolveCurrentNumericValue(p, bounds)
const precision = stepPrecision(bounds.step, p.precision)
const epsilon = Math.pow(10, -(precision + 2))
const updating = isNumericUpdating(p.key)
const canDecrease = !updating && currentNumeric > (Number(bounds.min) + epsilon)
const canIncrease = !updating && currentNumeric < (Number(bounds.max) - epsilon)
const defaultNumeric = resolveDefaultNumericValue(p, bounds)
const defaultLabel = defaultNumeric !== null
? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key)
: "N/A"
const canReset = !updating && defaultNumeric !== null && Math.abs(defaultNumeric - currentNumeric) > epsilon
const stepLabel = formatStepValue(bounds.step, precision)
return html`
<div class="ds-stepper">
<button
class="ds-stepper-btn"
?disabled="${!canDecrease}"
@click="${() => stepNumericParam(p, -1)}">-</button>
<div class="ds-stepper-meta">
<span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
<span class="ds-step-value">Step: ${stepLabel} per click</span>
<span class="ds-default-value">Default: ${defaultLabel}</span>
<div class="ds-manual-row">
<input
type="number"
class="ds-manual-input"
id="ds-manual-${p.key}"
min="${bounds.min}"
max="${bounds.max}"
step="${bounds.step}"
?disabled="${updating}"
value="${() => formatNumericForInput(resolveCurrentNumericValue(p, numericBounds(p)), precision)}"
@keydown="${(e) => {
if (e.key !== "Enter") return
e.preventDefault()
applyManualNumericParam(p)
}}" />
<button
class="ds-apply-btn"
?disabled="${updating}"
@click="${() => applyManualNumericParam(p)}">Apply</button>
</div>
<button
class="ds-reset-btn"
?disabled="${!canReset}"
@click="${() => resetNumericParam(p)}">Reset to Default</button>
</div>
<button
class="ds-stepper-btn"
?disabled="${!canIncrease}"
@click="${() => stepNumericParam(p, 1)}">+</button>
</div>
`
})()}
</div>
` : p.ui_type === "dropdown" ? html`
<select
class="ds-select"
id="ds-${p.key}"
data-endpoint="${p.options_endpoint || ""}"
@change="${() => updateParam(p.key, "dropdown")}">
<option value="">Loading...</option>
</select>
` : html`
<input
type="checkbox"
class="ds-toggle"
id="ds-${p.key}"
@change="${() => updateParam(p.key, "checkbox")}" />
`}
</div>
`
}
// Resolve the active section slug imperatively — NEVER inside a reactive expression
function resolveActiveSectionSlug(params) {
if (state.layout.length === 0) return
const sections = getSectionsWithSlug()
const validSlugs = new Set(sections.map(s => s.slug))
const requestedSlug = String(params?.section || "").toLowerCase()
const fallbackSlug = sections[0].slug
const nextSlug = validSlugs.has(requestedSlug)
? requestedSlug
: (validSlugs.has(state.activeSectionSlug) ? state.activeSectionSlug : fallbackSlug)
if (state.activeSectionSlug !== nextSlug) {
state.activeSectionSlug = nextSlug
}
}
export function DeviceSettings({ params }) {
lastParams = params
if (!state.fetched) {
state.fetched = true
fetchLayoutAndParams()
}
// Resolve slug imperatively (safe: runs in function body, not reactive context)
resolveActiveSectionSlug(params)
return html`
<div class="ds-wrapper">
<h2>Toggles</h2>
<div class="ds-search-row">
<input
class="ds-search"
type="text"
placeholder="Search settings..."
value="${() => state.filter}"
@keydown="${(e) => {
if (e.key === "Escape") clearSearchFilter()
}}"
@input="${(e) => {
state.filter = e.target.value
scheduleSyncInputs()
}}" />
${() => state.filter ? html`
<button
class="ds-search-clear"
@click="${() => clearSearchFilter()}">
Clear
</button>
` : ""}
</div>
${() => {
if (state.loadingLayout || state.loadingValues) {
return html`<div class="ds-loading">Loading configuration...</div>`
}
const sections = getSectionsWithSlug()
if (sections.length === 0) {
return html`<div class="ds-empty">No settings available.</div>`
}
// Sync DOM inputs after ArrowJS renders (safe: syncScheduled is non-reactive)
scheduleSyncInputs()
// Search active → show matching results from ALL sections
if (state.filter) {
const MAX_PER_SECTION = 25
const searchResults = sections
.map(s => ({ ...s, matches: s.params.filter(p => matchesFilter(p)) }))
.filter(s => s.matches.length > 0)
const totalMatches = searchResults.reduce((n, s) => n + s.matches.length, 0)
return html`
<div class="ds-status-bar">
<span>${totalMatches} result${totalMatches !== 1 ? "s" : ""} across ${searchResults.length} section${searchResults.length !== 1 ? "s" : ""}</span>
<span>${state.allKeys.length} total mapped</span>
</div>
${searchResults.map(section => html`
<div class="ds-section">
<div class="ds-section-header ds-static-header">
<i class="bi ${section.icon}"></i>
<span class="ds-section-title">${section.name} (${section.matches.length})</span>
</div>
<div class="ds-section-body">
${section.matches.slice(0, MAX_PER_SECTION).map(p => renderSettingRow(p))}
${section.matches.length > MAX_PER_SECTION ? html`<div class="ds-row"><span class="ds-row-label" style="opacity:0.5">+${section.matches.length - MAX_PER_SECTION} more — refine your search</span></div>` : ""}
</div>
</div>
`)}
${totalMatches === 0 ? html`<div class="ds-empty">No settings match your search.</div>` : ""}
`
}
// No search → normal tab-based single-section view
const activeSection = sections.find(s => s.slug === state.activeSectionSlug) || sections[0]
const visibleParams = activeSection.params.filter(p => matchesFilter(p))
return html`
<div class="ds-tabs" id="ds-tabs">
${sections.map(section => html`
<button
class="ds-tab ${section.slug === state.activeSectionSlug ? "active" : ""}"
@click="${(e) => {
handleSectionTabClick(section.slug, e)
}}">
<i class="bi ${section.icon}"></i>
<span>${section.name}</span>
</button>
`)}
</div>
<div class="ds-status-bar">
<span>${activeSection.params.length} settings in ${activeSection.name}</span>
<span>${state.allKeys.length} total mapped</span>
</div>
<div class="ds-section">
<div class="ds-section-header ds-static-header">
<i class="bi ${activeSection.icon}"></i>
<span class="ds-section-title">${activeSection.name} (${visibleParams.length})</span>
</div>
<div class="ds-section-body">
${visibleParams.map(p => renderSettingRow(p))}
</div>
</div>
${visibleParams.length === 0 ? html`<div class="ds-empty">No settings match your search.</div>` : ""}
`
}}
</div>
`
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { html } from "https://esm.sh/@arrow-js/core"
import { html } from "/assets/vendor/arrow-core.js"
export function DoorControl () {
async function lockDoors () {
@@ -1,4 +1,4 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core";
import { html, reactive } from "/assets/vendor/arrow-core.js";
import { formatSecondsToHuman, parseErrorLogToDate } from "/assets/js/utils.js";
import { Modal } from "/assets/components/modal.js";
@@ -0,0 +1,137 @@
/* ――― Galaxy Pairing Widget ――― */
.galaxy-wrapper {
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
.galaxy-loading {
color: var(--text-muted);
padding: var(--padding-xl);
text-align: center;
}
.galaxy-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--gap-lg);
max-width: var(--width-lg);
padding: var(--padding-xl);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.galaxy-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
/* ――― Status Badge ――― */
.galaxy-status-badge {
align-items: center;
border-radius: 2rem;
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
gap: var(--gap-xs);
padding: 0.3rem 1rem;
}
.galaxy-paired {
background-color: rgba(94, 200, 200, 0.15);
color: var(--success-fg);
}
.galaxy-unpaired {
background-color: rgba(224, 85, 119, 0.15);
color: var(--danger-fg);
}
/* ――― Text ――― */
.galaxy-text {
color: var(--text-color);
line-height: var(--line-height-base);
text-align: center;
}
/* ――― URL Link ――― */
.galaxy-url {
color: var(--main-fg);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
text-decoration: underline;
text-underline-offset: 3px;
transition: color var(--transition-fast);
word-break: break-all;
}
.galaxy-url:hover {
color: var(--accent-hover-bg);
}
/* ――― Input Group ――― */
.galaxy-input-group {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
}
.galaxy-input {
background-color: var(--input-bg);
border: var(--border-style-input);
border-radius: var(--border-radius-lg);
box-sizing: border-box;
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-base);
outline: none;
padding: var(--padding-sm) var(--padding-base);
transition: box-shadow var(--transition-fast);
width: 100%;
}
.galaxy-input:focus {
box-shadow: 0 0 0 2px var(--main-fg);
}
/* ――― Buttons ――― */
.galaxy-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
text-align: center;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.galaxy-button:hover:not(:disabled) {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.galaxy-button:disabled {
opacity: var(--disabled-opacity);
}
.galaxy-button-danger {
background-color: rgba(224, 85, 119, 0.2);
color: var(--danger-fg);
}
.galaxy-button-danger:hover:not(:disabled) {
background-color: rgba(224, 85, 119, 0.35);
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.galaxy-wrapper {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
}
@@ -0,0 +1,166 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js"
const state = reactive({
paired: false,
url: "",
password: "",
loading: true,
submitting: false,
showUnpairModal: false,
fetched: false,
})
async function fetchStatus() {
state.loading = true
try {
const res = await fetch("/api/galaxy/status")
const data = await res.json()
state.paired = data.paired
state.url = data.url
} catch (e) {
console.error("Failed to fetch Galaxy status:", e)
}
state.loading = false
}
async function pair() {
if (state.submitting) return
const pw = state.password.trim()
if (pw.length < 6) {
showSnackbar("Password must be at least 6 characters.")
return
}
state.submitting = true
try {
const res = await fetch("/api/galaxy/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw }),
})
const data = await res.json()
if (res.ok) {
state.paired = true
state.url = data.url
state.password = ""
// Clear the DOM input value directly (Arrow.js doesn't two-way bind)
const input = document.querySelector(".galaxy-input")
if (input) input.value = ""
showSnackbar(data.message || "Paired!")
} else {
showSnackbar(data.error || "Pairing failed.")
}
} catch (e) {
showSnackbar("Network error — is the device reachable?")
}
state.submitting = false
}
async function unpair() {
state.showUnpairModal = false
state.submitting = true
try {
const res = await fetch("/api/galaxy/unpair", { method: "POST" })
const data = await res.json()
if (res.ok) {
state.paired = false
state.url = ""
showSnackbar(data.message || "Unpaired!")
} else {
showSnackbar(data.error || "Unpairing failed.")
}
} catch (e) {
showSnackbar("Network error — is the device reachable?")
}
state.submitting = false
}
export function GalaxyPairing() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Galaxy Pairing Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Galaxy pairing requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (!state.fetched) {
state.fetched = true
fetchStatus()
}
return html`
<div class="galaxy-wrapper">
<h2>Galaxy</h2>
${() => {
if (state.loading) {
return html`<div class="galaxy-loading">Checking pairing status…</div>`
}
if (state.paired) {
return html`
<section class="galaxy-widget">
<div class="galaxy-status-badge galaxy-paired">
<i class="bi bi-check-circle-fill"></i> Paired
</div>
<p class="galaxy-text">
Your device is paired with Galaxy. Access it remotely at:
</p>
<a class="galaxy-url" href="${state.url}" target="_blank" rel="noopener">
${state.url}
</a>
<button
class="galaxy-button galaxy-button-danger"
@click="${() => { state.showUnpairModal = true }}"
disabled="${() => state.submitting}"
>
${() => state.submitting ? "Unpairing…" : "Unpair"}
</button>
</section>
${() => state.showUnpairModal ? Modal({
title: "Confirm Unpair",
message: "Are you sure you want to unpair from Galaxy? You will lose remote access until you pair again.",
onConfirm: unpair,
onCancel: () => { state.showUnpairModal = false },
confirmText: "Unpair",
}) : ""}
`
}
return html`
<section class="galaxy-widget">
<div class="galaxy-status-badge galaxy-unpaired">
<i class="bi bi-x-circle-fill"></i> Not Paired
</div>
<p class="galaxy-text">
Pair your device with Galaxy to access The Pond remotely from anywhere.
Enter a password to secure your connection.
</p>
<div class="galaxy-input-group">
<input
class="galaxy-input"
type="password"
placeholder="Password (min 6 characters)"
@input="${(e) => { state.password = e.target.value }}"
@keydown="${(e) => { if (e.key === 'Enter') pair() }}"
/>
<button
class="galaxy-button"
@click="${pair}"
disabled="${() => state.submitting || state.password.trim().length < 6}"
>
${() => state.submitting ? "Pairing…" : "Pair"}
</button>
</div>
</section>
`
}}
</div>
`
}
@@ -0,0 +1,117 @@
.longManeuverPage {
max-width: var(--width-xxl);
}
.longManeuverCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 90%;
padding: var(--border-radius-xl);
}
.longManeuverIntro {
margin-top: 0;
}
.longManeuverActions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.longManeuverButton {
background: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
}
.longManeuverButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.longManeuverButton:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.longManeuverButton.danger {
background: var(--danger-bg);
}
.longManeuverButton.danger:hover {
background: var(--danger-hover-bg);
}
.longManeuverError {
color: var(--danger-fg);
margin-top: var(--margin-base);
}
.longManeuverMuted {
color: var(--text-muted);
}
.longManeuverStatusGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.longManeuverStatusGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.longManeuverCurrent {
margin-top: var(--margin-base);
}
.longManeuverCurrent p {
font-size: var(--font-size-sm);
margin: 0 0 var(--margin-xs);
}
.longManeuverInstructions,
.longManeuverHistory {
margin-top: var(--margin-base);
}
.longManeuverInstructions h3,
.longManeuverHistory h3 {
margin-bottom: var(--margin-xs);
}
.longManeuverInstructions ol,
.longManeuverHistory ol {
margin: var(--margin-xs) 0 0;
padding-left: 1.4rem;
}
.longManeuverInstructions li,
.longManeuverHistory li {
font-size: var(--font-size-sm);
margin-bottom: 0.25rem;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.longManeuverCard {
max-width: 100%;
}
.longManeuverActions {
flex-direction: column;
}
.longManeuverStatusGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,183 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
busy: false,
error: "",
data: null,
})
let initialized = false
let pollHandle = null
const POLL_INTERVAL_MS = 1000
function isLongitudinalManeuversRouteActive() {
return window.location.pathname === "/longitudinal_maneuvers"
}
function safeNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
function formatAgeSeconds(value) {
const sec = safeNumber(value, -1)
if (sec < 0) return "unknown"
if (sec < 1) return "just now"
if (sec < 60) return `${Math.round(sec)}s ago`
const min = sec / 60
if (min < 60) return `${Math.round(min)}m ago`
const hr = min / 60
return `${Math.round(hr)}h ago`
}
async function fetchStatus() {
try {
const response = await fetch("/api/longitudinal_maneuvers/status")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load maneuver status")
}
state.data = payload
state.error = ""
} catch (error) {
state.error = error?.message || "Failed to load maneuver status"
} finally {
state.loading = false
}
}
function stopPolling() {
if (!pollHandle) return
clearTimeout(pollHandle)
pollHandle = null
}
function ensurePolling() {
if (pollHandle) return
const poll = async () => {
if (!isLongitudinalManeuversRouteActive()) {
pollHandle = null
return
}
if (document.visibilityState !== "visible") {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
return
}
await fetchStatus()
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
async function runAction(action) {
if (state.busy) return
state.busy = true
try {
const response = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" })
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || `Failed to ${action} maneuvers`)
}
state.data = payload
state.error = ""
showSnackbar(payload.message || "Action complete.")
} catch (error) {
const message = error?.message || `Failed to ${action} maneuvers`
state.error = message
showSnackbar(message, "error")
} finally {
state.busy = false
}
}
function initialize() {
if (initialized) return
initialized = true
fetchStatus()
ensurePolling()
}
function statusLine(label, value) {
return html`<p><strong>${label}:</strong> ${value}</p>`
}
export function LongitudinalManeuvers() {
initialize()
return html`
<div class="longManeuverPage">
<h2>Long Maneuvers</h2>
<div class="longManeuverCard">
<p class="longManeuverIntro">
Run the longitudinal maneuver suite from your phone and monitor progress live.
</p>
<div class="longManeuverActions">
<button
class="longManeuverButton"
?disabled="${state.busy}"
@click="${() => runAction("start")}">
Start / Arm
</button>
<button
class="longManeuverButton danger"
?disabled="${state.busy}"
@click="${() => runAction("stop")}">
Stop
</button>
</div>
${() => state.loading ? html`<p class="longManeuverMuted">Loading status...</p>` : ""}
${() => state.error ? html`<p class="longManeuverError">${state.error}</p>` : ""}
${() => state.data ? html`
<div class="longManeuverStatusGrid">
${statusLine("Mode Enabled", state.data.modeEnabled ? "Yes" : "No")}
${statusLine("State", state.data.state || "idle")}
${statusLine("Onroad", state.data.isOnroad ? "Yes" : "No")}
${statusLine("Engaged", state.data.isEngaged ? "Yes" : "No")}
${statusLine("Phase", state.data.phase || "n/a")}
${statusLine("Paddle Mode", state.data.paddleMode || "auto")}
${statusLine("Step", `${safeNumber(state.data.stepIndex, 0)}/${safeNumber(state.data.stepTotal, 0)}`)}
${statusLine("Run", `${safeNumber(state.data.runIndex, 0)}/${safeNumber(state.data.runTotal, 0)}`)}
${statusLine("Updated", formatAgeSeconds(state.data.updatedAgeSec))}
</div>
<div class="longManeuverCurrent">
<p><strong>Current Maneuver:</strong> ${state.data.maneuver || "n/a"}</p>
<p><strong>Popup Text:</strong> ${state.data.uiText1 || ""} ${state.data.uiText2 ? `| ${state.data.uiText2}` : ""}</p>
</div>
<div class="longManeuverInstructions">
<h3>Quick Guide</h3>
<ol>
<li>Find a large, empty, straight road or lot with no traffic.</li>
<li>Press <strong>Start / Arm</strong> here, then engage openpilot with SET.</li>
<li>Keep full supervision and be ready to disengage at all times.</li>
<li>For GM pedal-long cars, the suite runs both pedal-only and pedal+paddle phases automatically.</li>
<li>When the status says complete, collect logs and generate your HTML report.</li>
</ol>
</div>
<div class="longManeuverHistory">
<h3>Progress Chain</h3>
${() => (state.data.history || []).length ? html`
<ol>
${(state.data.history || []).slice().reverse().map((line) => html`<li>${line}</li>`)}
</ol>
` : html`<p class="longManeuverMuted">No steps logged yet.</p>`}
</div>
` : ""}
</div>
</div>
`
}
@@ -0,0 +1,253 @@
.mm-wrapper {
color: var(--text-color);
max-width: 1200px;
padding-right: var(--padding-base);
}
.mm-toolbar {
align-items: center;
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-wrap: wrap;
gap: var(--gap-md);
justify-content: space-between;
margin: var(--margin-base) 0;
padding: var(--padding-base);
}
.mm-summary {
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: var(--gap-lg);
}
.mm-actions {
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
}
.mm-status {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
}
.mm-progress {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
color: var(--text-color);
padding: 0.35rem 0.6rem;
}
.mm-filters {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
margin-bottom: var(--margin-base);
}
.mm-search,
.mm-select {
background: var(--input-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-base);
color: var(--text-color);
font-size: var(--font-size-base);
min-height: 2.15rem;
padding: 0.4rem 0.55rem;
}
.mm-search {
min-width: min(420px, 90vw);
}
.mm-filter-label,
.mm-filter-checkbox {
align-items: center;
color: var(--text-muted);
display: flex;
gap: var(--gap-xs);
}
.mm-filter-break {
display: none;
}
.mm-series {
background: var(--card-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
margin-bottom: var(--margin-base);
overflow: hidden;
}
.mm-series-header {
align-items: center;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--sidebar-border-color);
display: flex;
justify-content: space-between;
padding: 0.55rem 0.8rem;
}
.mm-series-header h3 {
font-size: 1.02rem;
margin: 0;
padding: 0;
}
.mm-series-header span {
color: var(--text-muted);
}
.mm-row {
align-items: center;
border-bottom: 1px solid var(--sidebar-border-color);
display: flex;
gap: var(--gap-md);
justify-content: space-between;
padding: 0.75rem 0.8rem;
}
.mm-row:last-child {
border-bottom: none;
}
.mm-row-main {
min-width: 0;
}
.mm-row-title {
align-items: center;
display: flex;
font-size: 1rem;
gap: var(--gap-xs);
}
.mm-row-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.35rem;
}
.mm-row-actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--gap-sm);
}
.mm-btn {
border: none;
border-radius: var(--border-radius-base);
color: var(--text-color);
min-height: 2.2rem;
min-width: 7.5rem;
padding: 0.45rem 0.8rem;
}
.mm-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.mm-btn-primary {
background: var(--success-bg);
color: var(--color-black);
}
.mm-btn-primary:hover:not(:disabled) {
background: var(--success-hover-bg);
}
.mm-btn-secondary {
background: var(--sidebar-active-bg);
}
.mm-btn-secondary:hover:not(:disabled) {
background: var(--accent-hover-bg);
}
.mm-btn-danger {
background: var(--danger-bg);
}
.mm-btn-danger:hover:not(:disabled) {
background: var(--danger-hover-bg);
}
.mm-chip {
background: var(--secondary-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: 999px;
color: var(--text-muted);
display: inline-flex;
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
}
.mm-chip-active {
background: var(--success-bg);
border-color: transparent;
color: var(--color-black);
}
.mm-chip-favorite {
background: rgba(212, 160, 96, 0.18);
border-color: rgba(212, 160, 96, 0.5);
color: var(--warning-bg);
}
.mm-chip-warning {
background: rgba(224, 85, 119, 0.12);
border-color: rgba(224, 85, 119, 0.45);
color: var(--danger-fg);
}
.mm-star {
background: transparent;
border: none;
color: #e0b45a;
font-size: 1rem;
line-height: 1;
padding: 0;
}
.mm-empty {
color: var(--text-muted);
padding: 0.7rem 0;
}
@media (max-width: 850px) {
.mm-row {
align-items: flex-start;
flex-direction: column;
}
.mm-row-actions {
width: 100%;
}
.mm-btn {
width: 100%;
}
.mm-search {
min-width: 100%;
}
.mm-filter-break {
display: block;
flex-basis: 100%;
height: 0;
}
}
@@ -0,0 +1,586 @@
import { html, reactive } from "/assets/vendor/arrow-core.js";
const state = reactive({
loading: true,
refreshing: false,
error: "",
actionBusy: false,
sortMode: "alphabetical",
communityFavoriteFilter: "all",
models: [],
currentModel: "",
summary: { installed: 0, missing: 0, total: 0 },
status: {
modelToDownload: "",
downloadAll: false,
downloading: false,
cancelling: false,
progress: "",
isOnroad: false,
terminal: false,
},
});
let initialized = false;
let pollingHandle = null;
let statusInFlight = false;
let lastStatusSignature = "";
const REQUEST_TIMEOUT_MS = 20000;
const ACTIVE_POLL_INTERVAL_MS = 1000;
const IDLE_POLL_INTERVAL_MS = 4000;
function notify(message, variant = "success") {
if (typeof showSnackbar === "function") {
showSnackbar(message, variant);
} else if (variant === "error") {
console.error(message);
} else {
console.log(message);
}
}
function logDebug(message, details = null) {
if (details === null || details === undefined) {
console.log(`[ModelManager] ${message}`);
} else {
console.log(`[ModelManager] ${message}`, details);
}
}
function isModelRouteActive() {
return window.location.pathname === "/manage_models";
}
function safeText(value, fallback = "") {
if (value === null || value === undefined) return fallback;
return String(value);
}
function toBool(value) {
return !!value;
}
function toInt(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function parseReleased(value) {
const ts = Date.parse(safeText(value, ""));
return Number.isNaN(ts) ? 0 : ts;
}
function normalizeSeries(model) {
return safeText(model?.series, "Custom Series") || "Custom Series";
}
function modelSortCompare(a, b) {
if (state.sortMode === "release_date") {
const dateDelta = parseReleased(b?.released) - parseReleased(a?.released);
if (dateDelta !== 0) return dateDelta;
}
return safeText(a?.label, a?.value).localeCompare(
safeText(b?.label, b?.value),
undefined,
{ sensitivity: "base", numeric: true },
);
}
function getFilteredModels() {
let rows = [...state.models].filter(model => model && typeof model === "object");
if (state.communityFavoriteFilter === "yes") {
rows = rows.filter(model => !!model.communityFavorite);
} else if (state.communityFavoriteFilter === "no") {
rows = rows.filter(model => !model.communityFavorite);
}
return rows;
}
function getSeriesGroups() {
const grouped = {};
for (const model of getFilteredModels()) {
const seriesName = normalizeSeries(model);
if (!grouped[seriesName]) grouped[seriesName] = [];
grouped[seriesName].push(model);
}
const seriesNames = Object.keys(grouped);
for (const seriesName of seriesNames) {
grouped[seriesName].sort(modelSortCompare);
}
if (state.sortMode === "release_date") {
seriesNames.sort((a, b) => {
const aNewest = Math.max(...grouped[a].map(model => parseReleased(model?.released)));
const bNewest = Math.max(...grouped[b].map(model => parseReleased(model?.released)));
const delta = bNewest - aNewest;
if (delta !== 0) return delta;
return a.localeCompare(b, undefined, { sensitivity: "base" });
});
} else {
seriesNames.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
return { grouped, seriesNames };
}
function getVisibleModels() {
const { grouped, seriesNames } = getSeriesGroups();
const rows = [];
for (const seriesName of seriesNames) {
rows.push(...grouped[seriesName]);
}
return rows;
}
function getReleaseOrderedModels() {
return getFilteredModels().sort(modelSortCompare);
}
function getInstalledModels() {
const rows = state.sortMode === "release_date" ? getReleaseOrderedModels() : getVisibleModels();
return rows.filter(model => !!model.installed);
}
function getCurrentModelName() {
const current = safeText(state.currentModel, "");
if (!current) return "none";
const match = state.models.find(model => safeText(model?.value, "") === current);
if (!match) return current;
return safeText(match.label, current);
}
async function fetchJson(url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok) {
const message = payload?.error || payload?.message || `Request failed (${response.status})`;
throw new Error(message);
}
return payload;
} finally {
clearTimeout(timer);
}
}
async function fetchStatus() {
if (statusInFlight) return;
statusInFlight = true;
try {
const payload = await fetchJson("/api/models/status");
const models = Array.isArray(payload.models)
? payload.models.filter(model => model && typeof model === "object")
: [];
state.models = models;
state.currentModel = safeText(payload.currentModel, "");
const summary = payload.summary && typeof payload.summary === "object" ? payload.summary : {};
state.summary = {
installed: toInt(summary.installed),
missing: toInt(summary.missing),
total: toInt(summary.total),
};
state.status = {
modelToDownload: safeText(payload.modelToDownload, ""),
downloadAll: toBool(payload.downloadAll),
downloading: toBool(payload.downloading),
cancelling: toBool(payload.cancelling),
progress: safeText(payload.progress, ""),
isOnroad: toBool(payload.isOnroad),
terminal: toBool(payload.terminal),
};
state.error = "";
const signature = [
state.models.length,
state.currentModel,
state.status.downloading,
state.status.downloadAll,
state.status.modelToDownload,
state.status.progress,
].join("|");
if (signature !== lastStatusSignature) {
lastStatusSignature = signature;
logDebug("Status updated", {
models: state.models.length,
currentModel: state.currentModel || "none",
downloading: state.status.downloading,
progress: state.status.progress || "Idle",
});
}
} catch (error) {
state.error = error?.message || String(error);
logDebug("Status fetch failed", state.error);
} finally {
statusInFlight = false;
state.loading = false;
state.refreshing = false;
}
}
async function refreshAll(showToast = false) {
state.refreshing = true;
if (state.models.length === 0) {
state.loading = true;
}
await fetchStatus();
if (showToast && !state.error) {
notify("Model list refreshed.");
}
}
function ensurePolling() {
if (pollingHandle) return;
const poll = async () => {
if (!isModelRouteActive()) {
pollingHandle = null;
return;
}
let nextDelay = IDLE_POLL_INTERVAL_MS;
try {
await fetchStatus();
nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
} finally {
pollingHandle = setTimeout(poll, nextDelay);
}
};
pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS);
}
async function setActiveModel(modelKey) {
const payload = await fetchJson("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "Model", value: modelKey }),
});
notify(payload.message || `Selected "${modelKey}".`);
}
async function startDownload(modelKey) {
const payload = await fetchJson("/api/models/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelKey }),
});
notify(payload.message || `Downloading "${modelKey}"...`);
}
async function startDownloadAll() {
const payload = await fetchJson("/api/models/download_all", { method: "POST" });
notify(payload.message || "Started downloading all models.");
}
async function cancelDownload() {
const payload = await fetchJson("/api/models/cancel", { method: "POST" });
notify(payload.message || "Cancellation requested.");
}
async function deleteModel(modelKey) {
const payload = await fetchJson("/api/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelKey }),
});
notify(payload.message || `Deleted files for "${modelKey}".`);
}
async function refreshManifest() {
const payload = await fetchJson("/api/models/refresh_manifest", { method: "POST" });
notify(payload.message || "Model manifest refreshed.");
}
async function runAction(action, modelKey = "") {
if (state.actionBusy) {
notify("Please wait for the current action to finish.", "error");
return;
}
state.actionBusy = true;
try {
if (action === "refresh") {
await refreshManifest();
await refreshAll(false);
return;
}
if (state.status.isOnroad && action !== "refresh") {
notify("Actions are blocked while onroad.", "error");
return;
}
if (action === "select") {
if (!modelKey) return;
await setActiveModel(modelKey);
} else if (action === "download") {
if (!modelKey) return;
await startDownload(modelKey);
} else if (action === "download-all") {
await startDownloadAll();
} else if (action === "cancel") {
await cancelDownload();
} else if (action === "delete") {
if (!modelKey) return;
const confirmed = window.confirm(`Delete local files for model \"${modelKey}\"?`);
if (!confirmed) return;
await deleteModel(modelKey);
}
await fetchStatus();
} catch (error) {
notify(error?.message || String(error), "error");
} finally {
state.actionBusy = false;
}
}
function bindDomHandlers() {
if (window.__modelManagerHandlersBound) return;
window.__modelManagerHandlersBound = true;
document.addEventListener("click", event => {
if (!isModelRouteActive()) return;
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest("[data-mm-action]");
if (!button) return;
const action = safeText(button.getAttribute("data-mm-action"), "");
const modelKey = safeText(button.getAttribute("data-model"), "");
runAction(action, modelKey).catch(() => {});
});
document.addEventListener("change", event => {
if (!isModelRouteActive()) return;
const target = event.target;
if (!(target instanceof HTMLSelectElement)) return;
if (target.id === "mm-active-model-select") {
const modelKey = safeText(target.value, "");
if (!modelKey) return;
runAction("select", modelKey).catch(() => {});
return;
}
if (target.id === "mm-sort-mode-select") {
const value = safeText(target.value, "alphabetical");
state.sortMode = value === "release_date" ? "release_date" : "alphabetical";
return;
}
if (target.id === "mm-community-filter-select") {
const value = safeText(target.value, "all");
if (value === "yes" || value === "no" || value === "all") {
state.communityFavoriteFilter = value;
} else {
state.communityFavoriteFilter = "all";
}
}
});
}
function renderActions(model) {
const modelKey = safeText(model.value, "");
const modelIsDownloading = state.status.downloading && !state.status.downloadAll && state.status.modelToDownload === modelKey;
if (state.currentModel === modelKey) {
return html`<span class="mm-chip mm-chip-active">Active</span>`;
}
if (state.status.downloading) {
if (state.status.downloadAll || modelIsDownloading) {
return html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel</button>`;
}
return html`<span class="mm-chip">Busy</span>`;
}
if (model.installed) {
return html`
<button class="mm-btn mm-btn-secondary" data-mm-action="select" data-model="${modelKey}">Set Active</button>
<button class="mm-btn mm-btn-danger" data-mm-action="delete" data-model="${modelKey}">Delete</button>
`;
}
return html`<button class="mm-btn mm-btn-primary" data-mm-action="download" data-model="${modelKey}">Download</button>`;
}
function renderModelRow(model) {
const label = safeText(model.label, safeText(model.value, "Unnamed"));
const key = safeText(model.value, "");
return html`
<div class="mm-row">
<div class="mm-row-main">
<div class="mm-row-title">
<span>${label}</span>
</div>
<div class="mm-row-meta">
<span class="mm-chip">${key}</span>
${state.sortMode === "release_date" ? "" : model.series ? html`<span class="mm-chip">${safeText(model.series)}</span>` : ""}
${model.version ? html`<span class="mm-chip">Version ${safeText(model.version)}</span>` : ""}
${model.released ? html`<span class="mm-chip">Released ${safeText(model.released)}</span>` : ""}
${model.communityFavorite ? html`<span class="mm-chip mm-chip-favorite">Community Favorite</span>` : ""}
${model.partial ? html`<span class="mm-chip mm-chip-warning">Partial Files</span>` : ""}
</div>
</div>
<div class="mm-row-actions">
${renderActions(model)}
</div>
</div>
`;
}
function renderSeriesSection(seriesName, models) {
return html`
<section class="mm-series">
<header class="mm-series-header">
<h3>${seriesName}</h3>
<span>${models.length}</span>
</header>
<div class="mm-series-body">
${models.map(model => renderModelRow(model))}
</div>
</section>
`;
}
export function ModelManager() {
if (!initialized) {
initialized = true;
bindDomHandlers();
logDebug("Initializing component");
refreshAll().catch(error => {
state.error = error?.message || String(error);
state.loading = false;
state.refreshing = false;
logDebug("Initial refresh failed", state.error);
});
}
ensurePolling();
return html`
<div class="mm-wrapper">
<h2>Model Manager</h2>
${() => state.error ? html`<div class="mm-error">${state.error}</div>` : ""}
<div class="mm-debug">
Available Models=${() => state.models.length}
Current Model=${() => getCurrentModelName()}
</div>
<div class="mm-toolbar">
<div class="mm-summary">
<span><b>${state.summary.installed}</b> installed</span>
<span><b>${state.summary.missing}</b> missing</span>
<span><b>${state.summary.total}</b> total</span>
</div>
<div class="mm-actions">
${() => state.status.downloading
? html`<button class="mm-btn mm-btn-danger" data-mm-action="cancel">Cancel Download</button>`
: html`<button class="mm-btn mm-btn-primary" data-mm-action="download-all">Download All Missing</button>`}
<button class="mm-btn mm-btn-secondary" data-mm-action="refresh">Refresh</button>
</div>
</div>
<div class="mm-status">
<span class="mm-chip">Current: ${getCurrentModelName()}</span>
<span class="mm-chip">Progress: ${safeText(state.status.progress, "Idle")}</span>
${() => state.status.isOnroad ? html`<span class="mm-chip mm-chip-warning">Onroad: actions disabled</span>` : ""}
</div>
<div class="mm-filters">
<label class="mm-filter-label" for="mm-active-model-select">Active Model</label>
<select class="mm-select" id="mm-active-model-select">
${(() => {
const orderedInstalled = getInstalledModels().sort((a, b) => {
const aCurrent = safeText(a.value) === state.currentModel ? 0 : 1;
const bCurrent = safeText(b.value) === state.currentModel ? 0 : 1;
if (aCurrent !== bCurrent) return aCurrent - bCurrent;
return safeText(a.label, a.value).localeCompare(safeText(b.label, b.value), undefined, { sensitivity: "base" });
});
return orderedInstalled.length > 0
? orderedInstalled.map(model => html`
<option value="${safeText(model.value)}" ${safeText(model.value) === state.currentModel ? "selected" : ""}>
${safeText(model.label, model.value)}
</option>
`)
: html`<option value="">No installed models</option>`;
})()}
</select>
<label class="mm-filter-label" for="mm-sort-mode-select">Sort</label>
<select class="mm-select" id="mm-sort-mode-select">
<option value="alphabetical" ${state.sortMode === "alphabetical" ? "selected" : ""}>Alphabetical</option>
<option value="release_date" ${state.sortMode === "release_date" ? "selected" : ""}>Release Date</option>
</select>
<div class="mm-filter-break"></div>
<label class="mm-filter-label" for="mm-community-filter-select">Community Favorite</label>
<select class="mm-select" id="mm-community-filter-select">
<option value="all" ${state.communityFavoriteFilter === "all" ? "selected" : ""}>All</option>
<option value="yes" ${state.communityFavoriteFilter === "yes" ? "selected" : ""}>Yes</option>
<option value="no" ${state.communityFavoriteFilter === "no" ? "selected" : ""}>No</option>
</select>
</div>
${() => state.loading ? html`<div class="mm-empty">Loading models...</div>` : ""}
${() => !state.loading ? html`
<div class="mm-list">
${(() => {
if (state.sortMode === "release_date") {
const models = getReleaseOrderedModels();
return models.length === 0
? html`<div class="mm-empty">No models available.</div>`
: models.map(model => renderModelRow(model));
}
const { grouped, seriesNames } = getSeriesGroups();
return seriesNames.length === 0
? html`<div class="mm-empty">No models available.</div>`
: seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
})()}
</div>
` : ""}
</div>
`;
}
@@ -0,0 +1,280 @@
.plotsPage {
max-width: var(--width-xxxxl);
}
.plotCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 95%;
padding: var(--border-radius-xl);
}
.plotStatusCard {
margin-bottom: var(--margin-base);
}
.plotDescription {
margin: 0;
}
.plotStatusGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.plotStatusGrid p,
.plotRangeRow span,
.plotLegendItem {
font-size: var(--font-size-sm);
margin: 0;
}
.plotActions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.plotButton {
background: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
}
.plotButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.plotCharts {
display: grid;
gap: var(--gap-sm);
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
max-width: 95%;
}
.plotAdvancedRow {
margin: var(--margin-base) 0;
}
.plotChartCard {
max-width: 100%;
}
.plotCardHeader {
align-items: baseline;
display: flex;
gap: var(--gap-sm);
justify-content: space-between;
}
.plotSource {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.plotLegend {
display: flex;
gap: var(--gap-sm);
margin-bottom: var(--margin-sm);
}
.plotLegendItem {
align-items: center;
display: inline-flex;
gap: var(--gap-xs);
}
.plotLegendLine {
border-radius: var(--border-radius-sm);
display: inline-block;
height: 3px;
width: 22px;
}
.plotLegendLine.desired,
.plotLine.desired {
background-color: var(--main-fg);
stroke: var(--main-fg);
}
.plotLegendLine.actual,
.plotLine.actual {
background-color: var(--success-fg);
stroke: var(--success-fg);
}
.plotLegendLine.p,
.plotLine.p {
background-color: #63b3ff;
stroke: #63b3ff;
}
.plotLegendLine.i,
.plotLine.i {
background-color: #63d79d;
stroke: #63d79d;
}
.plotLegendLine.d,
.plotLine.d {
background-color: #f0b35e;
stroke: #f0b35e;
}
.plotLegendLine.f,
.plotLine.f {
background-color: #d08bff;
stroke: #d08bff;
}
.plotSvgWrap {
background: #0b0b18;
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
min-height: 260px;
overflow: hidden;
}
.plotSvg {
display: block;
height: 260px;
width: 100%;
}
.plotLine {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.plotGridLine {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 1;
}
.plotZeroLine {
stroke: rgba(255, 255, 255, 0.35);
stroke-dasharray: 6 6;
stroke-width: 1;
}
.plotRangeRow {
display: flex;
justify-content: space-between;
margin-top: var(--margin-xs);
}
.plotEmpty {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: var(--font-size-sm);
height: 260px;
justify-content: center;
}
.plotError {
color: var(--danger-fg);
margin-top: var(--margin-sm);
}
.qualityDetail {
color: var(--text-muted);
font-size: calc(var(--font-size-sm) * 0.95);
}
.qualitySummaryGrid {
display: grid;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.qualitySummaryRow {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
padding: var(--padding-sm) var(--padding-base);
}
.qualitySentence {
align-items: baseline;
display: flex;
flex-wrap: wrap;
font-size: calc(var(--font-size-lg) * 1.05);
font-weight: var(--font-weight-demi-bold);
gap: var(--gap-xs);
line-height: 1.3;
margin: 0;
}
.qualityTone {
font-size: calc(var(--font-size-xl) * 1.05);
font-weight: var(--font-weight-extra-bold);
letter-spacing: 0.01em;
text-transform: uppercase;
}
.qualityTone-great {
color: #66e3ad;
}
.qualityTone-good {
color: #7fc2ff;
}
.qualityTone-fair {
color: #f8c36d;
}
.qualityTone-poor {
color: #ff8ca5;
}
.qualityTone-na {
color: #d7dcec;
}
.qualityMethodNote {
color: var(--text-muted);
font-size: calc(var(--font-size-sm) * 0.92);
margin-top: var(--margin-sm);
}
.qualityUserGuidance {
color: var(--text-muted);
font-size: calc(var(--font-size-sm) * 0.95);
margin: var(--margin-sm) 0 0 0;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.plotCard,
.plotCharts {
max-width: 100%;
}
.plotCharts {
grid-template-columns: 1fr;
}
.plotStatusGrid {
grid-template-columns: 1fr;
}
.plotActions {
flex-direction: column;
}
.plotLegend {
flex-direction: column;
gap: var(--gap-xs);
}
}
@@ -0,0 +1,666 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
error: "",
paused: false,
showAdvancedTerms: false,
live: null,
samples: [],
})
let initialized = false
let pollHandle = null
let lastTimestamp = 0
let visibilityListenerAttached = false
const MAX_POINTS = 240
const POLL_INTERVAL_MS = 750
const SVG_WIDTH = 1000
const SVG_HEIGHT = 260
const ADVANCED_TERMS_KEY = "plotsShowAdvancedTerms"
const QUALITY_WINDOW_SECONDS = 30
const QUALITY_MIN_SAMPLES = 12
const LATERAL_QUALITY_CONFIG = {
desiredKey: "desiredLateralAccel",
actualKey: "actualLateralAccel",
minSpeedMps: 0.5,
minDemand: 0.015,
allowLowDemandFallback: true,
fallbackMinSpeedMps: 1.0,
fallbackMinPeakDemand: 0.03,
great: 0.15,
good: 0.30,
fair: 0.50,
}
const LONGITUDINAL_QUALITY_CONFIG = {
desiredKey: "desiredLongitudinalAccel",
actualKey: "actualLongitudinalAccel",
minSpeedMps: 0.0,
minDemand: 0.08,
allowLowDemandFallback: false,
applyPersistenceRules: true,
warnError: 0.50,
severeError: 0.90,
great: 0.32,
good: 0.52,
fair: 0.78,
}
function isPlotsRouteActive() {
return window.location.pathname === "/plots"
}
function toNumber(value, fallback = 0) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
function formatValue(value, digits = 2) {
return toNumber(value).toFixed(digits)
}
function formatAge(seconds) {
const value = Math.max(0, toNumber(seconds))
if (value < 1) return `${Math.round(value * 1000)} ms`
return `${value.toFixed(1)} s`
}
function formatSourceLabel(kind, source) {
const normalizedKind = String(kind || "").toLowerCase()
const normalizedSource = String(source || "").toLowerCase()
if (normalizedKind === "lateral") {
if (normalizedSource === "torquestate") return "Steering controller output"
if (normalizedSource === "curvature") return "Path model estimate"
}
if (normalizedKind === "longitudinal") {
if (normalizedSource.includes("atarget")) return "Planner target acceleration + measured acceleration"
if (normalizedSource.includes("pid sum")) return "PID term sum + measured acceleration"
if (normalizedSource.includes("caroutput")) return "Final accel command + measured acceleration"
if (normalizedSource.includes("livelocationkalman")) return "Control output + calibrated acceleration"
if (normalizedSource === "controlsstate") return "Planner target output"
}
if (normalizedKind === "lateralterms") {
if (normalizedSource === "torquestate") return "Steering torque controller"
if (normalizedSource === "pidstate") return "Steering angle PID controller"
}
if (normalizedKind === "longitudinalterms") {
if (normalizedSource === "controlsstate") return "Longitudinal controller terms"
}
return "Live control signal"
}
function percentile(sortedValues, percentileValue) {
const values = Array.isArray(sortedValues) ? sortedValues : []
if (!values.length) return 0
const p = Math.max(0, Math.min(1, Number(percentileValue)))
const index = (values.length - 1) * p
const lower = Math.floor(index)
const upper = Math.ceil(index)
if (lower === upper) return values[lower]
const weight = index - lower
return values[lower] * (1 - weight) + values[upper] * weight
}
function formatQualityLabel(label) {
const text = String(label || "").trim()
return text || "N/A"
}
function computeMatchQuality(samples, config) {
const safeSamples = Array.isArray(samples) ? samples : []
if (safeSamples.length < 2) {
return { label: "N/A", value: null, detail: "Waiting for data" }
}
const latestTs = toNumber(safeSamples[safeSamples.length - 1]?.timestamp, 0)
const cutoffTs = latestTs > 0 ? latestTs - QUALITY_WINDOW_SECONDS : 0
const recentSamples = safeSamples.filter((sample) => toNumber(sample?.timestamp, 0) >= cutoffTs)
const demandEligibleSamples = recentSamples.filter((sample) => {
const speed = Math.abs(toNumber(sample?.speed, 0))
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
const speedOk = config.minSpeedMps <= 0 ? true : speed >= config.minSpeedMps
const demandOk = config.minDemand <= 0 ? true : desired >= config.minDemand
return speedOk && demandOk
})
let eligibleSamples = demandEligibleSamples
let usedLowDemandFallback = false
const allowLowDemandFallback = config.allowLowDemandFallback !== false
if (allowLowDemandFallback && eligibleSamples.length < QUALITY_MIN_SAMPLES && recentSamples.length >= QUALITY_MIN_SAMPLES) {
const totalSpeed = recentSamples.reduce((sum, sample) => sum + Math.abs(toNumber(sample?.speed, 0)), 0)
const avgSpeed = recentSamples.length > 0 ? (totalSpeed / recentSamples.length) : 0
const peakDemand = recentSamples.reduce((peak, sample) => {
const desired = Math.abs(toNumber(sample?.[config.desiredKey], 0))
return Math.max(peak, desired)
}, 0)
const fallbackMinSpeed = Math.max(0, toNumber(config.fallbackMinSpeedMps, 0))
const fallbackMinPeakDemand = Math.max(0, toNumber(config.fallbackMinPeakDemand, 0))
if (avgSpeed >= fallbackMinSpeed && peakDemand >= fallbackMinPeakDemand) {
eligibleSamples = recentSamples
usedLowDemandFallback = true
}
}
if (eligibleSamples.length < QUALITY_MIN_SAMPLES) {
if (allowLowDemandFallback) {
return {
label: "N/A",
value: null,
detail: `Need ${QUALITY_MIN_SAMPLES} samples (${eligibleSamples.length} eligible / ${recentSamples.length} total)`,
}
}
return {
label: "N/A",
value: null,
detail: `Need ${QUALITY_MIN_SAMPLES} demand samples (${eligibleSamples.length} demand / ${recentSamples.length} total)`,
}
}
const errors = eligibleSamples
.map((sample) => Math.abs(toNumber(sample?.[config.desiredKey], 0) - toNumber(sample?.[config.actualKey], 0)))
.sort((a, b) => a - b)
if (!errors.length) {
return { label: "N/A", value: null, detail: "No eligible samples" }
}
const p50 = percentile(errors, 0.50)
const p90 = percentile(errors, 0.90)
const robustError = (0.7 * p50) + (0.3 * p90)
const sampleSummary = `${eligibleSamples.length} samples / ${QUALITY_WINDOW_SECONDS}s${usedLowDemandFallback ? ", low-demand fallback" : ""}`
if (config.applyPersistenceRules) {
const warnThreshold = Math.max(config.warnError || 0.35, config.good || 0.35)
const severeThreshold = Math.max(config.severeError || 0.70, config.fair || 0.55)
const warnFrac = errors.filter((value) => value > warnThreshold).length / errors.length
const severeFrac = errors.filter((value) => value > severeThreshold).length / errors.length
let label = "Poor"
if (robustError <= config.great && warnFrac <= 0.18 && severeFrac <= 0.05) label = "Great"
else if (robustError <= config.good && warnFrac <= 0.34 && severeFrac <= 0.12) label = "Good"
else if (robustError <= config.fair && warnFrac <= 0.55 && severeFrac <= 0.24) label = "Fair"
const warnPct = Math.round(warnFrac * 100)
const severePct = Math.round(severeFrac * 100)
return {
label,
value: robustError,
detail: `${sampleSummary}, ${warnPct}% > ${formatValue(warnThreshold)} and ${severePct}% > ${formatValue(severeThreshold)}`,
}
}
let label = "Poor"
if (robustError <= config.great) label = "Great"
else if (robustError <= config.good) label = "Good"
else if (robustError <= config.fair) label = "Fair"
return {
label,
value: robustError,
detail: sampleSummary,
}
}
function qualityToneClass(label) {
const normalized = String(label || "").toLowerCase()
if (normalized === "great") return "qualityTone-great"
if (normalized === "good") return "qualityTone-good"
if (normalized === "fair") return "qualityTone-fair"
if (normalized === "poor") return "qualityTone-poor"
return "qualityTone-na"
}
function stopPolling() {
if (!pollHandle) return
clearTimeout(pollHandle)
pollHandle = null
}
function pushSample(payload) {
const timestamp = toNumber(payload.timestamp, 0)
if (!timestamp || timestamp <= 0 || timestamp === lastTimestamp) return
lastTimestamp = timestamp
state.samples.push({
timestamp,
speed: toNumber(payload.speed),
controlsActive: !!payload.controlsActive,
longitudinalControlActive: !!payload.longitudinalControlActive,
desiredLateralAccel: toNumber(payload.desiredLateralAccel),
actualLateralAccel: toNumber(payload.actualLateralAccel),
desiredLongitudinalAccel: toNumber(payload.desiredLongitudinalAccel),
actualLongitudinalAccel: toNumber(payload.actualLongitudinalAccel),
lateralP: toNumber(payload.lateralP),
lateralI: toNumber(payload.lateralI),
lateralD: toNumber(payload.lateralD),
lateralF: toNumber(payload.lateralF),
longitudinalUpAccelCmd: toNumber(payload.longitudinalUpAccelCmd),
longitudinalUiAccelCmd: toNumber(payload.longitudinalUiAccelCmd),
longitudinalUfAccelCmd: toNumber(payload.longitudinalUfAccelCmd),
})
if (state.samples.length > MAX_POINTS) {
state.samples.splice(0, state.samples.length - MAX_POINTS)
}
}
async function fetchLiveData() {
const response = await fetch("/api/plots/live")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load live plot data")
}
state.live = payload
state.error = ""
state.loading = false
if (!payload.stale) {
pushSample(payload)
}
}
function ensurePolling() {
if (pollHandle) return
const poll = async () => {
if (!isPlotsRouteActive()) {
pollHandle = null
return
}
if (document.visibilityState !== "visible") {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
return
}
if (!state.paused) {
try {
await fetchLiveData()
} catch (error) {
state.error = error?.message || String(error)
state.loading = false
}
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
function clearHistory() {
state.samples = []
lastTimestamp = 0
}
function togglePaused() {
state.paused = !state.paused
if (state.paused) {
stopPolling()
return
}
if (!isPlotsRouteActive()) return
fetchLiveData().catch((error) => {
state.error = error?.message || String(error)
})
ensurePolling()
}
function toggleAdvancedTerms() {
state.showAdvancedTerms = !state.showAdvancedTerms
try {
localStorage.setItem(ADVANCED_TERMS_KEY, state.showAdvancedTerms ? "1" : "0")
} catch (error) {
console.warn("Failed to persist plots advanced terms preference", error)
}
}
function yForValue(value, min, max) {
const safeMin = toNumber(min, -1)
const safeMax = toNumber(max, 1)
const clamped = Math.max(safeMin, Math.min(safeMax, toNumber(value)))
const span = Math.max(1e-6, safeMax - safeMin)
return ((safeMax - clamped) / span) * SVG_HEIGHT
}
function computeRange(samples, desiredKey, actualKey) {
let maxAbs = 0
for (const sample of samples) {
maxAbs = Math.max(
maxAbs,
Math.abs(toNumber(sample?.[desiredKey])),
Math.abs(toNumber(sample?.[actualKey])),
)
}
const halfSpan = Math.max(1.5, Math.ceil((maxAbs * 1.25) * 10) / 10)
return { min: -halfSpan, max: halfSpan }
}
function buildPolyline(samples, key, min, max) {
if (!samples.length) return ""
const lastIndex = Math.max(1, samples.length - 1)
return samples.map((sample, index) => {
const x = (index / lastIndex) * SVG_WIDTH
const y = yForValue(sample?.[key], min, max)
return `${x.toFixed(2)},${y.toFixed(2)}`
}).join(" ")
}
function computeRangeForKeys(samples, keys) {
let maxAbs = 0
for (const sample of samples) {
for (const key of keys) {
maxAbs = Math.max(maxAbs, Math.abs(toNumber(sample?.[key])))
}
}
const halfSpan = Math.max(0.15, Math.ceil((maxAbs * 1.35) * 1000) / 1000)
return { min: -halfSpan, max: halfSpan }
}
function latestSampleValue(key) {
const latest = state.samples[state.samples.length - 1]
if (latest) return latest[key]
return toNumber(state.live?.[key])
}
function PlotCard(title, desiredKey, actualKey, sourceKind, sourceLabel, desiredLabel = "Target", actualLabel = "Measured") {
const hasData = state.samples.length > 1
const range = computeRange(state.samples, desiredKey, actualKey)
const desiredPolyline = buildPolyline(state.samples, desiredKey, range.min, range.max)
const actualPolyline = buildPolyline(state.samples, actualKey, range.min, range.max)
const zeroY = yForValue(0, range.min, range.max)
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
return html`
<section class="plotCard plotChartCard">
<div class="plotCardHeader">
<h2>${title}</h2>
<span class="plotSource">Source: ${formatSourceLabel(sourceKind, sourceLabel)}</span>
</div>
<div class="plotLegend">
<span class="plotLegendItem"><i class="plotLegendLine desired"></i>${desiredLabel}: ${formatValue(latestSampleValue(desiredKey))}</span>
<span class="plotLegendItem"><i class="plotLegendLine actual"></i>${actualLabel}: ${formatValue(latestSampleValue(actualKey))}</span>
</div>
<div class="plotSvgWrap">
${hasData ? html`
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="${title} live plot">
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
<polyline class="plotLine desired" points="${desiredPolyline}"></polyline>
<polyline class="plotLine actual" points="${actualPolyline}"></polyline>
</svg>
` : html`
<div class="plotEmpty">Waiting for enough live samples...</div>
`}
</div>
<div class="plotRangeRow">
<span>${formatValue(range.max, 1)} m/</span>
<span>0.0 m/</span>
<span>${formatValue(range.min, 1)} m/</span>
</div>
</section>
`
}
function LateralTermsPlotCard(sourceLabel) {
const hasData = state.samples.length > 1
const keys = ["lateralP", "lateralI", "lateralD", "lateralF"]
const range = computeRangeForKeys(state.samples, keys)
const zeroY = yForValue(0, range.min, range.max)
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
return html`
<section class="plotCard plotChartCard">
<div class="plotCardHeader">
<h2>Lateral Controller Terms</h2>
<span class="plotSource">Source: ${formatSourceLabel("lateralTerms", sourceLabel)}</span>
</div>
<div class="plotLegend">
<span class="plotLegendItem"><i class="plotLegendLine p"></i>P: ${formatValue(latestSampleValue("lateralP"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine i"></i>I: ${formatValue(latestSampleValue("lateralI"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine d"></i>D: ${formatValue(latestSampleValue("lateralD"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine f"></i>F: ${formatValue(latestSampleValue("lateralF"), 3)}</span>
</div>
<div class="plotSvgWrap">
${hasData ? html`
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="Lateral controller terms live plot">
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
<polyline class="plotLine p" points="${buildPolyline(state.samples, "lateralP", range.min, range.max)}"></polyline>
<polyline class="plotLine i" points="${buildPolyline(state.samples, "lateralI", range.min, range.max)}"></polyline>
<polyline class="plotLine d" points="${buildPolyline(state.samples, "lateralD", range.min, range.max)}"></polyline>
<polyline class="plotLine f" points="${buildPolyline(state.samples, "lateralF", range.min, range.max)}"></polyline>
</svg>
` : html`
<div class="plotEmpty">Waiting for enough live samples...</div>
`}
</div>
<div class="plotRangeRow">
<span>${formatValue(range.max, 3)}</span>
<span>0.000</span>
<span>${formatValue(range.min, 3)}</span>
</div>
</section>
`
}
function LongitudinalTermsPlotCard(sourceLabel) {
const hasData = state.samples.length > 1
const keys = ["longitudinalUpAccelCmd", "longitudinalUiAccelCmd", "longitudinalUfAccelCmd"]
const range = computeRangeForKeys(state.samples, keys)
const zeroY = yForValue(0, range.min, range.max)
const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
return html`
<section class="plotCard plotChartCard">
<div class="plotCardHeader">
<h2>Longitudinal Accel Cmd Terms</h2>
<span class="plotSource">Source: ${formatSourceLabel("longitudinalTerms", sourceLabel)}</span>
</div>
<div class="plotLegend">
<span class="plotLegendItem"><i class="plotLegendLine p"></i>Up: ${formatValue(latestSampleValue("longitudinalUpAccelCmd"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine i"></i>Ui: ${formatValue(latestSampleValue("longitudinalUiAccelCmd"), 3)}</span>
<span class="plotLegendItem"><i class="plotLegendLine f"></i>Uf: ${formatValue(latestSampleValue("longitudinalUfAccelCmd"), 3)}</span>
</div>
<div class="plotSvgWrap">
${hasData ? html`
<svg class="plotSvg" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}" preserveAspectRatio="none" role="img" aria-label="Longitudinal accel cmd terms live plot">
<line class="plotGridLine" x1="0" y1="${topQuarterY}" x2="${SVG_WIDTH}" y2="${topQuarterY}"></line>
<line class="plotZeroLine" x1="0" y1="${zeroY}" x2="${SVG_WIDTH}" y2="${zeroY}"></line>
<line class="plotGridLine" x1="0" y1="${bottomQuarterY}" x2="${SVG_WIDTH}" y2="${bottomQuarterY}"></line>
<polyline class="plotLine p" points="${buildPolyline(state.samples, "longitudinalUpAccelCmd", range.min, range.max)}"></polyline>
<polyline class="plotLine i" points="${buildPolyline(state.samples, "longitudinalUiAccelCmd", range.min, range.max)}"></polyline>
<polyline class="plotLine f" points="${buildPolyline(state.samples, "longitudinalUfAccelCmd", range.min, range.max)}"></polyline>
</svg>
` : html`
<div class="plotEmpty">Waiting for enough live samples...</div>
`}
</div>
<div class="plotRangeRow">
<span>${formatValue(range.max, 3)}</span>
<span>0.000</span>
<span>${formatValue(range.min, 3)}</span>
</div>
</section>
`
}
async function initialize() {
try {
state.showAdvancedTerms = localStorage.getItem(ADVANCED_TERMS_KEY) === "1"
} catch (error) {
state.showAdvancedTerms = false
}
try {
await fetchLiveData()
} catch (error) {
state.error = error?.message || String(error)
state.loading = false
} finally {
ensurePolling()
}
if (!visibilityListenerAttached) {
visibilityListenerAttached = true
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") {
stopPolling()
return
}
if (isPlotsRouteActive() && !state.paused) {
fetchLiveData().catch((error) => {
state.error = error?.message || String(error)
state.loading = false
})
ensurePolling()
}
})
}
}
export function LivePlots() {
if (!initialized) {
initialized = true
initialize()
}
if (!state.paused) {
ensurePolling()
}
return html`
<div class="plotsPage">
<h1>Plots</h1>
<section class="plotCard plotStatusCard">
<p class="plotDescription">
Live comparison view for tuning diagnostics. These scores are a quick health check, not a final verdict. Short spikes from bumps, lane changes, traffic transitions, and manual inputs can temporarily lower a score.
</p>
<p class="qualityUserGuidance">
Work in progress: use this as trend guidance over time. If you see a brief "Poor," keep driving and look for repeated behavior across multiple situations before changing tune values.
</p>
<div class="plotStatusGrid">
<p><strong>Onroad:</strong> ${state.live?.isOnroad ? "Yes" : "No"}</p>
<p><strong>Sample Age:</strong> ${formatAge(state.live?.sampleAgeSeconds)}</p>
<p><strong>Vehicle Speed:</strong> ${formatValue(state.live?.speed)} m/s</p>
<p><strong>Samples:</strong> ${state.samples.length}</p>
</div>
${() => {
const lateralQuality = computeMatchQuality(state.samples, LATERAL_QUALITY_CONFIG)
const longQuality = computeMatchQuality(state.samples, LONGITUDINAL_QUALITY_CONFIG)
return html`
<div class="qualitySummaryGrid">
<div class="qualitySummaryRow">
<p class="qualitySentence">
Your lateral tuning is
<span class="qualityTone ${qualityToneClass(lateralQuality.label)}">${formatQualityLabel(lateralQuality.label)}</span>
</p>
<p class="qualityDetail">
${lateralQuality.value === null ? lateralQuality.detail : `${formatValue(lateralQuality.value)} m/s² error (${lateralQuality.detail})`}
</p>
</div>
<div class="qualitySummaryRow">
<p class="qualitySentence">
Your longitudinal tuning is
<span class="qualityTone ${qualityToneClass(longQuality.label)}">${formatQualityLabel(longQuality.label)}</span>
</p>
<p class="qualityDetail">
${longQuality.value === null ? longQuality.detail : `${formatValue(longQuality.value)} m/s² error (${longQuality.detail})`}
</p>
</div>
</div>
`
}}
<div class="plotActions">
<button class="plotButton" @click="${togglePaused}">
${state.paused ? "Resume Live" : "Pause Live"}
</button>
<button class="plotButton" @click="${clearHistory}">
Clear History
</button>
</div>
${state.error ? html`<p class="plotError"><strong>Error:</strong> ${state.error}</p>` : ""}
${state.live?.lastError ? html`<p class="plotError"><strong>Source Error:</strong> ${state.live.lastError}</p>` : ""}
<p class="qualityMethodNote">
Match rating uses a 30-second rolling window. Lateral prefers true steering-demand moments, and longitudinal also checks how much of the window stays above error limits so brief spikes are less likely to mark "Poor."
</p>
</section>
<div class="plotCharts">
${PlotCard(
"Lateral Response (m/s²)",
"desiredLateralAccel",
"actualLateralAccel",
"lateral",
state.live?.lateralSource,
"Target",
"Measured",
)}
${PlotCard(
"Longitudinal Response (m/s²)",
"desiredLongitudinalAccel",
"actualLongitudinalAccel",
"longitudinal",
state.live?.longitudinalSource,
"Target",
"Measured",
)}
</div>
<div class="plotAdvancedRow">
<button class="plotButton" @click="${toggleAdvancedTerms}">
${state.showAdvancedTerms ? "Hide Advanced Controller Terms" : "Show Advanced Controller Terms"}
</button>
</div>
${state.showAdvancedTerms ? html`
<div class="plotCharts">
${LateralTermsPlotCard(state.live?.lateralTermsSource)}
${LongitudinalTermsPlotCard(state.live?.longitudinalTermsSource)}
</div>
` : ""}
${state.loading ? html`<p>Loading live data...</p>` : ""}
</div>
`
}
@@ -1,4 +1,4 @@
import { html } from "https://esm.sh/@arrow-js/core"
import { html } from "/assets/vendor/arrow-core.js"
export function SpeedLimits() {
function handleDownload() {
@@ -0,0 +1,152 @@
.testingGroundPage {
max-width: var(--width-xxl);
}
.testingGroundCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 90%;
padding: var(--border-radius-xl);
}
.testingGroundIntro {
margin-top: 0;
}
.testingGroundTopList {
margin-top: var(--margin-base);
}
.testingGroundTopList ol {
margin: var(--margin-xs) 0 0;
padding-left: 1.4rem;
}
.testingGroundTopList li {
font-size: var(--font-size-sm);
margin-bottom: 0.25rem;
}
.testingGroundStatusGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.testingGroundStatusGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.testingGroundSelectionRow {
align-items: center;
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.testingGroundSelect {
background-color: var(--main-bg);
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
min-width: 0;
padding: var(--padding-sm);
}
.testingGroundModeButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.testingGroundDetails {
margin-top: var(--margin-base);
}
.testingGroundDetails h3 {
margin-bottom: var(--margin-xs);
}
.testingGroundDetails p {
font-size: var(--font-size-sm);
margin: 0 0 var(--margin-xs);
}
.testingGroundMuted {
color: var(--text-muted);
}
.testingGroundActionGrid {
display: grid;
gap: var(--gap-sm);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin-top: var(--margin-base);
}
.testingGroundModeButton {
align-items: center;
background: #2a2752;
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
display: flex;
flex-direction: column;
gap: var(--gap-xs);
min-height: 120px;
padding: var(--padding-base);
}
.testingGroundModeButtonB {
background: #27434b;
}
.testingGroundModeButtonC {
background: #413029;
}
.testingGroundModeButton.active {
box-shadow: 0 0 0 2px var(--main-fg) inset;
}
.testingGroundModeLetter {
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: var(--font-weight-bold);
line-height: 1;
}
.testingGroundModeLabel {
font-size: var(--font-size-sm);
text-align: center;
}
.testingGroundError {
color: var(--danger-fg);
margin-top: var(--margin-base);
}
.testingGroundActiveSummary {
margin-top: var(--margin-base);
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.testingGroundCard {
max-width: 100%;
}
.testingGroundStatusGrid {
grid-template-columns: 1fr;
}
.testingGroundSelectionRow {
align-items: stretch;
flex-direction: column;
}
.testingGroundActionGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,288 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
error: "",
busy: false,
data: null,
selectedSlot: "",
})
let initialized = false
function slotId(slot) {
return String(slot?.id || "").trim()
}
function isUnusedSlot(slot) {
const name = String(slot?.name || "").trim().toLowerCase()
return name === "unused" || name.startsWith("unused ")
}
function getSelectableSlots() {
const selectable = Array.isArray(state.data?.selectableSlots) ? state.data.selectableSlots : []
if (selectable.length) return selectable
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
return slots.filter((slot) => !isUnusedSlot(slot))
}
function getVisibleSlotLines() {
return getSelectableSlots().map((slot) => `${slot.id}. ${slot.name}`)
}
function getSelectedSlot() {
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
const selectedId = String(state.selectedSlot || "").trim()
if (!selectedId) return null
return slots.find((slot) => slotId(slot) === selectedId) || null
}
function getActiveSlot() {
const slots = Array.isArray(state.data?.slots) ? state.data.slots : []
const activeId = String(state.data?.activeSlot || "").trim()
if (!activeId) return null
return slots.find((slot) => slotId(slot) === activeId) || null
}
function getVariantLabels(slot) {
if (!slot || typeof slot !== "object") {
return { A: "A" }
}
const labels = {}
const rawVariantLabels = slot.variantLabels
if (rawVariantLabels && typeof rawVariantLabels === "object") {
Object.entries(rawVariantLabels).forEach(([rawMode, rawLabel]) => {
const mode = String(rawMode || "").trim().toUpperCase()
const label = String(rawLabel || "").trim()
if (mode.length === 1 && /^[A-Z]$/.test(mode) && label) {
labels[mode] = label
}
})
}
const aLabel = String(slot.aLabel || "").trim()
const bLabel = String(slot.bLabel || "").trim()
if (aLabel) labels.A = labels.A || aLabel
if (bLabel) labels.B = labels.B || bLabel
if (!labels.A) labels.A = "A"
return Object.keys(labels).sort().reduce((acc, key) => {
acc[key] = labels[key]
return acc
}, {})
}
function getVariantModes(slot) {
return Object.keys(getVariantLabels(slot))
}
function getDefaultMode(slot) {
const modes = getVariantModes(slot)
if (modes.includes("A")) return "A"
return modes[0] || "A"
}
function toModeLabel(slot, mode) {
const labels = getVariantLabels(slot)
return labels[String(mode || "").trim().toUpperCase()] || String(mode || "").trim().toUpperCase() || "A"
}
function isModeActive(mode) {
const normalizedMode = String(mode || "").trim().toUpperCase()
return String(state.data?.activeSlot || "").trim() === String(state.selectedSlot || "").trim() && String(state.data?.activeVariant || "").trim().toUpperCase() === normalizedMode
}
function getSelectedMode() {
const selectedSlot = getSelectedSlot()
if (!selectedSlot) return "A"
if (String(state.data?.activeSlot || "").trim() === String(state.selectedSlot || "").trim()) {
return String(state.data?.activeVariant || "").trim().toUpperCase() || getDefaultMode(selectedSlot)
}
return getDefaultMode(selectedSlot)
}
function modeButtonClass(mode) {
const normalizedMode = String(mode || "").trim().toUpperCase()
return [
"testingGroundModeButton",
`testingGroundModeButton${normalizedMode}`,
isModeActive(normalizedMode) ? "active" : "",
].filter(Boolean).join(" ")
}
async function fetchTestingGrounds() {
try {
const response = await fetch("/api/testing_grounds")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load testing grounds")
}
state.data = payload
state.error = ""
const selectable = Array.isArray(payload.selectableSlots) ? payload.selectableSlots : []
const hasCurrentSelection = selectable.some((slot) => slotId(slot) === String(state.selectedSlot || "").trim())
if (!hasCurrentSelection) {
const activeSlot = String(payload.activeSlot || "").trim()
const activeSelectable = selectable.some((slot) => slotId(slot) === activeSlot)
state.selectedSlot = activeSelectable ? activeSlot : slotId(selectable[0] || {})
}
} catch (error) {
const message = error?.message || "Failed to load testing grounds"
state.error = message
}
}
async function applySelection(slotValue, mode, showToast = true) {
const normalizedSlot = String(slotValue || "").trim()
const normalizedMode = String(mode || "").trim().toUpperCase()
if (!normalizedSlot || !normalizedMode) return false
state.busy = true
try {
const response = await fetch("/api/testing_grounds/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slotId: normalizedSlot, variant: normalizedMode }),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to update testing ground mode")
}
state.data = payload
state.error = ""
state.selectedSlot = normalizedSlot
if (showToast) {
showSnackbar(payload.message || `Testing Ground ${normalizedSlot} set to ${normalizedMode}.`)
}
return true
} catch (error) {
const message = error?.message || "Failed to update testing ground mode"
state.error = message
if (showToast) {
showSnackbar(message, "error")
}
return false
} finally {
state.busy = false
}
}
async function selectMode(mode) {
if (state.busy) return
const slot = getSelectedSlot()
const slotValue = slotId(slot)
if (!slotValue) {
showSnackbar("Select a testing ground first.", "error")
return
}
await applySelection(slotValue, mode, true)
}
async function selectSlot(slotValue) {
const normalizedSlot = String(slotValue || "").trim()
state.selectedSlot = normalizedSlot
if (!normalizedSlot || state.busy) return
const activeSlot = String(state.data?.activeSlot || "").trim()
if (normalizedSlot === activeSlot) return
const success = await applySelection(normalizedSlot, "A", false)
if (!success) {
state.selectedSlot = activeSlot
if (state.error) {
showSnackbar(state.error, "error")
}
}
}
function initialize() {
if (initialized) return
initialized = true
fetchTestingGrounds().finally(() => {
state.loading = false
})
}
export function TestingGround() {
initialize()
return html`
<div class="testingGroundPage">
<h2>Testing Ground</h2>
${() => state.loading ? html`<div class="testingGroundCard">Loading testing ground state...</div>` : ""}
${() => !state.loading ? html`
<div class="testingGroundCard">
<p class="testingGroundIntro">
A/B Tuning Sandbox. If you don't know what this is, you probably shouldn't be here ;)
</p>
<div class="testingGroundTopList">
<strong>Current Test Slots</strong>
<ol>
${getVisibleSlotLines().map((line) => html`<li>${line}</li>`)}
</ol>
</div>
<div class="testingGroundStatusGrid">
<p><strong>Selected Slot:</strong> ${getSelectedSlot()?.name || "Unknown"}</p>
<p><strong>Selected Mode:</strong> ${getSelectedMode()}</p>
<p><strong>Onroad:</strong> ${state.data?.isOnroad ? "Yes" : "No"}</p>
<p><strong>Selectable Slots:</strong> ${getSelectableSlots().length}</p>
</div>
<div class="testingGroundSelectionRow">
<select
class="testingGroundSelect"
?disabled="${state.busy || getSelectableSlots().length === 0}"
@change="${(event) => {
selectSlot(String(event.target.value || ""))
}}">
${() => getSelectableSlots().length
? getSelectableSlots().map((slot) => html`<option value="${slotId(slot)}" ${slotId(slot) === state.selectedSlot ? "selected" : ""}>${slot.id}. ${slot.name}</option>`)
: html`<option value="">No active test slots</option>`
}
</select>
</div>
${() => getSelectedSlot() ? html`
<div class="testingGroundDetails">
<h3>${getSelectedSlot().name}</h3>
${() => getSelectedSlot().description ? html`<p>${getSelectedSlot().description}</p>` : ""}
</div>
` : ""}
${() => getSelectedSlot() ? html`
<div class="testingGroundActionGrid">
${() => getVariantModes(getSelectedSlot()).map((mode) => html`
<button
class="${modeButtonClass(mode)}"
?disabled="${state.busy}"
@click="${() => selectMode(mode)}">
<span class="testingGroundModeLetter">${mode}</span>
<span class="testingGroundModeLabel">${toModeLabel(getSelectedSlot(), mode)}</span>
</button>
`)}
</div>
` : ""}
${() => state.error ? html`<p class="testingGroundError"><strong>Error:</strong> ${state.error}</p>` : ""}
${() => getActiveSlot() ? html`
<p class="testingGroundActiveSummary">
Currently active: <strong>${getActiveSlot().id}. ${getActiveSlot().name}</strong> in mode <strong>${state.data?.activeVariant || "A"}</strong>.
</p>
` : ""}
</div>
` : ""}
</div>
`
}
@@ -77,11 +77,11 @@
display: none;
}
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox {
.checklist-item input[type="checkbox"]:checked~.custom-checkbox {
background-color: var(--success-bg);
}
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox::before {
.checklist-item input[type="checkbox"]:checked~.custom-checkbox::before {
transform: translateX(24px);
}
@@ -170,6 +170,7 @@
flex-shrink: 0;
transition: color var(--transition-fast), transform 0.2s;
}
.file-clear-button:hover {
color: var(--danger-hover-bg);
transform: scale(1.1);
@@ -613,7 +614,7 @@ label.file-upload-button {
.turn-signal-input:focus,
.turn-signal-input:hover {
border-color: var(--thumb-color);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
outline: none;
transform: var(--hover-scale-sm);
}
@@ -690,8 +691,15 @@ label.file-upload-button {
}
@keyframes modalFadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
@@ -713,8 +721,8 @@ label.file-upload-button {
flex-wrap: wrap;
}
.save-button-wrapper > button {
.save-button-wrapper>button {
flex-basis: calc(50% - 1rem);
margin-bottom: var(--gap-sm);
}
}
}
@@ -1,4 +1,4 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core";
import { html, reactive } from "/assets/vendor/arrow-core.js";
import { Modal } from "/assets/components/modal.js";
const defaultColors = {
@@ -97,7 +97,8 @@ const state = reactive({
showDeleteConfirmModal: false,
themeToDelete: null,
themes: [],
activeTab: "colors"
activeTab: "colors",
fetched: false,
});
let draggedIndex = -1;
@@ -405,17 +406,19 @@ const fetchDownloadables = async () => {
};
(async () => {
try {
const response = await fetch("/api/params?key=DiscordUsername");
state.discordUsername = await response.text();
await loadDefaultTheme();
await fetchDownloadables();
} catch {}
})();
export function ThemeMaker() {
if (!state.fetched) {
state.fetched = true;
(async () => {
try {
const response = await fetch("/api/params?key=DiscordUsername");
state.discordUsername = await response.text();
await loadDefaultTheme();
await fetchDownloadables();
} catch {}
})();
}
const normalize = (str) => (str || "")
.toString()
.trim()
@@ -1,5 +1,5 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { formatSecondsToHuman } from "/assets/js/utils.js"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { formatSecondsToHuman, isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js";
const logSelectorState = reactive({
@@ -12,6 +12,16 @@ const logSelectorState = reactive({
newName: ""
});
let liveEventSource = null;
let livePollTimer = null;
let tmuxLifecycleVersion = 0;
const REMOTE_POLL_INTERVAL_MS = 2000;
const TRANSPORT_LIFECYCLE_INTERVAL_MS = 500;
function isTmuxRouteActive() {
return window.location.pathname === "/manage_tmux";
}
async function loadTmuxLogs() {
if (logSelectorState.loading || logSelectorState.logsLoadedOnce) return;
@@ -122,34 +132,34 @@ function TmuxLogSelector({ action, closeFn }) {
</div>
${() => {
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
return html`<div class="fileEntry"><p>Loading...</p></div>`;
}
if (logSelectorState.files.length === 0) {
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
}
return logSelectorState.files.map(file => html`
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
return html`<div class="fileEntry"><p>Loading...</p></div>`;
}
if (logSelectorState.files.length === 0) {
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
}
return logSelectorState.files.map(file => html`
<div class="fileEntry" @click="${() => handleFileClick(file)}">
<p><span class="label">Filename:</span> <span class="value">${file.filename}</span></p>
<p><span class="label">Date:</span> <span class="value">${file.date}</span></p>
<p><span class="label">Age:</span> <span class="value">${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}</span></p>
</div>
`);
}}
}}
<button @click="${closeFn}" class="cancel-button">Close</button>
${() => logSelectorState.logToDelete ? Modal({
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
onConfirm: confirmDeleteFile,
onCancel: () => { logSelectorState.logToDelete = null },
confirmText: "Yes, Delete"
}) : ""}
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
onConfirm: confirmDeleteFile,
onCancel: () => { logSelectorState.logToDelete = null },
confirmText: "Yes, Delete"
}) : ""}
${() => logSelectorState.logToRename ? Modal({
title: "Rename Log",
message: html`
title: "Rename Log",
message: html`
<div>
<p>Rename <strong>${logSelectorState.logToRename.filename}</strong> to:</p>
<div style="margin-top: 10px;">
@@ -163,14 +173,14 @@ function TmuxLogSelector({ action, closeFn }) {
</div>
</div>
`,
onConfirm: confirmRenameFile,
onCancel: () => {
logSelectorState.logToRename = null;
logSelectorState.newName = "";
},
confirmText: "Rename",
confirmClass: "btn-primary"
}) : ""}
onConfirm: confirmRenameFile,
onCancel: () => {
logSelectorState.logToRename = null;
logSelectorState.newName = "";
},
confirmText: "Rename",
confirmClass: "btn-primary"
}) : ""}
</div>
</div>
`
@@ -182,23 +192,143 @@ export function TmuxLog() {
latest: '',
log: '',
selectorAction: null,
transport: "connecting",
});
const lifecycleVersion = ++tmuxLifecycleVersion;
let lifecycleTimer = null;
const event_source = new EventSource("/api/tmux_log/live");
event_source.onmessage = e => {
state.latest = e.data;
function applyIncomingLog(data) {
state.latest = data || "";
if (!state.paused) {
state.log = state.latest;
}
}
event_source.onerror = err => {
console.error("Error receiving tmux log:", err);
event_source.close();
function stopLiveTransport() {
if (liveEventSource) {
liveEventSource.close();
liveEventSource = null;
}
if (livePollTimer) {
clearInterval(livePollTimer);
livePollTimer = null;
}
}
function togglePause () {
async function fetchSnapshot() {
try {
const response = await fetch("/api/tmux_log/snapshot");
if (!response.ok) throw new Error(await response.text());
const payload = await response.json();
applyIncomingLog(payload?.data || "");
} catch (err) {
console.error("Error fetching tmux log snapshot:", err);
}
}
function startPolling() {
if (livePollTimer) return;
if (liveEventSource) {
liveEventSource.close();
liveEventSource = null;
}
state.transport = "polling";
fetchSnapshot();
livePollTimer = setInterval(() => {
if (!isTmuxRouteActive()) {
stopLiveTransport();
return;
}
if (document.visibilityState !== "visible") {
return;
}
fetchSnapshot();
}, REMOTE_POLL_INTERVAL_MS);
}
function startEventStream() {
if (liveEventSource) return;
if (livePollTimer) {
clearInterval(livePollTimer);
livePollTimer = null;
}
state.transport = "streaming";
liveEventSource = new EventSource("/api/tmux_log/live");
let receivedAnyMessage = false;
liveEventSource.onmessage = e => {
if (!isTmuxRouteActive() || document.visibilityState !== "visible") return;
receivedAnyMessage = true;
applyIncomingLog(e.data);
};
liveEventSource.onerror = err => {
console.error("Error receiving tmux log stream:", err);
if (!isTmuxRouteActive()) {
stopLiveTransport();
return;
}
startPolling();
};
// If SSE connects but no payload arrives quickly (common through tunnels), fallback.
setTimeout(() => {
if (!receivedAnyMessage && state.transport === "streaming" && isTmuxRouteActive()) {
startPolling();
}
}, 3000);
}
function updateTransportForLifecycle() {
if (lifecycleVersion !== tmuxLifecycleVersion) {
stopLiveTransport();
if (lifecycleTimer) {
clearInterval(lifecycleTimer);
lifecycleTimer = null;
}
return;
}
if (!isTmuxRouteActive()) {
stopLiveTransport();
state.transport = "inactive";
if (lifecycleTimer) {
clearInterval(lifecycleTimer);
lifecycleTimer = null;
}
return;
}
if (document.visibilityState !== "visible") {
stopLiveTransport();
state.transport = "paused";
return;
}
if (isGalaxyTunnel()) {
startPolling();
return;
}
if (!liveEventSource && !livePollTimer) {
startEventStream();
}
}
updateTransportForLifecycle();
lifecycleTimer = setInterval(updateTransportForLifecycle, TRANSPORT_LIFECYCLE_INTERVAL_MS);
function getStatusLabel() {
if (state.transport === "polling") {
return "Tmux Live Log (Remote Mode)";
}
if (state.transport === "streaming") {
return "Tmux Live Log";
}
return "Tmux Live Log";
}
function togglePause() {
state.paused = !state.paused;
if (!state.paused) {
state.log = state.latest;
@@ -248,7 +378,7 @@ export function TmuxLog() {
<div class="tmux-block">
<div class="tmux-wrapper">
<div class="tmuxContainer">
<div class="tmuxHeader">Tmux Live Log</div>
<div class="tmuxHeader">${() => getStatusLabel()}</div>
<pre class="tmuxLog">${() => state.log}</pre>
</div>
</div>
@@ -263,20 +393,20 @@ export function TmuxLog() {
</div>
${() => state.selectorAction
? TmuxLogSelector({
action: state.selectorAction,
closeFn: () => (state.selectorAction = null)
})
: ""
}
? TmuxLogSelector({
action: state.selectorAction,
closeFn: () => (state.selectorAction = null)
})
: ""
}
${() => logSelectorState.showDeleteAllModal ? Modal({
title: "Delete All Logs",
message: "Are you sure you want to delete all of your session logs?",
onConfirm: deleteAllSessions,
onCancel: () => { logSelectorState.showDeleteAllModal = false },
confirmText: "Delete All"
}) : ""}
title: "Delete All Logs",
message: "Are you sure you want to delete all of your session logs?",
onConfirm: deleteAllSessions,
onCancel: () => { logSelectorState.showDeleteAllModal = false },
confirmText: "Delete All"
}) : ""}
</div>
`;
}
@@ -1,7 +1,8 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js"
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
export function ToggleControl () {
export function ToggleControl() {
const state = reactive({
showResetDefaultModal: false,
showResetStockModal: false,
@@ -14,7 +15,7 @@ export function ToggleControl () {
fileInput.addEventListener("change", restoreToggles)
document.body.appendChild(fileInput)
async function backupToggles () {
async function backupToggles() {
const response = await fetch("/api/toggles/backup", { method: "POST" })
const blob = await response.blob()
@@ -26,7 +27,7 @@ export function ToggleControl () {
URL.revokeObjectURL(downloadUrl)
}
async function restoreToggles (event) {
async function restoreToggles(event) {
const uploadedFile = event.target.files[0]
if (uploadedFile) {
const fileContents = await uploadedFile.text()
@@ -45,11 +46,11 @@ export function ToggleControl () {
}
}
function confirmResetDefault () {
function confirmResetDefault() {
state.showResetDefaultModal = true;
}
async function resetTogglesToDefault () {
async function resetTogglesToDefault() {
state.showResetDefaultModal = false;
showSnackbar("Resetting toggles to their default values...");
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -58,11 +59,11 @@ export function ToggleControl () {
await fetch("/api/toggles/reset_default", { method: "POST" });
}
function confirmResetStock () {
function confirmResetStock() {
state.showResetStockModal = true;
}
async function resetTogglesToStock () {
async function resetTogglesToStock() {
state.showResetStockModal = false;
showSnackbar("Resetting toggles to stock openpilot values...");
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -71,7 +72,7 @@ export function ToggleControl () {
await fetch("/api/toggles/reset_stock", { method: "POST" });
}
function triggerRestorePrompt () {
function triggerRestorePrompt() {
fileInput.click()
}
@@ -87,9 +88,9 @@ export function ToggleControl () {
</section>
<section class="toggle-control-widget" style="margin-left: 1.5rem">
<div class="toggle-control-title">Reset Toggles to Default FrogPilot/Stock openpilot</div>
<div class="toggle-control-title">Reset Toggles to Default StarPilot</div>
<p class="toggle-control-text">
Reset all toggles to default FrogPilot/stock openpilot settings.
Reset all toggles to default StarPilot settings.
</p>
<button class="toggle-control-button" @click="${confirmResetDefault}">
Reset Toggles to Default
@@ -98,20 +99,22 @@ export function ToggleControl () {
Reset Toggles to Stock
</button>
</section>
${TailscaleControl()}
</div>
${() => state.showResetDefaultModal ? Modal({
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
onConfirm: resetTogglesToDefault,
onCancel: () => { state.showResetDefaultModal = false; },
confirmText: "Reset to Default"
}) : ""}
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
onConfirm: resetTogglesToDefault,
onCancel: () => { state.showResetDefaultModal = false; },
confirmText: "Reset to Default"
}) : ""}
${() => state.showResetStockModal ? Modal({
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to stock openpilot values?",
onConfirm: resetTogglesToStock,
onCancel: () => { state.showResetStockModal = false; },
confirmText: "Reset to Stock"
}) : ""}
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to stock openpilot values?",
onConfirm: resetTogglesToStock,
onCancel: () => { state.showResetStockModal = false; },
confirmText: "Reset to Stock"
}) : ""}
`
}
@@ -0,0 +1,124 @@
.troubleshootPage {
display: flex;
flex-direction: column;
gap: 16px;
}
.troubleshootCard {
background: var(--sidebar-bg);
border: 1px solid var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: 16px;
}
.troubleshootIntro {
margin: 0 0 12px;
opacity: 0.9;
}
.troubleshootActionRow {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.troubleshootButton {
background: linear-gradient(135deg, #7a62b8, #8b6cc5);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
padding: 10px 14px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.troubleshootButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.troubleshootDanger {
background: linear-gradient(135deg, #b14a6b, #d95a7b);
}
.troubleshootError {
color: #ff6b6b;
}
.troubleshootStatusLine {
margin: 6px 0 0;
}
.troubleshootSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.troubleshootSectionHeader h3,
.troubleshootCard h3 {
margin: 0;
}
.troubleshootHeaderRow,
.troubleshootItemRow,
.troubleshootSnapshotRow {
display: grid;
gap: 10px;
align-items: center;
border-top: 1px solid var(--sidebar-border-color);
padding: 8px 0;
}
.troubleshootHeaderRow {
font-weight: 700;
opacity: 0.9;
}
.troubleshootHeaderRow {
grid-template-columns: 2fr 1fr 1fr;
}
.troubleshootHeaderRowSnapshot {
grid-template-columns: 2fr 2fr;
}
.troubleshootSnapshotRow {
grid-template-columns: 2fr 2fr;
}
.troubleshootItemRow {
grid-template-columns: 2fr 1fr 1fr;
}
.troubleshootItemLabel {
font-weight: 600;
}
.troubleshootItemValue,
.troubleshootItemDefault {
font-family: "Open Sans", sans-serif;
word-break: break-word;
}
@media (max-width: 720px) {
.troubleshootHeaderRow,
.troubleshootItemRow {
grid-template-columns: 1.3fr 1fr 1fr;
font-size: 0.9rem;
}
.troubleshootSnapshotRow {
grid-template-columns: 1fr;
gap: 4px;
}
.troubleshootSectionHeader {
align-items: flex-start;
flex-direction: column;
}
}
@@ -0,0 +1,208 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
error: "",
snapshot: [],
sections: [],
isOnroad: false,
refreshing: false,
busySection: "",
})
let initialized = false
function formatValue(value) {
if (typeof value === "boolean") return value ? "On" : "Off"
if (typeof value === "number") {
if (Number.isInteger(value)) return String(value)
return String(Number(value.toFixed(4)))
}
if (value === null || value === undefined) return "n/a"
const text = String(value).trim()
return text || "(empty)"
}
function buildReportText() {
const lines = []
lines.push("StarPilot Troubleshoot Report")
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(`Onroad: ${state.isOnroad ? "Yes" : "No"}`)
lines.push("")
lines.push("Snapshot")
for (const item of state.snapshot) {
lines.push(`- ${item.label}: ${formatValue(item.value)}`)
}
for (const section of state.sections) {
lines.push("")
lines.push(section.title)
for (const item of section.items || []) {
lines.push(`- ${item.label}: ${formatValue(item.value)} (default: ${formatValue(item.defaultValue)})`)
}
}
return lines.join("\n")
}
async function copyToClipboard() {
const text = buildReportText()
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textArea = document.createElement("textarea")
textArea.value = text
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
}
showSnackbar("Troubleshoot report copied to clipboard.", "", 2500, { key: "troubleshoot-copy" })
} catch (error) {
showSnackbar(error?.message || "Failed to copy report.", "error", 3000, { key: "troubleshoot-copy" })
}
}
async function fetchTroubleshoot(showToast = false) {
state.refreshing = true
try {
const response = await fetch("/api/troubleshoot")
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load troubleshoot data")
}
state.snapshot = Array.isArray(payload.snapshot) ? payload.snapshot : []
state.sections = Array.isArray(payload.sections) ? payload.sections : []
state.isOnroad = !!payload.isOnroad
state.error = ""
if (showToast) {
showSnackbar("Troubleshoot data refreshed.", "", 1800, { key: "troubleshoot-refresh" })
}
} catch (error) {
const message = error?.message || "Failed to load troubleshoot data"
state.error = message
if (showToast) {
showSnackbar(message, "error", 3000, { key: "troubleshoot-refresh" })
}
} finally {
state.loading = false
state.refreshing = false
}
}
async function resetSection(section) {
const sectionId = String(section?.id || "")
if (!sectionId || state.busySection) return
const sectionTitle = String(section?.title || "this section")
if (!confirm(`Reset ${sectionTitle} to defaults?`)) return
state.busySection = sectionId
try {
const response = await fetch("/api/troubleshoot/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sectionId }),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to reset section")
}
showSnackbar(payload.message || `${sectionTitle} reset.`, "", 2500, { key: "troubleshoot-reset" })
await fetchTroubleshoot(false)
} catch (error) {
showSnackbar(error?.message || "Failed to reset section.", "error", 3000, { key: "troubleshoot-reset" })
} finally {
state.busySection = ""
}
}
function initialize() {
if (initialized) return
initialized = true
fetchTroubleshoot(false)
}
function itemRows(section) {
const items = Array.isArray(section?.items) ? section.items : []
return items.map((item) => html`
<div class="troubleshootItemRow">
<div class="troubleshootItemLabel">${item.label}</div>
<div class="troubleshootItemValue">${formatValue(item.value)}</div>
<div class="troubleshootItemDefault">${formatValue(item.defaultValue)}</div>
</div>
`)
}
export function Troubleshoot() {
initialize()
return html`
<div class="troubleshootPage">
<h2>Troubleshoot</h2>
${() => state.loading ? html`<div class="troubleshootCard">Loading troubleshoot data...</div>` : ""}
${() => !state.loading ? html`
<div class="troubleshootCard">
<p class="troubleshootIntro">
Quick diagnostics snapshot for weird behavior reports and copy-ready debug logs.
</p>
<div class="troubleshootActionRow">
<button class="troubleshootButton" ?disabled="${state.refreshing}" @click="${() => fetchTroubleshoot(true)}">
${state.refreshing ? "Refreshing..." : "Refresh"}
</button>
<button class="troubleshootButton" @click="${copyToClipboard}">
Copy to Clipboard
</button>
</div>
${() => state.error ? html`<p class="troubleshootError"><strong>Error:</strong> ${state.error}</p>` : ""}
<p class="troubleshootStatusLine"><strong>Onroad:</strong> ${state.isOnroad ? "Yes" : "No"}</p>
</div>
<div class="troubleshootCard">
<h3>Snapshot</h3>
<div class="troubleshootHeaderRow troubleshootHeaderRowSnapshot">
<span>Field</span>
<span>Value</span>
</div>
${() => state.snapshot.map((item) => html`
<div class="troubleshootSnapshotRow">
<div class="troubleshootItemLabel">${item.label}</div>
<div class="troubleshootItemValue">${formatValue(item.value)}</div>
</div>
`)}
</div>
${() => state.sections.map((section) => html`
<div class="troubleshootCard">
<div class="troubleshootSectionHeader">
<h3>${section.title}</h3>
${section.resettable ? html`
<button
class="troubleshootButton troubleshootDanger"
?disabled="${state.busySection === section.id}"
@click="${() => resetSection(section)}">
${state.busySection === section.id ? "Resetting..." : "Reset to Default"}
</button>
` : ""}
</div>
<div class="troubleshootHeaderRow">
<span>Setting</span>
<span>Current</span>
<span>Default</span>
</div>
${itemRows(section)}
</div>
`)}
` : ""}
</div>
`
}
@@ -73,7 +73,7 @@
transition: opacity var(--transition-slow);
}
.tskkeys-group > .tskkeys-row:last-of-type {
.tskkeys-group>.tskkeys-row:last-of-type {
margin-bottom: 0;
}
@@ -105,7 +105,7 @@
.tskkeys-input:hover,
.tskkeys-input:focus {
border-color: var(--thumb-color);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
@@ -211,4 +211,4 @@
.tskkeys-input {
font-size: var(--font-size-sm);
}
}
}
@@ -1,4 +1,4 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { Modal } from "/assets/components/modal.js"
const state = reactive({
@@ -165,15 +165,16 @@ const api = {
}
}
let hasLoaded = false
if (!hasLoaded) {
hasLoaded = true
api.load()
}
export function TSKManager() {
return html`
<div class="tskkeys-wrapper tskkeys-offset-top">
let hasLoaded = false
export function TSKManager() {
if (!hasLoaded) {
hasLoaded = true
api.load()
}
return html`
<div class="tskkeys-wrapper tskkeys-offset-top">
<div class="tskkeys-container">
<div class="tskkeys-title">Toyota Security Key Manager</div>
@@ -0,0 +1,206 @@
.updateManager {
max-width: var(--width-xxl);
}
.updateActions {
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.updateButton {
background: var(--main-fg);
border: none;
border-radius: var(--border-radius-sm);
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
}
.updateButton.danger {
background: var(--danger-bg);
}
.updateButton.danger:hover {
background: var(--danger-hover-bg);
}
.updateButton:hover {
transform: var(--hover-scale-sm);
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.updateButton:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.updateCard {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-md);
color: var(--text-color);
max-width: 80%;
padding: var(--border-radius-xl);
}
.updateError {
color: var(--danger-fg);
margin-top: var(--margin-sm);
}
.updateGrid {
display: grid;
gap: 0.5em 1em;
grid-template-columns: repeat(3, 1fr);
}
.updateGrid p {
font-size: var(--font-size-sm);
margin: 0;
}
.updateLink {
color: var(--main-fg);
display: inline-block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.updateMessage {
margin-top: var(--margin-sm);
}
.updateProgressCard {
margin-top: var(--margin-base);
}
.updateProgressDetail {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
}
.updateProgressDetail.error {
color: var(--danger-fg);
}
.updateProgressFill {
background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%);
border-radius: var(--border-radius-sm);
height: 100%;
transition: width var(--transition-base);
}
.updateProgressFill.error {
background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%);
}
.updateProgressHeader {
display: flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-demi-bold);
justify-content: space-between;
margin-bottom: var(--margin-xs);
}
.updateProgressTrack {
background: var(--track-color);
border-radius: var(--border-radius-sm);
height: 12px;
overflow: hidden;
}
.updateProgressTrack.error {
border: 1px solid var(--danger-bg);
}
.updateToggleRow {
align-items: center;
display: flex;
gap: var(--gap-sm);
justify-content: space-between;
margin-top: var(--margin-base);
}
.updateWarning {
margin-top: var(--margin-base);
}
.updateHint {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
}
.updateRebootNotice {
font-weight: var(--font-weight-bold);
}
.updateFooter {
border-top: 1px solid var(--track-color);
margin-top: var(--margin-base);
padding-top: var(--margin-sm);
}
.updateBranchSection {
margin-top: var(--margin-base);
}
.updateAdvancedNote {
font-size: var(--font-size-sm);
margin-top: var(--margin-sm);
}
.updateBranchHeader {
align-items: center;
display: flex;
font-size: var(--font-size-sm);
justify-content: space-between;
}
.updateBranchRow {
align-items: center;
display: flex;
gap: var(--gap-sm);
margin-top: var(--margin-sm);
}
.updateSelect {
background-color: var(--main-bg);
border: 1px solid var(--track-color);
border-radius: var(--border-radius-sm);
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
min-width: 0;
padding: var(--padding-sm);
}
.updateSelect:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.updateToggleRow input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
.updateActions {
flex-direction: column;
}
.updateBranchRow {
flex-direction: column;
align-items: stretch;
}
.updateCard {
max-width: 100%;
}
.updateGrid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
}
@@ -0,0 +1,617 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const state = reactive({
loading: true,
error: "",
status: null,
checkedForUpdates: false,
checkBusy: false,
updateBusy: false,
toggleBusy: false,
showAdvancedOptions: false,
branches: [],
selectedBranch: "",
hasManualBranchSelection: false,
branchesError: "",
branchesBusy: false,
switchBusy: false,
})
let initialized = false
let pollHandle = null
let reconnectPending = false
let rebootNoticeShown = false
const POLL_INTERVAL_MS = 1000
const ADVANCED_OPTIONS_KEY = "softwareShowAdvancedOptions"
function isUpdateRouteActive() {
return window.location.pathname === "/manage_updates"
}
function shortHash(value) {
const text = String(value || "").trim()
return text ? text.slice(0, 10) : "Unknown"
}
function toPercent(value) {
const n = Number(value)
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.min(100, n))
}
function findBranchInList(branches, target) {
const wanted = String(target || "").trim()
if (!wanted || !Array.isArray(branches) || branches.length === 0) return ""
if (branches.includes(wanted)) return wanted
const wantedLower = wanted.toLowerCase()
const caseInsensitive = branches.find((branch) => String(branch || "").toLowerCase() === wantedLower)
return caseInsensitive || ""
}
function isSelectedBranchDifferent() {
const currentBranch = String(state.status?.branch || "").trim()
const selectedBranch = String(state.selectedBranch || "").trim()
return !!currentBranch && !!selectedBranch && currentBranch !== selectedBranch
}
function normalizeGithubRemote(remoteValue, commitsUrlValue = "") {
let remote = String(remoteValue || "").trim()
if (!remote) {
const commitsUrl = String(commitsUrlValue || "").trim()
const marker = "/commits/"
const markerIndex = commitsUrl.indexOf(marker)
if (commitsUrl.startsWith("https://github.com/") && markerIndex > 0) {
remote = commitsUrl.slice(0, markerIndex)
} else {
return ""
}
}
if (remote.startsWith("git@github.com:")) {
remote = `https://github.com/${remote.split(":", 2)[1] || ""}`
} else if (remote.startsWith("ssh://git@github.com/")) {
remote = `https://github.com/${remote.split("ssh://git@github.com/", 2)[1] || ""}`
} else if (remote.startsWith("http://github.com/")) {
remote = `https://github.com/${remote.split("http://github.com/", 2)[1] || ""}`
}
if (!remote.startsWith("https://github.com/")) return ""
remote = remote.replace(/\/+$/, "")
if (remote.endsWith(".git")) remote = remote.slice(0, -4)
return remote
}
function currentOrSelectedBranchForCommits() {
const selected = String(state.selectedBranch || "").trim()
if (selected) return selected
return String(state.status?.branch || "").trim()
}
function activeCommitsUrl() {
const branch = currentOrSelectedBranchForCommits()
if (!branch) return ""
const remote = normalizeGithubRemote(state.status?.originRemote, state.status?.commitsUrl)
if (!remote) return ""
return `${remote}/commits/${encodeURIComponent(branch)}/`
}
function activeCommitsLabel() {
const branch = currentOrSelectedBranchForCommits()
return branch
? `View latest commit for the "${branch}" branch`
: "View latest commit for this branch"
}
function shouldShowPrimaryUpdateAction() {
if (state.status?.running) return true
if (isSelectedBranchDifferent()) return true
return !!state.checkedForUpdates && !!state.status?.updateAvailable
}
function shouldContinuePolling() {
return !!state.status?.running || state.status?.stage === "rebooting" || reconnectPending
}
function shouldShowRebootNotice() {
if (state.status?.stage === "rebooting") return true
if (reconnectPending) return true
const message = String(state.status?.message || "").toLowerCase()
return message.includes("reboot")
}
function isHtmlLike(text) {
const value = String(text || "").trim()
return value.startsWith("<!DOCTYPE") || value.startsWith("<html") || value.startsWith("<")
}
async function readJsonPayload(response) {
const bodyText = await response.text()
if (!bodyText) return {}
try {
return JSON.parse(bodyText)
} catch (parseError) {
const error = new Error("Invalid JSON response")
error.cause = parseError
error.bodyText = bodyText
throw error
}
}
function setRebootingUiState(detail = "Reboot in progress...") {
state.status = {
...(state.status || {}),
running: false,
stage: "rebooting",
progressStep: 5,
progressTotalSteps: state.status?.progressTotalSteps || 5,
progressStepPercent: 100,
progressPercent: 100,
progressLabel: "Rebooting device",
progressDetail: detail,
message: "Update complete. Device is rebooting. Please wait...",
}
state.error = ""
}
function isRebootRelatedConnectionError(error) {
const message = String(error?.message || "")
return /Invalid JSON response|Unexpected token '<'|Failed to fetch|NetworkError|Load failed/i.test(message)
}
function stopPolling() {
if (!pollHandle) return
clearTimeout(pollHandle)
pollHandle = null
}
function ensurePolling() {
if (pollHandle) return
const poll = async () => {
if (!isUpdateRouteActive()) {
pollHandle = null
return
}
if (document.visibilityState !== "visible") {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
return
}
await fetchStatus(false)
if (shouldContinuePolling()) {
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
} else {
pollHandle = null
}
}
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
}
async function fetchStatus(showToast) {
if (showToast) state.checkBusy = true
try {
const response = await fetch("/api/update/fast/status")
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load update status")
}
state.status = payload
state.error = ""
if (reconnectPending && !payload.running && payload.stage !== "rebooting") {
reconnectPending = false
rebootNoticeShown = false
showSnackbar("Connection reestablished.")
}
if (payload.stage === "rebooting" && !rebootNoticeShown) {
rebootNoticeShown = true
showSnackbar("Device rebooting, please wait for reconnection...")
}
if (!state.selectedBranch && payload.branch) {
state.selectedBranch = payload.branch
}
if (shouldContinuePolling()) {
ensurePolling()
} else {
stopPolling()
}
if (showToast) {
state.checkedForUpdates = true
showSnackbar(payload.updateAvailable ? "Update available." : "No update available.")
}
} catch (error) {
const isRebootTransitionError = !showToast
&& (state.status?.running || state.status?.stage === "rebooting" || reconnectPending)
&& (isHtmlLike(error?.bodyText) || isRebootRelatedConnectionError(error))
if (isRebootTransitionError) {
reconnectPending = true
setRebootingUiState("Device rebooting, please wait for reconnection...")
if (!rebootNoticeShown) {
rebootNoticeShown = true
showSnackbar("Device rebooting, please wait for reconnection...")
}
ensurePolling()
return
}
const message = error?.message || String(error)
state.error = message
if (showToast) {
showSnackbar(message, "error")
}
} finally {
if (showToast) state.checkBusy = false
}
}
async function fetchBranches(showToast = false) {
state.branchesBusy = true
try {
const response = await fetch("/api/update/branches")
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to load branches")
}
const branches = Array.isArray(payload.branches) ? payload.branches : []
const currentBranchRaw = String(payload.currentBranch || state.status?.branch || "").trim()
const matchedCurrentBranch = findBranchInList(branches, currentBranchRaw)
state.branches = branches
state.branchesError = payload.remoteError || ""
if (branches.length === 0) {
state.selectedBranch = currentBranchRaw
state.hasManualBranchSelection = false
} else if (matchedCurrentBranch && !state.hasManualBranchSelection) {
// Default to the branch currently installed unless the user picked one explicitly.
state.selectedBranch = matchedCurrentBranch
} else if (!state.selectedBranch || !branches.includes(state.selectedBranch)) {
state.selectedBranch = matchedCurrentBranch || branches[0]
state.hasManualBranchSelection = false
}
if (showToast) {
showSnackbar(branches.length ? `Loaded ${branches.length} branches.` : "No branches found.")
}
} catch (error) {
const message = error?.message || "Failed to load branches"
state.branchesError = message
if (showToast) {
showSnackbar(message, "error")
}
} finally {
state.branchesBusy = false
}
}
function setAdvancedOptions(enabled) {
const next = !!enabled
state.showAdvancedOptions = next
if (!next) {
const currentBranch = String(state.status?.branch || "").trim()
if (currentBranch) {
state.selectedBranch = currentBranch
}
state.hasManualBranchSelection = false
}
try {
localStorage.setItem(ADVANCED_OPTIONS_KEY, next ? "1" : "0")
} catch (error) {
console.warn("Failed to persist advanced options preference", error)
}
if (next && state.branches.length === 0 && !state.branchesBusy) {
fetchBranches(false)
}
}
async function setAutomaticUpdates(enabled) {
if (state.toggleBusy) return
if (state.status?.isOnroad) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
state.toggleBusy = true
try {
const response = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "AutomaticUpdates", value: !!enabled }),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to update Automatic Updates")
}
state.status = { ...(state.status || {}), automaticUpdates: !!enabled }
showSnackbar(payload.message || "Automatic updates updated.")
} catch (error) {
showSnackbar(error?.message || "Failed to update Automatic Updates", "error")
await fetchStatus(false)
} finally {
state.toggleBusy = false
}
}
async function runFastUpdate() {
if (state.updateBusy) return
if (state.status?.running) {
showSnackbar("Fast update is already running.")
return
}
if (state.status?.isOnroad) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
const confirmed = window.confirm(
"Fast update warning:\n\n" +
"- This update method skips backup creation.\n" +
"- Your device will reboot when the update is done.\n\n" +
"Continue with fast update?"
)
if (!confirmed) return
state.updateBusy = true
try {
reconnectPending = false
rebootNoticeShown = false
const response = await fetch("/api/update/fast", { method: "POST" })
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to start fast update")
}
state.status = {
...(state.status || {}),
running: true,
stage: "starting",
progressStep: 1,
progressTotalSteps: state.status?.progressTotalSteps || 5,
progressStepPercent: 0,
progressPercent: 0,
progressLabel: "Preparing update",
progressDetail: "Initializing update process...",
message: payload.message || "Fast update started. Device will reboot when complete.",
lastError: "",
}
state.error = ""
showSnackbar(payload.message || "Fast update started.")
await fetchStatus(false)
ensurePolling()
} catch (error) {
showSnackbar(error?.message || "Failed to start fast update", "error")
} finally {
state.updateBusy = false
}
}
async function runBranchSwitch() {
if (state.switchBusy) return
if (state.status?.running) {
showSnackbar("An update action is already running.")
return
}
if (state.status?.isOnroad) {
showSnackbar("Actions are blocked while onroad.", "error")
return
}
const targetBranch = String(state.selectedBranch || "").trim()
if (!targetBranch) {
showSnackbar("Select a target branch first.", "error")
return
}
const currentBranch = String(state.status?.branch || "").trim()
const actionLabel = currentBranch && currentBranch === targetBranch ? "update" : "switch and update"
const confirmed = window.confirm(
`This will ${actionLabel} to the '${targetBranch}' branch.\n\n` +
"- This update method skips backup creation.\n" +
"- Your device will reboot when the update is done.\n\n" +
"Continue?"
)
if (!confirmed) return
state.switchBusy = true
try {
reconnectPending = false
rebootNoticeShown = false
const response = await fetch("/api/update/branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ branch: targetBranch }),
})
const payload = await readJsonPayload(response)
if (!response.ok) {
throw new Error(payload.error || response.statusText || "Failed to start branch switch")
}
state.status = {
...(state.status || {}),
running: true,
stage: "starting",
progressStep: 1,
progressTotalSteps: state.status?.progressTotalSteps || 5,
progressStepPercent: 0,
progressPercent: 0,
progressLabel: "Preparing branch switch",
progressDetail: "Initializing branch switch...",
message: payload.message || `Branch switch started for '${targetBranch}'. Device will reboot when complete.`,
lastError: "",
}
state.error = ""
showSnackbar(payload.message || "Branch switch started.")
await fetchStatus(false)
ensurePolling()
} catch (error) {
showSnackbar(error?.message || "Failed to start branch switch", "error")
} finally {
state.switchBusy = false
}
}
function initialize() {
if (initialized) return
initialized = true
try {
state.showAdvancedOptions = localStorage.getItem(ADVANCED_OPTIONS_KEY) === "1"
} catch (error) {
state.showAdvancedOptions = false
}
const initTasks = [fetchStatus(false)]
if (state.showAdvancedOptions) {
initTasks.push(fetchBranches(false))
}
Promise.all(initTasks).finally(() => {
state.loading = false
})
}
export function UpdateManager() {
initialize()
return html`
<div class="updateManager">
<h2>Software</h2>
${() => state.loading ? html`<div class="updateCard">Loading update status...</div>` : ""}
${() => !state.loading ? html`
<div class="updateCard">
<div class="updateGrid">
<p><strong>Current Branch:</strong> ${state.status?.branch || "Unknown"}</p>
<p><strong>Installed Commit:</strong> ${shortHash(state.status?.localCommit)}</p>
<p><strong>Latest Commit:</strong> ${shortHash(state.status?.remoteCommit)}</p>
<p><strong>Update Available:</strong> ${state.status?.updateAvailable ? "Yes" : "No"}</p>
<p><strong>Status:</strong> ${state.status?.stage || "idle"}</p>
<p><strong>Onroad:</strong> ${state.status?.isOnroad ? "Yes" : "No"}</p>
</div>
<div class="updateProgressCard">
<div class="updateProgressHeader">
<span>
Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}:
${state.status?.progressLabel || "Idle"}
</span>
<span>${Math.round(toPercent(state.status?.progressPercent))}%</span>
</div>
<div class="updateProgressTrack ${state.status?.stage === "error" ? "error" : ""}">
<div class="updateProgressFill ${state.status?.stage === "error" ? "error" : ""}" style="width: ${toPercent(state.status?.progressPercent)}%;"></div>
</div>
${() => state.status?.progressDetail ? html`<p class="updateProgressDetail ${state.status?.stage === "error" ? "error" : ""}">${state.status.progressDetail}</p>` : ""}
</div>
${() => state.status?.isOnroad ? html`<p class="updateWarning"><strong>Onroad: actions disabled</strong></p>` : ""}
<label class="updateToggleRow">
<span>Automatically install updates</span>
<input
type="checkbox"
class="ds-toggle"
?checked="${!!state.status?.automaticUpdates}"
?disabled="${!!state.status?.isOnroad || state.toggleBusy || !!state.status?.running}"
@change="${(event) => setAutomaticUpdates(!!event.target.checked)}"
/>
</label>
<label class="updateToggleRow updateAdvancedToggle">
<span>Show advanced options</span>
<input
type="checkbox"
class="ds-toggle"
?checked="${() => state.showAdvancedOptions}"
@change="${(event) => setAdvancedOptions(!!event.target.checked)}"
/>
</label>
${() => state.showAdvancedOptions ? html`
<div class="updateBranchSection">
<div class="updateBranchHeader">
<strong>Branch switching</strong>
${state.branchesBusy ? html`<span>Loading...</span>` : ""}
</div>
<p class="updateAdvancedNote">
For advanced users only. Switching to test/dev branches can introduce instability.
</p>
<div class="updateBranchRow">
<select
class="updateSelect"
?disabled="${!!state.status?.isOnroad || !!state.status?.running || state.switchBusy || state.branchesBusy || state.branches.length === 0}"
@change="${(event) => {
state.selectedBranch = String(event.target.value || "")
state.hasManualBranchSelection = true
}}">
${() => state.branches.length
? state.branches.map((branch) => html`<option value="${branch}" ${branch === state.selectedBranch ? "selected" : ""}>${branch}${branch === state.status?.branch ? " (current)" : ""}</option>`)
: html`<option value="">No branches found</option>`
}
</select>
<button
class="updateButton"
?disabled="${state.branchesBusy || !!state.status?.running}"
@click="${() => fetchBranches(true)}">
${state.branchesBusy ? "Refreshing..." : "Refresh Branches"}
</button>
</div>
${() => state.branchesError ? html`<p class="updateError"><strong>Branch List:</strong> ${state.branchesError}</p>` : ""}
</div>
` : ""}
${() => state.status?.message && state.status?.stage !== "rebooting" ? html`<p class="updateMessage">${state.status.message}</p>` : ""}
${() => state.status?.remoteError ? html`<p class="updateError"><strong>Remote Check:</strong> ${state.status.remoteError}</p>` : ""}
${() => state.status?.lastError ? html`<p class="updateError"><strong>Last Error:</strong> ${state.status.lastError}</p>` : ""}
${() => state.error ? html`<p class="updateError"><strong>Error:</strong> ${state.error}</p>` : ""}
<div class="updateActions">
${() => !isSelectedBranchDifferent() ? html`
<button class="updateButton" @click="${() => fetchStatus(true)}">
${state.checkBusy ? "Checking..." : "Check for Updates"}
</button>
` : ""}
${() => shouldShowPrimaryUpdateAction() ? html`
<button
class="updateButton danger"
?disabled="${!!state.status?.isOnroad || !!state.status?.running || (isSelectedBranchDifferent() ? state.switchBusy : state.updateBusy)}"
@click="${() => isSelectedBranchDifferent() ? runBranchSwitch() : runFastUpdate()}">
${state.status?.running
? "Update Running..."
: (isSelectedBranchDifferent()
? (state.switchBusy ? "Starting..." : "Switch + Update")
: (state.updateBusy ? "Starting..." : "Fast Update (No Backup)"))}
</button>
` : ""}
</div>
${() => !state.status?.running && !shouldShowPrimaryUpdateAction()
? html`<p class="updateHint">Run <strong>Check for Updates</strong> first, or select a different branch in advanced options.</p>`
: ""}
${() => shouldShowRebootNotice()
? html`<p class="updateHint updateRebootNotice">Device rebooting, please wait for reconnection...</p>`
: ""}
${() => activeCommitsUrl() ? html`
<div class="updateFooter">
<a class="updateLink" href="${activeCommitsUrl()}" target="_blank" rel="noopener noreferrer">
${activeCommitsLabel()}
</a>
</div>
` : ""}
</div>
` : ""}
</div>
`
}
@@ -0,0 +1,162 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
import { DoorControl } from "/assets/components/tools/doors.js"
import { TSKManager } from "/assets/components/tools/tsk_manager.js"
const state = reactive({
activeTool: null,
toolStatus: {
doors: "untested", // untested, allowed, denied
tsk: "untested"
},
loading: false
})
async function checkToolAvailability(toolName) {
if (state.toolStatus[toolName] !== "untested") {
state.activeTool = toolName;
return;
}
state.loading = true;
state.activeTool = toolName;
try {
const response = await fetch(`/api/car_features_check?tool=${toolName}`);
const data = await response.json();
state.toolStatus[toolName] = data.result ? "allowed" : "denied";
} catch (error) {
console.error("Failed to check feature availability:", error);
state.toolStatus[toolName] = "denied";
} finally {
state.loading = false;
}
}
export function VehicleFeatures() {
return html`
<style>
.vf-container {
padding: var(--padding-lg);
max-width: var(--max-width-content);
margin: 0 auto;
}
.vf-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--gap-lg);
margin-top: var(--margin-lg);
}
.vf-card {
background: var(--card-bg);
border: var(--border-width-thin) var(--border-style-base) var(--sidebar-border-color);
border-radius: var(--border-radius-lg);
padding: var(--padding-lg);
cursor: pointer;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.vf-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--main-fg);
}
.vf-card i {
font-size: 2.5rem;
margin-bottom: var(--margin-sm);
color: var(--main-fg);
}
.vf-card h3 {
margin: 0 0 var(--margin-xs) 0;
font-size: var(--font-size-lg);
}
.vf-card p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.vf-back {
background: none;
border: none;
color: var(--main-fg);
cursor: pointer;
font-size: var(--font-size-base);
display: flex;
align-items: center;
gap: var(--gap-sm);
padding: 0;
margin-bottom: var(--margin-lg);
}
.vf-back:hover {
text-decoration: underline;
}
.vf-error {
margin-top: var(--margin-lg);
padding: var(--padding-base);
background: rgba(224, 85, 119, 0.1);
border: var(--border-width-thin) var(--border-style-base) var(--danger-fg);
border-radius: var(--border-radius-md);
color: var(--danger-fg);
text-align: center;
}
.vf-loader {
text-align: center;
padding: var(--padding-xxl);
color: var(--text-muted);
}
</style>
<div class="vf-container">
${() => {
if (!state.activeTool) {
return html`
<h1>Vehicle Specific Features</h1>
<p style="color: var(--text-muted); margin-bottom: var(--margin-xl);">
Select a feature below to access it. These features verify vehicle compatibility when launched.
</p>
<div class="vf-grid">
<div class="vf-card" @click="${() => checkToolAvailability('doors')}">
<i class="bi bi-door-closed"></i>
<h3>Lock/Unlock Doors</h3>
<p>Send lock or unlock commands remotely to your vehicle.</p>
</div>
<div class="vf-card" @click="${() => checkToolAvailability('tsk')}">
<i class="bi bi-key-fill"></i>
<h3>Toyota Security Keys</h3>
<p>Manage and apply security keys for secOC protected devices.</p>
</div>
</div>
`;
}
return html`
<button class="vf-back" @click="${() => { state.activeTool = null; }}">
<i class="bi bi-arrow-left"></i> Back to Features
</button>
${() => {
if (state.loading) {
return html`<div class="vf-loader"><i class="bi bi-hourglass-split"></i> Verifying vehicle compatibility...</div>`;
}
if (state.toolStatus[state.activeTool] === "denied") {
const toolNames = { doors: "Lock/Unlock Doors", tsk: "Toyota Security Keys" };
return html`
<div class="vf-error">
<i class="bi bi-exclamation-triangle-fill" style="font-size: 2rem; display: block; margin-bottom: var(--margin-sm);"></i>
<strong>${toolNames[state.activeTool]}</strong> is not supported for your current vehicle.
</div>
`;
}
if (state.activeTool === "doors") return DoorControl();
if (state.activeTool === "tsk") return TSKManager();
return "";
}}
`;
}}
</div>
`;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 B

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 KiB

After

Width:  |  Height:  |  Size: 332 KiB

+39 -15
View File
@@ -1,26 +1,50 @@
function showSnackbar(msg, level, timeout = 3500) {
function showSnackbar(msg, level, timeout = 3500, options = {}) {
const wrapper = document.getElementById("snackbar_wrapper")
if (!wrapper) return
const key = String(options?.key || "").trim()
const replace = options?.replace !== false
const dismissSnackbar = (snackbar) => {
if (!snackbar) return
if (snackbar.__hideTimer) clearTimeout(snackbar.__hideTimer)
if (snackbar.__removeTimer) clearTimeout(snackbar.__removeTimer)
snackbar.style.opacity = 0
snackbar.__removeTimer = setTimeout(() => snackbar.remove(), 1000)
}
const setSnackbarContent = (snackbar) => {
snackbar.innerHTML = msg
snackbar.className = "snackbar show"
if (level === "error") {
snackbar.style.backgroundColor = "#f44336"
} else {
snackbar.style.backgroundColor = ""
}
}
if (key && replace) {
const existing = Array.from(wrapper.children).find((el) => el.dataset?.snackbarKey === key)
if (existing) {
setSnackbarContent(existing)
existing.style.opacity = 1
if (existing.__hideTimer) clearTimeout(existing.__hideTimer)
if (existing.__removeTimer) clearTimeout(existing.__removeTimer)
existing.__hideTimer = setTimeout(() => dismissSnackbar(existing), timeout)
return
}
}
// Ensure max 2 snackbars are visible
if (wrapper.children.length >= 2) {
const first = wrapper.children[0]
first.style.opacity = 0
setTimeout(() => first.remove(), 1000)
dismissSnackbar(wrapper.children[0])
}
const snackbar = document.createElement("div")
snackbar.innerHTML = msg
snackbar.className = "snackbar show"
snackbar.id = `snackbar_${Math.random().toString(36).slice(5)}`
if (level === "error") {
snackbar.style.backgroundColor = "#f44336"
}
if (key) snackbar.dataset.snackbarKey = key
setSnackbarContent(snackbar)
wrapper.appendChild(snackbar)
setTimeout(() => {
snackbar.style.opacity = 0
setTimeout(() => snackbar.remove(), 1000)
}, timeout)
snackbar.__hideTimer = setTimeout(() => dismissSnackbar(snackbar), timeout)
}
@@ -65,3 +65,11 @@ export function hideSidebar() {
document.getElementById("sidebarUnderlay")?.classList.add("hidden")
html.classList.remove("no_scroll")
}
/**
* Returns true when the page is being accessed through the Galaxy tunnel
* (the public-facing domain is galaxy.firestar.link)
*/
export function isGalaxyTunnel() {
return window.location.hostname === 'galaxy.firestar.link';
}
@@ -1,5 +1,5 @@
{
"name": "The Pond",
"name": "Galaxy",
"short_name": "",
"icons": [
{
@@ -17,4 +17,4 @@
"background_color": "#151414",
"theme_color": "#151414",
"display": "standalone"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+67 -47
View File
@@ -1,59 +1,79 @@
<!doctype html>
<html lang="en" id="htmlElement" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
<link rel="manifest" href="/assets/manifest.json">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta name="theme-color" content="#4285f4" />
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
<meta name="theme-color" content="#8b6cc5" />
<link rel="stylesheet" href="/assets/components/home/home.css">
<link rel="stylesheet" href="/assets/components/main.css">
<link rel="stylesheet" href="/assets/components/modal.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
<link rel="stylesheet" href="/assets/components/settings.css">
<link rel="stylesheet" href="/assets/components/sidebar.css">
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
<link rel="stylesheet" href="/assets/components/tools/doors.css">
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<script type="module" src="/assets/components/router.js"></script>
<script src="/assets/js/snackbar.js"></script>
<link rel="stylesheet" href="/assets/components/home/home.css">
<link rel="stylesheet" href="/assets/components/main.css">
<link rel="stylesheet" href="/assets/components/modal.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
<link rel="stylesheet" href="/assets/components/settings.css">
<link rel="stylesheet" href="/assets/components/sidebar.css">
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
<link rel="stylesheet" href="/assets/components/tools/doors.css">
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
<link rel="stylesheet" href="/assets/components/tools/plots.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/testing_ground.css">
<link rel="stylesheet" href="/assets/components/tools/troubleshoot.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
<script type="module">
import("/assets/components/router.js").catch((err) => {
console.error("[the_pond] bootstrap failed", err);
const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre");
pre.style.whiteSpace = "pre-wrap";
pre.style.color = "#ff8080";
pre.style.background = "#200";
pre.style.padding = "12px";
pre.style.margin = "12px";
pre.textContent = `[the_pond] failed to load router:\n${err?.stack || err}`;
target.appendChild(pre);
});
</script>
<script src="/assets/js/snackbar.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300..800&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
rel="stylesheet">
<title>The Pond</title>
</head>
<title>Galaxy</title>
</head>
<body>
<div id="app"></div>
<!-- Menu button for phone -->
<div id="menu_button">
<i class="bi bi-list"></i>
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
</body>
<body>
<div id="app"></div>
<!-- Menu button for phone -->
<div id="menu_button">
<i class="bi bi-list"></i>
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+13 -38
View File
@@ -8,7 +8,6 @@ import secrets
import shutil
import subprocess
import time
import uuid
from datetime import datetime
from pathlib import Path
@@ -563,20 +562,11 @@ def process_route(footage_path, route_name):
return {
"name": route_name,
"gif": f"/thumbnails/{route_name}--0/preview.gif",
"png": f"/thumbnails/{route_name}--0/preview.png",
"timestamp": route_timestamp_str,
"is_preserved": has_preserve_attr(segment_path)
}
def process_route_gif(footage_path, route_name):
segment_path = f"{footage_path}{route_name}--0"
qcamera_path = f"{segment_path}/qcamera.ts"
gif_output_path = os.path.join(segment_path, "preview.gif")
if not os.path.exists(gif_output_path):
video_to_gif(qcamera_path, gif_output_path)
def process_screen_recording(mp4):
stem = mp4.with_suffix("")
png_path = stem.with_suffix(".png")
@@ -592,44 +582,29 @@ def process_screen_recording(mp4):
return {
"filename": mp4.name,
"gif": f"/screen_recordings/{stem.with_suffix('.gif').name}",
"png": f"/screen_recordings/{png_path.name}",
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
"is_custom_name": is_custom_name
}
def process_screen_recording_gif(mp4):
stem = mp4.with_suffix("")
gif_path = stem.with_suffix(".gif")
if not gif_path.exists():
video_to_gif(mp4, gif_path)
def run_ffmpeg(args):
process = subprocess.Popen(["ffmpeg", "-hide_banner", "-loglevel", "error"] + args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
return stdout
def segment_to_segment_name(data_dir, segment):
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
return SegmentName(full_path)
def video_to_gif(input_path, output_path):
output_path = Path(output_path)
sped_up_path = output_path.with_suffix(f".{uuid.uuid4()}.spedup.mp4")
run_ffmpeg(["-i", str(input_path), "-an", "-vf", "setpts=PTS/35", str(sped_up_path)])
run_ffmpeg(["-i", str(sped_up_path), "-loop", "0", str(output_path)])
if os.path.exists(sped_up_path):
os.remove(sped_up_path)
def video_to_png(input_path, output_path):
run_ffmpeg([
"-ss", str(get_video_duration(input_path) / 2),
"-i", str(input_path),
"-frames:v", "1",
str(output_path)
])
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", "1",
"-i", str(input_path),
"-frames:v", "1",
"-y",
str(output_path)
], capture_output=True, check=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Failed to generate PNG for {input_path}")
if e.stderr:
print(e.stderr)
def xor_encrypt_decrypt(data, key):
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
@@ -103,6 +103,10 @@ class Parser:
out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
if 'planplus' in outs:
self.parse_mdn('planplus', outs, in_N=plan_in_N, out_N=plan_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
elif 'planplus' in outs:
planplus_mhp = outs['planplus'].shape[1] != 2 * ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH
planplus_in_N, planplus_out_N = (ModelConstants.PLAN_MHP_N, ModelConstants.PLAN_MHP_SELECTION) if planplus_mhp else (0, 0)
self.parse_mdn('planplus', outs, in_N=planplus_in_N, out_N=planplus_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH))
if 'lane_lines' in outs:
self.parse_mdn('lane_lines', outs, in_N=0, out_N=0,
out_shape=(ModelConstants.NUM_LANE_LINES, ModelConstants.IDX_N, ModelConstants.LANE_LINES_WIDTH))
+7 -5
View File
@@ -125,8 +125,8 @@ class ModelState:
cloudlog.warning(f"ModelVersion parameter not available: {e}")
model_dir = MODELS_PATH
# For the default "bd2" model, use built-in files from the models directory
if model_id == "bd2":
# For the default "sc" model, use built-in files from the models directory
if model_id == "sc":
models_dir = Path(__file__).parent / "models"
VISION_PKL_PATH = models_dir / "driving_vision_tinygrad.pkl"
POLICY_PKL_PATH = models_dir / "driving_policy_tinygrad.pkl"
@@ -191,6 +191,8 @@ class ModelState:
# Add policy_generation attribute after loading policy_metadata
self.policy_generation = model_version or "v8"
self.is_v11 = (self.policy_generation == "v11")
self.is_v10 = (self.policy_generation == "v10")
self.is_v12 = (self.policy_generation == "v12")
self.is_v9 = (self.policy_generation == "v9")
self.mlsim = (self.policy_generation in ("v8", "v10", "v11", "v12"))
@@ -329,14 +331,14 @@ class ModelState:
self.full_prev_desired_curv[0,-1,:] = policy_outputs_dict['desired_curvature'][0, :]
if self.prev_desired_curv_key is not None:
# v9 models expect zeros for prev_desired_curv(s); others use history
if self.is_v9:
# v9/v10/v11/v12 models expect zeros for prev_desired_curv(s); others use history
if self.is_v9 or self.is_v10 or self.is_v11 or self.is_v12:
self.numpy_inputs[self.prev_desired_curv_key][:] = 0 * self.full_prev_desired_curv[0, self.temporal_idxs]
else:
self.numpy_inputs[self.prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
if self.off_policy_enabled and self.off_policy_prev_desired_curv_key is not None:
if self.is_v9:
if self.is_v9 or self.is_v12:
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = 0 * self.full_prev_desired_curv[0, self.temporal_idxs]
else:
self.off_policy_numpy_inputs[self.off_policy_prev_desired_curv_key][:] = self.full_prev_desired_curv[0, self.temporal_idxs]
@@ -37,6 +37,7 @@ FrogPilotDevicePanel::FrogPilotDevicePanel(FrogPilotSettingsWindow *parent) : Fr
{"DeviceShutdown", tr("Device Shutdown Timer"), tr("<b>Keep the device on for the set amount of time after a drive</b> before it shuts down automatically."), ""},
{"NoLogging", tr("Disable Logging"), QString("<b>%1</b><br><br>%2").arg(tr("WARNING: This will prevent your drives from being recorded and all data will be unobtainable!")).arg(tr("<b>Prevent the device from saving driving data.</b>")), ""},
{"NoUploads", tr("Disable Uploads"), QString("<b>%1</b><br><br>%2").arg(tr("WARNING: This will prevent your drives from being uploaded to <b>comma connect</b> which will impact debugging and official support from comma!")).arg(tr("<b>Prevent the device from uploading driving data.</b>")), ""},
{"AlwaysAllowUploads", tr("Always Allow Uploads"), tr("<b>Force driving data uploads regardless of network type, metered status, and upload restrictions.</b> This overrides normal upload blocking checks."), ""},
{"HigherBitrate", tr("High-Quality Recording"), tr("<b>Save drive footage in higher video quality.</b>"), ""},
{"LowVoltageShutdown", tr("Low-Voltage Cutoff"), tr("<b>While parked, if the battery voltage falls below the set level, the device shuts down</b> to prevent excessive battery drain."), ""},
{"IncreaseThermalLimits", tr("Raise Temperature Limits"), QString("<b>%1</b><br><br>%2").arg(tr("WARNING: Running at higher temperatures may damage your device!")).arg(tr("<b>Allow the device to run at higher temperatures</b> before throttling or shutting down. Use only if you understand the risks!")), ""},
+1 -1
View File
@@ -25,7 +25,7 @@ private:
std::map<QString, AbstractControl*> toggles;
QSet<QString> deviceManagementKeys = {"DeviceShutdown", "HigherBitrate", "IncreaseThermalLimits", "LowVoltageShutdown", "NoLogging", "NoUploads", "UseKonikServer"};
QSet<QString> deviceManagementKeys = {"AlwaysAllowUploads", "DeviceShutdown", "HigherBitrate", "IncreaseThermalLimits", "LowVoltageShutdown", "NoLogging", "NoUploads", "UseKonikServer"};
QSet<QString> screenKeys = {"ScreenBrightness", "ScreenBrightnessOnroad", "ScreenRecorder", "ScreenTimeout", "ScreenTimeoutOnroad", "StandbyMode"};
QSet<QString> parentKeys;
@@ -273,7 +273,10 @@ void FrogPilotSettingsWindow::updateVariables() {
hasSNG = hasOpenpilotLongitudinal && CP.getAutoResumeSng();
hasZSS = frogpilot_toggles.value("has_zss").toBool();
isAngleCar = CP.getSteerControlType() == cereal::CarParams::SteerControlType::ANGLE;
isBolt = carFingerprint == "CHEVROLET_BOLT_CC" || carFingerprint == "CHEVROLET_BOLT_EUV";
isBolt = carFingerprint == "CHEVROLET_BOLT_ACC_2022_2023" ||
carFingerprint == "CHEVROLET_BOLT_CC_2022_2023" ||
carFingerprint == "CHEVROLET_BOLT_CC_2019_2021" ||
carFingerprint == "CHEVROLET_BOLT_CC_2017";
isGM = carMake == "gm";
isHKG = carMake == "hyundai";
isHKGCanFd = isHKG && safetyModel == cereal::CarParams::SafetyModel::HYUNDAI_CANFD;
@@ -103,6 +103,10 @@ FrogPilotLongitudinalPanel::FrogPilotLongitudinalPanel(
"vehicle's detected powertrain type but can be overridden if the "
"automatic choice doesn't match."),
""},
{"TruckTuning", tr("Truck Tuning"),
tr("<b>Use aggressive acceleration profiles tuned for trucks.</b> "
"Intended for heavy vehicles that need stronger throttle."),
""},
{"LongitudinalActuatorDelay",
longitudinalActuatorDelay != 0
? QString(tr("Actuator Delay (Default: %1)"))
@@ -1125,6 +1129,21 @@ FrogPilotLongitudinalPanel::FrogPilotLongitudinalPanel(
&FrogPilotLongitudinalPanel::updateToggles);
}
QObject::connect(static_cast<ToggleControl *>(toggles["EVTuning"]),
&ToggleControl::toggleFlipped, this, [this]() {
if (params.getBool("EVTuning")) {
params.putBool("TruckTuning", false);
}
updateToggles();
});
QObject::connect(static_cast<ToggleControl *>(toggles["TruckTuning"]),
&ToggleControl::toggleFlipped, this, [this]() {
if (params.getBool("TruckTuning")) {
params.putBool("EVTuning", false);
}
updateToggles();
});
FrogPilotParamValueControl *trafficFollowToggle =
static_cast<FrogPilotParamValueControl *>(toggles["TrafficFollow"]);
FrogPilotParamValueControl *trafficAccelerationToggle =
@@ -1632,10 +1651,15 @@ void FrogPilotLongitudinalPanel::updateToggles() {
else if (key == "StoppingDecelRate" || key == "VEgoStarting" ||
key == "VEgoStopping") {
setVisible &= !isGM || !params.getBool("ExperimentalGMTune");
setVisible &= !isToyota || !params.getBool("FrogsGoMoosTweak");
}
if (key == "EVTuning") {
toggle->setEnabled(!params.getBool("TruckTuning"));
} else if (key == "TruckTuning") {
toggle->setEnabled(!params.getBool("EVTuning"));
}
toggle->setVisible(setVisible);
if (setVisible) {
@@ -40,7 +40,7 @@ private:
std::map<QString, AbstractControl*> toggles;
QSet<QString> advancedLongitudinalTuneKeys = {"EVTuning", "LongitudinalActuatorDelay", "StartAccel", "StopAccel", "StoppingDecelRate", "VEgoStarting", "VEgoStopping"};
QSet<QString> advancedLongitudinalTuneKeys = {"EVTuning", "TruckTuning", "LongitudinalActuatorDelay", "StartAccel", "StopAccel", "StoppingDecelRate", "VEgoStarting", "VEgoStopping"};
QSet<QString> aggressivePersonalityKeys = {"AggressiveFollow", "AggressiveFollowHigh", "AggressiveJerkAcceleration", "AggressiveJerkDeceleration", "AggressiveJerkDanger", "AggressiveJerkSpeed", "AggressiveJerkSpeedDecrease", "ResetAggressivePersonality"};
QSet<QString> conditionalExperimentalKeys = {"CESpeed", "CESpeedLead", "CECurves", "CELead", "CEModelStopTime", "CENavigation", "CESignalSpeed", "ShowCEMStatus"};
QSet<QString> curveSpeedKeys = {"CalibratedLateralAcceleration", "CalibrationProgress", "ResetCurveData", "ShowCSCStatus"};
@@ -53,6 +53,7 @@ FrogPilotSoundsPanel::FrogPilotSoundsPanel(FrogPilotSettingsWindow *parent) : Fr
{"CustomAlerts", tr("FrogPilot Alerts"), tr("<b>Optional FrogPilot alerts</b> that highlight driving events in a more noticeable way."), "../../frogpilot/assets/toggle_icons/icon_green_light.png"},
{"GoatScream", tr("Goat Scream"), tr("<b>Play the infamous \"Goat Scream\" when the steering controller reaches its limit.</b> Based on the \"Turn Exceeds Steering Limit\" event."), ""},
{"GoatScreamCriticalAlerts", tr("Goat Scream Critical Alerts"), tr("<b>Play the infamous \"Goat Scream\" for full-screen critical alerts that require immediate takeover.</b><br><br>Examples include: \"TAKE CONTROL IMMEDIATELY\" and \"Stock AEB: Risk of Collision\"."), ""},
{"GreenLightAlert", tr("Green Light Alert"), tr("<b>Play an alert when the model predicts a red light has turned green.</b><br><br><i><b>Disclaimer</b>: openpilot does not explicitly detect traffic lights. This alert is based on end-to-end model predictions from camera input and may trigger even when the light has not changed.</i>"), ""},
{"LeadDepartingAlert", tr("Lead Departing Alert"), tr("<b>Play an alert when the lead vehicle departs from a stop.</b>"), ""},
{"LoudBlindspotAlert", tr("Loud \"Car Detected in Blindspot\" Alert"), tr("<b>Play a louder alert if a vehicle is in the blind spot when attempting to change lanes.</b> Based on the \"Car Detected in Blindspot\" event."), ""},
+1 -1
View File
@@ -28,7 +28,7 @@ private:
std::map<QString, AbstractControl*> toggles;
QSet<QString> alertVolumeControlKeys {"DisengageVolume", "EngageVolume", "PromptDistractedVolume", "PromptVolume", "RefuseVolume", "WarningImmediateVolume", "WarningSoftVolume"};
QSet<QString> customAlertsKeys {"GoatScream", "GreenLightAlert", "LeadDepartingAlert", "LoudBlindspotAlert", "SpeedLimitChangedAlert"};
QSet<QString> customAlertsKeys {"GoatScream", "GoatScreamCriticalAlerts", "GreenLightAlert", "LeadDepartingAlert", "LoudBlindspotAlert", "SpeedLimitChangedAlert"};
QSet<QString> parentKeys;
+65
View File
@@ -66,6 +66,11 @@ void deleteThemeAsset(QDir &directory, const QString &subFolder, const QString &
}
void downloadThemeAsset(const QString &input, const std::string &paramKey, const QString &assetParam, Params &params, Params &params_memory) {
if (paramKey == "BootLogoToDownload") {
params_memory.put(paramKey, input.trimmed().toStdString());
return;
}
QString output = input;
int tilde = output.indexOf("~");
if (tilde >= 0) {
@@ -228,6 +233,7 @@ FrogPilotThemesPanel::FrogPilotThemesPanel(FrogPilotSettingsWindow *parent) : Fr
const std::vector<std::tuple<QString, QString, QString, QString>> themeToggles {
{"PersonalizeOpenpilot", tr("Custom Themes"), tr("<b>The overall look and feel of openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!"), "../../frogpilot/assets/toggle_icons/icon_frog.png"},
{"BootLogo", tr("Boot Logo"), tr("<b>The boot logo shown while the device starts.</b>"), ""},
{"CustomColors", tr("Color Scheme"), tr("<b>The color scheme used throughout openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!"), ""},
{"CustomDistanceIcons", tr("Distance Button"), tr("<b>The distance button icons shown on the driving screen.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!"), ""},
{"CustomIcons", tr("Icon Pack"), tr("<b>The icon style used across openpilot.</b> Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!"), ""},
@@ -252,6 +258,57 @@ FrogPilotThemesPanel::FrogPilotThemesPanel(FrogPilotSettingsWindow *parent) : Fr
themesLayout->setCurrentWidget(customThemesPanel);
});
themeToggle = personalizeOpenpilotToggle;
} else if (param == "BootLogo") {
manageBootLogosButton = new FrogPilotButtonsControl(title, desc, icon, {tr("DELETE"), tr("DOWNLOAD"), tr("SELECT")});
QObject::connect(manageBootLogosButton, &FrogPilotButtonsControl::buttonClicked, [this](int id) {
// Show all downloaded boot logos, including the currently selected one.
QStringList bootLogos = getThemeList(true, QDir(bootLogosDirectory.path()), "", "BootLogo", params);
if (id == 0) {
QString bootLogoToDelete = MultiOptionDialog::getSelection(tr("Select a boot logo to delete"), bootLogos, "", this);
if (!bootLogoToDelete.isEmpty() && ConfirmationDialog::confirm(tr("Delete the \"%1\" boot logo?").arg(bootLogoToDelete), tr("Delete"), this)) {
bootLogosDownloaded = false;
deleteThemeAsset(bootLogosDirectory, "", "DownloadableBootLogos", bootLogoToDelete, params);
}
} else if (id == 1) {
if (bootLogoDownloading) {
cancellingDownload = true;
params_memory.putBool("CancelThemeDownload", true);
QTimer::singleShot(2500, [this]() {
bootLogoDownloading = false;
cancellingDownload = false;
themeDownloading = false;
params_memory.putBool("CancelThemeDownload", false);
});
} else {
QStringList downloadableBootLogos = QString::fromStdString(params.get("DownloadableBootLogos")).split(",");
bootLogoToDownload = MultiOptionDialog::getSelection(tr("Select a boot logo to download"), downloadableBootLogos, "", this);
if (!bootLogoToDownload.isEmpty()) {
manageBootLogosButton->setValue(storeThemeName(bootLogoToDownload, "BootLogo", params));
bootLogoDownloading = true;
themeDownloading = true;
params_memory.put("ThemeDownloadProgress", "Downloading...");
downloadThemeAsset(bootLogoToDownload, "BootLogoToDownload", "DownloadableBootLogos", params, params_memory);
downloadStatusLabel->setText("Downloading...");
}
}
} else if (id == 2) {
QString bootLogoToSelect = MultiOptionDialog::getSelection(tr("Select a boot logo"), bootLogos, getThemeName("BootLogo", params), this);
if (!bootLogoToSelect.isEmpty()) {
manageBootLogosButton->setValue(storeThemeName(bootLogoToSelect, "BootLogo", params));
}
}
});
manageBootLogosButton->setValue(getThemeName(param.toStdString(), params));
themeToggle = manageBootLogosButton;
} else if (param == "CustomColors") {
manageCustomColorsButton = new FrogPilotButtonsControl(title, desc, icon, {tr("DELETE"), tr("DOWNLOAD"), tr("SELECT")});
QObject::connect(manageCustomColorsButton, &FrogPilotButtonsControl::buttonClicked, [this](int id) {
@@ -704,6 +761,7 @@ FrogPilotThemesPanel::FrogPilotThemesPanel(FrogPilotSettingsWindow *parent) : Fr
}
void FrogPilotThemesPanel::showEvent(QShowEvent *event) {
bootLogosDownloaded = params.get("DownloadableBootLogos").empty();
colorsDownloaded = params.get("DownloadableColors").empty();
distanceIconsDownloaded = params.get("DownloadableDistanceIcons").empty();
iconsDownloaded = params.get("DownloadableIcons").empty();
@@ -756,6 +814,7 @@ void FrogPilotThemesPanel::updateState(const UIState &s, const FrogPilotUIState
finalizingDownload = true;
QTimer::singleShot(2500, [this]() {
bootLogoDownloading = false;
colorDownloading = false;
distanceIconDownloading = false;
finalizingDownload = false;
@@ -765,6 +824,7 @@ void FrogPilotThemesPanel::updateState(const UIState &s, const FrogPilotUIState
themeDownloading = false;
wheelDownloading = false;
bootLogosDownloaded = params.get("DownloadableBootLogos").empty();
colorsDownloaded = params.get("DownloadableColors").empty();
distanceIconsDownloaded = params.get("DownloadableDistanceIcons").empty();
iconsDownloaded = params.get("DownloadableIcons").empty();
@@ -782,6 +842,11 @@ void FrogPilotThemesPanel::updateState(const UIState &s, const FrogPilotUIState
bool parked = !s.scene.started || fs.frogpilot_scene.parked || fs.frogpilot_toggles.value("frogs_go_moo").toBool();
manageBootLogosButton->setText(1, bootLogoDownloading ? tr("CANCEL") : tr("DOWNLOAD"));
manageBootLogosButton->setEnabledButtons(0, !themeDownloading);
manageBootLogosButton->setEnabledButtons(1, fs.frogpilot_scene.online && (!themeDownloading || bootLogoDownloading) && !cancellingDownload && !finalizingDownload && !bootLogosDownloaded && parked);
manageBootLogosButton->setEnabledButtons(2, !themeDownloading);
manageCustomColorsButton->setText(1, colorDownloading ? tr("CANCEL") : tr("DOWNLOAD"));
manageCustomColorsButton->setEnabledButtons(0, !themeDownloading);
manageCustomColorsButton->setEnabledButtons(1, fs.frogpilot_scene.online && (!themeDownloading || colorDownloading) && !cancellingDownload && !finalizingDownload && !colorsDownloaded && parked);
+6 -1
View File
@@ -19,6 +19,8 @@ private:
void updateToggles();
bool cancellingDownload;
bool bootLogoDownloading = false;
bool bootLogosDownloaded = false;
bool colorDownloading;
bool colorsDownloaded;
bool distanceIconDownloading;
@@ -40,10 +42,11 @@ private:
std::map<QString, AbstractControl*> toggles;
QSet<QString> customThemeKeys = {"CustomColors", "CustomDistanceIcons", "CustomIcons", "CustomSignals", "CustomSounds", "DownloadStatusLabel", "WheelIcon"};
QSet<QString> customThemeKeys = {"BootLogo", "CustomColors", "CustomDistanceIcons", "CustomIcons", "CustomSignals", "CustomSounds", "DownloadStatusLabel", "WheelIcon"};
QSet<QString> parentKeys;
FrogPilotButtonsControl *manageBootLogosButton;
FrogPilotButtonsControl *manageCustomColorsButton;
FrogPilotButtonsControl *manageCustomIconsButton;
FrogPilotButtonsControl *manageCustomSignalsButton;
@@ -55,11 +58,13 @@ private:
LabelControl *downloadStatusLabel;
QDir bootLogosDirectory{"/data/themes/bootlogos/"};
QDir themePacksDirectory{"/data/themes/theme_packs/"};
QDir wheelsDirectory{"/data/themes/steering_wheels/"};
QJsonObject frogpilotToggleLevels;
QString bootLogoToDownload;
QString colorSchemeToDownload;
QString distanceIconPackToDownload;
QString iconPackToDownload;
+30 -4
View File
@@ -1,6 +1,9 @@
#include <QRegularExpression>
#include <QTextStream>
#include <chrono>
#include <thread>
#include "frogpilot/ui/qt/offroad/vehicle_settings.h"
QStringList getCarNames(const QString &carMake, QMap<QString, QString> &carModels) {
@@ -167,9 +170,11 @@ FrogPilotVehiclesPanel::FrogPilotVehiclesPanel(FrogPilotSettingsWindow *parent)
std::vector<std::tuple<QString, QString, QString, QString>> vehicleToggles {
{"GMToggles", tr("General Motors Settings"), tr("<b>FrogPilot features for General Motors vehicles.</b>"), ""},
{"ExperimentalGMTune", tr("FrogsGoMoo's Experimental Tune"), tr("<b>Experimental GM tune by FrogsGoMoo</b> that attempts to smoothen stopping and takeoff control. Use at your own risk!"), ""},
{"GMPedalLongitudinal", tr("Use Pedal for Longitudinal Control"), tr("<b>Use the pedal interceptor for longitudinal control</b> instead of camera ACC/Redneck when available."), ""},
{"RemapCancelToDistance", tr("Remap cancel to distance"), tr("<b>Treat CANCEL as distance-button input</b> on supported pedal-long GM platforms. Toggle requires a reboot to take effect."), ""},
{"LongPitch", tr("Smooth Pedal Response on Hills"), tr("<b>Smoothen acceleration and braking</b> when driving downhill/uphill."), ""},
{"RedPanda", tr("Red Panda"), tr("<b>Enable Red Panda behavior</b> for GM (alternate safety config and bus numbering). Requires a reboot to take effect."), ""},
{"RemoteStartBootsComma", tr("Remote Start Boots Comma"), tr("<b>Use GM C9 SystemPowerMode</b> for ignition detection. Toggle requires a panda firmware update and a reboot to take effect."), ""},
{"VoltSNG", tr("Stop-and-Go Hack"), tr("<b>Force stop-and-go</b> on the 2017 Chevy Volt."), ""},
{"HKGToggles", tr("Hyundai/Kia/Genesis Settings"), tr("<b>FrogPilot features for Genesis, Hyundai, and Kia vehicles.</b>"), ""},
@@ -283,7 +288,7 @@ FrogPilotVehiclesPanel::FrogPilotVehiclesPanel(FrogPilotSettingsWindow *parent)
static_cast<FrogPilotParamValueControl*>(toggles["LockDoorsTimer"])->setWarning("<b>Warning:</b> openpilot can't detect if keys are still inside the car, so ensure you have a spare key to prevent accidental lockouts!");
QSet<QString> rebootKeys = {"NewLongAPI", "TacoTuneHacks"};
QSet<QString> rebootKeys = {"NewLongAPI", "RemapCancelToDistance", "TacoTuneHacks"};
for (const QString &key : rebootKeys) {
QObject::connect(static_cast<ToggleControl*>(toggles[key]), &ToggleControl::toggleFlipped, [key, this](bool state) {
if (started) {
@@ -300,6 +305,25 @@ FrogPilotVehiclesPanel::FrogPilotVehiclesPanel(FrogPilotSettingsWindow *parent)
});
}
ParamControl *remoteStartToggle = static_cast<ParamControl*>(toggles["RemoteStartBootsComma"]);
QObject::connect(remoteStartToggle, &ToggleControl::toggleFlipped, [parent, remoteStartToggle, this](bool state) {
const QString prompt = tr("Remote Start requires a Panda firmware update. Flash the Panda now?");
if (!FrogPilotConfirmationDialog::yesorno(prompt, this)) {
params.putBool("RemoteStartBootsComma", !state);
remoteStartToggle->refresh();
return;
}
std::thread([parent, this]() {
parent->keepScreenOn = true;
params_memory.putBool("FlashPanda", true);
while (params_memory.getBool("FlashPanda")) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
Hardware::reboot();
}).detach();
});
openDescriptions(forceOpenDescriptions, toggles);
QObject::connect(uiState(), &UIState::offroadTransition, [selectMakeButton, selectModelButton, this]() {
@@ -381,7 +405,9 @@ void FrogPilotVehiclesPanel::updateToggles() {
bool setVisible = tuningLevel >= frogpilotToggleLevels[key].toDouble();
if (gmKeys.contains(key)) {
if (key == "ClusterOffset") {
setVisible &= isGM || isToyota;
} else if (gmKeys.contains(key)) {
setVisible &= isGM;
} else if (hkgKeys.contains(key)) {
setVisible &= isHKG;
@@ -407,7 +433,7 @@ void FrogPilotVehiclesPanel::updateToggles() {
setVisible &= isHKGCanFd;
}
else if (key == "GMPedalLongitudinal") {
else if (key == "GMPedalLongitudinal" || key == "RemapCancelToDistance") {
setVisible &= hasPedal;
}
+4 -3
View File
@@ -36,10 +36,10 @@ private:
std::map<QString, AbstractControl*> toggles;
QSet<QString> gmKeys = {"ExperimentalGMTune", "GMPedalLongitudinal", "LongPitch", "VoltSNG"};
QSet<QString> gmKeys = {"GMPedalLongitudinal", "LongPitch", "RedPanda", "RemapCancelToDistance", "RemoteStartBootsComma", "VoltSNG"};
QSet<QString> hkgKeys = {"NewLongAPI", "TacoTuneHacks"};
QSet<QString> longitudinalKeys = {"ExperimentalGMTune", "FrogsGoMoosTweak", "LongPitch", "NewLongAPI", "SNGHack", "VoltSNG"};
QSet<QString> toyotaKeys = {"ClusterOffset", "FrogsGoMoosTweak", "LockDoorsTimer", "SNGHack", "ToyotaDoors"};
QSet<QString> longitudinalKeys = {"FrogsGoMoosTweak", "LongPitch", "NewLongAPI", "RemapCancelToDistance", "SNGHack", "VoltSNG"};
QSet<QString> toyotaKeys = {"FrogsGoMoosTweak", "LockDoorsTimer", "SNGHack", "ToyotaDoors"};
QSet<QString> vehicleInfoKeys = {"BlindSpotSupport", "HardwareDetected", "OpenpilotLongitudinal", "PedalSupport", "RadarSupport", "SASCMSupport", "SDSUSupport", "SNGSupport"};
QSet<QString> parentKeys;
@@ -50,6 +50,7 @@ private:
ParamControl *forceFingerprint;
Params params;
Params params_memory{"/dev/shm/params"};
Params params_default{"/dev/shm/params_default"};
QJsonObject frogpilotToggleLevels;
+2 -1
View File
@@ -10,6 +10,7 @@ cdef class CLContext:
cdef class VisionBuf:
cdef cppVisionBuf * buf
cdef object _owner
@staticmethod
cdef create(cppVisionBuf*)
cdef create(cppVisionBuf*, object)
+6 -2
View File
@@ -30,11 +30,15 @@ cpdef enum VisionStreamType:
cdef class VisionBuf:
@staticmethod
cdef create(cppVisionBuf * cbuf):
cdef create(cppVisionBuf * cbuf, object owner):
buf = VisionBuf()
buf.buf = cbuf
buf._owner = owner
return buf
def __dealloc__(self):
self._owner = None
@property
def data(self):
return np.asarray(<cnp.uint8_t[:self.buf.len]> self.buf.addr)
@@ -155,7 +159,7 @@ cdef class VisionIpcClient:
buf = self.client.recv(&self.extra, timeout_ms)
if not buf:
return None
return VisionBuf.create(buf)
return VisionBuf.create(buf, self)
def connect(self, bool blocking):
return self.client.connect(blocking)
@@ -171,6 +171,7 @@ BO_ 481 ASCMSteeringButton: 7 K124_ASCM
SG_ DistanceButton : 22|1@0+ (1,0) [0|0] "" NEO
SG_ LKAButton : 23|1@0+ (1,0) [0|0] "" NEO
SG_ ACCAlwaysOne : 24|1@0+ (1,0) [0|1] "" XXX
SG_ ACCHiddenBit : 30|1@0+ (1,0) [0|1] "" XXX
SG_ ACCButtons : 46|3@0+ (1,0) [0|0] "" NEO
SG_ DriveModeButton : 39|1@0+ (1,0) [0|1] "" XXX
SG_ RollingCounter : 33|2@0+ (1,0) [0|3] "" NEO
+5
View File
@@ -13,6 +13,11 @@ a.out
dist/
pandacan.egg-info/
obj/
!board/obj/
!board/obj/bootstub.panda_remote.bin
!board/obj/bootstub.panda_h7_remote.bin
!board/obj/panda_remote.bin.signed
!board/obj/panda_h7_remote.bin.signed
examples/output.csv
.DS_Store
.vscode*
+17 -4
View File
@@ -33,6 +33,7 @@ extern bool can_loopback;
// Ignition detected from CAN meessages
bool ignition_can = false;
uint32_t ignition_can_cnt = 0U;
extern bool gm_remote_start_boots_comma;
#define ALL_CAN_SILENT 0xFF
#define ALL_CAN_LIVE 0
@@ -202,10 +203,22 @@ void ignition_can_hook(CANPacket_t *to_push) {
int len = GET_LEN(to_push);
// GM exception
if ((addr == 0xC9) && (len == 8)) {
// Matches SystemPowerMode (1=Run, 0=Off)
ignition_can = (GET_BYTE(to_push, 6) & 0x10U) != 0U;
ignition_can_cnt = 0U;
#ifdef PANDA_GM_REMOTE_START_C9
if (true) {
#else
if (gm_remote_start_boots_comma) {
#endif
if ((addr == 0xC9) && (len == 8)) {
// Matches SystemPowerMode (1=Run, 0=Off)
ignition_can = (GET_BYTE(to_push, 6) & 0x10U) != 0U;
ignition_can_cnt = 0U;
}
} else {
if ((addr == 0x1F1) && (len == 8)) {
// SystemPowerMode (2=Run, 3=Crank Request)
ignition_can = (GET_BYTE(to_push, 0) & 0x2U) != 0U;
ignition_can_cnt = 0U;
}
}
// Tesla exception
Binary file not shown.
Binary file not shown.

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