This commit is contained in:
firestarsdog
2026-02-26 09:48:31 -05:00
committed by firestar5683
parent 73823319be
commit bb972faba1
38 changed files with 3428 additions and 426 deletions
+1 -1
View File
@@ -456,7 +456,7 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [
("WarningSoftVolume", "101", 2, "101"),
("WheelIcon", "frog", 0, "stock"),
("WheelSpeed", "0", 2, "0"),
("StopDistance", "6", 3, "6"),
("StopDistance", "6.0", 3, "6.0"),
("RecoveryPower", "1.0", 2, "1.0")
]
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
import platform
import shutil
import signal
import subprocess
import tarfile
import time
import threading
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from openpilot.common.params import Params
GALAXY_DIR = Path("/data/galaxy")
FRPC_VERSION = "0.67.0"
FRPC_LOG = GALAXY_DIR / "frpc.log"
AUTH_PORT = 8083
process = None
auth_server = None
class AuthHandler(BaseHTTPRequestHandler):
"""Serves only GET /glxyauth — returns the PIN hash file contents."""
def do_GET(self):
if self.path == "/glxyauth":
auth_file = GALAXY_DIR / "glxyauth"
if auth_file.exists():
data = auth_file.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # suppress request logs
def start_auth_server():
global auth_server
if auth_server is not None:
return
auth_server = HTTPServer(("127.0.0.1", AUTH_PORT), AuthHandler)
thread = threading.Thread(target=auth_server.serve_forever, daemon=True)
thread.start()
print(f"Galaxy: Auth server listening on 127.0.0.1:{AUTH_PORT}")
def cleanup_frpc(*_):
global process
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process = None
def get_arch_url():
arch = platform.machine()
if arch in ("aarch64", "arm64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_arm64.tar.gz", f"frp_{FRPC_VERSION}_linux_arm64"
elif arch in ("x86_64", "amd64"):
return f"https://github.com/fatedier/frp/releases/download/v{FRPC_VERSION}/frp_{FRPC_VERSION}_linux_amd64.tar.gz", f"frp_{FRPC_VERSION}_linux_amd64"
return None, None
def setup_frpc():
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
frpc_bin = GALAXY_DIR / "frpc"
if not frpc_bin.exists():
print("Galaxy: Downloading frpc...")
url, folder_name = get_arch_url()
if not url:
print("Galaxy: Unsupported architecture")
return False
tar_path = GALAXY_DIR / "frp.tar.gz"
try:
urllib.request.urlretrieve(url, tar_path)
with tarfile.open(tar_path, "r:gz") as tar:
tar.extractall(path=GALAXY_DIR, filter='data')
# Move binary
extracted_bin = GALAXY_DIR / folder_name / "frpc"
extracted_bin.rename(frpc_bin)
frpc_bin.chmod(0o755)
# Cleanup
tar_path.unlink()
shutil.rmtree(GALAXY_DIR / folder_name)
print("Galaxy: frpc downloaded and installed.")
except Exception as e:
print(f"Galaxy: Failed to install frpc: {e}")
return False
return True
def main():
global process
params = Params()
signal.signal(signal.SIGTERM, cleanup_frpc)
signal.signal(signal.SIGINT, cleanup_frpc)
# Wait for DongleId to be set (usually set on boot/pairing)
dongle_id = params.get("DongleId", encoding='utf8')
while not dongle_id:
print("Galaxy: Waiting for DongleId...")
time.sleep(5)
dongle_id = params.get("DongleId", encoding='utf8')
print(f"Galaxy: DongleId: {dongle_id}")
print("Galaxy: Starting manager loop...")
while True:
glxyauth_file = GALAXY_DIR / "glxyauth"
galaxy_pin = glxyauth_file.read_text().strip() if glxyauth_file.exists() else None
is_paired = galaxy_pin and len(galaxy_pin) == 64
if is_paired:
if process is None or process.poll() is not None:
if process is not None:
print(f"Galaxy: frpc exited with code {process.returncode}. Restarting...")
print("Galaxy: PIN set. Preparing frpc tunnel...")
if not setup_frpc():
print("Galaxy: FRPC setup failed. Retrying later...")
time.sleep(10)
continue
# Start the tiny auth HTTP server (serves /glxyauth on localhost)
start_auth_server()
frpc_toml = GALAXY_DIR / "frpc.toml"
config = f"""\
serverAddr = "galaxy.firestar.link"
serverPort = 7000
[transport]
tls.enable = true
poolCount = 2
[[proxies]]
name = "{dongle_id}_pond"
type = "http"
localIP = "127.0.0.1"
localPort = 8082
customDomains = ["{dongle_id}.devices.local"]
transport.useCompression = true
[[proxies]]
name = "{dongle_id}_auth"
type = "http"
localIP = "127.0.0.1"
localPort = {AUTH_PORT}
customDomains = ["auth-{dongle_id}.devices.local"]
"""
frpc_toml.write_text(config)
print("Galaxy: Starting frpc tunnel...")
log_file = open(FRPC_LOG, 'a')
process = subprocess.Popen(
[str(GALAXY_DIR / "frpc"), "-c", str(frpc_toml)],
stdout=log_file,
stderr=log_file
)
else:
if process is not None and process.poll() is None:
print("Galaxy: PIN cleared. Stopping frpc tunnel...")
cleanup_frpc()
time.sleep(3)
if __name__ == "__main__":
main()
@@ -6,7 +6,7 @@
}
.disk .progress {
background: linear-gradient(to right, green 0%, yellow 80%, orange 90%, red 100%);
background: linear-gradient(to right, #5ec8c8 0%, #8b6cc5 60%, #e05577 85%, #c04466 100%);
border-radius: var(--border-radius-md);
height: var(--padding-base);
overflow: hidden;
@@ -78,4 +78,4 @@
gap: 0.5em 1em;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
}
@@ -102,18 +102,18 @@ export function Home() {
return html`
<div>
${() => {
if (state.isLoading) {
return html`<p>Loading...</p>`;
}
if (state.isLoading) {
return html`<p>Loading...</p>`;
}
if (state.error) {
return html`<p class="error">Failed to load data: ${state.error}</p>`;
}
if (state.error) {
return html`<p class="error">Failed to load data: ${state.error}</p>`;
}
if (state.data) {
const { driveStats, firehoseStats, softwareInfo } = state.data;
return html`
<h1>The Pond</h1>
if (state.data) {
const { driveStats, firehoseStats, softwareInfo } = state.data;
return html`
<h1>Galaxy</h1>
<div class="drivingStats">
${DriveStat("All Time", driveStats?.all, state.unit)}
@@ -139,10 +139,10 @@ export function Home() {
<div class="softwareGrid">${renderSoftwareInfo(softwareInfo)}</div>
</div>
`;
}
}
return html`<p>No data available.</p>`;
}}
return html`<p>No data available.</p>`;
}}
</div>
`;
}
@@ -20,47 +20,49 @@
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
/* Colors */
--accent-bg: #673ab7;
--accent-hover-bg: #512da8;
--card-bg: #234423;
/* Colors — Galaxy palette (cosmic purple · teal · rose · amber) */
--accent-bg: #8b6cc5;
--accent-hover-bg: #7558b0;
--card-bg: #121224;
--color-black: #000000;
--color-confirm: #1a73e8;
--color-confirm-hover: #0758ad;
--color-gray-100: #f5f5f5;
--color-gray-200: #e0e0e0;
--color-gray-300: #c2c2c2;
--color-gray-400: #a4a4a4;
--color-gray-500: #8f8f8f;
--color-gray-600: #737373;
--color-gray-700: #595959;
--color-gray-800: #333333;
--color-gray-900: #1a1a1a;
--color-confirm: #8b6cc5;
--color-confirm-hover: #7558b0;
--color-gray-100: #f0f0f8;
--color-gray-200: #d8d8e4;
--color-gray-300: #b8b8cc;
--color-gray-400: #9898b0;
--color-gray-500: #7e7e98;
--color-gray-600: #636380;
--color-gray-700: #4a4a64;
--color-gray-800: #2e2e44;
--color-gray-900: #1a1a30;
--color-white: #ffffff;
--danger-bg: #b71c1c;
--danger-fg: #ef1313;
--danger-hover-bg: #d32f2f;
--input-bg: #2f5432;
--main-bg: #0b1b0b;
--main-fg: #178643;
--secondary-bg: #264026;
--selected-camera-bg: #1f2f1f;
--sidebar-active-bg: #64c87826;
--sidebar-bg: #264026;
--sidebar-border-color: #1e2e1e;
--danger-bg: #e05577;
--danger-fg: #e05577;
--danger-hover-bg: #c04466;
--glow-primary: 0 0 0 2px var(--main-fg), 0 0 10px rgba(139, 108, 197, 0.35);
--input-bg: #161630;
--main-bg: #06060f;
--main-fg: #8b6cc5;
--secondary-bg: #0e0e1a;
--selected-camera-bg: #0b0b18;
--sidebar-active-bg: rgba(139, 108, 197, 0.15);
--sidebar-bg: #0a0a16;
--sidebar-border-color: #1e1e3e;
--sidebar-fg: #ffffff;
--sidebar-title-fg: #193446;
--success-bg: #039226;
--success-fg: #00a100;
--success-hover-bg: #01be2d;
--text-color: #ffffff;
--text-muted: #a0a0a0;
--sidebar-title-fg: #8b6cc5;
--success-bg: #5ec8c8;
--success-fg: #5ec8c8;
--success-hover-bg: #4ab3b3;
--switch-inactive-bg: #1e1e3e;
--text-color: #e8e8f0;
--text-muted: #8080a8;
--text-on-primary: var(--sidebar-fg);
--text-on-surface: var(--text-color);
--thumb-color: #178643;
--track-color: #1a3a1a;
--warning-bg: #ff9800;
--warning-hover-bg: #f57c00;
--thumb-color: #8b6cc5;
--track-color: #14142e;
--warning-bg: #d4a060;
--warning-hover-bg: #b8884a;
/* Effects */
--disabled-opacity: 0.5;
@@ -71,7 +73,7 @@
--hover-scale-lg: scale(1.1);
/* Fonts */
--font-body: "Open Sans", sans-serif;
--font-body: "Inter", "Open Sans", sans-serif;
--font-mono: "Courier New", Courier, monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.85rem;
@@ -96,9 +98,9 @@
--width-xxxxl: 1200px;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
--shadow-md: 0 4px 12px rgba(0,0,0,0.2);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
/* Spacing */
--gap-xxs: 0.125rem;
@@ -172,12 +174,14 @@ input[type="checkbox"],
input[type="radio"],
.manage-keys-link,
.route_card,
.sidebar .menu_section > li > ul > li,
.sidebar .menu_section>li>ul>li,
.clickable {
cursor: pointer;
}
body { padding-bottom: 0 !important; }
body {
padding-bottom: 0 !important;
}
/* ――― Layout containers ――― */
.content {
@@ -199,9 +203,17 @@ h3 {
}
/* ――― Helpers & states ――― */
.hidden { display: none; }
html { cursor: default; }
.no_scroll { overflow: hidden; }
.hidden {
display: none;
}
html {
cursor: default;
}
.no_scroll {
overflow: hidden;
}
.not_implemented {
cursor: not-allowed;
@@ -251,7 +263,9 @@ textarea {
cursor: text;
}
label[for] { cursor: default; }
label[for] {
cursor: default;
}
/* ――― Snackbar component ――― */
.snackbar {
@@ -297,6 +311,7 @@ a {
bottom: 0;
opacity: 0;
}
to {
bottom: var(--snackbar-offset, 30px);
opacity: 1;
@@ -304,13 +319,20 @@ a {
}
@keyframes fadeout {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* ――― Breakpoint overrides ――― */
@media only screen and (max-width: var(--breakpoint-md)) {
.content { margin-left: 0; }
.content {
margin-left: 0;
}
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
@@ -320,6 +342,7 @@ a {
padding-left: 1rem;
padding-right: 1rem;
}
#snackbar_wrapper {
left: 50%;
transform: translateX(-50%);
@@ -333,6 +356,7 @@ a {
bottom: 0;
opacity: 0;
}
to {
bottom: var(--snackbar-offset, 30px);
opacity: 1;
@@ -340,18 +364,62 @@ a {
}
@-webkit-keyframes fadeout {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* ——— Tunnel notice banner ——— */
.tunnel-notice {
align-items: center;
background: linear-gradient(135deg, var(--card-bg), var(--secondary-bg));
border: var(--border-width-thin) var(--border-style-base) var(--sidebar-border-color);
border-left: 3px solid var(--main-fg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
color: var(--text-color);
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin: var(--margin-xl) auto;
max-width: var(--width-xl);
padding: var(--padding-xl) var(--padding-xxl);
text-align: center;
}
.tunnel-notice-icon {
font-size: 2.5rem;
opacity: 0.8;
}
.tunnel-notice-title {
color: var(--text-color);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin: 0;
}
.tunnel-notice-body {
color: var(--text-muted);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
margin: 0;
}
::-webkit-scrollbar {
height: 8px;
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--track-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--thumb-color);
border-radius: var(--border-radius-sm);
}
}
@@ -109,7 +109,7 @@
.favorites-toggle-button:hover {
background-color: var(--main-fg);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
color: var(--text-color);
font-weight: var(--font-weight-bold);
transform: var(--hover-scale-sm);
@@ -352,7 +352,7 @@
.navigation-summary-widget button.directions:hover {
background-color: var(--success-hover-bg);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
color: var(--text-color);
transform: var(--hover-scale-sm);
}
@@ -466,12 +466,12 @@
}
.search-provider-toggle button.active {
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.search-provider-toggle button:hover {
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
@@ -533,4 +533,4 @@
min-width: 0;
width: calc(100% - var(--padding-xl));
}
}
}
@@ -47,7 +47,7 @@
transition: opacity 0.5s ease;
}
.navkeys-group > .navkeys-row:last-of-type {
.navkeys-group>.navkeys-row:last-of-type {
margin-bottom: 0;
}
@@ -79,7 +79,7 @@
.navkeys-input:hover,
.navkeys-input:focus {
border-color: var(--thumb-color);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
@@ -153,4 +153,4 @@
.navkeys-input {
font-size: var(--font-size-sm);
}
}
}
@@ -1,4 +1,5 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js"
import { Modal } from "/assets/components/modal.js";
@@ -124,27 +125,27 @@ async function deleteRoute(route) {
}
async function resetRouteName(route, dlg) {
const res = await fetch(`/api/routes/reset_name`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: route.name })
});
if (res.ok) {
const { timestamp } = await res.json();
closeDialog(dlg);
const routeInList = state.routes.find(r => r.name === route.name);
if (routeInList) {
routeInList.timestamp = formatRouteDate(timestamp);
}
route.timestamp = formatRouteDate(timestamp);
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = formatRouteDate(timestamp);
}
showSnackbar("Route name reset!");
} else {
showSnackbar("Resetting name failed...", "error");
const res = await fetch(`/api/routes/reset_name`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: route.name })
});
if (res.ok) {
const { timestamp } = await res.json();
closeDialog(dlg);
const routeInList = state.routes.find(r => r.name === route.name);
if (routeInList) {
routeInList.timestamp = formatRouteDate(timestamp);
}
route.timestamp = formatRouteDate(timestamp);
const overlayTitleSpan = overlay.querySelector(".media-player-title span");
if (overlayTitleSpan) {
overlayTitleSpan.textContent = formatRouteDate(timestamp);
}
showSnackbar("Route name reset!");
} else {
showSnackbar("Resetting name failed...", "error");
}
}
async function renameRoute(route) {
@@ -317,6 +318,16 @@ async function deleteAllRoutes() {
}
export function RouteRecordings() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰️</div>
<h3 class="tunnel-notice-title">Dashcam Routes Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Loading dashcam routes requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute);
return html`
@@ -332,75 +343,75 @@ export function RouteRecordings() {
</button>
${() => {
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
if (routesToShow.length === 0) {
if (state.loading && state.total > 0) {
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
}
if (state.loading && !state.isDeletingAll) {
return html`<p class="screen-recordings-message">Loading...</p>`;
}
if (state.isDeletingAll) {
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
}
if (state.showPreservedOnly) {
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
}
if (state.error) {
return html`<p class="screen-recordings-message">${state.error}</p>`;
}
return html`<p class="screen-recordings-message">No routes found...</p>`;
}
if (routesToShow.length === 0) {
if (state.loading && state.total > 0) {
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
}
if (state.loading && !state.isDeletingAll) {
return html`<p class="screen-recordings-message">Loading...</p>`;
}
if (state.isDeletingAll) {
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
}
if (state.showPreservedOnly) {
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
}
if (state.error) {
return html`<p class="screen-recordings-message">${state.error}</p>`;
}
return html`<p class="screen-recordings-message">No routes found...</p>`;
}
return html`
return html`
<div class="screen-recordings-grid">
${routesToShow.map(
route => html`
route => html`
<div
class="recording-card"
@mouseenter="${e => {
if (state.selectedRoute) return;
if (state.selectedRoute) return;
const card = e.currentTarget;
const gif = card.querySelector(".recording-preview-gif");
const png = card.querySelector(".recording-preview-png");
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;
}
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;
};
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;
}}"
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;
}
}}"
const card = e.currentTarget;
card.querySelector(".recording-preview-png").style.display = "block";
card.querySelector(".recording-preview-gif").style.display = "none";
if (card.dataset.loadingGif === "true") {
delete card.dataset.loadingGif;
}
}}"
@click="${() => {
state.selectedRoute = route;
}}"
state.selectedRoute = route;
}}"
>
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
@@ -410,6 +421,7 @@ export function RouteRecordings() {
src="${route.png}"
class="recording-preview recording-preview-png"
style="display:block;"
loading="lazy"
>
<img
data-src="${route.gif}"
@@ -420,13 +432,13 @@ export function RouteRecordings() {
<p class="recording-filename">${route.timestamp}</p>
</div>
`
)}
)}
</div>
`;
}}
}}
${() => {
if (state.routes.length > 0) {
return html`
if (state.routes.length > 0) {
return html`
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
@@ -435,17 +447,17 @@ export function RouteRecordings() {
${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
</button>
`;
}
return "";
}}
}
return "";
}}
</div>
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all routes? This action cannot be undone...",
onConfirm: deleteAllRoutes,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
title: "Confirm Delete All",
message: "Are you sure you want to delete all routes? This action cannot be undone...",
onConfirm: deleteAllRoutes,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
</div>
`;
}
@@ -1,4 +1,5 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js";
const state = reactive({
@@ -142,8 +143,8 @@ async function renameFile(rec) {
}
function confirmDeleteFile(rec) {
state.recordingToDelete = rec;
state.showDeleteModal = true;
state.recordingToDelete = rec;
state.showDeleteModal = true;
}
async function deleteFile() {
@@ -152,11 +153,11 @@ async function deleteFile() {
const res = await fetch(`/api/screen_recordings/delete/${encodeURIComponent(rec.filename)}`, { method: "DELETE" })
if (res.ok) {
closeOverlay();
refresh();
showSnackbar("Recording deleted!");
closeOverlay();
refresh();
showSnackbar("Recording deleted!");
} else {
showSnackbar("Delete failed...", "error");
showSnackbar("Delete failed...", "error");
}
state.showDeleteModal = false;
@@ -221,6 +222,16 @@ async function deleteAllRecordings() {
}
export function ScreenRecordings() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰️</div>
<h3 class="tunnel-notice-title">Screen Recordings Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Loading screen recordings requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (state.selectedRecording && !overlay) openOverlay(state.selectedRecording)
return html`
@@ -229,77 +240,77 @@ export function ScreenRecordings() {
<div class="screen-recordings-title">Screen Recordings</div>
${() => {
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
if (state.progress > 0 && state.progress < state.total) {
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
}
if (state.recordings.length === 0 && !state.loading) {
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
}
return ""
}}
if (state.loading && state.recordings.length === 0) return html`<p class="screen-recordings-message">Loading...</p>`
if (state.error) return html`<p class="screen-recordings-message">${state.error}</p>`
if (state.progress > 0 && state.progress < state.total) {
return html`<p class="screen-recordings-message">Processing Recordings: ${state.progress} of ${state.total}</p>`
}
if (state.recordings.length === 0 && !state.loading) {
return html`<p class="screen-recordings-message">No screen recordings found...</p>`
}
return ""
}}
<div class="screen-recordings-grid">
${() => state.recordings.map(rec => {
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
return html`
const displayName = rec.is_custom_name ? rec.filename.replace(/\.mp4$/i, "").replace(/_/g, " ") : formatScreenRecordingDate(rec.timestamp)
return html`
<div
class="recording-card"
@mouseenter="${e => {
if (state.selectedRecording) return;
if (state.selectedRecording) return;
const card = e.currentTarget;
const gif = card.querySelector(".recording-preview-gif");
const png = card.querySelector(".recording-preview-png");
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;
}
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;
};
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;
}}"
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;
}
}}"
const card = e.currentTarget;
card.querySelector(".recording-preview-png").style.display = "block";
card.querySelector(".recording-preview-gif").style.display = "none";
if (card.dataset.loadingGif === "true") {
delete card.dataset.loadingGif;
}
}}"
@click="${() => { state.selectedRecording = rec }}"
>
<div class="recording-preview-container">
<img src="${rec.png}" class="recording-preview recording-preview-png" style="display:block;">
<img 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>
`
})}
})}
</div>
${() => {
if (state.recordings.length > 0) {
return html`
if (state.recordings.length > 0) {
return html`
<button
class="delete-all-button"
@click="${() => (state.showDeleteAllModal = true)}"
@@ -307,24 +318,24 @@ export function ScreenRecordings() {
Delete All Recordings
</button>
`
}
return ""
}}
}
return ""
}}
</div>
${() => state.showDeleteModal ? Modal({
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
onConfirm: deleteFile,
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
confirmText: "Delete"
}) : ""}
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${state.recordingToDelete.filename}</strong>?`,
onConfirm: deleteFile,
onCancel: () => { state.showDeleteModal = false; state.recordingToDelete = null; },
confirmText: "Delete"
}) : ""}
${() => state.showDeleteAllModal ? Modal({
title: "Confirm Delete All",
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
onConfirm: deleteAllRecordings,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
title: "Confirm Delete All",
message: "Are you sure you want to delete all screen recordings? This action cannot be undone...",
onConfirm: deleteAllRecordings,
onCancel: () => { state.showDeleteAllModal = false; },
confirmText: "Delete All"
}) : ""}
</div>
`
}
@@ -1,6 +1,7 @@
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 { Home } from "/assets/components/home/home.js"
@@ -11,7 +12,6 @@ import { SettingsView } from "/assets/components/settings.js"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
@@ -23,13 +23,14 @@ function createRoute(id, path, component) {
return {
id,
path,
loader: () => {},
loader: () => { },
element: component,
}
}
function Root() {
let routes = [
createRoute("device_settings", "/device_settings", DeviceSettings),
createRoute("doors", "/lock_or_unlock_doors", DoorControl),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("navdestination", "/set_navigation_destination", NavDestination),
@@ -39,7 +40,6 @@ function Root() {
createRoute("screen_recordings", "/screen_recordings", ScreenRecordings),
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("tailscale", "/manage_tailscale", TailscaleControl),
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("tmux", "/manage_tmux", TmuxLog),
createRoute("toggles", "/manage_toggles", ToggleControl),
@@ -76,17 +76,17 @@ function Root() {
${() => Sidebar(routerState.activePathFull)}
<div class="content">
${() => {
if (!routerState.initialized || routerState.navigation.state === "loading") {
return html`<div>Loading...</div>`
}
if (!routerState.initialized || routerState.navigation.state === "loading") {
return html`<div>Loading...</div>`
}
if (routerState.errors?.root?.status === 404) {
return html`<h1>Not Found</h1>`
}
if (routerState.errors?.root?.status === 404) {
return html`<h1>Not Found</h1>`
}
const match = routes.find(r => r.path === routerState.activePath)
return match.element({ params: routerState.params })
}}
const match = routes.find(r => r.path === routerState.activePath)
return match.element({ params: routerState.params })
}}
</div>
`
}
@@ -30,7 +30,7 @@
position: relative;
}
.dropdown > select {
.dropdown>select {
appearance: none;
background-color: var(--sidebar-bg);
border: var(--border-width-thin) solid var(--sidebar-border-color);
@@ -74,19 +74,19 @@ input.searchfield:focus {
outline: none;
}
input:checked + .slider {
input:checked+.slider {
background-color: var(--success-bg);
}
input:checked + .slider:before {
input:checked+.slider:before {
transform: translateX(26px);
}
input:checked + .slider.loading:before {
input:checked+.slider.loading:before {
animation: rotationChecked 1s linear infinite;
}
input:focus + .slider {
input:focus+.slider {
box-shadow: 0 0 var(--border-width-thin) var(--success-bg);
}
@@ -133,11 +133,11 @@ input:focus + .slider {
z-index: 1;
}
.options > input {
.options>input {
display: none;
}
.options > input:checked + label {
.options>input:checked+label {
background-color: var(--success-bg);
color: var(--text-color);
font-weight: bold;
@@ -220,14 +220,13 @@ input:focus + .slider {
grid-column: span 2;
}
.setting.subsetting_link {
}
.setting.subsetting_link {}
.setting.subtoggle {
margin: 0 0 0 var(--padding-xl);
}
.setting.subtoggle + .setting {
.setting.subtoggle+.setting {
margin-top: var(--border-radius-xl);
}
@@ -300,7 +299,7 @@ input.searchfield {
animation: rotation 1s linear infinite;
background-color: none !important;
border: var(--border-width-thick) solid var(--sidebar-fg);
border-bottom-color: #46439b;
border-bottom-color: var(--main-fg);
box-sizing: border-box;
}
@@ -337,6 +336,7 @@ i.switch {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
@@ -346,7 +346,8 @@ i.switch {
0% {
transform: translateX(26px) rotate(0deg);
}
100% {
transform: translateX(26px) rotate(360deg);
}
}
}
@@ -60,7 +60,10 @@
}
.sidebar {
background-color: var(--sidebar-bg);
background:
radial-gradient(ellipse at 30% 80%, rgba(139, 108, 197, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 70% 20%, rgba(94, 200, 200, 0.04) 0%, transparent 50%),
var(--sidebar-bg);
border-right: var(--border-width-thin) solid var(--sidebar-border-color);
display: flex;
flex-direction: column;
@@ -102,7 +105,7 @@
padding: 0;
}
.sidebar .menu_section > li > a span {
.sidebar .menu_section>li>a span {
color: var(--text-color);
display: inline-block;
font-size: var(--font-size-lg);
@@ -111,13 +114,13 @@
padding-left: var(--padding-sm);
}
.sidebar .menu_section > li > ul {
.sidebar .menu_section>li>ul {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar .menu_section > li > ul > li {
.sidebar .menu_section>li>ul>li {
border-radius: var(--border-radius-sm);
margin-bottom: var(--padding-sm);
overflow: hidden;
@@ -125,23 +128,23 @@
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.sidebar .menu_section > li > ul > li > a.menu-item-link {
.sidebar .menu_section>li>ul>li>a.menu-item-link {
border-radius: inherit;
}
.sidebar .menu_section > li > ul > li.active,
.sidebar .menu_section > li > ul > li:hover {
.sidebar .menu_section>li>ul>li.active,
.sidebar .menu_section>li>ul>li:hover {
background-color: var(--sidebar-active-bg);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
.sidebar .menu_section > li > ul > li.active > a {
.sidebar .menu_section>li>ul>li.active>a {
color: var(--color-white);
font-weight: var(--font-weight-bold);
}
.sidebar .menu_section > li > ul > li:hover > a {
.sidebar .menu_section>li>ul>li:hover>a {
color: var(--color-white);
font-weight: var(--font-weight-demi-bold);
}
@@ -203,9 +206,13 @@
}
.sidebar_header p {
color: var(--success-hover-bg);
background: linear-gradient(135deg, #8b6cc5 0%, #5ec8c8 55%, #d4789c 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
letter-spacing: 0.05em;
}
.sidebar_widget {
@@ -234,7 +241,7 @@
transition: var(--transition-fast);
}
.sidebar .menu_section > li > a {
.sidebar .menu_section>li>a {
font-size: var(--padding-sm);
}
@@ -262,7 +269,7 @@
transition: var(--transition-fast);
}
.sidebar .menu_section > li > a {
.sidebar .menu_section>li>a {
font-size: var(--padding-sm);
}
@@ -277,4 +284,4 @@
#menu_button {
display: none;
}
}
}
@@ -14,10 +14,8 @@ const MenuItems = {
{ name: "Dashcam Routes", link: "/dashcam_routes", icon: "bi-camera-reels" },
{ name: "Screen Recordings", link: "/screen_recordings", icon: "bi-record-circle" },
],
tailscale: [
{ name: "Tailscale", link: "/manage_tailscale", icon: "bi-wifi" },
],
tools: [
{ name: "Device Settings", link: "/device_settings", icon: "bi-sliders" },
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
@@ -88,10 +86,9 @@ export function Sidebar() {
<div id="sidebar" class="sidebar">
<div>
<div class="title">
<img class="logo" src="/assets/images/main_logo.png" alt="FrogPilot logo" />
<img class="logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<div class="title_text sidebar_header">
<p>The Pond</p>
<a href="https://github.com/Aidenir">by&nbsp;Aidenir</a>
<p>Galaxy</p>
</div>
</div>
<hr />
@@ -102,30 +99,30 @@ 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 === "Lock/Unlock Doors" && !state.doorsVisible) {
return "";
}
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
return "";
}
if (link.name === "Toyota Security Keys" && !state.tskVisible) {
return "";
}
const isActive = state.activeRoute === link.name;
const classList = [isActive && "active"].filter(Boolean).join(" ");
const isActive = state.activeRoute === link.name;
const classList = [isActive && "active"].filter(Boolean).join(" ");
const content = html`
const content = html`
<div class="menu-item-link">
<i class="bi ${link.icon}"></i>
<span>${upperFirst(link.name)}</span>
</div>
`;
return html`
return html`
<li class="${classList}">
${Link(link.link, content, () => navigate(link))}
</li>
`;
})}
})}
</ul>
</li>
</ul>
@@ -52,13 +52,14 @@ export function TailscaleControl() {
checkInstallStatus()
return html`
<div class="tailscale-wrapper">
<div class="toggle-control-widget" style="margin-top: 1.5rem">
<section class="tailscale-widget">
<div class="tailscale-title">
<div class="toggle-control-title">
${() => state.installed ? 'Uninstall Tailscale' : 'Install Tailscale'}
</div>
<p class="tailscale-text">
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!
Tailscale creates a secure, private connection between your openpilot device and your phone or PC so you can access and control it from anywhere!<br><br>
<strong style="color: #ff9494;">Note: Not recommended. Using Galaxy Tunnel is the preferred remote connection method.</strong>
</p>
<div class="tailscale-button-wrapper">
<button
@@ -67,11 +68,11 @@ export function TailscaleControl() {
disabled="${() => state.status === 'installing' || state.status === 'uninstalling'}"
>
${() => {
if (state.status === 'installing') return 'Installing...'
if (state.status === 'uninstalling') return 'Uninstalling...'
if (state.installed) return 'Uninstall'
return 'Install'
}}
if (state.status === 'installing') return 'Installing...'
if (state.status === 'uninstalling') return 'Uninstalling...'
if (state.installed) return 'Uninstall'
return 'Install'
}}
</button>
<a class="tailscale-link" href="https://tailscale.com/download" target="_blank">
Download Tailscale on your other devices
@@ -79,12 +80,12 @@ export function TailscaleControl() {
</div>
</section>
${() => state.showUninstallModal ? Modal({
title: "Confirm Uninstall",
message: "Are you sure you want to uninstall Tailscale?",
onConfirm: handleAction,
onCancel: () => { state.showUninstallModal = false; },
confirmText: "Uninstall"
}) : ""}
title: "Confirm Uninstall",
message: "Are you sure you want to uninstall Tailscale?",
onConfirm: handleAction,
onCancel: () => { state.showUninstallModal = false; },
confirmText: "Uninstall"
}) : ""}
</div>
`
}
@@ -0,0 +1,336 @@
/* ――― Device Settings Page ――― */
.ds-wrapper {
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
/* ――― Search / Filter ――― */
.ds-search {
background-color: var(--input-bg);
border: var(--border-style-input);
border-radius: var(--border-radius-lg);
box-sizing: border-box;
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-base);
margin-bottom: var(--margin-lg);
outline: none;
padding: var(--padding-sm) var(--padding-base);
transition: box-shadow var(--transition-fast);
width: 100%;
}
.ds-search:focus {
box-shadow: 0 0 0 2px var(--main-fg);
}
/* ――― Section Cards ――― */
.ds-section {
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--margin-lg);
overflow: hidden;
transition: box-shadow var(--transition-fast);
}
.ds-section:hover {
box-shadow: var(--shadow-md);
}
.ds-section-header {
align-items: center;
background-color: var(--input-bg);
display: flex;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
user-select: none;
}
.ds-section-header i {
color: var(--main-fg);
font-size: var(--font-size-lg);
margin-right: var(--margin-sm);
min-width: 1.5rem;
text-align: center;
}
.ds-section-title {
color: var(--text-color);
flex: 1;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
}
.ds-section-chevron {
color: var(--text-muted);
font-size: var(--font-size-sm);
transition: transform var(--transition-fast);
}
.ds-section.collapsed .ds-section-chevron {
transform: rotate(-90deg);
}
.ds-section-body {
max-height: 5000px;
overflow: hidden;
transition: max-height 0.3s ease-in-out;
}
.ds-section.collapsed .ds-section-body {
max-height: 0;
}
/* ――― Individual Toggle Rows ――― */
.ds-row {
align-items: center;
border-top: 1px solid var(--sidebar-border-color);
display: flex;
justify-content: space-between;
padding: var(--padding-sm) var(--padding-base);
transition: background-color var(--transition-fast);
}
.ds-row:hover {
background-color: var(--sidebar-active-bg);
}
.ds-row-label {
color: var(--text-color);
flex: 1;
font-size: var(--font-size-sm);
margin-right: var(--margin-base);
}
.ds-row-desc {
color: var(--text-muted);
font-size: var(--font-size-xs);
margin-top: 0.25rem;
line-height: 1.3;
white-space: pre-line;
}
/* ――― Child Row Modifier (Sub-menus) ――― */
.ds-child-modifier {
border-left: 2px solid var(--color-gray-200);
margin-left: 1rem;
padding-left: 1rem;
}
.ds-manage-btn {
display: inline-flex;
align-items: center;
background-color: var(--color-gray-200);
border: 1px solid var(--color-gray-300);
border-radius: 1rem;
color: var(--main-fg);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
margin-top: 0.6rem;
padding: 0.25rem 0.75rem;
transition: all var(--transition-fast);
user-select: none;
width: fit-content;
}
.ds-manage-btn:hover {
background-color: var(--color-gray-300);
}
.ds-manage-btn i {
margin-left: 0.3rem;
}
/* ――― Toggle Switch ――― */
.ds-toggle {
appearance: none;
background-color: var(--track-color);
border: none;
border-radius: 1rem;
flex-shrink: 0;
height: 1.5rem;
outline: none;
position: relative;
transition: background-color var(--transition-fast);
width: 2.75rem;
}
.ds-toggle::after {
background-color: var(--color-gray-300);
border-radius: 50%;
content: "";
height: 1.1rem;
left: 0.2rem;
position: absolute;
top: 0.2rem;
transition: transform var(--transition-fast), background-color var(--transition-fast);
width: 1.1rem;
}
.ds-toggle:checked {
background-color: var(--main-fg);
}
.ds-select {
appearance: none;
background-color: var(--color-gray-950);
border: 1px solid var(--track-color);
border-radius: 0.5rem;
color: var(--color-gray-200);
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
padding: 0.5rem 2rem 0.5rem 1rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
width: 100%;
max-width: 250px;
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/200.svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1em;
}
.ds-select:hover {
border-color: var(--color-gray-500);
}
.ds-select:focus {
border-color: var(--main-fg);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
.ds-toggle:checked::after {
background-color: var(--color-white);
transform: translateX(1.25rem);
}
.ds-toggle:disabled {
opacity: var(--disabled-opacity);
}
/* ――― Loading State ――― */
.ds-loading {
color: var(--text-muted);
padding: var(--padding-xl);
text-align: center;
}
.ds-status-bar {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: var(--font-size-xs);
gap: var(--gap-sm);
justify-content: space-between;
margin-bottom: var(--margin-sm);
}
/* ――― Empty Filter State ――― */
.ds-empty {
color: var(--text-muted);
font-style: italic;
padding: var(--padding-lg);
text-align: center;
}
/* ――― Custom Range Slider ――― */
.ds-row-numeric {
align-items: flex-start;
flex-direction: column;
}
.ds-row-info {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: var(--margin-sm);
width: 100%;
}
.ds-row-value {
background-color: var(--sidebar-bg);
border: var(--border-style-main);
border-radius: var(--border-radius-base);
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-sm);
padding: 0.2rem 0.6rem;
}
.ds-slider-container {
width: 100%;
}
@media (min-width: 768px) {
.ds-slider-container {
max-width: 350px;
}
}
.ds-slider {
appearance: none;
background: transparent;
padding-bottom: var(--padding-sm);
width: 100%;
}
.ds-slider:focus {
outline: none;
}
.ds-slider::-webkit-slider-runnable-track {
background: var(--track-color);
border-radius: var(--border-radius-base);
height: 0.4rem;
width: 100%;
}
.ds-slider::-webkit-slider-thumb {
appearance: none;
background: var(--main-fg);
border-radius: 50%;
box-shadow: var(--shadow-sm);
cursor: pointer;
height: 1.2rem;
margin-top: -0.4rem;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
width: 1.2rem;
}
.ds-slider::-webkit-slider-thumb:hover {
box-shadow: var(--shadow-md);
transform: scale(1.15);
}
.ds-slider::-moz-range-track {
background: var(--track-color);
border-radius: var(--border-radius-base);
height: 0.4rem;
width: 100%;
}
.ds-slider::-moz-range-thumb {
background: var(--main-fg);
border: none;
border-radius: 50%;
box-shadow: var(--shadow-sm);
cursor: pointer;
height: 1.2rem;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
width: 1.2rem;
}
.ds-slider::-moz-range-thumb:hover {
box-shadow: var(--shadow-md);
transform: scale(1.15);
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.ds-wrapper {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
}
@@ -0,0 +1,321 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
// ―――――――――――――――――――――――――――――――
// Module-level state (persists across re-renders)
// ―――――――――――――――――――――――――――――――
const state = reactive({
layout: [],
allKeys: [],
values: {},
loadingLayout: true,
loadingValues: true,
filter: "",
collapsed: {},
expanded: {},
updatingKeys: {},
fetched: false,
})
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 layoutData = await layoutRes.json()
state.layout = layoutData
// Extract flatter key map
const keys = []
for (const section of layoutData) {
for (const p of section.params) {
keys.push(p.key)
}
}
state.allKeys = keys
} catch (e) {
console.error("Failed to fetch UI layout:", e)
}
state.loadingLayout = false
// 2. Fetch Live Values (Device State)
try {
const res = await fetch("/api/params/all")
const data = await res.json()
state.values = data
} catch (e) {
console.error("Failed to fetch param values:", e)
}
state.loadingValues = false
requestAnimationFrame(syncInputs)
}
function syncInputs() {
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")
if (endpoint && !el.dataset.hydrated) {
el.dataset.hydrated = "1"
fetch(endpoint).then(r => r.json()).then(options => {
el.innerHTML = ""
for (const opt of options) {
const o = document.createElement("option")
o.value = opt.value
o.textContent = opt.label
el.appendChild(o)
}
el.value = state.values[key] || ""
}).catch(() => { el.innerHTML = '<option value="">Error loading</option>' })
} else {
el.value = 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)
}
}
}
}
}
function formatSliderValue(val, stepStr, precisionInt, key) {
if (val === null || val === undefined) return "--"
const v = parseFloat(val)
if (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"
]
if (key && volumeKeys.includes(key)) {
if (v === 0) return "Muted"
if (v === 101) return "Auto"
return `${v}%`
}
if (precisionInt !== undefined && precisionInt !== null) {
return Number(v.toFixed(precisionInt)).toString()
}
if (!stepStr || !stepStr.includes(".")) return Math.round(v).toString()
const dec = stepStr.split(".")[1].length
return Number(v.toFixed(dec)).toString()
}
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
let formattedVal
if (elType === "checkbox") {
formattedVal = current ? false : true
} else if (elType === "dropdown") {
formattedVal = el.value
} else {
// Numeric slider - coerce to float
formattedVal = parseFloat(el.value)
}
try {
const res = await fetch("/api/params", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value: formattedVal }),
})
const data = await res.json()
if (res.ok) {
state.values = { ...state.values, [key]: formattedVal }
showSnackbar(data.message || `${key} updated`)
} else {
revertInput(key, current, elType)
showSnackbar(data.error || "Failed to update parameter")
}
} catch (e) {
revertInput(key, current, elType)
showSnackbar("Network error — is the device reachable?")
}
}
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 || ""
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)
}
}
}
}
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)
}
}
function toggleSection(name) {
state.collapsed = { ...state.collapsed, [name]: !state.collapsed[name] }
setTimeout(syncInputs, 50)
}
function toggleManage(key) {
state.expanded = { ...state.expanded, [key]: !state.expanded[key] }
setTimeout(syncInputs, 50)
}
function matchesFilter(p) {
if (!state.filter) return true
const q = state.filter.toLowerCase()
return p.label.toLowerCase().includes(q) || p.key.toLowerCase().includes(q)
}
// ―――――――――――――――――――――――――――――――
// Component
// ―――――――――――――――――――――――――――――――
export function DeviceSettings() {
if (!state.fetched) {
state.fetched = true
fetchLayoutAndParams()
}
return html`
<div class="ds-wrapper">
<h2>Device Settings</h2>
<input
class="ds-search"
type="text"
placeholder="Search settings..."
@input="${(e) => { state.filter = e.target.value }}"
/>
${() => {
if (state.loadingLayout || state.loadingValues) {
return html`<div class="ds-loading">Loading configuration...</div>`
}
const loadedKeys = state.allKeys.length
// Sync DOM inputs after reactive render
requestAnimationFrame(syncInputs)
return html`
<div class="ds-status-bar">
<span>${loadedKeys} settings mapped dynamically</span>
</div>
${state.layout.map(section => {
const visibleParams = section.params.filter(p => matchesFilter(p))
if (visibleParams.length === 0) return ""
const isCollapsed = state.collapsed[section.name]
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 ""
}
const isNumeric = p.ui_type === "numeric" || p.data_type === "float" || p.data_type === "int"
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">
<input
type="range"
class="ds-slider"
id="ds-${p.key}"
min="${p.min !== undefined ? p.min : (p.data_type === 'float' ? 0.0 : 0)}"
max="${p.max !== undefined ? p.max : (p.data_type === 'float' ? 100.0 : 100)}"
step="${p.step !== undefined ? p.step : (p.data_type === 'float' ? 0.01 : 1)}"
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>
</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>`
}
return ""
}}
`
}}
</div>
`
}
File diff suppressed because it is too large Load Diff
@@ -77,11 +77,11 @@
display: none;
}
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox {
.checklist-item input[type="checkbox"]:checked~.custom-checkbox {
background-color: var(--success-bg);
}
.checklist-item input[type="checkbox"]:checked ~ .custom-checkbox::before {
.checklist-item input[type="checkbox"]:checked~.custom-checkbox::before {
transform: translateX(24px);
}
@@ -170,6 +170,7 @@
flex-shrink: 0;
transition: color var(--transition-fast), transform 0.2s;
}
.file-clear-button:hover {
color: var(--danger-hover-bg);
transform: scale(1.1);
@@ -613,7 +614,7 @@ label.file-upload-button {
.turn-signal-input:focus,
.turn-signal-input:hover {
border-color: var(--thumb-color);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
outline: none;
transform: var(--hover-scale-sm);
}
@@ -690,8 +691,15 @@ label.file-upload-button {
}
@keyframes modalFadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media only screen and (max-width: 768px) and (orientation: portrait) {
@@ -713,8 +721,8 @@ label.file-upload-button {
flex-wrap: wrap;
}
.save-button-wrapper > button {
.save-button-wrapper>button {
flex-basis: calc(50% - 1rem);
margin-bottom: var(--gap-sm);
}
}
}
@@ -1,5 +1,5 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { formatSecondsToHuman } from "/assets/js/utils.js"
import { formatSecondsToHuman, isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js";
const logSelectorState = reactive({
@@ -122,34 +122,34 @@ function TmuxLogSelector({ action, closeFn }) {
</div>
${() => {
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
return html`<div class="fileEntry"><p>Loading...</p></div>`;
}
if (logSelectorState.files.length === 0) {
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
}
return logSelectorState.files.map(file => html`
if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) {
return html`<div class="fileEntry"><p>Loading...</p></div>`;
}
if (logSelectorState.files.length === 0) {
return html`<div class="fileEntry"><p>No tmux logs found!</p></div>`;
}
return logSelectorState.files.map(file => html`
<div class="fileEntry" @click="${() => handleFileClick(file)}">
<p><span class="label">Filename:</span> <span class="value">${file.filename}</span></p>
<p><span class="label">Date:</span> <span class="value">${file.date}</span></p>
<p><span class="label">Age:</span> <span class="value">${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}</span></p>
</div>
`);
}}
}}
<button @click="${closeFn}" class="cancel-button">Close</button>
${() => logSelectorState.logToDelete ? Modal({
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
onConfirm: confirmDeleteFile,
onCancel: () => { logSelectorState.logToDelete = null },
confirmText: "Yes, Delete"
}) : ""}
title: "Confirm Delete",
message: `Are you sure you want to delete <strong>${logSelectorState.logToDelete.filename}</strong>?`,
onConfirm: confirmDeleteFile,
onCancel: () => { logSelectorState.logToDelete = null },
confirmText: "Yes, Delete"
}) : ""}
${() => logSelectorState.logToRename ? Modal({
title: "Rename Log",
message: html`
title: "Rename Log",
message: html`
<div>
<p>Rename <strong>${logSelectorState.logToRename.filename}</strong> to:</p>
<div style="margin-top: 10px;">
@@ -163,20 +163,30 @@ function TmuxLogSelector({ action, closeFn }) {
</div>
</div>
`,
onConfirm: confirmRenameFile,
onCancel: () => {
logSelectorState.logToRename = null;
logSelectorState.newName = "";
},
confirmText: "Rename",
confirmClass: "btn-primary"
}) : ""}
onConfirm: confirmRenameFile,
onCancel: () => {
logSelectorState.logToRename = null;
logSelectorState.newName = "";
},
confirmText: "Rename",
confirmClass: "btn-primary"
}) : ""}
</div>
</div>
`
}
export function TmuxLog() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰</div>
<h3 class="tunnel-notice-title">Tmux Log Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Live tmux streaming requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
const state = reactive({
paused: false,
latest: '',
@@ -198,7 +208,7 @@ export function TmuxLog() {
event_source.close();
}
function togglePause () {
function togglePause() {
state.paused = !state.paused;
if (!state.paused) {
state.log = state.latest;
@@ -263,20 +273,20 @@ export function TmuxLog() {
</div>
${() => state.selectorAction
? TmuxLogSelector({
action: state.selectorAction,
closeFn: () => (state.selectorAction = null)
})
: ""
}
? TmuxLogSelector({
action: state.selectorAction,
closeFn: () => (state.selectorAction = null)
})
: ""
}
${() => logSelectorState.showDeleteAllModal ? Modal({
title: "Delete All Logs",
message: "Are you sure you want to delete all of your session logs?",
onConfirm: deleteAllSessions,
onCancel: () => { logSelectorState.showDeleteAllModal = false },
confirmText: "Delete All"
}) : ""}
title: "Delete All Logs",
message: "Are you sure you want to delete all of your session logs?",
onConfirm: deleteAllSessions,
onCancel: () => { logSelectorState.showDeleteAllModal = false },
confirmText: "Delete All"
}) : ""}
</div>
`;
}
@@ -1,7 +1,8 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { Modal } from "/assets/components/modal.js"
import { TailscaleControl } from "/assets/components/tailscale/tailscale.js"
export function ToggleControl () {
export function ToggleControl() {
const state = reactive({
showResetDefaultModal: false,
showResetStockModal: false,
@@ -14,7 +15,7 @@ export function ToggleControl () {
fileInput.addEventListener("change", restoreToggles)
document.body.appendChild(fileInput)
async function backupToggles () {
async function backupToggles() {
const response = await fetch("/api/toggles/backup", { method: "POST" })
const blob = await response.blob()
@@ -26,7 +27,7 @@ export function ToggleControl () {
URL.revokeObjectURL(downloadUrl)
}
async function restoreToggles (event) {
async function restoreToggles(event) {
const uploadedFile = event.target.files[0]
if (uploadedFile) {
const fileContents = await uploadedFile.text()
@@ -45,11 +46,11 @@ export function ToggleControl () {
}
}
function confirmResetDefault () {
function confirmResetDefault() {
state.showResetDefaultModal = true;
}
async function resetTogglesToDefault () {
async function resetTogglesToDefault() {
state.showResetDefaultModal = false;
showSnackbar("Resetting toggles to their default values...");
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -58,11 +59,11 @@ export function ToggleControl () {
await fetch("/api/toggles/reset_default", { method: "POST" });
}
function confirmResetStock () {
function confirmResetStock() {
state.showResetStockModal = true;
}
async function resetTogglesToStock () {
async function resetTogglesToStock() {
state.showResetStockModal = false;
showSnackbar("Resetting toggles to stock openpilot values...");
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -71,7 +72,7 @@ export function ToggleControl () {
await fetch("/api/toggles/reset_stock", { method: "POST" });
}
function triggerRestorePrompt () {
function triggerRestorePrompt() {
fileInput.click()
}
@@ -98,20 +99,22 @@ export function ToggleControl () {
Reset Toggles to Stock
</button>
</section>
${TailscaleControl()}
</div>
${() => state.showResetDefaultModal ? Modal({
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
onConfirm: resetTogglesToDefault,
onCancel: () => { state.showResetDefaultModal = false; },
confirmText: "Reset to Default"
}) : ""}
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to their default FrogPilot values?",
onConfirm: resetTogglesToDefault,
onCancel: () => { state.showResetDefaultModal = false; },
confirmText: "Reset to Default"
}) : ""}
${() => state.showResetStockModal ? Modal({
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to stock openpilot values?",
onConfirm: resetTogglesToStock,
onCancel: () => { state.showResetStockModal = false; },
confirmText: "Reset to Stock"
}) : ""}
title: "Reset Toggles",
message: "Are you sure you want to reset all toggles to stock openpilot values?",
onConfirm: resetTogglesToStock,
onCancel: () => { state.showResetStockModal = false; },
confirmText: "Reset to Stock"
}) : ""}
`
}
@@ -73,7 +73,7 @@
transition: opacity var(--transition-slow);
}
.tskkeys-group > .tskkeys-row:last-of-type {
.tskkeys-group>.tskkeys-row:last-of-type {
margin-bottom: 0;
}
@@ -105,7 +105,7 @@
.tskkeys-input:hover,
.tskkeys-input:focus {
border-color: var(--thumb-color);
box-shadow: 0 0 0 2px var(--thumb-color), 0 0 8px var(--thumb-color);
box-shadow: var(--glow-primary);
transform: var(--hover-scale-sm);
}
@@ -211,4 +211,4 @@
.tskkeys-input {
font-size: var(--font-size-sm);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 B

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 KiB

After

Width:  |  Height:  |  Size: 332 KiB

@@ -65,3 +65,11 @@ export function hideSidebar() {
document.getElementById("sidebarUnderlay")?.classList.add("hidden")
html.classList.remove("no_scroll")
}
/**
* Returns true when the page is being accessed through the Galaxy tunnel
* (the public-facing domain is galaxy.firestar.link)
*/
export function isGalaxyTunnel() {
return window.location.hostname === 'galaxy.firestar.link';
}
@@ -1,5 +1,5 @@
{
"name": "The Pond",
"name": "Galaxy",
"short_name": "",
"icons": [
{
@@ -17,4 +17,4 @@
"background_color": "#151414",
"theme_color": "#151414",
"display": "standalone"
}
}
+51 -48
View File
@@ -1,59 +1,62 @@
<!doctype html>
<html lang="en" id="htmlElement" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
<link rel="manifest" href="/assets/manifest.json">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta name="theme-color" content="#4285f4" />
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png">
<link rel="manifest" href="/assets/manifest.json">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
<meta name="theme-color" content="#8b6cc5" />
<link rel="stylesheet" href="/assets/components/home/home.css">
<link rel="stylesheet" href="/assets/components/main.css">
<link rel="stylesheet" href="/assets/components/modal.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
<link rel="stylesheet" href="/assets/components/settings.css">
<link rel="stylesheet" href="/assets/components/sidebar.css">
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
<link rel="stylesheet" href="/assets/components/tools/doors.css">
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
<script type="module" src="/assets/components/router.js"></script>
<script src="/assets/js/snackbar.js"></script>
<link rel="stylesheet" href="/assets/components/home/home.css">
<link rel="stylesheet" href="/assets/components/main.css">
<link rel="stylesheet" href="/assets/components/modal.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_destination.css">
<link rel="stylesheet" href="/assets/components/navigation/navigation_keys.css">
<link rel="stylesheet" href="/assets/components/recordings/dashcam_routes.css">
<link rel="stylesheet" href="/assets/components/recordings/screen_recordings.css">
<link rel="stylesheet" href="/assets/components/settings.css">
<link rel="stylesheet" href="/assets/components/sidebar.css">
<link rel="stylesheet" href="/assets/components/tailscale/tailscale.css">
<link rel="stylesheet" href="/assets/components/tools/doors.css">
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
<script type="module" src="/assets/components/router.js"></script>
<script src="/assets/js/snackbar.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>
<title>The Pond</title>
</head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300..800&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
rel="stylesheet">
<body>
<div id="app"></div>
<!-- Menu button for phone -->
<div id="menu_button">
<i class="bi bi-list"></i>
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
</body>
<title>Galaxy</title>
</head>
</html>
<body>
<div id="app"></div>
<!-- Menu button for phone -->
<div id="menu_button">
<i class="bi bi-list"></i>
</div>
<!-- Snackbar for messages -->
<div id="snackbar_wrapper"></div>
</body>
</html>
+132 -3
View File
@@ -300,10 +300,136 @@ def setup(app):
return jsonify(message=f"{', '.join(saved)} saved successfully!")
@app.route("/api/params", methods=["GET"])
@app.route("/api/params", methods=["GET", "PUT"])
def get_param():
if request.method == "PUT":
data = request.get_json()
if not data or "key" not in data or "value" not in data:
return jsonify({"error": "Missing 'key' or 'value' in request body."}), 400
key = data["key"]
val = data["value"]
# Python json parses true/false as boolean
if isinstance(val, bool):
str_val = "1" if val else "0"
else:
str_val = str(val)
allowed_keys = {k for k, _, _, _ in frogpilot_default_params if k not in EXCLUDED_KEYS}
if key not in allowed_keys:
return jsonify({"error": f"Parameter '{key}' is not editable."}), 403
# 1. Prevent changing the model or reboot-required toggles while the car is actively driving
reboot_keys = {"Model", "AlwaysOnLateral", "ForceTorqueController", "NNFF", "NNFFLite"}
if key in reboot_keys and params.get_bool("IsOnroad"):
friendly_names = {
"Model": "Driving Model",
"AlwaysOnLateral": "Always On Lateral",
"ForceTorqueController": "Force Torque Controller",
"NNFF": "NNFF",
"NNFFLite": "NNFF-Lite"
}
name = friendly_names.get(key, key)
return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403
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:
params.put("ModelVersion", versions[str_val])
except Exception:
pass # Failsafe if json doesn't exist
update_frogpilot_toggles()
return jsonify({"message": f"Parameter '{key}' updated successfully."}), 200
return params.get(request.args.get("key")) or "", 200
@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
result = {}
for key in allowed_keys:
t = types.get(key, str)
try:
if t == bool:
result[key] = params.get_bool(key)
else:
raw = params.get(key)
raw_str = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw or "")
if not raw_str:
result[key] = 0.0 if t == float else (0 if t == int else "")
elif t == float:
result[key] = float(raw_str)
elif t == int:
result[key] = int(float(raw_str))
else:
result[key] = raw_str
except Exception:
result[key] = None
return jsonify(result), 200
@app.route("/api/models/installed", methods=["GET"])
def get_installed_models():
"""Returns only models with files present in /data/models/."""
import os
available = (params.get("AvailableModels", encoding="utf-8") or "").split(",")
names = (params.get("AvailableModelNames", encoding="utf-8") or "").split(",")
models_dir = "/data/models"
try:
on_disk = os.listdir(models_dir) if os.path.isdir(models_dir) else []
except Exception:
on_disk = []
installed = []
for i, key in enumerate(available):
if not key:
continue
if any(f.startswith(f"{key}.") or f.startswith(f"{key}_") for f in on_disk):
label = names[i] if i < len(names) else key
installed.append({"value": key, "label": label})
return jsonify(installed), 200
@app.route("/api/params_memory", methods=["GET"])
def get_param_memory():
return params_memory.get(request.args.get("key")) or "", 200
@@ -1441,12 +1567,15 @@ def setup(app):
run_cmd(["tmux", "resize-window", "-t", "comma:0", "-x", "240", "-y", "70"], "Resized tmux window", "Failed to resize tmux window")
def generate():
last_output = ""
while True:
output = subprocess.check_output(["tmux", "capture-pane", "-t", "comma:0", "-p", "-S", "-1000"], text=True)
yield "data: " + "\n".join(reversed(output.splitlines())).replace("\n", "\ndata: ") + "\n\n"
if output != last_output:
yield "data: " + "\n".join(reversed(output.splitlines())).replace("\n", "\ndata: ") + "\n\n"
last_output = output
time.sleep(0.1)
time.sleep(0.5)
return Response(generate(), mimetype="text/event-stream")
@app.route("/api/tmux_log/rename/<old>/<new>", methods=["PUT"])
+80
View File
@@ -5,6 +5,8 @@
#include <vector>
#include <QDebug>
#include <QCryptographicHash>
#include <QrCode.hpp>
#include "common/watchdog.h"
#include "common/util.h"
@@ -207,6 +209,43 @@ void TogglesPanel::updateToggles() {
}
}
GalaxyQRPopup::GalaxyQRPopup(const QString &url, QWidget *parent) : DialogBase(parent) {
setStyleSheet("GalaxyQRPopup { background-color: #1a1a30; }");
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setAlignment(Qt::AlignCenter);
layout->setSpacing(30);
layout->setContentsMargins(60, 40, 60, 40);
// Generate QR image
auto qr = qrcodegen::QrCode::encodeText(url.toUtf8().data(), qrcodegen::QrCode::Ecc::LOW);
int sz = qr.getSize();
QImage im(sz, sz, QImage::Format_RGB32);
for (int y = 0; y < sz; y++)
for (int x = 0; x < sz; x++)
im.setPixel(x, y, qr.getModule(x, y) ? qRgb(0,0,0) : qRgb(255,255,255));
QPixmap qrPixmap = QPixmap::fromImage(im.scaled(400, 400, Qt::KeepAspectRatio), Qt::MonoOnly);
QLabel *title = new QLabel(tr("Scan to open Galaxy"), this);
title->setStyleSheet("font-size: 52px; font-weight: bold; color: white;");
title->setAlignment(Qt::AlignCenter);
layout->addWidget(title);
QLabel *qrLabel = new QLabel(this);
qrLabel->setPixmap(qrPixmap);
qrLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(qrLabel);
QLabel *urlLabel = new QLabel(url, this);
urlLabel->setStyleSheet("font-size: 36px; color: #8b6cc5;");
urlLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(urlLabel);
QLabel *hint = new QLabel(tr("Tap anywhere to dismiss"), this);
hint->setStyleSheet("font-size: 28px; color: #7e7e98;");
hint->setAlignment(Qt::AlignCenter);
layout->addWidget(hint);
}
DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
setSpacing(50);
addItem(new LabelControl(tr("Dongle ID"), getDongleId().value_or(tr("N/A"))));
@@ -220,6 +259,41 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) {
});
addItem(pair_device);
pair_galaxy = new ButtonControl(tr("Galaxy"), tr("Pair"), tr("Pair your device with Galaxy for remote access to The Pond."));
connect(pair_galaxy, &ButtonControl::clicked, [=]() {
std::string current_pin = util::read_file("/data/galaxy/glxyauth");
if (current_pin.empty()) {
QString new_pin = InputDialog::getText(tr("Enter 6-digit PIN"), this, tr("Please enter a 6-digit PIN to secure your Galaxy access."), false, 6);
if (!new_pin.isEmpty()) {
std::string hash = QCryptographicHash::hash(new_pin.toUtf8(), QCryptographicHash::Sha256).toHex().toStdString();
util::create_directories("/data/galaxy", 0775);
util::write_file("/data/galaxy/glxyauth", hash.data(), hash.size(), O_WRONLY | O_CREAT | O_TRUNC);
pair_galaxy->setText(tr("Unpair"));
ConfirmationDialog::alert(tr("Pairing successful! Visit galaxy.firestar.link/") + getDongleId().value_or("") + tr(" to connect."), this);
galaxy_qr->setVisible(true);
}
} else {
if (ConfirmationDialog::confirm(tr("Are you sure you want to unpair from Galaxy?"), tr("Unpair"), this)) {
std::remove("/data/galaxy/glxyauth");
pair_galaxy->setText(tr("Pair"));
galaxy_qr->setVisible(false);
}
}
});
addItem(pair_galaxy);
galaxy_qr = new ButtonControl(tr("Galaxy QR"), tr("SHOW"), tr("Show a QR code to quickly open Galaxy on your phone."));
connect(galaxy_qr, &ButtonControl::clicked, [=]() {
auto dongleId = getDongleId();
if (!dongleId) return;
QString url = "https://galaxy.firestar.link/" + *dongleId;
GalaxyQRPopup *popup = new GalaxyQRPopup(url, this);
popup->exec();
popup->deleteLater();
});
addItem(galaxy_qr);
// offroad-only buttons
auto dcamBtn = new ButtonControl(tr("Driver Camera"), tr("PREVIEW"),
@@ -359,6 +433,12 @@ void DevicePanel::poweroff() {
void DevicePanel::showEvent(QShowEvent *event) {
pair_device->setVisible(uiState()->primeType() == PrimeType::UNPAIRED);
std::string galaxy_pin = util::read_file("/data/galaxy/glxyauth");
bool galaxy_paired = !galaxy_pin.empty();
pair_galaxy->setText(galaxy_paired ? tr("Unpair") : tr("Pair"));
galaxy_qr->setVisible(galaxy_paired);
ListWidget::showEvent(event);
}
+14
View File
@@ -12,7 +12,9 @@
#include "selfdrive/ui/ui.h"
#include "selfdrive/ui/qt/util.h"
#include "selfdrive/ui/qt/util.h"
#include "selfdrive/ui/qt/widgets/controls.h"
#include "selfdrive/ui/qt/widgets/input.h"
// ********** settings window + top-level panels **********
class SettingsWindow : public QFrame {
@@ -55,6 +57,16 @@ private:
Params params;
};
class GalaxyQRPopup : public DialogBase {
Q_OBJECT
public:
explicit GalaxyQRPopup(const QString &url, QWidget *parent);
protected:
void mousePressEvent(QMouseEvent *e) override { reject(); }
};
class DevicePanel : public ListWidget {
Q_OBJECT
public:
@@ -73,6 +85,8 @@ private slots:
private:
Params params;
ButtonControl *pair_device;
ButtonControl *pair_galaxy;
ButtonControl *galaxy_qr;
};
class TogglesPanel : public ListWidget {
+4 -1
View File
@@ -338,7 +338,10 @@ def manager_thread() -> None:
running = ' '.join("{}{}\u001b[0m".format("\u001b[32m" if p.proc.is_alive() else "\u001b[31m", p.name)
for p in managed_processes.values() if p.proc)
print(running)
if os.path.isfile("/tmp/print_processes"):
print(running)
cloudlog.debug(running)
# send managerState
+1
View File
@@ -115,6 +115,7 @@ procs = [
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),
PythonProcess("galaxy", "frogpilot.system.galaxy.galaxy", always_run),
PythonProcess("tinygrad_modeld", "frogpilot.tinygrad_modeld.tinygrad_modeld", run_tinygrad_modeld),
]
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
derive_feasible_params.py
Dynamically parses the OpenPilot/StarPilot codebase to cross-reference logically
registered Param keys with UI string literals. This ensures that no hidden or
dynamically-instantiated UI toggles are missed, outputting a highly accurate "Golden List"
of parameters that can be safely modified by The Pond or other configuration interfaces.
"""
import os
import re
def get_repo_root() -> str:
# Resolves to the root of the StarPilot repository based on this script's location
return os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))
# Constants
REPO_ROOT = get_repo_root()
PARAMS_CC_PATH = os.path.join(REPO_ROOT, 'common/params.cc')
UI_DIRECTORIES = [
os.path.join(REPO_ROOT, 'selfdrive/ui'),
os.path.join(REPO_ROOT, 'frogpilot/ui')
]
# A curated list of parameters that are known to be strictly readable state metadata
# rather than user-toggled configurations.
KNOWN_READ_ONLY = {
"ApiCache_Device", "ApiCache_DriveStats", "ApiCache_NavDestinations",
"CarMake", "CarModel", "CarModelName", "CarParamsPersistent", "CarVin",
"ClusterOffset", "Compass", "DeveloperSidebarMetric1", "DeveloperSidebarMetric2",
"DeveloperSidebarMetric3", "DeveloperSidebarMetric4", "DeveloperSidebarMetric5",
"DeveloperSidebarMetric6", "DeveloperSidebarMetric7", "DongleId",
"FrogPilotCarParamsPersistent", "FrogPilotDrives", "FrogPilotKilometers",
"FrogPilotMinutes", "GitBranch", "GitCommit", "GitCommitDate", "GitDiff",
"GitRemote", "GithubSshKeys", "GithubUsername", "HardwareSerial", "IMEI",
"InstallDate", "IsRhdDetected", "KonikMinutes", "LastGPSPosition",
"LastMapsUpdate", "LastUpdateTime", "ModelDrivesAndScores", "ModelReleasedDates",
"ModelVersions", "PrimeType", "TermsVersion", "TrainingVersion", "Version",
"openpilotMinutes", "CompletedTrainingVersion"
}
def extract_registered_keys(params_path: str) -> set:
"""Extracts all legally registered parameter keys from common/params.cc"""
registered_keys = set()
try:
with open(params_path, 'r', encoding='utf-8') as f:
content = f.read()
# Isolate the keys `unordered_map` block
keys_block_match = re.search(r'unordered_map<std::string, uint32_t> keys = \{(.*?)\};', content, re.DOTALL)
if not keys_block_match:
print("Error: Could not locate 'keys' map in params.cc")
return registered_keys
# Extract {"KeyName", FLAG} entries
for match in re.finditer(r'\{"([A-Za-z0-9_]+)",\s*([^}]+)\}', keys_block_match.group(1)):
key, flag = match.group(1), match.group(2)
# Remove keys that are strictly internal ephemeral states
if 'CLEAR_ON_MANAGER_START' not in flag:
registered_keys.add(key)
except FileNotFoundError:
print(f"Error: Could not find params source file at {params_path}")
return registered_keys
def extract_ui_string_literals(ui_dirs: list) -> set:
"""Recursively walks UI directories to extract every string literal."""
ui_strings = set()
valid_extensions = ('.cc', '.h', '.cpp', '.hpp', '.qml')
for directory in ui_dirs:
if not os.path.exists(directory):
print(f"Warning: UI directory not found {directory}")
continue
for root, _, files in os.walk(directory):
for file in files:
if file.endswith(valid_extensions):
filepath = os.path.join(root, file)
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
# Extract all "StringLiterals" block
matches = re.findall(r'\"([A-Za-z0-9_]+)\"', f.read())
ui_strings.update(matches)
return ui_strings
def main():
print(f"Starting parameter derivation inside {REPO_ROOT}...")
# 1. Fetch
registered_keys = extract_registered_keys(PARAMS_CC_PATH)
ui_strings = extract_ui_string_literals(UI_DIRECTORIES)
# 2. Intersect
feasible_keys = registered_keys.intersection(ui_strings)
# 3. Filter Read-Only
editable_keys = feasible_keys - KNOWN_READ_ONLY
# 4. Export
output_path = os.path.join(os.path.dirname(__file__), 'feasibleparams.txt')
try:
with open(output_path, 'w', encoding='utf-8') as f:
f.write("Dynamically Derived Feasible Param Candidates (The Golden List)\n")
f.write("===============================================================\n\n")
f.write(f"Total globally registered C++ keys: {len(registered_keys)}\n")
f.write(f"Total explicit UI string references: {len(feasible_keys)}\n")
f.write(f"Total Editable/Toggleable targets: {len(editable_keys)}\n\n")
for key in sorted(list(editable_keys)):
f.write(f"{key}\n")
print(f"Successfully derived {len(editable_keys)} highly feasible parameter targets.")
print(f"Report exported to: {output_path}")
except Exception as e:
print(f"Error writing to output file: {e}")
if __name__ == '__main__':
main()
+338
View File
@@ -0,0 +1,338 @@
Dynamically Derived Feasible Param Candidates (The Golden List)
===============================================================
Total globally registered C++ keys: 417
Total explicit UI string references: 369
Total Editable/Toggleable targets: 331
AMapKey1
AMapKey2
AccelerationPath
AccelerationProfile
AdjacentLeadsUI
AdjacentPath
AdjacentPathMetrics
AdvancedCustomUI
AdvancedLateralTune
AdvancedLongitudinalTune
AggressiveFollow
AggressiveFollowHigh
AggressiveJerkAcceleration
AggressiveJerkDanger
AggressiveJerkDeceleration
AggressiveJerkSpeed
AggressiveJerkSpeedDecrease
AggressivePersonalityProfile
AlertVolumeControl
AlwaysOnLateral
AlwaysOnLateralLKAS
AlwaysOnLateralMain
AutomaticUpdates
AutomaticallyDownloadModels
AvailableModelNames
AvailableModelSeries
AvailableModels
BigMap
BlacklistedModels
BlindSpotMetrics
BlindSpotPath
BootLogo
BorderMetrics
CECurves
CECurvesLead
CELead
CEModelStopTime
CENavigation
CENavigationIntersections
CENavigationLead
CENavigationTurns
CESignalLaneDetection
CESignalSpeed
CESlowerLead
CESpeed
CESpeedLead
CEStatus
CEStoppedLead
CalibratedLateralAcceleration
CalibrationParams
CalibrationProgress
CameraView
CommunityFavorites
ConditionalExperimental
CurvatureData
CurveSpeedController
CustomAlerts
CustomColors
CustomCruise
CustomCruiseLong
CustomDistanceIcons
CustomIcons
CustomPersonalities
CustomSignals
CustomSounds
CustomUI
DebugMode
DecelerationProfile
DeveloperMetrics
DeveloperSidebar
DeveloperUI
DeveloperWidgets
DeviceManagement
DeviceShutdown
DisableOnroadUploads
DisableOpenpilotLongitudinal
DiscordUsername
DisengageOnAccelerator
DisengageVolume
DistanceButtonControl
DoToggleReset
DoToggleResetStock
DownloadableBootLogos
DownloadableColors
DownloadableDistanceIcons
DownloadableIcons
DownloadableSignals
DownloadableSounds
DownloadableWheels
DriverCamera
DynamicPathWidth
DynamicPedalsOnUI
EVTuning
EngageVolume
ExperimentalGMTune
ExperimentalLongitudinalEnabled
ExperimentalMode
ExperimentalModeConfirmed
FPSCounter
Fahrenheit
FavoriteDestinations
ForceAutoTune
ForceAutoTuneOff
ForceFingerprint
ForceMPHDashboard
ForceStops
ForceTorqueController
FrogPilotStats
FrogsGoMoosTweak
FullMap
GMPedalLongitudinal
GoatScream
GreenLightAlert
GsmApn
GsmMetered
GsmRoaming
HasAcceptedTerms
HideAlerts
HideLeadMarker
HideMap
HideMapIcon
HideMaxSpeed
HideSpeed
HideSpeedLimit
HigherBitrate
HolidayThemes
HumanAcceleration
HumanFollowing
IncreaseThermalLimits
IncreasedStoppedDistance
IsDriverViewEnabled
IsLdwEnabled
IsMetric
LKASButtonControl
LaneChangeTime
LaneChanges
LaneDetectionWidth
LaneLinesWidth
LanguageSetting
LateralTune
LeadDepartingAlert
LeadDetectionThreshold
LeadInfo
LiveDelay
LiveParameters
LiveTorqueParameters
LockDoors
LockDoorsTimer
LongDistanceButtonControl
LongPitch
LongitudinalActuatorDelay
LongitudinalActuatorDelayStock
LongitudinalPersonality
LongitudinalTune
LoudBlindspotAlert
LowVoltageShutdown
MapAcceleration
MapDeceleration
MapGears
MapStyle
MapboxPublicKey
MapboxSecretKey
MapsSelected
MaxDesiredAcceleration
MinimumLaneChangeSpeed
Model
ModelRandomizer
ModelUI
ModelVersion
NNFF
NNFFLite
NavPastDestinations
NavSettingLeftSide
NavSettingTime24h
NavigationUI
NewLongAPI
NoLogging
NoUploads
NudgelessLaneChange
NumericalTemp
OSMDownloadLocations
Offset1
Offset2
Offset3
Offset4
Offset5
Offset6
Offset7
OneLaneChange
OnroadDistanceButton
OpenpilotEnabledToggle
OverpassRequests
PathEdgeWidth
PathWidth
PauseAOLOnBrake
PauseLateralOnSignal
PauseLateralSpeed
PedalsOnUI
PersonalizeOpenpilot
PreferredSchedule
PromptDistractedVolume
PromptVolume
QOLLateral
QOLLongitudinal
QOLVisuals
RadarTracksUI
RainbowPath
RandomEvents
RandomThemes
RecordFront
RecordFrontLock
RecoveryPower
RedPanda
RefuseVolume
RelaxedFollow
RelaxedFollowHigh
RelaxedJerkAcceleration
RelaxedJerkDanger
RelaxedJerkDeceleration
RelaxedJerkSpeed
RelaxedJerkSpeedDecrease
RelaxedPersonalityProfile
RemoteStartBootsComma
ReverseCruise
RoadEdgesWidth
RoadNameUI
RotatingWheel
SLCConfirmation
SLCConfirmationHigher
SLCConfirmationLower
SLCFallback
SLCLookaheadHigher
SLCLookaheadLower
SLCMapboxFiller
SLCOverride
SNGHack
ScreenBrightness
ScreenBrightnessOnroad
ScreenManagement
ScreenRecorder
ScreenTimeout
ScreenTimeoutOnroad
SearchInput
SetSpeedLimit
SetSpeedOffset
ShowCEMStatus
ShowCPU
ShowCSCStatus
ShowGPU
ShowIP
ShowMemoryUsage
ShowSLCOffset
ShowSpeedLimits
ShowSteering
ShowStoppingPoint
ShowStoppingPointMetrics
ShowStorageLeft
ShowStorageUsed
ShownToggleDescriptions
Sidebar
SignalMetrics
SpeedLimitChangedAlert
SpeedLimitController
SpeedLimitFiller
SpeedLimitSources
SshEnabled
StandardFollow
StandardFollowHigh
StandardJerkAcceleration
StandardJerkDanger
StandardJerkDeceleration
StandardJerkSpeed
StandardJerkSpeedDecrease
StandardPersonalityProfile
StandbyMode
StartAccel
StartAccelStock
StartupMessageBottom
StartupMessageTop
StaticPedalsOnUI
SteerDelay
SteerDelayStock
SteerFriction
SteerFrictionStock
SteerKP
SteerKPStock
SteerLatAccel
SteerLatAccelStock
SteerOffset
SteerOffsetStock
SteerRatio
SteerRatioStock
StopAccel
StopAccelStock
StopDistance
StoppedTimer
StoppingDecelRate
StoppingDecelRateStock
TacoTune
TacoTuneHacks
TetheringEnabled
ToyotaDoors
TrafficFollow
TrafficJerkAcceleration
TrafficJerkDanger
TrafficJerkDeceleration
TrafficJerkSpeed
TrafficJerkSpeedDecrease
TrafficPersonalityProfile
TrailerLoad
TruckTuning
TuningLevel
TuningLevelConfirmed
TurnDesires
UnlimitedLength
UnlockDoors
UpdaterAvailableBranches
UseKonikServer
UseSI
UseVienna
UserFavorites
VEgoStarting
VEgoStartingStock
VEgoStopping
VEgoStoppingStock
VeryLongDistanceButtonControl
VoltSNG
WarningImmediateVolume
WarningSoftVolume
WheelIcon
WheelSpeed
+342
View File
@@ -0,0 +1,342 @@
import os
import re
import sys
import json
import ast
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))
CATEGORIES = [
{"file": "lateral_settings.cc", "name": "Lateral (Steering)", "icon": "bi-arrows-move"},
{"file": "longitudinal_settings.cc", "name": "Longitudinal (Speed & Following)", "icon": "bi-speedometer2"},
{"file": "visual_settings.cc", "name": "Visual (Display & UI)", "icon": "bi-eye"},
{"file": "sounds_settings.cc", "name": "Sounds & Alerts", "icon": "bi-volume-up"},
{"file": "vehicle_settings.cc", "name": "Vehicle", "icon": "bi-car-front"},
{"file": "device_settings.cc", "name": "Device & Data", "icon": "bi-hdd"},
{"file": "model_settings.cc", "name": "Model & Customization", "icon": "bi-cpu"},
]
DROPDOWN_MAPPING = {
"SelectModel": {
"key": "Model",
"options_endpoint": "/api/models/installed"
}
}
PARENT_KEYS_MAPPING = {
"device_settings.cc": {
"deviceManagementKeys": "DeviceManagement",
"screenKeys": "ScreenManagement"
},
"lateral_settings.cc": {
"advancedLateralTuneKeys": "AdvancedLateralTune",
"aolKeys": "AlwaysOnLateral",
"laneChangeKeys": "LaneChanges",
"lateralTuneKeys": "LateralTune",
"qolKeys": "QOLLateral"
},
"longitudinal_settings.cc": {
"advancedLongitudinalTuneKeys": "AdvancedLongitudinalTune",
"aggressivePersonalityKeys": "AggressivePersonalityProfile",
"conditionalExperimentalKeys": "ConditionalExperimental",
"curveSpeedKeys": "CurveSpeedControl",
"customDrivingPersonalityKeys": "CustomDrivingPersonality",
"longitudinalTuneKeys": "LongitudinalTune",
"qolKeys": "QOLLongitudinal",
"relaxedPersonalityKeys": "RelaxedPersonalityProfile",
"speedLimitControllerKeys": "SpeedLimitController",
"standardPersonalityKeys": "StandardPersonalityProfile",
"trafficPersonalityKeys": "TrafficPersonalityProfile"
},
"sounds_settings.cc": {
"alertVolumeControlKeys": "AlertVolumeControl",
"customAlertsKeys": "CustomAlerts"
},
"theme_settings.cc": {
"customThemeKeys": "CustomTheme"
},
"visual_settings.cc": {
"advancedCustomOnroadUIKeys": "AdvancedCustomUI",
"customOnroadUIKeys": "CustomUI",
"developerMetricKeys": "DeveloperMetrics",
"developerSidebarKeys": "DeveloperSidebar",
"developerUIKeys": "DeveloperUI",
"developerWidgetKeys": "DeveloperWidgets",
"modelUIKeys": "ModelUI",
"navigationUIKeys": "NavigationUI",
"qualityOfLifeKeys": "QOLVisuals"
},
"vehicle_settings.cc": {}
}
ALL_PARENT_KEYS = set()
for cmap in PARENT_KEYS_MAPPING.values():
for parent in cmap.values():
ALL_PARENT_KEYS.add(parent)
def get_variables_data():
filepath = os.path.join(REPO_ROOT, "frogpilot/common/frogpilot_variables.py")
excluded = set()
defaults = {}
if not os.path.exists(filepath):
return excluded, defaults
with open(filepath, 'r', encoding='utf-8') as f:
tree = ast.parse(f.read())
def parse_params_list(value_node):
try:
if isinstance(value_node, ast.List):
for elt in value_node.elts:
if isinstance(elt, ast.Tuple) and len(elt.elts) >= 2:
key_node = elt.elts[0]
val_node = elt.elts[1]
if isinstance(key_node, ast.Constant):
key = key_node.value
if isinstance(val_node, ast.Constant):
val = val_node.value
if isinstance(val, (str, bytes)):
v = val.decode('utf-8') if isinstance(val, bytes) else str(val)
if v in ("0", "1"):
defaults[key] = "bool"
elif "." in v and v.replace(".", "", 1).isdigit():
defaults[key] = "float"
elif v.isdigit():
defaults[key] = "int"
else:
defaults[key] = "string"
else:
defaults[key] = "unknown"
else:
defaults[key] = "unknown"
except:
pass
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if getattr(target, 'id', '') == 'EXCLUDED_KEYS':
try:
excluded = ast.literal_eval(node.value)
except:
pass
elif getattr(target, 'id', '') == 'frogpilot_default_params':
parse_params_list(node.value)
elif isinstance(node, ast.AnnAssign):
if getattr(node.target, 'id', '') == 'frogpilot_default_params':
parse_params_list(node.value)
return excluded, defaults
EXCLUDED_KEYS, DEFAULT_TYPES = get_variables_data()
def get_param_type(key):
return DEFAULT_TYPES.get(key, "unknown")
def extract_bracket_block(text, start_idx):
if text[start_idx] != '{': return ""
depth = 0
in_str = False
escape = False
for i in range(start_idx, len(text)):
char = text[i]
if escape:
escape = False
continue
if char == '\\':
escape = True
continue
if char == '"':
in_str = not in_str
continue
if not in_str:
if char == '{': depth += 1
elif char == '}':
depth -= 1
if depth == 0:
return text[start_idx:i+1]
return ""
def parse_cpp_file(filename):
filepath = os.path.join(REPO_ROOT, "frogpilot/ui/qt/offroad", filename)
if not os.path.exists(filepath): return []
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
vector_match = re.search(r'const std::vector<std::tuple<QString,\s*QString,\s*QString,\s*QString>> \w+\s*\{', content)
if not vector_match: return []
start_idx = vector_match.end() - 1
vector_content = extract_bracket_block(content, start_idx)
local_parent_map = PARENT_KEYS_MAPPING.get(filename, {})
child_to_parent = {}
header_filename = filename.replace(".cc", ".h")
header_filepath = os.path.join(REPO_ROOT, "frogpilot/ui/qt/offroad", header_filename)
full_source = content
if os.path.exists(header_filepath):
with open(header_filepath, 'r', encoding='utf-8') as fh:
full_source += "\n" + fh.read()
for qset_match in re.finditer(r'QSet<QString>\s+(\w+)\s*(?:=\s*)?\{([^}]+)\};', full_source):
qset_name = qset_match.group(1)
if qset_name in local_parent_map:
parent_key = local_parent_map[qset_name]
children_str = qset_match.group(2)
children = [c.strip().strip('"') for c in children_str.split(',') if c.strip()]
for child in children:
child_to_parent[child] = parent_key
items = []
idx = 0
while True:
idx = vector_content.find('{"', idx)
if idx == -1: break
block = extract_bracket_block(vector_content, idx)
if not block:
idx += 1
continue
row_match = re.search(r'\{"([A-Za-z0-9_]+)"\s*,\s*(.*?)\s*\}$', block, re.DOTALL)
if not row_match:
idx += len(block)
continue
key = row_match.group(1)
rest = row_match.group(2)
idx += len(block)
if key in EXCLUDED_KEYS or key.startswith("IgnoreMe"):
continue
strings = re.findall(r'tr\("((?:[^"\\]|\\.)+)"\)|"((?:[^"\\]|\\.)+)"', rest)
valid_strings = [s[0] or s[1] for s in strings if s[0] or s[1]]
if not valid_strings: continue
title = valid_strings[0]
desc = valid_strings[1] if len(valid_strings) > 1 else ""
options_endpoint = None
if key in DROPDOWN_MAPPING:
m = DROPDOWN_MAPPING[key]
key = m["key"]
widget_type = "dropdown"
options_endpoint = m["options_endpoint"]
data_type = "string"
else:
data_type = get_param_type(key)
if data_type == "unknown": continue
widget_type = "toggle"
min_val, max_val, step = None, None, None
for i in range(1, 10):
placeholder = f"%{i}"
if placeholder in desc and len(valid_strings) > i + 1:
desc = desc.replace(placeholder, valid_strings[i + 1])
desc = re.sub(r'<br\s*/?>', '\n', desc, flags=re.IGNORECASE)
desc = re.sub(r'<[^>]+>', '', desc)
desc = desc.replace('\\"', '"').strip()
title = re.sub(r'\s*\(\s*Default:\s*%\d\s*\)', '', title)
title = re.sub(r'%\d', '', title).strip()
desc = re.sub(r'\s*\(\s*Default:\s*%\d\s*\)', '', desc)
desc = re.sub(r'%\d', '', desc).strip()
if widget_type == "toggle":
snippet_match = None
qset_name = ""
if key in child_to_parent:
parent_k = child_to_parent[key]
for q, pk in local_parent_map.items():
if pk == parent_k:
qset_name = q
break
# Let's match the original's regex for finding the Toggle = assignment line
search_patterns = [r'param\s*==\s*"' + key + r'"']
if qset_name:
search_patterns.append(r'(?:' + qset_name + r'\.contains\(param\))')
for pattern in search_patterns:
match = re.search(pattern + r'.*?[a-zA-Z]+Toggle\s*=\s*(.*?);', content, re.DOTALL)
if match:
snippet_match = match
break
if snippet_match:
assignment = snippet_match.group(1)
if "FrogPilotParamValueControl" in assignment or "FrogPilotParamValueButtonControl" in assignment:
widget_type = "numeric"
if data_type in ("string", "bool", "unknown"):
data_type = "float"
if qset_name == "alertVolumeControlKeys":
if key in ["WarningImmediateVolume", "WarningSoftVolume"]:
min_val, max_val, step = "25", "101", "1"
else:
min_val, max_val, step = "0", "101", "1"
else:
args_match = re.search(r'Control[^(]*\(([^;]+)\)', assignment)
if args_match:
args_str = args_match.group(1)
num_match = re.search(r'icon\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,(?:[^,]*,){2}\s*([-\d.]+)', args_str)
if num_match:
min_val, max_val, step = num_match.group(1), num_match.group(2), num_match.group(3)
else:
num_match = re.search(r'icon\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)', args_str)
if num_match:
min_val, max_val = num_match.group(1), num_match.group(2)
step_match = re.search(r'(?:std::map<float,\s*QString>\(\)|[a-zA-Z0-9_]+Labels)\s*,\s*([-\d.]+)', args_str)
if step_match:
step = step_match.group(1)
precision = None
precision_match = re.search(r"QString::number\([^,]+,\s*'f'\s*,\s*(\d+)\)", rest)
if precision_match:
precision = int(precision_match.group(1))
if data_type == "float" and step and float(step).is_integer():
data_type = "int"
s = {
"key": key,
"label": title,
"description": desc,
"data_type": data_type,
"ui_type": widget_type
}
if widget_type == "numeric":
if min_val is not None: s["min"] = float(min_val)
if max_val is not None: s["max"] = float(max_val)
if step is not None: s["step"] = float(step)
if precision is not None: s["precision"] = precision
elif widget_type == "dropdown":
if options_endpoint: s["options_endpoint"] = options_endpoint
if key in child_to_parent: s["parent_key"] = child_to_parent[key]
if key in ALL_PARENT_KEYS: s["is_parent_toggle"] = True
items.append(s)
return items
def main():
layout = []
for cat in CATEGORIES:
items = parse_cpp_file(cat["file"])
if items:
layout.append({
"name": cat["name"],
"icon": cat["icon"],
"params": items
})
output_path = os.path.join(REPO_ROOT, "frogpilot/system/the_pond/assets/components/tools/device_settings_layout.json")
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(layout, f, indent=2)
if __name__ == '__main__':
main()