diff --git a/frogpilot/common/frogpilot_variables.py b/frogpilot/common/frogpilot_variables.py index 2355b651f..4f1fef9d5 100644 --- a/frogpilot/common/frogpilot_variables.py +++ b/frogpilot/common/frogpilot_variables.py @@ -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") ] diff --git a/frogpilot/system/galaxy/galaxy.py b/frogpilot/system/galaxy/galaxy.py new file mode 100644 index 000000000..13cedb81e --- /dev/null +++ b/frogpilot/system/galaxy/galaxy.py @@ -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() + diff --git a/frogpilot/system/the_pond/assets/components/home/home.css b/frogpilot/system/the_pond/assets/components/home/home.css index 08e8e8a9f..cc80e874c 100644 --- a/frogpilot/system/the_pond/assets/components/home/home.css +++ b/frogpilot/system/the_pond/assets/components/home/home.css @@ -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)); } -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/home/home.js b/frogpilot/system/the_pond/assets/components/home/home.js index 55312b4e2..7a700cb81 100644 --- a/frogpilot/system/the_pond/assets/components/home/home.js +++ b/frogpilot/system/the_pond/assets/components/home/home.js @@ -102,18 +102,18 @@ export function Home() { return html`
${() => { - if (state.isLoading) { - return html`

Loading...

`; - } + if (state.isLoading) { + return html`

Loading...

`; + } - if (state.error) { - return html`

Failed to load data: ${state.error}

`; - } + if (state.error) { + return html`

Failed to load data: ${state.error}

`; + } - if (state.data) { - const { driveStats, firehoseStats, softwareInfo } = state.data; - return html` -

The Pond

+ if (state.data) { + const { driveStats, firehoseStats, softwareInfo } = state.data; + return html` +

Galaxy

${DriveStat("All Time", driveStats?.all, state.unit)} @@ -139,10 +139,10 @@ export function Home() {
${renderSoftwareInfo(softwareInfo)}
`; - } + } - return html`

No data available.

`; - }} + return html`

No data available.

`; + }}
`; } diff --git a/frogpilot/system/the_pond/assets/components/main.css b/frogpilot/system/the_pond/assets/components/main.css index 89bdfe320..7a360fa49 100644 --- a/frogpilot/system/the_pond/assets/components/main.css +++ b/frogpilot/system/the_pond/assets/components/main.css @@ -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); -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.css b/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.css index b535df486..ff816fc15 100644 --- a/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.css +++ b/frogpilot/system/the_pond/assets/components/navigation/navigation_destination.css @@ -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)); } -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.css b/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.css index 91ee91dfa..641400395 100644 --- a/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.css +++ b/frogpilot/system/the_pond/assets/components/navigation/navigation_keys.css @@ -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); } -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js b/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js index 74e0677a7..84ea0c608 100644 --- a/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js +++ b/frogpilot/system/the_pond/assets/components/recordings/dashcam_routes.js @@ -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` +
+
🛰️
+

Dashcam Routes Unavailable via Galaxy

+

Loading dashcam routes requires a direct connection.
Connect to your device's local network to use this feature.

+
+ `; + } + if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute); return html` @@ -332,75 +343,75 @@ export function RouteRecordings() { ${() => { - 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`

Processing Routes: ${state.progress} of ${state.total}

`; - } - if (state.loading && !state.isDeletingAll) { - return html`

Loading...

`; - } - if (state.isDeletingAll) { - return html`

Deleting routes...

`; - } - if (state.showPreservedOnly) { - return html`

No preserved routes...

`; - } - if (state.error) { - return html`

${state.error}

`; - } - return html`

No routes found...

`; - } + if (routesToShow.length === 0) { + if (state.loading && state.total > 0) { + return html`

Processing Routes: ${state.progress} of ${state.total}

`; + } + if (state.loading && !state.isDeletingAll) { + return html`

Loading...

`; + } + if (state.isDeletingAll) { + return html`

Deleting routes...

`; + } + if (state.showPreservedOnly) { + return html`

No preserved routes...

`; + } + if (state.error) { + return html`

${state.error}

`; + } + return html`

No routes found...

`; + } - return html` + return html`
${routesToShow.map( - route => html` + route => html`
{ - 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; + }}" >
${() => html``} @@ -410,6 +421,7 @@ export function RouteRecordings() { src="${route.png}" class="recording-preview recording-preview-png" style="display:block;" + loading="lazy" > ${route.timestamp}

` - )} + )}
`; - }} + }} ${() => { - if (state.routes.length > 0) { - return html` + if (state.routes.length > 0) { + return html` `; - } - return ""; - }} + } + return ""; + }}
${() => 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" + }) : ""} `; } diff --git a/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js b/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js index 0a4406e43..a2015a546 100644 --- a/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js +++ b/frogpilot/system/the_pond/assets/components/recordings/screen_recordings.js @@ -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` +
+
🛰️
+

Screen Recordings Unavailable via Galaxy

+

Loading screen recordings requires a direct connection.
Connect to your device's local network to use this feature.

+
+ `; + } + if (state.selectedRecording && !overlay) openOverlay(state.selectedRecording) return html` @@ -229,77 +240,77 @@ export function ScreenRecordings() {
Screen Recordings
${() => { - if (state.loading && state.recordings.length === 0) return html`

Loading...

` - if (state.error) return html`

${state.error}

` - if (state.progress > 0 && state.progress < state.total) { - return html`

Processing Recordings: ${state.progress} of ${state.total}

` - } - if (state.recordings.length === 0 && !state.loading) { - return html`

No screen recordings found...

` - } - return "" - }} + if (state.loading && state.recordings.length === 0) return html`

Loading...

` + if (state.error) return html`

${state.error}

` + if (state.progress > 0 && state.progress < state.total) { + return html`

Processing Recordings: ${state.progress} of ${state.total}

` + } + if (state.recordings.length === 0 && !state.loading) { + return html`

No screen recordings found...

` + } + return "" + }}
${() => 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`
{ - 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 }}" >
- +

${displayName}

` - })} + })}
${() => { - if (state.recordings.length > 0) { - return html` + if (state.recordings.length > 0) { + return html` Download Tailscale on your other devices @@ -79,12 +80,12 @@ export function TailscaleControl() { ${() => 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" + }) : ""} ` } diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.css b/frogpilot/system/the_pond/assets/components/tools/device_settings.css new file mode 100644 index 000000000..4375f4dd3 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.css @@ -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); + } +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings.js b/frogpilot/system/the_pond/assets/components/tools/device_settings.js new file mode 100644 index 000000000..5a7d3204e --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings.js @@ -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 = '' }) + } 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` +
+

Device Settings

+ + + + ${() => { + if (state.loadingLayout || state.loadingValues) { + return html`
Loading configuration...
` + } + + const loadedKeys = state.allKeys.length + + // Sync DOM inputs after reactive render + requestAnimationFrame(syncInputs) + + return html` +
+ ${loadedKeys} settings mapped dynamically +
+ + ${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` +
+
+ + ${section.name} (${visibleParams.length}) + +
+
+ ${() => 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` +
+
+
+ ${p.label} + ${p.description ? html`
${p.description}
` : ""} + + ${() => p.is_parent_toggle && state.values[p.key] ? html` +
+ ${state.expanded[p.key] ? 'Close' : 'Manage'} +
+ ` : ''} +
+ ${isNumeric ? html`${state.values[p.key] !== undefined ? formatSliderValue(state.values[p.key], p.step !== undefined ? String(p.step) : undefined, p.precision, p.key) : '..'}` : ""} +
+ + ${isNumeric ? html` +
+ +
+ ` : p.ui_type === "dropdown" ? html` + + ` : html` + + `} +
+ ` + })} +
+
+ ` + })} + + ${() => { + const totalVisible = state.layout.reduce((acc, s) => + acc + s.params.filter(p => matchesFilter(p)).length, 0) + if (totalVisible === 0) { + return html`
No settings match your search.
` + } + return "" + }} + ` + }} +
+ ` +} diff --git a/frogpilot/system/the_pond/assets/components/tools/device_settings_layout.json b/frogpilot/system/the_pond/assets/components/tools/device_settings_layout.json new file mode 100644 index 000000000..b0e2f0879 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/device_settings_layout.json @@ -0,0 +1,1002 @@ +[ + { + "name": "Lateral (Steering)", + "icon": "bi-arrows-move", + "params": [ + { + "key": "AdvancedLateralTune", + "label": "Advanced Lateral Tuning", + "description": "Advanced steering control changes to fine-tune how openpilot drives.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "SteerDelay", + "label": "Actuator Delay", + "description": "Actuator Delay", + "data_type": "float", + "ui_type": "numeric", + "min": 0.01, + "max": 1.0, + "step": 0.01, + "precision": 2, + "parent_key": "AdvancedLateralTune" + }, + { + "key": "SteerFriction", + "label": "Friction", + "description": "Friction", + "data_type": "float", + "ui_type": "numeric", + "min": 0.0, + "max": 0.5, + "step": 0.01, + "precision": 2, + "parent_key": "AdvancedLateralTune" + }, + { + "key": "SteerOffset", + "label": "Steer Offset", + "description": "Steer Offset", + "data_type": "float", + "ui_type": "numeric", + "min": -0.2, + "max": 0.2, + "step": 0.005, + "precision": 3, + "parent_key": "AdvancedLateralTune" + }, + { + "key": "SteerKP", + "label": "Kp Factor", + "description": "Kp Factor", + "data_type": "float", + "ui_type": "numeric", + "step": 0.01, + "precision": 2, + "parent_key": "AdvancedLateralTune" + }, + { + "key": "SteerLatAccel", + "label": "Lateral Acceleration", + "description": "Lateral Acceleration", + "data_type": "float", + "ui_type": "numeric", + "step": 0.01, + "precision": 2, + "parent_key": "AdvancedLateralTune" + }, + { + "key": "SteerRatio", + "label": "Steer Ratio", + "description": "Steer Ratio", + "data_type": "float", + "ui_type": "numeric", + "step": 0.01, + "precision": 2, + "parent_key": "AdvancedLateralTune" + }, + { + "key": "ForceAutoTune", + "label": "Force Auto-Tune On", + "description": "Force-enable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\".", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedLateralTune" + }, + { + "key": "ForceAutoTuneOff", + "label": "Force Auto-Tune Off", + "description": "Force-disable openpilot's live auto-tuning for \"Friction\" and \"Lateral Acceleration\" and use the set value instead.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedLateralTune" + }, + { + "key": "ForceTorqueController", + "label": "Force Torque Controller", + "description": "Use torque-based steering control instead of angle-based control for smoother lane keeping, especially in curves.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedLateralTune" + }, + { + "key": "AlwaysOnLateral", + "label": "Always On Lateral", + "description": "openpilot's steering remains active even when the accelerator or brake pedals are pressed.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "AlwaysOnLateralMain", + "label": "Enable With Cruise Control", + "description": "Enable \"Always On Lateral\" whenever \"Cruise Control\" is on, even when openpilot is not engaged.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AlwaysOnLateral" + }, + { + "key": "AlwaysOnLateralLKAS", + "label": "Enable With LKAS", + "description": "Enable \"Always On Lateral\" whenever \"LKAS\" is on, even when openpilot is not engaged.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AlwaysOnLateral" + }, + { + "key": "PauseAOLOnBrake", + "label": "Pause on Brake Press Below", + "description": "Pause \"Always On Lateral\" below the set speed while the brake pedal is pressed.", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 99.0, + "step": 1.0, + "parent_key": "AlwaysOnLateral" + }, + { + "key": "LaneChanges", + "label": "Lane Changes", + "description": "Allow openpilot to change lanes.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "NudgelessLaneChange", + "label": "Automatic Lane Changes", + "description": "When the turn signal is on, openpilot will automatically change lanes. No steering-wheel nudge required!", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "LaneChanges" + }, + { + "key": "LaneChangeTime", + "label": "Lane Change Delay", + "description": "Delay between turn signal activation and the start of an automatic lane change.", + "data_type": "float", + "ui_type": "numeric", + "min": 0.0, + "max": 5.0, + "step": 0.1, + "parent_key": "LaneChanges" + }, + { + "key": "LaneDetectionWidth", + "label": "Minimum Lane Width", + "description": "Prevent automatic lane changes into lanes narrower than the set width.", + "data_type": "float", + "ui_type": "numeric", + "min": 0.0, + "max": 15.0, + "step": 0.1, + "parent_key": "LaneChanges" + }, + { + "key": "OneLaneChange", + "label": "One Lane Change Per Signal", + "description": "Limit automatic lane changes to one per turn-signal activation.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "LaneChanges" + }, + { + "key": "LateralTune", + "label": "Lateral Tuning", + "description": "Miscellaneous steering control changes to fine-tune how openpilot drives.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "TurnDesires", + "label": "Force Turn Desires Below Lane Change Speed", + "description": "While driving below the minimum lane change speed with an active turn signal, instruct openpilot to turn left/right.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "LateralTune" + }, + { + "key": "NNFF", + "label": "Neural Network Feedforward (NNFF)", + "description": "Twilsonco's \"Neural Network FeedForward\" model controller for smoother, model-based steering trained on your vehicle's data.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "LateralTune" + }, + { + "key": "NNFFLite", + "label": "Smooth Curve Handling", + "description": "Twilsonco's torque-based adjustments to smoothen out steering in curves.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "LateralTune" + }, + { + "key": "QOLLateral", + "label": "Quality of Life", + "description": "Steering control changes to fine-tune how openpilot drives.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "PauseLateralSpeed", + "label": "Pause Steering Below", + "description": "Pause steering below the set speed.", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 99.0, + "step": 1.0, + "parent_key": "QOLLateral" + } + ] + }, + { + "name": "Visual (Display & UI)", + "icon": "bi-eye", + "params": [ + { + "key": "AdvancedCustomUI", + "label": "Advanced UI Controls", + "description": "Advanced visual changes to fine-tune how the driving screen looks.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "HideSpeed", + "label": "Hide Current Speed", + "description": "Hide the current speed from the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "HideLeadMarker", + "label": "Hide Lead Marker", + "description": "Hide the lead-vehicle marker from the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "HideMapIcon", + "label": "Hide Map Settings Button", + "description": "Hide the map settings button or map from the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "HideMaxSpeed", + "label": "Hide Max Speed", + "description": "Hide the max speed from the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "HideAlerts", + "label": "Hide Non-Critical Alerts", + "description": "Hide non-critical alerts from the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "HideSpeedLimit", + "label": "Hide Speed Limits", + "description": "Hide posted speed limits from the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "WheelSpeed", + "label": "Use Wheel Speed", + "description": "Use the vehicle's wheel speed instead of the cluster speed. This is purely a visual change and doesn't impact how openpilot drives!", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "AdvancedCustomUI" + }, + { + "key": "DeveloperUI", + "label": "Developer UI", + "description": "Detailed information about openpilot's internal operations.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "AdjacentPathMetrics", + "label": "Adjacent Path Metrics", + "description": "Show the width of the adjacent lanes.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperMetrics" + }, + { + "key": "DeveloperMetrics", + "label": "Developer Metrics", + "description": "Performance data, sensor readings, and system metrics for debugging and optimizing openpilot.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperUI", + "is_parent_toggle": true + }, + { + "key": "BorderMetrics", + "label": "Border Metrics", + "description": "Show statuses along the border of the driving screen.\n\nBlind Spot: The border turns red when a vehicle is in a blind spot\nSteering Torque: The border goes from green to red according to how much steering torque is being used\nTurn Signal: The border flashes yellow when a turn signal is on", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperMetrics" + }, + { + "key": "LeadInfo", + "label": "Lead Info", + "description": "Show each tracked vehicle's distance and speed below its marker.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperMetrics" + }, + { + "key": "FPSCounter", + "label": "FPS Display", + "description": "Show the frames per second (FPS) at the bottom of the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperMetrics" + }, + { + "key": "NumericalTemp", + "label": "Numerical Temperature Gauge", + "description": "Show a numerical temperature in the sidebar instead of the status labels.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperMetrics" + }, + { + "key": "UseSI", + "label": "Use International System of Units", + "description": "Display measurements using the \"International System of Units\" (SI) standard.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperMetrics" + }, + { + "key": "DeveloperSidebar", + "label": "Developer Sidebar", + "description": "Display debugging info and metrics in a dedicated sidebar on the right side of the screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperUI", + "is_parent_toggle": true + }, + { + "key": "DeveloperSidebarMetric1", + "label": "Metric #1", + "description": "Select the metric shown in the first \"Developer Sidebar\" widget.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperSidebarMetric2", + "label": "Metric #2", + "description": "Select the metric shown in the second \"Developer Sidebar\" widget.", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperSidebarMetric3", + "label": "Metric #3", + "description": "Select the metric shown in the third \"Developer Sidebar\" widget.", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperSidebarMetric4", + "label": "Metric #4", + "description": "Select the metric shown in the fourth \"Developer Sidebar\" widget.", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperSidebarMetric5", + "label": "Metric #5", + "description": "Select the metric shown in the fifth \"Developer Sidebar\" widget.", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperSidebarMetric6", + "label": "Metric #6", + "description": "Select the metric shown in the sixth \"Developer Sidebar\" widget.", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperSidebarMetric7", + "label": "Metric #7", + "description": "Select the metric shown in the seventh \"Developer Sidebar\" widget.", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "DeveloperSidebar" + }, + { + "key": "DeveloperWidgets", + "label": "Developer Widgets", + "description": "Overlays for debugging visuals, internal states, and model predictions on the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperUI", + "is_parent_toggle": true + }, + { + "key": "AdjacentLeadsUI", + "label": "Adjacent Leads Tracking", + "description": "Display adjacent leads detected by the car's radar to the left and right of the current driving path.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperWidgets" + }, + { + "key": "ShowStoppingPoint", + "label": "Model Stopping Point", + "description": "Show a stop-sign marker where the model intends to stop.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperWidgets" + }, + { + "key": "RadarTracksUI", + "label": "Radar Tracks", + "description": "Display all radar points produced by the car's radar.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeveloperWidgets" + }, + { + "key": "CustomUI", + "label": "Driving Screen Widgets", + "description": "Custom FrogPilot widgets for the driving screen.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "AccelerationPath", + "label": "Acceleration Path", + "description": "Color the driving path by planned acceleration and braking.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "AdjacentPath", + "label": "Adjacent Lanes", + "description": "Show the driving paths for the left and right lanes.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "BlindSpotPath", + "label": "Blind Spot Path", + "description": "Show a red path when a vehicle is in that lane's blind spot.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "Compass", + "label": "Compass", + "description": "Show the current driving direction with a simple on-screen compass.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "OnroadDistanceButton", + "label": "Driving Personality Button", + "description": "Control and view the current driving personality via a driving screen widget.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "PedalsOnUI", + "label": "Gas / Brake Pedal Indicators", + "description": "On-screen gas and brake indicators.\n\nDynamic: Opacity changes according to how much openpilot is accelerating or braking\nStatic: Full when active, dim when not", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "RotatingWheel", + "label": "Rotating Steering Wheel", + "description": "Rotate the driving screen wheel with the physical steering wheel.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomUI" + }, + { + "key": "ModelUI", + "label": "Model UI", + "description": "Model visualizations for the driving path, lane lines, path edges, and road edges.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "DynamicPathWidth", + "label": "Dynamic Path Width", + "description": "Change the path width based on engagement.\n\nFully Engaged: 100%\nAlways On Lateral: 75%\nDisengaged: 50%", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "ModelUI" + }, + { + "key": "LaneLinesWidth", + "label": "Lane Lines Width", + "description": "Set the lane-line thickness.\n\nDefault matches the MUTCD lane-line width standard of 4 inches.", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 24.0, + "parent_key": "ModelUI" + }, + { + "key": "PathEdgeWidth", + "label": "Path Edges Width", + "description": "Set the driving-path edge width that represents different driving modes and statuses.\n\nDefault is 20% of the total path width.\n\nColor Guide:\n\n- Blue: Navigation\n- Light Blue: Always On Lateral\n- Green: Default\n- Orange: Experimental Mode\n- Red: Traffic Mode\n- Yellow: Conditional Experimental Mode overridden", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 100.0, + "parent_key": "ModelUI" + }, + { + "key": "PathWidth", + "label": "Path Width", + "description": "Set the driving-path width.\n\nDefault (6.1 feet) matches the width of a 2019 Lexus ES 350.", + "data_type": "float", + "ui_type": "numeric", + "min": 0.0, + "max": 10.0, + "step": 0.1, + "parent_key": "ModelUI" + }, + { + "key": "RoadEdgesWidth", + "label": "Road Edges Width", + "description": "Set the road-edge thickness.\n\nDefault matches half of the MUTCD lane-line width standard of 4 inches.", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 24.0, + "parent_key": "ModelUI" + }, + { + "key": "UnlimitedLength", + "label": "\\\"Unlimited\\\" Road UI", + "description": "Extend the length of the driving path, lane lines, and road edges for as far as the model can see.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "ModelUI" + }, + { + "key": "NavigationUI", + "label": "Navigation Widgets", + "description": "Map style, speed limits, and other navigation widgets.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "BigMap", + "label": "Larger Map Display", + "description": "Increase the map size for easier navigation readings.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "NavigationUI" + }, + { + "key": "MapStyle", + "label": "Map Style", + "description": "Select the map style for \"Navigate on openpilot\" (NOO):\n\nStock openpilot: Default comma.ai style\nFrogPilot: Official FrogPilot map style\nMapbox Streets: Standard street-focused view\nMapbox Outdoors: Emphasizes outdoor and terrain features\nMapbox Light: Minimalist, bright theme\nMapbox Dark: Minimalist, dark theme\nMapbox Navigation Day: Optimized for daytime navigation\nMapbox Navigation Night: Optimized for nighttime navigation\nMapbox Satellite: Satellite imagery only\nMapbox Satellite Streets: Hybrid satellite imagery with street labels\nMapbox Traffic Night: Dark theme emphasizing traffic conditions\nMike's Personalized Style: Customized hybrid satellite view", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "NavigationUI" + }, + { + "key": "RoadNameUI", + "label": "Road Name", + "description": "Display the road name at the bottom of the driving screen using data from \"OpenStreetMap (OSM)\".", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "NavigationUI" + }, + { + "key": "ShowSpeedLimits", + "label": "Show Speed Limits", + "description": "Show speed limits in the top-left corner of the driving screen. Uses data from the car's dashboard (if supported) and \"OpenStreetMap (OSM)\".", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "NavigationUI" + }, + { + "key": "SLCMapboxFiller", + "label": "Show Speed Limits from Mapbox", + "description": "Use Mapbox speed-limit data when no other source is available.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "NavigationUI" + }, + { + "key": "UseVienna", + "label": "Use Vienna-Style Speed Signs", + "description": "Show Vienna-style (EU) speed-limit signs instead of MUTCD (US).", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "NavigationUI" + }, + { + "key": "QOLVisuals", + "label": "Quality of Life", + "description": "Miscellaneous visual changes to fine-tune how the driving screen looks.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "CameraView", + "label": "Camera View", + "description": "Select the active camera view. This is purely a visual change and doesn't impact how openpilot drives!", + "data_type": "int", + "ui_type": "toggle", + "parent_key": "QOLVisuals" + }, + { + "key": "DriverCamera", + "label": "Show Driver Camera When In Reverse", + "description": "Show the driver camera feed when the vehicle is in reverse.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "QOLVisuals" + }, + { + "key": "StoppedTimer", + "label": "Stopped Timer", + "description": "Show a timer when stopped in place of the current speed to indicate how long the vehicle has been stopped.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "QOLVisuals" + } + ] + }, + { + "name": "Sounds & Alerts", + "icon": "bi-volume-up", + "params": [ + { + "key": "AlertVolumeControl", + "label": "Alert Volume Controller", + "description": "Set how loud each type of openpilot alert is to keep routine prompts from becoming distracting.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "DisengageVolume", + "label": "Disengage Volume", + "description": "Set the volume for alerts when openpilot disengages.\n\nExamples include: \"Cruise Fault: Restart the Car\", \"Parking Brake Engaged\", \"Pedal Pressed\".", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "EngageVolume", + "label": "Engage Volume", + "description": "Set the volume for the chime when openpilot engages, such as after pressing the \"RESUME\" or \"SET\" steering wheel buttons.", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "PromptVolume", + "label": "Prompt Volume", + "description": "Set the volume for prompts that need attention.\n\nExamples include: \"Car Detected in Blindspot\", \"Steering Temporarily Unavailable\", \"Turn Exceeds Steering Limit\".", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "PromptDistractedVolume", + "label": "Prompt Distracted Volume", + "description": "Set the volume for prompts when openpilot detects driver distraction or unresponsiveness.\n\nExamples include: \"Pay Attention\", \"Touch Steering Wheel\".", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "RefuseVolume", + "label": "Refuse Volume", + "description": "Set the volume for alerts when openpilot refuses to engage.\n\nExamples include: \"Brake Hold Active\", \"Door Open\", \"Seatbelt Unlatched\".", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "WarningSoftVolume", + "label": "Warning Soft Volume", + "description": "Set the volume for softer warnings about potential risks.\n\nExamples include: \"BRAKE! Risk of Collision\", \"Steering Temporarily Unavailable\".", + "data_type": "int", + "ui_type": "numeric", + "min": 25.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "WarningImmediateVolume", + "label": "Warning Immediate Volume", + "description": "Set the volume for the loudest warnings that require urgent attention.\n\nExamples include: \"DISENGAGE IMMEDIATELY \u2014 Driver Distracted\", \"DISENGAGE IMMEDIATELY \u2014 Driver Unresponsive\".", + "data_type": "int", + "ui_type": "numeric", + "min": 25.0, + "max": 101.0, + "step": 1.0, + "parent_key": "AlertVolumeControl" + }, + { + "key": "CustomAlerts", + "label": "FrogPilot Alerts", + "description": "Optional FrogPilot alerts that highlight driving events in a more noticeable way.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "GoatScream", + "label": "Goat Scream", + "description": "Play the infamous \"Goat Scream\" when the steering controller reaches its limit. Based on the \"Turn Exceeds Steering Limit\" event.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomAlerts" + }, + { + "key": "GreenLightAlert", + "label": "Green Light Alert", + "description": "Play an alert when the model predicts a red light has turned green.\n\nDisclaimer: openpilot does not explicitly detect traffic lights. This alert is based on end-to-end model predictions from camera input and may trigger even when the light has not changed.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomAlerts" + }, + { + "key": "LeadDepartingAlert", + "label": "Lead Departing Alert", + "description": "Play an alert when the lead vehicle departs from a stop.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomAlerts" + }, + { + "key": "LoudBlindspotAlert", + "label": "Loud \\\"Car Detected in Blindspot\\\" Alert", + "description": "Play a louder alert if a vehicle is in the blind spot when attempting to change lanes. Based on the \"Car Detected in Blindspot\" event.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomAlerts" + }, + { + "key": "SpeedLimitChangedAlert", + "label": "Speed Limit Changed Alert", + "description": "Play an alert when the posted speed limit changes.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "CustomAlerts" + } + ] + }, + { + "name": "Device & Data", + "icon": "bi-hdd", + "params": [ + { + "key": "DeviceManagement", + "label": "Device Settings", + "description": "Settings that control how the device runs, powers off, and manages driving data.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "DeviceShutdown", + "label": "Device Shutdown Timer", + "description": "Keep the device on for the set amount of time after a drive before it shuts down automatically.", + "data_type": "int", + "ui_type": "numeric", + "min": 0.0, + "max": 33.0, + "step": 1.0, + "parent_key": "DeviceManagement" + }, + { + "key": "NoLogging", + "label": "Disable Logging", + "description": "WARNING: This will prevent your drives from being recorded and all data will be unobtainable!\n\nPrevent the device from saving driving data.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeviceManagement" + }, + { + "key": "NoUploads", + "label": "Disable Uploads", + "description": "WARNING: This will prevent your drives from being uploaded to comma connect which will impact debugging and official support from comma!\n\nPrevent the device from uploading driving data.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeviceManagement" + }, + { + "key": "HigherBitrate", + "label": "High-Quality Recording", + "description": "Save drive footage in higher video quality.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeviceManagement" + }, + { + "key": "IncreaseThermalLimits", + "label": "Raise Temperature Limits", + "description": "WARNING: Running at higher temperatures may damage your device!\n\nAllow the device to run at higher temperatures before throttling or shutting down. Use only if you understand the risks!", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeviceManagement" + }, + { + "key": "UseKonikServer", + "label": "Use Konik Server", + "description": "Upload driving data to \"connect.konik.ai\" instead of \"connect.comma.ai\".", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "DeviceManagement" + }, + { + "key": "ScreenManagement", + "label": "Screen Settings", + "description": "Settings that control screen brightness, screen recording, and timeout duration.", + "data_type": "bool", + "ui_type": "toggle", + "is_parent_toggle": true + }, + { + "key": "ScreenBrightness", + "label": "Screen Brightness (Offroad)", + "description": "The screen brightness while not driving.", + "data_type": "int", + "ui_type": "numeric", + "step": 1.0, + "parent_key": "ScreenManagement" + }, + { + "key": "ScreenBrightnessOnroad", + "label": "Screen Brightness (Onroad)", + "description": "The screen brightness while driving.", + "data_type": "int", + "ui_type": "numeric", + "step": 1.0, + "parent_key": "ScreenManagement" + }, + { + "key": "ScreenRecorder", + "label": "Screen Recorder", + "description": "Add a button to the driving screen to record the display.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "ScreenManagement" + }, + { + "key": "ScreenTimeout", + "label": "Screen Timeout (Offroad)", + "description": "How long the screen stays on after being tapped while not driving.", + "data_type": "int", + "ui_type": "numeric", + "min": 5.0, + "max": 60.0, + "parent_key": "ScreenManagement" + }, + { + "key": "ScreenTimeoutOnroad", + "label": "Screen Timeout (Onroad)", + "description": "How long the screen stays on after being tapped while driving.", + "data_type": "int", + "ui_type": "numeric", + "min": 5.0, + "max": 60.0, + "parent_key": "ScreenManagement" + }, + { + "key": "StandbyMode", + "label": "Standby Mode", + "description": "Turn the screen off while driving and automatically wake it up for alerts or engagement state changes.", + "data_type": "bool", + "ui_type": "toggle", + "parent_key": "ScreenManagement" + } + ] + }, + { + "name": "Model & Customization", + "icon": "bi-cpu", + "params": [ + { + "key": "AutomaticallyDownloadModels", + "label": "Automatically Download New Models", + "description": "Automatically download new driving models as they become available.", + "data_type": "bool", + "ui_type": "toggle" + }, + { + "key": "ModelRandomizer", + "label": "Model Randomizer", + "description": "Driving models are chosen at random each drive and feedback prompts are used to find the model that best suits your needs.", + "data_type": "bool", + "ui_type": "toggle" + }, + { + "key": "RecoveryPower", + "label": "Recovery Power", + "description": "Adjust the strength of planplus lane recovery corrections (0.5 to 2.0).", + "data_type": "float", + "ui_type": "numeric", + "min": 0.5, + "max": 2.0, + "step": 0.1 + }, + { + "key": "StopDistance", + "label": "Stop Distance", + "description": "Adjust the model's stopping distance in meters (minimum 4 for safety). Most users prefer 6.", + "data_type": "float", + "ui_type": "numeric", + "min": 4.0, + "max": 10.0, + "step": 0.1 + }, + { + "key": "Model", + "label": "Select Driving Model", + "description": "Select the active driving model.", + "data_type": "string", + "ui_type": "dropdown", + "options_endpoint": "/api/models/installed" + } + ] + } +] \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/tools/theme_maker.css b/frogpilot/system/the_pond/assets/components/tools/theme_maker.css index 2f0efa6c8..7048f3233 100644 --- a/frogpilot/system/the_pond/assets/components/tools/theme_maker.css +++ b/frogpilot/system/the_pond/assets/components/tools/theme_maker.css @@ -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); } -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/components/tools/tmux.js b/frogpilot/system/the_pond/assets/components/tools/tmux.js index a440bdd5e..6f3f219d7 100644 --- a/frogpilot/system/the_pond/assets/components/tools/tmux.js +++ b/frogpilot/system/the_pond/assets/components/tools/tmux.js @@ -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 }) { ${() => { - if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) { - return html`

Loading...

`; - } - if (logSelectorState.files.length === 0) { - return html`

No tmux logs found!

`; - } - return logSelectorState.files.map(file => html` + if (logSelectorState.loading && !logSelectorState.logsLoadedOnce) { + return html`

Loading...

`; + } + if (logSelectorState.files.length === 0) { + return html`

No tmux logs found!

`; + } + return logSelectorState.files.map(file => html`

Filename: ${file.filename}

Date: ${file.date}

Age: ${file.timeSince < 60 ? "just now" : `${formatSecondsToHuman(file.timeSince, "minutes")} ago`}

`); - }} + }} ${() => logSelectorState.logToDelete ? Modal({ - title: "Confirm Delete", - message: `Are you sure you want to delete ${logSelectorState.logToDelete.filename}?`, - onConfirm: confirmDeleteFile, - onCancel: () => { logSelectorState.logToDelete = null }, - confirmText: "Yes, Delete" - }) : ""} + title: "Confirm Delete", + message: `Are you sure you want to delete ${logSelectorState.logToDelete.filename}?`, + onConfirm: confirmDeleteFile, + onCancel: () => { logSelectorState.logToDelete = null }, + confirmText: "Yes, Delete" + }) : ""} ${() => logSelectorState.logToRename ? Modal({ - title: "Rename Log", - message: html` + title: "Rename Log", + message: html`

Rename ${logSelectorState.logToRename.filename} to:

@@ -163,20 +163,30 @@ function TmuxLogSelector({ action, closeFn }) {
`, - 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" + }) : ""} ` } export function TmuxLog() { + if (isGalaxyTunnel()) { + return html` +
+
🛰️
+

Tmux Log Unavailable via Galaxy

+

Live tmux streaming requires a direct connection.
Connect to your device's local network to use this feature.

+
+ `; + } + 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() { ${() => 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" + }) : ""} `; } diff --git a/frogpilot/system/the_pond/assets/components/tools/toggles.js b/frogpilot/system/the_pond/assets/components/tools/toggles.js index e3f8c8dfb..db685061a 100644 --- a/frogpilot/system/the_pond/assets/components/tools/toggles.js +++ b/frogpilot/system/the_pond/assets/components/tools/toggles.js @@ -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 + + ${TailscaleControl()} ${() => 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" + }) : ""} ` } diff --git a/frogpilot/system/the_pond/assets/components/tools/tsk_manager.css b/frogpilot/system/the_pond/assets/components/tools/tsk_manager.css index 5a2f51743..da08a009f 100644 --- a/frogpilot/system/the_pond/assets/components/tools/tsk_manager.css +++ b/frogpilot/system/the_pond/assets/components/tools/tsk_manager.css @@ -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); } -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/assets/images/android-chrome-192x192.png b/frogpilot/system/the_pond/assets/images/android-chrome-192x192.png index 4ad74d653..f0b018fc7 100644 Binary files a/frogpilot/system/the_pond/assets/images/android-chrome-192x192.png and b/frogpilot/system/the_pond/assets/images/android-chrome-192x192.png differ diff --git a/frogpilot/system/the_pond/assets/images/android-chrome-512x512.png b/frogpilot/system/the_pond/assets/images/android-chrome-512x512.png index 820616880..09f3d92d9 100644 Binary files a/frogpilot/system/the_pond/assets/images/android-chrome-512x512.png and b/frogpilot/system/the_pond/assets/images/android-chrome-512x512.png differ diff --git a/frogpilot/system/the_pond/assets/images/favicon-16x16.png b/frogpilot/system/the_pond/assets/images/favicon-16x16.png index a1d047862..7c4dc7ce1 100644 Binary files a/frogpilot/system/the_pond/assets/images/favicon-16x16.png and b/frogpilot/system/the_pond/assets/images/favicon-16x16.png differ diff --git a/frogpilot/system/the_pond/assets/images/favicon-32x32.png b/frogpilot/system/the_pond/assets/images/favicon-32x32.png index 130785c99..07d0e0f98 100644 Binary files a/frogpilot/system/the_pond/assets/images/favicon-32x32.png and b/frogpilot/system/the_pond/assets/images/favicon-32x32.png differ diff --git a/frogpilot/system/the_pond/assets/images/favicon.ico b/frogpilot/system/the_pond/assets/images/favicon.ico index 03380a673..5dda417f8 100644 Binary files a/frogpilot/system/the_pond/assets/images/favicon.ico and b/frogpilot/system/the_pond/assets/images/favicon.ico differ diff --git a/frogpilot/system/the_pond/assets/images/main_logo.png b/frogpilot/system/the_pond/assets/images/main_logo.png index f450d8c56..1cc55ea81 100644 Binary files a/frogpilot/system/the_pond/assets/images/main_logo.png and b/frogpilot/system/the_pond/assets/images/main_logo.png differ diff --git a/frogpilot/system/the_pond/assets/js/utils.js b/frogpilot/system/the_pond/assets/js/utils.js index 43d089153..4584f57f3 100644 --- a/frogpilot/system/the_pond/assets/js/utils.js +++ b/frogpilot/system/the_pond/assets/js/utils.js @@ -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'; +} diff --git a/frogpilot/system/the_pond/assets/manifest.json b/frogpilot/system/the_pond/assets/manifest.json index d9e4031da..7c146c636 100644 --- a/frogpilot/system/the_pond/assets/manifest.json +++ b/frogpilot/system/the_pond/assets/manifest.json @@ -1,5 +1,5 @@ { - "name": "The Pond", + "name": "Galaxy", "short_name": "", "icons": [ { @@ -17,4 +17,4 @@ "background_color": "#151414", "theme_color": "#151414", "display": "standalone" -} +} \ No newline at end of file diff --git a/frogpilot/system/the_pond/templates/index.html b/frogpilot/system/the_pond/templates/index.html index 6321bd41d..d4c5f9035 100644 --- a/frogpilot/system/the_pond/templates/index.html +++ b/frogpilot/system/the_pond/templates/index.html @@ -1,59 +1,62 @@ - - - - - - - - - + + + + + + + + - - - + - - - - - - - - - - - - - - - - - + + + - - + + + + + + + + + + + + + + + + + + - + + - - - + - The Pond - + + + - -
- - - -
- + Galaxy + - + +
+ + + +
+ + + \ No newline at end of file diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index 9b9fe2c30..0cfc294a5 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -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//", methods=["PUT"]) diff --git a/selfdrive/ui/qt/offroad/settings.cc b/selfdrive/ui/qt/offroad/settings.cc index d6466bab4..66fcb1c2a 100644 --- a/selfdrive/ui/qt/offroad/settings.cc +++ b/selfdrive/ui/qt/offroad/settings.cc @@ -5,6 +5,8 @@ #include #include +#include +#include #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); } diff --git a/selfdrive/ui/qt/offroad/settings.h b/selfdrive/ui/qt/offroad/settings.h index 51f9c6f94..a5942adf4 100644 --- a/selfdrive/ui/qt/offroad/settings.h +++ b/selfdrive/ui/qt/offroad/settings.h @@ -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 { diff --git a/system/manager/manager.py b/system/manager/manager.py index dc2f19c8f..d323e3d37 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -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 diff --git a/system/manager/process_config.py b/system/manager/process_config.py index c9348ab39..fe31471d1 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -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), ] diff --git a/tools/StarPilot/derive_feasible_params.py b/tools/StarPilot/derive_feasible_params.py new file mode 100755 index 000000000..a00117be2 --- /dev/null +++ b/tools/StarPilot/derive_feasible_params.py @@ -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 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() diff --git a/tools/StarPilot/feasibleparams.txt b/tools/StarPilot/feasibleparams.txt new file mode 100644 index 000000000..e7abaa24c --- /dev/null +++ b/tools/StarPilot/feasibleparams.txt @@ -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 diff --git a/tools/StarPilot/generate_pond_layout.py b/tools/StarPilot/generate_pond_layout.py new file mode 100755 index 000000000..2c90d149e --- /dev/null +++ b/tools/StarPilot/generate_pond_layout.py @@ -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> \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\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'', '\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\(\)|[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()