Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea53286972 | |||
| d2a6a60ea0 | |||
| ecec3afbd6 | |||
| 5a67663069 | |||
| 54b307378b | |||
| 9b4c0a7151 | |||
| 299c42040a | |||
| 2d50c43309 | |||
| 5965d2389e | |||
| 43eeca188f | |||
| 7986f23b1c | |||
| d4ce22deaf | |||
| 782cef4c8b | |||
| 3ffe607452 | |||
| fc79fe1248 | |||
| 79e28d6f2e | |||
| 335e7cf74e | |||
| 978d57a674 | |||
| 33c0f0ef3a | |||
| c540998591 | |||
| 445e89e14c | |||
| 46e5eaa232 | |||
| d4d426f94e | |||
| 9364ffd6a8 | |||
| fcf8c3feb2 | |||
| 2881deb510 | |||
| 082418b012 | |||
| f8eab92141 | |||
| 208630f5ca | |||
| 0e0909cbd7 | |||
| 4320525df6 | |||
| 813e3183fc | |||
| b4cf18cdc2 | |||
| c8f18d1497 | |||
| 5ca3f503ee | |||
| 283e9b1d78 | |||
| 404922e80d | |||
| 77f314a170 | |||
| b0426254be | |||
| 0d911b2c31 | |||
| bd7f47eb20 | |||
| f14ac063b3 | |||
| b169d93464 | |||
| 14a3cb7025 | |||
| 9e6ef37d96 | |||
| aa36b2133e | |||
| 8eb6d5b447 | |||
| f2b9cc51a0 | |||
| f2d754e96d | |||
| cca4a277f1 | |||
| cd7cb862fd | |||
| e8009ad9b9 | |||
| cc700d9b81 | |||
| c24dd3552c | |||
| 2eced5b72c | |||
| 77ffd98a1a | |||
| d62d7dd9fc | |||
| c84acea993 | |||
| 876a45122f | |||
| f538eddb87 | |||
| 7c51e8aac1 | |||
| 4ab8b0e874 | |||
| b6f9f8180e | |||
| d07852b2bc | |||
| a70746918b | |||
| 0a6e03b7ca | |||
| f0f95d5d10 | |||
| cf735574a6 | |||
| d466d36429 | |||
| 33cb78cfa9 | |||
| e2a15a0985 | |||
| 984fd6a6cb | |||
| 7ef35817fc | |||
| 26e0b2406e | |||
| fed12740f2 | |||
| 030cf80148 | |||
| d082aa8a3c | |||
| fdcab8d127 | |||
| 266fda2c91 | |||
| 97a2b1522e | |||
| d59ab46e91 | |||
| 948cdf21d0 | |||
| f10391d9ce | |||
| 444edbe71b | |||
| bb41c76c62 | |||
| 1978e5fda1 | |||
| 85906ceb77 |
@@ -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)`
|
||||
|
||||
[](https://github.com/commaai/openpilot/actions/workflows/selfdrive_tests.yaml)
|
||||
[](LICENSE)
|
||||
[](https://x.com/comma_ai)
|
||||
[](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>
|
||||
|
||||
[](https://deepwiki.com/FrogAi/FrogPilot)
|
||||
[](https://discord.frogpilot.download)
|
||||
[](https://github.com/FrogAi/FrogPilot/releases/latest)
|
||||
[](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 Kart–style **"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 URL | Description | Recommended For |
|
||||
|----------------------------|---------------------------|--------------------------------------------------------|--------------------------|
|
||||
| FrogPilot | frogpilot.download | The main release branch. | Everyone |
|
||||
| FrogPilot‑Staging | staging.frogpilot.download| Beta branch with upcoming features. Expect bugs! | Early Adopters |
|
||||
| FrogPilot‑Testing | testing.frogpilot.download| Alpha branch with bleeding-edge features. Breaks often!| Advanced Testers |
|
||||
| FrogPilot‑Development| No :) | Active development branch. Don't use! | **FrogPilot** Developers|
|
||||
| MAKE‑PRS‑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!
|
||||
|
||||

|
||||
|
||||
🐞 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
|
||||
------
|
||||
|
||||
[](https://www.star-history.com/#FrogAi/FrogPilot&Date)
|
||||
# StarPilot
|
||||
|
||||
[](https://deepwiki.com/firestar5683/StarPilot)
|
||||
[](https://firestar.link/discord)
|
||||
[](https://github.com/firestar5683/StarPilot)
|
||||
[](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
|
||||
|
||||
@@ -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},
|
||||
@@ -324,7 +328,6 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"DynamicPathWidth", PERSISTENT},
|
||||
{"DynamicPedalsOnUI", PERSISTENT},
|
||||
{"EngageVolume", PERSISTENT},
|
||||
{"ExperimentalGMTune", PERSISTENT},
|
||||
{"EVTuning", PERSISTENT},
|
||||
{"TruckTuning", PERSISTENT},
|
||||
{"Fahrenheit", PERSISTENT},
|
||||
@@ -354,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},
|
||||
@@ -474,6 +478,7 @@ std::unordered_map<std::string, uint32_t> keys = {
|
||||
{"RoadName", CLEAR_ON_MANAGER_START},
|
||||
{"RoadNameUI", PERSISTENT},
|
||||
{"RedPanda", PERSISTENT},
|
||||
{"RemapCancelToDistance", PERSISTENT},
|
||||
{"RemoteStartBootsComma", PERSISTENT},
|
||||
{"RotatingWheel", PERSISTENT},
|
||||
{"ScreenBrightness", PERSISTENT},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -244,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
|
||||
|
||||
@@ -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 = {
|
||||
@@ -147,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"),
|
||||
@@ -172,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"),
|
||||
@@ -221,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"),
|
||||
@@ -239,11 +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"),
|
||||
@@ -456,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")
|
||||
]
|
||||
|
||||
@@ -534,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
|
||||
@@ -575,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)
|
||||
@@ -682,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"))
|
||||
@@ -706,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"))
|
||||
@@ -791,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
|
||||
@@ -822,13 +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
|
||||
|
||||
|
||||
@@ -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,10 +47,10 @@ 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.12, 1.12, 1.45]
|
||||
A_CRUISE_MAX_VALS_STANDARD_EV = [1.15, 1.15, 1.15, 1.15, 1.30, 1.30, 1.72]
|
||||
A_CRUISE_MAX_VALS_SPORT_EV = [1.25, 1.25, 1.25, 1.25, 1.45, 1.5, 2.0]
|
||||
A_CRUISE_MAX_VALS_SPORT_PLUS_EV = [1.35, 1.35, 1.35, 1.35, 1.60, 1.60, 2.10]
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 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>
|
||||
`
|
||||
}
|
||||
@@ -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/s²</span>
|
||||
<span>0.0 m/s²</span>
|
||||
<span>${formatValue(range.min, 1)} m/s²</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>
|
||||
`;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1008 KiB After Width: | Height: | Size: 332 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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!")), ""},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1651,7 +1651,6 @@ void FrogPilotLongitudinalPanel::updateToggles() {
|
||||
|
||||
else if (key == "StoppingDecelRate" || key == "VEgoStarting" ||
|
||||
key == "VEgoStopping") {
|
||||
setVisible &= !isGM || !params.getBool("ExperimentalGMTune");
|
||||
setVisible &= !isToyota || !params.getBool("FrogsGoMoosTweak");
|
||||
}
|
||||
|
||||
|
||||
@@ -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."), ""},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -170,8 +170,8 @@ 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."), ""},
|
||||
@@ -288,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) {
|
||||
@@ -405,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;
|
||||
@@ -431,7 +433,7 @@ void FrogPilotVehiclesPanel::updateToggles() {
|
||||
setVisible &= isHKGCanFd;
|
||||
}
|
||||
|
||||
else if (key == "GMPedalLongitudinal") {
|
||||
else if (key == "GMPedalLongitudinal" || key == "RemapCancelToDistance") {
|
||||
setVisible &= hasPedal;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,10 +36,10 @@ private:
|
||||
|
||||
std::map<QString, AbstractControl*> toggles;
|
||||
|
||||
QSet<QString> gmKeys = {"ExperimentalGMTune", "GMPedalLongitudinal", "LongPitch", "RedPanda", "RemoteStartBootsComma", "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;
|
||||
|
||||
@@ -10,6 +10,7 @@ cdef class CLContext:
|
||||
|
||||
cdef class VisionBuf:
|
||||
cdef cppVisionBuf * buf
|
||||
cdef object _owner
|
||||
|
||||
@staticmethod
|
||||
cdef create(cppVisionBuf*)
|
||||
cdef create(cppVisionBuf*, object)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
const uint8_t gitversion[] = "DEV-4d1efbb2-DEBUG";
|
||||
const uint8_t gitversion[] = "DEV-45631cf2-DEBUG";
|
||||
|
||||
@@ -48,16 +48,16 @@ const CanMsg GM_ASCM_TX_MSGS[] = {{0x180, 0, 4}, {0x409, 0, 7}, {0x40A, 0, 7}, {
|
||||
{0xA1, 1, 7}, {0x306, 1, 8}, {0x308, 1, 7}, {0x310, 1, 2}, // obs bus
|
||||
{0x315, 2, 5}}; // ch bus
|
||||
|
||||
const CanMsg GM_CAM_TX_MSGS[] = {{0x180, 0, 4}, {0x200, 0, 6}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
const CanMsg GM_CAM_TX_MSGS[] = {{0x180, 0, 4}, {0x370, 0, 6}, {0x200, 0, 6}, {0x1E1, 0, 7}, {0x3D1, 0, 8}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
{0x1E1, 2, 7}, {0x184, 2, 8}}; // camera bus
|
||||
|
||||
const CanMsg GM_CAM_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x315, 0, 5}, {0x2CB, 0, 8}, {0x370, 0, 6}, {0x200, 0, 6}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
const CanMsg GM_CAM_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x315, 0, 5}, {0x2CB, 0, 8}, {0x370, 0, 6}, {0x200, 0, 6}, {0x3D1, 0, 8}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
{0x315, 2, 5}, {0x1E1, 2, 7}, {0x184, 2, 8}}; // camera bus
|
||||
|
||||
const CanMsg GM_SDGM_TX_MSGS[] = {{0x180, 0, 4}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
{0x184, 2, 8}}; // camera bus
|
||||
|
||||
const CanMsg GM_CC_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x1E1, 0, 7}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
const CanMsg GM_CC_LONG_TX_MSGS[] = {{0x180, 0, 4}, {0x370, 0, 6}, {0x1E1, 0, 7}, {0x3D1, 0, 8}, {0xBD, 0, 7}, {0x1F5, 0, 8}, // pt bus
|
||||
{0x184, 2, 8}, {0x1E1, 2, 7}}; // camera bus
|
||||
|
||||
// TODO: do checksum and counter checks. Add correct timestep, 0.1s for now.
|
||||
@@ -72,6 +72,17 @@ RxCheck gm_rx_checks[] = {
|
||||
{.msg = {{0xC9, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
};
|
||||
|
||||
// Volt ASCM / ASCM_INT stock-camera path should pin 0x1E1/0xF1 to bus 0 to avoid
|
||||
// latching bus 2 when mirrored startup traffic is present.
|
||||
RxCheck gm_ascm_int_stock_cam_rx_checks[] = {
|
||||
{.msg = {{0x184, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
{.msg = {{0x34A, 0, 5, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
{.msg = {{0x1E1, 0, 7, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
{.msg = {{0xF1, 0, 6, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
{.msg = {{0x1C4, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
{.msg = {{0xC9, 0, 8, .frequency = 10U}, { 0 }, { 0 }}},
|
||||
};
|
||||
|
||||
const uint16_t GM_PARAM_HW_CAM = 1;
|
||||
const uint16_t GM_PARAM_HW_CAM_LONG = 2;
|
||||
const uint16_t GM_PARAM_CC_LONG = 4;
|
||||
@@ -86,6 +97,20 @@ const uint16_t GM_PARAM_HW_SDGM = 1024;
|
||||
const uint16_t GM_PARAM_BOLT_2017 = 2048;
|
||||
const uint16_t GM_PARAM_BOLT_2022_PEDAL = 4096;
|
||||
const uint16_t GM_PARAM_REMOTE_START_BOOTS_COMMA = 8192;
|
||||
const uint16_t GM_PARAM_PANDA_3D1_SCHED = 16384;
|
||||
const uint16_t GM_PARAM_PANDA_PADDLE_SCHED = 32768;
|
||||
|
||||
const uint32_t GM_3D1_PERIOD_US = 100000U;
|
||||
const uint32_t GM_3D1_TX_OFFSET_US = 0U;
|
||||
const uint32_t GM_3D1_LOCK_TOLERANCE_US = 20000U;
|
||||
const uint32_t GM_PADDLE_PERIOD_US = 25000U;
|
||||
const uint32_t GM_PADDLE_TX_OFFSET_US = 0U;
|
||||
const uint32_t GM_PADDLE_LOCK_TOLERANCE_US = 5000U;
|
||||
const uint32_t GM_PADDLE_STALE_US = 100000U;
|
||||
const uint32_t GM_PADDLE_FEED_STALE_US = 100000U;
|
||||
|
||||
void can_send(CANPacket_t *to_push, uint8_t bus_number, bool skip_tx_hook);
|
||||
void can_set_checksum(CANPacket_t *packet);
|
||||
|
||||
enum {
|
||||
GM_BTN_UNPRESS = 1,
|
||||
@@ -111,6 +136,111 @@ bool gm_bolt_2022_pedal = false;
|
||||
bool gm_ascm_int = false;
|
||||
bool gm_force_brake_c9 = false;
|
||||
bool gm_remote_start_boots_comma = false;
|
||||
bool gm_panda_3d1_sched = false;
|
||||
bool gm_panda_paddle_sched = false;
|
||||
|
||||
bool gm_3d1_spoof_valid = false;
|
||||
bool gm_3d1_internal_tx = false;
|
||||
uint8_t gm_3d1_spoof_data[8] = {0U};
|
||||
uint32_t gm_3d1_next_tx_us = 0U;
|
||||
uint32_t gm_3d1_expected_stock_us = 0U;
|
||||
uint32_t gm_3d1_last_stock_us = 0U;
|
||||
bool gm_3d1_phase_locked = false;
|
||||
bool gm_paddle_internal_tx = false;
|
||||
|
||||
typedef struct {
|
||||
bool spoof_valid;
|
||||
uint8_t spoof_data[8];
|
||||
uint32_t next_tx_us;
|
||||
uint32_t expected_stock_us;
|
||||
uint32_t last_stock_us;
|
||||
uint32_t last_feed_us;
|
||||
bool phase_locked;
|
||||
} GmPeriodicSpoofState;
|
||||
|
||||
GmPeriodicSpoofState gm_bd_state = {0};
|
||||
GmPeriodicSpoofState gm_prndl2_state = {0};
|
||||
|
||||
static void gm_update_periodic_phase(GmPeriodicSpoofState *state, uint32_t now_us, uint32_t period_us, uint32_t lock_tolerance_us) {
|
||||
state->last_stock_us = now_us;
|
||||
if (!state->phase_locked) {
|
||||
state->phase_locked = true;
|
||||
state->expected_stock_us = now_us + period_us;
|
||||
} else {
|
||||
int32_t phase_err_us = (int32_t)(now_us - state->expected_stock_us);
|
||||
if (phase_err_us < 0) {
|
||||
phase_err_us = -phase_err_us;
|
||||
}
|
||||
|
||||
if ((uint32_t)phase_err_us <= lock_tolerance_us) {
|
||||
state->expected_stock_us += period_us;
|
||||
} else {
|
||||
state->expected_stock_us = now_us + period_us;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool gm_periodic_scheduler_ready(const GmPeriodicSpoofState *state, uint32_t now_us, uint32_t stale_us, uint32_t feed_stale_us) {
|
||||
bool stock_stale = (state->last_stock_us == 0U) || (get_ts_elapsed(now_us, state->last_stock_us) > stale_us);
|
||||
bool feed_stale = (state->last_feed_us == 0U) || (get_ts_elapsed(now_us, state->last_feed_us) > feed_stale_us);
|
||||
return state->phase_locked && !stock_stale && !feed_stale;
|
||||
}
|
||||
|
||||
static void gm_try_send_periodic_spoof(uint32_t now_us, uint32_t addr, uint8_t dlc, GmPeriodicSpoofState *state, uint32_t period_us) {
|
||||
if (!(gm_panda_paddle_sched && state->spoof_valid && (state->next_tx_us != 0U))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gm_periodic_scheduler_ready(state, now_us, GM_PADDLE_STALE_US, GM_PADDLE_FEED_STALE_US)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int32_t)(now_us - state->next_tx_us) < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
CANPacket_t to_send = {0};
|
||||
to_send.returned = 0U;
|
||||
to_send.rejected = 0U;
|
||||
to_send.extended = 0U;
|
||||
to_send.addr = addr;
|
||||
to_send.bus = 0U;
|
||||
to_send.data_len_code = dlc;
|
||||
(void)memcpy(to_send.data, state->spoof_data, 8U);
|
||||
can_set_checksum(&to_send);
|
||||
|
||||
gm_paddle_internal_tx = true;
|
||||
can_send(&to_send, 0U, false);
|
||||
gm_paddle_internal_tx = false;
|
||||
|
||||
state->next_tx_us += period_us;
|
||||
}
|
||||
|
||||
static void gm_try_send_3d1_spoof(uint32_t now_us) {
|
||||
if (!(gm_panda_3d1_sched && gm_3d1_spoof_valid && (gm_3d1_next_tx_us != 0U))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int32_t)(now_us - gm_3d1_next_tx_us) < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
CANPacket_t to_send = {0};
|
||||
to_send.returned = 0U;
|
||||
to_send.rejected = 0U;
|
||||
to_send.extended = 0U;
|
||||
to_send.addr = 0x3D1U;
|
||||
to_send.bus = 0U;
|
||||
to_send.data_len_code = 8U;
|
||||
(void)memcpy(to_send.data, gm_3d1_spoof_data, 8U);
|
||||
can_set_checksum(&to_send);
|
||||
|
||||
gm_3d1_internal_tx = true;
|
||||
can_send(&to_send, 0U, false);
|
||||
gm_3d1_internal_tx = false;
|
||||
|
||||
gm_3d1_next_tx_us += GM_3D1_PERIOD_US;
|
||||
}
|
||||
|
||||
static void gm_rx_hook(const CANPacket_t *to_push) {
|
||||
if (GET_BUS(to_push) == 0U) {
|
||||
@@ -133,6 +263,13 @@ static void gm_rx_hook(const CANPacket_t *to_push) {
|
||||
// ACC steering wheel buttons (GM_CAM is tied to the PCM)
|
||||
if ((addr == 0x1E1) && (!gm_pcm_cruise || gm_cc_long)) {
|
||||
int button = (GET_BYTE(to_push, 5) & 0x70U) >> 4;
|
||||
bool remap_cancel_to_distance = (alternative_experience & ALT_EXP_GM_REMAP_CANCEL_TO_DISTANCE) != 0;
|
||||
// Malibu Hybrid pedal-long observed format uses byte3 bit0 set (typically 0x01/0x41) on ASCMSteeringButton.
|
||||
// Don't treat that physical CANCEL frame as OP disengage.
|
||||
bool malibu_cancel_passthrough = remap_cancel_to_distance && gm_bolt_2022_pedal && gm_pedal_long && ((GET_BYTE(to_push, 3) & 0x1U) != 0U);
|
||||
// Gen1 Bolt pedal-long behavior: let wheel CANCEL act as an in-drive aux button (personality cycling),
|
||||
// so safety should not force a controls drop on this RX cancel edge.
|
||||
bool bolt_cancel_passthrough = remap_cancel_to_distance && gm_pedal_long && !gm_has_acc && !gm_bolt_2022_pedal;
|
||||
|
||||
// enter controls on falling edge of set or rising edge of resume (avoids fault)
|
||||
bool set = (button != GM_BTN_SET) && (cruise_button_prev == GM_BTN_SET);
|
||||
@@ -142,7 +279,7 @@ static void gm_rx_hook(const CANPacket_t *to_push) {
|
||||
}
|
||||
|
||||
// exit controls on cancel press
|
||||
if (button == GM_BTN_CANCEL) {
|
||||
if ((button == GM_BTN_CANCEL) && !(malibu_cancel_passthrough || bolt_cancel_passthrough)) {
|
||||
controls_allowed = false;
|
||||
}
|
||||
|
||||
@@ -183,16 +320,53 @@ static void gm_rx_hook(const CANPacket_t *to_push) {
|
||||
|
||||
// Cruise check for CC only cars
|
||||
if ((addr == 0x3D1) && !gm_has_acc) {
|
||||
uint32_t now_us = microsecond_timer_get();
|
||||
gm_3d1_last_stock_us = now_us;
|
||||
bool cruise_engaged = (GET_BYTE(to_push, 4) >> 7) != 0U;
|
||||
if (gm_cc_long) {
|
||||
pcm_cruise_check(cruise_engaged);
|
||||
} else {
|
||||
cruise_engaged_prev = cruise_engaged;
|
||||
}
|
||||
|
||||
if (gm_panda_3d1_sched) {
|
||||
if (!gm_3d1_phase_locked) {
|
||||
gm_3d1_phase_locked = true;
|
||||
gm_3d1_expected_stock_us = now_us + GM_3D1_PERIOD_US;
|
||||
} else {
|
||||
int32_t phase_err_us = (int32_t)(now_us - gm_3d1_expected_stock_us);
|
||||
if (phase_err_us < 0) {
|
||||
phase_err_us = -phase_err_us;
|
||||
}
|
||||
|
||||
if ((uint32_t)phase_err_us <= GM_3D1_LOCK_TOLERANCE_US) {
|
||||
gm_3d1_expected_stock_us += GM_3D1_PERIOD_US;
|
||||
} else {
|
||||
gm_3d1_expected_stock_us = now_us + GM_3D1_PERIOD_US;
|
||||
}
|
||||
}
|
||||
|
||||
gm_3d1_next_tx_us = now_us + GM_3D1_TX_OFFSET_US;
|
||||
gm_try_send_3d1_spoof(now_us);
|
||||
}
|
||||
}
|
||||
|
||||
if (addr == 0xBD) {
|
||||
regen_braking = (GET_BYTE(to_push, 0) >> 4) != 0U;
|
||||
|
||||
if (gm_panda_paddle_sched) {
|
||||
uint32_t now_us = microsecond_timer_get();
|
||||
gm_update_periodic_phase(&gm_bd_state, now_us, GM_PADDLE_PERIOD_US, GM_PADDLE_LOCK_TOLERANCE_US);
|
||||
gm_bd_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||
gm_try_send_periodic_spoof(now_us, 0xBDU, 7U, &gm_bd_state, GM_PADDLE_PERIOD_US);
|
||||
}
|
||||
}
|
||||
|
||||
if ((addr == 0x1F5) && gm_panda_paddle_sched) {
|
||||
uint32_t now_us = microsecond_timer_get();
|
||||
gm_update_periodic_phase(&gm_prndl2_state, now_us, GM_PADDLE_PERIOD_US, GM_PADDLE_LOCK_TOLERANCE_US);
|
||||
gm_prndl2_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||
gm_try_send_periodic_spoof(now_us, 0x1F5U, 8U, &gm_prndl2_state, GM_PADDLE_PERIOD_US);
|
||||
}
|
||||
|
||||
// Pedal Interceptor
|
||||
@@ -291,12 +465,51 @@ static bool gm_tx_hook(const CANPacket_t *to_send) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cruise status spoofing only for non-ACC (CC-only) paths
|
||||
if (addr == 0x3D1) {
|
||||
bool allowed_cruise_status = !gm_has_acc;
|
||||
if (!allowed_cruise_status) {
|
||||
tx = false;
|
||||
} else if (gm_panda_3d1_sched) {
|
||||
if (gm_3d1_internal_tx) {
|
||||
tx = true;
|
||||
} else {
|
||||
uint32_t now_us = microsecond_timer_get();
|
||||
(void)memcpy(gm_3d1_spoof_data, to_send->data, 8U);
|
||||
gm_3d1_spoof_valid = true;
|
||||
if (gm_3d1_next_tx_us == 0U) {
|
||||
gm_3d1_next_tx_us = now_us + GM_3D1_TX_OFFSET_US;
|
||||
}
|
||||
bool stock_stale = (gm_3d1_last_stock_us == 0U) || (get_ts_elapsed(now_us, gm_3d1_last_stock_us) > 300000U);
|
||||
bool scheduler_ready = gm_3d1_phase_locked && !stock_stale;
|
||||
tx = !scheduler_ready;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REGEN PADDLE
|
||||
if (addr == 0xBD) {
|
||||
bool regen_apply = GET_BIT(to_send, 7) || GET_BIT(to_send, 6) || GET_BIT(to_send, 5) || GET_BIT(to_send, 4);
|
||||
if (!controls_allowed && regen_apply) {
|
||||
tx = false;
|
||||
}
|
||||
|
||||
if (gm_panda_paddle_sched && !gm_paddle_internal_tx) {
|
||||
uint32_t now_us = microsecond_timer_get();
|
||||
if (tx) {
|
||||
(void)memcpy(gm_bd_state.spoof_data, to_send->data, 7U);
|
||||
gm_bd_state.spoof_data[7] = 0U;
|
||||
gm_bd_state.spoof_valid = true;
|
||||
gm_bd_state.last_feed_us = now_us;
|
||||
if (gm_bd_state.next_tx_us == 0U) {
|
||||
gm_bd_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||
}
|
||||
}
|
||||
bool scheduler_ready = gm_periodic_scheduler_ready(&gm_bd_state, now_us, GM_PADDLE_STALE_US, GM_PADDLE_FEED_STALE_US);
|
||||
if (scheduler_ready) {
|
||||
tx = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRNDL2 regen check (7 for Gen0, Gen1. 5 For Gen2)
|
||||
@@ -306,6 +519,22 @@ static bool gm_tx_hook(const CANPacket_t *to_send) {
|
||||
if (!controls_allowed && prndl_apply) {
|
||||
tx = false;
|
||||
}
|
||||
|
||||
if (gm_panda_paddle_sched && !gm_paddle_internal_tx) {
|
||||
uint32_t now_us = microsecond_timer_get();
|
||||
if (tx) {
|
||||
(void)memcpy(gm_prndl2_state.spoof_data, to_send->data, 8U);
|
||||
gm_prndl2_state.spoof_valid = true;
|
||||
gm_prndl2_state.last_feed_us = now_us;
|
||||
if (gm_prndl2_state.next_tx_us == 0U) {
|
||||
gm_prndl2_state.next_tx_us = now_us + GM_PADDLE_TX_OFFSET_US;
|
||||
}
|
||||
}
|
||||
bool scheduler_ready = gm_periodic_scheduler_ready(&gm_prndl2_state, now_us, GM_PADDLE_STALE_US, GM_PADDLE_FEED_STALE_US);
|
||||
if (scheduler_ready) {
|
||||
tx = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx;
|
||||
}
|
||||
@@ -317,26 +546,32 @@ static int gm_fwd_hook(int bus_num, int addr) {
|
||||
if (bus_num == 0) {
|
||||
// block PSCMStatus; forwarded through openpilot to hide an alert from the camera
|
||||
bool is_pscm_msg = (addr == 0x184);
|
||||
if (!is_pscm_msg) {
|
||||
// For non-ACC camera/SDGM paths, keep stock ECMCruiseControl off camera side
|
||||
// so openpilot's spoofed 0x3D1 is the only cruise-status source there.
|
||||
bool is_ecm_cruise_status_msg = (addr == 0x3D1) && !gm_has_acc;
|
||||
if (!is_pscm_msg && !is_ecm_cruise_status_msg) {
|
||||
bus_fwd = 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (bus_num == 2) {
|
||||
bool is_lkas_msg = (addr == 0x180);
|
||||
bool block_msg = false;
|
||||
if (gm_bolt_2022_pedal) {
|
||||
// Block 0x370 only for experimental long without pedal interceptor
|
||||
bool is_acc_msg = (addr == 0x315) || (addr == 0x2CB);
|
||||
if (gm_cam_long && !enable_gas_interceptor) {
|
||||
is_acc_msg = is_acc_msg || (addr == 0x370);
|
||||
}
|
||||
block_msg = is_lkas_msg || (is_acc_msg && gm_cam_long);
|
||||
} else {
|
||||
// block lkas message and acc messages if gm_cam_long, forward all others
|
||||
bool is_acc_msg = (addr == 0x315) || (addr == 0x2CB) || (addr == 0x370);
|
||||
block_msg = is_lkas_msg || (is_acc_msg && gm_cam_long);
|
||||
bool is_acc_status_msg = (addr == 0x370);
|
||||
bool is_acc_actuation_msg = (addr == 0x315) || (addr == 0x2CB);
|
||||
|
||||
// Block steering if we are controlling LKA
|
||||
bool block_msg = is_lkas_msg;
|
||||
|
||||
// Block Dashboard Status if we are in Native Long OR Pedal Long
|
||||
if (gm_cam_long || gm_pedal_long) {
|
||||
block_msg |= is_acc_status_msg;
|
||||
}
|
||||
|
||||
// Block Native Actuation ONLY if we are in Native Long (not Pedal)
|
||||
if (gm_cam_long) {
|
||||
block_msg |= is_acc_actuation_msg;
|
||||
}
|
||||
|
||||
if (!block_msg) {
|
||||
bus_fwd = 0;
|
||||
}
|
||||
@@ -376,6 +611,33 @@ static safety_config gm_init(uint16_t param) {
|
||||
gm_has_acc = !GET_FLAG(param, GM_PARAM_NO_ACC);
|
||||
gm_force_brake_c9 = GET_FLAG(param, GM_PARAM_FORCE_BRAKE_C9);
|
||||
gm_remote_start_boots_comma = GET_FLAG(param, GM_PARAM_REMOTE_START_BOOTS_COMMA);
|
||||
gm_panda_3d1_sched = GET_FLAG(param, GM_PARAM_PANDA_3D1_SCHED) && gm_pedal_long && !gm_has_acc && !gm_bolt_2022_pedal;
|
||||
gm_panda_paddle_sched = GET_FLAG(param, GM_PARAM_PANDA_PADDLE_SCHED) && gm_pedal_long && enable_gas_interceptor;
|
||||
bool gm_ascm_int_stock_cam = gm_ascm_int && (gm_hw == GM_CAM) && gm_pcm_cruise && !gm_cam_long && !gm_pedal_long && !gm_cc_long;
|
||||
|
||||
gm_3d1_spoof_valid = false;
|
||||
gm_3d1_internal_tx = false;
|
||||
gm_3d1_next_tx_us = 0U;
|
||||
gm_3d1_expected_stock_us = 0U;
|
||||
gm_3d1_last_stock_us = 0U;
|
||||
gm_3d1_phase_locked = false;
|
||||
gm_paddle_internal_tx = false;
|
||||
|
||||
gm_bd_state.spoof_valid = false;
|
||||
gm_bd_state.next_tx_us = 0U;
|
||||
gm_bd_state.expected_stock_us = 0U;
|
||||
gm_bd_state.last_stock_us = 0U;
|
||||
gm_bd_state.last_feed_us = 0U;
|
||||
gm_bd_state.phase_locked = false;
|
||||
(void)memset(gm_bd_state.spoof_data, 0, sizeof(gm_bd_state.spoof_data));
|
||||
|
||||
gm_prndl2_state.spoof_valid = false;
|
||||
gm_prndl2_state.next_tx_us = 0U;
|
||||
gm_prndl2_state.expected_stock_us = 0U;
|
||||
gm_prndl2_state.last_stock_us = 0U;
|
||||
gm_prndl2_state.last_feed_us = 0U;
|
||||
gm_prndl2_state.phase_locked = false;
|
||||
(void)memset(gm_prndl2_state.spoof_data, 0, sizeof(gm_prndl2_state.spoof_data));
|
||||
|
||||
safety_config ret = BUILD_SAFETY_CFG(gm_rx_checks, GM_ASCM_TX_MSGS);
|
||||
if (gm_hw == GM_CAM) {
|
||||
@@ -387,6 +649,9 @@ static safety_config gm_init(uint16_t param) {
|
||||
ret = BUILD_SAFETY_CFG(gm_rx_checks, GM_CAM_TX_MSGS);
|
||||
}
|
||||
}
|
||||
if (gm_ascm_int_stock_cam) {
|
||||
SET_RX_CHECKS(gm_ascm_int_stock_cam_rx_checks, ret);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
@@ -275,3 +275,6 @@ const uint32_t RELAY_TRNS_TIMEOUT = 1U;
|
||||
|
||||
// Always on Lateral
|
||||
#define ALT_EXP_ALWAYS_ON_LATERAL 32
|
||||
|
||||
// GM pedal-long option: interpret wheel CANCEL as an auxiliary distance-style button.
|
||||
#define ALT_EXP_GM_REMAP_CANCEL_TO_DISTANCE 64
|
||||
|
||||
@@ -112,6 +112,7 @@ class ALTERNATIVE_EXPERIENCE:
|
||||
RAISE_LONGITUDINAL_LIMITS_TO_ISO_MAX = 8
|
||||
ALLOW_AEB = 16
|
||||
ALWAYS_ON_LATERAL = 32
|
||||
GM_REMAP_CANCEL_TO_DISTANCE = 64
|
||||
|
||||
class Panda:
|
||||
|
||||
@@ -243,6 +244,8 @@ class Panda:
|
||||
FLAG_GM_BOLT_2017 = 2048
|
||||
FLAG_GM_BOLT_2022_PEDAL = 4096
|
||||
FLAG_GM_REMOTE_START_BOOTS_COMMA = 8192
|
||||
FLAG_GM_PANDA_3D1_SCHED = 16384
|
||||
FLAG_GM_PANDA_PADDLE_SCHED = 32768
|
||||
|
||||
FLAG_FORD_LONG_CONTROL = 1
|
||||
FLAG_FORD_CANFD = 2
|
||||
|
||||
@@ -70,8 +70,9 @@ interface_names = _get_interface_names()
|
||||
interfaces = load_interfaces(interface_names)
|
||||
|
||||
|
||||
def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict]]:
|
||||
def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict], dict[int, set[int]]]:
|
||||
finger = gen_empty_fingerprint()
|
||||
nonzero_addrs = {bus: set() for bus in finger}
|
||||
candidate_cars = {i: all_legacy_fingerprint_cars() for i in [0, 1]} # attempt fingerprint on both bus 0 and 1
|
||||
frame = 0
|
||||
car_fingerprint = None
|
||||
@@ -86,7 +87,10 @@ def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict]]:
|
||||
if can.src < 128:
|
||||
if can.src not in finger:
|
||||
finger[can.src] = {}
|
||||
nonzero_addrs[can.src] = set()
|
||||
finger[can.src][can.address] = len(can.dat)
|
||||
if any(can.dat):
|
||||
nonzero_addrs[can.src].add(can.address)
|
||||
|
||||
for b in candidate_cars:
|
||||
# Ignore extended messages and VIN query response.
|
||||
@@ -107,7 +111,7 @@ def can_fingerprint(next_can: Callable) -> tuple[str | None, dict[int, dict]]:
|
||||
|
||||
frame += 1
|
||||
|
||||
return car_fingerprint, finger
|
||||
return car_fingerprint, finger, nonzero_addrs
|
||||
|
||||
|
||||
# **** for use live only ****
|
||||
@@ -164,7 +168,7 @@ def fingerprint(logcan, sendcan, num_pandas):
|
||||
# CAN fingerprint
|
||||
# drain CAN socket so we get the latest messages
|
||||
messaging.drain_sock_raw(logcan)
|
||||
car_fingerprint, finger = can_fingerprint(lambda: get_one_can(logcan))
|
||||
car_fingerprint, finger, nonzero_addrs = can_fingerprint(lambda: get_one_can(logcan))
|
||||
|
||||
exact_match = True
|
||||
source = car.CarParams.FingerprintSource.can
|
||||
@@ -183,7 +187,7 @@ def fingerprint(logcan, sendcan, num_pandas):
|
||||
fw_count=len(car_fw), ecu_responses=list(ecu_rx_addrs), vin_rx_addr=vin_rx_addr, vin_rx_bus=vin_rx_bus,
|
||||
fingerprints=repr(finger), fw_query_time=fw_query_time, error=True)
|
||||
|
||||
return car_fingerprint, finger, vin, car_fw, source, exact_match
|
||||
return car_fingerprint, finger, nonzero_addrs, vin, car_fw, source, exact_match
|
||||
|
||||
|
||||
def get_car_interface(CP, FPCP):
|
||||
@@ -250,7 +254,7 @@ def migrate_legacy_bolt_candidate(candidate: str) -> str:
|
||||
|
||||
|
||||
def get_car(logcan, sendcan, experimental_long_allowed, params, num_pandas=1, frogpilot_toggles=None):
|
||||
candidate, fingerprints, vin, car_fw, source, exact_match = fingerprint(logcan, sendcan, num_pandas)
|
||||
candidate, fingerprints, nonzero_addrs, vin, car_fw, source, exact_match = fingerprint(logcan, sendcan, num_pandas)
|
||||
|
||||
if candidate is None or frogpilot_toggles.force_fingerprint:
|
||||
if frogpilot_toggles.car_model is not None:
|
||||
@@ -299,12 +303,12 @@ def get_car(logcan, sendcan, experimental_long_allowed, params, num_pandas=1, fr
|
||||
if year_code in year_map:
|
||||
vin_candidate = year_map[year_code]
|
||||
if vin_candidate == GM_CAR.CHEVROLET_BOLT_ACC_2022_2023:
|
||||
has_acc_msg = (
|
||||
0x370 in fingerprints.get(GMCanBus.CAMERA, {}) or
|
||||
0x370 in fingerprints.get(GMCanBus.POWERTRAIN, {})
|
||||
has_acc_data = (
|
||||
0x370 in nonzero_addrs.get(GMCanBus.CAMERA, set()) or
|
||||
0x370 in nonzero_addrs.get(GMCanBus.POWERTRAIN, set())
|
||||
)
|
||||
has_pedal_msg = 0x201 in fingerprints.get(GMCanBus.POWERTRAIN, {})
|
||||
if has_acc_msg:
|
||||
if has_acc_data:
|
||||
vin_candidate = GM_CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL if has_pedal_msg else GM_CAR.CHEVROLET_BOLT_ACC_2022_2023
|
||||
else:
|
||||
vin_candidate = GM_CAR.CHEVROLET_BOLT_CC_2022_2023
|
||||
|
||||
@@ -56,8 +56,9 @@ class Car:
|
||||
|
||||
# set alternative experiences from parameters
|
||||
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
|
||||
self.CP.alternativeExperience = 0
|
||||
if not self.disengage_on_accelerator:
|
||||
if self.disengage_on_accelerator:
|
||||
self.CP.alternativeExperience &= ~ALTERNATIVE_EXPERIENCE.DISABLE_DISENGAGE_ON_GAS
|
||||
else:
|
||||
self.CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.DISABLE_DISENGAGE_ON_GAS
|
||||
|
||||
openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle")
|
||||
|
||||
@@ -15,7 +15,7 @@ from openpilot.selfdrive.car.gm.values import CAR, DBC, AccState, CanBus, CarCon
|
||||
from openpilot.selfdrive.car.interfaces import CarControllerBase
|
||||
from openpilot.selfdrive.controls.lib.drive_helpers import apply_deadzone
|
||||
from openpilot.selfdrive.controls.lib.vehicle_model import ACCELERATION_DUE_TO_GRAVITY
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.frogpilot.common.testing_grounds import testing_ground
|
||||
|
||||
VisualAlert = car.CarControl.HUDControl.VisualAlert
|
||||
NetworkLocation = car.CarParams.NetworkLocation
|
||||
@@ -27,15 +27,6 @@ TransmissionType = car.CarParams.TransmissionType
|
||||
CAMERA_CANCEL_DELAY_FRAMES = 10
|
||||
# Enforce a minimum interval between steering messages to avoid a fault
|
||||
MIN_STEER_MSG_INTERVAL_MS = 15
|
||||
# Two‑sided spacing tuned for ~33 Hz steer; target a 10 ms wide window per interval
|
||||
# Paddle spoofing and scheduling constants
|
||||
PADDLE_STEER_GAP_MIN_NS = 5_000_000 # ≥5 ms each side (EPS guard)
|
||||
PADDLE_STEER_GAP_MAX_NS = 12_000_000 # cap for long intervals
|
||||
PADDLE_GAP_TARGET_NS = 5_000_000 # aim per‑side gap even if interval//2 − early is larger
|
||||
PADDLE_NONBLOCK_GAP_NS = 1_000_000 # ≥1 ms since last paddle send
|
||||
PADDLE_SLOT_EARLY_NS = 1_000_000 # allow firing up to 1 ms before slot
|
||||
OVERFLOW_THRESH = 1.00 # fire one extra slot whenever credits ≥ 1.0
|
||||
PADDLE_TARGET_HZ = 42.0 # desired paddle rate (Hz) when regen active; steer is ~33 Hz
|
||||
# Constants for pitch compensation
|
||||
BRAKE_PITCH_FACTOR_BP = [5., 10.] # [m/s] smoothly revert to planned accel at low speeds
|
||||
BRAKE_PITCH_FACTOR_V = [0., 1.] # [unitless in [0,1]]; don't touch
|
||||
@@ -52,10 +43,6 @@ class CarController(CarControllerBase):
|
||||
self.frame = 0
|
||||
self.last_steer_frame = 0
|
||||
self.last_steer_ts_ns = 0
|
||||
self.last_regen_active = False
|
||||
self.prev_steer_ts_ns = 0
|
||||
self.last_spoof_ts_ns = 0
|
||||
self.last_paddle_ts_ns = 0
|
||||
self.last_button_frame = 0
|
||||
self.cancel_counter = 0
|
||||
self.pedal_steady = 0.
|
||||
@@ -79,7 +66,9 @@ class CarController(CarControllerBase):
|
||||
self.malibu_cancel_phase = 0
|
||||
self.malibu_cancel_last_ts = 0.0
|
||||
self.malibu_cancel_frame = 0
|
||||
self.malibu_cancel_frame_offset = 0
|
||||
self.malibu_button_phase = 0
|
||||
self.malibu_last_button_ts_nanos = 0
|
||||
|
||||
self.packer_pt = CANPacker(DBC[self.CP.carFingerprint]['pt'])
|
||||
self.packer_obj = CANPacker(DBC[self.CP.carFingerprint]['radar'])
|
||||
@@ -92,60 +81,163 @@ class CarController(CarControllerBase):
|
||||
self.regen_paddle_pressed = False
|
||||
self.aego = 0.0
|
||||
self.regen_paddle_timer = 0
|
||||
self.regen_press_counter = 0
|
||||
self.regen_release_counter = 0
|
||||
self.regen_min_on_frames = 0
|
||||
self.regen_min_off_frames = 0
|
||||
self.planner_regen_hold = False
|
||||
|
||||
|
||||
|
||||
# Midpoint + overflow spoof accumulator and flags
|
||||
self.spoof_accum = 0.0
|
||||
self.spoof_mid_sent = False
|
||||
self.spoof_over_sent = False
|
||||
self.last_interval_ns = 0
|
||||
self.paddle_handoff_frames = 0
|
||||
self.pedal_active_last = False
|
||||
self.maneuver_paddle_mode = "auto"
|
||||
|
||||
def calc_pedal_command(self, accel: float, long_active: bool, car_velocity) -> Tuple[float, bool]:
|
||||
if not long_active:
|
||||
self.planner_regen_hold = False
|
||||
self.regen_paddle_pressed = False
|
||||
self.regen_paddle_timer = 0
|
||||
self.regen_press_counter = 0
|
||||
self.regen_release_counter = 0
|
||||
self.regen_min_on_frames = 0
|
||||
self.regen_min_off_frames = 0
|
||||
self.pedal_active_last = False
|
||||
self.pedal_steady = 0.0
|
||||
return 0., False
|
||||
|
||||
# Regen paddle hysteresis (frame-based): hold 10 frames, with decrement dead-zone
|
||||
if not hasattr(self, 'regen_paddle_timer'):
|
||||
self.regen_paddle_timer = 0 # frames
|
||||
supports_regen_paddle = self.CP.carFingerprint in CC_REGEN_PADDLE_CAR
|
||||
switched_state = False
|
||||
press_regen_paddle = False
|
||||
if supports_regen_paddle:
|
||||
# Regen paddle state machine: speed-aware thresholds + min on/off duration for robust paddle transitions.
|
||||
press_cmd_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.90, -0.82, -0.72, -0.65])
|
||||
release_cmd_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.10, -0.17, -0.24, -0.30])
|
||||
press_aego_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.95, -0.86, -0.76, -0.70])
|
||||
release_aego_threshold = interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [-0.16, -0.23, -0.30, -0.36])
|
||||
|
||||
# Regen paddle hysteresis (frame‑based): count frames when decelerating hard, decrement only when truly released
|
||||
if self.aego < -0.7:
|
||||
self.regen_paddle_timer += 1
|
||||
elif self.aego > -0.3:
|
||||
self.regen_paddle_timer = max(self.regen_paddle_timer - 1, 0)
|
||||
# else: hold timer between -0.7 and -0.3
|
||||
press_confirm_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [8.0, 6.0, 5.0, 4.0])))
|
||||
release_confirm_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [18.0, 15.0, 12.0, 10.0])))
|
||||
min_on_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [34.0, 27.0, 20.0, 16.0])))
|
||||
min_off_frames = int(round(interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [16.0, 14.0, 12.0, 10.0])))
|
||||
# Extra hysteresis in the 8-20 mph band to reduce paddle edge pulsing.
|
||||
confirm_boost = int(round(interp(car_velocity, [0.0, 6.0, 8.0, 20.0, 25.0], [0.0, 0.0, 3.0, 3.0, 0.0])))
|
||||
press_confirm_frames += confirm_boost
|
||||
release_confirm_frames += confirm_boost
|
||||
|
||||
# Base paddle press hysteresis
|
||||
self.regen_paddle_pressed = self.regen_paddle_timer >= 10 # 10 frames
|
||||
press_regen_paddle = self.regen_paddle_pressed or self.planner_regen_hold
|
||||
want_press = self.planner_regen_hold or accel <= press_cmd_threshold or self.aego <= press_aego_threshold
|
||||
want_release = (not self.planner_regen_hold) and accel >= release_cmd_threshold and self.aego >= release_aego_threshold
|
||||
|
||||
if want_press:
|
||||
self.regen_press_counter += 1
|
||||
else:
|
||||
self.regen_press_counter = max(self.regen_press_counter - 1, 0)
|
||||
|
||||
if want_release:
|
||||
self.regen_release_counter += 1
|
||||
else:
|
||||
self.regen_release_counter = max(self.regen_release_counter - 1, 0)
|
||||
|
||||
# Strong planner request can skip most of debounce delay.
|
||||
if self.planner_regen_hold and accel <= (press_cmd_threshold - 0.30):
|
||||
self.regen_press_counter = max(self.regen_press_counter, press_confirm_frames)
|
||||
|
||||
if self.regen_min_on_frames > 0:
|
||||
self.regen_min_on_frames -= 1
|
||||
if self.regen_min_off_frames > 0:
|
||||
self.regen_min_off_frames -= 1
|
||||
|
||||
if self.regen_paddle_pressed:
|
||||
if self.regen_min_on_frames == 0 and self.regen_release_counter >= release_confirm_frames:
|
||||
self.regen_paddle_pressed = False
|
||||
self.regen_min_off_frames = min_off_frames
|
||||
self.regen_release_counter = 0
|
||||
switched_state = True
|
||||
else:
|
||||
if self.regen_min_off_frames == 0 and self.regen_press_counter >= press_confirm_frames:
|
||||
self.regen_paddle_pressed = True
|
||||
self.regen_min_on_frames = min_on_frames
|
||||
self.regen_press_counter = 0
|
||||
switched_state = True
|
||||
|
||||
self.regen_paddle_timer = self.regen_press_counter
|
||||
press_regen_paddle = self.regen_paddle_pressed
|
||||
|
||||
if self.maneuver_paddle_mode == "off":
|
||||
self.regen_paddle_pressed = False
|
||||
self.regen_press_counter = 0
|
||||
self.regen_release_counter = 0
|
||||
self.regen_min_on_frames = 0
|
||||
press_regen_paddle = False
|
||||
elif self.maneuver_paddle_mode == "force":
|
||||
forced_press = accel < -0.02
|
||||
self.regen_paddle_pressed = forced_press
|
||||
press_regen_paddle = forced_press
|
||||
else:
|
||||
self.planner_regen_hold = False
|
||||
self.regen_paddle_pressed = False
|
||||
self.regen_paddle_timer = 0
|
||||
self.regen_press_counter = 0
|
||||
self.regen_release_counter = 0
|
||||
self.regen_min_on_frames = 0
|
||||
self.regen_min_off_frames = 0
|
||||
|
||||
# Regen gain ratios from bin-averaged 60–0 deceleration sweep; Calculates stronger decel from paddle
|
||||
speed_mps = [0.559, 1.678, 2.797, 3.916, 5.035, 6.154, 7.273, 8.392, 9.511, 10.63,
|
||||
11.749, 12.868, 13.987, 15.106, 16.225, 17.344, 18.463, 19.582, 20.701, 21.820,
|
||||
22.939, 24.058, 25.177, 26.296]
|
||||
regen_gain_ratio = [
|
||||
1.000000, 1.057308, 1.131123, 1.220611, 1.270247, 1.300253, 1.339543, 1.361002,
|
||||
1.388410, 1.403253, 1.414721, 1.430949, 1.420289, 1.436787, 1.434116, 1.436805,
|
||||
1.417508, 1.402213, 1.395360, 1.360921, 1.342030, 1.292219, 1.270048, 1.239172
|
||||
]
|
||||
regen_gain_ratio = [1.01, 1.01, 1.02, 1.05, 1.08, 1.31, 1.33,
|
||||
1.34, 1.35, 1.36, 1.37, 1.38, 1.39, 1.39,
|
||||
1.40, 1.40, 1.41, 1.42, 1.43, 1.43, 1.44,
|
||||
1.44, 1.45, 1.45]
|
||||
|
||||
gain = interp(car_velocity, speed_mps, regen_gain_ratio)
|
||||
pedaloffset = interp(car_velocity, [0., 3, 6, 30], [0.10, 0.175, 0.240, 0.240])
|
||||
# Terminal-stop authority boost for paddle+pedal mode: lower gain at low speed => stronger net decel.
|
||||
gain *= interp(car_velocity, [0.0, 2.0, 4.0, 5.5, 8.0, 12.0], [0.92, 0.92, 0.93, 0.94, 0.96, 1.0])
|
||||
accel_gain = interp(car_velocity, [0.0, 3.0, 8.0, 20.0], [0.47, 0.52, 0.57, 0.61])
|
||||
pedaloffset = interp(car_velocity, [0.0, 1.0, 3.0, 6.0, 15.0, 30.0], [0.085, 0.11, 0.17, 0.23, 0.235, 0.23])
|
||||
|
||||
# Compute raw pedal gas
|
||||
raw_pedal_gas = clip((pedaloffset + (accel / gain) * 0.6), 0.0, 1.0) if press_regen_paddle else clip((pedaloffset + accel * 0.6), 0.0, 1.0)
|
||||
accel_term_scale = (1.0 / max(gain, 1e-3)) if press_regen_paddle else 1.0
|
||||
# De-sensitize small commands asymmetrically: keep decel smoother while preserving accel pickup.
|
||||
if accel >= 0.0:
|
||||
small_cmd_scale = interp(abs(accel), [0.0, 0.35, 0.8, 1.5, 2.5], [0.58, 0.68, 0.82, 0.93, 1.0])
|
||||
else:
|
||||
small_cmd_scale = interp(abs(accel), [0.0, 0.35, 0.8, 1.5, 2.5], [0.44, 0.54, 0.70, 0.89, 1.0])
|
||||
accel_cmd = accel * small_cmd_scale
|
||||
if (not press_regen_paddle) and accel < -2.0:
|
||||
accel_cmd *= interp(abs(accel), [2.0, 2.5, 3.0], [1.0, 1.03, 1.06])
|
||||
raw_pedal_gas = clip(pedaloffset + accel_cmd * accel_gain * accel_term_scale, 0.0, 1.0)
|
||||
|
||||
# --- Immediate application of raw pedal gas, no blending ---
|
||||
pedal_gas = raw_pedal_gas
|
||||
# Safety cap: ramp from 22% at 0 m/s to 37.25% at 10 mph (4.47 m/s), then allow full throttle
|
||||
pedal_gas_max = interp(car_velocity, [0.0, 4.47, 4.48], [0.22, 0.3725, 1.0])
|
||||
pedal_gas = clip(pedal_gas, 0.0, pedal_gas_max)
|
||||
# Safety cap with continuous low-speed transition (removes the near-step around ~4.5 m/s).
|
||||
pedal_gas_max = interp(car_velocity, [0.0, 1.0, 2.5, 4.5, 6.0, 8.0, 12.0], [0.20, 0.235, 0.29, 0.365, 0.52, 0.78, 1.0])
|
||||
target_pedal_gas = clip(raw_pedal_gas, 0.0, pedal_gas_max)
|
||||
|
||||
# Blend and rate-limit command changes for smoother transients while preserving clip bounds.
|
||||
if not self.pedal_active_last or switched_state:
|
||||
pedal_gas = target_pedal_gas
|
||||
self.pedal_active_last = True
|
||||
else:
|
||||
urgency = clip(abs(accel) / 2.0, 0.0, 1.0)
|
||||
rate_up = interp(car_velocity, [0.0, 3.0, 8.0, 20.0], [0.007, 0.012, 0.022, 0.036]) + 0.011 * urgency
|
||||
if accel > 1.2:
|
||||
# Recover high-command launch response without changing low-command smoothness.
|
||||
rate_up += interp(car_velocity, [0.0, 4.0, 12.0, 25.0], [0.006, 0.005, 0.003, 0.002])
|
||||
rate_down = interp(car_velocity, [0.0, 3.0, 8.0, 20.0], [0.008, 0.014, 0.026, 0.045]) + 0.015 * urgency
|
||||
pedal_gas = clip(target_pedal_gas, self.pedal_steady - rate_down, self.pedal_steady + rate_up)
|
||||
|
||||
self.pedal_steady = pedal_gas
|
||||
return pedal_gas, press_regen_paddle
|
||||
|
||||
def _update_malibu_button_slot(self, cs):
|
||||
button_ts = cs.steering_button_ts_nanos
|
||||
if button_ts != 0 and button_ts != self.malibu_last_button_ts_nanos:
|
||||
self.malibu_last_button_ts_nanos = button_ts
|
||||
return True
|
||||
return False
|
||||
|
||||
def _sync_malibu_phase_from_oem(self, cs):
|
||||
phase_map = gmcan.malibu_phase_map_for_acc(cs.cruise_buttons)
|
||||
if phase_map and cs.steering_button_checksum in phase_map:
|
||||
phase = phase_map[cs.steering_button_checksum]
|
||||
self.malibu_cancel_phase = phase
|
||||
self.malibu_button_phase = phase
|
||||
|
||||
def update(self, CC, CS, now_nanos, frogpilot_toggles):
|
||||
self.CS = CS
|
||||
@@ -153,6 +245,12 @@ class CarController(CarControllerBase):
|
||||
actuators = CC.actuators
|
||||
accel = brake_accel = actuators.accel
|
||||
press_regen_paddle = False
|
||||
|
||||
# Longitudinal maneuvers can force paddle behavior for A/B testing.
|
||||
if self.frame % 25 == 0:
|
||||
mode = self.params_.get("LongitudinalManeuverPaddleMode", encoding="utf-8")
|
||||
mode = (mode or "auto").strip().lower()
|
||||
self.maneuver_paddle_mode = mode if mode in ("auto", "off", "force") else "auto"
|
||||
kaofui_cars = SDGM_CAR | ASCM_INT | {
|
||||
CAR.CHEVROLET_VOLT,
|
||||
CAR.CHEVROLET_VOLT_2019,
|
||||
@@ -173,14 +271,18 @@ class CarController(CarControllerBase):
|
||||
# Planner-driven regen hold: gate by car support and OP long active, use commanded accel thresholds
|
||||
if (self.CP.enableGasInterceptor and self.CP.carFingerprint in CC_REGEN_PADDLE_CAR
|
||||
and self.CP.openpilotLongitudinalControl and CC.longActive):
|
||||
# Match original hysteresis intent: vehicle can usually stop without paddle up to ~1.0 m/s^2
|
||||
# Use the same thresholds as the aEgo-based hysteresis, but on commanded accel for preemption
|
||||
planner_press_threshold = -0.7
|
||||
planner_release_threshold = -0.3
|
||||
if accel <= planner_press_threshold:
|
||||
self.planner_regen_hold = True
|
||||
elif accel >= planner_release_threshold:
|
||||
if self.maneuver_paddle_mode == "off":
|
||||
self.planner_regen_hold = False
|
||||
elif self.maneuver_paddle_mode == "force":
|
||||
self.planner_regen_hold = accel < -0.02
|
||||
else:
|
||||
# Pre-arm paddle on strong commanded decel with speed-aware hysteresis.
|
||||
planner_press_threshold = interp(CS.out.vEgo, [0.0, 4.0, 12.0, 25.0], [-0.95, -0.82, -0.70, -0.62])
|
||||
planner_release_threshold = interp(CS.out.vEgo, [0.0, 4.0, 12.0, 25.0], [-0.14, -0.22, -0.30, -0.36])
|
||||
if accel <= planner_press_threshold:
|
||||
self.planner_regen_hold = True
|
||||
elif accel >= planner_release_threshold:
|
||||
self.planner_regen_hold = False
|
||||
else:
|
||||
self.planner_regen_hold = False
|
||||
|
||||
@@ -192,129 +294,38 @@ class CarController(CarControllerBase):
|
||||
|
||||
# Send CAN commands.
|
||||
can_sends = []
|
||||
paddle_sends = []
|
||||
|
||||
malibu_oem_button_slot = False
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||
phase_map = gmcan.malibu_phase_map_for_acc(CS.cruise_buttons)
|
||||
if phase_map and CS.steering_button_checksum in phase_map:
|
||||
phase = (phase_map[CS.steering_button_checksum] + 1) % 4
|
||||
self.malibu_cancel_phase = phase
|
||||
self.malibu_button_phase = phase
|
||||
malibu_oem_button_slot = self._update_malibu_button_slot(CS)
|
||||
if malibu_oem_button_slot:
|
||||
self._sync_malibu_phase_from_oem(CS)
|
||||
|
||||
raw_regen_active = (
|
||||
self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
|
||||
self.CP.openpilotLongitudinalControl and
|
||||
CC.longActive and
|
||||
self.CP.enableGasInterceptor and
|
||||
(self.regen_paddle_timer >= 10 or self.planner_regen_hold) # hysteresis or planner hint
|
||||
self.regen_paddle_pressed
|
||||
)
|
||||
regen_active = raw_regen_active
|
||||
use_panda_paddle_sched = (
|
||||
self.CP.enableGasInterceptor and
|
||||
self.CP.carFingerprint in CC_REGEN_PADDLE_CAR and
|
||||
self.CP.openpilotLongitudinalControl and
|
||||
bool(self.CP.flags & GMFlags.PEDAL_LONG.value)
|
||||
)
|
||||
if use_panda_paddle_sched:
|
||||
if CC.enabled:
|
||||
self.paddle_handoff_frames = 2
|
||||
paddle_sched_feed_active = CC.enabled or (self.paddle_handoff_frames > 0)
|
||||
if not CC.enabled and self.paddle_handoff_frames > 0:
|
||||
self.paddle_handoff_frames -= 1
|
||||
else:
|
||||
self.paddle_handoff_frames = 0
|
||||
paddle_sched_feed_active = False
|
||||
|
||||
# === Spoof scheduling: midpoint + overflow (~target Hz) ===
|
||||
# Rising-edge reset on regen start
|
||||
if raw_regen_active and not self.last_regen_active:
|
||||
self.prev_steer_ts_ns = self.last_steer_ts_ns
|
||||
self.last_spoof_ts_ns = 0
|
||||
self.spoof_accum = 0.0
|
||||
self.spoof_mid_sent = False
|
||||
self.spoof_over_sent = False
|
||||
|
||||
if raw_regen_active:
|
||||
# Interval between last two bus-0 steer sends
|
||||
interval_ns = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
||||
|
||||
# Adaptive two‑sided gap sized to the current steer interval, but capped to a target so the window stays wide enough
|
||||
gap_ns = (PADDLE_STEER_GAP_MIN_NS if interval_ns <= 0 else
|
||||
max(PADDLE_STEER_GAP_MIN_NS,
|
||||
min(PADDLE_STEER_GAP_MAX_NS,
|
||||
min((interval_ns // 2) - PADDLE_SLOT_EARLY_NS, PADDLE_GAP_TARGET_NS))))
|
||||
|
||||
# New steer interval? clear per-interval flags and add credits to reach target Hz
|
||||
if interval_ns != self.last_interval_ns:
|
||||
self.spoof_mid_sent = False
|
||||
self.spoof_over_sent = False
|
||||
self.last_interval_ns = interval_ns
|
||||
# Add credits once per new steer interval to reach the desired paddle rate
|
||||
if interval_ns > 0:
|
||||
steer_hz = 1e9 / float(interval_ns)
|
||||
extra_needed = max(0.0, (PADDLE_TARGET_HZ / steer_hz) - 1.0) # e.g., 42/33 − 1 ≈ 0.2727
|
||||
self.spoof_accum += extra_needed
|
||||
|
||||
# Midpoint spoof: one per interval
|
||||
if not self.spoof_mid_sent and interval_ns > 0:
|
||||
midpoint_ns = self.prev_steer_ts_ns + interval_ns // 2
|
||||
# Compute spacing to last and next steer (two-sided guard)
|
||||
next_steer_ts_ns = self.last_steer_ts_ns + interval_ns if interval_ns > 0 else 0
|
||||
delta_after_ns = now_nanos - self.last_steer_ts_ns
|
||||
delta_before_ns = (next_steer_ts_ns - now_nanos) if interval_ns > 0 else 1_000_000_000
|
||||
if (CS.out.vEgo > 2.68
|
||||
and now_nanos >= (midpoint_ns - PADDLE_SLOT_EARLY_NS)
|
||||
and delta_after_ns >= gap_ns
|
||||
and delta_before_ns >= gap_ns):
|
||||
# Non-blocking 1 ms spacing for paddle frames
|
||||
if now_nanos - self.last_paddle_ts_ns >= PADDLE_NONBLOCK_GAP_NS:
|
||||
paddle_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, True, self.CP))
|
||||
paddle_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, True))
|
||||
self.last_paddle_ts_ns = now_nanos
|
||||
self.last_spoof_ts_ns = now_nanos
|
||||
self.spoof_mid_sent = True
|
||||
|
||||
# Overflow spoof: insert extra when accumulator allows
|
||||
if self.spoof_accum >= OVERFLOW_THRESH and not self.spoof_over_sent and interval_ns > 0:
|
||||
slot2_ns = self.prev_steer_ts_ns + (interval_ns * 2) // 3
|
||||
# Two-sided spacing relative to steer
|
||||
next_steer_ts_ns = self.last_steer_ts_ns + interval_ns if interval_ns > 0 else 0
|
||||
delta_after_ns = now_nanos - self.last_steer_ts_ns
|
||||
delta_before_ns = (next_steer_ts_ns - now_nanos) if interval_ns > 0 else 1_000_000_000
|
||||
if (CS.out.vEgo > 2.68
|
||||
and now_nanos >= (slot2_ns - PADDLE_SLOT_EARLY_NS)
|
||||
and delta_after_ns >= gap_ns
|
||||
and delta_before_ns >= gap_ns):
|
||||
# Non-blocking 1 ms spacing for paddle frames
|
||||
if now_nanos - self.last_paddle_ts_ns >= PADDLE_NONBLOCK_GAP_NS:
|
||||
paddle_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, True, self.CP))
|
||||
paddle_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, True))
|
||||
self.last_paddle_ts_ns = now_nanos
|
||||
self.last_spoof_ts_ns = now_nanos
|
||||
self.spoof_over_sent = True
|
||||
self.spoof_accum -= OVERFLOW_THRESH
|
||||
# === End Spoof scheduling ===
|
||||
|
||||
# === Off-pulse scheduling on regen release ===
|
||||
if not raw_regen_active and self.last_regen_active:
|
||||
# schedule two off-slots at 1/3 and 2/3 of the last steer interval
|
||||
if self.prev_steer_ts_ns and self.last_steer_ts_ns:
|
||||
intv = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
||||
self.off_schedule_ns = [
|
||||
self.prev_steer_ts_ns + intv // 3,
|
||||
self.prev_steer_ts_ns + (2 * intv) // 3
|
||||
]
|
||||
self.off_sent = [False, False]
|
||||
|
||||
if hasattr(self, "off_schedule_ns"):
|
||||
for i, t_ns in enumerate(self.off_schedule_ns):
|
||||
if not self.off_sent[i] and now_nanos >= (t_ns - PADDLE_SLOT_EARLY_NS):
|
||||
# Two-sided spacing to steer before sending
|
||||
interval_ns = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
||||
gap_ns = (PADDLE_STEER_GAP_MIN_NS if interval_ns <= 0 else
|
||||
max(PADDLE_STEER_GAP_MIN_NS,
|
||||
min(PADDLE_STEER_GAP_MAX_NS,
|
||||
min((interval_ns // 2) - PADDLE_SLOT_EARLY_NS, PADDLE_GAP_TARGET_NS))))
|
||||
next_steer_ts_ns = self.last_steer_ts_ns + interval_ns if interval_ns > 0 else 0
|
||||
delta_after_ns = now_nanos - self.last_steer_ts_ns
|
||||
delta_before_ns = (next_steer_ts_ns - now_nanos) if interval_ns > 0 else 1_000_000_000
|
||||
if (delta_after_ns >= gap_ns and delta_before_ns >= gap_ns):
|
||||
# Non-blocking 1 ms spacing for paddle frames
|
||||
if now_nanos - self.last_paddle_ts_ns >= PADDLE_NONBLOCK_GAP_NS:
|
||||
paddle_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, False, self.CP))
|
||||
paddle_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, False))
|
||||
self.last_paddle_ts_ns = now_nanos
|
||||
self.off_sent[i] = True
|
||||
# clean up once both off pulses are sent
|
||||
if hasattr(self, "off_sent") and all(self.off_sent):
|
||||
del self.off_schedule_ns
|
||||
del self.off_sent
|
||||
# === End off-pulse scheduling ===
|
||||
# Preserve existing on-send gate: only send "pressed" while above low-speed threshold.
|
||||
paddle_spoof_pressed = raw_regen_active and (CS.out.vEgo > 2.68)
|
||||
|
||||
# Steering (Active: 50Hz, inactive: 10Hz)
|
||||
steer_step = self.params.STEER_STEP if CC.latActive else self.params.INACTIVE_STEER_STEP
|
||||
@@ -347,28 +358,29 @@ class CarController(CarControllerBase):
|
||||
if (self.CP.flags & GMFlags.CC_LONG.value) and CC.enabled and not CS.out.cruiseState.enabled: # Send 0 so Panda doesn't error
|
||||
apply_steer = 0
|
||||
|
||||
# shift previous steer timestamp
|
||||
self.prev_steer_ts_ns = self.last_steer_ts_ns
|
||||
self.last_steer_ts_ns = now_nanos
|
||||
self.last_steer_frame = self.frame
|
||||
self.apply_steer_last = apply_steer
|
||||
idx = self.lka_steering_cmd_counter % 4
|
||||
can_sends.append(gmcan.create_steering_control(self.packer_pt, CanBus.POWERTRAIN, apply_steer, idx, CC.latActive))
|
||||
|
||||
# Update regen_active state and last_regen_paddle_pressed for next loop
|
||||
self.last_regen_active = regen_active
|
||||
self.last_regen_paddle_pressed = self.regen_paddle_pressed or self.planner_regen_hold
|
||||
spoof_ecm_cruise_cars = {
|
||||
CAR.CHEVROLET_BOLT_CC_2017,
|
||||
CAR.CHEVROLET_BOLT_CC_2019_2021,
|
||||
CAR.CHEVROLET_BOLT_CC_2022_2023,
|
||||
CAR.CHEVROLET_MALIBU_HYBRID_CC,
|
||||
}
|
||||
non_acc_pedal_long = (self.CP.flags & GMFlags.PEDAL_LONG.value) and self.CP.carFingerprint in spoof_ecm_cruise_cars and self.CP.enableGasInterceptor
|
||||
if non_acc_pedal_long and self.frame % 4 == 0:
|
||||
spoof_enabled = True
|
||||
spoof_set_speed_kph = hud_v_cruise * CV.MS_TO_KPH
|
||||
can_sends.append(gmcan.create_ecm_cruise_control_command(
|
||||
self.packer_pt, CanBus.POWERTRAIN, spoof_enabled, spoof_set_speed_kph))
|
||||
|
||||
if paddle_sends:
|
||||
interval_ns = self.last_steer_ts_ns - self.prev_steer_ts_ns
|
||||
flush_gap_ns = (PADDLE_STEER_GAP_MIN_NS if interval_ns <= 0 else
|
||||
max(PADDLE_STEER_GAP_MIN_NS,
|
||||
min(PADDLE_STEER_GAP_MAX_NS,
|
||||
min((interval_ns // 2) - PADDLE_SLOT_EARLY_NS, PADDLE_GAP_TARGET_NS))))
|
||||
if now_nanos - self.last_steer_ts_ns >= flush_gap_ns:
|
||||
can_sends.extend(paddle_sends)
|
||||
malibu_cancel_requested = False
|
||||
|
||||
if self.CP.openpilotLongitudinalControl:
|
||||
|
||||
# Gas/regen, brakes, and UI commands - all at 25Hz
|
||||
if self.frame % 4 == 0:
|
||||
stopping = actuators.longControlState == LongCtrlState.stopping
|
||||
@@ -419,13 +431,18 @@ class CarController(CarControllerBase):
|
||||
|
||||
gas_max = self.params.MAX_GAS
|
||||
accel_max = self.params.ACCEL_MAX
|
||||
|
||||
if testing_ground.use_1:
|
||||
accel_max = min(accel_max, interp(CS.out.vEgo, [0.0, 4.0, 12.0], [1.25, 1.6, self.params.ACCEL_MAX]))
|
||||
|
||||
accel = clip(actuators.accel + accel_due_to_pitch, self.params.ACCEL_MIN, accel_max)
|
||||
torque = self.tireRadius * ((self.mass*accel) + (0.5*self.coeffDrag*self.frontalArea*self.airDensity*CS.out.vEgo**2))
|
||||
|
||||
|
||||
scaled_torque = torque + self.params.ZERO_GAS
|
||||
apply_gas_torque = clip(scaled_torque, self.params.MAX_ACC_REGEN, gas_max)
|
||||
BRAKE_SWITCH = int(round(interp(CS.out.vEgo, self.params.BRAKE_SWITCH_LOOKUP_BP, self.params.BRAKE_SWITCH_LOOKUP_V)))
|
||||
if testing_ground.use_1:
|
||||
brake_switch_bias = int(round(interp(CS.out.vEgo, [0.0, 6.0, 15.0, 30.0], [60.0, 120.0, 180.0, 220.0])))
|
||||
BRAKE_SWITCH = min(self.params.ZERO_GAS, BRAKE_SWITCH + brake_switch_bias)
|
||||
brake_accel = min((scaled_torque - BRAKE_SWITCH)/(self.tireRadius*self.mass), 0)
|
||||
self.apply_gas = int(round(apply_gas_torque))
|
||||
self.apply_brake = int(round(interp(brake_accel, self.params.BRAKE_LOOKUP_BP, self.params.BRAKE_LOOKUP_V)))
|
||||
@@ -448,6 +465,10 @@ class CarController(CarControllerBase):
|
||||
|
||||
idx = (self.frame // 4) % 4
|
||||
|
||||
if paddle_sched_feed_active:
|
||||
can_sends.append(gmcan.create_prndl2_command(self.packer_pt, CanBus.POWERTRAIN, paddle_spoof_pressed, self.CP))
|
||||
can_sends.append(gmcan.create_regen_paddle_command(self.packer_pt, CanBus.POWERTRAIN, paddle_spoof_pressed))
|
||||
|
||||
if self.CP.flags & GMFlags.CC_LONG.value:
|
||||
if CC.longActive and CS.out.cruiseState.enabled and CS.out.vEgo > self.CP.minEnableSpeed:
|
||||
# Using extend instead of append since the message is only sent intermittently
|
||||
@@ -467,7 +488,7 @@ class CarController(CarControllerBase):
|
||||
friction_brake_bus = CanBus.CHASSIS
|
||||
# GM Camera exceptions
|
||||
# TODO: can we always check the longControlState?
|
||||
if self.CP.networkLocation == NetworkLocation.fwdCamera and self.CP.carFingerprint not in CC_ONLY_CAR:
|
||||
if self.CP.networkLocation == NetworkLocation.fwdCamera:
|
||||
at_full_stop = at_full_stop and stopping
|
||||
friction_brake_bus = CanBus.POWERTRAIN
|
||||
if self.CP.carFingerprint in SDGM_CAR:
|
||||
@@ -488,10 +509,11 @@ class CarController(CarControllerBase):
|
||||
can_sends.append(gmcan.create_friction_brake_command(self.packer_ch, friction_brake_bus, self.apply_brake,
|
||||
idx, CC.enabled, near_stop, at_full_stop, self.CP))
|
||||
|
||||
# Send dashboard UI commands (ACC status)
|
||||
is_bolt_acc_pedal = self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL
|
||||
if self.CP.carFingerprint not in CC_ONLY_CAR or is_bolt_acc_pedal:
|
||||
send_fcw = hud_alert == VisualAlert.fcw
|
||||
can_sends.append(gmcan.create_acc_dashboard_command(self.packer_pt, CanBus.POWERTRAIN, CC.enabled,
|
||||
hud_v_cruise * CV.MS_TO_KPH, hud_control, send_fcw))
|
||||
can_sends.append(gmcan.create_acc_dashboard_command(
|
||||
self.packer_pt, CanBus.POWERTRAIN, CC.enabled, hud_v_cruise * CV.MS_TO_KPH, hud_control, send_fcw))
|
||||
else:
|
||||
# to keep accel steady for logs when not sending gas
|
||||
accel += self.accel_g
|
||||
@@ -501,7 +523,10 @@ class CarController(CarControllerBase):
|
||||
if not self.CP.radarUnavailable:
|
||||
send_adas = True
|
||||
if self.CP.carFingerprint in kaofui_cars:
|
||||
send_adas = (self.CP.networkLocation != NetworkLocation.fwdCamera) and (self.CP.carFingerprint not in SDGM_CAR)
|
||||
if self.CP.carFingerprint in ASCM_INT:
|
||||
send_adas = True
|
||||
else:
|
||||
send_adas = (self.CP.networkLocation != NetworkLocation.fwdCamera) and (self.CP.carFingerprint not in SDGM_CAR)
|
||||
|
||||
if send_adas:
|
||||
tt = self.frame * DT_CTRL
|
||||
@@ -532,17 +557,16 @@ class CarController(CarControllerBase):
|
||||
stock_cc_active = CS.out.cruiseState.enabled or CS.pcm_acc_status != AccState.OFF
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
|
||||
stock_cc_active = CS.out.cruiseState.enabled
|
||||
pedal_cancel = self.CP.flags & GMFlags.PEDAL_LONG.value
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||
pedal_cancel = pedal_cancel and CC.longActive
|
||||
|
||||
if (
|
||||
(self.CP.flags & GMFlags.PEDAL_LONG.value) # Always cancel stock CC when using pedal interceptor
|
||||
pedal_cancel
|
||||
or (self.CP.flags & GMFlags.CC_LONG.value and not CC.enabled) # Cancel stock CC if OP is not active
|
||||
) and stock_cc_active:
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||
# Match 33 Hz cadence (every 3 frames) and align phase to the last seen checksum.
|
||||
if self.malibu_cancel_frame % 3 == 0:
|
||||
can_sends.append(gmcan.create_buttons_malibu_cancel(
|
||||
CanBus.POWERTRAIN, self.malibu_cancel_phase, CS.steering_button_prefix))
|
||||
self.malibu_cancel_phase = (self.malibu_cancel_phase + 1) % 4
|
||||
self.malibu_cancel_frame += 1
|
||||
malibu_cancel_requested = True
|
||||
else:
|
||||
if (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
||||
self.last_button_frame = self.frame
|
||||
@@ -555,20 +579,24 @@ class CarController(CarControllerBase):
|
||||
self.cancel_counter = self.cancel_counter + 1 if CC.cruiseControl.cancel else 0
|
||||
|
||||
# Stock longitudinal, integrated at camera
|
||||
if (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||
if self.cancel_counter > CAMERA_CANCEL_DELAY_FRAMES:
|
||||
malibu_cancel_requested = True
|
||||
elif (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
|
||||
if self.cancel_counter > CAMERA_CANCEL_DELAY_FRAMES:
|
||||
self.last_button_frame = self.frame
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||
if self.malibu_cancel_frame % 3 == 0:
|
||||
can_sends.append(gmcan.create_buttons_malibu_cancel(
|
||||
CanBus.POWERTRAIN, self.malibu_cancel_phase, CS.steering_button_prefix))
|
||||
self.malibu_cancel_phase = (self.malibu_cancel_phase + 1) % 4
|
||||
self.malibu_cancel_frame += 1
|
||||
elif self.CP.carFingerprint in SDGM_CAR and self.CP.carFingerprint not in (volt_like | {CAR.CHEVROLET_BLAZER, CAR.CHEVROLET_MALIBU_SDGM, CAR.CHEVROLET_TRAVERSE}):
|
||||
if self.CP.carFingerprint in SDGM_CAR and self.CP.carFingerprint not in (volt_like | {CAR.CHEVROLET_BLAZER, CAR.CHEVROLET_MALIBU_SDGM, CAR.CHEVROLET_TRAVERSE}):
|
||||
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.POWERTRAIN, CS.buttons_counter, CruiseButtons.CANCEL))
|
||||
else:
|
||||
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.CAMERA, CS.buttons_counter, CruiseButtons.CANCEL))
|
||||
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC:
|
||||
# Malibu has no reliable rolling counter in 0x1E1; lock cancel send to each observed OEM 0x1E1 slot.
|
||||
if malibu_cancel_requested and malibu_oem_button_slot:
|
||||
can_sends.append(gmcan.create_buttons_malibu_cancel(
|
||||
CanBus.POWERTRAIN, (self.malibu_cancel_phase + 1) % 4, CS.steering_button_prefix))
|
||||
self.malibu_cancel_phase = (self.malibu_cancel_phase + 1) % 4
|
||||
|
||||
if self.CP.networkLocation == NetworkLocation.fwdCamera:
|
||||
# Silence "Take Steering" alert sent by camera, forward PSCMStatus with HandsOffSWlDetectionStatus=1
|
||||
if self.frame % 10 == 0:
|
||||
|
||||
@@ -28,12 +28,15 @@ class CarState(CarStateBase):
|
||||
self.buttons_counter = 0
|
||||
self.steering_button_checksum = 0
|
||||
self.steering_button_prefix = 0x01
|
||||
self.steering_button_ts_nanos = 0
|
||||
|
||||
self.prev_distance_button = 0
|
||||
self.distance_button = 0
|
||||
|
||||
self.single_pedal_mode = False
|
||||
self.pedal_steady = 0.
|
||||
self.ecm_cruise_control_ts_nanos = 0
|
||||
self.accelerator_pedal2_ts_nanos = 0
|
||||
|
||||
def update(self, pt_cp, cam_cp, loopback_cp, frogpilot_toggles):
|
||||
ret = car.CarState.new_message()
|
||||
@@ -54,6 +57,7 @@ class CarState(CarStateBase):
|
||||
self.distance_button = pt_cp.vl["ASCMSteeringButton"]["DistanceButton"]
|
||||
self.buttons_counter = pt_cp.vl["ASCMSteeringButton"]["RollingCounter"]
|
||||
self.steering_button_checksum = pt_cp.vl["ASCMSteeringButton"]["SteeringButtonChecksum"]
|
||||
self.steering_button_ts_nanos = pt_cp.ts_nanos["ASCMSteeringButton"]["ACCButtons"]
|
||||
acc_always_one = pt_cp.vl["ASCMSteeringButton"]["ACCAlwaysOne"]
|
||||
acc_hidden_bit = pt_cp.vl["ASCMSteeringButton"].get("ACCHiddenBit", 0)
|
||||
self.steering_button_prefix = (int(acc_always_one) & 1) | ((int(acc_hidden_bit) & 1) << 6)
|
||||
@@ -61,6 +65,7 @@ class CarState(CarStateBase):
|
||||
self.cruise_buttons = cam_cp.vl["ASCMSteeringButton"]["ACCButtons"]
|
||||
self.distance_button = cam_cp.vl["ASCMSteeringButton"]["DistanceButton"]
|
||||
self.buttons_counter = cam_cp.vl["ASCMSteeringButton"]["RollingCounter"]
|
||||
self.steering_button_ts_nanos = cam_cp.ts_nanos["ASCMSteeringButton"]["ACCButtons"]
|
||||
self.pscm_status = copy.copy(pt_cp.vl["PSCMStatus"])
|
||||
# This is to avoid a fault where you engage while still moving backwards after shifting to D.
|
||||
# An Equinox has been seen with an unsupported status (3), so only check if either wheel is in reverse (2)
|
||||
@@ -89,6 +94,7 @@ class CarState(CarStateBase):
|
||||
)
|
||||
ret.vEgoRaw = mean([ret.wheelSpeeds.fl, ret.wheelSpeeds.fr, ret.wheelSpeeds.rl, ret.wheelSpeeds.rr])
|
||||
ret.vEgo, ret.aEgo = self.update_speed_kf(ret.vEgoRaw)
|
||||
ret.vEgoCluster = ret.vEgo * frogpilot_toggles.cluster_offset
|
||||
# sample rear wheel speeds, standstill=True if ECM allows engagement with brake
|
||||
ret.standstill = ret.wheelSpeeds.rl <= STANDSTILL_THRESHOLD and ret.wheelSpeeds.rr <= STANDSTILL_THRESHOLD
|
||||
|
||||
@@ -97,7 +103,9 @@ class CarState(CarStateBase):
|
||||
else:
|
||||
ret.gearShifter = self.parse_gear_shifter(self.shifter_values.get(pt_cp.vl["ECMPRDNL2"]["PRNDL2"], None))
|
||||
|
||||
if self.CP.flags & GMFlags.NO_ACCELERATOR_POS_MSG.value:
|
||||
no_accel_pos = bool(self.CP.flags & GMFlags.NO_ACCELERATOR_POS_MSG.value)
|
||||
|
||||
if no_accel_pos:
|
||||
if self.CP.carFingerprint in kaofui_state_cars:
|
||||
ret.brake = pt_cp.vl.get("EBCMBrakePedalPosition", {}).get("BrakePedalPosition", 0) / 0xd0
|
||||
else:
|
||||
@@ -107,20 +115,17 @@ class CarState(CarStateBase):
|
||||
ret.brake = pt_cp.vl.get("ECMAcceleratorPos", {}).get("BrakePedalPos", 0)
|
||||
else:
|
||||
ret.brake = pt_cp.vl["ECMAcceleratorPos"]["BrakePedalPos"]
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_BLAZER:
|
||||
# Blazer can miss light taps on analog threshold; include digital brake switch.
|
||||
ret.brakePressed = (pt_cp.vl["ECMEngineStatus"]["BrakePressed"] != 0) or (ret.brake >= 0.7)
|
||||
elif self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_CC:
|
||||
# Malibu CC: keep strict opgm behavior using BrakePedalPos >= 8.
|
||||
|
||||
if self.CP.carFingerprint in {CAR.CHEVROLET_MALIBU_CC} or (self.CP.carFingerprint == CAR.CHEVROLET_BLAZER and not no_accel_pos):
|
||||
ret.brakePressed = ret.brake >= 8
|
||||
elif (self.CP.flags & GMFlags.FORCE_BRAKE_C9.value) or (self.CP.networkLocation == NetworkLocation.fwdCamera):
|
||||
elif (self.CP.flags & GMFlags.FORCE_BRAKE_C9.value) or ((self.CP.networkLocation == NetworkLocation.fwdCamera) and (self.CP.carFingerprint != CAR.CHEVROLET_BLAZER)):
|
||||
ret.brakePressed = pt_cp.vl["ECMEngineStatus"]["BrakePressed"] != 0
|
||||
else:
|
||||
# Some Volt 2016-17 have loose brake pedal push rod retainers which causes the ECM to believe
|
||||
# that the brake is being intermittently pressed without user interaction.
|
||||
# To avoid a cruise fault we need to use a conservative brake position threshold
|
||||
# https://static.nhtsa.gov/odi/tsbs/2017/MC-10137629-9999.pdf
|
||||
analog_thresh = 0.15 if (self.CP.flags & GMFlags.NO_ACCELERATOR_POS_MSG.value) else 8
|
||||
analog_thresh = 0.10 if no_accel_pos else 8
|
||||
ret.brakePressed = ret.brake >= analog_thresh
|
||||
|
||||
# Regen braking is braking
|
||||
@@ -194,6 +199,8 @@ class CarState(CarStateBase):
|
||||
if self.CP.pcmCruise and self.CP.carFingerprint not in ASCM_INT:
|
||||
ret.cruiseState.nonAdaptive = cam_cp.vl["ASCMActiveCruiseControlStatus"]["ACCCruiseState"] not in (2, 3)
|
||||
if self.CP.carFingerprint in CC_ONLY_CAR:
|
||||
self.ecm_cruise_control_ts_nanos = pt_cp.ts_nanos["ECMCruiseControl"]["CruiseActive"]
|
||||
self.accelerator_pedal2_ts_nanos = pt_cp.ts_nanos["AcceleratorPedal2"]["CruiseState"]
|
||||
ret.accFaulted = False
|
||||
ret.cruiseState.speed = pt_cp.vl["ECMCruiseControl"]["CruiseSetSpeed"] * CV.KPH_TO_MS
|
||||
if self.CP.carFingerprint == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL:
|
||||
@@ -207,6 +214,9 @@ class CarState(CarStateBase):
|
||||
ret.cruiseState.enabled = pt_cp.vl["ECMCruiseControl"]["CruiseActive"] != 0
|
||||
except:
|
||||
ret.cruiseState.enabled = cam_cp.vl["ASCMActiveCruiseControlStatus"]["ACCCmdActive"] != 0
|
||||
else:
|
||||
self.ecm_cruise_control_ts_nanos = 0
|
||||
self.accelerator_pedal2_ts_nanos = 0
|
||||
|
||||
if self.CP.enableBsm:
|
||||
if not sdgm_non_volt:
|
||||
|
||||