Galaxy Optimization

This commit is contained in:
firestar5683
2026-03-04 11:35:48 -06:00
parent 2eced5b72c
commit c24dd3552c
18 changed files with 1006 additions and 519 deletions
@@ -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
+299 -77
View File
@@ -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():
+13 -38
View File
@@ -8,7 +8,6 @@ import secrets
import shutil
import subprocess
import time
import uuid
from datetime import datetime
from pathlib import Path
@@ -563,20 +562,11 @@ def process_route(footage_path, route_name):
return {
"name": route_name,
"gif": f"/thumbnails/{route_name}--0/preview.gif",
"png": f"/thumbnails/{route_name}--0/preview.png",
"timestamp": route_timestamp_str,
"is_preserved": has_preserve_attr(segment_path)
}
def process_route_gif(footage_path, route_name):
segment_path = f"{footage_path}{route_name}--0"
qcamera_path = f"{segment_path}/qcamera.ts"
gif_output_path = os.path.join(segment_path, "preview.gif")
if not os.path.exists(gif_output_path):
video_to_gif(qcamera_path, gif_output_path)
def process_screen_recording(mp4):
stem = mp4.with_suffix("")
png_path = stem.with_suffix(".png")
@@ -592,44 +582,29 @@ def process_screen_recording(mp4):
return {
"filename": mp4.name,
"gif": f"/screen_recordings/{stem.with_suffix('.gif').name}",
"png": f"/screen_recordings/{png_path.name}",
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
"is_custom_name": is_custom_name
}
def process_screen_recording_gif(mp4):
stem = mp4.with_suffix("")
gif_path = stem.with_suffix(".gif")
if not gif_path.exists():
video_to_gif(mp4, gif_path)
def run_ffmpeg(args):
process = subprocess.Popen(["ffmpeg", "-hide_banner", "-loglevel", "error"] + args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
return stdout
def segment_to_segment_name(data_dir, segment):
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
return SegmentName(full_path)
def video_to_gif(input_path, output_path):
output_path = Path(output_path)
sped_up_path = output_path.with_suffix(f".{uuid.uuid4()}.spedup.mp4")
run_ffmpeg(["-i", str(input_path), "-an", "-vf", "setpts=PTS/35", str(sped_up_path)])
run_ffmpeg(["-i", str(sped_up_path), "-loop", "0", str(output_path)])
if os.path.exists(sped_up_path):
os.remove(sped_up_path)
def video_to_png(input_path, output_path):
run_ffmpeg([
"-ss", str(get_video_duration(input_path) / 2),
"-i", str(input_path),
"-frames:v", "1",
str(output_path)
])
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", "1",
"-i", str(input_path),
"-frames:v", "1",
"-y",
str(output_path)
], capture_output=True, check=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Failed to generate PNG for {input_path}")
if e.stderr:
print(e.stderr)
def xor_encrypt_decrypt(data, key):
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
+14 -6
View File
@@ -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
+2 -1
View File
@@ -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),
]