mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-06 00:36:25 +08:00
Galaxy Optimization
This commit is contained in:
@@ -15,7 +15,6 @@
|
||||
|
||||
.diskUsage,
|
||||
.drivingStats,
|
||||
.firehoseStats,
|
||||
.softwareInfo {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--border-radius-md);
|
||||
@@ -31,7 +30,6 @@
|
||||
}
|
||||
|
||||
.diskUsage p,
|
||||
.firehoseStats p,
|
||||
.softwareGrid p {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
@@ -65,7 +63,6 @@
|
||||
|
||||
.diskUsage:hover,
|
||||
.drivingStats:hover,
|
||||
.firehoseStats:hover,
|
||||
.softwareInfo:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: var(--hover-scale-sm);
|
||||
|
||||
@@ -116,7 +116,7 @@ export function Home() {
|
||||
}
|
||||
|
||||
if (state.data) {
|
||||
const { driveStats, firehoseStats, softwareInfo } = state.data;
|
||||
const { driveStats, softwareInfo } = state.data;
|
||||
return html`
|
||||
<h1>Galaxy</h1>
|
||||
|
||||
@@ -131,14 +131,6 @@ export function Home() {
|
||||
${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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -370,45 +370,6 @@ export function RouteRecordings() {
|
||||
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;
|
||||
}}"
|
||||
@@ -423,11 +384,6 @@ export function RouteRecordings() {
|
||||
style="display:block;"
|
||||
loading="lazy"
|
||||
>
|
||||
<img
|
||||
data-src="${route.gif}"
|
||||
class="recording-preview recording-preview-gif"
|
||||
style="display:none;"
|
||||
>
|
||||
</div>
|
||||
<p class="recording-filename">${route.timestamp}</p>
|
||||
</div>
|
||||
|
||||
@@ -127,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`
|
||||
}
|
||||
|
||||
@@ -257,50 +256,10 @@ export function ScreenRecordings() {
|
||||
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;" loading="lazy">
|
||||
<img data-src="${rec.gif}" class="recording-preview recording-preview-gif" style="display:none;">
|
||||
</div>
|
||||
<p class="recording-filename">${displayName}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { createBrowserHistory, createRouter } from "https://esm.sh/@remix-run/router@1.3.1"
|
||||
import { hideSidebar } from "/assets/js/utils.js"
|
||||
import { DeviceSettings } from "/assets/components/tools/device_settings.js"
|
||||
import { DoorControl } from "/assets/components/tools/doors.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 { NavDestination } from "/assets/components/navigation/navigation_destination.js"
|
||||
@@ -17,7 +17,6 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603
|
||||
import { ThemeMaker } from "/assets/components/tools/theme_maker.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"
|
||||
|
||||
let router, routerState
|
||||
|
||||
@@ -32,8 +31,7 @@ function createRoute(id, path, component) {
|
||||
|
||||
function Root() {
|
||||
let routes = [
|
||||
createRoute("device_settings", "/device_settings", DeviceSettings),
|
||||
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),
|
||||
@@ -47,7 +45,7 @@ function Root() {
|
||||
createRoute("thememaker", "/theme_maker", ThemeMaker),
|
||||
createRoute("tmux", "/manage_tmux", TmuxLog),
|
||||
createRoute("toggles", "/manage_toggles", ToggleControl),
|
||||
createRoute("tsk_manager", "/tsk_manager", TSKManager),
|
||||
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
|
||||
]
|
||||
|
||||
router = createRouter({
|
||||
|
||||
@@ -19,54 +19,27 @@ const MenuItems = {
|
||||
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
|
||||
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
|
||||
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
|
||||
{ name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
|
||||
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
|
||||
{ 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: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" },
|
||||
],
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
doorsVisible: false,
|
||||
isDoorsFetched: false,
|
||||
isTSKFetched: false,
|
||||
tskVisible: false,
|
||||
|
||||
activeRoute: ""
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const currentPath = window.location.pathname;
|
||||
const activeItem = Object.values(MenuItems).flat().find(item => item.link === currentPath);
|
||||
const matchesPath = (link) => {
|
||||
if (link === "/") return currentPath === "/";
|
||||
return currentPath === link || currentPath.startsWith(`${link}/`);
|
||||
};
|
||||
const activeItem = Object.values(MenuItems).flat().find(item => matchesPath(item.link));
|
||||
state.activeRoute = activeItem?.name ?? "";
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function navigate(link) {
|
||||
state.activeRoute = link.name;
|
||||
@@ -101,14 +74,6 @@ export function Sidebar() {
|
||||
<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(" ");
|
||||
|
||||
|
||||
@@ -4,6 +4,44 @@
|
||||
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 {
|
||||
background-color: var(--input-bg);
|
||||
@@ -47,6 +85,10 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ds-static-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ds-section-header i {
|
||||
color: var(--main-fg);
|
||||
font-size: var(--font-size-lg);
|
||||
@@ -333,4 +375,15 @@
|
||||
.ds-wrapper {
|
||||
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.ds-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
import { Navigate } from "/assets/components/router.js"
|
||||
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
// Module-level state (persists across re-renders)
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
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: [],
|
||||
@@ -11,29 +17,134 @@ const state = reactive({
|
||||
loadingLayout: true,
|
||||
loadingValues: true,
|
||||
filter: "",
|
||||
collapsed: {},
|
||||
expanded: {},
|
||||
updatingKeys: {},
|
||||
fetched: false,
|
||||
activeSectionSlug: "",
|
||||
})
|
||||
|
||||
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 fetchLayoutAndParams() {
|
||||
state.loadingLayout = true
|
||||
state.loadingValues = true
|
||||
|
||||
// 1. Fetch Layout Structure (Build-time Static JSON)
|
||||
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
|
||||
|
||||
// Extract flatter key map
|
||||
const keys = []
|
||||
const paramMetaByKey = {}
|
||||
for (const section of layoutData) {
|
||||
@@ -42,6 +153,7 @@ async function fetchLayoutAndParams() {
|
||||
paramMetaByKey[p.key] = p
|
||||
}
|
||||
}
|
||||
|
||||
state.allKeys = keys
|
||||
state.paramMetaByKey = paramMetaByKey
|
||||
} catch (e) {
|
||||
@@ -49,7 +161,7 @@ async function fetchLayoutAndParams() {
|
||||
}
|
||||
state.loadingLayout = false
|
||||
|
||||
// 2. Fetch Live Values (Device State)
|
||||
// Pull params once at page load; local state handles subsequent edits.
|
||||
try {
|
||||
const res = await fetch("/api/params/all")
|
||||
const data = await res.json()
|
||||
@@ -58,65 +170,21 @@ async function fetchLayoutAndParams() {
|
||||
console.error("Failed to fetch param values:", e)
|
||||
}
|
||||
state.loadingValues = false
|
||||
requestAnimationFrame(syncInputs)
|
||||
}
|
||||
|
||||
function syncInputs() {
|
||||
const selectValue = (value) => (value === null || value === undefined ? "" : String(value))
|
||||
const 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)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of state.allKeys) {
|
||||
const el = document.getElementById(`ds-${key}`)
|
||||
if (el) {
|
||||
if (el.type === "checkbox") {
|
||||
el.checked = !!state.values[key]
|
||||
} else if (el.tagName === "SELECT") {
|
||||
const endpoint = el.getAttribute("data-endpoint")
|
||||
const inlineOptions = state.paramMetaByKey[key]?.options
|
||||
if (endpoint && !el.dataset.hydrated) {
|
||||
el.dataset.hydrated = "1"
|
||||
fetch(endpoint).then(r => r.json()).then(options => {
|
||||
applySelectOptions(el, options)
|
||||
el.value = selectValue(state.values[key])
|
||||
}).catch(() => { el.innerHTML = '<option value="">Error loading</option>' })
|
||||
} else if (Array.isArray(inlineOptions) && inlineOptions.length > 0 && !el.dataset.hydrated) {
|
||||
el.dataset.hydrated = "1"
|
||||
applySelectOptions(el, inlineOptions)
|
||||
el.value = selectValue(state.values[key])
|
||||
} else {
|
||||
el.value = selectValue(state.values[key])
|
||||
}
|
||||
} else {
|
||||
el.value = state.values[key]
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(state.values[key], el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 (isNaN(v)) return val
|
||||
if (Number.isNaN(v)) return val
|
||||
|
||||
// Specific formatting for the Audio Volume sliders mappings to simulate C++ behavior
|
||||
const volumeKeys = [
|
||||
"DisengageVolume", "EngageVolume", "PromptVolume",
|
||||
"PromptDistractedVolume", "RefuseVolume",
|
||||
"WarningImmediateVolume", "WarningSoftVolume"
|
||||
"WarningImmediateVolume", "WarningSoftVolume",
|
||||
]
|
||||
if (key && volumeKeys.includes(key)) {
|
||||
if (v === 0) return "Muted"
|
||||
@@ -168,21 +236,32 @@ function numericBounds(param) {
|
||||
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
|
||||
}
|
||||
|
||||
async function updateParam(key, elType) {
|
||||
const current = state.values[key]
|
||||
|
||||
// Extract new value from the DOM directly to avoid reactive race conditions
|
||||
const el = document.getElementById(`ds-${key}`)
|
||||
if (!el) return
|
||||
|
||||
const param = state.paramMetaByKey[key] || {}
|
||||
|
||||
let formattedVal
|
||||
if (elType === "checkbox") {
|
||||
formattedVal = current ? false : true
|
||||
formattedVal = !!el.checked
|
||||
} else if (elType === "dropdown") {
|
||||
formattedVal = el.value
|
||||
formattedVal = coerceValueByType(el.value, param.data_type)
|
||||
} else {
|
||||
// Numeric slider - coerce to float
|
||||
formattedVal = parseFloat(el.value)
|
||||
formattedVal = coerceValueByType(el.value, param.data_type)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -194,8 +273,10 @@ async function updateParam(key, elType) {
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
state.values = { ...state.values, [key]: formattedVal }
|
||||
const updated = (data.updated && typeof data.updated === "object") ? data.updated : {}
|
||||
state.values = { ...state.values, [key]: formattedVal, ...updated }
|
||||
showSnackbar(data.message || `${key} updated`)
|
||||
scheduleSyncInputs()
|
||||
} else {
|
||||
revertInput(key, current, elType)
|
||||
showSnackbar(data.error || "Failed to update parameter")
|
||||
@@ -208,39 +289,40 @@ async function updateParam(key, elType) {
|
||||
|
||||
function revertInput(key, current, elType) {
|
||||
const el = document.getElementById(`ds-${key}`)
|
||||
if (el) {
|
||||
if (elType === "checkbox") el.checked = !!current
|
||||
else if (elType === "dropdown") el.value = (current === null || current === undefined ? "" : String(current))
|
||||
else {
|
||||
el.value = current
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(current, el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
if (!el) return
|
||||
|
||||
if (elType === "checkbox") {
|
||||
el.checked = !!current
|
||||
return
|
||||
}
|
||||
|
||||
if (elType === "dropdown") {
|
||||
el.value = toSelectValue(current)
|
||||
return
|
||||
}
|
||||
|
||||
el.value = current
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(current, el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSliderInput(e, key) {
|
||||
const displayEl = document.getElementById(`ds-display-${key}`)
|
||||
if (displayEl) {
|
||||
const el = e.target
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(el.value, el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
}
|
||||
if (!displayEl) return
|
||||
|
||||
function toggleSection(name) {
|
||||
state.collapsed = { ...state.collapsed, [name]: !state.collapsed[name] }
|
||||
setTimeout(syncInputs, 50)
|
||||
const el = e.target
|
||||
const precision = el.getAttribute("data-precision")
|
||||
const pInt = precision ? parseInt(precision, 10) : null
|
||||
displayEl.textContent = formatSliderValue(el.value, el.getAttribute("step"), pInt, key)
|
||||
}
|
||||
|
||||
function toggleManage(key) {
|
||||
state.expanded = { ...state.expanded, [key]: !state.expanded[key] }
|
||||
setTimeout(syncInputs, 50)
|
||||
scheduleSyncInputs()
|
||||
}
|
||||
|
||||
function matchesFilter(p) {
|
||||
@@ -249,15 +331,98 @@ function matchesFilter(p) {
|
||||
return p.label.toLowerCase().includes(q) || p.key.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
// Component
|
||||
// ―――――――――――――――――――――――――――――――
|
||||
export function DeviceSettings() {
|
||||
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}">${state.values[p.key] !== undefined ? formatSliderValue(state.values[p.key], p.step !== undefined ? String(p.step) : undefined, p.precision, p.key) : ".."}</span>` : ""}
|
||||
</div>
|
||||
|
||||
${isNumeric ? html`
|
||||
<div class="ds-slider-container">
|
||||
${(() => {
|
||||
const bounds = numericBounds(p)
|
||||
return html`
|
||||
<input
|
||||
type="range"
|
||||
class="ds-slider"
|
||||
id="ds-${p.key}"
|
||||
min="${bounds.min}"
|
||||
max="${bounds.max}"
|
||||
step="${bounds.step}"
|
||||
data-precision="${p.precision !== undefined ? p.precision : ""}"
|
||||
value="${state.values[p.key] !== undefined ? state.values[p.key] : ""}"
|
||||
@input="${(e) => handleSliderInput(e, p.key)}"
|
||||
@change="${() => updateParam(p.key, "numeric")}" />
|
||||
`
|
||||
})()}
|
||||
</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>Device Settings</h2>
|
||||
@@ -266,114 +431,92 @@ export function DeviceSettings() {
|
||||
class="ds-search"
|
||||
type="text"
|
||||
placeholder="Search settings..."
|
||||
@input="${(e) => { state.filter = e.target.value }}"
|
||||
/>
|
||||
@input="${(e) => {
|
||||
state.filter = e.target.value
|
||||
scheduleSyncInputs()
|
||||
}}" />
|
||||
|
||||
${() => {
|
||||
if (state.loadingLayout || state.loadingValues) {
|
||||
return html`<div class="ds-loading">Loading configuration...</div>`
|
||||
}
|
||||
|
||||
const loadedKeys = state.allKeys.length
|
||||
const sections = getSectionsWithSlug()
|
||||
if (sections.length === 0) {
|
||||
return html`<div class="ds-empty">No settings available.</div>`
|
||||
}
|
||||
|
||||
// Sync DOM inputs after reactive render
|
||||
requestAnimationFrame(syncInputs)
|
||||
// Sync DOM inputs after ArrowJS renders (safe: syncScheduled is non-reactive)
|
||||
scheduleSyncInputs()
|
||||
|
||||
return html`
|
||||
<div class="ds-status-bar">
|
||||
<span>${loadedKeys} settings mapped dynamically</span>
|
||||
</div>
|
||||
// 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)
|
||||
|
||||
${state.layout.map(section => {
|
||||
const visibleParams = section.params.filter(p => matchesFilter(p))
|
||||
if (visibleParams.length === 0) return ""
|
||||
|
||||
const isCollapsed = state.collapsed[section.name]
|
||||
const totalMatches = searchResults.reduce((n, s) => n + s.matches.length, 0)
|
||||
|
||||
return html`
|
||||
<div class="ds-section ${isCollapsed ? 'collapsed' : ''}">
|
||||
<div class="ds-section-header" @click="${() => toggleSection(section.name)}">
|
||||
<i class="bi ${section.icon}"></i>
|
||||
<span class="ds-section-title">${section.name} (${visibleParams.length})</span>
|
||||
<i class="bi bi-chevron-down ds-section-chevron"></i>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
${() => visibleParams.map(p => {
|
||||
if (p.parent_key) {
|
||||
if (!state.values[p.parent_key]) return ""
|
||||
if (!state.expanded[p.parent_key]) return ""
|
||||
}
|
||||
<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>
|
||||
|
||||
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}">${state.values[p.key] !== undefined ? formatSliderValue(state.values[p.key], p.step !== undefined ? String(p.step) : undefined, p.precision, p.key) : '..'}</span>` : ""}
|
||||
</div>
|
||||
|
||||
${isNumeric ? html`
|
||||
<div class="ds-slider-container">
|
||||
${(() => {
|
||||
const bounds = numericBounds(p)
|
||||
return html`
|
||||
<input
|
||||
type="range"
|
||||
class="ds-slider"
|
||||
id="ds-${p.key}"
|
||||
min="${bounds.min}"
|
||||
max="${bounds.max}"
|
||||
step="${bounds.step}"
|
||||
data-precision="${p.precision !== undefined ? p.precision : ''}"
|
||||
value="${state.values[p.key] !== undefined ? state.values[p.key] : ''}"
|
||||
@input="${(e) => handleSliderInput(e, p.key)}"
|
||||
@change="${() => updateParam(p.key, 'numeric')}"
|
||||
/>
|
||||
`
|
||||
})()}
|
||||
</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}"
|
||||
.checked="${!!state.values[p.key]}"
|
||||
@change="${() => updateParam(p.key, 'checkbox')}"
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`
|
||||
})}
|
||||
</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>
|
||||
`)}
|
||||
|
||||
${() => {
|
||||
const totalVisible = state.layout.reduce((acc, s) =>
|
||||
acc + s.params.filter(p => matchesFilter(p)).length, 0)
|
||||
if (totalVisible === 0) {
|
||||
return html`<div class="ds-empty">No settings match your search.</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">
|
||||
${sections.map(section => html`
|
||||
<button
|
||||
class="ds-tab ${section.slug === state.activeSectionSlug ? "active" : ""}"
|
||||
@click="${() => {
|
||||
if (section.slug !== state.activeSectionSlug) {
|
||||
Navigate("/device_settings/" + section.slug)
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
}}">
|
||||
<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>
|
||||
|
||||
@@ -2178,6 +2178,29 @@
|
||||
"name": "Vehicle",
|
||||
"icon": "bi-car-front",
|
||||
"params": [
|
||||
{
|
||||
"key": "CarMake",
|
||||
"label": "Car Make",
|
||||
"description": "Select your car make.",
|
||||
"data_type": "str",
|
||||
"ui_type": "dropdown",
|
||||
"options_endpoint": "/api/fingerprints/makes"
|
||||
},
|
||||
{
|
||||
"key": "CarModel",
|
||||
"label": "Car Model (Fingerprint)",
|
||||
"description": "Choose the fingerprint platform to use when automatic detection is disabled.",
|
||||
"data_type": "str",
|
||||
"ui_type": "dropdown",
|
||||
"options_endpoint": "/api/fingerprints/models?make={CarMake}"
|
||||
},
|
||||
{
|
||||
"key": "ForceFingerprint",
|
||||
"label": "Disable Automatic Fingerprint Detection",
|
||||
"description": "Force the selected fingerprint and prevent it from changing automatically.",
|
||||
"data_type": "bool",
|
||||
"ui_type": "toggle"
|
||||
},
|
||||
{
|
||||
"key": "GMPedalLongitudinal",
|
||||
"label": "Use Pedal for Longitudinal Control",
|
||||
@@ -2425,4 +2448,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -260,12 +260,15 @@ function ensurePolling() {
|
||||
if (pollingHandle) return;
|
||||
|
||||
const poll = async () => {
|
||||
if (!isModelRouteActive()) {
|
||||
pollingHandle = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let nextDelay = IDLE_POLL_INTERVAL_MS;
|
||||
try {
|
||||
if (isModelRouteActive()) {
|
||||
await fetchStatus();
|
||||
nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
||||
}
|
||||
await fetchStatus();
|
||||
nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
||||
} finally {
|
||||
pollingHandle = setTimeout(poll, nextDelay);
|
||||
}
|
||||
@@ -487,8 +490,8 @@ export function ModelManager() {
|
||||
state.refreshing = false;
|
||||
logDebug("Initial refresh failed", state.error);
|
||||
});
|
||||
ensurePolling();
|
||||
}
|
||||
ensurePolling();
|
||||
|
||||
return html`
|
||||
<div class="mm-wrapper">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
<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">
|
||||
|
||||
<link rel="stylesheet" href="/assets/components/home/home.css">
|
||||
<link rel="stylesheet" href="/assets/components/main.css">
|
||||
@@ -40,8 +39,6 @@
|
||||
<script type="module" src="/assets/components/router.js"></script>
|
||||
<script src="/assets/js/snackbar.js"></script>
|
||||
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
|
||||
@@ -64,6 +64,188 @@ MODEL_CANCEL_DOWNLOAD_PARAM = "CancelModelDownload"
|
||||
MODEL_SORT_MODE_PARAM = "ModelSortMode"
|
||||
MODEL_USER_FAVORITES_PARAM = "UserFavorites"
|
||||
|
||||
FINGERPRINT_MAKE_LABELS = [
|
||||
"Acura",
|
||||
"Audi",
|
||||
"Buick",
|
||||
"Cadillac",
|
||||
"Chevrolet",
|
||||
"Chrysler",
|
||||
"CUPRA",
|
||||
"Dodge",
|
||||
"Ford",
|
||||
"Genesis",
|
||||
"GMC",
|
||||
"Holden",
|
||||
"Honda",
|
||||
"Hyundai",
|
||||
"Jeep",
|
||||
"Kia",
|
||||
"Lexus",
|
||||
"Lincoln",
|
||||
"MAN",
|
||||
"Mazda",
|
||||
"Nissan",
|
||||
"Ram",
|
||||
"SEAT",
|
||||
"\u0160koda",
|
||||
"Subaru",
|
||||
"Tesla",
|
||||
"Toyota",
|
||||
"Volkswagen",
|
||||
]
|
||||
|
||||
FINGERPRINT_MAKE_TO_VALUES_DIR = {
|
||||
"acura": "honda",
|
||||
"audi": "volkswagen",
|
||||
"buick": "gm",
|
||||
"cadillac": "gm",
|
||||
"chevrolet": "gm",
|
||||
"chrysler": "chrysler",
|
||||
"cupra": "volkswagen",
|
||||
"dodge": "chrysler",
|
||||
"ford": "ford",
|
||||
"genesis": "hyundai",
|
||||
"gmc": "gm",
|
||||
"holden": "gm",
|
||||
"honda": "honda",
|
||||
"hyundai": "hyundai",
|
||||
"jeep": "chrysler",
|
||||
"kia": "hyundai",
|
||||
"lexus": "toyota",
|
||||
"lincoln": "ford",
|
||||
"man": "volkswagen",
|
||||
"mazda": "mazda",
|
||||
"nissan": "nissan",
|
||||
"ram": "chrysler",
|
||||
"seat": "volkswagen",
|
||||
"\u0161koda": "volkswagen",
|
||||
"subaru": "subaru",
|
||||
"tesla": "tesla",
|
||||
"toyota": "toyota",
|
||||
"volkswagen": "volkswagen",
|
||||
}
|
||||
|
||||
_FINGERPRINT_CARDOCS_RE = re.compile(r'CarDocs\(\s*"([^"]+)"')
|
||||
_FINGERPRINT_PLATFORM_RE = re.compile(r'(\w+)\s*=\s*\w+\s*\(\s*\[([\s\S]*?)\]\s*,')
|
||||
_FINGERPRINT_PLATFORM_NAME_RE = re.compile(r'^[A-Z0-9_]+$')
|
||||
_FINGERPRINT_VALID_NAME_RE = re.compile(r'^[A-Za-z0-9 \u0160.()\-]+$')
|
||||
|
||||
_openpilot_root_cache = None
|
||||
_fingerprint_catalog_cache = None
|
||||
|
||||
def _normalize_fingerprint_make_key(make_value):
|
||||
return str(make_value or "").strip().lower()
|
||||
|
||||
def _get_openpilot_root():
|
||||
global _openpilot_root_cache
|
||||
if _openpilot_root_cache is not None:
|
||||
return _openpilot_root_cache
|
||||
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
if (parent / "selfdrive" / "car").is_dir():
|
||||
_openpilot_root_cache = parent
|
||||
return _openpilot_root_cache
|
||||
|
||||
# Fallback to repo root shape used in this tree.
|
||||
_openpilot_root_cache = Path(__file__).resolve().parents[3]
|
||||
return _openpilot_root_cache
|
||||
|
||||
def _extract_fingerprint_models_for_make(make_key):
|
||||
source_make = FINGERPRINT_MAKE_TO_VALUES_DIR.get(make_key, make_key)
|
||||
values_path = _get_openpilot_root() / "selfdrive" / "car" / source_make / "values.py"
|
||||
if not values_path.is_file():
|
||||
return []
|
||||
|
||||
try:
|
||||
content = values_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
content = re.sub(r'#[^\n]*', "", content)
|
||||
content = re.sub(r'footnotes=\[[^\]]*\],\s*', "", content)
|
||||
|
||||
models = []
|
||||
seen = set()
|
||||
|
||||
for platform_match in _FINGERPRINT_PLATFORM_RE.finditer(content):
|
||||
platform_name = platform_match.group(1)
|
||||
if not _FINGERPRINT_PLATFORM_NAME_RE.match(platform_name):
|
||||
continue
|
||||
|
||||
platform_section = platform_match.group(2)
|
||||
for name_match in _FINGERPRINT_CARDOCS_RE.finditer(platform_section):
|
||||
car_name = name_match.group(1).strip()
|
||||
if " " not in car_name:
|
||||
continue
|
||||
if not _FINGERPRINT_VALID_NAME_RE.match(car_name):
|
||||
continue
|
||||
|
||||
if car_name.split(" ", 1)[0].lower() != make_key:
|
||||
continue
|
||||
|
||||
dedupe_key = (car_name, platform_name)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(dedupe_key)
|
||||
models.append({"value": platform_name, "label": car_name})
|
||||
|
||||
models.sort(key=lambda entry: entry["label"].lower())
|
||||
return models
|
||||
|
||||
def _get_fingerprint_catalog():
|
||||
global _fingerprint_catalog_cache
|
||||
if _fingerprint_catalog_cache is not None:
|
||||
return _fingerprint_catalog_cache
|
||||
|
||||
make_options = [{"value": label, "label": label} for label in FINGERPRINT_MAKE_LABELS]
|
||||
make_keys = [_normalize_fingerprint_make_key(label) for label in FINGERPRINT_MAKE_LABELS]
|
||||
make_label_by_key = {key: label for key, label in zip(make_keys, FINGERPRINT_MAKE_LABELS)}
|
||||
|
||||
models_by_make = {}
|
||||
all_models = []
|
||||
seen_all = set()
|
||||
model_to_label = {}
|
||||
model_to_make = {}
|
||||
label_to_model = {}
|
||||
|
||||
for make_key in make_keys:
|
||||
make_label = make_label_by_key.get(make_key, make_key.title())
|
||||
entries = _extract_fingerprint_models_for_make(make_key)
|
||||
|
||||
models_by_make[make_key] = entries
|
||||
for entry in entries:
|
||||
model_value = entry["value"]
|
||||
model_label = entry["label"]
|
||||
|
||||
model_to_label.setdefault(model_value, model_label)
|
||||
model_to_make.setdefault(model_value, make_label)
|
||||
label_to_model.setdefault(model_label, model_value)
|
||||
|
||||
dedupe_key = (model_label, model_value)
|
||||
if dedupe_key in seen_all:
|
||||
continue
|
||||
seen_all.add(dedupe_key)
|
||||
|
||||
all_models.append({
|
||||
"value": model_value,
|
||||
"label": model_label,
|
||||
"make": make_label,
|
||||
})
|
||||
|
||||
all_models.sort(key=lambda entry: entry["label"].lower())
|
||||
|
||||
_fingerprint_catalog_cache = {
|
||||
"makes": make_options,
|
||||
"models_by_make": models_by_make,
|
||||
"all_models": all_models,
|
||||
"make_label_by_key": make_label_by_key,
|
||||
"model_to_label": model_to_label,
|
||||
"model_to_make": model_to_make,
|
||||
"label_to_model": label_to_model,
|
||||
}
|
||||
return _fingerprint_catalog_cache
|
||||
|
||||
def read_legacy_param_file(key, default_value=""):
|
||||
try:
|
||||
value_path = Path(params.get_param_path(key))
|
||||
@@ -80,6 +262,52 @@ def write_legacy_param_file(key, value):
|
||||
tmp_path.write_text(str(value), encoding="utf-8")
|
||||
os.replace(tmp_path, value_path)
|
||||
|
||||
_layout_type_overrides = None
|
||||
|
||||
def _get_layout_type_overrides():
|
||||
global _layout_type_overrides
|
||||
if _layout_type_overrides is None:
|
||||
try:
|
||||
layout_path = os.path.join(os.path.dirname(__file__), "assets", "components", "tools", "device_settings_layout.json")
|
||||
with open(layout_path) as f:
|
||||
layout_data = json.load(f)
|
||||
_layout_type_overrides = {
|
||||
p["key"]: p["data_type"]
|
||||
for section in layout_data
|
||||
for p in section.get("params", [])
|
||||
if "key" in p and "data_type" in p
|
||||
}
|
||||
except Exception:
|
||||
_layout_type_overrides = {}
|
||||
return _layout_type_overrides
|
||||
|
||||
_cached_allowed_keys = None
|
||||
_cached_param_types = None
|
||||
|
||||
def _get_param_type_info():
|
||||
global _cached_allowed_keys, _cached_param_types
|
||||
if _cached_allowed_keys is None:
|
||||
_cached_allowed_keys = {k for k, _, _, _ in frogpilot_default_params if k not in EXCLUDED_KEYS}
|
||||
|
||||
types = {}
|
||||
for k, default_val, _, _ in frogpilot_default_params:
|
||||
if k in _cached_allowed_keys:
|
||||
if default_val in ("0", "1", b"0", b"1") or isinstance(default_val, bool):
|
||||
types[k] = bool
|
||||
elif isinstance(default_val, float) or (isinstance(default_val, str) and "." in default_val and default_val.replace(".", "", 1).isdigit()):
|
||||
types[k] = float
|
||||
elif isinstance(default_val, int) or (isinstance(default_val, str) and default_val.isdigit()):
|
||||
types[k] = int
|
||||
else:
|
||||
types[k] = str
|
||||
|
||||
for k, dt in _get_layout_type_overrides().items():
|
||||
if k in types and dt in ("int", "float") and types[k] == bool:
|
||||
types[k] = float if dt == "float" else int
|
||||
|
||||
_cached_param_types = types
|
||||
return _cached_allowed_keys, _cached_param_types
|
||||
|
||||
def setup(app):
|
||||
model_status_debug = {
|
||||
"last_signature": None,
|
||||
@@ -121,12 +349,18 @@ def setup(app):
|
||||
"icons": [],
|
||||
}), 200
|
||||
|
||||
@app.route("/api/doors_available", methods=["GET"])
|
||||
def doors_available():
|
||||
with car.CarParams.from_bytes(params.get("CarParamsPersistent")) as cp_reader:
|
||||
CP = cp_reader.as_builder()
|
||||
|
||||
return jsonify({"result": HARDWARE.get_device_type() != "tici" and CP.carName == "toyota"})
|
||||
@app.route("/api/car_features_check", methods=["GET"])
|
||||
def car_features_check():
|
||||
tool = request.args.get("tool")
|
||||
try:
|
||||
with car.CarParams.from_bytes(params.get("CarParamsPersistent")) as cp:
|
||||
if tool == "doors":
|
||||
return jsonify({"result": HARDWARE.get_device_type() != "tici" and cp.carName == "toyota"})
|
||||
elif tool == "tsk":
|
||||
return jsonify({"result": cp.secOcRequired})
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"result": False})
|
||||
|
||||
@app.route("/api/doors/lock", methods=["POST"])
|
||||
def lock_doors():
|
||||
@@ -355,6 +589,23 @@ def setup(app):
|
||||
|
||||
return jsonify(message=f"{', '.join(saved)} saved successfully!")
|
||||
|
||||
@app.route("/api/fingerprints/makes", methods=["GET"])
|
||||
def get_fingerprint_makes():
|
||||
return jsonify(_get_fingerprint_catalog()["makes"]), 200
|
||||
|
||||
@app.route("/api/fingerprints/models", methods=["GET"])
|
||||
def get_fingerprint_models():
|
||||
catalog = _get_fingerprint_catalog()
|
||||
make_key = _normalize_fingerprint_make_key(
|
||||
request.args.get("make") or params.get("CarMake", encoding="utf-8") or ""
|
||||
)
|
||||
|
||||
models = catalog["models_by_make"].get(make_key) if make_key else catalog["all_models"]
|
||||
if not models:
|
||||
models = catalog["all_models"]
|
||||
|
||||
return jsonify(models), 200
|
||||
|
||||
@app.route("/api/params", methods=["GET", "PUT"])
|
||||
def get_param():
|
||||
if request.method == "PUT":
|
||||
@@ -371,7 +622,7 @@ def setup(app):
|
||||
else:
|
||||
str_val = str(val)
|
||||
|
||||
allowed_keys = {k for k, _, _, _ in frogpilot_default_params if k not in EXCLUDED_KEYS}
|
||||
allowed_keys, _ = _get_param_type_info()
|
||||
if key not in allowed_keys:
|
||||
return jsonify({"error": f"Parameter '{key}' is not editable."}), 403
|
||||
|
||||
@@ -388,12 +639,51 @@ def setup(app):
|
||||
name = friendly_names.get(key, key)
|
||||
return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403
|
||||
|
||||
if key == "CarMake":
|
||||
catalog = _get_fingerprint_catalog()
|
||||
normalized_make = _normalize_fingerprint_make_key(str_val)
|
||||
stored_make = catalog["make_label_by_key"].get(normalized_make, str_val.strip())
|
||||
params.put("CarMake", stored_make)
|
||||
update_frogpilot_toggles()
|
||||
return jsonify({
|
||||
"message": "Car make updated successfully.",
|
||||
"updated": {"CarMake": stored_make},
|
||||
}), 200
|
||||
|
||||
if key == "CarModel":
|
||||
selected_model = str_val.strip()
|
||||
if not selected_model:
|
||||
return jsonify({"error": "Car model cannot be empty."}), 400
|
||||
|
||||
catalog = _get_fingerprint_catalog()
|
||||
model_label = catalog["model_to_label"].get(selected_model)
|
||||
make_label = catalog["model_to_make"].get(selected_model)
|
||||
|
||||
params.put("CarModel", selected_model)
|
||||
updated = {"CarModel": selected_model}
|
||||
|
||||
if model_label:
|
||||
params.put("CarModelName", model_label)
|
||||
updated["CarModelName"] = model_label
|
||||
else:
|
||||
params.remove("CarModelName")
|
||||
updated["CarModelName"] = ""
|
||||
|
||||
if make_label:
|
||||
params.put("CarMake", make_label)
|
||||
updated["CarMake"] = make_label
|
||||
|
||||
update_frogpilot_toggles()
|
||||
return jsonify({
|
||||
"message": f"Fingerprint set to '{model_label or selected_model}'.",
|
||||
"updated": updated,
|
||||
}), 200
|
||||
|
||||
params.put(key, str_val)
|
||||
|
||||
if key == "Model":
|
||||
# 2. Sync ModelVersion explicitly
|
||||
try:
|
||||
import json
|
||||
with open("/data/models/.model_versions.json", "r") as f:
|
||||
versions = json.load(f)
|
||||
if str_val in versions:
|
||||
@@ -409,34 +699,7 @@ def setup(app):
|
||||
|
||||
@app.route("/api/params/all", methods=["GET"])
|
||||
def get_all_params():
|
||||
allowed_keys = {k for k, _, _, _ in frogpilot_default_params if k not in EXCLUDED_KEYS}
|
||||
|
||||
# Establish intended types from defaults
|
||||
types = {}
|
||||
for k, default_val, _, _ in frogpilot_default_params:
|
||||
if k in allowed_keys:
|
||||
if default_val in ("0", "1", b"0", b"1") or isinstance(default_val, bool):
|
||||
types[k] = bool
|
||||
elif isinstance(default_val, float) or (isinstance(default_val, str) and "." in default_val and default_val.replace(".", "", 1).isdigit()):
|
||||
types[k] = float
|
||||
elif isinstance(default_val, int) or (isinstance(default_val, str) and default_val.isdigit()):
|
||||
types[k] = int
|
||||
else:
|
||||
types[k] = str
|
||||
|
||||
# Override ambiguous "0"/"1" defaults using layout JSON's authoritative data_type
|
||||
try:
|
||||
layout_path = os.path.join(os.path.dirname(__file__), "assets", "components", "tools", "device_settings_layout.json")
|
||||
with open(layout_path) as f:
|
||||
layout_data = json.load(f)
|
||||
for section in layout_data:
|
||||
for p in section.get("params", []):
|
||||
k = p.get("key")
|
||||
dt = p.get("data_type")
|
||||
if k in types and dt in ("int", "float") and types[k] == bool:
|
||||
types[k] = float if dt == "float" else int
|
||||
except Exception:
|
||||
pass
|
||||
allowed_keys, types = _get_param_type_info()
|
||||
|
||||
result = {}
|
||||
for key in allowed_keys:
|
||||
@@ -799,34 +1062,10 @@ def setup(app):
|
||||
try:
|
||||
result = future.result()
|
||||
yield f"data: {json.dumps({'routes': [result]})}\n\n"
|
||||
|
||||
path, name = futures[future]
|
||||
segments = utilities.get_segments_in_route(name, path)
|
||||
if segments:
|
||||
for camera, cam_file in {
|
||||
"forward": "fcamera.hevc",
|
||||
"wide": "ecamera.hevc",
|
||||
"driver": "dcamera.hevc"
|
||||
}.items():
|
||||
input_files = [
|
||||
os.path.join(path, seg, cam_file)
|
||||
for seg in segments
|
||||
if os.path.exists(os.path.join(path, seg, cam_file))
|
||||
]
|
||||
if input_files:
|
||||
executor.submit(
|
||||
utilities.ffmpeg_concat_segments_to_mp4,
|
||||
input_files,
|
||||
f"{name}-{camera}"
|
||||
)
|
||||
|
||||
except Exception as exception:
|
||||
print(f"Error processing route: {exception}")
|
||||
yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n"
|
||||
|
||||
for path, name in routes:
|
||||
utilities.process_route_gif(path, name)
|
||||
|
||||
return Response(generate(), mimetype="text/event-stream")
|
||||
|
||||
@app.route("/api/routes/<name>", methods=["DELETE"])
|
||||
@@ -1066,9 +1305,6 @@ def setup(app):
|
||||
|
||||
yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n"
|
||||
|
||||
for recording in recordings:
|
||||
utilities.process_screen_recording_gif(recording)
|
||||
|
||||
return Response(generate(), mimetype="text/event-stream")
|
||||
|
||||
@app.route("/screen_recordings/<path:filename>", methods=["GET"])
|
||||
@@ -1132,17 +1368,9 @@ def setup(app):
|
||||
else:
|
||||
env = short_branch
|
||||
|
||||
try:
|
||||
response = requests.get(f"https://api.comma.ai/v1/devices/{params.get('DongleId', encoding='utf8')}/firehose_stats", timeout=10)
|
||||
response.raise_for_status()
|
||||
firehose_stats = response.json().get("firehose", 0)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
firehose_stats = 0
|
||||
|
||||
return {
|
||||
"diskUsage": utilities.get_disk_usage(),
|
||||
"driveStats": utilities.get_drive_stats(),
|
||||
"firehoseStats": {"segments": firehose_stats},
|
||||
"softwareInfo": {
|
||||
"branchName": build_metadata.channel,
|
||||
"buildEnvironment": env,
|
||||
@@ -1985,12 +2213,6 @@ def setup(app):
|
||||
|
||||
return jsonify({"message": f"Renamed {old} to {new_safe}!"}), 200
|
||||
|
||||
@app.route("/api/tsk_available", methods=["GET"])
|
||||
def tsk_available():
|
||||
with car.CarParams.from_bytes(params.get("CarParamsPersistent")) as cp_reader:
|
||||
CP = cp_reader.as_builder()
|
||||
|
||||
return jsonify({"result": CP.secOcRequired})
|
||||
|
||||
@app.route("/api/tsk_keys", methods=["DELETE"])
|
||||
def delete_secoc_key():
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -21,8 +21,11 @@ WATCHDOG_FN = "/dev/shm/wd_"
|
||||
ENABLE_WATCHDOG = os.getenv("NO_WATCHDOG") is None
|
||||
|
||||
|
||||
def launcher(proc: str, name: str) -> None:
|
||||
def launcher(proc: str, name: str, nice: int | None = None) -> None:
|
||||
try:
|
||||
if nice is not None:
|
||||
os.nice(nice)
|
||||
|
||||
# import the process
|
||||
mod = importlib.import_module(proc)
|
||||
|
||||
@@ -47,9 +50,12 @@ def launcher(proc: str, name: str) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def nativelauncher(pargs: list[str], cwd: str, name: str) -> None:
|
||||
def nativelauncher(pargs: list[str], cwd: str, name: str, nice: int | None = None) -> None:
|
||||
os.environ['MANAGER_DAEMON'] = name
|
||||
|
||||
if nice is not None:
|
||||
os.nice(nice)
|
||||
|
||||
# exec the process
|
||||
os.chdir(cwd)
|
||||
os.execvp(pargs[0], pargs)
|
||||
@@ -168,7 +174,7 @@ class ManagerProcess(ABC):
|
||||
|
||||
|
||||
class NativeProcess(ManagerProcess):
|
||||
def __init__(self, name, cwd, cmdline, should_run, enabled=True, sigkill=False, watchdog_max_dt=None):
|
||||
def __init__(self, name, cwd, cmdline, should_run, enabled=True, sigkill=False, watchdog_max_dt=None, nice=None):
|
||||
self.name = name
|
||||
self.cwd = cwd
|
||||
self.cmdline = cmdline
|
||||
@@ -176,6 +182,7 @@ class NativeProcess(ManagerProcess):
|
||||
self.enabled = enabled
|
||||
self.sigkill = sigkill
|
||||
self.watchdog_max_dt = watchdog_max_dt
|
||||
self.nice = nice
|
||||
self.launcher = nativelauncher
|
||||
|
||||
def prepare(self) -> None:
|
||||
@@ -191,20 +198,21 @@ class NativeProcess(ManagerProcess):
|
||||
|
||||
cwd = os.path.join(BASEDIR, self.cwd)
|
||||
cloudlog.info(f"starting process {self.name}")
|
||||
self.proc = Process(name=self.name, target=self.launcher, args=(self.cmdline, cwd, self.name))
|
||||
self.proc = Process(name=self.name, target=self.launcher, args=(self.cmdline, cwd, self.name, self.nice))
|
||||
self.proc.start()
|
||||
self.watchdog_seen = False
|
||||
self.shutting_down = False
|
||||
|
||||
|
||||
class PythonProcess(ManagerProcess):
|
||||
def __init__(self, name, module, should_run, enabled=True, sigkill=False, watchdog_max_dt=None):
|
||||
def __init__(self, name, module, should_run, enabled=True, sigkill=False, watchdog_max_dt=None, nice=None):
|
||||
self.name = name
|
||||
self.module = module
|
||||
self.should_run = should_run
|
||||
self.enabled = enabled
|
||||
self.sigkill = sigkill
|
||||
self.watchdog_max_dt = watchdog_max_dt
|
||||
self.nice = nice
|
||||
self.launcher = launcher
|
||||
|
||||
def prepare(self) -> None:
|
||||
@@ -221,7 +229,7 @@ class PythonProcess(ManagerProcess):
|
||||
return
|
||||
|
||||
cloudlog.info(f"starting python {self.module}")
|
||||
self.proc = Process(name=self.name, target=self.launcher, args=(self.module, self.name))
|
||||
self.proc = Process(name=self.name, target=self.launcher, args=(self.module, self.name, self.nice))
|
||||
self.proc.start()
|
||||
self.watchdog_seen = False
|
||||
self.shutting_down = False
|
||||
|
||||
@@ -114,7 +114,8 @@ procs = [
|
||||
PythonProcess("frogpilot_process", "frogpilot.frogpilot_process", always_run),
|
||||
PythonProcess("mapd", "frogpilot.navigation.mapd", always_run),
|
||||
PythonProcess("speed_limit_filler", "frogpilot.system.speed_limit_filler", run_speed_limit_filler),
|
||||
PythonProcess("the_pond", "frogpilot.system.the_pond.the_pond", always_run),
|
||||
# Lower priority so onroad processes win CPU time if The Pond is busy.
|
||||
PythonProcess("the_pond", "frogpilot.system.the_pond.the_pond", always_run, nice=15),
|
||||
PythonProcess("galaxy", "frogpilot.system.galaxy.galaxy", always_run),
|
||||
PythonProcess("tinygrad_modeld", "frogpilot.tinygrad_modeld.tinygrad_modeld", run_tinygrad_modeld),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user